mirror of
https://github.com/PR0M3TH3AN/Artstr-Studio.git
synced 2026-11-03 12:10:39 +00:00
book(C.5.4): substitute {page-number}/{chapter-title}/etc tokens in master text layers
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 <[email protected]>
This commit is contained in:
1 parent
d500bcaf83
commit
f2252230fa
1 file changed
+159
-9
+159
-9
@@ -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
|
||||
// <a> with an unsafe href so a forked design can't carry an
|
||||
// active javascript: link into the editor.
|
||||
|
||||
Reference in new issue
Block a user