fix(memory-graph): release cancelled drags and keep minimaps current

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
MaheshtheDev 2026-10-13 22:26:05 +00:00
parent 2dbf02018f
commit 5f3e8e1902
10 changed files with 464 additions and 43 deletions

View file

@ -1,19 +1,10 @@
import { beforeEach, describe, expect, it } from "vitest"
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
import { SpatialIndex } from "../canvas/hit-test"
import { InputHandler } from "../canvas/input-handler"
import { ForceSimulation } from "../canvas/simulation"
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() {
@ -81,6 +72,8 @@ describe("InputHandler touch tap-to-select", () => {
let dragStarts: Array<{ id: string; node: GraphNode }>
let dragEnds: number
let testNode: GraphNode
let handler: InputHandler
let simulation: ForceSimulation | null
const fire = (name: string, e: TouchEvent) => {
const fn = listeners.get(name)
@ -94,28 +87,106 @@ describe("InputHandler touch tap-to-select", () => {
clicks = []
dragStarts = []
dragEnds = 0
simulation = null
// zoom 1 / pan 0 so screen coordinates equal world coordinates
viewport = new ViewportState(0, 0, 1)
const index = new SpatialIndex()
testNode = makeNode("doc-1", 100, 100)
index.rebuild([testNode])
new InputHandler(stub.canvas, viewport, index, {
handler = new InputHandler(stub.canvas, viewport, index, {
onNodeHover: () => {},
onNodeClick: (id) => {
clicks.push(id)
},
onNodeDragStart: (id, node) => {
dragStarts.push({ id, node })
simulation?.reheat()
},
onNodeDragEnd: () => {
dragEnds++
simulation?.coolDown()
},
onRequestRender: () => {},
})
})
afterEach(() => {
handler.destroy()
simulation?.destroy()
vi.unstubAllGlobals()
})
it("cancels a pending tap without selecting and permits a subsequent tap", () => {
fire("touchstart", touchEvent([touch(100, 100)]))
fire("touchcancel", touchEvent([]))
fire("touchend", touchEvent([]))
expect(clicks).toEqual([])
expect(dragEnds).toBe(0)
fire("touchstart", touchEvent([touch(100, 100)]))
fire("touchend", touchEvent([]))
expect(clicks).toEqual(["doc-1"])
})
it.each([
false,
true,
])("releases cancelled touch drags with reduced motion %s", (reduced) => {
vi.stubGlobal("matchMedia", () => ({ matches: reduced }))
simulation = new ForceSimulation()
simulation.init([testNode], [])
simulation.stop()
testNode.x = 100
testNode.y = 100
fire("touchstart", touchEvent([touch(100, 100)]))
fire("touchmove", touchEvent([touch(160, 120)]))
expect(testNode.fx).toBe(160)
expect(simulation.isActive()).toBe(!reduced)
fire("touchcancel", touchEvent([]))
expect(testNode.fx).toBeNull()
expect(testNode.fy).toBeNull()
expect(handler.getDraggingNode()).toBeNull()
expect(simulation.isActive()).toBe(false)
expect(dragEnds).toBe(1)
fire("touchcancel", touchEvent([]))
fire("touchend", touchEvent([]))
expect(dragEnds).toBe(1)
expect(clicks).toEqual([])
})
it("clears cancelled pan and pinch state without selecting", () => {
fire("touchstart", touchEvent([touch(400, 400)]))
fire("touchmove", touchEvent([touch(460, 400)]))
fire("touchcancel", touchEvent([]))
fire("touchmove", touchEvent([touch(500, 400)]))
expect(viewport.panX).toBe(60)
fire("touchstart", touchEvent([touch(100, 100), touch(200, 200)]))
fire("touchcancel", touchEvent([]))
fire("touchend", touchEvent([]))
expect(clicks).toEqual([])
})
it("releases a touch drag on pinch and removes cancellation listeners on destroy", () => {
fire("touchstart", touchEvent([touch(100, 100)]))
fire("touchmove", touchEvent([touch(160, 120)]))
fire("touchstart", touchEvent([touch(160, 120), touch(240, 200)]))
expect(testNode.fx).toBeNull()
expect(dragEnds).toBe(1)
handler.destroy()
expect(listeners.has("touchcancel")).toBe(false)
expect(listeners.size).toBe(0)
expect(clicks).toEqual([])
})
it("increments the position revision only when a drag moves the node", () => {
fire("touchstart", touchEvent([touch(100, 100)]))
fire("touchmove", touchEvent([touch(160, 120)]))
expect(viewport.nodePositionVersion).toBe(1)
fire("touchmove", touchEvent([touch(160, 120)]))
fire("touchend", touchEvent([]))
expect(viewport.nodePositionVersion).toBe(1)
})
it("selects a node on a single tap", () => {
fire("touchstart", touchEvent([touch(100, 100)]))
fire("touchend", touchEvent([]))

View file

@ -0,0 +1,193 @@
import { act, cleanup, render } from "@testing-library/react"
import { afterEach, describe, expect, it, vi } from "vitest"
import { InputHandler } from "../canvas/input-handler"
import { SpatialIndex } from "../canvas/hit-test"
import { ForceSimulation } from "../canvas/simulation"
import { ViewportState } from "../canvas/viewport"
import { Minimap } from "../components/minimap"
import { DEFAULT_COLORS } from "../constants"
import type { GraphNode } from "../types"
const handlers: InputHandler[] = []
const simulations: ForceSimulation[] = []
afterEach(() => {
cleanup()
for (const handler of handlers.splice(0)) handler.destroy()
for (const simulation of simulations.splice(0)) simulation.destroy()
vi.restoreAllMocks()
vi.unstubAllGlobals()
})
function makeNode(id: string, x: number, y: number): GraphNode {
return {
id,
type: "document",
x,
y,
size: 40,
borderColor: "#fff",
isHovered: false,
isDragging: false,
data: {
id,
title: id,
summary: null,
type: "text",
createdAt: "2026-01-01",
updatedAt: "2026-01-01",
memories: [],
},
}
}
describe("Minimap stopped-layout dragging", () => {
it.each([
{ reduced: false, touch: false },
{ reduced: false, touch: true },
{ reduced: true, touch: false },
{ reduced: true, touch: true },
])("refreshes actual dragging with reduced=$reduced and touch=$touch without idle scans", ({
reduced,
touch,
}) => {
const frames = new Map<number, FrameRequestCallback>()
let nextFrame = 0
vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => {
frames.set(++nextFrame, callback)
return nextFrame
})
vi.stubGlobal("cancelAnimationFrame", (id: number) => frames.delete(id))
vi.stubGlobal("matchMedia", () => ({ matches: reduced }))
const frame = () => {
const callbacks = [...frames.values()]
frames.clear()
act(() => {
for (const callback of callbacks) callback(0)
})
}
const clearRect = vi.fn()
const arc = vi.fn()
const ctx = new Proxy(
{ clearRect, arc },
{
get: (target, key) => Reflect.get(target, key) ?? (() => {}),
set: () => true,
},
) as unknown as CanvasRenderingContext2D
vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue(ctx)
vi.spyOn(
HTMLCanvasElement.prototype,
"getBoundingClientRect",
).mockReturnValue({
x: 0,
y: 0,
left: 0,
top: 0,
width: 800,
height: 600,
right: 800,
bottom: 600,
toJSON: () => ({}),
})
const node = makeNode("drag", 100, 100)
const nodes = [makeNode("left", 0, 0), node, makeNode("right", 400, 300)]
const viewport = new ViewportState(0, 0, 1)
let simulation: ForceSimulation | undefined
if (reduced) {
simulation = new ForceSimulation()
simulations.push(simulation)
simulation.init(nodes, [])
expect(simulation.isActive()).toBe(false)
}
let x = node.x
const readX = vi.fn(() => x)
Object.defineProperty(node, "x", {
get: readX,
set: (value: number) => {
x = value
},
})
const result = render(
<Minimap
nodes={nodes}
colors={DEFAULT_COLORS}
viewportRef={{ current: viewport }}
canvasWidth={800}
canvasHeight={600}
simulation={simulation}
/>,
)
frame()
expect(clearRect).toHaveBeenCalledTimes(1)
const initialArcs = [...arc.mock.calls]
readX.mockClear()
for (let i = 0; i < 5; i++) frame()
expect(clearRect).toHaveBeenCalledTimes(1)
expect(readX).not.toHaveBeenCalled()
const canvas = document.createElement("canvas")
document.body.appendChild(canvas)
const index = new SpatialIndex()
index.rebuild(nodes, true)
const onClick = vi.fn()
const onEnd = vi.fn(() => simulation?.coolDown())
const handler = new InputHandler(canvas, viewport, index, {
onNodeHover: () => {},
onNodeClick: onClick,
onNodeDragStart: () => simulation?.reheat(),
onNodeDragEnd: onEnd,
onRequestRender: () => {},
})
handlers.push(handler)
const startX = node.x
const startY = node.y
if (touch) {
canvas.dispatchEvent(
Object.assign(new Event("touchstart"), {
touches: [{ clientX: startX, clientY: startY }],
}),
)
canvas.dispatchEvent(
Object.assign(new Event("touchmove"), {
touches: [{ clientX: startX + 60, clientY: startY + 30 }],
}),
)
} else {
canvas.dispatchEvent(
new MouseEvent("mousedown", {
clientX: startX,
clientY: startY,
button: 0,
}),
)
window.dispatchEvent(
new MouseEvent("mousemove", {
clientX: startX + 60,
clientY: startY + 30,
}),
)
}
expect(node.x).toBe(startX + 60)
expect(viewport.nodePositionVersion).toBe(1)
frame()
expect(clearRect).toHaveBeenCalledTimes(2)
expect(arc.mock.calls.slice(3)).not.toEqual(initialArcs)
if (touch)
canvas.dispatchEvent(
Object.assign(new Event("touchend"), { touches: [] }),
)
else window.dispatchEvent(new MouseEvent("mouseup"))
expect(onEnd).toHaveBeenCalledOnce()
expect(onClick).not.toHaveBeenCalled()
readX.mockClear()
for (let i = 0; i < 5; i++) frame()
expect(clearRect).toHaveBeenCalledTimes(2)
expect(readX).not.toHaveBeenCalled()
viewport.pan(1, 0)
frame()
expect(clearRect).toHaveBeenCalledTimes(3)
result.unmount()
expect(frames.size).toBe(0)
canvas.remove()
})
})

View file

@ -71,6 +71,54 @@ describe("prefersReducedMotion", () => {
})
describe("ForceSimulation reduced-motion", () => {
it.each([
false,
true,
])("stops live motion and cleans up the %s legacy-query listener", (legacy) => {
const listeners = new Set<(event: MediaQueryListEvent) => void>()
const query = {
matches: false,
addEventListener: legacy
? undefined
: (_type: string, listener: (event: MediaQueryListEvent) => void) =>
listeners.add(listener),
removeEventListener: legacy
? undefined
: (_type: string, listener: (event: MediaQueryListEvent) => void) =>
listeners.delete(listener),
addListener: (listener: (event: MediaQueryListEvent) => void) =>
listeners.add(listener),
removeListener: (listener: (event: MediaQueryListEvent) => void) =>
listeners.delete(listener),
}
vi.stubGlobal("matchMedia", () => query)
const sim = new ForceSimulation()
sim.init(nodes, edges)
sim.reheat()
expect(sim.isActive()).toBe(true)
expect(listeners.size).toBe(1)
query.matches = true
for (const listener of listeners)
listener({ matches: true } as MediaQueryListEvent)
expect(sim.isActive()).toBe(false)
sim.reheat()
expect(sim.isActive()).toBe(false)
sim.init(nodes, edges)
expect(listeners.size).toBe(1)
sim.destroy()
expect(listeners.size).toBe(0)
})
it("preserves allowed-motion layout when matchMedia throws", () => {
vi.stubGlobal("matchMedia", () => {
throw new Error("unavailable")
})
const sim = new ForceSimulation()
sim.init(nodes, edges)
expect(sim.isActive()).toBe(true)
sim.destroy()
})
it("leaves the layout static after init and ignores reheat", () => {
stubReducedMotion(true)
const sim = new ForceSimulation()
@ -93,6 +141,21 @@ describe("ForceSimulation reduced-motion", () => {
})
describe("ViewportState reduced-motion", () => {
it("discards an existing fling when the live preference changes", () => {
const query = { matches: false }
vi.stubGlobal("matchMedia", () => query)
const vp = new ViewportState(0, 0, 1)
vp.releaseWithVelocity(50, 25)
vp.tick()
expect(vp.panX).toBe(50)
query.matches = true
vp.tick()
expect(vp.panX).toBe(50)
expect(vp.panY).toBe(25)
query.matches = false
expect(vp.tick()).toBe(false)
})
it("drops fling momentum under reduced motion", () => {
stubReducedMotion(true)
const vp = new ViewportState(0, 0, 1)

View file

@ -98,6 +98,41 @@ function getDocumentNode(
}
describe("shared memory identity", () => {
it("retains distinct hyphenated document endpoint pairs and deduplicates exact repeats", () => {
const documents = [
makeDocument("a-b", [makeMemory({ id: "c" }), makeMemory({ id: "c" })]),
makeDocument("a", [makeMemory({ id: "b-c" })]),
]
expect(
computeEdges(documents).map((edge) => [edge.source, edge.target]),
).toEqual([
["a-b", "c"],
["a", "b-c"],
])
})
it("retains distinct hyphenated relation endpoint pairs and the first repeated type", () => {
const documents = [
makeDocument("doc", [
makeMemory({ id: "a-b" }),
makeMemory({ id: "a" }),
makeMemory({ id: "c", memoryRelations: { "a-b": "updates" } }),
makeMemory({ id: "b-c", memoryRelations: { a: "extends" } }),
]),
makeDocument("other", [
makeMemory({ id: "c", memoryRelations: { "a-b": "extends" } }),
]),
]
expect(
computeEdges(documents)
.filter((edge) => edge.edgeType !== "document")
.map((edge) => [edge.source, edge.target, edge.edgeType]),
).toEqual([
["a-b", "c", "updates"],
["a", "b-c", "extends"],
])
})
it("materializes the first source occurrence once while retaining every document edge", () => {
const documents = [
makeDocument("doc-a", [

View file

@ -58,6 +58,7 @@ export class InputHandler {
private boundTouchStart: (e: TouchEvent) => void
private boundTouchMove: (e: TouchEvent) => void
private boundTouchEnd: (e: TouchEvent) => void
private boundTouchCancel: () => void
private boundGesture: (e: Event) => void
constructor(
@ -84,6 +85,7 @@ export class InputHandler {
this.boundTouchStart = this.onTouchStart.bind(this)
this.boundTouchMove = this.onTouchMove.bind(this)
this.boundTouchEnd = this.onTouchEnd.bind(this)
this.boundTouchCancel = this.onTouchCancel.bind(this)
this.boundGesture = (e: Event) => e.preventDefault()
canvas.addEventListener("mousedown", this.boundMouseDown)
@ -98,6 +100,7 @@ export class InputHandler {
passive: false,
})
canvas.addEventListener("touchend", this.boundTouchEnd)
canvas.addEventListener("touchcancel", this.boundTouchCancel)
canvas.addEventListener("gesturestart", this.boundGesture, {
passive: false,
})
@ -118,6 +121,7 @@ export class InputHandler {
c.removeEventListener("touchstart", this.boundTouchStart)
c.removeEventListener("touchmove", this.boundTouchMove)
c.removeEventListener("touchend", this.boundTouchEnd)
c.removeEventListener("touchcancel", this.boundTouchCancel)
c.removeEventListener("gesturestart", this.boundGesture)
c.removeEventListener("gesturechange", this.boundGesture)
c.removeEventListener("gestureend", this.boundGesture)
@ -188,9 +192,7 @@ export class InputHandler {
}
if (this.draggingNode) {
const world = this.viewport.screenToWorld(x, y)
this.draggingNode.x = this.draggingNode.fx = world.x - this.grabOffset.x
this.draggingNode.y = this.draggingNode.fy = world.y - this.grabOffset.y
this.moveDraggingNode(x, y)
this.didDrag = true
this.callbacks.onRequestRender()
return
@ -382,9 +384,7 @@ export class InputHandler {
this.callbacks.onNodeDragStart(this.draggingNode.id, this.draggingNode)
}
if (this.draggingNode) {
const world = this.viewport.screenToWorld(x, y)
this.draggingNode.x = this.draggingNode.fx = world.x - this.grabOffset.x
this.draggingNode.y = this.draggingNode.fy = world.y - this.grabOffset.y
this.moveDraggingNode(x, y)
this.callbacks.onRequestRender()
} else if (this.isPanning) {
this.viewport.pan(x - this.lastMouseX, y - this.lastMouseY)
@ -395,6 +395,26 @@ export class InputHandler {
}
}
private moveDraggingNode(x: number, y: number): void {
const node = this.draggingNode
if (!node) return
const world = this.viewport.screenToWorld(x, y)
const nextX = world.x - this.grabOffset.x
const nextY = world.y - this.grabOffset.y
if (node.x !== nextX || node.y !== nextY) {
this.viewport.nodePositionVersion++
}
node.x = node.fx = nextX
node.y = node.fy = nextY
}
private onTouchCancel(): void {
this.tapCandidate = false
this.isTouchGesture = false
this.isPanning = false
this.endTouchDrag()
}
private endTouchDrag(): void {
this.pressedNode = null
if (!this.draggingNode) return

View file

@ -1,9 +1,13 @@
// SSR-safe; false when matchMedia is unavailable.
export function prefersReducedMotion(): boolean {
if (typeof globalThis.matchMedia !== "function") return false
// SSR-safe; null when matchMedia is unavailable.
export function getReducedMotionQuery(): MediaQueryList | null {
if (typeof globalThis.matchMedia !== "function") return null
try {
return globalThis.matchMedia("(prefers-reduced-motion: reduce)").matches
return globalThis.matchMedia("(prefers-reduced-motion: reduce)")
} catch {
return false
return null
}
}
export function prefersReducedMotion(): boolean {
return getReducedMotionQuery()?.matches ?? false
}

View file

@ -1,18 +1,31 @@
import * as d3 from "d3-force"
import type { DocumentNodeData, GraphEdge, GraphNode } from "../types"
import { FORCE_CONFIG } from "../constants"
import { prefersReducedMotion } from "./reduced-motion"
import { getReducedMotionQuery, prefersReducedMotion } from "./reduced-motion"
export const DENSE_GRAPH_STATIC_THRESHOLD = 6000
export class ForceSimulation {
private sim: d3.Simulation<GraphNode, GraphEdge> | null = null
private holdingHeat = false
private reducedMotionQuery: MediaQueryList | null = null
private readonly onReducedMotionChange = (event: MediaQueryListEvent) => {
if (event.matches) this.stop()
}
init(nodes: GraphNode[], edges: GraphEdge[]): void {
this.destroy()
try {
this.reducedMotionQuery = getReducedMotionQuery()
if (this.reducedMotionQuery?.addEventListener) {
this.reducedMotionQuery.addEventListener(
"change",
this.onReducedMotionChange,
)
} else {
this.reducedMotionQuery?.addListener?.(this.onReducedMotionChange)
}
const structuralEdges = edges.filter((e) => e.edgeType !== "extends")
this.sim = d3
@ -148,6 +161,15 @@ export class ForceSimulation {
destroy(): void {
this.holdingHeat = false
if (this.reducedMotionQuery?.removeEventListener) {
this.reducedMotionQuery.removeEventListener(
"change",
this.onReducedMotionChange,
)
} else {
this.reducedMotionQuery?.removeListener?.(this.onReducedMotionChange)
}
this.reducedMotionQuery = null
if (this.sim) {
this.sim.stop()
this.sim = null

View file

@ -1,7 +1,10 @@
import { getReducedMotionQuery } from "./reduced-motion"
export class ViewportState {
panX: number
panY: number
zoom: number
nodePositionVersion = 0
private velocityX = 0
private velocityY = 0
@ -21,10 +24,7 @@ export class ViewportState {
private static readonly MAX_ZOOM = 5.0
private minZoom = ViewportState.DEFAULT_MIN_ZOOM
private readonly reducedMotionQuery: MediaQueryList | null =
typeof globalThis.matchMedia === "function"
? globalThis.matchMedia("(prefers-reduced-motion: reduce)")
: null
private readonly reducedMotionQuery = getReducedMotionQuery()
private get reducedMotion(): boolean {
return this.reducedMotionQuery?.matches ?? false
@ -145,6 +145,10 @@ export class ViewportState {
tick(): boolean {
const reduced = this.reducedMotion
let moving = false
if (reduced) {
this.velocityX = 0
this.velocityY = 0
}
if (Math.abs(this.velocityX) > 0.5 || Math.abs(this.velocityY) > 0.5) {
this.panX += this.velocityX

View file

@ -100,6 +100,7 @@ export function Minimap({
let lastPanX = Number.NaN
let lastPanY = Number.NaN
let lastZoom = Number.NaN
let lastNodePositionVersion = -1
let wasSimActive = true
const loop = () => {
const vp = viewportRef.current
@ -107,12 +108,15 @@ export function Minimap({
const vpChanged =
!!vp &&
(vp.panX !== lastPanX || vp.panY !== lastPanY || vp.zoom !== lastZoom)
if (vpChanged || simActive || wasSimActive) {
const nodesMoved =
!!vp && vp.nodePositionVersion !== lastNodePositionVersion
if (vpChanged || nodesMoved || simActive || wasSimActive) {
draw()
if (vp) {
lastPanX = vp.panX
lastPanY = vp.panY
lastZoom = vp.zoom
lastNodePositionVersion = vp.nodePositionVersion
}
}
wasSimActive = simActive

View file

@ -411,14 +411,18 @@ export function computeEdges(documents: GraphApiDocument[]): GraphEdge[] {
for (const mem of doc.memories) allNodeIds.add(mem.id)
}
const documentEdgeIds = new Set<string>()
const documentTargets = new Map<string, Set<string>>()
for (const doc of documents) {
let targets = documentTargets.get(doc.id)
for (const mem of doc.memories) {
const edgeId = `dm-${doc.id}-${mem.id}`
if (documentEdgeIds.has(edgeId)) continue
documentEdgeIds.add(edgeId)
if (targets?.has(mem.id)) continue
if (!targets) {
targets = new Set<string>()
documentTargets.set(doc.id, targets)
}
targets.add(mem.id)
result.push({
id: edgeId,
id: `dm-${doc.id}-${mem.id}`,
source: doc.id,
target: mem.id,
visualProps: getEdgeVisualProps("document"),
@ -427,19 +431,20 @@ export function computeEdges(documents: GraphApiDocument[]): GraphEdge[] {
}
}
// 2. Memory-to-memory relation edges from backend data.
// Uses memoryRelations (Record<targetId, relationType>) as primary source,
// falls back to parentMemoryId for legacy data.
const relationEdgeIds = new Set<string>()
const relationSources = new Map<string, Set<string>>()
for (const doc of documents) {
for (const mem of doc.memories) {
const relations = getMemoryRelationTargets(mem)
let sources = relationSources.get(mem.id)
for (const [targetId, relationType] of Object.entries(relations)) {
if (!allNodeIds.has(targetId)) continue
const edgeId = `rel-${targetId}-${mem.id}`
if (relationEdgeIds.has(edgeId)) continue
relationEdgeIds.add(edgeId)
if (sources?.has(targetId)) continue
if (!sources) {
sources = new Set<string>()
relationSources.set(mem.id, sources)
}
sources.add(targetId)
const edgeType =
relationType === "updates" ||
relationType === "extends" ||
@ -447,7 +452,7 @@ export function computeEdges(documents: GraphApiDocument[]): GraphEdge[] {
? relationType
: "updates"
result.push({
id: edgeId,
id: `rel-${targetId}-${mem.id}`,
source: targetId,
target: mem.id,
visualProps: getEdgeVisualProps(edgeType),