从零实现 Canvas 图形拖拽:让你的网页动起来!

513 阅读4分钟

前言

最近在做一个可视化项目时,需要实现在 Canvas 上绘制矩形并支持拖拽功能。这个看似简单的需求,实际上涉及了不少前端技术细节。今天就来分享一下如何从零开始实现一个支持绘制、拖拽、自定义光标的 Canvas 应用。

最终实现的效果:

  • 🎨 点击空白区域绘制彩色矩形
  • 🖱️ 点击已有矩形进行拖拽移动
  • 🎯 精确的相对位置拖拽(不会跳到左上角)

image.png

技术栈

  • 原生 JavaScript
  • HTML5 Canvas API
  • CSS 自定义光标
  • SVG 图标

核心实现

1. Canvas 初始化与高 DPI 适配

function init() {
  const w = 800,
    h = 500
  cvs.width = w * devicePixelRatio
  cvs.height = h * devicePixelRatio
  cvs.style.width = w + 'px'
  cvs.style.height = h + 'px'
}

关键点:通过devicePixelRatio适配高 DPI 屏幕,避免图形模糊。

2. 矩形类设计

class Rectangle {
  constructor(startX, startY, color) {
    this.startX = startX
    this.startY = startY
    this.color = color
    this.endX = startX
    this.endY = startY
    this.dragOffsetX = 0 // 拖拽偏移量
    this.dragOffsetY = 0
  }

  // 获取矩形边界
  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)
  }

  // 检测点击是否在矩形内
  isInside(x, y) {
    return this.minX <= x && this.maxX >= x && this.minY <= y && this.maxY >= y
  }
}

3. 拖拽功能的核心难点

问题:拖拽时矩形跳到左上角

最初的 naive 实现:

// ❌ 错误实现
move(newX, newY) {
  this.startX = newX
  this.startY = newY
  // 矩形会跳到鼠标位置的左上角
}

解决方案:记录相对偏移量

// ✅ 正确实现
move(newX, newY) {
  const width = this.maxX - this.minX
  const height = this.maxY - this.minY

  // 考虑鼠标点击位置的偏移
  this.startX = newX - this.dragOffsetX
  this.startY = newY - this.dragOffsetY
  this.endX = this.startX + width
  this.endY = this.startY + height
}

4. 事件处理逻辑

cvs.onmousedown = (e) => {
  const startX = e.offsetX
  const startY = e.offsetY

  const shape = getShape(startX, startY)
  if (shape) {
    // 拖拽模式:记录偏移量
    shape.dragOffsetX = startX - shape.minX
    shape.dragOffsetY = startY - shape.minY

    const rect = cvs.getBoundingClientRect()
    window.onmousemove = (e) => {
      const ex = e.clientX - rect.left
      const ey = e.clientY - rect.top
      shape.move(ex, ey)
    }
  } else {
    // 绘制模式:创建新矩形
    const shape = new Rectangle(startX, startY, colorPicker.value)
    shapes.push(shape)
    // ... 绘制逻辑
  }
}

5. 自定义彩色光标

系统默认光标无法修改颜色,解决方案是使用 SVG 自定义光标:

.custom-crosshair {
  cursor: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="%23ff0000" stroke-width="2"><path d="M12 2v20M2 12h20"/></svg>')
      12 12, crosshair;
}

.custom-grab {
  cursor: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="%230000ff" stroke-width="2"><path d="M9 11V7a3 3 0 0 1 6 0v4"/><path d="M12 12v9"/></svg>')
      12 12, grab;
}

技巧

  • 使用data:image/svg+xml内联 SVG
  • %23#的 URL 编码
  • 12 12指定光标热点位置

6. 智能光标切换

cvs.onmousemove = (e) => {
  const x = e.offsetX
  const y = e.offsetY
  const shape = getShape(x, y)

  if (shape) {
    cvs.classList.remove('custom-crosshair')
    cvs.classList.add('custom-grab')
  } else {
    cvs.classList.remove('custom-grab')
    cvs.classList.add('custom-crosshair')
  }
}

踩坑记录

坑 1:坐标系统混乱

Canvas 有多套坐标系统:

  • e.offsetX/Y:相对于 Canvas 元素
  • e.clientX/Y:相对于视口
  • Canvas 内部坐标需要乘以devicePixelRatio

坑 2:事件监听器清理

window.onmouseup = () => {
  window.onmousemove = null // 必须清理
  window.onmouseup = null // 避免内存泄漏
}

坑 3:拖拽偏移计算

关键是在mousedown时就计算好偏移量,而不是在mousemove时才计算。

性能优化

1. requestAnimationFrame 渲染循环

function draw() {
  requestAnimationFrame(draw)
  ctx.clearRect(0, 0, cvs.width, cvs.height)
  for (const shape of shapes) {
    shape.draw(ctx)
  }
}

2. 事件委托

使用window监听mousemove避免频繁绑定/解绑事件。

3. 碰撞检测优化

function getShape(x, y) {
  // 从后往前遍历,优先选择上层图形
  for (let i = shapes.length - 1; i >= 0; i--) {
    if (shapes[i].isInside(x, y)) {
      return shapes[i]
    }
  }
  return null
}

完整代码

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
  </head>
  <style>
    * {
      margin: 0;
      padding: 0;
      box-sizing: border-box;
    }
    body {
      display: flex;
      justify-content: center;
      align-items: center;
      height: 100vh;
      flex-direction: column;
    }
    canvas {
      background-color: #7d7d7d;
    }
  </style>
  <body>
    <input type="color" id="myInput" />
    <canvas></canvas>
    <script src="./js/index.js"></script>
  </body>
</html>

js部分


const colorPicker = document.querySelector('input')
const cvs = document.querySelector('canvas')
const ctx = cvs.getContext('2d')

function init() {
  const w = 800,
    h = 500
  cvs.width = w * devicePixelRatio
  cvs.height = h * devicePixelRatio
  cvs.style.width = w + 'px'
  cvs.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
    this.dragOffsetX = 0
    this.dragOffsetY = 0
  }

  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) {
    ctx.fillStyle = this.color
    ctx.fillRect(
      this.minX * devicePixelRatio,
      this.minY * devicePixelRatio,
      (this.maxX - this.minX) * devicePixelRatio,
      (this.maxY - this.minY) * devicePixelRatio
    )
    ctx.strokeStyle = '#fff'
    ctx.lineWidth = 4 * devicePixelRatio
    ctx.strokeRect(
      this.minX * devicePixelRatio,
      this.minY * devicePixelRatio,
      (this.maxX - this.minX) * devicePixelRatio,
      (this.maxY - this.minY) * devicePixelRatio
    )
  }

  change(newX, newY) {
    this.endX = newX
    this.endY = newY
  }

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

  move(newX, newY) {
    const width = this.maxX - this.minX
    const height = this.maxY - this.minY

    this.startX = newX - this.dragOffsetX
    this.startY = newY - this.dragOffsetY
    this.endX = this.startX + width
    this.endY = this.startY + height
  }
}

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

cvs.onmousedown = (e) => {
  const startX = e.offsetX
  const startY = e.offsetY

  const shape = getShape(startX, startY)
  if (shape) {
    const rect = cvs.getBoundingClientRect()
    shape.dragOffsetX = startX - shape.minX
    shape.dragOffsetY = startY - shape.minY

    window.onmousemove = (e) => {
      const ex = e.clientX - rect.left
      const ey = e.clientY - rect.top
      shape.move(ex, ey)
    }
  } else {
    const shape = new Rectangle(startX, startY, colorPicker.value)
    shapes.push(shape)

    const rect = cvs.getBoundingClientRect()
    window.onmousemove = (e) => {
      const ex = e.clientX - rect.left,
        ey = e.clientY - rect.top
      shape.change(ex, ey)
    }
  }

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

function draw() {
  requestAnimationFrame(draw)
  ctx.clearRect(0, 0, cvs.width, cvs.height)
  for (const shape of shapes) {
    shape.draw(ctx)
  }
}

draw()

扩展思路

基于这个基础框架,可以扩展出更多功能:

  1. 多选功能:Ctrl+点击支持多选
  2. 缩放旋转:添加控制点实现图形变换
  3. 撤销重做:使用命令模式
  4. 图层管理:Z-index 排序
  5. 导出功能:toDataURL 导出图片
  6. 框选功能:矩形选择区域

总结

Canvas 拖拽看似简单,实际上需要考虑:

  • 坐标系统转换
  • 事件处理机制
  • 拖拽偏移计算
  • 性能优化
  • 用户体验细节

通过这个项目,我们不仅实现了基础功能,还学会了如何处理复杂的交互逻辑。Canvas 的强大之处在于给了我们完全的控制权,但也需要我们自己处理所有的细节。

希望这篇文章对正在学习 Canvas 开发的小伙伴有所帮助!如果你有任何问题或者更好的实现方案,欢迎在评论区讨论。