diff --git a/frontend/src/preview/PdfViewer.tsx b/frontend/src/preview/PdfViewer.tsx index e143eee..6d521f8 100644 --- a/frontend/src/preview/PdfViewer.tsx +++ b/frontend/src/preview/PdfViewer.tsx @@ -46,6 +46,10 @@ interface RenderQueueRequest { } const RERENDER_DELTA = 48; +const MAX_PIXEL_RATIO = 2; +const FAST_SCROLL_VELOCITY_THRESHOLD = 0.8; // px/ms ~ 800px/s +const FAST_SCROLL_DWELL_THRESHOLD_MS = 120; +const SCROLL_VELOCITY_MIN_DELTA = 0.05; const getAvailableViewportSize = (viewportNode: Element, stackNode: Element) => { const viewportStyle = window.getComputedStyle(viewportNode); @@ -87,6 +91,7 @@ const PdfViewer = ({ src, title, className, viewportRef }: PdfViewerProps): JSX. const [pixelRatio, setPixelRatio] = useState(1); const [viewMode, setViewMode] = useState('contain'); const [visiblePages, setVisiblePages] = useState>(new Set()); + const [scrollVelocity, setScrollVelocity] = useState(0); const pdfRef = useRef(null); const wasmUrlRef = useRef(null); const pageNodeMapRef = useRef>(new Map()); @@ -94,6 +99,12 @@ const PdfViewer = ({ src, title, className, viewportRef }: PdfViewerProps): JSX. const renderQueueRef = useRef([]); const activeRendersRef = useRef(new Set()); const maxConcurrentRendersRef = useRef(2); + const scrollVelocityRef = useRef({ + lastPosition: 0, + lastTime: 0, + velocity: 0, + }); + const scrollElementRef = useRef(null); const requestQueueFlush = useCallback(() => { const queue = renderQueueRef.current; @@ -277,7 +288,7 @@ const PdfViewer = ({ src, title, className, viewportRef }: PdfViewerProps): JSX. return; } - const ratio = window.devicePixelRatio || 1; + const ratio = Math.min(window.devicePixelRatio || 1, MAX_PIXEL_RATIO); const descriptors: PageDescriptor[] = []; for (let pageNumber = 1; pageNumber <= pdf.numPages; pageNumber += 1) { if (cancelled) { @@ -401,6 +412,20 @@ const PdfViewer = ({ src, title, className, viewportRef }: PdfViewerProps): JSX. const intersectionRoot = viewportRef?.current || null; + const updateScrollVelocityState = useCallback((nextVelocity: number) => { + setScrollVelocity((previous) => { + const wasFast = Math.abs(previous) > FAST_SCROLL_VELOCITY_THRESHOLD; + const isFast = Math.abs(nextVelocity) > FAST_SCROLL_VELOCITY_THRESHOLD; + if (!isFast && !wasFast && Math.abs(previous - nextVelocity) < SCROLL_VELOCITY_MIN_DELTA) { + return previous; + } + if (Math.abs(previous - nextVelocity) < SCROLL_VELOCITY_MIN_DELTA && wasFast === isFast) { + return previous; + } + return nextVelocity; + }); + }, []); + const registerPageNode = useCallback((pageNumber: number, node: HTMLElement | null) => { const map = pageNodeMapRef.current; const existing = map.get(pageNumber); @@ -421,6 +446,8 @@ const PdfViewer = ({ src, title, className, viewportRef }: PdfViewerProps): JSX. useEffect(() => { let frame: number | null = null; let observer: IntersectionObserver | null = null; + let scrollElement: HTMLElement | null = null; + let scrollListener: (() => void) | null = null; const attachObserver = () => { const stackElement = containerRef.current; @@ -431,6 +458,34 @@ const PdfViewer = ({ src, title, className, viewportRef }: PdfViewerProps): JSX. const root = intersectionRoot || stackElement.closest('.document-viewer__viewport') || undefined; + scrollElement = (viewportRef?.current || stackElement.closest('.document-viewer__viewport')) ?? null; + scrollElementRef.current = scrollElement; + if (scrollElement && !scrollListener) { + scrollVelocityRef.current = { + lastPosition: scrollElement.scrollTop, + lastTime: performance.now(), + velocity: 0, + }; + scrollListener = () => { + if (!scrollElement) { + return; + } + const now = performance.now(); + const position = scrollElement.scrollTop; + const elapsed = now - scrollVelocityRef.current.lastTime; + if (elapsed <= 0) { + return; + } + const velocity = (position - scrollVelocityRef.current.lastPosition) / elapsed; + scrollVelocityRef.current = { + lastPosition: position, + lastTime: now, + velocity, + }; + updateScrollVelocityState(velocity); + }; + scrollElement.addEventListener('scroll', scrollListener, { passive: true }); + } observer = new IntersectionObserver((entries) => { entries.forEach((entry) => { const attr = entry.target.getAttribute('data-page-number'); @@ -462,8 +517,14 @@ const PdfViewer = ({ src, title, className, viewportRef }: PdfViewerProps): JSX. if (intersectionObserverRef.current === observer) { intersectionObserverRef.current = null; } + if (scrollElement && scrollListener) { + scrollElement.removeEventListener('scroll', scrollListener); + } + if (scrollElementRef.current === scrollElement) { + scrollElementRef.current = null; + } }; - }, [handlePageVisibilityChange, intersectionRoot]); + }, [handlePageVisibilityChange, intersectionRoot, updateScrollVelocityState, viewportRef]); const pageElements = useMemo(() => ( pages.map((page) => ( @@ -481,11 +542,13 @@ const PdfViewer = ({ src, title, className, viewportRef }: PdfViewerProps): JSX. enqueueRender={enqueueRender} cancelRenderRequest={cancelRenderRequest} onRenderFinished={handleRenderFinished} + scrollVelocity={scrollVelocity} /> )) ), [cancelRenderRequest, enqueueRender, handlePageClick, handleRenderFinished, - pages, pixelRatio, registerPageNode, shouldRenderPage, viewportHeight, viewportWidth, viewMode]); + pages, pixelRatio, registerPageNode, scrollVelocity, shouldRenderPage, viewportHeight, viewportWidth, + viewMode]); return (
@@ -522,6 +585,7 @@ interface PdfPageCanvasProps { enqueueRender: (request: RenderQueueRequest) => void; cancelRenderRequest: (pageNumber: number) => void; onRenderFinished: (pageNumber: number) => void; + scrollVelocity: number; } function PdfPageCanvas({ @@ -537,6 +601,7 @@ function PdfPageCanvas({ enqueueRender, cancelRenderRequest, onRenderFinished, + scrollVelocity, }: PdfPageCanvasProps): JSX.Element { const wrapperRef = useRef(null); const canvasRef = useRef(null); @@ -609,6 +674,19 @@ function PdfPageCanvas({ } }, [descriptor.number, descriptor.scale, onRenderFinished, pdfRef, pixelRatio]); + const meetsVelocityRequirement = useMemo(() => { + if (!shouldRender) { + return true; + } + const absoluteVelocity = Math.abs(scrollVelocity); + if (absoluteVelocity <= FAST_SCROLL_VELOCITY_THRESHOLD) { + return true; + } + const estimatedMs = (descriptor.height + viewportHeight) + / Math.max(absoluteVelocity, 0.001); + return estimatedMs >= FAST_SCROLL_DWELL_THRESHOLD_MS; + }, [descriptor.height, scrollVelocity, shouldRender, viewportHeight]); + useEffect(() => { if (!canvasMounted) { return undefined; @@ -635,7 +713,7 @@ function PdfPageCanvas({ }, [descriptor.number, resetCanvas]); useEffect(() => { - if (!shouldRender) { + if (!shouldRender || !meetsVelocityRequirement) { setAllowRender(false); cancelRenderRequest(descriptor.number); return; @@ -660,7 +738,7 @@ function PdfPageCanvas({ setAllowRender(false); cancelRenderRequest(descriptor.number); }; - }, [cancelRenderRequest, descriptor.number, enqueueRender, shouldRender]); + }, [cancelRenderRequest, descriptor.number, enqueueRender, meetsVelocityRequirement, shouldRender]); useEffect(() => { if (allowRender) {