Add Neon and Meridian glass themes, bundled fonts, configurable recovery backup location, autosave and UI polish
This commit is contained in:
parent
aaa491424d
commit
b74cfe98ab
59 changed files with 1848 additions and 185 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -5,3 +5,5 @@ dist/
|
||||||
releases/
|
releases/
|
||||||
.DS_Store
|
.DS_Store
|
||||||
tests/.tmp-folio-format-*.mjs
|
tests/.tmp-folio-format-*.mjs
|
||||||
|
|
||||||
|
.opencode/
|
||||||
|
|
|
||||||
|
|
@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
BIN
public/fonts/dm-sans-bold.woff2
Normal file
BIN
public/fonts/dm-sans-bold.woff2
Normal file
Binary file not shown.
BIN
public/fonts/dm-sans-regular.woff2
Normal file
BIN
public/fonts/dm-sans-regular.woff2
Normal file
Binary file not shown.
BIN
public/fonts/inter-bold.woff2
Normal file
BIN
public/fonts/inter-bold.woff2
Normal file
Binary file not shown.
BIN
public/fonts/inter-regular.woff2
Normal file
BIN
public/fonts/inter-regular.woff2
Normal file
Binary file not shown.
BIN
public/fonts/lato-bold.woff2
Normal file
BIN
public/fonts/lato-bold.woff2
Normal file
Binary file not shown.
BIN
public/fonts/lato-regular.woff2
Normal file
BIN
public/fonts/lato-regular.woff2
Normal file
Binary file not shown.
BIN
public/fonts/libre-baskerville-bold.woff2
Normal file
BIN
public/fonts/libre-baskerville-bold.woff2
Normal file
Binary file not shown.
BIN
public/fonts/libre-baskerville-regular.woff2
Normal file
BIN
public/fonts/libre-baskerville-regular.woff2
Normal file
Binary file not shown.
BIN
public/fonts/merriweather-bold.woff2
Normal file
BIN
public/fonts/merriweather-bold.woff2
Normal file
Binary file not shown.
BIN
public/fonts/merriweather-regular.woff2
Normal file
BIN
public/fonts/merriweather-regular.woff2
Normal file
Binary file not shown.
BIN
public/fonts/montserrat-bold.woff2
Normal file
BIN
public/fonts/montserrat-bold.woff2
Normal file
Binary file not shown.
BIN
public/fonts/montserrat-regular.woff2
Normal file
BIN
public/fonts/montserrat-regular.woff2
Normal file
Binary file not shown.
BIN
public/fonts/noto-sans-bold.woff2
Normal file
BIN
public/fonts/noto-sans-bold.woff2
Normal file
Binary file not shown.
BIN
public/fonts/noto-sans-regular.woff2
Normal file
BIN
public/fonts/noto-sans-regular.woff2
Normal file
Binary file not shown.
BIN
public/fonts/nunito-bold.woff2
Normal file
BIN
public/fonts/nunito-bold.woff2
Normal file
Binary file not shown.
BIN
public/fonts/nunito-regular.woff2
Normal file
BIN
public/fonts/nunito-regular.woff2
Normal file
Binary file not shown.
BIN
public/fonts/open-sans-bold.woff2
Normal file
BIN
public/fonts/open-sans-bold.woff2
Normal file
Binary file not shown.
BIN
public/fonts/open-sans-regular.woff2
Normal file
BIN
public/fonts/open-sans-regular.woff2
Normal file
Binary file not shown.
BIN
public/fonts/oswald-bold.woff2
Normal file
BIN
public/fonts/oswald-bold.woff2
Normal file
Binary file not shown.
BIN
public/fonts/oswald-regular.woff2
Normal file
BIN
public/fonts/oswald-regular.woff2
Normal file
Binary file not shown.
BIN
public/fonts/playfair-display-bold.woff2
Normal file
BIN
public/fonts/playfair-display-bold.woff2
Normal file
Binary file not shown.
BIN
public/fonts/playfair-display-regular.woff2
Normal file
BIN
public/fonts/playfair-display-regular.woff2
Normal file
Binary file not shown.
BIN
public/fonts/poppins-bold.woff2
Normal file
BIN
public/fonts/poppins-bold.woff2
Normal file
Binary file not shown.
BIN
public/fonts/poppins-regular.woff2
Normal file
BIN
public/fonts/poppins-regular.woff2
Normal file
Binary file not shown.
BIN
public/fonts/pt-sans-bold.woff2
Normal file
BIN
public/fonts/pt-sans-bold.woff2
Normal file
Binary file not shown.
BIN
public/fonts/pt-sans-regular.woff2
Normal file
BIN
public/fonts/pt-sans-regular.woff2
Normal file
Binary file not shown.
BIN
public/fonts/quicksand-bold.woff2
Normal file
BIN
public/fonts/quicksand-bold.woff2
Normal file
Binary file not shown.
BIN
public/fonts/quicksand-regular.woff2
Normal file
BIN
public/fonts/quicksand-regular.woff2
Normal file
Binary file not shown.
BIN
public/fonts/raleway-bold.woff2
Normal file
BIN
public/fonts/raleway-bold.woff2
Normal file
Binary file not shown.
BIN
public/fonts/raleway-regular.woff2
Normal file
BIN
public/fonts/raleway-regular.woff2
Normal file
Binary file not shown.
BIN
public/fonts/roboto-bold.woff2
Normal file
BIN
public/fonts/roboto-bold.woff2
Normal file
Binary file not shown.
BIN
public/fonts/roboto-regular.woff2
Normal file
BIN
public/fonts/roboto-regular.woff2
Normal file
Binary file not shown.
BIN
public/fonts/roboto-slab-bold.woff2
Normal file
BIN
public/fonts/roboto-slab-bold.woff2
Normal file
Binary file not shown.
BIN
public/fonts/roboto-slab-regular.woff2
Normal file
BIN
public/fonts/roboto-slab-regular.woff2
Normal file
Binary file not shown.
BIN
public/fonts/source-sans-3-bold.woff2
Normal file
BIN
public/fonts/source-sans-3-bold.woff2
Normal file
Binary file not shown.
BIN
public/fonts/source-sans-3-regular.woff2
Normal file
BIN
public/fonts/source-sans-3-regular.woff2
Normal file
Binary file not shown.
BIN
public/fonts/ubuntu-bold.woff2
Normal file
BIN
public/fonts/ubuntu-bold.woff2
Normal file
Binary file not shown.
BIN
public/fonts/ubuntu-regular.woff2
Normal file
BIN
public/fonts/ubuntu-regular.woff2
Normal file
Binary file not shown.
BIN
public/fonts/work-sans-bold.woff2
Normal file
BIN
public/fonts/work-sans-bold.woff2
Normal file
Binary file not shown.
BIN
public/fonts/work-sans-regular.woff2
Normal file
BIN
public/fonts/work-sans-regular.woff2
Normal file
Binary file not shown.
108
scripts/fetch-fonts.mjs
Normal file
108
scripts/fetch-fonts.mjs
Normal 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).`);
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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({
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)),
|
||||||
|
|
|
||||||
|
|
@ -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";
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
303
src/renderer/fonts.css
Normal 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");
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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">▾</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">≡</button>
|
<button class="toolbar-btn hl" data-cmd="highlight" title="Highlight selection (click again to remove). Pick a color from the ▮ menu.">≡</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 & 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>
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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": {
|
||||||
|
|
|
||||||
|
|
@ -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 });
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue