// React port of ../../contextMenu.ts (#125), the general-purpose version — // unlike the sidebar's own menu, this one also closes on scroll/resize, since // a menu anchored to coordinates goes stale the moment the page moves under it. import { useEffect, useLayoutEffect, useRef, useState } from "react"; import { createPortal } from "react-dom"; export interface ContextMenuItem { label: string; onSelect(): void; /** Renders the entry in the destructive style. */ danger?: boolean; } export interface ContextMenuProps { x: number; y: number; items: ContextMenuItem[]; onClose(): void; } export function ContextMenu({ x, y, items, onClose }: ContextMenuProps) { const ref = useRef(null); const [pos, setPos] = useState({ left: x, top: y }); // Nudge back inside the viewport once the menu has a measurable size. useLayoutEffect(() => { const rect = ref.current?.getBoundingClientRect(); if (!rect) return; const maxX = window.innerWidth - rect.width; const maxY = window.innerHeight - rect.height; setPos({ left: Math.max(0, Math.min(x, maxX)), top: Math.max(0, Math.min(y, maxY)) }); }, [x, y]); useEffect(() => { const onKeyDown = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); }; const onPointerDown = (e: Event) => { if (!ref.current?.contains(e.target as Node)) onClose(); }; document.addEventListener("keydown", onKeyDown, true); window.addEventListener("pointerdown", onPointerDown, true); window.addEventListener("scroll", onClose, true); window.addEventListener("resize", onClose); return () => { document.removeEventListener("keydown", onKeyDown, true); window.removeEventListener("pointerdown", onPointerDown, true); window.removeEventListener("scroll", onClose, true); window.removeEventListener("resize", onClose); }; }, [onClose]); useEffect(() => { (ref.current?.firstElementChild as HTMLElement | null)?.focus(); }, []); return createPortal(
{items.map((item) => ( ))}
, document.body, ); }