|
@@ -0,0 +1,137 @@
|
|
|
|
|
+import { useCallback, useEffect, useRef, useState } from 'react'
|
|
|
|
|
+import { Edge, Node } from 'reactflow'
|
|
|
|
|
+
|
|
|
|
|
+/**
|
|
|
|
|
+ * Undo and redo for the canvas.
|
|
|
|
|
+ *
|
|
|
|
|
+ * The graph is changed from around forty places - adding, pasting, deleting,
|
|
|
|
|
+ * connecting, auto-layout, config edits, toggling a node off. Recording each of
|
|
|
|
|
+ * them by hand would mean every future change has to remember to do the same,
|
|
|
|
|
+ * and the one that forgets is the one that loses somebody's work. So this
|
|
|
|
|
+ * watches the graph instead and records when it settles.
|
|
|
|
|
+ *
|
|
|
|
|
+ * What counts as a change is deliberately narrow. Selecting a node, hovering an
|
|
|
|
|
+ * edge, an execution result arriving - none of those are edits, and an undo that
|
|
|
|
|
+ * puts back a selection is worse than no undo at all. Only the shape of the
|
|
|
|
|
+ * workflow is compared: which nodes exist, where they are, what they are called,
|
|
|
|
|
+ * what they are configured with, whether they are off, and what is wired to what.
|
|
|
|
|
+ */
|
|
|
|
|
+
|
|
|
|
|
+const LIMIT = 50
|
|
|
|
|
+
|
|
|
|
|
+// Long enough that a paste, an auto-layout or a burst of typing lands as one
|
|
|
|
|
+// step rather than several, short enough not to be lost on a quick undo.
|
|
|
|
|
+const SETTLE_MS = 300
|
|
|
|
|
+
|
|
|
|
|
+export interface GraphSnapshot {
|
|
|
|
|
+ nodes: Node[]
|
|
|
|
|
+ edges: Edge[]
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function signature(nodes: Node[], edges: Edge[]): string {
|
|
|
|
|
+ const nodePart = nodes
|
|
|
|
|
+ .map((n) =>
|
|
|
|
|
+ [
|
|
|
|
|
+ n.id,
|
|
|
|
|
+ n.data?.type,
|
|
|
|
|
+ // Rounded, because a drag reports fractional pixels the whole way and
|
|
|
|
|
+ // half a pixel is not something anyone wants to undo to.
|
|
|
|
|
+ Math.round(n.position?.x ?? 0),
|
|
|
|
|
+ Math.round(n.position?.y ?? 0),
|
|
|
|
|
+ n.data?.label,
|
|
|
|
|
+ n.data?.disabled === true ? 'off' : 'on',
|
|
|
|
|
+ JSON.stringify(n.data?.config ?? {}),
|
|
|
|
|
+ ].join('')
|
|
|
|
|
+ )
|
|
|
|
|
+ .sort()
|
|
|
|
|
+ .join('')
|
|
|
|
|
+
|
|
|
|
|
+ const edgePart = edges
|
|
|
|
|
+ .map((e) => [e.source, e.sourceHandle, e.target, e.targetHandle].join(''))
|
|
|
|
|
+ .sort()
|
|
|
|
|
+ .join('')
|
|
|
|
|
+
|
|
|
|
|
+ return `${nodePart}${edgePart}`
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+export function useGraphHistory(
|
|
|
|
|
+ nodes: Node[],
|
|
|
|
|
+ edges: Edge[],
|
|
|
|
|
+ setNodes: (nodes: Node[]) => void,
|
|
|
|
|
+ setEdges: (edges: Edge[]) => void,
|
|
|
|
|
+ enabled: boolean
|
|
|
|
|
+) {
|
|
|
|
|
+ const past = useRef<GraphSnapshot[]>([])
|
|
|
|
|
+ const future = useRef<GraphSnapshot[]>([])
|
|
|
|
|
+
|
|
|
|
|
+ // The last state that settled. Undo needs the state *before* a change, and by
|
|
|
|
|
+ // the time a change is noticed the state after it is what is in hand.
|
|
|
|
|
+ const settled = useRef<GraphSnapshot>({ nodes: [], edges: [] })
|
|
|
|
|
+ const settledSignature = useRef<string | null>(null)
|
|
|
|
|
+
|
|
|
|
|
+ // Restoring changes the graph, which would otherwise be recorded as a fresh
|
|
|
|
|
+ // edit - and a fresh edit throws away everything redo would have replayed.
|
|
|
|
|
+ const restoring = useRef(false)
|
|
|
|
|
+
|
|
|
|
|
+ const [depth, setDepth] = useState({ undo: 0, redo: 0 })
|
|
|
|
|
+ const publish = () => setDepth({ undo: past.current.length, redo: future.current.length })
|
|
|
|
|
+
|
|
|
|
|
+ useEffect(() => {
|
|
|
|
|
+ if (!enabled) return
|
|
|
|
|
+
|
|
|
|
|
+ // Mid-drag the position changes every frame. The drop is the edit.
|
|
|
|
|
+ if (nodes.some((n) => (n as any).dragging)) return
|
|
|
|
|
+
|
|
|
|
|
+ const current = signature(nodes, edges)
|
|
|
|
|
+ if (current === settledSignature.current) return
|
|
|
|
|
+
|
|
|
|
|
+ const timer = setTimeout(() => {
|
|
|
|
|
+ if (restoring.current) {
|
|
|
|
|
+ restoring.current = false
|
|
|
|
|
+ } else if (settledSignature.current !== null) {
|
|
|
|
|
+ // The very first settled state is the baseline, not an edit - there is
|
|
|
|
|
+ // nothing before it to go back to.
|
|
|
|
|
+ past.current.push(settled.current)
|
|
|
|
|
+ if (past.current.length > LIMIT) past.current.shift()
|
|
|
|
|
+ future.current = []
|
|
|
|
|
+ publish()
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ settled.current = { nodes, edges }
|
|
|
|
|
+ settledSignature.current = current
|
|
|
|
|
+ }, SETTLE_MS)
|
|
|
|
|
+
|
|
|
|
|
+ return () => clearTimeout(timer)
|
|
|
|
|
+ }, [nodes, edges, enabled])
|
|
|
|
|
+
|
|
|
|
|
+ const step = useCallback(
|
|
|
|
|
+ (from: GraphSnapshot[], to: GraphSnapshot[]) => {
|
|
|
|
|
+ const target = from.pop()
|
|
|
|
|
+ if (!target) return false
|
|
|
|
|
+
|
|
|
|
|
+ to.push({ nodes, edges })
|
|
|
|
|
+ restoring.current = true
|
|
|
|
|
+ settled.current = target
|
|
|
|
|
+ settledSignature.current = null
|
|
|
|
|
+ setNodes(target.nodes)
|
|
|
|
|
+ setEdges(target.edges)
|
|
|
|
|
+ publish()
|
|
|
|
|
+ return true
|
|
|
|
|
+ },
|
|
|
|
|
+ [nodes, edges, setNodes, setEdges]
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ const undo = useCallback(() => step(past.current, future.current), [step])
|
|
|
|
|
+ const redo = useCallback(() => step(future.current, past.current), [step])
|
|
|
|
|
+
|
|
|
|
|
+ // Opening a different workflow is not something to undo into.
|
|
|
|
|
+ const reset = useCallback(() => {
|
|
|
|
|
+ past.current = []
|
|
|
|
|
+ future.current = []
|
|
|
|
|
+ settled.current = { nodes: [], edges: [] }
|
|
|
|
|
+ settledSignature.current = null
|
|
|
|
|
+ publish()
|
|
|
|
|
+ }, [])
|
|
|
|
|
+
|
|
|
|
|
+ return { undo, redo, reset, canUndo: depth.undo > 0, canRedo: depth.redo > 0 }
|
|
|
|
|
+}
|