mirror of
https://github.com/PR0M3TH3AN/Artstr-Studio.git
synced 2026-10-27 08:40:39 +00:00
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:
1 parent
83d451dfe4
commit
2d7e757308
1 file changed
+150
-11
+150
-11
@@ -10767,6 +10767,25 @@
|
||||
: (entry.masterId || book?.spec?.defaultMasterId || null);
|
||||
result.masterId = masterId;
|
||||
|
||||
// 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) {
|
||||
@@ -10785,9 +10804,9 @@
|
||||
: '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);
|
||||
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,
|
||||
@@ -10795,6 +10814,7 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user