返回 DeepSeek-Reasonix
ContextMenu.tsx
根目录 / desktop / frontend / src / components / ContextMenu.tsx
1 import { useEffect, useLayoutEffect, useRef, useState } from "react";
2 import type { KeyboardEvent as ReactKeyboardEvent, MouseEvent as ReactMouseEvent, ReactNode } from "react";
3 import { createPortal } from "react-dom";
4
5 export type ContextMenuPoint = {
6 left: number;
7 top: number;
8 keyboardTarget?: HTMLElement;
9 };
10
11 export type ContextMenuItem =
12 | {
13 type?: "item";
14 key: string;
15 icon?: ReactNode;
16 label: ReactNode;
17 shortcut?: string;
18 disabled?: boolean;
19 danger?: boolean;
20 variant?: "section";
21 onSelect: () => void;
22 }
23 | {
24 type: "separator";
25 key: string;
26 };
27
28 const EDGE_GAP = 8;
29
30 function clampMenuPoint(left: number, top: number, width: number, height: number): ContextMenuPoint {
31 if (typeof window === "undefined") return { left, top };
32 return {
33 left: Math.min(Math.max(EDGE_GAP, left), Math.max(EDGE_GAP, window.innerWidth - width - EDGE_GAP)),
34 top: Math.min(Math.max(EDGE_GAP, top), Math.max(EDGE_GAP, window.innerHeight - height - EDGE_GAP)),
35 };
36 }
37
38 export function contextMenuPointFromEvent(
39 event: ReactMouseEvent<HTMLElement> | ReactKeyboardEvent<HTMLElement>,
40 ): ContextMenuPoint {
41 if ("clientX" in event && event.clientX > 0 && event.clientY > 0) {
42 return { left: event.clientX, top: event.clientY };
43 }
44 const rect = event.currentTarget.getBoundingClientRect();
45 return {
46 left: rect.left + 12,
47 top: rect.bottom + 6,
48 ...("key" in event ? { keyboardTarget: event.currentTarget } : {}),
49 };
50 }
51
52 export function ContextMenu({
53 open,
54 point,
55 items,
56 onClose,
57 className,
58 minWidth = 180,
59 ariaLabel = "Context menu",
60 }: {
61 open: boolean;
62 point: ContextMenuPoint | null;
63 items: ContextMenuItem[];
64 onClose: () => void;
65 className?: string;
66 minWidth?: number;
67 ariaLabel?: string;
68 }) {
69 const menuRef = useRef<HTMLDivElement>(null);
70 const [position, setPosition] = useState<ContextMenuPoint | null>(point);
71
72 useLayoutEffect(() => {
73 if (!open || !point) return;
74 const rect = menuRef.current?.getBoundingClientRect();
75 if (!rect) {
76 setPosition(point);
77 return;
78 }
79 setPosition(clampMenuPoint(point.left, point.top, rect.width, rect.height));
80 }, [open, point, items]);
81
82 useLayoutEffect(() => {
83 const target = point?.keyboardTarget;
84 const menu = menuRef.current;
85 if (!open || !target || !menu) return;
86 menu.querySelector<HTMLButtonElement>('button[role="menuitem"]:not(:disabled)')?.focus({ preventScroll: true });
87 return () => {
88 // Do not steal focus from an outside click or an action opening a dialog.
89 if (target.isConnected && (menu.contains(document.activeElement) || document.activeElement === document.body)) {
90 target.focus({ preventScroll: true });
91 }
92 };
93 }, [open, point]);
94
95 const onMenuKeyDown = (event: ReactKeyboardEvent<HTMLDivElement>) => {
96 const buttons = Array.from(event.currentTarget.querySelectorAll<HTMLButtonElement>('button[role="menuitem"]:not(:disabled)'));
97 const index = buttons.indexOf(document.activeElement as HTMLButtonElement);
98 let next: number;
99 switch (event.key) {
100 case "ArrowDown": next = (index + 1) % buttons.length; break;
101 case "ArrowUp": next = index < 0 ? buttons.length - 1 : (index - 1 + buttons.length) % buttons.length; break;
102 case "Home": next = 0; break;
103 case "End": next = buttons.length - 1; break;
104 case "Tab":
105 // Let the browser continue from the invoking control in tab order.
106 point?.keyboardTarget?.focus({ preventScroll: true });
107 onClose();
108 event.stopPropagation();
109 return;
110 case "Escape":
111 event.preventDefault();
112 event.stopPropagation();
113 onClose();
114 return;
115 default: return;
116 }
117 event.preventDefault();
118 event.stopPropagation();
119 buttons[next]?.focus({ preventScroll: true });
120 };
121
122 useEffect(() => {
123 if (!open) return;
124 const closeOnOutsidePointerDown = (event: PointerEvent) => {
125 const target = event.target;
126 if (target instanceof Node && menuRef.current?.contains(target)) return;
127 onClose();
128 };
129 const close = () => onClose();
130 const closeOnEscape = (event: KeyboardEvent) => {
131 if (event.key === "Escape") onClose();
132 };
133 window.addEventListener("pointerdown", closeOnOutsidePointerDown, true);
134 window.addEventListener("resize", close);
135 window.addEventListener("keydown", closeOnEscape);
136 return () => {
137 window.removeEventListener("pointerdown", closeOnOutsidePointerDown, true);
138 window.removeEventListener("resize", close);
139 window.removeEventListener("keydown", closeOnEscape);
140 };
141 }, [open, onClose]);
142
143 if (!open || !point) return null;
144
145 return createPortal(
146 <div
147 ref={menuRef}
148 data-app-overlay=""
149 className={`context-menu${className ? ` ${className}` : ""}`}
150 role="menu"
151 aria-label={ariaLabel}
152 style={{ left: (position ?? point).left, top: (position ?? point).top, minWidth }}
153 onKeyDown={onMenuKeyDown}
154 onMouseDown={(event) => {
155 event.preventDefault();
156 event.stopPropagation();
157 }}
158 onClick={(event) => event.stopPropagation()}
159 onContextMenu={(event) => {
160 event.preventDefault();
161 event.stopPropagation();
162 }}
163 >
164 {items.map((item) => {
165 if (item.type === "separator") {
166 return <div key={item.key} className="context-menu__separator" role="separator" />;
167 }
168 return (
169 <button
170 key={item.key}
171 type="button"
172 role="menuitem"
173 disabled={item.disabled}
174 className={`context-menu__item${item.danger ? " context-menu__item--danger" : ""}${item.variant ? ` context-menu__item--${item.variant}` : ""}`}
175 onClick={(event) => {
176 event.stopPropagation();
177 if (!item.disabled) item.onSelect();
178 }}
179 >
180 {item.icon}
181 <span>{item.label}</span>
182 {item.shortcut && <span className="context-menu__shortcut">{item.shortcut}</span>}
183 </button>
184 );
185 })}
186 </div>,
187 document.body,
188 );
189 }
190
190 lines Plain Text