summaryrefslogtreecommitdiff
path: root/frontend/src/components/document/ContextMenu.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'frontend/src/components/document/ContextMenu.tsx')
-rw-r--r--frontend/src/components/document/ContextMenu.tsx98
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>
+ );
+}