This commit is contained in:
Aditya kumar singh 2026-09-26 20:20:52 +00:00 • committed by GitHub
commit 3b0b59bd79
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 79 additions and 23 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: () => {},
})
})
@ -127,9 +138,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.length).toBe(1)
expect(dragStarts[0]?.id).toBe("doc-1")
fire("touchmove", touchEvent([touch(160, 120)]))
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 +179,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

@ -46,6 +46,7 @@ export class InputHandler {
// the release hit-test is not thrown off by sub-threshold pans during the tap.
private touchStartWorldX = 0
private touchStartWorldY = 0
private touchStartNodeId: string | null = null
private boundMouseDown: (e: MouseEvent) => void
private boundMouseMove: (e: MouseEvent) => void
@ -300,6 +301,12 @@ export class InputHandler {
if (touches.length >= 2) {
this.isTouchGesture = true
this.tapCandidate = false
if (this.draggingNode) {
this.draggingNode.fx = null
this.draggingNode.fy = null
this.draggingNode = null
this.callbacks.onNodeDragEnd()
}
const t0 = touches[0]
const t1 = touches[1]
if (!t0 || !t1) return
@ -326,7 +333,19 @@ 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)
this.touchStartNodeId = node?.id ?? null
if (node) {
this.draggingNode = node
node.fx = node.x
node.fy = node.y
this.callbacks.onNodeDragStart(node.id, node)
this.isPanning = false
} else {
this.draggingNode = null
this.isPanning = true
}
}
}
@ -359,7 +378,6 @@ export class InputHandler {
this.callbacks.onRequestRender()
} else if (
touches.length === 1 &&
this.isPanning &&
!this.isTouchGesture &&
touches[0]
) {
@ -374,10 +392,20 @@ 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.draggingNode) {
const world = this.viewport.screenToWorld(x, y)
this.draggingNode.fx = world.x
this.draggingNode.fy = world.y
this.draggingNode.x = world.x
this.draggingNode.y = world.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()
}
}
}
@ -387,15 +415,16 @@ export class InputHandler {
}
if (e.touches.length === 0) {
this.isPanning = false
if (this.draggingNode) {
this.draggingNode.fx = null
this.draggingNode.fy = null
this.draggingNode = null
this.callbacks.onNodeDragEnd()
this.callbacks.onRequestRender()
}
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)
this.callbacks.onNodeClick(this.touchStartNodeId)
}
}
}