2026-05-12 22:56:42 +02:00
|
|
|
|
'use client';
|
|
|
|
|
|
|
2026-05-13 11:50:07 +02:00
|
|
|
|
import { useMemo, useState } from 'react';
|
2026-05-12 22:56:42 +02:00
|
|
|
|
import { Document, Page, pdfjs } from 'react-pdf';
|
2026-05-12 23:29:22 +02:00
|
|
|
|
import { usePinch } from '@use-gesture/react';
|
2026-05-12 22:56:42 +02:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-13 11:50:07 +02:00
|
|
|
|
export function PdfViewer(props: PdfViewerProps) {
|
|
|
|
|
|
// Key-based remount: re-mount the inner body whenever the url
|
|
|
|
|
|
// changes so all local state (page, zoom, error) re-initializes from
|
|
|
|
|
|
// scratch. Replaces the prior useEffect(reset, [url]) the Compiler
|
|
|
|
|
|
// flagged as set-state-in-effect.
|
|
|
|
|
|
return <PdfViewerBody key={props.url} {...props} />;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function PdfViewerBody({ url, fileName }: PdfViewerProps) {
|
2026-05-12 22:56:42 +02:00
|
|
|
|
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(
|
|
|
|
|
|
() => ({
|
|
|
|
|
|
cMapUrl: `https://unpkg.com/pdfjs-dist@${pdfjs.version}/cmaps/`,
|
|
|
|
|
|
standardFontDataUrl: `https://unpkg.com/pdfjs-dist@${pdfjs.version}/standard_fonts/`,
|
|
|
|
|
|
}),
|
|
|
|
|
|
[],
|
|
|
|
|
|
);
|
|
|
|
|
|
|
2026-05-12 23:29:22 +02:00
|
|
|
|
// Pinch-zoom on touch devices. usePinch's `offset` already maps
|
|
|
|
|
|
// gesture distance to a smoothly-changing scalar; we clamp it to
|
|
|
|
|
|
// the same [0.5, 3] range as the +/- buttons.
|
|
|
|
|
|
const pinchBindings = usePinch(
|
|
|
|
|
|
({ offset: [s] }) => {
|
|
|
|
|
|
setScale(Math.max(0.5, Math.min(3, s)));
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
scaleBounds: { min: 0.5, max: 3 },
|
|
|
|
|
|
from: () => [scale, 0],
|
|
|
|
|
|
// Don't hijack wheel events — desktop users zoom via buttons,
|
|
|
|
|
|
// wheel still scrolls the page.
|
|
|
|
|
|
eventOptions: { passive: false },
|
|
|
|
|
|
},
|
|
|
|
|
|
);
|
|
|
|
|
|
|
2026-05-12 22:56:42 +02:00
|
|
|
|
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>
|
|
|
|
|
|
|
2026-05-12 23:29:22 +02:00
|
|
|
|
<div
|
|
|
|
|
|
className="flex-1 overflow-auto bg-muted/30 p-4 touch-pan-y"
|
|
|
|
|
|
// Pinch-zoom on touch devices (tablets, phones). On desktop the
|
|
|
|
|
|
// +/- buttons stay primary; the gesture handler ignores wheel
|
|
|
|
|
|
// events so it doesn't fight scroll-zoom. Range matches the
|
|
|
|
|
|
// button zoom (50%–300%).
|
|
|
|
|
|
{...pinchBindings()}
|
|
|
|
|
|
>
|
2026-05-12 22:56:42 +02:00
|
|
|
|
{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>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|