Add texture-space book pagination foundation
This commit is contained in:
@@ -74,6 +74,7 @@ class BookPageFormatModule extends BaseModule {
|
|||||||
width: Math.max(1, width - margins.outer - margins.inner),
|
width: Math.max(1, width - margins.outer - margins.inner),
|
||||||
height: Math.max(1, height - margins.top - margins.bottom)
|
height: Math.max(1, height - margins.top - margins.bottom)
|
||||||
},
|
},
|
||||||
|
typographyLineHeightPx: this.inchesToTexture(this.format.typography.lineHeightPt / 72, height),
|
||||||
typography: this.format.typography
|
typography: this.format.typography
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,216 @@
|
|||||||
|
/**
|
||||||
|
* Book Pagination Module
|
||||||
|
* Converts story blocks into texture-space page lines for the WebGL book.
|
||||||
|
*/
|
||||||
|
import { BaseModule } from './base-module.js';
|
||||||
|
|
||||||
|
class BookPaginationModule extends BaseModule {
|
||||||
|
constructor() {
|
||||||
|
super('book-pagination', 'Book Pagination');
|
||||||
|
this.dependencies = ['book-page-format', 'paragraph-layout', 'story-history'];
|
||||||
|
this.pageFormat = null;
|
||||||
|
this.paragraphLayout = null;
|
||||||
|
this.storyHistory = null;
|
||||||
|
this.metrics = null;
|
||||||
|
this.spreads = [];
|
||||||
|
this.currentSpreadIndex = 0;
|
||||||
|
this.refreshToken = 0;
|
||||||
|
|
||||||
|
this.bindMethods([
|
||||||
|
'initialize',
|
||||||
|
'refreshFromHistory',
|
||||||
|
'buildSpreads',
|
||||||
|
'layoutTextBlock',
|
||||||
|
'extractLines',
|
||||||
|
'getLineGeometry',
|
||||||
|
'getSpread',
|
||||||
|
'getCurrentSpread',
|
||||||
|
'setCurrentSpread',
|
||||||
|
'publish'
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async initialize() {
|
||||||
|
this.pageFormat = this.getModule('book-page-format');
|
||||||
|
this.paragraphLayout = this.getModule('paragraph-layout');
|
||||||
|
this.storyHistory = this.getModule('story-history');
|
||||||
|
this.metrics = this.pageFormat.getTextureMetrics(1280);
|
||||||
|
|
||||||
|
this.reportProgress(35, 'Preparing book pagination metrics');
|
||||||
|
this.addEventListener(document, 'story:history-updated', this.refreshFromHistory);
|
||||||
|
this.addEventListener(document, 'book-pagination:set-spread', (event) => {
|
||||||
|
this.setCurrentSpread(event.detail?.spreadIndex);
|
||||||
|
});
|
||||||
|
this.reportProgress(100, 'Book pagination ready');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async refreshFromHistory(event = null) {
|
||||||
|
const token = ++this.refreshToken;
|
||||||
|
const detail = event?.detail || {};
|
||||||
|
const gameId = detail.gameId || this.storyHistory?.currentGameId || null;
|
||||||
|
const latestBlockId = Math.max(
|
||||||
|
0,
|
||||||
|
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.publish();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const blocks = await this.storyHistory.getBlocksRange(gameId, 1, latestBlockId);
|
||||||
|
if (token !== this.refreshToken) return;
|
||||||
|
this.spreads = this.buildSpreads(blocks);
|
||||||
|
this.currentSpreadIndex = Math.max(0, Math.min(this.currentSpreadIndex, Math.max(0, this.spreads.length - 1)));
|
||||||
|
this.publish();
|
||||||
|
}
|
||||||
|
|
||||||
|
buildSpreads(blocks = []) {
|
||||||
|
const spreads = [];
|
||||||
|
let cursorLine = 0;
|
||||||
|
const source = Array.isArray(blocks) ? blocks : [];
|
||||||
|
|
||||||
|
source.forEach((block) => {
|
||||||
|
const type = block?.kind || block?.type || 'paragraph';
|
||||||
|
if (!['paragraph', 'heading'].includes(type)) return;
|
||||||
|
|
||||||
|
const layout = this.layoutTextBlock(block, type);
|
||||||
|
if (!layout?.lines?.length) return;
|
||||||
|
|
||||||
|
layout.lines.forEach((line) => {
|
||||||
|
const geometry = this.getLineGeometry(cursorLine);
|
||||||
|
if (!spreads[geometry.spreadIndex]) {
|
||||||
|
spreads[geometry.spreadIndex] = { index: geometry.spreadIndex, left: [], right: [] };
|
||||||
|
}
|
||||||
|
spreads[geometry.spreadIndex][geometry.side].push({
|
||||||
|
blockId: block.blockId ?? null,
|
||||||
|
turnId: block.turnId ?? block.metadata?.turnId ?? null,
|
||||||
|
role: layout.role,
|
||||||
|
text: block.text || '',
|
||||||
|
line,
|
||||||
|
lineIndex: cursorLine,
|
||||||
|
pageLine: geometry.pageLine,
|
||||||
|
fontPx: layout.fontPx,
|
||||||
|
lineHeightPx: layout.lineHeightPx
|
||||||
|
});
|
||||||
|
cursorLine += 1;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return spreads.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
layoutTextBlock(block = {}, type = 'paragraph') {
|
||||||
|
const text = String(block.layoutText || block.text || '').trim();
|
||||||
|
if (!text || !this.paragraphLayout) return null;
|
||||||
|
|
||||||
|
const typography = this.metrics.typography;
|
||||||
|
const role = block.role || block.metadata?.role || (type === 'heading' ? 'chapter-heading' : 'body');
|
||||||
|
const isHeading = type === 'heading' || role === 'chapter-heading' || role === 'section-heading';
|
||||||
|
const fontPx = Math.max(1, Math.round(this.pageFormat.inchesToTexture(typography.bodyFontSizePt / 72, this.metrics.height)));
|
||||||
|
const lineHeightPx = Math.max(fontPx + 2, Math.round(this.pageFormat.inchesToTexture(typography.lineHeightPt / 72, this.metrics.height)));
|
||||||
|
const indent = (isHeading || block.isFirstParagraphInChapter || block.metadata?.isFirstParagraphInChapter || block.addTopSpace)
|
||||||
|
? 0
|
||||||
|
: lineHeightPx * 1.5;
|
||||||
|
const measures = isHeading
|
||||||
|
? [this.metrics.content.width]
|
||||||
|
: [Math.max(120, this.metrics.content.width - indent), this.metrics.content.width, this.metrics.content.width];
|
||||||
|
const lineOffsets = isHeading ? [0] : [indent, 0, 0];
|
||||||
|
|
||||||
|
const layout = this.paragraphLayout.calculateLayout(text, {
|
||||||
|
measures,
|
||||||
|
fontSize: `${fontPx}px`,
|
||||||
|
fontFamily: typography.fontFamily,
|
||||||
|
lineHeightPx,
|
||||||
|
lineHeight: lineHeightPx / fontPx
|
||||||
|
});
|
||||||
|
if (!layout) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
role,
|
||||||
|
fontPx,
|
||||||
|
lineHeightPx,
|
||||||
|
lines: this.extractLines(layout, {
|
||||||
|
measures,
|
||||||
|
lineOffsets,
|
||||||
|
align: isHeading ? 'center' : 'justify'
|
||||||
|
})
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
extractLines(layout, options = {}) {
|
||||||
|
const lines = [];
|
||||||
|
const breaks = Array.isArray(layout.breaks) ? layout.breaks : [];
|
||||||
|
const nodes = Array.isArray(layout.nodes) ? layout.nodes : [];
|
||||||
|
for (let index = 1; index < breaks.length; index += 1) {
|
||||||
|
const start = breaks[index - 1].position;
|
||||||
|
const end = breaks[index].position;
|
||||||
|
const lineNodes = [];
|
||||||
|
for (let nodeIndex = start; nodeIndex <= end; nodeIndex += 1) {
|
||||||
|
const node = nodes[nodeIndex];
|
||||||
|
if (!node) continue;
|
||||||
|
if (node.type === 'glue' && (nodeIndex === start || nodeIndex === end)) continue;
|
||||||
|
lineNodes.push({ ...node });
|
||||||
|
}
|
||||||
|
const measure = options.measures[Math.min(index - 1, options.measures.length - 1)] || this.metrics.content.width;
|
||||||
|
const offset = options.lineOffsets[Math.min(index - 1, options.lineOffsets.length - 1)] || 0;
|
||||||
|
lines.push({
|
||||||
|
nodes: lineNodes,
|
||||||
|
measure,
|
||||||
|
offset,
|
||||||
|
ratio: breaks[index].ratio || 0,
|
||||||
|
isFinal: index === breaks.length - 1,
|
||||||
|
align: options.align || 'justify'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
|
||||||
|
getLineGeometry(globalLine) {
|
||||||
|
const linesPerPage = Math.max(1, Math.floor(this.metrics.content.height / this.metrics.typographyLineHeightPx || 1));
|
||||||
|
const spreadLineCount = linesPerPage * 2;
|
||||||
|
const spreadIndex = Math.floor(globalLine / spreadLineCount);
|
||||||
|
const spreadLine = globalLine % spreadLineCount;
|
||||||
|
const side = spreadLine < linesPerPage ? 'left' : 'right';
|
||||||
|
return {
|
||||||
|
spreadIndex,
|
||||||
|
side,
|
||||||
|
pageLine: side === 'left' ? spreadLine : spreadLine - linesPerPage
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
getSpread(index = this.currentSpreadIndex) {
|
||||||
|
return this.spreads[Math.max(0, Number(index || 0))] || { index: 0, left: [], right: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
getCurrentSpread() {
|
||||||
|
return this.getSpread(this.currentSpreadIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
setCurrentSpread(index = 0) {
|
||||||
|
this.currentSpreadIndex = Math.max(0, Math.min(Math.round(Number(index || 0)), Math.max(0, this.spreads.length - 1)));
|
||||||
|
this.publish();
|
||||||
|
return this.currentSpreadIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
publish() {
|
||||||
|
document.dispatchEvent(new CustomEvent('book-pagination:spread-updated', {
|
||||||
|
detail: {
|
||||||
|
spread: this.getCurrentSpread(),
|
||||||
|
spreadIndex: this.currentSpreadIndex,
|
||||||
|
spreadCount: this.spreads.length
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const bookPagination = new BookPaginationModule();
|
||||||
|
|
||||||
|
export { bookPagination as BookPagination };
|
||||||
|
|
||||||
|
if (window.moduleRegistry) {
|
||||||
|
window.moduleRegistry.register(bookPagination);
|
||||||
|
}
|
||||||
|
|
||||||
|
window.BookPagination = bookPagination;
|
||||||
@@ -7,8 +7,9 @@ import { BaseModule } from './base-module.js';
|
|||||||
class BookTextureRendererModule extends BaseModule {
|
class BookTextureRendererModule extends BaseModule {
|
||||||
constructor() {
|
constructor() {
|
||||||
super('book-texture-renderer', 'Book Texture Renderer');
|
super('book-texture-renderer', 'Book Texture Renderer');
|
||||||
this.dependencies = ['book-page-format', 'localization'];
|
this.dependencies = ['book-page-format', 'book-pagination', 'localization'];
|
||||||
this.pageFormat = null;
|
this.pageFormat = null;
|
||||||
|
this.pagination = null;
|
||||||
this.localization = null;
|
this.localization = null;
|
||||||
this.metrics = null;
|
this.metrics = null;
|
||||||
this.canvases = {
|
this.canvases = {
|
||||||
@@ -28,8 +29,10 @@ class BookTextureRendererModule extends BaseModule {
|
|||||||
'initialize',
|
'initialize',
|
||||||
'createPageCanvases',
|
'createPageCanvases',
|
||||||
'drawEmptySpread',
|
'drawEmptySpread',
|
||||||
|
'drawSpread',
|
||||||
'drawPageBase',
|
'drawPageBase',
|
||||||
'drawDebugText',
|
'drawPageLines',
|
||||||
|
'drawLine',
|
||||||
'publishSpread',
|
'publishSpread',
|
||||||
'getPageCanvas',
|
'getPageCanvas',
|
||||||
'getHitMap',
|
'getHitMap',
|
||||||
@@ -39,11 +42,15 @@ class BookTextureRendererModule extends BaseModule {
|
|||||||
|
|
||||||
async initialize() {
|
async initialize() {
|
||||||
this.pageFormat = this.getModule('book-page-format');
|
this.pageFormat = this.getModule('book-page-format');
|
||||||
|
this.pagination = this.getModule('book-pagination');
|
||||||
this.localization = this.getModule('localization');
|
this.localization = this.getModule('localization');
|
||||||
this.reportProgress(20, 'Preparing page texture canvases');
|
this.reportProgress(20, 'Preparing page texture canvases');
|
||||||
this.createPageCanvases();
|
this.createPageCanvases();
|
||||||
this.drawEmptySpread();
|
this.drawEmptySpread();
|
||||||
this.addEventListener(document, 'webgl-book:scene-ready', this.handleSceneReady);
|
this.addEventListener(document, 'webgl-book:scene-ready', this.handleSceneReady);
|
||||||
|
this.addEventListener(document, 'book-pagination:spread-updated', (event) => {
|
||||||
|
this.drawSpread(event.detail?.spread || this.pagination?.getCurrentSpread?.());
|
||||||
|
});
|
||||||
this.reportProgress(100, 'Book texture renderer ready');
|
this.reportProgress(100, 'Book texture renderer ready');
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -62,7 +69,14 @@ class BookTextureRendererModule extends BaseModule {
|
|||||||
drawEmptySpread() {
|
drawEmptySpread() {
|
||||||
this.drawPageBase('left');
|
this.drawPageBase('left');
|
||||||
this.drawPageBase('right');
|
this.drawPageBase('right');
|
||||||
this.drawDebugText('right', 'Book canvas renderer ready');
|
this.publishSpread();
|
||||||
|
}
|
||||||
|
|
||||||
|
drawSpread(spread = null) {
|
||||||
|
this.drawPageBase('left');
|
||||||
|
this.drawPageBase('right');
|
||||||
|
this.drawPageLines('left', spread?.left || []);
|
||||||
|
this.drawPageLines('right', spread?.right || []);
|
||||||
this.publishSpread();
|
this.publishSpread();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,19 +105,51 @@ class BookTextureRendererModule extends BaseModule {
|
|||||||
this.hitMaps[side] = [];
|
this.hitMaps[side] = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
drawDebugText(side, text) {
|
drawPageLines(side, lines = []) {
|
||||||
const ctx = this.contexts[side];
|
const ctx = this.contexts[side];
|
||||||
const metrics = this.metrics;
|
if (!ctx || !this.metrics || !Array.isArray(lines)) return;
|
||||||
if (!ctx || !metrics) return;
|
|
||||||
|
|
||||||
ctx.save();
|
ctx.save();
|
||||||
ctx.fillStyle = 'rgba(31, 19, 10, 0.82)';
|
ctx.fillStyle = 'rgba(31, 19, 10, 0.86)';
|
||||||
ctx.font = `${Math.round(metrics.typography.bodyFontSizePt * 1.55)}px ${metrics.typography.fontFamily}`;
|
|
||||||
ctx.textBaseline = 'alphabetic';
|
ctx.textBaseline = 'alphabetic';
|
||||||
ctx.fillText(String(text || ''), metrics.content.x, metrics.content.y + 44);
|
lines.forEach(line => this.drawLine(ctx, line));
|
||||||
ctx.restore();
|
ctx.restore();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
drawLine(ctx, lineRecord = {}) {
|
||||||
|
const metrics = this.metrics;
|
||||||
|
const fontPx = Math.max(1, Number(lineRecord.fontPx || 22));
|
||||||
|
const lineHeightPx = Math.max(fontPx + 2, Number(lineRecord.lineHeightPx || metrics.typographyLineHeightPx || 30));
|
||||||
|
const line = lineRecord.line || {};
|
||||||
|
const nodes = Array.isArray(line.nodes) ? line.nodes : [];
|
||||||
|
const baseY = metrics.content.y + (Number(lineRecord.pageLine || 0) * lineHeightPx) + fontPx;
|
||||||
|
const ratio = line.isFinal || line.align === 'center' ? 0 : Number(line.ratio || 0);
|
||||||
|
const naturalWidth = nodes.reduce((sum, node) => {
|
||||||
|
if (node.type === 'box' || node.type === 'glue') return sum + Number(node.width || 0);
|
||||||
|
return sum;
|
||||||
|
}, 0);
|
||||||
|
const centerOffset = line.align === 'center'
|
||||||
|
? Math.max(0, (metrics.content.width - naturalWidth) / 2)
|
||||||
|
: Number(line.offset || 0);
|
||||||
|
let x = metrics.content.x + centerOffset;
|
||||||
|
|
||||||
|
ctx.font = `${fontPx}px ${metrics.typography.fontFamily}`;
|
||||||
|
nodes.forEach((node, index) => {
|
||||||
|
if (!node) return;
|
||||||
|
if (node.type === 'box' && node.value) {
|
||||||
|
const nextNode = nodes[index + 1];
|
||||||
|
const value = `${node.value}${nextNode?.type === 'penalty' && nextNode.penalty === 100 ? '-' : ''}`;
|
||||||
|
ctx.fillText(value, x, baseY);
|
||||||
|
x += Number(node.width || ctx.measureText(value).width || 0);
|
||||||
|
} else if (node.type === 'glue' && node.width !== 0) {
|
||||||
|
let width = Number(node.width || 0);
|
||||||
|
if (ratio > 0) width += Number(node.stretch || 0) * ratio;
|
||||||
|
if (ratio < 0) width += Number(node.shrink || 0) * ratio;
|
||||||
|
x += width;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
publishSpread() {
|
publishSpread() {
|
||||||
document.dispatchEvent(new CustomEvent('webgl-book:page-canvases', {
|
document.dispatchEvent(new CustomEvent('webgl-book:page-canvases', {
|
||||||
detail: {
|
detail: {
|
||||||
|
|||||||
+2
-1
@@ -24,7 +24,7 @@ const ModuleState = {
|
|||||||
ERROR: 'ERROR'
|
ERROR: 'ERROR'
|
||||||
};
|
};
|
||||||
|
|
||||||
const MODULE_CACHE_BUSTER = '20260606-webgl-texture-renderer-foundation';
|
const MODULE_CACHE_BUSTER = '20260606-webgl-pagination-foundation';
|
||||||
window.MODULE_CACHE_BUSTER = MODULE_CACHE_BUSTER;
|
window.MODULE_CACHE_BUSTER = MODULE_CACHE_BUSTER;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -114,6 +114,7 @@ const ModuleLoader = (function() {
|
|||||||
{ id: 'sentence-queue', script: '/js/sentence-queue-module.js', weight: 12 },
|
{ id: 'sentence-queue', script: '/js/sentence-queue-module.js', weight: 12 },
|
||||||
{ id: 'layout-renderer', script: '/js/layout-renderer-module.js', weight: 13 }, // Add Layout Renderer module
|
{ id: 'layout-renderer', script: '/js/layout-renderer-module.js', weight: 13 }, // Add Layout Renderer module
|
||||||
{ id: 'book-page-format', script: '/js/book-page-format-module.js', weight: 4 },
|
{ id: 'book-page-format', script: '/js/book-page-format-module.js', weight: 4 },
|
||||||
|
{ id: 'book-pagination', script: '/js/book-pagination-module.js', weight: 8 },
|
||||||
{ id: 'book-texture-renderer', script: '/js/book-texture-renderer-module.js', weight: 6 },
|
{ id: 'book-texture-renderer', script: '/js/book-texture-renderer-module.js', weight: 6 },
|
||||||
{ id: 'webgl-book-scene', script: '/js/webgl-book-scene-module.js', weight: 13 },
|
{ id: 'webgl-book-scene', script: '/js/webgl-book-scene-module.js', weight: 13 },
|
||||||
{ id: 'animation-queue', script: '/js/animation-queue-module.js', weight: 12 },
|
{ id: 'animation-queue', script: '/js/animation-queue-module.js', weight: 12 },
|
||||||
|
|||||||
Reference in New Issue
Block a user