diff --git a/packages/memory-graph/src/__tests__/input-handler-touch.test.ts b/packages/memory-graph/src/__tests__/input-handler-touch.test.ts index 12d0e4711..27e98ec6a 100644 --- a/packages/memory-graph/src/__tests__/input-handler-touch.test.ts +++ b/packages/memory-graph/src/__tests__/input-handler-touch.test.ts @@ -78,6 +78,10 @@ describe("InputHandler touch tap-to-select", () => { let clicks: Array 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"]) }) diff --git a/packages/memory-graph/src/canvas/input-handler.ts b/packages/memory-graph/src/canvas/input-handler.ts index a26c37ff1..968fc2e33 100644 --- a/packages/memory-graph/src/canvas/input-handler.ts +++ b/packages/memory-graph/src/canvas/input-handler.ts @@ -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) } } }