@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.
- package/bin/mailx.js +6 -9
- package/bin/mailx.js.map +1 -1
- package/bin/mailx.ts +6 -9
- package/client/android-bootstrap.bundle.js +38 -4
- package/client/android-bootstrap.bundle.js.map +2 -2
- package/client/app.bundle.js +149 -32
- package/client/app.bundle.js.map +2 -2
- package/client/components/address-book.js +93 -29
- package/client/components/address-book.js.map +1 -1
- package/client/components/address-book.ts +103 -24
- package/client/components/context-menu.js +76 -0
- package/client/components/context-menu.js.map +1 -1
- package/client/components/context-menu.ts +77 -0
- package/client/compose/compose.bundle.js +276 -116
- package/client/compose/compose.bundle.js.map +4 -4
- package/client/compose/compose.js +4 -149
- package/client/compose/compose.js.map +1 -1
- package/client/compose/compose.ts +4 -104
- package/client/compose/edit-commands.js +207 -0
- package/client/compose/edit-commands.js.map +1 -0
- package/client/compose/edit-commands.ts +181 -0
- package/client/compose/editor.js +6 -0
- package/client/compose/editor.js.map +1 -1
- package/client/compose/editor.ts +6 -0
- package/client/compose/spellcheck.js +19 -0
- package/client/compose/spellcheck.js.map +1 -1
- package/client/compose/spellcheck.ts +11 -0
- package/client/lib/api-client.js +2 -2
- package/client/lib/api-client.js.map +1 -1
- package/client/lib/api-client.ts +2 -2
- package/client/lib/mailxapi.js +2 -2
- package/client/styles/components.css +40 -0
- package/package.json +7 -7
- package/packages/mailx-imap/index.d.ts.map +1 -1
- package/packages/mailx-imap/index.js +32 -4
- package/packages/mailx-imap/index.js.map +1 -1
- package/packages/mailx-imap/index.ts +34 -5
- package/packages/mailx-imap/package-lock.json +2 -2
- package/packages/mailx-imap/package.json +1 -1
- package/packages/mailx-service/index.d.ts +1 -1
- package/packages/mailx-service/index.d.ts.map +1 -1
- package/packages/mailx-service/index.js +33 -4
- package/packages/mailx-service/index.js.map +1 -1
- package/packages/mailx-service/index.ts +28 -4
- package/packages/mailx-service/jsonrpc.js +1 -1
- package/packages/mailx-service/jsonrpc.js.map +1 -1
- package/packages/mailx-service/jsonrpc.ts +1 -1
- package/packages/mailx-service/package.json +1 -1
- package/packages/mailx-store/db.d.ts +30 -10
- package/packages/mailx-store/db.d.ts.map +1 -1
- package/packages/mailx-store/db.js +108 -10
- package/packages/mailx-store/db.js.map +1 -1
- package/packages/mailx-store/db.ts +126 -13
- package/packages/mailx-store/package.json +1 -1
- package/packages/mailx-store-web/android-bootstrap.d.ts.map +1 -1
- package/packages/mailx-store-web/android-bootstrap.js +17 -2
- package/packages/mailx-store-web/android-bootstrap.js.map +1 -1
- package/packages/mailx-store-web/android-bootstrap.ts +17 -2
- package/packages/mailx-store-web/db.d.ts +1 -1
- package/packages/mailx-store-web/db.d.ts.map +1 -1
- package/packages/mailx-store-web/db.js +17 -2
- package/packages/mailx-store-web/db.js.map +1 -1
- package/packages/mailx-store-web/db.ts +18 -3
- package/packages/mailx-store-web/package.json +1 -1
- /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 => ({ "&": "&", "<": "<", ">": ">" }[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
|
-
|
|
2268
|
-
|
|
2269
|
-
|
|
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 => ({ "&": "&", "<": "<", ">": ">" }[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 => ({ "&": "&", "<": "<", ">": ">" }[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
|
+
}
|
package/client/compose/editor.js
CHANGED
|
@@ -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) {
|