/* Minimal vanilla replacement for jquery-contextmenu. * * Public API matches the legacy `ContextMenuBinder` so swapping in this file * doesn't ripple into call sites — the only signature change is in the `build` * callback, which now receives a native HTMLElement and MouseEvent instead of * a jQuery wrapper and `JQuery.Event`. Callers updated alongside. * * Behaviour preserved: * - Right-click on any element matching `selector` opens the menu at the * pointer position. * - Menu flips to stay inside the viewport when the click is near an edge. * - Clicking outside the menu, pressing Escape, or invoking a menu item * closes the menu and removes its DOM. * - Re-binding the same selector destroys the prior binding first (matches * jquery-contextmenu's idempotent registration). */ export interface ContextMenuItem { key: string; text: string; icon?: 'edit' | 'cut' | 'copy' | 'paste' | 'delete' | 'add'; } export interface ContextMenuBuildResponse { callback: (key: string, options: any) => void; items: ContextMenuItem[]; } export interface ContextMenuArgs { selector: string; build: (trigger: HTMLElement, event: MouseEvent) => ContextMenuBuildResponse; } interface Registration { selector: string; handler: (e: MouseEvent) => void; } const registry = new Map(); let activeMenuCleanup: (() => void) | null = null; const closeActiveMenu = (): void => { if (activeMenuCleanup != null) { activeMenuCleanup(); activeMenuCleanup = null; } }; const iconMarkup = (icon?: ContextMenuItem['icon']): string => { if (icon == null) return ''; // Mirror jquery-contextmenu class names so any project-level CSS overrides // continue to apply (e.g. icon backgrounds defined in shared theme files). return `context-menu-icon context-menu-icon-${icon}`; }; const renderMenu = ( result: ContextMenuBuildResponse, clickX: number, clickY: number, ): void => { closeActiveMenu(); const menu = document.createElement('ul'); menu.className = 'pd-ctx-menu context-menu-list context-menu-root'; menu.setAttribute('role', 'menu'); for (const item of result.items) { const li = document.createElement('li'); li.className = `pd-ctx-menu-item context-menu-item ${iconMarkup(item.icon)}`.trim(); li.setAttribute('role', 'menuitem'); li.setAttribute('data-key', item.key); li.textContent = item.text; li.addEventListener('click', (ev) => { ev.preventDefault(); ev.stopPropagation(); result.callback(item.key, null); closeActiveMenu(); }); menu.appendChild(li); } menu.style.position = 'fixed'; menu.style.zIndex = '10000'; // Place off-screen for initial measurement so we don't flash at the wrong // position before the viewport-aware flip below kicks in. menu.style.top = '-9999px'; menu.style.left = '-9999px'; document.body.appendChild(menu); const rect = menu.getBoundingClientRect(); let x = clickX; let y = clickY; if (x + rect.width > window.innerWidth) { x = Math.max(0, x - rect.width); } if (y + rect.height > window.innerHeight) { y = Math.max(0, y - rect.height); } menu.style.left = `${x}px`; menu.style.top = `${y}px`; const onOutsideMouseDown = (ev: MouseEvent): void => { if (!menu.contains(ev.target as Node)) { closeActiveMenu(); } }; const onKeyDown = (ev: KeyboardEvent): void => { if (ev.key === 'Escape') closeActiveMenu(); }; const onScroll = (): void => closeActiveMenu(); // Defer the outside-mousedown bind so the right-click that opened the menu // (which bubbles to document) doesn't immediately close it. const bind = () => { document.addEventListener('mousedown', onOutsideMouseDown, true); document.addEventListener('keydown', onKeyDown); window.addEventListener('scroll', onScroll, true); }; setTimeout(bind, 0); activeMenuCleanup = () => { document.removeEventListener('mousedown', onOutsideMouseDown, true); document.removeEventListener('keydown', onKeyDown); window.removeEventListener('scroll', onScroll, true); menu.remove(); }; }; export default class NativeContextMenuBinder { static bindMenu(args: ContextMenuArgs): void { // jquery-contextmenu permitted re-binding the same selector; preserve. this.destroyMenu(args.selector); const handler = (e: MouseEvent): void => { const target = e.target as HTMLElement | null; if (target == null) return; const trigger = target.closest(args.selector) as HTMLElement | null; if (trigger == null) return; e.preventDefault(); const result = args.build(trigger, e); if (result == null || result.items.length === 0) return; renderMenu(result, e.clientX, e.clientY); }; document.addEventListener('contextmenu', handler); registry.set(args.selector, { selector: args.selector, handler }); } static destroyMenu(selector: string): void { const reg = registry.get(selector); if (reg == null) return; document.removeEventListener('contextmenu', reg.handler); registry.delete(selector); } }