基于canvas实现的多功能画板

前言

最近空闲时间比较多,就想做点小工具玩玩,方案选了好几个,最终决定做一个基于canvas的画板,目前已经完成了第一版,有以下主要功能

  1. 画笔(动态宽度设置,颜色设置)
  2. 橡皮擦
  3. 撤回,反撤回,清除画板,保存
  4. 画板拖拽
  5. 多图层

预览

目前实现效果如下

预览地址:https://lhrun.github.io/paint-board/
repo:https://github.com/LHRUN/paint-board

画板设计

  1. 首先是建立一个canvas画板类,所有canvas上的操作和数据全都在此处理,例如初始化,渲染,拖拽画板等等
    class PaintBoard {
      canvas: HTMLCanvasElement
      context: CanvasRenderingContext2D
      ...
      constructor(canvas: HTMLCanvasElement) {}
      // 初始化canvas
      initCanvas() {}
      // 渲染
      render() {}
      // 拖拽
      drag() {}
      ...
    }
  2. 然后基于canvas类,根据当前的操作,建立对应的canvas元素,比如画笔,橡皮擦,基本类型如下
    class CanvasElement {
      type: string // 元素类型
      layer: number // 图层
      // ...
      constructor(type: string, layer: number) {
        this.type = type
        this.layer = layer
        // ...
      }
      // ...
    }
  3. 最后根据渲染逻辑,还会封装一些通用的逻辑来改变canvas上最终的展示,比如撤回,反撤回,图层操作等等

画笔

  • 实现画笔效果首先要在鼠标按下时建立一个画笔元素,然后在构造函数中接受基础宽度,颜色,初始化鼠标移动记录和线宽记录,然后在鼠标移动时记录鼠标移动的坐标
  • 为了体现鼠标移动快,线宽就变窄,移动慢,线宽就恢复正常这个效果,我会计算当前移动的速度,然后根据速度计算线宽
    class FreeLine extends CanvasElement {
      ...
      constructor(color: string, width: number, layer: number) {
        this.positions = [] // 鼠标移动位置记录
        this.lineWidths = [0] // 线宽记录
        this.color = color // 当前绘线颜色
        this.maxWidth = width // 最大线宽
        this.minWidth = width / 2 // 最小线宽
        this.lastLineWidth = width // 最后绘线宽度
      }
    }
  • 记录鼠标位置和当前线宽
    interface MousePosition {
      x: number
      y: number
    }
    
    addPosition(position: MousePosition) {
      this.positions.push(position)
      // 处理当前线宽
      if (this.positions.length > 1) {
        const mouseSpeed = this._computedSpeed(
          this.positions[this.positions.length - 2],
          this.positions[this.positions.length - 1]
        )
        const lineWidth = this._computedLineWidth(mouseSpeed)
        this.lineWidths.push(lineWidth)
      }
    }
    
    /**
     * 计算移动速度
     * @param start 起点
     * @param end 终点
     */
    _computedSpeed(start: MousePosition, end: MousePosition) {
      // 获取距离
      const moveDistance = getDistance(start, end)
    
      const curTime = Date.now()
      // 获取移动间隔时间   lastMoveTime:最后鼠标移动时间
      const moveTime = curTime - this.lastMoveTime
      // 计算速度
      const mouseSpeed = moveDistance / moveTime
      // 更新最后移动时间
      this.lastMoveTime = curTime
      return mouseSpeed
    }
    
    /**
     * 计算画笔宽度
     * @param speed 鼠标移动速度
     */
    _computedLineWidth(speed: number) {
      let lineWidth = 0
      const minWidth = this.minWidth
      const maxWidth = this.maxWidth
      if (speed >= this.maxSpeed) {
        lineWidth = minWidth
      } else if (speed <= this.minSpeed) {
        lineWidth = maxWidth
      } else {
        lineWidth = maxWidth - (speed / this.maxSpeed) * maxWidth
      }
    
      lineWidth = lineWidth * (1 / 3) + this.lastLineWidth * (2 / 3)
      this.lastLineWidth = lineWidth
      return lineWidth
    }
  • 保存坐标后,渲染就是遍历所有坐标
    function freeLineRender(
      context: CanvasRenderingContext2D,
      instance: FreeLine
    ) {
      context.save()
      context.lineCap = 'round'
      context.lineJoin = 'round'
      context.strokeStyle = instance.color
      for (let i = 1; i < instance.positions.length; i++) {
        _drawLine(instance, i, context)
      }
      context.restore()
    }
    
    /**
     * 画笔轨迹是借鉴了网上的一些方案,分两种情况
     * 1. 如果是前两个坐标,就通过lineTo连接即可
     * 2. 如果是前两个坐标之后的坐标,就采用贝塞尔曲线进行连接,
     *    比如现在有a, b, c 三个点,到c点时,把ab坐标的中间点作为起点
     *     bc坐标的中间点作为终点,b点作为控制点进行连接
     */
    function _drawLine(
      instance: FreeLine,
      i: number,
      context: CanvasRenderingContext2D
    ) {
      const { positions, lineWidths } = instance
      const { x: centerX, y: centerY } = positions[i - 1]
      const { x: endX, y: endY } = positions[i]
      context.beginPath()
      if (i == 1) {
        context.moveTo(centerX, centerY)
        context.lineTo(endX, endY)
      } else {
        const { x: startX, y: startY } = positions[i - 2]
        const lastX = (startX + centerX) / 2
        const lastY = (startY + centerY) / 2
        const x = (centerX + endX) / 2
        const y = (centerY + endY) / 2
        context.moveTo(lastX, lastY)
        context.quadraticCurveTo(centerX, centerY, x, y)
      }
    
      context.lineWidth = lineWidths[i]
      context.stroke()
    }

橡皮擦

  • 橡皮擦是一个线状擦除,我采用的方案是通过计算每个点的圆弧轨迹和两个点之间的矩形区域,然后通过clip剪切后清除
    /**
     * 橡皮擦渲染
     * @param context canvas二维渲染上下文
     * @param cleanCanvas 清除画板
     * @param instance CleanLine
     */
    function cleanLineRender(
      context: CanvasRenderingContext2D,
      cleanCanvas: () => void,
      instance: CleanLine
    ) {
      for (let i = 0; i < instance.positions.length - 1; i++) {
        _cleanLine(
          instance.positions[i],
          instance.positions[i + 1],
          context,
          cleanCanvas,
          instance.cleanWidth
        )
      }
    }
    
    /**
     * 线状清除
     * @param start 起点
     * @param end 终点
     * @param context canvas二维渲染上下文
     * @param cleanCanvas 清除画板
     * @param cleanWidth 清楚宽度
     */
    function _cleanLine(
      start: MousePosition,
      end: MousePosition,
      context: CanvasRenderingContext2D,
      cleanCanvas: () => void,
      cleanWidth: number
    ){
      const { x: x1, y: y1 } = start
      const { x: x2, y: y2 } = end
    
      // 获取鼠标起点和终点之间的矩形区域端点
      const asin = cleanWidth * Math.sin(Math.atan((y2 - y1) / (x2 - x1)))
      const acos = cleanWidth * Math.cos(Math.atan((y2 - y1) / (x2 - x1)))
      const x3 = x1 + asin
      const y3 = y1 - acos
      const x4 = x1 - asin
      const y4 = y1 + acos
      const x5 = x2 + asin
      const y5 = y2 - acos
      const x6 = x2 - asin
      const y6 = y2 + acos
    
      // 清除末端圆弧
      context.save()
      context.beginPath()
      context.arc(x2, y2, cleanWidth, 0, 2 * Math.PI)
      context.clip()
      cleanCanvas()
      context.restore()
    
      // 清除矩形区域
      context.save()
      context.beginPath()
      context.moveTo(x3, y3)
      context.lineTo(x5, y5)
      context.lineTo(x6, y6)
      context.lineTo(x4, y4)
      context.closePath()
      context.clip()
      cleanCanvas()
      context.restore()
    }

撤回、反撤回

  • 实现撤回,反撤回就要把canvas上的每个元素的渲染数据进行存储,通过改变控制变量,限制渲染元素的遍历,这样就可以达到撤回的效果
  • 首先画板初始化时建立一个history类,然后建立缓存和step数据,撤回和反撤回时,只需要修改step即可
    class History<T> {
      cacheQueue: T[]
      step: number
      constructor(cacheQueue: T[]) {
        this.cacheQueue = cacheQueue
        this.step = cacheQueue.length - 1
      }
      // 添加数据
      add(data: T) {
        // 如果在回退时添加数据就删除暂存数据
        if (this.step !== this.cacheQueue.length - 1) {
          this.cacheQueue.length = this.step + 1
        }
        this.cacheQueue.push(data)
        this.step = this.cacheQueue.length - 1
      }
    
      // 遍历cacheQueue
      each(cb?: (ele: T, i: number) => void) {
        for (let i = 0; i <= this.step; i++) {
          cb?.(this.cacheQueue[i], i)
        }
      }
    
      // 后退
      undo() {
        if (this.step >= 0) {
          this.step--
          return this.cacheQueue[this.step]
        }
      }
    
      // 前进
      redo() {
        if (this.step < this.cacheQueue.length - 1) {
          this.step++
          return this.cacheQueue[this.step]
        }
      }
    }
  • 针对画板,通过监听鼠标按下操作,在history中添加一个元素,然后对渲染函数的遍历限制到step就达到了撤回的效果
    class PaintBoard {
      ...
      /**
       * 记录当前元素,并加入history
       */
      recordCurrent(type: string) {
        let ele: ELEMENT_INSTANCE | null = null
        switch (type) {
          case CANVAS_ELE_TYPE.FREE_LINE:
            ele = new FreeLine(
              this.currentLineColor,
              this.currentLineWidth,
              this.layer.current
            )
            break
          case CANVAS_ELE_TYPE.CLEAN_LINE:
            ele = new CleanLine(this.cleanWidth, this.layer.current)
            break
          default:
            break
        }
        if (ele) {
          this.history.add(ele)
          this.currentEle = ele
        }
      }
    
      /**
       * 遍历history渲染数据
       */
      render() {
        // 清除画布
        this.cleanCanvas()
        // 遍历history
        this.history.each((ele) => {
          this.context.save()
          // render....
          this.context,resore()
        })
        // 缓存数据
        this.cache()
      }
    }

拖拽画布

  • 拖拽画布的实现是通过计算鼠标移动距离,根据距离改变画布的原点位置,达到拖拽的效果
function drag(position: MousePosition) {
  const mousePosition = {
    x: position.x - this.canvasRect.left,
    y: position.y - this.canvasRect.top
  }
  if (this.originPosition.x && this.originPosition.y) {
    const translteX = mousePosition.x - this.originPosition.x
    const translteY = mousePosition.y - this.originPosition.y
    this.context.translate(translteX, translteY)
    this.originTranslate = {
      x: translteX + this.originTranslate.x,
      y: translteY + this.originTranslate.y
    }
    this.render()
  }
  this.originPosition = mousePosition
}

多图层

实现多图层需要对以下几个地方进行处理

  1. 画板初始化时建立图层类,所有的图层数据和图层逻辑全在此处
  2. 然后对canvas上的元素加layer属性,用于判断归属于哪个图层
  3. 画板的渲染函数改为按照图层顺序进行渲染
  4. 拖拽或者隐藏图层都需要重新渲染,删除图层把对应的缓存图层元素进行删除
interface ILayer {
  id: number // 图层id
  title: string // 图层名称
  show: boolean // 图层展示状态
}

/**
 * 图层
 */
class Layer {
  stack: ILayer[] // 图层数据
  current: number // 当前图层
  render: () => void // 画板渲染事件

  constructor(render: () => void, initData?: Layer) {
    const {
      stack = [
        {
          id: 1,
          title: 'item1',
          show: true
        }
      ],
      id = 1,
      current = 1
    } = initData || {}
    this.stack = stack
    this.id = id
    this.current = current
    this.render = render
  }
  ...
}

class PaintBoard {
  // 通过图层进行排序
   sortOnLayer() {
     this.history.sort((a, b) => {
       return (
         this.layer.stack.findIndex(({ id }) => id === b?.layer) -
         this.layer.stack.findIndex(({ id }) => id === a?.layer)
       )
     })
   }

   // 渲染函数只渲染图层展示状态的元素
   render() {
     const showLayerIds = new Set(
       this.layer.stack.reduce<number[]>((acc, cur) => {
         return cur.show ? [...acc, cur.id] : acc
       }, [])
     )
     this.history.each((ele) => {
       if (ele?.layer && showLayerIds.has(ele.layer)) {
         ...
       }
     } 
   }
}

总结

  • 我本篇主要是分享一些主要逻辑,还有一些兼容问题和一些UI交互就不叙述了
  • 这个画板写下来大概用了一个星期,有好多功能还没写上,如果过段时间有空的话就继续写下去,并进一步优化,现在还是有点优化问题没有写好,比如画笔宽度显示的还是有点问题,原点位置和一些初始化设计的不太好,不过写完这个画板还是挺有成就感的

参考资料