658 lines
22 KiB
TypeScript
658 lines
22 KiB
TypeScript
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 (
|
|
<div
|
|
className={`w-4 h-4 rounded border flex items-center justify-center transition-colors ${
|
|
checked ? 'bg-accent-primary border-accent-primary' : 'border-txt-tertiary'
|
|
}`}
|
|
>
|
|
{checked && (
|
|
<svg width="10" height="10" viewBox="0 0 24 24" fill="white">
|
|
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z" />
|
|
</svg>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── 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 (
|
|
<button
|
|
className={className}
|
|
style={isMobile ? undefined : ITEM_STYLE}
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
item.onChange(!checked);
|
|
}}
|
|
>
|
|
<span className="flex-1">{item.label}</span>
|
|
<CheckboxIndicator checked={checked} />
|
|
</button>
|
|
);
|
|
}
|
|
|
|
// ── Desktop submenu flyout ───────────────────────────────────────────────────
|
|
|
|
interface SubmenuFlyoutProps {
|
|
submenu: ContextMenuSubmenu;
|
|
triggerRef: React.RefObject<HTMLButtonElement | null>;
|
|
onMouseEnter: () => void;
|
|
onMouseLeave: () => void;
|
|
close: () => void;
|
|
}
|
|
|
|
function SubmenuFlyout({ submenu, triggerRef, onMouseEnter, onMouseLeave, close }: SubmenuFlyoutProps) {
|
|
const flyoutRef = useRef<HTMLDivElement>(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(
|
|
<div
|
|
ref={flyoutRef}
|
|
className="fixed z-[210] glass rounded-md py-1.5 min-w-[160px] overflow-y-auto scrollbar-thin animate-fade-in"
|
|
style={{ left: -9999, top: -9999 }}
|
|
onMouseEnter={onMouseEnter}
|
|
onMouseLeave={onMouseLeave}
|
|
onMouseDown={(e) => e.stopPropagation()}
|
|
>
|
|
{filteredChildren.map((child) => (
|
|
<DesktopLeafItem key={child.key} item={child} close={close} />
|
|
))}
|
|
</div>,
|
|
document.body,
|
|
);
|
|
}
|
|
|
|
// ── Desktop leaf item ────────────────────────────────────────────────────────
|
|
|
|
interface DesktopLeafItemProps {
|
|
item: ContextMenuLeafItem;
|
|
close: () => void;
|
|
}
|
|
|
|
function DesktopLeafItem({ item, close }: DesktopLeafItemProps) {
|
|
switch (item.type) {
|
|
case 'separator':
|
|
return <div className="h-px bg-white/[0.06] my-1 mx-1.5" />;
|
|
|
|
case 'custom':
|
|
return <div>{item.render()}</div>;
|
|
|
|
case 'checkbox':
|
|
return <ReactiveCheckboxItem item={item} />;
|
|
|
|
case 'action': {
|
|
const className = item.disabled
|
|
? ITEM_DISABLED_CLASS
|
|
: item.danger
|
|
? ITEM_DANGER_CLASS
|
|
: ITEM_CLASS;
|
|
return (
|
|
<button
|
|
className={className}
|
|
style={ITEM_STYLE}
|
|
disabled={item.disabled}
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
if (item.disabled) return;
|
|
item.onClick();
|
|
close();
|
|
}}
|
|
>
|
|
{item.icon && <span className="w-4 h-4">{item.icon}</span>}
|
|
{item.label}
|
|
</button>
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Desktop submenu trigger item ─────────────────────────────────────────────
|
|
|
|
interface DesktopSubmenuItemProps {
|
|
item: ContextMenuSubmenu;
|
|
close: () => void;
|
|
}
|
|
|
|
function DesktopSubmenuItem({ item, close }: DesktopSubmenuItemProps) {
|
|
const triggerRef = useRef<HTMLButtonElement>(null);
|
|
const closeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
const openTimer = useRef<ReturnType<typeof setTimeout> | 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 (
|
|
<>
|
|
<button
|
|
ref={triggerRef}
|
|
className={ITEM_CLASS}
|
|
style={ITEM_STYLE}
|
|
onMouseEnter={handleTriggerEnter}
|
|
onMouseLeave={handleTriggerLeave}
|
|
>
|
|
{item.icon && <span className="w-4 h-4">{item.icon}</span>}
|
|
<span className="flex-1">{item.label}</span>
|
|
<span className="text-txt-tertiary text-xs ml-auto">{'\u203A'}</span>
|
|
</button>
|
|
{isOpen && (
|
|
<SubmenuFlyout
|
|
submenu={item}
|
|
triggerRef={triggerRef}
|
|
onMouseEnter={handleFlyoutEnter}
|
|
onMouseLeave={handleFlyoutLeave}
|
|
close={close}
|
|
/>
|
|
)}
|
|
</>
|
|
);
|
|
}
|
|
|
|
// ── Desktop menu item dispatcher ─────────────────────────────────────────────
|
|
|
|
interface DesktopMenuItemProps {
|
|
item: ContextMenuItem;
|
|
close: () => void;
|
|
}
|
|
|
|
function DesktopMenuItem({ item, close }: DesktopMenuItemProps) {
|
|
if (item.type === 'submenu') {
|
|
return <DesktopSubmenuItem item={item} close={close} />;
|
|
}
|
|
return <DesktopLeafItem item={item} close={close} />;
|
|
}
|
|
|
|
// ── 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<HTMLDivElement>(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<HTMLDivElement>) => {
|
|
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<HTMLElement>(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 */}
|
|
<div
|
|
className="fixed inset-0 z-[199]"
|
|
onMouseDown={(e) => {
|
|
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 */}
|
|
<div
|
|
ref={menuRef}
|
|
tabIndex={-1}
|
|
className="fixed z-[200] min-w-[180px] py-1.5 glass rounded-md animate-fade-in max-h-[calc(100vh-16px)] overflow-y-auto scrollbar-thin outline-none"
|
|
style={{ left: position.x, top: position.y }}
|
|
onKeyDown={handleKeyDown}
|
|
onMouseDown={(e) => e.stopPropagation()}
|
|
>
|
|
{items.map((item) => (
|
|
<DesktopMenuItem key={item.key} item={item} close={close} />
|
|
))}
|
|
</div>
|
|
</>,
|
|
document.body,
|
|
);
|
|
}
|
|
|
|
// ── Mobile leaf item ─────────────────────────────────────────────────────────
|
|
|
|
interface MobileLeafItemProps {
|
|
item: ContextMenuLeafItem;
|
|
close: () => void;
|
|
}
|
|
|
|
function MobileLeafItem({ item, close }: MobileLeafItemProps) {
|
|
switch (item.type) {
|
|
case 'separator':
|
|
return <div className="h-px bg-white/[0.06] my-1 mx-1.5" />;
|
|
|
|
case 'custom':
|
|
return <div>{item.render()}</div>;
|
|
|
|
case 'checkbox':
|
|
return <ReactiveCheckboxItem item={item} isMobile />;
|
|
|
|
case 'action': {
|
|
const colorClass = item.danger ? 'text-txt-danger' : 'text-txt-primary';
|
|
return (
|
|
<button
|
|
className={`${MOBILE_ITEM_CLASS} ${colorClass} ${item.disabled ? 'opacity-50' : ''}`}
|
|
disabled={item.disabled}
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
if (item.disabled) return;
|
|
item.onClick();
|
|
close();
|
|
}}
|
|
>
|
|
{item.icon && <span className="w-5 h-5">{item.icon}</span>}
|
|
{item.label}
|
|
</button>
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Mobile bottom sheet menu ─────────────────────────────────────────────────
|
|
|
|
interface MobileMenuProps {
|
|
items: ContextMenuItem[];
|
|
close: () => void;
|
|
}
|
|
|
|
function MobileMenu({ items, close }: MobileMenuProps) {
|
|
const [submenuStack, setSubmenuStack] = useState<ContextMenuSubmenu | null>(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<ContextMenuLeafItem | ContextMenuItem> = submenuStack
|
|
? filterMenuItems(submenuStack.children)
|
|
: items;
|
|
|
|
return createPortal(
|
|
<>
|
|
{/* Backdrop */}
|
|
<div className="fixed inset-0 z-[300] bg-black/50" onClick={close} />
|
|
{/* Bottom sheet */}
|
|
<div
|
|
className="fixed bottom-0 left-0 right-0 z-[301] rounded-t-2xl glass-bubble animate-slide-up-sheet"
|
|
style={{ paddingBottom: 'env(safe-area-inset-bottom)' }}
|
|
>
|
|
<div className="w-10 h-1 bg-txt-tertiary/30 rounded-full mx-auto mt-2 mb-1" />
|
|
{/* Back button for submenu */}
|
|
{submenuStack && (
|
|
<button
|
|
className={`${MOBILE_ITEM_CLASS} text-txt-secondary`}
|
|
onClick={() => setSubmenuStack(null)}
|
|
>
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" className="flex-shrink-0">
|
|
<path d="M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z" />
|
|
</svg>
|
|
<span>Back</span>
|
|
</button>
|
|
)}
|
|
<div className="py-1">
|
|
{currentItems.map((item) => {
|
|
if (item.type === 'submenu') {
|
|
// Render submenu trigger as a button that switches sheet content
|
|
const colorClass = 'text-txt-primary';
|
|
return (
|
|
<button
|
|
key={item.key}
|
|
className={`${MOBILE_ITEM_CLASS} ${colorClass}`}
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
setSubmenuStack(item);
|
|
}}
|
|
>
|
|
{item.icon && <span className="w-5 h-5">{item.icon}</span>}
|
|
<span className="flex-1">{item.label}</span>
|
|
<span className="text-txt-tertiary text-sm">{'\u203A'}</span>
|
|
</button>
|
|
);
|
|
}
|
|
return <MobileLeafItem key={item.key} item={item as ContextMenuLeafItem} close={close} />;
|
|
})}
|
|
</div>
|
|
</div>
|
|
</>,
|
|
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<typeof setTimeout> | 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 <MobileMenu items={filteredItems} close={close} />;
|
|
}
|
|
|
|
return (
|
|
<DesktopMenu
|
|
items={filteredItems}
|
|
position={menu.position}
|
|
close={close}
|
|
closeGuard={closeGuard}
|
|
/>
|
|
);
|
|
}
|