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 {
editor: Editor;
selectChapter(id: string): Promise<void>;
saveCurrentChapter(): Promise<void>;
saveCurrentChapter(): Promise<boolean>;
saveAndNotify(): Promise<void>;
flushAutosave(): Promise<void>;
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<void> {
if (!state.selectedId) return;
async function saveCurrentChapter(): Promise<boolean> {
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<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() {
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.

View file

@ -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");

View file

@ -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);
});

View file

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

View file

@ -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;
}

View file

@ -75,6 +75,14 @@
<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="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">
<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>

View file

@ -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;
}