{T}

Canvas 交互与事件

本文档介绍如何为 Canvas 添加用户交互功能,包括鼠标事件、触摸事件、图形选择与拖拽等内容。

鼠标事件处理

Canvas 本身不支持为绘制的图形添加事件监听器,需要通过监听 Canvas 元素的事件并计算坐标来实现交互。

常用鼠标事件

事件说明
click单击
dblclick双击
mousedown鼠标按下
mouseup鼠标释放
mousemove鼠标移动
mouseenter鼠标进入
mouseleave鼠标离开

获取鼠标在 Canvas 中的坐标

javascript
const canvas = document.getElementById('myCanvas')

function getMousePos(canvas, e) {
  const rect = canvas.getBoundingClientRect()
  return {
    x: e.clientX - rect.left,
    y: e.clientY - rect.top
  }
}

canvas.addEventListener('click', (e) => {
  const pos = getMousePos(canvas, e)
  console.log('点击位置:', pos.x, pos.y)
})

鼠标绘制

javascript
let isDrawing = false
let lastX = 0
let lastY = 0

canvas.addEventListener('mousedown', (e) => {
  isDrawing = true
  const pos = getMousePos(canvas, e)
  lastX = pos.x
  lastY = pos.y
})

canvas.addEventListener('mousemove', (e) => {
  if (!isDrawing) return
  
  const pos = getMousePos(canvas, e)
  
  ctx.beginPath()
  ctx.moveTo(lastX, lastY)
  ctx.lineTo(pos.x, pos.y)
  ctx.strokeStyle = '#333'
  ctx.lineWidth = 2
  ctx.stroke()
  
  lastX = pos.x
  lastY = pos.y
})

canvas.addEventListener('mouseup', () => {
  isDrawing = false
})

canvas.addEventListener('mouseleave', () => {
  isDrawing = false
})

鼠标样式

javascript
canvas.addEventListener('mousemove', (e) => {
  const pos = getMousePos(canvas, e)
  
  if (isInsideShape(pos.x, pos.y)) {
    canvas.style.cursor = 'pointer'
  } else {
    canvas.style.cursor = 'default'
  }
})

触摸事件处理

触摸事件与鼠标事件类似,但需要处理多点触控。

常用触摸事件

事件说明
touchstart触摸开始
touchmove触摸移动
touchend触摸结束
touchcancel触摸取消

获取触摸坐标

javascript
function getTouchPos(canvas, touch) {
  const rect = canvas.getBoundingClientRect()
  return {
    x: touch.clientX - rect.left,
    y: touch.clientY - rect.top
  }
}

canvas.addEventListener('touchstart', (e) => {
  e.preventDefault()
  const touch = e.touches[0]
  const pos = getTouchPos(canvas, touch)
  console.log('触摸位置:', pos.x, pos.y)
})

触摸绘制

javascript
let isDrawing = false
let lastX = 0
let lastY = 0

canvas.addEventListener('touchstart', (e) => {
  e.preventDefault()
  isDrawing = true
  const pos = getTouchPos(canvas, e.touches[0])
  lastX = pos.x
  lastY = pos.y
})

canvas.addEventListener('touchmove', (e) => {
  e.preventDefault()
  if (!isDrawing) return
  
  const pos = getTouchPos(canvas, e.touches[0])
  
  ctx.beginPath()
  ctx.moveTo(lastX, lastY)
  ctx.lineTo(pos.x, pos.y)
  ctx.strokeStyle = '#333'
  ctx.lineWidth = 2
  ctx.stroke()
  
  lastX = pos.x
  lastY = pos.y
})

canvas.addEventListener('touchend', () => {
  isDrawing = false
})

阻止默认行为

javascript
// 阻止触摸时的默认行为(如滚动)
canvas.addEventListener('touchstart', (e) => e.preventDefault(), { passive: false })
canvas.addEventListener('touchmove', (e) => e.preventDefault(), { passive: false })

图形选择

由于 Canvas 绘制的图形不是 DOM 元素,需要手动实现图形选择逻辑。

点击检测

矩形检测

javascript
function isPointInRect(px, py, rect) {
  return px >= rect.x &&
         px <= rect.x + rect.width &&
         py >= rect.y &&
         py <= rect.y + rect.height
}

canvas.addEventListener('click', (e) => {
  const pos = getMousePos(canvas, e)
  
  if (isPointInRect(pos.x, pos.y, { x: 50, y: 50, width: 100, height: 80 })) {
    console.log('点击了矩形')
  }
})

圆形检测

javascript
function isPointInCircle(px, py, cx, cy, radius) {
  const dx = px - cx
  const dy = py - cy
  return dx * dx + dy * dy <= radius * radius
}

canvas.addEventListener('click', (e) => {
  const pos = getMousePos(canvas, e)
  
  if (isPointInCircle(pos.x, pos.y, 200, 150, 50)) {
    console.log('点击了圆形')
  }
})

使用 isPointInPath

javascript
canvas.addEventListener('click', (e) => {
  const pos = getMousePos(canvas, e)
  
  // 重新构建路径
  ctx.beginPath()
  ctx.arc(200, 150, 50, 0, Math.PI * 2)
  
  if (ctx.isPointInPath(pos.x, pos.y)) {
    console.log('点击了圆形')
  }
})

图形管理

javascript
class ShapeManager {
  constructor() {
    this.shapes = []
  }
  
  add(shape) {
    this.shapes.push(shape)
  }
  
  findAt(x, y) {
    // 从后往前查找(后绘制的在上层)
    for (let i = this.shapes.length - 1; i >= 0; i--) {
      if (this.shapes[i].contains(x, y)) {
        return this.shapes[i]
      }
    }
    return null
  }
  
  draw(ctx) {
    this.shapes.forEach(shape => shape.draw(ctx))
  }
}

// 图形类
class Rectangle {
  constructor(x, y, width, height, color) {
    this.x = x
    this.y = y
    this.width = width
    this.height = height
    this.color = color
  }
  
  contains(px, py) {
    return px >= this.x && px <= this.x + this.width &&
           py >= this.y && py <= this.y + this.height
  }
  
  draw(ctx) {
    ctx.fillStyle = this.color
    ctx.fillRect(this.x, this.y, this.width, this.height)
  }
}

// 使用
const manager = new ShapeManager()
manager.add(new Rectangle(50, 50, 100, 80, '#3498db'))
manager.add(new Rectangle(200, 100, 80, 60, '#e74c3c'))

canvas.addEventListener('click', (e) => {
  const pos = getMousePos(canvas, e)
  const shape = manager.findAt(pos.x, pos.y)
  
  if (shape) {
    console.log('点击了图形')
  }
})

拖拽功能

基本拖拽

javascript
let selectedShape = null
let offsetX = 0
let offsetY = 0

canvas.addEventListener('mousedown', (e) => {
  const pos = getMousePos(canvas, e)
  selectedShape = manager.findAt(pos.x, pos.y)
  
  if (selectedShape) {
    offsetX = pos.x - selectedShape.x
    offsetY = pos.y - selectedShape.y
  }
})

canvas.addEventListener('mousemove', (e) => {
  if (!selectedShape) return
  
  const pos = getMousePos(canvas, e)
  selectedShape.x = pos.x - offsetX
  selectedShape.y = pos.y - offsetY
  
  // 重绘
  ctx.clearRect(0, 0, canvas.width, canvas.height)
  manager.draw(ctx)
})

canvas.addEventListener('mouseup', () => {
  selectedShape = null
})

完整拖拽类

javascript
class DraggableShape {
  constructor(x, y, width, height, color) {
    this.x = x
    this.y = y
    this.width = width
    this.height = height
    this.color = color
    this.isDragging = false
  }
  
  contains(px, py) {
    return px >= this.x && px <= this.x + this.width &&
           py >= this.y && py <= this.y + this.height
  }
  
  startDrag(px, py) {
    this.isDragging = true
    this.offsetX = px - this.x
    this.offsetY = py - this.y
  }
  
  drag(px, py) {
    if (!this.isDragging) return
    this.x = px - this.offsetX
    this.y = py - this.offsetY
  }
  
  endDrag() {
    this.isDragging = false
  }
  
  draw(ctx) {
    ctx.fillStyle = this.color
    ctx.fillRect(this.x, this.y, this.width, this.height)
    
    // 拖拽时添加边框
    if (this.isDragging) {
      ctx.strokeStyle = '#333'
      ctx.lineWidth = 2
      ctx.strokeRect(this.x, this.y, this.width, this.height)
    }
  }
}

交互式应用

绘图应用

javascript
class DrawingApp {
  constructor(canvas) {
    this.canvas = canvas
    this.ctx = canvas.getContext('2d')
    this.isDrawing = false
    this.lastX = 0
    this.lastY = 0
    this.color = '#333'
    this.lineWidth = 2
    
    this.init()
  }
  
  init() {
    this.canvas.addEventListener('mousedown', this.startDraw.bind(this))
    this.canvas.addEventListener('mousemove', this.draw.bind(this))
    this.canvas.addEventListener('mouseup', this.endDraw.bind(this))
    this.canvas.addEventListener('mouseleave', this.endDraw.bind(this))
    
    // 触摸支持
    this.canvas.addEventListener('touchstart', this.startDrawTouch.bind(this))
    this.canvas.addEventListener('touchmove', this.drawTouch.bind(this))
    this.canvas.addEventListener('touchend', this.endDraw.bind(this))
  }
  
  getPos(e) {
    const rect = this.canvas.getBoundingClientRect()
    return {
      x: e.clientX - rect.left,
      y: e.clientY - rect.top
    }
  }
  
  startDraw(e) {
    this.isDrawing = true
    const pos = this.getPos(e)
    this.lastX = pos.x
    this.lastY = pos.y
  }
  
  startDrawTouch(e) {
    e.preventDefault()
    this.isDrawing = true
    const touch = e.touches[0]
    const rect = this.canvas.getBoundingClientRect()
    this.lastX = touch.clientX - rect.left
    this.lastY = touch.clientY - rect.top
  }
  
  draw(e) {
    if (!this.isDrawing) return
    
    const pos = this.getPos(e)
    
    this.ctx.beginPath()
    this.ctx.moveTo(this.lastX, this.lastY)
    this.ctx.lineTo(pos.x, pos.y)
    this.ctx.strokeStyle = this.color
    this.ctx.lineWidth = this.lineWidth
    this.ctx.lineCap = 'round'
    this.ctx.stroke()
    
    this.lastX = pos.x
    this.lastY = pos.y
  }
  
  drawTouch(e) {
    e.preventDefault()
    if (!this.isDrawing) return
    
    const touch = e.touches[0]
    const rect = this.canvas.getBoundingClientRect()
    const x = touch.clientX - rect.left
    const y = touch.clientY - rect.top
    
    this.ctx.beginPath()
    this.ctx.moveTo(this.lastX, this.lastY)
    this.ctx.lineTo(x, y)
    this.ctx.strokeStyle = this.color
    this.ctx.lineWidth = this.lineWidth
    this.ctx.lineCap = 'round'
    this.ctx.stroke()
    
    this.lastX = x
    this.lastY = y
  }
  
  endDraw() {
    this.isDrawing = false
  }
  
  clear() {
    this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height)
  }
  
  setColor(color) {
    this.color = color
  }
  
  setLineWidth(width) {
    this.lineWidth = width
  }
}

const app = new DrawingApp(canvas)

游戏交互

javascript
class Game {
  constructor(canvas) {
    this.canvas = canvas
    this.ctx = canvas.getContext('2d')
    this.player = { x: 200, y: 150, radius: 20 }
    this.target = { x: 300, y: 100, radius: 15 }
    this.score = 0
    
    this.init()
  }
  
  init() {
    this.canvas.addEventListener('click', this.handleClick.bind(this))
    this.animate()
  }
  
  handleClick(e) {
    const pos = this.getPos(e)
    
    // 检测是否点击目标
    const dx = pos.x - this.target.x
    const dy = pos.y - this.target.y
    const distance = Math.sqrt(dx * dx + dy * dy)
    
    if (distance <= this.target.radius) {
      this.score++
      this.relocateTarget()
    }
  }
  
  getPos(e) {
    const rect = this.canvas.getBoundingClientRect()
    return {
      x: e.clientX - rect.left,
      y: e.clientY - rect.top
    }
  }
  
  relocateTarget() {
    this.target.x = Math.random() * (this.canvas.width - 40) + 20
    this.target.y = Math.random() * (this.canvas.height - 40) + 20
  }
  
  draw() {
    this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height)
    
    // 绘制玩家
    this.ctx.fillStyle = '#3498db'
    this.ctx.beginPath()
    this.ctx.arc(this.player.x, this.player.y, this.player.radius, 0, Math.PI * 2)
    this.ctx.fill()
    
    // 绘制目标
    this.ctx.fillStyle = '#e74c3c'
    this.ctx.beginPath()
    this.ctx.arc(this.target.x, this.target.y, this.target.radius, 0, Math.PI * 2)
    this.ctx.fill()
    
    // 绘制分数
    this.ctx.fillStyle = '#333'
    this.ctx.font = '20px Arial'
    this.ctx.fillText(`分数: ${this.score}`, 20, 30)
  }
  
  animate() {
    this.draw()
    requestAnimationFrame(this.animate.bind(this))
  }
}

常见问题

1. 坐标计算错误

问题: 点击位置与实际位置不符。

解决方案: 确保正确计算 Canvas 的边界矩形。

javascript
function getMousePos(canvas, e) {
  const rect = canvas.getBoundingClientRect()
  const scaleX = canvas.width / rect.width
  const scaleY = canvas.height / rect.height
  
  return {
    x: (e.clientX - rect.left) * scaleX,
    y: (e.clientY - rect.top) * scaleY
  }
}

2. 触摸与鼠标冲突

问题: 同时监听触摸和鼠标事件导致重复触发。

解决方案: 检测设备类型,只使用一种事件。

javascript
const isTouchDevice = 'ontouchstart' in window

if (isTouchDevice) {
  canvas.addEventListener('touchstart', handleStart)
  canvas.addEventListener('touchmove', handleMove)
  canvas.addEventListener('touchend', handleEnd)
} else {
  canvas.addEventListener('mousedown', handleStart)
  canvas.addEventListener('mousemove', handleMove)
  canvas.addEventListener('mouseup', handleEnd)
}

3. 性能问题

问题: 频繁的事件处理导致性能下降。

解决方案: 使用节流或防抖。

javascript
function throttle(func, limit) {
  let inThrottle
  return function(...args) {
    if (!inThrottle) {
      func.apply(this, args)
      inThrottle = true
      setTimeout(() => inThrottle = false, limit)
    }
  }
}

canvas.addEventListener('mousemove', throttle(handleMove, 16))

下一步学习


返回Canvas 教程目录 | 上一篇高级特性