Add document/editor zoom controls

- Persist an app-wide zoom percentage (not per book) and apply it only to the
  writing view (.editor-panes) so the sidebar/navigation stay fixed size
- Add a [-] [100%] [+] control to the chapter and Full Book headers, with a
  click-to-reset level button
- Route Ctrl/Cmd+=, Ctrl/Cmd+-, and Ctrl/Cmd+0 to zoom in/out/reset,
  intercepting the browser's native zoom
This commit is contained in:
avi 2026-08-20 11:25:28 -05:00
commit 7bdddf32b2
5 changed files with 110 additions and 0 deletions

View file

@ -143,6 +143,16 @@ function forwardEditShortcuts(win: BrowserWindow) {
} else if ((key === "z" && input.shift) || (key === "y" && !input.shift)) {
event.preventDefault();
win.webContents.send("folio:redo-requested");
} else if (key === "=" || key === "+") {
// Ctrl/Cmd+= or Ctrl/Cmd++ → zoom in (intercept the browser's native zoom).
event.preventDefault();
win.webContents.send("folio:zoom-in-requested");
} else if (key === "-") {
event.preventDefault();
win.webContents.send("folio:zoom-out-requested");
} else if (key === "0") {
event.preventDefault();
win.webContents.send("folio:zoom-reset-requested");
}
});
}
@ -548,6 +558,18 @@ ipcMain.handle("folio:setTheme", (_e, id: unknown) => {
return { ok: true };
});
// Editor zoom preference (percentage). Persisted app-wide, not per book, so the
// writing view stays at the user's chosen size across books and restarts.
ipcMain.handle("folio:getZoom", () => {
const z = getSetting<number>("zoom", 100);
return Number.isFinite(z) && z > 0 ? Math.round(z) : 100;
});
ipcMain.handle("folio:setZoom", (_e, pct: unknown) => {
const z = typeof pct === "number" && Number.isFinite(pct) ? Math.round(pct) : 100;
setSetting("zoom", z);
return { ok: true };
});
ipcMain.handle("folio:importAttachment", async () => {
const bp = requireBook();
const { canceled, filePaths } = await showOpenDialog({

View file

@ -46,6 +46,8 @@ contextBridge.exposeInMainWorld("folio", {
copyText: (text: string) => ipcRenderer.invoke("folio:copyText", text),
getClipboardText: () => ipcRenderer.invoke("folio:getClipboardText"),
toggleFullscreen: () => ipcRenderer.invoke("folio:toggleFullscreen"),
getZoom: () => ipcRenderer.invoke("folio:getZoom"),
setZoom: (pct: number) => ipcRenderer.invoke("folio:setZoom", pct),
// theme preference
getTheme: () => ipcRenderer.invoke("folio:getTheme"),
@ -84,4 +86,10 @@ contextBridge.exposeInMainWorld("folio", {
ipcRenderer.on("folio:selectAll-requested", () => cb()),
onSaveRequested: (cb: () => void) =>
ipcRenderer.on("folio:save-requested", () => cb()),
onZoomInRequested: (cb: () => void) =>
ipcRenderer.on("folio:zoom-in-requested", () => cb()),
onZoomOutRequested: (cb: () => void) =>
ipcRenderer.on("folio:zoom-out-requested", () => cb()),
onZoomResetRequested: (cb: () => void) =>
ipcRenderer.on("folio:zoom-reset-requested", () => cb()),
});

View file

@ -51,6 +51,8 @@ export type FolioAPI = {
toggleFullscreen: () => Promise<boolean>;
getTheme: () => Promise<string>;
setTheme: (id: string) => Promise<any>;
getZoom: () => Promise<number>;
setZoom: (pct: number) => Promise<any>;
onBookOpened: (cb: (data: { path: string; title: string }) => void) => void;
onBookClosed: (cb: () => void) => void;
onChaptersChanged: (cb: () => void) => void;
@ -67,6 +69,9 @@ export type FolioAPI = {
onRedoRequested: (cb: () => void) => void;
onSelectAllRequested: (cb: () => void) => void;
onSaveRequested: (cb: () => void) => void;
onZoomInRequested: (cb: () => void) => void;
onZoomOutRequested: (cb: () => void) => void;
onZoomResetRequested: (cb: () => void) => void;
};
const $ = (id: string) => document.getElementById(id) as HTMLElement;
@ -1334,6 +1339,45 @@ export function initBook(api: FolioAPI) {
});
api.onSaveRequested(() => void saveAndNotify());
// ---- editor zoom (writing view only) ----
// Zoom scales the document content area (the .editor-panes containers) so the
// sidebar/navigation keep their normal size. The chosen level is persisted
// app-wide and re-applied on launch.
let currentZoom = 100;
function applyZoom(pct: number, persist = true) {
const clamped = Math.max(50, Math.min(300, Math.round(pct)));
currentZoom = clamped;
document.querySelectorAll<HTMLElement>(".editor-panes").forEach((el) => {
el.style.zoom = String(clamped / 100);
});
document.querySelectorAll<HTMLElement>(".zoom-level").forEach((el) => {
el.textContent = clamped + "%";
});
if (persist) void api.setZoom(clamped);
}
document.querySelectorAll<HTMLElement>(".zoom-control").forEach((ctrl) => {
ctrl
.querySelector('[data-zoom="out"]')
?.addEventListener("click", () => applyZoom(currentZoom - 10));
ctrl
.querySelector('[data-zoom="in"]')
?.addEventListener("click", () => applyZoom(currentZoom + 10));
ctrl
.querySelector('[data-zoom="reset"]')
?.addEventListener("click", () => applyZoom(100));
});
api.onZoomInRequested(() => applyZoom(currentZoom + 10));
api.onZoomOutRequested(() => applyZoom(currentZoom - 10));
api.onZoomResetRequested(() => applyZoom(100));
void (async () => {
try {
const z = await api.getZoom();
if (typeof z === "number" && z > 0) applyZoom(z, false);
} catch {
/* keep default 100% */
}
})();
// Ctrl/Cmd+S saves the current chapter (in addition to the menu item).
window.addEventListener("keydown", (e) => {
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "s") {

View file

@ -526,6 +526,32 @@ body.dragging-active * {
border-color: var(--color-on-accent-border);
}
/* Editor zoom controls (writing view only; sidebar/nav stay fixed size). */
.zoom-control {
display: inline-flex;
align-items: center;
gap: 2px;
}
.zoom-control .icon-btn {
font-size: 1rem;
line-height: 1;
}
.zoom-control .zoom-level {
min-width: 50px;
border: 1px solid var(--color-border);
background: transparent;
color: var(--color-text);
border-radius: calc(var(--radius) - 4px);
font-size: 0.78rem;
padding: 2px 4px;
cursor: pointer;
text-align: center;
}
.zoom-control .zoom-level:hover {
background: var(--color-on-accent-soft);
border-color: var(--color-on-accent-border);
}
/* Small accent dot shown next to a chapter's title when it has a color. */
.row-color-dot {
flex: 0 0 auto;

View file

@ -62,6 +62,11 @@
<span class="word-count" id="wordCount">words: 0</span>
<button class="icon-button" id="exportBtn" title="Export book (Markdown)">⬇</button>
<button class="icon-button" id="exportZipBtn" title="Export bundle (ZIP)">📦</button>
<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>
<button class="icon-btn" type="button" data-zoom="in" title="Zoom in (Ctrl+=)">&#43;</button>
</div>
</div>
</header>
<div class="editor-toolbar" id="editorToolbar">
@ -230,6 +235,11 @@
</div>
</div>
<button class="button button-primary" id="copyAllBtn" title="Copy the entire assembled manuscript as Markdown">Copy All</button>
<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>
<button class="icon-btn" type="button" data-zoom="in" title="Zoom in (Ctrl+=)">&#43;</button>
</div>
<span class="save-state" id="fullBookStatus"></span>
</div>
</header>