Files
pn-new-crm/src/components/files/pdf-viewer.tsx

149 lines
5.2 KiB
TypeScript
Raw Normal View History

'use client';
import { useEffect, useMemo, useState } from 'react';
import { Document, Page, pdfjs } from 'react-pdf';
import { ChevronLeft, ChevronRight, Loader2, Minus, Plus } from 'lucide-react';
import { Button } from '@/components/ui/button';
import 'react-pdf/dist/Page/AnnotationLayer.css';
import 'react-pdf/dist/Page/TextLayer.css';
/**
* In-app PDF viewer.
*
* Replaces the `<iframe>` preview which delegated rendering to the
* browser's built-in PDF viewer. The iframe path works on desktop
* Chrome/Firefox/Safari but is unreliable on mobile (older Android
* Chrome refuses to render PDFs inline; iOS Safari opens a new tab).
* react-pdf renders via pdfjs-dist which works identically everywhere.
*
* The pdfjs worker is loaded from a CDN matched to the installed
* pdfjs-dist version. Bundling the worker locally would inflate the
* main-route bundle by ~150kb; the CDN avoids that cost on every page
* that uses pdfjs at the cost of a single first-load fetch per user.
*/
// Match the pdfjs.version that react-pdf has bundled at runtime. If
// pdfjs-dist is bumped, the URL auto-tracks. .mjs (module worker) is
// the current pdfjs distribution; .js fallback handled by pdf.js
// internally for older browsers.
pdfjs.GlobalWorkerOptions.workerSrc = `https://unpkg.com/pdfjs-dist@${pdfjs.version}/build/pdf.worker.min.mjs`;
interface PdfViewerProps {
url: string;
/** Optional aria-friendly filename for the document. */
fileName?: string;
}
export function PdfViewer({ url, fileName }: PdfViewerProps) {
const [numPages, setNumPages] = useState<number | null>(null);
const [pageNumber, setPageNumber] = useState(1);
const [scale, setScale] = useState(1);
const [error, setError] = useState<string | null>(null);
// Keep options stable across renders so react-pdf doesn't refetch
// every render — useMemo wins because react-pdf compares by identity.
const options = useMemo(
() => ({
// Inline the worker fetch URL above; CMap/StandardFontDataUrl
// pull from the same CDN for unicode + non-system fonts.
cMapUrl: `https://unpkg.com/pdfjs-dist@${pdfjs.version}/cmaps/`,
standardFontDataUrl: `https://unpkg.com/pdfjs-dist@${pdfjs.version}/standard_fonts/`,
}),
[],
);
useEffect(() => {
// Reset on url change so navigation between documents lands on
// page 1 at default zoom.
setPageNumber(1);
setScale(1);
setError(null);
}, [url]);
return (
<div className="flex h-full flex-col">
<div className="flex items-center justify-between gap-2 border-b bg-muted/40 px-3 py-2 text-sm">
<div className="flex items-center gap-1">
<Button
type="button"
variant="ghost"
size="icon"
disabled={pageNumber <= 1}
onClick={() => setPageNumber((p) => Math.max(1, p - 1))}
aria-label="Previous page"
>
<ChevronLeft className="h-4 w-4" />
</Button>
<span className="min-w-[80px] text-center tabular-nums">
{numPages ? `${pageNumber} / ${numPages}` : '—'}
</span>
<Button
type="button"
variant="ghost"
size="icon"
disabled={numPages === null || pageNumber >= numPages}
onClick={() => setPageNumber((p) => (numPages ? Math.min(numPages, p + 1) : p))}
aria-label="Next page"
>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
<div className="flex items-center gap-1">
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => setScale((s) => Math.max(0.5, s - 0.25))}
aria-label="Zoom out"
>
<Minus className="h-4 w-4" />
</Button>
<span className="min-w-[48px] text-center tabular-nums">{Math.round(scale * 100)}%</span>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => setScale((s) => Math.min(3, s + 0.25))}
aria-label="Zoom in"
>
<Plus className="h-4 w-4" />
</Button>
</div>
</div>
<div className="flex-1 overflow-auto bg-muted/30 p-4">
{error ? (
<div className="flex h-full items-center justify-center text-sm text-destructive">
{error}
</div>
) : (
<div className="flex justify-center">
<Document
file={url}
options={options}
onLoadSuccess={({ numPages: n }) => setNumPages(n)}
onLoadError={(err) => setError(err.message || 'Failed to load PDF')}
loading={
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
Loading PDF
</div>
}
>
<Page
pageNumber={pageNumber}
scale={scale}
renderAnnotationLayer
renderTextLayer
aria-label={fileName ? `${fileName}, page ${pageNumber}` : `Page ${pageNumber}`}
/>
</Document>
</div>
)}
</div>
</div>
);
}