前言
好久没有分享文章了,由于刚换了一份工作去了鹅厂,每天忙于七七八八的事情,近些天也终于算是稳定了下来,就分享一下近段时间工作中遇到的一些可能对大家有用的小玩意儿。
最近项目中做了一个签字功能,其实几年前也做个签字功能,那时候刚毕业,不求甚解,很多东西都是使用别人写好的插件,知其然不知其所以然,完成需求就完事了,现在感觉许多东西还是要了解实现原理才行。前端之前流行这样一句话“不要重复造轮子”,就像jack马说他不喜欢钱,有钱才有资本说不喜欢钱,你懂原理才会说不要重复造轮子,其实重复造轮子很有必要,在这个过程中能学习许多思路和知识。
今天分享的是如何实现【签字功能】,这个功能现在使用场景还是很多的,之前我在银行机器办理社保、银行卡等业务时都是电子签名,而且我最近入职合同也是线上合同电子签名,以后电子签名的场景会越来越普遍。
签名功能用到Canvas
了 技术,去年圣诞我也分享了一篇关于Canvas的文章 使用canvas实现简单的下雪特效,有兴趣的可以去看看,接下来就看看如何使用canvas实现签名。
实现签名
先看效果图:实现了预览、撤销、清空、保存、裁剪功能
一、签名功能
签名绘制是最核心的功能,在移动端我们手指就是笔,所以绘制就要用到 touchstart
、 touchmove
、 touchend
事件,如果在pc端鼠标就是绘笔,绘制就要用到mousedown
、mousemove
、mouseup
事件,这篇文章以移动端为例,pc端原理相同,只是触发事件不一样。
基本思路是先初始化绘制的DOM元素后,通过touch事件获取当前的clientX和clientY坐标值,然后通过Canvas的绘制路径的api来绘制经过的路径,每执行完一次touch事件把当前的画布记录下来供后面的撤销等功能用。
上代码:
// html
<div id="app">
<div class="area">
<canvas ref="canvas"
@touchstart="touchStart"
@touchmove="touchMove"
@touchend="touchEnd">
</canvas>
<!-- <canvas ref="canvas"
@mousedown="mouseDown"
@mousemove="mouseMove"
@mouseup="mouseUp">
</canvas> -->
</div>
<button class="btn" @click="preview">预览</button>
<button class="btn" @click="revert">撤销</button>
<button class="btn" @click="clear">清空</button>
<button class="btn" @click="save">保存</button>
<button class="btn" @click="clip">裁剪</button>
<div class="preview">
<img src="" alt="">
</div>
</div>
new Vue({
el: '#app',
props: {
lineWidth: {
type: Number,
default: 4
},
lineColor: {
type: String,
default: '#000'
},
isCrop: {
type: Boolean,
default: false
}
},
data () {
return {
canvasRect: null, // 宽高clientRect数据
ctx: null, // 画笔对象
startX: 0,
startY: 0,
endX: 0,
endY: 0,
storageSteps: [], // 记录每步操作
isDrawing: false, // 是否正在绘制
isEmpty: true, // 画板是否为空
}
},
mounted () {
this.init()
// 在画板以外松开鼠标后冻结画笔
document.onmouseup = () => {
this.isDrawing = false
}
},
methods: {
init () {
const canvas = this.$refs.canvas;
this.canvasRect = canvas.getBoundingClientRect();
console.log(this.canvasRect)
canvas.width = this.canvasRect.width;
canvas.height = this.canvasRect.height;
this.ctx = canvas.getContext('2d')
},
// mobile
touchStart (e) {
e.preventDefault();
this.startX = e.targetTouches[0].clientX - this.canvasRect.left;
this.startY = e.targetTouches[0].clientY - this.canvasRect.top;
this.endX = this.startX;
this.endY = this.startY;
this.draw();
},
touchMove (e) {
e.preventDefault();
this.endX = e.targetTouches[0].clientX - this.canvasRect.left;
this.endY = e.targetTouches[0].clientY - this.canvasRect.top;
this.draw()
this.startX = this.endX;
this.startY = this.endY;
},
touchEnd (e) {
e.preventDefault();
// console.log(e)
this.endX = e.changedTouches[0].clientX - this.canvasRect.left;
this.endY = e.changedTouches[0].clientY - this.canvasRect.top;
let imgData = this.ctx.getImageData(0, 0, this.canvasRect.width, this.canvasRect.height)
console.log(imgData)
this.storageSteps.push(imgData)
// console.log(this.storageSteps)
},
// 绘制
draw () {
this.ctx.beginPath();
this.ctx.moveTo(this.startX, this.startY);
this.ctx.lineTo(this.endX, this.endY);
this.ctx.lineCap = 'round';
this.ctx.lineJoin = 'round';
this.ctx.lineWidth = this.lineWidth;
this.ctx.strokeStyle = this.lineColor;
this.ctx.stroke();
this.ctx.closePath();
this.isEmpty = false;
}
}
})
二、清空功能
清空功能即清空画布,可以使用Canvas API中的clearRect(x, y, width, height)
方法。
// 清空
clear () {
this.ctx.clearRect(0, 0, this.canvasRect.width, this.canvasRect.height);
this.storageSteps = []; // 清空清楚步骤记录
this.isEmpty = true; // 清空标记
}
三、撤销功能
原理:我们在触发touched
事件后记录当前画布的信息,然后保存在变量storageSteps数组中,然后在点击撤销的时候把前一个画布信息重新绘制一遍,
这里用到了getImageData()
和putImageData()
方法。
// touched事件
touchEnd (e) {
e.preventDefault();
// console.log(e)
this.endX = e.changedTouches[0].clientX - this.canvasRect.left;
this.endY = e.changedTouches[0].clientY - this.canvasRect.top;
let imgData = this.ctx.getImageData(0, 0, this.canvasRect.width, this.canvasRect.height) // 绘制结束记录当前画布信息
console.log(imgData)
this.storageSteps.push(imgData)
// console.log(this.storageSteps)
}
....省略....
// 撤销
revert () {
this.storageSteps.pop()
const len = this.storageSteps.length;
if (len) {
this.ctx.putImageData(this.storageSteps[len - 1], 0, 0);
} else {
this.clear()
}
// console.log('>>>', this.storageSteps)
}
四、预览功能
原理:把画布信息转化为base64,用到了toDataURL()
方法,返回一个包含图片展示的 data URI。
// 预览
preview () {
const base64 = this.$refs.canvas.toDataURL('image/png');
console.log(base64)
const img = document.querySelector('.preview img');
img.src = base64;
img.width = this.canvasRect.width;
img.height = this.canvasRect.height;
}
五、保存功能
实现原理同预览功能一样,即把Canvas画布数据转化为图片,然后利用a标签
下载下来
// 保存
save () {
if (this.isEmpty) {
console.log('画布为空!')
return
}
const name = prompt('请输入名称', 'canvas签名');
if (name && name.trim()) {
// console.log(name)
const a = document.createElement('a');
a.download = name;
a.href = this.$refs.canvas.toDataURL('image/png');
// console.log(a)
a.dispatchEvent(new MouseEvent('click')); // IE可能存在兼容性 可以把标签渲染出来再触发click事件
}
}
六、裁剪功能
何为裁剪功能呢?之前的功能不管是预览还是保存,我们在吧canvas画布转化为图片时,图片大小为canvas画布的大小,当我们在签名保存或下载时,图片边缘多出了很多透明的部分,这个裁剪功能就是把没有绘制到的地方去掉,只留签名的大小。
原理:ImageData()
构造函数使用给定的Uint8ClampedArray
创建一个 ImageData
对象,并包含图像的大小,Uint8ClampedArray的length = 4 x width x height。如果不给定数组,会创建一个“完全透明”(因为透明度值为0)的黑色矩形图像。
ImageData.data
属性描述了一个一维数组,包含以 RGBA 顺序的数据,数据使用 0 至 255(包含)的整数表示。
// 裁剪
clip () {
if (this.isEmpty) {
console.log('画布为空!')
return
}
const imgData = this.ctx.getImageData(0, 0, this.canvasRect.width, this.canvasRect.height);
const clipArea = this.getCropArea(imgData.data)
console.log(clipArea)
const _canvas = document.createElement('canvas')
_canvas.width = clipArea.x2 - clipArea.x1;
_canvas.height = clipArea.y2 - clipArea.y1;
const _ctx = _canvas.getContext('2d');
const imageData = this.ctx.getImageData(clipArea.x1, clipArea.y1, _canvas.width, _canvas.height);
_ctx.putImageData(imageData, 0, 0)
const base64 = _canvas.toDataURL('image/png');
// const name = prompt('请输入名称', 'canvas签名');
// if (name && name.trim()) {
// const a = document.createElement('a');
// a.download = name;
// a.href = base64;
// a.dispatchEvent(new MouseEvent('click')); // IE可能存在兼容性 可以把标签渲染出来再触发click事件
// }
const img = document.querySelector('.preview img');
img.src = base64;
img.width = _canvas.width;
img.height = _canvas.height;
},
// 计算空白区域
getCropArea (imgData) {
let x1 = Math.round(this.canvasRect.width);
let y1 = Math.round(this.canvasRect.height);
let x2 = 0;
let y2 = 0;
console.log([x1, y1, x2, y2])
for (let i = 0; i < Math.round(this.canvasRect.width); i++) {
for (let j = 0; j < Math.round(this.canvasRect.height); j++) {
let pos = (i + Math.round(this.canvasRect.width) * j) * 4;
if (imgData[pos] > 0 || imgData[pos + 1] > 0 || imgData[pos + 2] || imgData[pos + 3] > 0) { // 判断第j行第i列的像素不是透明的
// 找到有色彩的左上角坐标和右下角坐标
x1 = Math.min(i, x1);
y1 = Math.min(j, y1);
x2 = Math.max(i, x2)
y2 = Math.max(j, y2)
}
}
}
x1++
y1++
x2++
y2++
return { x1, y1, x2, y2 } // 由于循环是从0开始的,而我们认为的行列是从1开始的
}
}
结尾
上面就是签字常用的一些功能的原理实现,上面代码是使用vue编写的小demo,可能存在一些兼容性问题,也没有封装成组件,但是具有一些参考意义,用于生产可以自己去封装成组件使用,完整的代码在我的GitHub
本文是笔者总结编撰,如有偏颇,欢迎留言指正,若您觉得本文对你有用,不妨点个赞~