跳到正文

React 接入

使用 React 搭建 PDF 查看器:先实现最简单的文档查看,再加入页面导航、缩略图、批注工具栏和侧边栏。

搭建最简单的查看器

在 effect 中创建 Core 实例并加载 PDF,组件卸载时销毁实例:

tsx
import { useEffect, useRef } from 'react'
import type { InkLayerInstance } from '@inklayer-dev/core'
import { createInkLayer } from '@inklayer-dev/core/capabilities'
import '@inklayer-dev/core/style'

export function PdfViewer({ source }: { source: string }) {
  const root = useRef<HTMLDivElement>(null)
  const pages = useRef<HTMLDivElement>(null)

  useEffect(() => {
    let core: InkLayerInstance | undefined

    async function open() {
      core = await createInkLayer({
        root: root.current!,
        pageFlow: { container: pages.current!, scale: 'page-width' }
      })

      await core.load({ url: source })
    }

    void open()
    return () => { void core?.destroy() }
  }, [source])

  return (
    <div ref={root} style={{ height: '100vh' }}>
      <div ref={pages} style={{ height: '100%', overflow: 'auto' }} />
    </div>
  )
}

在父组件中引入 PdfViewer,并传入 PDF 地址:

tsx
import { PdfViewer } from './PdfViewer'

export default function App() {
  return <PdfViewer source="/documents/review.pdf" />
}

添加页面导航和缩略图

通过 Page Flow 控制页面跳转和缩放。缩略图由 Core 渲染成 Blob,然后转换成侧边栏可以使用的图片地址:

ts
core.getPageFlow()?.scrollToPage(pageIndex)
await core.getPageFlow()?.zoomIn()

const thumbnail = await core.viewer.renderThumbnail({ pageIndex, maxWidth: 140 })
const thumbnailUrl = URL.createObjectURL(thumbnail.blob)

缩略图显示期间需要保留图片地址,移除图片后再调用 URL.revokeObjectURL(thumbnailUrl) 释放。

搭建批注工作区

下面是完整组件,包含页面导航、缩放、缩略图、矩形批注工具和批注列表。React 负责工具栏与侧边栏,Core 负责渲染页面容器中的 PDF:

tsx
import { useEffect, useRef, useState } from 'react'
import type { Annotation, InkLayerInstance } from '@inklayer-dev/core'
import { createInkLayer } from '@inklayer-dev/core/capabilities'
import '@inklayer-dev/core/style'
import './PdfWorkspace.css'

export function PdfWorkspace({ source }: { source: string }) {
  const root = useRef<HTMLDivElement>(null)
  const pages = useRef<HTMLDivElement>(null)
  const core = useRef<InkLayerInstance | undefined>(undefined)
  const [annotations, setAnnotations] = useState<Annotation[]>([])
  const [thumbnails, setThumbnails] = useState<Array<{ pageIndex: number; url: string }>>([])
  const [currentPage, setCurrentPage] = useState(0)
  const [pageCount, setPageCount] = useState(0)
  const [zoom, setZoom] = useState(100)

  useEffect(() => {
    let stop: (() => void) | undefined
    const thumbnailUrls: string[] = []

    async function open() {
      const instance = await createInkLayer({
        root: root.current!,
        pageFlow: {
          container: pages.current!,
          scale: 'page-width',
          onCurrentPageChanged: setCurrentPage,
          onScaleChanged: state => setZoom(state.percentage)
        }
      })

      core.current = instance
      const pdf = await instance.load({ url: source })
      setPageCount(pdf.numPages)

      stop = instance.annotations.repository.subscribe(() => {
        setAnnotations([...instance.annotations.repository.getAll()])
      })

      for (let pageIndex = 0; pageIndex < pdf.numPages; pageIndex++) {
        const thumbnail = await instance.viewer.renderThumbnail({ pageIndex, maxWidth: 140 })
        const url = URL.createObjectURL(thumbnail.blob)
        thumbnailUrls.push(url)
        setThumbnails(items => [...items, { pageIndex, url }])
      }
    }

    void open()

    return () => {
      stop?.()
      for (const url of thumbnailUrls) URL.revokeObjectURL(url)
      void core.current?.destroy()
      core.current = undefined
    }
  }, [source])

  function goTo(pageIndex: number) {
    if (pageIndex >= 0 && pageIndex < pageCount) {
      core.current?.getPageFlow()?.scrollToPage(pageIndex)
    }
  }

  function selectAnnotation(annotation: Annotation) {
    core.current?.annotations.setSelection({ ids: [annotation.id] })
    goTo(annotation.pageIndex)
  }

  return (
    <div ref={root} className="workspace">
      <header className="toolbar">
        <button onClick={() => goTo(currentPage - 1)}>上一页</button>
        <span>{currentPage + 1} / {pageCount}</span>
        <button onClick={() => goTo(currentPage + 1)}>下一页</button>
        <button onClick={() => core.current?.getPageFlow()?.zoomOut()}>−</button>
        <span>{zoom}%</span>
        <button onClick={() => core.current?.getPageFlow()?.zoomIn()}>+</button>
        <button onClick={() => core.current?.annotations.setTool('select')}>选择</button>
        <button onClick={() => core.current?.annotations.setTool('rectangle')}>矩形</button>
      </header>

      <aside className="sidebar">
        {thumbnails.map(thumbnail => (
          <button key={thumbnail.pageIndex} onClick={() => goTo(thumbnail.pageIndex)}>
            <img src={thumbnail.url} alt={`第 ${thumbnail.pageIndex + 1} 页`} />
          </button>
        ))}
      </aside>

      <div ref={pages} className="pages" />

      <aside className="sidebar">
        {annotations.map(annotation => (
          <button key={annotation.id} onClick={() => selectAnnotation(annotation)}>
            {annotation.type} · 第 {annotation.pageIndex + 1} 页
          </button>
        ))}
      </aside>
    </div>
  )
}
css
.workspace {
  display: grid;
  grid-template-columns: 160px minmax(0, 1fr) 220px;
  grid-template-rows: auto minmax(0, 1fr);
  height: 100vh;
}

.toolbar { grid-column: 1 / -1; }
.pages, .sidebar { overflow: auto; }
.sidebar img { max-width: 100%; }

在应用入口中引入并使用 PdfWorkspace

tsx
import { PdfWorkspace } from './PdfWorkspace'

export default function App() {
  return <PdfWorkspace source="/documents/review.pdf" />
}

文字高亮、作者权限和批注保存请分别参考创建第一个批注保存和恢复批注

基于 MIT License 发布