/** * Layout Renderer Module * Renders calculated paragraph layouts into the DOM with proper animations */ import { BaseModule } from './base-module.js'; import { moduleRegistry } from './module-registry.js'; class LayoutRendererModule extends BaseModule { constructor() { super('layout-renderer', 'Layout Renderer'); // Module dependencies this.dependencies = ['animation-queue']; // Module references this.animationQueue = null; this.ttsPlayer = null; // Configuration this.updateConfig({ animation: { defaultSpeed: 1.0, wordAnimationClass: 'animate-word' } }); // Bind methods this.bindMethods([ 'renderParagraph', 'renderWord', 'scheduleWordAnimation' ]); } /** * Initialize the module * @returns {Promise} - Resolves with success status */ async initialize() { try { this.reportProgress(10, "Initializing Layout Renderer"); // Get animation queue from module registry this.animationQueue = this.getModule('animation-queue'); if (!this.animationQueue) { console.warn("Layout Renderer: Animation Queue module not found in registry"); } // We'll try to get the TTS module, but it's not a hard dependency // We'll check for it again at runtime when needed setTimeout(() => { // Try to get TTS module after a delay to allow it to initialize this.ttsPlayer = this.getModule('tts-player'); if (!this.ttsPlayer) { console.log("Layout Renderer: TTS Player module not found yet, will try again when needed"); } }, 500); this.reportProgress(100, "Layout Renderer ready"); return true; } catch (error) { console.error("Error initializing Layout Renderer:", error); return false; } } /** * Initialize default containers */ initializeContainers() { // Check if story container exists const storyContainer = document.getElementById('story'); if (!storyContainer) { console.log('Story container not found, creating it'); const container = document.createElement('div'); container.id = 'story'; document.body.appendChild(container); } } /** * Render a paragraph from layout data * @param {Object} layout - Layout data from paragraph-layout * @param {Object} options - Rendering options * @returns {HTMLElement} - The created paragraph element */ renderParagraph(layout, options = {}) { const { container = document.getElementById('paragraphs'), id = `p-${Date.now()}`, className = '', style = {}, animateWords = true, animationSpeed = this.config.animation.defaultSpeed, tts = false, onComplete = null } = options; if (!layout || !layout.breaks || !layout.nodes || !container) { console.error('Invalid layout data or container'); return null; } // Create paragraph element const paragraphElement = document.createElement('p'); paragraphElement.id = id; paragraphElement.className = `paragraph ${className}`.trim(); paragraphElement.style.position = 'relative'; // Get line height and container width for positioning const lineHeight = parseFloat(window.getComputedStyle(document.querySelector('#story')).lineHeight) || 1.5; const containerWidth = parseFloat(window.getComputedStyle(container).width); // Calculate paragraph height based on number of lines const numLines = layout.breaks.length - 1; paragraphElement.style.height = `${lineHeight * numLines}px`; // Apply custom styles Object.assign(paragraphElement.style, style); // Create a fragment to build the paragraph const fragment = document.createDocumentFragment(); // Track total delay for animations let totalDelay = 0; let wordElements = []; // Process each line in the layout for (let i = 1; i < layout.breaks.length; i++) { // Track the current x position within the line let xPosition = 0; // Process nodes in this line for (let j = layout.breaks[i-1].position; j < layout.breaks[i].position; j++) { const node = layout.nodes[j]; // Handle different node types switch (node.type) { case 'box': // This is a word if (node.value && node.value.trim() !== '') { const wordElement = this.renderWord(node.value, animateWords); // Position the word within the line wordElement.style.position = 'absolute'; wordElement.style.left = `${xPosition * 100 / containerWidth}%`; wordElement.style.top = `${(i - 1) * lineHeight}px`; // Update x position for next word xPosition += node.width; paragraphElement.appendChild(wordElement); wordElements.push(wordElement); } break; case 'glue': // This is a space - calculate its width based on the ratio const ratio = layout.breaks[i].ratio; let spaceWidth = node.width; if (ratio > 0) { // Stretch space spaceWidth += ratio * node.stretch; } else if (ratio < 0) { // Shrink space spaceWidth += ratio * node.shrink; } xPosition += spaceWidth; break; case 'penalty': // This is a hyphen or line break opportunity if (node.flagged && node.penalty < Infinity && j === layout.breaks[i].position) { const hyphenElement = document.createElement('span'); hyphenElement.className = 'hyphen-marker'; hyphenElement.textContent = '-'; hyphenElement.style.position = 'absolute'; hyphenElement.style.left = `${xPosition * 100 / containerWidth}%`; hyphenElement.style.top = `${(i - 1) * lineHeight}px`; paragraphElement.appendChild(hyphenElement); wordElements.push(hyphenElement); } break; case 'tag': // This is a preserved tag if (typeof node.value === 'string') { const tempDiv = document.createElement('div'); tempDiv.innerHTML = node.value; while (tempDiv.firstChild) { const tagElement = tempDiv.firstChild; tagElement.style.position = 'absolute'; tagElement.style.left = `${xPosition * 100 / containerWidth}%`; tagElement.style.top = `${(i - 1) * lineHeight}px`; paragraphElement.appendChild(tagElement); // Estimate width for positioning next element xPosition += 20; // Approximate width of tag } } break; } } } // Add the paragraph to the container container.appendChild(paragraphElement); // Schedule animations for words if enabled if (animateWords && this.animationQueue) { // Schedule animations for each word with a faster timing const baseDelay = 0; // Starting delay const wordDelay = 20; // Delay between words in ms (reduced from 40) wordElements.forEach((wordElement, index) => { const delay = baseDelay + (index * wordDelay); totalDelay = Math.max(totalDelay, delay); this.scheduleWordAnimation(wordElement, delay, animationSpeed); }); // Schedule TTS if enabled - start it earlier in the animation sequence if (tts && this.ttsPlayer) { // Get the full text for TTS const fullText = layout.originalText || layout.processedText || paragraphElement.textContent; // Schedule TTS with the animation queue - start after just a few words appear this.animationQueue.schedule(() => { this.ttsPlayer.speak(fullText, (result) => { if (!result || !result.success) { console.warn('TTS playback issue:', result ? result.reason : 'unknown'); } }); }, Math.min(100, wordDelay * 3)); // Start TTS earlier } // Schedule completion callback if (onComplete && typeof onComplete === 'function') { const completionDelay = totalDelay + 200; // Reduced completion delay this.animationQueue.schedule(onComplete, completionDelay); } } else if (onComplete && typeof onComplete === 'function') { // If not animating, call onComplete immediately setTimeout(onComplete, 0); } return paragraphElement; } /** * Render a single word * @param {string} word - Word to render * @param {boolean} animate - Whether to prepare for animation * @returns {HTMLElement} - The created word element */ renderWord(word, animate = true) { const wordElement = this.createWordElement(word); // Apply initial styles for animation if (animate) { wordElement.style.opacity = '0'; wordElement.style.transform = 'translateY(5px)'; wordElement.style.display = 'inline-block'; } return wordElement; } /** * Create a word element * @param {string} word - Word to render * @returns {HTMLElement} - The created word element */ createWordElement(word) { const wordElement = document.createElement('span'); wordElement.className = 'word'; wordElement.textContent = word; return wordElement; } /** * Schedule a word animation with the animation queue * @param {HTMLElement} wordElement - Word element to animate * @param {number} delay - Delay before animation starts * @param {number} speed - Animation speed factor */ scheduleWordAnimation(wordElement, delay, speed) { if (!this.animationQueue) return; const actualDelay = delay * speed; this.animationQueue.schedule(() => { wordElement.style.opacity = '1'; wordElement.style.transform = 'translateY(0)'; wordElement.style.transition = `opacity 0.2s ease-out, transform 0.3s ease-out`; }, actualDelay); } } // Create the singleton instance const LayoutRenderer = new LayoutRendererModule(); // Register with the module registry moduleRegistry.register(LayoutRenderer); // Export the module export { LayoutRenderer }; // Keep a reference in window for loader system window.LayoutRenderer = LayoutRenderer;