268 lines
9.9 KiB
JavaScript
268 lines
9.9 KiB
JavaScript
/**
|
|
* 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<boolean>} - 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 };
|