/** * Layout Renderer Module * Renders calculated paragraph layouts into the DOM with proper animations */ import { BaseModule } from './base-module.js'; class LayoutRendererModule extends BaseModule { constructor() { super('layout-renderer', 'Layout Renderer'); // Module dependencies this.dependencies = ['animation-queue', 'tts-player']; // 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"); // Check for animation queue dependency const animationQueue = this.getModule('animation-queue'); if (!animationQueue) { console.warn("Layout Renderer: Animation Queue module not found in registry"); return false; } 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 animationQueue = this.getModule('animation-queue'); 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; const paragraphHeight = numLines * lineHeight; paragraphElement.style.height = `${paragraphHeight}em`; // Apply custom style properties for (const prop in style) { paragraphElement.style[prop] = style[prop]; } // Populate with words const wordElements = []; let lineIndex = 0; let totalDelay = 0; // Calculate each word's position based on layout data for (let i = 0; i < layout.nodes.length; i++) { const wordNode = layout.nodes[i]; // Get the current line index from breaks array while (lineIndex < layout.breaks.length - 1 && i >= layout.breaks[lineIndex + 1]) { lineIndex++; } // Create the word element const wordElement = this.renderWord(wordNode.text, animateWords); wordElements.push(wordElement); // Position the word absolutely within paragraph if (wordNode.x !== undefined && wordNode.y !== undefined) { // Use calculated position wordElement.style.position = 'absolute'; wordElement.style.left = `${wordNode.x}px`; wordElement.style.top = `${lineIndex * lineHeight}em`; } else { // Fallback for missing positioning data wordElement.style.position = 'relative'; wordElement.style.marginRight = '0.25em'; } // Add to paragraph paragraphElement.appendChild(wordElement); // Handle whitespace after the word if (wordNode.spaceAfter) { const spaceElement = document.createElement('span'); spaceElement.className = 'space'; spaceElement.innerHTML = ' '; if (wordNode.x !== undefined) { // Position space after word spaceElement.style.position = 'absolute'; const wordWidth = wordElement.offsetWidth || wordNode.width || wordNode.text.length * 8; spaceElement.style.left = `${wordNode.x + wordWidth}px`; spaceElement.style.top = `${lineIndex * lineHeight}em`; } else { spaceElement.style.position = 'relative'; } paragraphElement.appendChild(spaceElement); } } // Add the paragraph to the container container.appendChild(paragraphElement); // Schedule animations for words if enabled if (animateWords && 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) { const ttsPlayer = this.getModule('tts-player'); if (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 animationQueue.schedule(() => { 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 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) { const animationQueue = this.getModule('animation-queue'); if (!animationQueue) return; const actualDelay = delay * speed; 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(); // Export the module export { LayoutRenderer };