1486 lines
62 KiB
JavaScript
1486 lines
62 KiB
JavaScript
/**
|
|
* UI Display Handler Module
|
|
* Manages the display of text and UI elements
|
|
*/
|
|
import { BaseModule } from './base-module.js';
|
|
|
|
const PAGE_LINE_COUNT = 25;
|
|
|
|
class UIDisplayHandlerModule extends BaseModule {
|
|
constructor() {
|
|
super('ui-display-handler', 'UI Display Handler');
|
|
|
|
// Module dependencies
|
|
this.dependencies = ['layout-renderer', 'playback-coordinator', 'game-config', 'localization', 'story-history', 'sentence-queue', 'persistence-manager'];
|
|
|
|
// 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;
|
|
this.visibleBlockLimit = 41;
|
|
this.historyBufferBlocks = 20;
|
|
this.pageLineCount = PAGE_LINE_COUNT;
|
|
this.historyWindowStartId = 1;
|
|
this.historyWindowEndId = 0;
|
|
this.loadingHistoryPage = false;
|
|
this.draggingStoryScrollbar = false;
|
|
this.historyWheelAccumulator = 0;
|
|
this.storyTopLine = 0;
|
|
this.storyOffsetPx = 0;
|
|
this.storyScrollAnimation = null;
|
|
this.storyScrollAnimationId = 0;
|
|
this.viewportLineCount = 1;
|
|
this.lineHeightPx = 24;
|
|
this.activeCenterBlockId = null;
|
|
this.historyEnsurePending = false;
|
|
this.lastEnsuredCenterBucket = 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',
|
|
'applyGameConfig',
|
|
'applyTranslations',
|
|
'renderSentence',
|
|
'markBlockRendered',
|
|
'trimVisibleBlocks',
|
|
'restoreFromHistory',
|
|
'renderStoredItem',
|
|
'renderHistoryWindow',
|
|
'renderHistoryWindowForTurn',
|
|
'insertStoredElement',
|
|
'trimVirtualWindow',
|
|
'handleHistoryWheel',
|
|
'disableAutoplayForManualScroll',
|
|
'updateStoryScrollbar',
|
|
'handleStoryScrollbarPointer',
|
|
'handleDeferredMediaBlock',
|
|
'renderImageBlock',
|
|
'revealImageBlock',
|
|
'resolveImageUrl',
|
|
'calculateImageMetrics',
|
|
'measureStoryLineHeight',
|
|
'measureBlockLines',
|
|
'recordRenderedMetrics',
|
|
'setVirtualPadding',
|
|
'setStoryOffset',
|
|
'scrollStoryByLines',
|
|
'setStoryTopLine',
|
|
'getMaxStoryTopLine',
|
|
'ensureLiveTailWindow',
|
|
'ensureHistoryWindowForLine',
|
|
'loadHistoryWindowAround',
|
|
'readFirstFiniteNumber',
|
|
'waitForSkippablePause',
|
|
'scrollStoryToEnd',
|
|
'scrollToTurn',
|
|
'handleStoryScroll',
|
|
'rerenderStory',
|
|
'clear',
|
|
'scheduleRerender',
|
|
'measureText',
|
|
'loadCSS',
|
|
'showChoices',
|
|
'preloadImages'
|
|
]);
|
|
|
|
console.log('UIDisplayHandler: Constructor initialized');
|
|
}
|
|
|
|
t(key, params = {}) {
|
|
return this.localization?.translate?.(key, params) || key;
|
|
}
|
|
|
|
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.gameConfig = this.getModule('game-config');
|
|
this.localization = this.getModule('localization');
|
|
this.storyHistory = this.getModule('story-history');
|
|
this.persistenceManager = this.getModule('persistence-manager');
|
|
|
|
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();
|
|
});
|
|
this.addEventListener(document, 'game:config', (event) => {
|
|
this.applyGameConfig(event.detail);
|
|
});
|
|
this.addEventListener(document, 'localization:languageChanged', () => {
|
|
this.applyTranslations();
|
|
});
|
|
this.addEventListener(document, 'story:scroll-to-turn', (event) => {
|
|
this.scrollToTurn(event.detail?.turnId);
|
|
});
|
|
this.addEventListener(document, 'story:history-updated', (event) => {
|
|
this.updateStoryScrollbar(event.detail || {});
|
|
});
|
|
this.addEventListener(document, 'wheel', this.handleHistoryWheel, { passive: false });
|
|
this.addEventListener(document, 'keydown', (event) => {
|
|
const tagName = String(event.target?.tagName || '').toLowerCase();
|
|
if (['input', 'textarea', 'select'].includes(tagName) || event.altKey || event.ctrlKey || event.metaKey) {
|
|
return;
|
|
}
|
|
if (event.key === 'ArrowUp') {
|
|
event.preventDefault();
|
|
this.disableAutoplayForManualScroll();
|
|
this.scrollStoryByLines(-3, true);
|
|
} else if (event.key === 'ArrowDown') {
|
|
event.preventDefault();
|
|
this.disableAutoplayForManualScroll();
|
|
this.scrollStoryByLines(3, true);
|
|
}
|
|
});
|
|
this.addEventListener(document, 'story:process-state', (event) => {
|
|
const state = event.detail?.state || 'ready';
|
|
const remark = document.getElementById('remark_text');
|
|
if (remark) {
|
|
remark.textContent = state === 'paused'
|
|
? this.t('title.continueHint')
|
|
: this.t('title.fastForwardHint');
|
|
}
|
|
});
|
|
|
|
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" id="game_author"></h2>
|
|
<h1 class="title" id="game_title"></h1>
|
|
<h3 class="subtitle" id="game_subtitle"></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 id="speech"></a>
|
|
<a id="autoplay"></a>
|
|
<span><a id="speed_reset"><span id="speed_label"></span><sup>*</sup></a><input type="range" min="50" max="150" value="100" id="speed" name="speed" /></span>
|
|
<a id="rewind"></a>
|
|
<a id="save"></a>
|
|
<a id="reload" disabled="disabled"></a>
|
|
<a id="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" rows="1" autofocus autocomplete="off" autocorrect="off" autocapitalize="sentences" spellcheck="true" aria-autocomplete="none" data-form-type="other" data-1p-ignore="true" data-lpignore="true" data-bwignore="true"></textarea>
|
|
<span id="cursor"></span>
|
|
</div>
|
|
`;
|
|
choicesContainer.appendChild(commandInput);
|
|
|
|
this.pageLeft.appendChild(choicesContainer);
|
|
|
|
// Create remark
|
|
const remark = document.createElement('div');
|
|
remark.id = 'remark';
|
|
remark.innerHTML = `
|
|
<div id="remark_hint"><i><sup>*</sup><span id="remark_text"></span></i></div>
|
|
<div id="game_legal"></div>
|
|
`;
|
|
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);
|
|
}
|
|
if (!document.getElementById('story_scrollbar')) {
|
|
const storyScrollbar = document.createElement('div');
|
|
storyScrollbar.id = 'story_scrollbar';
|
|
storyScrollbar.innerHTML = '<div id="story_scrollbar_thumb"></div>';
|
|
this.pageRight.appendChild(storyScrollbar);
|
|
}
|
|
const storyScrollbar = document.getElementById('story_scrollbar');
|
|
if (storyScrollbar && !storyScrollbar.dataset.historyScrollBound) {
|
|
storyScrollbar.dataset.historyScrollBound = 'true';
|
|
['pointerdown', 'pointerup', 'mousedown', 'mouseup', 'click', 'dblclick'].forEach((type) => {
|
|
storyScrollbar.addEventListener(type, (event) => {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
if (type === 'pointerdown') {
|
|
this.handleStoryScrollbarPointer(event);
|
|
}
|
|
if (typeof event.stopImmediatePropagation === 'function') {
|
|
event.stopImmediatePropagation();
|
|
}
|
|
}, true);
|
|
});
|
|
storyScrollbar.addEventListener('wheel', (event) => {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
this.handleHistoryWheel(event);
|
|
}, { passive: false });
|
|
}
|
|
|
|
// 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';
|
|
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');
|
|
this.applyGameConfig(this.gameConfig?.getConfig?.());
|
|
this.applyTranslations();
|
|
this.measureStoryLineHeight();
|
|
this.setStoryOffset(0);
|
|
}
|
|
|
|
applyGameConfig(config) {
|
|
const metadata = config?.metadata || this.gameConfig?.getMetadata?.() || {};
|
|
const titleElement = document.getElementById('game_title');
|
|
const authorElement = document.getElementById('game_author');
|
|
const subtitleElement = document.getElementById('game_subtitle');
|
|
const legalElement = document.getElementById('game_legal');
|
|
document.getElementById('game_version')?.remove();
|
|
document.getElementById('game_copyright')?.remove();
|
|
|
|
if (titleElement) titleElement.textContent = metadata.title || '';
|
|
if (authorElement) authorElement.textContent = metadata.author ? this.t('title.byAuthor', { author: metadata.author }) : '';
|
|
if (subtitleElement) subtitleElement.textContent = metadata.subtitle || '';
|
|
if (legalElement) {
|
|
const items = [
|
|
metadata.version ? this.t('title.version', { version: metadata.version }) : '',
|
|
metadata.copyright || ''
|
|
].filter(Boolean);
|
|
legalElement.textContent = items.join(' · ');
|
|
}
|
|
}
|
|
|
|
applyTranslations() {
|
|
this.localization = this.getModule('localization') || this.localization;
|
|
|
|
const setText = (id, key) => {
|
|
const element = document.getElementById(id);
|
|
if (element) element.textContent = this.t(key);
|
|
};
|
|
const setTitle = (id, key) => {
|
|
const element = document.getElementById(id);
|
|
if (element) element.setAttribute('title', this.t(key));
|
|
};
|
|
|
|
setText('speech', 'topbar.speech');
|
|
setText('autoplay', 'topbar.autoplay');
|
|
setText('speed_label', 'topbar.speed');
|
|
setText('rewind', 'topbar.newGame');
|
|
setText('save', 'topbar.save');
|
|
setText('reload', 'topbar.load');
|
|
setText('options', 'topbar.options');
|
|
setText('remark_text', 'title.fastForwardHint');
|
|
setText('start_prompt', 'title.startPrompt');
|
|
setTitle('speech', 'topbar.speechTitle');
|
|
setTitle('autoplay', 'topbar.autoplayTitle');
|
|
setTitle('rewind', 'topbar.newGameTitle');
|
|
setTitle('save', 'topbar.saveTitle');
|
|
setTitle('reload', 'topbar.loadTitle');
|
|
setTitle('options', 'topbar.optionsTitle');
|
|
|
|
const input = document.getElementById('player_input');
|
|
if (input) input.setAttribute('placeholder', this.t('input.placeholder'));
|
|
this.applyGameConfig(this.gameConfig?.getConfig?.());
|
|
}
|
|
|
|
/**
|
|
* 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 a local UI message outside the server turn protocol.
|
|
* Story output must flow through structured TurnResult objects instead.
|
|
*/
|
|
/**
|
|
* 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 === 'image' || sentence.kind === 'music')) {
|
|
return this.handleDeferredMediaBlock(sentence);
|
|
}
|
|
console.error('UIDisplayHandler: Invalid sentence object');
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
await this.ensureLiveTailWindow();
|
|
|
|
// Render DOM from layout data
|
|
const paragraphElement = this.layoutRenderer.renderParagraph(
|
|
sentence.layout,
|
|
{ id: sentence.id }
|
|
);
|
|
if (sentence.turnId != null) {
|
|
paragraphElement.dataset.turnId = String(sentence.turnId);
|
|
paragraphElement.classList.add('story-turn-block');
|
|
}
|
|
if (sentence.blockId != null) {
|
|
paragraphElement.dataset.storyBlockId = String(sentence.blockId);
|
|
this.markBlockRendered(sentence.blockId);
|
|
}
|
|
|
|
const renderedItem = {
|
|
type: sentence.kind === 'heading' ? 'heading' : 'paragraph',
|
|
id: sentence.id,
|
|
turnId: sentence.turnId ?? null,
|
|
blockId: sentence.blockId ?? null,
|
|
gameId: sentence.gameId ?? null,
|
|
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
|
|
}
|
|
};
|
|
|
|
// Append to container
|
|
if (this.paragraphContainer) {
|
|
this.paragraphContainer.appendChild(paragraphElement);
|
|
this.renderedItems.push(renderedItem);
|
|
this.historyWindowStartId = this.renderedItems.find(item => item.blockId != null)?.blockId || this.historyWindowStartId;
|
|
this.historyWindowEndId = [...this.renderedItems].reverse().find(item => item.blockId != null)?.blockId || this.historyWindowEndId;
|
|
this.updateStoryScrollbar();
|
|
if (typeof this.layoutRenderer.adjustJustification === 'function') {
|
|
this.layoutRenderer.adjustJustification(paragraphElement);
|
|
}
|
|
const updated = await this.recordRenderedMetrics(sentence.blockId, paragraphElement);
|
|
if (updated) {
|
|
renderedItem.lineStart = updated.lineStart;
|
|
renderedItem.lineCount = updated.lineCount;
|
|
renderedItem.metadata.lineStart = updated.lineStart;
|
|
renderedItem.metadata.lineCount = updated.lineCount;
|
|
this.setVirtualPadding();
|
|
}
|
|
} else {
|
|
console.error('UIDisplayHandler: Paragraph container not found');
|
|
return null;
|
|
}
|
|
|
|
// Store element reference in sentence
|
|
sentence.element = paragraphElement;
|
|
await this.trimVisibleBlocks();
|
|
|
|
await this.scrollStoryToEnd(true);
|
|
|
|
// Start coordinated playback (animation + TTS), including chapter headings.
|
|
await this.playbackCoordinator.play(sentence);
|
|
|
|
// Call completion callback
|
|
if (sentence.onComplete) {
|
|
sentence.onComplete();
|
|
}
|
|
|
|
return paragraphElement;
|
|
|
|
} catch (error) {
|
|
console.error('UIDisplayHandler: Error rendering sentence:', error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
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 storyTopLine = this.storyTopLine || 0;
|
|
this.paragraphContainer.innerHTML = '';
|
|
|
|
for (const item of this.renderedItems) {
|
|
if (item.type === 'image') {
|
|
const sentenceQueue = this.getModule('sentence-queue');
|
|
const metadata = {
|
|
...item,
|
|
...(item.metadata || {}),
|
|
turnId: item.turnId ?? item.metadata?.turnId,
|
|
blockId: item.blockId ?? item.metadata?.blockId
|
|
};
|
|
const imageLayout = sentenceQueue && typeof sentenceQueue.prepareImageLayout === 'function'
|
|
? await sentenceQueue.prepareImageLayout(metadata)
|
|
: null;
|
|
this.renderImageBlock({
|
|
...metadata,
|
|
imageLayout: imageLayout || metadata.imageLayout
|
|
}, false);
|
|
continue;
|
|
}
|
|
|
|
if (item.type === 'heading') {
|
|
const layout = await sentenceQueue.prepareLayout(item.text, item.metadata || {});
|
|
const heading = this.layoutRenderer.renderParagraph(layout, { id: item.id });
|
|
if (item.turnId != null) {
|
|
heading.dataset.turnId = String(item.turnId);
|
|
heading.classList.add('story-turn-block');
|
|
}
|
|
heading.querySelectorAll('.word').forEach(word => {
|
|
word.style.transition = 'none';
|
|
word.style.animation = 'none';
|
|
word.style.visibility = 'visible';
|
|
word.style.opacity = '1';
|
|
word.style.transform = 'translateY(0)';
|
|
word.style.clipPath = 'inset(0 0 0 0)';
|
|
});
|
|
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 });
|
|
if (item.turnId != null) {
|
|
paragraph.dataset.turnId = String(item.turnId);
|
|
paragraph.classList.add('story-turn-block');
|
|
}
|
|
paragraph.querySelectorAll('.word').forEach(word => {
|
|
word.style.transition = 'none';
|
|
word.style.animation = 'none';
|
|
word.style.visibility = 'visible';
|
|
word.style.opacity = '1';
|
|
word.style.transform = 'translateY(0)';
|
|
word.style.clipPath = 'inset(0 0 0 0)';
|
|
});
|
|
this.paragraphContainer.appendChild(paragraph);
|
|
}
|
|
|
|
this.measureStoryLineHeight();
|
|
this.setStoryTopLine(storyTopLine, false, { mode: 'rerender-preserve', ensure: false });
|
|
}
|
|
|
|
scrollStoryToEnd(smooth = true) {
|
|
return this.setStoryTopLine(this.getMaxStoryTopLine(), smooth, { mode: 'auto-bottom', ensure: false });
|
|
}
|
|
|
|
async restoreFromHistory(saveRecord = {}) {
|
|
if (!this.paragraphContainer || !this.storyHistory || !saveRecord?.gameId) return;
|
|
const latestRenderedBlockId = Math.max(0, Number(saveRecord.latestRenderedBlockId || 0));
|
|
if (!this.storyHistory.renderedLineCount) {
|
|
await this.storyHistory.getRenderedLineCount(saveRecord.gameId, latestRenderedBlockId);
|
|
}
|
|
const blocks = await this.storyHistory.getBlocks(
|
|
saveRecord.gameId,
|
|
this.visibleBlockLimit,
|
|
latestRenderedBlockId + 1
|
|
);
|
|
await this.renderHistoryWindow(blocks, 'bottom');
|
|
this.updateStoryScrollbar({ latestBlockId: saveRecord.latestBlockId || blocks.at(-1)?.blockId || 1 });
|
|
}
|
|
|
|
insertStoredElement(element, placement = 'append') {
|
|
if (!this.paragraphContainer || !element) return;
|
|
if (placement === 'prepend') {
|
|
this.paragraphContainer.insertBefore(element, this.paragraphContainer.firstChild);
|
|
} else {
|
|
this.paragraphContainer.appendChild(element);
|
|
}
|
|
}
|
|
|
|
async renderStoredItem(item, placement = 'append') {
|
|
const sentenceQueue = this.getModule('sentence-queue');
|
|
if (!sentenceQueue) return null;
|
|
if (placement === 'prepend') {
|
|
this.renderedItems.unshift(item);
|
|
} else {
|
|
this.renderedItems.push(item);
|
|
}
|
|
|
|
if (item.type === 'image') {
|
|
const metadata = {
|
|
...item,
|
|
...(item.metadata || {}),
|
|
turnId: item.turnId ?? item.metadata?.turnId,
|
|
blockId: item.blockId ?? item.metadata?.blockId
|
|
};
|
|
const imageLayout = typeof sentenceQueue.prepareImageLayout === 'function'
|
|
? await sentenceQueue.prepareImageLayout(metadata)
|
|
: null;
|
|
const imageElement = this.renderImageBlock({
|
|
...metadata,
|
|
imageLayout: imageLayout || metadata.imageLayout
|
|
}, false, placement);
|
|
if (imageElement && item.blockId != null) imageElement.dataset.storyBlockId = String(item.blockId);
|
|
if (imageElement && Number.isFinite(Number(item.lineStart))) imageElement.dataset.lineStart = String(item.lineStart);
|
|
if (imageElement && Number.isFinite(Number(item.lineCount))) imageElement.dataset.lineCount = String(item.lineCount);
|
|
return imageElement;
|
|
}
|
|
|
|
if (item.type !== 'heading' && item.type !== 'paragraph') return null;
|
|
const metadata = {
|
|
...(item.metadata || {}),
|
|
type: item.type,
|
|
role: item.role || item.metadata?.role || (item.type === 'heading' ? 'chapter-heading' : 'body'),
|
|
layoutText: item.layoutText || item.metadata?.layoutText || item.text,
|
|
isFirstParagraphInChapter: Boolean(item.isFirstParagraphInChapter ?? item.metadata?.isFirstParagraphInChapter),
|
|
dropCap: Boolean(item.dropCap ?? item.metadata?.dropCap),
|
|
addTopSpace: Boolean(item.addTopSpace ?? item.metadata?.addTopSpace),
|
|
paragraphIndex: item.paragraphIndex ?? item.metadata?.paragraphIndex,
|
|
cueMarkers: item.cueMarkers || item.metadata?.cueMarkers || [],
|
|
turnId: item.turnId ?? item.metadata?.turnId,
|
|
blockId: item.blockId ?? item.metadata?.blockId,
|
|
gameId: item.gameId ?? item.metadata?.gameId
|
|
};
|
|
const layout = await sentenceQueue.prepareLayout(item.text, metadata);
|
|
const element = this.layoutRenderer.renderParagraph(layout, { id: item.id });
|
|
if (item.turnId != null) {
|
|
element.dataset.turnId = String(item.turnId);
|
|
element.classList.add('story-turn-block');
|
|
}
|
|
if (item.blockId != null) element.dataset.storyBlockId = String(item.blockId);
|
|
if (Number.isFinite(Number(item.lineStart))) element.dataset.lineStart = String(item.lineStart);
|
|
if (Number.isFinite(Number(item.lineCount))) element.dataset.lineCount = String(item.lineCount);
|
|
element.querySelectorAll('.word').forEach(word => {
|
|
word.style.transition = 'none';
|
|
word.style.animation = 'none';
|
|
word.style.visibility = 'visible';
|
|
word.style.opacity = '1';
|
|
word.style.transform = 'translateY(0)';
|
|
word.style.clipPath = 'inset(0 0 0 0)';
|
|
});
|
|
this.insertStoredElement(element, placement);
|
|
return element;
|
|
}
|
|
|
|
async renderHistoryWindow(blocks = [], scrollTarget = 'top') {
|
|
if (!this.paragraphContainer) return;
|
|
const previousTopLine = this.storyTopLine || 0;
|
|
this.paragraphContainer.innerHTML = '';
|
|
this.renderedItems = [];
|
|
|
|
for (const item of blocks) {
|
|
await this.renderStoredItem(item, 'append');
|
|
}
|
|
|
|
this.historyWindowStartId = blocks[0]?.blockId || 1;
|
|
this.historyWindowEndId = blocks.at(-1)?.blockId || 0;
|
|
this.setVirtualPadding();
|
|
this.updateStoryScrollbar();
|
|
|
|
await new Promise(resolve => {
|
|
window.requestAnimationFrame(() => {
|
|
window.requestAnimationFrame(() => {
|
|
if (scrollTarget === 'bottom') {
|
|
this.scrollStoryToEnd(false);
|
|
} else if (scrollTarget === 'preserve') {
|
|
this.setStoryTopLine(previousTopLine, false, { mode: 'history-preserve', ensure: false });
|
|
} else {
|
|
const firstLine = Number(blocks[0]?.lineStart || 0);
|
|
this.setStoryTopLine(firstLine, false, { mode: 'history-top', ensure: false });
|
|
}
|
|
resolve();
|
|
});
|
|
});
|
|
});
|
|
}
|
|
|
|
async renderHistoryWindowForTurn(turnId) {
|
|
if (!this.storyHistory || !this.paragraphContainer || turnId == null) return null;
|
|
const result = await this.storyHistory.getWindowForTurn(
|
|
this.storyHistory.currentGameId,
|
|
turnId,
|
|
this.visibleBlockLimit
|
|
);
|
|
if (!result?.blocks?.length) return null;
|
|
await this.renderHistoryWindow(result.blocks, 'top');
|
|
return result.targetBlockId;
|
|
}
|
|
|
|
handleHistoryWheel(event) {
|
|
if (!event.target?.closest?.('#page_right') || !this.pageRight) return;
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
this.disableAutoplayForManualScroll();
|
|
const lineDelta = (Number(event.deltaY || 0) / Math.max(8, this.lineHeightPx || 24)) * 0.85;
|
|
this.scrollStoryByLines(lineDelta, true);
|
|
}
|
|
|
|
async trimVirtualWindow(direction = 1) {
|
|
if (!this.paragraphContainer) return;
|
|
const excess = this.renderedItems.length - this.visibleBlockLimit;
|
|
if (excess <= 0) return;
|
|
|
|
for (let index = 0; index < excess; index += 1) {
|
|
if (direction > 0) {
|
|
const removed = this.renderedItems.shift();
|
|
const element = removed?.blockId != null
|
|
? this.paragraphContainer.querySelector(`[data-story-block-id="${CSS.escape(String(removed.blockId))}"]`)
|
|
: null;
|
|
element?.remove();
|
|
} else {
|
|
const removed = this.renderedItems.pop();
|
|
const element = removed?.blockId != null
|
|
? this.paragraphContainer.querySelector(`[data-story-block-id="${CSS.escape(String(removed.blockId))}"]`)
|
|
: null;
|
|
element?.remove();
|
|
}
|
|
}
|
|
|
|
this.historyWindowStartId = this.renderedItems.find(item => item.blockId != null)?.blockId || 1;
|
|
this.historyWindowEndId = [...this.renderedItems].reverse().find(item => item.blockId != null)?.blockId || 0;
|
|
this.setVirtualPadding();
|
|
}
|
|
|
|
markBlockRendered(blockId) {
|
|
if (this.storyHistory && typeof this.storyHistory.markRendered === 'function') {
|
|
const latestRenderedBlockId = this.storyHistory.markRendered(blockId);
|
|
document.dispatchEvent(new CustomEvent('story:history-updated', {
|
|
detail: {
|
|
gameId: this.storyHistory.currentGameId || null,
|
|
latestBlockId: this.getLatestHistoryBlockId(),
|
|
latestRenderedBlockId
|
|
}
|
|
}));
|
|
}
|
|
}
|
|
|
|
getLatestHistoryBlockId() {
|
|
return Math.max(0, Number(this.storyHistory?.latestRenderedBlockId || 0));
|
|
}
|
|
|
|
hasNewerHistory() {
|
|
return this.historyWindowEndId < this.getLatestHistoryBlockId();
|
|
}
|
|
|
|
updateStoryScrollbar(detail = {}) {
|
|
const track = document.getElementById('story_scrollbar');
|
|
const thumb = document.getElementById('story_scrollbar_thumb');
|
|
if (!thumb) return;
|
|
this.measureStoryLineHeight();
|
|
const totalLines = Math.max(1, Number(detail.renderedLineCount || this.storyHistory?.renderedLineCount || 0));
|
|
const viewportLines = Math.max(1, this.viewportLineCount || 1);
|
|
const visibleLines = Math.min(viewportLines, totalLines);
|
|
const maxTopLine = Math.max(0, totalLines - visibleLines);
|
|
const currentTop = Math.max(0, Math.min(maxTopLine, this.storyTopLine || 0));
|
|
const heightPercent = Math.max(8, Math.min(100, (visibleLines / totalLines) * 100));
|
|
const topPercent = maxTopLine <= 0 ? 0 : (currentTop / maxTopLine) * (100 - heightPercent);
|
|
if (track) {
|
|
track.dataset.totalLines = String(totalLines);
|
|
track.dataset.viewportLines = String(viewportLines);
|
|
track.dataset.topLine = String(currentTop);
|
|
track.hidden = totalLines <= viewportLines;
|
|
}
|
|
thumb.style.height = `${heightPercent}%`;
|
|
thumb.style.top = `${topPercent}%`;
|
|
}
|
|
|
|
handleStoryScrollbarPointer(event) {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
this.disableAutoplayForManualScroll();
|
|
const track = event.currentTarget;
|
|
if (!track) return;
|
|
const moveToPointer = (pointerEvent) => {
|
|
const rect = track.getBoundingClientRect();
|
|
const ratio = Math.max(0, Math.min(1, (pointerEvent.clientY - rect.top) / Math.max(1, rect.height)));
|
|
this.setStoryTopLine(this.getMaxStoryTopLine() * ratio, true, { mode: 'manual' });
|
|
};
|
|
|
|
moveToPointer(event);
|
|
const onMove = (moveEvent) => moveToPointer(moveEvent);
|
|
const onUp = () => {
|
|
document.removeEventListener('pointermove', onMove);
|
|
document.removeEventListener('pointerup', onUp);
|
|
};
|
|
document.addEventListener('pointermove', onMove);
|
|
document.addEventListener('pointerup', onUp, { once: true });
|
|
}
|
|
|
|
disableAutoplayForManualScroll() {
|
|
if (!this.persistenceManager || typeof this.persistenceManager.updatePreference !== 'function') {
|
|
console.error('UIDisplayHandler: Cannot disable autoplay; persistence-manager dependency is unavailable.');
|
|
return;
|
|
}
|
|
this.persistenceManager.updatePreference('app', 'autoplay', false);
|
|
document.dispatchEvent(new CustomEvent('app:autoplay:change', {
|
|
detail: { enabled: false, autoplay: false, source: 'manual-story-scroll' }
|
|
}));
|
|
}
|
|
|
|
measureStoryLineHeight() {
|
|
const pageHeight = this.pageRight?.clientHeight || 0;
|
|
const lineHeight = pageHeight > 0
|
|
? pageHeight / this.pageLineCount
|
|
: this.lineHeightPx || 24;
|
|
this.lineHeightPx = lineHeight;
|
|
this.viewportLineCount = this.pageLineCount;
|
|
document.documentElement.style.setProperty('--page-line-count', String(this.pageLineCount));
|
|
document.documentElement.style.setProperty('--story-line-height', `${lineHeight}px`);
|
|
document.documentElement.style.setProperty('--story-font-size', `${lineHeight / 1.45}px`);
|
|
return this.lineHeightPx;
|
|
}
|
|
|
|
measureBlockLines(element, fallbackLineCount = 1) {
|
|
const lineHeight = this.measureStoryLineHeight();
|
|
const declaredLines = Number(element?.dataset?.heightLines);
|
|
if (Number.isFinite(declaredLines) && declaredLines > 0) {
|
|
const lineCount = Math.max(1, Math.round(declaredLines));
|
|
return { lineCount, heightPx: lineCount * lineHeight, lineHeightPx: lineHeight };
|
|
}
|
|
throw new Error(`UIDisplayHandler: Rendered story block ${element?.id || '(unknown)'} has no data-height-lines declaration.`);
|
|
}
|
|
|
|
async recordRenderedMetrics(blockId, element, fallbackLineCount = 1) {
|
|
if (!this.storyHistory || typeof this.storyHistory.updateBlockMetrics !== 'function' || blockId == null) return null;
|
|
await new Promise(resolve => window.requestAnimationFrame(resolve));
|
|
const metrics = this.measureBlockLines(element, fallbackLineCount);
|
|
const updated = await this.storyHistory.updateBlockMetrics(blockId, metrics);
|
|
if (updated && element) {
|
|
element.dataset.lineStart = String(updated.lineStart || 0);
|
|
element.dataset.lineCount = String(updated.lineCount || metrics.lineCount);
|
|
}
|
|
this.setVirtualPadding();
|
|
this.updateStoryScrollbar();
|
|
return updated;
|
|
}
|
|
|
|
setVirtualPadding() {
|
|
if (!this.paragraphContainer) return;
|
|
const first = this.renderedItems.find(item => item.blockId != null);
|
|
const last = [...this.renderedItems].reverse().find(item => item.blockId != null);
|
|
const topLines = Math.max(0, Number(first?.lineStart || first?.metadata?.lineStart || 0));
|
|
const lastStart = Number(last?.lineStart ?? last?.metadata?.lineStart ?? 0);
|
|
const lastCount = Number(last?.lineCount ?? last?.metadata?.lineCount ?? 0);
|
|
const totalLines = Math.max(0, Number(this.storyHistory?.renderedLineCount || 0));
|
|
const bottomLines = Math.max(0, totalLines - (Number.isFinite(lastStart) ? lastStart + Math.max(1, lastCount || 1) : totalLines));
|
|
const lineHeight = this.measureStoryLineHeight();
|
|
this.paragraphContainer.style.paddingTop = `${topLines * lineHeight}px`;
|
|
this.paragraphContainer.style.paddingBottom = `${bottomLines * lineHeight}px`;
|
|
}
|
|
|
|
setStoryOffset(offsetPx) {
|
|
this.storyOffsetPx = Number(offsetPx) || 0;
|
|
if (this.container) {
|
|
this.container.style.transform = `translateY(${this.storyOffsetPx}px)`;
|
|
}
|
|
this.handleStoryScroll();
|
|
this.updateStoryScrollbar();
|
|
}
|
|
|
|
getMaxStoryTopLine() {
|
|
this.measureStoryLineHeight();
|
|
const totalLines = Math.max(0, Number(this.storyHistory?.renderedLineCount || 0));
|
|
return Math.max(0, totalLines - Math.max(1, this.viewportLineCount || 1));
|
|
}
|
|
|
|
scrollStoryByLines(deltaLines, smooth = true) {
|
|
const nextLine = (this.storyTopLine || 0) + Number(deltaLines || 0);
|
|
return this.setStoryTopLine(nextLine, smooth, { mode: 'manual' });
|
|
}
|
|
|
|
setStoryTopLine(targetLine, smooth = true, options = {}) {
|
|
this.measureStoryLineHeight();
|
|
const maxTopLine = this.getMaxStoryTopLine();
|
|
const target = Math.round(Math.max(0, Math.min(maxTopLine, Number(targetLine || 0))));
|
|
const start = this.storyTopLine || 0;
|
|
const delta = target - start;
|
|
const animationId = ++this.storyScrollAnimationId;
|
|
|
|
if (!smooth || Math.abs(delta) < 0.02) {
|
|
this.storyTopLine = target;
|
|
this.setStoryOffset(-(target * this.lineHeightPx));
|
|
if (options.ensure !== false) {
|
|
this.ensureHistoryWindowForLine(target + (this.viewportLineCount / 2));
|
|
}
|
|
return Promise.resolve();
|
|
}
|
|
|
|
return new Promise(resolve => {
|
|
const startedAt = performance.now();
|
|
const duration = Math.min(1100, Math.max(360, Math.abs(delta) * 36));
|
|
const ease = (t) => t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
|
|
const shouldEnsureDuringAnimation = options.ensure !== false;
|
|
const step = (now) => {
|
|
if (animationId !== this.storyScrollAnimationId) {
|
|
resolve();
|
|
return;
|
|
}
|
|
const progress = Math.min(1, (now - startedAt) / duration);
|
|
this.storyTopLine = start + (delta * ease(progress));
|
|
this.setStoryOffset(-(this.storyTopLine * this.lineHeightPx));
|
|
if (shouldEnsureDuringAnimation) {
|
|
this.ensureHistoryWindowForLine(this.storyTopLine + (this.viewportLineCount / 2));
|
|
}
|
|
if (progress < 1) {
|
|
requestAnimationFrame(step);
|
|
} else {
|
|
this.storyTopLine = target;
|
|
this.setStoryOffset(-(target * this.lineHeightPx));
|
|
if (shouldEnsureDuringAnimation) {
|
|
this.ensureHistoryWindowForLine(target + (this.viewportLineCount / 2));
|
|
}
|
|
resolve();
|
|
}
|
|
};
|
|
requestAnimationFrame(step);
|
|
});
|
|
}
|
|
|
|
async ensureLiveTailWindow() {
|
|
if (!this.storyHistory || !this.paragraphContainer || this.loadingHistoryPage) return;
|
|
const latestRendered = Math.max(0, Number(this.storyHistory.latestRenderedBlockId || 0));
|
|
if (latestRendered <= 0) return;
|
|
if ((this.historyWindowEndId || 0) >= latestRendered) return;
|
|
|
|
const count = Math.max(1, this.visibleBlockLimit - 1);
|
|
const start = Math.max(1, latestRendered - count + 1);
|
|
const blocks = await this.storyHistory.getBlocksRange(this.storyHistory.currentGameId, start, latestRendered);
|
|
if (!blocks.length) return;
|
|
|
|
await this.renderHistoryWindow(blocks, 'bottom');
|
|
this.activeCenterBlockId = latestRendered;
|
|
this.lastEnsuredCenterBucket = null;
|
|
}
|
|
|
|
async ensureHistoryWindowForLine(centerLine) {
|
|
if (!this.storyHistory || this.loadingHistoryPage) return;
|
|
const bucket = Math.floor(Math.max(0, Number(centerLine || 0)) / Math.max(1, this.viewportLineCount || 1));
|
|
if (this.historyEnsurePending || bucket === this.lastEnsuredCenterBucket) return;
|
|
this.historyEnsurePending = true;
|
|
this.lastEnsuredCenterBucket = bucket;
|
|
try {
|
|
const block = await this.storyHistory.findBlockForLine(
|
|
this.storyHistory.currentGameId,
|
|
centerLine,
|
|
this.storyHistory.latestRenderedBlockId
|
|
);
|
|
const centerBlockId = block?.blockId;
|
|
if (!centerBlockId || centerBlockId === this.activeCenterBlockId) return;
|
|
this.activeCenterBlockId = centerBlockId;
|
|
const hasEnoughBefore = centerBlockId - (this.historyWindowStartId || 1) >= this.historyBufferBlocks;
|
|
const hasEnoughAfter = (this.historyWindowEndId || 0) - centerBlockId >= this.historyBufferBlocks;
|
|
if (!hasEnoughBefore || !hasEnoughAfter) {
|
|
await this.loadHistoryWindowAround(centerBlockId, 'preserve');
|
|
}
|
|
} finally {
|
|
this.historyEnsurePending = false;
|
|
}
|
|
}
|
|
|
|
async loadHistoryWindowAround(centerBlockId, scrollTarget = 'preserve') {
|
|
if (!this.storyHistory || this.loadingHistoryPage) return;
|
|
const latest = Math.max(0, Number(this.storyHistory.latestRenderedBlockId || 0));
|
|
const center = Math.max(1, Math.min(latest, Number(centerBlockId || 1)));
|
|
const start = Math.max(1, center - this.historyBufferBlocks);
|
|
const end = Math.min(latest, center + this.historyBufferBlocks);
|
|
if (start === this.historyWindowStartId && end === this.historyWindowEndId) return;
|
|
this.loadingHistoryPage = true;
|
|
try {
|
|
const blocks = await this.storyHistory.getBlocksRange(this.storyHistory.currentGameId, start, end);
|
|
if (blocks.length) {
|
|
await this.renderHistoryWindow(blocks, scrollTarget);
|
|
}
|
|
} finally {
|
|
this.loadingHistoryPage = false;
|
|
}
|
|
}
|
|
|
|
async trimVisibleBlocks() {
|
|
if (!this.paragraphContainer) return;
|
|
await this.trimVirtualWindow(1);
|
|
this.updateStoryScrollbar();
|
|
}
|
|
|
|
scrollToTurn(turnId) {
|
|
if (!this.pageRight || turnId == null) return;
|
|
const escapedTurnId = CSS.escape(String(turnId));
|
|
const scrollToLiveTarget = () => {
|
|
const target = this.paragraphContainer?.querySelector(`[data-turn-id="${escapedTurnId}"]`);
|
|
if (!target) return false;
|
|
const targetLine = Number(target.dataset.lineStart);
|
|
if (Number.isFinite(targetLine)) {
|
|
this.setStoryTopLine(targetLine, true, { mode: 'jump-to-turn' });
|
|
}
|
|
return true;
|
|
};
|
|
|
|
if (scrollToLiveTarget()) return;
|
|
this.renderHistoryWindowForTurn(turnId).then(() => {
|
|
requestAnimationFrame(() => scrollToLiveTarget());
|
|
});
|
|
}
|
|
|
|
handleStoryScroll() {
|
|
if (!this.pageRight || !this.paragraphContainer) return;
|
|
|
|
const blocks = Array.from(this.paragraphContainer.querySelectorAll('[data-turn-id]'));
|
|
if (blocks.length === 0) return;
|
|
|
|
const viewportMiddle = (this.storyTopLine * this.measureStoryLineHeight()) + (this.pageRight.clientHeight / 2);
|
|
let best = null;
|
|
let bestDistance = Infinity;
|
|
|
|
blocks.forEach((block) => {
|
|
const lineStart = Number(block.dataset.lineStart);
|
|
const lineCount = Number(block.dataset.lineCount);
|
|
if (!Number.isFinite(lineStart) || !Number.isFinite(lineCount)) {
|
|
return;
|
|
}
|
|
const blockMiddle = (lineStart + (lineCount / 2)) * this.lineHeightPx;
|
|
const distance = Math.abs(blockMiddle - viewportMiddle);
|
|
if (distance < bestDistance) {
|
|
bestDistance = distance;
|
|
best = block;
|
|
}
|
|
});
|
|
|
|
if (best?.dataset?.turnId && this.activeTurnId !== best.dataset.turnId) {
|
|
this.activeTurnId = best.dataset.turnId;
|
|
document.dispatchEvent(new CustomEvent('story:visible-turn', {
|
|
detail: { turnId: Number(best.dataset.turnId) }
|
|
}));
|
|
}
|
|
}
|
|
|
|
async handleDeferredMediaBlock(sentence) {
|
|
document.dispatchEvent(new CustomEvent('story:media-block', {
|
|
detail: {
|
|
id: sentence.id,
|
|
type: sentence.kind,
|
|
...(sentence.metadata || {})
|
|
}
|
|
}));
|
|
|
|
if (sentence.kind === 'image') {
|
|
await this.ensureLiveTailWindow();
|
|
const element = this.renderImageBlock({ ...(sentence.metadata || {}), id: sentence.id, revealImmediately: false }, true);
|
|
const renderedItem = {
|
|
type: 'image',
|
|
id: sentence.id,
|
|
turnId: sentence.turnId ?? null,
|
|
blockId: sentence.blockId ?? null,
|
|
gameId: sentence.gameId ?? null,
|
|
text: '',
|
|
metadata: { ...(sentence.metadata || {}), id: sentence.id }
|
|
};
|
|
if (element && sentence.blockId != null) {
|
|
element.dataset.storyBlockId = String(sentence.blockId);
|
|
this.markBlockRendered(sentence.blockId);
|
|
const updated = await this.recordRenderedMetrics(sentence.blockId, element, sentence.metadata?.imageLayout?.lineCount || 1);
|
|
if (updated) {
|
|
renderedItem.lineStart = updated.lineStart;
|
|
renderedItem.lineCount = updated.lineCount;
|
|
renderedItem.metadata.lineStart = updated.lineStart;
|
|
renderedItem.metadata.lineCount = updated.lineCount;
|
|
}
|
|
}
|
|
this.renderedItems.push(renderedItem);
|
|
this.historyWindowStartId = this.renderedItems.find(item => item.blockId != null)?.blockId || this.historyWindowStartId;
|
|
this.historyWindowEndId = [...this.renderedItems].reverse().find(item => item.blockId != null)?.blockId || this.historyWindowEndId;
|
|
this.setVirtualPadding();
|
|
this.updateStoryScrollbar();
|
|
await this.trimVisibleBlocks();
|
|
|
|
await this.scrollStoryToEnd(true);
|
|
this.revealImageBlock(element);
|
|
|
|
if (sentence.onComplete) {
|
|
sentence.onComplete();
|
|
}
|
|
|
|
return element;
|
|
}
|
|
|
|
if (sentence.kind === 'music') {
|
|
console.log('UIDisplayHandler: Music block started', sentence.metadata || {});
|
|
if (sentence.blockId != null) {
|
|
this.markBlockRendered(sentence.blockId);
|
|
}
|
|
}
|
|
|
|
if (sentence.onComplete) {
|
|
sentence.onComplete();
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
readFirstFiniteNumber(...values) {
|
|
for (const value of values) {
|
|
const number = Number(value);
|
|
if (Number.isFinite(number)) {
|
|
return Math.max(0, number);
|
|
}
|
|
}
|
|
|
|
return 0;
|
|
}
|
|
|
|
waitForSkippablePause(seconds, kind = 'media') {
|
|
const duration = Math.max(0, Number(seconds) || 0) * 1000;
|
|
if (duration <= 0) return Promise.resolve(false);
|
|
|
|
document.documentElement.dataset.skippablePause = 'true';
|
|
document.dispatchEvent(new CustomEvent('story:process-state', {
|
|
detail: { state: 'playing-ready', reason: `${kind}-pause-start`, duration }
|
|
}));
|
|
|
|
return new Promise(resolve => {
|
|
let finished = false;
|
|
let timeoutId = null;
|
|
|
|
const finish = (skipped) => {
|
|
if (finished) return;
|
|
finished = true;
|
|
clearTimeout(timeoutId);
|
|
document.removeEventListener('ui:command', onCommand);
|
|
delete document.documentElement.dataset.skippablePause;
|
|
document.dispatchEvent(new CustomEvent('story:process-state', {
|
|
detail: { state: 'playing-ready', reason: `${kind}-pause-${skipped ? 'skipped' : 'complete'}` }
|
|
}));
|
|
resolve(skipped);
|
|
};
|
|
|
|
const onCommand = (event) => {
|
|
if (event.detail?.type === 'continue') {
|
|
finish(true);
|
|
}
|
|
};
|
|
|
|
document.addEventListener('ui:command', onCommand);
|
|
timeoutId = setTimeout(() => finish(false), duration);
|
|
});
|
|
}
|
|
|
|
renderImageBlock(metadata = {}, animate = true, placement = 'append') {
|
|
if (!this.paragraphContainer) return null;
|
|
|
|
const metrics = metadata.imageLayout || this.calculateImageMetrics(metadata.size);
|
|
const figure = document.createElement('figure');
|
|
if (metadata.id) {
|
|
figure.id = metadata.id;
|
|
}
|
|
figure.className = [
|
|
'story-image-block',
|
|
`story-image-${metrics.size || 'landscape'}`,
|
|
metrics.floatSide === 'right' ? 'story-image-float-right' : '',
|
|
metrics.floatSide === 'left' ? 'story-image-float-left' : '',
|
|
animate ? 'story-image-pending' : 'story-image-visible'
|
|
].filter(Boolean).join(' ');
|
|
figure.style.width = `${metrics.width}px`;
|
|
figure.style.height = `${metrics.height}px`;
|
|
figure.style.marginTop = `${metrics.verticalMargin || 0}px`;
|
|
figure.style.marginBottom = `${metrics.verticalMargin || 0}px`;
|
|
figure.dataset.heightLines = String(Math.max(1, Math.round(metrics.lineCount || 1)));
|
|
if ((metrics.size || metadata.size) === 'portrait') {
|
|
const gap = metrics.gap || 0;
|
|
figure.style.shapeMargin = `${gap}px`;
|
|
if (metrics.floatSide === 'right') {
|
|
figure.style.marginLeft = `${gap}px`;
|
|
} else {
|
|
figure.style.marginRight = `${gap}px`;
|
|
}
|
|
}
|
|
figure.dataset.animationMs = '2000';
|
|
if (metadata.turnId != null) {
|
|
figure.dataset.turnId = String(metadata.turnId);
|
|
figure.classList.add('story-turn-block');
|
|
}
|
|
|
|
const img = document.createElement('img');
|
|
img.src = this.resolveImageUrl(metadata);
|
|
img.alt = metadata.alt || '';
|
|
img.decoding = 'async';
|
|
img.loading = 'eager';
|
|
figure.appendChild(img);
|
|
|
|
this.insertStoredElement(figure, placement);
|
|
|
|
if (animate && metadata.revealImmediately !== false) {
|
|
window.requestAnimationFrame(() => this.revealImageBlock(figure));
|
|
} else {
|
|
if (!animate) {
|
|
figure.classList.remove('story-image-pending');
|
|
figure.classList.add('story-image-visible');
|
|
}
|
|
}
|
|
|
|
return figure;
|
|
}
|
|
|
|
revealImageBlock(figure) {
|
|
if (!figure) return;
|
|
figure.classList.remove('story-image-pending');
|
|
figure.classList.add('story-image-visible');
|
|
}
|
|
|
|
resolveImageUrl(metadata = {}) {
|
|
const explicit = String(metadata.url || '').trim();
|
|
if (explicit) return explicit;
|
|
|
|
const filename = String(metadata.filename || '').trim();
|
|
if (!filename) return '';
|
|
if (/^(https?:|data:|blob:|\/)/i.test(filename)) return filename;
|
|
return `/images/${filename.replace(/^images[\\/]/i, '').replace(/\\/g, '/')}`;
|
|
}
|
|
|
|
calculateImageMetrics(size = 'landscape') {
|
|
const storyElement = document.getElementById('story');
|
|
const pageWidth = storyElement?.clientWidth || 600;
|
|
const lineHeight = this.measureStoryLineHeight();
|
|
|
|
const normalizedSize = String(size || 'landscape').toLowerCase() === 'widescreen'
|
|
? 'landscape'
|
|
: String(size || 'landscape').toLowerCase();
|
|
const aspect = normalizedSize === 'portrait' ? (9 / 16) : normalizedSize === 'square' ? 1 : (16 / 9);
|
|
const isPortrait = normalizedSize === 'portrait';
|
|
const imageGap = lineHeight;
|
|
const maxOuterWidth = isPortrait ? pageWidth * 0.5 : pageWidth;
|
|
const maxImageWidth = isPortrait
|
|
? Math.max(lineHeight * 4, maxOuterWidth - imageGap)
|
|
: maxOuterWidth;
|
|
const naturalHeight = maxImageWidth / aspect;
|
|
const imageLineCount = Math.max(1, Math.floor(naturalHeight / lineHeight));
|
|
const verticalMargin = isPortrait ? lineHeight / 2 : 0;
|
|
const lineCount = isPortrait ? imageLineCount + 1 : imageLineCount;
|
|
const height = isPortrait
|
|
? Math.max(lineHeight, (lineCount * lineHeight) - (verticalMargin * 2))
|
|
: imageLineCount * lineHeight;
|
|
const width = Math.min(maxImageWidth, height * aspect);
|
|
|
|
return {
|
|
size: normalizedSize,
|
|
aspect,
|
|
width,
|
|
height,
|
|
gap: imageGap,
|
|
lineCount,
|
|
imageLineCount,
|
|
lineHeight,
|
|
verticalMargin,
|
|
floatSide: 'left',
|
|
pageWidth
|
|
};
|
|
}
|
|
|
|
clear() {
|
|
if (document.documentElement.dataset.skippablePause === 'true') {
|
|
document.dispatchEvent(new CustomEvent('ui:command', {
|
|
detail: { moduleId: this.id, type: 'continue', source: 'display-clear' }
|
|
}));
|
|
delete document.documentElement.dataset.skippablePause;
|
|
}
|
|
|
|
if (this.container) {
|
|
this.container.innerHTML = '';
|
|
this.paragraphContainer = document.createElement('div');
|
|
this.paragraphContainer.id = 'paragraphs';
|
|
this.container.appendChild(this.paragraphContainer);
|
|
}
|
|
this.renderedItems = [];
|
|
this.historyWindowStartId = 1;
|
|
this.historyWindowEndId = 0;
|
|
this.storyTopLine = 0;
|
|
this.activeCenterBlockId = null;
|
|
this.setVirtualPadding();
|
|
this.setStoryOffset(0);
|
|
this.updateStoryScrollbar({ latestBlockId: this.getLatestHistoryBlockId() });
|
|
}
|
|
|
|
/**
|
|
* 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);
|
|
});
|
|
|
|
window.requestAnimationFrame(() => {
|
|
choicesGroup.classList.add('visible');
|
|
resolve(choicesGroup);
|
|
});
|
|
});
|
|
}
|
|
}
|
|
|
|
// 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;
|