Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 37 additions & 10 deletions packages/memory-graph/src/__tests__/input-handler-touch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`)
Expand All @@ -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: () => {},
})
})
Expand All @@ -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([])
Expand All @@ -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"])
})

Expand Down
55 changes: 42 additions & 13 deletions packages/memory-graph/src/canvas/input-handler.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { GraphNode } from "../types"

Check failure on line 1 in packages/memory-graph/src/canvas/input-handler.ts

View workflow job for this annotation

GitHub Actions / Quality Checks

format

File content differs from formatting output
import type { SpatialIndex } from "./hit-test"
import type { ViewportState } from "./viewport"

Expand Down Expand Up @@ -39,8 +39,9 @@
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

Check warning on line 42 in packages/memory-graph/src/canvas/input-handler.ts

View workflow job for this annotation

GitHub Actions / Quality Checks

lint/correctness/noUnusedPrivateClassMembers

This private class member is defined but never used.
private touchStartWorldY = 0

Check warning on line 43 in packages/memory-graph/src/canvas/input-handler.ts

View workflow job for this annotation

GitHub Actions / Quality Checks

lint/correctness/noUnusedPrivateClassMembers

This private class member is defined but never used.
private touchStartNodeId: string | null = null

private boundMouseDown: (e: MouseEvent) => void
private boundMouseMove: (e: MouseEvent) => void
Expand Down Expand Up @@ -253,6 +254,12 @@
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
Expand All @@ -279,7 +286,19 @@
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
}
}
}

Expand Down Expand Up @@ -312,7 +331,6 @@
this.callbacks.onRequestRender()
} else if (
touches.length === 1 &&
this.isPanning &&
!this.isTouchGesture &&
touches[0]
) {
Expand All @@ -327,10 +345,20 @@
) {
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()
}
}
}

Expand All @@ -340,15 +368,16 @@
}
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)
}
}
}
Expand Down
Loading