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

171 lines
6.1 KiB
TypeScript
Raw Normal View History

'use client';
import { useMemo, useState } from 'react';
import { Document, Page, pdfjs } from 'react-pdf';
feat(deps): Tier 2 UX polish — embla, lightbox, gestures, virtuoso, motion Installs all five Tier 2 polish deps the audit flagged. Each integrates where it adds concrete value today: - **embla-carousel-react** — shadcn-style `<Carousel>` primitive in `src/components/ui/carousel.tsx`. Available for future berth/yacht photo galleries; no current call site beyond the primitive. - **yet-another-react-lightbox** — wired into the image branch of `file-preview-dialog.tsx`. Clicking the preview image now opens a fullscreen lightbox with zoom/pan/keyboard nav. Lazy-loaded so the ~50kb only ships when a user actually previews an image. - **@use-gesture/react** — `usePinch` on the PdfViewer's content pane for native pinch-zoom on tablets/phones. Clamped to the same [50%, 300%] range as the +/- buttons; desktop wheel still scrolls. - **react-virtuoso** — installed but NOT wired. Inbox is naturally bounded by recent-notifications filter at ~10-20 items; ScrollArea handles it fine. Reserve for actual scale issues (admin audit log archive, etc.). - **motion** — installed but NOT wired. Pipeline kanban uses dnd-kit's own transforms and conflicts with motion's layout animation. @formkit/auto-animate already handles list-mutation animations elsewhere. Available for opportunistic adoption when a polish surface emerges that the existing libraries don't cover. Verified: tsc clean, vitest 1315/1315, next build green. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 23:29:22 +02:00
import { usePinch } from '@use-gesture/react';
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(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) {
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/`,
}),
[],
);
feat(deps): Tier 2 UX polish — embla, lightbox, gestures, virtuoso, motion Installs all five Tier 2 polish deps the audit flagged. Each integrates where it adds concrete value today: - **embla-carousel-react** — shadcn-style `<Carousel>` primitive in `src/components/ui/carousel.tsx`. Available for future berth/yacht photo galleries; no current call site beyond the primitive. - **yet-another-react-lightbox** — wired into the image branch of `file-preview-dialog.tsx`. Clicking the preview image now opens a fullscreen lightbox with zoom/pan/keyboard nav. Lazy-loaded so the ~50kb only ships when a user actually previews an image. - **@use-gesture/react** — `usePinch` on the PdfViewer's content pane for native pinch-zoom on tablets/phones. Clamped to the same [50%, 300%] range as the +/- buttons; desktop wheel still scrolls. - **react-virtuoso** — installed but NOT wired. Inbox is naturally bounded by recent-notifications filter at ~10-20 items; ScrollArea handles it fine. Reserve for actual scale issues (admin audit log archive, etc.). - **motion** — installed but NOT wired. Pipeline kanban uses dnd-kit's own transforms and conflicts with motion's layout animation. @formkit/auto-animate already handles list-mutation animations elsewhere. Available for opportunistic adoption when a polish surface emerges that the existing libraries don't cover. Verified: tsc clean, vitest 1315/1315, next build green. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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 },
},
);
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>
feat(deps): Tier 2 UX polish — embla, lightbox, gestures, virtuoso, motion Installs all five Tier 2 polish deps the audit flagged. Each integrates where it adds concrete value today: - **embla-carousel-react** — shadcn-style `<Carousel>` primitive in `src/components/ui/carousel.tsx`. Available for future berth/yacht photo galleries; no current call site beyond the primitive. - **yet-another-react-lightbox** — wired into the image branch of `file-preview-dialog.tsx`. Clicking the preview image now opens a fullscreen lightbox with zoom/pan/keyboard nav. Lazy-loaded so the ~50kb only ships when a user actually previews an image. - **@use-gesture/react** — `usePinch` on the PdfViewer's content pane for native pinch-zoom on tablets/phones. Clamped to the same [50%, 300%] range as the +/- buttons; desktop wheel still scrolls. - **react-virtuoso** — installed but NOT wired. Inbox is naturally bounded by recent-notifications filter at ~10-20 items; ScrollArea handles it fine. Reserve for actual scale issues (admin audit log archive, etc.). - **motion** — installed but NOT wired. Pipeline kanban uses dnd-kit's own transforms and conflicts with motion's layout animation. @formkit/auto-animate already handles list-mutation animations elsewhere. Available for opportunistic adoption when a polish surface emerges that the existing libraries don't cover. Verified: tsc clean, vitest 1315/1315, next build green. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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()}
>
{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>
);
}