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:
parent
8e49d2769a
commit
1a1d69936c
7 changed files with 95 additions and 19 deletions
|
|
@ -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) {
|
||||||
state.loadedDocJson = JSON.stringify(getEditorDoc(editor));
|
saveStateEl.textContent = "Failed";
|
||||||
state.dirty = false;
|
saveStateEl.classList.add("failed");
|
||||||
saveStateEl.textContent = "Saved";
|
return false;
|
||||||
saveStateEl.classList.remove("unsaved");
|
|
||||||
}
|
}
|
||||||
|
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.
|
// 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.
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 ▾</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+-)">−</button>
|
<button class="icon-btn" type="button" data-zoom="out" title="Zoom out (Ctrl+-)">−</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>
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -889,4 +889,4 @@ html[data-theme="cosmic-stardust-light"] .folio-prose code {
|
||||||
--color-bg: rgba(255, 248, 232, 0.97);
|
--color-bg: rgba(255, 248, 232, 0.97);
|
||||||
--color-panel: rgba(245, 234, 210, 0.94);
|
--color-panel: rgba(245, 234, 210, 0.94);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue