mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-10-02 02:11:20 +00:00
fix(memory-graph): enable touch and mobile node dragging and interaction
This commit is contained in:
parent
f11d8c4620
commit
994193ee3c
2 changed files with 79 additions and 23 deletions
|
|
@ -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"])
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -41,6 +41,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
|
||||
|
|
@ -253,6 +254,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
|
||||
|
|
@ -279,7 +286,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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -312,7 +331,6 @@ export class InputHandler {
|
|||
this.callbacks.onRequestRender()
|
||||
} else if (
|
||||
touches.length === 1 &&
|
||||
this.isPanning &&
|
||||
!this.isTouchGesture &&
|
||||
touches[0]
|
||||
) {
|
||||
|
|
@ -327,10 +345,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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -340,15 +368,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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue