4

foreword

I have a lot of free time recently, so I just wanted to make some gadgets to play with. I chose several plans, and finally decided to make a canvas-based drawing board. The first version has been completed, with the following main functions

  1. Brushes (dynamic width settings, color settings)
  2. Eraser
  3. Undo, Undo, Clear Artboard, Save
  4. Artboard drag and drop
  5. multi-layer

preview

The current effect is as follows

Preview address: https://lhrun.github.io/paint-board/
repo: https://github.com/LHRUN/paint-board welcome star⭐️

artboard design

  1. The first is to create a canvas artboard class, where all operations and data on canvas are processed, such as initialization, rendering, dragging and dropping artboards, etc.

     class PaintBoard {
      canvas: HTMLCanvasElement
      context: CanvasRenderingContext2D
      ...
      constructor(canvas: HTMLCanvasElement) {}
      // 初始化canvas
      initCanvas() {}
      // 渲染
      render() {}
      // 拖拽
      drag() {}
      ...
    }
  2. Then based on the canvas class, according to the current operation, create corresponding canvas elements, such as brushes, erasers, the basic types are as follows

     class CanvasElement {
      type: string // 元素类型
      layer: number // 图层
      // ...
      constructor(type: string, layer: number) {
       this.type = type
       this.layer = layer
       // ...
      }
      // ...
    }
  3. Finally, according to the rendering logic, some general logic will be encapsulated to change the final display on the canvas, such as retraction, reverse retraction, layer operations, etc.

brush

  • To achieve the brush effect, first create a brush element when the mouse is pressed, then accept the base width and color in the constructor, initialize the mouse movement record and line width record, and then record the coordinates of the mouse movement when the mouse moves
  • In order to reflect the effect that when the mouse moves fast, the line width becomes narrower, and when the mouse moves slowly, the line width returns to normal. I will calculate the current moving speed, and then calculate the line width according to the speed.

     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 // 最后绘线宽度
      }
    }
  • Record mouse position and current line 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
    }
  • After saving the coordinates, rendering is to traverse all the coordinates

     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()
    }

    Eraser

  • The eraser is a linear eraser. The solution I adopted is to calculate the arc trajectory of each point and the rectangular area between the two points, and then clear it after clipping.

     /**
     * 橡皮擦渲染
     * @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()
    }

withdraw, reverse

  • To achieve retraction, the rendering data of each element on the canvas must be stored for anti-retraction. By changing the control variable, the traversal of the rendering element is limited, so that the effect of retraction can be achieved.
  • First, create a history class when the sketchpad is initialized, and then create a cache and step data. When withdrawing and undoing, you only need to modify the 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]
        }
      }
    }
  • For the drawing board, by monitoring the mouse press operation, add an element to the history, and then limit the traversal of the rendering function to step to achieve the effect of withdrawal

     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()
      }
    }

drag the canvas

  • The implementation of dragging the canvas is to calculate the mouse movement distance, and change the origin position of the canvas according to the distance to achieve the effect of dragging.
 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
}

multi-layer

The realization of multi-layer needs to deal with the following places

  1. Create a layer class when the artboard is initialized, all layer data and layer logic are here
  2. Then add the layer attribute to the element on the canvas to determine which layer it belongs to
  3. The rendering function of the artboard is changed to render according to the layer order
  4. Drag or hide layers need to be re-rendered, delete the layer to delete the corresponding cache layer element
 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)) {
         ...
       }
     } 
   }
}

Summarize

  • In this article, I mainly share some main logic, and some compatibility issues and some UI interactions will not be described.
  • It took about a week to write this drawing board, and there are many functions that have not been written yet. If you have time after a while, you will continue to write and further optimize. There are still some optimization problems that have not been written yet. For example, the width of the brush is still a little bit. The problem, the origin position and some initialization designs are not very good, but it is quite a sense of accomplishment after writing this drawing board

References


LH_S
121 声望6 粉丝

keep learning...