refactor: deta histroy

This commit is contained in:
2026-03-12 18:08:54 +01:00
parent 923bf4bff0
commit 51c96641f0
2 changed files with 249 additions and 39 deletions

View File

@@ -1,6 +1,6 @@
/**
* Hook for canvas graph state and persistence. Wraps useGraphStateWithHistory
* with initial graph from project storage (or example) and debounced save.
* with initial graph from project storage (or example) and debounced + idle-based save.
* Keeps CanvasPage focused on composition and layout.
*/
@@ -12,20 +12,65 @@ import type { AppNode, AppEdge } from '@/lib/graph/nodeTypes'
export type UseCanvasGraphResult = ReturnType<typeof useGraphStateWithHistory>
/** Debounce delay (ms) before we schedule a save. */
const SAVE_DEBOUNCE_MS = 800
/** Max wait (ms) for requestIdleCallback before falling back to setTimeout. */
const SAVE_IDLE_TIMEOUT_MS = 2000
export function useCanvasGraph(projectId: string | undefined): UseCanvasGraphResult {
const initialGraph = useMemo(() => getInitialGraph(projectId), [projectId])
const result = useGraphStateWithHistory(initialGraph.nodes, initialGraph.edges)
const { nodes, edges } = result
const saveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const idleCallbackRef = useRef<number | null>(null)
const pendingSaveRef = useRef<{ projectId: string; nodes: AppNode[]; edges: AppEdge[] } | null>(
null
)
useEffect(() => {
if (!projectId) return
const save = () => {
saveGraphToStorage(projectId, { version: PROJECT_VERSION, nodes, edges })
const scheduleSave = () => {
pendingSaveRef.current = { projectId, nodes, edges }
const doSave = () => {
const pending = pendingSaveRef.current
pendingSaveRef.current = null
if (pending && pending.projectId === projectId) {
saveGraphToStorage(pending.projectId, {
version: PROJECT_VERSION,
nodes: pending.nodes,
edges: pending.edges,
})
}
}
if (typeof requestIdleCallback !== 'undefined') {
idleCallbackRef.current = requestIdleCallback(doSave, {
timeout: SAVE_IDLE_TIMEOUT_MS,
})
} else {
idleCallbackRef.current = window.setTimeout(doSave, 0) as unknown as number
}
}
saveTimeoutRef.current = setTimeout(save, 500)
if (saveTimeoutRef.current) clearTimeout(saveTimeoutRef.current)
saveTimeoutRef.current = setTimeout(scheduleSave, SAVE_DEBOUNCE_MS)
return () => {
if (saveTimeoutRef.current) clearTimeout(saveTimeoutRef.current)
if (saveTimeoutRef.current) {
clearTimeout(saveTimeoutRef.current)
saveTimeoutRef.current = null
}
if (idleCallbackRef.current != null) {
if (typeof cancelIdleCallback !== 'undefined') {
cancelIdleCallback(idleCallbackRef.current)
} else {
clearTimeout(idleCallbackRef.current)
}
idleCallbackRef.current = null
}
}
}, [projectId, nodes, edges])

View File

@@ -2,19 +2,158 @@ import { useCallback, useRef, useState } from 'react'
import type { AppNode, AppEdge } from '@/lib/graph/nodeTypes'
/**
* In-memory graph state. History is a past/future stack of GraphState snapshots.
* setNodes/setEdges push current state to past; setNodesSilent/setEdgesSilent update without history.
* setStateImmediate replaces state and clears history (e.g. load example, import).
* In-memory graph state. History is a past/future stack of inverse deltas (patches).
* setNodes/setEdges push an inverse delta to past so undo restores only what changed.
* setNodesSilent/setEdgesSilent update without history. setStateImmediate clears history.
*/
export type GraphState = { nodes: AppNode[]; edges: AppEdge[] }
/** Clone a single node (shallow clone with one-level data copy). */
function cloneNode(n: AppNode): AppNode {
return {
...n,
data:
n.data && typeof n.data === 'object'
? { ...(n.data as Record<string, unknown>) }
: n.data,
}
}
/** Clone a single edge. */
function cloneEdge(e: AppEdge): AppEdge {
return { ...e }
}
/** Full state clone (used for drag snapshot and when delta would be larger than full state). */
function cloneState(state: GraphState): GraphState {
return {
nodes: state.nodes.map((n) => ({ ...n, data: n.data && typeof n.data === 'object' ? { ...n.data } : n.data })),
edges: state.edges.map((e) => ({ ...e })),
nodes: state.nodes.map(cloneNode),
edges: state.edges.map(cloneEdge),
}
}
/**
* Inverse delta: what to restore on undo (from next back to prev).
* We only store nodes/edges that were removed or modified, not the full graph.
*/
export type HistoryDelta = {
addedNodeIds: string[]
restoredNodes: AppNode[]
addedEdgeIds: string[]
restoredEdges: AppEdge[]
}
function nodeEquals(a: AppNode, b: AppNode): boolean {
if (a.id !== b.id) return false
if (a.type !== b.type) return false
if (a.position?.x !== b.position?.x || a.position?.y !== b.position?.y) return false
if (a.data !== b.data) return false
if (JSON.stringify(a.style) !== JSON.stringify(b.style)) return false
return true
}
function edgeEquals(a: AppEdge, b: AppEdge): boolean {
return a.id === b.id && a.source === b.source && a.target === b.target
}
/**
* Compute inverse delta from prev to next: what to store so that
* applyInverseDelta(next, delta) restores prev. Restored nodes/edges are cloned.
*/
function computeInverseDelta(prev: GraphState, next: GraphState): HistoryDelta {
const prevNodeIds = new Set(prev.nodes.map((n) => n.id))
const nextNodeIds = new Set(next.nodes.map((n) => n.id))
const nextNodesById = new Map(next.nodes.map((n) => [n.id, n]))
const addedNodeIds: string[] = []
const restoredNodes: AppNode[] = []
for (const id of nextNodeIds) {
if (!prevNodeIds.has(id)) addedNodeIds.push(id)
}
for (const pNode of prev.nodes) {
const nNode = nextNodesById.get(pNode.id)
if (!nNode) {
restoredNodes.push(cloneNode(pNode))
} else if (!nodeEquals(pNode, nNode)) {
restoredNodes.push(cloneNode(pNode))
}
}
const prevEdgeIds = new Set(prev.edges.map((e) => e.id))
const nextEdgeIds = new Set(next.edges.map((e) => e.id))
const nextEdgesById = new Map(next.edges.map((e) => [e.id, e]))
const addedEdgeIds: string[] = []
const restoredEdges: AppEdge[] = []
for (const id of nextEdgeIds) {
if (!prevEdgeIds.has(id)) addedEdgeIds.push(id)
}
for (const pEdge of prev.edges) {
const nEdge = nextEdgesById.get(pEdge.id)
if (!nEdge) {
restoredEdges.push(cloneEdge(pEdge))
} else if (!edgeEquals(pEdge, nEdge)) {
restoredEdges.push(cloneEdge(pEdge))
}
}
return {
addedNodeIds,
restoredNodes,
addedEdgeIds,
restoredEdges,
}
}
/**
* Apply an inverse delta to current state to get the previous state (undo).
*/
function applyInverseDelta(current: GraphState, delta: HistoryDelta): GraphState {
const addedNodeIdsSet = new Set(delta.addedNodeIds)
const restoredNodesById = new Map(delta.restoredNodes.map((n) => [n.id, n]))
const baseNodes = current.nodes.filter((n) => !addedNodeIdsSet.has(n.id))
const nodeOrder = baseNodes.map((n) => n.id)
const byId = new Map(baseNodes.map((n) => [n.id, n]))
for (const r of delta.restoredNodes) {
byId.set(r.id, r)
}
const nodes: AppNode[] = nodeOrder
.map((id) => byId.get(id))
.filter((n): n is AppNode => n != null)
const restoredIdsInBase = new Set(nodes.map((n) => n.id))
for (const r of delta.restoredNodes) {
if (!restoredIdsInBase.has(r.id)) {
nodes.push(r)
restoredIdsInBase.add(r.id)
}
}
const addedEdgeIdsSet = new Set(delta.addedEdgeIds)
const restoredEdgesById = new Map(delta.restoredEdges.map((e) => [e.id, e]))
const baseEdges = current.edges.filter((e) => !addedEdgeIdsSet.has(e.id))
const edgeOrder = baseEdges.map((e) => e.id)
const edgesById = new Map(baseEdges.map((e) => [e.id, e]))
for (const r of delta.restoredEdges) {
edgesById.set(r.id, r)
}
const edges: AppEdge[] = edgeOrder
.map((id) => edgesById.get(id))
.filter((e): e is AppEdge => e != null)
const restoredEdgeIdsInBase = new Set(edges.map((e) => e.id))
for (const r of delta.restoredEdges) {
if (!restoredEdgeIdsInBase.has(r.id)) {
edges.push(r)
restoredEdgeIdsInBase.add(r.id)
}
}
return { nodes, edges }
}
const MAX_HISTORY = 100
export function useGraphStateWithHistory(initialNodes: AppNode[], initialEdges: AppEdge[]) {
@@ -22,30 +161,44 @@ export function useGraphStateWithHistory(initialNodes: AppNode[], initialEdges:
const [edges, setEdgesState] = useState<AppEdge[]>(initialEdges)
const [historySizes, setHistorySizes] = useState({ past: 0, future: 0 })
const pastRef = useRef<GraphState[]>([])
const futureRef = useRef<GraphState[]>([])
const pastRef = useRef<HistoryDelta[]>([])
const futureRef = useRef<HistoryDelta[]>([])
const preDragRef = useRef<GraphState | null>(null)
const nodesRef = useRef(nodes)
const edgesRef = useRef(edges)
nodesRef.current = nodes
edgesRef.current = edges
const pushToPast = useCallback((state: GraphState) => {
const pushDeltaToPast = useCallback((delta: HistoryDelta) => {
pastRef.current = pastRef.current.slice(-(MAX_HISTORY - 1))
pastRef.current.push(cloneState(state))
pastRef.current.push(delta)
futureRef.current = []
setHistorySizes({ past: pastRef.current.length, future: 0 })
}, [])
const setNodes = useCallback((updater: AppNode[] | ((prev: AppNode[]) => AppNode[])) => {
pushToPast({ nodes: nodesRef.current, edges: edgesRef.current })
setNodesState(typeof updater === 'function' ? updater : () => updater)
}, [pushToPast])
const setNodes = useCallback(
(updater: AppNode[] | ((prev: AppNode[]) => AppNode[])) => {
const prev = { nodes: nodesRef.current, edges: edgesRef.current }
const nextNodes = typeof updater === 'function' ? updater(prev.nodes) : updater
const next = { nodes: nextNodes, edges: prev.edges }
const delta = computeInverseDelta(prev, next)
pushDeltaToPast(delta)
setNodesState(nextNodes)
},
[pushDeltaToPast]
)
const setEdges = useCallback((updater: AppEdge[] | ((prev: AppEdge[]) => AppEdge[])) => {
pushToPast({ nodes: nodesRef.current, edges: edgesRef.current })
setEdgesState(typeof updater === 'function' ? updater : () => updater)
}, [pushToPast])
const setEdges = useCallback(
(updater: AppEdge[] | ((prev: AppEdge[]) => AppEdge[])) => {
const prev = { nodes: nodesRef.current, edges: edgesRef.current }
const nextEdges = typeof updater === 'function' ? updater(prev.edges) : updater
const next = { nodes: prev.nodes, edges: nextEdges }
const delta = computeInverseDelta(prev, next)
pushDeltaToPast(delta)
setEdgesState(nextEdges)
},
[pushDeltaToPast]
)
const setNodesSilent = useCallback((updater: AppNode[] | ((prev: AppNode[]) => AppNode[])) => {
setNodesState(typeof updater === 'function' ? updater : () => updater)
@@ -55,12 +208,17 @@ export function useGraphStateWithHistory(initialNodes: AppNode[], initialEdges:
setEdgesState(typeof updater === 'function' ? updater : () => updater)
}, [])
const applyGraph = useCallback((updater: (state: GraphState) => GraphState) => {
pushToPast({ nodes: nodesRef.current, edges: edgesRef.current })
const next = updater({ nodes: nodesRef.current, edges: edgesRef.current })
setNodesState(next.nodes)
setEdgesState(next.edges)
}, [pushToPast])
const applyGraph = useCallback(
(updater: (state: GraphState) => GraphState) => {
const prev = { nodes: nodesRef.current, edges: edgesRef.current }
const next = updater(prev)
const delta = computeInverseDelta(prev, next)
pushDeltaToPast(delta)
setNodesState(next.nodes)
setEdgesState(next.edges)
},
[pushDeltaToPast]
)
const saveForDragEnd = useCallback(() => {
preDragRef.current = cloneState({ nodes: nodesRef.current, edges: edgesRef.current })
@@ -68,29 +226,36 @@ export function useGraphStateWithHistory(initialNodes: AppNode[], initialEdges:
const commitDragEnd = useCallback(() => {
if (preDragRef.current) {
pastRef.current = pastRef.current.slice(-(MAX_HISTORY - 1))
pastRef.current.push(preDragRef.current)
futureRef.current = []
const snapshot = preDragRef.current
preDragRef.current = null
const current = { nodes: nodesRef.current, edges: edgesRef.current }
const delta = computeInverseDelta(snapshot, current)
pastRef.current = pastRef.current.slice(-(MAX_HISTORY - 1))
pastRef.current.push(delta)
futureRef.current = []
setHistorySizes({ past: pastRef.current.length, future: 0 })
}
}, [])
const undo = useCallback(() => {
if (pastRef.current.length === 0) return
const prev = pastRef.current.pop()!
futureRef.current.push(cloneState({ nodes: nodesRef.current, edges: edgesRef.current }))
setNodesState(prev.nodes)
setEdgesState(prev.edges)
const delta = pastRef.current.pop()!
const current = { nodes: nodesRef.current, edges: edgesRef.current }
const prevState = applyInverseDelta(current, delta)
futureRef.current = futureRef.current.slice(-(MAX_HISTORY - 1))
futureRef.current.push(cloneState(current))
setNodesState(prevState.nodes)
setEdgesState(prevState.edges)
setHistorySizes({ past: pastRef.current.length, future: futureRef.current.length })
}, [])
const redo = useCallback(() => {
if (futureRef.current.length === 0) return
const next = futureRef.current.pop()!
pastRef.current.push(cloneState({ nodes: nodesRef.current, edges: edgesRef.current }))
setNodesState(next.nodes)
setEdgesState(next.edges)
const nextState = futureRef.current.pop()!
const current = { nodes: nodesRef.current, edges: edgesRef.current }
pastRef.current.push(computeInverseDelta(nextState, current))
setNodesState(nextState.nodes)
setEdgesState(nextState.edges)
setHistorySizes({ past: pastRef.current.length, future: futureRef.current.length })
}, [])