import { BaseModule } from './base-module.js'; import { moduleRegistry } from './module-registry.js'; import { ModuleEvent } from './base-module.js'; class UIDisplayHandler extends BaseModule { constructor() { super('ui-display-handler'); // Dependencies this.dependencies = ['animation-queue', 'tts', 'text-processor', 'paragraph-layout']; // Display state this.container = null; this.textBuffer = []; this.currentAnimation = null; this.textElements = []; this.maxParagraphs = 5; // Number of paragraphs to keep in view // Required module references this.animationQueue = null; this.tts = null; this.textProcessor = null; this.paragraphLayout = null; // Formatting settings this.formatting = { fontSize: '1.1rem', lineHeight: '1.5', paragraphSpacing: '1.2rem' }; // Resources to preload this.cssPath = '/css/style.css'; this.imagesToPreload = [ '/images/book-3057904.png', '/images/brown-wooden-flooring.jpg' ]; // Bind methods used as event handlers or passed as callbacks this.handleAnimationEnd = this.handleAnimationEnd.bind(this); this.displayText = this.displayText.bind(this); this.measureText = this.measureText.bind(this); this.typesetParagraph = this.typesetParagraph.bind(this); // Store a bound version of dispatchEvent for use in methods this._dispatchModuleEvent = (name, detail) => { document.dispatchEvent(new CustomEvent(name, { detail: { moduleId: this.id, ...detail }, bubbles: true })); }; // Add flag to track if we're currently animating text this.isAnimating = false; console.log('UIDisplayHandler: Constructor initialized'); } /** * Load dependencies and resources * @returns {Promise} - Resolves when dependencies are loaded */ async loadDependencies() { try { this.reportProgress(10, "Loading CSS stylesheets"); // Load CSS file await this.loadCSS(this.cssPath); this.reportProgress(30, "CSS loaded successfully"); // Preload images this.reportProgress(40, "Preloading UI images"); await this.preloadImages(this.imagesToPreload); this.reportProgress(80, "UI images preloaded"); return true; } catch (error) { console.error("Error loading UI display resources:", error); return false; } } /** * 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 = () => { const error = new Error(`Failed to load CSS: ${cssPath}`); console.error(error); reject(error); }; // Append to document head document.head.appendChild(link); }); } /** * Preload images * @param {Array} imagePaths - Array of image paths to preload * @returns {Promise} */ preloadImages(imagePaths) { return new Promise((resolve) => { if (!imagePaths || imagePaths.length === 0) { resolve(); return; } let loaded = 0; const totalImages = imagePaths.length; const checkAllLoaded = () => { loaded++; // Update progress proportionally const percent = Math.round((loaded / totalImages) * 100); this.reportProgress(40 + percent * 0.4, `Preloaded ${loaded}/${totalImages} images`); if (loaded === totalImages) { resolve(); } }; // Preload each image imagePaths.forEach(path => { const img = new Image(); img.onload = checkAllLoaded; img.onerror = () => { console.warn(`UIDisplayHandler: Failed to preload image: ${path}`); checkAllLoaded(); }; img.src = path; }); }); } async initialize() { this.reportProgress(0, 'Initializing UI Display Handler'); try { this.reportProgress(20, 'Setting up display container'); // Create book structure first this.setupBookStructure(); // Create or get the text display container this.container = document.getElementById('story') || this.createDisplayContainer(); this.reportProgress(40, 'Configuring display settings'); // Apply initial formatting this.applyFormatting(); this.reportProgress(60, 'Setting up animation and text processing dependencies'); // Get references to required modules this.animationQueue = moduleRegistry.getModule('animation-queue'); this.tts = moduleRegistry.getModule('tts'); this.textProcessor = moduleRegistry.getModule('text-processor'); this.paragraphLayout = moduleRegistry.getModule('paragraph-layout'); // Set up our text measuring function for the paragraph layout if (this.paragraphLayout) { this.paragraphLayout.setMeasureFunction(this.measureText); } // Check if we have all required modules if (!this.animationQueue) { console.error('UIDisplayHandler: animation-queue module not found'); return false; } if (!this.textProcessor) { console.warn('UIDisplayHandler: text-processor module not found, text will not be formatted properly'); } if (!this.paragraphLayout) { console.warn('UIDisplayHandler: paragraph-layout module not found, text will not be justified properly'); } // Set up event listeners for animation sync this.setupEventListeners(); this.reportProgress(100, 'UI Display Handler ready'); // Notify that display handler is ready this._dispatchModuleEvent('ui:display:ready', {}); return true; } catch (error) { console.error('Error initializing UI Display Handler:', error); this.changeState('ERROR'); return false; } } setupBookStructure() { // Create book structure based on reference HTML const book = document.getElementById('book') || this.createBookElement(); // Create page_left if it doesn't exist const pageLeft = document.getElementById('page_left') || this.createElement('div', 'page_left', book); // Create page_right if it doesn't exist const pageRight = document.getElementById('page_right') || this.createElement('div', 'page_right', book); // Create header in page_left if needed let header = pageLeft.querySelector('.header'); if (!header) { header = this.createElement('div', null, pageLeft, 'header'); // Add header content const byline = this.createElement('h2', null, header, 'byline l10n-by'); byline.textContent = 'powered by Generative AI'; const title = this.createElement('h1', null, header, 'title'); title.textContent = 'AI Interactive Fiction'; const subtitle = this.createElement('h3', null, header, 'subtitle'); subtitle.textContent = 'An open-world text adventure'; const separator = this.createElement('div', null, header, 'separator'); const double = this.createElement('double', null, separator); double.textContent = '❦'; } // Create controls if needed if (!document.getElementById('controls')) { const controls = this.createElement('div', 'controls', pageLeft, 'buttons'); // Add speech toggle const speechLink = this.createElement('a', 'speech', controls, 'l10n-speech'); speechLink.title = 'Toggle text to speech'; speechLink.disabled = 'disabled'; speechLink.textContent = 'speech'; // Add speed control const speedSpan = this.createElement('span', null, controls); const speedReset = this.createElement('a', 'speed_reset', speedSpan); const speedSpanInner = this.createElement('span', null, speedReset, 'l10n-speed'); speedSpanInner.innerHTML = 'speed*'; const speedInput = document.createElement('input'); speedInput.type = 'range'; speedInput.min = '0'; speedInput.max = '100'; speedInput.value = '50'; speedInput.id = 'speed'; speedInput.name = 'speed'; speedSpan.appendChild(speedInput); // Add restart button const restartLink = this.createElement('a', 'rewind', controls, 'l10n-restart'); restartLink.title = 'Restart story from beginning'; restartLink.disabled = 'disabled'; restartLink.textContent = 'restart'; // Add save button const saveLink = this.createElement('a', 'save', controls, 'l10n-save'); saveLink.title = 'Save progress'; saveLink.textContent = 'save'; // Add load button const loadLink = this.createElement('a', 'reload', controls, 'l10n-load'); loadLink.title = 'Reload from save point'; loadLink.disabled = 'disabled'; loadLink.textContent = 'load'; } // Create remark section if needed if (!document.getElementById('remark')) { const remark = this.createElement('div', 'remark', pageLeft, 'l10n-remark'); remark.innerHTML = '*click on page or press spacebar to fast forward text animation'; } // Create story container in page_right if needed if (!document.getElementById('story')) { const story = this.createElement('div', 'story', pageRight, 'container'); } // Create lighting element if needed if (!document.getElementById('lighting')) { const lighting = this.createElement('div', 'lighting', document.body); } // Create ruler and indent elements if needed if (!document.getElementById('ruler')) { this.createElement('div', 'ruler', document.body); } if (!document.getElementById('indent')) { const indent = this.createElement('div', 'indent', document.body, 'l10n-prompt'); indent.textContent = 'What do you want to do next?'; } } createBookElement() { const book = this.createElement('div', 'book', document.body); return book; } createElement(tagName, id, parent, className) { const element = document.createElement(tagName); if (id) element.id = id; if (className) element.className = className; if (parent) parent.appendChild(element); return element; } createDisplayContainer() { const storyContainer = document.getElementById('story'); if (storyContainer) return storyContainer; // If not found, create necessary structure const book = document.getElementById('book') || this.createBookElement(); const pageRight = document.getElementById('page_right') || this.createElement('div', 'page_right', book); // Create story container return this.createElement('div', 'story', pageRight, 'container'); } setupEventListeners() { // Use the bound method directly as the listener document.addEventListener('animationend', this.handleAnimationEnd); } handleAnimationEnd(event) { // Check if the event target is a story paragraph before proceeding if (!event.target.classList.contains('story-paragraph')) { return; } const paragraph = event.target; paragraph.classList.remove('fade-in'); // Notify that text display is complete this._dispatchModuleEvent('ui:text:complete', {}); } applyFormatting() { if (this.container) { this.container.style.fontSize = this.formatting.fontSize; this.container.style.lineHeight = this.formatting.lineHeight; } } /** * Measure text width for paragraph layout * @param {string} text - Text to measure * @param {string} [style] - Optional CSS style * @returns {number} - Text width in pixels */ measureText(text, style = '') { // Create a temporary span for text measurement const ruler = document.getElementById('ruler') || this.createRuler(); // Apply any custom style if provided if (style) { ruler.style.cssText = style; } // Set text and measure ruler.textContent = text; return ruler.offsetWidth; } /** * Create a ruler element for text measurement * @returns {HTMLElement} - The ruler element */ createRuler() { const ruler = document.createElement('div'); ruler.id = 'ruler'; ruler.style.position = 'absolute'; ruler.style.visibility = 'hidden'; ruler.style.whiteSpace = 'nowrap'; ruler.style.font = window.getComputedStyle(this.container || document.body).font; document.body.appendChild(ruler); return ruler; } /** * Typeset a paragraph based on calculated line breaks * @param {Object} paragraphData - Line breaking data from ParagraphLayout * @param {number} delay - Initial delay for animation * @param {Array} measures - Line width measurements * @returns {Array} - [Paragraph element, final delay] */ typesetParagraph(paragraphData, delay = 0, measures = []) { // Create paragraph element const p = document.createElement('p'); p.className = 'story-paragraph'; // Set up initial styling p.style.position = 'relative'; p.style.width = '100%'; let lineHeight = parseInt(this.formatting.lineHeight) || 1.5; let lineTop = 0; // Iterate through lines from paragraph_data.breaks for(let i = 1; i < paragraphData.breaks.length; i++) { // Get the current line (from the previous break position to the current break position) let lineStart = paragraphData.breaks[i-1].position; let lineEnd = paragraphData.breaks[i].position; // Process each node (word, space, tag) within the line for(let j = lineStart; j <= lineEnd; j++) { const node = paragraphData.nodes[j]; if (!node || !node.type) continue; // Skip invalid nodes // Handle different node types if (node.type === 'box' || node.type === 'tag') { // Create span for word or tag const span = document.createElement('span'); span.style.position = 'absolute'; span.style.left = `${node.left || 0}px`; span.style.top = `${lineTop}px`; span.style.opacity = '0'; // Start invisible for fade-in // Set content based on node type if (node.type === 'box') { span.textContent = node.value; } else if (node.type === 'tag') { // Handle HTML tags (e.g., , , etc.) span.innerHTML = node.value; } // Add to paragraph p.appendChild(span); // Schedule animation using AnimationQueue if (this.animationQueue) { const wordLength = node.value ? node.value.length : 1; this.animationQueue.schedule(() => { span.style.opacity = '1'; // Fade in span.classList.add('animated'); }, delay); // Calculate delay for next element based on word length delay += (wordLength * 50); // Adjust timing as needed } else { // Without animation queue, make visible immediately span.style.opacity = '1'; } } // Glue (spaces) don't need visible elements } // Update line top position for next line lineTop += lineHeight * 16; // Assuming 1em = 16px, adjust based on font size } // Set paragraph height based on final line position p.style.height = `${lineTop + lineHeight}px`; return [p, delay]; } /** * Display text with formatting, animation, and optional TTS * @param {string} text - Text to display * @param {Object} options - Display options * @returns {Promise} - Resolves when text display is complete */ async displayText(text, options = {}) { if (!this.container || !text) return false; console.log(`UIDisplayHandler: Processing text for display: "${text.substring(0, 50)}${text.length > 50 ? '...' : ''}"`); // Set animating flag this.isAnimating = true; // Process text let processedText = text; if (this.textProcessor) { try { processedText = this.textProcessor.process(text, true); console.log('UIDisplayHandler: Text processed with typography enhancements'); } catch (error) { console.error('Error processing text:', error); // Continue with unprocessed text } } // Create a simple paragraph to display the text const paragraph = document.createElement('p'); paragraph.className = 'story-paragraph fade-in'; paragraph.textContent = processedText; // Apply any custom styling from options if (options.style && paragraph) { Object.assign(paragraph.style, options.style); } // Add to DOM this.container.appendChild(paragraph); this.textElements.push(paragraph); // Limit the number of paragraphs this.limitParagraphs(); // Scroll to the new paragraph this.scrollToBottom(); // If TTS is available and enabled, speak the text if (this.tts) { console.log('UIDisplayHandler: Starting TTS playback'); this.tts.speak(text); } // Return a promise that resolves when animation is complete return new Promise(resolve => { // Use a simple timeout for animation completion setTimeout(() => { console.log('UIDisplayHandler: Text animation complete'); this.isAnimating = false; // Dispatch text complete event document.dispatchEvent(new CustomEvent('ui:text:complete', { detail: { moduleId: this.id } })); resolve(); }, 1000); // Default animation time }); } limitParagraphs() { while (this.textElements.length > this.maxParagraphs) { const oldestElement = this.textElements.shift(); if (oldestElement && oldestElement.parentElement) { oldestElement.parentElement.removeChild(oldestElement); } } } scrollToBottom() { if (this.container) { this.container.scrollTop = this.container.scrollHeight; } } clear() { if (this.container) { this.container.innerHTML = ''; this.textElements = []; } } show() { if (this.container) { this.container.style.display = 'block'; } } hide() { if (this.container) { this.container.style.display = 'none'; } } processCommand(command) { switch (command.action) { case 'display': this.displayText(command.text, command.options); break; case 'clear': this.clear(); break; default: console.warn(`Unknown display command: ${command.action}`); } } } // 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;