From f2252230fa08cc1f96fcce57622a01cdd06aeac2 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Tue, 2 Jun 2026 21:34:42 -0400 Subject: [PATCH] book(C.5.4): substitute {page-number}/{chapter-title}/etc tokens in master text layers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a master text layer renders on top of a specific page — in tile thumbnails or the canvas underlay during book-page edit — `{page-number}`, `{page-count}`, `{physical-page-count}`, `{book-title}`, `{book-author}`, `{chapter-title}` and `{chapter-number}` resolve against that page's place in the manifest. - `enumerateBookPhysicalPages` walks the manifest into a flat physical-page sequence, paginating reflow segments and threading chapter context. - `bookTokenContext` returns the context for a given physical page within a manifest entry (designed pages use indexInEntry=0). - `substituteBookTokens` does HTML-safe string replacement; values are escaped before insertion so a chapter title with `<` or `&` can't break the layer markup. - Tile thumbnails for both designed pages and reflow segments now run master layers through `applyBookTokensToLayers` before composing. - The canvas editor substitutes tokens on master text layers when they appear as the page-edit underlay (and disables contenteditable so the substituted text can't accidentally overwrite the master's source). Master-edit mode leaves tokens literal. - `bookPageCount` (spine-width input) now counts physical pages. Co-Authored-By: Claude Opus 4.7 --- src/index.html | 168 ++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 159 insertions(+), 9 deletions(-) diff --git a/src/index.html b/src/index.html index 76677d7..3d9001a 100644 --- a/src/index.html +++ b/src/index.html @@ -8302,11 +8302,17 @@ if (!state.book) state.book = defaultBookState(); return state.book; } - // Page count + derived spine width readouts for the sidebar. - // Page count is the manifest entry count for Phase A — once reflow - // segments land in C.5 this counts physical pages after pagination. + // Physical page count — manifest entries except reflow segments, + // which expand into however many pages their text paginates to. + // Drives spine width + `{physical-page-count}` substitution. function bookPageCount() { - return (state.book?.pages || []).length; + const book = state.book; + if (!book) return 0; + try { + const seq = enumerateBookPhysicalPages(book); + if (seq && seq.length) return seq.length; + } catch {} + return (book.pages || []).length; } function bookSpineWidthInches() { const spec = state.book?.spec; @@ -8793,13 +8799,17 @@ if (p.type === 'design') { // Designed page thumb: master layers underneath, then the // page's own. Both remapped to 'canvas' so the preview - // helper's flat-canvas filter accepts them. + // helper's flat-canvas filter accepts them. Master text + // layers run through token substitution so `{page-number}` + // et al resolve against this page's place in the manifest. const pageTarget = `book-page:${p.id}`; const masterId = effectiveMasterIdForPage(p); const masterTarget = masterId ? `book-master:${masterId}` : null; - const masterLayers = masterTarget + const masterLayersRaw = masterTarget ? (state.layers || []).filter(l => l.target === masterTarget && !l.hidden) : []; + const ctx = bookTokenContext(book, p.id, 0); + const masterLayers = applyBookTokensToLayers(masterLayersRaw, ctx); const pageLayers = (state.layers || []).filter(l => l.target === pageTarget && !l.hidden); const composed = [ ...masterLayers.map((l, i) => ({ ...l, target: 'canvas', z: -1e6 + i })), @@ -8820,15 +8830,19 @@ } else if (p.type === 'reflow') { // Reflow tile: same master underlay as designed pages, // then the first paginated page of the chapter rendered - // as positioned text blocks on top. + // as positioned text blocks on top. Tokens in master text + // layers resolve to the first physical page of this + // chapter segment. const masterId = p.masterId || book.spec?.defaultMasterId || null; const masterTarget = masterId ? `book-master:${masterId}` : null; - const masterLayers = masterTarget + const masterLayersRaw = masterTarget ? (state.layers || []).filter(l => l.target === masterTarget && !l.hidden // Don't render the text-frame outline in the thumb — // the reflowed prose takes its place visually. && !(l.type === 'shape' && l.shape?.kind === 'text-frame')) : []; + const ctx = bookTokenContext(book, p.id, 0); + const masterLayers = applyBookTokensToLayers(masterLayersRaw, ctx); const composed = masterLayers.map((l, i) => ({ ...l, target: 'canvas', z: -1e6 + i })); try { renderCustomArtPreviewDOM(thumb, { @@ -9288,6 +9302,124 @@ parent.appendChild(overlay); } + // Walk the manifest into a flat physical-page sequence, paginating + // reflow segments so each chapter contributes one entry per page. + // Used for token substitution + total-page-count readouts. + function enumerateBookPhysicalPages(book) { + const out = []; + if (!book) return out; + const numbering = book.spec?.pageNumbering || {}; + const enabled = numbering.enabled !== false; + const startAt = Number.isFinite(numbering.startAt) ? numbering.startAt : 1; + const firstNumberedIdx = (Number.isFinite(numbering.firstNumberedPage) ? numbering.firstNumberedPage : 1) - 1; + let printedNum = startAt; + let currentChapter = { title: null, number: null }; + (book.pages || []).forEach((p, manifestIdx) => { + const baseNumbered = enabled && !p.skipNumber && manifestIdx >= firstNumberedIdx; + if (p.type === 'reflow') { + currentChapter = { + title: p.chapterTitle || null, + number: p.chapterNumber != null ? p.chapterNumber : null, + }; + let physicalCount = 1; + try { + const pag = paginateChapter(p, book); + if (pag.pages.length) physicalCount = pag.pages.length; + } catch {} + for (let i = 0; i < physicalCount; i++) { + const skipThisOne = baseNumbered && p.skipNumberOnFirstPage && i === 0; + const numberedHere = baseNumbered && !skipThisOne; + out.push({ + entryId: p.id, + manifestIdx, + indexInEntry: i, + type: 'reflow', + masterId: p.masterId || book.spec?.defaultMasterId || null, + pageNumber: numberedHere ? printedNum : null, + chapterTitle: currentChapter.title, + chapterNumber: currentChapter.number, + }); + if (numberedHere) printedNum++; + } + } else { + out.push({ + entryId: p.id, + manifestIdx, + indexInEntry: 0, + type: p.type, + masterId: effectiveMasterIdForPage(p), + pageNumber: baseNumbered ? printedNum : null, + chapterTitle: currentChapter.title, + chapterNumber: currentChapter.number, + }); + if (baseNumbered) printedNum++; + } + }); + return out; + } + + // Build the substitution context for a given physical page within + // a manifest entry. `indexInEntry` is 0 for designed pages and the + // physical offset (0-based) within a reflow segment. + function bookTokenContext(book, entryId, indexInEntry = 0) { + const seq = enumerateBookPhysicalPages(book); + const totalPhysical = seq.length; + let totalNumbered = 0; + for (const s of seq) if (s.pageNumber != null) totalNumbered++; + const match = seq.find(s => s.entryId === entryId && s.indexInEntry === indexInEntry) || null; + let bookAuthor = ''; + try { + const pk = state.community?.myPubkey || ''; + if (pk) { + const cached = (typeof _profilePages !== 'undefined' && _profilePages && _profilePages.get) + ? _profilePages.get(pk)?.profile : null; + bookAuthor = cached + ? bestDisplayName(cached, pk) + : (typeof shortNpub === 'function' ? shortNpub(pk) : ''); + } + } catch {} + return { + pageNumber: match?.pageNumber ?? null, + pageCount: totalNumbered, + physicalPageCount: totalPhysical, + bookTitle: state.meta?.title || '', + bookAuthor, + chapterTitle: match?.chapterTitle || '', + chapterNumber: match?.chapterNumber ?? null, + }; + } + // Token substitution for master-layer HTML. Tokens are + // case-sensitive and escaped before insertion so a chapter + // title with `<` or `&` can't break the layer's HTML. + function substituteBookTokens(html, ctx) { + if (!html || !ctx || typeof html !== 'string') return html; + if (html.indexOf('{') === -1) return html; + const esc = (v) => (typeof escapeHtml === 'function' ? escapeHtml(String(v)) : String(v)); + return html.replace(/\{(page-number|page-count|physical-page-count|book-title|book-author|chapter-title|chapter-number)\}/g, (whole, key) => { + switch (key) { + case 'page-number': return ctx.pageNumber != null ? esc(ctx.pageNumber) : ''; + case 'page-count': return esc(ctx.pageCount); + case 'physical-page-count': return esc(ctx.physicalPageCount); + case 'book-title': return esc(ctx.bookTitle || ''); + case 'book-author': return esc(ctx.bookAuthor || ''); + case 'chapter-title': return esc(ctx.chapterTitle || ''); + case 'chapter-number': return ctx.chapterNumber != null ? esc(ctx.chapterNumber) : ''; + default: return whole; + } + }); + } + // Map a list of master layers into preview-ready clones, replacing + // tokens in any text layer's `html` field. Other layer types pass + // through unchanged. + function applyBookTokensToLayers(layers, ctx) { + if (!layers || !layers.length || !ctx) return layers || []; + return layers.map(l => { + if (!l || typeof l.html !== 'string') return l; + if (l.html.indexOf('{') === -1) return l; + return { ...l, html: substituteBookTokens(l.html, ctx) }; + }); + } + // 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 @@ -18854,7 +18986,25 @@ text.className = 'textLayer'; text.contentEditable = 'true'; text.spellcheck = false; - text.innerHTML = layer.html || ''; + // When a master text layer renders as the underlay on a + // book page we're editing, resolve `{page-number}` etc. + // against this page's manifest context. Master-edit mode + // leaves tokens literal so the author can type them. + let renderedHtml = layer.html || ''; + const _bookMasterUnderlay = state.templateMode === 'book' + && !!state.book?.editingEntryId + && !state.book?.editingMasterId + && String(layer.target || '').startsWith('book-master:'); + if (_bookMasterUnderlay) { + try { + const _ctx = bookTokenContext(state.book, state.book.editingEntryId, 0); + renderedHtml = substituteBookTokens(renderedHtml, _ctx); + } catch (err) { + console.warn('book token substitution failed', err); + } + text.contentEditable = 'false'; + } + text.innerHTML = renderedHtml; // Same scrub as applyPreviewLayerTextStyle: strip / unwrap any // with an unsafe href so a forked design can't carry an // active javascript: link into the editor.