Mouse Tracking

Tracking Mouse Position and Drag State

Pointer events cover mouse, pen and touch with one set of handlers. setPointerCapture() keeps the moves coming when a drag leaves the canvas, and touch-action: none stops touch drags from scrolling the page. The twenty-eighth part tracks drags and clicks, telling them apart by the distance moved:

webgl-helper.js, twenty-eighth part: pointer tracking, drags and clicksJavaScript
// Section 3.17: pointer input on a canvas: drag(dx, dy) in CSS pixels while a button is held,
// click(point) for a press and release that barely moved; locate() converts an event to
// drawing-buffer pixels with y up (as readPixels counts them) and to clip space
GLH.pointer = function (canvas, { drag = () => {}, click = () => {} } = {}) {
  const state = { dragging: false, moved: 0, x: 0, y: 0 };
  const locate = (e) => {
    const r = canvas.getBoundingClientRect();
    const x = (e.clientX - r.left) * canvas.width / r.width;       // CSS to buffer pixels,
    const y = (e.clientY - r.top) * canvas.height / r.height;      // still counted downward
    return { pixel: [Math.floor(x), canvas.height - 1 - Math.floor(y)],   // row 0 at bottom
      clip: [2 * x / canvas.width - 1, 1 - 2 * y / canvas.height] };
  };
  canvas.style.touchAction = 'none';                // no scrolling or zooming on touch drags
  canvas.addEventListener('pointerdown', (e) => {
    canvas.setPointerCapture(e.pointerId);          // keep the moves that leave the canvas
    Object.assign(state, { dragging: true, moved: 0, x: e.clientX, y: e.clientY });
  });
  canvas.addEventListener('pointermove', (e) => {
    if (!state.dragging) return;
    const dx = e.clientX - state.x, dy = e.clientY - state.y;
    state.moved += Math.hypot(dx, dy); [state.x, state.y] = [e.clientX, e.clientY];
    drag(dx, dy);
  });
  canvas.addEventListener('pointerup', (e) => {
    state.dragging = false;
    if (state.moved < 4) click(locate(e));          // a drag is not a click
  });
  return { state, locate };
};
Pointer events drawn as they arrive: drags as a blue trail, clicks (presses that barely moved) as gold ringsHTMLLive
<!doctype html>
<style>
  body { margin: 0; font: 11px system-ui, sans-serif; background: #f7f4ee; color: #333; }
  canvas { display: block; width: 100%; max-width: 600px; cursor: crosshair; }
  p { max-width: 600px; margin: 4px 0; font-family: monospace; }
</style>
<canvas id="c" width="1200" height="500"></canvas>
<p id="out">drag or click on the canvas</p>
<script>
const canvas = document.getElementById('c');
const gl = canvas.getContext('webgl2');
// Pointer input: drag(dx, dy) in CSS pixels while a button is held, click(point) for a press
// and release that barely moved; locate() converts an event to buffer pixels (y up) and clip space.
function pointer(canvas, { drag = () => {}, click = () => {} } = {}) {
  const state = { dragging: false, moved: 0, x: 0, y: 0 };
  const locate = (e) => {
    const r = canvas.getBoundingClientRect();
    const x = (e.clientX - r.left) * canvas.width / r.width;
    const y = (e.clientY - r.top) * canvas.height / r.height;
    return { pixel: [Math.floor(x), canvas.height - 1 - Math.floor(y)], clip: [2 * x / canvas.width - 1, 1 - 2 * y / canvas.height] };
  };
  canvas.style.touchAction = 'none';                       // no scrolling or zooming on touch drags
  canvas.addEventListener('pointerdown', (e) => {
    if (e.isTrusted) canvas.setPointerCapture(e.pointerId); // keep the moves that leave the canvas
    Object.assign(state, { dragging: true, moved: 0, x: e.clientX, y: e.clientY });
  });
  canvas.addEventListener('pointermove', (e) => {
    if (!state.dragging) return;
    const dx = e.clientX - state.x, dy = e.clientY - state.y;
    state.moved += Math.hypot(dx, dy); [state.x, state.y] = [e.clientX, e.clientY];
    drag(dx, dy, locate(e));
  });
  canvas.addEventListener('pointerup', (e) => {
    state.dragging = false;
    if (state.moved < 4) click(locate(e));                 // a drag is not a click
  });
  return { state, locate };
}

const trail = [], rings = [];                              // clip-space points to draw
const input = pointer(canvas, {
  drag: (dx, dy, { clip }) => {
    trail.push(clip); if (trail.length > 400) trail.shift();
    document.getElementById('out').textContent = `drag: dx ${dx.toFixed(1)}, dy ${dy.toFixed(1)} CSS px, moved ${input.state.moved.toFixed(0)} px`;
  },
  click: ({ pixel, clip }) => {
    rings.push({ clip, born: performance.now() });
    document.getElementById('out').textContent = `click at buffer pixel (${pixel}), clip (${clip.map(v => v.toFixed(2))})`;
  },
});
const program = gl.createProgram();
for (const [type, src] of [[gl.VERTEX_SHADER, `#version 300 es
layout(location = 0) in vec2 aPos; uniform float uSize;
void main() { gl_Position = vec4(aPos, 0.0, 1.0); gl_PointSize = uSize; }`], [gl.FRAGMENT_SHADER, `#version 300 es
precision mediump float; uniform vec4 uColor; uniform bool uRing; out vec4 c;
void main() {
  float d = length(gl_PointCoord - 0.5);
  if (d > 0.5 || (uRing && d < 0.38)) discard;
  c = uColor;
}`]]) {
  const s = gl.createShader(type); gl.shaderSource(s, src); gl.compileShader(s);
  if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) throw new Error(gl.getShaderInfoLog(s));
  gl.attachShader(program, s);
}
gl.linkProgram(program); gl.useProgram(program);
gl.bindVertexArray(gl.createVertexArray());
gl.bindBuffer(gl.ARRAY_BUFFER, gl.createBuffer());
gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0); gl.enableVertexAttribArray(0);
const u = (n) => gl.getUniformLocation(program, n);
gl.enable(gl.BLEND); gl.blendFuncSeparate(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA, gl.ONE, gl.ONE_MINUS_SRC_ALPHA);

// Scripted events for the preview: a looping drag, then two clicks.
const fire = (type, x, y) => { const r = canvas.getBoundingClientRect();
  canvas.dispatchEvent(new PointerEvent(type, { clientX: r.left + x * r.width, clientY: r.top + y * r.height, pointerId: 1, bubbles: true })); };
let frameNo = 0;
function frame(now) {
  if (frameNo === 0) fire('pointerdown', 0.2, 0.5);
  if (frameNo > 0 && frameNo < 90) { const t = frameNo / 90 * Math.PI * 2; fire('pointermove', 0.2 + frameNo / 90 * 0.5, 0.5 + 0.3 * Math.sin(t)); }
  if (frameNo === 90) fire('pointerup', 0.7, 0.5);
  if (frameNo === 100 || frameNo === 130) { const x = frameNo === 100 ? 0.82 : 0.9; fire('pointerdown', x, 0.3); fire('pointerup', x, 0.3); }
  frameNo++;
  gl.clearColor(0.97, 0.96, 0.93, 1); gl.clear(gl.COLOR_BUFFER_BIT);
  gl.uniform1i(u('uRing'), 0);
  gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(trail.flat()), gl.DYNAMIC_DRAW);
  gl.uniform1f(u('uSize'), 10); gl.uniform4f(u('uColor'), 0.12, 0.37, 0.55, 0.8);
  gl.drawArrays(gl.POINTS, 0, trail.length);
  gl.uniform1i(u('uRing'), 1);
  for (const { clip, born } of rings) {
    const age = Math.min(1, (now - born) / 1500);
    gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(clip), gl.DYNAMIC_DRAW);
    gl.uniform1f(u('uSize'), 30 + 40 * age); gl.uniform4f(u('uColor'), 0.88, 0.6, 0.06, 1 - 0.6 * age);
    gl.drawArrays(gl.POINTS, 0, 1);
  }
  requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
</script>