mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-10-27 06:15:15 +00:00
feat(memory-graph): respect prefers-reduced-motion (#1576)
This commit is contained in:
parent
fa4ab9805a
commit
cd6ea514d1
4 changed files with 179 additions and 4 deletions
140
packages/memory-graph/src/__tests__/reduced-motion.test.ts
Normal file
140
packages/memory-graph/src/__tests__/reduced-motion.test.ts
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
import { afterEach, describe, expect, it, vi } from "vitest"
|
||||
import { prefersReducedMotion } from "../canvas/reduced-motion"
|
||||
import { ForceSimulation } from "../canvas/simulation"
|
||||
import { ViewportState } from "../canvas/viewport"
|
||||
import type { GraphEdge, GraphNode } from "../types"
|
||||
|
||||
function stubReducedMotion(matches: boolean) {
|
||||
vi.stubGlobal("matchMedia", (query: string) => ({
|
||||
matches: query.includes("reduce") ? matches : false,
|
||||
media: query,
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
addListener: () => {},
|
||||
removeListener: () => {},
|
||||
onchange: null,
|
||||
dispatchEvent: () => false,
|
||||
}))
|
||||
}
|
||||
|
||||
afterEach(() => vi.unstubAllGlobals())
|
||||
|
||||
function makeNode(id: string, x: number, y: number): GraphNode {
|
||||
return {
|
||||
id,
|
||||
type: "document",
|
||||
x,
|
||||
y,
|
||||
size: 50,
|
||||
borderColor: "#fff",
|
||||
isHovered: false,
|
||||
isDragging: false,
|
||||
data: {
|
||||
id,
|
||||
title: id,
|
||||
summary: null,
|
||||
type: "text",
|
||||
createdAt: "2024-01-01",
|
||||
updatedAt: "2024-01-01",
|
||||
memories: [],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const nodes: GraphNode[] = [
|
||||
makeNode("a", 0, 0),
|
||||
makeNode("b", 100, 0),
|
||||
makeNode("c", 0, 100),
|
||||
]
|
||||
const edges: GraphEdge[] = [
|
||||
{
|
||||
id: "a-b",
|
||||
source: "a",
|
||||
target: "b",
|
||||
edgeType: "derives",
|
||||
visualProps: { opacity: 1, thickness: 1 },
|
||||
},
|
||||
]
|
||||
|
||||
describe("prefersReducedMotion", () => {
|
||||
it("returns false when matchMedia is unavailable", () => {
|
||||
vi.stubGlobal("matchMedia", undefined)
|
||||
expect(prefersReducedMotion()).toBe(false)
|
||||
})
|
||||
|
||||
it("reflects the matchMedia result", () => {
|
||||
stubReducedMotion(true)
|
||||
expect(prefersReducedMotion()).toBe(true)
|
||||
stubReducedMotion(false)
|
||||
expect(prefersReducedMotion()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("ForceSimulation reduced-motion", () => {
|
||||
it("leaves the layout static after init and ignores reheat", () => {
|
||||
stubReducedMotion(true)
|
||||
const sim = new ForceSimulation()
|
||||
sim.init(nodes, edges)
|
||||
expect(sim.isActive()).toBe(false)
|
||||
sim.reheat()
|
||||
expect(sim.isActive()).toBe(false)
|
||||
sim.settle()
|
||||
expect(sim.isActive()).toBe(false)
|
||||
sim.destroy()
|
||||
})
|
||||
|
||||
it("keeps the simulation running after init when motion is allowed", () => {
|
||||
stubReducedMotion(false)
|
||||
const sim = new ForceSimulation()
|
||||
sim.init(nodes, edges)
|
||||
expect(sim.isActive()).toBe(true)
|
||||
sim.destroy()
|
||||
})
|
||||
})
|
||||
|
||||
describe("ViewportState reduced-motion", () => {
|
||||
it("drops fling momentum under reduced motion", () => {
|
||||
stubReducedMotion(true)
|
||||
const vp = new ViewportState(0, 0, 1)
|
||||
vp.releaseWithVelocity(50, 50)
|
||||
vp.tick()
|
||||
expect(vp.panX).toBe(0)
|
||||
expect(vp.panY).toBe(0)
|
||||
})
|
||||
|
||||
it("keeps fling momentum when motion is allowed", () => {
|
||||
stubReducedMotion(false)
|
||||
const vp = new ViewportState(0, 0, 1)
|
||||
vp.releaseWithVelocity(50, 50)
|
||||
vp.tick()
|
||||
expect(vp.panX).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it("snaps zoom to target in a single tick under reduced motion", () => {
|
||||
stubReducedMotion(true)
|
||||
const vp = new ViewportState(0, 0, 1)
|
||||
vp.zoomTo(3, 100, 100)
|
||||
vp.tick()
|
||||
expect(vp.zoom).toBe(3)
|
||||
})
|
||||
|
||||
it("eases zoom across ticks when motion is allowed", () => {
|
||||
stubReducedMotion(false)
|
||||
const vp = new ViewportState(0, 0, 1)
|
||||
vp.zoomTo(3, 100, 100)
|
||||
vp.tick()
|
||||
expect(vp.zoom).toBeGreaterThan(1)
|
||||
expect(vp.zoom).toBeLessThan(3)
|
||||
})
|
||||
|
||||
it("snaps a pan target instantly under reduced motion", () => {
|
||||
stubReducedMotion(true)
|
||||
const vp = new ViewportState(0, 0, 1)
|
||||
vp.centerOn(500, 500, 800, 600)
|
||||
const moved = vp.tick()
|
||||
expect(moved).toBe(true)
|
||||
// target = width/2 - worldX*zoom = 400 - 500 = -100, etc.
|
||||
expect(vp.panX).toBe(-100)
|
||||
expect(vp.panY).toBe(-200)
|
||||
})
|
||||
})
|
||||
9
packages/memory-graph/src/canvas/reduced-motion.ts
Normal file
9
packages/memory-graph/src/canvas/reduced-motion.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
// SSR-safe; false when matchMedia is unavailable.
|
||||
export function prefersReducedMotion(): boolean {
|
||||
if (typeof globalThis.matchMedia !== "function") return false
|
||||
try {
|
||||
return globalThis.matchMedia("(prefers-reduced-motion: reduce)").matches
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import * as d3 from "d3-force"
|
||||
import type { DocumentNodeData, GraphEdge, GraphNode } from "../types"
|
||||
import { FORCE_CONFIG } from "../constants"
|
||||
import { prefersReducedMotion } from "./reduced-motion"
|
||||
|
||||
export const DENSE_GRAPH_STATIC_THRESHOLD = 6000
|
||||
|
||||
|
|
@ -67,7 +68,11 @@ export class ForceSimulation {
|
|||
: FORCE_CONFIG.preSettleTicks
|
||||
for (let i = 0; i < preSettleTicks; i++) this.sim.tick()
|
||||
|
||||
if (nodes.length > DENSE_GRAPH_STATIC_THRESHOLD) {
|
||||
// Dense graphs stay static for perf; reduced-motion reuses that path.
|
||||
if (
|
||||
nodes.length > DENSE_GRAPH_STATIC_THRESHOLD ||
|
||||
prefersReducedMotion()
|
||||
) {
|
||||
this.stop()
|
||||
} else {
|
||||
this.settle()
|
||||
|
|
@ -87,6 +92,8 @@ export class ForceSimulation {
|
|||
}
|
||||
|
||||
reheat(): void {
|
||||
// Dragged node still moves via InputHandler; neighbours stay put.
|
||||
if (prefersReducedMotion()) return
|
||||
this.holdingHeat = true
|
||||
this.sim?.on("tick.settle", null)
|
||||
this.sim?.alphaTarget(FORCE_CONFIG.alphaTarget).restart()
|
||||
|
|
@ -94,7 +101,7 @@ export class ForceSimulation {
|
|||
|
||||
settle(): void {
|
||||
const sim = this.sim
|
||||
if (!sim || this.holdingHeat) return
|
||||
if (!sim || this.holdingHeat || prefersReducedMotion()) return
|
||||
let ticks = 0
|
||||
let stableTicks = 0
|
||||
sim
|
||||
|
|
|
|||
|
|
@ -21,6 +21,16 @@ export class ViewportState {
|
|||
private static readonly MAX_ZOOM = 5.0
|
||||
private minZoom = ViewportState.DEFAULT_MIN_ZOOM
|
||||
|
||||
// Cached query; `.matches` still tracks live OS changes.
|
||||
private readonly reducedMotionQuery: MediaQueryList | null =
|
||||
typeof globalThis.matchMedia === "function"
|
||||
? globalThis.matchMedia("(prefers-reduced-motion: reduce)")
|
||||
: null
|
||||
|
||||
private get reducedMotion(): boolean {
|
||||
return this.reducedMotionQuery?.matches ?? false
|
||||
}
|
||||
|
||||
constructor(initialPanX = 0, initialPanY = 0, initialZoom = 0.5) {
|
||||
this.panX = initialPanX
|
||||
this.panY = initialPanY
|
||||
|
|
@ -59,6 +69,7 @@ export class ViewportState {
|
|||
}
|
||||
|
||||
releaseWithVelocity(vx: number, vy: number): void {
|
||||
if (this.reducedMotion) return
|
||||
this.velocityX = vx
|
||||
this.velocityY = vy
|
||||
}
|
||||
|
|
@ -133,6 +144,7 @@ export class ViewportState {
|
|||
}
|
||||
|
||||
tick(): boolean {
|
||||
const reduced = this.reducedMotion
|
||||
let moving = false
|
||||
|
||||
if (Math.abs(this.velocityX) > 0.5 || Math.abs(this.velocityY) > 0.5) {
|
||||
|
|
@ -149,7 +161,7 @@ export class ViewportState {
|
|||
const zoomDiff = this.targetZoom - this.zoom
|
||||
if (Math.abs(zoomDiff) > this.zoom * 0.001) {
|
||||
const world = this.screenToWorld(this.zoomAnchorX, this.zoomAnchorY)
|
||||
this.zoom += zoomDiff * this.zoomSpring
|
||||
this.zoom += reduced ? zoomDiff : zoomDiff * this.zoomSpring
|
||||
this.panX = this.zoomAnchorX - world.x * this.zoom
|
||||
this.panY = this.zoomAnchorY - world.y * this.zoom
|
||||
moving = true
|
||||
|
|
@ -158,11 +170,18 @@ export class ViewportState {
|
|||
if (this.targetPanX !== null && this.targetPanY !== null) {
|
||||
const dx = this.targetPanX - this.panX
|
||||
const dy = this.targetPanY - this.panY
|
||||
if (Math.abs(dx) > 0.5 || Math.abs(dy) > 0.5) {
|
||||
if (!reduced && (Math.abs(dx) > 0.5 || Math.abs(dy) > 0.5)) {
|
||||
this.panX += dx * this.panLerp
|
||||
this.panY += dy * this.panLerp
|
||||
moving = true
|
||||
} else {
|
||||
// Reduced-motion always snaps here, so report the jump as movement.
|
||||
if (
|
||||
reduced &&
|
||||
(this.panX !== this.targetPanX || this.panY !== this.targetPanY)
|
||||
) {
|
||||
moving = true
|
||||
}
|
||||
this.panX = this.targetPanX
|
||||
this.panY = this.targetPanY
|
||||
this.targetPanX = null
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue