Add automatic chapter page breaks to Full Book

This commit is contained in:
avi 2026-08-18 09:05:05 -05:00
commit ebbb424ee5
4 changed files with 144 additions and 7 deletions

View file

@ -1,7 +1,8 @@
import type { BookMeta } from "../main/project.js"; import type { BookMeta } from "../main/project.js";
import { alertMessage, confirmMessage, promptInput } from "./dialog.js"; import { alertMessage, confirmMessage, promptInput } from "./dialog.js";
import { createMdEditor, editorText, setEditorText } from "./md-editor.js"; import { createMdEditor, editorText, setEditorText } from "./md-editor.js";
import type { EditorView } from "@codemirror/view"; import { Decoration, EditorView, type DecorationSet } from "@codemirror/view";
import { StateEffect, StateField } from "@codemirror/state";
import { redo, selectAll, undo, indentLess, indentMore } from "@codemirror/commands"; import { redo, selectAll, undo, indentLess, indentMore } from "@codemirror/commands";
import { import {
applyFormatPainter, applyFormatPainter,
@ -117,9 +118,27 @@ export function initBook(api: FolioAPI) {
const copyAllBtn = $("copyAllBtn") as HTMLButtonElement; const copyAllBtn = $("copyAllBtn") as HTMLButtonElement;
const fullBookStatus = $("fullBookStatus"); const fullBookStatus = $("fullBookStatus");
// Marks the line where every chapter after the first begins. In print this
// line carries the page-break; on screen it shows a subtle separator. The
// marker lives only in the rendered DOM, never in the assembled Markdown.
const chapterBreakLine = Decoration.line({ class: "chapter-break" });
const setChapterBreaks = StateEffect.define<DecorationSet>();
const chapterBreaksField = StateField.define<DecorationSet>({
create: () => Decoration.none,
update(decorations, tr) {
decorations = decorations.map(tr.changes);
for (const effect of tr.effects) {
if (effect.is(setChapterBreaks)) decorations = effect.value;
}
return decorations;
},
provide: (field) => EditorView.decorations.from(field),
});
const fullBookEditor: EditorView = createMdEditor(fullBookHost, { const fullBookEditor: EditorView = createMdEditor(fullBookHost, {
onChange: () => undefined, onChange: () => undefined,
readOnly: true, readOnly: true,
extensions: [chapterBreaksField],
}); });
const editorView: EditorView = createMdEditor(editorHost, { const editorView: EditorView = createMdEditor(editorHost, {
@ -504,11 +523,17 @@ export function initBook(api: FolioAPI) {
// Assemble the whole book from the canonical book metadata (the same source // Assemble the whole book from the canonical book metadata (the same source
// the chapter sidebar uses) so Full Book always reflects the current chapter // the chapter sidebar uses) so Full Book always reflects the current chapter
// set and order, regardless of any in-memory renderer state. // set and order, regardless of any in-memory renderer state.
async function assembleFullBook(): Promise<string> { //
// Each chapter is preserved as its own block (title + content) and we also
// record where every chapter begins in the assembled text so the view can
// mark chapter boundaries for page breaks without touching the chapter files.
async function assembleFullBook(): Promise<{ text: string; chapterStarts: number[] }> {
const res = await api.getBookMeta(); const res = await api.getBookMeta();
if (!res || typeof res !== "object" || "error" in res) return ""; if (!res || typeof res !== "object" || "error" in res) return { text: "", chapterStarts: [] };
const book = res as BookMeta; const book = res as BookMeta;
const parts: string[] = []; const parts: string[] = [];
const chapterStarts: number[] = [];
let offset = 0;
for (const id of book.chapterOrder) { for (const id of book.chapterOrder) {
const ch = book.chapters[id]; const ch = book.chapters[id];
if (!ch) continue; if (!ch) continue;
@ -525,14 +550,23 @@ export function initBook(api: FolioAPI) {
} else if (body) { } else if (body) {
block.push(body); block.push(body);
} }
if (block.length) parts.push(block.join("\n")); if (block.length) {
chapterStarts.push(offset);
const joined = block.join("\n");
parts.push(joined);
offset += joined.length + 2;
} }
return parts.join("\n\n"); }
return { text: parts.join("\n\n"), chapterStarts };
} }
async function renderFullBook() { async function renderFullBook() {
const text = await assembleFullBook(); const { text, chapterStarts } = await assembleFullBook();
setEditorText(fullBookEditor, text); setEditorText(fullBookEditor, text);
const ranges = chapterStarts.slice(1).map((from) => chapterBreakLine.range(from));
fullBookEditor.dispatch({
effects: setChapterBreaks.of(Decoration.set(ranges, true)),
});
fullBookStats.textContent = `words: ${countWords(text)} · chars: ${text.length}`; fullBookStats.textContent = `words: ${countWords(text)} · chars: ${text.length}`;
} }

View file

@ -508,6 +508,61 @@ html, body {
overflow: hidden; overflow: hidden;
} }
/* ---- full book chapter breaks ----
The Full Book stays one continuous scroll on screen; every chapter after
the first gets a subtle separator where a printed page will begin. The
separator is a pure CSS border on the chapter's first line, so it never
becomes part of the copied chapter text. */
#fullBookHost .cm-line.chapter-break {
border-top: 1px solid var(--color-border);
margin-top: 2.5rem;
padding-top: 1.5rem;
}
@media print {
html, body, #root {
height: auto !important;
overflow: visible !important;
}
.sidebar, .full-book-nav, .chapter-header, .editor-placeholder {
display: none !important;
}
.editor, .full-book-view, .editor-panes {
height: auto !important;
min-height: 0 !important;
overflow: visible !important;
}
#fullBookHost .cm-editor,
#fullBookHost .cm-scroller,
#fullBookHost .cm-content {
height: auto !important;
min-height: 0 !important;
max-height: none !important;
overflow: visible !important;
}
#fullBookHost .cm-scroller {
display: block !important;
}
#fullBookHost .cm-content {
white-space: pre-wrap !important;
}
/* Every chapter after the first starts on a new printed page. The first
chapter keeps its natural position (no blank page before it). */
#fullBookHost .cm-line.chapter-break {
break-before: page;
page-break-before: always;
border-top: none;
margin-top: 0;
padding-top: 0;
}
}
/* ---- editor toolbar ---- */ /* ---- editor toolbar ---- */
.editor-toolbar { .editor-toolbar {
display: flex; display: flex;

View file

@ -1,4 +1,4 @@
import { EditorState } from "@codemirror/state"; import { EditorState, type Extension } from "@codemirror/state";
import { import {
EditorView, EditorView,
drawSelection, drawSelection,
@ -23,6 +23,7 @@ export interface MdEditorCallbacks {
onChange: (text: string) => void; onChange: (text: string) => void;
onSelection?: (view: EditorView) => void; onSelection?: (view: EditorView) => void;
readOnly?: boolean; readOnly?: boolean;
extensions?: Extension[];
} }
const formatBindings: KeyBinding[] = [ const formatBindings: KeyBinding[] = [
@ -119,6 +120,7 @@ export function createMdEditor(host: HTMLElement, callbacks: MdEditorCallbacks):
search({ top: true }), search({ top: true }),
syntaxHighlighting(mdHighlight), syntaxHighlighting(mdHighlight),
folioTheme, folioTheme,
...(callbacks.extensions ?? []),
...(readOnly ? [EditorState.readOnly.of(true)] : []), ...(readOnly ? [EditorState.readOnly.of(true)] : []),
keymap.of( keymap.of(
readOnly readOnly

View file

@ -245,6 +245,33 @@ const BOOK2_DIR = __BOOKDIR2_VALUE__;
out.fullBookStatsOk = out.fullBookStatsOk =
statsEl.textContent === `words: ${words} · chars: ${expectedAssembled.length}`; statsEl.textContent === `words: ${words} · chars: ${expectedAssembled.length}`;
// --- chapter page breaks in Full Book ---
// Each chapter after the first gets a "chapter-break" marker on its first
// rendered line (a CSS border on screen, break-before: page when printed).
// The first chapter must never be marked, so no blank page precedes it.
const chapterBreakLines = () =>
Array.from(document.querySelectorAll("#fullBookHost .cm-line.chapter-break"));
const allContentLines = () =>
Array.from(document.querySelectorAll("#fullBookHost .cm-content .cm-line"));
const fbBreaks = chapterBreakLines();
const firstLine = allContentLines()[0];
out.chapterBreakCount = fbBreaks.length;
out.chapterBreakCountOk = fbBreaks.length === 2; // 3 chapters -> 2 breaks
out.firstChapterUnmarked = !!firstLine && !firstLine.classList.contains("chapter-break");
out.breaksAtChapterStarts =
fbBreaks.length === 2 &&
fbBreaks.every((el) => {
const t = el.textContent;
return t.startsWith("# Beta") || t.startsWith("# Gamma");
});
// print rules must be present in the stylesheet (fetch the raw CSS because
// Chromium's CSSOM normalizes the page-break-before alias into break-before)
const rawCss = await (await fetch("./index.css")).text();
out.printBreakRule = /break-before\s*:\s*page/.test(rawCss);
out.printBreakFallback = /page-break-before\s*:\s*always/.test(rawCss);
out.printBreakScoped = /#fullBookHost\s+\.cm-line\.chapter-break/.test(rawCss);
document.getElementById("copyAllBtn").click(); document.getElementById("copyAllBtn").click();
await new Promise((r) => setTimeout(r, 200)); await new Promise((r) => setTimeout(r, 200));
const clip = await window.folio.getClipboardText(); const clip = await window.folio.getClipboardText();
@ -303,6 +330,18 @@ const BOOK2_DIR = __BOOKDIR2_VALUE__;
assembledGAB.includes("Alpha body text") && assembledGAB.includes("Alpha body text") &&
assembledGAB.includes("Beta body without heading"); assembledGAB.includes("Beta body without heading");
// chapter breaks follow the reordered manuscript: still exactly 2 breaks,
// now marking Alpha and Beta (Gamma is first, so it stays unmarked)
const fbBreaksReordered = chapterBreakLines();
out.fbBreaksAfterReorder = fbBreaksReordered.map((el) => el.textContent);
out.fbBreakAfterReorderOk =
fbBreaksReordered.length === 2 &&
fbBreaksReordered.every((el) => {
const t = el.textContent;
return t.startsWith("# Alpha") || t.startsWith("# Beta");
}) &&
!allContentLines()[0].classList.contains("chapter-break");
// switching between an individual chapter and Full Book never shows stale data // switching between an individual chapter and Full Book never shows stale data
document.querySelector("#chapterList .chapter-row").click(); document.querySelector("#chapterList .chapter-row").click();
await new Promise((r) => setTimeout(r, 200)); await new Promise((r) => setTimeout(r, 200));
@ -392,6 +431,12 @@ const BOOK2_DIR = __BOOKDIR2_VALUE__;
"fullBookShown", "fullBookShown",
"assembledOk", "assembledOk",
"fullBookStatsOk", "fullBookStatsOk",
"chapterBreakCountOk",
"firstChapterUnmarked",
"breaksAtChapterStarts",
"printBreakRule",
"printBreakFallback",
"printBreakScoped",
"copyAllOk", "copyAllOk",
"copyStatusShown", "copyStatusShown",
"fullBookSelectAllOk", "fullBookSelectAllOk",
@ -399,6 +444,7 @@ const BOOK2_DIR = __BOOKDIR2_VALUE__;
"reorderOk", "reorderOk",
"fbAfterReorderOk", "fbAfterReorderOk",
"fbHasAllContent", "fbHasAllContent",
"fbBreakAfterReorderOk",
"fbNoStale", "fbNoStale",
"fbLayoutOk", "fbLayoutOk",
"reopenPersists", "reopenPersists",