From 651d1c6a527d3a7f3edfc445283eebae35b9a17b Mon Sep 17 00:00:00 2001 From: Jackie Quach Date: Tue, 11 Aug 2026 14:54:52 -0400 Subject: [PATCH 01/11] initial pdfjs reader --- example/index.tsx | 37 ++ example/use-new-pdf-reader.tsx | 43 ++ src/NewReader/PdfPage.tsx | 292 ++++++++++++ src/NewReader/PdfReader.css | 400 ++++++++++++++++ src/NewReader/constants.ts | 7 + src/NewReader/index.tsx | 826 +++++++++++++++++++++++++++++++++ src/NewReader/reducer.ts | 119 +++++ src/NewReader/types.ts | 81 ++++ src/NewReader/utils.ts | 119 +++++ src/index.tsx | 1 + src/types.ts | 2 +- src/useWebReader.tsx | 21 +- 12 files changed, 1934 insertions(+), 14 deletions(-) create mode 100644 example/use-new-pdf-reader.tsx create mode 100644 src/NewReader/PdfPage.tsx create mode 100644 src/NewReader/PdfReader.css create mode 100644 src/NewReader/constants.ts create mode 100644 src/NewReader/index.tsx create mode 100644 src/NewReader/reducer.ts create mode 100644 src/NewReader/types.ts create mode 100644 src/NewReader/utils.ts diff --git a/example/index.tsx b/example/index.tsx index 049132dc..238a16ca 100644 --- a/example/index.tsx +++ b/example/index.tsx @@ -33,6 +33,7 @@ import mobyEpub2Manifest from './static/samples/moby-epub2-exploded/manifest.jso import pdfSingleResourceManifest from './static/samples/pdf/single-resource-short.json'; import Tests from './Tests'; import UseHtmlReader from './use-html-reader'; +import UseNewPdfReader from './use-new-pdf-reader'; import UsePdfReader from './use-pdf-reader'; const origin = window.location.origin; @@ -120,6 +121,30 @@ const PdfReaders = () => { pdfWorkerSrc={`${origin}/pdf-worker/pdf.worker.min.mjs`} /> + + + NewReader + + New Pdf Reader using pdf.js directly instead of react-pdf. + + + + + + = async (url) => { return syntheticUrl; }; +const fetchAsUrl = async ( + resourceUrl: string, + proxyUrl?: string +): Promise => { + return proxyUrl + ? `${proxyUrl}${encodeURIComponent(resourceUrl)}` + : resourceUrl; +}; + const SingleResourcePdf = () => { const { data: modifiedManifestUrl, isLoading } = useSWR( '/samples/pdf/single-resource-short.json', @@ -427,6 +461,9 @@ const HomePage = () => { usePdfReader hook + + NewReader with Manifest + Single-PDF Webpub (large file) diff --git a/example/use-new-pdf-reader.tsx b/example/use-new-pdf-reader.tsx new file mode 100644 index 00000000..b32e09dd --- /dev/null +++ b/example/use-new-pdf-reader.tsx @@ -0,0 +1,43 @@ +import { Box } from '@chakra-ui/react'; +import React from 'react'; +import { useNewReader } from '../src'; +import { WebpubManifest } from '../src/types'; +import Header from '../src/ui/Header'; + +type UseNewPdfReaderProps = { + webpubManifestUrl: string; + manifest: WebpubManifest; + proxyUrl?: string; + pdfWorkerSrc?: string; +}; + +const UseNewPdfReader: React.FC = ({ + webpubManifestUrl, + manifest, + proxyUrl, + pdfWorkerSrc, +}) => { + const newReader = useNewReader({ + webpubManifestUrl, + manifest, + proxyUrl, + pdfWorkerSrc, + }); + const containerRef = React.useRef(null); + + return ( + + {newReader.type &&
} + + {newReader.content} + + + ); +}; + +export default UseNewPdfReader; diff --git a/src/NewReader/PdfPage.tsx b/src/NewReader/PdfPage.tsx new file mode 100644 index 00000000..e8cb0354 --- /dev/null +++ b/src/NewReader/PdfPage.tsx @@ -0,0 +1,292 @@ +import type { PDFDocumentProxy, RenderTask } from 'pdfjs-dist'; +import { AnnotationLayer, TextLayer } from 'pdfjs-dist'; +import React, { useCallback, useEffect, useMemo, useRef } from 'react'; +import { PageSize, RefProxy } from './types'; +import { getRotatedSize, toError } from './utils'; + +interface PdfPageProps { + pdfDoc: PDFDocumentProxy; + pageNumber: number; + scale: number; + rotation: number; + baseSize: PageSize; + isVisible: boolean; + registerContainer: (pageNumber: number, el: HTMLDivElement | null) => void; + goToPage: (pageNumber: number) => void; + onError?: (error: Error) => void; +} + +/** + * A single page: a fixed-size placeholder (so the scrollbar/layout never + * jumps) whose canvas/text/annotation layers only get painted once the + * page scrolls near the viewport (`isVisible`), and re-painted whenever + * `scale`/`rotation` change. + */ +const PdfPage = React.memo(function PdfPage({ + pdfDoc, + pageNumber, + scale, + rotation, + baseSize, + isVisible, + registerContainer, + goToPage, + onError, +}: PdfPageProps) { + const canvasRef = useRef(null); + const textLayerRef = useRef(null); + const annotationLayerRef = useRef(null); + const renderTaskRef = useRef(null); + const textLayerTaskRef = useRef<{ cancel: () => void } | null>(null); + const renderedKeyRef = useRef(null); + + const releaseRenderedContent = useCallback(() => { + const canvas = canvasRef.current; + if (canvas) { + canvas.width = 0; + canvas.height = 0; + canvas.style.width = '0px'; + canvas.style.height = '0px'; + } + if (textLayerRef.current) { + textLayerRef.current.innerHTML = ''; + textLayerRef.current.style.removeProperty('--scale-factor'); + } + if (annotationLayerRef.current) { + annotationLayerRef.current.innerHTML = ''; + } + renderedKeyRef.current = null; + }, []); + + const rotated = useMemo( + () => getRotatedSize(baseSize, rotation), + [baseSize, rotation] + ); + const displayWidth = rotated.width * scale; + const displayHeight = rotated.height * scale; + + useEffect(() => { + if (!isVisible) { + renderTaskRef.current?.cancel(); + textLayerTaskRef.current?.cancel(); + textLayerTaskRef.current = null; + releaseRenderedContent(); + return undefined; + } + + const key = `${scale}|${rotation}`; + if (renderedKeyRef.current === key) return undefined; + + let cancelled = false; + + const run = async () => { + const page = await pdfDoc.getPage(pageNumber); + if (cancelled) return; + + const viewport = page.getViewport({ scale, rotation }); + const canvas = canvasRef.current; + const textLayerDiv = textLayerRef.current; + const annotationLayerDiv = annotationLayerRef.current; + if (!canvas || !textLayerDiv || !annotationLayerDiv) return; + + const outputScale = window.devicePixelRatio || 1; + canvas.width = Math.floor(viewport.width * outputScale); + canvas.height = Math.floor(viewport.height * outputScale); + canvas.style.width = `${viewport.width}px`; + canvas.style.height = `${viewport.height}px`; + textLayerDiv.style.width = `${viewport.width}px`; + textLayerDiv.style.height = `${viewport.height}px`; + annotationLayerDiv.style.width = `${viewport.width}px`; + annotationLayerDiv.style.height = `${viewport.height}px`; + + if (renderTaskRef.current) { + renderTaskRef.current.cancel(); + } + + const ctx = canvas.getContext('2d'); + if (!ctx) return; + const transform = + outputScale !== 1 ? [outputScale, 0, 0, outputScale, 0, 0] : undefined; + + const task = page.render({ + canvasContext: ctx, + viewport, + transform, + }); + renderTaskRef.current = task; + + try { + await task.promise; + } catch (err: unknown) { + if ( + err instanceof Error && + err.name === 'RenderingCancelledException' + ) { + return; + } + const nextError = toError(err, `Failed to render page ${pageNumber}`); + onError?.(nextError); + return; + } + if (cancelled) return; + + textLayerDiv.innerHTML = ''; + textLayerDiv.style.setProperty('--scale-factor', String(viewport.scale)); + if (textLayerTaskRef.current) { + textLayerTaskRef.current.cancel(); + textLayerTaskRef.current = null; + } + try { + const textContent = await page.getTextContent(); + const textLayer = new TextLayer({ + textContentSource: textContent, + container: textLayerDiv, + viewport, + }); + textLayerTaskRef.current = textLayer; + await textLayer.render(); + } catch (err: unknown) { + // TextLayer.cancel() rejects the in-flight render promise by design, + // not a real error. + if ( + !(err instanceof Error && err.name === 'RenderingCancelledException') + ) { + // Non-fatal: some pages have no extractable text. + } + } + if (cancelled) return; + + annotationLayerDiv.innerHTML = ''; + try { + const annotations = await page.getAnnotations({ intent: 'display' }); + const linkService = { + goToDestination: async (dest: string | unknown[] | null) => { + let d = dest; + if (typeof d === 'string') d = await pdfDoc.getDestination(d); + if (Array.isArray(d)) { + const idx = await pdfDoc.getPageIndex(d[0] as RefProxy); + goToPage(idx + 1); + } + }, + goToPage: (n: number) => goToPage(n), + getDestinationHash: () => '#', + getAnchorUrl: () => '#', + addLinkAttributes: () => { + void 0; + }, + setHash: () => { + void 0; + }, + executeNamedAction: () => { + void 0; + }, + executeSetOCGState: () => { + void 0; + }, + get pagesCount() { + return 0; + }, + get page() { + return 0; + }, + set page(_: number) { + void 0; + }, + get rotation() { + return 0; + }, + set rotation(_: number) { + void 0; + }, + get isInPresentationMode() { + return false; + }, + get externalLinkEnabled() { + return true; + }, + set externalLinkEnabled(_: boolean) { + void 0; + }, + }; + + const layer = new AnnotationLayer({ + div: annotationLayerDiv, + accessibilityManager: null, + annotationCanvasMap: null, + annotationEditorUIManager: null, + structTreeLayer: null, + page, + viewport: viewport.clone({ dontFlip: true }), + }); + + await layer.render({ + viewport: viewport.clone({ dontFlip: true }), + div: annotationLayerDiv, + annotations, + page, + linkService, // properly typed here via AnnotationLayerParameters + annotationStorage: undefined, + renderForms: true, + }); + } catch { + // Non-fatal: annotation rendering support varies by pdf.js version. + } + + if (!cancelled) renderedKeyRef.current = key; + }; + + run(); + + return () => { + cancelled = true; + renderTaskRef.current?.cancel(); + textLayerTaskRef.current?.cancel(); + textLayerTaskRef.current = null; + }; + }, [ + isVisible, + scale, + rotation, + pdfDoc, + pageNumber, + goToPage, + onError, + releaseRenderedContent, + ]); + + useEffect( + () => () => { + renderTaskRef.current?.cancel(); + textLayerTaskRef.current?.cancel(); + releaseRenderedContent(); + }, + [releaseRenderedContent] + ); + + return ( +
registerContainer(pageNumber, el)} + style={{ width: displayWidth, height: displayHeight, overflow: 'hidden' }} + > + +
+
+
+ ); +}); + +export default PdfPage; diff --git a/src/NewReader/PdfReader.css b/src/NewReader/PdfReader.css new file mode 100644 index 00000000..c088691a --- /dev/null +++ b/src/NewReader/PdfReader.css @@ -0,0 +1,400 @@ +.pdf-root { + display: flex; + flex-direction: column; + width: 100%; + height: 100%; + min-height: 400px; + background: #525659; + color: #e8eaed; + font-family: + -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + overflow: hidden; +} + +.pdf-root:fullscreen { + width: 100vw; + height: 100vh; +} + +.pdf-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 6px 10px; + background: #2b2d30; + border-bottom: 1px solid #1c1d1f; + flex-wrap: wrap; + flex-shrink: 0; +} + +.pdf-toolbar-group { + display: flex; + align-items: center; + gap: 4px; +} + +.pdf-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + border: none; + border-radius: 6px; + background: transparent; + color: #e8eaed; + cursor: pointer; + transition: background 0.12s ease; +} + +.pdf-btn:hover:not(:disabled) { + background: rgba(255, 255, 255, 0.1); +} + +.pdf-btn:disabled { + opacity: 0.35; + cursor: default; +} + +.pdf-page-form { + display: flex; + align-items: center; + gap: 4px; + margin: 0 2px; +} + +.pdf-page-input { + width: 40px; + text-align: center; + background: #1c1d1f; + border: 1px solid #3c4043; + border-radius: 4px; + color: #e8eaed; + font-size: 13px; + padding: 4px 2px; +} + +.pdf-page-input:focus { + outline: 1px solid #8ab4f8; +} + +.pdf-page-total, +.pdf-zoom-label { + font-size: 13px; + color: #b0b3b8; + min-width: 42px; + text-align: center; + user-select: none; +} + +/* Body: sidebar + viewport */ +.pdf-body { + display: flex; + flex: 1; + min-height: 0; +} + +.pdf-sidebar { + width: 260px; + flex-shrink: 0; + background: #323639; + border-right: 1px solid #1c1d1f; + overflow-y: auto; +} + +.pdf-sidebar-header { + padding: 12px 14px; + font-size: 12px; + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; + color: #9aa0a6; + border-bottom: 1px solid #1c1d1f; +} + +.pdf-sidebar-empty { + padding: 14px; + font-size: 13px; + color: #7a7d81; +} + +.pdf-toc-list { + list-style: none; + margin: 0; + padding: 0; +} + +.pdf-toc-item { + display: block; + width: 100%; + text-align: left; + background: none; + border: none; + color: #d3d6db; + font-size: 13px; + padding: 7px 12px; + cursor: pointer; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.pdf-toc-item:hover:not(:disabled) { + background: rgba(255, 255, 255, 0.06); +} + +.pdf-toc-item.active { + background: rgba(66, 133, 244, 0.25); + color: #8ab4f8; +} + +.pdf-toc-item:disabled { + color: #6a6d71; + cursor: default; +} + +/* Viewport (scroll container) */ +.pdf-viewport { + flex: 1; + overflow: auto; + position: relative; + background: #252525; +} + +/* Loading / error states */ +.pdf-status-container { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; +} + +.pdf-status { + display: flex; + flex-direction: column; + align-items: center; + gap: 10px; + color: #b0b3b8; + font-size: 14px; +} + +.pdf-error { + color: #f28b82; +} + +.pdf-pages-stack { + display: flex; + flex-direction: column; + align-items: flex-start; + width: max-content; + min-width: 100%; + padding-top: 16px; + background: #252525; +} + +.pdf-page-wrap { + position: relative; + flex-shrink: 0; + margin-inline: auto; + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.5); + background: #252525; +} + +.pdf-canvas { + display: block; +} + +/* Text and annotation layer styles adapted from react-pdf/pdf.js */ +.pdf-root { + --react-pdf-text-layer: 1; +} + +[data-main-rotation='90'] .pdf-text-layer.textLayer { + transform: rotate(90deg) translateY(-100%); +} + +[data-main-rotation='180'] .pdf-text-layer.textLayer { + transform: rotate(180deg) translate(-100%, -100%); +} + +[data-main-rotation='270'] .pdf-text-layer.textLayer { + transform: rotate(270deg) translateX(-100%); +} + +.pdf-text-layer.textLayer { + color-scheme: only light; + position: absolute; + text-align: initial; + inset: 0; + overflow: clip; + opacity: 1; + line-height: 1; + text-size-adjust: none; + forced-color-adjust: none; + transform-origin: 0 0; + caret-color: CanvasText; + z-index: 0; + + --min-font-size: 1; + --total-scale-factor: var(--scale-factor, 1); + --text-scale-factor: calc(var(--total-scale-factor) * var(--min-font-size)); + --min-font-size-inv: calc(1 / var(--min-font-size)); +} + +.pdf-text-layer.textLayer :is(span, br) { + color: transparent; + position: absolute; + white-space: pre; + cursor: text; + margin: 0; + transform-origin: 0 0; +} + +.pdf-text-layer.textLayer > :not(.markedContent), +.pdf-text-layer.textLayer .markedContent span:not(.markedContent) { + z-index: 1; + + --font-height: 0; + font-size: calc(var(--text-scale-factor) * var(--font-height)); + + --scale-x: 1; + --rotate: 0deg; + transform: rotate(var(--rotate)) scaleX(var(--scale-x)) + scale(var(--min-font-size-inv)); +} + +.pdf-text-layer.textLayer .markedContent { + display: contents; +} + +.pdf-text-layer.textLayer span[role='img'] { + user-select: none; + cursor: default; +} + +.pdf-text-layer.textLayer .highlight { + --highlight-bg-color: rgb(180 0 170 / 0.25); + --highlight-selected-bg-color: rgb(0 100 0 / 0.25); + --highlight-backdrop-filter: none; + --highlight-selected-backdrop-filter: none; + + margin: -1px; + padding: 1px; + background-color: var(--highlight-bg-color); + backdrop-filter: var(--highlight-backdrop-filter); + border-radius: 4px; +} + +@media screen and (forced-colors: active) { + .pdf-text-layer.textLayer .highlight { + --highlight-bg-color: Highlight; + --highlight-selected-bg-color: ButtonText; + } +} + +.pdf-text-layer.textLayer .highlight.appended { + position: initial; +} + +.pdf-text-layer.textLayer .highlight.begin { + border-radius: 4px 0 0 4px; +} + +.pdf-text-layer.textLayer .highlight.end { + border-radius: 0 4px 4px 0; +} + +.pdf-text-layer.textLayer .highlight.middle { + border-radius: 0; +} + +.pdf-text-layer.textLayer .highlight.selected { + background-color: var(--highlight-selected-bg-color); + backdrop-filter: var(--highlight-selected-backdrop-filter); +} + +.pdf-text-layer.textLayer ::selection { + background: rgba(0, 0, 255, 0.25); +} + +@supports (background: color-mix(in srgb, AccentColor, transparent 75%)) { + .pdf-text-layer.textLayer ::selection { + background: color-mix(in srgb, AccentColor, transparent 75%); + } +} + +.pdf-text-layer.textLayer br::selection { + background: transparent; +} + +.pdf-text-layer.textLayer .endOfContent { + display: block; + position: absolute; + inset: 100% 0 0; + z-index: 0; + cursor: default; + user-select: none; +} + +.pdf-text-layer.textLayer.selecting .endOfContent { + top: 0; +} + +.pdf-text-layer.textLayer .hiddenCanvasElement { + position: absolute; + top: 0; + left: 0; + width: 0; + height: 0; + display: none; +} + +.pdf-annotation-layer { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + overflow: hidden; +} + +.pdf-annotation-layer section { + position: absolute; +} + +.pdf-annotation-layer .linkAnnotation > a { + position: absolute; + font-size: 1em; + top: 0; + left: 0; + width: 100%; + height: 100%; +} + +.pdf-annotation-layer .linkAnnotation > a:hover { + background: rgba(255, 255, 0, 0.15); + box-shadow: 0 0 0 1px rgba(255, 255, 0, 0.4); +} + +.pdf-annotation-layer input, +.pdf-annotation-layer textarea, +.pdf-annotation-layer select { + background: rgba(255, 255, 255, 0.6); + border: 1px solid #8ab4f8; + box-sizing: border-box; + font: inherit; +} + +@media (max-width: 640px) { + .pdf-sidebar { + position: absolute; + z-index: 5; + height: 100%; + } + .pdf-page-total, + .pdf-zoom-label { + min-width: unset; + } +} diff --git a/src/NewReader/constants.ts b/src/NewReader/constants.ts new file mode 100644 index 00000000..edbaf57c --- /dev/null +++ b/src/NewReader/constants.ts @@ -0,0 +1,7 @@ +export const MIN_SCALE = 0.25; +export const MAX_SCALE = 5; +export const SCALE_STEP = 0.1; +export const PAGE_GAP = 16; +export const PAGE_PADDING = 32; +export const RENDER_ROOT_MARGIN = '300px 0px 300px 0px'; +export const SCROLLSPY_ANCHOR_RATIO = 0.3; diff --git a/src/NewReader/index.tsx b/src/NewReader/index.tsx new file mode 100644 index 00000000..ba4183f4 --- /dev/null +++ b/src/NewReader/index.tsx @@ -0,0 +1,826 @@ +import { Icon } from '@chakra-ui/react'; +import type { PDFDocumentProxy } from 'pdfjs-dist'; +import { getDocument, GlobalWorkerOptions, version } from 'pdfjs-dist'; +import React, { + KeyboardEvent, + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useReducer, + useRef, + useState, +} from 'react'; +import { DEFAULT_SETTINGS } from '../constants'; +import { + PdfNavigator, + ReaderReturn, + ReaderState, + WebpubManifest, +} from '../types'; +import LoadingSkeleton from '../ui/LoadingSkeleton'; +import PdfPage from './PdfPage'; +import './PdfReader.css'; +import { + PAGE_GAP, + PAGE_PADDING, + RENDER_ROOT_MARGIN, + SCROLLSPY_ANCHOR_RATIO, +} from './constants'; +import { PdfReaderAction, pdfReaderReducer, PdfReaderState } from './reducer'; +import { + FitMode, + OutlineItem, + PageSize, + PdfOutlineEntry, + PdfReaderContentProps, + PdfReaderProps, + ViewportAnchor, +} from './types'; +import { + getDisplayPageHeight, + getManifestTitle, + getManifestTocFromOutline, + getPageTop, + getRotatedSize, + resolveOutline, + resolveResourceUrl, + toError, + toWorkerSafePdfBytes, +} from './utils'; + +GlobalWorkerOptions.workerSrc = `https://cdnjs.cloudflare.com/ajax/libs/pdf.js/${version}/pdf.worker.min.mjs`; + +export const PdfReader = ({ + fileUrl, + file, + data, + pdfWorkerSrc, + className = '', + onDocumentLoad, + onLoadComplete, + onPageChange, + onError, + pageNumber, + navigationRequestId, + scale, + fitMode, + rotation, + dispatch, + pendingAction, + clearPendingAction, + onOutlineLoad, + onPageSizesReady, +}: PdfReaderContentProps): React.ReactElement => { + const [pdfDoc, setPdfDoc] = useState(null); + + useEffect(() => { + if (pdfWorkerSrc) GlobalWorkerOptions.workerSrc = pdfWorkerSrc; + }, [pdfWorkerSrc]); + + const [pageBaseSizes, setPageBaseSizes] = useState([]); + const [visiblePages, setVisiblePages] = useState>(new Set()); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const rootRef = useRef(null); + const viewportWrapRef = useRef(null); + const containerRefs = useRef>(new Map()); + const renderObserverRef = useRef(null); + const currentPageRef = useRef(pageNumber); + const pendingScrollTargetRef = useRef(null); + const lastHandledNavigationRequestRef = useRef(0); + const pendingViewportAnchorRef = useRef(null); + const initialWidthFitTargetScaleRef = useRef(null); + const suppressNextResizeFitRef = useRef(false); + const onDocumentLoadRef = useRef(onDocumentLoad); + const onLoadCompleteRef = useRef(onLoadComplete); + const onErrorRef = useRef(onError); + const onPageSizesReadyRef = useRef(onPageSizesReady); + const [hasInitialWidthFit, setHasInitialWidthFit] = useState(false); + + useEffect(() => { + onDocumentLoadRef.current = onDocumentLoad; + onLoadCompleteRef.current = onLoadComplete; + onErrorRef.current = onError; + onPageSizesReadyRef.current = onPageSizesReady; + }, [onDocumentLoad, onLoadComplete, onError, onPageSizesReady]); + + useEffect(() => { + currentPageRef.current = pageNumber; + }, [pageNumber]); + + useEffect(() => { + return () => { + pdfDoc?.destroy(); + }; + }, [pdfDoc]); + + // Load doc + useEffect(() => { + let cancelled = false; + let loadingTask: ReturnType | null = null; + setLoading(true); + setError(null); + setPdfDoc(null); + onOutlineLoad([]); + setPageBaseSizes([]); + setVisiblePages(new Set()); + setHasInitialWidthFit(false); + initialWidthFitTargetScaleRef.current = null; + containerRefs.current.clear(); + + const loadDocument = async () => { + try { + const resolvedFile = file ?? data; + + if (!resolvedFile && !fileUrl) { + throw new Error('A PDF file/data or fileUrl is required'); + } + + const source = resolvedFile + ? { + data: await toWorkerSafePdfBytes(resolvedFile), + } + : { url: fileUrl, withCredentials: false }; + + loadingTask = getDocument(source); + const doc = await loadingTask.promise; + if (cancelled) return; + + setPdfDoc(doc); + dispatch({ type: 'PAGES_LOADED', numPages: doc.numPages }); + onDocumentLoadRef.current?.(doc); + onLoadCompleteRef.current?.(doc.numPages); + + try { + const rawOutline = (await doc.getOutline()) as + | PdfOutlineEntry[] + | null; + if (rawOutline && !cancelled) { + onOutlineLoad(await resolveOutline(doc, rawOutline)); + } + } catch { + // Outline is optional, ignore failures. + } + } catch (err: unknown) { + if (cancelled) return; + const nextError = toError(err, 'Failed to load PDF document'); + setError(nextError); + onErrorRef.current?.(nextError); + } finally { + if (!cancelled) setLoading(false); + } + }; + + loadDocument(); + + return () => { + cancelled = true; + loadingTask?.destroy?.(); + }; + }, [fileUrl, file, data, dispatch, onOutlineLoad]); + + // Read every page's intrinsic size to determine scroll container's height + // before any page has actually been painted. + // TODO: for very large documents consider windowing this fetch + useEffect(() => { + if (!pdfDoc) return undefined; + let cancelled = false; + + (async () => { + try { + const sizes: PageSize[] = []; + for (let i = 1; i <= pdfDoc.numPages; i += 1) { + if (cancelled) return; + const page = await pdfDoc.getPage(i); + const vp = page.getViewport({ scale: 1, rotation: 0 }); + sizes.push({ width: vp.width, height: vp.height }); + // Hint to pdf.js to release temporary resources for this page. + page.cleanup(); + } + if (cancelled) return; + setPageBaseSizes(sizes); + onPageSizesReadyRef.current?.(); + } catch (err: unknown) { + if (cancelled) return; + const nextError = toError(err, 'Failed to read page dimensions'); + setError(nextError); + onErrorRef.current?.(nextError); + } + })(); + + return () => { + cancelled = true; + }; + }, [pdfDoc]); + + // Lazy render: observe which pages are near the viewport + useEffect(() => { + const wrap = viewportWrapRef.current; + if (!wrap || !pageBaseSizes.length) return undefined; + + const observer = new IntersectionObserver( + (entries) => { + setVisiblePages((prev) => { + let changed = false; + const next = new Set(prev); + entries.forEach((entry) => { + const pn = Number((entry.target as HTMLElement).dataset.pageNumber); + if (!pn) return; + if (entry.isIntersecting && !next.has(pn)) { + next.add(pn); + changed = true; + } else if (!entry.isIntersecting && next.has(pn)) { + next.delete(pn); + changed = true; + } + }); + return changed ? next : prev; + }); + }, + { root: wrap, rootMargin: RENDER_ROOT_MARGIN, threshold: 0 } + ); + + renderObserverRef.current = observer; + containerRefs.current.forEach((el) => observer.observe(el)); + + return () => { + observer.disconnect(); + renderObserverRef.current = null; + }; + }, [pageBaseSizes.length]); + + const registerContainer = useCallback( + (pageNumber: number, el: HTMLDivElement | null) => { + if (el) { + containerRefs.current.set(pageNumber, el); + renderObserverRef.current?.observe(el); + } else { + const existing = containerRefs.current.get(pageNumber); + if (existing && renderObserverRef.current) { + renderObserverRef.current.unobserve(existing); + } + containerRefs.current.delete(pageNumber); + } + }, + [] + ); + + // Scrollspy: derive "current page" from scroll position + useEffect(() => { + const wrap = viewportWrapRef.current; + if (!wrap || !pageBaseSizes.length) return undefined; + + let ticking = false; + + const update = () => { + ticking = false; + if (pendingScrollTargetRef.current != null) { + const targetEl = containerRefs.current.get( + pendingScrollTargetRef.current + ); + if (targetEl && Math.abs(targetEl.offsetTop - wrap.scrollTop) < 4) { + pendingScrollTargetRef.current = null; + } + } + + const anchor = + wrap.scrollTop + wrap.clientHeight * SCROLLSPY_ANCHOR_RATIO; + let best = currentPageRef.current; + let bestTop = -Infinity; + containerRefs.current.forEach((el, pn) => { + if (el.offsetTop <= anchor && el.offsetTop > bestTop) { + bestTop = el.offsetTop; + best = pn; + } + }); + + if (best !== currentPageRef.current) + dispatch({ type: 'PAGE_IN_VIEW', page: best }); + }; + + const onScroll = () => { + if (!ticking) { + ticking = true; + requestAnimationFrame(update); + } + }; + + wrap.addEventListener('scroll', onScroll); + update(); + + return () => wrap.removeEventListener('scroll', onScroll); + }, [dispatch, pageBaseSizes.length]); + + useEffect(() => { + const wrap = viewportWrapRef.current; + if (!wrap || !pageBaseSizes.length || navigationRequestId <= 0) return; + if (navigationRequestId === lastHandledNavigationRequestRef.current) return; + + const target = containerRefs.current.get(pageNumber); + if (!target) return; + + // Ensure the destination page (and immediate neighbors) are considered + // visible right away so rendering starts before IntersectionObserver settles. + setVisiblePages((prev) => { + const next = new Set(prev); + next.add(pageNumber); + if (pageNumber > 1) next.add(pageNumber - 1); + if (pageNumber < pageBaseSizes.length) next.add(pageNumber + 1); + return next; + }); + + pendingScrollTargetRef.current = pageNumber; + target.scrollIntoView({ block: 'start', behavior: 'auto' }); + lastHandledNavigationRequestRef.current = navigationRequestId; + }, [navigationRequestId, pageBaseSizes.length, pageNumber]); + + useEffect(() => { + onPageChange?.(pageNumber); + }, [onPageChange, pageNumber]); + + // Viewport anchor capture + // Records what content is currently visible so that after a layout-affecting + // change (scale/rotation) the scroll position is restored. + const captureViewportAnchor = useCallback(() => { + if (pendingViewportAnchorRef.current) return; + + const wrap = viewportWrapRef.current; + if (!wrap || !pageBaseSizes.length) return; + + const viewportOffset = wrap.clientHeight * SCROLLSPY_ANCHOR_RATIO; + const anchorY = wrap.scrollTop + viewportOffset; + + let anchorPage = 1; + let intraPageRatio = 0; + + for (let i = 1; i <= pageBaseSizes.length; i += 1) { + const top = getPageTop(pageBaseSizes, i, scale, rotation); + const height = getDisplayPageHeight(pageBaseSizes, i, scale, rotation); + if (anchorY <= top + height || i === pageBaseSizes.length) { + anchorPage = i; + intraPageRatio = height > 0 ? (anchorY - top) / height : 0; + break; + } + } + + pendingViewportAnchorRef.current = { + pageNumber: anchorPage, + intraPageRatio: Math.max(0, Math.min(1, intraPageRatio)), + viewportOffset, + }; + }, [pageBaseSizes, scale, rotation]); + + // Fit-to-width/height + const computeFitScaleValue = useCallback( + (mode: FitMode, rotationForCalc: number): number | null => { + if (!mode || !pageBaseSizes.length) return null; + const wrap = viewportWrapRef.current; + if (!wrap) return null; + + const refPageNumber = Math.min( + Math.max(currentPageRef.current, 1), + pageBaseSizes.length + ); + const base = pageBaseSizes[refPageNumber - 1]; + const rotated = getRotatedSize(base, rotationForCalc); + + const widestRotatedPageWidth = pageBaseSizes.reduce((maxWidth, page) => { + const nextWidth = getRotatedSize(page, rotationForCalc).width; + return Math.max(maxWidth, nextWidth); + }, 0); + + const availWidth = wrap.clientWidth - PAGE_PADDING; + const availHeight = wrap.clientHeight - PAGE_PADDING; + + const newScale = + mode === 'width' + ? availWidth / widestRotatedPageWidth + : availHeight / rotated.height; + return newScale > 0 ? newScale : null; + }, + [pageBaseSizes] + ); + + const scaleRef = useRef(scale); + useEffect(() => { + scaleRef.current = scale; + }, [scale]); + + const applyFitScale = useCallback( + (mode: FitMode) => { + const newScale = computeFitScaleValue(mode, rotation); + if (newScale == null) return; + + const relativeDelta = + scaleRef.current > 0 + ? Math.abs(scaleRef.current - newScale) / scaleRef.current + : 1; + if (relativeDelta < 0.005) return; + + captureViewportAnchor(); + + suppressNextResizeFitRef.current = true; + dispatch({ type: 'SET_SCALE', scale: newScale }); + }, + [computeFitScaleValue, rotation, captureViewportAnchor, dispatch] + ); + + useLayoutEffect(() => { + const anchor = pendingViewportAnchorRef.current; + const wrap = viewportWrapRef.current; + if (!anchor || !wrap || !pageBaseSizes.length) return; + + const top = getPageTop(pageBaseSizes, anchor.pageNumber, scale, rotation); + const height = getDisplayPageHeight( + pageBaseSizes, + anchor.pageNumber, + scale, + rotation + ); + const anchorY = top + height * anchor.intraPageRatio; + const nextScrollTop = Math.max(0, anchorY - anchor.viewportOffset); + wrap.scrollTop = nextScrollTop; + + pendingViewportAnchorRef.current = null; + }, [scale, rotation, pageBaseSizes]); + + useEffect(() => { + if (!pendingAction) return; + + if (pendingAction.type === 'ZOOM_IN' || pendingAction.type === 'ZOOM_OUT') { + captureViewportAnchor(); + dispatch({ type: pendingAction.type }); + } + + if (pendingAction.type === 'ROTATE_CCW') { + captureViewportAnchor(); + + const nextRotation = (rotation + 270) % 360; + const nextScale = fitMode + ? computeFitScaleValue(fitMode, nextRotation) + : undefined; + if (nextScale != null) suppressNextResizeFitRef.current = true; + dispatch({ type: 'ROTATE_CCW', nextScale: nextScale ?? undefined }); + } + + clearPendingAction(); + }, [ + captureViewportAnchor, + clearPendingAction, + computeFitScaleValue, + fitMode, + pendingAction, + rotation, + dispatch, + ]); + + useEffect(() => { + if (fitMode) applyFitScale(fitMode); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [fitMode, pageBaseSizes]); + + useLayoutEffect(() => { + if (hasInitialWidthFit) return; + if (fitMode !== 'width') { + setHasInitialWidthFit(true); + return; + } + if (!pageBaseSizes.length) return; + + const targetScale = computeFitScaleValue('width', rotation); + if (targetScale == null) return; + + initialWidthFitTargetScaleRef.current = targetScale; + + const relativeDelta = scale > 0 ? Math.abs(scale - targetScale) / scale : 1; + + if (relativeDelta < 0.005) { + setHasInitialWidthFit(true); + initialWidthFitTargetScaleRef.current = null; + return; + } + + suppressNextResizeFitRef.current = true; + dispatch({ type: 'SET_SCALE', scale: targetScale }); + }, [ + computeFitScaleValue, + dispatch, + fitMode, + hasInitialWidthFit, + pageBaseSizes.length, + rotation, + scale, + ]); + + useEffect(() => { + if (hasInitialWidthFit) return; + const targetScale = initialWidthFitTargetScaleRef.current; + if (targetScale == null) return; + + const relativeDelta = + targetScale > 0 ? Math.abs(scale - targetScale) / targetScale : 0; + if (relativeDelta < 0.005) { + setHasInitialWidthFit(true); + initialWidthFitTargetScaleRef.current = null; + } + }, [hasInitialWidthFit, scale]); + + // Re-apply fit scale on container resize. + useEffect(() => { + const wrap = viewportWrapRef.current; + if (!wrap) return undefined; + const ro = new ResizeObserver(() => { + if (suppressNextResizeFitRef.current) { + suppressNextResizeFitRef.current = false; + return; + } + if (fitMode) applyFitScale(fitMode); + }); + ro.observe(wrap); + return () => ro.disconnect(); + }, [fitMode, applyFitScale]); + + // Keyboard shortcuts + useEffect(() => { + const handler = (e: KeyboardEvent) => { + if (!rootRef.current?.contains(document.activeElement)) return; + if (e.target instanceof HTMLInputElement) return; + if (e.key === 'ArrowRight' || e.key === 'PageDown') { + dispatch({ type: 'GO_FORWARD' }); + } + if (e.key === 'ArrowLeft' || e.key === 'PageUp') { + dispatch({ type: 'GO_BACKWARD' }); + } + if (e.key === '+' || e.key === '=') { + captureViewportAnchor(); + dispatch({ type: 'ZOOM_IN' }); + } + if (e.key === '-') { + captureViewportAnchor(); + dispatch({ type: 'ZOOM_OUT' }); + } + }; + window.addEventListener('keydown', handler as unknown as EventListener); + return () => + window.removeEventListener( + 'keydown', + handler as unknown as EventListener + ); + }, [captureViewportAnchor, dispatch, pageNumber]); + + const requestGoToPage = useCallback( + (page: number) => dispatch({ type: 'GO_TO_PAGE', page }), + [dispatch] + ); + + return ( +
+
+
+ {loading && ( +
+
+ +
+
+ )} + {!loading && error && ( +
+
+ + + + {error.message || 'Failed to load PDF'} +
+
+ )} + {!loading && !error && pdfDoc && pageBaseSizes.length > 0 && ( +
+ {pageBaseSizes.map((size, idx) => { + const pageNumber = idx + 1; + return ( + + ); + })} +
+ )} +
+
+
+ ); +}; + +export function useNewReader({ + fileUrl: fileUrlProp, + file, + data, + webpubManifestUrl, + manifest: inputManifest, + proxyUrl, + pdfWorkerSrc, + height = '100%', + initialPage = 1, + initialScale = 1, + initialFit = 'width', + showToc = true, + className = '', + onDocumentLoad, + onLoadComplete, + onPageChange, + onError, + toggleFullScreen, +}: PdfReaderProps): Exclude { + // Resolve fileUrl from manifest when webpubManifestUrl + manifest are provided + const fileUrl = useMemo(() => { + if (fileUrlProp) return fileUrlProp; + if (webpubManifestUrl && inputManifest) { + return resolveResourceUrl(inputManifest, proxyUrl); + } + return undefined; + }, [fileUrlProp, webpubManifestUrl, inputManifest, proxyUrl]); + const [viewerState, dispatch] = useReducer(pdfReaderReducer, { + pageNumber: initialPage, + numPages: 0, + scale: initialScale, + fitMode: initialFit, + rotation: 0, + navigationRequestId: 0, + } satisfies PdfReaderState); + const { + pageNumber, + numPages, + scale, + fitMode, + rotation, + navigationRequestId, + } = viewerState; + const [outline, setOutline] = useState([]); + const [pendingAction, setPendingAction] = useState( + null + ); + const [pdfLoadFailed, setPdfLoadFailed] = useState(false); + const [pageSizesReady, setPageSizesReady] = useState(false); + + useEffect(() => { + setPdfLoadFailed(false); + setPageSizesReady(false); + }, [fileUrl, file, data]); + + const wrappedOnPageSizesReady = useCallback(() => { + setPageSizesReady(true); + }, []); + + const wrappedOnError = useCallback( + (error: Error) => { + setPdfLoadFailed(true); + onError?.(error); + }, + [onError] + ); + + useEffect(() => { + dispatch({ type: 'GO_TO_PAGE', page: initialPage }); + }, [initialPage]); + + useEffect(() => { + dispatch({ type: 'SET_SCALE', scale: initialScale }); + }, [initialScale]); + + useEffect(() => { + dispatch({ type: 'SET_FIT', mode: initialFit }); + }, [initialFit]); + + const clearPendingAction = useCallback(() => setPendingAction(null), []); + + const state: ReaderState = useMemo( + () => ({ + atStart: pageNumber <= 1, + atEnd: pageNumber >= numPages, + settings: DEFAULT_SETTINGS, + fitMode: fitMode ?? 'width', + rotation, + }), + [fitMode, numPages, pageNumber, rotation] + ); + + const navigator: PdfNavigator = useMemo( + () => ({ + goForward: () => dispatch({ type: 'GO_FORWARD' }), + goBackward: () => dispatch({ type: 'GO_BACKWARD' }), + setScroll: async () => undefined, + goToPage: (href: string) => dispatch({ type: 'GO_TO_HREF', href }), + goToPageNumber: (page: number) => dispatch({ type: 'GO_TO_PAGE', page }), + setFitMode: (mode) => dispatch({ type: 'SET_FIT', mode }), + zoomIn: async () => setPendingAction({ type: 'ZOOM_IN' }), + zoomOut: async () => setPendingAction({ type: 'ZOOM_OUT' }), + rotateCounterClockwise: () => setPendingAction({ type: 'ROTATE_CCW' }), + }), + [] + ); + + const fileName = file instanceof File ? file.name : undefined; + const manifestTitle = getManifestTitle('PDF Document', fileName); + + const manifest: WebpubManifest = useMemo(() => { + if (inputManifest) { + return { + ...inputManifest, + toc: + showToc && outline.length > 0 + ? getManifestTocFromOutline(outline) + : (inputManifest.toc ?? []), + }; + } + return { + metadata: { + title: manifestTitle, + }, + links: [], + readingOrder: [], + toc: showToc ? getManifestTocFromOutline(outline) : [], + }; + }, [inputManifest, manifestTitle, outline, showToc]); + + const hasSource = !!(fileUrl || file || data); + const isDocLoading = hasSource && !pageSizesReady && !pdfLoadFailed; + + const content = ( +
+ {isDocLoading && ( +
+ +
+ )} + +
+ ); + + return { + type: 'PDF', + isLoading: isDocLoading, + content, + state, + navigator, + manifest, + currentPage: pageNumber, + totalPages: numPages, + toggleFullScreen, + }; +} + +export default function NewReader(props: PdfReaderProps): React.ReactElement { + const reader = useNewReader(props); + + if (!props.fileUrl && !props.file && !props.data) { + return ; + } + + return reader.content; +} diff --git a/src/NewReader/reducer.ts b/src/NewReader/reducer.ts new file mode 100644 index 00000000..dfb86b94 --- /dev/null +++ b/src/NewReader/reducer.ts @@ -0,0 +1,119 @@ +import { MAX_SCALE, MIN_SCALE, SCALE_STEP } from './constants'; +import { FitMode } from './types'; + +export type PdfReaderState = { + pageNumber: number; + numPages: number; + scale: number; + fitMode: FitMode; + rotation: number; + navigationRequestId: number; +}; + +export type PdfReaderAction = + | { type: 'GO_FORWARD' } + | { type: 'GO_BACKWARD' } + | { type: 'GO_TO_PAGE'; page: number } + | { type: 'GO_TO_HREF'; href: string } + | { type: 'PAGE_IN_VIEW'; page: number } + | { type: 'PAGES_LOADED'; numPages: number } + | { type: 'ZOOM_IN' } + | { type: 'ZOOM_OUT' } + | { type: 'ROTATE_CCW'; nextScale?: number } + | { type: 'SET_FIT'; mode: FitMode } + | { type: 'SET_SCALE'; scale: number }; + +function handleInvalidTransition( + state: PdfReaderState, + action: PdfReaderAction +): PdfReaderState { + console.trace( + `Invalid state transition attempted with action: ${action.type}` + ); + return state; +} + +export function pdfReaderReducer( + state: PdfReaderState, + action: PdfReaderAction +): PdfReaderState { + switch (action.type) { + case 'GO_FORWARD': { + const next = state.pageNumber + 1; + if (next > state.numPages) return state; + return { + ...state, + pageNumber: next, + navigationRequestId: state.navigationRequestId + 1, + }; + } + + case 'GO_BACKWARD': { + const prev = state.pageNumber - 1; + if (prev < 1) return state; + return { + ...state, + pageNumber: prev, + navigationRequestId: state.navigationRequestId + 1, + }; + } + + case 'GO_TO_PAGE': { + const numPages = state.numPages || 1; + const page = Math.max(1, Math.min(action.page, numPages)); + if (page === state.pageNumber) return state; + return { + ...state, + pageNumber: page, + navigationRequestId: state.navigationRequestId + 1, + }; + } + + case 'GO_TO_HREF': { + const parsed = parseInt(action.href, 10); + if (Number.isNaN(parsed)) return handleInvalidTransition(state, action); + const numPages = state.numPages || 1; + const page = Math.max(1, Math.min(parsed, numPages)); + return { + ...state, + pageNumber: page, + navigationRequestId: state.navigationRequestId + 1, + }; + } + + case 'PAGE_IN_VIEW': { + if (state.pageNumber === action.page) return state; + return { ...state, pageNumber: action.page }; + } + + case 'PAGES_LOADED': + return { ...state, numPages: action.numPages }; + + case 'ZOOM_IN': + return { + ...state, + fitMode: null, + scale: Math.min(MAX_SCALE, +(state.scale + SCALE_STEP).toFixed(2)), + }; + + case 'ZOOM_OUT': + return { + ...state, + fitMode: null, + scale: Math.max(MIN_SCALE, +(state.scale - SCALE_STEP).toFixed(2)), + }; + + case 'ROTATE_CCW': + return { + ...state, + rotation: (state.rotation + 270) % 360, + scale: action.nextScale ?? state.scale, + }; + + case 'SET_FIT': + return { ...state, fitMode: action.mode }; + + case 'SET_SCALE': + return { ...state, scale: action.scale }; + } +} diff --git a/src/NewReader/types.ts b/src/NewReader/types.ts new file mode 100644 index 00000000..20e83a2e --- /dev/null +++ b/src/NewReader/types.ts @@ -0,0 +1,81 @@ +import { PDFDocumentProxy } from 'pdfjs-dist'; +import { Dispatch } from 'react'; +import { WebpubManifest } from '../types'; +import { PdfReaderAction } from './reducer'; + +export type FitMode = 'width' | 'height' | null; + +export interface OutlineItem { + title: string; + pageNumber: number | null; + items: OutlineItem[]; +} + +export interface PageSize { + width: number; + height: number; +} + +export interface ViewportAnchor { + pageNumber: number; + intraPageRatio: number; + viewportOffset: number; +} + +export interface PdfReaderProps { + fileUrl?: string; + file?: Blob | File | Uint8Array | ArrayBuffer; + data?: Uint8Array | ArrayBuffer; + webpubManifestUrl?: string; + manifest?: WebpubManifest; + proxyUrl?: string; + pdfWorkerSrc?: string; + height?: string | number; + initialPage?: number; + initialScale?: number; + initialFit?: FitMode; + showToc?: boolean; + className?: string; + onDocumentLoad?: (pdfDoc: PDFDocumentProxy) => void; + onLoadComplete?: (numPages: number) => void; + onPageChange?: (pageNumber: number) => void; + onError?: (error: Error) => void; + toggleFullScreen?: () => void; +} + +export interface PdfReaderContentProps { + fileUrl?: string; + file?: Blob | File | Uint8Array | ArrayBuffer; + data?: Uint8Array | ArrayBuffer; + pdfWorkerSrc?: string; + className?: string; + onDocumentLoad?: (pdfDoc: PDFDocumentProxy) => void; + onLoadComplete?: (numPages: number) => void; + onPageChange?: (pageNumber: number) => void; + onError?: (error: Error) => void; + pageNumber: number; + navigationRequestId: number; + scale: number; + fitMode: FitMode; + rotation: number; + dispatch: Dispatch; + pendingAction: PdfReaderAction | null; + clearPendingAction: () => void; + onOutlineLoad: (outlineItems: OutlineItem[]) => void; + onPageSizesReady?: () => void; +} + +export interface RenderTask { + promise: Promise; + cancel: () => void; +} + +export interface PdfOutlineEntry { + title: string; + dest: string | unknown[] | null; + items?: PdfOutlineEntry[]; +} + +export type RefProxy = import('pdfjs-dist/types/src/display/api').RefProxy; + +export type PdfBinaryInput = Blob | File | Uint8Array | ArrayBuffer; diff --git a/src/NewReader/utils.ts b/src/NewReader/utils.ts new file mode 100644 index 00000000..d17ae2bb --- /dev/null +++ b/src/NewReader/utils.ts @@ -0,0 +1,119 @@ +import type { PDFDocumentProxy } from 'pdfjs-dist'; +import { WebpubManifest } from '../types'; +import { ReadiumLink } from '../WebpubManifestTypes/ReadiumLink'; +import { PAGE_GAP } from './constants'; +import { + OutlineItem, + PageSize, + PdfBinaryInput, + PdfOutlineEntry, + RefProxy, +} from './types'; + +export const toError = (err: unknown, fallbackMessage: string): Error => { + if (err instanceof Error) return err; + return new Error(fallbackMessage); +}; + +export const toWorkerSafePdfBytes = async ( + input: PdfBinaryInput +): Promise => { + if (input instanceof Blob) { + return new Uint8Array(await input.arrayBuffer()); + } + + if (input instanceof ArrayBuffer) { + return new Uint8Array(input.slice(0)); + } + + return new Uint8Array(input); +}; + +export const getRotatedSize = (base: PageSize, rotation: number): PageSize => { + return rotation % 180 !== 0 + ? { width: base.height, height: base.width } + : base; +}; + +export const getDisplayPageHeight = ( + pageBaseSizes: PageSize[], + pageNumber: number, + scale: number, + rotation: number +): number => { + const base = pageBaseSizes[pageNumber - 1]; + if (!base) return 0; + const rotated = getRotatedSize(base, rotation); + return rotated.height * scale; +}; + +export const getPageTop = ( + pageBaseSizes: PageSize[], + pageNumber: number, + scale: number, + rotation: number +): number => { + let top = PAGE_GAP; + for (let i = 1; i < pageNumber; i += 1) { + top += getDisplayPageHeight(pageBaseSizes, i, scale, rotation) + PAGE_GAP; + } + return top; +}; + +/** + * Recursively resolves a pdf.js outline (TOC) into page numbers. + */ +export const resolveOutline = async ( + pdfDoc: PDFDocumentProxy, + items: PdfOutlineEntry[] +): Promise => { + const resolved: OutlineItem[] = []; + for (const item of items) { + let pageNumber: number | null = null; + try { + let dest = item.dest; + if (typeof dest === 'string') { + dest = await pdfDoc.getDestination(dest); + } + if (Array.isArray(dest)) { + const pageRef = dest[0] as RefProxy; + const pageIndex = await pdfDoc.getPageIndex(pageRef); + pageNumber = pageIndex + 1; + } + } catch { + // Some destinations can't be resolved (e.g. external links), skip. + } + resolved.push({ + title: item.title, + pageNumber, + items: item.items?.length ? await resolveOutline(pdfDoc, item.items) : [], + }); + } + + return resolved; +}; + +export const getManifestTitle = (fallback: string, title?: string): string => { + if (!title || !title.trim()) return fallback; + return title.trim(); +}; + +export const getManifestTocFromOutline = ( + outline: OutlineItem[] +): ReadiumLink[] => { + return outline + .filter((item) => item.pageNumber != null) + .map((item) => ({ + href: String(item.pageNumber), + title: item.title, + })); +}; + +export const resolveResourceUrl = ( + manifest: WebpubManifest, + proxyUrl?: string +): string | undefined => { + const href = manifest?.readingOrder?.[0]?.href; + if (!href) return undefined; + return proxyUrl ? `${proxyUrl}${encodeURIComponent(href)}` : href; +}; diff --git a/src/index.tsx b/src/index.tsx index 42f43f14..242c69b1 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -39,6 +39,7 @@ export default WebReader; export * from './constants'; export { default as useHtmlReader } from './HtmlReader'; +export { default as NewReader, useNewReader } from './NewReader'; export { default as usePdfReader } from './PdfReader'; export { default as addTocToManifest } from './PdfReader/addTocToManifest'; export { default as useColorModeValue } from './ui/hooks/useColorModeValue'; diff --git a/src/types.ts b/src/types.ts index 2e893a0c..c09e5ae2 100644 --- a/src/types.ts +++ b/src/types.ts @@ -72,7 +72,7 @@ export type LoadingReader = { }; type CommonReader = { - isLoading: false; + isLoading: boolean; content: JSX.Element; manifest: WebpubManifest; currentPage: number; diff --git a/src/useWebReader.tsx b/src/useWebReader.tsx index 82402c1a..fbd832da 100644 --- a/src/useWebReader.tsx +++ b/src/useWebReader.tsx @@ -4,7 +4,7 @@ import { DEFAULT_SHOULD_GROW_WHEN_SCROLLING, } from './constants'; import useHtmlReader from './HtmlReader'; -import usePdfReader from './PdfReader'; +import { useNewReader } from './NewReader'; import { GetContent, HTMLActiveReader, @@ -70,12 +70,11 @@ export default function useWebReader( manifest ? manifest.metadata.conformsTo : null ); const getContentHtml = getContent as GetContent | undefined; - const getContentPdf = getContent as GetContent | undefined; /** - * Our HTML reader and PDf Reader. Note that we cannot conditionally + * Our HTML reader and PDF Reader. Note that we cannot conditionally * call a React hook, so we must _always_ call the hook, but allow for the - * case where we call the hook with `undefined`, which tells the hook that - * that format is inactive, and it will in turn return the InactiveState. + * case where we call the hook with no source, which tells the hook that + * that format is inactive. */ const htmlReader = useHtmlReader( readerType === 'HTML' && manifest @@ -94,21 +93,17 @@ export default function useWebReader( : undefined ); - const pdfReader = usePdfReader( + const newPdfReader = useNewReader( readerType === 'PDF' && manifest ? { webpubManifestUrl, manifest, - getContent: getContentPdf, proxyUrl, pdfWorkerSrc, height, - growWhenScrolling, - persistLastLocation, - persistSettings, toggleFullScreen, } - : undefined + : {} ); // fetch the manifest and set it in state @@ -136,8 +131,8 @@ export default function useWebReader( if (htmlReader) { return htmlReader; } - if (pdfReader) { - return pdfReader; + if (newPdfReader) { + return newPdfReader; } throw new Error( From b0de46c04279d622eccd70d1ebdef5c106944abc Mon Sep 17 00:00:00 2001 From: Jackie Quach Date: Tue, 11 Aug 2026 15:45:07 -0400 Subject: [PATCH 02/11] remove unrelated change --- example/index.tsx | 9 --------- src/NewReader/PdfPage.tsx | 2 +- 2 files changed, 1 insertion(+), 10 deletions(-) diff --git a/example/index.tsx b/example/index.tsx index 238a16ca..38718508 100644 --- a/example/index.tsx +++ b/example/index.tsx @@ -249,15 +249,6 @@ const fetchAndModifyManifest: Fetcher = async (url) => { return syntheticUrl; }; -const fetchAsUrl = async ( - resourceUrl: string, - proxyUrl?: string -): Promise => { - return proxyUrl - ? `${proxyUrl}${encodeURIComponent(resourceUrl)}` - : resourceUrl; -}; - const SingleResourcePdf = () => { const { data: modifiedManifestUrl, isLoading } = useSWR( '/samples/pdf/single-resource-short.json', diff --git a/src/NewReader/PdfPage.tsx b/src/NewReader/PdfPage.tsx index e8cb0354..1c763bfa 100644 --- a/src/NewReader/PdfPage.tsx +++ b/src/NewReader/PdfPage.tsx @@ -224,7 +224,7 @@ const PdfPage = React.memo(function PdfPage({ div: annotationLayerDiv, annotations, page, - linkService, // properly typed here via AnnotationLayerParameters + linkService, annotationStorage: undefined, renderForms: true, }); From 00415c9eb31eff05eb4106e96899f641ef36d334 Mon Sep 17 00:00:00 2001 From: Jackie Quach Date: Mon, 17 Aug 2026 14:42:51 -0400 Subject: [PATCH 03/11] mobile header and cleanup styles --- example/index.tsx | 7 +- example/use-new-pdf-reader.tsx | 15 +- src/NewReader/PdfReader.css | 33 +-- src/NewReader/index.tsx | 25 +-- src/ui/Header.tsx | 384 +++++++++++++++++++++++---------- src/ui/LoadingSkeleton.tsx | 8 +- 6 files changed, 294 insertions(+), 178 deletions(-) diff --git a/example/index.tsx b/example/index.tsx index 38718508..b0095c15 100644 --- a/example/index.tsx +++ b/example/index.tsx @@ -123,19 +123,20 @@ const PdfReaders = () => { - NewReader + UseNewPdfReader New Pdf Reader using pdf.js directly instead of react-pdf. = ({ manifest, proxyUrl, pdfWorkerSrc, + height: '80vh', }); const containerRef = React.useRef(null); return ( - +
{newReader.type &&
} - - {newReader.content} - - + {newReader.content} +
); }; diff --git a/src/NewReader/PdfReader.css b/src/NewReader/PdfReader.css index c088691a..a19ad0ae 100644 --- a/src/NewReader/PdfReader.css +++ b/src/NewReader/PdfReader.css @@ -6,8 +6,6 @@ min-height: 400px; background: #525659; color: #e8eaed; - font-family: - -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; overflow: hidden; } @@ -160,6 +158,7 @@ overflow: auto; position: relative; background: #252525; + color-scheme: dark; } /* Loading / error states */ @@ -224,7 +223,6 @@ } .pdf-text-layer.textLayer { - color-scheme: only light; position: absolute; text-align: initial; inset: 0; @@ -275,25 +273,11 @@ } .pdf-text-layer.textLayer .highlight { - --highlight-bg-color: rgb(180 0 170 / 0.25); - --highlight-selected-bg-color: rgb(0 100 0 / 0.25); - --highlight-backdrop-filter: none; - --highlight-selected-backdrop-filter: none; - margin: -1px; padding: 1px; - background-color: var(--highlight-bg-color); - backdrop-filter: var(--highlight-backdrop-filter); border-radius: 4px; } -@media screen and (forced-colors: active) { - .pdf-text-layer.textLayer .highlight { - --highlight-bg-color: Highlight; - --highlight-selected-bg-color: ButtonText; - } -} - .pdf-text-layer.textLayer .highlight.appended { position: initial; } @@ -310,21 +294,6 @@ border-radius: 0; } -.pdf-text-layer.textLayer .highlight.selected { - background-color: var(--highlight-selected-bg-color); - backdrop-filter: var(--highlight-selected-backdrop-filter); -} - -.pdf-text-layer.textLayer ::selection { - background: rgba(0, 0, 255, 0.25); -} - -@supports (background: color-mix(in srgb, AccentColor, transparent 75%)) { - .pdf-text-layer.textLayer ::selection { - background: color-mix(in srgb, AccentColor, transparent 75%); - } -} - .pdf-text-layer.textLayer br::selection { background: transparent; } diff --git a/src/NewReader/index.tsx b/src/NewReader/index.tsx index ba4183f4..d58687cb 100644 --- a/src/NewReader/index.tsx +++ b/src/NewReader/index.tsx @@ -11,7 +11,7 @@ import React, { useRef, useState, } from 'react'; -import { DEFAULT_SETTINGS } from '../constants'; +import { DEFAULT_HEIGHT, DEFAULT_SETTINGS } from '../constants'; import { PdfNavigator, ReaderReturn, @@ -56,7 +56,6 @@ export const PdfReader = ({ file, data, pdfWorkerSrc, - className = '', onDocumentLoad, onLoadComplete, onPageChange, @@ -82,6 +81,7 @@ export const PdfReader = ({ const [visiblePages, setVisiblePages] = useState>(new Set()); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + const [hasInitialWidthFit, setHasInitialWidthFit] = useState(false); const rootRef = useRef(null); const viewportWrapRef = useRef(null); @@ -97,7 +97,6 @@ export const PdfReader = ({ const onLoadCompleteRef = useRef(onLoadComplete); const onErrorRef = useRef(onError); const onPageSizesReadyRef = useRef(onPageSizesReady); - const [hasInitialWidthFit, setHasInitialWidthFit] = useState(false); useEffect(() => { onDocumentLoadRef.current = onDocumentLoad; @@ -553,14 +552,6 @@ export const PdfReader = ({ if (e.key === 'ArrowLeft' || e.key === 'PageUp') { dispatch({ type: 'GO_BACKWARD' }); } - if (e.key === '+' || e.key === '=') { - captureViewportAnchor(); - dispatch({ type: 'ZOOM_IN' }); - } - if (e.key === '-') { - captureViewportAnchor(); - dispatch({ type: 'ZOOM_OUT' }); - } }; window.addEventListener('keydown', handler as unknown as EventListener); return () => @@ -576,7 +567,13 @@ export const PdfReader = ({ ); return ( -
+
- - - {type === 'PDF' && } - {type === 'HTML' && ( - - )} - - - - {type === 'PDF' && ( - + )} + - )} - - - - - - + {type === 'PDF' && ( + + + + )} + + - - / - {totalPages} - - - + + - - - - - - - - - + + + + + + - + + + + {type === 'HTML' && } + + + ) : ( + + + + + + + + + / + {totalPages} + + + + + + +
+ + + {type === 'PDF' && } + {type === 'HTML' && ( + + )} + + + + {type === 'PDF' && ( + + + + )} + + + + - - - {type === 'HTML' && } - - + + + + {type === 'HTML' && } + +
+ + )} ); } diff --git a/src/ui/LoadingSkeleton.tsx b/src/ui/LoadingSkeleton.tsx index d8b86f08..8d095e1c 100644 --- a/src/ui/LoadingSkeleton.tsx +++ b/src/ui/LoadingSkeleton.tsx @@ -31,10 +31,12 @@ const LoadingSkeletonContent = ({ role="progressbar" > - + - - + + + + ); From 1bd594dda73f6ee5187c86279173c46d796ba03f Mon Sep 17 00:00:00 2001 From: Jackie Quach Date: Mon, 17 Aug 2026 16:49:33 -0400 Subject: [PATCH 04/11] mock matchMedia --- tests/Header.test.tsx | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/tests/Header.test.tsx b/tests/Header.test.tsx index 409f2dc6..6e10f046 100644 --- a/tests/Header.test.tsx +++ b/tests/Header.test.tsx @@ -5,6 +5,20 @@ import { MockHtmlReaderProps } from './utils/MockData'; import { axe } from 'jest-axe'; +beforeEach(() => { + Object.defineProperty(window, 'matchMedia', { + writable: true, + value: jest.fn().mockImplementation((query) => ({ + matches: false, + media: query, + onchange: null, + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + dispatchEvent: jest.fn(), + })), + }); +}); + describe('Header Accessibility checker', () => { test('header component should have no violation', async () => { const containerRef = React.createRef(); From bb5148bf8cf95554163625537706f1a2bc05c975 Mon Sep 17 00:00:00 2001 From: Jackie Quach Date: Tue, 18 Aug 2026 15:22:34 -0400 Subject: [PATCH 05/11] more cleanup and test updates --- playwright/pageobjects/web-reader.page.ts | 2 +- src/NewReader/index.tsx | 146 +++++++++------------- src/NewReader/types.ts | 20 +-- 3 files changed, 59 insertions(+), 109 deletions(-) diff --git a/playwright/pageobjects/web-reader.page.ts b/playwright/pageobjects/web-reader.page.ts index 96ba9126..832d5ec4 100644 --- a/playwright/pageobjects/web-reader.page.ts +++ b/playwright/pageobjects/web-reader.page.ts @@ -205,7 +205,7 @@ class PdfReaderPage extends WebReaderPage { async getZoomValue(): Promise { return await this.page - .locator('canvas:visible') + .locator('.textLayer:visible') .first() .evaluate((el) => { return Number( diff --git a/src/NewReader/index.tsx b/src/NewReader/index.tsx index d58687cb..115f7b33 100644 --- a/src/NewReader/index.tsx +++ b/src/NewReader/index.tsx @@ -11,7 +11,12 @@ import React, { useRef, useState, } from 'react'; -import { DEFAULT_HEIGHT, DEFAULT_SETTINGS } from '../constants'; +import { getPageNumberFromHref } from '../PdfReader/lib'; +import { + DEFAULT_HEIGHT, + DEFAULT_SETTINGS, + MAIN_CONTENT_ID, +} from '../constants'; import { PdfNavigator, ReaderReturn, @@ -46,20 +51,13 @@ import { resolveOutline, resolveResourceUrl, toError, - toWorkerSafePdfBytes, } from './utils'; GlobalWorkerOptions.workerSrc = `https://cdnjs.cloudflare.com/ajax/libs/pdf.js/${version}/pdf.worker.min.mjs`; export const PdfReader = ({ fileUrl, - file, - data, pdfWorkerSrc, - onDocumentLoad, - onLoadComplete, - onPageChange, - onError, pageNumber, navigationRequestId, scale, @@ -93,17 +91,11 @@ export const PdfReader = ({ const pendingViewportAnchorRef = useRef(null); const initialWidthFitTargetScaleRef = useRef(null); const suppressNextResizeFitRef = useRef(false); - const onDocumentLoadRef = useRef(onDocumentLoad); - const onLoadCompleteRef = useRef(onLoadComplete); - const onErrorRef = useRef(onError); const onPageSizesReadyRef = useRef(onPageSizesReady); useEffect(() => { - onDocumentLoadRef.current = onDocumentLoad; - onLoadCompleteRef.current = onLoadComplete; - onErrorRef.current = onError; onPageSizesReadyRef.current = onPageSizesReady; - }, [onDocumentLoad, onLoadComplete, onError, onPageSizesReady]); + }, [onPageSizesReady]); useEffect(() => { currentPageRef.current = pageNumber; @@ -131,26 +123,16 @@ export const PdfReader = ({ const loadDocument = async () => { try { - const resolvedFile = file ?? data; - - if (!resolvedFile && !fileUrl) { - throw new Error('A PDF file/data or fileUrl is required'); + if (!fileUrl) { + throw new Error('A PDF fileUrl is required'); } - const source = resolvedFile - ? { - data: await toWorkerSafePdfBytes(resolvedFile), - } - : { url: fileUrl, withCredentials: false }; - - loadingTask = getDocument(source); + loadingTask = getDocument({ url: fileUrl, withCredentials: false }); const doc = await loadingTask.promise; if (cancelled) return; setPdfDoc(doc); dispatch({ type: 'PAGES_LOADED', numPages: doc.numPages }); - onDocumentLoadRef.current?.(doc); - onLoadCompleteRef.current?.(doc.numPages); try { const rawOutline = (await doc.getOutline()) as @@ -166,7 +148,6 @@ export const PdfReader = ({ if (cancelled) return; const nextError = toError(err, 'Failed to load PDF document'); setError(nextError); - onErrorRef.current?.(nextError); } finally { if (!cancelled) setLoading(false); } @@ -178,7 +159,7 @@ export const PdfReader = ({ cancelled = true; loadingTask?.destroy?.(); }; - }, [fileUrl, file, data, dispatch, onOutlineLoad]); + }, [fileUrl, dispatch, onOutlineLoad]); // Read every page's intrinsic size to determine scroll container's height // before any page has actually been painted. @@ -205,7 +186,6 @@ export const PdfReader = ({ if (cancelled) return; const nextError = toError(err, 'Failed to read page dimensions'); setError(nextError); - onErrorRef.current?.(nextError); } })(); @@ -335,10 +315,6 @@ export const PdfReader = ({ lastHandledNavigationRequestRef.current = navigationRequestId; }, [navigationRequestId, pageBaseSizes.length, pageNumber]); - useEffect(() => { - onPageChange?.(pageNumber); - }, [onPageChange, pageNumber]); - // Viewport anchor capture // Records what content is currently visible so that after a layout-affecting // change (scale/rotation) the scroll position is restored. @@ -573,6 +549,7 @@ export const PdfReader = ({ tabIndex={-1} role="region" aria-label="Reader content" + id={MAIN_CONTENT_ID} >
{loading && (
-
- -
+
Loading
)} {!loading && error && ( @@ -622,7 +597,6 @@ export const PdfReader = ({ isVisible={visiblePages.has(pageNumber)} registerContainer={registerContainer} goToPage={requestGoToPage} - onError={onErrorRef.current} /> ); })} @@ -635,37 +609,38 @@ export const PdfReader = ({ }; export function useNewReader({ - fileUrl: fileUrlProp, - file, - data, webpubManifestUrl, manifest: inputManifest, proxyUrl, pdfWorkerSrc, height = DEFAULT_HEIGHT, - initialPage = 1, - initialScale = 1, - initialFit = 'width', - showToc = true, - onDocumentLoad, - onLoadComplete, - onPageChange, - onError, toggleFullScreen, }: PdfReaderProps): Exclude { - // Resolve fileUrl from manifest when webpubManifestUrl + manifest are provided + // Resolve fileUrl from manifest const fileUrl = useMemo(() => { - if (fileUrlProp) return fileUrlProp; if (webpubManifestUrl && inputManifest) { return resolveResourceUrl(inputManifest, proxyUrl); } return undefined; - }, [fileUrlProp, webpubManifestUrl, inputManifest, proxyUrl]); + }, [webpubManifestUrl, inputManifest, proxyUrl]); + + // Extract initial page from resource href if present + const resolvedInitialPage = useMemo(() => { + if (webpubManifestUrl && inputManifest) { + const originalHref = inputManifest?.readingOrder?.[0]?.href; + if (originalHref) { + const pageFromUrl = getPageNumberFromHref(originalHref); + if (pageFromUrl) return pageFromUrl; + } + } + return 1; + }, [webpubManifestUrl, inputManifest]); + const [viewerState, dispatch] = useReducer(pdfReaderReducer, { - pageNumber: initialPage, + pageNumber: resolvedInitialPage, numPages: 0, - scale: initialScale, - fitMode: initialFit, + scale: 1, + fitMode: 'width', rotation: 0, navigationRequestId: 0, } satisfies PdfReaderState); @@ -683,35 +658,29 @@ export function useNewReader({ ); const [pdfLoadFailed, setPdfLoadFailed] = useState(false); const [pageSizesReady, setPageSizesReady] = useState(false); + const hasNavigatedToInitialPageRef = useRef(false); useEffect(() => { setPdfLoadFailed(false); setPageSizesReady(false); - }, [fileUrl, file, data]); + hasNavigatedToInitialPageRef.current = false; + }, [fileUrl]); const wrappedOnPageSizesReady = useCallback(() => { setPageSizesReady(true); }, []); - const wrappedOnError = useCallback( - (error: Error) => { - setPdfLoadFailed(true); - onError?.(error); - }, - [onError] - ); - useEffect(() => { - dispatch({ type: 'GO_TO_PAGE', page: initialPage }); - }, [initialPage]); - - useEffect(() => { - dispatch({ type: 'SET_SCALE', scale: initialScale }); - }, [initialScale]); - - useEffect(() => { - dispatch({ type: 'SET_FIT', mode: initialFit }); - }, [initialFit]); + if ( + pageSizesReady && + numPages > 0 && + !hasNavigatedToInitialPageRef.current + ) { + hasNavigatedToInitialPageRef.current = true; + const validPage = Math.min(Math.max(resolvedInitialPage, 1), numPages); + dispatch({ type: 'GO_TO_PAGE', page: validPage }); + } + }, [pageSizesReady, numPages, resolvedInitialPage]); const clearPendingAction = useCallback(() => setPendingAction(null), []); @@ -741,7 +710,7 @@ export function useNewReader({ [] ); - const fileName = file instanceof File ? file.name : undefined; + const fileName: string | undefined = undefined; const manifestTitle = getManifestTitle('PDF Document', fileName); const manifest: WebpubManifest = useMemo(() => { @@ -749,7 +718,7 @@ export function useNewReader({ return { ...inputManifest, toc: - showToc && outline.length > 0 + outline.length > 0 ? getManifestTocFromOutline(outline) : (inputManifest.toc ?? []), }; @@ -760,15 +729,16 @@ export function useNewReader({ }, links: [], readingOrder: [], - toc: showToc ? getManifestTocFromOutline(outline) : [], + toc: getManifestTocFromOutline(outline), }; - }, [inputManifest, manifestTitle, outline, showToc]); + }, [inputManifest, manifestTitle, outline]); - const hasSource = !!(fileUrl || file || data); + const hasSource = !!fileUrl; const isDocLoading = hasSource && !pageSizesReady && !pdfLoadFailed; + const heightValue = typeof height === 'number' ? `${height}px` : height; const content = ( -
+
{isDocLoading && (
@@ -776,13 +746,7 @@ export function useNewReader({ )} ; + if (!props.webpubManifestUrl || !props.manifest) { + return ; } return reader.content; diff --git a/src/NewReader/types.ts b/src/NewReader/types.ts index 20e83a2e..17c7caf6 100644 --- a/src/NewReader/types.ts +++ b/src/NewReader/types.ts @@ -1,4 +1,3 @@ -import { PDFDocumentProxy } from 'pdfjs-dist'; import { Dispatch } from 'react'; import { WebpubManifest } from '../types'; import { PdfReaderAction } from './reducer'; @@ -24,35 +23,18 @@ export interface ViewportAnchor { export interface PdfReaderProps { fileUrl?: string; - file?: Blob | File | Uint8Array | ArrayBuffer; - data?: Uint8Array | ArrayBuffer; webpubManifestUrl?: string; manifest?: WebpubManifest; proxyUrl?: string; pdfWorkerSrc?: string; height?: string | number; - initialPage?: number; - initialScale?: number; - initialFit?: FitMode; - showToc?: boolean; className?: string; - onDocumentLoad?: (pdfDoc: PDFDocumentProxy) => void; - onLoadComplete?: (numPages: number) => void; - onPageChange?: (pageNumber: number) => void; - onError?: (error: Error) => void; toggleFullScreen?: () => void; } export interface PdfReaderContentProps { - fileUrl?: string; - file?: Blob | File | Uint8Array | ArrayBuffer; - data?: Uint8Array | ArrayBuffer; + fileUrl: string | undefined; pdfWorkerSrc?: string; - className?: string; - onDocumentLoad?: (pdfDoc: PDFDocumentProxy) => void; - onLoadComplete?: (numPages: number) => void; - onPageChange?: (pageNumber: number) => void; - onError?: (error: Error) => void; pageNumber: number; navigationRequestId: number; scale: number; From 05efc3ed342f04aff7ea542cee192c33226d7c1d Mon Sep 17 00:00:00 2001 From: Jackie Quach Date: Tue, 18 Aug 2026 16:21:38 -0400 Subject: [PATCH 06/11] fix flaky tests --- playwright/pageobjects/web-reader.page.ts | 27 ++++++++++++++--------- 1 file changed, 16 insertions(+), 11 deletions(-) diff --git a/playwright/pageobjects/web-reader.page.ts b/playwright/pageobjects/web-reader.page.ts index 832d5ec4..57f108f8 100644 --- a/playwright/pageobjects/web-reader.page.ts +++ b/playwright/pageobjects/web-reader.page.ts @@ -204,30 +204,35 @@ class PdfReaderPage extends WebReaderPage { } async getZoomValue(): Promise { - return await this.page - .locator('.textLayer:visible') - .first() - .evaluate((el) => { - return Number( - window.getComputedStyle(el).getPropertyValue('--scale-factor') + const textLayer = this.pageOne.locator('.textLayer'); + let value = 0; + await expect + .poll(async () => { + value = await textLayer.evaluate((el) => + Number(window.getComputedStyle(el).getPropertyValue('--scale-factor')) ); - }); + return value; + }) + .toBeGreaterThan(0); + return value; } async zoomIn(): Promise { const beforeScaleFactor = await this.getZoomValue(); await expect(this.zoomInButton).toBeVisible(); await this.zoomInButton.click(); - const afterScaleFactor = await this.getZoomValue(); - expect(afterScaleFactor).toBeGreaterThan(beforeScaleFactor); + await expect + .poll(() => this.getZoomValue()) + .toBeGreaterThan(beforeScaleFactor); } async zoomOut(): Promise { const beforeScaleFactor = await this.getZoomValue(); await expect(this.zoomOutButton).toBeVisible(); await this.zoomOutButton.click(); - const afterScaleFactor = await this.getZoomValue(); - expect(afterScaleFactor).toBeLessThan(beforeScaleFactor); + await expect + .poll(() => this.getZoomValue()) + .toBeLessThan(beforeScaleFactor); } async scrollDown(): Promise { From 6a85c4f165fb7d12ce1b7b01ec300c51b5788281 Mon Sep 17 00:00:00 2001 From: Jackie Quach Date: Wed, 19 Aug 2026 11:19:28 -0400 Subject: [PATCH 07/11] replace old pdfreader with newreader --- example/index.tsx | 32 +- example/use-new-pdf-reader.tsx | 36 - src/NewReader/PdfReader.css | 369 ---------- src/NewReader/reducer.ts | 119 --- src/NewReader/types.ts | 63 -- src/PdfReader/ChakraPage.tsx | 51 -- src/{NewReader => PdfReader}/PdfPage.tsx | 0 .../PdfReaderContent.tsx} | 264 +------ src/PdfReader/ScrollPage.tsx | 109 --- src/{NewReader => PdfReader}/constants.ts | 0 src/PdfReader/index.tsx | 689 +++++------------- src/PdfReader/lib.ts | 95 --- src/PdfReader/pdfReader.css | 372 +++++++++- src/PdfReader/reducer.ts | 339 +++------ src/PdfReader/types.ts | 115 ++- src/PdfReader/useMeasure.tsx | 71 -- src/{NewReader => PdfReader}/utils.ts | 17 + src/index.tsx | 1 - src/useWebReader.tsx | 4 +- tests/PdfReducer.test.ts | 198 +++-- 20 files changed, 850 insertions(+), 2094 deletions(-) delete mode 100644 example/use-new-pdf-reader.tsx delete mode 100644 src/NewReader/PdfReader.css delete mode 100644 src/NewReader/reducer.ts delete mode 100644 src/NewReader/types.ts delete mode 100644 src/PdfReader/ChakraPage.tsx rename src/{NewReader => PdfReader}/PdfPage.tsx (100%) rename src/{NewReader/index.tsx => PdfReader/PdfReaderContent.tsx} (67%) delete mode 100644 src/PdfReader/ScrollPage.tsx rename src/{NewReader => PdfReader}/constants.ts (100%) delete mode 100644 src/PdfReader/lib.ts delete mode 100644 src/PdfReader/useMeasure.tsx rename src/{NewReader => PdfReader}/utils.ts (85%) diff --git a/example/index.tsx b/example/index.tsx index b0095c15..467bdde8 100644 --- a/example/index.tsx +++ b/example/index.tsx @@ -25,7 +25,8 @@ import useSWR, { Fetcher } from 'swr'; import readiumAfter from 'url:../src/HtmlReader/ReadiumCss/ReadiumCSS-after.css'; import readiumBefore from 'url:../src/HtmlReader/ReadiumCss/ReadiumCSS-before.css'; import readiumDefault from 'url:../src/HtmlReader/ReadiumCss/ReadiumCSS-default.css'; -import WebReader, { addTocToManifest } from '../src'; +import WebReader from '../src'; +import addTocToManifest from '../src/PdfReader/addTocToManifest'; import { Injectable } from '../src/Readium/Injectable'; import { WebpubManifest } from '../src/types'; import { getTheme } from '../src/ui/theme'; @@ -33,7 +34,6 @@ import mobyEpub2Manifest from './static/samples/moby-epub2-exploded/manifest.jso import pdfSingleResourceManifest from './static/samples/pdf/single-resource-short.json'; import Tests from './Tests'; import UseHtmlReader from './use-html-reader'; -import UseNewPdfReader from './use-new-pdf-reader'; import UsePdfReader from './use-pdf-reader'; const origin = window.location.origin; @@ -121,31 +121,6 @@ const PdfReaders = () => { pdfWorkerSrc={`${origin}/pdf-worker/pdf.worker.min.mjs`} /> - - - UseNewPdfReader - - New Pdf Reader using pdf.js directly instead of react-pdf. - - - - - - { usePdfReader hook - - NewReader with Manifest - Single-PDF Webpub (large file) diff --git a/example/use-new-pdf-reader.tsx b/example/use-new-pdf-reader.tsx deleted file mode 100644 index 6b941119..00000000 --- a/example/use-new-pdf-reader.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import React from 'react'; -import { useNewReader } from '../src'; -import { WebpubManifest } from '../src/types'; -import Header from '../src/ui/Header'; - -type UseNewPdfReaderProps = { - webpubManifestUrl: string; - manifest: WebpubManifest; - proxyUrl?: string; - pdfWorkerSrc?: string; -}; - -const UseNewPdfReader: React.FC = ({ - webpubManifestUrl, - manifest, - proxyUrl, - pdfWorkerSrc, -}) => { - const newReader = useNewReader({ - webpubManifestUrl, - manifest, - proxyUrl, - pdfWorkerSrc, - height: '80vh', - }); - const containerRef = React.useRef(null); - - return ( -
- {newReader.type &&
} - {newReader.content} -
- ); -}; - -export default UseNewPdfReader; diff --git a/src/NewReader/PdfReader.css b/src/NewReader/PdfReader.css deleted file mode 100644 index a19ad0ae..00000000 --- a/src/NewReader/PdfReader.css +++ /dev/null @@ -1,369 +0,0 @@ -.pdf-root { - display: flex; - flex-direction: column; - width: 100%; - height: 100%; - min-height: 400px; - background: #525659; - color: #e8eaed; - overflow: hidden; -} - -.pdf-root:fullscreen { - width: 100vw; - height: 100vh; -} - -.pdf-toolbar { - display: flex; - align-items: center; - justify-content: space-between; - gap: 8px; - padding: 6px 10px; - background: #2b2d30; - border-bottom: 1px solid #1c1d1f; - flex-wrap: wrap; - flex-shrink: 0; -} - -.pdf-toolbar-group { - display: flex; - align-items: center; - gap: 4px; -} - -.pdf-btn { - display: inline-flex; - align-items: center; - justify-content: center; - width: 32px; - height: 32px; - border: none; - border-radius: 6px; - background: transparent; - color: #e8eaed; - cursor: pointer; - transition: background 0.12s ease; -} - -.pdf-btn:hover:not(:disabled) { - background: rgba(255, 255, 255, 0.1); -} - -.pdf-btn:disabled { - opacity: 0.35; - cursor: default; -} - -.pdf-page-form { - display: flex; - align-items: center; - gap: 4px; - margin: 0 2px; -} - -.pdf-page-input { - width: 40px; - text-align: center; - background: #1c1d1f; - border: 1px solid #3c4043; - border-radius: 4px; - color: #e8eaed; - font-size: 13px; - padding: 4px 2px; -} - -.pdf-page-input:focus { - outline: 1px solid #8ab4f8; -} - -.pdf-page-total, -.pdf-zoom-label { - font-size: 13px; - color: #b0b3b8; - min-width: 42px; - text-align: center; - user-select: none; -} - -/* Body: sidebar + viewport */ -.pdf-body { - display: flex; - flex: 1; - min-height: 0; -} - -.pdf-sidebar { - width: 260px; - flex-shrink: 0; - background: #323639; - border-right: 1px solid #1c1d1f; - overflow-y: auto; -} - -.pdf-sidebar-header { - padding: 12px 14px; - font-size: 12px; - font-weight: 600; - letter-spacing: 0.04em; - text-transform: uppercase; - color: #9aa0a6; - border-bottom: 1px solid #1c1d1f; -} - -.pdf-sidebar-empty { - padding: 14px; - font-size: 13px; - color: #7a7d81; -} - -.pdf-toc-list { - list-style: none; - margin: 0; - padding: 0; -} - -.pdf-toc-item { - display: block; - width: 100%; - text-align: left; - background: none; - border: none; - color: #d3d6db; - font-size: 13px; - padding: 7px 12px; - cursor: pointer; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.pdf-toc-item:hover:not(:disabled) { - background: rgba(255, 255, 255, 0.06); -} - -.pdf-toc-item.active { - background: rgba(66, 133, 244, 0.25); - color: #8ab4f8; -} - -.pdf-toc-item:disabled { - color: #6a6d71; - cursor: default; -} - -/* Viewport (scroll container) */ -.pdf-viewport { - flex: 1; - overflow: auto; - position: relative; - background: #252525; - color-scheme: dark; -} - -/* Loading / error states */ -.pdf-status-container { - position: absolute; - inset: 0; - display: flex; - align-items: center; - justify-content: center; -} - -.pdf-status { - display: flex; - flex-direction: column; - align-items: center; - gap: 10px; - color: #b0b3b8; - font-size: 14px; -} - -.pdf-error { - color: #f28b82; -} - -.pdf-pages-stack { - display: flex; - flex-direction: column; - align-items: flex-start; - width: max-content; - min-width: 100%; - padding-top: 16px; - background: #252525; -} - -.pdf-page-wrap { - position: relative; - flex-shrink: 0; - margin-inline: auto; - box-shadow: 0 2px 10px rgba(0, 0, 0, 0.5); - background: #252525; -} - -.pdf-canvas { - display: block; -} - -/* Text and annotation layer styles adapted from react-pdf/pdf.js */ -.pdf-root { - --react-pdf-text-layer: 1; -} - -[data-main-rotation='90'] .pdf-text-layer.textLayer { - transform: rotate(90deg) translateY(-100%); -} - -[data-main-rotation='180'] .pdf-text-layer.textLayer { - transform: rotate(180deg) translate(-100%, -100%); -} - -[data-main-rotation='270'] .pdf-text-layer.textLayer { - transform: rotate(270deg) translateX(-100%); -} - -.pdf-text-layer.textLayer { - position: absolute; - text-align: initial; - inset: 0; - overflow: clip; - opacity: 1; - line-height: 1; - text-size-adjust: none; - forced-color-adjust: none; - transform-origin: 0 0; - caret-color: CanvasText; - z-index: 0; - - --min-font-size: 1; - --total-scale-factor: var(--scale-factor, 1); - --text-scale-factor: calc(var(--total-scale-factor) * var(--min-font-size)); - --min-font-size-inv: calc(1 / var(--min-font-size)); -} - -.pdf-text-layer.textLayer :is(span, br) { - color: transparent; - position: absolute; - white-space: pre; - cursor: text; - margin: 0; - transform-origin: 0 0; -} - -.pdf-text-layer.textLayer > :not(.markedContent), -.pdf-text-layer.textLayer .markedContent span:not(.markedContent) { - z-index: 1; - - --font-height: 0; - font-size: calc(var(--text-scale-factor) * var(--font-height)); - - --scale-x: 1; - --rotate: 0deg; - transform: rotate(var(--rotate)) scaleX(var(--scale-x)) - scale(var(--min-font-size-inv)); -} - -.pdf-text-layer.textLayer .markedContent { - display: contents; -} - -.pdf-text-layer.textLayer span[role='img'] { - user-select: none; - cursor: default; -} - -.pdf-text-layer.textLayer .highlight { - margin: -1px; - padding: 1px; - border-radius: 4px; -} - -.pdf-text-layer.textLayer .highlight.appended { - position: initial; -} - -.pdf-text-layer.textLayer .highlight.begin { - border-radius: 4px 0 0 4px; -} - -.pdf-text-layer.textLayer .highlight.end { - border-radius: 0 4px 4px 0; -} - -.pdf-text-layer.textLayer .highlight.middle { - border-radius: 0; -} - -.pdf-text-layer.textLayer br::selection { - background: transparent; -} - -.pdf-text-layer.textLayer .endOfContent { - display: block; - position: absolute; - inset: 100% 0 0; - z-index: 0; - cursor: default; - user-select: none; -} - -.pdf-text-layer.textLayer.selecting .endOfContent { - top: 0; -} - -.pdf-text-layer.textLayer .hiddenCanvasElement { - position: absolute; - top: 0; - left: 0; - width: 0; - height: 0; - display: none; -} - -.pdf-annotation-layer { - position: absolute; - top: 0; - left: 0; - right: 0; - bottom: 0; - overflow: hidden; -} - -.pdf-annotation-layer section { - position: absolute; -} - -.pdf-annotation-layer .linkAnnotation > a { - position: absolute; - font-size: 1em; - top: 0; - left: 0; - width: 100%; - height: 100%; -} - -.pdf-annotation-layer .linkAnnotation > a:hover { - background: rgba(255, 255, 0, 0.15); - box-shadow: 0 0 0 1px rgba(255, 255, 0, 0.4); -} - -.pdf-annotation-layer input, -.pdf-annotation-layer textarea, -.pdf-annotation-layer select { - background: rgba(255, 255, 255, 0.6); - border: 1px solid #8ab4f8; - box-sizing: border-box; - font: inherit; -} - -@media (max-width: 640px) { - .pdf-sidebar { - position: absolute; - z-index: 5; - height: 100%; - } - .pdf-page-total, - .pdf-zoom-label { - min-width: unset; - } -} diff --git a/src/NewReader/reducer.ts b/src/NewReader/reducer.ts deleted file mode 100644 index dfb86b94..00000000 --- a/src/NewReader/reducer.ts +++ /dev/null @@ -1,119 +0,0 @@ -import { MAX_SCALE, MIN_SCALE, SCALE_STEP } from './constants'; -import { FitMode } from './types'; - -export type PdfReaderState = { - pageNumber: number; - numPages: number; - scale: number; - fitMode: FitMode; - rotation: number; - navigationRequestId: number; -}; - -export type PdfReaderAction = - | { type: 'GO_FORWARD' } - | { type: 'GO_BACKWARD' } - | { type: 'GO_TO_PAGE'; page: number } - | { type: 'GO_TO_HREF'; href: string } - | { type: 'PAGE_IN_VIEW'; page: number } - | { type: 'PAGES_LOADED'; numPages: number } - | { type: 'ZOOM_IN' } - | { type: 'ZOOM_OUT' } - | { type: 'ROTATE_CCW'; nextScale?: number } - | { type: 'SET_FIT'; mode: FitMode } - | { type: 'SET_SCALE'; scale: number }; - -function handleInvalidTransition( - state: PdfReaderState, - action: PdfReaderAction -): PdfReaderState { - console.trace( - `Invalid state transition attempted with action: ${action.type}` - ); - return state; -} - -export function pdfReaderReducer( - state: PdfReaderState, - action: PdfReaderAction -): PdfReaderState { - switch (action.type) { - case 'GO_FORWARD': { - const next = state.pageNumber + 1; - if (next > state.numPages) return state; - return { - ...state, - pageNumber: next, - navigationRequestId: state.navigationRequestId + 1, - }; - } - - case 'GO_BACKWARD': { - const prev = state.pageNumber - 1; - if (prev < 1) return state; - return { - ...state, - pageNumber: prev, - navigationRequestId: state.navigationRequestId + 1, - }; - } - - case 'GO_TO_PAGE': { - const numPages = state.numPages || 1; - const page = Math.max(1, Math.min(action.page, numPages)); - if (page === state.pageNumber) return state; - return { - ...state, - pageNumber: page, - navigationRequestId: state.navigationRequestId + 1, - }; - } - - case 'GO_TO_HREF': { - const parsed = parseInt(action.href, 10); - if (Number.isNaN(parsed)) return handleInvalidTransition(state, action); - const numPages = state.numPages || 1; - const page = Math.max(1, Math.min(parsed, numPages)); - return { - ...state, - pageNumber: page, - navigationRequestId: state.navigationRequestId + 1, - }; - } - - case 'PAGE_IN_VIEW': { - if (state.pageNumber === action.page) return state; - return { ...state, pageNumber: action.page }; - } - - case 'PAGES_LOADED': - return { ...state, numPages: action.numPages }; - - case 'ZOOM_IN': - return { - ...state, - fitMode: null, - scale: Math.min(MAX_SCALE, +(state.scale + SCALE_STEP).toFixed(2)), - }; - - case 'ZOOM_OUT': - return { - ...state, - fitMode: null, - scale: Math.max(MIN_SCALE, +(state.scale - SCALE_STEP).toFixed(2)), - }; - - case 'ROTATE_CCW': - return { - ...state, - rotation: (state.rotation + 270) % 360, - scale: action.nextScale ?? state.scale, - }; - - case 'SET_FIT': - return { ...state, fitMode: action.mode }; - - case 'SET_SCALE': - return { ...state, scale: action.scale }; - } -} diff --git a/src/NewReader/types.ts b/src/NewReader/types.ts deleted file mode 100644 index 17c7caf6..00000000 --- a/src/NewReader/types.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { Dispatch } from 'react'; -import { WebpubManifest } from '../types'; -import { PdfReaderAction } from './reducer'; - -export type FitMode = 'width' | 'height' | null; - -export interface OutlineItem { - title: string; - pageNumber: number | null; - items: OutlineItem[]; -} - -export interface PageSize { - width: number; - height: number; -} - -export interface ViewportAnchor { - pageNumber: number; - intraPageRatio: number; - viewportOffset: number; -} - -export interface PdfReaderProps { - fileUrl?: string; - webpubManifestUrl?: string; - manifest?: WebpubManifest; - proxyUrl?: string; - pdfWorkerSrc?: string; - height?: string | number; - className?: string; - toggleFullScreen?: () => void; -} - -export interface PdfReaderContentProps { - fileUrl: string | undefined; - pdfWorkerSrc?: string; - pageNumber: number; - navigationRequestId: number; - scale: number; - fitMode: FitMode; - rotation: number; - dispatch: Dispatch; - pendingAction: PdfReaderAction | null; - clearPendingAction: () => void; - onOutlineLoad: (outlineItems: OutlineItem[]) => void; - onPageSizesReady?: () => void; -} - -export interface RenderTask { - promise: Promise; - cancel: () => void; -} - -export interface PdfOutlineEntry { - title: string; - dest: string | unknown[] | null; - items?: PdfOutlineEntry[]; -} - -export type RefProxy = import('pdfjs-dist/types/src/display/api').RefProxy; - -export type PdfBinaryInput = Blob | File | Uint8Array | ArrayBuffer; diff --git a/src/PdfReader/ChakraPage.tsx b/src/PdfReader/ChakraPage.tsx deleted file mode 100644 index 12dac558..00000000 --- a/src/PdfReader/ChakraPage.tsx +++ /dev/null @@ -1,51 +0,0 @@ -import { chakra, shouldForwardProp } from '@chakra-ui/react'; -import React from 'react'; -import { Page, PageProps } from 'react-pdf'; -import { FitMode } from '../types'; - -interface ChakraPageProps extends Omit { - width?: number; - height?: number; - fitMode: FitMode; -} - -// Wrap Page component so that we can pass it styles -const ChakraPage = chakra( - ({ width, height, fitMode, ...rest }: ChakraPageProps) => ( - - ), - { - shouldForwardProp: (prop) => { - // Forward width, height, scale, fitMode, and react-pdf props - if ( - [ - 'width', - 'height', - 'scale', - 'fitMode', - 'pageNumber', - 'rotate', - 'onLoadSuccess', - 'renderAnnotationLayer', - 'renderTextLayer', - 'loading', - ].includes(prop) - ) - return true; - // don't forward the rest of Chakra's props - const isChakraProp = !shouldForwardProp(prop); - if (isChakraProp) return false; - return true; - }, - baseStyle: { - outline: '1px', - outlineColor: 'ui.gray.light-cool', - }, - } -); - -export default ChakraPage; diff --git a/src/NewReader/PdfPage.tsx b/src/PdfReader/PdfPage.tsx similarity index 100% rename from src/NewReader/PdfPage.tsx rename to src/PdfReader/PdfPage.tsx diff --git a/src/NewReader/index.tsx b/src/PdfReader/PdfReaderContent.tsx similarity index 67% rename from src/NewReader/index.tsx rename to src/PdfReader/PdfReaderContent.tsx index 115f7b33..e41a1700 100644 --- a/src/NewReader/index.tsx +++ b/src/PdfReader/PdfReaderContent.tsx @@ -1,29 +1,15 @@ import { Icon } from '@chakra-ui/react'; import type { PDFDocumentProxy } from 'pdfjs-dist'; -import { getDocument, GlobalWorkerOptions, version } from 'pdfjs-dist'; +import { getDocument, GlobalWorkerOptions } from 'pdfjs-dist'; import React, { KeyboardEvent, useCallback, useEffect, useLayoutEffect, - useMemo, - useReducer, useRef, useState, } from 'react'; -import { getPageNumberFromHref } from '../PdfReader/lib'; -import { - DEFAULT_HEIGHT, - DEFAULT_SETTINGS, - MAIN_CONTENT_ID, -} from '../constants'; -import { - PdfNavigator, - ReaderReturn, - ReaderState, - WebpubManifest, -} from '../types'; -import LoadingSkeleton from '../ui/LoadingSkeleton'; +import { MAIN_CONTENT_ID } from '../constants'; import PdfPage from './PdfPage'; import './PdfReader.css'; import { @@ -32,30 +18,22 @@ import { RENDER_ROOT_MARGIN, SCROLLSPY_ANCHOR_RATIO, } from './constants'; -import { PdfReaderAction, pdfReaderReducer, PdfReaderState } from './reducer'; import { FitMode, - OutlineItem, PageSize, PdfOutlineEntry, PdfReaderContentProps, - PdfReaderProps, ViewportAnchor, } from './types'; import { getDisplayPageHeight, - getManifestTitle, - getManifestTocFromOutline, getPageTop, getRotatedSize, resolveOutline, - resolveResourceUrl, toError, } from './utils'; -GlobalWorkerOptions.workerSrc = `https://cdnjs.cloudflare.com/ajax/libs/pdf.js/${version}/pdf.worker.min.mjs`; - -export const PdfReader = ({ +const PdfReaderContent = ({ fileUrl, pdfWorkerSrc, pageNumber, @@ -79,7 +57,6 @@ export const PdfReader = ({ const [visiblePages, setVisiblePages] = useState>(new Set()); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); - const [hasInitialWidthFit, setHasInitialWidthFit] = useState(false); const rootRef = useRef(null); const viewportWrapRef = useRef(null); @@ -89,7 +66,6 @@ export const PdfReader = ({ const pendingScrollTargetRef = useRef(null); const lastHandledNavigationRequestRef = useRef(0); const pendingViewportAnchorRef = useRef(null); - const initialWidthFitTargetScaleRef = useRef(null); const suppressNextResizeFitRef = useRef(false); const onPageSizesReadyRef = useRef(onPageSizesReady); @@ -117,8 +93,6 @@ export const PdfReader = ({ onOutlineLoad([]); setPageBaseSizes([]); setVisiblePages(new Set()); - setHasInitialWidthFit(false); - initialWidthFitTargetScaleRef.current = null; containerRefs.current.clear(); const loadDocument = async () => { @@ -456,52 +430,6 @@ export const PdfReader = ({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [fitMode, pageBaseSizes]); - useLayoutEffect(() => { - if (hasInitialWidthFit) return; - if (fitMode !== 'width') { - setHasInitialWidthFit(true); - return; - } - if (!pageBaseSizes.length) return; - - const targetScale = computeFitScaleValue('width', rotation); - if (targetScale == null) return; - - initialWidthFitTargetScaleRef.current = targetScale; - - const relativeDelta = scale > 0 ? Math.abs(scale - targetScale) / scale : 1; - - if (relativeDelta < 0.005) { - setHasInitialWidthFit(true); - initialWidthFitTargetScaleRef.current = null; - return; - } - - suppressNextResizeFitRef.current = true; - dispatch({ type: 'SET_SCALE', scale: targetScale }); - }, [ - computeFitScaleValue, - dispatch, - fitMode, - hasInitialWidthFit, - pageBaseSizes.length, - rotation, - scale, - ]); - - useEffect(() => { - if (hasInitialWidthFit) return; - const targetScale = initialWidthFitTargetScaleRef.current; - if (targetScale == null) return; - - const relativeDelta = - targetScale > 0 ? Math.abs(scale - targetScale) / targetScale : 0; - if (relativeDelta < 0.005) { - setHasInitialWidthFit(true); - initialWidthFitTargetScaleRef.current = null; - } - }, [hasInitialWidthFit, scale]); - // Re-apply fit scale on container resize. useEffect(() => { const wrap = viewportWrapRef.current; @@ -552,11 +480,7 @@ export const PdfReader = ({ id={MAIN_CONTENT_ID} >
-
+
{loading && (
Loading
@@ -608,182 +532,4 @@ export const PdfReader = ({ ); }; -export function useNewReader({ - webpubManifestUrl, - manifest: inputManifest, - proxyUrl, - pdfWorkerSrc, - height = DEFAULT_HEIGHT, - toggleFullScreen, -}: PdfReaderProps): Exclude { - // Resolve fileUrl from manifest - const fileUrl = useMemo(() => { - if (webpubManifestUrl && inputManifest) { - return resolveResourceUrl(inputManifest, proxyUrl); - } - return undefined; - }, [webpubManifestUrl, inputManifest, proxyUrl]); - - // Extract initial page from resource href if present - const resolvedInitialPage = useMemo(() => { - if (webpubManifestUrl && inputManifest) { - const originalHref = inputManifest?.readingOrder?.[0]?.href; - if (originalHref) { - const pageFromUrl = getPageNumberFromHref(originalHref); - if (pageFromUrl) return pageFromUrl; - } - } - return 1; - }, [webpubManifestUrl, inputManifest]); - - const [viewerState, dispatch] = useReducer(pdfReaderReducer, { - pageNumber: resolvedInitialPage, - numPages: 0, - scale: 1, - fitMode: 'width', - rotation: 0, - navigationRequestId: 0, - } satisfies PdfReaderState); - const { - pageNumber, - numPages, - scale, - fitMode, - rotation, - navigationRequestId, - } = viewerState; - const [outline, setOutline] = useState([]); - const [pendingAction, setPendingAction] = useState( - null - ); - const [pdfLoadFailed, setPdfLoadFailed] = useState(false); - const [pageSizesReady, setPageSizesReady] = useState(false); - const hasNavigatedToInitialPageRef = useRef(false); - - useEffect(() => { - setPdfLoadFailed(false); - setPageSizesReady(false); - hasNavigatedToInitialPageRef.current = false; - }, [fileUrl]); - - const wrappedOnPageSizesReady = useCallback(() => { - setPageSizesReady(true); - }, []); - - useEffect(() => { - if ( - pageSizesReady && - numPages > 0 && - !hasNavigatedToInitialPageRef.current - ) { - hasNavigatedToInitialPageRef.current = true; - const validPage = Math.min(Math.max(resolvedInitialPage, 1), numPages); - dispatch({ type: 'GO_TO_PAGE', page: validPage }); - } - }, [pageSizesReady, numPages, resolvedInitialPage]); - - const clearPendingAction = useCallback(() => setPendingAction(null), []); - - const state: ReaderState = useMemo( - () => ({ - atStart: pageNumber <= 1, - atEnd: pageNumber >= numPages, - settings: DEFAULT_SETTINGS, - fitMode: fitMode ?? 'width', - rotation, - }), - [fitMode, numPages, pageNumber, rotation] - ); - - const navigator: PdfNavigator = useMemo( - () => ({ - goForward: () => dispatch({ type: 'GO_FORWARD' }), - goBackward: () => dispatch({ type: 'GO_BACKWARD' }), - setScroll: async () => undefined, - goToPage: (href: string) => dispatch({ type: 'GO_TO_HREF', href }), - goToPageNumber: (page: number) => dispatch({ type: 'GO_TO_PAGE', page }), - setFitMode: (mode) => dispatch({ type: 'SET_FIT', mode }), - zoomIn: async () => setPendingAction({ type: 'ZOOM_IN' }), - zoomOut: async () => setPendingAction({ type: 'ZOOM_OUT' }), - rotateCounterClockwise: () => setPendingAction({ type: 'ROTATE_CCW' }), - }), - [] - ); - - const fileName: string | undefined = undefined; - const manifestTitle = getManifestTitle('PDF Document', fileName); - - const manifest: WebpubManifest = useMemo(() => { - if (inputManifest) { - return { - ...inputManifest, - toc: - outline.length > 0 - ? getManifestTocFromOutline(outline) - : (inputManifest.toc ?? []), - }; - } - return { - metadata: { - title: manifestTitle, - }, - links: [], - readingOrder: [], - toc: getManifestTocFromOutline(outline), - }; - }, [inputManifest, manifestTitle, outline]); - - const hasSource = !!fileUrl; - const isDocLoading = hasSource && !pageSizesReady && !pdfLoadFailed; - const heightValue = typeof height === 'number' ? `${height}px` : height; - - const content = ( -
- {isDocLoading && ( -
- -
- )} - -
- ); - - return { - type: 'PDF', - isLoading: isDocLoading, - content, - state, - navigator, - manifest, - currentPage: pageNumber, - totalPages: numPages, - toggleFullScreen, - }; -} - -export default function NewReader(props: PdfReaderProps): React.ReactElement { - const reader = useNewReader(props); - const heightValue = - typeof props.height === 'number' - ? `${props.height}px` - : (props.height ?? DEFAULT_HEIGHT); - - if (!props.webpubManifestUrl || !props.manifest) { - return ; - } - - return reader.content; -} +export default PdfReaderContent; diff --git a/src/PdfReader/ScrollPage.tsx b/src/PdfReader/ScrollPage.tsx deleted file mode 100644 index 56fbd690..00000000 --- a/src/PdfReader/ScrollPage.tsx +++ /dev/null @@ -1,109 +0,0 @@ -import React, { FC } from 'react'; -import { useInView } from 'react-intersection-observer'; -import { PageProps } from 'react-pdf'; -import { FitMode } from '../types'; -import ChakraPage from './ChakraPage'; - -type ScrollPageProps = { - pageNumber: number; - width: number | undefined; - height: number | undefined; - scale: number; - onLoadSuccess: (page: PageProps) => void; - onPageRef?: (pageNumber: number, element: HTMLElement | null) => void; - placeholderHeight: number; - placeholderWidth: number; - allowInView?: boolean; - onInView?: (pageNumber: number, ratio: number) => void; - fitMode: FitMode; - rotate: number; -}; - -type PlaceholderProps = { - height: number; - width: number | undefined; - pageNumber: number; -}; - -const Placeholder: FC = ({ width, height, pageNumber }) => { - return ( -
- ); -}; - -const ScrollPage: FC = ({ - scale, - pageNumber, - width, - height, - onLoadSuccess, - onPageRef, - placeholderHeight, - placeholderWidth, - allowInView, - onInView, - fitMode, - rotate, -}) => { - const { ref: loadRef, inView: loadInView } = useInView({ - threshold: 0, - triggerOnce: true, - }); - - const { ref: visibilityRef, entry } = useInView({ - threshold: Array.from({ length: 11 }, (_, i) => i * 0.1), - triggerOnce: false, - }); - - const setRefs = React.useCallback( - (el: Element | null) => { - if (typeof loadRef === 'function') loadRef(el); - if (typeof visibilityRef === 'function') visibilityRef(el); - if (onPageRef) onPageRef(pageNumber, el as HTMLElement | null); - }, - [loadRef, onPageRef, pageNumber, visibilityRef] - ); - - const handleLoadSuccess = React.useCallback( - (page: PageProps) => { - onLoadSuccess(page); - }, - [onLoadSuccess] - ); - - React.useEffect(() => { - if (allowInView && onInView && entry) { - onInView(pageNumber, entry.intersectionRatio || 0); - } - }, [allowInView, entry, onInView, pageNumber]); - - return ( -
- {loadInView ? ( - - ) : ( - - )} -
- ); -}; - -export default ScrollPage; diff --git a/src/NewReader/constants.ts b/src/PdfReader/constants.ts similarity index 100% rename from src/NewReader/constants.ts rename to src/PdfReader/constants.ts diff --git a/src/PdfReader/index.tsx b/src/PdfReader/index.tsx index 82b1eb86..4f1e4384 100644 --- a/src/PdfReader/index.tsx +++ b/src/PdfReader/index.tsx @@ -1,536 +1,201 @@ -import { Flex } from '@chakra-ui/react'; -import * as React from 'react'; -import { Document, PageProps, pdfjs } from 'react-pdf'; +import { GlobalWorkerOptions, version } from 'pdfjs-dist'; +import React, { + useCallback, + useEffect, + useMemo, + useReducer, + useRef, + useState, +} from 'react'; import { + DEFAULT_FIT_MODE, DEFAULT_HEIGHT, - DEFAULT_SHOULD_GROW_WHEN_SCROLLING, - IN_VIEW_DELAY_MS, - MAIN_CONTENT_ID, - READER_MARGIN, + DEFAULT_SETTINGS, } from '../constants'; -import { FitMode, ReaderReturn } from '../types'; +import { + PdfNavigator, + ReaderReturn, + ReaderState, + WebpubManifest, +} from '../types'; import LoadingSkeleton from '../ui/LoadingSkeleton'; -import ChakraPage from './ChakraPage'; -import { fetchAsUint8Array, getResourceUrl, SCALE_STEP } from './lib'; -import './pdfReader.css'; -import { makePdfReducer } from './reducer'; -import ScrollPage from './ScrollPage'; -import { PdfReaderArguments } from './types'; -import useMeasure from './useMeasure'; - -/** - * The PDF reader - * - * The PDF reader loads resources in two stages: First, it fetches the PDF resource as an Uint8Array - * Then, it passes this array into the object, which loads the PDF inside an iframe - * - * @param args T - * @returns - */ -export default function usePdfReader(args: PdfReaderArguments): ReaderReturn { - // use a passed in src for the pdf worker - if (args?.pdfWorkerSrc) { - pdfjs.GlobalWorkerOptions.workerSrc = args.pdfWorkerSrc; - } - - const { - webpubManifestUrl, - manifest, - proxyUrl, - getContent = fetchAsUint8Array, - injectablesReflowable, - injectablesFixed, - height = DEFAULT_HEIGHT, - growWhenScrolling = DEFAULT_SHOULD_GROW_WHEN_SCROLLING, - toggleFullScreen, - } = args ?? {}; - - const [state, dispatch] = React.useReducer(makePdfReducer(args), { - state: 'INACTIVE', - resourceIndex: 0, - resource: null, - pageNumber: 1, - numPages: null, - scale: 1, - pdfWidth: 0, - pdfHeight: 0, - pageHeight: undefined, - pageWidth: undefined, - atStart: true, - atEnd: false, - settings: undefined, - rendered: false, - fitMode: 'width', - }); - - // state we can derive from the state above - const isFetching = !state.resource; - const isParsed = typeof state.numPages === 'number'; - const [containerRef, containerSize] = useMeasure(); - - const documentContainerRef = React.useRef(null); - const pageRefs = React.useRef>(new Map()); - const setPageRef = React.useCallback( - (pageNumber: number, element: HTMLElement | null) => { - if (element) { - pageRefs.current.set(pageNumber, element); - return; - } - pageRefs.current.delete(pageNumber); - }, - [] - ); - - const scrollState = React.useRef({ - ratios: new Map(), - lastVisiblePage: state.pageNumber, - isInViewUpdate: false, - lastProgrammaticNavAt: 0, - }); - - // dispatch action when arguments change - React.useEffect(() => { - if (!webpubManifestUrl || !manifest) { - return dispatch({ type: 'ARGS_CHANGED', args: undefined }); - } - dispatch({ - type: 'ARGS_CHANGED', - args: { - webpubManifestUrl, - manifest, - getContent, - injectablesReflowable, - injectablesFixed, - height, - growWhenScrolling, - }, - }); - }, [ - webpubManifestUrl, - manifest, - getContent, - injectablesReflowable, - injectablesFixed, - height, - growWhenScrolling, - ]); - - /** - * Load the current resource and set it in state, - * and reload whenever it changes (via navigation) - */ - React.useEffect(() => { - // bail out if there is not manifest passed in, - // that indicates that this format is inactive - if (!manifest) return; - // throw an error on a badly formed manifest - if (!manifest.readingOrder || !manifest.readingOrder.length) { - throw new Error('Manifest has no Reading Order'); - } - - const currentResource = getResourceUrl( - state.resourceIndex, - manifest.readingOrder - ); - - const fetchResource = async () => { - getContent(currentResource, proxyUrl).then((data) => { - dispatch({ - type: 'RESOURCE_FETCH_SUCCESS', - resource: { data }, - }); - }); - }; - if (manifest.readingOrder && manifest.readingOrder.length) { - fetchResource(); +import './PdfReader.css'; +import PdfReaderContent from './PdfReaderContent'; +import { PdfReaderAction, pdfReaderReducer, PdfReaderState } from './reducer'; +import { OutlineItem, PdfReaderProps } from './types'; +import { + getManifestTitle, + getManifestTocFromOutline, + getPageNumberFromHref, + resolveResourceUrl, +} from './utils'; + +GlobalWorkerOptions.workerSrc = `https://cdnjs.cloudflare.com/ajax/libs/pdf.js/${version}/pdf.worker.min.mjs`; + +const usePdfReader = ({ + webpubManifestUrl, + manifest: inputManifest, + proxyUrl, + pdfWorkerSrc, + height = DEFAULT_HEIGHT, + toggleFullScreen, +}: PdfReaderProps): Exclude => { + // Resolve fileUrl from manifest + const fileUrl = useMemo(() => { + if (webpubManifestUrl && inputManifest) { + return resolveResourceUrl(inputManifest, proxyUrl); } - }, [state.resourceIndex, manifest, proxyUrl, getContent]); - - /** - * calculate the height or width of the pdf page to fit to dimensions. - * - if the page's aspect ratio is taller than the container's, we will constrain - * the page to the height of the container. - * - if the page's aspect ratio is wider than the container's, we will constrain - * the page to the width of the container - */ - const resizePage = React.useCallback( - ( - containerSize: { width: number; height: number }, - fitMode: FitMode, - rotation: number, - scale: number - ) => { - if (!fitMode) return; - - let width, height, aspectRatio; - const isRotated = rotation % 180 !== 0; - const pdfWidth = isRotated ? state.pdfHeight : state.pdfWidth; - const pdfHeight = isRotated ? state.pdfWidth : state.pdfHeight; - - if (fitMode === 'width' && containerSize.width) { - width = Math.round(containerSize.width - READER_MARGIN); - aspectRatio = pdfHeight / pdfWidth; - height = Math.round(width * aspectRatio); - } else if ( - fitMode === 'height' && - pdfWidth && - pdfHeight && - containerSize.height - ) { - aspectRatio = pdfHeight / pdfWidth; - height = Math.round((containerSize.height - READER_MARGIN) * scale); - width = Math.round((height / aspectRatio) * scale); + return undefined; + }, [webpubManifestUrl, inputManifest, proxyUrl]); + + // Extract initial page from resource href if present + const resolvedInitialPage = useMemo(() => { + if (webpubManifestUrl && inputManifest) { + const originalHref = inputManifest?.readingOrder?.[0]?.href; + if (originalHref) { + const pageFromUrl = getPageNumberFromHref(originalHref); + if (pageFromUrl) return pageFromUrl; } - if (width || height) { - dispatch({ type: 'RESIZE_PAGE', width, height }); - } - }, - [state.pdfWidth, state.pdfHeight] - ); - - React.useEffect(() => { - resizePage(containerSize, state.fitMode, state.rotation ?? 0, state.scale); - }, [containerSize, resizePage, state.fitMode, state.rotation, state.scale]); - - /** - * Update the atStart/atEnd state to tell the UI whether to show the prev/next buttons - * Whether to have the next/prev buttons enabled. We disable them: - * - When on the first or last page of the first or last resource - */ - React.useEffect(() => { - const isFirstResource = state.resourceIndex === 0; - const isFirstResourceStart = isFirstResource && state.pageNumber === 1; - const showPrevButton = !isFirstResourceStart; - - const isLastResource = - state.resourceIndex === (manifest?.readingOrder?.length ?? 1) - 1; - const isLastResourceEnd = - isLastResource && state.pageNumber === state.numPages; - const showNextButton = !isLastResourceEnd; - - dispatch({ - type: 'BOOK_BOUNDARY_CHANGED', - atStart: !showPrevButton, - atEnd: !showNextButton, - }); - }, [ - manifest?.readingOrder?.length, - state.pageNumber, - state.resourceIndex, - state.settings?.isScrolling, - state.numPages, - ]); - - /** - * In scrolling mode, manually scroll the user when the page changes - */ - React.useEffect(() => { - // if the resource is not yet loaded, don't do anything yet - if (!state.settings?.isScrolling || !state.rendered) return; - - // If the change came from a scroll event, don't trigger a manual scroll back - if (scrollState.current.isInViewUpdate) { - scrollState.current.isInViewUpdate = false; - return; - } - - const documentContainer = documentContainerRef.current; - const pageRef = pageRefs.current.get(state.pageNumber); - - if (documentContainer && pageRef) { - const containerRect = documentContainer.getBoundingClientRect(); - const pageRect = pageRef.getBoundingClientRect(); - - documentContainer.scrollTo({ - top: documentContainer.scrollTop + (pageRect.top - containerRect.top), - }); } - }, [state.pageNumber, state.settings?.isScrolling, state.rendered]); - - const beginProgrammaticNavigation = React.useCallback( - (pendingPage: number) => { - const currentScrollState = scrollState.current; - currentScrollState.lastVisiblePage = pendingPage; - currentScrollState.lastProgrammaticNavAt = Date.now(); - currentScrollState.ratios.clear(); - }, - [] - ); + return 1; + }, [webpubManifestUrl, inputManifest]); - const goForward = React.useCallback(async () => { - beginProgrammaticNavigation( - state.numPages - ? Math.min(state.pageNumber + 1, state.numPages) - : state.pageNumber + 1 - ); - dispatch({ type: 'GO_FORWARD' }); - }, [beginProgrammaticNavigation, state.numPages, state.pageNumber]); - - const goBackward = React.useCallback(async () => { - beginProgrammaticNavigation(Math.max(1, state.pageNumber - 1)); - dispatch({ type: 'GO_BACKWARD' }); - }, [beginProgrammaticNavigation, state.pageNumber]); - - const setScroll = React.useCallback( - async (val: 'scrolling' | 'paginated') => { - const isScrolling = val === 'scrolling'; - dispatch({ type: 'SET_SCROLL', isScrolling }); - }, - [] + const [viewerState, dispatch] = useReducer(pdfReaderReducer, { + pageNumber: resolvedInitialPage, + numPages: 0, + scale: 1, + fitMode: DEFAULT_FIT_MODE, + rotation: 0, + navigationRequestId: 0, + } satisfies PdfReaderState); + const { + pageNumber, + numPages, + scale, + fitMode, + rotation, + navigationRequestId, + } = viewerState; + const [outline, setOutline] = useState([]); + const [pendingAction, setPendingAction] = useState( + null ); - - const zoomIn = React.useCallback(async () => { - dispatch({ - type: 'SET_SCALE', - scale: state.scale + SCALE_STEP, - }); - }, [state.scale]); - - const zoomOut = React.useCallback(async () => { - dispatch({ - type: 'SET_SCALE', - scale: state.scale - SCALE_STEP, - }); - }, [state.scale]); - - const rotateCounterClockwise = React.useCallback(async () => { - dispatch({ type: 'ROTATE_COUNTER_CLOCKWISE' }); + const [pdfLoadFailed, setPdfLoadFailed] = useState(false); + const [pageSizesReady, setPageSizesReady] = useState(false); + const hasNavigatedToInitialPageRef = useRef(false); + + useEffect(() => { + setPdfLoadFailed(false); + setPageSizesReady(false); + hasNavigatedToInitialPageRef.current = false; + }, [fileUrl]); + + const wrappedOnPageSizesReady = useCallback(() => { + setPageSizesReady(true); }, []); - const goToPage = React.useCallback(async (href: string) => { - dispatch({ type: 'GO_TO_HREF', href }); - }, []); - - const goToPageNumber = React.useCallback( - (page: number) => { - beginProgrammaticNavigation(page); - dispatch({ type: 'GO_TO_PAGE', page: page }); - }, - [beginProgrammaticNavigation] + useEffect(() => { + if ( + pageSizesReady && + numPages > 0 && + !hasNavigatedToInitialPageRef.current + ) { + hasNavigatedToInitialPageRef.current = true; + const validPage = Math.min(Math.max(resolvedInitialPage, 1), numPages); + dispatch({ type: 'GO_TO_PAGE', page: validPage }); + } + }, [pageSizesReady, numPages, resolvedInitialPage]); + + const clearPendingAction = useCallback(() => setPendingAction(null), []); + + const state: ReaderState = useMemo( + () => ({ + atStart: pageNumber <= 1, + atEnd: pageNumber >= numPages, + settings: DEFAULT_SETTINGS, + fitMode: fitMode ?? DEFAULT_FIT_MODE, + rotation, + }), + [fitMode, numPages, pageNumber, rotation] ); - // const resetSettings = React.useCallback(async () => { - // dispatch({ type: 'RESET_SETTINGS' }); - // }, []); - - const setFitMode = React.useCallback((mode: FitMode) => { - dispatch({ type: 'SET_FIT_MODE', fitMode: mode }); - }, []); - - const onInView = React.useCallback( - (pageNum: number, ratio: number) => { - const currentScrollState = scrollState.current; - - if ( - !state.settings?.isScrolling || - Date.now() - currentScrollState.lastProgrammaticNavAt < IN_VIEW_DELAY_MS - ) - return; - - if (ratio <= 0) { - currentScrollState.ratios.delete(pageNum); - return; - } - - currentScrollState.ratios.set(pageNum, ratio); - - let mostVisiblePage = currentScrollState.lastVisiblePage; - let maxRatio = -1; - - for (const [p, r] of currentScrollState.ratios) { - if (r > maxRatio) { - maxRatio = r; - mostVisiblePage = p; - } - if (r > 0.8) break; - } - - if (mostVisiblePage !== currentScrollState.lastVisiblePage) { - currentScrollState.lastVisiblePage = mostVisiblePage; - if (state.pageNumber !== mostVisiblePage) { - currentScrollState.isInViewUpdate = true; - dispatch({ type: 'PAGE_IN_VIEW', page: mostVisiblePage }); - } - } - }, - [state.settings?.isScrolling, state.pageNumber] + const navigator: PdfNavigator = useMemo( + () => ({ + goForward: () => dispatch({ type: 'GO_FORWARD' }), + goBackward: () => dispatch({ type: 'GO_BACKWARD' }), + setScroll: async () => undefined, + goToPage: (href: string) => dispatch({ type: 'GO_TO_HREF', href }), + goToPageNumber: (page: number) => dispatch({ type: 'GO_TO_PAGE', page }), + setFitMode: (mode) => dispatch({ type: 'SET_FIT', mode }), + zoomIn: async () => setPendingAction({ type: 'ZOOM_IN' }), + zoomOut: async () => setPendingAction({ type: 'ZOOM_OUT' }), + rotateCounterClockwise: () => setPendingAction({ type: 'ROTATE_CCW' }), + }), + [] ); - // this format is inactive, return null - if (!webpubManifestUrl || !manifest) return null; - - if (state.state === 'INACTIVE' || isFetching) { + const fileName: string | undefined = undefined; + const manifestTitle = getManifestTitle('PDF Document', fileName); + + const manifest: WebpubManifest = useMemo(() => { + if (inputManifest) { + return { + ...inputManifest, + toc: + outline.length > 0 + ? getManifestTocFromOutline(outline) + : (inputManifest.toc ?? []), + }; + } return { - type: null, - isLoading: true, - content: , - navigator: null, - manifest: null, - state: null, + metadata: { + title: manifestTitle, + }, + links: [], + readingOrder: [], + toc: getManifestTocFromOutline(outline), }; - } - - if (state.state === 'ERROR') throw state.error; - - // if (isFetching) { - // // The Reader is fetching a PDF resource - // return { - // type: 'PDF', - // isLoading: false, - // content: ( - // - // PDF is loading - // - // ), - // state, - // manifest, - // navigator: { - // goForward, - // goBackward, - // zoomIn, - // zoomOut, - // setScroll, - // goToPage, - // }, - // }; - // } - - const onDocumentLoadSuccess = ({ numPages }: { numPages: number }) => { - dispatch({ - type: 'PDF_PARSED', - numPages: numPages, - }); - }; - - const onDocumentLoadError = (error: Error) => { - dispatch({ - type: 'PDF_LOAD_ERROR', - error: error, - }); - }; - - function onRenderSuccess(page: PageProps) { - if (!page.height || !page.width) - throw new Error( - 'Error rendering page from Reader, please refresh your page.' - ); - if ( - Math.round(page.height) !== state.pdfHeight || - Math.round(page.width) > state.pdfWidth - ) { - dispatch({ - type: 'PAGE_LOAD_SUCCESS', - height: Math.round(page.height), - width: Math.round(page.width), - }); - - resizePage( - containerSize, - state.fitMode, - state.rotation ?? 0, - state.scale - ); - } - } + }, [inputManifest, manifestTitle, outline]); + + const hasSource = !!fileUrl; + const isDocLoading = hasSource && !pageSizesReady && !pdfLoadFailed; + const heightValue = typeof height === 'number' ? `${height}px` : height; + + const content = ( +
+ {isDocLoading && ( +
+ +
+ )} + +
+ ); - // the reader is active but loading a page return { type: 'PDF', - isLoading: false, - content: ( - - - {isParsed && state.numPages && ( - <> - {state.settings.isScrolling && - Array.from(new Array(state.numPages), (_, index) => ( - - ))} - {!state.settings.isScrolling && ( - } - fitMode={state.fitMode} - /> - )} - - )} - - - ), + isLoading: isDocLoading, + content, state, + navigator, manifest, - navigator: { - goForward, - goBackward, - setScroll, - zoomIn, - zoomOut, - rotateCounterClockwise, - goToPage, - goToPageNumber, - // resetSettings, - setFitMode, - }, - currentPage: state.pageNumber, - totalPages: state.numPages ?? 0, + currentPage: pageNumber, + totalPages: numPages, toggleFullScreen, }; -} +}; + +export default usePdfReader; diff --git a/src/PdfReader/lib.ts b/src/PdfReader/lib.ts deleted file mode 100644 index e11e070a..00000000 --- a/src/PdfReader/lib.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { WebpubManifest } from '../types'; -import { ReadiumLink } from '../WebpubManifestTypes/ReadiumLink'; - -export const SCALE_STEP = 0.1; -export const START_QUERY = 'start'; - -export const getResourceUrl = ( - index: number, - readingOrder: ReadiumLink[] | undefined -): string => { - if (!readingOrder || !readingOrder.length) { - throw new Error('A manifest has been returned, but has no reading order'); - } - - // If it has no children, return the link href - return readingOrder[index].href; -}; - -export const fetchAsUint8Array = async ( - resourceUrl: string, - proxyUrl?: string -): Promise => { - // Generate the resource URL using the proxy - const url: string = proxyUrl - ? `${proxyUrl}${encodeURIComponent(resourceUrl)}` - : resourceUrl; - const response = await fetch(url, { mode: 'cors' }); - const array = new Uint8Array(await response.arrayBuffer()); - - if (!response.ok) { - throw new Error('Response not Ok for URL: ' + url); - } - return array; -}; - -/** - * Gets the index of the provided href in the readingOrder, or throws an error if one - * is not found. - */ -export function getIndexFromHref( - href: string, - manifest: WebpubManifest -): number { - const input = new URL(href); - const index = manifest?.readingOrder?.findIndex((link) => { - return doHrefsMatch(link.href, input); - }); - if (index < 0) { - throw new Error(`Cannot find resource in readingOrder: ${href}`); - } - return index; -} - -/** - * Compares two hrefs without query params or hash - */ -export function doHrefsMatch( - href1: string | URL, - href2: string | URL -): boolean { - const input1 = new URL(href1); - const input2 = new URL(href2); - return ( - input1.pathname === input2.pathname && - input1.hostname === input2.hostname && - input1.protocol === input2.protocol - ); -} - -/** - * Extracts a start page from a href if it exists and is in the format - * `?startPage=1`. Returns undefined if none found. - */ -export const getStartPageFromHref = (href: string): number | undefined => { - const params = new URL(href).searchParams; - const startPage = params.get(START_QUERY); - return startPage ? parseInt(startPage) : undefined; -}; - -/** - * Extracts a page number from a href if it exists and is in - * the format of `#page=1` - */ -export const getPageNumberFromHref = (href: string): number | undefined => { - const hash = new URL(href).hash; - try { - const strPageNumber = hash.replace('#page=', ''); - if (!strPageNumber || strPageNumber === 'NaN') return undefined; - const pageNumber = parseInt(strPageNumber); - return pageNumber; - } catch (e) { - console.warn(`Failed to parse page number from hash ${hash}`); - return undefined; - } -}; diff --git a/src/PdfReader/pdfReader.css b/src/PdfReader/pdfReader.css index c509fc8e..a19ad0ae 100644 --- a/src/PdfReader/pdfReader.css +++ b/src/PdfReader/pdfReader.css @@ -1,25 +1,369 @@ -.react-pdf__Document { - scrollbar-color: #8d8d8d #424242; +.pdf-root { display: flex; flex-direction: column; - background: #252525; + width: 100%; + height: 100%; + min-height: 400px; + background: #525659; + color: #e8eaed; + overflow: hidden; +} + +.pdf-root:fullscreen { + width: 100vw; + height: 100vh; +} + +.pdf-toolbar { + display: flex; + align-items: center; + justify-content: space-between; gap: 8px; + padding: 6px 10px; + background: #2b2d30; + border-bottom: 1px solid #1c1d1f; + flex-wrap: wrap; + flex-shrink: 0; +} - > div { - background: #252525; - } +.pdf-toolbar-group { + display: flex; + align-items: center; + gap: 4px; +} + +.pdf-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + border: none; + border-radius: 6px; + background: transparent; + color: #e8eaed; + cursor: pointer; + transition: background 0.12s ease; +} + +.pdf-btn:hover:not(:disabled) { + background: rgba(255, 255, 255, 0.1); +} + +.pdf-btn:disabled { + opacity: 0.35; + cursor: default; +} + +.pdf-page-form { + display: flex; + align-items: center; + gap: 4px; + margin: 0 2px; +} + +.pdf-page-input { + width: 40px; + text-align: center; + background: #1c1d1f; + border: 1px solid #3c4043; + border-radius: 4px; + color: #e8eaed; + font-size: 13px; + padding: 4px 2px; +} + +.pdf-page-input:focus { + outline: 1px solid #8ab4f8; +} + +.pdf-page-total, +.pdf-zoom-label { + font-size: 13px; + color: #b0b3b8; + min-width: 42px; + text-align: center; + user-select: none; +} + +/* Body: sidebar + viewport */ +.pdf-body { + display: flex; + flex: 1; + min-height: 0; +} + +.pdf-sidebar { + width: 260px; + flex-shrink: 0; + background: #323639; + border-right: 1px solid #1c1d1f; + overflow-y: auto; } -.react-pdf__Page { +.pdf-sidebar-header { + padding: 12px 14px; + font-size: 12px; + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; + color: #9aa0a6; + border-bottom: 1px solid #1c1d1f; +} + +.pdf-sidebar-empty { + padding: 14px; + font-size: 13px; + color: #7a7d81; +} + +.pdf-toc-list { + list-style: none; + margin: 0; + padding: 0; +} + +.pdf-toc-item { display: block; - background: #252525 !important; + width: 100%; + text-align: left; + background: none; + border: none; + color: #d3d6db; + font-size: 13px; + padding: 7px 12px; + cursor: pointer; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.pdf-toc-item:hover:not(:disabled) { + background: rgba(255, 255, 255, 0.06); +} + +.pdf-toc-item.active { + background: rgba(66, 133, 244, 0.25); + color: #8ab4f8; +} + +.pdf-toc-item:disabled { + color: #6a6d71; + cursor: default; +} + +/* Viewport (scroll container) */ +.pdf-viewport { + flex: 1; + overflow: auto; + position: relative; + background: #252525; + color-scheme: dark; +} + +/* Loading / error states */ +.pdf-status-container { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; +} + +.pdf-status { + display: flex; + flex-direction: column; + align-items: center; + gap: 10px; + color: #b0b3b8; + font-size: 14px; +} + +.pdf-error { + color: #f28b82; +} + +.pdf-pages-stack { + display: flex; + flex-direction: column; + align-items: flex-start; + width: max-content; + min-width: 100%; + padding-top: 16px; + background: #252525; } -.react-pdf__Page { - canvas, - .textLayer, - .annotationLayer { - display: block; - margin: 0 auto; +.pdf-page-wrap { + position: relative; + flex-shrink: 0; + margin-inline: auto; + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.5); + background: #252525; +} + +.pdf-canvas { + display: block; +} + +/* Text and annotation layer styles adapted from react-pdf/pdf.js */ +.pdf-root { + --react-pdf-text-layer: 1; +} + +[data-main-rotation='90'] .pdf-text-layer.textLayer { + transform: rotate(90deg) translateY(-100%); +} + +[data-main-rotation='180'] .pdf-text-layer.textLayer { + transform: rotate(180deg) translate(-100%, -100%); +} + +[data-main-rotation='270'] .pdf-text-layer.textLayer { + transform: rotate(270deg) translateX(-100%); +} + +.pdf-text-layer.textLayer { + position: absolute; + text-align: initial; + inset: 0; + overflow: clip; + opacity: 1; + line-height: 1; + text-size-adjust: none; + forced-color-adjust: none; + transform-origin: 0 0; + caret-color: CanvasText; + z-index: 0; + + --min-font-size: 1; + --total-scale-factor: var(--scale-factor, 1); + --text-scale-factor: calc(var(--total-scale-factor) * var(--min-font-size)); + --min-font-size-inv: calc(1 / var(--min-font-size)); +} + +.pdf-text-layer.textLayer :is(span, br) { + color: transparent; + position: absolute; + white-space: pre; + cursor: text; + margin: 0; + transform-origin: 0 0; +} + +.pdf-text-layer.textLayer > :not(.markedContent), +.pdf-text-layer.textLayer .markedContent span:not(.markedContent) { + z-index: 1; + + --font-height: 0; + font-size: calc(var(--text-scale-factor) * var(--font-height)); + + --scale-x: 1; + --rotate: 0deg; + transform: rotate(var(--rotate)) scaleX(var(--scale-x)) + scale(var(--min-font-size-inv)); +} + +.pdf-text-layer.textLayer .markedContent { + display: contents; +} + +.pdf-text-layer.textLayer span[role='img'] { + user-select: none; + cursor: default; +} + +.pdf-text-layer.textLayer .highlight { + margin: -1px; + padding: 1px; + border-radius: 4px; +} + +.pdf-text-layer.textLayer .highlight.appended { + position: initial; +} + +.pdf-text-layer.textLayer .highlight.begin { + border-radius: 4px 0 0 4px; +} + +.pdf-text-layer.textLayer .highlight.end { + border-radius: 0 4px 4px 0; +} + +.pdf-text-layer.textLayer .highlight.middle { + border-radius: 0; +} + +.pdf-text-layer.textLayer br::selection { + background: transparent; +} + +.pdf-text-layer.textLayer .endOfContent { + display: block; + position: absolute; + inset: 100% 0 0; + z-index: 0; + cursor: default; + user-select: none; +} + +.pdf-text-layer.textLayer.selecting .endOfContent { + top: 0; +} + +.pdf-text-layer.textLayer .hiddenCanvasElement { + position: absolute; + top: 0; + left: 0; + width: 0; + height: 0; + display: none; +} + +.pdf-annotation-layer { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + overflow: hidden; +} + +.pdf-annotation-layer section { + position: absolute; +} + +.pdf-annotation-layer .linkAnnotation > a { + position: absolute; + font-size: 1em; + top: 0; + left: 0; + width: 100%; + height: 100%; +} + +.pdf-annotation-layer .linkAnnotation > a:hover { + background: rgba(255, 255, 0, 0.15); + box-shadow: 0 0 0 1px rgba(255, 255, 0, 0.4); +} + +.pdf-annotation-layer input, +.pdf-annotation-layer textarea, +.pdf-annotation-layer select { + background: rgba(255, 255, 255, 0.6); + border: 1px solid #8ab4f8; + box-sizing: border-box; + font: inherit; +} + +@media (max-width: 640px) { + .pdf-sidebar { + position: absolute; + z-index: 5; + height: 100%; + } + .pdf-page-total, + .pdf-zoom-label { + min-width: unset; } } diff --git a/src/PdfReader/reducer.ts b/src/PdfReader/reducer.ts index fe43a32a..92e0e455 100644 --- a/src/PdfReader/reducer.ts +++ b/src/PdfReader/reducer.ts @@ -1,255 +1,118 @@ -import { DEFAULT_FIT_MODE, DEFAULT_SETTINGS } from '../constants'; -import { - getIndexFromHref, - getPageNumberFromHref, - getStartPageFromHref, -} from './lib'; -import { PdfReaderAction, PdfReaderArguments, PdfState } from './types'; - -export function makePdfReducer( - args: PdfReaderArguments -): (state: PdfState, action: PdfReaderAction) => PdfState { - /** - * If there are no args, it's an inactive hook, or you are pre-first render. - * just use a function that returns the state in most cases so you don't have - * to keep checking if args is defined. - */ - if (!args) return (state: PdfState, _action: PdfReaderAction) => state; - const { manifest } = args; +import { MAX_SCALE, MIN_SCALE, SCALE_STEP } from './constants'; +import { FitMode } from './types'; + +export type PdfReaderState = { + pageNumber: number; + numPages: number; + scale: number; + fitMode: FitMode; + rotation: number; + navigationRequestId: number; +}; + +export type PdfReaderAction = + | { type: 'GO_FORWARD' } + | { type: 'GO_BACKWARD' } + | { type: 'GO_TO_PAGE'; page: number } + | { type: 'GO_TO_HREF'; href: string } + | { type: 'PAGE_IN_VIEW'; page: number } + | { type: 'PAGES_LOADED'; numPages: number } + | { type: 'ZOOM_IN' } + | { type: 'ZOOM_OUT' } + | { type: 'ROTATE_CCW'; nextScale?: number } + | { type: 'SET_FIT'; mode: FitMode } + | { type: 'SET_SCALE'; scale: number }; + +function handleInvalidTransition( + state: PdfReaderState, + action: PdfReaderAction +): PdfReaderState { + console.trace( + `Invalid state transition attempted with action: ${action.type}` + ); + return state; +} - return function reducer(state: PdfState, action: PdfReaderAction): PdfState { - if (state.state !== 'ACTIVE' && action.type !== 'ARGS_CHANGED') { - return handleInvalidTransition(state, action); +export function pdfReaderReducer( + state: PdfReaderState, + action: PdfReaderAction +): PdfReaderState { + switch (action.type) { + case 'GO_FORWARD': { + const next = state.pageNumber + 1; + if (next > state.numPages) return state; + return { + ...state, + pageNumber: next, + navigationRequestId: state.navigationRequestId + 1, + }; } - /** - * Utility function to generate state navigating us to a given resource and page. - * Used by multiple cases below. - */ - function goToLocation(index: number, page = 1): PdfState { - // only set the resource to null if you're actually changing resources (not just - // navigating to a different page in the same resource) - const shouldResetResource = state.resourceIndex !== index; + case 'GO_BACKWARD': { + const prev = state.pageNumber - 1; + if (prev < 1) return state; + return { + ...state, + pageNumber: prev, + navigationRequestId: state.navigationRequestId + 1, + }; + } - const newState = { + case 'GO_TO_PAGE': { + const numPages = state.numPages || 1; + const page = Math.max(1, Math.min(action.page, numPages)); + return { ...state, - resourceIndex: index, pageNumber: page, + navigationRequestId: state.navigationRequestId + 1, }; - if (shouldResetResource) { - return { - ...newState, - resource: null, - numPages: null, - rendered: false, - pageHeight: undefined, - pageWidth: undefined, - pdfHeight: 0, - pdfWidth: 0, - fitMode: DEFAULT_FIT_MODE, - rotation: 0, - }; - } - return newState; } - switch (action.type) { - case 'ARGS_CHANGED': { - return { - state: 'ACTIVE', - settings: DEFAULT_SETTINGS, - resourceIndex: 0, - resource: null, - pageNumber: 1, - numPages: null, - scale: 1, - pdfWidth: 0, - pdfHeight: 0, - pageHeight: undefined, - pageWidth: undefined, - atStart: true, - atEnd: false, - rendered: false, - fitMode: DEFAULT_FIT_MODE, - rotation: 0, - }; - } - - case 'GO_FORWARD': { - /** - * Navigate forward one page or one resource if at the end of the current - * resource. Do nothing at the end of the last resource. - */ - // do nothing if we have not parsed the number of pages yet. - if (!state.numPages) return state; - const atEndOfResource = state.pageNumber === state.numPages; - const atEndOfBook = - state.resourceIndex === args.manifest.readingOrder.length - 1; - - if (atEndOfResource) { - if (atEndOfBook) return state; - // go to next resource - return goToLocation(state.resourceIndex + 1); - } - // go to next page - return goToLocation(state.resourceIndex, state.pageNumber + 1); - } - - case 'GO_BACKWARD': { - /** - * Navigate backward one page or to the end of the previous resource - * if at the beginning of the current resource. Do nothing at the - * beginning of the first resource. - */ - // do nothing if we have not parsed the number of pages yet. - if (!state.numPages) return state; - const atStartOfResource = state.pageNumber === 1; - const atStartOfBook = state.resourceIndex === 0; - if (atStartOfResource) { - if (atStartOfBook) return state; - // go to end of prev resource - return { - ...goToLocation(state.resourceIndex - 1, -1), - }; - } - // go to prev page, allowing navigation below startPage within the resource - return goToLocation( - state.resourceIndex, - Math.max(1, state.pageNumber - 1) - ); - } - - case 'GO_TO_HREF': { - const resourceIndex = getIndexFromHref(action.href, args.manifest); - const startPage = getStartPageFromHref(action.href); - const pageNumber = getPageNumberFromHref(action.href); - - const page = pageNumber ?? startPage ?? 1; - return goToLocation(resourceIndex, page); - } - - case 'GO_TO_PAGE': { - const numPages = state.numPages || 1; - const page = Math.max(1, Math.min(action.page, numPages)); - return goToLocation(state.resourceIndex, page); - } - - case 'PAGE_IN_VIEW': { - if (state.pageNumber === action.page) return state; - return { - ...state, - pageNumber: action.page, - }; - } - - case 'RESOURCE_FETCH_SUCCESS': - return { - ...state, - resource: action.resource, - }; - - // called when the resource has been parsed by react-pdf - // and we know the number of pages - case 'PDF_PARSED': { - const { numPages } = action; - const { pageNumber: currentPage, resourceIndex } = state; - - const currentHref = manifest.readingOrder[resourceIndex]?.href; - const startPage = getStartPageFromHref(currentHref) ?? 1; - - // 1. If -1, go to the end. - // 2. Otherwise, ensure we don't fall below startPage. - const pageNumber = - currentPage === -1 ? numPages : Math.max(currentPage, startPage); - - return { - ...state, - numPages, - pageNumber, - }; - } - - case 'PDF_LOAD_ERROR': - return { - ...state, - state: 'ERROR', - error: action.error, - settings: DEFAULT_SETTINGS, - }; - - case 'SET_SCROLL': - if (state.state !== 'ACTIVE') { - return handleInvalidTransition(state, action); - } - return { - ...state, - settings: { - ...state.settings, - isScrolling: action.isScrolling, - }, - }; - - case 'SET_SCALE': - return { - ...state, - scale: action.scale, - }; - - // case 'RESET_SETTINGS': - // if (state.state === 'INACTIVE') { - // return handleInvalidTransition(state, action); - // } - - // return { - // ...state, - // settings: DEFAULT_SETTINGS, - // scale: 1, - // }; + case 'GO_TO_HREF': { + const parsed = parseInt(action.href, 10); + if (Number.isNaN(parsed)) return handleInvalidTransition(state, action); + const numPages = state.numPages || 1; + const page = Math.max(1, Math.min(parsed, numPages)); + return { + ...state, + pageNumber: page, + navigationRequestId: state.navigationRequestId + 1, + }; + } - case 'PAGE_LOAD_SUCCESS': - return { - ...state, - rendered: true, - pdfWidth: action.width, - pdfHeight: action.height, - pageWidth: action.width, - pageHeight: action.height, - }; + case 'PAGE_IN_VIEW': { + if (state.pageNumber === action.page) return state; + return { ...state, pageNumber: action.page }; + } - case 'RESIZE_PAGE': - return { - ...state, - pageWidth: action.width, - pageHeight: action.height, - }; + case 'PAGES_LOADED': + return { ...state, numPages: action.numPages }; - case 'SET_FIT_MODE': - return { ...state, fitMode: action.fitMode, scale: 1 }; + case 'ZOOM_IN': + return { + ...state, + fitMode: null, + scale: Math.min(MAX_SCALE, +(state.scale + SCALE_STEP).toFixed(2)), + }; - case 'ROTATE_COUNTER_CLOCKWISE': { - const newRotation = ((state.rotation ?? 0) - 90 + 360) % 360; + case 'ZOOM_OUT': + return { + ...state, + fitMode: null, + scale: Math.max(MIN_SCALE, +(state.scale - SCALE_STEP).toFixed(2)), + }; - return { - ...state, - rotation: newRotation, - fitMode: - newRotation === 0 || newRotation === 180 ? 'height' : 'width', - }; - } + case 'ROTATE_CCW': + return { + ...state, + rotation: (state.rotation + 270) % 360, + scale: action.nextScale ?? state.scale, + }; - case 'BOOK_BOUNDARY_CHANGED': - return { - ...state, - atStart: action.atStart, - atEnd: action.atEnd, - }; - } - }; -} + case 'SET_FIT': + return { ...state, fitMode: action.mode }; -function handleInvalidTransition(state: PdfState, action: PdfReaderAction) { - console.trace( - `Inavlid state transition attempted: ${state} with ${action.type}` - ); - return state; + case 'SET_SCALE': + return { ...state, scale: action.scale }; + } } diff --git a/src/PdfReader/types.ts b/src/PdfReader/types.ts index 4255a645..17c7caf6 100644 --- a/src/PdfReader/types.ts +++ b/src/PdfReader/types.ts @@ -1,68 +1,63 @@ -import { - ActiveReaderArguments, - FitMode, - InactiveReaderArguments, - ReaderSettings, - ReaderState, -} from '../types'; +import { Dispatch } from 'react'; +import { WebpubManifest } from '../types'; +import { PdfReaderAction } from './reducer'; -export type InternalState = { - resourceIndex: number; - resource: { data: Uint8Array } | null; - // we only know the numPages once the resource has been parsed - numPages: number | null; - // if pageNumber is -1, we will navigate to the end of the - // resource once it is parsed +export type FitMode = 'width' | 'height' | null; + +export interface OutlineItem { + title: string; + pageNumber: number | null; + items: OutlineItem[]; +} + +export interface PageSize { + width: number; + height: number; +} + +export interface ViewportAnchor { pageNumber: number; - scale: number; - pdfHeight: number; - pdfWidth: number; - pageHeight: number | undefined; - pageWidth: number | undefined; - rendered: boolean; -}; + intraPageRatio: number; + viewportOffset: number; +} -export type InactiveState = ReaderState & - InternalState & { state: 'INACTIVE'; settings: undefined }; +export interface PdfReaderProps { + fileUrl?: string; + webpubManifestUrl?: string; + manifest?: WebpubManifest; + proxyUrl?: string; + pdfWorkerSrc?: string; + height?: string | number; + className?: string; + toggleFullScreen?: () => void; +} -export type ActiveState = ReaderState & - InternalState & { state: 'ACTIVE'; settings: ReaderSettings }; +export interface PdfReaderContentProps { + fileUrl: string | undefined; + pdfWorkerSrc?: string; + pageNumber: number; + navigationRequestId: number; + scale: number; + fitMode: FitMode; + rotation: number; + dispatch: Dispatch; + pendingAction: PdfReaderAction | null; + clearPendingAction: () => void; + onOutlineLoad: (outlineItems: OutlineItem[]) => void; + onPageSizesReady?: () => void; +} -export type ErrorState = ReaderState & - InternalState & { - state: 'ERROR'; - error: Error; - settings: ReaderSettings; - }; +export interface RenderTask { + promise: Promise; + cancel: () => void; +} -export type PdfState = InactiveState | ActiveState | ErrorState; +export interface PdfOutlineEntry { + title: string; + dest: string | unknown[] | null; + items?: PdfOutlineEntry[]; +} -export type PdfReaderArguments = - | ActiveReaderArguments - | InactiveReaderArguments; +export type RefProxy = import('pdfjs-dist/types/src/display/api').RefProxy; -export type PdfReaderAction = - | { - type: 'ARGS_CHANGED'; - args: PdfReaderArguments; - } - | { type: 'GO_FORWARD' } - | { type: 'GO_BACKWARD' } - | { type: 'GO_TO_HREF'; href: string } - | { type: 'GO_TO_PAGE'; page: number } - | { type: 'PAGE_IN_VIEW'; page: number } - | { type: 'RESOURCE_FETCH_SUCCESS'; resource: { data: Uint8Array } } - | { type: 'PDF_PARSED'; numPages: number } - | { type: 'PDF_LOAD_ERROR'; error: Error } - | { type: 'SET_SCALE'; scale: number } - // | { type: 'RESET_SETTINGS' } may be needed in future - | { type: 'SET_SCROLL'; isScrolling: boolean } - | { type: 'PAGE_LOAD_SUCCESS'; height: number; width: number } - | { - type: 'RESIZE_PAGE'; - height: number | undefined; - width: number | undefined; - } - | { type: 'SET_FIT_MODE'; fitMode: FitMode } - | { type: 'ROTATE_COUNTER_CLOCKWISE' } - | { type: 'BOOK_BOUNDARY_CHANGED'; atStart: boolean; atEnd: boolean }; +export type PdfBinaryInput = Blob | File | Uint8Array | ArrayBuffer; diff --git a/src/PdfReader/useMeasure.tsx b/src/PdfReader/useMeasure.tsx deleted file mode 100644 index 6aec227b..00000000 --- a/src/PdfReader/useMeasure.tsx +++ /dev/null @@ -1,71 +0,0 @@ -import * as React from 'react'; - -declare const window: Window & - typeof globalThis & { - ResizeObserver: ResizeObserver; - }; -export type Dimensions = Pick< - DOMRectReadOnly, - 'x' | 'y' | 'top' | 'left' | 'right' | 'bottom' | 'height' | 'width' ->; -export type UseMeasureRef = (element: E) => void; -export type UseMeasureResult = [ - UseMeasureRef | null, - Dimensions, -]; - -const DEFAULT_DIMENSION = { - x: 0, - y: 0, - width: 0, - height: 0, - top: 0, - left: 0, - bottom: 0, - right: 0, -}; - -export default function useMeasure< - E extends Element = Element, ->(): UseMeasureResult { - // this is a little trick to get a reference to an HTML element. Using useRef wouldn't - // work because we actually need rerenders when it changes, to update the useLayoutEffect - const [element, ref] = React.useState(null); - const [rect, setRect] = React.useState(DEFAULT_DIMENSION); - const observer = React.useMemo( - () => - new window.ResizeObserver( - ( - entries: { - contentRect: { - x: number; - y: number; - width: number; - height: number; - top: number; - left: number; - bottom: number; - right: number; - }; - }[] - ) => { - if (entries[0]) { - const { x, y, width, height, top, left, bottom, right } = - entries[0].contentRect; - setRect({ x, y, width, height, top, left, bottom, right }); - } - } - ), - [] - ); - - React.useLayoutEffect(() => { - if (!element) return; - observer.observe(element); - return () => { - observer.disconnect(); - }; - }, [element, observer]); - - return [ref, rect]; -} diff --git a/src/NewReader/utils.ts b/src/PdfReader/utils.ts similarity index 85% rename from src/NewReader/utils.ts rename to src/PdfReader/utils.ts index d17ae2bb..b8c743bb 100644 --- a/src/NewReader/utils.ts +++ b/src/PdfReader/utils.ts @@ -117,3 +117,20 @@ export const resolveResourceUrl = ( if (!href) return undefined; return proxyUrl ? `${proxyUrl}${encodeURIComponent(href)}` : href; }; + +/** + * Extracts a page number from a href if it exists and is in + * the format of `#page=1` + */ +export const getPageNumberFromHref = (href: string): number | undefined => { + const hash = new URL(href).hash; + try { + const strPageNumber = hash.replace('#page=', ''); + if (!strPageNumber || strPageNumber === 'NaN') return undefined; + const pageNumber = parseInt(strPageNumber); + return pageNumber; + } catch (e) { + console.warn(`Failed to parse page number from hash ${hash}`); + return undefined; + } +}; diff --git a/src/index.tsx b/src/index.tsx index 242c69b1..42f43f14 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -39,7 +39,6 @@ export default WebReader; export * from './constants'; export { default as useHtmlReader } from './HtmlReader'; -export { default as NewReader, useNewReader } from './NewReader'; export { default as usePdfReader } from './PdfReader'; export { default as addTocToManifest } from './PdfReader/addTocToManifest'; export { default as useColorModeValue } from './ui/hooks/useColorModeValue'; diff --git a/src/useWebReader.tsx b/src/useWebReader.tsx index fbd832da..245fc91c 100644 --- a/src/useWebReader.tsx +++ b/src/useWebReader.tsx @@ -4,7 +4,7 @@ import { DEFAULT_SHOULD_GROW_WHEN_SCROLLING, } from './constants'; import useHtmlReader from './HtmlReader'; -import { useNewReader } from './NewReader'; +import usePdfReader from './PdfReader'; import { GetContent, HTMLActiveReader, @@ -93,7 +93,7 @@ export default function useWebReader( : undefined ); - const newPdfReader = useNewReader( + const newPdfReader = usePdfReader( readerType === 'PDF' && manifest ? { webpubManifestUrl, diff --git a/tests/PdfReducer.test.ts b/tests/PdfReducer.test.ts index d0d7afd3..e0297331 100644 --- a/tests/PdfReducer.test.ts +++ b/tests/PdfReducer.test.ts @@ -1,84 +1,152 @@ -import { makePdfReducer } from '../src/PdfReader/reducer'; -import { PdfReaderArguments, PdfState } from '../src/PdfReader/types'; -import { DEFAULT_FIT_MODE, DEFAULT_SETTINGS } from '../src/constants'; - -function makeArgs(href: string): PdfReaderArguments { - return { - manifest: { - metadata: { title: 'Test' }, - readingOrder: [{ href, type: 'application/pdf' }], - }, - } as unknown as PdfReaderArguments; -} - -const baseState: PdfState = { - state: 'ACTIVE', - settings: DEFAULT_SETTINGS, - resourceIndex: 0, - resource: null, +import { pdfReaderReducer, PdfReaderState } from '../src/PdfReader/reducer'; + +const baseState: PdfReaderState = { pageNumber: 1, - numPages: null, + numPages: 0, scale: 1, - pdfWidth: 0, - pdfHeight: 0, - pageHeight: undefined, - pageWidth: undefined, - atStart: true, - atEnd: false, - rendered: false, - fitMode: DEFAULT_FIT_MODE, + fitMode: 'width', rotation: 0, + navigationRequestId: 0, }; -describe('makePdfReducer — PDF_PARSED', () => { - it('keeps page 1 when no start query param is present', () => { - const args = makeArgs('https://example.com/doc.pdf'); - const reducer = makePdfReducer(args); - const state = reducer(baseState, { type: 'PDF_PARSED', numPages: 100 }); - expect(state.pageNumber).toBe(1); +describe('pdfReaderReducer — GO_FORWARD', () => { + it('advances to the next page', () => { + const state = { ...baseState, pageNumber: 5, numPages: 100 }; + const result = pdfReaderReducer(state, { type: 'GO_FORWARD' }); + expect(result.pageNumber).toBe(6); + expect(result.navigationRequestId).toBe(1); }); - it('jumps to start on initial load when pageNumber is below start', () => { - const args = makeArgs('https://example.com/doc.pdf?start=15'); - const reducer = makePdfReducer(args); - const state = reducer(baseState, { type: 'PDF_PARSED', numPages: 100 }); - expect(state.pageNumber).toBe(15); + it('does not advance past the last page', () => { + const state = { ...baseState, pageNumber: 100, numPages: 100 }; + const result = pdfReaderReducer(state, { type: 'GO_FORWARD' }); + expect(result.pageNumber).toBe(100); + expect(result.navigationRequestId).toBe(0); }); +}); - it('does not override pageNumber when user has already navigated past start', () => { - const args = makeArgs('https://example.com/doc.pdf?start=15'); - const reducer = makePdfReducer(args); - const navigatedState = { ...baseState, pageNumber: 20 }; - const state = reducer(navigatedState, { - type: 'PDF_PARSED', - numPages: 100, - }); - expect(state.pageNumber).toBe(20); +describe('pdfReaderReducer — GO_BACKWARD', () => { + it('goes back a page', () => { + const state = { ...baseState, pageNumber: 5, numPages: 100 }; + const result = pdfReaderReducer(state, { type: 'GO_BACKWARD' }); + expect(result.pageNumber).toBe(4); + expect(result.navigationRequestId).toBe(1); + }); + + it('does not go below page 1', () => { + const state = { ...baseState, pageNumber: 1, numPages: 100 }; + const result = pdfReaderReducer(state, { type: 'GO_BACKWARD' }); + expect(result.pageNumber).toBe(1); + expect(result.navigationRequestId).toBe(0); + }); +}); + +describe('pdfReaderReducer — GO_TO_PAGE', () => { + it('navigates to a valid page', () => { + const state = { ...baseState, pageNumber: 1, numPages: 100 }; + const result = pdfReaderReducer(state, { type: 'GO_TO_PAGE', page: 42 }); + expect(result.pageNumber).toBe(42); + expect(result.navigationRequestId).toBe(1); }); it('navigates to last page when pageNumber is -1', () => { - const args = makeArgs('https://example.com/doc.pdf'); - const reducer = makePdfReducer(args); - const endState = { ...baseState, pageNumber: -1 }; - const state = reducer(endState, { type: 'PDF_PARSED', numPages: 100 }); - expect(state.pageNumber).toBe(100); + const state = { ...baseState, pageNumber: 50, numPages: 100 }; + const result = pdfReaderReducer(state, { type: 'GO_TO_PAGE', page: -1 }); + expect(result.pageNumber).toBe(1); }); }); -describe('makePdfReducer — GO_BACKWARD', () => { - it('allows navigating back below start page within the same resource', () => { - const args = makeArgs('https://example.com/doc.pdf?start=5'); - const reducer = makePdfReducer(args); - const onStartPage = { ...baseState, pageNumber: 5, numPages: 100 }; - const state = reducer(onStartPage, { type: 'GO_BACKWARD' }); - expect(state.pageNumber).toBe(4); +describe('pdfReaderReducer — PAGE_IN_VIEW', () => { + it('updates the current page without bumping navigationRequestId', () => { + const state = { ...baseState, pageNumber: 1, numPages: 100 }; + const result = pdfReaderReducer(state, { + type: 'PAGE_IN_VIEW', + page: 7, + }); + expect(result.pageNumber).toBe(7); + expect(result.navigationRequestId).toBe(0); }); - it('does not go below page 1', () => { - const args = makeArgs('https://example.com/doc.pdf'); - const reducer = makePdfReducer(args); - const onFirstPage = { ...baseState, pageNumber: 1, numPages: 100 }; - const state = reducer(onFirstPage, { type: 'GO_BACKWARD' }); - expect(state.pageNumber).toBe(1); + it('is a no-op when already on that page', () => { + const state = { ...baseState, pageNumber: 7, numPages: 100 }; + const result = pdfReaderReducer(state, { + type: 'PAGE_IN_VIEW', + page: 7, + }); + expect(result).toBe(state); + }); +}); + +describe('pdfReaderReducer — PAGES_LOADED', () => { + it('sets numPages', () => { + const result = pdfReaderReducer(baseState, { + type: 'PAGES_LOADED', + numPages: 250, + }); + expect(result.numPages).toBe(250); + }); +}); + +describe('pdfReaderReducer — ZOOM_IN / ZOOM_OUT', () => { + it('increases scale and clears fitMode', () => { + const state = { ...baseState, scale: 1, fitMode: 'width' as const }; + const result = pdfReaderReducer(state, { type: 'ZOOM_IN' }); + expect(result.scale).toBeCloseTo(1.1); + expect(result.fitMode).toBeNull(); + }); + + it('does not zoom in past the max scale', () => { + const state = { ...baseState, scale: 5, fitMode: null }; + const result = pdfReaderReducer(state, { type: 'ZOOM_IN' }); + expect(result.scale).toBe(5); + }); + + it('decreases scale and clears fitMode', () => { + const state = { ...baseState, scale: 1, fitMode: 'width' as const }; + const result = pdfReaderReducer(state, { type: 'ZOOM_OUT' }); + expect(result.scale).toBeCloseTo(0.9); + expect(result.fitMode).toBeNull(); + }); + + it('does not zoom out past the min scale', () => { + const state = { ...baseState, scale: 0.25, fitMode: null }; + const result = pdfReaderReducer(state, { type: 'ZOOM_OUT' }); + expect(result.scale).toBe(0.25); + }); +}); + +describe('pdfReaderReducer — ROTATE_CCW', () => { + it('rotates counter-clockwise by 270 degrees, wrapping at 360', () => { + const state = { ...baseState, rotation: 90 }; + const result = pdfReaderReducer(state, { type: 'ROTATE_CCW' }); + expect(result.rotation).toBe(0); + }); + + it('applies an explicit nextScale when provided', () => { + const state = { ...baseState, rotation: 0, scale: 1 }; + const result = pdfReaderReducer(state, { + type: 'ROTATE_CCW', + nextScale: 1.5, + }); + expect(result.rotation).toBe(270); + expect(result.scale).toBe(1.5); + }); +}); + +describe('pdfReaderReducer — SET_FIT / SET_SCALE', () => { + it('sets the fit mode', () => { + const result = pdfReaderReducer(baseState, { + type: 'SET_FIT', + mode: 'height', + }); + expect(result.fitMode).toBe('height'); + }); + + it('sets the scale', () => { + const result = pdfReaderReducer(baseState, { + type: 'SET_SCALE', + scale: 2, + }); + expect(result.scale).toBe(2); }); }); From 71629b810724df38a9cf1be0cd0dcfe3621f9135 Mon Sep 17 00:00:00 2001 From: Jackie Quach Date: Wed, 19 Aug 2026 11:30:14 -0400 Subject: [PATCH 08/11] update imports --- package-lock.json | 256 ++++++++++++++++++----------- src/PdfReader/PdfReaderContent.tsx | 1 - src/PdfReader/index.tsx | 2 +- 3 files changed, 161 insertions(+), 98 deletions(-) diff --git a/package-lock.json b/package-lock.json index 7d354138..e60a34b3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7974,55 +7974,43 @@ } }, "node_modules/axios": { - "version": "1.15.2", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.15.2.tgz", - "integrity": "sha512-wLrXxPtcrPTsNlJmKjkPnNPK2Ihe0hn0wGSaTEiHRPxwjvJwT3hKmXF4dpqxmPO9SoNb2FsYXj/xEo0gHN+D5A==", + "version": "1.19.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.19.0.tgz", + "integrity": "sha512-ht/iuYZXEjFxLH/Hkezgd7m6JKlHHXEUSneaDz8uZe1Gj5QZtCnpyDsckvAiEnT89OEbCLmnte4R4sn7P0EKFw==", "dev": true, "license": "MIT", "dependencies": { - "follow-redirects": "^1.15.11", - "form-data": "^4.0.5", + "follow-redirects": "^1.16.0", + "form-data": "^4.0.6", + "https-proxy-agent": "^5.0.1", "proxy-from-env": "^2.1.0" } }, - "node_modules/axios/node_modules/form-data": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", - "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "node_modules/axios/node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", "dev": true, "license": "MIT", "dependencies": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.8", - "es-set-tostringtag": "^2.1.0", - "hasown": "^2.0.2", - "mime-types": "^2.1.12" + "debug": "4" }, "engines": { - "node": ">= 6" + "node": ">= 6.0.0" } }, - "node_modules/axios/node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/axios/node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "node_modules/axios/node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", "dev": true, "license": "MIT", "dependencies": { - "mime-db": "1.52.0" + "agent-base": "6", + "debug": "4" }, "engines": { - "node": ">= 0.6" + "node": ">= 6" } }, "node_modules/axios/node_modules/proxy-from-env": { @@ -8233,19 +8221,22 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.9.16", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.16.tgz", - "integrity": "sha512-KeUZdBuxngy825i8xvzaK1Ncnkx0tBmb3k8DkEuqjKRkmtvNTjey2ZsNeh8Dw4lfKvbCOu9oeNx2TKm2vHqcRw==", + "version": "2.11.15", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.15.tgz", + "integrity": "sha512-FwMjJJ7HnyZpWe+oWxegG0fezZyBZUagI5LZEoO3GCbtbKNwRfMH9Ue5d5v01PNePBy1QSfPSDTTeVL0Hb9EzA==", "dev": true, "license": "Apache-2.0", "bin": { - "baseline-browser-mapping": "dist/cli.js" + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" } }, "node_modules/basic-ftp": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.3.0.tgz", - "integrity": "sha512-5K9eNNn7ywHPsYnFwjKgYH8Hf8B5emh7JKcPaVjjrMJFQQwGpwowEnZNEtHs7DfR7hCZsmaK3VA4HUK0YarT+w==", + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.3.1.tgz", + "integrity": "sha512-bopVNp6ugyA150DDuZfPFdt1KZ5a94ZDiwX4hMgZDzF+GttD80lEy8kj98kbyhLXnPvhtIo93mdnLIjpCAeeOw==", "dev": true, "license": "MIT", "engines": { @@ -8292,22 +8283,36 @@ "license": "MIT" }, "node_modules/body-parser": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", - "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", "dev": true, "license": "MIT", "dependencies": { "bytes": "^3.1.2", - "content-type": "^1.0.5", + "content-type": "^2.0.0", "debug": "^4.4.3", - "http-errors": "^2.0.0", - "iconv-lite": "^0.7.0", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", "on-finished": "^2.4.1", - "qs": "^6.14.1", - "raw-body": "^3.0.1", - "type-is": "^2.0.1" + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" + }, + "engines": { + "node": ">=18" }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/body-parser/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "dev": true, + "license": "MIT", "engines": { "node": ">=18" }, @@ -8317,9 +8322,9 @@ } }, "node_modules/brace-expansion": { - "version": "1.1.13", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", - "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", "dev": true, "license": "MIT", "dependencies": { @@ -8603,9 +8608,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001765", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001765.tgz", - "integrity": "sha512-LWcNtSyZrakjECqmpP4qdg0MMGdN368D7X8XvvAqOcqMv0RxnlqVKZl2V6/mBR68oYMxOZPLw/gO7DuisMHUvQ==", + "version": "1.0.30001809", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001809.tgz", + "integrity": "sha512-xxWVywk6a6Arlk+hymeycyn/VgqEfLDxupvhH/xiY5SJ/18kmi9o6MiO320DCUzypORHLtvh0I4i04tUhCNHNQ==", "dev": true, "funding": [ { @@ -10835,9 +10840,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", - "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz", + "integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==", "dev": true, "funding": [ { @@ -11023,6 +11028,46 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/form-data/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/form-data/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -11588,9 +11633,9 @@ } }, "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -11913,9 +11958,9 @@ } }, "node_modules/ip-address": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", - "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.5.0.tgz", + "integrity": "sha512-R5SnVLJmgYYvf2F2ZgwSBnelz5G4q5AxIC277GDfUaNbrZKNANcBC7RHqYYePlszf4kBolVkJauG0ZjHHFh55g==", "dev": true, "license": "MIT", "engines": { @@ -14107,9 +14152,9 @@ } }, "node_modules/joi": { - "version": "17.13.3", - "resolved": "https://registry.npmjs.org/joi/-/joi-17.13.3.tgz", - "integrity": "sha512-otDA4ldcIx+ZXsKHWmp0YizCweVRZG96J10b0FevjfuncLO1oX59THoAmHkNubYJ+9gWsYsp5k8v4ib6oDv1fA==", + "version": "17.13.6", + "resolved": "https://registry.npmjs.org/joi/-/joi-17.13.6.tgz", + "integrity": "sha512-ImNZaq/LSysofih+xIGYfR0WUXMA9GLUNB//YTCSrZptoRmVgaNAdJyi6K1kXi9pkLEoSkoI8I4UwtNiu/D7nw==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -14137,9 +14182,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "3.14.2", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", - "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", + "version": "3.15.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.1.tgz", + "integrity": "sha512-S99WuO3HlhO3XN41EtYUNl9zzXjoJx7QvmipxsJVxtCBT0YHEFy+iOJhjSvrmV12nYhWpZaM8lPHkJm0yUMbag==", "dev": true, "license": "MIT", "dependencies": { @@ -15373,9 +15418,9 @@ } }, "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", "dev": true, "funding": [ { @@ -16589,9 +16634,9 @@ } }, "node_modules/postcss": { - "version": "8.5.6", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", - "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", "dev": true, "funding": [ { @@ -16611,7 +16656,7 @@ "optional": true, "peer": true, "dependencies": { - "nanoid": "^3.3.11", + "nanoid": "^3.3.17", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -16893,13 +16938,14 @@ "license": "MIT" }, "node_modules/qs": { - "version": "6.15.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz", - "integrity": "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==", + "version": "6.15.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", + "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", "dev": true, "license": "BSD-3-Clause", "dependencies": { - "side-channel": "^1.1.0" + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" }, "engines": { "node": ">=0.6" @@ -18051,15 +18097,15 @@ } }, "node_modules/side-channel": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", - "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", - "object-inspect": "^1.13.3", - "side-channel-list": "^1.0.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" }, @@ -18071,14 +18117,14 @@ } }, "node_modules/side-channel-list": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", - "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", - "object-inspect": "^1.13.3" + "object-inspect": "^1.13.4" }, "engines": { "node": ">= 0.4" @@ -19666,18 +19712,36 @@ } }, "node_modules/type-is": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", - "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", "dev": true, "license": "MIT", "dependencies": { - "content-type": "^1.0.5", + "content-type": "^2.0.0", "media-typer": "^1.1.0", "mime-types": "^3.0.0" }, "engines": { - "node": ">= 0.6" + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/typed-array-buffer": { @@ -20468,9 +20532,9 @@ } }, "node_modules/ws": { - "version": "8.19.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", - "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz", + "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==", "dev": true, "license": "MIT", "engines": { diff --git a/src/PdfReader/PdfReaderContent.tsx b/src/PdfReader/PdfReaderContent.tsx index e41a1700..71292a7f 100644 --- a/src/PdfReader/PdfReaderContent.tsx +++ b/src/PdfReader/PdfReaderContent.tsx @@ -11,7 +11,6 @@ import React, { } from 'react'; import { MAIN_CONTENT_ID } from '../constants'; import PdfPage from './PdfPage'; -import './PdfReader.css'; import { PAGE_GAP, PAGE_PADDING, diff --git a/src/PdfReader/index.tsx b/src/PdfReader/index.tsx index 4f1e4384..f9365f85 100644 --- a/src/PdfReader/index.tsx +++ b/src/PdfReader/index.tsx @@ -19,7 +19,7 @@ import { WebpubManifest, } from '../types'; import LoadingSkeleton from '../ui/LoadingSkeleton'; -import './PdfReader.css'; +import './pdfReader.css'; import PdfReaderContent from './PdfReaderContent'; import { PdfReaderAction, pdfReaderReducer, PdfReaderState } from './reducer'; import { OutlineItem, PdfReaderProps } from './types'; From 30ca3b572964b885136a16be95cc18179f7ebae9 Mon Sep 17 00:00:00 2001 From: Jackie Quach Date: Wed, 19 Aug 2026 11:38:49 -0400 Subject: [PATCH 09/11] update var names --- src/useWebReader.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/useWebReader.tsx b/src/useWebReader.tsx index 245fc91c..268f2753 100644 --- a/src/useWebReader.tsx +++ b/src/useWebReader.tsx @@ -93,7 +93,7 @@ export default function useWebReader( : undefined ); - const newPdfReader = usePdfReader( + const pdfReader = usePdfReader( readerType === 'PDF' && manifest ? { webpubManifestUrl, @@ -131,8 +131,8 @@ export default function useWebReader( if (htmlReader) { return htmlReader; } - if (newPdfReader) { - return newPdfReader; + if (pdfReader) { + return pdfReader; } throw new Error( From 179203ff32d595e5fe1b5c27d865b7ecc5aa1bd4 Mon Sep 17 00:00:00 2001 From: Jackie Quach Date: Wed, 19 Aug 2026 12:01:29 -0400 Subject: [PATCH 10/11] fix double resize issue --- src/PdfReader/PdfReaderContent.tsx | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/PdfReader/PdfReaderContent.tsx b/src/PdfReader/PdfReaderContent.tsx index 71292a7f..55945df3 100644 --- a/src/PdfReader/PdfReaderContent.tsx +++ b/src/PdfReader/PdfReaderContent.tsx @@ -430,6 +430,16 @@ const PdfReaderContent = ({ }, [fitMode, pageBaseSizes]); // Re-apply fit scale on container resize. + const fitModeRef = useRef(fitMode); + useEffect(() => { + fitModeRef.current = fitMode; + }, [fitMode]); + + const applyFitScaleRef = useRef(applyFitScale); + useEffect(() => { + applyFitScaleRef.current = applyFitScale; + }, [applyFitScale]); + useEffect(() => { const wrap = viewportWrapRef.current; if (!wrap) return undefined; @@ -438,11 +448,11 @@ const PdfReaderContent = ({ suppressNextResizeFitRef.current = false; return; } - if (fitMode) applyFitScale(fitMode); + if (fitModeRef.current) applyFitScaleRef.current(fitModeRef.current); }); ro.observe(wrap); return () => ro.disconnect(); - }, [fitMode, applyFitScale]); + }, []); // Keyboard shortcuts useEffect(() => { From 9198a59839f2fc9ce202123f9377440923090326 Mon Sep 17 00:00:00 2001 From: Jackie Quach Date: Wed, 19 Aug 2026 12:30:32 -0400 Subject: [PATCH 11/11] revert header changes --- src/ui/Header.tsx | 384 +++++++++++++----------------------------- tests/Header.test.tsx | 14 -- 2 files changed, 114 insertions(+), 284 deletions(-) diff --git a/src/ui/Header.tsx b/src/ui/Header.tsx index d18555c5..a75df5f3 100644 --- a/src/ui/Header.tsx +++ b/src/ui/Header.tsx @@ -1,12 +1,4 @@ -import { - Flex, - HStack, - Icon, - Input, - Spacer, - Text, - useMediaQuery, -} from '@chakra-ui/react'; +import { Flex, HStack, Icon, Input, Spacer, Text } from '@chakra-ui/react'; import React, { ComponentProps, useEffect, useState } from 'react'; import { ActiveReader } from '../types'; import useColorModeValue from '../ui/hooks/useColorModeValue'; @@ -120,8 +112,6 @@ export default function Header( }; }, [handleFullscreen, isFullScreen, toggleFullScreen]); - const [isLargerThan479] = useMediaQuery('(min-width: 479px)'); - return ( - {isLargerThan479 ? ( - - - {type === 'PDF' && } - {type === 'HTML' && ( - - )} - + + {type === 'PDF' && } + {type === 'HTML' && ( + + )} + + + + {type === 'PDF' && ( + - {type === 'PDF' && ( - - - - )} - - + )} + + + + + + - - - - - - / - {totalPages} - - - - - - - - - / + {totalPages} + + + + + + + + + + - - {type === 'HTML' && } - - - ) : ( - - - - - - - - - / - {totalPages} - - - - - - -
- - - {type === 'PDF' && } - {type === 'HTML' && ( - - )} - - - - {type === 'PDF' && ( - - - - )} - - - - - - - - {type === 'HTML' && } - - -
- )} + + + {type === 'HTML' && } + +
); } diff --git a/tests/Header.test.tsx b/tests/Header.test.tsx index 6e10f046..409f2dc6 100644 --- a/tests/Header.test.tsx +++ b/tests/Header.test.tsx @@ -5,20 +5,6 @@ import { MockHtmlReaderProps } from './utils/MockData'; import { axe } from 'jest-axe'; -beforeEach(() => { - Object.defineProperty(window, 'matchMedia', { - writable: true, - value: jest.fn().mockImplementation((query) => ({ - matches: false, - media: query, - onchange: null, - addEventListener: jest.fn(), - removeEventListener: jest.fn(), - dispatchEvent: jest.fn(), - })), - }); -}); - describe('Header Accessibility checker', () => { test('header component should have no violation', async () => { const containerRef = React.createRef();