Fix page label and restore nav code after bad clone-removal revert

The previous commit's clone removal corrupted the flip (shared live canvas overwritten mid-
animation -> flicker); reverting it fixed the flip but also discarded code that had ridden into
that commit and broke the page label. Recovering:

- Page label: the right-page pageNumber lookup returned null (meta not populated for the queried
  index) so every spread read 0. Now derive the printed number from the index (frontmatter
  pages 0-2 are unnumbered, so right-page index N prints as N-2), preferring the paginated
  pageNumber when present. Title still reads 0.
- Restored the manual-navigation-busy guard and the written-content navigation cap (no flipping
  forward into blank leaves before content exists; title stays on its own spread).

The flip flicker fix is the clone restoration in the prior revert; this restores the label and
navigation behavior on top of it. Suite 182. Flip-flicker and per-paragraph stutter still need
verification on a real (non-throttled) foreground tab.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-20 07:45:29 +02:00
parent 7f60ce0d63
commit 8e87f935b8
2 changed files with 39 additions and 10 deletions
+37 -9
View File
@@ -1950,14 +1950,24 @@ function getCurrentPagePosition() {
function getMaxNavigableSpread() { function getMaxNavigableSpread() {
const spreadCount = Math.max(1, Math.round(Number(bookPaginationState.spreadCount || 1))); const spreadCount = Math.max(1, Math.round(Number(bookPaginationState.spreadCount || 1)));
const visitedSpread = pageToSpreadIndex(maxVisitedPagePosition); const visitedSpread = pageToSpreadIndex(maxVisitedPagePosition);
return Math.max(0, Math.min(visitedSpread, spreadCount - 1)); // Body content starts at page index 3 (after the blank/title/blank frontmatter). Until any
// content is written the book stays on the title spread — no flipping forward into blank
// leaves. Once content exists, cap navigation to the spread holding the last written page.
const writtenPageLimit = Math.max(0, Number(bookPaginationState.writtenPageLimit || 0));
const contentSpread = writtenPageLimit >= 3 ? pageToSpreadIndex(writtenPageLimit) : 0;
return Math.max(0, Math.min(visitedSpread, contentSpread, spreadCount - 1));
} }
// The page-number readout shows the odd (right) page of the visible pair, or 0 at the // Title spread reads 0; every other spread reads the printed page number of its right page.
// title spread. // Frontmatter (blank/title/blank) occupies page indices 0-2 and is unnumbered, so the first
// body page (index 3) prints as 1 and right-page index N prints as N-2. Prefer the paginated
// page number when present, otherwise derive it from the index.
function spreadPageLabel(spreadIndex) { function spreadPageLabel(spreadIndex) {
const spread = Math.max(0, Math.round(Number(spreadIndex || 0))); const spread = Math.max(0, Math.round(Number(spreadIndex || 0)));
return spread <= 0 ? '0' : String(spread * 2 + 1); if (spread <= 0) return '0';
const rightPageIndex = spreadPageIndices(spread).right;
const pageNumber = getPaginationPageMeta(rightPageIndex)?.pageNumber;
return pageNumber != null ? String(pageNumber) : String(Math.max(0, rightPageIndex - 2));
} }
function scheduleBookRebuild(reason = 'scheduled') { function scheduleBookRebuild(reason = 'scheduled') {
@@ -2130,6 +2140,16 @@ function ensureBottomNavigation() {
} }
function navigateToSpread(targetSpread) { function navigateToSpread(targetSpread) {
if (isManualBookNavigationBusy()) {
markPageTextureTiming('navigation:blocked-busy', {
targetSpread,
activeFlips: activeFlips.length,
revealActive: hasActivePageReveal(),
playbackActive: document.documentElement.dataset.webglBookPlaybackActive === 'true'
});
syncBookControls();
return false;
}
const maxSpread = getMaxNavigableSpread(); const maxSpread = getMaxNavigableSpread();
const target = THREE.MathUtils.clamp(Math.round(Number(targetSpread || 0)), 0, maxSpread); const target = THREE.MathUtils.clamp(Math.round(Number(targetSpread || 0)), 0, maxSpread);
const currentSpread = Math.max(0, Math.round(Number(bookPaginationState.spreadIndex || 0))); const currentSpread = Math.max(0, Math.round(Number(bookPaginationState.spreadIndex || 0)));
@@ -2159,7 +2179,7 @@ function navigateByPageDelta(delta) {
} }
function syncBookControls() { function syncBookControls() {
const busy = activeFlips.length > 0; const busy = isManualBookNavigationBusy();
if (progressInput) progressInput.value = readingProgress.toFixed(3); if (progressInput) progressInput.value = readingProgress.toFixed(3);
if (progressValue) progressValue.textContent = readingProgress.toFixed(2); if (progressValue) progressValue.textContent = readingProgress.toFixed(2);
if (pageCountInput) pageCountInput.value = String(bookPageCount); if (pageCountInput) pageCountInput.value = String(bookPageCount);
@@ -2188,10 +2208,12 @@ function syncBottomNavigation() {
bottomNavigation.root.style.setProperty('--book-nav-reserve-start', '1'); bottomNavigation.root.style.setProperty('--book-nav-reserve-start', '1');
bottomNavigation.root.dataset.bookSize = String(bookPageCount); bottomNavigation.root.dataset.bookSize = String(bookPageCount);
bottomNavigation.root.dataset.pageReserve = String(pageReserve); bottomNavigation.root.dataset.pageReserve = String(pageReserve);
bottomNavigation.startButton.disabled = activeFlips.length > 0 || currentSpread <= 0; const busy = isManualBookNavigationBusy();
bottomNavigation.backButton.disabled = activeFlips.length > 0 || currentSpread <= 0; bottomNavigation.slider.disabled = busy;
bottomNavigation.forwardButton.disabled = activeFlips.length > 0 || currentSpread >= maxSpread; bottomNavigation.startButton.disabled = busy || currentSpread <= 0;
bottomNavigation.endButton.disabled = activeFlips.length > 0 || currentSpread >= maxSpread; bottomNavigation.backButton.disabled = busy || currentSpread <= 0;
bottomNavigation.forwardButton.disabled = busy || currentSpread >= maxSpread;
bottomNavigation.endButton.disabled = busy || currentSpread >= maxSpread;
} }
function handlePageTextureRecords(event) { function handlePageTextureRecords(event) {
@@ -3339,6 +3361,12 @@ function hasActivePageReveal() {
}); });
} }
function isManualBookNavigationBusy() {
return activeFlips.length > 0
|| hasActivePageReveal()
|| document.documentElement.dataset.webglBookPlaybackActive === 'true';
}
function applyResidentSpreadTextures(spreadIndex, reason = 'resident-spread', options = {}) { function applyResidentSpreadTextures(spreadIndex, reason = 'resident-spread', options = {}) {
const skipSides = Array.isArray(options.skipSides) ? options.skipSides : []; const skipSides = Array.isArray(options.skipSides) ? options.skipSides : [];
const pageIndices = spreadPageIndices(spreadIndex); const pageIndices = spreadPageIndices(spreadIndex);
+2 -1
View File
@@ -258,7 +258,8 @@ const checks = [
['book playback timeline flips at planned right-page fragment time without a stray commit timeout', /waitForPlannedRightReveal/.test(bookPlaybackTimelineSource) && /getRightRevealDurationMs/.test(bookPlaybackTimelineSource) && /segment\.revealStartedPromise/.test(bookPlaybackTimelineSource) && /const timer = setTimeout\(\(\) => finish\(true\), remaining\)/.test(bookPlaybackTimelineSource) && !/waitForRevealCommit/.test(bookPlaybackTimelineSource)], ['book playback timeline flips at planned right-page fragment time without a stray commit timeout', /waitForPlannedRightReveal/.test(bookPlaybackTimelineSource) && /getRightRevealDurationMs/.test(bookPlaybackTimelineSource) && /segment\.revealStartedPromise/.test(bookPlaybackTimelineSource) && /const timer = setTimeout\(\(\) => finish\(true\), remaining\)/.test(bookPlaybackTimelineSource) && !/waitForRevealCommit/.test(bookPlaybackTimelineSource)],
['book playback timeline exposes reveal lifecycle benchmark entries', /benchmarkEntries/.test(bookPlaybackTimelineSource) && /markBenchmark\('reveal-start'/.test(bookPlaybackTimelineSource) && /markBenchmark\('reveal-committed'/.test(bookPlaybackTimelineSource) && /webglBookBenchmark/.test(bookPlaybackTimelineSource)], ['book playback timeline exposes reveal lifecycle benchmark entries', /benchmarkEntries/.test(bookPlaybackTimelineSource) && /markBenchmark\('reveal-start'/.test(bookPlaybackTimelineSource) && /markBenchmark\('reveal-committed'/.test(bookPlaybackTimelineSource) && /webglBookBenchmark/.test(bookPlaybackTimelineSource)],
['webgl scene records reveal start and slow-frame benchmark diagnostics', /revealState:created/.test(source) && /revealStart:applied/.test(source) && /slowFrameLog/.test(source) && /getBenchmarkState/.test(source) && /webglSlowFrames/.test(source)], ['webgl scene records reveal start and slow-frame benchmark diagnostics', /revealState:created/.test(source) && /revealStart:applied/.test(source) && /slowFrameLog/.test(source) && /getBenchmarkState/.test(source) && /webglSlowFrames/.test(source)],
['webgl navigation is spread-based and caps at visited/written spread', /function navigateToSpread\(/.test(source) && /function navigateBySpreadDelta\(/.test(source) && /function getMaxNavigableSpread\(\)/.test(source) && /Math\.min\(visitedSpread, spreadCount - 1\)/.test(source) && /navigateBySpreadDelta\(1\)/.test(source) && /spread <= 0 \? '0' : String\(spread \* 2 \+ 1\)/.test(source) && /currentSpread < getMaxNavigableSpread\(\)/.test(source)], ['webgl navigation is spread-based and caps at the written-content spread (title-only before content)', /function navigateToSpread\(/.test(source) && /function navigateBySpreadDelta\(/.test(source) && /function getMaxNavigableSpread\(\)/.test(source) && /Math\.min\(visitedSpread, contentSpread, spreadCount - 1\)/.test(source) && /writtenPageLimit >= 3 \? pageToSpreadIndex\(writtenPageLimit\) : 0/.test(source) && /navigateBySpreadDelta\(1\)/.test(source) && /currentSpread < getMaxNavigableSpread\(\)/.test(source)],
['webgl spread label reads 0 at the title and the right page number elsewhere', /function spreadPageLabel\(spreadIndex\)/.test(source) && /if \(spread <= 0\) return '0'/.test(source) && /spreadPageIndices\(spread\)\.right/.test(source) && /rightPageIndex - 2/.test(source)],
['webgl manual page navigation is blocked while reveal playback or flips are active', /function isManualBookNavigationBusy\(\) \{[\s\S]*activeFlips\.length > 0[\s\S]*hasActivePageReveal\(\)[\s\S]*webglBookPlaybackActive/.test(source) && /function navigateToSpread\(targetSpread\) \{[\s\S]*if \(isManualBookNavigationBusy\(\)\) \{[\s\S]*navigation:blocked-busy/.test(source) && /bottomNavigation\.slider\.disabled = busy/.test(source)], ['webgl manual page navigation is blocked while reveal playback or flips are active', /function isManualBookNavigationBusy\(\) \{[\s\S]*activeFlips\.length > 0[\s\S]*hasActivePageReveal\(\)[\s\S]*webglBookPlaybackActive/.test(source) && /function navigateToSpread\(targetSpread\) \{[\s\S]*if \(isManualBookNavigationBusy\(\)\) \{[\s\S]*navigation:blocked-busy/.test(source) && /bottomNavigation\.slider\.disabled = busy/.test(source)],
['webgl fast-forward always reaches scene reveal state even without renderer-side active animations', /fastForwardAnimations\(\) \{[\s\S]*webgl-book:page-reveal-fast-forward[\s\S]*broad: !changed/.test(textureRendererSource) && /function fastForwardPageReveals\(blockIds = \[\]\) \{[\s\S]*const matches = ids\.size === 0/.test(source)], ['webgl fast-forward always reaches scene reveal state even without renderer-side active animations', /fastForwardAnimations\(\) \{[\s\S]*webgl-book:page-reveal-fast-forward[\s\S]*broad: !changed/.test(textureRendererSource) && /function fastForwardPageReveals\(blockIds = \[\]\) \{[\s\S]*const matches = ids\.size === 0/.test(source)],
['webgl save restore carries visited page limit for navigation', /maxVisitedPagePosition/.test(source) && /setMaxVisitedPagePosition/.test(source) && /state\.maxVisitedPagePosition \?\? state\.pagePosition/.test(webglSceneSource)], ['webgl save restore carries visited page limit for navigation', /maxVisitedPagePosition/.test(source) && /setMaxVisitedPagePosition/.test(source) && /state\.maxVisitedPagePosition \?\? state\.pagePosition/.test(webglSceneSource)],