当前位置: 首页 > news >正文

在 Vue2 中使用 pdf.js + pdf-lib 实现 PDF 预览、手写签名、文字批注与高保真导出

本文演示如何在前端(Vue.js)中结合 pdf.js、pdf-lib 与 Canvas 技术实现 PDF 预览、图片签名、手写批注、文字标注,并导出高保真 PDF。

先上demo截图,后续会附上代码仓库地址(目前还有部分问题暂未进行优化,希望各位大佬们提出意见)

待优化项

  • PDF预览与签批时无法使用手指进行缩放
  • 批注与预览模型下图层不一致,无法进行互通

在这里插入图片描述
在这里插入图片描述

1. 功能目录

  • PDF 文件预览(连续 / 单页 / 批注模式)
  • 在页面上放置图片签名(本地签名模板)并支持拖拽/缩放/旋转
  • 页面上添加文字标注(可编辑、对齐与颜色)
  • 手写批注(自由绘图,保存为笔画数据并可回放)
  • 将签名、文字与笔画嵌入并导出为新的 PDF 文件供下载

2. 主要依赖与插件

  • pdf.js (pdfjs-dist):将 PDF 渲染到 Canvas
  • pdf-lib:在浏览器端修改并导出 PDF(嵌入图片、绘制线条)
  • Canvas 2D API:用于渲染、合成与生成高 DPI 图片
  • SmoothSignature(或类似库):签名采集与透明 PNG 导出
  • 浏览器 API:localStoragedevicePixelRatiogetBoundingClientRect()
    "pdf-lib": "^1.17.1","pdfjs-dist": "^2.0.943","smooth-signature": "^1.1.0",

3. 实现思路

使用 pdf.js 渲染每页到一个 <canvas>,在其上方放两层:一层 DOM(signature-layer)用于放置图片签名和文字标注,另一层 Canvas(drawing-layer)用于自由绘图。交互(拖拽/定位/缩放/对齐)在 DOM 层完成;导出时把 DOM 的 CSS 尺寸和绘图 Canvas 的物理像素分别映射为 PDF 单位,并使用 pdf-lib 嵌入图片或重绘线条生成新的 PDF。

4.实现

1.主界面

<template><div class="pdf-container"><!-- 横屏提示遮罩 --><div class="landscape-tip-overlay" v-show="showLandscapeTip"><div class="landscape-tip-content"><div class="rotate-icon">📱</div><p class="rotate-text">请将设备旋转至竖屏模式</p><p class="rotate-subtext">以获得更好的浏览体验</p></div></div><!-- 顶部导航栏 --><div class="header" v-show="!showLandscapeTip"><div class="header-left"><span class="iconfont icon-back" @click="goBack"></span></div><div class="header-title"><span>{{ pdfTitle }}</span></div><div class="header-right"><span class="iconfont icon-download" @click="downloadPdf"></span><span class="iconfont icon-more" @click="showMoreOptions"></span></div></div><!-- PDF查看区域 --><div class="pdf-content" v-show="!showLandscapeTip"><div v-if="!pdfLoaded" class="pdf-loading"><p>正在加载PDF...</p></div><div v-else-if="pdfError" class="pdf-error"><p>PDF加载失败</p><button @click="loadPDF">重新加载</button></div><div v-else class="pdf-viewer"><!-- PDF渲染画布 --><divclass="pdf-canvas-container":class="{'single-page-mode': viewMode === 'single','annotation-mode': viewMode === 'annotation',}"@touchstart="handleTouchStart"@touchmove="handleTouchMove"@touchend="handleTouchEnd"@wheel="handleWheel"@dblclick="handleDoubleClick"@scroll="handleScroll"><!-- 连续滚动模式 - 显示所有页面 --><divv-if="viewMode === 'continuous' || viewMode === 'annotation'"class="continuous-pages"><divv-for="pageNum in totalPages":key="pageNum"class="page-wrapper":data-page="pageNum"><canvas:ref="`pdfCanvas${pageNum}`"class="pdf-canvas":data-page="pageNum"></canvas><!-- 电子签名层 - 仅在连续滚动模式显示 --><divv-if="viewMode === 'continuous'"class="signature-layer":data-page="pageNum"@click="handleSignatureLayerClick()"><!-- 已放置的签名和文字标注 --><divv-for="signature in getPageSignatures(pageNum)":key="signature.id"class="placed-signature":class="{ selected: selectedSignature === signature.id }":style="getSignatureStyle(signature)"@touchstart.stop="handleSignatureTouchStart($event, signature)"@mousedown.stop="handleSignatureMouseDown($event, signature)"><!-- 图片签名 --><imgv-if="signature.type !== 'text'":src="signature.image":alt="signature.name"/><!-- 文字标注 --><divv-elseclass="text-annotation":style="{...getTextStyle(signature),color: signature.color,fontSize: signature.fontSize,textAlign: signature.align,}">{{ signature.text }}</div><!-- 控制点 --><divv-if="selectedSignature === signature.id"class="signature-controls"><!-- 删除按钮 --><divclass="control-btn delete-btn"@click.stop="deleteSignatureFromPdf(signature.id)"@touchstart.stoptitle="删除">删除</div><!-- 转90°按钮 --><divclass="control-btn rotate-btn"@click.stop="rotateSignature90(signature.id)"@touchstart.stoptitle="转90°">转90°</div></div><!-- 拖拽缩放手柄 --><divv-if="selectedSignature === signature.id"class="resize-handle"@touchstart.stop="handleResizeStart($event, signature)"@mousedown.stop="handleResizeStart($event, signature)"title="拖拽缩放">⤢</div></div></div><!-- 批注模式签名层 - 只显示,不可操作 --><divv-if="viewMode === 'annotation'"class="signature-layer annotation-signature-layer":data-page="pageNum"><!-- 已放置的签名和文字标注(只读显示) --><divv-for="signature in getPageSignatures(pageNum)":key="signature.id"class="placed-signature readonly-signature":style="getSignatureStyle(signature)"><!-- 图片签名 --><imgv-if="signature.type !== 'text'":src="signature.image":alt="signature.name"/><!-- 文字标注 --><divv-elseclass="text-annotation":style="{...getTextStyle(signature),color: signature.color,fontSize: signature.fontSize,textAlign: signature.align,}">{{ signature.text }}</div></div></div><!-- 绘图层 - 在连续滚动和批注模式下都显示,但只在批注模式下可编辑 --><divv-if="viewMode === 'continuous' || viewMode === 'annotation'"class="drawing-layer":class="{ 'readonly-drawing': viewMode === 'continuous' }":data-page="pageNum"><canvas:ref="`drawingCanvas${pageNum}`"class="drawing-canvas"@touchstart="viewMode === 'annotation'? startDrawing($event, pageNum): null"@touchmove="viewMode === 'annotation' ? drawing($event, pageNum) : null"@touchend="viewMode === 'annotation'? stopDrawing($event, pageNum): null"@mousedown="viewMode === 'annotation'? startDrawing($event, pageNum): null"@mousemove="viewMode === 'annotation' ? drawing($event, pageNum) : null"@mouseup="viewMode === 'annotation'? stopDrawing($event, pageNum): null"@mouseleave="viewMode === 'annotation'? stopDrawing($event, pageNum): null"></canvas></div></div></div><!-- 单页模式 - 只显示当前页 --><div v-else class="single-page-wrapper"><canvas ref="pdfCanvas" class="pdf-canvas"></canvas><!-- 单页模式的签名层(只显示,不可操作) --><div class="signature-layer single-page-signature-layer"><!-- 当前页面的已放置签名和文字标注 --><divv-for="signature in getPageSignatures(currentPage)":key="signature.id"class="placed-signature readonly-signature":style="getSignatureStyle(signature)"><!-- 图片签名 --><imgv-if="signature.type !== 'text'":src="signature.image":alt="signature.name"/><!-- 文字标注 --><divv-elseclass="text-annotation":style="{...getTextStyle(signature),color: signature.color,fontSize: signature.fontSize,textAlign: signature.align,}">{{ signature.text }}</div></div></div><!-- 绘图层 - 只在批注模式下显示 --><div v-if="isAnnotationMode" class="drawing-layer"><canvasref="drawingCanvas"class="drawing-canvas"@touchstart="startDrawing"@touchmove="drawing"@touchend="stopDrawing"@mousedown="startDrawing"@mousemove="drawing"@mouseup="stopDrawing"@mouseleave="stopDrawing"></canvas></div></div><!-- 单页模式翻页按钮 --><divv-if="viewMode === 'single' && totalPages > 1"class="page-controls"><buttonclass="page-btn prev-btn":disabled="currentPage <= 1"@click="prevPage"><span class="iconfont">▲</span></button><buttonclass="page-btn next-btn":disabled="currentPage >= totalPages"@click="nextPage"><span class="iconfont">▼</span></button></div><!-- 滑动提示 --><divclass="swipe-hint"v-if="viewMode === 'continuous' && totalPages > 1"><span>↑↓ 滚动浏览</span></div><!-- 批注模式提示 --><divclass="annotation-hint"v-if="viewMode === 'annotation' && totalPages > 1"><span>批注模式</span></div></div></div></div><!-- 页码显示 --><div class="page-indicator" v-show="!showLandscapeTip"><span v-if="viewMode === 'continuous' || viewMode === 'annotation'">{{ visiblePage }} / {{ totalPages }}</span><span v-else>{{ currentPage }} / {{ totalPages }}</span></div><!-- 底部工具栏 - 只在非批注模式下显示 --><div class="footer" v-show="!showLandscapeTip && !isAnnotationMode"><div class="tool-item" @click="handleSign"><span class="iconfont">✎</span><span>签名</span></div><div class="tool-item" @click="handleText"><span class="iconfont">T</span><span>文字</span></div><div class="tool-item" @click="handleAnnotation"><span class="iconfont">○</span><span>批注</span></div></div><!-- 绘图工具栏 - 只在批注模式下显示,替代底部工具栏 --><div class="drawing-footer" v-show="!showLandscapeTip && isAnnotationMode"><divclass="drawing-tool-item"@click="setDrawingMode('pen')":class="{ active: drawingMode === 'pen' }"><span class="drawing-icon">✏️</span><span>画笔</span></div><divclass="drawing-tool-item"@click="setDrawingMode('eraser')":class="{ active: drawingMode === 'eraser' }"><span class="drawing-icon">🧽</span><span>橡皮擦</span></div><div class="drawing-tool-item" @click="clearDrawing"><span class="drawing-icon">🧹</span><span>清除</span></div><!-- 翻页按钮 --><divclass="drawing-tool-item page-tool"@click="prevPage":class="{ disabled: visiblePage <= 1 }"v-if="totalPages > 1"><span class="drawing-icon">⬆️</span><span>上页</span></div><divclass="drawing-tool-item page-tool"@click="nextPage":class="{ disabled: visiblePage >= totalPages }"v-if="totalPages > 1"><span class="drawing-icon">⬇️</span><span>下页</span></div><div class="drawing-tool-item confirm-tool" @click="exitAnnotationMode"><span class="drawing-icon">✓</span><span>确定</span></div></div><!-- 签名选择弹窗 --><divv-if="showSignatureModal"class="signature-modal"@click="closeSignatureModal"><div class="signature-modal-content" @click.stop><div class="signature-header"><h3>选择签名</h3><span class="close-btn" @click="closeSignatureModal">×</span></div><div class="signature-templates"><!-- 签名列表 --><divv-for="template in signatureTemplates":key="template.id"class="signature-item"@click="selectSignature(template)"><img:src="template.image":alt="template.name"class="signature-image"/><buttonclass="delete-btn"@click.stop="deleteSignature(template.id)"title="删除签名">×</button></div><!-- 新增签名按钮 --><div class="signature-item add-signature" @click="addNewSignature"><span class="add-icon">+</span><p class="add-text">新增签名</p></div></div></div></div><!-- 文字标注弹窗 --><div v-if="showTextModal" class="text-modal" @click="closeTextModal"><div class="text-modal-content" @click.stop><div class="text-header"><h3>添加文字标注</h3><span class="close-btn" @click="closeTextModal">×</span></div><div class="text-input-section"><textareav-model="textInput"class="text-input"placeholder="请输入文本"rows="3"maxlength="200"></textarea><div class="input-counter">{{ textInput.length }}/200</div></div><div class="text-options"><!-- 颜色选择 --><div class="option-group"><label class="option-label">颜色</label><div class="color-options"><divv-for="color in textColors":key="color.value"class="color-item":class="{ active: selectedTextColor === color.value }":style="{ backgroundColor: color.value }"@click="selectedTextColor = color.value"></div></div></div><!-- 对齐方式 --><div class="option-group"><label class="option-label">对齐</label><div class="align-options"><divv-for="align in textAligns":key="align.value"class="align-item":class="{ active: selectedTextAlign === align.value }"@click="selectedTextAlign = align.value"><span class="align-icon">{{ align.icon }}</span></div></div></div></div><div class="text-actions"><button class="cancel-btn" @click="closeTextModal">取消</button><buttonclass="confirm-btn":disabled="!textInput.trim()"@click="addTextAnnotation">确定</button></div></div></div></div>
</template><script>
// import * as pdfjsLib from "pdfjs-dist";
// // 设置worker路径
// pdfjsLib.GlobalWorkerOptions.workerSrc =
//   "http://192.168.21.4:9002/file/PDFTest/pdf.worker.min.js";
// pdfjsLib.GlobalWorkerOptions.workerSrc =
// "https://cdnjs.cloudflare.com/ajax/libs/pdf.js/2.0.943/pdf.worker.min.js";import * as pdfjsLib from "pdfjs-dist";
// 导入 worker 文件
import pdfWorkerUrl from "pdfjs-dist/build/pdf.worker.min.js";// 设置 worker 路径
pdfjsLib.GlobalWorkerOptions.workerSrc = pdfWorkerUrl;export default {data() {return {pdfTitle: "PDF文件预览批注",pdfDoc: null,currentPage: 1,totalPages: 0,pdfLoaded: false,pdfError: false,scale: 1.0,// 显示模式 - 默认始终是连续滚动viewMode: "continuous", // 'continuous' 连续滚动模式 | 'single' 单页模式(只在批注时使用) | 'annotation' 批注模式(连续布局但禁用交互)isAnnotationMode: false, // 是否处于批注模式visiblePage: 1, // 连续滚动模式下当前可见的页面// 签名相关showSignatureModal: false,signatureTemplates: [],// 屏幕方向提示showLandscapeTip: false,// 电子签名功能placedSignatures: [], // 已放置的签名列表selectedSignature: null, // 当前选中的签名IDpendingSignature: null, // 待放置的签名previewPosition: { x: 0, y: 0 }, // 放置位置previewPageNum: 1, // 放置所在页面// 拖拽和操作相关isDragging: false,isResizing: false,dragStartPos: { x: 0, y: 0 },resizeStartPos: { x: 0, y: 0 },resizeStartSize: { width: 0, height: 0, scale: 1 },// 触摸操作lastTouchPos: null,operationStartTime: 0,// 单页模式缩放比例singlePageScaleX: 1.0,singlePageScaleY: 1.0,// 文字标注相关showTextModal: false,textInput: "",selectedTextColor: "#000000",selectedTextAlign: "left",textColors: [{ value: "#000000", label: "黑色" },{ value: "#666666", label: "深灰" },{ value: "#999999", label: "灰色" },{ value: "#ff0000", label: "红色" },{ value: "#ff4757", label: "亮红" },{ value: "#ffa500", label: "橙色" },{ value: "#ffff00", label: "黄色" },],textAligns: [{ value: "left", icon: "≡" },{ value: "center", icon: "≡" },{ value: "right", icon: "≡" },],// 绘图批注相关isDrawing: false,drawingMode: "pen", // 'pen' | 'eraser' | 'clear'penColor: "#ff0000", // 画笔颜色penWidth: 3, // 画笔粗细drawingCanvas: null, // 绘图画布drawingContext: null, // 绘图上下文drawingStrokesByPage: {}, // 按页面存储绘制的笔画 {pageNum: [strokes]}currentStroke: [], // 当前笔画currentStrokeId: 0, // 当前笔画ID,用于标识每一条笔画currentDrawingPage: null, // 当前正在绘制的页面// 滚动恢复定时器跟踪scrollRestoreTimers: [], // 跟踪所有滚动恢复定时器};},computed: {// 预览样式previewStyle() {return {width: 100,height: 50,};},},mounted() {// 检查屏幕方向this.checkOrientation();this.loadPDF();this.loadSignatureTemplates();// 监听窗口大小变化和屏幕方向变化window.addEventListener("resize", this.handleResize);window.addEventListener("orientationchange", this.handleOrientationChange);// 监听全局鼠标和触摸事件,用于拖拽签名window.addEventListener("mousemove", this.handleGlobalMouseMove);window.addEventListener("mouseup", this.handleGlobalMouseUp);window.addEventListener("touchmove", this.handleGlobalTouchMove);window.addEventListener("touchend", this.handleGlobalTouchEnd);},beforeDestroy() {window.removeEventListener("resize", this.handleResize);window.removeEventListener("orientationchange",this.handleOrientationChange);// 移除全局事件监听器window.removeEventListener("mousemove", this.handleGlobalMouseMove);window.removeEventListener("mouseup", this.handleGlobalMouseUp);window.removeEventListener("touchmove", this.handleGlobalTouchMove);window.removeEventListener("touchend", this.handleGlobalTouchEnd);// 清理所有滚动恢复定时器this.scrollRestoreTimers.forEach((timerId) => {clearTimeout(timerId);});this.scrollRestoreTimers = [];},methods: {// 加载PDF文件async loadPDF() {// 重置状态this.pdfLoaded = false;this.pdfError = false;this.pdfDoc = null;try {// 使用绝对路径或相对路径const pdfUrl = window.location.origin + "/testPDF.pdf";// const pdfUrl = "http://192.168.21.4:9002/file/PDFTest/testPDF.pdf";const loadingTask = pdfjsLib.getDocument(pdfUrl);this.pdfDoc = await loadingTask.promise;this.totalPages = this.pdfDoc.numPages;this.pdfLoaded = true;// 等待下一个tick再渲染,确保DOM已更新this.$nextTick(() => {if (this.viewMode === "continuous") {this.renderAllPages();} else {this.renderPage(1);}});} catch (error) {console.error("PDF加载失败:", error);this.pdfLoaded = true;this.pdfError = true;// 移除alert,在控制台查看详细错误console.error("详细错误信息:", error);}},// 渲染所有页面(连续滚动模式)async renderAllPages() {if (!this.pdfDoc) {console.error("PDF文档未加载");return;}try {// 串行渲染,避免过度负载for (let pageNum = 1; pageNum <= this.totalPages; pageNum++) {await this.renderSinglePage(pageNum, `pdfCanvas${pageNum}`);}// 初始化签名层尺寸for (let pageNum = 1; pageNum <= this.totalPages; pageNum++) {this.syncSignatureLayerSize(pageNum);}// 初始化绘图画布并显示已保存的批注(连续滚动模式下也要显示批注)this.$nextTick(() => {this.initAllDrawingCanvases();});// 渲染完成后,强制滚动到第一页this.$nextTick(() => {setTimeout(() => {this.scrollToFirstPage();}, 100);});} catch (error) {console.error("渲染所有页面失败:", error);}},// 渲染单个页面到指定canvasasync renderSinglePage(pageNum, canvasRefName) {if (!this.pdfDoc) {console.error("PDF文档未加载");return;}try {const page = await this.pdfDoc.getPage(pageNum);await this.$nextTick();// 获取canvas引用let canvas;if (canvasRefName === "pdfCanvas") {canvas = this.$refs.pdfCanvas;} else {const canvases = this.$refs[canvasRefName];canvas = Array.isArray(canvases) ? canvases[0] : canvases;}if (!canvas) {console.error(`Canvas元素未找到: ${canvasRefName}`);return;}const context = canvas.getContext("2d");// 检查page对象的view属性const view = page.view || [0, 0, 595, 842];let scale;if (this.viewMode === "continuous") {// 连续模式使用自适应宽度缩放const container = canvas.closest(".pdf-canvas-container");if (container) {const containerWidth = container.clientWidth - 40; // 减去paddingconst pageWidth = Math.abs(view[2] - view[0]);if (containerWidth > 0 && pageWidth > 0) {scale = Math.min(containerWidth / pageWidth, 1.2); // 最大1.2倍} else {scale = 1.0;}} else {scale = 1.0;}} else {// 单页模式根据容器大小自适应const container = canvas.closest(".pdf-canvas-container");if (container) {// 获取实际可用的容器尺寸const containerWidth = container.clientWidth - 20; // 减去padding 10pxconst containerHeight = container.clientHeight - 20;const pageWidth = Math.abs(view[2] - view[0]);const pageHeight = Math.abs(view[3] - view[1]);if (containerWidth > 100 &&containerHeight > 100 &&pageWidth > 0 &&pageHeight > 0) {const scaleX = containerWidth / pageWidth;const scaleY = containerHeight / pageHeight;scale = Math.min(scaleX, scaleY, 2.0); // 允许更大的缩放} else {// 如果容器尺寸获取失败,使用窗口尺寸计算const windowWidth = window.innerWidth - 40;const windowHeight = window.innerHeight - 140; // 减去header和footerconst scaleX = windowWidth / pageWidth;const scaleY = windowHeight / pageHeight;scale = Math.min(scaleX, scaleY, 1.5);}} else {scale = 1.0;console.warn("容器元素未找到");}}// 获取设备像素比以提升清晰度const devicePixelRatio = window.devicePixelRatio || 1;const outputScale = devicePixelRatio;// 手动计算viewport,考虑设备像素比const viewport = {width: Math.abs(view[2] - view[0]) * scale,height: Math.abs(view[3] - view[1]) * scale,transform: [scale, 0, 0, scale, 0, 0],};// 如果计算的尺寸有问题,使用固定尺寸if (!viewport.width ||!viewport.height ||viewport.width <= 0 ||viewport.height <= 0) {viewport.width = this.viewMode === "continuous" ? 595 : 714;viewport.height = this.viewMode === "continuous" ? 842 : 1010;}// 设置canvas尺寸,考虑设备像素比以提升清晰度canvas.width = viewport.width * outputScale;canvas.height = viewport.height * outputScale;// 对于单页模式,让canvas填满容器if (this.viewMode === "single") {// 保持宽高比的情况下最大化显示const displayContainer = canvas.closest(".pdf-canvas-container");if (displayContainer) {const containerWidth = displayContainer.clientWidth - 20; // 减去padding 10pxconst containerHeight = displayContainer.clientHeight - 20;// 计算显示尺寸(保持PDF原始宽高比)const aspectRatio = viewport.width / viewport.height;let displayWidth = containerWidth;let displayHeight = containerWidth / aspectRatio;if (displayHeight > containerHeight) {displayHeight = containerHeight;displayWidth = containerHeight * aspectRatio;}canvas.style.width = displayWidth + "px";canvas.style.height = displayHeight + "px";// 设置CSS样式确保高DPI显示清晰canvas.style.imageRendering = "auto";} else {canvas.style.width = viewport.width + "px";canvas.style.height = viewport.height + "px";// 设置CSS样式确保高DPI显示清晰canvas.style.imageRendering = "auto";}} else {// 连续模式直接使用viewport尺寸canvas.style.width = viewport.width + "px";canvas.style.height = viewport.height + "px";// 设置CSS样式确保高DPI显示清晰canvas.style.imageRendering = "auto";}// 清空canvas并设置白色背景context.clearRect(0, 0, canvas.width, canvas.height);context.fillStyle = "white";context.fillRect(0, 0, canvas.width, canvas.height);// 修复坐标系翻转问题并应用设备像素比缩放context.save();context.scale(outputScale, -outputScale);context.translate(0, -canvas.height / outputScale);const renderContext = {canvasContext: context,viewport: viewport,};const renderTask = page.render(renderContext);await renderTask.promise;// 恢复context状态context.restore();// 渲染完成后,初始化签名层this.syncSignatureLayerSize(pageNum);} catch (error) {console.error(`渲染第${pageNum}页失败:`, error);}},// 渲染指定页面(单页模式)async renderPage(pageNum) {if (!this.pdfDoc) {console.error("PDF文档未加载");return;}try {const page = await this.pdfDoc.getPage(pageNum);await this.$nextTick(); // 确保DOM已更新const canvas = this.$refs.pdfCanvas;if (!canvas) {console.error("Canvas元素未找到");return;}const context = canvas.getContext("2d");// 根据PDF.js 2.0.943版本,直接使用page的view属性计算viewportlet viewport;const view = page.view || [0, 0, 595, 842]; // 默认A4尺寸// 单页模式使用自适应缩放let scale;const container = canvas.closest(".pdf-canvas-container");if (container) {const containerWidth = container.clientWidth - 20; // 减去padding 10pxconst containerHeight = container.clientHeight - 20;const pageWidth = Math.abs(view[2] - view[0]);const pageHeight = Math.abs(view[3] - view[1]);if (containerWidth > 100 &&containerHeight > 100 &&pageWidth > 0 &&pageHeight > 0) {const scaleX = containerWidth / pageWidth;const scaleY = containerHeight / pageHeight;scale = Math.min(scaleX, scaleY, 2.0);} else {const windowWidth = window.innerWidth - 40;const windowHeight = window.innerHeight - 140;const scaleX = windowWidth / pageWidth;const scaleY = windowHeight / pageHeight;scale = Math.min(scaleX, scaleY, 1.5);}} else {scale = 1.2;}// 获取设备像素比以提升清晰度const devicePixelRatio = window.devicePixelRatio || 1;const outputScale = devicePixelRatio;// 手动计算viewportviewport = {width: Math.abs(view[2] - view[0]) * scale,height: Math.abs(view[3] - view[1]) * scale,transform: [scale, 0, 0, scale, 0, 0],};// 如果计算的尺寸还是有问题,使用固定尺寸if (!viewport.width ||!viewport.height ||viewport.width <= 0 ||viewport.height <= 0) {viewport.width = 714; // 595 * 1.2viewport.height = 1010; // 842 * 1.2}// 设置canvas尺寸,考虑设备像素比以提升清晰度canvas.width = viewport.width * outputScale;canvas.height = viewport.height * outputScale;// 让canvas填满容器(单页模式)const canvasContainer = canvas.closest(".pdf-canvas-container");if (canvasContainer) {const containerWidth = canvasContainer.clientWidth - 20; // 减去padding 10pxconst containerHeight = canvasContainer.clientHeight - 20;// 计算显示尺寸(保持PDF原始宽高比)const aspectRatio = viewport.width / viewport.height;let displayWidth = containerWidth;let displayHeight = containerWidth / aspectRatio;if (displayHeight > containerHeight) {displayHeight = containerHeight;displayWidth = containerHeight * aspectRatio;}canvas.style.width = displayWidth + "px";canvas.style.height = displayHeight + "px";// 设置CSS样式确保高DPI显示清晰canvas.style.imageRendering = "auto";} else {canvas.style.width = viewport.width + "px";canvas.style.height = viewport.height + "px";// 设置CSS样式确保高DPI显示清晰canvas.style.imageRendering = "auto";}// 清空canvas并设置白色背景context.clearRect(0, 0, canvas.width, canvas.height);context.fillStyle = "white";context.fillRect(0, 0, canvas.width, canvas.height);// 修复坐标系翻转问题并应用设备像素比缩放context.save();context.scale(outputScale, -outputScale);context.translate(0, -canvas.height / outputScale);const renderContext = {canvasContext: context,viewport: viewport,};const renderTask = page.render(renderContext);await renderTask.promise;// 恢复context状态context.restore();this.currentPage = pageNum;// 单页模式下也需要同步签名层尺寸this.$nextTick(() => {this.syncSinglePageSignatureLayer();// 如果在批注模式下,重新绘制当前页面的批注if (this.isAnnotationMode && this.drawingContext) {setTimeout(() => {this.redrawCurrentPageStrokes();}, 100);}});} catch (error) {console.error("渲染页面时发生错误:", error);// 显示错误信息const canvas = this.$refs.pdfCanvas;if (canvas) {const context = canvas.getContext("2d");canvas.width = 600;canvas.height = 400;context.fillStyle = "lightgray";context.fillRect(0, 0, canvas.width, canvas.height);context.fillStyle = "red";context.font = "20px Arial";context.fillText("PDF渲染失败", 50, 100);context.fillText("错误: " + error.message, 50, 130);}}},// 上一页async prevPage() {if (this.viewMode === "single") {// 单页模式if (this.currentPage > 1) {await this.renderPage(this.currentPage - 1);// 单页模式下同步签名层this.$nextTick(() => {this.syncSinglePageSignatureLayer();// 如果在批注模式下,重新初始化绘图画布if (this.isAnnotationMode) {setTimeout(() => {this.initDrawingCanvas();}, 100);}});}} else if (this.viewMode === "annotation") {// 批注模式:滚动到上一页if (this.visiblePage > 1) {this.scrollToPageInAnnotationMode(this.visiblePage - 1);}}},// 下一页async nextPage() {if (this.viewMode === "single") {// 单页模式if (this.currentPage < this.totalPages) {await this.renderPage(this.currentPage + 1);// 单页模式下同步签名层this.$nextTick(() => {this.syncSinglePageSignatureLayer();// 如果在批注模式下,重新初始化绘图画布if (this.isAnnotationMode) {setTimeout(() => {this.initDrawingCanvas();}, 100);}});}} else if (this.viewMode === "annotation") {// 批注模式:滚动到下一页if (this.visiblePage < this.totalPages) {this.scrollToPageInAnnotationMode(this.visiblePage + 1);}}},// 返回上一页goBack() {this.$router.go(-1);},// 下载PDFasync downloadPdf() {// 1. 读取原始PDFconst pdfUrl = "/testPDF.pdf";try {const { PDFDocument, rgb } = await import("pdf-lib");const res = await fetch(pdfUrl);const arrayBuffer = await res.arrayBuffer();const pdfDoc = await PDFDocument.load(arrayBuffer);// 取第一页canvas,获取物理像素宽高// 2. 处理签名和文字标注(基于signature-layer的CSS宽高做比例换算)for (const sig of this.placedSignatures) {const page = pdfDoc.getPage(sig.page - 1);if (!page) continue;const pdfPageWidth = page.getWidth();const pdfPageHeight = page.getHeight();// 获取signature-layer的CSS宽高const sigLayer = document.querySelector(`[data-page="${sig.page}"] .signature-layer`);let cssLayerWidth = 375,cssLayerHeight = 500;if (sigLayer) {cssLayerWidth = sigLayer.offsetWidth;cssLayerHeight = sigLayer.offsetHeight;}// 用CSS像素做比例换算const xRatio = (sig.x || 0) / cssLayerWidth;const yRatio = (sig.y || 0) / cssLayerHeight;const wRatio = (sig.width || 100) / cssLayerWidth;const hRatio = (sig.height || 50) / cssLayerHeight;const pdfX = xRatio * pdfPageWidth;const drawWidth = wRatio * pdfPageWidth * (sig.scale || 1);const drawHeight = hRatio * pdfPageHeight * (sig.scale || 1);// 顶部对齐const pdfY = pdfPageHeight - yRatio * pdfPageHeight - drawHeight;if (sig.type === "handwritten" || sig.type === "signature") {const pngImage = await pdfDoc.embedPng(sig.image);page.drawImage(pngImage, {x: pdfX,y: pdfY,width: drawWidth,height: drawHeight,rotate: sig.rotate? { type: "degrees", angle: sig.rotate }: undefined,});} else if (sig.type === "text") {// 为了在 PDF 中保持文字清晰且大小接近 UI:// - 计算在 PDF 中绘制的目标宽高(pdf 单位) drawWidth/drawHeight// - 按目标宽高和一个像素密度(targetDensity)生成高像素密度的 PNG// - 嵌入并按 pdf 单位宽高绘制const fontSize = sig.fontSize ? parseInt(sig.fontSize) : 16;// 目标在PDF中的宽高已经是 drawWidth/drawHeight(PDF points)const pdfTargetW = drawWidth;const pdfTargetH = drawHeight;// 设定目标像素密度:以 devicePixelRatio 为基础,乘以一个放大系数以提升导出清晰度const deviceDPR = window.devicePixelRatio || 1;const targetDensity = Math.max(2, Math.round(deviceDPR * 2));// 计算需要生成的图片像素尺寸(像素 = PDF points * density)const imagePixelWidth = Math.max(1,Math.ceil(pdfTargetW * targetDensity));const imagePixelHeight = Math.max(1,Math.ceil(pdfTargetH * targetDensity));// 在内存中创建 canvas 并绘制文字(按像素尺寸绘制)try {const tmpCanvas = document.createElement("canvas");tmpCanvas.width = imagePixelWidth;tmpCanvas.height = imagePixelHeight;// 将CSS显示尺寸设置为PDF点尺寸(便于测量)tmpCanvas.style.width = pdfTargetW + "px";tmpCanvas.style.height = pdfTargetH + "px";const ctx = tmpCanvas.getContext("2d");// 清空并设置透明背景ctx.clearRect(0, 0, tmpCanvas.width, tmpCanvas.height);ctx.fillStyle = sig.selectedTextColor || sig.color || "#d32f2f";// 计算字体在像素画布上的大小:基于原始 fontSize (CSS px) 缩放到 imagePixelWidthconst origCssWidth = sig.width || cssLayerWidth * (wRatio || 0.1);const fontSizeNumber = fontSize || 16;// 字体缩放因子 = imagePixelWidth / 原始 CSS 宽度(使文字在图片中占比接近 UI)const fontScale =imagePixelWidth / (origCssWidth || imagePixelWidth);const scaledFontSize = Math.max(8,Math.round(fontSizeNumber * fontScale));ctx.font = `${scaledFontSize}px sans-serif`;ctx.textBaseline = "top";// 计算文本绘制位置根据对齐方式(在像素画布上)const measured = ctx.measureText(sig.text || "");const textWidthPx = measured.width;let drawX = 0;const align = sig.selectedTextAlign || sig.align || "left";if (align === "center") {drawX = (tmpCanvas.width - textWidthPx) / 2;} else if (align === "right") {drawX =tmpCanvas.width - textWidthPx - Math.round(4 * targetDensity);} else {drawX = Math.round(4 * targetDensity); // left padding}const drawY = Math.round(2 * targetDensity); // small top padding// 绘制文字(使用 fillText)ctx.fillText(sig.text || "", drawX, drawY);const textImgDataUrl = tmpCanvas.toDataURL("image/png");// 嵌入并绘制到PDFconst embeddedTextImg = await pdfDoc.embedPng(textImgDataUrl);page.drawImage(embeddedTextImg, {x: pdfX,y: pdfY,width: pdfTargetW,height: pdfTargetH,});} catch (embedErr) {console.error("生成或嵌入文字图片到PDF失败:", embedErr);}}}// 3. 处理手写批注if (this.drawingStrokesByPage) {for (const [pageNum, strokes] of Object.entries(this.drawingStrokesByPage)) {const page = pdfDoc.getPage(Number(pageNum) - 1);if (!page) continue;const pdfPageWidth = page.getWidth();const pdfPageHeight = page.getHeight();for (const stroke of strokes) {if (!stroke.points || stroke.points.length < 2) continue;// 颜色支持let color = rgb(1, 0, 0);if (stroke.color) {const hex = stroke.color.replace("#", "");if (hex.length === 6) {const r = parseInt(hex.substring(0, 2), 16) / 255;const g = parseInt(hex.substring(2, 4), 16) / 255;const b = parseInt(hex.substring(4, 6), 16) / 255;color = rgb(r, g, b);}}// 计算用于归一化笔画坐标的画布物理像素尺寸// 优先使用批注绘图canvas的物理像素尺寸,如果不可用则回退到PDF页面的点尺寸let canvasPixelWidth = pdfPageWidth;let canvasPixelHeight = pdfPageHeight;try {let drawingCanvas = null;const drawingRef = this.$refs[`drawingCanvas${pageNum}`];if (drawingRef) {drawingCanvas = Array.isArray(drawingRef)? drawingRef[0]: drawingRef;}if (!drawingCanvas) {drawingCanvas = document.querySelector(`[data-page="${pageNum}"] canvas.drawing-canvas`);}if (drawingCanvas) {// canvas.width/height 是物理像素(考虑devicePixelRatio)canvasPixelWidth = drawingCanvas.width || canvasPixelWidth;canvasPixelHeight = drawingCanvas.height || canvasPixelHeight;}} catch (e) {// 忽略并使用pdf页面尺寸作为回退}for (let i = 1; i < stroke.points.length; i++) {const p1 = stroke.points[i - 1];const p2 = stroke.points[i];// 坐标全部用canvas物理像素做比例const pdfP1 = {x: (p1.x / canvasPixelWidth) * pdfPageWidth,y: pdfPageHeight - (p1.y / canvasPixelHeight) * pdfPageHeight,};const pdfP2 = {x: (p2.x / canvasPixelWidth) * pdfPageWidth,y: pdfPageHeight - (p2.y / canvasPixelHeight) * pdfPageHeight,};page.drawLine({start: pdfP1,end: pdfP2,thickness:((stroke.width || 2) / canvasPixelWidth) * pdfPageWidth,color: color,});}}}}// 4. 导出PDFconst pdfBytes = await pdfDoc.save();const blob = new Blob([pdfBytes], { type: "application/pdf" });const url = URL.createObjectURL(blob);const link = document.createElement("a");link.href = url;link.download = "批注文档.pdf";document.body.appendChild(link);link.click();setTimeout(() => {document.body.removeChild(link);URL.revokeObjectURL(url);}, 100);} catch (err) {alert("导出PDF失败:" + err.message);}},// 将文字转为图片(base64 PNG)async textToImage(text,fontSize = 16,color = "#d32f2f",align = "left",width = 120,height = 32) {// 支持高DPR,宽高与sig一致,颜色准确return new Promise((resolve) => {const dpr = window.devicePixelRatio || 1;const canvasWidth = width || fontSize * text.length + 20;const canvasHeight = height || fontSize + 16;const canvas = document.createElement("canvas");canvas.width = canvasWidth * dpr;canvas.height = canvasHeight * dpr;canvas.style.width = canvasWidth + "px";canvas.style.height = canvasHeight + "px";const ctx = canvas.getContext("2d");ctx.setTransform(1, 0, 0, 1, 0, 0); // 重置变换ctx.scale(dpr, dpr);ctx.font = `${fontSize}px sans-serif`;ctx.textBaseline = "top";ctx.fillStyle = color;let x = 10;const textWidth = ctx.measureText(text).width;if (align === "center") x = (canvasWidth - textWidth) / 2;if (align === "right") x = canvasWidth - textWidth - 10;ctx.clearRect(0, 0, canvasWidth, canvasHeight);ctx.fillText(text, x, 8);resolve(canvas.toDataURL("image/png"));});},// 显示更多选项showMoreOptions() {alert("更多选项功能开发中");},// 加载签名模板loadSignatureTemplates() {try {const savedSignatures = localStorage.getItem("userSignatures");if (savedSignatures) {this.signatureTemplates = JSON.parse(savedSignatures);} else {this.signatureTemplates = [];}} catch (error) {console.error("加载签名模板失败:", error);this.signatureTemplates = [];}},// 底部工具栏功能handleSign() {// 重新加载签名模板,确保显示最新的签名this.loadSignatureTemplates();this.showSignatureModal = true;},// 关闭签名弹窗closeSignatureModal() {this.showSignatureModal = false;},// 选择签名模板selectSignature(template) {// 只在连续滚动模式下允许放置签名if (this.viewMode === "continuous") {this.pendingSignature = template;this.previewPageNum = this.visiblePage || 1;// 获取当前可视区域中心位置作为放置位置this.$nextTick(() => {const container = document.querySelector(".pdf-canvas-container");const continuousPages = document.querySelector(".continuous-pages");if (container && continuousPages) {// 首先找到可视区域中心真正对应的页面const containerRect = container.getBoundingClientRect();const containerCenterY =containerRect.top + containerRect.height / 2;let targetPageNum = 1;let targetCanvas = null;// 遍历所有页面,找到包含可视区域中心的页面for (let pageNum = 1; pageNum <= this.totalPages; pageNum++) {const canvas = this.$refs[`pdfCanvas${pageNum}`];if (canvas && canvas[0]) {const canvasRect = canvas[0].getBoundingClientRect();if (containerCenterY >= canvasRect.top &&containerCenterY <= canvasRect.bottom) {targetPageNum = pageNum;targetCanvas = canvas[0];break;}}}if (targetCanvas) {// 计算当前可视区域的中心点const visibleCenterX = containerRect.width / 2;const visibleCenterY = containerRect.height / 2;// 直接使用签名层计算位置const signatureLayer = document.querySelector(`[data-page="${targetPageNum}"] .signature-layer`);let originalCanvasX, originalCanvasY;if (signatureLayer) {const signatureLayerRect = signatureLayer.getBoundingClientRect();// 计算可视区域中心的绝对位置const viewportAbsCenterX = containerRect.left + visibleCenterX;const viewportAbsCenterY = containerRect.top + visibleCenterY;// 计算相对于签名层的位置originalCanvasX = viewportAbsCenterX - signatureLayerRect.left;originalCanvasY = viewportAbsCenterY - signatureLayerRect.top;} else {// 备用方案:使用可视区域中心originalCanvasX = visibleCenterX;originalCanvasY = visibleCenterY;}// 转换为签名层坐标this.previewPosition = {x: originalCanvasX - 50, // 中心X - 签名宽度的一半y: originalCanvasY - 25, // 中心Y - 签名高度的一半};// 更新目标页面号并直接放置签名this.previewPageNum = targetPageNum;this.placeSignature(targetPageNum, template);} else {// 备用方案:使用画布中心this.previewPosition = { x: 200, y: 200 };this.placeSignature(this.previewPageNum, template);}} else {// 备用方案:如果找不到容器或continuousPagesthis.previewPosition = { x: 200, y: 200 };this.placeSignature(this.previewPageNum, template);}});this.closeSignatureModal();} else {if (this.viewMode === "annotation") {alert("批注模式下无法放置签名,请先退出批注模式");} else {alert("请先切换到浏览模式以放置签名");}this.closeSignatureModal();}},// 删除签名deleteSignature(signatureId) {if (confirm("确定要删除这个签名吗?")) {try {const savedSignatures = JSON.parse(localStorage.getItem("userSignatures") || "[]");const filteredSignatures = savedSignatures.filter((sig) => sig.id !== signatureId);localStorage.setItem("userSignatures",JSON.stringify(filteredSignatures));this.signatureTemplates = filteredSignatures;} catch (error) {console.error("删除签名失败:", error);alert("删除失败,请重试");}}},// 新增签名addNewSignature() {this.$router.push("/handWrittenSignature");},handleText() {this.showTextModal = true;},handleAnnotation() {if (this.isAnnotationMode) {// 退出批注模式this.exitAnnotationMode();} else {// 进入批注模式this.switchToAnnotationMode();}},// 触摸事件处理handleTouchStart(event) {// 单页模式或批注模式下处理触摸if (this.viewMode === "single") return;// 批注模式下禁用滚动和缩放if (this.viewMode === "annotation") {event.preventDefault();return;}const touches = event.touches;if (touches.length === 1) {// 单指触摸,不阻止默认行为,允许原生滚动// 浏览器会自动处理滚动}},handleTouchMove(event) {// 单页模式下不处理触摸if (this.viewMode === "single") return;// 批注模式下禁用滚动和缩放if (this.viewMode === "annotation") {event.preventDefault();return;}const touches = event.touches;if (touches.length === 1) {// 单指滑动,允许正常滚动,不阻止默认行为// 浏览器会自动处理滚动}},handleTouchEnd(event) {// 单页模式下不处理触摸if (this.viewMode === "single") return;// 批注模式下禁用滚动和缩放if (this.viewMode === "annotation") {event.preventDefault();return;}// 移除所有触摸缩放相关代码// 保留空方法以防其他地方调用},// 鼠标滚轮事件(桌面端)handleWheel(event) {// 单页模式下不处理滚轮if (this.viewMode === "single") return;// 批注模式下禁用滚轮滚动if (this.viewMode === "annotation") {event.preventDefault();return;}// 移除缩放功能,保留正常滚动// 浏览器会自动处理滚动},// 检查屏幕方向checkOrientation() {// 检查是否为横屏const isLandscape = window.innerWidth > window.innerHeight;this.showLandscapeTip = isLandscape;},// 处理屏幕方向变化handleOrientationChange() {setTimeout(() => {this.checkOrientation();}, 300);},// 仍要继续(在横屏模式下浏览)continueLandscape() {this.showLandscapeTip = false;},// 处理窗口大小变化handleResize() {// 检查屏幕方向this.checkOrientation();// 单页模式下重新同步签名层if (this.viewMode === "single" && !this.showLandscapeTip) {setTimeout(() => {this.syncSinglePageSignatureLayer();}, 100);}},// 双击事件handleDoubleClick(event) {if (this.viewMode === "continuous") {// 移除缩放功能,保留双击事件处理框架event.preventDefault();}},// 滚动监听(连续模式)handleScroll(event) {if (this.viewMode !== "continuous" && this.viewMode !== "annotation")return;const container = event.target;const canvases = container.querySelectorAll(".pdf-canvas");// 找到当前可见的页面let visiblePage = 1;let minDistance = Infinity;for (let i = 0; i < canvases.length; i++) {const canvas = canvases[i];const rect = canvas.getBoundingClientRect();const containerRect = container.getBoundingClientRect();// 计算页面中心到容器中心的距离const pageCenterY = rect.top + rect.height / 2;const containerCenterY = containerRect.top + containerRect.height / 2;const distance = Math.abs(pageCenterY - containerCenterY);if (distance < minDistance) {minDistance = distance;visiblePage = i + 1;}}this.visiblePage = visiblePage;},// 切换到批注模式switchToAnnotationMode() {this.isAnnotationMode = true;this.viewMode = "annotation"; // 使用批注模式,保持连续布局this.$nextTick(() => {// 延迟一下确保DOM完全更新setTimeout(() => {// 初始化所有页面的绘图画布this.initAllDrawingCanvases();// 同步所有签名层尺寸for (let pageNum = 1; pageNum <= this.totalPages; pageNum++) {this.syncAnnotationSignatureLayerSize(pageNum);}}, 200);});},// 退出批注模式exitAnnotationMode() {this.isAnnotationMode = false;this.viewMode = "continuous";// 保存批注到PDF中或做其他处理this.saveAnnotations();// 清理所有可能干扰滚动的定时器this.scrollRestoreTimers.forEach((timerId) => {clearTimeout(timerId);});this.scrollRestoreTimers = [];// 恢复容器的滚动功能this.$nextTick(() => {const container = document.querySelector(".pdf-canvas-container");if (container) {// 重置容器滚动样式,恢复连续滚动功能container.style.overflow = ""; // 清除内联样式,让CSS类生效container.style.overflowY = "";container.style.overflowX = "";container.style.touchAction = "";container.style.pointerEvents = "";container.style.cursor = "";container.style.contain = "";// 强制重新应用连续滚动模式的样式container.style.overflowY = "auto";container.style.overflowX = "hidden";container.style.touchAction = "pan-y pinch-zoom";container.style.contain = "layout style paint";}// 清理绘图数据this.clearAnnotationData();// 重新初始化绘图画布以在连续滚动模式下显示批注setTimeout(() => {if (this.viewMode === "continuous") {this.initAllDrawingCanvases();}}, 200);});},// 滚动到第一页scrollToFirstPage() {const container = document.querySelector(".pdf-canvas-container");if (container) {// 立即设置滚动位置container.scrollTo(0, 0);this.visiblePage = 1;// 强制重新计算this.$nextTick(() => {container.scrollTo(0, 0);this.visiblePage = 1;// 最后确认setTimeout(() => {container.scrollTo(0, 0);this.visiblePage = 1;}, 100);});}},// 滚动到指定页面scrollToPage(pageNum) {if (this.viewMode !== "continuous" && this.viewMode !== "annotation") {return;}const container = document.querySelector(".pdf-canvas-container");const continuousPages = document.querySelector(".continuous-pages");const canvas = this.$refs[`pdfCanvas${pageNum}`];if (container && canvas && canvas[0]) {const canvasElement = canvas[0];// 使用更简单的滚动方式const targetScrollTop = canvasElement.offsetTop - 50; // 页面顶部留50px间距// 批注模式下强制启用滚动if (this.viewMode === "annotation") {// 完全重置滚动相关样式container.style.overflow = "auto";container.style.overflowY = "auto";container.style.overflowX = "hidden";container.style.touchAction = "auto";container.style.pointerEvents = "auto";// 移除可能干扰的样式container.style.contain = "none";}// 方式1:直接设置scrollTopcontainer.scrollTop = Math.max(0, targetScrollTop);// 方式2:如果方式1失败,使用scrollToif (Math.abs(container.scrollTop - Math.max(0, targetScrollTop)) > 5) {container.scrollTo({top: Math.max(0, targetScrollTop),behavior: "auto", // 使用auto而不是smooth});}// 方式3:如果还是失败,尝试操作连续页面容器setTimeout(() => {if (Math.abs(container.scrollTop - Math.max(0, targetScrollTop)) > 5) {if (continuousPages) {// 尝试通过修改连续页面容器的transform来实现"滚动"效果const translateY = -targetScrollTop;continuousPages.style.transform = `translateY(${translateY}px)`;this.visiblePage = pageNum; // 更新页面指示器return;}// 最后尝试:强制滚动container.scrollTop = Math.max(0, targetScrollTop);}}, 100);this.visiblePage = pageNum;}},// 电子签名层点击事件handleSignatureLayerClick() {// 取消任何选中的签名this.selectedSignature = null;},// 获取页面已放置的签名getPageSignatures(pageNum) {return this.placedSignatures.filter((sig) => sig.page === pageNum);},// 检查签名是否已放置isSignaturePlaced(signatureId) {return this.placedSignatures.some((sig) => sig.id === signatureId);},// 获取签名样式getSignatureStyle(signature) {const placedSignature = this.placedSignatures.find((sig) => sig.id === signature.id);if (placedSignature) {// 基本样式let style = {position: "absolute",left: `${placedSignature.x}px`,top: `${placedSignature.y}px`,transform: `rotate(${placedSignature.angle}deg) scale(${placedSignature.scale})`,transformOrigin: "center center",zIndex: 10, // 确保签名在其他内容之上};// 对于文字标注,使用auto尺寸让容器适应内容if (placedSignature.type === "text") {style.width = "auto";style.height = "auto";style.display = "inline-block";// 设置最小尺寸,确保操作控件能够正确显示style.minWidth = "20px";style.minHeight = "16px";} else {// 图片签名使用固定尺寸style.width = `${placedSignature.width}px`;style.height = `${placedSignature.height}px`;}// 在单页模式下,签名坐标需要根据画布缩放进行调整if (this.viewMode === "single") {// 应用缩放比例调整坐标和尺寸const scaledX = placedSignature.x * this.singlePageScaleX;const scaledY = placedSignature.y * this.singlePageScaleY;style.left = `${scaledX}px`;style.top = `${scaledY}px`;if (placedSignature.type !== "text") {const scaledWidth = placedSignature.width * this.singlePageScaleX;const scaledHeight = placedSignature.height * this.singlePageScaleY;style.width = `${scaledWidth}px`;style.height = `${scaledHeight}px`;}// 保持原有的旋转和缩放变换style.transform = `rotate(${placedSignature.angle}deg) scale(${placedSignature.scale})`;}return style;}return {};},// 获取文字标注样式getTextStyle(textAnnotation) {const style = {color: textAnnotation.color || "#000000",fontSize: textAnnotation.fontSize || "16px",textAlign: textAnnotation.align || "left",fontFamily: "Arial, sans-serif",lineHeight: "1.2",userSelect: "none",};return style;},// 放置签名placeSignature(pageNum, signature) {let x = this.previewPosition.x;let y = this.previewPosition.y;let signatureWidth = 100;let signatureHeight = 50;let scale = signature.scale || 1;let rotate = signature.rotate || 0;let type = signature.type || "signature";let image = signature.image;// 优先用签名模板自带宽高if (signature.width) signatureWidth = signature.width;if (signature.height) signatureHeight = signature.height;// 保持x/y/width/height为CSS像素,页面渲染和交互不变// 生成唯一IDconst signatureId =signature.id ||`sig_${Date.now()}_${Math.floor(Math.random() * 10000)}`;// 组装签名对象,确保导出PDF时信息完整const newSignature = {id: signatureId,type: type,image: image,x: x,y: y,// store base width/height and current width/height derived from scalebaseWidth: signatureWidth,baseHeight: signatureHeight,width: signatureWidth,height: signatureHeight,scale: scale,page: pageNum,rotate: rotate,name: signature.name || "",createTime: signature.createTime || new Date().toISOString(),};this.placedSignatures.push(newSignature);this.selectedSignature = null; // 取消选中this.pendingSignature = null;},// 删除签名deleteSignatureFromPdf(signatureId) {this.placedSignatures = this.placedSignatures.filter((sig) => sig.id !== signatureId);this.selectedSignature = null;},// 旋转签名90度rotateSignature90(signatureId) {const signature = this.placedSignatures.find((sig) => sig.id === signatureId);if (signature) {signature.angle = (signature.angle + 90) % 360;}},// 开始拖拽缩放handleResizeStart(event, signature) {event.preventDefault();event.stopPropagation();this.selectedSignature = signature.id;this.isResizing = true;// 记录初始位置和尺寸this.resizeStartPos = {x: event.touches ? event.touches[0].clientX : event.clientX,y: event.touches ? event.touches[0].clientY : event.clientY,};const signatureData = this.placedSignatures.find((sig) => sig.id === signature.id);if (signatureData) {this.resizeStartSize = {width: signatureData.width,height: signatureData.height,scale: signatureData.scale,};}},// 拖拽放大签名handleSignatureResize(event) {if (this.isResizing && this.selectedSignature) {event.preventDefault();const currentPos = {x: event.touches ? event.touches[0].clientX : event.clientX,y: event.touches ? event.touches[0].clientY : event.clientY,};// 计算移动距离const dx = currentPos.x - this.resizeStartPos.x;const dy = currentPos.y - this.resizeStartPos.y;// 使用对角线距离来计算缩放比例,支持正负方向const distance = Math.sqrt(dx * dx + dy * dy);// 判断拖拽方向:向右下角为正(放大),向左上角为负(缩小)const direction = dx + dy >= 0 ? 1 : -1;const signedDistance = distance * direction;// 计算新的缩放比例const scaleFactor = this.resizeStartSize.scale + signedDistance / 120; // 每120px变化1倍const signature = this.placedSignatures.find((sig) => sig.id === this.selectedSignature);if (signature) {// 限制缩放范围(0.5x 到 1.5x)const newScale = Math.max(0.5, Math.min(1.5, scaleFactor));signature.scale = newScale;// 更新宽高为基准尺寸乘以当前缩放,比直接叠加scale更稳健if (typeof signature.baseWidth === "number") {signature.width = signature.baseWidth * newScale;} else {signature.width = 100 * newScale;}if (typeof signature.baseHeight === "number") {signature.height = signature.baseHeight * newScale;} else {signature.height = 50 * newScale;}}}},// 结束拖拽缩放handleResizeEnd() {this.isResizing = false;this.resizeStartPos = { x: 0, y: 0 };this.resizeStartSize = { width: 0, height: 0, scale: 1 };},// 开始拖拽签名handleSignatureTouchStart(event, signature) {event.preventDefault();event.stopPropagation();this.selectedSignature = signature.id;this.isDragging = true;this.dragStartPos = {x: event.touches ? event.touches[0].clientX : event.clientX,y: event.touches ? event.touches[0].clientY : event.clientY,};},handleSignatureMouseDown(event, signature) {event.preventDefault();event.stopPropagation();this.selectedSignature = signature.id;this.isDragging = true;this.dragStartPos = {x: event.clientX,y: event.clientY,};},// 拖拽签名handleSignatureDrag(event) {if (this.isDragging && this.selectedSignature) {event.preventDefault();const currentPos = {x: event.touches ? event.touches[0].clientX : event.clientX,y: event.touches ? event.touches[0].clientY : event.clientY,};const dx = currentPos.x - this.dragStartPos.x;const dy = currentPos.y - this.dragStartPos.y;const signature = this.placedSignatures.find((sig) => sig.id === this.selectedSignature);if (signature) {// 获取PDF画布的边界const canvas = this.$refs[`pdfCanvas${signature.page}`];if (canvas && canvas[0]) {const canvasElement = canvas[0];// 直接使用原始距离let newX = signature.x + dx;let newY = signature.y + dy;// 获取签名的实际尺寸(考虑缩放)let signatureWidth, signatureHeight;if (signature.type === "text") {// 文字标注使用默认最小尺寸signatureWidth = 50; // 默认最小宽度signatureHeight = 20; // 默认最小高度} else {// 图片签名使用基准尺寸乘以当前缩放(避免重复乘以已经包含scale的width)const baseW =typeof signature.baseWidth === "number"? signature.baseWidth: signature.width;const baseH =typeof signature.baseHeight === "number"? signature.baseHeight: signature.height;signatureWidth = baseW * (signature.scale || 1);signatureHeight = baseH * (signature.scale || 1);}// 获取签名层的实际尺寸(已同步为画布尺寸)const signatureLayer = document.querySelector(`[data-page="${signature.page}"] .signature-layer`);let layerWidth, layerHeight;if (signatureLayer) {layerWidth = parseFloat(signatureLayer.style.width || signatureLayer.offsetWidth);layerHeight = parseFloat(signatureLayer.style.height || signatureLayer.offsetHeight);} else {// 备用方案:使用画布尺寸const canvasStyle = window.getComputedStyle(canvasElement);layerWidth = parseFloat(canvasStyle.width);layerHeight = parseFloat(canvasStyle.height);}// 限制拖拽范围不超出签名层边界const minX = 0;const maxX = layerWidth - signatureWidth;const minY = 0;const maxY = layerHeight - signatureHeight;// 应用边界限制newX = Math.max(minX, Math.min(maxX, newX));newY = Math.max(minY, Math.min(maxY, newY));signature.x = newX;signature.y = newY;} else {// 如果无法获取画布信息,使用原始逻辑signature.x += dx;signature.y += dy;}this.dragStartPos = currentPos;}}},// 结束拖拽签名handleSignatureDragEnd() {this.isDragging = false;this.dragStartPos = { x: 0, y: 0 };},// 开始旋转签名handleRotateStart(event, signature) {if (this.isAnnotationMode) {this.selectedSignature = signature.id;this.isRotating = true;this.rotateStartAngle = this.placedSignatures.find((sig) => sig.id === signature.id).angle;}},// 旋转签名handleSignatureRotate(event) {if (this.isAnnotationMode && this.selectedSignature) {const currentPos = {x: event.touches ? event.touches[0].clientX : event.clientX,y: event.touches ? event.touches[0].clientY : event.clientY,};const dx = currentPos.x - this.dragStartPos.x;const dy = currentPos.y - this.dragStartPos.y;const newAngle = this.rotateStartAngle + (dx - dy) * 0.5; // 简单的旋转计算this.placedSignatures.find((sig) => sig.id === this.selectedSignature).angle = newAngle;this.dragStartPos = currentPos;this.$nextTick(async () => {await this.renderPage(this.currentPage);});}},// 结束旋转签名handleRotateEnd() {this.isRotating = false;this.dragStartPos = { x: 0, y: 0 };},// 开始缩放签名handleScaleStart(event, signature) {if (this.isAnnotationMode) {this.selectedSignature = signature.id;this.isScaling = true;this.scaleStartDistance = this.getTouchDistance(event.touches ? event.touches[0] : event,event.touches ? event.touches[1] : event);}},// 缩放签名handleSignatureScale(event) {if (this.isAnnotationMode && this.selectedSignature) {const currentDistance = this.getTouchDistance(event.touches ? event.touches[0] : event,event.touches ? event.touches[1] : event);const scaleChange = currentDistance / this.scaleStartDistance;const newScale =this.placedSignatures.find((sig) => sig.id === this.selectedSignature).scale * scaleChange;this.placedSignatures.find((sig) => sig.id === this.selectedSignature).scale = newScale;this.scaleStartDistance = currentDistance;this.$nextTick(async () => {await this.renderPage(this.currentPage);});}},// 结束缩放签名handleScaleEnd() {this.isScaling = false;this.scaleStartDistance = 0;},// 全局鼠标移动事件handleGlobalMouseMove(event) {if (this.isDragging) {this.handleSignatureDrag(event);} else if (this.isResizing) {this.handleSignatureResize(event);}},// 全局鼠标释放事件handleGlobalMouseUp() {if (this.isDragging) {this.handleSignatureDragEnd();} else if (this.isResizing) {this.handleResizeEnd();}},// 全局触摸移动事件handleGlobalTouchMove(event) {if (this.isDragging) {this.handleSignatureDrag(event);} else if (this.isResizing) {this.handleSignatureResize(event);}},// 全局触摸结束事件handleGlobalTouchEnd() {if (this.isDragging) {this.handleSignatureDragEnd();} else if (this.isResizing) {this.handleResizeEnd();}},// 同步签名层位置和尺寸以匹配PDF画布syncSignatureLayerSize(pageNum) {this.$nextTick(() => {const canvas = this.$refs[`pdfCanvas${pageNum}`];const signatureLayer = document.querySelector(`[data-page="${pageNum}"] .signature-layer`);if (canvas && canvas[0] && signatureLayer) {const canvasElement = canvas[0];// 获取画布的CSS尺寸const canvasStyle = window.getComputedStyle(canvasElement);const canvasWidth = parseFloat(canvasStyle.width);const canvasHeight = parseFloat(canvasStyle.height);// 计算画布在page-wrapper中的居中位置// page-wrapper的尺寸是容器宽度const pageWrapper = canvasElement.closest(".page-wrapper");if (pageWrapper) {const pageWrapperWidth = pageWrapper.offsetWidth;// 画布居中,所以left = (容器宽度 - 画布宽度) / 2const leftOffset = (pageWrapperWidth - canvasWidth) / 2;// 设置签名层的位置和尺寸匹配画布signatureLayer.style.left = `${leftOffset}px`;signatureLayer.style.top = `2px`; // 匹配canvas的margin-topsignatureLayer.style.width = `${canvasWidth}px`;signatureLayer.style.height = `${canvasHeight}px`;}}});},// 同步单页模式签名层尺寸syncSinglePageSignatureLayer() {this.$nextTick(() => {const canvas = this.$refs.pdfCanvas;const signatureLayer = document.querySelector(".single-page-signature-layer");if (canvas && signatureLayer) {// 获取画布的CSS尺寸和位置const canvasStyle = window.getComputedStyle(canvas);const canvasWidth = parseFloat(canvasStyle.width);const canvasHeight = parseFloat(canvasStyle.height);// 获取画布相对于容器的位置const container = canvas.closest(".single-page-wrapper");if (container) {const containerRect = container.getBoundingClientRect();const canvasRect = canvas.getBoundingClientRect();// 计算画布相对于容器的偏移const leftOffset = canvasRect.left - containerRect.left;const topOffset = canvasRect.top - containerRect.top;// 设置签名层的位置和尺寸匹配画布signatureLayer.style.position = "absolute";signatureLayer.style.left = `${leftOffset}px`;signatureLayer.style.top = `${topOffset}px`;signatureLayer.style.width = `${canvasWidth}px`;signatureLayer.style.height = `${canvasHeight}px`;signatureLayer.style.pointerEvents = "none"; // 禁用交互// 计算缩放比例,用于调整签名位置和大小this.calculateSinglePageScale(canvasWidth, canvasHeight);}}});},// 计算单页模式的缩放比例calculateSinglePageScale(singlePageCanvasWidth, singlePageCanvasHeight) {// 获取连续模式下的参考画布尺寸(第一页)const continuousCanvas = this.$refs[`pdfCanvas1`];if (continuousCanvas && continuousCanvas[0]) {const continuousStyle = window.getComputedStyle(continuousCanvas[0]);const continuousWidth = parseFloat(continuousStyle.width);const continuousHeight = parseFloat(continuousStyle.height);if (continuousWidth > 0 && continuousHeight > 0) {// 计算缩放比例this.singlePageScaleX = singlePageCanvasWidth / continuousWidth;this.singlePageScaleY = singlePageCanvasHeight / continuousHeight;console.log(`单页模式缩放比例: X=${this.singlePageScaleX.toFixed(2)}, Y=${this.singlePageScaleY.toFixed(2)}`);} else {// 如果无法获取连续模式画布尺寸,使用默认比例this.singlePageScaleX = 1.0;this.singlePageScaleY = 1.0;}} else {// 默认比例this.singlePageScaleX = 1.0;this.singlePageScaleY = 1.0;}},// 关闭文字标注弹窗closeTextModal() {this.showTextModal = false;this.resetTextModal();},// 添加文字标注addTextAnnotation() {if (!this.textInput.trim()) {return;}// 保存当前的输入值,避免在异步操作中被清空const textToAdd = this.textInput;const colorToAdd = this.selectedTextColor;const alignToAdd = this.selectedTextAlign;const sizeToAdd = "16px"; // 使用默认字体大小// 只在连续滚动模式下允许放置文字标注if (this.viewMode === "continuous") {// 获取当前可视区域中心位置作为放置位置this.$nextTick(() => {const container = document.querySelector(".pdf-canvas-container");const continuousPages = document.querySelector(".continuous-pages");if (container && continuousPages) {// 找到可视区域中心对应的页面const containerRect = container.getBoundingClientRect();const containerCenterY =containerRect.top + containerRect.height / 2;let targetPageNum = this.visiblePage || 1;let targetCanvas = null;// 遍历所有页面,找到包含可视区域中心的页面for (let pageNum = 1; pageNum <= this.totalPages; pageNum++) {const canvas = this.$refs[`pdfCanvas${pageNum}`];if (canvas && canvas[0]) {const canvasRect = canvas[0].getBoundingClientRect();if (containerCenterY >= canvasRect.top &&containerCenterY <= canvasRect.bottom) {targetPageNum = pageNum;targetCanvas = canvas[0];break;}}}if (targetCanvas) {// 计算放置位置const visibleCenterX = containerRect.width / 2;const visibleCenterY = containerRect.height / 2;// 使用签名层计算位置const signatureLayer = document.querySelector(`[data-page="${targetPageNum}"] .signature-layer`);let originalCanvasX, originalCanvasY;if (signatureLayer) {const signatureLayerRect = signatureLayer.getBoundingClientRect();const viewportAbsCenterX = containerRect.left + visibleCenterX;const viewportAbsCenterY = containerRect.top + visibleCenterY;originalCanvasX = viewportAbsCenterX - signatureLayerRect.left;originalCanvasY = viewportAbsCenterY - signatureLayerRect.top;} else {originalCanvasX = visibleCenterX;originalCanvasY = visibleCenterY;}// 创建文字标注对象this.placeTextAnnotation(targetPageNum,{x: originalCanvasX - 50, // 中心X - 文字宽度的一半y: originalCanvasY - 10, // 中心Y - 文字高度的一半},textToAdd,colorToAdd,alignToAdd,sizeToAdd);} else {// 备用方案:使用画布中心this.placeTextAnnotation(this.visiblePage || 1,{ x: 200, y: 200 },textToAdd,colorToAdd,alignToAdd,sizeToAdd);}} else {// 备用方案this.placeTextAnnotation(this.visiblePage || 1,{ x: 200, y: 200 },textToAdd,colorToAdd,alignToAdd,sizeToAdd);}});this.closeTextModal(); // closeTextModal内部已经调用了resetTextModal} else {if (this.viewMode === "annotation") {alert("批注模式下无法放置文字标注,请先退出批注模式");} else {alert("请先切换到浏览模式以放置文字标注");}this.closeTextModal();}},// 放置文字标注async placeTextAnnotation(pageNum, position, text, color, align, fontSize) {// 先放置,等DOM渲染后再取宽高const newTextAnnotation = {id: `text_${Date.now()}`,type: "text",text: text,color: color,align: align,fontSize: fontSize,page: pageNum,x: Math.max(10, position.x),y: Math.max(10, position.y),angle: 0,scale: 1,// width/height 稍后赋值};this.placedSignatures.push(newTextAnnotation);this.selectedSignature = null;// 等待DOM渲染await this.$nextTick();// 找到刚刚插入的DOM元素const pageLayer = document.querySelector(`[data-page="${pageNum}"] .signature-layer`);if (pageLayer) {// 通过id找到对应的text-annotationconst textNodes = pageLayer.querySelectorAll(".text-annotation");let found = null;textNodes.forEach((node) => {if (node.textContent === text) found = node;});if (found) {const w = found.offsetWidth;const h = found.offsetHeight;// 更新placedSignatures里最后一个(刚插入的)const last = this.placedSignatures[this.placedSignatures.length - 1];if (last && last.id === newTextAnnotation.id) {this.$set(last, "width", w);this.$set(last, "height", h);}}}},// 重置文字标注弹窗resetTextModal() {this.textInput = "";this.selectedTextColor = "#000000";this.selectedTextAlign = "left";},// ========== 绘图批注相关方法 ==========// 初始化绘图画布initDrawingCanvas() {this.$nextTick(() => {const pdfCanvas = this.$refs.pdfCanvas;const drawingCanvas = this.$refs.drawingCanvas;if (pdfCanvas && drawingCanvas) {// 设置绘图画布尺寸与PDF画布一致const pdfRect = pdfCanvas.getBoundingClientRect();drawingCanvas.width = pdfCanvas.width;drawingCanvas.height = pdfCanvas.height;drawingCanvas.style.width = pdfRect.width + "px";drawingCanvas.style.height = pdfRect.height + "px";// 获取绘图上下文this.drawingContext = drawingCanvas.getContext("2d");this.drawingContext.lineCap = "round";this.drawingContext.lineJoin = "round";// 初始化当前页面的批注存储if (!this.drawingStrokesByPage[this.currentPage]) {this.drawingStrokesByPage[this.currentPage] = [];}// 重新绘制当前页面的批注this.redrawCurrentPageStrokes();}});},// 设置绘图模式setDrawingMode(mode) {this.drawingMode = mode;// 橡皮擦模式不再使用destination-out,而是改为笔画删除模式// 画笔模式正常绘制if (this.drawingContext && mode === "pen") {this.drawingContext.globalCompositeOperation = "source-over";}// 改变鼠标样式const canvas = this.$refs.drawingCanvas;if (canvas) {if (mode === "eraser") {canvas.style.cursor = "grab";} else {canvas.style.cursor = "crosshair";}}},// 检查点击位置是否在笔画路径上isPointOnStroke(point, stroke) {const tolerance = Math.max(stroke.width, 10); // 容错范围,至少10像素for (let i = 0; i < stroke.points.length - 1; i++) {const p1 = stroke.points[i];const p2 = stroke.points[i + 1];// 计算点到线段的距离const distance = this.pointToLineDistance(point, p1, p2);if (distance <= tolerance) {return true;}}return false;},// 计算点到线段的距离pointToLineDistance(point, lineStart, lineEnd) {const A = point.x - lineStart.x;const B = point.y - lineStart.y;const C = lineEnd.x - lineStart.x;const D = lineEnd.y - lineStart.y;const dot = A * C + B * D;const lenSq = C * C + D * D;if (lenSq === 0) {// 线段长度为0,返回点到起点的距离return Math.sqrt(A * A + B * B);}let t = dot / lenSq;t = Math.max(0, Math.min(1, t)); // 限制在线段范围内const projection = {x: lineStart.x + t * C,y: lineStart.y + t * D,};const dx = point.x - projection.x;const dy = point.y - projection.y;return Math.sqrt(dx * dx + dy * dy);},// 重新绘制当前页面的所有笔画redrawCurrentPageStrokes() {if (!this.drawingContext) return;const canvas = this.$refs.drawingCanvas;this.drawingContext.clearRect(0, 0, canvas.width, canvas.height);// 获取当前页面的笔画const currentPageStrokes =this.drawingStrokesByPage[this.currentPage] || [];// 重新绘制当前页面的所有笔画currentPageStrokes.forEach((stroke) => {if (stroke.points.length > 1) {this.drawingContext.beginPath();this.drawingContext.strokeStyle = stroke.color;this.drawingContext.lineWidth = stroke.width;this.drawingContext.lineCap = "round";this.drawingContext.lineJoin = "round";this.drawingContext.globalCompositeOperation = "source-over";this.drawingContext.moveTo(stroke.points[0].x, stroke.points[0].y);for (let i = 1; i < stroke.points.length; i++) {this.drawingContext.lineTo(stroke.points[i].x, stroke.points[i].y);}this.drawingContext.stroke();}});},// 清理绘图数据clearDrawingData() {this.isDrawing = false;this.drawingCanvas = null;this.drawingContext = null;this.drawingStrokesByPage = {};this.currentStroke = [];this.currentStrokeId = 0;},// ========== 批注模式相关方法 ==========// 初始化所有页面的绘图画布initAllDrawingCanvases() {this.$nextTick(() => {for (let pageNum = 1; pageNum <= this.totalPages; pageNum++) {this.initSingleDrawingCanvas(pageNum);}});},// 初始化单个页面的绘图画布initSingleDrawingCanvas(pageNum) {const pdfCanvas = this.$refs[`pdfCanvas${pageNum}`];const drawingCanvases = this.$refs[`drawingCanvas${pageNum}`];if (pdfCanvas && pdfCanvas[0] && drawingCanvases && drawingCanvases[0]) {const pdfCanvasElement = pdfCanvas[0];const drawingCanvas = drawingCanvases[0];// 设置绘图画布尺寸与PDF画布一致const pdfRect = pdfCanvasElement.getBoundingClientRect();drawingCanvas.width = pdfCanvasElement.width;drawingCanvas.height = pdfCanvasElement.height;drawingCanvas.style.width = pdfRect.width + "px";drawingCanvas.style.height = pdfRect.height + "px";// 获取绘图上下文const context = drawingCanvas.getContext("2d");context.lineCap = "round";context.lineJoin = "round";// 初始化该页面的批注存储if (!this.drawingStrokesByPage[pageNum]) {this.drawingStrokesByPage[pageNum] = [];}// 重新绘制该页面的批注this.redrawPageStrokes(pageNum);}},// 同步批注模式签名层尺寸syncAnnotationSignatureLayerSize(pageNum) {this.$nextTick(() => {const canvas = this.$refs[`pdfCanvas${pageNum}`];const signatureLayer = document.querySelector(`[data-page="${pageNum}"] .annotation-signature-layer`);if (canvas && canvas[0] && signatureLayer) {const canvasElement = canvas[0];// 获取画布的CSS尺寸const canvasStyle = window.getComputedStyle(canvasElement);const canvasWidth = parseFloat(canvasStyle.width);const canvasHeight = parseFloat(canvasStyle.height);// 计算画布在page-wrapper中的居中位置const pageWrapper = canvasElement.closest(".page-wrapper");if (pageWrapper) {const pageWrapperWidth = pageWrapper.offsetWidth;const leftOffset = (pageWrapperWidth - canvasWidth) / 2;// 设置签名层的位置和尺寸匹配画布signatureLayer.style.left = `${leftOffset}px`;signatureLayer.style.top = `2px`;signatureLayer.style.width = `${canvasWidth}px`;signatureLayer.style.height = `${canvasHeight}px`;}}});},// 开始绘图(支持多页面)startDrawing(event, pageNum) {if (!this.isAnnotationMode || this.viewMode !== "annotation") return;event.preventDefault();const pos = this.getDrawingPosition(event, pageNum);if (this.drawingMode === "pen") {// 画笔模式:正常绘制this.isDrawing = true;this.currentStroke = [pos];this.currentStrokeId++;this.currentDrawingPage = pageNum; // 记录当前绘制的页面const drawingCanvases = this.$refs[`drawingCanvas${pageNum}`];if (drawingCanvases && drawingCanvases[0]) {const context = drawingCanvases[0].getContext("2d");context.beginPath();context.moveTo(pos.x, pos.y);context.strokeStyle = this.penColor;context.lineWidth = this.penWidth;context.globalCompositeOperation = "source-over";}} else if (this.drawingMode === "eraser") {// 橡皮擦模式:检测点击的笔画并删除this.eraseStrokeAtPosition(pos, pageNum);}},// 绘图中(支持多页面)drawing(event, pageNum) {if (!this.isDrawing ||!this.isAnnotationMode ||this.drawingMode !== "pen" ||this.currentDrawingPage !== pageNum)return;event.preventDefault();const pos = this.getDrawingPosition(event, pageNum);this.currentStroke.push(pos);const drawingCanvases = this.$refs[`drawingCanvas${pageNum}`];if (drawingCanvases && drawingCanvases[0]) {const context = drawingCanvases[0].getContext("2d");context.lineTo(pos.x, pos.y);context.stroke();}},// 停止绘图(支持多页面)stopDrawing(event, pageNum) {if (!this.isDrawing ||this.drawingMode !== "pen" ||this.currentDrawingPage !== pageNum)return;this.isDrawing = false;// 保存当前笔画到指定页面if (this.currentStroke.length > 0) {if (!this.drawingStrokesByPage[pageNum]) {this.drawingStrokesByPage[pageNum] = [];}this.drawingStrokesByPage[pageNum].push({id: this.currentStrokeId,points: [...this.currentStroke],color: this.penColor,width: this.penWidth,});this.currentStroke = [];}this.currentDrawingPage = null;},// 获取绘图位置(支持多页面)getDrawingPosition(event, pageNum) {const drawingCanvases = this.$refs[`drawingCanvas${pageNum}`];if (!drawingCanvases || !drawingCanvases[0]) return { x: 0, y: 0 };const canvas = drawingCanvases[0];const rect = canvas.getBoundingClientRect();const clientX = event.touches ? event.touches[0].clientX : event.clientX;const clientY = event.touches ? event.touches[0].clientY : event.clientY;const scaleX = canvas.width / rect.width;const scaleY = canvas.height / rect.height;return {x: (clientX - rect.left) * scaleX,y: (clientY - rect.top) * scaleY,};},// 橡皮擦:删除指定页面点击位置的笔画eraseStrokeAtPosition(clickPos, pageNum) {const currentPageStrokes = this.drawingStrokesByPage[pageNum] || [];// 从后往前遍历(最新的笔画优先)for (let i = currentPageStrokes.length - 1; i >= 0; i--) {const stroke = currentPageStrokes[i];// 检查点击位置是否在笔画路径上if (this.isPointOnStroke(clickPos, stroke)) {// 删除这条笔画currentPageStrokes.splice(i, 1);// 重新绘制该页面的所有笔画this.redrawPageStrokes(pageNum);break; // 只删除一条笔画}}},// 重新绘制指定页面的所有笔画redrawPageStrokes(pageNum) {const drawingCanvases = this.$refs[`drawingCanvas${pageNum}`];if (!drawingCanvases || !drawingCanvases[0]) return;const canvas = drawingCanvases[0];const context = canvas.getContext("2d");context.clearRect(0, 0, canvas.width, canvas.height);// 获取该页面的笔画const pageStrokes = this.drawingStrokesByPage[pageNum] || [];// 重新绘制该页面的所有笔画pageStrokes.forEach((stroke) => {if (stroke.points.length > 1) {context.beginPath();context.strokeStyle = stroke.color;context.lineWidth = stroke.width;context.lineCap = "round";context.lineJoin = "round";context.globalCompositeOperation = "source-over";context.moveTo(stroke.points[0].x, stroke.points[0].y);for (let i = 1; i < stroke.points.length; i++) {context.lineTo(stroke.points[i].x, stroke.points[i].y);}context.stroke();}});},// 清除指定页面的绘图clearPageDrawing(pageNum) {const drawingCanvases = this.$refs[`drawingCanvas${pageNum}`];if (drawingCanvases && drawingCanvases[0]) {const canvas = drawingCanvases[0];const context = canvas.getContext("2d");context.clearRect(0, 0, canvas.width, canvas.height);// 清除该页面的所有笔画if (this.drawingStrokesByPage[pageNum]) {this.drawingStrokesByPage[pageNum] = [];}}},// 清除当前页面的绘图(重写原方法以支持批注模式)clearDrawing() {if (this.viewMode === "annotation") {// 批注模式:清除当前可见页面的绘图this.clearPageDrawing(this.visiblePage || 1);} else if (this.drawingContext) {// 单页模式:清除绘图画布const canvas = this.$refs.drawingCanvas;this.drawingContext.clearRect(0, 0, canvas.width, canvas.height);if (this.drawingStrokesByPage[this.currentPage]) {this.drawingStrokesByPage[this.currentPage] = [];}this.currentStroke = [];}},// 保存批注saveAnnotations() {// 这里可以实现将批注保存到PDF或服务器的逻辑// 例如:可以将批注数据转换为图片并叠加到PDF上},// 清理批注数据clearAnnotationData() {// 清理批注模式的数据,但保留已保存的批注this.isDrawing = false;this.currentStroke = [];this.currentDrawingPage = null;// 注意:不清理 this.drawingStrokesByPage,因为用户可能想保留批注},// 批注模式专用滚动方法scrollToPageInAnnotationMode(pageNum) {// 临时移除批注模式的滚动限制const container = document.querySelector(".pdf-canvas-container");if (!container) {return;}// 完全重置容器样式以确保可以滚动container.style.overflow = "auto";container.style.overflowY = "auto";container.style.overflowX = "hidden";container.style.touchAction = "auto";container.style.pointerEvents = "auto";// 找到目标页面const canvas = this.$refs[`pdfCanvas${pageNum}`];if (canvas && canvas[0]) {const canvasElement = canvas[0];// 找到页面包装器来计算更准确的滚动位置const pageWrapper = canvasElement.closest(".page-wrapper");let targetScrollTop;if (pageWrapper) {// 使用页面包装器的位置targetScrollTop = pageWrapper.offsetTop - 20; // 页面顶部留20px空间} else {// 备用方案:使用画布位置,但确保不为负数targetScrollTop = Math.max(0, canvasElement.offsetTop - 20);}// 确保目标位置在有效范围内const maxScroll = container.scrollHeight - container.clientHeight;targetScrollTop = Math.max(0, Math.min(targetScrollTop, maxScroll));// 先尝试立即设置container.scrollTop = targetScrollTop;// 如果立即设置失败,尝试scrollToif (Math.abs(container.scrollTop - targetScrollTop) > 10) {container.scrollTo({top: targetScrollTop,behavior: "auto", // 使用auto而不是smooth,避免动画问题});}// 更新页面指示器this.visiblePage = pageNum;// 2秒后恢复批注模式的滚动限制(但只在仍处于批注模式时才恢复)const timerId = setTimeout(() => {// 检查是否仍在批注模式,如果已退出则不恢复限制if (this.viewMode === "annotation" && this.isAnnotationMode) {container.style.overflow = "hidden";container.style.overflowY = "hidden";container.style.touchAction = "none";// 恢复批注模式滚动限制} else {// 已退出批注模式,保持连续滚动状态}// 从跟踪数组中移除这个定时器const index = this.scrollRestoreTimers.indexOf(timerId);if (index > -1) {this.scrollRestoreTimers.splice(index, 1);}}, 2000);// 跟踪这个定时器this.scrollRestoreTimers.push(timerId);}},},
};
</script><style lang="scss" scoped>
.pdf-container {display: flex;flex-direction: column;height: 100vh;background-color: #f1f1f1;position: relative;
}/* 顶部导航栏 */
.header {position: fixed;top: 0;left: 0;right: 0;display: flex;align-items: center;height: 44px;padding: 0 15px;background-color: #ffffff;box-shadow: 0 1px 5px rgba(0, 0, 0, 0.1);z-index: 100;.header-left {width: 40px;text-align: left;.iconfont {font-size: 24px;cursor: pointer;}}.header-title {flex: 1;text-align: center;font-size: 16px;color: #333;overflow: hidden;text-overflow: ellipsis;white-space: nowrap;}.header-right {width: 60px;display: flex;justify-content: space-between;.iconfont {font-size: 20px;padding: 0 5px;cursor: pointer;}}
}/* PDF内容区域 */
.pdf-content {position: fixed;top: 44px;bottom: 60px;left: 0;right: 0;background: #f5f5f5;display: flex;flex-direction: column;.pdf-loading {display: flex;align-items: center;justify-content: center;height: 100%;color: #999;font-size: 14px;}.pdf-error {display: flex;flex-direction: column;align-items: center;justify-content: center;height: 100%;color: #e74c3c;font-size: 14px;button {margin-top: 10px;padding: 8px 16px;border: 1px solid #e74c3c;border-radius: 4px;background: #fff;color: #e74c3c;cursor: pointer;&:hover {background: #e74c3c;color: #fff;}}}.pdf-viewer {display: flex;flex-direction: column;height: 100%;.pdf-canvas-container {flex: 1;display: flex;justify-content: center;align-items: flex-start;background: #ffffff;overflow-y: auto;overflow-x: hidden;padding: 0;touch-action: pan-y pinch-zoom;-webkit-touch-callout: none;-webkit-user-select: none;user-select: none;position: relative;width: 100%;/* 确保缩放只在此容器内生效 */contain: layout style paint;&.single-page-mode {overflow: hidden;align-items: center;justify-content: center;cursor: default;}&.annotation-mode {/* 默认禁用用户手动滚动,但允许程序化滚动 */overflow-y: hidden; /* 初始禁用滚动 */overflow-x: hidden;touch-action: none; /* 禁用手势操作 */cursor: crosshair; /* 批注模式下的鼠标样式 *//* 隐藏滚动条 */scrollbar-width: none; /* Firefox */-ms-overflow-style: none; /* IE and Edge */&::-webkit-scrollbar {display: none; /* Chrome, Safari, Opera */}/* 确保可以进行程序化滚动 */scroll-behavior: smooth;/* 当临时启用滚动时的样式 */&.temp-scroll-enabled {overflow-y: auto;}}&:not(.single-page-mode) {cursor: grab;&:active {cursor: grabbing;}}.continuous-pages {display: flex;flex-direction: column;// gap: 20px;padding: 10px;align-items: center;width: 100%;max-width: 100%;box-sizing: border-box;transition: transform 0.1s ease-out;transform-origin: center center;/* 确保缩放时内容保持在容器内 */will-change: transform;}.page-wrapper {position: relative;width: 100%;height: 100%;display: flex;justify-content: center;align-items: center;}.pdf-canvas {max-width: calc(100% - 20px);border: 1px solid #ddd;box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);background: white;pointer-events: none;margin: 2px 0;display: block;// 单页模式下的样式.single-page-mode & {max-width: 100%;max-height: 100%;margin: 0;}}// 单页模式包装器.single-page-wrapper {position: relative;width: 100%;height: 100%;display: flex;justify-content: center;align-items: center;}.signature-layer {position: absolute;pointer-events: auto; /* 允许签名层接收点击事件 */z-index: 5; /* 确保签名层在PDF内容之上 *//* 动态设置位置和尺寸以匹配对应的canvas */&.single-page-signature-layer {pointer-events: none; /* 单页模式下禁用交互 */}&.annotation-signature-layer {pointer-events: none; /* 批注模式下禁用签名层交互 */}}.placed-signature {position: absolute;cursor: grab;user-select: none;-webkit-user-select: none;-moz-user-select: none;-ms-user-select: none;-o-user-select: none;pointer-events: auto; /* 允许签名接收点击事件 *//* 确保容器能够适应内容 */&[style*="display: inline-block"] {/* 文字标注的特殊样式 */min-width: 30px;min-height: 20px;/* 确保事件能够正确触发 */pointer-events: auto;/* 添加一些内边距,增加可点击区域 */padding: 2px 4px;margin: -2px -4px;}&.selected {border: 2px solid #ff4757;border-radius: 4px;/* 对于文字标注,添加最小内边距确保边框可见 */&[style*="display: inline-block"] {padding: 4px;margin: -4px;}}&.readonly-signature {cursor: default;pointer-events: none; /* 只读签名不可交互 */}img {width: 100%;height: 100%;object-fit: contain;border-radius: 6px;}.text-annotation {/* 完全填充父容器 */display: block;width: 100%;height: 100%;padding: 0;margin: 0;/* 透明背景,不遮挡PDF内容 */background: transparent;border: none;box-shadow: none;/* 确保文字能够正确显示 */overflow: visible;/* 让文字自然换行 */white-space: pre-wrap;word-wrap: break-word;word-break: break-word;/* 确保文字有足够的对比度 */text-shadow: 0 0 3px rgba(255, 255, 255, 0.9),0 0 6px rgba(255, 255, 255, 0.7),1px 1px 1px rgba(255, 255, 255, 0.8),-1px -1px 1px rgba(255, 255, 255, 0.8);}.signature-controls {position: absolute;top: -40px;right: -10px;display: flex;flex-direction: row;gap: 0;z-index: 10;background: rgba(60, 60, 60, 0.95);border-radius: 20px;padding: 0;box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);backdrop-filter: blur(10px);.control-btn {min-width: 50px;height: 32px;background: transparent;color: white;display: flex;align-items: center;justify-content: center;cursor: pointer;font-size: 11px;font-weight: 500;line-height: 1;padding: 0 12px;border: none;transition: all 0.2s ease;position: relative;&:first-child {border-radius: 20px 0 0 20px;}&:last-child {border-radius: 0 20px 20px 0;}&:not(:last-child)::after {content: "";position: absolute;right: 0;top: 6px;bottom: 6px;width: 1px;background: rgba(255, 255, 255, 0.3);}&:hover {background: rgba(255, 255, 255, 0.1);}&:active {transform: scale(0.95);background: rgba(255, 255, 255, 0.2);}}.delete-btn {color: #ff6b7a;&:hover {background: rgba(255, 107, 122, 0.2);color: #ff4757;}}.rotate-btn {color: #4cd137;&:hover {background: rgba(76, 209, 55, 0.2);color: #2ed573;}}}.resize-handle {position: absolute;bottom: -8px;right: -8px;width: 18px;height: 18px;background: #ff4757;color: white;border-radius: 50%;display: flex;align-items: center;justify-content: center;cursor: nw-resize;font-size: 10px;font-weight: bold;z-index: 15;border: 2px solid white;transition: all 0.2s ease;transform: rotate(75deg);&:hover {background: #ff3742;transform: scale(1.05);}&:active {transform: scale(0.98);}}}.page-controls {position: absolute;right: 15px;top: 50%;transform: translateY(-50%);display: flex;flex-direction: column;gap: 15px;z-index: 20; /* 确保在绘图层之上 */.page-btn {width: 40px;height: 40px;border-radius: 50%;border: none;background: rgba(128, 128, 128, 0.85);color: white;display: flex;align-items: center;justify-content: center;cursor: pointer;transition: all 0.2s ease;box-shadow: 0 2px 8px rgba(0, 0, 0, 0.25);&:hover {background: rgba(96, 96, 96, 0.9);}&:active {background: rgba(64, 64, 64, 0.9);transform: scale(0.95);}&:disabled {background: rgba(200, 200, 200, 0.5);color: rgba(255, 255, 255, 0.5);cursor: not-allowed;&:hover {background: rgba(200, 200, 200, 0.5);}}.iconfont {font-size: 18px;font-weight: bold;line-height: 1;}}}.swipe-hint {position: absolute;top: 10px;right: 10px;background: rgba(0, 0, 0, 0.7);color: white;padding: 8px 12px;border-radius: 20px;font-size: 12px;opacity: 0.8;animation: fadeInOut 3s ease-in-out;pointer-events: none;span {display: flex;align-items: center;gap: 5px;}}.annotation-hint {position: absolute;top: 10px;right: 10px;background: rgba(255, 71, 87, 0.9);color: white;padding: 8px 12px;border-radius: 20px;font-size: 12px;opacity: 0.9;pointer-events: none;z-index: 15;span {display: flex;align-items: center;gap: 5px;font-weight: 500;}}}@keyframes fadeInOut {0% {opacity: 0;}20% {opacity: 0.8;}80% {opacity: 0.8;}100% {opacity: 0;}}}
}/* 横屏提示遮罩 */
.landscape-tip-overlay {position: fixed;top: 0;left: 0;right: 0;bottom: 0;background: rgba(0, 0, 0, 0.9);display: flex;align-items: center;justify-content: center;z-index: 2000;.landscape-tip-content {text-align: center;color: white;padding: 40px 30px;.rotate-icon {font-size: 80px;margin-bottom: 20px;animation: rotatePhoneReverse 2s ease-in-out infinite;}.rotate-text {font-size: 20px;font-weight: 600;margin: 0 0 10px 0;}.rotate-subtext {font-size: 16px;opacity: 0.8;margin: 0 0 30px 0;}.continue-btn {padding: 12px 24px;background: rgba(255, 255, 255, 0.2);border: 2px solid rgba(255, 255, 255, 0.5);border-radius: 25px;color: white;font-size: 16px;cursor: pointer;transition: all 0.3s ease;&:hover {background: rgba(255, 255, 255, 0.3);border-color: rgba(255, 255, 255, 0.8);}&:active {transform: scale(0.95);}}}
}@keyframes rotatePhoneReverse {0% {transform: rotate(-90deg);}25% {transform: rotate(-75deg);}75% {transform: rotate(0deg);}100% {transform: rotate(0deg);}
}/* 页码显示 */
.page-indicator {position: fixed;top: 50px;left: 10px;background: rgba(0, 0, 0, 0.8);color: white;padding: 8px 12px;border-radius: 20px;font-size: 13px;font-weight: 500;opacity: 0.95;pointer-events: none;z-index: 200;box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);backdrop-filter: blur(10px);span {font-family: "Arial", sans-serif;}
}/* 签名选择弹窗 */
.signature-modal {position: fixed;top: 0;left: 0;right: 0;bottom: 0;background: rgba(0, 0, 0, 0.5);display: flex;align-items: center;justify-content: center;z-index: 1000;padding: 20px;.signature-modal-content {background: white;border-radius: 12px;width: 100%;max-width: 400px;max-height: 80vh;overflow-y: auto;box-shadow: 0 10px 30px rgba(0, 0, 0, 0.3);.signature-header {display: flex;justify-content: space-between;align-items: center;padding: 16px 20px;border-bottom: 1px solid #eee;h3 {margin: 0;font-size: 18px;color: #333;}.close-btn {font-size: 24px;color: #999;cursor: pointer;line-height: 1;padding: 4px;&:hover {color: #666;}}}.signature-templates {display: grid;grid-template-columns: repeat(2, 1fr);gap: 15px;padding: 20px;max-height: 350px;overflow-y: auto;.signature-item {border: 1px solid #ddd;border-radius: 12px;padding: 15px;cursor: pointer;transition: all 0.2s ease;display: flex;align-items: center;justify-content: center;position: relative;aspect-ratio: 1.2; // 稍微宽一点的矩形min-height: 80px;background: #fff;&:hover {border-color: #007aff;background-color: #f8f9ff;transform: translateY(-2px);box-shadow: 0 4px 12px rgba(0, 122, 255, 0.15);}.signature-image {width: 100%;height: 100%;object-fit: contain;border-radius: 6px;}.delete-btn {position: absolute;top: -8px;right: -8px;width: 22px;height: 22px;border-radius: 50%;border: 2px solid white;background: #ff4757;color: white;font-size: 11px;font-weight: bold;cursor: pointer;display: flex;align-items: center;justify-content: center;box-shadow: 0 2px 8px rgba(255, 71, 87, 0.3);opacity: 1; /* 始终显示删除按钮 */transition: all 0.2s ease;transform: scale(1);&:hover {background: #ff3742;transform: scale(1.1);}&:active {transform: scale(0.95);}}&.add-signature {border: 2px dashed #007aff;border-color: #007aff;background: #f8faff;flex-direction: column;.add-icon {font-size: 28px;color: #007aff;font-weight: normal;margin-bottom: 4px;line-height: 1;}.add-text {color: #007aff;font-size: 11px;font-weight: 500;margin: 0;}&:hover {background-color: #e8f2ff;border-color: #0056d6;transform: translateY(-2px);box-shadow: 0 4px 12px rgba(0, 122, 255, 0.2);.add-icon {color: #0056d6;}.add-text {color: #0056d6;}}}}}}
}/* 底部工具栏 */
.footer {position: fixed;bottom: 0;left: 0;right: 0;display: flex;justify-content: space-around;align-items: center;height: 60px;background-color: #ffffff;box-shadow: 0 -1px 5px rgba(0, 0, 0, 0.1);z-index: 100;.tool-item {display: flex;flex-direction: column;align-items: center;cursor: pointer;transition: opacity 0.2s ease;&:hover {opacity: 0.8;}&:active {transform: scale(0.95);}.iconfont {font-size: 25px;margin-bottom: 3px;}span {font-size: 12px;}}
}/* 文字标注弹窗 */
.text-modal {position: fixed;top: 0;left: 0;right: 0;bottom: 0;background: rgba(0, 0, 0, 0.5);display: flex;align-items: center;justify-content: center;z-index: 1000;padding: 20px;.text-modal-content {background: white;border-radius: 12px;width: 100%;max-width: 400px;max-height: 80vh;overflow-y: auto;box-shadow: 0 10px 30px rgba(0, 0, 0, 0.3);.text-header {display: flex;justify-content: space-between;align-items: center;padding: 16px 20px;border-bottom: 1px solid #eee;h3 {margin: 0;font-size: 18px;color: #333;}.close-btn {font-size: 24px;color: #999;cursor: pointer;line-height: 1;padding: 4px;&:hover {color: #666;}}}.text-input-section {padding: 20px;.text-input {width: 100%;padding: 12px;border: 1px solid #ddd;border-radius: 6px;font-size: 14px;resize: vertical;min-height: 80px;box-sizing: border-box;&:focus {outline: none;border-color: #007aff;box-shadow: 0 0 0 2px rgba(0, 122, 255, 0.2);}}.input-counter {text-align: right;color: #999;font-size: 12px;margin-top: 5px;}}.text-options {padding: 0 20px;margin-bottom: 20px;.option-group {margin-bottom: 20px;.option-label {display: block;margin-bottom: 8px;font-weight: 600;color: #333;font-size: 14px;}.color-options {display: flex;flex-wrap: wrap;gap: 8px;.color-item {width: 32px;height: 32px;border-radius: 50%;cursor: pointer;border: 2px solid #ddd;transition: all 0.2s ease;&:hover {transform: scale(1.1);}&.active {border-color: #007aff;box-shadow: 0 0 0 2px rgba(0, 122, 255, 0.3);}}}.align-options {display: flex;gap: 8px;.align-item {width: 40px;height: 32px;border: 1px solid #ddd;border-radius: 4px;cursor: pointer;display: flex;align-items: center;justify-content: center;transition: all 0.2s ease;&:hover {border-color: #007aff;}&.active {border-color: #007aff;background-color: rgba(0, 122, 255, 0.1);}.align-icon {font-size: 14px;color: #666;}}}}}.text-actions {display: flex;justify-content: space-between;padding: 20px;border-top: 1px solid #eee;gap: 12px;.cancel-btn,.confirm-btn {flex: 1;padding: 12px 20px;border-radius: 6px;border: none;font-size: 14px;font-weight: 500;cursor: pointer;transition: all 0.2s ease;}.cancel-btn {background-color: #f5f5f5;color: #666;&:hover {background-color: #e8e8e8;}}.confirm-btn {background-color: #007aff;color: white;&:hover {background-color: #0056d6;}&:disabled {background-color: #ccc;cursor: not-allowed;&:hover {background-color: #ccc;}}}}}
}/* 绘图层和工具栏样式 */
.drawing-layer {position: absolute;top: 0;left: 0;width: 100%;height: 100%;z-index: 10;pointer-events: auto;.drawing-canvas {position: absolute;top: 0;left: 0;cursor: crosshair;touch-action: none;&.eraser-mode {cursor: grab;}}/* 只读模式下的绘图层样式 */&.readonly-drawing {pointer-events: none; /* 禁用所有交互 */z-index: 5; /* 降低层级,确保在签名层之下 */.drawing-canvas {cursor: default; /* 普通鼠标指针 */touch-action: auto; /* 恢复正常触摸行为 */}}
}/* 绘图模式底部工具栏 - 与原工具栏样式保持一致 */
.drawing-footer {position: fixed;bottom: 0;left: 0;right: 0;display: flex;justify-content: space-around;align-items: center;height: 60px;background-color: #ffffff;box-shadow: 0 -1px 5px rgba(0, 0, 0, 0.1);z-index: 100;.drawing-tool-item {display: flex;flex-direction: column;align-items: center;cursor: pointer;transition: opacity 0.2s ease;&:hover {opacity: 0.8;}&:active {transform: scale(0.95);}.drawing-icon {font-size: 25px;margin-bottom: 3px;color: #333;}span:last-child {font-size: 12px;color: #333;}// 激活状态样式&.active .drawing-icon {color: #007aff;}&.active span:last-child {color: #007aff;}// 翻页工具样式&.page-tool {&.disabled {opacity: 0.3;cursor: not-allowed;pointer-events: none;.drawing-icon {color: #ccc;}span:last-child {color: #ccc;}}&:not(.disabled):hover {opacity: 0.8;background-color: rgba(0, 122, 255, 0.1);}}}
}
</style>

2.手写签名

<template><div class="signature-container"><!-- 竖屏提示遮罩 --><div class="rotate-tip-overlay" v-show="showRotateTip"><div class="rotate-tip-content"><div class="rotate-icon">📱</div><p class="rotate-text">请将设备旋转至横屏模式</p><p class="rotate-subtext">以获得更好的签名体验</p></div></div><!-- 签名界面 --><div class="signature-main" v-show="!showRotateTip"><!-- 签名画布 --><div class="canvas-wrapper"><canvas class="signature-canvas" ref="signatureCanvas" /><!-- 悬浮工具栏 --><div class="floating-toolbar"><button class="floating-btn back-btn" @click="goBack" title="返回"><span>←</span></button><button class="floating-btn danger" @click="handleClear" title="清除"><span>✕</span></button><button class="floating-btn warning" @click="handleUndo" title="撤销"><span>↶</span></button><button class="floating-btn success" @click="handleSave" title="保存"><span>✓</span></button></div></div></div></div>
</template><script>
import SmoothSignature from "smooth-signature";export default {name: "handWrittenSignature",data() {return {signature: null,showRotateTip: false, // 是否显示旋转提示};},mounted() {// 检查屏幕方向this.checkOrientation();// 延迟初始化,确保DOM完全加载setTimeout(() => {if (!this.showRotateTip) {this.initSignature();}}, 300);// 监听窗口大小变化和屏幕方向变化window.addEventListener("resize", this.handleResize);window.addEventListener("orientationchange", this.handleOrientationChange);},beforeDestroy() {window.removeEventListener("resize", this.handleResize);window.removeEventListener("orientationchange",this.handleOrientationChange);},methods: {// 检查屏幕方向checkOrientation() {// 检查是否为竖屏const isPortrait = window.innerHeight > window.innerWidth;this.showRotateTip = isPortrait;if (!isPortrait) {// 横屏时初始化签名this.$nextTick(() => {setTimeout(() => {this.initSignature();}, 200);});}},// 处理屏幕方向变化handleOrientationChange() {setTimeout(() => {this.checkOrientation();}, 300);},// 处理窗口大小变化handleResize() {setTimeout(() => {this.checkOrientation();if (!this.showRotateTip) {this.initSignature();}}, 100);},// 初始化签名initSignature() {const canvas = this.$refs.signatureCanvas;if (!canvas) {console.error("Canvas元素未找到");return;}// 计算画布尺寸const canvasWrapper = canvas.parentElement;if (!canvasWrapper) {console.error("Canvas容器未找到");return;}// 等待DOM完全渲染this.$nextTick(() => {const rect = canvasWrapper.getBoundingClientRect();let width = rect.width - 40; // 减少左右边距let height = rect.height - 80; // 考虑上下padding和按钮空间// 兼容性处理:如果获取不到尺寸,使用窗口尺寸计算if (width <= 0 || height <= 0) {width = Math.max(window.innerWidth - 60, 300);height = Math.max(window.innerHeight - 160, 200);}// 确保最小尺寸width = Math.max(width, 250);height = Math.max(height, 150);const options = {width: width,height: height,minWidth: 2,maxWidth: 8,openSmooth: true,color: "#000000",// 移除背景色,让画布透明// bgColor: "#ffffff",};// 销毁旧实例if (this.signature) {try {this.signature.clear();} catch (e) {// 忽略清理错误}this.signature = null;}try {this.signature = new SmoothSignature(canvas, options);} catch (error) {console.error("签名组件初始化失败:", error);}});},// 清除签名handleClear() {if (this.signature) {this.signature.clear();}},// 撤销handleUndo() {if (this.signature) {this.signature.undo();}},// 生成透明背景的PNGgetTransparentPNG() {if (!this.signature) {throw new Error("签名组件未初始化");}try {// 获取原始canvas,用于后处理const originalCanvas = this.$refs.signatureCanvas;if (!originalCanvas) {// 如果找不到canvas,返回库的默认结果return this.signature.getPNG();}// 创建一个新的canvas用于生成透明背景的图片const tempCanvas = document.createElement("canvas");const tempCtx = tempCanvas.getContext("2d");// 设置相同的尺寸tempCanvas.width = originalCanvas.width;tempCanvas.height = originalCanvas.height;// 清除背景(默认就是透明的)tempCtx.clearRect(0, 0, tempCanvas.width, tempCanvas.height);// 获取原始画布的图像数据const originalCtx = originalCanvas.getContext("2d");const imageData = originalCtx.getImageData(0,0,originalCanvas.width,originalCanvas.height);const data = imageData.data;// 处理像素数据,将白色背景变为透明for (let i = 0; i < data.length; i += 4) {const r = data[i];const g = data[i + 1];const b = data[i + 2];// 如果是白色或接近白色的像素,设为透明// 但保留黑色的签名笔迹if (r > 250 && g > 250 && b > 250) {data[i + 3] = 0; // 设置alpha为0(透明)}}// 将处理后的数据绘制到新画布tempCtx.putImageData(imageData, 0, 0);// 返回base64格式的PNGreturn tempCanvas.toDataURL("image/png");} catch (error) {console.error("生成透明背景签名失败:", error);// 如果处理失败,回退到原始方法return this.signature.getPNG();}},// 保存签名handleSave() {if (!this.signature) {alert("签名组件未初始化");return;}const isEmpty = this.signature.isEmpty();if (isEmpty) {alert("请先进行签名");return;}try {// 获取画布数据,生成透明背景的PNGconst pngUrl = this.getTransparentPNG();// 生成签名ID和名称const timestamp = Date.now();const signatureId = `signature_${timestamp}`;const now = new Date();const dateStr = `${now.getMonth() +1}${now.getDate()}${now.getHours()}${now.getMinutes()}`;const signatureName = `签名${dateStr}`;// 创建签名对象const signatureData = {id: signatureId,name: signatureName,image: pngUrl,createTime: new Date().toISOString(),type: "handwritten",};// 获取现有的签名列表const existingSignatures = JSON.parse(localStorage.getItem("userSignatures") || "[]");// 添加新签名到列表开头existingSignatures.unshift(signatureData);// 限制最多保存10个签名if (existingSignatures.length > 10) {existingSignatures.splice(10);}// 保存到本地存储localStorage.setItem("userSignatures",JSON.stringify(existingSignatures));alert("签名保存成功!");// 保存成功后返回上一页setTimeout(() => {this.goBack();}, 500);} catch (error) {console.error("保存签名失败:", error);alert("保存失败,请重试");}},// 返回上一页goBack() {this.$router.go(-1);},},
};
</script><style lang="scss" scoped>
.signature-container {position: fixed;top: 0;left: 0;width: 100vw;height: 100vh;background: #f5f5f5;overflow: hidden;
}// 竖屏提示遮罩
.rotate-tip-overlay {position: absolute;top: 0;left: 0;width: 100%;height: 100%;background: rgba(0, 0, 0, 0.9);display: flex;align-items: center;justify-content: center;z-index: 1000;.rotate-tip-content {text-align: center;color: white;padding: 40px 30px;.rotate-icon {font-size: 80px;margin-bottom: 20px;animation: rotatePhone 2s ease-in-out infinite;}.rotate-text {font-size: 20px;font-weight: 600;margin: 0 0 10px 0;}.rotate-subtext {font-size: 16px;opacity: 0.8;margin: 0 0 30px 0;}.continue-btn {padding: 12px 24px;background: rgba(255, 255, 255, 0.2);border: 2px solid rgba(255, 255, 255, 0.5);border-radius: 25px;color: white;font-size: 16px;cursor: pointer;transition: all 0.3s ease;&:hover {background: rgba(255, 255, 255, 0.3);border-color: rgba(255, 255, 255, 0.8);}&:active {transform: scale(0.95);}}}
}@keyframes rotatePhone {0% {transform: rotate(0deg);}25% {transform: rotate(-15deg);}75% {transform: rotate(-90deg);}100% {transform: rotate(-90deg);}
}// 签名界面
.signature-main {width: 100%;height: 100%;display: flex;flex-direction: column;padding: 20px;box-sizing: border-box;.canvas-wrapper {flex: 1;background: white;border-radius: 16px;box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);display: flex;align-items: center;justify-content: center;position: relative;box-sizing: border-box;min-height: 0; // 确保flex布局正常工作.signature-canvas {border: 2px dashed #dee2e6;border-radius: 12px;cursor: crosshair;touch-action: none;// 使用网格背景来显示透明区域,类似PS的透明背景background: linear-gradient(45deg, #f0f0f0 25%, transparent 25%),linear-gradient(-45deg, #f0f0f0 25%, transparent 25%),linear-gradient(45deg, transparent 75%, #f0f0f0 75%),linear-gradient(-45deg, transparent 75%, #f0f0f0 75%);background-size: 20px 20px;background-position: 0 0, 0 10px, 10px -10px, -10px 0px;display: block;margin: 0 auto;}.floating-toolbar {position: absolute;left: 50%;bottom: 5px;transform: translateX(-50%);display: flex;flex-direction: row;gap: 12px;z-index: 10;pointer-events: none;.floating-btn {width: 35px;height: 35px;border: none;border-radius: 50%;cursor: pointer;transition: all 0.3s ease;font-size: 16px;font-weight: bold;display: flex;align-items: center;justify-content: center;box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);opacity: 0.9;pointer-events: all;&:hover {opacity: 1;transform: scale(1.1);box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);}&:active {transform: scale(0.95);}&.back-btn {background: rgba(108, 117, 125, 0.9);color: white;&:hover {background: rgba(90, 98, 104, 1);}}&.danger {background: rgba(220, 53, 69, 0.9);color: white;&:hover {background: rgba(200, 35, 51, 1);}}&.warning {background: rgba(253, 126, 20, 0.9);color: white;&:hover {background: rgba(232, 101, 14, 1);}}&.success {background: rgba(40, 167, 69, 0.9);color: white;&:hover {background: rgba(33, 136, 56, 1);}}}}}
}
</style>

5.仓库地址

gitee仓库地址

http://www.lryc.cn/news/621758.html

相关文章:

  • 力扣习题:基本计算器
  • Spring 工具类:StopWatch
  • Java 泛型类型擦除
  • 【递归、搜索与回溯算法】DFS解决FloodFill算法
  • Pytest项目_day17(随机测试数据)
  • JUC学习笔记-----LongAdder
  • 2025年最新油管视频下载,附MassTube下载软件地址
  • 嵌入式 C 语言编程规范个人学习笔记,参考华为《C 语言编程规范》
  • 嵌入式硬件篇---电容串并联
  • 嵌入式硬件篇---电容滤波
  • flutter开发(二)检测媒体中的静音
  • Flinksql bug: Heartbeat of TaskManager with id container_XXX timed out.
  • 对抗损失(GAN)【生成器+判断器】
  • LeetCode 922.按奇偶排序数组2
  • 大模型LLM部署与入门应用指南:核心原理、实战案例及私有化部署
  • 解决安装特定版本 anaconda-client 的错误
  • CSS从入门到精通完整指南
  • 【科研绘图系列】R语言绘制三维曲线图
  • 探索无人机图传技术:创新视野与无限可能
  • Salary Queries
  • 商品数据仓库构建指南:TB 级淘宝 API 历史详情数据归档方案
  • 8.15网络编程——UDP和TCP并发服务器
  • ​​金仓数据库KingbaseES V9R1C10安装教程 - Windows版详细指南​
  • MySQL知识点(上)
  • 复杂度扫尾+链表经典算法题
  • 开发避坑指南(27):Vue3中高效安全修改列表元素属性的方法
  • 科普:Pygame 中,`pg.Surface` v.s. `screen`
  • 力扣 hot100 Day74
  • wordpress忘记密码怎么办
  • 2025最新:如何禁止指定软件联网?