622 lines
23 KiB
JavaScript
622 lines
23 KiB
JavaScript
import { BaseModule } from './base-module.js';
|
|
import { moduleRegistry } from './module-registry.js';
|
|
import { ModuleEvent } from './base-module.js';
|
|
|
|
class UIDisplayHandler extends BaseModule {
|
|
constructor() {
|
|
super('ui-display-handler');
|
|
|
|
// Dependencies
|
|
this.dependencies = ['animation-queue', 'tts', 'text-processor', 'paragraph-layout'];
|
|
|
|
// Display state
|
|
this.container = null;
|
|
this.textBuffer = [];
|
|
this.currentAnimation = null;
|
|
this.textElements = [];
|
|
this.maxParagraphs = 5; // Number of paragraphs to keep in view
|
|
|
|
// Required module references
|
|
this.animationQueue = null;
|
|
this.tts = null;
|
|
this.textProcessor = null;
|
|
this.paragraphLayout = null;
|
|
|
|
// Formatting settings
|
|
this.formatting = {
|
|
fontSize: '1.1rem',
|
|
lineHeight: '1.5',
|
|
paragraphSpacing: '1.2rem'
|
|
};
|
|
|
|
// Resources to preload
|
|
this.cssPath = '/css/style.css';
|
|
this.imagesToPreload = [
|
|
'/images/book-3057904.png',
|
|
'/images/brown-wooden-flooring.jpg'
|
|
];
|
|
|
|
// Bind methods used as event handlers or passed as callbacks
|
|
this.handleAnimationEnd = this.handleAnimationEnd.bind(this);
|
|
this.displayText = this.displayText.bind(this);
|
|
this.measureText = this.measureText.bind(this);
|
|
this.typesetParagraph = this.typesetParagraph.bind(this);
|
|
|
|
// Store a bound version of dispatchEvent for use in methods
|
|
this._dispatchModuleEvent = (name, detail) => {
|
|
document.dispatchEvent(new CustomEvent(name, {
|
|
detail: { moduleId: this.id, ...detail },
|
|
bubbles: true
|
|
}));
|
|
};
|
|
|
|
// Add flag to track if we're currently animating text
|
|
this.isAnimating = false;
|
|
|
|
console.log('UIDisplayHandler: Constructor initialized');
|
|
}
|
|
|
|
/**
|
|
* Load dependencies and resources
|
|
* @returns {Promise<boolean>} - Resolves when dependencies are loaded
|
|
*/
|
|
async loadDependencies() {
|
|
try {
|
|
this.reportProgress(10, "Loading CSS stylesheets");
|
|
|
|
// Load CSS file
|
|
await this.loadCSS(this.cssPath);
|
|
this.reportProgress(30, "CSS loaded successfully");
|
|
|
|
// Preload images
|
|
this.reportProgress(40, "Preloading UI images");
|
|
await this.preloadImages(this.imagesToPreload);
|
|
this.reportProgress(80, "UI images preloaded");
|
|
|
|
return true;
|
|
} catch (error) {
|
|
console.error("Error loading UI display resources:", error);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Load CSS file asynchronously and wait for it to be applied
|
|
* @param {string} cssPath - Path to CSS file
|
|
* @returns {Promise<void>}
|
|
*/
|
|
loadCSS(cssPath) {
|
|
return new Promise((resolve, reject) => {
|
|
// Check if the stylesheet is already loaded
|
|
const existingLinks = document.querySelectorAll('link[rel="stylesheet"]');
|
|
for (const link of existingLinks) {
|
|
if (link.href.includes(cssPath)) {
|
|
console.log(`UIDisplayHandler: CSS ${cssPath} already loaded`);
|
|
resolve();
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Create link element
|
|
const link = document.createElement('link');
|
|
link.rel = 'stylesheet';
|
|
link.href = cssPath;
|
|
|
|
// Set up load and error handlers
|
|
link.onload = () => {
|
|
console.log(`UIDisplayHandler: CSS ${cssPath} loaded successfully`);
|
|
|
|
// Give a small delay for the CSS to be applied
|
|
setTimeout(() => {
|
|
resolve();
|
|
}, 50);
|
|
};
|
|
|
|
link.onerror = () => {
|
|
const error = new Error(`Failed to load CSS: ${cssPath}`);
|
|
console.error(error);
|
|
reject(error);
|
|
};
|
|
|
|
// Append to document head
|
|
document.head.appendChild(link);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Preload images
|
|
* @param {Array<string>} imagePaths - Array of image paths to preload
|
|
* @returns {Promise<void>}
|
|
*/
|
|
preloadImages(imagePaths) {
|
|
return new Promise((resolve) => {
|
|
if (!imagePaths || imagePaths.length === 0) {
|
|
resolve();
|
|
return;
|
|
}
|
|
|
|
let loaded = 0;
|
|
const totalImages = imagePaths.length;
|
|
|
|
const checkAllLoaded = () => {
|
|
loaded++;
|
|
|
|
// Update progress proportionally
|
|
const percent = Math.round((loaded / totalImages) * 100);
|
|
this.reportProgress(40 + percent * 0.4, `Preloaded ${loaded}/${totalImages} images`);
|
|
|
|
if (loaded === totalImages) {
|
|
resolve();
|
|
}
|
|
};
|
|
|
|
// Preload each image
|
|
imagePaths.forEach(path => {
|
|
const img = new Image();
|
|
img.onload = checkAllLoaded;
|
|
img.onerror = () => {
|
|
console.warn(`UIDisplayHandler: Failed to preload image: ${path}`);
|
|
checkAllLoaded();
|
|
};
|
|
img.src = path;
|
|
});
|
|
});
|
|
}
|
|
|
|
async initialize() {
|
|
this.reportProgress(0, 'Initializing UI Display Handler');
|
|
|
|
try {
|
|
this.reportProgress(20, 'Setting up display container');
|
|
|
|
// Create book structure first
|
|
this.setupBookStructure();
|
|
|
|
// Create or get the text display container
|
|
this.container = document.getElementById('story') || this.createDisplayContainer();
|
|
|
|
this.reportProgress(40, 'Configuring display settings');
|
|
|
|
// Apply initial formatting
|
|
this.applyFormatting();
|
|
|
|
this.reportProgress(60, 'Setting up animation and text processing dependencies');
|
|
|
|
// Get references to required modules
|
|
this.animationQueue = moduleRegistry.getModule('animation-queue');
|
|
this.tts = moduleRegistry.getModule('tts');
|
|
this.textProcessor = moduleRegistry.getModule('text-processor');
|
|
this.paragraphLayout = moduleRegistry.getModule('paragraph-layout');
|
|
|
|
// Set up our text measuring function for the paragraph layout
|
|
if (this.paragraphLayout) {
|
|
this.paragraphLayout.setMeasureFunction(this.measureText);
|
|
}
|
|
|
|
// Check if we have all required modules
|
|
if (!this.animationQueue) {
|
|
console.error('UIDisplayHandler: animation-queue module not found');
|
|
return false;
|
|
}
|
|
|
|
if (!this.textProcessor) {
|
|
console.warn('UIDisplayHandler: text-processor module not found, text will not be formatted properly');
|
|
}
|
|
|
|
if (!this.paragraphLayout) {
|
|
console.warn('UIDisplayHandler: paragraph-layout module not found, text will not be justified properly');
|
|
}
|
|
|
|
// Set up event listeners for animation sync
|
|
this.setupEventListeners();
|
|
|
|
this.reportProgress(100, 'UI Display Handler ready');
|
|
|
|
// Notify that display handler is ready
|
|
this._dispatchModuleEvent('ui:display:ready', {});
|
|
|
|
return true;
|
|
} catch (error) {
|
|
console.error('Error initializing UI Display Handler:', error);
|
|
this.changeState('ERROR');
|
|
return false;
|
|
}
|
|
}
|
|
|
|
setupBookStructure() {
|
|
// Create book structure based on reference HTML
|
|
const book = document.getElementById('book') || this.createBookElement();
|
|
|
|
// Create page_left if it doesn't exist
|
|
const pageLeft = document.getElementById('page_left') ||
|
|
this.createElement('div', 'page_left', book);
|
|
|
|
// Create page_right if it doesn't exist
|
|
const pageRight = document.getElementById('page_right') ||
|
|
this.createElement('div', 'page_right', book);
|
|
|
|
// Create header in page_left if needed
|
|
let header = pageLeft.querySelector('.header');
|
|
if (!header) {
|
|
header = this.createElement('div', null, pageLeft, 'header');
|
|
|
|
// Add header content
|
|
const byline = this.createElement('h2', null, header, 'byline l10n-by');
|
|
byline.textContent = 'powered by Generative AI';
|
|
|
|
const title = this.createElement('h1', null, header, 'title');
|
|
title.textContent = 'AI Interactive Fiction';
|
|
|
|
const subtitle = this.createElement('h3', null, header, 'subtitle');
|
|
subtitle.textContent = 'An open-world text adventure';
|
|
|
|
const separator = this.createElement('div', null, header, 'separator');
|
|
const double = this.createElement('double', null, separator);
|
|
double.textContent = '❦';
|
|
}
|
|
|
|
// Create controls if needed
|
|
if (!document.getElementById('controls')) {
|
|
const controls = this.createElement('div', 'controls', pageLeft, 'buttons');
|
|
|
|
// Add speech toggle
|
|
const speechLink = this.createElement('a', 'speech', controls, 'l10n-speech');
|
|
speechLink.title = 'Toggle text to speech';
|
|
speechLink.disabled = 'disabled';
|
|
speechLink.textContent = 'speech';
|
|
|
|
// Add speed control
|
|
const speedSpan = this.createElement('span', null, controls);
|
|
const speedReset = this.createElement('a', 'speed_reset', speedSpan);
|
|
const speedSpanInner = this.createElement('span', null, speedReset, 'l10n-speed');
|
|
speedSpanInner.innerHTML = 'speed<sup>*</sup>';
|
|
|
|
const speedInput = document.createElement('input');
|
|
speedInput.type = 'range';
|
|
speedInput.min = '0';
|
|
speedInput.max = '100';
|
|
speedInput.value = '50';
|
|
speedInput.id = 'speed';
|
|
speedInput.name = 'speed';
|
|
speedSpan.appendChild(speedInput);
|
|
|
|
// Add restart button
|
|
const restartLink = this.createElement('a', 'rewind', controls, 'l10n-restart');
|
|
restartLink.title = 'Restart story from beginning';
|
|
restartLink.disabled = 'disabled';
|
|
restartLink.textContent = 'restart';
|
|
|
|
// Add save button
|
|
const saveLink = this.createElement('a', 'save', controls, 'l10n-save');
|
|
saveLink.title = 'Save progress';
|
|
saveLink.textContent = 'save';
|
|
|
|
// Add load button
|
|
const loadLink = this.createElement('a', 'reload', controls, 'l10n-load');
|
|
loadLink.title = 'Reload from save point';
|
|
loadLink.disabled = 'disabled';
|
|
loadLink.textContent = 'load';
|
|
}
|
|
|
|
// Create remark section if needed
|
|
if (!document.getElementById('remark')) {
|
|
const remark = this.createElement('div', 'remark', pageLeft, 'l10n-remark');
|
|
remark.innerHTML = '<i><sup>*</sup>click on page or press spacebar to fast forward text animation</i>';
|
|
}
|
|
|
|
// Create story container in page_right if needed
|
|
if (!document.getElementById('story')) {
|
|
const story = this.createElement('div', 'story', pageRight, 'container');
|
|
}
|
|
|
|
// Create lighting element if needed
|
|
if (!document.getElementById('lighting')) {
|
|
const lighting = this.createElement('div', 'lighting', document.body);
|
|
}
|
|
|
|
// Create ruler and indent elements if needed
|
|
if (!document.getElementById('ruler')) {
|
|
this.createElement('div', 'ruler', document.body);
|
|
}
|
|
|
|
if (!document.getElementById('indent')) {
|
|
const indent = this.createElement('div', 'indent', document.body, 'l10n-prompt');
|
|
indent.textContent = 'What do you want to do next?';
|
|
}
|
|
}
|
|
|
|
createBookElement() {
|
|
const book = this.createElement('div', 'book', document.body);
|
|
return book;
|
|
}
|
|
|
|
createElement(tagName, id, parent, className) {
|
|
const element = document.createElement(tagName);
|
|
if (id) element.id = id;
|
|
if (className) element.className = className;
|
|
if (parent) parent.appendChild(element);
|
|
return element;
|
|
}
|
|
|
|
createDisplayContainer() {
|
|
const storyContainer = document.getElementById('story');
|
|
if (storyContainer) return storyContainer;
|
|
|
|
// If not found, create necessary structure
|
|
const book = document.getElementById('book') || this.createBookElement();
|
|
const pageRight = document.getElementById('page_right') ||
|
|
this.createElement('div', 'page_right', book);
|
|
|
|
// Create story container
|
|
return this.createElement('div', 'story', pageRight, 'container');
|
|
}
|
|
|
|
setupEventListeners() {
|
|
// Use the bound method directly as the listener
|
|
document.addEventListener('animationend', this.handleAnimationEnd);
|
|
}
|
|
|
|
handleAnimationEnd(event) {
|
|
// Check if the event target is a story paragraph before proceeding
|
|
if (!event.target.classList.contains('story-paragraph')) {
|
|
return;
|
|
}
|
|
|
|
const paragraph = event.target;
|
|
paragraph.classList.remove('fade-in');
|
|
|
|
// Notify that text display is complete
|
|
this._dispatchModuleEvent('ui:text:complete', {});
|
|
}
|
|
|
|
applyFormatting() {
|
|
if (this.container) {
|
|
this.container.style.fontSize = this.formatting.fontSize;
|
|
this.container.style.lineHeight = this.formatting.lineHeight;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Measure text width for paragraph layout
|
|
* @param {string} text - Text to measure
|
|
* @param {string} [style] - Optional CSS style
|
|
* @returns {number} - Text width in pixels
|
|
*/
|
|
measureText(text, style = '') {
|
|
// Create a temporary span for text measurement
|
|
const ruler = document.getElementById('ruler') || this.createRuler();
|
|
|
|
// Apply any custom style if provided
|
|
if (style) {
|
|
ruler.style.cssText = style;
|
|
}
|
|
|
|
// Set text and measure
|
|
ruler.textContent = text;
|
|
return ruler.offsetWidth;
|
|
}
|
|
|
|
/**
|
|
* Create a ruler element for text measurement
|
|
* @returns {HTMLElement} - The ruler element
|
|
*/
|
|
createRuler() {
|
|
const ruler = document.createElement('div');
|
|
ruler.id = 'ruler';
|
|
ruler.style.position = 'absolute';
|
|
ruler.style.visibility = 'hidden';
|
|
ruler.style.whiteSpace = 'nowrap';
|
|
ruler.style.font = window.getComputedStyle(this.container || document.body).font;
|
|
document.body.appendChild(ruler);
|
|
return ruler;
|
|
}
|
|
|
|
/**
|
|
* Typeset a paragraph based on calculated line breaks
|
|
* @param {Object} paragraphData - Line breaking data from ParagraphLayout
|
|
* @param {number} delay - Initial delay for animation
|
|
* @param {Array} measures - Line width measurements
|
|
* @returns {Array} - [Paragraph element, final delay]
|
|
*/
|
|
typesetParagraph(paragraphData, delay = 0, measures = []) {
|
|
// Create paragraph element
|
|
const p = document.createElement('p');
|
|
p.className = 'story-paragraph';
|
|
|
|
// Set up initial styling
|
|
p.style.position = 'relative';
|
|
p.style.width = '100%';
|
|
|
|
let lineHeight = parseInt(this.formatting.lineHeight) || 1.5;
|
|
let lineTop = 0;
|
|
|
|
// Iterate through lines from paragraph_data.breaks
|
|
for(let i = 1; i < paragraphData.breaks.length; i++) {
|
|
// Get the current line (from the previous break position to the current break position)
|
|
let lineStart = paragraphData.breaks[i-1].position;
|
|
let lineEnd = paragraphData.breaks[i].position;
|
|
|
|
// Process each node (word, space, tag) within the line
|
|
for(let j = lineStart; j <= lineEnd; j++) {
|
|
const node = paragraphData.nodes[j];
|
|
|
|
if (!node || !node.type) continue; // Skip invalid nodes
|
|
|
|
// Handle different node types
|
|
if (node.type === 'box' || node.type === 'tag') {
|
|
// Create span for word or tag
|
|
const span = document.createElement('span');
|
|
span.style.position = 'absolute';
|
|
span.style.left = `${node.left || 0}px`;
|
|
span.style.top = `${lineTop}px`;
|
|
span.style.opacity = '0'; // Start invisible for fade-in
|
|
|
|
// Set content based on node type
|
|
if (node.type === 'box') {
|
|
span.textContent = node.value;
|
|
} else if (node.type === 'tag') {
|
|
// Handle HTML tags (e.g., <b>, <i>, etc.)
|
|
span.innerHTML = node.value;
|
|
}
|
|
|
|
// Add to paragraph
|
|
p.appendChild(span);
|
|
|
|
// Schedule animation using AnimationQueue
|
|
if (this.animationQueue) {
|
|
const wordLength = node.value ? node.value.length : 1;
|
|
this.animationQueue.schedule(() => {
|
|
span.style.opacity = '1'; // Fade in
|
|
span.classList.add('animated');
|
|
}, delay);
|
|
|
|
// Calculate delay for next element based on word length
|
|
delay += (wordLength * 50); // Adjust timing as needed
|
|
} else {
|
|
// Without animation queue, make visible immediately
|
|
span.style.opacity = '1';
|
|
}
|
|
}
|
|
// Glue (spaces) don't need visible elements
|
|
}
|
|
|
|
// Update line top position for next line
|
|
lineTop += lineHeight * 16; // Assuming 1em = 16px, adjust based on font size
|
|
}
|
|
|
|
// Set paragraph height based on final line position
|
|
p.style.height = `${lineTop + lineHeight}px`;
|
|
|
|
return [p, delay];
|
|
}
|
|
|
|
/**
|
|
* Display text with formatting, animation, and optional TTS
|
|
* @param {string} text - Text to display
|
|
* @param {Object} options - Display options
|
|
* @returns {Promise} - Resolves when text display is complete
|
|
*/
|
|
async displayText(text, options = {}) {
|
|
if (!this.container || !text) return false;
|
|
|
|
console.log(`UIDisplayHandler: Processing text for display: "${text.substring(0, 50)}${text.length > 50 ? '...' : ''}"`);
|
|
|
|
// Set animating flag
|
|
this.isAnimating = true;
|
|
|
|
// Process text
|
|
let processedText = text;
|
|
if (this.textProcessor) {
|
|
try {
|
|
processedText = this.textProcessor.process(text, true);
|
|
console.log('UIDisplayHandler: Text processed with typography enhancements');
|
|
} catch (error) {
|
|
console.error('Error processing text:', error);
|
|
// Continue with unprocessed text
|
|
}
|
|
}
|
|
|
|
// Create a simple paragraph to display the text
|
|
const paragraph = document.createElement('p');
|
|
paragraph.className = 'story-paragraph fade-in';
|
|
paragraph.textContent = processedText;
|
|
|
|
// Apply any custom styling from options
|
|
if (options.style && paragraph) {
|
|
Object.assign(paragraph.style, options.style);
|
|
}
|
|
|
|
// Add to DOM
|
|
this.container.appendChild(paragraph);
|
|
this.textElements.push(paragraph);
|
|
|
|
// Limit the number of paragraphs
|
|
this.limitParagraphs();
|
|
|
|
// Scroll to the new paragraph
|
|
this.scrollToBottom();
|
|
|
|
// If TTS is available and enabled, speak the text
|
|
if (this.tts) {
|
|
console.log('UIDisplayHandler: Starting TTS playback');
|
|
this.tts.speak(text);
|
|
}
|
|
|
|
// Return a promise that resolves when animation is complete
|
|
return new Promise(resolve => {
|
|
// Use a simple timeout for animation completion
|
|
setTimeout(() => {
|
|
console.log('UIDisplayHandler: Text animation complete');
|
|
this.isAnimating = false;
|
|
|
|
// Dispatch text complete event
|
|
document.dispatchEvent(new CustomEvent('ui:text:complete', {
|
|
detail: { moduleId: this.id }
|
|
}));
|
|
|
|
resolve();
|
|
}, 1000); // Default animation time
|
|
});
|
|
}
|
|
|
|
limitParagraphs() {
|
|
while (this.textElements.length > this.maxParagraphs) {
|
|
const oldestElement = this.textElements.shift();
|
|
if (oldestElement && oldestElement.parentElement) {
|
|
oldestElement.parentElement.removeChild(oldestElement);
|
|
}
|
|
}
|
|
}
|
|
|
|
scrollToBottom() {
|
|
if (this.container) {
|
|
this.container.scrollTop = this.container.scrollHeight;
|
|
}
|
|
}
|
|
|
|
clear() {
|
|
if (this.container) {
|
|
this.container.innerHTML = '';
|
|
this.textElements = [];
|
|
}
|
|
}
|
|
|
|
show() {
|
|
if (this.container) {
|
|
this.container.style.display = 'block';
|
|
}
|
|
}
|
|
|
|
hide() {
|
|
if (this.container) {
|
|
this.container.style.display = 'none';
|
|
}
|
|
}
|
|
|
|
processCommand(command) {
|
|
switch (command.action) {
|
|
case 'display':
|
|
this.displayText(command.text, command.options);
|
|
break;
|
|
case 'clear':
|
|
this.clear();
|
|
break;
|
|
default:
|
|
console.warn(`Unknown display command: ${command.action}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Create the singleton instance
|
|
const uiDisplayHandler = new UIDisplayHandler();
|
|
|
|
// Register with the module registry
|
|
moduleRegistry.register(uiDisplayHandler);
|
|
|
|
// Export the module
|
|
export { uiDisplayHandler as UIDisplayHandler };
|
|
|
|
// Keep a reference in window for loader system
|
|
console.log('UIDisplayHandler: Registering with window');
|
|
window.UIDisplayHandler = uiDisplayHandler;
|