Add automatic chapter page breaks to Full Book
This commit is contained in:
parent
06fa671f84
commit
ebbb424ee5
4 changed files with 144 additions and 7 deletions
|
|
@ -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}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue