Fix TTS module initialization and dependency issues. Update module IDs for consistency, improve circular dependency detection, and fix UI Controller event handling.
This commit is contained in:
+290
-230
@@ -1,251 +1,311 @@
|
||||
/**
|
||||
* LayoutRenderer Module
|
||||
* Translates the abstract layout data into concrete visual elements (DOM nodes).
|
||||
* Layout Renderer Module
|
||||
* Renders calculated paragraph layouts into the DOM with proper animations
|
||||
*/
|
||||
export class LayoutRenderer {
|
||||
/**
|
||||
* Create a new LayoutRenderer
|
||||
* @param {Object} animationQueue - The AnimationQueue instance
|
||||
*/
|
||||
constructor(animationQueue) {
|
||||
this.animationQueue = animationQueue;
|
||||
this.fastForwardingAll = false;
|
||||
}
|
||||
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'
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a paragraph based on layout data
|
||||
* @param {Object} paragraphData - The layout data from ParagraphLayout
|
||||
* @param {number} delay - Initial delay for animations
|
||||
* @param {Array<number>} measure - Array of line width measurements
|
||||
* @returns {Array} Array containing the paragraph element and the final delay
|
||||
* Initialize the module
|
||||
* @returns {Promise<boolean>} - Resolves with success status
|
||||
*/
|
||||
renderParagraph(paragraphData, delay = 0, measure = []) {
|
||||
const stack = [];
|
||||
let left = 0;
|
||||
const p = document.createElement("p");
|
||||
p.style.position = 'relative';
|
||||
p.classList.add("latest-paragraph");
|
||||
p.dataset.numberOfLines = paragraphData.breaks.length - 1;
|
||||
|
||||
const lineHeight = parseFloat(window.getComputedStyle(document.querySelector('#ruler')).lineHeight);
|
||||
const lineWidth = parseFloat(window.getComputedStyle(document.getElementById('story')).width);
|
||||
const pageHeight = parseFloat(window.getComputedStyle(document.getElementById('page_right')).height);
|
||||
|
||||
p.style.height = lineHeight * (paragraphData.breaks.length - 1) + 'px';
|
||||
const paragraphHeight = parseFloat(p.style.height);
|
||||
p.dataset.vpc = paragraphHeight * 100 / pageHeight;
|
||||
p.style.marginBlockEnd = 0;
|
||||
stack.push(p);
|
||||
|
||||
for (let i = 1; i < paragraphData.breaks.length; i++) {
|
||||
left = measure[measure.length - 1] - measure[Math.min(i - 1, measure.length - 1)];
|
||||
let lastChild = null;
|
||||
let syllable = "";
|
||||
async initialize() {
|
||||
try {
|
||||
this.reportProgress(10, "Initializing Layout Renderer");
|
||||
|
||||
for (let j = paragraphData.breaks[i-1].position; j <= paragraphData.breaks[i].position; j++) {
|
||||
if (paragraphData.nodes[j].type === 'box' && paragraphData.nodes[j].value !== '' && j < paragraphData.breaks[i].position) {
|
||||
if (j > paragraphData.breaks[i-1].position + 1 && paragraphData.nodes[j-1].type === 'penalty' && lastChild) {
|
||||
syllable += '\u200c' + paragraphData.nodes[j].value;
|
||||
lastChild.innerHTML = syllable;
|
||||
left += paragraphData.nodes[j].width;
|
||||
} else {
|
||||
let word = document.createElement("span");
|
||||
word.style.position = 'absolute';
|
||||
word.classList.add("fade-in");
|
||||
word.style.animationDuration = this.animationQueue.getSpeed() * 10 + 'ms';
|
||||
word.style.top = lineHeight * (i - 1) * 100 / paragraphHeight + '%';
|
||||
word.style.left = left * 100 / lineWidth + '%';
|
||||
syllable = paragraphData.nodes[j].value;
|
||||
word.innerHTML = syllable;
|
||||
lastChild = word;
|
||||
// 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 (!this.fastForwardingAll) {
|
||||
this.insertAfter(delay, stack[stack.length-1], word);
|
||||
if (ratio > 0) {
|
||||
// Stretch space
|
||||
spaceWidth += ratio * node.stretch;
|
||||
} else if (ratio < 0) {
|
||||
// Shrink space
|
||||
spaceWidth += ratio * node.shrink;
|
||||
}
|
||||
|
||||
delay += this.animationQueue.getSpeed();
|
||||
left += paragraphData.nodes[j].width;
|
||||
}
|
||||
} else if (paragraphData.nodes[j].type === 'tag') {
|
||||
if (paragraphData.nodes[j].value.substr(0, 2) == '</') {
|
||||
stack.pop();
|
||||
} else {
|
||||
let tmp = document.createElement('div');
|
||||
tmp.innerHTML = paragraphData.nodes[j].value;
|
||||
const word = tmp.firstChild;
|
||||
word.style.left = left * 100 / lineWidth + '%';
|
||||
stack[stack.length-1].appendChild(word);
|
||||
stack.push(word);
|
||||
}
|
||||
} else if (j > paragraphData.breaks[i-1].position && paragraphData.nodes[j].type === 'glue' && paragraphData.nodes[j].width !== 0 && j <= paragraphData.breaks[i].position) {
|
||||
// Insert space character
|
||||
if (paragraphData.breaks[i].ratio > 0) {
|
||||
left += paragraphData.nodes[j].width + paragraphData.breaks[i].ratio * paragraphData.nodes[j].stretch;
|
||||
} else {
|
||||
left += paragraphData.nodes[j].width + paragraphData.breaks[i].ratio * paragraphData.nodes[j].shrink;
|
||||
}
|
||||
|
||||
let word = document.createElement("span");
|
||||
word.style.position = 'absolute';
|
||||
word.classList.add("fade-in");
|
||||
word.style.top = lineHeight * (i - 1) * 100 / paragraphHeight + '%';
|
||||
word.style.left = left * 100 / lineWidth + '%';
|
||||
word.innerHTML = " ";
|
||||
|
||||
if (!this.fastForwardingAll) {
|
||||
this.insertAfter(delay, stack[stack.length-1], word);
|
||||
}
|
||||
} else if (paragraphData.nodes[j].type === 'penalty' && paragraphData.nodes[j].penalty === 100 && j === paragraphData.breaks[i].position) {
|
||||
// Create a hyphen at the end of the line if breaking at a hyphenation point
|
||||
let hyphen = document.createElement("span");
|
||||
hyphen.style.position = 'absolute';
|
||||
hyphen.classList.add("fade-in");
|
||||
hyphen.classList.add("hyphen-marker"); // Add a class for easier styling if needed
|
||||
hyphen.style.top = lineHeight * (i - 1) * 100 / paragraphHeight + '%';
|
||||
hyphen.style.left = left * 100 / lineWidth + '%';
|
||||
hyphen.innerHTML = "-";
|
||||
|
||||
// Ensure hyphen is visible with stronger styling
|
||||
hyphen.style.fontWeight = "normal";
|
||||
hyphen.style.opacity = "1";
|
||||
|
||||
if (!this.fastForwardingAll) {
|
||||
this.insertAfter(delay, stack[stack.length-1], hyphen);
|
||||
// Log for debugging
|
||||
console.log("Inserted hyphen at line break:", i, "position:", left);
|
||||
}
|
||||
|
||||
delay += this.animationQueue.getSpeed();
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [p, delay];
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert an element after a delay
|
||||
* @param {number} delay - The delay in milliseconds
|
||||
* @param {HTMLElement} target - The target element to append to
|
||||
* @param {HTMLElement} el - The element to insert
|
||||
* @param {boolean} fadeIn - Whether to fade in the element
|
||||
*/
|
||||
insertAfter(delay, target, el, fadeIn = true) {
|
||||
if (fadeIn) {
|
||||
el.classList.add("fade-in");
|
||||
this.animationQueue.schedule(function() {
|
||||
target.appendChild(el);
|
||||
}, delay);
|
||||
} else {
|
||||
this.animationQueue.schedule(function() {
|
||||
target.appendChild(el);
|
||||
}, delay);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show an element after a delay
|
||||
* @param {number} delay - The delay in milliseconds
|
||||
* @param {HTMLElement} el - The element to show
|
||||
*/
|
||||
showAfter(delay, el) {
|
||||
el.classList.add("hide");
|
||||
setTimeout(function() {
|
||||
setTimeout(function() { el.classList.remove("hide") }, delay);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a visual tag
|
||||
* @param {string} tagType - The type of tag (IMAGE, BACKGROUND, etc.)
|
||||
* @param {string} tagValue - The value of the tag
|
||||
* @param {HTMLElement} container - The container to append to
|
||||
* @param {number} delay - The delay in milliseconds
|
||||
* @returns {HTMLElement|null} The created element or null
|
||||
*/
|
||||
renderVisualTag(tagType, tagValue, container, delay = 0) {
|
||||
switch (tagType) {
|
||||
case "IMAGE":
|
||||
const imageElement = document.createElement('img');
|
||||
imageElement.src = tagValue;
|
||||
container.appendChild(imageElement);
|
||||
this.showAfter(delay, imageElement);
|
||||
return imageElement;
|
||||
|
||||
case "BACKGROUND":
|
||||
const outerScrollContainer = document.querySelector('#book');
|
||||
outerScrollContainer.style.backgroundImage = 'url(' + tagValue + ')';
|
||||
return null;
|
||||
|
||||
case "CHAPTER":
|
||||
const h = document.createElement('H2');
|
||||
h.appendChild(document.createTextNode(tagValue));
|
||||
h.classList.add("chapter-heading");
|
||||
h.classList.add("fade-in");
|
||||
container.appendChild(h);
|
||||
return h;
|
||||
|
||||
case "SEPARATOR":
|
||||
const d = document.createElement('double');
|
||||
d.appendChild(document.createTextNode('\u2766'));
|
||||
d.classList.add("fade-in");
|
||||
d.classList.add("separator");
|
||||
container.appendChild(d);
|
||||
return d;
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the fast forwarding state
|
||||
* @param {boolean} state - The fast forwarding state
|
||||
*/
|
||||
setFastForwardingAll(state) {
|
||||
this.fastForwardingAll = state;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the fast forwarding state
|
||||
* @returns {boolean} The fast forwarding state
|
||||
*/
|
||||
getFastForwardingAll() {
|
||||
return this.fastForwardingAll;
|
||||
}
|
||||
|
||||
/**
|
||||
* Smooth scroll to an element
|
||||
* @param {HTMLElement} target - The target element to scroll to
|
||||
* @param {number} duration - The duration of the scroll animation
|
||||
*/
|
||||
smoothScroll(target, duration) {
|
||||
const display = document.getElementById('page_right');
|
||||
const targetPosition = target.getBoundingClientRect().top;
|
||||
const startPosition = display.scrollTop;
|
||||
const distance = targetPosition;
|
||||
let startTime = null;
|
||||
// Add the paragraph to the container
|
||||
container.appendChild(paragraphElement);
|
||||
|
||||
if (duration < 5) {
|
||||
display.scrollTo(0, targetPosition);
|
||||
return;
|
||||
// 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);
|
||||
}
|
||||
|
||||
function animation(currentTime) {
|
||||
if (startTime === null) startTime = currentTime;
|
||||
const timeElapsed = currentTime - startTime;
|
||||
const run = ease(timeElapsed, startPosition, distance, duration);
|
||||
display.scrollTo(0, run);
|
||||
if (timeElapsed < duration) requestAnimationFrame(animation);
|
||||
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';
|
||||
}
|
||||
|
||||
function ease(t, b, c, d) {
|
||||
t /= d / 2;
|
||||
if (t < 1) return c / 2 * t * t + b;
|
||||
t--;
|
||||
return -c / 2 * (t * (t - 2) - 1) + b;
|
||||
}
|
||||
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;
|
||||
|
||||
requestAnimationFrame(animation);
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user