diff --git a/.gitignore b/.gitignore index ff81fda..f85fa32 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ dist/ *.asar releases/ .DS_Store +.directory tests/.tmp-folio-format-*.mjs .opencode/ diff --git a/electron-builder.yml b/electron-builder.yml index 029025c..d8f3b67 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -22,6 +22,25 @@ linux: - AppImage - deb +# OS file associations: double-click / "Open with Folio" on Markdown and +# plain-text files imports them as books (see folio:openPathOrImport and +# fileArgFrom in src/main/index.ts). Without this the desktop environment +# never offers Folio for .txt/.md files. +fileAssociations: + - ext: + - md + - markdown + name: Folio Markdown document + description: Markdown document opened as a Folio book + role: Editor + mimeType: text/markdown + - ext: + - txt + name: Folio text document + description: Plain-text document opened as a Folio book + role: Editor + mimeType: text/plain + # Debian package options live at the config root in electron-builder v26. # Setting `depends` replaces the auto-generated list, so keep its entries and # add what Electron links but the defaults omit — notably libasound2 (ALSA), diff --git a/package.json b/package.json index 747ffd3..50efb29 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,7 @@ "dev": "concurrently -k \"npm run build:watch\" \"wait-on dist/main.js && npm run electron\"", "start": "npm run build && npm run electron", "lint": "tsc --noEmit", - "test": "node tests/run-project-test.mjs && node tests/run-chapters-test.mjs && node tests/run-wikilinks-test.mjs && node tests/run-office-export-test.mjs && node tests/run-migration-test.mjs && node tests/run-editor-test.mjs && node tests/run-fullbook-test.mjs && node tests/run-print-test.mjs", + "test": "node tests/run-project-test.mjs && node tests/run-chapters-test.mjs && node tests/run-wikilinks-test.mjs && node tests/run-office-export-test.mjs && node tests/run-migration-test.mjs && node tests/run-editor-test.mjs && node tests/run-fullbook-test.mjs && node tests/run-print-test.mjs && node tests/run-update-import-test.mjs && node tests/run-update-installer-test.mjs && node tests/run-snapshots-test.mjs && node tests/run-search-test.mjs", "package": "electron-builder" }, "devDependencies": { 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/import-md.ts b/src/main/import-md.ts new file mode 100644 index 0000000..968cfad --- /dev/null +++ b/src/main/import-md.ts @@ -0,0 +1,56 @@ +// Import standalone Markdown files as Folio books. +// +// Electron-free on purpose (like backup.ts): takes the file list and the +// destination root as arguments so the logic is unit-testable without an +// app instance. One book per file, named after the file's first H1 (or its +// filename when there is no heading). The original file is never modified — +// its content is copied into the new book's chapter. +import fs from "fs"; +import path from "path"; +import { createBook, sanitizeBookFolderName } from "./project.js"; +import { createChapter, setChapterContent } from "./chapters.js"; + +export interface ImportResult { + ok: boolean; + file: string; + bookPath?: string; + title?: string; + error?: string; +} + +const MD_EXT = /\.(md|markdown|txt)$/i; + +export function isImportableMarkdown(file: string): boolean { + return MD_EXT.test(file); +} + +// Title = first ATX H1 ("# Title"), else the filename stem. +export function mdTitle(raw: string, fallback: string): string { + const m = /^[ \t]*#[ \t]+(.+?)[ \t]*#*[ \t]*$/m.exec(raw); + const t = (m && m[1] ? m[1] : fallback).trim(); + return t || fallback; +} + +export function importMarkdownFiles(files: string[], root: string): ImportResult[] { + return files.map((file) => { + try { + const raw = fs.readFileSync(file, "utf-8"); + const stem = path.basename(file).replace(MD_EXT, "") || "Untitled"; + const title = mdTitle(raw, stem); + const folder = + sanitizeBookFolderName(title) || sanitizeBookFolderName(stem) || "Imported"; + let dir = path.join(root, folder); + let n = 2; + while (fs.existsSync(dir)) { + dir = path.join(root, `${folder} ${n}`); + n += 1; + } + createBook(dir, title); + const entry = createChapter(dir, title); + setChapterContent(dir, entry.id, raw); + return { ok: true, file, bookPath: dir, title }; + } catch (e) { + return { ok: false, file, error: (e as Error).message }; + } + }); +} diff --git a/src/main/index.ts b/src/main/index.ts index 3b7b272..8c565f2 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1,4 +1,4 @@ -import { app, BrowserWindow, clipboard, dialog, ipcMain, Menu, shell } from "electron"; +import { app, BrowserWindow, clipboard, dialog, ipcMain, Menu, session, shell } from "electron"; import type { OpenDialogOptions, SaveDialogOptions } from "electron"; import fs from "fs"; import path from "path"; @@ -34,14 +34,32 @@ 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"; import { getSetting, setSetting } from "./settings.js"; import { migrateChapterToNative, migrateBookToNative } from "./migration.js"; import { loadChapterDoc, saveChapterDoc } from "./chapters.js"; +import { importMarkdownFiles, isImportableMarkdown } from "./import-md.js"; +import { + INSTALL_DIR_DEFAULT, + LAUNCHER_DEFAULT, + UPDATE_BASE_DEFAULT, + UPDATE_REPO, + buildInstallerScript, + isTrustedDownloadUrl, + isNewer, + parseRelease, + pickAsset, +} from "./update.js"; let mainWindow: BrowserWindow | null = null; let bookPath: string | null = null; @@ -68,8 +86,8 @@ function createWindow() { mainWindow = new BrowserWindow({ width: 1280, height: 780, - minWidth: 960, - minHeight: 640, + minWidth: 560, + minHeight: 400, title: "Folio", icon: path.join(__dirname, "icons", "icon.png"), webPreferences: { @@ -79,6 +97,7 @@ function createWindow() { }, }); forwardEditShortcuts(mainWindow); + wireContextMenu(mainWindow); mainWindow.loadFile(path.join(__dirname, "index.html")); mainWindow.webContents.on("did-finish-load", () => { console.log("[folio] renderer loaded"); @@ -109,6 +128,15 @@ function createWindow() { app.whenReady().then(() => { migrateLegacyRecents(); + // English-only spellcheck dictionary + stored on/off preference. + try { + session.defaultSession.setSpellCheckerLanguages(["en-US"]); + if (getSetting("spellcheckEnabled", true) === false) { + session.defaultSession.setSpellCheckerEnabled(false); + } + } catch (err) { + console.error("[folio] spellchecker setup failed:", (err as Error).message); + } buildMenu( getWindow, getState, @@ -116,6 +144,13 @@ app.whenReady().then(() => { () => getWindow()?.webContents.send("folio:open-book-requested") ); createWindow(); + // Open a book folder or Markdown file passed on the command line + // (`folio notes.md`, or file-association launches from the desktop). + const initialFile = fileArgFrom(process.argv); + if (initialFile) { + const win = getWindow(); + win?.webContents.once("did-finish-load", () => openFromArg(initialFile)); + } app.on("activate", () => { if (BrowserWindow.getAllWindows().length === 0) createWindow(); }); @@ -685,19 +720,57 @@ handleIpc("folio:toggleFullscreen", () => { return win.isFullScreen(); }); -// Right-click context menu (Electron has none by default), so Cut/Copy/Paste -// via the mouse work. The roles act on whatever element is focused. -ipcMain.on("folio:context-menu", (event, x: number, y: number) => { - const win = BrowserWindow.fromWebContents(event.sender); - if (!win) return; - const menu = Menu.buildFromTemplate([ - { role: "cut" }, - { role: "copy" }, - { role: "paste" }, - { type: "separator" }, - { role: "selectAll" }, - ]); - menu.popup({ window: win, x, y }); +// Right-click menu with spelling suggestions (English-only dictionary). +// Electron builds no default context menu, so without this the red +// misspelling underlines had no way to reach a suggestion. Chromium reports +// the misspelled word + candidates via the webContents event; suggestions +// replace the word in place, and "Add to dictionary" persists to the +// session's custom words. +function wireContextMenu(win: BrowserWindow) { + win.webContents.on("context-menu", (_e, params) => { + const target = getWindow(); + if (!target) return; + const items: Electron.MenuItemConstructorOptions[] = []; + if (params.misspelledWord) { + const sugs = (params.dictionarySuggestions || []).slice(0, 5); + if (sugs.length) { + for (const s of sugs) { + items.push({ label: s, click: () => target.webContents.replaceMisspelling(s) }); + } + } else { + items.push({ label: "(no suggestions)", enabled: false }); + } + items.push({ + label: "Add to dictionary", + click: () => + target.webContents.session.addWordToSpellCheckerDictionary(params.misspelledWord), + }); + items.push({ type: "separator" }); + } + items.push( + { role: "cut" }, + { role: "copy" }, + { role: "paste" }, + { type: "separator" }, + { role: "selectAll" } + ); + Menu.buildFromTemplate(items).popup({ window: target, x: params.x, y: params.y }); + }); +} + +// Spellcheck preference (English-only dictionary). Applied at startup and +// toggled live from Settings; the editor's spellcheck attribute stays on so +// Chromium underlines misspellings whenever the session checker is enabled. +handleIpc("folio:getSpellcheck", () => getSetting("spellcheckEnabled", true)); +handleIpc("folio:setSpellcheck", (_e, on: unknown) => { + const enabled = on !== false; + setSetting("spellcheckEnabled", enabled); + try { + session.defaultSession.setSpellCheckerEnabled(enabled); + } catch { + // applied on next launch if the session is not ready yet + } + return { ok: true, enabled }; }); // Theme preference. Stored as a plain string so future themes can be added @@ -733,6 +806,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({ @@ -756,3 +941,237 @@ handleIpc("folio:importAttachment", async () => { const relPath = path.relative(bp, path.join(dir, name)).split(path.sep).join("/"); return { ok: true, name, relPath }; }); + +// ---- Markdown import ------------------------------------------------------ + +// Import standalone .md files as Folio books (one book per file, named after +// the file's first H1 or its filename). Files are copied — the originals are +// never modified. Books land in Documents, same as "New". +handleIpc("folio:importMarkdown", async () => { + const { canceled, filePaths } = await showOpenDialog({ + title: "Open Markdown or text file(s)", + properties: ["openFile", "multiSelections"], + filters: [{ name: "Markdown and text", extensions: ["md", "markdown", "txt"] }], + }); + if (canceled || !filePaths.length) return { canceled: true } as const; + return importMarkdownFiles(filePaths, app.getPath("documents")); +}); + +// Open a path that may be either a Folio book directory or a Markdown file +// (the desktop launcher can be handed either via file associations). +handleIpc("folio:openPathOrImport", async (_e, p: string) => { + if (p && isImportableMarkdown(p)) { + const results = importMarkdownFiles([p], app.getPath("documents")); + const r = results[0]; + if (!r.ok) return { error: r.error }; + return openBookAt(r.bookPath as string); + } + return openBookAt(p); +}); + +// ---- In-app update -------------------------------------------------------- + +// Release source. Defaults to the project's Gitea instance; the installed +// tree may override it with update.json next to the binary (e.g. for +// staging), and FOLIO_UPDATE_BASE/FOLIO_UPDATE_REPO override everything. +function updateConfig(): { base: string; repo: string; installDir: string; launcher: string } { + const cfg = { + base: UPDATE_BASE_DEFAULT, + repo: UPDATE_REPO, + installDir: INSTALL_DIR_DEFAULT, + launcher: LAUNCHER_DEFAULT, + }; + try { + const p = path.join(cfg.installDir, "update.json"); + if (fs.existsSync(p)) { + const j = JSON.parse(fs.readFileSync(p, "utf-8")); + if (typeof j.base === "string") cfg.base = j.base.replace(/\/+$/, ""); + if (typeof j.repo === "string") cfg.repo = j.repo; + if (typeof j.launcher === "string") cfg.launcher = j.launcher; + } + } catch { + /* malformed update.json: keep defaults */ + } + if (process.env.FOLIO_UPDATE_BASE) cfg.base = process.env.FOLIO_UPDATE_BASE.replace(/\/+$/, ""); + if (process.env.FOLIO_UPDATE_REPO) cfg.repo = process.env.FOLIO_UPDATE_REPO; + return cfg; +} + +interface UpdateCheckResult { + current: string; + latest?: string; + tag?: string; + updateAvailable?: boolean; + assetName?: string; + notes?: string; + url?: string; + canInstall?: boolean; + error?: string; +} + +async function fetchLatestRelease(): Promise> { + const { base, repo } = updateConfig(); + const res = await fetch(`${base}/api/v1/repos/${repo}/releases/latest`, { + headers: { accept: "application/json" }, + signal: AbortSignal.timeout(10000), + }); + if (res.status === 404) throw new Error("No published releases yet."); + if (!res.ok) throw new Error(`Release server returned HTTP ${res.status}.`); + return parseRelease(await res.json()); +} + +handleIpc("folio:checkUpdate", async (): Promise => { + try { + const rel = await fetchLatestRelease(); + const asset = pickAsset(rel.assets); + const current = app.getVersion(); + return { + current, + latest: rel.version, + tag: rel.tag, + updateAvailable: isNewer(rel.version, current), + assetName: asset ? asset.name : undefined, + notes: rel.notes, + url: rel.url, + canInstall: + !!asset && + isTrustedDownloadUrl(asset.browser_download_url, updateConfig().base) && + process.platform === "linux" && + fs.existsSync(path.join(updateConfig().installDir, "folio")), + }; + } catch (e) { + return { current: app.getVersion(), error: (e as Error).message }; + } +}); + +// Download the release tarball to the OS temp dir, verify it looks like a +// gzip stream, then hand off to the detached swap script and quit. The old +// install is kept until the swap succeeds, so failure can never strand the +// user without a working app. +handleIpc("folio:performUpdate", async () => { + try { + const cfg = updateConfig(); + const rel = await fetchLatestRelease(); + const asset = pickAsset(rel.assets); + if (!asset) return { error: `Release ${rel.tag} has no tar.gz asset to install.` }; + if (!isTrustedDownloadUrl(asset.browser_download_url, cfg.base)) { + return { error: "Download URL is not on the trusted release host; update aborted." }; + } + if (!fs.existsSync(path.join(cfg.installDir, "folio"))) { + return { + error: + "This Folio is not the user-local install (~/.local/share/Folio), so in-place update is unavailable.", + }; + } + const tarball = path.join( + app.getPath("temp"), + `folio-update-${Date.now()}.tar.gz` + ); + const res = await fetch(asset.browser_download_url, { + redirect: "follow", + signal: AbortSignal.timeout(10 * 60 * 1000), + }); + if (!res.ok || !res.body) { + return { error: `Download failed (HTTP ${res.status}).` }; + } + const total = Number(res.headers.get("content-length")) || asset.size || 0; + const { Readable } = await import("stream"); + const { createWriteStream } = await import("fs"); + const { pipeline } = await import("stream/promises"); + let received = 0; + const source = Readable.fromWeb(res.body as never); + source.on("data", (chunk: Buffer) => { + received += chunk.length; + getWindow()?.webContents.send("folio:update-progress", { received, total }); + }); + await pipeline(source, createWriteStream(tarball)); + // Verify the gzip magic (2 bytes) without loading a ~100MB tarball into + // memory. Anything else means a truncated download or an HTML error page. + let head = Buffer.alloc(0); + try { + const fd = fs.openSync(tarball, "r"); + try { + const buf = Buffer.alloc(2); + const n = fs.readSync(fd, buf, 0, 2, 0); + head = buf.subarray(0, n); + } finally { + fs.closeSync(fd); + } + } catch { + head = Buffer.alloc(0); + } + if (head.length < 2 || head[0] !== 0x1f || head[1] !== 0x8b) { + fs.rmSync(tarball, { force: true }); + return { error: "Downloaded file is not a valid archive; update aborted." }; + } + const script = path.join( + app.getPath("temp"), + `folio-update-${process.pid}.sh` + ); + fs.writeFileSync( + script, + buildInstallerScript({ + pid: process.pid, + tarball, + installDir: cfg.installDir, + relaunch: cfg.launcher, + }), + { mode: 0o700 } + ); + const { spawn } = await import("child_process"); + const child = spawn("/bin/sh", [script], { detached: true, stdio: "ignore" }); + child.on("error", (err) => { + console.error("[folio] updater spawn failed:", err.message); + }); + child.unref(); + // Quit so the swap script can replace the app directory. + setTimeout(() => app.quit(), 300); + return { ok: true, version: rel.version }; + } catch (e) { + return { error: (e as Error).message }; + } +}); + +// ---- Files passed on the command line / file associations ----------------- + +// Pull the first usable file path out of argv (the desktop launcher forwards +// %F; `folio path/to/book` or `folio notes.md` both work). +function fileArgFrom(argv: string[]): string | null { + for (const a of argv.slice(1)) { + if (!a || a.startsWith("-")) continue; + try { + if (fs.statSync(a).isFile() || hasBook(a)) return path.resolve(a); + } catch { + /* not a readable path; ignore */ + } + } + return null; +} + +function openFromArg(p: string): void { + if (isImportableMarkdown(p)) { + const results = importMarkdownFiles([p], app.getPath("documents")); + const r = results[0]; + if (r.ok) { + openBookAt(r.bookPath as string); + return; + } + console.error(`[folio] import failed for ${p}: ${r.error}`); + return; + } + void openBookAt(p); +} + +// Single instance: opening a second file (double-clicking while Folio is +// already open) forwards the path to the running window instead of stacking +// another app instance. +if (app.requestSingleInstanceLock()) { + app.on("second-instance", (_e, argv) => { + const f = fileArgFrom(argv); + if (mainWindow) { + if (mainWindow.isMinimized()) mainWindow.restore(); + mainWindow.focus(); + } + if (f) openFromArg(f); + }); +} diff --git a/src/main/menu.ts b/src/main/menu.ts index f5189fa..7964a22 100644 --- a/src/main/menu.ts +++ b/src/main/menu.ts @@ -1,8 +1,7 @@ import path from "path"; -import { app, dialog, Menu, BrowserWindow } from "electron"; +import { app, Menu, BrowserWindow } from "electron"; import { listRecents } from "./recents.js"; import type { Recent } from "./recents.js"; -import { setRecoveryRoot } from "./backup.js"; type MenuItem = Electron.MenuItemConstructorOptions; type GetState = () => { bookPath: string | null }; @@ -38,6 +37,10 @@ export function buildMenu( submenu: [ { label: "New", accelerator: "CmdOrCtrl+N", click: onRequestNew }, { label: "Open", accelerator: "CmdOrCtrl+O", click: onRequestOpen }, + { + label: "Open File…", + click: () => getWindow()?.webContents.send("folio:open-markdown-requested"), + }, { type: "separator" }, { label: "Save", @@ -71,18 +74,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" }, { @@ -113,6 +107,11 @@ export function buildMenu( click: () => getWindow()?.webContents.send("folio:export-odt-requested"), }, { type: "separator" }, + { + label: "Check for Updates…", + click: () => getWindow()?.webContents.send("folio:check-update-requested"), + }, + { type: "separator" }, { label: "Close", accelerator: isMac ? "Cmd+W" : "Ctrl+W", diff --git a/src/main/preload.ts b/src/main/preload.ts index 7431d15..5b36446 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -10,8 +10,10 @@ contextBridge.exposeInMainWorld("folio", { revealInFolder: (p: string) => ipcRenderer.invoke("folio:reveal", p), getRecents: () => ipcRenderer.invoke("folio:getRecents"), closeBook: () => ipcRenderer.invoke("folio:closeBook"), - showContextMenu: (x: number, y: number) => - ipcRenderer.send("folio:context-menu", x, y), + + // spelling (English dictionary, suggestions in the native context menu) + getSpellcheck: () => ipcRenderer.invoke("folio:getSpellcheck"), + setSpellcheck: (on: boolean) => ipcRenderer.invoke("folio:setSpellcheck", on), // chapters getBookMeta: () => ipcRenderer.invoke("folio:getBookMeta"), @@ -54,6 +56,12 @@ contextBridge.exposeInMainWorld("folio", { exportOdt: (chapterIds?: string[] | null) => ipcRenderer.invoke("folio:exportOdt", chapterIds), importAttachment: () => ipcRenderer.invoke("folio:importAttachment"), + openMarkdownFiles: () => ipcRenderer.invoke("folio:importMarkdown"), + openPathOrImport: (p: string) => ipcRenderer.invoke("folio:openPathOrImport", p), + checkUpdate: () => ipcRenderer.invoke("folio:checkUpdate"), + performUpdate: () => ipcRenderer.invoke("folio:performUpdate"), + onUpdateProgress: (cb: (p: { received: number; total: number }) => void) => + ipcRenderer.on("folio:update-progress", (_e, data) => cb(data)), copyText: (text: string) => ipcRenderer.invoke("folio:copyText", text), getClipboardText: () => ipcRenderer.invoke("folio:getClipboardText"), toggleFullscreen: () => ipcRenderer.invoke("folio:toggleFullscreen"), @@ -69,6 +77,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)), @@ -110,4 +127,10 @@ contextBridge.exposeInMainWorld("folio", { ipcRenderer.on("folio:zoom-out-requested", () => cb()), onZoomResetRequested: (cb: () => void) => ipcRenderer.on("folio:zoom-reset-requested", () => cb()), + onOpenMarkdownRequested: (cb: () => void) => + 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/main/update.ts b/src/main/update.ts new file mode 100644 index 0000000..0ea6423 --- /dev/null +++ b/src/main/update.ts @@ -0,0 +1,173 @@ +// In-app update support for the user-local install of Folio. +// +// Architecture: a release published on the Gitea instance carries a +// linux tar.gz asset (the unpacked Electron app). "Install update" +// downloads that tarball, then hands off to a tiny detached shell script +// which waits for this process to exit, swaps ~/.local/share/Folio with the +// new contents (keeping a .old copy until the swap succeeds), and relaunches +// the user launcher. No root, no package manager, no reinstall. +// +// The pure logic lives here (Electron-free) so it is unit-testable; index.ts +// only wires IPC, fetch, and spawn around it. +import path from "path"; + +export const UPDATE_BASE_DEFAULT = "https://git.atitlan.io"; +export const UPDATE_REPO = "avi/Folio"; +export const INSTALL_DIR_DEFAULT = path.join( + process.env.HOME || "~", + ".local", + "share", + "Folio" +); +export const LAUNCHER_DEFAULT = path.join( + process.env.HOME || "~", + ".local", + "bin", + "folio" +); + +export interface ReleaseAsset { + name: string; + browser_download_url: string; + size?: number; +} + +export interface ReleaseInfo { + tag: string; + version: string; + url?: string; + notes?: string; + assets: ReleaseAsset[]; +} + +// "v0.1.2" / "0.1.2-rc1" / "0.1.2" -> [0,1,2]; null when unparseable. +export function parseVersion(v: string): number[] | null { + const m = /^v?(\d+)\.(\d+)\.(\d+)/.exec(String(v || "").trim()); + return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null; +} + +export function compareVersions(a: string, b: string): number { + const va = parseVersion(a); + const vb = parseVersion(b); + if (!va || !vb) return 0; + for (let i = 0; i < 3; i++) { + if (va[i] !== vb[i]) return va[i] < vb[i] ? -1 : 1; + } + return 0; +} + +export function isNewer(latest: string, current: string): boolean { + return compareVersions(latest, current) > 0; +} + +// Normalize a Gitea release JSON object. Throws when the payload has no tag +// (including Gitea's error objects, which carry a message but no tag_name). +export function parseRelease(json: any): ReleaseInfo { + const tag = json && typeof json.tag_name === "string" ? json.tag_name : ""; + if (!tag) { + const msg = json && typeof json.message === "string" ? json.message : "invalid response"; + throw new Error(`No release found (${msg})`); + } + const assets: ReleaseAsset[] = Array.isArray(json.assets) + ? json.assets + .filter( + (a: any) => + a && + typeof a.name === "string" && + typeof a.browser_download_url === "string" + ) + .map((a: any) => ({ + name: a.name, + browser_download_url: a.browser_download_url, + size: typeof a.size === "number" ? a.size : undefined, + })) + : []; + const version = (parseVersion(tag) ? tag.replace(/^v/, "") : "").trim(); + return { + tag, + version: version || tag, + url: typeof json.html_url === "string" ? json.html_url : undefined, + notes: typeof json.body === "string" ? json.body : undefined, + assets, + }; +} + +// Choose the update asset: the linux tar.gz (preferred, unpackable without +// FUSE). AppImage/deb assets exist but the swap logic here installs the +// unpacked tree, so only tar.gz is eligible. +export function pickAsset(assets: ReleaseAsset[]): ReleaseAsset | null { + const tarballs = assets.filter((a) => /\.tar\.gz$/.test(a.name)); + const linux = tarballs.find( + (a) => /linux/i.test(a.name) && /x86_64|amd64/i.test(a.name) + ); + return linux || tarballs[0] || null; +} + +// The download URL must live on the same origin as the configured update +// base — this blocks a malicious release body from redirecting the installer +// to arbitrary hosts. +export function isTrustedDownloadUrl(url: string, base: string): boolean { + try { + const u = new URL(url); + const b = new URL(base); + return u.protocol === b.protocol && u.host === b.host; + } catch { + return false; + } +} + +// Single-quote a path for safe interpolation into /bin/sh scripts. +export function shQuote(p: string): string { + return `'${String(p).replace(/'/g, `'\\''`)}'`; +} + +export interface InstallerSpec { + pid: number; + tarball: string; + installDir: string; + relaunch: string; +} + +// The handoff script. Deliberately forgiving (no set -e): on any failure it +// still relaunches the OLD version, so a bad download can never leave the +// user without an app. The old tree is kept as .old. and pruned only +// after a successful swap. +export function buildInstallerScript(spec: InstallerSpec): string { + const { pid, tarball, installDir, relaunch } = spec; + return [ + "#!/bin/sh", + "# Folio updater — waits for the app to exit, swaps in the new version,", + "# then relaunches. Generated by Folio; safe to delete after one run.", + `PID=${Number(pid) || -1}`, + `TARBALL=${shQuote(tarball)}`, + `INSTALL=${shQuote(installDir)}`, + `RELAUNCH=${shQuote(relaunch)}`, + `NEW="$INSTALL.update-new.$$"`, + `OLD="$INSTALL.old.$$"`, + "# Wait (bounded to ~2 minutes) for the app process to exit.", + "i=0", + 'while [ "$PID" -gt 0 ] 2>/dev/null && kill -0 "$PID" 2>/dev/null && [ "$i" -lt 400 ]; do sleep 0.3; i=$((i+1)); done', + 'rm -rf "$NEW"', + 'mkdir -p "$NEW"', + 'if tar -xzf "$TARBALL" -C "$NEW"; then', + ' # electron-builder nests everything under a top-level dir; flatten it.', + ' if [ ! -e "$NEW/folio" ]; then', + ' ONLY=$(find "$NEW" -mindepth 1 -maxdepth 1 -type d | head -n 1)', + ' [ -n "$ONLY" ] && mv "$ONLY"/* "$NEW"/ 2>/dev/null', + " fi", + "fi", + 'if [ -x "$NEW/folio" ]; then', + ' [ -e "$INSTALL" ] && mv "$INSTALL" "$OLD"', + ' if mv "$NEW" "$INSTALL"; then', + ' rm -rf "$OLD" "$INSTALL.update-new".* "$INSTALL.old".* 2>/dev/null', + ' rm -f "$TARBALL"', + " else", + ' [ -e "$OLD" ] && mv "$OLD" "$INSTALL"', + " fi", + "else", + ' rm -rf "$NEW"', + "fi", + '"$RELAUNCH" >/dev/null 2>&1 &', + "", + ].join("\n"); +} diff --git a/src/renderer/book/chapter-list.ts b/src/renderer/book/chapter-list.ts index f7fd273..538791e 100644 --- a/src/renderer/book/chapter-list.ts +++ b/src/renderer/book/chapter-list.ts @@ -41,6 +41,7 @@ export function createChapterListFeature(ctx: BookCtx): ChapterListFeature { "#3b82f6", "#8b5cf6", "#78716c", "#18181b", ]; const HEX_RE = /^#[0-9a-fA-F]{3,8}$/; + let animateNextRender = false; let colorPopover: HTMLDivElement | null = null; function closeColorPopover() { @@ -150,7 +151,6 @@ export function createChapterListFeature(ctx: BookCtx): ChapterListFeature { li.draggable = false; li.tabIndex = -1; li.innerHTML = ` - ⋮ ${ch.title} @@ -299,12 +299,29 @@ export function createChapterListFeature(ctx: BookCtx): ChapterListFeature { } } - async function persistOrder() { + async function persistOrder(scrollSelected = false) { if (!state.meta) return; state.meta.chapterOrder = state.ordered.map((c) => c.id); const res = await api.reorderChapters(state.meta.chapterOrder); if (res && "error" in res) await alertMessage("Could not reorder chapters", (res as { error: string }).error); - else renderChapterList(); + else { + renderChapterList(); + if (scrollSelected) keepSelectedVisible(); + } + } + + // After renderChapterList() rebuilds the list, DOM focus is lost and the + // moved row can end up outside the visible scroll area. Restore focus so + // repeated Alt+Arrow presses keep working, and scroll the selected row + // into view so it follows the move. + function keepSelectedVisible() { + if (!state.selectedId) return; + const row = chapterList.querySelector( + `li.chapter-row[data-id="${CSS.escape(state.selectedId)}"]` + ) as HTMLElement | null; + if (!row) return; + row.focus({ preventScroll: true }); + row.scrollIntoView({ block: "nearest" }); } function moveSelectedDown() { @@ -313,8 +330,10 @@ export function createChapterListFeature(ctx: BookCtx): ChapterListFeature { if (i < 0 || i >= state.ordered.length - 1) return; state.ordered = moveInList(state.ordered, i, i + 1); state.meta!.chapterOrder = state.ordered.map((c) => c.id); + animateNextRender = true; renderChapterList(); - void persistOrder(); + keepSelectedVisible(); + void persistOrder(true); } function moveSelectedUp() { @@ -323,8 +342,10 @@ export function createChapterListFeature(ctx: BookCtx): ChapterListFeature { if (i <= 0) return; state.ordered = moveInList(state.ordered, i, i - 1); state.meta!.chapterOrder = state.ordered.map((c) => c.id); + animateNextRender = true; renderChapterList(); - void persistOrder(); + keepSelectedVisible(); + void persistOrder(true); } // ---- pointer-based reordering ---- @@ -333,10 +354,65 @@ export function createChapterListFeature(ctx: BookCtx): ChapterListFeature { let dragStartY = 0; let dragMoved = false; let dragJustHappened = false; - let animateNextRender = false; let dragGhost: HTMLElement | null = null; let dragPlaceholder: HTMLElement | null = null; let dragOffsetY = 0; + let dragClientX = 0; + let dragClientY = 0; + let dragScrollRaf = 0; + + function updateDropTarget(clientX: number, clientY: number) { + const overEl = document.elementFromPoint(clientX, clientY); + const overLi = overEl?.closest("li.chapter-row") as HTMLElement | null; + chapterList.querySelectorAll(".chapter-row").forEach((n) => { + n.classList.remove("drop-target", "drop-before", "drop-after"); + }); + if (overLi && overLi.dataset.id !== draggingId) { + overLi.classList.add("drop-target"); + const before = clientY < overLi.getBoundingClientRect().top + overLi.offsetHeight / 2; + overLi.classList.add(before ? "drop-before" : "drop-after"); + if (dragPlaceholder) { + if (before) chapterList.insertBefore(dragPlaceholder, overLi); + else chapterList.insertBefore(dragPlaceholder, overLi.nextSibling); + } + } + } + + function stopDragScroll() { + if (dragScrollRaf) cancelAnimationFrame(dragScrollRaf); + dragScrollRaf = 0; + } + + // Continuously scroll the chapter list while the dragged ghost is held + // near its top/bottom edge, so chapters above/below the fold are reachable. + // pointermove alone is not enough: it stops firing when the pointer holds + // still, which is exactly when the user expects the list to keep scrolling. + function tickDragScroll() { + dragScrollRaf = 0; + if (!dragMoved || draggingId === null) return; + const rect = chapterList.getBoundingClientRect(); + const EDGE = 48; + const MAX_SPEED = 14; + let dy = 0; + if (dragClientY < rect.top + EDGE) { + const strength = Math.min(1, (rect.top + EDGE - dragClientY) / EDGE); + dy = -(2 + MAX_SPEED * strength); + } else if (dragClientY > rect.bottom - EDGE) { + const strength = Math.min(1, (dragClientY - (rect.bottom - EDGE)) / EDGE); + dy = 2 + MAX_SPEED * strength; + } + if (dy !== 0) { + chapterList.scrollTop += dy; + if (dragGhost) dragGhost.style.top = `${dragClientY - dragOffsetY}px`; + updateDropTarget(dragClientX, dragClientY); + } + dragScrollRaf = requestAnimationFrame(tickDragScroll); + } + + function startDragScroll() { + stopDragScroll(); + dragScrollRaf = requestAnimationFrame(tickDragScroll); + } chapterList.addEventListener("pointerdown", (e) => { if (e.button !== 0) return; // left button only @@ -353,6 +429,8 @@ export function createChapterListFeature(ctx: BookCtx): ChapterListFeature { document.addEventListener("pointermove", (e) => { if (dragPointerId === null || draggingId === null) return; + dragClientX = e.clientX; + dragClientY = e.clientY; if (!dragMoved) { if (Math.abs(e.clientY - dragStartY) < 4) return; // movement threshold dragMoved = true; @@ -375,27 +453,16 @@ export function createChapterListFeature(ctx: BookCtx): ChapterListFeature { src.replaceWith(dragPlaceholder); } document.body.classList.add("dragging-active"); + startDragScroll(); } if (dragGhost) dragGhost.style.top = `${e.clientY - dragOffsetY}px`; - const overEl = document.elementFromPoint(e.clientX, e.clientY); - const overLi = overEl?.closest("li.chapter-row") as HTMLElement | null; - chapterList.querySelectorAll(".chapter-row").forEach((n) => { - n.classList.remove("drop-target", "drop-before", "drop-after"); - }); - if (overLi && overLi.dataset.id !== draggingId) { - overLi.classList.add("drop-target"); - const before = e.clientY < overLi.getBoundingClientRect().top + overLi.offsetHeight / 2; - overLi.classList.add(before ? "drop-before" : "drop-after"); - if (dragPlaceholder) { - if (before) chapterList.insertBefore(dragPlaceholder, overLi); - else chapterList.insertBefore(dragPlaceholder, overLi.nextSibling); - } - } + updateDropTarget(e.clientX, e.clientY); }); document.addEventListener("pointerup", (e) => { if (dragPointerId === null) return; dragPointerId = null; + stopDragScroll(); document.body.classList.remove("dragging-active"); if (!dragMoved || !draggingId) { draggingId = null; @@ -437,6 +504,22 @@ export function createChapterListFeature(ctx: BookCtx): ChapterListFeature { draggingId = null; }); + // If the OS cancels the gesture (touch interruption, alert, etc.), + // stop scrolling and restore the list so no ghost/placeholder is left behind. + document.addEventListener("pointercancel", () => { + if (dragPointerId === null) return; + dragPointerId = null; + stopDragScroll(); + document.body.classList.remove("dragging-active"); + dragGhost?.remove(); + dragGhost = null; + dragPlaceholder?.remove(); + dragPlaceholder = null; + draggingId = null; + dragMoved = false; + renderChapterList(); + }); + addChapterBtn.addEventListener("click", () => void create()); return { diff --git a/src/renderer/book/editor.ts b/src/renderer/book/editor.ts index 38e8a4c..3474d82 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 { @@ -48,6 +51,45 @@ export function createEditorFeature(ctx: BookCtx): EditorFeature { }); const editorHost = $("editorHost"); + // ---- typewriter mode ---- + // Keeps the caret near 42% of the visible editor height by scrolling the + // ProseMirror surface (which is its own scroll container) as the selection + // moves. Renderer-local preference; nothing leaves the machine. + const TYPEWRITER_KEY = "folio:typewriter"; + let typewriterOn = false; + try { + typewriterOn = localStorage.getItem(TYPEWRITER_KEY) === "1"; + } catch { + typewriterOn = false; + } + function setTypewriterEnabled(on: boolean) { + typewriterOn = on !== false; + try { + localStorage.setItem(TYPEWRITER_KEY, typewriterOn ? "1" : "0"); + } catch { + // private-mode storage failure: mode still applies for the session + } + if (typewriterOn) centerCaret(); + } + function isTypewriterEnabled(): boolean { + return typewriterOn; + } + function centerCaret() { + if (!typewriterOn || !editor.isFocused) return; + if (!editor.state.selection.empty) return; + const scroller = editor.view.dom as HTMLElement; + const rect = scroller.getBoundingClientRect(); + if (rect.height <= 0) return; + let y = 0; + try { + y = editor.view.coordsAtPos(editor.state.selection.from).top; + } catch { + return; + } + const dy = y - (rect.top + rect.height * 0.42); + if (Math.abs(dy) > 2) scroller.scrollTop += dy; + } + // The ProseMirror surface is narrower than its host for readable line // lengths. Keep the surrounding editor area clickable so a new chapter can // be focused without requiring a click on the centered content column. @@ -63,6 +105,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 +208,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 +235,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); @@ -217,6 +278,8 @@ export function createEditorFeature(ctx: BookCtx): EditorFeature { setActive("align-left", fmt.align === "left" || fmt.align === ""); setActive("align-center", fmt.align === "center"); setActive("align-right", fmt.align === "right"); + // Defer to the next frame so coords reflect the updated layout. + if (typewriterOn) requestAnimationFrame(centerCaret); } // Ctrl/Cmd+S saves the current chapter (in addition to the menu item). @@ -359,5 +422,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 083de0c..c35df40 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); @@ -131,6 +139,56 @@ export function initBook(api: FolioAPI) { // The comments module owns its own DOM chrome and wiring; here we only keep the // top-level constructor call and the bridge assignments it already set above. + // ---- update flow ---- + // "Check for Updates…" (File menu / welcome button): query the release + // server, then offer a one-click install. The app downloads the tarball, + // hands off to a detached swap script, and relaunches itself — no manual + // reinstall. See src/main/update.ts for the safety properties. + let updateProgressWired = false; + async function checkForUpdates() { + showToast("Checking for updates…"); + const res = await api.checkUpdate(); + if (!res || res.error) { + await alertMessage("Update check failed", (res && res.error) || "No response from the release server."); + return; + } + if (!res.updateAvailable) { + await alertMessage("You're up to date", `Folio ${res.current} is the latest release.`); + return; + } + const go = await confirmMessage( + `Update to ${res.latest}?`, + `Folio ${res.current} → ${res.latest}. Folio will download the update, restart, and finish installing on its own.`, + "Update" + ); + if (!go) return; + if (!res.canInstall) { + await alertMessage( + "Automatic install unavailable", + "This release has no installable Linux archive, or this Folio is not the user-local install." + + (res.url ? ` Download it manually from ${res.url}` : " Download it manually from the releases page.") + ); + return; + } + // Registered once: checkForUpdates can run repeatedly per session and + // ipcRenderer.on would otherwise stack duplicate progress listeners. + if (!updateProgressWired) { + updateProgressWired = true; + let lastPct = -1; + api.onUpdateProgress(({ received, total }) => { + const pct = total ? Math.floor((received / total) * 100) : -1; + if (pct === lastPct) return; + lastPct = pct; + showToast(pct >= 0 ? `Downloading update… ${pct}%` : `Downloading update… ${(received / 1048576).toFixed(1)} MB`); + }); + } + const up = await api.performUpdate(); + if (up && up.error) { + await alertMessage("Update failed", up.error); + } + // On success the app quits momentarily and reopens by itself. + } + // ---- api menu handlers ---- // These now delegate to the extracted feature facades rather than calling // inline functions defined in this module. @@ -140,6 +198,9 @@ export function initBook(api: FolioAPI) { api.onNewBookRequested(() => void welcome.startNewBook()); api.onOpenBookRequested(() => void welcome.startOpenBook()); api.onOpenRecent((p) => void welcome.openPath(p)); + api.onOpenMarkdownRequested(() => void welcome.openMarkdown()); + api.onCheckUpdateRequested(() => void checkForUpdates()); + $("welcomeUpdate")?.addEventListener("click", () => void checkForUpdates()); api.onRevealRequested(async (p) => { const ok = await api.revealInFolder(p); showToast( 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/toolbar.ts b/src/renderer/book/toolbar.ts index 1e7544d..49dbf5b 100644 --- a/src/renderer/book/toolbar.ts +++ b/src/renderer/book/toolbar.ts @@ -201,17 +201,10 @@ export function createToolbarFeature(ctx: BookCtx, editor: Editor): ToolbarFeatu // any of those closes the dropdowns. window.addEventListener("scroll", closeDropdowns, true); 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); - }); + // Note: right-click is handled by the main process (webContents + // context-menu with spelling suggestions + Cut/Copy/Paste), so no DOM + // contextmenu listener here — preventing the default would suppress the + // native suggestion menu for misspelled words. const headingSelect = $("headingSelect") as HTMLSelectElement; headingSelect.addEventListener("change", () => { diff --git a/src/renderer/book/types.ts b/src/renderer/book/types.ts index 109f226..93c9abc 100644 --- a/src/renderer/book/types.ts +++ b/src/renderer/book/types.ts @@ -39,7 +39,8 @@ export type FolioAPI = { revealInFolder: (p: string) => Promise; getRecents: () => Promise; closeBook: () => Promise; - showContextMenu: (x: number, y: number) => void; + getSpellcheck: () => Promise; + setSpellcheck: (on: boolean) => Promise; getBookMeta: () => Promise; setComments: (comments: Record) => Promise; getChapterList: () => Promise; @@ -64,6 +65,11 @@ export type FolioAPI = { exportDocx: (chapterIds?: string[] | null) => Promise; exportOdt: (chapterIds?: string[] | null) => Promise; importAttachment: () => Promise; + openMarkdownFiles: () => Promise; + openPathOrImport: (p: string) => Promise; + checkUpdate: () => Promise; + performUpdate: () => Promise; + onUpdateProgress: (cb: (p: { received: number; total: number }) => void) => void; copyText: (text: string) => Promise; getClipboardText: () => Promise; toggleFullscreen: () => Promise; @@ -71,6 +77,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; @@ -91,6 +103,9 @@ export type FolioAPI = { onZoomInRequested: (cb: () => void) => void; onZoomOutRequested: (cb: () => void) => void; onZoomResetRequested: (cb: () => void) => void; + onOpenMarkdownRequested: (cb: () => void) => void; + onCheckUpdateRequested: (cb: () => void) => void; + onSettingsRequested: (cb: () => void) => void; }; // ---- P7 decomposition contracts ---- @@ -118,6 +133,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/book/welcome.ts b/src/renderer/book/welcome.ts index ce8800b..31eec94 100644 --- a/src/renderer/book/welcome.ts +++ b/src/renderer/book/welcome.ts @@ -19,6 +19,7 @@ export interface WelcomeFeature { refreshMeta(): Promise; startNewBook(): Promise; startOpenBook(): Promise; + openMarkdown(): Promise; openPath(p: string): Promise; } @@ -162,6 +163,24 @@ export function createWelcomeFeature(ctx: BookCtx): WelcomeFeature { await renderRecents(); } + // Open one or more standalone Markdown files: each becomes a new book + // (copied, originals untouched) and the first one opens immediately. + async function openMarkdown() { + const res = await api.openMarkdownFiles(); + if (!res || "canceled" in res) return; + if (!Array.isArray(res)) { await alertMessage("Error", (res as { error: string }).error); return; } + const results = res as { ok: boolean; file: string; bookPath?: string; title?: string; error?: string }[]; + const failed = results.filter((r) => !r.ok); + const first = results.find((r) => r.ok); + if (first?.bookPath) await openPath(first.bookPath); + if (failed.length) { + await alertMessage( + "Some files could not be imported", + failed.map((f) => `${f.file}: ${f.error}`).join("\n") + ); + } + } + async function renderRecents() { try { const recents = await api.getRecents(); @@ -223,6 +242,7 @@ export function createWelcomeFeature(ctx: BookCtx): WelcomeFeature { // ---- wiring (bind once at construction) ---- $("welcomeNewBook").addEventListener("click", () => void startNewBook()); $("welcomeOpenBook").addEventListener("click", () => void startOpenBook()); + $("welcomeOpenMd")?.addEventListener("click", () => void openMarkdown()); homeBtn?.addEventListener("click", async () => { hooks.clearTimers(); if (state.selectedId && state.dirty) { @@ -244,5 +264,6 @@ export function createWelcomeFeature(ctx: BookCtx): WelcomeFeature { startNewBook, startOpenBook, openPath, + openMarkdown, }; } diff --git a/src/renderer/dialog.ts b/src/renderer/dialog.ts index 186fc7f..e996b1e 100644 --- a/src/renderer/dialog.ts +++ b/src/renderer/dialog.ts @@ -18,7 +18,7 @@ const cancelBtn = el("modalCancel") as HTMLButtonElement; let currentMode: Mode = "prompt"; let resolver: ((value: string | null) => void) | null = null; -function open(mode: Mode, title: string, body: string, value: string): Promise { +function open(mode: Mode, title: string, body: string, value: string, okLabel?: string): Promise { // If a previous dialog was somehow still open (menu accelerators can open // one while another is visible), settle its promise as cancelled so the // awaiting caller isn't stranded forever. @@ -30,7 +30,7 @@ function open(mode: Mode, title: string, body: string, value: string): Promise { - return open("confirm", title, body ?? "", "").then((v) => v !== null); +export function confirmMessage(title: string, body?: string, okLabel?: string): Promise { + return open("confirm", title, body ?? "", "", okLabel).then((v) => v !== null); } export function alertMessage(title: string, body?: string): Promise { diff --git a/src/renderer/index.css b/src/renderer/index.css index c2087f4..9495beb 100644 --- a/src/renderer/index.css +++ b/src/renderer/index.css @@ -44,23 +44,29 @@ html, body { color: var(--color-text); -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; + overflow: hidden; + max-width: 100vw; } #root { display: flex; height: 100vh; + width: 100vw; + max-width: 100vw; overflow: hidden; } .sidebar { flex: 0 0 280px; min-width: 240px; - max-width: 360px; + max-width: min(360px, 85vw); + flex-shrink: 0; background: var(--color-panel); border-right: 1px solid var(--color-border); display: flex; flex-direction: column; padding: var(--gap); + overflow-x: clip; } .sidebar-header { @@ -221,6 +227,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); @@ -288,8 +348,10 @@ html, body { .editor { flex: 1; display: flex; + min-width: 0; + max-width: 100%; background: var(--color-bg); - overflow: visible; + overflow: hidden; } .editor-placeholder { @@ -662,23 +724,6 @@ body.dragging-active * { font-weight: 600; } -.drag-handle { - cursor: grab; - padding: 4px 6px; - font-size: 1.05rem; - line-height: 1; - color: var(--color-muted); - opacity: 0.6; - border-radius: 4px; - transition: opacity 0.12s ease, color 0.12s ease; - user-select: none; -} - -.chapter-row:hover .drag-handle { - opacity: 0.9; - color: var(--color-on-accent); -} - .row-title { flex: 1; overflow: hidden; @@ -855,6 +900,7 @@ body.dragging-active * { flex: 1; display: flex; flex-direction: column; + min-width: 0; padding: 0; overflow: hidden; } @@ -863,17 +909,22 @@ body.dragging-active * { flex: 1; display: flex; flex-direction: column; + min-width: 0; padding: 0; overflow: hidden; } .chapter-header { display: flex; - align-items: baseline; + align-items: center; justify-content: space-between; gap: 12px; padding: 14px 20px; border-bottom: 1px solid var(--color-border); + flex-wrap: wrap; + min-width: 0; + max-width: 100%; + overflow-x: clip; } .chapter-title { @@ -882,6 +933,11 @@ body.dragging-active * { font-weight: 600; margin: 0; color: var(--color-text); + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + flex: 1 1 auto; } .word-count { @@ -892,6 +948,11 @@ body.dragging-active * { .editor-actions { display: flex; align-items: center; + flex-wrap: wrap; + row-gap: 6px; + justify-content: flex-end; + min-width: 0; + max-width: 100%; gap: 10px; color: var(--color-muted); font-size: 0.72rem; @@ -1102,9 +1163,13 @@ body.dragging-active * { .editor-toolbar { display: flex; align-items: center; - flex-wrap: nowrap; - overflow: visible; + align-content: flex-start; + flex-wrap: wrap; + overflow-x: clip; + max-width: 100%; + min-width: 0; gap: 4px; + row-gap: 6px; padding: 6px 12px; border-bottom: 1px solid var(--color-border); background: var(--color-panel); @@ -1116,11 +1181,15 @@ body.dragging-active * { .toolbar-group { display: flex; align-items: center; + flex-wrap: wrap; + row-gap: 6px; gap: 2px; padding-right: 8px; margin-right: 8px; border-right: 1px solid var(--color-border); - flex: 0 0 auto; + flex: 0 1 auto; + min-width: 0; + max-width: 100%; } .toolbar-group:last-child { @@ -1937,3 +2006,40 @@ body:not(.dragging-active) .chapter-row { animation: none !important; } } + +/* ---- narrow / tiled windows (Hyprland split-screen etc.) ---- + Below ~900px the sidebar + full header + toolbar can't fit side by side, + so shrink the chrome instead of overflowing past the tile edge. */ +@media (max-width: 900px) { + .sidebar { + flex-basis: 220px; + min-width: 200px; + } + .brand-icon { + width: 48px; + height: 48px; + } + .chapter-header { + padding: 10px 12px; + } + .editor-toolbar { + padding: 6px 8px; + } + .toolbar-select { + max-width: 110px; + } + .toolbar-group { + padding-right: 6px; + margin-right: 6px; + } +} + +@media (max-width: 640px) { + .sidebar { + flex-basis: 200px; + min-width: 180px; + } + .chapter-title { + font-size: 1.15rem; + } +} diff --git a/src/renderer/index.html b/src/renderer/index.html index fb1f3d6..f935365 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -30,6 +30,7 @@