import { useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { ChevronLeft, ChevronRight, FileText, Loader2, X, ZoomIn, ZoomOut } from 'lucide-react'; import type { PDFDocumentLoadingTask, PDFDocumentProxy, RenderTask } from 'pdfjs-dist/legacy/build/pdf.mjs'; import { api, getAuthToken } from '../api/client'; import { formatFileSize } from '../utils/file'; // Fetching and parsing happen fully in the browser; beyond this size the // preview shows a notice instead of stalling the tab on a giant download. export const PDF_PREVIEW_MAX_BYTES = 50 * 1024 * 1024; interface PdfPreviewModalProps { libraryFileId: number; filename: string; fileSize: number; onClose: () => void; /** Called once with a 256px PNG of the first page, for the grid thumbnail (#2976). */ onSnapshot?: (blob: Blob) => void; } // Square 256px crop of the rendered page, white-backed like a paper page. function snapshotFromCanvas(source: HTMLCanvasElement): Promise { const size = 256; const target = document.createElement('canvas'); target.width = size; target.height = size; const ctx = target.getContext('2d'); if (!ctx) return Promise.resolve(null); ctx.fillStyle = '#ffffff'; ctx.fillRect(0, 0, size, size); const scale = size / Math.max(source.width, source.height); const w = source.width * scale; const h = source.height * scale; ctx.drawImage(source, (size - w) / 2, (size - h) / 2, w, h); return new Promise((resolve) => target.toBlob(resolve, 'image/png')); } export function PdfPreviewModal({ libraryFileId, filename, fileSize, onClose, onSnapshot }: PdfPreviewModalProps) { const { t } = useTranslation(); const containerRef = useRef(null); const canvasRef = useRef(null); const renderTaskRef = useRef(null); const snapshotSentRef = useRef(false); const onSnapshotRef = useRef(onSnapshot); useEffect(() => { onSnapshotRef.current = onSnapshot; }); const [doc, setDoc] = useState(null); const [pageNum, setPageNum] = useState(1); const [zoom, setZoom] = useState(1); const [error, setError] = useState(null); const [rendering, setRendering] = useState(true); useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); }; window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); }, [onClose]); // Load the document. pdf.js is imported on demand so the viewer and its // worker stay out of the main bundle. useEffect(() => { let cancelled = false; let loadingTask: PDFDocumentLoadingTask | null = null; setDoc(null); setError(null); setPageNum(1); setRendering(true); if (fileSize > PDF_PREVIEW_MAX_BYTES) { setError(t('fileManager.preview.tooLarge', { size: formatFileSize(fileSize) })); setRendering(false); return; } const headers: HeadersInit = {}; const token = getAuthToken(); if (token) headers['Authorization'] = `Bearer ${token}`; (async () => { const res = await fetch(api.getLibraryFileDownloadUrl(libraryFileId), { headers }); if (!res.ok) throw new Error(`HTTP ${res.status}`); const buffer = await res.arrayBuffer(); // The legacy build, not the standard one: pdf.js 6 calls // Map.prototype.getOrInsertComputed (Chrome 145, Firefox 144) and // other recent APIs directly, so on any older browser every PDF failed // with "cannot be previewed". The legacy build bundles polyfills for // them in both the page and the worker. The worker goes through Vite // (`?worker&url`) rather than being copied as-is (`?url`), so // `build.target` lowers its class static block for Safari 16.0-16.3 // like the rest of the app, and the baseline check can see it. const pdfjs = await import('pdfjs-dist/legacy/build/pdf.mjs'); if (!pdfjs.GlobalWorkerOptions.workerSrc) { pdfjs.GlobalWorkerOptions.workerSrc = ( await import('pdfjs-dist/legacy/build/pdf.worker.min.mjs?worker&url') ).default; } loadingTask = pdfjs.getDocument({ data: new Uint8Array(buffer) }); const loaded = await loadingTask.promise; if (cancelled) { // Cleanup below already ran; destroying the task tears down the doc. return; } setDoc(loaded); })().catch(() => { if (!cancelled) { setError(t('fileManager.preview.error')); setRendering(false); } }); return () => { cancelled = true; renderTaskRef.current?.cancel(); renderTaskRef.current = null; // Destroying the loading task also destroys the document and worker. loadingTask?.destroy(); }; }, [libraryFileId, fileSize, t]); // Render the current page into the canvas. useEffect(() => { if (!doc) return; let cancelled = false; setRendering(true); (async () => { const page = await doc.getPage(pageNum); const canvas = canvasRef.current; const container = containerRef.current; if (!canvas || !container || cancelled) return; const baseViewport = page.getViewport({ scale: 1 }); // Fit the page width to the panel at zoom 1; render at device pixels. const fitScale = Math.max((container.clientWidth - 32) / baseViewport.width, 0.1); const dpr = Math.min(window.devicePixelRatio || 1, 2); const viewport = page.getViewport({ scale: fitScale * zoom * dpr }); canvas.width = viewport.width; canvas.height = viewport.height; canvas.style.width = `${viewport.width / dpr}px`; canvas.style.height = `${viewport.height / dpr}px`; renderTaskRef.current?.cancel(); const task = page.render({ canvas, viewport }); renderTaskRef.current = task; await task.promise; if (cancelled) return; setRendering(false); if (pageNum === 1 && onSnapshotRef.current && !snapshotSentRef.current) { snapshotSentRef.current = true; const blob = await snapshotFromCanvas(canvas); if (blob && !cancelled) onSnapshotRef.current?.(blob); } })().catch((err: unknown) => { // A cancelled render throws RenderingCancelledException — not an error. if (!cancelled && (err as { name?: string })?.name !== 'RenderingCancelledException') { setError(t('fileManager.preview.error')); setRendering(false); } }); return () => { cancelled = true; }; }, [doc, pageNum, zoom, t]); const pageCount = doc?.numPages ?? 0; return (
{/* Header */}

{filename}

{doc && pageCount > 1 && (
{t('fileManager.preview.page', { current: pageNum, total: pageCount })}
)} {doc && ( <> )}
{/* Page */}
{error ? (

{error}

) : (
)} {!error && rendering && (
)}
); }