From 2d7e757308025a196a9ddd9fa6a205ef12067beb Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Thu, 4 Jun 2026 20:28:35 -0400 Subject: [PATCH] =?UTF-8?q?feat(book):=20J4=20=E2=80=94=20thread-aware=20p?= =?UTF-8?q?agination,=20text=20flows=20through=20linked=20frames?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/index.html | 205 +++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 172 insertions(+), 33 deletions(-) diff --git a/src/index.html b/src/index.html index f7ac442..4596d9d 100644 --- a/src/index.html +++ b/src/index.html @@ -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); + } } } }