
中间画布所见即所得右侧缩略图一眼看 50 页顶部导出按钮一键下载新 PDF整套流程不上传任何文件到服务器——PDF 解析、渲染、编辑、导出全部在浏览器里跑老板的合同也不会被传到我们的服务器。整体效果展示技术栈一览类别技术用途框架Next.js 14(App Router)服务端组件、路由、i18nUI 库React 18TypeScript 5组件、Hooks、严格类型样式Tailwind CSS 3shadcn/ui工具栏 / 状态栏渲染引擎PDF.jsCDN 按需加载把 PDF 页面渲染到 Canvas导出引擎pdf-lib按需import(pdf-lib)把标注写回 PDF 字节流画布原生 Canvas 2D矢量标注绘制、离屏合成Portalreact-domcreatePortal文本输入浮层fixed 定位图标lucide-react12 个工具 动作按钮核心选型逻辑PDF.js 是 Mozilla 维护的 PDF 渲染标准库Chrome 自带的内置 PDF 阅读器底层就是它没有比它更可靠的 PDF 解析方案。pdf-lib负责「写」——能在不破坏原 PDF 结构的情况下追加图形元素。一、整体架构输入 → 渲染 → 编辑 → 导出整个工具的数据流非常清晰4 个阶段阶段入口关键对象输出输入input typefile/ 拖拽File→ArrayBufferpdfDocumentPDF.js 实例渲染renderPagepdfDocument.getPage(n)主 Canvas 像素编辑鼠标 / 触摸 / 键盘EditElement[]矢量元素列表重绘到主 Canvas导出exportPDFpdf-libPDFDocument新 PDFBlob 下载最关键的抽象是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] useStateidle | loading | ready | error(idle) const [errorMessage, setErrorMessage] useStatestring | 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])两个隐藏 bugpdfFile状态先于pdfDocument。用户选文件时 PDF.js 还没加载完loadPDF提前 return等 PDF.js 加载完成后必须自动重新触发loadPDF(pdfFile)——这个useEffect就是修这个的。新文件必须清空标注。setElements([])setHistory([[]])setHistoryIndex(0)——忘了一个旧标注就会显示在新 PDF 上非常尴尬。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是个PartialEditElementdrawElements 会把真实元素 预览元素都画上。四、撤销 / 重做双数组栈模式撤销重做的核心是「历史栈 索引」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 - 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/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实时画预览不进 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 倍。5.3 橡皮擦的特殊逻辑直接删除相交文字橡皮擦不只是「画白色线条」——它真的删除与之相交的文字标注if (activeTool eraser) { // 计算擦除轨迹的 AABB const eb eraserStrokeBounds(penPoints, eraserWidth / 2) // 关键过滤掉被擦除轨迹相交的文字 const withoutHitText elements.filter((el) { if (el.pageIndex ! pIdx || el.type ! text) return true return !aabbIntersects(textAnnotationBounds(el), eb) }) const merged [...withoutHitText, newEl] setElements(merged) }为什么只对文字生效矩形、圆、箭头是几何形状擦掉不优雅会留下半截文字是有语义的擦掉代表删除这段——只对文字做这个逻辑是有意识的设计选择。5.4 文字标注浮层输入文字工具最复杂——需要弹出浮层输入框{textPlacement typeof document ! undefined createPortal( input ref{textPlacementInputRef} typetext value{textInput} onChange{(e) setTextInput(e.target.value)} onBlur{() confirmTextPlacement(textPlacement.sessionId)} onKeyDown{(e) { if (e.key Enter) { e.preventDefault(); confirmTextPlacement(textPlacement.sessionId) } if (e.key Escape) { e.preventDefault(); cancelTextPlacement() } }} placeholder{t(common.textContent)} classNamefixed z-[51] ... style{(() { // 关键把画布坐标转换为视口坐标 const canvas canvasRef.current const rect canvas?.getBoundingClientRect() const sx rect rect.width 0 ? rect.width / canvas.width : 1 const sy rect rect.height 0 ? rect.height / canvas.height : 1 const anchorLeft rect ? rect.left textPlacement.x * sx : textPlacement.viewX const anchorTop rect ? rect.top textPlacement.y * sy : textPlacement.viewY // 视口边界保护 let left Math.min(anchorLeft, window.innerWidth - inputW - 8) left Math.max(8, left) let top Math.min(anchorTop, window.innerHeight - inputH - 8) top Math.max(8, top) return { left, top, width: inputW, fontSize: ${textPlacement.fontSize}px, ... } })()} /, document.body )}3 个值得说的设计createPortal到document.body。用 absolute 定位会被父容器的overflow: hidden截断——Portal 出来 fixed 定位永远在视口。画布坐标 → 视口坐标。rect.left textPlacement.x * (rect.width / canvas.width)这个公式考虑了缩放scale和滚动。视口边界保护。Math.min(anchorLeft, window.innerWidth - inputW - 8)Math.max(8, left)——输入框不能超出视口。这种细节用户感知不到但少了它滚动到边缘时输入框就飞出去了。文字浮层 Portal 到 body5.5 fill油漆桶只对矩形/圆生效if (activeTool fill) { const clicked [...pageEls].reverse().find((el) isPointInFillableShape(el, pos)) if (clicked) { const paintColor fillColor ! transparent ? fillColor : strokeColor const newElements elements.map((el) el.id clicked.id ? { ...el, fillColor: paintColor } : el ) setElements(newElements) addToHistory(newElements) setSelectedElement(clicked.id) } }椭圆判定用归一化坐标function isPointInFillableShape(el: EditElement, pos: PathPoint): boolean { if (el.type ! rectangle el.type ! circle) return false if (el.type rectangle) { return pos.x el.x pos.x el.x el.width pos.y el.y pos.y el.y el.height } // 椭圆把点归一化到椭圆坐标系x² y² ≤ 1 即在内部 const rx el.width / 2, ry el.height / 2 const cx el.x rx, cy el.y ry const nx (pos.x - cx) / rx const ny (pos.y - cy) / ry return nx * nx ny * ny 1 }为什么不直接用浏览器ctx.isPointInPath那个是当前路径判定而我们要在已绘制的椭圆上判定——必须自己算。12 种工具分组六、导出 PDF坐标转换 pdf-lib 写回这是整个工具的「最后一公里」——把 Canvas 上的标注重新画回PDF 页面const exportPDF useCallback(async () { if (!pdfFile) return try { const { PDFDocument, rgb, StandardFonts } await import(pdf-lib) const bytes await pdfFile.arrayBuffer() const pdfDoc await PDFDocument.load(bytes) const pages pdfDoc.getPages() const font await pdfDoc.embedFont(StandardFonts.Helvetica) const toRgb (hex: string) { const c parseHexColor(hex) return rgb(c.r, c.g, c.b) } for (const elementRaw of elements) { // 关键viewport 像素 → PDF 点的转换÷ scale const s scale const element: EditElement { ...elementRaw, x: elementRaw.x / s, y: elementRaw.y / s, width: elementRaw.width ! null ? elementRaw.width / s : elementRaw.width, height: elementRaw.height ! null ? elementRaw.height / s : elementRaw.height, fontSize: elementRaw.fontSize ! null ? elementRaw.fontSize / s : elementRaw.fontSize, lineWidth: elementRaw.lineWidth ! null ? elementRaw.lineWidth / s : elementRaw.lineWidth, points: elementRaw.points?.map((p) ({ x: p.x / s, y: p.y / s })), } if (element.pageIndex pages.length) continue const page pages[element.pageIndex] const { height: pH } page.getSize() const elH element.height ?? 0 // 关键Y 轴翻转。Canvas 原点在左上PDF 原点在左下 const pdfY pH - element.y - elH const strokeRgb toRgb(element.color ?? #000000) const lw element.lineWidth ?? 2 switch (element.type) { case text: { page.drawText(element.content ?? , { x: element.x, y: pH - element.y - (element.fontSize ?? 16), size: element.fontSize ?? 16, font, color: strokeRgb, }) break } case rectangle: { const fillRgb element.fillColor element.fillColor ! transparent ? toRgb(element.fillColor) : undefined page.drawRectangle({ x: element.x, y: pdfY, width: element.width ?? 0, height: elH, borderColor: strokeRgb, borderWidth: lw, color: fillRgb }) break } case arrow: { // 画主线 两侧箭头线段 const ax1 element.x, ay1 pH - element.y const ax2 element.x (element.width ?? 0), ay2 pH - (element.y elH) const headLen 14, aAngle Math.atan2(ay2 - ay1, ax2 - ax1) page.drawLine({ start: { x: ax1, y: ay1 }, end: { x: ax2, y: ay2 }, color: strokeRgb, thickness: lw }) page.drawLine({ start: { x: ax2, y: ay2 }, end: { x: ax2 - headLen * Math.cos(aAngle - Math.PI / 6), y: ay2 - headLen * Math.sin(aAngle - Math.PI / 6) }, color: strokeRgb, thickness: lw }) page.drawLine({ start: { x: ax2, y: ay2 }, end: { x: ax2 - headLen * Math.cos(aAngle Math.PI / 6), y: ay2 - headLen * Math.sin(aAngle Math.PI / 6) }, color: strokeRgb, thickness: lw }) break } case pen: { const pts element.points ?? [] for (let i 1; i pts.length; i) { page.drawLine({ start: { x: pts[i - 1].x, y: pH - pts[i - 1].y }, end: { x: pts[i].x, y: pH - pts[i].y }, color: strokeRgb, thickness: lw }) } break } // ... 其它类型 } } const savedBytes await pdfDoc.save() const blob new Blob([new Uint8Array(savedBytes)], { type: application/pdf }) const url URL.createObjectURL(blob) const link document.createElement(a) link.href url link.download ${pdfFile.name.replace(/\.pdf$/i, )}-edited.pdf link.click() URL.revokeObjectURL(url) } catch (error) { console.error(Export error:, error) showNotice(t(notices.exportError)) } }, [elements, pdfFile, scale, showNotice, t])5 个关键决策viewport 像素 → PDF 点。element.x / scale——所有坐标都从「用户视口scale 倍」除以 scale还原为 PDF 点。忘了一步位置全部错位。Y 轴翻转。Canvas 原点在左上PDF 原点在左下。pdfY pH - element.y - elH——少了这一步矩形会出现在 PDF 顶部而不是原本位置。StandardFonts.Helvetica内置字体。不嵌入 TTF——pdf-lib的StandardFonts是 14 种 PDF 1.7 内置字体Helvetica / Times / Courier 等无需嵌入、自动支持。中文 PDF 标注需要嵌入字体这个工具目前不支持——try { page.drawText(...) } catch { /* ignore font encoding issues */ }这条 try-catch 就是兜底。drawLine画笔。pdf-lib没有drawPathAPI——画笔路径必须拆成多段drawLine。100 点的画笔就是 99 个drawLine调用效率不高但能用。Y 轴翻转要每个 line 都做。y: pH - pts[i - 1].y——路径里所有点都要翻转不是只在 switch 入口翻一次。颜色转换const parseHexColor (hex: string) { const clean hex.replace(#, ) return { r: parseInt(clean.slice(0, 2), 16) / 255, // [0, 255] → [0, 1] g: parseInt(clean.slice(2, 4), 16) / 255, b: parseInt(clean.slice(4, 6), 16) / 255, } }pdf-lib的rgb(r, g, b)接收0-1 范围——和 CSS 16 进制0-255的转换必须除以 255。七、缩略图栏右侧 0.3x 缩略图右侧缩略图用0.3 倍渲染——比 1x 渲染快 10 倍视觉上够用useEffect(() { if (!pdfDocument || !isPdfJsLoaded || totalPages 0) return let cancelled false const thumbScale 0.3 ;(async () { const urls: string[] [] for (let i 1; i totalPages; i) { if (cancelled) break // 组件卸载或依赖变化时立即终止 try { const page await pdfDocument.getPage(i) const viewport page.getViewport({ scale: thumbScale }) const offscreen document.createElement(canvas) offscreen.width viewport.width offscreen.height viewport.height const ctx offscreen.getContext(2d)! await page.render({ canvasContext: ctx, viewport }).promise urls.push(offscreen.toDataURL(image/jpeg, 0.7)) } catch { urls.push() } } if (!cancelled) setThumbnailUrls(urls) })() return () { cancelled true } }, [pdfDocument, isPdfJsLoaded, totalPages])cancelled标志位是必须的——useEffectcleanup 时设cancelled true循环里下一次迭代检测到立即 break——避免setState on unmounted警告 节省无意义的渲染。缩略图渲染示意缩略图渲染核心要点0.3x 渲染——getViewport({ scale: 0.3 })50 页的 PDF 在缩略图侧基本不卡离屏 Canvas toDataURL(image/jpeg, 0.7)——JPEG 70% 质量 离屏比 PNG 小 80%cancelled标志位红色块——这是项目里真实踩过的坑用户连续打开 2 个 PDF 时第 1 个文件的缩略图循环还在 await第 2 个文件已经触发了新的 useEffect——不 cancel 就会setState on unmounted警告cleanup 流程靛蓝块——React 18 Strict Mode 下 useEffect 会执行 2 次cleanup 会被频繁调用cancelled 是唯一的安全网try/catch 容错灰色块——单页渲染失败不影响其他页urls.push()占位一句话总结scale 0.3离屏 Canvascancelled标志位 一个能扛住 50 页 PDF 的缩略图条八、触摸 / 键盘支持触摸移动端 / 平板const handleTouchStart useCallback((e: React.TouchEventHTMLCanvasElement) { if (e.touches.length ! 1) return e.preventDefault() const synth { clientX: e.touches[0].clientX, clientY: e.touches[0].clientY, } as React.MouseEventHTMLCanvasElement handleMouseDown({ ...synth, currentTarget: e.currentTarget, target: e.target } as any) }, [handleMouseDown])为什么不直接给canvas加 onTouchStart 然后写一套独立的触摸处理复用鼠标事件处理逻辑——把 TouchEvent 包装成 MouseEvent一份代码两套入口。前提是单指操作e.touches.length ! 1直接 return——多指缩放手势不在当前范围。键盘快捷键工具栏按钮的title属性提示快捷键如CtrlZ/CtrlY快捷键本身用useEffect监听keydown事件实现Ctrl/Cmd Z→ undoCtrl/Cmd Shift Z或Ctrl/Cmd Y→ redoDelete/Backspace→ 删除选中元素Escape→ 取消文字输入九、可访问性 / 响应式细节左侧工具栏在 PC 端是竖排移动端是横排底部——order-2 md:order-noneflex-row md:flex-col。顶部操作栏在 PC 是完整显示撤销 / 重做 / 打开 / 导出移动端简化只显示图标 短文本——span classNamehidden sm:inline控制。所有按钮都是原生button——Tab 键可聚焦、屏幕阅读器可识别。hoveredForDelete红色虚线hoveredForFill绿色虚线——除了视觉提示对色弱用户不够友好——可考虑加图标。