/** * UI Display Handler Module * Manages the display of text and UI elements */ import { BaseModule } from './base-module.js'; class UIDisplayHandlerModule extends BaseModule { constructor() { super('ui-display-handler', 'UI Display Handler'); // Module dependencies this.dependencies = ['layout-renderer', 'playback-coordinator', 'game-config', 'localization', 'story-history', 'sentence-queue', 'persistence-manager']; // DOM elements this.container = null; this.pageLeft = null; this.pageRight = null; this.paragraphContainer = null; this.renderedItems = []; this.resizeTimer = null; this.storyResizeObserver = null; this.lastStoryMetrics = null; this.visibleBlockLimit = 41; this.historyBufferBlocks = 20; this.historyWindowStartId = 1; this.historyWindowEndId = 0; this.loadingHistoryPage = false; this.draggingStoryScrollbar = false; this.pendingHistoryWindowRequest = null; this.historyWheelAccumulator = 0; this.storyTopLine = 0; this.storyOffsetPx = 0; this.storyScrollAnimation = null; this.storyScrollAnimationId = 0; this.viewportLineCount = 1; this.lineHeightPx = 24; this.activeCenterBlockId = null; this.historyEnsurePending = false; this.lastEnsuredCenterBucket = null; // Resources to preload this.cssPath = '/css/style.css'; this.imagesToPreload = [ '/images/book-3057904.png', '/images/brown-wooden-flooring.jpg' ]; // Bind methods using parent's bindMethods utility this.bindMethods([ 'initializeContainers', 'applyGameConfig', 'applyTranslations', 'renderSentence', 'markBlockRendered', 'trimVisibleBlocks', 'restoreFromHistory', 'renderStoredItem', 'renderHistoryWindow', 'renderHistoryWindowForTurn', 'loadPreviousHistoryPage', 'loadNextHistoryPage', 'loadHistoryWindowAt', 'shiftHistoryWindow', 'loadAdjacentHistoryBlock', 'insertStoredElement', 'trimVirtualWindow', 'handleHistoryWheel', 'disableAutoplayForManualScroll', 'updateStoryScrollbar', 'handleStoryScrollbarPointer', 'handleDeferredMediaBlock', 'renderImageBlock', 'revealImageBlock', 'resolveImageUrl', 'calculateImageMetrics', 'measureStoryLineHeight', 'measureBlockLines', 'recordRenderedMetrics', 'setVirtualPadding', 'setStoryOffset', 'scrollStoryByLines', 'setStoryTopLine', 'getMaxStoryTopLine', 'ensureLiveTailWindow', 'ensureHistoryWindowForLine', 'loadHistoryWindowAround', 'readFirstFiniteNumber', 'waitForSkippablePause', 'scrollStoryToEnd', 'animateStoryOffset', 'scrollToTurn', 'handleStoryScroll', 'rerenderStory', 'clear', 'scheduleRerender', 'measureText', 'loadCSS', 'showChoices', 'preloadImages' ]); console.log('UIDisplayHandler: Constructor initialized'); } t(key, params = {}) { return this.localization?.translate?.(key, params) || key; } async initialize() { try { this.reportProgress(10, "Initializing UI Display Handler"); // Load CSS and preload images this.reportProgress(20, "Loading CSS and preloading images"); await this.loadCSS(this.cssPath); await this.preloadImages(this.imagesToPreload); this.reportProgress(30, "Getting module dependencies"); // Get references to required modules using parent's getModule method this.layoutRenderer = this.getModule('layout-renderer'); this.playbackCoordinator = this.getModule('playback-coordinator'); this.gameConfig = this.getModule('game-config'); this.localization = this.getModule('localization'); this.storyHistory = this.getModule('story-history'); this.persistenceManager = this.getModule('persistence-manager'); this.reportProgress(50, "Initializing display containers"); // Initialize container elements this.initializeContainers(); this.reportProgress(70, "Setting up typography"); this.reportProgress(90, "Setting up event listeners"); this.addEventListener(document, 'book:resized', () => { this.scheduleRerender(); }); this.addEventListener(document, 'game:config', (event) => { this.applyGameConfig(event.detail); }); this.addEventListener(document, 'localization:languageChanged', () => { this.applyTranslations(); }); this.addEventListener(document, 'story:scroll-to-turn', (event) => { this.scrollToTurn(event.detail?.turnId); }); this.addEventListener(document, 'story:history-updated', (event) => { this.updateStoryScrollbar(event.detail || {}); }); this.addEventListener(document, 'wheel', this.handleHistoryWheel, { passive: false }); this.addEventListener(document, 'keydown', (event) => { const tagName = String(event.target?.tagName || '').toLowerCase(); if (['input', 'textarea', 'select'].includes(tagName) || event.altKey || event.ctrlKey || event.metaKey) { return; } if (event.key === 'ArrowUp') { event.preventDefault(); this.disableAutoplayForManualScroll(); this.scrollStoryByLines(-3, true); } else if (event.key === 'ArrowDown') { event.preventDefault(); this.disableAutoplayForManualScroll(); this.scrollStoryByLines(3, true); } }); this.addEventListener(document, 'story:process-state', (event) => { const state = event.detail?.state || 'ready'; const remark = document.getElementById('remark_text'); if (remark) { remark.textContent = state === 'paused' ? this.t('title.continueHint') : this.t('title.fastForwardHint'); } }); if (window.ResizeObserver && this.paragraphContainer) { this.storyResizeObserver = new ResizeObserver((entries) => { const entry = entries[0]; if (!entry) { return; } const computedStyle = window.getComputedStyle(this.paragraphContainer); const metrics = { width: Math.round(entry.contentRect.width), fontSize: computedStyle.fontSize, lineHeight: computedStyle.lineHeight }; if (!this.lastStoryMetrics) { this.lastStoryMetrics = metrics; return; } const changed = metrics.width !== this.lastStoryMetrics.width || metrics.fontSize !== this.lastStoryMetrics.fontSize || metrics.lineHeight !== this.lastStoryMetrics.lineHeight; this.lastStoryMetrics = metrics; if (changed) { this.scheduleRerender(); } }); this.storyResizeObserver.observe(this.paragraphContainer); } this.reportProgress(100, "UI Display Handler ready"); return true; } catch (error) { console.error("Error initializing UI Display Handler:", error); return false; } } scheduleRerender() { clearTimeout(this.resizeTimer); this.resizeTimer = setTimeout(() => this.rerenderStory(), 80); } /** * Load CSS file asynchronously and wait for it to be applied * @param {string} cssPath - Path to CSS file * @returns {Promise} */ loadCSS(cssPath) { return new Promise((resolve, reject) => { // Create link element const link = document.createElement('link'); link.rel = 'stylesheet'; link.href = cssPath; // Set up load and error handlers link.onload = () => { console.log(`UIDisplayHandler: CSS ${cssPath} loaded successfully`); resolve(); }; link.onerror = (error) => { console.error(`UIDisplayHandler: Failed to load CSS ${cssPath}:`, error); reject(error); }; // Add to document head document.head.appendChild(link); }); } /** * Preload images to ensure they're in the cache * @param {Array} imagePaths - Array of image paths to preload * @returns {Promise} */ preloadImages(imagePaths) { if (!imagePaths || imagePaths.length === 0) { return Promise.resolve(); } const promises = imagePaths.map(path => { return new Promise((resolve) => { const img = new Image(); img.onload = () => resolve(); img.onerror = () => { console.warn(`UIDisplayHandler: Failed to preload image ${path}`); resolve(); // Resolve anyway to not block loading }; img.src = path; }); }); return Promise.all(promises); } /** * Initialize the UI containers */ initializeContainers() { // Check if the book container already exists let bookContainer = document.getElementById('book'); if (!bookContainer) { console.log('UIDisplayHandler: Book container not found, creating it'); bookContainer = document.createElement('div'); bookContainer.id = 'book'; document.body.appendChild(bookContainer); } // Create or find page_left this.pageLeft = document.getElementById('page_left'); if (!this.pageLeft) { console.log('UIDisplayHandler: Left page not found, creating it'); this.pageLeft = document.createElement('div'); this.pageLeft.id = 'page_left'; // Create header content const header = document.createElement('div'); header.className = 'header'; header.innerHTML = `

`; this.pageLeft.appendChild(header); // Create controls const controls = document.createElement('div'); controls.id = 'controls'; controls.className = 'buttons'; controls.innerHTML = ` * `; this.pageLeft.appendChild(controls); // Create choices container const choicesContainer = document.createElement('div'); choicesContainer.id = 'choices'; choicesContainer.className = 'container'; // Create command history container const commandHistory = document.createElement('div'); commandHistory.id = 'command_history'; choicesContainer.appendChild(commandHistory); // Create command input container const commandInput = document.createElement('div'); commandInput.id = 'command_input'; commandInput.innerHTML = `
`; choicesContainer.appendChild(commandInput); this.pageLeft.appendChild(choicesContainer); // Create remark const remark = document.createElement('div'); remark.id = 'remark'; remark.innerHTML = `
*
`; this.pageLeft.appendChild(remark); bookContainer.appendChild(this.pageLeft); } // Create or find page_right this.pageRight = document.getElementById('page_right'); if (!this.pageRight) { console.log('UIDisplayHandler: Right page not found, creating it'); this.pageRight = document.createElement('div'); this.pageRight.id = 'page_right'; bookContainer.appendChild(this.pageRight); } if (!document.getElementById('story_scrollbar')) { const storyScrollbar = document.createElement('div'); storyScrollbar.id = 'story_scrollbar'; storyScrollbar.innerHTML = '
'; this.pageRight.appendChild(storyScrollbar); } const storyScrollbar = document.getElementById('story_scrollbar'); if (storyScrollbar && !storyScrollbar.dataset.historyScrollBound) { storyScrollbar.dataset.historyScrollBound = 'true'; ['pointerdown', 'pointerup', 'mousedown', 'mouseup', 'click', 'dblclick'].forEach((type) => { storyScrollbar.addEventListener(type, (event) => { event.preventDefault(); event.stopPropagation(); if (type === 'pointerdown') { this.handleStoryScrollbarPointer(event); } if (typeof event.stopImmediatePropagation === 'function') { event.stopImmediatePropagation(); } }, true); }); storyScrollbar.addEventListener('wheel', (event) => { event.preventDefault(); event.stopPropagation(); this.handleHistoryWheel(event); }, { passive: false }); } // Create or find story container this.container = document.getElementById('story'); if (!this.container) { console.log('UIDisplayHandler: Story container not found, creating it'); this.container = document.createElement('div'); this.container.id = 'story'; this.container.className = 'container'; this.pageRight.appendChild(this.container); } if (!document.getElementById('start_prompt')) { const startPrompt = document.createElement('div'); startPrompt.id = 'start_prompt'; this.pageRight.appendChild(startPrompt); } // Create paragraph container inside story container this.paragraphContainer = document.getElementById('paragraphs'); if (!this.paragraphContainer) { console.log('UIDisplayHandler: Paragraphs container not found, creating it'); this.paragraphContainer = document.createElement('div'); this.paragraphContainer.id = 'paragraphs'; this.container.appendChild(this.paragraphContainer); } // Create ruler for text measurements let ruler = document.getElementById('ruler'); if (!ruler) { ruler = document.createElement('div'); ruler.id = 'ruler'; document.body.appendChild(ruler); } // Create lighting effect let lighting = document.getElementById('lighting'); if (!lighting) { lighting = document.createElement('div'); lighting.id = 'lighting'; document.body.appendChild(lighting); } console.log('UIDisplayHandler: All containers initialized'); this.applyGameConfig(this.gameConfig?.getConfig?.()); this.applyTranslations(); this.measureStoryLineHeight(); this.setStoryOffset(0); } applyGameConfig(config) { const metadata = config?.metadata || this.gameConfig?.getMetadata?.() || {}; const titleElement = document.getElementById('game_title'); const authorElement = document.getElementById('game_author'); const subtitleElement = document.getElementById('game_subtitle'); const legalElement = document.getElementById('game_legal'); document.getElementById('game_version')?.remove(); document.getElementById('game_copyright')?.remove(); if (titleElement) titleElement.textContent = metadata.title || ''; if (authorElement) authorElement.textContent = metadata.author ? this.t('title.byAuthor', { author: metadata.author }) : ''; if (subtitleElement) subtitleElement.textContent = metadata.subtitle || ''; if (legalElement) { const items = [ metadata.version ? this.t('title.version', { version: metadata.version }) : '', metadata.copyright || '' ].filter(Boolean); legalElement.textContent = items.join(' · '); } } applyTranslations() { this.localization = this.getModule('localization') || this.localization; const setText = (id, key) => { const element = document.getElementById(id); if (element) element.textContent = this.t(key); }; const setTitle = (id, key) => { const element = document.getElementById(id); if (element) element.setAttribute('title', this.t(key)); }; setText('speech', 'topbar.speech'); setText('autoplay', 'topbar.autoplay'); setText('speed_label', 'topbar.speed'); setText('rewind', 'topbar.newGame'); setText('save', 'topbar.save'); setText('reload', 'topbar.load'); setText('options', 'topbar.options'); setText('remark_text', 'title.fastForwardHint'); setText('start_prompt', 'title.startPrompt'); setTitle('speech', 'topbar.speechTitle'); setTitle('autoplay', 'topbar.autoplayTitle'); setTitle('rewind', 'topbar.newGameTitle'); setTitle('save', 'topbar.saveTitle'); setTitle('reload', 'topbar.loadTitle'); setTitle('options', 'topbar.optionsTitle'); const input = document.getElementById('player_input'); if (input) input.setAttribute('placeholder', this.t('input.placeholder')); this.applyGameConfig(this.gameConfig?.getConfig?.()); } /** * Measure text width using canvas * @param {string} text - Text to measure * @returns {number} - Width of the text */ measureText(text) { // Use ParagraphLayout's measureText function instead of implementing our own if (this.paragraphLayout && typeof this.paragraphLayout.measureText === 'function') { return this.paragraphLayout.measureText(text); } // Fallback measuring if paragraph layout is not available if (!this.canvas) { this.canvas = document.createElement('canvas'); this.context = this.canvas.getContext('2d'); this.context.font = `${this.config.typography.fontSize} ${this.config.typography.fontFamily}`; } return this.context.measureText(text).width; } /** * Display a local UI message outside the server turn protocol. * Story output must flow through structured TurnResult objects instead. */ /** * Render a prepared sentence to the display * @param {Object} sentence - Prepared sentence object from SentenceQueue * @returns {Promise} - Promise resolving to the paragraph element */ async renderSentence(sentence) { if (!sentence || !sentence.layout) { if (sentence && (sentence.kind === 'image' || sentence.kind === 'music')) { return this.handleDeferredMediaBlock(sentence); } console.error('UIDisplayHandler: Invalid sentence object'); return null; } try { await this.ensureLiveTailWindow(); // Render DOM from layout data const paragraphElement = this.layoutRenderer.renderParagraph( sentence.layout, { id: sentence.id } ); if (sentence.turnId != null) { paragraphElement.dataset.turnId = String(sentence.turnId); paragraphElement.classList.add('story-turn-block'); } if (sentence.blockId != null) { paragraphElement.dataset.storyBlockId = String(sentence.blockId); this.markBlockRendered(sentence.blockId); } const renderedItem = { type: sentence.kind === 'heading' ? 'heading' : 'paragraph', id: sentence.id, turnId: sentence.turnId ?? null, blockId: sentence.blockId ?? null, gameId: sentence.gameId ?? null, text: sentence.text, metadata: { layoutText: sentence.layout?.sourceLayoutText || sentence.text, cueMarkers: sentence.cueMarkers || [], role: sentence.role || 'body', isFirstParagraphInChapter: sentence.isFirstParagraphInChapter, dropCap: sentence.dropCap, addTopSpace: sentence.addTopSpace, paragraphIndex: sentence.paragraphIndex } }; // Append to container if (this.paragraphContainer) { this.paragraphContainer.appendChild(paragraphElement); this.renderedItems.push(renderedItem); this.historyWindowStartId = this.renderedItems.find(item => item.blockId != null)?.blockId || this.historyWindowStartId; this.historyWindowEndId = [...this.renderedItems].reverse().find(item => item.blockId != null)?.blockId || this.historyWindowEndId; this.updateStoryScrollbar(); if (typeof this.layoutRenderer.adjustJustification === 'function') { this.layoutRenderer.adjustJustification(paragraphElement); } const updated = await this.recordRenderedMetrics(sentence.blockId, paragraphElement); if (updated) { renderedItem.lineStart = updated.lineStart; renderedItem.lineCount = updated.lineCount; renderedItem.metadata.lineStart = updated.lineStart; renderedItem.metadata.lineCount = updated.lineCount; this.setVirtualPadding(); } } else { console.error('UIDisplayHandler: Paragraph container not found'); return null; } // Store element reference in sentence sentence.element = paragraphElement; await this.trimVisibleBlocks(); await this.scrollStoryToEnd(true); // Start coordinated playback (animation + TTS), including chapter headings. await this.playbackCoordinator.play(sentence); // Call completion callback if (sentence.onComplete) { sentence.onComplete(); } return paragraphElement; } catch (error) { console.error('UIDisplayHandler: Error rendering sentence:', error); throw error; } } async rerenderStory() { if (!this.paragraphContainer || this.renderedItems.length === 0) return; const sentenceQueue = this.getModule('sentence-queue'); if (!sentenceQueue || typeof sentenceQueue.prepareLayout !== 'function') return; console.log('UIDisplayHandler: Re-typesetting story after page resize'); const storyTopLine = this.storyTopLine || 0; this.paragraphContainer.innerHTML = ''; for (const item of this.renderedItems) { if (item.type === 'image') { const sentenceQueue = this.getModule('sentence-queue'); const metadata = { ...item, ...(item.metadata || {}), turnId: item.turnId ?? item.metadata?.turnId, blockId: item.blockId ?? item.metadata?.blockId }; const imageLayout = sentenceQueue && typeof sentenceQueue.prepareImageLayout === 'function' ? await sentenceQueue.prepareImageLayout(metadata) : null; this.renderImageBlock({ ...metadata, imageLayout: imageLayout || metadata.imageLayout }, false); continue; } if (item.type === 'heading') { const layout = await sentenceQueue.prepareLayout(item.text, item.metadata || {}); const heading = this.layoutRenderer.renderParagraph(layout, { id: item.id }); if (item.turnId != null) { heading.dataset.turnId = String(item.turnId); heading.classList.add('story-turn-block'); } heading.querySelectorAll('.word').forEach(word => { word.style.transition = 'none'; word.style.animation = 'none'; word.style.visibility = 'visible'; word.style.opacity = '1'; word.style.transform = 'translateY(0)'; word.style.clipPath = 'inset(0 0 0 0)'; }); this.paragraphContainer.appendChild(heading); continue; } if (item.type !== 'paragraph') continue; const layout = await sentenceQueue.prepareLayout(item.text, item.metadata || {}); const paragraph = this.layoutRenderer.renderParagraph(layout, { id: item.id }); if (item.turnId != null) { paragraph.dataset.turnId = String(item.turnId); paragraph.classList.add('story-turn-block'); } paragraph.querySelectorAll('.word').forEach(word => { word.style.transition = 'none'; word.style.animation = 'none'; word.style.visibility = 'visible'; word.style.opacity = '1'; word.style.transform = 'translateY(0)'; word.style.clipPath = 'inset(0 0 0 0)'; }); this.paragraphContainer.appendChild(paragraph); } this.measureStoryLineHeight(); this.setStoryTopLine(storyTopLine, false, { mode: 'rerender-preserve', ensure: false }); } scrollStoryToEnd(smooth = true) { this.measureStoryLineHeight(); const targetTopLine = this.getMaxStoryTopLine(); const storyHeight = this.paragraphContainer?.offsetHeight || 0; const viewportHeight = this.pageRight?.clientHeight || 0; const targetOffset = Math.min(0, viewportHeight - storyHeight); this.storyTopLine = targetTopLine; return this.animateStoryOffset(targetOffset, smooth ? 720 : 0, { mode: 'auto-bottom' }); } async restoreFromHistory(saveRecord = {}) { if (!this.paragraphContainer || !this.storyHistory || !saveRecord?.gameId) return; const latestRenderedBlockId = Math.max(0, Number(saveRecord.latestRenderedBlockId || 0)); if (!this.storyHistory.renderedLineCount) { await this.storyHistory.getRenderedLineCount(saveRecord.gameId, latestRenderedBlockId); } const blocks = await this.storyHistory.getBlocks( saveRecord.gameId, this.visibleBlockLimit, latestRenderedBlockId + 1 ); await this.renderHistoryWindow(blocks, 'bottom'); this.updateStoryScrollbar({ latestBlockId: saveRecord.latestBlockId || blocks.at(-1)?.blockId || 1 }); } insertStoredElement(element, placement = 'append') { if (!this.paragraphContainer || !element) return; if (placement === 'prepend') { this.paragraphContainer.insertBefore(element, this.paragraphContainer.firstChild); } else { this.paragraphContainer.appendChild(element); } } async renderStoredItem(item, placement = 'append') { const sentenceQueue = this.getModule('sentence-queue'); if (!sentenceQueue) return null; if (placement === 'prepend') { this.renderedItems.unshift(item); } else { this.renderedItems.push(item); } if (item.type === 'image') { const metadata = { ...item, ...(item.metadata || {}), turnId: item.turnId ?? item.metadata?.turnId, blockId: item.blockId ?? item.metadata?.blockId }; const imageLayout = typeof sentenceQueue.prepareImageLayout === 'function' ? await sentenceQueue.prepareImageLayout(metadata) : null; const imageElement = this.renderImageBlock({ ...metadata, imageLayout: imageLayout || metadata.imageLayout }, false, placement); if (imageElement && item.blockId != null) imageElement.dataset.storyBlockId = String(item.blockId); if (imageElement && Number.isFinite(Number(item.lineStart))) imageElement.dataset.lineStart = String(item.lineStart); if (imageElement && Number.isFinite(Number(item.lineCount))) imageElement.dataset.lineCount = String(item.lineCount); return imageElement; } if (item.type !== 'heading' && item.type !== 'paragraph') return null; const metadata = { ...(item.metadata || {}), type: item.type, role: item.role || item.metadata?.role || (item.type === 'heading' ? 'chapter-heading' : 'body'), layoutText: item.layoutText || item.metadata?.layoutText || item.text, isFirstParagraphInChapter: Boolean(item.isFirstParagraphInChapter ?? item.metadata?.isFirstParagraphInChapter), dropCap: Boolean(item.dropCap ?? item.metadata?.dropCap), addTopSpace: Boolean(item.addTopSpace ?? item.metadata?.addTopSpace), paragraphIndex: item.paragraphIndex ?? item.metadata?.paragraphIndex, cueMarkers: item.cueMarkers || item.metadata?.cueMarkers || [], turnId: item.turnId ?? item.metadata?.turnId, blockId: item.blockId ?? item.metadata?.blockId, gameId: item.gameId ?? item.metadata?.gameId }; const layout = await sentenceQueue.prepareLayout(item.text, metadata); const element = this.layoutRenderer.renderParagraph(layout, { id: item.id }); if (item.turnId != null) { element.dataset.turnId = String(item.turnId); element.classList.add('story-turn-block'); } if (item.blockId != null) element.dataset.storyBlockId = String(item.blockId); if (Number.isFinite(Number(item.lineStart))) element.dataset.lineStart = String(item.lineStart); if (Number.isFinite(Number(item.lineCount))) element.dataset.lineCount = String(item.lineCount); element.querySelectorAll('.word').forEach(word => { word.style.transition = 'none'; word.style.animation = 'none'; word.style.visibility = 'visible'; word.style.opacity = '1'; word.style.transform = 'translateY(0)'; word.style.clipPath = 'inset(0 0 0 0)'; }); this.insertStoredElement(element, placement); return element; } async renderHistoryWindow(blocks = [], scrollTarget = 'top') { if (!this.paragraphContainer) return; const previousTopLine = this.storyTopLine || 0; this.paragraphContainer.innerHTML = ''; this.renderedItems = []; for (const item of blocks) { await this.renderStoredItem(item, 'append'); } this.historyWindowStartId = blocks[0]?.blockId || 1; this.historyWindowEndId = blocks.at(-1)?.blockId || 0; this.setVirtualPadding(); this.updateStoryScrollbar(); await new Promise(resolve => { window.requestAnimationFrame(() => { window.requestAnimationFrame(() => { if (scrollTarget === 'bottom') { this.scrollStoryToEnd(false); } else if (scrollTarget === 'preserve') { this.setStoryTopLine(previousTopLine, false, { mode: 'history-preserve', ensure: false }); } else { const firstLine = Number(blocks[0]?.lineStart || 0); this.setStoryTopLine(firstLine, false, { mode: 'history-top', ensure: false }); } resolve(); }); }); }); } async renderHistoryWindowForTurn(turnId) { if (!this.storyHistory || !this.paragraphContainer || turnId == null) return null; const result = await this.storyHistory.getWindowForTurn( this.storyHistory.currentGameId, turnId, this.visibleBlockLimit ); if (!result?.blocks?.length) return null; await this.renderHistoryWindow(result.blocks, 'top'); return result.targetBlockId; } async loadPreviousHistoryPage() { if (!this.storyHistory || !this.paragraphContainer || this.loadingHistoryPage) return; const beforeBlockId = this.historyWindowStartId || Number(this.paragraphContainer.querySelector('[data-story-block-id]')?.dataset?.storyBlockId || 0); if (!beforeBlockId || beforeBlockId <= 1) return; await this.loadAdjacentHistoryBlock(-1); } async loadNextHistoryPage() { if (!this.storyHistory || !this.paragraphContainer || this.loadingHistoryPage || !this.hasNewerHistory()) return; await this.loadAdjacentHistoryBlock(1); } async shiftHistoryWindow(deltaBlocks, scrollTarget = 'top') { if (!this.storyHistory) return; if (Math.abs(Number(deltaBlocks) || 0) === 1 && scrollTarget === 'preserve') { await this.loadAdjacentHistoryBlock(deltaBlocks); return; } const latest = this.getLatestHistoryBlockId(); const visible = Math.min(this.visibleBlockLimit, latest || this.visibleBlockLimit); const maxStart = Math.max(1, latest - visible + 1); const start = Math.max(1, Math.min(maxStart, (this.historyWindowStartId || 1) + deltaBlocks)); if (start === this.historyWindowStartId) return; await this.loadHistoryWindowAt(start, scrollTarget); } handleHistoryWheel(event) { if (!event.target?.closest?.('#page_right') || !this.pageRight) return; event.preventDefault(); event.stopPropagation(); this.disableAutoplayForManualScroll(); const lineDelta = (Number(event.deltaY || 0) / Math.max(8, this.lineHeightPx || 24)) * 0.85; this.scrollStoryByLines(lineDelta, true); } async loadAdjacentHistoryBlock(direction) { if (!this.storyHistory || !this.paragraphContainer || this.loadingHistoryPage) { if (direction) { const startBlockId = Math.max(1, (this.historyWindowStartId || 1) + Math.sign(direction)); this.pendingHistoryWindowRequest = { startBlockId, scrollTarget: 'preserve' }; } return; } const step = Math.sign(Number(direction) || 0); if (!step) return; const latest = this.getLatestHistoryBlockId(); const targetBlockId = step < 0 ? (this.historyWindowStartId || 1) - 1 : (this.historyWindowEndId || 0) + 1; if (targetBlockId < 1 || targetBlockId > latest) return; this.loadingHistoryPage = true; try { const blocks = await this.storyHistory.getBlocksRange(this.storyHistory.currentGameId, targetBlockId, targetBlockId); const block = blocks[0]; if (!block) return; await this.renderStoredItem(block, step < 0 ? 'prepend' : 'append'); this.historyWindowStartId = Math.min(this.historyWindowStartId || targetBlockId, targetBlockId); this.historyWindowEndId = Math.max(this.historyWindowEndId || targetBlockId, targetBlockId); await new Promise(resolve => window.requestAnimationFrame(resolve)); await this.trimVirtualWindow(step); this.setVirtualPadding(); this.updateStoryScrollbar(); } finally { this.loadingHistoryPage = false; const pending = this.pendingHistoryWindowRequest; this.pendingHistoryWindowRequest = null; if (pending && Number(pending.startBlockId) !== this.historyWindowStartId) { await this.loadAdjacentHistoryBlock(Number(pending.startBlockId) < this.historyWindowStartId ? -1 : 1); } } } async trimVirtualWindow(direction = 1) { if (!this.paragraphContainer) return; const excess = this.renderedItems.length - this.visibleBlockLimit; if (excess <= 0) return; for (let index = 0; index < excess; index += 1) { if (direction > 0) { const removed = this.renderedItems.shift(); const element = removed?.blockId != null ? this.paragraphContainer.querySelector(`[data-story-block-id="${CSS.escape(String(removed.blockId))}"]`) : null; element?.remove(); } else { const removed = this.renderedItems.pop(); const element = removed?.blockId != null ? this.paragraphContainer.querySelector(`[data-story-block-id="${CSS.escape(String(removed.blockId))}"]`) : null; element?.remove(); } } this.historyWindowStartId = this.renderedItems.find(item => item.blockId != null)?.blockId || 1; this.historyWindowEndId = [...this.renderedItems].reverse().find(item => item.blockId != null)?.blockId || 0; this.setVirtualPadding(); } async loadHistoryWindowAt(startBlockId, scrollTarget = 'top') { if (!this.storyHistory) return; if (this.loadingHistoryPage) { this.pendingHistoryWindowRequest = { startBlockId, scrollTarget }; return; } const latest = this.getLatestHistoryBlockId(); const maxStart = Math.max(1, latest - this.visibleBlockLimit + 1); const start = Math.max(1, Math.min(maxStart, Number(startBlockId || 1))); const end = Math.min(latest, start + this.visibleBlockLimit - 1); this.loadingHistoryPage = true; try { const blocks = await this.storyHistory.getBlocksRange(this.storyHistory.currentGameId, start, end); if (blocks.length) { await this.renderHistoryWindow(blocks, scrollTarget); } } finally { this.loadingHistoryPage = false; const pending = this.pendingHistoryWindowRequest; this.pendingHistoryWindowRequest = null; if (pending && Number(pending.startBlockId) !== this.historyWindowStartId) { await this.loadHistoryWindowAt(pending.startBlockId, pending.scrollTarget); } } } markBlockRendered(blockId) { if (this.storyHistory && typeof this.storyHistory.markRendered === 'function') { const latestRenderedBlockId = this.storyHistory.markRendered(blockId); document.dispatchEvent(new CustomEvent('story:history-updated', { detail: { gameId: this.storyHistory.currentGameId || null, latestBlockId: this.getLatestHistoryBlockId(), latestRenderedBlockId } })); } } getLatestHistoryBlockId() { return Math.max(0, Number(this.storyHistory?.latestRenderedBlockId || 0)); } hasNewerHistory() { return this.historyWindowEndId < this.getLatestHistoryBlockId(); } updateStoryScrollbar(detail = {}) { const track = document.getElementById('story_scrollbar'); const thumb = document.getElementById('story_scrollbar_thumb'); if (!thumb) return; this.measureStoryLineHeight(); const totalLines = Math.max(1, Number(detail.renderedLineCount || this.storyHistory?.renderedLineCount || 0)); const viewportLines = Math.max(1, this.viewportLineCount || 1); const visibleLines = Math.min(viewportLines, totalLines); const maxTopLine = Math.max(0, totalLines - visibleLines); const currentTop = Math.max(0, Math.min(maxTopLine, this.storyTopLine || 0)); const heightPercent = Math.max(8, Math.min(100, (visibleLines / totalLines) * 100)); const topPercent = maxTopLine <= 0 ? 0 : (currentTop / maxTopLine) * (100 - heightPercent); if (track) { track.dataset.totalLines = String(totalLines); track.dataset.viewportLines = String(viewportLines); track.dataset.topLine = String(currentTop); track.hidden = totalLines <= viewportLines; } thumb.style.height = `${heightPercent}%`; thumb.style.top = `${topPercent}%`; } handleStoryScrollbarPointer(event) { event.preventDefault(); event.stopPropagation(); this.disableAutoplayForManualScroll(); const track = event.currentTarget; if (!track) return; const moveToPointer = (pointerEvent) => { const rect = track.getBoundingClientRect(); const ratio = Math.max(0, Math.min(1, (pointerEvent.clientY - rect.top) / Math.max(1, rect.height))); this.setStoryTopLine(this.getMaxStoryTopLine() * ratio, true, { mode: 'manual' }); }; moveToPointer(event); const onMove = (moveEvent) => moveToPointer(moveEvent); const onUp = () => { document.removeEventListener('pointermove', onMove); document.removeEventListener('pointerup', onUp); }; document.addEventListener('pointermove', onMove); document.addEventListener('pointerup', onUp, { once: true }); } disableAutoplayForManualScroll() { if (!this.persistenceManager || typeof this.persistenceManager.updatePreference !== 'function') { console.error('UIDisplayHandler: Cannot disable autoplay; persistence-manager dependency is unavailable.'); return; } this.persistenceManager.updatePreference('app', 'autoplay', false); document.dispatchEvent(new CustomEvent('app:autoplay:change', { detail: { enabled: false, autoplay: false, source: 'manual-story-scroll' } })); } measureStoryLineHeight() { const element = this.paragraphContainer || this.container || this.pageRight; const computed = element ? window.getComputedStyle(element) : null; const parsed = parseFloat(computed?.lineHeight || ''); const fontSize = parseFloat(computed?.fontSize || ''); this.lineHeightPx = Number.isFinite(parsed) ? parsed : (Number.isFinite(fontSize) ? fontSize * 1.45 : 24); this.viewportLineCount = Math.max(1, Math.floor((this.pageRight?.clientHeight || this.lineHeightPx) / this.lineHeightPx)); return this.lineHeightPx; } measureBlockLines(element, fallbackLineCount = 1) { if (!element) return { lineCount: Math.max(1, fallbackLineCount), heightPx: 0, lineHeightPx: this.measureStoryLineHeight() }; const lineHeight = this.measureStoryLineHeight(); const style = window.getComputedStyle(element); const marginTop = parseFloat(style.marginTop || '0') || 0; const marginBottom = parseFloat(style.marginBottom || '0') || 0; const heightPx = Math.max(0, element.offsetHeight + marginTop + marginBottom); const lineCount = Math.max(1, Math.ceil((heightPx || lineHeight * fallbackLineCount) / Math.max(1, lineHeight))); return { lineCount, heightPx, lineHeightPx: lineHeight }; } async recordRenderedMetrics(blockId, element, fallbackLineCount = 1) { if (!this.storyHistory || typeof this.storyHistory.updateBlockMetrics !== 'function' || blockId == null) return null; await new Promise(resolve => window.requestAnimationFrame(resolve)); const metrics = this.measureBlockLines(element, fallbackLineCount); const updated = await this.storyHistory.updateBlockMetrics(blockId, metrics); if (updated && element) { element.dataset.lineStart = String(updated.lineStart || 0); element.dataset.lineCount = String(updated.lineCount || metrics.lineCount); } this.setVirtualPadding(); this.updateStoryScrollbar(); return updated; } setVirtualPadding() { if (!this.paragraphContainer) return; const first = this.renderedItems.find(item => item.blockId != null); const last = [...this.renderedItems].reverse().find(item => item.blockId != null); const topLines = Math.max(0, Number(first?.lineStart || first?.metadata?.lineStart || 0)); const lastStart = Number(last?.lineStart ?? last?.metadata?.lineStart ?? 0); const lastCount = Number(last?.lineCount ?? last?.metadata?.lineCount ?? 0); const totalLines = Math.max(0, Number(this.storyHistory?.renderedLineCount || 0)); const bottomLines = Math.max(0, totalLines - (Number.isFinite(lastStart) ? lastStart + Math.max(1, lastCount || 1) : totalLines)); const lineHeight = this.measureStoryLineHeight(); this.paragraphContainer.style.paddingTop = `${topLines * lineHeight}px`; this.paragraphContainer.style.paddingBottom = `${bottomLines * lineHeight}px`; } setStoryOffset(offsetPx) { this.storyOffsetPx = Number(offsetPx) || 0; if (this.container) { this.container.style.transform = `translateY(${this.storyOffsetPx}px)`; } this.handleStoryScroll(); this.updateStoryScrollbar(); } getMaxStoryTopLine() { this.measureStoryLineHeight(); const totalLines = Math.max(0, Number(this.storyHistory?.renderedLineCount || 0)); return Math.max(0, totalLines - Math.max(1, this.viewportLineCount || 1)); } scrollStoryByLines(deltaLines, smooth = true) { const nextLine = (this.storyTopLine || 0) + Number(deltaLines || 0); return this.setStoryTopLine(nextLine, smooth, { mode: 'manual' }); } setStoryTopLine(targetLine, smooth = true, options = {}) { this.measureStoryLineHeight(); const maxTopLine = this.getMaxStoryTopLine(); const target = Math.round(Math.max(0, Math.min(maxTopLine, Number(targetLine || 0)))); const start = this.storyTopLine || 0; const delta = target - start; const animationId = ++this.storyScrollAnimationId; if (!smooth || Math.abs(delta) < 0.02) { this.storyTopLine = target; this.setStoryOffset(-(target * this.lineHeightPx)); if (options.ensure !== false) { this.ensureHistoryWindowForLine(target + (this.viewportLineCount / 2)); } return Promise.resolve(); } return new Promise(resolve => { const startedAt = performance.now(); const duration = Math.min(1100, Math.max(360, Math.abs(delta) * 36)); const ease = (t) => t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2; const shouldEnsureDuringAnimation = options.ensure !== false; const step = (now) => { if (animationId !== this.storyScrollAnimationId) { resolve(); return; } const progress = Math.min(1, (now - startedAt) / duration); this.storyTopLine = start + (delta * ease(progress)); this.setStoryOffset(-(this.storyTopLine * this.lineHeightPx)); if (shouldEnsureDuringAnimation) { this.ensureHistoryWindowForLine(this.storyTopLine + (this.viewportLineCount / 2)); } if (progress < 1) { requestAnimationFrame(step); } else { this.storyTopLine = target; this.setStoryOffset(-(target * this.lineHeightPx)); if (shouldEnsureDuringAnimation) { this.ensureHistoryWindowForLine(target + (this.viewportLineCount / 2)); } resolve(); } }; requestAnimationFrame(step); }); } async ensureLiveTailWindow() { if (!this.storyHistory || !this.paragraphContainer || this.loadingHistoryPage) return; const latestRendered = Math.max(0, Number(this.storyHistory.latestRenderedBlockId || 0)); if (latestRendered <= 0) return; if ((this.historyWindowEndId || 0) >= latestRendered) return; const count = Math.max(1, this.visibleBlockLimit - 1); const start = Math.max(1, latestRendered - count + 1); const blocks = await this.storyHistory.getBlocksRange(this.storyHistory.currentGameId, start, latestRendered); if (!blocks.length) return; await this.renderHistoryWindow(blocks, 'bottom'); this.activeCenterBlockId = latestRendered; this.lastEnsuredCenterBucket = null; } async ensureHistoryWindowForLine(centerLine) { if (!this.storyHistory || this.loadingHistoryPage) return; const bucket = Math.floor(Math.max(0, Number(centerLine || 0)) / Math.max(1, this.viewportLineCount || 1)); if (this.historyEnsurePending || bucket === this.lastEnsuredCenterBucket) return; this.historyEnsurePending = true; this.lastEnsuredCenterBucket = bucket; try { const block = await this.storyHistory.findBlockForLine( this.storyHistory.currentGameId, centerLine, this.storyHistory.latestRenderedBlockId ); const centerBlockId = block?.blockId; if (!centerBlockId || centerBlockId === this.activeCenterBlockId) return; this.activeCenterBlockId = centerBlockId; const hasEnoughBefore = centerBlockId - (this.historyWindowStartId || 1) >= this.historyBufferBlocks; const hasEnoughAfter = (this.historyWindowEndId || 0) - centerBlockId >= this.historyBufferBlocks; if (!hasEnoughBefore || !hasEnoughAfter) { await this.loadHistoryWindowAround(centerBlockId, 'preserve'); } } finally { this.historyEnsurePending = false; } } async loadHistoryWindowAround(centerBlockId, scrollTarget = 'preserve') { if (!this.storyHistory || this.loadingHistoryPage) return; const latest = Math.max(0, Number(this.storyHistory.latestRenderedBlockId || 0)); const center = Math.max(1, Math.min(latest, Number(centerBlockId || 1))); const start = Math.max(1, center - this.historyBufferBlocks); const end = Math.min(latest, center + this.historyBufferBlocks); if (start === this.historyWindowStartId && end === this.historyWindowEndId) return; this.loadingHistoryPage = true; try { const blocks = await this.storyHistory.getBlocksRange(this.storyHistory.currentGameId, start, end); if (blocks.length) { await this.renderHistoryWindow(blocks, scrollTarget); } } finally { this.loadingHistoryPage = false; } } async trimVisibleBlocks() { if (!this.paragraphContainer) return; await this.trimVirtualWindow(1); this.updateStoryScrollbar(); } animateStoryOffset(targetOffset, duration = 720, options = {}) { const target = Number(targetOffset || 0); const start = Number(this.storyOffsetPx || 0); const delta = target - start; const animationId = ++this.storyScrollAnimationId; if (!duration || Math.abs(delta) < 1) { this.setStoryOffset(target); this.updateStoryScrollbar({ mode: options.mode }); return Promise.resolve(); } return new Promise(resolve => { const startedAt = performance.now(); const ease = (t) => t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2; const step = (now) => { if (animationId !== this.storyScrollAnimationId) { resolve(); return; } const progress = Math.min(1, (now - startedAt) / duration); this.setStoryOffset(start + (delta * ease(progress))); if (progress < 1) { requestAnimationFrame(step); return; } this.setStoryOffset(target); resolve(); }; requestAnimationFrame(step); }); } scrollToTurn(turnId) { if (!this.pageRight || turnId == null) return; const escapedTurnId = CSS.escape(String(turnId)); const scrollToLiveTarget = () => { const target = this.paragraphContainer?.querySelector(`[data-turn-id="${escapedTurnId}"]`); if (!target) return false; const targetLine = Number(target.dataset.lineStart); if (Number.isFinite(targetLine)) { this.setStoryTopLine(targetLine, true, { mode: 'jump-to-turn' }); } else { this.setStoryTopLine((target.offsetTop || 0) / Math.max(1, this.measureStoryLineHeight()), true, { mode: 'jump-to-turn' }); } return true; }; if (scrollToLiveTarget()) return; this.renderHistoryWindowForTurn(turnId).then(() => { requestAnimationFrame(() => scrollToLiveTarget()); }); } handleStoryScroll() { if (!this.pageRight || !this.paragraphContainer) return; const blocks = Array.from(this.paragraphContainer.querySelectorAll('[data-turn-id]')); if (blocks.length === 0) return; const viewportMiddle = (this.storyTopLine * this.measureStoryLineHeight()) + (this.pageRight.clientHeight / 2); let best = null; let bestDistance = Infinity; blocks.forEach((block) => { const lineStart = Number(block.dataset.lineStart); const lineCount = Number(block.dataset.lineCount); const blockMiddle = Number.isFinite(lineStart) && Number.isFinite(lineCount) ? ((lineStart + (lineCount / 2)) * this.lineHeightPx) : block.offsetTop + (block.offsetHeight / 2); const distance = Math.abs(blockMiddle - viewportMiddle); if (distance < bestDistance) { bestDistance = distance; best = block; } }); if (best?.dataset?.turnId && this.activeTurnId !== best.dataset.turnId) { this.activeTurnId = best.dataset.turnId; document.dispatchEvent(new CustomEvent('story:visible-turn', { detail: { turnId: Number(best.dataset.turnId) } })); } } async handleDeferredMediaBlock(sentence) { document.dispatchEvent(new CustomEvent('story:media-block', { detail: { id: sentence.id, type: sentence.kind, ...(sentence.metadata || {}) } })); if (sentence.kind === 'image') { await this.ensureLiveTailWindow(); const element = this.renderImageBlock({ ...(sentence.metadata || {}), id: sentence.id, revealImmediately: false }, true); const renderedItem = { type: 'image', id: sentence.id, turnId: sentence.turnId ?? null, blockId: sentence.blockId ?? null, gameId: sentence.gameId ?? null, text: '', metadata: { ...(sentence.metadata || {}), id: sentence.id } }; if (element && sentence.blockId != null) { element.dataset.storyBlockId = String(sentence.blockId); this.markBlockRendered(sentence.blockId); const updated = await this.recordRenderedMetrics(sentence.blockId, element, sentence.metadata?.imageLayout?.lineCount || 1); if (updated) { renderedItem.lineStart = updated.lineStart; renderedItem.lineCount = updated.lineCount; renderedItem.metadata.lineStart = updated.lineStart; renderedItem.metadata.lineCount = updated.lineCount; } } this.renderedItems.push(renderedItem); this.historyWindowStartId = this.renderedItems.find(item => item.blockId != null)?.blockId || this.historyWindowStartId; this.historyWindowEndId = [...this.renderedItems].reverse().find(item => item.blockId != null)?.blockId || this.historyWindowEndId; this.setVirtualPadding(); this.updateStoryScrollbar(); await this.trimVisibleBlocks(); await this.scrollStoryToEnd(true); this.revealImageBlock(element); if (sentence.onComplete) { sentence.onComplete(); } return element; } if (sentence.kind === 'music') { console.log('UIDisplayHandler: Music block started', sentence.metadata || {}); if (sentence.blockId != null) { this.markBlockRendered(sentence.blockId); } } if (sentence.onComplete) { sentence.onComplete(); } return null; } readFirstFiniteNumber(...values) { for (const value of values) { const number = Number(value); if (Number.isFinite(number)) { return Math.max(0, number); } } return 0; } waitForSkippablePause(seconds, kind = 'media') { const duration = Math.max(0, Number(seconds) || 0) * 1000; if (duration <= 0) return Promise.resolve(false); document.documentElement.dataset.skippablePause = 'true'; document.dispatchEvent(new CustomEvent('story:process-state', { detail: { state: 'playing-ready', reason: `${kind}-pause-start`, duration } })); return new Promise(resolve => { let finished = false; let timeoutId = null; const finish = (skipped) => { if (finished) return; finished = true; clearTimeout(timeoutId); document.removeEventListener('ui:command', onCommand); delete document.documentElement.dataset.skippablePause; document.dispatchEvent(new CustomEvent('story:process-state', { detail: { state: 'playing-ready', reason: `${kind}-pause-${skipped ? 'skipped' : 'complete'}` } })); resolve(skipped); }; const onCommand = (event) => { if (event.detail?.type === 'continue') { finish(true); } }; document.addEventListener('ui:command', onCommand); timeoutId = setTimeout(() => finish(false), duration); }); } renderImageBlock(metadata = {}, animate = true, placement = 'append') { if (!this.paragraphContainer) return null; const metrics = metadata.imageLayout || this.calculateImageMetrics(metadata.size); const figure = document.createElement('figure'); if (metadata.id) { figure.id = metadata.id; } figure.className = [ 'story-image-block', `story-image-${metrics.size || 'landscape'}`, metrics.floatSide === 'right' ? 'story-image-float-right' : '', metrics.floatSide === 'left' ? 'story-image-float-left' : '', animate ? 'story-image-pending' : 'story-image-visible' ].filter(Boolean).join(' '); figure.style.width = `${metrics.width}px`; figure.style.height = `${metrics.height}px`; figure.style.marginTop = `${metrics.verticalMargin || 0}px`; figure.style.marginBottom = `${metrics.verticalMargin || 0}px`; if ((metrics.size || metadata.size) === 'portrait') { const gap = metrics.gap || 0; figure.style.shapeMargin = `${gap}px`; if (metrics.floatSide === 'right') { figure.style.marginLeft = `${gap}px`; } else { figure.style.marginRight = `${gap}px`; } } figure.dataset.animationMs = '2000'; if (metadata.turnId != null) { figure.dataset.turnId = String(metadata.turnId); figure.classList.add('story-turn-block'); } const img = document.createElement('img'); img.src = this.resolveImageUrl(metadata); img.alt = metadata.alt || ''; img.decoding = 'async'; img.loading = 'eager'; figure.appendChild(img); this.insertStoredElement(figure, placement); if (animate && metadata.revealImmediately !== false) { window.requestAnimationFrame(() => this.revealImageBlock(figure)); } else { if (!animate) { figure.classList.remove('story-image-pending'); figure.classList.add('story-image-visible'); } } return figure; } revealImageBlock(figure) { if (!figure) return; figure.classList.remove('story-image-pending'); figure.classList.add('story-image-visible'); } resolveImageUrl(metadata = {}) { const explicit = String(metadata.url || '').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, '/')}`; } calculateImageMetrics(size = 'landscape') { const storyElement = document.getElementById('story'); const pageWidth = storyElement?.clientWidth || 600; const probe = document.createElement('p'); probe.style.visibility = 'hidden'; probe.style.position = 'absolute'; probe.style.left = '-8000px'; probe.style.top = '-8000px'; (storyElement || document.body).appendChild(probe); const lineHeight = parseFloat(window.getComputedStyle(probe).lineHeight) || 24; probe.remove(); const normalizedSize = String(size || 'landscape').toLowerCase() === 'widescreen' ? 'landscape' : String(size || 'landscape').toLowerCase(); const aspect = normalizedSize === 'portrait' ? (9 / 16) : normalizedSize === 'square' ? 1 : (16 / 9); const isPortrait = normalizedSize === 'portrait'; const imageGap = lineHeight; const maxOuterWidth = isPortrait ? pageWidth * 0.5 : pageWidth; const maxImageWidth = isPortrait ? Math.max(lineHeight * 4, maxOuterWidth - imageGap) : maxOuterWidth; const naturalHeight = maxImageWidth / aspect; const imageLineCount = Math.max(1, Math.floor(naturalHeight / lineHeight)); const verticalMargin = isPortrait ? lineHeight / 2 : 0; const lineCount = isPortrait ? imageLineCount + 1 : imageLineCount; const height = isPortrait ? Math.max(lineHeight, (lineCount * lineHeight) - (verticalMargin * 2)) : imageLineCount * lineHeight; const width = Math.min(maxImageWidth, height * aspect); return { size: normalizedSize, aspect, width, height, gap: imageGap, lineCount, imageLineCount, lineHeight, verticalMargin, floatSide: 'left', pageWidth }; } clear() { if (document.documentElement.dataset.skippablePause === 'true') { document.dispatchEvent(new CustomEvent('ui:command', { detail: { moduleId: this.id, type: 'continue', source: 'display-clear' } })); delete document.documentElement.dataset.skippablePause; } if (this.container) { this.container.innerHTML = ''; this.paragraphContainer = document.createElement('div'); this.paragraphContainer.id = 'paragraphs'; this.container.appendChild(this.paragraphContainer); } this.renderedItems = []; this.historyWindowStartId = 1; this.historyWindowEndId = 0; this.storyTopLine = 0; this.activeCenterBlockId = null; this.setVirtualPadding(); this.setStoryOffset(0); this.updateStoryScrollbar({ latestBlockId: this.getLatestHistoryBlockId() }); } /** * Show choices in the UI * @param {Array} choices - Array of choice objects * @param {Function} callback - Callback function for choice selection * @returns {Promise} - Promise resolving to the choices container */ showChoices(choices, callback) { if (!choices || choices.length === 0) { return Promise.resolve(null); } return new Promise((resolve) => { // Find or create choices container let choicesContainer = document.getElementById('choices'); if (!choicesContainer) { // UI Input Handler should create this, but if it doesn't exist yet, create it choicesContainer = document.createElement('div'); choicesContainer.id = 'choices'; choicesContainer.className = 'container'; this.pageLeft.appendChild(choicesContainer); } // Create a dedicated container for this set of choices const choicesGroup = document.createElement('div'); choicesGroup.className = 'choices-group'; choicesContainer.appendChild(choicesGroup); // Create each choice button choices.forEach((choice, index) => { const choiceButton = document.createElement('button'); choiceButton.className = 'choice-button'; choiceButton.textContent = choice.text; // Add index as data attribute choiceButton.dataset.index = index; // Add event listener choiceButton.addEventListener('click', () => { // Disable all buttons in this group Array.from(choicesGroup.querySelectorAll('button')).forEach(btn => { btn.disabled = true; btn.classList.add('selected'); }); // Highlight the selected button choiceButton.classList.add('selected'); // Call the callback if (typeof callback === 'function') { callback(index, choice); } }); choicesGroup.appendChild(choiceButton); }); window.requestAnimationFrame(() => { choicesGroup.classList.add('visible'); resolve(choicesGroup); }); }); } } // Create the singleton instance const uiDisplayHandler = new UIDisplayHandlerModule(); // Export the module export { uiDisplayHandler as UIDisplayHandler }; // Register with the module registry if (window.moduleRegistry) { window.moduleRegistry.register(uiDisplayHandler); } // Keep a reference in window for loader system window.UIDisplayHandler = uiDisplayHandler;