From 0a1644392988d2f5cf651c8a8879a1610debca35 Mon Sep 17 00:00:00 2001 From: abhay-codes07 Date: Sun, 12 Jul 2026 23:33:38 +0530 Subject: [PATCH] fix(memory-graph): allow selecting nodes on touch devices Node selection only worked through the canvas click handler, but onTouchStart calls preventDefault() to stop page scroll/zoom, which also suppresses the browser-synthesized click. A single-finger touch went straight into panning, so on phones and tablets tapping a node did nothing: no selection, no popover, no way to open a document from the graph. Detect taps from the raw touch lifecycle instead: remember where the single-finger touch started, cancel the tap once the finger moves more than 10px or a second finger lands, and on release hit-test the start point through the viewport transform and forward the result to onNodeClick. Tapping empty space clears the selection, matching mouse behaviour. Covered with unit tests driving the handler through a stub canvas: tap on node, tap on empty space, jitter within the slop threshold, pan, pinch, and a tap through a zoomed/panned viewport. --- .../src/__tests__/input-handler-touch.test.ts | 160 ++++++++++++++++++ .../memory-graph/src/canvas/input-handler.ts | 27 +++ 2 files changed, 187 insertions(+) create mode 100644 packages/memory-graph/src/__tests__/input-handler-touch.test.ts 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..279fa7e7 --- /dev/null +++ b/packages/memory-graph/src/__tests__/input-handler-touch.test.ts @@ -0,0 +1,160 @@ +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 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..08acdd77 100644 --- a/packages/memory-graph/src/canvas/input-handler.ts +++ b/packages/memory-graph/src/canvas/input-handler.ts @@ -31,6 +31,13 @@ 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 + private boundMouseDown: (e: MouseEvent) => void private boundMouseMove: (e: MouseEvent) => void private boundMouseUp: (e: MouseEvent) => void @@ -241,6 +248,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 +266,9 @@ 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 + this.tapCandidate = true this.isPanning = true } } @@ -299,6 +310,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 +330,15 @@ export class InputHandler { } if (e.touches.length === 0) { this.isPanning = false + if (this.tapCandidate) { + this.tapCandidate = false + const world = this.viewport.screenToWorld( + this.touchStartX, + this.touchStartY, + ) + const node = this.spatialIndex.queryPoint(world.x, world.y) + this.callbacks.onNodeClick(node?.id ?? null) + } } } }