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.
This commit is contained in:
abhay-codes07 2026-07-12 23:33:38 +05:30
parent 2cebe81512
commit 0a16443929
No known key found for this signature in database
2 changed files with 187 additions and 0 deletions

View file

@ -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<string, Listener>()
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<string, Listener>
let clicks: Array<string | null>
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"])
})
})

View file

@ -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)
}
}
}
}