Files
ai.interactive.fiction/public/js/ui-display-handler-module.js
T

2618 lines
112 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', 'webgl-book-scene', 'playback-coordinator', 'game-config', 'localization', 'story-history', 'sentence-queue', 'persistence-manager', 'markup-parser', 'book-pagination', 'book-texture-renderer'];
// 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.draggingStoryScrollbar = false;
this.storyTopLine = 0;
this.storyOffsetPx = 0;
this.windowOriginLine = 0;
this.storyScrollAnimation = null;
this.scrollAnimationFrameId = null;
this.scrollAnimationPromise = null;
this.scrollAnimationResolve = null;
this.scrollTargetLine = null;
this.scrollRequestId = 0;
this.renderWindowToken = 0;
this.displayGeneration = 0;
this.wheelLineAccumulator = 0;
this.viewportLineCount = 1;
this.lineHeightPx = 24;
this.activeCenterBlockId = null;
this.maxTraversalBlocks = 190;
this.scrollbarPreviewLine = null;
this.storyScrollbarReleaseHandler = null;
this.lastManualScrollAt = 0;
this.layoutFlowLine = 0;
this.layoutExclusions = [];
this.notificationQueue = [];
this.notificationActive = false;
this.pendingTerminalNotifications = [];
this.latestInputMode = 'text';
this.markdownRendererPromise = null;
// Resources to preload
this.cssPath = '/css/style.css';
this.imagesToPreload = [
'/images/book_detailed.png',
'/images/mat.png'
];
// Bind methods using parent's bindMethods utility
this.bindMethods([
'initializeContainers',
'applyGameConfig',
'applyTranslations',
'renderSentence',
'isWebGLMode',
'prepareWebGLBookReveal',
'renderStoryBlock',
'prepareRenderableBlock',
'prepareTextRenderable',
'prepareImageRenderable',
'createImageExclusion',
'addImageExclusion',
'rebuildLayoutExclusions',
'getActiveExclusions',
'buildLineGeometry',
'makeRenderedWordsVisible',
'markBlockRendered',
'restoreFromHistory',
'renderHistoryWindow',
'renderIncrementalWindow',
'setWindowOriginLine',
'removeRenderedBlocksOutside',
'removeRenderedElement',
'dedupeRenderedWindow',
'reflowTextBlocksForActiveExclusions',
'blockIntersectsExclusions',
'getFlowLineFromItems',
'insertStoredElement',
'handleHistoryWheel',
'handleManualScrollStart',
'getActiveLineForTopLine',
'getTopLineForActiveLine',
'getRenderedBlockForLine',
'getWindowBoundsForTraversal',
'renderWindowForBounds',
'disableAutoplayForManualScroll',
'updateStoryScrollbar',
'handleStoryScrollbarPointer',
'renderImageBlock',
'revealImageBlock',
'resolveImageUrl',
'calculateImageMetrics',
'measureStoryLineHeight',
'measureBlockLines',
'recordRenderedMetrics',
'setVirtualPadding',
'setStoryOffset',
'scrollUp',
'scrollDown',
'scrollTo',
'getCurrentScrollLine',
'getLiveEndLine',
'ensureScrollRangeForTarget',
'animateToTopLine',
'getMaxStoryTopLine',
'ensureLiveTailWindow',
'readFirstFiniteNumber',
'waitForSkippablePause',
'focusTurn',
'handleStoryScroll',
'rerenderStory',
'clear',
'scheduleRerender',
'isDisplayGenerationCurrent',
'measureText',
'loadCSS',
'showChoices',
'preloadImages',
'createCreditsDialog',
'openCreditsDialog',
'closeCreditsDialog',
'loadCreditsText',
'getMarkdownRenderer',
'renderMarkdown',
'populateCreativeCredits',
'creditLink',
'createNotificationDialog',
'handleStoryTag',
'getTagMessage',
'dispatchDeferredTagsForBlock',
'showNotification',
'displayNextNotification',
'queueTerminalNotification',
'flushTerminalNotifications',
'closeNotification',
'renderInlineMarkup'
]);
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.webglBookScene = this.getModule('webgl-book-scene');
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.focusTurn(event.detail?.turnId);
});
this.addEventListener(document, 'story:history-updated', (event) => {
this.updateStoryScrollbar(event.detail || {});
});
this.addEventListener(document, 'story:tag', (event) => {
this.handleStoryTag(event.detail);
});
this.addEventListener(document, 'story:turn-start', () => {
this.latestInputMode = 'text';
});
this.addEventListener(document, 'story:input-mode', (event) => {
this.latestInputMode = event.detail || 'text';
});
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.handleManualScrollStart('arrow-up');
this.scrollUp(1);
} else if (event.key === 'ArrowDown') {
event.preventDefault();
this.handleManualScrollStart('arrow-down');
this.scrollDown(1);
} else if (event.key === 'PageUp') {
event.preventDefault();
this.handleManualScrollStart('page-up');
this.scrollUp(24);
} else if (event.key === 'PageDown') {
event.preventDefault();
this.handleManualScrollStart('page-down');
this.scrollDown(24);
} else if (event.key === 'Home') {
event.preventDefault();
this.handleManualScrollStart('home');
this.scrollTo(0, { mode: 'home' });
} else if (event.key === 'End') {
event.preventDefault();
this.handleManualScrollStart('end');
this.scrollTo(this.getLiveEndLine(), { mode: 'end' });
}
});
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 (state === 'ready' && this.latestInputMode === 'end') {
this.flushTerminalNotifications();
}
});
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() {
this.webglBookScene?.ensureShell?.();
// 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></a><input type="range" min="50" max="200" 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';
const controlSeparator = document.createElement('div');
controlSeparator.id = 'left_control_separator';
choicesContainer.appendChild(controlSeparator);
// 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><span id="remark_text"></span></i></div>
<div id="game_legal"></div>
`;
this.pageLeft.appendChild(remark);
bookContainer.appendChild(this.pageLeft);
}
const choicesPanel = document.getElementById('choices');
if (choicesPanel && !document.getElementById('left_control_separator')) {
const controlSeparator = document.createElement('div');
controlSeparator.id = 'left_control_separator';
choicesPanel.insertBefore(controlSeparator, choicesPanel.firstChild);
}
// 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);
} else if ((type === 'pointerup' || type === 'mouseup') && typeof this.storyScrollbarReleaseHandler === 'function') {
this.storyScrollbarReleaseHandler(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);
}
this.createCreditsDialog();
this.createNotificationDialog();
console.log('UIDisplayHandler: All containers initialized');
this.webglBookScene?.adoptPageContent?.();
this.webglBookScene?.refreshModalOverview?.();
this.applyGameConfig(this.gameConfig?.getConfig?.());
this.webglBookScene?.adoptPageContent?.();
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.innerHTML = '';
const legalText = document.createElement('span');
legalText.id = 'game_legal_text';
legalText.textContent = items.join(' | ');
legalElement.appendChild(legalText);
const creditsButton = document.createElement('button');
creditsButton.id = 'credits_button';
creditsButton.type = 'button';
creditsButton.textContent = this.t('credits.button');
creditsButton.title = this.t('credits.buttonTitle');
creditsButton.addEventListener('click', (event) => {
event.preventDefault();
event.stopPropagation();
this.openCreditsDialog();
});
if (items.length > 0) {
legalElement.appendChild(document.createTextNode(' | '));
}
legalElement.appendChild(creditsButton);
}
}
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');
setText('credits_dialog_title', 'credits.title');
setText('credits_close_footer', 'credits.close');
setText('story_popup_ok', 'popup.ok');
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?.());
}
createCreditsDialog() {
if (document.getElementById('credits_modal')) {
return;
}
const modal = document.createElement('div');
modal.id = 'credits_modal';
modal.className = 'credits-modal';
modal.setAttribute('aria-hidden', 'true');
modal.innerHTML = `
<div class="credits-dialog" role="dialog" aria-modal="true" aria-labelledby="credits_dialog_title">
<div class="credits-dialog-header">
<h2 id="credits_dialog_title"></h2>
<button type="button" id="credits_close" class="close" aria-label="Close">&times;</button>
</div>
<div id="credits_creative" class="credits-creative"></div>
<div id="credits_content" class="credits-content"></div>
<div class="modal-footer">
<button type="button" id="credits_close_footer"></button>
</div>
</div>
`;
document.body.appendChild(modal);
modal.addEventListener('click', (event) => {
if (event.target === modal) {
this.closeCreditsDialog();
}
});
[document.getElementById('credits_close'), document.getElementById('credits_close_footer')]
.filter(Boolean)
.forEach(button => button.addEventListener('click', () => this.closeCreditsDialog()));
}
async openCreditsDialog() {
const modal = document.getElementById('credits_modal');
const content = document.getElementById('credits_content');
if (!modal || !content) {
return;
}
modal.classList.add('visible');
modal.setAttribute('aria-hidden', 'false');
if (!content.dataset.loaded) {
content.textContent = this.t('credits.loading');
content.innerHTML = await this.renderMarkdown(await this.loadCreditsText());
content.dataset.loaded = 'true';
}
this.populateCreativeCredits();
}
closeCreditsDialog() {
const modal = document.getElementById('credits_modal');
if (!modal) {
return;
}
modal.classList.remove('visible');
modal.setAttribute('aria-hidden', 'true');
}
async loadCreditsText() {
try {
const response = await fetch('/THIRD_PARTY_NOTICES.md', { cache: 'no-cache' });
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
return await response.text();
} catch (error) {
console.warn('UIDisplayHandler: Failed to load credits notices', error);
return this.t('credits.loadFailed');
}
}
async getMarkdownRenderer() {
if (!this.markdownRendererPromise) {
this.markdownRendererPromise = import('/js/vendor/marked.esm.js')
.then(module => module.marked || module.default || module);
}
return this.markdownRendererPromise;
}
async renderMarkdown(markdown) {
try {
const renderer = await this.getMarkdownRenderer();
if (typeof renderer.parse === 'function') {
return renderer.parse(String(markdown || ''), { async: false });
}
} catch (error) {
console.warn('UIDisplayHandler: Failed to render Markdown notices', error);
}
return `<pre>${String(markdown || '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')}</pre>`;
}
populateCreativeCredits() {
const container = document.getElementById('credits_creative');
if (!container || container.dataset.loaded) return;
const sections = [
{
title: 'Production',
rows: [
['Produced by', ['Bad Tools Studio']],
['Story', ['Georg Tomitsch']],
['Writing', ['Georg Tomitsch', 'ChatGPT']],
['UI visual design', ['Georg Tomitsch']],
['Typography', ['EB Garamond 12 by Georg Duffner and Octavio Pardo', 'EB Garamond Initials by Georg Duffner']],
['Art direction', ['Georg Tomitsch']],
['Music', ['Georg Tomitsch', 'Suno']],
['Images', ['OpenAI GPT-image-2']]
]
},
{
title: 'Technology',
rows: [
['Runtime server programming', ['Georg Tomitsch', 'OpenAI Codex']],
['Client and UI programming', ['Georg Tomitsch', 'OpenAI Codex', 'Claude Code']],
['Game engine', ['Ink by Inkle', 'inkjs by Yannick Lohse']]
]
}
];
container.innerHTML = sections.map(section => `
<section class="credits-creative-column">
<h3>${section.title}</h3>
${section.rows.map(([label, names]) => `
<div class="credits-creative-row">
<dt>${label}</dt>
<dd>${names.map(name => this.creditLink(name)).join(', ')}</dd>
</div>
`).join('')}
</section>
`).join('');
container.dataset.loaded = 'true';
}
creditLink(name) {
const links = {
'Bad Tools Studio': '',
'OpenAI Codex': 'https://openai.com/codex/',
'OpenAI GPT-image-2': 'https://openai.com/',
'ChatGPT': 'https://chatgpt.com/',
'Claude Code': 'https://www.anthropic.com/claude-code',
'Ink by Inkle': 'https://www.inklestudios.com/ink/',
'inkjs by Yannick Lohse': 'https://www.npmjs.com/package/inkjs',
'EB Garamond 12 by Georg Duffner and Octavio Pardo': 'https://github.com/octaviopardo/EBGaramond12',
'EB Garamond Initials by Georg Duffner': 'https://github.com/georgd/EB-Garamond',
'Suno': 'https://suno.com/'
};
const escaped = String(name || '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
const url = links[name];
return url ? `<a href="${url}" target="_blank" rel="noreferrer">${escaped}</a>` : escaped;
}
createNotificationDialog() {
if (document.getElementById('story_popup_modal')) {
return;
}
const modal = document.createElement('div');
modal.id = 'story_popup_modal';
modal.className = 'story-popup-modal';
modal.setAttribute('aria-hidden', 'true');
modal.innerHTML = `
<div class="story-popup-dialog" role="dialog" aria-modal="true" aria-labelledby="story_popup_title">
<div class="story-popup-dialog-header">
<h2 id="story_popup_title"></h2>
<button type="button" id="story_popup_close" class="close" aria-label="Close">&times;</button>
</div>
<div id="story_popup_message"></div>
<div class="modal-footer">
<button type="button" id="story_popup_ok"></button>
</div>
</div>
`;
document.body.appendChild(modal);
modal.addEventListener('click', (event) => {
event.preventDefault();
event.stopPropagation();
if (event.target === modal) {
this.closeNotification();
}
});
modal.addEventListener('pointerdown', (event) => {
event.stopPropagation();
});
[document.getElementById('story_popup_ok'), document.getElementById('story_popup_close')]
.filter(Boolean)
.forEach(button => button.addEventListener('click', (event) => {
event.preventDefault();
event.stopPropagation();
this.closeNotification();
}));
}
handleStoryTag(tag) {
const key = String(tag?.key || '').toLowerCase();
if (!['score', 'error', 'achievement', 'alert'].includes(key)) {
return;
}
const message = this.getTagMessage(tag);
if (key === 'score') {
this.showNotification(
'ending',
this.t('popup.endingTitle'),
message || this.t('popup.defaultEnding')
);
} else if (key === 'error') {
this.showNotification(
'error',
this.t('popup.errorTitle'),
message || this.t('popup.defaultError')
);
} else if (key === 'achievement') {
this.showNotification(
'achievement',
this.t('popup.achievementTitle'),
message || this.t('popup.defaultAchievement')
);
} else if (key === 'alert') {
this.showNotification(
'alert',
this.t('popup.alertTitle'),
message || this.t('popup.defaultAlert')
);
}
}
getTagMessage(tag) {
return [tag?.value, tag?.param]
.map((part) => String(part || '').trim())
.filter(Boolean)
.join('\n');
}
dispatchDeferredTagsForBlock(block) {
const directTags = Array.isArray(block?.deferredTags) ? block.deferredTags : [];
const metadataTags = Array.isArray(block?.metadata?.deferredTags) ? block.metadata.deferredTags : [];
const tags = [...directTags, ...metadataTags];
if (tags.length === 0) return;
tags.forEach((tag) => {
if (!tag?.key) return;
document.dispatchEvent(new CustomEvent('story:tag', {
detail: {
...tag,
blockId: block.blockId ?? null,
turnId: block.turnId ?? null
}
}));
});
block.deferredTags = [];
if (block.metadata) {
block.metadata.deferredTags = [];
}
}
showNotification(kind, title, message) {
this.notificationQueue.push({ kind, title, message });
this.displayNextNotification();
}
queueTerminalNotification(kind, title, message) {
this.pendingTerminalNotifications.push({ kind, title, message });
if (this.latestInputMode === 'end') {
this.flushTerminalNotifications();
}
}
flushTerminalNotifications() {
if (this.pendingTerminalNotifications.length === 0) {
return;
}
this.pendingTerminalNotifications.splice(0).forEach((notification) => {
this.showNotification(notification.kind, notification.title, notification.message);
});
}
displayNextNotification() {
if (this.notificationActive || this.notificationQueue.length === 0) {
return;
}
const next = this.notificationQueue.shift();
const modal = document.getElementById('story_popup_modal');
const title = document.getElementById('story_popup_title');
const message = document.getElementById('story_popup_message');
const okButton = document.getElementById('story_popup_ok');
if (!modal || !title || !message) {
return;
}
modal.dataset.kind = next.kind;
title.innerHTML = this.renderInlineMarkup(next.title);
message.innerHTML = this.renderInlineMarkup(next.message);
if (okButton) {
okButton.textContent = this.t('popup.ok');
setTimeout(() => okButton.focus(), 0);
}
this.notificationActive = true;
modal.classList.add('visible');
modal.setAttribute('aria-hidden', 'false');
}
closeNotification() {
const modal = document.getElementById('story_popup_modal');
if (!modal) {
this.notificationActive = false;
return;
}
modal.classList.remove('visible');
modal.setAttribute('aria-hidden', 'true');
this.notificationActive = false;
setTimeout(() => this.displayNextNotification(), 0);
}
renderInlineMarkup(text) {
const markupParser = this.getModule('markup-parser');
if (markupParser && typeof markupParser.markdownToHtml === 'function') {
return markupParser.markdownToHtml(String(text || ''));
}
return String(text || '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
/**
* 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;
}
async renderSentence(sentence) {
if (!sentence) {
console.error('UIDisplayHandler: Invalid sentence object');
return null;
}
const generation = this.displayGeneration;
const sentenceGameId = sentence.gameId || null;
const isCurrent = () => this.isDisplayGenerationCurrent(generation, sentenceGameId);
try {
await this.ensureLiveTailWindow();
if (!isCurrent()) return null;
await this.scrollTo(this.getLiveEndLine(), { mode: 'enter-live-tail', smooth: false });
if (!isCurrent()) return null;
this.rebuildLayoutExclusions(this.renderedItems);
this.layoutFlowLine = this.getFlowLineFromItems(this.renderedItems);
const useWebGLBookReveal = this.isWebGLMode() && (sentence.kind === 'paragraph' || sentence.kind === 'heading');
const element = await this.renderStoryBlock(sentence, {
animate: true,
playback: true,
placement: 'append',
token: this.renderWindowToken,
generation,
deferRenderedMark: useWebGLBookReveal
});
if (!element) return null;
if (!isCurrent()) {
element.remove();
return null;
}
sentence.element = element;
await this.scrollTo(this.getLiveEndLine(), { mode: 'append-live' });
if (!isCurrent()) {
element.remove();
return null;
}
if (sentence.kind === 'image') {
this.revealImageBlock(element);
} else if (sentence.kind === 'paragraph' || sentence.kind === 'heading') {
if (useWebGLBookReveal) {
await this.prepareWebGLBookReveal(sentence);
}
await this.playbackCoordinator.play(sentence);
if (useWebGLBookReveal && sentence.blockId != null) {
this.markBlockRendered(sentence.blockId);
}
} else if (sentence.kind === 'music') {
console.log('UIDisplayHandler: Music block started', sentence.metadata || {});
}
if (!isCurrent()) return null;
this.dispatchDeferredTagsForBlock(sentence);
if (sentence.onComplete) {
sentence.onComplete();
}
return element;
} catch (error) {
console.error('UIDisplayHandler: Error rendering sentence:', error);
throw error;
}
}
isWebGLMode() {
return document.body?.dataset?.webglUiMode === '3d'
|| document.body?.classList?.contains('webgl-mode');
}
async prepareWebGLBookReveal(sentence) {
const bookPagination = this.getModule('book-pagination');
const bookTextureRenderer = this.getModule('book-texture-renderer');
if (!bookPagination || !bookTextureRenderer || sentence.blockId == null) return;
const sentenceQueue = this.getModule('sentence-queue');
if (!Array.isArray(sentence.animation?.wordTimings) || sentence.animation.wordTimings.length === 0) {
const words = String(sentence.layoutText || sentence.text || '').match(/\S+/g) || [];
sentence.animation = sentenceQueue?.calculateAnimationTiming?.(words, sentence.tts?.duration || 0, sentence.cueMarkers || [])
|| { wordTimings: [], cueTimings: [], totalDuration: 0 };
}
if (typeof bookPagination.preparePendingBlock === 'function') {
await bookPagination.preparePendingBlock(sentence);
} else {
document.dispatchEvent(new CustomEvent('book-pagination:prepare-block', {
detail: {
block: sentence
}
}));
}
const revealDetail = {
id: sentence.id,
blockId: sentence.blockId,
wordTimings: sentence.animation?.wordTimings || [],
cueTimings: sentence.animation?.cueTimings || [],
totalDuration: sentence.animation?.totalDuration || 0
};
if (typeof bookTextureRenderer.prepareRevealBlock === 'function') {
bookTextureRenderer.prepareRevealBlock(revealDetail);
} else {
document.dispatchEvent(new CustomEvent('book-texture:prepare-reveal-block', {
detail: revealDetail
}));
}
}
async rerenderStory() {
if (!this.paragraphContainer || this.renderedItems.length === 0) return;
console.log('UIDisplayHandler: Re-typesetting story after page resize');
const generation = this.displayGeneration;
const activeLine = this.getCurrentScrollLine();
await this.renderHistoryWindow([...this.renderedItems], { generation });
if (!this.isDisplayGenerationCurrent(generation)) return;
await this.scrollTo(activeLine, { mode: 'rerender-preserve', smooth: false });
}
isDisplayGenerationCurrent(generation = this.displayGeneration, gameId = null) {
if (generation !== this.displayGeneration) return false;
if (gameId && this.storyHistory?.currentGameId && this.storyHistory.currentGameId !== gameId) {
return false;
}
return true;
}
async restoreFromHistory(saveRecord = {}) {
if (!this.paragraphContainer || !this.storyHistory || !saveRecord?.gameId) return;
const generation = this.displayGeneration;
const latestRenderedBlockId = Math.max(0, Number(saveRecord.latestRenderedBlockId || 0));
if (!this.storyHistory.renderedLineCount) {
await this.storyHistory.getRenderedLineCount(saveRecord.gameId, latestRenderedBlockId);
if (!this.isDisplayGenerationCurrent(generation, saveRecord.gameId)) return;
}
const targetLine = Math.max(0, latestRenderedBlockId > 0 ? this.storyHistory.renderedLineCount - 1 : 0);
if (latestRenderedBlockId > 0) {
const targetBlock = await this.storyHistory.findBlockForLine(saveRecord.gameId, targetLine, latestRenderedBlockId);
if (!this.isDisplayGenerationCurrent(generation, saveRecord.gameId)) return;
const targetBlockId = Math.max(1, Number(targetBlock?.blockId || latestRenderedBlockId));
await this.renderWindowForBounds({
start: Math.max(1, targetBlockId - this.historyBufferBlocks),
end: Math.min(latestRenderedBlockId, targetBlockId + this.historyBufferBlocks),
targetBlockId,
windowOriginLine: this.getTopLineForActiveLine(targetLine),
gameId: saveRecord.gameId,
generation
});
} else {
await this.renderHistoryWindow([], { windowOriginLine: 0, generation });
}
if (!this.isDisplayGenerationCurrent(generation, saveRecord.gameId)) return;
await this.scrollTo(targetLine, {
mode: 'restore-bottom',
smooth: false
});
if (!this.isDisplayGenerationCurrent(generation, saveRecord.gameId)) return;
this.updateStoryScrollbar({ latestBlockId: saveRecord.latestBlockId || latestRenderedBlockId || 1 });
}
insertStoredElement(element, placement = 'append', targetContainer = this.paragraphContainer) {
if (!targetContainer || !element) return;
if (placement === 'prepend') {
targetContainer.insertBefore(element, targetContainer.firstChild);
} else {
targetContainer.appendChild(element);
}
}
async renderStoryBlock(item, options = {}) {
const {
animate = false,
playback = false,
placement = 'append',
targetContainer = this.paragraphContainer,
renderedItemsTarget = this.renderedItems,
token = null,
recordMetrics = true,
generation = this.displayGeneration,
deferRenderedMark = false
} = options;
if (!item || !this.paragraphContainer) return null;
const renderable = await this.prepareRenderableBlock(item);
if (!this.isDisplayGenerationCurrent(generation, item.gameId || null)) return null;
if (token != null && token !== this.renderWindowToken) return null;
if (!renderable) return null;
const type = renderable.type;
let element = null;
if (type === 'image') {
element = this.renderImageBlock(renderable, animate, placement, targetContainer);
} else if (type === 'paragraph' || type === 'heading') {
element = this.layoutRenderer.renderParagraph(renderable.layout, { id: renderable.id });
this.insertStoredElement(element, placement, targetContainer);
if (!animate) {
this.makeRenderedWordsVisible(element);
element.dataset.playbackComplete = 'true';
}
if (playback) {
const sentenceQueue = this.getModule('sentence-queue');
const words = sentenceQueue?.extractWords?.(renderable.layout.nodes) || [];
item.animation = sentenceQueue?.calculateAnimationTiming?.(words, item.tts?.duration || 0, renderable.metadata.cueMarkers || [])
|| { wordTimings: [], cueTimings: [], totalDuration: 0 };
item.element = element;
}
} else {
element = document.createElement('div');
element.style.display = 'none';
this.insertStoredElement(element, placement, targetContainer);
if (playback) {
document.dispatchEvent(new CustomEvent('story:media-block', {
detail: {
id: item.id,
type,
...(item.metadata || {})
}
}));
}
}
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 (!deferRenderedMark) this.markBlockRendered(item.blockId);
}
element.dataset.lineStart = String(renderable.lineStart);
element.dataset.lineCount = String(renderable.lineCount);
element.dataset.heightLines = String(renderable.lineCount);
const renderedItem = {
...item,
type,
lineStart: renderable.lineStart,
lineCount: renderable.lineCount,
metadata: {
...(item.metadata || {}),
...renderable.metadata,
lineStart: renderable.lineStart,
lineCount: renderable.lineCount
}
};
if (placement === 'prepend') {
renderedItemsTarget.unshift(renderedItem);
} else {
renderedItemsTarget.push(renderedItem);
}
if (recordMetrics && item.blockId != null) {
const updated = await this.recordRenderedMetrics(item.blockId, element, renderable.lineCount, renderable.lineStart);
if (!this.isDisplayGenerationCurrent(generation, item.gameId || null)) {
element?.remove();
return null;
}
if (token != null && token !== this.renderWindowToken) {
element?.remove();
return null;
}
if (updated) {
renderedItem.lineStart = updated.lineStart;
renderedItem.lineCount = updated.lineCount;
renderedItem.metadata.lineStart = updated.lineStart;
renderedItem.metadata.lineCount = updated.lineCount;
}
}
this.historyWindowStartId = renderedItemsTarget.find(entry => entry.blockId != null)?.blockId || this.historyWindowStartId;
this.historyWindowEndId = [...renderedItemsTarget].reverse().find(entry => entry.blockId != null)?.blockId || this.historyWindowEndId;
this.setVirtualPadding();
this.updateStoryScrollbar();
return element;
}
async renderHistoryWindow(blocks = [], options = {}) {
if (!this.paragraphContainer) return;
const generation = options.generation ?? this.displayGeneration;
if (!this.isDisplayGenerationCurrent(generation, options.gameId || blocks[0]?.gameId || null)) return;
const token = ++this.renderWindowToken;
const orderedBlocks = Array.isArray(blocks)
? [...blocks].sort((left, right) => Number(left?.blockId || 0) - Number(right?.blockId || 0))
: [];
const lineStarts = orderedBlocks
.map(block => Number(block?.lineStart ?? block?.metadata?.lineStart))
.filter(Number.isFinite);
const nextOrigin = lineStarts.length ? Math.max(0, Math.min(...lineStarts)) : 0;
const fragment = document.createDocumentFragment();
const nextRenderedItems = [];
this.windowOriginLine = nextOrigin;
this.layoutFlowLine = nextOrigin;
this.rebuildLayoutExclusions(orderedBlocks);
for (const item of orderedBlocks) {
await this.renderStoryBlock(item, {
animate: false,
playback: false,
placement: 'append',
targetContainer: fragment,
renderedItemsTarget: nextRenderedItems,
token,
generation
});
if (token !== this.renderWindowToken) return;
if (!this.isDisplayGenerationCurrent(generation, item.gameId || options.gameId || null)) return;
}
if (token !== this.renderWindowToken) return;
if (!this.isDisplayGenerationCurrent(generation, options.gameId || orderedBlocks[0]?.gameId || null)) return;
this.paragraphContainer.replaceChildren(fragment);
this.renderedItems = nextRenderedItems;
this.historyWindowStartId = orderedBlocks[0]?.blockId || 1;
this.historyWindowEndId = orderedBlocks.at(-1)?.blockId || 0;
this.setVirtualPadding();
this.setStoryOffset(-((this.storyTopLine - (this.windowOriginLine || 0)) * this.lineHeightPx));
this.updateStoryScrollbar();
}
setWindowOriginLine(originLine = 0) {
const nextOrigin = Math.max(0, Math.round(Number(originLine || 0)));
this.windowOriginLine = nextOrigin;
const lineHeight = this.measureStoryLineHeight();
this.paragraphContainer?.querySelectorAll?.('[data-story-block-id][data-line-start]')?.forEach(element => {
const lineStart = Number(element.dataset.lineStart);
if (Number.isFinite(lineStart)) {
element.style.top = `${(lineStart - nextOrigin) * lineHeight}px`;
}
});
this.setStoryOffset(-((this.storyTopLine - nextOrigin) * lineHeight));
this.setVirtualPadding();
}
removeRenderedElement(item) {
if (!item?.blockId || !this.paragraphContainer) return;
const element = this.paragraphContainer.querySelector(`[data-story-block-id="${CSS.escape(String(item.blockId))}"]`);
element?.remove();
}
removeRenderedBlocksOutside(startBlockId, endBlockId) {
const start = Math.max(1, Number(startBlockId || 1));
const end = Math.max(start, Number(endBlockId || start));
this.renderedItems = this.renderedItems.filter(item => {
const blockId = Number(item?.blockId || 0);
const keep = blockId >= start && blockId <= end;
if (!keep) {
this.removeRenderedElement(item);
}
return keep;
});
this.historyWindowStartId = this.renderedItems.find(entry => entry.blockId != null)?.blockId || 1;
this.historyWindowEndId = [...this.renderedItems].reverse().find(entry => entry.blockId != null)?.blockId || 0;
}
dedupeRenderedWindow() {
const seenItems = new Set();
this.renderedItems = this.renderedItems
.sort((left, right) => Number(left?.blockId || 0) - Number(right?.blockId || 0))
.filter(item => {
const blockId = Number(item?.blockId || 0);
if (!blockId) return true;
if (seenItems.has(blockId)) return false;
seenItems.add(blockId);
return true;
});
const seenElements = new Set();
this.paragraphContainer?.querySelectorAll?.('[data-story-block-id]')?.forEach(element => {
const blockId = element.dataset.storyBlockId;
if (!blockId) return;
if (seenElements.has(blockId)) {
element.remove();
} else {
seenElements.add(blockId);
}
});
if (this.paragraphContainer) {
const ordered = document.createDocumentFragment();
this.renderedItems.forEach(item => {
if (item?.blockId == null) return;
const element = this.paragraphContainer.querySelector(`[data-story-block-id="${CSS.escape(String(item.blockId))}"]`);
if (element) ordered.appendChild(element);
});
this.paragraphContainer.appendChild(ordered);
}
}
blockIntersectsExclusions(item = {}) {
if (!this.layoutExclusions.length) return false;
const type = item.kind || item.type || 'paragraph';
if (type !== 'paragraph' && type !== 'heading') return false;
const start = Number(item.lineStart ?? item.metadata?.lineStart);
const count = Math.max(0, Number(item.lineCount ?? item.metadata?.lineCount ?? 0));
if (!Number.isFinite(start) || count <= 0) return false;
const end = start + count;
return this.layoutExclusions.some(exclusion => start < exclusion.endLine && end > exclusion.startLine);
}
getFlowLineFromItems(items = this.renderedItems) {
const source = Array.isArray(items) ? items : [];
return source.reduce((max, item) => {
const type = String(item?.kind || item?.type || '').toLowerCase();
const size = String(item?.metadata?.imageLayout?.size || item?.metadata?.size || item?.size || '').toLowerCase();
if (type === 'image' && size === 'portrait') {
return max;
}
const start = Number(item?.lineStart ?? item?.metadata?.lineStart);
const count = Math.max(0, Number(item?.lineCount ?? item?.metadata?.lineCount ?? 0));
return Number.isFinite(start) && count > 0 ? Math.max(max, start + count) : max;
}, 0);
}
async reflowTextBlocksForActiveExclusions(token = this.renderWindowToken) {
if (!this.layoutExclusions.length || !this.paragraphContainer) return;
const generation = this.displayGeneration;
const candidates = this.renderedItems.filter(item => this.blockIntersectsExclusions(item));
for (const item of candidates) {
if (token !== this.renderWindowToken) return;
if (!this.isDisplayGenerationCurrent(generation, item.gameId || null)) return;
const oldElement = this.paragraphContainer.querySelector(`[data-story-block-id="${CSS.escape(String(item.blockId))}"]`);
if (!oldElement) continue;
const previousFlowLine = this.layoutFlowLine;
const previousExclusions = [...this.layoutExclusions];
const renderable = await this.prepareTextRenderable(item, (item.kind || item.type) === 'heading' ? 'heading' : 'paragraph');
this.layoutFlowLine = previousFlowLine;
this.layoutExclusions = previousExclusions;
if (token !== this.renderWindowToken) return;
if (!this.isDisplayGenerationCurrent(generation, item.gameId || null)) return;
renderable.layout.lineCount = Math.max(1, Number(item.lineCount ?? item.metadata?.lineCount ?? renderable.lineCount));
renderable.lineCount = renderable.layout.lineCount;
const replacement = this.layoutRenderer.renderParagraph(renderable.layout, { id: renderable.id });
replacement.dataset.lineStart = String(renderable.lineStart);
replacement.dataset.lineCount = String(renderable.lineCount);
replacement.dataset.heightLines = String(renderable.lineCount);
if (item.turnId != null) {
replacement.dataset.turnId = String(item.turnId);
replacement.classList.add('story-turn-block');
}
if (item.blockId != null) {
replacement.dataset.storyBlockId = String(item.blockId);
}
this.makeRenderedWordsVisible(replacement);
oldElement.replaceWith(replacement);
}
}
async renderIncrementalWindow(bounds = {}, requestId = null) {
if (!this.storyHistory || !this.paragraphContainer) return;
const generation = bounds.generation ?? this.displayGeneration;
const gameId = bounds.gameId || this.storyHistory.currentGameId;
if (!this.isDisplayGenerationCurrent(generation, gameId)) return;
if (!this.renderedItems.length) {
await this.renderWindowForBounds(bounds, requestId);
return;
}
const token = ++this.renderWindowToken;
const start = Math.max(1, Number(bounds.start || 1));
const end = Math.max(start, Number(bounds.end || start));
const keptItems = this.renderedItems.filter(item => {
const blockId = Number(item?.blockId || 0);
return blockId >= start && blockId <= end;
});
const existingIds = new Set(keptItems.map(item => Number(item?.blockId || 0)).filter(Boolean));
const missingBeforeEnd = Math.min(end, (this.historyWindowStartId || start) - 1);
const missingAfterStart = Math.max(start, (this.historyWindowEndId || 0) + 1);
const missingBeforeBlocks = start <= missingBeforeEnd
? await this.storyHistory.getBlocksRange(gameId, start, missingBeforeEnd)
: [];
if (requestId != null && requestId !== this.scrollRequestId) return;
if (token !== this.renderWindowToken) return;
if (!this.isDisplayGenerationCurrent(generation, gameId)) return;
const missingAfterBlocks = missingAfterStart <= end
? await this.storyHistory.getBlocksRange(gameId, missingAfterStart, end)
: [];
if (requestId != null && requestId !== this.scrollRequestId) return;
if (token !== this.renderWindowToken) return;
if (!this.isDisplayGenerationCurrent(generation, gameId)) return;
const uniqueBeforeBlocks = missingBeforeBlocks.filter(block => {
const id = Number(block?.blockId || 0);
if (!id || existingIds.has(id)) return false;
existingIds.add(id);
return true;
});
const uniqueAfterBlocks = missingAfterBlocks.filter(block => {
const id = Number(block?.blockId || 0);
if (!id || existingIds.has(id)) return false;
existingIds.add(id);
return true;
});
const stagedItems = [...uniqueBeforeBlocks, ...keptItems, ...uniqueAfterBlocks]
.sort((left, right) => Number(left?.blockId || 0) - Number(right?.blockId || 0));
const originCandidates = stagedItems
.map(item => Number(item?.lineStart ?? item?.metadata?.lineStart))
.filter(Number.isFinite);
const finalOrigin = originCandidates.length ? Math.min(...originCandidates) : 0;
const previousOrigin = this.windowOriginLine;
const previousFlowLine = this.layoutFlowLine;
const previousExclusions = [...this.layoutExclusions];
this.windowOriginLine = finalOrigin;
this.layoutFlowLine = finalOrigin;
this.rebuildLayoutExclusions(stagedItems);
const beforeFragment = document.createDocumentFragment();
const beforeItems = [];
for (const block of uniqueBeforeBlocks) {
await this.renderStoryBlock(block, {
animate: false,
playback: false,
placement: 'append',
targetContainer: beforeFragment,
renderedItemsTarget: beforeItems,
token,
generation,
recordMetrics: false
});
if (token !== this.renderWindowToken || (requestId != null && requestId !== this.scrollRequestId)) {
if (token === this.renderWindowToken) {
this.windowOriginLine = previousOrigin;
this.layoutFlowLine = previousFlowLine;
this.layoutExclusions = previousExclusions;
this.setWindowOriginLine(previousOrigin);
}
return;
}
}
const afterFragment = document.createDocumentFragment();
const afterItems = [];
for (const block of uniqueAfterBlocks) {
await this.renderStoryBlock(block, {
animate: false,
playback: false,
placement: 'append',
targetContainer: afterFragment,
renderedItemsTarget: afterItems,
token,
generation,
recordMetrics: false
});
if (token !== this.renderWindowToken || (requestId != null && requestId !== this.scrollRequestId)) {
if (token === this.renderWindowToken) {
this.windowOriginLine = previousOrigin;
this.layoutFlowLine = previousFlowLine;
this.layoutExclusions = previousExclusions;
this.setWindowOriginLine(previousOrigin);
}
return;
}
}
this.setWindowOriginLine(finalOrigin);
this.paragraphContainer.insertBefore(beforeFragment, this.paragraphContainer.firstChild);
this.paragraphContainer.appendChild(afterFragment);
this.renderedItems = [...beforeItems, ...keptItems, ...afterItems]
.sort((left, right) => Number(left?.blockId || 0) - Number(right?.blockId || 0));
this.removeRenderedBlocksOutside(start, end);
this.dedupeRenderedWindow();
this.setWindowOriginLine(finalOrigin);
this.rebuildLayoutExclusions(this.renderedItems);
await this.reflowTextBlocksForActiveExclusions(token);
if (token !== this.renderWindowToken) return;
if (!this.isDisplayGenerationCurrent(generation, gameId)) return;
this.setVirtualPadding();
this.updateStoryScrollbar();
}
handleHistoryWheel(event) {
if (!event.target?.closest?.('#page_right') || !this.pageRight) return;
if (event.target?.closest?.('.story-choices')) return;
event.preventDefault();
event.stopPropagation();
this.handleManualScrollStart('wheel');
let lineDelta = 0;
const rawDelta = Number(event.deltaY || 0);
if (event.deltaMode === WheelEvent.DOM_DELTA_LINE) {
lineDelta = rawDelta;
} else if (event.deltaMode === WheelEvent.DOM_DELTA_PAGE) {
lineDelta = rawDelta * (this.viewportLineCount || this.pageLineCount || 25);
} else {
lineDelta = rawDelta / Math.max(8, this.lineHeightPx || 24);
}
this.wheelLineAccumulator += lineDelta;
const wholeLines = Math.trunc(this.wheelLineAccumulator);
this.wheelLineAccumulator -= wholeLines;
if (wholeLines < 0) {
this.scrollUp(Math.abs(wholeLines), { mode: 'wheel' });
} else if (wholeLines > 0) {
this.scrollDown(wholeLines, { mode: 'wheel' });
}
}
handleManualScrollStart(source = 'manual-scroll') {
this.lastManualScrollAt = performance.now();
this.disableAutoplayForManualScroll();
if (this.playbackCoordinator && this.playbackCoordinator.isPlaying && typeof this.playbackCoordinator.fastForward === 'function') {
this.playbackCoordinator.fastForward();
}
document.dispatchEvent(new CustomEvent('story:manual-scroll', {
detail: { source }
}));
}
async prepareRenderableBlock(item) {
const type = item.kind || item.type || 'paragraph';
if (type === 'music' || type === 'sfx') {
return {
type,
id: item.id || `${type}-${item.blockId || Date.now()}`,
lineStart: this.layoutFlowLine,
lineCount: 0,
metadata: { ...(item.metadata || {}) }
};
}
if (type === 'image') {
return this.prepareImageRenderable(item);
}
return this.prepareTextRenderable(item, type === 'heading' ? 'heading' : 'paragraph');
}
async prepareTextRenderable(item, type = 'paragraph') {
const sentenceQueue = this.getModule('sentence-queue');
if (!sentenceQueue || typeof sentenceQueue.prepareLayout !== 'function') {
throw new Error('UIDisplayHandler: sentence-queue layout calculator unavailable.');
}
if (document.fonts && document.fonts.ready) {
await document.fonts.ready;
}
const metadata = {
...(item.metadata || {}),
type,
role: item.role || item.metadata?.role || (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 || [],
glossaryEntries: item.glossaryEntries || item.metadata?.glossaryEntries || [],
turnId: item.turnId ?? item.metadata?.turnId,
blockId: item.blockId ?? item.metadata?.blockId,
gameId: item.gameId ?? item.metadata?.gameId
};
if (metadata.dropCap && typeof sentenceQueue.measureDropCapReservation === 'function') {
const dropCapText = typeof sentenceQueue.getDropCapText === 'function'
? sentenceQueue.getDropCapText(metadata.layoutText || item.text || '')
: String(metadata.layoutText || item.text || '').trim().charAt(0);
metadata.dropCapWidth = await sentenceQueue.measureDropCapReservation(
this.container || this.paragraphContainer || document.getElementById('story'),
dropCapText,
this.measureStoryLineHeight()
);
}
const role = metadata.role;
const isHeading = type === 'heading' || role === 'chapter-heading' || role === 'section-heading';
if (isHeading) {
this.layoutFlowLine = Math.max(this.layoutFlowLine, this.getExclusionEndLine());
this.layoutExclusions = this.layoutExclusions.filter(exclusion => exclusion.endLine > this.layoutFlowLine);
}
const topSpace = role === 'chapter-heading' ? 2 : role === 'section-heading' ? 1 : metadata.addTopSpace ? 1 : 0;
const bottomSpace = role === 'chapter-heading' ? 1 : role === 'section-heading' ? 1 : 0;
const lineStart = Number.isFinite(Number(item.lineStart)) ? Number(item.lineStart) : this.layoutFlowLine;
const contentStartLine = lineStart + topSpace;
const geometry = this.buildLineGeometry(metadata, contentStartLine);
const layout = await sentenceQueue.prepareLayout(item.text || '', {
...metadata,
measures: geometry.measures,
lineOffsets: geometry.lineOffsets,
imageWrap: geometry.imageWrap
});
const contentLines = Math.max(1, (layout.breaks?.length || 2) - 1);
const lineCount = Math.max(1, topSpace + contentLines + bottomSpace);
layout.lineStart = lineStart;
layout.lineCount = lineCount;
layout.contentTopLines = topSpace;
layout.measures = geometry.measures;
layout.lineOffsets = geometry.lineOffsets;
layout.pageWidth = geometry.pageWidth;
layout.windowOriginLine = this.windowOriginLine || 0;
this.layoutFlowLine = Math.max(this.layoutFlowLine, contentStartLine + contentLines + bottomSpace);
this.layoutExclusions = this.layoutExclusions.filter(exclusion => exclusion.endLine > this.layoutFlowLine);
return {
type,
id: item.id || `${type}-${item.blockId || Date.now()}`,
lineStart,
lineCount,
layout,
metadata
};
}
prepareImageRenderable(item) {
const metadata = { ...(item.metadata || {}), ...item };
const metrics = this.calculateImageMetrics(metadata);
const lineStart = Number.isFinite(Number(item.lineStart)) ? Number(item.lineStart) : this.layoutFlowLine;
const lineCount = Math.max(1, Math.round(metrics.lineCount || 1));
const renderMetadata = {
...metadata,
imageLayout: { ...metrics, lineStart, lineCount },
lineStart,
lineCount,
windowOriginLine: this.windowOriginLine || 0
};
if ((metrics.size || metadata.size) === 'portrait') {
this.addImageExclusion({ ...item, metadata: renderMetadata, lineStart, lineCount });
} else {
this.layoutFlowLine = Math.max(this.layoutFlowLine, this.getExclusionEndLine());
this.layoutExclusions = [];
this.layoutFlowLine = Math.max(this.layoutFlowLine, lineStart + lineCount);
}
return {
type: 'image',
id: item.id || `image-${item.blockId || Date.now()}`,
lineStart,
lineCount,
metadata: renderMetadata
};
}
createImageExclusion(item = {}) {
const type = item.kind || item.type;
const metadata = { ...(item.metadata || {}), ...item };
const layout = metadata.imageLayout || {};
const size = String(layout.size || metadata.size || '').toLowerCase();
if (type !== 'image' || size !== 'portrait') return null;
const lineStart = Number(metadata.lineStart ?? layout.lineStart ?? item.lineStart);
const lineCount = Math.max(1, Number(metadata.lineCount ?? layout.lineCount ?? item.lineCount ?? 1));
const width = Number(layout.width ?? metadata.width ?? 0);
const gap = Number(layout.gap ?? metadata.gap ?? this.measureStoryLineHeight());
if (!Number.isFinite(lineStart) || !Number.isFinite(width) || width <= 0) return null;
return {
blockId: item.blockId ?? metadata.blockId,
startLine: Math.max(0, lineStart),
endLine: Math.max(0, lineStart) + lineCount,
width: width + Math.max(0, gap),
side: layout.floatSide || metadata.floatSide || 'right'
};
}
addImageExclusion(item = {}) {
const exclusion = this.createImageExclusion(item);
if (!exclusion) return;
const key = String(exclusion.blockId ?? `${exclusion.startLine}:${exclusion.endLine}:${exclusion.side}`);
const existingIndex = this.layoutExclusions.findIndex(entry => {
const entryKey = String(entry.blockId ?? `${entry.startLine}:${entry.endLine}:${entry.side}`);
return entryKey === key;
});
if (existingIndex >= 0) {
this.layoutExclusions[existingIndex] = exclusion;
} else {
this.layoutExclusions.push(exclusion);
}
}
rebuildLayoutExclusions(items = this.renderedItems) {
this.layoutExclusions = [];
const source = Array.isArray(items) ? items : [];
source.forEach(item => this.addImageExclusion(item));
}
getExclusionEndLine() {
return this.layoutExclusions.reduce((max, exclusion) => Math.max(max, exclusion.endLine || 0), this.layoutFlowLine || 0);
}
getActiveExclusions(line) {
return this.layoutExclusions.filter(exclusion => line >= exclusion.startLine && line < exclusion.endLine);
}
buildLineGeometry(metadata = {}, contentStartLine = 0) {
const pageWidth = this.container?.clientWidth || this.paragraphContainer?.clientWidth || 600;
const lineHeight = this.measureStoryLineHeight();
const isHeading = metadata.type === 'heading' || metadata.role === 'chapter-heading' || metadata.role === 'section-heading';
const dropCapLines = metadata.dropCap ? 2 : 0;
const dropCapWidth = metadata.dropCap
? (Number.isFinite(Number(metadata.dropCapWidth)) && Number(metadata.dropCapWidth) > 0
? Number(metadata.dropCapWidth)
: lineHeight * 1.34)
: 0;
const indentWidth = (isHeading || metadata.isFirstParagraphInChapter || metadata.addTopSpace) ? 0 : lineHeight * 1.5;
const maxConsideredLines = Math.max(80, this.pageLineCount * 4);
const measures = [];
const lineOffsets = [];
for (let index = 0; index < maxConsideredLines; index += 1) {
const line = contentStartLine + index;
const active = isHeading ? [] : this.getActiveExclusions(line);
const leftExclusion = active.filter(exclusion => exclusion.side !== 'right')
.reduce((sum, exclusion) => sum + Number(exclusion.width || 0), 0);
const rightExclusion = active.filter(exclusion => exclusion.side === 'right')
.reduce((sum, exclusion) => sum + Number(exclusion.width || 0), 0);
const available = Math.max(120, pageWidth - leftExclusion - rightExclusion);
const firstLineInset = metadata.dropCap && index < dropCapLines
? dropCapWidth
: index === 0
? indentWidth
: 0;
measures.push(Math.max(120, available - firstLineInset));
lineOffsets.push(isHeading ? 0 : leftExclusion + firstLineInset);
}
return {
pageWidth,
measures,
lineOffsets,
imageWrap: this.layoutExclusions.length > 0 ? [...this.layoutExclusions] : null
};
}
makeRenderedWordsVisible(element) {
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 = 'none';
});
}
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?.nextBlockId || 1) - 1));
}
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 previewTop = this.scrollbarPreviewLine == null
? null
: this.getTopLineForActiveLine(this.scrollbarPreviewLine);
const currentTop = Math.max(0, Math.min(maxTopLine, previewTop ?? (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.handleManualScrollStart('scrollbar');
const track = event.currentTarget;
if (!track) return;
const thumb = document.getElementById('story_scrollbar_thumb');
const pointerId = event.pointerId;
if (typeof track.setPointerCapture === 'function' && pointerId != null) {
try {
track.setPointerCapture(pointerId);
} catch (error) {
console.warn('UIDisplayHandler: Story scrollbar pointer capture unavailable', error);
}
}
this.draggingStoryScrollbar = true;
track.dataset.dragging = 'true';
const trackRect = track.getBoundingClientRect();
const thumbRect = thumb?.getBoundingClientRect?.();
const grabOffset = thumb && thumb.contains(event.target)
? Math.max(0, event.clientY - (thumbRect?.top || trackRect.top))
: Math.max(0, (thumbRect?.height || 0) / 2);
const previewToPointer = (pointerEvent) => {
const rect = track.getBoundingClientRect();
const thumbHeight = thumb?.getBoundingClientRect?.().height || 0;
const travel = Math.max(1, rect.height - thumbHeight);
const thumbTop = Math.max(0, Math.min(travel, pointerEvent.clientY - rect.top - grabOffset));
const ratio = Math.max(0, Math.min(1, thumbTop / travel));
const maxTopLine = this.getMaxStoryTopLine();
const targetTopLine = Math.round(maxTopLine * ratio);
this.scrollbarPreviewLine = this.getActiveLineForTopLine(targetTopLine);
if (thumb) {
thumb.style.transition = 'none';
const totalLines = Math.max(1, Number(this.storyHistory?.renderedLineCount || 0));
const viewportLines = Math.max(1, this.viewportLineCount || 1);
const heightPercent = Math.max(8, Math.min(100, (Math.min(viewportLines, totalLines) / totalLines) * 100));
thumb.style.height = `${heightPercent}%`;
thumb.style.top = `${ratio * (100 - heightPercent)}%`;
}
};
previewToPointer(event);
const onMove = (moveEvent) => previewToPointer(moveEvent);
const cleanup = () => {
this.storyScrollbarReleaseHandler = null;
document.removeEventListener('pointermove', onMove);
document.removeEventListener('pointerup', onRelease);
document.removeEventListener('pointercancel', onRelease);
document.removeEventListener('mouseup', onRelease);
window.removeEventListener('blur', onRelease);
if (typeof track.releasePointerCapture === 'function' && pointerId != null) {
try {
track.releasePointerCapture(pointerId);
} catch (error) {
// The browser may already have released capture; cleanup can continue.
}
}
this.draggingStoryScrollbar = false;
delete track.dataset.dragging;
if (thumb) {
thumb.style.transition = '';
}
};
const onRelease = async (releaseEvent) => {
releaseEvent?.preventDefault?.();
releaseEvent?.stopPropagation?.();
cleanup();
const targetLine = this.scrollbarPreviewLine;
this.scrollbarPreviewLine = null;
if (Number.isFinite(Number(targetLine))) {
await this.scrollTo(targetLine, { mode: 'scrollbar-release' });
} else {
this.updateStoryScrollbar();
}
};
this.storyScrollbarReleaseHandler = onRelease;
document.addEventListener('pointermove', onMove);
document.addEventListener('pointerup', onRelease);
document.addEventListener('pointercancel', onRelease);
document.addEventListener('mouseup', onRelease);
window.addEventListener('blur', onRelease);
}
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(0, 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, lineStart = null) {
if (!this.storyHistory || typeof this.storyHistory.updateBlockMetrics !== 'function' || blockId == null) return null;
const metrics = this.measureBlockLines(element, fallbackLineCount);
if (Number.isFinite(Number(lineStart))) {
metrics.lineStart = Math.max(0, Number(lineStart));
}
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 renderedEndLine = this.renderedItems.reduce((max, item) => {
const start = Number(item?.lineStart ?? item?.metadata?.lineStart);
const count = Math.max(0, Number(item?.lineCount ?? item?.metadata?.lineCount ?? 0));
return Number.isFinite(start) ? Math.max(max, start + count) : max;
}, this.windowOriginLine || 0);
const totalLines = Math.max(0, renderedEndLine - Math.max(0, this.windowOriginLine || 0));
const lineHeight = this.measureStoryLineHeight();
this.paragraphContainer.style.paddingTop = '0';
this.paragraphContainer.style.paddingBottom = '0';
this.paragraphContainer.style.height = `${totalLines * 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));
}
getActiveLineForTopLine(topLine = this.storyTopLine) {
const totalLines = Math.max(0, Number(this.storyHistory?.renderedLineCount || 0));
if (totalLines <= 0) return 0;
const requested = Math.round(Number(topLine || 0)) + Math.max(1, this.viewportLineCount || 1) - 1;
return Math.max(0, Math.min(totalLines - 1, requested));
}
getTopLineForActiveLine(activeLine = 0) {
const maxTopLine = this.getMaxStoryTopLine();
const requested = Math.round(Number(activeLine || 0)) - Math.max(1, this.viewportLineCount || 1) + 1;
return Math.max(0, Math.min(maxTopLine, requested));
}
getRenderedBlockForLine(line = 0) {
const target = Math.max(0, Number(line || 0));
return this.renderedItems.find(item => {
const start = Number(item.lineStart ?? item.metadata?.lineStart);
const count = Math.max(0, Number(item.lineCount ?? item.metadata?.lineCount ?? 0));
return Number.isFinite(start) && count > 0 && target >= start && target < start + count;
}) || null;
}
getCurrentScrollLine() {
if (Number.isFinite(Number(this.scrollTargetLine))) {
return Math.max(0, Number(this.scrollTargetLine));
}
return this.getActiveLineForTopLine(this.storyTopLine);
}
getLiveEndLine() {
return Math.max(0, Number(this.storyHistory?.renderedLineCount || 0) - 1);
}
scrollUp(numberOfLines = 1, options = {}) {
const lines = Math.max(0, Math.round(Number(numberOfLines || 0)));
return this.scrollTo(this.getCurrentScrollLine() - lines, { ...options, direction: -1 });
}
scrollDown(numberOfLines = 1, options = {}) {
const lines = Math.max(0, Math.round(Number(numberOfLines || 0)));
return this.scrollTo(this.getCurrentScrollLine() + lines, { ...options, direction: 1 });
}
async scrollTo(lineNumber = 0, options = {}) {
this.measureStoryLineHeight();
const totalLines = Math.max(0, Number(this.storyHistory?.renderedLineCount || 0));
const targetLine = Math.max(0, Math.min(Math.max(0, totalLines - 1), Math.round(Number(lineNumber || 0))));
const previousLine = this.getCurrentScrollLine();
const requestId = ++this.scrollRequestId;
this.scrollTargetLine = targetLine;
if (this.storyHistory && totalLines > 0) {
await this.ensureScrollRangeForTarget(previousLine, targetLine, { ...options, requestId });
if (requestId !== this.scrollRequestId) return;
}
const targetTopLine = this.getTopLineForActiveLine(targetLine);
await this.animateToTopLine(targetTopLine, options.smooth !== false, options);
this.scrollTargetLine = this.getActiveLineForTopLine(this.storyTopLine);
}
async ensureScrollRangeForTarget(previousLine = 0, targetLine = 0, options = {}) {
if (!this.storyHistory || !this.paragraphContainer) return;
const latest = Math.max(0, Number(this.storyHistory.latestRenderedBlockId || 0));
if (latest <= 0) return;
const bounds = await this.getWindowBoundsForTraversal(previousLine, targetLine, latest);
if (!bounds) return;
if (options.mode === 'append-live' && this.getRenderedBlockForLine(targetLine)) {
this.activeCenterBlockId = bounds.targetBlockId;
this.updateStoryScrollbar();
return;
}
const currentWindowCoversBounds = bounds.start >= this.historyWindowStartId && bounds.end <= this.historyWindowEndId;
const exactWindowAlreadyLoaded = bounds.start === this.historyWindowStartId && bounds.end === this.historyWindowEndId;
if (bounds.teleport) {
this.paragraphContainer.classList.add('story-history-fading');
await new Promise(resolve => setTimeout(resolve, 220));
await this.renderWindowForBounds(bounds, options.requestId);
this.paragraphContainer.classList.remove('story-history-fading');
} else if (!currentWindowCoversBounds || !exactWindowAlreadyLoaded) {
await this.renderIncrementalWindow(bounds, options.requestId);
}
this.activeCenterBlockId = bounds.targetBlockId;
this.updateStoryScrollbar();
}
animateToTopLine(targetLine, smooth = true, options = {}) {
this.measureStoryLineHeight();
const maxTopLine = this.getMaxStoryTopLine();
const target = Math.round(Math.max(0, Math.min(maxTopLine, Number(targetLine || 0))));
if (!smooth) {
if (this.scrollAnimationFrameId != null) {
cancelAnimationFrame(this.scrollAnimationFrameId);
this.scrollAnimationFrameId = null;
}
if (this.scrollAnimationResolve) {
this.scrollAnimationResolve();
this.scrollAnimationResolve = null;
this.scrollAnimationPromise = null;
}
this.storyTopLine = target;
this.setStoryOffset(-((target - (this.windowOriginLine || 0)) * this.lineHeightPx));
return Promise.resolve();
}
const now = performance.now();
const distance = Math.abs(target - (this.storyTopLine || 0));
this.storyScrollAnimation = {
startTopLine: this.storyTopLine || 0,
targetTopLine: target,
startedAt: now,
duration: Math.max(180, Math.min(700, 160 + (distance * 35)))
};
if (!this.scrollAnimationPromise) {
this.scrollAnimationPromise = new Promise(resolve => {
this.scrollAnimationResolve = resolve;
});
}
if (this.scrollAnimationFrameId == null) {
const step = (now) => {
const animation = this.storyScrollAnimation;
if (!animation) {
this.scrollAnimationFrameId = null;
const resolve = this.scrollAnimationResolve;
this.scrollAnimationResolve = null;
this.scrollAnimationPromise = null;
resolve?.();
return;
}
const progress = Math.min(1, Math.max(0, (now - animation.startedAt) / Math.max(1, animation.duration)));
const eased = progress < 0.5
? 4 * progress * progress * progress
: 1 - Math.pow(-2 * progress + 2, 3) / 2;
this.storyTopLine = animation.startTopLine + ((animation.targetTopLine - animation.startTopLine) * eased);
this.setStoryOffset(-((this.storyTopLine - (this.windowOriginLine || 0)) * this.lineHeightPx));
if (progress >= 1 || Math.abs(animation.targetTopLine - this.storyTopLine) < 0.02) {
this.storyTopLine = animation.targetTopLine;
this.setStoryOffset(-((this.storyTopLine - (this.windowOriginLine || 0)) * this.lineHeightPx));
this.storyScrollAnimation = null;
this.scrollAnimationFrameId = null;
const resolve = this.scrollAnimationResolve;
this.scrollAnimationResolve = null;
this.scrollAnimationPromise = null;
resolve?.();
return;
}
this.scrollAnimationFrameId = requestAnimationFrame(step);
};
this.scrollAnimationFrameId = requestAnimationFrame(step);
}
return this.scrollAnimationPromise;
}
async ensureLiveTailWindow() {
if (!this.storyHistory || !this.paragraphContainer) return;
const generation = this.displayGeneration;
const gameId = this.storyHistory.currentGameId;
const latestRendered = Math.max(0, Number(this.storyHistory.latestRenderedBlockId || 0));
if (latestRendered > 0) {
const start = Math.max(1, latestRendered - (this.visibleBlockLimit - 2));
const end = latestRendered;
if (this.historyWindowStartId !== start || this.historyWindowEndId !== end) {
const liveEndLine = Math.max(0, Number(this.storyHistory.renderedLineCount || 0) - 1);
await this.renderWindowForBounds({
start,
end,
targetBlockId: latestRendered,
windowOriginLine: this.getTopLineForActiveLine(liveEndLine),
gameId,
generation
});
if (!this.isDisplayGenerationCurrent(generation, gameId)) return;
}
} else if (this.renderedItems.length) {
this.paragraphContainer.innerHTML = '';
this.renderedItems = [];
this.historyWindowStartId = 1;
this.historyWindowEndId = 0;
this.windowOriginLine = 0;
}
this.layoutFlowLine = this.getFlowLineFromItems(this.renderedItems);
this.activeCenterBlockId = latestRendered || null;
}
async getWindowBoundsForTraversal(previousLine = 0, targetLine = 0, latest = null) {
const latestRendered = Math.max(0, Number(latest ?? this.storyHistory?.latestRenderedBlockId ?? 0));
if (!this.storyHistory || latestRendered <= 0) return null;
const targetBlock = this.getRenderedBlockForLine(targetLine) || await this.storyHistory.findBlockForLine(
this.storyHistory.currentGameId,
targetLine,
latestRendered
);
if (!targetBlock?.blockId) return null;
const previousBlock = this.getRenderedBlockForLine(previousLine) || await this.storyHistory.findBlockForLine(
this.storyHistory.currentGameId,
previousLine,
latestRendered
) || targetBlock;
const targetBlockId = Math.max(1, Number(targetBlock.blockId));
const previousBlockId = Math.max(1, Number(previousBlock.blockId || targetBlockId));
const rangeStart = Math.min(previousBlockId, targetBlockId);
const rangeEnd = Math.max(previousBlockId, targetBlockId);
const expandedStart = Math.max(1, rangeStart - this.historyBufferBlocks);
const expandedEnd = Math.min(latestRendered, rangeEnd + this.historyBufferBlocks);
const expandedCount = expandedEnd - expandedStart + 1;
if (expandedCount > this.maxTraversalBlocks) {
return {
start: Math.max(1, targetBlockId - this.historyBufferBlocks),
end: Math.min(latestRendered, targetBlockId + this.historyBufferBlocks),
targetBlockId,
windowOriginLine: this.getTopLineForActiveLine(targetLine),
teleport: true
};
}
return {
start: expandedStart,
end: expandedEnd,
targetBlockId,
windowOriginLine: Math.min(
this.getTopLineForActiveLine(previousLine),
this.getTopLineForActiveLine(targetLine)
),
teleport: false
};
}
async renderWindowForBounds(bounds = {}, requestId = null) {
if (!this.storyHistory) return;
const generation = bounds.generation ?? this.displayGeneration;
const gameId = bounds.gameId || this.storyHistory.currentGameId;
if (!this.isDisplayGenerationCurrent(generation, gameId)) return;
const start = Math.max(1, Number(bounds.start || 1));
const end = Math.max(start, Number(bounds.end || start));
const blocks = await this.storyHistory.getBlocksRange(gameId, start, end);
if (requestId != null && requestId !== this.scrollRequestId) return;
if (!this.isDisplayGenerationCurrent(generation, gameId)) return;
await this.renderHistoryWindow(blocks, {
windowOriginLine: bounds.windowOriginLine,
gameId,
generation
});
}
focusTurn(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.scrollTo(targetLine, { mode: 'jump-to-turn' });
}
return true;
};
if (scrollToLiveTarget()) return;
this.storyHistory?.getFirstBlockForTurn?.(this.storyHistory.currentGameId, turnId).then((block) => {
const targetLine = Number(block?.lineStart);
if (Number.isFinite(targetLine)) {
this.scrollTo(targetLine, { mode: 'jump-to-turn' });
}
});
}
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) }
}));
}
}
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(renderableOrMetadata = {}, animate = true, placement = 'append', targetContainer = this.paragraphContainer) {
if (!this.paragraphContainer) return null;
const metadata = renderableOrMetadata.metadata || renderableOrMetadata;
const metrics = metadata.imageLayout || this.calculateImageMetrics(metadata.size);
const lineStart = Math.max(0, Number(renderableOrMetadata.lineStart ?? metadata.lineStart ?? metrics.lineStart ?? 0));
const lineCount = Math.max(1, Number(renderableOrMetadata.lineCount ?? metadata.lineCount ?? metrics.lineCount ?? 1));
const windowOriginLine = Math.max(0, Number(renderableOrMetadata.windowOriginLine ?? metadata.windowOriginLine ?? this.windowOriginLine ?? 0));
const lineHeight = this.measureStoryLineHeight();
const pageWidth = this.container?.clientWidth || this.paragraphContainer?.clientWidth || metrics.pageWidth || 600;
const figure = document.createElement('figure');
if (metadata.id) {
figure.id = metadata.id;
}
figure.className = [
'story-image-block',
`story-image-${metrics.size || 'landscape'}`,
animate ? 'story-image-pending' : 'story-image-visible'
].filter(Boolean).join(' ');
figure.style.position = 'absolute';
figure.style.top = `${(lineStart - windowOriginLine) * lineHeight}px`;
figure.style.height = `${lineCount * lineHeight}px`;
figure.style.width = `${metrics.width}px`;
figure.style.margin = '0';
figure.style.padding = '0';
const side = metrics.floatSide || metadata.floatSide || 'right';
if ((metrics.size || metadata.size) === 'portrait' && side === 'right') {
figure.style.left = `${Math.max(0, pageWidth - metrics.width)}px`;
} else if ((metrics.size || metadata.size) === 'portrait') {
figure.style.left = '0px';
} else {
figure.style.left = `${Math.max(0, (pageWidth - metrics.width) / 2)}px`;
}
figure.dataset.heightLines = String(lineCount);
figure.dataset.lineStart = String(lineStart);
figure.dataset.lineCount = String(lineCount);
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';
img.style.position = 'absolute';
img.style.left = '0';
img.style.top = `${metrics.verticalMargin || 0}px`;
img.style.width = `${metrics.width}px`;
img.style.height = `${metrics.height}px`;
figure.appendChild(img);
this.insertStoredElement(figure, placement, targetContainer);
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(metadataOrSize = 'landscape') {
const storyElement = document.getElementById('story');
const pageWidth = storyElement?.clientWidth || 600;
const lineHeight = this.measureStoryLineHeight();
const metadata = typeof metadataOrSize === 'object' && metadataOrSize !== null ? metadataOrSize : { size: metadataOrSize };
const normalizedSize = String(metadata.size || 'landscape').toLowerCase() === 'widescreen'
? 'landscape'
: String(metadata.size || 'landscape').toLowerCase();
const aspect = normalizedSize === 'portrait'
? (9 / 16)
: normalizedSize === 'square'
? 1
: normalizedSize === 'full'
? (4.25 / 6.875)
: (16 / 9);
const isPortrait = normalizedSize === 'portrait';
const isFullPage = normalizedSize === 'full';
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 = lineHeight / 2;
const lineCount = isFullPage ? this.pageLineCount : imageLineCount + 1;
const height = Math.max(lineHeight, (lineCount * lineHeight) - (verticalMargin * 2));
const width = Math.min(maxImageWidth, height * aspect);
return {
size: normalizedSize,
aspect,
width,
height,
gap: imageGap,
lineCount,
imageLineCount,
lineHeight,
verticalMargin,
floatSide: metadata.floatSide || 'right',
pageWidth
};
}
clear() {
this.displayGeneration += 1;
this.renderWindowToken += 1;
this.scrollRequestId += 1;
if (this.scrollAnimationFrameId != null) {
cancelAnimationFrame(this.scrollAnimationFrameId);
this.scrollAnimationFrameId = null;
}
if (this.scrollAnimationResolve) {
this.scrollAnimationResolve();
this.scrollAnimationResolve = null;
this.scrollAnimationPromise = null;
}
this.storyScrollAnimation = null;
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.notificationQueue = [];
this.pendingTerminalNotifications = [];
this.notificationActive = false;
document.getElementById('story_popup_modal')?.classList.remove('visible');
document.getElementById('story_popup_modal')?.setAttribute('aria-hidden', 'true');
this.historyWindowStartId = 1;
this.historyWindowEndId = 0;
this.storyTopLine = 0;
this.scrollTargetLine = null;
this.wheelLineAccumulator = 0;
this.draggingStoryScrollbar = false;
this.scrollbarPreviewLine = null;
this.activeCenterBlockId = null;
this.layoutFlowLine = 0;
this.layoutExclusions = [];
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;