Files
ai.interactive.fiction/public/js/layout-renderer.js

312 lines
12 KiB
JavaScript

/**
* 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<boolean>} - 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;