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 { alertMessage, confirmMessage, promptInput } from "./dialog.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 {
applyFormatPainter,
@ -117,9 +118,27 @@ export function initBook(api: FolioAPI) {
const copyAllBtn = $("copyAllBtn") as HTMLButtonElement;
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, {
onChange: () => undefined,
readOnly: true,
extensions: [chapterBreaksField],
});
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
// the chapter sidebar uses) so Full Book always reflects the current chapter
// 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();
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 parts: string[] = [];
const chapterStarts: number[] = [];
let offset = 0;
for (const id of book.chapterOrder) {
const ch = book.chapters[id];
if (!ch) continue;
@ -525,14 +550,23 @@ export function initBook(api: FolioAPI) {
} else if (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() {
const text = await assembleFullBook();
const { text, chapterStarts } = await assembleFullBook();
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}`;
}

View file

@ -508,6 +508,61 @@ html, body {
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 {
display: flex;

View file

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