功能说明:
1.支持清空画布。
2.支持撤销、恢复功能。
3.支持获取签名后的图片src。
4.支持下载签名png图片。
5.获取到的签名图片是裁剪过周围多余空白的。
6.页面resize后,清空画布,画布自适应父节点大小,建议div 包裹,给外层div设置宽高即可。
7.可校验签名大小 - 需自行调用校验函数,见demo。
8.支持移动端。
9.canvas 上面有滚动条也不影响。
效果如下:
image.pnggif演示
GIF 2023-11-9 17-19-32.gif版本依赖如下,element-plus
是拿来错误提示用的
"element-plus": "^2.3.14","vue": "^3.2.45",
直接上代码,别忘记点赞+收藏哦:
signCanvas/index.vue
<!--
author: yangfeng
date: 20231109
注意: canvas 宽高是获取的父节点的宽高
-->
<template>
<canvas class="signCanvas" ref="canvasDomRef">您的浏览器不支持 HTML5 canvas标签</canvas>
</template>
<script lang="ts">
export default {
name: 'signCanvas',
}
</script>
<script setup lang="ts">
import { ref, reactive, onMounted, onUnmounted } from 'vue'
import { ElMessage } from 'element-plus'
import { getToPageXY, IsPC, getSignImgPngSrc } from './index'
interface IProps {
lineColor?: string;
}
interface IrectBoundary {
minX: number;
minY: number;
maxX: number;
maxY: number;
}
interface IRecordItem {
imgData: ImageData;
rectBoundary: IrectBoundary;
}
const props = withDefaults(defineProps<IProps>(), {
lineColor: '#000000', // 线条颜色
})
// 变量
const data = reactive({
isMouseDown: false // 鼠标是否按下
})
const canvasDomRef = ref()
let tickTimer: NodeJS.Timeout | null = null // 防抖
let resizeObserver: ResizeObserver;
let isMobile = false
let rectBoundary: IrectBoundary = { // 签名的最大使用区域,用于裁剪,去掉周围的空白区域
minX: 0,
minY: 0,
maxX: 0,
maxY: 0
}
// 撤销回退
let undoList: IRecordItem[] = [] // 撤销
let redoList: IRecordItem[] = [] // 恢复
//#region 签名边界
// 给签名的边界赋值,
const setRectBoundary = (x: number, y: number) => {
let { minX, minY, maxX, maxY } = rectBoundary
rectBoundary.minX = x < minX ? x : minX
rectBoundary.minY = y < minY ? y : minY
rectBoundary.maxX = x > maxX ? x : maxX
rectBoundary.maxY = y > maxY ? y : maxY
}
// 给签名的边界初值
const initRectBoundary = () => {
let canvas = canvasDomRef.value
rectBoundary = {
minX: canvas.width,
minY: canvas.height,
maxX: 0,
maxY: 0
}
}
//#endregion 签名边界
//#region 撤销、恢复操作
const setUndoList = () => {
let canvas = canvasDomRef.value
let ctx = canvas.getContext('2d')
undoList.push({
imgData: ctx.getImageData(0, 0, canvas.width, canvas.height),
rectBoundary: { // 记录此刻的签名边界
...rectBoundary
}
})
}
// 撤销
const undo = () => {
if (undoList.length > 0) {
redoList.push(undoList.pop() as IRecordItem)
}
reDrawCanvas()
}
// 恢复
const redo = () => {
if (redoList.length > 0) {
undoList.push(redoList.pop() as IRecordItem)
}
reDrawCanvas()
}
// 将历史记录绘制到画布中
const reDrawCanvas = () => {
if (undoList.length) {
let canvas = canvasDomRef.value
let ctx = canvas.getContext('2d')
let record = undoList[undoList.length - 1]
rectBoundary = record.rectBoundary // 恢复此时的签名边界
ctx.putImageData(record.imgData, 0, 0);
} else { // 清空画布
clear()
}
}
// 清空历史记录
const clearUodoRedoList = () => {
undoList = []
redoList = []
}
//#endregion 撤销、恢复操作
// 转为在canvas画布中的像素
const getCanvasPx: (arg: { x: number, y: number }) => { x: number; y: number } = ({ x, y }) => {
let canvas = canvasDomRef.value
let { left, top } = canvas.getBoundingClientRect()
return {
x: x - left,
y: y - top
}
}
// 清空画布
const clear = () => {
let canvas = canvasDomRef.value
if (!canvas) return
let ctx = canvas.getContext('2d')
// ctx.save();
ctx.clearRect(0, 0, canvas.width, canvas.height);
// ctx.restore();
initRectBoundary() // 清空签名的边界
}
// resize
const resizeHandle = () => {
clearTimeout(Number(tickTimer))
tickTimer = setTimeout(() => {
clear()
clearUodoRedoList() // 每次reize 清空历史记录 - 因为宽高改变恢复了也是变形的
let canvas = canvasDomRef.value
if (!canvas) return
let parentNode = canvas.parentNode
let wd = parentNode.clientWidth
let ht = parentNode.clientHeight
canvas.width = wd
canvas.height = ht
// canvas.style.width = wd + 'px'
// canvas.style.height = ht + 'px'
}, 100)
}
// mousedowm
const downHandle = (e: MouseEvent) => {
data.isMouseDown = true
let canvas = canvasDomRef.value
let { x, y } = getCanvasPx(getToPageXY(e))
let ctx = canvas.getContext('2d')
ctx.beginPath();
ctx.moveTo(x, y);
setRectBoundary(x, y) // 存储签名的最大使用区域
}
// mousemove
const moveHandle = (e: MouseEvent) => {
if (!data.isMouseDown) return
let canvas = canvasDomRef.value
let { x, y } = getCanvasPx(getToPageXY(e))
let ctx = canvas.getContext('2d')
ctx.lineTo(x, y);
ctx.strokeStyle = props.lineColor
// ctx.lineWidth = 2 * (window.devicePixelRatio || 1)
ctx.lineWidth = 2
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
//移动端去掉模糊提高手写渲染速度
if (isMobile) {
ctx.shadowBlur = 1;
ctx.shadowColor = props.lineColor;
}
ctx.stroke();
setRectBoundary(x, y) // 存储签名的最大使用区域
}
// mouseup
const upHandle = () => {
data.isMouseDown = false
setUndoList()
}
const addEvents = () => {
let canvas = canvasDomRef.value
if (!canvas) return
if (isMobile) {
canvas.addEventListener('touchstart', downHandle, false)
canvas.addEventListener('touchmove', moveHandle, false)
canvas.addEventListener('touchend', upHandle, false)
} else {
canvas.addEventListener('pointerdown', downHandle, false)
canvas.addEventListener('pointermove', moveHandle, false)
canvas.addEventListener('pointerup', upHandle, false)
}
// 和传统 window.resize不同 ResizeObserver 可以在div上监听resize
// 1.指定resize事件
resizeObserver = new ResizeObserver(resizeHandle) // 会在绘制前和布局后调用 resize 事件,因此不用提前调用 event_windowResize 方法
// 2.指定该resize事件的触发dom
resizeObserver.observe(canvas);
}
const removeEvents = () => {
clearTimeout(Number(tickTimer))
let canvas = canvasDomRef.value
if (!canvas) return
if (isMobile) {
canvas.removeEventListener('touchstart', downHandle)
canvas.removeEventListener('touchmove', moveHandle)
canvas.removeEventListener('touchend', upHandle)
} else {
canvas.removeEventListener('pointerdown', downHandle)
canvas.removeEventListener('pointermove', moveHandle)
canvas.removeEventListener('pointerup', upHandle)
}
resizeObserver.unobserve(canvas) // 结束对指定 Element 的监听。
}
// 获取签名后的png图片
const getSignPNGImgSrc = () => {
let canvas = canvasDomRef.value
let { minX, minY, maxX, maxY } = rectBoundary
if (!maxY && !maxX) { // 未曾签名 - 提示
ElMessage({
showClose: true,
message: '请签名后继续',
type: 'warning',
})
return null
}
return getSignImgPngSrc({
canvas,
sx: minX,
sy: minY,
sw: maxX - minX,
sh: maxY - minY
})
}
// 下载签名图片
const downLoadSignPNGImg = () => {
let url = getSignPNGImgSrc()
if (!url) return
// 创建a标签,用于跳转至下载链接
const tempLink = document.createElement("a");
tempLink.style.display = "none";
tempLink.href = url;
tempLink.setAttribute("download", url);
// 兼容:某些浏览器不支持HTML5的download属性
if (typeof tempLink.download === "undefined") {
tempLink.setAttribute("target", "_blank");
}
// 挂载a标签
document.body.appendChild(tempLink);
tempLink.click();
document.body.removeChild(tempLink);
}
const init = () => {
isMobile = !IsPC()
addEvents()
}
onMounted(() => {
init()
})
onUnmounted(() => {
removeEvents()
})
defineExpose({
clear, // 清空画布
getSignPNGImgSrc, // 获取签名图片src地址 - 裁剪过的
downLoadSignPNGImg, // 下载签名图片
undo, // 撤销
redo // 恢复
})
</script>
<style lang="scss" scoped>
.signCanvas {
width: 100%;
height: 100%;
}
</style>
signCanvas/index.ts
/*
author:yangfeng
date: 20231109
*/
// 获取到文档的距离
export function getToPageXY(e: MouseEvent | TouchEvent) {
let touchE = e as TouchEvent;
let mouseE = e as MouseEvent;
if (touchE.changedTouches) {
// 移动端
return {
x: touchE.changedTouches[0].pageX,
y: touchE.changedTouches[0].pageY,
};
} else {
return {
x: mouseE.x || mouseE.pageX,
y: mouseE.y || mouseE.pageY,
};
}
}
// 当前是否pc版本
export function IsPC() {
let userAgentInfo = navigator.userAgent;
let Agents = [
"Android",
"iPhone",
"SymbianOS",
"Windows Phone",
"iPad",
"iPod",
];
let flag = true;
for (let v = 0; v < Agents.length; v++) {
if (userAgentInfo.indexOf(Agents[v]) > 0) {
flag = false;
break;
}
}
return flag;
}
/**
* canvas 是否为空
* @param canvas
* @returns boolean
*/
export function isCanvasBlank(canvas: HTMLCanvasElement) {
var blank = document.createElement("canvas"); //系统获取一个空canvas对象
blank.width = canvas.width;
blank.height = canvas.height;
return canvas.toDataURL() === blank.toDataURL(); //比较值相等则为空
}
/**
* 校验签名图片是不是太小
* @param imgSrc
* @param size
* @returns
*/
export function validateImageSize(imgSrc: string, size = 10) {
let img = new Image();
img.src = imgSrc;
return new Promise((resolve, reject) => {
img.onload = (e) => {
let target = (e.target || e.srcElement) as any;
let width = target.width;
let height = target.height;
if (width < size && height < size) {
reject({
description: "签字太小了",
});
} else {
resolve(true);
}
};
});
}
interface IcropCanvasParams {
canvas: HTMLCanvasElement; // 需要裁剪的canvas
sx: number; // 裁剪开始点的x
sy: number; // 裁剪开始点的y
sw: number; // 裁剪宽
sh: number; // 裁剪高
}
//
/**
* 裁剪 canvas 的指定区域
* @param param0
* @returns
*/
export function cropCanvas({
canvas, // 需要裁剪的canvas
sx, // 裁剪开始点的x
sy, // 裁剪开始点的y
sw, // 裁剪宽
sh, // 裁剪高
}: IcropCanvasParams) {
if (!canvas) return null;
let newCanvas = document.createElement("canvas");
let newCxt = newCanvas.getContext("2d");
let gap = 4; // 签字留空隙
newCanvas.width = sw + 2 * gap;
newCanvas.height = sh + 2 * gap;
let imgData = canvas
.getContext("2d")!
.getImageData(sx - gap, sy - gap, newCanvas.width, newCanvas.height);
newCxt?.putImageData(imgData, 0, 0);
return newCanvas;
}
export function getSignImgPngSrc({
canvas, // 需要裁剪的canvas
sx, // 裁剪开始点的x
sy, // 裁剪开始点的y
sw, // 裁剪宽
sh, // 裁剪高
}: IcropCanvasParams) {
let newCanvas = cropCanvas({
canvas, // 需要裁剪的canvas
sx, // 裁剪开始点的x
sy, // 裁剪开始点的y
sw, // 裁剪宽
sh, // 裁剪高
});
if (!newCanvas) return null;
// if (this.isMobile && this.height > this.width) {
// let canvas1 = document.createElement('canvas'), cxt1 = canvas1.getContext('2d');
// canvas1.width = canvas.height;
// canvas1.height = canvas.width;
// let xpos = canvas1.width / 2, ypos = canvas1.height / 2;
// cxt1.translate(xpos, ypos);
// cxt1.rotate(-90 * Math.PI / 180);
// cxt1.translate(-xpos, -ypos);
// cxt1.drawImage(canvas, xpos - canvas.width / 2, ypos - canvas.height / 2);
//
// return this.isCanvasBlank(canvas1) ? null : canvas1.toDataURL('image/png');
// }
return isCanvasBlank(newCanvas) ? null : newCanvas.toDataURL("image/png");
}
调用方式 index.vue:
<template>
<div class="wrap">
<p>canvas 签名</p>
<div>
<el-button @click="canvasRef.clear()">清空</el-button>
<el-button @click="canvasRef.undo()">撤销</el-button>
<el-button @click="canvasRef.redo()">恢复</el-button>
<el-button type="primary" @click="getImgSrc">获取签名图片</el-button>
<el-button type="primary" @click="canvasRef.downLoadSignPNGImg()">下载签名图片</el-button>
</div>
<div class="canvasBox">
<signCanvas ref="canvasRef" />
</div>
<div class="imgBox" v-show="data.imgSrc">
<img :src="data.imgSrc" />
</div>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import signCanvas from './components/signCanvas/index.vue'
import { validateImageSize } from './components/signCanvas/index.ts'
const canvasRef = ref()
const data = reactive({
imgSrc: '',
})
const getImgSrc = () => {
data.imgSrc = '' // 清空
let src = canvasRef.value.getSignPNGImgSrc()
if (!src) return
// 校验签名是否太小
validateImageSize(src).then(res => {
data.imgSrc = src
}).catch(e => {
ElMessage({
showClose: true,
message: e.description,
type: 'warning',
})
})
}
</script>
<style lang="scss" scoped>
p{
padding: 20px;
}
.wrap {
width: 100%;
text-align: center;
}
.canvasBox {
// margin-top: 820px;
margin: 20px auto;
border: 1px solid #dddddd;
width: 100%;
height: 300px;
box-sizing: border-box;
}
.imgBox {
padding: 0;
text-align: center;
img {
border: 1px solid #dddddd;
}
}
</style>
几个技巧:
1.如何判断canvas是否为空?
答:创建一个同等宽高空的 canvas,t比较oDataURL()
2.怎么校验签名生成的base64地址指向的图片大小满足要求?
答:放到img里面,获取其宽高即可判断。
本文原创,若对你有帮助,请点个赞吧,若能打赏不胜感激,谢谢支持!
本文地址:https://www.jianshu.com/p/15e55dba5521?v=1699536978071,转载请注明出处,谢谢。
参考:
https://developer.mozilla.org/zh-CN/docs/Web/API/HTMLCanvasElement/toDataURL
https://www.douyin.com/video/7171086219508452616
网友评论