/** * 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, fontStyle: layout.fontStyle }); 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 lineHeightPx = Math.max(1, Number(this.metrics.typographyLineHeightPx || 1)); const fontPx = Math.max(1, Number(this.metrics.bodyFontSizePx || lineHeightPx / 1.5)); 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, fontStyle: isHeading ? 'italic' : 'normal', 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;