Checkpoint line-grid renderer state
This commit is contained in:
+12
-7
@@ -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 */
|
||||
|
||||
@@ -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`);
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
};
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user