Implement WebGL book spread flip groundwork

This commit is contained in:
2026-06-08 09:03:35 +02:00
parent c86a304364
commit 86b6fa0419
8 changed files with 652 additions and 27 deletions
+375 -15
View File
@@ -13,6 +13,7 @@ class BookPaginationModule extends BaseModule {
this.storyHistory = null;
this.metrics = null;
this.spreads = [];
this.pages = [];
this.currentSpreadIndex = 0;
this.refreshToken = 0;
this.latestBlockId = 0;
@@ -23,6 +24,20 @@ class BookPaginationModule extends BaseModule {
'refreshFromHistory',
'preparePendingBlock',
'buildSpreads',
'buildPages',
'buildSpreadsFromPages',
'createBlankPage',
'createTitlePage',
'ensurePage',
'nextContentPageNumber',
'advancePage',
'advanceToNextRightPage',
'shouldAdvanceBeforeTextLine',
'getLinesPerPage',
'layoutImageBlock',
'createImageRecord',
'persistPaginationMetrics',
'collectPaginationMetrics',
'layoutTextBlock',
'getDropCapText',
'extractDropCapText',
@@ -59,6 +74,10 @@ class BookPaginationModule extends BaseModule {
this.addEventListener(document, 'book-pagination:set-spread', (event) => {
this.setCurrentSpread(event.detail?.spreadIndex);
});
this.addEventListener(document, 'webgl-book:page-flip-near-end', (event) => {
const direction = Math.sign(Number(event.detail?.direction || 0));
if (direction !== 0) this.setCurrentSpread(this.currentSpreadIndex + direction);
});
this.reportProgress(100, 'Book pagination ready');
return true;
}
@@ -78,9 +97,11 @@ class BookPaginationModule extends BaseModule {
Number(detail.latestRenderedBlockId || detail.latestBlockId || this.storyHistory?.latestRenderedBlockId || (this.storyHistory?.nextBlockId || 1) - 1)
);
if (!gameId || latestBlockId <= 0 || typeof this.storyHistory?.getBlocksRange !== 'function') {
this.spreads = [];
this.pages = this.buildPages([]);
this.spreads = this.buildSpreadsFromPages(this.pages);
this.latestBlockId = 0;
this.latestRenderedBlockId = 0;
this.currentSpreadIndex = 0;
this.publish();
return;
}
@@ -92,7 +113,9 @@ class BookPaginationModule extends BaseModule {
0,
Number(detail.latestRenderedBlockId || this.storyHistory?.latestRenderedBlockId || 0)
);
this.spreads = this.buildSpreads(blocks);
this.pages = this.buildPages(blocks);
this.spreads = this.buildSpreadsFromPages(this.pages);
this.persistPaginationMetrics(this.pages);
this.currentSpreadIndex = Math.max(0, Math.min(this.currentSpreadIndex, Math.max(0, this.spreads.length - 1)));
this.publish();
}
@@ -126,7 +149,8 @@ class BookPaginationModule extends BaseModule {
gameId
}
};
const preparedSpreads = this.buildSpreads([...historyBlocks, normalizedBlock]);
const preparedPages = this.buildPages([...historyBlocks, normalizedBlock]);
const preparedSpreads = this.buildSpreadsFromPages(preparedPages);
const targetSpread = preparedSpreads.find(spread => ['left', 'right'].some(side => {
const lines = Array.isArray(spread?.[side]) ? spread[side] : [];
return lines.some(line => Number(line?.blockId || 0) === pendingBlockId);
@@ -134,6 +158,7 @@ class BookPaginationModule extends BaseModule {
if (options.activate !== false) {
this.latestBlockId = pendingBlockId;
this.latestRenderedBlockId = latestRenderedBlockId;
this.pages = preparedPages;
this.spreads = preparedSpreads;
this.currentSpreadIndex = Math.max(0, Math.min(this.spreads.length - 1, this.currentSpreadIndex));
if (targetSpread) this.currentSpreadIndex = targetSpread.index;
@@ -153,33 +178,74 @@ class BookPaginationModule extends BaseModule {
}
buildSpreads(blocks = []) {
const spreads = [];
let cursorLine = 0;
this.pages = this.buildPages(blocks);
return this.buildSpreadsFromPages(this.pages);
}
buildPages(blocks = []) {
const pages = [
this.createBlankPage(0, { section: 'frontmatter' }),
this.createTitlePage(1),
this.createBlankPage(2, { section: 'frontmatter' })
];
let pageIndex = 3;
let pageLine = 0;
let contentPageNumber = 1;
const source = Array.isArray(blocks) ? blocks : [];
const linesPerPage = this.getLinesPerPage();
source.forEach((block) => {
const type = block?.kind || block?.type || 'paragraph';
if (type === 'image') {
({ pageIndex, pageLine, contentPageNumber } = this.layoutImageBlock(
pages,
block,
pageIndex,
pageLine,
contentPageNumber,
linesPerPage
));
return;
}
if (!['paragraph', 'heading'].includes(type)) return;
const layout = this.layoutTextBlock(block, type);
if (!layout?.lines?.length) return;
let blockWordCursor = 0;
cursorLine += layout.topSpaceLines;
const isHeading = type === 'heading' || layout.role === 'chapter-heading' || layout.role === 'section-heading';
if (isHeading) {
({ pageIndex, pageLine, contentPageNumber } = this.advanceToNextRightPage(pages, pageIndex, pageLine, contentPageNumber));
} else if (pageLine + layout.topSpaceLines >= linesPerPage) {
({ pageIndex, pageLine, contentPageNumber } = this.advancePage(pages, pageIndex, contentPageNumber));
}
pageLine += layout.topSpaceLines;
layout.lines.forEach((line, layoutLineIndex) => {
const geometry = this.getLineGeometry(cursorLine);
const lineWordCount = this.countLineWords(line);
if (!spreads[geometry.spreadIndex]) {
spreads[geometry.spreadIndex] = { index: geometry.spreadIndex, left: [], right: [] };
if (this.shouldAdvanceBeforeTextLine({
line,
layout,
layoutLineIndex,
pageIndex,
pageLine,
linesPerPage
})) {
({ pageIndex, pageLine, contentPageNumber } = this.advancePage(pages, pageIndex, contentPageNumber));
}
spreads[geometry.spreadIndex][geometry.side].push({
const page = this.ensurePage(pages, pageIndex, {
pageNumber: contentPageNumber,
section: 'body'
});
const lineWordCount = this.countLineWords(line);
page.lines.push({
blockId: block.blockId ?? null,
turnId: block.turnId ?? block.metadata?.turnId ?? null,
role: layout.role,
text: block.text || '',
line,
lineIndex: cursorLine,
pageLine: geometry.pageLine,
lineIndex: page.index * linesPerPage + pageLine,
pageIndex: page.index,
pageNumber: page.pageNumber,
pageLine,
fontPx: layout.fontPx,
lineHeightPx: layout.lineHeightPx,
fontStyle: layout.fontStyle,
@@ -188,14 +254,308 @@ class BookPaginationModule extends BaseModule {
smallCaps: Boolean(layout.dropCap && layoutLineIndex === 0)
});
blockWordCursor += lineWordCount;
cursorLine += 1;
pageLine += 1;
});
cursorLine += layout.bottomSpaceLines;
pageLine += layout.bottomSpaceLines;
if (pageLine >= linesPerPage) {
({ pageIndex, pageLine, contentPageNumber } = this.advancePage(pages, pageIndex, contentPageNumber));
}
});
return pages;
}
buildSpreadsFromPages(pages = []) {
const spreads = [];
const linesPerPage = this.getLinesPerPage();
pages.forEach((page, pageIndex) => {
const spreadIndex = Math.floor(pageIndex / 2);
const side = pageIndex % 2 === 0 ? 'left' : 'right';
if (!spreads[spreadIndex]) {
spreads[spreadIndex] = {
index: spreadIndex,
left: [],
right: [],
pageMeta: {
left: null,
right: null
}
};
}
spreads[spreadIndex][side] = Array.isArray(page?.lines) ? page.lines : [];
spreads[spreadIndex].pageMeta[side] = {
kind: page?.kind || 'content',
pageIndex,
pageNumber: page?.pageNumber ?? null,
section: page?.section || 'body',
omitPageNumber: page?.omitPageNumber === true,
linesPerPage
};
});
return spreads.filter(Boolean);
}
createBlankPage(index = 0, options = {}) {
return {
index,
kind: 'blank',
section: options.section || 'body',
pageNumber: options.pageNumber ?? null,
omitPageNumber: true,
lines: []
};
}
createTitlePage(index = 1) {
return {
index,
kind: 'title',
section: 'frontmatter',
pageNumber: null,
omitPageNumber: true,
lines: []
};
}
ensurePage(pages, index, options = {}) {
if (!pages[index]) {
pages[index] = {
index,
kind: options.kind || 'content',
section: options.section || 'body',
pageNumber: options.pageNumber ?? null,
omitPageNumber: options.omitPageNumber === true,
lines: []
};
} else if (options.pageNumber != null && pages[index].pageNumber == null) {
pages[index].pageNumber = options.pageNumber;
pages[index].kind = pages[index].kind === 'blank' ? 'content' : pages[index].kind;
pages[index].section = options.section || pages[index].section;
}
return pages[index];
}
nextContentPageNumber(pages = []) {
return pages.reduce((max, page) => Math.max(max, Number(page?.pageNumber || 0)), 0) + 1;
}
advancePage(pages, pageIndex, contentPageNumber) {
const nextIndex = pageIndex + 1;
const pageNumber = Math.max(Number(contentPageNumber || 1), this.nextContentPageNumber(pages));
this.ensurePage(pages, nextIndex, {
kind: 'content',
section: 'body',
pageNumber,
omitPageNumber: false
});
return {
pageIndex: nextIndex,
pageLine: 0,
contentPageNumber: pageNumber + 1
};
}
advanceToNextRightPage(pages, pageIndex, pageLine, contentPageNumber) {
let nextIndex = pageIndex;
let nextLine = pageLine;
let nextPageNumber = contentPageNumber;
if (nextLine > 0) {
const advanced = this.advancePage(pages, nextIndex, nextPageNumber);
nextIndex = advanced.pageIndex;
nextLine = advanced.pageLine;
nextPageNumber = advanced.contentPageNumber;
}
if (nextIndex % 2 === 0) {
const blankNumber = Math.max(Number(nextPageNumber || 1), this.nextContentPageNumber(pages));
this.ensurePage(pages, nextIndex, {
kind: 'blank',
section: 'body',
pageNumber: blankNumber,
omitPageNumber: true
});
nextIndex += 1;
nextPageNumber = blankNumber + 1;
}
this.ensurePage(pages, nextIndex, {
kind: 'content',
section: 'body',
pageNumber: Math.max(Number(nextPageNumber || 1), this.nextContentPageNumber(pages)),
omitPageNumber: false
});
return {
pageIndex: nextIndex,
pageLine: 0,
contentPageNumber: this.nextContentPageNumber(pages)
};
}
shouldAdvanceBeforeTextLine({ line, layout, layoutLineIndex, pageIndex, pageLine, linesPerPage }) {
if (pageLine >= linesPerPage) return true;
const remainingPageLines = linesPerPage - pageLine;
const remainingBlockLines = Math.max(0, layout.lines.length - layoutLineIndex);
if (remainingPageLines === 1 && remainingBlockLines > 1) return true;
if (remainingBlockLines === 1 && pageLine === 0 && layout.lines.length > 1) return true;
if (pageIndex % 2 === 1 && pageLine === linesPerPage - 1 && line?.hyphenated) return true;
return false;
}
layoutImageBlock(pages, block, pageIndex, pageLine, contentPageNumber, linesPerPage) {
const metrics = this.metrics;
const content = metrics.contentBySide?.right || metrics.content || {};
const metadata = { ...(block?.metadata || {}), ...block };
const requestedSize = String(metadata.size || metadata.imageLayout?.size || 'landscape').toLowerCase();
const size = requestedSize === 'widescreen' ? 'landscape' : requestedSize;
const lineHeightPx = Math.max(1, Number(metrics.typographyLineHeightPx || 1));
const textAreaWidth = Math.max(1, Number(content.width || metrics.content?.width || 1));
const textAreaHeight = Math.max(1, Number(content.height || linesPerPage * lineHeightPx));
let imageLineCount = Math.max(1, Math.ceil(linesPerPage * 0.5));
let rect = null;
if (size === 'full') {
if (pageLine > 0) {
({ pageIndex, pageLine, contentPageNumber } = this.advancePage(pages, pageIndex, contentPageNumber));
}
rect = {
x: 0,
y: 0,
width: metrics.width,
height: metrics.height
};
imageLineCount = linesPerPage;
} else if (size === 'portrait') {
const aspect = 9 / 16;
const imageWidth = textAreaWidth * 0.5;
const imageHeight = imageWidth / aspect;
imageLineCount = Math.max(1, Math.ceil(imageHeight / lineHeightPx));
if (pageLine + imageLineCount > linesPerPage) {
({ pageIndex, pageLine, contentPageNumber } = this.advancePage(pages, pageIndex, contentPageNumber));
}
rect = {
x: textAreaWidth - imageWidth,
y: pageLine * lineHeightPx,
width: imageWidth,
height: Math.min(textAreaHeight - pageLine * lineHeightPx, imageHeight)
};
} else {
const bottomHalfStart = Math.ceil(linesPerPage * 0.5);
if (pageLine > bottomHalfStart) {
({ pageIndex, pageLine, contentPageNumber } = this.advancePage(pages, pageIndex, contentPageNumber));
} else {
pageLine = Math.max(pageLine, bottomHalfStart);
}
imageLineCount = linesPerPage - pageLine;
rect = {
x: 0,
y: pageLine * lineHeightPx,
width: textAreaWidth,
height: Math.max(lineHeightPx, imageLineCount * lineHeightPx)
};
}
const page = this.ensurePage(pages, pageIndex, {
pageNumber: contentPageNumber,
section: 'body'
});
page.lines.push(this.createImageRecord(block, page, pageLine, imageLineCount, rect, size));
pageLine += imageLineCount;
if (pageLine >= linesPerPage) {
({ pageIndex, pageLine, contentPageNumber } = this.advancePage(pages, pageIndex, contentPageNumber));
}
return { pageIndex, pageLine, contentPageNumber };
}
persistPaginationMetrics(pages = []) {
if (!this.storyHistory || typeof this.storyHistory.updateBlockMetrics !== 'function') return;
const metricsByBlock = this.collectPaginationMetrics(pages);
metricsByBlock.forEach((metrics, blockId) => {
this.storyHistory.updateBlockMetrics(blockId, metrics).catch(error => {
console.warn('BookPagination: Failed to persist pagination metrics', error);
});
});
}
collectPaginationMetrics(pages = []) {
const byBlock = new Map();
pages.forEach((page) => {
const lines = Array.isArray(page?.lines) ? page.lines : [];
lines.forEach((line) => {
const blockId = Number(line?.blockId || 0);
if (blockId <= 0) return;
const pageLineStart = Math.max(0, Number(line.pageLine || 0));
const pageLineEnd = pageLineStart + Math.max(1, Number(line.lineCount || 1)) - 1;
const lineStart = Math.max(0, Number(line.lineIndex || 0));
const lineCount = Math.max(1, Number(line.lineCount || 1));
const spreadIndex = Math.floor(Number(page.index || 0) / 2);
const current = byBlock.get(blockId);
if (!current) {
byBlock.set(blockId, {
lineStart,
lineCount,
pageStart: Number(page.index || 0),
pageEnd: Number(page.index || 0),
pageLineStart,
pageLineEnd,
spreadStart: spreadIndex,
spreadEnd: spreadIndex,
pagination: {
pages: [{
pageIndex: Number(page.index || 0),
pageNumber: page.pageNumber ?? null,
firstLine: pageLineStart,
lastLine: pageLineEnd
}]
}
});
return;
}
current.lineStart = Math.min(current.lineStart, lineStart);
current.lineCount = Math.max(current.lineStart + current.lineCount, lineStart + lineCount) - current.lineStart;
current.pageStart = Math.min(current.pageStart, Number(page.index || 0));
current.pageEnd = Math.max(current.pageEnd, Number(page.index || 0));
current.pageLineStart = Math.min(current.pageLineStart, pageLineStart);
current.pageLineEnd = Math.max(current.pageLineEnd, pageLineEnd);
current.spreadStart = Math.min(current.spreadStart, spreadIndex);
current.spreadEnd = Math.max(current.spreadEnd, spreadIndex);
current.pagination.pages.push({
pageIndex: Number(page.index || 0),
pageNumber: page.pageNumber ?? null,
firstLine: pageLineStart,
lastLine: pageLineEnd
});
});
});
return byBlock;
}
createImageRecord(block, page, pageLine, lineCount, rect, size) {
return {
type: 'image',
kind: 'image',
blockId: block.blockId ?? null,
turnId: block.turnId ?? block.metadata?.turnId ?? null,
pageIndex: page.index,
pageNumber: page.pageNumber,
pageLine,
lineIndex: page.index * this.getLinesPerPage() + pageLine,
lineCount,
metadata: {
...(block.metadata || {}),
...block,
imageLayout: {
...(block.metadata?.imageLayout || {}),
size,
textureRect: rect,
lineStart: pageLine,
lineCount
}
}
};
}
getLinesPerPage() {
return Math.max(1, Math.floor(this.metrics.content.height / this.metrics.typographyLineHeightPx || 1));
}
layoutTextBlock(block = {}, type = 'paragraph') {
const sourceText = String(block.layoutText || block.text || '').trim();
const dropCap = Boolean(block.dropCap || block.metadata?.dropCap);