返回 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 = { left: number; top: number };
6
7 export type ContextMenuItem =
8 | {
9 type?: "item";
10 key: string;
11 icon?: ReactNode;
12 label: ReactNode;
13 shortcut?: string;
14 disabled?: boolean;
15 danger?: boolean;
16 variant?: "section";
17 onSelect: () => void;
18 }
19 | {
20 type: "separator";
21 key: string;
22 };
23
24 const EDGE_GAP = 8;
25
26 function clampMenuPoint(left: number, top: number, width: number, height: number): ContextMenuPoint {
27 if (typeof window === "undefined") return { left, top };
28 return {
29 left: Math.min(Math.max(EDGE_GAP, left), Math.max(EDGE_GAP, window.innerWidth - width - EDGE_GAP)),
30 top: Math.min(Math.max(EDGE_GAP, top), Math.max(EDGE_GAP, window.innerHeight - height - EDGE_GAP)),
31 };
32 }
33
34 export function contextMenuPointFromEvent(
35 event: ReactMouseEvent<HTMLElement> | ReactKeyboardEvent<HTMLElement>,
36 ): ContextMenuPoint {
37 if ("clientX" in event && event.clientX > 0 && event.clientY > 0) {
38 return { left: event.clientX, top: event.clientY };
39 }
40 const rect = event.currentTarget.getBoundingClientRect();
41 return { left: rect.left + 12, top: rect.bottom + 6 };
42 }
43
44 export function ContextMenu({
45 open,
46 point,
47 items,
48 onClose,
49 className,
50 minWidth = 180,
51 ariaLabel = "Context menu",
52 }: {
53 open: boolean;
54 point: ContextMenuPoint | null;
55 items: ContextMenuItem[];
56 onClose: () => void;
57 className?: string;
58 minWidth?: number;
59 ariaLabel?: string;
60 }) {
61 const menuRef = useRef<HTMLDivElement>(null);
62 const [position, setPosition] = useState<ContextMenuPoint | null>(point);
63
64 useLayoutEffect(() => {
65 if (!open || !point) return;
66 const rect = menuRef.current?.getBoundingClientRect();
67 if (!rect) {
68 setPosition(point);
69 return;
70 }
71 setPosition(clampMenuPoint(point.left, point.top, rect.width, rect.height));
72 }, [open, point, items]);
73
74 useEffect(() => {
75 if (!open) return;
76 const closeOnOutsidePointerDown = (event: PointerEvent) => {
77 const target = event.target;
78 if (target instanceof Node && menuRef.current?.contains(target)) return;
79 onClose();
80 };
81 const close = () => onClose();
82 const closeOnEscape = (event: KeyboardEvent) => {
83 if (event.key === "Escape") onClose();
84 };
85 window.addEventListener("pointerdown", closeOnOutsidePointerDown, true);
86 window.addEventListener("resize", close);
87 window.addEventListener("keydown", closeOnEscape);
88 return () => {
89 window.removeEventListener("pointerdown", closeOnOutsidePointerDown, true);
90 window.removeEventListener("resize", close);
91 window.removeEventListener("keydown", closeOnEscape);
92 };
93 }, [open, onClose]);
94
95 if (!open || !point || !position) return null;
96
97 return createPortal(
98 <div
99 ref={menuRef}
100 className={`context-menu${className ? ` ${className}` : ""}`}
101 role="menu"
102 aria-label={ariaLabel}
103 style={{ left: position.left, top: position.top, minWidth }}
104 onMouseDown={(event) => {
105 event.preventDefault();
106 event.stopPropagation();
107 }}
108 onClick={(event) => event.stopPropagation()}
109 onContextMenu={(event) => {
110 event.preventDefault();
111 event.stopPropagation();
112 }}
113 >
114 {items.map((item) => {
115 if (item.type === "separator") {
116 return <div key={item.key} className="context-menu__separator" role="separator" />;
117 }
118 return (
119 <button
120 key={item.key}
121 type="button"
122 role="menuitem"
123 disabled={item.disabled}
124 className={`context-menu__item${item.danger ? " context-menu__item--danger" : ""}${item.variant ? ` context-menu__item--${item.variant}` : ""}`}
125 onClick={(event) => {
126 event.stopPropagation();
127 if (!item.disabled) item.onSelect();
128 }}
129 >
130 {item.icon}
131 <span>{item.label}</span>
132 {item.shortcut && <span className="context-menu__shortcut">{item.shortcut}</span>}
133 </button>
134 );
135 })}
136 </div>,
137 document.body,
138 );
139 }
140
140 lines Plain Text