第十七章 PDF预览交互

274 阅读2分钟

参考官方的预览页: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