refactor(renderer): split book.ts into 12 focused modules

- Extract chapter-list, editor, exports, full-book, search, toolbar, ui, welcome, comments, types
- Add shared native-doc-core.ts for document contracts
- Update main process, menu, and native-doc for new structure
- Theme and CSS updates
This commit is contained in:
avi 2026-08-29 20:16:12 -05:00
commit a2d2d4858b
26 changed files with 3298 additions and 2554 deletions

49
.impeccable/config.json Normal file
View file

@ -0,0 +1,49 @@
{
"detector": {
"ignoreRules": [],
"ignoreFiles": [
"releases/linux-unpacked/LICENSES.chromium.html"
],
"ignoreValues": [
{
"rule": "overused-font",
"value": "inter",
"createdAt": "2026-08-29T16:09:55.801Z",
"reason": "Folio writing app intentionally uses Inter for UI shell and bundles common writing fonts for user choice"
},
{
"rule": "overused-font",
"value": "roboto",
"createdAt": "2026-08-29T16:10:01.545Z",
"reason": "Bundled writing font choice"
},
{
"rule": "overused-font",
"value": "open sans",
"createdAt": "2026-08-29T16:10:01.593Z",
"reason": "Bundled writing font choice"
},
{
"rule": "overused-font",
"value": "lato",
"createdAt": "2026-08-29T16:10:01.642Z",
"reason": "Bundled writing font choice"
},
{
"rule": "overused-font",
"value": "montserrat",
"createdAt": "2026-08-29T16:10:01.696Z",
"reason": "Bundled writing font choice"
},
{
"rule": "clipped-overflow-container",
"value": "*",
"files": [
"src/renderer/index.html"
],
"createdAt": "2026-08-29T16:12:00.835Z",
"reason": "Hidden chapter views are display:none; main editor fixed to visible for dropdowns"
}
]
}
}

View file

@ -144,9 +144,13 @@ function notifyChange() {
// unhandled promise rejection that the UI would silently swallow. // unhandled promise rejection that the UI would silently swallow.
/* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/no-explicit-any */
function handleIpc(channel: string, fn: (...args: any[]) => unknown): void { function handleIpc(channel: string, fn: (...args: any[]) => unknown): void {
ipcMain.handle(channel, async (_e, ...args: any[]) => { ipcMain.handle(channel, async (event, ...args: any[]) => {
try { try {
return await fn(...args); // Handlers follow the raw ipcMain convention of (event, ...args); the
// unified wrapper must forward the event so their declared params line
// up with what preload actually sent. Dropping it here shifted every
// argument left by one ("The path argument must be of type string").
return await fn(event, ...args);
} catch (err) { } catch (err) {
const msg = err instanceof Error ? err.message : String(err); const msg = err instanceof Error ? err.message : String(err);
console.error(`[folio] ${channel} failed: ${msg}`); console.error(`[folio] ${channel} failed: ${msg}`);

View file

@ -36,8 +36,8 @@ export function buildMenu(
const fileMenu: MenuItem = { const fileMenu: MenuItem = {
label: "File", label: "File",
submenu: [ submenu: [
{ label: "New Book", accelerator: "CmdOrCtrl+N", click: onRequestNew }, { label: "New", accelerator: "CmdOrCtrl+N", click: onRequestNew },
{ label: "Open Book", accelerator: "CmdOrCtrl+O", click: onRequestOpen }, { label: "Open", accelerator: "CmdOrCtrl+O", click: onRequestOpen },
{ type: "separator" }, { type: "separator" },
{ {
label: "Save", label: "Save",

View file

@ -1,16 +1,15 @@
// Native Folio rich-text document model. // Native Folio chapter persistence (main process only).
// //
// This is the single source of truth for chapter content once a book is // The pure document model — types, format constants, serialization and
// migrated to the native format. The document is a ProseMirror/Tiptap JSON // migration logic — lives in src/shared/native-doc-core.ts so the renderer
// tree, not Markdown. See Stage 2 of the rich-text migration plan. // can bundle it without dragging Node builtins into the browser build. This
// module keeps only the disk IO and re-exports the core, so existing
// main-process imports are unaffected.
// //
// Persistence layout (per book): // Persistence layout (per book):
// folio.json -> book metadata (chapterOrder, chapters, docFormat, ...) // folio.json -> book metadata (chapterOrder, chapters, docFormat, ...)
// chapters/<id>.json -> { schemaVersion, doc } (native content) // chapters/<id>.json -> { schemaVersion, doc } (native content)
// assets/ -> copied image assets referenced by chapters // assets/ -> copied image assets referenced by chapters
//
// Markdown remains only for legacy migration (this module does the parsing)
// and optional export (added later).
import fs from "fs"; import fs from "fs";
import path from "path"; import path from "path";
@ -19,135 +18,14 @@ import {
saveMeta, saveMeta,
chaptersDir, chaptersDir,
} from "./project.js"; } from "./project.js";
import {
nativeChapterFile,
serializeChapter,
parseChapter,
type FolioChapterDoc,
} from "../shared/native-doc-core.js";
export interface FolioMark { export * from "../shared/native-doc-core.js";
type: string;
attrs?: Record<string, unknown>;
}
export interface FolioNode {
type: string;
attrs?: Record<string, unknown>;
content?: FolioNode[];
text?: string;
marks?: FolioMark[];
}
export interface FolioChapterDoc {
schemaVersion: number;
doc: FolioNode;
}
// Bump this whenever the native document shape needs a one-way migration.
export const CURRENT_DOC_SCHEMA_VERSION = 1;
// ---- Cross-converter contracts -------------------------------------------
// These string constants are part of the document format itself. They are
// consumed by every module that reads or writes native docs (markdown-to-native,
// native-to-markdown, renderer/print-format), so they live here — next to the
// schema they belong to — instead of being re-typed as literals in each
// converter. Changing any of them changes what is persisted on disk.
//
// Callout title paragraphs carry this class so serializers can tell the title
// apart from body paragraphs (set on parse, stripped on serialize/render).
export const CALLOUT_TITLE_CLASS = "callout-title";
// Internal chapter-link scheme: folio:chapter/<id>. Serializers turn it back
// into [[wikilinks]]; the wikilink plugin turns [[text]] into it on parse.
export const CHAPTER_LINK_PREFIX = "folio:chapter/";
// ---- Chapter presentation helpers ----------------------------------------
// Shared by every assembled/exported rendering of a chapter (Full Book view,
// PDF print, DOCX/ODT) so "does this chapter already carry its own title?"
// has exactly one answer everywhere. Before this lived in two files with
// different rules, DOCX and PDF could disagree about injecting the title.
/** Concatenated plain text of a node subtree (atom leaves contribute nothing). */
export function nativeNodeText(node: FolioNode): string {
if (node.text) return node.text;
let t = "";
for (const c of node.content ?? []) t += nativeNodeText(c);
return t;
}
/** Whether any top-level heading in the content already matches the title
* (case/space-insensitive). If true, renderers must not inject a duplicate
* title heading ahead of the content. */
export function nativeHasTitleHeading(content: FolioNode[], title: string): boolean {
const t = title.trim().toLowerCase();
if (!t) return false;
for (const n of content) {
if (n.type === "heading" && nativeNodeText(n).trim().toLowerCase() === t) return true;
}
return false;
}
/** Top-level nodes for one chapter, injecting a title heading when the
* content does not already provide one. */
export function buildChapterNativeContent(content: FolioNode[], title: string): FolioNode[] {
if (nativeHasTitleHeading(content, title)) return content.slice();
return [
{ type: "heading", attrs: { level: 1 }, content: [{ type: "text", text: title }] },
...content,
];
}
// Each entry migrates the document FROM its key version TO key+1.
// Entries are additive and must be non-destructive.
const MIGRATIONS: Record<number, (doc: FolioChapterDoc) => FolioChapterDoc> = {};
// File name for a native chapter (replaces <id>.md).
export function nativeChapterFile(id: string): string {
return `${id}.json`;
}
// Apply any pending schema migrations, always returning the current version.
export function migrateDoc(doc: FolioChapterDoc): FolioChapterDoc {
let current = doc;
while (current.schemaVersion < CURRENT_DOC_SCHEMA_VERSION) {
const step = MIGRATIONS[current.schemaVersion];
if (!step) break;
current = step(current);
}
current.schemaVersion = CURRENT_DOC_SCHEMA_VERSION;
return current;
}
// Normalize legacy node type names on read. The editor's StarterKit historically
// emitted Tiptap's default `hardBreak` (camelCase), while the converter/export
// pipeline uses `hard_break` (snake_case). Rename so every consumer sees one
// canonical name regardless of how the document was originally saved.
function normalizeDocTypes(node: unknown): unknown {
if (!node || typeof node !== "object") return node;
const n = node as Record<string, any>;
if (typeof n.type === "string" && n.type === "hardBreak") n.type = "hard_break";
if (Array.isArray(n.content)) n.content = n.content.map((c) => normalizeDocTypes(c));
return n;
}
export function serializeChapter(doc: FolioChapterDoc): string {
return JSON.stringify(migrateDoc(doc), null, 2);
}
export function parseChapter(text: string): FolioChapterDoc {
let raw: unknown;
try {
raw = JSON.parse(text);
} catch {
throw new Error("Not a native Folio chapter document (invalid JSON).");
}
if (
!raw ||
typeof raw !== "object" ||
!("doc" in raw) ||
(raw as FolioChapterDoc).doc?.type !== "doc"
) {
throw new Error("Not a native Folio chapter document (missing doc tree).");
}
const parsed = migrateDoc(raw as FolioChapterDoc);
normalizeDocTypes(parsed.doc);
return parsed;
}
// Read a native chapter document (throws if the chapter is not native JSON). // Read a native chapter document (throws if the chapter is not native JSON).
export function getChapterDoc(bookPath: string, id: string): FolioChapterDoc { export function getChapterDoc(bookPath: string, id: string): FolioChapterDoc {
@ -170,9 +48,4 @@ export function setChapterDoc(
fs.writeFileSync(file, serializeChapter(doc), "utf-8"); fs.writeFileSync(file, serializeChapter(doc), "utf-8");
} }
// Validate that a parsed object is structurally a usable chapter document.
export function isValidDoc(doc: FolioChapterDoc): boolean {
return !!doc && doc.doc?.type === "doc" && Array.isArray(doc.doc.content);
}
export { loadBook, saveMeta }; export { loadBook, saveMeta };

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,384 @@
// Seam 5: the chapter sidebar list.
//
// Row rendering, the chapter-color popover, CRUD with undo toasts, canonical
// order persistence, and pointer-based drag reordering. Drag uses pointer
// events rather than native HTML5 DnD: Wayland's compositor controls the
// no-drop cursor and ignores dropEffect, but respects a normal CSS cursor
// (grab/grabbing).
//
// Cross-feature calls go through BookHooks exclusively — no sibling imports.
import type { BookCtx } from "./types.js";
import { alertMessage, promptInput } from "../dialog.js";
import {
$,
closeEl,
moveInList,
openEl,
showUndoToast,
} from "./ui.js";
export interface ChapterListFeature {
renderChapterList(): void;
setChapterButtons(enabled: boolean): void;
}
export function createChapterListFeature(ctx: BookCtx): ChapterListFeature {
const { api, state, hooks } = ctx;
const chapterList = $("chapterList") as HTMLUListElement;
const addChapterBtn = $("addChapterBtn") as HTMLButtonElement;
function setChapterButtons(enabled: boolean) {
addChapterBtn.disabled = !enabled;
$("fullBookNav").classList.toggle("hidden", !enabled);
}
// Predefined palette used for the chapter-color picker (kept in sync with the
// text/highlight palettes so the whole app feels consistent).
const CHAPTER_COLORS = [
"#e11d48", "#f97316", "#eab308", "#22c55e",
"#3b82f6", "#8b5cf6", "#78716c", "#18181b",
];
const HEX_RE = /^#[0-9a-fA-F]{3,8}$/;
let colorPopover: HTMLDivElement | null = null;
function closeColorPopover() {
closeEl(colorPopover);
}
function openColorPopover(
anchor: HTMLElement,
current: string | null,
onPick: (c: string | null) => void
) {
if (!colorPopover) {
colorPopover = document.createElement("div");
colorPopover.className = "color-popover hidden";
document.body.appendChild(colorPopover);
}
const pop = colorPopover;
pop.innerHTML = "";
const grid = document.createElement("div");
grid.className = "cp-grid";
for (const c of CHAPTER_COLORS) {
const sw = document.createElement("button");
sw.type = "button";
sw.className = "cp-swatch" + (current === c ? " active" : "");
sw.style.background = c;
sw.title = c;
sw.setAttribute("aria-label", c);
sw.addEventListener("click", (e) => {
e.stopPropagation();
closeColorPopover();
onPick(c);
});
grid.appendChild(sw);
}
pop.appendChild(grid);
const remove = document.createElement("button");
remove.type = "button";
remove.className = "cp-remove";
remove.textContent = current ? "Remove color" : "No color set";
remove.disabled = !current;
remove.addEventListener("click", (e) => {
e.stopPropagation();
closeColorPopover();
onPick(null);
});
pop.appendChild(remove);
openEl(pop);
const pw = pop.offsetWidth;
const ph = pop.offsetHeight;
const rect = anchor.getBoundingClientRect();
let left = rect.left;
let top = rect.bottom + 4;
if (left + pw > window.innerWidth - 8) {
left = Math.max(8, window.innerWidth - pw - 8);
}
if (top + ph > window.innerHeight - 8) {
top = Math.max(8, rect.top - ph - 4);
}
pop.style.left = `${left}px`;
pop.style.top = `${top}px`;
}
// Click outside the popover closes it (the open button stops propagation so it
// doesn't immediately self-close).
document.addEventListener("click", (e) => {
if (colorPopover && !colorPopover.classList.contains("hidden") &&
!colorPopover.contains(e.target as Node)) {
closeColorPopover();
}
});
window.addEventListener("scroll", closeColorPopover, true);
window.addEventListener("resize", closeColorPopover);
async function applyChapterColor(id: string, color: string | null) {
const res = await api.setChapterColor(id, color);
if (res && "error" in res) {
await alertMessage("Could not set chapter color", (res as { error: string }).error);
return;
}
const entry = state.ordered.find((c) => c.id === id);
if (entry) {
if (color === null) delete entry.color;
else entry.color = color;
}
if (state.meta && state.meta.chapters[id]) {
if (color === null) delete state.meta.chapters[id].color;
else state.meta.chapters[id].color = color;
}
renderChapterList();
}
function renderChapterList() {
chapterList.innerHTML = "";
for (const ch of state.ordered) {
const li = document.createElement("li");
li.className = "chapter-row" + (state.selectedId === ch.id ? " active" : "");
li.dataset.id = ch.id;
li.draggable = false;
li.tabIndex = -1;
li.innerHTML = `
<span class="drag-handle" title="Drag to reorder">⋮</span>
<span class="row-title" title="${ch.title}">${ch.title}</span>
<span class="row-actions">
<button class="icon-btn" data-act="color" title="Chapter color">●</button>
<button class="icon-btn" data-act="rename" title="Rename">✎</button>
<button class="icon-btn" data-act="dup" title="Duplicate">⧉</button>
<button class="icon-btn" data-act="del" title="Delete">×</button>
</span>`;
if (ch.color && HEX_RE.test(ch.color)) {
const dot = document.createElement("span");
dot.className = "row-color-dot";
dot.style.background = ch.color;
dot.title = "Chapter color: " + ch.color;
li.insertBefore(dot, li.querySelector(".row-title"));
}
li.addEventListener("click", (e) => {
if (dragJustHappened) {
dragJustHappened = false;
e.stopPropagation();
return;
}
e.stopPropagation();
li.focus();
void hooks.selectChapter(ch.id);
});
li.querySelector('[data-act="color"]')?.addEventListener("click", (e) => {
e.stopPropagation();
openColorPopover(li, ch.color ?? null, (c) => void applyChapterColor(ch.id, c));
});
li.querySelector('[data-act="rename"]')?.addEventListener("click", (e) => {
e.stopPropagation();
void rename(ch.id);
});
li.querySelector('[data-act="dup"]')?.addEventListener("click", (e) => {
e.stopPropagation();
void duplicate(ch.id);
});
li.querySelector('[data-act="del"]')?.addEventListener("click", (e) => {
e.stopPropagation();
void del(ch.id);
});
li.addEventListener("keydown", (e) => {
if (!e.altKey) return;
if (e.key === "ArrowDown") { e.preventDefault(); moveSelectedDown(); }
else if (e.key === "ArrowUp") { e.preventDefault(); moveSelectedUp(); }
});
chapterList.appendChild(li);
}
}
async function create() {
if (!state.meta) return;
const count = state.meta.chapterOrder.length + 1;
const title = await promptInput("New chapter", `Chapter ${count}`);
if (!title) return;
const res = await api.createChapter(title);
if (res && "error" in res) await alertMessage("Could not create chapter", (res as { error: string }).error);
else {
await hooks.refreshMeta();
if (res && "id" in res) void hooks.selectChapter((res as { id: string }).id);
}
}
async function rename(id: string) {
if (!state.meta || !state.meta.chapters[id]) return;
const entry = state.meta.chapters[id];
const title = await promptInput("Rename chapter", entry.title);
if (!title) return;
const res = await api.renameChapter(id, title);
if (res && "error" in res) await alertMessage("Could not rename chapter", (res as { error: string }).error);
else {
await hooks.refreshMeta();
if (state.selectedId === id && state.meta && state.meta.chapters[id]) {
$("chapterTitle").textContent = state.meta.chapters[id].title;
}
}
}
async function del(id: string) {
if (!state.meta || !state.meta.chapters[id]) return;
const entry = state.meta.chapters[id];
const index = state.meta.chapterOrder.indexOf(id);
// Flush unsaved edits before snapshotting: the undo snapshot reads from
// disk, so text typed since the last autosave would otherwise be lost
// even through Undo. Also stop a pending autosave timer from firing
// while the chapter is being removed.
if (state.selectedId === id) {
hooks.clearTimers();
if (state.dirty) await hooks.saveCurrentChapter();
}
const content = (await api.getChapterContent(id)) as string;
const res = await api.deleteChapter(id);
if (res && "error" in res) {
await alertMessage("Could not delete chapter", (res as { error: string }).error);
return;
}
const wasSelected = state.selectedId === id;
if (wasSelected) { state.selectedId = null; hooks.showPlaceholder(); }
await hooks.refreshMeta();
showUndoToast(`Deleted "${entry.title}"`, async () => {
const r = await api.restoreChapter({
id,
title: entry.title,
color: entry.color ?? null,
content,
index,
});
if (r && "error" in r) {
await alertMessage("Could not restore chapter", (r as { error: string }).error);
return;
}
await hooks.refreshMeta();
if (wasSelected) await hooks.selectChapter(id);
});
}
async function duplicate(id: string) {
if (!state.meta || !state.meta.chapters[id]) return;
const entry = state.meta.chapters[id];
const title = await promptInput("Duplicate as", `${entry.title} (copy)`);
if (title === null) return;
const res = await api.duplicateChapter(id, title || undefined);
if (res && "error" in res) await alertMessage("Could not duplicate chapter", (res as { error: string }).error);
else if (res && "id" in res) {
await hooks.refreshMeta();
void hooks.selectChapter((res as { id: string }).id);
}
}
async function persistOrder() {
if (!state.meta) return;
state.meta.chapterOrder = state.ordered.map((c) => c.id);
const res = await api.reorderChapters(state.meta.chapterOrder);
if (res && "error" in res) await alertMessage("Could not reorder chapters", (res as { error: string }).error);
else renderChapterList();
}
function moveSelectedDown() {
if (!state.selectedId) return;
const i = state.ordered.findIndex((c) => c.id === state.selectedId);
if (i < 0 || i >= state.ordered.length - 1) return;
state.ordered = moveInList(state.ordered, i, i + 1);
state.meta!.chapterOrder = state.ordered.map((c) => c.id);
renderChapterList();
void persistOrder();
}
function moveSelectedUp() {
if (!state.selectedId) return;
const i = state.ordered.findIndex((c) => c.id === state.selectedId);
if (i <= 0) return;
state.ordered = moveInList(state.ordered, i, i - 1);
state.meta!.chapterOrder = state.ordered.map((c) => c.id);
renderChapterList();
void persistOrder();
}
// ---- pointer-based reordering ----
let draggingId: string | null = null;
let dragPointerId: number | null = null;
let dragStartY = 0;
let dragMoved = false;
let dragJustHappened = false;
chapterList.addEventListener("pointerdown", (e) => {
if (e.button !== 0) return; // left button only
dragJustHappened = false;
const target = e.target as HTMLElement;
if (target.closest("button, input, a, .row-actions")) return; // don't drag from controls
const li = target.closest("li.chapter-row") as HTMLElement | null;
if (!li) return;
dragPointerId = e.pointerId;
draggingId = li.dataset.id ?? null;
dragStartY = e.clientY;
dragMoved = false;
});
document.addEventListener("pointermove", (e) => {
if (dragPointerId === null || draggingId === null) return;
if (!dragMoved) {
if (Math.abs(e.clientY - dragStartY) < 4) return; // movement threshold
dragMoved = true;
const src = chapterList.querySelector(
`li.chapter-row[data-id="${draggingId}"]`
) as HTMLElement | null;
src?.classList.add("dragging");
document.body.classList.add("dragging-active");
}
const overEl = document.elementFromPoint(e.clientX, e.clientY);
const overLi = overEl?.closest("li.chapter-row") as HTMLElement | null;
chapterList
.querySelectorAll(".chapter-row")
.forEach((n) => n.classList.remove("drop-target"));
if (overLi && overLi.dataset.id !== draggingId) overLi.classList.add("drop-target");
});
document.addEventListener("pointerup", (e) => {
if (dragPointerId === null) return;
dragPointerId = null;
document.body.classList.remove("dragging-active");
if (!dragMoved || !draggingId) {
draggingId = null;
return;
}
dragJustHappened = true;
const overEl = document.elementFromPoint(e.clientX, e.clientY);
const overLi = overEl?.closest("li.chapter-row") as HTMLElement | null;
const targetId = overLi?.dataset.id ?? null;
const src = chapterList.querySelector(
`li.chapter-row[data-id="${draggingId}"]`
) as HTMLElement | null;
src?.classList.remove("dragging");
chapterList
.querySelectorAll(".chapter-row")
.forEach((n) => n.classList.remove("drop-target"));
if (targetId && targetId !== draggingId) {
const from = state.ordered.findIndex((c) => c.id === draggingId);
const to = state.ordered.findIndex((c) => c.id === targetId);
if (from >= 0 && to >= 0) {
state.ordered = moveInList(state.ordered, from, to);
state.meta!.chapterOrder = state.ordered.map((c) => c.id);
renderChapterList();
// Selecting the dragged row must go through selectChapter: assigning
// selectedId directly would pair the id with whatever document is
// still in the editor, and the next autosave would write that
// document under the dragged chapter's id.
void hooks.selectChapter(draggingId);
void persistOrder();
}
}
draggingId = null;
});
addChapterBtn.addEventListener("click", () => void create());
return {
renderChapterList,
setChapterButtons,
};
}

View file

@ -0,0 +1,143 @@
// Comments / annotations modal.
//
// Lists per-chapter comments, adds a comment from the current editor
// selection, deletes comments, and offers "Go to" navigation through the
// search feature's scrollToTerm hook.
//
// Cross-feature calls go through BookHooks exclusively — no sibling imports.
import type { Editor } from "@tiptap/core";
import type { BookCtx } from "./types.js";
import type { ChapterComment } from "../../main/project.js";
import { alertMessage, promptInput } from "../dialog.js";
import { $, closeEl, openEl, selectedText } from "./ui.js";
export interface CommentsFeature {
updateCommentsBadge(): void;
}
export function createCommentsFeature(ctx: BookCtx, editor: Editor): CommentsFeature {
const { api, state, hooks } = ctx;
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 || !state.meta || !state.meta.comments) return [];
return state.meta.comments[id] || [];
}
function updateCommentsBadge(): void {
const n = chapterComments(state.selectedId).length;
commentsBtn.textContent = n ? `💬 ${n}` : "💬";
}
function renderComments(): void {
commentsBody.innerHTML = "";
const id = state.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 hooks.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 (!state.meta) return;
await api.setComments(state.meta.comments || {});
}
async function addComment(): Promise<void> {
if (!state.selectedId) {
await alertMessage("No chapter", "Open a chapter and select text first.");
return;
}
const quote = selectedText(editor).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: state.selectedId,
quote,
text,
createdAt: new Date().toISOString(),
};
if (!state.meta!.comments) state.meta!.comments = {};
if (!state.meta!.comments[state.selectedId]) state.meta!.comments[state.selectedId] = [];
state.meta!.comments[state.selectedId].push(comment);
await persistComments();
renderComments();
updateCommentsBadge();
}
async function deleteComment(id: string): Promise<void> {
if (!state.selectedId || !state.meta || !state.meta.comments) return;
const list = state.meta.comments[state.selectedId] || [];
state.meta.comments[state.selectedId] = list.filter((c) => c.id !== id);
await persistComments();
renderComments();
updateCommentsBadge();
}
function openComments(): void {
if (!state.meta) return;
const title = (state.selectedId && state.meta.chapters[state.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();
});
return {
updateCommentsBadge,
};
}

312
src/renderer/book/editor.ts Normal file
View file

@ -0,0 +1,312 @@
// The chapter editing surface: main Tiptap editor instance, dirty-tracking +
// autosave engine, chapter selection (with stale-load guard), zoom, and the
// writing-statistics modal.
//
// Storage note: state.dirty / state.loadedDocJson are the shared contract
// fields; until Step 7 they are backed by the orchestrator's closure locals
// through the accessor bridge, so writes here are visible to every module.
import type { Editor } from "@tiptap/core";
import type { BookCtx } from "./types.js";
import { alertMessage } from "../dialog.js";
import {
$,
closeEl,
countWords,
formatWordCount,
markdownToPlain,
openEl,
setActive,
showToast,
} from "./ui.js";
import {
createTiptapEditor,
setEditorDoc,
getEditorDoc,
getFormatState,
} from "../tiptap-editor.js";
export interface EditorFeature {
editor: Editor;
selectChapter(id: string): Promise<void>;
saveCurrentChapter(): Promise<void>;
saveAndNotify(): Promise<void>;
flushAutosave(): Promise<void>;
clearTimers(): void;
}
export function createEditorFeature(ctx: BookCtx): EditorFeature {
const { api, state, hooks } = ctx;
// Native rich-text editor (Tiptap / ProseMirror). This is the editing surface
// for a single chapter; its state is native JSON, never Markdown.
const editor = createTiptapEditor($("editorHost"), {
onChange: () => handleEditorChange(),
onSelectionUpdate: () => handleSelection(),
});
// 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;
let saveTimer: ReturnType<typeof setTimeout> | null = null;
const saveStateEl = $("saveState");
function clearTimers() {
if (saveTimer) { clearTimeout(saveTimer); saveTimer = null; }
}
async function saveCurrentChapter(): Promise<void> {
if (!state.selectedId) return;
clearTimers();
const res = await api.saveChapterDoc(state.selectedId, getEditorDoc(editor));
if (!(res && "error" in res)) {
state.loadedDocJson = JSON.stringify(getEditorDoc(editor));
state.dirty = false;
saveStateEl.textContent = "Saved";
saveStateEl.classList.remove("unsaved");
}
}
// Explicit save (Ctrl/Cmd+S or menu) — always give visible feedback.
async function saveAndNotify(): Promise<void> {
if (!state.selectedId) {
if (hooks.isFullBookVisible?.()) {
showToast("All chapters saved");
return;
}
showToast(state.currentBook ? "Nothing to save" : "Nothing to save");
return;
}
await saveCurrentChapter();
showToast("Saved");
}
// Guards against overlapping selections: rapid clicks (or metadata
// refreshes) can interleave loads; only the newest one may commit state.
let selectToken = 0;
async function selectChapter(id: string) {
clearTimers();
if (state.selectedId && state.dirty) {
// Auto-save silently so navigating away never loses text and returning to
// this chapter shows what was just written. The save targets the
// chapter still selected right now, so it happens before reassignment.
await saveCurrentChapter();
}
const token = ++selectToken;
let res: { doc?: never; error?: string } | null = null;
try {
res = await api.loadChapterDoc(id);
} catch {
res = null;
}
// A newer selection superseded this one: drop the stale reply instead of
// pairing its document with the wrong chapter id.
if (token !== selectToken) return;
if (!(res && "doc" in res)) {
// Load failed. Keep the current editor contents and selection intact —
// seeding an empty document here would let the next autosave overwrite
// the real chapter file with near-empty content.
const msg = res && "error" in res ? String(res.error) : "The chapter file could not be read.";
void alertMessage("Couldn't open chapter", msg);
return;
}
state.selectedId = id;
hooks.renderChapterList();
hooks.updateCommentsBadge();
const doc = (res as { doc: never }).doc;
const title = (state.meta && state.meta.chapters[id]?.title) || id;
$("chapterTitle").textContent = title;
setEditorDoc(editor, doc);
state.loadedDocJson = JSON.stringify(getEditorDoc(editor));
state.dirty = false;
saveStateEl.textContent = "Saved";
saveStateEl.classList.remove("unsaved");
$("wordCount").textContent = formatWordCount(editor.getText());
editor.commands.focus("end");
clearTimers();
$("fullBookBtn").classList.remove("active");
$("fullBookView").classList.add("hidden");
$("chapterView").classList.remove("hidden");
$("editorPlaceholder").classList.add("hidden");
}
// 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 (!state.selectedId || !state.dirty) return;
await saveCurrentChapter();
}
function handleEditorChange() {
if (!state.selectedId) return;
const json = JSON.stringify(getEditorDoc(editor));
state.dirty = json !== state.loadedDocJson;
saveStateEl.textContent = state.dirty ? "Unsaved" : "Saved";
saveStateEl.classList.toggle("unsaved", state.dirty);
$("wordCount").textContent = formatWordCount(editor.getText());
if (state.dirty) scheduleAutosave();
}
function handleSelection() {
const headingSelect = $("headingSelect") as HTMLSelectElement;
const fontSizeSelect = $("fontSizeSelect") as HTMLSelectElement;
const lineSpacingSelect = $("lineSpacingSelect") as HTMLSelectElement;
const fmt = getFormatState(editor);
headingSelect.value = String(fmt.heading);
if (fontSizeSelect) fontSizeSelect.value = fmt.fontSize || "16px";
if (lineSpacingSelect) lineSpacingSelect.value = fmt.lineHeight || "1.0";
setActive("bold", fmt.bold);
setActive("italic", fmt.italic);
setActive("strike", fmt.strike);
setActive("underline", fmt.underline);
setActive("highlight", editor.isActive("highlight"));
setActive("code", fmt.code);
setActive("h2", fmt.heading === 2);
setActive("h3", fmt.heading === 3);
setActive("link", fmt.link);
setActive("bullet", fmt.bullet);
setActive("number", fmt.ordered);
setActive("checklist", fmt.task);
setActive("quote", fmt.blockquote);
setActive("align-left", fmt.align === "left" || fmt.align === "");
setActive("align-center", fmt.align === "center");
setActive("align-right", fmt.align === "right");
}
// Ctrl/Cmd+S saves the current chapter (in addition to the menu item).
window.addEventListener("keydown", (e) => {
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "s") {
e.preventDefault();
void saveAndNotify();
}
});
// 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());
// ---- editor zoom (writing view only) ----
// Zoom scales the document content area (the .editor-panes containers) so the
// sidebar/navigation keep their normal size. The chosen level is persisted
// app-wide and re-applied on launch.
let currentZoom = 100;
function applyZoom(pct: number, persist = true) {
const clamped = Math.max(50, Math.min(300, Math.round(pct)));
currentZoom = clamped;
document.querySelectorAll<HTMLElement>(".editor-panes").forEach((el) => {
el.style.zoom = String(clamped / 100);
});
document.querySelectorAll<HTMLElement>(".zoom-level").forEach((el) => {
el.textContent = clamped + "%";
});
if (persist) void api.setZoom(clamped);
}
document.querySelectorAll<HTMLElement>(".zoom-control").forEach((ctrl) => {
ctrl
.querySelector('[data-zoom="out"]')
?.addEventListener("click", () => applyZoom(currentZoom - 10));
ctrl
.querySelector('[data-zoom="in"]')
?.addEventListener("click", () => applyZoom(currentZoom + 10));
ctrl
.querySelector('[data-zoom="reset"]')
?.addEventListener("click", () => applyZoom(100));
});
api.onZoomInRequested(() => applyZoom(currentZoom + 10));
api.onZoomOutRequested(() => applyZoom(currentZoom - 10));
api.onZoomResetRequested(() => applyZoom(100));
void (async () => {
try {
const z = await api.getZoom();
if (typeof z === "number" && z > 0) applyZoom(z, false);
} catch {
/* keep default 100% */
}
})();
// ---- 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 {
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 (state.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();
});
return {
editor,
selectChapter,
saveCurrentChapter,
saveAndNotify,
flushAutosave,
clearTimers,
};
}

View file

@ -0,0 +1,266 @@
// Export workflows: Markdown, zip bundle, PDF/DOCX/ODT (with whole-book vs
// selected-chapters scope modal), menu routing, and native-format migration.
//
// Cross-feature calls go through BookHooks exclusively — no sibling imports.
import type { BookCtx } from "./types.js";
import { alertMessage, confirmMessage } from "../dialog.js";
import { $, basename, closeEl, openEl } from "./ui.js";
export interface ExportsFeature {
exportMarkdown(): void;
exportZip(): void;
exportPdf(chapterIds?: string[] | null): Promise<void>;
exportDocx(chapterIds?: string[] | null): Promise<void>;
exportOdt(chapterIds?: string[] | null): Promise<void>;
migrateBookNative(): Promise<void>;
exportFromMenu(kind: string): Promise<void>;
}
export function createExportsFeature(ctx: BookCtx): ExportsFeature {
const { api, state, hooks } = ctx;
function status(text: string, ms = 3000) {
const saveStateEl = $("saveState");
saveStateEl.textContent = text;
saveStateEl.classList.add("unsaved");
setTimeout(() => {
saveStateEl.textContent = state.dirty ? "Unsaved" : "Saved";
saveStateEl.classList.toggle("unsaved", state.dirty);
}, ms);
}
function exportMarkdown(): void {
if (!state.currentBook) return;
void (async () => {
const res = await api.exportMarkdown();
if (res && "error" in res) await alertMessage("Export failed", (res as { error: string }).error);
else if (res && "filePath" in res) status(`Exported to ${basename(res.filePath as string)}`);
else if (res && "canceled" in res) status("Export canceled");
})();
}
function exportZip(): void {
if (!state.currentBook) return;
void (async () => {
const res = await api.exportZip();
if (res && "error" in res) await alertMessage("Export failed", (res as { error: string }).error);
else if (res && "filePath" in res) status(`Bundle saved to ${basename(res.filePath as string)}`);
else if (res && "canceled" in res) status("Export canceled");
})();
}
// Export the assembled book as a PDF. With no chapter selection it prints the
// whole book (jumping to the Full Book view first if needed); with a selection
// it renders just those chapters into the print container before printing.
async function exportPdf(chapterIds?: string[] | null): Promise<void> {
if (!state.currentBook) return;
if (chapterIds && chapterIds.length) {
await hooks.renderChaptersToPrint(chapterIds);
} else {
if (!hooks.isFullBookVisible()) await hooks.selectFullBook();
}
const res = await api.exportPdf(chapterIds && chapterIds.length ? chapterIds : undefined);
if (res && "error" in res) await alertMessage("Export failed", (res as { error: string }).error);
else if (res && "canceled" in res) hooks.fullBookNotice("Export canceled");
else if (res && "filePath" in res) hooks.fullBookNotice(`PDF saved to ${basename(res.filePath as string)}`);
}
async function exportDocx(chapterIds?: string[] | null): Promise<void> {
if (!state.currentBook) return;
const res = await api.exportDocx(chapterIds && chapterIds.length ? chapterIds : undefined);
if (res && "error" in res) await alertMessage("Export failed", (res as { error: string }).error);
else if (res && "canceled" in res) hooks.fullBookNotice("Export canceled");
else if (res && "filePath" in res) hooks.fullBookNotice(`DOCX saved to ${basename(res.filePath as string)}`);
}
async function exportOdt(chapterIds?: string[] | null): Promise<void> {
if (!state.currentBook) return;
const res = await api.exportOdt(chapterIds && chapterIds.length ? chapterIds : undefined);
if (res && "error" in res) await alertMessage("Export failed", (res as { error: string }).error);
else if (res && "canceled" in res) hooks.fullBookNotice("Export canceled");
else if (res && "filePath" in res) hooks.fullBookNotice(`ODT saved to ${basename(res.filePath as string)}`);
}
async function exportFromMenu(kind: string): Promise<void> {
if (kind === "native") {
await migrateBookNative();
return;
}
// PDF/DOCX/ODT open the scope/selection workflow so the user can export the
// whole book or just selected chapters.
if (kind === "pdf" || kind === "docx" || kind === "odt") {
openExportWorkflow(kind);
}
}
// Explicitly migrate the whole book to the native document format. The main
// process performs a recovery backup and leaves the original .md files in
// place; after this, chapters are stored as native JSON but the (still
// Markdown-based) editor and exports keep working via the format bridge.
async function migrateBookNative(): Promise<void> {
if (!state.currentBook) return;
const confirmed = await confirmMessage(
"Migrate this book to the native document format?",
"Your original Markdown files are kept and a recovery backup is created in ~/Folio-Recovery. This only changes how chapters are stored — nothing else."
);
if (!confirmed) return;
const res = await api.migrateBook();
if (res && "error" in res) {
await alertMessage("Migration failed", (res as { error: string }).error);
return;
}
await hooks.refreshMeta();
if (hooks.isFullBookVisible()) await hooks.refreshFullBook();
hooks.fullBookNotice("Book migrated to native format");
}
// ---- toolbar buttons + dropdown menu ----
$("exportBtn").addEventListener("click", () => void exportMarkdown());
$("exportZipBtn").addEventListener("click", () => void exportZip());
const exportMenuToggle = $("exportMenuToggle") as HTMLButtonElement;
const exportMenu = $("exportMenu");
function closeExportMenu() {
closeEl(exportMenu);
exportMenuToggle.classList.remove("open");
}
exportMenuToggle.addEventListener("click", (e) => {
e.stopPropagation();
const wasOpen = !exportMenu.classList.contains("hidden");
closeExportMenu();
if (!wasOpen) openEl(exportMenu);
exportMenuToggle.classList.toggle("open", wasOpen);
});
exportMenu.addEventListener("click", (e) => {
const item = (e.target as HTMLElement).closest("[data-export]") as HTMLElement | null;
if (!item) return;
closeExportMenu();
void exportFromMenu(item.dataset.export ?? "");
});
document.addEventListener("click", (e) => {
if (!(e.target as HTMLElement).closest("#exportDropdown")) closeExportMenu();
});
// ---- export workflow (whole book vs selected chapters) ----
const exportModal = $("exportModal") as HTMLDivElement;
const exportFormatLabel = $("exportFormatLabel") as HTMLElement;
const exportChapterList = $("exportChapterList") as HTMLElement;
const exportChapterItems = $("exportChapterItems") as HTMLUListElement;
const exportSelectAll = $("exportSelectAll") as HTMLButtonElement;
const exportClearAll = $("exportClearAll") as HTMLButtonElement;
const exportConfirm = $("exportConfirm") as HTMLButtonElement;
const exportCancel = $("exportCancel") as HTMLButtonElement;
const exportError = $("exportError") as HTMLElement;
let pendingExportFormat: "pdf" | "docx" | "odt" | null = null;
function openExportWorkflow(format: string) {
if (format !== "pdf" && format !== "docx" && format !== "odt") return;
pendingExportFormat = format;
exportFormatLabel.textContent = format.toUpperCase();
exportError.classList.add("hidden");
const allRadio = exportModal.querySelector<HTMLInputElement>(
'input[name="exportScope"][value="all"]'
);
const selRadio = exportModal.querySelector<HTMLInputElement>(
'input[name="exportScope"][value="selected"]'
);
if (allRadio) allRadio.checked = true;
if (selRadio) selRadio.checked = false;
exportChapterList.classList.add("hidden");
// Populate the chapter checklist in canonical order (the `ordered` list).
exportChapterItems.innerHTML = "";
for (const ch of state.ordered) {
const li = document.createElement("li");
li.className = "export-chapter-item";
const label = document.createElement("label");
const cb = document.createElement("input");
cb.type = "checkbox";
cb.value = ch.id;
cb.dataset.id = ch.id;
label.appendChild(cb);
const span = document.createElement("span");
span.textContent = ch.title;
label.appendChild(span);
li.appendChild(label);
exportChapterItems.appendChild(li);
}
openEl(exportModal);
}
function closeExportModal() {
closeEl(exportModal);
pendingExportFormat = null;
}
// Selected ids in canonical order (not the order they were clicked).
function getSelectedChapterIds(): string[] {
const checked = new Set(
Array.from(
exportChapterItems.querySelectorAll<HTMLInputElement>('input[type="checkbox"]')
)
.filter((cb) => cb.checked)
.map((cb) => cb.dataset.id as string)
);
return state.ordered.filter((ch) => checked.has(ch.id)).map((ch) => ch.id);
}
exportModal
.querySelectorAll<HTMLInputElement>('input[name="exportScope"]')
.forEach((r) =>
r.addEventListener("change", () => {
const selected =
exportModal.querySelector<HTMLInputElement>('input[name="exportScope"]:checked')
?.value === "selected";
exportChapterList.classList.toggle("hidden", !selected);
})
);
exportSelectAll.addEventListener("click", () => {
exportChapterItems
.querySelectorAll<HTMLInputElement>('input[type="checkbox"]')
.forEach((cb) => (cb.checked = true));
});
exportClearAll.addEventListener("click", () => {
exportChapterItems
.querySelectorAll<HTMLInputElement>('input[type="checkbox"]')
.forEach((cb) => (cb.checked = false));
});
exportCancel.addEventListener("click", closeExportModal);
exportModal.addEventListener("click", (e) => {
if (e.target === exportModal) closeExportModal();
});
exportConfirm.addEventListener("click", async () => {
if (!pendingExportFormat) return;
const scope = exportModal.querySelector<HTMLInputElement>(
'input[name="exportScope"]:checked'
)?.value;
let chapterIds: string[] | null = null;
if (scope === "selected") {
chapterIds = getSelectedChapterIds();
if (chapterIds.length === 0) {
exportError.textContent = "Select at least one chapter to export.";
exportError.classList.remove("hidden");
return;
}
}
const fmt = pendingExportFormat;
closeExportModal();
if (fmt === "pdf") await exportPdf(chapterIds);
else if (fmt === "docx") await exportDocx(chapterIds);
else if (fmt === "odt") await exportOdt(chapterIds);
});
return {
exportMarkdown,
exportZip,
exportPdf,
exportDocx,
exportOdt,
migrateBookNative,
exportFromMenu,
};
}

View file

@ -0,0 +1,297 @@
// Seam 3: the read-only assembled Full Book view.
//
// Renders every chapter's native document back-to-back in a single ProseMirror
// surface, separated by ChapterBreak nodes. Those nodes carry the chapter id as
// a data attribute so navigation can scroll to them via the DOM, and render the
// on-screen separator / print page break. Navigation state tracks every chapter
// in canonical order along with its DOM anchor — the same boundary data that
// drives page breaks, so navigation can never drift from the assembled order.
// The current chapter is kept in sync with the scroll position (manual
// scrolling updates the selector too).
//
// Cross-feature calls go through BookHooks exclusively — no sibling imports.
import type { Editor } from "@tiptap/core";
import type { FolioNode } from "../../shared/native-doc-core.js";
import type { BookMeta } from "../../main/project.js";
import type { WikilinkTarget } from "../wikilinks.js";
import type { BookCtx } from "./types.js";
import { alertMessage } from "../dialog.js";
import { $, countWords } from "./ui.js";
import {
createTiptapEditor,
setEditorDoc,
getEditorDoc,
ChapterBreak,
} from "../tiptap-editor.js";
import {
buildFullBookDoc,
nativeNodeText,
} from "../native-assembly.js";
import { renderPrintChaptersNative } from "../print-format.js";
export interface FullBookFeature {
fullBookEditor: Editor;
isFullBookVisible(): boolean;
refreshFullBook(): Promise<void>;
selectFullBook(): Promise<void>;
renderChaptersToPrint(ids: string[]): Promise<void>;
resetFullBookNav(): void;
fullBookNotice(text: string): void;
}
export function createFullBookFeature(ctx: BookCtx): FullBookFeature {
const { api, state, hooks } = ctx;
const fullBookView = $("fullBookView");
const fullBookHost = $("fullBookHost");
const fullBookStats = $("fullBookStats");
const printBook = $("printBook");
const fullBookStatus = $("fullBookStatus");
const copyAllBtn = $("copyAllBtn") as HTMLButtonElement;
const fbPrevBtn = $("fbPrevBtn") as HTMLButtonElement;
const fbNextBtn = $("fbNextBtn") as HTMLButtonElement;
const fbChapterSelect = $("fbChapterSelect") as HTMLSelectElement;
const fullBookEditor: Editor = createTiptapEditor(
fullBookHost,
{ onChange: () => undefined },
{ editable: false, extensions: [ChapterBreak] }
);
interface FullBookChapterNav {
id: string;
title: string;
el: HTMLElement | null;
}
let fullBookChapterEls: HTMLElement[] = [];
let fullBookChapters: FullBookChapterNav[] = [];
let currentChapterIndex = 0;
let navScrollRaf: number | null = null;
function populateChapterNav(entries: { id: string; title: string }[]) {
fbChapterSelect.innerHTML = "";
for (const e of entries) {
const opt = document.createElement("option");
opt.value = e.id;
opt.textContent = e.title;
fbChapterSelect.appendChild(opt);
}
setCurrentChapter(0);
}
function setCurrentChapter(index: number) {
if (!fullBookChapters.length) currentChapterIndex = 0;
else currentChapterIndex = Math.max(0, Math.min(index, fullBookChapters.length - 1));
updateChapterNavUI();
}
function updateChapterNavUI() {
const n = fullBookChapters.length;
fbPrevBtn.disabled = n === 0 || currentChapterIndex <= 0;
fbNextBtn.disabled = n === 0 || currentChapterIndex >= n - 1;
const entry = fullBookChapters[currentChapterIndex];
if (entry && fbChapterSelect.value !== entry.id) fbChapterSelect.value = entry.id;
}
// Scroll the assembled manuscript so the given chapter's anchor sits near the
// top of the Full Book viewport. This only touches the rendered editor, never
// the chapter files or the assembled content.
function jumpToChapter(index: number) {
if (!fullBookChapters.length) return;
const clamped = Math.max(0, Math.min(index, fullBookChapters.length - 1));
fullBookChapterEls[clamped]?.scrollIntoView({ block: "start" });
setCurrentChapter(clamped);
}
// Map the element currently at the top of the Full Book viewport back to the
// chapter whose anchor is the last one above it, so manual scrolling updates
// the selector.
function detectChapterFromScroll(): number {
if (!fullBookChapters.length) return 0;
const scroller = fullBookEditor.view.dom as HTMLElement;
const rect = scroller.getBoundingClientRect();
if (rect.height === 0) return currentChapterIndex;
const atBottom = scroller.scrollTop + scroller.clientHeight >= scroller.scrollHeight - 2;
if (atBottom) return fullBookChapters.length - 1;
let idx = 0;
for (let i = 0; i < fullBookChapters.length; i++) {
const el = fullBookChapterEls[i];
if (!el) continue;
const r = el.getBoundingClientRect();
if (r.top <= rect.top + 4) idx = i;
else break;
}
return idx;
}
(fullBookEditor.view.dom as HTMLElement).addEventListener("scroll", () => {
if (!fullBookChapters.length) return;
if (navScrollRaf !== null) cancelAnimationFrame(navScrollRaf);
navScrollRaf = requestAnimationFrame(() => {
navScrollRaf = null;
setCurrentChapter(detectChapterFromScroll());
});
});
// Assemble the whole book directly from native chapter documents. The view is
// a single ProseMirror doc with a ChapterBreak anchor before every chapter
// (the first one unmarked). Print/export still use the Markdown chapters
// computed alongside, so export formats are unchanged. The pure logic lives
// in native-assembly.ts; here we only fetch the per-chapter data.
async function assembleFullBook(): Promise<{
nativeDoc: FolioNode;
chapterEntries: { id: string; title: string }[];
targets: WikilinkTarget[];
}> {
const res = await api.getBookMeta();
if (!res || typeof res !== "object" || "error" in res) {
return {
nativeDoc: { type: "doc", content: [] },
chapterEntries: [],
targets: [],
};
}
const book = res as BookMeta;
const inputs = [];
for (const id of book.chapterOrder) {
const ch = book.chapters[id];
if (!ch) continue;
const docRes = await api.loadChapterDoc(ch.id);
const doc = docRes && "doc" in docRes ? (docRes as { doc: FolioNode }).doc : { type: "doc", content: [] };
inputs.push({ id: ch.id, title: ch.title, doc });
}
return buildFullBookDoc(inputs);
}
async function renderFullBook() {
const { nativeDoc, chapterEntries } = await assembleFullBook();
setEditorDoc(fullBookEditor, nativeDoc);
fullBookChapterEls = Array.from(
fullBookHost.querySelectorAll<HTMLElement>("[data-chapter-id]")
);
fullBookChapters = chapterEntries.map((e, i) => ({ ...e, el: fullBookChapterEls[i] ?? null }));
populateChapterNav(chapterEntries);
const text = nativeNodeText(nativeDoc);
fullBookStats.textContent = `words: ${countWords(text)} · chars: ${text.length} · ~${countWords(text) ? Math.ceil(countWords(text) / 200) : 0} min read`;
printBook.innerHTML = renderPrintChaptersNative(nativeDoc);
// Re-render replaces the whole document, which resets the scroll position,
// so re-derive the current chapter once layout has settled.
requestAnimationFrame(() => {
requestAnimationFrame(() => setCurrentChapter(detectChapterFromScroll()));
});
}
async function selectFullBook() {
if (state.selectedId && state.dirty) {
await hooks.saveCurrentChapter();
state.dirty = false;
$("saveState").textContent = "Saved";
$("saveState").classList.remove("unsaved");
}
hooks.clearTimers();
state.selectedId = null;
$("chapterList").querySelectorAll(".chapter-row").forEach((r) => r.classList.remove("active"));
$("fullBookBtn").classList.add("active");
$("chapterView").classList.add("hidden");
$("editorPlaceholder").classList.add("hidden");
fullBookView.classList.remove("hidden");
await renderFullBook();
fullBookEditor.commands.focus();
}
async function refreshFullBook() {
if (!isFullBookVisible()) return;
await renderFullBook();
}
function isFullBookVisible(): boolean {
return !fullBookView.classList.contains("hidden");
}
function resetFullBookNav(): void {
setEditorDoc(fullBookEditor, { type: "doc", content: [] });
fullBookChapters = [];
fullBookChapterEls = [];
currentChapterIndex = 0;
fbChapterSelect.innerHTML = "";
updateChapterNavUI();
}
function fullBookNotice(text: string) {
fullBookStatus.textContent = text;
fullBookStatus.classList.add("unsaved");
setTimeout(() => {
fullBookStatus.textContent = "";
fullBookStatus.classList.remove("unsaved");
}, 3000);
}
// Build a native Full Book document from an explicit, ordered set of chapter
// ids. Used for "selected chapters" PDF export so the printed manuscript
// contains exactly those chapters in canonical order.
async function assembleChapters(
ids: string[]
): Promise<{ nativeDoc: FolioNode; chapterEntries: { id: string; title: string }[] }> {
const res = await api.getBookMeta();
const inputs: { id: string; title: string; doc: FolioNode }[] = [];
if (res && typeof res === "object" && !("error" in res)) {
const book = res as BookMeta;
for (const id of ids) {
const ch = book.chapters[id];
if (!ch) continue;
const docRes = await api.loadChapterDoc(id);
const doc =
docRes && "doc" in docRes ? (docRes as { doc: FolioNode }).doc : { type: "doc", content: [] };
inputs.push({ id, title: ch.title, doc });
}
}
return buildFullBookDoc(inputs);
}
// Render a specific set of chapters into the print container for PDF export.
async function renderChaptersToPrint(ids: string[]) {
const asm = await assembleChapters(ids);
printBook.innerHTML = renderPrintChaptersNative(asm.nativeDoc);
}
$("fullBookBtn").addEventListener("click", () => void selectFullBook());
fbPrevBtn.addEventListener("click", () => jumpToChapter(currentChapterIndex - 1));
fbNextBtn.addEventListener("click", () => jumpToChapter(currentChapterIndex + 1));
fbChapterSelect.addEventListener("change", () => {
const idx = fullBookChapters.findIndex((c) => c.id === fbChapterSelect.value);
if (idx >= 0) jumpToChapter(idx);
});
copyAllBtn.addEventListener("click", async () => {
// Copy the assembled manuscript as native content (the combined ProseMirror
// document), not concatenated Markdown.
const doc = getEditorDoc(fullBookEditor);
const text = JSON.stringify(doc);
if (!text || text === JSON.stringify({ schemaVersion: 1, doc: { type: "doc", content: [] } })) {
fullBookStatus.textContent = "Nothing to copy";
return;
}
const res = await api.copyText(text);
if (res && "error" in res) {
await alertMessage("Could not copy", (res as { error: string }).error);
return;
}
fullBookStatus.textContent = "Full book copied (native)";
fullBookStatus.classList.add("unsaved");
setTimeout(() => {
fullBookStatus.textContent = "";
fullBookStatus.classList.remove("unsaved");
}, 3000);
});
return {
fullBookEditor,
isFullBookVisible,
refreshFullBook,
selectFullBook,
renderChaptersToPrint,
resetFullBookNav,
fullBookNotice,
};
}

183
src/renderer/book/index.ts Normal file
View file

@ -0,0 +1,183 @@
import type { BookMeta, ChapterComment } from "../../main/project.js";
import { alertMessage, confirmMessage, promptInput } from "../dialog.js";
import type { ChapterEntry, FolioAPI, Recent } from "./types.js";
import type { BookCtx, BookHooks, SharedState } from "./types.js";
import { createEditorFeature } from "./editor.js";
import { createToolbarFeature } from "./toolbar.js";
import { createSearchFeature } from "./search.js";
import { createFullBookFeature } from "./full-book.js";
import { createChapterListFeature } from "./chapter-list.js";
import { createWelcomeFeature } from "./welcome.js";
import { createExportsFeature } from "./exports.js";
import { createCommentsFeature } from "./comments.js";
import {
$,
basename,
closeEl,
hideUndoToast,
openEl,
selectedText,
showUndoToast,
showToast,
} from "./ui.js";
export type { ChapterSnapshot, FolioAPI } from "./types.js";
export function initBook(api: FolioAPI) {
let currentBook: { path: string; title: string } | null = null;
let meta: BookMeta | null = null;
let ordered: ChapterEntry[] = [];
let selectedId: string | null = null;
let dirty = false;
let loadedDocJson = "";
// ---- P7 decomposition bridge -------------------------------------------
// Temporary: exposes these closure locals through the SharedState contract so
// extracted feature modules share one source of truth. Removed in Step 7 when
// the last feature leaves and state becomes a plain object literal.
const state: SharedState = {
get currentBook() { return currentBook; },
set currentBook(v) { currentBook = v; },
get meta() { return meta; },
set meta(v) { meta = v; },
get ordered() { return ordered; },
set ordered(v) { ordered = v; },
get selectedId() { return selectedId; },
set selectedId(v) { selectedId = v; },
get dirty() { return dirty; },
set dirty(v) { dirty = v; },
get loadedDocJson() { return loadedDocJson; },
set loadedDocJson(v) { loadedDocJson = v; },
};
const hooks = {} as BookHooks;
const ctx: BookCtx = { api, state, hooks };
// Feature: chapter editing surface + autosave engine + zoom + stats modal.
const ed = createEditorFeature(ctx);
const editor = ed.editor;
hooks.selectChapter = ed.selectChapter;
hooks.saveCurrentChapter = ed.saveCurrentChapter;
hooks.saveAndNotify = ed.saveAndNotify;
hooks.flushAutosave = ed.flushAutosave;
hooks.clearTimers = ed.clearTimers;
// Feature: formatting toolbar + font controls.
createToolbarFeature(ctx, editor);
// Feature: cross-chapter search & replace.
hooks.scrollToTerm = createSearchFeature(ctx, editor).scrollToTerm;
// Feature: read-only assembled Full Book view + navigation.
const fb = createFullBookFeature(ctx);
hooks.refreshFullBook = fb.refreshFullBook;
hooks.selectFullBook = fb.selectFullBook;
hooks.renderChaptersToPrint = fb.renderChaptersToPrint;
hooks.resetFullBookNav = fb.resetFullBookNav;
hooks.isFullBookVisible = fb.isFullBookVisible;
hooks.fullBookNotice = fb.fullBookNotice;
// Feature: chapter sidebar list, color popover, CRUD, drag reorder.
const cl = createChapterListFeature(ctx);
hooks.renderChapterList = cl.renderChapterList;
hooks.setChapterButtons = cl.setChapterButtons;
// Feature: welcome screen + recents + session coordination.
const welcome = createWelcomeFeature(ctx);
hooks.refreshMeta = welcome.refreshMeta;
hooks.showPlaceholder = welcome.showPlaceholder;
// Feature: export workflow (whole book vs selected chapters).
const exports = createExportsFeature(ctx);
// Wire export facades from the extracted module.
hooks.exportMarkdown = exports.exportMarkdown;
hooks.exportZip = exports.exportZip;
hooks.exportPdf = exports.exportPdf;
hooks.exportDocx = exports.exportDocx;
hooks.exportOdt = exports.exportOdt;
hooks.migrateBookNative = exports.migrateBookNative;
hooks.exportFromMenu = exports.exportFromMenu;
// Feature: comments / annotations modal.
const comments = createCommentsFeature(ctx, editor);
// Wire comment facade from the extracted module.
hooks.updateCommentsBadge = comments.updateCommentsBadge;
// ---- element consts (kept from the original file) ----
const editorPlaceholder = $("editorPlaceholder");
const chapterView = $("chapterView");
const chapterTitleEl = $("chapterTitle");
const saveStateEl = $("saveState");
const exportBtn = $("exportBtn") as HTMLButtonElement;
const exportZipBtn = $("exportZipBtn") as HTMLButtonElement;
const projectBar = $("projectBar");
const fullBookNav = $("fullBookNav");
const fullBookBtn = $("fullBookBtn") as HTMLButtonElement;
const exportMenuToggle = $("exportMenuToggle") as HTMLButtonElement;
const exportMenu = $("exportMenu");
const exportDropdown = $("exportDropdown");
const welcomeEl = $("welcome");
const recentList = $("recentList") as HTMLUListElement;
// ---- welcome / recents logic (moved from inline into createWelcomeFeature) ----
// The welcome module owns its own DOM chrome and wiring; here we only keep the
// top-level constructor call and the bridge assignments it already set above.
// ---- export workflow (whole book vs selected chapters) ----
// The exports module owns its own DOM chrome and wiring; here we only keep the
// top-level constructor call and the bridge assignments it already set above.
// ---- comments / annotations ----
// The comments module owns its own DOM chrome and wiring; here we only keep the
// top-level constructor call and the bridge assignments it already set above.
// ---- api menu handlers ----
// These now delegate to the extracted feature facades rather than calling
// inline functions defined in this module.
api.onBookOpened((data) => welcome.setCurrentBook(data));
api.onBookClosed(() => void welcome.showWelcome());
api.onChaptersChanged(() => void hooks.refreshMeta());
api.onNewBookRequested(() => void welcome.startNewBook());
api.onOpenBookRequested(() => void welcome.startOpenBook());
api.onOpenRecent((p) => void welcome.openPath(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 hooks.exportMarkdown());
api.onExportZipRequested(() => void hooks.exportZip());
api.onExportPdfRequested(() => void hooks.exportPdf());
api.onExportDocxRequested(() => void hooks.exportDocx());
api.onExportOdtRequested(() => void hooks.exportOdt());
api.onUndoRequested(() => {
if (hooks.isFullBookVisible()) fb.fullBookEditor.commands.undo();
else editor.commands.undo();
});
api.onRedoRequested(() => {
if (hooks.isFullBookVisible()) fb.fullBookEditor.commands.redo();
else editor.commands.redo();
});
api.onSelectAllRequested(() => {
if (hooks.isFullBookVisible()) {
fb.fullBookEditor.commands.focus();
fb.fullBookEditor.commands.selectAll();
} else {
editor.commands.selectAll();
}
});
api.onSaveRequested(() => void hooks.saveAndNotify());
// ---- comments / annotations wiring ----
// Already wired through the comments feature's internal listeners; the
// badge is kept in sync via hooks.updateCommentsBadge from the bridge.
hooks.setChapterButtons(false);
void welcome.showWelcome();
(window as any).__folioEditor = editor;
(window as any).__folioFullBookEditor = fb.fullBookEditor;
}

279
src/renderer/book/search.ts Normal file
View file

@ -0,0 +1,279 @@
// Seam 1: cross-chapter search & replace (#searchModal).
//
// The search flattens chapter Markdown to reader-visible plain text; replace
// operates on native chapter documents via throwaway editors so native JSON
// stays the storage format. Results navigate through hooks.selectChapter +
// scrollToTerm. Historically this wiring lived inside showWelcome() and
// re-registered ~15 listeners per Home trip; it has been bind-once since P6
// and now binds exactly once at construction (P7).
//
// Cross-feature calls go through BookHooks exclusively — no sibling imports.
import type { Editor } from "@tiptap/core";
import type { FolioNode } from "../../shared/native-doc-core.js";
import type { BookCtx } from "./types.js";
import { alertMessage } from "../dialog.js";
import { $, closeEl, findMatches, makeSnippet, markdownToPlain, openEl } from "./ui.js";
import {
createTiptapEditor,
setEditorDoc,
getEditorDoc,
} from "../tiptap-editor.js";
export interface SearchFeature {
scrollToTerm(term: string, matchCase: boolean): void;
}
export function createSearchFeature(ctx: BookCtx, editor: Editor): SearchFeature {
const { api, state, hooks } = ctx;
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" });
}
// ---- search across all chapters ----
const searchBtn = $("searchBtn") as HTMLButtonElement;
const searchModal = $("searchModal") as HTMLDivElement;
const searchInput = $("searchInput") as HTMLInputElement;
const searchCaseCb = $("searchCase") as HTMLInputElement;
const searchMeta = $("searchMeta") as HTMLElement;
const searchResults = $("searchResults") as HTMLUListElement;
const searchClose = $("searchClose") as HTMLButtonElement;
const replaceInput = $("replaceInput") as HTMLInputElement;
const replaceBtn = $("replaceBtn") as HTMLButtonElement;
const replaceAllBtn = $("replaceAllBtn") as HTMLButtonElement;
let searchTimer: number | undefined;
function openSearch(): void {
if (!state.meta) {
void alertMessage("No book open", "Open a book before searching.");
return;
}
openEl(searchModal);
searchInput.value = "";
searchResults.innerHTML = "";
searchMeta.textContent = "";
setTimeout(() => searchInput.focus(), 0);
}
function closeSearch(): void {
closeEl(searchModal);
}
function runSearchNow(): void {
const term = searchInput.value.trim();
searchResults.innerHTML = "";
if (!term) {
searchMeta.textContent = "";
return;
}
if (!state.meta) {
searchMeta.textContent = "Open a book to search.";
return;
}
const matchCase = searchCaseCb.checked;
void (async () => {
const list = await api.getChapterList();
const chapters = Array.isArray(list) ? list : [];
const byChapter: { id: string; title: string; plain: string; positions: number[] }[] = [];
let total = 0;
for (const ch of chapters) {
if (total >= 200) break;
const res = await api.getChapterContent(ch.id);
const md = typeof res === "string" ? res : "";
const plain = markdownToPlain(md);
const positions = findMatches(plain, term, matchCase);
if (positions.length) {
byChapter.push({ id: ch.id, title: ch.title || ch.id, plain, positions });
total += positions.length;
}
}
searchMeta.textContent = total
? `${total} match${total === 1 ? "" : "es"} in ${byChapter.length} chapter${byChapter.length === 1 ? "" : "s"}`
: "No matches.";
for (const r of byChapter) {
const head = document.createElement("li");
head.className = "search-chapter-head";
head.textContent = `${r.title} (${r.positions.length})`;
searchResults.appendChild(head);
for (const pos of r.positions) {
const li = document.createElement("li");
li.className = "search-result";
li.innerHTML = makeSnippet(r.plain, pos, term.length);
li.addEventListener("click", () => void goToResult(r.id, pos, term, matchCase));
searchResults.appendChild(li);
}
}
})();
}
function goToResult(id: string, pos: number, term: string, matchCase: boolean): void {
closeSearch();
void (async () => {
await hooks.selectChapter(id);
scrollToTerm(term, matchCase);
})();
}
// --- find & replace (operates on native chapter documents) ---
// Build a literal (or case-insensitive) RegExp from a user term. Replace
// treats the term as a literal string to avoid surprising the user.
function termToRegExp(term: string, matchCase: boolean): RegExp {
const escaped = term.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
return new RegExp(escaped, matchCase ? "g" : "gi");
}
// Find every occurrence of `term` within a Tiptap editor's text and replace
// it with `replacement`. Matches are gathered per text node (positions are
// ProseMirror positions) and applied in reverse so earlier positions stay
// valid. Returns the number of replacements made.
function findAndReplaceInEditor(
ed: Editor,
term: string,
replacement: string,
matchCase: boolean
): number {
if (!term) return 0;
const re = termToRegExp(term, matchCase);
const matches: { from: number; to: number }[] = [];
ed.state.doc.descendants((node, pos) => {
if (node.isText && node.text) {
let m: RegExpExecArray | null;
re.lastIndex = 0;
while ((m = re.exec(node.text)) !== null) {
if (m[0].length === 0) {
re.lastIndex++;
continue;
}
matches.push({ from: pos + m.index, to: pos + m.index + m[0].length });
}
}
return true;
});
if (!matches.length) return 0;
// Apply highest-position match first so lower positions are unaffected.
const { state: edState, view } = ed;
let tr = edState.tr;
for (let i = matches.length - 1; i >= 0; i--) {
tr = tr.insertText(replacement, matches[i].from, matches[i].to);
}
view.dispatch(tr);
return matches.length;
}
// Load a chapter's native document into a throwaway editor, run find & replace
// on it, and persist the result. Returns the number of replacements.
async function replaceInChapterDoc(
id: string,
term: string,
replacement: string,
matchCase: boolean
): Promise<number> {
const res = await api.loadChapterDoc(id);
if (!res || typeof res !== "object" || "error" in res) return 0;
const doc = (res as { doc?: unknown }).doc;
if (!doc) return 0;
const host = document.createElement("div");
host.style.position = "absolute";
host.style.left = "-99999px";
host.style.top = "0";
document.body.appendChild(host);
const tmp = createTiptapEditor(host);
let count = 0;
try {
setEditorDoc(tmp, doc as FolioNode);
count = findAndReplaceInEditor(tmp, term, replacement, matchCase);
if (count > 0) await api.saveChapterDoc(id, getEditorDoc(tmp));
} finally {
tmp.destroy();
host.remove();
}
return count;
}
// Flush current edits, then replace either in the current chapter or the whole
// book. Afterward the UI is refreshed so the changes are visible.
async function doReplace(all: boolean): Promise<void> {
const term = searchInput.value.trim();
const replacement = replaceInput.value;
if (!term) {
searchMeta.textContent = "Enter a search term to replace.";
return;
}
const meta = state.meta;
if (!meta) return;
await hooks.saveCurrentChapter();
const ids = all ? meta.chapterOrder.slice() : state.selectedId ? [state.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 (state.selectedId) await hooks.selectChapter(state.selectedId);
if (!hooks.isFullBookVisible()) await hooks.refreshFullBook();
searchMeta.textContent = `Replaced ${total} occurrence${total === 1 ? "" : "s"} in ${chaptersTouched} chapter${chaptersTouched === 1 ? "" : "s"}.`;
void runSearchNow();
}
searchInput.addEventListener("input", () => {
clearTimeout(searchTimer);
searchTimer = window.setTimeout(runSearchNow, 200);
});
searchCaseCb.addEventListener("change", runSearchNow);
replaceBtn.addEventListener("click", () => void doReplace(false));
replaceAllBtn.addEventListener("click", () => void doReplace(true));
searchBtn.addEventListener("click", openSearch);
searchClose.addEventListener("click", closeSearch);
searchModal.addEventListener("click", (e) => {
if (e.target === searchModal) closeSearch();
});
window.addEventListener("keydown", (e) => {
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "f") {
e.preventDefault();
openSearch();
} else if (e.key === "Escape" && !searchModal.classList.contains("hidden")) {
closeSearch();
} else if (
e.key === "Enter" &&
!searchModal.classList.contains("hidden") &&
document.activeElement === searchInput
) {
const first = searchResults.querySelector<HTMLElement>(".search-result");
if (first) first.click();
}
});
return { scrollToTerm };
}

View file

@ -0,0 +1,402 @@
// The formatting toolbar: command dispatch, special insert actions, dropdown
// pinning, heading/size/spacing controls — plus the custom font-family menu
// (merged here in P7: font selection is a UI control of the toolbar, not a
// standalone feature domain).
//
// Cross-feature calls go through BookHooks exclusively — no sibling imports.
import type { Editor } from "@tiptap/core";
import type { FolioNode } from "../../shared/native-doc-core.js";
import type { BookCtx } from "./types.js";
import { alertMessage, promptInput } from "../dialog.js";
import {
$,
closeEl,
openEl,
selectedText,
setActive,
} from "./ui.js";
import {
createTiptapEditor,
setEditorDoc,
getEditorDoc,
runCommand,
} from "../tiptap-editor.js";
export interface ToolbarFeature {}
export function createToolbarFeature(ctx: BookCtx, editor: Editor): ToolbarFeature {
const { api, state, hooks } = ctx;
const toolbar = $("editorToolbar");
function closeDropdowns() {
toolbar.querySelectorAll<HTMLElement>(".dropdown-menu").forEach((m) => closeEl(m));
toolbar.querySelectorAll(".dropdown-toggle").forEach((t) => t.classList.remove("open"));
}
async function toggleFullscreen() {
const isFs = await api.toggleFullscreen();
setActive("fullscreen", !!isFs);
}
document.addEventListener("fullscreenchange", () => {
setActive("fullscreen", !!document.fullscreenElement);
});
function toggleFocus() {
const on = document.body.classList.toggle("focus-mode");
setActive("focus", on);
}
async function attachFile() {
const res = await api.importAttachment();
if (!res) return;
if ("canceled" in res) return;
if ("error" in res) {
await alertMessage("Could not attach file", (res as { error: string }).error);
return;
}
if ("relPath" in res) {
runCommand(editor, "wikilink", {
href: (res as { relPath: string }).relPath,
text: (res as { name: string }).name,
});
}
}
async function handleSpecial(item: HTMLElement) {
const action = item.dataset.action;
switch (action) {
case "wikilink": {
const sel = selectedText(editor);
const name = await promptInput("Wikilink to chapter", sel);
if (name === null) return;
runCommand(editor, "wikilink", {
href: `folio:chapter/${name}`,
text: sel && sel !== name ? sel : name,
});
break;
}
case "image": {
const res = await api.importImageAsset();
if (!res || "canceled" in res) return;
if ("error" in res) {
await alertMessage("Could not insert image", (res as { error: string }).error);
return;
}
if ("relPath" in res) {
runCommand(editor, "image", {
src: (res as { relPath: string }).relPath,
});
}
break;
}
case "attach": {
await attachFile();
return;
}
case "callout": {
// The native editor has no callout node; insert a styled blockquote.
runCommand(editor, "quote");
break;
}
case "table": {
const rowsEl = $("tableRows") as HTMLInputElement;
const colsEl = $("tableCols") as HTMLInputElement;
const rows = Math.max(1, Math.min(12, parseInt(rowsEl.value, 10) || 3));
const cols = Math.max(1, Math.min(8, parseInt(colsEl.value, 10) || 2));
runCommand(editor, "table", { rows, cols });
break;
}
default:
return;
}
editor.commands.focus();
}
toolbar.addEventListener("pointerdown", (e) => {
// Prevent default only for buttons so the editor keeps its selection/focus
// when a formatting command is invoked. Never prevent it for <select>
// elements — doing so blocks the native dropdown from opening.
if ((e.target as HTMLElement).closest("button")) e.preventDefault();
});
document.addEventListener("click", (e) => {
if (!(e.target as HTMLElement).closest(".toolbar-dropdown")) closeDropdowns();
});
toolbar.addEventListener("click", (e) => {
const item = (e.target as HTMLElement).closest("[data-cmd], [data-action]") as HTMLElement | null;
if (!item) return;
closeDropdowns();
const cmd = item.dataset.cmd;
if (cmd === "fullscreen") { void toggleFullscreen(); return; }
if (cmd === "focus") { toggleFocus(); return; }
if (cmd === "painter") return; // painter removed in native editor
if (cmd) {
execToolbarCmd(cmd);
return;
}
void handleSpecial(item);
});
// Map a toolbar data-cmd to a Tiptap editor command.
function execToolbarCmd(cmd: string): void {
if (cmd === "link") {
void (async () => {
const sel = selectedText(editor);
const url = await promptInput("Link URL", sel.startsWith("http") ? sel : "https://");
if (url === null) return;
runCommand(editor, "link", { href: url || "https://" });
editor.commands.focus();
})();
return;
}
if (cmd === "removecolor") {
runCommand(editor, "color", {});
} else if (cmd === "removehl") {
runCommand(editor, "highlight", {});
} else if (cmd.startsWith("color:")) {
runCommand(editor, "color", { color: cmd.slice(6) });
} else if (cmd.startsWith("hcolor:")) {
runCommand(editor, "highlight", { color: cmd.slice(7) });
} else {
runCommand(editor, cmd);
}
editor.commands.focus();
}
const customTextColor = toolbar.querySelector<HTMLInputElement>("#customTextColor");
customTextColor?.addEventListener("change", () => {
runCommand(editor, "color", { color: customTextColor.value });
editor.commands.focus();
closeDropdowns();
});
// Bind toolbar dropdown toggles. The toolbar itself is a horizontal scroll
// container (overflow-x: auto), which would clip an absolutely-positioned
// menu, so an open menu is pinned to the viewport under its toggle instead.
toolbar.querySelectorAll<HTMLButtonElement>("button[data-dropdown]").forEach((toggle) => {
const menu = document.getElementById(toggle.dataset.dropdown ?? "");
if (!menu) return;
toggle.addEventListener("click", (e) => {
e.stopPropagation();
const wasOpen = !menu.classList.contains("hidden");
closeDropdowns();
if (!wasOpen) {
openEl(menu as HTMLElement);
toggle.classList.add("open");
const rect = toggle.getBoundingClientRect();
const menuWidth = menu.offsetWidth;
const left = Math.max(8, Math.min(rect.left, window.innerWidth - menuWidth - 8));
menu.style.position = "fixed";
menu.style.top = `${rect.bottom + 4}px`;
menu.style.left = `${left}px`;
}
});
});
// A pinned menu goes stale if the page scrolls or the window resizes, so
// any of those closes the dropdowns.
window.addEventListener("scroll", closeDropdowns, true);
window.addEventListener("resize", closeDropdowns);
// Electron has no default right-click menu, so show one with Cut/Copy/Paste.
window.addEventListener("contextmenu", (e) => {
e.preventDefault();
api.showContextMenu(e.clientX, e.clientY);
});
const headingSelect = $("headingSelect") as HTMLSelectElement;
headingSelect.addEventListener("change", () => {
const v = Number(headingSelect.value);
if (v === 0) runCommand(editor, "paragraph");
else runCommand(editor, `h${v}`);
editor.commands.focus();
});
// Custom font-family dropdown. A native <select> can't render each option
// in its own font reliably, so we build buttons whose label uses its own
// font-family. Fonts are bundled locally (see fonts.css) so each name shows
// in its actual typeface, and a search box + categories keep the long list
// manageable. Selecting a font applies it book-wide.
type FontDef = { name: string; family: string; cat: string };
const FONT_LIST: FontDef[] = [
{ name: "Inter", family: '"Inter", sans-serif', cat: "Sans-serif" },
{ name: "Roboto", family: '"Roboto", sans-serif', cat: "Sans-serif" },
{ name: "Open Sans", family: '"Open Sans", sans-serif', cat: "Sans-serif" },
{ name: "Lato", family: "Lato, sans-serif", cat: "Sans-serif" },
{ name: "Montserrat", family: "Montserrat, sans-serif", cat: "Sans-serif" },
{ name: "Poppins", family: "Poppins, sans-serif", cat: "Sans-serif" },
{ name: "Oswald", family: "Oswald, sans-serif", cat: "Sans-serif" },
{ name: "Raleway", family: "Raleway, sans-serif", cat: "Sans-serif" },
{ name: "Nunito", family: "Nunito, sans-serif", cat: "Sans-serif" },
{ name: "Ubuntu", family: "Ubuntu, sans-serif", cat: "Sans-serif" },
{ name: "Source Sans 3", family: '"Source Sans 3", sans-serif', cat: "Sans-serif" },
{ name: "Noto Sans", family: '"Noto Sans", sans-serif', cat: "Sans-serif" },
{ name: "PT Sans", family: '"PT Sans", sans-serif', cat: "Sans-serif" },
{ name: "DM Sans", family: '"DM Sans", sans-serif', cat: "Sans-serif" },
{ name: "Quicksand", family: "Quicksand, sans-serif", cat: "Sans-serif" },
{ name: "Work Sans", family: '"Work Sans", sans-serif', cat: "Sans-serif" },
{ name: "Roboto Slab", family: '"Roboto Slab", serif', cat: "Serif" },
{ name: "Merriweather", family: "Merriweather, serif", cat: "Serif" },
{ name: "Playfair Display", family: '"Playfair Display", serif', cat: "Serif" },
{ name: "Libre Baskerville", family: '"Libre Baskerville", serif', cat: "Serif" },
];
const fontMenu = $("fontMenu") as HTMLDivElement;
const fontLabel = $("fontLabel") as HTMLElement;
function setFontLabel(name: string, family: string) {
fontLabel.textContent = name;
fontLabel.style.fontFamily = family;
}
async function selectFont(name: string, family: string) {
// Apply font to the current selection (or cursor position).
// Book-wide propagation is handled separately via applyFontToAllChapters.
editor.chain().focus().setMark("fontFamily", { fontFamily: family }).run();
setFontLabel(name, family);
if (state.selectedId) await api.saveChapterDoc(state.selectedId, getEditorDoc(editor));
await applyFontToAllChapters(family, state.selectedId ?? undefined);
if (!hooks.isFullBookVisible()) await hooks.refreshFullBook();
}
// Search box (filters the list without closing the menu).
const fontSearch = document.createElement("input");
fontSearch.type = "text";
fontSearch.placeholder = "Search fonts…";
fontSearch.className = "font-search";
fontSearch.addEventListener("click", (e) => e.stopPropagation());
fontMenu.appendChild(fontSearch);
const fontToggle = $("fontToggle") as HTMLButtonElement;
let activeIndex = 0;
const visibleOptions = () => optionEls.filter((el) => el.style.display !== "none");
function highlight(index: number) {
optionEls.forEach((el) => el.classList.remove("is-active"));
const vis = visibleOptions();
if (!vis.length) return;
activeIndex = Math.max(0, Math.min(index, vis.length - 1));
const el = vis[activeIndex];
if (el) {
el.classList.add("is-active");
el.scrollIntoView({ block: "nearest" });
}
}
// Open the menu with the search box focused so arrow-key navigation works
// immediately.
fontToggle.addEventListener("click", () => {
if (!fontMenu.classList.contains("hidden")) {
setTimeout(() => { fontSearch.focus(); highlight(activeIndex); }, 0);
}
});
fontMenu.addEventListener("keydown", (e) => {
if (e.target !== fontSearch) return;
e.stopPropagation();
const vis = visibleOptions();
if (e.key === "ArrowDown") {
e.preventDefault();
highlight(activeIndex + 1);
} else if (e.key === "ArrowUp") {
e.preventDefault();
highlight(activeIndex - 1);
} else if (e.key === "Enter") {
e.preventDefault();
const el = vis[activeIndex];
if (el) el.click();
}
});
const fontListWrap = document.createElement("div");
fontMenu.appendChild(fontListWrap);
const optionEls: HTMLButtonElement[] = [];
const categories = [...new Set(FONT_LIST.map((f) => f.cat))];
for (const cat of categories) {
const header = document.createElement("div");
header.className = "font-cat";
header.textContent = cat;
fontListWrap.appendChild(header);
for (const opt of FONT_LIST.filter((f) => f.cat === cat)) {
const item = document.createElement("button");
item.type = "button";
item.className = "dropdown-item font-option";
item.textContent = opt.name;
item.style.fontFamily = opt.family;
item.dataset.name = opt.name.toLowerCase();
item.addEventListener("click", (e) => {
e.stopPropagation();
void selectFont(opt.name, opt.family);
closeDropdowns();
});
fontListWrap.appendChild(item);
optionEls.push(item);
}
}
fontSearch.addEventListener("input", () => {
const q = fontSearch.value.trim().toLowerCase();
for (const el of optionEls) {
el.style.display = !q || (el.dataset.name || "").includes(q) ? "" : "none";
}
fontListWrap.querySelectorAll<HTMLElement>(".font-cat").forEach((h) => {
const cat = (h.textContent || "").trim();
const anyVisible = FONT_LIST.filter((f) => f.cat === cat).some(
(f) => !q || f.name.toLowerCase().includes(q)
);
h.style.display = anyVisible ? "" : "none";
});
activeIndex = 0;
highlight(activeIndex);
});
// Default writing font is Inter (the first bundled font).
setFontLabel(FONT_LIST[0].name, FONT_LIST[0].family);
// Write the given font family into every chapter's content so the change
// propagates to all chapters (and survives reloads and exports).
async function applyFontToAllChapters(fontFamily: string, excludeId?: string) {
if (!state.meta) return;
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);
try {
for (const ch of state.ordered) {
if (excludeId && ch.id === excludeId) continue;
try {
const res = await api.loadChapterDoc(ch.id);
if (!res || typeof res !== "object" || "error" in res) continue;
const doc = (res as { doc?: unknown }).doc;
if (!doc) continue;
setEditorDoc(tmp, doc as FolioNode);
if (fontFamily)
tmp.chain().selectAll().setMark("fontFamily", { fontFamily }).run();
else tmp.chain().selectAll().unsetMark("fontFamily").run();
await api.saveChapterDoc(ch.id, getEditorDoc(tmp));
} catch (err) {
console.error("applyFontToAllChapters failed for", ch.id, err);
}
}
} finally {
tmp.destroy();
host.remove();
}
}
const fontSizeSelect = $("fontSizeSelect") as HTMLSelectElement;
fontSizeSelect?.addEventListener("change", () => {
runCommand(editor, "fontsize", { fontSize: fontSizeSelect.value });
editor.commands.focus();
});
const lineSpacingSelect = $("lineSpacingSelect") as HTMLSelectElement;
lineSpacingSelect?.addEventListener("change", () => {
runCommand(editor, "linespacing", { lineHeight: lineSpacingSelect.value });
editor.commands.focus();
});
return {};
}

150
src/renderer/book/types.ts Normal file
View file

@ -0,0 +1,150 @@
// Shared contracts for the decomposed book UI (P7). Feature modules receive a
// single BookCtx and never import each other, which keeps the dependency graph
// acyclic: types.ts + ui.ts are the only shared leaves.
export type { ChapterComment } from "../../main/project.js";
import type { BookMeta } from "../../main/project.js";
export interface ChapterSnapshot {
id: string;
title: string;
color?: string | null;
content: string;
index: number;
}
export interface ChapterEntry {
id: string;
title: string;
file: string;
color?: string;
}
export interface Recent {
path: string;
title: string;
lastOpened: string;
}
export interface OpenedBook {
path: string;
title: string;
}
export type FolioAPI = {
newBookNamed: (name: string) => Promise<any>;
deleteBook: (p: string) => Promise<any>;
restoreBook: (p: string) => Promise<any>;
openBook: () => Promise<any>;
openBookPath: (p: string) => Promise<any>;
revealInFolder: (p: string) => Promise<boolean>;
getRecents: () => Promise<any[]>;
closeBook: () => Promise<boolean>;
showContextMenu: (x: number, y: number) => void;
getBookMeta: () => Promise<BookMeta | { error: string }>;
setComments: (comments: Record<string, unknown>) => Promise<any>;
getChapterList: () => Promise<any[]>;
getChapterContent: (id: string) => Promise<string | { error: string }>;
createChapter: (title: string) => Promise<any>;
renameChapter: (id: string, title: string) => Promise<any>;
setChapterColor: (id: string, color: string | null) => Promise<any>;
deleteChapter: (id: string) => Promise<any>;
restoreChapter: (snap: ChapterSnapshot) => Promise<any>;
duplicateChapter: (id: string, title?: string) => Promise<any>;
reorderChapters: (newOrder: string[]) => Promise<any>;
saveChapter: (id: string, content: string) => Promise<any>;
migrateChapter: (id: string) => Promise<any>;
migrateBook: () => Promise<any>;
loadChapterDoc: (id: string) => Promise<any>;
saveChapterDoc: (id: string, doc: unknown) => Promise<any>;
importImageAsset: () => Promise<any>;
exportMarkdown: () => Promise<any>;
exportZip: () => Promise<any>;
exportPdf: (chapterIds?: string[] | null) => Promise<any>;
exportDocx: (chapterIds?: string[] | null) => Promise<any>;
exportOdt: (chapterIds?: string[] | null) => Promise<any>;
importAttachment: () => Promise<any>;
copyText: (text: string) => Promise<any>;
getClipboardText: () => Promise<string>;
toggleFullscreen: () => Promise<boolean>;
getTheme: () => Promise<string>;
setTheme: (id: string) => Promise<any>;
getZoom: () => Promise<number>;
setZoom: (pct: number) => Promise<any>;
onBookOpened: (cb: (data: { path: string; title: string }) => void) => void;
onBookClosed: (cb: () => void) => void;
onChaptersChanged: (cb: () => void) => void;
onNewBookRequested: (cb: () => void) => void;
onOpenBookRequested: (cb: () => void) => void;
onOpenRecent: (cb: (p: string) => void) => void;
onRevealRequested: (cb: (p: string) => void) => void;
onExportMarkdownRequested: (cb: () => void) => void;
onExportZipRequested: (cb: () => void) => void;
onExportPdfRequested: (cb: () => void) => void;
onExportDocxRequested: (cb: () => void) => void;
onExportOdtRequested: (cb: () => void) => void;
onUndoRequested: (cb: () => void) => void;
onRedoRequested: (cb: () => void) => void;
onSelectAllRequested: (cb: () => void) => void;
onSaveRequested: (cb: () => void) => void;
onZoomInRequested: (cb: () => void) => void;
onZoomOutRequested: (cb: () => void) => void;
onZoomResetRequested: (cb: () => void) => void;
};
// ---- P7 decomposition contracts ----
// Mutable session state shared by every feature module. The fields mirror the
// former initBook closure locals; the object is passed by reference so
// mutations stay visible everywhere (identical semantics to the closure).
export interface SharedState {
currentBook: OpenedBook | null;
meta: BookMeta | null;
ordered: ChapterEntry[];
selectedId: string | null;
dirty: boolean;
loadedDocJson: string;
}
// Late-bound cross-feature operations. Features call these at event time only
// (never during construction), and the orchestrator populates the registry
// before initBook finishes wiring listeners, so no feature needs to import
// another.
export interface BookHooks {
// editor feature
selectChapter(id: string): Promise<void>;
saveCurrentChapter(): Promise<void>;
saveAndNotify(): Promise<void>;
flushAutosave(): Promise<void>;
clearTimers(): void;
// session coordinators (orchestrator)
refreshMeta(): Promise<void>;
showPlaceholder(): void;
// full-book feature
refreshFullBook(): Promise<void>;
selectFullBook(): Promise<void>;
renderChaptersToPrint(ids: string[]): Promise<void>;
resetFullBookNav(): void;
isFullBookVisible(): boolean;
fullBookNotice(text: string): void;
// chapter-list feature
renderChapterList(): void;
setChapterButtons(enabled: boolean): void;
// comments feature
updateCommentsBadge(): void;
// search feature
scrollToTerm(term: string, matchCase: boolean): void;
// exports feature
exportMarkdown(): void;
exportZip(): void;
exportPdf(chapterIds?: string[] | null): Promise<void>;
exportDocx(chapterIds?: string[] | null): Promise<void>;
exportOdt(chapterIds?: string[] | null): Promise<void>;
migrateBookNative(): Promise<void>;
exportFromMenu(kind: string): Promise<void>;
}
export interface BookCtx {
api: FolioAPI;
state: SharedState;
hooks: BookHooks;
}

171
src/renderer/book/ui.ts Normal file
View file

@ -0,0 +1,171 @@
// Shared DOM + text helpers for the book feature modules. Everything here is
// stateless (or owns only its own widget state) and depends on nothing else
// in the book/ folder.
import type { Editor } from "@tiptap/core";
export const $ = (id: string) => document.getElementById(id) as HTMLElement;
// Concatenated plain text of the current editor selection (" " between block
// nodes). Used by link/wikilink prompts and comment quotes.
export function selectedText(editor: Editor): string {
const { from, to } = editor.state.selection;
return editor.state.doc.textBetween(from, to, " ");
}
// 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.
export function openEl(el: HTMLElement) {
const pending = (el as unknown as { __close?: () => void }).__close;
if (pending) pending();
el.classList.remove("hidden", "closing");
}
export 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;
};
}
export function setActive(cmd: string, on: boolean) {
document.querySelectorAll(`[data-cmd="${cmd}"]`).forEach((el) => el.classList.toggle("active", on));
}
let toastTimer: ReturnType<typeof setTimeout> | null = null;
let toastEl: HTMLElement | null = null;
export function showToast(msg: string) {
if (!toastEl) toastEl = $("toast");
if (!toastEl) return;
toastEl.textContent = msg;
toastEl.classList.remove("hidden");
toastEl.classList.add("show");
if (toastTimer) clearTimeout(toastTimer);
toastTimer = setTimeout(() => {
toastEl!.classList.remove("show");
toastTimer = setTimeout(() => toastEl!.classList.add("hidden"), 200);
}, 1400);
}
// Toast with an Undo action, used after destructive operations (delete book,
// delete/cut chapter). Auto-dismisses; the action runs once if tapped.
let undoToastTimer: ReturnType<typeof setTimeout> | null = null;
export function hideUndoToast(): void {
const el = $("undoToast");
if (!el) return;
el.classList.remove("show");
// Drop the handler so a second click during the hide animation cannot
// run the undo twice (restore would duplicate the chapter entry).
const btn = $("undoToastBtn") as HTMLButtonElement | null;
if (btn) btn.onclick = null;
setTimeout(() => el.classList.add("hidden"), 200);
}
export function showUndoToast(msg: string, onUndo: () => void, timeout = 9000): void {
const el = $("undoToast");
if (!el) {
onUndo();
return;
}
const msgEl = $("undoToastMsg");
const btn = $("undoToastBtn") as HTMLButtonElement | null;
if (msgEl) msgEl.textContent = msg;
if (btn)
btn.onclick = () => {
// Single-fire: detach before running so double-clicks are ignored.
btn.onclick = null;
hideUndoToast();
onUndo();
};
el.classList.remove("hidden");
el.classList.add("show");
if (undoToastTimer) clearTimeout(undoToastTimer);
undoToastTimer = setTimeout(hideUndoToast, timeout);
}
export function basename(p: string): string {
return p.split(/[/\\]/).pop() || p;
}
export function countWords(text: string): number {
const trimmed = text.trim();
if (!trimmed) return 0;
return trimmed.split(/\s+/).length;
}
// Word-count label with an approximate reading-time estimate (~200 wpm).
export function formatWordCount(text: string): string {
const w = countWords(text);
return w ? `words: ${w} · ~${Math.ceil(w / 200)} min read` : "words: 0";
}
export function moveInList<T>(items: T[], fromIndex: number, toIndex: number): T[] {
if (fromIndex === toIndex) return items;
const copy = items.slice();
const [moved] = copy.splice(fromIndex, 1);
copy.splice(toIndex, 0, moved);
return copy;
}
// Reduce Markdown to searchable plain text (headings, emphasis, links, etc.
// are flattened so a search matches what the reader actually sees).
export function markdownToPlain(md: string): string {
return md
.replace(/```[\s\S]*?```/g, " ")
.replace(/`([^`]+)`/g, "$1")
.replace(/!\[[^\]]*\]\([^)]*\)/g, " ")
.replace(/\[([^\]]+)\]\([^)]*\)/g, "$1")
.replace(/^ {0,3}#{1,6}\s+/gm, "")
.replace(/^\s{0,3}>+\s?/gm, "")
.replace(/[*_~]{1,3}([^*_~]+)[*_~]{1,3}/g, "$1")
.replace(/^\s*[-*+]\s+/gm, "• ")
.replace(/\r?\n\s*[-*_]{3,}\s*\r?\n/g, " ")
.replace(/\|/g, " ")
.replace(/[ \t]+/g, " ");
}
export function findMatches(plain: string, term: string, matchCase: boolean): number[] {
const out: number[] = [];
const hay = matchCase ? plain : plain.toLowerCase();
const needle = matchCase ? term : term.toLowerCase();
if (!needle) return out;
let idx = hay.indexOf(needle);
while (idx !== -1 && out.length < 200) {
out.push(idx);
idx = hay.indexOf(needle, idx + needle.length);
}
return out;
}
export function escapeHtml(s: string): string {
return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
export function makeSnippet(plain: string, pos: number, len: number): string {
const start = Math.max(0, pos - 40);
const end = Math.min(plain.length, pos + len + 40);
const before = plain.slice(start, pos);
const match = plain.slice(pos, pos + len);
const after = plain.slice(pos + len, end);
const pre = start > 0 ? "…" : "";
const post = end < plain.length ? "…" : "";
return escapeHtml(pre + before) + "<mark>" + escapeHtml(match) + "</mark>" + escapeHtml(after) + post;
}

View file

@ -0,0 +1,248 @@
// Welcome screen, recents, and session coordination (open/close/new book).
//
// Owns the welcome DOM chrome and the book-session entry points. Metadata
// loading (loadMeta/refreshMeta) lives here as the orchestrator-side
// coordinator; features reach it via hooks.refreshMeta.
//
// Cross-feature calls go through BookHooks exclusively — no sibling imports.
import type { BookCtx } from "./types.js";
import type { ChapterEntry, Recent } from "./types.js";
import type { BookMeta } from "../../main/project.js";
import { alertMessage, promptInput } from "../dialog.js";
import { $, showUndoToast } from "./ui.js";
export interface WelcomeFeature {
showWelcome(): Promise<void>;
showPlaceholder(): void;
setCurrentBook(data: { path: string; title: string }): void;
refreshMeta(): Promise<void>;
startNewBook(): Promise<void>;
startOpenBook(): Promise<void>;
openPath(p: string): Promise<void>;
}
export function createWelcomeFeature(ctx: BookCtx): WelcomeFeature {
const { api, state, hooks } = ctx;
const welcomeEl = $("welcome");
const chapterView = $("chapterView");
const fullBookView = $("fullBookView");
const editorPlaceholder = $("editorPlaceholder");
const projectBar = $("projectBar");
const fullBookBtn = $("fullBookBtn") as HTMLButtonElement;
const bookTitleEl = $("bookTitle");
const bookPathEl = $("bookPath");
const recentList = $("recentList") as HTMLUListElement;
const homeBtn = $("homeBtn") as HTMLButtonElement | null;
async function showWelcome() {
welcomeEl.classList.remove("hidden");
chapterView.classList.add("hidden");
fullBookView.classList.add("hidden");
editorPlaceholder.classList.add("hidden");
// Keep the project bar visible so the Home button is a persistent nav
// control. On Home it is shown disabled/active (you are already here).
projectBar.classList.remove("hidden");
bookTitleEl.textContent = "";
bookTitleEl.classList.add("hidden");
bookPathEl.classList.add("hidden");
bookPathEl.title = "";
$("revealBtn")?.classList.add("hidden");
if (homeBtn) {
homeBtn.disabled = true;
homeBtn.classList.add("active");
homeBtn.setAttribute("aria-current", "page");
}
state.currentBook = null;
state.meta = null;
state.ordered = [];
state.selectedId = null;
hooks.setChapterButtons(false);
fullBookBtn.classList.remove("active");
hooks.resetFullBookNav();
hooks.renderChapterList();
void renderRecents();
}
function showPlaceholder() {
chapterView.classList.add("hidden");
fullBookView.classList.add("hidden");
fullBookBtn.classList.remove("active");
editorPlaceholder.classList.remove("hidden");
}
function setCurrentBook(data: { path: string; title: string }) {
state.currentBook = data;
// The "Home" label is part of the button itself (see index.html), so the
// legacy bookTitle span stays out of the way.
bookTitleEl.textContent = "";
bookTitleEl.classList.add("hidden");
bookPathEl.classList.add("hidden");
$("revealBtn")?.classList.remove("hidden");
if (homeBtn) {
homeBtn.disabled = false;
homeBtn.classList.remove("active");
homeBtn.removeAttribute("aria-current");
}
projectBar.classList.remove("hidden");
welcomeEl.classList.add("hidden");
state.selectedId = null;
void loadMeta();
}
async function loadMeta() {
const res = await api.getBookMeta();
if (res && typeof res === "object" && "error" in res) {
await alertMessage("Could not load book", (res as { error: string }).error);
state.meta = null;
} else {
const meta = res as BookMeta;
state.meta = meta;
state.ordered = meta.chapterOrder
.map((id) => meta.chapters[id])
.filter(Boolean) as ChapterEntry[];
}
hooks.setChapterButtons(!!state.meta);
hooks.renderChapterList();
// Preserve the chapter currently being read. Metadata refreshes fire on
// every rename/color/reorder, and jumping back to the first chapter
// here would yank the editor out from under the user mid-session.
const keepId =
state.selectedId && state.meta?.chapters[state.selectedId] ? state.selectedId : null;
if (keepId) {
const entry = state.meta!.chapters[keepId];
$("chapterTitle").textContent = entry.title || keepId;
hooks.updateCommentsBadge();
await hooks.refreshFullBook();
return;
}
if (state.ordered.length) {
await hooks.selectChapter(state.ordered[0].id);
} else {
showPlaceholder();
}
await hooks.refreshFullBook();
}
async function refreshMeta() {
await loadMeta();
if (state.meta && state.ordered.length === 0) {
state.selectedId = null;
showPlaceholder();
}
}
async function handleResult(res: any) {
if (!res) return;
if ("canceled" in res) return;
if ("error" in res) { await alertMessage("Error", (res as { error: string }).error); return; }
if ("path" in res) setCurrentBook({ path: res.path, title: res.title });
}
async function startNewBook() {
const name = await promptInput("New book", "My Book");
if (name === null) return;
const trimmed = name.trim();
if (!trimmed) return;
const res = await api.newBookNamed(trimmed);
await handleResult(res);
await renderRecents();
}
async function startOpenBook() {
const res = await api.openBook();
await handleResult(res);
await renderRecents();
}
async function openPath(p: string) {
const res = await api.openBookPath(p);
await handleResult(res);
await renderRecents();
}
async function renderRecents() {
try {
const recents = await api.getRecents();
recentList.innerHTML = "";
if (!recents.length) {
const li = document.createElement("li");
li.className = "muted";
li.textContent = "No recent books.";
recentList.appendChild(li);
return;
}
for (const r of recents as (Recent & { missing?: boolean })[]) {
const li = document.createElement("li");
li.className = "recent-item";
const label = document.createElement("span");
label.className = "recent-title";
label.textContent = (r.title || r.path) + (r.missing ? " (missing)" : "");
label.title = r.path;
label.addEventListener("mousedown", (e) => {
e.preventDefault();
void openPath(r.path);
});
const del = document.createElement("button");
del.className = "recent-delete icon-button";
del.type = "button";
del.title = "Delete book";
del.setAttribute("aria-label", "Delete book");
del.textContent = "×";
del.addEventListener("click", async (e) => {
e.stopPropagation();
const res = await api.deleteBook(r.path);
if (res && "error" in res) {
await alertMessage("Could not delete", (res as { error: string }).error);
return;
}
await renderRecents();
// Only promise an in-app Undo where restore actually works (Linux
// home trash). Elsewhere the book sits in the OS trash, which the
// user can restore manually — no false "Undo" button.
if (!(res as { canUndoRestore?: boolean }).canUndoRestore) return;
showUndoToast(`Deleted "${r.title || r.path}"`, async () => {
const rr = await api.restoreBook(r.path);
if (rr && "error" in rr) {
await alertMessage("Could not restore", (rr as { error: string }).error);
return;
}
await renderRecents();
});
});
li.appendChild(label);
li.appendChild(del);
recentList.appendChild(li);
}
} catch {
// Recents are best-effort; never block the welcome screen.
}
}
// ---- wiring (bind once at construction) ----
$("welcomeNewBook").addEventListener("click", () => void startNewBook());
$("welcomeOpenBook").addEventListener("click", () => void startOpenBook());
homeBtn?.addEventListener("click", async () => {
hooks.clearTimers();
if (state.selectedId && state.dirty) {
// Auto-save silently instead of prompting, so closing the book never
// loses the chapter's text.
await hooks.saveCurrentChapter();
}
void api.closeBook();
});
$("revealBtn")?.addEventListener("click", () => {
if (state.currentBook) void api.revealInFolder(state.currentBook.path);
});
return {
showWelcome,
showPlaceholder,
setCurrentBook,
refreshMeta,
startNewBook,
startOpenBook,
openPath,
};
}

View file

@ -229,23 +229,23 @@ html, body {
} }
.full-book-btn { .full-book-btn {
width: 100%; display: inline-flex;
text-align: left; align-items: center;
border: 1px solid var(--color-border); justify-content: center;
border: 1px dashed var(--color-border);
border-radius: var(--radius); border-radius: var(--radius);
background: transparent; background: transparent;
color: var(--color-text); color: var(--color-muted);
font-family: var(--font-serif); font-family: var(--font-sans);
font-size: 0.95rem; font-size: 0.85rem;
padding: 8px 10px; padding: 6px 12px;
cursor: pointer; cursor: pointer;
transition: background 0.12s ease, color 0.12s ease, border-color 0.12s ease; transition: background 0.12s ease, color 0.12s ease, border-color 0.12s ease;
min-width: 100px;
} }
.full-book-btn:hover { .full-book-btn:hover {
background: var(--color-accent); border-style: solid;
color: var(--color-on-accent);
border-color: var(--color-accent);
} }
.full-book-btn.active { .full-book-btn.active {
@ -289,7 +289,7 @@ html, body {
flex: 1; flex: 1;
display: flex; display: flex;
background: var(--color-bg); background: var(--color-bg);
overflow: hidden; overflow: visible;
} }
.editor-placeholder { .editor-placeholder {
@ -383,10 +383,27 @@ html, body {
gap: 4px; gap: 4px;
} }
/* Label lives inside the button so Home reads consistently on every
screen (welcome included). Matches the old book-title typography. */
#homeBtn .home-label {
font-size: 0.9rem;
font-weight: 600;
white-space: nowrap;
}
/* The chapters "+" must stay clearly visible (white) on dark surfaces. */ /* The chapters "+" must stay clearly visible (white) on dark surfaces. */
:root[data-theme="dark-mode"] #addChapterBtn, :root[data-theme="dark-mode"] #addChapterBtn,
:root[data-theme="neon"] #addChapterBtn, :root[data-theme="neon"] #addChapterBtn,
:root[data-theme="meridian"] #addChapterBtn { :root[data-theme="meridian"] #addChapterBtn,
:root[data-theme="impeccable-dark"] #addChapterBtn {
color: #ffffff;
}
/* Home label: black text in light mode, white text in dark/neon/meridian themes. */
:root[data-theme="dark-mode"] #homeBtn .home-label,
:root[data-theme="neon"] #homeBtn .home-label,
:root[data-theme="meridian"] #homeBtn .home-label,
:root[data-theme="impeccable-dark"] #homeBtn .home-label {
color: #ffffff; color: #ffffff;
} }
@ -403,7 +420,9 @@ html, body {
:root[data-theme="neon"] .brand-icon-light, :root[data-theme="neon"] .brand-icon-light,
:root[data-theme="neon"] .welcome-brand-light, :root[data-theme="neon"] .welcome-brand-light,
:root[data-theme="meridian"] .brand-icon-light, :root[data-theme="meridian"] .brand-icon-light,
:root[data-theme="meridian"] .welcome-brand-light { :root[data-theme="meridian"] .welcome-brand-light,
:root[data-theme="impeccable-dark"] .brand-icon-light,
:root[data-theme="impeccable-dark"] .welcome-brand-light {
display: none; display: none;
} }
:root[data-theme="dark-mode"] .brand-icon-dark, :root[data-theme="dark-mode"] .brand-icon-dark,
@ -411,7 +430,9 @@ html, body {
:root[data-theme="neon"] .brand-icon-dark, :root[data-theme="neon"] .brand-icon-dark,
:root[data-theme="neon"] .welcome-brand-dark, :root[data-theme="neon"] .welcome-brand-dark,
:root[data-theme="meridian"] .brand-icon-dark, :root[data-theme="meridian"] .brand-icon-dark,
:root[data-theme="meridian"] .welcome-brand-dark { :root[data-theme="meridian"] .welcome-brand-dark,
:root[data-theme="impeccable-dark"] .brand-icon-dark,
:root[data-theme="impeccable-dark"] .welcome-brand-dark {
display: inline-block; display: inline-block;
} }
@ -732,7 +753,7 @@ body.dragging-active * {
z-index: 1000; z-index: 1000;
background: var(--color-panel); background: var(--color-panel);
color: var(--color-text); color: var(--color-text);
border: 1px solid var(--color-border); border: none;
border-radius: var(--radius); border-radius: var(--radius);
padding: 8px; padding: 8px;
box-shadow: 0 8px 24px var(--color-shadow-strong); box-shadow: 0 8px 24px var(--color-shadow-strong);
@ -977,7 +998,7 @@ body.dragging-active * {
border-radius: 2px; border-radius: 2px;
} }
#printBook .callout { #printBook .callout {
border-left: 4px solid #3b82f6; border-left: 1px solid #3b82f6;
background: #f0f6ff; background: #f0f6ff;
padding: 0.6em 1em; padding: 0.6em 1em;
margin: 0 0 1.7em; margin: 0 0 1.7em;
@ -1025,7 +1046,7 @@ body.dragging-active * {
display: flex; display: flex;
align-items: center; align-items: center;
flex-wrap: nowrap; flex-wrap: nowrap;
overflow-x: auto; overflow: visible;
gap: 4px; gap: 4px;
padding: 6px 12px; padding: 6px 12px;
border-bottom: 1px solid var(--color-border); border-bottom: 1px solid var(--color-border);
@ -1185,9 +1206,9 @@ body.dragging-active * {
z-index: 60; z-index: 60;
min-width: 190px; min-width: 190px;
background: var(--color-panel); background: var(--color-panel);
border: 1px solid var(--color-border); border: none;
border-radius: var(--radius); border-radius: var(--radius);
box-shadow: 0 10px 30px var(--color-shadow); box-shadow: 0 8px 16px var(--color-shadow);
padding: 4px; padding: 4px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -1410,11 +1431,11 @@ body.focus-mode .editor {
.modal { .modal {
background: var(--color-panel); background: var(--color-panel);
border: 1px solid var(--color-border); border: none;
border-radius: var(--radius); border-radius: var(--radius);
padding: var(--gap); padding: var(--gap);
width: min(420px, calc(100vw - 48px)); width: min(420px, calc(100vw - 48px));
box-shadow: 0 12px 40px var(--color-shadow-strong); box-shadow: 0 8px 16px var(--color-shadow-strong);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 12px; gap: 12px;
@ -1550,7 +1571,7 @@ body.focus-mode .editor {
font-size: 0.82rem; font-size: 0.82rem;
font-style: italic; font-style: italic;
color: var(--color-muted); color: var(--color-muted);
border-left: 3px solid var(--color-accent); border-left: 2px solid var(--color-accent);
padding-left: 8px; padding-left: 8px;
white-space: pre-wrap; white-space: pre-wrap;
} }
@ -1586,7 +1607,7 @@ body.focus-mode .editor {
min-height: 100%; min-height: 100%;
overflow-y: auto; overflow-y: auto;
font-family: var(--font-sans); font-family: var(--font-sans);
font-size: 1.05rem; font-size: 16px;
line-height: 1.7; line-height: 1.7;
color: var(--color-text); color: var(--color-text);
background: var(--color-bg); background: var(--color-bg);
@ -1606,7 +1627,7 @@ body.focus-mode .editor {
.folio-prose p { margin: 0 0 0.8em; } .folio-prose p { margin: 0 0 0.8em; }
.folio-prose ul, .folio-prose ol { padding-left: 1.4em; margin: 0 0 0.8em; } .folio-prose ul, .folio-prose ol { padding-left: 1.4em; margin: 0 0 0.8em; }
.folio-prose blockquote { .folio-prose blockquote {
border-left: 3px solid var(--color-accent); border-left: 2px solid var(--color-accent);
margin: 0 0 0.8em; margin: 0 0 0.8em;
padding: 0.2em 0 0.2em 1em; padding: 0.2em 0 0.2em 1em;
color: var(--color-text-dim); color: var(--color-text-dim);

View file

@ -20,7 +20,7 @@
<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"><span class="home-icon">🏠</span></button> <button class="icon-button" id="homeBtn" title="Home" aria-label="Home"><span class="home-icon">🏠</span><span class="home-label">Home</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>
</div> </div>
@ -37,6 +37,8 @@
<option value="dark-mode">Dark Mode</option> <option value="dark-mode">Dark Mode</option>
<option value="neon">Neon</option> <option value="neon">Neon</option>
<option value="meridian">Meridian</option> <option value="meridian">Meridian</option>
<option value="impeccable">Impeccable Light</option>
<option value="impeccable-dark">Impeccable Dark</option>
</select> </select>
</div> </div>
</div> </div>
@ -46,7 +48,7 @@
<img class="welcome-brand welcome-brand-light" src="./brand-icon-black.png" alt="Folio" draggable="false" /> <img class="welcome-brand welcome-brand-light" src="./brand-icon-black.png" alt="Folio" draggable="false" />
<img class="welcome-brand welcome-brand-dark" src="./brand-icon.png" alt="Folio" draggable="false" /> <img class="welcome-brand welcome-brand-dark" src="./brand-icon.png" alt="Folio" draggable="false" />
<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 documents, organized your way.</p>
<div class="welcome-actions"> <div class="welcome-actions">
<button class="button" id="welcomeNewBook">New</button> <button class="button" id="welcomeNewBook">New</button>
<button class="button button-ghost" id="welcomeOpenBook">Open</button> <button class="button button-ghost" id="welcomeOpenBook">Open</button>
@ -105,23 +107,29 @@
<div class="dropdown-menu font-menu hidden" id="fontMenu"></div> <div class="dropdown-menu font-menu hidden" id="fontMenu"></div>
</div> </div>
<select id="fontSizeSelect" class="toolbar-select" title="Font size"> <select id="fontSizeSelect" class="toolbar-select" title="Font size">
<option value="">17px</option> <option value="8px">8px</option>
<option value="9px">9px</option>
<option value="10px">10px</option>
<option value="11px">11px</option>
<option value="12px">12px</option> <option value="12px">12px</option>
<option value="14px">14px</option> <option value="14px">14px</option>
<option value="16px">16px</option> <option value="16px" selected>16px</option>
<option value="18px">18px</option> <option value="18px">18px</option>
<option value="20px">20px</option> <option value="20px">20px</option>
<option value="24px">24px</option> <option value="24px">24px</option>
<option value="28px">28px</option>
<option value="32px">32px</option> <option value="32px">32px</option>
<option value="36px">36px</option>
<option value="48px">48px</option>
<option value="72px">72px</option>
</select> </select>
<select id="lineSpacingSelect" class="toolbar-select" title="Line spacing"> <select id="lineSpacingSelect" class="toolbar-select" title="Line spacing">
<option value="">1.7</option> <option value="1.0" selected>1.0</option>
<option value="1">1.0</option>
<option value="1.15">1.15</option> <option value="1.15">1.15</option>
<option value="1.25">1.25</option>
<option value="1.5">1.5</option> <option value="1.5">1.5</option>
<option value="2">2.0</option> <option value="1.75">1.75</option>
<option value="2.5">2.5</option> <option value="2.0">2.0</option>
<option value="3">3.0</option>
</select> </select>
</div> </div>
<div class="toolbar-group"> <div class="toolbar-group">

View file

@ -1,5 +1,5 @@
import { initBook } from "./book.js"; import { initBook } from "./book/index.js";
import type { FolioAPI } from "./book.js"; import type { FolioAPI } from "./book/index.js";
import { initTheme } from "./theme.js"; import { initTheme } from "./theme.js";
declare global { declare global {

View file

@ -4,12 +4,12 @@
// in a single read-only ProseMirror surface, separated by ChapterBreak anchors. // in a single read-only ProseMirror surface, separated by ChapterBreak anchors.
// These helpers are kept free of DOM/IPC so they can be unit-tested directly // These helpers are kept free of DOM/IPC so they can be unit-tested directly
// and reused by the renderer (book.ts). // and reused by the renderer (book.ts).
import type { FolioNode } from "../main/native-doc.js"; import type { FolioNode } from "../shared/native-doc-core.js";
import { import {
nativeNodeText, nativeNodeText,
nativeHasTitleHeading, nativeHasTitleHeading,
buildChapterNativeContent, buildChapterNativeContent,
} from "../main/native-doc.js"; } from "../shared/native-doc-core.js";
import type { WikilinkTarget } from "./wikilinks.js"; import type { WikilinkTarget } from "./wikilinks.js";
// The title-heading helpers are shared with the DOCX/ODT exporter and live // The title-heading helpers are shared with the DOCX/ODT exporter and live

View file

@ -5,11 +5,11 @@
// formatting feature the editor can write. Chapters are emitted as // formatting feature the editor can write. Chapters are emitted as
// <section class="print-chapter"> blocks, delimited by the chapterBreak nodes // <section class="print-chapter"> blocks, delimited by the chapterBreak nodes
// the Full Book assembly inserts between chapters. // the Full Book assembly inserts between chapters.
import type { FolioMark, FolioNode } from "../main/native-doc.js"; import type { FolioMark, FolioNode } from "../shared/native-doc-core.js";
import { import {
CALLOUT_TITLE_CLASS, CALLOUT_TITLE_CLASS,
CHAPTER_LINK_PREFIX, CHAPTER_LINK_PREFIX,
} from "../main/native-doc.js"; } from "../shared/native-doc-core.js";
// Native-document print rendering for the PDF-exported manuscript (#printBook). // Native-document print rendering for the PDF-exported manuscript (#printBook).
// //

View file

@ -17,7 +17,7 @@ const LIGHT = "light";
// Themes with a real entry in themes.css + the selector. Anything else that // Themes with a real entry in themes.css + the selector. Anything else that
// might linger in saved settings (a removed theme, a typo) falls back to // might linger in saved settings (a removed theme, a typo) falls back to
// Light so the <select> never desyncs from its <option> list. // Light so the <select> never desyncs from its <option> list.
const KNOWN = new Set(["light", "dark-mode", "neon", "meridian"]); const KNOWN = new Set(["light", "dark-mode", "neon", "meridian", "impeccable", "impeccable-dark"]);
function normalizeTheme(id: unknown): string { function normalizeTheme(id: unknown): string {
if (typeof id !== "string" || !id || id === "default") return LIGHT; if (typeof id !== "string" || !id || id === "default") return LIGHT;

View file

@ -32,7 +32,7 @@
--color-panel: #f5f1e9; --color-panel: #f5f1e9;
--color-border: #e4e0d5; --color-border: #e4e0d5;
--color-text: #2b2925; --color-text: #2b2925;
--color-muted: #7c786d; --color-muted: #6f6a60;
--color-accent: #2f6b5f; --color-accent: #2f6b5f;
--color-accent-hover: #26584e; --color-accent-hover: #26584e;
@ -182,7 +182,7 @@
:root[data-theme="meridian"] .color-popover { :root[data-theme="meridian"] .color-popover {
box-shadow: box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.09), inset 0 1px 0 rgba(255, 255, 255, 0.09),
0 12px 40px var(--color-shadow-strong); 0 8px 16px var(--color-shadow-strong);
} }
/* The manuscript pane joins the glass: the writing region carries the same /* The manuscript pane joins the glass: the writing region carries the same
@ -195,6 +195,13 @@
:root[data-theme="meridian"] .folio-prose { :root[data-theme="meridian"] .folio-prose {
background-color: transparent; background-color: transparent;
} }
/* Override index.css `.editor-panes .folio-prose.ProseMirror { background: var(--color-bg) }`
which otherwise wins by specificity/order and leaves a solid prose column
whose edges read as a dark seam in the middle of the glass field. */
:root[data-theme="meridian"] .editor-panes .folio-prose.ProseMirror {
background: transparent !important;
background-color: transparent !important;
}
/* If the runtime can't blur, drop the glass pretense and raise opacity so /* If the runtime can't blur, drop the glass pretense and raise opacity so
text contrast never depends on backdrop-filter support. */ text contrast never depends on backdrop-filter support. */
@ -204,3 +211,172 @@
--color-panel: rgba(22, 22, 22, 0.94); --color-panel: rgba(22, 22, 22, 0.94);
} }
} }
/* ---- Impeccable (light) ----
Verbatim editorial system from https://impeccable.style (light variant):
lacquer/kinpaku/patina tokens, Alumni Sans display. Copied from
Nostr_Keynctr/frontend/src/styles.css :root[data-theme='impeccable'] — mapped
to Folio's --color-* tokens (original --bg/--surface naming kept as alias). */
:root[data-theme="impeccable"] {
--color-bg: oklch(97% 0.012 95);
--color-panel: oklch(99% 0.008 95);
--color-border: oklch(25% 0.02 95 / 0.16);
--color-text: oklch(25% 0.018 95);
--color-muted: oklch(45% 0.015 95);
--color-accent: oklch(77% 0.13 82);
--color-accent-hover: oklch(61% 0.085 78);
--color-on-accent: oklch(14% 0.018 95);
--color-on-accent-soft: oklch(77% 0.13 82 / 0.14);
--color-on-accent-border: oklch(25% 0.02 95 / 0.22);
--color-selection: oklch(77% 0.13 82 / 0.22);
--color-selection-strong: oklch(77% 0.13 82 / 0.42);
--color-shadow: oklch(25% 0.02 95 / 0.1);
--color-shadow-strong: oklch(25% 0.02 95 / 0.16);
--color-backdrop: oklch(25% 0.02 95 / 0.4);
--color-scrollbar: oklch(25% 0.02 95 / 0.22);
--color-scrollbar-hover: oklch(25% 0.02 95 / 0.38);
--radius: 8px;
--radius-sm: 3px;
/* aliases for Nostr naming compatibility */
--bg: oklch(97% 0.012 95);
--surface: oklch(99% 0.008 95);
--surface-2: oklch(91% 0.012 95);
--surface-hover: oklch(92% 0.014 95);
--border: oklch(25% 0.02 95 / 0.16);
--border-strong: oklch(25% 0.02 95 / 0.42);
--text: oklch(25% 0.018 95);
--text-muted: oklch(45% 0.015 95);
--primary: oklch(77% 0.13 82);
--primary-hover: oklch(61% 0.085 78);
--primary-soft: oklch(77% 0.13 82 / 0.14);
--on-primary: oklch(14% 0.018 95);
--focus: oklch(77% 0.13 82);
--shadow: 0 2px 6px oklch(25% 0.02 95 / 0.1);
--shadow-modal: 0 10px 28px oklch(25% 0.02 95 / 0.16);
}
html[data-theme="impeccable"] h1,
html[data-theme="impeccable"] h2 {
font-family: "Alumni Sans", "Albert Sans", "Avenir Next", "Helvetica Neue", Arial, sans-serif;
letter-spacing: -0.01em;
font-weight: 300;
text-transform: none;
}
html[data-theme="impeccable"] h1 {
font-size: 32px;
line-height: 1.05;
font-weight: 700;
}
html[data-theme="impeccable"] h2 {
font-size: 18px;
line-height: 1.2;
}
html[data-theme="impeccable"] .theme-label,
html[data-theme="impeccable"] .search-case,
html[data-theme="impeccable"] .recents-title,
html[data-theme="impeccable"] .chapter-header .word-count {
text-transform: uppercase;
letter-spacing: 0.08em;
font-size: 11px;
font-weight: 650;
color: var(--color-muted);
}
html[data-theme="impeccable"] .button.button-primary {
background: var(--color-accent);
color: var(--color-on-accent);
border-color: transparent;
}
html[data-theme="impeccable"] .button.button-primary:hover:not(:disabled) {
background: var(--color-accent-hover);
}
html[data-theme="impeccable"] .button.button-ghost {
background: var(--color-panel);
border-color: var(--color-border);
}
html[data-theme="impeccable"] .chapter-list li.is-active {
background: transparent;
color: var(--color-accent);
border-left: 1px solid var(--color-accent);
border-radius: 0 4px 4px 0;
}
html[data-theme="impeccable"] .sidebar,
html[data-theme="impeccable"] .modal,
html[data-theme="impeccable"] .color-popover,
html[data-theme="impeccable"] .editor {
border-width: 1px;
box-shadow: 0 2px 6px var(--color-shadow);
}
html[data-theme="impeccable"] code,
html[data-theme="impeccable"] .folio-prose code {
background: oklch(91% 0.012 95);
border-color: var(--color-border);
}
html[data-theme="impeccable"] input[type="text"],
html[data-theme="impeccable"] input[type="search"],
html[data-theme="impeccable"] select,
html[data-theme="impeccable"] textarea,
html[data-theme="impeccable"] .modal-input {
background: var(--color-panel);
}
/* ---- Impeccable Dark ----
Same kinpaku system, lacquer-deep base from the site's dark default.
Copied from Nostr_Keynctr/frontend/src/styles.css :root[data-theme='impeccable-dark'] */
:root[data-theme="impeccable-dark"] {
--color-bg: oklch(15% 0.006 95);
--color-panel: oklch(20% 0.006 95);
--color-border: oklch(78% 0 0 / 0.22);
--color-text: oklch(88% 0 0);
--color-muted: oklch(72% 0 0);
--color-accent: oklch(84% 0.19 80.46);
--color-accent-hover: oklch(77% 0.13 82);
--color-on-accent: oklch(14% 0.018 95);
--color-on-accent-soft: oklch(77% 0.13 82 / 0.14);
--color-on-accent-border: oklch(78% 0 0 / 0.35);
--color-selection: oklch(84% 0.19 80.46 / 0.28);
--color-selection-strong: oklch(84% 0.19 80.46 / 0.45);
--color-shadow: oklch(0% 0 0 / 0.35);
--color-shadow-strong: oklch(0% 0 0 / 0.45);
--color-backdrop: oklch(0% 0 0 / 0.55);
--color-scrollbar: oklch(78% 0 0 / 0.22);
--color-scrollbar-hover: oklch(78% 0 0 / 0.38);
--radius: 8px;
--radius-sm: 3px;
color-scheme: dark;
--bg: oklch(15% 0.006 95);
--surface: oklch(20% 0.006 95);
--surface-2: oklch(25% 0.008 95);
--surface-hover: oklch(30% 0.008 95);
--border: oklch(78% 0 0 / 0.22);
--border-strong: oklch(80% 0 0 / 0.5);
--text: oklch(88% 0 0);
--text-muted: oklch(72% 0 0);
--primary: oklch(84% 0.19 80.46);
--primary-hover: oklch(77% 0.13 82);
--primary-soft: oklch(77% 0.13 82 / 0.14);
--on-primary: oklch(14% 0.018 95);
--focus: oklch(84% 0.19 80.46);
--shadow: 0 2px 6px oklch(0% 0 0 / 0.35);
--shadow-modal: 0 10px 28px oklch(0% 0 0 / 0.45);
}
html[data-theme="impeccable-dark"] h1,
html[data-theme="impeccable-dark"] h2 {
font-family: "Alumni Sans", "Albert Sans", "Avenir Next", "Helvetica Neue", Arial, sans-serif;
letter-spacing: -0.01em;
font-weight: 300;
}
html[data-theme="impeccable-dark"] .theme-label,
html[data-theme="impeccable-dark"] .search-case,
html[data-theme="impeccable-dark"] .recents-title {
text-transform: uppercase;
letter-spacing: 0.08em;
font-size: 11px;
}
html[data-theme="impeccable-dark"] .sidebar {
background: oklch(12% 0.006 95);
border-right-color: var(--color-border);
}
html[data-theme="impeccable-dark"] .chapter-list li.is-active {
color: var(--color-accent);
border-left-color: var(--color-accent);
}

View file

@ -22,7 +22,7 @@ import Table from "@tiptap/extension-table";
import TableRow from "@tiptap/extension-table-row"; import TableRow from "@tiptap/extension-table-row";
import TableHeader from "@tiptap/extension-table-header"; import TableHeader from "@tiptap/extension-table-header";
import TableCell from "@tiptap/extension-table-cell"; import TableCell from "@tiptap/extension-table-cell";
import type { FolioChapterDoc, FolioNode } from "../main/native-doc.js"; import type { FolioChapterDoc, FolioNode } from "../shared/native-doc-core.js";
// --- Custom marks matching the native Folio schema ------------------------- // --- Custom marks matching the native Folio schema -------------------------
// The native model uses `textColor` (not Tiptap's `textStyle`) so the // The native model uses `textColor` (not Tiptap's `textStyle`) so the

View file

@ -1,5 +1,5 @@
import type { MarkdownIt, StateInline } from "markdown-it"; import type { MarkdownIt, StateInline } from "markdown-it";
import { CHAPTER_LINK_PREFIX } from "../main/native-doc.js"; import { CHAPTER_LINK_PREFIX } from "../shared/native-doc-core.js";
export interface WikilinkTarget { export interface WikilinkTarget {
id: string; id: string;

View file

@ -0,0 +1,143 @@
// Native Folio rich-text document model — pure core.
//
// This is the single source of truth for chapter content once a book is
// migrated to the native format. The document is a ProseMirror/Tiptap JSON
// tree, not Markdown. See Stage 2 of the rich-text migration plan.
//
// Everything here must stay free of Node builtins (fs/path) and Electron:
// it is imported by both processes and bundled into the renderer. Disk IO
// lives in src/main/native-doc.ts, which re-exports this module.
export interface FolioMark {
type: string;
attrs?: Record<string, unknown>;
}
export interface FolioNode {
type: string;
attrs?: Record<string, unknown>;
content?: FolioNode[];
text?: string;
marks?: FolioMark[];
}
export interface FolioChapterDoc {
schemaVersion: number;
doc: FolioNode;
}
// Bump this whenever the native document shape needs a one-way migration.
export const CURRENT_DOC_SCHEMA_VERSION = 1;
// ---- Cross-converter contracts -------------------------------------------
// These string constants are part of the document format itself. They are
// consumed by every module that reads or writes native docs (markdown-to-native,
// native-to-markdown, renderer/print-format), so they live here — next to the
// schema they belong to — instead of being re-typed as literals in each
// converter. Changing any of them changes what is persisted on disk.
//
// Callout title paragraphs carry this class so serializers can tell the title
// apart from body paragraphs (set on parse, stripped on serialize/render).
export const CALLOUT_TITLE_CLASS = "callout-title";
// Internal chapter-link scheme: folio:chapter/<id>. Serializers turn it back
// into [[wikilinks]]; the wikilink plugin turns [[text]] into it on parse.
export const CHAPTER_LINK_PREFIX = "folio:chapter/";
// ---- Chapter presentation helpers ----------------------------------------
// Shared by every assembled/exported rendering of a chapter (Full Book view,
// PDF print, DOCX/ODT) so "does this chapter already carry its own title?"
// has exactly one answer everywhere. Before this lived in two files with
// different rules, DOCX and PDF could disagree about injecting the title.
/** Concatenated plain text of a node subtree (atom leaves contribute nothing). */
export function nativeNodeText(node: FolioNode): string {
if (node.text) return node.text;
let t = "";
for (const c of node.content ?? []) t += nativeNodeText(c);
return t;
}
/** Whether any top-level heading in the content already matches the title
* (case/space-insensitive). If true, renderers must not inject a duplicate
* title heading ahead of the content. */
export function nativeHasTitleHeading(content: FolioNode[], title: string): boolean {
const t = title.trim().toLowerCase();
if (!t) return false;
for (const n of content) {
if (n.type === "heading" && nativeNodeText(n).trim().toLowerCase() === t) return true;
}
return false;
}
/** Top-level nodes for one chapter, injecting a title heading when the
* content does not already provide one. */
export function buildChapterNativeContent(content: FolioNode[], title: string): FolioNode[] {
if (nativeHasTitleHeading(content, title)) return content.slice();
return [
{ type: "heading", attrs: { level: 1 }, content: [{ type: "text", text: title }] },
...content,
];
}
// Each entry migrates the document FROM its key version TO key+1.
// Entries are additive and must be non-destructive.
const MIGRATIONS: Record<number, (doc: FolioChapterDoc) => FolioChapterDoc> = {};
// File name for a native chapter (replaces <id>.md).
export function nativeChapterFile(id: string): string {
return `${id}.json`;
}
// Apply any pending schema migrations, always returning the current version.
export function migrateDoc(doc: FolioChapterDoc): FolioChapterDoc {
let current = doc;
while (current.schemaVersion < CURRENT_DOC_SCHEMA_VERSION) {
const step = MIGRATIONS[current.schemaVersion];
if (!step) break;
current = step(current);
}
current.schemaVersion = CURRENT_DOC_SCHEMA_VERSION;
return current;
}
// Normalize legacy node type names on read. The editor's StarterKit historically
// emitted Tiptap's default `hardBreak` (camelCase), while the converter/export
// pipeline uses `hard_break` (snake_case). Rename so every consumer sees one
// canonical name regardless of how the document was originally saved.
function normalizeDocTypes(node: unknown): unknown {
if (!node || typeof node !== "object") return node;
const n = node as Record<string, any>;
if (typeof n.type === "string" && n.type === "hardBreak") n.type = "hard_break";
if (Array.isArray(n.content)) n.content = n.content.map((c) => normalizeDocTypes(c));
return n;
}
export function serializeChapter(doc: FolioChapterDoc): string {
return JSON.stringify(migrateDoc(doc), null, 2);
}
export function parseChapter(text: string): FolioChapterDoc {
let raw: unknown;
try {
raw = JSON.parse(text);
} catch {
throw new Error("Not a native Folio chapter document (invalid JSON).");
}
if (
!raw ||
typeof raw !== "object" ||
!("doc" in raw) ||
(raw as FolioChapterDoc).doc?.type !== "doc"
) {
throw new Error("Not a native Folio chapter document (missing doc tree).");
}
const parsed = migrateDoc(raw as FolioChapterDoc);
normalizeDocTypes(parsed.doc);
return parsed;
}
// Validate that a parsed object is structurally a usable chapter document.
export function isValidDoc(doc: FolioChapterDoc): boolean {
return !!doc && doc.doc?.type === "doc" && Array.isArray(doc.doc.content);
}