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:
2026-05-12 22:56:42 +02:00
parent 75920a2540
commit d0a3a054b6
4 changed files with 366 additions and 3 deletions

View File

@@ -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>

View 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>
);
}