/** * 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']; // 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; // 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', 'displayText', 'renderSentence', 'renderHeading', 'handleDeferredMediaBlock', 'rerenderStory', 'clear', 'scheduleRerender', 'measureText', 'loadCSS', 'showChoices', 'preloadImages' ]); console.log('UIDisplayHandler: Constructor initialized'); } 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.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(); }); 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 = `

AI Interactive Fiction

An open-world text adventure

`; this.pageLeft.appendChild(header); // Create controls const controls = document.createElement('div'); controls.id = 'controls'; controls.className = 'buttons'; controls.innerHTML = ` speech speed* new game save load options `; 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.className = 'l10n-remark'; remark.innerHTML = '*click on page or press spacebar to fast forward text animation'; 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); } // 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'; startPrompt.textContent = 'Klick on new game or load to start the game'; 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'); } /** * 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 text in the UI (backward compatibility) * Note: Text should flow through SentenceQueue instead * @param {string} text - Text to display * @param {Object} options - Display options * @returns {Promise} - Promise resolving to the displayed paragraph element */ displayText(text, options = {}) { if (!text) return Promise.resolve(null); // For backward compatibility, delegate to sentence queue console.warn('UIDisplayHandler.displayText called directly, text should flow through SentenceQueue'); 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 === 'heading') { return this.renderHeading(sentence); } 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 } ); // 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; this.renderedItems.push({ type: 'paragraph', id: sentence.id, 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 } }); // Start coordinated playback (animation + TTS) await this.playbackCoordinator.play(sentence); // Scroll to bottom if (this.pageRight) { this.pageRight.scrollTop = this.pageRight.scrollHeight; } // Call completion callback if (sentence.onComplete) { sentence.onComplete(); } return paragraphElement; } catch (error) { console.error('UIDisplayHandler: Error rendering sentence:', error); throw error; } } async renderHeading(sentence) { const heading = document.createElement('p'); heading.id = sentence.id; heading.className = 'story-chapter-heading'; heading.innerHTML = sentence.metadata?.layoutText || sentence.text; this.renderedItems.push({ type: 'heading', id: sentence.id, text: sentence.text, layoutText: sentence.metadata?.layoutText || sentence.text }); if (this.paragraphContainer) { this.paragraphContainer.appendChild(heading); } if (sentence.onComplete) { sentence.onComplete(); } return heading; } 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 === 'heading') { const heading = document.createElement('p'); heading.id = item.id; heading.className = 'story-chapter-heading'; heading.innerHTML = item.layoutText || item.text; 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 }); paragraph.querySelectorAll('.word').forEach(word => { word.style.transition = 'none'; word.style.visibility = 'visible'; word.style.opacity = '1'; word.style.transform = 'translateY(0)'; }); this.paragraphContainer.appendChild(paragraph); } if (this.pageRight) { this.pageRight.scrollTop = scrollTop; } } async handleDeferredMediaBlock(sentence) { document.dispatchEvent(new CustomEvent('story:media-block', { detail: { id: sentence.id, type: sentence.kind, ...(sentence.metadata || {}) } })); if (sentence.kind === 'music') { const leadInSeconds = Number(sentence.metadata?.leadInSeconds || sentence.metadata?.leadIn || 0); if (leadInSeconds > 0) { console.log(`UIDisplayHandler: Waiting ${leadInSeconds}s before continuing after music block`); await new Promise(resolve => setTimeout(resolve, leadInSeconds * 1000)); } } if (sentence.onComplete) { sentence.onComplete(); } return null; } clear() { if (this.container) { this.container.innerHTML = ''; this.paragraphContainer = document.createElement('div'); this.paragraphContainer.id = 'paragraphs'; this.container.appendChild(this.paragraphContainer); } this.renderedItems = []; } /** * 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;