From 1a1d69936c0361117461f92a165e729aed3fc0dd Mon Sep 17 00:00:00 2001 From: avi Date: Sat, 5 Sep 2026 12:21:16 -0500 Subject: [PATCH] fix(renderer): normalize editor line-height to 1.0; add chapter-view export dropdown - Set .folio-prose.ProseMirror line-height from 1.7 to 1.0 to fix excessive spacing between paragraphs and blockquotes - Add PDF/DOCX/ODT export dropdown to the chapter writing view header, matching the Full Book view's export workflow - Wire chapter export dropdown toggle, menu selection, and close-on-outside-click --- src/renderer/book/editor.ts | 48 +++++++++++++++++++++++++++--------- src/renderer/book/exports.ts | 31 +++++++++++++++++++++++ src/renderer/book/toolbar.ts | 6 +++++ src/renderer/book/types.ts | 2 +- src/renderer/index.css | 7 +++++- src/renderer/index.html | 8 ++++++ src/renderer/themes.css | 12 ++++----- 7 files changed, 95 insertions(+), 19 deletions(-) diff --git a/src/renderer/book/editor.ts b/src/renderer/book/editor.ts index bb230c0..07f5ef6 100644 --- a/src/renderer/book/editor.ts +++ b/src/renderer/book/editor.ts @@ -30,7 +30,7 @@ import { export interface EditorFeature { editor: Editor; selectChapter(id: string): Promise; - saveCurrentChapter(): Promise; + saveCurrentChapter(): Promise; saveAndNotify(): Promise; flushAutosave(): Promise; clearTimers(): void; @@ -65,18 +65,25 @@ export function createEditorFeature(ctx: BookCtx): EditorFeature { function clearTimers() { if (saveTimer) { clearTimeout(saveTimer); saveTimer = null; } + stopForcedAutosave(); } - async function saveCurrentChapter(): Promise { - if (!state.selectedId) return; + async function saveCurrentChapter(): Promise { + if (!state.selectedId) return false; clearTimers(); + saveStateEl.textContent = "Saving…"; + saveStateEl.classList.remove("unsaved", "failed"); const res = await api.saveChapterDoc(state.selectedId, getEditorDoc(editor)); - if (!(res && "error" in res)) { - state.loadedDocJson = JSON.stringify(getEditorDoc(editor)); - state.dirty = false; - saveStateEl.textContent = "Saved"; - saveStateEl.classList.remove("unsaved"); + if (res && "error" in res) { + saveStateEl.textContent = "Failed"; + saveStateEl.classList.add("failed"); + return false; } + state.loadedDocJson = JSON.stringify(getEditorDoc(editor)); + state.dirty = false; + saveStateEl.textContent = "Saved"; + saveStateEl.classList.remove("unsaved", "failed"); + return true; } // Explicit save (Ctrl/Cmd+S or menu) — always give visible feedback. @@ -89,8 +96,8 @@ export function createEditorFeature(ctx: BookCtx): EditorFeature { showToast(state.currentBook ? "Nothing to save" : "Nothing to save"); return; } - await saveCurrentChapter(); - showToast("Saved"); + const ok = await saveCurrentChapter(); + showToast(ok ? "Saved" : "Save failed — check disk space/permissions"); } // Guards against overlapping selections: rapid clicks (or metadata @@ -132,10 +139,11 @@ export function createEditorFeature(ctx: BookCtx): EditorFeature { state.loadedDocJson = JSON.stringify(getEditorDoc(editor)); state.dirty = false; saveStateEl.textContent = "Saved"; - saveStateEl.classList.remove("unsaved"); + saveStateEl.classList.remove("unsaved", "failed"); $("wordCount").textContent = formatWordCount(editor.getText()); editor.commands.focus("end"); clearTimers(); + startForcedAutosave(); $("fullBookBtn").classList.remove("active"); $("fullBookView").classList.add("hidden"); showView($("chapterView")); @@ -160,6 +168,20 @@ export function createEditorFeature(ctx: BookCtx): EditorFeature { await saveCurrentChapter(); } + // Periodic forced autosave (every 30s) as a safety net — saves even if user + // keeps typing continuously. Runs alongside the idle-based debounce. + const FORCED_AUTOSAVE_MS = 30_000; + let forcedSaveTimer: ReturnType | null = null; + function startForcedAutosave() { + if (forcedSaveTimer) return; + forcedSaveTimer = setInterval(() => { + if (state.selectedId && state.dirty) void flushAutosave(); + }, FORCED_AUTOSAVE_MS); + } + function stopForcedAutosave() { + if (forcedSaveTimer) { clearInterval(forcedSaveTimer); forcedSaveTimer = null; } + } + function handleEditorChange() { if (!state.selectedId) return; const json = JSON.stringify(getEditorDoc(editor)); @@ -204,6 +226,10 @@ export function createEditorFeature(ctx: BookCtx): EditorFeature { } }); + // Toolbar Save button click handler. + const saveBtn = $("saveBtn"); + saveBtn?.addEventListener("click", () => void saveAndNotify()); + // Flush any pending auto-save when the window is hidden or loses focus, so the // last stretch of typing is persisted even if the app closes within the // debounce window. diff --git a/src/renderer/book/exports.ts b/src/renderer/book/exports.ts index 7c2c13f..60e4c3f 100644 --- a/src/renderer/book/exports.ts +++ b/src/renderer/book/exports.ts @@ -119,6 +119,37 @@ export function createExportsFeature(ctx: BookCtx): ExportsFeature { $("exportBtn").addEventListener("click", () => void exportMarkdown()); $("exportZipBtn").addEventListener("click", () => void exportZip()); + // ---- chapter view export dropdown (PDF / DOCX / ODT) ---- + const chapterExportMenuToggle = $("chapterExportMenuToggle") as HTMLButtonElement; + const chapterExportMenu = $("chapterExportMenu"); + + function closeChapterExportMenu() { + closeEl(chapterExportMenu); + chapterExportMenuToggle.classList.remove("open"); + } + + chapterExportMenuToggle.addEventListener("click", (e) => { + e.stopPropagation(); + const wasOpen = !chapterExportMenu.classList.contains("hidden"); + closeChapterExportMenu(); + if (!wasOpen) { + openEl(chapterExportMenu); + chapterExportMenuToggle.classList.add("open"); + } + }); + + chapterExportMenu.addEventListener("click", (e) => { + const item = (e.target as HTMLElement).closest("[data-export]") as HTMLElement | null; + if (!item) return; + closeChapterExportMenu(); + void exportFromMenu(item.dataset.export ?? ""); + }); + + document.addEventListener("click", (e) => { + if (!(e.target as HTMLElement).closest("#chapterExportDropdown")) closeChapterExportMenu(); + }); + + // ---- full book export dropdown ---- const exportMenuToggle = $("exportMenuToggle") as HTMLButtonElement; const exportMenu = $("exportMenu"); diff --git a/src/renderer/book/toolbar.ts b/src/renderer/book/toolbar.ts index 00417cc..1e7544d 100644 --- a/src/renderer/book/toolbar.ts +++ b/src/renderer/book/toolbar.ts @@ -203,6 +203,12 @@ export function createToolbarFeature(ctx: BookCtx, editor: Editor): ToolbarFeatu window.addEventListener("resize", closeDropdowns); // Electron has no default right-click menu, so show one with Cut/Copy/Paste. window.addEventListener("contextmenu", (e) => { + // Let Chromium show its native spelling suggestions when the target is a + // misspelled word. The custom menu is used for the rest of the editor. + const target = e.target as HTMLElement | null; + if (target?.closest(".ProseMirror") && window.getSelection()?.isCollapsed) { + return; + } e.preventDefault(); api.showContextMenu(e.clientX, e.clientY); }); diff --git a/src/renderer/book/types.ts b/src/renderer/book/types.ts index e99daee..13ae945 100644 --- a/src/renderer/book/types.ts +++ b/src/renderer/book/types.ts @@ -112,7 +112,7 @@ export interface SharedState { export interface BookHooks { // editor feature selectChapter(id: string): Promise; - saveCurrentChapter(): Promise; + saveCurrentChapter(): Promise; saveAndNotify(): Promise; flushAutosave(): Promise; clearTimers(): void; diff --git a/src/renderer/index.css b/src/renderer/index.css index 890defe..c2087f4 100644 --- a/src/renderer/index.css +++ b/src/renderer/index.css @@ -902,6 +902,11 @@ body.dragging-active * { font-weight: 600; } +.save-state.failed { + color: #e11d48; + font-weight: 700; +} + .editor-panes { flex: 1; display: flex; @@ -1664,7 +1669,7 @@ body.focus-mode .editor { color: var(--color-text); background: var(--color-bg); padding: 24px 20px; - max-width: 820px; + width: 100%; margin: 0; outline: none; } diff --git a/src/renderer/index.html b/src/renderer/index.html index 4c854cf..c6b9e3f 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -75,6 +75,14 @@ +
+ + +
diff --git a/src/renderer/themes.css b/src/renderer/themes.css index 70d7bb9..502b378 100644 --- a/src/renderer/themes.css +++ b/src/renderer/themes.css @@ -567,15 +567,15 @@ html[data-theme="cosmic-light"] .folio-prose code { --color-border: #2a2210; --color-text: #fff5dc; --color-muted: #d5bd8d; - --color-accent: #efbd62; + --color-accent: #F3B407; --color-accent-hover: #ffdb91; --color-on-accent: #0d0a06; --color-on-accent-soft: rgba(13, 10, 6, 0.18); --color-on-accent-border: rgba(13, 10, 6, 0.38); - --color-selection: rgba(239, 189, 98, 0.3); - --color-selection-strong: rgba(239, 189, 98, 0.55); + --color-selection: rgba(243, 180, 7, 0.3); + --color-selection-strong: rgba(243, 180, 7, 0.55); --color-shadow: rgba(0, 0, 0, 0.6); --color-shadow-strong: rgba(0, 0, 0, 0.75); @@ -588,11 +588,11 @@ html[data-theme="cosmic-light"] .folio-prose code { --font-serif: "Tirra", "Source Serif 4", Georgia, serif; --font-display: "Yeseva One", "Cormorant Garamond", Georgia, serif; - --color-h1: #efbd62; + --color-h1: #F3B407; --color-h2: #74d7cb; --color-h3: #f08e7d; --color-h4: #d89ce7; - --color-h5: #79c7ef; + --color-h5: #87E6FB; --color-h6: #f5ddae; } @@ -889,4 +889,4 @@ html[data-theme="cosmic-stardust-light"] .folio-prose code { --color-bg: rgba(255, 248, 232, 0.97); --color-panel: rgba(245, 234, 210, 0.94); } -} \ No newline at end of file +}