/** * 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} */ 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} imagePaths - Array of image paths to preload * @returns {Promise} */ 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 = `

`; this.pageLeft.appendChild(header); // Create controls const controls = document.createElement('div'); controls.id = 'controls'; controls.className = 'buttons'; controls.innerHTML = ` `; 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 = `
`; choicesContainer.appendChild(commandInput); this.pageLeft.appendChild(choicesContainer); // Create remark const remark = document.createElement('div'); remark.id = 'remark'; remark.innerHTML = `
`; 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 = '
'; 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 = ` `; 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 `
${String(markdown || '').replace(/&/g, '&').replace(//g, '>')}
`; } 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.title}

${section.rows.map(([label, names]) => `
${label}
${names.map(name => this.creditLink(name)).join(', ')}
`).join('')}
`).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, '>'); const url = links[name]; return url ? `${escaped}` : 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 = ` `; 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 || [], glossaryEntries: item.glossaryEntries || item.metadata?.glossaryEntries || [], turnId: item.turnId ?? item.metadata?.turnId, blockId: item.blockId ?? item.metadata?.blockId, gameId: item.gameId ?? item.metadata?.gameId }; if (metadata.dropCap && typeof sentenceQueue.measureDropCapReservation === 'function') { const dropCapText = typeof sentenceQueue.getDropCapText === 'function' ? sentenceQueue.getDropCapText(metadata.layoutText || item.text || '') : String(metadata.layoutText || item.text || '').trim().charAt(0); metadata.dropCapWidth = await sentenceQueue.measureDropCapReservation( this.container || this.paragraphContainer || document.getElementById('story'), dropCapText, this.measureStoryLineHeight() ); } const role = metadata.role; const isHeading = type === 'heading' || role === 'chapter-heading' || role === 'section-heading'; if (isHeading) { this.layoutFlowLine = Math.max(this.layoutFlowLine, this.getExclusionEndLine()); this.layoutExclusions = this.layoutExclusions.filter(exclusion => exclusion.endLine > this.layoutFlowLine); } const topSpace = role === 'chapter-heading' ? 2 : role === 'section-heading' ? 1 : metadata.addTopSpace ? 1 : 0; const bottomSpace = role === 'chapter-heading' ? 1 : role === 'section-heading' ? 1 : 0; const lineStart = Number.isFinite(Number(item.lineStart)) ? Number(item.lineStart) : this.layoutFlowLine; const contentStartLine = lineStart + topSpace; const geometry = this.buildLineGeometry(metadata, contentStartLine); const layout = await sentenceQueue.prepareLayout(item.text || '', { ...metadata, measures: geometry.measures, lineOffsets: geometry.lineOffsets, imageWrap: geometry.imageWrap }); const contentLines = Math.max(1, (layout.breaks?.length || 2) - 1); const lineCount = Math.max(1, topSpace + contentLines + bottomSpace); layout.lineStart = lineStart; layout.lineCount = lineCount; layout.contentTopLines = topSpace; layout.measures = geometry.measures; layout.lineOffsets = geometry.lineOffsets; layout.pageWidth = geometry.pageWidth; layout.windowOriginLine = this.windowOriginLine || 0; this.layoutFlowLine = Math.max(this.layoutFlowLine, contentStartLine + contentLines + bottomSpace); this.layoutExclusions = this.layoutExclusions.filter(exclusion => exclusion.endLine > this.layoutFlowLine); return { type, id: item.id || `${type}-${item.blockId || Date.now()}`, lineStart, lineCount, layout, metadata }; } prepareImageRenderable(item) { const metadata = { ...(item.metadata || {}), ...item }; const metrics = this.calculateImageMetrics(metadata); const lineStart = Number.isFinite(Number(item.lineStart)) ? Number(item.lineStart) : this.layoutFlowLine; const lineCount = Math.max(1, Math.round(metrics.lineCount || 1)); const renderMetadata = { ...metadata, imageLayout: { ...metrics, lineStart, lineCount }, lineStart, lineCount, windowOriginLine: this.windowOriginLine || 0 }; if ((metrics.size || metadata.size) === 'portrait') { this.addImageExclusion({ ...item, metadata: renderMetadata, lineStart, lineCount }); } else { this.layoutFlowLine = Math.max(this.layoutFlowLine, this.getExclusionEndLine()); this.layoutExclusions = []; this.layoutFlowLine = Math.max(this.layoutFlowLine, lineStart + lineCount); } return { type: 'image', id: item.id || `image-${item.blockId || Date.now()}`, lineStart, lineCount, metadata: renderMetadata }; } createImageExclusion(item = {}) { const type = item.kind || item.type; const metadata = { ...(item.metadata || {}), ...item }; const layout = metadata.imageLayout || {}; const size = String(layout.size || metadata.size || '').toLowerCase(); if (type !== 'image' || size !== 'portrait') return null; const lineStart = Number(metadata.lineStart ?? layout.lineStart ?? item.lineStart); const lineCount = Math.max(1, Number(metadata.lineCount ?? layout.lineCount ?? item.lineCount ?? 1)); const width = Number(layout.width ?? metadata.width ?? 0); const gap = Number(layout.gap ?? metadata.gap ?? this.measureStoryLineHeight()); if (!Number.isFinite(lineStart) || !Number.isFinite(width) || width <= 0) return null; return { blockId: item.blockId ?? metadata.blockId, startLine: Math.max(0, lineStart), endLine: Math.max(0, lineStart) + lineCount, width: width + Math.max(0, gap), side: layout.floatSide || metadata.floatSide || 'right' }; } addImageExclusion(item = {}) { const exclusion = this.createImageExclusion(item); if (!exclusion) return; const key = String(exclusion.blockId ?? `${exclusion.startLine}:${exclusion.endLine}:${exclusion.side}`); const existingIndex = this.layoutExclusions.findIndex(entry => { const entryKey = String(entry.blockId ?? `${entry.startLine}:${entry.endLine}:${entry.side}`); return entryKey === key; }); if (existingIndex >= 0) { this.layoutExclusions[existingIndex] = exclusion; } else { this.layoutExclusions.push(exclusion); } } rebuildLayoutExclusions(items = this.renderedItems) { this.layoutExclusions = []; const source = Array.isArray(items) ? items : []; source.forEach(item => this.addImageExclusion(item)); } getExclusionEndLine() { return this.layoutExclusions.reduce((max, exclusion) => Math.max(max, exclusion.endLine || 0), this.layoutFlowLine || 0); } getActiveExclusions(line) { return this.layoutExclusions.filter(exclusion => line >= exclusion.startLine && line < exclusion.endLine); } buildLineGeometry(metadata = {}, contentStartLine = 0) { const pageWidth = this.container?.clientWidth || this.paragraphContainer?.clientWidth || 600; const lineHeight = this.measureStoryLineHeight(); const isHeading = metadata.type === 'heading' || metadata.role === 'chapter-heading' || metadata.role === 'section-heading'; const dropCapLines = metadata.dropCap ? 2 : 0; const dropCapWidth = metadata.dropCap ? (Number.isFinite(Number(metadata.dropCapWidth)) && Number(metadata.dropCapWidth) > 0 ? Number(metadata.dropCapWidth) : lineHeight * 1.34) : 0; const indentWidth = (isHeading || metadata.isFirstParagraphInChapter || metadata.addTopSpace) ? 0 : lineHeight * 1.5; const maxConsideredLines = Math.max(80, this.pageLineCount * 4); const measures = []; const lineOffsets = []; for (let index = 0; index < maxConsideredLines; index += 1) { const line = contentStartLine + index; const active = isHeading ? [] : this.getActiveExclusions(line); const leftExclusion = active.filter(exclusion => exclusion.side !== 'right') .reduce((sum, exclusion) => sum + Number(exclusion.width || 0), 0); const rightExclusion = active.filter(exclusion => exclusion.side === 'right') .reduce((sum, exclusion) => sum + Number(exclusion.width || 0), 0); const available = Math.max(120, pageWidth - leftExclusion - rightExclusion); const firstLineInset = metadata.dropCap && index < dropCapLines ? dropCapWidth : index === 0 ? indentWidth : 0; measures.push(Math.max(120, available - firstLineInset)); lineOffsets.push(isHeading ? 0 : leftExclusion + firstLineInset); } return { pageWidth, measures, lineOffsets, imageWrap: this.layoutExclusions.length > 0 ? [...this.layoutExclusions] : null }; } makeRenderedWordsVisible(element) { element?.querySelectorAll?.('.word')?.forEach(word => { word.style.transition = 'none'; word.style.animation = 'none'; word.style.visibility = 'visible'; word.style.opacity = '1'; word.style.transform = 'translateY(0)'; word.style.clipPath = 'none'; }); } markBlockRendered(blockId) { if (this.storyHistory && typeof this.storyHistory.markRendered === 'function') { const latestRenderedBlockId = this.storyHistory.markRendered(blockId); document.dispatchEvent(new CustomEvent('story:history-updated', { detail: { gameId: this.storyHistory.currentGameId || null, latestBlockId: this.getLatestHistoryBlockId(), latestRenderedBlockId } })); } } getLatestHistoryBlockId() { return Math.max(0, Number(this.storyHistory?.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() { this.renderWindowToken += 1; this.scrollRequestId += 1; if (this.scrollAnimationFrameId != null) { cancelAnimationFrame(this.scrollAnimationFrameId); this.scrollAnimationFrameId = null; } if (this.scrollAnimationResolve) { this.scrollAnimationResolve(); this.scrollAnimationResolve = null; this.scrollAnimationPromise = null; } this.storyScrollAnimation = null; if (document.documentElement.dataset.skippablePause === 'true') { document.dispatchEvent(new CustomEvent('ui:command', { detail: { moduleId: this.id, type: 'continue', source: 'display-clear' } })); delete document.documentElement.dataset.skippablePause; } if (this.container) { this.container.innerHTML = ''; this.paragraphContainer = document.createElement('div'); this.paragraphContainer.id = 'paragraphs'; this.container.appendChild(this.paragraphContainer); } this.renderedItems = []; this.notificationQueue = []; this.pendingTerminalNotifications = []; this.notificationActive = false; document.getElementById('story_popup_modal')?.classList.remove('visible'); document.getElementById('story_popup_modal')?.setAttribute('aria-hidden', 'true'); this.historyWindowStartId = 1; this.historyWindowEndId = 0; this.storyTopLine = 0; this.activeCenterBlockId = null; this.layoutFlowLine = 0; this.layoutExclusions = []; this.setVirtualPadding(); this.setStoryOffset(0); this.updateStoryScrollbar({ latestBlockId: this.getLatestHistoryBlockId() }); } /** * Show choices in the UI * @param {Array} choices - Array of choice objects * @param {Function} callback - Callback function for choice selection * @returns {Promise} - 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;