refactor: deta histroy
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* Hook for canvas graph state and persistence. Wraps useGraphStateWithHistory
|
* 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.
|
* 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>
|
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 {
|
export function useCanvasGraph(projectId: string | undefined): UseCanvasGraphResult {
|
||||||
const initialGraph = useMemo(() => getInitialGraph(projectId), [projectId])
|
const initialGraph = useMemo(() => getInitialGraph(projectId), [projectId])
|
||||||
const result = useGraphStateWithHistory(initialGraph.nodes, initialGraph.edges)
|
const result = useGraphStateWithHistory(initialGraph.nodes, initialGraph.edges)
|
||||||
const { nodes, edges } = result
|
const { nodes, edges } = result
|
||||||
|
|
||||||
const saveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
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(() => {
|
useEffect(() => {
|
||||||
if (!projectId) return
|
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 () => {
|
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])
|
}, [projectId, nodes, edges])
|
||||||
|
|
||||||
|
|||||||
@@ -2,19 +2,158 @@ import { useCallback, useRef, useState } from 'react'
|
|||||||
import type { AppNode, AppEdge } from '@/lib/graph/nodeTypes'
|
import type { AppNode, AppEdge } from '@/lib/graph/nodeTypes'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* In-memory graph state. History is a past/future stack of GraphState snapshots.
|
* In-memory graph state. History is a past/future stack of inverse deltas (patches).
|
||||||
* setNodes/setEdges push current state to past; setNodesSilent/setEdgesSilent update without history.
|
* setNodes/setEdges push an inverse delta to past so undo restores only what changed.
|
||||||
* setStateImmediate replaces state and clears history (e.g. load example, import).
|
* setNodesSilent/setEdgesSilent update without history. setStateImmediate clears history.
|
||||||
*/
|
*/
|
||||||
export type GraphState = { nodes: AppNode[]; edges: AppEdge[] }
|
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 {
|
function cloneState(state: GraphState): GraphState {
|
||||||
return {
|
return {
|
||||||
nodes: state.nodes.map((n) => ({ ...n, data: n.data && typeof n.data === 'object' ? { ...n.data } : n.data })),
|
nodes: state.nodes.map(cloneNode),
|
||||||
edges: state.edges.map((e) => ({ ...e })),
|
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
|
const MAX_HISTORY = 100
|
||||||
|
|
||||||
export function useGraphStateWithHistory(initialNodes: AppNode[], initialEdges: AppEdge[]) {
|
export function useGraphStateWithHistory(initialNodes: AppNode[], initialEdges: AppEdge[]) {
|
||||||
@@ -22,30 +161,44 @@ export function useGraphStateWithHistory(initialNodes: AppNode[], initialEdges:
|
|||||||
const [edges, setEdgesState] = useState<AppEdge[]>(initialEdges)
|
const [edges, setEdgesState] = useState<AppEdge[]>(initialEdges)
|
||||||
const [historySizes, setHistorySizes] = useState({ past: 0, future: 0 })
|
const [historySizes, setHistorySizes] = useState({ past: 0, future: 0 })
|
||||||
|
|
||||||
const pastRef = useRef<GraphState[]>([])
|
const pastRef = useRef<HistoryDelta[]>([])
|
||||||
const futureRef = useRef<GraphState[]>([])
|
const futureRef = useRef<HistoryDelta[]>([])
|
||||||
const preDragRef = useRef<GraphState | null>(null)
|
const preDragRef = useRef<GraphState | null>(null)
|
||||||
const nodesRef = useRef(nodes)
|
const nodesRef = useRef(nodes)
|
||||||
const edgesRef = useRef(edges)
|
const edgesRef = useRef(edges)
|
||||||
nodesRef.current = nodes
|
nodesRef.current = nodes
|
||||||
edgesRef.current = edges
|
edgesRef.current = edges
|
||||||
|
|
||||||
const pushToPast = useCallback((state: GraphState) => {
|
const pushDeltaToPast = useCallback((delta: HistoryDelta) => {
|
||||||
pastRef.current = pastRef.current.slice(-(MAX_HISTORY - 1))
|
pastRef.current = pastRef.current.slice(-(MAX_HISTORY - 1))
|
||||||
pastRef.current.push(cloneState(state))
|
pastRef.current.push(delta)
|
||||||
futureRef.current = []
|
futureRef.current = []
|
||||||
setHistorySizes({ past: pastRef.current.length, future: 0 })
|
setHistorySizes({ past: pastRef.current.length, future: 0 })
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const setNodes = useCallback((updater: AppNode[] | ((prev: AppNode[]) => AppNode[])) => {
|
const setNodes = useCallback(
|
||||||
pushToPast({ nodes: nodesRef.current, edges: edgesRef.current })
|
(updater: AppNode[] | ((prev: AppNode[]) => AppNode[])) => {
|
||||||
setNodesState(typeof updater === 'function' ? updater : () => updater)
|
const prev = { nodes: nodesRef.current, edges: edgesRef.current }
|
||||||
}, [pushToPast])
|
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[])) => {
|
const setEdges = useCallback(
|
||||||
pushToPast({ nodes: nodesRef.current, edges: edgesRef.current })
|
(updater: AppEdge[] | ((prev: AppEdge[]) => AppEdge[])) => {
|
||||||
setEdgesState(typeof updater === 'function' ? updater : () => updater)
|
const prev = { nodes: nodesRef.current, edges: edgesRef.current }
|
||||||
}, [pushToPast])
|
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[])) => {
|
const setNodesSilent = useCallback((updater: AppNode[] | ((prev: AppNode[]) => AppNode[])) => {
|
||||||
setNodesState(typeof updater === 'function' ? updater : () => updater)
|
setNodesState(typeof updater === 'function' ? updater : () => updater)
|
||||||
@@ -55,12 +208,17 @@ export function useGraphStateWithHistory(initialNodes: AppNode[], initialEdges:
|
|||||||
setEdgesState(typeof updater === 'function' ? updater : () => updater)
|
setEdgesState(typeof updater === 'function' ? updater : () => updater)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const applyGraph = useCallback((updater: (state: GraphState) => GraphState) => {
|
const applyGraph = useCallback(
|
||||||
pushToPast({ nodes: nodesRef.current, edges: edgesRef.current })
|
(updater: (state: GraphState) => GraphState) => {
|
||||||
const next = updater({ nodes: nodesRef.current, edges: edgesRef.current })
|
const prev = { nodes: nodesRef.current, edges: edgesRef.current }
|
||||||
setNodesState(next.nodes)
|
const next = updater(prev)
|
||||||
setEdgesState(next.edges)
|
const delta = computeInverseDelta(prev, next)
|
||||||
}, [pushToPast])
|
pushDeltaToPast(delta)
|
||||||
|
setNodesState(next.nodes)
|
||||||
|
setEdgesState(next.edges)
|
||||||
|
},
|
||||||
|
[pushDeltaToPast]
|
||||||
|
)
|
||||||
|
|
||||||
const saveForDragEnd = useCallback(() => {
|
const saveForDragEnd = useCallback(() => {
|
||||||
preDragRef.current = cloneState({ nodes: nodesRef.current, edges: edgesRef.current })
|
preDragRef.current = cloneState({ nodes: nodesRef.current, edges: edgesRef.current })
|
||||||
@@ -68,29 +226,36 @@ export function useGraphStateWithHistory(initialNodes: AppNode[], initialEdges:
|
|||||||
|
|
||||||
const commitDragEnd = useCallback(() => {
|
const commitDragEnd = useCallback(() => {
|
||||||
if (preDragRef.current) {
|
if (preDragRef.current) {
|
||||||
pastRef.current = pastRef.current.slice(-(MAX_HISTORY - 1))
|
const snapshot = preDragRef.current
|
||||||
pastRef.current.push(preDragRef.current)
|
|
||||||
futureRef.current = []
|
|
||||||
preDragRef.current = null
|
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 })
|
setHistorySizes({ past: pastRef.current.length, future: 0 })
|
||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const undo = useCallback(() => {
|
const undo = useCallback(() => {
|
||||||
if (pastRef.current.length === 0) return
|
if (pastRef.current.length === 0) return
|
||||||
const prev = pastRef.current.pop()!
|
const delta = pastRef.current.pop()!
|
||||||
futureRef.current.push(cloneState({ nodes: nodesRef.current, edges: edgesRef.current }))
|
const current = { nodes: nodesRef.current, edges: edgesRef.current }
|
||||||
setNodesState(prev.nodes)
|
const prevState = applyInverseDelta(current, delta)
|
||||||
setEdgesState(prev.edges)
|
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 })
|
setHistorySizes({ past: pastRef.current.length, future: futureRef.current.length })
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const redo = useCallback(() => {
|
const redo = useCallback(() => {
|
||||||
if (futureRef.current.length === 0) return
|
if (futureRef.current.length === 0) return
|
||||||
const next = futureRef.current.pop()!
|
const nextState = futureRef.current.pop()!
|
||||||
pastRef.current.push(cloneState({ nodes: nodesRef.current, edges: edgesRef.current }))
|
const current = { nodes: nodesRef.current, edges: edgesRef.current }
|
||||||
setNodesState(next.nodes)
|
pastRef.current.push(computeInverseDelta(nextState, current))
|
||||||
setEdgesState(next.edges)
|
setNodesState(nextState.nodes)
|
||||||
|
setEdgesState(nextState.edges)
|
||||||
setHistorySizes({ past: pastRef.current.length, future: futureRef.current.length })
|
setHistorySizes({ past: pastRef.current.length, future: futureRef.current.length })
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user