Computer >> Hướng Dẫn Máy Tính >  >> Lập Trình >> JavaScript

Sự kiện chuột không kích hoạt trên HTML5 Canvas? Cách khắc phục


Nguyên nhân thường gặp

  1. CSS Transform/Filter trên canvas hoặc ancestor: Tạo stacking context mới, làm lệch toạ độ event
  2. Canvas size ≠ CSS size: canvas.width/height (buffer) khác style.width/height (display)
  3. Pointer-events: none: Trên canvas hoặc overlay element
  4. Event listener gắn sai: Gắn trên window/document thay vì canvas
  5. High DPI / Retina: devicePixelRatio không được xử lý

Giải pháp 1: Fix CSS Transform Issue

/* Vấn đề: transform trên parent làm lệch offsetX/offsetY */
.canvas-wrapper {
  transform: translateZ(0); /* Hoặc bất kỳ transform nào */
}

/* Giải pháp: Đảm bảo canvas không bị ảnh hưởng bởi transform cha */
/* Cách 1: Không đặt transform trên ancestor của canvas */
/* Cách 2: Tính toạ độ thủ công qua getBoundingClientRect() */
canvas.addEventListener('mousemove', (e) => {
  const rect = canvas.getBoundingClientRect();
  const x = e.clientX - rect.left;
  const y = e.clientY - rect.top;
  // x, y giờ chính xác tuyệt đối so với canvas CSS box
});

Giải pháp 2: Canvas CSS Size vs Buffer Size (High DPI)

<canvas id="canvas" width="800" height="600" style="width: 400px; height: 300px;"></canvas>

<script>
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');

// ❌ SAI: Dùng offsetX/offsetY trực tiếp (trên High DPI sẽ lệch x2)
// canvas.addEventListener('click', e => console.log(e.offsetX, e.offsetY));

// ✅ ĐÚNG: Tính toạ độ chuẩn hóa
function getCanvasCoords(canvas, event) {
  const rect = canvas.getBoundingClientRect();
  const scaleX = canvas.width / rect.width;  // Buffer width / CSS width
  const scaleY = canvas.height / rect.height; // Buffer height / CSS height
  
  return {
    x: (event.clientX - rect.left) * scaleX,
    y: (event.clientY - rect.top) * scaleY
  };
}

canvas.addEventListener('click', (e) => {
  const { x, y } = getCanvasCoords(canvas, e);
  console.log('Canvas coordinates:', x, y); // Đúng buffer coordinates
});

Giải pháp 3: Hardware Acceleration Fix (webkit-transform)

Một số trường hợp thêm -webkit-transform: translate3d(0,0,0) ép GPU rendering, fix event dispatch:

canvas {
  /* Force GPU layer, fix một số bug event dispatch trên Chrome/Safari cũ */
  -webkit-transform: translate3d(0, 0, 0);
  transform: translate3d(0, 0, 0);
  /* Hoặc */
  will-change: transform;
}

Giải pháp 4: Pointer Events (Hiện đại, thống nhất)

// Thay vì mousedown/mousemove/mouseup/touchstart/touchmove/touchend
// Dùng Pointer Events (chuẩn hóa mouse, touch, pen)
canvas.addEventListener('pointerdown', handlePointerDown);
canvas.addEventListener('pointermove', handlePointerMove);
canvas.addEventListener('pointerup', handlePointerUp);
canvas.addEventListener('pointerleave', handlePointerUp);
canvas.addEventListener('pointercancel', handlePointerUp);

function handlePointerDown(e) {
  // e.pointerType: 'mouse' | 'touch' | 'pen'
  // e.isPrimary: true cho primary pointer
  const coords = getCanvasCoords(canvas, e);
  // ...
}

// CSS: Cho phép pointer events trên canvas
canvas {
  touch-action: none; /* Ngăn chặn scroll/zoom mặc định trên touch */
}

Giải pháp 5: Overlay Elements chặn event

/* Nếu có overlay div che trên canvas */
.overlay {
  pointer-events: none; /* Cho event xuyên qua đến canvas */
}

/* Hoặc forward event thủ công */
overlay.addEventListener('mousemove', (e) => {
  // Re-dispatch trên canvas
  canvas.dispatchEvent(new MouseEvent('mousemove', {
    clientX: e.clientX,
    clientY: e.clientY,
    buttons: e.buttons,
    bubbles: true
  }));
});

Debug Checklist

// Debug script - paste vào console
function debugCanvasEvents(canvas) {
  ['click', 'mousedown', 'mouseup', 'mousemove', 'pointerdown', 'pointermove', 'pointerup'].forEach(type => {
    canvas.addEventListener(type, e => {
      console.log(`${type}:`, {
        offset: [e.offsetX, e.offsetY],
        client: [e.clientX, e.clientY],
        canvasSize: [canvas.width, canvas.height],
        cssSize: [canvas.clientWidth, canvas.clientHeight],
        dpr: window.devicePixelRatio
      });
    });
  });
  
  // Check computed styles
  const style = getComputedStyle(canvas);
  console.log('Canvas styles:', {
    pointerEvents: style.pointerEvents,
    touchAction: style.touchAction,
    transform: style.transform,
    position: style.position,
    zIndex: style.zIndex
  });
  
  // Check ancestors
  let parent = canvas.parentElement;
  while (parent) {
    const s = getComputedStyle(parent);
    if (s.transform !== 'none' || s.filter !== 'none' || s.pointerEvents === 'none') {
      console.warn('Ancestor may affect events:', parent, { transform: s.transform, filter: s.filter, pointerEvents: s.pointerEvents });
    }
    parent = parent.parentElement;
  }
}

debugCanvasEvents(document.querySelector('canvas'));

Ví dụ hoàn chỉnh: Canvas Drawing App

<!DOCTYPE html>
<html lang="vi">
<head>
  <meta charset="UTF-8">
  <title>Canvas Drawing - Fixed Events</title>
  <style>
    .container { max-width: 800px; margin: 2rem auto; }
    .toolbar { display: flex; gap: 0.5rem; margin-bottom: 1rem; flex-wrap: wrap; }
    button { padding: 0.5rem 1rem; background: #3498db; color: white; border: none; border-radius: 4px; cursor: pointer; }
    button.active { background: #27ae60; }
    #canvas { border: 1px solid #ddd; border-radius: 8px; cursor: crosshair; touch-action: none; }
  </style>
</head>
<body>
  <div class="container">
    <div class="toolbar">
      <button id="penBtn" class="active" data-tool="pen">Bút (Pen)</button>
      <button id="eraserBtn" data-tool="eraser">Tẩy (Eraser)</button>
      <button onclick="clearCanvas()">Xóa tất cả</button>
      <label>Size: <input type="range" id="size" min="1" max="50" value="5"></label>
      <label>Color: <input type="color" id="color" value="#3498db"></label>
    </div>
    <canvas id="canvas" width="800" height="500"></canvas>
  </div>

  <script>
    const canvas = document.getElementById('canvas');
    const ctx = canvas.getContext('2d');
    
    // Setup High DPI
    function setupHighDPI() {
      const dpr = window.devicePixelRatio || 1;
      const rect = canvas.getBoundingClientRect();
      canvas.width = rect.width * dpr;
      canvas.height = rect.height * dpr;
      ctx.scale(dpr, dpr);
      canvas.style.width = rect.width + 'px';
      canvas.style.height = rect.height + 'px';
    }
    
    // Get normalized coordinates
    function getCoords(e) {
      const rect = canvas.getBoundingClientRect();
      return {
        x: (e.clientX - rect.left) * (canvas.width / rect.width),
        y: (e.clientY - rect.top) * (canvas.height / rect.height)
      };
    }
    
    let isDrawing = false;
    let lastCoords = null;
    let currentTool = 'pen';
    
    // Pointer events
    canvas.addEventListener('pointerdown', (e) => {
      isDrawing = true;
      lastCoords = getCoords(e);
      canvas.setPointerCapture(e.pointerId); // Quan trọng: capture pointer
    });
    
    canvas.addEventListener('pointermove', (e) => {
      if (!isDrawing) return;
      const coords = getCoords(e);
      drawLine(lastCoords.x, lastCoords.y, coords.x, coords.y);
      lastCoords = coords;
    });
    
    canvas.addEventListener('pointerup', (e) => {
      isDrawing = false;
      canvas.releasePointerCapture(e.pointerId);
    });
    
    canvas.addEventListener('pointerleave', (e) => {
      isDrawing = false;
    });
    
    function drawLine(x1, y1, x2, y2) {
      ctx.beginPath();
      ctx.moveTo(x1, y1);
      ctx.lineTo(x2, y2);
      ctx.strokeStyle = currentTool === 'eraser' ? '#fff' : document.getElementById('color').value;
      ctx.lineWidth = document.getElementById('size').value;
      ctx.lineCap = 'round';
      ctx.lineJoin = 'round';
      ctx.globalCompositeOperation = currentTool === 'eraser' ? 'destination-out' : 'source-over';
      ctx.stroke();
    }
    
    function clearCanvas() {
      ctx.clearRect(0, 0, canvas.width, canvas.height);
    }
    
    // Tool buttons
    document.querySelectorAll('[data-tool]').forEach(btn => {
      btn.addEventListener('click', () => {
        document.querySelectorAll('[data-tool]').forEach(b => b.classList.remove('active'));
        btn.classList.add('active');
        currentTool = btn.dataset.tool;
      });
    });
    
    // Init
    setupHighDPI();
    window.addEventListener('resize', setupHighDPI);
    ctx.fillStyle = '#fff';
    ctx.fillRect(0, 0, canvas.width, canvas.height);
  </script>
</body>
</html>