dsh-plugin-term-dictionary 0.0.0-stage → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +189 -0
- package/LICENSE +21 -0
- package/README.md +776 -2
- package/cordis.patch.yml +14 -0
- package/icon.svg +13 -0
- package/lib/ROADMAP-lexicon.md +52 -0
- package/lib/client.js +13354 -0
- package/lib/core/api.js +278 -0
- package/lib/core/bus.js +98 -0
- package/lib/core/copy.js +614 -0
- package/lib/core/core.js +309 -0
- package/lib/core/dictionary.js +1187 -0
- package/lib/core/entries.js +454 -0
- package/lib/core/highlight.js +282 -0
- package/lib/core/hover.js +470 -0
- package/lib/core/hovercard.js +173 -0
- package/lib/core/interact.js +1802 -0
- package/lib/core/lexicon.en.js +872 -0
- package/lib/core/lexicon.zh.js +249 -0
- package/lib/core/overlay.js +239 -0
- package/lib/core/pack.js +372 -0
- package/lib/core/package.json +4 -0
- package/lib/core/selection.js +83 -0
- package/lib/core/settings.js +366 -0
- package/lib/core/shell.js +1003 -0
- package/lib/core/stopwords.js +147 -0
- package/lib/core/store.js +397 -0
- package/lib/core/styles.js +574 -0
- package/lib/core/terms.js +398 -0
- package/lib/core/transfer.js +382 -0
- package/lib/core/views.js +2428 -0
- package/lib/index.js +1110 -0
- package/lib/pack-code.js +84 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +71 -3
|
@@ -0,0 +1,1802 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Everything the plugin does inside the conversation, kept in one place because
|
|
5
|
+
* both jobs share the same read of the transcript:
|
|
6
|
+
*
|
|
7
|
+
* - **auto-collection**: watch the rendered transcript, and when a message stops
|
|
8
|
+
* changing, collect the terminology in it;
|
|
9
|
+
* - **selection to create**: a selection inside a reply grows a small
|
|
10
|
+
* "add to dictionary" affordance next to it.
|
|
11
|
+
*
|
|
12
|
+
* It also owns the transcript region the annotation layer paints on, and the settled
|
|
13
|
+
* pass that tells that layer when the transcript changed.
|
|
14
|
+
*
|
|
15
|
+
* It never mutates the transcript: no wrapping spans, no injected nodes, no
|
|
16
|
+
* React interference. Selection geometry is read from the render tree with `Range`,
|
|
17
|
+
* which is what makes this safe to run over the host's virtualized message list.
|
|
18
|
+
*
|
|
19
|
+
* There is deliberately no pointer hit test here. Resolving "which term is under this
|
|
20
|
+
* coordinate" means a cached box per message plus an offset mapping for the assembled
|
|
21
|
+
* text, and both go wrong in a real page rather than in a fixture: the transcript
|
|
22
|
+
* remounts, the cached elements detach, every `getBoundingClientRect()` reads 0, and
|
|
23
|
+
* every gesture is refused. Hover and click now belong to `dsh-plugin-term-wikilink`,
|
|
24
|
+
* which indexes `(text node, offset)` pairs and asks the event which region it happened
|
|
25
|
+
* in, so it holds nothing that can go stale.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
const core = require("./core.js");
|
|
29
|
+
const { selectionActive } = require("./selection.js");
|
|
30
|
+
|
|
31
|
+
const {
|
|
32
|
+
normalizeTerm,
|
|
33
|
+
termId,
|
|
34
|
+
contextAround,
|
|
35
|
+
isTermShaped,
|
|
36
|
+
looksLikeIdentifier,
|
|
37
|
+
isAcronym,
|
|
38
|
+
hasTechnicalMorphology,
|
|
39
|
+
tokenize
|
|
40
|
+
} = core;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Message root inside the conversation region.
|
|
44
|
+
*
|
|
45
|
+
* The chat region is the conversation BODY, and the composer is a seat *inside*
|
|
46
|
+
* it (`data-composer-seat`, itself tagged `data-conversation-region="composer"`).
|
|
47
|
+
* The region therefore is not the same thing as "the messages": reading it
|
|
48
|
+
* wholesale would fold every message into one block and sweep in the user's
|
|
49
|
+
* unsent draft. {@link readRegion} cuts the composer out, and every reader here
|
|
50
|
+
* goes through it.
|
|
51
|
+
*
|
|
52
|
+
* The value `chat` is part of the selector on purpose. The host tags BOTH nodes with
|
|
53
|
+
* the same attribute name — the body with `="chat"` and the seat nested inside it with
|
|
54
|
+
* `="composer"` — so the bare attribute would make every pointer test here ambiguous
|
|
55
|
+
* about which of the two it had adopted, and the composer would be a legal answer.
|
|
56
|
+
*/
|
|
57
|
+
const REGION_SELECTOR = '[data-conversation-region="chat"]';
|
|
58
|
+
|
|
59
|
+
/** The composer seat, which is inside the region and must never be read as a message. */
|
|
60
|
+
const COMPOSER_SELECTOR = "[data-composer-seat], [data-conversation-region='composer']";
|
|
61
|
+
|
|
62
|
+
/** The scrolling element whose mutations drive auto-collection. */
|
|
63
|
+
const SCROLL_SELECTOR = "[data-conversation-scroll]";
|
|
64
|
+
|
|
65
|
+
/** Containers whose text is data rather than prose. */
|
|
66
|
+
const SKIP_SELECTOR = "pre, code, a, [data-lexical-editor], [contenteditable=true], [data-term-dictionary]";
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Code and tool output.
|
|
70
|
+
*
|
|
71
|
+
* A hit here may still be EXPLAINED — an identifier the dictionary already holds is worth
|
|
72
|
+
* reading wherever it appears — but it may never offer to CREATE an entry, because a code
|
|
73
|
+
* identifier is data, not prose the user is reading.
|
|
74
|
+
*/
|
|
75
|
+
const DATA_SELECTOR = "pre, code";
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Containers that never resolve a term at all.
|
|
79
|
+
*
|
|
80
|
+
* `SKIP_SELECTOR` minus code, plus the composer. This is the gate that used to be
|
|
81
|
+
* `SKIP_SELECTOR`, and that is what made the feature look broken: terms are collected and
|
|
82
|
+
* MARKED inside inline code (the surrounding block is prose, so the block passes
|
|
83
|
+
* `isProseBlock`), while the hit test refused every hit inside `code`. The marked words the
|
|
84
|
+
* user actually reaches for — `runInTransaction`, `elementFromPoint`, the identifiers — are
|
|
85
|
+
* exactly the inline-code ones, so underlined words could not be hovered or clicked at all.
|
|
86
|
+
*
|
|
87
|
+
* The marking and the interaction have to agree about what is interactive; either both
|
|
88
|
+
* include code or neither does. Both include it now, except for the create affordance.
|
|
89
|
+
*/
|
|
90
|
+
const BLOCKED_SELECTOR = "a, [data-lexical-editor], [contenteditable=true], [data-term-dictionary]";
|
|
91
|
+
|
|
92
|
+
/** Tags whose text starts a new line, so the caret-to-offset walk can join text nodes correctly. */
|
|
93
|
+
const BLOCK_TAGS = new Set([
|
|
94
|
+
"ADDRESS", "ARTICLE", "ASIDE", "BLOCKQUOTE", "DD", "DIV", "DL", "DT", "FIELDSET", "FIGCAPTION",
|
|
95
|
+
"FIGURE", "FOOTER", "FORM", "H1", "H2", "H3", "H4", "H5", "H6", "HEADER", "HR", "LI", "MAIN",
|
|
96
|
+
"NAV", "OL", "P", "PRE", "SECTION", "TABLE", "TBODY", "TD", "TFOOT", "TH", "THEAD", "TR", "UL"
|
|
97
|
+
]);
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Whether an element lays its content out as its own block.
|
|
101
|
+
*
|
|
102
|
+
* This is what separates a message from a chip. A message's prose lives inside a
|
|
103
|
+
* paragraph, a list item or a container; an avatar label, a timestamp and the Copy
|
|
104
|
+
* and Retry buttons are inline, so they are chrome rather than blocks.
|
|
105
|
+
*
|
|
106
|
+
* @param element - the element to test.
|
|
107
|
+
* @returns true when the element establishes its own block.
|
|
108
|
+
*/
|
|
109
|
+
function isBlockLevel(element) {
|
|
110
|
+
return element?.nodeType === 1 && BLOCK_TAGS.has(element.tagName);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** How long the transcript must be quiet before a message is treated as settled. */
|
|
114
|
+
const SETTLE_MS = 700;
|
|
115
|
+
|
|
116
|
+
/** How long a selection must be stable before the affordance appears. */
|
|
117
|
+
const SELECTION_DEBOUNCE_MS = 140;
|
|
118
|
+
|
|
119
|
+
// The hover rest-delay used to live here as `HOVER_DELAY_MS = 130`. It is gone, and so is the
|
|
120
|
+
// behaviour it timed: after the two plugins were merged, waiting for the pointer to come to rest is
|
|
121
|
+
// `hover.js`'s job (`HOVER_DELAY_MS`, now the configurable `hoverInMs` preference, default 110). Both
|
|
122
|
+
// constants were named the same and disagreed, so the dead one read as the live one — which is how the
|
|
123
|
+
// README spent a while advertising a 130 ms rest delay that no code had used since the merge.
|
|
124
|
+
|
|
125
|
+
/** Most entries one message may add, so a wall of jargon cannot flood the panel. */
|
|
126
|
+
const MAX_AUTO_PER_MESSAGE = 3;
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Most entries automatic collection may add per minute, across all messages.
|
|
130
|
+
*
|
|
131
|
+
* The per-message cap alone bounds a burst but not a session: a long conversation where
|
|
132
|
+
* every reply carries one new identifier still adds one entry per reply, forever, which
|
|
133
|
+
* is what "收录过程太频繁" describes. This is the session-level budget.
|
|
134
|
+
*/
|
|
135
|
+
const MAX_AUTO_PER_MINUTE = 8;
|
|
136
|
+
|
|
137
|
+
/** The window the per-minute budget is measured over. */
|
|
138
|
+
const AUTO_BUDGET_WINDOW_MS = 60_000;
|
|
139
|
+
|
|
140
|
+
/** Remembered message fingerprints, so a re-render does not re-collect. */
|
|
141
|
+
const MAX_SEEN_FINGERPRINTS = 400;
|
|
142
|
+
|
|
143
|
+
/** Longest selection the plugin will offer to add. */
|
|
144
|
+
const MAX_SELECTION_CHARS = 80;
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Hard ceiling on a stored context window.
|
|
148
|
+
*
|
|
149
|
+
* The panel renders a context under the editor's title, so an unbounded context is not
|
|
150
|
+
* a cosmetic problem: it is what made the editor unusable.
|
|
151
|
+
*/
|
|
152
|
+
const MAX_CONTEXT_CHARS = 240;
|
|
153
|
+
|
|
154
|
+
/** FNV-1a over a string, used only to recognize a message the plugin already read. */
|
|
155
|
+
function hashText(text) {
|
|
156
|
+
let hash = 0x811c9dc5;
|
|
157
|
+
for (let index = 0; index < text.length; index++) {
|
|
158
|
+
hash ^= text.charCodeAt(index);
|
|
159
|
+
hash = Math.imul(hash, 0x01000193) >>> 0;
|
|
160
|
+
}
|
|
161
|
+
return hash.toString(36);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Collapse every run of whitespace to one space, the same way the detector sees text. */
|
|
165
|
+
function collapse(text) {
|
|
166
|
+
return typeof text === "string" ? text.replace(/\s+/g, " ").trim() : "";
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* The blocks of a container, in document order, each with its own text.
|
|
171
|
+
*
|
|
172
|
+
* The boundary list is the render tree's own granularity: the children of the
|
|
173
|
+
* scrolling region are messages, so every block in the returned list is one
|
|
174
|
+
* message. Within a block, offset 0 of the text is the block's first character.
|
|
175
|
+
*
|
|
176
|
+
* @param root - the region element.
|
|
177
|
+
* @returns `{ blocks: Array<{ element, text }>, text, offsets }`, where `offsets`
|
|
178
|
+
* maps every block back to its start in the joined `text`.
|
|
179
|
+
*/
|
|
180
|
+
function readRegion(root) {
|
|
181
|
+
if (root === null || root === undefined) return { blocks: [], text: "", offsets: [] };
|
|
182
|
+
const blocks = [];
|
|
183
|
+
const offsets = [];
|
|
184
|
+
let text = "";
|
|
185
|
+
for (const element of findMessageElements(root)) {
|
|
186
|
+
const blockText = collapse(readBlock(element));
|
|
187
|
+
if (blockText === "") continue;
|
|
188
|
+
const separator = text === "" ? "" : " ";
|
|
189
|
+
const start = text.length + separator.length;
|
|
190
|
+
text += separator + blockText;
|
|
191
|
+
offsets.push(start);
|
|
192
|
+
blocks.push({ element, text: blockText, start });
|
|
193
|
+
}
|
|
194
|
+
return { blocks, text, offsets };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** Longest text a single message block may hold before it stops being one block. */
|
|
198
|
+
const MAX_BLOCK_CHARS = 20000;
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Whether an element holds its text directly rather than in child elements.
|
|
202
|
+
*
|
|
203
|
+
* A paragraph of prose is a run of text nodes and inline elements; a message
|
|
204
|
+
* container has element children that are themselves blocks. `childNodes` is what
|
|
205
|
+
* separates them, because `children` only counts elements.
|
|
206
|
+
*
|
|
207
|
+
* @param element - the element to test.
|
|
208
|
+
* @returns true when the element carries no element children.
|
|
209
|
+
*/
|
|
210
|
+
function isTextOnly(element) {
|
|
211
|
+
const nodes = Array.from(element.childNodes ?? []);
|
|
212
|
+
if (nodes.length === 0) return true;
|
|
213
|
+
return nodes.every((node) => node.nodeType !== 1);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* The attribute every conversation row carries, whatever it renders.
|
|
218
|
+
*
|
|
219
|
+
* Found in the shipped chat bundle (`ChatView_module_css_default.flowItem`): each row
|
|
220
|
+
* is a `div` with `data-chat-flow-key`, `data-chat-node-key` and
|
|
221
|
+
* `data-chat-flow-kind`, and the row's own message content lives in a child marked
|
|
222
|
+
* `data-slot="conversation.chat.node"`.
|
|
223
|
+
*/
|
|
224
|
+
const FLOW_ROW_SELECTOR = "[data-chat-flow-key]";
|
|
225
|
+
|
|
226
|
+
/** The child of a flow row that holds that row's rendered message. */
|
|
227
|
+
const FLOW_NODE_SELECTOR = '[data-slot="conversation.chat.node"]';
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Row kinds that are grouping furniture rather than message text.
|
|
231
|
+
*
|
|
232
|
+
* A turn or a step is a container: its row carries `data-step-process` or
|
|
233
|
+
* `data-turn-process-member`, and its text is the sum of the rows nested inside it.
|
|
234
|
+
* Reading it as a message would duplicate every word it contains, so those rows are
|
|
235
|
+
* skipped and the leaves underneath are read instead.
|
|
236
|
+
*
|
|
237
|
+
* The leave-one-out filter below then drops any row that merely encloses another row,
|
|
238
|
+
* which covers grouping the kinds do not name.
|
|
239
|
+
*/
|
|
240
|
+
const FLOW_GROUP_MARKER = "[data-step-process], [data-turn-process-member]";
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Classes that mark UI chrome rather than message text.
|
|
244
|
+
*
|
|
245
|
+
* This exists because of a real defect: the "加载更早" control sits inside the
|
|
246
|
+
* scrolling column next to the messages, and a structural walk with no notion of the
|
|
247
|
+
* host's markup read it as a message and created a dictionary entry from a button.
|
|
248
|
+
* `data-slot` is the general answer, but a control that is not wrapped in one still
|
|
249
|
+
* has to be excluded, and the host names its controls in these classes.
|
|
250
|
+
*/
|
|
251
|
+
const CHROME_CLASS_PATTERN = /(?:^|_)(?:older|hint|openError|actions|endInfo|timeStart|timeEnd|toolbar|footer|triggerMenu|queueDock)(?:_|$)/;
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Whether an element is a UI control rather than part of a message.
|
|
255
|
+
* @param element - the element to test.
|
|
256
|
+
* @returns true when the element carries a chrome class.
|
|
257
|
+
*/
|
|
258
|
+
function isChrome(element) {
|
|
259
|
+
const className = typeof element?.getAttribute === "function" ? element.getAttribute("class") ?? "" : element?.className ?? "";
|
|
260
|
+
if (typeof className !== "string" || className === "") return false;
|
|
261
|
+
return className.split(/\s+/).some((token) => CHROME_CLASS_PATTERN.test(token));
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Child elements of one parent, or an empty list when the host offers no `children`.
|
|
266
|
+
*
|
|
267
|
+
* Everything here goes through this rather than touching `.children` directly, so a
|
|
268
|
+
* stand-in without it degrades to "no children" instead of throwing.
|
|
269
|
+
*
|
|
270
|
+
* @param element - the parent.
|
|
271
|
+
* @returns the element children.
|
|
272
|
+
*/
|
|
273
|
+
function childElements(element) {
|
|
274
|
+
return element === null || element === undefined ? [] : Array.from(element.children ?? []);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* The elements that each hold one message's rendered text.
|
|
279
|
+
*
|
|
280
|
+
* The row contract is the host's own (`{@link FLOW_ROW_SELECTOR}`), and it is used in
|
|
281
|
+
* preference to any structural guess: the shipped column also contains controls and
|
|
282
|
+
* grouping rows, and inferring "this is a message" from text shape read a button as a
|
|
283
|
+
* message. When the contract is unavailable — a stripped build, or a stand-in used by
|
|
284
|
+
* a test — the structural walk below is the fallback.
|
|
285
|
+
*
|
|
286
|
+
* @param root - the region element.
|
|
287
|
+
* @returns message elements in document order.
|
|
288
|
+
*/
|
|
289
|
+
function findMessageElements(root) {
|
|
290
|
+
if (root === null || root === undefined) return [];
|
|
291
|
+
if (typeof root.querySelectorAll === "function") {
|
|
292
|
+
const rows = flowRows(root);
|
|
293
|
+
// No rows means the contract is not present in this subtree — a stripped build, a
|
|
294
|
+
// stand-in without the attributes, or a transcript with no messages yet. The
|
|
295
|
+
// structural walk is then the only way to find text, and it cannot run on a real
|
|
296
|
+
// page whose rows were found.
|
|
297
|
+
if (rows.length > 0) return rows;
|
|
298
|
+
}
|
|
299
|
+
return structuralBlocks(root);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* The message text containers, taken from the host's flow-row contract.
|
|
304
|
+
* @param root - the region element.
|
|
305
|
+
* @returns message elements in document order.
|
|
306
|
+
*/
|
|
307
|
+
function flowRows(root) {
|
|
308
|
+
const rows = Array.from(root.querySelectorAll(FLOW_ROW_SELECTOR));
|
|
309
|
+
const claimed = new Set();
|
|
310
|
+
const textOf = (element) => collapse(element?.innerText ?? element?.textContent ?? "");
|
|
311
|
+
const included = [];
|
|
312
|
+
for (const row of rows) {
|
|
313
|
+
// A grouped row's text is its descendants' text; reading both duplicates it.
|
|
314
|
+
if (typeof row.matches === "function" && row.matches(FLOW_GROUP_MARKER)) continue;
|
|
315
|
+
// Leave-one-out: a row that encloses another row is a container, not a message.
|
|
316
|
+
if (rows.some((other) => other !== row && typeof row.contains === "function" && row.contains(other))) continue;
|
|
317
|
+
const content = typeof row.querySelector === "function" ? row.querySelector(FLOW_NODE_SELECTOR) ?? row : row;
|
|
318
|
+
if (isChrome(content) || isChrome(row)) continue;
|
|
319
|
+
const text = textOf(content);
|
|
320
|
+
if (text === "" || text.length > MAX_BLOCK_CHARS) continue;
|
|
321
|
+
included.push(content);
|
|
322
|
+
claimed.add(content);
|
|
323
|
+
}
|
|
324
|
+
// A message must not be read twice: the content container and the row can both be
|
|
325
|
+
// candidates when the host changes shape.
|
|
326
|
+
return included.filter((element) => !included.some((other) => other !== element && typeof other.contains === "function" && other.contains(element)));
|
|
327
|
+
void claimed;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* The fallback split, used only when the host's row contract is unavailable.
|
|
332
|
+
*
|
|
333
|
+
* - an element whose text sits in its own text nodes is a block;
|
|
334
|
+
* - a container with several prose children is descended into;
|
|
335
|
+
* - a single-wrapper chain is followed through;
|
|
336
|
+
* - the composer and anything at or beyond it is never entered, so the user's
|
|
337
|
+
* unsent draft is not read as a message.
|
|
338
|
+
*
|
|
339
|
+
* @param root - the region element.
|
|
340
|
+
* @returns message elements in document order.
|
|
341
|
+
*/
|
|
342
|
+
function structuralBlocks(root) {
|
|
343
|
+
const found = [];
|
|
344
|
+
/**
|
|
345
|
+
* Walk one element, collecting messages.
|
|
346
|
+
* @param element - the element to inspect.
|
|
347
|
+
* @param depth - current recursion depth.
|
|
348
|
+
*/
|
|
349
|
+
const walk = (element, depth) => {
|
|
350
|
+
if (element === null || element === undefined) return;
|
|
351
|
+
// Only elements can be message blocks: a text node has no box and no
|
|
352
|
+
// `getBoundingClientRect`, so the pointer path could not use it.
|
|
353
|
+
if (element.nodeType !== 1) return;
|
|
354
|
+
if (isComposer(element) || isChrome(element)) return;
|
|
355
|
+
const children = childElements(element);
|
|
356
|
+
const text = collapse(element.innerText ?? element.textContent ?? "");
|
|
357
|
+
if (text === "") return;
|
|
358
|
+
if (children.length === 0 || isTextOnly(element)) {
|
|
359
|
+
// Text in the element's own text nodes: this is one block.
|
|
360
|
+
if (text.length <= MAX_BLOCK_CHARS) found.push(element);
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
// A child counts as meaningful only when it holds something the reader could
|
|
364
|
+
// see. `textContent` is the structural test: `innerText` is already collapsed
|
|
365
|
+
// by the host and reports an empty string for an element that still holds
|
|
366
|
+
// whitespace and markup.
|
|
367
|
+
const meaningful = children.filter((child) => collapse(child.textContent ?? "") !== "" && !isComposer(child) && !isChrome(child));
|
|
368
|
+
if (meaningful.length === 0) return;
|
|
369
|
+
|
|
370
|
+
const containers = meaningful.filter((child) => isBlockLevel(child) || childElements(child).some((grandchild) => isBlockLevel(grandchild)));
|
|
371
|
+
|
|
372
|
+
if (depth >= 8) {
|
|
373
|
+
if (text.length <= MAX_BLOCK_CHARS) found.push(element);
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
if (containers.length > 1) {
|
|
377
|
+
// Several prose children and nothing of its own: this is the list.
|
|
378
|
+
for (const child of containers) walk(child, depth + 1);
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
381
|
+
if (containers.length === 1 && meaningful.length === 1) {
|
|
382
|
+
// A single-wrapper chain: the block boundary is inside it.
|
|
383
|
+
walk(containers[0], depth + 1);
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
386
|
+
// No prose child at all, or prose beside chrome with nothing of its own: read
|
|
387
|
+
// this subtree as one block rather than descending forever.
|
|
388
|
+
const blockText = children.some((child) => isComposer(child))
|
|
389
|
+
? collapse(meaningful.map((child) => child.innerText ?? child.textContent ?? "").join(" "))
|
|
390
|
+
: text;
|
|
391
|
+
if (blockText !== "" && blockText.length <= MAX_BLOCK_CHARS) found.push(element);
|
|
392
|
+
};
|
|
393
|
+
walk(root, 0);
|
|
394
|
+
return found;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* Whether an element holds text of its own, beside its child elements.
|
|
399
|
+
*
|
|
400
|
+
* A list container has only element children. A message row has text nodes next to
|
|
401
|
+
* its children — a preamble, or the whitespace the layout leaves between them — and
|
|
402
|
+
* that is the difference between "descend into the messages" and "this row is one
|
|
403
|
+
* message".
|
|
404
|
+
*
|
|
405
|
+
* @param element - the element to test.
|
|
406
|
+
* @returns true when the element has a non-blank text node child.
|
|
407
|
+
*/
|
|
408
|
+
function hasOwnTextNodes(element) {
|
|
409
|
+
for (const node of Array.from(element.childNodes ?? [])) {
|
|
410
|
+
if (node.nodeType === 3 && collapse(node.nodeValue ?? "") !== "") return true;
|
|
411
|
+
}
|
|
412
|
+
return false;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* Whether one node is the other or lives inside it.
|
|
417
|
+
*
|
|
418
|
+
* Written as a parent walk rather than `contains`, because a caller may hold a
|
|
419
|
+
* detached stand-in where `contains` is absent, and an element does not contain a
|
|
420
|
+
* text node through `contains` on every implementation.
|
|
421
|
+
*
|
|
422
|
+
* @param ancestor - the element to test against.
|
|
423
|
+
* @param node - the candidate descendant.
|
|
424
|
+
* @returns true when `node` is inside `ancestor`.
|
|
425
|
+
*/
|
|
426
|
+
function containsNode(ancestor, node) {
|
|
427
|
+
let at = node;
|
|
428
|
+
while (at !== null && at !== undefined) {
|
|
429
|
+
if (at === ancestor) return true;
|
|
430
|
+
at = at.parentElement ?? at.parentNode;
|
|
431
|
+
}
|
|
432
|
+
return false;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* The plugin-owned marker on a node or its ancestors, if any.
|
|
437
|
+
*
|
|
438
|
+
* The marker is an attribute rather than a class name because the host is free to
|
|
439
|
+
* restyle the plugin's own surfaces while the marker is what the pointer logic needs.
|
|
440
|
+
*
|
|
441
|
+
* @param node - the event target.
|
|
442
|
+
* @returns the marker value (`affordance`, `popup`, `panel`, …) or null.
|
|
443
|
+
*/
|
|
444
|
+
function ownMarker(node) {
|
|
445
|
+
let at = node;
|
|
446
|
+
while (at !== null && at !== undefined) {
|
|
447
|
+
if (at.nodeType === 1 && typeof at.getAttribute === "function") {
|
|
448
|
+
const marker = at.getAttribute("data-term-dictionary");
|
|
449
|
+
if (typeof marker === "string" && marker !== "") return marker;
|
|
450
|
+
}
|
|
451
|
+
at = at.parentElement ?? at.parentNode;
|
|
452
|
+
}
|
|
453
|
+
return null;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/**
|
|
457
|
+
* Whether a node is (or is inside) the plugin's own selection affordance.
|
|
458
|
+
*
|
|
459
|
+
* The affordance lives in the overlay layer rather than in the transcript, so it is
|
|
460
|
+
* outside the region; and a press on it must be distinguished from a press anywhere
|
|
461
|
+
* else on the page, because only the latter means "the selection is being abandoned".
|
|
462
|
+
*
|
|
463
|
+
* @param node - the event target.
|
|
464
|
+
* @returns true when the node belongs to the affordance.
|
|
465
|
+
*/
|
|
466
|
+
function isOwnAffordance(node) {
|
|
467
|
+
return ownMarker(node) === "affordance";
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/**
|
|
471
|
+
* Whether a node belongs to any surface the plugin owns and paints.
|
|
472
|
+
*
|
|
473
|
+
* This is what a press outside the plugin's own UI is measured against. The explanation
|
|
474
|
+
* popup is one of these surfaces, and it has no path that closes when the user presses
|
|
475
|
+
* anywhere else — so it stayed open over the transcript, and every pointer that landed
|
|
476
|
+
* on it was then blocked by {@link BLOCKED_SELECTOR}, which lists the same marker.
|
|
477
|
+
*
|
|
478
|
+
* @param node - the event target.
|
|
479
|
+
* @returns true when the node is the plugin's own.
|
|
480
|
+
*/
|
|
481
|
+
function isOwnSurface(node) {
|
|
482
|
+
return ownMarker(node) !== null;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/**
|
|
486
|
+
* Whether an element is the composer seat or lives inside it.
|
|
487
|
+
*
|
|
488
|
+
* A structural walk rather than `closest`, because the seat is identified by an
|
|
489
|
+
* attribute and a caller may hold a detached stand-in (a test fixture) where
|
|
490
|
+
* `closest` does not exist.
|
|
491
|
+
*
|
|
492
|
+
* @param element - a node.
|
|
493
|
+
* @returns true when the node belongs to the input area.
|
|
494
|
+
*/
|
|
495
|
+
function isComposer(element) {
|
|
496
|
+
let at = element;
|
|
497
|
+
while (at !== null && at !== undefined) {
|
|
498
|
+
if (at.nodeType === 1) {
|
|
499
|
+
if (typeof at.hasAttribute === "function" && at.hasAttribute("data-composer-seat")) return true;
|
|
500
|
+
if (typeof at.getAttribute === "function" && at.getAttribute("data-conversation-region") === "composer") return true;
|
|
501
|
+
}
|
|
502
|
+
at = at.parentElement ?? at.parentNode;
|
|
503
|
+
}
|
|
504
|
+
return false;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/**
|
|
508
|
+
* The text of one block, whitespace-collapsed exactly like {@link readRegion}.
|
|
509
|
+
* @param element - a block element.
|
|
510
|
+
* @returns its collapsed text.
|
|
511
|
+
*/
|
|
512
|
+
function readBlock(element) {
|
|
513
|
+
return collapse(element?.innerText ?? element?.textContent ?? "");
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
/**
|
|
517
|
+
* Which block a viewport point falls in, by hit-testing each block's box.
|
|
518
|
+
*
|
|
519
|
+
* Text offsets are deliberately not derived from `Range` lengths: collapsing
|
|
520
|
+
* whitespace makes a range length disagree with the text the detector scans, and
|
|
521
|
+
* a geometry test cannot drift. The offset is then the clamped difference between
|
|
522
|
+
* the pointer and the block's left edge, which only has to be good enough to pick
|
|
523
|
+
* the word under the pointer.
|
|
524
|
+
*
|
|
525
|
+
* @param blocks - blocks from {@link readRegion}.
|
|
526
|
+
* @param x - viewport x.
|
|
527
|
+
* @param y - viewport y.
|
|
528
|
+
* @returns `{ block, offset }`, or null when the point is outside every block.
|
|
529
|
+
*/
|
|
530
|
+
function blockAtPoint(blocks, x, y) {
|
|
531
|
+
let nearest = null;
|
|
532
|
+
for (const block of blocks) {
|
|
533
|
+
const rect = block.element.getBoundingClientRect();
|
|
534
|
+
if (rect.width <= 0 || rect.height <= 0) continue;
|
|
535
|
+
if (y >= rect.top && y <= rect.bottom) {
|
|
536
|
+
const offset = clampOffset(block.text, x - rect.left, rect);
|
|
537
|
+
return { block, offset };
|
|
538
|
+
}
|
|
539
|
+
const distance = y < rect.top ? rect.top - y : y - rect.bottom;
|
|
540
|
+
if (nearest === null || distance < nearest.distance) nearest = { block, distance };
|
|
541
|
+
}
|
|
542
|
+
// A point that is not inside any block resolves to NOTHING.
|
|
543
|
+
//
|
|
544
|
+
// This used to return the nearest block at offset 0, and that is what made a click
|
|
545
|
+
// on empty space below the transcript open an explanation for some unrelated term:
|
|
546
|
+
// the gap between two messages, the padding around them, and the blank area under
|
|
547
|
+
// the last one all resolved to "the closest message, first character". A click is
|
|
548
|
+
// only about a term when it lands on one.
|
|
549
|
+
void nearest;
|
|
550
|
+
return null;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
/**
|
|
554
|
+
* Approximate a character offset from a horizontal distance into a block.
|
|
555
|
+
* Only the fallback: a block laid out with a proportional font does not map
|
|
556
|
+
* distance to character count linearly, so the caret-derived offset is preferred
|
|
557
|
+
* whenever the browser offers one.
|
|
558
|
+
* @param text - the block's collapsed text.
|
|
559
|
+
* @param dx - distance from the block's left edge.
|
|
560
|
+
* @param rect - the block's box.
|
|
561
|
+
* @returns a clamped character offset.
|
|
562
|
+
*/
|
|
563
|
+
function clampOffset(text, dx, rect) {
|
|
564
|
+
const width = rect.width <= 0 ? 1 : rect.width;
|
|
565
|
+
const ratio = Math.max(0, Math.min(1, dx / width));
|
|
566
|
+
return Math.max(0, Math.min(text.length, Math.round(ratio * text.length)));
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
/**
|
|
570
|
+
* Whether a node must not be treated as transcript prose: a code block, a link, an
|
|
571
|
+
* editor, or anything inside the composer.
|
|
572
|
+
*
|
|
573
|
+
* Module-level rather than closure-local so {@link isProseBlock} can share it, and so
|
|
574
|
+
* the annotation layer asks the same question the collector does.
|
|
575
|
+
*
|
|
576
|
+
* @param node - a node.
|
|
577
|
+
* @returns true when the node is off limits.
|
|
578
|
+
*/
|
|
579
|
+
function isSkippedNode(node) {
|
|
580
|
+
const element = node?.nodeType === 1 ? node : node?.parentElement;
|
|
581
|
+
if (element === null || element === undefined) return false;
|
|
582
|
+
if (isComposer(element)) return true;
|
|
583
|
+
return typeof element.closest === "function" && element.closest(SKIP_SELECTOR) !== null;
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
/**
|
|
587
|
+
* Whether a node must not resolve a term at all: the composer, a link, an editor, or the
|
|
588
|
+
* plugin's own UI. Code is deliberately NOT here — see {@link BLOCKED_SELECTOR}.
|
|
589
|
+
* @param node - a node.
|
|
590
|
+
* @returns true when the node is off limits for the pointer gestures.
|
|
591
|
+
*/
|
|
592
|
+
function isBlockedNode(node) {
|
|
593
|
+
const element = node?.nodeType === 1 ? node : node?.parentElement;
|
|
594
|
+
if (element === null || element === undefined) return false;
|
|
595
|
+
if (isComposer(element)) return true;
|
|
596
|
+
return typeof element.closest === "function" && element.closest(BLOCKED_SELECTOR) !== null;
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
/**
|
|
600
|
+
* Whether a node's text is code or tool output.
|
|
601
|
+
* @param node - a node.
|
|
602
|
+
* @returns true when the node sits inside `pre` or `code`.
|
|
603
|
+
*/
|
|
604
|
+
function isDataNode(node) {
|
|
605
|
+
const element = node?.nodeType === 1 ? node : node?.parentElement;
|
|
606
|
+
if (element === null || element === undefined) return false;
|
|
607
|
+
return typeof element.closest === "function" && element.closest(DATA_SELECTOR) !== null;
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
/**
|
|
611
|
+
* Whether a block is prose rather than code, a link or chrome.
|
|
612
|
+
*
|
|
613
|
+
* This is the gate the collector was missing, and the reason the dictionary filled up
|
|
614
|
+
* with junk: `collect()` scanned EVERY block `readRegion` produced, and a transcript's
|
|
615
|
+
* code blocks and tool output are blocks too. One session of shell commands therefore
|
|
616
|
+
* collected `Invoke-WebRequest`, `StatusCode`, `StartTime` and fourteen more
|
|
617
|
+
* identifiers, none of which anyone wanted a gloss for — exactly the "do not let the
|
|
618
|
+
* dictionary get bulky" failure this plugin is supposed to avoid.
|
|
619
|
+
*
|
|
620
|
+
* A block is prose when at least one of its text runs sits outside a skipped
|
|
621
|
+
* container. That keeps a paragraph that merely CONTAINS an inline code span (the
|
|
622
|
+
* common case in a technical reply) while dropping a block that is entirely code.
|
|
623
|
+
*
|
|
624
|
+
* @param block - a block from {@link readRegion}, or any element.
|
|
625
|
+
* @returns true when the block carries prose.
|
|
626
|
+
*/
|
|
627
|
+
function isProseBlock(block) {
|
|
628
|
+
const element = block?.element ?? block;
|
|
629
|
+
if (element === null || element === undefined || element.nodeType !== 1) return false;
|
|
630
|
+
if (isSkippedNode(element)) return false;
|
|
631
|
+
const runs = textRuns(element).runs;
|
|
632
|
+
if (runs.length === 0) return false;
|
|
633
|
+
return runs.some((run) => !isSkippedNode(run.node));
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
/**
|
|
637
|
+
* Hard-clamp a context string, for the paths where no block could be located.
|
|
638
|
+
* @param text - the raw text.
|
|
639
|
+
* @returns one bounded, single-lined window.
|
|
640
|
+
*/
|
|
641
|
+
function clampContext(text) {
|
|
642
|
+
if (typeof text !== "string") return "";
|
|
643
|
+
const single = text.replace(/\s+/g, " ").trim();
|
|
644
|
+
return single.length <= MAX_CONTEXT_CHARS ? single : `${single.slice(0, MAX_CONTEXT_CHARS)}…`;
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
/** Whether an element starts a new line of text. */
|
|
648
|
+
function isBlockElement(element) {
|
|
649
|
+
return element !== null && element !== undefined && element.nodeType === 1 && BLOCK_TAGS.has(element.tagName);
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
/**
|
|
653
|
+
* Split a block's rendered text into runs, joining them exactly the way
|
|
654
|
+
* {@link readRegion} joins blocks: one space between lines and between inline
|
|
655
|
+
* pieces whose source text contained whitespace, nothing between adjacent pieces.
|
|
656
|
+
* Concatenating the runs reproduces the block's collapsed text, and each run
|
|
657
|
+
* remembers which DOM text node it came from, so a caret can be mapped back to an
|
|
658
|
+
* offset.
|
|
659
|
+
*
|
|
660
|
+
* @param block - the block element.
|
|
661
|
+
* @returns `{ runs, text }` where each run is `{ node, start, end }`.
|
|
662
|
+
*/
|
|
663
|
+
function textRuns(block) {
|
|
664
|
+
const runs = [];
|
|
665
|
+
let text = "";
|
|
666
|
+
let pendingSpace = false;
|
|
667
|
+
|
|
668
|
+
/**
|
|
669
|
+
* Append one text node's contribution.
|
|
670
|
+
* @param node - a text node.
|
|
671
|
+
*/
|
|
672
|
+
const visit = (node) => {
|
|
673
|
+
const raw = typeof node.nodeValue === "string" ? node.nodeValue : "";
|
|
674
|
+
if (raw === "") return;
|
|
675
|
+
if (raw.trim() === "") {
|
|
676
|
+
pendingSpace = text !== "";
|
|
677
|
+
return;
|
|
678
|
+
}
|
|
679
|
+
const leading = /^\s/.test(raw);
|
|
680
|
+
const trailing = /\s$/.test(raw);
|
|
681
|
+
const body = raw.replace(/\s+/g, " ").trim();
|
|
682
|
+
if ((pendingSpace || leading) && text !== "" && !text.endsWith(" ")) text += " ";
|
|
683
|
+
pendingSpace = false;
|
|
684
|
+
const start = text.length;
|
|
685
|
+
text += body;
|
|
686
|
+
runs.push({ node, start, end: text.length, body, leading: leading ? 1 : 0 });
|
|
687
|
+
if (trailing) pendingSpace = true;
|
|
688
|
+
};
|
|
689
|
+
|
|
690
|
+
/**
|
|
691
|
+
* Walk the block's descendants in document order.
|
|
692
|
+
* @param element - the element to walk.
|
|
693
|
+
*/
|
|
694
|
+
const walk = (element) => {
|
|
695
|
+
for (const child of Array.from(element.childNodes ?? [])) {
|
|
696
|
+
if (child.nodeType === 3) {
|
|
697
|
+
visit(child);
|
|
698
|
+
continue;
|
|
699
|
+
}
|
|
700
|
+
if (child.nodeType !== 1) continue;
|
|
701
|
+
if (isBlockElement(child)) pendingSpace = text !== "";
|
|
702
|
+
walk(child);
|
|
703
|
+
}
|
|
704
|
+
};
|
|
705
|
+
walk(block);
|
|
706
|
+
return { runs, text };
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
/**
|
|
710
|
+
* Map a caret inside a block to an offset in the block's collapsed text.
|
|
711
|
+
*
|
|
712
|
+
* The walk mirrors {@link textRuns} exactly, so the offset is a real index into
|
|
713
|
+
* the block's `text` rather than a guess. When the caret lands on an element
|
|
714
|
+
* rather than a character, the offset is the start of the run that follows.
|
|
715
|
+
*
|
|
716
|
+
* @param block - the block element.
|
|
717
|
+
* @param container - the caret's container node.
|
|
718
|
+
* @param caret - the caret's offset inside that container.
|
|
719
|
+
* @param runs - the runs from {@link textRuns}.
|
|
720
|
+
* @returns the collapsed-text offset, or null when the caret is outside the block.
|
|
721
|
+
*/
|
|
722
|
+
function offsetFromCaret(block, container, caret, runs) {
|
|
723
|
+
if (container === null || container === undefined) return null;
|
|
724
|
+
/** The run produced by one node, if it produced one. */
|
|
725
|
+
const runOf = (node) => runs.find((candidate) => candidate.node === node);
|
|
726
|
+
/** The first run at or after one element in document order. */
|
|
727
|
+
const firstRunIn = (node) => {
|
|
728
|
+
if (node === null || node === undefined) return undefined;
|
|
729
|
+
return runs.find((candidate) => node === candidate.node || (typeof node.contains === "function" && node.contains(candidate.node)));
|
|
730
|
+
};
|
|
731
|
+
if (container.nodeType === 3) {
|
|
732
|
+
const run = runOf(container);
|
|
733
|
+
if (run === undefined) return null;
|
|
734
|
+
// Count this node's collapsed characters before the caret: the same
|
|
735
|
+
// whitespace collapsing the run applied, so the count lands on a real index.
|
|
736
|
+
const raw = container.nodeValue ?? "";
|
|
737
|
+
const before = raw.slice(0, Math.max(0, Math.min(caret, raw.length)));
|
|
738
|
+
let counted = 0;
|
|
739
|
+
let inSpace = false;
|
|
740
|
+
for (let at = 0; at < before.length; at++) {
|
|
741
|
+
const character = before[at];
|
|
742
|
+
if (/\s/.test(character)) {
|
|
743
|
+
if (!inSpace) {
|
|
744
|
+
inSpace = true;
|
|
745
|
+
counted++;
|
|
746
|
+
}
|
|
747
|
+
continue;
|
|
748
|
+
}
|
|
749
|
+
inSpace = false;
|
|
750
|
+
counted++;
|
|
751
|
+
}
|
|
752
|
+
return Math.max(run.start, Math.min(run.end, run.start + counted));
|
|
753
|
+
}
|
|
754
|
+
if (container.nodeType === 1) {
|
|
755
|
+
const children = Array.from(container.childNodes ?? []);
|
|
756
|
+
const following = children[caret];
|
|
757
|
+
if (following === undefined) {
|
|
758
|
+
// Past the last child: the previous run's end is the closest real offset.
|
|
759
|
+
const containing = runs.filter((candidate) => typeof container.contains === "function" && container.contains(candidate.node));
|
|
760
|
+
const last = containing[containing.length - 1];
|
|
761
|
+
return last === undefined ? null : last.end;
|
|
762
|
+
}
|
|
763
|
+
const run = firstRunIn(following);
|
|
764
|
+
return run === undefined ? null : run.start;
|
|
765
|
+
}
|
|
766
|
+
return null;
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
/**
|
|
770
|
+
* The word around one offset, extended to the word runs the detector uses.
|
|
771
|
+
* @param text - the containing text.
|
|
772
|
+
* @param offset - a character offset.
|
|
773
|
+
* @param isWordCharacter - character classifier from the core module.
|
|
774
|
+
* @returns `{ start, end }` covering the word, or null when there is none.
|
|
775
|
+
*/
|
|
776
|
+
function wordAt(text, offset, isWordCharacter) {
|
|
777
|
+
if (typeof text !== "string" || text.length === 0) return null;
|
|
778
|
+
let at = Math.max(0, Math.min(offset, text.length - 1));
|
|
779
|
+
if (!isWordCharacter(text[at]) && at > 0 && isWordCharacter(text[at - 1])) at -= 1;
|
|
780
|
+
if (!isWordCharacter(text[at])) return null;
|
|
781
|
+
let start = at;
|
|
782
|
+
while (start > 0 && isWordCharacter(text[start - 1])) start--;
|
|
783
|
+
let end = at + 1;
|
|
784
|
+
while (end < text.length && isWordCharacter(text[end])) end++;
|
|
785
|
+
return { start, end };
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
/**
|
|
789
|
+
* Build the interaction layer for one page.
|
|
790
|
+
*
|
|
791
|
+
* @param options - `detector`, `store`, `onExplain`, `onActivate`,
|
|
792
|
+
* `onPressOutside`, `onToast`, `onSettled`, `now`, `logger`, `diagnose`.
|
|
793
|
+
* `onExplain(hit, point)` opens the popup for a term the dictionary has never
|
|
794
|
+
* collected; `onActivate(hit, point)` is the click on a term the detector already
|
|
795
|
+
* knows, which the shell turns into "open that entry";
|
|
796
|
+
* `onPressOutside()` reports a press on something that is neither
|
|
797
|
+
* the transcript nor one of the plugin's own surfaces; `onSettled(region)` fires after
|
|
798
|
+
* each settled-transcript pass so the annotation layer can refresh against the same DOM
|
|
799
|
+
* the layer just read; `diagnose(kind, record)` receives `click` and
|
|
800
|
+
* `rejected` records, each drawn from its own budget.
|
|
801
|
+
* @returns the layer's lifecycle.
|
|
802
|
+
*/
|
|
803
|
+
function createInteractionLayer(options) {
|
|
804
|
+
const detector = options.detector;
|
|
805
|
+
const store = options.store;
|
|
806
|
+
const onExplain = typeof options.onExplain === "function" ? options.onExplain : () => {};
|
|
807
|
+
const onActivate = typeof options.onActivate === "function" ? options.onActivate : onExplain;
|
|
808
|
+
const onPressOutside = typeof options.onPressOutside === "function" ? options.onPressOutside : null;
|
|
809
|
+
const onSettled = typeof options.onSettled === "function" ? options.onSettled : () => {};
|
|
810
|
+
const onToast = typeof options.onToast === "function" ? options.onToast : () => {};
|
|
811
|
+
const logger = options.logger;
|
|
812
|
+
const doc = options.document ?? globalThis.document;
|
|
813
|
+
const win = options.window ?? globalThis.window;
|
|
814
|
+
|
|
815
|
+
let region = null;
|
|
816
|
+
let scrollRoot = null;
|
|
817
|
+
let observer = null;
|
|
818
|
+
/**
|
|
819
|
+
* The id of the conversation last seen on screen, empty until one is.
|
|
820
|
+
*
|
|
821
|
+
* Kept here rather than read on demand because the panel and the transcript never share the main
|
|
822
|
+
* region: by the time a panel button wants the id, there is usually no transcript left to read it
|
|
823
|
+
* from. See {@link rememberSession}.
|
|
824
|
+
*/
|
|
825
|
+
let lastSessionId = "";
|
|
826
|
+
let settleTimer = null;
|
|
827
|
+
let selectionTimer = null;
|
|
828
|
+
/**
|
|
829
|
+
* Whether automatic collection runs at all.
|
|
830
|
+
*
|
|
831
|
+
* A live flag rather than a construction option: the panel's switch flips while the
|
|
832
|
+
* layer is attached, and re-creating the layer to change a preference would drop the
|
|
833
|
+
* transcript fingerprints and re-collect the entire conversation.
|
|
834
|
+
*/
|
|
835
|
+
let autoCollectEnabled = true;
|
|
836
|
+
/** Creation timestamps inside {@link AUTO_BUDGET_WINDOW_MS}, oldest first. */
|
|
837
|
+
const recentCreations = [];
|
|
838
|
+
/**
|
|
839
|
+
* How many diagnostic probes may still be reported, per gesture kind.
|
|
840
|
+
*
|
|
841
|
+
* Per-kind budgets rather than one shared counter: a single budget of 3 was spent by
|
|
842
|
+
* the first three clicks, so a page whose other gestures were already dead had also
|
|
843
|
+
* used up the only channel that could say so — and the rejection path printed nothing
|
|
844
|
+
* at all, because it returned before the report. Silence is the thing this layer has
|
|
845
|
+
* to make speakable.
|
|
846
|
+
*/
|
|
847
|
+
let clickBudget = 5;
|
|
848
|
+
let rejectBudget = 3;
|
|
849
|
+
/** How many times the layer adopted a conversation body from a gesture target. */
|
|
850
|
+
let regionAdoptions = 0;
|
|
851
|
+
/**
|
|
852
|
+
* Why each point-to-term resolution failed, by stage.
|
|
853
|
+
*
|
|
854
|
+
* Painting the marking uses the text runs and answers nothing about geometry, so `hl` can be
|
|
855
|
+
* healthy at the same moment every hit is refused. This is the one readout that names the
|
|
856
|
+
* refusing stage, and it costs a counter increment per refusal.
|
|
857
|
+
*/
|
|
858
|
+
const hitStages = {
|
|
859
|
+
"no-caret": 0,
|
|
860
|
+
"blocked-caret": 0,
|
|
861
|
+
"no-block": 0,
|
|
862
|
+
"no-over": 0,
|
|
863
|
+
"over-outside": 0,
|
|
864
|
+
"caret-outside": 0,
|
|
865
|
+
"code-unknown": 0,
|
|
866
|
+
"no-word": 0,
|
|
867
|
+
"not-candidate": 0,
|
|
868
|
+
ok: 0
|
|
869
|
+
};
|
|
870
|
+
|
|
871
|
+
/** Count a hit-test refusal by stage, and return null so the call site can `return` it. */
|
|
872
|
+
function refuse(reason) {
|
|
873
|
+
hitStages[reason] = (hitStages[reason] ?? 0) + 1;
|
|
874
|
+
return null;
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
/** Count a resolved hit, so a stage tally is readable as a fraction rather than a list. */
|
|
878
|
+
function accept(hit) {
|
|
879
|
+
hitStages.ok++;
|
|
880
|
+
return hit;
|
|
881
|
+
}
|
|
882
|
+
let clickSeen = 0;
|
|
883
|
+
let attached = false;
|
|
884
|
+
let pendingSelection = null;
|
|
885
|
+
let snapshot = { blocks: [], text: "", offsets: [] };
|
|
886
|
+
const seen = new Map();
|
|
887
|
+
const disposers = [];
|
|
888
|
+
|
|
889
|
+
/** The detector generation, replaced when the dictionary changes. */
|
|
890
|
+
let activeDetector = detector;
|
|
891
|
+
|
|
892
|
+
/** Test seam: when set, every sighting reports this answer instead of the store's. */
|
|
893
|
+
let forcedSightingAnswer = null;
|
|
894
|
+
|
|
895
|
+
/**
|
|
896
|
+
* Test seam: overrides the "is this word rare enough to record by itself?" decision.
|
|
897
|
+
* It narrows a policy that is otherwise fixed, and it cannot make the layer record a
|
|
898
|
+
* term the glossary already explains — the checks above still run first.
|
|
899
|
+
*/
|
|
900
|
+
let autoCreateFilter = null;
|
|
901
|
+
|
|
902
|
+
/** Ask the store to record one sighting, honouring the test seam. */
|
|
903
|
+
function noteSighting(sighting) {
|
|
904
|
+
if (forcedSightingAnswer !== null) return Promise.resolve(forcedSightingAnswer);
|
|
905
|
+
return Promise.resolve(store?.noteSighting?.(sighting) ?? null);
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
/**
|
|
909
|
+
* Swap in a detector built against the current dictionary. Called after every
|
|
910
|
+
* dictionary change, so highlighting picks up a newly created entry.
|
|
911
|
+
* @param next - the replacement detector.
|
|
912
|
+
*/
|
|
913
|
+
function setDetector(next) {
|
|
914
|
+
activeDetector = next;
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
/** Report a caught failure without breaking the page. */
|
|
918
|
+
function warn(message, error) {
|
|
919
|
+
logger?.warn?.(`term-dictionary: ${message}: ${error instanceof Error ? error.message : String(error)}`);
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
/** Add a document listener and remember its removal. */
|
|
923
|
+
function listen(target, type, handler, opts) {
|
|
924
|
+
if (target === null || target === undefined) return;
|
|
925
|
+
target.addEventListener(type, handler, opts);
|
|
926
|
+
disposers.push(() => target.removeEventListener(type, handler, opts));
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
/**
|
|
930
|
+
* Whether a node must not be treated as transcript prose: a code block, a
|
|
931
|
+
* link, an editor, or anything inside the composer.
|
|
932
|
+
* @param node - a node.
|
|
933
|
+
* @returns true when the node is off limits.
|
|
934
|
+
*/
|
|
935
|
+
function isSkipped(node) {
|
|
936
|
+
return isSkippedNode(node);
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
/** Resolve the caret under a point through either browser API. */
|
|
940
|
+
function caretAtPoint(x, y) {
|
|
941
|
+
if (typeof doc?.caretRangeFromPoint === "function") {
|
|
942
|
+
const range = doc.caretRangeFromPoint(x, y);
|
|
943
|
+
if (range !== null && range !== undefined) return range;
|
|
944
|
+
}
|
|
945
|
+
if (typeof doc?.caretPositionFromPoint === "function") {
|
|
946
|
+
const position = doc.caretPositionFromPoint(x, y);
|
|
947
|
+
if (position !== null && position !== undefined && position.offsetNode !== null) {
|
|
948
|
+
const range = doc.createRange();
|
|
949
|
+
range.setStart(position.offsetNode, position.offset);
|
|
950
|
+
range.collapse(true);
|
|
951
|
+
return range;
|
|
952
|
+
}
|
|
953
|
+
}
|
|
954
|
+
return null;
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
/**
|
|
958
|
+
* Resolve a pointer position to a term occurrence in the transcript.
|
|
959
|
+
* @param x - viewport x.
|
|
960
|
+
* @param y - viewport y.
|
|
961
|
+
* @returns `{ term, key, context, known }`, or null.
|
|
962
|
+
*/
|
|
963
|
+
function termAtPoint(x, y) {
|
|
964
|
+
const caret = caretAtPoint(x, y);
|
|
965
|
+
if (caret === null) return refuse("no-caret");
|
|
966
|
+
if (isBlockedNode(caret.startContainer)) return refuse("blocked-caret");
|
|
967
|
+
// A hit inside code may be explained, never created. See BLOCKED_SELECTOR.
|
|
968
|
+
const inData = isDataNode(caret.startContainer);
|
|
969
|
+
// The snapshot is refreshed on demand so the very first click works: `attach`
|
|
970
|
+
// binds the region but the settle pass that fills the snapshot runs on a timer.
|
|
971
|
+
if (snapshot.blocks.length === 0) refreshSnapshot();
|
|
972
|
+
const hit = blockAtPoint(snapshot.blocks, x, y);
|
|
973
|
+
if (hit === null) return refuse("no-block");
|
|
974
|
+
// The point must be over the transcript — not over something floating above it, such
|
|
975
|
+
// as the plugin's own popup or another panel.
|
|
976
|
+
//
|
|
977
|
+
// This used to demand that the element under the pointer be a DESCENDANT of the
|
|
978
|
+
// block, which is a different and far stricter question. Any sibling element the
|
|
979
|
+
// host draws over a message — a hover toolbar, a selection layer, a sticky row —
|
|
980
|
+
// made the element under the pointer a non-descendant, and this returned null for
|
|
981
|
+
// every hover and every click in that message. The check below is the one that
|
|
982
|
+
// answers the real question precisely, because it asks about the CARET's node.
|
|
983
|
+
if (typeof doc?.elementFromPoint === "function") {
|
|
984
|
+
const over = doc.elementFromPoint(x, y);
|
|
985
|
+
if (over === null || over === undefined) return refuse("no-over");
|
|
986
|
+
if (region !== null && !containsNode(region, over) && !containsNode(hit.block.element, over)) return refuse("over-outside");
|
|
987
|
+
}
|
|
988
|
+
// And the caret's own node must be inside that block, which catches a hit whose
|
|
989
|
+
// box overlaps a neighbouring message's text — and a point in the padding beside a
|
|
990
|
+
// message, where the caret API reports the nearest text position instead.
|
|
991
|
+
if (!containsNode(hit.block.element, caret.startContainer)) return refuse("caret-outside");
|
|
992
|
+
// The caret gives the exact character the pointer is over; the block's box cannot,
|
|
993
|
+
// because a proportional font does not map distance to character count.
|
|
994
|
+
//
|
|
995
|
+
// The caret offset and the scanned text must come from the SAME walk. This used to
|
|
996
|
+
// scan `hit.block.text` (the block's `innerText`, whitespace-collapsed) while the
|
|
997
|
+
// offset came from `textRuns`, and it kept the exact offset only when the two
|
|
998
|
+
// strings happened to be equal. They are always equal for a single text node — and
|
|
999
|
+
// therefore always equal in the tests — but a real reply has nested elements and
|
|
1000
|
+
// hidden children, where `innerText` and the run walk legitimately differ. In that
|
|
1001
|
+
// case the code threw the caret offset away and used `hit.offset`, a GEOMETRIC
|
|
1002
|
+
// estimate from the pointer's distance to the block's left edge. On a paragraph of
|
|
1003
|
+
// more than one line that estimate is not approximate but systematically wrong: a
|
|
1004
|
+
// point at 20% of the width on the fifth line of twelve is about 40% of the way
|
|
1005
|
+
// through the text, not 20%. The resolved offset landed on unrelated words, so over
|
|
1006
|
+
// a real multi-line reply hovering found no term and clicking either did nothing or
|
|
1007
|
+
// offered to create a word that was already in the dictionary.
|
|
1008
|
+
//
|
|
1009
|
+
// Scanning the run text and using the run offset removes the disagreement rather
|
|
1010
|
+
// than papering over it. `block.text` stays as the fallback for the one case the
|
|
1011
|
+
// caret API genuinely cannot help with: no run for the caret's node.
|
|
1012
|
+
const runs = textRuns(hit.block.element);
|
|
1013
|
+
const exact = offsetFromCaret(hit.block.element, caret.startContainer, caret.startOffset, runs.runs);
|
|
1014
|
+
const scanText = exact === null ? hit.block.text : runs.text;
|
|
1015
|
+
const offset = exact === null ? hit.offset : Math.max(0, Math.min(scanText.length, exact));
|
|
1016
|
+
const found = activeDetector.scan(scanText, { includeCandidates: true });
|
|
1017
|
+
// Prefer the occurrence containing the caret's offset; among overlapping
|
|
1018
|
+
// candidates the longest match wins, which is the phrase rather than a word
|
|
1019
|
+
// inside it.
|
|
1020
|
+
let best = null;
|
|
1021
|
+
for (const occurrence of found) {
|
|
1022
|
+
if (offset < occurrence.start || offset > occurrence.end) continue;
|
|
1023
|
+
if (best === null || occurrence.end - occurrence.start > best.end - best.start) best = occurrence;
|
|
1024
|
+
}
|
|
1025
|
+
// Inside code, only a term the dictionary already holds resolves. An unknown one is
|
|
1026
|
+
// never offered there: an identifier in a code span is data, and offering to define it
|
|
1027
|
+
// is the bulk-import behaviour to avoid.
|
|
1028
|
+
if (inData && (best === null || best.known !== true)) return refuse("code-unknown");
|
|
1029
|
+
if (best === null) {
|
|
1030
|
+
// Only a word that could plausibly be terminology gets the create-entry
|
|
1031
|
+
// affordance; otherwise a click on ordinary prose would offer to define it.
|
|
1032
|
+
const word = wordAt(scanText, offset, options.isWordCharacter);
|
|
1033
|
+
if (word === null) return refuse("no-word");
|
|
1034
|
+
const term = scanText.slice(word.start, word.end);
|
|
1035
|
+
if (term.trim() === "" || term.length > MAX_SELECTION_CHARS) return refuse("no-word");
|
|
1036
|
+
if (typeof activeDetector.isCandidateWord === "function" && !activeDetector.isCandidateWord(term)) return refuse("not-candidate");
|
|
1037
|
+
return accept({ term, key: normalizeTerm(term), context: contextAround(scanText, word.start, word.end), known: false });
|
|
1038
|
+
}
|
|
1039
|
+
return accept({ term: best.term, key: best.key, context: contextAround(scanText, best.start, best.end), known: best.known });
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
/**
|
|
1043
|
+
* Explain what the pointer pipeline sees at one point.
|
|
1044
|
+
*
|
|
1045
|
+
* A DIAGNOSTIC, not part of the feature. It reports every stage `termAtPoint` decides
|
|
1046
|
+
* on, so a page that resolves nothing can say why — which is the one thing the test
|
|
1047
|
+
* fixtures cannot tell us, because they have no real layout, no `elementFromPoint`, and
|
|
1048
|
+
* single-text-node blocks.
|
|
1049
|
+
*
|
|
1050
|
+
* @param x - viewport x.
|
|
1051
|
+
* @param y - viewport y.
|
|
1052
|
+
* @returns a compact record of each stage.
|
|
1053
|
+
*/
|
|
1054
|
+
function probe(x, y) {
|
|
1055
|
+
const record = { at: [Math.round(x), Math.round(y)] };
|
|
1056
|
+
try {
|
|
1057
|
+
record.region = region === null ? "null" : `${region.nodeName}[${(typeof region.getAttribute === "function" ? region.getAttribute("data-conversation-region") : "") ?? "?"}]`;
|
|
1058
|
+
record.scroll = scrollRoot === null ? "null" : scrollRoot.nodeName;
|
|
1059
|
+
record.caretApi =
|
|
1060
|
+
typeof doc?.caretRangeFromPoint === "function" ? "caretRangeFromPoint" : typeof doc?.caretPositionFromPoint === "function" ? "caretPositionFromPoint" : "none";
|
|
1061
|
+
const caret = caretAtPoint(x, y);
|
|
1062
|
+
if (caret === null) {
|
|
1063
|
+
record.caret = "null";
|
|
1064
|
+
} else {
|
|
1065
|
+
const container = caret.startContainer;
|
|
1066
|
+
record.caret = container?.nodeType === 3 ? `text@${caret.startOffset}/${container.nodeValue?.length ?? -1}` : `el:${container?.nodeName}`;
|
|
1067
|
+
record.caretSkipped = isSkipped(container);
|
|
1068
|
+
}
|
|
1069
|
+
record.blocks = snapshot.blocks.length;
|
|
1070
|
+
const hit = blockAtPoint(snapshot.blocks, x, y);
|
|
1071
|
+
if (hit === null) {
|
|
1072
|
+
record.block = "null";
|
|
1073
|
+
} else {
|
|
1074
|
+
const rect = hit.block.element.getBoundingClientRect();
|
|
1075
|
+
record.block = `${snapshot.blocks.indexOf(hit.block)}:${hit.block.element.nodeName}:top=${Math.round(rect.top)}:h=${Math.round(rect.height)}:w=${Math.round(rect.width)}:chars=${hit.block.text.length}`;
|
|
1076
|
+
const runs = textRuns(hit.block.element);
|
|
1077
|
+
record.runs = `${runs.runs.length} runs/${runs.text.length} chars`;
|
|
1078
|
+
}
|
|
1079
|
+
const over = typeof doc?.elementFromPoint === "function" ? doc.elementFromPoint(x, y) : null;
|
|
1080
|
+
record.over = over === null || over === undefined ? "none" : `${over.nodeName}.${String(over.className ?? "").slice(0, 20)}`;
|
|
1081
|
+
if (over !== null && over !== undefined) {
|
|
1082
|
+
record.overInRegion = containsNode(region, over);
|
|
1083
|
+
record.overInBlock = hit !== null && containsNode(hit.block.element, over);
|
|
1084
|
+
}
|
|
1085
|
+
if (hit !== null && caret !== null) record.caretInBlock = containsNode(hit.block.element, caret.startContainer);
|
|
1086
|
+
const resolved = termAtPoint(x, y);
|
|
1087
|
+
record.hit = resolved === null ? "null" : `${resolved.term}|${resolved.key}|${resolved.known === true ? "known" : "unknown"}`;
|
|
1088
|
+
} catch (error) {
|
|
1089
|
+
record.error = `${error instanceof Error ? error.message : String(error)}`.slice(0, 160);
|
|
1090
|
+
}
|
|
1091
|
+
return record;
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
/**
|
|
1095
|
+
* How many conversation bodies the document mounts right now.
|
|
1096
|
+
*
|
|
1097
|
+
* One copy of this count, because the rejection record and the heartbeat report have to
|
|
1098
|
+
* be comparable — two readings of two selectors would be two different facts about the
|
|
1099
|
+
* same page. Returns `null` rather than a guess when there is no document to ask.
|
|
1100
|
+
*
|
|
1101
|
+
* @returns the number of chat bodies, or null.
|
|
1102
|
+
*/
|
|
1103
|
+
function chatCount() {
|
|
1104
|
+
if (typeof doc?.querySelectorAll !== "function") return null;
|
|
1105
|
+
try {
|
|
1106
|
+
return doc.querySelectorAll(REGION_SELECTOR).length;
|
|
1107
|
+
} catch (error) {
|
|
1108
|
+
void error;
|
|
1109
|
+
return null;
|
|
1110
|
+
}
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
/**
|
|
1114
|
+
* Report one diagnostic record, bounded per gesture.
|
|
1115
|
+
*
|
|
1116
|
+
* The record is built by a factory so a spent budget costs nothing.
|
|
1117
|
+
*
|
|
1118
|
+
* @param kind - `click` or `rejected`, which selects the budget.
|
|
1119
|
+
* @param makeRecord - builds the record only if there is budget left.
|
|
1120
|
+
*/
|
|
1121
|
+
function report(kind, makeRecord) {
|
|
1122
|
+
if (typeof options.diagnose !== "function") return;
|
|
1123
|
+
if (kind === "click") {
|
|
1124
|
+
if (clickBudget <= 0) return;
|
|
1125
|
+
clickBudget--;
|
|
1126
|
+
} else {
|
|
1127
|
+
if (rejectBudget <= 0) return;
|
|
1128
|
+
rejectBudget--;
|
|
1129
|
+
}
|
|
1130
|
+
try {
|
|
1131
|
+
options.diagnose(kind, makeRecord());
|
|
1132
|
+
} catch (error) {
|
|
1133
|
+
warn("the diagnostic failed", error);
|
|
1134
|
+
}
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
/**
|
|
1138
|
+
* The conversation body a pointer target belongs to, adopting it if that is a change.
|
|
1139
|
+
*
|
|
1140
|
+
* `locate()` asks the document, and the document answers in document order. That is
|
|
1141
|
+
* not the same question as "the body the user is pointing at" once more than one is
|
|
1142
|
+
* mounted — the host tags an embedded conversation body with the same attribute — and
|
|
1143
|
+
* a pointer resting in a body the layer never adopted was indistinguishable from a
|
|
1144
|
+
* pointer that receives no events: hundreds of moves counted, none of them in region.
|
|
1145
|
+
*
|
|
1146
|
+
* @param target - the event target.
|
|
1147
|
+
* @returns the region the target lives in, or null when it lives in none.
|
|
1148
|
+
*/
|
|
1149
|
+
function regionForPointer(target) {
|
|
1150
|
+
if (region !== null && containsNode(region, target)) return region;
|
|
1151
|
+
const element = target?.nodeType === 1 ? target : target?.parentElement;
|
|
1152
|
+
const chat = typeof element?.closest === "function" ? element.closest(REGION_SELECTOR) : null;
|
|
1153
|
+
if (chat === null || chat === undefined) return null;
|
|
1154
|
+
// The region the POINTER is in beats the first one in document order: an embedded conversation
|
|
1155
|
+
// body carries the same attribute, so "the first chat region" can be a sub-session's.
|
|
1156
|
+
rememberSession(chat);
|
|
1157
|
+
if (bind(chat, doc?.querySelector?.(SCROLL_SELECTOR) ?? null)) regionAdoptions++;
|
|
1158
|
+
return region;
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
/**
|
|
1162
|
+
* Remember which conversation is on screen.
|
|
1163
|
+
*
|
|
1164
|
+
* Read HERE, while there is a transcript to read it from, and kept afterwards: the panel takes the
|
|
1165
|
+
* main region, so the conversation body is very often unmounted at the moment a panel button needs
|
|
1166
|
+
* the id. The attribute is the host's own (`data-conversation-session`, see the notes on the DOM),
|
|
1167
|
+
* and the periodic locate is what keeps it current as the user moves between sessions.
|
|
1168
|
+
*
|
|
1169
|
+
* @param element - a region element, or null when none is mounted.
|
|
1170
|
+
*/
|
|
1171
|
+
function rememberSession(element) {
|
|
1172
|
+
const id = typeof element?.getAttribute === "function" ? element.getAttribute("data-conversation-session") : null;
|
|
1173
|
+
if (typeof id === "string" && id !== "") lastSessionId = id;
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
/** Recompute the cached transcript snapshot, tolerating an unmounted region. */
|
|
1177
|
+
function refreshSnapshot() {
|
|
1178
|
+
snapshot = region === null || region === undefined ? { blocks: [], text: "", offsets: [] } : readRegion(region);
|
|
1179
|
+
return snapshot;
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
/** Hide the selection affordance. */
|
|
1183
|
+
function clearSelectionAffordance() {
|
|
1184
|
+
pendingSelection = null;
|
|
1185
|
+
if (typeof options.onSelection === "function") options.onSelection(null);
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
/** React to a settled text selection inside a reply. */
|
|
1189
|
+
function onSelectionChange() {
|
|
1190
|
+
if (selectionTimer !== null) clearTimeout(selectionTimer);
|
|
1191
|
+
selectionTimer = setTimeout(() => {
|
|
1192
|
+
selectionTimer = null;
|
|
1193
|
+
try {
|
|
1194
|
+
const selection = win?.getSelection?.();
|
|
1195
|
+
if (selection === null || selection === undefined || selection.isCollapsed === true || selection.rangeCount === 0) {
|
|
1196
|
+
clearSelectionAffordance();
|
|
1197
|
+
return;
|
|
1198
|
+
}
|
|
1199
|
+
const text = collapse(selection.toString());
|
|
1200
|
+
if (text === "" || text.length > MAX_SELECTION_CHARS) {
|
|
1201
|
+
clearSelectionAffordance();
|
|
1202
|
+
return;
|
|
1203
|
+
}
|
|
1204
|
+
const range = selection.getRangeAt(0);
|
|
1205
|
+
if (!isInsideRegion(range.commonAncestorContainer)) {
|
|
1206
|
+
clearSelectionAffordance();
|
|
1207
|
+
return;
|
|
1208
|
+
}
|
|
1209
|
+
if (isSkipped(range.startContainer) || isSkipped(range.endContainer)) {
|
|
1210
|
+
clearSelectionAffordance();
|
|
1211
|
+
return;
|
|
1212
|
+
}
|
|
1213
|
+
const rect = range.getBoundingClientRect();
|
|
1214
|
+
const context = contextForSelection(range, rect);
|
|
1215
|
+
pendingSelection = {
|
|
1216
|
+
text,
|
|
1217
|
+
context,
|
|
1218
|
+
rect: { left: rect.left, top: rect.top, right: rect.right, bottom: rect.bottom, width: rect.width, height: rect.height }
|
|
1219
|
+
};
|
|
1220
|
+
if (typeof options.onSelection === "function") options.onSelection(pendingSelection);
|
|
1221
|
+
} catch (error) {
|
|
1222
|
+
warn("reading the selection failed", error);
|
|
1223
|
+
}
|
|
1224
|
+
}, SELECTION_DEBOUNCE_MS);
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1227
|
+
/** Whether a node belongs to the conversation region. */
|
|
1228
|
+
function isInsideRegion(node) {
|
|
1229
|
+
if (region === null || node === null || node === undefined) return false;
|
|
1230
|
+
const element = node.nodeType === 1 ? node : node.parentElement;
|
|
1231
|
+
return element !== null && element !== undefined && (element === region || region.contains(element));
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1234
|
+
/**
|
|
1235
|
+
* The sentence around a selection, never the whole transcript.
|
|
1236
|
+
*
|
|
1237
|
+
* This used to be `readBlock(regionFor(node))`, and that is what flooded the editor:
|
|
1238
|
+
* {@link regionFor} returns the region's DIRECT CHILD, which in the real layout is
|
|
1239
|
+
* the scroll container holding every message. So `context` was the entire
|
|
1240
|
+
* conversation — every turn, every tool-chip label, because `innerText` of that
|
|
1241
|
+
* container includes them. The panel renders the context directly under the editor's
|
|
1242
|
+
* title, so adding one term filled the panel with the whole session and pushed the
|
|
1243
|
+
* fields and the Save button off-screen: the editor became unusable.
|
|
1244
|
+
*
|
|
1245
|
+
* The selection's own centre point identifies the block the user actually selected
|
|
1246
|
+
* in (the same tested lookup a click uses), and {@link contextAround} then clamps the
|
|
1247
|
+
* result to one bounded window around the selection.
|
|
1248
|
+
*
|
|
1249
|
+
* @param range - the selection's range.
|
|
1250
|
+
* @param rect - the range's viewport box.
|
|
1251
|
+
* @returns the context sentence, bounded.
|
|
1252
|
+
*/
|
|
1253
|
+
function contextForSelection(range, rect) {
|
|
1254
|
+
const selected = collapse(range.toString());
|
|
1255
|
+
if (snapshot.blocks.length === 0) refreshSnapshot();
|
|
1256
|
+
const centreX = (rect.left + rect.right) / 2;
|
|
1257
|
+
const centreY = (rect.top + rect.bottom) / 2;
|
|
1258
|
+
const hit = blockAtPoint(snapshot.blocks, centreX, centreY);
|
|
1259
|
+
if (hit === null) return clampContext(selected);
|
|
1260
|
+
let offset = hit.offset;
|
|
1261
|
+
try {
|
|
1262
|
+
const runs = textRuns(hit.block.element);
|
|
1263
|
+
const exact = offsetFromCaret(hit.block.element, range.startContainer, range.startOffset, runs.runs);
|
|
1264
|
+
if (exact !== null) offset = exact;
|
|
1265
|
+
} catch (error) {
|
|
1266
|
+
// The geometric offset stays: it only has to be close enough for the window.
|
|
1267
|
+
void error;
|
|
1268
|
+
}
|
|
1269
|
+
return contextAround(hit.block.text, offset, offset + selected.length);
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1272
|
+
/** Handle a click on the transcript. */
|
|
1273
|
+
function onClick(event) {
|
|
1274
|
+
clickSeen++;
|
|
1275
|
+
if (event.defaultPrevented === true || event.button !== 0) return;
|
|
1276
|
+
if (event.altKey === true || event.metaKey === true) return;
|
|
1277
|
+
// A click that ends a drag-select must not also open the popup. The answer comes from the shared
|
|
1278
|
+
// module now, because the annotated-term path in `hover.js` needs the same one and two copies of
|
|
1279
|
+
// this rule is how one of them ends up missing.
|
|
1280
|
+
if (selectionActive(win)) return;
|
|
1281
|
+
const target = event.target;
|
|
1282
|
+
if (isBlockedNode(target)) return;
|
|
1283
|
+
// The same adoption the hover path gets: a click has to resolve against the body it
|
|
1284
|
+
// happened in, not against whichever body `querySelector` reached first.
|
|
1285
|
+
regionForPointer(target);
|
|
1286
|
+
// Diagnostic: the same report for a click, which is the gesture that "does nothing".
|
|
1287
|
+
report("click", () => probe(event.clientX, event.clientY));
|
|
1288
|
+
let hit = null;
|
|
1289
|
+
try {
|
|
1290
|
+
hit = termAtPoint(event.clientX, event.clientY);
|
|
1291
|
+
} catch (error) {
|
|
1292
|
+
warn("resolving the clicked term failed", error);
|
|
1293
|
+
return;
|
|
1294
|
+
}
|
|
1295
|
+
if (hit === null) return;
|
|
1296
|
+
// A click on a term the detector knows is a navigation, not an explanation:
|
|
1297
|
+
// the explanation was already offered as the hint the pointer was resting on.
|
|
1298
|
+
// The hint is dropped first, because selecting a panel unmounts the transcript
|
|
1299
|
+
// and an overlay left behind would outlive the term it describes.
|
|
1300
|
+
try {
|
|
1301
|
+
if (hit.known === true) {
|
|
1302
|
+
onActivate(hit, { x: event.clientX, y: event.clientY });
|
|
1303
|
+
} else {
|
|
1304
|
+
onExplain(hit, { x: event.clientX, y: event.clientY });
|
|
1305
|
+
}
|
|
1306
|
+
} catch (error) {
|
|
1307
|
+
warn("opening the explanation failed", error);
|
|
1308
|
+
}
|
|
1309
|
+
}
|
|
1310
|
+
|
|
1311
|
+
/** Run one settled-transcript pass: snapshot, then collect new terminology. */
|
|
1312
|
+
function settle() {
|
|
1313
|
+
settleTimer = null;
|
|
1314
|
+
try {
|
|
1315
|
+
const current = refreshSnapshot();
|
|
1316
|
+
if (region !== null) onSettled(region);
|
|
1317
|
+
if (current.text === "") return;
|
|
1318
|
+
collect(current);
|
|
1319
|
+
} catch (error) {
|
|
1320
|
+
warn("scanning the transcript failed", error);
|
|
1321
|
+
}
|
|
1322
|
+
}
|
|
1323
|
+
|
|
1324
|
+
/**
|
|
1325
|
+
* Whether the plugin should create an entry for this occurrence on its own.
|
|
1326
|
+
*
|
|
1327
|
+
* The point of the feature is the RARE word in the conversation, not a dictionary
|
|
1328
|
+
* of everything the agent says. Three things are therefore required:
|
|
1329
|
+
*
|
|
1330
|
+
* - The term must be genuinely term-like: an acronym or an identifier. The detector
|
|
1331
|
+
* already refuses ordinary prose, so this keeps the bar where it was rather than
|
|
1332
|
+
* lowering it.
|
|
1333
|
+
* - The built-in glossary must NOT already explain it. Hauling a term the plugin can
|
|
1334
|
+
* already define into the user's personal dictionary is the bulk-import behaviour
|
|
1335
|
+
* to avoid: it fills the panel with words the user never asked about and buries the
|
|
1336
|
+
* ones they did. A glossary term is still clickable and still explainable — it is
|
|
1337
|
+
* read from the built-in lexicon — and the user can add it deliberately by
|
|
1338
|
+
* selecting it.
|
|
1339
|
+
* - The user must not have DELETED it. Deletion is a deliberate act, and a deleted
|
|
1340
|
+
* term is not an unknown one: reviving it belongs to the manual path (selecting
|
|
1341
|
+
* the word, or typing in the editor), never to the collector noticing it again.
|
|
1342
|
+
* Without this, a later agent reply that happens to carry a definition for the term
|
|
1343
|
+
* would revive it on its own, and the user's deletion would quietly undo itself.
|
|
1344
|
+
*
|
|
1345
|
+
* @param occurrence - one detected occurrence.
|
|
1346
|
+
* @param detector - the detector that produced it.
|
|
1347
|
+
* @param deletedKeys - the terms the user has deleted.
|
|
1348
|
+
* @returns true when the entry should be created automatically.
|
|
1349
|
+
*/
|
|
1350
|
+
function shouldAutoCreate(occurrence, detector, deletedKeys) {
|
|
1351
|
+
if (occurrence.known) return false;
|
|
1352
|
+
if (deletedKeys !== undefined && deletedKeys.has(occurrence.key)) return false;
|
|
1353
|
+
if (typeof detector?.glossaryOf === "function" && detector.glossaryOf(occurrence.key) !== undefined) return false;
|
|
1354
|
+
if (autoCreateFilter !== null) return autoCreateFilter(occurrence, detector) === true;
|
|
1355
|
+
return isAcronym(occurrence.term) || looksLikeIdentifier(occurrence.term);
|
|
1356
|
+
}
|
|
1357
|
+
|
|
1358
|
+
/**
|
|
1359
|
+
* The terms the user has deleted, as a lookup set.
|
|
1360
|
+
*
|
|
1361
|
+
* Read from the store rather than tracked here, because deletion can happen in another
|
|
1362
|
+
* window and arrive through a merge.
|
|
1363
|
+
*
|
|
1364
|
+
* @returns the deleted term keys.
|
|
1365
|
+
*/
|
|
1366
|
+
function deletedTermKeys() {
|
|
1367
|
+
const raw = typeof store?.getState === "function" ? store.getState() : undefined;
|
|
1368
|
+
// `getState()` is the document; `getSnapshot()` is the render projection, which
|
|
1369
|
+
// carries the same document under `state`. Both are accepted so the collector does
|
|
1370
|
+
// not silently lose the tombstones when it is handed one rather than the other.
|
|
1371
|
+
const state = raw?.state ?? raw;
|
|
1372
|
+
const keys = new Set();
|
|
1373
|
+
for (const key of state?.deletedKeys ?? []) {
|
|
1374
|
+
if (typeof key === "string") keys.add(normalizeTerm(key));
|
|
1375
|
+
}
|
|
1376
|
+
for (const record of state?.backing ?? []) {
|
|
1377
|
+
if (record === null || record === undefined || (record.deletedAt ?? 0) <= 0) continue;
|
|
1378
|
+
keys.add(record.key ?? normalizeTerm(record.term ?? ""));
|
|
1379
|
+
}
|
|
1380
|
+
return keys;
|
|
1381
|
+
}
|
|
1382
|
+
|
|
1383
|
+
/**
|
|
1384
|
+
* Collect the terminology of the messages the plugin has not read yet.
|
|
1385
|
+
*
|
|
1386
|
+
* The store is the authority on what happened: a sighting of a term the user
|
|
1387
|
+
* deleted is refused, so the count of new entries comes from the store's answers
|
|
1388
|
+
* rather than from this loop's own guesses. Guessing is what produced a
|
|
1389
|
+
* "collected N terms" notice for terms that were not collected at all.
|
|
1390
|
+
*
|
|
1391
|
+
* @param current - the transcript snapshot.
|
|
1392
|
+
* @returns a promise resolving to the number of entries actually created.
|
|
1393
|
+
*/
|
|
1394
|
+
/**
|
|
1395
|
+
* The user's automatic-collection preferences, read fresh per pass.
|
|
1396
|
+
*
|
|
1397
|
+
* The layer owns collection, the shell owns the settings store, so they are handed over
|
|
1398
|
+
* as a thunk rather than copied at attach time: flipping a switch must take effect on the
|
|
1399
|
+
* next settled message, not on the next page load.
|
|
1400
|
+
*
|
|
1401
|
+
* @returns `{ minLength, identifiers, cjk }`.
|
|
1402
|
+
*/
|
|
1403
|
+
function collectPreferences() {
|
|
1404
|
+
const raw = typeof options.collectSettings === "function" ? options.collectSettings() : null;
|
|
1405
|
+
return {
|
|
1406
|
+
minLength: typeof raw?.collectMinLength === "number" && raw.collectMinLength > 0 ? raw.collectMinLength : 4,
|
|
1407
|
+
identifiers: raw?.collectIdentifiers === true,
|
|
1408
|
+
cjk: raw?.collectCjk !== false
|
|
1409
|
+
};
|
|
1410
|
+
}
|
|
1411
|
+
|
|
1412
|
+
/**
|
|
1413
|
+
* Whether a term is shaped like code rather than like a word.
|
|
1414
|
+
*
|
|
1415
|
+
* The dictionary filled up with `data-conversation-region`, `getBoundingClientRect`,
|
|
1416
|
+
* `term-wikilink-card`, `Set-Content` and `12-0D` — code, none of it terminology a reader
|
|
1417
|
+
* wants explained. The test is deliberately blunt so it can be explained in one line: a
|
|
1418
|
+
* hyphen or underscore, or a capital inside a word (camelCase / PascalCase). A single
|
|
1419
|
+
* leading capital (`Quorum`) is ordinary prose and passes.
|
|
1420
|
+
*
|
|
1421
|
+
* @param term - the candidate.
|
|
1422
|
+
* @returns true when the term looks like an identifier.
|
|
1423
|
+
*/
|
|
1424
|
+
function codeShaped(term) {
|
|
1425
|
+
return /[_-]/.test(term) || /[a-z][A-Z]/.test(term);
|
|
1426
|
+
}
|
|
1427
|
+
|
|
1428
|
+
/**
|
|
1429
|
+
* The names this plugin's own diagnostics use.
|
|
1430
|
+
*
|
|
1431
|
+
* Never terminology, and never subject to a user's preference: `zz-*` is what the
|
|
1432
|
+
* heartbeat and the pointer probes are called, and one of them was collected from a
|
|
1433
|
+
* message that merely MENTIONED it.
|
|
1434
|
+
*/
|
|
1435
|
+
const DIAGNOSTIC_NAME = /^zz-/i;
|
|
1436
|
+
|
|
1437
|
+
/**
|
|
1438
|
+
* Whether the collection preferences allow one candidate.
|
|
1439
|
+
*
|
|
1440
|
+
* @param term - the candidate term.
|
|
1441
|
+
* @param preferences - from {@link collectPreferences}.
|
|
1442
|
+
* @returns true when it may be collected.
|
|
1443
|
+
*/
|
|
1444
|
+
function allowedByPreferences(term, preferences) {
|
|
1445
|
+
const text = typeof term === "string" ? term.trim() : "";
|
|
1446
|
+
if (text === "") return false;
|
|
1447
|
+
if (DIAGNOSTIC_NAME.test(text)) return false;
|
|
1448
|
+
if (text.length < preferences.minLength) return false;
|
|
1449
|
+
if (!preferences.cjk && /[\u3400-\u4dbf\u4e00-\u9fff\uf900-\ufaff]/.test(text)) return false;
|
|
1450
|
+
if (!preferences.identifiers && codeShaped(text)) return false;
|
|
1451
|
+
return true;
|
|
1452
|
+
}
|
|
1453
|
+
|
|
1454
|
+
function collect(current) {
|
|
1455
|
+
// The switch first: with auto-collection off, a settled message must cost nothing
|
|
1456
|
+
// and must not even mark itself as read, so that turning the switch back on still
|
|
1457
|
+
// sees the conversation.
|
|
1458
|
+
if (!autoCollectEnabled) return Promise.resolve(0);
|
|
1459
|
+
// Measure the budget from now: timestamps older than the window have expired, and
|
|
1460
|
+
// without this the list could only ever grow and collection would stop for good
|
|
1461
|
+
// once the budget was first reached.
|
|
1462
|
+
const startedAt = Date.now();
|
|
1463
|
+
const cutoff = startedAt - AUTO_BUDGET_WINDOW_MS;
|
|
1464
|
+
while (recentCreations.length > 0 && recentCreations[0] <= cutoff) recentCreations.shift();
|
|
1465
|
+
let created = 0;
|
|
1466
|
+
/** The sightings that stuck, so the shell can ask for their explanations. */
|
|
1467
|
+
const stuck = [];
|
|
1468
|
+
const pending = [];
|
|
1469
|
+
const knownEntries = () => (typeof store?.getState === "function" ? store.getState().entries : []);
|
|
1470
|
+
const deletedKeys = deletedTermKeys();
|
|
1471
|
+
// One snapshot per pass, so a switch flipped mid-conversation cannot make two blocks of
|
|
1472
|
+
// the same pass disagree about what is allowed.
|
|
1473
|
+
const preferences = collectPreferences();
|
|
1474
|
+
for (const block of current.blocks) {
|
|
1475
|
+
// Code blocks and tool output are blocks too. Collecting from them is how the
|
|
1476
|
+
// dictionary filled with shell-script identifiers nobody asked about.
|
|
1477
|
+
if (!isProseBlock(block)) continue;
|
|
1478
|
+
const fingerprint = hashText(block.text);
|
|
1479
|
+
if (seen.has(fingerprint)) continue;
|
|
1480
|
+
remember(fingerprint);
|
|
1481
|
+
const occurrences = activeDetector.scan(block.text, { includeCandidates: true });
|
|
1482
|
+
if (occurrences.length === 0) continue;
|
|
1483
|
+
// Counted per BLOCK. `created` used to be the guard here, but it is the total for
|
|
1484
|
+
// the whole pass — so the constant named "per message" really capped a settle
|
|
1485
|
+
// pass at three, and a five-message pass silently dropped two messages' worth of
|
|
1486
|
+
// terminology. The session bound is the per-minute budget below; this one is the
|
|
1487
|
+
// per-message bound its name promises.
|
|
1488
|
+
let createdInBlock = 0;
|
|
1489
|
+
for (const occurrence of occurrences) {
|
|
1490
|
+
if (createdInBlock >= MAX_AUTO_PER_MESSAGE) break;
|
|
1491
|
+
// A burst of jargon must not fill the panel in one message, and a long
|
|
1492
|
+
// session must not fill it one message at a time: the per-minute budget is
|
|
1493
|
+
// what makes "收录过程太频繁" stop being true even when every message
|
|
1494
|
+
// carries something new.
|
|
1495
|
+
if (recentCreations.length >= MAX_AUTO_PER_MINUTE) break;
|
|
1496
|
+
if (!shouldAutoCreate(occurrence, activeDetector, deletedKeys)) continue;
|
|
1497
|
+
if (!allowedByPreferences(occurrence.term, preferences)) continue;
|
|
1498
|
+
const isNew = knownEntries().every((entry) => entry.key !== occurrence.key);
|
|
1499
|
+
if (!isNew) continue;
|
|
1500
|
+
const context = blockText(block, occurrence);
|
|
1501
|
+
const glossary = activeDetector.glossaryOf(occurrence.key);
|
|
1502
|
+
const item = { term: occurrence.term, key: occurrence.key, context };
|
|
1503
|
+
const answer = noteSighting({
|
|
1504
|
+
term: occurrence.term,
|
|
1505
|
+
context,
|
|
1506
|
+
glossary,
|
|
1507
|
+
confidence: occurrence.confidence
|
|
1508
|
+
});
|
|
1509
|
+
created++;
|
|
1510
|
+
createdInBlock++;
|
|
1511
|
+
recentCreations.push(Date.now());
|
|
1512
|
+
pending.push({ item, answer });
|
|
1513
|
+
}
|
|
1514
|
+
}
|
|
1515
|
+
const announce = (actual, collected) => {
|
|
1516
|
+
if (actual > 0) onToast({ kind: "auto", count: actual });
|
|
1517
|
+
if (collected.length > 0 && typeof options.onCollected === "function") {
|
|
1518
|
+
try {
|
|
1519
|
+
options.onCollected(collected);
|
|
1520
|
+
} catch (error) {
|
|
1521
|
+
warn("reporting the collected terms failed", error);
|
|
1522
|
+
}
|
|
1523
|
+
}
|
|
1524
|
+
return actual;
|
|
1525
|
+
};
|
|
1526
|
+
if (pending.length === 0) return Promise.resolve(announce(created, []));
|
|
1527
|
+
return Promise.all(pending.map((entry) => entry.answer)).then((answers) => {
|
|
1528
|
+
const refused = answers.filter((answer) => answer !== null && answer !== undefined && answer.deleted === true).length;
|
|
1529
|
+
// The store is the authority: a refused sighting creates nothing, so it must not
|
|
1530
|
+
// be reported as collected to whoever asks for explanations.
|
|
1531
|
+
const accepted = pending.filter((entry, index) => answers[index]?.deleted !== true).map((entry) => entry.item);
|
|
1532
|
+
return announce(Math.max(0, created - refused), accepted);
|
|
1533
|
+
});
|
|
1534
|
+
}
|
|
1535
|
+
|
|
1536
|
+
/**
|
|
1537
|
+
* The sentence around one occurrence inside a block.
|
|
1538
|
+
*
|
|
1539
|
+
* Delegates to {@link contextAround} so there is one windowing rule in the plugin:
|
|
1540
|
+
* it expands to sentence boundaries where it can and hard-clamps at
|
|
1541
|
+
* {@link MAX_CONTEXT_CHARS}, which is what keeps a long message from becoming an
|
|
1542
|
+
* entry's context.
|
|
1543
|
+
*/
|
|
1544
|
+
function blockText(block, occurrence) {
|
|
1545
|
+
return contextAround(block.text, occurrence.start, occurrence.end);
|
|
1546
|
+
}
|
|
1547
|
+
|
|
1548
|
+
/** Remember a message fingerprint, keeping the cache bounded. */
|
|
1549
|
+
function remember(fingerprint) {
|
|
1550
|
+
seen.set(fingerprint, Date.now());
|
|
1551
|
+
if (seen.size <= MAX_SEEN_FINGERPRINTS) return;
|
|
1552
|
+
const oldest = [...seen.entries()].sort((left, right) => left[1] - right[1]).slice(0, seen.size - MAX_SEEN_FINGERPRINTS);
|
|
1553
|
+
for (const [key] of oldest) seen.delete(key);
|
|
1554
|
+
}
|
|
1555
|
+
|
|
1556
|
+
/** Schedule a settled-transcript pass. */
|
|
1557
|
+
function scheduleSettle() {
|
|
1558
|
+
if (settleTimer !== null) clearTimeout(settleTimer);
|
|
1559
|
+
settleTimer = setTimeout(settle, SETTLE_MS);
|
|
1560
|
+
}
|
|
1561
|
+
|
|
1562
|
+
/**
|
|
1563
|
+
* Point the layer at a transcript and re-arm the settled pass.
|
|
1564
|
+
*
|
|
1565
|
+
* Region and scroll root move together because every reader below trusts the pair:
|
|
1566
|
+
* {@link refreshSnapshot} walks the region while the observer watches the scroll root.
|
|
1567
|
+
*
|
|
1568
|
+
* The settled pass is scheduled whenever a region exists, not only when a scroll root
|
|
1569
|
+
* does. It used to live inside the observer's own condition, so a page that mounted the
|
|
1570
|
+
* conversation body without the observer ever being created ran no pass at all — the
|
|
1571
|
+
* snapshot stayed empty, {@link onSettled} never fired, and the annotation layer had
|
|
1572
|
+
* nothing to paint. The heartbeat recorded that as `hl:0` and `blocks:null` for an
|
|
1573
|
+
* entire page generation while reporting the pointer counters correctly.
|
|
1574
|
+
*
|
|
1575
|
+
* @param nextRegion - the conversation body, or null.
|
|
1576
|
+
* @param nextScroll - its scrolling element, or null.
|
|
1577
|
+
* @returns true when the pair actually changed.
|
|
1578
|
+
*/
|
|
1579
|
+
function bind(nextRegion, nextScroll) {
|
|
1580
|
+
if (nextRegion === region && nextScroll === scrollRoot) return false;
|
|
1581
|
+
if (observer !== null) {
|
|
1582
|
+
observer.disconnect();
|
|
1583
|
+
observer = null;
|
|
1584
|
+
}
|
|
1585
|
+
region = nextRegion;
|
|
1586
|
+
scrollRoot = nextScroll;
|
|
1587
|
+
if (scrollRoot !== null && typeof win?.MutationObserver === "function") {
|
|
1588
|
+
observer = new win.MutationObserver(() => scheduleSettle());
|
|
1589
|
+
observer.observe(scrollRoot, { childList: true, subtree: true, characterData: true });
|
|
1590
|
+
}
|
|
1591
|
+
if (region !== null) {
|
|
1592
|
+
refreshSnapshot();
|
|
1593
|
+
scheduleSettle();
|
|
1594
|
+
}
|
|
1595
|
+
return true;
|
|
1596
|
+
}
|
|
1597
|
+
|
|
1598
|
+
/** Find the live region and scroll root by asking the document. */
|
|
1599
|
+
function locate() {
|
|
1600
|
+
const next = doc?.querySelector?.(REGION_SELECTOR) ?? null;
|
|
1601
|
+
rememberSession(next);
|
|
1602
|
+
return bind(next, doc?.querySelector?.(SCROLL_SELECTOR) ?? null);
|
|
1603
|
+
}
|
|
1604
|
+
|
|
1605
|
+
return {
|
|
1606
|
+
/** Attach every listener and start observing the transcript. */
|
|
1607
|
+
attach() { if (attached || doc === null || doc === undefined) return;
|
|
1608
|
+
attached = true;
|
|
1609
|
+
locate();
|
|
1610
|
+
listen(doc, "selectionchange", onSelectionChange);
|
|
1611
|
+
listen(doc, "mousedown", (event) => {
|
|
1612
|
+
// A press on the plugin's own affordance must NOT clear the selection.
|
|
1613
|
+
// This listener is bound on the document in the CAPTURE phase, so it runs
|
|
1614
|
+
// before the button's own handler: clearing here unmounted the button
|
|
1615
|
+
// before its `click` could fire, and that is why pressing "加入词典" on a
|
|
1616
|
+
// selection did nothing at all. The button is identified by its marker
|
|
1617
|
+
// rather than by a class name, which the host is free to change.
|
|
1618
|
+
if (event.button !== 0) return;
|
|
1619
|
+
if (isOwnAffordance(event.target)) return;
|
|
1620
|
+
// A press anywhere that is not one of the plugin's own surfaces means the
|
|
1621
|
+
// explanation popup has served its purpose. It has no other dismissal path,
|
|
1622
|
+
// and it is a real fixed-position card over the transcript: while it stayed
|
|
1623
|
+
// open, the pointer landing on it was blocked by the very marker that
|
|
1624
|
+
// identifies it, and the area it covered stopped answering hovers and clicks.
|
|
1625
|
+
if (onPressOutside !== null && !isOwnSurface(event.target)) onPressOutside();
|
|
1626
|
+
clearSelectionAffordance();
|
|
1627
|
+
}, true);
|
|
1628
|
+
listen(win, "scroll", () => {
|
|
1629
|
+
clearSelectionAffordance();
|
|
1630
|
+
}, true);
|
|
1631
|
+
const watch = setInterval(() => {
|
|
1632
|
+
try {
|
|
1633
|
+
locate();
|
|
1634
|
+
// Without an observer nothing refreshes the transcript, and a reply that
|
|
1635
|
+
// streams into a mounted body would be read exactly once, at attach. The
|
|
1636
|
+
// poll is what the observer normally does; it runs only in that case.
|
|
1637
|
+
if (region !== null && observer === null) scheduleSettle();
|
|
1638
|
+
} catch (error) {
|
|
1639
|
+
warn("locating the transcript failed", error);
|
|
1640
|
+
}
|
|
1641
|
+
}, 2000);
|
|
1642
|
+
disposers.push(() => clearInterval(watch));
|
|
1643
|
+
// Bound on the document in the CAPTURE phase, and filtered by region rather than
|
|
1644
|
+
// by target, so it survives the transcript remounting. Capture is not cosmetic:
|
|
1645
|
+
// React 17+ delegates its own handlers to the root container, which sits below
|
|
1646
|
+
// the document, so a `stopPropagation()` anywhere in the app's tree silences a
|
|
1647
|
+
// bubble-phase document listener completely.
|
|
1648
|
+
listen(doc, "click", onClick, true);
|
|
1649
|
+
},
|
|
1650
|
+
/** Remove every listener and timer. */
|
|
1651
|
+
detach() {
|
|
1652
|
+
attached = false;
|
|
1653
|
+
for (const dispose of disposers.splice(0)) {
|
|
1654
|
+
try {
|
|
1655
|
+
dispose();
|
|
1656
|
+
} catch (error) {
|
|
1657
|
+
warn("removing a listener failed", error);
|
|
1658
|
+
}
|
|
1659
|
+
}
|
|
1660
|
+
if (observer !== null) observer.disconnect();
|
|
1661
|
+
observer = null;
|
|
1662
|
+
if (settleTimer !== null) clearTimeout(settleTimer);
|
|
1663
|
+
if (selectionTimer !== null) clearTimeout(selectionTimer);
|
|
1664
|
+
settleTimer = null;
|
|
1665
|
+
selectionTimer = null;
|
|
1666
|
+
region = null;
|
|
1667
|
+
scrollRoot = null;
|
|
1668
|
+
seen.clear();
|
|
1669
|
+
},
|
|
1670
|
+
/** Replace the detector after a dictionary change. */
|
|
1671
|
+
setDetector,
|
|
1672
|
+
/**
|
|
1673
|
+
* Turn automatic collection on or off.
|
|
1674
|
+
* @param enabled - the new state.
|
|
1675
|
+
*/
|
|
1676
|
+
setAutoCollect(enabled) {
|
|
1677
|
+
autoCollectEnabled = enabled !== false;
|
|
1678
|
+
},
|
|
1679
|
+
/** How many automatic creations the current budget window still allows. @returns the remaining count. */
|
|
1680
|
+
remainingBudget() {
|
|
1681
|
+
const cutoff = Date.now() - AUTO_BUDGET_WINDOW_MS;
|
|
1682
|
+
let live = 0;
|
|
1683
|
+
for (const at of recentCreations) if (at > cutoff) live++;
|
|
1684
|
+
return Math.max(0, MAX_AUTO_PER_MINUTE - live);
|
|
1685
|
+
},
|
|
1686
|
+
/**
|
|
1687
|
+
* Force the answer every future sighting reports. Test seam: it lets a test
|
|
1688
|
+
* assert the notice follows the store rather than this layer's own guess.
|
|
1689
|
+
* @param answer - the answer `noteSighting` should resolve to, or null to use
|
|
1690
|
+
* the store's own.
|
|
1691
|
+
*/
|
|
1692
|
+
setSightingAnswer(answer) {
|
|
1693
|
+
forcedSightingAnswer = answer ?? null;
|
|
1694
|
+
},
|
|
1695
|
+
/**
|
|
1696
|
+
* Narrow the "rare enough to record by itself?" decision. Test seam: it lets a
|
|
1697
|
+
* check drive the collector's refusal path with a stub detector whose word is not a
|
|
1698
|
+
* real identifier, without loosening the policy for the plugin.
|
|
1699
|
+
* @param filter - a predicate, or null to restore the built-in policy.
|
|
1700
|
+
*/
|
|
1701
|
+
setAutoCreateFilter(filter) {
|
|
1702
|
+
autoCreateFilter = typeof filter === "function" ? filter : null;
|
|
1703
|
+
},
|
|
1704
|
+
/** Force one collection pass, used by tests. */
|
|
1705
|
+
scanNow() {
|
|
1706
|
+
refreshSnapshot();
|
|
1707
|
+
return collect(snapshot);
|
|
1708
|
+
},
|
|
1709
|
+
/** The cached transcript snapshot. */
|
|
1710
|
+
snapshot() {
|
|
1711
|
+
return snapshot;
|
|
1712
|
+
},
|
|
1713
|
+
/** Resolve a point to a term, used by tests. */
|
|
1714
|
+
termAtPoint,
|
|
1715
|
+
/**
|
|
1716
|
+
* The id of the conversation last seen on screen, or "" when none has been.
|
|
1717
|
+
*
|
|
1718
|
+
* The one thing in this layer the panel needs: the host's feedback channel takes a session id,
|
|
1719
|
+
* and the transcript that carries it is unmounted while the panel is open.
|
|
1720
|
+
* @returns the session id.
|
|
1721
|
+
*/
|
|
1722
|
+
sessionId() {
|
|
1723
|
+
return lastSessionId;
|
|
1724
|
+
},
|
|
1725
|
+
/** Report what the pointer pipeline sees at a point, used by tests and diagnosis. */
|
|
1726
|
+
probe,
|
|
1727
|
+
/** Report the layer's own state, for the diagnostic heartbeat. */
|
|
1728
|
+
stats() {
|
|
1729
|
+
return {
|
|
1730
|
+
attached,
|
|
1731
|
+
caretApi:
|
|
1732
|
+
typeof doc?.caretRangeFromPoint === "function" ? "caretRangeFromPoint" : typeof doc?.caretPositionFromPoint === "function" ? "caretPositionFromPoint" : "none",
|
|
1733
|
+
regionAdoptions,
|
|
1734
|
+
clickSeen,
|
|
1735
|
+
hitStages: { ...hitStages },
|
|
1736
|
+
budget: { click: clickBudget, reject: rejectBudget }
|
|
1737
|
+
};
|
|
1738
|
+
},
|
|
1739
|
+
/**
|
|
1740
|
+
* Run the settled-transcript pass now, used by tests.
|
|
1741
|
+
*
|
|
1742
|
+
* The delay is what keeps a streaming reply from being scanned per token, and a test
|
|
1743
|
+
* cannot wait for a wall clock it does not control.
|
|
1744
|
+
* @returns how many blocks the pass read.
|
|
1745
|
+
*/
|
|
1746
|
+
settleNow() {
|
|
1747
|
+
settle();
|
|
1748
|
+
return snapshot.blocks.length;
|
|
1749
|
+
},
|
|
1750
|
+
/**
|
|
1751
|
+
* Whether a settled pass is waiting to run, used by tests.
|
|
1752
|
+
*
|
|
1753
|
+
* This is the only way to see the scheduling half of {@link bind} from outside. The
|
|
1754
|
+
* pass itself was previously reached only through the observer, so a page that mounted
|
|
1755
|
+
* a body without one had nothing pending and nothing said so.
|
|
1756
|
+
*
|
|
1757
|
+
* @returns true when a pass is armed.
|
|
1758
|
+
*/
|
|
1759
|
+
settleScheduled() {
|
|
1760
|
+
return settleTimer !== null;
|
|
1761
|
+
},
|
|
1762
|
+
/** Whether the layer currently holds a conversation region. */
|
|
1763
|
+
hasRegion() {
|
|
1764
|
+
return region !== null;
|
|
1765
|
+
},
|
|
1766
|
+
/** How many conversation bodies the document mounts, read with the layer's own selector. */
|
|
1767
|
+
regionCount() {
|
|
1768
|
+
return chatCount();
|
|
1769
|
+
}
|
|
1770
|
+
};
|
|
1771
|
+
}
|
|
1772
|
+
|
|
1773
|
+
module.exports = {
|
|
1774
|
+
createInteractionLayer,
|
|
1775
|
+
readRegion,
|
|
1776
|
+
findMessageElements,
|
|
1777
|
+
readBlock,
|
|
1778
|
+
blockAtPoint,
|
|
1779
|
+
wordAt,
|
|
1780
|
+
hashText,
|
|
1781
|
+
collapse,
|
|
1782
|
+
textRuns,
|
|
1783
|
+
offsetFromCaret,
|
|
1784
|
+
isBlockElement,
|
|
1785
|
+
isBlockLevel,
|
|
1786
|
+
hasOwnTextNodes,
|
|
1787
|
+
isComposer,
|
|
1788
|
+
isSkippedNode,
|
|
1789
|
+
isProseBlock,
|
|
1790
|
+
clampContext,
|
|
1791
|
+
MAX_CONTEXT_CHARS,
|
|
1792
|
+
REGION_SELECTOR,
|
|
1793
|
+
COMPOSER_SELECTOR,
|
|
1794
|
+
SCROLL_SELECTOR,
|
|
1795
|
+
SKIP_SELECTOR,
|
|
1796
|
+
SETTLE_MS,
|
|
1797
|
+
MAX_BLOCK_CHARS,
|
|
1798
|
+
MAX_AUTO_PER_MESSAGE,
|
|
1799
|
+
MAX_AUTO_PER_MINUTE,
|
|
1800
|
+
AUTO_BUDGET_WINDOW_MS,
|
|
1801
|
+
MAX_SELECTION_CHARS
|
|
1802
|
+
};
|