diff --git a/src/renderer/book.ts b/src/renderer/book.ts index e535c74..3c12b77 100644 --- a/src/renderer/book.ts +++ b/src/renderer/book.ts @@ -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(); + const chapterBreaksField = StateField.define({ + 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 { + // + // 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}`; } diff --git a/src/renderer/index.css b/src/renderer/index.css index e9d55f9..8c009f2 100644 --- a/src/renderer/index.css +++ b/src/renderer/index.css @@ -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; diff --git a/src/renderer/md-editor.ts b/src/renderer/md-editor.ts index 40b61cb..52efc4b 100644 --- a/src/renderer/md-editor.ts +++ b/src/renderer/md-editor.ts @@ -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 diff --git a/tests/selftest-scenario.js b/tests/selftest-scenario.js index 7b73607..c2d6dd5 100644 --- a/tests/selftest-scenario.js +++ b/tests/selftest-scenario.js @@ -245,6 +245,33 @@ const BOOK2_DIR = __BOOKDIR2_VALUE__; out.fullBookStatsOk = 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(); await new Promise((r) => setTimeout(r, 200)); const clip = await window.folio.getClipboardText(); @@ -303,6 +330,18 @@ const BOOK2_DIR = __BOOKDIR2_VALUE__; assembledGAB.includes("Alpha body text") && 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 document.querySelector("#chapterList .chapter-row").click(); await new Promise((r) => setTimeout(r, 200)); @@ -392,6 +431,12 @@ const BOOK2_DIR = __BOOKDIR2_VALUE__; "fullBookShown", "assembledOk", "fullBookStatsOk", + "chapterBreakCountOk", + "firstChapterUnmarked", + "breaksAtChapterStarts", + "printBreakRule", + "printBreakFallback", + "printBreakScoped", "copyAllOk", "copyStatusShown", "fullBookSelectAllOk", @@ -399,6 +444,7 @@ const BOOK2_DIR = __BOOKDIR2_VALUE__; "reorderOk", "fbAfterReorderOk", "fbHasAllContent", + "fbBreakAfterReorderOk", "fbNoStale", "fbLayoutOk", "reopenPersists",