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:
// 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 };
};<!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>