/** * UI Display Handler Module * Manages the display of text and UI elements */ import { BaseModule } from './base-module.js'; import { moduleRegistry } from './module-registry.js'; class UIDisplayHandler extends BaseModule { constructor() { super('ui-display-handler', 'UI Display Handler'); // Module dependencies this.dependencies = ['paragraph-layout', 'layout-renderer', 'animation-queue']; // DOM elements this.container = null; this.pageLeft = null; this.pageRight = null; this.paragraphContainer = null; // State this.currentParagraphId = 0; this.pendingParagraphs = []; // Resources to preload this.cssPath = '/css/style.css'; this.imagesToPreload = [ '/images/book-3057904.png', '/images/brown-wooden-flooring.jpg' ]; // Configuration this.updateConfig({ typography: { fontFamily: "'EB Garamond', serif", fontSize: '1.15rem', lineHeight: 1.5, maxWidth: 600 }, animation: { speed: 0.05, // Speed multiplier useTypingAnimation: true }, display: { showChoices: true } }); // Bind methods using parent's bindMethods utility this.bindMethods([ 'initializeContainers', 'displayText', 'showChoices', 'processNextParagraph', 'measureText', 'updateTypographySettings', 'loadCSS', '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.paragraphLayout = this.getModule('paragraph-layout'); this.layoutRenderer = this.getModule('layout-renderer'); this.animationQueue = this.getModule('animation-queue'); if (!this.paragraphLayout) { console.error("UIDisplayHandler: Missing paragraph-layout module"); return false; } if (!this.layoutRenderer) { console.error("UIDisplayHandler: Missing layout-renderer module"); return false; } if (!this.animationQueue) { console.error("UIDisplayHandler: Missing animation-queue module"); return false; } this.reportProgress(50, "Initializing display containers"); // Initialize container elements this.initializeContainers(); this.reportProgress(70, "Setting up typography"); // Set up measure function for paragraph layout const { fontSize, fontFamily } = this.config.typography; this.paragraphLayout.updateFont(fontSize, fontFamily); this.reportProgress(90, "Setting up event listeners"); // Set up event listeners this.setupEventListeners(); this.reportProgress(100, "UI Display Handler ready"); return true; } catch (error) { console.error("Error initializing UI Display Handler:", error); return false; } } /** * Set up event listeners */ setupEventListeners() { // Listen for typography setting changes this.addEventListener(document, 'ui:typography:update', (event) => { if (event.detail) { this.updateTypographySettings(event.detail); } }); // Listen for animation speed changes this.addEventListener(document, 'ui:animation:speed', (event) => { if (event.detail && typeof event.detail.speed === 'number') { this.config.animation.speed = event.detail.speed; } }); } /** * 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) => { // Check if the stylesheet is already loaded const existingLinks = document.querySelectorAll('link[rel="stylesheet"]'); for (const link of existingLinks) { if (link.href.includes(cssPath)) { console.log(`UIDisplayHandler: CSS ${cssPath} already loaded`); resolve(); return; } } // 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`); // Give a small delay for the CSS to be applied setTimeout(() => { resolve(); }, 50); }; 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* restart 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); } // 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; } /** * Update typography settings * @param {Object} settings - Typography settings */ updateTypographySettings(settings) { let changed = false; if (settings.fontSize && settings.fontSize !== this.config.typography.fontSize) { this.config.typography.fontSize = settings.fontSize; changed = true; } if (settings.fontFamily && settings.fontFamily !== this.config.typography.fontFamily) { this.config.typography.fontFamily = settings.fontFamily; changed = true; } if (settings.lineHeight && settings.lineHeight !== this.config.typography.lineHeight) { this.config.typography.lineHeight = settings.lineHeight; changed = true; } // If font settings changed, update the paragraph layout if (changed && this.paragraphLayout) { // Use the existing updateFont method this.paragraphLayout.updateFont( this.config.typography.fontSize, this.config.typography.fontFamily ); // Also update our local canvas context if (this.context) { this.context.font = `${this.config.typography.fontSize} ${this.config.typography.fontFamily}`; } // Dispatch event about typography changes this.dispatchEvent('ui:font:change', { fontSize: this.config.typography.fontSize, fontFamily: this.config.typography.fontFamily, lineHeight: this.config.typography.lineHeight }); } } /** * Display text in the UI * @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); return new Promise((resolve) => { // Generate a unique ID for this paragraph const paragraphId = `p-${Date.now()}-${this.currentParagraphId++}`; // Add to pending paragraphs queue this.pendingParagraphs.push({ id: paragraphId, text, options, resolve }); // If this is the only paragraph, process it immediately if (this.pendingParagraphs.length === 1) { this.processNextParagraph(); } else { console.log(`UIDisplayHandler: Queued paragraph (${this.pendingParagraphs.length} total)`); } }); } /** * Process the next paragraph in the queue */ processNextParagraph() { if (this.pendingParagraphs.length === 0) return; const paragraph = this.pendingParagraphs[0]; const { id, text, options, resolve } = paragraph; try { // Use the paragraph layout to calculate the optimal layout const layout = this.paragraphLayout.calculateLayout(text, { width: this.config.typography.maxWidth, fontSize: options.fontSize || this.config.typography.fontSize, fontFamily: options.fontFamily || this.config.typography.fontFamily, lineHeight: options.lineHeight || this.config.typography.lineHeight }); if (!layout) { console.error("UIDisplayHandler: Failed to calculate paragraph layout"); this.pendingParagraphs.shift(); // Remove this paragraph resolve(null); // Process next paragraph if any if (this.pendingParagraphs.length > 0) { this.processNextParagraph(); } return; } // Store the original text in the layout for TTS layout.originalText = text; // Use the layout renderer to create the DOM elements const paragraphElement = this.layoutRenderer.renderParagraph(layout, { container: this.paragraphContainer, id: id, className: options.className || '', style: options.style || {}, animateWords: this.config.animation.useTypingAnimation, animationSpeed: this.config.animation.speed, tts: options.speak !== false, // Enable TTS by default onComplete: () => { // Dispatch event when paragraph is complete this.dispatchEvent('ui:paragraph:complete', { id }); // Remove this paragraph from the queue this.pendingParagraphs.shift(); // Resolve the promise with the paragraph element resolve(paragraphElement); // Process next paragraph if any if (this.pendingParagraphs.length > 0) { this.processNextParagraph(); } } }); // Scroll to the new paragraph if (paragraphElement) { paragraphElement.scrollIntoView({ behavior: 'smooth', block: 'end' }); } } catch (error) { console.error("UIDisplayHandler: Error processing paragraph:", error); // Remove this paragraph from the queue this.pendingParagraphs.shift(); resolve(null); // Process next paragraph if any if (this.pendingParagraphs.length > 0) { this.processNextParagraph(); } } } /** * 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 UIDisplayHandler(); // Register with the module registry moduleRegistry.register(uiDisplayHandler); // Export the module export { uiDisplayHandler as UIDisplayHandler }; // Keep a reference in window for loader system console.log('UIDisplayHandler: Registering with window'); window.UIDisplayHandler = uiDisplayHandler;