Something went wrong. Try again.
This repository has no description
Something went wrong. Try again.
JavaScript
at master
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState,} from "react";import { api } from "../api.js";import { buildPageLayout, navigationScrollTop, positionOffset, readingPosition, renderedWindow,} from "./layout.js";
const SCROLL_KEYS = new Set([ "ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight", "PageUp", "PageDown", "Home", "End", " ",]);
export function useContinuousPdf({ material, page, navigationToken, onVisiblePage, notify, screenshotRef,}) { const scrollRoot = useRef(null); const pageRoots = useRef(new Map()); const readyPages = useRef(new Map()); const [count, setCount] = useState(material.page_count || 0); const [width, setWidth] = useState(650); const [zoom, setZoom] = useState(material.position?.zoom || 1); const zoomRef = useRef(zoom); zoomRef.current = zoom; const [ratios, setRatios] = useState({}); const [defaultRatio, setDefaultRatio] = useState(Math.SQRT2); const [ready, setReady] = useState(false); const [windowRange, setWindowRange] = useState({ start: 1, end: 0 }); const [, setRenderVersion] = useState(0); const position = useRef({ page: material.position?.page || page || 1, pageOffset: material.position?.pageOffset || 0, }); const lastNavigation = useRef(navigationToken); const pendingNavigation = useRef(null); const frame = useRef(0), saveTimer = useRef(0), dirtyPosition = useRef(null); const lastSaved = useRef(JSON.stringify({ ...position.current, zoom })); const saveQueue = useRef(Promise.resolve()); const callbacks = useRef({ onVisiblePage, notify }); callbacks.current = { onVisiblePage, notify }; const pageWidth = width * zoom; const layout = useMemo( () => buildPageLayout(count, pageWidth, ratios, defaultRatio), [count, pageWidth, ratios, defaultRatio], ); const layoutRef = useRef(layout), pageWidthRef = useRef(pageWidth), live = useRef(true);
const renderKey = useCallback((number, currentWidth, currentLayout) => { const pageLayout = currentLayout || layoutRef.current; const width = currentWidth ?? pageWidthRef.current; return `${width}:${pageLayout.heights[number - 1] ?? 0}`; }, []);
function padding() { return scrollRoot.current ? parseFloat(getComputedStyle(scrollRoot.current).paddingTop) || 0 : 0; } const savePosition = useCallback(() => { clearTimeout(saveTimer.current); const next = dirtyPosition.current; if (!next) return; dirtyPosition.current = null; const signature = JSON.stringify(next); if (signature === lastSaved.current) return; saveQueue.current = saveQueue.current .catch(() => {}) .then(() => api("/materials/" + material.id, { method: "PATCH", body: { position: next }, }), ) .then(() => { lastSaved.current = signature; }) .catch((error) => { if (live.current) callbacks.current.notify(error.message, "error"); }); }, [material.id]); const updateWindow = useCallback(() => { const element = scrollRoot.current; if (!element || !layoutRef.current.count) return; const next = renderedWindow( layoutRef.current, element.scrollTop - padding(), element.clientHeight, ); setWindowRange((current) => current.start === next.start && current.end === next.end ? current : next, ); }, []); useLayoutEffect(() => { layoutRef.current = layout; pageWidthRef.current = pageWidth; if (!ready || !scrollRoot.current) return; scrollRoot.current.scrollTop = padding() + positionOffset(layout, position.current); updateWindow(); }, [layout, pageWidth, ready, updateWindow]); useEffect(() => { const element = scrollRoot.current; if (!element) return; let timer, measured = false, observedWidth; const observer = new ResizeObserver(([entry]) => { const nextWidth = Math.max(180, Math.floor(entry.contentRect.width)); if (observedWidth !== nextWidth) { observedWidth = nextWidth; readyPages.current.clear(); setRenderVersion((version) => version + 1); } clearTimeout(timer); if (!measured) { setWidth(nextWidth); measured = true; } else timer = setTimeout(() => setWidth(nextWidth), 140); updateWindow(); }); observer.observe(element); return () => { clearTimeout(timer); observer.disconnect(); }; }, [updateWindow]); useLayoutEffect(() => { if ( !ready || lastNavigation.current === navigationToken || !scrollRoot.current ) return; lastNavigation.current = navigationToken; position.current = { page: Math.max(1, Math.min(count, page)), pageOffset: 0, }; pendingNavigation.current = position.current; dirtyPosition.current = { ...position.current, zoom: zoomRef.current, }; savePosition(); const element = scrollRoot.current; // Stop an in-flight native keyboard scroll before animating a new jump. element.scrollTo({ top: element.scrollTop, behavior: "auto" }); const top = padding() + positionOffset(layoutRef.current, position.current); const nearby = Math.abs(top - element.scrollTop) < element.clientHeight * 2; const reducedMotion = matchMedia( "(prefers-reduced-motion: reduce)", ).matches; element.scrollTo({ top, behavior: nearby && !reducedMotion ? "smooth" : "auto", }); updateWindow(); }, [navigationToken, ready, count, page, savePosition, updateWindow]); const onScroll = useCallback(() => { if (!ready || frame.current) return; frame.current = requestAnimationFrame(() => { frame.current = 0; const element = scrollRoot.current; if (!element) return; const target = pendingNavigation.current; if (target) { const targetTop = navigationScrollTop( layoutRef.current, target, padding(), element.scrollHeight - element.clientHeight, ); // Metadata can change during a smooth jump. Keep its destination as // the layout anchor until arrival, rather than persisting interim pages. if (Math.abs(element.scrollTop - targetTop) > 1) { updateWindow(); return; } pendingNavigation.current = null; position.current = target; callbacks.current.onVisiblePage(target.page); updateWindow(); return; } const next = readingPosition( layoutRef.current, element.scrollTop - padding(), ); if (next.page !== position.current.page) callbacks.current.onVisiblePage(next.page); position.current = next; dirtyPosition.current = { ...next, zoom: zoomRef.current }; clearTimeout(saveTimer.current); saveTimer.current = setTimeout(savePosition, 450); updateWindow(); }); }, [ready, savePosition, updateWindow]); const cancelNavigation = useCallback( (event) => { if (event?.type === "keydown" && !SCROLL_KEYS.has(event.key)) return; if (!pendingNavigation.current || !scrollRoot.current) return; pendingNavigation.current = null; const next = readingPosition( layoutRef.current, scrollRoot.current.scrollTop - padding(), ); position.current = next; callbacks.current.onVisiblePage(next.page); dirtyPosition.current = { ...next, zoom: zoomRef.current }; savePosition(); }, [savePosition], ); useEffect(() => { live.current = true; return () => { live.current = false; cancelAnimationFrame(frame.current); savePosition(); }; }, [savePosition]); const loadDocument = useCallback(async (pdf) => { const first = await pdf.getPage(1); if (!live.current) return; const viewport = first.getViewport({ scale: 1 }); setDefaultRatio(viewport.height / viewport.width); setCount(pdf.numPages); position.current.page = Math.min(position.current.page, pdf.numPages); callbacks.current.onVisiblePage(position.current.page); setReady(true); }, []); const measurePage = useCallback((number, pdfPage) => { const viewport = pdfPage.getViewport({ scale: 1 }); const ratio = viewport.height / viewport.width; setRatios((current) => current[number] === ratio ? current : { ...current, [number]: ratio }, ); }, []); const registerPage = useCallback((number, node) => { if (node) pageRoots.current.set(number, node); else { pageRoots.current.delete(number); readyPages.current.delete(number); } }, []); const renderedPage = useCallback((number, key) => { readyPages.current.set(number, key); setRenderVersion((version) => version + 1); }, []); useEffect(() => { screenshotRef.current = (requestedPage = position.current.page) => { if (readyPages.current.get(requestedPage) !== renderKey(requestedPage)) return null; const canvas = pageRoots.current .get(requestedPage) ?.querySelector("canvas"); if (!canvas) return null; const image = document.createElement("canvas"); const ratio = Math.min(1, 1400 / canvas.width); image.width = canvas.width * ratio; image.height = canvas.height * ratio; image.getContext("2d").drawImage(canvas, 0, 0, image.width, image.height); return image.toDataURL("image/jpeg", 0.7); }; return () => { screenshotRef.current = null; }; }, [renderKey, screenshotRef]); function changeZoom(value) { zoomRef.current = value; setZoom(value); dirtyPosition.current = { ...position.current, zoom: value }; savePosition(); } return { scrollRoot, count, zoom, pageWidth, layout, windowRange, ready, currentLoaded: readyPages.current.get(page) === renderKey(page, pageWidth, layout), renderKey, loadDocument, measurePage, registerPage, renderedPage, onScroll, cancelNavigation, changeZoom, };}