# Canvas画板

使用canvas实现图片的绘制,并对图片进行涂鸦、缩放、添加文字、旋转等操作。

# 本次实现的功能及遇到的问题和解决方案

# 思维导图 (opens new window)

canvas

# 目录结构划分

# 1. 项目结构

项目结构

该项目框架的搭建也是基于现有的一个签名项目搭建的,因为现有项目的结构很清晰,才决定继续在上面完善功能

  • src/child/base.js:canvas属性相关设置
  • src/child/canvas.js:绘制的关键,包括绘制执行及历史记录的存储
  • src/child/dom.js:使用原生JS封装的操作dom的方法
  • src/child/text.js:模拟在canvas上添加文字及对文字拖拽的过程
  • src/child/util.js:封装的工具方法
  • src/canvas-handwriting.vue:画板的DOM
  • src/index.js:相当于整个画板组件的入口文件,对外提供了很多操作画板的方法

# 2. 历史记录存储结构

class PaintHistory{
    constructor(){
        this.undoHistory = []; // 撤回的依据,是撤回画笔还是文字
        this.history = []; // 写字的路径
        this.currentScale = 1; // 缩放倍数
        this.currentColor = ''; // 文字颜色(包括画笔和输入的文字)
        this.currentRotate = 0 ; // 旋转角度
        this.moveDistance = { // 平移的距离
            x: 0,
            y: 0
        }
        this.imageData = { // 图片信息
            data: null,
            dx: 0,
            dy: 0,
            width: 0,
            height: 0,
            originWidth: 0, // 旋转之前的图片宽度
            originHeight: 0 
        };
        this.textData = [] ; // 文字
    },
    set(history) {
        ...
    },
    get() {
        return {
            ...
        }
    },
    // 还有很多写入PaintHistory的方法
    ...
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33

# 3. dom结构

<template>
    <div class="canvas-handwriting" ref="canvasHandwriting">
        <!-- 页面头部 固定 -->
        <div class="canvas-header"></div>

        <!-- canvas画板 -->
        <div class="canvas-board"></div>

        <!-- 做了一个蒙层,处理添加文字的效果 -->
        <div class="canvas-mask"></div>

        <!-- 顶部操作栏 - 固定 -->
        <div class="canvas-operate"></div>

        <!-- 文字输入框 -->
        <div ref="canvasTextArea" class="canvas-text-area"></div>
    </div>
</template>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

# 实现canvas画板的一些前期准备和思路

慕课网上“liuyubobobo”老师的关于canvas相关的视频基本都观看并实现了一遍,有些章节可能还反复看了多次。

该项目的难点是在画布的 缩放、写字和旋转 上,涉及到的相关计算很多(好像除了画线都是难点,笑哭...)。因此在掘金、github等各个技术网站上尝试找一个成熟的案例,发现很困难,要不就是demo实现的功能简单,要不就是非vue写的,超出了我的认知范畴。因此还是尝试自己写,如履薄冰吧。

# 具体的实现步骤及解决方案

  • # 坐标转换

/**
* 坐标转换
* @param {number} clientX: 鼠标位置  
* @param {number} clientY:鼠标位置
* @param {boolean} isAddText:是否为添加文字
*/
function computePos(clientX, clientY, isAddText){
    let historyData = this.paint.get() ,
        move = historyData.move ;

    let tempX = (clientX - this.canvasDOMPos.clientX - (move.x / this.deviceRatio)) / this.cssWidth * this.width,
        tempY = (clientY - this.canvasDOMPos.clientY - (move.y / this.deviceRatio)) / this.cssHeight * this.height;

    if( isAddText ) {
        this.x  = tempX;
        this.y = tempY;
    }else {
        let point = this.computeRotatePos(historyData.rotate , tempX, tempY) ;
        this.x = point.x;
        this.y = point.y ;
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
  • # 图片绘制

    看上去是个很简单的事情,实际上也还是有很多讲究的,并不是纯粹的调用drawImage那么简单。

    • 首先需要考虑图片如何自适应容器;写了一个公共方法
    /**
    * 图片自适应容器
    * @param {number} w: 图片的原始宽度
    * @param {number} h: 图片的原始高度
    * @param {number} maxW: 容器的宽度
    * @param {number} maxH: 容器的高度
    */
    export function scaleAdaptive(w, h, maxW, maxH) {
        if (w > maxW) {
            return scaleAdaptive(maxW, h / (w / maxW), maxW, maxH);
        }
        if (h > maxH) {
            return scaleAdaptive(w / (h / maxH), maxH, maxW, maxH);
        }
        return [w, h];
    }
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    • 其次如何更好的将图片绘制在容器的中间,也就是canvas画布的中间 假设画布的原点在容器的左上角,如果图片的宽高不及容器的宽高,那么我们在绘制图片时需要拿容器的宽高与图片的宽高对比去计算绘制图片的起点位置,计算麻烦。
      如果使用canvas的translate方法将画布的原点移至容器的中点,再去绘制会容易很多;

      // 先平移
      this.context.translate(this.width / 2, this.height / 2);
      
      // 再绘制
      this.context.drawImage(imageData.data, imageData.width/-2, imageData.height/-2, imageData.width, imageData.height);
      
      1
      2
      3
      4
      5
    • 绘制出来的图片在手机上很模糊 模糊的原因是屏幕的物理像素分辨率与css像素分辨率不一致导致,那么我们在绘制时便要先算出设备像素比,然后再根据设备像素比设置canvas的宽高。

      function getDevicePixelRatio(context) {
          let devicePixelRatio = window.devicePixelRatio || 1,
              backingStoreRatio = context.webkitBackingStorePixelRatio ||
                                  context.mozBackingStorePixelRatio ||
                                  context.msBackingStorePixelRatio ||
                                  context.oBackingStorePixelRatio ||
                                  context.backingStorePixelRatio || 1 ;
      
          return parseInt(devicePixelRatio) / parseInt(backingStoreRatio);
      }
      
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      this.width = width * this.deviceRatio;     //画布宽度
      this.height = height * this.deviceRatio;   //画布高度
      this.canvasDOM.setAttribute('width',this.width);
      this.canvasDOM.setAttribute('height',this.height);  
      this.canvasDOM.style.width = '100%';    //撑满父容器
      this.canvasDOM.style.height = '100%';
      
      1
      2
      3
      4
      5
      6
    • 最后就是图片的跨域问题
      创建image对象时,设置crossOrigin='Anonymous',服务端也需进行相应的跨域设置;

      分场景讲解跨域引发的问题及解决方案 (opens new window)

  • # 画线

    画线是画板中最简单的功能了,要做的转换坐标并将坐标存入历史记录即可。

    /**
    * 记录每一条画线的相关属性
    */
    function start(x,y){
        this.history.push({
            strokeStyle: this.currentColor, // 画线的 颜色
            rotate: this.currentRotate, // 旋转角度
            moveTo:[x,y], // 画线的起点
            path:[] // 起点以外的其他坐标
        });
    }
    function join(x,y){
        this.history[this.history.length - 1].path.push([x,y]);
    }
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
  • # 缩放

    首先想到的是通过计算两点间的距离来作为画布上图片被放大的倍数,那么两点间的距离怎么计算呢?

    /**
    * 计算两点间的距离
    * @param {object} start: 起点坐标位置  
    * @param {object} stop :终点坐标位置
    */
    function getInstance(start, stop) {
        return Math.hypot(stop.x - start.x, stop.y - start.y);
    }
    
    1
    2
    3
    4
    5
    6
    7
    8

    图片缩放能正常使用,但当加上画线的路径之后,再缩放,发现原有路径对应的坐标再经过一系列的计算后,始终与缩放后画布的位置对不上,好难算...

    因此尝试使用canvas提供的 scale() 方法,对画布的坐标系进行缩放。此时则不再需要考虑当画布缩放后原来的坐标如何去计算的问题。

    this.context.scale(historyData.scale, historyData.scale)
    
    1
  • # 添加文字

    添加文字也尝试了两种实现方式:

    1. 使用 fillText() 将文字绘制到canvas上,并给文字增加鼠标事件,进行文字的拖拽;
    2. 在蒙层上处理好文字的位置,并将当前在蒙层上的文字的位置转换成canvas坐标系中的坐标,然后使用 fillText() 写入canvas。

    在使用第一种方式时,发现要给画布上的每一个元素去绑定事件,是一个麻烦的事情... 因为canvas就是一个整体,它不像DOM一样有层级结构,如果非要去对画布上的元素去绑定事件的话,只能根据当前鼠标点击的位置去查找对应画布上是否有元素,如果有再进行事件绑定。

    对于画布上规则的图形来说,是一个好的方法。当面对不规则图形时,要判断当前鼠标位置是否在图形中,如果再通过以上方式处理的话,你会发现很麻烦。而canvas确实非常强大,提供了一个API解决这个问题,那就是 isPointInPath()(还未实践过,待后续研究...)。

    我目前使用的是第二种方式处理的添加文字,但有一个缺陷是,交互体验不友好。当添加完文字后,不能对文字再进行拖拽位移,后续尝试使用第一种方式去进行优化。

    historyData.textData.forEach( t=> {
        this.context.rotate(t.rotate * Math.PI);
        let fontSize = 18 * this.deviceRatio;
        this.context.font = 'bold ' + fontSize + 'px sans-serif';
        this.context.fillStyle = t.textColor;
        this.context.fillText(t.text, t.x, t.y, t.width);
    })
    
    1
    2
    3
    4
    5
    6
    7

    除了canvas本身的问题之外,还遇到了一些移动端兼容的相关问题,也是花费了不少的时间来解决,下面一一来说明吧。

    • ios上使用inputfocus()不聚焦;

      我开始是这么写的,代码实际被有效执行,但是输入框始终不能聚焦,而不能唤起软键盘。

      其实这是出于ios的安全机制限制,ios上只有用户交互触发的focus事件才会生效,而延时回调的focus是不会触发的。

      而我页面中的input是动态显示的,在某些情况下dom中不会有input,因此最后是使用opacity解决的问题。

      this.$nextTick( () => {
          this.$refs['canvasInput'].focus();
      })
      
      1
      2
      3
    • ios下软键盘遮挡输入框;

      在上面问题解决后,这个问题又接踵而至。也是一顿百度后,找到一个方法scrollIntoView,完美的解决了这个问题。

      /**
       * scrollIntoView: 滚动浏览器窗口或容器元素,以便在当前视窗的可见范围看见当前元素。
       * @param {boolen} alignWithTop: 
          若为 true,或者什么都不传,那么窗口滚动之后会让调用元素的顶部与视口顶部尽可能平齐;
       *  若为 false,调用元素会尽可能全部出现在视口中,可能的话,调用元素的底部会与视口顶部平齐,不过顶部不一定平齐。 
       */
      this.$refs['canvasInput'].scrollIntoView(true);
      
      1
      2
      3
      4
      5
      6
      7
    • 如何自定义软键盘的换行按钮的文字和样式;

      html<input>type属性可以实现一部分软键盘换行按钮被定制的场景,但明显在本项目中无法通过type去定义。因为这里的type="text",那么在这种情况如果去实现产品小姐姐的要求?那肯定第一时间去找张鑫旭大哥的博客,他对原生的属性和方法的使用和输出是很全面的。不出所料,确实有这样的属性enterkeyhint

      HTML enterkeyhint设置iOS/Android键盘enter键 (opens new window)

    • 如何捕获软键盘上方的完成按钮事件;
      通过inputblur()解决了一部分场景,还有些问题;

    • Android下唤起软键盘时页面被挤压。

      this.$refs['canvasHandwriting'].style.height = document.body.offsetHeight;
      
      1
  • # 旋转

    使用canvas提供的API进行画布旋转

    this.context.rotate( historyData.rotate * Math.PI );
    
    1

    要注意的点是,在画布旋转后,再使用画笔,需要将当前画笔的位置转换为画布旋转后的位置,见下方公式。同时这其中还涉及到一个要计算的点是,画布旋转后,需要计算图片的宽高并重绘。那么这就会影响原有画笔绘制的路径,因此需要根据画布旋转前后的图片宽高比再计算一次原有路径的坐标。

    /**
     * 坐标轴逆时针旋转后的坐标位置计算
     * @param {number} rotate: 旋转的角度 0~2之间的值
     */
    function computeRotatePos(rotate, clientX, clientY) {
        let centerX = this.width / 2, // 坐标轴原点坐标
            centerY = this.height / 2 ; 
    
        return {
            x: (clientX - centerX) * Math.cos( rotate * Math.PI) + (clientY - centerY) * Math.sin(rotate* Math.PI) + centerX ,
            y: (clientY - centerY) * Math.cos(rotate * Math.PI) - (clientX - centerX) * Math.sin(rotate* Math.PI) + centerY 
        }
    
        // 如果是顺时针旋转,则计算公式调整为:
        // return {
        //     x: (clientX - centerX) * Math.cos( rotate * Math.PI) - (clientY - centerY) * Math.sin(rotate* Math.PI) + centerX ,
        //     y: (clientY - centerY) * Math.cos(rotate * Math.PI) + (clientX - centerX) * Math.sin(rotate* Math.PI) + centerY 
        // }
    }
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
  • # 其他

    做完以后就没管代码调优了,其实还有很多优化的点:

    • 边界检测的计算
    • 文字增加拖拽
    • deviceRatio的应用,哪些场景下才需要
    • 有没有性能调优的空间(目前是每进行一次调整,都会重新绘制一遍)

# 权威且实用的API

# MDN (opens new window)
# 张鑫旭 (opens new window)
最后更新: 12/27/2020, 2:00:17 PM