From 51c96641f0eb35434942bf6b6b283e724fd3de36 Mon Sep 17 00:00:00 2001 From: dtoro Date: Thu, 12 Mar 2026 18:08:54 +0100 Subject: [PATCH] refactor: deta histroy --- frontend/src/app/canvas/useCanvasGraph.ts | 55 ++++- .../src/hooks/useGraphStateWithHistory.ts | 233 +++++++++++++++--- 2 files changed, 249 insertions(+), 39 deletions(-) diff --git a/frontend/src/app/canvas/useCanvasGraph.ts b/frontend/src/app/canvas/useCanvasGraph.ts index b170022..577a77c 100644 --- a/frontend/src/app/canvas/useCanvasGraph.ts +++ b/frontend/src/app/canvas/useCanvasGraph.ts @@ -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 +/** 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 | null>(null) + const idleCallbackRef = useRef(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]) diff --git a/frontend/src/hooks/useGraphStateWithHistory.ts b/frontend/src/hooks/useGraphStateWithHistory.ts index c6b8c82..2b426d3 100644 --- a/frontend/src/hooks/useGraphStateWithHistory.ts +++ b/frontend/src/hooks/useGraphStateWithHistory.ts @@ -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) } + : 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(initialEdges) const [historySizes, setHistorySizes] = useState({ past: 0, future: 0 }) - const pastRef = useRef([]) - const futureRef = useRef([]) + const pastRef = useRef([]) + const futureRef = useRef([]) const preDragRef = useRef(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 }) }, [])