dsh-plugin-term-dictionary 0.0.0-stage → 1.0.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.
@@ -0,0 +1,470 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * The pointer layer: the hover card, the pointer cursor, and the click that opens an entry.
5
+ *
6
+ * This is `dsh-plugin-term-wikilink`'s pointer layer, moved in and made smaller by the move.
7
+ *
8
+ * What the move removed
9
+ * --------------------
10
+ * As a separate plugin this layer had to FETCH the terms (`/dsh-term-dictionary/state`, with a
11
+ * `localStorage` fallback), walk the transcript a second time, and carry its own matcher — so two
12
+ * independent pieces of code decided what counts as a marked term, and they disagreed: the marking
13
+ * painted inline-code words that the hit test then refused, which is what "underlined but nothing
14
+ * happens" was. It also had to find the dictionary's row by DOM marker (`data-term-entry`) to
15
+ * reveal an entry, because the panel belonged to another plugin.
16
+ *
17
+ * Inside the dictionary all three disappear:
18
+ *
19
+ * - the terms are here, and the ANNOTATION already computes every occurrence as a
20
+ * `(node, start, end)` triple, filtered to the entries a reader would want explained
21
+ * (`highlight.js`). A caret is the same kind of coordinate, so the hit test is a lookup in
22
+ * that list — one text walk in the whole plugin, one matching rule;
23
+ * - revealing an entry is a call to the panel this plugin already owns.
24
+ *
25
+ * The three behaviours worth naming, all of them learned the hard way:
26
+ *
27
+ * - **the region comes from the event's own target**, every time. The host can mount more than
28
+ * one conversation body, and `querySelector` returns the document-order first, which may be
29
+ * the hidden one;
30
+ * - **listeners are registered in the CAPTURE phase.** React 17+ delegates to the root container
31
+ * below `document`, so any `stopPropagation()` in the tree silences a bubble-phase listener —
32
+ * indistinguishable from "the page produced no event";
33
+ * - **the delay governs APPEARING only.** Leaving closes at once: a card that waits out a timer
34
+ * after the pointer has gone reads as lag, not as composure.
35
+ */
36
+
37
+ /** The conversation BODY. The bare attribute also matches the composer seat inside it. */
38
+ const CHAT_SELECTOR = '[data-conversation-region="chat"]';
39
+
40
+ /** How long the pointer must rest on a term before the card appears. */
41
+ const HOVER_DELAY_MS = 110;
42
+
43
+ /**
44
+ * The highlight registry key for the term the pointer is on. Distinct from the annotation's own
45
+ * name, and given a higher priority so the hovered word reads as the live one.
46
+ */
47
+ const ACTIVE_HIGHLIGHT = "term-dictionary-active";
48
+
49
+ /** The class put on the conversation body while the pointer is on a term, for the cursor rule. */
50
+ const HOT_CLASS = "term-dictionary-hot";
51
+
52
+ const { selectionActive } = require("./selection.js");
53
+
54
+ /**
55
+ * Turn a caret into a text node plus an offset inside it.
56
+ *
57
+ * A caret may land on an element (between two children) rather than on a character; that is
58
+ * resolved to the start of the following text, or to the end of the preceding one.
59
+ *
60
+ * @param caret - `{ startContainer, startOffset }`.
61
+ * @param document - the document to build a walker with.
62
+ * @returns `{ node, offset }`, or null when no text can be found.
63
+ */
64
+ function resolveCaret(caret, document) {
65
+ const container = caret?.startContainer;
66
+ if (container === null || container === undefined) return null;
67
+ if (container.nodeType === 3) {
68
+ const length = container.nodeValue?.length ?? 0;
69
+ return { node: container, offset: Math.max(0, Math.min(caret.startOffset ?? 0, length)) };
70
+ }
71
+ if (container.nodeType !== 1) return null;
72
+ const children = Array.from(container.childNodes ?? []);
73
+ const following = children[caret.startOffset ?? 0];
74
+ if (following !== undefined) {
75
+ if (following.nodeType === 3) return { node: following, offset: 0 };
76
+ const inner = firstTextNode(following);
77
+ if (inner !== null) return { node: inner, offset: 0 };
78
+ }
79
+ const last = lastTextNode(container);
80
+ return last === null ? null : { node: last, offset: last.nodeValue?.length ?? 0 };
81
+ }
82
+
83
+ /**
84
+ * The first text node inside a subtree.
85
+ * @param root - the subtree root.
86
+ * @returns the node, or null.
87
+ */
88
+ function firstTextNode(root) {
89
+ for (const child of Array.from(root.childNodes ?? [])) {
90
+ if (child.nodeType === 3 && (child.nodeValue ?? "") !== "") return child;
91
+ if (child.nodeType === 1) {
92
+ const found = firstTextNode(child);
93
+ if (found !== null) return found;
94
+ }
95
+ }
96
+ return null;
97
+ }
98
+
99
+ /**
100
+ * The last text node inside a subtree.
101
+ * @param root - the subtree root.
102
+ * @returns the node, or null.
103
+ */
104
+ function lastTextNode(root) {
105
+ const children = Array.from(root.childNodes ?? []);
106
+ for (let at = children.length - 1; at >= 0; at--) {
107
+ const child = children[at];
108
+ if (child.nodeType === 3 && (child.nodeValue ?? "") !== "") return child;
109
+ if (child.nodeType === 1) {
110
+ const found = lastTextNode(child);
111
+ if (found !== null) return found;
112
+ }
113
+ }
114
+ return null;
115
+ }
116
+
117
+ /**
118
+ * Build the pointer layer.
119
+ *
120
+ * @param options - `document`, `segments` (a thunk returning the marked occurrences), `delays`
121
+ * (a thunk returning `{ inMs, outMs }`), `onOpen`, `onActivate`, `onClose`, `logger`.
122
+ * @returns `{ attach, detach, close, matchAt, stats, view }`.
123
+ */
124
+ function createHoverLayer(options) {
125
+ const doc = options.document ?? globalThis.document;
126
+ const logger = options.logger ?? null;
127
+ const readSegments = typeof options.segments === "function" ? options.segments : () => [];
128
+ const readDelays = typeof options.delays === "function" ? options.delays : () => null;
129
+ const onOpen = typeof options.onOpen === "function" ? options.onOpen : null;
130
+ const onActivate = typeof options.onActivate === "function" ? options.onActivate : null;
131
+ const onClose = typeof options.onClose === "function" ? options.onClose : null;
132
+
133
+ /**
134
+ * The two delays, read fresh for every gesture.
135
+ *
136
+ * A thunk rather than two numbers copied at attach time: the settings page is a few centimetres
137
+ * away from the transcript, and a knob that only takes effect after a reload is a knob nobody
138
+ * trusts. Defaults match the constants so the layer is usable on its own — in a test, or before
139
+ * the settings store has hydrated.
140
+ *
141
+ * @returns `{ inMs, outMs }`, both whole milliseconds.
142
+ */
143
+ function delays() {
144
+ const raw = readDelays() ?? {};
145
+ const inMs = Number.isFinite(raw.inMs) ? Math.max(0, Math.round(raw.inMs)) : HOVER_DELAY_MS;
146
+ const outMs = Number.isFinite(raw.outMs) ? Math.max(0, Math.round(raw.outMs)) : 0;
147
+ return { inMs, outMs };
148
+ }
149
+
150
+ /** The card's state, kept outside React so the listeners can drive it. */
151
+ let view = { kind: "closed" };
152
+ const viewListeners = new Set();
153
+ const setView = (next) => {
154
+ view = next;
155
+ for (const listener of [...viewListeners]) listener();
156
+ };
157
+
158
+ let hoverTimer = null;
159
+ /** The pending exit, when the user has asked for a grace period. */
160
+ let closeTimer = null;
161
+ let hoverPoint = null;
162
+ let hotRegion = null;
163
+ /** The conversation body the pointer was last inside, for the cursor class. */
164
+ let activeRegion = null;
165
+ let attached = false;
166
+
167
+ const stats = { moves: 0, inRegion: 0, lookups: 0, hits: 0, clicks: 0, opens: 0, closes: 0 };
168
+
169
+ /** Report a caught failure without breaking the page. */
170
+ function warn(message, error) {
171
+ logger?.warn?.(`term-dictionary: ${message}: ${error instanceof Error ? error.message : String(error)}`);
172
+ }
173
+
174
+ /**
175
+ * The conversation body an event happened in, or null when it is not in a transcript.
176
+ * @param target - the event target.
177
+ * @returns the region element, or null.
178
+ */
179
+ function regionFromTarget(target) {
180
+ if (target === null || target === undefined || typeof target.closest !== "function") return null;
181
+ return target.closest(CHAT_SELECTOR);
182
+ }
183
+
184
+ /** Resolve the caret under a point through either browser API. */
185
+ function caretAt(x, y) {
186
+ if (typeof doc?.caretRangeFromPoint === "function") {
187
+ const range = doc.caretRangeFromPoint(x, y);
188
+ if (range !== null && range !== undefined) return range;
189
+ }
190
+ if (typeof doc?.caretPositionFromPoint === "function") {
191
+ const position = doc.caretPositionFromPoint(x, y);
192
+ if (position !== null && position !== undefined && position.offsetNode != null) {
193
+ const range = doc.createRange();
194
+ range.setStart(position.offsetNode, position.offset);
195
+ range.collapse(true);
196
+ return range;
197
+ }
198
+ }
199
+ return null;
200
+ }
201
+
202
+ /**
203
+ * The marked occurrence under a point.
204
+ *
205
+ * A caret on a boundary belongs to both neighbours; the narrower span wins, and among equal
206
+ * spans the one that starts AT the caret does — a caret on the first character means that
207
+ * word, not the one ending there.
208
+ *
209
+ * @param x - viewport x.
210
+ * @param y - viewport y.
211
+ * @returns the segment, or null.
212
+ */
213
+ function matchAt(x, y) {
214
+ const caret = caretAt(x, y);
215
+ if (caret === null) return null;
216
+ const at = resolveCaret(caret, doc);
217
+ if (at === null) return null;
218
+ let best = null;
219
+ for (const segment of readSegments()) {
220
+ if (segment.node !== at.node) continue;
221
+ if (at.offset < segment.start || at.offset > segment.end) continue;
222
+ if (best === null) {
223
+ best = segment;
224
+ continue;
225
+ }
226
+ const span = segment.end - segment.start;
227
+ const bestSpan = best.end - best.start;
228
+ if (span < bestSpan || (span === bestSpan && segment.start > best.start)) best = segment;
229
+ }
230
+ return best;
231
+ }
232
+
233
+ /** A DOM Range over one segment, for its box and for the emphasis. */
234
+ function rangeOf(segment) {
235
+ if (typeof doc?.createRange !== "function") return null;
236
+ try {
237
+ const range = doc.createRange();
238
+ range.setStart(segment.node, segment.start);
239
+ range.setEnd(segment.node, segment.end);
240
+ return range;
241
+ } catch (error) {
242
+ return null;
243
+ }
244
+ }
245
+
246
+ /** Mark one occurrence as the live link: an emphasis, and a pointer cursor. */
247
+ function setActive(segment, region) {
248
+ clearActive();
249
+ const range = rangeOf(segment);
250
+ if (range === null) return;
251
+ try {
252
+ const registry = globalThis.CSS?.highlights ?? null;
253
+ const HighlightConstructor = globalThis.Highlight ?? null;
254
+ if (registry !== null && typeof registry.set === "function" && typeof HighlightConstructor === "function") {
255
+ const highlight = new HighlightConstructor(range);
256
+ try {
257
+ highlight.priority = 1;
258
+ } catch (error) {
259
+ void error;
260
+ }
261
+ registry.set(ACTIVE_HIGHLIGHT, highlight);
262
+ }
263
+ } catch (error) {
264
+ warn("marking the hovered term failed", error);
265
+ }
266
+ try {
267
+ if (typeof region?.classList?.add === "function") {
268
+ region.classList.add(HOT_CLASS);
269
+ hotRegion = region;
270
+ }
271
+ } catch (error) {
272
+ void error;
273
+ }
274
+ }
275
+
276
+ /** Drop the emphasis and the pointer cursor. */
277
+ function clearActive() {
278
+ try {
279
+ const registry = globalThis.CSS?.highlights ?? null;
280
+ if (registry !== null && typeof registry.delete === "function") registry.delete(ACTIVE_HIGHLIGHT);
281
+ } catch (error) {
282
+ void error;
283
+ }
284
+ try {
285
+ if (hotRegion !== null && typeof hotRegion.classList?.remove === "function") hotRegion.classList.remove(HOT_CLASS);
286
+ } catch (error) {
287
+ void error;
288
+ }
289
+ hotRegion = null;
290
+ }
291
+
292
+ /** Hide the card, dropping the emphasis with it. */
293
+ function close() {
294
+ cancelClose();
295
+ clearActive();
296
+ if (view.kind !== "closed") {
297
+ stats.closes++;
298
+ setView({ kind: "closed" });
299
+ if (onClose !== null) onClose();
300
+ }
301
+ }
302
+
303
+ /** Drop a pending exit, because the pointer came back to a term. */
304
+ function cancelClose() {
305
+ if (closeTimer !== null) {
306
+ clearTimeout(closeTimer);
307
+ closeTimer = null;
308
+ }
309
+ }
310
+
311
+ /**
312
+ * Leave the term: at once by default, or after the configured grace period.
313
+ *
314
+ * The grace period has to be CANCELLABLE, which is the whole reason it is a timer and not a
315
+ * sleep: without that, moving from one marked term to the next would schedule a close on leaving
316
+ * the first and then close the second one's card from under the pointer.
317
+ */
318
+ function leaveTerm() {
319
+ const { outMs } = delays();
320
+ if (outMs <= 0) {
321
+ close();
322
+ return;
323
+ }
324
+ if (closeTimer !== null) clearTimeout(closeTimer);
325
+ closeTimer = setTimeout(() => {
326
+ closeTimer = null;
327
+ close();
328
+ }, outMs);
329
+ }
330
+
331
+ /** The card's anchor: the term's own box, never the pointer. */
332
+ function anchorOf(segment) {
333
+ const range = rangeOf(segment);
334
+ if (range === null) return null;
335
+ try {
336
+ const box = range.getBoundingClientRect();
337
+ if (box === null || box === undefined) return null;
338
+ if (box.width === 0 && box.height === 0) return null;
339
+ return { left: box.left, top: box.top, right: box.right, bottom: box.bottom, width: box.width, height: box.height };
340
+ } catch (error) {
341
+ return null;
342
+ }
343
+ }
344
+
345
+ /** Resolve the resting pointer to a card, or close the card. */
346
+ function resolveHover() {
347
+ hoverTimer = null;
348
+ const point = hoverPoint;
349
+ if (point === null) return;
350
+ stats.lookups++;
351
+ const segment = matchAt(point.x, point.y);
352
+ if (segment === null) {
353
+ leaveTerm();
354
+ return;
355
+ }
356
+ // A term is under the pointer, so any pending exit is void.
357
+ cancelClose();
358
+ if (view.kind === "open" && view.segment.key === segment.key) return;
359
+ stats.hits++;
360
+ setActive(segment, activeRegion);
361
+ const anchor = anchorOf(segment);
362
+ setView({ kind: "open", segment, anchor });
363
+ if (onOpen !== null) onOpen(segment, anchor);
364
+ }
365
+
366
+ /**
367
+ * React to pointer movement.
368
+ * @param event - the pointer event.
369
+ */
370
+ function onPointerMove(event) {
371
+ stats.moves++;
372
+ const region = regionFromTarget(event.target);
373
+ activeRegion = region;
374
+ if (region === null) {
375
+ hoverPoint = null;
376
+ if (hoverTimer !== null) {
377
+ clearTimeout(hoverTimer);
378
+ hoverTimer = null;
379
+ }
380
+ close();
381
+ return;
382
+ }
383
+ stats.inRegion++;
384
+ hoverPoint = { x: event.clientX, y: event.clientY };
385
+ // A card that is DISAPPEARING must not wait for the ENTRY delay. That delay exists so a card
386
+ // does not flash while the pointer crosses a word on its way somewhere else — an argument
387
+ // about appearing, not about leaving. Leaving has its own delay, and it defaults to zero.
388
+ if (view.kind === "open") {
389
+ const segment = matchAt(hoverPoint.x, hoverPoint.y);
390
+ if (segment !== null && segment.key === view.segment.key) {
391
+ cancelClose();
392
+ return;
393
+ }
394
+ leaveTerm();
395
+ return;
396
+ }
397
+ if (hoverTimer !== null) clearTimeout(hoverTimer);
398
+ hoverTimer = setTimeout(resolveHover, delays().inMs);
399
+ }
400
+
401
+ /**
402
+ * React to a click: open the entry of the term under the pointer.
403
+ * @param event - the click event.
404
+ */
405
+ function onClick(event) {
406
+ if (event.button !== 0 || event.altKey === true || event.metaKey === true) return;
407
+ stats.clicks++;
408
+ if (regionFromTarget(event.target) === null) return;
409
+ // A click that ENDS a selection is a selection gesture, not a choice.
410
+ //
411
+ // This layer handles the words the dictionary already knows — which are exactly the words a
412
+ // person selects while copying a sentence out of a reply. Dragging across one and releasing on
413
+ // it used to switch the whole main region to the dictionary, so the sentence being copied was
414
+ // replaced by an entry page. `interact.js` has guarded its own click path for as long as it has
415
+ // had one; this path was added later and never got the guard, and the two now share one answer
416
+ // (`selection.js`) so they cannot drift apart again.
417
+ if (selectionActive(doc)) return;
418
+ const segment = matchAt(event.clientX, event.clientY);
419
+ if (segment === null) return;
420
+ stats.opens++;
421
+ close();
422
+ if (onActivate !== null) onActivate(segment);
423
+ }
424
+
425
+ /** Bind every listener. */
426
+ function attach() {
427
+ if (attached) return;
428
+ attached = true;
429
+ const onKeyDown = (event) => {
430
+ if (event.key === "Escape") close();
431
+ };
432
+ const onScroll = () => close();
433
+ // Capture on all four: see the note at the top of this file.
434
+ doc.addEventListener("pointermove", onPointerMove, true);
435
+ doc.addEventListener("click", onClick, true);
436
+ doc.addEventListener("keydown", onKeyDown, true);
437
+ globalThis.addEventListener?.("scroll", onScroll, true);
438
+ options.disposers.push(() => doc.removeEventListener("pointermove", onPointerMove, true));
439
+ options.disposers.push(() => doc.removeEventListener("click", onClick, true));
440
+ options.disposers.push(() => doc.removeEventListener("keydown", onKeyDown, true));
441
+ options.disposers.push(() => globalThis.removeEventListener?.("scroll", onScroll, true));
442
+ }
443
+
444
+ /** Remove every listener and timer. */
445
+ function detach() {
446
+ attached = false;
447
+ if (hoverTimer !== null) clearTimeout(hoverTimer);
448
+ hoverTimer = null;
449
+ cancelClose();
450
+ hoverPoint = null;
451
+ close();
452
+ }
453
+
454
+ return {
455
+ attach,
456
+ detach,
457
+ close,
458
+ matchAt,
459
+ stats,
460
+ view: {
461
+ getSnapshot: () => view,
462
+ subscribe: (listener) => {
463
+ viewListeners.add(listener);
464
+ return () => viewListeners.delete(listener);
465
+ }
466
+ }
467
+ };
468
+ }
469
+
470
+ module.exports = { createHoverLayer, resolveCaret, ACTIVE_HIGHLIGHT, HOT_CLASS, HOVER_DELAY_MS };
@@ -0,0 +1,173 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * The hover card: the brief explanation shown while the pointer rests on a marked term.
5
+ *
6
+ * Moved in from `dsh-plugin-term-wikilink` with one change of substance — the content now comes
7
+ * from this plugin's own store and detector instead of a fetched term list, so there is no
8
+ * "the dictionary is unreachable" state to degrade into.
9
+ *
10
+ * Two properties are deliberate and worth keeping:
11
+ *
12
+ * - it is a fixed-position overlay, because the transcript belongs to the host renderer and a
13
+ * plugin must not write into it;
14
+ * - it carries `pointer-events: none`, because the card is a reading aid and the click that opens
15
+ * the entry has to reach the text underneath it.
16
+ *
17
+ * `data-term-dictionary="hover-card"` marks it as one of this plugin's own surfaces, which is what
18
+ * keeps the layer from treating the card itself as a term (the marker is in the plugin's blocked
19
+ * set).
20
+ */
21
+
22
+ const React = require("react");
23
+
24
+ const h = React.createElement;
25
+
26
+ /** The registry key and the cursor class, shared with the pointer layer that creates them. */
27
+ const { ACTIVE_HIGHLIGHT, HOT_CLASS } = require("./hover.js");
28
+
29
+ /** Gap between the term's box and the card. */
30
+ const ANCHOR_GAP = 10;
31
+
32
+ /** Minimum distance from a viewport edge. */
33
+ const EDGE_GAP = 10;
34
+
35
+ /** Shared surface recipe, kept to theme tokens the platform actually defines. */
36
+ const SURFACE = {
37
+ background: "var(--dsw-alias-bg-overlay)",
38
+ border: "0.5px solid var(--dsw-alias-border-l2)",
39
+ borderRadius: "var(--dsw-radius-lg, 12px)"
40
+ };
41
+
42
+ const STYLES = {
43
+ card: {
44
+ ...SURFACE,
45
+ position: "fixed",
46
+ zIndex: 2147482000,
47
+ width: "min(340px, calc(100vw - 24px))",
48
+ maxHeight: "min(52vh, 360px)",
49
+ overflowY: "auto",
50
+ boxShadow: "0 10px 30px rgba(0, 0, 0, 0.22)",
51
+ padding: "12px 14px",
52
+ display: "flex",
53
+ flexDirection: "column",
54
+ gap: "6px",
55
+ font: "inherit",
56
+ color: "var(--dsw-alias-label-primary)",
57
+ // The card must never eat a pointer event: the click belongs to the term underneath.
58
+ pointerEvents: "none"
59
+ },
60
+ head: { display: "flex", alignItems: "baseline", gap: "8px", flexWrap: "wrap" },
61
+ term: { margin: 0, fontSize: "15px", fontWeight: 600, lineHeight: 1.3 },
62
+ zh: { fontSize: "13px", color: "var(--dsw-alias-label-secondary)" },
63
+ domain: {
64
+ fontSize: "10px",
65
+ lineHeight: 1.7,
66
+ padding: "0 6px",
67
+ borderRadius: "999px",
68
+ color: "var(--dsw-alias-label-secondary)",
69
+ background: "var(--dsw-alias-bg-layer-2, transparent)",
70
+ border: "0.5px solid var(--dsw-alias-border-l2)"
71
+ },
72
+ gloss: { margin: 0, fontSize: "13px", lineHeight: 1.7, color: "var(--dsw-alias-label-secondary)", whiteSpace: "pre-wrap" },
73
+ glossMuted: { margin: 0, fontSize: "13px", fontStyle: "italic", color: "var(--dsw-alias-label-tertiary)" },
74
+ usage: {
75
+ margin: 0,
76
+ fontSize: "12px",
77
+ lineHeight: 1.6,
78
+ color: "var(--dsw-alias-label-tertiary)",
79
+ paddingLeft: "8px",
80
+ borderLeft: "2px solid var(--dsw-alias-border-l2)",
81
+ whiteSpace: "pre-wrap"
82
+ },
83
+ footer: {
84
+ margin: 0,
85
+ paddingTop: "6px",
86
+ borderTop: "0.5px solid var(--dsw-alias-border-l2)",
87
+ fontSize: "11px",
88
+ color: "var(--dsw-alias-brand-primary, var(--dsw-alias-label-secondary))"
89
+ }
90
+ };
91
+
92
+ /**
93
+ * The card itself.
94
+ *
95
+ * @param props - `entry` (`{ term, zh, gloss, usage, domain }`), `anchor`, `t`.
96
+ * @returns the card element.
97
+ */
98
+ function HoverCard(props) {
99
+ const { entry, anchor, t } = props;
100
+ const ref = React.useRef(null);
101
+ const [placement, setPlacement] = React.useState(null);
102
+
103
+ // Measure after paint, then pull the box back inside the viewport. The anchor is the TERM's
104
+ // box: anchoring to the pointer made the card follow the mouse, which reads as jitter rather
105
+ // than as a tooltip. A card belongs to the word.
106
+ React.useLayoutEffect(() => {
107
+ const element = ref.current;
108
+ if (element === null || anchor === null || anchor === undefined) return;
109
+ const box = element.getBoundingClientRect();
110
+ const width = globalThis.innerWidth ?? 0;
111
+ const height = globalThis.innerHeight ?? 0;
112
+ let left = anchor.left;
113
+ let top = anchor.bottom + ANCHOR_GAP;
114
+ // No room below the word: open above it, the way a tooltip should.
115
+ if (height > 0 && top + box.height > height - EDGE_GAP) top = Math.max(EDGE_GAP, anchor.top - ANCHOR_GAP - box.height);
116
+ if (width > 0 && left + box.width > width - EDGE_GAP) left = Math.max(EDGE_GAP, width - EDGE_GAP - box.width);
117
+ setPlacement({ left: Math.round(left), top: Math.round(top) });
118
+ }, [anchor?.left, anchor?.top, anchor?.bottom, entry?.key, entry?.gloss]);
119
+
120
+ const gloss = typeof entry?.gloss === "string" ? entry.gloss : "";
121
+ const style = {
122
+ ...STYLES.card,
123
+ ...(placement === null ? { left: "-9999px", top: "-9999px" } : { left: `${placement.left}px`, top: `${placement.top}px` })
124
+ };
125
+
126
+ return h(
127
+ "div",
128
+ { ref, style, role: "tooltip", "aria-label": entry?.term ?? "", "data-term-dictionary": "hover-card" },
129
+ h(
130
+ "div",
131
+ { style: STYLES.head },
132
+ h("p", { style: STYLES.term }, entry?.term ?? ""),
133
+ entry?.zh !== undefined && entry?.zh !== "" ? h("span", { style: STYLES.zh }, entry.zh) : null,
134
+ entry?.domain !== undefined && entry?.domain !== "" ? h("span", { style: STYLES.domain }, entry.domain) : null
135
+ ),
136
+ gloss !== "" ? h("p", { style: STYLES.gloss }, gloss) : h("p", { style: STYLES.glossMuted }, t("hoverNoGloss")),
137
+ entry?.usage !== undefined && entry?.usage !== "" ? h("p", { style: STYLES.usage }, entry.usage) : null,
138
+ h("p", { style: STYLES.footer }, t("hoverOpenEntry"))
139
+ );
140
+ }
141
+
142
+ /**
143
+ * The stylesheet the pointer layer needs, rendered as an element so unmounting removes it.
144
+ *
145
+ * Two rules, neither of which can be an inline style:
146
+ *
147
+ * - `::highlight(term-dictionary-active)` is the hovered term's emphasis. The transcript word has
148
+ * no element to give a class to — it is painted through the CSS Custom Highlight API — so "this
149
+ * link is live" can only be expressed as a highlight. It sits above the annotation's own
150
+ * marking by `priority`.
151
+ * - `.term-dictionary-hot` is the cursor. `cursor` is NOT one of the properties `::highlight()`
152
+ * may set, and the browser shows an I-beam over selectable text, which reads as plain text —
153
+ * the opposite of a link. The class goes on the conversation body for exactly as long as the
154
+ * pointer is on a term, so the rest of the reply keeps its normal cursor, and `!important` is
155
+ * needed because the host's own text rules set a text cursor.
156
+ *
157
+ * @returns the style element.
158
+ */
159
+ function HoverStyle() {
160
+ return h(
161
+ "style",
162
+ { "data-term-dictionary": "hover-style" },
163
+ `::highlight(${ACTIVE_HIGHLIGHT}) {` +
164
+ " background-color: var(--dsw-alias-interactive-bg-hover, rgba(77, 107, 254, 0.16));" +
165
+ " text-decoration: underline solid var(--dsw-alias-brand-primary, #4d6bfe);" +
166
+ " text-decoration-thickness: 2px;" +
167
+ " text-underline-offset: 2px;" +
168
+ " }" +
169
+ `.${HOT_CLASS}, .${HOT_CLASS} * { cursor: pointer !important; }`
170
+ );
171
+ }
172
+
173
+ module.exports = { HoverCard, HoverStyle, STYLES };