Split everything up into dynamically loaded modules.

This commit is contained in:
2025-04-04 00:00:43 +00:00
parent 2f7cda4b6d
commit aa29a6fd93
32 changed files with 8768 additions and 3935 deletions
+621
View File
@@ -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;