feat(book): J4 — thread-aware pagination, text flows through linked frames

Hooks up the threading wiring so chapter text actually pours through
the frame chain you authored in J3 + J5.

What changed:

- paginateChapter now accepts opts.frameOverride = { x, y, w, h,
  padding }. When provided, it skips the master-frame lookup and
  uses the override directly. Existing callers (master-driven
  pagination) keep working unchanged because the override is opt-in.
- New _paginateChapterIntoThread(headLayer, book) — builds a
  synthetic call to paginateChapter using the head frame's geometry,
  returns the pages + the resolved chain.
- New _paintFrameBlocks(el, page, frameW, frameH, padding) — paints
  one paginated page directly into a freeLayer wrapper. Like
  paintReflowPageOverlay but coordinates are frame-local instead of
  page-local.
- In _attachBookFramePorts: when a chip is being shown (chapter
  placed on the thread's head), paginate + paint the slice for THIS
  frame. If the chapter overruns the chain, the last frame gets a
  red ⊞ OVERSET badge. If the chapter is shorter than the chain,
  extra frames get a muted '(thread continues — chapter ended
  upstream)' placeholder.

You can now: thread frames, place a chapter on the head, and the
text actually flows across the chain. Real text, real wrapping,
real headings + lists + code blocks — same layout engine the
reflow-entry path has always used.

Limitation captured for J4.b: every frame in a thread shares the
head's geometry. Variable-geometry threads (e.g. front-matter frame
narrower than body frames) need the engine to walk tokens per-frame
instead of all at once. Today the line breaks are computed once at
head geometry and re-used.

PDF export, Reader Mode, and the per-tile thumbnail painter still
walk book.pages reflow entries the old way — they don't see threaded
frames yet. That's the J4.c cleanup once the engine settles.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
This commit is contained in:
thePR0M3TH3ANandClaude Opus 4.7 committed 2026-06-04 20:28:35 -04:00
1 parent 83d451dfe4
commit 2d7e757308
1 file changed
+172 -33
+172 -33
View File
@@ -10767,34 +10767,54 @@
: (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;
// J4: `opts.frameOverride` lets the thread paginator skip the
// master-frame lookup and pour text into whatever frame
// geometry the caller specifies. Shape: { x, y, w, h, padding }
// in canvas-inch space. Used by threaded text frames whose
// geometry is independent of any master.
let padding, frameW, frameH;
if (opts.frameOverride) {
const fo = opts.frameOverride;
padding = Number(fo.padding) || 0;
frameW = Math.max(0.5, (Number(fo.w) || 0) - 2 * padding);
frameH = Math.max(0.5, (Number(fo.h) || 0) - 2 * padding);
result.frame = {
x: Number(fo.x) || 0,
y: Number(fo.y) || 0,
w: Number(fo.w) || 0,
h: Number(fo.h) || 0,
padding,
};
} else {
// 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;
}
padding = Number(frame.shape?.padding) || 0;
frameW = Math.max(0.5, (Number(frame.w) || 0) - 2 * padding);
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,
};
}
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, { gfm: true, breaks: false });
@@ -22743,6 +22763,91 @@
layer.nextFrame = null;
render();
}
// J4 — paginate a chapter's markdown into a thread's frame
// sequence. Builds a synthetic reflow entry from the head layer's
// sourceChapter and calls paginateChapter with the head's
// geometry as the override. Returns the pages array (one entry
// per frame, in chain order) plus any overflow.
//
// Limitation: v1 assumes every frame in the chain shares the head
// frame's geometry. Variable-geometry threads are J4.b territory
// (the engine would need to re-flow line breaks per frame, which
// means walking tokens frame-by-frame instead of all at once).
function _paginateChapterIntoThread(headLayer, book) {
if (!headLayer?.sourceChapter || !book) return { pages: [], chain: [headLayer] };
const sourceEntry = (book.pages || []).find(p => p.id === headLayer.sourceChapter);
if (!sourceEntry || sourceEntry.type !== 'reflow') return { pages: [], chain: [headLayer] };
const padding = Number(headLayer.shape?.padding) || 0;
const result = paginateChapter(sourceEntry, book, {
frameOverride: {
x: Number(headLayer.x) || 0,
y: Number(headLayer.y) || 0,
w: Number(headLayer.w) || 0,
h: Number(headLayer.h) || 0,
padding,
},
});
return {
pages: result.pages || [],
chain: _bookCollectThreadChain(headLayer),
overflow: result.overflow || [],
warnings: result.warnings || [],
};
}
// J4 — paint one paginated page's blocks directly into a
// freeLayer wrapper (the rendered text frame's own element).
// Coordinates are relative to the frame's own bounding box, NOT
// the whole page like paintReflowPageOverlay. Simpler — no
// frame.x/y translation needed.
function _paintFrameBlocks(el, page, frameWIn, frameHIn, paddingIn) {
if (!page || !page.blocks?.length) return;
const padding = Number(paddingIn) || 0;
const innerW = Math.max(0.1, frameWIn - 2 * padding);
const innerH = Math.max(0.1, frameHIn - 2 * padding);
const overlay = document.createElement('div');
const padPct = (padding / Math.max(frameWIn, 0.001)) * 100;
const padPctY = (padding / Math.max(frameHIn, 0.001)) * 100;
overlay.style.cssText = [
'position:absolute',
`left:${padPct}%`,
`top:${padPctY}%`,
`width:${100 - 2 * padPct}%`,
`height:${100 - 2 * padPctY}%`,
'pointer-events:none',
'overflow:hidden',
'z-index:6',
].join(';');
// Scale = pixels per inch in the rendered frame. Use the
// freeLayer's actual width if available, else fall back to a
// 96 dpi assumption.
const pxW = el.offsetWidth || (frameWIn * 96);
const scale = pxW / (frameWIn * 96);
for (const block of page.blocks) {
const style = block.style || {};
const fs = Number(style.fontSize) || 11;
const blk = document.createElement('div');
blk.style.cssText = [
'position:absolute',
`left:${((Number(style.marginLeft) || 0) / innerW) * 100}%`,
`right:${((Number(style.marginRight) || 0) / innerW) * 100}%`,
`top:${(block.y / innerH) * 100}%`,
`font-family:${style.fontFamily || 'Georgia, serif'}`,
`font-size:${fs * scale}px`,
`font-weight:${style.fontWeight || 400}`,
`font-style:${style.fontStyle || 'normal'}`,
`line-height:${style.lineHeight || 1.5}`,
`color:${style.color || '#111111'}`,
`text-align:${style.alignment || 'left'}`,
style.background ? `background:${style.background}` : '',
'white-space:pre-wrap',
].filter(Boolean).join(';');
blk.textContent = (block.lines || []).join('\n');
overlay.appendChild(blk);
}
el.appendChild(overlay);
}
// J5 — return the user's currently selected layer if it's a
// text-frame in book mode, else null. Used by the Chapters tab
// 'Place into selected frame' affordance.
@@ -22946,14 +23051,48 @@
}
el.appendChild(chip);
// Placeholder body — real pagination + line layout into
// this frame's bounding box lands in J4.
const pending = document.createElement('div');
pending.className = 'bookFrameSourcePending';
pending.textContent = chain.length > 1
? `(text flows through frames ${myIndex + 1} / ${chain.length} — J4 pours it)`
: '(text will flow here — J4 pours it)';
el.appendChild(pending);
// J4 — paginate the chapter into the thread's frame
// sequence and paint THIS frame's slice. Re-runs on every
// render; perf-sensitive book renders can add a cache
// keyed by (head.id, sourceChapter id, frame dimensions,
// chapterMarkdown length) in a later pass.
try {
const paginated = _paginateChapterIntoThread(head, state.book);
const pageForThisFrame = paginated.pages[myIndex];
if (pageForThisFrame && pageForThisFrame.blocks?.length) {
const padding = Number(layer.shape?.padding) || 0;
_paintFrameBlocks(el, pageForThisFrame, Number(layer.w) || 0, Number(layer.h) || 0, padding);
// Last frame in the chain with text still left over →
// mark it overset so the user knows to add more frames.
const isLast = myIndex === paginated.pages.length - 1
&& paginated.pages.length === chain.length;
const hasMore = paginated.pages.length > chain.length
|| (paginated.overflow && paginated.overflow.length);
if (myIndex === chain.length - 1 && hasMore) {
const overset = document.createElement('div');
overset.style.cssText = 'position:absolute;bottom:6px;right:6px;background:#fee2e2;color:#991b1b;border:1px solid #fca5a5;border-radius:3px;padding:1px 6px;font-size:.62rem;font-weight:700;pointer-events:none;z-index:9;letter-spacing:.05em';
overset.textContent = '⊞ OVERSET';
overset.title = `Text wants ${paginated.pages.length} frames but the thread has ${chain.length}. Add more frames + extend the thread.`;
el.appendChild(overset);
}
} else if (!pageForThisFrame) {
// No content for this frame slot — chapter ran out of
// text upstream. Light placeholder so the user knows
// the frame is part of the thread but unused.
const empty = document.createElement('div');
empty.className = 'bookFrameSourcePending';
empty.style.color = 'rgba(71,85,105,.5)';
empty.textContent = '(thread continues — but the chapter ended on an earlier frame)';
el.appendChild(empty);
}
} catch (err) {
console.warn('thread paginate failed', err);
const errEl = document.createElement('div');
errEl.className = 'bookFrameSourcePending';
errEl.style.color = '#b91c1c';
errEl.textContent = `(pagination error: ${err.message || 'unknown'})`;
el.appendChild(errEl);
}
}
}
}