mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-08-28 05:25:33 +00:00
fix(memory-graph): allow selecting nodes on touch devices (#1262)
This commit is contained in:
parent
fa16e853a8
commit
d5f95827ed
2 changed files with 215 additions and 0 deletions
177
packages/memory-graph/src/__tests__/input-handler-touch.test.ts
Normal file
177
packages/memory-graph/src/__tests__/input-handler-touch.test.ts
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
import { beforeEach, describe, expect, it } from "vitest"
|
||||
import { SpatialIndex } from "../canvas/hit-test"
|
||||
import { InputHandler } from "../canvas/input-handler"
|
||||
import { ViewportState } from "../canvas/viewport"
|
||||
import type { GraphNode } from "../types"
|
||||
|
||||
/**
|
||||
* InputHandler tap-to-select on touch devices.
|
||||
*
|
||||
* onTouchStart calls preventDefault() (required to stop scroll/zoom of the
|
||||
* page), which also suppresses the browser's synthesized click event — so
|
||||
* node selection must be detected from the raw touch lifecycle. These tests
|
||||
* drive the handler through a stub canvas that records the listeners it
|
||||
* registers.
|
||||
*/
|
||||
|
||||
type Listener = (e: Event) => void
|
||||
|
||||
function makeStubCanvas() {
|
||||
const listeners = new Map<string, Listener>()
|
||||
const canvas = {
|
||||
addEventListener: (name: string, fn: Listener) => {
|
||||
listeners.set(name, fn)
|
||||
},
|
||||
removeEventListener: (name: string) => {
|
||||
listeners.delete(name)
|
||||
},
|
||||
getBoundingClientRect: () => ({
|
||||
left: 0,
|
||||
top: 0,
|
||||
right: 800,
|
||||
bottom: 600,
|
||||
width: 800,
|
||||
height: 600,
|
||||
x: 0,
|
||||
y: 0,
|
||||
}),
|
||||
style: {} as CSSStyleDeclaration,
|
||||
}
|
||||
return { canvas: canvas as unknown as HTMLCanvasElement, listeners }
|
||||
}
|
||||
|
||||
function touch(clientX: number, clientY: number) {
|
||||
return { clientX, clientY }
|
||||
}
|
||||
|
||||
function touchEvent(touches: Array<{ clientX: number; clientY: number }>) {
|
||||
return {
|
||||
touches,
|
||||
preventDefault: () => {},
|
||||
} as unknown as TouchEvent
|
||||
}
|
||||
|
||||
function makeNode(id: string, x: number, y: number): GraphNode {
|
||||
return {
|
||||
id,
|
||||
type: "document",
|
||||
x,
|
||||
y,
|
||||
data: {
|
||||
id,
|
||||
title: id,
|
||||
summary: "",
|
||||
type: "",
|
||||
createdAt: "2026-01-01",
|
||||
updatedAt: "2026-01-01",
|
||||
memories: [],
|
||||
},
|
||||
size: 40,
|
||||
borderColor: "#fff",
|
||||
isHovered: false,
|
||||
isDragging: false,
|
||||
}
|
||||
}
|
||||
|
||||
describe("InputHandler touch tap-to-select", () => {
|
||||
let listeners: Map<string, Listener>
|
||||
let clicks: Array<string | null>
|
||||
let viewport: ViewportState
|
||||
|
||||
const fire = (name: string, e: TouchEvent) => {
|
||||
const fn = listeners.get(name)
|
||||
if (!fn) throw new Error(`no listener registered for ${name}`)
|
||||
fn(e as unknown as Event)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
const stub = makeStubCanvas()
|
||||
listeners = stub.listeners
|
||||
clicks = []
|
||||
|
||||
// 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)])
|
||||
|
||||
new InputHandler(stub.canvas, viewport, index, {
|
||||
onNodeHover: () => {},
|
||||
onNodeClick: (id) => {
|
||||
clicks.push(id)
|
||||
},
|
||||
onNodeDragStart: () => {},
|
||||
onNodeDragEnd: () => {},
|
||||
onRequestRender: () => {},
|
||||
})
|
||||
})
|
||||
|
||||
it("selects a node on a single tap", () => {
|
||||
fire("touchstart", touchEvent([touch(100, 100)]))
|
||||
fire("touchend", touchEvent([]))
|
||||
|
||||
expect(clicks).toEqual(["doc-1"])
|
||||
})
|
||||
|
||||
it("clears selection when tapping empty space", () => {
|
||||
fire("touchstart", touchEvent([touch(400, 400)]))
|
||||
fire("touchend", touchEvent([]))
|
||||
|
||||
expect(clicks).toEqual([null])
|
||||
})
|
||||
|
||||
it("still counts jittery taps within the movement threshold", () => {
|
||||
fire("touchstart", touchEvent([touch(100, 100)]))
|
||||
fire("touchmove", touchEvent([touch(104, 103)]))
|
||||
fire("touchend", touchEvent([]))
|
||||
|
||||
expect(clicks).toEqual(["doc-1"])
|
||||
})
|
||||
|
||||
it("does not fire a click after a pan", () => {
|
||||
fire("touchstart", touchEvent([touch(100, 100)]))
|
||||
fire("touchmove", touchEvent([touch(160, 100)]))
|
||||
fire("touchend", touchEvent([]))
|
||||
|
||||
expect(clicks).toEqual([])
|
||||
// the drag actually panned the viewport
|
||||
expect(viewport.panX).toBe(60)
|
||||
})
|
||||
|
||||
it("does not fire a click after a pinch gesture", () => {
|
||||
fire("touchstart", touchEvent([touch(100, 100)]))
|
||||
// second finger lands -> pinch, no longer a tap
|
||||
fire("touchstart", touchEvent([touch(100, 100), touch(200, 200)]))
|
||||
fire("touchend", touchEvent([touch(100, 100)]))
|
||||
fire("touchend", touchEvent([]))
|
||||
|
||||
expect(clicks).toEqual([])
|
||||
})
|
||||
|
||||
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
|
||||
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)
|
||||
expect(clicks).toEqual(["doc-1"])
|
||||
})
|
||||
|
||||
it("hit-tests the tap through the current viewport transform", () => {
|
||||
// zoom 2x, pan (50, 50): world (100, 100) renders at screen (250, 250)
|
||||
viewport.pan(50, 50)
|
||||
viewport.zoomImmediate(2, 50, 50)
|
||||
|
||||
fire("touchstart", touchEvent([touch(250, 250)]))
|
||||
fire("touchend", touchEvent([]))
|
||||
|
||||
expect(clicks).toEqual(["doc-1"])
|
||||
})
|
||||
})
|
||||
|
|
@ -31,6 +31,17 @@ export class InputHandler {
|
|||
private lastTouchCenter = { x: 0, y: 0 }
|
||||
private isTouchGesture = false
|
||||
|
||||
// Tap detection: touch browsers never fire the synthesized click because
|
||||
// onTouchStart calls preventDefault(), so taps are detected manually.
|
||||
private static readonly TAP_MOVE_THRESHOLD = 10
|
||||
private tapCandidate = false
|
||||
private touchStartX = 0
|
||||
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
|
||||
private touchStartWorldY = 0
|
||||
|
||||
private boundMouseDown: (e: MouseEvent) => void
|
||||
private boundMouseMove: (e: MouseEvent) => void
|
||||
private boundMouseUp: (e: MouseEvent) => void
|
||||
|
|
@ -241,6 +252,7 @@ export class InputHandler {
|
|||
|
||||
if (touches.length >= 2) {
|
||||
this.isTouchGesture = true
|
||||
this.tapCandidate = false
|
||||
const t0 = touches[0]
|
||||
const t1 = touches[1]
|
||||
if (!t0 || !t1) return
|
||||
|
|
@ -258,6 +270,15 @@ export class InputHandler {
|
|||
const rect = this.canvas.getBoundingClientRect()
|
||||
this.lastMouseX = t.clientX - rect.left
|
||||
this.lastMouseY = t.clientY - rect.top
|
||||
this.touchStartX = this.lastMouseX
|
||||
this.touchStartY = this.lastMouseY
|
||||
const startWorld = this.viewport.screenToWorld(
|
||||
this.lastMouseX,
|
||||
this.lastMouseY,
|
||||
)
|
||||
this.touchStartWorldX = startWorld.x
|
||||
this.touchStartWorldY = startWorld.y
|
||||
this.tapCandidate = true
|
||||
this.isPanning = true
|
||||
}
|
||||
}
|
||||
|
|
@ -299,6 +320,13 @@ export class InputHandler {
|
|||
const rect = this.canvas.getBoundingClientRect()
|
||||
const x = t.clientX - rect.left
|
||||
const y = t.clientY - rect.top
|
||||
if (
|
||||
this.tapCandidate &&
|
||||
Math.hypot(x - this.touchStartX, y - this.touchStartY) >
|
||||
InputHandler.TAP_MOVE_THRESHOLD
|
||||
) {
|
||||
this.tapCandidate = false
|
||||
}
|
||||
this.viewport.pan(x - this.lastMouseX, y - this.lastMouseY)
|
||||
this.lastMouseX = x
|
||||
this.lastMouseY = y
|
||||
|
|
@ -312,6 +340,16 @@ export class InputHandler {
|
|||
}
|
||||
if (e.touches.length === 0) {
|
||||
this.isPanning = false
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue