使用原生JavaScript实现一个canvas画板

4,778 阅读4分钟

前言

  1. 本文在 canvas实现画板功能的基础上进行了一些完善

  2. 可以通过Canvas_API了解canvas。

  3. 代码直接使用了ES6的语法,在谷歌浏览器(版本 76.0.3809.100)上能实现预期的效果。

实现过程

添加元素

在HTML中添加上操作按钮以及canvas元素。

  <div id="operations">
    <input type="button" id="pencil" value="铅笔"/>
    <input type="button" id="straightLine" value="直线"/>
    <input type="button" id="rectangle" value="矩形"/>
    <input type="button" id="solidRectangle" value="实心矩形"/>
    <input type="button" id="circle" value="圆形"/>
    <input type="button" id="solidCircle" value="实心圆形"/>
    <input type="button" id="eraser" value="橡皮擦"/>
    <input type="button" id="image" value="导入图片"/>
    <input type="button" id="save" value="保存"/>
    <input type="button" id="redo" value="重做"/>
    <input type="button" id="undo" value="撤销"/>
    <input type="button" id="clear" value="清除"/>
    <label>颜色:<input type="color" id="color" /></label>
    <label>线条粗细:1<input type="range" id="lineWidth" min="1" max="100" value="1"/>100</label>
    <input type="file" id="imageFile" name="image"/>
    <a id="downloadLink"></a>
  </div>
  <div class="canvas-container">
    <canvas id="canvas" width="800" height="800"></canvas>
  </div>

添加处理事件

创建了一个名为Draw的类,其中handleOperations属性用于放置按钮的处理函数。handleMousemove属性用于处理选中不同类型(例如直线和铅笔)的情况下,在mousemove的过程中需要做的处理。

class Draw {
  constructor (elements) {
    const { canvas, color, lineWidth, operations, imageFile, downloadLink } = elements; // 控制画布的元素
    this.type = 'pencil'; // 类型初始化为铅笔
    this.canvas = canvas; // canvas元素
    this.context = canvas.getContext('2d'); // 获取canvas的2d上下文对象
    ...
 },
 ...
 handleOperations () { ... }
 handleMousemove () { ... }

绑定元素的点击事件

前几个按钮的点击事件设置Draw实例的type属性。

  • clear事件把画布用背景色填充,达到“清除”的效果。
  • image事件模拟typefileinput框的点击,通过FileReader对象获取到input框选中的文件的base64地址,并使用drawImage将其绘制到canvas上。
  • save事件通过toDataURL方法得到当前画布的base64地址,并设置a标签的download属性,调用元素的click()方法模拟点击,从而下载文件。
  • redo事件和undo事件的思路差不多,都是通过拿到保存在historyUrls中的base64地址,调用canvasdrawImage方法将图片绘制到canvas中。只不过一个是往前拿(undo),一个是往后拿(redo)。
handleOperations () {
  return {
    pencil: () => { this.type = 'pencil'; }, // 铅笔按钮绑定的事件
    straightLine: () => { this.type = 'straightLine'; }, // 直线按钮绑定的事件
    rectangle: () => { this.type = 'rectangle'; }, // 矩形按钮绑定的事件
    solidRectangle: () => { this.type = 'solidRectangle'; }, // 实心矩形按钮绑定的事件
    eraser: () => { this.type = 'eraser'; }, // 橡皮擦绑定的事件
    circle: () => { this.type = 'circle'; }, // 圆形按钮绑定的事件
    solidCircle: () => { this.type = 'solidCircle'; }, // 实心圆形按钮绑定的事件
    clear: () => { this.clear(); }, // 清除按钮绑定的事件
    image: () => { // 导入图片按钮绑定的事件
      this.imageFile.click();
      this.imageFile.onchange = (event) => {
        let reader = new FileReader();
        reader.readAsDataURL(event.target.files[0]);
        reader.onload = (evt) => {
          let img = new Image();
          img.src = evt.target.result;
          img.onload = () => {
            this.context.drawImage(img, 0, 0); // 将图片画在画布上
            this.addHistory();
          };
        }
      }
    },
    save: () => { // 保存按钮绑定的事件
      this.downloadLink.href = this.canvas.toDataURL('image/png');
      this.downloadLink.download = 'drawing.png';
      this.downloadLink.click();
    },
    redo: () => { // 重做按钮绑定的事件
      let length = this.historyUrls.length;
      let currentIndex = this.currentHistoryIndex + 1;
      if (currentIndex > length - 1 ) {
        this.currentHistoryIndex = length - 1;
        return;
      };
      this.currentHistoryIndex = currentIndex;
      this.historyImage.src = this.historyUrls[currentIndex];
      this.historyImage.onload = () => {
        this.context.drawImage(this.historyImage, 0, 0);
      }
    },
    undo: () => { // 撤回按钮绑定的事件
      let currentIndex = this.currentHistoryIndex - 1;
      if (currentIndex < 0) { 
        currentIndex === -1 && this.clear();
        this.currentHistoryIndex = -1;
        return;
      }
      this.currentHistoryIndex = currentIndex;
      this.historyImage.src = this.historyUrls[currentIndex];
      this.historyImage.onload = () => {
        this.context.drawImage(this.historyImage, 0, 0);
      }
    }
  }
}

鼠标移动的过程中触发的事件

绑定canvasmousemove事件处理函数。

this.canvas.addEventListener('mousemove', (event) => {
  if (this.isDrawing) {
    const { clientX, clientY } = event;
    const x = clientX - offsetLeft;
    const y = clientY - offsetTop;
    let newOriginX = originX, newOriginY = originY;
    let distanceX = Math.abs(x-originX);
    let distanceY = Math.abs(y-originY);

    // 让形状左上角的坐标永远大于右下角坐标,保证图形能正常绘制
    if (x < originX) newOriginX = x;
    if (y < originY) newOriginY = y;
    
    // (x, y)为鼠标移动的过程中在画布上的坐标,(originX, originY)为鼠标点击时在画布上的坐标,
    //(newOriginX, newOriginY)为绘制形状(比如矩形)时形状左上角的坐标
    const mousePosition = { x, y, originX, originY, newOriginX, newOriginY, distanceX, distanceY };
    let handleMousemove = this.handleMousemove();
    let currentHandleMousemove = handleMousemove[this.type]; // 根据当前类型的不同采取不同的操作
    currentHandleMousemove && currentHandleMousemove(mousePosition);
  }
}, false);

mousemove的过程中会根据type的值做相应的处理。

  • pencilx, y是鼠标移动的过程中的坐标,直接使用lineTo将线条连接到当前的(x, y)坐标,就能实现铅笔的效果了。
  • eraser:和铅笔的实现方式相同,不过橡皮擦需要设置线条的颜色为画布的背景色,这样看起来就像被擦掉了一样。在擦除之后需要把线条颜色重新置为当前color元素选中的颜色(这部分的处理放在mouseup中,而不是mousemove中比较好)。
  • straightLine:将绘画的起点移动到鼠标点击的那个点(originX, originY),再将起点和鼠标移动时的(x, y)连接,就能达到直线的效果了。这里的this.reDraw();是为了防止在mousemove的过程中把“轨迹”也绘制出来。
  • 矩形的绘制、圆形的绘制和直线类似,只是调用的方法不同,而且在调用绘图方法时,必须保证绘制的形状的左上角要高于右上角,否则无法正常地绘制。
handleMousemove () {
  return {
    pencil: (mousePosition) => {
      const { x, y } = mousePosition;
      this.context.lineTo(x, y);
      this.context.stroke();
    },
    eraser: (mousePosition) => {
      const { x, y } = mousePosition;
      this.context.strokeStyle = this.canvasBackground;;
      this.context.lineTo(x, y);
      this.context.stroke();
      this.context.strokeStyle = this.color.value;
      this.context.fillStyle = this.color.value;
    },
    straightLine: (mousePosition) => {
      let { x, y, originX, originY } = mousePosition;
      this.reDraw();       

      this.context.moveTo(originX, originY);
      this.context.lineTo(x, y);
      this.context.stroke();

      this.context.closePath();
    },
    rectangle: (mousePosition) => {
      let {newOriginX, newOriginY, distanceX, distanceY  } = mousePosition;
      this.reDraw();
      this.context.rect(newOriginX, newOriginY, distanceX, distanceY);
      this.context.stroke();

      this.context.closePath();
    },
    solidRectangle: (mousePosition) => {
      let { newOriginX, newOriginY, distanceX, distanceY } = mousePosition;
      this.reDraw();
      this.context.fillRect(newOriginX, newOriginY, distanceX, distanceY);
      this.context.closePath();
    },
    circle: (mousePosition) => {
      let { newOriginX, newOriginY, distanceX, distanceY } = mousePosition;
      this.reDraw();

      let r = Math.sqrt(distanceX * distanceX + distanceY * distanceY);
      this.context.arc(distanceX + newOriginX, distanceY + newOriginY , r, 0, 2 * Math.PI);
      this.context.stroke();

      this.context.closePath();
    },
    solidCircle: (mousePosition) => {
      let { newOriginX, newOriginY, distanceX, distanceY } = mousePosition;
      this.reDraw();       

      let r = Math.sqrt(distanceX * distanceX + distanceY * distanceY);
      this.context.arc(distanceX + newOriginX, distanceX + newOriginY , r, 0, 2 * Math.PI);
      this.context.fillStyle = this.color.value;
      this.context.fill();

      this.context.closePath();
    },
    clear: () => {
      this.clear();
    }
  }
}

解决的问题

  1. 画布上的像素值和页面的像素值不一致。

我在页面上画了个50px * 50px的正方形,又在canvas画布上用this.context.strokeRect( 0 ,0 ,50, 50);画了个正方形,发现画布上与页面上的像素大小不一致。

原因是我一开始没有在canvas元素的属性中定义宽高,只在css样式中定义了canvas元素的宽高。

.canvas {
  height: 800px;
  width: 800px;
  background-color: #ccc;
}

当没有设置宽度和高度的时候,canvas会初始化宽度为300像素和高度为150像素。

在canvas元素的属性中定义好宽高后就没问题了。

 <canvas id="canvas" class="canvas" width="800" height="800"></canvas>
  1. 成功获取图片的base64URL了,但是在画布上什么也画不出来。

这是因为图片还没有加载完成就开始绘制了,等图片加载完再绘就可以了。

 img.onload = () => {
    this.context.drawImage(img, 0, 0); // 将图片画在画布上
 };
  1. 下载的图片在电脑上打开是正常的,但是在手机上打开就是一片漆黑。 猜想是因为没有添加背景颜色的原因,手机默认使用黑色作为背景色,把线条“隐藏”了,使用不同颜色的画笔画了幅画保存之后,果然能看见彩色的画笔画的部分。所以只要图片添加上背景色就可以了。
    this.context.fillStyle = '#ffffff';
    this.context.fillRect(0, 0, 800, 800);

一开始橡皮擦使用了clearRect方法来擦除画布上的内容,添加背景色后就不能使用这种方法了,因为会把背景色也擦掉。将橡皮擦设置为白色的画笔来模拟擦除的效果。

之前橡皮擦的实现方式:

let eraserWidth = parseInt(this.lineWidth.value);
if (eraserWidth < 10) {
  eraserWidth = 10;  // 因为橡皮擦像素太小的时候清除的效果不明显,所以设置橡皮擦的最小宽度为5px
}
let halfEraserWidth = eraserWidth / 2;
this.context.clearRect(x - halfEraserWidth, y - halfEraserWidth, eraserWidth, eraserWidth);

处理完问题后橡皮擦的实现方式:

this.context.strokeStyle = '#ffffff';
this.context.lineTo(x, y);
this.context.stroke();
this.context.strokeStyle = this.color.value;

其他

  1. 源码地址
  2. 实现效果: