@bobfrankston/rmfmail 1.0.676 → 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.
- package/TODO.md +11 -1
- package/bin/build-spellcheck-dict.js +25 -0
- package/client/app.bundle.js +16 -19
- package/client/app.bundle.js.map +2 -2
- package/client/app.js +34 -22
- package/client/app.js.map +1 -1
- package/client/app.ts +34 -24
- package/client/components/calendar-sidebar.js +14 -1
- package/client/components/calendar-sidebar.js.map +1 -1
- package/client/components/calendar-sidebar.ts +13 -1
- package/client/compose/compose.bundle.js +1254 -4
- package/client/compose/compose.bundle.js.map +4 -4
- package/client/compose/compose.css +28 -2
- package/client/compose/compose.js +23 -1
- package/client/compose/compose.js.map +1 -1
- package/client/compose/compose.ts +21 -1
- package/client/compose/spellcheck.js +296 -0
- package/client/compose/spellcheck.js.map +1 -0
- package/client/compose/spellcheck.ts +267 -0
- package/client/lib/dict/en.aff +205 -0
- package/client/lib/dict/en.dic +49569 -0
- package/client/lib/rmf-tiny.js +3 -2
- package/package.json +8 -2
- package/packages/mailx-service/index.d.ts.map +1 -1
- package/packages/mailx-service/index.js +9 -1
- package/packages/mailx-service/index.js.map +1 -1
- package/packages/mailx-service/index.ts +9 -1
|
@@ -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
|
+
}
|