@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.
- package/bin/mailx.js +30 -2
- package/bin/mailx.js.map +1 -1
- package/bin/mailx.ts +32 -3
- package/client/app.bundle.js +123 -0
- package/client/app.bundle.js.map +2 -2
- package/client/app.js +45 -1
- package/client/app.js.map +1 -1
- package/client/app.ts +40 -1
- package/client/components/message-list.js +20 -1
- package/client/components/message-list.js.map +1 -1
- package/client/components/message-list.ts +21 -1
- package/client/compose/compose.bundle.js +2158 -1779
- package/client/compose/compose.bundle.js.map +4 -4
- package/client/compose/compose.js +5 -1
- package/client/compose/compose.js.map +1 -1
- package/client/compose/compose.ts +6 -1
- package/client/compose/editor.js +85 -0
- package/client/compose/editor.js.map +1 -1
- package/client/compose/editor.ts +79 -0
- package/client/compose/spellcheck-core.js +253 -0
- package/client/compose/spellcheck-core.js.map +1 -0
- package/client/compose/spellcheck-core.ts +242 -0
- package/client/lib/api-client.js +89 -0
- package/client/lib/api-client.js.map +1 -1
- package/client/lib/api-client.ts +81 -0
- package/package.json +5 -5
- package/packages/mailx-service/jsonrpc.js +12 -2
- package/packages/mailx-service/jsonrpc.js.map +1 -1
- package/packages/mailx-service/jsonrpc.ts +12 -2
- /package/packages/mailx-imap/{node_modules.npmglobalize-stash-50120 → node_modules.npmglobalize-stash-19808}/.package-lock.json +0 -0
package/client/compose/editor.ts
CHANGED
|
@@ -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
|
+
}
|