Refine line-based story scrolling
This commit is contained in:
+12
-9
@@ -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
@@ -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>
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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' }
|
||||
}));
|
||||
|
||||
@@ -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) {
|
||||
|
||||
+1039
-512
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user