435 lines
17 KiB
JavaScript
435 lines
17 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'];
|
|
this.pageFormat = null;
|
|
this.pagination = null;
|
|
this.localization = 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.animationFrameId = null;
|
|
this.lastAnimationFrameAt = 0;
|
|
this.targetFrameDurationMs = 1000 / 30;
|
|
|
|
this.bindMethods([
|
|
'initialize',
|
|
'createPageCanvases',
|
|
'drawEmptySpread',
|
|
'drawSpread',
|
|
'drawPageBase',
|
|
'drawPageLines',
|
|
'drawLine',
|
|
'drawWord',
|
|
'getPageContent',
|
|
'buildLineSegments',
|
|
'startRevealAnimation',
|
|
'fastForwardAnimations',
|
|
'stopAnimations',
|
|
'getBlockSides',
|
|
'getAnimatedSides',
|
|
'markPendingReveal',
|
|
'requestAnimationFrame',
|
|
'tickAnimations',
|
|
'publishSpread',
|
|
'getPageCanvas',
|
|
'getHitMap',
|
|
'handlePageCountChanged',
|
|
'handleSceneReady'
|
|
]);
|
|
}
|
|
|
|
async initialize() {
|
|
this.pageFormat = this.getModule('book-page-format');
|
|
this.pagination = this.getModule('book-pagination');
|
|
this.localization = this.getModule('localization');
|
|
this.reportProgress(10, 'Waiting for book fonts');
|
|
if (document.fonts?.ready) await document.fonts.ready;
|
|
this.reportProgress(20, 'Preparing page texture canvases');
|
|
this.createPageCanvases();
|
|
this.drawEmptySpread();
|
|
this.addEventListener(document, 'webgl-book:page-count-changed', this.handlePageCountChanged);
|
|
this.addEventListener(document, 'webgl-book:scene-ready', this.handleSceneReady);
|
|
this.addEventListener(document, 'book-pagination:spread-updated', (event) => {
|
|
const latestBlockId = event.detail?.latestBlockId;
|
|
const latestRenderedBlockId = Math.max(0, Number(event.detail?.latestRenderedBlockId || 0));
|
|
if (latestBlockId && Number(latestBlockId) > latestRenderedBlockId) this.markPendingReveal(latestBlockId);
|
|
this.drawSpread(event.detail?.spread || this.pagination?.getCurrentSpread?.());
|
|
});
|
|
this.addEventListener(document, 'book-texture:reveal-block', (event) => {
|
|
this.startRevealAnimation(event.detail || {});
|
|
});
|
|
this.addEventListener(document, 'book-texture:fast-forward', this.fastForwardAnimations);
|
|
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.reportProgress(100, 'Book texture renderer ready');
|
|
return true;
|
|
}
|
|
|
|
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');
|
|
});
|
|
}
|
|
|
|
drawEmptySpread() {
|
|
this.drawPageBase('left');
|
|
this.drawPageBase('right');
|
|
this.publishSpread();
|
|
}
|
|
|
|
drawSpread(spread = null, sides = null) {
|
|
this.currentSpread = spread || { left: [], right: [] };
|
|
const sidesToDraw = Array.isArray(sides) && sides.length ? sides : ['left', 'right'];
|
|
sidesToDraw.forEach((side) => {
|
|
if (!this.canvases[side]) return;
|
|
this.drawPageBase(side);
|
|
this.drawPageLines(side, this.currentSpread?.[side] || []);
|
|
});
|
|
this.publishSpread(sidesToDraw);
|
|
}
|
|
|
|
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] = [];
|
|
}
|
|
|
|
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 => this.drawLine(ctx, line, side));
|
|
ctx.restore();
|
|
}
|
|
|
|
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 fontStyle = lineRecord.fontStyle === 'italic' ? 'italic ' : '';
|
|
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;
|
|
|
|
if ('fontVariantCaps' in ctx) ctx.fontVariantCaps = smallCaps ? 'all-small-caps' : 'normal';
|
|
if ('letterSpacing' in ctx) ctx.letterSpacing = smallCaps ? `${fontPx * 0.012}px` : '0px';
|
|
ctx.font = `${fontStyle}${smallCaps ? 'small-caps ' : ''}${fontPx}px ${metrics.typography.fontFamily}`;
|
|
if (lineRecord.dropCapText) {
|
|
ctx.save();
|
|
const alpha = this.getWordAlpha(lineRecord, 0);
|
|
if (alpha <= 0) {
|
|
ctx.restore();
|
|
} else {
|
|
ctx.globalAlpha *= alpha;
|
|
ctx.font = `${Math.round(fontPx * 2.68)}px "EB Garamond Initials", ${metrics.typography.fontFamily}`;
|
|
ctx.textBaseline = 'top';
|
|
ctx.fillText(
|
|
String(lineRecord.dropCapText),
|
|
content.x,
|
|
content.y + (Number(lineRecord.pageLine || 0) * lineHeightPx) + (fontPx * 0.25)
|
|
);
|
|
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';
|
|
ctx.font = `${fontStyle}${smallCaps ? 'small-caps ' : ''}${fontPx}px ${metrics.typography.fontFamily}`;
|
|
}
|
|
this.buildLineSegments(ctx, nodes, line, ratio).forEach((segment) => {
|
|
this.drawWord(ctx, segment.value, x + segment.x, baseY, lineRecord, segment.wordIndex);
|
|
});
|
|
if ('fontVariantCaps' in ctx) ctx.fontVariantCaps = previousVariantCaps || 'normal';
|
|
if ('letterSpacing' in ctx) ctx.letterSpacing = previousLetterSpacing || '0px';
|
|
}
|
|
|
|
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) {
|
|
const segments = [];
|
|
let x = 0;
|
|
let currentSegment = null;
|
|
let previousWasGlue = true;
|
|
|
|
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);
|
|
if (currentSegment && !previousWasGlue) {
|
|
currentSegment.value += value;
|
|
currentSegment.width += width;
|
|
} else {
|
|
currentSegment = {
|
|
value,
|
|
x,
|
|
width,
|
|
wordIndex: segments.length
|
|
};
|
|
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;
|
|
}
|
|
});
|
|
|
|
return segments;
|
|
}
|
|
|
|
drawWord(ctx, value, x, baseY, lineRecord, localWordIndex) {
|
|
const alpha = this.getWordAlpha(lineRecord, localWordIndex);
|
|
if (alpha <= 0) return;
|
|
const previousAlpha = ctx.globalAlpha;
|
|
ctx.globalAlpha = previousAlpha * alpha;
|
|
ctx.fillText(value, x, baseY);
|
|
ctx.globalAlpha = previousAlpha;
|
|
}
|
|
|
|
getWordAlpha(lineRecord, localWordIndex) {
|
|
const animation = this.activeAnimations.get(String(lineRecord.blockId ?? ''));
|
|
if (!animation) {
|
|
return 1;
|
|
}
|
|
|
|
const globalWordIndex = Number(lineRecord.blockWordStart || 0) + localWordIndex;
|
|
const timing = animation.wordTimings[globalWordIndex];
|
|
if (!timing) {
|
|
return animation.completed ? 1 : 0;
|
|
}
|
|
|
|
const elapsed = animation.completed
|
|
? Number.POSITIVE_INFINITY
|
|
: performance.now() - animation.startedAt;
|
|
const duration = Math.max(1, Number(timing.duration || 1));
|
|
const progress = Math.max(0, Math.min(1, (elapsed - Number(timing.delay || 0)) / duration));
|
|
return progress;
|
|
}
|
|
|
|
startRevealAnimation(detail = {}) {
|
|
const blockId = detail.blockId ?? detail.id ?? null;
|
|
if (blockId == null || !Array.isArray(detail.wordTimings)) return;
|
|
this.activeAnimations.set(String(blockId), {
|
|
blockId,
|
|
wordTimings: detail.wordTimings,
|
|
startedAt: performance.now(),
|
|
completed: false
|
|
});
|
|
this.drawSpread(this.currentSpread || this.pagination?.getCurrentSpread?.(), this.getBlockSides(blockId));
|
|
this.requestAnimationFrame();
|
|
}
|
|
|
|
fastForwardAnimations() {
|
|
let changed = false;
|
|
this.activeAnimations.forEach((animation) => {
|
|
if (!animation.completed) {
|
|
animation.completed = true;
|
|
this.revealedBlockIds.add(String(animation.blockId ?? ''));
|
|
changed = true;
|
|
}
|
|
});
|
|
if (changed) {
|
|
this.drawSpread(this.currentSpread || this.pagination?.getCurrentSpread?.(), this.getAnimatedSides(true));
|
|
}
|
|
}
|
|
|
|
stopAnimations() {
|
|
this.activeAnimations.clear();
|
|
if (this.animationFrameId) {
|
|
clearTimeout(this.animationFrameId);
|
|
this.animationFrameId = null;
|
|
}
|
|
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'];
|
|
}
|
|
|
|
markPendingReveal(blockId) {
|
|
const id = String(blockId ?? '');
|
|
if (!id || this.activeAnimations.has(id) || this.revealedBlockIds.has(id)) return;
|
|
this.activeAnimations.set(id, {
|
|
blockId,
|
|
wordTimings: [],
|
|
startedAt: performance.now(),
|
|
completed: false
|
|
});
|
|
}
|
|
|
|
requestAnimationFrame() {
|
|
if (this.animationFrameId) return;
|
|
this.animationFrameId = window.setTimeout(() => this.tickAnimations(performance.now()), this.targetFrameDurationMs);
|
|
}
|
|
|
|
tickAnimations(now) {
|
|
this.animationFrameId = null;
|
|
if (now - this.lastAnimationFrameAt < this.targetFrameDurationMs) {
|
|
this.requestAnimationFrame();
|
|
return;
|
|
}
|
|
this.lastAnimationFrameAt = now;
|
|
|
|
let hasActive = false;
|
|
const currentNow = performance.now();
|
|
this.activeAnimations.forEach((animation) => {
|
|
if (animation.completed) return;
|
|
if (!Array.isArray(animation.wordTimings) || animation.wordTimings.length === 0) return;
|
|
const lastTiming = animation.wordTimings.at(-1);
|
|
const total = Number(lastTiming?.delay || 0) + Number(lastTiming?.duration || 0);
|
|
if (currentNow - animation.startedAt >= total + 50) {
|
|
animation.completed = true;
|
|
this.revealedBlockIds.add(String(animation.blockId ?? ''));
|
|
} else {
|
|
hasActive = true;
|
|
}
|
|
});
|
|
this.drawSpread(this.currentSpread || this.pagination?.getCurrentSpread?.(), this.getAnimatedSides(true));
|
|
if (hasActive) this.requestAnimationFrame();
|
|
}
|
|
|
|
publishSpread(sides = null) {
|
|
const sidesToPublish = Array.isArray(sides) && sides.length ? sides : ['left', 'right'];
|
|
const detail = {
|
|
metrics: this.metrics,
|
|
hitMaps: this.hitMaps
|
|
};
|
|
if (sidesToPublish.includes('left')) detail.left = this.canvases.left;
|
|
if (sidesToPublish.includes('right')) detail.right = this.canvases.right;
|
|
document.dispatchEvent(new CustomEvent('webgl-book:page-canvases', {
|
|
detail
|
|
}));
|
|
}
|
|
|
|
getPageCanvas(side) {
|
|
return this.canvases[side] || null;
|
|
}
|
|
|
|
getHitMap(side) {
|
|
return this.hitMaps[side] || [];
|
|
}
|
|
|
|
handlePageCountChanged(event) {
|
|
this.pageFormat?.setPageCount?.(event.detail?.pageCount);
|
|
this.createPageCanvases();
|
|
this.drawSpread(this.currentSpread || this.pagination?.getCurrentSpread?.());
|
|
}
|
|
|
|
handleSceneReady() {
|
|
this.publishSpread();
|
|
}
|
|
}
|
|
|
|
const bookTextureRenderer = new BookTextureRendererModule();
|
|
|
|
export { bookTextureRenderer as BookTextureRenderer };
|
|
|
|
if (window.moduleRegistry) {
|
|
window.moduleRegistry.register(bookTextureRenderer);
|
|
}
|
|
|
|
window.BookTextureRenderer = bookTextureRenderer;
|