拖拽交互

学习Canvas拖拽交互技术,掌握拖拽实现、拖放操作和交互反馈方法。拖拽交互是指用户按住图形并移动到新位置,是Canvas交互的重要功能。

基本拖拽

拖拽流程

  1. mousedown:检测点击的图形,开始拖拽
  2. mousemove:更新图形位置
  3. mouseup:结束拖拽

基本实现

let isDragging = false
let dragShape = null
let dragOffsetX = 0
let dragOffsetY = 0

canvas.addEventListener('mousedown', (e) => {
  const pos = getMousePos(canvas, e)
  dragShape = findShapeAt(pos.x, pos.y)
  
  if (dragShape) {
    isDragging = true
    dragOffsetX = pos.x - dragShape.x
    dragOffsetY = pos.y - dragShape.y
  }
})

canvas.addEventListener('mousemove', (e) => {
  if (!isDragging || !dragShape) return
  
  const pos = getMousePos(canvas, e)
  dragShape.x = pos.x - dragOffsetX
  dragShape.y = pos.y - dragOffsetY
  
  redraw()
})

canvas.addEventListener('mouseup', () => {
  isDragging = false
  dragShape = null
})

canvas.addEventListener('mouseleave', () => {
  isDragging = false
  dragShape = null
})

拖拽演示

拖拽交互(拖动图形)

拖拽约束

边界约束

function clamp(value, min, max) {
  return Math.max(min, Math.min(max, value))
}

canvas.addEventListener('mousemove', (e) => {
  if (!isDragging || !dragShape) return
  
  const pos = getMousePos(canvas, e)
  
  dragShape.x = clamp(pos.x - dragOffsetX, 0, canvas.width - dragShape.width)
  dragShape.y = clamp(pos.y - dragOffsetY, 0, canvas.height - dragShape.height)
  
  redraw()
})

网格约束

const gridSize = 20

function snapToGrid(value) {
  return Math.round(value / gridSize) * gridSize
}

canvas.addEventListener('mousemove', (e) => {
  if (!isDragging || !dragShape) return
  
  const pos = getMousePos(canvas, e)
  
  dragShape.x = snapToGrid(pos.x - dragOffsetX)
  dragShape.y = snapToGrid(pos.y - dragOffsetY)
  
  redraw()
})

轴约束

function constrainToAxis(shape, axis) {
  if (axis === 'x') {
    shape.y = shape.originalY
  } else if (axis === 'y') {
    shape.x = shape.originalX
  }
}

canvas.addEventListener('mousedown', (e) => {
  const pos = getMousePos(canvas, e)
  dragShape = findShapeAt(pos.x, pos.y)
  
  if (dragShape) {
    isDragging = true
    dragShape.originalX = dragShape.x
    dragShape.originalY = dragShape.y
    dragOffsetX = pos.x - dragShape.x
    dragOffsetY = pos.y - dragShape.y
    dragAxis = e.shiftKey ? 'y' : (e.altKey ? 'x' : null)
  }
})

canvas.addEventListener('mousemove', (e) => {
  if (!isDragging || !dragShape) return
  
  const pos = getMousePos(canvas, e)
  dragShape.x = pos.x - dragOffsetX
  dragShape.y = pos.y - dragOffsetY
  
  if (dragAxis) {
    constrainToAxis(dragShape, dragAxis)
  }
  
  redraw()
})

拖拽反馈

视觉反馈

canvas.addEventListener('mousedown', (e) => {
  const pos = getMousePos(canvas, e)
  dragShape = findShapeAt(pos.x, pos.y)
  
  if (dragShape) {
    isDragging = true
    dragShape.originalColor = dragShape.color
    dragShape.color = lightenColor(dragShape.color, 0.2)
    dragShape.zIndex = 100
    bringToFront(dragShape)
    redraw()
  }
})

canvas.addEventListener('mouseup', () => {
  if (dragShape) {
    dragShape.color = dragShape.originalColor
    dragShape.zIndex = 0
  }
  isDragging = false
  dragShape = null
  redraw()
})

阴影效果

function drawShadow(ctx, shape) {
  ctx.save()
  ctx.fillStyle = 'rgba(0, 0, 0, 0.2)'
  
  if (shape.type === 'rect') {
    ctx.fillRect(shape.x + 5, shape.y + 5, shape.width, shape.height)
  } else if (shape.type === 'circle') {
    ctx.beginPath()
    ctx.arc(shape.x + 5, shape.y + 5, shape.radius, 0, Math.PI * 2)
    ctx.fill()
  }
  
  ctx.restore()
}

function redraw() {
  ctx.clearRect(0, 0, canvas.width, canvas.height)
  
  shapes.forEach(shape => {
    if (shape === dragShape) {
      drawShadow(ctx, shape)
    }
    drawShape(ctx, shape)
  })
}

拖拽类

class Draggable {
  constructor(shape) {
    this.shape = shape
    this.isDragging = false
    this.dragOffsetX = 0
    this.dragOffsetY = 0
    this.constraints = []
    this.onDragStart = null
    this.onDrag = null
    this.onDragEnd = null
  }
  
  startDrag(x, y) {
    this.isDragging = true
    this.dragOffsetX = x - this.shape.x
    this.dragOffsetY = y - this.shape.y
    
    if (this.onDragStart) {
      this.onDragStart(this.shape)
    }
  }
  
  drag(x, y) {
    if (!this.isDragging) return
    
    this.shape.x = x - this.dragOffsetX
    this.shape.y = y - this.dragOffsetY
    
    this.applyConstraints()
    
    if (this.onDrag) {
      this.onDrag(this.shape)
    }
  }
  
  endDrag() {
    this.isDragging = false
    
    if (this.onDragEnd) {
      this.onDragEnd(this.shape)
    }
  }
  
  addConstraint(constraint) {
    this.constraints.push(constraint)
    return this
  }
  
  applyConstraints() {
    this.constraints.forEach(constraint => {
      constraint(this.shape)
    })
  }
}

const draggable = new Draggable(shape)
  .addConstraint(shape => {
    shape.x = Math.max(0, Math.min(canvas.width - shape.width, shape.x))
    shape.y = Math.max(0, Math.min(canvas.height - shape.height, shape.y))
  })

拖拽管理器

class DragManager {
  constructor(canvas) {
    this.canvas = canvas
    this.shapes = []
    this.draggables = new Map()
    this.activeDraggable = null
    
    this.bindEvents()
  }
  
  addShape(shape) {
    this.shapes.push(shape)
    const draggable = new Draggable(shape)
    this.draggables.set(shape, draggable)
    return draggable
  }
  
  removeShape(shape) {
    const index = this.shapes.indexOf(shape)
    if (index !== -1) {
      this.shapes.splice(index, 1)
    }
    this.draggables.delete(shape)
  }
  
  bindEvents() {
    this.canvas.addEventListener('mousedown', this.onMouseDown.bind(this))
    this.canvas.addEventListener('mousemove', this.onMouseMove.bind(this))
    this.canvas.addEventListener('mouseup', this.onMouseUp.bind(this))
    this.canvas.addEventListener('mouseleave', this.onMouseLeave.bind(this))
  }
  
  getMousePos(e) {
    const rect = this.canvas.getBoundingClientRect()
    return {
      x: e.clientX - rect.left,
      y: e.clientY - rect.top
    }
  }
  
  findShapeAt(x, y) {
    for (let i = this.shapes.length - 1; i >= 0; i--) {
      if (this.shapes[i].containsPoint(x, y)) {
        return this.shapes[i]
      }
    }
    return null
  }
  
  onMouseDown(e) {
    const pos = this.getMousePos(e)
    const shape = this.findShapeAt(pos.x, pos.y)
    
    if (shape) {
      const draggable = this.draggables.get(shape)
      this.activeDraggable = draggable
      draggable.startDrag(pos.x, pos.y)
    }
  }
  
  onMouseMove(e) {
    const pos = this.getMousePos(e)
    
    if (this.activeDraggable) {
      this.activeDraggable.drag(pos.x, pos.y)
      this.redraw()
    }
  }
  
  onMouseUp() {
    if (this.activeDraggable) {
      this.activeDraggable.endDrag()
      this.activeDraggable = null
    }
  }
  
  onMouseLeave() {
    if (this.activeDraggable) {
      this.activeDraggable.endDrag()
      this.activeDraggable = null
    }
  }
  
  redraw() {
    ctx.clearRect(0, 0, this.canvas.width, this.canvas.height)
    this.shapes.forEach(shape => drawShape(ctx, shape))
  }
}

const dragManager = new DragManager(canvas)

dragManager.addShape(new RectShape(50, 50, 100, 80))
dragManager.addShape(new CircleShape(200, 100, 40))

拖放操作

拖放区域

class DropZone {
  constructor(x, y, width, height, options = {}) {
    this.x = x
    this.y = y
    this.width = width
    this.height = height
    this.accepts = options.accepts || []
    this.onDrop = options.onDrop || null
    this.highlighted = false
  }
  
  containsPoint(px, py) {
    return px >= this.x && px <= this.x + this.width &&
           py >= this.y && py <= this.y + this.height
  }
  
  canAccept(shape) {
    if (this.accepts.length === 0) return true
    return this.accepts.includes(shape.type)
  }
  
  highlight(enabled) {
    this.highlighted = enabled
  }
  
  draw(ctx) {
    ctx.fillStyle = this.highlighted ? 'rgba(52, 152, 219, 0.2)' : 'rgba(52, 152, 219, 0.1)'
    ctx.fillRect(this.x, this.y, this.width, this.height)
    ctx.strokeStyle = '#3498db'
    ctx.lineWidth = 2
    ctx.strokeRect(this.x, this.y, this.width, this.height)
  }
}

const dropZone = new DropZone(300, 50, 80, 80, {
  accepts: ['circle'],
  onDrop: (shape) => {
    console.log('放入:', shape)
  }
})

拖放实现

let draggedShape = null
let dropZones = []

canvas.addEventListener('mousedown', (e) => {
  const pos = getMousePos(canvas, e)
  draggedShape = findShapeAt(pos.x, pos.y)
})

canvas.addEventListener('mousemove', (e) => {
  const pos = getMousePos(canvas, e)
  
  if (draggedShape) {
    draggedShape.x = pos.x - dragOffsetX
    draggedShape.y = pos.y - dragOffsetY
    
    dropZones.forEach(zone => {
      zone.highlight(zone.containsPoint(pos.x, pos.y) && zone.canAccept(draggedShape))
    })
    
    redraw()
  }
})

canvas.addEventListener('mouseup', (e) => {
  if (draggedShape) {
    const pos = getMousePos(canvas, e)
    
    dropZones.forEach(zone => {
      if (zone.containsPoint(pos.x, pos.y) && zone.canAccept(draggedShape)) {
        if (zone.onDrop) {
          zone.onDrop(draggedShape)
        }
      }
      zone.highlight(false)
    })
    
    draggedShape = null
    redraw()
  }
})

触摸拖拽

统一拖拽

class UnifiedDrag {
  constructor(canvas) {
    this.canvas = canvas
    this.shapes = []
    this.activeShape = null
    this.dragOffset = { x: 0, y: 0 }
    this.activePointer = null
    
    this.bindEvents()
  }
  
  bindEvents() {
    this.canvas.addEventListener('mousedown', this.onStart.bind(this))
    this.canvas.addEventListener('mousemove', this.onMove.bind(this))
    this.canvas.addEventListener('mouseup', this.onEnd.bind(this))
    
    this.canvas.addEventListener('touchstart', this.onStart.bind(this))
    this.canvas.addEventListener('touchmove', this.onMove.bind(this))
    this.canvas.addEventListener('touchend', this.onEnd.bind(this))
  }
  
  getPos(e) {
    const rect = this.canvas.getBoundingClientRect()
    const touch = e.touches ? e.touches[0] : e
    return {
      x: (touch.clientX || touch.offsetX) - rect.left,
      y: (touch.clientY || touch.offsetY) - rect.top,
      id: touch.identifier || 0
    }
  }
  
  onStart(e) {
    if (e.touches) e.preventDefault()
    
    const pos = this.getPos(e)
    const shape = { ...this.findShapeAt(pos.x, pos.y) }
    
    if (shape) {
      this.activePointer = pos.id
      this.activeShape = shape
      this.dragOffset = {
        x: pos.x - shape.x,
        y: pos.y - shape.y
      }
    }
  }
  
  onMove(e) {
    if (e.touches) e.preventDefault()
    if (!this.activeShape) return
    
    const pos = this.getPos(e)
    if (pos.id !== this.activePointer) return
    
    this.activeShape.x = pos.x - this.dragOffset.x
    this.activeShape.y = pos.y - this.dragOffset.y
    
    this.redraw()
  }
  
  onEnd(e) {
    const pos = this.getPos(e)
    if (pos.id === this.activePointer) {
      this.activeShape = null
      this.activePointer = null
    }
  }
  
  findShapeAt(x, y) {
    for (let i = this.shapes.length - 1; i >= 0; i--) {
      if (this.shapes[i].containsPoint(x, y)) {
        return this.shapes[i]
      }
    }
    return null
  }
  
  redraw() {
    ctx.clearRect(0, 0, this.canvas.width, this.canvas.height)
    this.shapes.forEach(shape => drawShape(ctx, shape))
  }
}