mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-10-20 05:06:31 +00:00
fix(memory-graph): allow selecting nodes on touch devices
Node selection only worked through the canvas click handler, but onTouchStart calls preventDefault() to stop page scroll/zoom, which also suppresses the browser-synthesized click. A single-finger touch went straight into panning, so on phones and tablets tapping a node did nothing: no selection, no popover, no way to open a document from the graph. Detect taps from the raw touch lifecycle instead: remember where the single-finger touch started, cancel the tap once the finger moves more than 10px or a second finger lands, and on release hit-test the start point through the viewport transform and forward the result to onNodeClick. Tapping empty space clears the selection, matching mouse behaviour. Covered with unit tests driving the handler through a stub canvas: tap on node, tap on empty space, jitter within the slop threshold, pan, pinch, and a tap through a zoomed/panned viewport.
This commit is contained in:
parent
2cebe81512
commit
0a16443929
2 changed files with 187 additions and 0 deletions
160
packages/memory-graph/src/__tests__/input-handler-touch.test.ts
Normal file
160
packages/memory-graph/src/__tests__/input-handler-touch.test.ts
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
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 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,13 @@ 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
|
||||
|
||||
private boundMouseDown: (e: MouseEvent) => void
|
||||
private boundMouseMove: (e: MouseEvent) => void
|
||||
private boundMouseUp: (e: MouseEvent) => void
|
||||
|
|
@ -241,6 +248,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 +266,9 @@ 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
|
||||
this.tapCandidate = true
|
||||
this.isPanning = true
|
||||
}
|
||||
}
|
||||
|
|
@ -299,6 +310,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 +330,15 @@ export class InputHandler {
|
|||
}
|
||||
if (e.touches.length === 0) {
|
||||
this.isPanning = false
|
||||
if (this.tapCandidate) {
|
||||
this.tapCandidate = false
|
||||
const world = this.viewport.screenToWorld(
|
||||
this.touchStartX,
|
||||
this.touchStartY,
|
||||
)
|
||||
const node = this.spatialIndex.queryPoint(world.x, world.y)
|
||||
this.callbacks.onNodeClick(node?.id ?? null)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue