extend
uipdf-viewersource: ok · refreshed
PDF Viewer
A PDF viewer with page virtualization, thumbnails, search, zoom, rotation, upload, and page overlay hooks.
npx shadcn add "https://ui.extend.ai/r/pdf-viewer.json"free
No selection tags yet — empty means no evidence, not lack of support.
Dependencies
Bundle weight · light@embedpdf/core@^2.15.0@embedpdf/engines@^2.15.0@embedpdf/models@^2.15.0@embedpdf/plugin-document-manager@^2.15.0@embedpdf/plugin-interaction-manager@^2.15.0@embedpdf/plugin-render@^2.15.0@embedpdf/plugin-rotate@^2.15.0@embedpdf/plugin-scroll@^2.15.0@embedpdf/plugin-search@^2.15.0@embedpdf/plugin-selection@^2.15.0@embedpdf/plugin-thumbnail@^2.15.0@embedpdf/plugin-tiling@^2.15.0@embedpdf/plugin-viewport@^2.15.0@embedpdf/plugin-zoom@^2.15.0pdf-lib@^1.17.1
Registry dependencies
@extend/document-viewer-sidebarutils@extend/pdf-editor-corebuttondropdown-menuinputpopoverscroll-areaselectseparatortooltip
Files2 · source cached 10/8/2026, 3:22:59 AM
components/extend/pdf-viewer.tsx
"use client"
import * as React from "react"
import { createPluginRegistration, refreshPages } from "@embedpdf/core"
import { EmbedPDF, useRegistry } from "@embedpdf/core/react"
import type {
PdfDocumentObject,
PdfEngine,
Rect,
Rotation,
} from "@embedpdf/models"
import {
DocumentManagerPluginPackage,
useActiveDocument,
useDocumentManagerCapability,
} from "@embedpdf/plugin-document-manager/react"
import {
GlobalPointerProvider,
InteractionManagerPluginPackage,
PagePointerProvider,
} from "@embedpdf/plugin-interaction-manager/react"
import { RenderLayer, RenderPluginPackage } from "@embedpdf/plugin-render/react"
import { Rotate, RotatePluginPackage } from "@embedpdf/plugin-rotate/react"
import {
ScrollPluginPackage,
ScrollStrategy,
useScroll,
useScrollPlugin,
type PageLayout,
type ScrollerLayout,
type VirtualItem,
} from "@embedpdf/plugin-scroll/react"
import {
SearchLayer,
SearchPluginPackage,
useSearch,
} from "@embedpdf/plugin-search/react"
import {
CopyToClipboard,
SelectionPluginPackage,
useSelectionCapability,
useSelectionPlugin,
} from "@embedpdf/plugin-selection/react"
import {
ThumbImg,
ThumbnailPluginPackage,
useThumbnailCapability,
useThumbnailPlugin,
type ThumbMeta,
} from "@embedpdf/plugin-thumbnail/react"
import { TilingLayer, TilingPluginPackage } from "@embedpdf/plugin-tiling/react"
import {
useIsViewportGated,
useViewportCapability,
useViewportElement,
useViewportRef,
ViewportElementContext,
ViewportPluginPackage,
} from "@embedpdf/plugin-viewport/react"
import {
useZoom,
ZoomMode,
ZoomPluginPackage,
} from "@embedpdf/plugin-zoom/react"
import { flushSync } from "react-dom"
import { loadSharedPdfEngine } from "@/lib/pdf-thumbnail-utils"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { Input } from "@/components/ui/input"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover"
import { ScrollArea } from "@/components/ui/scroll-area"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { Separator } from "@/components/ui/separator"
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip"
import {
DocumentViewerSidebarSkeleton,
DocumentViewerThumbnailSidebar,
useElementWidth,
useInlineThumbnailSidebar,
} from "@/components/extend/document-viewer-sidebar"
import { IconPlaceholder } from "@/components/icon-placeholder"
export type PDFViewerPageOverlayProps = {
pageNumber: number
pageWidth: number
pageHeight: number
scale: number
rotation: number
}
export type PDFViewerHandle = {
scrollToPage: (pageNumber: number, options?: ScrollIntoViewOptions) => void
scrollToPageArea: (
pageNumber: number,
area: {
top: number
left?: number
width?: number
height?: number
},
options?: ScrollToOptions
) => void
getViewportElement: () => HTMLDivElement | null
}
export type PDFViewerScrollAreaViewportResolver = (
container: HTMLDivElement
) => HTMLDivElement | null
export type PDFViewerZoomLevel = number | "fit-page" | "fit-width" | "automatic"
export type PDFViewerProps = {
className?: string
defaultZoom?: PDFViewerZoomLevel
fileName?: string
resolveScrollAreaViewport?: PDFViewerScrollAreaViewportResolver
showDownload?: boolean
showToolbar?: boolean
showRotateControls?: boolean
showUpload?: boolean
src?: string
toolbarActions?: React.ReactNode
pageClassName?: (pageNumber: number) => string | undefined
renderPageOverlay?: (props: PDFViewerPageOverlayProps) => React.ReactNode
onActivePageChange?: (pageNumber: number) => void
onDocumentLoadSuccess?: (numPages: number) => void
onPdfUpload?: (file: File) => void
onPagePointerDown?: (
event: React.PointerEvent<HTMLDivElement>,
pageNumber: number
) => void
onPagePointerMove?: (
event: React.PointerEvent<HTMLDivElement>,
pageNumber: number
) => void
onPagePointerUp?: (
event: React.PointerEvent<HTMLDivElement>,
pageNumber: number
) => void
onPagePointerCancel?: (
event: React.PointerEvent<HTMLDivElement>,
pageNumber: number
) => void
}
const DEFAULT_ZOOM = 1
const ZOOM_OPTIONS = [0.1, 0.25, 0.5, 0.75, 1, 1.25, 1.5, 2]
const PAGE_GAP = 24
const THUMBNAIL_PAGE_WIDTH = 92
const THUMBNAIL_IMAGE_PADDING = 8
const THUMBNAIL_WIDTH = THUMBNAIL_PAGE_WIDTH + THUMBNAIL_IMAGE_PADDING * 2
const THUMBNAIL_LABEL_HEIGHT = 24
const THUMBNAIL_GAP = 12
const THUMBNAIL_PANE_PADDING_Y = 16
const THUMBNAIL_SIDEBAR_WIDTH_CLASS = "w-40"
const THUMBNAIL_SIDEBAR_CLOSED_CLASS = "-ml-40"
const PAGE_BASE_RENDER_MAX_SCALE = 1
const PAGE_BASE_RENDER_DPR = 1
const PDF_SEARCH_DEBOUNCE_MS = 300
const TEXT_SELECTION_BACKGROUND = "rgba(59, 130, 246, 0.14)"
const THUMBNAIL_FOCUS_RING_CLASS =
"group-focus-visible/pdf-thumbnail-sidebar:ring-2 group-focus-visible/pdf-thumbnail-sidebar:ring-ring group-focus-visible/pdf-thumbnail-sidebar:ring-offset-1 group-focus-visible/pdf-thumbnail-sidebar:ring-offset-background"
const DEFAULT_SCROLL_AREA_VIEWPORT_SELECTOR =
'[data-slot="scroll-area-viewport"]'
const ZOOM_MODE_LABELS: Record<ZoomMode, string> = {
[ZoomMode.FitPage]: "Fit page",
[ZoomMode.FitWidth]: "Fit width",
[ZoomMode.Automatic]: "Automatic",
}
function toZoomLevel(level: PDFViewerZoomLevel): ZoomMode | number {
if (level === "fit-page") return ZoomMode.FitPage
if (level === "fit-width") return ZoomMode.FitWidth
if (level === "automatic") return ZoomMode.Automatic
return level
}
function isZoomMode(value: unknown): value is ZoomMode {
return (
value === ZoomMode.FitPage ||
value === ZoomMode.FitWidth ||
value === ZoomMode.Automatic
)
}
function resolveDefaultScrollAreaViewport(container: HTMLDivElement) {
return container.querySelector<HTMLDivElement>(
DEFAULT_SCROLL_AREA_VIEWPORT_SELECTOR
)
}
const PDFViewerScrollAreaResolverContext =
React.createContext<PDFViewerScrollAreaViewportResolver>(
resolveDefaultScrollAreaViewport
)
type PageRotationDeltas = Map<number, Rotation>
type ThumbnailSelectionMode = "replace" | "toggle" | "range"
function getPageIndexRange(from: number, to: number): Set<number> {
const start = Math.min(from, to)
const end = Math.max(from, to)
const range = new Set<number>()
for (let pageIndex = start; pageIndex <= end; pageIndex += 1) {
range.add(pageIndex)
}
return range
}
function arePageIndexSetsEqual(left: Set<number>, right: Set<number>) {
if (left.size !== right.size) return false
for (const value of left) {
if (!right.has(value)) return false
}
return true
}
function normalizeRotation(rotation: number): Rotation {
return (((rotation % 4) + 4) % 4) as Rotation
}
function useSharedPdfEngine() {
const [engine, setEngine] = React.useState<PdfEngine | null>(null)
const [error, setError] = React.useState<Error | null>(null)
React.useEffect(() => {
let cancelled = false
loadSharedPdfEngine().then(
(loadedEngine) => {
if (!cancelled) setEngine(loadedEngine)
},
(loadError: Error) => {
if (!cancelled) setError(loadError)
}
)
return () => {
cancelled = true
}
}, [])
return { engine, error }
}
function rotationToDegrees(rotation: Rotation) {
return (rotation as number) * 90
}
function normalizeDegrees(rotation: number) {
return ((rotation % 360) + 360) % 360
}
function ensurePdfExtension(fileName: string) {
return fileName.toLowerCase().endsWith(".pdf") ? fileName : `${fileName}.pdf`
}
function getPdfDownloadFileName(fileName: string | undefined, src: string) {
if (fileName?.trim()) return ensurePdfExtension(fileName.trim())
const pathname = src.split(/[?#]/)[0] ?? ""
const rawName = pathname.split("/").pop() || "document.pdf"
try {
return ensurePdfExtension(decodeURIComponent(rawName))
} catch {
return ensurePdfExtension(rawName)
}
}
function getRotatedPdfDownloadFileName(fileName: string) {
return fileName.replace(/\.pdf$/i, "-rotated.pdf")
}
function downloadBlob(blob: Blob, fileName: string) {
const url = URL.createObjectURL(blob)
const anchor = document.createElement("a")
anchor.href = url
anchor.download = fileName
anchor.rel = "noopener"
document.body.append(anchor)
anchor.click()
anchor.remove()
window.setTimeout(() => URL.revokeObjectURL(url), 0)
}
async function downloadPdfWithPageRotations({
fileName,
pageRotationDeltas,
src,
}: {
fileName: string
pageRotationDeltas: PageRotationDeltas
src: string
}) {
const response = await fetch(src)
if (!response.ok) {
throw new Error(`Failed to download PDF (${response.status})`)
}
if (pageRotationDeltas.size === 0) {
downloadBlob(await response.blob(), fileName)
return
}
const [{ PDFDocument, degrees }, pdfBytes] = await Promise.all([
import("pdf-lib"),
response.arrayBuffer(),
])
const pdfDocument = await PDFDocument.load(pdfBytes)
pdfDocument.getPages().forEach((page, pageIndex) => {
const rotationDelta = pageRotationDeltas.get(pageIndex)
if (!rotationDelta) return
page.setRotation(
degrees(
normalizeDegrees(
page.getRotation().angle + rotationToDegrees(rotationDelta)
)
)
)
})
const nextPdfBytes = await pdfDocument.save()
const nextPdfBuffer = new ArrayBuffer(nextPdfBytes.byteLength)
new Uint8Array(nextPdfBuffer).set(nextPdfBytes)
downloadBlob(
new Blob([nextPdfBuffer], { type: "application/pdf" }),
getRotatedPdfDownloadFileName(fileName)
)
}
function getThumbnailMetaForPage({
page,
pageIndex,
rotation,
width,
imagePadding,
labelHeight,
top,
}: {
page: PdfDocumentObject["pages"][number]
pageIndex: number
rotation: Rotation
width: number
imagePadding: number
labelHeight: number
top: number
}): ThumbMeta {
const innerWidth = Math.max(1, width - imagePadding * 2)
const pageWidth = rotation % 2 === 1 ? page.size.height : page.size.width
const pageHeight = rotation % 2 === 1 ? page.size.width : page.size.height
const imageHeight = Math.round(innerWidth * (pageHeight / pageWidth))
const wrapperHeight = imagePadding + imageHeight + imagePadding + labelHeight
return {
pageIndex,
width: innerWidth,
height: imageHeight,
wrapperHeight,
top,
labelHeight,
padding: imagePadding,
}
}
function buildThumbnailLayout({
basePageRotations,
pageRotationDeltas,
pdfDocument,
width,
gap,
imagePadding,
labelHeight,
paddingY,
}: {
basePageRotations: Rotation[]
pageRotationDeltas: PageRotationDeltas
pdfDocument: PdfDocumentObject | null
width: number
gap: number
imagePadding: number
labelHeight: number
paddingY: number
}) {
if (!pdfDocument) return null
let top = paddingY
const items = pdfDocument.pages.map((page, pageIndex) => {
const basePageRotation =
basePageRotations[pageIndex] ?? normalizeRotation(page.rotation)
const pageRotation = normalizeRotation(
basePageRotation + (pageRotationDeltas.get(pageIndex) ?? 0)
)
const meta = getThumbnailMetaForPage({
page,
pageIndex,
rotation: pageRotation,
width,
imagePadding,
labelHeight,
top,
})
top += meta.wrapperHeight + gap
return meta
})
return {
items,
totalHeight: items.length ? top - gap + paddingY : paddingY * 2,
}
}
function getVisibleThumbnailItems({
buffer,
clientHeight,
items,
scrollTop,
}: {
buffer: number
clientHeight: number
items: ThumbMeta[]
scrollTop: number
}) {
if (items.length === 0) return []
if (clientHeight <= 0)
return items.slice(0, Math.min(items.length, buffer * 2))
const viewportBottom = scrollTop + clientHeight
let start = items.findIndex(
(item) => item.top + item.wrapperHeight >= scrollTop
)
if (start === -1) start = items.length - 1
let end = start
while (end < items.length && items[end].top <= viewportBottom) {
end += 1
}
return items.slice(
Math.max(0, start - buffer),
Math.min(items.length, end + buffer)
)
}
function PDFViewerLoadingSkeleton({
sidebarOpen,
sidebarInline,
}: {
sidebarOpen: boolean
sidebarInline: boolean
}) {
return (
<div className="absolute inset-0 z-20 flex bg-muted/30">
{sidebarOpen ? (
<DocumentViewerSidebarSkeleton
className={THUMBNAIL_SIDEBAR_WIDTH_CLASS}
inline={sidebarInline}
/>
) : null}
<div className="grid min-w-0 flex-1 place-items-center">
<InlineSpinner className="size-4" />
</div>
</div>
)
}
// Rendered while the engine or document is not ready: same frame and controls
// as the full viewer, with document-dependent controls disabled.
function PDFViewerFallbackShell({
className,
defaultZoom,
errorMessage = "Unable to load the PDF preview.",
showDownload,
showRotateControls,
showToolbar,
showUpload,
sidebarOpen,
state,
toolbarActions,
onUploadFile,
}: {
className?: string
defaultZoom: PDFViewerZoomLevel
errorMessage?: string
showDownload: boolean
showRotateControls: boolean
showToolbar: boolean
showUpload: boolean
sidebarOpen: boolean
state: "loading" | "error" | "empty"
toolbarActions?: React.ReactNode
onUploadFile?: (file: File) => void
}) {
return (
<div
data-slot="pdf-viewer"
className={cn(
"flex h-full max-h-full min-h-0 w-full flex-col overflow-hidden bg-background",
className
)}
>
{showToolbar ? (
<PDFViewerToolbar
activePage={1}
controlsDisabled
currentZoomLevel={
typeof defaultZoom === "number" ? defaultZoom : DEFAULT_ZOOM
}
zoomLevel={toZoomLevel(defaultZoom)}
numPages={0}
searchControl={
<ToolbarTooltip label="Search text">
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label="Search text"
disabled
>
<IconPlaceholder
lucide="Search"
tabler="IconSearch"
hugeicons="Search01Icon"
phosphor="MagnifyingGlassIcon"
remixicon="RiSearchLine"
className="size-4"
/>
</Button>
</ToolbarTooltip>
}
sidebarOpen={sidebarOpen}
showDownload={showDownload}
showRotateControls={showRotateControls}
showUpload={showUpload}
toolbarActions={toolbarActions}
onPageChange={() => undefined}
onRotate={() => undefined}
onToggleSidebar={() => undefined}
onUploadFile={onUploadFile}
onZoomChange={() => undefined}
/>
) : null}
<div className="relative flex min-h-0 flex-1 overflow-hidden bg-muted/30">
{state === "loading" ? (
<PDFViewerLoadingSkeleton sidebarInline sidebarOpen={sidebarOpen} />
) : null}
{state === "error" ? (
<div className="absolute inset-0 z-20 grid place-items-center bg-background p-6 text-sm text-muted-foreground">
{errorMessage}
</div>
) : null}
{state === "empty" ? (
<div className="absolute inset-0 z-20 grid place-items-center bg-background p-6 text-center text-sm text-muted-foreground">
<div className="max-w-sm space-y-3">
<div className="font-medium text-foreground">
Upload a PDF to preview
</div>
<div>
Pass a PDF URL with the <code>src</code> prop or use the upload
control.
</div>
</div>
</div>
) : null}
</div>
</div>
)
}
function ToolbarTooltip({
label,
children,
}: {
label: string
children: React.ReactNode
}) {
return (
<Tooltip>
<TooltipTrigger
render={<span className="inline-flex">{children}</span>}
></TooltipTrigger>
<TooltipContent side="bottom">{label}</TooltipContent>
</Tooltip>
)
}
function PDFViewerFileActionsMenu({
downloadDisabled,
isPreparingDownload = false,
onDownload,
onUploadFile,
showDownload = false,
showUpload = false,
}: {
downloadDisabled?: boolean
isPreparingDownload?: boolean
onDownload?: () => void
onUploadFile?: (file: File) => void
showDownload?: boolean
showUpload?: boolean
}) {
const inputRef = React.useRef<HTMLInputElement>(null)
if (!showDownload && !showUpload) return null
return (
<>
{showUpload && onUploadFile ? (
<input
ref={inputRef}
type="file"
accept="application/pdf,.pdf"
className="sr-only"
tabIndex={-1}
onChange={(event) => {
const nextFile = event.target.files?.[0]
if (nextFile) {
onUploadFile(nextFile)
event.currentTarget.value = ""
}
}}
/>
) : null}
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label="Open PDF actions"
>
<IconPlaceholder
lucide="Ellipsis"
tabler="IconDots"
hugeicons="MoreHorizontalIcon"
phosphor="DotsThreeIcon"
remixicon="RiMoreLine"
className="size-4"
/>
</Button>
}
></DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-40">
{showDownload ? (
<DropdownMenuItem disabled={downloadDisabled} onClick={onDownload}>
{isPreparingDownload ? (
<InlineSpinner className="size-4" />
) : (
<IconPlaceholder
lucide="Download"
tabler="IconDownload"
hugeicons="Download01Icon"
phosphor="DownloadSimpleIcon"
remixicon="RiDownload2Line"
className="size-4"
/>
)}
Download
</DropdownMenuItem>
) : null}
{showUpload && onUploadFile ? (
<DropdownMenuItem onClick={() => inputRef.current?.click()}>
<IconPlaceholder
lucide="Upload"
tabler="IconUpload"
hugeicons="Upload01Icon"
phosphor="UploadSimpleIcon"
remixicon="RiUpload2Line"
className="size-4"
/>
Upload
</DropdownMenuItem>
) : null}
</DropdownMenuContent>
</DropdownMenu>
</>
)
}
function PDFViewerPageNumberControl({
activePage,
controlsDisabled,
numPages,
onPageChange,
}: {
activePage: number
controlsDisabled: boolean
numPages: number
onPageChange: (pageNumber: number) => void
}) {
const inputRef = React.useRef<HTMLInputElement>(null)
const displayPage = numPages ? activePage : 1
const [isEditing, setIsEditing] = React.useState(false)
const [draftPage, setDraftPage] = React.useState(() => String(displayPage))
React.useEffect(() => {
if (!isEditing) return
inputRef.current?.focus()
inputRef.current?.select()
}, [isEditing])
const applyPageDraft = React.useCallback(
(value: string) => {
const trimmedValue = value.trim()
if (!trimmedValue) return
const parsedPage = Number(trimmedValue)
if (!Number.isInteger(parsedPage)) return
onPageChange(Math.min(Math.max(parsedPage, 1), Math.max(numPages, 1)))
},
[numPages, onPageChange]
)
return (
<div className="flex items-center text-sm whitespace-nowrap text-primary">
<span>Page</span>
{isEditing ? (
<Input
ref={inputRef}
aria-label="Page number"
inputMode="numeric"
pattern="[0-9]*"
value={draftPage}
onBlur={() => setIsEditing(false)}
onChange={(event: React.ChangeEvent<HTMLInputElement>) => {
const nextValue = event.target.value
setDraftPage(nextValue)
applyPageDraft(nextValue)
}}
onKeyDown={(event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.key === "Enter" || event.key === "Escape") {
event.currentTarget.blur()
}
}}
className={cn(
"h-8 px-2.5",
"mx-1 w-14 min-w-14 rounded-md [&_[data-slot=input]]:text-center"
)}
/>
) : (
<Button
type="button"
variant="ghost"
size="sm"
className="font-normal"
aria-label={`Current page ${displayPage}. Edit page number`}
disabled={controlsDisabled || !numPages}
onClick={() => {
setDraftPage(String(displayPage))
setIsEditing(true)
}}
>
{displayPage}
</Button>
)}
<span>of {numPages || "–"}</span>
</div>
)
}
function PDFViewerSearchControl({
documentId,
controlsDisabled,
}: {
documentId: string
controlsDisabled: boolean
}) {
const { state, provides } = useSearch(documentId)
const { provides: scroll } = useScroll(documentId)
const [searchDraft, setSearchDraft] = React.useState("")
const [searchQuery, setSearchQuery] = React.useState("")
const [isSearching, setIsSearching] = React.useState(false)
const providesRef = React.useRef(provides)
const scrollRef = React.useRef(scroll)
const searchRequestIdRef = React.useRef(0)
const hasActiveQuery = Boolean(searchQuery.trim())
const resultLabel = isSearching
? "Searching"
: !hasActiveQuery
? "No search"
: state.total
? `${state.activeResultIndex + 1} / ${state.total}`
: "No results"
const scrollToResult = React.useCallback(
(index: number) => {
const result = state.results[index]
if (!result || !scroll) return
const firstRect = result.rects[0]
scroll.scrollToPage({
pageNumber: result.pageIndex + 1,
...(firstRect
? {
pageCoordinates: {
x: firstRect.origin.x,
y: firstRect.origin.y,
},
alignY: 30,
}
: {}),
behavior: "auto",
})
},
[scroll, state.results]
)
React.useEffect(() => {
providesRef.current = provides
scrollRef.current = scroll
}, [provides, scroll])
const runSearch = React.useCallback((rawQuery: string) => {
const query = rawQuery.trim()
const requestId = searchRequestIdRef.current + 1
searchRequestIdRef.current = requestId
setSearchQuery(query)
const searchProvider = providesRef.current
const scrollProvider = scrollRef.current
if (!searchProvider) {
setIsSearching(false)
return
}
if (!query) {
searchProvider.stopSearch()
setIsSearching(false)
return
}
setIsSearching(true)
searchProvider.startSearch()
searchProvider.searchAllPages(query).wait(
(result) => {
if (searchRequestIdRef.current !== requestId) return
const firstResult = result.results[0]
if (firstResult && scrollProvider) {
searchProvider.goToResult(0)
const firstRect = firstResult.rects[0]
scrollProvider.scrollToPage({
pageNumber: firstResult.pageIndex + 1,
...(firstRect
? {
pageCoordinates: {
x: firstRect.origin.x,
y: firstRect.origin.y,
},
alignY: 30,
}
: {}),
behavior: "auto",
})
}
setIsSearching(false)
},
() => {
if (searchRequestIdRef.current !== requestId) return
setIsSearching(false)
}
)
}, [])
React.useEffect(() => {
if (!searchDraft.trim()) return
const timeoutId = window.setTimeout(() => {
runSearch(searchDraft)
}, PDF_SEARCH_DEBOUNCE_MS)
return () => window.clearTimeout(timeoutId)
}, [runSearch, searchDraft])
const handleSearchDraftChange = React.useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
const nextDraft = event.target.value
setSearchDraft(nextDraft)
if (nextDraft.trim()) {
setIsSearching(true)
return
}
searchRequestIdRef.current += 1
setSearchQuery("")
setIsSearching(false)
provides?.stopSearch()
},
[provides]
)
const clearSearch = React.useCallback(() => {
searchRequestIdRef.current += 1
setSearchDraft("")
setSearchQuery("")
setIsSearching(false)
provides?.stopSearch()
}, [provides])
const navigate = React.useCallback(
(direction: 1 | -1) => {
if (!provides || state.total === 0) return
const index =
direction === 1 ? provides.nextResult() : provides.previousResult()
scrollToResult(index)
},
[provides, scrollToResult, state.total]
)
return (
<Popover>
<ToolbarTooltip label="Search text">
<PopoverTrigger
render={
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label="Search text"
disabled={controlsDisabled}
>
<IconPlaceholder
lucide="Search"
tabler="IconSearch"
hugeicons="Search01Icon"
phosphor="MagnifyingGlassIcon"
remixicon="RiSearchLine"
className="size-4"
/>
</Button>
}
></PopoverTrigger>
</ToolbarTooltip>
<PopoverContent align="end" className="w-72">
<div className="space-y-3">
<Input
placeholder="Search text"
value={searchDraft}
onChange={handleSearchDraftChange}
onKeyDown={(event) => {
if (event.key !== "Enter") return
event.preventDefault()
if (event.shiftKey && state.total) {
navigate(-1)
} else if (state.total) {
navigate(1)
} else if (searchDraft.trim()) {
runSearch(searchDraft)
}
}}
/>
<div className="flex items-center justify-between gap-2">
<div className="min-w-0 text-xs text-muted-foreground">
<div className="truncate">
{state.total ? (
<>
<span className="text-primary">
{state.activeResultIndex + 1}
</span>
{` / ${state.total}`}
</>
) : (
resultLabel
)}
</div>
</div>
<div className="flex shrink-0 items-center gap-1">
<Button
type="button"
variant="outline"
size="icon-sm"
aria-label="Previous result"
disabled={isSearching || state.total === 0}
onClick={() => navigate(-1)}
>
<IconPlaceholder
lucide="ChevronLeft"
tabler="IconChevronLeft"
hugeicons="ArrowLeft01Icon"
phosphor="CaretLeftIcon"
remixicon="RiArrowLeftSLine"
className="size-4"
/>
</Button>
<Button
type="button"
variant="outline"
size="icon-sm"
aria-label="Next result"
disabled={isSearching || state.total === 0}
onClick={() => navigate(1)}
>
<IconPlaceholder
lucide="ArrowRight"
tabler="IconArrowRight"
hugeicons="ArrowRight01Icon"
phosphor="ArrowRightIcon"
remixicon="RiArrowRightLine"
className="size-4"
/>
</Button>
</div>
</div>
<div className="flex justify-end">
<Button
type="button"
variant="outline"
size="sm"
onClick={clearSearch}
>
Clear
</Button>
</div>
</div>
</PopoverContent>
</Popover>
)
}
function PDFViewerToolbar({
activePage,
controlsDisabled,
currentZoomLevel,
downloadDisabled = controlsDisabled,
isPreparingDownload = false,
numPages,
searchControl,
sidebarOpen,
showDownload,
showRotateControls,
showUpload,
toolbarActions,
onDownload,
onPageChange,
onRotate,
onToggleSidebar,
onUploadFile,
onZoomChange,
zoomLevel,
}: {
activePage: number
controlsDisabled: boolean
currentZoomLevel: number
downloadDisabled?: boolean
isPreparingDownload?: boolean
numPages: number
searchControl: React.ReactNode
sidebarOpen: boolean
showDownload: boolean
showRotateControls: boolean
showUpload: boolean
toolbarActions?: React.ReactNode
onDownload?: () => void
onPageChange: (pageNumber: number) => void
onRotate: (direction: 1 | -1) => void
onToggleSidebar: () => void
onUploadFile?: (file: File) => void
onZoomChange: (zoomLevel: ZoomMode | number) => void
zoomLevel: ZoomMode | number
}) {
const selectValue = isZoomMode(zoomLevel)
? zoomLevel
: String(Number(currentZoomLevel.toFixed(2)))
const zoomOptions = ZOOM_OPTIONS.includes(Number(currentZoomLevel.toFixed(2)))
? ZOOM_OPTIONS
: [...ZOOM_OPTIONS, Number(currentZoomLevel.toFixed(2))].sort(
(left, right) => left - right
)
return (
<div className="flex min-h-12 flex-wrap items-center justify-between gap-2 border-b bg-background px-3 py-2">
<div className="flex min-w-0 flex-wrap items-center gap-2">
<TooltipProvider>
<ToolbarTooltip label="Pages sidebar">
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label="Pages sidebar"
aria-pressed={sidebarOpen}
data-active={sidebarOpen ? "" : undefined}
className={cn(
sidebarOpen &&
"bg-accent text-accent-foreground ring-1 ring-ring/40 ring-inset"
)}
disabled={controlsDisabled}
onClick={onToggleSidebar}
>
<IconPlaceholder
lucide="PanelLeft"
tabler="IconLayoutSidebar"
hugeicons="SidebarLeftIcon"
phosphor="SidebarIcon"
remixicon="RiLayoutLeftLine"
className="size-4"
/>
</Button>
</ToolbarTooltip>
</TooltipProvider>
<PDFViewerPageNumberControl
activePage={activePage}
controlsDisabled={controlsDisabled}
numPages={numPages}
onPageChange={onPageChange}
/>
</div>
<TooltipProvider>
<div className="flex min-w-0 flex-wrap items-center justify-end gap-1">
{showRotateControls ? (
<>
<div className="flex flex-none items-center gap-1">
<ToolbarTooltip label="Rotate counterclockwise">
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label="Rotate counterclockwise"
disabled={controlsDisabled}
onClick={() => onRotate(-1)}
>
<IconPlaceholder
lucide="RotateCw"
tabler="IconRotateClockwise"
hugeicons="RotateClockwiseIcon"
phosphor="ArrowClockwiseIcon"
remixicon="RiClockwiseLine"
className="size-4"
/>
</Button>
</ToolbarTooltip>
<ToolbarTooltip label="Rotate clockwise">
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label="Rotate clockwise"
disabled={controlsDisabled}
onClick={() => onRotate(1)}
>
<IconPlaceholder
lucide="RotateCw"
tabler="IconRotateClockwise"
hugeicons="RotateClockwiseIcon"
phosphor="ArrowClockwiseIcon"
remixicon="RiClockwiseLine"
className="size-4 -scale-x-100"
/>
</Button>
</ToolbarTooltip>
</div>
<Separator
orientation="vertical"
className="mx-1 h-4 self-center"
/>
</>
) : null}
<div className="flex flex-none items-center gap-1">
<ToolbarTooltip label="Zoom out">
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label="Zoom out"
disabled={
controlsDisabled || currentZoomLevel <= ZOOM_OPTIONS[0]
}
onClick={() => {
const nextZoom = [...ZOOM_OPTIONS]
.reverse()
.find((option) => option < currentZoomLevel)
onZoomChange(nextZoom ?? ZOOM_OPTIONS[0])
}}
>
<IconPlaceholder
lucide="CircleMinus"
tabler="IconCircleMinus"
hugeicons="MinusSignCircleIcon"
phosphor="MinusCircleIcon"
remixicon="RiIndeterminateCircleLine"
className="size-4"
/>
</Button>
</ToolbarTooltip>
<Select
value={selectValue}
onValueChange={(value) => {
const next = String(value)
onZoomChange(isZoomMode(next) ? next : Number(next))
}}
disabled={controlsDisabled}
modal={false}
>
<SelectTrigger
size="sm"
className="w-[104px] min-w-[104px]"
aria-label="Zoom level"
>
<SelectValue placeholder="Zoom">
{Math.round(currentZoomLevel * 100)}%
</SelectValue>
</SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
{(Object.keys(ZOOM_MODE_LABELS) as ZoomMode[]).map((mode) => (
<SelectItem key={mode} value={mode}>
{ZOOM_MODE_LABELS[mode]}
</SelectItem>
))}
{zoomOptions.map((option) => (
<SelectItem key={option} value={String(option)}>
{Math.round(option * 100)}%
</SelectItem>
))}
</SelectContent>
</Select>
<ToolbarTooltip label="Zoom in">
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label="Zoom in"
disabled={
controlsDisabled ||
currentZoomLevel >= ZOOM_OPTIONS[ZOOM_OPTIONS.length - 1]
}
onClick={() => {
const nextZoom = ZOOM_OPTIONS.find(
(option) => option > currentZoomLevel
)
onZoomChange(
nextZoom ?? ZOOM_OPTIONS[ZOOM_OPTIONS.length - 1]
)
}}
>
<IconPlaceholder
lucide="CirclePlusIcon"
tabler="IconCirclePlusFilled"
hugeicons="PlusSignCircleIcon"
phosphor="PlusCircleIcon"
remixicon="RiAddCircleFill"
className="size-4"
/>
</Button>
</ToolbarTooltip>
</div>
<Separator orientation="vertical" className="mx-1 h-4 self-center" />
{searchControl}
{toolbarActions ? (
<>
<Separator
orientation="vertical"
className="mx-1 h-4 self-center"
/>
{toolbarActions}
</>
) : null}
{showDownload || showUpload ? (
<>
<Separator
orientation="vertical"
className="mx-1 h-4 self-center"
/>
<PDFViewerFileActionsMenu
downloadDisabled={downloadDisabled}
isPreparingDownload={isPreparingDownload}
onDownload={onDownload}
onUploadFile={onUploadFile}
showDownload={showDownload}
showUpload={showUpload}
/>
</>
) : null}
</div>
</TooltipProvider>
</div>
)
}
function setPdfViewerRef<T>(ref: React.Ref<T> | undefined, value: T | null) {
if (!ref) return
if (typeof ref === "function") {
ref(value)
} else {
ref.current = value
}
}
function PDFViewerScrollArea({
children,
className,
viewportClassName,
viewportProps,
viewportRef,
}: {
children: React.ReactNode
className?: string
viewportClassName?: string
viewportProps?: React.HTMLAttributes<HTMLDivElement>
viewportRef?: React.Ref<HTMLDivElement>
}) {
const resolveScrollAreaViewport = React.useContext(
PDFViewerScrollAreaResolverContext
)
const containerRef = React.useRef<HTMLDivElement | null>(null)
const { className: viewportPropsClassName, ...resolvedViewportProps } =
viewportProps ?? {}
const setViewportRef = React.useCallback(
(viewport: HTMLDivElement | null) => {
setPdfViewerRef(viewportRef, viewport)
},
[viewportRef]
)
React.useLayoutEffect(() => {
const container = containerRef.current
if (!container) return
const viewport = resolveScrollAreaViewport(container)
if (!viewport) {
console.error(
`PDFViewer could not resolve the scroll viewport. Add ${DEFAULT_SCROLL_AREA_VIEWPORT_SELECTOR} to your ScrollArea viewport or pass resolveScrollAreaViewport.`
)
return
}
setViewportRef(viewport)
return () => setViewportRef(null)
}, [resolveScrollAreaViewport, setViewportRef])
return (
<div
ref={containerRef}
data-slot="pdf-viewer-scroll-area"
className={cn("size-full min-h-0", className)}
>
<ScrollArea className="size-full min-h-0">
<div
{...resolvedViewportProps}
data-slot="pdf-viewer-scroll-content"
className={cn(
"min-h-full",
viewportPropsClassName,
viewportClassName
)}
>
{children}
</div>
</ScrollArea>
</div>
)
}
function PDFViewerThumbnails({
basePageRotations,
documentId,
activePage,
pageCount,
pageRotationDeltas,
pdfDocument,
selectedPageIndexes,
onSelectPage,
}: {
basePageRotations: Rotation[]
documentId: string
activePage: number
pageCount: number
pageRotationDeltas: PageRotationDeltas
pdfDocument: PdfDocumentObject | null
selectedPageIndexes: Set<number>
onSelectPage: (pageNumber: number, mode: ThumbnailSelectionMode) => void
}) {
const thumbnailListboxId = React.useId()
const activeDescendantId =
activePage > 0 ? `${thumbnailListboxId}-page-${activePage}` : undefined
const handleKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => {
if (pageCount < 1) return
const currentPage = activePage > 0 ? activePage : 1
let nextPage: number | null = null
if (event.key === "ArrowDown") {
nextPage = Math.min(pageCount, currentPage + 1)
} else if (event.key === "ArrowUp") {
nextPage = Math.max(1, currentPage - 1)
} else if (event.key === "Home") {
nextPage = 1
} else if (event.key === "End") {
nextPage = pageCount
} else if (event.key === " ") {
event.preventDefault()
onSelectPage(currentPage, "toggle")
return
}
if (nextPage === null) return
event.preventDefault()
onSelectPage(nextPage, event.shiftKey ? "range" : "replace")
},
[activePage, onSelectPage, pageCount]
)
return (
<PDFViewerThumbnailScrollArea
activeDescendantId={activeDescendantId}
basePageRotations={basePageRotations}
documentId={documentId}
onKeyDown={handleKeyDown}
pageRotationDeltas={pageRotationDeltas}
pdfDocument={pdfDocument}
>
{(meta: ThumbMeta) => {
const pageNumber = meta.pageIndex + 1
const isActive = pageNumber === activePage
const isSelected = selectedPageIndexes.has(meta.pageIndex)
const imagePadding = meta.padding ?? 0
const pageRotationDelta = pageRotationDeltas.get(meta.pageIndex) ?? 0
const thumbnailImageStyle: React.CSSProperties =
pageRotationDelta % 2 === 1
? {
height: meta.width,
transform: `rotate(${rotationToDegrees(pageRotationDelta)}deg)`,
width: meta.height,
}
: {
height: meta.height,
transform:
pageRotationDelta === 0
? undefined
: `rotate(${rotationToDegrees(pageRotationDelta)}deg)`,
width: meta.width,
}
return (
<div
key={meta.pageIndex}
data-pdf-viewer-thumbnail={pageNumber}
className={cn(
"absolute right-0 left-0 flex justify-center",
isActive && "z-10"
)}
style={{ top: meta.top, height: meta.wrapperHeight }}
>
<div
id={`${thumbnailListboxId}-page-${pageNumber}`}
role="option"
data-pdf-viewer-thumbnail-option={pageNumber}
aria-current={isActive ? "page" : undefined}
aria-label={`Page ${pageNumber}`}
aria-posinset={pageNumber}
aria-selected={isSelected}
aria-setsize={pageCount}
data-selected={isSelected ? "" : undefined}
className={cn(
"flex h-full w-full cursor-default flex-col items-center justify-between rounded-md px-2 py-0 text-xs transition-shadow outline-none select-none hover:bg-sidebar-accent",
isActive || isSelected
? "bg-sidebar-accent text-foreground"
: "text-muted-foreground",
isActive && THUMBNAIL_FOCUS_RING_CLASS
)}
onClick={(event) => {
const mode = event.shiftKey
? "range"
: event.metaKey || event.ctrlKey
? "toggle"
: "replace"
onSelectPage(pageNumber, mode)
}}
>
<span
className="mt-0 flex items-center justify-center overflow-hidden rounded-md bg-transparent"
style={{
width: meta.width + imagePadding * 2,
height: meta.height + imagePadding * 2,
padding: imagePadding,
}}
>
<ThumbImg
documentId={documentId}
meta={meta}
className="block rounded-sm object-contain"
style={thumbnailImageStyle}
/>
</span>
<span
className="flex items-center justify-center tabular-nums"
style={{ height: meta.labelHeight }}
>
<span className="flex min-w-5 items-center justify-center px-1.5 text-center leading-5">
{pageNumber}
</span>
</span>
</div>
</div>
)
}}
</PDFViewerThumbnailScrollArea>
)
}
function PDFViewerThumbnailScrollArea({
activeDescendantId,
basePageRotations,
children,
documentId,
onKeyDown,
pageRotationDeltas,
pdfDocument,
}: {
activeDescendantId?: string
basePageRotations: Rotation[]
children: (meta: ThumbMeta) => React.ReactNode
documentId: string
onKeyDown: React.KeyboardEventHandler<HTMLDivElement>
pageRotationDeltas: PageRotationDeltas
pdfDocument: PdfDocumentObject | null
}) {
const { plugin: thumbnailPlugin } = useThumbnailPlugin()
const viewportRef = React.useRef<HTMLDivElement | null>(null)
const [viewportMetrics, setViewportMetrics] = React.useState({
clientHeight: 0,
scrollTop: 0,
})
const thumbnailScope = React.useMemo(
() => thumbnailPlugin?.provides().forDocument(documentId) ?? null,
[documentId, thumbnailPlugin]
)
const windowState = React.useSyncExternalStore(
React.useCallback(
(onStoreChange) => {
if (!thumbnailScope) return () => undefined
return thumbnailScope.onWindow(() => onStoreChange())
},
[thumbnailScope]
),
React.useCallback(
() => thumbnailScope?.getWindow() ?? null,
[thumbnailScope]
),
() => null
)
const hasWindowState = Boolean(windowState)
const paddingY = thumbnailPlugin?.cfg.paddingY ?? 0
const thumbnailLayout = React.useMemo(
() =>
buildThumbnailLayout({
basePageRotations,
pageRotationDeltas,
pdfDocument,
width: thumbnailPlugin?.cfg.width ?? THUMBNAIL_WIDTH,
gap: thumbnailPlugin?.cfg.gap ?? THUMBNAIL_GAP,
imagePadding: thumbnailPlugin?.cfg.imagePadding ?? 0,
labelHeight: thumbnailPlugin?.cfg.labelHeight ?? THUMBNAIL_LABEL_HEIGHT,
paddingY,
}),
[
basePageRotations,
pageRotationDeltas,
pdfDocument,
paddingY,
thumbnailPlugin,
]
)
const effectiveWindowState = React.useMemo(() => {
if (!thumbnailLayout) return windowState
const items = getVisibleThumbnailItems({
buffer: thumbnailPlugin?.cfg.buffer ?? 3,
clientHeight: viewportMetrics.clientHeight,
items: thumbnailLayout.items,
scrollTop: viewportMetrics.scrollTop,
})
return {
start: items[0]?.pageIndex ?? -1,
end: items.at(-1)?.pageIndex ?? -1,
items,
totalHeight: thumbnailLayout.totalHeight,
}
}, [thumbnailLayout, thumbnailPlugin, viewportMetrics, windowState])
React.useEffect(() => {
const viewport = viewportRef.current
if (!viewport || !thumbnailScope) return
const updateWindow = () => {
setViewportMetrics({
clientHeight: viewport.clientHeight,
scrollTop: viewport.scrollTop,
})
thumbnailScope.updateWindow(viewport.scrollTop, viewport.clientHeight)
}
viewport.addEventListener("scroll", updateWindow)
const frame = window.requestAnimationFrame(updateWindow)
return () => {
window.cancelAnimationFrame(frame)
viewport.removeEventListener("scroll", updateWindow)
}
}, [thumbnailScope])
React.useEffect(() => {
const viewport = viewportRef.current
if (!viewport || !thumbnailScope) return
const resizeObserver = new ResizeObserver(() => {
setViewportMetrics({
clientHeight: viewport.clientHeight,
scrollTop: viewport.scrollTop,
})
thumbnailScope.updateWindow(viewport.scrollTop, viewport.clientHeight)
})
resizeObserver.observe(viewport)
return () => resizeObserver.disconnect()
}, [thumbnailScope])
React.useEffect(() => {
const viewport = viewportRef.current
if (!viewport || !thumbnailScope) return
thumbnailScope.updateWindow(viewport.scrollTop, viewport.clientHeight)
}, [thumbnailLayout, thumbnailScope, windowState])
React.useEffect(() => {
const viewport = viewportRef.current
if (!viewport || !thumbnailScope || !hasWindowState) return
return thumbnailScope.onScrollTo(({ top, behavior }) => {
viewport.scrollTo({ top, behavior })
})
}, [hasWindowState, thumbnailScope])
return (
<PDFViewerScrollArea
className="h-full w-full"
viewportClassName="group/pdf-thumbnail-sidebar px-4 focus-visible:ring-0 focus-visible:ring-offset-0"
viewportProps={{
"aria-activedescendant": activeDescendantId,
"aria-label": "PDF pages",
"aria-multiselectable": true,
onKeyDown,
onMouseDown: (event) => {
event.currentTarget.focus({ preventScroll: true })
},
role: "listbox",
style: {
paddingBottom: paddingY,
paddingTop: paddingY,
},
tabIndex: 0,
}}
viewportRef={viewportRef}
>
<div
className="relative"
style={{ height: effectiveWindowState?.totalHeight ?? 0 }}
>
{effectiveWindowState?.items.map((meta) => children(meta))}
</div>
</PDFViewerScrollArea>
)
}
function PDFViewerScrollAreaViewport({
children,
className,
documentId,
}: {
children: React.ReactNode
className?: string
documentId: string
}) {
const viewportRef = useViewportRef(documentId)
const { provides: viewport } = useViewportCapability()
const isGated = useIsViewportGated(documentId)
const viewportGap = viewport?.getViewportGap() ?? 0
return (
<ViewportElementContext.Provider value={viewportRef}>
<PDFViewerScrollArea
className={className}
viewportClassName="relative select-none selection:bg-transparent selection:text-inherit"
viewportProps={{
style: {
padding: viewportGap,
},
}}
viewportRef={viewportRef}
>
{isGated ? null : children}
</PDFViewerScrollArea>
</ViewportElementContext.Provider>
)
}
// Captures the scrollable viewport element so the imperative handle can expose
// it.
function PDFViewerViewportBridge({
viewportElementRef,
}: {
viewportElementRef: React.MutableRefObject<HTMLDivElement | null>
}) {
const elementRef = useViewportElement()
React.useEffect(() => {
viewportElementRef.current = elementRef?.current ?? null
})
return null
}
function PDFViewerTextSelectionLayer({
documentId,
pageIndex,
scale,
}: {
documentId: string
pageIndex: number
scale: number
}) {
const { plugin: selectionPlugin } = useSelectionPlugin()
const [rects, setRects] = React.useState<Rect[]>([])
React.useEffect(() => {
if (!selectionPlugin) return
return selectionPlugin.registerSelectionOnPage({
documentId,
pageIndex,
onRectsChange: ({ rects: nextRects }) => {
setRects(nextRects)
},
})
}, [documentId, pageIndex, selectionPlugin])
if (!rects.length) return null
return (
<>
{rects.map((rect, index) => (
<div
key={`${index}-${rect.origin.x}-${rect.origin.y}`}
className="pointer-events-none absolute"
style={{
background: TEXT_SELECTION_BACKGROUND,
height: rect.size.height * scale,
left: rect.origin.x * scale,
top: rect.origin.y * scale,
width: rect.size.width * scale,
}}
/>
))}
</>
)
}
function PDFViewerSelectionReleaseGuard({
documentId,
}: {
documentId: string
}) {
const { plugin: selectionPlugin } = useSelectionPlugin()
const { provides: selection } = useSelectionCapability()
const lastSelectionModeIdRef = React.useRef<string | null>(null)
React.useEffect(() => {
if (!selection) return
return selection.forDocument(documentId).onBeginSelection(({ modeId }) => {
lastSelectionModeIdRef.current = modeId
})
}, [documentId, selection])
React.useEffect(() => {
if (!selection) return
let cleanupFrame = 0
const finalizeIfStillSelecting = () => {
window.cancelAnimationFrame(cleanupFrame)
cleanupFrame = window.requestAnimationFrame(() => {
const selectionState = selection.getState(documentId)
if (!selectionState.selecting) return
if (selectionState.selection && selectionPlugin) {
const pluginWithEndSelection = selectionPlugin as unknown as {
endSelection?: (documentId: string, modeId: string) => void
}
pluginWithEndSelection.endSelection?.(
documentId,
lastSelectionModeIdRef.current ?? "pointerMode"
)
return
}
if (!selectionState.selection) {
selection.clear(documentId)
}
})
}
window.addEventListener("pointerup", finalizeIfStillSelecting)
window.addEventListener("pointercancel", finalizeIfStillSelecting)
window.addEventListener("blur", finalizeIfStillSelecting)
return () => {
window.cancelAnimationFrame(cleanupFrame)
window.removeEventListener("pointerup", finalizeIfStillSelecting)
window.removeEventListener("pointercancel", finalizeIfStillSelecting)
window.removeEventListener("blur", finalizeIfStillSelecting)
}
}, [documentId, selection, selectionPlugin])
return null
}
function isEditableCopyTarget(target: EventTarget | null) {
if (!(target instanceof HTMLElement)) return false
if (target.isContentEditable) return true
return Boolean(target.closest("input, textarea, [contenteditable='true']"))
}
function PDFViewerSelectionCopyShortcut({
documentId,
}: {
documentId: string
}) {
const { provides: selection } = useSelectionCapability()
React.useEffect(() => {
if (!selection) return
const copySelectedPdfText = (event: Event) => {
if (isEditableCopyTarget(event.target)) return
if (!selection.getState(documentId).selection) return
event.preventDefault()
selection.copyToClipboard(documentId)
}
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key.toLowerCase() !== "c") return
if (!event.metaKey && !event.ctrlKey) return
copySelectedPdfText(event)
}
document.addEventListener("copy", copySelectedPdfText)
document.addEventListener("keydown", handleKeyDown)
return () => {
document.removeEventListener("copy", copySelectedPdfText)
document.removeEventListener("keydown", handleKeyDown)
}
}, [documentId, selection])
return null
}
function isQuarterTurn(rotation: Rotation) {
return rotation % 2 === 1
}
function getRotatedDimensions({
height,
rotation,
width,
}: {
height: number
rotation: Rotation
width: number
}) {
return isQuarterTurn(rotation)
? { height: width, width: height }
: { height, width }
}
function getRotatedPageDimensions(page: PageLayout, rotation: Rotation) {
return getRotatedDimensions({
height: page.height,
rotation,
width: page.width,
})
}
function applyPageRotationDeltasToScrollerLayout({
basePageRotations,
layout,
pageRotationDeltas,
}: {
basePageRotations: Rotation[]
layout: ScrollerLayout
pageRotationDeltas: PageRotationDeltas
}): ScrollerLayout {
if (pageRotationDeltas.size === 0) return layout
let maxWidth = 0
let maxHeight = 0
let offset = 0
const pageGap = layout.pageGap
let startSpacingAdjustment = 0
const items: VirtualItem[] = layout.items.map((item, itemIndex) => {
let pageOffset = 0
let itemWidth = 0
let itemHeight = 0
const pageLayouts = item.pageLayouts.map((page) => {
const basePageRotation =
basePageRotations[page.pageIndex] ?? normalizeRotation(0)
const pageRotation = normalizeRotation(
basePageRotation + (pageRotationDeltas.get(page.pageIndex) ?? 0)
)
const rotatedSize = getRotatedPageDimensions(page, pageRotation)
const oldScrollAxisSize =
layout.strategy === ScrollStrategy.Horizontal
? page.rotatedWidth
: page.rotatedHeight
const newScrollAxisSize =
layout.strategy === ScrollStrategy.Horizontal
? rotatedSize.width
: rotatedSize.height
if (
layout.startSpacing === 0 &&
itemIndex === 0 &&
pageOffset === 0 &&
newScrollAxisSize < oldScrollAxisSize
) {
startSpacingAdjustment = Math.max(
startSpacingAdjustment,
(oldScrollAxisSize - newScrollAxisSize) / 2
)
}
const nextPageLayout = {
...page,
rotatedHeight: rotatedSize.height,
rotatedWidth: rotatedSize.width,
x: layout.strategy === ScrollStrategy.Horizontal ? 0 : pageOffset,
y: layout.strategy === ScrollStrategy.Horizontal ? pageOffset : 0,
}
pageOffset +=
(layout.strategy === ScrollStrategy.Horizontal
? rotatedSize.height
: rotatedSize.width) + pageGap
itemWidth =
layout.strategy === ScrollStrategy.Horizontal
? Math.max(itemWidth, rotatedSize.width)
: itemWidth + rotatedSize.width
itemHeight =
layout.strategy === ScrollStrategy.Horizontal
? itemHeight + rotatedSize.height
: Math.max(itemHeight, rotatedSize.height)
return nextPageLayout
})
if (pageLayouts.length > 1) {
if (layout.strategy === ScrollStrategy.Horizontal) {
itemHeight -= pageGap
} else {
itemWidth -= pageGap
}
}
const nextItem = {
...item,
height: itemHeight,
offset,
pageLayouts,
width: itemWidth,
x: layout.strategy === ScrollStrategy.Horizontal ? offset : item.x,
y: layout.strategy === ScrollStrategy.Horizontal ? item.y : offset,
}
if (layout.strategy === ScrollStrategy.Horizontal) {
offset += itemWidth + pageGap
maxHeight = Math.max(maxHeight, itemHeight)
} else {
offset += itemHeight + pageGap
maxWidth = Math.max(maxWidth, itemWidth)
}
return nextItem
})
if (items.length > 0) {
offset -= pageGap
}
return {
...layout,
endSpacing: layout.endSpacing,
items,
startSpacing: layout.startSpacing + startSpacingAdjustment,
totalHeight:
layout.strategy === ScrollStrategy.Horizontal
? maxHeight
: layout.startSpacing +
startSpacingAdjustment +
offset +
layout.endSpacing,
totalWidth:
layout.strategy === ScrollStrategy.Horizontal
? layout.startSpacing +
startSpacingAdjustment +
offset +
layout.endSpacing
: maxWidth,
}
}
function PDFViewerScroller({
documentId,
pageRotationDeltas,
basePageRotations,
renderPage,
}: {
documentId: string
pageRotationDeltas: PageRotationDeltas
basePageRotations: Rotation[]
renderPage: (props: PageLayout) => React.ReactNode
}) {
const { plugin: scrollPlugin } = useScrollPlugin()
const [layoutData, setLayoutData] = React.useState<{
docId: string | null
layout: ScrollerLayout | null
}>({ docId: null, layout: null })
React.useEffect(() => {
if (!scrollPlugin || !documentId) return
let frame = 0
const setCurrentLayout = () => {
try {
setLayoutData({
docId: documentId,
layout: scrollPlugin.getScrollerLayout(documentId),
})
} catch {
setLayoutData({ docId: documentId, layout: null })
}
}
const unsubscribe = scrollPlugin.onScrollerData(documentId, (layout) => {
setLayoutData({ docId: documentId, layout })
})
frame = window.requestAnimationFrame(setCurrentLayout)
return () => {
window.cancelAnimationFrame(frame)
unsubscribe()
setLayoutData({ docId: null, layout: null })
scrollPlugin.clearLayoutReady(documentId)
}
}, [documentId, scrollPlugin])
const scrollerLayout = React.useMemo(() => {
if (layoutData.docId !== documentId || !layoutData.layout) return null
return applyPageRotationDeltasToScrollerLayout({
basePageRotations,
layout: layoutData.layout,
pageRotationDeltas,
})
}, [basePageRotations, documentId, layoutData, pageRotationDeltas])
React.useLayoutEffect(() => {
if (!scrollPlugin || !documentId || !scrollerLayout) return
scrollPlugin.setLayoutReady(documentId)
}, [documentId, scrollPlugin, scrollerLayout])
if (!scrollerLayout) return null
return (
<div
style={{
width: `${scrollerLayout.totalWidth}px`,
height: `${scrollerLayout.totalHeight}px`,
position: "relative",
boxSizing: "border-box",
margin: "0 auto",
...(scrollerLayout.strategy === ScrollStrategy.Horizontal && {
display: "flex",
flexDirection: "row",
}),
}}
>
<div
style={
scrollerLayout.strategy === ScrollStrategy.Horizontal
? {
width: scrollerLayout.startSpacing,
height: "100%",
flexShrink: 0,
}
: {
height: scrollerLayout.startSpacing,
width: "100%",
}
}
/>
<div
style={{
gap: scrollerLayout.pageGap,
display: "flex",
alignItems: "center",
position: "relative",
boxSizing: "border-box",
...(scrollerLayout.strategy === ScrollStrategy.Horizontal
? {
flexDirection: "row",
minHeight: "100%",
}
: {
flexDirection: "column",
minWidth: "fit-content",
}),
}}
>
{scrollerLayout.items.map((item) => (
<div
key={item.pageNumbers[0]}
style={{
display: "flex",
justifyContent: "center",
gap: scrollerLayout.pageGap,
}}
>
{item.pageLayouts.map((layout) => (
<div
key={layout.pageNumber}
style={{
width: `${layout.rotatedWidth}px`,
height: `${layout.rotatedHeight}px`,
position: "relative",
zIndex: layout.elevated ? 1 : undefined,
}}
>
{renderPage(layout)}
</div>
))}
</div>
))}
</div>
<div
style={
scrollerLayout.strategy === ScrollStrategy.Horizontal
? {
width: scrollerLayout.endSpacing,
height: "100%",
flexShrink: 0,
}
: {
height: scrollerLayout.endSpacing,
width: "100%",
}
}
/>
</div>
)
}
type PDFViewerInnerProps = {
viewerRef: React.ForwardedRef<PDFViewerHandle>
pdfFile: string
documentId: string
document: PdfDocumentObject | null
defaultZoom: PDFViewerZoomLevel
className?: string
fileName?: string
showDownload: boolean
showToolbar: boolean
showRotateControls: boolean
showUpload: boolean
toolbarActions?: React.ReactNode
pageClassName?: (pageNumber: number) => string | undefined
renderPageOverlay?: (props: PDFViewerPageOverlayProps) => React.ReactNode
onActivePageChange?: (pageNumber: number) => void
onPdfUpload?: (file: File) => void
onPagePointerDown?: PDFViewerProps["onPagePointerDown"]
onPagePointerMove?: PDFViewerProps["onPagePointerMove"]
onPagePointerUp?: PDFViewerProps["onPagePointerUp"]
onPagePointerCancel?: PDFViewerProps["onPagePointerCancel"]
onUploadFile: (file: File) => void
}
function PDFViewerInner({
viewerRef,
pdfFile,
documentId,
document: pdfDocument,
defaultZoom,
className,
fileName,
showDownload,
showToolbar,
showRotateControls,
showUpload,
toolbarActions,
pageClassName,
renderPageOverlay,
onActivePageChange,
onPdfUpload,
onPagePointerDown,
onPagePointerMove,
onPagePointerUp,
onPagePointerCancel,
onUploadFile,
}: PDFViewerInnerProps) {
const { registry } = useRegistry()
const { state: scrollState, provides: scroll } = useScroll(documentId)
const { state: zoomState, provides: zoom } = useZoom(documentId)
const { provides: thumbnails } = useThumbnailCapability()
const { plugin: thumbnailPlugin } = useThumbnailPlugin()
const [sidebarOpen, setSidebarOpen] = React.useState(false)
const [isPreparingDownload, setIsPreparingDownload] = React.useState(false)
const [pageRotationDeltas, setPageRotationDeltas] =
React.useState<PageRotationDeltas>(() => new Map())
const [selectedPageIndexes, setSelectedPageIndexes] = React.useState<
Set<number>
>(() => new Set())
const basePageRotations = React.useMemo(
() =>
pdfDocument?.pages.map((page) => normalizeRotation(page.rotation)) ?? [],
[pdfDocument]
)
const [viewerShellRef, viewerShellWidth] = useElementWidth<HTMLDivElement>()
const sidebarInline = useInlineThumbnailSidebar(viewerShellWidth)
const viewportElementRef = React.useRef<HTMLDivElement | null>(null)
const pageRotationDeltasRef = React.useRef(pageRotationDeltas)
const selectedPageIndexesRef = React.useRef(selectedPageIndexes)
const selectionAnchorPageIndexRef = React.useRef<number | null>(null)
const suppressActivePageSelectionSyncRef = React.useRef<number | null>(null)
const initializedSelectionDocumentRef = React.useRef<string | null>(null)
const activePage = scrollState.currentPage
const numPages = pdfDocument?.pageCount ?? 0
const isLoading = !pdfDocument
const controlsDisabled = !numPages
const downloadDisabled = controlsDisabled || isPreparingDownload
const thumbnailSidebarVisible = sidebarOpen && !isLoading
const currentZoomLevel = zoomState.currentZoomLevel
const alignedThumbnailSidebarDocumentRef = React.useRef<string | null>(null)
React.useEffect(() => {
pageRotationDeltasRef.current = pageRotationDeltas
}, [pageRotationDeltas])
React.useEffect(() => {
selectedPageIndexesRef.current = selectedPageIndexes
}, [selectedPageIndexes])
React.useEffect(() => {
if (activePage > 0) onActivePageChange?.(activePage)
}, [activePage, onActivePageChange])
React.useEffect(() => {
if (activePage < 1 || numPages < 1) return
const activePageIndex = activePage - 1
const suppressedPageIndex = suppressActivePageSelectionSyncRef.current
suppressActivePageSelectionSyncRef.current = null
if (suppressedPageIndex === activePageIndex) return
const nextSelection = new Set([activePageIndex])
selectionAnchorPageIndexRef.current = activePageIndex
selectedPageIndexesRef.current = nextSelection
setSelectedPageIndexes((previousSelection) =>
arePageIndexSetsEqual(previousSelection, nextSelection)
? previousSelection
: nextSelection
)
}, [activePage, numPages])
React.useEffect(() => {
if (
numPages < 1 ||
initializedSelectionDocumentRef.current === documentId
) {
return
}
const initialPageIndex = Math.max(0, (activePage > 0 ? activePage : 1) - 1)
const initialSelection = new Set([initialPageIndex])
initializedSelectionDocumentRef.current = documentId
selectionAnchorPageIndexRef.current = initialPageIndex
selectedPageIndexesRef.current = initialSelection
setSelectedPageIndexes(initialSelection)
}, [activePage, documentId, numPages])
React.useEffect(() => {
if (!thumbnailSidebarVisible) {
alignedThumbnailSidebarDocumentRef.current = null
return
}
if (
activePage < 1 ||
!thumbnails ||
alignedThumbnailSidebarDocumentRef.current === documentId
) {
return
}
alignedThumbnailSidebarDocumentRef.current = documentId
const frame = window.requestAnimationFrame(() => {
thumbnails.forDocument(documentId).scrollToThumb(activePage - 1)
})
return () => window.cancelAnimationFrame(frame)
}, [activePage, documentId, thumbnailSidebarVisible, thumbnails])
// The zoom plugin only releases its viewport gate for mode-based zoom
// levels (automatic/fit); with a numeric default the gate would never
// lift, so apply the initial zoom explicitly once the document loads.
const initialZoomDocumentRef = React.useRef<string | null>(null)
React.useEffect(() => {
if (!pdfDocument || !zoom) return
if (initialZoomDocumentRef.current === documentId) return
initialZoomDocumentRef.current = documentId
zoom.requestZoom(toZoomLevel(defaultZoom))
}, [defaultZoom, documentId, pdfDocument, zoom])
const scrollToPage = React.useCallback(
(pageNumber: number, options?: ScrollIntoViewOptions) => {
scroll?.scrollToPage({
pageNumber,
behavior: options?.behavior === "smooth" ? "smooth" : "auto",
})
},
[scroll]
)
const selectThumbnailPage = React.useCallback(
(pageNumber: number, mode: ThumbnailSelectionMode) => {
const pageIndex = pageNumber - 1
if (pageIndex < 0 || pageIndex >= numPages) return
suppressActivePageSelectionSyncRef.current = pageIndex
setSelectedPageIndexes((previousSelection) => {
let nextSelection: Set<number>
if (mode === "range") {
const anchorPageIndex =
selectionAnchorPageIndexRef.current ??
(activePage > 0 ? activePage - 1 : pageIndex)
nextSelection = getPageIndexRange(anchorPageIndex, pageIndex)
} else if (mode === "toggle") {
nextSelection = new Set(previousSelection)
if (nextSelection.has(pageIndex)) {
nextSelection.delete(pageIndex)
} else {
nextSelection.add(pageIndex)
}
selectionAnchorPageIndexRef.current = pageIndex
} else {
nextSelection = new Set([pageIndex])
selectionAnchorPageIndexRef.current = pageIndex
}
selectedPageIndexesRef.current = nextSelection
return nextSelection
})
scrollToPage(pageNumber)
},
[activePage, numPages, scrollToPage]
)
React.useImperativeHandle(
viewerRef,
() => ({
scrollToPage,
scrollToPageArea: (pageNumber, area, options) => {
const pageSize = pdfDocument?.pages[pageNumber - 1]?.size
scroll?.scrollToPage({
pageNumber,
...(pageSize
? {
pageCoordinates: {
x: ((area.left ?? 0) / 100) * pageSize.width,
y: (area.top / 100) * pageSize.height,
},
alignY: 25,
}
: {}),
behavior: options?.behavior === "smooth" ? "smooth" : "auto",
})
},
getViewportElement: () => viewportElementRef.current,
}),
[pdfDocument, scroll, scrollToPage]
)
const handleDownload = React.useCallback(async () => {
if (!pdfFile || isPreparingDownload) return
setIsPreparingDownload(true)
try {
await downloadPdfWithPageRotations({
fileName: getPdfDownloadFileName(fileName, pdfFile),
pageRotationDeltas,
src: pdfFile,
})
} catch (error) {
console.error(error)
} finally {
setIsPreparingDownload(false)
}
}, [fileName, isPreparingDownload, pageRotationDeltas, pdfFile])
const rotateSelectedPages = React.useCallback(
(direction: -1 | 1) => {
if (!pdfDocument || !registry || activePage < 1) return
const documentState = registry.getStore().getState().core.documents[
documentId
]
const currentDocument = documentState?.document ?? pdfDocument
const selectedTargetPageIndexes = Array.from(
selectedPageIndexesRef.current
).filter((pageIndex) => currentDocument.pages[pageIndex])
const fallbackPageIndex = activePage - 1
const targetPageIndexes = (
selectedTargetPageIndexes.length
? selectedTargetPageIndexes
: [fallbackPageIndex]
)
.filter((pageIndex) => currentDocument.pages[pageIndex])
.sort((a, b) => a - b)
if (targetPageIndexes.length === 0) return
const previousDeltas = pageRotationDeltasRef.current
const nextDeltas = new Map(previousDeltas)
const referencePageIndex =
activePage > 0 && currentDocument.pages[activePage - 1]
? activePage - 1
: targetPageIndexes[0]
let scrollDelta = 0
for (const pageIndex of targetPageIndexes) {
const currentPage = currentDocument.pages[pageIndex]
if (!currentPage) continue
const previousDelta = previousDeltas.get(pageIndex) ?? 0
const nextDelta = normalizeRotation(previousDelta + direction)
const basePageRotation =
basePageRotations[pageIndex] ??
normalizeRotation(currentPage.rotation)
const previousPageRotation = normalizeRotation(
basePageRotation + previousDelta
)
const nextPageRotation = normalizeRotation(basePageRotation + nextDelta)
const previousRotatedSize = getRotatedDimensions({
height: currentPage.size.height * currentZoomLevel,
rotation: previousPageRotation,
width: currentPage.size.width * currentZoomLevel,
})
const nextRotatedSize = getRotatedDimensions({
height: currentPage.size.height * currentZoomLevel,
rotation: nextPageRotation,
width: currentPage.size.width * currentZoomLevel,
})
const heightDelta = nextRotatedSize.height - previousRotatedSize.height
if (pageIndex < referencePageIndex) {
scrollDelta += heightDelta
} else if (pageIndex === referencePageIndex) {
scrollDelta += heightDelta / 2
}
if (nextDelta) {
nextDeltas.set(pageIndex, nextDelta)
} else {
nextDeltas.delete(pageIndex)
}
}
const store = registry.getStore()
const viewport = viewportElementRef.current
pageRotationDeltasRef.current = nextDeltas
flushSync(() => {
setPageRotationDeltas(nextDeltas)
store.dispatchToCore(refreshPages(documentId, targetPageIndexes))
})
if (viewport && scrollDelta !== 0) {
viewport.scrollTop += scrollDelta
}
;(
thumbnailPlugin as {
calculateWindowState?: (documentId: string) => void
} | null
)?.calculateWindowState?.(documentId)
},
[
activePage,
basePageRotations,
currentZoomLevel,
documentId,
pdfDocument,
registry,
thumbnailPlugin,
]
)
const handleUpload = React.useCallback(
(file: File) => {
onUploadFile(file)
onPdfUpload?.(file)
},
[onPdfUpload, onUploadFile]
)
const renderPage = React.useCallback(
(page: PageLayout) => {
const pageNumber = page.pageNumber
const basePageRotation =
basePageRotations[page.pageIndex] ??
pdfDocument?.pages[page.pageIndex]?.rotation ??
normalizeRotation(0)
const pageRotation = normalizeRotation(
basePageRotation + (pageRotationDeltas.get(page.pageIndex) ?? 0)
)
return (
<Rotate
documentId={documentId}
pageIndex={page.pageIndex}
rotation={pageRotation}
>
<PagePointerProvider
documentId={documentId}
pageIndex={page.pageIndex}
rotation={pageRotation}
key={`${page.pageIndex}-${pageRotation}`}
data-pdf-viewer-page={pageNumber}
className={cn(
"relative border border-transparent bg-transparent shadow-xs select-none selection:bg-transparent selection:text-inherit",
pageClassName?.(pageNumber)
)}
style={{ backgroundColor: "transparent" }}
onPointerDown={(event: React.PointerEvent<HTMLDivElement>) =>
onPagePointerDown?.(event, pageNumber)
}
onPointerMove={(event: React.PointerEvent<HTMLDivElement>) =>
onPagePointerMove?.(event, pageNumber)
}
onPointerUp={(event: React.PointerEvent<HTMLDivElement>) =>
onPagePointerUp?.(event, pageNumber)
}
onPointerCancel={(event: React.PointerEvent<HTMLDivElement>) =>
onPagePointerCancel?.(event, pageNumber)
}
>
<div
aria-hidden="true"
className="pointer-events-none absolute inset-0 bg-white"
/>
<RenderLayer
documentId={documentId}
pageIndex={page.pageIndex}
scale={Math.min(currentZoomLevel, PAGE_BASE_RENDER_MAX_SCALE)}
dpr={PAGE_BASE_RENDER_DPR}
className="pointer-events-none absolute inset-0 h-full w-full object-fill opacity-100 blur-[0.35px] transition-none"
/>
<TilingLayer
documentId={documentId}
pageIndex={page.pageIndex}
key={`tiles-${page.pageIndex}-${pageRotation}`}
className="pointer-events-none opacity-100 transition-none [&_img]:opacity-100 [&_img]:transition-none"
/>
<SearchLayer
documentId={documentId}
pageIndex={page.pageIndex}
className="pointer-events-none"
highlightColor="rgba(253, 224, 71, 0.45)"
activeHighlightColor="rgba(249, 115, 22, 0.55)"
/>
<PDFViewerTextSelectionLayer
documentId={documentId}
pageIndex={page.pageIndex}
scale={currentZoomLevel}
/>
{renderPageOverlay?.({
pageNumber,
pageWidth: page.width,
pageHeight: page.height,
scale: currentZoomLevel,
rotation: rotationToDegrees(pageRotation),
})}
</PagePointerProvider>
</Rotate>
)
},
[
basePageRotations,
currentZoomLevel,
onPagePointerCancel,
onPagePointerDown,
onPagePointerMove,
onPagePointerUp,
pageClassName,
pageRotationDeltas,
renderPageOverlay,
documentId,
pdfDocument,
]
)
return (
<div
data-slot="pdf-viewer"
className={cn(
"flex h-full max-h-full min-h-0 w-full flex-col overflow-hidden bg-background",
className
)}
>
{showToolbar ? (
<PDFViewerToolbar
activePage={activePage}
controlsDisabled={controlsDisabled}
currentZoomLevel={currentZoomLevel}
downloadDisabled={downloadDisabled}
isPreparingDownload={isPreparingDownload}
numPages={numPages}
searchControl={
<PDFViewerSearchControl
key={documentId}
documentId={documentId}
controlsDisabled={controlsDisabled}
/>
}
sidebarOpen={sidebarOpen}
showDownload={showDownload}
showRotateControls={showRotateControls}
showUpload={showUpload}
toolbarActions={toolbarActions}
onDownload={handleDownload}
onPageChange={scrollToPage}
onRotate={rotateSelectedPages}
onToggleSidebar={() => setSidebarOpen((open) => !open)}
onUploadFile={handleUpload}
onZoomChange={(zoomLevel) => zoom?.requestZoom(zoomLevel)}
zoomLevel={zoomState.zoomLevel}
/>
) : null}
<div
ref={viewerShellRef}
className="relative flex min-h-0 flex-1 overflow-hidden bg-muted/30"
>
{isLoading ? (
<PDFViewerLoadingSkeleton
sidebarInline={sidebarInline}
sidebarOpen={sidebarOpen}
/>
) : null}
<div className="flex h-full max-h-full min-h-0 w-full flex-1 overflow-hidden">
<DocumentViewerThumbnailSidebar
closedInlineClassName={THUMBNAIL_SIDEBAR_CLOSED_CLASS}
inline={sidebarInline}
open={thumbnailSidebarVisible}
widthClassName={THUMBNAIL_SIDEBAR_WIDTH_CLASS}
>
{thumbnailSidebarVisible ? (
<PDFViewerThumbnails
basePageRotations={basePageRotations}
documentId={documentId}
activePage={activePage}
pageCount={numPages}
pageRotationDeltas={pageRotationDeltas}
pdfDocument={pdfDocument}
selectedPageIndexes={selectedPageIndexes}
onSelectPage={selectThumbnailPage}
/>
) : null}
</DocumentViewerThumbnailSidebar>
<PDFViewerScrollAreaViewport
documentId={documentId}
className="relative h-full max-h-full min-h-0 min-w-0 flex-1"
>
<PDFViewerViewportBridge viewportElementRef={viewportElementRef} />
<PDFViewerSelectionCopyShortcut documentId={documentId} />
<PDFViewerSelectionReleaseGuard documentId={documentId} />
<GlobalPointerProvider documentId={documentId}>
<PDFViewerScroller
basePageRotations={basePageRotations}
documentId={documentId}
pageRotationDeltas={pageRotationDeltas}
renderPage={renderPage}
/>
</GlobalPointerProvider>
<CopyToClipboard />
</PDFViewerScrollAreaViewport>
</div>
</div>
</div>
)
}
function PDFViewerDocumentLoader({
pdfFile,
onDocumentLoadSuccess,
...innerProps
}: {
pdfFile: string
onDocumentLoadSuccess?: (numPages: number) => void
} & Omit<PDFViewerInnerProps, "pdfFile" | "documentId" | "document">) {
const { provides: documentManager } = useDocumentManagerCapability()
const { activeDocumentId, activeDocument } = useActiveDocument()
const [loadError, setLoadError] = React.useState(false)
const openedFileRef = React.useRef<string | null>(null)
const onDocumentLoadSuccessRef = React.useRef(onDocumentLoadSuccess)
React.useEffect(() => {
onDocumentLoadSuccessRef.current = onDocumentLoadSuccess
})
React.useEffect(() => {
if (!documentManager || !pdfFile) return
if (openedFileRef.current === pdfFile) return
openedFileRef.current = pdfFile
setLoadError(false)
const previousDocumentIds = documentManager
.getOpenDocuments()
.map((openDocument) => openDocument.id)
const handleOpenError = () => {
if (openedFileRef.current === pdfFile) setLoadError(true)
}
documentManager
.openDocumentUrl({
url: pdfFile,
mode: pdfFile.startsWith("blob:") ? "full-fetch" : "auto",
})
.wait((response) => {
response.task.wait((openedDocument) => {
onDocumentLoadSuccessRef.current?.(openedDocument.pageCount)
previousDocumentIds.forEach((documentIdToClose) => {
documentManager.closeDocument(documentIdToClose).wait(
() => undefined,
() => undefined
)
})
}, handleOpenError)
}, handleOpenError)
}, [documentManager, pdfFile])
const document =
activeDocument?.status === "loaded" ? activeDocument.document : null
const documentFailed = loadError || activeDocument?.status === "error"
if (!activeDocumentId || documentFailed || !pdfFile) {
return (
<PDFViewerFallbackShell
className={innerProps.className}
defaultZoom={innerProps.defaultZoom}
showDownload={innerProps.showDownload}
showRotateControls={innerProps.showRotateControls}
showToolbar={innerProps.showToolbar}
showUpload={innerProps.showUpload}
sidebarOpen={false}
state={!pdfFile ? "empty" : documentFailed ? "error" : "loading"}
toolbarActions={innerProps.toolbarActions}
onUploadFile={(file) => {
innerProps.onUploadFile(file)
innerProps.onPdfUpload?.(file)
}}
/>
)
}
return (
<PDFViewerInner
key={activeDocumentId}
{...innerProps}
pdfFile={pdfFile}
documentId={activeDocumentId}
document={document}
/>
)
}
export const PDFViewer = React.forwardRef<PDFViewerHandle, PDFViewerProps>(
function PDFViewer(
{
className,
defaultZoom = DEFAULT_ZOOM,
fileName,
resolveScrollAreaViewport,
showDownload = true,
showRotateControls = true,
showToolbar = true,
showUpload = true,
src,
toolbarActions,
pageClassName,
renderPageOverlay,
onActivePageChange,
onDocumentLoadSuccess,
onPdfUpload,
onPagePointerDown,
onPagePointerMove,
onPagePointerUp,
onPagePointerCancel,
},
ref
) {
const { engine, error: engineError } = useSharedPdfEngine()
const [uploadedPdfFile, setUploadedPdfFile] = React.useState<{
src: string | undefined
url: string | null
}>(() => ({ src, url: null }))
const uploadedPdfUrl =
uploadedPdfFile.src === src ? uploadedPdfFile.url : null
const pdfFile = uploadedPdfUrl ?? src ?? ""
React.useEffect(
() => () => {
if (uploadedPdfUrl) URL.revokeObjectURL(uploadedPdfUrl)
},
[uploadedPdfUrl]
)
const handleUploadFile = React.useCallback(
(nextFile: File) => {
const nextUrl = URL.createObjectURL(nextFile)
setUploadedPdfFile({ src, url: nextUrl })
},
[src]
)
// Plugin registrations are created once per viewer instance.
const [plugins] = React.useState(() => [
createPluginRegistration(DocumentManagerPluginPackage),
createPluginRegistration(ViewportPluginPackage, {
viewportGap: PAGE_GAP,
}),
createPluginRegistration(ScrollPluginPackage, {
defaultPageGap: PAGE_GAP,
defaultBufferSize: 2,
}),
createPluginRegistration(RenderPluginPackage),
createPluginRegistration(TilingPluginPackage, {
tileSize: 768,
overlapPx: 2.5,
extraRings: 0,
}),
createPluginRegistration(InteractionManagerPluginPackage),
createPluginRegistration(SelectionPluginPackage, {
marquee: { enabled: false },
}),
createPluginRegistration(SearchPluginPackage, {
showAllResults: true,
}),
createPluginRegistration(ThumbnailPluginPackage, {
width: THUMBNAIL_WIDTH,
gap: THUMBNAIL_GAP,
imagePadding: THUMBNAIL_IMAGE_PADDING,
labelHeight: THUMBNAIL_LABEL_HEIGHT,
paddingY: THUMBNAIL_PANE_PADDING_Y,
buffer: 3,
autoScroll: true,
scrollBehavior: "auto",
}),
createPluginRegistration(ZoomPluginPackage, {
defaultZoomLevel: toZoomLevel(defaultZoom),
minZoom: ZOOM_OPTIONS[0],
maxZoom: ZOOM_OPTIONS[ZOOM_OPTIONS.length - 1],
}),
createPluginRegistration(RotatePluginPackage),
])
if (engineError) {
return (
<PDFViewerFallbackShell
className={className}
defaultZoom={defaultZoom}
errorMessage="Unable to load the PDF engine."
showDownload={showDownload}
showRotateControls={showRotateControls}
showToolbar={showToolbar}
showUpload={showUpload}
sidebarOpen={false}
state="error"
toolbarActions={toolbarActions}
onUploadFile={(file) => {
handleUploadFile(file)
onPdfUpload?.(file)
}}
/>
)
}
if (!engine) {
return (
<PDFViewerFallbackShell
className={className}
defaultZoom={defaultZoom}
showDownload={showDownload}
showRotateControls={showRotateControls}
showToolbar={showToolbar}
showUpload={showUpload}
sidebarOpen={false}
state="loading"
toolbarActions={toolbarActions}
onUploadFile={(file) => {
handleUploadFile(file)
onPdfUpload?.(file)
}}
/>
)
}
return (
<PDFViewerScrollAreaResolverContext.Provider
value={resolveScrollAreaViewport ?? resolveDefaultScrollAreaViewport}
>
<EmbedPDF engine={engine} plugins={plugins}>
<PDFViewerDocumentLoader
viewerRef={ref}
pdfFile={pdfFile}
defaultZoom={defaultZoom}
className={className}
fileName={fileName}
showDownload={showDownload}
showToolbar={showToolbar}
showRotateControls={showRotateControls}
showUpload={showUpload}
toolbarActions={toolbarActions}
pageClassName={pageClassName}
renderPageOverlay={renderPageOverlay}
onActivePageChange={onActivePageChange}
onDocumentLoadSuccess={onDocumentLoadSuccess}
onPdfUpload={onPdfUpload}
onPagePointerDown={onPagePointerDown}
onPagePointerMove={onPagePointerMove}
onPagePointerUp={onPagePointerUp}
onPagePointerCancel={onPagePointerCancel}
onUploadFile={handleUploadFile}
/>
</EmbedPDF>
</PDFViewerScrollAreaResolverContext.Provider>
)
}
)
function InlineSpinner({ className, ...props }: InlineRegistryIconProps) {
return (
<IconPlaceholder
lucide="LoaderCircle"
tabler="IconLoader2"
hugeicons="Loading03Icon"
phosphor="CircleNotchIcon"
remixicon="RiLoader4Line"
role="status"
aria-label="Loading"
className={cn("size-4 animate-spin", className)}
{...props}
/>
)
}
type InlineRegistryIconProps = Omit<
React.ComponentProps<"svg">,
"children" | "strokeWidth"
> & { strokeWidth?: number }
lib/pdf-thumbnail-utils.ts
import type { PdfDocumentObject, PdfEngine } from "@embedpdf/models"
const PDFIUM_VERSION = "2.15.0"
const PDFIUM_WASM_URL = `https://cdn.jsdelivr.net/npm/@embedpdf/pdfium@${PDFIUM_VERSION}/dist/pdfium.wasm`
let sharedEnginePromise: Promise<PdfEngine> | null = null
const pdfDocumentCache = new Map<string, Promise<PdfDocumentObject>>()
const thumbnailUrlCache = new Map<string, Promise<string | null>>()
export function loadSharedPdfEngine() {
sharedEnginePromise ??= import("@embedpdf/engines/pdfium-worker-engine").then(
({ createPdfiumEngine }) => createPdfiumEngine(PDFIUM_WASM_URL, {})
)
return sharedEnginePromise
}
export async function loadPdfDocument(url: string) {
let documentPromise = pdfDocumentCache.get(url)
if (!documentPromise) {
documentPromise = loadSharedPdfEngine().then((engine) =>
engine
.openDocumentUrl(
{ id: url, url },
{ mode: url.startsWith("blob:") ? "full-fetch" : "auto" }
)
.toPromise()
)
pdfDocumentCache.set(url, documentPromise)
}
return documentPromise
}
export async function getPdfPageCount(url: string) {
return (await loadPdfDocument(url)).pageCount
}
export function renderPdfThumbnailUrl({
dpr = typeof window === "undefined" ? 1 : window.devicePixelRatio || 1,
pageIndex,
url,
width,
}: {
dpr?: number
pageIndex: number
url: string
width: number
}) {
const cacheKey = `${url}#${pageIndex}@${width}x${dpr}`
let thumbnailPromise = thumbnailUrlCache.get(cacheKey)
if (!thumbnailPromise) {
thumbnailPromise = (async () => {
const [engine, document] = await Promise.all([
loadSharedPdfEngine(),
loadPdfDocument(url),
])
const page = document.pages[pageIndex]
if (!page) return null
const blob = await engine
.renderThumbnail(document, page, {
dpr,
imageType: "image/png",
scaleFactor: width / page.size.width,
withAnnotations: true,
})
.toPromise()
return URL.createObjectURL(blob)
})()
thumbnailUrlCache.set(cacheKey, thumbnailPromise)
}
return thumbnailPromise
}