feat(renderer): add Save As button to chapter header

- Add 'Save As' button next to Save in the chapter writing view
- Exports current chapter as Markdown to a user-chosen file location
- New IPC handler folio:saveChapterAs shows OS save dialog
This commit is contained in:
avi 2026-09-05 12:44:07 -05:00
commit 04c6540e4c
5 changed files with 34 additions and 0 deletions

View file

@ -550,6 +550,22 @@ handleIpc("folio:saveChapter", (_e, id: string, content: string) => {
return { ok: true }; return { ok: true };
}); });
handleIpc("folio:saveChapterAs", async (_e, id: string) => {
const bp = requireBook();
const meta = loadBook(bp);
const entry = meta.chapters[id];
if (!entry) return { error: "Chapter not found" };
const { canceled, filePath } = await showSaveDialog({
title: "Save Chapter As",
defaultPath: `${entry.title}.md`,
filters: [{ name: "Markdown", extensions: ["md"] }],
});
if (canceled || !filePath) return { canceled: true } as const;
const content = getChapterContent(bp, id);
fs.writeFileSync(filePath, content, "utf-8");
return { ok: true, filePath };
});
// Explicit, opt-in migration of a single chapter (or the whole book) to the // Explicit, opt-in migration of a single chapter (or the whole book) to the
// native Folio document format. Never automatic — the UI triggers this. The // native Folio document format. Never automatic — the UI triggers this. The
// migration pipeline makes a recovery backup and keeps the original .md files. // migration pipeline makes a recovery backup and keeps the original .md files.

View file

@ -42,6 +42,8 @@ contextBridge.exposeInMainWorld("folio", {
ipcRenderer.invoke("folio:loadChapterDoc", id), ipcRenderer.invoke("folio:loadChapterDoc", id),
saveChapterDoc: (id: string, doc: unknown) => saveChapterDoc: (id: string, doc: unknown) =>
ipcRenderer.invoke("folio:saveChapterDoc", id, doc), ipcRenderer.invoke("folio:saveChapterDoc", id, doc),
saveChapterAs: (id: string) =>
ipcRenderer.invoke("folio:saveChapterAs", id),
importImageAsset: () => ipcRenderer.invoke("folio:importImageAsset"), importImageAsset: () => ipcRenderer.invoke("folio:importImageAsset"),
exportMarkdown: () => ipcRenderer.invoke("folio:exportMarkdown"), exportMarkdown: () => ipcRenderer.invoke("folio:exportMarkdown"),
exportZip: () => ipcRenderer.invoke("folio:exportZip"), exportZip: () => ipcRenderer.invoke("folio:exportZip"),

View file

@ -11,6 +11,7 @@ import type { BookCtx } from "./types.js";
import { alertMessage } from "../dialog.js"; import { alertMessage } from "../dialog.js";
import { import {
$, $,
basename,
closeEl, closeEl,
countWords, countWords,
formatWordCount, formatWordCount,
@ -230,6 +231,19 @@ export function createEditorFeature(ctx: BookCtx): EditorFeature {
const saveBtn = $("saveBtn"); const saveBtn = $("saveBtn");
saveBtn?.addEventListener("click", () => void saveAndNotify()); saveBtn?.addEventListener("click", () => void saveAndNotify());
// Save As button — exports the current chapter as a Markdown file to a
// user-chosen location via the OS save dialog.
const saveAsBtn = $("saveAsBtn");
saveAsBtn?.addEventListener("click", async () => {
if (!state.selectedId) return;
const res = await api.saveChapterAs(state.selectedId);
if (res && "error" in res) {
showToast("Save As failed");
} else if (res && "filePath" in res) {
showToast(`Saved to ${basename(res.filePath as string)}`);
}
});
// 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

@ -56,6 +56,7 @@ export type FolioAPI = {
migrateBook: () => Promise<any>; migrateBook: () => Promise<any>;
loadChapterDoc: (id: string) => Promise<any>; loadChapterDoc: (id: string) => Promise<any>;
saveChapterDoc: (id: string, doc: unknown) => Promise<any>; saveChapterDoc: (id: string, doc: unknown) => Promise<any>;
saveChapterAs: (id: string) => Promise<any>;
importImageAsset: () => Promise<any>; importImageAsset: () => Promise<any>;
exportMarkdown: () => Promise<any>; exportMarkdown: () => Promise<any>;
exportZip: () => Promise<any>; exportZip: () => Promise<any>;

View file

@ -70,6 +70,7 @@
<div class="editor-actions"> <div class="editor-actions">
<span class="save-state" id="saveState">Saved</span> <span class="save-state" id="saveState">Saved</span>
<button class="button button-ghost" id="saveBtn" title="Save chapter (Ctrl+S)">Save</button> <button class="button button-ghost" id="saveBtn" title="Save chapter (Ctrl+S)">Save</button>
<button class="button button-ghost" id="saveAsBtn" title="Save chapter as Markdown file">Save As</button>
<span class="word-count" id="wordCount">words: 0</span> <span class="word-count" id="wordCount">words: 0</span>
<button class="icon-button" id="statsBtn" title="Writing statistics">📊</button> <button class="icon-button" id="statsBtn" title="Writing statistics">📊</button>
<button class="icon-button" id="commentsBtn" title="Comments">💬</button> <button class="icon-button" id="commentsBtn" title="Comments">💬</button>