本文演示如何在前端(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:
localStorage
、devicePixelRatio
、getBoundingClientRect()
等"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渲染画布 -->
<div
class="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"
>
<!-- 连续滚动模式 - 显示所有页面 -->
<div
v-if="viewMode === 'continuous' || viewMode === 'annotation'"
class="continuous-pages"
>
<div
v-for="pageNum in totalPages"
:key="pageNum"
class="page-wrapper"
:data-page="pageNum"
>
<canvas
:ref="`pdfCanvas${pageNum}`"
class="pdf-canvas"
:data-page="pageNum"
></canvas>
<!-- 电子签名层 - 仅在连续滚动模式显示 -->
<div
v-if="viewMode === 'continuous'"
class="signature-layer"
:data-page="pageNum"
@click="handleSignatureLayerClick()"
>
<!-- 已放置的签名和文字标注 -->
<div
v-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)"
>
<!-- 图片签名 -->
<img
v-if="signature.type !== 'text'"
:src="signature.image"
:alt="signature.name"
/>
<!-- 文字标注 -->
<div
v-else
class="text-annotation"
:style="{
...getTextStyle(signature),
color: signature.color,
fontSize: signature.fontSize,
textAlign: signature.align,
}"
>
{{ signature.text }}
</div>
<!-- 控制点 -->
<div
v-if="selectedSignature === signature.id"
class="signature-controls"
>
<!-- 删除按钮 -->
<div
class="control-btn delete-btn"
@click.stop="deleteSignatureFromPdf(signature.id)"
@touchstart.stop
title="删除"
>
删除
</div>
<!-- 转90°按钮 -->
<div
class="control-btn rotate-btn"
@click.stop="rotateSignature90(signature.id)"
@touchstart.stop
title="转90°"
>
转90°
</div>
</div>
<!-- 拖拽缩放手柄 -->
<div
v-if="selectedSignature === signature.id"
class="resize-handle"
@touchstart.stop="handleResizeStart($event, signature)"
@mousedown.stop="handleResizeStart($event, signature)"
title="拖拽缩放"
>
⤢
</div>
</div>
</div>
<!-- 批注模式签名层 - 只显示,不可操作 -->
<div
v-if="viewMode === 'annotation'"
class="signature-layer annotation-signature-layer"
:data-page="pageNum"
>
<!-- 已放置的签名和文字标注(只读显示) -->
<div
v-for="signature in getPageSignatures(pageNum)"
:key="signature.id"
class="placed-signature readonly-signature"
:style="getSignatureStyle(signature)"
>
<!-- 图片签名 -->
<img
v-if="signature.type !== 'text'"
:src="signature.image"
:alt="signature.name"
/>
<!-- 文字标注 -->
<div
v-else
class="text-annotation"
:style="{
...getTextStyle(signature),
color: signature.color,
fontSize: signature.fontSize,
textAlign: signature.align,
}"
>
{{ signature.text }}
</div>
</div>
</div>
<!-- 绘图层 - 在连续滚动和批注模式下都显示,但只在批注模式下可编辑 -->
<div
v-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">
<!-- 当前页面的已放置签名和文字标注 -->
<div
v-for="signature in getPageSignatures(currentPage)"
:key="signature.id"
class="placed-signature readonly-signature"
:style="getSignatureStyle(signature)"
>
<!-- 图片签名 -->
<img
v-if="signature.type !== 'text'"
:src="signature.image"
:alt="signature.name"
/>
<!-- 文字标注 -->
<div
v-else
class="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">
<canvas
ref="drawingCanvas"
class="drawing-canvas"
@touchstart="startDrawing"
@touchmove="drawing"
@touchend="stopDrawing"
@mousedown="startDrawing"
@mousemove="drawing"
@mouseup="stopDrawing"
@mouseleave="stopDrawing"
></canvas>
</div>
</div>
<!-- 单页模式翻页按钮 -->
<div
v-if="viewMode === 'single' && totalPages > 1"
class="page-controls"
>
<button
class="page-btn prev-btn"
:disabled="currentPage <= 1"
@click="prevPage"
>
<span class="iconfont">▲</span>
</button>
<button
class="page-btn next-btn"
:disabled="currentPage >= totalPages"
@click="nextPage"
>
<span class="iconfont">▼</span>
</button>
</div>
<!-- 滑动提示 -->
<div
class="swipe-hint"
v-if="viewMode === 'continuous' && totalPages > 1"
>
<span>↑↓ 滚动浏览</span>
</div>
<!-- 批注模式提示 -->
<div
class="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">
<div
class="drawing-tool-item"
@click="setDrawingMode('pen')"
:class="{ active: drawingMode === 'pen' }"
>
<span class="drawing-icon">✏️</span>
<span>画笔</span>
</div>
<div
class="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>
<!-- 翻页按钮 -->
<div
class="drawing-tool-item page-tool"
@click="prevPage"
:class="{ disabled: visiblePage <= 1 }"
v-if="totalPages > 1"
>
<span class="drawing-icon">⬆️</span>
<span>上页</span>
</div>
<div
class="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>
<!-- 签名选择弹窗 -->
<div
v-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">
<!-- 签名列表 -->
<div
v-for="template in signatureTemplates"
:key="template.id"
class="signature-item"
@click="selectSignature(template)"
>
<img
:src="template.image"
:alt="template.name"
class="signature-image"
/>
<button
class="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">
<textarea
v-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">
<div
v-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">
<div
v-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>
<button
class="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, // 当前选中的签名ID
pendingSignature: 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);
}
},
// 渲染单个页面到指定canvas
async 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; // 减去padding
const 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 10px
const 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和footer
const 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 10px
const 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属性计算viewport
let 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 10px
const 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;
// 手动计算viewport
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 = 714; // 595 * 1.2
viewport.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 10px
const 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);
},
// 下载PDF
async downloadPdf() {
// 1. 读取原始PDF
const 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) 缩放到 imagePixelWidth
const 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");
// 嵌入并绘制到PDF
const 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. 导出PDF
const 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 {
// 备用方案:如果找不到容器或continuousPages
this.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:直接设置scrollTop
container.scrollTop = Math.max(0, targetScrollTop);
// 方式2:如果方式1失败,使用scrollTo
if (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像素,页面渲染和交互不变
// 生成唯一ID
const 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 scale
baseWidth: 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 = (容器宽度 - 画布宽度) / 2
const leftOffset = (pageWrapperWidth - canvasWidth) / 2;
// 设置签名层的位置和尺寸匹配画布
signatureLayer.style.left = `${leftOffset}px`;
signatureLayer.style.top = `2px`; // 匹配canvas的margin-top
signatureLayer.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-annotation
const 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;
// 如果立即设置失败,尝试scrollTo
if (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();
}
},
// 生成透明背景的PNG
getTransparentPNG() {
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格式的PNG
return 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 {
// 获取画布数据,生成透明背景的PNG
const 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>