From 707841faa52b65b6f2503b97aaa019f165781adc Mon Sep 17 00:00:00 2001 From: alea12 Date: Wed, 15 Jul 2026 13:52:27 -0400 Subject: [PATCH] Fix initial page drift in PDF scroll mode In scrolling mode, navigating to a start page performed a single scrollTo as soon as the first page rendered. While the PDF streams in, placeholders are swapped for rendered pages and the fit-mode resize changes every page's height, shifting the target page after the scroll already happened. The viewport then landed a dozen or more pages past the requested one, and PAGE_IN_VIEW locked that wrong page number in. Keep the target page anchored by re-checking its position every frame and correcting the scroll offset until the layout has been stable for ANCHOR_SETTLE_MS, or until the user interacts with the reader (wheel, touch, mouse, or keyboard). Each correction also refreshes the programmatic-navigation guard so PAGE_IN_VIEW does not override the target page mid-anchoring. --- src/PdfReader/index.tsx | 68 +++++++++++++++++++++++++++++++++++------ src/constants.ts | 6 +++- 2 files changed, 64 insertions(+), 10 deletions(-) diff --git a/src/PdfReader/index.tsx b/src/PdfReader/index.tsx index 82b1eb86..78c09b5d 100644 --- a/src/PdfReader/index.tsx +++ b/src/PdfReader/index.tsx @@ -2,6 +2,7 @@ import { Flex } from '@chakra-ui/react'; import * as React from 'react'; import { Document, PageProps, pdfjs } from 'react-pdf'; import { + ANCHOR_SETTLE_MS, DEFAULT_HEIGHT, DEFAULT_SHOULD_GROW_WHEN_SCROLLING, IN_VIEW_DELAY_MS, @@ -222,7 +223,13 @@ export default function usePdfReader(args: PdfReaderArguments): ReaderReturn { ]); /** - * In scrolling mode, manually scroll the user when the page changes + * In scrolling mode, manually scroll the user when the page changes. + * + * A single scrollTo is not enough: while the PDF streams in, placeholders + * are swapped for rendered pages and the fit-mode resize changes every + * page's height, both of which shift the target page after we have already + * scrolled to it. We keep the target anchored until its position has been + * stable for ANCHOR_SETTLE_MS, or until the user interacts with the reader. */ React.useEffect(() => { // if the resource is not yet loaded, don't do anything yet @@ -235,16 +242,59 @@ export default function usePdfReader(args: PdfReaderArguments): ReaderReturn { } const documentContainer = documentContainerRef.current; - const pageRef = pageRefs.current.get(state.pageNumber); + if (!documentContainer) return; + + const userInteractionEvents = [ + 'wheel', + 'touchstart', + 'mousedown', + 'keydown', + ] as const; + const targetPage = state.pageNumber; + let rafId = 0; + let stableSince: number | null = null; + + const stop = () => { + cancelAnimationFrame(rafId); + userInteractionEvents.forEach((event) => + documentContainer.removeEventListener(event, stop) + ); + }; - if (documentContainer && pageRef) { - const containerRect = documentContainer.getBoundingClientRect(); - const pageRect = pageRef.getBoundingClientRect(); + userInteractionEvents.forEach((event) => + documentContainer.addEventListener(event, stop, { passive: true }) + ); - documentContainer.scrollTo({ - top: documentContainer.scrollTop + (pageRect.top - containerRect.top), - }); - } + const anchor = () => { + const pageRef = pageRefs.current.get(targetPage); + if (pageRef) { + const containerRect = documentContainer.getBoundingClientRect(); + const pageRect = pageRef.getBoundingClientRect(); + const delta = pageRect.top - containerRect.top; + + if (Math.abs(delta) > 1) { + // suppress PAGE_IN_VIEW while we correct, the same way + // beginProgrammaticNavigation does for explicit navigation + scrollState.current.lastVisiblePage = targetPage; + scrollState.current.lastProgrammaticNavAt = Date.now(); + scrollState.current.ratios.clear(); + + documentContainer.scrollTo({ + top: documentContainer.scrollTop + delta, + }); + stableSince = null; + } else if (stableSince === null) { + stableSince = Date.now(); + } else if (Date.now() - stableSince >= ANCHOR_SETTLE_MS) { + stop(); + return; + } + } + rafId = requestAnimationFrame(anchor); + }; + + anchor(); + return stop; }, [state.pageNumber, state.settings?.isScrolling, state.rendered]); const beginProgrammaticNavigation = React.useCallback( diff --git a/src/constants.ts b/src/constants.ts index 46bd338d..f03e7826 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -28,7 +28,8 @@ export const DEFAULT_FONT_WIDTH = 250; export const FONT_DETAILS = { publisher: { heading: "Publisher's default font", - body: "Show the publisher's-specified fonts and layout choices in this ebook", + body: + "Show the publisher's-specified fonts and layout choices in this ebook", token: 'body', fontWeight: 'light', }, @@ -53,6 +54,9 @@ export const FONT_DETAILS = { }; export const IN_VIEW_DELAY_MS = 150; +// how long the scroll target's position must be stable before we stop +// re-anchoring it (see the scroll effect in PdfReader) +export const ANCHOR_SETTLE_MS = 1000; // local storage keys export const LOCAL_STORAGE_SETTINGS_KEY = 'web-reader-settings';