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,282 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The annotation layer: marks the terms the dictionary has really collected
|
|
5
|
+
* wherever they appear in the transcript.
|
|
6
|
+
*
|
|
7
|
+
* Why this is a CSS Custom Highlight and not wrapped text
|
|
8
|
+
* ------------------------------------------------------
|
|
9
|
+
* Marking terms by wrapping them in spans means writing into the host's message
|
|
10
|
+
* tree. React owns that tree: the next re-render discards the wrappers, a
|
|
11
|
+
* streaming reply re-renders constantly, and inserting nodes into a virtualized
|
|
12
|
+
* list fights its measurement. The Custom Highlight API paints `Range` objects
|
|
13
|
+
* through the style layer instead, so the DOM the renderer owns is never touched
|
|
14
|
+
* and nothing is left behind when the plugin unloads.
|
|
15
|
+
*
|
|
16
|
+
* What it marks, and what it deliberately does not
|
|
17
|
+
* ------------------------------------------------
|
|
18
|
+
* Only a term backed by one of the user's own entries (`source === "dictionary"`)
|
|
19
|
+
* is marked. A built-in glossary term is still hoverable and clickable, but it has
|
|
20
|
+
* no entry to open, and underlining every word the bundled lexicon can define
|
|
21
|
+
* would mark most of a technical reply — the same "do not make the dictionary
|
|
22
|
+
* bulky" rule the collector follows, applied to the page.
|
|
23
|
+
*
|
|
24
|
+
* The whole module degrades to a no-op where the API is missing: `supported` is
|
|
25
|
+
* false, `refresh` marks nothing, and hover and click keep working.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
const { textRuns, readRegion, isProseBlock } = require("./interact.js");
|
|
29
|
+
|
|
30
|
+
/** The highlight registry key. One page-level layer, one name. */
|
|
31
|
+
const HIGHLIGHT_NAME = "term-dictionary-entry";
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The rule that paints the highlight.
|
|
35
|
+
*
|
|
36
|
+
* Rendered as a component-owned `<style>` element rather than applied inline,
|
|
37
|
+
* because a `::highlight()` rule only exists in a stylesheet. Only theme tokens
|
|
38
|
+
* appear as colors, so light and dark follow without a second rule.
|
|
39
|
+
*
|
|
40
|
+
* The underline is `label-secondary`, not `brand-primary`. In the DARK theme the
|
|
41
|
+
* platform resolves `brand-primary` to `--dsw-static-neutral-bluish-50`, which is the
|
|
42
|
+
* very same value it gives `label-primary` — so a brand-coloured underline under
|
|
43
|
+
* brand-coloured text is invisible, and the marking the feature promises would simply
|
|
44
|
+
* not be there. `label-secondary` is measurably dimmer than the body text in both
|
|
45
|
+
* themes (bluish-700 on light, bluish-300 on dark), which is what makes a marked word
|
|
46
|
+
* readable as marked. The wash behind it comes from the platform's own hover tint,
|
|
47
|
+
* a translucent value in both themes (`#2631480f` light, `#ffffff14` dark).
|
|
48
|
+
*/
|
|
49
|
+
const HIGHLIGHT_CSS =
|
|
50
|
+
`::highlight(${HIGHLIGHT_NAME}) {` +
|
|
51
|
+
" text-decoration: underline dotted var(--dsw-alias-label-secondary);" +
|
|
52
|
+
" text-decoration-thickness: 1px;" +
|
|
53
|
+
" text-underline-offset: 2px;" +
|
|
54
|
+
" background-color: var(--dsw-alias-interactive-bg-hover, transparent);" +
|
|
55
|
+
" }";
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Map an offset in a run's collapsed body back to an offset in its text node.
|
|
59
|
+
*
|
|
60
|
+
* `textRuns` builds a run's body by skipping leading whitespace, collapsing every
|
|
61
|
+
* whitespace run to one space and dropping trailing whitespace. This walks the raw
|
|
62
|
+
* text with the same rules, so a range built from run offsets lands on the
|
|
63
|
+
* characters the detector actually matched instead of drifting by the width of the
|
|
64
|
+
* whitespace it removed.
|
|
65
|
+
*
|
|
66
|
+
* @param raw - the text node's value.
|
|
67
|
+
* @param bodyOffset - an offset into the run's `body`, from 0 to `body.length`.
|
|
68
|
+
* @returns the corresponding offset into `raw`.
|
|
69
|
+
*/
|
|
70
|
+
function rawOffsetForBody(raw, bodyOffset) {
|
|
71
|
+
if (typeof raw !== "string" || raw === "") return 0;
|
|
72
|
+
const target = Math.max(0, Math.floor(bodyOffset));
|
|
73
|
+
let bodyIndex = 0;
|
|
74
|
+
let at = 0;
|
|
75
|
+
// Leading whitespace is trimmed off the body, so offset 0 is the first survivor.
|
|
76
|
+
while (at < raw.length && /\s/.test(raw[at])) at++;
|
|
77
|
+
if (target === 0) return at;
|
|
78
|
+
while (at < raw.length) {
|
|
79
|
+
if (/\s/.test(raw[at])) {
|
|
80
|
+
// The whole run stands for the single space the body carries.
|
|
81
|
+
if (bodyIndex === target) return at;
|
|
82
|
+
bodyIndex++;
|
|
83
|
+
while (at < raw.length && /\s/.test(raw[at])) at++;
|
|
84
|
+
if (bodyIndex === target) return at;
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
if (bodyIndex === target) return at;
|
|
88
|
+
bodyIndex++;
|
|
89
|
+
at++;
|
|
90
|
+
}
|
|
91
|
+
return raw.length;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Turn one detected span into DOM ranges, one per run it covers.
|
|
96
|
+
*
|
|
97
|
+
* A phrase can straddle inline elements — `event <strong>sourcing</strong>` is one
|
|
98
|
+
* dictionary term across two text nodes — and a highlight range cannot cross
|
|
99
|
+
* nodes, so such a term yields one range per run. They paint as one continuous
|
|
100
|
+
* marking.
|
|
101
|
+
*
|
|
102
|
+
* @param runs - runs from {@link textRuns}.
|
|
103
|
+
* @param start - the span's start offset in the runs' shared text.
|
|
104
|
+
* @param end - the span's end offset (exclusive).
|
|
105
|
+
* @returns `[{ node, start, end }]`, possibly empty.
|
|
106
|
+
*/
|
|
107
|
+
function rangesAcrossRuns(runs, start, end) {
|
|
108
|
+
const segments = [];
|
|
109
|
+
for (const run of runs) {
|
|
110
|
+
if (run.end <= start) continue;
|
|
111
|
+
if (run.start >= end) break;
|
|
112
|
+
const from = Math.max(start, run.start) - run.start;
|
|
113
|
+
const to = Math.min(end, run.end) - run.start;
|
|
114
|
+
if (to <= from) continue;
|
|
115
|
+
const raw = run.node?.nodeValue ?? "";
|
|
116
|
+
const nodeStart = rawOffsetForBody(raw, from);
|
|
117
|
+
const nodeEnd = rawOffsetForBody(raw, to);
|
|
118
|
+
if (nodeEnd > nodeStart) segments.push({ node: run.node, start: nodeStart, end: nodeEnd });
|
|
119
|
+
}
|
|
120
|
+
return segments;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Collect the ranges to paint for one message block.
|
|
125
|
+
*
|
|
126
|
+
* @param block - a block from {@link readRegion}, or any element.
|
|
127
|
+
* @param detector - the current detector generation.
|
|
128
|
+
* @returns `[{ node, start, end, term, key, id }]`.
|
|
129
|
+
*/
|
|
130
|
+
function rangesForBlock(block, detector) {
|
|
131
|
+
const element = block?.element ?? block;
|
|
132
|
+
if (element === null || element === undefined || detector === null || detector === undefined) return [];
|
|
133
|
+
const { runs, text } = textRuns(element);
|
|
134
|
+
if (text === "" || runs.length === 0) return [];
|
|
135
|
+
// `includeCandidates: false` keeps the weak evidence out: only a term backed by a
|
|
136
|
+
// dictionary or glossary entry is reported, which is exactly the set worth marking.
|
|
137
|
+
const found = detector.scan(text, { includeCandidates: false });
|
|
138
|
+
const collected = [];
|
|
139
|
+
for (const occurrence of found) {
|
|
140
|
+
if (occurrence.known !== true || occurrence.source !== "dictionary") continue;
|
|
141
|
+
for (const segment of rangesAcrossRuns(runs, occurrence.start, occurrence.end)) {
|
|
142
|
+
collected.push({ ...segment, term: occurrence.term, key: occurrence.key, id: occurrence.id });
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return collected;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Build the page's annotation layer.
|
|
150
|
+
*
|
|
151
|
+
* @param options - `document`, `detector`, and an optional `logger`.
|
|
152
|
+
* @returns `{ supported, setDetector, refresh, clear, count }`.
|
|
153
|
+
*/
|
|
154
|
+
function createTermHighlighter(options) {
|
|
155
|
+
const doc = options.document ?? globalThis.document;
|
|
156
|
+
const logger = options.logger;
|
|
157
|
+
const registry = globalThis.CSS?.highlights ?? null;
|
|
158
|
+
const HighlightConstructor = globalThis.Highlight ?? null;
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Whether this page can paint a highlight at all. Probed rather than assumed:
|
|
162
|
+
* an older runtime must lose the marking, not the panel.
|
|
163
|
+
*/
|
|
164
|
+
const supported =
|
|
165
|
+
registry !== null &&
|
|
166
|
+
typeof registry.set === "function" &&
|
|
167
|
+
typeof registry.delete === "function" &&
|
|
168
|
+
typeof HighlightConstructor === "function" &&
|
|
169
|
+
typeof doc?.createRange === "function";
|
|
170
|
+
|
|
171
|
+
let detector = options.detector ?? null;
|
|
172
|
+
let painted = 0;
|
|
173
|
+
|
|
174
|
+
/** Report a caught failure without breaking the page. */
|
|
175
|
+
function warn(message, error) {
|
|
176
|
+
logger?.warn?.(`term-dictionary: ${message}: ${error instanceof Error ? error.message : String(error)}`);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* The segments behind the current paint.
|
|
181
|
+
*
|
|
182
|
+
* KEPT, not discarded, because they are the answer to a second question: "which term is the
|
|
183
|
+
* pointer on?" The marking already knows every occurrence as a `(node, start, end)` triple,
|
|
184
|
+
* and a caret is the same kind of coordinate — so the hit test is a lookup in this list rather
|
|
185
|
+
* than a second text walk with its own matching rules.
|
|
186
|
+
*
|
|
187
|
+
* That duplication is what the two-plugin split used to cost: the pointer layer had to fetch
|
|
188
|
+
* the terms over a route, walk the transcript again, and keep its own matcher, so the two
|
|
189
|
+
* could disagree about what counts as a marked term — and they did.
|
|
190
|
+
*/
|
|
191
|
+
let segments = [];
|
|
192
|
+
|
|
193
|
+
/** Replace every painted range with the given segments. */
|
|
194
|
+
function paint(next) {
|
|
195
|
+
segments = next;
|
|
196
|
+
const built = [];
|
|
197
|
+
for (const segment of next) {
|
|
198
|
+
try {
|
|
199
|
+
const range = doc.createRange();
|
|
200
|
+
range.setStart(segment.node, segment.start);
|
|
201
|
+
range.setEnd(segment.node, segment.end);
|
|
202
|
+
built.push(range);
|
|
203
|
+
} catch (error) {
|
|
204
|
+
// The node was replaced between the read and the paint, which is normal
|
|
205
|
+
// while a reply streams: skip that segment and keep the rest.
|
|
206
|
+
warn("mapping a highlight range failed", error);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
painted = built.length;
|
|
210
|
+
if (built.length === 0) {
|
|
211
|
+
registry.delete(HIGHLIGHT_NAME);
|
|
212
|
+
return 0;
|
|
213
|
+
}
|
|
214
|
+
registry.set(HIGHLIGHT_NAME, new HighlightConstructor(...built));
|
|
215
|
+
return built.length;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
return {
|
|
219
|
+
supported,
|
|
220
|
+
/**
|
|
221
|
+
* The occurrences currently marked, as `{ node, start, end, term, key, id }`.
|
|
222
|
+
*
|
|
223
|
+
* The pointer layer reads this instead of walking the transcript itself.
|
|
224
|
+
* @returns the segments behind the last paint.
|
|
225
|
+
*/
|
|
226
|
+
segments() {
|
|
227
|
+
return segments;
|
|
228
|
+
},
|
|
229
|
+
/**
|
|
230
|
+
* Swap in the detector built against the current dictionary.
|
|
231
|
+
* @param next - the replacement detector.
|
|
232
|
+
*/
|
|
233
|
+
setDetector(next) {
|
|
234
|
+
detector = next ?? null;
|
|
235
|
+
},
|
|
236
|
+
/**
|
|
237
|
+
* Re-read the transcript and repaint the collected terms in it.
|
|
238
|
+
* @param region - the conversation region element, or null.
|
|
239
|
+
* @returns how many ranges are painted.
|
|
240
|
+
*/
|
|
241
|
+
refresh(region) {
|
|
242
|
+
if (!supported) return 0;
|
|
243
|
+
try {
|
|
244
|
+
if (region === null || region === undefined) return paint([]);
|
|
245
|
+
const segments = [];
|
|
246
|
+
for (const block of readRegion(region).blocks) {
|
|
247
|
+
// Code and tool output are not prose, so a term that merely appears in a
|
|
248
|
+
// shell snippet is not marked as if it were part of the reply.
|
|
249
|
+
if (!isProseBlock(block)) continue;
|
|
250
|
+
for (const segment of rangesForBlock(block, detector)) segments.push(segment);
|
|
251
|
+
}
|
|
252
|
+
return paint(segments);
|
|
253
|
+
} catch (error) {
|
|
254
|
+
warn("refreshing the highlight failed", error);
|
|
255
|
+
return painted;
|
|
256
|
+
}
|
|
257
|
+
},
|
|
258
|
+
/** Remove the marking without touching the DOM. */
|
|
259
|
+
clear() {
|
|
260
|
+
if (!supported) return;
|
|
261
|
+
try {
|
|
262
|
+
registry.delete(HIGHLIGHT_NAME);
|
|
263
|
+
} catch (error) {
|
|
264
|
+
warn("clearing the highlight failed", error);
|
|
265
|
+
}
|
|
266
|
+
painted = 0;
|
|
267
|
+
},
|
|
268
|
+
/** How many ranges the last paint produced. */
|
|
269
|
+
count() {
|
|
270
|
+
return painted;
|
|
271
|
+
}
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
module.exports = {
|
|
276
|
+
createTermHighlighter,
|
|
277
|
+
rangesForBlock,
|
|
278
|
+
rangesAcrossRuns,
|
|
279
|
+
rawOffsetForBody,
|
|
280
|
+
HIGHLIGHT_NAME,
|
|
281
|
+
HIGHLIGHT_CSS
|
|
282
|
+
};
|