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);
|
return spreadIndexToPagePosition(bookPaginationState.spreadIndex);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Manual navigation must not run past the spreads that actually exist (so a stale
|
// Navigation is spread-based. The highest spread the reader may reach is the lesser of
|
||||||
// restored maxVisitedPagePosition cannot enable a flip into empty pages), but it must
|
// the spreads they have already visited and the spreads that actually exist (spreadCount
|
||||||
// still reach the last existing spread. spreadCount is the real spread count; the last
|
// is the real count). This prevents a stale restored position from flipping into empty
|
||||||
// navigable spread is spreadCount - 1. (writtenPageLimit deliberately under-counts by a
|
// pages while still allowing reaching the last existing spread.
|
||||||
// spread, so it must not be used for this.)
|
function getMaxNavigableSpread() {
|
||||||
function getNavigablePageLimit() {
|
const spreadCount = Math.max(1, Math.round(Number(bookPaginationState.spreadCount || 1)));
|
||||||
const lastSpreadIndex = Math.max(0, Math.round(Number(bookPaginationState.spreadCount || 1)) - 1);
|
const visitedSpread = pageToSpreadIndex(maxVisitedPagePosition);
|
||||||
const contentNavigable = spreadIndexToPagePosition(lastSpreadIndex);
|
return Math.max(0, Math.min(visitedSpread, spreadCount - 1));
|
||||||
return Math.min(maxVisitedPagePosition, getWritablePageLimit(), contentNavigable);
|
}
|
||||||
|
|
||||||
|
// 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') {
|
function scheduleBookRebuild(reason = 'scheduled') {
|
||||||
@@ -2045,17 +2051,17 @@ function ensureBottomNavigation() {
|
|||||||
const forwardButton = makeButton('webgl_book_nav_forward', appInitialState.t?.('webgl.forward') || 'Forward', '▶');
|
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', '⏭');
|
const endButton = makeButton('webgl_book_nav_end', appInitialState.t?.('webgl.goToEnd') || 'Go to end', '⏭');
|
||||||
|
|
||||||
startButton.addEventListener('click', () => navigateToPagePosition(0));
|
startButton.addEventListener('click', () => navigateToSpread(0));
|
||||||
backButton.addEventListener('click', () => navigateByPageDelta(-1));
|
backButton.addEventListener('click', () => navigateBySpreadDelta(-1));
|
||||||
forwardButton.addEventListener('click', () => navigateByPageDelta(1));
|
forwardButton.addEventListener('click', () => navigateBySpreadDelta(1));
|
||||||
endButton.addEventListener('click', () => navigateToPagePosition(maxVisitedPagePosition));
|
endButton.addEventListener('click', () => navigateToSpread(getMaxNavigableSpread()));
|
||||||
slider.addEventListener('input', () => {
|
slider.addEventListener('input', () => {
|
||||||
const requested = Number(slider.value);
|
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);
|
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);
|
document.body.appendChild(root);
|
||||||
bottomNavigation = {
|
bottomNavigation = {
|
||||||
@@ -2072,27 +2078,33 @@ function ensureBottomNavigation() {
|
|||||||
return bottomNavigation;
|
return bottomNavigation;
|
||||||
}
|
}
|
||||||
|
|
||||||
function navigateByPageDelta(delta) {
|
function navigateToSpread(targetSpread) {
|
||||||
const current = getCurrentPagePosition();
|
const maxSpread = getMaxNavigableSpread();
|
||||||
const next = Math.max(0, current + Math.sign(Number(delta || 0)));
|
const target = THREE.MathUtils.clamp(Math.round(Number(targetSpread || 0)), 0, maxSpread);
|
||||||
return navigateToPagePosition(next);
|
const currentSpread = Math.max(0, Math.round(Number(bookPaginationState.spreadIndex || 0)));
|
||||||
}
|
const spreadDelta = target - currentSpread;
|
||||||
|
if (spreadDelta === 0) {
|
||||||
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) {
|
|
||||||
syncBookControls();
|
syncBookControls();
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const targetSpread = pageToSpreadIndex(targetPage);
|
|
||||||
const currentSpread = bookPaginationState.spreadIndex;
|
|
||||||
const spreadDelta = targetSpread - currentSpread;
|
|
||||||
if (Math.abs(spreadDelta) === 1) {
|
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() {
|
function syncBookControls() {
|
||||||
@@ -2110,24 +2122,25 @@ function syncBookControls() {
|
|||||||
|
|
||||||
function syncBottomNavigation() {
|
function syncBottomNavigation() {
|
||||||
if (!bottomNavigation) return;
|
if (!bottomNavigation) return;
|
||||||
const currentPage = getCurrentPagePosition();
|
const currentSpread = Math.max(0, Math.round(Number(bookPaginationState.spreadIndex || 0)));
|
||||||
const writableLimit = getWritablePageLimit();
|
const spreadCount = Math.max(1, Math.round(Number(bookPaginationState.spreadCount || 1)));
|
||||||
const navigableLimit = getNavigablePageLimit();
|
const maxSpread = getMaxNavigableSpread();
|
||||||
const reservedStart = Math.max(0, writableLimit);
|
const lastSpread = Math.max(0, spreadCount - 1);
|
||||||
bottomNavigation.slider.max = String(Math.max(0, bookPageCount));
|
const denominator = Math.max(1, lastSpread);
|
||||||
bottomNavigation.slider.value = String(Math.min(currentPage, navigableLimit));
|
bottomNavigation.slider.max = String(lastSpread);
|
||||||
|
bottomNavigation.slider.value = String(Math.min(currentSpread, maxSpread));
|
||||||
bottomNavigation.minLabel.textContent = '0';
|
bottomNavigation.minLabel.textContent = '0';
|
||||||
bottomNavigation.maxLabel.textContent = String(bookPageCount);
|
bottomNavigation.maxLabel.textContent = spreadPageLabel(lastSpread);
|
||||||
bottomNavigation.pageLabel.textContent = `${appInitialState.t?.('webgl.page') || 'Page'} ${Math.min(currentPage, navigableLimit)}`;
|
bottomNavigation.pageLabel.textContent = `${appInitialState.t?.('webgl.page') || 'Page'} ${spreadPageLabel(currentSpread)}`;
|
||||||
bottomNavigation.root.style.setProperty('--book-nav-position', `${bookPageCount > 0 ? currentPage / bookPageCount : 0}`);
|
bottomNavigation.root.style.setProperty('--book-nav-position', `${currentSpread / denominator}`);
|
||||||
bottomNavigation.root.style.setProperty('--book-nav-written', `${bookPageCount > 0 ? maxVisitedPagePosition / bookPageCount : 0}`);
|
bottomNavigation.root.style.setProperty('--book-nav-written', `${maxSpread / denominator}`);
|
||||||
bottomNavigation.root.style.setProperty('--book-nav-reserve-start', `${bookPageCount > 0 ? reservedStart / bookPageCount : 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 || currentPage <= 0;
|
bottomNavigation.startButton.disabled = activeFlips.length > 0 || currentSpread <= 0;
|
||||||
bottomNavigation.backButton.disabled = activeFlips.length > 0 || currentPage <= 0;
|
bottomNavigation.backButton.disabled = activeFlips.length > 0 || currentSpread <= 0;
|
||||||
bottomNavigation.forwardButton.disabled = activeFlips.length > 0 || currentPage >= navigableLimit;
|
bottomNavigation.forwardButton.disabled = activeFlips.length > 0 || currentSpread >= maxSpread;
|
||||||
bottomNavigation.endButton.disabled = activeFlips.length > 0 || currentPage >= navigableLimit;
|
bottomNavigation.endButton.disabled = activeFlips.length > 0 || currentSpread >= maxSpread;
|
||||||
}
|
}
|
||||||
|
|
||||||
function handlePageTextureRecords(event) {
|
function handlePageTextureRecords(event) {
|
||||||
@@ -2634,7 +2647,16 @@ function syncFlipRevealShaderFromSource(sourceSide, targetMaterial = materials.f
|
|||||||
const sourceState = pageRevealState[sourceSide];
|
const sourceState = pageRevealState[sourceSide];
|
||||||
const sourceShader = getPageRevealShader(sourceSide);
|
const sourceShader = getPageRevealShader(sourceSide);
|
||||||
const targetShader = targetMaterial.userData.bookRevealShader || null;
|
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 sourceUniforms = sourceShader.uniforms;
|
||||||
const targetUniforms = targetShader.uniforms;
|
const targetUniforms = targetShader.uniforms;
|
||||||
targetUniforms.bookRevealActive.value = sourceUniforms.bookRevealActive?.value || 0;
|
targetUniforms.bookRevealActive.value = sourceUniforms.bookRevealActive?.value || 0;
|
||||||
@@ -3159,9 +3181,9 @@ function resolveFlipBackTexture(pageMeta = null, prewarmedTexture = null) {
|
|||||||
|
|
||||||
function canPageFlip(direction) {
|
function canPageFlip(direction) {
|
||||||
if (!currentProceduralBookModel) return false;
|
if (!currentProceduralBookModel) return false;
|
||||||
const currentPage = getCurrentPagePosition();
|
const currentSpread = Math.max(0, Math.round(Number(bookPaginationState.spreadIndex || 0)));
|
||||||
if (direction > 0) return currentPage < getNavigablePageLimit();
|
if (direction > 0) return currentSpread < getMaxNavigableSpread();
|
||||||
return currentPage > 0;
|
return currentSpread > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isChoiceAwaitingPlayer() {
|
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 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)],
|
['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 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 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 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)],
|
['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