canvas绘制拖拽矩形那些事

引子

本次分享的是将一个svg实现的矩形框改成canvas方式绘制,主要解决操作矩形框同时又能穿透操作下面的其他元素,原本想用pointer-event做穿透操作,但实际效果并不理想,所以就想着趁这个机会把矩形框用canvas方式实现一下

旋转矩阵的一般形式

在开始正题之前,先介绍一下旋转矩阵的一般形式。

旋转矩阵的一般形式可以用来描述一个点在平面上逆时针旋转的变换。对于二维平面上的点 (x,y),旋转矩阵的一般形式如下:

其中:

  • θ 是旋转角度,以弧度为单位。
  • cos(θ) 和 sin(θ) 分别是角度 θ 的余弦和正弦值。

用这个式子可以用来求出旋转后点位对应未旋转时的点位,这样就可以只考虑矩形在未旋转时的移动、变形等操作。

由此可以实现以下两个方法:

计算旋转之前的点位

function calculatePointBeforeRotate(center: Point, point: Point, angle: number): Point {
  if (angle === 0) return point;
  const reverseAngleRadians = -((angle * Math.PI) / 180);

  return [
    center[0] + Math.cos(reverseAngleRadians) * (point[0] - center[0]) - Math.sin(reverseAngleRadians) * (point[1] - center[1]),
    center[1] + Math.sin(reverseAngleRadians) * (point[0] - center[0]) + Math.cos(reverseAngleRadians) * (point[1] - center[1])
  ]
}

计算旋转之后的点位

function calculatePointAfterRotate(center: Point, point: Point, angle: number): Point {
  if (angle === 0) return point;
  const angleRadians = (angle * Math.PI) / 180;

  return [
    center[0] + Math.cos(angleRadians) * (point[0] - center[0]) - Math.sin(angleRadians) * (point[1] - center[1]),
    center[1] + Math.sin(angleRadians) * (point[0] - center[0]) + Math.cos(angleRadians) * (point[1] - center[1])
  ];
}

矩形绘制

在这次开发过程中主要遇到问题是,矩形旋转之后,拖拽移动、拉伸矩形框如何绘制。

drawRect

用canvas绘制代码如下,根据图像张数画一条条线,这里需要注意的是旋转问题:

  • 使用 context.rotate 方法对 Canvas 进行旋转。rotate 是旋转的角度,使用弧度制,因此需要将角度转换为弧度(乘以 Math.PI / 180)。
  • 旋转中心问题,需要利用context.translate()移到选中中心进行旋转,并且再将原点移回到左上角。
for (let i = 0; i <= images; i++) {
    context.save();

    // 设置旋转中心
    const centerX = x + width / 2;
    const centerY = y + height / 2

    context.translate(centerX, centerY);
    context.rotate(rotate * Math.PI / 180);
    context.translate(-centerX, -centerY);

    // 绘制线
    if (i === 0 || i === images) {
      context.setLineDash([]);
    } else {
      context.setLineDash([5, 5]);
    }

    context.beginPath();
    context.moveTo(x, y + i * spacing);
    context.lineTo(x + width, y + i * spacing);
    context.stroke();

    context.restore();
  }

判断当前着落点是否在矩形

这里主要写了两个方法,一个是鼠标着落点是否在在四个顶点和四条边,另一个是鼠标着落点是否在矩形内部,前者用来旋转、变形,后者用来移动。

isPointInRectAngle

这个方法里最重要的是四个顶点确认,确定了四个顶点,四个边也就确认了,判断point是否在其附近,正常未旋转情况下,根据矩形的width、height,以及左上角的x,y都能推算出来,但这里麻烦点在与旋转后的计算,这里引出了另外一个方法 calculatePointAfterRotate 计算旋转前的点位。

function isPointInRectAngle(point, rect) {
  const { width, height, left: x, top: y, rotate: angle } = rect;
  const threshold = 10; // 定义一个阈值来确定附近的距离

  // 计算旋转后的矩形四个顶点
  const topLeft = calculatePointAfterRotate(centerPoint, [x, y], angle)
  const topRight = calculatePointAfterRotate(centerPoint, [x + width, y], angle)
  const bottomLeft = calculatePointAfterRotate(centerPoint, [x, y + height], angle)
  const bottomRight = calculatePointAfterRotate(centerPoint, [x + width, y + height], angle)

  const isNearTop = calculateDistanceToSegment(point, {
    startPoint: topLeft,
    endPoint: topRight
  }) <= threshold
  const isNearRight = calculateDistanceToSegment(point, {
    startPoint: topRight,
    endPoint: bottomRight
  }) <= threshold
  const isNearBottom = calculateDistanceToSegment(point, {
    startPoint: bottomLeft,
    endPoint: bottomRight
  }) <= threshold
  const isNearLeft = calculateDistanceToSegment(point, {
    startPoint: topLeft,
    endPoint: bottomLeft
  }) <= threshold

  const isNearTopLeft = Math.abs(point[0] - topLeft[0]) < threshold && Math.abs(point[1] - topLeft[1]) < threshold;
  const isNearTopRight = Math.abs(point[0] - topRight[0]) < threshold && Math.abs(point[1] - topRight[1]) < threshold;
  const isNearBottomLeft = Math.abs(point[0] - bottomLeft[0]) < threshold && Math.abs(point[1] - bottomLeft[1]) < threshold;
  const isNearBottomRight = Math.abs(point[0] - bottomRight[0]) < threshold && Math.abs(point[1] - bottomRight[1]) < threshold;

  return (isNearTopLeft && "leftTopCorner") ||
    (isNearTopRight && "rightTopCorner") ||
    (isNearBottomLeft && "leftBottomCorner") ||
    (isNearBottomRight && "rightBottomCorner") ||
    (isNearTop && "topLine") ||
    (isNearLeft && "leftLine") ||
    (isNearRight && "rightLine") ||
    (isNearBottom && "bottomLine")
}

计算点到线段的距离

/**
 * @desc 计算点到线段的距离
 * @param point
 * @param segment
 * @return number
 * **/
function calculateDistanceToSegment(point: Point, segment: { startPoint: Point; endPoint: Point }): number {
  const { startPoint, endPoint } = segment;

  // 计算线段的长度
  const segmentLength = Math.sqrt((endPoint[0] - startPoint[0]) ** 2 + (endPoint[1] - startPoint[1]) ** 2);

  // 如果线段长度为零,返回点到起点的距离
  if (segmentLength === 0) {
    return Math.sqrt((point[0] - startPoint[0]) ** 2 + (point[1] - startPoint[1]) ** 2);
  }

  // 计算点到线段的投影点
  const t = Math.max(0, Math.min(1, ((point[0] - startPoint[0]) * (endPoint[0] - startPoint[0]) + (point[1] - startPoint[1]) * (endPoint[1] - startPoint[1])) / segmentLength ** 2));
  const projectionX = startPoint[0] + t * (endPoint[0] - startPoint[0]);
  const projectionY = startPoint[1] + t * (endPoint[1] - startPoint[1]);

  // 计算点到线段的垂直距离
  return Math.sqrt((point[0] - projectionX) ** 2 + (point[1] - projectionY) ** 2);
}

这个方法主要用了欧几里得距离的公式来计算两点之间距离。

具体来说,它使用了勾股定理来计算两点之间的直线距离。

  • (endPoint[0] - startPoint[0]): 表示两点在 x 轴上的差值。
  • (endPoint[1] - startPoint[1]): 表示两点在 y 轴上的差值。

将这两个差值的平方相加,然后取平方根,即可得到两点之间的直线距离。整个公式使用了 Math.sqrt 函数,该函数用于计算平方根。

isPointInRect

function isPointInRect(point, rect) {
  return (point[0] >= rect.left &&
    point[0] <= rect.left + rect.width &&
    point[1] >= rect.top &&
    point[1] <= rect.top + rect.height)
}

这个判断没啥好说的了,就是判断point是否在矩形内部

通过上述两个方法很容易的在mouseDown和mouseMove的时候得知是否在操作矩形,从而可以区别判断

旋转角度

旋转角度计算主要用到了Math.atan2 计算两个点与中心点形成的角度

计算拖拽前后的角度

const angleBefore = Math.atan2(mouseDownMousePoint[1] - centerPoint[1], mouseDownMousePoint[0] - centerPoint[0]) / Math.PI * 180;
const angleAfter = Math.atan2(point[1] - centerPoint[1], point[0] - centerPoint[0]) / Math.PI * 180;

这里使用 Math.atan2 计算了两个点与中心点形成的角度。angleBefore 表示拖拽前鼠标指针相对于矩形中心的角度,而 angleAfter 表示拖拽后鼠标指针相对于矩形中心的角度。

计算旋转的角度

rectInfo.rotate = (rotateAngle + angleAfter - angleBefore + 360) % 360;

这里 rotateAngle 是矩形拖拽前的旋转角度。计算新的旋转角度时,加上拖拽后的角度 angleAfter,减去拖拽前的角度 angleBefore,并加上 360,最后取模 360。这样做是为了确保旋转角度在 [0, 360) 范围内。

mouseDrag

function handleMouseMove(event) {
  if (!opt.isActiveViewer.value) return
  event.stopPropagation()
  const point = getPoint(event)
  isMouseAngleDown.value = isPointInRectAngle(point, rectInfo)

  if (isMouseAngleDown.value) {
    setAngle(rectInfo.rotate)
    event.target.style.cursor = "pointer"
    opt.beforeDrag && opt.beforeDrag();
    return
  }
  if(isPointInRect(point, rectInfo)) {
    event.target.style.cursor = "move"
    opt.beforeDrag && opt.beforeDrag();
    return;
  }
  opt.afterDrag && opt.afterDrag();
}

function handleMouseDrag(event) {
  event.stopPropagation()
  // 图像的宽高
  const imageWidth = event.detail.image.width;
  const imageHeight = event.detail.image.height;
  const point = getPoint(event)
  // 边界检测:不能超出给定的范围
  const bound = opt.rectBound.value || {
    left: 0,
    top: 0,
    width: imageWidth,
    height: imageHeight,
  }

  if (isMouseAngleDown.value) {
    /**
     * originalPoint:为了方便计算旋转后的矩形伸缩偏移量,需要先计算旋转前的point坐标;
     * lineIntersection: 经过centerPoint和point的垂线的交点;
     * distance:centerPoint 和 lineIntersection 的距离;
     * **/
    const originalPoint = calculatePointBeforeRotate(centerPoint, point, rectInfo.rotate)
    const lineIntersection = isMouseAngleDown.value === "topLine" || isMouseAngleDown.value === "bottomLine" ?
      [centerPoint[0], originalPoint[1]] as Point :
      [originalPoint[0], centerPoint[1]] as Point
    const distance = calculateDistance(centerPoint, lineIntersection )

    if (isMouseAngleDown.value === "topLine") {
      // 限制最小高度为10,最大高度为image的高度,矩形左上角点位不能移出image范围
      if (distance * 2 <= bound.height && distance * 2 >= 10 && (originalPoint[1] >= bound.top && originalPoint[1] <= centerPoint[1])) {
        rectInfo.top = originalPoint[1]
        rectInfo.height = distance * 2
      }
    } else if (isMouseAngleDown.value === "rightLine") {
      if (distance * 2 <= bound.width && distance * 2 >= 10 && (originalPoint[0] - distance * 2 >= bound.left && originalPoint[0] >= centerPoint[0])) {
        rectInfo.left = originalPoint[0] - distance * 2
        rectInfo.width = distance * 2
      }
    } else if (isMouseAngleDown.value === "leftLine") {
      if (distance * 2 <= bound.width && distance * 2 >= 10 && (originalPoint[0] >= bound.left && originalPoint[0] <= centerPoint[0])) {
        rectInfo.left = originalPoint[0]
        rectInfo.width = distance * 2
      }
    } else if (isMouseAngleDown.value === "bottomLine") {
      if (distance * 2 <= bound.height && distance * 2 >= 10 && originalPoint[1] - distance * 2 >= bound.top && originalPoint[1] >= centerPoint[1]) {
        rectInfo.top = originalPoint[1] - distance * 2
        rectInfo.height = distance * 2
      }
    } else  {
      const angleBefore = Math.atan2(mouseDownMousePoint[1] - centerPoint[1], mouseDownMousePoint[0] - centerPoint[0]) / Math.PI * 180
      const angleAfter = Math.atan2(point[1] - centerPoint[1], point[0] - centerPoint[0]) / Math.PI * 180

      // 旋转的角度
      rectInfo.rotate = (rotateAngle + angleAfter - angleBefore + 360) % 360
      console.log("旋转角度", rectInfo.rotate)
    }

    if (rectInfo.lockType === "imageSize") {
      rectInfo.spacing = rectInfo.height / rectInfo.images
    } else if (rectInfo.lockType === "sliceThickNess") {
      // 高度改变 锁定间隔/层厚 修改张数
      // rectInfo.spacing = rectInfo.height / parseInt(rectInfo.images + "")
      const num =  Math.round(rectInfo.height / rectInfo.spacing)
      // 最低两张
      if (Math.abs(num)<2) return
      console.log(num)
      rectInfo.height = num * rectInfo.spacing
      rectInfo.images = num

    }

    prevPoint = point;
    return;
  }
  if (!isMouseDown) return;


  // 计算移动的距离,并算出新的位置
  const dx = (point[0] - mouseDownMousePoint[0]);
  const dy = (point[1] - mouseDownMousePoint[1]);
  const newLeft = mouseDownRectInfo.left + dx;
  const newTop = mouseDownRectInfo.top + dy;
  const maxLeft = bound.left + bound.width - rectInfo.width;
  const maxTop = bound.top + bound.height - rectInfo.height;
  if (newLeft <= bound.left) {
    // console.log('???newLeft 小于 bound.left', newLeft, bound.left)
    rectInfo.left = bound.left;
  }
  else if (newLeft > maxLeft) {
    // console.log('???newLeft 大于 maxLeft', newLeft, maxLeft)
    rectInfo.left = maxLeft;
  }
  else {
    // console.log('???newLeft 正常', newLeft)
    rectInfo.left = newLeft;
  }

  if (newTop <= bound.top) rectInfo.top = bound.top;
  else if (newTop > maxTop) rectInfo.top = maxTop;
  else rectInfo.top = newTop;

}

这样在拖拽操作过程中只需要考虑未旋转情况下的界限问题。

写在最后

因为旋转问题卡了好久,一直没有很好的解决方案,最初版本是想通过旋转角度来界定移动方位,因为判断太多想优化一下,想到了求原始点位方案。

Last Updated 2024/12/27 11:36:49