@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 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 deleteCurrentMessage() {
294
- const current = getCurrentMessage();
295
- if (!current)
296
- return;
297
- const { accountId, message } = current;
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
- await deleteMessage(accountId, message.uid);
300
- lastDeleted = { accountId, uid: message.uid, folderId: message.folderId, subject: message.subject };
301
- // Show undo notification in status bar
302
- const statusSync = document.getElementById("status-sync");
303
- if (statusSync)
304
- statusSync.textContent = `Deleted "${message.subject.substring(0, 40)}" — Ctrl+Z to undo`;
305
- // Clear undo after 30 seconds
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("Ctrl+Z"))
334
+ if (statusSync?.textContent?.includes("undo"))
311
335
  statusSync.textContent = "";
312
336
  }, 30000);
313
- // Remove the deleted row and select the next message
314
- const mlBody = document.getElementById("ml-body");
315
- if (mlBody) {
316
- const deletedRow = mlBody.querySelector(`.ml-row[data-uid="${message.uid}"]`);
317
- if (deletedRow) {
318
- const nextRow = (deletedRow.nextElementSibling || deletedRow.previousElementSibling);
319
- deletedRow.remove();
320
- if (nextRow?.classList.contains("ml-row")) {
321
- nextRow.click();
322
- }
323
- else {
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", deleteCurrentMessage);
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 = `Sync: ${event.phase} ${event.progress}%`;
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
- // Only reload message list if user isn't reading a message
530
- if (!getCurrentMessage())
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+D or Delete = Delete message
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
- deleteCurrentMessage();
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
- <script src="https://cdn.jsdelivr.net/npm/quill@2/dist/quill.js"></script>
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
- // ── Quill Editor ──
7
- const editor = new Quill("#compose-editor", {
8
- theme: "snow",
9
- placeholder: "Write your message...",
10
- modules: {
11
- toolbar: [
12
- [{ header: [1, 2, 3, false] }],
13
- ["bold", "italic", "underline", "strike"],
14
- [{ list: "ordered" }, { list: "bullet" }],
15
- ["blockquote", "link", "image"],
16
- ["clean"]
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
- // Make toolbar buttons non-tabbable so Tab goes straight to editor body
21
- document.querySelectorAll(".ql-toolbar button, .ql-toolbar select, .ql-toolbar .ql-picker-label").forEach(el => el.setAttribute("tabindex", "-1"));
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.clipboard.dangerouslyPasteHTML(init.bodyHtml);
218
- editor.setSelection(0, 0);
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.root.innerHTML + subjectInput.value + toInput.value;
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.root.innerHTML,
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.root.innerHTML,
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">&#8226;</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">&ldquo;</button>
66
+ <button class="tt-btn" data-cmd="link" title="Link" tabindex="-1">&#128279;</button>
67
+ <button class="tt-btn" data-cmd="clearFormat" title="Clear formatting" tabindex="-1">&#8999;</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,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx",
3
- "version": "1.0.50",
3
+ "version": "1.0.57",
4
4
  "description": "Local-first email client with IMAP sync and standalone native app",
5
5
  "type": "module",
6
6
  "main": "bin/mailx.js",
@@ -1,9 +1,10 @@
1
1
  /**
2
2
  * @bobfrankston/mailx-api
3
- * Express Router with all REST endpoints for the mailx client.
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