Checkpoint current interactive fiction state
This commit is contained in:
@@ -9,13 +9,17 @@ class UIDisplayHandlerModule extends BaseModule {
|
||||
super('ui-display-handler', 'UI Display Handler');
|
||||
|
||||
// Module dependencies
|
||||
this.dependencies = ['paragraph-layout', 'layout-renderer', 'animation-queue'];
|
||||
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';
|
||||
@@ -28,6 +32,12 @@ class UIDisplayHandlerModule extends BaseModule {
|
||||
this.bindMethods([
|
||||
'initializeContainers',
|
||||
'displayText',
|
||||
'renderSentence',
|
||||
'renderHeading',
|
||||
'handleDeferredMediaBlock',
|
||||
'rerenderStory',
|
||||
'clear',
|
||||
'scheduleRerender',
|
||||
'measureText',
|
||||
'loadCSS',
|
||||
'showChoices',
|
||||
@@ -49,9 +59,8 @@ class UIDisplayHandlerModule extends BaseModule {
|
||||
this.reportProgress(30, "Getting module dependencies");
|
||||
|
||||
// Get references to required modules using parent's getModule method
|
||||
this.paragraphLayout = this.getModule('paragraph-layout');
|
||||
this.layoutRenderer = this.getModule('layout-renderer');
|
||||
this.animationQueue = this.getModule('animation-queue');
|
||||
this.playbackCoordinator = this.getModule('playback-coordinator');
|
||||
|
||||
this.reportProgress(50, "Initializing display containers");
|
||||
|
||||
@@ -61,6 +70,40 @@ class UIDisplayHandlerModule extends BaseModule {
|
||||
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;
|
||||
@@ -70,6 +113,11 @@ class UIDisplayHandlerModule extends BaseModule {
|
||||
}
|
||||
}
|
||||
|
||||
scheduleRerender() {
|
||||
clearTimeout(this.resizeTimer);
|
||||
this.resizeTimer = setTimeout(() => this.rerenderStory(), 80);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Load CSS file asynchronously and wait for it to be applied
|
||||
@@ -160,9 +208,9 @@ class UIDisplayHandlerModule extends BaseModule {
|
||||
controls.id = 'controls';
|
||||
controls.className = 'buttons';
|
||||
controls.innerHTML = `
|
||||
<a class="l10n-speech" id="speech" title="Toggle text to speech" disabled="disabled">speech</a>
|
||||
<span><a id="speed_reset"><span class="l10n-speed">speed<sup>*</sup></span></a><input type="range" min="0" max="100" value="50" id="speed" name="speed" /></span>
|
||||
<a class="l10n-restart" id="rewind" title="Restart story from beginning" disabled="disabled">restart</a>
|
||||
<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>
|
||||
@@ -220,6 +268,13 @@ class UIDisplayHandlerModule extends BaseModule {
|
||||
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');
|
||||
@@ -272,33 +327,194 @@ class UIDisplayHandlerModule extends BaseModule {
|
||||
|
||||
|
||||
/**
|
||||
* Display text in the UI
|
||||
* 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);
|
||||
|
||||
return new Promise((resolve) => {
|
||||
// Generate a unique ID for this paragraph
|
||||
const paragraphId = `p-${Date.now()}-${this.currentParagraphId++}`;
|
||||
|
||||
// Add to pending paragraphs queue
|
||||
this.pendingParagraphs.push({
|
||||
id: paragraphId,
|
||||
text,
|
||||
options,
|
||||
resolve
|
||||
|
||||
// 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));
|
||||
});
|
||||
|
||||
// If this is the only paragraph, process it immediately
|
||||
if (this.pendingParagraphs.length === 1) {
|
||||
this.processNextParagraph();
|
||||
} else {
|
||||
console.log(`UIDisplayHandler: Queued paragraph (${this.pendingParagraphs.length} total)`);
|
||||
}
|
||||
|
||||
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 = [];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -376,3 +592,11 @@ 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;
|
||||
|
||||
Reference in New Issue
Block a user