@bobfrankston/rmfmail 1.2.166 → 1.2.168

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.
Files changed (50) hide show
  1. package/TODO.md +4 -0
  2. package/client/android-bootstrap.bundle.js +23 -4
  3. package/client/android-bootstrap.bundle.js.map +2 -2
  4. package/client/app.bundle.js +45 -0
  5. package/client/app.bundle.js.map +2 -2
  6. package/client/app.js +23 -0
  7. package/client/app.js.map +1 -1
  8. package/client/app.ts +18 -0
  9. package/client/components/folder-tree.js +25 -1
  10. package/client/components/folder-tree.js.map +1 -1
  11. package/client/components/folder-tree.ts +21 -1
  12. package/client/compose/compose.bundle.js +292 -0
  13. package/client/compose/compose.bundle.js.map +4 -4
  14. package/client/compose/compose.js +23 -0
  15. package/client/compose/compose.js.map +1 -1
  16. package/client/compose/compose.ts +20 -0
  17. package/client/compose/harper-lint.js +269 -0
  18. package/client/compose/harper-lint.js.map +1 -0
  19. package/client/compose/harper-lint.ts +265 -0
  20. package/client/index.html +1 -0
  21. package/client/lib/api-client.js +8 -0
  22. package/client/lib/api-client.js.map +1 -1
  23. package/client/lib/api-client.ts +8 -0
  24. package/client/lib/mailxapi.js +6 -0
  25. package/docs/harper-integration.md +120 -0
  26. package/package.json +8 -5
  27. package/packages/mailx-imap/package-lock.json +2 -2
  28. package/packages/mailx-imap/package.json +1 -1
  29. package/packages/mailx-service/index.d.ts +11 -0
  30. package/packages/mailx-service/index.d.ts.map +1 -1
  31. package/packages/mailx-service/index.js +56 -0
  32. package/packages/mailx-service/index.js.map +1 -1
  33. package/packages/mailx-service/index.ts +54 -0
  34. package/packages/mailx-service/jsonrpc.js +2 -0
  35. package/packages/mailx-service/jsonrpc.js.map +1 -1
  36. package/packages/mailx-service/jsonrpc.ts +2 -0
  37. package/packages/mailx-service/package.json +1 -1
  38. package/packages/mailx-settings/docs/harper-integration.md +120 -0
  39. package/packages/mailx-settings/package.json +1 -1
  40. package/packages/mailx-store/package.json +1 -1
  41. package/packages/mailx-store-web/android-bootstrap.d.ts.map +1 -1
  42. package/packages/mailx-store-web/android-bootstrap.js +31 -4
  43. package/packages/mailx-store-web/android-bootstrap.js.map +1 -1
  44. package/packages/mailx-store-web/android-bootstrap.ts +31 -4
  45. package/packages/mailx-store-web/package.json +1 -1
  46. package/packages/mailx-types/mailx-api.d.ts +12 -0
  47. package/packages/mailx-types/mailx-api.d.ts.map +1 -1
  48. package/packages/mailx-types/mailx-api.ts +4 -0
  49. package/packages/mailx-types/package.json +1 -1
  50. /package/packages/mailx-imap/{node_modules.npmglobalize-stash-61300 → node_modules.npmglobalize-stash-72420}/.package-lock.json +0 -0
@@ -0,0 +1,269 @@
1
+ import { grammarLint } from "../lib/api-client.js";
2
+ const DEBOUNCE_MS = 700;
3
+ const OVERLAY_ID = "harper-overlay";
4
+ const POPUP_ID = "harper-popup";
5
+ // Spelling squiggles stay with the native WebView2 spellchecker (red, with
6
+ // its own suggestion menu) — double-underlining every typo helps no one.
7
+ const SKIP_KINDS = new Set(["Spelling"]);
8
+ export function initHarperLint(ed) {
9
+ let disposed = false;
10
+ let timer = null;
11
+ let placed = [];
12
+ let paintQueued = false;
13
+ let bridgeDead = false; // host has no grammarLint (Android today) — go silent
14
+ const target = () => {
15
+ try {
16
+ const nat = ed.nativeEditor;
17
+ if (nat?.getBody) { // TinyMCE: editable body inside its iframe
18
+ const b = nat.getBody();
19
+ return b ? { root: b, doc: b.ownerDocument } : null;
20
+ }
21
+ return ed.root ? { root: ed.root, doc: ed.root.ownerDocument } : null;
22
+ }
23
+ catch {
24
+ return null;
25
+ }
26
+ };
27
+ /** Overlay host — html element for TinyMCE (its body IS the editable;
28
+ * divs appended there would serialize into the message), page body for
29
+ * in-page editors. */
30
+ const overlayHost = (doc, root) => (root === doc.body ? doc.documentElement : doc.body);
31
+ const clearOverlay = (doc) => {
32
+ doc.getElementById(OVERLAY_ID)?.remove();
33
+ doc.getElementById(POPUP_ID)?.remove();
34
+ };
35
+ /** Blocks = top-level element children of the editable root (or the root
36
+ * itself when it has none). Quoted reply chains are the author's
37
+ * correspondent's text — skip them (also keeps lint cost proportional
38
+ * to what's actually being written). */
39
+ const collectBlocks = (root) => {
40
+ const kids = Array.from(root.children).filter((c) => c instanceof HTMLElement);
41
+ const blocks = (kids.length ? kids : [root]).filter(b => b.tagName !== "BLOCKQUOTE" && !b.closest("blockquote") && !b.querySelector("blockquote"));
42
+ return blocks;
43
+ };
44
+ /** Flatten a block to plain text + a map back to its text nodes. */
45
+ const blockText = (block, doc) => {
46
+ const walker = doc.createTreeWalker(block, NodeFilter.SHOW_TEXT);
47
+ const nodes = [];
48
+ let text = "";
49
+ for (let n = walker.nextNode(); n; n = walker.nextNode()) {
50
+ nodes.push({ node: n, from: text.length });
51
+ text += n.data;
52
+ }
53
+ return { text, nodes };
54
+ };
55
+ const spanToRange = (doc, nodes, start, end) => {
56
+ let a = null;
57
+ let b = null;
58
+ for (const e of nodes) {
59
+ const len = e.node.data.length;
60
+ if (!a && start >= e.from && start <= e.from + len)
61
+ a = { node: e.node, off: start - e.from };
62
+ if (end >= e.from && end <= e.from + len)
63
+ b = { node: e.node, off: end - e.from };
64
+ }
65
+ if (!a || !b)
66
+ return null;
67
+ try {
68
+ const r = doc.createRange();
69
+ r.setStart(a.node, a.off);
70
+ r.setEnd(b.node, b.off);
71
+ return r;
72
+ }
73
+ catch {
74
+ return null;
75
+ }
76
+ };
77
+ const paint = () => {
78
+ if (disposed)
79
+ return;
80
+ const t = target();
81
+ if (!t)
82
+ return;
83
+ clearOverlay(t.doc);
84
+ if (placed.length === 0)
85
+ return;
86
+ const host = overlayHost(t.doc, t.root);
87
+ const ov = t.doc.createElement("div");
88
+ ov.id = OVERLAY_ID;
89
+ ov.style.cssText = "position:absolute;left:0;top:0;width:0;height:0;overflow:visible;pointer-events:none;z-index:5;";
90
+ const win = t.doc.defaultView || window;
91
+ for (const p of placed) {
92
+ let rects = [];
93
+ try {
94
+ rects = p.range.getClientRects();
95
+ }
96
+ catch {
97
+ continue;
98
+ }
99
+ for (const r of Array.from(rects)) {
100
+ if (r.width < 2)
101
+ continue;
102
+ const u = t.doc.createElement("div");
103
+ // Dotted blue-ish underline — distinct from the native
104
+ // spellchecker's red squiggle. Hardcoded color: TinyMCE's
105
+ // iframe can't see the page's CSS variables.
106
+ u.style.cssText = `position:absolute;left:${r.left + win.scrollX}px;top:${r.bottom + win.scrollY - 2}px;width:${r.width}px;height:0;border-bottom:2px dotted rgba(66,133,244,0.9);pointer-events:none;`;
107
+ ov.appendChild(u);
108
+ }
109
+ }
110
+ host.appendChild(ov);
111
+ };
112
+ const queuePaint = () => {
113
+ if (paintQueued || disposed)
114
+ return;
115
+ paintQueued = true;
116
+ requestAnimationFrame(() => { paintQueued = false; paint(); });
117
+ };
118
+ const lint = async () => {
119
+ if (disposed || bridgeDead)
120
+ return;
121
+ const t = target();
122
+ if (!t)
123
+ return;
124
+ const blocks = collectBlocks(t.root);
125
+ const texts = blocks.map(b => blockText(b, t.doc));
126
+ let res;
127
+ try {
128
+ res = await grammarLint(texts.map(x => x.text));
129
+ }
130
+ catch {
131
+ // Host without grammarLint (Android bridge today) or IPC failure:
132
+ // feature goes quietly dark for this compose session.
133
+ bridgeDead = true;
134
+ clearOverlayAll();
135
+ return;
136
+ }
137
+ if (disposed)
138
+ return;
139
+ const next = [];
140
+ (res?.blocks || []).forEach((lints, i) => {
141
+ const tx = texts[i];
142
+ if (!tx)
143
+ return;
144
+ for (const l of lints || []) {
145
+ if (SKIP_KINDS.has(l.kind))
146
+ continue;
147
+ const range = spanToRange(t.doc, tx.nodes, l.start, l.end);
148
+ if (range)
149
+ next.push({ lint: l, range });
150
+ }
151
+ });
152
+ placed = next;
153
+ queuePaint();
154
+ };
155
+ const clearOverlayAll = () => {
156
+ const t = target();
157
+ if (t)
158
+ clearOverlay(t.doc);
159
+ placed = [];
160
+ };
161
+ const schedule = () => {
162
+ if (timer)
163
+ clearTimeout(timer);
164
+ timer = setTimeout(() => { void lint(); }, DEBOUNCE_MS);
165
+ };
166
+ /** Apply a suggestion. Prefer the editor's own API (undo coherence);
167
+ * fall back to execCommand insertText, which all three engines accept
168
+ * as user input. */
169
+ const applyFix = (p, replacement, doc) => {
170
+ try {
171
+ const nat = ed.nativeEditor;
172
+ if (nat?.selection?.setRng && nat?.insertContent) { // TinyMCE
173
+ nat.selection.setRng(p.range);
174
+ const esc = replacement.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
175
+ nat.insertContent(esc);
176
+ }
177
+ else {
178
+ const sel = (doc.defaultView || window).getSelection();
179
+ if (!sel)
180
+ return;
181
+ sel.removeAllRanges();
182
+ sel.addRange(p.range);
183
+ doc.execCommand("insertText", false, replacement);
184
+ }
185
+ }
186
+ catch { /* squiggle stays; user can edit manually */ }
187
+ doc.getElementById(POPUP_ID)?.remove();
188
+ schedule();
189
+ };
190
+ const showPopup = (p, x, y, doc) => {
191
+ doc.getElementById(POPUP_ID)?.remove();
192
+ const pop = doc.createElement("div");
193
+ pop.id = POPUP_ID;
194
+ pop.style.cssText = `position:absolute;left:${x}px;top:${y + 6}px;z-index:2100;background:#fff;color:#1a1a1a;border:1px solid #bbb;border-radius:6px;box-shadow:0 4px 16px rgba(0,0,0,0.25);padding:6px 0;min-width:180px;max-width:340px;font:13px system-ui,sans-serif;`;
195
+ const msg = doc.createElement("div");
196
+ msg.style.cssText = "padding:4px 12px 6px;color:#555;border-bottom:1px solid #eee;";
197
+ msg.textContent = p.lint.message;
198
+ pop.appendChild(msg);
199
+ for (const s of p.lint.suggestions.slice(0, 5)) {
200
+ const item = doc.createElement("div");
201
+ item.style.cssText = "padding:6px 12px;cursor:pointer;font-weight:600;";
202
+ item.textContent = s;
203
+ item.addEventListener("mouseenter", () => { item.style.background = "#eef3ff"; });
204
+ item.addEventListener("mouseleave", () => { item.style.background = ""; });
205
+ item.addEventListener("mousedown", (e) => { e.preventDefault(); e.stopPropagation(); applyFix(p, s, doc); });
206
+ pop.appendChild(item);
207
+ }
208
+ if (p.lint.suggestions.length === 0) {
209
+ const none = doc.createElement("div");
210
+ none.style.cssText = "padding:6px 12px;color:#888;";
211
+ none.textContent = "(no suggestion — reword manually)";
212
+ pop.appendChild(none);
213
+ }
214
+ overlayHost(doc, target()?.root || doc.body).appendChild(pop);
215
+ const dismiss = () => { pop.remove(); doc.removeEventListener("mousedown", dismiss, true); };
216
+ setTimeout(() => doc.addEventListener("mousedown", dismiss, true), 0);
217
+ };
218
+ const onClick = (e) => {
219
+ if (disposed || placed.length === 0)
220
+ return;
221
+ const t = target();
222
+ if (!t)
223
+ return;
224
+ for (const p of placed) {
225
+ let rects = [];
226
+ try {
227
+ rects = Array.from(p.range.getClientRects());
228
+ }
229
+ catch {
230
+ continue;
231
+ }
232
+ for (const r of rects) {
233
+ // Small grace margin so the underline itself is clickable.
234
+ if (e.clientX >= r.left - 2 && e.clientX <= r.right + 2 && e.clientY >= r.top - 2 && e.clientY <= r.bottom + 4) {
235
+ const win = t.doc.defaultView || window;
236
+ showPopup(p, r.left + win.scrollX, r.bottom + win.scrollY, t.doc);
237
+ return;
238
+ }
239
+ }
240
+ }
241
+ };
242
+ // ── Wiring ──
243
+ const t0 = target();
244
+ ed.onContentChange(schedule);
245
+ if (t0) {
246
+ t0.doc.addEventListener("click", onClick);
247
+ t0.doc.addEventListener("scroll", queuePaint, true);
248
+ (t0.doc.defaultView || window).addEventListener("resize", queuePaint);
249
+ }
250
+ try {
251
+ ed.getScrollContainer()?.addEventListener("scroll", queuePaint);
252
+ }
253
+ catch { /* */ }
254
+ // First pass once the editor has settled (reply bodies arrive async).
255
+ setTimeout(() => { void lint(); }, 1500);
256
+ return () => {
257
+ disposed = true;
258
+ if (timer)
259
+ clearTimeout(timer);
260
+ clearOverlayAll();
261
+ const t = target();
262
+ if (t) {
263
+ t.doc.removeEventListener("click", onClick);
264
+ t.doc.removeEventListener("scroll", queuePaint, true);
265
+ (t.doc.defaultView || window).removeEventListener("resize", queuePaint);
266
+ }
267
+ };
268
+ }
269
+ //# sourceMappingURL=harper-lint.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"harper-lint.js","sourceRoot":"","sources":["harper-lint.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,265 @@
1
+ /**
2
+ * Live grammar squiggles in compose via Harper (offline; the 18 MB WASM
3
+ * engine runs in the DAEMON — see mailx-service grammarLint — so this module
4
+ * costs the compose page nothing but IPC calls and a handful of divs).
5
+ *
6
+ * ONE implementation for all three editors (Quill / tiptap / TinyMCE): they
7
+ * are all contenteditable DOM. Per-editor knowledge is limited to finding
8
+ * the editable root (TinyMCE = iframe body via nativeEditor.getBody()).
9
+ * Squiggles are painted in an OVERLAY (never inside the editable subtree —
10
+ * that would leak markup into the outgoing message); positions come from
11
+ * Range.getClientRects(), so nothing mutates editor state and the failure
12
+ * mode of every path is "no squiggle", never a broken compose.
13
+ *
14
+ * Plan + phasing: docs/harper-integration.md (TODO C159).
15
+ */
16
+ import type { MailxEditor } from "./editor.js";
17
+ import { grammarLint } from "../lib/api-client.js";
18
+
19
+ type WireLint = { start: number; end: number; kind: string; message: string; suggestions: string[] };
20
+ type Placed = { lint: WireLint; range: Range };
21
+
22
+ const DEBOUNCE_MS = 700;
23
+ const OVERLAY_ID = "harper-overlay";
24
+ const POPUP_ID = "harper-popup";
25
+ // Spelling squiggles stay with the native WebView2 spellchecker (red, with
26
+ // its own suggestion menu) — double-underlining every typo helps no one.
27
+ const SKIP_KINDS = new Set(["Spelling"]);
28
+
29
+ export function initHarperLint(ed: MailxEditor): () => void {
30
+ let disposed = false;
31
+ let timer: ReturnType<typeof setTimeout> | null = null;
32
+ let placed: Placed[] = [];
33
+ let paintQueued = false;
34
+ let bridgeDead = false; // host has no grammarLint (Android today) — go silent
35
+
36
+ const target = (): { root: HTMLElement; doc: Document } | null => {
37
+ try {
38
+ const nat: any = ed.nativeEditor;
39
+ if (nat?.getBody) { // TinyMCE: editable body inside its iframe
40
+ const b = nat.getBody();
41
+ return b ? { root: b, doc: b.ownerDocument } : null;
42
+ }
43
+ return ed.root ? { root: ed.root, doc: ed.root.ownerDocument } : null;
44
+ } catch { return null; }
45
+ };
46
+
47
+ /** Overlay host — html element for TinyMCE (its body IS the editable;
48
+ * divs appended there would serialize into the message), page body for
49
+ * in-page editors. */
50
+ const overlayHost = (doc: Document, root: HTMLElement): HTMLElement =>
51
+ (root === doc.body ? doc.documentElement : doc.body);
52
+
53
+ const clearOverlay = (doc: Document): void => {
54
+ doc.getElementById(OVERLAY_ID)?.remove();
55
+ doc.getElementById(POPUP_ID)?.remove();
56
+ };
57
+
58
+ /** Blocks = top-level element children of the editable root (or the root
59
+ * itself when it has none). Quoted reply chains are the author's
60
+ * correspondent's text — skip them (also keeps lint cost proportional
61
+ * to what's actually being written). */
62
+ const collectBlocks = (root: HTMLElement): HTMLElement[] => {
63
+ const kids = Array.from(root.children).filter((c): c is HTMLElement => c instanceof HTMLElement);
64
+ const blocks = (kids.length ? kids : [root]).filter(b =>
65
+ b.tagName !== "BLOCKQUOTE" && !b.closest("blockquote") && !b.querySelector("blockquote"));
66
+ return blocks;
67
+ };
68
+
69
+ /** Flatten a block to plain text + a map back to its text nodes. */
70
+ const blockText = (block: HTMLElement, doc: Document): { text: string; nodes: { node: Text; from: number }[] } => {
71
+ const walker = doc.createTreeWalker(block, NodeFilter.SHOW_TEXT);
72
+ const nodes: { node: Text; from: number }[] = [];
73
+ let text = "";
74
+ for (let n = walker.nextNode(); n; n = walker.nextNode()) {
75
+ nodes.push({ node: n as Text, from: text.length });
76
+ text += (n as Text).data;
77
+ }
78
+ return { text, nodes };
79
+ };
80
+
81
+ const spanToRange = (doc: Document, nodes: { node: Text; from: number }[], start: number, end: number): Range | null => {
82
+ let a: { node: Text; off: number } | null = null;
83
+ let b: { node: Text; off: number } | null = null;
84
+ for (const e of nodes) {
85
+ const len = e.node.data.length;
86
+ if (!a && start >= e.from && start <= e.from + len) a = { node: e.node, off: start - e.from };
87
+ if (end >= e.from && end <= e.from + len) b = { node: e.node, off: end - e.from };
88
+ }
89
+ if (!a || !b) return null;
90
+ try {
91
+ const r = doc.createRange();
92
+ r.setStart(a.node, a.off);
93
+ r.setEnd(b.node, b.off);
94
+ return r;
95
+ } catch { return null; }
96
+ };
97
+
98
+ const paint = (): void => {
99
+ if (disposed) return;
100
+ const t = target();
101
+ if (!t) return;
102
+ clearOverlay(t.doc);
103
+ if (placed.length === 0) return;
104
+ const host = overlayHost(t.doc, t.root);
105
+ const ov = t.doc.createElement("div");
106
+ ov.id = OVERLAY_ID;
107
+ ov.style.cssText = "position:absolute;left:0;top:0;width:0;height:0;overflow:visible;pointer-events:none;z-index:5;";
108
+ const win = t.doc.defaultView || window;
109
+ for (const p of placed) {
110
+ let rects: DOMRectList | DOMRect[] = [];
111
+ try { rects = p.range.getClientRects(); } catch { continue; }
112
+ for (const r of Array.from(rects)) {
113
+ if (r.width < 2) continue;
114
+ const u = t.doc.createElement("div");
115
+ // Dotted blue-ish underline — distinct from the native
116
+ // spellchecker's red squiggle. Hardcoded color: TinyMCE's
117
+ // iframe can't see the page's CSS variables.
118
+ u.style.cssText = `position:absolute;left:${r.left + win.scrollX}px;top:${r.bottom + win.scrollY - 2}px;width:${r.width}px;height:0;border-bottom:2px dotted rgba(66,133,244,0.9);pointer-events:none;`;
119
+ ov.appendChild(u);
120
+ }
121
+ }
122
+ host.appendChild(ov);
123
+ };
124
+
125
+ const queuePaint = (): void => {
126
+ if (paintQueued || disposed) return;
127
+ paintQueued = true;
128
+ requestAnimationFrame(() => { paintQueued = false; paint(); });
129
+ };
130
+
131
+ const lint = async (): Promise<void> => {
132
+ if (disposed || bridgeDead) return;
133
+ const t = target();
134
+ if (!t) return;
135
+ const blocks = collectBlocks(t.root);
136
+ const texts = blocks.map(b => blockText(b, t.doc));
137
+ let res: Awaited<ReturnType<typeof grammarLint>>;
138
+ try {
139
+ res = await grammarLint(texts.map(x => x.text));
140
+ } catch {
141
+ // Host without grammarLint (Android bridge today) or IPC failure:
142
+ // feature goes quietly dark for this compose session.
143
+ bridgeDead = true;
144
+ clearOverlayAll();
145
+ return;
146
+ }
147
+ if (disposed) return;
148
+ const next: Placed[] = [];
149
+ (res?.blocks || []).forEach((lints, i) => {
150
+ const tx = texts[i];
151
+ if (!tx) return;
152
+ for (const l of lints || []) {
153
+ if (SKIP_KINDS.has(l.kind)) continue;
154
+ const range = spanToRange(t.doc, tx.nodes, l.start, l.end);
155
+ if (range) next.push({ lint: l, range });
156
+ }
157
+ });
158
+ placed = next;
159
+ queuePaint();
160
+ };
161
+
162
+ const clearOverlayAll = (): void => {
163
+ const t = target();
164
+ if (t) clearOverlay(t.doc);
165
+ placed = [];
166
+ };
167
+
168
+ const schedule = (): void => {
169
+ if (timer) clearTimeout(timer);
170
+ timer = setTimeout(() => { void lint(); }, DEBOUNCE_MS);
171
+ };
172
+
173
+ /** Apply a suggestion. Prefer the editor's own API (undo coherence);
174
+ * fall back to execCommand insertText, which all three engines accept
175
+ * as user input. */
176
+ const applyFix = (p: Placed, replacement: string, doc: Document): void => {
177
+ try {
178
+ const nat: any = ed.nativeEditor;
179
+ if (nat?.selection?.setRng && nat?.insertContent) { // TinyMCE
180
+ nat.selection.setRng(p.range);
181
+ const esc = replacement.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
182
+ nat.insertContent(esc);
183
+ } else {
184
+ const sel = (doc.defaultView || window).getSelection();
185
+ if (!sel) return;
186
+ sel.removeAllRanges();
187
+ sel.addRange(p.range);
188
+ doc.execCommand("insertText", false, replacement);
189
+ }
190
+ } catch { /* squiggle stays; user can edit manually */ }
191
+ doc.getElementById(POPUP_ID)?.remove();
192
+ schedule();
193
+ };
194
+
195
+ const showPopup = (p: Placed, x: number, y: number, doc: Document): void => {
196
+ doc.getElementById(POPUP_ID)?.remove();
197
+ const pop = doc.createElement("div");
198
+ pop.id = POPUP_ID;
199
+ pop.style.cssText = `position:absolute;left:${x}px;top:${y + 6}px;z-index:2100;background:#fff;color:#1a1a1a;border:1px solid #bbb;border-radius:6px;box-shadow:0 4px 16px rgba(0,0,0,0.25);padding:6px 0;min-width:180px;max-width:340px;font:13px system-ui,sans-serif;`;
200
+ const msg = doc.createElement("div");
201
+ msg.style.cssText = "padding:4px 12px 6px;color:#555;border-bottom:1px solid #eee;";
202
+ msg.textContent = p.lint.message;
203
+ pop.appendChild(msg);
204
+ for (const s of p.lint.suggestions.slice(0, 5)) {
205
+ const item = doc.createElement("div");
206
+ item.style.cssText = "padding:6px 12px;cursor:pointer;font-weight:600;";
207
+ item.textContent = s;
208
+ item.addEventListener("mouseenter", () => { item.style.background = "#eef3ff"; });
209
+ item.addEventListener("mouseleave", () => { item.style.background = ""; });
210
+ item.addEventListener("mousedown", (e) => { e.preventDefault(); e.stopPropagation(); applyFix(p, s, doc); });
211
+ pop.appendChild(item);
212
+ }
213
+ if (p.lint.suggestions.length === 0) {
214
+ const none = doc.createElement("div");
215
+ none.style.cssText = "padding:6px 12px;color:#888;";
216
+ none.textContent = "(no suggestion — reword manually)";
217
+ pop.appendChild(none);
218
+ }
219
+ overlayHost(doc, target()?.root || doc.body).appendChild(pop);
220
+ const dismiss = (): void => { pop.remove(); doc.removeEventListener("mousedown", dismiss, true); };
221
+ setTimeout(() => doc.addEventListener("mousedown", dismiss, true), 0);
222
+ };
223
+
224
+ const onClick = (e: MouseEvent): void => {
225
+ if (disposed || placed.length === 0) return;
226
+ const t = target();
227
+ if (!t) return;
228
+ for (const p of placed) {
229
+ let rects: DOMRect[] = [];
230
+ try { rects = Array.from(p.range.getClientRects()); } catch { continue; }
231
+ for (const r of rects) {
232
+ // Small grace margin so the underline itself is clickable.
233
+ if (e.clientX >= r.left - 2 && e.clientX <= r.right + 2 && e.clientY >= r.top - 2 && e.clientY <= r.bottom + 4) {
234
+ const win = t.doc.defaultView || window;
235
+ showPopup(p, r.left + win.scrollX, r.bottom + win.scrollY, t.doc);
236
+ return;
237
+ }
238
+ }
239
+ }
240
+ };
241
+
242
+ // ── Wiring ──
243
+ const t0 = target();
244
+ ed.onContentChange(schedule);
245
+ if (t0) {
246
+ t0.doc.addEventListener("click", onClick);
247
+ t0.doc.addEventListener("scroll", queuePaint, true);
248
+ (t0.doc.defaultView || window).addEventListener("resize", queuePaint);
249
+ }
250
+ try { ed.getScrollContainer()?.addEventListener("scroll", queuePaint); } catch { /* */ }
251
+ // First pass once the editor has settled (reply bodies arrive async).
252
+ setTimeout(() => { void lint(); }, 1500);
253
+
254
+ return () => {
255
+ disposed = true;
256
+ if (timer) clearTimeout(timer);
257
+ clearOverlayAll();
258
+ const t = target();
259
+ if (t) {
260
+ t.doc.removeEventListener("click", onClick);
261
+ t.doc.removeEventListener("scroll", queuePaint, true);
262
+ (t.doc.defaultView || window).removeEventListener("resize", queuePaint);
263
+ }
264
+ };
265
+ }
package/client/index.html CHANGED
@@ -762,6 +762,7 @@
762
762
  <label class="tb-menu-item"><input type="radio" name="opt-extedit" value="word" id="opt-extedit-word"> Microsoft Word</label>
763
763
  <label class="tb-menu-item"><input type="radio" name="opt-extedit" value="libreoffice" id="opt-extedit-libre"> LibreOffice Writer</label>
764
764
  <hr class="tb-menu-sep">
765
+ <label class="tb-menu-item" title="Live grammar/phrasing squiggles while composing — Harper engine, runs entirely locally (nothing leaves this machine). Click a squiggle for suggestions. English only."><input type="checkbox" id="opt-grammar-check" checked> Grammar check (local)</label>
765
766
  <label class="tb-menu-item" title="Ghost-text completions while composing — Ollama / Claude / OpenAI back-end, off by default"><input type="checkbox" id="opt-autocomplete"> AI autocomplete</label>
766
767
  <label class="tb-menu-item" title="Right-click in message body → Translate"><input type="checkbox" id="opt-ai-translate"> AI translate (off by default)</label>
767
768
  <label class="tb-menu-item" title="Right-click in compose editor → Proofread (when wired)"><input type="checkbox" id="opt-ai-proofread"> AI proofread (off by default)</label>
@@ -618,6 +618,14 @@ export async function getMessageSource(accountId, uid, folderId) {
618
618
  /** Open a message in its own native OS window (msger WebView spawned by the
619
619
  * daemon). Resolves { ok:false, reason } when no popout host is available —
620
620
  * caller falls back to the in-window overlay. */
621
+ /** Offline grammar lint via the daemon's Harper engine. Throws when the
622
+ * host has no grammarLint (Android web-service today) — caller disables. */
623
+ export async function grammarLint(blocks) {
624
+ const fn = ipc().grammarLint;
625
+ if (!fn)
626
+ throw new Error("no grammarLint bridge");
627
+ return fn(blocks);
628
+ }
621
629
  /** Register rmfmail as the OS-level mailto: handler (Settings menu). */
622
630
  export async function registerMailto() {
623
631
  const fn = ipc().registerMailto;