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
}

总结

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

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值