Checkpoint current interactive fiction state
This commit is contained in:
+204
-191
@@ -9,7 +9,7 @@ class LayoutRendererModule extends BaseModule {
|
||||
super('layout-renderer', 'Layout Renderer');
|
||||
|
||||
// Module dependencies
|
||||
this.dependencies = ['animation-queue', 'tts-player'];
|
||||
this.dependencies = [];
|
||||
|
||||
// Configuration
|
||||
this.updateConfig({
|
||||
@@ -22,8 +22,8 @@ class LayoutRendererModule extends BaseModule {
|
||||
// Bind methods
|
||||
this.bindMethods([
|
||||
'renderParagraph',
|
||||
'renderWord',
|
||||
'scheduleWordAnimation'
|
||||
'initializeContainers',
|
||||
'adjustJustification'
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -34,14 +34,6 @@ class LayoutRendererModule extends BaseModule {
|
||||
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) {
|
||||
@@ -65,199 +57,212 @@ class LayoutRendererModule extends BaseModule {
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a paragraph from layout data
|
||||
* @param {Object} layout - Layout data from paragraph-layout
|
||||
* Render a paragraph from layout data (pure DOM creation, no animation)
|
||||
* @param {Object} layoutData - Layout data containing breaks, nodes, and measures
|
||||
* @param {Object} options - Rendering options
|
||||
* @returns {HTMLElement} - The created paragraph element
|
||||
*/
|
||||
renderParagraph(layout, options = {}) {
|
||||
const animationQueue = this.getModule('animation-queue');
|
||||
renderParagraph(layoutData, options = {}) {
|
||||
const { id = `p-${Date.now()}` } = options;
|
||||
const { breaks, nodes, measures, fontSize, fontFamily, lineHeightPx } = layoutData;
|
||||
|
||||
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');
|
||||
if (!breaks || !nodes) {
|
||||
console.error('LayoutRenderer: Invalid layout data');
|
||||
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];
|
||||
|
||||
// Create paragraph container
|
||||
const paragraph = document.createElement('p');
|
||||
paragraph.id = id;
|
||||
paragraph.className = [
|
||||
layoutData.role ? `story-${layoutData.role}` : '',
|
||||
layoutData.addTopSpace ? 'story-textblock-start' : '',
|
||||
layoutData.dropCap ? 'story-dropcap-paragraph' : ''
|
||||
].filter(Boolean).join(' ');
|
||||
paragraph.style.position = 'relative';
|
||||
paragraph.style.margin = '0';
|
||||
if (fontSize) paragraph.style.fontSize = fontSize;
|
||||
if (fontFamily) paragraph.style.fontFamily = fontFamily;
|
||||
if (Array.isArray(measures) && measures.length > 0) {
|
||||
paragraph.style.width = `${Math.max(...measures)}px`;
|
||||
paragraph.style.maxWidth = '100%';
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
// Calculate paragraph height
|
||||
const storyElement = document.getElementById('story');
|
||||
if (!storyElement) {
|
||||
console.error('LayoutRenderer: Story container not found');
|
||||
return null;
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
const lineHeight = lineHeightPx || parseFloat(window.getComputedStyle(paragraph).lineHeight) || 24;
|
||||
const maxLineWidth = Array.isArray(measures) && measures.length > 0
|
||||
? Math.max(...measures)
|
||||
: storyElement.clientWidth;
|
||||
// Height should include all lines (breaks.length represents number of lines)
|
||||
const numLines = breaks.length - 1;
|
||||
paragraph.style.height = `${lineHeight * numLines}px`;
|
||||
|
||||
console.log(`LayoutRenderer: Rendering paragraph ${id} - ${breaks.length} breaks (${numLines} lines), lineHeight: ${lineHeight}px, total height: ${lineHeight * numLines}px`);
|
||||
|
||||
// Debug: log break ratios
|
||||
breaks.forEach((brk, idx) => {
|
||||
if (idx > 0) {
|
||||
console.log(` Line ${idx - 1}: break position ${brk.position}, ratio ${brk.ratio ? brk.ratio.toFixed(3) : 'undefined'}`);
|
||||
}
|
||||
});
|
||||
|
||||
// Position words according to layout with proper justification
|
||||
let wordCount = 0;
|
||||
let lastChild = null;
|
||||
let syllable = "";
|
||||
const stack = [paragraph];
|
||||
|
||||
if (layoutData.dropCap && layoutData.dropCapText) {
|
||||
const dropCap = document.createElement('span');
|
||||
dropCap.className = 'drop-cap story-drop-cap';
|
||||
dropCap.textContent = layoutData.dropCapText;
|
||||
paragraph.appendChild(dropCap);
|
||||
}
|
||||
|
||||
for (let i = 1; i < breaks.length; i++) {
|
||||
const lineIndex = i - 1;
|
||||
const lineWidth = measures[Math.min(lineIndex, measures.length - 1)];
|
||||
const lineOffset = maxLineWidth - lineWidth;
|
||||
const currentBreak = breaks[i];
|
||||
const isFinalLine = i === breaks.length - 1;
|
||||
const ratio = isFinalLine ? 0 : (currentBreak.ratio || 0);
|
||||
|
||||
let currentLeft = 0;
|
||||
lastChild = null;
|
||||
|
||||
// Iterate through nodes on this line (break positions are inclusive)
|
||||
for (let j = breaks[i-1].position; j <= currentBreak.position; j++) {
|
||||
const node = nodes[j];
|
||||
|
||||
if (node.type === 'box' && node.value !== '' && j < currentBreak.position) {
|
||||
const followsGlue = j > 0 && nodes[j - 1].type === 'glue';
|
||||
const isTrailingPunctuation = /^[,.;:!?)]$/.test(node.value) && !followsGlue;
|
||||
|
||||
// Check if this box follows a penalty (hyphenation point)
|
||||
if (lastChild && isTrailingPunctuation) {
|
||||
syllable += node.value;
|
||||
lastChild.innerHTML = syllable;
|
||||
currentLeft += node.width;
|
||||
} else if (j > breaks[i-1].position + 1 &&
|
||||
nodes[j-1].type === 'penalty' &&
|
||||
lastChild) {
|
||||
// Combine with previous syllable using zero-width non-joiner
|
||||
syllable += '\u200c' + node.value;
|
||||
lastChild.innerHTML = syllable;
|
||||
currentLeft += node.width;
|
||||
} else {
|
||||
// Create new word span
|
||||
const word = document.createElement('span');
|
||||
word.className = 'word';
|
||||
word.style.position = 'absolute';
|
||||
word.style.display = 'inline-block';
|
||||
word.style.whiteSpace = 'nowrap';
|
||||
word.dataset.line = String(lineIndex);
|
||||
word.dataset.lineStart = String(lineOffset);
|
||||
word.dataset.lineWidth = String(lineWidth);
|
||||
|
||||
// Calculate position with proper line and justification
|
||||
const topPercent = (lineIndex * lineHeight * 100) / parseFloat(paragraph.style.height);
|
||||
const leftPercent = ((lineOffset + currentLeft) * 100) / maxLineWidth;
|
||||
|
||||
word.style.top = `${topPercent}%`;
|
||||
word.style.left = `${leftPercent}%`;
|
||||
word.style.opacity = '0'; // Hidden until animated
|
||||
word.style.visibility = 'hidden';
|
||||
syllable = node.value;
|
||||
word.innerHTML = syllable;
|
||||
lastChild = word;
|
||||
|
||||
if (wordCount < 5 || (wordCount % 20 === 0)) {
|
||||
console.log(` Word ${wordCount} "${node.value}" at line ${lineIndex}, top: ${topPercent.toFixed(1)}%, left: ${leftPercent.toFixed(1)}%`);
|
||||
}
|
||||
wordCount++;
|
||||
|
||||
stack[stack.length - 1].appendChild(word);
|
||||
currentLeft += node.width;
|
||||
}
|
||||
|
||||
} else if (node.type === 'tag') {
|
||||
if (node.value.substr(0, 2) === '</') {
|
||||
if (stack.length > 1) stack.pop();
|
||||
} else {
|
||||
const template = document.createElement('div');
|
||||
template.innerHTML = node.value;
|
||||
const tag = template.firstChild;
|
||||
if (tag) {
|
||||
tag.style.display = 'contents';
|
||||
stack[stack.length - 1].appendChild(tag);
|
||||
stack.push(tag);
|
||||
}
|
||||
}
|
||||
|
||||
} else if (node.type === 'glue' && j > breaks[i-1].position && node.width !== 0 && j <= currentBreak.position) {
|
||||
// Apply justification: adjust glue width based on line's ratio
|
||||
let adjustedWidth = node.width;
|
||||
|
||||
if (ratio > 0) {
|
||||
// Line needs stretching
|
||||
adjustedWidth = node.width + (node.stretch * ratio);
|
||||
} else if (ratio < 0) {
|
||||
// Line needs shrinking
|
||||
adjustedWidth = node.width + (node.shrink * ratio);
|
||||
}
|
||||
// If ratio === 0, line fits perfectly, use natural width
|
||||
|
||||
if (wordCount < 3) {
|
||||
// Debug first line's glue adjustments
|
||||
console.log(` Glue at position ${j}: natural=${node.width.toFixed(2)}px, adjusted=${adjustedWidth.toFixed(2)}px, ratio=${ratio.toFixed(3)}, left before: ${currentLeft.toFixed(2)}px`);
|
||||
}
|
||||
|
||||
// Increment position by the adjusted glue width
|
||||
currentLeft += adjustedWidth;
|
||||
|
||||
} else if (node.type === 'penalty' && node.penalty === 100 && j === currentBreak.position) {
|
||||
// Add hyphen at line break
|
||||
if (lastChild) {
|
||||
lastChild.innerHTML = `${lastChild.innerHTML}-`;
|
||||
continue;
|
||||
}
|
||||
|
||||
const word = document.createElement('span');
|
||||
word.className = 'word';
|
||||
word.style.position = 'absolute';
|
||||
word.style.display = 'inline-block';
|
||||
word.style.whiteSpace = 'nowrap';
|
||||
word.dataset.line = String(lineIndex);
|
||||
word.dataset.lineStart = String(lineOffset);
|
||||
word.dataset.lineWidth = String(lineWidth);
|
||||
const topPercent = (lineIndex * lineHeight * 100) / parseFloat(paragraph.style.height);
|
||||
const leftPercent = ((lineOffset + currentLeft) * 100) / maxLineWidth;
|
||||
word.style.top = `${topPercent}%`;
|
||||
word.style.left = `${leftPercent}%`;
|
||||
word.style.opacity = '0';
|
||||
word.style.visibility = 'hidden';
|
||||
word.innerHTML = "-";
|
||||
stack[stack.length - 1].appendChild(word);
|
||||
}
|
||||
}
|
||||
|
||||
// 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;
|
||||
|
||||
return paragraph;
|
||||
}
|
||||
|
||||
/**
|
||||
* Paragraph positions are already computed from browser DOM measurements.
|
||||
* Keep this hook for callers that still invoke it, but do not reflow the
|
||||
* prototype layout after rendering.
|
||||
* @param {HTMLElement} paragraph - Rendered paragraph element
|
||||
*/
|
||||
adjustJustification(paragraph) {
|
||||
return paragraph;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
@@ -265,3 +270,11 @@ const LayoutRenderer = new LayoutRendererModule();
|
||||
|
||||
// Export the module
|
||||
export { LayoutRenderer };
|
||||
|
||||
// Register with the module registry
|
||||
if (window.moduleRegistry) {
|
||||
window.moduleRegistry.register(LayoutRenderer);
|
||||
}
|
||||
|
||||
// Keep a reference in window for loader system
|
||||
window.LayoutRenderer = LayoutRenderer;
|
||||
|
||||
Reference in New Issue
Block a user