Split everything up into dynamically loaded modules.
This commit is contained in:
@@ -0,0 +1,621 @@
|
||||
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;
|
||||
Reference in New Issue
Block a user