fix(memory-graph): enable touch and mobile node dragging and interaction (#1639)

This commit is contained in:
Aditya kumar singh 2026-10-12 21:43:19 -07:00 • committed by Mahesh Sanikommu
parent 3454a22554
commit 35794564e4
2 changed files with 75 additions and 21 deletions

View file

@ -78,6 +78,10 @@ describe("InputHandler touch tap-to-select", () => {
let clicks: Array<string | null>
let viewport: ViewportState
let dragStarts: Array<{ id: string; node: GraphNode }>
let dragEnds: number
let testNode: GraphNode
const fire = (name: string, e: TouchEvent) => {
const fn = listeners.get(name)
if (!fn) throw new Error(`no listener registered for ${name}`)
@ -88,19 +92,26 @@ describe("InputHandler touch tap-to-select", () => {
const stub = makeStubCanvas()
listeners = stub.listeners
clicks = []
dragStarts = []
dragEnds = 0
// 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)])
testNode = makeNode("doc-1", 100, 100)
index.rebuild([testNode])
new InputHandler(stub.canvas, viewport, index, {
onNodeHover: () => {},
onNodeClick: (id) => {
clicks.push(id)
},
onNodeDragStart: () => {},
onNodeDragEnd: () => {},
onNodeDragStart: (id, node) => {
dragStarts.push({ id, node })
},
onNodeDragEnd: () => {
dragEnds++
},
onRequestRender: () => {},
})
})
@ -110,6 +121,8 @@ describe("InputHandler touch tap-to-select", () => {
fire("touchend", touchEvent([]))
expect(clicks).toEqual(["doc-1"])
expect(dragStarts).toEqual([])
expect(testNode.fx).toBeUndefined()
})
it("clears selection when tapping empty space", () => {
@ -127,9 +140,28 @@ describe("InputHandler touch tap-to-select", () => {
expect(clicks).toEqual(["doc-1"])
})
it("does not fire a click after a pan", () => {
it("drags a node on touch and updates its fixed coordinates", () => {
fire("touchstart", touchEvent([touch(100, 100)]))
fire("touchmove", touchEvent([touch(160, 100)]))
expect(dragStarts).toEqual([])
fire("touchmove", touchEvent([touch(160, 120)]))
expect(dragStarts.map((d) => d.id)).toEqual(["doc-1"])
expect(testNode.x).toBe(160)
expect(testNode.y).toBe(120)
expect(testNode.fx).toBe(160)
expect(testNode.fy).toBe(120)
fire("touchend", touchEvent([]))
expect(dragEnds).toBe(1)
expect(testNode.fx).toBeNull()
expect(testNode.fy).toBeNull()
// no tap click should fire after a real drag
expect(clicks).toEqual([])
})
it("pans the viewport when dragging empty space", () => {
fire("touchstart", touchEvent([touch(400, 400)]))
fire("touchmove", touchEvent([touch(460, 400)]))
fire("touchend", touchEvent([]))
expect(clicks).toEqual([])
@ -149,18 +181,15 @@ describe("InputHandler touch tap-to-select", () => {
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
// 8px screen jitter (below the 10px tap threshold)
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)
// the tap resolves the node under the finger at touchstart
expect(clicks).toEqual(["doc-1"])
})

View file

@ -300,6 +300,7 @@ export class InputHandler {
if (touches.length >= 2) {
this.isTouchGesture = true
this.tapCandidate = false
this.endTouchDrag()
const t0 = touches[0]
const t1 = touches[1]
if (!t0 || !t1) return
@ -326,7 +327,13 @@ export class InputHandler {
this.touchStartWorldX = startWorld.x
this.touchStartWorldY = startWorld.y
this.tapCandidate = true
this.isPanning = true
const node = this.spatialIndex.queryPoint(startWorld.x, startWorld.y)
if (node) {
this.pressedNode = node
this.grabOffset = { x: startWorld.x - node.x, y: startWorld.y - node.y }
} else {
this.isPanning = true
}
}
}
@ -357,12 +364,7 @@ export class InputHandler {
this.lastTouchDistance = dist
this.lastTouchCenter = center
this.callbacks.onRequestRender()
} else if (
touches.length === 1 &&
this.isPanning &&
!this.isTouchGesture &&
touches[0]
) {
} else if (touches.length === 1 && !this.isTouchGesture && touches[0]) {
const t = touches[0]
const rect = this.canvas.getBoundingClientRect()
const x = t.clientX - rect.left
@ -374,19 +376,42 @@ export class InputHandler {
) {
this.tapCandidate = false
}
this.viewport.pan(x - this.lastMouseX, y - this.lastMouseY)
this.lastMouseX = x
this.lastMouseY = y
this.callbacks.onRequestRender()
if (this.pressedNode && !this.tapCandidate) {
this.draggingNode = this.pressedNode
this.pressedNode = null
this.callbacks.onNodeDragStart(this.draggingNode.id, this.draggingNode)
}
if (this.draggingNode) {
const world = this.viewport.screenToWorld(x, y)
this.draggingNode.x = this.draggingNode.fx = world.x - this.grabOffset.x
this.draggingNode.y = this.draggingNode.fy = world.y - this.grabOffset.y
this.callbacks.onRequestRender()
} else if (this.isPanning) {
this.viewport.pan(x - this.lastMouseX, y - this.lastMouseY)
this.lastMouseX = x
this.lastMouseY = y
this.callbacks.onRequestRender()
}
}
}
private endTouchDrag(): void {
this.pressedNode = null
if (!this.draggingNode) return
this.draggingNode.fx = null
this.draggingNode.fy = null
this.draggingNode = null
this.callbacks.onNodeDragEnd()
this.callbacks.onRequestRender()
}
private onTouchEnd(e: TouchEvent): void {
if (e.touches.length < 2) {
this.isTouchGesture = false
}
if (e.touches.length === 0) {
this.isPanning = false
this.endTouchDrag()
if (this.tapCandidate) {
this.tapCandidate = false
// Use the world point captured at touchstart, not the start screen