fix(memory-graph): allow selecting nodes on touch devices (#1262)

This commit is contained in:
Abhay Singh 2026-07-22 17:57:50 +05:30 committed by GitHub
parent fa16e853a8
commit d5f95827ed
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 215 additions and 0 deletions

View file

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

View file

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