@bobfrankston/rmfmail 1.2.222 → 1.2.224

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 (65) hide show
  1. package/bin/mailx.js +6 -9
  2. package/bin/mailx.js.map +1 -1
  3. package/bin/mailx.ts +6 -9
  4. package/client/android-bootstrap.bundle.js +38 -4
  5. package/client/android-bootstrap.bundle.js.map +2 -2
  6. package/client/app.bundle.js +149 -32
  7. package/client/app.bundle.js.map +2 -2
  8. package/client/components/address-book.js +93 -29
  9. package/client/components/address-book.js.map +1 -1
  10. package/client/components/address-book.ts +103 -24
  11. package/client/components/context-menu.js +76 -0
  12. package/client/components/context-menu.js.map +1 -1
  13. package/client/components/context-menu.ts +77 -0
  14. package/client/compose/compose.bundle.js +276 -116
  15. package/client/compose/compose.bundle.js.map +4 -4
  16. package/client/compose/compose.js +4 -149
  17. package/client/compose/compose.js.map +1 -1
  18. package/client/compose/compose.ts +4 -104
  19. package/client/compose/edit-commands.js +207 -0
  20. package/client/compose/edit-commands.js.map +1 -0
  21. package/client/compose/edit-commands.ts +181 -0
  22. package/client/compose/editor.js +6 -0
  23. package/client/compose/editor.js.map +1 -1
  24. package/client/compose/editor.ts +6 -0
  25. package/client/compose/spellcheck.js +19 -0
  26. package/client/compose/spellcheck.js.map +1 -1
  27. package/client/compose/spellcheck.ts +11 -0
  28. package/client/lib/api-client.js +2 -2
  29. package/client/lib/api-client.js.map +1 -1
  30. package/client/lib/api-client.ts +2 -2
  31. package/client/lib/mailxapi.js +2 -2
  32. package/client/styles/components.css +40 -0
  33. package/package.json +7 -7
  34. package/packages/mailx-imap/index.d.ts.map +1 -1
  35. package/packages/mailx-imap/index.js +32 -4
  36. package/packages/mailx-imap/index.js.map +1 -1
  37. package/packages/mailx-imap/index.ts +34 -5
  38. package/packages/mailx-imap/package-lock.json +2 -2
  39. package/packages/mailx-imap/package.json +1 -1
  40. package/packages/mailx-service/index.d.ts +1 -1
  41. package/packages/mailx-service/index.d.ts.map +1 -1
  42. package/packages/mailx-service/index.js +33 -4
  43. package/packages/mailx-service/index.js.map +1 -1
  44. package/packages/mailx-service/index.ts +28 -4
  45. package/packages/mailx-service/jsonrpc.js +1 -1
  46. package/packages/mailx-service/jsonrpc.js.map +1 -1
  47. package/packages/mailx-service/jsonrpc.ts +1 -1
  48. package/packages/mailx-service/package.json +1 -1
  49. package/packages/mailx-store/db.d.ts +30 -10
  50. package/packages/mailx-store/db.d.ts.map +1 -1
  51. package/packages/mailx-store/db.js +108 -10
  52. package/packages/mailx-store/db.js.map +1 -1
  53. package/packages/mailx-store/db.ts +126 -13
  54. package/packages/mailx-store/package.json +1 -1
  55. package/packages/mailx-store-web/android-bootstrap.d.ts.map +1 -1
  56. package/packages/mailx-store-web/android-bootstrap.js +17 -2
  57. package/packages/mailx-store-web/android-bootstrap.js.map +1 -1
  58. package/packages/mailx-store-web/android-bootstrap.ts +17 -2
  59. package/packages/mailx-store-web/db.d.ts +1 -1
  60. package/packages/mailx-store-web/db.d.ts.map +1 -1
  61. package/packages/mailx-store-web/db.js +17 -2
  62. package/packages/mailx-store-web/db.js.map +1 -1
  63. package/packages/mailx-store-web/db.ts +18 -3
  64. package/packages/mailx-store-web/package.json +1 -1
  65. /package/packages/mailx-imap/{node_modules.npmglobalize-stash-104584 → node_modules.npmglobalize-stash-142920}/.package-lock.json +0 -0
@@ -6,6 +6,7 @@
6
6
 
7
7
  import { createEditor, type MailxEditor } from "./editor.js";
8
8
  import { getVersion, getSettings, getAccounts, searchContacts, sendMessage, saveDraft as apiSaveDraft, deleteDraft, logClientEvent, addPreferredContact, addToDenylist, openInWord, closeWordEdit, onEvent, installConsoleCapture, popoutCompose, consumePopoutComposeInit, closeComposePopout } from "../lib/api-client.js";
9
+ import { runFormatCommand, type EditCommandId } from "./edit-commands.js";
9
10
  import { showContextMenu, type MenuItem } from "../components/context-menu.js";
10
11
 
11
12
  // Capture compose-iframe console + window errors to the daemon log. Same
@@ -2178,77 +2179,6 @@ async function popoutHandoff(): Promise<void> {
2178
2179
  }
2179
2180
  window.addEventListener("compose-popout", () => { void popoutHandoff(); });
2180
2181
 
2181
- // ── Native context-menu clipboard commands ──
2182
- // Chromium drops Cut/Copy from its default context menu whenever the caret is
2183
- // collapsed — and right-clicking a word IS a collapsed caret — so the menu you
2184
- // get while editing has no Cut and no Copy (Bob 2026-08-07). These entries
2185
- // expand to the word under the caret first, matching what the formatting
2186
- // entries already do, and go through the async Clipboard API rather than the
2187
- // deprecated document.execCommand (which needs a user gesture the native menu
2188
- // callback doesn't carry).
2189
- //
2190
- // Clipboard READ is permission-gated in a way write is not, so paste can
2191
- // legitimately be refused. Say so out loud rather than no-op'ing — a menu item
2192
- // that silently does nothing is worse than one that isn't there.
2193
- async function runClipboardCommand(id: "cut" | "copy" | "paste"): Promise<void> {
2194
- const ne: any = (editor as any)?.nativeEditor;
2195
- const isTiny = !!ne && typeof ne.execCommand === "function" && !!ne.selection;
2196
- const label = id === "cut" ? "Cut" : id === "copy" ? "Copy" : "Paste";
2197
- const shortcut = id === "cut" ? "Ctrl+X" : id === "copy" ? "Ctrl+C" : "Ctrl+V";
2198
- try {
2199
- if (id === "paste") {
2200
- let html = "";
2201
- let text = "";
2202
- if (navigator.clipboard?.read) {
2203
- for (const item of await navigator.clipboard.read()) {
2204
- if (item.types.includes("text/html")) html = await (await item.getType("text/html")).text();
2205
- if (item.types.includes("text/plain")) text = await (await item.getType("text/plain")).text();
2206
- }
2207
- } else {
2208
- text = await navigator.clipboard.readText();
2209
- }
2210
- // Prefer HTML so formatting survives, exactly as Ctrl+V would.
2211
- // Plain text has to be escaped and line-broken by hand — inserted
2212
- // raw it would be parsed as markup.
2213
- const content = html || (text
2214
- ? text.replace(/[&<>]/g, c => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;" }[c] as string)).replace(/\r?\n/g, "<br>")
2215
- : "");
2216
- if (!content) return;
2217
- if (isTiny) ne.execCommand("mceInsertContent", false, content);
2218
- else if (ne && typeof ne.getSelection === "function") {
2219
- const sel = ne.getSelection(true);
2220
- ne.clipboard?.dangerouslyPasteHTML?.(sel?.index ?? 0, content);
2221
- } else throw new Error("no editor");
2222
- return;
2223
- }
2224
- // Cut / Copy. Expand a collapsed caret to the word under it so the
2225
- // command has something to act on.
2226
- if (isTiny) {
2227
- try { if (ne.selection.isCollapsed() && typeof ne.selection.expand === "function") ne.selection.expand({ type: "word" }); } catch { /* older TinyMCE */ }
2228
- }
2229
- const html = isTiny ? ne.selection.getContent({ format: "html" }) : "";
2230
- const text = isTiny ? ne.selection.getContent({ format: "text" }) : (window.getSelection()?.toString() || "");
2231
- if (!text && !html) return;
2232
- if (html && typeof ClipboardItem === "function" && navigator.clipboard?.write) {
2233
- await navigator.clipboard.write([new ClipboardItem({
2234
- "text/html": new Blob([html], { type: "text/html" }),
2235
- "text/plain": new Blob([text], { type: "text/plain" }),
2236
- })]);
2237
- } else {
2238
- await navigator.clipboard.writeText(text);
2239
- }
2240
- if (id === "cut") {
2241
- if (isTiny) ne.execCommand("Delete");
2242
- else if (ne && typeof ne.deleteText === "function") {
2243
- const sel = ne.getSelection();
2244
- if (sel?.length) ne.deleteText(sel.index, sel.length);
2245
- }
2246
- }
2247
- } catch (e: any) {
2248
- showDraftStatus(`${label} failed: ${e?.message || e}. ${shortcut} still works.`, true);
2249
- }
2250
- }
2251
-
2252
2182
  // ── Native context-menu app commands ──
2253
2183
  // msger appends app items to the WEBVIEW's native right-click menu (the
2254
2184
  // native menu must stay — Chromium's spellcheck suggestions exist only
@@ -2264,39 +2194,9 @@ async function runClipboardCommand(id: "cut" | "copy" | "paste"): Promise<void>
2264
2194
  try { (editor as any)?.showSource?.(); } catch { /* non-TinyMCE engine */ }
2265
2195
  return;
2266
2196
  }
2267
- if (id === "cut" || id === "copy" || id === "paste") { void runClipboardCommand(id); return; }
2268
- const ne: any = (editor as any)?.nativeEditor;
2269
- if (!ne) return;
2270
- const isTiny = typeof ne.execCommand === "function" && ne.selection;
2271
- if (isTiny) {
2272
- // "On a word" = collapsed caret → expand to the word first.
2273
- try { if (ne.selection.isCollapsed() && typeof ne.selection.expand === "function") ne.selection.expand({ type: "word" }); } catch { /* older TinyMCE */ }
2274
- const cmd: Record<string, string> = { bold: "Bold", italic: "Italic", underline: "Underline", strike: "Strikethrough", link: "mceLink", clear: "RemoveFormat" };
2275
- if (cmd[id]) ne.execCommand(cmd[id]);
2276
- return;
2277
- }
2278
- if (typeof ne.format === "function" && typeof ne.getSelection === "function") {
2279
- // Quill. Expand a collapsed caret to the word under it.
2280
- let sel = ne.getSelection();
2281
- if (sel && sel.length === 0) {
2282
- const text = ne.getText();
2283
- let a = sel.index, b = sel.index;
2284
- while (a > 0 && /S/.test(text[a - 1])) a--;
2285
- while (b < text.length && /S/.test(text[b])) b++;
2286
- if (b > a) { ne.setSelection(a, b - a); sel = { index: a, length: b - a }; }
2287
- }
2288
- if (!sel || sel.length === 0) return;
2289
- const cur = ne.getFormat(sel);
2290
- switch (id) {
2291
- case "bold": ne.format("bold", !cur.bold); break;
2292
- case "italic": ne.format("italic", !cur.italic); break;
2293
- case "underline": ne.format("underline", !cur.underline); break;
2294
- case "strike": ne.format("strike", !cur.strike); break;
2295
- case "clear": ne.removeFormat(sel.index, sel.length); break;
2296
- // link: Quill's dialog lives in its keyboard binding — Ctrl+K.
2297
- case "link": try { ne.root.dispatchEvent(new KeyboardEvent("keydown", { key: "K", ctrlKey: true, bubbles: true, cancelable: true })); } catch { /* */ } break;
2298
- }
2299
- }
2197
+ // Everything else is a shared editing command. Same implementation the
2198
+ // spell menus use one place to fix, so the two menus can't drift.
2199
+ runFormatCommand((editor as any)?.nativeEditor, id as EditCommandId);
2300
2200
  };
2301
2201
 
2302
2202
  // ── Cc / Bcc toggle ──
@@ -0,0 +1,207 @@
1
+ /**
2
+ * Shared editing commands for compose menus.
3
+ *
4
+ * These exist because mailx has THREE right-click menus over the same editor
5
+ * and they kept drifting apart:
6
+ * - msger's native menu, with app items appended (compose.ts dispatches
7
+ * them through window.__msgerContextCommand)
8
+ * - the TinyMCE spell menu (spellcheck.ts)
9
+ * - the Quill spell menu (editor.ts)
10
+ *
11
+ * The spell menus call preventDefault and REPLACE the native menu, so a
12
+ * right-click on a misspelled word — the single most likely right-click in a
13
+ * draft — produced a menu with spelling actions and nothing else: no Cut, no
14
+ * Copy, no Paste, no formatting (Bob 2026-08-07, "what happened to all the
15
+ * previous menu options"). Taking over a menu means taking over ALL of it, so
16
+ * the commands live here and every menu builds from the same set.
17
+ *
18
+ * Engine-agnostic: pass the underlying editor (TinyMCE Editor or Quill). The
19
+ * TinyMCE and Quill paths differ enough that each command branches, but the
20
+ * branching happens once, here, instead of in each menu.
21
+ */
22
+ function isTinyEditor(ne) {
23
+ return !!ne && typeof ne.execCommand === "function" && !!ne.selection;
24
+ }
25
+ /** Expand a collapsed caret to the word under it, so "right-click a word →
26
+ * Copy" (or Bold) acts on that word rather than on nothing. Matches what the
27
+ * native-menu formatting items have always done. */
28
+ function expandToWord(ne) {
29
+ if (isTinyEditor(ne)) {
30
+ try {
31
+ if (ne.selection.isCollapsed() && typeof ne.selection.expand === "function") {
32
+ ne.selection.expand({ type: "word" });
33
+ }
34
+ }
35
+ catch { /* older TinyMCE — command still applies at the caret */ }
36
+ return;
37
+ }
38
+ if (ne && typeof ne.getSelection === "function" && typeof ne.setSelection === "function") {
39
+ const sel = ne.getSelection();
40
+ if (!sel || sel.length > 0)
41
+ return;
42
+ const text = ne.getText();
43
+ let a = sel.index, b = sel.index;
44
+ while (a > 0 && /\S/.test(text[a - 1]))
45
+ a--;
46
+ while (b < text.length && /\S/.test(text[b]))
47
+ b++;
48
+ if (b > a)
49
+ ne.setSelection(a, b - a);
50
+ }
51
+ }
52
+ function escapeHtml(s) {
53
+ return s.replace(/[&<>]/g, c => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;" }[c]));
54
+ }
55
+ /**
56
+ * Cut / Copy / Paste through the async Clipboard API.
57
+ *
58
+ * NOT document.execCommand: that needs transient user activation, which a
59
+ * native-menu callback (dispatched from Rust via ExecuteScript) does not
60
+ * carry. Clipboard WRITE is allowed for a focused document; clipboard READ is
61
+ * permission-gated and can legitimately be refused — which is why this
62
+ * rejects with a real message instead of returning quietly. Callers surface
63
+ * it; a menu item that silently does nothing is worse than no menu item.
64
+ */
65
+ export async function runClipboardCommand(ne, id) {
66
+ const isTiny = isTinyEditor(ne);
67
+ if (id === "paste") {
68
+ let html = "";
69
+ let text = "";
70
+ if (navigator.clipboard?.read) {
71
+ for (const item of await navigator.clipboard.read()) {
72
+ if (item.types.includes("text/html"))
73
+ html = await (await item.getType("text/html")).text();
74
+ if (item.types.includes("text/plain"))
75
+ text = await (await item.getType("text/plain")).text();
76
+ }
77
+ }
78
+ else {
79
+ text = await navigator.clipboard.readText();
80
+ }
81
+ // Prefer HTML so formatting survives, as Ctrl+V would. Plain text has
82
+ // to be escaped and line-broken by hand or it lands as raw markup.
83
+ const content = html || (text ? escapeHtml(text).replace(/\r?\n/g, "<br>") : "");
84
+ if (!content)
85
+ return;
86
+ if (isTiny) {
87
+ ne.execCommand("mceInsertContent", false, content);
88
+ return;
89
+ }
90
+ if (ne?.clipboard?.dangerouslyPasteHTML) {
91
+ const sel = ne.getSelection(true);
92
+ ne.clipboard.dangerouslyPasteHTML(sel?.index ?? 0, content);
93
+ return;
94
+ }
95
+ throw new Error("editor doesn't support paste");
96
+ }
97
+ expandToWord(ne);
98
+ let html = "";
99
+ let text = "";
100
+ if (isTiny) {
101
+ html = ne.selection.getContent({ format: "html" });
102
+ text = ne.selection.getContent({ format: "text" });
103
+ }
104
+ else {
105
+ text = window.getSelection()?.toString() || "";
106
+ }
107
+ if (!text && !html)
108
+ return;
109
+ if (html && typeof ClipboardItem === "function" && navigator.clipboard?.write) {
110
+ await navigator.clipboard.write([new ClipboardItem({
111
+ "text/html": new Blob([html], { type: "text/html" }),
112
+ "text/plain": new Blob([text], { type: "text/plain" }),
113
+ })]);
114
+ }
115
+ else {
116
+ await navigator.clipboard.writeText(text);
117
+ }
118
+ if (id !== "cut")
119
+ return;
120
+ if (isTiny) {
121
+ ne.execCommand("Delete");
122
+ return;
123
+ }
124
+ if (typeof ne?.deleteText === "function") {
125
+ const sel = ne.getSelection();
126
+ if (sel?.length)
127
+ ne.deleteText(sel.index, sel.length);
128
+ }
129
+ }
130
+ /** Bold / Italic / Underline / Strikethrough / Link / Clear formatting, acting
131
+ * on the selection or, with a collapsed caret, the word under it. */
132
+ export function runFormatCommand(ne, id) {
133
+ if (!ne)
134
+ return;
135
+ expandToWord(ne);
136
+ if (isTinyEditor(ne)) {
137
+ const cmd = {
138
+ bold: "Bold", italic: "Italic", underline: "Underline",
139
+ strike: "Strikethrough", link: "mceLink", clear: "RemoveFormat",
140
+ };
141
+ if (cmd[id])
142
+ ne.execCommand(cmd[id]);
143
+ return;
144
+ }
145
+ if (typeof ne.format !== "function" || typeof ne.getSelection !== "function")
146
+ return;
147
+ const sel = ne.getSelection();
148
+ if (!sel || sel.length === 0)
149
+ return;
150
+ const cur = ne.getFormat(sel);
151
+ switch (id) {
152
+ case "bold":
153
+ ne.format("bold", !cur.bold);
154
+ break;
155
+ case "italic":
156
+ ne.format("italic", !cur.italic);
157
+ break;
158
+ case "underline":
159
+ ne.format("underline", !cur.underline);
160
+ break;
161
+ case "strike":
162
+ ne.format("strike", !cur.strike);
163
+ break;
164
+ case "clear":
165
+ ne.removeFormat(sel.index, sel.length);
166
+ break;
167
+ // Quill's link dialog lives in its keyboard binding — Ctrl+K.
168
+ case "link":
169
+ try {
170
+ ne.root.dispatchEvent(new KeyboardEvent("keydown", { key: "K", ctrlKey: true, bubbles: true, cancelable: true }));
171
+ }
172
+ catch { /* */ }
173
+ break;
174
+ }
175
+ }
176
+ /** The standard editing block appended to any menu that REPLACES the native
177
+ * one. `onError` reports a refused clipboard read rather than swallowing it.
178
+ * `showSource` is optional — only the TinyMCE path has a source dialog. */
179
+ export function standardEditItems(getEditor, opts = {}) {
180
+ const report = opts.onError || ((m) => console.warn(`[edit-commands] ${m}`));
181
+ const clip = (id) => () => {
182
+ void runClipboardCommand(getEditor(), id).catch((e) => {
183
+ const key = id === "cut" ? "Ctrl+X" : id === "copy" ? "Ctrl+C" : "Ctrl+V";
184
+ report(`${id[0].toUpperCase()}${id.slice(1)} failed: ${e?.message || e}. ${key} still works.`);
185
+ });
186
+ };
187
+ const fmt = (id) => () => runFormatCommand(getEditor(), id);
188
+ const items = [
189
+ { label: "", action: () => { }, separator: true },
190
+ { label: "Cut", action: clip("cut") },
191
+ { label: "Copy", action: clip("copy") },
192
+ { label: "Paste", action: clip("paste") },
193
+ { label: "", action: () => { }, separator: true },
194
+ { label: "Bold", action: fmt("bold") },
195
+ { label: "Italic", action: fmt("italic") },
196
+ { label: "Underline", action: fmt("underline") },
197
+ { label: "Strikethrough", action: fmt("strike") },
198
+ { label: "Link…", action: fmt("link") },
199
+ { label: "Clear formatting", action: fmt("clear") },
200
+ ];
201
+ if (opts.showSource) {
202
+ items.push({ label: "", action: () => { }, separator: true });
203
+ items.push({ label: "View HTML source…", action: opts.showSource });
204
+ }
205
+ return items;
206
+ }
207
+ //# sourceMappingURL=edit-commands.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"edit-commands.js","sourceRoot":"","sources":["edit-commands.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,181 @@
1
+ /**
2
+ * Shared editing commands for compose menus.
3
+ *
4
+ * These exist because mailx has THREE right-click menus over the same editor
5
+ * and they kept drifting apart:
6
+ * - msger's native menu, with app items appended (compose.ts dispatches
7
+ * them through window.__msgerContextCommand)
8
+ * - the TinyMCE spell menu (spellcheck.ts)
9
+ * - the Quill spell menu (editor.ts)
10
+ *
11
+ * The spell menus call preventDefault and REPLACE the native menu, so a
12
+ * right-click on a misspelled word — the single most likely right-click in a
13
+ * draft — produced a menu with spelling actions and nothing else: no Cut, no
14
+ * Copy, no Paste, no formatting (Bob 2026-08-07, "what happened to all the
15
+ * previous menu options"). Taking over a menu means taking over ALL of it, so
16
+ * the commands live here and every menu builds from the same set.
17
+ *
18
+ * Engine-agnostic: pass the underlying editor (TinyMCE Editor or Quill). The
19
+ * TinyMCE and Quill paths differ enough that each command branches, but the
20
+ * branching happens once, here, instead of in each menu.
21
+ */
22
+
23
+ export type EditCommandId = "cut" | "copy" | "paste" | "bold" | "italic" | "underline" | "strike" | "link" | "clear";
24
+
25
+ function isTinyEditor(ne: any): boolean {
26
+ return !!ne && typeof ne.execCommand === "function" && !!ne.selection;
27
+ }
28
+
29
+ /** Expand a collapsed caret to the word under it, so "right-click a word →
30
+ * Copy" (or Bold) acts on that word rather than on nothing. Matches what the
31
+ * native-menu formatting items have always done. */
32
+ function expandToWord(ne: any): void {
33
+ if (isTinyEditor(ne)) {
34
+ try {
35
+ if (ne.selection.isCollapsed() && typeof ne.selection.expand === "function") {
36
+ ne.selection.expand({ type: "word" });
37
+ }
38
+ } catch { /* older TinyMCE — command still applies at the caret */ }
39
+ return;
40
+ }
41
+ if (ne && typeof ne.getSelection === "function" && typeof ne.setSelection === "function") {
42
+ const sel = ne.getSelection();
43
+ if (!sel || sel.length > 0) return;
44
+ const text: string = ne.getText();
45
+ let a = sel.index, b = sel.index;
46
+ while (a > 0 && /\S/.test(text[a - 1])) a--;
47
+ while (b < text.length && /\S/.test(text[b])) b++;
48
+ if (b > a) ne.setSelection(a, b - a);
49
+ }
50
+ }
51
+
52
+ function escapeHtml(s: string): string {
53
+ return s.replace(/[&<>]/g, c => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;" }[c] as string));
54
+ }
55
+
56
+ /**
57
+ * Cut / Copy / Paste through the async Clipboard API.
58
+ *
59
+ * NOT document.execCommand: that needs transient user activation, which a
60
+ * native-menu callback (dispatched from Rust via ExecuteScript) does not
61
+ * carry. Clipboard WRITE is allowed for a focused document; clipboard READ is
62
+ * permission-gated and can legitimately be refused — which is why this
63
+ * rejects with a real message instead of returning quietly. Callers surface
64
+ * it; a menu item that silently does nothing is worse than no menu item.
65
+ */
66
+ export async function runClipboardCommand(ne: any, id: "cut" | "copy" | "paste"): Promise<void> {
67
+ const isTiny = isTinyEditor(ne);
68
+ if (id === "paste") {
69
+ let html = "";
70
+ let text = "";
71
+ if (navigator.clipboard?.read) {
72
+ for (const item of await navigator.clipboard.read()) {
73
+ if (item.types.includes("text/html")) html = await (await item.getType("text/html")).text();
74
+ if (item.types.includes("text/plain")) text = await (await item.getType("text/plain")).text();
75
+ }
76
+ } else {
77
+ text = await navigator.clipboard.readText();
78
+ }
79
+ // Prefer HTML so formatting survives, as Ctrl+V would. Plain text has
80
+ // to be escaped and line-broken by hand or it lands as raw markup.
81
+ const content = html || (text ? escapeHtml(text).replace(/\r?\n/g, "<br>") : "");
82
+ if (!content) return;
83
+ if (isTiny) { ne.execCommand("mceInsertContent", false, content); return; }
84
+ if (ne?.clipboard?.dangerouslyPasteHTML) {
85
+ const sel = ne.getSelection(true);
86
+ ne.clipboard.dangerouslyPasteHTML(sel?.index ?? 0, content);
87
+ return;
88
+ }
89
+ throw new Error("editor doesn't support paste");
90
+ }
91
+
92
+ expandToWord(ne);
93
+ let html = "";
94
+ let text = "";
95
+ if (isTiny) {
96
+ html = ne.selection.getContent({ format: "html" });
97
+ text = ne.selection.getContent({ format: "text" });
98
+ } else {
99
+ text = window.getSelection()?.toString() || "";
100
+ }
101
+ if (!text && !html) return;
102
+ if (html && typeof ClipboardItem === "function" && navigator.clipboard?.write) {
103
+ await navigator.clipboard.write([new ClipboardItem({
104
+ "text/html": new Blob([html], { type: "text/html" }),
105
+ "text/plain": new Blob([text], { type: "text/plain" }),
106
+ })]);
107
+ } else {
108
+ await navigator.clipboard.writeText(text);
109
+ }
110
+ if (id !== "cut") return;
111
+ if (isTiny) { ne.execCommand("Delete"); return; }
112
+ if (typeof ne?.deleteText === "function") {
113
+ const sel = ne.getSelection();
114
+ if (sel?.length) ne.deleteText(sel.index, sel.length);
115
+ }
116
+ }
117
+
118
+ /** Bold / Italic / Underline / Strikethrough / Link / Clear formatting, acting
119
+ * on the selection or, with a collapsed caret, the word under it. */
120
+ export function runFormatCommand(ne: any, id: EditCommandId): void {
121
+ if (!ne) return;
122
+ expandToWord(ne);
123
+ if (isTinyEditor(ne)) {
124
+ const cmd: Record<string, string> = {
125
+ bold: "Bold", italic: "Italic", underline: "Underline",
126
+ strike: "Strikethrough", link: "mceLink", clear: "RemoveFormat",
127
+ };
128
+ if (cmd[id]) ne.execCommand(cmd[id]);
129
+ return;
130
+ }
131
+ if (typeof ne.format !== "function" || typeof ne.getSelection !== "function") return;
132
+ const sel = ne.getSelection();
133
+ if (!sel || sel.length === 0) return;
134
+ const cur = ne.getFormat(sel);
135
+ switch (id) {
136
+ case "bold": ne.format("bold", !cur.bold); break;
137
+ case "italic": ne.format("italic", !cur.italic); break;
138
+ case "underline": ne.format("underline", !cur.underline); break;
139
+ case "strike": ne.format("strike", !cur.strike); break;
140
+ case "clear": ne.removeFormat(sel.index, sel.length); break;
141
+ // Quill's link dialog lives in its keyboard binding — Ctrl+K.
142
+ case "link":
143
+ try { ne.root.dispatchEvent(new KeyboardEvent("keydown", { key: "K", ctrlKey: true, bubbles: true, cancelable: true })); } catch { /* */ }
144
+ break;
145
+ }
146
+ }
147
+
148
+ /** The standard editing block appended to any menu that REPLACES the native
149
+ * one. `onError` reports a refused clipboard read rather than swallowing it.
150
+ * `showSource` is optional — only the TinyMCE path has a source dialog. */
151
+ export function standardEditItems(
152
+ getEditor: () => any,
153
+ opts: { onError?: (msg: string) => void; showSource?: () => void } = {},
154
+ ): Array<{ label: string; action: () => void; separator?: boolean }> {
155
+ const report = opts.onError || ((m: string) => console.warn(`[edit-commands] ${m}`));
156
+ const clip = (id: "cut" | "copy" | "paste") => () => {
157
+ void runClipboardCommand(getEditor(), id).catch((e: any) => {
158
+ const key = id === "cut" ? "Ctrl+X" : id === "copy" ? "Ctrl+C" : "Ctrl+V";
159
+ report(`${id[0].toUpperCase()}${id.slice(1)} failed: ${e?.message || e}. ${key} still works.`);
160
+ });
161
+ };
162
+ const fmt = (id: EditCommandId) => () => runFormatCommand(getEditor(), id);
163
+ const items: Array<{ label: string; action: () => void; separator?: boolean }> = [
164
+ { label: "", action: () => { /* */ }, separator: true },
165
+ { label: "Cut", action: clip("cut") },
166
+ { label: "Copy", action: clip("copy") },
167
+ { label: "Paste", action: clip("paste") },
168
+ { label: "", action: () => { /* */ }, separator: true },
169
+ { label: "Bold", action: fmt("bold") },
170
+ { label: "Italic", action: fmt("italic") },
171
+ { label: "Underline", action: fmt("underline") },
172
+ { label: "Strikethrough", action: fmt("strike") },
173
+ { label: "Link…", action: fmt("link") },
174
+ { label: "Clear formatting", action: fmt("clear") },
175
+ ];
176
+ if (opts.showSource) {
177
+ items.push({ label: "", action: () => { /* */ }, separator: true });
178
+ items.push({ label: "View HTML source…", action: opts.showSource });
179
+ }
180
+ return items;
181
+ }
@@ -343,6 +343,12 @@ function createQuillEditor(container) {
343
343
  label: "Ignore (this session)",
344
344
  action: () => _spellSp.add(word),
345
345
  });
346
+ // Same reasoning as the TinyMCE path in spellcheck.ts: this menu
347
+ // replaced the native one, so it has to carry the standard editing
348
+ // commands too, or right-clicking a misspelled word loses Cut,
349
+ // Copy, Paste and every formatting entry.
350
+ const { standardEditItems } = await import("./edit-commands.js");
351
+ items.push(...standardEditItems(() => q));
346
352
  core.showSuggestionsMenu(document, e.clientX, e.clientY, items);
347
353
  }
348
354
  catch (err) {