"use client"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Loader2, ZoomIn, ZoomOut } from "lucide-react"; import { useFetchSingleDoc } from "@/app/hooks/useFetchSingleDoc"; import { DocxView } from "./DocxView"; import type { CitationQuote } from "./types"; import { clearHighlights, getPdfJs, highlightQuote, STANDARD_FONT_DATA_URL, } from "./highlightQuote"; interface Props { doc: { document_id: string; version_id?: string | null } | null; /** Preferred: one or more (page, quote) pairs to highlight. */ quotes?: CitationQuote[]; /** Changes when the parent wants the current quote re-focused. */ quoteFocusKey?: string | number; /** Back-compat single-quote API. Ignored if `quotes` is provided. */ quote?: string; fallbackPage?: number; rounded?: boolean; bordered?: boolean; } type QuoteEntry = { page?: number; quote: string }; const SIDE_PADDING = 20; const ZOOM_MIN = 0.5; const ZOOM_MAX = 3.0; const ZOOM_STEP = 0.25; type RenderedPage = { page: import("pdfjs-dist").PDFPageProxy; viewport: import("pdfjs-dist").PageViewport; wrapper: HTMLDivElement; canvas: HTMLCanvasElement; textDivs: HTMLElement[]; }; export function DocView({ doc, quotes, quoteFocusKey, quote, fallbackPage, rounded = true, bordered = true, }: Props) { const containerRef = useRef(null); const scrollContainerRef = useRef(null); const pdfDocRef = useRef( null, ); const renderedPagesRef = useRef([]); const quoteListRef = useRef([]); const zoomRef = useRef(1.0); const currentPageRef = useRef(1); const quoteList: QuoteEntry[] = useMemo(() => { if (quotes?.length) return quotes.map((q) => ({ page: q.page, quote: q.quote })); if (quote) return [{ page: fallbackPage, quote }]; return []; }, [quotes, quote, fallbackPage]); // Stable string key so effects can depend on quote-list identity const quoteKey = quoteList .map((q) => `${q.page ?? ""}:${q.quote}`) .join("|"); const [containerWidth, setContainerWidth] = useState(0); const [zoom, setZoom] = useState(1.0); const [currentPage, setCurrentPage] = useState(1); const [numPages, setNumPages] = useState(0); const { result, loading, error } = useFetchSingleDoc( doc?.document_id ?? null, doc?.version_id ?? null, ); // /display returned DOCX bytes — the active version has no PDF // rendition, so fall back to docx-preview (still applies citation // highlighting via the same `quotes` API). const fallbackToDocx = result?.type === "docx"; // Track container width via ResizeObserver so re-renders fire on resize useEffect(() => { const el = scrollContainerRef.current; if (!el) return; const ro = new ResizeObserver((entries) => { setContainerWidth(entries[0]?.contentRect.width ?? 0); }); ro.observe(el); return () => ro.disconnect(); }, []); // Track current page via scroll position useEffect(() => { const scrollEl = scrollContainerRef.current; if (!scrollEl) return; const handleScroll = () => { const pages = renderedPagesRef.current; if (!pages.length) return; const scrollCenter = scrollEl.scrollTop + scrollEl.clientHeight / 2; let closest = 0; let closestDist = Infinity; pages.forEach((p, i) => { const pageCenter = p.wrapper.offsetTop + p.wrapper.clientHeight / 2; const dist = Math.abs(pageCenter - scrollCenter); if (dist < closestDist) { closestDist = dist; closest = i; } }); currentPageRef.current = closest + 1; setCurrentPage(closest + 1); }; scrollEl.addEventListener("scroll", handleScroll, { passive: true }); return () => scrollEl.removeEventListener("scroll", handleScroll); }, []); // Highlights all entries in `list` across the already-rendered pages. // Returns the 1-based page number of the first successfully highlighted entry, or null. const applyHighlights = useCallback( async (list: QuoteEntry[]): Promise => { // Clear any prior highlights across all pages for (const p of renderedPagesRef.current) clearHighlights(p.textDivs); let firstHitPage: number | null = null; for (const entry of list) { let hitPage: number | null = null; if (entry.page) { const target = renderedPagesRef.current[entry.page - 1]; if (target) { const found = await highlightQuote( target.textDivs, entry.quote, ); if (found) hitPage = entry.page; } } // Fall back to scanning all pages for this quote if (hitPage === null) { console.warn( `Quote not found on hinted page, scanning all pages: "${entry.quote.slice(0, 60)}..."`, ); for (let i = 0; i < renderedPagesRef.current.length; i++) { const p = renderedPagesRef.current[i]; const found = await highlightQuote( p.textDivs, entry.quote, ); if (found) { hitPage = i + 1; break; } } } if (hitPage !== null && firstHitPage === null) { firstHitPage = hitPage; } } return firstHitPage; }, [], ); const renderPDF = useCallback( async ( doc: import("pdfjs-dist").PDFDocumentProxy, list: QuoteEntry[], scrollToPage?: number, ) => { if (!containerRef.current) return; containerRef.current.innerHTML = ""; renderedPagesRef.current = []; const lib = await getPdfJs(); lib.TextLayer.cleanup(); setNumPages(doc.numPages); setCurrentPage(1); currentPageRef.current = 1; const hasCitation = list.length > 0; if (hasCitation && scrollContainerRef.current) { scrollContainerRef.current.style.opacity = "0"; } const reveal = () => { if (scrollContainerRef.current) scrollContainerRef.current.style.opacity = "1"; }; const panelW = containerRef.current.clientWidth; const firstPage = await doc.getPage(1); const naturalWidth = firstPage.getViewport({ scale: 1 }).width; const baseScale = Math.max( 0.5, (panelW - SIDE_PADDING) / naturalWidth, ); const scale = baseScale * zoomRef.current; for (let pageNum = 1; pageNum <= doc.numPages; pageNum++) { const page = await doc.getPage(pageNum); const viewport = page.getViewport({ scale }); const wrapper = document.createElement("div"); wrapper.style.position = "relative"; wrapper.style.margin = "0 auto 8px"; wrapper.style.width = "fit-content"; wrapper.className = "shadow-md"; const canvas = document.createElement("canvas"); canvas.width = viewport.width; canvas.height = viewport.height; canvas.style.display = "block"; wrapper.appendChild(canvas); containerRef.current?.appendChild(wrapper); const ctx = canvas.getContext("2d"); if (!ctx) continue; const task = page.render({ canvasContext: ctx, viewport }); try { await task.promise; } catch (e: unknown) { if ( (e as { name?: string })?.name !== "RenderingCancelledException" ) { console.error("PDF render error", e); } continue; } const textLayerDiv = document.createElement("div"); textLayerDiv.className = "pdf-text-layer"; textLayerDiv.style.position = "absolute"; textLayerDiv.style.left = "0"; textLayerDiv.style.top = "0"; textLayerDiv.style.width = `${viewport.width}px`; textLayerDiv.style.height = `${viewport.height}px`; textLayerDiv.style.setProperty("--scale-factor", String(scale)); wrapper.appendChild(textLayerDiv); const textLayer = new lib.TextLayer({ textContentSource: page.streamTextContent(), container: textLayerDiv, viewport, }); await textLayer.render(); const textDivs = textLayer.textDivs; renderedPagesRef.current.push({ page, viewport, wrapper, canvas, textDivs, }); } // Apply highlights across all entries, then scroll to the first hit. let targetPage: number | null = null; if (list.length) { targetPage = await applyHighlights(list); if (targetPage === null) { // Fallback: scroll to the first entry's page hint, even without a highlight const hint = list.find((e) => e.page)?.page ?? null; targetPage = hint; } } if (targetPage && targetPage >= 1) { scrollToHighlightOnPage(targetPage); } else if (!hasCitation && scrollToPage && scrollToPage > 1) { // Restore scroll position after zoom re-render const pageEntry = renderedPagesRef.current[scrollToPage - 1]; if (pageEntry) pageEntry.wrapper.scrollIntoView({ behavior: "instant" as ScrollBehavior, block: "start", }); } reveal(); }, [applyHighlights], ); // Scroll so the first highlight on `pageNum` lands at the vertical center // of the viewer. We compute the scroll position explicitly on the scroll // container — calling `scrollIntoView` on a child of the absolutely- // positioned text layer can scroll just the overlay while leaving the // canvas untouched, which is why we don't use it here. function scrollToHighlightOnPage(pageNum: number) { const pageEntry = renderedPagesRef.current[pageNum - 1]; const scrollEl = scrollContainerRef.current; if (!pageEntry || !scrollEl) return; const highlightEl = pageEntry.wrapper.querySelector( ".pdf-text-highlight", ); if (highlightEl) { const containerRect = scrollEl.getBoundingClientRect(); const highlightRect = highlightEl.getBoundingClientRect(); const offsetWithinContainer = highlightRect.top - containerRect.top; const targetTop = scrollEl.scrollTop + offsetWithinContainer - scrollEl.clientHeight / 2 + highlightRect.height / 2; scrollEl.scrollTo({ top: Math.max(0, targetTop), behavior: "instant" as ScrollBehavior, }); } else { const wrapperRect = pageEntry.wrapper.getBoundingClientRect(); const containerRect = scrollEl.getBoundingClientRect(); const targetTop = scrollEl.scrollTop + (wrapperRect.top - containerRect.top); scrollEl.scrollTo({ top: Math.max(0, targetTop), behavior: "instant" as ScrollBehavior, }); } } const rehighlightQuotes = useCallback( async (list: QuoteEntry[]) => { const targetPage = await applyHighlights(list); const scrollPage = targetPage ?? list.find((e) => e.page)?.page ?? null; if (scrollPage && scrollPage >= 1) { scrollToHighlightOnPage(scrollPage); } }, [applyHighlights], ); // Trackpad pinch-to-zoom (wheel + ctrlKey) useEffect(() => { const el = scrollContainerRef.current; if (!el) return; let debounceTimer: ReturnType | null = null; const handleWheel = (e: WheelEvent) => { if (!e.ctrlKey) return; e.preventDefault(); const delta = e.deltaMode === 0 ? e.deltaY / 300 : e.deltaY * 0.1; const next = Math.min( ZOOM_MAX, Math.max( ZOOM_MIN, Math.round(zoomRef.current * Math.exp(-delta) * 100) / 100, ), ); if (next === zoomRef.current) return; zoomRef.current = next; setZoom(next); if (debounceTimer) clearTimeout(debounceTimer); debounceTimer = setTimeout(() => { if (pdfDocRef.current) { renderPDF( pdfDocRef.current, quoteListRef.current, currentPageRef.current, ); } }, 150); }; el.addEventListener("wheel", handleWheel, { passive: false }); return () => { el.removeEventListener("wheel", handleWheel); if (debounceTimer) clearTimeout(debounceTimer); }; }, [renderPDF]); // Touch pinch-to-zoom useEffect(() => { const el = scrollContainerRef.current; if (!el) return; let initialDist = 0; let initialZoom = 1.0; function getTouchDist(touches: TouchList) { const dx = touches[0].clientX - touches[1].clientX; const dy = touches[0].clientY - touches[1].clientY; return Math.hypot(dx, dy); } const handleTouchStart = (e: TouchEvent) => { if (e.touches.length === 2) { initialDist = getTouchDist(e.touches); initialZoom = zoomRef.current; } }; const handleTouchMove = (e: TouchEvent) => { if (e.touches.length !== 2 || initialDist === 0) return; e.preventDefault(); const next = Math.min( ZOOM_MAX, Math.max( ZOOM_MIN, Math.round( initialZoom * (getTouchDist(e.touches) / initialDist) * 100, ) / 100, ), ); zoomRef.current = next; setZoom(next); }; const handleTouchEnd = (e: TouchEvent) => { if (e.touches.length < 2 && initialDist > 0) { initialDist = 0; if (pdfDocRef.current) { renderPDF( pdfDocRef.current, quoteListRef.current, currentPageRef.current, ); } } }; el.addEventListener("touchstart", handleTouchStart, { passive: true }); el.addEventListener("touchmove", handleTouchMove, { passive: false }); el.addEventListener("touchend", handleTouchEnd, { passive: true }); return () => { el.removeEventListener("touchstart", handleTouchStart); el.removeEventListener("touchmove", handleTouchMove); el.removeEventListener("touchend", handleTouchEnd); }; }, [renderPDF]); // Clean up PDF.js static font-measurement canvases on unmount useEffect(() => { return () => { getPdfJs().then((lib) => lib.TextLayer.cleanup()); }; }, []); // Render PDF when fetch result arrives useEffect(() => { if (!result || result.type !== "pdf") return; pdfDocRef.current = null; renderedPagesRef.current = []; quoteListRef.current = quoteList; zoomRef.current = 1.0; setZoom(1.0); setNumPages(0); const list = quoteList; let cancelled = false; (async () => { const lib = await getPdfJs(); if (cancelled) return; const pdfDoc = await lib.getDocument({ data: new Uint8Array(result.buffer), standardFontDataUrl: STANDARD_FONT_DATA_URL, }).promise; if (cancelled) return; pdfDocRef.current = pdfDoc; await renderPDF(pdfDoc, list); })(); return () => { cancelled = true; }; }, [result, renderPDF]); // eslint-disable-line react-hooks/exhaustive-deps // Re-render at new scale when container is resized (debounced 150ms) useEffect(() => { if (!pdfDocRef.current) return; const timer = setTimeout(() => { if (pdfDocRef.current) { renderPDF(pdfDocRef.current, quoteListRef.current); } }, 150); return () => clearTimeout(timer); }, [containerWidth, renderPDF]); // eslint-disable-line react-hooks/exhaustive-deps // Re-highlight when quotes change without full re-render useEffect(() => { if (!pdfDocRef.current) return; quoteListRef.current = quoteList; rehighlightQuotes(quoteList); }, [quoteKey, quoteFocusKey, rehighlightQuotes]); // eslint-disable-line react-hooks/exhaustive-deps function handleZoomIn() { const next = Math.min( ZOOM_MAX, Math.round((zoomRef.current + ZOOM_STEP) * 100) / 100, ); zoomRef.current = next; setZoom(next); if (pdfDocRef.current) { renderPDF( pdfDocRef.current, quoteListRef.current, currentPageRef.current, ); } } function handleZoomOut() { const next = Math.max( ZOOM_MIN, Math.round((zoomRef.current - ZOOM_STEP) * 100) / 100, ); zoomRef.current = next; setZoom(next); if (pdfDocRef.current) { renderPDF( pdfDocRef.current, quoteListRef.current, currentPageRef.current, ); } } if (fallbackToDocx && doc?.document_id) { return ( ); } return (
{loading && (
)} {error && (

{error}

)}
{numPages > 0 && ( <> {/* Page counter — bottom left */}
{currentPage}/{numPages}
{/* Zoom controls — bottom right */}
{Math.round(zoom * 100)}%
)}
); }