返回 DeepSeek-Reasonix
messageSelectionCopy.ts
根目录 / desktop / frontend / src / lib / messageSelectionCopy.ts
1 const MESSAGE_SELECTION_SELECTOR = ".msg__body, .reasoning__body";
2
3 export interface MessageSelectionCopyState {
4 text: string;
5 isCollapsed: boolean;
6 targetIsEditable: boolean;
7 intersectsMessage: boolean;
8 canWriteClipboard: boolean;
9 }
10
11 export function messageSelectionCopyText(state: MessageSelectionCopyState): string | null {
12 if (state.isCollapsed) return null;
13 if (state.targetIsEditable) return null;
14 if (!state.intersectsMessage) return null;
15 if (!state.canWriteClipboard) return null;
16 if (state.text.trim() === "") return null;
17 return state.text;
18 }
19
20 // Gates the transcript right-click menu the same way the copy interceptor
21 // gates ⌘C — a non-collapsed, non-editable selection touching a message or
22 // reasoning body — plus one menu-only rule: the right-click must land inside
23 // a message body that the selection itself touches. A selection outlives
24 // clicks elsewhere: surfaces like the project tree and tab bar own their
25 // right-click menus, and offering Copy on message B while message A holds
26 // the selection would copy text from somewhere other than the click. A
27 // selection spanning several messages accepts a right-click on any of them.
28 // Returns the text the menu would copy, or null when the menu should not
29 // open. canWriteClipboard is true because the menu writes through
30 // writeClipboardText rather than a ClipboardEvent's clipboardData.
31 export function messageSelectionContextText(doc: Document, target: EventTarget | null): string | null {
32 const selection = doc.getSelection();
33 if (!targetWithinSelectedMessage(target, selection)) return null;
34 return messageSelectionCopyText({
35 text: restoreLatexInSelection(selection),
36 isCollapsed: selection == null || selection.isCollapsed,
37 targetIsEditable: isEditableTarget(target),
38 intersectsMessage: selectionIntersectsMessage(selection, doc),
39 canWriteClipboard: true,
40 });
41 }
42
43 function targetWithinSelectedMessage(target: EventTarget | null, selection: Selection | null): boolean {
44 const el = elementFromEventTarget(target);
45 const message = el?.closest(MESSAGE_SELECTION_SELECTOR);
46 if (!message || !selection || selection.rangeCount === 0) return false;
47 for (let i = 0; i < selection.rangeCount; i += 1) {
48 if (rangeIntersectsNode(selection.getRangeAt(i), message)) return true;
49 }
50 return false;
51 }
52
53 // Range.intersectsNode with a boundary-point fallback for DOM implementations
54 // that lack it (jsdom). Ranges intersect a node when the range starts before
55 // the node's end and ends after the node's start.
56 function rangeIntersectsNode(range: Range, node: Node): boolean {
57 try {
58 if (typeof range.intersectsNode === "function") return range.intersectsNode(node);
59 const doc = node.ownerDocument;
60 if (!doc) return false;
61 const nodeRange = doc.createRange();
62 nodeRange.selectNodeContents(node);
63 return range.compareBoundaryPoints(nodeRange.END_TO_START, nodeRange) < 0
64 && range.compareBoundaryPoints(nodeRange.START_TO_END, nodeRange) > 0;
65 } catch {
66 return false;
67 }
68 }
69
70 export function installMessageSelectionCopy(doc: Document = document): () => void {
71 const onCopy = (event: ClipboardEvent) => {
72 const selection = doc.getSelection();
73 const text = messageSelectionCopyText({
74 text: restoreLatexInSelection(selection),
75 isCollapsed: selection == null || selection.isCollapsed,
76 targetIsEditable: isEditableTarget(event.target),
77 intersectsMessage: selectionIntersectsMessage(selection, doc),
78 canWriteClipboard: event.clipboardData != null,
79 });
80 if (text == null || event.clipboardData == null) return;
81
82 event.clipboardData.setData("text/plain", text);
83 event.preventDefault();
84 };
85
86 doc.addEventListener("copy", onCopy);
87 return () => doc.removeEventListener("copy", onCopy);
88 }
89
90 function restoreLatexInSelection(selection: Selection | null): string {
91 if (!selection || selection.isCollapsed || selection.rangeCount === 0) return "";
92 const range = selection.getRangeAt(0);
93 const baseText = selection.toString();
94 if (baseText.trim() === "") return "";
95
96 const formulas = selectedKatexRoots(range);
97 if (formulas.length === 0) return baseText;
98
99 const doc = range.commonAncestorContainer.ownerDocument;
100 if (!doc) return baseText;
101
102 let cursorNode = range.startContainer;
103 let cursorOffset = range.startOffset;
104 let text = "";
105
106 for (const formula of formulas) {
107 const source = katexSource(formula);
108 if (source == null) continue;
109
110 const formulaRange = doc.createRange();
111 formulaRange.selectNode(formula);
112 if (comparePoints(
113 doc,
114 formulaRange.endContainer,
115 formulaRange.endOffset,
116 range.startContainer,
117 range.startOffset,
118 ) <= 0) continue;
119 if (comparePoints(
120 doc,
121 formulaRange.startContainer,
122 formulaRange.startOffset,
123 range.endContainer,
124 range.endOffset,
125 ) >= 0) break;
126
127 if (comparePoints(
128 doc,
129 cursorNode,
130 cursorOffset,
131 formulaRange.startContainer,
132 formulaRange.startOffset,
133 ) < 0) {
134 const before = doc.createRange();
135 before.setStart(cursorNode, cursorOffset);
136 before.setEnd(formulaRange.startContainer, formulaRange.startOffset);
137 text += before.toString();
138 }
139
140 text += source;
141 if (comparePoints(
142 doc,
143 formulaRange.endContainer,
144 formulaRange.endOffset,
145 range.endContainer,
146 range.endOffset,
147 ) >= 0) {
148 return text;
149 }
150 cursorNode = formulaRange.endContainer;
151 cursorOffset = formulaRange.endOffset;
152 }
153
154 if (comparePoints(
155 doc,
156 cursorNode,
157 cursorOffset,
158 range.endContainer,
159 range.endOffset,
160 ) < 0) {
161 const after = doc.createRange();
162 after.setStart(cursorNode, cursorOffset);
163 after.setEnd(range.endContainer, range.endOffset);
164 text += after.toString();
165 }
166 return text || baseText;
167 }
168
169 function selectedKatexRoots(range: Range): HTMLElement[] {
170 const common = elementFromNode(range.commonAncestorContainer);
171 if (!common) return [];
172
173 const roots = new Set<HTMLElement>();
174 const ancestor = katexRoot(common);
175 if (ancestor && rangeIntersectsNode(range, ancestor)) roots.add(ancestor);
176
177 const candidates = common.matches(".katex, .katex-display")
178 ? [common, ...Array.from(common.querySelectorAll(".katex, .katex-display"))]
179 : Array.from(common.querySelectorAll(".katex, .katex-display"));
180 for (const candidate of candidates) {
181 const root = katexRoot(candidate);
182 if (root && rangeIntersectsNode(range, root)) roots.add(root);
183 }
184
185 return Array.from(roots).sort((a, b) => {
186 if (a === b) return 0;
187 const position = a.compareDocumentPosition(b);
188 return position & Node.DOCUMENT_POSITION_FOLLOWING ? -1 : 1;
189 });
190 }
191
192 function katexRoot(node: Element): HTMLElement | null {
193 const display = node.closest<HTMLElement>(".katex-display");
194 if (display) return display;
195 return node.closest<HTMLElement>(".katex");
196 }
197
198 function katexSource(root: HTMLElement): string | null {
199 const annotation = root.querySelector<HTMLElement>(
200 'annotation[encoding="application/x-tex"]',
201 );
202 const tex = annotation?.textContent ?? root.getAttribute("data-latex-source");
203 if (!tex) return null;
204 return root.classList.contains("katex-display")
205 ? `$$\n${tex}\n$$`
206 : `$${tex}$`;
207 }
208
209 function comparePoints(
210 doc: Document,
211 leftNode: Node,
212 leftOffset: number,
213 rightNode: Node,
214 rightOffset: number,
215 ): number {
216 const left = doc.createRange();
217 left.setStart(leftNode, leftOffset);
218 left.collapse(true);
219 const right = doc.createRange();
220 right.setStart(rightNode, rightOffset);
221 right.collapse(true);
222 return left.compareBoundaryPoints(0, right);
223 }
224
225 function selectionIntersectsMessage(selection: Selection | null, root: ParentNode): boolean {
226 if (selection == null || selection.rangeCount === 0 || selection.isCollapsed) return false;
227 for (let i = 0; i < selection.rangeCount; i += 1) {
228 if (rangeIntersectsMessage(selection.getRangeAt(i), root)) return true;
229 }
230 return false;
231 }
232
233 function rangeIntersectsMessage(range: Range, root: ParentNode): boolean {
234 const common = elementFromNode(range.commonAncestorContainer);
235 const directMessage = common?.closest(MESSAGE_SELECTION_SELECTOR);
236 if (directMessage) return true;
237
238 const scope = common ?? root;
239 const candidates = scope instanceof Element && scope.matches(MESSAGE_SELECTION_SELECTOR)
240 ? [scope, ...Array.from(scope.querySelectorAll(MESSAGE_SELECTION_SELECTOR))]
241 : Array.from(scope.querySelectorAll(MESSAGE_SELECTION_SELECTOR));
242
243 return candidates.some((node) => rangeIntersectsNode(range, node));
244 }
245
246 function isEditableTarget(target: EventTarget | null): boolean {
247 const el = elementFromEventTarget(target);
248 if (!el) return false;
249 if (el.closest("input, textarea, select")) return true;
250 for (let node: Element | null = el; node; node = node.parentElement) {
251 if (node instanceof HTMLElement && node.isContentEditable) return true;
252 }
253 return false;
254 }
255
256 function elementFromEventTarget(target: EventTarget | null): Element | null {
257 return target instanceof Element ? target : null;
258 }
259
260 function elementFromNode(node: Node | null): Element | null {
261 if (!node) return null;
262 return node.nodeType === Node.ELEMENT_NODE ? node as Element : node.parentElement;
263 }
264
264 lines TYPESCRIPT