-
Notifications
You must be signed in to change notification settings - Fork 99
Expand file tree
/
Copy pathtranscriptContextMenu.ts
More file actions
333 lines (287 loc) · 9.15 KB
/
transcriptContextMenu.ts
File metadata and controls
333 lines (287 loc) · 9.15 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
import {
TRANSCRIPT_IGNORE_CONTEXT_MENU_SELECTOR,
TRANSCRIPT_MESSAGE_SELECTOR,
TRANSCRIPT_QUOTE_ROOT_SELECTOR,
TRANSCRIPT_QUOTE_TEXT_ATTRIBUTE,
} from "./transcriptQuoteAttributes";
// Preserve native link context-menu actions (open/copy link, etc.) by treating
// anchors and explicitly opted-out transcript chrome as interactive targets.
const INTERACTIVE_SELECTOR = `button, [role='button'], input, textarea, select, a[href], ${TRANSCRIPT_IGNORE_CONTEXT_MENU_SELECTOR}`;
const QUOTEABLE_BLOCK_SELECTOR = [
".code-block-wrapper",
".mermaid-container",
"p",
"li",
"blockquote",
"pre",
"code",
"td",
"th",
"h1",
"h2",
"h3",
"h4",
"h5",
"h6",
"summary",
].join(", ");
function normalizeTranscriptText(rawText: string): string {
return rawText.replace(/\r\n?/g, "\n").replace(/\u00a0/g, " ");
}
function hasNonWhitespaceTranscriptText(text: string): boolean {
return text.trim().length > 0;
}
function getEventTargetElement(target: EventTarget | null): Element | null {
if (!target || typeof target !== "object") {
return null;
}
const nodeTarget = target as { nodeType?: number; parentElement?: Element | null };
if (nodeTarget.nodeType === 1) {
return target as Element;
}
if (nodeTarget.nodeType === 3) {
return nodeTarget.parentElement ?? null;
}
return null;
}
function getClosestTranscriptAncestor(
transcriptRoot: HTMLElement,
element: Element | null,
selector: string
): Element | null {
if (!element || !transcriptRoot.contains(element)) {
return null;
}
const ancestor = element.closest(selector);
return ancestor && transcriptRoot.contains(ancestor) ? ancestor : null;
}
function getTranscriptQuoteOverride(element: Element | null): string | null {
if (!element) {
return null;
}
const override = element.getAttribute(TRANSCRIPT_QUOTE_TEXT_ATTRIBUTE);
if (override == null) {
return null;
}
const normalizedOverride = normalizeTranscriptText(override);
return hasNonWhitespaceTranscriptText(normalizedOverride) ? normalizedOverride : null;
}
function getTranscriptQuoteableText(element: Element | null): string | null {
if (!element) {
return null;
}
const override = getTranscriptQuoteOverride(element);
if (override) {
return override;
}
const normalizedText = normalizeTranscriptText(element.textContent ?? "");
return hasNonWhitespaceTranscriptText(normalizedText) ? normalizedText : null;
}
function getClosestTranscriptQuoteBlock(
quoteRoot: Element,
targetElement: Element
): Element | null {
const quoteBlock = targetElement.closest(QUOTEABLE_BLOCK_SELECTOR);
return quoteBlock && quoteRoot.contains(quoteBlock) ? quoteBlock : null;
}
function selectionIntersectsIgnoredChrome(quoteRoot: Element, selectionRange: Range): boolean {
for (const ignoredElement of quoteRoot.querySelectorAll(
TRANSCRIPT_IGNORE_CONTEXT_MENU_SELECTOR
)) {
if (selectionRange.intersectsNode(ignoredElement)) {
return true;
}
}
return false;
}
function getSelectedTranscriptText(
transcriptRoot: HTMLElement,
selection: Selection | null,
target: EventTarget | null
): string | null {
if (!selection || selection.rangeCount === 0) {
return null;
}
const selectedText = normalizeTranscriptText(selection.toString());
if (!hasNonWhitespaceTranscriptText(selectedText)) {
return null;
}
const selectedRange = selection.getRangeAt(0);
const startElement = getEventTargetElement(selectedRange.startContainer);
const endElement = getEventTargetElement(selectedRange.endContainer);
const targetElement = getEventTargetElement(target);
const startMessage = getClosestTranscriptAncestor(
transcriptRoot,
startElement,
TRANSCRIPT_MESSAGE_SELECTOR
);
const endMessage = getClosestTranscriptAncestor(
transcriptRoot,
endElement,
TRANSCRIPT_MESSAGE_SELECTOR
);
const startQuoteRoot = getClosestTranscriptAncestor(
transcriptRoot,
startElement,
TRANSCRIPT_QUOTE_ROOT_SELECTOR
);
const endQuoteRoot = getClosestTranscriptAncestor(
transcriptRoot,
endElement,
TRANSCRIPT_QUOTE_ROOT_SELECTOR
);
// Require the full selection range to stay within a single quoteable transcript body
// so we do not accidentally quote text from non-message interstitial UI.
if (
startMessage === null ||
endMessage === null ||
startMessage !== endMessage ||
startQuoteRoot === null ||
endQuoteRoot === null ||
startQuoteRoot !== endQuoteRoot
) {
return null;
}
const targetMessage = getClosestTranscriptAncestor(
transcriptRoot,
targetElement,
TRANSCRIPT_MESSAGE_SELECTOR
);
const targetQuoteRoot = getClosestTranscriptAncestor(
transcriptRoot,
targetElement,
TRANSCRIPT_QUOTE_ROOT_SELECTOR
);
if (
targetMessage !== null &&
targetQuoteRoot !== null &&
(targetMessage !== startMessage || targetQuoteRoot !== startQuoteRoot)
) {
return null;
}
if (selectionIntersectsIgnoredChrome(startQuoteRoot, selectedRange)) {
return null;
}
return selectedText;
}
function getHoveredTranscriptText(
transcriptRoot: HTMLElement,
target: EventTarget | null
): string | null {
const targetElement = getEventTargetElement(target);
if (!targetElement || !transcriptRoot.contains(targetElement)) {
return null;
}
if (targetElement.closest(INTERACTIVE_SELECTOR)) {
return null;
}
const quoteRoot = getClosestTranscriptAncestor(
transcriptRoot,
targetElement,
TRANSCRIPT_QUOTE_ROOT_SELECTOR
);
if (!quoteRoot) {
return null;
}
const quoteBlock = getClosestTranscriptQuoteBlock(quoteRoot, targetElement);
if (quoteBlock) {
return getTranscriptQuoteableText(quoteBlock);
}
// Quote roots act as selection boundaries. Fall back to an explicit root-level text override
// for custom renderers whose DOM does not expose stable semantic blocks, but avoid quoting the
// entire message when the user right-clicks the root container's empty padding.
if (targetElement !== quoteRoot) {
const quoteRootOverride = getTranscriptQuoteOverride(quoteRoot);
if (quoteRootOverride) {
return quoteRootOverride;
}
return getTranscriptQuoteableText(targetElement);
}
return null;
}
export interface TranscriptContextMenuTextOptions {
transcriptRoot: HTMLElement;
target: EventTarget | null;
selection: Selection | null;
}
export interface TranscriptContextMenuLinkOptions {
transcriptRoot: HTMLElement;
target: EventTarget | null;
}
/**
* Resolve an anchor href for right-click link actions (e.g. "Copy link").
*
* Returns the rendered href string when the right-click target is inside an
* anchor within the transcript; otherwise null. This complements
* `getTranscriptContextMenuText` so the transcript can offer link-specific
* actions on anchors (which would otherwise bypass the text resolver).
*/
export function getTranscriptContextMenuLink(
options: TranscriptContextMenuLinkOptions
): string | null {
// Reuse the shared ancestor helper so the null/contains guards for the target
// element and the resolved anchor stay in one place.
const anchor = getClosestTranscriptAncestor(
options.transcriptRoot,
getEventTargetElement(options.target),
"a[href]"
);
if (!anchor) {
return null;
}
const href = anchor.getAttribute("href");
if (href === null) {
return null;
}
const trimmedHref = href.trim();
return trimmedHref.length > 0 ? trimmedHref : null;
}
/**
* Resolve transcript text for right-click actions.
*
* Priority:
* 1) Current selection inside the same quoteable transcript body
* 2) The nearest explicitly marked quote block under the cursor
* 3) A root-level raw-text override for custom renderers whose DOM is not a faithful text source
*/
export function getTranscriptContextMenuText(
options: TranscriptContextMenuTextOptions
): string | null {
// Interactive transcript targets should keep native browser context-menu actions
// (e.g. open/copy link) even when a transcript selection currently exists.
const targetElement = getEventTargetElement(options.target);
if (
targetElement &&
options.transcriptRoot.contains(targetElement) &&
targetElement.closest(INTERACTIVE_SELECTOR)
) {
return null;
}
const selectedText = getSelectedTranscriptText(
options.transcriptRoot,
options.selection,
options.target
);
if (selectedText) {
return selectedText;
}
return getHoveredTranscriptText(options.transcriptRoot, options.target);
}
/**
* Convert plain transcript text into Markdown blockquote syntax so pasted context
* is visually separated from the user's next prompt.
*/
export function formatTranscriptTextAsQuote(text: string): string {
const normalizedText = normalizeTranscriptText(text);
if (!hasNonWhitespaceTranscriptText(normalizedText)) {
return "";
}
// Strip leading/trailing newlines so the quote block doesn't start or end
// with empty "> " lines (e.g. from DOM whitespace around block elements).
const trimmedText = normalizedText.replace(/^\n+|\n+$/g, "");
if (!hasNonWhitespaceTranscriptText(trimmedText)) {
return "";
}
const quotedLines = trimmedText.split("\n").map((line) => (line.length > 0 ? `> ${line}` : ">"));
return `${quotedLines.join("\n")}\n\n`;
}