返回 DeepSeek-Reasonix
TranscriptSelectionMenu.tsx
根目录 / desktop / frontend / src / components / TranscriptSelectionMenu.tsx
1 import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
2 import { createPortal } from "react-dom";
3 import { MessageSquare } from "lucide-react";
4 import { ContextMenu, type ContextMenuPoint } from "./ContextMenu";
5 import { messageSelectionContextText } from "../lib/messageSelectionCopy";
6 import { writeClipboardText } from "../lib/clipboard";
7 import {
8 detectShortcutPlatform,
9 formatShortcutCombo,
10 onShortcutsChanged,
11 resolvedShortcutCombo,
12 useGlobalShortcut,
13 } from "../lib/keyboardShortcuts";
14 import { useT } from "../lib/i18n";
15
16 // Inside the Wails shell main.tsx suppresses the webview's default context
17 // menu (its Reload/Back/Inspect entries can navigate away from the app), which
18 // also removes the native Copy menu for selected transcript text — ⌘C still
19 // works, but the right-click path is dead. This mounts one document-level
20 // listener that offers an app-drawn Copy menu whenever a suppressed selection
21 // menu would have applied, gated exactly like the ⌘C interceptor. It stays
22 // inert in a plain browser (no window.runtime), where the native menu opens.
23 type SelectionAction = {
24 text: string;
25 point: ContextMenuPoint;
26 };
27
28 const ACTION_EDGE_GAP = 8;
29
30 export function TranscriptSelectionMenu({
31 enabled = true,
32 resetKey,
33 onAddToChat,
34 }: {
35 enabled?: boolean;
36 // Identifies the transcript the selection was made in (the active tab).
37 // The overlay captures only text, while onAddToChat routes to whatever is
38 // active at click time — so any surviving overlay must be discarded when
39 // the source changes or a selection from session A could land in session B.
40 resetKey?: string | number;
41 onAddToChat?: (text: string) => void;
42 }) {
43 const t = useT();
44 const [point, setPoint] = useState<ContextMenuPoint | null>(null);
45 const [text, setText] = useState("");
46 const [action, setAction] = useState<SelectionAction | null>(null);
47 const [actionPoint, setActionPoint] = useState<ContextMenuPoint | null>(null);
48 const actionRef = useRef<HTMLDivElement>(null);
49 // Escape dismisses the floating action but browsers keep the text selection,
50 // so the trailing keyup would immediately re-show it. Remember the dismissed
51 // selection and stay hidden until it changes or a new pointer gesture lands.
52 const dismissedRef = useRef<string | null>(null);
53 const shortcutPlatform = useMemo(() => detectShortcutPlatform(), []);
54 const [shortcutRevision, setShortcutRevision] = useState(0);
55 useEffect(() => onShortcutsChanged(() => setShortcutRevision((value) => value + 1)), []);
56 const addShortcut = useMemo(
57 () => formatShortcutCombo(resolvedShortcutCombo("selection.addToChat", shortcutPlatform), shortcutPlatform),
58 // shortcutRevision re-resolves the combo after the user rebinds it in settings.
59 // eslint-disable-next-line react-hooks/exhaustive-deps
60 [shortcutPlatform, shortcutRevision],
61 );
62
63 const closeAction = useCallback(() => {
64 setAction(null);
65 setActionPoint(null);
66 }, []);
67
68 // Drop every piece of captured selection state when the source transcript
69 // changes: the floating action, the copy menu, and the Escape-dismissal
70 // memory all describe the previous tab's content.
71 const lastResetKeyRef = useRef(resetKey);
72 useEffect(() => {
73 if (lastResetKeyRef.current === resetKey) return;
74 lastResetKeyRef.current = resetKey;
75 dismissedRef.current = null;
76 setPoint(null);
77 setText("");
78 closeAction();
79 }, [closeAction, resetKey]);
80
81 const addSelectionToChat = useCallback(() => {
82 if (!action || !onAddToChat) return;
83 const selectedText = action.text;
84 document.getSelection()?.removeAllRanges();
85 closeAction();
86 onAddToChat(selectedText);
87 }, [action, closeAction, onAddToChat]);
88
89 // The shortcut is registered in SHORTCUT_DEFINITIONS so settings can rebind
90 // it and conflict-check it against other actions; it only arms while the
91 // floating action is visible.
92 useGlobalShortcut(
93 "selection.addToChat",
94 addSelectionToChat,
95 [],
96 Boolean(action) && enabled && Boolean(onAddToChat),
97 );
98
99 useLayoutEffect(() => {
100 if (!action) {
101 setActionPoint(null);
102 return;
103 }
104 const rect = actionRef.current?.getBoundingClientRect();
105 if (!rect) {
106 setActionPoint(action.point);
107 return;
108 }
109 setActionPoint({
110 left: Math.min(
111 Math.max(ACTION_EDGE_GAP, action.point.left),
112 Math.max(ACTION_EDGE_GAP, window.innerWidth - rect.width - ACTION_EDGE_GAP),
113 ),
114 top: Math.min(
115 Math.max(ACTION_EDGE_GAP, action.point.top),
116 Math.max(ACTION_EDGE_GAP, window.innerHeight - rect.height - ACTION_EDGE_GAP),
117 ),
118 });
119 }, [action]);
120
121 useEffect(() => {
122 const onContextMenu = (event: MouseEvent) => {
123 if (typeof window === "undefined" || !window.runtime) return;
124 const selected = messageSelectionContextText(document, event.target);
125 if (selected == null) return;
126 event.preventDefault();
127 setText(selected);
128 setPoint(menuPointFromEvent(event));
129 };
130 document.addEventListener("contextmenu", onContextMenu);
131 return () => document.removeEventListener("contextmenu", onContextMenu);
132 }, []);
133
134 useEffect(() => {
135 if (!enabled || !onAddToChat) {
136 closeAction();
137 return;
138 }
139
140 let frame: number | null = null;
141 const showForTarget = (target: EventTarget | null) => {
142 const selected = messageSelectionContextText(document, target);
143 const selection = document.getSelection();
144 const range = selection?.rangeCount ? selection.getRangeAt(selection.rangeCount - 1) : null;
145 if (selected == null || !range) {
146 dismissedRef.current = null;
147 closeAction();
148 return;
149 }
150 if (dismissedRef.current === selected) return;
151 dismissedRef.current = null;
152 const rect = typeof range.getBoundingClientRect === "function" ? range.getBoundingClientRect() : null;
153 setAction({
154 text: selected,
155 point: rect && (rect.width > 0 || rect.height > 0)
156 ? { left: rect.right, top: rect.bottom + 8 }
157 : { left: 12, top: 12 },
158 });
159 };
160 const scheduleShow = (target: EventTarget | null) => {
161 if (frame !== null) cancelAnimationFrame(frame);
162 frame = requestAnimationFrame(() => {
163 frame = null;
164 showForTarget(target);
165 });
166 };
167 const onPointerUp = (event: PointerEvent) => {
168 if (event.button !== 0) return;
169 dismissedRef.current = null;
170 scheduleShow(event.target);
171 };
172 const onKeyUp = (event: KeyboardEvent) => {
173 const selection = document.getSelection();
174 const target = selection?.focusNode instanceof Element
175 ? selection.focusNode
176 : selection?.focusNode?.parentElement ?? event.target;
177 scheduleShow(target);
178 };
179 const onSelectionChange = () => {
180 const selection = document.getSelection();
181 if (!selection || selection.isCollapsed || selection.toString().trim() === "") {
182 dismissedRef.current = null;
183 closeAction();
184 }
185 };
186 const onKeyDown = (event: KeyboardEvent) => {
187 if (event.key !== "Escape" || !action) return;
188 dismissedRef.current = action.text;
189 closeAction();
190 };
191 const close = () => closeAction();
192
193 document.addEventListener("pointerup", onPointerUp);
194 document.addEventListener("keyup", onKeyUp);
195 document.addEventListener("keydown", onKeyDown);
196 document.addEventListener("selectionchange", onSelectionChange);
197 window.addEventListener("resize", close);
198 window.addEventListener("scroll", close, true);
199 return () => {
200 if (frame !== null) cancelAnimationFrame(frame);
201 document.removeEventListener("pointerup", onPointerUp);
202 document.removeEventListener("keyup", onKeyUp);
203 document.removeEventListener("keydown", onKeyDown);
204 document.removeEventListener("selectionchange", onSelectionChange);
205 window.removeEventListener("resize", close);
206 window.removeEventListener("scroll", close, true);
207 };
208 }, [action, closeAction, enabled, onAddToChat]);
209
210 return <>
211 <ContextMenu
212 open={point != null}
213 point={point}
214 minWidth={140}
215 ariaLabel={t("common.copy")}
216 items={[
217 {
218 key: "copy",
219 label: t("common.copy"),
220 shortcut: formatShortcutCombo(
221 shortcutPlatform === "darwin" ? { key: "c", meta: true } : { key: "c", ctrl: true },
222 shortcutPlatform,
223 ),
224 onSelect: () => {
225 void writeClipboardText(text);
226 setPoint(null);
227 },
228 },
229 ]}
230 onClose={() => setPoint(null)}
231 />
232 {action && typeof document !== "undefined" && createPortal(
233 <div
234 ref={actionRef}
235 className="transcript-selection-action"
236 role="toolbar"
237 aria-label={t("selection.actions")}
238 style={{
239 left: actionPoint?.left ?? action.point.left,
240 top: actionPoint?.top ?? action.point.top,
241 visibility: actionPoint ? "visible" : "hidden",
242 }}
243 onMouseDown={(event) => event.preventDefault()}
244 >
245 <button type="button" onClick={addSelectionToChat}>
246 <MessageSquare size={14} aria-hidden="true" />
247 <span>{t("selection.addToChat")}</span>
248 <kbd>{addShortcut}</kbd>
249 </button>
250 </div>,
251 document.body,
252 )}
253 </>;
254 }
255
256 // The keyboard context-menu key fires contextmenu at (0, 0); anchor the menu
257 // to the selection instead so it opens next to the highlighted text.
258 function menuPointFromEvent(event: MouseEvent): ContextMenuPoint {
259 if (event.clientX > 0 || event.clientY > 0) {
260 return { left: event.clientX, top: event.clientY };
261 }
262 const range = document.getSelection()?.rangeCount ? document.getSelection()?.getRangeAt(0) : null;
263 const rect = typeof range?.getBoundingClientRect === "function" ? range.getBoundingClientRect() : null;
264 if (rect && (rect.width > 0 || rect.height > 0)) {
265 return { left: rect.left, top: rect.bottom + 4 };
266 }
267 return { left: 12, top: 12 };
268 }
269
269 lines Plain Text