mirror of
https://github.com/PR0M3TH3AN/Artstr-Studio.git
synced 2026-10-27 08:40:39 +00:00
book(C.5.2): pagination engine + page-count readout
New pure function `paginateChapter(entry, book)`:
- Resolves the chapter's effective master + finds its sole
text-frame layer. No master / no frame → returns warnings,
empty pages, so the UI can hint at what's missing.
- Tokenises the markdown via `marked.lexer` (token shapes, not
HTML), then for each token builds a "block" carrying the
laid-out lines + the style metadata + the total height.
- Block kinds today: paragraph, h1-h6, blockquote, code (no
wrapping inside code blocks), list (flat "• item" / "1. item"
lines), hr. Inline runs (bold / italic / inline-code) collapse
to plain text for now — Phase I gives them per-run styles.
- Text wraps greedily into lines that fit the frame's available
width (frame.w minus 2 × frame padding minus left/right
margins from the block's style). Words too wide to fit are
emitted anyway and surfaced as a warning so the author can
spot them.
- Pages fill until adding the next block would exceed frame
height, then a new page starts. `keepWithNext` (true on h1-h6)
pushes a heading to the next page if its following block
wouldn't fit alongside it. Widow / orphan rules land in C.5.x.
- A block too tall to ever fit in the frame goes to `overflow`
with a matching warning rather than looping forever.
- Returns `{ pages, overflow, warnings, masterId, frame }`.
Pages are arrays of `{ blocks }`; each block carries
`{ kind, tokenType, lines, style, lineHeightIn, y, blockH }`
so the C.5.3 renderer can paint them straight onto a virtual
page.
The chapter editor toolbar now shows live word + page counts
("142 words · 4 pages") via the engine, plus a warning blurb +
hover-title with full details when something doesn't lay out
cleanly. Updates on every textarea keystroke (cheap path —
re-runs the engine for the active chapter only).
C.5.3 wires the engine to actually paint paginated pages: tile
thumbnails for reflow segments + an optional paginated preview
in the chapter editor's right pane.
This commit is contained in:
1 parent
f41e72e942
commit
b12c3cb625
1 file changed
+254
-3
+254
-3
@@ -8951,6 +8951,240 @@
|
||||
syncInputs();
|
||||
render();
|
||||
}
|
||||
// Pagination engine. Pure function shape so it's testable in
|
||||
// isolation. Takes a reflow entry + the book state, returns
|
||||
// `{ pages, overflow, warnings, masterId, frame }`.
|
||||
//
|
||||
// pages: array of `{ blocks }` where each block is one
|
||||
// markdown token already laid out:
|
||||
// { kind, lines: string[], style, lineHeightIn, y, blockH }
|
||||
// overflow: blocks that wouldn't fit on any page (e.g. a frame
|
||||
// too small to hold a single line)
|
||||
// warnings: human-readable hints (long words, missing frame…)
|
||||
// masterId: the master used (may be null)
|
||||
// frame: the text-frame's geometry in canvas-inch space
|
||||
// (x, y, w, h, padding) — null when no master / no frame.
|
||||
//
|
||||
// Phase C.5.2 ships paragraph + heading + blockquote + code +
|
||||
// list + hr layout with greedy line breaking. Inline runs
|
||||
// (bold / italic / inline-code) collapse to plain text using
|
||||
// the block's base style; Phase I gives them per-run styles.
|
||||
function paginateChapter(entry, book) {
|
||||
const result = { pages: [], overflow: [], warnings: [], masterId: null, frame: null };
|
||||
if (!entry || entry.type !== 'reflow') return result;
|
||||
if (typeof window.marked === 'undefined' || !window.marked.lexer) {
|
||||
result.warnings.push('marked library failed to load');
|
||||
return result;
|
||||
}
|
||||
const md = String(entry.chapterMarkdown || '');
|
||||
const styles = (book?.spec?.textStyles) || defaultBookTextStyles();
|
||||
const masterId = entry.masterId || book?.spec?.defaultMasterId || null;
|
||||
result.masterId = masterId;
|
||||
|
||||
// Locate the text-frame layer on the master.
|
||||
let frame = null;
|
||||
if (masterId) {
|
||||
const target = `book-master:${masterId}`;
|
||||
for (const l of state.layers || []) {
|
||||
if (l.target !== target) continue;
|
||||
if (l.type === 'shape' && l.shape?.kind === 'text-frame') {
|
||||
frame = l;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!frame) {
|
||||
result.warnings.push(masterId
|
||||
? 'Master has no text-frame layer. Add one in master-edit mode to paginate.'
|
||||
: 'No master selected for this chapter.');
|
||||
return result;
|
||||
}
|
||||
const padding = Number(frame.shape?.padding) || 0;
|
||||
const frameW = Math.max(0.5, (Number(frame.w) || 0) - 2 * padding);
|
||||
const frameH = Math.max(0.5, (Number(frame.h) || 0) - 2 * padding);
|
||||
result.frame = {
|
||||
x: Number(frame.x) || 0,
|
||||
y: Number(frame.y) || 0,
|
||||
w: Number(frame.w) || 0,
|
||||
h: Number(frame.h) || 0,
|
||||
padding,
|
||||
};
|
||||
|
||||
const tokens = window.marked.lexer(md);
|
||||
|
||||
// One shared Canvas2D context for text-width measurement.
|
||||
// Sized at 96 px/inch so widths come out in pixels and we
|
||||
// divide back into inches at the end.
|
||||
const ctx = document.createElement('canvas').getContext('2d');
|
||||
const styleFor = (kind) => styles[kind] || styles.body || {};
|
||||
const setFont = (style) => {
|
||||
const fs = Number(style.fontSize) || 11;
|
||||
const fw = Number(style.fontWeight) || 400;
|
||||
const fst = style.fontStyle === 'italic' ? 'italic ' : '';
|
||||
const ff = style.fontFamily || 'Georgia, serif';
|
||||
ctx.font = `${fst}${fw} ${fs * 96 / 72}px ${ff}`; // pt → px at 96dpi
|
||||
};
|
||||
const lineHeightIn = (style) => {
|
||||
const fs = Number(style.fontSize) || 11;
|
||||
const lh = Number(style.lineHeight) || 1.5;
|
||||
return (fs * 96 / 72 / 96) * lh; // pt → inches × line-height
|
||||
};
|
||||
|
||||
// Greedy wrap for a single string into multiple lines that
|
||||
// fit `availableW` inches. Long single words overflow with a
|
||||
// warning; we don't auto-hyphenate.
|
||||
function wrapTextIntoLines(text, availableW) {
|
||||
const lines = [];
|
||||
// Preserve hard line breaks (paragraph internal `\n`) by
|
||||
// wrapping each segment separately.
|
||||
const segments = String(text).split('\n');
|
||||
for (let s = 0; s < segments.length; s++) {
|
||||
const words = segments[s].split(/\s+/).filter(Boolean);
|
||||
if (!words.length) {
|
||||
// Empty paragraph line — emit a blank line so spacing
|
||||
// stays correct.
|
||||
if (s > 0) lines.push('');
|
||||
continue;
|
||||
}
|
||||
let line = '';
|
||||
for (const word of words) {
|
||||
const candidate = line ? line + ' ' + word : word;
|
||||
const widthIn = ctx.measureText(candidate).width / 96;
|
||||
if (widthIn > availableW && line) {
|
||||
lines.push(line);
|
||||
line = word;
|
||||
// Check if the single word still overflows the column.
|
||||
if (ctx.measureText(word).width / 96 > availableW) {
|
||||
result.warnings.push(`Long word doesn't fit: "${word.slice(0, 24)}${word.length > 24 ? '…' : ''}"`);
|
||||
}
|
||||
} else {
|
||||
line = candidate;
|
||||
}
|
||||
}
|
||||
if (line) lines.push(line);
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
// Convert a single marked token to a "block": one or more
|
||||
// measured lines + the style metadata + the total block
|
||||
// height. Returns null for tokens we skip silently (space).
|
||||
function blockForToken(token) {
|
||||
if (!token) return null;
|
||||
if (token.type === 'space') return null;
|
||||
// Headings: depth 1-6 → h1-h6 style key.
|
||||
let styleKey = 'body';
|
||||
if (token.type === 'heading') styleKey = `h${Math.min(6, Math.max(1, token.depth || 1))}`;
|
||||
else if (token.type === 'paragraph') styleKey = 'body';
|
||||
else if (token.type === 'blockquote') styleKey = 'blockquote';
|
||||
else if (token.type === 'code') styleKey = 'codeBlock';
|
||||
else if (token.type === 'hr') styleKey = 'hr';
|
||||
else if (token.type === 'list') styleKey = 'list';
|
||||
else styleKey = 'body';
|
||||
|
||||
const style = styleFor(styleKey);
|
||||
const marginBefore = Number(style.marginBefore) || 0;
|
||||
const marginAfter = Number(style.marginAfter) || 0;
|
||||
const marginLeft = Number(style.marginLeft) || 0;
|
||||
const marginRight = Number(style.marginRight) || 0;
|
||||
const availW = Math.max(0.1, frameW - marginLeft - marginRight);
|
||||
setFont(style);
|
||||
const lineH = lineHeightIn(style);
|
||||
|
||||
let lines = [];
|
||||
let extraH = 0;
|
||||
if (token.type === 'hr') {
|
||||
// Render as a one-line rule.
|
||||
lines = [''];
|
||||
extraH = (Number(style.thickness) || 0.5) / 96;
|
||||
} else if (token.type === 'code' || token.type === 'codeBlock') {
|
||||
// Code blocks: each `\n`-separated line stays a line,
|
||||
// no wrapping (the user controls line lengths).
|
||||
lines = String(token.text || '').split('\n');
|
||||
} else if (token.type === 'list') {
|
||||
// Flatten list items into "• text" lines. Each item
|
||||
// wraps using the list style.
|
||||
const items = Array.isArray(token.items) ? token.items : [];
|
||||
const ordered = !!token.ordered;
|
||||
items.forEach((item, i) => {
|
||||
const marker = ordered ? `${i + 1}. ` : '• ';
|
||||
const text = String(item.text || '');
|
||||
const itemLines = wrapTextIntoLines(marker + text, availW);
|
||||
lines = lines.concat(itemLines);
|
||||
});
|
||||
} else if (token.type === 'blockquote') {
|
||||
// marked emits blockquote with inner tokens; concatenate
|
||||
// their plain text + wrap.
|
||||
const inner = (token.tokens || [])
|
||||
.map(t => t.text || t.raw || '')
|
||||
.join('\n\n');
|
||||
lines = wrapTextIntoLines(inner, availW);
|
||||
} else {
|
||||
// heading / paragraph / fallback: flat text.
|
||||
lines = wrapTextIntoLines(token.text || '', availW);
|
||||
}
|
||||
|
||||
const linesH = (lines.length || 1) * lineH + extraH;
|
||||
const blockH = marginBefore + linesH + marginAfter;
|
||||
return {
|
||||
kind: styleKey,
|
||||
tokenType: token.type,
|
||||
lines,
|
||||
style,
|
||||
lineHeightIn: lineH,
|
||||
marginBefore, marginAfter, marginLeft, marginRight,
|
||||
blockH,
|
||||
keepWithNext: !!style.keepWithNext,
|
||||
};
|
||||
}
|
||||
|
||||
// Lay blocks out into pages.
|
||||
let currentPage = { blocks: [] };
|
||||
let yCursor = 0;
|
||||
const pages = [currentPage];
|
||||
const pushPage = () => {
|
||||
currentPage = { blocks: [] };
|
||||
yCursor = 0;
|
||||
pages.push(currentPage);
|
||||
};
|
||||
|
||||
for (let i = 0; i < tokens.length; i++) {
|
||||
const block = blockForToken(tokens[i]);
|
||||
if (!block) continue;
|
||||
|
||||
// Empty page + block too tall to ever fit → record as
|
||||
// overflow and skip so we don't loop forever.
|
||||
if (block.blockH > frameH && currentPage.blocks.length === 0) {
|
||||
result.overflow.push(block);
|
||||
result.warnings.push(`Block too tall for the text-frame: ${block.kind}.`);
|
||||
continue;
|
||||
}
|
||||
// Page break: doesn't fit on current page → push a new page.
|
||||
if (yCursor + block.blockH > frameH && currentPage.blocks.length > 0) {
|
||||
pushPage();
|
||||
}
|
||||
// keepWithNext: if this block is a heading and there's a
|
||||
// next block, peek ahead — if the heading would land at
|
||||
// the very bottom (its bottom equals the frame), push it
|
||||
// to the next page so it stays with the following content.
|
||||
if (block.keepWithNext) {
|
||||
const next = (i + 1 < tokens.length) ? blockForToken(tokens[i + 1]) : null;
|
||||
if (next && yCursor + block.blockH + next.blockH > frameH
|
||||
&& currentPage.blocks.length > 0) {
|
||||
pushPage();
|
||||
}
|
||||
}
|
||||
block.y = yCursor + block.marginBefore;
|
||||
currentPage.blocks.push(block);
|
||||
yCursor += block.blockH;
|
||||
}
|
||||
|
||||
// Drop a trailing empty page if no blocks were laid out at all.
|
||||
if (pages.length > 1 && pages[pages.length - 1].blocks.length === 0) pages.pop();
|
||||
result.pages = pages;
|
||||
return result;
|
||||
}
|
||||
|
||||
// Chapter editor (Phase C.5). Reflow entries open a two-pane
|
||||
// textarea + live preview instead of the canvas. The book stays
|
||||
// in 'book' template mode; an `editingChapterId` field tracks
|
||||
@@ -8990,7 +9224,14 @@
|
||||
if (titleEl && titleEl !== document.activeElement) titleEl.value = entry.chapterTitle || '';
|
||||
if (ta && ta !== document.activeElement) ta.value = entry.chapterMarkdown || '';
|
||||
if (preview) preview.innerHTML = markdownToSafeHtml(entry.chapterMarkdown || '');
|
||||
if (pagesEl) pagesEl.textContent = 'Pagination preview lands in Phase C.5.2';
|
||||
if (pagesEl) {
|
||||
const pag = paginateChapter(entry, book);
|
||||
const n = pag.pages.length;
|
||||
const wordCount = (entry.chapterMarkdown || '').trim().split(/\s+/).filter(Boolean).length;
|
||||
const warn = pag.warnings.length ? ` · ⚠ ${pag.warnings[0]}` : '';
|
||||
pagesEl.textContent = `${wordCount} word${wordCount === 1 ? '' : 's'} · ${n} page${n === 1 ? '' : 's'}${warn}`;
|
||||
pagesEl.title = pag.warnings.join('\n');
|
||||
}
|
||||
}
|
||||
// Minimal HTML escape + marked render. Sanitizes by routing
|
||||
// through marked.parse with `mangle:false, headerIds:false`
|
||||
@@ -9021,10 +9262,20 @@
|
||||
if (!entry) return;
|
||||
pushHistoryDebounced(`book:chapter:${id}:md`, 'book: edit chapter', 600);
|
||||
entry.chapterMarkdown = String(value || '');
|
||||
// Only re-render preview (cheap); thumb / pagination will
|
||||
// re-run in C.5.2 once the engine exists.
|
||||
// Cheap path on every keystroke: just re-render the preview
|
||||
// HTML + the paginated count readout. Full state render()
|
||||
// catches up at the next debounced history snapshot.
|
||||
const preview = document.getElementById('bookChapterEditorPreview');
|
||||
if (preview) preview.innerHTML = markdownToSafeHtml(entry.chapterMarkdown);
|
||||
const pagesEl = document.getElementById('bookChapterEditorPages');
|
||||
if (pagesEl) {
|
||||
const pag = paginateChapter(entry, book);
|
||||
const n = pag.pages.length;
|
||||
const wordCount = entry.chapterMarkdown.trim().split(/\s+/).filter(Boolean).length;
|
||||
const warn = pag.warnings.length ? ` · ⚠ ${pag.warnings[0]}` : '';
|
||||
pagesEl.textContent = `${wordCount} word${wordCount === 1 ? '' : 's'} · ${n} page${n === 1 ? '' : 's'}${warn}`;
|
||||
pagesEl.title = pag.warnings.join('\n');
|
||||
}
|
||||
}
|
||||
function updateBookChapterTitle(value) {
|
||||
const book = state.book;
|
||||
|
||||
Reference in new issue
Block a user