关于前端:基于canvas实现的多功能画板

35次阅读

共计 7132 个字符,预计需要花费 18 分钟才能阅读完成。

前言

最近闲暇工夫比拟多,就想做点小工具玩玩,计划选了好几个,最终决定做一个基于 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 交互就不叙述了
  • 这个画板写下来大略用了一个星期,有好多功能还没写上,如果过段时间有空的话就持续写下去,并进一步优化,当初还是有点优化问题没有写好,比方画笔宽度显示的还是有点问题,原点地位和一些初始化设计的不太好,不过写完这个画板还是挺有成就感的

参考资料

  • HTML5 实现橡皮擦的擦除成果
  • 我做了一个在线白板!

正文完
 0