@bobfrankston/rmfmail 1.1.174 → 1.1.180

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.
@@ -268,6 +268,85 @@ function createQuillEditor(container: HTMLElement): MailxEditor {
268
268
  // - Anything else: default Quill behavior (verbatim plain or HTML).
269
269
  // C36: AI proofread on right-click → "Proofread selection" item.
270
270
  // Uses the existing aiTransform IPC. Gated by autocomplete.proofreadEnabled.
271
+ // ── Spell-check right-click handler (Quill) ──
272
+ // Runs FIRST so a right-click on a misspelled word shows our suggestions
273
+ // menu (Add to dictionary / Ignore / replace) instead of falling through
274
+ // to the WebView2 native menu (which on msger-hosted compose iframes was
275
+ // unreliable for "Add to dictionary" — Bob 2026-05-27 "can't ignore nor
276
+ // add an entry"). Shares the nspell dictionary + user-dict-mirror with
277
+ // the TinyMCE editor's spellcheck.ts via spellcheck-core.ts — same
278
+ // dictionary, same persistence, same suggestion ranking.
279
+ //
280
+ // Decoration (red wavy underlines) is NOT wired here yet — Quill's
281
+ // MutationObserver-based delta tracking interferes with arbitrary
282
+ // span-wrapping. The visible-on-hover suggestion is left to native
283
+ // (WebView2 still shows red squiggles via OS spell-check on
284
+ // spellcheck="true"); we just take over the right-click flow so the
285
+ // user can ACT on a flagged word with persistence that actually works.
286
+ let _spellSp: any = null;
287
+ import("./spellcheck-core.js").then(m => m.getSpell()).then(sp => { _spellSp = sp; }).catch(() => { /* dict unavailable */ });
288
+ q.root.addEventListener("contextmenu", async (e: MouseEvent) => {
289
+ try {
290
+ if (!_spellSp) return; // dict still loading
291
+ const sel = q.getSelection();
292
+ if (sel && sel.length > 0) return; // selection path handled below
293
+ const core = await import("./spellcheck-core.js");
294
+ const hit = core.getWordAtPoint(q.root as HTMLElement, e.clientX, e.clientY);
295
+ if (!hit) return;
296
+ const { word, node, start, end } = hit;
297
+ if (_spellSp.correct(word)) return; // word is fine — let native menu fire
298
+ e.preventDefault();
299
+ e.stopPropagation();
300
+ const sugs = core.buildSuggestionList(word, _spellSp);
301
+ const items: Array<{ label: string; action: () => void; emphasized?: boolean; separator?: boolean }> = [];
302
+ if (sugs.length === 0) {
303
+ items.push({ label: "(no suggestions)", action: () => { /* */ } });
304
+ } else {
305
+ for (const s of sugs) {
306
+ items.push({
307
+ label: s,
308
+ emphasized: true,
309
+ action: () => {
310
+ // Replace via Range so the browser's text-edit
311
+ // handling kicks in and Quill sees it as a normal
312
+ // edit (undoable, Delta-tracked). Going through
313
+ // q.deleteText/insertText needs character offsets
314
+ // from the Quill index space — possible but more
315
+ // arithmetic; Range is simpler and works for the
316
+ // text-node case the misspelling always lives in.
317
+ try {
318
+ const range = document.createRange();
319
+ range.setStart(node, start);
320
+ range.setEnd(node, end);
321
+ const docSel = document.getSelection();
322
+ if (docSel) {
323
+ docSel.removeAllRanges();
324
+ docSel.addRange(range);
325
+ if (!document.execCommand("insertText", false, s)) {
326
+ range.deleteContents();
327
+ range.insertNode(document.createTextNode(s));
328
+ }
329
+ }
330
+ } catch { /* */ }
331
+ },
332
+ });
333
+ }
334
+ }
335
+ items.push({ label: "", action: () => {}, separator: true });
336
+ items.push({
337
+ label: `Add "${word}" to dictionary`,
338
+ action: () => core.addToUserDict(word, _spellSp),
339
+ });
340
+ items.push({
341
+ label: "Ignore (this session)",
342
+ action: () => _spellSp.add(word),
343
+ });
344
+ core.showSuggestionsMenu(document, e.clientX, e.clientY, items);
345
+ } catch (err: any) {
346
+ console.warn("[spellcheck] right-click handler error:", err?.message || err);
347
+ }
348
+ });
349
+
271
350
  q.root.addEventListener("contextmenu", async (e: MouseEvent) => {
272
351
  try {
273
352
  const sel = q.getSelection();
@@ -0,0 +1,253 @@
1
+ /**
2
+ * Editor-agnostic spell-check core.
3
+ *
4
+ * The pieces that are NOT tied to a specific editor:
5
+ * - Dictionary load (nspell + hunspell en.aff/en.dic + user-dict from cloud).
6
+ * - addToUserDict (write-through cache + cloud round-trip).
7
+ * - showSuggestionsMenu (renders a floating menu in any document).
8
+ * - getWordAtPoint (finds the word under a click in a contenteditable).
9
+ * - buildSuggestionList (transposition-first list + nspell.suggest, capped).
10
+ *
11
+ * The pieces that ARE editor-specific (separate per editor):
12
+ * - DOM walking for live decoration.
13
+ * - replaceWord — needs the editor's selection model so undo works.
14
+ * - Serializer filter that strips marker spans (depends on the editor's
15
+ * output API).
16
+ *
17
+ * spellcheck.ts (TinyMCE) and spellcheck-quill.ts (Quill) both consume
18
+ * this module. Adding a new editor means writing a thin adapter for the
19
+ * editor-specific bits and reusing everything else here.
20
+ */
21
+ // @ts-expect-error — nspell ships no type defs.
22
+ import NSpell from "nspell";
23
+ import { getUserDict, addUserDictWord, addUserDictWords } from "../lib/api-client.js";
24
+ export const USER_DICT_KEY = "mailx-user-dict";
25
+ export const MARKER_ATTR = "data-mailx-spellerror";
26
+ export const MIN_WORD_LEN = 3;
27
+ export const SKIP_TAGS = new Set(["BLOCKQUOTE", "CODE", "PRE", "A", "SCRIPT", "STYLE", "KBD", "SAMP", "VAR"]);
28
+ let spellPromise = null;
29
+ /** Resolve the shared nspell instance. Loads the en.aff/en.dic Hunspell
30
+ * files once, layers in the user's dictionary from localStorage + GDrive,
31
+ * and reconciles any local-only additions back up to the cloud. */
32
+ export async function getSpell() {
33
+ if (spellPromise)
34
+ return spellPromise;
35
+ spellPromise = (async () => {
36
+ const [affRes, dicRes] = await Promise.all([
37
+ fetch("../lib/dict/en.aff"),
38
+ fetch("../lib/dict/en.dic"),
39
+ ]);
40
+ if (!affRes.ok || !dicRes.ok) {
41
+ throw new Error(`spellcheck: dict fetch failed (aff=${affRes.status} dic=${dicRes.status})`);
42
+ }
43
+ const [aff, dic] = await Promise.all([affRes.text(), dicRes.text()]);
44
+ const sp = new NSpell({ aff, dic });
45
+ try {
46
+ const raw = localStorage.getItem(USER_DICT_KEY);
47
+ if (raw)
48
+ for (const w of JSON.parse(raw))
49
+ sp.add(w);
50
+ }
51
+ catch { /* corrupt cache */ }
52
+ getUserDict().then(cloud => {
53
+ const cloudArr = Array.isArray(cloud) ? cloud : [];
54
+ for (const w of cloudArr)
55
+ sp.add(w);
56
+ let local = [];
57
+ try {
58
+ const raw = localStorage.getItem(USER_DICT_KEY);
59
+ local = raw ? JSON.parse(raw) : [];
60
+ }
61
+ catch {
62
+ local = [];
63
+ }
64
+ const cloudSet = new Set(cloudArr);
65
+ const localOnly = local.filter(w => !cloudSet.has(w));
66
+ if (localOnly.length > 0) {
67
+ addUserDictWords(localOnly).catch(e => console.error("[spell] reconcile:", e));
68
+ }
69
+ try {
70
+ const merged = [...new Set([...local, ...cloudArr])];
71
+ localStorage.setItem(USER_DICT_KEY, JSON.stringify(merged));
72
+ }
73
+ catch { /* */ }
74
+ }).catch(() => { });
75
+ return sp;
76
+ })();
77
+ return spellPromise;
78
+ }
79
+ /** Persist a word to the user dictionary. Synchronous local-cache write +
80
+ * in-memory nspell.add for immediate effect, plus a fire-and-forget cloud
81
+ * round-trip so the userdict.csv on GDrive picks it up too. */
82
+ export function addToUserDict(word, sp) {
83
+ try {
84
+ const raw = localStorage.getItem(USER_DICT_KEY);
85
+ const arr = raw ? JSON.parse(raw) : [];
86
+ if (!arr.includes(word)) {
87
+ arr.push(word);
88
+ localStorage.setItem(USER_DICT_KEY, JSON.stringify(arr));
89
+ }
90
+ }
91
+ catch { /* */ }
92
+ sp.add(word);
93
+ addUserDictWord(word).catch(e => console.error("[spell] addUserDictWord:", e));
94
+ }
95
+ /** Build a suggestion list: transpositions first (the single most common
96
+ * English typo class — "hte"→"the"), then nspell.suggest, deduped, capped at 7. */
97
+ export function buildSuggestionList(word, sp) {
98
+ const transposed = [];
99
+ for (let i = 0; i < word.length - 1; i++) {
100
+ const swapped = word.slice(0, i) + word[i + 1] + word[i] + word.slice(i + 2);
101
+ if (swapped !== word && sp.correct(swapped) && !transposed.includes(swapped)) {
102
+ transposed.push(swapped);
103
+ }
104
+ }
105
+ const nspellSugs = sp.suggest(word);
106
+ const sugs = [];
107
+ for (const s of [...transposed, ...nspellSugs]) {
108
+ if (!sugs.includes(s))
109
+ sugs.push(s);
110
+ if (sugs.length >= 7)
111
+ break;
112
+ }
113
+ return sugs;
114
+ }
115
+ /** Render a floating suggestions menu at (x,y) in the given document.
116
+ * Dismisses on Escape, on mousedown outside the menu, or after the user
117
+ * picks an item. Listeners attached to every document the user could
118
+ * plausibly click into so the menu can't get stuck. */
119
+ export function showSuggestionsMenu(parentDoc, x, y, items, extraDismissDocs = []) {
120
+ parentDoc.getElementById("mailx-spell-menu")?.remove();
121
+ const menu = parentDoc.createElement("div");
122
+ menu.id = "mailx-spell-menu";
123
+ menu.style.cssText = `
124
+ position: fixed;
125
+ left: ${x}px; top: ${y}px;
126
+ z-index: 10000;
127
+ background: var(--color-bg, #fff);
128
+ color: var(--color-text, #222);
129
+ border: 1px solid var(--color-border, #ccc);
130
+ border-radius: 6px;
131
+ box-shadow: 0 4px 16px rgba(0,0,0,0.18);
132
+ padding: 4px 0;
133
+ font: 13px system-ui, sans-serif;
134
+ min-width: 180px;
135
+ max-width: 320px;
136
+ `;
137
+ for (const it of items) {
138
+ if (it.separator) {
139
+ const sep = parentDoc.createElement("div");
140
+ sep.style.cssText = "border-top:1px solid var(--color-border,#ddd); margin: 4px 0;";
141
+ menu.appendChild(sep);
142
+ continue;
143
+ }
144
+ const btn = parentDoc.createElement("button");
145
+ btn.type = "button";
146
+ btn.textContent = it.label;
147
+ btn.style.cssText = `
148
+ display: block; width: 100%; text-align: left;
149
+ padding: 5px 12px; border: none; background: none;
150
+ color: inherit; cursor: pointer; font: inherit;
151
+ ${it.emphasized ? "font-weight: 600;" : ""}
152
+ `;
153
+ btn.addEventListener("mouseenter", () => { btn.style.background = "var(--color-bg-hover, #eef)"; });
154
+ btn.addEventListener("mouseleave", () => { btn.style.background = "none"; });
155
+ btn.addEventListener("click", () => {
156
+ try {
157
+ it.action();
158
+ }
159
+ finally {
160
+ menu.remove();
161
+ }
162
+ });
163
+ menu.appendChild(btn);
164
+ }
165
+ parentDoc.body.appendChild(menu);
166
+ const r = menu.getBoundingClientRect();
167
+ if (r.right > window.innerWidth)
168
+ menu.style.left = `${Math.max(8, window.innerWidth - r.width - 8)}px`;
169
+ if (r.bottom > window.innerHeight)
170
+ menu.style.top = `${Math.max(8, window.innerHeight - r.height - 8)}px`;
171
+ const docs = [parentDoc, ...extraDismissDocs];
172
+ const dismiss = (e) => {
173
+ if (e.type === "keydown" && e.key !== "Escape")
174
+ return;
175
+ if (e.type === "mousedown" && menu.contains(e.target))
176
+ return;
177
+ menu.remove();
178
+ for (const d of docs) {
179
+ d.removeEventListener("mousedown", dismiss, true);
180
+ d.removeEventListener("keydown", dismiss, true);
181
+ }
182
+ };
183
+ setTimeout(() => {
184
+ for (const d of docs) {
185
+ d.addEventListener("mousedown", dismiss, true);
186
+ d.addEventListener("keydown", dismiss, true);
187
+ }
188
+ }, 0);
189
+ }
190
+ /** Find the word at the given client (x,y) inside `root`. Returns null if
191
+ * there's no word there (whitespace, link, code, etc). Works in any
192
+ * contenteditable in the same document — used by the Quill adapter for
193
+ * the right-click-on-misspelling path where there's no marker span to
194
+ * hang off of (Quill doesn't decorate).
195
+ *
196
+ * Uses caretPositionFromPoint where available (Firefox), falls back to
197
+ * caretRangeFromPoint (Chromium/WebView2). Both give us the text node +
198
+ * offset under the click; we then expand to word boundaries. */
199
+ export function getWordAtPoint(root, x, y) {
200
+ const doc = root.ownerDocument || document;
201
+ let node = null;
202
+ let offset = 0;
203
+ const winAny = doc.defaultView;
204
+ if (typeof doc.caretPositionFromPoint === "function") {
205
+ const pos = doc.caretPositionFromPoint(x, y);
206
+ if (pos) {
207
+ node = pos.offsetNode;
208
+ offset = pos.offset;
209
+ }
210
+ }
211
+ else if (typeof doc.caretRangeFromPoint === "function") {
212
+ const range = doc.caretRangeFromPoint(x, y);
213
+ if (range) {
214
+ node = range.startContainer;
215
+ offset = range.startOffset;
216
+ }
217
+ }
218
+ if (!node || node.nodeType !== Node.TEXT_NODE)
219
+ return null;
220
+ // Make sure the text node is inside `root` — caretPositionFromPoint
221
+ // can land outside if the click is in a sibling.
222
+ let walk = node;
223
+ while (walk && walk !== root)
224
+ walk = walk.parentNode;
225
+ if (!walk)
226
+ return null;
227
+ // Reject if the click is inside a skipped tag (link, code, blockquote).
228
+ let p = node.parentNode;
229
+ while (p && p !== root) {
230
+ if (p.nodeType === Node.ELEMENT_NODE && SKIP_TAGS.has(p.tagName))
231
+ return null;
232
+ p = p.parentNode;
233
+ }
234
+ const text = node.data;
235
+ if (offset > text.length)
236
+ offset = text.length;
237
+ // Word boundary: letters, apostrophes, hyphens. Same regex as the
238
+ // walker in spellcheck.ts so both editors flag the same tokens.
239
+ const isWordChar = (c) => /[\p{L}'’\-]/u.test(c);
240
+ let start = offset;
241
+ while (start > 0 && isWordChar(text[start - 1]))
242
+ start--;
243
+ let end = offset;
244
+ while (end < text.length && isWordChar(text[end]))
245
+ end++;
246
+ if (end - start < MIN_WORD_LEN)
247
+ return null;
248
+ const word = text.slice(start, end);
249
+ if (!/^[\p{L}]/u.test(word))
250
+ return null; // must start with a letter
251
+ return { word, node: node, start, end };
252
+ }
253
+ //# sourceMappingURL=spellcheck-core.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"spellcheck-core.js","sourceRoot":"","sources":["spellcheck-core.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,242 @@
1
+ /**
2
+ * Editor-agnostic spell-check core.
3
+ *
4
+ * The pieces that are NOT tied to a specific editor:
5
+ * - Dictionary load (nspell + hunspell en.aff/en.dic + user-dict from cloud).
6
+ * - addToUserDict (write-through cache + cloud round-trip).
7
+ * - showSuggestionsMenu (renders a floating menu in any document).
8
+ * - getWordAtPoint (finds the word under a click in a contenteditable).
9
+ * - buildSuggestionList (transposition-first list + nspell.suggest, capped).
10
+ *
11
+ * The pieces that ARE editor-specific (separate per editor):
12
+ * - DOM walking for live decoration.
13
+ * - replaceWord — needs the editor's selection model so undo works.
14
+ * - Serializer filter that strips marker spans (depends on the editor's
15
+ * output API).
16
+ *
17
+ * spellcheck.ts (TinyMCE) and spellcheck-quill.ts (Quill) both consume
18
+ * this module. Adding a new editor means writing a thin adapter for the
19
+ * editor-specific bits and reusing everything else here.
20
+ */
21
+
22
+ // @ts-expect-error — nspell ships no type defs.
23
+ import NSpell from "nspell";
24
+ import { getUserDict, addUserDictWord, addUserDictWords } from "../lib/api-client.js";
25
+
26
+ export type NSpellInstance = any;
27
+
28
+ export const USER_DICT_KEY = "mailx-user-dict";
29
+ export const MARKER_ATTR = "data-mailx-spellerror";
30
+ export const MIN_WORD_LEN = 3;
31
+ export const SKIP_TAGS = new Set(["BLOCKQUOTE", "CODE", "PRE", "A", "SCRIPT", "STYLE", "KBD", "SAMP", "VAR"]);
32
+
33
+ let spellPromise: Promise<NSpellInstance> | null = null;
34
+
35
+ /** Resolve the shared nspell instance. Loads the en.aff/en.dic Hunspell
36
+ * files once, layers in the user's dictionary from localStorage + GDrive,
37
+ * and reconciles any local-only additions back up to the cloud. */
38
+ export async function getSpell(): Promise<NSpellInstance> {
39
+ if (spellPromise) return spellPromise;
40
+ spellPromise = (async () => {
41
+ const [affRes, dicRes] = await Promise.all([
42
+ fetch("../lib/dict/en.aff"),
43
+ fetch("../lib/dict/en.dic"),
44
+ ]);
45
+ if (!affRes.ok || !dicRes.ok) {
46
+ throw new Error(`spellcheck: dict fetch failed (aff=${affRes.status} dic=${dicRes.status})`);
47
+ }
48
+ const [aff, dic] = await Promise.all([affRes.text(), dicRes.text()]);
49
+ const sp = new (NSpell as any)({ aff, dic });
50
+ try {
51
+ const raw = localStorage.getItem(USER_DICT_KEY);
52
+ if (raw) for (const w of JSON.parse(raw) as string[]) sp.add(w);
53
+ } catch { /* corrupt cache */ }
54
+ getUserDict().then(cloud => {
55
+ const cloudArr = Array.isArray(cloud) ? cloud : [];
56
+ for (const w of cloudArr) sp.add(w);
57
+ let local: string[] = [];
58
+ try {
59
+ const raw = localStorage.getItem(USER_DICT_KEY);
60
+ local = raw ? (JSON.parse(raw) as string[]) : [];
61
+ } catch { local = []; }
62
+ const cloudSet = new Set(cloudArr);
63
+ const localOnly = local.filter(w => !cloudSet.has(w));
64
+ if (localOnly.length > 0) {
65
+ addUserDictWords(localOnly).catch(e => console.error("[spell] reconcile:", e));
66
+ }
67
+ try {
68
+ const merged = [...new Set([...local, ...cloudArr])];
69
+ localStorage.setItem(USER_DICT_KEY, JSON.stringify(merged));
70
+ } catch { /* */ }
71
+ }).catch(() => { /* offline */ });
72
+ return sp;
73
+ })();
74
+ return spellPromise;
75
+ }
76
+
77
+ /** Persist a word to the user dictionary. Synchronous local-cache write +
78
+ * in-memory nspell.add for immediate effect, plus a fire-and-forget cloud
79
+ * round-trip so the userdict.csv on GDrive picks it up too. */
80
+ export function addToUserDict(word: string, sp: NSpellInstance): void {
81
+ try {
82
+ const raw = localStorage.getItem(USER_DICT_KEY);
83
+ const arr = raw ? (JSON.parse(raw) as string[]) : [];
84
+ if (!arr.includes(word)) {
85
+ arr.push(word);
86
+ localStorage.setItem(USER_DICT_KEY, JSON.stringify(arr));
87
+ }
88
+ } catch { /* */ }
89
+ sp.add(word);
90
+ addUserDictWord(word).catch(e => console.error("[spell] addUserDictWord:", e));
91
+ }
92
+
93
+ /** Build a suggestion list: transpositions first (the single most common
94
+ * English typo class — "hte"→"the"), then nspell.suggest, deduped, capped at 7. */
95
+ export function buildSuggestionList(word: string, sp: NSpellInstance): string[] {
96
+ const transposed: string[] = [];
97
+ for (let i = 0; i < word.length - 1; i++) {
98
+ const swapped = word.slice(0, i) + word[i + 1] + word[i] + word.slice(i + 2);
99
+ if (swapped !== word && sp.correct(swapped) && !transposed.includes(swapped)) {
100
+ transposed.push(swapped);
101
+ }
102
+ }
103
+ const nspellSugs: string[] = sp.suggest(word) as string[];
104
+ const sugs: string[] = [];
105
+ for (const s of [...transposed, ...nspellSugs]) {
106
+ if (!sugs.includes(s)) sugs.push(s);
107
+ if (sugs.length >= 7) break;
108
+ }
109
+ return sugs;
110
+ }
111
+
112
+ export type MenuItem = {
113
+ label: string;
114
+ action: () => void;
115
+ emphasized?: boolean;
116
+ separator?: boolean;
117
+ };
118
+
119
+ /** Render a floating suggestions menu at (x,y) in the given document.
120
+ * Dismisses on Escape, on mousedown outside the menu, or after the user
121
+ * picks an item. Listeners attached to every document the user could
122
+ * plausibly click into so the menu can't get stuck. */
123
+ export function showSuggestionsMenu(
124
+ parentDoc: Document,
125
+ x: number,
126
+ y: number,
127
+ items: MenuItem[],
128
+ extraDismissDocs: Document[] = [],
129
+ ): void {
130
+ parentDoc.getElementById("mailx-spell-menu")?.remove();
131
+ const menu = parentDoc.createElement("div");
132
+ menu.id = "mailx-spell-menu";
133
+ menu.style.cssText = `
134
+ position: fixed;
135
+ left: ${x}px; top: ${y}px;
136
+ z-index: 10000;
137
+ background: var(--color-bg, #fff);
138
+ color: var(--color-text, #222);
139
+ border: 1px solid var(--color-border, #ccc);
140
+ border-radius: 6px;
141
+ box-shadow: 0 4px 16px rgba(0,0,0,0.18);
142
+ padding: 4px 0;
143
+ font: 13px system-ui, sans-serif;
144
+ min-width: 180px;
145
+ max-width: 320px;
146
+ `;
147
+ for (const it of items) {
148
+ if (it.separator) {
149
+ const sep = parentDoc.createElement("div");
150
+ sep.style.cssText = "border-top:1px solid var(--color-border,#ddd); margin: 4px 0;";
151
+ menu.appendChild(sep);
152
+ continue;
153
+ }
154
+ const btn = parentDoc.createElement("button");
155
+ btn.type = "button";
156
+ btn.textContent = it.label;
157
+ btn.style.cssText = `
158
+ display: block; width: 100%; text-align: left;
159
+ padding: 5px 12px; border: none; background: none;
160
+ color: inherit; cursor: pointer; font: inherit;
161
+ ${it.emphasized ? "font-weight: 600;" : ""}
162
+ `;
163
+ btn.addEventListener("mouseenter", () => { btn.style.background = "var(--color-bg-hover, #eef)"; });
164
+ btn.addEventListener("mouseleave", () => { btn.style.background = "none"; });
165
+ btn.addEventListener("click", () => {
166
+ try { it.action(); } finally { menu.remove(); }
167
+ });
168
+ menu.appendChild(btn);
169
+ }
170
+ parentDoc.body.appendChild(menu);
171
+ const r = menu.getBoundingClientRect();
172
+ if (r.right > window.innerWidth) menu.style.left = `${Math.max(8, window.innerWidth - r.width - 8)}px`;
173
+ if (r.bottom > window.innerHeight) menu.style.top = `${Math.max(8, window.innerHeight - r.height - 8)}px`;
174
+ const docs: Document[] = [parentDoc, ...extraDismissDocs];
175
+ const dismiss = (e: Event) => {
176
+ if (e.type === "keydown" && (e as KeyboardEvent).key !== "Escape") return;
177
+ if (e.type === "mousedown" && menu.contains(e.target as Node)) return;
178
+ menu.remove();
179
+ for (const d of docs) {
180
+ d.removeEventListener("mousedown", dismiss, true);
181
+ d.removeEventListener("keydown", dismiss, true);
182
+ }
183
+ };
184
+ setTimeout(() => {
185
+ for (const d of docs) {
186
+ d.addEventListener("mousedown", dismiss, true);
187
+ d.addEventListener("keydown", dismiss, true);
188
+ }
189
+ }, 0);
190
+ }
191
+
192
+ /** Find the word at the given client (x,y) inside `root`. Returns null if
193
+ * there's no word there (whitespace, link, code, etc). Works in any
194
+ * contenteditable in the same document — used by the Quill adapter for
195
+ * the right-click-on-misspelling path where there's no marker span to
196
+ * hang off of (Quill doesn't decorate).
197
+ *
198
+ * Uses caretPositionFromPoint where available (Firefox), falls back to
199
+ * caretRangeFromPoint (Chromium/WebView2). Both give us the text node +
200
+ * offset under the click; we then expand to word boundaries. */
201
+ export function getWordAtPoint(
202
+ root: HTMLElement,
203
+ x: number,
204
+ y: number,
205
+ ): { word: string; node: Text; start: number; end: number } | null {
206
+ const doc = root.ownerDocument || document;
207
+ let node: Node | null = null;
208
+ let offset = 0;
209
+ const winAny = doc.defaultView as any;
210
+ if (typeof (doc as any).caretPositionFromPoint === "function") {
211
+ const pos = (doc as any).caretPositionFromPoint(x, y);
212
+ if (pos) { node = pos.offsetNode; offset = pos.offset; }
213
+ } else if (typeof (doc as any).caretRangeFromPoint === "function") {
214
+ const range = (doc as any).caretRangeFromPoint(x, y);
215
+ if (range) { node = range.startContainer; offset = range.startOffset; }
216
+ }
217
+ if (!node || node.nodeType !== Node.TEXT_NODE) return null;
218
+ // Make sure the text node is inside `root` — caretPositionFromPoint
219
+ // can land outside if the click is in a sibling.
220
+ let walk: Node | null = node;
221
+ while (walk && walk !== root) walk = walk.parentNode;
222
+ if (!walk) return null;
223
+ // Reject if the click is inside a skipped tag (link, code, blockquote).
224
+ let p: Node | null = node.parentNode;
225
+ while (p && p !== root) {
226
+ if (p.nodeType === Node.ELEMENT_NODE && SKIP_TAGS.has((p as Element).tagName)) return null;
227
+ p = p.parentNode;
228
+ }
229
+ const text = (node as Text).data;
230
+ if (offset > text.length) offset = text.length;
231
+ // Word boundary: letters, apostrophes, hyphens. Same regex as the
232
+ // walker in spellcheck.ts so both editors flag the same tokens.
233
+ const isWordChar = (c: string) => /[\p{L}'’\-]/u.test(c);
234
+ let start = offset;
235
+ while (start > 0 && isWordChar(text[start - 1])) start--;
236
+ let end = offset;
237
+ while (end < text.length && isWordChar(text[end])) end++;
238
+ if (end - start < MIN_WORD_LEN) return null;
239
+ const word = text.slice(start, end);
240
+ if (!/^[\p{L}]/u.test(word)) return null; // must start with a letter
241
+ return { word, node: node as Text, start, end };
242
+ }