资讯专栏INFORMATION COLUMN

匠心打造canvas签名组件

MAX_zuo / 2717人阅读

摘要:原文匠心打造签名组件导读月又是项目吃紧的时候,一大波需求袭来,猝不及防。可以先戳这里体验把后面将要提到的签名组件。剩下的也是绑定事件中关键的一步。设置完成了上述功能,一个签名插件就已经成型了。

本文首发于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

本文链接: http://louiszhai.github.io/20...

参考文章:

HTML5 canvas transform与矩阵

Canvas之平移translate、旋转rotate、缩放scale

文章版权归作者所有,未经允许请勿转载,若此文章存在违规行为,您可以联系管理员删除。

转载请注明本文地址:https://www.ucloud.cn/yun/83953.html

相关文章

  • 2017-07-08 前端日报

    摘要:前端日报精选精读与提案知乎专栏第期认识引擎记录一次利用工具进行性能优化的真实案例简书中的使用规则教程继承的实现方法个人文章中文译组件渲染性能探索个人文章周刊第期表单性能的改进实践知乎专栏简单可重用的图表库知乎专栏 2017-07-08 前端日报 精选 精读 TC39 与 ECMAScript 提案 - 知乎专栏【第989期】认识 V8 引擎记录一次利用 Timeline/Perform...

    王岩威 评论0 收藏0
  • 匠心打造Vue侧滑菜单组件

    摘要:本文介绍一个简单的类似的布局组件的实现,基于。介绍的内容已经制作成组件。即当不可以拖出抽屉时,应触发默认事件,比如垂直方向的滚动等等。这种优化可以将一部分复杂的计算工作提前准备好,使页面的反应更为快速灵敏。 本文介绍一个简单的DrawerLayout(类似Android的DrawerLayout)布局组件的实现,基于Vue.js。介绍的内容已经制作成 vue-drawer-layout...

    sutaking 评论0 收藏0
  • 匠心打造Vue侧滑菜单组件

    摘要:本文介绍一个简单的类似的布局组件的实现,基于。介绍的内容已经制作成组件。即当不可以拖出抽屉时,应触发默认事件,比如垂直方向的滚动等等。这种优化可以将一部分复杂的计算工作提前准备好,使页面的反应更为快速灵敏。 本文介绍一个简单的DrawerLayout(类似Android的DrawerLayout)布局组件的实现,基于Vue.js。介绍的内容已经制作成 vue-drawer-layout...

    张汉庆 评论0 收藏0
  • 匠心和铁手,打造自己的人生 —— Wtser

    摘要:刚刚踏入编程世界大门的你,是不是对程序员生活充满了是不是幻想前方是一条令人热血沸腾的杀怪之路亦或是默默坐在电脑前做孤独英雄一辈子充满好奇,不如来看看最具匠心的工程师王铁手的感悟。 刚刚踏入编程世界大门的你,是不是对程序员生活充满了 YY?是不是幻想前方是一条令人热血沸腾的杀怪之路?亦或是默默坐在电脑前做孤独英雄一辈子?充满好奇,不如来看看 SegmentFault 最具匠心的工程师——...

    luckyw 评论0 收藏0

发表评论

0条评论

最新活动
阅读需要支付1元查看
<