"use client"; import { ZoomInIcon, ZoomOutIcon } from "lucide-react"; import { useCallback, useEffect, useRef, useState } from "react"; import * as pdfjsLib from "pdfjs-dist"; import type { PDFDocumentProxy, RenderTask } from "pdfjs-dist"; 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; } const ZOOM_STEP = 0.15; const MIN_ZOOM = 0.5; const MAX_ZOOM = 3; const PAGE_GAP = 12; export function PdfViewer({ pdfUrl }: PdfViewerProps) { const [numPages, setNumPages] = useState(0); const [scale, setScale] = useState(1); const [loading, setLoading] = useState(true); const [loadError, setLoadError] = useState(null); const [currentPage, setCurrentPage] = useState(1); const scrollContainerRef = useRef(null); const pagesContainerRef = useRef(null); const pdfDocRef = useRef(null); const canvasRefs = useRef>(new Map()); const renderTasksRef = useRef>(new Map()); const renderedScalesRef = useRef>(new Map()); 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); } catch (err: unknown) { if (err instanceof Error && err.message?.includes("cancelled")) return; console.error(`Failed to render page ${pageNum}:`, err); } }, []); useEffect(() => { let cancelled = false; const loadDocument = async () => { setLoading(true); setLoadError(null); setNumPages(0); setCurrentPage(1); try { const loadingTask = pdfjsLib.getDocument({ url: pdfUrl, httpHeaders: getAuthHeaders(), }); const pdf = await loadingTask.promise; if (cancelled) { pdf.destroy(); return; } pdfDocRef.current = pdf; 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(); pdfDocRef.current?.destroy(); pdfDocRef.current = null; }; }, [pdfUrl]); useEffect(() => { if (!pdfDocRef.current || numPages === 0) return; renderedScalesRef.current.clear(); for (let i = 1; i <= numPages; i++) { renderPage(i, scale); } }, [scale, numPages, renderPage]); useEffect(() => { const container = scrollContainerRef.current; if (!container || numPages <= 1) return; const handleScroll = () => { const canvases = canvasRefs.current; const containerTop = container.scrollTop; const containerMid = containerTop + container.clientHeight / 2; let closest = 1; let closestDist = Number.POSITIVE_INFINITY; for (let i = 1; i <= numPages; i++) { const canvas = canvases.get(i); if (!canvas) continue; const rect = canvas.getBoundingClientRect(); const containerRect = container.getBoundingClientRect(); const canvasMid = rect.top - containerRect.top + containerTop + rect.height / 2; const dist = Math.abs(canvasMid - containerMid); if (dist < closestDist) { closestDist = dist; closest = i; } } setCurrentPage(closest); }; container.addEventListener("scroll", handleScroll, { passive: true }); return () => container.removeEventListener("scroll", handleScroll); }, [numPages]); 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 resume preview

{loadError}

); } return (
{numPages > 0 && (
{numPages > 1 && ( <> {currentPage} / {numPages}
)} {Math.round(scale * 100)}%
)}
{loading ? (
) : (
{Array.from({ length: numPages }, (_, i) => i + 1).map((pageNum) => ( setCanvasRef(pageNum, el)} className="shadow-lg" /> ))}
)}
); }