
类别 技术 用途框架 Next.js 14 (App Router) 服务端组件、路由、i18nUI 库 React 18 TypeScript 5 组件、Hooks、严格类型样式 Tailwind CSS 3 shadcn/ui 工具栏 / 状态栏渲染引擎 PDF.jsCDN 按需加载 把 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 pdfDocumentPDF.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: stringtype: ToolTypex: number // 画布坐标scale 倍y: numberwidth?: numberheight?: numbercontent?: string // text 类型用color?: string // 描边 / 文字色fillColor?: string // 填充色‘transparent’ 表示无填充fontSize?: numberlineWidth?: numberopacity?: numberpageIndex: number // 属于哪一页0-basedpoints?: PathPoint[] // 画笔 / 橡皮路径点}为什么所有标注统一一个类型撤销/重做只需保存 EditElement[] 快照导出只需 for 循环每个 element 调 pdf-lib 对应 API跨页支持用 pageIndex 字段——elements 是所有页的并集渲染时按 el.pageIndex currentPage - 1 过滤。二、PDF.js 加载按需 重试机制PDF.js 不打包进首屏 bundle——体积太大~500KB用户没上传 PDF 前根本不需要。CDN 按需加载 重试// hooks/usePdfjsLoader.ts — 自定义 Hookexport function usePdfjsLoader() {const [status, setStatus] useState‘idle’ | ‘loading’ | ‘ready’ | ‘error’(‘idle’)const [errorMessage, setErrorMessage] useStatestring | null(null)useEffect(() {if (typeof window ‘undefined’) returnif ((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).pdfjsLibconst arrayBuffer await file.arrayBuffer()const pdf await pdfjsLib.getDocument({ data: arrayBuffer }).promisesetPdfDocument(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])两个隐藏 bugpdfFile 状态先于 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) returntry {const page await pdfDocument.getPage(currentPage)const canvas canvasRef.currentconst 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 是个 PartialdrawElements 会把真实元素 预览元素都画上。四、撤销 / 重做双数组栈模式撤销重做的核心是「历史栈 索引」const [history, setHistory] useStateEditElement[][]([[]])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 - 1setHistoryIndex(idx)setElements([…history[idx]]) // 拷贝一份避免后续修改污染历史}}, [history, historyIndex])const redo useCallback(() {if (historyIndex history.length - 1) {const idx historyIndex 1setHistoryIndex(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/ymouseup 加一次历史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实时画预览不进 elementsconst dx pos.x - drawStart.x, dy pos.y - drawStart.yrenderPage({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提交到 elementsif (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 画笔 / 橡皮路径点列表和形状类不同画笔需要记录所有路径点// mousedownif (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 : lineWidthconst col activeTool ‘eraser’ ? ERASER_COLOR : strokeColorctx.save()ctx.strokeStyle colctx.lineWidth wctx.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 倍。