dsh-spell-check 0.2.1 → 0.2.2
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/README.md +1 -1
- package/README.zh-CN.md +1 -1
- package/dsh.plugin.json +1 -1
- package/package.json +1 -1
- package/scripts/test-client.mjs +107 -16
- package/src/client.js +93 -1
package/README.md
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
|
|
10
10
|
English | [中文](README.zh-CN.md)
|
|
11
11
|
|
|
12
|
-
           
|
|
13
13
|
|
|
14
14
|
A [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) plugin that spell-checks the composer the way Chrome does: the word you are typing is checked as you type it, and fixing it never takes your hands off the keyboard.
|
|
15
15
|
|
package/README.zh-CN.md
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
|
|
10
10
|
[English](README.md) | 中文
|
|
11
11
|
|
|
12
|
-
           
|
|
13
13
|
|
|
14
14
|
一个 [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) 插件,像 Chrome 那样给输入框做拼写检查:正在输入的词会边打边查,改错时手不用离开键盘。
|
|
15
15
|
|
package/dsh.plugin.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-spell-check",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.2",
|
|
4
4
|
"description": "Chrome-style spell check for the dsh composer: the misspelled word under the caret gets a suggestion strip, and Tab accepts it. Local only — no network, no LLM, no telemetry.",
|
|
5
5
|
"icon": "assets/icon.png",
|
|
6
6
|
"type": "module",
|
package/scripts/test-client.mjs
CHANGED
|
@@ -62,18 +62,30 @@ const fakeElement = (tag) => {
|
|
|
62
62
|
getAttribute(key) {
|
|
63
63
|
return this.attributes[key] ?? null
|
|
64
64
|
},
|
|
65
|
-
// The underliner draws on a canvas; the
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
65
|
+
// The underliner draws on a canvas; record the calls so a test can prove a
|
|
66
|
+
// repaint happened (the stale-underline bug was a repaint that never ran).
|
|
67
|
+
getContext() {
|
|
68
|
+
if (!this.__ctx) {
|
|
69
|
+
this.__draws = { clear: 0, stroke: 0 }
|
|
70
|
+
const draws = this.__draws
|
|
71
|
+
this.__ctx = {
|
|
72
|
+
setTransform() {},
|
|
73
|
+
clearRect() {
|
|
74
|
+
draws.clear += 1
|
|
75
|
+
},
|
|
76
|
+
beginPath() {},
|
|
77
|
+
moveTo() {},
|
|
78
|
+
quadraticCurveTo() {},
|
|
79
|
+
stroke() {
|
|
80
|
+
draws.stroke += 1
|
|
81
|
+
},
|
|
82
|
+
strokeStyle: '',
|
|
83
|
+
lineWidth: 0,
|
|
84
|
+
lineCap: '',
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return this.__ctx
|
|
88
|
+
},
|
|
77
89
|
addEventListener() {},
|
|
78
90
|
removeEventListener() {},
|
|
79
91
|
contains() {
|
|
@@ -92,18 +104,43 @@ const fakeElement = (tag) => {
|
|
|
92
104
|
return node
|
|
93
105
|
}
|
|
94
106
|
|
|
95
|
-
globalThis.Node = class Node {}
|
|
107
|
+
globalThis.Node = class Node {}
|
|
96
108
|
globalThis.NodeFilter = { SHOW_TEXT: 4 }
|
|
97
109
|
globalThis.Element = class Element {
|
|
98
110
|
closest() {
|
|
99
111
|
return null
|
|
100
112
|
}
|
|
101
113
|
}
|
|
114
|
+
|
|
115
|
+
// Observers the underliner uses to repaint when the DOM changes without a
|
|
116
|
+
// keyboard event. The stubs capture the callback so a test can fire it by hand.
|
|
117
|
+
const createdCanvases = []
|
|
118
|
+
let queryResult = null
|
|
119
|
+
let mutationCallback = null
|
|
120
|
+
const intervals = []
|
|
121
|
+
globalThis.MutationObserver = class {
|
|
122
|
+
constructor(callback) {
|
|
123
|
+
mutationCallback = callback
|
|
124
|
+
}
|
|
125
|
+
observe() {}
|
|
126
|
+
disconnect() {
|
|
127
|
+
mutationCallback = null
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
globalThis.ResizeObserver = class {
|
|
131
|
+
observe() {}
|
|
132
|
+
disconnect() {}
|
|
133
|
+
}
|
|
134
|
+
|
|
102
135
|
globalThis.document = {
|
|
103
136
|
head: { appendChild: (node) => styles.push(node) },
|
|
104
137
|
body: { appendChild: (node) => { node.parentNode = { body: true } } },
|
|
105
|
-
createElement:
|
|
106
|
-
|
|
138
|
+
createElement: (tag) => {
|
|
139
|
+
const node = fakeElement(tag)
|
|
140
|
+
if (tag === 'canvas') createdCanvases.push(node)
|
|
141
|
+
return node
|
|
142
|
+
},
|
|
143
|
+
createTreeWalker: () => ({ nextNode: () => null }),
|
|
107
144
|
createRange: () => ({
|
|
108
145
|
setStart() {},
|
|
109
146
|
setEnd() {},
|
|
@@ -113,7 +150,7 @@ globalThis.document = {
|
|
|
113
150
|
toString: () => '',
|
|
114
151
|
}),
|
|
115
152
|
getElementById: () => null,
|
|
116
|
-
querySelector: () =>
|
|
153
|
+
querySelector: () => queryResult,
|
|
117
154
|
querySelectorAll: () => [],
|
|
118
155
|
addEventListener: (type) => listeners.push(type),
|
|
119
156
|
removeEventListener: (type) => {
|
|
@@ -130,6 +167,11 @@ globalThis.window = {
|
|
|
130
167
|
return 1
|
|
131
168
|
},
|
|
132
169
|
clearTimeout: () => {},
|
|
170
|
+
setInterval: (fn, ms) => {
|
|
171
|
+
intervals.push({ fn, ms })
|
|
172
|
+
return intervals.length
|
|
173
|
+
},
|
|
174
|
+
clearInterval: () => {},
|
|
133
175
|
addEventListener: (type) => listeners.push('win:' + type),
|
|
134
176
|
removeEventListener: (type) => {
|
|
135
177
|
const index = listeners.indexOf('win:' + type)
|
|
@@ -300,6 +342,55 @@ check(
|
|
|
300
342
|
)
|
|
301
343
|
check('detach releases the window slot', globalThis.window.__dshSpellCheck === undefined)
|
|
302
344
|
|
|
345
|
+
/* --- the stale-underline regression ----------------------------------- */
|
|
346
|
+
|
|
347
|
+
// The reported bug: after pressing Enter to send, the blue underline stayed
|
|
348
|
+
// floating in mid-air over the now-empty composer, and only vanished when a
|
|
349
|
+
// screenshot hotkey caused an unrelated focus/resize repaint. Root cause: the
|
|
350
|
+
// full-window canvas was repainted ONLY by keyboard/selection events, and a
|
|
351
|
+
// programmatic draft-clear that moves focus out of the editor fires none of
|
|
352
|
+
// them. These checks prove the canvas now repaints from the DOM itself.
|
|
353
|
+
|
|
354
|
+
const fakeBox = { top: 100, left: 120, width: 600, height: 40 }
|
|
355
|
+
const fakeEditor = {
|
|
356
|
+
textContent: 'theh',
|
|
357
|
+
getBoundingClientRect: () => fakeBox,
|
|
358
|
+
}
|
|
359
|
+
queryResult = fakeEditor
|
|
360
|
+
|
|
361
|
+
const underliner = moduleExports.createUnderliner()
|
|
362
|
+
const underlineCanvas = createdCanvases[createdCanvases.length - 1]
|
|
363
|
+
const releaseUnderline = underliner.attach()
|
|
364
|
+
|
|
365
|
+
check('underliner paints once on attach', underlineCanvas.__draws.clear === 1, String(underlineCanvas.__draws.clear))
|
|
366
|
+
check('underliner watches the editor DOM', mutationCallback !== null)
|
|
367
|
+
check('underliner exposes observe', typeof underliner.observe === 'function')
|
|
368
|
+
|
|
369
|
+
// Path A — the actual bug: the draft is cleared programmatically (send) and NO
|
|
370
|
+
// keyboard or selection event fires. The MutationObserver must still repaint.
|
|
371
|
+
fakeEditor.textContent = ''
|
|
372
|
+
mutationCallback()
|
|
373
|
+
check(
|
|
374
|
+
'a programmatic clear repaints with no keyboard event',
|
|
375
|
+
underlineCanvas.__draws.clear === 2,
|
|
376
|
+
String(underlineCanvas.__draws.clear),
|
|
377
|
+
)
|
|
378
|
+
|
|
379
|
+
// Path B — the composer shrank/moved after send (the "mid-air" geometry). The
|
|
380
|
+
// self-heal tick must notice the box changed and repaint even with no mutation.
|
|
381
|
+
fakeBox.top = 40
|
|
382
|
+
const heal = intervals[intervals.length - 1].fn
|
|
383
|
+
heal()
|
|
384
|
+
check('self-heal repaints when the editor box moves', underlineCanvas.__draws.clear === 3, String(underlineCanvas.__draws.clear))
|
|
385
|
+
|
|
386
|
+
// Path C — heal is idempotent: an unchanged editor must not repaint every tick.
|
|
387
|
+
heal()
|
|
388
|
+
check('self-heal is a no-op when nothing changed', underlineCanvas.__draws.clear === 3, String(underlineCanvas.__draws.clear))
|
|
389
|
+
|
|
390
|
+
releaseUnderline()
|
|
391
|
+
check('detach disconnects the DOM observer', mutationCallback === null)
|
|
392
|
+
queryResult = null
|
|
393
|
+
|
|
303
394
|
/* --- unmount ---------------------------------------------------------- */
|
|
304
395
|
|
|
305
396
|
for (const cleanup of effectCleanups) cleanup()
|
package/src/client.js
CHANGED
|
@@ -1420,6 +1420,64 @@ function createEngine(words, typoMap = TYPO_MAP) {
|
|
|
1420
1420
|
canvas.className = "sp-underline";
|
|
1421
1421
|
canvas.setAttribute("aria-hidden", "true");
|
|
1422
1422
|
let timer = null;
|
|
1423
|
+
// The canvas is fixed and full-window, and every mark is painted at an
|
|
1424
|
+
// absolute viewport coordinate. That means it is only correct at the
|
|
1425
|
+
// instant it is painted: the moment the editor's text or geometry changes
|
|
1426
|
+
// without a repaint, the old marks hang in the air like an imprint.
|
|
1427
|
+
//
|
|
1428
|
+
// Keyboard and selection events are NOT enough to keep it correct. Sending
|
|
1429
|
+
// a message clears the draft programmatically and usually moves focus out
|
|
1430
|
+
// of the composer, so no `keyup`/`selectionchange` fires a repaint — the
|
|
1431
|
+
// exact "underline stays after I press Enter" bug. These observers watch
|
|
1432
|
+
// the DOM and the box directly, so a clear or a resize repaints regardless
|
|
1433
|
+
// of where focus went.
|
|
1434
|
+
let mutationObserver = null;
|
|
1435
|
+
let resizeObserver = null;
|
|
1436
|
+
let observedEditor = null;
|
|
1437
|
+
let lastSignature = "";
|
|
1438
|
+
let healTimer = null;
|
|
1439
|
+
|
|
1440
|
+
const EDITOR_SELECTOR = '[data-lexical-editor="true"][contenteditable="true"]';
|
|
1441
|
+
|
|
1442
|
+
/** A cheap fingerprint of "what the canvas should currently show". */
|
|
1443
|
+
function signature(editor) {
|
|
1444
|
+
if (editor === null || editor === undefined) return "";
|
|
1445
|
+
let rect = null;
|
|
1446
|
+
try {
|
|
1447
|
+
rect = editor.getBoundingClientRect();
|
|
1448
|
+
} catch {
|
|
1449
|
+
rect = null;
|
|
1450
|
+
}
|
|
1451
|
+
const box = rect
|
|
1452
|
+
? Math.round(rect.left) + "," + Math.round(rect.top) + "," + Math.round(rect.width) + "," + Math.round(rect.height)
|
|
1453
|
+
: "0";
|
|
1454
|
+
// Length, not the whole string: a clear (→0), an edit, a remount or a
|
|
1455
|
+
// resize all change it, and it stays O(1) to compare on every heal tick.
|
|
1456
|
+
return (editor.textContent ?? "").length + ":" + box;
|
|
1457
|
+
}
|
|
1458
|
+
|
|
1459
|
+
/** Keep the observers pointed at the live editor, even across remounts. */
|
|
1460
|
+
function observe(editor) {
|
|
1461
|
+
if (editor === observedEditor) return;
|
|
1462
|
+
observedEditor = editor;
|
|
1463
|
+
if (mutationObserver !== null) {
|
|
1464
|
+
mutationObserver.disconnect();
|
|
1465
|
+
mutationObserver = null;
|
|
1466
|
+
}
|
|
1467
|
+
if (resizeObserver !== null) {
|
|
1468
|
+
resizeObserver.disconnect();
|
|
1469
|
+
resizeObserver = null;
|
|
1470
|
+
}
|
|
1471
|
+
if (editor === null || editor === undefined) return;
|
|
1472
|
+
if (typeof MutationObserver !== "undefined") {
|
|
1473
|
+
mutationObserver = new MutationObserver(() => schedule(50));
|
|
1474
|
+
mutationObserver.observe(editor, { childList: true, characterData: true, subtree: true });
|
|
1475
|
+
}
|
|
1476
|
+
if (typeof ResizeObserver !== "undefined") {
|
|
1477
|
+
resizeObserver = new ResizeObserver(() => schedule(50));
|
|
1478
|
+
resizeObserver.observe(editor);
|
|
1479
|
+
}
|
|
1480
|
+
}
|
|
1423
1481
|
|
|
1424
1482
|
function ensure() {
|
|
1425
1483
|
if (canvas.parentNode === null) document.body.appendChild(canvas);
|
|
@@ -1451,7 +1509,9 @@ function createEngine(words, typoMap = TYPO_MAP) {
|
|
|
1451
1509
|
|
|
1452
1510
|
/** Repaint every misspelled word currently laid out in the editor. */
|
|
1453
1511
|
function paint() {
|
|
1454
|
-
const editor = document.querySelector(
|
|
1512
|
+
const editor = document.querySelector(EDITOR_SELECTOR);
|
|
1513
|
+
observe(editor);
|
|
1514
|
+
lastSignature = signature(editor);
|
|
1455
1515
|
const ratio = ensure();
|
|
1456
1516
|
const context = canvas.getContext("2d");
|
|
1457
1517
|
if (context === null) return;
|
|
@@ -1525,14 +1585,39 @@ function createEngine(words, typoMap = TYPO_MAP) {
|
|
|
1525
1585
|
timer = window.setTimeout(paint, delay);
|
|
1526
1586
|
}
|
|
1527
1587
|
|
|
1588
|
+
/**
|
|
1589
|
+
* Safety net for any change no event announced.
|
|
1590
|
+
*
|
|
1591
|
+
* The observers above catch the common cases immediately; this catches the
|
|
1592
|
+
* rest — an editor remounted onto a new node, a programmatic clear that
|
|
1593
|
+
* raced focus, a layout shift — by comparing a cheap fingerprint and
|
|
1594
|
+
* repainting only when it actually changed. Without it, a single missed
|
|
1595
|
+
* event left a permanent imprint until the next unrelated repaint.
|
|
1596
|
+
*/
|
|
1597
|
+
function heal() {
|
|
1598
|
+
const editor = document.querySelector(EDITOR_SELECTOR);
|
|
1599
|
+
if (signature(editor) !== lastSignature) paint();
|
|
1600
|
+
}
|
|
1601
|
+
|
|
1528
1602
|
return {
|
|
1529
1603
|
schedule,
|
|
1530
1604
|
paint,
|
|
1605
|
+
observe,
|
|
1531
1606
|
attach() {
|
|
1532
1607
|
ensure();
|
|
1533
1608
|
schedule(0);
|
|
1609
|
+
if (typeof window.setInterval === "function") {
|
|
1610
|
+
healTimer = window.setInterval(heal, 400);
|
|
1611
|
+
}
|
|
1534
1612
|
return () => {
|
|
1535
1613
|
window.clearTimeout(timer);
|
|
1614
|
+
if (healTimer !== null && typeof window.clearInterval === "function") {
|
|
1615
|
+
window.clearInterval(healTimer);
|
|
1616
|
+
healTimer = null;
|
|
1617
|
+
}
|
|
1618
|
+
if (mutationObserver !== null) mutationObserver.disconnect();
|
|
1619
|
+
if (resizeObserver !== null) resizeObserver.disconnect();
|
|
1620
|
+
observedEditor = null;
|
|
1536
1621
|
canvas.remove();
|
|
1537
1622
|
};
|
|
1538
1623
|
},
|
|
@@ -1626,6 +1711,7 @@ function createEngine(words, typoMap = TYPO_MAP) {
|
|
|
1626
1711
|
// The underlines follow every keystroke; the strip follows only the
|
|
1627
1712
|
// word under the caret.
|
|
1628
1713
|
silenceNativeSpellcheck(editor);
|
|
1714
|
+
underliner.observe(editor);
|
|
1629
1715
|
underliner.schedule();
|
|
1630
1716
|
const at = tokenAtCaret(editor);
|
|
1631
1717
|
if (at === null) {
|
|
@@ -1985,6 +2071,11 @@ function createEngine(words, typoMap = TYPO_MAP) {
|
|
|
1985
2071
|
}
|
|
1986
2072
|
|
|
1987
2073
|
function onViewportChange() {
|
|
2074
|
+
// The underline canvas is fixed and full-window with marks at absolute
|
|
2075
|
+
// viewport coordinates, so a scroll or resize detaches every mark from
|
|
2076
|
+
// its word until it is repainted. Repaint unconditionally, not only when
|
|
2077
|
+
// the strip happens to be visible.
|
|
2078
|
+
underliner.schedule();
|
|
1988
2079
|
if (visible) position();
|
|
1989
2080
|
}
|
|
1990
2081
|
|
|
@@ -2229,6 +2320,7 @@ function createEngine(words, typoMap = TYPO_MAP) {
|
|
|
2229
2320
|
// Exported for the test harness only; never read by dsh.
|
|
2230
2321
|
exports.engine = ENGINE;
|
|
2231
2322
|
exports.createController = createController;
|
|
2323
|
+
exports.createUnderliner = createUnderliner;
|
|
2232
2324
|
|
|
2233
2325
|
return module.exports;
|
|
2234
2326
|
},
|