1+ import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
2+ import ReactDOM from 'react-dom';
3+
4+ export type MenuItem =
5+ | {
6+ label: string;
7+ action: () => void;
8+ icon?: React.FC<{ className?: string }>;
9+ disabled?: boolean;
10+ shortcut?: string;
11+ submenu?: never;
12+ }
13+ | {
14+ label: string;
15+ submenu: MenuItem[];
16+ icon?: React.FC<{ className?: string }>;
17+ disabled?: boolean;
18+ shortcut?: string;
19+ action?: never;
20+ }
21+ | { type: 'separator' };
22+
23+ interface ContextMenuProps {
24+ isOpen: boolean;
25+ position: { x: number; y: number };
26+ items: MenuItem[];
27+ onClose: () => void;
28+ }
29+
30+ const EDGE_MARGIN = 8;
31+
32+ const ContextMenu: React.FC<ContextMenuProps> = ({ isOpen, position, items, onClose }) => {
33+ const menuRef = useRef<HTMLDivElement>(null);
34+ const [openSubmenu, setOpenSubmenu] = useState<string | null>(null);
35+ const [menuStyle, setMenuStyle] = useState<{ top: number; left: number; maxHeight: number; overflowY: React.CSSProperties['overflowY'] }>({
36+ top: position.y,
37+ left: position.x,
38+ maxHeight: 0,
39+ overflowY: 'visible',
40+ });
41+
42+ const recalculatePosition = useCallback(() => {
43+ const menu = menuRef.current;
44+ if (!menu) return;
45+
46+ const { innerWidth, innerHeight } = window;
47+ const rect = menu.getBoundingClientRect();
48+ const maxHeight = Math.max(innerHeight - EDGE_MARGIN * 2, 0);
49+
50+ let left = rect.left;
51+ let top = rect.top;
52+
53+ if (rect.right > innerWidth - EDGE_MARGIN) {
54+ left = Math.max(EDGE_MARGIN, innerWidth - rect.width - EDGE_MARGIN);
55+ }
56+ if (left < EDGE_MARGIN) {
57+ left = EDGE_MARGIN;
58+ }
59+
60+ if (rect.bottom > innerHeight - EDGE_MARGIN) {
61+ top = Math.max(EDGE_MARGIN, innerHeight - rect.height - EDGE_MARGIN);
62+ }
63+ if (top < EDGE_MARGIN) {
64+ top = EDGE_MARGIN;
65+ }
66+
67+ const overflowY: React.CSSProperties['overflowY'] = rect.height > maxHeight ? 'auto' : 'visible';
68+
69+ setMenuStyle((previous) => {
70+ if (
71+ previous.top === top &&
72+ previous.left === left &&
73+ previous.maxHeight === maxHeight &&
74+ previous.overflowY === overflowY
75+ ) {
76+ return previous;
77+ }
78+
79+ return {
80+ top,
81+ left,
82+ maxHeight,
83+ overflowY,
84+ };
85+ });
86+ }, []);
87+
88+ useEffect(() => {
89+ if (!isOpen) return;
90+
91+ setOpenSubmenu(null);
92+
93+ const handleClickOutside = (event: MouseEvent) => {
94+ if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
95+ onClose();
96+ }
97+ };
98+ const handleKeyDown = (event: KeyboardEvent) => {
99+ if (event.key === 'Escape') {
100+ onClose();
101+ }
102+ }
103+
104+ document.addEventListener('mousedown', handleClickOutside);
105+ window.addEventListener('keydown', handleKeyDown);
106+ return () => {
107+ document.removeEventListener('mousedown', handleClickOutside);
108+ window.removeEventListener('keydown', handleKeyDown);
109+ };
110+ }, [isOpen, onClose]);
111+
112+ useLayoutEffect(() => {
113+ if (!isOpen) return;
114+
115+ setMenuStyle((previous) => {
116+ if (previous.top === position.y && previous.left === position.x) {
117+ return previous;
118+ }
119+
120+ return {
121+ top: position.y,
122+ left: position.x,
123+ maxHeight: previous.maxHeight,
124+ overflowY: previous.overflowY,
125+ };
126+ });
127+
128+ const frame = requestAnimationFrame(() => {
129+ recalculatePosition();
130+ });
131+
132+ return () => cancelAnimationFrame(frame);
133+ }, [isOpen, position.x, position.y, recalculatePosition]);
134+
135+ useLayoutEffect(() => {
136+ if (!isOpen) return;
137+
138+ const frame = requestAnimationFrame(() => {
139+ recalculatePosition();
140+ });
141+
142+ return () => cancelAnimationFrame(frame);
143+ }, [isOpen, items, recalculatePosition]);
144+
145+ useEffect(() => {
146+ if (!isOpen) return;
147+
148+ const handleResize = () => {
149+ recalculatePosition();
150+ };
151+
152+ window.addEventListener('resize', handleResize);
153+ return () => {
154+ window.removeEventListener('resize', handleResize);
155+ };
156+ }, [isOpen, recalculatePosition]);
157+
158+ if (!isOpen) return null;
159+
160+ const overlayRoot = document.getElementById('overlay-root');
161+ if (!overlayRoot) return null;
162+
163+ const hasEnabledItem = (menuItems: MenuItem[]): boolean => {
164+ return menuItems.some(item => {
165+ if ('type' in item) {
166+ return false;
167+ }
168+
169+ if ('submenu' in item) {
170+ return !item.disabled && hasEnabledItem(item.submenu);
171+ }
172+
173+ return !item.disabled;
174+ });
175+ };
176+
177+ const renderItems = (menuItems: MenuItem[], depth = 0) => {
178+ return menuItems.map((item, index) => {
179+ if ('type' in item) {
180+ return <li key={`separator-${depth}-${index}`} className="h-px bg-border-color my-1.5" />;
181+ }
182+
183+ if ('submenu' in item) {
184+ const menuKey = `${depth}-${index}`;
185+ const isOpen = openSubmenu === menuKey;
186+ const hasEnabledSubitem = hasEnabledItem(item.submenu);
187+ const Icon = item.icon;
188+
189+ return (
190+ <li
191+ key={`${item.label}-${depth}-${index}`}
192+ className="relative"
193+ onMouseEnter={() => setOpenSubmenu(menuKey)}
194+ onMouseLeave={() => setOpenSubmenu(null)}
195+ >
196+ <button
197+ onClick={() => {
198+ if (!item.disabled && hasEnabledSubitem) {
199+ setOpenSubmenu(menuKey);
200+ }
201+ }}
202+ disabled={item.disabled || !hasEnabledSubitem}
203+ className="w-full flex items-center justify-between text-left px-2 py-1.5 text-xs rounded-md transition-colors text-text-main disabled:text-text-secondary/50 disabled:cursor-not-allowed hover:bg-primary hover:text-primary-text focus:bg-primary focus:text-primary-text focus:outline-none"
204+ >
205+ <div className="flex items-center gap-3">
206+ {Icon && <Icon className="w-4 h-4" />}
207+ <span>{item.label}</span>
208+ </div>
209+ <span className="text-text-secondary">›</span>
210+ </button>
211+ {isOpen && item.submenu.length > 0 && (
212+ <div className="absolute top-0 left-full ml-1 z-10 w-[16.8rem] rounded-md bg-secondary p-1.5 shadow-2xl border border-border-color animate-fade-in-fast">
213+ <ul className="space-y-1">{renderItems(item.submenu, depth + 1)}</ul>
214+ </div>
215+ )}
216+ </li>
217+ );
218+ }
219+
220+ const { label, action, icon: Icon, disabled, shortcut } = item;
221+
222+ return (
223+ <li key={`${label}-${depth}-${index}`}>
224+ <button
225+ onClick={() => {
226+ if (!disabled) {
227+ action();
228+ onClose();
229+ }
230+ }}
231+ disabled={disabled}
232+ className="w-full flex items-center justify-between text-left px-2 py-1.5 text-xs rounded-md transition-colors text-text-main disabled:text-text-secondary/50 disabled:cursor-not-allowed hover:bg-primary hover:text-primary-text focus:bg-primary focus:text-primary-text focus:outline-none"
233+ >
234+ <div className="flex items-center gap-3">
235+ {Icon && <Icon className="w-4 h-4" />}
236+ <span>{label}</span>
237+ </div>
238+ {shortcut && <span className="text-xs text-text-secondary">{shortcut}</span>}
239+ </button>
240+ </li>
241+ );
242+ });
243+ };
244+
245+ return ReactDOM.createPortal(
246+ <div
247+ ref={menuRef}
248+ style={{
249+ top: menuStyle.top,
250+ left: menuStyle.left,
251+ maxHeight: menuStyle.maxHeight ? menuStyle.maxHeight : undefined,
252+ overflowY: menuStyle.overflowY,
253+ }}
254+ className="fixed z-50 w-[16.8rem] rounded-md bg-secondary p-1.5 shadow-2xl border border-border-color animate-fade-in-fast"
255+ >
256+ <ul className="space-y-1">
257+ {renderItems(items)}
258+ </ul>
259+ <style>{`
260+ @keyframes fade-in-fast {
261+ from { opacity: 0; transform: scale(0.95); }
262+ to { opacity: 1; transform: scale(1); }
263+ }
264+ .animate-fade-in-fast {
265+ animation: fade-in-fast 0.1s ease-out forwards;
266+ }
267+ `}</style>
268+ </div>,
269+ overlayRoot
270+ );
271+ };
272+
273+ export default ContextMenu;
0 commit comments