diff --git a/.gitignore b/.gitignore index f85fa32..ff81fda 100644 --- a/.gitignore +++ b/.gitignore @@ -4,7 +4,6 @@ dist/ *.asar releases/ .DS_Store -.directory tests/.tmp-folio-format-*.mjs .opencode/ diff --git a/electron-builder.yml b/electron-builder.yml index d8f3b67..029025c 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -22,25 +22,6 @@ 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 50efb29..747ffd3 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 && 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", + "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", "package": "electron-builder" }, "devDependencies": { diff --git a/src/main/backup.ts b/src/main/backup.ts index 9d93187..ba9c651 100644 --- a/src/main/backup.ts +++ b/src/main/backup.ts @@ -70,132 +70,3 @@ 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 deleted file mode 100644 index 968cfad..0000000 --- a/src/main/import-md.ts +++ /dev/null @@ -1,56 +0,0 @@ -// 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 8c565f2..3b7b272 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1,4 +1,4 @@ -import { app, BrowserWindow, clipboard, dialog, ipcMain, Menu, session, shell } from "electron"; +import { app, BrowserWindow, clipboard, dialog, ipcMain, Menu, shell } from "electron"; import type { OpenDialogOptions, SaveDialogOptions } from "electron"; import fs from "fs"; import path from "path"; @@ -34,32 +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"; 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; @@ -86,8 +68,8 @@ function createWindow() { mainWindow = new BrowserWindow({ width: 1280, height: 780, - minWidth: 560, - minHeight: 400, + minWidth: 960, + minHeight: 640, title: "Folio", icon: path.join(__dirname, "icons", "icon.png"), webPreferences: { @@ -97,7 +79,6 @@ function createWindow() { }, }); forwardEditShortcuts(mainWindow); - wireContextMenu(mainWindow); mainWindow.loadFile(path.join(__dirname, "index.html")); mainWindow.webContents.on("did-finish-load", () => { console.log("[folio] renderer loaded"); @@ -128,15 +109,6 @@ 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, @@ -144,13 +116,6 @@ 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(); }); @@ -720,57 +685,19 @@ handleIpc("folio:toggleFullscreen", () => { return win.isFullScreen(); }); -// 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 }; +// 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 }); }); // Theme preference. Stored as a plain string so future themes can be added @@ -806,118 +733,6 @@ 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({ @@ -941,237 +756,3 @@ 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 7964a22..f5189fa 100644 --- a/src/main/menu.ts +++ b/src/main/menu.ts @@ -1,7 +1,8 @@ import path from "path"; -import { app, Menu, BrowserWindow } from "electron"; +import { app, dialog, 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 }; @@ -37,10 +38,6 @@ 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", @@ -74,9 +71,18 @@ export function buildMenu( }, }, { - label: "Settings…", - accelerator: "CmdOrCtrl+,", - click: () => getWindow()?.webContents.send("folio:settings-requested"), + 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]); + }, }, { type: "separator" }, { @@ -107,11 +113,6 @@ 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 5b36446..7431d15 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -10,10 +10,8 @@ contextBridge.exposeInMainWorld("folio", { revealInFolder: (p: string) => ipcRenderer.invoke("folio:reveal", p), getRecents: () => ipcRenderer.invoke("folio:getRecents"), closeBook: () => ipcRenderer.invoke("folio:closeBook"), - - // spelling (English dictionary, suggestions in the native context menu) - getSpellcheck: () => ipcRenderer.invoke("folio:getSpellcheck"), - setSpellcheck: (on: boolean) => ipcRenderer.invoke("folio:setSpellcheck", on), + showContextMenu: (x: number, y: number) => + ipcRenderer.send("folio:context-menu", x, y), // chapters getBookMeta: () => ipcRenderer.invoke("folio:getBookMeta"), @@ -56,12 +54,6 @@ 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"), @@ -77,15 +69,6 @@ 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)), @@ -127,10 +110,4 @@ 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 deleted file mode 100644 index 0ea6423..0000000 --- a/src/main/update.ts +++ /dev/null @@ -1,173 +0,0 @@ -// 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 538791e..f7fd273 100644 --- a/src/renderer/book/chapter-list.ts +++ b/src/renderer/book/chapter-list.ts @@ -41,7 +41,6 @@ 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() { @@ -151,6 +150,7 @@ export function createChapterListFeature(ctx: BookCtx): ChapterListFeature { li.draggable = false; li.tabIndex = -1; li.innerHTML = ` + ⋮ ${ch.title} @@ -299,29 +299,12 @@ export function createChapterListFeature(ctx: BookCtx): ChapterListFeature { } } - async function persistOrder(scrollSelected = false) { + async function persistOrder() { 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(); - 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" }); + else renderChapterList(); } function moveSelectedDown() { @@ -330,10 +313,8 @@ 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(); - keepSelectedVisible(); - void persistOrder(true); + void persistOrder(); } function moveSelectedUp() { @@ -342,10 +323,8 @@ 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(); - keepSelectedVisible(); - void persistOrder(true); + void persistOrder(); } // ---- pointer-based reordering ---- @@ -354,65 +333,10 @@ 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 @@ -429,8 +353,6 @@ 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; @@ -453,16 +375,27 @@ 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`; - updateDropTarget(e.clientX, e.clientY); + 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); + } + } }); document.addEventListener("pointerup", (e) => { if (dragPointerId === null) return; dragPointerId = null; - stopDragScroll(); document.body.classList.remove("dragging-active"); if (!dragMoved || !draggingId) { draggingId = null; @@ -504,22 +437,6 @@ 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 3474d82..38e8a4c 100644 --- a/src/renderer/book/editor.ts +++ b/src/renderer/book/editor.ts @@ -35,9 +35,6 @@ 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 { @@ -51,45 +48,6 @@ 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. @@ -105,19 +63,6 @@ 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; } @@ -208,15 +153,9 @@ 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. With background autosave off this - // only marks the chapter Unsaved until the user saves explicitly. + // is about to be persisted automatically. 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); @@ -235,7 +174,7 @@ export function createEditorFeature(ctx: BookCtx): EditorFeature { const FORCED_AUTOSAVE_MS = 30_000; let forcedSaveTimer: ReturnType | null = null; function startForcedAutosave() { - if (forcedSaveTimer || !autosaveOn) return; + if (forcedSaveTimer) return; forcedSaveTimer = setInterval(() => { if (state.selectedId && state.dirty) void flushAutosave(); }, FORCED_AUTOSAVE_MS); @@ -278,8 +217,6 @@ 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). @@ -422,8 +359,5 @@ 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 c35df40..083de0c 100644 --- a/src/renderer/book/index.ts +++ b/src/renderer/book/index.ts @@ -10,7 +10,6 @@ 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, @@ -61,13 +60,6 @@ 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); @@ -139,56 +131,6 @@ 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. @@ -198,9 +140,6 @@ 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 deleted file mode 100644 index f14e02e..0000000 --- a/src/renderer/book/settings.ts +++ /dev/null @@ -1,179 +0,0 @@ -// 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 49dbf5b..1e7544d 100644 --- a/src/renderer/book/toolbar.ts +++ b/src/renderer/book/toolbar.ts @@ -201,10 +201,17 @@ export function createToolbarFeature(ctx: BookCtx, editor: Editor): ToolbarFeatu // any of those closes the dropdowns. window.addEventListener("scroll", closeDropdowns, true); window.addEventListener("resize", closeDropdowns); - // 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. + // 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); + }); const headingSelect = $("headingSelect") as HTMLSelectElement; headingSelect.addEventListener("change", () => { diff --git a/src/renderer/book/types.ts b/src/renderer/book/types.ts index 93c9abc..109f226 100644 --- a/src/renderer/book/types.ts +++ b/src/renderer/book/types.ts @@ -39,8 +39,7 @@ export type FolioAPI = { revealInFolder: (p: string) => Promise; getRecents: () => Promise; closeBook: () => Promise; - getSpellcheck: () => Promise; - setSpellcheck: (on: boolean) => Promise; + showContextMenu: (x: number, y: number) => void; getBookMeta: () => Promise; setComments: (comments: Record) => Promise; getChapterList: () => Promise; @@ -65,11 +64,6 @@ 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; @@ -77,12 +71,6 @@ 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; @@ -103,9 +91,6 @@ 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 ---- @@ -133,11 +118,6 @@ 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 31eec94..ce8800b 100644 --- a/src/renderer/book/welcome.ts +++ b/src/renderer/book/welcome.ts @@ -19,7 +19,6 @@ export interface WelcomeFeature { refreshMeta(): Promise; startNewBook(): Promise; startOpenBook(): Promise; - openMarkdown(): Promise; openPath(p: string): Promise; } @@ -163,24 +162,6 @@ 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(); @@ -242,7 +223,6 @@ 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) { @@ -264,6 +244,5 @@ export function createWelcomeFeature(ctx: BookCtx): WelcomeFeature { startNewBook, startOpenBook, openPath, - openMarkdown, }; } diff --git a/src/renderer/dialog.ts b/src/renderer/dialog.ts index e996b1e..186fc7f 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, okLabel?: string): Promise { +function open(mode: Mode, title: string, body: string, value: 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, okLabel?: inputEl.classList.toggle("hidden", mode !== "prompt"); msgEl.classList.toggle("hidden", mode === "prompt"); cancelBtn.classList.toggle("hidden", mode === "alert"); - okBtn.textContent = okLabel || (mode === "confirm" ? "Delete" : "OK"); + okBtn.textContent = mode === "confirm" ? "Delete" : "OK"; openEl(backdrop); if (mode === "prompt") { inputEl.focus(); @@ -84,8 +84,8 @@ export function promptInput(title: string, value?: string): Promise { - return open("confirm", title, body ?? "", "", okLabel).then((v) => v !== null); +export function confirmMessage(title: string, body?: string): Promise { + return open("confirm", title, body ?? "", "").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 9495beb..c2087f4 100644 --- a/src/renderer/index.css +++ b/src/renderer/index.css @@ -44,29 +44,23 @@ 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: min(360px, 85vw); - flex-shrink: 0; + max-width: 360px; background: var(--color-panel); border-right: 1px solid var(--color-border); display: flex; flex-direction: column; padding: var(--gap); - overflow-x: clip; } .sidebar-header { @@ -227,60 +221,6 @@ 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); @@ -348,10 +288,8 @@ html, body { .editor { flex: 1; display: flex; - min-width: 0; - max-width: 100%; background: var(--color-bg); - overflow: hidden; + overflow: visible; } .editor-placeholder { @@ -724,6 +662,23 @@ 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; @@ -900,7 +855,6 @@ body.dragging-active * { flex: 1; display: flex; flex-direction: column; - min-width: 0; padding: 0; overflow: hidden; } @@ -909,22 +863,17 @@ body.dragging-active * { flex: 1; display: flex; flex-direction: column; - min-width: 0; padding: 0; overflow: hidden; } .chapter-header { display: flex; - align-items: center; + align-items: baseline; 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 { @@ -933,11 +882,6 @@ 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 { @@ -948,11 +892,6 @@ 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; @@ -1163,13 +1102,9 @@ body.dragging-active * { .editor-toolbar { display: flex; align-items: center; - align-content: flex-start; - flex-wrap: wrap; - overflow-x: clip; - max-width: 100%; - min-width: 0; + flex-wrap: nowrap; + overflow: visible; gap: 4px; - row-gap: 6px; padding: 6px 12px; border-bottom: 1px solid var(--color-border); background: var(--color-panel); @@ -1181,15 +1116,11 @@ 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 1 auto; - min-width: 0; - max-width: 100%; + flex: 0 0 auto; } .toolbar-group:last-child { @@ -2006,40 +1937,3 @@ 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 f935365..fb1f3d6 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -30,7 +30,6 @@