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

View file

@ -9,7 +9,29 @@ import fs from "fs";
import os from "os";
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
// 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 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 });
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 fs from "fs";
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 {
BookResult,
ErrResult,
createBook,
hasBook,
loadBook,
saveMeta,
Recent,
} from "./project.js";
import { listRecents, addRecent, removeRecent } from "./recents.js";
import { listRecents, addRecent, removeRecent, migrateLegacyRecents } from "./recents.js";
import { buildMenu } from "./menu.js";
import {
createChapter,
@ -25,6 +32,11 @@ import {
restoreChapter,
} from "./chapters.js";
import { combineMarkdown, buildZip } from "./export.js";
import {
bindBackupSettings,
getRecoveryRoot,
setRecoveryRoot,
} from "./backup.js";
import { buildDocx, buildOdt } from "./office-export.js";
import { getSetting, setSetting } from "./settings.js";
import { migrateChapterToNative, migrateBookToNative } from "./migration.js";
@ -95,6 +107,7 @@ function createWindow() {
}
app.whenReady().then(() => {
migrateLegacyRecents();
buildMenu(
getWindow,
getState,
@ -209,7 +222,14 @@ ipcMain.handle("folio:newBookNamed", async (_e, name: string) => {
ipcMain.handle("folio:deleteBook", async (_e, p: string) => {
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
// accidental delete can be undone from the desktop trash.
try {
@ -220,7 +240,6 @@ ipcMain.handle("folio:deleteBook", async (_e, p: string) => {
// book is moved to the trash and remains recoverable.
fs.rmSync(p, { recursive: true, force: true });
}
removeRecent(p);
if (bookPath === p) bookPath = null;
refreshMenu();
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:reveal", async (_e, p: string) => {
if (p) await shell.openPath(p);
return true;
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;
}
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());
@ -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) => {
try {
return getChapterContent(requireBook(), id);
@ -658,6 +704,16 @@ ipcMain.handle("folio:setZoom", (_e, pct: unknown) => {
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 () => {
const bp = requireBook();
const { canceled, filePaths } = await showOpenDialog({

View file

@ -1,7 +1,8 @@
import path from "path";
import { app, Menu, BrowserWindow } from "electron";
import { app, dialog, Menu, BrowserWindow } from "electron";
import { listRecents } from "./recents.js";
import type { Recent } from "./recents.js";
import { setRecoveryRoot } from "./backup.js";
type MenuItem = Electron.MenuItemConstructorOptions;
type GetState = () => { bookPath: string | null };
@ -40,12 +41,33 @@ export function buildMenu(
{ label: "Open Recent", submenu: recentItems },
{ type: "separator" },
{
id: "reveal",
label: "Reveal in Folder",
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: () => {
const p = getState().bookPath;
if (p) getWindow()?.webContents.send("folio:reveal-requested", p);
const target =
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" },
@ -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);
return menu;
}

View file

@ -15,6 +15,8 @@ contextBridge.exposeInMainWorld("folio", {
// chapters
getBookMeta: () => ipcRenderer.invoke("folio:getBookMeta"),
setComments: (comments: unknown) =>
ipcRenderer.invoke("folio:setComments", comments),
getChapterList: () => ipcRenderer.invoke("folio:getChapterList"),
getChapterContent: (id: string) => ipcRenderer.invoke("folio:getChapterContent", id),
createChapter: (title: string) =>
@ -60,6 +62,11 @@ contextBridge.exposeInMainWorld("folio", {
getTheme: () => ipcRenderer.invoke("folio:getTheme"),
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
onBookOpened: (cb: (data: { path: string; title: string }) => void) =>
ipcRenderer.on("folio:bookOpened", (_e, data) => cb(data)),

View file

@ -10,12 +10,22 @@ export interface ChapterEntry {
color?: string;
}
export interface ChapterComment {
id: string;
chapterId: string;
quote: string;
text: string;
createdAt: string;
}
export interface BookMeta {
app: string;
version: number;
title: string;
chapterOrder: string[];
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
// Markdown books omit these fields entirely.
docFormat?: "markdown" | "folio-doc";

View file

@ -1,3 +1,4 @@
import fs from "fs";
import path from "path";
import { app } from "electron";
import {
@ -11,24 +12,62 @@ import type { Recent } from "./project.js";
export type { Recent };
// Canonical recents file for this install.
export function recentsFilePath(): string {
return path.join(app.getPath("userData"), `${APP_ID}-recents.json`);
}
export function listRecents(): Recent[] {
// Drop entries whose book no longer exists (e.g. e2e temp books).
const recents = _listRecents(recentsFilePath()).filter(
(r) => r && typeof r.path === "string" && hasBook(r.path)
// Legacy location written by dev runs before app.setName("Folio") unified the
// userData directory (~/.config/Electron instead of ~/.config/Folio).
function legacyElectronRecentsFile(): string {
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 deduped: Recent[] = [];
for (const r of recents) {
const deduped: (Recent & { missing?: boolean })[] = [];
for (const r of loadRaw(recentsFilePath())) {
const key = (r.title || r.path).toLowerCase();
if (seen.has(key)) continue;
seen.add(key);
deduped.push(r);
deduped.push({ ...r, missing: !hasBook(r.path) });
}
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 { renderPrintChaptersNative } from "./print-format.js";
import type { WikilinkTarget } from "./wikilinks.js";
@ -36,6 +36,7 @@ export type FolioAPI = {
closeBook: () => Promise<boolean>;
showContextMenu: (x: number, y: number) => void;
getBookMeta: () => Promise<BookMeta | { error: string }>;
setComments: (comments: Record<string, unknown>) => Promise<any>;
getChapterList: () => Promise<any[]>;
getChapterContent: (id: string) => Promise<string | { error: string }>;
createChapter: (title: string) => Promise<any>;
@ -153,12 +154,38 @@ function makeSnippet(plain: string, pos: number, len: number): string {
return escapeHtml(pre + before) + "<mark>" + escapeHtml(match) + "</mark>" + escapeHtml(after) + post;
}
export function initBook(api: FolioAPI) {
export function initBook(api: FolioAPI) {
let currentBook: { path: string; title: string } | null = null;
let meta: BookMeta | null = null;
let ordered: ChapterEntry[] = [];
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 editorPlaceholder = $("editorPlaceholder");
const chapterView = $("chapterView");
@ -287,8 +314,41 @@ export function initBook(api: FolioAPI) {
const toolbar = $("editorToolbar");
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() {
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"));
}
@ -451,7 +511,7 @@ export function initBook(api: FolioAPI) {
const wasOpen = !menu.classList.contains("hidden");
closeDropdowns();
if (!wasOpen) {
menu.classList.remove("hidden");
openEl(menu);
toggle.classList.add("open");
const rect = toggle.getBoundingClientRect();
const menuWidth = menu.offsetWidth;
@ -480,17 +540,143 @@ export function initBook(api: FolioAPI) {
editor.commands.focus();
});
const fontFamilySelect = $("fontFamilySelect") as HTMLSelectElement;
fontFamilySelect?.addEventListener("change", async () => {
const f = fontFamilySelect.value;
// Font is a book-wide setting: changing it in one chapter applies the same
// font to every chapter so the whole book stays consistent.
if (f) editor.chain().focus().selectAll().setMark("fontFamily", { fontFamily: f }).run();
else editor.chain().focus().selectAll().unsetMark("fontFamily").run();
// Custom font-family dropdown. A native <select> can't render each option
// in its own font reliably, so we build buttons whose label uses its own
// font-family. Fonts are bundled locally (see fonts.css) so each name shows
// in its actual typeface, and a search box + categories keep the long list
// manageable. Selecting a font applies it book-wide.
type FontDef = { name: string; family: string; cat: string };
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));
await applyFontToAllChapters(f, selectedId ?? undefined);
await applyFontToAllChapters(family, selectedId ?? undefined);
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
// 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;
const doc = (res as { doc?: unknown }).doc;
if (!doc) continue;
tmp.commands.setContent(doc as never, false);
setEditorDoc(tmp, doc as FolioNode);
if (fontFamily)
tmp.chain().selectAll().setMark("fontFamily", { fontFamily }).run();
else tmp.chain().selectAll().unsetMark("fontFamily").run();
@ -539,19 +725,21 @@ export function initBook(api: FolioAPI) {
function setChapterButtons(enabled: boolean) {
addChapterBtn.disabled = !enabled;
newChapterBtn.disabled = !enabled;
fullBookNav.classList.toggle("hidden", !enabled);
}
let dirty = false;
let loadedDocJson = "";
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 bookPathEl = $("bookPath");
const welcome = $("welcome");
const newChapterBtn = $("newChapterBtn") as HTMLButtonElement;
const recentList = $("recentList") as HTMLUListElement;
function showWelcome() {
async function showWelcome() {
welcome.classList.remove("hidden");
chapterView.classList.add("hidden");
fullBookView.classList.add("hidden");
@ -563,7 +751,7 @@ export function initBook(api: FolioAPI) {
bookTitleEl.classList.add("hidden");
bookPathEl.classList.add("hidden");
bookPathEl.title = "";
$("revealBtn").classList.add("hidden");
$("revealBtn")?.classList.add("hidden");
const hb = homeBtn as HTMLButtonElement | null;
if (hb) {
hb.disabled = true;
@ -574,6 +762,7 @@ export function initBook(api: FolioAPI) {
meta = null;
ordered = [];
selectedId = null;
await renderRecents();
// ---- search across all chapters ----
const searchBtn = $("searchBtn") as HTMLButtonElement;
const searchModal = $("searchModal") as HTMLDivElement;
@ -582,6 +771,9 @@ export function initBook(api: FolioAPI) {
const searchMeta = $("searchMeta") as HTMLElement;
const searchResults = $("searchResults") as HTMLUListElement;
const searchClose = $("searchClose") as HTMLButtonElement;
const replaceInput = $("replaceInput") as HTMLInputElement;
const replaceBtn = $("replaceBtn") as HTMLButtonElement;
const replaceAllBtn = $("replaceAllBtn") as HTMLButtonElement;
let searchTimer: number | undefined;
const saveBtn = $("saveBtn") as HTMLButtonElement;
@ -592,14 +784,14 @@ export function initBook(api: FolioAPI) {
void alertMessage("No book open", "Open a book before searching.");
return;
}
searchModal.classList.remove("hidden");
openEl(searchModal);
searchInput.value = "";
searchResults.innerHTML = "";
searchMeta.textContent = "";
setTimeout(() => searchInput.focus(), 0);
}
function closeSearch(): void {
searchModal.classList.add("hidden");
closeEl(searchModal);
}
function runSearchNow(): void {
@ -657,29 +849,115 @@ export function initBook(api: FolioAPI) {
})();
}
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;
// --- find & replace (operates on native chapter documents) ---
// Build a literal (or case-insensitive) RegExp from a user term. Replace
// treats the term as a literal string to avoid surprising the user.
function termToRegExp(term: string, matchCase: boolean): RegExp {
const escaped = term.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
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) {
const t = matchCase ? node.text : node.text.toLowerCase();
const idx = t.indexOf(needle);
if (idx !== -1) {
found = pos + idx;
return false;
let m: RegExpExecArray | null;
re.lastIndex = 0;
while ((m = re.exec(node.text)) !== null) {
if (m[0].length === 0) {
re.lastIndex++;
continue;
}
matches.push({ from: pos + m.index, to: pos + m.index + m[0].length });
}
}
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" });
if (!matches.length) return 0;
// Apply highest-position match first so lower positions are unaffected.
const { state, view } = ed;
let tr = state.tr;
for (let i = matches.length - 1; i >= 0; i--) {
tr = tr.insertText(replacement, matches[i].from, matches[i].to);
}
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", () => {
@ -687,6 +965,8 @@ export function initBook(api: FolioAPI) {
searchTimer = window.setTimeout(runSearchNow, 200);
});
searchCaseCb.addEventListener("change", runSearchNow);
replaceBtn.addEventListener("click", () => void doReplace(false));
replaceAllBtn.addEventListener("click", () => void doReplace(true));
searchBtn.addEventListener("click", openSearch);
searchClose.addEventListener("click", closeSearch);
searchModal.addEventListener("click", (e) => {
@ -729,12 +1009,10 @@ export function initBook(api: FolioAPI) {
function setCurrentBook(data: { path: string; title: string }) {
currentBook = data;
bookTitleEl.textContent = data.title;
bookTitleEl.textContent = "Home";
bookTitleEl.classList.remove("hidden");
bookPathEl.textContent = data.path;
bookPathEl.title = data.path;
bookPathEl.classList.remove("hidden");
$("revealBtn").classList.remove("hidden");
bookPathEl.classList.add("hidden");
$("revealBtn")?.classList.remove("hidden");
const hb = homeBtn as HTMLButtonElement | null;
if (hb) {
hb.disabled = false;
@ -786,7 +1064,7 @@ export function initBook(api: FolioAPI) {
let colorPopover: HTMLDivElement | null = null;
function closeColorPopover() {
if (colorPopover) colorPopover.classList.add("hidden");
closeEl(colorPopover);
}
function openColorPopover(
@ -830,7 +1108,7 @@ export function initBook(api: FolioAPI) {
});
pop.appendChild(remove);
pop.classList.remove("hidden");
openEl(pop);
const pw = pop.offsetWidth;
const ph = pop.offsetHeight;
const rect = anchor.getBoundingClientRect();
@ -977,6 +1255,7 @@ export function initBook(api: FolioAPI) {
async function saveCurrentChapter(): Promise<void> {
if (!selectedId) return;
clearTimers();
const res = await api.saveChapterDoc(selectedId, getEditorDoc(editor));
if (!(res && "error" in res)) {
loadedDocJson = JSON.stringify(getEditorDoc(editor));
@ -997,20 +1276,15 @@ export function initBook(api: FolioAPI) {
}
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();
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;
renderChapterList();
updateCommentsBadge();
try {
const res = await api.loadChapterDoc(id);
const doc =
@ -1024,7 +1298,7 @@ export function initBook(api: FolioAPI) {
dirty = false;
saveStateEl.textContent = "Saved";
saveStateEl.classList.remove("unsaved");
wordCountEl.textContent = `words: ${countWords(editor.getText())}`;
wordCountEl.textContent = formatWordCount(editor.getText());
editor.commands.focus("end");
clearTimers();
fullBookBtn.classList.remove("active");
@ -1087,7 +1361,7 @@ export function initBook(api: FolioAPI) {
fullBookChapters = chapterEntries.map((e, i) => ({ ...e, el: fullBookChapterEls[i] ?? null }));
populateChapterNav(chapterEntries);
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);
// Re-render replaces the whole document, which resets the scroll position,
// 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; }
}
// 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() {
if (!selectedId) return;
const json = JSON.stringify(getEditorDoc(editor));
dirty = json !== loadedDocJson;
saveStateEl.textContent = dirty ? "Unsaved" : "Saved";
saveStateEl.classList.toggle("unsaved", dirty);
wordCountEl.textContent = `words: ${countWords(editor.getText())}`;
wordCountEl.textContent = formatWordCount(editor.getText());
if (dirty) scheduleAutosave();
}
function handleSelection() {
@ -1292,6 +1585,12 @@ export function initBook(api: FolioAPI) {
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() {
if (!meta) return;
const count = meta.chapterOrder.length + 1;
@ -1421,52 +1720,56 @@ export function initBook(api: FolioAPI) {
}
async function renderRecents() {
const recents = await api.getRecents();
recentList.innerHTML = "";
if (!recents.length) {
const li = document.createElement("li");
li.className = "muted";
li.textContent = "No recent books.";
recentList.appendChild(li);
return;
}
for (const r of recents as Recent[]) {
const li = document.createElement("li");
li.className = "recent-item";
const label = document.createElement("span");
label.className = "recent-title";
label.textContent = r.title || r.path;
label.title = r.path;
label.addEventListener("mousedown", (e) => {
e.preventDefault();
void openPath(r.path);
});
const del = document.createElement("button");
del.className = "recent-delete icon-button";
del.type = "button";
del.title = "Delete book";
del.setAttribute("aria-label", "Delete book");
del.textContent = "×";
del.addEventListener("click", async (e) => {
e.stopPropagation();
const res = await api.deleteBook(r.path);
if (res && "error" in res) {
await alertMessage("Could not delete", (res as { error: string }).error);
return;
}
await renderRecents();
showUndoToast(`Deleted "${r.title || r.path}"`, async () => {
const rr = await api.restoreBook(r.path);
if (rr && "error" in rr) {
await alertMessage("Could not restore", (rr as { error: string }).error);
try {
const recents = await api.getRecents();
recentList.innerHTML = "";
if (!recents.length) {
const li = document.createElement("li");
li.className = "muted";
li.textContent = "No recent books.";
recentList.appendChild(li);
return;
}
for (const r of recents as (Recent & { missing?: boolean })[]) {
const li = document.createElement("li");
li.className = "recent-item";
const label = document.createElement("span");
label.className = "recent-title";
label.textContent = (r.title || r.path) + (r.missing ? " (missing)" : "");
label.title = r.path;
label.addEventListener("mousedown", (e) => {
e.preventDefault();
void openPath(r.path);
});
const del = document.createElement("button");
del.className = "recent-delete icon-button";
del.type = "button";
del.title = "Delete book";
del.setAttribute("aria-label", "Delete book");
del.textContent = "×";
del.addEventListener("click", async (e) => {
e.stopPropagation();
const res = await api.deleteBook(r.path);
if (res && "error" in res) {
await alertMessage("Could not delete", (res as { error: string }).error);
return;
}
await renderRecents();
showUndoToast(`Deleted "${r.title || r.path}"`, async () => {
const rr = await api.restoreBook(r.path);
if (rr && "error" in rr) {
await alertMessage("Could not restore", (rr as { error: string }).error);
return;
}
await renderRecents();
});
});
});
li.appendChild(label);
li.appendChild(del);
recentList.appendChild(li);
li.appendChild(label);
li.appendChild(del);
recentList.appendChild(li);
}
} catch {
// Recents are best-effort; never block the welcome screen.
}
}
@ -1549,17 +1852,14 @@ export function initBook(api: FolioAPI) {
$("welcomeOpenBook").addEventListener("click", () => void startOpenBook());
const homeBtn = $("homeBtn");
homeBtn?.addEventListener("click", async () => {
clearTimers();
if (selectedId && dirty) {
const ok = await confirmMessage(
"Unsaved changes",
"Save changes to this chapter before closing the book?"
);
if (ok) await saveCurrentChapter();
else return;
// Auto-save silently instead of prompting, so closing the book never
// loses the chapter's text.
await saveCurrentChapter();
}
void api.closeBook();
});
newChapterBtn.addEventListener("click", () => void create());
addChapterBtn.addEventListener("click", () => void create());
exportBtn.addEventListener("click", () => void exportMarkdown());
exportZipBtn.addEventListener("click", () => void exportZip());
@ -1571,7 +1871,7 @@ export function initBook(api: FolioAPI) {
if (idx >= 0) jumpToChapter(idx);
});
function closeExportMenu() {
exportMenu.classList.add("hidden");
closeEl(exportMenu);
exportMenuToggle.classList.remove("open");
}
@ -1579,7 +1879,7 @@ export function initBook(api: FolioAPI) {
e.stopPropagation();
const wasOpen = !exportMenu.classList.contains("hidden");
closeExportMenu();
if (!wasOpen) exportMenu.classList.remove("hidden");
if (!wasOpen) openEl(exportMenu);
exportMenuToggle.classList.toggle("open", wasOpen);
});
@ -1637,11 +1937,11 @@ export function initBook(api: FolioAPI) {
li.appendChild(label);
exportChapterItems.appendChild(li);
}
exportModal.classList.remove("hidden");
openEl(exportModal);
}
function closeExportModal() {
exportModal.classList.add("hidden");
closeEl(exportModal);
pendingExportFormat = null;
}
@ -1723,7 +2023,7 @@ export function initBook(api: FolioAPI) {
fullBookStatus.classList.remove("unsaved");
}, 3000);
});
$("revealBtn").addEventListener("click", () => {
$("revealBtn")?.addEventListener("click", () => {
if (currentBook) void api.revealInFolder(currentBook.path);
});
@ -1733,7 +2033,14 @@ export function initBook(api: FolioAPI) {
api.onNewBookRequested(() => void startNewBook());
api.onOpenBookRequested(() => void startOpenBook());
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.onExportZipRequested(() => void exportZip());
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);
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);
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; }
::-webkit-scrollbar {
@ -68,8 +79,8 @@ html, body {
}
.brand-icon {
width: 64px;
height: 64px;
width: 80px;
height: 80px;
flex: none;
object-fit: contain;
}
@ -163,6 +174,53 @@ html, body {
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 {
margin-top: var(--gap);
@ -318,8 +376,17 @@ html, body {
border-color: var(--color-accent);
}
/* The chapters "+" must stay clearly visible (white) in dark mode. */
:root[data-theme="dark-mode"] #addChapterBtn {
/* Home button pairs an icon with a text label. */
#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;
}
@ -332,11 +399,19 @@ html, body {
display: none;
}
: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;
}
: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;
}
@ -354,8 +429,8 @@ html, body {
}
.welcome-brand {
width: 128px;
height: 128px;
width: 160px;
height: 160px;
object-fit: contain;
margin-bottom: 12px;
}
@ -1139,6 +1214,72 @@ body.dragging-active * {
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 {
height: 1px;
background: var(--color-border);
@ -1326,6 +1467,113 @@ body.focus-mode .editor {
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 {
opacity: 0.5;
cursor: not-allowed;
@ -1337,7 +1585,7 @@ body.focus-mode .editor {
height: 100%;
min-height: 100%;
overflow-y: auto;
font-family: var(--font-serif);
font-family: var(--font-sans);
font-size: 1.05rem;
line-height: 1.7;
color: var(--color-text);
@ -1461,7 +1709,143 @@ body.focus-mode .editor {
border-radius: 2px;
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;
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" />
<title>Folio</title>
<link rel="stylesheet" href="./themes.css" />
<link rel="stylesheet" href="./fonts.css" />
<link rel="stylesheet" href="./index.css" />
</head>
<body>
@ -19,10 +20,9 @@
<button class="icon-button" id="addChapterBtn" title="Add chapter">+</button>
</div>
<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-path" id="bookPath" title=""></span>
<button class="icon-button" id="revealBtn" title="Reveal in folder">📁</button>
</div>
<ul class="chapter-list" id="chapterList"></ul>
<div class="full-book-nav hidden" id="fullBookNav">
@ -35,9 +35,10 @@
<select id="themeSelect" class="theme-select" title="Interface theme">
<option value="light">Light</option>
<option value="dark-mode">Dark Mode</option>
<option value="neon">Neon</option>
<option value="meridian">Meridian</option>
</select>
</div>
<button class="button button-ghost" id="newChapterBtn">+ New Chapter</button>
</div>
</aside>
<main class="editor" id="editor">
@ -47,11 +48,11 @@
<h1 class="app-name">Folio</h1>
<p class="app-tagline">Your stories, in folders you choose.</p>
<div class="welcome-actions">
<button class="button" id="welcomeNewBook">New Book</button>
<button class="button button-ghost" id="welcomeOpenBook">Open Book</button>
<button class="button" id="welcomeNewBook">New</button>
<button class="button button-ghost" id="welcomeOpenBook">Open</button>
</div>
<div class="recents-block">
<div class="recents-title">Recent Projects</div>
<div class="recents-title">Recent</div>
<ul class="recent-list" id="recentList">
<li class="muted">No recent books.</li>
</ul>
@ -64,6 +65,8 @@
<span class="save-state" id="saveState">Saved</span>
<button class="button button-ghost" id="saveBtn" title="Save chapter (Ctrl+S)">Save</button>
<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="exportZipBtn" title="Export bundle (ZIP)">📦</button>
<div class="zoom-control" role="group" aria-label="Zoom">
@ -94,20 +97,13 @@
<option value="5">Heading 5</option>
<option value="6">Heading 6</option>
</select>
<select id="fontFamilySelect" class="toolbar-select" title="Font family">
<option value="">Serif</option>
<option value="DejaVu Serif, Liberation Serif, serif">DejaVu Serif</option>
<option value="C059, Times New Roman, serif">C059</option>
<option value="Bitstream Charter, serif">Bitstream Charter</option>
<option value="IBM Plex Serif, serif">IBM Plex Serif</option>
<option value="URW Bookman, DejaVu Serif, serif">Bookman</option>
<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>
<div class="toolbar-dropdown" id="fontDropdown">
<button class="toolbar-select dropdown-toggle" id="fontToggle" data-dropdown="fontMenu" title="Font family" type="button">
<span id="fontLabel">Inter</span>
<span class="caret">&#9662;</span>
</button>
<div class="dropdown-menu font-menu hidden" id="fontMenu"></div>
</div>
<select id="fontSizeSelect" class="toolbar-select" title="Font size">
<option value="">17px</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 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 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>
</div>
<div class="toolbar-group">
@ -279,18 +275,44 @@
</div>
<div class="modal-backdrop hidden" id="searchModal">
<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">
<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>
</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>
<ul class="search-results" id="searchResults"></ul>
<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>
</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 export-modal" role="dialog" aria-modal="true" aria-label="Export book">
<h3 class="modal-title">Export <span id="exportFormatLabel"></span></h3>

View file

@ -9,6 +9,12 @@ declare global {
}
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);
initBook(window.folio);
});

View file

@ -2,14 +2,18 @@
Every interface color is driven by the semantic variables below; the UI and
editor styles only ever reference these variables. A theme is therefore
just a block of variable values — adding one means dropping a
`:root[data-theme="..."]` block here plus an <option> in the theme selector.
`:root[data-theme="..."]` block here plus an <option> in the theme selector.
Light is Folio's default: a warm, writing-focused light scheme. Dark Mode is
a fixed dark, futuristic theme. */
Light is Folio's default: a warm, writing-focused light scheme. Dark Mode is
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 ---- */
:root {
--font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
--font-sans: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
"Helvetica Neue", Arial, sans-serif;
--font-serif: Georgia, "Times New Roman", serif;
--radius: 8px;
@ -73,4 +77,130 @@
--color-scrollbar: #263047;
--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": {
const c = payload.color ? String(payload.color) : "";
if (c) chain().toggleHighlight({ color: c }).run();
else chain().unsetHighlight().run();
if (c) {
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;
}
case "fontfamily": {