/** * 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']; // 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 = 20; // 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', 'displayText', 'renderSentence', 'recordRenderedItem', 'trimVisibleBlocks', 'restoreFromHistory', 'renderStoredItem', 'loadPreviousHistoryPage', 'updateStoryScrollbar', 'handleDeferredMediaBlock', 'renderImageBlock', 'calculateImageMetrics', 'readFirstFiniteNumber', 'waitForSkippablePause', 'scrollStoryToEnd', 'animatePageScroll', '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.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', (event) => { if (event.target?.closest?.('#page_right') && event.deltaY < 0 && this.pageRight?.scrollTop <= 2) { this.loadPreviousHistoryPage(); } }, { passive: 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); } // 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(); if (this.pageRight && !this.pageRight.dataset.turnScrollBound) { this.pageRight.dataset.turnScrollBound = 'true'; this.pageRight.addEventListener('scroll', this.handleStoryScroll, { passive: true }); } } 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. */ displayText(text, options = {}) { if (!text) return Promise.resolve(null); console.warn('UIDisplayHandler.displayText called directly; story text should come from TurnResult'); const sentenceQueue = this.getModule('sentence-queue'); if (sentenceQueue) { return new Promise(resolve => { sentenceQueue.addSentence(text, () => resolve(null)); }); } return Promise.resolve(null); } /** * 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 { // 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'); } // Append to container if (this.paragraphContainer) { this.paragraphContainer.appendChild(paragraphElement); if (typeof this.layoutRenderer.adjustJustification === 'function') { this.layoutRenderer.adjustJustification(paragraphElement); } } else { console.error('UIDisplayHandler: Paragraph container not found'); return null; } // Store element reference in sentence sentence.element = paragraphElement; await this.recordRenderedItem({ type: sentence.kind === 'heading' ? 'heading' : 'paragraph', id: sentence.id, turnId: sentence.turnId ?? 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 } }); await this.trimVisibleBlocks(); 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 scrollTop = this.pageRight ? this.pageRight.scrollTop : 0; this.paragraphContainer.innerHTML = ''; for (const item of this.renderedItems) { if (item.type === 'image') { const sentenceQueue = this.getModule('sentence-queue'); const imageLayout = sentenceQueue && typeof sentenceQueue.prepareImageLayout === 'function' ? await sentenceQueue.prepareImageLayout(item.metadata || {}) : null; this.renderImageBlock({ ...(item.metadata || {}), imageLayout: imageLayout || item.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); } if (this.pageRight) { this.pageRight.scrollTop = scrollTop; } } scrollStoryToEnd(smooth = true) { if (!this.pageRight) { return; } window.requestAnimationFrame(() => { this.animatePageScroll( Math.max(0, this.pageRight.scrollHeight - this.pageRight.clientHeight), smooth ? 720 : 0 ); }); } async restoreFromHistory(saveRecord = {}) { if (!this.paragraphContainer || !this.storyHistory || !saveRecord?.gameId) return; const sentenceQueue = this.getModule('sentence-queue'); if (!sentenceQueue || typeof sentenceQueue.prepareLayout !== 'function') return; const blocks = await this.storyHistory.getBlocks( saveRecord.gameId, this.visibleBlockLimit, (saveRecord.latestBlockId || Number.MAX_SAFE_INTEGER) + 1 ); this.paragraphContainer.innerHTML = ''; this.renderedItems = []; for (const item of blocks) { await this.renderStoredItem(item); } this.updateStoryScrollbar({ latestBlockId: saveRecord.latestBlockId || blocks.at(-1)?.blockId || 1 }); this.scrollStoryToEnd(false); } async renderStoredItem(item) { const sentenceQueue = this.getModule('sentence-queue'); if (!sentenceQueue) return null; this.renderedItems.push(item); if (item.type === 'image') { const imageLayout = typeof sentenceQueue.prepareImageLayout === 'function' ? await sentenceQueue.prepareImageLayout(item.metadata || {}) : null; const imageElement = this.renderImageBlock({ ...(item.metadata || {}), imageLayout: imageLayout || item.metadata?.imageLayout }, false); if (imageElement && item.blockId != null) imageElement.dataset.storyBlockId = String(item.blockId); return imageElement; } if (item.type !== 'heading' && item.type !== 'paragraph') return null; const layout = await sentenceQueue.prepareLayout(item.text, item.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); 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.paragraphContainer.appendChild(element); return element; } async loadPreviousHistoryPage() { if (!this.storyHistory || !this.paragraphContainer || this.loadingHistoryPage) return; const firstBlock = this.paragraphContainer.querySelector('[data-story-block-id]'); const beforeBlockId = Number(firstBlock?.dataset?.storyBlockId || 0); if (!beforeBlockId || beforeBlockId <= 1) return; this.loadingHistoryPage = true; try { const blocks = await this.storyHistory.getBlocks( this.storyHistory.currentGameId, this.visibleBlockLimit, beforeBlockId ); if (!blocks.length) return; this.paragraphContainer.innerHTML = ''; this.renderedItems = []; for (const item of blocks) { await this.renderStoredItem(item); } this.pageRight.scrollTop = 0; this.updateStoryScrollbar({ latestBlockId: this.storyHistory.nextBlockId - 1 }); } finally { this.loadingHistoryPage = false; } } async recordRenderedItem(item) { this.renderedItems.push(item); if (this.storyHistory && typeof this.storyHistory.recordBlock === 'function') { try { const record = await this.storyHistory.recordBlock(item); if (record && item.id) { item.blockId = record.blockId; item.gameId = record.gameId; const element = document.getElementById(item.id); if (element) element.dataset.storyBlockId = String(record.blockId); } document.dispatchEvent(new CustomEvent('story:history-updated', { detail: { gameId: record?.gameId || null, latestBlockId: record?.blockId || null } })); } catch (error) { console.warn('UIDisplayHandler: Failed to store story history item:', error); } } } updateStoryScrollbar(detail = {}) { const thumb = document.getElementById('story_scrollbar_thumb'); if (!thumb) return; const latest = Math.max(1, Number(detail.latestBlockId || this.storyHistory?.nextBlockId || 1)); const visible = Math.min(this.visibleBlockLimit, latest); const heightPercent = Math.max(8, Math.min(100, (visible / latest) * 100)); const topPercent = latest <= visible ? 0 : 100 - heightPercent; thumb.style.height = `${heightPercent}%`; thumb.style.top = `${topPercent}%`; } async trimVisibleBlocks() { if (!this.paragraphContainer) return; const blocks = Array.from(this.paragraphContainer.querySelectorAll('.story-turn-block')); const excess = blocks.length - this.visibleBlockLimit; if (excess <= 0) return; blocks.slice(0, excess).forEach(block => { block.classList.add('story-block-archiving'); window.setTimeout(() => block.remove(), 360); }); this.renderedItems = this.renderedItems.slice(Math.max(0, this.renderedItems.length - this.visibleBlockLimit)); } animatePageScroll(targetTop, duration = 720) { if (!this.pageRight) return; if (!duration) { this.pageRight.scrollTop = targetTop; return; } const startTop = this.pageRight.scrollTop; const delta = targetTop - startTop; if (Math.abs(delta) < 1) return; const startedAt = performance.now(); const ease = (t) => 1 - Math.pow(1 - t, 3); const step = (now) => { const progress = Math.min(1, (now - startedAt) / duration); this.pageRight.scrollTop = startTop + (delta * ease(progress)); if (progress < 1) { requestAnimationFrame(step); } }; requestAnimationFrame(step); } scrollToTurn(turnId) { if (!this.pageRight || turnId == null) return; const escapedTurnId = CSS.escape(String(turnId)); const target = this.paragraphContainer?.querySelector(`[data-turn-id="${escapedTurnId}"]`); if (!target) return; this.pageRight.scrollTo({ top: Math.max(0, target.offsetTop - 12), behavior: 'smooth' }); } 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.pageRight.scrollTop + (this.pageRight.clientHeight / 2); let best = null; let bestDistance = Infinity; blocks.forEach((block) => { const blockMiddle = 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') { const element = this.renderImageBlock({ ...(sentence.metadata || {}), id: sentence.id }, true); await this.recordRenderedItem({ type: 'image', id: sentence.id, turnId: sentence.turnId ?? null, text: '', metadata: { ...(sentence.metadata || {}), id: sentence.id } }); await this.trimVisibleBlocks(); this.scrollStoryToEnd(true); if (sentence.onComplete) { sentence.onComplete(); } return element; } if (sentence.kind === 'music') { console.log('UIDisplayHandler: Music block started', sentence.metadata || {}); } 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) { 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`; figure.dataset.animationMs = '900'; if (metadata.turnId != null) { figure.dataset.turnId = String(metadata.turnId); figure.classList.add('story-turn-block'); } const img = document.createElement('img'); img.src = metadata.url || metadata.filename || ''; img.alt = metadata.alt || ''; img.decoding = 'async'; img.loading = 'eager'; figure.appendChild(img); this.paragraphContainer.appendChild(figure); if (animate) { window.requestAnimationFrame(() => { figure.classList.remove('story-image-pending'); figure.classList.add('story-image-visible'); }); } else { figure.classList.remove('story-image-pending'); figure.classList.add('story-image-visible'); } return figure; } 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 imageGap = lineHeight * 0.9; const maxWidth = normalizedSize === 'portrait' ? pageWidth * 0.5 : pageWidth; const naturalHeight = maxWidth / aspect; const imageLineCount = Math.max(1, Math.floor(naturalHeight / lineHeight)); const height = imageLineCount * lineHeight; const width = Math.min(maxWidth, height * aspect); const verticalMargin = lineHeight / 2; const lineCount = imageLineCount + 1; 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 = []; if (this.pageRight) { this.pageRight.scrollTop = 0; } } /** * 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); }); // Schedule transition for choices to appear setTimeout(() => { choicesGroup.classList.add('visible'); // Scroll to the choices choicesGroup.scrollIntoView({ behavior: 'smooth', block: 'end' }); // Resolve the promise with the choices container resolve(choicesGroup); }, 100); }); } } // 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;