@bobfrankston/rmfmail 1.1.242 → 1.1.244

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.
@@ -1,754 +1,256 @@
1
1
  /**
2
- * Live spell-check for the TinyMCE compose editor.
2
+ * Spell-check for the TinyMCE compose editor — NON-MUTATING OVERLAY.
3
3
  *
4
- * Why this exists: WebView2's built-in spell checker isn't reliably
5
- * enabled on our msger-hosted compose iframe (see msger main.rs around
6
- * AreDefaultContextMenusEnabled the comment claims it leaves
7
- * defaults for spell-suggest menus, but `IsSpellcheckEnabled` is never
8
- * explicitly set and the default varies by WebView2 runtime). Rather
9
- * than depend on the host, we ship a JS spellchecker (`nspell`) + the
10
- * standard `dictionary-en` Hunspell files.
4
+ * Why this rewrite (Bob 2026-06-12): the previous version drew the red
5
+ * squiggles by WRAPPING each misspelled word in a `<span>` inside the live
6
+ * contenteditable, on a debounce, while the user typed then tried to put
7
+ * the caret back by re-counting characters. That approach fought the user's
8
+ * typing: it yanked the cursor to a different position, and because it
9
+ * reshaped the DOM between keystrokes it corrupted TinyMCE's undo stack so a
10
+ * single Ctrl+Z reverted a whole reshape instead of the last few characters.
11
+ * It had been patched ~10 times and still regressed. The editor became
12
+ * untrustworthy.
11
13
  *
12
- * Two layers:
13
- * 1. Live decoration words that nspell flags get wrapped in a
14
- * `<span data-mailx-spellerror="1">` while editing. CSS gives them
15
- * a wavy red underline (text-decoration: underline wavy #d33). The
16
- * markers are stripped at serialization time so they never reach
17
- * the sent message or the saved draft.
18
- * 2. Right-click — clicking on a marker shows our own popup with
19
- * suggestions plus "Add to dictionary" / "Ignore (this session)".
14
+ * The fix is to stop editing the content at all. We:
15
+ * 1. READ the body's text nodes (TreeWalker) and ask nspell which words are
16
+ * misspelled no mutation.
17
+ * 2. Measure each misspelled word's on-screen rectangle with a DOM Range's
18
+ * getClientRects(), and draw a wavy red underline at that rectangle in a
19
+ * SEPARATE overlay layer.
20
20
  *
21
- * Decoration runs:
22
- * - Once after the editor inits + dict loads (initial scan of pre-
23
- * populated quote / signature, though we skip blockquote content).
24
- * - Debounced after every input (~500 ms idle).
25
- * - After setContent (paste, reply-quote insertion, …).
21
+ * The overlay is a single `<div>` appended to the body but marked
22
+ * `contenteditable="false"` + `data-mce-bogus="all"` (TinyMCE excludes bogus
23
+ * elements from both serialization and the undo snapshot) and
24
+ * `pointer-events:none` (the caret can never enter it, clicks pass through to
25
+ * the text). Mutating it therefore can't move the cursor, can't pollute undo,
26
+ * and can't leak into the sent message or the saved draft. The squiggles are
27
+ * absolutely positioned in the body's content coordinate space, so they scroll
28
+ * with the text without any per-scroll work.
26
29
  *
27
- * The decoration walker:
28
- * - Skips `<blockquote>` (the quoted reply not the user's words),
29
- * `<code>`, `<pre>`, `<a>` (URLs aren't natural-language words),
30
- * and content inside any existing marker (so re-scans don't double-
31
- * wrap).
32
- * - Uses TinyMCE's `undoManager.ignore` + selection bookmarks so the
33
- * decoration mutations don't pollute undo and don't move the caret.
30
+ * Right-click uses getWordAtPoint() (find the word under the click directly),
31
+ * so there are no marker spans to hang suggestions off of. Applying a
32
+ * correction goes through TinyMCE's own selection + insertContent so it's
33
+ * undo-tracked and focus-safe.
34
34
  *
35
- * Dictionary persistence:
36
- * - User additions: `localStorage["mailx-user-dict"]` (a JSON array).
37
- * - "Ignore this session": in-memory only via `nspell.add()`.
35
+ * Dictionary load, suggestion building, the floating menu, word-at-point, and
36
+ * user-dictionary persistence are all shared with the Quill adapter via
37
+ * spellcheck-core.ts.
38
38
  */
39
- // @ts-expect-error — nspell ships no type defs. Treated as `any`; the
40
- // surface we use (`new NSpell({aff, dic})`, `.correct`, `.suggest`,
41
- // `.add`) is small and stable.
42
- import NSpell from "nspell";
43
- import { getUserDict, addUserDictWord, addUserDictWords } from "../lib/api-client.js";
44
- type NSpell = any;
39
+ import {
40
+ getSpell, addToUserDict, buildSuggestionList, showSuggestionsMenu,
41
+ getWordAtPoint, MIN_WORD_LEN, SKIP_TAGS, type NSpellInstance, type MenuItem,
42
+ } from "./spellcheck-core.js";
45
43
 
46
- // Cloud-mirrored dictionary. `userdict.csv` on GDrive (a plain one-word-
47
- // per-line list) is the source of truth; the localStorage entry is a
48
- // write-through cache so the popup of suggestions can resolve synchronously
49
- // while the service round-trips the add.
50
- const USER_DICT_KEY = "mailx-user-dict";
51
- const MARKER_ATTR = "data-mailx-spellerror";
52
- // Long enough that a mid-word pause doesn't fire decoration. Bob 2026-05-12:
53
- // "popping up the redlines too quickly and then very slow about removing
54
- // them and you keep extending them. At least wait till I finish typing a
55
- // word." 500 ms was too eager. 1200 ms feels reactive after pause but
56
- // stays out of the way while typing.
57
- const DECORATE_DEBOUNCE_MS = 1200;
58
- // Removal-only cleanup runs on a much shorter debounce than the full
59
- // decorate pass. Removing a corrected word's red underline can't thrash
60
- // the way *adding* underlines mid-typing can, so it doesn't need the calm
61
- // 1200 ms — a corrected word's underline clearing in ~0.3 s instead of
62
- // 1.2 s is the difference Bob asked about (2026-05-17).
63
- const CLEANUP_DEBOUNCE_MS = 300;
64
- const MIN_WORD_LEN = 3;
65
- const SKIP_TAGS = new Set(["BLOCKQUOTE", "CODE", "PRE", "A", "SCRIPT", "STYLE", "KBD", "SAMP", "VAR"]);
44
+ // Re-scan after the user pauses. Long enough not to fire mid-word; short
45
+ // enough to feel responsive. Nothing about this debounce affects typing
46
+ // stability anymore (the scan never touches the content), so it's purely a
47
+ // CPU/perf knob.
48
+ const SCAN_DEBOUNCE_MS = 600;
66
49
 
67
- let spellPromise: Promise<NSpell> | null = null;
68
- async function getSpell(): Promise<NSpell> {
69
- if (spellPromise) return spellPromise;
70
- spellPromise = (async () => {
71
- const [affRes, dicRes] = await Promise.all([
72
- fetch("../lib/dict/en.aff"),
73
- fetch("../lib/dict/en.dic"),
74
- ]);
75
- if (!affRes.ok || !dicRes.ok) {
76
- throw new Error(`spellcheck: dict fetch failed (aff=${affRes.status} dic=${dicRes.status})`);
77
- }
78
- const [aff, dic] = await Promise.all([affRes.text(), dicRes.text()]);
79
- const sp = new NSpell({ aff, dic });
80
- // 1) Seed from local cache so the editor never has to wait on
81
- // network for known-correct words to disappear from the
82
- // redline pass.
83
- try {
84
- const raw = localStorage.getItem(USER_DICT_KEY);
85
- if (raw) for (const w of JSON.parse(raw) as string[]) sp.add(w);
86
- } catch { /* corrupt cache — start clean */ }
87
- // 2) Pull the cloud copy, union it in, and reconcile. Fire-and-forget
88
- // — if it fails the cache still works.
89
- getUserDict().then(cloud => {
90
- const cloudArr = Array.isArray(cloud) ? cloud : [];
91
- for (const w of cloudArr) sp.add(w);
92
- // Read this machine's local cache.
93
- let local: string[] = [];
94
- try {
95
- const raw = localStorage.getItem(USER_DICT_KEY);
96
- local = raw ? (JSON.parse(raw) as string[]) : [];
97
- } catch { local = []; }
98
- // Reconcile up: words that exist only in localStorage (e.g. added
99
- // on a build where the cloud round-trip was a silent no-op) get
100
- // pushed to the server so they land in userdict.csv.
101
- const cloudSet = new Set(cloudArr);
102
- const localOnly = local.filter(w => !cloudSet.has(w));
103
- if (localOnly.length > 0) {
104
- addUserDictWords(localOnly).catch(e => console.error("[spell] reconcile:", e));
105
- }
106
- // Refresh the cache with the union so the next boot starts whole.
107
- try {
108
- const merged = [...new Set([...local, ...cloudArr])];
109
- localStorage.setItem(USER_DICT_KEY, JSON.stringify(merged));
110
- } catch { /* */ }
111
- }).catch(() => { /* offline / no cloud — that's fine */ });
112
- return sp;
113
- })();
114
- return spellPromise;
115
- }
116
-
117
- function addToUserDict(word: string, sp: NSpell): void {
118
- // Local cache: synchronous, so suggestions disappear immediately.
119
- try {
120
- const raw = localStorage.getItem(USER_DICT_KEY);
121
- const arr = raw ? (JSON.parse(raw) as string[]) : [];
122
- if (!arr.includes(word)) {
123
- arr.push(word);
124
- localStorage.setItem(USER_DICT_KEY, JSON.stringify(arr));
125
- }
126
- } catch { /* */ }
127
- sp.add(word);
128
- // Cloud: fire-and-forget so the right-click "Add" doesn't block the
129
- // editor. The service merges with the existing cloud copy so concurrent
130
- // adds from a second machine don't lose entries.
131
- addUserDictWord(word).catch(e => console.error("[spell] addUserDictWord:", e));
132
- }
133
-
134
- // ── Live decoration ───────────────────────────────────────────────
135
-
136
- /** Walk the editor body, wrap newly-misspelled words, unwrap markers
137
- * that are now correct. Mutations are wrapped in undoManager.ignore so
138
- * they don't pollute undo history; selection is preserved via a
139
- * TinyMCE bookmark. */
140
- function decorate(editor: any, sp: NSpell): void {
141
- const body: HTMLElement | null = editor.getBody?.();
142
- const doc: Document | null = editor.getDoc?.();
143
- if (!body || !doc) return;
144
-
145
- // Active non-collapsed selection? Bail. The decorate pass saves only
146
- // focusNode/focusOffset and restores as a collapsed range, so running
147
- // it over a live selection erases the selection (Bob 2026-05-26: "I
148
- // select a sentence and then move to B for bold and the sentence gets
149
- // unselected"). Skipping the whole pass is safe — the next input /
150
- // nodechange / keyup event reschedules decorate, and the misspellings
151
- // get marked the moment the user is no longer holding a range.
152
- const _activeSel = doc.getSelection();
153
- if (_activeSel && _activeSel.rangeCount > 0 && !_activeSel.isCollapsed) return;
154
-
155
- // "Don't fight active typing" is enforced WORD-LEVEL by the walker's
156
- // caret-in-word skip below — NOT by a function-level early-return.
157
- // The earlier function-level bail (skip the whole pass if the caret
158
- // sits in any marker) had a worse failure mode: once a marker existed
159
- // and the caret stayed inside it, browsers extended the marker as
160
- // adjacent text was typed, and decorate never ran to unwrap-and-
161
- // re-mark, so the underline grew to span entire sentences and
162
- // persisted (Bob 2026-05-22 "the persistent spellcheck twiddle is
163
- // not going away"). The walker already skips the specific word the
164
- // caret is on; everything else is fair game.
165
-
166
- // Caret preservation: TinyMCE's getBookmark(2) + moveToBookmark
167
- // claimed to be mutation-safe, but in practice it landed the caret
168
- // at the START of the nearest wrapped span (Bob 2026-05-12: "you
169
- // violently yank the cursor and plop it down at the beginning of
170
- // the twiddle"). Replace with an absolute-character-offset save
171
- // before mutations and a walk-to-restore after — robust against
172
- // any reshape that preserves the text content (which our wrap/
173
- // unwrap operations do by construction).
174
- // Save the focusNode REFERENCE too. The abs-offset alone loses the
175
- // user's element when they sit in an empty `<p>` (typical reply
176
- // scaffold: empty <p> on top + `<div class="reply">` below). With
177
- // only abs=0, restore walks to the first TEXT node — which lives
178
- // INSIDE the quoted reply block — and caret yanks INTO the quote
179
- // every 1.2 s (Bob 2026-05-25: "I move the cursor to the top, wait,
180
- // it goes to the beginning of the reply"). The decorate mutations
181
- // only wrap/unwrap misspelling spans inside text — they don't touch
182
- // the user's `<p>` containers — so the focusNode reference survives
183
- // the pass unchanged. Restore by reference when we can; abs-offset
184
- // is the fallback when the node was inside a re-wrapped span.
185
- let savedFocusNode: Node | null = null;
186
- let savedFocusOffset = 0;
187
- const _preSel = doc.getSelection();
188
- if (_preSel && _preSel.rangeCount > 0) {
189
- savedFocusNode = _preSel.focusNode;
190
- savedFocusOffset = _preSel.focusOffset;
191
- }
192
- const savedAbs = caretAbsOffsetFromBody(body);
193
- // Scroll preservation: unwrap → normalize → wrap reshapes the DOM
194
- // around the caret. Browsers (incl. WebView2) often scroll the new
195
- // selection into view, which yanks the editor viewport to the top
196
- // of the document when the restored caret lands above the previous
197
- // viewport. Save scrollTop on both the editor doc's scrolling element
198
- // AND the body (TinyMCE sometimes uses one or the other depending
199
- // on theme/skin) and restore them in the finally block. Bob 2026-05-12:
200
- // "the message that shows is different. It jumped to the top."
201
- const scroller = doc.scrollingElement || doc.documentElement;
202
- const savedScrollTop = scroller?.scrollTop ?? 0;
203
- const savedBodyScrollTop = body.scrollTop;
204
- try {
205
- editor.undoManager?.ignore?.(() => {
206
- // 1. Unwrap any existing markers — we'll re-wrap fresh based
207
- // on the current content. Cheaper than incremental update
208
- // and avoids stale markers if the user fixed a word
209
- // without going through our menu (just retyped it).
210
- const old = body.querySelectorAll(`span[${MARKER_ATTR}]`);
211
- for (const m of old) {
212
- const parent = m.parentNode;
213
- if (!parent) continue;
214
- while (m.firstChild) parent.insertBefore(m.firstChild, m);
215
- parent.removeChild(m);
216
- }
217
- // Merge text nodes that were split by the now-removed spans.
218
- body.normalize();
219
-
220
- // 2. Walk text nodes and collect words to wrap.
221
- const walker = doc.createTreeWalker(body, NodeFilter.SHOW_TEXT, {
222
- acceptNode(node) {
223
- let p: Node | null = node.parentNode;
224
- while (p && p !== body) {
225
- if (p.nodeType === Node.ELEMENT_NODE && SKIP_TAGS.has((p as Element).tagName)) {
226
- return NodeFilter.FILTER_REJECT;
227
- }
228
- p = p.parentNode;
229
- }
230
- return NodeFilter.FILTER_ACCEPT;
231
- },
232
- });
233
- // Capture the caret position so we can skip flagging the
234
- // word currently being typed. Without this, every keystroke
235
- // mid-word produces a red underline that grows as the user
236
- // types — exactly the "twiddling" Bob called out. Stripping
237
- // existing markers (step 1 above) plus this skip means a
238
- // freshly-typed word stays clean; once the caret leaves it
239
- // (space, punctuation, arrow keys) the next pass will mark
240
- // it if still misspelled.
241
- //
242
- // After body.normalize() in step 1, the live selection's
243
- // focusNode may have been collapsed away — re-read it now.
244
- let caretNode: Text | null = null;
245
- let caretOffset = 0;
246
- const liveSel = doc.getSelection();
247
- if (liveSel && liveSel.rangeCount > 0) {
248
- const f = liveSel.focusNode;
249
- if (f && f.nodeType === Node.TEXT_NODE) {
250
- caretNode = f as Text;
251
- caretOffset = liveSel.focusOffset;
252
- }
253
- }
254
- type Hit = { node: Text; start: number; end: number };
255
- const hits: Hit[] = [];
256
- let n: Node | null = walker.nextNode();
257
- // Letter / digit / apostrophe / hyphen — tokenize words via
258
- // Unicode-aware regex so we don't false-flag accented words.
259
- const WORD_RE = /[\p{L}][\p{L}'’\-]*/gu;
260
- // Email addresses aren't natural-language words. Without this,
261
- // `bob@example.com` tokenizes to `bob` / `example` / `com` and
262
- // each gets spell-checked (and usually redlined). Find email
263
- // spans per text node up front and skip any word hit inside one.
264
- const EMAIL_RE = /[^\s@<>()]+@[^\s@<>()]+\.[^\s@<>()]+/g;
265
- while (n) {
266
- const tn = n as Text;
267
- const text = tn.data;
268
- const emailRanges: Array<[number, number]> = [];
269
- EMAIL_RE.lastIndex = 0;
270
- let em: RegExpExecArray | null;
271
- while ((em = EMAIL_RE.exec(text)) !== null) {
272
- emailRanges.push([em.index, em.index + em[0].length]);
273
- }
274
- let m: RegExpExecArray | null;
275
- WORD_RE.lastIndex = 0;
276
- while ((m = WORD_RE.exec(text)) !== null) {
277
- const word = m[0];
278
- if (word.length < MIN_WORD_LEN) continue;
279
- // Inside an email address — not a word to check.
280
- const wStart = m.index, wEnd = m.index + word.length;
281
- if (emailRanges.some(([s, e]) => wStart < e && wEnd > s)) continue;
282
- // Skip the word the caret is sitting inside (or
283
- // immediately adjacent to — inclusive on both ends).
284
- if (caretNode === tn
285
- && caretOffset >= m.index
286
- && caretOffset <= m.index + word.length) {
287
- continue;
288
- }
289
- if (sp.correct(word)) continue;
290
- hits.push({ node: tn, start: m.index, end: m.index + word.length });
291
- }
292
- n = walker.nextNode();
293
- }
50
+ // Red wavy underline: a 6x3 SVG wave tiled horizontally under the word.
51
+ // encodeURIComponent keeps the inline SVG valid inside a CSS url().
52
+ const WAVE = `url("data:image/svg+xml,${encodeURIComponent(
53
+ '<svg xmlns="http://www.w3.org/2000/svg" width="6" height="3">' +
54
+ '<path d="M0 2 Q1.5 0 3 2 T6 2" stroke="#d33" fill="none" stroke-width="1"/></svg>',
55
+ )}")`;
294
56
 
295
- // 3. Wrap hits in reverse order — wrapping a span splits the
296
- // text node, which would invalidate earlier offsets. Going
297
- // right-to-left keeps the not-yet-touched offsets valid.
298
- hits.reverse();
299
- for (const h of hits) {
300
- const range = doc.createRange();
301
- range.setStart(h.node, h.start);
302
- range.setEnd(h.node, h.end);
303
- const span = doc.createElement("span");
304
- span.setAttribute(MARKER_ATTR, "1");
305
- try { range.surroundContents(span); }
306
- catch { /* range spans a node boundary — rare; skip */ }
307
- }
308
- });
309
- } finally {
310
- // Prefer the node-reference path when it still attaches to the body.
311
- // Falls back to abs-offset only when the saved node was inside a
312
- // span that got unwrapped/rewrapped during the pass.
313
- let restored = false;
314
- if (savedFocusNode && body.contains(savedFocusNode)) {
315
- try {
316
- const range = doc.createRange();
317
- const maxOffset = savedFocusNode.nodeType === Node.TEXT_NODE
318
- ? (savedFocusNode as Text).data.length
319
- : savedFocusNode.childNodes.length;
320
- range.setStart(savedFocusNode, Math.min(savedFocusOffset, maxOffset));
321
- range.collapse(true);
322
- const sel = doc.getSelection();
323
- if (sel) { sel.removeAllRanges(); sel.addRange(range); restored = true; }
324
- } catch { /* fall through to abs-offset path */ }
325
- }
326
- if (!restored && savedAbs != null) restoreCaretFromAbsOffset(body, savedAbs);
327
- // Restore scroll positions AFTER the caret restore — setting the
328
- // caret may itself trigger scrollIntoView; setting scrollTop last
329
- // wins. Both targets get restored because the active scroller
330
- // differs across TinyMCE skins.
331
- if (scroller && scroller.scrollTop !== savedScrollTop) scroller.scrollTop = savedScrollTop;
332
- if (body.scrollTop !== savedBodyScrollTop) body.scrollTop = savedBodyScrollTop;
333
- }
334
- }
335
-
336
- /** Live focus position → absolute character offset from `body`. Walks all
337
- * text nodes, accumulates lengths until reaching focusNode, then adds
338
- * focusOffset. Returns null when no selection or selection isn't in a
339
- * text node we can find. */
340
- function caretAbsOffsetFromBody(body: HTMLElement): number | null {
341
- const doc = body.ownerDocument!;
342
- const sel = doc.getSelection();
343
- if (!sel || sel.rangeCount === 0) return null;
344
- const focusNode = sel.focusNode;
345
- const focusOffset = sel.focusOffset;
346
- if (!focusNode) return null;
347
- // Caret on element node (rare for typing-time decoration) — fall back
348
- // to "offset" being a child index; treat as zero contribution.
349
- if (focusNode.nodeType !== Node.TEXT_NODE) {
350
- // Walk only text BEFORE the focus point.
351
- let abs = 0;
352
- const walker = doc.createTreeWalker(body, NodeFilter.SHOW_TEXT);
353
- let n: Node | null = walker.nextNode();
354
- while (n) {
355
- if (focusNode.contains(n)) break;
356
- // n strictly precedes focusNode in tree order?
357
- const cmp = focusNode.compareDocumentPosition(n);
358
- if (cmp & Node.DOCUMENT_POSITION_PRECEDING) abs += (n as Text).data.length;
359
- n = walker.nextNode();
360
- }
361
- return abs;
362
- }
363
- let abs = 0;
364
- const walker = doc.createTreeWalker(body, NodeFilter.SHOW_TEXT);
365
- let n: Node | null = walker.nextNode();
366
- while (n) {
367
- if (n === focusNode) return abs + focusOffset;
368
- abs += (n as Text).data.length;
369
- n = walker.nextNode();
370
- }
371
- return null;
372
- }
373
-
374
- /** Restore caret to the absolute character offset from `body`. Walks
375
- * text nodes until the cumulative length crosses `abs`, then collapses
376
- * the selection there. No-op if abs is out of range. */
377
- function restoreCaretFromAbsOffset(body: HTMLElement, abs: number): void {
378
- const doc = body.ownerDocument!;
379
- const sel = doc.getSelection();
380
- if (!sel) return;
381
- const walker = doc.createTreeWalker(body, NodeFilter.SHOW_TEXT);
382
- let acc = 0;
383
- let n: Node | null = walker.nextNode();
384
- while (n) {
385
- const len = (n as Text).data.length;
386
- if (acc + len >= abs) {
387
- const range = doc.createRange();
388
- range.setStart(n, Math.max(0, abs - acc));
389
- range.collapse(true);
390
- sel.removeAllRanges();
391
- sel.addRange(range);
392
- return;
393
- }
394
- acc += len;
395
- n = walker.nextNode();
396
- }
397
- // abs past end — drop caret at end of last text node if any.
398
- const last = walker.previousNode() as Text | null;
399
- if (last) {
400
- const range = doc.createRange();
401
- range.setStart(last, last.data.length);
402
- range.collapse(true);
403
- sel.removeAllRanges();
404
- sel.addRange(range);
405
- }
406
- }
407
-
408
- /** Inject the wavy-red CSS into the editor iframe. */
409
- function installDecorationStyle(editor: any): void {
410
- const doc: Document | null = editor.getDoc?.();
411
- if (!doc) return;
412
- if (doc.getElementById("mailx-spell-style")) return;
413
- const style = doc.createElement("style");
414
- style.id = "mailx-spell-style";
415
- style.textContent = `
416
- span[${MARKER_ATTR}] {
417
- text-decoration: underline wavy #d33;
418
- text-decoration-skip-ink: none;
419
- text-underline-offset: 2px;
420
- /* No background — keeps the styling subtle, like a native
421
- * spell underline, not a Find-highlight. */
422
- background: transparent;
423
- }
424
- `;
425
- doc.head.appendChild(style);
426
- }
427
-
428
- /** Strip decoration markers from serialized output. TinyMCE fires
429
- * attribute filters during getContent / draft-save; this filter
430
- * unwraps the span so the saved/sent HTML carries only the text. */
431
- function installSerializerFilter(editor: any): void {
432
- if ((editor as any).__mailxSpellSerializerWired) return;
433
- (editor as any).__mailxSpellSerializerWired = true;
434
- try {
435
- editor.serializer.addAttributeFilter(MARKER_ATTR, (nodes: any[]) => {
436
- for (const node of nodes) {
437
- // TinyMCE's html-node API: `unwrap()` replaces the node
438
- // with its children. Exactly what we want — keep the
439
- // text, drop the span.
440
- if (typeof node.unwrap === "function") node.unwrap();
441
- }
442
- });
443
- } catch (e) {
444
- console.warn("[spellcheck] serializer filter setup failed:", e);
445
- }
446
- }
447
-
448
- // ── Context menu ──────────────────────────────────────────────────
449
-
450
- function showSuggestionsMenu(
451
- parentDoc: Document, x: number, y: number,
452
- items: Array<{ label: string; action: () => void; emphasized?: boolean; separator?: boolean }>,
453
- ): void {
454
- parentDoc.getElementById("mailx-spell-menu")?.remove();
455
- const menu = parentDoc.createElement("div");
456
- menu.id = "mailx-spell-menu";
457
- menu.style.cssText = `
458
- position: fixed;
459
- left: ${x}px; top: ${y}px;
460
- z-index: 10000;
461
- background: var(--color-bg, #fff);
462
- color: var(--color-text, #222);
463
- border: 1px solid var(--color-border, #ccc);
464
- border-radius: 6px;
465
- box-shadow: 0 4px 16px rgba(0,0,0,0.18);
466
- padding: 4px 0;
467
- font: 13px system-ui, sans-serif;
468
- min-width: 180px;
469
- max-width: 320px;
470
- `;
471
- for (const it of items) {
472
- if (it.separator) {
473
- const sep = parentDoc.createElement("div");
474
- sep.style.cssText = "border-top:1px solid var(--color-border,#ddd); margin: 4px 0;";
475
- menu.appendChild(sep);
476
- continue;
477
- }
478
- const btn = parentDoc.createElement("button");
479
- btn.type = "button";
480
- btn.textContent = it.label;
481
- btn.style.cssText = `
482
- display: block; width: 100%; text-align: left;
483
- padding: 5px 12px; border: none; background: none;
484
- color: inherit; cursor: pointer; font: inherit;
485
- ${it.emphasized ? "font-weight: 600;" : ""}
486
- `;
487
- btn.addEventListener("mouseenter", () => { btn.style.background = "var(--color-bg-hover, #eef)"; });
488
- btn.addEventListener("mouseleave", () => { btn.style.background = "none"; });
489
- btn.addEventListener("click", () => {
490
- try { it.action(); } finally { menu.remove(); }
491
- });
492
- menu.appendChild(btn);
493
- }
494
- parentDoc.body.appendChild(menu);
495
- const r = menu.getBoundingClientRect();
496
- if (r.right > window.innerWidth) menu.style.left = `${Math.max(8, window.innerWidth - r.width - 8)}px`;
497
- if (r.bottom > window.innerHeight) menu.style.top = `${Math.max(8, window.innerHeight - r.height - 8)}px`;
498
- // Dismiss listeners on EVERY document the user could plausibly click
499
- // into: the compose document (parentDoc, where the menu is rendered),
500
- // the TinyMCE editor iframe doc (where the right-click originated and
501
- // where the user's caret usually lives), and the top mailx window
502
- // (outside the compose overlay entirely). Without the editor-iframe
503
- // and top-doc listeners, clicking back into the editor or onto the
504
- // folder list left the menu pinned (Bob 2026-05-12: "when I click
505
- // outside this menu why isn't it going away?").
506
- const docs: Document[] = [parentDoc];
507
- try {
508
- const composeWin = parentDoc.defaultView as Window | null;
509
- if (composeWin?.frameElement && composeWin.parent?.document
510
- && composeWin.parent.document !== parentDoc) {
511
- docs.push(composeWin.parent.document);
512
- }
513
- } catch { /* cross-origin — ignore */ }
514
- // Editor iframe sits inside parentDoc; locate it via TinyMCE convention
515
- // (.tox-edit-area iframe) or fall back to first iframe in the body.
516
- try {
517
- const editorIframe = parentDoc.querySelector("iframe.tox-edit-area__iframe")
518
- || parentDoc.querySelector("iframe");
519
- const editorDoc = (editorIframe as HTMLIFrameElement | null)?.contentDocument;
520
- if (editorDoc && editorDoc !== parentDoc) docs.push(editorDoc);
521
- } catch { /* */ }
522
- const dismiss = (e: Event) => {
523
- if (e.type === "keydown" && (e as KeyboardEvent).key !== "Escape") return;
524
- if (e.type === "mousedown" && menu.contains(e.target as Node)) return;
525
- menu.remove();
526
- for (const d of docs) {
527
- d.removeEventListener("mousedown", dismiss, true);
528
- d.removeEventListener("keydown", dismiss, true);
529
- }
530
- };
531
- setTimeout(() => {
532
- for (const d of docs) {
533
- d.addEventListener("mousedown", dismiss, true);
534
- d.addEventListener("keydown", dismiss, true);
535
- }
536
- }, 0);
537
- }
538
-
539
- /** Replace a misspelling marker span with the correction.
540
- *
541
- * Uses TinyMCE's own selection + insertContent API, which focuses the editor
542
- * iframe, replaces the selection, and registers on the undo stack.
543
- *
544
- * The previous implementation called `doc.execCommand("insertText")` directly
545
- * on the iframe document. That silently failed: the suggestions popup lives in
546
- * the TOP document (showSuggestionsMenu(document, …)), so clicking a suggestion
547
- * moves focus OUT of the editor iframe — and Chromium/WebView2 `execCommand` on
548
- * an UNFOCUSED contenteditable returns `true` while doing nothing. Because it
549
- * returned truthy, the raw-DOM fallback never ran either, so the correction was
550
- * simply dropped (Bob 2026-06-11: "spelling corrections not getting applied").
551
- * editor.focus() + editor.selection.select() makes the replacement land. */
552
- function replaceMarker(editor: any, marker: HTMLElement, replacement: string): void {
553
- try {
554
- editor.focus();
555
- editor.selection.select(marker);
556
- editor.insertContent(editor.dom.encode(replacement));
557
- } catch {
558
- // Raw-DOM fallback if the editor API is unavailable for any reason.
559
- const doc: Document = editor.getDoc();
560
- const range = doc.createRange();
561
- range.selectNode(marker);
562
- range.deleteContents();
563
- range.insertNode(doc.createTextNode(replacement));
564
- }
565
- }
566
-
567
- /** Fast removal-only pass: unwrap any misspelling marker whose word is now
568
- * correct (or no longer a single word). Runs on the short CLEANUP debounce.
569
- * Safe at a short interval because it only ever *removes* underlines —
570
- * unlike decorate(), which adds them and so waits the calmer 1200 ms.
571
- * This is what makes a corrected word's red line vanish promptly. */
572
- function cleanupCorrected(editor: any, sp: NSpell): void {
573
- const body: HTMLElement | null = editor.getBody?.();
574
- const doc: Document | null = editor.getDoc?.();
575
- if (!body || !doc) return;
576
- // Same selection-active guard as decorate(). Unwrap reshapes text-node
577
- // parentage, which can collapse a live range to a point in WebView2;
578
- // skip if the user is holding a selection.
579
- const activeSel = doc.getSelection();
580
- if (activeSel && activeSel.rangeCount > 0 && !activeSel.isCollapsed) return;
581
- const markers = body.querySelectorAll(`span[${MARKER_ATTR}]`);
582
- if (markers.length === 0) return;
583
- // The marker the caret is inside is being actively edited — leave it.
584
- let caretMarker: Node | null = null;
585
- const sel = doc.getSelection();
586
- if (sel && sel.rangeCount > 0) {
587
- let p: Node | null = sel.focusNode;
588
- while (p && p !== body) {
589
- if (p.nodeType === Node.ELEMENT_NODE && (p as Element).hasAttribute?.(MARKER_ATTR)) { caretMarker = p; break; }
590
- p = p.parentNode;
591
- }
592
- }
593
- const stale: Element[] = [];
594
- for (const m of markers) {
595
- const word = m.textContent || "";
596
- // Now correct, emptied, or split by editing into multiple tokens.
597
- const isStale = !word || /\s/.test(word) || sp.correct(word);
598
- // Caret protection applies ONLY to non-stale markers — i.e. the
599
- // user actively mid-correcting a real misspelling. A stale marker
600
- // (whitespace inside = it's grown past the original misspelled
601
- // word, or contents are now correct) is unwrapped regardless;
602
- // the caret survives because unwrap leaves the text node in place
603
- // (moved to the marker's parent at the same offset). Without
604
- // this, a marker that swallowed adjacent typing kept its red
605
- // wavy underline until the user moved the caret out + the 1200 ms
606
- // decorate debounce — "took a very long time" (Bob 2026-05-22).
607
- if (!isStale && m === caretMarker) continue;
608
- if (isStale) stale.push(m);
609
- }
610
- if (stale.length === 0) return;
611
- // Unwrap only — no re-walk, no body.normalize() — so the live caret
612
- // Range stays valid (we never touch the caret's own marker, and a plain
613
- // unwrap leaves text-node offsets intact). No caret save/restore needed.
614
- editor.undoManager?.ignore?.(() => {
615
- for (const m of stale) {
616
- const parent = m.parentNode;
617
- if (!parent) continue;
618
- while (m.firstChild) parent.insertBefore(m.firstChild, m);
619
- parent.removeChild(m);
620
- }
621
- });
622
- }
623
-
624
- // ── Public entry point ────────────────────────────────────────────
57
+ const OVERLAY_ID = "mailx-spell-overlay";
625
58
 
626
59
  /** Wire the spell-check into a TinyMCE editor instance. Idempotent. */
627
60
  export function wireSpellcheck(editor: any): void {
628
61
  if ((editor as any).__mailxSpellWired) return;
629
62
  (editor as any).__mailxSpellWired = true;
630
63
 
631
- // Kill the native (Chromium/WebView2) spellchecker on this editor. mailx
632
- // runs its own nspell checker; leaving the native one on too double-
633
- // underlines every word AND pops the native suggestion menu instead of
634
- // ours (Bob 2026-05-18: "why so much red twiddle" / "spell fixing is
635
- // broken?"). Force spellcheck="false" on the body and keep it off some
636
- // WebView2 builds re-enable it, hence the observer.
637
- const killNativeSpellcheck = (): void => {
64
+ let sp: NSpellInstance | null = null;
65
+
66
+ // Kill the native (Chromium/WebView2) spellchecker on this editor so we
67
+ // don't get two sets of underlines / the native suggestion menu instead of
68
+ // ours. Some WebView2 builds re-enable it, hence the observer.
69
+ const killNative = (): void => {
638
70
  try {
639
71
  const body: HTMLElement | null = editor.getBody?.();
640
72
  if (body && body.getAttribute("spellcheck") !== "false") {
641
73
  body.setAttribute("spellcheck", "false");
642
74
  }
643
- } catch { /* editor not ready — retried by the observer / events */ }
75
+ } catch { /* editor not ready */ }
644
76
  };
645
- killNativeSpellcheck();
77
+ killNative();
646
78
  try {
647
79
  const body: HTMLElement | null = editor.getBody?.();
648
- if (body) new MutationObserver(killNativeSpellcheck)
80
+ if (body) new MutationObserver(killNative)
649
81
  .observe(body, { attributes: true, attributeFilter: ["spellcheck"] });
650
82
  } catch { /* */ }
651
83
 
652
- let sp: NSpell | null = null;
653
- let decorateTimer: ReturnType<typeof setTimeout> | null = null;
654
- const scheduleDecorate = (): void => {
84
+ // The overlay layer. Bogus + non-editable + pointer-events:none so it's
85
+ // invisible to serialization, undo, the caret, and the mouse.
86
+ const ensureOverlay = (): HTMLElement | null => {
87
+ const body: HTMLElement | null = editor.getBody?.();
88
+ const doc: Document | null = editor.getDoc?.();
89
+ if (!body || !doc) return null;
90
+ let ov = doc.getElementById(OVERLAY_ID) as HTMLElement | null;
91
+ if (!ov || ov.parentNode !== body) {
92
+ ov?.remove();
93
+ ov = doc.createElement("div");
94
+ ov.id = OVERLAY_ID;
95
+ ov.setAttribute("contenteditable", "false");
96
+ ov.setAttribute("data-mce-bogus", "all");
97
+ ov.style.cssText = "position:absolute;top:0;left:0;pointer-events:none;user-select:none;";
98
+ body.appendChild(ov);
99
+ }
100
+ return ov;
101
+ };
102
+
103
+ // Read the content, find misspelled words, draw squiggles. Reads the
104
+ // content DOM (TreeWalker + Range measurement) but writes ONLY to the
105
+ // bogus overlay — never the user's text or selection.
106
+ const scan = (): void => {
655
107
  if (!sp) return;
656
- if (decorateTimer) clearTimeout(decorateTimer);
657
- decorateTimer = setTimeout(() => {
658
- decorateTimer = null;
659
- if (sp) decorate(editor, sp);
660
- }, DECORATE_DEBOUNCE_MS);
108
+ const body: HTMLElement | null = editor.getBody?.();
109
+ const doc: Document | null = editor.getDoc?.();
110
+ const ov = ensureOverlay();
111
+ if (!body || !doc || !ov) return;
112
+
113
+ const walker = doc.createTreeWalker(body, NodeFilter.SHOW_TEXT, {
114
+ acceptNode(node: Node) {
115
+ // Skip the overlay's own (none, but defensive) and non-prose
116
+ // containers: quoted reply, code, links, etc.
117
+ let p: Node | null = node.parentNode;
118
+ while (p && p !== body) {
119
+ if (p.nodeType === Node.ELEMENT_NODE) {
120
+ const el = p as Element;
121
+ if (el.id === OVERLAY_ID) return NodeFilter.FILTER_REJECT;
122
+ if (SKIP_TAGS.has(el.tagName)) return NodeFilter.FILTER_REJECT;
123
+ }
124
+ p = p.parentNode;
125
+ }
126
+ return NodeFilter.FILTER_ACCEPT;
127
+ },
128
+ });
129
+
130
+ const bodyRect = body.getBoundingClientRect();
131
+ const sx = body.scrollLeft;
132
+ const sy = body.scrollTop;
133
+ const frag = doc.createDocumentFragment();
134
+ // A word starts with a letter, then letters / apostrophes / hyphens.
135
+ const wordRe = /[\p{L}][\p{L}'’-]*/gu;
136
+
137
+ for (let n = walker.nextNode() as Text | null; n; n = walker.nextNode() as Text | null) {
138
+ const text = n.data;
139
+ if (!text || text.length < MIN_WORD_LEN) continue;
140
+ wordRe.lastIndex = 0;
141
+ let m: RegExpExecArray | null;
142
+ while ((m = wordRe.exec(text))) {
143
+ const w = m[0];
144
+ if (w.length < MIN_WORD_LEN) continue;
145
+ if (sp.correct(w)) continue;
146
+ const r = doc.createRange();
147
+ r.setStart(n, m.index);
148
+ r.setEnd(n, m.index + w.length);
149
+ const rects = r.getClientRects();
150
+ for (let i = 0; i < rects.length; i++) {
151
+ const rect = rects[i];
152
+ if (rect.width < 1) continue;
153
+ const sq = doc.createElement("div");
154
+ // Content-space coordinates: the overlay is an absolutely
155
+ // positioned child of the (scrolling) body, so a child placed
156
+ // at content-space (x,y) tracks the word through scroll with
157
+ // no per-scroll recompute.
158
+ sq.style.cssText =
159
+ "position:absolute;" +
160
+ `left:${(rect.left - bodyRect.left + sx).toFixed(1)}px;` +
161
+ `top:${(rect.bottom - bodyRect.top + sy - 3).toFixed(1)}px;` +
162
+ `width:${rect.width.toFixed(1)}px;height:3px;` +
163
+ `background:${WAVE} repeat-x left bottom;`;
164
+ frag.appendChild(sq);
165
+ }
166
+ }
167
+ }
168
+ ov.textContent = "";
169
+ ov.appendChild(frag);
661
170
  };
662
- // Short-debounce removal-only pass so a corrected word's red line clears
663
- // promptly without waiting on the full (add+remove) decorate debounce.
664
- let cleanupTimer: ReturnType<typeof setTimeout> | null = null;
665
- const scheduleCleanup = (): void => {
171
+
172
+ let scanTimer: ReturnType<typeof setTimeout> | null = null;
173
+ const scheduleScan = (): void => {
666
174
  if (!sp) return;
667
- if (cleanupTimer) clearTimeout(cleanupTimer);
668
- cleanupTimer = setTimeout(() => {
669
- cleanupTimer = null;
670
- if (sp) cleanupCorrected(editor, sp);
671
- }, CLEANUP_DEBOUNCE_MS);
175
+ if (scanTimer) clearTimeout(scanTimer);
176
+ scanTimer = setTimeout(() => { scanTimer = null; scan(); }, SCAN_DEBOUNCE_MS);
672
177
  };
673
178
 
674
- // Kick off the dictionary load. First decoration runs as soon as
675
- // it resolves; subsequent runs are triggered by editor events.
676
- getSpell().then((loaded) => {
677
- sp = loaded;
678
- installDecorationStyle(editor);
679
- installSerializerFilter(editor);
680
- decorate(editor, loaded);
681
- }).catch((err) => {
682
- console.error("[spellcheck] dict load failed:", err);
683
- });
179
+ getSpell().then((loaded: NSpellInstance) => { sp = loaded; scan(); })
180
+ .catch((e: any) => console.error("[spellcheck] dict load failed:", e));
684
181
 
685
- // Re-decorate on edits / paste / content swap. `nodechange` covers
686
- // most of these; `input` catches typing in finer-grained events on
687
- // some TinyMCE versions. The short cleanup pass runs on the same
688
- // events so a just-corrected word loses its underline quickly.
689
- editor.on("input nodechange setcontent paste keyup", scheduleDecorate);
690
- editor.on("input nodechange setcontent paste keyup", scheduleCleanup);
182
+ // Re-scan on any content change. NodeChange is deliberately excluded — it
183
+ // can fire on pure selection moves and we don't want a scan per caret tick;
184
+ // input/keyup cover real edits, and Undo/Redo/SetContent cover the rest.
185
+ editor.on("input keyup paste SetContent Undo Redo", scheduleScan);
186
+ // Reposition after the editor reflows or scrolls.
187
+ editor.on("ResizeEditor", scheduleScan);
188
+ try {
189
+ editor.getDoc()?.addEventListener("scroll", scheduleScan, { passive: true } as AddEventListenerOptions);
190
+ } catch { /* */ }
191
+
192
+ // Apply a correction: select the word's text-node range and let TinyMCE
193
+ // replace it. editor.focus() + selection + insertContent is undo-tracked
194
+ // and lands even though the suggestion menu (in the top document) stole
195
+ // focus — the same focus-aware path the old replaceMarker needed.
196
+ const apply = (node: Text, start: number, end: number, replacement: string): void => {
197
+ try {
198
+ const doc = editor.getDoc();
199
+ const range = doc.createRange();
200
+ range.setStart(node, start);
201
+ range.setEnd(node, end);
202
+ editor.focus();
203
+ editor.selection.setRng(range);
204
+ editor.insertContent(editor.dom.encode(replacement));
205
+ } catch {
206
+ // Raw-DOM fallback if the editor API is unavailable.
207
+ try {
208
+ const doc = editor.getDoc();
209
+ const range = doc.createRange();
210
+ range.setStart(node, start);
211
+ range.setEnd(node, end);
212
+ range.deleteContents();
213
+ range.insertNode(doc.createTextNode(replacement));
214
+ } catch { /* */ }
215
+ }
216
+ scheduleScan();
217
+ };
691
218
 
692
- // Right-click handler. If the click landed on a marker span, show
693
- // suggestions; otherwise let the default menu (WebView2's) fire.
219
+ // Right-click: if the click landed on a misspelled word, show suggestions;
220
+ // otherwise let the default (native) menu fire.
694
221
  const iframeDoc: Document = editor.getDoc();
695
222
  iframeDoc.addEventListener("contextmenu", (ev: Event) => {
696
223
  const e = ev as MouseEvent;
697
- const target = e.target as HTMLElement | null;
698
- if (!target) return;
699
- const marker = target.closest?.(`span[${MARKER_ATTR}]`) as HTMLElement | null;
700
- if (!marker) return; // not on a misspelled word — default menu fires
701
- const word = marker.textContent || "";
702
- if (!word || !sp) return;
224
+ const body: HTMLElement | null = editor.getBody?.();
225
+ if (!body || !sp) return;
226
+ const hit = getWordAtPoint(body, e.clientX, e.clientY);
227
+ if (!hit) return; // not on a word
228
+ if (sp.correct(hit.word)) return; // spelled correctly — no menu
703
229
  e.preventDefault();
704
230
  e.stopPropagation();
705
- // nspell's suggest() leans on Hunspell's TRY/REP heuristics and
706
- // doesn't reliably surface adjacent-letter transpositions — the
707
- // single most common English typo (Bob 2026-05-12: "what kind of
708
- // a spell is this if it can't see hte is the"). Prepend our own
709
- // transposition pass: for each adjacent pair in the word, swap
710
- // them and keep results that exist in the dictionary. Then merge
711
- // with nspell's list, transpositions first, dedup, cap at 7.
712
- const transposed: string[] = [];
713
- for (let i = 0; i < word.length - 1; i++) {
714
- const swapped = word.slice(0, i) + word[i + 1] + word[i] + word.slice(i + 2);
715
- if (swapped !== word && sp.correct(swapped) && !transposed.includes(swapped)) {
716
- transposed.push(swapped);
717
- }
718
- }
719
- const nspellSugs: string[] = sp.suggest(word) as string[];
720
- const sugs: string[] = [];
721
- for (const s of [...transposed, ...nspellSugs]) {
722
- if (!sugs.includes(s)) sugs.push(s);
723
- if (sugs.length >= 7) break;
724
- }
725
- const iframeEl = editor.iframeElement as HTMLIFrameElement | undefined;
726
- const iframeRect = iframeEl ? iframeEl.getBoundingClientRect() : { left: 0, top: 0 };
727
- const items: Array<{ label: string; action: () => void; emphasized?: boolean; separator?: boolean }> = [];
728
- if (sugs.length === 0) {
729
- items.push({ label: "(no suggestions)", action: () => { /* */ } });
730
- } else {
731
- for (const s of sugs) {
732
- items.push({
733
- label: s,
734
- emphasized: true,
735
- action: () => {
736
- replaceMarker(editor, marker, s);
737
- // Re-decorate so the replacement is checked too.
738
- scheduleDecorate();
739
- },
740
- });
741
- }
742
- }
231
+
232
+ const sugs = buildSuggestionList(hit.word, sp);
233
+ const items: MenuItem[] = sugs.length === 0
234
+ ? [{ label: "(no suggestions)", action: () => { /* */ } }]
235
+ : sugs.map(s => ({
236
+ label: s,
237
+ emphasized: true,
238
+ action: () => apply(hit.node, hit.start, hit.end, s),
239
+ }));
743
240
  items.push({ label: "", action: () => { /* */ }, separator: true });
744
241
  items.push({
745
- label: `Add "${word}" to dictionary`,
746
- action: () => { if (sp) addToUserDict(word, sp); scheduleDecorate(); },
242
+ label: `Add "${hit.word}" to dictionary`,
243
+ action: () => { if (sp) addToUserDict(hit.word, sp); scheduleScan(); },
747
244
  });
748
245
  items.push({
749
246
  label: "Ignore (this session)",
750
- action: () => { if (sp) sp.add(word); scheduleDecorate(); },
247
+ action: () => { if (sp) sp.add(hit.word); scheduleScan(); },
751
248
  });
752
- showSuggestionsMenu(document, iframeRect.left + e.clientX, iframeRect.top + e.clientY, items);
249
+
250
+ // getWordAtPoint's (x,y) are iframe-local; the menu lives in the top
251
+ // document, so offset by the iframe's position on the page.
252
+ const iframeEl = editor.iframeElement as HTMLIFrameElement | undefined;
253
+ const rect = iframeEl ? iframeEl.getBoundingClientRect() : ({ left: 0, top: 0 } as DOMRect);
254
+ showSuggestionsMenu(document, rect.left + e.clientX, rect.top + e.clientY, items, [iframeDoc]);
753
255
  }, true);
754
256
  }