Nguyên nhân thường gặp
- CSS Transform/Filter trên canvas hoặc ancestor: Tạo stacking context mới, làm lệch toạ độ event
- Canvas size ≠ CSS size:
canvas.width/height(buffer) khácstyle.width/height(display) - Pointer-events: none: Trên canvas hoặc overlay element
- Event listener gắn sai: Gắn trên window/document thay vì canvas
- 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>