Add Neon and Meridian glass themes, bundled fonts, configurable recovery backup location, autosave and UI polish

This commit is contained in:
avi 2026-08-24 18:58:16 -05:00
commit b74cfe98ab
59 changed files with 1848 additions and 185 deletions

2
.gitignore vendored
View file

@ -5,3 +5,5 @@ dist/
releases/ releases/
.DS_Store .DS_Store
tests/.tmp-folio-format-*.mjs tests/.tmp-folio-format-*.mjs
.opencode/

View file

@ -21,6 +21,10 @@ const copyAssets = {
path.join("src", "renderer", "themes.css"), path.join("src", "renderer", "themes.css"),
path.join("dist", "themes.css") path.join("dist", "themes.css")
); );
fs.copyFileSync(
path.join("src", "renderer", "fonts.css"),
path.join("dist", "fonts.css")
);
fs.mkdirSync(path.join("dist", "icons"), { recursive: true }); fs.mkdirSync(path.join("dist", "icons"), { recursive: true });
fs.copyFileSync( fs.copyFileSync(
path.join("build", "icons", "256x256.png"), path.join("build", "icons", "256x256.png"),
@ -34,6 +38,14 @@ const copyAssets = {
path.join("build", "brand-icons", "128x128-black.png"), path.join("build", "brand-icons", "128x128-black.png"),
path.join("dist", "brand-icon-black.png") path.join("dist", "brand-icon-black.png")
); );
// Bundle locally-stored fonts so the app needs no network at runtime.
fs.mkdirSync(path.join("dist", "fonts"), { recursive: true });
const fontsDir = path.join("public", "fonts");
if (fs.existsSync(fontsDir)) {
for (const f of fs.readdirSync(fontsDir)) {
fs.copyFileSync(path.join(fontsDir, f), path.join("dist", "fonts", f));
}
}
}); });
}, },
}; };

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

108
scripts/fetch-fonts.mjs Normal file
View file

@ -0,0 +1,108 @@
// One-time setup script: copy locally-bundled Google Fonts (.woff2) into
// public/fonts and generate the offline @font-face CSS (src/renderer/fonts.css).
//
// Run ONCE while online (or with @fontsource packages already installed via npm):
// node scripts/fetch-fonts.mjs
//
// After this, the app never requests fonts from the network at runtime.
//
// Fonts are sourced from the @fontsource packages (already in node_modules
// after `npm i @fontsource/<font>`). If a package is missing, the script
// falls back to downloading the file from the jsDelivr CDN.
import fs from "fs";
import path from "path";
import { fileURLToPath } from "url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const root = path.resolve(__dirname, "..");
// Each entry: display name, @fontsource package, file slug, CSS fallback.
const FONTS = [
{ name: "Inter", pkg: "inter", slug: "inter", fallback: "sans-serif" },
{ name: "Roboto", pkg: "roboto", slug: "roboto", fallback: "sans-serif" },
{ name: "Open Sans", pkg: "open-sans", slug: "open-sans", fallback: "sans-serif" },
{ name: "Lato", pkg: "lato", slug: "lato", fallback: "sans-serif" },
{ name: "Montserrat", pkg: "montserrat", slug: "montserrat", fallback: "sans-serif" },
{ name: "Poppins", pkg: "poppins", slug: "poppins", fallback: "sans-serif" },
{ name: "Oswald", pkg: "oswald", slug: "oswald", fallback: "sans-serif" },
{ name: "Roboto Slab", pkg: "roboto-slab", slug: "roboto-slab", fallback: "serif" },
{ name: "Merriweather", pkg: "merriweather", slug: "merriweather", fallback: "serif" },
{ name: "Playfair Display", pkg: "playfair-display", slug: "playfair-display", fallback: "serif" },
{ name: "Raleway", pkg: "raleway", slug: "raleway", fallback: "sans-serif" },
{ name: "Nunito", pkg: "nunito", slug: "nunito", fallback: "sans-serif" },
{ name: "Ubuntu", pkg: "ubuntu", slug: "ubuntu", fallback: "sans-serif" },
{ name: "Source Sans 3", pkg: "source-sans-3", slug: "source-sans-3", fallback: "sans-serif" },
{ name: "Noto Sans", pkg: "noto-sans", slug: "noto-sans", fallback: "sans-serif" },
{ name: "PT Sans", pkg: "pt-sans", slug: "pt-sans", fallback: "sans-serif" },
{ name: "Libre Baskerville", pkg: "libre-baskerville", slug: "libre-baskerville", fallback: "serif" },
{ name: "DM Sans", pkg: "dm-sans", slug: "dm-sans", fallback: "sans-serif" },
{ name: "Quicksand", pkg: "quicksand", slug: "quicksand", fallback: "sans-serif" },
{ name: "Work Sans", pkg: "work-sans", slug: "work-sans", fallback: "sans-serif" },
];
const publicFonts = path.join(root, "public", "fonts");
fs.mkdirSync(publicFonts, { recursive: true });
async function copyOrDownload(srcRel, destFile, cdnUrl) {
const src = path.join(root, srcRel);
const dest = path.join(publicFonts, destFile);
if (fs.existsSync(src)) {
fs.copyFileSync(src, dest);
return true;
}
// Fallback: download once from jsDelivr CDN.
try {
const res = await fetch(cdnUrl);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const buf = Buffer.from(await res.arrayBuffer());
fs.writeFileSync(dest, buf);
return true;
} catch (err) {
console.error(` ! failed: ${destFile} (${err.message})`);
return false;
}
}
let css = "/* AUTO-GENERATED by scripts/fetch-fonts.mjs — do not edit by hand.\n" +
" All fonts are bundled locally; no network requests at runtime. */\n\n";
let ok = 0;
const report = [];
for (const f of FONTS) {
const regularDest = `${f.slug}-regular.woff2`;
const boldDest = `${f.slug}-bold.woff2`;
const rOk = await copyOrDownload(
`node_modules/@fontsource/${f.pkg}/files/${f.slug}-latin-400-normal.woff2`,
regularDest,
`https://cdn.jsdelivr.net/npm/@fontsource/${f.pkg}/files/${f.slug}-latin-400-normal.woff2`
);
const bOk = await copyOrDownload(
`node_modules/@fontsource/${f.pkg}/files/${f.slug}-latin-700-normal.woff2`,
boldDest,
`https://cdn.jsdelivr.net/npm/@fontsource/${f.pkg}/files/${f.slug}-latin-700-normal.woff2`
);
if (rOk && bOk) ok++;
report.push(` ${f.name}: public/fonts/${regularDest} + public/fonts/${boldDest}`);
css += `@font-face {\n` +
` font-family: "${f.name}";\n` +
` font-style: normal;\n` +
` font-weight: 400;\n` +
` font-display: swap;\n` +
` src: url("./fonts/${regularDest}") format("woff2");\n` +
`}\n` +
`@font-face {\n` +
` font-family: "${f.name}";\n` +
` font-style: normal;\n` +
` font-weight: 700;\n` +
` font-display: swap;\n` +
` src: url("./fonts/${boldDest}") format("woff2");\n` +
`}\n\n`;
}
fs.writeFileSync(path.join(root, "src", "renderer", "fonts.css"), css);
console.log(`Copied ${ok}/${FONTS.length} font families (2 files each).`);
console.log("Files placed in public/fonts/:");
console.log(report.join("\n"));
console.log(`\nGenerated src/renderer/fonts.css (${ok * 2} @font-face rules).`);

View file

@ -9,7 +9,29 @@ import fs from "fs";
import os from "os"; import os from "os";
import path from "path"; import path from "path";
export const RECOVERY_ROOT = path.join(os.homedir(), "Folio-Recovery"); // Settings bridge, wired by the main process (bindBackupSettings). Keeping
// settings access behind injection lets this module stay Electron-free so
// the test harness can bundle it directly.
interface BackupSettingsStore {
get<T>(key: string, fallback: T): T;
set(key: string, value: unknown): void;
}
let store: BackupSettingsStore | null = null;
export function bindBackupSettings(s: BackupSettingsStore): void {
store = s;
}
export function getRecoveryRoot(): string {
const custom = store ? store.get<string>("backupLocation", "") : "";
if (custom && custom.trim()) return path.resolve(custom.trim());
return path.join(os.homedir(), "Folio-Recovery");
}
export function setRecoveryRoot(loc: string): void {
if (!store) return;
store.set("backupLocation", loc.trim() || "");
}
// Books already backed up in this process run (avoids duplicate copies when // Books already backed up in this process run (avoids duplicate copies when
// several chapters of the same book are migrated one after another). // several chapters of the same book are migrated one after another).
@ -23,9 +45,9 @@ export function ensureRecoveryBackup(bookPath: string): string | null {
const bookName = path.basename(key) || "book"; const bookName = path.basename(key) || "book";
const stamp = new Date().toISOString().replace(/[:.]/g, "-"); const stamp = new Date().toISOString().replace(/[:.]/g, "-");
const dest = path.join(RECOVERY_ROOT, `${bookName}-${stamp}`); const dest = path.join(getRecoveryRoot(), `${bookName}-${stamp}`);
fs.mkdirSync(RECOVERY_ROOT, { recursive: true }); fs.mkdirSync(getRecoveryRoot(), { recursive: true });
fs.cpSync(key, dest, { recursive: true }); fs.cpSync(key, dest, { recursive: true });
sessionBackedUp.add(key); sessionBackedUp.add(key);

View file

@ -2,15 +2,22 @@ import { app, BrowserWindow, clipboard, dialog, ipcMain, Menu, shell } from "ele
import type { OpenDialogOptions, SaveDialogOptions } from "electron"; import type { OpenDialogOptions, SaveDialogOptions } from "electron";
import fs from "fs"; import fs from "fs";
import path from "path"; import path from "path";
// When launched via `electron .` (npm run dev), Electron defaults app.name to
// "electron", which puts userData under ~/.config/Electron and splits recents
// and settings from the packaged build (~/.config/Folio). Unify them by setting
// the name before any userData path is read.
app.setName("Folio");
import { import {
BookResult, BookResult,
ErrResult, ErrResult,
createBook, createBook,
hasBook, hasBook,
loadBook, loadBook,
saveMeta,
Recent, Recent,
} from "./project.js"; } from "./project.js";
import { listRecents, addRecent, removeRecent } from "./recents.js"; import { listRecents, addRecent, removeRecent, migrateLegacyRecents } from "./recents.js";
import { buildMenu } from "./menu.js"; import { buildMenu } from "./menu.js";
import { import {
createChapter, createChapter,
@ -25,6 +32,11 @@ import {
restoreChapter, restoreChapter,
} from "./chapters.js"; } from "./chapters.js";
import { combineMarkdown, buildZip } from "./export.js"; import { combineMarkdown, buildZip } from "./export.js";
import {
bindBackupSettings,
getRecoveryRoot,
setRecoveryRoot,
} from "./backup.js";
import { buildDocx, buildOdt } from "./office-export.js"; import { buildDocx, buildOdt } from "./office-export.js";
import { getSetting, setSetting } from "./settings.js"; import { getSetting, setSetting } from "./settings.js";
import { migrateChapterToNative, migrateBookToNative } from "./migration.js"; import { migrateChapterToNative, migrateBookToNative } from "./migration.js";
@ -95,6 +107,7 @@ function createWindow() {
} }
app.whenReady().then(() => { app.whenReady().then(() => {
migrateLegacyRecents();
buildMenu( buildMenu(
getWindow, getWindow,
getState, getState,
@ -209,7 +222,14 @@ ipcMain.handle("folio:newBookNamed", async (_e, name: string) => {
ipcMain.handle("folio:deleteBook", async (_e, p: string) => { ipcMain.handle("folio:deleteBook", async (_e, p: string) => {
try { try {
if (!hasBook(p)) return { error: "Not a Folio book." }; // Always drop the recent entry, even if the folder is gone or was moved,
// so stale/missing books can be cleared from the list.
removeRecent(p);
if (!hasBook(p)) {
if (bookPath === p) bookPath = null;
refreshMenu();
return { ok: true };
}
// Move to the system trash rather than permanently erasing, so an // Move to the system trash rather than permanently erasing, so an
// accidental delete can be undone from the desktop trash. // accidental delete can be undone from the desktop trash.
try { try {
@ -220,7 +240,6 @@ ipcMain.handle("folio:deleteBook", async (_e, p: string) => {
// book is moved to the trash and remains recoverable. // book is moved to the trash and remains recoverable.
fs.rmSync(p, { recursive: true, force: true }); fs.rmSync(p, { recursive: true, force: true });
} }
removeRecent(p);
if (bookPath === p) bookPath = null; if (bookPath === p) bookPath = null;
refreshMenu(); refreshMenu();
return { ok: true }; return { ok: true };
@ -245,8 +264,24 @@ ipcMain.handle("folio:openBook", async () => {
ipcMain.handle("folio:openPath", async (_e, p: string) => openBookAt(p)); ipcMain.handle("folio:openPath", async (_e, p: string) => openBookAt(p));
ipcMain.handle("folio:reveal", async (_e, p: string) => { ipcMain.handle("folio:reveal", async (_e, p: string) => {
if (p) await shell.openPath(p); if (!p) return false;
// True "reveal": show the book's folio.json selected in the file manager.
// Fall back to opening the folder itself if the meta file is missing.
const metaFile = path.join(p, "folio.json");
if (fs.existsSync(metaFile)) {
shell.showItemInFolder(metaFile);
return true; return true;
}
const err = await shell.openPath(p);
return !err;
});
ipcMain.handle("folio:pickBackupFolder", async () => {
const { canceled, filePaths } = await showOpenDialog({
properties: ["openDirectory"],
title: "Choose recovery backup folder",
});
return { canceled, filePaths };
}); });
ipcMain.handle("folio:getRecents", (): Recent[] => listRecents()); ipcMain.handle("folio:getRecents", (): Recent[] => listRecents());
@ -272,6 +307,17 @@ ipcMain.handle("folio:getBookMeta", () => {
} }
}); });
ipcMain.handle("folio:setComments", (_e, comments: Record<string, unknown>) => {
try {
const meta = loadBook(requireBook());
meta.comments = comments as never;
saveMeta(requireBook(), meta);
return { ok: true };
} catch (e) {
return { error: (e as Error).message };
}
});
ipcMain.handle("folio:getChapterContent", (_e, id: string) => { ipcMain.handle("folio:getChapterContent", (_e, id: string) => {
try { try {
return getChapterContent(requireBook(), id); return getChapterContent(requireBook(), id);
@ -658,6 +704,16 @@ ipcMain.handle("folio:setZoom", (_e, pct: unknown) => {
return { ok: true }; return { ok: true };
}); });
// Backup location preference. Backup reads/writes settings through the
// injected store so the module itself stays Electron-free for tests.
bindBackupSettings({ get: getSetting, set: setSetting });
ipcMain.handle("folio:getBackupLocation", () => getRecoveryRoot());
ipcMain.handle("folio:setBackupLocation", (_e, loc: unknown) => {
const s = typeof loc === "string" ? loc.trim() : "";
setRecoveryRoot(s);
return { ok: true };
});
ipcMain.handle("folio:importAttachment", async () => { ipcMain.handle("folio:importAttachment", async () => {
const bp = requireBook(); const bp = requireBook();
const { canceled, filePaths } = await showOpenDialog({ const { canceled, filePaths } = await showOpenDialog({

View file

@ -1,7 +1,8 @@
import path from "path"; import path from "path";
import { app, Menu, BrowserWindow } from "electron"; import { app, dialog, Menu, BrowserWindow } from "electron";
import { listRecents } from "./recents.js"; import { listRecents } from "./recents.js";
import type { Recent } from "./recents.js"; import type { Recent } from "./recents.js";
import { setRecoveryRoot } from "./backup.js";
type MenuItem = Electron.MenuItemConstructorOptions; type MenuItem = Electron.MenuItemConstructorOptions;
type GetState = () => { bookPath: string | null }; type GetState = () => { bookPath: string | null };
@ -40,12 +41,33 @@ export function buildMenu(
{ label: "Open Recent", submenu: recentItems }, { label: "Open Recent", submenu: recentItems },
{ type: "separator" }, { type: "separator" },
{ {
id: "reveal",
label: "Reveal in Folder", label: "Reveal in Folder",
accelerator: "CmdOrCtrl+R", accelerator: "CmdOrCtrl+R",
enabled: !!getState().bookPath, // Always usable: falls back to the most recent valid book when none
// is currently open, so the item never looks clickable yet do nothing.
enabled: !!getState().bookPath || listRecents().some((r) => !r.missing),
click: () => { click: () => {
const p = getState().bookPath; const target =
if (p) getWindow()?.webContents.send("folio:reveal-requested", p); getState().bookPath ??
listRecents().find((r) => !r.missing)?.path ??
null;
if (!target) return;
getWindow()?.webContents.send("folio:reveal-requested", target);
},
},
{
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" }, { type: "separator" },
@ -112,7 +134,23 @@ export function buildMenu(
], ],
}; };
const menu = Menu.buildFromTemplate([fileMenu, editMenu]); const viewMenu: MenuItem = {
label: "View",
submenu: [
{
label: "Reload Window",
accelerator: "F5",
click: () => getWindow()?.webContents.reload(),
},
{
label: "Toggle Developer Tools",
accelerator: "F12",
click: () => getWindow()?.webContents.toggleDevTools(),
},
],
};
const menu = Menu.buildFromTemplate([fileMenu, editMenu, viewMenu]);
Menu.setApplicationMenu(menu); Menu.setApplicationMenu(menu);
return menu; return menu;
} }

View file

@ -15,6 +15,8 @@ contextBridge.exposeInMainWorld("folio", {
// chapters // chapters
getBookMeta: () => ipcRenderer.invoke("folio:getBookMeta"), getBookMeta: () => ipcRenderer.invoke("folio:getBookMeta"),
setComments: (comments: unknown) =>
ipcRenderer.invoke("folio:setComments", comments),
getChapterList: () => ipcRenderer.invoke("folio:getChapterList"), getChapterList: () => ipcRenderer.invoke("folio:getChapterList"),
getChapterContent: (id: string) => ipcRenderer.invoke("folio:getChapterContent", id), getChapterContent: (id: string) => ipcRenderer.invoke("folio:getChapterContent", id),
createChapter: (title: string) => createChapter: (title: string) =>
@ -60,6 +62,11 @@ contextBridge.exposeInMainWorld("folio", {
getTheme: () => ipcRenderer.invoke("folio:getTheme"), getTheme: () => ipcRenderer.invoke("folio:getTheme"),
setTheme: (id: string) => ipcRenderer.invoke("folio:setTheme", id), setTheme: (id: string) => ipcRenderer.invoke("folio:setTheme", id),
// backup location preference
getBackupLocation: () => ipcRenderer.invoke("folio:getBackupLocation"),
setBackupLocation: (loc: string) => ipcRenderer.invoke("folio:setBackupLocation", loc),
pickBackupFolder: () => ipcRenderer.invoke("folio:pickBackupFolder"),
// events // events
onBookOpened: (cb: (data: { path: string; title: string }) => void) => onBookOpened: (cb: (data: { path: string; title: string }) => void) =>
ipcRenderer.on("folio:bookOpened", (_e, data) => cb(data)), ipcRenderer.on("folio:bookOpened", (_e, data) => cb(data)),

View file

@ -10,12 +10,22 @@ export interface ChapterEntry {
color?: string; color?: string;
} }
export interface ChapterComment {
id: string;
chapterId: string;
quote: string;
text: string;
createdAt: string;
}
export interface BookMeta { export interface BookMeta {
app: string; app: string;
version: number; version: number;
title: string; title: string;
chapterOrder: string[]; chapterOrder: string[];
chapters: Record<string, ChapterEntry>; chapters: Record<string, ChapterEntry>;
// Per-chapter user annotations, keyed by chapter id.
comments?: Record<string, ChapterComment[]>;
// Native document format metadata (added when a book is migrated). Legacy // Native document format metadata (added when a book is migrated). Legacy
// Markdown books omit these fields entirely. // Markdown books omit these fields entirely.
docFormat?: "markdown" | "folio-doc"; docFormat?: "markdown" | "folio-doc";

View file

@ -1,3 +1,4 @@
import fs from "fs";
import path from "path"; import path from "path";
import { app } from "electron"; import { app } from "electron";
import { import {
@ -11,24 +12,62 @@ import type { Recent } from "./project.js";
export type { Recent }; export type { Recent };
// Canonical recents file for this install.
export function recentsFilePath(): string { export function recentsFilePath(): string {
return path.join(app.getPath("userData"), `${APP_ID}-recents.json`); return path.join(app.getPath("userData"), `${APP_ID}-recents.json`);
} }
export function listRecents(): Recent[] { // Legacy location written by dev runs before app.setName("Folio") unified the
// Drop entries whose book no longer exists (e.g. e2e temp books). // userData directory (~/.config/Electron instead of ~/.config/Folio).
const recents = _listRecents(recentsFilePath()).filter( function legacyElectronRecentsFile(): string {
(r) => r && typeof r.path === "string" && hasBook(r.path) return path.join(
path.dirname(app.getPath("userData")),
"Electron",
`${APP_ID}-recents.json`
); );
// Collapse repeats of the same book (same title) so the home/recent list }
// is not cluttered with multiple temp copies of one book.
function loadRaw(file: string): Recent[] {
try {
return _listRecents(file).filter((r) => r && typeof r.path === "string");
} catch {
return [];
}
}
// One-time migration: fold entries from a stray ~/.config/Electron recents
// file into the canonical one, then delete the legacy file. Reading two files
// made removals ambiguous (an entry deleted from the canonical file came back
// from the legacy one), so after this everything lives in a single file.
export function migrateLegacyRecents(): void {
try {
const legacy = legacyElectronRecentsFile();
if (!fs.existsSync(legacy)) return;
const canonical = recentsFilePath();
const current = loadRaw(canonical);
const incoming = loadRaw(legacy);
const known = new Set(current.map((r) => r.path));
const merged = [...current, ...incoming.filter((r) => !known.has(r.path))];
if (merged.length !== current.length) {
fs.writeFileSync(canonical, JSON.stringify(merged, null, 2), "utf-8");
}
fs.rmSync(legacy, { force: true });
} catch {
// Migration is best-effort; never block startup.
}
}
export function listRecents(): (Recent & { missing?: boolean })[] {
// Keep every saved entry so a book never "disappears" from the list just
// because its folder moved or was renamed. Mark entries whose book can't be
// found so the UI can show them as missing instead of hiding them.
const seen = new Set<string>(); const seen = new Set<string>();
const deduped: Recent[] = []; const deduped: (Recent & { missing?: boolean })[] = [];
for (const r of recents) { for (const r of loadRaw(recentsFilePath())) {
const key = (r.title || r.path).toLowerCase(); const key = (r.title || r.path).toLowerCase();
if (seen.has(key)) continue; if (seen.has(key)) continue;
seen.add(key); seen.add(key);
deduped.push(r); deduped.push({ ...r, missing: !hasBook(r.path) });
} }
return deduped; return deduped;
} }

View file

@ -1,4 +1,4 @@
import type { BookMeta } from "../main/project.js"; import type { BookMeta, ChapterComment } from "../main/project.js";
import { alertMessage, confirmMessage, promptInput } from "./dialog.js"; import { alertMessage, confirmMessage, promptInput } from "./dialog.js";
import { renderPrintChaptersNative } from "./print-format.js"; import { renderPrintChaptersNative } from "./print-format.js";
import type { WikilinkTarget } from "./wikilinks.js"; import type { WikilinkTarget } from "./wikilinks.js";
@ -36,6 +36,7 @@ export type FolioAPI = {
closeBook: () => Promise<boolean>; closeBook: () => Promise<boolean>;
showContextMenu: (x: number, y: number) => void; showContextMenu: (x: number, y: number) => void;
getBookMeta: () => Promise<BookMeta | { error: string }>; getBookMeta: () => Promise<BookMeta | { error: string }>;
setComments: (comments: Record<string, unknown>) => Promise<any>;
getChapterList: () => Promise<any[]>; getChapterList: () => Promise<any[]>;
getChapterContent: (id: string) => Promise<string | { error: string }>; getChapterContent: (id: string) => Promise<string | { error: string }>;
createChapter: (title: string) => Promise<any>; createChapter: (title: string) => Promise<any>;
@ -159,6 +160,32 @@ export function initBook(api: FolioAPI) {
let ordered: ChapterEntry[] = []; let ordered: ChapterEntry[] = [];
let selectedId: string | null = null; let selectedId: string | null = null;
function scrollToTerm(term: string, matchCase: boolean): void {
if (!term) return;
const needle = matchCase ? term : term.toLowerCase();
let found: number | null = null;
editor.state.doc.descendants((node, pos) => {
if (found !== null) return false;
if (node.isText && node.text) {
const t = matchCase ? node.text : node.text.toLowerCase();
const idx = t.indexOf(needle);
if (idx !== -1) {
found = pos + idx;
return false;
}
}
return true;
});
if (found === null) return;
const to = Math.min(found + term.length, editor.state.doc.content.size);
editor.commands.focus();
editor.commands.setTextSelection({ from: found, to });
const dom = (editor.view as any).domAt(found);
const node = (dom.node.nodeType === 3 ? dom.node.parentElement : dom.node) as HTMLElement | null;
node?.scrollIntoView({ block: "center" });
}
const chapterList = $("chapterList") as HTMLUListElement; const chapterList = $("chapterList") as HTMLUListElement;
const editorPlaceholder = $("editorPlaceholder"); const editorPlaceholder = $("editorPlaceholder");
const chapterView = $("chapterView"); const chapterView = $("chapterView");
@ -287,8 +314,41 @@ export function initBook(api: FolioAPI) {
const toolbar = $("editorToolbar"); const toolbar = $("editorToolbar");
const headingSelect = $("headingSelect") as HTMLSelectElement; const headingSelect = $("headingSelect") as HTMLSelectElement;
// Open/close helpers that drive the CSS open/close animations. `closeEl`
// adds `.closing` so the exit animation can play, then hides after it ends
// (with a fallback timer). `openEl` cancels any pending close so rapid
// toggles don't leave an element stuck hidden.
function openEl(el: HTMLElement) {
const pending = (el as unknown as { __close?: () => void }).__close;
if (pending) pending();
el.classList.remove("hidden", "closing");
}
function closeEl(el: HTMLElement | null) {
if (!el || el.classList.contains("hidden")) return;
el.classList.add("closing");
let done = false;
const finish = () => {
if (done) return;
done = true;
el.classList.add("hidden");
el.classList.remove("closing");
el.removeEventListener("animationend", onEnd);
delete (el as unknown as { __close?: () => void }).__close;
};
const onEnd = () => finish();
el.addEventListener("animationend", onEnd);
const timer = setTimeout(finish, 420);
(el as unknown as { __close?: () => void }).__close = () => {
done = true;
clearTimeout(timer);
el.removeEventListener("animationend", onEnd);
el.classList.remove("closing");
delete (el as unknown as { __close?: () => void }).__close;
};
}
function closeDropdowns() { function closeDropdowns() {
toolbar.querySelectorAll(".dropdown-menu").forEach((m) => m.classList.add("hidden")); toolbar.querySelectorAll<HTMLElement>(".dropdown-menu").forEach((m) => closeEl(m));
toolbar.querySelectorAll(".dropdown-toggle").forEach((t) => t.classList.remove("open")); toolbar.querySelectorAll(".dropdown-toggle").forEach((t) => t.classList.remove("open"));
} }
@ -451,7 +511,7 @@ export function initBook(api: FolioAPI) {
const wasOpen = !menu.classList.contains("hidden"); const wasOpen = !menu.classList.contains("hidden");
closeDropdowns(); closeDropdowns();
if (!wasOpen) { if (!wasOpen) {
menu.classList.remove("hidden"); openEl(menu);
toggle.classList.add("open"); toggle.classList.add("open");
const rect = toggle.getBoundingClientRect(); const rect = toggle.getBoundingClientRect();
const menuWidth = menu.offsetWidth; const menuWidth = menu.offsetWidth;
@ -480,17 +540,143 @@ export function initBook(api: FolioAPI) {
editor.commands.focus(); editor.commands.focus();
}); });
const fontFamilySelect = $("fontFamilySelect") as HTMLSelectElement; // Custom font-family dropdown. A native <select> can't render each option
fontFamilySelect?.addEventListener("change", async () => { // in its own font reliably, so we build buttons whose label uses its own
const f = fontFamilySelect.value; // font-family. Fonts are bundled locally (see fonts.css) so each name shows
// Font is a book-wide setting: changing it in one chapter applies the same // in its actual typeface, and a search box + categories keep the long list
// font to every chapter so the whole book stays consistent. // manageable. Selecting a font applies it book-wide.
if (f) editor.chain().focus().selectAll().setMark("fontFamily", { fontFamily: f }).run(); type FontDef = { name: string; family: string; cat: string };
else editor.chain().focus().selectAll().unsetMark("fontFamily").run(); const FONT_LIST: FontDef[] = [
{ name: "Inter", family: '"Inter", sans-serif', cat: "Sans-serif" },
{ name: "Roboto", family: '"Roboto", sans-serif', cat: "Sans-serif" },
{ name: "Open Sans", family: '"Open Sans", sans-serif', cat: "Sans-serif" },
{ name: "Lato", family: "Lato, sans-serif", cat: "Sans-serif" },
{ name: "Montserrat", family: "Montserrat, sans-serif", cat: "Sans-serif" },
{ name: "Poppins", family: "Poppins, sans-serif", cat: "Sans-serif" },
{ name: "Oswald", family: "Oswald, sans-serif", cat: "Sans-serif" },
{ name: "Raleway", family: "Raleway, sans-serif", cat: "Sans-serif" },
{ name: "Nunito", family: "Nunito, sans-serif", cat: "Sans-serif" },
{ name: "Ubuntu", family: "Ubuntu, sans-serif", cat: "Sans-serif" },
{ name: "Source Sans 3", family: '"Source Sans 3", sans-serif', cat: "Sans-serif" },
{ name: "Noto Sans", family: '"Noto Sans", sans-serif', cat: "Sans-serif" },
{ name: "PT Sans", family: '"PT Sans", sans-serif', cat: "Sans-serif" },
{ name: "DM Sans", family: '"DM Sans", sans-serif', cat: "Sans-serif" },
{ name: "Quicksand", family: "Quicksand, sans-serif", cat: "Sans-serif" },
{ name: "Work Sans", family: '"Work Sans", sans-serif', cat: "Sans-serif" },
{ name: "Roboto Slab", family: '"Roboto Slab", serif', cat: "Serif" },
{ name: "Merriweather", family: "Merriweather, serif", cat: "Serif" },
{ name: "Playfair Display", family: '"Playfair Display", serif', cat: "Serif" },
{ name: "Libre Baskerville", family: '"Libre Baskerville", serif', cat: "Serif" },
];
const fontMenu = $("fontMenu") as HTMLDivElement;
const fontLabel = $("fontLabel") as HTMLElement;
function setFontLabel(name: string, family: string) {
fontLabel.textContent = name;
fontLabel.style.fontFamily = family;
}
async function selectFont(name: string, family: string) {
// Apply font to the current selection (or cursor position).
// Book-wide propagation is handled separately via applyFontToAllChapters.
editor.chain().focus().setMark("fontFamily", { fontFamily: family }).run();
setFontLabel(name, family);
if (selectedId) await api.saveChapterDoc(selectedId, getEditorDoc(editor)); if (selectedId) await api.saveChapterDoc(selectedId, getEditorDoc(editor));
await applyFontToAllChapters(f, selectedId ?? undefined); await applyFontToAllChapters(family, selectedId ?? undefined);
if (!fullBookView.classList.contains("hidden")) await refreshFullBook(); if (!fullBookView.classList.contains("hidden")) await refreshFullBook();
}
// Search box (filters the list without closing the menu).
const fontSearch = document.createElement("input");
fontSearch.type = "text";
fontSearch.placeholder = "Search fonts…";
fontSearch.className = "font-search";
fontSearch.addEventListener("click", (e) => e.stopPropagation());
fontMenu.appendChild(fontSearch);
const fontToggle = $("fontToggle") as HTMLButtonElement;
let activeIndex = 0;
const visibleOptions = () => optionEls.filter((el) => el.style.display !== "none");
function highlight(index: number) {
optionEls.forEach((el) => el.classList.remove("is-active"));
const vis = visibleOptions();
if (!vis.length) return;
activeIndex = Math.max(0, Math.min(index, vis.length - 1));
const el = vis[activeIndex];
if (el) {
el.classList.add("is-active");
el.scrollIntoView({ block: "nearest" });
}
}
// Open the menu with the search box focused so arrow-key navigation works
// immediately.
fontToggle.addEventListener("click", () => {
if (!fontMenu.classList.contains("hidden")) {
setTimeout(() => { fontSearch.focus(); highlight(activeIndex); }, 0);
}
}); });
fontMenu.addEventListener("keydown", (e) => {
if (e.target !== fontSearch) return;
e.stopPropagation();
const vis = visibleOptions();
if (e.key === "ArrowDown") {
e.preventDefault();
highlight(activeIndex + 1);
} else if (e.key === "ArrowUp") {
e.preventDefault();
highlight(activeIndex - 1);
} else if (e.key === "Enter") {
e.preventDefault();
const el = vis[activeIndex];
if (el) el.click();
}
});
const fontListWrap = document.createElement("div");
fontMenu.appendChild(fontListWrap);
const optionEls: HTMLButtonElement[] = [];
const categories = [...new Set(FONT_LIST.map((f) => f.cat))];
for (const cat of categories) {
const header = document.createElement("div");
header.className = "font-cat";
header.textContent = cat;
fontListWrap.appendChild(header);
for (const opt of FONT_LIST.filter((f) => f.cat === cat)) {
const item = document.createElement("button");
item.type = "button";
item.className = "dropdown-item font-option";
item.textContent = opt.name;
item.style.fontFamily = opt.family;
item.dataset.name = opt.name.toLowerCase();
item.addEventListener("click", (e) => {
e.stopPropagation();
void selectFont(opt.name, opt.family);
closeDropdowns();
});
fontListWrap.appendChild(item);
optionEls.push(item);
}
}
fontSearch.addEventListener("input", () => {
const q = fontSearch.value.trim().toLowerCase();
for (const el of optionEls) {
el.style.display = !q || (el.dataset.name || "").includes(q) ? "" : "none";
}
fontListWrap.querySelectorAll<HTMLElement>(".font-cat").forEach((h) => {
const cat = (h.textContent || "").trim();
const anyVisible = FONT_LIST.filter((f) => f.cat === cat).some(
(f) => !q || f.name.toLowerCase().includes(q)
);
h.style.display = anyVisible ? "" : "none";
});
activeIndex = 0;
highlight(activeIndex);
});
// Default writing font is Inter (the first bundled font).
setFontLabel(FONT_LIST[0].name, FONT_LIST[0].family);
// Write the given font family into every chapter's content so the change // Write the given font family into every chapter's content so the change
// propagates to all chapters (and survives reloads and exports). // propagates to all chapters (and survives reloads and exports).
@ -510,7 +696,7 @@ export function initBook(api: FolioAPI) {
if (!res || typeof res !== "object" || "error" in res) continue; if (!res || typeof res !== "object" || "error" in res) continue;
const doc = (res as { doc?: unknown }).doc; const doc = (res as { doc?: unknown }).doc;
if (!doc) continue; if (!doc) continue;
tmp.commands.setContent(doc as never, false); setEditorDoc(tmp, doc as FolioNode);
if (fontFamily) if (fontFamily)
tmp.chain().selectAll().setMark("fontFamily", { fontFamily }).run(); tmp.chain().selectAll().setMark("fontFamily", { fontFamily }).run();
else tmp.chain().selectAll().unsetMark("fontFamily").run(); else tmp.chain().selectAll().unsetMark("fontFamily").run();
@ -539,19 +725,21 @@ export function initBook(api: FolioAPI) {
function setChapterButtons(enabled: boolean) { function setChapterButtons(enabled: boolean) {
addChapterBtn.disabled = !enabled; addChapterBtn.disabled = !enabled;
newChapterBtn.disabled = !enabled;
fullBookNav.classList.toggle("hidden", !enabled); fullBookNav.classList.toggle("hidden", !enabled);
} }
let dirty = false; let dirty = false;
let loadedDocJson = ""; let loadedDocJson = "";
let saveTimer: ReturnType<typeof setTimeout> | null = null; let saveTimer: ReturnType<typeof setTimeout> | null = null;
// Debounce for automatic saves while typing. When the user stops typing for
// this long the chapter is persisted in the background so navigating away
// (or closing the book) never loses text.
const AUTOSAVE_MS = 1000;
const bookTitleEl = $("bookTitle"); const bookTitleEl = $("bookTitle");
const bookPathEl = $("bookPath"); const bookPathEl = $("bookPath");
const welcome = $("welcome"); const welcome = $("welcome");
const newChapterBtn = $("newChapterBtn") as HTMLButtonElement;
const recentList = $("recentList") as HTMLUListElement; const recentList = $("recentList") as HTMLUListElement;
function showWelcome() { async function showWelcome() {
welcome.classList.remove("hidden"); welcome.classList.remove("hidden");
chapterView.classList.add("hidden"); chapterView.classList.add("hidden");
fullBookView.classList.add("hidden"); fullBookView.classList.add("hidden");
@ -563,7 +751,7 @@ export function initBook(api: FolioAPI) {
bookTitleEl.classList.add("hidden"); bookTitleEl.classList.add("hidden");
bookPathEl.classList.add("hidden"); bookPathEl.classList.add("hidden");
bookPathEl.title = ""; bookPathEl.title = "";
$("revealBtn").classList.add("hidden"); $("revealBtn")?.classList.add("hidden");
const hb = homeBtn as HTMLButtonElement | null; const hb = homeBtn as HTMLButtonElement | null;
if (hb) { if (hb) {
hb.disabled = true; hb.disabled = true;
@ -574,6 +762,7 @@ export function initBook(api: FolioAPI) {
meta = null; meta = null;
ordered = []; ordered = [];
selectedId = null; selectedId = null;
await renderRecents();
// ---- search across all chapters ---- // ---- search across all chapters ----
const searchBtn = $("searchBtn") as HTMLButtonElement; const searchBtn = $("searchBtn") as HTMLButtonElement;
const searchModal = $("searchModal") as HTMLDivElement; const searchModal = $("searchModal") as HTMLDivElement;
@ -582,6 +771,9 @@ export function initBook(api: FolioAPI) {
const searchMeta = $("searchMeta") as HTMLElement; const searchMeta = $("searchMeta") as HTMLElement;
const searchResults = $("searchResults") as HTMLUListElement; const searchResults = $("searchResults") as HTMLUListElement;
const searchClose = $("searchClose") as HTMLButtonElement; const searchClose = $("searchClose") as HTMLButtonElement;
const replaceInput = $("replaceInput") as HTMLInputElement;
const replaceBtn = $("replaceBtn") as HTMLButtonElement;
const replaceAllBtn = $("replaceAllBtn") as HTMLButtonElement;
let searchTimer: number | undefined; let searchTimer: number | undefined;
const saveBtn = $("saveBtn") as HTMLButtonElement; const saveBtn = $("saveBtn") as HTMLButtonElement;
@ -592,14 +784,14 @@ export function initBook(api: FolioAPI) {
void alertMessage("No book open", "Open a book before searching."); void alertMessage("No book open", "Open a book before searching.");
return; return;
} }
searchModal.classList.remove("hidden"); openEl(searchModal);
searchInput.value = ""; searchInput.value = "";
searchResults.innerHTML = ""; searchResults.innerHTML = "";
searchMeta.textContent = ""; searchMeta.textContent = "";
setTimeout(() => searchInput.focus(), 0); setTimeout(() => searchInput.focus(), 0);
} }
function closeSearch(): void { function closeSearch(): void {
searchModal.classList.add("hidden"); closeEl(searchModal);
} }
function runSearchNow(): void { function runSearchNow(): void {
@ -657,29 +849,115 @@ export function initBook(api: FolioAPI) {
})(); })();
} }
function scrollToTerm(term: string, matchCase: boolean): void { // --- find & replace (operates on native chapter documents) ---
if (!term) return; // Build a literal (or case-insensitive) RegExp from a user term. Replace
const needle = matchCase ? term : term.toLowerCase(); // treats the term as a literal string to avoid surprising the user.
let found: number | null = null; function termToRegExp(term: string, matchCase: boolean): RegExp {
editor.state.doc.descendants((node, pos) => { const escaped = term.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
if (found !== null) return false; return new RegExp(escaped, matchCase ? "g" : "gi");
}
// Find every occurrence of `term` within a Tiptap editor's text and replace
// it with `replacement`. Matches are gathered per text node (positions are
// ProseMirror positions) and applied in reverse so earlier positions stay
// valid. Returns the number of replacements made.
function findAndReplaceInEditor(
ed: Editor,
term: string,
replacement: string,
matchCase: boolean
): number {
if (!term) return 0;
const re = termToRegExp(term, matchCase);
const matches: { from: number; to: number }[] = [];
ed.state.doc.descendants((node, pos) => {
if (node.isText && node.text) { if (node.isText && node.text) {
const t = matchCase ? node.text : node.text.toLowerCase(); let m: RegExpExecArray | null;
const idx = t.indexOf(needle); re.lastIndex = 0;
if (idx !== -1) { while ((m = re.exec(node.text)) !== null) {
found = pos + idx; if (m[0].length === 0) {
return false; re.lastIndex++;
continue;
}
matches.push({ from: pos + m.index, to: pos + m.index + m[0].length });
} }
} }
return true; return true;
}); });
if (found === null) return; if (!matches.length) return 0;
const to = Math.min(found + term.length, editor.state.doc.content.size); // Apply highest-position match first so lower positions are unaffected.
editor.commands.focus(); const { state, view } = ed;
editor.commands.setTextSelection({ from: found, to }); let tr = state.tr;
const dom = (editor.view as any).domAt(found); for (let i = matches.length - 1; i >= 0; i--) {
const node = (dom.node.nodeType === 3 ? dom.node.parentElement : dom.node) as HTMLElement | null; tr = tr.insertText(replacement, matches[i].from, matches[i].to);
node?.scrollIntoView({ block: "center" }); }
view.dispatch(tr);
return matches.length;
}
// Load a chapter's native document into a throwaway editor, run find & replace
// on it, and persist the result. Returns the number of replacements.
async function replaceInChapterDoc(
id: string,
term: string,
replacement: string,
matchCase: boolean
): Promise<number> {
const res = await api.loadChapterDoc(id);
if (!res || typeof res !== "object" || "error" in res) return 0;
const doc = (res as { doc?: unknown }).doc;
if (!doc) return 0;
const host = document.createElement("div");
host.style.position = "absolute";
host.style.left = "-99999px";
host.style.top = "0";
document.body.appendChild(host);
const tmp = createTiptapEditor(host);
let count = 0;
try {
setEditorDoc(tmp, doc as FolioNode);
count = findAndReplaceInEditor(tmp, term, replacement, matchCase);
if (count > 0) await api.saveChapterDoc(id, getEditorDoc(tmp));
} finally {
tmp.destroy();
host.remove();
}
return count;
}
// Flush current edits, then replace either in the current chapter or the whole
// book. Afterward the UI is refreshed so the changes are visible.
async function doReplace(all: boolean): Promise<void> {
const term = searchInput.value.trim();
const replacement = replaceInput.value;
if (!term) {
searchMeta.textContent = "Enter a search term to replace.";
return;
}
if (!meta) return;
await saveCurrentChapter();
const ids = all ? meta.chapterOrder.slice() : selectedId ? [selectedId] : [];
if (!ids.length) {
searchMeta.textContent = "No chapter to replace in.";
return;
}
let total = 0;
let chaptersTouched = 0;
for (const id of ids) {
const c = await replaceInChapterDoc(id, term, replacement, searchCaseCb.checked);
if (c > 0) {
total += c;
chaptersTouched++;
}
}
if (total === 0) {
searchMeta.textContent = "No matches to replace.";
return;
}
if (selectedId) await selectChapter(selectedId);
if (!fullBookView.classList.contains("hidden")) await refreshFullBook();
searchMeta.textContent = `Replaced ${total} occurrence${total === 1 ? "" : "s"} in ${chaptersTouched} chapter${chaptersTouched === 1 ? "" : "s"}.`;
void runSearchNow();
} }
searchInput.addEventListener("input", () => { searchInput.addEventListener("input", () => {
@ -687,6 +965,8 @@ export function initBook(api: FolioAPI) {
searchTimer = window.setTimeout(runSearchNow, 200); searchTimer = window.setTimeout(runSearchNow, 200);
}); });
searchCaseCb.addEventListener("change", runSearchNow); searchCaseCb.addEventListener("change", runSearchNow);
replaceBtn.addEventListener("click", () => void doReplace(false));
replaceAllBtn.addEventListener("click", () => void doReplace(true));
searchBtn.addEventListener("click", openSearch); searchBtn.addEventListener("click", openSearch);
searchClose.addEventListener("click", closeSearch); searchClose.addEventListener("click", closeSearch);
searchModal.addEventListener("click", (e) => { searchModal.addEventListener("click", (e) => {
@ -729,12 +1009,10 @@ export function initBook(api: FolioAPI) {
function setCurrentBook(data: { path: string; title: string }) { function setCurrentBook(data: { path: string; title: string }) {
currentBook = data; currentBook = data;
bookTitleEl.textContent = data.title; bookTitleEl.textContent = "Home";
bookTitleEl.classList.remove("hidden"); bookTitleEl.classList.remove("hidden");
bookPathEl.textContent = data.path; bookPathEl.classList.add("hidden");
bookPathEl.title = data.path; $("revealBtn")?.classList.remove("hidden");
bookPathEl.classList.remove("hidden");
$("revealBtn").classList.remove("hidden");
const hb = homeBtn as HTMLButtonElement | null; const hb = homeBtn as HTMLButtonElement | null;
if (hb) { if (hb) {
hb.disabled = false; hb.disabled = false;
@ -786,7 +1064,7 @@ export function initBook(api: FolioAPI) {
let colorPopover: HTMLDivElement | null = null; let colorPopover: HTMLDivElement | null = null;
function closeColorPopover() { function closeColorPopover() {
if (colorPopover) colorPopover.classList.add("hidden"); closeEl(colorPopover);
} }
function openColorPopover( function openColorPopover(
@ -830,7 +1108,7 @@ export function initBook(api: FolioAPI) {
}); });
pop.appendChild(remove); pop.appendChild(remove);
pop.classList.remove("hidden"); openEl(pop);
const pw = pop.offsetWidth; const pw = pop.offsetWidth;
const ph = pop.offsetHeight; const ph = pop.offsetHeight;
const rect = anchor.getBoundingClientRect(); const rect = anchor.getBoundingClientRect();
@ -977,6 +1255,7 @@ export function initBook(api: FolioAPI) {
async function saveCurrentChapter(): Promise<void> { async function saveCurrentChapter(): Promise<void> {
if (!selectedId) return; if (!selectedId) return;
clearTimers();
const res = await api.saveChapterDoc(selectedId, getEditorDoc(editor)); const res = await api.saveChapterDoc(selectedId, getEditorDoc(editor));
if (!(res && "error" in res)) { if (!(res && "error" in res)) {
loadedDocJson = JSON.stringify(getEditorDoc(editor)); loadedDocJson = JSON.stringify(getEditorDoc(editor));
@ -997,20 +1276,15 @@ export function initBook(api: FolioAPI) {
} }
async function selectChapter(id: string) { async function selectChapter(id: string) {
if (selectedId && dirty) {
const ok = await confirmMessage(
"Unsaved changes",
"Save changes to this chapter before switching?"
);
if (ok) {
await saveCurrentChapter();
} else {
return; // keep editing the current chapter; don't switch
}
}
clearTimers(); clearTimers();
if (selectedId && dirty) {
// Auto-save silently so navigating away never loses text and returning to
// this chapter shows what was just written.
await saveCurrentChapter();
}
selectedId = id; selectedId = id;
renderChapterList(); renderChapterList();
updateCommentsBadge();
try { try {
const res = await api.loadChapterDoc(id); const res = await api.loadChapterDoc(id);
const doc = const doc =
@ -1024,7 +1298,7 @@ export function initBook(api: FolioAPI) {
dirty = false; dirty = false;
saveStateEl.textContent = "Saved"; saveStateEl.textContent = "Saved";
saveStateEl.classList.remove("unsaved"); saveStateEl.classList.remove("unsaved");
wordCountEl.textContent = `words: ${countWords(editor.getText())}`; wordCountEl.textContent = formatWordCount(editor.getText());
editor.commands.focus("end"); editor.commands.focus("end");
clearTimers(); clearTimers();
fullBookBtn.classList.remove("active"); fullBookBtn.classList.remove("active");
@ -1087,7 +1361,7 @@ export function initBook(api: FolioAPI) {
fullBookChapters = chapterEntries.map((e, i) => ({ ...e, el: fullBookChapterEls[i] ?? null })); fullBookChapters = chapterEntries.map((e, i) => ({ ...e, el: fullBookChapterEls[i] ?? null }));
populateChapterNav(chapterEntries); populateChapterNav(chapterEntries);
const text = nativeNodeText(nativeDoc); const text = nativeNodeText(nativeDoc);
fullBookStats.textContent = `words: ${countWords(text)} · chars: ${text.length}`; fullBookStats.textContent = `words: ${countWords(text)} · chars: ${text.length} · ~${countWords(text) ? Math.ceil(countWords(text) / 200) : 0} min read`;
printBook.innerHTML = renderPrintChaptersNative(nativeDoc); printBook.innerHTML = renderPrintChaptersNative(nativeDoc);
// Re-render replaces the whole document, which resets the scroll position, // Re-render replaces the whole document, which resets the scroll position,
// so re-derive the current chapter once layout has settled. // so re-derive the current chapter once layout has settled.
@ -1123,13 +1397,32 @@ export function initBook(api: FolioAPI) {
if (saveTimer) { clearTimeout(saveTimer); saveTimer = null; } if (saveTimer) { clearTimeout(saveTimer); saveTimer = null; }
} }
// 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.
function scheduleAutosave() {
clearTimers();
saveStateEl.textContent = "Saving…";
saveStateEl.classList.remove("unsaved");
saveTimer = setTimeout(() => void flushAutosave(), AUTOSAVE_MS);
}
// Persist the current chapter if it has unsaved edits. Safe to call any time;
// does nothing once the chapter is already clean.
async function flushAutosave() {
saveTimer = null;
if (!selectedId || !dirty) return;
await saveCurrentChapter();
}
function handleEditorChange() { function handleEditorChange() {
if (!selectedId) return; if (!selectedId) return;
const json = JSON.stringify(getEditorDoc(editor)); const json = JSON.stringify(getEditorDoc(editor));
dirty = json !== loadedDocJson; dirty = json !== loadedDocJson;
saveStateEl.textContent = dirty ? "Unsaved" : "Saved"; saveStateEl.textContent = dirty ? "Unsaved" : "Saved";
saveStateEl.classList.toggle("unsaved", dirty); saveStateEl.classList.toggle("unsaved", dirty);
wordCountEl.textContent = `words: ${countWords(editor.getText())}`; wordCountEl.textContent = formatWordCount(editor.getText());
if (dirty) scheduleAutosave();
} }
function handleSelection() { function handleSelection() {
@ -1292,6 +1585,12 @@ export function initBook(api: FolioAPI) {
return trimmed.split(/\s+/).length; return trimmed.split(/\s+/).length;
} }
// Word-count label with an approximate reading-time estimate (~200 wpm).
function formatWordCount(text: string): string {
const w = countWords(text);
return w ? `words: ${w} · ~${Math.ceil(w / 200)} min read` : "words: 0";
}
async function create() { async function create() {
if (!meta) return; if (!meta) return;
const count = meta.chapterOrder.length + 1; const count = meta.chapterOrder.length + 1;
@ -1421,6 +1720,7 @@ export function initBook(api: FolioAPI) {
} }
async function renderRecents() { async function renderRecents() {
try {
const recents = await api.getRecents(); const recents = await api.getRecents();
recentList.innerHTML = ""; recentList.innerHTML = "";
if (!recents.length) { if (!recents.length) {
@ -1430,12 +1730,12 @@ export function initBook(api: FolioAPI) {
recentList.appendChild(li); recentList.appendChild(li);
return; return;
} }
for (const r of recents as Recent[]) { for (const r of recents as (Recent & { missing?: boolean })[]) {
const li = document.createElement("li"); const li = document.createElement("li");
li.className = "recent-item"; li.className = "recent-item";
const label = document.createElement("span"); const label = document.createElement("span");
label.className = "recent-title"; label.className = "recent-title";
label.textContent = r.title || r.path; label.textContent = (r.title || r.path) + (r.missing ? " (missing)" : "");
label.title = r.path; label.title = r.path;
label.addEventListener("mousedown", (e) => { label.addEventListener("mousedown", (e) => {
e.preventDefault(); e.preventDefault();
@ -1468,6 +1768,9 @@ export function initBook(api: FolioAPI) {
li.appendChild(del); li.appendChild(del);
recentList.appendChild(li); recentList.appendChild(li);
} }
} catch {
// Recents are best-effort; never block the welcome screen.
}
} }
// ---- pointer-based reordering ---- // ---- pointer-based reordering ----
@ -1549,17 +1852,14 @@ export function initBook(api: FolioAPI) {
$("welcomeOpenBook").addEventListener("click", () => void startOpenBook()); $("welcomeOpenBook").addEventListener("click", () => void startOpenBook());
const homeBtn = $("homeBtn"); const homeBtn = $("homeBtn");
homeBtn?.addEventListener("click", async () => { homeBtn?.addEventListener("click", async () => {
clearTimers();
if (selectedId && dirty) { if (selectedId && dirty) {
const ok = await confirmMessage( // Auto-save silently instead of prompting, so closing the book never
"Unsaved changes", // loses the chapter's text.
"Save changes to this chapter before closing the book?" await saveCurrentChapter();
);
if (ok) await saveCurrentChapter();
else return;
} }
void api.closeBook(); void api.closeBook();
}); });
newChapterBtn.addEventListener("click", () => void create());
addChapterBtn.addEventListener("click", () => void create()); addChapterBtn.addEventListener("click", () => void create());
exportBtn.addEventListener("click", () => void exportMarkdown()); exportBtn.addEventListener("click", () => void exportMarkdown());
exportZipBtn.addEventListener("click", () => void exportZip()); exportZipBtn.addEventListener("click", () => void exportZip());
@ -1571,7 +1871,7 @@ export function initBook(api: FolioAPI) {
if (idx >= 0) jumpToChapter(idx); if (idx >= 0) jumpToChapter(idx);
}); });
function closeExportMenu() { function closeExportMenu() {
exportMenu.classList.add("hidden"); closeEl(exportMenu);
exportMenuToggle.classList.remove("open"); exportMenuToggle.classList.remove("open");
} }
@ -1579,7 +1879,7 @@ export function initBook(api: FolioAPI) {
e.stopPropagation(); e.stopPropagation();
const wasOpen = !exportMenu.classList.contains("hidden"); const wasOpen = !exportMenu.classList.contains("hidden");
closeExportMenu(); closeExportMenu();
if (!wasOpen) exportMenu.classList.remove("hidden"); if (!wasOpen) openEl(exportMenu);
exportMenuToggle.classList.toggle("open", wasOpen); exportMenuToggle.classList.toggle("open", wasOpen);
}); });
@ -1637,11 +1937,11 @@ export function initBook(api: FolioAPI) {
li.appendChild(label); li.appendChild(label);
exportChapterItems.appendChild(li); exportChapterItems.appendChild(li);
} }
exportModal.classList.remove("hidden"); openEl(exportModal);
} }
function closeExportModal() { function closeExportModal() {
exportModal.classList.add("hidden"); closeEl(exportModal);
pendingExportFormat = null; pendingExportFormat = null;
} }
@ -1723,7 +2023,7 @@ export function initBook(api: FolioAPI) {
fullBookStatus.classList.remove("unsaved"); fullBookStatus.classList.remove("unsaved");
}, 3000); }, 3000);
}); });
$("revealBtn").addEventListener("click", () => { $("revealBtn")?.addEventListener("click", () => {
if (currentBook) void api.revealInFolder(currentBook.path); if (currentBook) void api.revealInFolder(currentBook.path);
}); });
@ -1733,7 +2033,14 @@ export function initBook(api: FolioAPI) {
api.onNewBookRequested(() => void startNewBook()); api.onNewBookRequested(() => void startNewBook());
api.onOpenBookRequested(() => void startOpenBook()); api.onOpenBookRequested(() => void startOpenBook());
api.onOpenRecent((p) => void openPath(p)); api.onOpenRecent((p) => void openPath(p));
api.onRevealRequested((p) => void api.revealInFolder(p)); api.onRevealRequested(async (p) => {
const ok = await api.revealInFolder(p);
showToast(
ok
? "Opened your book's folder in the file manager — check the taskbar if you don't see it"
: "Could not open the file manager"
);
});
api.onExportMarkdownRequested(() => void exportMarkdown()); api.onExportMarkdownRequested(() => void exportMarkdown());
api.onExportZipRequested(() => void exportZip()); api.onExportZipRequested(() => void exportZip());
api.onExportPdfRequested(() => void exportPdf()); api.onExportPdfRequested(() => void exportPdf());
@ -1804,6 +2111,195 @@ export function initBook(api: FolioAPI) {
} }
}); });
// Flush any pending auto-save when the window is hidden or loses focus, so the
// last stretch of typing is persisted even if the app closes within the
// debounce window.
window.addEventListener("pagehide", () => void flushAutosave());
window.addEventListener("blur", () => void flushAutosave());
// ---- writing statistics (reading time + counts) ----
const statsModal = $("statsModal") as HTMLDivElement;
const statsBody = $("statsBody") as HTMLElement;
const statsBtn = $("statsBtn") as HTMLButtonElement;
const statsClose = $("statsClose") as HTMLButtonElement;
function computeTextStats(text: string) {
const trimmed = text.trim();
const words = trimmed ? trimmed.split(/\s+/).length : 0;
const chars = text.length;
const charsNoSpace = text.replace(/\s/g, "").length;
const sentences = trimmed
? trimmed.split(/[.!?]+/).filter((s) => s.trim().length).length
: 0;
const minutes = words ? Math.max(1, Math.ceil(words / 200)) : 0;
return { words, chars, charsNoSpace, sentences, minutes };
}
function openStats(): void {
if (!editor) return;
const curText = editor.getText();
const cur = computeTextStats(curText);
let paragraphs = 0;
editor.state.doc.forEach((node) => {
if (node.isBlock && node.textContent.trim()) paragraphs++;
});
void (async () => {
let bookWords = 0;
if (meta) {
const list = await api.getChapterList();
const chapters = Array.isArray(list) ? list : [];
for (const ch of chapters) {
const res = await api.getChapterContent(ch.id);
const md = typeof res === "string" ? res : "";
bookWords += countWords(markdownToPlain(md));
}
}
const bookMinutes = bookWords ? Math.max(1, Math.ceil(bookWords / 200)) : 0;
statsBody.innerHTML = `
<h4>Current chapter</h4>
<ul class="stats-list">
<li>Words: <strong>${cur.words}</strong></li>
<li>Characters: <strong>${cur.chars}</strong> (${cur.charsNoSpace} without spaces)</li>
<li>Paragraphs: <strong>${paragraphs}</strong></li>
<li>Sentences: <strong>${cur.sentences}</strong></li>
<li>Reading time: <strong>~${cur.minutes} min</strong></li>
</ul>
<h4>Whole book</h4>
<ul class="stats-list">
<li>Words: <strong>${bookWords}</strong></li>
<li>Reading time: <strong>~${bookMinutes} min</strong></li>
</ul>`;
})();
openEl(statsModal);
}
function closeStats(): void {
closeEl(statsModal);
}
statsBtn.addEventListener("click", openStats);
statsClose.addEventListener("click", closeStats);
statsModal.addEventListener("click", (e) => {
if (e.target === statsModal) closeStats();
});
// ---- comments / annotations ----
const commentsModal = $("commentsModal") as HTMLDivElement;
const commentsBody = $("commentsList") as HTMLUListElement;
const commentsChapter = $("commentsChapter") as HTMLElement;
const commentsBtn = $("commentsBtn") as HTMLButtonElement;
const commentsClose = $("commentsClose") as HTMLButtonElement;
const addCommentBtn = $("addCommentBtn") as HTMLButtonElement;
function chapterComments(id: string | null): ChapterComment[] {
if (!id || !meta || !meta.comments) return [];
return meta.comments[id] || [];
}
function updateCommentsBadge(): void {
const n = chapterComments(selectedId).length;
commentsBtn.textContent = n ? `💬 ${n}` : "💬";
}
function renderComments(): void {
commentsBody.innerHTML = "";
const id = selectedId;
if (!id) {
commentsBody.innerHTML = `<li class="muted">Open a chapter first.</li>`;
return;
}
const list = chapterComments(id);
if (!list.length) {
commentsBody.innerHTML = `<li class="muted">No comments in this chapter yet.</li>`;
return;
}
for (const c of list) {
const li = document.createElement("li");
li.className = "comment-item";
const quote = document.createElement("div");
quote.className = "comment-quote";
quote.textContent = c.quote || "(no selection)";
const txt = document.createElement("div");
txt.className = "comment-text";
txt.textContent = c.text;
const metaRow = document.createElement("div");
metaRow.className = "comment-meta";
const go = document.createElement("button");
go.className = "button button-ghost button-small";
go.textContent = "Go to";
go.addEventListener("click", () => {
closeComments();
void scrollToTerm(c.quote, false);
});
const del = document.createElement("button");
del.className = "button button-ghost button-small";
del.textContent = "Delete";
del.addEventListener("click", () => void deleteComment(c.id));
metaRow.appendChild(go);
metaRow.appendChild(del);
li.appendChild(quote);
li.appendChild(txt);
li.appendChild(metaRow);
commentsBody.appendChild(li);
}
}
async function persistComments(): Promise<void> {
if (!meta) return;
await api.setComments(meta.comments || {});
}
async function addComment(): Promise<void> {
if (!selectedId) {
await alertMessage("No chapter", "Open a chapter and select text first.");
return;
}
const quote = selectedText().trim();
if (!quote) {
await alertMessage("No selection", "Select some text in the chapter to comment on.");
return;
}
const text = await promptInput("Comment", "");
if (text === null) return;
const comment: ChapterComment = {
id: `c-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
chapterId: selectedId,
quote,
text,
createdAt: new Date().toISOString(),
};
if (!meta!.comments) meta!.comments = {};
if (!meta!.comments[selectedId]) meta!.comments[selectedId] = [];
meta!.comments[selectedId].push(comment);
await persistComments();
renderComments();
updateCommentsBadge();
}
async function deleteComment(id: string): Promise<void> {
if (!selectedId || !meta || !meta.comments) return;
const list = meta.comments[selectedId] || [];
meta.comments[selectedId] = list.filter((c) => c.id !== id);
await persistComments();
renderComments();
updateCommentsBadge();
}
function openComments(): void {
if (!meta) return;
const title = (selectedId && meta.chapters[selectedId]?.title) || "";
commentsChapter.textContent = title ? `— ${title}` : "";
renderComments();
openEl(commentsModal);
}
function closeComments(): void {
closeEl(commentsModal);
}
commentsBtn.addEventListener("click", openComments);
commentsClose.addEventListener("click", closeComments);
addCommentBtn.addEventListener("click", () => void addComment());
commentsModal.addEventListener("click", (e) => {
if (e.target === commentsModal) closeComments();
});
setChapterButtons(false); setChapterButtons(false);
void showWelcome(); void showWelcome();

303
src/renderer/fonts.css Normal file
View file

@ -0,0 +1,303 @@
/* AUTO-GENERATED by scripts/fetch-fonts.mjs — do not edit by hand.
All fonts are bundled locally; no network requests at runtime. */
@font-face {
font-family: "Inter";
font-style: normal;
font-weight: 400;
font-display: swap;
src: url("./fonts/inter-regular.woff2") format("woff2");
}
@font-face {
font-family: "Inter";
font-style: normal;
font-weight: 700;
font-display: swap;
src: url("./fonts/inter-bold.woff2") format("woff2");
}
@font-face {
font-family: "Roboto";
font-style: normal;
font-weight: 400;
font-display: swap;
src: url("./fonts/roboto-regular.woff2") format("woff2");
}
@font-face {
font-family: "Roboto";
font-style: normal;
font-weight: 700;
font-display: swap;
src: url("./fonts/roboto-bold.woff2") format("woff2");
}
@font-face {
font-family: "Open Sans";
font-style: normal;
font-weight: 400;
font-display: swap;
src: url("./fonts/open-sans-regular.woff2") format("woff2");
}
@font-face {
font-family: "Open Sans";
font-style: normal;
font-weight: 700;
font-display: swap;
src: url("./fonts/open-sans-bold.woff2") format("woff2");
}
@font-face {
font-family: "Lato";
font-style: normal;
font-weight: 400;
font-display: swap;
src: url("./fonts/lato-regular.woff2") format("woff2");
}
@font-face {
font-family: "Lato";
font-style: normal;
font-weight: 700;
font-display: swap;
src: url("./fonts/lato-bold.woff2") format("woff2");
}
@font-face {
font-family: "Montserrat";
font-style: normal;
font-weight: 400;
font-display: swap;
src: url("./fonts/montserrat-regular.woff2") format("woff2");
}
@font-face {
font-family: "Montserrat";
font-style: normal;
font-weight: 700;
font-display: swap;
src: url("./fonts/montserrat-bold.woff2") format("woff2");
}
@font-face {
font-family: "Poppins";
font-style: normal;
font-weight: 400;
font-display: swap;
src: url("./fonts/poppins-regular.woff2") format("woff2");
}
@font-face {
font-family: "Poppins";
font-style: normal;
font-weight: 700;
font-display: swap;
src: url("./fonts/poppins-bold.woff2") format("woff2");
}
@font-face {
font-family: "Oswald";
font-style: normal;
font-weight: 400;
font-display: swap;
src: url("./fonts/oswald-regular.woff2") format("woff2");
}
@font-face {
font-family: "Oswald";
font-style: normal;
font-weight: 700;
font-display: swap;
src: url("./fonts/oswald-bold.woff2") format("woff2");
}
@font-face {
font-family: "Roboto Slab";
font-style: normal;
font-weight: 400;
font-display: swap;
src: url("./fonts/roboto-slab-regular.woff2") format("woff2");
}
@font-face {
font-family: "Roboto Slab";
font-style: normal;
font-weight: 700;
font-display: swap;
src: url("./fonts/roboto-slab-bold.woff2") format("woff2");
}
@font-face {
font-family: "Merriweather";
font-style: normal;
font-weight: 400;
font-display: swap;
src: url("./fonts/merriweather-regular.woff2") format("woff2");
}
@font-face {
font-family: "Merriweather";
font-style: normal;
font-weight: 700;
font-display: swap;
src: url("./fonts/merriweather-bold.woff2") format("woff2");
}
@font-face {
font-family: "Playfair Display";
font-style: normal;
font-weight: 400;
font-display: swap;
src: url("./fonts/playfair-display-regular.woff2") format("woff2");
}
@font-face {
font-family: "Playfair Display";
font-style: normal;
font-weight: 700;
font-display: swap;
src: url("./fonts/playfair-display-bold.woff2") format("woff2");
}
@font-face {
font-family: "Raleway";
font-style: normal;
font-weight: 400;
font-display: swap;
src: url("./fonts/raleway-regular.woff2") format("woff2");
}
@font-face {
font-family: "Raleway";
font-style: normal;
font-weight: 700;
font-display: swap;
src: url("./fonts/raleway-bold.woff2") format("woff2");
}
@font-face {
font-family: "Nunito";
font-style: normal;
font-weight: 400;
font-display: swap;
src: url("./fonts/nunito-regular.woff2") format("woff2");
}
@font-face {
font-family: "Nunito";
font-style: normal;
font-weight: 700;
font-display: swap;
src: url("./fonts/nunito-bold.woff2") format("woff2");
}
@font-face {
font-family: "Ubuntu";
font-style: normal;
font-weight: 400;
font-display: swap;
src: url("./fonts/ubuntu-regular.woff2") format("woff2");
}
@font-face {
font-family: "Ubuntu";
font-style: normal;
font-weight: 700;
font-display: swap;
src: url("./fonts/ubuntu-bold.woff2") format("woff2");
}
@font-face {
font-family: "Source Sans 3";
font-style: normal;
font-weight: 400;
font-display: swap;
src: url("./fonts/source-sans-3-regular.woff2") format("woff2");
}
@font-face {
font-family: "Source Sans 3";
font-style: normal;
font-weight: 700;
font-display: swap;
src: url("./fonts/source-sans-3-bold.woff2") format("woff2");
}
@font-face {
font-family: "Noto Sans";
font-style: normal;
font-weight: 400;
font-display: swap;
src: url("./fonts/noto-sans-regular.woff2") format("woff2");
}
@font-face {
font-family: "Noto Sans";
font-style: normal;
font-weight: 700;
font-display: swap;
src: url("./fonts/noto-sans-bold.woff2") format("woff2");
}
@font-face {
font-family: "PT Sans";
font-style: normal;
font-weight: 400;
font-display: swap;
src: url("./fonts/pt-sans-regular.woff2") format("woff2");
}
@font-face {
font-family: "PT Sans";
font-style: normal;
font-weight: 700;
font-display: swap;
src: url("./fonts/pt-sans-bold.woff2") format("woff2");
}
@font-face {
font-family: "Libre Baskerville";
font-style: normal;
font-weight: 400;
font-display: swap;
src: url("./fonts/libre-baskerville-regular.woff2") format("woff2");
}
@font-face {
font-family: "Libre Baskerville";
font-style: normal;
font-weight: 700;
font-display: swap;
src: url("./fonts/libre-baskerville-bold.woff2") format("woff2");
}
@font-face {
font-family: "DM Sans";
font-style: normal;
font-weight: 400;
font-display: swap;
src: url("./fonts/dm-sans-regular.woff2") format("woff2");
}
@font-face {
font-family: "DM Sans";
font-style: normal;
font-weight: 700;
font-display: swap;
src: url("./fonts/dm-sans-bold.woff2") format("woff2");
}
@font-face {
font-family: "Quicksand";
font-style: normal;
font-weight: 400;
font-display: swap;
src: url("./fonts/quicksand-regular.woff2") format("woff2");
}
@font-face {
font-family: "Quicksand";
font-style: normal;
font-weight: 700;
font-display: swap;
src: url("./fonts/quicksand-bold.woff2") format("woff2");
}
@font-face {
font-family: "Work Sans";
font-style: normal;
font-weight: 400;
font-display: swap;
src: url("./fonts/work-sans-regular.woff2") format("woff2");
}
@font-face {
font-family: "Work Sans";
font-style: normal;
font-weight: 700;
font-display: swap;
src: url("./fonts/work-sans-bold.woff2") format("woff2");
}

View file

@ -1,5 +1,16 @@
/* Theme color tokens live in themes.css (`:root[data-theme="..."]` blocks); /* Theme color tokens live in themes.css (`:root[data-theme="..."]` blocks);
this stylesheet only consumes them via var(). System fonts, no remote fonts. */ this stylesheet only consumes them via var(). System fonts, no remote fonts. */
/* ---- Motion tokens (KDE Plasma / macOS-inspired) ---- */
:root {
--motion-fast: 160ms;
--motion-base: 220ms;
--motion-window: 300ms;
--ease-out: cubic-bezier(0.16, 1, 0.3, 1);
--ease-in: cubic-bezier(0.4, 0, 1, 1);
--ease-in-out: cubic-bezier(0.65, 0, 0.35, 1);
}
* { box-sizing: border-box; } * { box-sizing: border-box; }
::-webkit-scrollbar { ::-webkit-scrollbar {
@ -68,8 +79,8 @@ html, body {
} }
.brand-icon { .brand-icon {
width: 64px; width: 80px;
height: 64px; height: 80px;
flex: none; flex: none;
object-fit: contain; object-fit: contain;
} }
@ -163,6 +174,53 @@ html, body {
border-color: var(--color-accent); border-color: var(--color-accent);
} }
/* ---- backup location ---- */
.backup-control {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 10px;
}
.backup-label {
font-size: 0.72rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--color-muted);
white-space: nowrap;
min-width: 44px;
}
.backup-input {
flex: 1;
min-width: 0;
height: 28px;
border: 1px solid var(--color-border);
border-radius: var(--radius);
background: var(--color-bg);
color: var(--color-text);
font-family: var(--font-mono);
font-size: 0.72rem;
padding: 0 6px;
outline: none;
}
.backup-input:focus {
border-color: var(--color-accent);
}
.backup-browse {
height: 28px;
width: 28px;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--radius);
font-size: 0.9rem;
}
/* ---- full book nav ---- */ /* ---- full book nav ---- */
.full-book-nav { .full-book-nav {
margin-top: var(--gap); margin-top: var(--gap);
@ -318,8 +376,17 @@ html, body {
border-color: var(--color-accent); border-color: var(--color-accent);
} }
/* The chapters "+" must stay clearly visible (white) in dark mode. */ /* Home button pairs an icon with a text label. */
:root[data-theme="dark-mode"] #addChapterBtn { #homeBtn {
display: inline-flex;
align-items: center;
gap: 4px;
}
/* The chapters "+" must stay clearly visible (white) on dark surfaces. */
:root[data-theme="dark-mode"] #addChapterBtn,
:root[data-theme="neon"] #addChapterBtn,
:root[data-theme="meridian"] #addChapterBtn {
color: #ffffff; color: #ffffff;
} }
@ -332,11 +399,19 @@ html, body {
display: none; display: none;
} }
:root[data-theme="dark-mode"] .brand-icon-light, :root[data-theme="dark-mode"] .brand-icon-light,
:root[data-theme="dark-mode"] .welcome-brand-light { :root[data-theme="dark-mode"] .welcome-brand-light,
:root[data-theme="neon"] .brand-icon-light,
:root[data-theme="neon"] .welcome-brand-light,
:root[data-theme="meridian"] .brand-icon-light,
:root[data-theme="meridian"] .welcome-brand-light {
display: none; display: none;
} }
:root[data-theme="dark-mode"] .brand-icon-dark, :root[data-theme="dark-mode"] .brand-icon-dark,
:root[data-theme="dark-mode"] .welcome-brand-dark { :root[data-theme="dark-mode"] .welcome-brand-dark,
:root[data-theme="neon"] .brand-icon-dark,
:root[data-theme="neon"] .welcome-brand-dark,
:root[data-theme="meridian"] .brand-icon-dark,
:root[data-theme="meridian"] .welcome-brand-dark {
display: inline-block; display: inline-block;
} }
@ -354,8 +429,8 @@ html, body {
} }
.welcome-brand { .welcome-brand {
width: 128px; width: 160px;
height: 128px; height: 160px;
object-fit: contain; object-fit: contain;
margin-bottom: 12px; margin-bottom: 12px;
} }
@ -1139,6 +1214,72 @@ body.dragging-active * {
color: var(--color-on-accent); color: var(--color-on-accent);
} }
/* Font-family dropdown: each option shows its name in its own font. */
.toolbar-select.dropdown-toggle {
display: inline-flex;
align-items: center;
gap: 4px;
max-width: 150px;
}
.toolbar-select .caret {
opacity: 0.65;
font-size: 0.7em;
margin-left: 2px;
}
#fontLabel {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 110px;
}
.font-option {
font-size: 0.95rem;
}
.font-option.is-active {
background: var(--color-accent);
color: var(--color-on-accent);
}
/* Searchable, categorized font menu */
.font-menu {
min-width: 280px;
max-height: 360px;
overflow-y: auto;
}
.font-menu::-webkit-scrollbar {
width: 12px;
}
.font-menu::-webkit-scrollbar-thumb {
background: var(--color-border);
border-radius: 6px;
}
.font-menu::-webkit-scrollbar-thumb:hover {
background: var(--color-accent);
}
.font-search {
width: 100%;
box-sizing: border-box;
margin-bottom: 4px;
padding: 6px 8px;
border: 1px solid var(--color-border);
border-radius: 6px;
background: var(--color-bg);
color: var(--color-text);
font-family: var(--font-sans);
font-size: 0.82rem;
outline: none;
}
.font-search:focus {
border-color: var(--color-accent);
}
.font-cat {
padding: 7px 10px 2px;
font-size: 0.66rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-muted);
}
.dropdown-sep { .dropdown-sep {
height: 1px; height: 1px;
background: var(--color-border); background: var(--color-border);
@ -1326,6 +1467,113 @@ body.focus-mode .editor {
border-color: var(--color-accent-hover); border-color: var(--color-accent-hover);
} }
.button-small {
padding: 4px 8px;
font-size: 0.78rem;
}
/* Search & replace */
.replace-box {
display: flex;
flex-direction: column;
gap: 8px;
}
.search-meta {
font-size: 0.82rem;
color: var(--color-muted);
min-height: 1em;
}
.search-results {
list-style: none;
margin: 0;
padding: 0;
max-height: 260px;
overflow-y: auto;
border: 1px solid var(--color-border);
border-radius: var(--radius);
}
.search-chapter-head {
padding: 6px 10px;
font-size: 0.78rem;
font-weight: 600;
color: var(--color-muted);
background: var(--color-bg);
position: sticky;
top: 0;
}
.search-result {
padding: 8px 10px;
font-size: 0.85rem;
border-top: 1px solid var(--color-border);
cursor: pointer;
}
.search-result:hover {
background: var(--color-accent-soft);
}
.search-result mark {
background: var(--color-highlight);
border-radius: 2px;
}
/* Stats modal */
.stats-list {
list-style: none;
margin: 4px 0 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 4px;
font-size: 0.9rem;
color: var(--color-text);
}
/* Comments */
.comments-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 10px;
max-height: 320px;
overflow-y: auto;
}
.comment-item {
border: 1px solid var(--color-border);
border-radius: var(--radius);
padding: 8px 10px;
display: flex;
flex-direction: column;
gap: 6px;
}
.comment-quote {
font-size: 0.82rem;
font-style: italic;
color: var(--color-muted);
border-left: 3px solid var(--color-accent);
padding-left: 8px;
white-space: pre-wrap;
}
.comment-text {
font-size: 0.9rem;
color: var(--color-text);
white-space: pre-wrap;
}
.comment-meta {
display: flex;
gap: 8px;
justify-content: flex-end;
}
.comments-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
}
.comments-empty {
color: var(--color-muted);
font-size: 0.85rem;
}
.button:disabled { .button:disabled {
opacity: 0.5; opacity: 0.5;
cursor: not-allowed; cursor: not-allowed;
@ -1337,7 +1585,7 @@ body.focus-mode .editor {
height: 100%; height: 100%;
min-height: 100%; min-height: 100%;
overflow-y: auto; overflow-y: auto;
font-family: var(--font-serif); font-family: var(--font-sans);
font-size: 1.05rem; font-size: 1.05rem;
line-height: 1.7; line-height: 1.7;
color: var(--color-text); color: var(--color-text);
@ -1461,7 +1709,143 @@ body.focus-mode .editor {
border-radius: 2px; border-radius: 2px;
padding: 0 1px; padding: 0 1px;
} }
:root[data-theme="dark-mode"] .search-result mark { :root[data-theme="dark-mode"] .search-result mark,
:root[data-theme="neon"] .search-result mark,
:root[data-theme="meridian"] .search-result mark {
background: #a16207; background: #a16207;
color: #fde68a; color: #fde68a;
} }
/* ============================================================
Animations — calm, precise, native (Plasma / macOS feel)
Open animations are pure CSS (play when an element leaves
`display:none`). Close animations use a `.closing` class set
by JS (see `closeEl`) so the exit can play before hiding.
============================================================ */
@keyframes backdropIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes modalIn {
from { opacity: 0; transform: scale(0.98) translateY(6px); }
to { opacity: 1; transform: scale(1) translateY(0); }
}
@keyframes modalOut {
from { opacity: 1; transform: scale(1) translateY(0); }
to { opacity: 0; transform: scale(0.97) translateY(4px); }
}
@keyframes menuIn {
from { opacity: 0; transform: scale(0.98) translateY(-4px); }
to { opacity: 1; transform: scale(1) translateY(0); }
}
@keyframes menuOut {
from { opacity: 1; transform: scale(1) translateY(0); }
to { opacity: 0; transform: scale(0.98) translateY(-4px); }
}
@keyframes listIn {
from { opacity: 0; transform: translateY(4px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes rootIn {
from { opacity: 0; transform: scale(0.985); }
to { opacity: 1; transform: scale(1); }
}
/* Dialogs / modals */
.modal-backdrop:not(.hidden) .modal {
animation: modalIn var(--motion-window) var(--ease-out) both;
}
.modal-backdrop.closing .modal {
animation: modalOut var(--motion-window) var(--ease-in) both;
}
.modal-backdrop:not(.hidden) {
animation: backdropIn var(--motion-window) var(--ease-out) both;
}
.modal-backdrop.closing {
animation: backdropIn var(--motion-window) var(--ease-in) reverse both;
}
/* Toolbar dropdowns, export menu, color popover (anchor from top-left) */
.dropdown-menu:not(.hidden),
.color-popover:not(.hidden) {
animation: menuIn var(--motion-fast) var(--ease-out) both;
transform-origin: top left;
}
.dropdown-menu.closing,
.color-popover.closing {
animation: menuOut var(--motion-fast) var(--ease-in) both;
transform-origin: top left;
}
/* App / "window open" entrance */
#root {
animation: rootIn var(--motion-window) var(--ease-out) both;
}
/* List item entrances */
.recent-list .recent-item {
animation: listIn var(--motion-base) var(--ease-out) both;
}
.recent-list .recent-item:nth-child(1) { animation-delay: 20ms; }
.recent-list .recent-item:nth-child(2) { animation-delay: 40ms; }
.recent-list .recent-item:nth-child(3) { animation-delay: 60ms; }
.recent-list .recent-item:nth-child(4) { animation-delay: 80ms; }
.recent-list .recent-item:nth-child(5) { animation-delay: 100ms; }
.recent-list .recent-item:nth-child(6) { animation-delay: 120ms; }
.recent-list .recent-item:nth-child(7) { animation-delay: 140ms; }
.recent-list .recent-item:nth-child(8) { animation-delay: 160ms; }
body:not(.dragging-active) .chapter-row {
animation: none;
}
/* Microinteractions: buttons press with a subtle scale */
.button {
transition: background var(--motion-fast) var(--ease-out),
border-color var(--motion-fast) var(--ease-out),
color var(--motion-fast) var(--ease-out),
transform var(--motion-fast) var(--ease-out),
box-shadow var(--motion-fast) var(--ease-out);
}
.button:active {
transform: scale(0.97);
}
.button-primary {
transition: background var(--motion-fast) var(--ease-out),
border-color var(--motion-fast) var(--ease-out),
color var(--motion-fast) var(--ease-out),
transform var(--motion-fast) var(--ease-out),
box-shadow var(--motion-fast) var(--ease-out);
}
.button-primary:hover {
box-shadow: 0 2px 10px var(--color-shadow);
}
.icon-button {
transition: background var(--motion-fast) var(--ease-out),
border-color var(--motion-fast) var(--ease-out),
color var(--motion-fast) var(--ease-out),
transform var(--motion-fast) var(--ease-out);
}
.icon-button:active {
transform: scale(0.96);
}
/* Respect reduced-motion: disable transforms and lengthened timing */
@media (prefers-reduced-motion: reduce) {
:root {
--motion-fast: 1ms;
--motion-base: 1ms;
--motion-window: 1ms;
}
*, *::before, *::after {
animation-duration: 0.001ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.001ms !important;
scroll-behavior: auto !important;
}
.modal, .modal-backdrop, .dropdown-menu, .color-popover,
.recent-item, .chapter-row, #root {
transform: none !important;
animation: none !important;
}
}

View file

@ -5,6 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Folio</title> <title>Folio</title>
<link rel="stylesheet" href="./themes.css" /> <link rel="stylesheet" href="./themes.css" />
<link rel="stylesheet" href="./fonts.css" />
<link rel="stylesheet" href="./index.css" /> <link rel="stylesheet" href="./index.css" />
</head> </head>
<body> <body>
@ -19,10 +20,9 @@
<button class="icon-button" id="addChapterBtn" title="Add chapter">+</button> <button class="icon-button" id="addChapterBtn" title="Add chapter">+</button>
</div> </div>
<div class="project-bar hidden" id="projectBar"> <div class="project-bar hidden" id="projectBar">
<button class="icon-button" id="homeBtn" title="Home" aria-label="Home">🏠</button> <button class="icon-button" id="homeBtn" title="Home" aria-label="Home"><span class="home-icon">🏠</span></button>
<span class="book-title" id="bookTitle"></span> <span class="book-title" id="bookTitle"></span>
<span class="book-path" id="bookPath" title=""></span> <span class="book-path" id="bookPath" title=""></span>
<button class="icon-button" id="revealBtn" title="Reveal in folder">📁</button>
</div> </div>
<ul class="chapter-list" id="chapterList"></ul> <ul class="chapter-list" id="chapterList"></ul>
<div class="full-book-nav hidden" id="fullBookNav"> <div class="full-book-nav hidden" id="fullBookNav">
@ -35,9 +35,10 @@
<select id="themeSelect" class="theme-select" title="Interface theme"> <select id="themeSelect" class="theme-select" title="Interface theme">
<option value="light">Light</option> <option value="light">Light</option>
<option value="dark-mode">Dark Mode</option> <option value="dark-mode">Dark Mode</option>
<option value="neon">Neon</option>
<option value="meridian">Meridian</option>
</select> </select>
</div> </div>
<button class="button button-ghost" id="newChapterBtn">+ New Chapter</button>
</div> </div>
</aside> </aside>
<main class="editor" id="editor"> <main class="editor" id="editor">
@ -47,11 +48,11 @@
<h1 class="app-name">Folio</h1> <h1 class="app-name">Folio</h1>
<p class="app-tagline">Your stories, in folders you choose.</p> <p class="app-tagline">Your stories, in folders you choose.</p>
<div class="welcome-actions"> <div class="welcome-actions">
<button class="button" id="welcomeNewBook">New Book</button> <button class="button" id="welcomeNewBook">New</button>
<button class="button button-ghost" id="welcomeOpenBook">Open Book</button> <button class="button button-ghost" id="welcomeOpenBook">Open</button>
</div> </div>
<div class="recents-block"> <div class="recents-block">
<div class="recents-title">Recent Projects</div> <div class="recents-title">Recent</div>
<ul class="recent-list" id="recentList"> <ul class="recent-list" id="recentList">
<li class="muted">No recent books.</li> <li class="muted">No recent books.</li>
</ul> </ul>
@ -64,6 +65,8 @@
<span class="save-state" id="saveState">Saved</span> <span class="save-state" id="saveState">Saved</span>
<button class="button button-ghost" id="saveBtn" title="Save chapter (Ctrl+S)">Save</button> <button class="button button-ghost" id="saveBtn" title="Save chapter (Ctrl+S)">Save</button>
<span class="word-count" id="wordCount">words: 0</span> <span class="word-count" id="wordCount">words: 0</span>
<button class="icon-button" id="statsBtn" title="Writing statistics">📊</button>
<button class="icon-button" id="commentsBtn" title="Comments">💬</button>
<button class="icon-button" id="exportBtn" title="Export book (Markdown)">⬇</button> <button class="icon-button" id="exportBtn" title="Export book (Markdown)">⬇</button>
<button class="icon-button" id="exportZipBtn" title="Export bundle (ZIP)">📦</button> <button class="icon-button" id="exportZipBtn" title="Export bundle (ZIP)">📦</button>
<div class="zoom-control" role="group" aria-label="Zoom"> <div class="zoom-control" role="group" aria-label="Zoom">
@ -94,20 +97,13 @@
<option value="5">Heading 5</option> <option value="5">Heading 5</option>
<option value="6">Heading 6</option> <option value="6">Heading 6</option>
</select> </select>
<select id="fontFamilySelect" class="toolbar-select" title="Font family"> <div class="toolbar-dropdown" id="fontDropdown">
<option value="">Serif</option> <button class="toolbar-select dropdown-toggle" id="fontToggle" data-dropdown="fontMenu" title="Font family" type="button">
<option value="DejaVu Serif, Liberation Serif, serif">DejaVu Serif</option> <span id="fontLabel">Inter</span>
<option value="C059, Times New Roman, serif">C059</option> <span class="caret">&#9662;</span>
<option value="Bitstream Charter, serif">Bitstream Charter</option> </button>
<option value="IBM Plex Serif, serif">IBM Plex Serif</option> <div class="dropdown-menu font-menu hidden" id="fontMenu"></div>
<option value="URW Bookman, DejaVu Serif, serif">Bookman</option> </div>
<option value="DejaVu Sans, Liberation Sans, sans-serif">DejaVu Sans</option>
<option value="Carlito, DejaVu Sans, sans-serif">Carlito</option>
<option value="IBM Plex Sans, DejaVu Sans, sans-serif">IBM Plex Sans</option>
<option value="DejaVu Sans Mono, Liberation Mono, monospace">DejaVu Mono</option>
<option value="IBM Plex Mono, DejaVu Sans Mono, monospace">IBM Plex Mono</option>
<option value="FreeMono, monospace">FreeMono</option>
</select>
<select id="fontSizeSelect" class="toolbar-select" title="Font size"> <select id="fontSizeSelect" class="toolbar-select" title="Font size">
<option value="">17px</option> <option value="">17px</option>
<option value="12px">12px</option> <option value="12px">12px</option>
@ -133,7 +129,7 @@
<button class="toolbar-btn i" data-cmd="italic" title="Italic (Ctrl+I)">I</button> <button class="toolbar-btn i" data-cmd="italic" title="Italic (Ctrl+I)">I</button>
<button class="toolbar-btn s" data-cmd="strike" title="Strikethrough (Ctrl+Shift+S)">S</button> <button class="toolbar-btn s" data-cmd="strike" title="Strikethrough (Ctrl+Shift+S)">S</button>
<button class="toolbar-btn u" data-cmd="underline" title="Underline (Ctrl+U)">U</button> <button class="toolbar-btn u" data-cmd="underline" title="Underline (Ctrl+U)">U</button>
<button class="toolbar-btn hl" data-cmd="highlight" title="Highlight">&#8801;</button> <button class="toolbar-btn hl" data-cmd="highlight" title="Highlight selection (click again to remove). Pick a color from the ▮ menu.">&#8801;</button>
<button class="toolbar-btn code" data-cmd="code" title="Inline code">`</button> <button class="toolbar-btn code" data-cmd="code" title="Inline code">`</button>
</div> </div>
<div class="toolbar-group"> <div class="toolbar-group">
@ -279,18 +275,44 @@
</div> </div>
<div class="modal-backdrop hidden" id="searchModal"> <div class="modal-backdrop hidden" id="searchModal">
<div class="modal search-modal" role="dialog" aria-modal="true" aria-label="Search all chapters"> <div class="modal search-modal" role="dialog" aria-modal="true" aria-label="Search all chapters">
<h3 class="modal-title">Search</h3> <h3 class="modal-title">Search &amp; Replace</h3>
<div class="search-box"> <div class="search-box">
<input type="text" class="modal-input" id="searchInput" placeholder="Search all chapters…" autocomplete="off" /> <input type="text" class="modal-input" id="searchInput" placeholder="Search all chapters…" autocomplete="off" />
<label class="search-case"><input type="checkbox" id="searchCase" /> Match case</label> <label class="search-case"><input type="checkbox" id="searchCase" /> Match case</label>
</div> </div>
<div class="search-box replace-box">
<input type="text" class="modal-input" id="replaceInput" placeholder="Replace with…" autocomplete="off" />
</div>
<div class="search-meta" id="searchMeta"></div> <div class="search-meta" id="searchMeta"></div>
<ul class="search-results" id="searchResults"></ul> <ul class="search-results" id="searchResults"></ul>
<div class="modal-actions"> <div class="modal-actions">
<button class="button button-ghost" id="replaceBtn" title="Replace matches in the current chapter">Replace</button>
<button class="button button-ghost" id="replaceAllBtn" title="Replace matches across the whole book">Replace All</button>
<button class="button" id="searchClose">Close</button> <button class="button" id="searchClose">Close</button>
</div> </div>
</div> </div>
</div> </div>
<div class="modal-backdrop hidden" id="statsModal">
<div class="modal stats-modal" role="dialog" aria-modal="true" aria-label="Writing statistics">
<h3 class="modal-title">Writing statistics</h3>
<div id="statsBody" class="stats-body"></div>
<div class="modal-actions">
<button class="button" id="statsClose">Close</button>
</div>
</div>
</div>
<div class="modal-backdrop hidden" id="commentsModal">
<div class="modal comments-modal" role="dialog" aria-modal="true" aria-label="Comments">
<h3 class="modal-title">Comments <span id="commentsChapter"></span></h3>
<div class="comments-actions">
<button class="button button-ghost" id="addCommentBtn">Add comment from selection</button>
</div>
<ul id="commentsList" class="comments-list"></ul>
<div class="modal-actions">
<button class="button" id="commentsClose">Close</button>
</div>
</div>
</div>
<div class="modal-backdrop hidden" id="exportModal"> <div class="modal-backdrop hidden" id="exportModal">
<div class="modal export-modal" role="dialog" aria-modal="true" aria-label="Export book"> <div class="modal export-modal" role="dialog" aria-modal="true" aria-label="Export book">
<h3 class="modal-title">Export <span id="exportFormatLabel"></span></h3> <h3 class="modal-title">Export <span id="exportFormatLabel"></span></h3>

View file

@ -9,6 +9,12 @@ declare global {
} }
document.addEventListener("DOMContentLoaded", () => { document.addEventListener("DOMContentLoaded", () => {
(window as any).__errors = [];
window.addEventListener("error", (e) => {
(window as any).__errors.push(
String((e as ErrorEvent).error?.stack || (e as ErrorEvent).message)
);
});
initTheme(window.folio); initTheme(window.folio);
initBook(window.folio); initBook(window.folio);
}); });

View file

@ -7,9 +7,13 @@
Light is Folio's default: a warm, writing-focused light scheme. Dark Mode is Light is Folio's default: a warm, writing-focused light scheme. Dark Mode is
a fixed dark, futuristic theme. */ a fixed dark, futuristic theme. */
/* Bundled web fonts (Inter, Roboto, Open Sans, …) are declared in fonts.css,
which is generated by scripts/fetch-fonts.mjs. No font is fetched from the
network at runtime. */
/* ---- shared (non-color) tokens ---- */ /* ---- shared (non-color) tokens ---- */
:root { :root {
--font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, --font-sans: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
"Helvetica Neue", Arial, sans-serif; "Helvetica Neue", Arial, sans-serif;
--font-serif: Georgia, "Times New Roman", serif; --font-serif: Georgia, "Times New Roman", serif;
--radius: 8px; --radius: 8px;
@ -74,3 +78,129 @@
--color-scrollbar: #263047; --color-scrollbar: #263047;
--color-scrollbar-hover: #33425f; --color-scrollbar-hover: #33425f;
} }
/* ---- Neon ----
Pure-black chrome inspired by the Matrix-styled "Caja Fuerte" Obsidian
theme (MIT, AreAjoErda/CAJAFUERTE): black surfaces, hot-pink (#ff1493)
primary accent, intense-purple (#8b00ff) secondary glow and pearl text.
Borders keep a faint purple tint so layout structure stays readable on
the all-black background. */
:root[data-theme="neon"] {
--color-bg: #000000;
--color-panel: #100e17;
--color-border: #2a1838;
--color-text: #eae0c8;
--color-muted: #9b9282;
--color-accent: #ff1493;
--color-accent-hover: #ff4fb8;
--color-on-accent: #14060d;
--color-on-accent-soft: rgba(20, 6, 13, 0.18);
--color-on-accent-border: rgba(20, 6, 13, 0.38);
--color-selection: rgba(255, 20, 147, 0.32);
--color-selection-strong: rgba(255, 20, 147, 0.55);
--color-shadow: rgba(0, 0, 0, 0.65);
--color-shadow-strong: rgba(0, 0, 0, 0.8);
--color-backdrop: rgba(0, 0, 0, 0.72);
--color-scrollbar: #2a1838;
--color-scrollbar-hover: #45285e;
}
/* ---- Meridian ----
Dark frosted-glass scheme inspired by mvahaste/meridian (MIT): near-black
translucent surfaces floating over a dim indigo/violet aurora, soft #ccc
text on charcoal, Obsidian's default violet accent. Readability comes from
layering: the manuscript area stays ~85% opaque and unblurred while only
the chrome (sidebar, modals, popovers) frosts via backdrop-filter. */
:root[data-theme="meridian"] {
--color-bg: rgba(17, 17, 17, 0.85);
--color-panel: rgba(20, 20, 20, 0.6);
--color-border: rgba(204, 204, 204, 0.14);
--color-text: #cccccc;
--color-muted: #707070;
--color-accent: #8a6cef;
--color-accent-hover: #a48ef3;
--color-on-accent: #14101f;
--color-on-accent-soft: rgba(20, 16, 31, 0.18);
--color-on-accent-border: rgba(20, 16, 31, 0.38);
--color-selection: rgba(138, 108, 239, 0.35);
--color-selection-strong: rgba(138, 108, 239, 0.55);
--color-shadow: rgba(0, 0, 0, 0.55);
--color-shadow-strong: rgba(0, 0, 0, 0.7);
--color-backdrop: rgba(5, 5, 8, 0.55);
--color-scrollbar: rgba(204, 204, 204, 0.16);
--color-scrollbar-hover: rgba(204, 204, 204, 0.28);
/* Shared glass textures. The film-grain tile is what makes frosting
perceptible; the sheen is a pre-softened echo of the body aurora that
panes carry so they read as smoked glass even when the compositor
ignores backdrop-filter. */
--glass-grain: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='180' height='180'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.82' numOctaves='2' stitchTiles='stitch'/%3E%3CfeColorMatrix type='matrix' values='0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0.07 0'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
--glass-sheen:
radial-gradient(900px 620px at 82% -18%, rgba(99, 91, 255, 0.17), transparent 62%),
radial-gradient(820px 640px at -28% 118%, rgba(45, 74, 122, 0.22), transparent 60%);
}
/* The ambient layer the glass sits on: near-black base with fixed
indigo/violet washes plus a fine film-grain tile. The grain matters —
backdrop-filter only reads as "frost" when there is high-frequency
detail behind the panel to smear, exactly like wallpaper behind
Meridian's vibrancy on macOS. Painted opaquely on <body>. */
:root[data-theme="meridian"] body {
background:
var(--glass-grain),
radial-gradient(1200px 800px at 85% -10%, rgba(99, 91, 255, 0.22), transparent 60%),
radial-gradient(1000px 750px at -10% 110%, rgba(45, 74, 122, 0.28), transparent 58%),
radial-gradient(720px 520px at 50% 45%, rgba(138, 108, 239, 0.08), transparent 65%),
linear-gradient(170deg, #101013, #0d0d10 55%, #12101a) fixed;
}
/* Subtle frosting on chrome only — never on the scrolling editor. Panes
carry their own baked-in grain + sheen so the glass is visible even
without backdrop-filter; the blur then softens live content behind them
where the compositor supports it. A hair of top-light sells the edge. */
:root[data-theme="meridian"] .sidebar {
background-image: var(--glass-grain), var(--glass-sheen);
-webkit-backdrop-filter: blur(12px) saturate(115%);
backdrop-filter: blur(12px) saturate(115%);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.07);
}
:root[data-theme="meridian"] .modal,
:root[data-theme="meridian"] .color-popover {
background-image: var(--glass-grain), var(--glass-sheen);
-webkit-backdrop-filter: blur(16px) saturate(115%);
backdrop-filter: blur(16px) saturate(115%);
}
:root[data-theme="meridian"] .modal,
:root[data-theme="meridian"] .color-popover {
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.09),
0 12px 40px var(--color-shadow-strong);
}
/* The manuscript pane joins the glass: the writing region carries the same
baked-in grain + sheen, while the prose column goes transparent so its
old second layer no longer hides the pane. Text stays crisp — no blur is
ever applied to the surface the reader looks at. */
:root[data-theme="meridian"] .editor {
background-image: var(--glass-grain), var(--glass-sheen);
}
:root[data-theme="meridian"] .folio-prose {
background-color: transparent;
}
/* If the runtime can't blur, drop the glass pretense and raise opacity so
text contrast never depends on backdrop-filter support. */
@supports not ((backdrop-filter: blur(4px)) or (-webkit-backdrop-filter: blur(4px))) {
:root[data-theme="meridian"] {
--color-bg: rgba(19, 19, 19, 0.97);
--color-panel: rgba(22, 22, 22, 0.94);
}
}

View file

@ -512,8 +512,17 @@ export function runCommand(
} }
case "highlight": { case "highlight": {
const c = payload.color ? String(payload.color) : ""; const c = payload.color ? String(payload.color) : "";
if (c) chain().toggleHighlight({ color: c }).run(); if (c) {
else chain().unsetHighlight().run(); chain().toggleHighlight({ color: c }).run();
} else if (editor.isActive("highlight")) {
// Clicking the toolbar Highlight button with no color chosen removes
// an existing highlight (toggle off).
chain().unsetHighlight().run();
} else {
// No selection color and nothing highlighted yet: apply a default
// highlight to the current selection so the button is never a no-op.
chain().toggleHighlight({ color: "#fde047" }).run();
}
break; break;
} }
case "fontfamily": { case "fontfamily": {

View file

@ -214,6 +214,27 @@ setEditorDoc(editor5, normalized.doc);
ok(editor5.getText().includes("alpha") && editor5.getText().includes("beta"), ok(editor5.getText().includes("alpha") && editor5.getText().includes("beta"),
"legacy camelCase hardBreak chapter loads (not empty)"); "legacy camelCase hardBreak chapter loads (not empty)");
// --- text alignment via toolbar command ---
const alignEd = makeEditor();
setEditorDoc(alignEd, { type: "doc", content: [{ type: "paragraph", content: [{ type: "text", text: "align me" }] }] });
alignEd.commands.focus("end");
runCommand(alignEd, "align-center");
ok(
(alignEd.getJSON().content[0].attrs?.textAlign || "") === "center",
"align-center sets the paragraph textAlign"
);
// --- standalone Highlight button (no color) must apply a highlight, not be a no-op ---
const hlEd = makeEditor();
setEditorDoc(hlEd, { type: "doc", content: [{ type: "paragraph", content: [{ type: "text", text: "mark me" }] }] });
hlEd.commands.selectAll();
runCommand(hlEd, "highlight");
const hlMarks = hlEd.getJSON().content[0].content[0].marks || [];
ok(
hlMarks.some((m) => m.type === "highlight"),
"standalone Highlight button applies a default highlight to the selection"
);
fs.rmSync(out, { force: true }); fs.rmSync(out, { force: true });
fs.rmSync(book, { recursive: true, force: true }); fs.rmSync(book, { recursive: true, force: true });

View file

@ -24,7 +24,7 @@ export { createBook, loadBook, saveMeta } from "./src/main/project.ts";
export { setChapterContent, getChapterContent } from "./src/main/chapters.ts"; export { setChapterContent, getChapterContent } from "./src/main/chapters.ts";
export { markdownToNative } from "./src/main/markdown-to-native.ts"; export { markdownToNative } from "./src/main/markdown-to-native.ts";
export { parseChapter, serializeChapter, migrateDoc, getChapterDoc, nativeChapterFile, CURRENT_DOC_SCHEMA_VERSION, isValidDoc } from "./src/main/native-doc.ts"; export { parseChapter, serializeChapter, migrateDoc, getChapterDoc, nativeChapterFile, CURRENT_DOC_SCHEMA_VERSION, isValidDoc } from "./src/main/native-doc.ts";
export { RECOVERY_ROOT, _resetSessionBackupGuard } from "./src/main/backup.ts"; export { getRecoveryRoot, _resetSessionBackupGuard } from "./src/main/backup.ts";
` `
); );
await esbuild.build({ await esbuild.build({
@ -54,7 +54,7 @@ const {
serializeChapter, serializeChapter,
migrateDoc, migrateDoc,
CURRENT_DOC_SCHEMA_VERSION, CURRENT_DOC_SCHEMA_VERSION,
RECOVERY_ROOT, getRecoveryRoot,
_resetSessionBackupGuard, _resetSessionBackupGuard,
} = mod; } = mod;
@ -244,10 +244,10 @@ ok(
// Recovery backup exists under ~/Folio-Recovery and contains folio.json // Recovery backup exists under ~/Folio-Recovery and contains folio.json
const bookName = path.basename(book); const bookName = path.basename(book);
const backups = fs const backups = fs
.readdirSync(RECOVERY_ROOT) .readdirSync(getRecoveryRoot())
.filter((d) => d.startsWith(bookName) && fs.statSync(path.join(RECOVERY_ROOT, d)).isDirectory()); .filter((d) => d.startsWith(bookName) && fs.statSync(path.join(getRecoveryRoot(), d)).isDirectory());
ok(backups.length >= 1, "recovery backup created under ~/Folio-Recovery"); ok(backups.length >= 1, "recovery backup created under ~/Folio-Recovery");
const backupDir = path.join(RECOVERY_ROOT, backups[0]); const backupDir = path.join(getRecoveryRoot(), backups[0]);
ok(fs.existsSync(path.join(backupDir, "folio.json")), "backup contains folio.json"); ok(fs.existsSync(path.join(backupDir, "folio.json")), "backup contains folio.json");
ok( ok(
fs.existsSync(path.join(backupDir, "chapters", `${c1}.md`)), fs.existsSync(path.join(backupDir, "chapters", `${c1}.md`)),
@ -294,7 +294,7 @@ ok(
fs.rmSync(out, { force: true }); fs.rmSync(out, { force: true });
fs.rmSync(book, { recursive: true, force: true }); fs.rmSync(book, { recursive: true, force: true });
for (const d of backups) { for (const d of backups) {
fs.rmSync(path.join(RECOVERY_ROOT, d), { recursive: true, force: true }); fs.rmSync(path.join(getRecoveryRoot(), d), { recursive: true, force: true });
} }
console.log( console.log(

View file

@ -90,8 +90,9 @@
!!document.querySelector("#chapterList li.chapter-row.active") !!document.querySelector("#chapterList li.chapter-row.active")
); );
// Manual save: typing marks the chapter Unsaved; the Save button (or // Auto-save: typing marks the chapter Unsaved, then a debounced
// Ctrl/Cmd+S) is required to persist. No auto-save should occur. // background save persists it automatically (no prompt, no manual
// Save required). Verify the indicator ends in "Saved".
const edSave = window.__folioEditor; const edSave = window.__folioEditor;
if (edSave) { if (edSave) {
edSave.commands.focus("end"); edSave.commands.focus("end");
@ -99,11 +100,8 @@
await sleep(150); await sleep(150);
const unsaved = document.getElementById("saveState"); const unsaved = document.getElementById("saveState");
step("afterTypeUnsaved", unsaved ? unsaved.textContent : "n/a"); step("afterTypeUnsaved", unsaved ? unsaved.textContent : "n/a");
const sb = document.getElementById("saveBtn"); // Wait past the autosave debounce for the chapter to self-persist.
if (sb) { await sleep(1600);
sb.click();
await sleep(400);
}
const after = document.getElementById("saveState"); const after = document.getElementById("saveState");
step("afterSaveState", after ? after.textContent : "n/a"); step("afterSaveState", after ? after.textContent : "n/a");
} else { } else {