@bobfrankston/rmfmail 1.0.677 → 1.0.678

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.
@@ -0,0 +1,296 @@
1
+ /**
2
+ * Right-click spell-check for the TinyMCE compose editor.
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 — settings are left at defaults but
7
+ * `IsSpellcheckEnabled` is never explicitly set, and the default varies
8
+ * by WebView2 runtime). Rather than depend on the host, we ship a JS
9
+ * spellchecker (`nspell`) + the standard `dictionary-en` Hunspell files.
10
+ *
11
+ * Scope (intentionally minimal — Bob 2026-05-11):
12
+ * - On right-click inside the editor body, find the word at the click.
13
+ * - If nspell flags it, show a small popup with suggestions plus an
14
+ * "Add to mailx dictionary" item.
15
+ * - Custom dictionary persists in localStorage under
16
+ * `mailx-user-dict`; added words are also fed to nspell.add() so the
17
+ * same session stops flagging them immediately.
18
+ * - No live red-underline decoration — that would mutate DOM the user
19
+ * is actively typing into and adds significant complexity. The
20
+ * right-click flow gives the suggestions experience without the
21
+ * editing-experience tradeoffs.
22
+ *
23
+ * Loads nspell + the ~1 MB dictionary lazily on first right-click in
24
+ * an editor session, so picking TinyMCE in Settings doesn't pay this
25
+ * cost up front.
26
+ */
27
+ // @ts-expect-error — nspell ships no type defs. Treated as `any`; the
28
+ // surface we use (`new NSpell({aff, dic})`, `.correct`, `.suggest`,
29
+ // `.add`) is small and stable.
30
+ import NSpell from "nspell";
31
+ const USER_DICT_KEY = "mailx-user-dict";
32
+ let spellPromise = null;
33
+ async function getSpell() {
34
+ if (spellPromise)
35
+ return spellPromise;
36
+ spellPromise = (async () => {
37
+ // Paths are relative to compose.html. msger serves files from
38
+ // contentDir=client/, so `../lib/dict/...` lands in the right
39
+ // place under both msger.localhost and Android file:// schemes.
40
+ const [affRes, dicRes] = await Promise.all([
41
+ fetch("../lib/dict/en.aff"),
42
+ fetch("../lib/dict/en.dic"),
43
+ ]);
44
+ if (!affRes.ok || !dicRes.ok) {
45
+ throw new Error(`spellcheck: dict fetch failed (aff=${affRes.status} dic=${dicRes.status})`);
46
+ }
47
+ const [aff, dic] = await Promise.all([affRes.text(), dicRes.text()]);
48
+ const sp = new NSpell({ aff, dic });
49
+ // Seed with user dictionary.
50
+ try {
51
+ const raw = localStorage.getItem(USER_DICT_KEY);
52
+ if (raw)
53
+ for (const w of JSON.parse(raw))
54
+ sp.add(w);
55
+ }
56
+ catch { /* corrupt localStorage entry — start clean */ }
57
+ return sp;
58
+ })();
59
+ return spellPromise;
60
+ }
61
+ function addToUserDict(word, sp) {
62
+ try {
63
+ const raw = localStorage.getItem(USER_DICT_KEY);
64
+ const arr = raw ? JSON.parse(raw) : [];
65
+ if (arr.includes(word))
66
+ return;
67
+ arr.push(word);
68
+ localStorage.setItem(USER_DICT_KEY, JSON.stringify(arr));
69
+ }
70
+ catch { /* localStorage write failed — at least the in-memory add wins for this session */ }
71
+ sp.add(word);
72
+ }
73
+ /** Find the word at a given (x,y) point inside a document. Uses
74
+ * `caretPositionFromPoint` (Firefox) or `caretRangeFromPoint`
75
+ * (WebKit/Blink). Returns the word string + a Range covering it,
76
+ * or null if the click was on whitespace / punctuation / outside text. */
77
+ function wordAtPoint(doc, x, y) {
78
+ const anyDoc = doc;
79
+ let caretNode = null;
80
+ let caretOffset = 0;
81
+ if (typeof anyDoc.caretRangeFromPoint === "function") {
82
+ const r = anyDoc.caretRangeFromPoint(x, y);
83
+ if (!r)
84
+ return null;
85
+ caretNode = r.startContainer;
86
+ caretOffset = r.startOffset;
87
+ }
88
+ else if (typeof anyDoc.caretPositionFromPoint === "function") {
89
+ const p = anyDoc.caretPositionFromPoint(x, y);
90
+ if (!p)
91
+ return null;
92
+ caretNode = p.offsetNode;
93
+ caretOffset = p.offset;
94
+ }
95
+ else {
96
+ return null;
97
+ }
98
+ if (!caretNode || caretNode.nodeType !== Node.TEXT_NODE)
99
+ return null;
100
+ const text = caretNode.data;
101
+ // Expand left and right from caret to a word boundary. Hunspell-ish
102
+ // word characters: letters, digits, apostrophe, hyphen-in-word.
103
+ const isWordChar = (ch) => /[\p{L}\p{N}'’\-]/u.test(ch);
104
+ let start = caretOffset;
105
+ let end = caretOffset;
106
+ while (start > 0 && isWordChar(text[start - 1]))
107
+ start--;
108
+ while (end < text.length && isWordChar(text[end]))
109
+ end++;
110
+ if (start === end)
111
+ return null;
112
+ // Strip leading/trailing punctuation a word boundary regex might
113
+ // have included (apostrophe at the edge: "'hello" → "hello").
114
+ let word = text.slice(start, end);
115
+ while (word.length && /^['’\-]/.test(word)) {
116
+ word = word.slice(1);
117
+ start++;
118
+ }
119
+ while (word.length && /['’\-]$/.test(word)) {
120
+ word = word.slice(0, -1);
121
+ end--;
122
+ }
123
+ if (!word)
124
+ return null;
125
+ const range = doc.createRange();
126
+ range.setStart(caretNode, start);
127
+ range.setEnd(caretNode, end);
128
+ return { word, range };
129
+ }
130
+ /** Show a small floating menu at (x, y) in the parent document
131
+ * (NOT inside the editor iframe — the iframe clips to its own bounds,
132
+ * and a positioned menu can extend past the iframe edge). Returns
133
+ * a remove() function. */
134
+ function showSuggestionsMenu(parentDoc, x, y, items) {
135
+ // Close any prior instance.
136
+ parentDoc.getElementById("mailx-spell-menu")?.remove();
137
+ const menu = parentDoc.createElement("div");
138
+ menu.id = "mailx-spell-menu";
139
+ menu.style.cssText = `
140
+ position: fixed;
141
+ left: ${x}px; top: ${y}px;
142
+ z-index: 10000;
143
+ background: var(--color-bg, #fff);
144
+ color: var(--color-text, #222);
145
+ border: 1px solid var(--color-border, #ccc);
146
+ border-radius: 6px;
147
+ box-shadow: 0 4px 16px rgba(0,0,0,0.18);
148
+ padding: 4px 0;
149
+ font: 13px system-ui, sans-serif;
150
+ min-width: 160px;
151
+ max-width: 320px;
152
+ `;
153
+ for (const it of items) {
154
+ if (it.label === "---") {
155
+ const sep = parentDoc.createElement("div");
156
+ sep.style.cssText = "border-top:1px solid var(--color-border,#ddd); margin: 4px 0;";
157
+ menu.appendChild(sep);
158
+ continue;
159
+ }
160
+ const btn = parentDoc.createElement("button");
161
+ btn.type = "button";
162
+ btn.textContent = it.label;
163
+ btn.style.cssText = `
164
+ display: block; width: 100%; text-align: left;
165
+ padding: 5px 12px; border: none; background: none;
166
+ color: inherit; cursor: pointer; font: inherit;
167
+ ${it.emphasized ? "font-weight: 600;" : ""}
168
+ `;
169
+ btn.addEventListener("mouseenter", () => { btn.style.background = "var(--color-bg-hover, #eef)"; });
170
+ btn.addEventListener("mouseleave", () => { btn.style.background = "none"; });
171
+ btn.addEventListener("click", () => {
172
+ try {
173
+ it.action();
174
+ }
175
+ finally {
176
+ menu.remove();
177
+ }
178
+ });
179
+ menu.appendChild(btn);
180
+ }
181
+ parentDoc.body.appendChild(menu);
182
+ // Clamp into viewport if it would overflow.
183
+ const r = menu.getBoundingClientRect();
184
+ if (r.right > window.innerWidth)
185
+ menu.style.left = `${Math.max(8, window.innerWidth - r.width - 8)}px`;
186
+ if (r.bottom > window.innerHeight)
187
+ menu.style.top = `${Math.max(8, window.innerHeight - r.height - 8)}px`;
188
+ // Dismiss on next click anywhere or Esc.
189
+ const dismiss = (e) => {
190
+ if (e.type === "keydown" && e.key !== "Escape")
191
+ return;
192
+ if (e.type === "mousedown" && menu.contains(e.target))
193
+ return;
194
+ menu.remove();
195
+ parentDoc.removeEventListener("mousedown", dismiss, true);
196
+ parentDoc.removeEventListener("keydown", dismiss, true);
197
+ };
198
+ setTimeout(() => {
199
+ parentDoc.addEventListener("mousedown", dismiss, true);
200
+ parentDoc.addEventListener("keydown", dismiss, true);
201
+ }, 0);
202
+ }
203
+ /** Replace the text covered by `range` with `replacement` via a normal
204
+ * Selection edit — TinyMCE observes this through its normal undo /
205
+ * dirty-tracking pathway. Document.execCommand("insertText") gives the
206
+ * best undo integration; fall back to range mutation if the command
207
+ * isn't recognized (older WebViews). */
208
+ function replaceRange(doc, range, replacement) {
209
+ const sel = doc.getSelection();
210
+ if (!sel)
211
+ return;
212
+ sel.removeAllRanges();
213
+ sel.addRange(range);
214
+ try {
215
+ if (!doc.execCommand("insertText", false, replacement)) {
216
+ range.deleteContents();
217
+ range.insertNode(doc.createTextNode(replacement));
218
+ }
219
+ }
220
+ catch {
221
+ range.deleteContents();
222
+ range.insertNode(doc.createTextNode(replacement));
223
+ }
224
+ }
225
+ /** Wire the right-click spell-suggest flow into a TinyMCE editor instance.
226
+ * Idempotent (safe to call multiple times — only attaches once). */
227
+ export function wireSpellcheck(editor) {
228
+ if (editor.__mailxSpellWired)
229
+ return;
230
+ editor.__mailxSpellWired = true;
231
+ const iframeDoc = editor.getDoc?.() || null;
232
+ if (!iframeDoc)
233
+ return;
234
+ iframeDoc.addEventListener("contextmenu", (ev) => {
235
+ const e = ev;
236
+ // Translate the iframe-internal point to the parent's viewport
237
+ // for menu positioning. The iframe element's bounding rect plus
238
+ // the event's clientX/Y inside the iframe gives parent-relative
239
+ // coords.
240
+ const iframeEl = editor.iframeElement;
241
+ if (!iframeEl)
242
+ return;
243
+ const found = wordAtPoint(iframeDoc, e.clientX, e.clientY);
244
+ if (!found)
245
+ return; // not on a word — let WebView2 default fire
246
+ // Lazy-load the dictionary. First right-click on a word in any
247
+ // editor session triggers the load; subsequent are instant.
248
+ getSpell().then(sp => {
249
+ if (sp.correct(found.word))
250
+ return; // word IS in the dictionary, no menu
251
+ // Misspelled — suppress default and show suggestions.
252
+ const sugs = sp.suggest(found.word).slice(0, 7);
253
+ const iframeRect = iframeEl.getBoundingClientRect();
254
+ const menuX = iframeRect.left + e.clientX;
255
+ const menuY = iframeRect.top + e.clientY;
256
+ const items = [];
257
+ if (sugs.length === 0) {
258
+ items.push({ label: "(no suggestions)", action: () => { } });
259
+ }
260
+ else {
261
+ for (const s of sugs) {
262
+ items.push({
263
+ label: s,
264
+ emphasized: true,
265
+ action: () => replaceRange(iframeDoc, found.range, s),
266
+ });
267
+ }
268
+ }
269
+ items.push({ label: "---", action: () => { } });
270
+ items.push({
271
+ label: `Add "${found.word}" to dictionary`,
272
+ action: () => addToUserDict(found.word, sp),
273
+ });
274
+ items.push({
275
+ label: "Ignore (this session)",
276
+ action: () => sp.add(found.word),
277
+ });
278
+ showSuggestionsMenu(document, menuX, menuY, items);
279
+ }).catch(err => {
280
+ // Dict load failed (network / corrupt files). Don't intercept
281
+ // the menu — let WebView2 default fire. Log so it's debuggable.
282
+ console.error("[spellcheck] dict load failed:", err);
283
+ return;
284
+ });
285
+ // Preempt the browser default ONLY when we're about to show our
286
+ // own menu. We don't know synchronously whether the word is
287
+ // misspelled (the load is async), so we have to commit to either
288
+ // intercepting or not. Compromise: preempt always when on a
289
+ // word, then dismiss instantly if the word turned out correct.
290
+ // For the user's typical case (right-click on text), this means
291
+ // a brief moment where neither menu shows — acceptable.
292
+ e.preventDefault();
293
+ e.stopPropagation();
294
+ }, true);
295
+ }
296
+ //# sourceMappingURL=spellcheck.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"spellcheck.js","sourceRoot":"","sources":["spellcheck.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,267 @@
1
+ /**
2
+ * Right-click spell-check for the TinyMCE compose editor.
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 — settings are left at defaults but
7
+ * `IsSpellcheckEnabled` is never explicitly set, and the default varies
8
+ * by WebView2 runtime). Rather than depend on the host, we ship a JS
9
+ * spellchecker (`nspell`) + the standard `dictionary-en` Hunspell files.
10
+ *
11
+ * Scope (intentionally minimal — Bob 2026-05-11):
12
+ * - On right-click inside the editor body, find the word at the click.
13
+ * - If nspell flags it, show a small popup with suggestions plus an
14
+ * "Add to mailx dictionary" item.
15
+ * - Custom dictionary persists in localStorage under
16
+ * `mailx-user-dict`; added words are also fed to nspell.add() so the
17
+ * same session stops flagging them immediately.
18
+ * - No live red-underline decoration — that would mutate DOM the user
19
+ * is actively typing into and adds significant complexity. The
20
+ * right-click flow gives the suggestions experience without the
21
+ * editing-experience tradeoffs.
22
+ *
23
+ * Loads nspell + the ~1 MB dictionary lazily on first right-click in
24
+ * an editor session, so picking TinyMCE in Settings doesn't pay this
25
+ * cost up front.
26
+ */
27
+ // @ts-expect-error — nspell ships no type defs. Treated as `any`; the
28
+ // surface we use (`new NSpell({aff, dic})`, `.correct`, `.suggest`,
29
+ // `.add`) is small and stable.
30
+ import NSpell from "nspell";
31
+ type NSpell = any;
32
+
33
+ const USER_DICT_KEY = "mailx-user-dict";
34
+
35
+ let spellPromise: Promise<NSpell> | null = null;
36
+ async function getSpell(): Promise<NSpell> {
37
+ if (spellPromise) return spellPromise;
38
+ spellPromise = (async () => {
39
+ // Paths are relative to compose.html. msger serves files from
40
+ // contentDir=client/, so `../lib/dict/...` lands in the right
41
+ // place under both msger.localhost and Android file:// schemes.
42
+ const [affRes, dicRes] = await Promise.all([
43
+ fetch("../lib/dict/en.aff"),
44
+ fetch("../lib/dict/en.dic"),
45
+ ]);
46
+ if (!affRes.ok || !dicRes.ok) {
47
+ throw new Error(`spellcheck: dict fetch failed (aff=${affRes.status} dic=${dicRes.status})`);
48
+ }
49
+ const [aff, dic] = await Promise.all([affRes.text(), dicRes.text()]);
50
+ const sp = new NSpell({ aff, dic });
51
+ // Seed with user dictionary.
52
+ try {
53
+ const raw = localStorage.getItem(USER_DICT_KEY);
54
+ if (raw) for (const w of JSON.parse(raw) as string[]) sp.add(w);
55
+ } catch { /* corrupt localStorage entry — start clean */ }
56
+ return sp;
57
+ })();
58
+ return spellPromise;
59
+ }
60
+
61
+ function addToUserDict(word: string, sp: NSpell): void {
62
+ try {
63
+ const raw = localStorage.getItem(USER_DICT_KEY);
64
+ const arr = raw ? (JSON.parse(raw) as string[]) : [];
65
+ if (arr.includes(word)) return;
66
+ arr.push(word);
67
+ localStorage.setItem(USER_DICT_KEY, JSON.stringify(arr));
68
+ } catch { /* localStorage write failed — at least the in-memory add wins for this session */ }
69
+ sp.add(word);
70
+ }
71
+
72
+ /** Find the word at a given (x,y) point inside a document. Uses
73
+ * `caretPositionFromPoint` (Firefox) or `caretRangeFromPoint`
74
+ * (WebKit/Blink). Returns the word string + a Range covering it,
75
+ * or null if the click was on whitespace / punctuation / outside text. */
76
+ function wordAtPoint(doc: Document, x: number, y: number): { word: string; range: Range } | null {
77
+ const anyDoc = doc as any;
78
+ let caretNode: Node | null = null;
79
+ let caretOffset = 0;
80
+ if (typeof anyDoc.caretRangeFromPoint === "function") {
81
+ const r = anyDoc.caretRangeFromPoint(x, y) as Range | null;
82
+ if (!r) return null;
83
+ caretNode = r.startContainer;
84
+ caretOffset = r.startOffset;
85
+ } else if (typeof anyDoc.caretPositionFromPoint === "function") {
86
+ const p = anyDoc.caretPositionFromPoint(x, y);
87
+ if (!p) return null;
88
+ caretNode = p.offsetNode;
89
+ caretOffset = p.offset;
90
+ } else {
91
+ return null;
92
+ }
93
+ if (!caretNode || caretNode.nodeType !== Node.TEXT_NODE) return null;
94
+ const text = (caretNode as Text).data;
95
+ // Expand left and right from caret to a word boundary. Hunspell-ish
96
+ // word characters: letters, digits, apostrophe, hyphen-in-word.
97
+ const isWordChar = (ch: string): boolean => /[\p{L}\p{N}'’\-]/u.test(ch);
98
+ let start = caretOffset;
99
+ let end = caretOffset;
100
+ while (start > 0 && isWordChar(text[start - 1])) start--;
101
+ while (end < text.length && isWordChar(text[end])) end++;
102
+ if (start === end) return null;
103
+ // Strip leading/trailing punctuation a word boundary regex might
104
+ // have included (apostrophe at the edge: "'hello" → "hello").
105
+ let word = text.slice(start, end);
106
+ while (word.length && /^['’\-]/.test(word)) { word = word.slice(1); start++; }
107
+ while (word.length && /['’\-]$/.test(word)) { word = word.slice(0, -1); end--; }
108
+ if (!word) return null;
109
+ const range = doc.createRange();
110
+ range.setStart(caretNode, start);
111
+ range.setEnd(caretNode, end);
112
+ return { word, range };
113
+ }
114
+
115
+ /** Show a small floating menu at (x, y) in the parent document
116
+ * (NOT inside the editor iframe — the iframe clips to its own bounds,
117
+ * and a positioned menu can extend past the iframe edge). Returns
118
+ * a remove() function. */
119
+ function showSuggestionsMenu(parentDoc: Document, x: number, y: number, items: Array<{ label: string; action: () => void; emphasized?: boolean }>): void {
120
+ // Close any prior instance.
121
+ parentDoc.getElementById("mailx-spell-menu")?.remove();
122
+ const menu = parentDoc.createElement("div");
123
+ menu.id = "mailx-spell-menu";
124
+ menu.style.cssText = `
125
+ position: fixed;
126
+ left: ${x}px; top: ${y}px;
127
+ z-index: 10000;
128
+ background: var(--color-bg, #fff);
129
+ color: var(--color-text, #222);
130
+ border: 1px solid var(--color-border, #ccc);
131
+ border-radius: 6px;
132
+ box-shadow: 0 4px 16px rgba(0,0,0,0.18);
133
+ padding: 4px 0;
134
+ font: 13px system-ui, sans-serif;
135
+ min-width: 160px;
136
+ max-width: 320px;
137
+ `;
138
+ for (const it of items) {
139
+ if (it.label === "---") {
140
+ const sep = parentDoc.createElement("div");
141
+ sep.style.cssText = "border-top:1px solid var(--color-border,#ddd); margin: 4px 0;";
142
+ menu.appendChild(sep);
143
+ continue;
144
+ }
145
+ const btn = parentDoc.createElement("button");
146
+ btn.type = "button";
147
+ btn.textContent = it.label;
148
+ btn.style.cssText = `
149
+ display: block; width: 100%; text-align: left;
150
+ padding: 5px 12px; border: none; background: none;
151
+ color: inherit; cursor: pointer; font: inherit;
152
+ ${it.emphasized ? "font-weight: 600;" : ""}
153
+ `;
154
+ btn.addEventListener("mouseenter", () => { btn.style.background = "var(--color-bg-hover, #eef)"; });
155
+ btn.addEventListener("mouseleave", () => { btn.style.background = "none"; });
156
+ btn.addEventListener("click", () => {
157
+ try { it.action(); } finally { menu.remove(); }
158
+ });
159
+ menu.appendChild(btn);
160
+ }
161
+ parentDoc.body.appendChild(menu);
162
+ // Clamp into viewport if it would overflow.
163
+ const r = menu.getBoundingClientRect();
164
+ if (r.right > window.innerWidth) menu.style.left = `${Math.max(8, window.innerWidth - r.width - 8)}px`;
165
+ if (r.bottom > window.innerHeight) menu.style.top = `${Math.max(8, window.innerHeight - r.height - 8)}px`;
166
+ // Dismiss on next click anywhere or Esc.
167
+ const dismiss = (e: Event) => {
168
+ if (e.type === "keydown" && (e as KeyboardEvent).key !== "Escape") return;
169
+ if (e.type === "mousedown" && menu.contains(e.target as Node)) return;
170
+ menu.remove();
171
+ parentDoc.removeEventListener("mousedown", dismiss, true);
172
+ parentDoc.removeEventListener("keydown", dismiss, true);
173
+ };
174
+ setTimeout(() => {
175
+ parentDoc.addEventListener("mousedown", dismiss, true);
176
+ parentDoc.addEventListener("keydown", dismiss, true);
177
+ }, 0);
178
+ }
179
+
180
+ /** Replace the text covered by `range` with `replacement` via a normal
181
+ * Selection edit — TinyMCE observes this through its normal undo /
182
+ * dirty-tracking pathway. Document.execCommand("insertText") gives the
183
+ * best undo integration; fall back to range mutation if the command
184
+ * isn't recognized (older WebViews). */
185
+ function replaceRange(doc: Document, range: Range, replacement: string): void {
186
+ const sel = doc.getSelection();
187
+ if (!sel) return;
188
+ sel.removeAllRanges();
189
+ sel.addRange(range);
190
+ try {
191
+ if (!doc.execCommand("insertText", false, replacement)) {
192
+ range.deleteContents();
193
+ range.insertNode(doc.createTextNode(replacement));
194
+ }
195
+ } catch {
196
+ range.deleteContents();
197
+ range.insertNode(doc.createTextNode(replacement));
198
+ }
199
+ }
200
+
201
+ /** Wire the right-click spell-suggest flow into a TinyMCE editor instance.
202
+ * Idempotent (safe to call multiple times — only attaches once). */
203
+ export function wireSpellcheck(editor: any): void {
204
+ if ((editor as any).__mailxSpellWired) return;
205
+ (editor as any).__mailxSpellWired = true;
206
+
207
+ const iframeDoc: Document | null = editor.getDoc?.() || null;
208
+ if (!iframeDoc) return;
209
+
210
+ iframeDoc.addEventListener("contextmenu", (ev: Event) => {
211
+ const e = ev as MouseEvent;
212
+ // Translate the iframe-internal point to the parent's viewport
213
+ // for menu positioning. The iframe element's bounding rect plus
214
+ // the event's clientX/Y inside the iframe gives parent-relative
215
+ // coords.
216
+ const iframeEl = editor.iframeElement as HTMLIFrameElement | undefined;
217
+ if (!iframeEl) return;
218
+ const found = wordAtPoint(iframeDoc, e.clientX, e.clientY);
219
+ if (!found) return; // not on a word — let WebView2 default fire
220
+ // Lazy-load the dictionary. First right-click on a word in any
221
+ // editor session triggers the load; subsequent are instant.
222
+ getSpell().then(sp => {
223
+ if (sp.correct(found.word)) return; // word IS in the dictionary, no menu
224
+ // Misspelled — suppress default and show suggestions.
225
+ const sugs = sp.suggest(found.word).slice(0, 7);
226
+ const iframeRect = iframeEl.getBoundingClientRect();
227
+ const menuX = iframeRect.left + e.clientX;
228
+ const menuY = iframeRect.top + e.clientY;
229
+ const items: Array<{ label: string; action: () => void; emphasized?: boolean }> = [];
230
+ if (sugs.length === 0) {
231
+ items.push({ label: "(no suggestions)", action: () => { /* */ } });
232
+ } else {
233
+ for (const s of sugs) {
234
+ items.push({
235
+ label: s,
236
+ emphasized: true,
237
+ action: () => replaceRange(iframeDoc, found.range, s),
238
+ });
239
+ }
240
+ }
241
+ items.push({ label: "---", action: () => { /* */ } });
242
+ items.push({
243
+ label: `Add "${found.word}" to dictionary`,
244
+ action: () => addToUserDict(found.word, sp),
245
+ });
246
+ items.push({
247
+ label: "Ignore (this session)",
248
+ action: () => sp.add(found.word),
249
+ });
250
+ showSuggestionsMenu(document, menuX, menuY, items);
251
+ }).catch(err => {
252
+ // Dict load failed (network / corrupt files). Don't intercept
253
+ // the menu — let WebView2 default fire. Log so it's debuggable.
254
+ console.error("[spellcheck] dict load failed:", err);
255
+ return;
256
+ });
257
+ // Preempt the browser default ONLY when we're about to show our
258
+ // own menu. We don't know synchronously whether the word is
259
+ // misspelled (the load is async), so we have to commit to either
260
+ // intercepting or not. Compromise: preempt always when on a
261
+ // word, then dismiss instantly if the word turned out correct.
262
+ // For the user's typical case (right-click on text), this means
263
+ // a brief moment where neither menu shows — acceptable.
264
+ e.preventDefault();
265
+ e.stopPropagation();
266
+ }, true);
267
+ }