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 CHANGED
@@ -9,7 +9,7 @@
9
9
 
10
10
  English | [中文](README.zh-CN.md)
11
11
 
12
- ![language](https://img.shields.io/badge/language-javascript-f7dc6f) ![license](https://img.shields.io/badge/license-MIT-blue) ![node](https://img.shields.io/badge/node-%E2%89%A5%2022-brightgreen) ![dsh](https://img.shields.io/badge/dsh-web%20profile-orange) ![runtime](https://img.shields.io/badge/runtime-offline_%C2%B7_100%25_local-brightgreen) ![tests](https://img.shields.io/badge/tests-128%20checks-brightgreen)
12
+ ![language](https://img.shields.io/badge/language-javascript-f7dc6f) ![license](https://img.shields.io/badge/license-MIT-blue) ![node](https://img.shields.io/badge/node-%E2%89%A5%2022-brightgreen) ![dsh](https://img.shields.io/badge/dsh-web%20profile-orange) ![runtime](https://img.shields.io/badge/runtime-offline_%C2%B7_100%25_local-brightgreen) ![tests](https://img.shields.io/badge/tests-136%20checks-brightgreen) ![npm](https://img.shields.io/npm/v/dsh-spell-check)
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
- ![language](https://img.shields.io/badge/language-javascript-f7dc6f) ![license](https://img.shields.io/badge/license-MIT-blue) ![node](https://img.shields.io/badge/node-%E2%89%A5%2022-brightgreen) ![dsh](https://img.shields.io/badge/dsh-web%20profile-orange) ![runtime](https://img.shields.io/badge/runtime-offline_%C2%B7_100%25_local-brightgreen) ![tests](https://img.shields.io/badge/tests-129%20checks-brightgreen)
12
+ ![language](https://img.shields.io/badge/language-javascript-f7dc6f) ![license](https://img.shields.io/badge/license-MIT-blue) ![node](https://img.shields.io/badge/node-%E2%89%A5%2022-brightgreen) ![dsh](https://img.shields.io/badge/dsh-web%20profile-orange) ![runtime](https://img.shields.io/badge/runtime-offline_%C2%B7_100%25_local-brightgreen) ![tests](https://img.shields.io/badge/tests-136%20checks-brightgreen) ![npm](https://img.shields.io/npm/v/dsh-spell-check)
13
13
 
14
14
  一个 [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) 插件,像 Chrome 那样给输入框做拼写检查:正在输入的词会边打边查,改错时手不用离开键盘。
15
15
 
package/dsh.plugin.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "id": "spell-check",
3
- "version": "0.2.1",
3
+ "version": "0.2.2",
4
4
  "main": "src/index.js",
5
5
  "description": "Chrome-style local spell check for the dsh composer",
6
6
  "engines": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-spell-check",
3
- "version": "0.2.1",
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",
@@ -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 stub only has to not throw.
66
- getContext: () => ({
67
- setTransform() {},
68
- clearRect() {},
69
- beginPath() {},
70
- moveTo() {},
71
- quadraticCurveTo() {},
72
- stroke() {},
73
- strokeStyle: '',
74
- lineWidth: 0,
75
- lineCap: '',
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: fakeElement,
106
- createTreeWalker: () => ({ nextNode: () => null }),
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: () => null,
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('[data-lexical-editor="true"][contenteditable="true"]');
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
  },