web 前端签名插件_匠心打造canvas签名组件

本文详细介绍了如何利用canvas实现一个前端签名组件,包括手写、描线、旋转、缩放和上传等功能。通过监听touchstart和touchmove事件实现手写,使用requestAnimationFrame优化移动绘制。在移动端,通过旋转90度适应屏幕,并使用rotate和drawImage方法处理旋转。最后,讨论了两种旋转方案的优劣,以及如何处理retina屏幕的锯齿问题。
摘要由CSDN通过智能技术生成

本文首发于CSDN网站,下面的版本又经过进一步的修订。

原文:匠心打造canvas签名组件

导读

6月又是项目吃紧的时候,一大波需求袭来,猝不及防。

度过了漫长而煎熬的6月,是时候总结一波。最近移动端的一款产品原计划是引入第三方的签名插件,该插件依赖复杂,若干个js使用document.write顺序加载,插件源码是ES5的,甚至说是ES3都不为过。为了能够顺利嵌入我们的VUE项目,我阅读了两天插件的源码(demo及文档不全,囧),然后花了一天多点的时间使用ES6引用它。鉴于单页应用中,任何非全局资源都不该提前加载的指导性原则,为了做到动态加载,我甚至还专门写了一个simple的vue组件iload.js去顺序加载这些资源并执行回调。一切看似很完美,结果发现demo引用的一个压缩的js中居然写死了插件相关DOM节点的id和style,此刻我的内心几乎是崩溃的。这样的一个插件我怕是无力引入了吧。

虽然嘴上这么说,身体还是很诚实的,费尽千辛万苦我还是把这个插件用在了项目中。随着项目推进,业务上经过多次沟通,我们砍掉了该签名插件的数字证书验证部分。也就是说,这么大的一个插件,只剩下用户签名的功能,我完全可以自己做啊。于是我悄悄移除了这个插件,为这几天的调研和码字过程划上了一个完美的句号(深藏功与名)。

签名是若干操作的集合,起于用户手写姓名,终于签名图片上传,中间还包含图片的处理,比如说减少锯齿、旋转、缩小、预览等。canvas几乎是最适合的解决方案。

手写

从交互上看,用户签名的过程,只有开始的手写部分是有交互的,后面是自动处理。为了完成手写,需要监听画布的两个事件:touchstart、touchmove(移动端touchend在touchmove之后不触发)。前者定义起始点,后者不停地描线。

const canvas = document.getElementById('canvas');

const touchstart = (e) => {

/* TODO 定义起点 */

};

const touchmove = (e) => {

/* TODO 连点成线,并且填充颜色 */

};

canvas.addEventListener('touchstart', touchstart);

canvas.addEventListener('touchmove', touchmove);

注: 以下默认canvas和context对象已有。

可以先戳这里体验把后面将要提到的签名组件 canvas-draw。

描线

既然要连点成线,自然需要一个变量来存储这些点。

const point = {};

接下来就是画线的部分。canvas画线只需4行代码:

开始路径(beginPath)

定位起点(moveTo)

移动画笔(lineTo)

绘制路径(stroke)

考虑到start和move两个动作,那么一个描线的方法就呼之欲出了,如下:

const paint = (signal) => {

switch (signal) {

case 1: // 开始路径

context.beginPath();

context.moveTo(point.x, point.y);

case 2: // 前面之所以没有break语句,是为了点击时就能描画出一个点

context.lineTo(point.x, point.y);

context.stroke();

break;

}

};

绑定事件

为了兼容PC端的类似需求,我们有必要区分下平台。移动端,使用手指操作,需要绑定的是touchstart和touchmove;PC端,使用鼠标操作,需要绑定的是mousedown和mousemove。如下一行代码可用于判断是否移动端:

const isMobile = /(phone|pad|pod|iPhone|iPod|ios|iPad|Android|Mobile|BlackBerry|IEMobile|MQQBrowser|JUC|Fennec|wOSBrowser|BrowserNG|WebOS|Symbian|Windows Phone)/i.test(navigator.userAgent);

描线的方法准备妥当后,剩下的就是在适当的时候,记录当前划过的点,并且调用paint方法进行绘制。这里可以抽象出一个事件生成器:

let pressed = false; // 标示是否发生鼠标按下或者手指按下事件

const create = signal => (e) => {

if (signal === 1) {

pressed = true;

}

if (signal === 1 || pressed) {

e = isMobile ? e.touches[0] : e;

point.x = e.clientX - left + 0.5; // 不加0.5,整数坐标处绘制直线,直线宽度将会多1px(不理解的不妨谷歌下)

point.y = e.clientY - top + 0.5;

paint(signal);

}

};

以上代码中的left和top并非内置变量,它们分别表示着画布距屏幕左边和顶部的像素距离,主要用于将屏幕坐标点转换为画布坐标点。以下是一种获取方法:

const { left, top } = canvas.getBoundingClientRect();

很明显,上述的事件生成器是一个高阶函数,用于固化signal参数并返回一个新的Function。基于此,start和move回调便呈现了。

const start = create(1);

const move = create(2);

为了避免UI过度绘制,让move操作执行得更加流畅,requestAnimationFrame优化自然是少不了的。

const requestAnimationFrame = window.requestAnimationFrame;

const optimizedMove = requestAnimationFrame ? (e) => {

requestAnimationFrame(() => {

move(e);

});

} : move;

剩下的也是绑定事件中关键的一步。PC端中,mousedown和mousemove没有先后顺序,不是每一次画布之上的鼠标移动都是有效的操作,因此我们使用pressed变量来保证mousemove事件回调只在mousedown事件之后执行。实际上,设置后的pressed变量总需要还原,还原的契机就是mouseup和mouseleave回调,由于mouseup事件并不总能触发(比如说鼠标移动到别的节点上才弹起,此时触发的是其他节点的mouseup事件),mouseleave便是鼠标移出画布时的兜底逻辑。而移动端的touch事件,其天然的连续性,保证了touchmove只会在touchstart之后触发,因此无须设置pressed变量,也不需要还原它。代码如下:

if (isMobile) {

canvas.addEventListener('touchstart', start);

canvas.addEventListener('touchmove', optimizedMove);

} else {

canvas.addEventListener('mousedown', start);

canvas.addEventListener('mousemove', optimizedMove);

['mouseup', 'mouseleave'].forEach((event) => {

canvas.addEventListener(event, () => {

pressed = false;

});

});

}

旋转

想要在移动端签名,往往面临着屏幕宽度不够的尴尬。竖屏下写不了几个汉字,甚至三个都够呛。如果app webview或浏览器不支持横屏展示,此时并不是意味着没有了办法,起码我们可以将整个网页旋转90°。

方案一:起初我的想法是将画布也一同旋转90°,后来发现难以处理旋转后的坐标系和屏幕坐标系的对应关系,因此我采取了旋转90°绘制页面,但是正常布局画布的方案,从而保证坐标系的一致性(这样就不用重新纠正canvas画布的坐标系了,关于纠正坐标系后续还有方案二,请耐心阅读)。

由于用户是横屏操作画布的,完成签名后,图片需要逆时针旋转90°才能保上传到服务器。因此还差一个旋转的方法。实际上,rotate方法可以旋转画布,drawImage方法可以在新的画布中绘制一张图片或老的画布,这种绘制的定制化程度很高。

rotate

rotate用于旋转当前的画布。

语法: rotate(angle),angle表示旋转的弧度,这里需要将角度转换为弧度计算,比如顺时针旋转90°,angle的值就等于-90 * Math.PI / 180。ratate旋转时默认以画布左上角为中心,如果需要以画布中心位置为中心,需要在rotate方法执行前将画布的坐标原点移至中心位置,旋转完成后,再移动回来。如下:

const { width, height } = canvas;

context.translate(width / 2, height / 2); // 坐标原点移至画布中心

context.rotate(90 * Math.PI / 180); // 顺时针旋转90°

context.translate(-width / 2, -height / 2); // 坐标原点还原到起始位置

实际上,这种变换处理,使用transform(Math.cos(90 * Math.PI / 180), 1, -1, Math.cos(90 * Math.PI / 180), 0, 0)同样可以顺时针旋转90°。

drawImage

drawImage用于绘制图片、画布或者视频,可自定义宽高、位置、甚至局部裁剪。它有三种形态的api:

drawImage(img,x,y),x,y为画布中的坐标,img可以是图片、画布或视频资源,表示在画布的指定坐标处绘制。

drawImage(img,x,y,width,height),width,height表示指定图片绘制后的宽高(可以任意缩放或调整宽高比例)。

context.drawImage(img,sx,sy,swidth,sheight,x,y,width,height),sx,sy表示从指定的坐标位置裁剪原始图片,并且裁剪swidth的宽度和sheight的高度。

通常情况下,我们可能需要旋转一张图片90°、180°或者-90°。代码如下:

const rotate = (degree, image) => {

degree = ~~degree;

if (degree !== 0) {

const maxDegree = 180;

const minDegree = -90;

if (degree > maxDegree) {

degree = maxDegree;

} else if (degree < minDegree) {

degree = minDegree;

}

const canvas = document.createElement('canvas');

const context = canvas.getContext('2d');

const height = image.height;

const width = image.width;

const angle = (degree * Math.PI) / 180;

switch (degree) {

// 逆时针旋转90°

case -90:

canvas.width = height;

canvas.height = width;

context.rotate(angle);

context.drawImage(image, -width, 0);

break;

// 顺时针旋转90°

case 90:

canvas.width = height;

canvas.height = width;

context.rotate(angle);

context.drawImage(image, 0, -height);

break;

// 顺时针旋转180°

case 180:

canvas.width = width;

canvas.height = height;

context.rotate(angle);

context.drawImage(image, -width, -height);

break;

}

image = canvas;

}

return image;

};

缩放

旋转后的画布,通常需要进一步格式化其宽高才能上传。此处还是利用drawImage去改变画布宽高,以达到缩小和放大的目的。如下:

const scale = (width, height) => {

const w = canvas.width;

const h = canvas.height;

width = width || w;

height = height || h;

if (width !== w || height !== h) {

const tmpCanvas = document.createElement('canvas');

const tmpContext = tmpCanvas.getContext('2d');

tmpCanvas.width = width;

tmpCanvas.height = height;

tmpContext.drawImage(canvas, 0, 0, w, h, 0, 0, width, height);

canvas = tmpCanvas;

}

return canvas;

};

上传

我们做了这么多的操作和转换,最终的目的还是上传图片。

首先,获取画布中的图片:

const getPNGImage = () => {

return canvas.toDataURL('image/png');

};

getPNGImage方法返回的是dataURL,需要转换为Blob对象才能上传。如下:

const dataURLtoBlob = (dataURL) => {

const arr = dataURL.split(',');

const mime = arr[0].match(/:(.*?);/)[1];

const bStr = atob(arr[1]);

let n = bStr.length;

const u8arr = new Uint8Array(n);

while (n--) {

u8arr[n] = bStr.charCodeAt(n);

}

return new Blob([u8arr], { type: mime });

};

完成了上面这些,才能一波ajax请求(xhr、fetch、axios都可)带走签名图片。

const upload = (blob, url, callback) => {

const formData = new FormData();

const xhr = new XMLHttpRequest();

xhr.withCredentials = true;

formData.append('image', blob, 'sign');

xhr.open('POST', url, true);

xhr.onload = () => {

if ((xhr.status >= 200 && xhr.status < 300) || xhr.status === 304) {

callback(xhr.responseText);

}

};

xhr.onerror = (e) => {

console.log(`upload img error: ${e}`);

};

xhr.send(formData);

};

设置

完成了上述功能,一个签名插件就已经成型了。除非你迫不及待想要发布,否则,这样的代码我是不建议拿出去的。一些必要的设置通常是不能忽略的。

通常画布中的直线是1px大小,这么细的线,是不能模拟笔触的,可如果你要放大至10px,便会发现,绘制的直线其实是矩形。这在签名过程中也是不合适的,我们期望的是圆滑的笔触,因此需要尽量模拟手写。实际上,lineCap就可指定直线首尾圆滑,lineJoin可以指定线条交汇时的边角圆滑。如下是一个simple的设置:

context.lineWidth = 10; // 直线宽度

context.strokeStyle = 'black'; // 路径的颜色

context.lineCap = 'round'; // 直线首尾端圆滑

context.lineJoin = 'round'; // 当两条线条交汇时,创建圆形边角

context.shadowBlur = 1; // 边缘模糊,防止直线边缘出现锯齿

context.shadowColor = 'black'; // 边缘颜色

优化

一切看似很完美,直到遇到了retina屏幕。retina屏是用4个物理像素绘制一个虚拟像素,屏幕宽度相同的画布,其每个像素点都会由4倍物理像素去绘制,画布中点与点之间的距离增加,会产生较为明显的锯齿,可通过放大画布然后压缩展示来解决这个问题。

let { width, height } = window.getComputedStyle(canvas, null);

width = width.replace('px', '');

height = height.replace('px', '');

// 根据设备像素比优化canvas绘图

const devicePixelRatio = window.devicePixelRatio;

if (devicePixelRatio) {

canvas.style.width = `${width}px`;

canvas.style.height = `${height}px`;

canvas.height = height * devicePixelRatio; // 画布宽高放大

canvas.width = width * devicePixelRatio;

context.scale(devicePixelRatio, devicePixelRatio); // 画布内容放大相同的倍数

} else {

canvas.width = width;

canvas.height = height;

}

重置坐标系

由于采取了方案一,签名的工作流变成了:『页面顺时针旋转90°绘制、画布正常竖屏绘制』—>『手写签名』—>『逆时针旋转画布90°』—> 『合理缩放画布至屏幕宽度』—> 『导出图片并上传』。由此可见方案一流程复杂,处理起来也比较麻烦。

换个角度想想,既然画布是可以旋转的,我刚好可以利用这种坐标系的反向旋转去抵消页面的正向旋转,这样页面上点的坐标就可以映射到画布本身的坐标上。于是有了方案二。

方案二:页面顺时针旋转90°,画布跟随着一起旋转(画布的坐标系也跟着旋转90°);然后再逆向旋转画布90°,重置画布的坐标系,使之与页面坐标系映射起来。

顺时针旋转90°的页面如下所示:

此时canvas画布也随着页面顺时针旋转90°,想要重置画布坐标系,可借由rotate逆向旋转90°,然后由translate平移坐标系。以下代码包含了顺逆时针旋转90°、180° 的处理(为了便于描述,假设画布充满屏幕):

context.rotate((degree * Math.PI) / 180);

switch (degree) {

// 页面顺时针旋转90°后,画布左上角的原点位置落到了屏幕的右上角(此时宽高互换),围绕原点逆时针旋转90°后,画布与原位置垂直,居于屏幕右侧,需要向左平移画布当前高度相同的距离。

case -90:

context.translate(-height, 0);

break;

// 页面逆时针旋转90°后,画布左上角的原点位置落到了屏幕的左下角(此时宽高互换),围绕原点顺时针旋转90°后,画布与原位置垂直,居于屏幕下侧,需要向上平移画布当前宽度相同的距离。

case 90:

context.translate(0, -width);

break;

// 页面顺逆时针旋转180°回到了同一个位置(即页面倒立),画布左上角的原点位置落到了屏幕的右下角(此时宽高不变),围绕原点反方向旋转180°后,画布与原位置平行,居于屏幕右侧的下侧,需要向左平移画布宽度相同的距离,向右平移画布高度的距离。

case -180:

case 180:

context.translate(-width, -height);

}

拥有了对画布坐标系重置的能力,我们能够将画布逆时针旋转90°、甚至180°,都是可行的。如下:

当然重置画布坐标系后,需要注意清屏时,清屏的范围也有可能发生变化,需要稍作如下处理。

const clear = () => {

let width;

let height;

switch (this.degree) { // this.degree是画布坐标系旋转的度数

case -90:

case 90:

width = this.height; // 画布旋转之前的高度

height = this.width; // 画布选择之前的宽度

break;

default:

width = this.width;

height = this.height;

}

this.context.clearRect(0, 0, width, height);

};

方案一简单粗暴,布局上,canvas画布虽然不需要旋转,但需要单独绝对定位布局,给页面视觉展示带来不便,同时,上传图片之前需要对图片做旋转、缩放等处理,流程复杂。

方案二用纠正画布坐标系的方式,省去了布局和图片上的特殊处理,一步到位,因此方案二更佳。

以上,涉及的代码可以在这里找到:canvas-draw,这是一个借助vue cli 搭建起来的壳,主要是为了方便调试,核心代码见 canvas-draw/draw.js,喜欢的同学不妨轻点star。

本问就讨论这么多内容,大家有什么问题或好的想法欢迎在下方参与留言和评论.

本文作者:louis

参考文章:

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值