Canvas系列-签字功能

2,590 阅读4分钟

前言

 好久没有分享文章了,由于刚换了一份工作去了鹅厂,每天忙于七七八八的事情,近些天也终于算是稳定了下来,就分享一下近段时间工作中遇到的一些可能对大家有用的小玩意儿。

 最近项目中做了一个签字功能,其实几年前也做个签字功能,那时候刚毕业,不求甚解,很多东西都是使用别人写好的插件,知其然不知其所以然,完成需求就完事了,现在感觉许多东西还是要了解实现原理才行。前端之前流行这样一句话“不要重复造轮子”,就像jack马说他不喜欢钱,有钱才有资本说不喜欢钱,你懂原理才会说不要重复造轮子,其实重复造轮子很有必要,在这个过程中能学习许多思路和知识。

 今天分享的是如何实现【签字功能】,这个功能现在使用场景还是很多的,之前我在银行机器办理社保、银行卡等业务时都是电子签名,而且我最近入职合同也是线上合同电子签名,以后电子签名的场景会越来越普遍。

 签名功能用到Canvas了 技术,去年圣诞我也分享了一篇关于Canvas的文章 使用canvas实现简单的下雪特效,有兴趣的可以去看看,接下来就看看如何使用canvas实现签名。

实现签名

先看效果图:实现了预览、撤销、清空、保存、裁剪功能

效果图signatue.png

一、签名功能

签名绘制是最核心的功能,在移动端我们手指就是笔,所以绘制就要用到 touchstarttouchmovetouchend 事件,如果在pc端鼠标就是绘笔,绘制就要用到mousedownmousemovemouseup事件,这篇文章以移动端为例,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


本文是笔者总结编撰,如有偏颇,欢迎留言指正,若您觉得本文对你有用,不妨点个赞~

关于作者: GitHub 简书 掘金