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
This commit is contained in:
avi 2026-09-05 12:21:16 -05:00
commit 1a1d69936c
7 changed files with 95 additions and 19 deletions

View file

@ -30,7 +30,7 @@ import {
export interface EditorFeature { export interface EditorFeature {
editor: Editor; editor: Editor;
selectChapter(id: string): Promise<void>; selectChapter(id: string): Promise<void>;
saveCurrentChapter(): Promise<void>; saveCurrentChapter(): Promise<boolean>;
saveAndNotify(): Promise<void>; saveAndNotify(): Promise<void>;
flushAutosave(): Promise<void>; flushAutosave(): Promise<void>;
clearTimers(): void; clearTimers(): void;
@ -65,18 +65,25 @@ export function createEditorFeature(ctx: BookCtx): EditorFeature {
function clearTimers() { function clearTimers() {
if (saveTimer) { clearTimeout(saveTimer); saveTimer = null; } if (saveTimer) { clearTimeout(saveTimer); saveTimer = null; }
stopForcedAutosave();
} }
async function saveCurrentChapter(): Promise<void> { async function saveCurrentChapter(): Promise<boolean> {
if (!state.selectedId) return; if (!state.selectedId) return false;
clearTimers(); clearTimers();
saveStateEl.textContent = "Saving…";
saveStateEl.classList.remove("unsaved", "failed");
const res = await api.saveChapterDoc(state.selectedId, getEditorDoc(editor)); const res = await api.saveChapterDoc(state.selectedId, getEditorDoc(editor));
if (!(res && "error" in res)) { if (res && "error" in res) {
saveStateEl.textContent = "Failed";
saveStateEl.classList.add("failed");
return false;
}
state.loadedDocJson = JSON.stringify(getEditorDoc(editor)); state.loadedDocJson = JSON.stringify(getEditorDoc(editor));
state.dirty = false; state.dirty = false;
saveStateEl.textContent = "Saved"; saveStateEl.textContent = "Saved";
saveStateEl.classList.remove("unsaved"); saveStateEl.classList.remove("unsaved", "failed");
} return true;
} }
// Explicit save (Ctrl/Cmd+S or menu) — always give visible feedback. // 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"); showToast(state.currentBook ? "Nothing to save" : "Nothing to save");
return; return;
} }
await saveCurrentChapter(); const ok = await saveCurrentChapter();
showToast("Saved"); showToast(ok ? "Saved" : "Save failed — check disk space/permissions");
} }
// Guards against overlapping selections: rapid clicks (or metadata // 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.loadedDocJson = JSON.stringify(getEditorDoc(editor));
state.dirty = false; state.dirty = false;
saveStateEl.textContent = "Saved"; saveStateEl.textContent = "Saved";
saveStateEl.classList.remove("unsaved"); saveStateEl.classList.remove("unsaved", "failed");
$("wordCount").textContent = formatWordCount(editor.getText()); $("wordCount").textContent = formatWordCount(editor.getText());
editor.commands.focus("end"); editor.commands.focus("end");
clearTimers(); clearTimers();
startForcedAutosave();
$("fullBookBtn").classList.remove("active"); $("fullBookBtn").classList.remove("active");
$("fullBookView").classList.add("hidden"); $("fullBookView").classList.add("hidden");
showView($("chapterView")); showView($("chapterView"));
@ -160,6 +168,20 @@ export function createEditorFeature(ctx: BookCtx): EditorFeature {
await saveCurrentChapter(); 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<typeof setInterval> | 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() { function handleEditorChange() {
if (!state.selectedId) return; if (!state.selectedId) return;
const json = JSON.stringify(getEditorDoc(editor)); 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 // 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 // last stretch of typing is persisted even if the app closes within the
// debounce window. // debounce window.

View file

@ -119,6 +119,37 @@ export function createExportsFeature(ctx: BookCtx): ExportsFeature {
$("exportBtn").addEventListener("click", () => void exportMarkdown()); $("exportBtn").addEventListener("click", () => void exportMarkdown());
$("exportZipBtn").addEventListener("click", () => void exportZip()); $("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 exportMenuToggle = $("exportMenuToggle") as HTMLButtonElement;
const exportMenu = $("exportMenu"); const exportMenu = $("exportMenu");

View file

@ -203,6 +203,12 @@ export function createToolbarFeature(ctx: BookCtx, editor: Editor): ToolbarFeatu
window.addEventListener("resize", closeDropdowns); window.addEventListener("resize", closeDropdowns);
// Electron has no default right-click menu, so show one with Cut/Copy/Paste. // Electron has no default right-click menu, so show one with Cut/Copy/Paste.
window.addEventListener("contextmenu", (e) => { 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(); e.preventDefault();
api.showContextMenu(e.clientX, e.clientY); api.showContextMenu(e.clientX, e.clientY);
}); });

View file

@ -112,7 +112,7 @@ export interface SharedState {
export interface BookHooks { export interface BookHooks {
// editor feature // editor feature
selectChapter(id: string): Promise<void>; selectChapter(id: string): Promise<void>;
saveCurrentChapter(): Promise<void>; saveCurrentChapter(): Promise<boolean>;
saveAndNotify(): Promise<void>; saveAndNotify(): Promise<void>;
flushAutosave(): Promise<void>; flushAutosave(): Promise<void>;
clearTimers(): void; clearTimers(): void;

View file

@ -902,6 +902,11 @@ body.dragging-active * {
font-weight: 600; font-weight: 600;
} }
.save-state.failed {
color: #e11d48;
font-weight: 700;
}
.editor-panes { .editor-panes {
flex: 1; flex: 1;
display: flex; display: flex;
@ -1664,7 +1669,7 @@ body.focus-mode .editor {
color: var(--color-text); color: var(--color-text);
background: var(--color-bg); background: var(--color-bg);
padding: 24px 20px; padding: 24px 20px;
max-width: 820px; width: 100%;
margin: 0; margin: 0;
outline: none; outline: none;
} }

View file

@ -75,6 +75,14 @@
<button class="icon-button" id="commentsBtn" title="Comments">💬</button> <button class="icon-button" id="commentsBtn" title="Comments">💬</button>
<button class="icon-button" id="exportBtn" title="Export book (Markdown)">⬇</button> <button class="icon-button" id="exportBtn" title="Export book (Markdown)">⬇</button>
<button class="icon-button" id="exportZipBtn" title="Export bundle (ZIP)">📦</button> <button class="icon-button" id="exportZipBtn" title="Export bundle (ZIP)">📦</button>
<div class="toolbar-dropdown export-dropdown" id="chapterExportDropdown">
<button class="button dropdown-toggle" id="chapterExportMenuToggle" title="Export as PDF, DOCX, or ODT">Export &#9662;</button>
<div class="dropdown-menu hidden" id="chapterExportMenu">
<button class="dropdown-item" data-export="pdf">PDF</button>
<button class="dropdown-item" data-export="docx">DOCX</button>
<button class="dropdown-item" data-export="odt">ODT</button>
</div>
</div>
<div class="zoom-control" role="group" aria-label="Zoom"> <div class="zoom-control" role="group" aria-label="Zoom">
<button class="icon-btn" type="button" data-zoom="out" title="Zoom out (Ctrl+-)">&#8722;</button> <button class="icon-btn" type="button" data-zoom="out" title="Zoom out (Ctrl+-)">&#8722;</button>
<button class="zoom-level" type="button" data-zoom="reset" title="Reset zoom to 100% (Ctrl+0)">100%</button> <button class="zoom-level" type="button" data-zoom="reset" title="Reset zoom to 100% (Ctrl+0)">100%</button>

View file

@ -567,15 +567,15 @@ html[data-theme="cosmic-light"] .folio-prose code {
--color-border: #2a2210; --color-border: #2a2210;
--color-text: #fff5dc; --color-text: #fff5dc;
--color-muted: #d5bd8d; --color-muted: #d5bd8d;
--color-accent: #efbd62; --color-accent: #F3B407;
--color-accent-hover: #ffdb91; --color-accent-hover: #ffdb91;
--color-on-accent: #0d0a06; --color-on-accent: #0d0a06;
--color-on-accent-soft: rgba(13, 10, 6, 0.18); --color-on-accent-soft: rgba(13, 10, 6, 0.18);
--color-on-accent-border: rgba(13, 10, 6, 0.38); --color-on-accent-border: rgba(13, 10, 6, 0.38);
--color-selection: rgba(239, 189, 98, 0.3); --color-selection: rgba(243, 180, 7, 0.3);
--color-selection-strong: rgba(239, 189, 98, 0.55); --color-selection-strong: rgba(243, 180, 7, 0.55);
--color-shadow: rgba(0, 0, 0, 0.6); --color-shadow: rgba(0, 0, 0, 0.6);
--color-shadow-strong: rgba(0, 0, 0, 0.75); --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-serif: "Tirra", "Source Serif 4", Georgia, serif;
--font-display: "Yeseva One", "Cormorant Garamond", Georgia, serif; --font-display: "Yeseva One", "Cormorant Garamond", Georgia, serif;
--color-h1: #efbd62; --color-h1: #F3B407;
--color-h2: #74d7cb; --color-h2: #74d7cb;
--color-h3: #f08e7d; --color-h3: #f08e7d;
--color-h4: #d89ce7; --color-h4: #d89ce7;
--color-h5: #79c7ef; --color-h5: #87E6FB;
--color-h6: #f5ddae; --color-h6: #f5ddae;
} }