import { useState, useEffect, useRef, createContext, useContext, useCallback } from 'react'; import { Document, Page, pdfjs } from 'react-pdf'; import { Loader2 } from 'lucide-react'; // Configure PDF.js worker - use CDN for the worker pdfjs.GlobalWorkerOptions.workerSrc = `//unpkg.com/pdfjs-dist@${pdfjs.version}/build/pdf.worker.min.mjs`; // Loading spinner component function LoadingSpinner({ className, size = 'md' }: { className?: string; size?: 'sm' | 'md' | 'lg' }) { const sizeClasses = { sm: 'w-4 h-4', md: 'w-6 h-6', lg: 'w-8 h-8' }; return (
); } interface PdfPageRendererProps { pdfUrl: string; pageNumber: number; width?: number; className?: string; renderTextLayer?: boolean; renderAnnotationLayer?: boolean; onLoadSuccess?: (numPages: number) => void; onError?: (error: Error) => void; } export function PdfPageRenderer({ pdfUrl, pageNumber, width, className, renderTextLayer = false, renderAnnotationLayer = false, onLoadSuccess, onError }: PdfPageRendererProps) { const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const handleLoadSuccess = ({ numPages }: { numPages: number }) => { setLoading(false); onLoadSuccess?.(numPages); }; const handleError = (err: Error) => { setLoading(false); setError(err); onError?.(err); }; if (error) { return (
Failed to load PDF
); } return (
{loading && ( )} } />
); } // Page dimensions from PDF interface PageDimensions { width: number; height: number; aspectRatio: number; } // PDF document proxy type interface PDFDocumentProxy { numPages: number; getPage: (pageNum: number) => Promise<{ getViewport: (options: { scale: number }) => { width: number; height: number } }>; } // Context for sharing PDF state interface SharedPdfContextValue { pdfUrl: string; numPages: number; loading: boolean; error: Error | null; pageDimensions: PageDimensions | null; } const SharedPdfContext = createContext(null); interface SharedPdfProviderProps { pdfUrl: string; urlLoading?: boolean; children: (renderPage: (pageNumber: number, width?: number) => React.ReactNode) => React.ReactNode; onLoadSuccess?: (numPages: number) => void; } /** * Provider that loads a PDF once using a single Document component. * Children receive a renderPage function to render pages inside the Document. */ export function SharedPdfProvider({ pdfUrl, urlLoading = false, children, onLoadSuccess }: SharedPdfProviderProps) { const [numPages, setNumPages] = useState(0); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [pageDimensions, setPageDimensions] = useState(null); const handleLoadSuccess = async (pdf: PDFDocumentProxy) => { setNumPages(pdf.numPages); onLoadSuccess?.(pdf.numPages); try { const page = await pdf.getPage(1); const viewport = page.getViewport({ scale: 1 }); setPageDimensions({ width: viewport.width, height: viewport.height, aspectRatio: viewport.width / viewport.height }); } catch (err) { console.error('Failed to get page dimensions:', err); } setLoading(false); }; const handleError = (err: Error) => { setLoading(false); setError(err); }; const isLoading = urlLoading || (pdfUrl ? loading : true); const value: SharedPdfContextValue = { pdfUrl, numPages, loading: isLoading, error, pageDimensions }; // Render function that children use to render pages const renderPage = (pageNumber: number, width?: number) => ( } /> ); if (error) { return (
Failed to load PDF
); } return ( {pdfUrl ? ( } > {children(renderPage)} ) : ( )} ); } export function useSharedPdf() { return useContext(SharedPdfContext); } // A4 portrait aspect ratio const A4_ASPECT_RATIO = 210 / 297; interface SimplePdfPageProps { pageNumber: number; width?: number; className?: string; } /** * Simple wrapper for a PDF page that adds styling. * Must be used inside SharedPdfProvider's children render function. */ export function SimplePdfPage({ pageNumber, width, className }: SimplePdfPageProps) { const context = useSharedPdf(); const aspectRatio = context?.pageDimensions?.aspectRatio ?? A4_ASPECT_RATIO; const placeholderHeight = width ? Math.round(width / aspectRatio) : 200; if (context?.loading) { return (
); } return (
} />
); } interface PdfThumbnailListProps { pdfUrl: string; urlLoading?: boolean; pageCount: number; currentPage: number; thumbnailWidth?: number; onPageSelect: (pageNumber: number) => void; renderThumbnail: (props: { pageNumber: number; isSelected: boolean; pageElement: React.ReactNode; onSelect: () => void; }) => React.ReactNode; /** Optional ref to the scroll container. If not provided, will search for scrollable ancestor. */ scrollContainerRef?: React.RefObject; /** Callback when aspect ratio is determined from the PDF. Useful for synchronizing placeholder sizing. */ onAspectRatioChange?: (aspectRatio: number) => void; /** Callback when item height is calculated. Useful for scroll position calculations. */ onItemHeightChange?: (itemHeight: number) => void; /** Custom function to calculate item height. Receives placeholder height and should return total item height. */ calculateItemHeight?: (placeholderHeight: number) => number; /** Callback when actual page count is determined from the PDF. Useful when initial count is estimated. */ onPageCountChange?: (pageCount: number) => void; } /** * Virtualized PDF thumbnail that only renders the Page when visible. * Uses IntersectionObserver for efficient visibility detection. */ function VirtualizedThumbnail({ pageNumber, width, isSelected, onSelect, renderThumbnail, aspectRatio = A4_ASPECT_RATIO, rootMargin = '200px 0px' }: { pageNumber: number; width?: number; isSelected: boolean; onSelect: () => void; renderThumbnail: PdfThumbnailListProps['renderThumbnail']; aspectRatio?: number; rootMargin?: string; }) { const containerRef = useRef(null); const [hasBeenVisible, setHasBeenVisible] = useState(false); // Set up intersection observer useEffect(() => { const container = containerRef.current; if (!container) return; const observer = new IntersectionObserver( (entries) => { const entry = entries[0]; if (entry?.isIntersecting) { setHasBeenVisible(true); } }, { rootMargin, threshold: 0 } ); observer.observe(container); return () => observer.disconnect(); }, [rootMargin]); const placeholderHeight = width ? Math.round(width / aspectRatio) : 200; // Only render the actual Page component if visible or has been visible // Once rendered, keep it rendered to preserve the canvas const shouldRenderPage = hasBeenVisible; const pageElement = shouldRenderPage ? ( } /> ) : (
{pageNumber}
); return (
{renderThumbnail({ pageNumber, isSelected, pageElement, onSelect })}
); } /** * Renders a list of PDF page thumbnails using a single Document. * Uses windowed virtualization for better performance with large PDFs. * Only renders components for pages within a window around the current scroll position. */ // Helper to find the scrollable ancestor element function findScrollableAncestor(element: HTMLElement | null): HTMLElement | null { if (!element) return null; let current = element.parentElement; while (current) { const style = window.getComputedStyle(current); const overflowY = style.overflowY; if (overflowY === 'auto' || overflowY === 'scroll') { return current; } current = current.parentElement; } return null; } export function PdfThumbnailList({ pdfUrl, urlLoading = false, pageCount, currentPage, thumbnailWidth, onPageSelect, renderThumbnail, scrollContainerRef, onAspectRatioChange, onItemHeightChange, calculateItemHeight, onPageCountChange }: PdfThumbnailListProps) { const [error, setError] = useState(null); const [visibleRange, setVisibleRange] = useState({ start: 0, end: Math.min(15, pageCount) }); // Start with null to indicate we haven't loaded the PDF yet const [aspectRatio, setAspectRatio] = useState(null); const containerRef = useRef(null); const handleError = useCallback((err: Error) => { setError(err); }, []); // Get actual page dimensions and count from PDF on load const handleLoadSuccess = useCallback(async (pdf: PDFDocumentProxy) => { // Report actual page count from PDF onPageCountChange?.(pdf.numPages); try { const page = await pdf.getPage(1); const viewport = page.getViewport({ scale: 1 }); const ratio = viewport.width / viewport.height; setAspectRatio(ratio); onAspectRatioChange?.(ratio); } catch (err) { console.error('Failed to get page dimensions:', err); // Fall back to A4 if we can't get dimensions setAspectRatio(A4_ASPECT_RATIO); onAspectRatioChange?.(A4_ASPECT_RATIO); } }, [onAspectRatioChange, onPageCountChange]); // Use A4 as fallback if aspect ratio not yet determined const effectiveAspectRatio = aspectRatio ?? A4_ASPECT_RATIO; // Calculate placeholder height using actual aspect ratio from PDF const placeholderHeight = thumbnailWidth ? Math.round(thumbnailWidth / effectiveAspectRatio) : 200; // Total height per item - use custom calculator if provided, otherwise default formula // Default: padding (p-2 = 8px top + 8px bottom) + page number text (~24px) + gap const itemHeight = calculateItemHeight ? calculateItemHeight(placeholderHeight) : placeholderHeight + 16 + 24 + 8; // Notify parent of item height changes for scroll calculations useEffect(() => { if (itemHeight > 0 && aspectRatio !== null) { onItemHeightChange?.(itemHeight); } }, [itemHeight, aspectRatio, onItemHeightChange]); // Window size: how many pages to render above and below visible area const WINDOW_BUFFER = 5; // Track scroll position to update visible range useEffect(() => { // Find the scroll container - either from prop or by searching ancestors const container = scrollContainerRef?.current || findScrollableAncestor(containerRef.current); if (!container) return; const updateVisibleRange = () => { const scrollTop = container.scrollTop; const viewportHeight = container.clientHeight; // Calculate which pages are visible const firstVisible = Math.floor(scrollTop / itemHeight); const lastVisible = Math.ceil((scrollTop + viewportHeight) / itemHeight); // Add buffer around visible range const start = Math.max(0, firstVisible - WINDOW_BUFFER); const end = Math.min(pageCount, lastVisible + WINDOW_BUFFER); setVisibleRange(prev => { if (prev.start !== start || prev.end !== end) { return { start, end }; } return prev; }); }; // Initial calculation updateVisibleRange(); // Listen to scroll events container.addEventListener('scroll', updateVisibleRange, { passive: true }); return () => container.removeEventListener('scroll', updateVisibleRange); }, [itemHeight, pageCount, scrollContainerRef]); if (error) { return (
Failed to load PDF
); } if (urlLoading || !pdfUrl) { return ; } // Calculate spacer heights for virtual scrolling const topSpacerHeight = visibleRange.start * itemHeight; const bottomSpacerHeight = (pageCount - visibleRange.end) * itemHeight; // Only render the virtualized list once we have the actual aspect ratio // This prevents the total scroll height from changing after initial render const hasAspectRatio = aspectRatio !== null; return (
} > {hasAspectRatio ? ( <> {/* Top spacer for pages above visible window */} {topSpacerHeight > 0 && (
)} {/* Only render pages within the visible window */} {Array.from({ length: visibleRange.end - visibleRange.start }, (_, index) => { const pageNumber = visibleRange.start + index + 1; return (
onPageSelect(pageNumber)} renderThumbnail={renderThumbnail} aspectRatio={effectiveAspectRatio} />
); })} {/* Bottom spacer for pages below visible window */} {bottomSpacerHeight > 0 && (
)} ) : ( )}
); } interface PdfDocumentRendererProps { pdfUrl: string; pageNumber: number; width?: number; height?: number; className?: string; renderTextLayer?: boolean; renderAnnotationLayer?: boolean; onPageChange?: (pageNumber: number, totalPages: number) => void; } export function PdfDocumentRenderer({ pdfUrl, pageNumber, width, height, className, renderTextLayer = false, renderAnnotationLayer = false, onPageChange }: PdfDocumentRendererProps) { const [numPages, setNumPages] = useState(0); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); useEffect(() => { if (numPages > 0) { onPageChange?.(pageNumber, numPages); } }, [pageNumber, numPages, onPageChange]); const handleLoadSuccess = ({ numPages: pages }: { numPages: number }) => { setNumPages(pages); setLoading(false); }; const handleError = (err: Error) => { setLoading(false); setError(err); }; if (error) { return (
Failed to load PDF: {error.message}
); } return (
{loading && ( )} } />
); }