Implement WebGL book spread flip groundwork
This commit is contained in:
@@ -13,6 +13,7 @@ class BookPaginationModule extends BaseModule {
|
|||||||
this.storyHistory = null;
|
this.storyHistory = null;
|
||||||
this.metrics = null;
|
this.metrics = null;
|
||||||
this.spreads = [];
|
this.spreads = [];
|
||||||
|
this.pages = [];
|
||||||
this.currentSpreadIndex = 0;
|
this.currentSpreadIndex = 0;
|
||||||
this.refreshToken = 0;
|
this.refreshToken = 0;
|
||||||
this.latestBlockId = 0;
|
this.latestBlockId = 0;
|
||||||
@@ -23,6 +24,20 @@ class BookPaginationModule extends BaseModule {
|
|||||||
'refreshFromHistory',
|
'refreshFromHistory',
|
||||||
'preparePendingBlock',
|
'preparePendingBlock',
|
||||||
'buildSpreads',
|
'buildSpreads',
|
||||||
|
'buildPages',
|
||||||
|
'buildSpreadsFromPages',
|
||||||
|
'createBlankPage',
|
||||||
|
'createTitlePage',
|
||||||
|
'ensurePage',
|
||||||
|
'nextContentPageNumber',
|
||||||
|
'advancePage',
|
||||||
|
'advanceToNextRightPage',
|
||||||
|
'shouldAdvanceBeforeTextLine',
|
||||||
|
'getLinesPerPage',
|
||||||
|
'layoutImageBlock',
|
||||||
|
'createImageRecord',
|
||||||
|
'persistPaginationMetrics',
|
||||||
|
'collectPaginationMetrics',
|
||||||
'layoutTextBlock',
|
'layoutTextBlock',
|
||||||
'getDropCapText',
|
'getDropCapText',
|
||||||
'extractDropCapText',
|
'extractDropCapText',
|
||||||
@@ -59,6 +74,10 @@ class BookPaginationModule extends BaseModule {
|
|||||||
this.addEventListener(document, 'book-pagination:set-spread', (event) => {
|
this.addEventListener(document, 'book-pagination:set-spread', (event) => {
|
||||||
this.setCurrentSpread(event.detail?.spreadIndex);
|
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');
|
this.reportProgress(100, 'Book pagination ready');
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -78,9 +97,11 @@ class BookPaginationModule extends BaseModule {
|
|||||||
Number(detail.latestRenderedBlockId || detail.latestBlockId || this.storyHistory?.latestRenderedBlockId || (this.storyHistory?.nextBlockId || 1) - 1)
|
Number(detail.latestRenderedBlockId || detail.latestBlockId || this.storyHistory?.latestRenderedBlockId || (this.storyHistory?.nextBlockId || 1) - 1)
|
||||||
);
|
);
|
||||||
if (!gameId || latestBlockId <= 0 || typeof this.storyHistory?.getBlocksRange !== 'function') {
|
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.latestBlockId = 0;
|
||||||
this.latestRenderedBlockId = 0;
|
this.latestRenderedBlockId = 0;
|
||||||
|
this.currentSpreadIndex = 0;
|
||||||
this.publish();
|
this.publish();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -92,7 +113,9 @@ class BookPaginationModule extends BaseModule {
|
|||||||
0,
|
0,
|
||||||
Number(detail.latestRenderedBlockId || this.storyHistory?.latestRenderedBlockId || 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.currentSpreadIndex = Math.max(0, Math.min(this.currentSpreadIndex, Math.max(0, this.spreads.length - 1)));
|
||||||
this.publish();
|
this.publish();
|
||||||
}
|
}
|
||||||
@@ -126,7 +149,8 @@ class BookPaginationModule extends BaseModule {
|
|||||||
gameId
|
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 targetSpread = preparedSpreads.find(spread => ['left', 'right'].some(side => {
|
||||||
const lines = Array.isArray(spread?.[side]) ? spread[side] : [];
|
const lines = Array.isArray(spread?.[side]) ? spread[side] : [];
|
||||||
return lines.some(line => Number(line?.blockId || 0) === pendingBlockId);
|
return lines.some(line => Number(line?.blockId || 0) === pendingBlockId);
|
||||||
@@ -134,6 +158,7 @@ class BookPaginationModule extends BaseModule {
|
|||||||
if (options.activate !== false) {
|
if (options.activate !== false) {
|
||||||
this.latestBlockId = pendingBlockId;
|
this.latestBlockId = pendingBlockId;
|
||||||
this.latestRenderedBlockId = latestRenderedBlockId;
|
this.latestRenderedBlockId = latestRenderedBlockId;
|
||||||
|
this.pages = preparedPages;
|
||||||
this.spreads = preparedSpreads;
|
this.spreads = preparedSpreads;
|
||||||
this.currentSpreadIndex = Math.max(0, Math.min(this.spreads.length - 1, this.currentSpreadIndex));
|
this.currentSpreadIndex = Math.max(0, Math.min(this.spreads.length - 1, this.currentSpreadIndex));
|
||||||
if (targetSpread) this.currentSpreadIndex = targetSpread.index;
|
if (targetSpread) this.currentSpreadIndex = targetSpread.index;
|
||||||
@@ -153,33 +178,74 @@ class BookPaginationModule extends BaseModule {
|
|||||||
}
|
}
|
||||||
|
|
||||||
buildSpreads(blocks = []) {
|
buildSpreads(blocks = []) {
|
||||||
const spreads = [];
|
this.pages = this.buildPages(blocks);
|
||||||
let cursorLine = 0;
|
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 source = Array.isArray(blocks) ? blocks : [];
|
||||||
|
const linesPerPage = this.getLinesPerPage();
|
||||||
|
|
||||||
source.forEach((block) => {
|
source.forEach((block) => {
|
||||||
const type = block?.kind || block?.type || 'paragraph';
|
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;
|
if (!['paragraph', 'heading'].includes(type)) return;
|
||||||
|
|
||||||
const layout = this.layoutTextBlock(block, type);
|
const layout = this.layoutTextBlock(block, type);
|
||||||
if (!layout?.lines?.length) return;
|
if (!layout?.lines?.length) return;
|
||||||
let blockWordCursor = 0;
|
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) => {
|
layout.lines.forEach((line, layoutLineIndex) => {
|
||||||
const geometry = this.getLineGeometry(cursorLine);
|
if (this.shouldAdvanceBeforeTextLine({
|
||||||
const lineWordCount = this.countLineWords(line);
|
line,
|
||||||
if (!spreads[geometry.spreadIndex]) {
|
layout,
|
||||||
spreads[geometry.spreadIndex] = { index: geometry.spreadIndex, left: [], right: [] };
|
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,
|
blockId: block.blockId ?? null,
|
||||||
turnId: block.turnId ?? block.metadata?.turnId ?? null,
|
turnId: block.turnId ?? block.metadata?.turnId ?? null,
|
||||||
role: layout.role,
|
role: layout.role,
|
||||||
text: block.text || '',
|
text: block.text || '',
|
||||||
line,
|
line,
|
||||||
lineIndex: cursorLine,
|
lineIndex: page.index * linesPerPage + pageLine,
|
||||||
pageLine: geometry.pageLine,
|
pageIndex: page.index,
|
||||||
|
pageNumber: page.pageNumber,
|
||||||
|
pageLine,
|
||||||
fontPx: layout.fontPx,
|
fontPx: layout.fontPx,
|
||||||
lineHeightPx: layout.lineHeightPx,
|
lineHeightPx: layout.lineHeightPx,
|
||||||
fontStyle: layout.fontStyle,
|
fontStyle: layout.fontStyle,
|
||||||
@@ -188,14 +254,308 @@ class BookPaginationModule extends BaseModule {
|
|||||||
smallCaps: Boolean(layout.dropCap && layoutLineIndex === 0)
|
smallCaps: Boolean(layout.dropCap && layoutLineIndex === 0)
|
||||||
});
|
});
|
||||||
blockWordCursor += lineWordCount;
|
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);
|
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') {
|
layoutTextBlock(block = {}, type = 'paragraph') {
|
||||||
const sourceText = String(block.layoutText || block.text || '').trim();
|
const sourceText = String(block.layoutText || block.text || '').trim();
|
||||||
const dropCap = Boolean(block.dropCap || block.metadata?.dropCap);
|
const dropCap = Boolean(block.dropCap || block.metadata?.dropCap);
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ class BookTextureRendererModule extends BaseModule {
|
|||||||
this.lastAnimationFrameAt = 0;
|
this.lastAnimationFrameAt = 0;
|
||||||
this.targetFrameDurationMs = 1000 / 30;
|
this.targetFrameDurationMs = 1000 / 30;
|
||||||
this.pipelineTimings = [];
|
this.pipelineTimings = [];
|
||||||
|
this.imageCache = new Map();
|
||||||
|
|
||||||
this.bindMethods([
|
this.bindMethods([
|
||||||
'initialize',
|
'initialize',
|
||||||
@@ -50,7 +51,14 @@ class BookTextureRendererModule extends BaseModule {
|
|||||||
'getDrawSignature',
|
'getDrawSignature',
|
||||||
'cloneCanvas',
|
'cloneCanvas',
|
||||||
'drawPageBase',
|
'drawPageBase',
|
||||||
|
'drawPageMeta',
|
||||||
|
'drawTitlePage',
|
||||||
|
'drawPageNumber',
|
||||||
'drawPageLines',
|
'drawPageLines',
|
||||||
|
'drawImageRecord',
|
||||||
|
'resolveImageSource',
|
||||||
|
'getCachedImage',
|
||||||
|
'drawImageFitted',
|
||||||
'drawLine',
|
'drawLine',
|
||||||
'drawWord',
|
'drawWord',
|
||||||
'recordRevealRect',
|
'recordRevealRect',
|
||||||
@@ -194,7 +202,9 @@ class BookTextureRendererModule extends BaseModule {
|
|||||||
if (!this.canvases[side]) return;
|
if (!this.canvases[side]) return;
|
||||||
this.drawPageBase(side);
|
this.drawPageBase(side);
|
||||||
if (hasReveal) this.revealBaseCanvases[side] = this.cloneCanvas(this.canvases[side]);
|
if (hasReveal) this.revealBaseCanvases[side] = this.cloneCanvas(this.canvases[side]);
|
||||||
|
this.drawPageMeta(side, 'before-lines');
|
||||||
this.drawPageLines(side, this.currentSpread?.[side] || []);
|
this.drawPageLines(side, this.currentSpread?.[side] || []);
|
||||||
|
this.drawPageMeta(side, 'after-lines');
|
||||||
});
|
});
|
||||||
const published = this.publishSpread(sidesToDraw, options);
|
const published = this.publishSpread(sidesToDraw, options);
|
||||||
this.markPipelineTiming('drawSpread:end', {
|
this.markPipelineTiming('drawSpread:end', {
|
||||||
@@ -214,8 +224,9 @@ class BookTextureRendererModule extends BaseModule {
|
|||||||
const source = spread || {};
|
const source = spread || {};
|
||||||
return sides.map(side => {
|
return sides.map(side => {
|
||||||
const lines = Array.isArray(source[side]) ? source[side] : [];
|
const lines = Array.isArray(source[side]) ? source[side] : [];
|
||||||
const ids = lines.map(line => `${line.blockId ?? ''}:${line.lineIndex ?? ''}:${line.pageLine ?? ''}:${line.line?.nodes?.length || 0}`).join(',');
|
const meta = source.pageMeta?.[side] || {};
|
||||||
return `${side}[${ids}]`;
|
const ids = lines.map(line => `${line.type || 'line'}:${line.blockId ?? ''}:${line.lineIndex ?? ''}:${line.pageLine ?? ''}:${line.lineCount ?? ''}:${line.line?.nodes?.length || 0}`).join(',');
|
||||||
|
return `${side}:${meta.kind || ''}:${meta.pageIndex ?? ''}:${meta.pageNumber ?? ''}:${meta.omitPageNumber === true}[${ids}]`;
|
||||||
}).join('|');
|
}).join('|');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -254,6 +265,69 @@ class BookTextureRendererModule extends BaseModule {
|
|||||||
this.hitMaps[side] = [];
|
this.hitMaps[side] = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
drawPageMeta(side, phase = 'after-lines') {
|
||||||
|
const meta = this.currentSpread?.pageMeta?.[side] || null;
|
||||||
|
if (!meta) return;
|
||||||
|
if (phase === 'before-lines' && meta.kind === 'title') this.drawTitlePage(side);
|
||||||
|
if (phase === 'after-lines') this.drawPageNumber(side, meta);
|
||||||
|
}
|
||||||
|
|
||||||
|
drawTitlePage(side) {
|
||||||
|
const ctx = this.contexts[side];
|
||||||
|
if (!ctx || !this.metrics) return;
|
||||||
|
const content = this.getPageContent(side);
|
||||||
|
const titleText = document.getElementById('game_title')?.textContent?.trim() || '';
|
||||||
|
const authorText = document.getElementById('game_author')?.textContent?.trim() || '';
|
||||||
|
const subtitleText = document.getElementById('game_subtitle')?.textContent?.trim() || '';
|
||||||
|
const ornamentText = document.querySelector('#start_prompt .separator, #start_prompt .ornament, #start_prompt [class*="separator"]')?.textContent?.trim() || '';
|
||||||
|
const legalText = document.getElementById('game_legal_text')?.textContent?.trim() || '';
|
||||||
|
const centerX = content.x + content.width * 0.5;
|
||||||
|
const font = this.metrics.typography.fontFamily;
|
||||||
|
|
||||||
|
ctx.save();
|
||||||
|
ctx.fillStyle = 'rgba(31, 19, 10, 0.9)';
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.textBaseline = 'middle';
|
||||||
|
if ('fontKerning' in ctx) ctx.fontKerning = 'normal';
|
||||||
|
if (authorText) {
|
||||||
|
ctx.font = `italic ${Math.round(this.metrics.bodyFontSizePx * 0.86)}px ${font}`;
|
||||||
|
ctx.fillText(authorText, centerX, content.y + content.height * 0.18);
|
||||||
|
}
|
||||||
|
if (titleText) {
|
||||||
|
ctx.font = `${Math.round(this.metrics.bodyFontSizePx * 1.55)}px ${font}`;
|
||||||
|
if ('fontVariantCaps' in ctx) ctx.fontVariantCaps = 'small-caps';
|
||||||
|
ctx.fillText(titleText, centerX, content.y + content.height * 0.28);
|
||||||
|
if ('fontVariantCaps' in ctx) ctx.fontVariantCaps = 'normal';
|
||||||
|
}
|
||||||
|
if (subtitleText) {
|
||||||
|
ctx.font = `${Math.round(this.metrics.bodyFontSizePx * 0.94)}px ${font}`;
|
||||||
|
ctx.fillText(subtitleText, centerX, content.y + content.height * 0.39);
|
||||||
|
}
|
||||||
|
if (ornamentText) {
|
||||||
|
ctx.font = `${Math.round(this.metrics.bodyFontSizePx * 1.3)}px ${font}`;
|
||||||
|
ctx.fillText(ornamentText, centerX, content.y + content.height * 0.52);
|
||||||
|
}
|
||||||
|
if (legalText) {
|
||||||
|
ctx.font = `${Math.round(this.metrics.bodyFontSizePx * 0.62)}px ${font}`;
|
||||||
|
ctx.fillText(legalText, centerX, content.y + content.height * 0.96);
|
||||||
|
}
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
drawPageNumber(side, meta = {}) {
|
||||||
|
if (meta.omitPageNumber || meta.pageNumber == null) return;
|
||||||
|
const ctx = this.contexts[side];
|
||||||
|
if (!ctx || !this.metrics) return;
|
||||||
|
const content = this.getPageContent(side);
|
||||||
|
ctx.save();
|
||||||
|
ctx.fillStyle = 'rgba(31, 19, 10, 0.74)';
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.textBaseline = 'middle';
|
||||||
|
ctx.font = `${Math.round(this.metrics.bodyFontSizePx * 0.68)}px ${this.metrics.typography.fontFamily}`;
|
||||||
|
ctx.fillText(String(meta.pageNumber), content.x + content.width * 0.5, content.y + content.height + this.metrics.margin.bottom * 0.48);
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
|
||||||
drawPageLines(side, lines = []) {
|
drawPageLines(side, lines = []) {
|
||||||
const ctx = this.contexts[side];
|
const ctx = this.contexts[side];
|
||||||
if (!ctx || !this.metrics || !Array.isArray(lines)) return;
|
if (!ctx || !this.metrics || !Array.isArray(lines)) return;
|
||||||
@@ -263,10 +337,73 @@ class BookTextureRendererModule extends BaseModule {
|
|||||||
ctx.textBaseline = 'alphabetic';
|
ctx.textBaseline = 'alphabetic';
|
||||||
if ('fontKerning' in ctx) ctx.fontKerning = 'normal';
|
if ('fontKerning' in ctx) ctx.fontKerning = 'normal';
|
||||||
if ('fontVariantCaps' in ctx) ctx.fontVariantCaps = 'normal';
|
if ('fontVariantCaps' in ctx) ctx.fontVariantCaps = 'normal';
|
||||||
lines.forEach(line => this.drawLine(ctx, line, side));
|
lines.forEach(line => {
|
||||||
|
if (line?.type === 'image' || line?.kind === 'image') this.drawImageRecord(ctx, line, side);
|
||||||
|
else this.drawLine(ctx, line, side);
|
||||||
|
});
|
||||||
ctx.restore();
|
ctx.restore();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
drawImageRecord(ctx, lineRecord = {}, side = 'left') {
|
||||||
|
const content = this.getPageContent(side);
|
||||||
|
const layout = lineRecord.metadata?.imageLayout || {};
|
||||||
|
const rect = layout.textureRect || {};
|
||||||
|
const x = content.x + Number(rect.x || 0);
|
||||||
|
const y = content.y + Number(rect.y || 0);
|
||||||
|
const width = Math.max(1, Number(rect.width || content.width));
|
||||||
|
const height = Math.max(1, Number(rect.height || this.metrics.typographyLineHeightPx));
|
||||||
|
const src = this.resolveImageSource(lineRecord.metadata || {});
|
||||||
|
|
||||||
|
ctx.save();
|
||||||
|
if (src) {
|
||||||
|
const image = this.getCachedImage(src);
|
||||||
|
if (image?.complete && image.naturalWidth > 0) {
|
||||||
|
this.drawImageFitted(ctx, image, x, y, width, height);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
resolveImageSource(metadata = {}) {
|
||||||
|
const explicit = String(metadata.url || metadata.src || '').trim();
|
||||||
|
if (explicit) return explicit;
|
||||||
|
const filename = String(metadata.filename || '').trim();
|
||||||
|
if (!filename) return '';
|
||||||
|
if (/^(https?:|data:|blob:|\/)/i.test(filename)) return filename;
|
||||||
|
return `/images/${filename.replace(/^images[\\/]/i, '').replace(/\\/g, '/')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
getCachedImage(src) {
|
||||||
|
if (!src) return null;
|
||||||
|
if (this.imageCache.has(src)) return this.imageCache.get(src);
|
||||||
|
const image = new Image();
|
||||||
|
image.decoding = 'async';
|
||||||
|
image.onload = () => this.drawSpread(this.currentSpread || this.pagination?.getCurrentSpread?.());
|
||||||
|
image.onerror = () => this.markPipelineTiming('image:load-error', { src });
|
||||||
|
image.src = src;
|
||||||
|
this.imageCache.set(src, image);
|
||||||
|
return image;
|
||||||
|
}
|
||||||
|
|
||||||
|
drawImageFitted(ctx, image, x, y, width, height) {
|
||||||
|
const sourceWidth = image.naturalWidth || image.width || 1;
|
||||||
|
const sourceHeight = image.naturalHeight || image.height || 1;
|
||||||
|
const sourceAspect = sourceWidth / sourceHeight;
|
||||||
|
const targetAspect = width / height;
|
||||||
|
let sx = 0;
|
||||||
|
let sy = 0;
|
||||||
|
let sw = sourceWidth;
|
||||||
|
let sh = sourceHeight;
|
||||||
|
if (sourceAspect > targetAspect) {
|
||||||
|
sw = sourceHeight * targetAspect;
|
||||||
|
sx = (sourceWidth - sw) * 0.5;
|
||||||
|
} else if (sourceAspect < targetAspect) {
|
||||||
|
sh = sourceWidth / targetAspect;
|
||||||
|
sy = (sourceHeight - sh) * 0.5;
|
||||||
|
}
|
||||||
|
ctx.drawImage(image, sx, sy, sw, sh, x, y, width, height);
|
||||||
|
}
|
||||||
|
|
||||||
drawLine(ctx, lineRecord = {}, side = 'left') {
|
drawLine(ctx, lineRecord = {}, side = 'left') {
|
||||||
const metrics = this.metrics;
|
const metrics = this.metrics;
|
||||||
const content = this.getPageContent(side);
|
const content = this.getPageContent(side);
|
||||||
@@ -708,7 +845,8 @@ class BookTextureRendererModule extends BaseModule {
|
|||||||
const detail = {
|
const detail = {
|
||||||
metrics: this.metrics,
|
metrics: this.metrics,
|
||||||
hitMaps: this.hitMaps,
|
hitMaps: this.hitMaps,
|
||||||
sides: sidesToPublish
|
sides: sidesToPublish,
|
||||||
|
pageMeta: this.currentSpread?.pageMeta || {}
|
||||||
};
|
};
|
||||||
if (options.preloadOnly) detail.preloadOnly = true;
|
if (options.preloadOnly) detail.preloadOnly = true;
|
||||||
if (sidesToPublish.includes('left')) {
|
if (sidesToPublish.includes('left')) {
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ class MarkupParserModule extends BaseModule {
|
|||||||
const lower = token.toLowerCase();
|
const lower = token.toLowerCase();
|
||||||
const [key, value] = lower.split('=');
|
const [key, value] = lower.split('=');
|
||||||
|
|
||||||
if (['landscape', 'widescreen', 'portrait', 'square'].includes(lower)) {
|
if (['landscape', 'widescreen', 'portrait', 'square', 'full'].includes(lower)) {
|
||||||
options.size = lower === 'widescreen' ? 'landscape' : lower;
|
options.size = lower === 'widescreen' ? 'landscape' : lower;
|
||||||
} else if (['lead', 'lead-in', 'leadins', 'lead-in-seconds', 'delay', 'intro', 'pause', 'wait', 'hold'].includes(key)) {
|
} else if (['lead', 'lead-in', 'leadins', 'lead-in-seconds', 'delay', 'intro', 'pause', 'wait', 'hold'].includes(key)) {
|
||||||
const seconds = Number(value);
|
const seconds = Number(value);
|
||||||
|
|||||||
@@ -157,6 +157,13 @@ class StoryHistoryModule extends BaseModule {
|
|||||||
...record,
|
...record,
|
||||||
lineStart,
|
lineStart,
|
||||||
lineCount,
|
lineCount,
|
||||||
|
...(Number.isFinite(Number(metrics.pageStart)) ? { pageStart: Math.max(0, Number(metrics.pageStart)) } : {}),
|
||||||
|
...(Number.isFinite(Number(metrics.pageEnd)) ? { pageEnd: Math.max(0, Number(metrics.pageEnd)) } : {}),
|
||||||
|
...(Number.isFinite(Number(metrics.pageLineStart)) ? { pageLineStart: Math.max(0, Number(metrics.pageLineStart)) } : {}),
|
||||||
|
...(Number.isFinite(Number(metrics.pageLineEnd)) ? { pageLineEnd: Math.max(0, Number(metrics.pageLineEnd)) } : {}),
|
||||||
|
...(Number.isFinite(Number(metrics.spreadStart)) ? { spreadStart: Math.max(0, Number(metrics.spreadStart)) } : {}),
|
||||||
|
...(Number.isFinite(Number(metrics.spreadEnd)) ? { spreadEnd: Math.max(0, Number(metrics.spreadEnd)) } : {}),
|
||||||
|
...(metrics.pagination ? { pagination: metrics.pagination } : {}),
|
||||||
metricsUpdatedAt: Date.now()
|
metricsUpdatedAt: Date.now()
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -2453,8 +2453,15 @@ class UIDisplayHandlerModule extends BaseModule {
|
|||||||
const normalizedSize = String(metadata.size || 'landscape').toLowerCase() === 'widescreen'
|
const normalizedSize = String(metadata.size || 'landscape').toLowerCase() === 'widescreen'
|
||||||
? 'landscape'
|
? 'landscape'
|
||||||
: String(metadata.size || 'landscape').toLowerCase();
|
: String(metadata.size || 'landscape').toLowerCase();
|
||||||
const aspect = normalizedSize === 'portrait' ? (9 / 16) : normalizedSize === 'square' ? 1 : (16 / 9);
|
const aspect = normalizedSize === 'portrait'
|
||||||
|
? (9 / 16)
|
||||||
|
: normalizedSize === 'square'
|
||||||
|
? 1
|
||||||
|
: normalizedSize === 'full'
|
||||||
|
? (4.25 / 6.875)
|
||||||
|
: (16 / 9);
|
||||||
const isPortrait = normalizedSize === 'portrait';
|
const isPortrait = normalizedSize === 'portrait';
|
||||||
|
const isFullPage = normalizedSize === 'full';
|
||||||
const imageGap = lineHeight;
|
const imageGap = lineHeight;
|
||||||
const maxOuterWidth = isPortrait ? pageWidth * 0.5 : pageWidth;
|
const maxOuterWidth = isPortrait ? pageWidth * 0.5 : pageWidth;
|
||||||
const maxImageWidth = isPortrait
|
const maxImageWidth = isPortrait
|
||||||
@@ -2463,7 +2470,7 @@ class UIDisplayHandlerModule extends BaseModule {
|
|||||||
const naturalHeight = maxImageWidth / aspect;
|
const naturalHeight = maxImageWidth / aspect;
|
||||||
const imageLineCount = Math.max(1, Math.floor(naturalHeight / lineHeight));
|
const imageLineCount = Math.max(1, Math.floor(naturalHeight / lineHeight));
|
||||||
const verticalMargin = lineHeight / 2;
|
const verticalMargin = lineHeight / 2;
|
||||||
const lineCount = imageLineCount + 1;
|
const lineCount = isFullPage ? this.pageLineCount : imageLineCount + 1;
|
||||||
const height = Math.max(lineHeight, (lineCount * lineHeight) - (verticalMargin * 2));
|
const height = Math.max(lineHeight, (lineCount * lineHeight) - (verticalMargin * 2));
|
||||||
const width = Math.min(maxImageWidth, height * aspect);
|
const width = Math.min(maxImageWidth, height * aspect);
|
||||||
|
|
||||||
|
|||||||
+104
-3
@@ -185,8 +185,8 @@ function markPageTextureTiming(name, detail = {}) {
|
|||||||
|
|
||||||
const book = new THREE.Group();
|
const book = new THREE.Group();
|
||||||
scene.add(book);
|
scene.add(book);
|
||||||
const initialReadingProgress = THREE.MathUtils.clamp(Number.parseFloat(urlParams.get('progress') ?? appInitialState.progress ?? '0.28'), 0, 1);
|
const initialReadingProgress = THREE.MathUtils.clamp(Number.parseFloat(urlParams.get('progress') ?? appInitialState.progress ?? '0'), 0, 1);
|
||||||
let readingProgress = Number.isFinite(initialReadingProgress) ? initialReadingProgress : 0.28;
|
let readingProgress = Number.isFinite(initialReadingProgress) ? initialReadingProgress : 0;
|
||||||
let bookPageCount = snapProceduralPageCount(urlParams.get('pages') ?? appInitialState.pageCount ?? '240');
|
let bookPageCount = snapProceduralPageCount(urlParams.get('pages') ?? appInitialState.pageCount ?? '240');
|
||||||
let currentProceduralBookModel = null;
|
let currentProceduralBookModel = null;
|
||||||
const progressInput = document.getElementById('progress_control');
|
const progressInput = document.getElementById('progress_control');
|
||||||
@@ -235,10 +235,22 @@ function createPageCanvasTexture(sourceCanvas) {
|
|||||||
return texture;
|
return texture;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getBlankPageTexture() {
|
||||||
|
if (blankPageTexture) return blankPageTexture;
|
||||||
|
blankPageTexture = createPageCanvasTexture(createPageCanvas('blank'));
|
||||||
|
return blankPageTexture;
|
||||||
|
}
|
||||||
|
|
||||||
const preparedPageTextures = {
|
const preparedPageTextures = {
|
||||||
left: new Map(),
|
left: new Map(),
|
||||||
right: new Map()
|
right: new Map()
|
||||||
};
|
};
|
||||||
|
let blankPageTexture = null;
|
||||||
|
let currentPageMeta = {
|
||||||
|
left: null,
|
||||||
|
right: null
|
||||||
|
};
|
||||||
|
let pendingRightPageFlip = false;
|
||||||
const pageRevealState = {
|
const pageRevealState = {
|
||||||
left: null,
|
left: null,
|
||||||
right: null
|
right: null
|
||||||
@@ -518,6 +530,15 @@ document.addEventListener('webgl-book:page-reveal-start', (event) => {
|
|||||||
document.addEventListener('webgl-book:page-reveal-fast-forward', (event) => {
|
document.addEventListener('webgl-book:page-reveal-fast-forward', (event) => {
|
||||||
fastForwardPageReveals(event.detail?.blockIds || []);
|
fastForwardPageReveals(event.detail?.blockIds || []);
|
||||||
});
|
});
|
||||||
|
document.addEventListener('webgl-book:reveal-committed', (event) => {
|
||||||
|
handleRevealCommittedForPageFlip(event.detail || {});
|
||||||
|
});
|
||||||
|
document.addEventListener('ui:command', (event) => {
|
||||||
|
if (event.detail?.type === 'continue' && pendingRightPageFlip) {
|
||||||
|
pendingRightPageFlip = false;
|
||||||
|
startPageFlip(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
installBookControls();
|
installBookControls();
|
||||||
installCameraControls();
|
installCameraControls();
|
||||||
resize();
|
resize();
|
||||||
@@ -1673,11 +1694,18 @@ function syncBookControls() {
|
|||||||
|
|
||||||
function handlePageCanvases(event) {
|
function handlePageCanvases(event) {
|
||||||
const detail = event.detail || {};
|
const detail = event.detail || {};
|
||||||
|
if (detail.pageMeta) {
|
||||||
|
currentPageMeta = {
|
||||||
|
left: detail.pageMeta.left || currentPageMeta.left || null,
|
||||||
|
right: detail.pageMeta.right || currentPageMeta.right || null
|
||||||
|
};
|
||||||
|
}
|
||||||
markPageTextureTiming('handlePageCanvases:start', {
|
markPageTextureTiming('handlePageCanvases:start', {
|
||||||
hasLeft: Boolean(detail.left),
|
hasLeft: Boolean(detail.left),
|
||||||
hasRight: Boolean(detail.right),
|
hasRight: Boolean(detail.right),
|
||||||
revealSides: Object.keys(detail.reveal || {}),
|
revealSides: Object.keys(detail.reveal || {}),
|
||||||
preloadOnly: Boolean(detail.preloadOnly)
|
preloadOnly: Boolean(detail.preloadOnly),
|
||||||
|
pageMeta: currentPageMeta
|
||||||
});
|
});
|
||||||
if (detail.preloadOnly) {
|
if (detail.preloadOnly) {
|
||||||
if (detail.left) preloadPageTexture('left', detail.left, detail.reveal?.left);
|
if (detail.left) preloadPageTexture('left', detail.left, detail.reveal?.left);
|
||||||
@@ -2111,8 +2139,11 @@ function textureHitPageSide(hit) {
|
|||||||
|
|
||||||
function startPageFlip(direction) {
|
function startPageFlip(direction) {
|
||||||
if (activeFlips.length || !currentProceduralBookModel || !canPageFlip(direction)) return false;
|
if (activeFlips.length || !currentProceduralBookModel || !canPageFlip(direction)) return false;
|
||||||
|
pendingRightPageFlip = false;
|
||||||
|
delete document.documentElement.dataset.webglPendingPageFlip;
|
||||||
const flip = createPageFlip(direction, performance.now(), normalFlipDuration);
|
const flip = createPageFlip(direction, performance.now(), normalFlipDuration);
|
||||||
if (!flip) return false;
|
if (!flip) return false;
|
||||||
|
prepareStaticPageForFlip(flip);
|
||||||
activeFlips.push(flip);
|
activeFlips.push(flip);
|
||||||
syncBookControls();
|
syncBookControls();
|
||||||
updateActiveFlips(flip.startTime);
|
updateActiveFlips(flip.startTime);
|
||||||
@@ -2123,6 +2154,7 @@ function startFastPageFlip(direction) {
|
|||||||
if (activeFlips.length || !currentProceduralBookModel || !canPageFlip(direction)) return false;
|
if (activeFlips.length || !currentProceduralBookModel || !canPageFlip(direction)) return false;
|
||||||
const firstFlip = createPageFlip(direction, performance.now(), fastFlipDuration);
|
const firstFlip = createPageFlip(direction, performance.now(), fastFlipDuration);
|
||||||
if (!firstFlip) return false;
|
if (!firstFlip) return false;
|
||||||
|
prepareStaticPageForFlip(firstFlip);
|
||||||
const startTime = firstFlip.startTime;
|
const startTime = firstFlip.startTime;
|
||||||
const interval = fastFlipDuration / fastFlipOverlap;
|
const interval = fastFlipDuration / fastFlipOverlap;
|
||||||
for (let index = 0; index < fastFlipCount; index += 1) {
|
for (let index = 0; index < fastFlipCount; index += 1) {
|
||||||
@@ -2142,11 +2174,13 @@ function startFastPageFlip(direction) {
|
|||||||
|
|
||||||
function createPageFlip(direction, startTime, duration) {
|
function createPageFlip(direction, startTime, duration) {
|
||||||
const sourceSide = direction > 0 ? 1 : -1;
|
const sourceSide = direction > 0 ? 1 : -1;
|
||||||
|
const sourcePageSide = direction > 0 ? 'right' : 'left';
|
||||||
const sourceLine = topVisibleLine(sourceSide);
|
const sourceLine = topVisibleLine(sourceSide);
|
||||||
const destinationLine = topVisibleLine(-sourceSide);
|
const destinationLine = topVisibleLine(-sourceSide);
|
||||||
if (!sourceLine || !destinationLine) return null;
|
if (!sourceLine || !destinationLine) return null;
|
||||||
return {
|
return {
|
||||||
direction,
|
direction,
|
||||||
|
sourcePageSide,
|
||||||
sourceLine,
|
sourceLine,
|
||||||
destinationLine,
|
destinationLine,
|
||||||
startTime,
|
startTime,
|
||||||
@@ -2158,12 +2192,64 @@ function createPageFlip(direction, startTime, duration) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function prepareStaticPageForFlip(flip) {
|
||||||
|
if (!flip) return;
|
||||||
|
const sourceMaterial = flip.sourcePageSide === 'left' ? materials.leftPage : materials.rightPage;
|
||||||
|
const sourceTexture = sourceMaterial?.map || (flip.sourcePageSide === 'left' ? leftTexture : rightTexture);
|
||||||
|
materials.flipPageSurface.map = sourceTexture;
|
||||||
|
materials.flipPageSurface.normalMap = materials.pageSurface.normalMap;
|
||||||
|
materials.flipPageSurface.roughnessMap = materials.pageSurface.roughnessMap;
|
||||||
|
materials.flipPageSurface.needsUpdate = true;
|
||||||
|
flip.sourceTexture = sourceTexture;
|
||||||
|
if (flip.direction > 0) {
|
||||||
|
const blankTexture = getBlankPageTexture();
|
||||||
|
if (blankTexture && materials.rightPage.map !== blankTexture) {
|
||||||
|
clearPageReveal('right', 'page-flip-start');
|
||||||
|
materials.rightPage.map = blankTexture;
|
||||||
|
materials.rightPage.needsUpdate = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function canPageFlip(direction) {
|
function canPageFlip(direction) {
|
||||||
if (!currentProceduralBookModel) return false;
|
if (!currentProceduralBookModel) return false;
|
||||||
if (direction > 0) return readingProgress < 1;
|
if (direction > 0) return readingProgress < 1;
|
||||||
return readingProgress > 0;
|
return readingProgress > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleRevealCommittedForPageFlip(detail = {}) {
|
||||||
|
if (detail.side !== 'right' || !isRightBodyPageComplete()) return;
|
||||||
|
if (activeFlips.length > 0 || pendingRightPageFlip) return;
|
||||||
|
if (isChoiceAwaitingPlayer()) return;
|
||||||
|
if (isTtsPlaybackActive()) {
|
||||||
|
startPageFlip(1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
pendingRightPageFlip = true;
|
||||||
|
document.documentElement.dataset.webglPendingPageFlip = 'right';
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRightBodyPageComplete() {
|
||||||
|
const meta = currentPageMeta?.right || null;
|
||||||
|
if (!meta || meta.section !== 'body' || meta.kind === 'blank' || meta.kind === 'title') return false;
|
||||||
|
const rendererDebug = window.BookTextureRenderer?.currentSpread || null;
|
||||||
|
const rightLines = Array.isArray(rendererDebug?.right) ? rendererDebug.right : [];
|
||||||
|
const maxLine = rightLines.reduce((max, line) => Math.max(max, Number(line?.pageLine || 0) + Math.max(1, Number(line?.lineCount || 1))), 0);
|
||||||
|
const expectedLines = Math.max(1, Number(meta.linesPerPage || 25));
|
||||||
|
return maxLine >= expectedLines;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isChoiceAwaitingPlayer() {
|
||||||
|
return document.documentElement.dataset.choiceAwaiting === 'true'
|
||||||
|
|| document.body?.dataset?.choiceAwaiting === 'true'
|
||||||
|
|| Boolean(document.querySelector('#choice_menu:not([hidden]) .choice, #choice_menu.visible .choice'));
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTtsPlaybackActive() {
|
||||||
|
const coordinator = window.moduleRegistry?.getModule?.('playback-coordinator') || window.PlaybackCoordinator || null;
|
||||||
|
return Boolean(coordinator?.isPlaying || coordinator?.state === 'playing' || document.documentElement.dataset.ttsPlaying === 'true');
|
||||||
|
}
|
||||||
|
|
||||||
function topVisibleLine(side) {
|
function topVisibleLine(side) {
|
||||||
const sideLines = currentProceduralBookModel.lines
|
const sideLines = currentProceduralBookModel.lines
|
||||||
.filter((line) => line.side === side)
|
.filter((line) => line.side === side)
|
||||||
@@ -2180,6 +2266,15 @@ function updateActiveFlips(now) {
|
|||||||
const t = THREE.MathUtils.clamp(elapsed, 0, 1);
|
const t = THREE.MathUtils.clamp(elapsed, 0, 1);
|
||||||
const surface = buildFlippingPageSurface(flip.sourceLine, flip.destinationLine, flip.direction, easeInOutCubic(t), flip.pageOffset);
|
const surface = buildFlippingPageSurface(flip.sourceLine, flip.destinationLine, flip.direction, easeInOutCubic(t), flip.pageOffset);
|
||||||
setActivePageGeometry(flip, surface);
|
setActivePageGeometry(flip, surface);
|
||||||
|
if (!flip.spreadAdvanced && t >= 0.82) {
|
||||||
|
flip.spreadAdvanced = true;
|
||||||
|
document.dispatchEvent(new CustomEvent('webgl-book:page-flip-near-end', {
|
||||||
|
detail: {
|
||||||
|
direction: flip.direction,
|
||||||
|
sourceSide: flip.sourcePageSide || (flip.direction > 0 ? 'right' : 'left')
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
if (t >= 1) completed.push(flip);
|
if (t >= 1) completed.push(flip);
|
||||||
});
|
});
|
||||||
completed.forEach((flip) => finishActiveFlip(flip));
|
completed.forEach((flip) => finishActiveFlip(flip));
|
||||||
@@ -2378,6 +2473,12 @@ function createFlippingPageGeometry(surface) {
|
|||||||
function finishActiveFlip(flip) {
|
function finishActiveFlip(flip) {
|
||||||
removeFlipMesh(flip);
|
removeFlipMesh(flip);
|
||||||
activeFlips = activeFlips.filter((active) => active !== flip);
|
activeFlips = activeFlips.filter((active) => active !== flip);
|
||||||
|
document.dispatchEvent(new CustomEvent('webgl-book:page-flip-finished', {
|
||||||
|
detail: {
|
||||||
|
direction: flip.direction,
|
||||||
|
sourceSide: flip.sourcePageSide || (flip.direction > 0 ? 'right' : 'left')
|
||||||
|
}
|
||||||
|
}));
|
||||||
if (flip.commitBundleOnFinish) {
|
if (flip.commitBundleOnFinish) {
|
||||||
shiftReadingProgressByBundle(flip.direction);
|
shiftReadingProgressByBundle(flip.direction);
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
import { BaseModule } from './base-module.js';
|
import { BaseModule } from './base-module.js';
|
||||||
|
|
||||||
const DEFAULT_BOOK_PAGE_COUNT = 300;
|
const DEFAULT_BOOK_PAGE_COUNT = 300;
|
||||||
const DEFAULT_BOOK_PROGRESS = 0.5;
|
const DEFAULT_BOOK_PROGRESS = 0;
|
||||||
|
|
||||||
class WebGLBookSceneModule extends BaseModule {
|
class WebGLBookSceneModule extends BaseModule {
|
||||||
constructor() {
|
constructor() {
|
||||||
|
|||||||
@@ -15,8 +15,12 @@ const bookPaginationPath = path.join(__dirname, '..', 'public', 'js', 'book-pagi
|
|||||||
const bookPaginationSource = fs.readFileSync(bookPaginationPath, 'utf8');
|
const bookPaginationSource = fs.readFileSync(bookPaginationPath, 'utf8');
|
||||||
const sentenceQueuePath = path.join(__dirname, '..', 'public', 'js', 'sentence-queue-module.js');
|
const sentenceQueuePath = path.join(__dirname, '..', 'public', 'js', 'sentence-queue-module.js');
|
||||||
const sentenceQueueSource = fs.readFileSync(sentenceQueuePath, 'utf8');
|
const sentenceQueueSource = fs.readFileSync(sentenceQueuePath, 'utf8');
|
||||||
|
const storyHistoryPath = path.join(__dirname, '..', 'public', 'js', 'story-history-module.js');
|
||||||
|
const storyHistorySource = fs.readFileSync(storyHistoryPath, 'utf8');
|
||||||
const webglScenePath = path.join(__dirname, '..', 'public', 'js', 'webgl-book-scene-module.js');
|
const webglScenePath = path.join(__dirname, '..', 'public', 'js', 'webgl-book-scene-module.js');
|
||||||
const webglSceneSource = fs.readFileSync(webglScenePath, 'utf8');
|
const webglSceneSource = fs.readFileSync(webglScenePath, 'utf8');
|
||||||
|
const markupParserPath = path.join(__dirname, '..', 'public', 'js', 'markup-parser-module.js');
|
||||||
|
const markupParserSource = fs.readFileSync(markupParserPath, 'utf8');
|
||||||
const loaderPath = path.join(__dirname, '..', 'public', 'js', 'loader.js');
|
const loaderPath = path.join(__dirname, '..', 'public', 'js', 'loader.js');
|
||||||
const loaderSource = fs.readFileSync(loaderPath, 'utf8');
|
const loaderSource = fs.readFileSync(loaderPath, 'utf8');
|
||||||
const pageFormatPath = path.join(__dirname, '..', 'public', 'js', 'book-page-format-module.js');
|
const pageFormatPath = path.join(__dirname, '..', 'public', 'js', 'book-page-format-module.js');
|
||||||
@@ -139,7 +143,15 @@ const checks = [
|
|||||||
['drop-cap reservation uses both ink bounds and font advance width', /const advanceWidth = metrics\.width \|\| 0/.test(bookPaginationSource) && /Math\.max\(inkRight, advanceWidth, lineHeightPx \* 1\.08\)/.test(bookPaginationSource)],
|
['drop-cap reservation uses both ink bounds and font advance width', /const advanceWidth = metrics\.width \|\| 0/.test(bookPaginationSource) && /Math\.max\(inkRight, advanceWidth, lineHeightPx \* 1\.08\)/.test(bookPaginationSource)],
|
||||||
['webgl scene avoids duplicate initial texture publish', !/this\.triggerTextureRefresh\(\)/.test(methodBody(webglSceneSource, 'initializeScene'))],
|
['webgl scene avoids duplicate initial texture publish', !/this\.triggerTextureRefresh\(\)/.test(methodBody(webglSceneSource, 'initializeScene'))],
|
||||||
['webgl scene does not republish 3D page textures from DOM refresh events', !/addEventListener\(document, 'story:turn-start', this\.triggerTextureRefresh\)/.test(webglSceneSource) && !/addEventListener\(document, 'story:turn-complete', this\.triggerTextureRefresh\)/.test(webglSceneSource) && !/addEventListener\(document, 'story:history-updated', this\.triggerTextureRefresh\)/.test(webglSceneSource) && !/addEventListener\(document, 'input', this\.triggerTextureRefresh/.test(webglSceneSource) && !/addEventListener\(document, 'change', this\.triggerTextureRefresh/.test(webglSceneSource)],
|
['webgl scene does not republish 3D page textures from DOM refresh events', !/addEventListener\(document, 'story:turn-start', this\.triggerTextureRefresh\)/.test(webglSceneSource) && !/addEventListener\(document, 'story:turn-complete', this\.triggerTextureRefresh\)/.test(webglSceneSource) && !/addEventListener\(document, 'story:history-updated', this\.triggerTextureRefresh\)/.test(webglSceneSource) && !/addEventListener\(document, 'input', this\.triggerTextureRefresh/.test(webglSceneSource) && !/addEventListener\(document, 'change', this\.triggerTextureRefresh/.test(webglSceneSource)],
|
||||||
['webgl scene adoptPageContent does not republish 3D page textures', !/triggerTextureRefresh/.test(methodBody(webglSceneSource, 'adoptPageContent'))]
|
['webgl scene adoptPageContent does not republish 3D page textures', !/triggerTextureRefresh/.test(methodBody(webglSceneSource, 'adoptPageContent'))],
|
||||||
|
['webgl book starts at progress zero', /const DEFAULT_BOOK_PROGRESS = 0;/.test(webglSceneSource) && /appInitialState\.progress \?\? '0'/.test(source)],
|
||||||
|
['pagination opens with blank left and title right spread', /this\.createBlankPage\(0, \{ section: 'frontmatter' \}\)/.test(bookPaginationSource) && /this\.createTitlePage\(1\)/.test(bookPaginationSource) && /this\.createBlankPage\(2, \{ section: 'frontmatter' \}\)/.test(bookPaginationSource)],
|
||||||
|
['pagination publishes page metadata and advances near the end of physical flips', /pageMeta/.test(bookPaginationSource) && /webgl-book:page-flip-near-end/.test(bookPaginationSource) && /this\.setCurrentSpread\(this\.currentSpreadIndex \+ direction\)/.test(bookPaginationSource)],
|
||||||
|
['texture renderer draws title page and page numbers from page metadata', /drawTitlePage/.test(textureRendererSource) && /game_title/.test(textureRendererSource) && /drawPageNumber/.test(textureRendererSource) && /pageMeta: this\.currentSpread\?\.pageMeta/.test(textureRendererSource)],
|
||||||
|
['webgl flip borrows resident page texture and blanks right stack before forward animation', /prepareStaticPageForFlip/.test(source) && /materials\.flipPageSurface\.map = sourceTexture/.test(source) && /materials\.rightPage\.map = blankTexture/.test(source) && /webgl-book:page-flip-near-end/.test(source)],
|
||||||
|
['webgl right-page completion arms a flip without bypassing choices', /handleRevealCommittedForPageFlip/.test(source) && /isRightBodyPageComplete/.test(source) && /isChoiceAwaitingPlayer/.test(source) && /pendingRightPageFlip/.test(source)],
|
||||||
|
['markup and 3d pagination accept full-page images', /'full'/.test(markupParserSource) && /size === 'full'/.test(bookPaginationSource)],
|
||||||
|
['story history can persist 3d pagination decisions', /persistPaginationMetrics/.test(bookPaginationSource) && /collectPaginationMetrics/.test(bookPaginationSource) && /pageStart/.test(storyHistorySource) && /pagination: metrics\.pagination/.test(storyHistorySource)]
|
||||||
];
|
];
|
||||||
|
|
||||||
const failures = checks.filter(([, passed]) => !passed).map(([name]) => name);
|
const failures = checks.filter(([, passed]) => !passed).map(([name]) => name);
|
||||||
|
|||||||
Reference in New Issue
Block a user