@bobfrankston/rmfmail 1.1.174 → 1.1.177

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,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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/rmfmail",
3
- "version": "1.1.174",
3
+ "version": "1.1.177",
4
4
  "description": "Local-first email client with IMAP sync and standalone native app",
5
5
  "type": "module",
6
6
  "main": "bin/mailx.js",
@@ -38,8 +38,8 @@
38
38
  "dependencies": {
39
39
  "@bobfrankston/iflow-direct": "^0.1.51",
40
40
  "@bobfrankston/mailx-host": "^0.1.13",
41
- "@bobfrankston/mailx-imap": "^0.1.68",
42
- "@bobfrankston/mailx-store-web": "^0.1.25",
41
+ "@bobfrankston/mailx-imap": "^0.1.69",
42
+ "@bobfrankston/mailx-store-web": "^0.1.26",
43
43
  "@bobfrankston/mailx-sync": "^0.1.19",
44
44
  "@bobfrankston/miscinfo": "^1.0.12",
45
45
  "@bobfrankston/msger": "^0.1.383",
@@ -118,8 +118,8 @@
118
118
  "dependencies": {
119
119
  "@bobfrankston/iflow-direct": "^0.1.51",
120
120
  "@bobfrankston/mailx-host": "^0.1.13",
121
- "@bobfrankston/mailx-imap": "^0.1.68",
122
- "@bobfrankston/mailx-store-web": "^0.1.25",
121
+ "@bobfrankston/mailx-imap": "^0.1.69",
122
+ "@bobfrankston/mailx-store-web": "^0.1.26",
123
123
  "@bobfrankston/mailx-sync": "^0.1.19",
124
124
  "@bobfrankston/miscinfo": "^1.0.12",
125
125
  "@bobfrankston/msger": "^0.1.383",