From df88fcd0fbff3f651e59c43888591ec5dbf89811 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Thu, 16 Apr 2026 11:23:13 +0530 Subject: [PATCH] refactor: streamline PDF handling in viewer and resume components by replacing react-pdf with pdfjs-dist, enhancing loading states and error management --- .../components/report-panel/pdf-viewer.tsx | 242 +++++++++++++----- .../components/tool-ui/generate-resume.tsx | 137 ++++++---- 2 files changed, 260 insertions(+), 119 deletions(-) diff --git a/surfsense_web/components/report-panel/pdf-viewer.tsx b/surfsense_web/components/report-panel/pdf-viewer.tsx index 5a2b5c130..75e56cb37 100644 --- a/surfsense_web/components/report-panel/pdf-viewer.tsx +++ b/surfsense_web/components/report-panel/pdf-viewer.tsx @@ -1,15 +1,14 @@ "use client"; -import { ChevronLeftIcon, ChevronRightIcon, ZoomInIcon, ZoomOutIcon } from "lucide-react"; -import { useCallback, useRef, useState } from "react"; -import { Document, Page, pdfjs } from "react-pdf"; -import "react-pdf/dist/Page/AnnotationLayer.css"; -import "react-pdf/dist/Page/TextLayer.css"; +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"; -pdfjs.GlobalWorkerOptions.workerSrc = new URL( +pdfjsLib.GlobalWorkerOptions.workerSrc = new URL( "pdfjs-dist/build/pdf.worker.min.mjs", import.meta.url ).toString(); @@ -21,39 +20,164 @@ interface PdfViewerProps { 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 [pageNumber, setPageNumber] = useState(1); + const [numPages, setNumPages] = useState(0); const [scale, setScale] = useState(1); + const [loading, setLoading] = useState(true); const [loadError, setLoadError] = useState(null); - const containerRef = useRef(null); - const documentOptionsRef = useRef({ httpHeaders: getAuthHeaders() }); + const [currentPage, setCurrentPage] = useState(1); - const onDocumentLoadSuccess = useCallback(({ numPages }: { numPages: number }) => { - setNumPages(numPages); - setPageNumber(1); - setLoadError(null); + 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); + } }, []); - const onDocumentLoadError = useCallback((error: Error) => { - setLoadError(error.message || "Failed to load PDF"); - }, []); + useEffect(() => { + let cancelled = false; - const goToPrevPage = useCallback(() => { - setPageNumber((prev) => Math.max(1, prev - 1)); - }, []); + const loadDocument = async () => { + setLoading(true); + setLoadError(null); + setNumPages(0); + setCurrentPage(1); - const goToNextPage = useCallback(() => { - setPageNumber((prev) => Math.min(numPages, prev + 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)); + setScale((prev) => Math.min(MAX_ZOOM, +(prev + ZOOM_STEP).toFixed(2))); }, []); const zoomOut = useCallback(() => { - setScale((prev) => Math.max(MIN_ZOOM, prev - ZOOM_STEP)); + setScale((prev) => Math.max(MIN_ZOOM, +(prev - ZOOM_STEP).toFixed(2))); }, []); if (loadError) { @@ -67,32 +191,13 @@ export function PdfViewer({ pdfUrl }: PdfViewerProps) { return (
- {/* Controls bar */} {numPages > 0 && (
{numPages > 1 && ( <> - - {pageNumber} / {numPages} + {currentPage} / {numPages} -
)} @@ -108,32 +213,29 @@ export function PdfViewer({ pdfUrl }: PdfViewerProps) {
)} - {/* PDF content */} -
- - -
- } - > - - Failed to render page {pageNumber} -
- } - /> - +
+ {loading ? ( +
+ +
+ ) : ( +
+ {Array.from({ length: numPages }, (_, i) => i + 1).map((pageNum) => ( + setCanvasRef(pageNum, el)} + className="shadow-lg" + /> + ))} +
+ )}
); diff --git a/surfsense_web/components/tool-ui/generate-resume.tsx b/surfsense_web/components/tool-ui/generate-resume.tsx index d1ea95ea9..d540c7d18 100644 --- a/surfsense_web/components/tool-ui/generate-resume.tsx +++ b/surfsense_web/components/tool-ui/generate-resume.tsx @@ -3,10 +3,8 @@ import type { ToolCallMessagePartProps } from "@assistant-ui/react"; import { useAtomValue, useSetAtom } from "jotai"; import { useParams, usePathname } from "next/navigation"; -import { useCallback, useEffect, useRef, useState } from "react"; -import { Document, Page, pdfjs } from "react-pdf"; -import "react-pdf/dist/Page/AnnotationLayer.css"; -import "react-pdf/dist/Page/TextLayer.css"; +import { useEffect, useRef, useState } from "react"; +import * as pdfjsLib from "pdfjs-dist"; import { z } from "zod"; import { openReportPanelAtom, reportPanelAtom } from "@/atoms/chat/report-panel.atom"; import { TextShimmerLoader } from "@/components/prompt-kit/loader"; @@ -14,7 +12,7 @@ import { Spinner } from "@/components/ui/spinner"; import { useMediaQuery } from "@/hooks/use-media-query"; import { getAuthHeaders } from "@/lib/auth-utils"; -pdfjs.GlobalWorkerOptions.workerSrc = new URL( +pdfjsLib.GlobalWorkerOptions.workerSrc = new URL( "pdfjs-dist/build/pdf.worker.min.mjs", import.meta.url ).toString(); @@ -90,6 +88,80 @@ function ResumeCancelledState() { ); } +function PdfThumbnail({ pdfUrl }: { pdfUrl: string }) { + const canvasRef = useRef(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(false); + + useEffect(() => { + let cancelled = false; + + const renderThumbnail = async () => { + try { + const loadingTask = pdfjsLib.getDocument({ + url: pdfUrl, + httpHeaders: getAuthHeaders(), + }); + + const pdf = await loadingTask.promise; + if (cancelled) { pdf.destroy(); return; } + + const page = await pdf.getPage(1); + if (cancelled) { pdf.destroy(); return; } + + const canvas = canvasRef.current; + if (!canvas) { pdf.destroy(); return; } + + const containerWidth = canvas.parentElement?.clientWidth || 400; + const unscaledViewport = page.getViewport({ scale: 1 }); + const fitScale = containerWidth / unscaledViewport.width; + const viewport = page.getViewport({ scale: fitScale }); + 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`; + + await page.render({ + canvas, + viewport, + transform: dpr !== 1 ? [dpr, 0, 0, dpr, 0, 0] : undefined, + }).promise; + if (!cancelled) setLoading(false); + + pdf.destroy(); + } catch { + if (!cancelled) { + setError(true); + setLoading(false); + } + } + }; + + renderThumbnail(); + return () => { cancelled = true; }; + }, [pdfUrl]); + + if (error) { + return

Preview unavailable

; + } + + return ( + <> + {loading && ( +
+ +
+ )} + + + ); +} + function ResumeCard({ reportId, title, @@ -106,9 +178,6 @@ function ResumeCard({ const isDesktop = useMediaQuery("(min-width: 768px)"); const autoOpenedRef = useRef(false); const [pdfUrl, setPdfUrl] = useState(null); - const [pdfReady, setPdfReady] = useState(false); - const [pdfError, setPdfError] = useState(false); - const documentOptionsRef = useRef({ httpHeaders: getAuthHeaders() }); useEffect(() => { setPdfUrl( @@ -126,15 +195,6 @@ function ResumeCard({ } }, [reportId, title, shareToken, autoOpen, isDesktop, openPanel]); - const onPdfLoadSuccess = useCallback(() => { - setPdfReady(true); - }, []); - - const onPdfLoadError = useCallback(() => { - setPdfReady(true); - setPdfError(true); - }, []); - const isActive = panelState.isOpen && panelState.reportId === reportId; const handleOpen = () => { @@ -163,39 +223,18 @@ function ResumeCard({
- {pdfUrl && !pdfError ? ( -
-
- - -
- } - > - {pdfReady && ( - - )} - + {pdfUrl ? ( +
+
+ +
-
- ) : pdfError ? ( -

Preview unavailable

) : (