Implement workflow history management and helper line calculations for improved node alignment and undo/redo functionality

This commit is contained in:
mlogclub
2026-06-23 20:52:09 +08:00
parent 717544b57c
commit 188c6b6f6b
3 changed files with 582 additions and 11 deletions
@@ -14,14 +14,18 @@ import {
MarkerType,
Position,
ReactFlow,
ViewportPortal,
useEdgesState,
useNodesState,
type Connection,
type ConnectionLineComponentProps,
type Edge,
type EdgeChange,
type EdgeProps,
type FinalConnectionState,
type Node,
type NodeChange,
type OnNodeDrag,
type NodeProps,
type ReactFlowInstance,
} from "@xyflow/react"
@@ -31,6 +35,8 @@ import {
PanelLeftCloseIcon,
PanelLeftOpenIcon,
PlusIcon,
Redo2Icon,
Undo2Icon,
} from "lucide-react"
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
@@ -49,17 +55,24 @@ import { cn } from "@/lib/utils"
import type { AIWorkflowDefinition, AIWorkflowNodeSpec } from "@/lib/api/admin"
import {
applyAutoInputMappings,
calculateWorkflowHelperLines,
createWorkflowHistory,
createWorkflowNodeFromSpec,
fromApiDefinition,
getAvailableVariables,
getNodeSpec,
getRequiredInputs,
pushWorkflowHistory,
redoWorkflowHistory,
toApiDefinition,
undoWorkflowHistory,
validateWorkflowDraft,
type WorkflowVariableRef,
type WorkflowVariableSelector,
type WorkflowEditorEdge,
type WorkflowEditorNode,
type WorkflowHistory,
type WorkflowHelperLine,
} from "./workflow-utils"
import { NodeConfigPanel, type WorkflowBranchSummary } from "./node-config-panel"
import { VariableSelector } from "./variable-selector"
@@ -81,6 +94,10 @@ type WorkflowNodeData = Record<string, unknown> & {
type WorkflowFlowNode = Node<WorkflowNodeData>
type WorkflowFlowEdge = Edge
type WorkflowEditorSnapshot = {
nodes: WorkflowFlowNode[]
edges: WorkflowFlowEdge[]
}
type WorkflowEdgeCondition = NonNullable<WorkflowEditorEdge["data"]>["condition"]
type WorkflowEdgeRenderData = WorkflowEditorEdge["data"] & {
active?: boolean
@@ -193,6 +210,7 @@ export function WorkflowEditor({
const [nodeLibraryWidth, setNodeLibraryWidth] = useState(260)
const [nodeLibraryResizing, setNodeLibraryResizing] = useState(false)
const [pendingNodeDrag, setPendingNodeDrag] = useState<PendingNodeDrag | null>(null)
const [helperLines, setHelperLines] = useState<WorkflowHelperLine>({})
const [propertyPanelNode, setPropertyPanelNode] = useState<WorkflowFlowNode | null>(null)
const [selectedEdgeId, setSelectedEdgeId] = useState<string | null>(null)
const [propertyPanelEdge, setPropertyPanelEdge] = useState<WorkflowFlowEdge | null>(null)
@@ -200,10 +218,16 @@ export function WorkflowEditor({
const editorRef = useRef<HTMLDivElement | null>(null)
const canvasRef = useRef<HTMLElement | null>(null)
const pendingNodeDragRef = useRef<PendingNodeDrag | null>(null)
const historyRef = useRef<WorkflowHistory<WorkflowEditorSnapshot>>(createWorkflowHistory())
const dragStartSnapshotRef = useRef<WorkflowEditorSnapshot | null>(null)
const suppressNextClickRef = useRef(false)
const nodeLibraryAnimationTimerRef = useRef<number | null>(null)
const propertyPanelAnimationTimerRef = useRef<number | null>(null)
const draft = useMemo(() => toDraft(nodes, edges), [nodes, edges])
const [historyAvailability, setHistoryAvailability] = useState({
canUndo: false,
canRedo: false,
})
const validation = useMemo(
() => validateWorkflowDraft(draft, nodeSpecs),
[draft, nodeSpecs]
@@ -229,10 +253,6 @@ export function WorkflowEditor({
onDefinitionChange(toApiDefinition(draft) as AIWorkflowDefinition)
}, [draft, onDefinitionChange])
useEffect(() => {
onDefinitionChange(toApiDefinition(draft) as AIWorkflowDefinition)
}, [draft, onDefinitionChange])
useEffect(() => {
return () => {
if (nodeLibraryAnimationTimerRef.current !== null) {
@@ -304,11 +324,103 @@ export function WorkflowEditor({
}, 220)
}, [])
const syncHistoryAvailability = useCallback(() => {
setHistoryAvailability({
canUndo: historyRef.current.past.length > 0,
canRedo: historyRef.current.future.length > 0,
})
}, [])
const getCurrentSnapshot = useCallback((): WorkflowEditorSnapshot => ({
nodes,
edges,
}), [edges, nodes])
const pushSnapshotToHistory = useCallback(
(snapshot: WorkflowEditorSnapshot) => {
historyRef.current = pushWorkflowHistory(historyRef.current, snapshot)
syncHistoryAvailability()
},
[syncHistoryAvailability]
)
const pushCurrentSnapshotToHistory = useCallback(() => {
pushSnapshotToHistory(getCurrentSnapshot())
}, [getCurrentSnapshot, pushSnapshotToHistory])
const applySnapshot = useCallback(
(snapshot: WorkflowEditorSnapshot) => {
setNodes(snapshot.nodes)
setEdges(snapshot.edges)
setHelperLines({})
setSelectedEdgeId((current) =>
current && snapshot.edges.some((edge) => edge.id === current) ? current : null
)
setPropertyPanelNode((current) =>
current ? snapshot.nodes.find((node) => node.id === current.id) ?? null : null
)
setPropertyPanelEdge((current) =>
current ? snapshot.edges.find((edge) => edge.id === current.id) ?? null : null
)
},
[setEdges, setNodes]
)
const undoWorkflowEdit = useCallback(() => {
const result = undoWorkflowHistory(historyRef.current, getCurrentSnapshot())
if (!result) {
return
}
historyRef.current = result.history
applySnapshot(result.snapshot)
syncHistoryAvailability()
}, [applySnapshot, getCurrentSnapshot, syncHistoryAvailability])
const redoWorkflowEdit = useCallback(() => {
const result = redoWorkflowHistory(historyRef.current, getCurrentSnapshot())
if (!result) {
return
}
historyRef.current = result.history
applySnapshot(result.snapshot)
syncHistoryAvailability()
}, [applySnapshot, getCurrentSnapshot, syncHistoryAvailability])
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (!event.metaKey && !event.ctrlKey) {
return
}
if (isEditableKeyboardTarget(event.target)) {
return
}
const key = event.key.toLowerCase()
if (key === "z" && event.shiftKey) {
event.preventDefault()
redoWorkflowEdit()
return
}
if (key === "y") {
event.preventDefault()
redoWorkflowEdit()
return
}
if (key === "z") {
event.preventDefault()
undoWorkflowEdit()
}
}
window.addEventListener("keydown", handleKeyDown)
return () => window.removeEventListener("keydown", handleKeyDown)
}, [redoWorkflowEdit, undoWorkflowEdit])
const onConnect = useCallback(
(connection: Connection) => {
if (!connection.source || !connection.target) {
return
}
pushCurrentSnapshotToHistory()
const edge = {
...connection,
id: uniqueEdgeId(edges, connection.source, connection.target),
@@ -338,7 +450,7 @@ export function WorkflowEditor({
})
})
},
[edges, nodeSpecs, setEdges, setNodes]
[edges, nodeSpecs, pushCurrentSnapshotToHistory, setEdges, setNodes]
)
const connectToNode = useCallback(
@@ -382,7 +494,73 @@ export function WorkflowEditor({
[connectToNode]
)
const onWorkflowNodesChange = useCallback(
(changes: NodeChange<WorkflowFlowNode>[]) => {
if (changes.some((change) => change.type === "remove")) {
pushCurrentSnapshotToHistory()
}
onNodesChange(changes)
},
[onNodesChange, pushCurrentSnapshotToHistory]
)
const onWorkflowEdgesChange = useCallback(
(changes: EdgeChange<WorkflowFlowEdge>[]) => {
if (changes.some((change) => change.type === "remove")) {
pushCurrentSnapshotToHistory()
}
onEdgesChange(changes)
},
[onEdgesChange, pushCurrentSnapshotToHistory]
)
const onNodeDragStart = useCallback<OnNodeDrag<WorkflowFlowNode>>(() => {
dragStartSnapshotRef.current = getCurrentSnapshot()
}, [getCurrentSnapshot])
const onNodeDrag = useCallback<OnNodeDrag<WorkflowFlowNode>>(
(_event, node) => {
const nextHelperLines = calculateWorkflowHelperLines(nodes, node)
setHelperLines({
horizontal: nextHelperLines.horizontal,
vertical: nextHelperLines.vertical,
})
if (
nextHelperLines.position.x === node.position.x &&
nextHelperLines.position.y === node.position.y
) {
return
}
setNodes((current) =>
current.map((item) =>
item.id === node.id
? {
...item,
position: nextHelperLines.position,
}
: item
)
)
},
[nodes, setNodes]
)
const onNodeDragStop = useCallback<OnNodeDrag<WorkflowFlowNode>>((_event, node) => {
setHelperLines({})
const startSnapshot = dragStartSnapshotRef.current
dragStartSnapshotRef.current = null
const startNode = startSnapshot?.nodes.find((item) => item.id === node.id)
if (
startSnapshot &&
startNode &&
(startNode.position.x !== node.position.x || startNode.position.y !== node.position.y)
) {
pushSnapshotToHistory(startSnapshot)
}
}, [pushSnapshotToHistory])
const addNode = (spec: AIWorkflowNodeSpec) => {
pushCurrentSnapshotToHistory()
setNodes((current) => {
const node = createWorkflowNodeFromSpec(
spec,
@@ -403,6 +581,7 @@ export function WorkflowEditor({
const addNodeAfter = useCallback(
(sourceNodeId: string, spec: AIWorkflowNodeSpec) => {
pushCurrentSnapshotToHistory()
setNodes((current) => {
const sourceNode = current.find((node) => node.id === sourceNodeId)
const nextPosition = sourceNode
@@ -427,7 +606,7 @@ export function WorkflowEditor({
return [...current, nextNode]
})
},
[setEdges, setNodes]
[pushCurrentSnapshotToHistory, setEdges, setNodes]
)
const renderedNodes = useMemo(
@@ -452,6 +631,7 @@ export function WorkflowEditor({
if (x < rect.left || x > rect.right || y < rect.top || y > rect.bottom) {
return false
}
pushCurrentSnapshotToHistory()
const position = flowInstance.screenToFlowPosition({ x, y })
setNodes((current) => [
...current,
@@ -459,7 +639,7 @@ export function WorkflowEditor({
])
return true
},
[flowInstance, setNodes]
[flowInstance, pushCurrentSnapshotToHistory, setNodes]
)
const onNodePointerDown = (event: React.PointerEvent<HTMLButtonElement>, spec: AIWorkflowNodeSpec) => {
@@ -510,6 +690,7 @@ export function WorkflowEditor({
}
const updateNodeData = (nodeId: string, data: WorkflowNodeData) => {
pushCurrentSnapshotToHistory()
const nextData = {
...data,
label: data.name ?? data.nodeType ?? nodeId,
@@ -561,6 +742,7 @@ export function WorkflowEditor({
)
const updateEdgeCondition = (edgeId: string, condition?: WorkflowEdgeCondition) => {
pushCurrentSnapshotToHistory()
const updateEdge = (edge: WorkflowFlowEdge) => ({
...edge,
label: condition ? "条件" : undefined,
@@ -721,10 +903,13 @@ export function WorkflowEditor({
connectionMode={ConnectionMode.Loose}
connectionRadius={34}
connectOnClick
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onNodesChange={onWorkflowNodesChange}
onEdgesChange={onWorkflowEdgesChange}
onConnect={onConnect}
onConnectEnd={onConnectEnd}
onNodeDragStart={onNodeDragStart}
onNodeDrag={onNodeDrag}
onNodeDragStop={onNodeDragStop}
onInit={setFlowInstance}
onNodeClick={(event, node) => {
event.stopPropagation()
@@ -754,8 +939,17 @@ export function WorkflowEditor({
className="!bottom-4 !left-4 overflow-hidden !rounded-xl !border !border-border/70 !bg-background/95 !shadow-lg"
showInteractive={false}
/>
<WorkflowHelperLines lines={helperLines} />
</ReactFlow>
<WorkflowValidationBadge errors={validation.errors} valid={validation.valid} />
<div className="absolute left-3 top-3 z-20 flex items-center gap-2">
<WorkflowValidationBadge errors={validation.errors} valid={validation.valid} />
<WorkflowHistoryControls
canUndo={historyAvailability.canUndo}
canRedo={historyAvailability.canRedo}
onUndo={undoWorkflowEdit}
onRedo={redoWorkflowEdit}
/>
</div>
{propertyPanelNode || propertyPanelEdge ? (
<aside
className={[
@@ -1035,6 +1229,16 @@ function getEventClientPoint(event: MouseEvent | TouchEvent) {
return { x: event.clientX, y: event.clientY }
}
function isEditableKeyboardTarget(target: EventTarget | null) {
if (!(target instanceof HTMLElement)) {
return false
}
if (target.isContentEditable) {
return true
}
return Boolean(target.closest("input, textarea, select, [contenteditable='true']"))
}
function getEdgeEndpointOffset(position: Position, amount: number) {
switch (position) {
case Position.Left:
@@ -1372,7 +1576,7 @@ function WorkflowValidationBadge({
valid: boolean
}) {
return (
<div className="absolute left-3 top-3 flex gap-2">
<div className="flex gap-2">
{valid ? (
<Badge variant="default"></Badge>
) : (
@@ -1404,3 +1608,75 @@ function WorkflowValidationBadge({
</div>
)
}
function WorkflowHistoryControls({
canUndo,
canRedo,
onUndo,
onRedo,
}: {
canUndo: boolean
canRedo: boolean
onUndo: () => void
onRedo: () => void
}) {
return (
<div className="flex overflow-hidden rounded-md border bg-background/95 shadow-sm">
<Button
type="button"
variant="ghost"
size="icon"
className="size-7 rounded-none text-muted-foreground hover:text-foreground"
onClick={onUndo}
disabled={!canUndo}
aria-label="撤销"
title="撤销"
>
<Undo2Icon className="size-3.5" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="size-7 rounded-none border-l text-muted-foreground hover:text-foreground"
onClick={onRedo}
disabled={!canRedo}
aria-label="反撤销"
title="反撤销"
>
<Redo2Icon className="size-3.5" />
</Button>
</div>
)
}
function WorkflowHelperLines({ lines }: { lines: WorkflowHelperLine }) {
if (!lines.horizontal && !lines.vertical) {
return null
}
return (
<ViewportPortal>
{lines.horizontal ? (
<div
className="pointer-events-none absolute z-10 h-px bg-primary/70 shadow-[0_0_0_1px_hsl(var(--primary)/0.18)]"
style={{
left: lines.horizontal.left,
top: lines.horizontal.y,
width: lines.horizontal.width,
}}
/>
) : null}
{lines.vertical ? (
<div
className="pointer-events-none absolute z-10 w-px bg-primary/70 shadow-[0_0_0_1px_hsl(var(--primary)/0.18)]"
style={{
left: lines.vertical.x,
top: lines.vertical.top,
height: lines.vertical.height,
}}
/>
) : null}
</ViewportPortal>
)
}
@@ -179,6 +179,86 @@ describe("createWorkflowNodeFromSpec", () => {
})
})
describe("calculateWorkflowHelperLines", () => {
it("snaps dragged node to a nearby horizontal alignment", async () => {
const { calculateWorkflowHelperLines } = await loadModule()
const result = calculateWorkflowHelperLines(
[
{ id: "start_1", position: { x: 100, y: 120 }, width: 220, height: 84 },
{ id: "reply_1", position: { x: 392, y: 124 }, width: 220, height: 84 },
],
{ id: "reply_1", position: { x: 392, y: 124 }, width: 220, height: 84 }
)
assert.deepEqual(plain(result), {
position: { x: 392, y: 120 },
horizontal: { y: 120, left: 100, width: 512 },
})
})
it("does not show helper lines outside the alignment threshold", async () => {
const { calculateWorkflowHelperLines } = await loadModule()
const result = calculateWorkflowHelperLines(
[
{ id: "start_1", position: { x: 100, y: 120 }, width: 220, height: 84 },
{ id: "reply_1", position: { x: 392, y: 132 }, width: 220, height: 84 },
],
{ id: "reply_1", position: { x: 392, y: 132 }, width: 220, height: 84 }
)
assert.deepEqual(plain(result), {
position: { x: 392, y: 132 },
})
})
})
describe("workflow history", () => {
it("undoes and redoes snapshots while clearing redo after a new edit", async () => {
const {
createWorkflowHistory,
pushWorkflowHistory,
undoWorkflowHistory,
redoWorkflowHistory,
} = await loadModule()
const first = {
nodes: [{ id: "start_1", position: { x: 0, y: 0 } }],
edges: [],
}
const second = {
nodes: [{ id: "start_1", position: { x: 100, y: 0 } }],
edges: [],
}
const third = {
nodes: [{ id: "start_1", position: { x: 200, y: 0 } }],
edges: [],
}
const branch = {
nodes: [{ id: "start_1", position: { x: 300, y: 0 } }],
edges: [],
}
let history = createWorkflowHistory()
history = pushWorkflowHistory(history, first)
history = pushWorkflowHistory(history, second)
const undone = undoWorkflowHistory(history, third)
assert.deepEqual(plain(undone.snapshot), second)
assert.equal(undone.history.past.length, 1)
assert.equal(undone.history.future.length, 1)
const redone = redoWorkflowHistory(undone.history, undone.snapshot)
assert.deepEqual(plain(redone.snapshot), third)
assert.equal(redone.history.past.length, 2)
assert.equal(redone.history.future.length, 0)
const branched = pushWorkflowHistory(undone.history, branch)
assert.equal(branched.future.length, 0)
})
})
describe("getAvailableVariables", () => {
it("exposes start outputs to retrieve node", async () => {
const { getAvailableVariables } = await loadModule()
@@ -3,6 +3,34 @@ export type WorkflowNodePosition = {
y: number
}
export type WorkflowHelperLineNode = {
id: string
position: WorkflowNodePosition
width?: number | null
height?: number | null
measured?: {
width?: number | null
height?: number | null
}
}
export type WorkflowHelperLine = {
horizontal?: {
y: number
left: number
width: number
}
vertical?: {
x: number
top: number
height: number
}
}
export type WorkflowHelperLineResult = WorkflowHelperLine & {
position: WorkflowNodePosition
}
export type WorkflowEditorNode = {
id: string
type?: string
@@ -103,6 +131,193 @@ export type WorkflowDraftValidation = {
errors: string[]
}
export type WorkflowHistory<T> = {
past: T[]
future: T[]
limit: number
}
export type WorkflowHistoryChange<T> = {
history: WorkflowHistory<T>
snapshot: T
}
const helperLineAlignmentThreshold = 6
const defaultWorkflowHistoryLimit = 50
function cloneHistorySnapshot<T>(snapshot: T): T {
return JSON.parse(JSON.stringify(snapshot)) as T
}
export function createWorkflowHistory<T>(limit = defaultWorkflowHistoryLimit): WorkflowHistory<T> {
return {
past: [],
future: [],
limit,
}
}
export function pushWorkflowHistory<T>(
history: WorkflowHistory<T>,
snapshot: T
): WorkflowHistory<T> {
const past = [...history.past, cloneHistorySnapshot(snapshot)]
return {
past: past.slice(Math.max(0, past.length - history.limit)),
future: [],
limit: history.limit,
}
}
export function undoWorkflowHistory<T>(
history: WorkflowHistory<T>,
current: T
): WorkflowHistoryChange<T> | null {
const snapshot = history.past.at(-1)
if (!snapshot) {
return null
}
return {
snapshot: cloneHistorySnapshot(snapshot),
history: {
past: history.past.slice(0, -1),
future: [cloneHistorySnapshot(current), ...history.future],
limit: history.limit,
},
}
}
export function redoWorkflowHistory<T>(
history: WorkflowHistory<T>,
current: T
): WorkflowHistoryChange<T> | null {
const snapshot = history.future[0]
if (!snapshot) {
return null
}
const past = [...history.past, cloneHistorySnapshot(current)]
return {
snapshot: cloneHistorySnapshot(snapshot),
history: {
past: past.slice(Math.max(0, past.length - history.limit)),
future: history.future.slice(1),
limit: history.limit,
},
}
}
function getNodeSize(node: WorkflowHelperLineNode) {
return {
width: node.measured?.width ?? node.width ?? 0,
height: node.measured?.height ?? node.height ?? 0,
}
}
function getNodeAnchorValues(node: WorkflowHelperLineNode) {
const size = getNodeSize(node)
return {
x: [
node.position.x,
node.position.x + size.width / 2,
node.position.x + size.width,
],
y: [
node.position.y,
node.position.y + size.height / 2,
node.position.y + size.height,
],
}
}
function getNearestAlignment(
axis: "x" | "y",
nodes: WorkflowHelperLineNode[],
draggingNode: WorkflowHelperLineNode
) {
const draggingAnchors = getNodeAnchorValues(draggingNode)[axis]
let nearest:
| {
diff: number
targetValue: number
candidate: WorkflowHelperLineNode
}
| undefined
for (const candidate of nodes) {
if (candidate.id === draggingNode.id) {
continue
}
const candidateAnchors = getNodeAnchorValues(candidate)[axis]
for (const draggingAnchor of draggingAnchors) {
for (const candidateAnchor of candidateAnchors) {
const diff = candidateAnchor - draggingAnchor
if (Math.abs(diff) > helperLineAlignmentThreshold) {
continue
}
if (!nearest || Math.abs(diff) < Math.abs(nearest.diff)) {
nearest = {
diff,
targetValue: candidateAnchor,
candidate,
}
}
}
}
}
return nearest
}
export function calculateWorkflowHelperLines(
nodes: WorkflowHelperLineNode[],
draggingNode: WorkflowHelperLineNode
): WorkflowHelperLineResult {
const xAlignment = getNearestAlignment("x", nodes, draggingNode)
const yAlignment = getNearestAlignment("y", nodes, draggingNode)
const position = {
x: draggingNode.position.x + (xAlignment?.diff ?? 0),
y: draggingNode.position.y + (yAlignment?.diff ?? 0),
}
const draggingSize = getNodeSize(draggingNode)
const snappedDraggingNode = {
...draggingNode,
position,
}
const result: WorkflowHelperLineResult = {
position,
}
if (yAlignment) {
const candidateSize = getNodeSize(yAlignment.candidate)
const left = Math.min(snappedDraggingNode.position.x, yAlignment.candidate.position.x)
const right = Math.max(
snappedDraggingNode.position.x + draggingSize.width,
yAlignment.candidate.position.x + candidateSize.width
)
result.horizontal = {
y: yAlignment.targetValue,
left,
width: right - left,
}
}
if (xAlignment) {
const candidateSize = getNodeSize(xAlignment.candidate)
const top = Math.min(snappedDraggingNode.position.y, xAlignment.candidate.position.y)
const bottom = Math.max(
snappedDraggingNode.position.y + draggingSize.height,
xAlignment.candidate.position.y + candidateSize.height
)
result.vertical = {
x: xAlignment.targetValue,
top,
height: bottom - top,
}
}
return result
}
export function validateWorkflowDraft(
draft: WorkflowDraft,
nodeSpecs: WorkflowNodeSpec[] = []