Refine line-based story scrolling

This commit is contained in:
2026-05-16 21:40:36 +02:00
parent b9ae7f71c5
commit e368d252ad
10 changed files with 1238 additions and 755 deletions
+12 -9
View File
@@ -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 */
+1 -1
View File
@@ -297,6 +297,6 @@
originalLog.apply(console, args);
};
</script>
<script type="module" src="/js/loader.js?v=20260515-lead-kap-verified"></script>
<script type="module" src="/js/loader.js?v=20260516-scroll-window"></script>
</body>
</html>
+20 -25
View File
@@ -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}%`;
+1 -1
View File
@@ -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;
/**
+16 -9
View File
@@ -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);
}
/**
+28 -167
View File
@@ -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<Object>} - 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' }
}));
+22 -24
View File
@@ -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) {
File diff suppressed because it is too large Load Diff