refactor: deta histroy
This commit is contained in:
@@ -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])
|
||||
|
||||
|
||||
@@ -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 })
|
||||
}, [])
|
||||
|
||||
|
||||
Reference in New Issue
Block a user