2444 lines
104 KiB
JavaScript
2444 lines
104 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.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.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',
|
|
'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',
|
|
'measureText',
|
|
'loadCSS',
|
|
'showChoices',
|
|
'preloadImages',
|
|
'createCreditsDialog',
|
|
'openCreditsDialog',
|
|
'closeCreditsDialog',
|
|
'loadCreditsText',
|
|
'getMarkdownRenderer',
|
|
'renderMarkdown',
|
|
'populateCreativeCredits',
|
|
'creditLink',
|
|
'createNotificationDialog',
|
|
'handleStoryTag',
|
|
'getTagMessage',
|
|
'dispatchDeferredTagsForBlock',
|
|
'showNotification',
|
|
'displayNextNotification',
|
|
'queueTerminalNotification',
|
|
'flushTerminalNotifications',
|
|
'closeNotification'
|
|
]);
|
|
|
|
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.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() {
|
|
// 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="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';
|
|
|
|
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.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.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">×</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, '&').replace(/</g, '<').replace(/>/g, '>')}</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, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
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">×</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.textContent = next.title;
|
|
message.textContent = 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);
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
}
|
|
|
|
try {
|
|
await this.ensureLiveTailWindow();
|
|
await this.scrollTo(this.getLiveEndLine(), { mode: 'enter-live-tail', smooth: false });
|
|
this.rebuildLayoutExclusions(this.renderedItems);
|
|
this.layoutFlowLine = this.getFlowLineFromItems(this.renderedItems);
|
|
const element = await this.renderStoryBlock(sentence, { animate: true, playback: true, placement: 'append' });
|
|
if (!element) return null;
|
|
sentence.element = element;
|
|
await this.scrollTo(this.getLiveEndLine(), { mode: 'append-live' });
|
|
|
|
if (sentence.kind === 'image') {
|
|
this.revealImageBlock(element);
|
|
} else if (sentence.kind === 'paragraph' || sentence.kind === 'heading') {
|
|
await this.playbackCoordinator.play(sentence);
|
|
} else if (sentence.kind === 'music') {
|
|
console.log('UIDisplayHandler: Music block started', sentence.metadata || {});
|
|
}
|
|
|
|
this.dispatchDeferredTagsForBlock(sentence);
|
|
|
|
if (sentence.onComplete) {
|
|
sentence.onComplete();
|
|
}
|
|
|
|
return element;
|
|
|
|
} catch (error) {
|
|
console.error('UIDisplayHandler: Error rendering sentence:', error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async rerenderStory() {
|
|
if (!this.paragraphContainer || this.renderedItems.length === 0) return;
|
|
console.log('UIDisplayHandler: Re-typesetting story after page resize');
|
|
const activeLine = this.getCurrentScrollLine();
|
|
await this.renderHistoryWindow([...this.renderedItems]);
|
|
await this.scrollTo(activeLine, { mode: 'rerender-preserve', smooth: 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 targetLine = Math.max(0, latestRenderedBlockId > 0 ? this.storyHistory.renderedLineCount - 1 : 0);
|
|
if (latestRenderedBlockId > 0) {
|
|
const targetBlock = await this.storyHistory.findBlockForLine(saveRecord.gameId, targetLine, latestRenderedBlockId);
|
|
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)
|
|
});
|
|
} else {
|
|
await this.renderHistoryWindow([], { windowOriginLine: 0 });
|
|
}
|
|
await this.scrollTo(targetLine, {
|
|
mode: 'restore-bottom',
|
|
smooth: false
|
|
});
|
|
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
|
|
} = options;
|
|
if (!item || !this.paragraphContainer) return null;
|
|
const renderable = await this.prepareRenderableBlock(item);
|
|
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);
|
|
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 (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 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
|
|
});
|
|
if (token !== this.renderWindowToken) return;
|
|
}
|
|
|
|
if (token !== this.renderWindowToken) 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 candidates = this.renderedItems.filter(item => this.blockIntersectsExclusions(item));
|
|
for (const item of candidates) {
|
|
if (token !== this.renderWindowToken) 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;
|
|
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;
|
|
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(this.storyHistory.currentGameId, start, missingBeforeEnd)
|
|
: [];
|
|
if (requestId != null && requestId !== this.scrollRequestId) return;
|
|
if (token !== this.renderWindowToken) return;
|
|
const missingAfterBlocks = missingAfterStart <= end
|
|
? await this.storyHistory.getBlocksRange(this.storyHistory.currentGameId, missingAfterStart, end)
|
|
: [];
|
|
if (requestId != null && requestId !== this.scrollRequestId) return;
|
|
if (token !== this.renderWindowToken) 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,
|
|
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,
|
|
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;
|
|
this.setVirtualPadding();
|
|
this.updateStoryScrollbar();
|
|
}
|
|
|
|
handleHistoryWheel(event) {
|
|
if (!event.target?.closest?.('#page_right') || !this.pageRight) 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 || [],
|
|
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?.latestRenderedBlockId || 0));
|
|
}
|
|
|
|
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 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)
|
|
});
|
|
}
|
|
} 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) {
|
|
const start = Math.max(1, Number(bounds.start || 1));
|
|
const end = Math.max(start, Number(bounds.end || start));
|
|
const blocks = await this.storyHistory.getBlocksRange(this.storyHistory.currentGameId, start, end);
|
|
if (requestId != null && requestId !== this.scrollRequestId) return;
|
|
await this.renderHistoryWindow(blocks, { windowOriginLine: bounds.windowOriginLine });
|
|
}
|
|
|
|
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 : (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 = lineHeight / 2;
|
|
const lineCount = 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() {
|
|
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.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;
|