在 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.pswp.cn/bicheng/93448.shtml
繁體地址,請注明出處:http://hk.pswp.cn/bicheng/93448.shtml
英文地址,請注明出處:http://en.pswp.cn/bicheng/93448.shtml

如若內容造成侵權/違法違規/事實不符,請聯系多彩編程網進行投訴反饋email:809451989@qq.com,一經查實,立即刪除!

相關文章

tomcat 定時重啟

tomcat 定時重啟 定時重啟的目的是:修復內存泄漏等問題,tomcat 長時間未重啟,導致頁面卡頓,卡死,無法訪問,影響用戶訪問 1.編寫腳本 su - tomcat [tomcat@u1abomap02 ~]$ ls restart_tomcat_gosi.sh tomcat_gosi.log vi restart_tomcat_gosi.sh #!/bin/bash# 定義日志目…

WinForm 簡單用戶登錄記錄器實現教程

目錄 功能概述 實現思路 一、程序入口&#xff08;Program.cs&#xff09; 二、登錄用戶控件&#xff08;Login.cs&#xff09; 2.1 控件初始化與密碼顯示邏輯 2.2 登錄控件設計器&#xff08;Login.Designer.cs&#xff09; 三、主窗體&#xff08;Form1.cs&#xff09…

docker 安裝 使用

Docker安裝 一鍵安裝命令 sudo curl -fsSL https://get.docker.com| bash -s docker --mirror Aliyun啟動docker sudo service docker startpull鏡像加速配置 sudo vi /etc/docker/daemon.json輸入下列內容&#xff0c;最后按ESC&#xff0c;輸入 :wq! 保存退出。 {"regis…

無人機探測器技術解析

一、工作模式 無人機探測器通過多模式協同實現全流程防御閉環&#xff1a; 1. 主動掃描模式 雷達主動探測&#xff1a;發射電磁波&#xff08;如Ka/Ku波段&#xff09;&#xff0c;通過回波時差與多普勒頻移計算目標距離、速度及航向&#xff0c;適用于廣域掃描&#xff08;…

Linux學習-軟件編程(進程與線程)

進程回收wait原型&#xff1a;pid_t wait(int *wstatus); 功能&#xff1a;回收子進程空間 參數&#xff1a;wstatus&#xff1a;存放子進程結束狀態空間的首地址 返回值&#xff1a;成功返回回收到的子進程的PID失敗返回-1WIFEXITED(wstatus)&#xff1a;測試進程是否正常結束…

大模型微調分布式訓練-大模型壓縮訓練(知識蒸餾)-大模型推理部署(分布式推理與量化部署)-大模型評估測試(OpenCompass)

大模型微調分布式訓練 LLama Factory與Xtuner分布式微調大模型 大模型分布式微調訓練的基本概念 為什么需要分布式訓練&#xff1f; 模型規模爆炸&#xff1a;現代大模型&#xff08;如GPT-3、LLaMA等&#xff09;參數量達千億級別&#xff0c;單卡GPU無法存儲完整模型。 …

物聯網、大數據與云計算持續發展,樓宇自控系統應用日益廣泛

在深圳某智慧園區的控制中心&#xff0c;管理人員通過云端平臺實時監控著5公里外園區內每臺空調的運行參數、每盞路燈的開關狀態和每個區域的能耗數據。當系統檢測到某棟樓宇的電梯運行振動異常時&#xff0c;大數據算法自動預判可能的故障點并推送維修建議&#xff1b;物聯網傳…

在實驗室連接地下車庫工控機及其數據采集設備

在實驗室連接地下車庫工控機及其數據采集設備 我們小組為項目的數據采集組&#xff0c;目前在車頂集成了一個工控機、兩個激光雷達、兩個攝像頭、一個戶外電源 由于地下車庫蚊子太多了&#xff0c;我們可受不了這個苦&#xff0c;所以想坐在實驗室吹著空調就能連接工控機來修改…

icmpsh、PingTunnel--安裝、使用

用途限制聲明&#xff0c;本文僅用于網絡安全技術研究、教育與知識分享。文中涉及的滲透測試方法與工具&#xff0c;嚴禁用于未經授權的網絡攻擊、數據竊取或任何違法活動。任何因不當使用本文內容導致的法律后果&#xff0c;作者及發布平臺不承擔任何責任。滲透測試涉及復雜技…

系統思考:情緒內耗與思維模式

我們正在努力解決的問題&#xff0c;很多時候&#xff0c;根源就在我們自己。 在日常的工作和生活中&#xff0c;我們常常感到焦慮、內耗和失控。這些情緒和狀態&#xff0c;似乎總是在不斷循環。但如果停下來仔細思考&#xff0c;會發現&#xff0c;問題的背后&#xff0c;并不…

詳解grafana k6 中stage的核心概念與作用

在Grafana k6中&#xff0c;??Stage&#xff08;階段&#xff09;?? 是負載測試腳本的核心配置概念&#xff0c;用于動態控制虛擬用戶&#xff08;VUs&#xff09;的數量隨時間的變化。通過定義多個階段&#xff0c;用戶可以模擬真實場景中的流量波動&#xff08;如用戶逐步…

JS 和 JSX 的區別

JS 和 JSX 是兩種不同的概念&#xff0c;盡管它們都與 JavaScript 密切相關&#xff0c;尤其是在 React 開發中。以下是它們的主要區別&#xff1a;1. 定義JS (JavaScript): 一種通用的編程語言&#xff0c;用于開發動態網頁、服務器端應用程序等。它是標準的 ECMAScript 語言。…

Linux軟件編程-進程(2)及線程(1)

1.進程回收資源空間&#xff08;1&#xff09;wait函數頭文件&#xff1a;#include <sys/types.h>#include <sys/wait.h>函數接口&#xff1a;pid_t wait(int *wstatus);功能&#xff1a;阻塞等待回收子進程的資源空間參數&#xff1a;wstatus &#xff1a;保存子進…

java 集合 之 集合工具類Collections

前言早期開發者經常需要對集合進行各種操作比如排序、查找最大最小值等等但是當時沒有統一的工具類來處理所以導致代碼重復且容易出錯java.util.Collections 工具類的引入為開發者提供了大量 靜態方法 來操作集合它就像一個經驗豐富的助手和數組工具類 Arrays 一樣避免了我們重…

2025 年電賽 C 題 發揮部分 1:多正方形 / 重疊正方形高精度識別與最小邊長測量

2025 年全國大學生電子設計競賽 C 題 發揮部分 1&#xff1a;多正方形 / 重疊正方形高精度識別與最小邊長測量 香橙派 OpenCV C 全流程解析 目錄 賽題背景與需求技術難點全景圖系統總體架構硬件平臺與接線軟件架構與線程模型算法流水線逐幀拆解 6.1 圖像預處理6.2 輪廓提取與…

【自動駕駛】自動駕駛概述 ② ( 自動駕駛技術路徑 | L0 ~ L5 級別自動駕駛 )

文章目錄一、自動駕駛技術路徑1、L0 級別 自動駕駛2、L1 級別 自動駕駛3、L2 級別 自動駕駛4、L3 級別 自動駕駛5、L4 級別 自動駕駛6、L5 級別 自動駕駛一、自動駕駛技術路徑 美國汽車工程師學會 ( SAE ) 將 自動駕駛 分為 L0 ~ L5 六個級別 : 其中 L0 級別 是 完全手動 , L5…

C++少兒編程(二十二)—條件結構

1.理解條件結構小朋友們&#xff0c;今天讓我們一起來探索一個神奇而有趣的知識——程序的條件結構&#xff01;首先&#xff0c;讓我們來想象一個有趣的場景。比如說&#xff0c;你們正在準備去公園玩耍。在出發之前&#xff0c;你們會看看天氣怎么樣。如果天氣晴朗&#xff0…

Ubuntu20.04下Px4使用UORB發布消息

1 .msg文件夾定義數據類型及 變量名文件位置如圖&#xff0c;在PX4-Autopilot/msg文件夾下&#xff0c;筆者創建的文件名為gps_msg.msggps_msg.msg內容如下 uint64 timestamp # 時間戳 float32 latitude float32 longitude float32 altitude 同時&#xff0c;在CM…

three.js學習記錄(第二節:鼠標控制相機移動)

效果展示&#xff1a; 鼠標控制一、鼠標控制 - 軌道控制器&#xff08;OrbitControls&#xff09; 1. 從nodeModules中導入OrbitControls&#xff0c;OrbitControls 是一個附加組件&#xff0c;必須顯式導入 import { OrbitControls } from "three/examples/jsm/controls/…

Shortest Routes II(Floyd最短路)

題目描述There are n cities and m roads between them. Your task is to process q queries where you have to determine the length of the shortest route between two given cities.輸入The first input line has three integers n, m and q: the number of cities, roads…