/** * 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.animationFrameId = null; this.lastAnimationFrameAt = 0; this.targetFrameDurationMs = 1000 / 30; this.bindMethods([ 'initialize', 'createPageCanvases', 'drawEmptySpread', 'drawSpread', 'drawPageBase', 'drawPageLines', 'drawLine', 'drawWord', 'startRevealAnimation', 'fastForwardAnimations', 'stopAnimations', 'requestAnimationFrame', 'tickAnimations', 'publishSpread', 'getPageCanvas', 'getHitMap', 'handleSceneReady' ]); } async initialize() { this.pageFormat = this.getModule('book-page-format'); this.pagination = this.getModule('book-pagination'); this.localization = this.getModule('localization'); this.reportProgress(20, 'Preparing page texture canvases'); this.createPageCanvases(); this.drawEmptySpread(); 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.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 = 1280) { 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) { this.currentSpread = spread || { left: [], right: [] }; this.drawPageBase('left'); this.drawPageBase('right'); this.drawPageLines('left', this.currentSpread?.left || []); this.drawPageLines('right', this.currentSpread?.right || []); this.publishSpread(); } 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 = '#fff7dc'; 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.10)'); shade.addColorStop(0.78, 'rgba(255, 255, 255, 0)'); shade.addColorStop(1, 'rgba(82, 42, 14, 0.16)'); } else { shade.addColorStop(0, 'rgba(82, 42, 14, 0.16)'); shade.addColorStop(0.22, 'rgba(255, 255, 255, 0)'); shade.addColorStop(1, 'rgba(255, 255, 255, 0.10)'); } 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)); 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 fontStyle = lineRecord.fontStyle === 'italic' ? 'italic ' : ''; 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; let wordIndex = 0; ctx.font = `${fontStyle}${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 ? '-' : ''}`; this.drawWord(ctx, value, x, baseY, lineRecord, wordIndex); x += Number(node.width || ctx.measureText(value).width || 0); wordIndex += 1; } 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; } }); } drawWord(ctx, value, x, baseY, lineRecord, localWordIndex) { const animation = this.activeAnimations.get(String(lineRecord.blockId ?? '')); if (!animation) { ctx.globalAlpha = 1; ctx.fillText(value, x, baseY); return; } const globalWordIndex = Number(lineRecord.blockWordStart || 0) + localWordIndex; const timing = animation.wordTimings[globalWordIndex]; if (!timing) { ctx.globalAlpha = animation.completed ? 1 : 0; ctx.fillText(value, x, baseY); ctx.globalAlpha = 1; return; } 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)); if (progress <= 0) return; const previousAlpha = ctx.globalAlpha; ctx.globalAlpha = previousAlpha * progress; ctx.fillText(value, x, baseY); ctx.globalAlpha = previousAlpha; } 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.requestAnimationFrame(); } fastForwardAnimations() { let changed = false; this.activeAnimations.forEach((animation) => { if (!animation.completed) { animation.completed = true; changed = true; } }); if (changed) { this.drawSpread(this.currentSpread || this.pagination?.getCurrentSpread?.()); } } stopAnimations() { this.activeAnimations.clear(); if (this.animationFrameId) { clearTimeout(this.animationFrameId); this.animationFrameId = null; } this.drawSpread(this.currentSpread || this.pagination?.getCurrentSpread?.()); } 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; 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; } else { hasActive = true; } }); this.drawSpread(this.currentSpread || this.pagination?.getCurrentSpread?.()); if (hasActive) this.requestAnimationFrame(); } publishSpread() { document.dispatchEvent(new CustomEvent('webgl-book:page-canvases', { detail: { left: this.canvases.left, right: this.canvases.right, metrics: this.metrics, hitMaps: this.hitMaps } })); } getPageCanvas(side) { return this.canvases[side] || null; } getHitMap(side) { return this.hitMaps[side] || []; } handleSceneReady() { this.publishSpread(); } } const bookTextureRenderer = new BookTextureRendererModule(); export { bookTextureRenderer as BookTextureRenderer }; if (window.moduleRegistry) { window.moduleRegistry.register(bookTextureRenderer); } window.BookTextureRenderer = bookTextureRenderer;