picker/js/input.js
2026-10-09 13:45:54 +02:00

149 lines
5 KiB
JavaScript

// Pointer input. Fingers are tracked by pointerId; pointercancel counts as
// a lift (Android cancels touches it thinks are palms or system gestures).
//
// Debug mode (?debug=1): mouse clicks place persistent fake fingers.
// Click a fake finger again to remove it; drag it to move it.
// Keys: A adds a fake finger at a random spot, C clears all fake fingers.
const DRAG_THRESHOLD = 6;
export function createInput(canvas, { debug, onDown, onMove, onUp }) {
const active = new Set(); // real pointer ids currently down
const fakes = new Map(); // fake id -> {x, y}
let fakeSeq = 0;
let mouse = null; // {pointerId, fakeId, startX, startY, moved, created}
const now = () => performance.now();
const hitRadius = () => Math.max(40, Math.min(64, Math.min(innerWidth, innerHeight) * 0.115));
function release(pointerId) {
if (!active.delete(pointerId)) return;
onUp(String(pointerId), now());
}
canvas.addEventListener('pointerdown', (e) => {
if (e.pointerType === 'mouse') {
if (debug && e.button === 0) debugDown(e);
return;
}
e.preventDefault();
try {
canvas.setPointerCapture(e.pointerId);
} catch {
/* not fatal */
}
active.add(e.pointerId);
onDown(String(e.pointerId), e.clientX, e.clientY, now());
});
canvas.addEventListener('pointermove', (e) => {
if (mouse && e.pointerId === mouse.pointerId) return debugMove(e);
if (!active.has(e.pointerId)) return;
onMove(String(e.pointerId), e.clientX, e.clientY);
});
const up = (e) => {
if (mouse && e.pointerId === mouse.pointerId) return debugUp(e);
release(e.pointerId);
};
canvas.addEventListener('pointerup', up);
canvas.addEventListener('pointercancel', up);
// Safety net: if capture is lost without an up/cancel, treat it as a lift.
canvas.addEventListener('lostpointercapture', (e) => {
if (!mouse || e.pointerId !== mouse.pointerId) release(e.pointerId);
});
// ---- debug: fake fingers with the mouse ----
function fakeAt(x, y) {
const r = hitRadius();
for (const [id, f] of fakes) if (Math.hypot(f.x - x, f.y - y) < r) return id;
return null;
}
function addFake(x, y) {
const id = `fake-${++fakeSeq}`;
fakes.set(id, { x, y });
onDown(id, x, y, now());
return id;
}
function removeFake(id) {
if (!fakes.delete(id)) return;
onUp(id, now());
}
function debugDown(e) {
const hit = fakeAt(e.clientX, e.clientY);
const fakeId = hit ?? addFake(e.clientX, e.clientY);
mouse = { pointerId: e.pointerId, fakeId, startX: e.clientX, startY: e.clientY, moved: false, created: !hit };
try {
canvas.setPointerCapture(e.pointerId);
} catch {
/* ignore */
}
}
function debugMove(e) {
if (!mouse.moved && Math.hypot(e.clientX - mouse.startX, e.clientY - mouse.startY) > DRAG_THRESHOLD) mouse.moved = true;
if (mouse.moved && fakes.has(mouse.fakeId)) {
fakes.set(mouse.fakeId, { x: e.clientX, y: e.clientY });
onMove(mouse.fakeId, e.clientX, e.clientY);
}
}
function debugUp() {
const m = mouse;
mouse = null;
if (!m.created && !m.moved) removeFake(m.fakeId);
}
if (debug) {
window.addEventListener('keydown', (e) => {
if (e.target instanceof HTMLInputElement) return;
if (e.key === 'a' || e.key === 'A') {
const m = 80;
addFake(m + Math.random() * (innerWidth - 2 * m), m + Math.random() * (innerHeight - 2 * m));
} else if (e.key === 'c' || e.key === 'C') {
for (const id of [...fakes.keys()]) removeFake(id);
}
});
}
return {
/** Forget all touches (page hidden). Fake fingers are kept. */
clearReal() {
active.clear();
},
/** Place fake fingers in a circle (debug ?fingers=N). */
addFakeCircle(n) {
const cx = innerWidth / 2;
const cy = innerHeight / 2;
const rad = Math.min(innerWidth, innerHeight) * 0.32;
for (let i = 0; i < n; i++) {
const a = -Math.PI / 2 + (i / n) * Math.PI * 2;
addFake(cx + Math.cos(a) * rad, cy + Math.sin(a) * rad);
}
},
fakeCount: () => fakes.size,
};
}
/** Stop the browser from scrolling, zooming, selecting or opening menus. */
export function blockBrowserGestures(canvas) {
const opts = { passive: false };
// iOS ignores touch-action for some gestures; cancel touches on the canvas.
canvas.addEventListener('touchstart', (e) => e.preventDefault(), opts);
canvas.addEventListener('touchmove', (e) => e.preventDefault(), opts);
canvas.addEventListener('touchend', (e) => e.preventDefault(), opts);
// iOS pinch-zoom
for (const t of ['gesturestart', 'gesturechange', 'gestureend']) document.addEventListener(t, (e) => e.preventDefault(), opts);
// No long-press menus or text selection anywhere except form fields.
document.addEventListener('contextmenu', (e) => {
if (!(e.target instanceof HTMLInputElement)) e.preventDefault();
});
document.addEventListener('selectstart', (e) => {
if (!(e.target instanceof HTMLInputElement)) e.preventDefault();
});
document.addEventListener('dblclick', (e) => e.preventDefault(), opts);
}