import { useState, useRef, useEffect, useCallback } from "react"; import { Document, Page, pdfjs } from "react-pdf"; import "react-pdf/dist/Page/AnnotationLayer.css"; import "react-pdf/dist/Page/TextLayer.css"; pdfjs.GlobalWorkerOptions.workerSrc = `https://unpkg.com/pdfjs-dist@${pdfjs.version}/build/pdf.worker.min.mjs`; export default function PdfViewer({ fileUrl, currentPage, onPageChange, }: { fileUrl: string; currentPage?: number; onPageChange?: (page: number) => void; }) { const [numPages, setNumPages] = useState(0); const [containerWidth, setContainerWidth] = useState(0); const containerRef = useRef(null); const scrollRef = useRef(null); const pageRefs = useRef>(new Map()); const [jumpPage, setJumpPage] = useState(""); const programmaticScroll = useRef(false); // Resize observer for container width useEffect(() => { if (!containerRef.current) return; const observer = new ResizeObserver((entries) => { setContainerWidth(entries[0].contentRect.width); }); observer.observe(containerRef.current); return () => observer.disconnect(); }, []); // Scroll to page when currentPage changes (programmatic) useEffect(() => { if (!currentPage || currentPage < 1) return; const el = pageRefs.current.get(currentPage); if (el) { programmaticScroll.current = true; el.scrollIntoView({ behavior: "smooth", block: "start" }); setTimeout(() => { programmaticScroll.current = false; }, 2000); } }, [currentPage]); // IntersectionObserver to detect visible page on user scroll useEffect(() => { if (numPages === 0 || !scrollRef.current) return; const visiblePages = new Map(); const observer = new IntersectionObserver( (entries) => { for (const entry of entries) { const pageNum = Number(entry.target.getAttribute("data-page")); if (entry.isIntersecting) { visiblePages.set(pageNum, entry.intersectionRatio); } else { visiblePages.delete(pageNum); } } // Don't fire callback during programmatic scroll if (programmaticScroll.current) return; // Find the page with the highest visibility ratio let bestPage = 0; let bestRatio = 0; for (const [page, ratio] of visiblePages) { if (ratio > bestRatio) { bestRatio = ratio; bestPage = page; } } if (bestPage > 0) { onPageChange?.(bestPage); } }, { root: scrollRef.current, threshold: [0, 0.25, 0.5, 0.75, 1], }, ); for (const [, el] of pageRefs.current) { observer.observe(el); } return () => observer.disconnect(); }, [numPages, onPageChange]); const setPageRef = useCallback((pageNum: number, el: HTMLDivElement | null) => { if (el) { el.setAttribute("data-page", String(pageNum)); pageRefs.current.set(pageNum, el); } else { pageRefs.current.delete(pageNum); } }, []); const handleJump = () => { const p = parseInt(jumpPage, 10); if (p >= 1 && p <= numPages) { const el = pageRefs.current.get(p); el?.scrollIntoView({ behavior: "smooth", block: "start" }); } setJumpPage(""); }; return (
{/* Page controls */}
{numPages} pages | Go to{" "} setJumpPage(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") handleJump(); }} placeholder="#" className="w-12 text-center border border-gray-300 rounded px-1 py-0.5 text-sm" min={1} max={numPages} />
{/* All pages scrollable */}
setNumPages(n)} loading={
Loading PDF...
} error={
Failed to load PDF
} > {numPages > 0 && Array.from({ length: numPages }, (_, i) => i + 1).map((pageNum) => (
setPageRef(pageNum, el)} className="flex justify-center mb-2" >
0 ? containerWidth - 48 : undefined} renderAnnotationLayer renderTextLayer />
))}
); }