feat(deps): pdfjs-dist + react-pdf for consistent in-app PDF preview
Replaces the `<iframe src={presignedUrl}>` preview path which
delegated rendering to the browser's built-in PDF viewer. The iframe
worked on desktop but failed on mobile (older Android Chrome
refuses inline PDFs; iOS Safari opens a new tab).
`<PdfViewer>` renders via pdfjs-dist + react-pdf so the experience
is identical across all browsers + form factors. Adds page nav,
zoom controls, and per-page accessibility labels.
Lazy-loaded via next/dynamic with ssr:false — pdfjs is ~150kb gzip,
no route ships it unless a PDF is actually previewed.
pdfjs worker + CMaps + fonts loaded from unpkg CDN pinned to the
matched pdfjs-dist version (first-load cost paid once per user, no
bundle-size impact on routes that never preview a PDF).
Verified: tsc clean, vitest 1315/1315, next build green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,11 +1,23 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import dynamic from 'next/dynamic';
|
||||
import { ExternalLink } from 'lucide-react';
|
||||
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
|
||||
// pdfjs-dist is ~150kb gzip — lazy-load so routes that never preview
|
||||
// PDFs don't ship it. ssr:false because the worker setup needs window.
|
||||
const PdfViewer = dynamic(() => import('./pdf-viewer').then((m) => ({ default: m.PdfViewer })), {
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
||||
Loading PDF viewer…
|
||||
</div>
|
||||
),
|
||||
});
|
||||
|
||||
interface FilePreviewDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
@@ -94,7 +106,7 @@ export function FilePreviewDialog({
|
||||
)}
|
||||
|
||||
{!loading && !error && previewUrl && isPdf && (
|
||||
<iframe src={previewUrl} title={fileName ?? 'PDF Preview'} className="h-full w-full" />
|
||||
<PdfViewer url={previewUrl} fileName={fileName} />
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
|
||||
148
src/components/files/pdf-viewer.tsx
Normal file
148
src/components/files/pdf-viewer.tsx
Normal file
@@ -0,0 +1,148 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user