Implement WebGL page reserve navigation

This commit is contained in:
2026-06-08 10:25:54 +02:00
parent 3e28d7db23
commit efd1e6cfff
13 changed files with 571 additions and 52 deletions
+291 -24
View File
@@ -187,7 +187,8 @@ const book = new THREE.Group();
scene.add(book);
const initialReadingProgress = THREE.MathUtils.clamp(Number.parseFloat(urlParams.get('progress') ?? appInitialState.progress ?? '0'), 0, 1);
let readingProgress = Number.isFinite(initialReadingProgress) ? initialReadingProgress : 0;
let bookPageCount = snapProceduralPageCount(urlParams.get('pages') ?? appInitialState.pageCount ?? '240');
let bookPageCount = snapProceduralPageCount(urlParams.get('pages') ?? appInitialState.pageCount ?? '300');
let pageReserve = clampPageReserve(appInitialState.pageReserve ?? 50, bookPageCount);
let currentProceduralBookModel = null;
const progressInput = document.getElementById('progress_control');
const progressValue = document.getElementById('progress_value');
@@ -197,6 +198,12 @@ const fastBackwardButton = document.getElementById('fast_backward');
const backwardButton = document.getElementById('flip_backward');
const forwardButton = document.getElementById('flip_forward');
const fastForwardButton = document.getElementById('fast_forward');
let bottomNavigation = null;
let bookPaginationState = {
spreadIndex: 0,
spreadCount: 1,
writtenPageLimit: 0
};
const normalFlipDuration = 900;
const fastFlipDuration = 520;
const fastFlipCount = 10;
@@ -399,6 +406,14 @@ const materials = {
envMapIntensity: 0
})
};
materials.flipPageBackSurface = materials.flipPageSurface.clone();
materials.flipPageBackSurface.map = getBlankPageTexture();
materials.flipPageBackSurface.side = THREE.DoubleSide;
materials.flipPageEdge = materials.pageSurface.clone();
materials.flipPageEdge.map = paperTextures.edge;
materials.flipPageEdge.normalMap = paperTextures.normal;
materials.flipPageEdge.roughnessMap = paperTextures.roughness;
materials.flipPageEdge.side = THREE.DoubleSide;
materials.leftPage.userData.bookPageReveal = {
side: 'left'
};
@@ -421,6 +436,8 @@ configureBookShadowReceiver(materials.pageBlock, 0.18);
configureBookShadowReceiver(materials.pageEdge, 0.16);
configureBookShadowReceiver(materials.pageSurface, 0.11);
configureBookShadowReceiver(materials.flipPageSurface, 0.11);
configureBookShadowReceiver(materials.flipPageBackSurface, 0.11);
configureBookShadowReceiver(materials.flipPageEdge, 0.09);
configureBookShadowReceiver(materials.leftPage, 0.08);
configureBookShadowReceiver(materials.rightPage, 0.08);
configureBookShadowReceiver(materials.spineCloth, 0.48);
@@ -498,6 +515,23 @@ window.BookLabDebug = {
setBookPageCount(value);
return bookPageCount;
},
setPageReserve(value) {
setPageReserve(value);
return pageReserve;
},
getBookState() {
return {
pageCount: bookPageCount,
pageReserve,
progress: readingProgress,
pagePosition: getCurrentPagePosition(),
spreadIndex: bookPaginationState.spreadIndex,
writtenPageLimit: bookPaginationState.writtenPageLimit
};
},
navigateToPagePosition(value) {
return navigateToPagePosition(value);
},
redrawPageTextures() {
window.BookTextureRenderer?.publishSpread?.();
return true;
@@ -533,6 +567,31 @@ document.addEventListener('webgl-book:page-reveal-fast-forward', (event) => {
document.addEventListener('webgl-book:reveal-committed', (event) => {
handleRevealCommittedForPageFlip(event.detail || {});
});
document.addEventListener('book-pagination:spread-updated', (event) => {
const detail = event.detail || {};
const previousPageCount = bookPageCount;
bookPaginationState = {
spreadIndex: Math.max(0, Number(detail.spreadIndex || 0)),
spreadCount: Math.max(1, Number(detail.spreadCount || 1)),
writtenPageLimit: Math.max(0, Number(detail.writtenPageLimit || 0))
};
growBookIfWritableLimitReached();
if (bookPageCount !== previousPageCount) {
buildBook();
notifyBookPageCountChanged();
window.WebGLBookPreferenceBridge?.updatePageCount?.(bookPageCount);
}
syncBookControls();
});
document.addEventListener('webgl-book:page-reserve-directive', (event) => {
const detail = event.detail || {};
const value = Number(detail.value);
if (!Number.isFinite(value)) return;
const nextReserve = detail.unit === 'percent'
? Math.round(bookPageCount * (value / 100))
: Math.round(value);
setPageReserve(nextReserve);
});
document.addEventListener('ui:command', (event) => {
if (event.detail?.type === 'continue' && pendingRightPageFlip) {
pendingRightPageFlip = false;
@@ -1640,16 +1699,68 @@ function setReadingProgress(value) {
window.WebGLBookPreferenceBridge?.updateProgress?.(readingProgress);
}
function clampPageReserve(value, pageCount = bookPageCount) {
const parsed = Math.round(Number(value));
if (!Number.isFinite(parsed)) return 50;
return THREE.MathUtils.clamp(parsed, 0, Math.max(0, Math.floor(Number(pageCount) || 0)));
}
function pageToSpreadIndex(pagePosition) {
const page = Math.max(0, Math.round(Number(pagePosition || 0)));
return page <= 0 ? 0 : Math.ceil(page / 2);
}
function spreadIndexToPagePosition(spreadIndex) {
const spread = Math.max(0, Math.round(Number(spreadIndex || 0)));
return spread <= 0 ? 0 : Math.max(1, spread * 2 - 1);
}
function getWritablePageLimit() {
return Math.max(0, bookPageCount - pageReserve);
}
function getCurrentPagePosition() {
return spreadIndexToPagePosition(bookPaginationState.spreadIndex);
}
function syncReadingProgressToCurrentPage() {
const nextProgress = THREE.MathUtils.clamp(getCurrentPagePosition() / Math.max(1, bookPageCount), 0, 1);
if (Math.abs(nextProgress - readingProgress) < 0.0001) return;
readingProgress = nextProgress;
buildBook();
notifyBookPageCountChanged();
window.WebGLBookPreferenceBridge?.updateProgress?.(readingProgress);
}
function growBookIfWritableLimitReached() {
const writtenLimit = Math.max(0, bookPaginationState.writtenPageLimit || 0);
while (writtenLimit >= getWritablePageLimit() && bookPageCount < PROCEDURAL_BOOK.PAGE_COUNT_MAX) {
bookPageCount = snapProceduralPageCount(bookPageCount + PROCEDURAL_BOOK.PAGE_COUNT_STEP);
}
}
function setBookPageCount(value) {
const nextPageCount = snapProceduralPageCount(value);
if (!Number.isFinite(nextPageCount)) return;
bookPageCount = nextPageCount;
bookPageCount = Math.max(nextPageCount, bookPageCount);
pageReserve = clampPageReserve(pageReserve, bookPageCount);
growBookIfWritableLimitReached();
buildBook();
notifyBookPageCountChanged();
syncBookControls();
window.WebGLBookPreferenceBridge?.updatePageCount?.(bookPageCount);
}
function setPageReserve(value) {
pageReserve = clampPageReserve(value, bookPageCount);
growBookIfWritableLimitReached();
buildBook();
notifyBookPageCountChanged();
syncBookControls();
window.WebGLBookPreferenceBridge?.updatePageReserve?.(pageReserve);
window.WebGLBookPreferenceBridge?.updatePageCount?.(bookPageCount);
}
function notifyBookPageCountChanged() {
document.dispatchEvent(new CustomEvent('webgl-book:page-count-changed', {
detail: {
@@ -1664,15 +1775,18 @@ function stepReadingProgress(pageDelta) {
}
function installBookControls() {
if (!progressInput || !pageCountInput) return;
progressInput.value = readingProgress.toFixed(3);
pageCountInput.min = String(PROCEDURAL_BOOK.PAGE_COUNT_MIN);
pageCountInput.max = String(PROCEDURAL_BOOK.PAGE_COUNT_MAX);
pageCountInput.step = String(PROCEDURAL_BOOK.PAGE_COUNT_STEP);
pageCountInput.value = String(bookPageCount);
progressInput.addEventListener('input', () => setReadingProgress(progressInput.value));
pageCountInput.addEventListener('input', () => setBookPageCount(pageCountInput.value));
ensureBottomNavigation();
if (progressInput) {
progressInput.value = readingProgress.toFixed(3);
progressInput.addEventListener('input', () => setReadingProgress(progressInput.value));
}
if (pageCountInput) {
pageCountInput.min = String(PROCEDURAL_BOOK.PAGE_COUNT_MIN);
pageCountInput.max = String(PROCEDURAL_BOOK.PAGE_COUNT_MAX);
pageCountInput.step = String(PROCEDURAL_BOOK.PAGE_COUNT_STEP);
pageCountInput.value = String(bookPageCount);
pageCountInput.addEventListener('input', () => setBookPageCount(pageCountInput.value));
}
backwardButton?.addEventListener('click', () => startPageFlip(-1));
forwardButton?.addEventListener('click', () => startPageFlip(1));
fastBackwardButton?.addEventListener('click', () => startFastPageFlip(-1));
@@ -1680,6 +1794,93 @@ function installBookControls() {
syncBookControls();
}
function ensureBottomNavigation() {
if (bottomNavigation) return bottomNavigation;
const root = document.createElement('nav');
root.id = 'webgl_book_navigation';
root.setAttribute('aria-label', appInitialState.t?.('webgl.bookControls') || 'Book controls');
const makeButton = (id, label, icon) => {
const button = document.createElement('button');
button.id = id;
button.type = 'button';
button.className = 'webgl-book-nav-button';
button.setAttribute('aria-label', label);
button.title = label;
button.textContent = icon;
root.appendChild(button);
return button;
};
const startButton = makeButton('webgl_book_nav_start', appInitialState.t?.('webgl.returnToBeginning') || 'Return to beginning', '⏮');
const backButton = makeButton('webgl_book_nav_back', appInitialState.t?.('webgl.backward') || 'Backward', '◀');
const sliderWrap = document.createElement('div');
sliderWrap.className = 'webgl-book-nav-slider-wrap';
const pageLabel = document.createElement('output');
pageLabel.id = 'webgl_book_nav_page_label';
pageLabel.className = 'webgl-book-nav-page-label';
pageLabel.textContent = '0';
const slider = document.createElement('input');
slider.id = 'webgl_book_nav_position';
slider.type = 'range';
slider.min = '0';
slider.step = '1';
slider.value = '0';
sliderWrap.appendChild(slider);
sliderWrap.appendChild(pageLabel);
root.appendChild(sliderWrap);
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(bookPaginationState.writtenPageLimit));
slider.addEventListener('input', () => {
const requested = Number(slider.value);
const clamped = Math.min(requested, Math.max(0, bookPaginationState.writtenPageLimit || 0), getWritablePageLimit());
if (requested !== clamped) slider.value = String(clamped);
pageLabel.textContent = `${appInitialState.t?.('webgl.page') || 'Page'} ${clamped}`;
});
slider.addEventListener('change', () => navigateToPagePosition(Number(slider.value)));
document.body.appendChild(root);
bottomNavigation = {
root,
startButton,
backButton,
slider,
pageLabel,
forwardButton,
endButton
};
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 writtenLimit = Math.max(0, bookPaginationState.writtenPageLimit || 0);
const targetPage = THREE.MathUtils.clamp(Math.round(Number(pagePosition || 0)), 0, Math.min(writableLimit, writtenLimit));
const currentPage = getCurrentPagePosition();
if (targetPage === currentPage) {
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 startFastPageFlip(Math.sign(spreadDelta), { targetSpread, skippedSpreads: Math.abs(spreadDelta) });
}
function syncBookControls() {
const busy = activeFlips.length > 0;
if (progressInput) progressInput.value = readingProgress.toFixed(3);
@@ -1690,6 +1891,28 @@ function syncBookControls() {
if (fastBackwardButton) fastBackwardButton.disabled = busy || !canPageFlip(-1);
if (forwardButton) forwardButton.disabled = busy || !canPageFlip(1);
if (fastForwardButton) fastForwardButton.disabled = busy || !canPageFlip(1);
syncBottomNavigation();
}
function syncBottomNavigation() {
if (!bottomNavigation) return;
const currentPage = getCurrentPagePosition();
const writtenLimit = Math.max(0, bookPaginationState.writtenPageLimit || 0);
const writableLimit = getWritablePageLimit();
const navigableLimit = Math.min(writtenLimit, writableLimit);
const reservedStart = Math.max(0, writableLimit);
bottomNavigation.slider.max = String(Math.max(0, bookPageCount));
bottomNavigation.slider.value = String(Math.min(currentPage, navigableLimit));
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 ? writtenLimit / bookPageCount : 0}`);
bottomNavigation.root.style.setProperty('--book-nav-reserve-start', `${bookPageCount > 0 ? reservedStart / bookPageCount : 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;
}
function handlePageCanvases(event) {
@@ -2137,12 +2360,13 @@ function textureHitPageSide(hit) {
return null;
}
function startPageFlip(direction) {
function startPageFlip(direction, options = {}) {
if (activeFlips.length || !currentProceduralBookModel || !canPageFlip(direction)) return false;
pendingRightPageFlip = false;
delete document.documentElement.dataset.webglPendingPageFlip;
const flip = createPageFlip(direction, performance.now(), normalFlipDuration);
if (!flip) return false;
flip.targetSpread = Number.isFinite(Number(options.targetSpread)) ? Math.max(0, Math.round(Number(options.targetSpread))) : null;
prepareStaticPageForFlip(flip);
activeFlips.push(flip);
syncBookControls();
@@ -2150,20 +2374,23 @@ function startPageFlip(direction) {
return true;
}
function startFastPageFlip(direction) {
function startFastPageFlip(direction, options = {}) {
if (activeFlips.length || !currentProceduralBookModel || !canPageFlip(direction)) return false;
const firstFlip = createPageFlip(direction, performance.now(), fastFlipDuration);
if (!firstFlip) return false;
prepareStaticPageForFlip(firstFlip);
const startTime = firstFlip.startTime;
const interval = fastFlipDuration / fastFlipOverlap;
for (let index = 0; index < fastFlipCount; index += 1) {
const skippedSpreads = Math.max(2, Number(options.skippedSpreads || fastFlipCount));
const visibleFlipCount = THREE.MathUtils.clamp(Math.round(skippedSpreads), 2, 5);
for (let index = 0; index < visibleFlipCount; index += 1) {
activeFlips.push({
...firstFlip,
mesh: null,
startTime: startTime + index * interval,
pageOffset: index * 0.002,
commitBundleOnFinish: index === fastFlipCount - 1,
targetSpread: Number.isFinite(Number(options.targetSpread)) ? Math.max(0, Math.round(Number(options.targetSpread))) : null,
commitBundleOnFinish: index === visibleFlipCount - 1,
countAsPending: false
});
}
@@ -2195,12 +2422,19 @@ function createPageFlip(direction, startTime, duration) {
function prepareStaticPageForFlip(flip) {
if (!flip) return;
const sourceMaterial = flip.sourcePageSide === 'left' ? materials.leftPage : materials.rightPage;
const oppositeMaterial = flip.sourcePageSide === 'left' ? materials.rightPage : materials.leftPage;
const sourceTexture = sourceMaterial?.map || (flip.sourcePageSide === 'left' ? leftTexture : rightTexture);
const backTexture = oppositeMaterial?.map || getBlankPageTexture();
materials.flipPageSurface.map = sourceTexture;
materials.flipPageBackSurface.map = backTexture;
materials.flipPageSurface.normalMap = materials.pageSurface.normalMap;
materials.flipPageBackSurface.normalMap = materials.pageSurface.normalMap;
materials.flipPageSurface.roughnessMap = materials.pageSurface.roughnessMap;
materials.flipPageBackSurface.roughnessMap = materials.pageSurface.roughnessMap;
materials.flipPageSurface.needsUpdate = true;
materials.flipPageBackSurface.needsUpdate = true;
flip.sourceTexture = sourceTexture;
flip.backTexture = backTexture;
if (flip.direction > 0) {
const blankTexture = getBlankPageTexture();
if (blankTexture && materials.rightPage.map !== blankTexture) {
@@ -2208,13 +2442,22 @@ function prepareStaticPageForFlip(flip) {
materials.rightPage.map = blankTexture;
materials.rightPage.needsUpdate = true;
}
} else if (flip.direction < 0) {
const blankTexture = getBlankPageTexture();
if (blankTexture && materials.leftPage.map !== blankTexture) {
clearPageReveal('left', 'page-flip-start');
materials.leftPage.map = blankTexture;
materials.leftPage.needsUpdate = true;
}
}
}
function canPageFlip(direction) {
if (!currentProceduralBookModel) return false;
if (direction > 0) return readingProgress < 1;
return readingProgress > 0;
const currentPage = getCurrentPagePosition();
const maxNavigablePage = Math.min(Math.max(0, bookPaginationState.writtenPageLimit || 0), getWritablePageLimit());
if (direction > 0) return currentPage < maxNavigablePage;
return currentPage > 0;
}
function handleRevealCommittedForPageFlip(detail = {}) {
@@ -2268,10 +2511,14 @@ function updateActiveFlips(now) {
setActivePageGeometry(flip, surface);
if (!flip.spreadAdvanced && t >= 0.82) {
flip.spreadAdvanced = true;
const targetSpread = Number.isFinite(Number(flip.targetSpread))
? Math.max(0, Math.round(Number(flip.targetSpread)))
: null;
document.dispatchEvent(new CustomEvent('webgl-book:page-flip-near-end', {
detail: {
direction: flip.direction,
sourceSide: flip.sourcePageSide || (flip.direction > 0 ? 'right' : 'left')
sourceSide: flip.sourcePageSide || (flip.direction > 0 ? 'right' : 'left'),
targetSpread
}
}));
}
@@ -2395,7 +2642,11 @@ function lineYAtX(points, x) {
function setActivePageGeometry(flip, surface) {
const geometry = createFlippingPageGeometry(surface);
if (!flip.mesh) {
flip.mesh = new THREE.Mesh(geometry, materials.flipPageSurface);
flip.mesh = new THREE.Mesh(geometry, [
materials.flipPageSurface,
materials.flipPageBackSurface,
materials.flipPageEdge
]);
flip.mesh.castShadow = false;
flip.mesh.receiveShadow = false;
flip.mesh.userData.bookPart = 'flippingPage';
@@ -2411,9 +2662,12 @@ function createFlippingPageGeometry(surface) {
const positions = [];
const uvs = [];
const indices = [];
const topIndices = [];
const bottomIndices = [];
const wallIndices = [];
const topGrid = [];
const bottomGrid = [];
const pageThickness = 0.006;
const pageThickness = Math.max(0.0008, Number(PROCEDURAL_BOOK.SHEET_THICKNESS_MODEL || 0.001));
const widthSegments = surface.length - 1;
const depthSegments = surface[0].length - 1;
const push = (point, yOffset, u, v) => {
@@ -2445,8 +2699,8 @@ function createFlippingPageGeometry(surface) {
const bottomB = bottomGrid[index + 1][zIndex];
const bottomC = bottomGrid[index][zIndex + 1];
const bottomD = bottomGrid[index + 1][zIndex + 1];
indices.push(a, c, b, b, c, d);
indices.push(bottomA, bottomB, bottomC, bottomB, bottomD, bottomC);
topIndices.push(a, c, b, b, c, d);
bottomIndices.push(bottomA, bottomB, bottomC, bottomB, bottomD, bottomC);
}
}
for (let index = 0; index < widthSegments; index += 1) {
@@ -2458,15 +2712,21 @@ function createFlippingPageGeometry(surface) {
addWall(topGrid[widthSegments][zIndex], topGrid[widthSegments][zIndex + 1], bottomGrid[widthSegments][zIndex], bottomGrid[widthSegments][zIndex + 1]);
}
indices.push(...topIndices, ...bottomIndices, ...wallIndices);
const geometry = new THREE.BufferGeometry();
geometry.setIndex(indices);
geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
geometry.setAttribute('uv', new THREE.Float32BufferAttribute(uvs, 2));
geometry.clearGroups();
geometry.addGroup(0, topIndices.length, 0);
geometry.addGroup(topIndices.length, bottomIndices.length, 1);
geometry.addGroup(topIndices.length + bottomIndices.length, wallIndices.length, 2);
geometry.computeVertexNormals();
return geometry;
function addWall(topA, topB, bottomA, bottomB) {
indices.push(topA, bottomA, topB, topB, bottomA, bottomB);
wallIndices.push(topA, bottomA, topB, topB, bottomA, bottomB);
}
}
@@ -2479,8 +2739,15 @@ function finishActiveFlip(flip) {
sourceSide: flip.sourcePageSide || (flip.direction > 0 ? 'right' : 'left')
}
}));
if (activeFlips.length === 0 && Number.isFinite(Number(flip.targetSpread))) {
syncReadingProgressToCurrentPage();
}
if (flip.commitBundleOnFinish) {
shiftReadingProgressByBundle(flip.direction);
if (Number.isFinite(Number(flip.targetSpread))) {
syncBookControls();
} else {
shiftReadingProgressByBundle(flip.direction);
}
return;
}
if (!flip.countAsPending) {