canvas实现签名板功能,支持清空、返回上一步、回放画笔书写过程。

665 阅读4分钟

bandicam 2022-10-15 23-30-38-224.gif
在线签名板这个功能使用的还是挺多的,今天我是用canvas实现了一版。

!!!注意!!!接下来文中说的
每一笔是指:鼠标按下-移动-鼠标抬起,这一连贯的动作,和我们正常用笔写字一样
每一笔的坐标是指:每一笔完成后鼠标在画布中书写时一个个的像素点连接起来的路径,构成这个路径的像素坐标的集合

实现的核心思路就是用一个历史记录列表drawRecord记录每一笔,coords记录每一笔的坐标信息,当鼠标按下时开始向coords中添加鼠标经过的每一个像素坐标,鼠标抬起后,将coords中的信息存放在一个数组中,作为drawRecord的一条数据,添加进历史记录列表drawRecord中,这样就完成了每一笔及其每一笔坐标信息的记录。

这个记录将用于返回上一步回放书写笔迹

回放书写笔迹:用历史记录drawRecord中的数据坐标信息绘制书写笔迹
返回上一步时:删除历史记录drawRecord中的最后一条数据,用剩余的坐标信息重新在画布中绘制笔迹

代码中我做了详细来的注释。

最后我在请教下大家:我使用requestAnimationFrame实现的定时器,在实际的使用过程中性能很差,特别是在回放时。有知道的还请帮忙指出,一起进步

index.html:

<!DOCTYPE html>
<html lang="en">
<head>
  <style>#canvas{border: 1px solid rgba(0, 0, 0, 0.401);}</style>
</head>
<body>
  <div>
    <button class="clear">清空</button>
    <button class="revoke">上一笔</button>
    <button class="playback">播放</button>
  </div>
  <canvas id="canvas"></canvas>
</body>
<script src="./index.js"></script>
<script>
const board = new DrawingBoard({
  selecter: '#canvas'
});
board.init();
document.querySelector('.clear').onclick = () => {
  board.clear()
};
document.querySelector('.playback').onclick = () => {
  board.playback()
};
document.querySelector('.revoke').onclick = () => {
  board.revoke()
};
</script>
</html>

index.js:

class DrawingBoard {

  constructor(param = {}) {
    this.width = param?.width ?? 400;
    this.height = param?.height ?? 300;
    this.color = param?.color ?? 'red';
    this.size = param?.size ?? 2;
    this.speed = param?.speed ?? 10;
    this.isOutStop = param?.isOutStop ?? true;

    /**
     * drawRecord: 绘制历史列表中,即每一笔的记录集合
     *             这是一个二维数组,里面的每一条数据都是一次起笔到抬起的路径集合,
     *             二级数组中的数据为这一笔划过的路径的坐标信息
     * coords: 每一笔划过路径的坐标信息
     * timerId: 存放定时器ID,用于随时取消回放
     * isDrag: 设置开始绘画的开关
     */
    this.drawRecord = [];
    this.coords = [];
    this.timerId = null;
    this.isDrag = false;
    this.isPlay = false;

    /** @type {HTMLCanvasElement} */
    this.canvas = document.querySelector(param.selecter);
    this.canvas.width = this.width;
    this.canvas.height = this.height;
    this.canvas.style.cursor = 'crosshair';

    this.ctx = this.canvas.getContext('2d');
  };

  /**
   * 初始化画笔样式
   * 为画布添加鼠标交互监听事件
   */
  init() {
    const {canvas, ctx, color, size} = this;

    ctx.lineWidth = size;
    ctx.lineJoin = 'round';
    ctx.strokeStyle = color;
    ctx.shadowColor = color;

    canvas.onmousedown = this.onmouseDown.bind(this);

    canvas.onmousemove = this.onmouseMove.bind(this);
    
    canvas.onmouseup = this.onmouseUp.bind(this);

    canvas.onmouseout = this.onmouseout.bind(this)
  };
  /**
   * 绘制线条
   */
  draw([x, y] = []) {
    this.ctx.lineTo(x, y)
    this.ctx.stroke();
  };

  onmouseDown() {
    if (this.isPlay) return;
    this.isDrag = true;
    this.ctx.beginPath();
  };

  onmouseMove(e) {
    if (this.isDrag) {
      const x = e.offsetX;
      const y = e.offsetY;

      this.draw([x, y])
      /**
       * 将当前画笔的路径坐标暂存起来,
       * 在鼠标抬起后将这一条绘制记录放入绘制历史列表中
       */
      this.coords.push([x, y])
    } 
  };

  onmouseUp() {
    this.isDrag = false;
    /**
     * 将当前绘画的这一笔存入到绘制历史记录中,
     * 并清空画笔坐标记录列表,避免下次重复记录到下一笔中
     */
    this.drawRecord.push([...this.coords]);
    this.coords = []
  };
  /**
   * 鼠标在绘制过程中超出画布是否要停止绘画
   * 超出后停止绘画的前提是鼠标必须处于按下的状态
   */
  onmouseout() {
    if (this.isOutStop && this.isDrag) {
      this.onmouseUp()
    }
  };
  /**
   * 清空画布
   */
  clear(resetDrawRecord = true) {
    if (resetDrawRecord) {
      this.drawRecord = []
    };
    // 清除定时器,避免在点击清空是正在回放的绘制未结束,导致不能正常清空内容
    clearTimeout(this.timerId)

    this.ctx.clearRect(0, 0, this.width, this.height)
  };

  /**
   * 播放绘制路径
   * @param { number } speed - 播放速度
   * 如果传入的speed为0,应当直接同步绘制,不需要定时器
   */
  playback(speed = this.speed) {
    // 播放前清空画布,但不清空记录
    this.clear(false);
    
    // 判断当前是否有绘制记录
    if (!this.drawRecord?.length) return;

    this.ctx.beginPath();
    
    const allCoord = this.collectAllCoords();

    this.isPlay = true;

    /**
     * 通过递归是便于控制播放的速度
     */
    const reDraw = (i) => {
      this.draw(allCoord[i]);
      
      clearTimeout(this.timerId);
      /**
       * 如果coord不存在说明已经超出数组范围了,则可以停止继续绘制了
       */
      const coord = allCoord[i + 1];
      if (!coord) {
        this.isPlay = false;
        return
      };
      /**
       * 当coord为reBeginPath时说明已经绘制到了新的一笔
       */
      if (coord === 'reBeginPath') {
        this.ctx.beginPath();
      };

      if (speed === 0) {
        reDraw(i + 1);
        return
      };

      this.timerId = setTimeout(() => {
        reDraw(i + 1)
      }, speed)
    }

    reDraw(0)
  };

  /**
   * 将画笔历史记录中每一笔的路径坐标按顺序放在一个数组中
   * 在每一此记录被放入时在前面添加一个重新起笔的标识reBeginPath
   * 播放书写过程中,如果不设置重新起笔
   * 所有的笔画都将被连接起来
   */
  collectAllCoords() {
    const allCoord = [];

    this.drawRecord.forEach((coords) => {
      allCoord.push('reBeginPath');
      allCoord.push(...coords);
    });

    return allCoord
  };
  /**
   * 返回上一笔
   */
  revoke() {
    this.drawRecord.pop();
    this.playback(0)
  };
  /**
   * 保存图片
   */
  saveImg() {
    if (this.drawRecord.length === 0) return;

    const a = document.createElement('a');
    a.download = 'user_name';
    a.style.display = 'none';

    a.href = this.canvas.toDataURL();
    document.body.appendChild(a);

    a.click();
    document.body.removeChild(a);
  };
  /**
   * 利用requestAnimationFrame实现setTimeout功能
   */
  _setTimeout(callback, delay) {

    let start = 0, timeStamp = new Date().getTime();

    const implement = (t) => {

      if (start === 0) {
        start = t
      };

      if (t >= (delay + start)) {
        window.cancelAnimationFrame(window[timeStamp]);
        delete window[timeStamp];
        callback();
        return
      };

      window.cancelAnimationFrame(window[timeStamp]);
      delete window[timeStamp];

      window[timeStamp] = window.requestAnimationFrame(implement);
    };

    window[timeStamp] = window.requestAnimationFrame(implement);

    return timeStamp
  };

  _clearTimeout(id) {
    window.cancelAnimationFrame(window[id]);
    delete window[id]
  }
};