Make WebGL book navigation spread-based and clear stale flip reveal mask
- Navigation now operates on spread indices, not the non-contiguous page-position scheme that mapped a forward step onto the same spread (so forward stalled and triggered a no-op multi-flip). Forward/back move one spread; start/end and the slider use spread indices. The page readout shows the odd page of the visible pair (2*spread+1) or 0 at the title spread. - Flipping forward could show the source page with its last word still masked: a stale reveal mask left on the flip surface by a previous playback flip was not cleared when the (finished) source page had no active reveal. Reset the flip surface reveal shader in that case so the full page shows during the turn. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+73
-51
@@ -1892,15 +1892,21 @@ function getCurrentPagePosition() {
|
||||
return spreadIndexToPagePosition(bookPaginationState.spreadIndex);
|
||||
}
|
||||
|
||||
// Manual navigation must not run past the spreads that actually exist (so a stale
|
||||
// restored maxVisitedPagePosition cannot enable a flip into empty pages), but it must
|
||||
// still reach the last existing spread. spreadCount is the real spread count; the last
|
||||
// navigable spread is spreadCount - 1. (writtenPageLimit deliberately under-counts by a
|
||||
// spread, so it must not be used for this.)
|
||||
function getNavigablePageLimit() {
|
||||
const lastSpreadIndex = Math.max(0, Math.round(Number(bookPaginationState.spreadCount || 1)) - 1);
|
||||
const contentNavigable = spreadIndexToPagePosition(lastSpreadIndex);
|
||||
return Math.min(maxVisitedPagePosition, getWritablePageLimit(), contentNavigable);
|
||||
// Navigation is spread-based. The highest spread the reader may reach is the lesser of
|
||||
// the spreads they have already visited and the spreads that actually exist (spreadCount
|
||||
// is the real count). This prevents a stale restored position from flipping into empty
|
||||
// pages while still allowing reaching the last existing spread.
|
||||
function getMaxNavigableSpread() {
|
||||
const spreadCount = Math.max(1, Math.round(Number(bookPaginationState.spreadCount || 1)));
|
||||
const visitedSpread = pageToSpreadIndex(maxVisitedPagePosition);
|
||||
return Math.max(0, Math.min(visitedSpread, spreadCount - 1));
|
||||
}
|
||||
|
||||
// The page-number readout shows the odd (right) page of the visible pair, or 0 at the
|
||||
// title spread.
|
||||
function spreadPageLabel(spreadIndex) {
|
||||
const spread = Math.max(0, Math.round(Number(spreadIndex || 0)));
|
||||
return spread <= 0 ? '0' : String(spread * 2 + 1);
|
||||
}
|
||||
|
||||
function scheduleBookRebuild(reason = 'scheduled') {
|
||||
@@ -2045,17 +2051,17 @@ function ensureBottomNavigation() {
|
||||
const forwardButton = makeButton('webgl_book_nav_forward', appInitialState.t?.('webgl.forward') || 'Forward', '▶');
|
||||
const endButton = makeButton('webgl_book_nav_end', appInitialState.t?.('webgl.goToEnd') || 'Go to end', '⏭');
|
||||
|
||||
startButton.addEventListener('click', () => navigateToPagePosition(0));
|
||||
backButton.addEventListener('click', () => navigateByPageDelta(-1));
|
||||
forwardButton.addEventListener('click', () => navigateByPageDelta(1));
|
||||
endButton.addEventListener('click', () => navigateToPagePosition(maxVisitedPagePosition));
|
||||
startButton.addEventListener('click', () => navigateToSpread(0));
|
||||
backButton.addEventListener('click', () => navigateBySpreadDelta(-1));
|
||||
forwardButton.addEventListener('click', () => navigateBySpreadDelta(1));
|
||||
endButton.addEventListener('click', () => navigateToSpread(getMaxNavigableSpread()));
|
||||
slider.addEventListener('input', () => {
|
||||
const requested = Number(slider.value);
|
||||
const clamped = Math.min(requested, maxVisitedPagePosition, getWritablePageLimit());
|
||||
const clamped = Math.min(requested, getMaxNavigableSpread());
|
||||
if (requested !== clamped) slider.value = String(clamped);
|
||||
pageLabel.textContent = `${appInitialState.t?.('webgl.page') || 'Page'} ${clamped}`;
|
||||
pageLabel.textContent = `${appInitialState.t?.('webgl.page') || 'Page'} ${spreadPageLabel(clamped)}`;
|
||||
});
|
||||
slider.addEventListener('change', () => navigateToPagePosition(Number(slider.value)));
|
||||
slider.addEventListener('change', () => navigateToSpread(Number(slider.value)));
|
||||
|
||||
document.body.appendChild(root);
|
||||
bottomNavigation = {
|
||||
@@ -2072,27 +2078,33 @@ function ensureBottomNavigation() {
|
||||
return bottomNavigation;
|
||||
}
|
||||
|
||||
function navigateByPageDelta(delta) {
|
||||
const current = getCurrentPagePosition();
|
||||
const next = Math.max(0, current + Math.sign(Number(delta || 0)));
|
||||
return navigateToPagePosition(next);
|
||||
}
|
||||
|
||||
function navigateToPagePosition(pagePosition) {
|
||||
const writableLimit = getWritablePageLimit();
|
||||
const targetPage = THREE.MathUtils.clamp(Math.round(Number(pagePosition || 0)), 0, Math.min(writableLimit, maxVisitedPagePosition));
|
||||
const currentPage = getCurrentPagePosition();
|
||||
if (targetPage === currentPage) {
|
||||
function navigateToSpread(targetSpread) {
|
||||
const maxSpread = getMaxNavigableSpread();
|
||||
const target = THREE.MathUtils.clamp(Math.round(Number(targetSpread || 0)), 0, maxSpread);
|
||||
const currentSpread = Math.max(0, Math.round(Number(bookPaginationState.spreadIndex || 0)));
|
||||
const spreadDelta = target - currentSpread;
|
||||
if (spreadDelta === 0) {
|
||||
syncBookControls();
|
||||
return false;
|
||||
}
|
||||
const targetSpread = pageToSpreadIndex(targetPage);
|
||||
const currentSpread = bookPaginationState.spreadIndex;
|
||||
const spreadDelta = targetSpread - currentSpread;
|
||||
if (Math.abs(spreadDelta) === 1) {
|
||||
return startPageFlip(Math.sign(spreadDelta), { targetSpread });
|
||||
return startPageFlip(Math.sign(spreadDelta), { targetSpread: target });
|
||||
}
|
||||
return startFastPageFlip(Math.sign(spreadDelta), { targetSpread, skippedSpreads: Math.abs(spreadDelta) });
|
||||
return startFastPageFlip(Math.sign(spreadDelta), { targetSpread: target, skippedSpreads: Math.abs(spreadDelta) });
|
||||
}
|
||||
|
||||
function navigateBySpreadDelta(delta) {
|
||||
const currentSpread = Math.max(0, Math.round(Number(bookPaginationState.spreadIndex || 0)));
|
||||
return navigateToSpread(currentSpread + Math.sign(Number(delta || 0)));
|
||||
}
|
||||
|
||||
// Compatibility wrappers for the page-position-based external API (save/restore, debug).
|
||||
function navigateToPagePosition(pagePosition) {
|
||||
return navigateToSpread(pageToSpreadIndex(Math.max(0, Math.round(Number(pagePosition || 0)))));
|
||||
}
|
||||
|
||||
function navigateByPageDelta(delta) {
|
||||
return navigateBySpreadDelta(delta);
|
||||
}
|
||||
|
||||
function syncBookControls() {
|
||||
@@ -2110,24 +2122,25 @@ function syncBookControls() {
|
||||
|
||||
function syncBottomNavigation() {
|
||||
if (!bottomNavigation) return;
|
||||
const currentPage = getCurrentPagePosition();
|
||||
const writableLimit = getWritablePageLimit();
|
||||
const navigableLimit = getNavigablePageLimit();
|
||||
const reservedStart = Math.max(0, writableLimit);
|
||||
bottomNavigation.slider.max = String(Math.max(0, bookPageCount));
|
||||
bottomNavigation.slider.value = String(Math.min(currentPage, navigableLimit));
|
||||
const currentSpread = Math.max(0, Math.round(Number(bookPaginationState.spreadIndex || 0)));
|
||||
const spreadCount = Math.max(1, Math.round(Number(bookPaginationState.spreadCount || 1)));
|
||||
const maxSpread = getMaxNavigableSpread();
|
||||
const lastSpread = Math.max(0, spreadCount - 1);
|
||||
const denominator = Math.max(1, lastSpread);
|
||||
bottomNavigation.slider.max = String(lastSpread);
|
||||
bottomNavigation.slider.value = String(Math.min(currentSpread, maxSpread));
|
||||
bottomNavigation.minLabel.textContent = '0';
|
||||
bottomNavigation.maxLabel.textContent = String(bookPageCount);
|
||||
bottomNavigation.pageLabel.textContent = `${appInitialState.t?.('webgl.page') || 'Page'} ${Math.min(currentPage, navigableLimit)}`;
|
||||
bottomNavigation.root.style.setProperty('--book-nav-position', `${bookPageCount > 0 ? currentPage / bookPageCount : 0}`);
|
||||
bottomNavigation.root.style.setProperty('--book-nav-written', `${bookPageCount > 0 ? maxVisitedPagePosition / bookPageCount : 0}`);
|
||||
bottomNavigation.root.style.setProperty('--book-nav-reserve-start', `${bookPageCount > 0 ? reservedStart / bookPageCount : 1}`);
|
||||
bottomNavigation.maxLabel.textContent = spreadPageLabel(lastSpread);
|
||||
bottomNavigation.pageLabel.textContent = `${appInitialState.t?.('webgl.page') || 'Page'} ${spreadPageLabel(currentSpread)}`;
|
||||
bottomNavigation.root.style.setProperty('--book-nav-position', `${currentSpread / denominator}`);
|
||||
bottomNavigation.root.style.setProperty('--book-nav-written', `${maxSpread / denominator}`);
|
||||
bottomNavigation.root.style.setProperty('--book-nav-reserve-start', '1');
|
||||
bottomNavigation.root.dataset.bookSize = String(bookPageCount);
|
||||
bottomNavigation.root.dataset.pageReserve = String(pageReserve);
|
||||
bottomNavigation.startButton.disabled = activeFlips.length > 0 || currentPage <= 0;
|
||||
bottomNavigation.backButton.disabled = activeFlips.length > 0 || currentPage <= 0;
|
||||
bottomNavigation.forwardButton.disabled = activeFlips.length > 0 || currentPage >= navigableLimit;
|
||||
bottomNavigation.endButton.disabled = activeFlips.length > 0 || currentPage >= navigableLimit;
|
||||
bottomNavigation.startButton.disabled = activeFlips.length > 0 || currentSpread <= 0;
|
||||
bottomNavigation.backButton.disabled = activeFlips.length > 0 || currentSpread <= 0;
|
||||
bottomNavigation.forwardButton.disabled = activeFlips.length > 0 || currentSpread >= maxSpread;
|
||||
bottomNavigation.endButton.disabled = activeFlips.length > 0 || currentSpread >= maxSpread;
|
||||
}
|
||||
|
||||
function handlePageTextureRecords(event) {
|
||||
@@ -2634,7 +2647,16 @@ function syncFlipRevealShaderFromSource(sourceSide, targetMaterial = materials.f
|
||||
const sourceState = pageRevealState[sourceSide];
|
||||
const sourceShader = getPageRevealShader(sourceSide);
|
||||
const targetShader = targetMaterial.userData.bookRevealShader || null;
|
||||
if (!sourceState || !sourceShader?.uniforms || !targetShader?.uniforms) return false;
|
||||
if (!targetShader?.uniforms) return false;
|
||||
if (!sourceState || !sourceShader?.uniforms) {
|
||||
// The source page has no active reveal (finished content). Clear any stale reveal
|
||||
// mask left on the flip surface by a previous playback flip, so the full page —
|
||||
// including its last word — shows during the turn.
|
||||
targetShader.uniforms.bookRevealActive.value = 0;
|
||||
targetShader.uniforms.bookRevealRegionCount.value = 0;
|
||||
if (targetShader.uniforms.bookRevealUseBaseMap) targetShader.uniforms.bookRevealUseBaseMap.value = 0;
|
||||
return true;
|
||||
}
|
||||
const sourceUniforms = sourceShader.uniforms;
|
||||
const targetUniforms = targetShader.uniforms;
|
||||
targetUniforms.bookRevealActive.value = sourceUniforms.bookRevealActive?.value || 0;
|
||||
@@ -3159,9 +3181,9 @@ function resolveFlipBackTexture(pageMeta = null, prewarmedTexture = null) {
|
||||
|
||||
function canPageFlip(direction) {
|
||||
if (!currentProceduralBookModel) return false;
|
||||
const currentPage = getCurrentPagePosition();
|
||||
if (direction > 0) return currentPage < getNavigablePageLimit();
|
||||
return currentPage > 0;
|
||||
const currentSpread = Math.max(0, Math.round(Number(bookPaginationState.spreadIndex || 0)));
|
||||
if (direction > 0) return currentSpread < getMaxNavigableSpread();
|
||||
return currentSpread > 0;
|
||||
}
|
||||
|
||||
function isChoiceAwaitingPlayer() {
|
||||
|
||||
@@ -240,7 +240,7 @@ const checks = [
|
||||
['book playback timeline flips at planned right-page fragment time instead of full TTS completion', /waitForPlannedRightReveal/.test(bookPlaybackTimelineSource) && /getRightRevealDurationMs/.test(bookPlaybackTimelineSource) && /segment\.revealStartedPromise/.test(bookPlaybackTimelineSource) && /Promise\.race\(\[[\s\S]*this\.waitForRevealCommit\(segment\)/.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 navigation buttons cap at visited page and written content limit', /maxVisitedPagePosition/.test(source) && /navigateToPagePosition\(maxVisitedPagePosition\)/.test(source) && /function getNavigablePageLimit\(\)/.test(source) && /const navigableLimit = getNavigablePageLimit\(\)/.test(source) && /Math\.min\(maxVisitedPagePosition, getWritablePageLimit\(\), contentNavigable\)/.test(source) && !/navigateToPagePosition\(bookPaginationState\.writtenPageLimit\)/.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 save restore carries visited page limit for navigation', /maxVisitedPagePosition/.test(source) && /setMaxVisitedPagePosition/.test(source) && /state\.maxVisitedPagePosition \?\? state\.pagePosition/.test(webglSceneSource)],
|
||||
['webgl page flips require resident nonblank back textures before animation starts', /prepareStaticPageForFlip\(flip, prewarm = null\)/.test(source) && /flip-back-texture-missing/.test(source) && /targetBackPageMeta\.kind !== 'blank'/.test(source) && /return false;/.test(methodBody(source, 'prepareStaticPageForFlip')) && /flipTexturePreflight:ready/.test(source) && /if \(!prepareStaticPageForFlip\(flip, options\.prewarm \|\| null\)\) \{[\s\S]*return false;[\s\S]*\}/.test(source)],
|
||||
['webgl fast page flips preflight the actual target spread', /firstFlip\.targetSpread = Number\.isFinite\(Number\(options\.targetSpread\)\)/.test(source) && /if \(!prepareStaticPageForFlip\(firstFlip, options\.prewarm \|\| null\)\) return false/.test(source)],
|
||||
|
||||
Reference in New Issue
Block a user