@bobfrankston/mailx 1.0.50 → 1.0.57
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/client/app.js +103 -41
- package/client/compose/compose.css +66 -0
- package/client/compose/compose.html +1 -2
- package/client/compose/compose.js +59 -21
- package/client/compose/editor.js +160 -0
- package/client/index.html +8 -0
- package/client/styles/components.css +2 -0
- package/package.json +1 -1
- package/packages/mailx-api/index.d.ts +2 -1
- package/packages/mailx-api/index.js +56 -505
- package/packages/mailx-api/package.json +2 -3
- package/packages/mailx-imap/index.d.ts +4 -0
- package/packages/mailx-imap/index.js +164 -83
- package/packages/mailx-service/index.d.ts +58 -0
- package/packages/mailx-service/index.js +456 -0
- package/packages/mailx-service/package.json +22 -0
- package/packages/mailx-settings/index.d.ts +1 -0
- package/packages/mailx-settings/index.js +1 -0
- package/packages/mailx-types/index.d.ts +1 -0
package/client/app.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* Wires together all UI components and WebSocket connection.
|
|
4
4
|
*/
|
|
5
5
|
import { initFolderTree, refreshFolderTree } from "./components/folder-tree.js";
|
|
6
|
-
import { initMessageList, loadMessages, loadUnifiedInbox, loadSearchResults, reloadCurrentFolder } from "./components/message-list.js";
|
|
6
|
+
import { initMessageList, loadMessages, loadUnifiedInbox, loadSearchResults, reloadCurrentFolder, getSelectedMessages } from "./components/message-list.js";
|
|
7
7
|
import { showMessage, getCurrentMessage } from "./components/message-viewer.js";
|
|
8
8
|
import { connectWebSocket, onWsEvent, triggerSync, getAccounts, getFolders, deleteMessage, undeleteMessage, restartServer, rebuildServer, getSyncPending } from "./lib/api-client.js";
|
|
9
9
|
// ── New message badge (favicon + title) ──
|
|
@@ -290,46 +290,61 @@ function forwardBody(msg) {
|
|
|
290
290
|
}
|
|
291
291
|
let lastDeleted = null;
|
|
292
292
|
let undoTimeout = null;
|
|
293
|
-
async function
|
|
294
|
-
const
|
|
295
|
-
if
|
|
296
|
-
|
|
297
|
-
|
|
293
|
+
async function deleteSelectedMessages() {
|
|
294
|
+
const selected = getSelectedMessages();
|
|
295
|
+
// Fall back to single message from viewer if nothing selected in list
|
|
296
|
+
if (selected.length === 0) {
|
|
297
|
+
const current = getCurrentMessage();
|
|
298
|
+
if (!current)
|
|
299
|
+
return;
|
|
300
|
+
selected.push({ accountId: current.accountId, uid: current.message.uid, folderId: current.message.folderId });
|
|
301
|
+
}
|
|
302
|
+
const statusSync = document.getElementById("status-sync");
|
|
303
|
+
const mlBody = document.getElementById("ml-body");
|
|
298
304
|
try {
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
305
|
+
// Find the row after the last selected for re-selection
|
|
306
|
+
let nextRow = null;
|
|
307
|
+
if (mlBody) {
|
|
308
|
+
const lastRow = mlBody.querySelector(`.ml-row[data-uid="${selected[selected.length - 1].uid}"]`);
|
|
309
|
+
if (lastRow)
|
|
310
|
+
nextRow = (lastRow.nextElementSibling || lastRow.previousElementSibling);
|
|
311
|
+
}
|
|
312
|
+
// Delete all selected
|
|
313
|
+
for (const msg of selected) {
|
|
314
|
+
await deleteMessage(msg.accountId, msg.uid);
|
|
315
|
+
// Remove row from DOM
|
|
316
|
+
if (mlBody)
|
|
317
|
+
mlBody.querySelector(`.ml-row[data-uid="${msg.uid}"]`)?.remove();
|
|
318
|
+
}
|
|
319
|
+
// Undo supports the last batch
|
|
320
|
+
if (selected.length === 1) {
|
|
321
|
+
lastDeleted = { ...selected[0], subject: "" };
|
|
322
|
+
if (statusSync)
|
|
323
|
+
statusSync.textContent = `Deleted 1 message — Ctrl+Z to undo`;
|
|
324
|
+
}
|
|
325
|
+
else {
|
|
326
|
+
lastDeleted = null; // Multi-delete undo not supported yet
|
|
327
|
+
if (statusSync)
|
|
328
|
+
statusSync.textContent = `Deleted ${selected.length} messages`;
|
|
329
|
+
}
|
|
306
330
|
if (undoTimeout)
|
|
307
331
|
clearTimeout(undoTimeout);
|
|
308
332
|
undoTimeout = setTimeout(() => {
|
|
309
333
|
lastDeleted = null;
|
|
310
|
-
if (statusSync?.textContent?.includes("
|
|
334
|
+
if (statusSync?.textContent?.includes("undo"))
|
|
311
335
|
statusSync.textContent = "";
|
|
312
336
|
}, 30000);
|
|
313
|
-
//
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
// No more messages — clear preview
|
|
325
|
-
const bodyEl = document.getElementById("mv-body");
|
|
326
|
-
const headerEl = document.getElementById("mv-header");
|
|
327
|
-
if (bodyEl)
|
|
328
|
-
bodyEl.innerHTML = `<div class="mv-empty">Select a message to read</div>`;
|
|
329
|
-
if (headerEl)
|
|
330
|
-
headerEl.hidden = true;
|
|
331
|
-
}
|
|
332
|
-
}
|
|
337
|
+
// Select next row or clear viewer
|
|
338
|
+
if (nextRow?.classList.contains("ml-row")) {
|
|
339
|
+
nextRow.click();
|
|
340
|
+
}
|
|
341
|
+
else {
|
|
342
|
+
const bodyEl = document.getElementById("mv-body");
|
|
343
|
+
const headerEl = document.getElementById("mv-header");
|
|
344
|
+
if (bodyEl)
|
|
345
|
+
bodyEl.innerHTML = `<div class="mv-empty">Select a message to read</div>`;
|
|
346
|
+
if (headerEl)
|
|
347
|
+
headerEl.hidden = true;
|
|
333
348
|
}
|
|
334
349
|
}
|
|
335
350
|
catch (e) {
|
|
@@ -354,7 +369,7 @@ async function undoDelete() {
|
|
|
354
369
|
console.error(`Undo failed: ${e.message}`);
|
|
355
370
|
}
|
|
356
371
|
}
|
|
357
|
-
document.getElementById("btn-delete")?.addEventListener("click",
|
|
372
|
+
document.getElementById("btn-delete")?.addEventListener("click", deleteSelectedMessages);
|
|
358
373
|
document.getElementById("btn-compose")?.addEventListener("click", () => openCompose("new"));
|
|
359
374
|
document.getElementById("btn-reply")?.addEventListener("click", () => openCompose("reply"));
|
|
360
375
|
document.getElementById("btn-reply-all")?.addEventListener("click", () => openCompose("replyAll"));
|
|
@@ -519,16 +534,15 @@ onWsEvent((event) => {
|
|
|
519
534
|
break;
|
|
520
535
|
case "syncProgress":
|
|
521
536
|
if (statusSync)
|
|
522
|
-
statusSync.textContent = `
|
|
537
|
+
statusSync.textContent = `Syncing ${event.accountId}: ${event.phase} ${event.progress}%`;
|
|
523
538
|
if (startupStatus)
|
|
524
539
|
startupStatus.textContent = `Syncing ${event.accountId}: ${event.phase}`;
|
|
525
540
|
break;
|
|
526
541
|
case "folderCountsChanged": {
|
|
527
542
|
refreshFolderTree();
|
|
528
543
|
updateNewMessageCount();
|
|
529
|
-
//
|
|
530
|
-
|
|
531
|
-
reloadCurrentFolder();
|
|
544
|
+
// Reload message list but keep current scroll position and selection
|
|
545
|
+
reloadCurrentFolder();
|
|
532
546
|
// Sync finished — re-enable sync button
|
|
533
547
|
const syncBtn = document.getElementById("btn-sync");
|
|
534
548
|
if (syncBtn) {
|
|
@@ -566,10 +580,18 @@ document.addEventListener("keydown", (e) => {
|
|
|
566
580
|
e.preventDefault();
|
|
567
581
|
openCompose("replyAll");
|
|
568
582
|
}
|
|
569
|
-
// Ctrl+
|
|
583
|
+
// Ctrl+A = Select all visible messages
|
|
584
|
+
if (e.ctrlKey && e.key === "a") {
|
|
585
|
+
const mlBody = document.getElementById("ml-body");
|
|
586
|
+
if (mlBody && document.activeElement?.closest(".message-list, .ml-body, body")) {
|
|
587
|
+
e.preventDefault();
|
|
588
|
+
mlBody.querySelectorAll(".ml-row").forEach(r => r.classList.add("selected"));
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
// Ctrl+D or Delete = Delete selected messages
|
|
570
592
|
if ((e.ctrlKey && e.key === "d") || e.key === "Delete") {
|
|
571
593
|
e.preventDefault();
|
|
572
|
-
|
|
594
|
+
deleteSelectedMessages();
|
|
573
595
|
}
|
|
574
596
|
// Ctrl+Z = Undo delete
|
|
575
597
|
if (e.ctrlKey && e.key === "z") {
|
|
@@ -600,6 +622,8 @@ viewBtn?.addEventListener("click", (e) => {
|
|
|
600
622
|
document.addEventListener("click", () => {
|
|
601
623
|
if (viewDropdown)
|
|
602
624
|
viewDropdown.hidden = true;
|
|
625
|
+
if (settingsDropdown)
|
|
626
|
+
settingsDropdown.hidden = true;
|
|
603
627
|
});
|
|
604
628
|
// Restore saved view settings
|
|
605
629
|
const savedTwoLine = localStorage.getItem("mailx-two-line") === "true";
|
|
@@ -666,6 +690,44 @@ optFlagged?.addEventListener("change", () => {
|
|
|
666
690
|
}
|
|
667
691
|
localStorage.setItem("mailx-flagged", String(optFlagged.checked));
|
|
668
692
|
});
|
|
693
|
+
// ── Settings menu ──
|
|
694
|
+
const settingsBtn = document.getElementById("btn-settings");
|
|
695
|
+
const settingsDropdown = document.getElementById("settings-dropdown");
|
|
696
|
+
const optEditorQuill = document.getElementById("opt-editor-quill");
|
|
697
|
+
const optEditorTiptap = document.getElementById("opt-editor-tiptap");
|
|
698
|
+
settingsBtn?.addEventListener("click", (e) => {
|
|
699
|
+
e.stopPropagation();
|
|
700
|
+
if (settingsDropdown)
|
|
701
|
+
settingsDropdown.hidden = !settingsDropdown.hidden;
|
|
702
|
+
});
|
|
703
|
+
// Close handled by the shared document click handler above
|
|
704
|
+
// Load current editor setting from server
|
|
705
|
+
fetch("/api/settings").then(r => r.json()).then(s => {
|
|
706
|
+
const ed = s.ui?.editor || "quill";
|
|
707
|
+
if (optEditorQuill)
|
|
708
|
+
optEditorQuill.checked = ed === "quill";
|
|
709
|
+
if (optEditorTiptap)
|
|
710
|
+
optEditorTiptap.checked = ed === "tiptap";
|
|
711
|
+
}).catch(() => { });
|
|
712
|
+
// Save editor choice to server settings
|
|
713
|
+
function saveEditorSetting(editor) {
|
|
714
|
+
fetch("/api/settings").then(r => r.json()).then(settings => {
|
|
715
|
+
settings.ui = { ...settings.ui, editor };
|
|
716
|
+
fetch("/api/settings", {
|
|
717
|
+
method: "PUT",
|
|
718
|
+
headers: { "Content-Type": "application/json" },
|
|
719
|
+
body: JSON.stringify(settings),
|
|
720
|
+
});
|
|
721
|
+
}).catch(() => { });
|
|
722
|
+
}
|
|
723
|
+
optEditorQuill?.addEventListener("change", () => {
|
|
724
|
+
if (optEditorQuill.checked)
|
|
725
|
+
saveEditorSetting("quill");
|
|
726
|
+
});
|
|
727
|
+
optEditorTiptap?.addEventListener("change", () => {
|
|
728
|
+
if (optEditorTiptap.checked)
|
|
729
|
+
saveEditorSetting("tiptap");
|
|
730
|
+
});
|
|
669
731
|
const isApp = typeof mailxapi !== "undefined" && mailxapi?.isApp;
|
|
670
732
|
fetch("/api/version").then(r => r.json()).then(d => {
|
|
671
733
|
const el = document.getElementById("app-version");
|
|
@@ -188,6 +188,72 @@ body {
|
|
|
188
188
|
font-size: 0.8em;
|
|
189
189
|
}
|
|
190
190
|
|
|
191
|
+
/* ── tiptap editor ── */
|
|
192
|
+
.editor-tiptap {
|
|
193
|
+
display: flex;
|
|
194
|
+
flex-direction: column;
|
|
195
|
+
overflow: hidden;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
.tt-toolbar {
|
|
199
|
+
display: flex;
|
|
200
|
+
align-items: center;
|
|
201
|
+
gap: 2px;
|
|
202
|
+
padding: var(--gap-xs) var(--gap-sm);
|
|
203
|
+
border-bottom: 1px solid var(--color-border);
|
|
204
|
+
background: var(--color-bg-toolbar);
|
|
205
|
+
flex-wrap: wrap;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
.tt-btn {
|
|
209
|
+
border: none;
|
|
210
|
+
background: transparent;
|
|
211
|
+
color: var(--color-text-muted);
|
|
212
|
+
padding: 4px 8px;
|
|
213
|
+
border-radius: var(--radius-sm);
|
|
214
|
+
cursor: pointer;
|
|
215
|
+
font-size: var(--font-size-sm);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
.tt-btn:hover { background: var(--color-bg-hover); color: var(--color-text); }
|
|
219
|
+
|
|
220
|
+
.tt-heading {
|
|
221
|
+
border: 1px solid var(--color-border);
|
|
222
|
+
background: var(--color-bg);
|
|
223
|
+
color: var(--color-text);
|
|
224
|
+
padding: 2px 4px;
|
|
225
|
+
border-radius: var(--radius-sm);
|
|
226
|
+
font-size: var(--font-size-sm);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
.tt-content {
|
|
230
|
+
flex: 1;
|
|
231
|
+
overflow-y: auto;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
.tt-content .tiptap {
|
|
235
|
+
padding: var(--gap-md);
|
|
236
|
+
min-height: 100%;
|
|
237
|
+
outline: none;
|
|
238
|
+
color: var(--color-text);
|
|
239
|
+
font-family: var(--font-ui);
|
|
240
|
+
font-size: var(--font-size-base);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
.tt-content .tiptap p.is-editor-empty:first-child::before {
|
|
244
|
+
content: attr(data-placeholder);
|
|
245
|
+
color: var(--color-text-muted);
|
|
246
|
+
pointer-events: none;
|
|
247
|
+
float: left;
|
|
248
|
+
height: 0;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
.tt-content .tiptap blockquote {
|
|
252
|
+
border-left: 3px solid var(--color-border);
|
|
253
|
+
padding-left: var(--gap-md);
|
|
254
|
+
color: var(--color-text-muted);
|
|
255
|
+
}
|
|
256
|
+
|
|
191
257
|
/* Quoted original message in reply/forward */
|
|
192
258
|
.reply {
|
|
193
259
|
border-left: 3px solid var(--color-border);
|
|
@@ -6,9 +6,8 @@
|
|
|
6
6
|
<title>Compose - mailx</title>
|
|
7
7
|
<link rel="icon" type="image/svg+xml" href="../favicon.svg">
|
|
8
8
|
<link rel="stylesheet" href="../styles/variables.css">
|
|
9
|
-
<link href="https://cdn.jsdelivr.net/npm/quill@2/dist/quill.snow.css" rel="stylesheet">
|
|
10
9
|
<link rel="stylesheet" href="compose.css">
|
|
11
|
-
|
|
10
|
+
<!-- Editor CSS/JS loaded dynamically by compose.ts based on setting -->
|
|
12
11
|
<script type="module" src="compose.js"></script>
|
|
13
12
|
</head>
|
|
14
13
|
<body>
|
|
@@ -3,22 +3,61 @@
|
|
|
3
3
|
* Opened as a popup from the main mailx window.
|
|
4
4
|
* Receives init data via window.opener.postMessage or URL params.
|
|
5
5
|
*/
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
6
|
+
import { createEditor } from "./editor.js";
|
|
7
|
+
// ── Load editor scripts dynamically ──
|
|
8
|
+
function loadScript(src) {
|
|
9
|
+
return new Promise((resolve, reject) => {
|
|
10
|
+
const s = document.createElement("script");
|
|
11
|
+
s.src = src;
|
|
12
|
+
s.onload = () => resolve();
|
|
13
|
+
s.onerror = () => reject(new Error(`Failed to load ${src}`));
|
|
14
|
+
document.head.appendChild(s);
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
function loadCSS(href) {
|
|
18
|
+
const link = document.createElement("link");
|
|
19
|
+
link.rel = "stylesheet";
|
|
20
|
+
link.href = href;
|
|
21
|
+
document.head.appendChild(link);
|
|
22
|
+
}
|
|
23
|
+
async function loadEditorAssets(type) {
|
|
24
|
+
if (type === "tiptap") {
|
|
25
|
+
// tiptap UMD bundles from CDN
|
|
26
|
+
const cdn = "https://cdn.jsdelivr.net/npm";
|
|
27
|
+
await loadScript(`${cdn}/@tiptap/core@2/dist/index.umd.js`);
|
|
28
|
+
await Promise.all([
|
|
29
|
+
loadScript(`${cdn}/@tiptap/starter-kit@2/dist/index.umd.js`),
|
|
30
|
+
loadScript(`${cdn}/@tiptap/extension-link@2/dist/index.umd.js`),
|
|
31
|
+
loadScript(`${cdn}/@tiptap/extension-image@2/dist/index.umd.js`),
|
|
32
|
+
loadScript(`${cdn}/@tiptap/extension-underline@2/dist/index.umd.js`),
|
|
33
|
+
loadScript(`${cdn}/@tiptap/extension-placeholder@2/dist/index.umd.js`),
|
|
34
|
+
]);
|
|
18
35
|
}
|
|
19
|
-
|
|
20
|
-
//
|
|
21
|
-
|
|
36
|
+
else {
|
|
37
|
+
// Quill
|
|
38
|
+
loadCSS("https://cdn.jsdelivr.net/npm/quill@2/dist/quill.snow.css");
|
|
39
|
+
await loadScript("https://cdn.jsdelivr.net/npm/quill@2/dist/quill.js");
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
// ── Determine editor type from settings ──
|
|
43
|
+
let editorType = "quill";
|
|
44
|
+
try {
|
|
45
|
+
const res = await fetch("/api/version");
|
|
46
|
+
if (res.ok) {
|
|
47
|
+
// Check settings for editor preference
|
|
48
|
+
const settingsRes = await fetch("/api/settings");
|
|
49
|
+
if (settingsRes.ok) {
|
|
50
|
+
const settings = await settingsRes.json();
|
|
51
|
+
if (settings.ui?.editor === "tiptap")
|
|
52
|
+
editorType = "tiptap";
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
catch { /* default to quill */ }
|
|
57
|
+
await loadEditorAssets(editorType);
|
|
58
|
+
const container = document.getElementById("compose-editor");
|
|
59
|
+
container.classList.add(editorType === "tiptap" ? "editor-tiptap" : "editor-quill");
|
|
60
|
+
const editor = await createEditor(container, editorType);
|
|
22
61
|
// ── Populate from init data ──
|
|
23
62
|
const fromSelect = document.getElementById("compose-from-select");
|
|
24
63
|
const fromCustom = document.getElementById("compose-from-custom");
|
|
@@ -214,8 +253,8 @@ function applyInit(init) {
|
|
|
214
253
|
ccInput.value = formatAddrs(init.cc);
|
|
215
254
|
subjectInput.value = init.subject;
|
|
216
255
|
if (init.bodyHtml) {
|
|
217
|
-
editor.
|
|
218
|
-
editor.
|
|
256
|
+
editor.setHtml(init.bodyHtml);
|
|
257
|
+
editor.setCursor(0);
|
|
219
258
|
}
|
|
220
259
|
// If resuming a draft, track its UID for deletion after send
|
|
221
260
|
if (init.draftUid) {
|
|
@@ -254,7 +293,7 @@ let draftUid = null;
|
|
|
254
293
|
let draftTimer;
|
|
255
294
|
let lastDraftContent = "";
|
|
256
295
|
async function saveDraft() {
|
|
257
|
-
const content = editor.
|
|
296
|
+
const content = editor.getHtml() + subjectInput.value + toInput.value;
|
|
258
297
|
if (content === lastDraftContent)
|
|
259
298
|
return; // no changes
|
|
260
299
|
if (!editor.getText().trim() && !subjectInput.value && !toInput.value)
|
|
@@ -267,7 +306,7 @@ async function saveDraft() {
|
|
|
267
306
|
body: JSON.stringify({
|
|
268
307
|
accountId: getFromAccountId(),
|
|
269
308
|
subject: subjectInput.value,
|
|
270
|
-
bodyHtml: editor.
|
|
309
|
+
bodyHtml: editor.getHtml(),
|
|
271
310
|
bodyText: editor.getText(),
|
|
272
311
|
to: toInput.value,
|
|
273
312
|
cc: ccInput.value,
|
|
@@ -292,7 +331,7 @@ document.getElementById("btn-send")?.addEventListener("click", async () => {
|
|
|
292
331
|
cc: parseAddrs(ccInput.value),
|
|
293
332
|
bcc: parseAddrs(bccInput.value),
|
|
294
333
|
subject: subjectInput.value,
|
|
295
|
-
bodyHtml: editor.
|
|
334
|
+
bodyHtml: editor.getHtml(),
|
|
296
335
|
bodyText: editor.getText(),
|
|
297
336
|
};
|
|
298
337
|
try {
|
|
@@ -349,5 +388,4 @@ document.addEventListener("keydown", (e) => {
|
|
|
349
388
|
target.dispatchEvent(new Event("input"));
|
|
350
389
|
}
|
|
351
390
|
});
|
|
352
|
-
export {};
|
|
353
391
|
//# sourceMappingURL=compose.js.map
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Editor abstraction — wraps Quill or tiptap behind a common interface.
|
|
3
|
+
* The compose window loads this module and calls createEditor() based on the user's setting.
|
|
4
|
+
*/
|
|
5
|
+
function createQuillEditor(container) {
|
|
6
|
+
const q = new Quill(container, {
|
|
7
|
+
theme: "snow",
|
|
8
|
+
placeholder: "Write your message...",
|
|
9
|
+
modules: {
|
|
10
|
+
toolbar: [
|
|
11
|
+
[{ header: [1, 2, 3, false] }],
|
|
12
|
+
["bold", "italic", "underline", "strike"],
|
|
13
|
+
[{ list: "ordered" }, { list: "bullet" }],
|
|
14
|
+
["blockquote", "link", "image"],
|
|
15
|
+
["clean"]
|
|
16
|
+
]
|
|
17
|
+
}
|
|
18
|
+
});
|
|
19
|
+
// Make toolbar buttons non-tabbable so Tab goes straight to editor body
|
|
20
|
+
document.querySelectorAll(".ql-toolbar button, .ql-toolbar select, .ql-toolbar .ql-picker-label").forEach(el => el.setAttribute("tabindex", "-1"));
|
|
21
|
+
return {
|
|
22
|
+
setHtml(html) {
|
|
23
|
+
q.clipboard.dangerouslyPasteHTML(html);
|
|
24
|
+
},
|
|
25
|
+
getHtml() {
|
|
26
|
+
return q.root.innerHTML;
|
|
27
|
+
},
|
|
28
|
+
getText() {
|
|
29
|
+
return q.getText();
|
|
30
|
+
},
|
|
31
|
+
focus() {
|
|
32
|
+
q.focus();
|
|
33
|
+
},
|
|
34
|
+
setCursor(pos) {
|
|
35
|
+
q.setSelection(pos, 0);
|
|
36
|
+
},
|
|
37
|
+
root: q.root
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
// ── tiptap ──
|
|
41
|
+
async function createTiptapEditor(container) {
|
|
42
|
+
// tiptap loaded via CDN — use global UMD bundles
|
|
43
|
+
const { Editor } = window.tiptapCore;
|
|
44
|
+
const { StarterKit } = window.tiptapStarterKit;
|
|
45
|
+
const { Link } = window.tiptapExtensionLink;
|
|
46
|
+
const { Image } = window.tiptapExtensionImage;
|
|
47
|
+
const { Underline } = window.tiptapExtensionUnderline;
|
|
48
|
+
const { Placeholder } = window.tiptapExtensionPlaceholder;
|
|
49
|
+
// Build toolbar
|
|
50
|
+
const toolbar = document.createElement("div");
|
|
51
|
+
toolbar.className = "tt-toolbar";
|
|
52
|
+
toolbar.innerHTML = `
|
|
53
|
+
<select class="tt-heading" tabindex="-1">
|
|
54
|
+
<option value="p">Normal</option>
|
|
55
|
+
<option value="1">Heading 1</option>
|
|
56
|
+
<option value="2">Heading 2</option>
|
|
57
|
+
<option value="3">Heading 3</option>
|
|
58
|
+
</select>
|
|
59
|
+
<button class="tt-btn" data-cmd="bold" title="Bold" tabindex="-1"><b>B</b></button>
|
|
60
|
+
<button class="tt-btn" data-cmd="italic" title="Italic" tabindex="-1"><i>I</i></button>
|
|
61
|
+
<button class="tt-btn" data-cmd="underline" title="Underline" tabindex="-1"><u>U</u></button>
|
|
62
|
+
<button class="tt-btn" data-cmd="strike" title="Strikethrough" tabindex="-1"><s>S</s></button>
|
|
63
|
+
<button class="tt-btn" data-cmd="bulletList" title="Bullet list" tabindex="-1">•</button>
|
|
64
|
+
<button class="tt-btn" data-cmd="orderedList" title="Ordered list" tabindex="-1">1.</button>
|
|
65
|
+
<button class="tt-btn" data-cmd="blockquote" title="Blockquote" tabindex="-1">“</button>
|
|
66
|
+
<button class="tt-btn" data-cmd="link" title="Link" tabindex="-1">🔗</button>
|
|
67
|
+
<button class="tt-btn" data-cmd="clearFormat" title="Clear formatting" tabindex="-1">⌧</button>
|
|
68
|
+
`;
|
|
69
|
+
// Content area
|
|
70
|
+
const content = document.createElement("div");
|
|
71
|
+
content.className = "tt-content";
|
|
72
|
+
container.appendChild(toolbar);
|
|
73
|
+
container.appendChild(content);
|
|
74
|
+
const ed = new Editor({
|
|
75
|
+
element: content,
|
|
76
|
+
extensions: [
|
|
77
|
+
StarterKit,
|
|
78
|
+
Link.configure({ openOnClick: false }),
|
|
79
|
+
Image,
|
|
80
|
+
Underline,
|
|
81
|
+
Placeholder.configure({ placeholder: "Write your message..." }),
|
|
82
|
+
],
|
|
83
|
+
content: "",
|
|
84
|
+
});
|
|
85
|
+
// Wire toolbar buttons
|
|
86
|
+
toolbar.querySelectorAll(".tt-btn").forEach((btn) => {
|
|
87
|
+
btn.addEventListener("mousedown", (e) => {
|
|
88
|
+
e.preventDefault();
|
|
89
|
+
const cmd = btn.dataset.cmd;
|
|
90
|
+
switch (cmd) {
|
|
91
|
+
case "bold":
|
|
92
|
+
ed.chain().focus().toggleBold().run();
|
|
93
|
+
break;
|
|
94
|
+
case "italic":
|
|
95
|
+
ed.chain().focus().toggleItalic().run();
|
|
96
|
+
break;
|
|
97
|
+
case "underline":
|
|
98
|
+
ed.chain().focus().toggleUnderline().run();
|
|
99
|
+
break;
|
|
100
|
+
case "strike":
|
|
101
|
+
ed.chain().focus().toggleStrike().run();
|
|
102
|
+
break;
|
|
103
|
+
case "bulletList":
|
|
104
|
+
ed.chain().focus().toggleBulletList().run();
|
|
105
|
+
break;
|
|
106
|
+
case "orderedList":
|
|
107
|
+
ed.chain().focus().toggleOrderedList().run();
|
|
108
|
+
break;
|
|
109
|
+
case "blockquote":
|
|
110
|
+
ed.chain().focus().toggleBlockquote().run();
|
|
111
|
+
break;
|
|
112
|
+
case "link": {
|
|
113
|
+
const url = prompt("URL:");
|
|
114
|
+
if (url)
|
|
115
|
+
ed.chain().focus().setLink({ href: url }).run();
|
|
116
|
+
break;
|
|
117
|
+
}
|
|
118
|
+
case "clearFormat":
|
|
119
|
+
ed.chain().focus().clearNodes().unsetAllMarks().run();
|
|
120
|
+
break;
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
// Wire heading select
|
|
125
|
+
const headingSelect = toolbar.querySelector(".tt-heading");
|
|
126
|
+
headingSelect?.addEventListener("change", () => {
|
|
127
|
+
const val = headingSelect.value;
|
|
128
|
+
if (val === "p")
|
|
129
|
+
ed.chain().focus().setParagraph().run();
|
|
130
|
+
else
|
|
131
|
+
ed.chain().focus().toggleHeading({ level: parseInt(val) }).run();
|
|
132
|
+
});
|
|
133
|
+
const editorEl = content.querySelector(".tiptap") || content;
|
|
134
|
+
return {
|
|
135
|
+
setHtml(html) {
|
|
136
|
+
ed.commands.setContent(html);
|
|
137
|
+
},
|
|
138
|
+
getHtml() {
|
|
139
|
+
return ed.getHTML();
|
|
140
|
+
},
|
|
141
|
+
getText() {
|
|
142
|
+
return ed.getText();
|
|
143
|
+
},
|
|
144
|
+
focus() {
|
|
145
|
+
ed.commands.focus("start");
|
|
146
|
+
},
|
|
147
|
+
setCursor(pos) {
|
|
148
|
+
ed.commands.focus("start");
|
|
149
|
+
},
|
|
150
|
+
root: editorEl
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
// ── Factory ──
|
|
154
|
+
export async function createEditor(container, type) {
|
|
155
|
+
if (type === "tiptap") {
|
|
156
|
+
return createTiptapEditor(container);
|
|
157
|
+
}
|
|
158
|
+
return createQuillEditor(container);
|
|
159
|
+
}
|
|
160
|
+
//# sourceMappingURL=editor.js.map
|
package/client/index.html
CHANGED
|
@@ -28,6 +28,14 @@
|
|
|
28
28
|
<label class="tb-menu-item"><input type="checkbox" id="opt-flagged"> ★ Flagged only</label>
|
|
29
29
|
</div>
|
|
30
30
|
</div>
|
|
31
|
+
<div class="tb-menu" id="settings-menu">
|
|
32
|
+
<button class="tb-btn" id="btn-settings">Settings</button>
|
|
33
|
+
<div class="tb-menu-dropdown" id="settings-dropdown" hidden>
|
|
34
|
+
<span class="tb-menu-label">Editor</span>
|
|
35
|
+
<label class="tb-menu-item"><input type="radio" name="opt-editor" value="quill" id="opt-editor-quill" checked> Quill</label>
|
|
36
|
+
<label class="tb-menu-item"><input type="radio" name="opt-editor" value="tiptap" id="opt-editor-tiptap"> tiptap</label>
|
|
37
|
+
</div>
|
|
38
|
+
</div>
|
|
31
39
|
<span id="app-version" class="app-version"></span>
|
|
32
40
|
</div>
|
|
33
41
|
<div class="toolbar-right">
|
|
@@ -107,6 +107,8 @@
|
|
|
107
107
|
button.tb-menu-item { background: none; border: none; color: inherit; width: 100%; text-align: left; }
|
|
108
108
|
.tb-menu-sep { border: none; border-top: 1px solid var(--color-border); margin: var(--gap-xs) 0; }
|
|
109
109
|
.tb-menu-hint { display: block; padding: var(--gap-xs) var(--gap-md); font-size: 0.75rem; color: var(--color-text-muted); }
|
|
110
|
+
.tb-menu-label { display: block; padding: var(--gap-xs) var(--gap-md); font-size: 0.75rem; font-weight: 600; color: var(--color-text-muted); text-transform: uppercase; letter-spacing: 0.05em; }
|
|
111
|
+
.tb-menu-item input[type="radio"] { accent-color: var(--color-accent); }
|
|
110
112
|
.tb-sep { width: 1px; height: 1.2rem; background: var(--color-border); margin: 0 var(--gap-xs); }
|
|
111
113
|
|
|
112
114
|
.search-bar {
|
package/package.json
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @bobfrankston/mailx-api
|
|
3
|
-
* Express Router
|
|
3
|
+
* Thin Express Router — delegates all logic to mailx-service.
|
|
4
4
|
*/
|
|
5
5
|
import { Router } from "express";
|
|
6
6
|
import { MailxDB } from "@bobfrankston/mailx-store";
|
|
7
7
|
import { ImapManager } from "@bobfrankston/mailx-imap";
|
|
8
8
|
export declare function createApiRouter(db: MailxDB, imapManager: ImapManager): Router;
|
|
9
|
+
export { MailxService } from "@bobfrankston/mailx-service";
|
|
9
10
|
//# sourceMappingURL=index.d.ts.map
|