import { Component, ElementRef, computed, effect, inject, signal, viewChild, } from '@angular/core'; import { NgIcon } from '@ng-icons/core'; import { ContextMenuService, type MenuItem } from '@core/context-menu.service'; import { FocusService } from '@core/focus.service'; import { stepIndex } from '@core/menu-nav'; import { remPx } from '@core/metrics'; /** Rough per-item metrics in REM, used only to decide whether the menu must * flip. Converted through remPx() at open time so the math tracks the same * root font-size the CSS scales with — the old px constants (`MENU_W = 224`) * were 16px-root mirrors of rem classes, so the box grew with the interface * scale while the flip math still believed 224 and fired at the wrong edge. */ const ITEM_H_REM = 1.625; // px-3 py-1 row + --path-font line ≈ 26px @16 const SEPARATOR_H_REM = 0.4375; // my-1 + 1px border ≈ 7px @16 const MENU_W_REM = 14; // w-56 const EDGE_GAP = 6; // px on purpose: a viewport-edge margin, not a UI size /** * The panel right-click menu. Replaces electrobun's native ContextMenu, which * is a no-op on Linux — see ContextMenuService for the why. Emits the same * `ctx-*` / `col:*` action strings the native menu did. */ @Component({ selector: 'app-context-menu', imports: [NgIcon], template: ` @if (menu.request(); as req) {
} `, // Closing on scroll/blur matches native menu behavior: the anchor row moves // out from under a menu that would otherwise float over stale content. host: { '(window:blur)': 'close()', '(window:resize)': 'close()', '(document:scroll)': 'close()', }, }) export class ContextMenu { readonly menu = inject(ContextMenuService); private readonly focus = inject(FocusService); private readonly menuEl = viewChild>('menuEl'); readonly activeIndex = signal(-1); /** Click point, flipped/clamped so the menu never overflows the viewport. * All UI metrics convert through the live root font-size; window.inner* is * viewport CSS px, so both sides of every comparison share units. */ readonly position = computed(() => { const req = this.menu.request(); if (!req) return { x: 0, y: 0 }; const itemH = remPx(ITEM_H_REM); const separatorH = remPx(SEPARATOR_H_REM); const menuW = remPx(MENU_W_REM); const height = req.items.reduce((sum, i) => sum + (i.type === 'separator' ? separatorH : itemH), 0) + remPx(0.5); // the menu's py-1 (0.25rem x2) const x = req.x + menuW > window.innerWidth - EDGE_GAP ? Math.max(EDGE_GAP, req.x - menuW) : req.x; const y = req.y + height > window.innerHeight - EDGE_GAP ? Math.max(EDGE_GAP, req.y - height) : req.y; return { x, y }; }); constructor() { // Focus the menu once it exists so arrow keys / Escape reach it. Keyed on // the viewChild signal, NOT on request(): under zoneless CD an effect can // run before the @if has rendered the element. effect(() => this.menuEl()?.nativeElement.focus()); } close(): void { if (!this.menu.request()) return; this.menu.close(); this.activeIndex.set(-1); // a fresh menu starts with nothing highlighted this.focus.bump(); } pick(item: MenuItem): void { // the keyboard path never consults the disabled attribute — guard here if (item.disabled) return; if (!item.action) return; const action = item.action; this.close(); this.menu.onPick(action); } /** Right-clicking the backdrop closes rather than stacking a second menu. */ onBackdropContext(event: MouseEvent): void { event.preventDefault(); this.close(); } onKeydown(event: KeyboardEvent): void { const items = this.menu.request()?.items ?? []; switch (event.key) { case 'Escape': event.preventDefault(); event.stopPropagation(); this.close(); break; case 'ArrowDown': event.preventDefault(); this.step(items, 1); break; case 'ArrowUp': event.preventDefault(); this.step(items, -1); break; case 'Enter': case ' ': { event.preventDefault(); const item = items[this.activeIndex()]; if (item) this.pick(item); break; } } } /** Move the highlight by one selectable item (see stepIndex). */ private step(items: MenuItem[], delta: 1 | -1): void { const selectable = items.map((i) => i.type !== 'separator' && !i.disabled); this.activeIndex.set(stepIndex(selectable, this.activeIndex(), delta)); } }