From b9ae7f71c507828282160f8a940f1f14ebc5dd91 Mon Sep 17 00:00:00 2001 From: Georg Tomitsch Date: Sat, 16 May 2026 15:57:03 +0200 Subject: [PATCH] Checkpoint line-grid renderer state --- public/css/style.css | 19 ++- public/js/layout-renderer-module.js | 12 +- public/js/sentence-queue-module.js | 11 +- public/js/story-history-module.js | 2 - public/js/ui-display-handler-module.js | 195 ++++--------------------- 5 files changed, 49 insertions(+), 190 deletions(-) diff --git a/public/css/style.css b/public/css/style.css index 7da10ad..5cca629 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -116,6 +116,9 @@ body.switched { --book-width: 2727px; --book-height: 1691px; --book-scale: 1; + --page-line-count: 25; + --story-line-height: calc((var(--book-height) * 0.85) / var(--page-line-count)); + --story-font-size: calc(var(--story-line-height) / 1.45); font-size: calc(var(--book-height)/(34 * 1.5)); --img-aspect-ratio: 1.613; --aspect-ratio: min(var(--viewport-aspect-ratio), var(--img-aspect-ratio)); @@ -403,7 +406,6 @@ ol.choice { padding: 0 3rem 1rem 1rem; /* border: 1px dotted rgba(200,200,200,1); */ overflow: visible; - overflow-y: auto; opacity: 0.95; mix-blend-mode: darken; } @@ -415,7 +417,8 @@ ol.choice { text-align: justify; text-justify: inter-word; margin-bottom: 0; - line-height: 1.5; + font-size: var(--story-font-size); + line-height: var(--story-line-height); will-change: transform; transform: translateY(0); } @@ -485,6 +488,7 @@ ol.choice { #page_left { left: 11.5%; + overflow-y: auto; } #page_right { @@ -938,17 +942,18 @@ html[data-process-state="playing-ready"] * { #story p { text-align: justify; text-justify: inter-word; - margin-bottom: 1.2em; - line-height: 1.45; + font-size: var(--story-font-size); + margin: 0; + line-height: var(--story-line-height); } #story p.story-chapter-heading { position: relative; height: auto; text-align: center; - font-size: 1.2rem; + font-size: var(--story-font-size); font-style: italic; - line-height: 1.45; + line-height: var(--story-line-height); } #story p.story-section-heading { @@ -956,7 +961,7 @@ html[data-process-state="playing-ready"] * { height: auto; text-align: center; font-style: normal; - line-height: 1.45; + line-height: var(--story-line-height); } /* Typography for word elements in rendered paragraphs */ diff --git a/public/js/layout-renderer-module.js b/public/js/layout-renderer-module.js index 9190e56..1b9c64a 100644 --- a/public/js/layout-renderer-module.js +++ b/public/js/layout-renderer-module.js @@ -95,22 +95,30 @@ class LayoutRendererModule extends BaseModule { return null; } - const lineHeight = lineHeightPx || parseFloat(window.getComputedStyle(paragraph).lineHeight) || 24; + 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 maxLineWidth = Array.isArray(measures) && measures.length > 0 ? Math.max(...measures) : storyElement.clientWidth; // Height should include all lines (breaks.length represents number of lines) - const numLines = breaks.length - 1; + const numLines = Math.max(1, breaks.length - 1); paragraph.style.height = `${lineHeight * numLines}px`; + paragraph.dataset.heightLines = String(numLines + marginLines); console.log(`LayoutRenderer: Rendering paragraph ${id} - ${breaks.length} breaks (${numLines} lines), lineHeight: ${lineHeight}px, total height: ${lineHeight * numLines}px`); diff --git a/public/js/sentence-queue-module.js b/public/js/sentence-queue-module.js index 3da0915..1614e4d 100644 --- a/public/js/sentence-queue-module.js +++ b/public/js/sentence-queue-module.js @@ -751,15 +751,8 @@ class SentenceQueueModule extends BaseModule { await document.fonts.ready; } - const probe = document.createElement('p'); - probe.style.visibility = 'hidden'; - probe.style.position = 'absolute'; - probe.style.left = '-8000px'; - probe.style.top = '-8000px'; - storyElement.appendChild(probe); - const computedStyle = window.getComputedStyle(probe); + const computedStyle = window.getComputedStyle(storyElement); const lineHeight = parseFloat(computedStyle.lineHeight) || 24; - probe.remove(); const pageWidth = storyElement.clientWidth; const requestedSize = String(metadata.size || 'landscape').toLowerCase(); @@ -782,7 +775,7 @@ class SentenceQueueModule extends BaseModule { if (isPortrait) { this.activeImageWrap = { - lines: lineCount + 1, + lines: lineCount, width: width + imageGap, imageWidth: width, gap: imageGap, diff --git a/public/js/story-history-module.js b/public/js/story-history-module.js index 0a1c7b2..0c46a36 100644 --- a/public/js/story-history-module.js +++ b/public/js/story-history-module.js @@ -156,8 +156,6 @@ class StoryHistoryModule extends BaseModule { ...record, lineStart, lineCount, - heightPx: Math.max(0, Number(metrics.heightPx || record.heightPx || 0)), - measuredLineHeightPx: Math.max(0, Number(metrics.lineHeightPx || record.measuredLineHeightPx || 0)), metricsUpdatedAt: Date.now() }; diff --git a/public/js/ui-display-handler-module.js b/public/js/ui-display-handler-module.js index 5317faa..740aa6f 100644 --- a/public/js/ui-display-handler-module.js +++ b/public/js/ui-display-handler-module.js @@ -4,6 +4,8 @@ */ import { BaseModule } from './base-module.js'; +const PAGE_LINE_COUNT = 25; + class UIDisplayHandlerModule extends BaseModule { constructor() { super('ui-display-handler', 'UI Display Handler'); @@ -22,11 +24,11 @@ class UIDisplayHandlerModule extends BaseModule { this.lastStoryMetrics = null; this.visibleBlockLimit = 41; this.historyBufferBlocks = 20; + this.pageLineCount = PAGE_LINE_COUNT; this.historyWindowStartId = 1; this.historyWindowEndId = 0; this.loadingHistoryPage = false; this.draggingStoryScrollbar = false; - this.pendingHistoryWindowRequest = null; this.historyWheelAccumulator = 0; this.storyTopLine = 0; this.storyOffsetPx = 0; @@ -57,11 +59,6 @@ class UIDisplayHandlerModule extends BaseModule { 'renderStoredItem', 'renderHistoryWindow', 'renderHistoryWindowForTurn', - 'loadPreviousHistoryPage', - 'loadNextHistoryPage', - 'loadHistoryWindowAt', - 'shiftHistoryWindow', - 'loadAdjacentHistoryBlock', 'insertStoredElement', 'trimVirtualWindow', 'handleHistoryWheel', @@ -87,7 +84,6 @@ class UIDisplayHandlerModule extends BaseModule { 'readFirstFiniteNumber', 'waitForSkippablePause', 'scrollStoryToEnd', - 'animateStoryOffset', 'scrollToTurn', 'handleStoryScroll', 'rerenderStory', @@ -683,13 +679,7 @@ class UIDisplayHandlerModule extends BaseModule { } scrollStoryToEnd(smooth = true) { - this.measureStoryLineHeight(); - const targetTopLine = this.getMaxStoryTopLine(); - const storyHeight = this.paragraphContainer?.offsetHeight || 0; - const viewportHeight = this.pageRight?.clientHeight || 0; - const targetOffset = Math.min(0, viewportHeight - storyHeight); - this.storyTopLine = targetTopLine; - return this.animateStoryOffset(targetOffset, smooth ? 720 : 0, { mode: 'auto-bottom' }); + return this.setStoryTopLine(this.getMaxStoryTopLine(), smooth, { mode: 'auto-bottom', ensure: false }); } async restoreFromHistory(saveRecord = {}) { @@ -825,32 +815,6 @@ class UIDisplayHandlerModule extends BaseModule { return result.targetBlockId; } - async loadPreviousHistoryPage() { - if (!this.storyHistory || !this.paragraphContainer || this.loadingHistoryPage) return; - const beforeBlockId = this.historyWindowStartId || Number(this.paragraphContainer.querySelector('[data-story-block-id]')?.dataset?.storyBlockId || 0); - if (!beforeBlockId || beforeBlockId <= 1) return; - await this.loadAdjacentHistoryBlock(-1); - } - - async loadNextHistoryPage() { - if (!this.storyHistory || !this.paragraphContainer || this.loadingHistoryPage || !this.hasNewerHistory()) return; - await this.loadAdjacentHistoryBlock(1); - } - - async shiftHistoryWindow(deltaBlocks, scrollTarget = 'top') { - if (!this.storyHistory) return; - if (Math.abs(Number(deltaBlocks) || 0) === 1 && scrollTarget === 'preserve') { - await this.loadAdjacentHistoryBlock(deltaBlocks); - return; - } - const latest = this.getLatestHistoryBlockId(); - const visible = Math.min(this.visibleBlockLimit, latest || this.visibleBlockLimit); - const maxStart = Math.max(1, latest - visible + 1); - const start = Math.max(1, Math.min(maxStart, (this.historyWindowStartId || 1) + deltaBlocks)); - if (start === this.historyWindowStartId) return; - await this.loadHistoryWindowAt(start, scrollTarget); - } - handleHistoryWheel(event) { if (!event.target?.closest?.('#page_right') || !this.pageRight) return; event.preventDefault(); @@ -860,48 +824,6 @@ class UIDisplayHandlerModule extends BaseModule { this.scrollStoryByLines(lineDelta, true); } - async loadAdjacentHistoryBlock(direction) { - if (!this.storyHistory || !this.paragraphContainer || this.loadingHistoryPage) { - if (direction) { - const startBlockId = Math.max(1, (this.historyWindowStartId || 1) + Math.sign(direction)); - this.pendingHistoryWindowRequest = { startBlockId, scrollTarget: 'preserve' }; - } - return; - } - - const step = Math.sign(Number(direction) || 0); - if (!step) return; - const latest = this.getLatestHistoryBlockId(); - const targetBlockId = step < 0 - ? (this.historyWindowStartId || 1) - 1 - : (this.historyWindowEndId || 0) + 1; - if (targetBlockId < 1 || targetBlockId > latest) return; - - this.loadingHistoryPage = true; - try { - const blocks = await this.storyHistory.getBlocksRange(this.storyHistory.currentGameId, targetBlockId, targetBlockId); - const block = blocks[0]; - if (!block) return; - - await this.renderStoredItem(block, step < 0 ? 'prepend' : 'append'); - this.historyWindowStartId = Math.min(this.historyWindowStartId || targetBlockId, targetBlockId); - this.historyWindowEndId = Math.max(this.historyWindowEndId || targetBlockId, targetBlockId); - - await new Promise(resolve => window.requestAnimationFrame(resolve)); - - await this.trimVirtualWindow(step); - this.setVirtualPadding(); - this.updateStoryScrollbar(); - } finally { - this.loadingHistoryPage = false; - const pending = this.pendingHistoryWindowRequest; - this.pendingHistoryWindowRequest = null; - if (pending && Number(pending.startBlockId) !== this.historyWindowStartId) { - await this.loadAdjacentHistoryBlock(Number(pending.startBlockId) < this.historyWindowStartId ? -1 : 1); - } - } - } - async trimVirtualWindow(direction = 1) { if (!this.paragraphContainer) return; const excess = this.renderedItems.length - this.visibleBlockLimit; @@ -928,33 +850,6 @@ class UIDisplayHandlerModule extends BaseModule { this.setVirtualPadding(); } - async loadHistoryWindowAt(startBlockId, scrollTarget = 'top') { - if (!this.storyHistory) return; - if (this.loadingHistoryPage) { - this.pendingHistoryWindowRequest = { startBlockId, scrollTarget }; - return; - } - const latest = this.getLatestHistoryBlockId(); - const maxStart = Math.max(1, latest - this.visibleBlockLimit + 1); - const start = Math.max(1, Math.min(maxStart, Number(startBlockId || 1))); - const end = Math.min(latest, start + this.visibleBlockLimit - 1); - - 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; - const pending = this.pendingHistoryWindowRequest; - this.pendingHistoryWindowRequest = null; - if (pending && Number(pending.startBlockId) !== this.historyWindowStartId) { - await this.loadHistoryWindowAt(pending.startBlockId, pending.scrollTarget); - } - } - } - markBlockRendered(blockId) { if (this.storyHistory && typeof this.storyHistory.markRendered === 'function') { const latestRenderedBlockId = this.storyHistory.markRendered(blockId); @@ -1032,26 +927,26 @@ class UIDisplayHandlerModule extends BaseModule { } measureStoryLineHeight() { - const element = this.paragraphContainer || this.container || this.pageRight; - const computed = element ? window.getComputedStyle(element) : null; - const parsed = parseFloat(computed?.lineHeight || ''); - const fontSize = parseFloat(computed?.fontSize || ''); - this.lineHeightPx = Number.isFinite(parsed) - ? parsed - : (Number.isFinite(fontSize) ? fontSize * 1.45 : 24); - this.viewportLineCount = Math.max(1, Math.floor((this.pageRight?.clientHeight || this.lineHeightPx) / this.lineHeightPx)); + const pageHeight = this.pageRight?.clientHeight || 0; + const lineHeight = pageHeight > 0 + ? pageHeight / this.pageLineCount + : this.lineHeightPx || 24; + this.lineHeightPx = lineHeight; + this.viewportLineCount = this.pageLineCount; + document.documentElement.style.setProperty('--page-line-count', String(this.pageLineCount)); + document.documentElement.style.setProperty('--story-line-height', `${lineHeight}px`); + document.documentElement.style.setProperty('--story-font-size', `${lineHeight / 1.45}px`); return this.lineHeightPx; } measureBlockLines(element, fallbackLineCount = 1) { - if (!element) return { lineCount: Math.max(1, fallbackLineCount), heightPx: 0, lineHeightPx: this.measureStoryLineHeight() }; const lineHeight = this.measureStoryLineHeight(); - const style = window.getComputedStyle(element); - const marginTop = parseFloat(style.marginTop || '0') || 0; - const marginBottom = parseFloat(style.marginBottom || '0') || 0; - const heightPx = Math.max(0, element.offsetHeight + marginTop + marginBottom); - const lineCount = Math.max(1, Math.ceil((heightPx || lineHeight * fallbackLineCount) / Math.max(1, lineHeight))); - return { lineCount, heightPx, lineHeightPx: lineHeight }; + const declaredLines = Number(element?.dataset?.heightLines); + if (Number.isFinite(declaredLines) && declaredLines > 0) { + const lineCount = Math.max(1, Math.round(declaredLines)); + return { lineCount, heightPx: lineCount * lineHeight, lineHeightPx: lineHeight }; + } + throw new Error(`UIDisplayHandler: Rendered story block ${element?.id || '(unknown)'} has no data-height-lines declaration.`); } async recordRenderedMetrics(blockId, element, fallbackLineCount = 1) { @@ -1215,39 +1110,6 @@ class UIDisplayHandlerModule extends BaseModule { this.updateStoryScrollbar(); } - animateStoryOffset(targetOffset, duration = 720, options = {}) { - const target = Number(targetOffset || 0); - const start = Number(this.storyOffsetPx || 0); - const delta = target - start; - const animationId = ++this.storyScrollAnimationId; - - if (!duration || Math.abs(delta) < 1) { - this.setStoryOffset(target); - this.updateStoryScrollbar({ mode: options.mode }); - return Promise.resolve(); - } - - return new Promise(resolve => { - const startedAt = performance.now(); - const ease = (t) => t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2; - const step = (now) => { - if (animationId !== this.storyScrollAnimationId) { - resolve(); - return; - } - const progress = Math.min(1, (now - startedAt) / duration); - this.setStoryOffset(start + (delta * ease(progress))); - if (progress < 1) { - requestAnimationFrame(step); - return; - } - this.setStoryOffset(target); - resolve(); - }; - requestAnimationFrame(step); - }); - } - scrollToTurn(turnId) { if (!this.pageRight || turnId == null) return; const escapedTurnId = CSS.escape(String(turnId)); @@ -1257,8 +1119,6 @@ class UIDisplayHandlerModule extends BaseModule { const targetLine = Number(target.dataset.lineStart); if (Number.isFinite(targetLine)) { this.setStoryTopLine(targetLine, true, { mode: 'jump-to-turn' }); - } else { - this.setStoryTopLine((target.offsetTop || 0) / Math.max(1, this.measureStoryLineHeight()), true, { mode: 'jump-to-turn' }); } return true; }; @@ -1282,9 +1142,10 @@ class UIDisplayHandlerModule extends BaseModule { blocks.forEach((block) => { const lineStart = Number(block.dataset.lineStart); const lineCount = Number(block.dataset.lineCount); - const blockMiddle = Number.isFinite(lineStart) && Number.isFinite(lineCount) - ? ((lineStart + (lineCount / 2)) * this.lineHeightPx) - : block.offsetTop + (block.offsetHeight / 2); + if (!Number.isFinite(lineStart) || !Number.isFinite(lineCount)) { + return; + } + const blockMiddle = (lineStart + (lineCount / 2)) * this.lineHeightPx; const distance = Math.abs(blockMiddle - viewportMiddle); if (distance < bestDistance) { bestDistance = distance; @@ -1429,6 +1290,7 @@ class UIDisplayHandlerModule extends BaseModule { 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`; @@ -1484,14 +1346,7 @@ class UIDisplayHandlerModule extends BaseModule { calculateImageMetrics(size = 'landscape') { const storyElement = document.getElementById('story'); const pageWidth = storyElement?.clientWidth || 600; - const probe = document.createElement('p'); - probe.style.visibility = 'hidden'; - probe.style.position = 'absolute'; - probe.style.left = '-8000px'; - probe.style.top = '-8000px'; - (storyElement || document.body).appendChild(probe); - const lineHeight = parseFloat(window.getComputedStyle(probe).lineHeight) || 24; - probe.remove(); + const lineHeight = this.measureStoryLineHeight(); const normalizedSize = String(size || 'landscape').toLowerCase() === 'widescreen' ? 'landscape'