"use client" import * as React from "react" import { ChevronDownIcon, ChevronLeftIcon, ChevronRightIcon, DownloadIcon, FileIcon, ListIcon, LoaderCircleIcon, LockKeyholeIcon, MaximizeIcon, MinimizeIcon, MinusIcon, PanelLeftCloseIcon, PanelLeftOpenIcon, PlusIcon, PrinterIcon, RefreshCwIcon, RotateCcwIcon, RotateCwIcon, SearchIcon, XIcon, } from "lucide-react" import { GlobalWorkerOptions, getDocument, type DocumentInitParameters, type PDFDocumentProxy, type PDFPageProxy, type RenderTask, } from "pdfjs-dist" import { Button } from "@/components/ui/button" import { cn } from "@/lib/utils" if (typeof window !== "undefined" && !GlobalWorkerOptions.workerSrc) { GlobalWorkerOptions.workerSrc = new URL( "pdfjs-dist/build/pdf.worker.min.mjs", import.meta.url ).toString() } export type PdfSource = | string | URL | Uint8Array | ArrayBuffer | DocumentInitParameters | null | undefined export type PdfViewMode = "single" | "continuous" export type PdfFitMode = "custom" | "width" | "page" export type PdfLoadingProgress = { loaded: number total?: number percent?: number } export type PdfSearchResult = { pageNumber: number matchIndex: number text: string context: string } export type PdfPasswordRequest = { reason: number submit: (password: string) => void } export type PdfViewerLabels = { previousPage?: string nextPage?: string page?: string of?: string zoomOut?: string zoomIn?: string fitWidth?: string fitPage?: string rotateCounterClockwise?: string rotateClockwise?: string download?: string print?: string fullscreen?: string exitFullscreen?: string openSidebar?: string closeSidebar?: string thumbnails?: string singlePage?: string continuous?: string search?: string searchPlaceholder?: string previousMatch?: string nextMatch?: string closeSearch?: string noMatches?: string loading?: string rendering?: string error?: string retry?: string empty?: string passwordTitle?: string passwordDescription?: string passwordPlaceholder?: string passwordSubmit?: string incorrectPassword?: string } export type PdfViewerStateContent = { empty?: React.ReactNode loading?: React.ReactNode | ((progress: PdfLoadingProgress | null) => React.ReactNode) error?: React.ReactNode | ((error: Error, retry: () => void) => React.ReactNode) password?: React.ReactNode | ((request: PdfPasswordRequest) => React.ReactNode) } export type PdfViewerHandle = { getDocument: () => PDFDocumentProxy | null previousPage: () => void nextPage: () => void goToPage: (page: number) => void zoomOut: () => void zoomIn: () => void setScale: (scale: number) => void fitWidth: () => Promise fitPage: () => Promise rotateCounterClockwise: () => void rotateClockwise: () => void search: (query: string) => Promise previousMatch: () => void nextMatch: () => void download: () => Promise print: () => Promise toggleFullscreen: () => Promise } export type PdfViewerToolbarContext = { document: PDFDocumentProxy | null page: number pageCount: number pageLabel: string scale: number rotation: number viewMode: PdfViewMode fitMode: PdfFitMode sidebarOpen: boolean searchOpen: boolean searching: boolean searchQuery: string searchResults: PdfSearchResult[] activeSearchIndex: number loading: boolean rendering: boolean fullscreen: boolean actions: PdfViewerHandle & { setViewMode: (mode: PdfViewMode) => void setSidebarOpen: (open: boolean) => void setSearchOpen: (open: boolean) => void setSearchQuery: (query: string) => void } } export type PdfViewerProps = Omit, "onError"> & { src?: PdfSource page?: number defaultPage?: number onPageChange?: (page: number) => void scale?: number defaultScale?: number onScaleChange?: (scale: number) => void rotation?: number defaultRotation?: number onRotationChange?: (rotation: number) => void viewMode?: PdfViewMode defaultViewMode?: PdfViewMode onViewModeChange?: (mode: PdfViewMode) => void fitMode?: PdfFitMode defaultFitMode?: PdfFitMode onFitModeChange?: (mode: PdfFitMode) => void sidebarOpen?: boolean defaultSidebarOpen?: boolean onSidebarOpenChange?: (open: boolean) => void searchOpen?: boolean defaultSearchOpen?: boolean onSearchOpenChange?: (open: boolean) => void searchQuery?: string defaultSearchQuery?: string onSearchQueryChange?: (query: string) => void onSearchResultsChange?: (results: PdfSearchResult[]) => void searchOnChange?: boolean searchDebounceMs?: number maxSearchResults?: number minScale?: number maxScale?: number scaleStep?: number thumbnailWidth?: number continuousGap?: number lazyRootMargin?: string autoFitOnResize?: boolean keyboardShortcuts?: boolean showToolbar?: boolean showSidebarToggle?: boolean showThumbnails?: boolean showSearch?: boolean showViewModeToggle?: boolean showZoom?: boolean showRotate?: boolean showPrint?: boolean showDownload?: boolean showFullscreen?: boolean showPasswordPrompt?: boolean downloadName?: string password?: string labels?: PdfViewerLabels stateContent?: PdfViewerStateContent renderToolbar?: (context: PdfViewerToolbarContext) => React.ReactNode sidebarContent?: React.ReactNode | ((context: PdfViewerToolbarContext) => React.ReactNode) beforeToolbar?: React.ReactNode afterToolbar?: React.ReactNode overlay?: React.ReactNode onLoad?: (document: PDFDocumentProxy) => void onProgress?: (progress: PdfLoadingProgress) => void onPasswordRequest?: (request: PdfPasswordRequest) => void onMetadata?: (metadata: Awaited>) => void onOutline?: (outline: Awaited>) => void onPageRendered?: (pageNumber: number, page: PDFPageProxy) => void onDownload?: (data: Uint8Array) => void onPrint?: (data: Uint8Array) => void onError?: (error: Error) => void ariaLabel?: string canvasClassName?: string pageClassName?: string viewportClassName?: string toolbarClassName?: string sidebarClassName?: string searchClassName?: string } type PasswordRequestState = PdfPasswordRequest & { incorrect: boolean } type PdfViewerCallbacks = Pick< PdfViewerProps, | "onLoad" | "onProgress" | "onPasswordRequest" | "onMetadata" | "onOutline" | "onPageRendered" | "onDownload" | "onPrint" | "onError" | "onSearchResultsChange" > type PdfCanvasPageProps = { document: PDFDocumentProxy pageNumber: number scale: number rotation: number lazy?: boolean rootMargin?: string targetWidth?: number active?: boolean searchMatchCount?: number canvasClassName?: string pageClassName?: string onVisible?: (pageNumber: number, ratio: number) => void onRendered?: (pageNumber: number, page: PDFPageProxy) => void onRenderingChange?: (pageNumber: number, rendering: boolean) => void onError?: (error: Error) => void labels?: PdfViewerLabels } function useLatest(value: T) { const ref = React.useRef(value) ref.current = value return ref } function useControllableState({ value, defaultValue, onChange, }: { value: T | undefined defaultValue: T onChange?: (value: T) => void }) { const [internalValue, setInternalValue] = React.useState(defaultValue) const controlled = value !== undefined const currentValue = controlled ? value : internalValue const setValue = React.useCallback( (nextValue: T) => { if (!controlled) setInternalValue(nextValue) onChange?.(nextValue) }, [controlled, onChange] ) return [currentValue, setValue] as const } function clamp(value: number, min: number, max: number) { return Math.min(Math.max(value, min), max) } function normalizeRotation(rotation: number) { return ((rotation % 360) + 360) % 360 } function normalizePdfSource(src: PdfSource, password?: string): DocumentInitParameters | null { if (src == null || src === "") return null if (src instanceof ArrayBuffer) return { data: new Uint8Array(src), password } if (src instanceof Uint8Array) return { data: src, password } if (src instanceof URL) return { url: src.toString(), password } if (typeof src === "string") return { url: src, password } if (password && src.password == null) return { ...src, password } return src } function createError(cause: unknown, fallback: string) { return cause instanceof Error ? cause : new Error(fallback) } function getTextItemValue(item: unknown) { if (typeof item === "object" && item != null && "str" in item) { return String((item as { str: unknown }).str) } return "" } function createSearchContext(text: string, index: number, queryLength: number) { const start = Math.max(0, index - 44) const end = Math.min(text.length, index + queryLength + 56) return `${start > 0 ? "…" : ""}${text.slice(start, end).trim()}${end < text.length ? "…" : ""}` } function toPdfBlob(data: Uint8Array) { const buffer = data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength) return new Blob([buffer as ArrayBuffer], { type: "application/pdf" }) } function resolveStateContent( content: React.ReactNode | ((...args: T) => React.ReactNode) | undefined, fallback: React.ReactNode, ...args: T ) { if (typeof content === "function") return content(...args) return content ?? fallback } function PdfCanvasPage({ document, pageNumber, scale, rotation, lazy = false, rootMargin = "500px 0px", targetWidth, active, searchMatchCount = 0, canvasClassName, pageClassName, onVisible, onRendered, onRenderingChange, onError, labels, }: PdfCanvasPageProps) { const containerRef = React.useRef(null) const canvasRef = React.useRef(null) const renderTaskRef = React.useRef(null) const [visible, setVisible] = React.useState(!lazy) const [rendering, setRendering] = React.useState(false) const [dimensions, setDimensions] = React.useState<{ width: number; height: number } | null>(null) const [error, setError] = React.useState(null) React.useEffect(() => { const element = containerRef.current if (!element || !lazy || typeof IntersectionObserver === "undefined") { setVisible(true) return } const observer = new IntersectionObserver( ([entry]) => { if (!entry) return if (entry.isIntersecting) setVisible(true) onVisible?.(pageNumber, entry.intersectionRatio) }, { rootMargin, threshold: [0, 0.25, 0.5, 0.75, 1] } ) observer.observe(element) return () => observer.disconnect() }, [lazy, onVisible, pageNumber, rootMargin]) React.useEffect(() => { if (!visible || !canvasRef.current) return let disposed = false setRendering(true) setError(null) onRenderingChange?.(pageNumber, true) renderTaskRef.current?.cancel() document .getPage(pageNumber) .then((pdfPage) => { if (disposed || !canvasRef.current) return null const baseViewport = pdfPage.getViewport({ scale: 1, rotation }) const resolvedScale = targetWidth ? targetWidth / Math.max(baseViewport.width, 1) : scale const viewport = pdfPage.getViewport({ scale: resolvedScale, rotation }) const outputScale = Math.max(window.devicePixelRatio || 1, 1) const canvas = canvasRef.current const context = canvas.getContext("2d") if (!context) throw new Error("Canvas 2D context is unavailable") canvas.width = Math.floor(viewport.width * outputScale) canvas.height = Math.floor(viewport.height * outputScale) canvas.style.width = `${Math.floor(viewport.width)}px` canvas.style.height = `${Math.floor(viewport.height)}px` setDimensions({ width: viewport.width, height: viewport.height }) const renderTask = pdfPage.render({ canvasContext: context, viewport, transform: outputScale === 1 ? undefined : [outputScale, 0, 0, outputScale, 0, 0], }) renderTaskRef.current = renderTask return renderTask.promise.then(() => pdfPage) }) .then((pdfPage) => { if (disposed || !pdfPage) return setRendering(false) onRenderingChange?.(pageNumber, false) onRendered?.(pageNumber, pdfPage) }) .catch((cause: unknown) => { if (disposed || (cause instanceof Error && cause.name === "RenderingCancelledException")) return const nextError = createError(cause, "PDF page could not be rendered") setError(nextError) setRendering(false) onRenderingChange?.(pageNumber, false) onError?.(nextError) }) return () => { disposed = true renderTaskRef.current?.cancel() renderTaskRef.current = null onRenderingChange?.(pageNumber, false) } }, [document, onError, onRendered, onRenderingChange, pageNumber, rotation, scale, targetWidth, visible]) return (
{rendering ? (
) : null} {error ? (

{error.message}

) : null} {searchMatchCount > 0 ? ( {searchMatchCount} match{searchMatchCount === 1 ? "" : "es"} ) : null}
) } function DefaultEmptyState({ labels }: { labels?: PdfViewerLabels }) { return (

{labels?.empty ?? "No PDF document"}

Pass a URL, byte array, or PDF.js document options.

) } function DefaultLoadingState({ labels, progress, }: { labels?: PdfViewerLabels progress: PdfLoadingProgress | null }) { return (