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