Skip to content

Canvas 实现可拖拽矩形功能详解 🎨 ​

功能概述 🎯 ​

本文将详细介绍如何使用 Canvas 实现一个交互性强的矩形绘制与拖拽功能,主要包含以下特性:

  • ✨ 绘制矩形
  • 🔄 拖拽移动
  • ↔️ 边缘拉伸
  • 🎯 边界检测
  • 🖱️ 智能光标

核心实现 💡 ​

1. 基础设置 ​

首先需要处理 Canvas 的基础设置,特别是针对高清屏的适配:

javascript
function init() {
  const w = 500, h = 300;
  canvas.width = w * devicePixelRatio;
  canvas.height = h * devicePixelRatio;
}

2. Rectangle 类设计 📐 ​

Rectangle 类是整个功能的核心,包含以下关键方法:

javascript
class Rectangle {
  constructor(startX, startY, color) {
    this.startX = startX;
    this.startY = startY;
    this.endX = startX;
    this.endY = startY;
    this.color = color;
  }
  
  // getter 方法用于计算矩形边界
  get minX() { return Math.min(this.startX, this.endX); }
  get maxX() { return Math.max(this.startX, this.endX); }
  // ... 其他 getter
}

3. 边缘检测实现 🔍 ​

为实现精确的边缘拖拽,我们需要实现边缘检测功能:

javascript
getEdge(x, y, threshold = 5) {
  if (!this.isInside(x, y)) return null;

  const edges = {
    left: Math.abs(x - this.minX) <= threshold,
    right: Math.abs(x - this.maxX) <= threshold,
    top: Math.abs(y - this.minY) <= threshold,
    bottom: Math.abs(y - this.maxY) <= threshold
  };

  return Object.entries(edges)
    .filter(([_, value]) => value)
    .map(([edge]) => edge);
}

4. 智能光标反馈 🖱️ ​

根据用户鼠标位置,提供适当的光标样式:

javascript
updateCursor(x, y, canvas) {
  const edges = this.getEdge(x, y);
  if (!edges || edges.length === 0) {
    canvas.style.cursor = 'move';
    return null;
  }

  // 处理不同边缘的光标样式
  if (edges.includes('left') || edges.includes('right')) {
    canvas.style.cursor = 'ew-resize';
  }
  // ... 其他光标样式逻辑
}

性能优化技巧 🚀 ​

  1. 使用 requestAnimationFrame
javascript
function draw() {
  requestAnimationFrame(draw);
  ctx.clearRect(0, 0, canvas.width, canvas.height);
  shapes.forEach(shape => shape.draw());
}
  1. 边界约束优化
javascript
constrainToBounds(canvasWidth, canvasHeight) {
  const w = canvasWidth / devicePixelRatio;
  const h = canvasHeight / devicePixelRatio;
  // ... 边界约束逻辑
}

注意事项 ⚠️ ​

  1. 设备像素比处理
  • 始终考虑 devicePixelRatio 以支持高清屏显示
  • 在绘制时需要将所有坐标乘以 devicePixelRatio
  1. 事件处理
  • 使用 offsetX/offsetY 获取相对于 Canvas 的坐标
  • 正确处理 mouseup 事件以清理事件监听器
  1. 性能考虑
  • 使用 requestAnimationFrame 进行动画循环
  • 适当的边界检查以防止性能问题

扩展思路 🔮 ​

  1. 添加旋转功能
  2. 实现缩放功能
  3. 添加图层管理
  4. 实现撤销/重做功能
  5. 添加快捷键支持

总结 📝 ​

通过合理的类设计和事件处理,我们实现了一个功能完整的 Canvas 矩形编辑器。关键在于:

  • 良好的代码组织结构
  • 精确的边缘检测算法
  • 流畅的拖拽体验
  • 合理的性能优化

完整代码 💻 ​

html
<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
  <style>
    body {
      display: flex;
      flex-direction: column;
      justify-content: center;
      align-items: center;
      height: 100vh;
      gap: 20px;
    }

    canvas {
      background: #999;
    }
  </style>
</head>

<body>
  <canvas></canvas>
  <script>
    const colorPicker = document.querySelector('input[type="color"]');
    const canvas = document.querySelector('canvas');
    const ctx = canvas.getContext('2d');

    function init() {
      const w = 500, h = 300;
      // devicePixelRatio 是设备像素比,用于处理高清屏显示
      // 比如在 Retina 屏幕上,devicePixelRatio 可能是 2,意味着一个 CSS 像素对应 2 个物理像素
      canvas.width = w * devicePixelRatio;
      canvas.height = h * devicePixelRatio;
      // style.width/height 设置的是 CSS 像素,决定了画布在页面上的实际显示大小
      canvas.style.width = `${w}px`;
      canvas.style.height = `${h}px`;
    }

    init();

    const shapes = []  // 存储所有形状

    class Rectangle {
      constructor(startX, startY, color) {
        // 记录矩形的起始点坐标
        this.startX = startX;
        this.startY = startY;
        this.color = color;
        // 初始时终点和起点相同,随着鼠标移动会更新终点位置
        this.endX = startX;
        this.endY = startY;
      }

      // 使用 getter 计算矩形的实际边界
      // 因为用户可能从任意方向拖动,所以需要计算最小/最大值
      get minX() {
        return Math.min(this.startX, this.endX);
      }

      get minY() {
        return Math.min(this.startY, this.endY);
      }

      get maxX() {
        return Math.max(this.startX, this.endX);
      }

      get maxY() {
        return Math.max(this.startY, this.endY);
      }

      draw() {
        ctx.fillStyle = 'transparent';
        ctx.fillRect(
          this.minX * devicePixelRatio,
          this.minY * devicePixelRatio,
          (this.maxX - this.minX) * devicePixelRatio,
          (this.maxY - this.minY) * devicePixelRatio
        );
        ctx.strokeStyle = '#fff';
        ctx.lineWidth = 3 * devicePixelRatio;
        ctx.strokeRect(
          this.minX * devicePixelRatio,
          this.minY * devicePixelRatio,
          (this.maxX - this.minX) * devicePixelRatio,
          (this.maxY - this.minY) * devicePixelRatio
        );
      }

      isInside(x, y) {
        return x >= this.minX && x <= this.maxX && y >= this.minY && y <= this.maxY;
      }

      // 添加边缘检测方法
      getEdge(x, y, threshold = 5) {
        if (!this.isInside(x, y)) return null;

        const edges = {
          left: Math.abs(x - this.minX) <= threshold,
          right: Math.abs(x - this.maxX) <= threshold,
          top: Math.abs(y - this.minY) <= threshold,
          bottom: Math.abs(y - this.maxY) <= threshold
        };

        // 返回检测到的边缘,可能同时检测到多个边缘(角落位置)
        return Object.entries(edges)
          .filter(([_, value]) => value)
          .map(([edge]) => edge);
      }

      // 更新鼠标样式方法
      updateCursor(x, y, canvas) {
        const edges = this.getEdge(x, y);
        if (!edges || edges.length === 0) {
          canvas.style.cursor = 'move';
          return null;
        }

        if (edges.includes('left') || edges.includes('right')) {
          canvas.style.cursor = 'ew-resize';
        }
        if (edges.includes('top') || edges.includes('bottom')) {
          canvas.style.cursor = 'ns-resize';
        }
        if ((edges.includes('left') && edges.includes('top')) ||
          (edges.includes('right') && edges.includes('bottom'))) {
          canvas.style.cursor = 'nwse-resize';
        }
        if ((edges.includes('right') && edges.includes('top')) ||
          (edges.includes('left') && edges.includes('bottom'))) {
          canvas.style.cursor = 'nesw-resize';
        }
        return edges;
      }

      // 添加边界检查方法
      constrainToBounds(canvasWidth, canvasHeight) {
        // 将 canvas 尺寸转换为 CSS 像素
        const w = canvasWidth / devicePixelRatio;
        const h = canvasHeight / devicePixelRatio;

        // 如果整个矩形超出右边界,将其移回画布内
        if (this.minX < 0) {
          const width = this.maxX - this.minX;
          this.startX = this.startX - this.minX;
          this.endX = this.startX + width;
        }
        if (this.maxX > w) {
          const width = this.maxX - this.minX;
          this.endX = w;
          this.startX = w - width;
        }

        // 如果整个矩形超出下边界,将其移回画布内
        if (this.minY < 0) {
          const height = this.maxY - this.minY;
          this.startY = this.startY - this.minY;
          this.endY = this.startY + height;
        }
        if (this.maxY > h) {
          const height = this.maxY - this.minY;
          this.endY = h;
          this.startY = h - height;
        }
      }

      // 添加边缘拖动约束方法
      constrainEdgeToBounds(edge, value, canvasWidth, canvasHeight) {
        const w = canvasWidth / devicePixelRatio;
        const h = canvasHeight / devicePixelRatio;

        switch (edge) {
          case 'left':
            return Math.max(0, Math.min(value, this.endX));
          case 'right':
            return Math.max(this.startX, Math.min(value, w));
          case 'top':
            return Math.max(0, Math.min(value, this.endY));
          case 'bottom':
            return Math.max(this.startY, Math.min(value, h));
          default:
            return value;
        }
      }
    }


    function getShape(x, y) {
      for (let i = shapes.length - 1; i >= 0; i--) {
        const shape = shapes[i];
        if (shape.isInside(x, y)) {
          return shape;
        }
      }
    }

    canvas.addEventListener('mousedown', (e) => {
      const shape = getShape(e.offsetX, e.offsetY);
      const canvasRect = canvas.getBoundingClientRect();

      if (shape) {
        const edges = shape.getEdge(e.offsetX, e.offsetY);
        const sx = e.offsetX;
        const sy = e.offsetY;
        const { startX, startY, endX, endY } = shape;

        window.onmousemove = (e) => {
          const x = e.clientX - canvasRect.left;
          const y = e.clientY - canvasRect.top;

          if (edges && edges.length > 0) {
            // 边缘拖动逻辑
            if (edges.includes('left')) {
              shape.startX = shape.constrainEdgeToBounds('left', x, canvas.width, canvas.height);
            }
            if (edges.includes('right')) {
              shape.endX = shape.constrainEdgeToBounds('right', x, canvas.width, canvas.height);
            }
            if (edges.includes('top')) {
              shape.startY = shape.constrainEdgeToBounds('top', y, canvas.width, canvas.height);
            }
            if (edges.includes('bottom')) {
              shape.endY = shape.constrainEdgeToBounds('bottom', y, canvas.width, canvas.height);
            }
          } else {
            // 整体拖动逻辑
            const dx = x - sx;
            const dy = y - sy;
            shape.startX = startX + dx;
            shape.startY = startY + dy;
            shape.endX = endX + dx;
            shape.endY = endY + dy;
            // 确保矩形在画布范围内
            shape.constrainToBounds(canvas.width, canvas.height);
          }
        };
      } else {
        const rect = new Rectangle(e.offsetX, e.offsetY, colorPicker.value);
        shapes.push(rect);
        window.onmousemove = (e) => {
          const x = e.clientX - canvasRect.left;
          const y = e.clientY - canvasRect.top;
          rect.endX = x;
          rect.endY = y;
        }
      }

      window.onmouseup = () => {
        window.onmousemove = null;
        window.onmouseup = null;
      }
    });

    canvas.addEventListener('mousemove', (e) => {
      const shape = getShape(e.offsetX, e.offsetY);
      if (shape) {
        shape.updateCursor(e.offsetX, e.offsetY, canvas);
      } else {
        canvas.style.cursor = 'default';
      }
    });

    function draw() {
      requestAnimationFrame(draw);
      // 清除整个画布
      ctx.clearRect(0, 0, canvas.width, canvas.height);
      shapes.forEach(shape => shape.draw());
    }

    draw();

  </script>
</body>

</html>