"use client"; import { ZoomInIcon, ZoomOutIcon } from "lucide-react"; import type { PDFDocumentProxy, RenderTask } from "pdfjs-dist"; import * as pdfjsLib from "pdfjs-dist"; import { useCallback, useEffect, useRef, useState } from "react"; import { Button } from "@/components/ui/button"; import { Spinner } from "@/components/ui/spinner"; import { getAuthHeaders } from "@/lib/auth-utils"; pdfjsLib.GlobalWorkerOptions.workerSrc = new URL( "pdfjs-dist/build/pdf.worker.min.mjs", import.meta.url ).toString(); interface PdfViewerProps { pdfUrl: string; isPublic?: boolean; } interface PageDimensions { width: number; height: number; } const ZOOM_STEP = 0.15; const MIN_ZOOM = 0.5; const MAX_ZOOM = 3; const PAGE_GAP = 12; const SCROLL_DEBOUNCE_MS = 30; const BUFFER_PAGES = 1; export function PdfViewer({ pdfUrl, isPublic = false }: PdfViewerProps) { const [numPages, setNumPages] = useState(0); const [scale, setScale] = useState(1); const [loading, setLoading] = useState(true); const [loadError, setLoadError] = useState(null); const scrollContainerRef = useRef(null); const pdfDocRef = useRef(null); const canvasRefs = useRef>(new Map()); const renderTasksRef = useRef>(new Map()); const renderedScalesRef = useRef>(new Map()); const pageDimsRef = useRef([]); const visiblePagesRef = useRef>(new Set()); const scrollTimerRef = useRef | null>(null); const getScaledHeight = useCallback( (pageIndex: number) => { const dims = pageDimsRef.current[pageIndex]; return dims ? Math.floor(dims.height * scale) : 0; }, [scale] ); const getVisibleRange = useCallback(() => { const container = scrollContainerRef.current; if (!container || pageDimsRef.current.length === 0) return { first: 1, last: 1 }; const scrollTop = container.scrollTop; const viewportHeight = container.clientHeight; const scrollBottom = scrollTop + viewportHeight; let cumTop = 16; let first = 1; let last = pageDimsRef.current.length; for (let i = 0; i < pageDimsRef.current.length; i++) { const pageHeight = getScaledHeight(i); const pageBottom = cumTop + pageHeight; if (pageBottom >= scrollTop && first === 1) { first = i + 1; } if (cumTop > scrollBottom) { last = i; break; } cumTop = pageBottom + PAGE_GAP; } first = Math.max(1, first - BUFFER_PAGES); last = Math.min(pageDimsRef.current.length, last + BUFFER_PAGES); return { first, last }; }, [getScaledHeight]); const renderPage = useCallback(async (pageNum: number, currentScale: number) => { const pdf = pdfDocRef.current; const canvas = canvasRefs.current.get(pageNum); if (!pdf || !canvas) return; if (renderedScalesRef.current.get(pageNum) === currentScale) return; const existing = renderTasksRef.current.get(pageNum); if (existing) { existing.cancel(); renderTasksRef.current.delete(pageNum); } try { const page = await pdf.getPage(pageNum); const viewport = page.getViewport({ scale: currentScale }); const dpr = window.devicePixelRatio || 1; canvas.width = Math.floor(viewport.width * dpr); canvas.height = Math.floor(viewport.height * dpr); canvas.style.width = `${Math.floor(viewport.width)}px`; canvas.style.height = `${Math.floor(viewport.height)}px`; const renderTask = page.render({ canvas, viewport, transform: dpr !== 1 ? [dpr, 0, 0, dpr, 0, 0] : undefined, }); renderTasksRef.current.set(pageNum, renderTask); await renderTask.promise; renderTasksRef.current.delete(pageNum); renderedScalesRef.current.set(pageNum, currentScale); page.cleanup(); } catch (err: unknown) { if (err instanceof Error && err.message?.includes("cancelled")) return; console.error(`Failed to render page ${pageNum}:`, err); } }, []); const cleanupPage = useCallback((pageNum: number) => { const existing = renderTasksRef.current.get(pageNum); if (existing) { existing.cancel(); renderTasksRef.current.delete(pageNum); } const canvas = canvasRefs.current.get(pageNum); if (canvas) { const ctx = canvas.getContext("2d"); if (ctx) ctx.clearRect(0, 0, canvas.width, canvas.height); canvas.width = 0; canvas.height = 0; } renderedScalesRef.current.delete(pageNum); }, []); const renderVisiblePages = useCallback(() => { if (!pdfDocRef.current || pageDimsRef.current.length === 0) return; const { first, last } = getVisibleRange(); const newVisible = new Set(); for (let i = first; i <= last; i++) { newVisible.add(i); renderPage(i, scale); } for (const pageNum of visiblePagesRef.current) { if (!newVisible.has(pageNum)) { cleanupPage(pageNum); } } visiblePagesRef.current = newVisible; }, [getVisibleRange, renderPage, cleanupPage, scale]); useEffect(() => { let cancelled = false; const loadDocument = async () => { setLoading(true); setLoadError(null); setNumPages(0); pageDimsRef.current = []; try { const loadingTask = pdfjsLib.getDocument({ url: pdfUrl, httpHeaders: getAuthHeaders(), }); const pdf = await loadingTask.promise; if (cancelled) { pdf.destroy(); return; } const dims: PageDimensions[] = []; for (let i = 1; i <= pdf.numPages; i++) { const page = await pdf.getPage(i); const viewport = page.getViewport({ scale: 1 }); dims.push({ width: viewport.width, height: viewport.height }); page.cleanup(); } if (cancelled) { pdf.destroy(); return; } pdfDocRef.current = pdf; pageDimsRef.current = dims; setNumPages(pdf.numPages); setLoading(false); } catch (err: unknown) { if (cancelled) return; const message = err instanceof Error ? err.message : "Failed to load PDF"; setLoadError(message); setLoading(false); } }; loadDocument(); return () => { cancelled = true; for (const task of renderTasksRef.current.values()) { task.cancel(); } renderTasksRef.current.clear(); renderedScalesRef.current.clear(); visiblePagesRef.current.clear(); pdfDocRef.current?.destroy(); pdfDocRef.current = null; }; }, [pdfUrl]); useEffect(() => { if (numPages === 0) return; renderedScalesRef.current.clear(); visiblePagesRef.current.clear(); const frame = requestAnimationFrame(() => { renderVisiblePages(); }); return () => cancelAnimationFrame(frame); }, [numPages, renderVisiblePages]); useEffect(() => { const container = scrollContainerRef.current; if (!container || numPages === 0) return; const handleScroll = () => { if (scrollTimerRef.current) clearTimeout(scrollTimerRef.current); scrollTimerRef.current = setTimeout(() => { renderVisiblePages(); }, SCROLL_DEBOUNCE_MS); }; container.addEventListener("scroll", handleScroll, { passive: true }); return () => { container.removeEventListener("scroll", handleScroll); if (scrollTimerRef.current) clearTimeout(scrollTimerRef.current); }; }, [numPages, renderVisiblePages]); const setCanvasRef = useCallback((pageNum: number, el: HTMLCanvasElement | null) => { if (el) { canvasRefs.current.set(pageNum, el); } else { canvasRefs.current.delete(pageNum); } }, []); const zoomIn = useCallback(() => { setScale((prev) => Math.min(MAX_ZOOM, +(prev + ZOOM_STEP).toFixed(2))); }, []); const zoomOut = useCallback(() => { setScale((prev) => Math.max(MIN_ZOOM, +(prev - ZOOM_STEP).toFixed(2))); }, []); if (loadError) { return (

Failed to load PDF

{loadError}

); } return (
{numPages > 0 && (
{Math.round(scale * 100)}%
)}
{loading ? (
) : (
{pageDimsRef.current.map((dims, i) => { const pageNum = i + 1; const scaledWidth = Math.floor(dims.width * scale); const scaledHeight = Math.floor(dims.height * scale); return (
setCanvasRef(pageNum, el)} className="shadow-lg absolute inset-0" /> {numPages > 1 && ( Page {pageNum}/{numPages} )}
); })}
)}
); }