diff --git a/CLIENT_TODO.md b/CLIENT_TODO.md index 7d4a0c9..2c35d07 100644 --- a/CLIENT_TODO.md +++ b/CLIENT_TODO.md @@ -4,7 +4,7 @@ This file is the single living technical specification, implementation checklist ## Product Goal -Build an AI-assisted interactive fiction client that feels like a carefully typeset illustrated novel rather than a chat window. The game server owns game state and narrative generation. The client renders incoming narrative as synchronized animated prose with optional speech, sound effects, music, future image blocks, and persistent player options. +Build an AI-assisted interactive fiction client that feels like a carefully typeset illustrated novel rather than a chat window. The game server owns game state and narrative generation. The client renders incoming narrative as synchronized animated prose with optional speech, sound effects, music, image blocks, and persistent player options. The production client must tolerate TTS being unavailable. The safe default TTS provider is `none`; a game, user preference, or explicit option can select another provider. @@ -25,8 +25,8 @@ The production client must tolerate TTS being unavailable. The safe default TTS - Done: mouse cursor state reporting by process state. - Done: placeholder game API for new/load/save/running state. - Done: sound effect and music folders, sound effect playback, music playback, and music ducking during TTS. -- Partial: image markup is parsed and queued, but actual image rendering is still future work. -- Partial: save-game API is a placeholder; saves are per socket session and do not survive reload. +- Done: image markup is parsed, persisted in history, restored from save/history, and rendered as line-snapped page blocks. +- Partial: save-game API restores story state and Ink state, but the broader save/storage model still needs hardening for all engines. - Pending: deeper automated tests for layout, playback timing, TTS provider switching, and media cue timing. ## Module System Specification @@ -58,8 +58,8 @@ The loader is deliberately the conductor, not the orchestra. Module-specific con - `markup-parser-module.js`: converts story text into text blocks, inline styled spans, image blocks, sound cues, and music cues. - `text-processor-module.js`: applies SmartyPants and Hyphenopoly according to active language. - `paragraph-layout-module.js`: measures text and computes Knuth-Plass layout. -- `layout-renderer-module.js`: turns layout data into page DOM with stable word positions and animation metadata. -- `sentence-queue-module.js`: prepares sentence objects and coordinates layout/TTS readiness. +- `layout-renderer-module.js`: turns line-coordinate layout data into absolutely positioned page DOM with stable word positions and animation metadata. +- `sentence-queue-module.js`: prepares speech/media readiness. It must not own page layout, image wrapping, or history rendering state. - `playback-coordinator-module.js`: starts synchronized text/audio playback in the right order. - `animation-queue-module.js`: schedules and fast-forwards visual text animation. - `audio-manager-module.js`: owns sound effects, music tracks, music ducking, volume application, and speech audio playback helpers. @@ -73,7 +73,7 @@ The loader is deliberately the conductor, not the orchestra. Module-specific con - `localization-module.js`: language state used by UI, hyphenation, and TTS selection. - `options-ui-module.js`: options modal, persisted controls, provider status displays. - `ui-controller-module.js`: top-bar commands, global input behavior, game API control wiring. -- `ui-display-handler-module.js`: book page display, startup prompt, text insertion, and media block dispatch. +- `ui-display-handler-module.js`: book page display, startup prompt, unified live/history rendering, line-coordinate scrolling, image placement, and media block dispatch. - `ui-input-handler-module.js`: command entry, history, fast-forward key handling. - `socket-client-module.js`: socket connection and game API request wrapper. - `game-loop-module.js`: high-level client/game flow. @@ -95,6 +95,66 @@ The right page must look like typeset book text: - Punctuation and short marks should not visually break the measure; optical margin handling is desirable future polish. - The page must scale as a fixed-aspect book page. Font sizes and word positions scale with page size, preserving the composition when the window is resized. +## Right Page History And Scrolling Specification + +The right page uses one virtual, line-addressed content pane. It must not behave like browser pagination and must not rely on native scrolling inside `#page_right`, `#story`, or story blocks. + +Line model invariants: + +- `#page_right` has a size relative to the browser window. +- There is exactly one story line-height value. +- The page height is divided into a fixed number of lines; currently `PAGE_LINE_COUNT = 25`. +- `lineHeight = pageRightHeight / PAGE_LINE_COUNT`. +- All rendered content has a height that is an exact multiple of line height, including margins, internal spacing, drop cap space, image vertical spacing, and section/chapter spacing. +- All virtual content coordinates and pixel positions are derived mathematically from line coordinates. +- Stored content does not change line numbers after creation. +- Visible content is never inserted between already existing blocks; new live content is appended at the end of the virtual history. +- Therefore cumulative pixel measurements from the browser DOM are not authoritative and cumulative line starts should not need updating after a block has been assigned coordinates. +- In portrait-image cases, text and image blocks may occupy overlapping cumulative line ranges, but every block edge still lands on a line boundary. + +Scroll positioning: + +- Scrolling means translating the content pane vertically with an ease-in/ease-out animation. +- Every finished scroll position must snap to the nearest position where page edges align with line edges. +- Scrolling to the top means the top edge of the first line of the first block aligns with the top edge of the page. +- Scrolling to the bottom means the bottom edge of the last line of the last rendered block aligns with the bottom edge of the page. +- Scrolling to the bottom to insert new content uses the same bottom rule, but the new block is first added invisibly to block history, advancing the block counter and line history. The page scrolls to the resulting bottom position, then the block reveal animation starts. +- If playback continues while the user is viewing older history, the view must first return to the live bottom insertion position before revealing new content. +- If manual scrolling moves currently animating content out of focus, active text animation and TTS playback must be fast-forwarded through the same path used by page click/space, including TTS fade/stop. + +Active line and active block model: + +- The 41-block retention target is not pagination. +- There is one active line representing the current view position. +- If enough content exists above it, the active line is considered to be the last visible line of the page, line 25. +- The block containing that active line is the active block. +- The DOM should normally contain 20 blocks before the active block, the active block itself, and 20 blocks after the active block, when those blocks exist. +- When normal scrolling shifts the active line into a different block, load one block in the direction of travel and unload one block from the opposite side. +- This one-block exchange should happen as soon as the active block changes, not after the viewport reaches a DOM edge. +- Mouse wheel, arrow key, and scrollbar interactions must drive this active-line model rather than loading page-sized chunks. + +Random-position and scrollbar jumps: + +- Scrolling to a random target first identifies the target line and target block. +- If the target is reached by traversal, the one-block exchange model applies. +- If the target is jumped to, the page first loads: + - 20 blocks before the current/starting active block, as available, + - the current/starting active block, + - all blocks between the starting block and the target block, + - the target block, + - 20 blocks after the target block, as available. +- The whole loaded range can then be traversed smoothly from the starting position to the target position. +- The final target aligns so the bottom edge of the requested line aligns with the bottom edge of the page when enough content exists above it; otherwise it uses the top rule. +- After the scroll finishes, blocks farther than the retained margin are unloaded. +- If the required loaded range would exceed a sensible DOM budget, currently 150 blocks total, all visible page content fades out, the old DOM content is unloaded, the target block plus 20 blocks before and after it are loaded, and the page fades in at the target position. + +Scrollbar behavior: + +- The custom scrollbar represents virtual history position and history size in line coordinates, not native DOM scroll state. +- Dragging the scrollbar thumb should move the thumb preview freely without scrolling content or loading history during the drag. +- On pointer release, the target line/block is resolved, the required block range is loaded according to the random-position rules, and then the content scroll animation runs. +- Scrollbar pointer events must not bubble into story fast-forward/continue handlers. + Processing order: 1. Parse block and inline story markup. @@ -342,7 +402,7 @@ Longer-term goal: - [x] Added chapter heading and dropcap markup. - [x] Added section/textblock markup. - [x] Added Markdown emphasis parsing. -- [x] Added image markup parsing for future image rendering. +- [x] Added image markup parsing, line-snapped rendering, and history/save restoration. - [x] Added sound effect markup and playback. - [x] Added music markup, playback modes, loop/once, and lead-in. - [x] Added music ducking during TTS. diff --git a/NOTE.md b/NOTE.md index 30f2627..e04c3b3 100644 --- a/NOTE.md +++ b/NOTE.md @@ -22,3 +22,35 @@ Assume the following: 2.) All content has an exact multiple of line height as height all margins and paddings included. 3.) Therefore any coordinates or pixel sizes of the virtual content pane can be derived mathematically from line coordinates. 4.) Scrolling means translating the content vertically (with ease in/eas out animation) to the closest position where the page edges aligns with line edges. +5.) Since stored content does not change line numbers after creation, and (visible) content is never added in between existing blocks, updating the cumulative values should be unnecessary. +6.) Scrolling to the bottom means to scroll to the position where the bottom edge of the last line of the last element aligns with the bottom edge of the page. +7.) Scrolling to the top means to scroll to the position where the top edge of the first line of the first block aligns with the top edge of the page. +8.) Scrolling to the bottom to insert new content means the same as 6. but with the new content already added invisibly to the block history (advancing the current block counter), scrolling to the position and only then activating the fade in animation. +9.) In the case of portrait format images next to text the cumulative line positions can overlap but still should border on the line edges. +10.) Scrolling to a random position means to first load all content between the starting point and the target point + additional blocks in the movement direction into the right_page. Then scrolling so the bottom edge of the requested line aligns with the bottom edge of the page, if there is enough content above it otherwise it's the same as 7.) scrolling to the top. After the scroll has finished blocks a certain distance from the reached position are unloded. + + +Looks like you partially work with outdated specs: Here the last version: +1.) The #right_page div has a size relative to the window. There is ONE line height value, which is a divisor or the page height/the page height has a fixed number of lines: Line height = Page height/fixed number of lines. +2.) All content has an exact multiple of line height as height all margins and paddings included. +3.) Therefore any coordinates or pixel sizes of the virtual content pane can be derived mathematically from line coordinates. +4.) Scrolling means translating the content vertically (with ease in/eas out animation) to the closest position where the page edges aligns with line edges. +5.) Since stored content does not change line numbers after creation, and (visible) content is never added in between existing blocks, updating the cumulative values should be unnecessary. +6.) Scrolling to the bottom means to scroll to the position where the bottom edge of the last line of the last element aligns with the bottom edge of the page. +7.) Scrolling to the top means to scroll to the position where the top edge of the first line of the first block aligns with the top edge of the page. +8.) Scrolling to the bottom to insert new content means the same as 6. but with the new content already added invisibly to the block history (advancing the current block counter), scrolling to the position and only then activating the fade in animation. +9.) In the case of portrait format images next to text the cumulative line positions can overlap but still should border on the line edges. +10.) Scrolling to a random position means to first load all content between the starting point and the target point + additional blocks in the movement direction into the right_page. Then scrolling so the bottom edge of the requested line aligns with the bottom edge of the page, if there is enough content above it otherwise it's the same as 7.) scrolling to the top. After the scroll has finished blocks a certain distance from the reached position are unloded. + +Put that wherever you keep your project specs but refine it with the following information: +DO NOT do pagination. The 41 blocks mean there should be one line that is the current position. If there is enough lines content before it this position is always asumed to be the last line of the page (line 25). Whichever block this line belongs to is the active block. The system should always keep 20 blocks before the active block, the active block, and 20 blocks after the active block loaded. That's where the 41 blocks come from. The moment scrolling shifts the active line into a new block in any direction one other block is to be loaded (in that direction) and one is to be unloaded (opposite this direction). If the coordinate is not reached by traversal but jumped to 20 blocks before the active block, the active block, all blocks between the starting block and the target block, the target block and 20 blocks after the target block should be loaded so the whole range can be traversed. If this exceeds a sensible amount of blocks in the dom, let's say 150 blocsk in total, all content on the page is faded out and unloaded, then the target block and 20 blocks before and after it (as available) are loaded before the content of the page is faded in again. + +Please give me feedback whether you understand how I imagine this to work, If you agree this is feasable and then apply it to whatever your notes you keep about the project specifications. + +What works and what doesnt: +1.) up arrow and down arrow perfectly scrolls up and down line by line, but pressing the button again while it is still scrolling leads to stuttering movement. Also all formats and layouts seem to be correct. +2,) Using the mousewheel or page up and down does move the content in the right direction but seemingly a random number of lines (about 1-5) not 24 or whatever the mousewheel speed says. Find out why? How is wheel speed translated into a scroll command? +3.) Home loads a completely new page which takes some time and then flickers into existence ... then it correctly scrolls to the top. +4.) End removes all content from the page, takes some time then you can see the scroll bar go to the bottom, but content does not re-appear before or after the scroll. Only manually scrolling using the arrow key shows the content again. +5.) Scrolling correctly cancels playback and animation. Resuming correctly scrolls to the bottom. Sometimes it resumes play as intended, but under certain conditions new TTS audio is played, but no new text is added. The game just always scrolls down to the last visible block and stays there, while the story and audio continues. +Do not fix it yet. Explain to me step by step why? Do not guess. Do not invent an explanation! Trace the what the program actually does from triggering event to end state and explain to me why this should work, cannot work, is complete or was left in an unfinished state! diff --git a/public/css/style.css b/public/css/style.css index 5cca629..8a6084c 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -424,9 +424,17 @@ ol.choice { } #paragraphs { + position: relative; box-sizing: border-box; overflow: visible !important; overflow-anchor: none; + margin: 0; + padding: 0; + transition: opacity 220ms ease; +} + +#paragraphs.story-history-fading { + opacity: 0; } .story-block-archiving { @@ -453,20 +461,11 @@ ol.choice { .story-image-landscape, .story-image-square { - clear: both; margin-left: auto; margin-right: auto; } .story-image-portrait { - float: left; - margin-left: 0; - margin-right: 0; - shape-outside: inset(0); -} - -.story-image-portrait.story-image-float-right { - float: right; margin-left: 0; margin-right: 0; } @@ -546,6 +545,10 @@ ol.choice { pointer-events: auto; } +#story_scrollbar[data-dragging="true"] #story_scrollbar_thumb { + transition: none; +} + /* ===== Scrollbar CSS ===== */ /* Firefox */ diff --git a/public/index.html b/public/index.html index 52d9b5b..5e9f63f 100644 --- a/public/index.html +++ b/public/index.html @@ -297,6 +297,6 @@ originalLog.apply(console, args); }; - + diff --git a/public/js/layout-renderer-module.js b/public/js/layout-renderer-module.js index 1b9c64a..af2ef73 100644 --- a/public/js/layout-renderer-module.js +++ b/public/js/layout-renderer-module.js @@ -79,15 +79,14 @@ class LayoutRendererModule extends BaseModule { layoutData.addTopSpace ? 'story-textblock-start' : '', layoutData.dropCap ? 'story-dropcap-paragraph' : '' ].filter(Boolean).join(' '); - paragraph.style.position = 'relative'; + paragraph.style.position = 'absolute'; paragraph.style.margin = '0'; + paragraph.style.left = '0'; + const globalLineStart = Math.max(0, Number(layoutData.lineStart || 0)); + const windowOriginLine = Math.max(0, Number(layoutData.windowOriginLine || 0)); + paragraph.style.top = `${(globalLineStart - windowOriginLine) * Number(lineHeightPx || 0)}px`; if (fontSize) paragraph.style.fontSize = fontSize; if (fontFamily) paragraph.style.fontFamily = fontFamily; - if (Array.isArray(measures) && measures.length > 0) { - paragraph.style.width = `${Math.max(...measures)}px`; - paragraph.style.maxWidth = '100%'; - } - // Calculate paragraph height const storyElement = document.getElementById('story'); if (!storyElement) { @@ -95,30 +94,25 @@ class LayoutRendererModule extends BaseModule { return null; } + const pageWidth = Number(layoutData.pageWidth || storyElement.clientWidth); + paragraph.style.width = `${pageWidth}px`; + paragraph.style.maxWidth = '100%'; + if (!Number.isFinite(Number(lineHeightPx)) || Number(lineHeightPx) <= 0) { throw new Error('LayoutRenderer: Missing canonical lineHeightPx for story layout.'); } const lineHeight = Number(lineHeightPx); - let marginLines = 0; - if (layoutData.role === 'chapter-heading') { - paragraph.style.marginTop = `${lineHeight * 2}px`; - paragraph.style.marginBottom = `${lineHeight}px`; - marginLines = 3; - } else if (layoutData.role === 'section-heading') { - paragraph.style.marginTop = `${lineHeight}px`; - paragraph.style.marginBottom = `${lineHeight}px`; - marginLines = 2; - } else if (layoutData.addTopSpace) { - paragraph.style.marginTop = `${lineHeight}px`; - marginLines = 1; - } + const contentTopLines = Math.max(0, Number(layoutData.contentTopLines || 0)); const maxLineWidth = Array.isArray(measures) && measures.length > 0 - ? Math.max(...measures) - : storyElement.clientWidth; + ? Math.max(pageWidth, ...measures) + : pageWidth; // Height should include all lines (breaks.length represents number of lines) const numLines = Math.max(1, breaks.length - 1); - paragraph.style.height = `${lineHeight * numLines}px`; - paragraph.dataset.heightLines = String(numLines + marginLines); + const totalLines = Math.max(1, Number(layoutData.lineCount || (numLines + contentTopLines))); + paragraph.style.height = `${lineHeight * totalLines}px`; + paragraph.dataset.heightLines = String(totalLines); + paragraph.dataset.lineStart = String(globalLineStart); + paragraph.dataset.lineCount = String(totalLines); console.log(`LayoutRenderer: Rendering paragraph ${id} - ${breaks.length} breaks (${numLines} lines), lineHeight: ${lineHeight}px, total height: ${lineHeight * numLines}px`); @@ -139,6 +133,7 @@ class LayoutRendererModule extends BaseModule { const dropCap = document.createElement('span'); dropCap.className = 'drop-cap story-drop-cap'; dropCap.textContent = layoutData.dropCapText; + dropCap.style.top = `${contentTopLines * lineHeight}px`; paragraph.appendChild(dropCap); } @@ -195,7 +190,7 @@ class LayoutRendererModule extends BaseModule { word.dataset.lineWidth = String(lineWidth); // Calculate position with proper line and justification - const topPercent = (lineIndex * lineHeight * 100) / parseFloat(paragraph.style.height); + const topPercent = ((contentTopLines + lineIndex) * lineHeight * 100) / parseFloat(paragraph.style.height); const leftPercent = ((lineOffset + currentLeft) * 100) / maxLineWidth; word.style.top = `${topPercent}%`; @@ -266,7 +261,7 @@ class LayoutRendererModule extends BaseModule { word.dataset.line = String(lineIndex); word.dataset.lineStart = String(lineOffset); word.dataset.lineWidth = String(lineWidth); - const topPercent = (lineIndex * lineHeight * 100) / parseFloat(paragraph.style.height); + const topPercent = ((contentTopLines + lineIndex) * lineHeight * 100) / parseFloat(paragraph.style.height); const leftPercent = ((lineOffset + currentLeft) * 100) / maxLineWidth; word.style.top = `${topPercent}%`; word.style.left = `${leftPercent}%`; diff --git a/public/js/loader.js b/public/js/loader.js index 0c5da4d..e8c6334 100644 --- a/public/js/loader.js +++ b/public/js/loader.js @@ -24,7 +24,7 @@ const ModuleState = { ERROR: 'ERROR' }; -const MODULE_CACHE_BUSTER = '20260515-lead-kap-verified'; +const MODULE_CACHE_BUSTER = '20260516-scroll-window'; window.MODULE_CACHE_BUSTER = MODULE_CACHE_BUSTER; /** diff --git a/public/js/playback-coordinator-module.js b/public/js/playback-coordinator-module.js index c6abc56..2a478d1 100644 --- a/public/js/playback-coordinator-module.js +++ b/public/js/playback-coordinator-module.js @@ -21,6 +21,7 @@ class PlaybackCoordinatorModule extends BaseModule { 'calculateWordTimings', 'animateWords', 'waitForAudioStart', + 'completeSentenceVisual', 'fastForward', 'stop' ]); @@ -81,11 +82,25 @@ class PlaybackCoordinatorModule extends BaseModule { console.error('PlaybackCoordinator: Error during playback:', error); throw error; } finally { + this.completeSentenceVisual(sentence); this.isPlaying = false; this.currentSentence = null; } } + completeSentenceVisual(sentence) { + if (!sentence?.element) return; + sentence.element.dataset.playbackComplete = 'true'; + sentence.element.querySelectorAll('.word').forEach(word => { + word.style.transition = 'none'; + word.style.animation = 'none'; + word.style.visibility = 'visible'; + word.style.opacity = '1'; + word.style.transform = 'translateY(0)'; + word.style.clipPath = 'inset(0 0 0 0)'; + }); + } + /** * Play TTS audio for a sentence * @param {Object} sentence - Sentence object with TTS data @@ -307,15 +322,7 @@ class PlaybackCoordinatorModule extends BaseModule { } // Complete all word animations immediately - if (this.currentSentence.element) { - const wordElements = this.currentSentence.element.querySelectorAll('.word'); - wordElements.forEach(word => { - word.style.animation = 'none'; - word.style.opacity = '1'; - word.style.transform = 'translateY(0)'; - word.style.clipPath = 'inset(0 0 0 0)'; - }); - } + this.completeSentenceVisual(this.currentSentence); } /** diff --git a/public/js/sentence-queue-module.js b/public/js/sentence-queue-module.js index 1614e4d..d3ff054 100644 --- a/public/js/sentence-queue-module.js +++ b/public/js/sentence-queue-module.js @@ -16,10 +16,8 @@ class SentenceQueueModule extends BaseModule { this.isProcessing = false; this.onSentenceReadyCallback = null; - // Cache for prefetched sentences - this.preparedCache = new Map(); - this.prefetchingCache = new Map(); - this.activeImageWrap = null; + // Cache in-flight TTS prefetches only. Layout belongs to the renderer. + this.prefetchingSpeech = new Map(); this.autoplay = true; this.inputMode = 'text'; this.lastContinueAt = 0; @@ -44,7 +42,6 @@ class SentenceQueueModule extends BaseModule { 'waitForManualContinue', 'prepareSentence', 'prepareLayout', - 'prepareImageLayout', 'extractWords', 'getDropCapText', 'extractDropCapText', @@ -279,9 +276,8 @@ class SentenceQueueModule extends BaseModule { } /** - * Prepare a complete sentence object with TTS and layout - * @param {string} text - Text to prepare - * @returns {Promise} - Complete sentence object + * Prepare queue metadata. This module intentionally does not create layout: + * live rendering and history rendering must go through the same renderer. */ async prepareSentence(item) { const text = typeof item === 'string' ? item : item.text; @@ -297,10 +293,6 @@ class SentenceQueueModule extends BaseModule { } } - const imageLayout = metadata.type === 'image' - ? await this.prepareImageLayout(metadata) - : null; - return { id, kind: metadata.type, @@ -309,7 +301,7 @@ class SentenceQueueModule extends BaseModule { blockId: metadata.blockId ?? null, gameId: metadata.gameId ?? null, status: 'ready', - metadata: imageLayout ? { ...metadata, imageLayout } : metadata, + metadata, tts: { duration: 0, provider: null, audioData: null, play: null, stop: null, enabled: false }, animation: { wordTimings: [], cueTimings: [], totalDuration: 0 }, element: null, @@ -322,17 +314,9 @@ class SentenceQueueModule extends BaseModule { await audioManager.preloadMediaCues(metadata.cueMarkers || []); } - // Prepare TTS and layout in parallel - const [ttsData, layoutData] = await Promise.all([ - this.prepareSpeechMetadata(text), - this.prepareLayout(text, metadata) - ]); + const ttsData = await this.prepareSpeechMetadata(text); - // Calculate animation timing based on TTS duration - const words = this.extractWords(layoutData.nodes); - const animation = this.calculateAnimationTiming(words, ttsData.duration, metadata.cueMarkers || []); - - console.log(`SentenceQueue: Prepared sentence "${text.substring(0, 50)}..." - TTS duration: ${ttsData.duration}ms, Words: ${words.length}, Animation total: ${animation.totalDuration}ms, Layout breaks: ${layoutData.breaks.length}`); + console.log(`SentenceQueue: Prepared speech "${text.substring(0, 50)}..." - TTS duration: ${ttsData.duration}ms`); return { id, @@ -348,7 +332,6 @@ class SentenceQueueModule extends BaseModule { addTopSpace: Boolean(metadata.addTopSpace), cueMarkers: metadata.cueMarkers || [], status: 'ready', - layout: layoutData, tts: { duration: ttsData.duration, provider: ttsData.handler, @@ -357,7 +340,7 @@ class SentenceQueueModule extends BaseModule { stop: ttsData.stop, enabled: ttsData.isTtsEnabled }, - animation: animation, + animation: { wordTimings: [], cueTimings: [], totalDuration: 0 }, element: null, onComplete: null }; @@ -415,27 +398,10 @@ class SentenceQueueModule extends BaseModule { const indentWidth = (isHeading || metadata.isFirstParagraphInChapter || metadata.addTopSpace) ? 0 : lineHeight * 1.5; const layoutText = metadata.layoutText || text; const layoutPlainText = metadata.dropCap ? this.extractDropCapText(layoutText) : layoutText; - const wrap = this.consumeImageWrap(); - - // Measures are consumed in line order by the line breaker. - const wrappedWidth = wrap ? Math.max(120, containerWidth - wrap.width) : containerWidth; - const imageLeftOffset = wrap && wrap.side !== 'right' ? wrap.width : 0; - const imageRightOffset = wrap && wrap.side === 'right' ? wrap.width : 0; - const measures = isHeading + const measures = Array.isArray(metadata.measures) && metadata.measures.length > 0 + ? metadata.measures + : isHeading ? [containerWidth] - : wrap && metadata.dropCap - ? [ - Math.max(120, wrappedWidth - dropCapWidth), - Math.max(120, wrappedWidth - dropCapWidth), - ...Array(Math.max(0, wrap.lines - dropCapLines)).fill(wrappedWidth), - containerWidth - ] - : wrap - ? [ - Math.max(120, wrappedWidth - indentWidth), - ...Array(Math.max(0, wrap.lines - 1)).fill(wrappedWidth), - containerWidth - ] : metadata.dropCap ? [ Math.max(120, containerWidth - dropCapWidth), @@ -447,21 +413,10 @@ class SentenceQueueModule extends BaseModule { containerWidth, containerWidth ]; - const lineOffsets = isHeading + const lineOffsets = Array.isArray(metadata.lineOffsets) && metadata.lineOffsets.length > 0 + ? metadata.lineOffsets + : isHeading ? [0] - : wrap && metadata.dropCap - ? [ - imageLeftOffset + dropCapWidth, - imageLeftOffset + dropCapWidth, - ...Array(Math.max(0, wrap.lines - dropCapLines)).fill(imageLeftOffset), - 0 - ] - : wrap - ? [ - imageLeftOffset + indentWidth, - ...Array(Math.max(0, wrap.lines - 1)).fill(imageLeftOffset), - 0 - ] : metadata.dropCap ? [ dropCapWidth, @@ -474,7 +429,7 @@ class SentenceQueueModule extends BaseModule { 0 ]; - console.log(`SentenceQueue: Layout calculation - indentWidth: ${indentWidth.toFixed(1)}px, imageRightOffset: ${imageRightOffset.toFixed(1)}px, measures: [${measures.map(m => m.toFixed(1)).join(', ')}], offsets: [${lineOffsets.map(m => m.toFixed(1)).join(', ')}]`); + console.log(`SentenceQueue: Layout calculation - indentWidth: ${indentWidth.toFixed(1)}px, measures: [${measures.map(m => m.toFixed(1)).join(', ')}], offsets: [${lineOffsets.map(m => m.toFixed(1)).join(', ')}]`); const layout = paragraphLayout.calculateLayout(layoutPlainText, { measures, @@ -488,14 +443,6 @@ class SentenceQueueModule extends BaseModule { throw new Error('Paragraph layout calculation failed'); } - if (wrap) { - const usedLines = Math.max(0, (layout.breaks?.length || 1) - 1); - const remainingLines = Math.max(0, wrap.lines - usedLines); - this.activeImageWrap = remainingLines > 0 - ? { ...wrap, lines: remainingLines } - : null; - } - return { breaks: layout.breaks, nodes: layout.nodes, @@ -504,7 +451,7 @@ class SentenceQueueModule extends BaseModule { measures, lineOffsets, indentWidth, - imageWrap: wrap, + imageWrap: metadata.imageWrap || null, dropCap: Boolean(metadata.dropCap), dropCapText: metadata.dropCap ? this.getDropCapText(layoutText) : '', dropCapLines, @@ -574,25 +521,9 @@ class SentenceQueueModule extends BaseModule { } async getPreparedSentence(item) { - const cacheKey = this.getCacheKey(item); - const cached = this.preparedCache.get(cacheKey); - - if (cached) { - console.log('SentenceQueue: Using cached sentence'); - this.preparedCache.delete(cacheKey); - return cached; - } - - const pending = this.prefetchingCache.get(cacheKey); + const pending = this.prefetchingSpeech.get(this.getCacheKey(item)); if (pending) { - console.log('SentenceQueue: Awaiting active prefetch'); - try { - const prepared = await pending; - return prepared || await this.prepareSentence(item); - } finally { - this.prefetchingCache.delete(cacheKey); - this.preparedCache.delete(cacheKey); - } + await pending.catch(() => null); } return this.prepareSentence(item); @@ -614,7 +545,7 @@ class SentenceQueueModule extends BaseModule { for (let index = 1; index < limit; index += 1) { const nextItem = this.sentenceQueue[index]; const nextCacheKey = this.getCacheKey(nextItem); - if (this.preparedCache.has(nextCacheKey) || this.prefetchingCache.has(nextCacheKey)) { + if (this.prefetchingSpeech.has(nextCacheKey)) { if (this.isSpeechItem(nextItem)) spokenPrepared += 1; continue; } @@ -625,25 +556,26 @@ class SentenceQueueModule extends BaseModule { })); console.log(`Process state: ${state}`, { reason: 'prefetch-start', sentenceId: nextItem.id, queueIndex: index }); - const promise = this.prepareSentence(nextItem) - .then(prepared => { - this.preparedCache.set(nextCacheKey, prepared); - console.log('SentenceQueue: Prefetched queued item', { sentenceId: nextItem.id, queueIndex: index }); + const promise = (this.isSpeechItem(nextItem) + ? this.prepareSpeechMetadata(nextItem.text || '') + : Promise.resolve(null)) + .then(() => { + console.log('SentenceQueue: Prefetched queued speech/media', { sentenceId: nextItem.id, queueIndex: index }); document.dispatchEvent(new CustomEvent('story:process-state', { detail: { state: 'playing-ready', reason: 'prefetch-complete', sentenceId: nextItem.id, queueIndex: index } })); console.log('Process state: playing-ready', { reason: 'prefetch-complete', sentenceId: nextItem.id, queueIndex: index }); - return prepared; + return true; }) .catch(err => { console.warn('SentenceQueue: Prefetch failed:', err); return null; }) .finally(() => { - this.prefetchingCache.delete(nextCacheKey); + this.prefetchingSpeech.delete(nextCacheKey); }); - this.prefetchingCache.set(nextCacheKey, promise); + this.prefetchingSpeech.set(nextCacheKey, promise); started += 1; if (this.isSpeechItem(nextItem)) { @@ -741,76 +673,6 @@ class SentenceQueueModule extends BaseModule { }); } - async prepareImageLayout(metadata = {}) { - const storyElement = document.getElementById('story'); - if (!storyElement) { - throw new Error("Story container not found"); - } - - if (document.fonts && document.fonts.ready) { - await document.fonts.ready; - } - - const computedStyle = window.getComputedStyle(storyElement); - const lineHeight = parseFloat(computedStyle.lineHeight) || 24; - - const pageWidth = storyElement.clientWidth; - const requestedSize = String(metadata.size || 'landscape').toLowerCase(); - const size = requestedSize === 'widescreen' ? 'landscape' : requestedSize; - const isPortrait = size === 'portrait'; - const aspect = isPortrait ? (9 / 16) : size === 'square' ? 1 : (16 / 9); - const imageGap = lineHeight; - const maxOuterWidth = isPortrait ? pageWidth * 0.5 : pageWidth; - const maxImageWidth = isPortrait - ? Math.max(lineHeight * 4, maxOuterWidth - imageGap) - : maxOuterWidth; - const naturalHeight = maxImageWidth / aspect; - const imageLineCount = Math.max(1, Math.floor(naturalHeight / lineHeight)); - const verticalMargin = isPortrait ? lineHeight / 2 : 0; - const lineCount = isPortrait ? imageLineCount + 1 : imageLineCount; - const height = isPortrait - ? Math.max(lineHeight, (lineCount * lineHeight) - (verticalMargin * 2)) - : imageLineCount * lineHeight; - const width = Math.min(maxImageWidth, height * aspect); - - if (isPortrait) { - this.activeImageWrap = { - lines: lineCount, - width: width + imageGap, - imageWidth: width, - gap: imageGap, - height, - lineHeight, - side: metadata.floatSide || 'left' - }; - } - - return { - size, - aspect, - width, - height, - gap: imageGap, - lineCount, - imageLineCount, - lineHeight, - verticalMargin, - floatSide: metadata.floatSide || 'left', - pageWidth - }; - } - - consumeImageWrap() { - if (!this.activeImageWrap || this.activeImageWrap.lines <= 0) { - this.activeImageWrap = null; - return null; - } - - const wrap = { ...this.activeImageWrap }; - this.activeImageWrap = null; - return wrap; - } - /** * Extract words from layout nodes * @param {Array} nodes - Layout nodes from Knuth-Plass algorithm @@ -919,8 +781,7 @@ class SentenceQueueModule extends BaseModule { clear() { this.sentenceQueue = []; this.isProcessing = false; - this.preparedCache.clear(); - this.activeImageWrap = null; + this.prefetchingSpeech.clear(); document.dispatchEvent(new CustomEvent('tts:queue-empty', { detail: { reason: 'sentence-queue-cleared' } })); diff --git a/public/js/story-history-module.js b/public/js/story-history-module.js index 0c46a36..0ebc0e4 100644 --- a/public/js/story-history-module.js +++ b/public/js/story-history-module.js @@ -34,8 +34,9 @@ class StoryHistoryModule extends BaseModule { 'hasSaveSlot', 'getSaveSlots', 'getBlocks', + 'getBlock', 'getBlocksRange', - 'getWindowForTurn', + 'getFirstBlockForTurn', 'getRenderedLineCount', 'findBlockForLine', 'clearGame', @@ -145,12 +146,12 @@ class StoryHistoryModule extends BaseModule { }); if (!record) return null; - const lineCount = Math.max(1, Number(metrics.lineCount || record.lineCount || 1)); - const previousLineCount = Number(record.lineCount || 0); - const hadLineStart = Number.isFinite(Number(record.lineStart)); - const lineStart = hadLineStart - ? Math.max(0, Number(record.lineStart)) - : this.renderedLineCount; + const lineCount = Math.max(0, Number(metrics.lineCount ?? record.lineCount ?? 1)); + const lineStart = Number.isFinite(Number(metrics.lineStart)) + ? Math.max(0, Number(metrics.lineStart)) + : Number.isFinite(Number(record.lineStart)) + ? Math.max(0, Number(record.lineStart)) + : this.renderedLineCount; const updated = { ...record, @@ -159,11 +160,7 @@ class StoryHistoryModule extends BaseModule { metricsUpdatedAt: Date.now() }; - if (!hadLineStart) { - this.renderedLineCount = Math.max(this.renderedLineCount, lineStart + lineCount); - } else if (lineStart + previousLineCount >= this.renderedLineCount) { - this.renderedLineCount = Math.max(lineStart + lineCount, this.renderedLineCount + (lineCount - previousLineCount)); - } + this.renderedLineCount = Math.max(this.renderedLineCount, lineStart + lineCount); await new Promise((resolve, reject) => { const request = this.tx(this.historyStore, 'readwrite').put(updated); @@ -235,6 +232,16 @@ class StoryHistoryModule extends BaseModule { }); } + getBlock(gameId = this.currentGameId, blockId = null) { + if (!this.db || !gameId || blockId == null) return Promise.resolve(null); + const id = Math.max(1, Number(blockId || 1)); + return new Promise((resolve, reject) => { + const request = this.tx(this.historyStore).get(`${gameId}:${id}`); + request.onsuccess = () => resolve(request.result || null); + request.onerror = () => reject(request.error); + }); + } + getBlocksRange(gameId = this.currentGameId, startBlockId = 1, endBlockId = Infinity) { if (!this.db || !gameId) return Promise.resolve([]); const start = Math.max(1, Number(startBlockId || 1)); @@ -259,9 +266,9 @@ class StoryHistoryModule extends BaseModule { }); } - async getWindowForTurn(gameId = this.currentGameId, turnId, visibleLimit = this.visibleLimit) { - if (!this.db || !gameId || turnId == null) return { blocks: [], targetBlockId: null }; - const target = await new Promise((resolve, reject) => { + async getFirstBlockForTurn(gameId = this.currentGameId, turnId) { + if (!this.db || !gameId || turnId == null) return null; + return new Promise((resolve, reject) => { const index = this.tx(this.historyStore).index('gameId'); const request = index.openCursor(IDBKeyRange.only(gameId), 'next'); request.onsuccess = () => { @@ -278,15 +285,6 @@ class StoryHistoryModule extends BaseModule { }; request.onerror = () => reject(request.error); }); - - if (!target?.blockId) return { blocks: [], targetBlockId: null }; - const latest = Math.max(0, this.nextBlockId - 1); - const limit = Math.max(1, Number(visibleLimit || this.visibleLimit)); - const halfBefore = Math.floor(limit / 2); - const maxStart = Math.max(1, latest - limit + 1); - const start = Math.max(1, Math.min(maxStart, target.blockId - halfBefore)); - const blocks = await this.getBlocksRange(gameId, start, Math.min(latest, start + limit - 1)); - return { blocks, targetBlockId: target.blockId }; } async getRenderedLineCount(gameId = this.currentGameId, latestRenderedBlockId = this.latestRenderedBlockId) { diff --git a/public/js/ui-display-handler-module.js b/public/js/ui-display-handler-module.js index 740aa6f..f72ec51 100644 --- a/public/js/ui-display-handler-module.js +++ b/public/js/ui-display-handler-module.js @@ -27,18 +27,27 @@ class UIDisplayHandlerModule extends BaseModule { this.pageLineCount = PAGE_LINE_COUNT; this.historyWindowStartId = 1; this.historyWindowEndId = 0; - this.loadingHistoryPage = false; this.draggingStoryScrollbar = false; - this.historyWheelAccumulator = 0; this.storyTopLine = 0; this.storyOffsetPx = 0; + this.windowOriginLine = 0; this.storyScrollAnimation = null; - this.storyScrollAnimationId = 0; + 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.historyEnsurePending = false; - this.lastEnsuredCenterBucket = null; + this.maxTraversalBlocks = 190; + this.scrollbarPreviewLine = null; + this.storyScrollbarReleaseHandler = null; + this.lastManualScrollAt = 0; + this.layoutFlowLine = 0; + this.layoutExclusions = []; // Resources to preload this.cssPath = '/css/style.css'; @@ -53,19 +62,37 @@ class UIDisplayHandlerModule extends BaseModule { 'applyGameConfig', 'applyTranslations', 'renderSentence', + 'renderStoryBlock', + 'prepareRenderableBlock', + 'prepareTextRenderable', + 'prepareImageRenderable', + 'createImageExclusion', + 'addImageExclusion', + 'rebuildLayoutExclusions', + 'getActiveExclusions', + 'buildLineGeometry', + 'makeRenderedWordsVisible', 'markBlockRendered', - 'trimVisibleBlocks', 'restoreFromHistory', - 'renderStoredItem', 'renderHistoryWindow', - 'renderHistoryWindowForTurn', + 'renderIncrementalWindow', + 'setWindowOriginLine', + 'removeRenderedBlocksOutside', + 'removeRenderedElement', + 'dedupeRenderedWindow', + 'reflowTextBlocksForActiveExclusions', + 'blockIntersectsExclusions', 'insertStoredElement', - 'trimVirtualWindow', 'handleHistoryWheel', + 'handleManualScrollStart', + 'getActiveLineForTopLine', + 'getTopLineForActiveLine', + 'getRenderedBlockForLine', + 'getWindowBoundsForTraversal', + 'renderWindowForBounds', 'disableAutoplayForManualScroll', 'updateStoryScrollbar', 'handleStoryScrollbarPointer', - 'handleDeferredMediaBlock', 'renderImageBlock', 'revealImageBlock', 'resolveImageUrl', @@ -75,16 +102,18 @@ class UIDisplayHandlerModule extends BaseModule { 'recordRenderedMetrics', 'setVirtualPadding', 'setStoryOffset', - 'scrollStoryByLines', - 'setStoryTopLine', + 'scrollUp', + 'scrollDown', + 'scrollTo', + 'getCurrentScrollLine', + 'getLiveEndLine', + 'ensureScrollRangeForTarget', + 'animateToTopLine', 'getMaxStoryTopLine', 'ensureLiveTailWindow', - 'ensureHistoryWindowForLine', - 'loadHistoryWindowAround', 'readFirstFiniteNumber', 'waitForSkippablePause', - 'scrollStoryToEnd', - 'scrollToTurn', + 'focusTurn', 'handleStoryScroll', 'rerenderStory', 'clear', @@ -139,7 +168,7 @@ class UIDisplayHandlerModule extends BaseModule { this.applyTranslations(); }); this.addEventListener(document, 'story:scroll-to-turn', (event) => { - this.scrollToTurn(event.detail?.turnId); + this.focusTurn(event.detail?.turnId); }); this.addEventListener(document, 'story:history-updated', (event) => { this.updateStoryScrollbar(event.detail || {}); @@ -152,12 +181,28 @@ class UIDisplayHandlerModule extends BaseModule { } if (event.key === 'ArrowUp') { event.preventDefault(); - this.disableAutoplayForManualScroll(); - this.scrollStoryByLines(-3, true); + this.handleManualScrollStart('arrow-up'); + this.scrollUp(1); } else if (event.key === 'ArrowDown') { event.preventDefault(); - this.disableAutoplayForManualScroll(); - this.scrollStoryByLines(3, true); + 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) => { @@ -372,6 +417,8 @@ class UIDisplayHandlerModule extends BaseModule { 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(); @@ -509,97 +556,35 @@ class UIDisplayHandlerModule extends BaseModule { } - /** - * Display a local UI message outside the server turn protocol. - * Story output must flow through structured TurnResult objects instead. - */ - /** - * Render a prepared sentence to the display - * @param {Object} sentence - Prepared sentence object from SentenceQueue - * @returns {Promise} - Promise resolving to the paragraph element - */ async renderSentence(sentence) { - if (!sentence || !sentence.layout) { - if (sentence && (sentence.kind === 'image' || sentence.kind === 'music')) { - return this.handleDeferredMediaBlock(sentence); - } + 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.layoutFlowLine = Math.max(0, Number(this.storyHistory?.renderedLineCount || 0)); + this.rebuildLayoutExclusions(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' }); - // Render DOM from layout data - const paragraphElement = this.layoutRenderer.renderParagraph( - sentence.layout, - { id: sentence.id } - ); - if (sentence.turnId != null) { - paragraphElement.dataset.turnId = String(sentence.turnId); - paragraphElement.classList.add('story-turn-block'); - } - if (sentence.blockId != null) { - paragraphElement.dataset.storyBlockId = String(sentence.blockId); - this.markBlockRendered(sentence.blockId); + 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 || {}); } - const renderedItem = { - type: sentence.kind === 'heading' ? 'heading' : 'paragraph', - id: sentence.id, - turnId: sentence.turnId ?? null, - blockId: sentence.blockId ?? null, - gameId: sentence.gameId ?? null, - text: sentence.text, - metadata: { - layoutText: sentence.layout?.sourceLayoutText || sentence.text, - cueMarkers: sentence.cueMarkers || [], - role: sentence.role || 'body', - isFirstParagraphInChapter: sentence.isFirstParagraphInChapter, - dropCap: sentence.dropCap, - addTopSpace: sentence.addTopSpace, - paragraphIndex: sentence.paragraphIndex - } - }; - - // Append to container - if (this.paragraphContainer) { - this.paragraphContainer.appendChild(paragraphElement); - this.renderedItems.push(renderedItem); - this.historyWindowStartId = this.renderedItems.find(item => item.blockId != null)?.blockId || this.historyWindowStartId; - this.historyWindowEndId = [...this.renderedItems].reverse().find(item => item.blockId != null)?.blockId || this.historyWindowEndId; - this.updateStoryScrollbar(); - if (typeof this.layoutRenderer.adjustJustification === 'function') { - this.layoutRenderer.adjustJustification(paragraphElement); - } - const updated = await this.recordRenderedMetrics(sentence.blockId, paragraphElement); - if (updated) { - renderedItem.lineStart = updated.lineStart; - renderedItem.lineCount = updated.lineCount; - renderedItem.metadata.lineStart = updated.lineStart; - renderedItem.metadata.lineCount = updated.lineCount; - this.setVirtualPadding(); - } - } else { - console.error('UIDisplayHandler: Paragraph container not found'); - return null; - } - - // Store element reference in sentence - sentence.element = paragraphElement; - await this.trimVisibleBlocks(); - - await this.scrollStoryToEnd(true); - - // Start coordinated playback (animation + TTS), including chapter headings. - await this.playbackCoordinator.play(sentence); - - // Call completion callback if (sentence.onComplete) { sentence.onComplete(); } - return paragraphElement; + return element; } catch (error) { console.error('UIDisplayHandler: Error rendering sentence:', error); @@ -609,77 +594,10 @@ class UIDisplayHandlerModule extends BaseModule { async rerenderStory() { if (!this.paragraphContainer || this.renderedItems.length === 0) return; - - const sentenceQueue = this.getModule('sentence-queue'); - if (!sentenceQueue || typeof sentenceQueue.prepareLayout !== 'function') return; - console.log('UIDisplayHandler: Re-typesetting story after page resize'); - const storyTopLine = this.storyTopLine || 0; - this.paragraphContainer.innerHTML = ''; - - for (const item of this.renderedItems) { - if (item.type === 'image') { - const sentenceQueue = this.getModule('sentence-queue'); - const metadata = { - ...item, - ...(item.metadata || {}), - turnId: item.turnId ?? item.metadata?.turnId, - blockId: item.blockId ?? item.metadata?.blockId - }; - const imageLayout = sentenceQueue && typeof sentenceQueue.prepareImageLayout === 'function' - ? await sentenceQueue.prepareImageLayout(metadata) - : null; - this.renderImageBlock({ - ...metadata, - imageLayout: imageLayout || metadata.imageLayout - }, false); - continue; - } - - if (item.type === 'heading') { - const layout = await sentenceQueue.prepareLayout(item.text, item.metadata || {}); - const heading = this.layoutRenderer.renderParagraph(layout, { id: item.id }); - if (item.turnId != null) { - heading.dataset.turnId = String(item.turnId); - heading.classList.add('story-turn-block'); - } - heading.querySelectorAll('.word').forEach(word => { - word.style.transition = 'none'; - word.style.animation = 'none'; - word.style.visibility = 'visible'; - word.style.opacity = '1'; - word.style.transform = 'translateY(0)'; - word.style.clipPath = 'inset(0 0 0 0)'; - }); - this.paragraphContainer.appendChild(heading); - continue; - } - - if (item.type !== 'paragraph') continue; - - const layout = await sentenceQueue.prepareLayout(item.text, item.metadata || {}); - const paragraph = this.layoutRenderer.renderParagraph(layout, { id: item.id }); - if (item.turnId != null) { - paragraph.dataset.turnId = String(item.turnId); - paragraph.classList.add('story-turn-block'); - } - paragraph.querySelectorAll('.word').forEach(word => { - word.style.transition = 'none'; - word.style.animation = 'none'; - word.style.visibility = 'visible'; - word.style.opacity = '1'; - word.style.transform = 'translateY(0)'; - word.style.clipPath = 'inset(0 0 0 0)'; - }); - this.paragraphContainer.appendChild(paragraph); - } - - this.measureStoryLineHeight(); - this.setStoryTopLine(storyTopLine, false, { mode: 'rerender-preserve', ensure: false }); - } - - scrollStoryToEnd(smooth = true) { - return this.setStoryTopLine(this.getMaxStoryTopLine(), smooth, { mode: 'auto-bottom', ensure: false }); + const activeLine = this.getCurrentScrollLine(); + await this.renderHistoryWindow([...this.renderedItems]); + await this.scrollTo(activeLine, { mode: 'rerender-preserve', smooth: false }); } async restoreFromHistory(saveRecord = {}) { @@ -688,58 +606,462 @@ class UIDisplayHandlerModule extends BaseModule { if (!this.storyHistory.renderedLineCount) { await this.storyHistory.getRenderedLineCount(saveRecord.gameId, latestRenderedBlockId); } - const blocks = await this.storyHistory.getBlocks( - saveRecord.gameId, - this.visibleBlockLimit, - latestRenderedBlockId + 1 - ); - await this.renderHistoryWindow(blocks, 'bottom'); - this.updateStoryScrollbar({ latestBlockId: saveRecord.latestBlockId || blocks.at(-1)?.blockId || 1 }); + 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') { - if (!this.paragraphContainer || !element) return; + insertStoredElement(element, placement = 'append', targetContainer = this.paragraphContainer) { + if (!targetContainer || !element) return; if (placement === 'prepend') { - this.paragraphContainer.insertBefore(element, this.paragraphContainer.firstChild); + targetContainer.insertBefore(element, targetContainer.firstChild); } else { - this.paragraphContainer.appendChild(element); + targetContainer.appendChild(element); } } - async renderStoredItem(item, placement = 'append') { - const sentenceQueue = this.getModule('sentence-queue'); - if (!sentenceQueue) return null; - if (placement === 'prepend') { - this.renderedItems.unshift(item); + 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 { - this.renderedItems.push(item); + 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.type === 'image') { - const metadata = { - ...item, + 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 || {}), - turnId: item.turnId ?? item.metadata?.turnId, - blockId: item.blockId ?? item.metadata?.blockId - }; - const imageLayout = typeof sentenceQueue.prepareImageLayout === 'function' - ? await sentenceQueue.prepareImageLayout(metadata) - : null; - const imageElement = this.renderImageBlock({ - ...metadata, - imageLayout: imageLayout || metadata.imageLayout - }, false, placement); - if (imageElement && item.blockId != null) imageElement.dataset.storyBlockId = String(item.blockId); - if (imageElement && Number.isFinite(Number(item.lineStart))) imageElement.dataset.lineStart = String(item.lineStart); - if (imageElement && Number.isFinite(Number(item.lineCount))) imageElement.dataset.lineCount = String(item.lineCount); - return imageElement; + ...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); + } + + 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; } - if (item.type !== 'heading' && item.type !== 'paragraph') return null; const metadata = { ...(item.metadata || {}), - type: item.type, - role: item.role || item.metadata?.role || (item.type === 'heading' ? 'chapter-heading' : 'body'), + 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), @@ -750,16 +1072,167 @@ class UIDisplayHandlerModule extends BaseModule { blockId: item.blockId ?? item.metadata?.blockId, gameId: item.gameId ?? item.metadata?.gameId }; - const layout = await sentenceQueue.prepareLayout(item.text, metadata); - const element = this.layoutRenderer.renderParagraph(layout, { id: item.id }); - if (item.turnId != null) { - element.dataset.turnId = String(item.turnId); - element.classList.add('story-turn-block'); + + 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); } - if (item.blockId != null) element.dataset.storyBlockId = String(item.blockId); - if (Number.isFinite(Number(item.lineStart))) element.dataset.lineStart = String(item.lineStart); - if (Number.isFinite(Number(item.lineCount))) element.dataset.lineCount = String(item.lineCount); - element.querySelectorAll('.word').forEach(word => { + + 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 ? lineHeight * 1.45 : 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'; @@ -767,87 +1240,6 @@ class UIDisplayHandlerModule extends BaseModule { word.style.transform = 'translateY(0)'; word.style.clipPath = 'inset(0 0 0 0)'; }); - this.insertStoredElement(element, placement); - return element; - } - - async renderHistoryWindow(blocks = [], scrollTarget = 'top') { - if (!this.paragraphContainer) return; - const previousTopLine = this.storyTopLine || 0; - this.paragraphContainer.innerHTML = ''; - this.renderedItems = []; - - for (const item of blocks) { - await this.renderStoredItem(item, 'append'); - } - - this.historyWindowStartId = blocks[0]?.blockId || 1; - this.historyWindowEndId = blocks.at(-1)?.blockId || 0; - this.setVirtualPadding(); - this.updateStoryScrollbar(); - - await new Promise(resolve => { - window.requestAnimationFrame(() => { - window.requestAnimationFrame(() => { - if (scrollTarget === 'bottom') { - this.scrollStoryToEnd(false); - } else if (scrollTarget === 'preserve') { - this.setStoryTopLine(previousTopLine, false, { mode: 'history-preserve', ensure: false }); - } else { - const firstLine = Number(blocks[0]?.lineStart || 0); - this.setStoryTopLine(firstLine, false, { mode: 'history-top', ensure: false }); - } - resolve(); - }); - }); - }); - } - - async renderHistoryWindowForTurn(turnId) { - if (!this.storyHistory || !this.paragraphContainer || turnId == null) return null; - const result = await this.storyHistory.getWindowForTurn( - this.storyHistory.currentGameId, - turnId, - this.visibleBlockLimit - ); - if (!result?.blocks?.length) return null; - await this.renderHistoryWindow(result.blocks, 'top'); - return result.targetBlockId; - } - - handleHistoryWheel(event) { - if (!event.target?.closest?.('#page_right') || !this.pageRight) return; - event.preventDefault(); - event.stopPropagation(); - this.disableAutoplayForManualScroll(); - const lineDelta = (Number(event.deltaY || 0) / Math.max(8, this.lineHeightPx || 24)) * 0.85; - this.scrollStoryByLines(lineDelta, true); - } - - async trimVirtualWindow(direction = 1) { - if (!this.paragraphContainer) return; - const excess = this.renderedItems.length - this.visibleBlockLimit; - if (excess <= 0) return; - - for (let index = 0; index < excess; index += 1) { - if (direction > 0) { - const removed = this.renderedItems.shift(); - const element = removed?.blockId != null - ? this.paragraphContainer.querySelector(`[data-story-block-id="${CSS.escape(String(removed.blockId))}"]`) - : null; - element?.remove(); - } else { - const removed = this.renderedItems.pop(); - const element = removed?.blockId != null - ? this.paragraphContainer.querySelector(`[data-story-block-id="${CSS.escape(String(removed.blockId))}"]`) - : null; - element?.remove(); - } - } - - this.historyWindowStartId = this.renderedItems.find(item => item.blockId != null)?.blockId || 1; - this.historyWindowEndId = [...this.renderedItems].reverse().find(item => item.blockId != null)?.blockId || 0; - this.setVirtualPadding(); } markBlockRendered(blockId) { @@ -867,10 +1259,6 @@ class UIDisplayHandlerModule extends BaseModule { return Math.max(0, Number(this.storyHistory?.latestRenderedBlockId || 0)); } - hasNewerHistory() { - return this.historyWindowEndId < this.getLatestHistoryBlockId(); - } - updateStoryScrollbar(detail = {}) { const track = document.getElementById('story_scrollbar'); const thumb = document.getElementById('story_scrollbar_thumb'); @@ -880,7 +1268,10 @@ class UIDisplayHandlerModule extends BaseModule { const viewportLines = Math.max(1, this.viewportLineCount || 1); const visibleLines = Math.min(viewportLines, totalLines); const maxTopLine = Math.max(0, totalLines - visibleLines); - const currentTop = Math.max(0, Math.min(maxTopLine, this.storyTopLine || 0)); + const 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) { @@ -896,23 +1287,84 @@ class UIDisplayHandlerModule extends BaseModule { handleStoryScrollbarPointer(event) { event.preventDefault(); event.stopPropagation(); - this.disableAutoplayForManualScroll(); + this.handleManualScrollStart('scrollbar'); const track = event.currentTarget; if (!track) return; - const moveToPointer = (pointerEvent) => { + 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 ratio = Math.max(0, Math.min(1, (pointerEvent.clientY - rect.top) / Math.max(1, rect.height))); - this.setStoryTopLine(this.getMaxStoryTopLine() * ratio, true, { mode: 'manual' }); + 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)}%`; + } }; - moveToPointer(event); - const onMove = (moveEvent) => moveToPointer(moveEvent); - const onUp = () => { + previewToPointer(event); + const onMove = (moveEvent) => previewToPointer(moveEvent); + const cleanup = () => { + this.storyScrollbarReleaseHandler = null; document.removeEventListener('pointermove', onMove); - document.removeEventListener('pointerup', onUp); + 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', onUp, { once: true }); + document.addEventListener('pointerup', onRelease); + document.addEventListener('pointercancel', onRelease); + document.addEventListener('mouseup', onRelease); + window.addEventListener('blur', onRelease); } disableAutoplayForManualScroll() { @@ -942,17 +1394,19 @@ class UIDisplayHandlerModule extends BaseModule { measureBlockLines(element, fallbackLineCount = 1) { const lineHeight = this.measureStoryLineHeight(); const declaredLines = Number(element?.dataset?.heightLines); - if (Number.isFinite(declaredLines) && declaredLines > 0) { - const lineCount = Math.max(1, Math.round(declaredLines)); + 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) { + async recordRenderedMetrics(blockId, element, fallbackLineCount = 1, lineStart = null) { if (!this.storyHistory || typeof this.storyHistory.updateBlockMetrics !== 'function' || blockId == null) return null; - await new Promise(resolve => window.requestAnimationFrame(resolve)); const metrics = this.measureBlockLines(element, fallbackLineCount); + 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); @@ -965,16 +1419,16 @@ class UIDisplayHandlerModule extends BaseModule { setVirtualPadding() { if (!this.paragraphContainer) return; - const first = this.renderedItems.find(item => item.blockId != null); - const last = [...this.renderedItems].reverse().find(item => item.blockId != null); - const topLines = Math.max(0, Number(first?.lineStart || first?.metadata?.lineStart || 0)); - const lastStart = Number(last?.lineStart ?? last?.metadata?.lineStart ?? 0); - const lastCount = Number(last?.lineCount ?? last?.metadata?.lineCount ?? 0); - const totalLines = Math.max(0, Number(this.storyHistory?.renderedLineCount || 0)); - const bottomLines = Math.max(0, totalLines - (Number.isFinite(lastStart) ? lastStart + Math.max(1, lastCount || 1) : totalLines)); + const 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 = `${topLines * lineHeight}px`; - this.paragraphContainer.style.paddingBottom = `${bottomLines * lineHeight}px`; + this.paragraphContainer.style.paddingTop = '0'; + this.paragraphContainer.style.paddingBottom = '0'; + this.paragraphContainer.style.height = `${totalLines * lineHeight}px`; } setStoryOffset(offsetPx) { @@ -992,125 +1446,243 @@ class UIDisplayHandlerModule extends BaseModule { return Math.max(0, totalLines - Math.max(1, this.viewportLineCount || 1)); } - scrollStoryByLines(deltaLines, smooth = true) { - const nextLine = (this.storyTopLine || 0) + Number(deltaLines || 0); - return this.setStoryTopLine(nextLine, smooth, { mode: 'manual' }); + 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)); } - setStoryTopLine(targetLine, smooth = true, options = {}) { - this.measureStoryLineHeight(); + getTopLineForActiveLine(activeLine = 0) { const maxTopLine = this.getMaxStoryTopLine(); - const target = Math.round(Math.max(0, Math.min(maxTopLine, Number(targetLine || 0)))); - const start = this.storyTopLine || 0; - const delta = target - start; - const animationId = ++this.storyScrollAnimationId; - - if (!smooth || Math.abs(delta) < 0.02) { - this.storyTopLine = target; - this.setStoryOffset(-(target * this.lineHeightPx)); - if (options.ensure !== false) { - this.ensureHistoryWindowForLine(target + (this.viewportLineCount / 2)); - } - return Promise.resolve(); - } - - return new Promise(resolve => { - const startedAt = performance.now(); - const duration = Math.min(1100, Math.max(360, Math.abs(delta) * 36)); - const ease = (t) => t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2; - const shouldEnsureDuringAnimation = options.ensure !== false; - const step = (now) => { - if (animationId !== this.storyScrollAnimationId) { - resolve(); - return; - } - const progress = Math.min(1, (now - startedAt) / duration); - this.storyTopLine = start + (delta * ease(progress)); - this.setStoryOffset(-(this.storyTopLine * this.lineHeightPx)); - if (shouldEnsureDuringAnimation) { - this.ensureHistoryWindowForLine(this.storyTopLine + (this.viewportLineCount / 2)); - } - if (progress < 1) { - requestAnimationFrame(step); - } else { - this.storyTopLine = target; - this.setStoryOffset(-(target * this.lineHeightPx)); - if (shouldEnsureDuringAnimation) { - this.ensureHistoryWindowForLine(target + (this.viewportLineCount / 2)); - } - resolve(); - } - }; - requestAnimationFrame(step); - }); + const requested = Math.round(Number(activeLine || 0)) - Math.max(1, this.viewportLineCount || 1) + 1; + return Math.max(0, Math.min(maxTopLine, requested)); } - async ensureLiveTailWindow() { - if (!this.storyHistory || !this.paragraphContainer || this.loadingHistoryPage) return; - const latestRendered = Math.max(0, Number(this.storyHistory.latestRenderedBlockId || 0)); - if (latestRendered <= 0) return; - if ((this.historyWindowEndId || 0) >= latestRendered) return; - - const count = Math.max(1, this.visibleBlockLimit - 1); - const start = Math.max(1, latestRendered - count + 1); - const blocks = await this.storyHistory.getBlocksRange(this.storyHistory.currentGameId, start, latestRendered); - if (!blocks.length) return; - - await this.renderHistoryWindow(blocks, 'bottom'); - this.activeCenterBlockId = latestRendered; - this.lastEnsuredCenterBucket = null; + 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; } - async ensureHistoryWindowForLine(centerLine) { - if (!this.storyHistory || this.loadingHistoryPage) return; - const bucket = Math.floor(Math.max(0, Number(centerLine || 0)) / Math.max(1, this.viewportLineCount || 1)); - if (this.historyEnsurePending || bucket === this.lastEnsuredCenterBucket) return; - this.historyEnsurePending = true; - this.lastEnsuredCenterBucket = bucket; - try { - const block = await this.storyHistory.findBlockForLine( - this.storyHistory.currentGameId, - centerLine, - this.storyHistory.latestRenderedBlockId - ); - const centerBlockId = block?.blockId; - if (!centerBlockId || centerBlockId === this.activeCenterBlockId) return; - this.activeCenterBlockId = centerBlockId; - const hasEnoughBefore = centerBlockId - (this.historyWindowStartId || 1) >= this.historyBufferBlocks; - const hasEnoughAfter = (this.historyWindowEndId || 0) - centerBlockId >= this.historyBufferBlocks; - if (!hasEnoughBefore || !hasEnoughAfter) { - await this.loadHistoryWindowAround(centerBlockId, 'preserve'); - } - } finally { - this.historyEnsurePending = false; + getCurrentScrollLine() { + if (Number.isFinite(Number(this.scrollTargetLine))) { + return Math.max(0, Number(this.scrollTargetLine)); } + return this.getActiveLineForTopLine(this.storyTopLine); } - async loadHistoryWindowAround(centerBlockId, scrollTarget = 'preserve') { - if (!this.storyHistory || this.loadingHistoryPage) return; + 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)); - const center = Math.max(1, Math.min(latest, Number(centerBlockId || 1))); - const start = Math.max(1, center - this.historyBufferBlocks); - const end = Math.min(latest, center + this.historyBufferBlocks); - if (start === this.historyWindowStartId && end === this.historyWindowEndId) return; - this.loadingHistoryPage = true; - try { - const blocks = await this.storyHistory.getBlocksRange(this.storyHistory.currentGameId, start, end); - if (blocks.length) { - await this.renderHistoryWindow(blocks, scrollTarget); - } - } finally { - this.loadingHistoryPage = false; - } - } + if (latest <= 0) return; - async trimVisibleBlocks() { - if (!this.paragraphContainer) return; - await this.trimVirtualWindow(1); + const bounds = await this.getWindowBoundsForTraversal(previousLine, targetLine, latest); + if (!bounds) 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(); } - scrollToTurn(turnId) { + 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 = Math.max(0, Number(this.storyHistory.renderedLineCount || 0)); + 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 = () => { @@ -1118,14 +1690,17 @@ class UIDisplayHandlerModule extends BaseModule { if (!target) return false; const targetLine = Number(target.dataset.lineStart); if (Number.isFinite(targetLine)) { - this.setStoryTopLine(targetLine, true, { mode: 'jump-to-turn' }); + this.scrollTo(targetLine, { mode: 'jump-to-turn' }); } return true; }; if (scrollToLiveTarget()) return; - this.renderHistoryWindowForTurn(turnId).then(() => { - requestAnimationFrame(() => scrollToLiveTarget()); + 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' }); + } }); } @@ -1161,69 +1736,6 @@ class UIDisplayHandlerModule extends BaseModule { } } - async handleDeferredMediaBlock(sentence) { - document.dispatchEvent(new CustomEvent('story:media-block', { - detail: { - id: sentence.id, - type: sentence.kind, - ...(sentence.metadata || {}) - } - })); - - if (sentence.kind === 'image') { - await this.ensureLiveTailWindow(); - const element = this.renderImageBlock({ ...(sentence.metadata || {}), id: sentence.id, revealImmediately: false }, true); - const renderedItem = { - type: 'image', - id: sentence.id, - turnId: sentence.turnId ?? null, - blockId: sentence.blockId ?? null, - gameId: sentence.gameId ?? null, - text: '', - metadata: { ...(sentence.metadata || {}), id: sentence.id } - }; - if (element && sentence.blockId != null) { - element.dataset.storyBlockId = String(sentence.blockId); - this.markBlockRendered(sentence.blockId); - const updated = await this.recordRenderedMetrics(sentence.blockId, element, sentence.metadata?.imageLayout?.lineCount || 1); - if (updated) { - renderedItem.lineStart = updated.lineStart; - renderedItem.lineCount = updated.lineCount; - renderedItem.metadata.lineStart = updated.lineStart; - renderedItem.metadata.lineCount = updated.lineCount; - } - } - this.renderedItems.push(renderedItem); - this.historyWindowStartId = this.renderedItems.find(item => item.blockId != null)?.blockId || this.historyWindowStartId; - this.historyWindowEndId = [...this.renderedItems].reverse().find(item => item.blockId != null)?.blockId || this.historyWindowEndId; - this.setVirtualPadding(); - this.updateStoryScrollbar(); - await this.trimVisibleBlocks(); - - await this.scrollStoryToEnd(true); - this.revealImageBlock(element); - - if (sentence.onComplete) { - sentence.onComplete(); - } - - return element; - } - - if (sentence.kind === 'music') { - console.log('UIDisplayHandler: Music block started', sentence.metadata || {}); - if (sentence.blockId != null) { - this.markBlockRendered(sentence.blockId); - } - } - - if (sentence.onComplete) { - sentence.onComplete(); - } - - return null; - } - readFirstFiniteNumber(...values) { for (const value of values) { const number = Number(value); @@ -1271,10 +1783,16 @@ class UIDisplayHandlerModule extends BaseModule { }); } - renderImageBlock(metadata = {}, animate = true, placement = 'append') { + 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; @@ -1282,24 +1800,25 @@ class UIDisplayHandlerModule extends BaseModule { figure.className = [ 'story-image-block', `story-image-${metrics.size || 'landscape'}`, - metrics.floatSide === 'right' ? 'story-image-float-right' : '', - metrics.floatSide === 'left' ? 'story-image-float-left' : '', animate ? 'story-image-pending' : 'story-image-visible' ].filter(Boolean).join(' '); + figure.style.position = 'absolute'; + figure.style.top = `${(lineStart - windowOriginLine) * lineHeight}px`; + figure.style.height = `${lineCount * lineHeight}px`; figure.style.width = `${metrics.width}px`; - figure.style.height = `${metrics.height}px`; - figure.style.marginTop = `${metrics.verticalMargin || 0}px`; - figure.style.marginBottom = `${metrics.verticalMargin || 0}px`; - figure.dataset.heightLines = String(Math.max(1, Math.round(metrics.lineCount || 1))); - if ((metrics.size || metadata.size) === 'portrait') { - const gap = metrics.gap || 0; - figure.style.shapeMargin = `${gap}px`; - if (metrics.floatSide === 'right') { - figure.style.marginLeft = `${gap}px`; - } else { - figure.style.marginRight = `${gap}px`; - } + figure.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); @@ -1311,9 +1830,14 @@ class UIDisplayHandlerModule extends BaseModule { 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); + this.insertStoredElement(figure, placement, targetContainer); if (animate && metadata.revealImmediately !== false) { window.requestAnimationFrame(() => this.revealImageBlock(figure)); @@ -1343,14 +1867,15 @@ class UIDisplayHandlerModule extends BaseModule { return `/images/${filename.replace(/^images[\\/]/i, '').replace(/\\/g, '/')}`; } - calculateImageMetrics(size = 'landscape') { + calculateImageMetrics(metadataOrSize = 'landscape') { const storyElement = document.getElementById('story'); const pageWidth = storyElement?.clientWidth || 600; const lineHeight = this.measureStoryLineHeight(); - const normalizedSize = String(size || 'landscape').toLowerCase() === 'widescreen' + const metadata = typeof metadataOrSize === 'object' && metadataOrSize !== null ? metadataOrSize : { size: metadataOrSize }; + const normalizedSize = String(metadata.size || 'landscape').toLowerCase() === 'widescreen' ? 'landscape' - : String(size || 'landscape').toLowerCase(); + : String(metadata.size || 'landscape').toLowerCase(); const aspect = normalizedSize === 'portrait' ? (9 / 16) : normalizedSize === 'square' ? 1 : (16 / 9); const isPortrait = normalizedSize === 'portrait'; const imageGap = lineHeight; @@ -1377,7 +1902,7 @@ class UIDisplayHandlerModule extends BaseModule { imageLineCount, lineHeight, verticalMargin, - floatSide: 'left', + floatSide: metadata.floatSide || 'right', pageWidth }; } @@ -1401,6 +1926,8 @@ class UIDisplayHandlerModule extends BaseModule { this.historyWindowEndId = 0; this.storyTopLine = 0; this.activeCenterBlockId = null; + this.layoutFlowLine = 0; + this.layoutExclusions = []; this.setVirtualPadding(); this.setStoryOffset(0); this.updateStoryScrollbar({ latestBlockId: this.getLatestHistoryBlockId() });