@bobfrankston/rmfmail 1.0.502 → 1.0.504
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 +9 -1
- package/bin/mailx.js.map +1 -1
- package/bin/mailx.ts +9 -1
- package/client/app.js +82 -32
- package/client/app.js.map +1 -1
- package/client/app.ts +88 -32
- package/client/components/message-viewer.js +51 -21
- package/client/components/message-viewer.js.map +1 -1
- package/client/components/message-viewer.ts +38 -19
- package/client/compose/compose.css +14 -0
- package/client/compose/compose.html +4 -3
- package/client/compose/compose.js +288 -36
- package/client/compose/compose.js.map +1 -1
- package/client/compose/compose.ts +270 -40
- package/client/compose/editor-help.js +107 -0
- package/client/compose/editor-help.js.map +1 -0
- package/client/compose/editor-help.ts +106 -0
- package/client/compose/editor.js +43 -0
- package/client/compose/editor.js.map +1 -1
- package/client/compose/editor.ts +22 -0
- package/client/index.html +1 -4
- package/docs/editor.md +92 -0
- package/package.json +10 -4
- package/packages/mailx-service/html-to-docx.d.ts +1 -0
- package/packages/mailx-service/index.d.ts +10 -2
- package/packages/mailx-service/index.d.ts.map +1 -1
- package/packages/mailx-service/index.js +143 -21
- package/packages/mailx-service/index.js.map +1 -1
- package/packages/mailx-service/index.ts +114 -21
- package/packages/mailx-service/package.json +5 -3
- package/packages/mailx-settings/docs/accounts.md +41 -0
- package/packages/mailx-settings/docs/allowlist.md +27 -0
- package/packages/mailx-settings/docs/clients.md +24 -0
- package/packages/mailx-settings/docs/config.md +32 -0
- package/packages/mailx-settings/docs/contact-rules.md +40 -0
- package/packages/mailx-settings/docs/contacts.md +48 -0
- package/packages/mailx-settings/docs/editor.md +92 -0
- package/packages/mailx-settings/docs/preferences.md +43 -0
- package/packages/mailx-settings/index.d.ts +2 -2
- package/packages/mailx-settings/index.d.ts.map +1 -1
- package/packages/mailx-settings/index.js +120 -23
- package/packages/mailx-settings/index.js.map +1 -1
- package/packages/mailx-settings/index.ts +98 -23
- package/packages/mailx-store/db.d.ts +8 -0
- package/packages/mailx-store/db.d.ts.map +1 -1
- package/packages/mailx-store/db.js +22 -0
- package/packages/mailx-store/db.js.map +1 -1
- package/packages/mailx-store/db.ts +22 -0
- package/packages/mailx-store/file-store.d.ts +31 -13
- package/packages/mailx-store/file-store.d.ts.map +1 -1
- package/packages/mailx-store/file-store.js +75 -31
- package/packages/mailx-store/file-store.js.map +1 -1
- package/packages/mailx-store/file-store.ts +72 -27
- package/packages/mailx-types/groups.d.ts +38 -0
- package/packages/mailx-types/groups.d.ts.map +1 -0
- package/packages/mailx-types/groups.js +99 -0
- package/packages/mailx-types/groups.js.map +1 -0
- package/packages/mailx-types/groups.ts +112 -0
- package/packages/mailx-types/index.d.ts +2 -0
- package/packages/mailx-types/index.d.ts.map +1 -1
- package/packages/mailx-types/index.js +5 -0
- package/packages/mailx-types/index.js.map +1 -1
- package/packages/mailx-types/index.ts +9 -0
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
</div>
|
|
20
20
|
<div class="compose-field">
|
|
21
21
|
<label for="compose-to">To</label>
|
|
22
|
-
<
|
|
22
|
+
<textarea id="compose-to" class="compose-addr-input" autocomplete="off" rows="1" spellcheck="false"></textarea>
|
|
23
23
|
<span class="compose-recipient-toggle">
|
|
24
24
|
<button type="button" class="compose-toggle-btn" id="btn-toggle-cc" tabindex="-1" title="Show/hide Cc">Cc</button>
|
|
25
25
|
<button type="button" class="compose-toggle-btn" id="btn-toggle-bcc" tabindex="-1" title="Show/hide Bcc">Bcc</button>
|
|
@@ -27,11 +27,11 @@
|
|
|
27
27
|
</div>
|
|
28
28
|
<div class="compose-field" id="compose-cc-row" hidden>
|
|
29
29
|
<label for="compose-cc">Cc</label>
|
|
30
|
-
<
|
|
30
|
+
<textarea id="compose-cc" class="compose-addr-input" autocomplete="off" rows="1" spellcheck="false"></textarea>
|
|
31
31
|
</div>
|
|
32
32
|
<div class="compose-field" id="compose-bcc-row" hidden>
|
|
33
33
|
<label for="compose-bcc">Bcc</label>
|
|
34
|
-
<
|
|
34
|
+
<textarea id="compose-bcc" class="compose-addr-input" autocomplete="off" rows="1" spellcheck="false"></textarea>
|
|
35
35
|
</div>
|
|
36
36
|
<div class="compose-field">
|
|
37
37
|
<label for="compose-subject">Subject</label>
|
|
@@ -43,6 +43,7 @@
|
|
|
43
43
|
<button class="tb-btn" id="btn-attach">Attach</button>
|
|
44
44
|
<input type="file" id="compose-file" multiple hidden>
|
|
45
45
|
<button class="tb-btn" id="btn-edit-in-word" title="Open the body in Microsoft Word — saves in Word reload back into the editor">Edit in Word</button>
|
|
46
|
+
<button class="tb-btn" id="btn-compose-help" title="Editor shortcuts and formatting reference (Quill vs tiptap differences)">?</button>
|
|
46
47
|
<button class="tb-btn" id="btn-discard">Discard</button>
|
|
47
48
|
<span id="compose-status" class="compose-status"></span>
|
|
48
49
|
</div>
|
|
@@ -92,34 +92,48 @@ catch { /* private-mode / SecurityError — default quill */ }
|
|
|
92
92
|
}
|
|
93
93
|
catch { /* non-fatal */ }
|
|
94
94
|
})();
|
|
95
|
-
//
|
|
96
|
-
//
|
|
97
|
-
//
|
|
98
|
-
//
|
|
99
|
-
//
|
|
100
|
-
//
|
|
95
|
+
// Editor init — try the preferred type first; if its CDN/setup fails,
|
|
96
|
+
// fall back to the OTHER editor before giving up on the toolbar entirely.
|
|
97
|
+
// Both Quill and tiptap fetch from jsdelivr; transient CDN issues used to
|
|
98
|
+
// drop the user into a plain-textarea fallback with no formatting controls.
|
|
99
|
+
// Now we keep trying until either an editor with a toolbar comes up, or
|
|
100
|
+
// both fail and we render the minimal contenteditable as last resort.
|
|
101
101
|
let editor;
|
|
102
|
-
let editorAssetError = null;
|
|
103
|
-
try {
|
|
104
|
-
await loadEditorAssets(editorType);
|
|
105
|
-
}
|
|
106
|
-
catch (e) {
|
|
107
|
-
editorAssetError = e;
|
|
108
|
-
logClientEvent("compose-editor-assets-failed", { type: editorType, error: String(e?.message || e) });
|
|
109
|
-
}
|
|
110
102
|
const container = document.getElementById("compose-editor");
|
|
111
|
-
|
|
112
|
-
try {
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
103
|
+
async function tryEditor(type) {
|
|
104
|
+
try {
|
|
105
|
+
await loadEditorAssets(type);
|
|
106
|
+
}
|
|
107
|
+
catch (e) {
|
|
108
|
+
logClientEvent("compose-editor-assets-failed", { type, error: String(e?.message || e) });
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
container.classList.remove("editor-tiptap", "editor-quill");
|
|
112
|
+
container.classList.add(type === "tiptap" ? "editor-tiptap" : "editor-quill");
|
|
113
|
+
try {
|
|
114
|
+
return await createEditor(container, type);
|
|
115
|
+
}
|
|
116
|
+
catch (e) {
|
|
117
|
+
logClientEvent("compose-editor-create-failed", { type, error: String(e?.message || e) });
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
116
120
|
}
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
//
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
121
|
+
editor = (await tryEditor(editorType));
|
|
122
|
+
if (!editor) {
|
|
123
|
+
// Preferred editor failed — try the other one before giving up.
|
|
124
|
+
const fallbackType = editorType === "tiptap" ? "quill" : "tiptap";
|
|
125
|
+
logClientEvent("compose-editor-fallback-other", { from: editorType, to: fallbackType });
|
|
126
|
+
container.innerHTML = ""; // clear any partial render from the failed try
|
|
127
|
+
editor = (await tryEditor(fallbackType));
|
|
128
|
+
if (editor) {
|
|
129
|
+
setTimeout(() => showDraftStatus(`${editorType} editor unavailable — using ${fallbackType} instead.`, false), 0);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
if (!editor) {
|
|
133
|
+
// Both editors failed — render a minimal contenteditable as last resort.
|
|
134
|
+
// No toolbar, no rich-text shortcuts; user can still type. Status bar
|
|
135
|
+
// surfaces the failure so the missing toolbar isn't a silent mystery.
|
|
136
|
+
logClientEvent("compose-editor-create-failed-both", {});
|
|
123
137
|
container.innerHTML = `<div class="compose-fallback-editor" contenteditable="true" style="border:1px solid #c00;padding:8px;min-height:200px;background:#fff" data-fallback="true"></div>`;
|
|
124
138
|
const fallback = container.querySelector(".compose-fallback-editor");
|
|
125
139
|
editor = {
|
|
@@ -144,9 +158,7 @@ catch (e) {
|
|
|
144
158
|
}
|
|
145
159
|
},
|
|
146
160
|
};
|
|
147
|
-
|
|
148
|
-
// why the toolbar is missing and the editor is plain.
|
|
149
|
-
setTimeout(() => showDraftStatus(`Editor failed to load (${editorType}). Plain-text fallback in use. Open log for details.`, true), 0);
|
|
161
|
+
setTimeout(() => showDraftStatus(`Both editors failed to load. Plain-text fallback in use. Check the log; CDN may be unreachable.`, true), 0);
|
|
150
162
|
}
|
|
151
163
|
// Ctrl+scroll / Ctrl+= / Ctrl+- / Ctrl+0 zoom for the compose editor body.
|
|
152
164
|
// Persists per-session in localStorage so zoom survives window pop/close cycles.
|
|
@@ -193,10 +205,44 @@ catch (e) {
|
|
|
193
205
|
// separate "Other..." escape hatch, no hidden custom input toggle.
|
|
194
206
|
const fromInput = document.getElementById("compose-from-input");
|
|
195
207
|
const fromOptions = document.getElementById("compose-from-options");
|
|
208
|
+
// Address fields are textareas (not inputs) so they wrap to multiple lines
|
|
209
|
+
// when the recipient list gets long — single-line inputs scrolled off-screen
|
|
210
|
+
// horizontally. JS auto-grows their height on input below.
|
|
196
211
|
const toInput = document.getElementById("compose-to");
|
|
197
212
|
const ccInput = document.getElementById("compose-cc");
|
|
198
213
|
const bccInput = document.getElementById("compose-bcc");
|
|
199
214
|
const subjectInput = document.getElementById("compose-subject");
|
|
215
|
+
/** Resize an address textarea to fit its content. Called on input + after
|
|
216
|
+
* programmatic value sets (applyInit). Caps at 8 lines — beyond that the
|
|
217
|
+
* field scrolls to keep the overall compose window manageable. */
|
|
218
|
+
function autoGrowAddrInput(el) {
|
|
219
|
+
if (!el)
|
|
220
|
+
return;
|
|
221
|
+
el.style.height = "auto";
|
|
222
|
+
const max = 8 * 22; // ~8 lines × line-height
|
|
223
|
+
el.style.height = Math.min(el.scrollHeight, max) + "px";
|
|
224
|
+
if (el.scrollHeight > max)
|
|
225
|
+
el.style.overflowY = "auto";
|
|
226
|
+
else
|
|
227
|
+
el.style.overflowY = "hidden";
|
|
228
|
+
}
|
|
229
|
+
for (const el of [toInput, ccInput, bccInput]) {
|
|
230
|
+
el?.addEventListener("input", () => autoGrowAddrInput(el));
|
|
231
|
+
// Treat Enter as a recipient separator (split on comma OR newline at
|
|
232
|
+
// send time), but don't insert a literal newline — that'd break the
|
|
233
|
+
// implicit "comma-separated list" assumption downstream. Replace with
|
|
234
|
+
// a comma + space.
|
|
235
|
+
el?.addEventListener("keydown", (e) => {
|
|
236
|
+
if (e.key === "Enter" && !e.shiftKey && !e.ctrlKey && !e.metaKey) {
|
|
237
|
+
e.preventDefault();
|
|
238
|
+
const start = el.selectionStart || 0;
|
|
239
|
+
const v = el.value;
|
|
240
|
+
el.value = v.slice(0, start).replace(/[,\s]+$/, "") + ", " + v.slice(start).replace(/^[,\s]+/, "");
|
|
241
|
+
el.selectionStart = el.selectionEnd = start + 2;
|
|
242
|
+
autoGrowAddrInput(el);
|
|
243
|
+
}
|
|
244
|
+
});
|
|
245
|
+
}
|
|
200
246
|
let knownAccounts = [];
|
|
201
247
|
// ── AI ghost text autocomplete ──
|
|
202
248
|
if (appSettings?.autocomplete?.enabled && appSettings.autocomplete.provider !== "off") {
|
|
@@ -485,17 +531,18 @@ function setupAutocomplete(input) {
|
|
|
485
531
|
if (!dropdown)
|
|
486
532
|
return;
|
|
487
533
|
const items = dropdown.querySelectorAll(".ac-item");
|
|
488
|
-
|
|
534
|
+
const ke = e;
|
|
535
|
+
if (ke.key === "ArrowDown") {
|
|
489
536
|
e.preventDefault();
|
|
490
537
|
activeIndex = Math.min(activeIndex + 1, items.length - 1);
|
|
491
538
|
items.forEach((el, i) => el.classList.toggle("ac-active", i === activeIndex));
|
|
492
539
|
}
|
|
493
|
-
else if (
|
|
540
|
+
else if (ke.key === "ArrowUp") {
|
|
494
541
|
e.preventDefault();
|
|
495
542
|
activeIndex = Math.max(activeIndex - 1, 0);
|
|
496
543
|
items.forEach((el, i) => el.classList.toggle("ac-active", i === activeIndex));
|
|
497
544
|
}
|
|
498
|
-
else if (
|
|
545
|
+
else if (ke.key === "Tab" || ke.key === "Enter") {
|
|
499
546
|
if (items.length > 0) {
|
|
500
547
|
e.preventDefault();
|
|
501
548
|
const idx = activeIndex >= 0 ? activeIndex : 0;
|
|
@@ -504,7 +551,7 @@ function setupAutocomplete(input) {
|
|
|
504
551
|
return;
|
|
505
552
|
}
|
|
506
553
|
}
|
|
507
|
-
else if (
|
|
554
|
+
else if (ke.key === "Escape") {
|
|
508
555
|
closeDropdown();
|
|
509
556
|
}
|
|
510
557
|
});
|
|
@@ -534,6 +581,47 @@ function parseAddrs(s) {
|
|
|
534
581
|
return { name: "", address: part };
|
|
535
582
|
});
|
|
536
583
|
}
|
|
584
|
+
/** Expand any group names in a recipient string against the user's
|
|
585
|
+
* contacts.jsonc → groups map. Recipients that match a group name (case
|
|
586
|
+
* insensitive) get replaced by their member list, recursively. Unresolved
|
|
587
|
+
* tokens are left in place so the user sees them and can fix the typo.
|
|
588
|
+
* Loaded once per send via `getContactsConfig`; cached for the session. */
|
|
589
|
+
let _groupsCache = null;
|
|
590
|
+
async function loadGroupsMap() {
|
|
591
|
+
if (_groupsCache)
|
|
592
|
+
return _groupsCache;
|
|
593
|
+
try {
|
|
594
|
+
const { readJsoncFile } = await import("../lib/api-client.js");
|
|
595
|
+
const r = await readJsoncFile("contacts.jsonc");
|
|
596
|
+
if (!r?.content) {
|
|
597
|
+
_groupsCache = {};
|
|
598
|
+
return _groupsCache;
|
|
599
|
+
}
|
|
600
|
+
// Use a loose JSONC parse: strip // comments + trailing commas.
|
|
601
|
+
const stripped = r.content.replace(/^\s*\/\/.*$/gm, "").replace(/,(\s*[}\]])/g, "$1");
|
|
602
|
+
const cfg = JSON.parse(stripped);
|
|
603
|
+
_groupsCache = (cfg && typeof cfg.groups === "object" && cfg.groups) ? cfg.groups : {};
|
|
604
|
+
return _groupsCache;
|
|
605
|
+
}
|
|
606
|
+
catch {
|
|
607
|
+
_groupsCache = {};
|
|
608
|
+
return _groupsCache;
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
/** Expand the raw recipient string against the loaded groups map. */
|
|
612
|
+
async function expandGroups(raw) {
|
|
613
|
+
if (!raw.trim())
|
|
614
|
+
return raw;
|
|
615
|
+
const groups = await loadGroupsMap();
|
|
616
|
+
if (Object.keys(groups).length === 0)
|
|
617
|
+
return raw;
|
|
618
|
+
const { expandRecipients } = await import("@bobfrankston/mailx-types");
|
|
619
|
+
const r = expandRecipients(raw, groups);
|
|
620
|
+
if (r.unresolved.length > 0) {
|
|
621
|
+
console.warn(`[groups] unresolved recipients: ${r.unresolved.join(", ")}`);
|
|
622
|
+
}
|
|
623
|
+
return r.addresses.join(", ");
|
|
624
|
+
}
|
|
537
625
|
function applyInit(init) {
|
|
538
626
|
// Populate the From datalist with known accounts
|
|
539
627
|
populateFromOptions(init.accounts, init.accountId);
|
|
@@ -547,6 +635,12 @@ function applyInit(init) {
|
|
|
547
635
|
toInput.value = formatAddrs(init.to);
|
|
548
636
|
ccInput.value = formatAddrs(init.cc);
|
|
549
637
|
subjectInput.value = init.subject;
|
|
638
|
+
// Resize the textareas to match the freshly-loaded recipient lists —
|
|
639
|
+
// without this the textareas stay 1-row even when the To: contains a
|
|
640
|
+
// 200-character reply-all address list.
|
|
641
|
+
autoGrowAddrInput(toInput);
|
|
642
|
+
autoGrowAddrInput(ccInput);
|
|
643
|
+
autoGrowAddrInput(bccInput);
|
|
550
644
|
// Auto-expand Cc row if the init already has Cc content (reply-all, draft-with-cc)
|
|
551
645
|
if (ccInput.value.trim()) {
|
|
552
646
|
const ccRowEl = document.getElementById("compose-cc-row");
|
|
@@ -837,18 +931,24 @@ window.addEventListener("blur", () => {
|
|
|
837
931
|
}
|
|
838
932
|
catch { /* */ }
|
|
839
933
|
});
|
|
840
|
-
document.getElementById("btn-send")?.addEventListener("click", () => {
|
|
934
|
+
document.getElementById("btn-send")?.addEventListener("click", async () => {
|
|
841
935
|
// Loud tracing through the whole send pipeline. Every step ships a
|
|
842
936
|
// `[client] compose-send-*` event to the Node log so a "vanished message"
|
|
843
937
|
// report can be traced end-to-end without devtools. If the log stops
|
|
844
938
|
// at any step, that's where the pipeline broke.
|
|
845
939
|
logClientEvent("compose-send-click");
|
|
940
|
+
// Group expansion — replace any group names (family, neighbors, etc.)
|
|
941
|
+
// with their address lists from contacts.jsonc → groups before parsing
|
|
942
|
+
// into the validated {name, address}[] shape the service expects.
|
|
943
|
+
const toExpanded = await expandGroups(toInput.value);
|
|
944
|
+
const ccExpanded = await expandGroups(ccInput.value);
|
|
945
|
+
const bccExpanded = await expandGroups(bccInput.value);
|
|
846
946
|
const body = {
|
|
847
947
|
from: getFromAccountId(),
|
|
848
948
|
fromAddress: getFromAddress(),
|
|
849
|
-
to: parseAddrs(
|
|
850
|
-
cc: parseAddrs(
|
|
851
|
-
bcc: parseAddrs(
|
|
949
|
+
to: parseAddrs(toExpanded),
|
|
950
|
+
cc: parseAddrs(ccExpanded),
|
|
951
|
+
bcc: parseAddrs(bccExpanded),
|
|
852
952
|
subject: subjectInput.value,
|
|
853
953
|
bodyHtml: editor.getHtml(),
|
|
854
954
|
bodyText: editor.getText(),
|
|
@@ -1170,11 +1270,163 @@ document.getElementById("btn-edit-in-word")?.addEventListener("click", async ()
|
|
|
1170
1270
|
result.opener === "libreoffice" ? "LibreOffice" :
|
|
1171
1271
|
"your default editor";
|
|
1172
1272
|
showDraftStatus(`Editing in ${label} — saves there will reload here.`, false);
|
|
1273
|
+
showExternalEditHint(label);
|
|
1173
1274
|
}
|
|
1174
1275
|
catch (e) {
|
|
1175
1276
|
showDraftStatus(`Edit-in-Word failed: ${e?.message || e}`, true);
|
|
1176
1277
|
}
|
|
1177
1278
|
});
|
|
1279
|
+
// Editor help button — opens a reference modal with shortcuts and the
|
|
1280
|
+
// Quill / tiptap differences. Source content lives in app/docs/editor.md
|
|
1281
|
+
// (and is mirrored in editor-help.ts for runtime embed).
|
|
1282
|
+
document.getElementById("btn-compose-help")?.addEventListener("click", async () => {
|
|
1283
|
+
try {
|
|
1284
|
+
const { EDITOR_HELP_MD } = await import("./editor-help.js");
|
|
1285
|
+
showEditorHelpModal(EDITOR_HELP_MD);
|
|
1286
|
+
}
|
|
1287
|
+
catch (e) {
|
|
1288
|
+
showDraftStatus(`Couldn't open help: ${e?.message || e}`, true);
|
|
1289
|
+
}
|
|
1290
|
+
});
|
|
1291
|
+
/** Render a markdown string in a centered modal. Click outside / Esc /
|
|
1292
|
+
* Close button dismiss. Lightweight markdown rendering — handles headings,
|
|
1293
|
+
* paragraphs, lists, tables, inline code, bold/italic. Intentionally
|
|
1294
|
+
* doesn't pull a full markdown library since this is one document. */
|
|
1295
|
+
function showEditorHelpModal(md) {
|
|
1296
|
+
if (document.getElementById("mailx-editor-help-modal"))
|
|
1297
|
+
return;
|
|
1298
|
+
const backdrop = document.createElement("div");
|
|
1299
|
+
backdrop.id = "mailx-editor-help-modal";
|
|
1300
|
+
backdrop.style.cssText = "position:fixed;inset:0;background:rgba(0,0,0,0.5);z-index:10001;display:flex;align-items:center;justify-content:center;padding:24px;";
|
|
1301
|
+
const panel = document.createElement("div");
|
|
1302
|
+
panel.style.cssText = "background:var(--color-bg, #fff);color:var(--color-text, #000);border-radius:8px;max-width:780px;max-height:88vh;width:100%;display:flex;flex-direction:column;box-shadow:0 8px 32px rgba(0,0,0,0.4);";
|
|
1303
|
+
const header = document.createElement("div");
|
|
1304
|
+
header.style.cssText = "display:flex;justify-content:space-between;align-items:center;padding:12px 18px;border-bottom:1px solid var(--color-border, #ddd);";
|
|
1305
|
+
header.innerHTML = `<span style="font-weight:600;">Editor help</span><button id="mailx-editor-help-close" style="background:none;border:0;font-size:18px;cursor:pointer;color:var(--color-text);padding:2px 8px;" aria-label="Close">×</button>`;
|
|
1306
|
+
const body = document.createElement("div");
|
|
1307
|
+
body.style.cssText = "flex:1;overflow:auto;padding:18px 22px;font:14px/1.55 system-ui;";
|
|
1308
|
+
body.innerHTML = renderMarkdownLite(md);
|
|
1309
|
+
panel.appendChild(header);
|
|
1310
|
+
panel.appendChild(body);
|
|
1311
|
+
backdrop.appendChild(panel);
|
|
1312
|
+
document.body.appendChild(backdrop);
|
|
1313
|
+
const close = () => {
|
|
1314
|
+
backdrop.remove();
|
|
1315
|
+
document.removeEventListener("keydown", onKey, true);
|
|
1316
|
+
};
|
|
1317
|
+
const onKey = (e) => {
|
|
1318
|
+
if (e.key === "Escape") {
|
|
1319
|
+
e.stopPropagation();
|
|
1320
|
+
e.preventDefault();
|
|
1321
|
+
close();
|
|
1322
|
+
}
|
|
1323
|
+
};
|
|
1324
|
+
document.addEventListener("keydown", onKey, true);
|
|
1325
|
+
header.querySelector("#mailx-editor-help-close")?.addEventListener("click", close);
|
|
1326
|
+
backdrop.addEventListener("mousedown", (e) => { if (e.target === backdrop)
|
|
1327
|
+
close(); });
|
|
1328
|
+
}
|
|
1329
|
+
/** Compact markdown renderer. Handles headings, paragraphs, code blocks,
|
|
1330
|
+
* inline code, bold, italic, links, lists, and pipe-tables. Built for the
|
|
1331
|
+
* editor help doc shape — not a general-purpose markdown engine. */
|
|
1332
|
+
function renderMarkdownLite(md) {
|
|
1333
|
+
const esc = (s) => s.replace(/[&<>"']/g, c => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]);
|
|
1334
|
+
const inline = (s) => esc(s)
|
|
1335
|
+
.replace(/`([^`]+)`/g, '<code style="background:var(--color-bg-surface,#f3f3f3);padding:1px 4px;border-radius:3px;">$1</code>')
|
|
1336
|
+
.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>")
|
|
1337
|
+
.replace(/(?<![*\w])\*([^*\n]+)\*(?!\w)/g, "<em>$1</em>")
|
|
1338
|
+
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>');
|
|
1339
|
+
const lines = md.split(/\r?\n/);
|
|
1340
|
+
const out = [];
|
|
1341
|
+
let i = 0;
|
|
1342
|
+
while (i < lines.length) {
|
|
1343
|
+
const line = lines[i];
|
|
1344
|
+
const h = /^(#{1,6})\s+(.*)$/.exec(line);
|
|
1345
|
+
if (h) {
|
|
1346
|
+
out.push(`<h${h[1].length} style="margin:18px 0 8px 0;">${inline(h[2])}</h${h[1].length}>`);
|
|
1347
|
+
i++;
|
|
1348
|
+
continue;
|
|
1349
|
+
}
|
|
1350
|
+
if (/^\s*$/.test(line)) {
|
|
1351
|
+
i++;
|
|
1352
|
+
continue;
|
|
1353
|
+
}
|
|
1354
|
+
// Pipe-table
|
|
1355
|
+
if (line.includes("|") && /^\s*\|/.test(line)) {
|
|
1356
|
+
const rows = [];
|
|
1357
|
+
while (i < lines.length && /^\s*\|/.test(lines[i])) {
|
|
1358
|
+
rows.push(lines[i].trim().split("|").slice(1, -1).map(s => s.trim()));
|
|
1359
|
+
i++;
|
|
1360
|
+
}
|
|
1361
|
+
if (rows.length >= 2 && /^[\s\-:]+$/.test(rows[1].join(""))) {
|
|
1362
|
+
const head = rows[0];
|
|
1363
|
+
const data = rows.slice(2);
|
|
1364
|
+
out.push(`<table style="border-collapse:collapse;margin:8px 0;">`);
|
|
1365
|
+
out.push(`<thead><tr>${head.map(h => `<th style="border-bottom:2px solid var(--color-border,#ccc);padding:4px 10px;text-align:left;">${inline(h)}</th>`).join("")}</tr></thead>`);
|
|
1366
|
+
out.push(`<tbody>${data.map(r => `<tr>${r.map(c => `<td style="border-bottom:1px solid var(--color-border,#eee);padding:4px 10px;">${inline(c)}</td>`).join("")}</tr>`).join("")}</tbody>`);
|
|
1367
|
+
out.push(`</table>`);
|
|
1368
|
+
continue;
|
|
1369
|
+
}
|
|
1370
|
+
}
|
|
1371
|
+
// Bullet / numbered list
|
|
1372
|
+
if (/^\s*[-*]\s+/.test(line)) {
|
|
1373
|
+
const items = [];
|
|
1374
|
+
while (i < lines.length && /^\s*[-*]\s+/.test(lines[i])) {
|
|
1375
|
+
items.push(lines[i].replace(/^\s*[-*]\s+/, ""));
|
|
1376
|
+
i++;
|
|
1377
|
+
}
|
|
1378
|
+
out.push(`<ul style="margin:6px 0 6px 22px;">${items.map(it => `<li style="margin:3px 0;">${inline(it)}</li>`).join("")}</ul>`);
|
|
1379
|
+
continue;
|
|
1380
|
+
}
|
|
1381
|
+
// Default: paragraph
|
|
1382
|
+
out.push(`<p style="margin:6px 0;">${inline(line)}</p>`);
|
|
1383
|
+
i++;
|
|
1384
|
+
}
|
|
1385
|
+
return out.join("\n");
|
|
1386
|
+
}
|
|
1387
|
+
/** Modal hint shown when Edit-in-Word launches. The user is about to lose
|
|
1388
|
+
* focus to Word and may not know what to do next, so spell it out: save in
|
|
1389
|
+
* Word → switch back here → click Send. Dismissed by Esc, the close button,
|
|
1390
|
+
* or any click outside the panel. */
|
|
1391
|
+
function showExternalEditHint(editorLabel) {
|
|
1392
|
+
if (document.getElementById("mailx-extedit-hint"))
|
|
1393
|
+
return; // don't stack
|
|
1394
|
+
const backdrop = document.createElement("div");
|
|
1395
|
+
backdrop.id = "mailx-extedit-hint";
|
|
1396
|
+
backdrop.style.cssText = "position:fixed;inset:0;background:rgba(0,0,0,0.45);z-index:10001;display:flex;align-items:center;justify-content:center;";
|
|
1397
|
+
const panel = document.createElement("div");
|
|
1398
|
+
panel.style.cssText = "background:var(--color-bg, #fff);color:var(--color-text, #000);border:1px solid var(--color-border, #ccc);border-radius:8px;padding:18px 22px;max-width:480px;box-shadow:0 8px 32px rgba(0,0,0,0.4);font:14px/1.5 system-ui;";
|
|
1399
|
+
panel.innerHTML = `
|
|
1400
|
+
<div style="font-weight:600;font-size:16px;margin-bottom:10px;">Editing in ${escapeHtml(editorLabel)}</div>
|
|
1401
|
+
<ol style="margin:0 0 12px 18px;padding:0;">
|
|
1402
|
+
<li>Edit your message in <b>${escapeHtml(editorLabel)}</b>.</li>
|
|
1403
|
+
<li>Save (<b>Ctrl+S</b>). Today, choose <b>"Web Page, Filtered (.htm)"</b> if Word asks for a format — keep the same filename.</li>
|
|
1404
|
+
<li>Switch back to this window (<b>Alt+Tab</b>). The body will reload here.</li>
|
|
1405
|
+
<li>Click <b>Send</b> in this window when ready.</li>
|
|
1406
|
+
</ol>
|
|
1407
|
+
<div style="text-align:right;margin-top:8px;">
|
|
1408
|
+
<button type="button" id="mailx-extedit-hint-ok" style="padding:6px 16px;border:1px solid var(--color-border, #ccc);background:var(--color-bg-surface, #f6f6f6);border-radius:4px;cursor:pointer;font:inherit;">Got it</button>
|
|
1409
|
+
</div>
|
|
1410
|
+
`;
|
|
1411
|
+
backdrop.appendChild(panel);
|
|
1412
|
+
document.body.appendChild(backdrop);
|
|
1413
|
+
const close = () => {
|
|
1414
|
+
backdrop.remove();
|
|
1415
|
+
document.removeEventListener("keydown", onKey, true);
|
|
1416
|
+
};
|
|
1417
|
+
const onKey = (e) => {
|
|
1418
|
+
if (e.key === "Escape") {
|
|
1419
|
+
e.stopPropagation();
|
|
1420
|
+
e.preventDefault();
|
|
1421
|
+
close();
|
|
1422
|
+
}
|
|
1423
|
+
};
|
|
1424
|
+
document.addEventListener("keydown", onKey, true);
|
|
1425
|
+
panel.querySelector("#mailx-extedit-hint-ok")?.addEventListener("click", close);
|
|
1426
|
+
// Click outside the panel (i.e. on the backdrop) dismisses.
|
|
1427
|
+
backdrop.addEventListener("mousedown", (e) => { if (e.target === backdrop)
|
|
1428
|
+
close(); });
|
|
1429
|
+
}
|
|
1178
1430
|
// Listen for external-editor saves. Only react to events for this compose's
|
|
1179
1431
|
// editId — multiple compose windows can be open and should not stomp each
|
|
1180
1432
|
// other's bodies.
|