diff --git a/src/main/backup.ts b/src/main/backup.ts index ba9c651..9d93187 100644 --- a/src/main/backup.ts +++ b/src/main/backup.ts @@ -70,3 +70,132 @@ export function ensureRecoveryBackup(bookPath: string): string | null { export function _resetSessionBackupGuard(): void { sessionBackedUp.clear(); } + +// ---- Continuous snapshots ----------------------------------------------- +// Unlike the migration recovery backup above (external location, once per +// session), snapshots are periodic copies of the whole book used as an +// ongoing safety net while writing. Defaults keep them next to the work: +// +// /Folio-Backups/-/ +// +// A custom root can be chosen in Settings; it then applies to every book. +// Only the newest `keep` snapshots are retained (oldest pruned). + +export const SNAPSHOT_DIR_NAME = "Folio-Backups"; +export const SNAPSHOT_KEEP_DEFAULT = 20; + +export function getSnapshotRoot(bookPath?: string): string { + const custom = store ? store.get("snapshotLocation", "") : ""; + if (custom && custom.trim()) return path.resolve(custom.trim()); + if (bookPath) return path.join(path.resolve(bookPath), SNAPSHOT_DIR_NAME); + return getRecoveryRoot(); +} + +function snapshotPrefix(bookPath: string): string { + return `${path.basename(path.resolve(bookPath)) || "book"}-`; +} + +// Snapshot directories for a book, oldest first. +export function listSnapshots(bookPath: string): string[] { + const root = getSnapshotRoot(bookPath); + const prefix = snapshotPrefix(bookPath); + let entries: string[]; + try { + entries = fs.readdirSync(root); + } catch { + return []; + } + return entries + .filter((e) => e.startsWith(prefix)) + .sort() + .map((e) => path.join(root, e)); +} + +// True when any file in the book (outside its own snapshot dir) changed +// after `sinceMs`. Lets the interval timer skip quiet periods instead of +// piling up identical copies. +export function bookChangedSince(bookPath: string, sinceMs: number): boolean { + const key = path.resolve(bookPath); + const snapRoot = path.join(key, SNAPSHOT_DIR_NAME) + path.sep; + const stack: string[] = [key]; + while (stack.length) { + const cur = stack.pop() as string; + let stat: fs.Stats; + try { + stat = fs.statSync(cur); + } catch { + continue; + } + if (stat.isDirectory()) { + if ((cur + path.sep).startsWith(snapRoot)) continue; + let kids: string[]; + try { + kids = fs.readdirSync(cur); + } catch { + continue; + } + for (const k of kids) stack.push(path.join(cur, k)); + } else if (stat.mtimeMs > sinceMs) { + return true; + } + } + return false; +} + +export function pruneSnapshots(bookPath: string, keep: number): void { + const n = Number.isFinite(keep) && keep >= 0 ? Math.floor(keep) : SNAPSHOT_KEEP_DEFAULT; + const snaps = listSnapshots(bookPath); + const excess = snaps.length - n; + for (let i = 0; i < excess; i++) { + try { + fs.rmSync(snaps[i], { recursive: true, force: true }); + } catch { + // best-effort pruning only; a leftover old snapshot is harmless + } + } +} + +// Copy the book to a new timestamped snapshot directory (excluding the +// snapshot dir itself so snapshots never nest). Returns the new directory, +// or null when the book path does not exist. +export function createSnapshot(bookPath: string, keep?: number): string | null { + const key = path.resolve(bookPath); + let stat: fs.Stats; + try { + stat = fs.statSync(key); + } catch { + return null; + } + if (!stat.isDirectory()) return null; + const root = getSnapshotRoot(bookPath); + const stamp = new Date().toISOString().replace(/[:.]/g, "-"); + let dest = path.join(root, `${snapshotPrefix(bookPath)}${stamp}`); + // Same-millisecond snapshots (e.g. double-clicked "Snapshot now") must not + // silently merge into one directory. + for (let n = 2; fs.existsSync(dest); n++) { + dest = path.join(root, `${snapshotPrefix(bookPath)}${stamp}-${n}`); + } + fs.mkdirSync(root, { recursive: true }); + try { + // Copy entry-by-entry (skipping the snapshot dir) instead of one cpSync + // of the book root: Node refuses to copy a directory into a subdirectory + // of itself even when a filter excludes the destination. + const entries = fs.readdirSync(key); + fs.mkdirSync(dest, { recursive: true }); + for (const e of entries) { + if (e === SNAPSHOT_DIR_NAME) continue; + fs.cpSync(path.join(key, e), path.join(dest, e), { recursive: true }); + } + } catch (err) { + // A half-copied directory is worse than none — it looks like a good + // snapshot. Remove the partial copy, then surface the failure. + try { + fs.rmSync(dest, { recursive: true, force: true }); + } catch { + // best-effort cleanup only + } + throw err; + } + pruneSnapshots(bookPath, keep ?? SNAPSHOT_KEEP_DEFAULT); + return dest; +} diff --git a/src/main/index.ts b/src/main/index.ts index 3f83fb3..9db9721 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -34,8 +34,14 @@ import { import { combineMarkdown, buildZip } from "./export.js"; import { bindBackupSettings, + bookChangedSince, + createSnapshot, getRecoveryRoot, + getSnapshotRoot, + listSnapshots, + pruneSnapshots, setRecoveryRoot, + SNAPSHOT_KEEP_DEFAULT, } from "./backup.js"; import { sanitizeBookFolderName } from "./ids.js"; import { buildDocx, buildOdt } from "./office-export.js"; @@ -752,6 +758,118 @@ handleIpc("folio:setBackupLocation", (_e, loc: unknown) => { return { ok: true }; }); +// ---- Autosave + continuous snapshots -------------------------------------- +// Settings keys (all in the shared settings store, defaults = on / sane): +// autosaveEnabled background autosave while typing (default true) +// snapshotsEnabled periodic whole-book snapshots (default true) +// snapshotIntervalMin minutes between snapshot checks (default 5) +// snapshotKeep how many recent snapshots to retain (default 20) +// snapshotLocation custom snapshot root, "" = /Folio-Backups +const SNAPSHOT_DEFAULTS = { + autosaveEnabled: true, + snapshotsEnabled: true, + intervalMin: 5, + keep: SNAPSHOT_KEEP_DEFAULT, +}; + +function clampInt(v: unknown, fallback: number, min: number, max: number): number { + const n = typeof v === "number" ? Math.round(v) : fallback; + if (!Number.isFinite(n)) return fallback; + return Math.max(min, Math.min(max, n)); +} + +function snapshotSettingsSnapshot() { + const location = getSetting("snapshotLocation", ""); + const resolved = bookPath ? getSnapshotRoot(bookPath) : getSnapshotRoot(); + const snaps = bookPath ? listSnapshots(bookPath) : []; + return { + autosaveEnabled: getSetting("autosaveEnabled", SNAPSHOT_DEFAULTS.autosaveEnabled), + snapshotsEnabled: getSetting("snapshotsEnabled", SNAPSHOT_DEFAULTS.snapshotsEnabled), + intervalMin: clampInt(getSetting("snapshotIntervalMin", SNAPSHOT_DEFAULTS.intervalMin), SNAPSHOT_DEFAULTS.intervalMin, 1, 1440), + keep: clampInt(getSetting("snapshotKeep", SNAPSHOT_DEFAULTS.keep), SNAPSHOT_DEFAULTS.keep, 0, 200), + location, + resolvedRoot: resolved, + snapshotCount: snaps.length, + lastSnapshot: snaps.length ? snaps[snaps.length - 1] : null, + }; +} + +handleIpc("folio:getAutosave", () => + getSetting("autosaveEnabled", SNAPSHOT_DEFAULTS.autosaveEnabled) +); +handleIpc("folio:setAutosave", (_e, on: unknown) => { + setSetting("autosaveEnabled", on !== false); + return { ok: true, enabled: on !== false }; +}); +handleIpc("folio:getSnapshotSettings", () => snapshotSettingsSnapshot()); +handleIpc("folio:setSnapshotSettings", (_e, patch: unknown) => { + const p = (patch && typeof patch === "object" ? patch : {}) as Record; + if ("snapshotsEnabled" in p) setSetting("snapshotsEnabled", p.snapshotsEnabled !== false); + if ("intervalMin" in p) { + setSetting("snapshotIntervalMin", clampInt(p.intervalMin, SNAPSHOT_DEFAULTS.intervalMin, 1, 1440)); + } + if ("keep" in p) { + const keep = clampInt(p.keep, SNAPSHOT_DEFAULTS.keep, 0, 200); + setSetting("snapshotKeep", keep); + // Apply a lowered retention immediately to the open book. + if (bookPath) { + try { pruneSnapshots(bookPath, keep); } catch { /* best effort */ } + } + } + if ("location" in p) { + const s = typeof p.location === "string" ? p.location.trim() : ""; + setSetting("snapshotLocation", s); + } + return { ok: true, settings: snapshotSettingsSnapshot() }; +}); +handleIpc("folio:pickSnapshotFolder", async () => { + const { canceled, filePaths } = await showOpenDialog({ + properties: ["openDirectory"], + title: "Choose snapshot folder", + }); + return { canceled, filePaths }; +}); +// Interval-snapshot bookkeeping (declared before use by the handlers below). +let snapshotLastRunMs = 0; +let snapshotLastSnapMs = 0; +let snapshotBook: string | null = null; +handleIpc("folio:snapshotNow", () => { + const bp = requireBook(); + const keep = clampInt(getSetting("snapshotKeep", SNAPSHOT_DEFAULTS.keep), SNAPSHOT_DEFAULTS.keep, 0, 200); + const dest = createSnapshot(bp, keep); + snapshotLastSnapMs = Date.now(); + return { ok: true, path: dest, settings: snapshotSettingsSnapshot() }; +}); + +// Interval snapshots: once a minute, check whether the open book changed +// since the last snapshot and whether the configured interval elapsed. The +// 1-minute tick keeps wakeups cheap; the user-facing cadence is intervalMin. +setInterval(() => { + try { + if (!bookPath) return; + if (snapshotBook !== bookPath) { + snapshotBook = bookPath; + snapshotLastSnapMs = Date.now(); + snapshotLastRunMs = Date.now(); + return; + } + if (getSetting("snapshotsEnabled", SNAPSHOT_DEFAULTS.snapshotsEnabled) !== true) return; + const intervalMin = clampInt( + getSetting("snapshotIntervalMin", SNAPSHOT_DEFAULTS.intervalMin), + SNAPSHOT_DEFAULTS.intervalMin, 1, 1440 + ); + const now = Date.now(); + if (now - snapshotLastRunMs < intervalMin * 60_000) return; + snapshotLastRunMs = now; + if (!bookChangedSince(bookPath, snapshotLastSnapMs)) return; + const keep = clampInt(getSetting("snapshotKeep", SNAPSHOT_DEFAULTS.keep), SNAPSHOT_DEFAULTS.keep, 0, 200); + createSnapshot(bookPath, keep); + snapshotLastSnapMs = now; + } catch (err) { + console.error("[folio] interval snapshot failed:", (err as Error).message); + } +}, 60_000); + handleIpc("folio:importAttachment", async () => { const bp = requireBook(); const { canceled, filePaths } = await showOpenDialog({ diff --git a/src/main/menu.ts b/src/main/menu.ts index e42494c..67b9399 100644 --- a/src/main/menu.ts +++ b/src/main/menu.ts @@ -75,18 +75,9 @@ export function buildMenu( }, }, { - label: "Backup Location…", - click: async () => { - const win = getWindow(); - const opts = { - properties: ["openDirectory" as const], - title: "Choose recovery backup folder", - }; - const { canceled, filePaths } = win - ? await dialog.showOpenDialog(win, opts) - : await dialog.showOpenDialog(opts); - if (!canceled && filePaths[0]) setRecoveryRoot(filePaths[0]); - }, + label: "Settings…", + accelerator: "CmdOrCtrl+,", + click: () => getWindow()?.webContents.send("folio:settings-requested"), }, { type: "separator" }, { diff --git a/src/main/preload.ts b/src/main/preload.ts index 569a145..3ee2b2d 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -75,6 +75,15 @@ contextBridge.exposeInMainWorld("folio", { setBackupLocation: (loc: string) => ipcRenderer.invoke("folio:setBackupLocation", loc), pickBackupFolder: () => ipcRenderer.invoke("folio:pickBackupFolder"), + // autosave + continuous snapshots + getAutosave: () => ipcRenderer.invoke("folio:getAutosave"), + setAutosave: (on: boolean) => ipcRenderer.invoke("folio:setAutosave", on), + getSnapshotSettings: () => ipcRenderer.invoke("folio:getSnapshotSettings"), + setSnapshotSettings: (patch: unknown) => + ipcRenderer.invoke("folio:setSnapshotSettings", patch), + pickSnapshotFolder: () => ipcRenderer.invoke("folio:pickSnapshotFolder"), + snapshotNow: () => ipcRenderer.invoke("folio:snapshotNow"), + // events onBookOpened: (cb: (data: { path: string; title: string }) => void) => ipcRenderer.on("folio:bookOpened", (_e, data) => cb(data)), @@ -120,4 +129,6 @@ contextBridge.exposeInMainWorld("folio", { ipcRenderer.on("folio:open-markdown-requested", () => cb()), onCheckUpdateRequested: (cb: () => void) => ipcRenderer.on("folio:check-update-requested", () => cb()), + onSettingsRequested: (cb: () => void) => + ipcRenderer.on("folio:settings-requested", () => cb()), }); diff --git a/src/renderer/book/editor.ts b/src/renderer/book/editor.ts index 38e8a4c..0d57d87 100644 --- a/src/renderer/book/editor.ts +++ b/src/renderer/book/editor.ts @@ -35,6 +35,9 @@ export interface EditorFeature { saveAndNotify(): Promise; flushAutosave(): Promise; clearTimers(): void; + setAutosaveEnabled(on: boolean): void; + setTypewriterEnabled(on: boolean): void; + isTypewriterEnabled(): boolean; } export function createEditorFeature(ctx: BookCtx): EditorFeature { @@ -63,6 +66,19 @@ export function createEditorFeature(ctx: BookCtx): EditorFeature { const AUTOSAVE_MS = 1000; let saveTimer: ReturnType | null = null; const saveStateEl = $("saveState"); + // Background autosave switch (Settings). When off, typing only marks the + // chapter Unsaved; explicit Save, chapter switches, and app-hide flushes + // still persist so work is never silently lost. + let autosaveOn = true; + void api.getAutosave().then( + (v) => { autosaveOn = v !== false; }, + () => { /* default on when the backend is unreachable */ } + ); + function setAutosaveEnabled(on: boolean) { + autosaveOn = on !== false; + if (autosaveOn && state.selectedId && state.dirty) scheduleAutosave(); + else if (!autosaveOn && saveTimer) { clearTimeout(saveTimer); saveTimer = null; } + } function clearTimers() { if (saveTimer) { clearTimeout(saveTimer); saveTimer = null; } @@ -153,9 +169,15 @@ export function createEditorFeature(ctx: BookCtx): EditorFeature { // Schedule a background save a short time after the last edit. The indicator // shows "Saving…" while the debounce is pending so the user knows the chapter - // is about to be persisted automatically. + // is about to be persisted automatically. With background autosave off this + // only marks the chapter Unsaved until the user saves explicitly. function scheduleAutosave() { clearTimers(); + if (!autosaveOn) { + saveStateEl.textContent = "Unsaved"; + saveStateEl.classList.add("unsaved"); + return; + } saveStateEl.textContent = "Saving…"; saveStateEl.classList.remove("unsaved"); saveTimer = setTimeout(() => void flushAutosave(), AUTOSAVE_MS); @@ -174,7 +196,7 @@ export function createEditorFeature(ctx: BookCtx): EditorFeature { const FORCED_AUTOSAVE_MS = 30_000; let forcedSaveTimer: ReturnType | null = null; function startForcedAutosave() { - if (forcedSaveTimer) return; + if (forcedSaveTimer || !autosaveOn) return; forcedSaveTimer = setInterval(() => { if (state.selectedId && state.dirty) void flushAutosave(); }, FORCED_AUTOSAVE_MS); @@ -359,5 +381,8 @@ export function createEditorFeature(ctx: BookCtx): EditorFeature { saveAndNotify, flushAutosave, clearTimers, + setAutosaveEnabled, + setTypewriterEnabled, + isTypewriterEnabled, }; } diff --git a/src/renderer/book/index.ts b/src/renderer/book/index.ts index 5915827..2980f3e 100644 --- a/src/renderer/book/index.ts +++ b/src/renderer/book/index.ts @@ -10,6 +10,7 @@ import { createChapterListFeature } from "./chapter-list.js"; import { createWelcomeFeature } from "./welcome.js"; import { createExportsFeature } from "./exports.js"; import { createCommentsFeature } from "./comments.js"; +import { createSettingsFeature } from "./settings.js"; import { $, basename, @@ -60,6 +61,13 @@ export function initBook(api: FolioAPI) { hooks.saveAndNotify = ed.saveAndNotify; hooks.flushAutosave = ed.flushAutosave; hooks.clearTimers = ed.clearTimers; + hooks.setAutosaveEnabled = ed.setAutosaveEnabled; + hooks.setTypewriterEnabled = ed.setTypewriterEnabled; + hooks.isTypewriterEnabled = ed.isTypewriterEnabled; + + // Feature: settings modal (autosave + snapshot backups). + const settings = createSettingsFeature(ctx); + hooks.openSettings = settings.openSettings; // Feature: formatting toolbar + font controls. createToolbarFeature(ctx, editor); diff --git a/src/renderer/book/settings.ts b/src/renderer/book/settings.ts new file mode 100644 index 0000000..f14e02e --- /dev/null +++ b/src/renderer/book/settings.ts @@ -0,0 +1,179 @@ +// Settings: autosave + continuous snapshot backups. +// +// Owns the Settings modal (#settingsModal): background-autosave switch, +// snapshot cadence/retention/location, and a manual "Snapshot now" button. +// Persisted through the main-process settings store; the editor's background +// engine is toggled live via hooks.setAutosaveEnabled. Snapshot cadence runs +// in the main process (1-minute tick), so this module only edits preferences. +// +// Cross-feature calls go through BookHooks exclusively — no sibling imports. + +import type { BookCtx } from "./types.js"; +import { alertMessage } from "../dialog.js"; +import { $, closeEl, openEl, showToast } from "./ui.js"; + +export interface SettingsFeature { + openSettings(): void; +} + +interface SnapshotSettings { + autosaveEnabled: boolean; + snapshotsEnabled: boolean; + intervalMin: number; + keep: number; + location: string; + resolvedRoot: string; + snapshotCount: number; + lastSnapshot: string | null; +} + +export function createSettingsFeature(ctx: BookCtx): SettingsFeature { + const { api, hooks } = ctx; + + const modal = $("settingsModal") as HTMLDivElement; + const autosaveCb = $("settingAutosave") as HTMLInputElement; + const snapsCb = $("settingSnapshots") as HTMLInputElement; + const spellCb = $("settingSpellcheck") as HTMLInputElement; + const typewriterCb = $("settingTypewriter") as HTMLInputElement; + const intervalInput = $("settingInterval") as HTMLInputElement; + const keepInput = $("settingKeep") as HTMLInputElement; + const locationInput = $("settingLocation") as HTMLInputElement; + const resolvedEl = $("settingResolved") as HTMLElement; + const lastSnapEl = $("settingLastSnap") as HTMLElement; + const closeBtn = $("settingsClose") as HTMLButtonElement; + const browseBtn = $("settingBrowse") as HTMLButtonElement; + const resetBtn = $("settingResetLoc") as HTMLButtonElement; + const snapNowBtn = $("settingSnapNow") as HTMLButtonElement; + + let loaded = false; + + function shortPath(p: string): string { + const home = (window as unknown as { __folioHome?: string }).__folioHome; + if (home && p.startsWith(home)) return "~" + p.slice(home.length); + return p; + } + + function render(s: SnapshotSettings) { + autosaveCb.checked = s.autosaveEnabled !== false; + snapsCb.checked = s.snapshotsEnabled !== false; + intervalInput.value = String(s.intervalMin ?? 5); + keepInput.value = String(s.keep ?? 20); + locationInput.value = s.location || ""; + locationInput.placeholder = "Default: /Folio-Backups"; + resolvedEl.textContent = `Saving to: ${shortPath(s.resolvedRoot || "—")}`; + lastSnapEl.textContent = s.lastSnapshot + ? `${s.snapshotCount} snapshot${s.snapshotCount === 1 ? "" : "s"} · latest ${s.lastSnapshot.split("/").pop()}` + : "No snapshots yet."; + const dis = !snapsCb.checked; + intervalInput.disabled = dis; + keepInput.disabled = dis; + snapNowBtn.disabled = dis; + } + + async function load() { + try { + const s = (await api.getSnapshotSettings()) as SnapshotSettings; + render(s); + loaded = true; + } catch { + // Settings are best-effort; the modal still opens with defaults. + } + try { + spellCb.checked = (await api.getSpellcheck()) !== false; + } catch { + // keep default checked state + } + try { + typewriterCb.checked = hooks.isTypewriterEnabled(); + } catch { + // keep default unchecked state + } + } + + function openSettings(): void { + openEl(modal); + void load(); + setTimeout(() => autosaveCb.focus(), 0); + } + function closeSettings(): void { + closeEl(modal); + } + + async function push(patch: Record, applyAutosave = false) { + const res = await api.setSnapshotSettings(patch); + if (res && "error" in res) { + await alertMessage("Could not save settings", (res as { error: string }).error); + return; + } + const s = (res as { settings: SnapshotSettings }).settings; + if (s) render(s); + if (applyAutosave && s) hooks.setAutosaveEnabled(s.autosaveEnabled !== false); + } + + autosaveCb.addEventListener("change", () => { + const on = autosaveCb.checked; + // Apply instantly to the editor; persist alongside. + hooks.setAutosaveEnabled(on); + void (async () => { + await api.setAutosave(on); + await load(); + })(); + }); + snapsCb.addEventListener("change", () => void push({ snapshotsEnabled: snapsCb.checked })); + spellCb.addEventListener("change", () => void (async () => { + const res = await api.setSpellcheck(spellCb.checked); + if (res && "error" in res) { + await alertMessage("Could not save settings", (res as { error: string }).error); + await load(); + } + })()); + typewriterCb.addEventListener("change", () => { + hooks.setTypewriterEnabled(typewriterCb.checked); + }); + intervalInput.addEventListener("change", () => { + const v = Math.max(1, Math.min(1440, Math.round(Number(intervalInput.value) || 5))); + intervalInput.value = String(v); + void push({ intervalMin: v }); + }); + keepInput.addEventListener("change", () => { + const v = Math.max(0, Math.min(200, Math.round(Number(keepInput.value) || 0))); + keepInput.value = String(v); + void push({ keep: v }); + }); + browseBtn.addEventListener("click", () => void (async () => { + const res = await api.pickSnapshotFolder(); + if (!res || res.canceled || !res.filePaths?.length) return; + await push({ location: res.filePaths[0] }); + })()); + resetBtn.addEventListener("click", () => void push({ location: "" })); + snapNowBtn.addEventListener("click", () => void (async () => { + const res = await api.snapshotNow(); + if (!res) return; + if ("error" in res) { + await alertMessage("Snapshot failed", (res as { error: string }).error); + return; + } + showToast("Snapshot saved"); + await load(); + })()); + closeBtn.addEventListener("click", closeSettings); + modal.addEventListener("click", (e) => { + if (e.target === modal) closeSettings(); + }); + window.addEventListener("keydown", (e) => { + if (e.key === "Escape" && !modal.classList.contains("hidden")) closeSettings(); + }); + + // Sidebar + File-menu entry points. + $("settingsBtn")?.addEventListener("click", openSettings); + api.onSettingsRequested(() => openSettings()); + + // Refresh the resolved-path line whenever a book opens (the default root + // is per-book). Only refresh while the modal is open to avoid IPC noise. + api.onBookOpened(() => { + if (!modal.classList.contains("hidden") || !loaded) return; + void load(); + }); + + return { openSettings }; +} diff --git a/src/renderer/book/types.ts b/src/renderer/book/types.ts index a2739bd..d60af22 100644 --- a/src/renderer/book/types.ts +++ b/src/renderer/book/types.ts @@ -76,6 +76,12 @@ export type FolioAPI = { setTheme: (id: string) => Promise; getZoom: () => Promise; setZoom: (pct: number) => Promise; + getAutosave: () => Promise; + setAutosave: (on: boolean) => Promise; + getSnapshotSettings: () => Promise; + setSnapshotSettings: (patch: unknown) => Promise; + pickSnapshotFolder: () => Promise; + snapshotNow: () => Promise; onBookOpened: (cb: (data: { path: string; title: string }) => void) => void; onBookClosed: (cb: () => void) => void; onChaptersChanged: (cb: () => void) => void; @@ -98,6 +104,7 @@ export type FolioAPI = { onZoomResetRequested: (cb: () => void) => void; onOpenMarkdownRequested: (cb: () => void) => void; onCheckUpdateRequested: (cb: () => void) => void; + onSettingsRequested: (cb: () => void) => void; }; // ---- P7 decomposition contracts ---- @@ -125,6 +132,11 @@ export interface BookHooks { saveAndNotify(): Promise; flushAutosave(): Promise; clearTimers(): void; + setAutosaveEnabled(on: boolean): void; + setTypewriterEnabled(on: boolean): void; + isTypewriterEnabled(): boolean; + // settings feature + openSettings(): void; // session coordinators (orchestrator) refreshMeta(): Promise; showPlaceholder(): void; diff --git a/src/renderer/index.css b/src/renderer/index.css index c2087f4..1cb21dd 100644 --- a/src/renderer/index.css +++ b/src/renderer/index.css @@ -221,6 +221,60 @@ html, body { font-size: 0.9rem; } +/* ---- settings modal ---- */ +.settings-modal { + min-width: min(480px, 92vw); +} + +.setting-row { + display: flex; + align-items: flex-start; + gap: 10px; + padding: 8px 0; + cursor: pointer; + font-size: 0.88rem; + color: var(--color-text); +} + +.setting-row input[type="checkbox"] { + margin-top: 3px; + width: 16px; + height: 16px; + accent-color: var(--color-accent); + flex: 0 0 auto; +} + +.setting-hint { + font-size: 0.78rem; + color: var(--color-muted); +} + +.setting-grid { + display: flex; + gap: 16px; + padding: 6px 0 10px 26px; + font-size: 0.85rem; + color: var(--color-text); +} + +.setting-grid label { + display: flex; + align-items: center; + gap: 6px; +} + +.setting-num { + width: 72px; +} + +.setting-meta { + font-size: 0.76rem; + color: var(--color-muted); + font-family: var(--font-mono); + margin: 2px 0; + word-break: break-all; +} + /* ---- full book nav ---- */ .full-book-nav { margin-top: var(--gap); diff --git a/src/renderer/index.html b/src/renderer/index.html index a052ce3..9e7a5e8 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -30,6 +30,7 @@