canvas画板之绘画元素的框选

前言

我大概在两个月之前做了一个基于canvas的画板,基于canvas实现的多功能画板,然后最近不是太忙,就利用下班的时间又迭代了一个版本,增加了以下内容

  1. 在选择模式下可以点击元素进行框选,并按住手柄进行缩放或者移动,点击Backspace键可以删除选择元素
  2. 双击画板输入文字绘制在指定位置

本篇我就详细介绍下框选元素的实现思路和具体代码,效果如下

预览

预览地址:https://songlh.top/paint-board/
repo:https://github.com/LHRUN/paint-board 欢迎Star⭐️

实现思路

  1. 首先需要框选的元素必须在初始化时和更新时记录矩形属性,比如宽高、矩形坐标,这是实现框选的基础
  2. 鼠标在移动时需要根据当前坐标判断悬浮在哪个元素上方,这样才能在点击时进行处理,并且鼠标移动时需要有光标的改变
  3. 在有框选元素的情况下,渲染时在最后根据框选元素的矩形属性渲染框选效果
  4. 在有框选元素的情况下,拖拽时根据拖拽的位置来判断是移动还是改变大小
  5. 元素改变大小有两种情况,保持比例(文字)的缩放和不保持比例(画笔)的缩放

记录矩形属性

因为画笔随着绘画一直在增加新的坐标点,所以我在矩形属性外另记录了最小和最大的xy坐标用于计算宽高

/**
 * 根据新坐标点,更新矩形属性
 * @param instance 画笔元素
 * @param position 坐标点
 */
export const updateRect = (instance: FreeDraw, position: MousePosition) => {
  const { x, y } = position
  let { minX, maxX, minY, maxY } = instance.rect
  if (x < minX) {
    minX = x
  }
  if (x > maxX) {
    maxX = x
  }
  if (y < minY) {
    minY = y
  }
  if (y > maxY) {
    maxY = y
  }
  const rect = {
    minX,
    maxX,
    minY,
    maxY,
    x: minX,
    y: minY,
    width: maxX - minX,
    height: maxY - minY
  }
  instance.rect = rect
  return rect
}

计算鼠标移动坐标

  • 随着鼠标移动,我们需要改变光标让使用者感知到已经移动到元素上方,所以就需要计算鼠标坐标是否已经和绘画路径临近到一定距离
  • 绘画路径是由一个个的坐标点组成,我们可以把每个坐标点和上一个坐标点连成一个线段,然后在满足以下任何一种情况就可以认为是悬浮在元素上方了
    • 鼠标坐标距离线段起点小于10像素
    • 鼠标坐标距离线段终点小于10像素
    • 鼠标坐标距离线段小于10像素并且x和y坐标在线段的两端点范围内
    • // 遍历画笔元素所有坐标点
      for (let i = 1; i < positions.length; i++) {
        // 距离起点距离
        const startDistance = getDistance(movePos, positions[i - 1])
        // 距离终点距离
        const endDistance = getDistance(movePos, positions[i])
        // 距离线段距离
        const lineDistance = getPositionToLineDistance(
          movePos,
          positions[i - 1],
          positions[i]
        )
        const rangeX =
          Math.max(positions[i - 1].x, positions[i].x) >= movePos.x &&
          movePos.x >= Math.min(positions[i - 1].x, positions[i].x)
        const rangeY =
          Math.max(positions[i - 1].y, positions[i].y) >= movePos.y &&
          movePos.y >= Math.min(positions[i - 1].y, positions[i].y)
      
        // 满足三种情况其中一种就可以记录下画笔元素
        if (
          startDistance < 10 ||
          endDistance < 10 ||
          (lineDistance < 10 && rangeX && rangeY)
        ) {
          this.mouseHoverElementIndex = eleIndex
        }
      }
      // ...
      
      /**
       * 计算两点之间的距离
       * @param start 起点
       * @param end 终点
       * @returns 距离
       */
      export const getDistance = (start: MousePosition, end: MousePosition) => {
        return Math.sqrt(Math.pow(start.x - end.x, 2) + Math.pow(start.y - end.y, 2))
      }
      
      /**
       * 获取鼠标坐标距离线段距离
       * @param pos 鼠标坐标
       * @param startPos 线段起点
       * @param endPos 线段终点
       * @returns 距离
       */
      export const getPositionToLineDistance = (
        pos: MousePosition,
        startPos: MousePosition,
        endPos: MousePosition
      ) => {
        /**
         * 1. 计算三点之间的直线距离
         * 2. 计算三角形半周长
         * 3. 通过海伦公式求面积
         * 4. 根据面积公式求三角形的高
         */
        const A = Math.abs(getDistance(pos, startPos))
        const B = Math.abs(getDistance(pos, endPos))
        const C = Math.abs(getDistance(startPos, endPos))
      
        const P = (A + B + C) / 2
        const area = Math.abs(Math.sqrt(P * (P - A) * (P - B) * (P - C)))
        const distance = (2 * area) / C
        return distance
      }

点击渲染框选效果

  • 点击时如果在之前hover时满足三种情况已经记录下来了,就继续记录为框选元素
  • 然后在画板渲染时,就按照框选元素的矩形属性渲染框选效果
    if (this.select.selectElementIndex !== -1) {
      // 获取选择元素的矩形属性,绘制框选效果
      const rect = this.select.getCurSelectElement().rect
      drawResizeRect(this.context, rect)
    }
    
    /**
     * 绘制拖拽矩形
     */
    export const drawResizeRect = (
      context: CanvasRenderingContext2D,
      rect: ElementRect
    ) => {
      const { x, y, width, height } = rect
      context.save()
      context.strokeStyle = '#65CC8A'
      context.setLineDash([5])
      context.lineWidth = 2
      context.lineCap = 'round'
      context.lineJoin = 'round'
      // 绘制虚线框
      drawRect(context, x, y, width, height)
    
      // 绘制四角手柄
      context.fillStyle = '#65CC8A'
      drawRect(context, x - 10, y - 10, 10, 10, true)
      drawRect(context, x + width, y - 10, 10, 10, true)
      drawRect(context, x - 10, y + height, 10, 10, true)
      drawRect(context, x + width, y + height, 10, 10, true)
      context.restore()
    }
    
    /**
     * 绘制矩形
     */
    export const drawRect = (
      context: CanvasRenderingContext2D,
      x: number,
      y: number,
      width: number,
      height: number,
      fill = false // 是否填充
    ) => {
      context.beginPath()
      context.rect(x, y, width, height)
      if (fill) {
        context.fill()
      } else {
        context.stroke()
      }
    }

拖拽元素

拖拽元素比较简单,就是计算鼠标移动的距离,然后遍历坐标点加上距离即可

// startMousePos就是上一个移动的坐标
const disntanceX = x - this.startMousePos.x
const disntanceY = y - this.startMousePos.y

/**
 * 更新位置
 * @param distanceX
 * @param distanceY
 */
export const moveFreeDraw = (
  instance: FreeDraw,
  distanceX: number,
  distanceY: number
) => {
  initRect(instance)
  instance.positions.forEach((position) => {
    position.x += distanceX
    position.y += distanceY
    updateRect(instance, position)
  })
}

画笔缩放(不保持比例)

  • 画笔缩放我先以右下角手柄拖拽为例分析
  • 首先画笔的缩放比例是分为水平缩放比例和垂直缩放比例
    • 水平缩放比例 = (旧矩形的宽 + 鼠标水平移动的距离) / 旧矩形的宽
    • 垂直缩放比例 = (旧矩形的高 + 鼠标垂直移动的距离) / 旧矩形的高
  • 然后遍历画笔的所有坐标点进行缩放,这时会出现一个偏移的缩放效果,如下图
  • 这时就需要计算当前拖拽手柄对角顶点移动的距离是多少,然后减去这个距离就得到了正确的缩放效果了
  • 当然四个角的拖拽计算是不一致的,但是思路一致
    switch (this.resizeType) {
      // disntanceX 鼠标水平移动距离
      // disntanceY 鼠标垂直移动距离
      // 右下角
      case RESIZE_TYPE.BOTTOM_RIGHT:
        resizeFreeDraw(
          resizeElement as FreeDraw,
          (rect.width + disntanceX) / rect.width,
          (rect.height + disntanceY) / rect.height,
          rect,
          RESIZE_TYPE.BOTTOM_RIGHT
        )
        break
      // 左下角
      case RESIZE_TYPE.BOTTOM_LEFT:
        resizeFreeDraw(
          resizeElement as FreeDraw,
          (rect.width - disntanceX) / rect.width,
          (rect.height + disntanceY) / rect.height,
          rect,
          RESIZE_TYPE.BOTTOM_LEFT
        )
        break
      // 左上角
      case RESIZE_TYPE.TOP_LEFT:
        resizeFreeDraw(
          resizeElement as FreeDraw,
          (rect.width - disntanceX) / rect.width,
          (rect.height - disntanceY) / rect.height,
          rect,
          RESIZE_TYPE.TOP_LEFT
        )
        break
      // 右上角
      case RESIZE_TYPE.TOP_RIGHT:
        resizeFreeDraw(
          resizeElement as FreeDraw,
          (rect.width + disntanceX) / rect.width,
          (rect.height - disntanceY) / rect.height,
          rect,
          RESIZE_TYPE.TOP_RIGHT
        )
        break
      default:
        break
    }
    
    /**
     * 缩放绘画
     * @param instance
     * @param scaleX
     * @param scaleY
     * @param rect
     * @param resizeType
     */
    export const resizeFreeDraw = (
      instance: FreeDraw,
      scaleX: number,
      scaleY: number,
      rect: FreeDrawRect,
      resizeType: string
    ) => {
      // 初始化矩形
      initRect(instance)
      // 遍历所有坐标进行缩放
      instance.positions.forEach((position) => {
        position.x = position.x * scaleX
        position.y = position.y * scaleY
        updateRect(instance, position)
      })
      const { x: newX, y: newY, width: newWidth, height: newHeight } = instance.rect
      let offsetX = 0
      let offsetY = 0
      // 计算偏移距离,这个是要根据当前缩放手柄的对角顶点进行计算,所以要分为4种情况
      switch (resizeType) {
        case RESIZE_TYPE.BOTTOM_RIGHT:
          offsetX = newX - rect.x
          offsetY = newY - rect.y
          break
        case RESIZE_TYPE.BOTTOM_LEFT:
          offsetX = newX + newWidth - (rect.x + rect.width)
          offsetY = newY - rect.y
          break
        case RESIZE_TYPE.TOP_LEFT:
          offsetX = newX + newWidth - (rect.x + rect.width)
          offsetY = newY + newHeight - (rect.y + rect.height)
          break
        case RESIZE_TYPE.TOP_RIGHT:
          offsetX = newX - rect.x
          offsetY = newY + newHeight - (rect.y + rect.height)
          break
        default:
          break
      }
      initRect(instance)
      // 减去偏移距离
      instance.positions.forEach((position) => {
        position.x = position.x - offsetX
        position.y = position.y - offsetY
        updateRect(instance, position)
      })
    }

文字缩放(保持比例)

  • 文字缩放需要一直保持着宽高比,通过计算出新旧矩形的宽高比
    • 当新的宽高比小于旧的宽高比时,宽度不变,计算 高度 = 宽度 / 旧的宽高比
    • 但新的宽高比大于旧的宽高比时,高度不变,计算 宽度 = 高度 * 旧的宽高比
      switch (this.resizeType) {
        // ...
        // 右下角
        case RESIZE_TYPE.BOTTOM_RIGHT:
          resizeTextElement(
            resizeElement as TextElement,
            resizeElement.rect.width + disntanceX,
            resizeElement.rect.height + disntanceY,
            RESIZE_TYPE.BOTTOM_RIGHT
          )
          break
        // 左下角
        case RESIZE_TYPE.BOTTOM_LEFT:
          resizeTextElement(
            resizeElement as TextElement,
            resizeElement.rect.width - disntanceX,
            resizeElement.rect.height + disntanceY,
            RESIZE_TYPE.BOTTOM_LEFT
          )
          break
        // 左上角
        case RESIZE_TYPE.TOP_LEFT:
          resizeTextElement(
            resizeElement as TextElement,
            resizeElement.rect.width - disntanceX,
            resizeElement.rect.height - disntanceY,
            RESIZE_TYPE.TOP_LEFT
          )
          break
        // 右上角
        case RESIZE_TYPE.TOP_RIGHT:
          resizeTextElement(
            resizeElement as TextElement,
            resizeElement.rect.width + disntanceX,
            resizeElement.rect.height - disntanceY,
            RESIZE_TYPE.TOP_RIGHT
          )
          break
        default:
          break
      }
      
      /**
       * 修改文本元素大小
       * @param ele 文本元素
       * @param width 改变后的宽度
       * @param height 改变后的高度
       * @param resizeType 拖拽类型
       */
      export const resizeTextElement = (
        ele: TextElement,
        width: number,
        height: number,
        resizeType: string
      ) => {
        const oldRatio = ele.rect.width / ele.rect.height
        const newRatio = width / height
        // 按照之前的说明,修改宽高比不一致的情况
        if (newRatio < oldRatio) {
          height = width / oldRatio
        } else if (newRatio > oldRatio) {
          width = oldRatio * height
        }
      
        /**
         * 因为这个缩放是按照左上角缩放的
         * 所以为了达到当前拖拽手柄不移动,就需要进行偏移操作
         */
        switch (resizeType) {
          case RESIZE_TYPE.BOTTOM_RIGHT:
            break
          case RESIZE_TYPE.BOTTOM_LEFT:
            ele.rect.x -= width - ele.rect.width
            break
          case RESIZE_TYPE.TOP_LEFT:
            ele.rect.x -= width - ele.rect.width
            ele.rect.y -= height - ele.rect.height
            break
          case RESIZE_TYPE.TOP_RIGHT:
            ele.rect.y -= height - ele.rect.height
            break
          default:
            break
        }
        ele.rect.height = height
        ele.rect.width = width
        // 字体大小按照高度修改
        ele.fontSize = ele.rect.height
      }

总结

如果有发现问题或者有好的方案,欢迎讨论👻