diff --git a/packages/memory-graph/src/__tests__/input-handler-touch.test.ts b/packages/memory-graph/src/__tests__/input-handler-touch.test.ts new file mode 100644 index 00000000..12d0e471 --- /dev/null +++ b/packages/memory-graph/src/__tests__/input-handler-touch.test.ts @@ -0,0 +1,177 @@ +import { beforeEach, describe, expect, it } from "vitest" +import { SpatialIndex } from "../canvas/hit-test" +import { InputHandler } from "../canvas/input-handler" +import { ViewportState } from "../canvas/viewport" +import type { GraphNode } from "../types" + +/** + * InputHandler tap-to-select on touch devices. + * + * onTouchStart calls preventDefault() (required to stop scroll/zoom of the + * page), which also suppresses the browser's synthesized click event — so + * node selection must be detected from the raw touch lifecycle. These tests + * drive the handler through a stub canvas that records the listeners it + * registers. + */ + +type Listener = (e: Event) => void + +function makeStubCanvas() { + const listeners = new Map() + const canvas = { + addEventListener: (name: string, fn: Listener) => { + listeners.set(name, fn) + }, + removeEventListener: (name: string) => { + listeners.delete(name) + }, + getBoundingClientRect: () => ({ + left: 0, + top: 0, + right: 800, + bottom: 600, + width: 800, + height: 600, + x: 0, + y: 0, + }), + style: {} as CSSStyleDeclaration, + } + return { canvas: canvas as unknown as HTMLCanvasElement, listeners } +} + +function touch(clientX: number, clientY: number) { + return { clientX, clientY } +} + +function touchEvent(touches: Array<{ clientX: number; clientY: number }>) { + return { + touches, + preventDefault: () => {}, + } as unknown as TouchEvent +} + +function makeNode(id: string, x: number, y: number): GraphNode { + return { + id, + type: "document", + x, + y, + data: { + id, + title: id, + summary: "", + type: "", + createdAt: "2026-01-01", + updatedAt: "2026-01-01", + memories: [], + }, + size: 40, + borderColor: "#fff", + isHovered: false, + isDragging: false, + } +} + +describe("InputHandler touch tap-to-select", () => { + let listeners: Map + let clicks: Array + let viewport: ViewportState + + const fire = (name: string, e: TouchEvent) => { + const fn = listeners.get(name) + if (!fn) throw new Error(`no listener registered for ${name}`) + fn(e as unknown as Event) + } + + beforeEach(() => { + const stub = makeStubCanvas() + listeners = stub.listeners + clicks = [] + + // zoom 1 / pan 0 so screen coordinates equal world coordinates + viewport = new ViewportState(0, 0, 1) + const index = new SpatialIndex() + index.rebuild([makeNode("doc-1", 100, 100)]) + + new InputHandler(stub.canvas, viewport, index, { + onNodeHover: () => {}, + onNodeClick: (id) => { + clicks.push(id) + }, + onNodeDragStart: () => {}, + onNodeDragEnd: () => {}, + onRequestRender: () => {}, + }) + }) + + it("selects a node on a single tap", () => { + fire("touchstart", touchEvent([touch(100, 100)])) + fire("touchend", touchEvent([])) + + expect(clicks).toEqual(["doc-1"]) + }) + + it("clears selection when tapping empty space", () => { + fire("touchstart", touchEvent([touch(400, 400)])) + fire("touchend", touchEvent([])) + + expect(clicks).toEqual([null]) + }) + + it("still counts jittery taps within the movement threshold", () => { + fire("touchstart", touchEvent([touch(100, 100)])) + fire("touchmove", touchEvent([touch(104, 103)])) + fire("touchend", touchEvent([])) + + expect(clicks).toEqual(["doc-1"]) + }) + + it("does not fire a click after a pan", () => { + fire("touchstart", touchEvent([touch(100, 100)])) + fire("touchmove", touchEvent([touch(160, 100)])) + fire("touchend", touchEvent([])) + + expect(clicks).toEqual([]) + // the drag actually panned the viewport + expect(viewport.panX).toBe(60) + }) + + it("does not fire a click after a pinch gesture", () => { + fire("touchstart", touchEvent([touch(100, 100)])) + // second finger lands -> pinch, no longer a tap + fire("touchstart", touchEvent([touch(100, 100), touch(200, 200)])) + fire("touchend", touchEvent([touch(100, 100)])) + fire("touchend", touchEvent([])) + + expect(clicks).toEqual([]) + }) + + it("hit-tests a jittery tap against the node under the finger at touchstart", () => { + // Zoomed out, a few-pixel finger jitter maps to a large world-space shift. + // The sub-threshold move still pans the viewport, so re-projecting the + // start screen point through the panned transform lands well off the node. + // zoom 0.25: world (100, 100) renders at screen (25, 25). + viewport.zoomImmediate(0.25, 0, 0) + + fire("touchstart", touchEvent([touch(25, 25)])) + // 8px screen jitter (below the 10px tap threshold) that pans the viewport + fire("touchmove", touchEvent([touch(33, 25)])) + fire("touchend", touchEvent([])) + + // the jitter did move the viewport, but the tap still resolves the node + expect(viewport.panX).toBe(8) + expect(clicks).toEqual(["doc-1"]) + }) + + it("hit-tests the tap through the current viewport transform", () => { + // zoom 2x, pan (50, 50): world (100, 100) renders at screen (250, 250) + viewport.pan(50, 50) + viewport.zoomImmediate(2, 50, 50) + + fire("touchstart", touchEvent([touch(250, 250)])) + fire("touchend", touchEvent([])) + + expect(clicks).toEqual(["doc-1"]) + }) +}) diff --git a/packages/memory-graph/src/canvas/input-handler.ts b/packages/memory-graph/src/canvas/input-handler.ts index 9f0afc19..a26c37ff 100644 --- a/packages/memory-graph/src/canvas/input-handler.ts +++ b/packages/memory-graph/src/canvas/input-handler.ts @@ -31,6 +31,17 @@ export class InputHandler { private lastTouchCenter = { x: 0, y: 0 } private isTouchGesture = false + // Tap detection: touch browsers never fire the synthesized click because + // onTouchStart calls preventDefault(), so taps are detected manually. + private static readonly TAP_MOVE_THRESHOLD = 10 + private tapCandidate = false + private touchStartX = 0 + private touchStartY = 0 + // World point under the finger at touchstart, captured before any panning so + // the release hit-test is not thrown off by sub-threshold pans during the tap. + private touchStartWorldX = 0 + private touchStartWorldY = 0 + private boundMouseDown: (e: MouseEvent) => void private boundMouseMove: (e: MouseEvent) => void private boundMouseUp: (e: MouseEvent) => void @@ -241,6 +252,7 @@ export class InputHandler { if (touches.length >= 2) { this.isTouchGesture = true + this.tapCandidate = false const t0 = touches[0] const t1 = touches[1] if (!t0 || !t1) return @@ -258,6 +270,15 @@ export class InputHandler { const rect = this.canvas.getBoundingClientRect() this.lastMouseX = t.clientX - rect.left this.lastMouseY = t.clientY - rect.top + this.touchStartX = this.lastMouseX + this.touchStartY = this.lastMouseY + const startWorld = this.viewport.screenToWorld( + this.lastMouseX, + this.lastMouseY, + ) + this.touchStartWorldX = startWorld.x + this.touchStartWorldY = startWorld.y + this.tapCandidate = true this.isPanning = true } } @@ -299,6 +320,13 @@ export class InputHandler { const rect = this.canvas.getBoundingClientRect() const x = t.clientX - rect.left const y = t.clientY - rect.top + if ( + this.tapCandidate && + Math.hypot(x - this.touchStartX, y - this.touchStartY) > + InputHandler.TAP_MOVE_THRESHOLD + ) { + this.tapCandidate = false + } this.viewport.pan(x - this.lastMouseX, y - this.lastMouseY) this.lastMouseX = x this.lastMouseY = y @@ -312,6 +340,16 @@ export class InputHandler { } if (e.touches.length === 0) { this.isPanning = false + if (this.tapCandidate) { + this.tapCandidate = false + // Use the world point captured at touchstart, not the start screen + // point re-projected through the (possibly panned) current viewport. + const node = this.spatialIndex.queryPoint( + this.touchStartWorldX, + this.touchStartWorldY, + ) + this.callbacks.onNodeClick(node?.id ?? null) + } } } }