Vue3 使用 Signature Pad 实现电子签名(签名位置偏差问题解决)

Signature Pad 基础用法

安装与引入

首先需要安装 signature_pad 库:

复制代码
npm install signature_pad

然后在Vue组件中引入:

javascript 复制代码
import SignaturePad from "signature_pad";

基本实现

xml 复制代码
<template>
  <div>
    <div class="signature-pad-container">
      <canvas ref="signaturePad"></canvas>
    </div>
    <button @click="clear">清除</button>
    <button @click="save">保存</button>
  </div>
</template>

<script setup>
import { ref, onMounted } from 'vue'
import SignaturePad from 'signature_pad'

const signaturePad = ref(null)
let signaturePadInstance = null

onMounted(() => {
  signaturePadInstance = new SignaturePad(signaturePad.value)
})

const clear = () => {
  signaturePadInstance.clear()
}

const save = () => {
  const dataURL = signaturePadInstance.toDataURL()
  console.log(dataURL) // 输出签名图片的Base64编码
}
</script>

解决签名位置偏差问题

问题根源分析

签名位置偏差通常由以下原因导致:

  1. Canvas尺寸与显示尺寸不匹配Canvaswidth / height 属性和CSS的 width / height 样式,两者需要一致。
  2. 设备像素比(DPR)问题 :在高分辨率屏幕上,CSS 像素与设备像素不一致。
  3. 坐标系未校正:未考虑设备像素比导致的事件坐标转换错误。

解决方案

1. 获取设备像素比(DPR): 设备像素比(Device Pixel Ratio)表示一个CSS像素对应多少个设备物理像素。

javascript 复制代码
const dpr = window.devicePixelRatio || 1;

2. 正确设置Canvas尺寸: 确保了 Canvas 内部绘制缓冲区的大小与屏幕显示大小成正确比例。

javascript 复制代码
const rect = canvas.getBoundingClientRect();
canvas.width = rect.width * dpr;
canvas.height = rect.height * dpr;
canvas.style.width = `${rect.width}px`;
canvas.style.height = `${rect.height}px`;

3. 初始化签名板并调整笔迹粗细: 根据 DPI 调整笔迹粗细,确保在高分辨率设备上线条不会显得过细。

javascript 复制代码
const baseWidth = 2;
signaturePadInstance = new SignaturePad(canvas, {
  minWidth: baseWidth * dpr,
  maxWidth: baseWidth * dpr * 2,
  // 其他配置...
});

4. 坐标系校正:(关键) 使事件坐标与实际绘制位置匹配。

javascript 复制代码
const ctx = canvas.getContext("2d");
ctx.scale(dpr, dpr);

5. 触摸设备支持: 禁用触摸设备的默认滚动行为,确保签名体验流畅。

javascript 复制代码
canvas.addEventListener("touchstart", preventScroll, { passive: false });
canvas.addEventListener("touchmove", preventScroll, { passive: false });

const preventScroll = (e) => {
  e.preventDefault();
};

6. 响应式处理: 监听窗口大小变化并重新初始化签名板,确保在响应式布局中正常工作。

javascript 复制代码
const handleResize = () => {
  if (signaturePadInstance) {
    initSignaturePad(); // 重新初始化以适应新尺寸
  }
};

onMounted(() => {
  window.addEventListener("resize", handleResize);
});

onUnmounted(() => {
  window.removeEventListener("resize", handleResize);
});

代码

ini 复制代码
const initSignaturePad = () => {
  nextTick(() => {
    const canvas = signaturePad.value;
    if (canvas) {
      // 1. 获取设备像素比
      const dpr = window.devicePixelRatio || 1;
      
      // 2. 获取Canvas的实际显示尺寸
      const rect = canvas.getBoundingClientRect();
      
      // 3. 设置Canvas的实际绘制尺寸(考虑DPI)
      canvas.width = rect.width * dpr;
      canvas.height = rect.height * dpr;
      
      // 4. 设置Canvas的CSS显示尺寸(保持与容器一致)
      canvas.style.width = `${rect.width}px`;
      canvas.style.height = `${rect.height}px`;
      
      // 5. 根据DPI调整笔迹粗细
      const baseWidth = 2;
      
      // 6. 初始化签名板
      signaturePadInstance = new SignaturePad(canvas, {
        backgroundColor: "rgb(255, 255, 255)",
        penColor: "rgb(0, 0, 0)",
        minWidth: baseWidth * dpr,
        maxWidth: baseWidth * dpr * 2,
        throttle: 16 // 节流控制提高性能
      });
      
      // 7. 调整坐标系以匹配高DPI
      const ctx = canvas.getContext("2d");
      ctx.scale(dpr, dpr);
      
      // 8. 添加触摸设备支持
      canvas.addEventListener("touchstart", preventScroll, { passive: false });
      canvas.addEventListener("touchmove", preventScroll, { passive: false });
    }
  });
};

效果

完整代码

html 复制代码
<template>
  <div class="signature-container">
    <h2>电子签名</h2>
    <div class="signature-pad-container">
      <canvas ref="signaturePad" class="signature-pad"></canvas>
    </div>
    <div class="signature-actions">
      <el-button @click="clearSignature">清除</el-button>
      <el-button type="primary" @click="saveSignature">保存签名</el-button>
    </div>
    <div v-if="signatureUrl" class="signature-preview">
      <el-image v-if="signatureUrl" :src="signatureUrl"></el-image>
    </div>
  </div>
</template>
javascript 复制代码
<script setup>
import { ref, onMounted, onUnmounted, nextTick } from "vue";
import SignaturePad from "signature_pad";

const signaturePad = ref(null);
let signaturePadInstance = null;
const signatureUrl = ref("");

const initSignaturePad = () => {
  nextTick(() => {
    const canvas = signaturePad.value;
    if (!canvas) return;

    const dpr = window.devicePixelRatio || 1;
    const rect = canvas.getBoundingClientRect();
    
    canvas.width = rect.width * dpr;
    canvas.height = rect.height * dpr;
    canvas.style.width = `${rect.width}px`;
    canvas.style.height = `${rect.height}px`;
    
    const baseWidth = 2;
    
    signaturePadInstance = new SignaturePad(canvas, {
      backgroundColor: "rgb(255, 255, 255)",
      penColor: "rgb(0, 0, 0)",
      minWidth: baseWidth * dpr,
      maxWidth: baseWidth * dpr * 2,
      throttle: 16,
      velocityFilterWeight: 0.7,
      minDistance: 5
    });
    
    const ctx = canvas.getContext("2d");
    ctx.scale(dpr, dpr);
    
    // 触摸支持
    canvas.addEventListener("touchstart", preventScroll, { passive: false });
    canvas.addEventListener("touchmove", preventScroll, { passive: false });
  });
};

const preventScroll = (e) => {
  e.preventDefault();
};

const clearSignature = () => {
  if (signaturePadInstance) {
    signaturePadInstance.clear();
    signatureUrl.value = "";
  }
};

const saveSignature = () => {
  if (!signaturePadInstance || signaturePadInstance.isEmpty()) {
    ElMessage.warning("请先签名");
    return;
  }
  
  // 保存高质量PNG
  signatureUrl.value = signaturePadInstance.toDataURL('image/png', 1.0);
  ElMessage.success("签名保存成功");
};

const handleResize = () => {
  if (signaturePadInstance) {
    initSignaturePad();
  }
};

onMounted(() => {
  initSignaturePad();
  window.addEventListener("resize", handleResize);
});

onUnmounted(() => {
  window.removeEventListener("resize", handleResize);
});
</script>
css 复制代码
<style scoped>
.signature-container {
  max-width: 800px;
  margin: 0 auto;
  padding: 20px;
}

.signature-pad-container {
  position: relative;
  width: 100%;
  height: 400px;
  border: 1px solid #dcdfe6;
  border-radius: 4px;
  margin: 20px 0;
}

.signature-pad {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  touch-action: none;
}

.signature-actions {
  display: flex;
  justify-content: flex-end;
  gap: 10px;
  margin-bottom: 20px;
}

.signature-preview {
  margin-top: 20px;
  text-align: center;
}
</style>
相关推荐
m0_7400437313 小时前
3、Vuex-Axios-Element UI
前端·javascript·vue.js
鹏北海13 小时前
微信扫码登录 iframe 方案中的状态拦截陷阱
前端·javascript·vue.js
狗哥哥13 小时前
Vite 插件实战 v2:让 keep-alive 的“组件名”自动长出来
前端·vue.js·架构
小黑的铁粉13 小时前
Vue2 vs Vue3
vue.js
AAA阿giao13 小时前
代码宇宙的精密蓝图:深入探索 Vue 3 + Vite 项目的灵魂结构
前端·javascript·vue.js
半桶水专家14 小时前
vue中的props详解
前端·javascript·vue.js
前端不太难14 小时前
RN 遇到复杂手势(缩放、拖拽、旋转)时怎么设计架构
javascript·vue.js·架构
白兰地空瓶14 小时前
一行 npm init vite,前端工程化的世界就此展开
前端·vue.js·vite
码力巨能编14 小时前
Markdown 作为 Vue 组件导入
前端·javascript·vue.js
仰望.15 小时前
vue 甘特图 vxe-gantt table 拖拽任务调整开始日期和结束日期的使用,拖拽任务调整日期
vue.js·甘特图·vxe-ui