6bd1f45362
Starting a new game reuses block ids (1,2,3...). The reveal clock's per-block start times (activeRevealBlockStarts in the lab) and the renderer's animation/ revealed sets are keyed by block id and were never cleared on a client reset, so a new game over already-cached content inherited the previous run's start times. beginPageReveal then computed a huge elapsed and the shader treated the reveal as already complete — showing everything at once instead of animating. resetClientPlaybackAndDisplay (run on new game and restore) now emits story:client-reset; the lab clears activeRevealBlockStarts/pending reveal state, the texture renderer clears active animations and revealed-block ids, and the timeline invalidates prepared segments. So each game starts with a clean reveal clock. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1187 lines
52 KiB
JavaScript
1187 lines
52 KiB
JavaScript
/**
|
|
* Book Texture Renderer Module
|
|
* Draws the virtual book pages directly into texture-space canvases.
|
|
*/
|
|
import { BaseModule } from './base-module.js';
|
|
|
|
class BookTextureRendererModule extends BaseModule {
|
|
constructor() {
|
|
super('book-texture-renderer', 'Book Texture Renderer');
|
|
this.dependencies = ['book-page-format', 'book-pagination', 'localization', 'game-config', 'webgl-page-cache'];
|
|
this.pageFormat = null;
|
|
this.pagination = null;
|
|
this.localization = null;
|
|
this.gameConfig = null;
|
|
this.pageCache = null;
|
|
this.metrics = null;
|
|
this.canvases = {
|
|
left: null,
|
|
right: null
|
|
};
|
|
this.contexts = {
|
|
left: null,
|
|
right: null
|
|
};
|
|
this.hitMaps = {
|
|
left: [],
|
|
right: []
|
|
};
|
|
this.currentSpread = null;
|
|
this.activeAnimations = new Map();
|
|
this.revealedBlockIds = new Set();
|
|
this.revealBaseCanvases = null;
|
|
this.revealPublishBlockIds = null;
|
|
this.lastDrawSignature = null;
|
|
this.lastDrawSkipLoggedAt = 0;
|
|
this.pipelineTimings = [];
|
|
this.imageCache = new Map();
|
|
this.pageContentVersions = new Map();
|
|
|
|
this.bindMethods([
|
|
'initialize',
|
|
'markPipelineTiming',
|
|
'waitForTextureFonts',
|
|
'ensureTextureFontFace',
|
|
'createPageCanvases',
|
|
'drawSpread',
|
|
'getDrawSignature',
|
|
'cloneCanvas',
|
|
'drawPageBase',
|
|
'drawPageMeta',
|
|
'drawTitlePage',
|
|
'drawPageNumber',
|
|
'drawPageLines',
|
|
'drawImageRecord',
|
|
'resolveImageSource',
|
|
'getCachedImage',
|
|
'drawImageFitted',
|
|
'drawLine',
|
|
'drawWord',
|
|
'buildRevealRegions',
|
|
'shouldFlipAfterSideReveal',
|
|
'collectRevealRegionCandidates',
|
|
'createRevealRegionForLine',
|
|
'assignRevealTiming',
|
|
'getLineInkRect',
|
|
'getLineNaturalWidth',
|
|
'getLineWordCount',
|
|
'getImageRevealDurationMs',
|
|
'getInlineStyleState',
|
|
'updateInlineStyleState',
|
|
'getCanvasFont',
|
|
'applyTextStyle',
|
|
'getPageContent',
|
|
'buildLineSegments',
|
|
'prepareRevealBlock',
|
|
'preloadAdditionalRevealSpreads',
|
|
'spreadContainsBlock',
|
|
'createAnimationState',
|
|
'getDrawPhase',
|
|
'publishPreparedReveal',
|
|
'startPreparedRevealAnimation',
|
|
'fastForwardAnimations',
|
|
'stopAnimations',
|
|
'getBlockSides',
|
|
'getAnimatedSides',
|
|
'publishSpread',
|
|
'buildPageTextureRecords',
|
|
'cachePublishedPages',
|
|
'getPageCanvas',
|
|
'getHitMap',
|
|
'handlePageCountChanged'
|
|
]);
|
|
}
|
|
|
|
async initialize() {
|
|
this.pageFormat = this.getModule('book-page-format');
|
|
this.pagination = this.getModule('book-pagination');
|
|
this.localization = this.getModule('localization');
|
|
this.gameConfig = this.getModule('game-config');
|
|
this.pageCache = this.getModule('webgl-page-cache');
|
|
window.BookTextureRendererDebug = {
|
|
pipelineTimings: this.pipelineTimings
|
|
};
|
|
this.reportProgress(10, 'Waiting for book fonts');
|
|
await this.waitForTextureFonts();
|
|
this.reportProgress(20, 'Preparing page texture canvases');
|
|
this.createPageCanvases();
|
|
this.addEventListener(document, 'webgl-book:page-count-changed', this.handlePageCountChanged);
|
|
// The renderer is a pure renderer. It does not react to pagination spread
|
|
// updates with draws or reveals — the playback owner (book-playback-timeline)
|
|
// drives every draw explicitly. See docs/webgl-3d-ui-spec.md "Single ownership".
|
|
this.addEventListener(document, 'book-texture:fast-forward', this.fastForwardAnimations);
|
|
this.addEventListener(document, 'webgl-book:reveal-committed', (event) => {
|
|
this.completeRevealBlockIds(event.detail?.blockIds || []);
|
|
});
|
|
this.addEventListener(document, 'ui:command', (event) => {
|
|
if (event.detail?.type === 'continue') this.fastForwardAnimations();
|
|
});
|
|
this.addEventListener(document, 'story:manual-scroll', this.fastForwardAnimations);
|
|
this.addEventListener(document, 'story:history-restoring', this.stopAnimations);
|
|
this.addEventListener(document, 'story:client-reset', this.stopAnimations);
|
|
this.currentSpread = this.pagination?.getCurrentSpread?.() || { index: 0, left: [], right: [], pageMeta: { left: null, right: null } };
|
|
this.drawSpread(this.currentSpread);
|
|
this.reportProgress(100, 'Book texture renderer ready');
|
|
return true;
|
|
}
|
|
|
|
markPipelineTiming(name, detail = {}) {
|
|
const entry = {
|
|
name,
|
|
at: performance.now(),
|
|
detail
|
|
};
|
|
this.pipelineTimings.push(entry);
|
|
if (this.pipelineTimings.length > 120) this.pipelineTimings.splice(0, this.pipelineTimings.length - 120);
|
|
document.documentElement.dataset.webglTexturePipeline = JSON.stringify(this.pipelineTimings);
|
|
return entry;
|
|
}
|
|
|
|
async waitForTextureFonts() {
|
|
if (!document.fonts) return;
|
|
await Promise.all([
|
|
this.ensureTextureFontFace('EB Garamond', '/fonts/EBGaramond12-Regular.otf', { style: 'normal', weight: '400' }),
|
|
this.ensureTextureFontFace('EB Garamond', '/fonts/EBGaramond12-Italic.otf', { style: 'italic', weight: '400' }),
|
|
this.ensureTextureFontFace('EB Garamond 12', '/fonts/EBGaramond12/webfonts/EBGaramond-Regular.woff2'),
|
|
this.ensureTextureFontFace('EB Garamond Initials', '/fonts/EB-Garamond-Initials/EBGaramond-0.016/otf/EBGaramond-Initials.otf')
|
|
]);
|
|
await Promise.all([
|
|
document.fonts.load('24px "EB Garamond"'),
|
|
document.fonts.load('italic 24px "EB Garamond"'),
|
|
document.fonts.load('bold 24px "EB Garamond"'),
|
|
document.fonts.load('italic bold 24px "EB Garamond"'),
|
|
document.fonts.load('24px "EB Garamond 12"'),
|
|
document.fonts.load('72px "EB Garamond Initials"')
|
|
]);
|
|
await document.fonts.ready;
|
|
}
|
|
|
|
async ensureTextureFontFace(family, url, descriptors = {}) {
|
|
if (!window.FontFace) return;
|
|
const face = new FontFace(family, `url(${url})`, descriptors);
|
|
const loadedFace = await face.load();
|
|
document.fonts.add(loadedFace);
|
|
}
|
|
|
|
createPageCanvases(textureWidth = this.pageFormat?.getTextureWidth?.() || 3072) {
|
|
this.metrics = this.pageFormat.getTextureMetrics(textureWidth);
|
|
['left', 'right'].forEach((side) => {
|
|
const canvas = document.createElement('canvas');
|
|
canvas.width = this.metrics.width;
|
|
canvas.height = this.metrics.height;
|
|
this.canvases[side] = canvas;
|
|
this.contexts[side] = canvas.getContext('2d');
|
|
});
|
|
}
|
|
|
|
drawSpread(spread = null, sides = null, options = {}) {
|
|
const previousSpread = this.currentSpread;
|
|
this.currentSpread = spread || { left: [], right: [] };
|
|
const sidesToDraw = Array.isArray(sides) && sides.length ? sides : ['left', 'right'];
|
|
const hasReveal = this.revealPublishBlockIds && this.revealPublishBlockIds.size > 0;
|
|
const phase = this.getDrawPhase(options);
|
|
const drawSignature = this.getDrawSignature(this.currentSpread, sidesToDraw);
|
|
if (options.force !== true && phase !== 'prepare' && !hasReveal && drawSignature === this.lastDrawSignature) {
|
|
const now = performance.now();
|
|
if (now - this.lastDrawSkipLoggedAt > 1000) {
|
|
this.lastDrawSkipLoggedAt = now;
|
|
this.markPipelineTiming('drawSpread:skip', { sides: sidesToDraw });
|
|
}
|
|
if (phase === 'prepare') this.currentSpread = previousSpread;
|
|
return null;
|
|
}
|
|
this.markPipelineTiming('drawSpread:start', {
|
|
sides: sidesToDraw,
|
|
revealBlockIds: this.revealPublishBlockIds ? Array.from(this.revealPublishBlockIds) : [],
|
|
phase
|
|
});
|
|
this.revealBaseCanvases = { left: null, right: null };
|
|
sidesToDraw.forEach((side) => {
|
|
if (!this.canvases[side]) return;
|
|
this.drawPageBase(side);
|
|
if (hasReveal) this.revealBaseCanvases[side] = this.cloneCanvas(this.canvases[side]);
|
|
this.drawPageMeta(side, 'before-lines');
|
|
this.drawPageLines(side, this.currentSpread?.[side] || []);
|
|
this.drawPageMeta(side, 'after-lines');
|
|
});
|
|
const published = this.publishSpread(sidesToDraw, options);
|
|
this.markPipelineTiming('drawSpread:end', {
|
|
sides: sidesToDraw,
|
|
phase
|
|
});
|
|
this.revealBaseCanvases = null;
|
|
this.revealPublishBlockIds = null;
|
|
if (phase !== 'prepare' && !hasReveal) this.lastDrawSignature = drawSignature;
|
|
if (phase === 'prepare') this.currentSpread = previousSpread;
|
|
return published;
|
|
}
|
|
|
|
getDrawPhase(options = {}) {
|
|
if (options.phase === 'prepare' || options.phase === 'activate') return options.phase;
|
|
return 'activate';
|
|
}
|
|
|
|
getDrawSignature(spread = null, sides = []) {
|
|
const source = spread || {};
|
|
return sides.map(side => {
|
|
const lines = Array.isArray(source[side]) ? source[side] : [];
|
|
const meta = source.pageMeta?.[side] || {};
|
|
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('|');
|
|
}
|
|
|
|
cloneCanvas(canvas) {
|
|
if (!canvas) return null;
|
|
const clone = document.createElement('canvas');
|
|
clone.width = canvas.width;
|
|
clone.height = canvas.height;
|
|
const context = clone.getContext('2d');
|
|
if (context) context.drawImage(canvas, 0, 0);
|
|
return clone;
|
|
}
|
|
|
|
drawPageBase(side) {
|
|
const canvas = this.canvases[side];
|
|
const ctx = this.contexts[side];
|
|
if (!canvas || !ctx) return;
|
|
|
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
|
ctx.fillStyle = '#f2ead0';
|
|
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
|
|
|
const shade = ctx.createLinearGradient(0, 0, canvas.width, 0);
|
|
if (side === 'left') {
|
|
shade.addColorStop(0, 'rgba(255, 255, 255, 0.06)');
|
|
shade.addColorStop(0.78, 'rgba(255, 255, 255, 0)');
|
|
shade.addColorStop(1, 'rgba(70, 48, 28, 0.08)');
|
|
} else {
|
|
shade.addColorStop(0, 'rgba(70, 48, 28, 0.08)');
|
|
shade.addColorStop(0.22, 'rgba(255, 255, 255, 0)');
|
|
shade.addColorStop(1, 'rgba(255, 255, 255, 0.06)');
|
|
}
|
|
ctx.fillStyle = shade;
|
|
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
|
|
|
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 metadata = this.gameConfig?.getMetadata?.() || {};
|
|
const titleText = document.getElementById('game_title')?.textContent?.trim() || metadata.title || '';
|
|
const authorText = document.getElementById('game_author')?.textContent?.trim()
|
|
|| (metadata.author ? this.localization?.t?.('title.byAuthor', { author: metadata.author }) : '')
|
|
|| '';
|
|
const subtitleText = document.getElementById('game_subtitle')?.textContent?.trim() || metadata.subtitle || '';
|
|
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() || [
|
|
metadata.version ? this.localization?.t?.('title.version', { version: metadata.version }) : '',
|
|
metadata.copyright || ''
|
|
].filter(Boolean).join(' | ');
|
|
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.margins.bottom * 0.48);
|
|
ctx.restore();
|
|
}
|
|
|
|
drawPageLines(side, lines = []) {
|
|
const ctx = this.contexts[side];
|
|
if (!ctx || !this.metrics || !Array.isArray(lines)) return;
|
|
|
|
ctx.save();
|
|
ctx.fillStyle = 'rgba(31, 19, 10, 0.86)';
|
|
ctx.textBaseline = 'alphabetic';
|
|
if ('fontKerning' in ctx) ctx.fontKerning = 'normal';
|
|
if ('fontVariantCaps' in ctx) ctx.fontVariantCaps = 'normal';
|
|
lines.forEach(line => {
|
|
if (line?.type === 'image' || line?.kind === 'image') this.drawImageRecord(ctx, line, side);
|
|
else this.drawLine(ctx, line, side);
|
|
});
|
|
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') {
|
|
const metrics = this.metrics;
|
|
const content = this.getPageContent(side);
|
|
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 = 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, (content.width - naturalWidth) / 2)
|
|
: Number(line.offset || 0);
|
|
let x = content.x + centerOffset;
|
|
const smallCaps = Boolean(lineRecord.smallCaps || line.smallCaps);
|
|
const previousVariantCaps = 'fontVariantCaps' in ctx ? ctx.fontVariantCaps : null;
|
|
const previousLetterSpacing = 'letterSpacing' in ctx ? ctx.letterSpacing : null;
|
|
const baseStyle = this.getInlineStyleState(line.activeStyleTags || [], {
|
|
italic: lineRecord.fontStyle === 'italic'
|
|
});
|
|
|
|
if ('fontVariantCaps' in ctx) ctx.fontVariantCaps = smallCaps ? 'all-small-caps' : 'normal';
|
|
if ('letterSpacing' in ctx) ctx.letterSpacing = smallCaps ? `${fontPx * 0.012}px` : '0px';
|
|
this.applyTextStyle(ctx, fontPx, smallCaps, baseStyle);
|
|
if (lineRecord.dropCapText) {
|
|
ctx.save();
|
|
const dropCapFontPx = Math.round(fontPx * 2.68);
|
|
const dropCapX = content.x;
|
|
const dropCapY = content.y + (Number(lineRecord.pageLine || 0) * lineHeightPx) + (fontPx * 0.25);
|
|
ctx.font = `${dropCapFontPx}px "EB Garamond Initials", ${metrics.typography.fontFamily}`;
|
|
ctx.textBaseline = 'top';
|
|
ctx.fillText(String(lineRecord.dropCapText), dropCapX, dropCapY);
|
|
ctx.restore();
|
|
if ('fontVariantCaps' in ctx) ctx.fontVariantCaps = smallCaps ? 'all-small-caps' : 'normal';
|
|
if ('letterSpacing' in ctx) ctx.letterSpacing = smallCaps ? `${fontPx * 0.012}px` : '0px';
|
|
this.applyTextStyle(ctx, fontPx, smallCaps, baseStyle);
|
|
}
|
|
this.buildLineSegments(ctx, nodes, line, ratio, baseStyle).forEach((segment) => {
|
|
this.drawWord(ctx, segment, x + segment.x, baseY, lineRecord, segment.wordIndex, side, fontPx, lineHeightPx, smallCaps);
|
|
});
|
|
if ('fontVariantCaps' in ctx) ctx.fontVariantCaps = previousVariantCaps || 'normal';
|
|
if ('letterSpacing' in ctx) ctx.letterSpacing = previousLetterSpacing || '0px';
|
|
}
|
|
|
|
getInlineStyleState(tags = [], base = {}) {
|
|
const state = {
|
|
bold: Boolean(base.bold),
|
|
italic: Boolean(base.italic)
|
|
};
|
|
tags.forEach(tag => {
|
|
if (tag?.bold) state.bold = true;
|
|
if (tag?.italic) state.italic = true;
|
|
});
|
|
return state;
|
|
}
|
|
|
|
updateInlineStyleState(stack = [], value = '') {
|
|
const text = String(value || '');
|
|
if (!text.startsWith('<')) return stack;
|
|
if (text.startsWith('</')) {
|
|
if (stack.length) stack.pop();
|
|
return stack;
|
|
}
|
|
const template = document.createElement('div');
|
|
template.innerHTML = text;
|
|
const element = template.firstElementChild;
|
|
if (!element) return stack;
|
|
const tagName = element.tagName.toLowerCase();
|
|
const style = String(element.getAttribute('style') || '').toLowerCase();
|
|
const className = String(element.getAttribute('class') || '').toLowerCase();
|
|
stack.push({
|
|
tagName,
|
|
bold: tagName === 'strong' || tagName === 'b' || /font-weight\s*:\s*(bold|[6-9]00)/.test(style) || className.includes('bold'),
|
|
italic: tagName === 'em' || tagName === 'i' || /font-style\s*:\s*italic/.test(style) || className.includes('italic')
|
|
});
|
|
return stack;
|
|
}
|
|
|
|
getCanvasFont(fontPx, smallCaps = false, style = {}) {
|
|
const metrics = this.metrics;
|
|
return [
|
|
style.italic ? 'italic' : '',
|
|
smallCaps ? 'small-caps' : '',
|
|
style.bold ? '700' : '',
|
|
`${fontPx}px`,
|
|
metrics.typography.fontFamily
|
|
].filter(Boolean).join(' ');
|
|
}
|
|
|
|
applyTextStyle(ctx, fontPx, smallCaps = false, style = {}) {
|
|
if ('fontVariantCaps' in ctx) ctx.fontVariantCaps = smallCaps ? 'all-small-caps' : 'normal';
|
|
ctx.font = this.getCanvasFont(fontPx, smallCaps, style);
|
|
}
|
|
|
|
getPageContent(side = 'left') {
|
|
return this.metrics?.contentBySide?.[side] || this.metrics?.content || {
|
|
x: 0,
|
|
y: 0,
|
|
width: this.metrics?.width || 1,
|
|
height: this.metrics?.height || 1
|
|
};
|
|
}
|
|
|
|
buildLineSegments(ctx, nodes = [], line = {}, ratio = 0, baseStyle = {}) {
|
|
const segments = [];
|
|
let x = 0;
|
|
let currentSegment = null;
|
|
let previousWasGlue = true;
|
|
let currentWordIndex = -1;
|
|
const styleStack = Array.isArray(line.activeStyleTags) ? line.activeStyleTags.map(tag => ({ ...tag })) : [];
|
|
|
|
nodes.forEach((node, index) => {
|
|
if (!node) return;
|
|
if (node.type === 'box' && node.value) {
|
|
const value = String(node.value);
|
|
const width = Number(node.width || ctx.measureText(value).width || 0);
|
|
const style = this.getInlineStyleState(styleStack, baseStyle);
|
|
if (currentSegment && !previousWasGlue && currentSegment.style.bold === style.bold && currentSegment.style.italic === style.italic) {
|
|
currentSegment.value += value;
|
|
currentSegment.width += width;
|
|
} else {
|
|
if (previousWasGlue) currentWordIndex += 1;
|
|
currentSegment = {
|
|
value,
|
|
x,
|
|
width,
|
|
wordIndex: Math.max(0, currentWordIndex),
|
|
style
|
|
};
|
|
segments.push(currentSegment);
|
|
}
|
|
x += width;
|
|
previousWasGlue = false;
|
|
} 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;
|
|
previousWasGlue = true;
|
|
currentSegment = null;
|
|
} else if (node.type === 'penalty' && node.penalty === 100) {
|
|
const isLineEndHyphen = Boolean(line.hyphenated && index === nodes.length - 1 && currentSegment);
|
|
if (isLineEndHyphen) {
|
|
const hyphenWidth = Number(node.width || ctx.measureText('-').width || 0);
|
|
currentSegment.value += '-';
|
|
currentSegment.width += hyphenWidth;
|
|
x += hyphenWidth;
|
|
}
|
|
previousWasGlue = false;
|
|
} else if (node.type === 'tag') {
|
|
this.updateInlineStyleState(styleStack, node.value);
|
|
}
|
|
});
|
|
|
|
return segments;
|
|
}
|
|
|
|
drawWord(ctx, segment, x, baseY, lineRecord, localWordIndex, side, fontPx, lineHeightPx, smallCaps = false) {
|
|
const value = segment?.value || '';
|
|
this.applyTextStyle(ctx, fontPx, smallCaps, segment?.style || {});
|
|
ctx.fillText(value, x, baseY);
|
|
}
|
|
|
|
buildRevealRegions(side) {
|
|
if (!this.revealPublishBlockIds || !this.metrics) return null;
|
|
const candidates = this.collectRevealRegionCandidates();
|
|
if (!candidates.length) return null;
|
|
const byBlock = candidates.reduce((map, region) => {
|
|
if (!map.has(region.blockId)) map.set(region.blockId, []);
|
|
map.get(region.blockId).push(region);
|
|
return map;
|
|
}, new Map());
|
|
const regions = [];
|
|
byBlock.forEach((blockRegions, blockId) => {
|
|
const animation = this.activeAnimations.get(blockId);
|
|
if (!animation || animation.completed) return;
|
|
regions.push(...this.assignRevealTiming(blockRegions, animation));
|
|
});
|
|
const currentSpreadIndex = Math.max(0, Number(this.currentSpread?.index ?? this.pagination?.currentSpreadIndex ?? 0));
|
|
const sideRegions = regions.filter(region => region.side === side && Math.max(0, Number(region.spreadIndex || 0)) === currentSpreadIndex);
|
|
if (!sideRegions.length) return null;
|
|
const bounds = sideRegions.reduce((box, region) => ({
|
|
x: Math.min(box.x, region.pixelRect.x),
|
|
y: Math.min(box.y, region.pixelRect.y),
|
|
right: Math.max(box.right, region.pixelRect.right),
|
|
bottom: Math.max(box.bottom, region.pixelRect.bottom)
|
|
}), {
|
|
x: this.metrics.width,
|
|
y: this.metrics.height,
|
|
right: 0,
|
|
bottom: 0
|
|
});
|
|
return {
|
|
blockIds: Array.from(byBlock.keys()),
|
|
durationMs: sideRegions.reduce((maxDuration, region) => Math.max(maxDuration, region.timing.delay + region.timing.duration), 0),
|
|
pageFlipAfterReveal: this.shouldFlipAfterSideReveal(side),
|
|
baseCanvas: null,
|
|
lineRects: sideRegions.map(region => ({
|
|
blockId: region.blockId,
|
|
lineIndex: region.lineIndex,
|
|
rect: region.rect,
|
|
timing: region.timing,
|
|
timingArea: region.timingArea || region.area || 0
|
|
})),
|
|
bounds: {
|
|
x: bounds.x / this.metrics.width,
|
|
y: bounds.y / this.metrics.height,
|
|
width: Math.max(0.001, (bounds.right - bounds.x) / this.metrics.width),
|
|
height: Math.max(0.001, (bounds.bottom - bounds.y) / this.metrics.height)
|
|
}
|
|
};
|
|
}
|
|
|
|
shouldFlipAfterSideReveal(side) {
|
|
if (side !== 'right') return false;
|
|
const meta = this.currentSpread?.pageMeta?.right || null;
|
|
if (!meta || meta.section !== 'body' || meta.kind === 'blank' || meta.kind === 'title') return false;
|
|
const rightLines = Array.isArray(this.currentSpread?.right) ? this.currentSpread.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;
|
|
}
|
|
|
|
collectRevealRegionCandidates() {
|
|
const candidates = [];
|
|
const sourceSpreads = [];
|
|
if (this.currentSpread) sourceSpreads.push(this.currentSpread);
|
|
if (Array.isArray(this.pagination?.spreads)) {
|
|
this.pagination.spreads.forEach((spread) => {
|
|
if (!spread) return;
|
|
if (this.currentSpread && Number(spread.index) === Number(this.currentSpread.index)) return;
|
|
sourceSpreads.push(spread);
|
|
});
|
|
}
|
|
if (!sourceSpreads.length) sourceSpreads.push({ index: 0, left: [], right: [] });
|
|
sourceSpreads.forEach((spread) => {
|
|
['left', 'right'].forEach((side) => {
|
|
const spreadLines = Array.isArray(spread?.[side]) ? spread[side] : [];
|
|
spreadLines.forEach((lineRecord) => {
|
|
const region = this.createRevealRegionForLine(side, lineRecord, spread?.index);
|
|
if (region) candidates.push(region);
|
|
});
|
|
});
|
|
});
|
|
return candidates;
|
|
}
|
|
|
|
assignRevealTiming(blockRegions = [], animation = {}) {
|
|
const requestedTotalDuration = Math.max(
|
|
Number(animation.totalDuration || 0),
|
|
...((Array.isArray(animation.wordTimings) ? animation.wordTimings : []).map(timing => Number(timing.delay || 0) + Number(timing.duration || 0)))
|
|
);
|
|
const sortedRegions = [...blockRegions].sort((a, b) => {
|
|
const aSpread = Math.max(0, Number(a.spreadIndex || 0));
|
|
const bSpread = Math.max(0, Number(b.spreadIndex || 0));
|
|
if (aSpread !== bSpread) return aSpread - bSpread;
|
|
const aLine = Math.max(0, Number(a.lineIndex || 0));
|
|
const bLine = Math.max(0, Number(b.lineIndex || 0));
|
|
return aLine - bLine;
|
|
});
|
|
const timedRegions = [];
|
|
const textRegions = sortedRegions.filter(region => !(region.fixedDurationMs > 0));
|
|
const fixedRegions = sortedRegions.filter(region => region.fixedDurationMs > 0);
|
|
const totalArea = textRegions.reduce((sum, region) => sum + Math.max(1, region.timingArea || region.area), 0);
|
|
const lineHeight = Math.max(1, Number(this.metrics?.typographyLineHeightPx || 1));
|
|
const estimatedTextWidth = totalArea / lineHeight;
|
|
const baseDuration = requestedTotalDuration > 1
|
|
? requestedTotalDuration
|
|
: Math.max(800, estimatedTextWidth * 16);
|
|
// Word-proportional scaling: these regions may cover only part of the block (the
|
|
// rest is on another spread this reveal does not include). Reveal only this portion's
|
|
// share of the block TTS, offset by the words before it, so the page reveals at
|
|
// normal pace and flips when its words are spoken — the continuation then resumes on
|
|
// the next spread instead of the page absorbing the whole TTS. When the regions cover
|
|
// the whole block (unified plan or single-page block) this is a no-op.
|
|
const totalBlockWords = Array.isArray(animation.wordTimings) ? animation.wordTimings.length : 0;
|
|
const collectedWords = textRegions.reduce((sum, region) => sum + Math.max(0, Number(region.blockWordCount || 0)), 0);
|
|
const wordsBefore = textRegions.reduce((min, region) => Math.min(min, Math.max(0, Number(region.blockWordStart || 0))), Number.POSITIVE_INFINITY);
|
|
const useWordShare = totalBlockWords > 0 && collectedWords > 0 && collectedWords < totalBlockWords;
|
|
const totalDuration = useWordShare ? baseDuration * (collectedWords / totalBlockWords) : baseDuration;
|
|
let fallbackDelay = useWordShare && Number.isFinite(wordsBefore) ? baseDuration * (wordsBefore / totalBlockWords) : 0;
|
|
textRegions.forEach((region) => {
|
|
const duration = totalArea > 0
|
|
? Math.max(1, totalDuration * (Math.max(1, region.timingArea || region.area) / totalArea))
|
|
: Math.max(1, totalDuration / Math.max(1, textRegions.length));
|
|
timedRegions.push({
|
|
...region,
|
|
timing: { delay: fallbackDelay, duration }
|
|
});
|
|
fallbackDelay += duration;
|
|
});
|
|
|
|
fixedRegions.forEach((region) => {
|
|
timedRegions.push({
|
|
...region,
|
|
timing: {
|
|
delay: fallbackDelay,
|
|
duration: Math.max(1, region.fixedDurationMs)
|
|
}
|
|
});
|
|
fallbackDelay += Math.max(1, region.fixedDurationMs);
|
|
});
|
|
|
|
return timedRegions.sort((a, b) => {
|
|
const aDelay = Number(a.timing?.delay || 0);
|
|
const bDelay = Number(b.timing?.delay || 0);
|
|
if (aDelay !== bDelay) return aDelay - bDelay;
|
|
return Number(a.lineIndex || 0) - Number(b.lineIndex || 0);
|
|
});
|
|
}
|
|
|
|
getLineTimingFromWords(region = {}, wordTimings = []) {
|
|
const start = Math.max(0, Math.floor(Number(region.blockWordStart || 0)));
|
|
const count = Math.max(1, Math.floor(Number(region.blockWordCount || 1)));
|
|
const first = wordTimings[Math.min(start, wordTimings.length - 1)] || { delay: 0, duration: 1 };
|
|
const lastIndex = Math.min(wordTimings.length - 1, start + count - 1);
|
|
const last = wordTimings[lastIndex] || first;
|
|
const delay = Math.max(0, Number(first.delay || 0));
|
|
const end = Math.max(
|
|
delay + 1,
|
|
Number(last.delay || 0) + Math.max(1, Number(last.duration || 1))
|
|
);
|
|
return {
|
|
delay,
|
|
duration: Math.max(1, end - delay)
|
|
};
|
|
}
|
|
|
|
createRevealRegionForLine(side, lineRecord = {}, spreadIndex = null) {
|
|
const blockId = String(lineRecord?.blockId ?? '');
|
|
if (!blockId || !this.revealPublishBlockIds.has(blockId)) return null;
|
|
const animation = this.activeAnimations.get(blockId);
|
|
if (!animation || animation.completed) return null;
|
|
if (lineRecord.type === 'image' || lineRecord.kind === 'image') {
|
|
const content = this.getPageContent(side);
|
|
const rect = lineRecord.metadata?.imageLayout?.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));
|
|
return this.normalizeRevealRegion(side, blockId, lineRecord, x, y, width, height, this.getImageRevealDurationMs(lineRecord), spreadIndex);
|
|
}
|
|
const rect = this.getLineInkRect(side, lineRecord);
|
|
if (!rect) return null;
|
|
return this.normalizeRevealRegion(side, blockId, lineRecord, rect.x, rect.y, rect.width, rect.height, 0, spreadIndex);
|
|
}
|
|
|
|
normalizeRevealRegion(side, blockId, lineRecord, x, y, width, height, fixedDurationMs = 0, spreadIndex = null) {
|
|
const padding = Math.max(2, Number(lineRecord.fontPx || 18) * 0.12);
|
|
const left = Math.max(0, x - padding);
|
|
const top = Math.max(0, y - padding);
|
|
const right = Math.min(this.metrics.width, x + width + padding);
|
|
const bottom = Math.min(this.metrics.height, y + height + padding);
|
|
const rectWidth = Math.max(1, right - left);
|
|
const rectHeight = Math.max(1, bottom - top);
|
|
const timingWidth = Math.max(1, Number(lineRecord.timingWidthPx || width || rectWidth));
|
|
const timingHeight = Math.max(1, Number(lineRecord.timingHeightPx || height || rectHeight));
|
|
return {
|
|
side,
|
|
spreadIndex: Math.max(0, Number((spreadIndex ?? Math.floor(Number(lineRecord.pageIndex || 0) / 2)) || 0)),
|
|
blockId,
|
|
lineIndex: Number(lineRecord.lineIndex ?? lineRecord.pageLine ?? 0),
|
|
blockWordStart: Number(lineRecord.blockWordStart ?? 0),
|
|
blockWordCount: Number(lineRecord.lineWordCount ?? 0),
|
|
fixedDurationMs,
|
|
area: rectWidth * rectHeight,
|
|
timingArea: timingWidth * timingHeight,
|
|
pixelRect: { x: left, y: top, right, bottom },
|
|
rect: {
|
|
x: left / this.metrics.width,
|
|
y: top / this.metrics.height,
|
|
width: Math.max(0.001, rectWidth / this.metrics.width),
|
|
height: Math.max(0.001, rectHeight / this.metrics.height)
|
|
}
|
|
};
|
|
}
|
|
|
|
getLineInkRect(side, lineRecord = {}) {
|
|
const content = this.getPageContent(side);
|
|
const fontPx = Math.max(1, Number(lineRecord.fontPx || 22));
|
|
const lineHeightPx = Math.max(fontPx + 2, Number(lineRecord.lineHeightPx || this.metrics.typographyLineHeightPx || 30));
|
|
const line = lineRecord.line || {};
|
|
const naturalWidth = this.getLineNaturalWidth(line);
|
|
const centerOffset = line.align === 'center'
|
|
? Math.max(0, (content.width - naturalWidth) / 2)
|
|
: Number(line.offset || 0);
|
|
const measuredWidth = Number(line.measure || lineRecord.measure || 0);
|
|
const isJustified = line.align !== 'center' && !line.isFinal;
|
|
let x = content.x + centerOffset;
|
|
let y = content.y + (Number(lineRecord.pageLine || 0) * lineHeightPx);
|
|
let width = Math.max(1, Math.min(content.width - centerOffset, isJustified ? (measuredWidth || content.width - centerOffset) : (naturalWidth || measuredWidth || content.width - centerOffset)));
|
|
let height = lineHeightPx;
|
|
if (lineRecord.dropCapText) {
|
|
const dropCapFontPx = Math.round(fontPx * 2.68);
|
|
const dropCapY = content.y + (Number(lineRecord.pageLine || 0) * lineHeightPx) + (fontPx * 0.25);
|
|
const dropCapWidth = fontPx * 2.9;
|
|
const normalRight = x + width;
|
|
x = Math.min(content.x, x);
|
|
y = Math.min(y, dropCapY);
|
|
width = Math.max(normalRight, content.x + dropCapWidth) - x;
|
|
height = Math.max((content.y + (Number(lineRecord.pageLine || 0) * lineHeightPx)) + lineHeightPx, dropCapY + (dropCapFontPx * 0.9)) - y;
|
|
}
|
|
return { x, y, width, height };
|
|
}
|
|
|
|
getLineNaturalWidth(line = {}) {
|
|
const nodes = Array.isArray(line.nodes) ? line.nodes : [];
|
|
return nodes.reduce((sum, node) => {
|
|
if (node?.type === 'box' || node?.type === 'glue') return sum + Number(node.width || 0);
|
|
return sum;
|
|
}, 0);
|
|
}
|
|
|
|
getLineWordCount(line = {}) {
|
|
const nodes = Array.isArray(line.nodes) ? line.nodes : [];
|
|
let count = 0;
|
|
let previousWasGlue = true;
|
|
nodes.forEach((node) => {
|
|
if (!node) return;
|
|
if (node.type === 'glue') {
|
|
previousWasGlue = true;
|
|
return;
|
|
}
|
|
if (node.type === 'penalty') return;
|
|
if (node.type === 'box' && node.value) {
|
|
if (previousWasGlue) count += 1;
|
|
previousWasGlue = false;
|
|
}
|
|
});
|
|
return count;
|
|
}
|
|
|
|
getImageRevealDurationMs(lineRecord = {}) {
|
|
const metadata = lineRecord.metadata || {};
|
|
const explicit = Number(metadata.animationMs || metadata.revealMs || metadata.imageRevealMs || 0);
|
|
return Number.isFinite(explicit) && explicit > 0 ? explicit : 2000;
|
|
}
|
|
|
|
createAnimationState(blockId, wordTimings = [], detail = {}) {
|
|
return {
|
|
blockId,
|
|
wordTimings,
|
|
startedAt: null,
|
|
totalDuration: Math.max(
|
|
Number(detail.totalDuration || 0),
|
|
...wordTimings.map(timing => Number(timing.delay || 0) + Number(timing.duration || 0))
|
|
),
|
|
completed: false,
|
|
prepared: true
|
|
};
|
|
}
|
|
|
|
prepareRevealBlock(detail = {}, options = {}) {
|
|
const blockId = detail.blockId ?? detail.id ?? null;
|
|
if (blockId == null || !Array.isArray(detail.wordTimings)) return;
|
|
const id = String(blockId);
|
|
const wordTimings = detail.wordTimings;
|
|
const phase = detail.phase === 'prepare' || options.phase === 'prepare'
|
|
? 'prepare'
|
|
: 'activate';
|
|
this.markPipelineTiming('prepareRevealBlock:start', {
|
|
blockId: id,
|
|
wordTimingCount: wordTimings.length,
|
|
phase
|
|
});
|
|
// forceRebuild: the cached plan was built before the block's continuation was
|
|
// committed (it would be right-only). Discard it and redraw from current spreads.
|
|
if (options.forceRebuild === true) this.pageCache?.takePreparedRevealPlan?.(id);
|
|
if (phase === 'activate' && options.forceRebuild !== true && this.pageCache?.hasPreparedRevealPlan?.(id)) {
|
|
const cached = this.pageCache.takePreparedRevealPlan(id);
|
|
this.activeAnimations.set(id, this.createAnimationState(blockId, wordTimings, detail));
|
|
this.publishPreparedReveal(cached, options);
|
|
this.markPipelineTiming('prepareRevealBlock:end', {
|
|
blockId: id,
|
|
wordTimingCount: wordTimings.length,
|
|
reusedPreparedCanvas: true
|
|
});
|
|
return {
|
|
...cached,
|
|
phase: 'activate',
|
|
preparedFromCache: true
|
|
};
|
|
}
|
|
|
|
this.activeAnimations.set(id, this.createAnimationState(blockId, wordTimings, detail));
|
|
this.revealPublishBlockIds = new Set([id]);
|
|
const spread = detail.spread || this.currentSpread || this.pagination?.getCurrentSpread?.();
|
|
const sides = ['left', 'right'];
|
|
const published = this.drawSpread(spread, sides, {
|
|
phase,
|
|
publishEvent: options.publishEvent !== false
|
|
});
|
|
this.preloadAdditionalRevealSpreads(id, spread);
|
|
if (phase === 'prepare' && published) {
|
|
this.pageCache?.rememberPreparedRevealPlan?.(id, {
|
|
...published,
|
|
blockId,
|
|
wordTimings,
|
|
totalDuration: detail.totalDuration || 0
|
|
});
|
|
}
|
|
this.markPipelineTiming('prepareRevealBlock:end', {
|
|
blockId: id,
|
|
wordTimingCount: wordTimings.length,
|
|
phase
|
|
});
|
|
return published ? {
|
|
...published,
|
|
blockId,
|
|
wordTimings,
|
|
totalDuration: detail.totalDuration || 0
|
|
} : null;
|
|
}
|
|
|
|
preloadAdditionalRevealSpreads(blockId, primarySpread = null) {
|
|
const spreads = Array.isArray(this.pagination?.spreads) ? this.pagination.spreads : [];
|
|
if (!spreads.length) return;
|
|
const primaryIndex = Number(primarySpread?.index);
|
|
spreads.forEach((spread) => {
|
|
if (!spread || Number(spread.index) === primaryIndex) return;
|
|
if (!this.spreadContainsBlock(spread, blockId)) return;
|
|
this.drawSpread(spread, ['left', 'right'], { phase: 'prepare' });
|
|
});
|
|
}
|
|
|
|
spreadContainsBlock(spread = {}, blockId = '') {
|
|
const id = String(blockId ?? '');
|
|
return ['left', 'right'].some((side) => {
|
|
const lines = Array.isArray(spread?.[side]) ? spread[side] : [];
|
|
return lines.some(line => String(line?.blockId ?? '') === id);
|
|
});
|
|
}
|
|
|
|
publishPreparedReveal(prepared, options = {}) {
|
|
if (!prepared) return null;
|
|
this.markPipelineTiming('publishPreparedReveal', {
|
|
blockId: prepared.blockId,
|
|
sides: prepared.sides || [],
|
|
hasReveal: Boolean(prepared.reveal && Object.keys(prepared.reveal).length)
|
|
});
|
|
const detail = {
|
|
metrics: prepared.metrics,
|
|
hitMaps: prepared.hitMaps || this.hitMaps,
|
|
records: prepared.records || this.buildPageTextureRecords(prepared.sides || ['left', 'right'], prepared),
|
|
reveal: prepared.reveal || {},
|
|
pageMeta: prepared.pageMeta || {},
|
|
phase: 'activate',
|
|
preparedFromCache: true
|
|
};
|
|
if (options.publishEvent !== false) {
|
|
document.dispatchEvent(new CustomEvent('webgl-book:page-texture-records', { detail }));
|
|
}
|
|
return detail;
|
|
}
|
|
|
|
startPreparedRevealAnimation(blockId, options = {}) {
|
|
const id = String(blockId ?? '');
|
|
const animation = this.activeAnimations.get(id);
|
|
if (!animation) return false;
|
|
this.markPipelineTiming('startPreparedRevealAnimation', {
|
|
blockId: id,
|
|
wordTimingCount: Array.isArray(animation.wordTimings) ? animation.wordTimings.length : 0
|
|
});
|
|
animation.startedAt = performance.now();
|
|
animation.prepared = false;
|
|
animation.completed = false;
|
|
if (options.publishEvent !== false) {
|
|
document.dispatchEvent(new CustomEvent('webgl-book:page-reveal-start', {
|
|
detail: {
|
|
blockId: animation.blockId
|
|
}
|
|
}));
|
|
}
|
|
return {
|
|
blockId: animation.blockId,
|
|
wordTimingCount: Array.isArray(animation.wordTimings) ? animation.wordTimings.length : 0
|
|
};
|
|
}
|
|
|
|
fastForwardAnimations() {
|
|
let changed = false;
|
|
const blockIds = [];
|
|
this.activeAnimations.forEach((animation) => {
|
|
if (!animation.completed) {
|
|
animation.completed = true;
|
|
this.revealedBlockIds.add(String(animation.blockId ?? ''));
|
|
blockIds.push(animation.blockId);
|
|
changed = true;
|
|
}
|
|
});
|
|
if (changed) {
|
|
document.dispatchEvent(new CustomEvent('webgl-book:page-reveal-fast-forward', {
|
|
detail: {
|
|
blockIds
|
|
}
|
|
}));
|
|
}
|
|
}
|
|
|
|
completeRevealBlockIds(blockIds = []) {
|
|
const ids = Array.isArray(blockIds) ? blockIds : [];
|
|
ids.forEach((blockId) => {
|
|
const id = String(blockId ?? '');
|
|
if (!id) return;
|
|
const animation = this.activeAnimations.get(id);
|
|
if (animation) animation.completed = true;
|
|
this.revealedBlockIds.add(id);
|
|
});
|
|
}
|
|
|
|
stopAnimations() {
|
|
this.activeAnimations.clear();
|
|
this.revealedBlockIds.clear();
|
|
this.drawSpread(this.currentSpread || this.pagination?.getCurrentSpread?.());
|
|
}
|
|
|
|
getBlockSides(blockId) {
|
|
const id = String(blockId ?? '');
|
|
const spread = this.currentSpread || this.pagination?.getCurrentSpread?.() || { left: [], right: [] };
|
|
return ['left', 'right'].filter((side) => {
|
|
const lines = Array.isArray(spread?.[side]) ? spread[side] : [];
|
|
return lines.some(line => String(line?.blockId ?? '') === id);
|
|
});
|
|
}
|
|
|
|
getAnimatedSides(includeCompleted = false) {
|
|
const spread = this.currentSpread || this.pagination?.getCurrentSpread?.() || { left: [], right: [] };
|
|
const activeBlockIds = new Set();
|
|
this.activeAnimations.forEach((animation, blockId) => {
|
|
if (includeCompleted || !animation.completed) activeBlockIds.add(String(blockId));
|
|
});
|
|
const sides = ['left', 'right'].filter((side) => {
|
|
const lines = Array.isArray(spread?.[side]) ? spread[side] : [];
|
|
return lines.some(line => activeBlockIds.has(String(line?.blockId ?? '')));
|
|
});
|
|
return sides.length ? sides : ['left', 'right'];
|
|
}
|
|
|
|
publishSpread(sides = null, options = {}) {
|
|
const sidesToPublish = Array.isArray(sides) && sides.length ? sides : ['left', 'right'];
|
|
const phase = this.getDrawPhase(options);
|
|
const regionCounts = {
|
|
left: 0,
|
|
right: 0
|
|
};
|
|
const detail = {
|
|
metrics: this.metrics,
|
|
hitMaps: this.hitMaps,
|
|
sides: sidesToPublish,
|
|
pageMeta: this.buildPublishPageMeta(sidesToPublish),
|
|
phase
|
|
};
|
|
if (sidesToPublish.includes('left')) {
|
|
detail.left = phase === 'prepare' ? this.cloneCanvas(this.canvases.left) : this.canvases.left;
|
|
}
|
|
if (sidesToPublish.includes('right')) {
|
|
detail.right = phase === 'prepare' ? this.cloneCanvas(this.canvases.right) : this.canvases.right;
|
|
}
|
|
const reveal = {};
|
|
sidesToPublish.forEach((side) => {
|
|
const sideReveal = this.buildRevealRegions(side);
|
|
if (!sideReveal) return;
|
|
sideReveal.baseCanvas = phase === 'prepare'
|
|
? this.cloneCanvas(this.revealBaseCanvases?.[side])
|
|
: this.revealBaseCanvases?.[side] || null;
|
|
regionCounts[side] = sideReveal.lineRects.length;
|
|
reveal[side] = sideReveal;
|
|
});
|
|
if (Object.keys(reveal).length) detail.reveal = reveal;
|
|
detail.records = this.buildPageTextureRecords(sidesToPublish, detail);
|
|
this.cachePublishedPages(sidesToPublish, detail);
|
|
this.markPipelineTiming('publishSpread', {
|
|
sides: sidesToPublish,
|
|
hasReveal: Object.keys(reveal).length > 0,
|
|
regionCounts,
|
|
phase
|
|
});
|
|
if (options.publishEvent !== false) {
|
|
document.dispatchEvent(new CustomEvent('webgl-book:page-texture-records', {
|
|
detail
|
|
}));
|
|
}
|
|
return detail;
|
|
}
|
|
|
|
buildPageTextureRecords(sides = [], detail = {}) {
|
|
return sides.map((side) => ({
|
|
side,
|
|
phase: detail.phase || 'activate',
|
|
canvas: detail[side] || null,
|
|
pageMeta: detail.pageMeta?.[side] || null,
|
|
reveal: detail.reveal?.[side] || null,
|
|
state: {
|
|
canvasReady: Boolean(detail[side]),
|
|
vramReady: detail.phase === 'prepare',
|
|
visible: detail.phase !== 'prepare'
|
|
}
|
|
}));
|
|
}
|
|
|
|
buildPublishPageMeta(sides = []) {
|
|
const baseMeta = this.currentSpread?.pageMeta || {};
|
|
const spreadIndex = Math.max(0, Math.round(Number(this.currentSpread?.index || 0)));
|
|
return sides.reduce((meta, side) => {
|
|
const pageIndex = side === 'left' ? spreadIndex * 2 : spreadIndex * 2 + 1;
|
|
const source = baseMeta[side] || {
|
|
kind: 'blank',
|
|
section: pageIndex < 3 ? 'frontmatter' : 'body',
|
|
pageIndex,
|
|
pageNumber: null,
|
|
omitPageNumber: true
|
|
};
|
|
const lines = Array.isArray(this.currentSpread?.[side]) ? this.currentSpread[side] : [];
|
|
const maxBlockId = lines.reduce((max, line) => Math.max(max, Number(line?.blockId || 0)), 0);
|
|
const lineCount = lines.length;
|
|
const normalizedPageIndex = Number(source.pageIndex);
|
|
const key = Number.isFinite(normalizedPageIndex) ? normalizedPageIndex : side;
|
|
const nextVersion = Math.max(1, Number(this.pageContentVersions.get(key) || 0) + 1);
|
|
this.pageContentVersions.set(key, nextVersion);
|
|
meta[side] = {
|
|
...source,
|
|
pageIndex: Number.isFinite(normalizedPageIndex) ? normalizedPageIndex : pageIndex,
|
|
contentVersion: nextVersion,
|
|
completenessScore: (maxBlockId * 1000) + lineCount,
|
|
maxBlockId,
|
|
lineCount
|
|
};
|
|
return meta;
|
|
}, {});
|
|
}
|
|
|
|
cachePublishedPages(sides = [], detail = {}) {
|
|
if (!this.pageCache || typeof this.pageCache.storePageCanvas !== 'function') return;
|
|
sides.forEach((side) => {
|
|
const canvas = detail[side];
|
|
const pageMeta = detail.pageMeta?.[side] || null;
|
|
if (!canvas || !pageMeta || !Number.isFinite(Number(pageMeta.pageIndex))) return;
|
|
this.pageCache.storePageCanvas(pageMeta, canvas, { persist: true, resident: true });
|
|
});
|
|
}
|
|
|
|
getPageCanvas(side) {
|
|
return this.canvases[side] || null;
|
|
}
|
|
|
|
getHitMap(side) {
|
|
return this.hitMaps[side] || [];
|
|
}
|
|
|
|
handlePageCountChanged(event) {
|
|
this.pageFormat?.setPageCount?.(event.detail?.pageCount);
|
|
this.createPageCanvases();
|
|
this.lastDrawSignature = null;
|
|
this.drawSpread(this.currentSpread || this.pagination?.getCurrentSpread?.());
|
|
}
|
|
|
|
}
|
|
|
|
const bookTextureRenderer = new BookTextureRendererModule();
|
|
|
|
export { bookTextureRenderer as BookTextureRenderer };
|
|
|
|
if (window.moduleRegistry) {
|
|
window.moduleRegistry.register(bookTextureRenderer);
|
|
}
|
|
|
|
window.BookTextureRenderer = bookTextureRenderer;
|