diff options
Diffstat (limited to 'frontend/src/components/document/ContextMenu.tsx')
| -rw-r--r-- | frontend/src/components/document/ContextMenu.tsx | 98 |
1 files changed, 98 insertions, 0 deletions
diff --git a/frontend/src/components/document/ContextMenu.tsx b/frontend/src/components/document/ContextMenu.tsx new file mode 100644 index 0000000..5aed940 --- /dev/null +++ b/frontend/src/components/document/ContextMenu.tsx @@ -0,0 +1,98 @@ +import { useCallback, useEffect, useRef } from 'react'; +import './ContextMenu.css'; + +export interface ContextMenuAction { + label: string; + icon: string; + disabled?: boolean; + onClick: () => void; +} + +export interface ContextMenuProps { + x: number; + y: number; + actions: ContextMenuAction[]; + dividerAfter?: number[]; + onClose: () => void; +} + +export default function ContextMenu({ + x, + y, + actions, + dividerAfter = [], + onClose, +}: ContextMenuProps) { + const menuRef = useRef<HTMLDivElement>(null); + + // Adjust position so menu stays within viewport + const adjustedPosition = useCallback(() => { + const el = menuRef.current; + if (!el) return { left: x, top: y }; + const rect = el.getBoundingClientRect(); + const left = x + rect.width > window.innerWidth ? x - rect.width : x; + const top = y + rect.height > window.innerHeight ? y - rect.height : y; + return { left: Math.max(0, left), top: Math.max(0, top) }; + }, [x, y]); + + // Close on click outside + useEffect(() => { + const handler = (e: MouseEvent) => { + if (menuRef.current && !menuRef.current.contains(e.target as Node)) { + onClose(); + } + }; + // Use capture so we catch clicks before any other handler + document.addEventListener('mousedown', handler, true); + return () => document.removeEventListener('mousedown', handler, true); + }, [onClose]); + + // Close on Escape + useEffect(() => { + const handler = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose(); + }; + document.addEventListener('keydown', handler); + return () => document.removeEventListener('keydown', handler); + }, [onClose]); + + // After mount, adjust position + useEffect(() => { + const el = menuRef.current; + if (!el) return; + const pos = adjustedPosition(); + el.style.left = `${pos.left}px`; + el.style.top = `${pos.top}px`; + }, [adjustedPosition]); + + const dividerSet = new Set(dividerAfter); + + return ( + <div + ref={menuRef} + className="ctx-menu" + style={{ left: x, top: y }} + role="menu" + > + {actions.map((action, i) => ( + <div key={i}> + <button + className={`ctx-menu-item ${action.disabled ? 'ctx-menu-item-disabled' : ''}`} + role="menuitem" + disabled={action.disabled} + onClick={() => { + if (!action.disabled) { + action.onClick(); + onClose(); + } + }} + > + <span className="ctx-menu-icon">{action.icon}</span> + <span className="ctx-menu-label">{action.label}</span> + </button> + {dividerSet.has(i) && <div className="ctx-menu-divider" />} + </div> + ))} + </div> + ); +} |
