summaryrefslogtreecommitdiff
path: root/frontend/src/components/document/ContextMenu.tsx
blob: 5aed940725d35f9152fdf9d85baf7a7b950c28c0 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
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>
  );
}