// 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); }