import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { Document, Page } from 'react-pdf' import type { FieldProvenance } from './types' import { useStore } from './store' import { api } from './api' interface Props { sessionId: string } export function PDFPane({ sessionId }: Props) { const sessionData = useStore((s) => s.sessionData) const activePdfFile = useStore((s) => s.activePdfFile) const activeProvenance = useStore((s) => s.activeProvenance) const setActivePdf = useStore((s) => s.setActivePdf) const [numPages, setNumPages] = useState(0) const [renderedPages, setRenderedPages] = useState>(new Set()) const [containerWidth, setContainerWidth] = useState(600) const containerRef = useRef(null) const pageRefs = useRef>(new Map()) // Track which PDF URL we last requested a scroll for, to avoid re-firing const pendingScrollRef = useRef<{ page: number; pdfFile: string } | null>(null) // Unique PDF filenames from provenance const pdfFiles = useMemo(() => { const seen = new Set() return (sessionData?.provenance ?? []) .map((p) => p.source_filename) .filter((f) => { const fresh = !seen.has(f); seen.add(f); return fresh }) }, [sessionData?.provenance]) // Set container width on resize useEffect(() => { const el = containerRef.current if (!el) return const obs = new ResizeObserver(([entry]) => { setContainerWidth(Math.floor(entry.contentRect.width) - 24) }) obs.observe(el) setContainerWidth(Math.floor(el.clientWidth) - 24) return () => obs.disconnect() }, []) // When active provenance changes: enqueue a scroll request useEffect(() => { if (!activeProvenance) return pendingScrollRef.current = { page: activeProvenance.location.page, pdfFile: activeProvenance.source_filename, } // Reset rendered-pages set when switching documents if (activeProvenance.source_filename !== activePdfFile) { setRenderedPages(new Set()) } // Try immediately (page already rendered) tryScroll() }, [activeProvenance]) // eslint-disable-line react-hooks/exhaustive-deps // When a page finishes rendering, check if a scroll is pending for it const handlePageRenderSuccess = useCallback((pageNum: number) => { setRenderedPages((prev) => new Set([...prev, pageNum])) const pending = pendingScrollRef.current if (pending && pending.page === pageNum && pending.pdfFile === activePdfFile) { const el = pageRefs.current.get(pageNum) el?.scrollIntoView({ behavior: 'smooth', block: 'center' }) pendingScrollRef.current = null } }, [activePdfFile]) function tryScroll() { const pending = pendingScrollRef.current if (!pending) return if (pending.pdfFile !== activePdfFile) return const el = pageRefs.current.get(pending.page) if (el) { el.scrollIntoView({ behavior: 'smooth', block: 'center' }) pendingScrollRef.current = null } } // Reset rendered pages when the PDF URL changes const pdfUrl = activePdfFile ? api.pdfUrl(sessionId, activePdfFile) : null const prevPdfUrlRef = useRef(null) if (pdfUrl !== prevPdfUrlRef.current) { prevPdfUrlRef.current = pdfUrl // Clear page refs — old page elements are stale after document switch pageRefs.current.clear() } // Highlights for the currently displayed PDF const highlights = useMemo((): FieldProvenance[] => { if (!sessionData || !activePdfFile) return [] return sessionData.provenance.filter( (p) => p.source_filename === activePdfFile, ) }, [sessionData, activePdfFile]) return (
{/* PDF file selector */} {pdfFiles.length > 1 && (
{pdfFiles.map((f) => ( ))}
)} {/* PDF scroll area */}
{pdfUrl ? ( { setNumPages(n) setRenderedPages(new Set()) }} loading={} error={} > {Array.from({ length: numPages }, (_, i) => i + 1).map((pageNum) => { const pageHighlights = highlights.filter( (h) => h.location.page === pageNum, ) const hasActive = activeProvenance?.location.page === pageNum && activeProvenance.source_filename === activePdfFile return (
{ if (el) pageRefs.current.set(pageNum, el) else pageRefs.current.delete(pageNum) }} // Use block + explicit width so the overlay div always matches // the canvas dimensions exactly (inline-block can shrink-wrap) style={{ position: 'relative', width: containerWidth }} className={`rounded shadow-md transition-shadow overflow-hidden ${ hasActive ? 'ring-4 ring-blue-500' : '' }`} > handlePageRenderSuccess(pageNum)} /> {/* Highlight overlay — percentage-based, top-left origin */}
{pageHighlights.map((h) => { const [x0, y0, x1, y1] = h.location.bbox const isActive = activeProvenance?.field_path === h.field_path return (
) })}
) })} ) : (
No PDF selected
)}
) } function LoadingPlaceholder() { return (
Loading PDF…
) } function ErrorPlaceholder() { return (
Failed to load PDF.
) }