603 lines
23 KiB
JavaScript
603 lines
23 KiB
JavaScript
/**
|
|
* UI Display Handler Module
|
|
* Manages the display of text and UI elements
|
|
*/
|
|
import { BaseModule } from './base-module.js';
|
|
|
|
class UIDisplayHandlerModule extends BaseModule {
|
|
constructor() {
|
|
super('ui-display-handler', 'UI Display Handler');
|
|
|
|
// Module dependencies
|
|
this.dependencies = ['layout-renderer', 'playback-coordinator'];
|
|
|
|
// DOM elements
|
|
this.container = null;
|
|
this.pageLeft = null;
|
|
this.pageRight = null;
|
|
this.paragraphContainer = null;
|
|
this.renderedItems = [];
|
|
this.resizeTimer = null;
|
|
this.storyResizeObserver = null;
|
|
this.lastStoryMetrics = null;
|
|
|
|
// Resources to preload
|
|
this.cssPath = '/css/style.css';
|
|
this.imagesToPreload = [
|
|
'/images/book-3057904.png',
|
|
'/images/brown-wooden-flooring.jpg'
|
|
];
|
|
|
|
// Bind methods using parent's bindMethods utility
|
|
this.bindMethods([
|
|
'initializeContainers',
|
|
'displayText',
|
|
'renderSentence',
|
|
'renderHeading',
|
|
'handleDeferredMediaBlock',
|
|
'rerenderStory',
|
|
'clear',
|
|
'scheduleRerender',
|
|
'measureText',
|
|
'loadCSS',
|
|
'showChoices',
|
|
'preloadImages'
|
|
]);
|
|
|
|
console.log('UIDisplayHandler: Constructor initialized');
|
|
}
|
|
|
|
async initialize() {
|
|
try {
|
|
this.reportProgress(10, "Initializing UI Display Handler");
|
|
|
|
// Load CSS and preload images
|
|
this.reportProgress(20, "Loading CSS and preloading images");
|
|
await this.loadCSS(this.cssPath);
|
|
await this.preloadImages(this.imagesToPreload);
|
|
|
|
this.reportProgress(30, "Getting module dependencies");
|
|
|
|
// Get references to required modules using parent's getModule method
|
|
this.layoutRenderer = this.getModule('layout-renderer');
|
|
this.playbackCoordinator = this.getModule('playback-coordinator');
|
|
|
|
this.reportProgress(50, "Initializing display containers");
|
|
|
|
// Initialize container elements
|
|
this.initializeContainers();
|
|
|
|
this.reportProgress(70, "Setting up typography");
|
|
|
|
this.reportProgress(90, "Setting up event listeners");
|
|
this.addEventListener(document, 'book:resized', () => {
|
|
this.scheduleRerender();
|
|
});
|
|
|
|
if (window.ResizeObserver && this.paragraphContainer) {
|
|
this.storyResizeObserver = new ResizeObserver((entries) => {
|
|
const entry = entries[0];
|
|
if (!entry) {
|
|
return;
|
|
}
|
|
|
|
const computedStyle = window.getComputedStyle(this.paragraphContainer);
|
|
const metrics = {
|
|
width: Math.round(entry.contentRect.width),
|
|
fontSize: computedStyle.fontSize,
|
|
lineHeight: computedStyle.lineHeight
|
|
};
|
|
|
|
if (!this.lastStoryMetrics) {
|
|
this.lastStoryMetrics = metrics;
|
|
return;
|
|
}
|
|
|
|
const changed = metrics.width !== this.lastStoryMetrics.width ||
|
|
metrics.fontSize !== this.lastStoryMetrics.fontSize ||
|
|
metrics.lineHeight !== this.lastStoryMetrics.lineHeight;
|
|
|
|
this.lastStoryMetrics = metrics;
|
|
if (changed) {
|
|
this.scheduleRerender();
|
|
}
|
|
});
|
|
this.storyResizeObserver.observe(this.paragraphContainer);
|
|
}
|
|
|
|
this.reportProgress(100, "UI Display Handler ready");
|
|
return true;
|
|
} catch (error) {
|
|
console.error("Error initializing UI Display Handler:", error);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
scheduleRerender() {
|
|
clearTimeout(this.resizeTimer);
|
|
this.resizeTimer = setTimeout(() => this.rerenderStory(), 80);
|
|
}
|
|
|
|
|
|
/**
|
|
* 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) => {
|
|
// 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`);
|
|
resolve();
|
|
};
|
|
|
|
link.onerror = (error) => {
|
|
console.error(`UIDisplayHandler: Failed to load CSS ${cssPath}:`, error);
|
|
reject(error);
|
|
};
|
|
|
|
// Add to document head
|
|
document.head.appendChild(link);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Preload images to ensure they're in the cache
|
|
* @param {Array<string>} imagePaths - Array of image paths to preload
|
|
* @returns {Promise<void>}
|
|
*/
|
|
preloadImages(imagePaths) {
|
|
if (!imagePaths || imagePaths.length === 0) {
|
|
return Promise.resolve();
|
|
}
|
|
|
|
const promises = imagePaths.map(path => {
|
|
return new Promise((resolve) => {
|
|
const img = new Image();
|
|
img.onload = () => resolve();
|
|
img.onerror = () => {
|
|
console.warn(`UIDisplayHandler: Failed to preload image ${path}`);
|
|
resolve(); // Resolve anyway to not block loading
|
|
};
|
|
img.src = path;
|
|
});
|
|
});
|
|
|
|
return Promise.all(promises);
|
|
}
|
|
|
|
/**
|
|
* Initialize the UI containers
|
|
*/
|
|
initializeContainers() {
|
|
// Check if the book container already exists
|
|
let bookContainer = document.getElementById('book');
|
|
if (!bookContainer) {
|
|
console.log('UIDisplayHandler: Book container not found, creating it');
|
|
bookContainer = document.createElement('div');
|
|
bookContainer.id = 'book';
|
|
document.body.appendChild(bookContainer);
|
|
}
|
|
|
|
// Create or find page_left
|
|
this.pageLeft = document.getElementById('page_left');
|
|
if (!this.pageLeft) {
|
|
console.log('UIDisplayHandler: Left page not found, creating it');
|
|
this.pageLeft = document.createElement('div');
|
|
this.pageLeft.id = 'page_left';
|
|
|
|
// Create header content
|
|
const header = document.createElement('div');
|
|
header.className = 'header';
|
|
header.innerHTML = `
|
|
<h2 class="byline l10n-by">powered by Generative AI</h2>
|
|
<h1 class="title">AI Interactive Fiction</h1>
|
|
<h3 class="subtitle">An open-world text adventure</h3>
|
|
<div class="separator"><double>❦</double></div>
|
|
`;
|
|
this.pageLeft.appendChild(header);
|
|
|
|
// Create controls
|
|
const controls = document.createElement('div');
|
|
controls.id = 'controls';
|
|
controls.className = 'buttons';
|
|
controls.innerHTML = `
|
|
<a class="l10n-speech" id="speech" title="Toggle text to speech">speech</a>
|
|
<span><a id="speed_reset"><span class="l10n-speed">speed<sup>*</sup></span></a><input type="range" min="50" max="150" value="100" id="speed" name="speed" /></span>
|
|
<a class="l10n-restart" id="rewind" title="Start a new game">new game</a>
|
|
<a class="l10n-save" id="save" title="Save progress">save</a>
|
|
<a class="l10n-load" id="reload" title="Reload from save point" disabled="disabled">load</a>
|
|
<a class="l10n-options" id="options" title="Options">options</a>
|
|
`;
|
|
this.pageLeft.appendChild(controls);
|
|
|
|
// Create choices container
|
|
const choicesContainer = document.createElement('div');
|
|
choicesContainer.id = 'choices';
|
|
choicesContainer.className = 'container';
|
|
|
|
// Create command history container
|
|
const commandHistory = document.createElement('div');
|
|
commandHistory.id = 'command_history';
|
|
choicesContainer.appendChild(commandHistory);
|
|
|
|
// Create command input container
|
|
const commandInput = document.createElement('div');
|
|
commandInput.id = 'command_input';
|
|
commandInput.innerHTML = `
|
|
<div class="input-wrapper">
|
|
<textarea id="player_input" placeholder="Enter your command..." rows="1" autofocus></textarea>
|
|
<span id="cursor"></span>
|
|
</div>
|
|
`;
|
|
choicesContainer.appendChild(commandInput);
|
|
|
|
this.pageLeft.appendChild(choicesContainer);
|
|
|
|
// Create remark
|
|
const remark = document.createElement('div');
|
|
remark.id = 'remark';
|
|
remark.className = 'l10n-remark';
|
|
remark.innerHTML = '<i><sup>*</sup>click on page or press spacebar to fast forward text animation</i>';
|
|
this.pageLeft.appendChild(remark);
|
|
|
|
bookContainer.appendChild(this.pageLeft);
|
|
}
|
|
|
|
// Create or find page_right
|
|
this.pageRight = document.getElementById('page_right');
|
|
if (!this.pageRight) {
|
|
console.log('UIDisplayHandler: Right page not found, creating it');
|
|
this.pageRight = document.createElement('div');
|
|
this.pageRight.id = 'page_right';
|
|
bookContainer.appendChild(this.pageRight);
|
|
}
|
|
|
|
// Create or find story container
|
|
this.container = document.getElementById('story');
|
|
if (!this.container) {
|
|
console.log('UIDisplayHandler: Story container not found, creating it');
|
|
this.container = document.createElement('div');
|
|
this.container.id = 'story';
|
|
this.container.className = 'container';
|
|
this.pageRight.appendChild(this.container);
|
|
}
|
|
|
|
if (!document.getElementById('start_prompt')) {
|
|
const startPrompt = document.createElement('div');
|
|
startPrompt.id = 'start_prompt';
|
|
startPrompt.textContent = 'Klick on new game or load to start the game';
|
|
this.pageRight.appendChild(startPrompt);
|
|
}
|
|
|
|
// Create paragraph container inside story container
|
|
this.paragraphContainer = document.getElementById('paragraphs');
|
|
if (!this.paragraphContainer) {
|
|
console.log('UIDisplayHandler: Paragraphs container not found, creating it');
|
|
this.paragraphContainer = document.createElement('div');
|
|
this.paragraphContainer.id = 'paragraphs';
|
|
this.container.appendChild(this.paragraphContainer);
|
|
}
|
|
|
|
// Create ruler for text measurements
|
|
let ruler = document.getElementById('ruler');
|
|
if (!ruler) {
|
|
ruler = document.createElement('div');
|
|
ruler.id = 'ruler';
|
|
document.body.appendChild(ruler);
|
|
}
|
|
|
|
// Create lighting effect
|
|
let lighting = document.getElementById('lighting');
|
|
if (!lighting) {
|
|
lighting = document.createElement('div');
|
|
lighting.id = 'lighting';
|
|
document.body.appendChild(lighting);
|
|
}
|
|
|
|
console.log('UIDisplayHandler: All containers initialized');
|
|
}
|
|
|
|
/**
|
|
* Measure text width using canvas
|
|
* @param {string} text - Text to measure
|
|
* @returns {number} - Width of the text
|
|
*/
|
|
measureText(text) {
|
|
// Use ParagraphLayout's measureText function instead of implementing our own
|
|
if (this.paragraphLayout && typeof this.paragraphLayout.measureText === 'function') {
|
|
return this.paragraphLayout.measureText(text);
|
|
}
|
|
|
|
// Fallback measuring if paragraph layout is not available
|
|
if (!this.canvas) {
|
|
this.canvas = document.createElement('canvas');
|
|
this.context = this.canvas.getContext('2d');
|
|
this.context.font = `${this.config.typography.fontSize} ${this.config.typography.fontFamily}`;
|
|
}
|
|
|
|
return this.context.measureText(text).width;
|
|
}
|
|
|
|
|
|
/**
|
|
* Display text in the UI (backward compatibility)
|
|
* Note: Text should flow through SentenceQueue instead
|
|
* @param {string} text - Text to display
|
|
* @param {Object} options - Display options
|
|
* @returns {Promise<HTMLElement>} - Promise resolving to the displayed paragraph element
|
|
*/
|
|
displayText(text, options = {}) {
|
|
if (!text) return Promise.resolve(null);
|
|
|
|
// For backward compatibility, delegate to sentence queue
|
|
console.warn('UIDisplayHandler.displayText called directly, text should flow through SentenceQueue');
|
|
|
|
const sentenceQueue = this.getModule('sentence-queue');
|
|
if (sentenceQueue) {
|
|
return new Promise(resolve => {
|
|
sentenceQueue.addSentence(text, () => resolve(null));
|
|
});
|
|
}
|
|
|
|
return Promise.resolve(null);
|
|
}
|
|
|
|
/**
|
|
* Render a prepared sentence to the display
|
|
* @param {Object} sentence - Prepared sentence object from SentenceQueue
|
|
* @returns {Promise<HTMLElement>} - Promise resolving to the paragraph element
|
|
*/
|
|
async renderSentence(sentence) {
|
|
if (!sentence || !sentence.layout) {
|
|
if (sentence && sentence.kind === 'heading') {
|
|
return this.renderHeading(sentence);
|
|
}
|
|
if (sentence && (sentence.kind === 'image' || sentence.kind === 'music')) {
|
|
return this.handleDeferredMediaBlock(sentence);
|
|
}
|
|
console.error('UIDisplayHandler: Invalid sentence object');
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
// Render DOM from layout data
|
|
const paragraphElement = this.layoutRenderer.renderParagraph(
|
|
sentence.layout,
|
|
{ id: sentence.id }
|
|
);
|
|
|
|
// Append to container
|
|
if (this.paragraphContainer) {
|
|
this.paragraphContainer.appendChild(paragraphElement);
|
|
if (typeof this.layoutRenderer.adjustJustification === 'function') {
|
|
this.layoutRenderer.adjustJustification(paragraphElement);
|
|
}
|
|
} else {
|
|
console.error('UIDisplayHandler: Paragraph container not found');
|
|
return null;
|
|
}
|
|
|
|
// Store element reference in sentence
|
|
sentence.element = paragraphElement;
|
|
this.renderedItems.push({
|
|
type: 'paragraph',
|
|
id: sentence.id,
|
|
text: sentence.text,
|
|
metadata: {
|
|
layoutText: sentence.layout?.sourceLayoutText || sentence.text,
|
|
cueMarkers: sentence.cueMarkers || [],
|
|
role: sentence.role || 'body',
|
|
isFirstParagraphInChapter: sentence.isFirstParagraphInChapter,
|
|
dropCap: sentence.dropCap,
|
|
addTopSpace: sentence.addTopSpace,
|
|
paragraphIndex: sentence.paragraphIndex
|
|
}
|
|
});
|
|
|
|
// Start coordinated playback (animation + TTS)
|
|
await this.playbackCoordinator.play(sentence);
|
|
|
|
// Scroll to bottom
|
|
if (this.pageRight) {
|
|
this.pageRight.scrollTop = this.pageRight.scrollHeight;
|
|
}
|
|
|
|
// Call completion callback
|
|
if (sentence.onComplete) {
|
|
sentence.onComplete();
|
|
}
|
|
|
|
return paragraphElement;
|
|
|
|
} catch (error) {
|
|
console.error('UIDisplayHandler: Error rendering sentence:', error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async renderHeading(sentence) {
|
|
const heading = document.createElement('p');
|
|
heading.id = sentence.id;
|
|
heading.className = 'story-chapter-heading';
|
|
heading.innerHTML = sentence.metadata?.layoutText || sentence.text;
|
|
this.renderedItems.push({
|
|
type: 'heading',
|
|
id: sentence.id,
|
|
text: sentence.text,
|
|
layoutText: sentence.metadata?.layoutText || sentence.text
|
|
});
|
|
|
|
if (this.paragraphContainer) {
|
|
this.paragraphContainer.appendChild(heading);
|
|
}
|
|
|
|
if (sentence.onComplete) {
|
|
sentence.onComplete();
|
|
}
|
|
|
|
return heading;
|
|
}
|
|
|
|
async rerenderStory() {
|
|
if (!this.paragraphContainer || this.renderedItems.length === 0) return;
|
|
|
|
const sentenceQueue = this.getModule('sentence-queue');
|
|
if (!sentenceQueue || typeof sentenceQueue.prepareLayout !== 'function') return;
|
|
|
|
console.log('UIDisplayHandler: Re-typesetting story after page resize');
|
|
const scrollTop = this.pageRight ? this.pageRight.scrollTop : 0;
|
|
this.paragraphContainer.innerHTML = '';
|
|
|
|
for (const item of this.renderedItems) {
|
|
if (item.type === 'heading') {
|
|
const heading = document.createElement('p');
|
|
heading.id = item.id;
|
|
heading.className = 'story-chapter-heading';
|
|
heading.innerHTML = item.layoutText || item.text;
|
|
this.paragraphContainer.appendChild(heading);
|
|
continue;
|
|
}
|
|
|
|
if (item.type !== 'paragraph') continue;
|
|
|
|
const layout = await sentenceQueue.prepareLayout(item.text, item.metadata || {});
|
|
const paragraph = this.layoutRenderer.renderParagraph(layout, { id: item.id });
|
|
paragraph.querySelectorAll('.word').forEach(word => {
|
|
word.style.transition = 'none';
|
|
word.style.visibility = 'visible';
|
|
word.style.opacity = '1';
|
|
word.style.transform = 'translateY(0)';
|
|
});
|
|
this.paragraphContainer.appendChild(paragraph);
|
|
}
|
|
|
|
if (this.pageRight) {
|
|
this.pageRight.scrollTop = scrollTop;
|
|
}
|
|
}
|
|
|
|
async handleDeferredMediaBlock(sentence) {
|
|
document.dispatchEvent(new CustomEvent('story:media-block', {
|
|
detail: {
|
|
id: sentence.id,
|
|
type: sentence.kind,
|
|
...(sentence.metadata || {})
|
|
}
|
|
}));
|
|
|
|
if (sentence.kind === 'music') {
|
|
const leadInSeconds = Number(sentence.metadata?.leadInSeconds || sentence.metadata?.leadIn || 0);
|
|
if (leadInSeconds > 0) {
|
|
console.log(`UIDisplayHandler: Waiting ${leadInSeconds}s before continuing after music block`);
|
|
await new Promise(resolve => setTimeout(resolve, leadInSeconds * 1000));
|
|
}
|
|
}
|
|
|
|
if (sentence.onComplete) {
|
|
sentence.onComplete();
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
clear() {
|
|
if (this.container) {
|
|
this.container.innerHTML = '';
|
|
this.paragraphContainer = document.createElement('div');
|
|
this.paragraphContainer.id = 'paragraphs';
|
|
this.container.appendChild(this.paragraphContainer);
|
|
}
|
|
this.renderedItems = [];
|
|
}
|
|
|
|
/**
|
|
* Show choices in the UI
|
|
* @param {Array<Object>} choices - Array of choice objects
|
|
* @param {Function} callback - Callback function for choice selection
|
|
* @returns {Promise<HTMLElement>} - Promise resolving to the choices container
|
|
*/
|
|
showChoices(choices, callback) {
|
|
if (!choices || choices.length === 0) {
|
|
return Promise.resolve(null);
|
|
}
|
|
|
|
return new Promise((resolve) => {
|
|
// Find or create choices container
|
|
let choicesContainer = document.getElementById('choices');
|
|
if (!choicesContainer) {
|
|
// UI Input Handler should create this, but if it doesn't exist yet, create it
|
|
choicesContainer = document.createElement('div');
|
|
choicesContainer.id = 'choices';
|
|
choicesContainer.className = 'container';
|
|
this.pageLeft.appendChild(choicesContainer);
|
|
}
|
|
|
|
// Create a dedicated container for this set of choices
|
|
const choicesGroup = document.createElement('div');
|
|
choicesGroup.className = 'choices-group';
|
|
choicesContainer.appendChild(choicesGroup);
|
|
|
|
// Create each choice button
|
|
choices.forEach((choice, index) => {
|
|
const choiceButton = document.createElement('button');
|
|
choiceButton.className = 'choice-button';
|
|
choiceButton.textContent = choice.text;
|
|
|
|
// Add index as data attribute
|
|
choiceButton.dataset.index = index;
|
|
|
|
// Add event listener
|
|
choiceButton.addEventListener('click', () => {
|
|
// Disable all buttons in this group
|
|
Array.from(choicesGroup.querySelectorAll('button')).forEach(btn => {
|
|
btn.disabled = true;
|
|
btn.classList.add('selected');
|
|
});
|
|
|
|
// Highlight the selected button
|
|
choiceButton.classList.add('selected');
|
|
|
|
// Call the callback
|
|
if (typeof callback === 'function') {
|
|
callback(index, choice);
|
|
}
|
|
});
|
|
|
|
choicesGroup.appendChild(choiceButton);
|
|
});
|
|
|
|
// Schedule transition for choices to appear
|
|
setTimeout(() => {
|
|
choicesGroup.classList.add('visible');
|
|
|
|
// Scroll to the choices
|
|
choicesGroup.scrollIntoView({ behavior: 'smooth', block: 'end' });
|
|
|
|
// Resolve the promise with the choices container
|
|
resolve(choicesGroup);
|
|
}, 100);
|
|
});
|
|
}
|
|
}
|
|
|
|
// Create the singleton instance
|
|
const uiDisplayHandler = new UIDisplayHandlerModule();
|
|
|
|
// Export the module
|
|
export { uiDisplayHandler as UIDisplayHandler };
|
|
|
|
// Register with the module registry
|
|
if (window.moduleRegistry) {
|
|
window.moduleRegistry.register(uiDisplayHandler);
|
|
}
|
|
|
|
// Keep a reference in window for loader system
|
|
window.UIDisplayHandler = uiDisplayHandler;
|