Fix bugs and clean noise reduction

This commit is contained in:
2026-03-06 02:22:43 +01:00
parent 0ed9ca93d3
commit 2d84fbd91a
39 changed files with 3443 additions and 1544 deletions

View File

@@ -0,0 +1,22 @@
<!-- Invisible backdrop that captures clicks outside -->
<div
class="fixed inset-0 z-40"
(click)="closed.emit(undefined)"
(contextmenu)="$event.preventDefault(); closed.emit(undefined)"
(keydown.enter)="closed.emit(undefined)"
(keydown.space)="closed.emit(undefined)"
role="button"
tabindex="0"
aria-label="Close menu"
></div>
<!-- Positioned menu panel -->
<div
#panel
class="fixed z-50 bg-card border border-border rounded-lg shadow-lg py-1"
[class]="widthPx() ? '' : width()"
[style.left.px]="clampedX()"
[style.top.px]="clampedY()"
[style.width.px]="widthPx() || null"
>
<ng-content />
</div>

View File

@@ -0,0 +1,28 @@
:host {
display: contents;
}
/* Convenience classes consumers can use on projected buttons */
:host ::ng-deep .context-menu-item {
@apply w-full text-left px-3 py-1.5 text-sm hover:bg-secondary transition-colors text-foreground;
}
:host ::ng-deep .context-menu-item-danger {
@apply w-full text-left px-3 py-1.5 text-sm hover:bg-secondary transition-colors text-destructive;
}
:host ::ng-deep .context-menu-item-icon {
@apply w-full text-left px-3 py-2 text-sm hover:bg-secondary transition-colors text-foreground flex items-center gap-2;
}
:host ::ng-deep .context-menu-item-icon-danger {
@apply w-full text-left px-3 py-2 text-sm hover:bg-destructive/10 transition-colors text-destructive flex items-center gap-2;
}
:host ::ng-deep .context-menu-divider {
@apply border-t border-border my-1;
}
:host ::ng-deep .context-menu-empty {
@apply px-3 py-1.5 text-sm text-muted-foreground;
}

View File

@@ -2,11 +2,16 @@ import {
Component,
input,
output,
HostListener
signal,
HostListener,
ViewChild,
ElementRef,
AfterViewInit,
OnInit
} from '@angular/core';
/**
* Generic positioned context-menu overlay.
* Generic positioned context-menu overlay with automatic viewport clamping.
*
* Usage:
* ```html
@@ -17,6 +22,13 @@ import {
* }
* ```
*
* For pixel-based widths (e.g. sliders), use `[widthPx]` instead of `[width]`:
* ```html
* <app-context-menu [x]="menuX()" [y]="menuY()" [widthPx]="240" (closed)="closeMenu()">
* ...custom content...
* </app-context-menu>
* ```
*
* Built-in item classes are available via the host styles:
* - `.context-menu-item` - normal item
* - `.context-menu-item-danger` - destructive (red) item
@@ -25,68 +37,73 @@ import {
@Component({
selector: 'app-context-menu',
standalone: true,
template: `
<!-- Invisible backdrop that captures clicks outside -->
<div
class="fixed inset-0 z-40"
(click)="closed.emit(undefined)"
(keydown.enter)="closed.emit(undefined)"
(keydown.space)="closed.emit(undefined)"
role="button"
tabindex="0"
aria-label="Close menu"
></div>
<!-- Positioned menu panel -->
<div
class="fixed z-50 bg-card border border-border rounded-lg shadow-lg py-1"
[class]="width()"
[style.left.px]="x()"
[style.top.px]="y()"
>
<ng-content />
</div>
`,
styles: [
`
:host {
display: contents;
}
/* Convenience classes consumers can use on projected buttons */
:host ::ng-deep .context-menu-item {
@apply w-full text-left px-3 py-1.5 text-sm hover:bg-secondary transition-colors text-foreground;
}
:host ::ng-deep .context-menu-item-danger {
@apply w-full text-left px-3 py-1.5 text-sm hover:bg-secondary transition-colors text-destructive;
}
:host ::ng-deep .context-menu-item-icon {
@apply w-full text-left px-3 py-2 text-sm hover:bg-secondary transition-colors text-foreground flex items-center gap-2;
}
:host ::ng-deep .context-menu-item-icon-danger {
@apply w-full text-left px-3 py-2 text-sm hover:bg-destructive/10 transition-colors text-destructive flex items-center gap-2;
}
:host ::ng-deep .context-menu-divider {
@apply border-t border-border my-1;
}
:host ::ng-deep .context-menu-empty {
@apply px-3 py-1.5 text-sm text-muted-foreground;
}
`
]
templateUrl: './context-menu.component.html',
styleUrl: './context-menu.component.scss'
})
export class ContextMenuComponent {
/* eslint-disable @typescript-eslint/member-ordering */
export class ContextMenuComponent implements OnInit, AfterViewInit {
/** Horizontal position (px from left). */
// eslint-disable-next-line id-length, id-denylist
x = input.required<number>();
/** Vertical position (px from top). */
// eslint-disable-next-line id-length, id-denylist
y = input.required<number>();
/** Tailwind width class for the panel (default `w-48`). */
/** Tailwind width class for the panel (default `w-48`). Ignored when `widthPx` is set. */
width = input<string>('w-48');
/** Optional fixed width in pixels (overrides `width`). Useful for custom content like sliders. */
widthPx = input<number | null>(null);
/** Emitted when the menu should close (backdrop click or Escape). */
closed = output<undefined>();
@ViewChild('panel', { static: true }) panelRef!: ElementRef<HTMLDivElement>;
/** Viewport-clamped X position. */
clampedX = signal(0);
/** Viewport-clamped Y position. */
clampedY = signal(0);
ngOnInit(): void {
// Initial clamp with estimated dimensions
this.clampedX.set(this.clampX(this.x(), this.estimateWidth()));
this.clampedY.set(this.clampY(this.y(), 80));
}
ngAfterViewInit(): void {
// Refine with actual rendered dimensions
const rect = this.panelRef.nativeElement.getBoundingClientRect();
this.clampedX.set(this.clampX(this.x(), rect.width));
this.clampedY.set(this.clampY(this.y(), rect.height));
}
@HostListener('document:keydown.escape')
onEscape(): void {
this.closed.emit(undefined);
}
private estimateWidth(): number {
const px = this.widthPx();
if (px)
return px;
// Parse Tailwind w-XX class to approximate pixel width
const match = this.width().match(/w-(\d+)/);
return match ? parseInt(match[1], 10) * 4 : 192;
}
private clampX(rawX: number, panelWidth: number): number {
const margin = 8;
const maxX = window.innerWidth - panelWidth - margin;
return Math.max(margin, Math.min(rawX, maxX));
}
private clampY(rawY: number, panelHeight: number): number {
const margin = 8;
const maxY = window.innerHeight - panelHeight - margin;
return Math.max(margin, Math.min(rawY, maxY));
}
}