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

前言

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

最终实现的效果:

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

技术栈

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

核心实现

1. Canvas 初始化与高 DPI 适配

javascript 复制代码
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. 矩形类设计

javascript 复制代码
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 实现:

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

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

javascript 复制代码
// ✅ 正确实现
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. 事件处理逻辑

javascript 复制代码
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 自定义光标:

css 复制代码
.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. 智能光标切换

javascript 复制代码
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:事件监听器清理

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

坑 3:拖拽偏移计算

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

性能优化

1. requestAnimationFrame 渲染循环

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

2. 事件委托

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

3. 碰撞检测优化

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

扩展思路

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

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

总结

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

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

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

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

相关推荐
lovingsoft4 分钟前
Chrome Lighthouse 详解
前端·chrome
Qlly14 分钟前
全栈工程师怎么接需求?从一句「加个功能」聊到能开发、能验收
前端·后端
马可家的菠萝18 分钟前
你的微信“文件传输助手”,是不是已经成了第二个网盘?
前端·后端·架构
略略略啦19 分钟前
Vibe Coding 基于Spec DD和TDD的实践指南
前端
shmily麻瓜小菜鸡39 分钟前
前端实现图片与PDF文字识别敏感信息打码方案
前端·pdf·状态模式
打工仔折腾 AI1 小时前
LangChain 工具调用实战:从 Tool 定义到 Agent 执行链路拆解
java·前端·人工智能·后端·python·langchain·ai agent 实战
汉堡大王95271 小时前
单次输出拉到100万Token:拆完 Gemini 4 Argon 的续写协议,才看懂谷歌这7个月在憋什么
前端·人工智能·后端
看浪的路人1 小时前
第9讲:大模型应用监控与可观测性——OpenTelemetry + Prometheus + Grafana 全链路追踪
java·前端·spring
内存过载1 小时前
用表格模拟甘特图:项目排期的土办法
前端·低代码
葡萄城技术团队1 小时前
用 SpreadJS 数据验证实现省市区三级联动
前端