Implement WebGL book spread flip groundwork
This commit is contained in:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user