import React, { useEffect, useLayoutEffect, useRef, useCallback, useState, useSyncExternalStore } from 'react'; import { createPortal } from 'react-dom'; import { useContextMenuStore, filterMenuItems, type ContextMenuItem, type ContextMenuCheckbox, type ContextMenuLeafItem, type ContextMenuSubmenu, } from '../../stores/contextMenuStore'; import { useUIStore } from '../../stores/uiStore'; // ── Desktop item button ────────────────────────────────────────────────────── const ITEM_CLASS = 'w-full text-left px-2 py-1.5 mx-1.5 text-sm rounded-sm flex items-center gap-2 text-txt-secondary hover:bg-accent-primary hover:text-white'; const ITEM_DANGER_CLASS = 'w-full text-left px-2 py-1.5 mx-1.5 text-sm rounded-sm flex items-center gap-2 text-txt-danger hover:bg-accent-rose hover:text-white'; const ITEM_DISABLED_CLASS = 'w-full text-left px-2 py-1.5 mx-1.5 text-sm rounded-sm flex items-center gap-2 text-txt-secondary opacity-50 cursor-default'; const ITEM_STYLE: React.CSSProperties = { width: 'calc(100% - 12px)' }; // ── Mobile item button ─────────────────────────────────────────────────────── const MOBILE_ITEM_CLASS = 'w-full text-left px-5 py-3 text-sm flex items-center gap-3'; // ── Checkbox indicator ─────────────────────────────────────────────────────── function CheckboxIndicator({ checked }: { checked: boolean }) { return (
{checked && ( )}
); } // ── Reactive checkbox wrapper ─────────────────────────────────────────────── interface ReactiveCheckboxItemProps { item: ContextMenuCheckbox; isMobile?: boolean; } function ReactiveCheckboxItem({ item, isMobile }: ReactiveCheckboxItemProps) { const checked = useSyncExternalStore(item.subscribe, item.getChecked); const className = isMobile ? `${MOBILE_ITEM_CLASS} text-txt-primary` : ITEM_CLASS; return ( ); } // ── Desktop submenu flyout ─────────────────────────────────────────────────── interface SubmenuFlyoutProps { submenu: ContextMenuSubmenu; triggerRef: React.RefObject; onMouseEnter: () => void; onMouseLeave: () => void; close: () => void; } function SubmenuFlyout({ submenu, triggerRef, onMouseEnter, onMouseLeave, close }: SubmenuFlyoutProps) { const flyoutRef = useRef(null); const filteredChildren = filterMenuItems(submenu.children); useLayoutEffect(() => { const flyout = flyoutRef.current; const trigger = triggerRef.current; if (!flyout || !trigger) return; const tRect = trigger.getBoundingClientRect(); const fRect = flyout.getBoundingClientRect(); const gap = 4; let left = tRect.right + gap; if (left + fRect.width > window.innerWidth) { left = tRect.left - fRect.width - gap; } if (left < 8) left = 8; let top = tRect.top; if (top + fRect.height > window.innerHeight - 8) { top = window.innerHeight - fRect.height - 8; } if (top < 8) top = 8; flyout.style.left = `${left}px`; flyout.style.top = `${top}px`; }, [triggerRef]); if (filteredChildren.length === 0) return null; return createPortal(
e.stopPropagation()} > {filteredChildren.map((child) => ( ))}
, document.body, ); } // ── Desktop leaf item ──────────────────────────────────────────────────────── interface DesktopLeafItemProps { item: ContextMenuLeafItem; close: () => void; } function DesktopLeafItem({ item, close }: DesktopLeafItemProps) { switch (item.type) { case 'separator': return
; case 'custom': return
{item.render()}
; case 'checkbox': return ; case 'action': { const className = item.disabled ? ITEM_DISABLED_CLASS : item.danger ? ITEM_DANGER_CLASS : ITEM_CLASS; return ( ); } } } // ── Desktop submenu trigger item ───────────────────────────────────────────── interface DesktopSubmenuItemProps { item: ContextMenuSubmenu; close: () => void; } function DesktopSubmenuItem({ item, close }: DesktopSubmenuItemProps) { const triggerRef = useRef(null); const closeTimer = useRef | null>(null); const openTimer = useRef | null>(null); const openSubmenuKey = useContextMenuStore((s) => s.openSubmenuKey); const setOpenSubmenu = useContextMenuStore((s) => s.setOpenSubmenu); const isOpen = openSubmenuKey === item.key; const cancelTimers = useCallback(() => { if (closeTimer.current) { clearTimeout(closeTimer.current); closeTimer.current = null; } if (openTimer.current) { clearTimeout(openTimer.current); openTimer.current = null; } }, []); const handleTriggerEnter = useCallback(() => { cancelTimers(); openTimer.current = setTimeout(() => { setOpenSubmenu(item.key); }, 150); }, [cancelTimers, setOpenSubmenu, item.key]); const handleTriggerLeave = useCallback(() => { cancelTimers(); closeTimer.current = setTimeout(() => { setOpenSubmenu(null); }, 150); }, [cancelTimers, setOpenSubmenu]); const handleFlyoutEnter = useCallback(() => { cancelTimers(); }, [cancelTimers]); const handleFlyoutLeave = useCallback(() => { cancelTimers(); closeTimer.current = setTimeout(() => { setOpenSubmenu(null); }, 150); }, [cancelTimers, setOpenSubmenu]); useEffect(() => { return () => { cancelTimers(); }; }, [cancelTimers]); return ( <> {isOpen && ( )} ); } // ── Desktop menu item dispatcher ───────────────────────────────────────────── interface DesktopMenuItemProps { item: ContextMenuItem; close: () => void; } function DesktopMenuItem({ item, close }: DesktopMenuItemProps) { if (item.type === 'submenu') { return ; } return ; } // ── Desktop menu panel ─────────────────────────────────────────────────────── interface DesktopMenuProps { items: ContextMenuItem[]; position: { x: number; y: number }; close: () => void; closeGuard: boolean; } function DesktopMenu({ items, position, close, closeGuard }: DesktopMenuProps) { const menuRef = useRef(null); // Viewport-aware positioning via direct DOM mutation useLayoutEffect(() => { const el = menuRef.current; if (!el) return; const rect = el.getBoundingClientRect(); let x = position.x; let y = position.y; if (x + rect.width > window.innerWidth - 8) x = window.innerWidth - rect.width - 8; if (y + rect.height > window.innerHeight - 8) y = window.innerHeight - rect.height - 8; if (x < 8) x = 8; if (y < 8) y = 8; el.style.left = `${x}px`; el.style.top = `${y}px`; }, [position]); // Auto-focus on mount for keyboard nav useEffect(() => { menuRef.current?.focus(); }, []); // Dismiss on scroll/resize useEffect(() => { const dismiss = () => close(); window.addEventListener('scroll', dismiss, true); window.addEventListener('resize', dismiss); return () => { window.removeEventListener('scroll', dismiss, true); window.removeEventListener('resize', dismiss); }; }, [close]); // Keyboard navigation const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { const container = menuRef.current; if (!container) return; const focusableSelector = 'button:not([disabled])'; if (e.key === 'Escape') { e.preventDefault(); // Close submenu first if open, otherwise close the whole menu const openSubmenuKey = useContextMenuStore.getState().openSubmenuKey; if (openSubmenuKey) { useContextMenuStore.getState().setOpenSubmenu(null); } else { close(); } return; } if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { e.preventDefault(); const buttons = Array.from(container.querySelectorAll(focusableSelector)); if (buttons.length === 0) return; const currentIndex = buttons.indexOf(document.activeElement as HTMLElement); let nextIndex: number; if (e.key === 'ArrowDown') { nextIndex = currentIndex < 0 ? 0 : (currentIndex + 1) % buttons.length; } else { nextIndex = currentIndex <= 0 ? buttons.length - 1 : currentIndex - 1; } buttons[nextIndex]?.focus(); return; } if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); const focused = document.activeElement; if (focused instanceof HTMLButtonElement && container.contains(focused)) { focused.click(); } return; } if (e.key === 'ArrowRight') { // Open submenu if focused item is a submenu trigger e.preventDefault(); const focused = document.activeElement; if (focused instanceof HTMLButtonElement) { // Simulate mouse enter to open the submenu focused.dispatchEvent(new MouseEvent('mouseenter', { bubbles: true })); } return; } if (e.key === 'ArrowLeft') { e.preventDefault(); const openSubmenuKey = useContextMenuStore.getState().openSubmenuKey; if (openSubmenuKey) { useContextMenuStore.getState().setOpenSubmenu(null); } return; } }, [close], ); return createPortal( <> {/* Backdrop */}
{ if (!closeGuard) { close(); } }} onContextMenu={(e) => { // Prevent browser context menu but do NOT stopPropagation. // This allows right-clicks to reach underlying elements that may // open a new context menu via their own onContextMenu handler. e.preventDefault(); }} /> {/* Menu panel */}
e.stopPropagation()} > {items.map((item) => ( ))}
, document.body, ); } // ── Mobile leaf item ───────────────────────────────────────────────────────── interface MobileLeafItemProps { item: ContextMenuLeafItem; close: () => void; } function MobileLeafItem({ item, close }: MobileLeafItemProps) { switch (item.type) { case 'separator': return
; case 'custom': return
{item.render()}
; case 'checkbox': return ; case 'action': { const colorClass = item.danger ? 'text-txt-danger' : 'text-txt-primary'; return ( ); } } } // ── Mobile bottom sheet menu ───────────────────────────────────────────────── interface MobileMenuProps { items: ContextMenuItem[]; close: () => void; } function MobileMenu({ items, close }: MobileMenuProps) { const [submenuStack, setSubmenuStack] = useState(null); // Dismiss on scroll/resize useEffect(() => { const dismiss = () => close(); window.addEventListener('scroll', dismiss, true); window.addEventListener('resize', dismiss); return () => { window.removeEventListener('scroll', dismiss, true); window.removeEventListener('resize', dismiss); }; }, [close]); // Determine what items to render: root or submenu const currentItems: ReadonlyArray = submenuStack ? filterMenuItems(submenuStack.children) : items; return createPortal( <> {/* Backdrop */}
{/* Bottom sheet */}
{/* Back button for submenu */} {submenuStack && ( )}
{currentItems.map((item) => { if (item.type === 'submenu') { // Render submenu trigger as a button that switches sheet content const colorClass = 'text-txt-primary'; return ( ); } return ; })}
, document.body, ); } // ── Global long-press → contextmenu for mobile ────────────────────────────── /** * Adds document-level touch listeners that detect long-press (500ms hold, * < 10px movement) and dispatch a synthetic `contextmenu` event on the * touched element. This allows ALL existing onContextMenu handlers to work * on touch devices without per-component changes. */ function useGlobalLongPress(isMobile: boolean) { useEffect(() => { if (!isMobile) return; let timer: ReturnType | null = null; let originX = 0; let originY = 0; let fired = false; let activeTarget: EventTarget | null = null; const cancel = () => { if (timer !== null) { clearTimeout(timer); timer = null; } }; const onTouchStart = (e: TouchEvent) => { // NOTE: Do NOT call e.preventDefault() here — it kills the synthesized // click event on mobile (touchstart → touchend → click chain). // Text selection prevention is handled by CSS user-select: none on // [data-context-menu] elements (see globals.css). if (e.touches.length !== 1) { cancel(); return; } const touch = e.touches[0]!; originX = touch.clientX; originY = touch.clientY; fired = false; activeTarget = e.target; timer = setTimeout(() => { timer = null; fired = true; // Clear any native text selection that started during the hold window.getSelection()?.removeAllRanges(); // Dispatch synthetic contextmenu on the original target. // React's event delegation picks it up and fires onContextMenu handlers. const syntheticEvent = new MouseEvent('contextmenu', { bubbles: true, cancelable: true, clientX: originX, clientY: originY, screenX: originX, screenY: originY, }); if (activeTarget) { activeTarget.dispatchEvent(syntheticEvent); } }, 500); }; const onTouchMove = (e: TouchEvent) => { if (timer === null) return; const touch = e.touches[0]; if (!touch) return; if (Math.hypot(touch.clientX - originX, touch.clientY - originY) > 10) cancel(); }; const onTouchEnd = () => { cancel(); if (fired) { const suppressClick = (ev: MouseEvent) => { ev.preventDefault(); ev.stopPropagation(); }; document.addEventListener('click', suppressClick, { capture: true, once: true }); setTimeout(() => { document.removeEventListener('click', suppressClick, { capture: true }); }, 500); fired = false; } }; document.addEventListener('touchstart', onTouchStart, { passive: true }); document.addEventListener('touchmove', onTouchMove, { passive: true }); document.addEventListener('touchend', onTouchEnd, { passive: true }); document.addEventListener('touchcancel', onTouchEnd, { passive: true }); return () => { cancel(); document.removeEventListener('touchstart', onTouchStart); document.removeEventListener('touchmove', onTouchMove); document.removeEventListener('touchend', onTouchEnd); document.removeEventListener('touchcancel', onTouchEnd); }; }, [isMobile]); } // ── Main renderer ──────────────────────────────────────────────────────────── export function ContextMenuRenderer() { const menu = useContextMenuStore((s) => s.menu); const close = useContextMenuStore((s) => s.close); const closeGuard = useContextMenuStore((s) => s.closeGuard); const isMobile = useUIStore((s) => s.isMobile); // Global long-press detection — enables context menus on all touch targets useGlobalLongPress(isMobile); if (!menu) return null; const filteredItems = filterMenuItems(menu.items); if (filteredItems.length === 0) return null; if (isMobile) { return ; } return ( ); }