@bobfrankston/rmfmail 1.1.243 → 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
|
-
*
|
|
2
|
+
* Spell-check for the TinyMCE compose editor — NON-MUTATING OVERLAY.
|
|
3
3
|
*
|
|
4
|
-
* Why this
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
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
|
-
*
|
|
13
|
-
* 1.
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
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
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
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
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
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
|
|
36
|
-
*
|
|
37
|
-
*
|
|
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
|
-
|
|
40
|
-
//
|
|
41
|
-
//
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
//
|
|
46
|
-
//
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
const
|
|
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
|
-
|
|
670
|
-
//
|
|
671
|
-
//
|
|
672
|
-
// ours
|
|
673
|
-
|
|
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
|
|
66
|
+
catch { /* editor not ready */ }
|
|
683
67
|
};
|
|
684
|
-
|
|
68
|
+
killNative();
|
|
685
69
|
try {
|
|
686
70
|
const body = editor.getBody?.();
|
|
687
71
|
if (body)
|
|
688
|
-
new MutationObserver(
|
|
72
|
+
new MutationObserver(killNative)
|
|
689
73
|
.observe(body, { attributes: true, attributeFilter: ["spellcheck"] });
|
|
690
74
|
}
|
|
691
75
|
catch { /* */ }
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
const
|
|
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
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
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
|
-
|
|
706
|
-
|
|
707
|
-
let cleanupTimer = null;
|
|
708
|
-
const scheduleCleanup = () => {
|
|
168
|
+
let scanTimer = null;
|
|
169
|
+
const scheduleScan = () => {
|
|
709
170
|
if (!sp)
|
|
710
171
|
return;
|
|
711
|
-
if (
|
|
712
|
-
clearTimeout(
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
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
|
-
//
|
|
720
|
-
//
|
|
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
|
|
741
|
-
if (!
|
|
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
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
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);
|
|
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);
|
|
248
|
+
sp.add(hit.word); scheduleScan(); },
|
|
802
249
|
});
|
|
803
|
-
|
|
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
|