| 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 |