mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-05-05 23:40:57 +00:00
324 lines
9.5 KiB
TypeScript
324 lines
9.5 KiB
TypeScript
import type { ViewportState } from "./viewport"
|
|
import type { SpatialIndex } from "./hit-test"
|
|
import type { GraphNode } from "../types"
|
|
|
|
interface InputCallbacks {
|
|
onNodeHover: (id: string | null) => void
|
|
onNodeClick: (id: string | null) => void
|
|
onNodeDragStart: (id: string, node: GraphNode) => void
|
|
onNodeDragEnd: () => void
|
|
onRequestRender: () => void
|
|
}
|
|
|
|
export class InputHandler {
|
|
private canvas: HTMLCanvasElement
|
|
private viewport: ViewportState
|
|
private spatialIndex: SpatialIndex
|
|
private callbacks: InputCallbacks
|
|
|
|
private isPanning = false
|
|
private lastMouseX = 0
|
|
private lastMouseY = 0
|
|
|
|
// Ring buffer for velocity tracking
|
|
private posHistory: Array<{ x: number; y: number; t: number }> = []
|
|
|
|
private draggingNode: GraphNode | null = null
|
|
private dragStartX = 0
|
|
private dragStartY = 0
|
|
private didDrag = false
|
|
|
|
private currentHoveredId: string | null = null
|
|
|
|
// Touch state
|
|
private lastTouchDistance = 0
|
|
private lastTouchCenter = { x: 0, y: 0 }
|
|
private isTouchGesture = false
|
|
|
|
// Bound handlers for cleanup
|
|
private boundMouseDown: (e: MouseEvent) => void
|
|
private boundMouseMove: (e: MouseEvent) => void
|
|
private boundMouseUp: (e: MouseEvent) => void
|
|
private boundWheel: (e: WheelEvent) => void
|
|
private boundClick: (e: MouseEvent) => void
|
|
private boundDblClick: (e: MouseEvent) => void
|
|
private boundTouchStart: (e: TouchEvent) => void
|
|
private boundTouchMove: (e: TouchEvent) => void
|
|
private boundTouchEnd: (e: TouchEvent) => void
|
|
private boundGesture: (e: Event) => void
|
|
|
|
constructor(
|
|
canvas: HTMLCanvasElement,
|
|
viewport: ViewportState,
|
|
spatialIndex: SpatialIndex,
|
|
callbacks: InputCallbacks,
|
|
) {
|
|
this.canvas = canvas
|
|
this.viewport = viewport
|
|
this.spatialIndex = spatialIndex
|
|
this.callbacks = callbacks
|
|
|
|
this.boundMouseDown = this.onMouseDown.bind(this)
|
|
this.boundMouseMove = this.onMouseMove.bind(this)
|
|
this.boundMouseUp = this.onMouseUp.bind(this)
|
|
this.boundWheel = this.onWheel.bind(this)
|
|
this.boundClick = this.onClick.bind(this)
|
|
this.boundDblClick = this.onDblClick.bind(this)
|
|
this.boundTouchStart = this.onTouchStart.bind(this)
|
|
this.boundTouchMove = this.onTouchMove.bind(this)
|
|
this.boundTouchEnd = this.onTouchEnd.bind(this)
|
|
this.boundGesture = (e: Event) => e.preventDefault()
|
|
|
|
canvas.addEventListener("mousedown", this.boundMouseDown)
|
|
canvas.addEventListener("mousemove", this.boundMouseMove)
|
|
canvas.addEventListener("mouseup", this.boundMouseUp)
|
|
canvas.addEventListener("click", this.boundClick)
|
|
canvas.addEventListener("dblclick", this.boundDblClick)
|
|
canvas.addEventListener("wheel", this.boundWheel, { passive: false })
|
|
canvas.addEventListener("touchstart", this.boundTouchStart, {
|
|
passive: false,
|
|
})
|
|
canvas.addEventListener("touchmove", this.boundTouchMove, {
|
|
passive: false,
|
|
})
|
|
canvas.addEventListener("touchend", this.boundTouchEnd)
|
|
canvas.addEventListener("gesturestart", this.boundGesture, {
|
|
passive: false,
|
|
})
|
|
canvas.addEventListener("gesturechange", this.boundGesture, {
|
|
passive: false,
|
|
})
|
|
canvas.addEventListener("gestureend", this.boundGesture, { passive: false })
|
|
}
|
|
|
|
destroy(): void {
|
|
const c = this.canvas
|
|
c.removeEventListener("mousedown", this.boundMouseDown)
|
|
c.removeEventListener("mousemove", this.boundMouseMove)
|
|
c.removeEventListener("mouseup", this.boundMouseUp)
|
|
c.removeEventListener("click", this.boundClick)
|
|
c.removeEventListener("dblclick", this.boundDblClick)
|
|
c.removeEventListener("wheel", this.boundWheel)
|
|
c.removeEventListener("touchstart", this.boundTouchStart)
|
|
c.removeEventListener("touchmove", this.boundTouchMove)
|
|
c.removeEventListener("touchend", this.boundTouchEnd)
|
|
c.removeEventListener("gesturestart", this.boundGesture)
|
|
c.removeEventListener("gesturechange", this.boundGesture)
|
|
c.removeEventListener("gestureend", this.boundGesture)
|
|
}
|
|
|
|
getDraggingNode(): GraphNode | null {
|
|
return this.draggingNode
|
|
}
|
|
|
|
private canvasXY(e: MouseEvent): { x: number; y: number } {
|
|
const rect = this.canvas.getBoundingClientRect()
|
|
return { x: e.clientX - rect.left, y: e.clientY - rect.top }
|
|
}
|
|
|
|
private onMouseDown(e: MouseEvent): void {
|
|
const { x, y } = this.canvasXY(e)
|
|
const world = this.viewport.screenToWorld(x, y)
|
|
const node = this.spatialIndex.queryPoint(world.x, world.y)
|
|
|
|
this.lastMouseX = x
|
|
this.lastMouseY = y
|
|
this.posHistory = [{ x, y, t: performance.now() }]
|
|
this.didDrag = false
|
|
|
|
if (node) {
|
|
this.draggingNode = node
|
|
this.dragStartX = x
|
|
this.dragStartY = y
|
|
node.fx = node.x
|
|
node.fy = node.y
|
|
this.callbacks.onNodeDragStart(node.id, node)
|
|
this.canvas.style.cursor = "grabbing"
|
|
} else {
|
|
this.isPanning = true
|
|
this.canvas.style.cursor = "grabbing"
|
|
}
|
|
}
|
|
|
|
private onMouseMove(e: MouseEvent): void {
|
|
const { x, y } = this.canvasXY(e)
|
|
|
|
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.didDrag = true
|
|
this.callbacks.onRequestRender()
|
|
return
|
|
}
|
|
|
|
if (this.isPanning) {
|
|
const dx = x - this.lastMouseX
|
|
const dy = y - this.lastMouseY
|
|
this.viewport.pan(dx, dy)
|
|
this.lastMouseX = x
|
|
this.lastMouseY = y
|
|
this.didDrag = true
|
|
|
|
// Track positions for velocity (keep last 4)
|
|
const now = performance.now()
|
|
this.posHistory.push({ x, y, t: now })
|
|
if (this.posHistory.length > 4) this.posHistory.shift()
|
|
|
|
this.callbacks.onRequestRender()
|
|
return
|
|
}
|
|
|
|
// Hover detection
|
|
const world = this.viewport.screenToWorld(x, y)
|
|
const node = this.spatialIndex.queryPoint(world.x, world.y)
|
|
const id = node?.id ?? null
|
|
if (id !== this.currentHoveredId) {
|
|
this.currentHoveredId = id
|
|
this.callbacks.onNodeHover(id)
|
|
this.canvas.style.cursor = id ? "grab" : "default"
|
|
this.callbacks.onRequestRender()
|
|
}
|
|
}
|
|
|
|
private onMouseUp(_e: MouseEvent): void {
|
|
if (this.draggingNode) {
|
|
this.draggingNode.fx = null
|
|
this.draggingNode.fy = null
|
|
this.draggingNode = null
|
|
this.callbacks.onNodeDragEnd()
|
|
this.canvas.style.cursor = this.currentHoveredId ? "grab" : "default"
|
|
return
|
|
}
|
|
|
|
if (this.isPanning) {
|
|
this.isPanning = false
|
|
|
|
// Calculate release velocity from position history
|
|
if (this.posHistory.length >= 2) {
|
|
const newest = this.posHistory[this.posHistory.length - 1]!
|
|
const oldest = this.posHistory[0]!
|
|
const dt = newest.t - oldest.t
|
|
if (dt > 0 && dt < 200) {
|
|
const vx = ((newest.x - oldest.x) / dt) * 16 // scale to ~60fps frame
|
|
const vy = ((newest.y - oldest.y) / dt) * 16
|
|
this.viewport.releaseWithVelocity(vx, vy)
|
|
}
|
|
}
|
|
|
|
this.canvas.style.cursor = "default"
|
|
this.callbacks.onRequestRender()
|
|
}
|
|
}
|
|
|
|
private onClick(e: MouseEvent): void {
|
|
if (this.didDrag) return
|
|
|
|
const { x, y } = this.canvasXY(e)
|
|
const world = this.viewport.screenToWorld(x, y)
|
|
const node = this.spatialIndex.queryPoint(world.x, world.y)
|
|
this.callbacks.onNodeClick(node?.id ?? null)
|
|
}
|
|
|
|
private onDblClick(e: MouseEvent): void {
|
|
const { x, y } = this.canvasXY(e)
|
|
this.viewport.zoomTo(this.viewport.zoom * 1.5, x, y)
|
|
this.callbacks.onRequestRender()
|
|
}
|
|
|
|
private onWheel(e: WheelEvent): void {
|
|
e.preventDefault()
|
|
|
|
const { x, y } = this.canvasXY(e)
|
|
|
|
// Horizontal scroll -> pan
|
|
if (Math.abs(e.deltaX) > Math.abs(e.deltaY)) {
|
|
this.viewport.pan(-e.deltaX, 0)
|
|
this.callbacks.onRequestRender()
|
|
return
|
|
}
|
|
|
|
// Vertical scroll -> zoom
|
|
const factor = e.deltaY > 0 ? 0.97 : 1.03
|
|
this.viewport.zoomImmediate(factor, x, y)
|
|
this.callbacks.onRequestRender()
|
|
}
|
|
|
|
// Touch handling
|
|
private onTouchStart(e: TouchEvent): void {
|
|
e.preventDefault()
|
|
const touches = e.touches
|
|
|
|
if (touches.length >= 2) {
|
|
this.isTouchGesture = true
|
|
const t0 = touches[0]!
|
|
const t1 = touches[1]!
|
|
this.lastTouchDistance = Math.hypot(
|
|
t1.clientX - t0.clientX,
|
|
t1.clientY - t0.clientY,
|
|
)
|
|
this.lastTouchCenter = {
|
|
x: (t0.clientX + t1.clientX) / 2,
|
|
y: (t0.clientY + t1.clientY) / 2,
|
|
}
|
|
} else if (touches.length === 1) {
|
|
this.isTouchGesture = false
|
|
const t = touches[0]!
|
|
const rect = this.canvas.getBoundingClientRect()
|
|
this.lastMouseX = t.clientX - rect.left
|
|
this.lastMouseY = t.clientY - rect.top
|
|
this.isPanning = true
|
|
}
|
|
}
|
|
|
|
private onTouchMove(e: TouchEvent): void {
|
|
e.preventDefault()
|
|
const touches = e.touches
|
|
|
|
if (touches.length >= 2 && this.isTouchGesture) {
|
|
const t0 = touches[0]!
|
|
const t1 = touches[1]!
|
|
const dist = Math.hypot(t1.clientX - t0.clientX, t1.clientY - t0.clientY)
|
|
const center = {
|
|
x: (t0.clientX + t1.clientX) / 2,
|
|
y: (t0.clientY + t1.clientY) / 2,
|
|
}
|
|
const rect = this.canvas.getBoundingClientRect()
|
|
const cx = center.x - rect.left
|
|
const cy = center.y - rect.top
|
|
|
|
// Pinch zoom
|
|
const scale = dist / this.lastTouchDistance
|
|
this.viewport.zoomImmediate(scale, cx, cy)
|
|
|
|
// Pan from center movement
|
|
const dx = center.x - this.lastTouchCenter.x
|
|
const dy = center.y - this.lastTouchCenter.y
|
|
this.viewport.pan(dx, dy)
|
|
|
|
this.lastTouchDistance = dist
|
|
this.lastTouchCenter = center
|
|
this.callbacks.onRequestRender()
|
|
} else if (touches.length === 1 && this.isPanning && !this.isTouchGesture) {
|
|
const t = touches[0]!
|
|
const rect = this.canvas.getBoundingClientRect()
|
|
const x = t.clientX - rect.left
|
|
const y = t.clientY - rect.top
|
|
this.viewport.pan(x - this.lastMouseX, y - this.lastMouseY)
|
|
this.lastMouseX = x
|
|
this.lastMouseY = y
|
|
this.callbacks.onRequestRender()
|
|
}
|
|
}
|
|
|
|
private onTouchEnd(e: TouchEvent): void {
|
|
if (e.touches.length < 2) {
|
|
this.isTouchGesture = false
|
|
}
|
|
if (e.touches.length === 0) {
|
|
this.isPanning = false
|
|
}
|
|
}
|
|
}
|