技术栈一览
类别 技术 用途
框架 Next.js 14 (App Router) 服务端组件、路由、i18n
UI 库 React 18 + TypeScript 5 组件、Hooks、严格类型
样式 Tailwind CSS 3 + shadcn/ui 工具栏 / 状态栏
渲染引擎 PDF.js(CDN 按需加载) 把 PDF 页面渲染到 Canvas
导出引擎 pdf-lib(按需 import(‘pdf-lib’)) 把标注写回 PDF 字节流
画布 原生 Canvas 2D 矢量标注绘制、离屏合成
Portal react-dom createPortal 文本输入浮层(fixed 定位)
图标 lucide-react 12 个工具 + 动作按钮
核心选型逻辑:PDF.js 是 Mozilla 维护的 PDF 渲染标准库(Chrome 自带的内置 PDF 阅读器底层就是它),没有比它更可靠的 PDF 解析方案。pdf-lib 负责「写」——能在不破坏原 PDF 结构的情况下追加图形元素。
一、整体架构:输入 → 渲染 → 编辑 → 导出
架构图:PDF.js 渲染 + Canvas 标注 + pdf-lib 导出
整个工具的数据流非常清晰,4 个阶段:
阶段 入口 关键对象 输出
输入 / 拖拽 File → ArrayBuffer pdfDocument(PDF.js 实例)
渲染 renderPage pdfDocument.getPage(n) 主 Canvas 像素
编辑 鼠标 / 触摸 / 键盘 EditElement[] 矢量元素列表 重绘到主 Canvas
导出 exportPDF pdf-lib PDFDocument 新 PDF Blob + 下载
最关键的抽象是 EditElement 类型——所有矢量标注(文字、矩形、圆、箭头等)都用同一个数据结构表示:
// types/edit-element.ts — 所有标注的统一类型
type ToolType =
| “select” | “text” | “rectangle” | “circle” | “arrow”
| “fill” | “highlight” | “underline” | “strikethrough”
| “pen” | “eraser” | “delete”
interface PathPoint { x: number; y: number }
interface EditElement {
id: string
type: ToolType
x: number // 画布坐标(scale 倍)
y: number
width?: number
height?: number
content?: string // text 类型用
color?: string // 描边 / 文字色
fillColor?: string // 填充色,‘transparent’ 表示无填充
fontSize?: number
lineWidth?: number
opacity?: number
pageIndex: number // 属于哪一页(0-based)
points?: PathPoint[] // 画笔 / 橡皮路径点
}
为什么所有标注统一一个类型:
撤销/重做只需保存 EditElement[] 快照;
导出只需 for 循环每个 element 调 pdf-lib 对应 API;
跨页支持用 pageIndex 字段——elements 是所有页的并集,渲染时按 el.pageIndex === currentPage - 1 过滤。
二、PDF.js 加载:按需 + 重试机制
PDF.js 不打包进首屏 bundle——体积太大(~500KB),用户没上传 PDF 前根本不需要。CDN 按需加载 + 重试:
// hooks/usePdfjsLoader.ts — 自定义 Hook
export function usePdfjsLoader() {
const [status, setStatus] = useState<‘idle’ | ‘loading’ | ‘ready’ | ‘error’>(‘idle’)
const [errorMessage, setErrorMessage] = useState<string | null>(null)
useEffect(() => {
if (typeof window === ‘undefined’) return
if ((window as any).pdfjsLib) { setStatus(‘ready’); return }
setStatus('loading') const script = document.createElement('script') script.src = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/4.0.379/pdf.min.mjs' script.type = 'module' script.onload = () => setStatus('ready') script.onerror = () => { setStatus('error') setErrorMessage('PDF.js 加载失败,请检查网络后重试') } document.head.appendChild(script)}, [])
const retry = useCallback(() => {
setStatus(‘idle’)
setErrorMessage(null)
// 重新触发 useEffect
}, [])
return { status, errorMessage, isReady: status === ‘ready’, retry }
}
为什么用 CDN 而不是 npm 包:PDF.js 的 worker 线程需要单独的 pdf.worker.js 文件,Next.js 打包会牵涉到 public 目录 + Webpack 配置,CDN 引入省心得多。代价是依赖第三方 CDN 的可用性——所以内置了重试机制。
加载完成后才能用:
const loadPDF = useCallback(async (file: File) => {
if (!isPdfJsLoaded) {
showNotice(t(‘notices.pdfLoading’))
return
}
try {
const pdfjsLib = (window as any).pdfjsLib
const arrayBuffer = await file.arrayBuffer()
const pdf = await pdfjsLib.getDocument({ data: arrayBuffer }).promise
setPdfDocument(pdf)
setTotalPages(pdf.numPages)
setCurrentPage(1)
setPdfFile(file)
setElements([]) // 重要:清空旧标注
setHistory([[]])
setHistoryIndex(0)
setSelectedElement(null)
} catch (error) {
console.error(‘Error loading PDF:’, error)
showNotice(t(‘notices.pdfLoadError’))
}
}, [isPdfJsLoaded, showNotice, t])
// 修复 Bug2:确保 PDF.js 加载完成后自动重新加载用户已选的文件
useEffect(() => {
if (pdfFile && isPdfJsLoaded && !pdfDocument) {
loadPDF(pdfFile)
}
}, [pdfFile, isPdfJsLoaded])
两个隐藏 bug:
pdfFile 状态先于 pdfDocument。用户选文件时 PDF.js 还没加载完,loadPDF 提前 return;等 PDF.js 加载完成后,必须自动重新触发 loadPDF(pdfFile)——这个 useEffect 就是修这个的。
新文件必须清空标注。setElements([]) + setHistory([[]]) + setHistoryIndex(0)——忘了一个,旧标注就会显示在新 PDF 上,非常尴尬。
PDF.js 加载流程
PDF.js 加载流程与重试机制示意
三、PDF 渲染:离屏 Canvas 消除闪烁
PDF 渲染 + 标注叠加是个频繁重绘的操作(拖一个矩形要 30+ FPS 实时显示预览)。直接在主 Canvas 上画会闪烁——PDF 页面渲染是异步的,在主 Canvas 上半帧 PDF + 半帧标注的状态肉眼可见。
解决方案:离屏 Canvas 合成:
// renderPage 核心逻辑
const renderPage = useCallback(
async (previewEl, hoveredDelId, hoveredFillId) => {
if (!pdfDocument || !canvasRef.current || !isPdfJsLoaded) return
try {
const page = await pdfDocument.getPage(currentPage)
const canvas = canvasRef.current
const viewport = page.getViewport({ scale })
// 关键:先渲染到离屏 Canvas const offscreen = document.createElement('canvas') offscreen.width = viewport.width offscreen.height = viewport.height const offCtx = offscreen.getContext('2d')! await page.render({ canvasContext: offCtx, viewport }).promise // 在离屏 Canvas 上叠加标注 const pageElements = elements.filter( (el) => el.pageIndex === currentPage - 1 ) const delId = hoveredDelId !== undefined ? hoveredDelId : hoveredForDelete const fillHId = hoveredFillId !== undefined ? hoveredFillId : hoveredForFill drawElements(offCtx, pageElements, previewEl, delId, fillHId) // 一次性 blit 到主 Canvas —— 用户看不到中间态 canvas.width = viewport.width canvas.height = viewport.height const ctx = canvas.getContext('2d')! ctx.drawImage(offscreen, 0, 0) } catch (err) { console.error('renderPage error', err) }},
[pdfDocument, currentPage, scale, elements, isPdfJsLoaded, drawElements, hoveredForDelete, hoveredForFill]
)
3 个性能优化点:
离屏 Canvas 一次性 blit。drawImage(offscreen, 0, 0) 是 GPU 加速的位图拷贝,比逐个图形重绘快 10 倍。
canvas.width = … 一次性设置。改 width / height 会重置整个 Canvas 上下文(fillStyle 之类都重置),所以这步必须在 getContext 之前。
previewEl 参数支持「绘制中预览」。拖矩形时,矩形还没松开(没进 elements),但要实时显示——previewEl 是个 Partial,drawElements 会把真实元素 + 预览元素都画上。
四、撤销 / 重做:双数组栈模式
撤销重做的核心是「历史栈 + 索引」:
const [history, setHistory] = useState<EditElement[][]>([[]])
const [historyIndex, setHistoryIndex] = useState(0)
const addToHistory = useCallback(
(newElements: EditElement[]) => {
setHistory((prev) => {
// 关键:截断当前位置之后的所有历史
const trimmed = prev.slice(0, historyIndex + 1)
return […trimmed, […newElements]]
})
setHistoryIndex((prev) => prev + 1)
},
[historyIndex]
)
const undo = useCallback(() => {
if (historyIndex > 0) {
const idx = historyIndex - 1
setHistoryIndex(idx)
setElements([…history[idx]]) // 拷贝一份,避免后续修改污染历史
}
}, [history, historyIndex])
const redo = useCallback(() => {
if (historyIndex < history.length - 1) {
const idx = historyIndex + 1
setHistoryIndex(idx)
setElements([…history[idx]])
}
}, [history, historyIndex])
4 个关键决策:
截断未来。prev.slice(0, historyIndex + 1)——撤销后新操作必须丢弃当前位置之后的历史,否则时间线会错乱(典型 bug:撤销 → 新增 → 重做 报错)。
[…newElements] 深拷贝。elements 数组是引用,直接 push 进去未来修改会污染历史。
history 是 EditElement[][]。外层数组是历史快照,内层数组是某一时刻的 elements 列表。
撤销后要重新选 selectedElement。这个工具没自动做——撤销时如果当前选中的元素被删了,红框还在但元素没了。后续优化可以用 useEffect 监听 elements 清理 selectedElement。
「什么时候不该 addToHistory」
性能优化点:拖动过程中(isDragging 持续触发)不应该每个像素都加历史——拖 100 个像素就是 100 条历史。正确做法:
mousedown 不加历史;
mousemove 不加历史,只更新 selectedElement 的 x/y;
mouseup 加一次历史(addToHistory(elements))。
// mouseup 时
if (isDragging && selectedElement) {
setIsDragging(false)
addToHistory(elements) // 拖动结束,只加一次
return
}
五、12 种工具实现
12 种工具(select / text / rectangle / circle / arrow / fill / highlight / underline / strikethrough / pen / eraser / delete)按交互模式分 3 类:
5.1 形状类(矩形 / 圆 / 箭头 / 高亮 / 下划线 / 删除线)
统一模式:mousedown 记起点 → mousemove 用 previewEl 实时显示 → mouseup 提交到 elements。
// mousedown:记起点
setIsDrawing(true)
setDrawStart(pos)
// mousemove:实时画预览(不进 elements)
const dx = pos.x - drawStart.x, dy = pos.y - drawStart.y
renderPage({
type: activeTool,
x: Math.min(drawStart.x, pos.x),
y: Math.min(drawStart.y, pos.y),
width: Math.abs(dx),
height: Math.abs(dy),
color: strokeColor,
fillColor,
lineWidth,
opacity: activeTool === ‘highlight’ ? highlightOpacity : 1,
})
// mouseup:提交到 elements
if (Math.abs(dx) < 5 && Math.abs(dy) < 5) return // 过滤 5px 以下的"误触"
const newEl: EditElement = {
id: generateId(),
type: activeTool,
x: Math.min(drawStart.x, pos.x),
y: Math.min(drawStart.y, pos.y),
width: Math.abs(dx),
height: Math.abs(dy),
// … 其它字段
pageIndex: currentPage - 1,
}
const newElements = […elements, newEl]
setElements(newElements)
addToHistory(newElements)
Math.abs(dx) < 5 是必须的——否则用户误点就会产生一个 0×0 的元素,导出会留下一个像素点。
5.2 画笔 / 橡皮:路径点列表
和形状类不同,画笔需要记录所有路径点:
// mousedown
if (activeTool === ‘pen’ || activeTool === ‘eraser’) {
setIsDrawing(true)
setPenPoints([pos])
return
}
// mousemove:每帧追加一个点
const newPts = […penPoints, pos]
setPenPoints(newPts)
const ctx = canvasRef.current.getContext(‘2d’)
if (ctx && newPts.length >= 2) {
// 关键:只画"最后一段"而不是整条路径 —— 性能优化
const last = newPts[newPts.length - 2]
const w = activeTool === ‘eraser’ ? eraserWidth : lineWidth
const col = activeTool === ‘eraser’ ? ERASER_COLOR : strokeColor
ctx.save()
ctx.strokeStyle = col
ctx.lineWidth = w
ctx.lineJoin = ‘round’
ctx.lineCap = ‘round’
ctx.beginPath()
ctx.moveTo(last.x, last.y)
ctx.lineTo(pos.x, pos.y)
ctx.stroke()
ctx.restore()
}
// mouseup:保存完整路径
const newEl: EditElement = {
id: generateId(),
type: ‘pen’, // 或 ‘eraser’
x: Math.min(…xs),
y: Math.min(…ys),
width: Math.max(…xs) - Math.min(…xs),
height: Math.max(…ys) - Math.min(…ys),
points: penPoints,
color: strokeColor,
lineWidth,
pageIndex: currentPage - 1,
}
关键优化:mousemove 时只画最后一段(moveTo(last) → lineTo(pos)),不重画整条路径——100 点的画笔轨迹,每帧只画 1 段,性能提升 100 倍。