参考官方的预览页:projects.wojtekmaj.pl/react-pdf/t…
一、安装依赖
# pdf预览核心
https://www.npmjs.com/package/react-pdf
npm install react-pdf
# 浏览器使用ResizeObservers原生支持元素调整大小处理。该库使用这些观察器来帮助您处理 React 中的元素大小调整。
https://www.npmjs.com/package/react-resize-detector
npm i react-resize-detector
# 用自定义 CSS 样式的滚动条替换浏览器的默认滚动条,而不会损失性能
https://github.com/grsmto/simplebar
npm i simplebar-react
二、配置参考
具体配置
webpack: (config) => {
config.resolve.alias.canvas = false
return config
},
使用自定义滚动前提,在src/app/layout.tsx导入样式
import 'simplebar-react/dist/simplebar.min.css'
三、预览组件
完成预览组件,最简单使用如下
// src/components/PdfRenderer.tsx
'use client'
import { Loader2 } from 'lucide-react'
import { Document, Page, pdfjs } from 'react-pdf'
import 'react-pdf/dist/Page/AnnotationLayer.css'
import 'react-pdf/dist/Page/TextLayer.css'
import { useResizeDetector } from 'react-resize-detector'
import SimpleBar from 'simplebar-react'
import { useToast } from '../ui/use-toast'
pdfjs.GlobalWorkerOptions.workerSrc = `//cdnjs.cloudflare.com/ajax/libs/pdf.js/${pdfjs.version}/pdf.worker.js`
interface PdfRendererProps {
url: string
}
const PdfRenderer = ({ url }: PdfRendererProps) => {
const { toast } = useToast()
const { width, ref } = useResizeDetector()
return (
<div className="w-full bg-white rounded-md shadow flex flex-col items-center">
<div className="h-14 w-full border-b border-zinc-200 flex items-center justify-between px-2">
<div className="flex items-center gap-1.5">top bar</div>
</div>
<div className="flex-1 w-full max-h-screen">
<SimpleBar autoHide={false} className="max-h-[calc(100vh-10rem)]">
<div ref={ref}>
<Document
loading={
<div className="flex justify-center">
<Loader2 className="my-24 h-6 w-6 animate-spin" />
</div>
}
file={url}
className="max-h-full"
>
<Page width={width ? width : 1} pageNumber={1} />
</Document>
</div>
</SimpleBar>
</div>
</div>
)
}
export default PdfRenderer
四、交互组件
// src/components/PdfRenderer.tsx
'use client'
import {
ChevronDown,
ChevronUp,
FileX,
Loader2,
RotateCw,
Search,
} from 'lucide-react'
import { Document, Page, pdfjs } from 'react-pdf'
import { useToast } from '../ui/use-toast'
import { useResizeDetector } from 'react-resize-detector'
import SimpleBar from 'simplebar-react'
import { useCallback, useMemo, useState } from 'react'
import { Button } from '../ui/button'
import { useForm } from 'react-hook-form'
import { z } from 'zod'
import { zodResolver } from '@hookform/resolvers/zod'
import { Input } from '../ui/input'
import { cn } from '@/lib/utils'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '../ui/dropdown-menu'
import 'react-pdf/dist/Page/AnnotationLayer.css'
import 'react-pdf/dist/Page/TextLayer.css'
pdfjs.GlobalWorkerOptions.workerSrc = `//cdnjs.cloudflare.com/ajax/libs/pdf.js/${pdfjs.version}/pdf.worker.js`
interface PdfRendererProps {
url: string
}
const PdfRenderer = ({ url }: PdfRendererProps) => {
const { toast } = useToast()
const { width, ref } = useResizeDetector()
const [numPages, setNumPages] = useState<number>()
const [currPage, setCurrPage] = useState<number>(1)
const [scale, setScale] = useState<number>(1)
const [rotation, setRotation] = useState<number>(0)
const [renderedScale, setRenderedScale] = useState<number | null>(null)
const isLoading = renderedScale !== scale
const CustomPageValidator = useMemo(() => {
return z.object({
page: z
.string()
.refine((num) => Number(num) > 0 && Number(num) <= numPages!),
})
}, [numPages])
type TCustomPageValidator = z.infer<typeof CustomPageValidator>
const {
register,
handleSubmit,
formState: { errors },
setValue,
} = useForm<TCustomPageValidator>({
defaultValues: {
page: '1',
},
resolver: zodResolver(CustomPageValidator),
})
const handlePageSubmit = useCallback(
({ page }: TCustomPageValidator) => {
setCurrPage(Number(page))
setValue('page', String(page))
},
[setCurrPage, setValue]
)
const inputClass = cn('w-12 h-8', errors.page && 'focus-visible:ring-red-500')
const onItemClick = useCallback(
(args: { pageNumber: number }) => {
const { pageNumber } = args
setCurrPage(pageNumber)
setValue('page', String(pageNumber))
},
[setValue]
)
const pageProps = {
width: width ? width : 1,
pageNumber: currPage,
scale: scale,
rotate: rotation,
}
return (
<div className="w-full bg-white rounded-md shadow flex flex-col items-center">
<div className="h-14 w-full border-b border-zinc-200 flex items-center justify-between px-2">
<div className="flex items-center gap-1.5">
<Button
disabled={currPage <= 1}
onClick={() => {
setCurrPage((prev) => (prev - 1 > 1 ? prev - 1 : 1))
setValue('page', String(currPage - 1))
}}
variant="ghost"
aria-label="previous page"
>
<ChevronDown className="h-4 w-4" />
</Button>
<div className="flex items-center gap-1.5">
<Input
{...register('page')}
className={inputClass}
onKeyDown={(e) => {
if (e.key === 'Enter') {
handleSubmit(handlePageSubmit)()
}
}}
/>
<p className="text-zinc-700 text-sm space-x-1">
<span>/</span>
<span>{numPages ?? 'x'}</span>
</p>
</div>
<Button
disabled={numPages === undefined || currPage === numPages}
onClick={() => {
setCurrPage((prev) =>
prev + 1 > numPages! ? numPages! : prev + 1
)
setValue('page', String(currPage + 1))
}}
variant="ghost"
aria-label="next page"
>
<ChevronUp className="h-4 w-4" />
</Button>
</div>
<div className="space-x-2">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button className="gap-1.5" aria-label="zoom" variant="ghost">
<Search className="h-4 w-4" />
{scale * 100}%
<ChevronDown className="h-3 w-3 opacity-50" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem onSelect={() => setScale(1)}>
100%
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => setScale(1.5)}>
150%
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => setScale(2)}>
200%
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => setScale(2.5)}>
250%
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Button
onClick={() =>
setRotation((prev) => (prev + 90 < 360 ? prev + 90 : 0))
}
variant="ghost"
aria-label="rotate 90 degrees"
>
<RotateCw className="h-4 w-4" />
</Button>
</div>
</div>
<div className="flex-1 w-full max-h-screen">
<SimpleBar autoHide={false} className="max-h-[calc(100vh-10rem)]">
<div ref={ref}>
<Document
loading={
<div className="flex justify-center">
<Loader2 className="my-24 h-6 w-6 animate-spin" />
</div>
}
error={
<div className="m-16 flex flex-col items-center gap-2">
<FileX className="h-8 w-8 text-zinc-800" />
<h3 className="font-semibold text-xl mt-2">PDF加载错误</h3>
<p>检查文件是否被删除,或者稍后重试。</p>
</div>
}
onLoadSuccess={({ numPages }) => setNumPages(numPages)}
file={url}
onItemClick={onItemClick}
className="max-h-full"
>
{isLoading && renderedScale ? (
<Page {...pageProps} key={'@' + renderedScale} />
) : null}
<Page
{...pageProps}
className={cn(isLoading ? 'hidden' : '')}
key={'@' + scale}
loading={
<div className="flex justify-center">
<Loader2 className="my-24 h-6 w-6 animate-spin" />
</div>
}
onRenderSuccess={() => setRenderedScale(scale)}
/>
</Document>
</div>
</SimpleBar>
</div>
</div>
)
}
export default PdfRenderer