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, MarkerType,
Position, Position,
ReactFlow, ReactFlow,
ViewportPortal,
useEdgesState, useEdgesState,
useNodesState, useNodesState,
type Connection, type Connection,
type ConnectionLineComponentProps, type ConnectionLineComponentProps,
type Edge, type Edge,
type EdgeChange,
type EdgeProps, type EdgeProps,
type FinalConnectionState, type FinalConnectionState,
type Node, type Node,
type NodeChange,
type OnNodeDrag,
type NodeProps, type NodeProps,
type ReactFlowInstance, type ReactFlowInstance,
} from "@xyflow/react" } from "@xyflow/react"
@@ -31,6 +35,8 @@ import {
PanelLeftCloseIcon, PanelLeftCloseIcon,
PanelLeftOpenIcon, PanelLeftOpenIcon,
PlusIcon, PlusIcon,
Redo2Icon,
Undo2Icon,
} from "lucide-react" } from "lucide-react"
import { useCallback, useEffect, useMemo, useRef, useState } from "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 type { AIWorkflowDefinition, AIWorkflowNodeSpec } from "@/lib/api/admin"
import { import {
applyAutoInputMappings, applyAutoInputMappings,
calculateWorkflowHelperLines,
createWorkflowHistory,
createWorkflowNodeFromSpec, createWorkflowNodeFromSpec,
fromApiDefinition, fromApiDefinition,
getAvailableVariables, getAvailableVariables,
getNodeSpec, getNodeSpec,
getRequiredInputs, getRequiredInputs,
pushWorkflowHistory,
redoWorkflowHistory,
toApiDefinition, toApiDefinition,
undoWorkflowHistory,
validateWorkflowDraft, validateWorkflowDraft,
type WorkflowVariableRef, type WorkflowVariableRef,
type WorkflowVariableSelector, type WorkflowVariableSelector,
type WorkflowEditorEdge, type WorkflowEditorEdge,
type WorkflowEditorNode, type WorkflowEditorNode,
type WorkflowHistory,
type WorkflowHelperLine,
} from "./workflow-utils" } from "./workflow-utils"
import { NodeConfigPanel, type WorkflowBranchSummary } from "./node-config-panel" import { NodeConfigPanel, type WorkflowBranchSummary } from "./node-config-panel"
import { VariableSelector } from "./variable-selector" import { VariableSelector } from "./variable-selector"
@@ -81,6 +94,10 @@ type WorkflowNodeData = Record<string, unknown> & {
type WorkflowFlowNode = Node<WorkflowNodeData> type WorkflowFlowNode = Node<WorkflowNodeData>
type WorkflowFlowEdge = Edge type WorkflowFlowEdge = Edge
type WorkflowEditorSnapshot = {
nodes: WorkflowFlowNode[]
edges: WorkflowFlowEdge[]
}
type WorkflowEdgeCondition = NonNullable<WorkflowEditorEdge["data"]>["condition"] type WorkflowEdgeCondition = NonNullable<WorkflowEditorEdge["data"]>["condition"]
type WorkflowEdgeRenderData = WorkflowEditorEdge["data"] & { type WorkflowEdgeRenderData = WorkflowEditorEdge["data"] & {
active?: boolean active?: boolean
@@ -193,6 +210,7 @@ export function WorkflowEditor({
const [nodeLibraryWidth, setNodeLibraryWidth] = useState(260) const [nodeLibraryWidth, setNodeLibraryWidth] = useState(260)
const [nodeLibraryResizing, setNodeLibraryResizing] = useState(false) const [nodeLibraryResizing, setNodeLibraryResizing] = useState(false)
const [pendingNodeDrag, setPendingNodeDrag] = useState<PendingNodeDrag | null>(null) const [pendingNodeDrag, setPendingNodeDrag] = useState<PendingNodeDrag | null>(null)
const [helperLines, setHelperLines] = useState<WorkflowHelperLine>({})
const [propertyPanelNode, setPropertyPanelNode] = useState<WorkflowFlowNode | null>(null) const [propertyPanelNode, setPropertyPanelNode] = useState<WorkflowFlowNode | null>(null)
const [selectedEdgeId, setSelectedEdgeId] = useState<string | null>(null) const [selectedEdgeId, setSelectedEdgeId] = useState<string | null>(null)
const [propertyPanelEdge, setPropertyPanelEdge] = useState<WorkflowFlowEdge | null>(null) const [propertyPanelEdge, setPropertyPanelEdge] = useState<WorkflowFlowEdge | null>(null)
@@ -200,10 +218,16 @@ export function WorkflowEditor({
const editorRef = useRef<HTMLDivElement | null>(null) const editorRef = useRef<HTMLDivElement | null>(null)
const canvasRef = useRef<HTMLElement | null>(null) const canvasRef = useRef<HTMLElement | null>(null)
const pendingNodeDragRef = useRef<PendingNodeDrag | 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 suppressNextClickRef = useRef(false)
const nodeLibraryAnimationTimerRef = useRef<number | null>(null) const nodeLibraryAnimationTimerRef = useRef<number | null>(null)
const propertyPanelAnimationTimerRef = useRef<number | null>(null) const propertyPanelAnimationTimerRef = useRef<number | null>(null)
const draft = useMemo(() => toDraft(nodes, edges), [nodes, edges]) const draft = useMemo(() => toDraft(nodes, edges), [nodes, edges])
const [historyAvailability, setHistoryAvailability] = useState({
canUndo: false,
canRedo: false,
})
const validation = useMemo( const validation = useMemo(
() => validateWorkflowDraft(draft, nodeSpecs), () => validateWorkflowDraft(draft, nodeSpecs),
[draft, nodeSpecs] [draft, nodeSpecs]
@@ -229,10 +253,6 @@ export function WorkflowEditor({
onDefinitionChange(toApiDefinition(draft) as AIWorkflowDefinition) onDefinitionChange(toApiDefinition(draft) as AIWorkflowDefinition)
}, [draft, onDefinitionChange]) }, [draft, onDefinitionChange])
useEffect(() => {
onDefinitionChange(toApiDefinition(draft) as AIWorkflowDefinition)
}, [draft, onDefinitionChange])
useEffect(() => { useEffect(() => {
return () => { return () => {
if (nodeLibraryAnimationTimerRef.current !== null) { if (nodeLibraryAnimationTimerRef.current !== null) {
@@ -304,11 +324,103 @@ export function WorkflowEditor({
}, 220) }, 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( const onConnect = useCallback(
(connection: Connection) => { (connection: Connection) => {
if (!connection.source || !connection.target) { if (!connection.source || !connection.target) {
return return
} }
pushCurrentSnapshotToHistory()
const edge = { const edge = {
...connection, ...connection,
id: uniqueEdgeId(edges, connection.source, connection.target), 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( const connectToNode = useCallback(
@@ -382,7 +494,73 @@ export function WorkflowEditor({
[connectToNode] [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) => { const addNode = (spec: AIWorkflowNodeSpec) => {
pushCurrentSnapshotToHistory()
setNodes((current) => { setNodes((current) => {
const node = createWorkflowNodeFromSpec( const node = createWorkflowNodeFromSpec(
spec, spec,
@@ -403,6 +581,7 @@ export function WorkflowEditor({
const addNodeAfter = useCallback( const addNodeAfter = useCallback(
(sourceNodeId: string, spec: AIWorkflowNodeSpec) => { (sourceNodeId: string, spec: AIWorkflowNodeSpec) => {
pushCurrentSnapshotToHistory()
setNodes((current) => { setNodes((current) => {
const sourceNode = current.find((node) => node.id === sourceNodeId) const sourceNode = current.find((node) => node.id === sourceNodeId)
const nextPosition = sourceNode const nextPosition = sourceNode
@@ -427,7 +606,7 @@ export function WorkflowEditor({
return [...current, nextNode] return [...current, nextNode]
}) })
}, },
[setEdges, setNodes] [pushCurrentSnapshotToHistory, setEdges, setNodes]
) )
const renderedNodes = useMemo( const renderedNodes = useMemo(
@@ -452,6 +631,7 @@ export function WorkflowEditor({
if (x < rect.left || x > rect.right || y < rect.top || y > rect.bottom) { if (x < rect.left || x > rect.right || y < rect.top || y > rect.bottom) {
return false return false
} }
pushCurrentSnapshotToHistory()
const position = flowInstance.screenToFlowPosition({ x, y }) const position = flowInstance.screenToFlowPosition({ x, y })
setNodes((current) => [ setNodes((current) => [
...current, ...current,
@@ -459,7 +639,7 @@ export function WorkflowEditor({
]) ])
return true return true
}, },
[flowInstance, setNodes] [flowInstance, pushCurrentSnapshotToHistory, setNodes]
) )
const onNodePointerDown = (event: React.PointerEvent<HTMLButtonElement>, spec: AIWorkflowNodeSpec) => { const onNodePointerDown = (event: React.PointerEvent<HTMLButtonElement>, spec: AIWorkflowNodeSpec) => {
@@ -510,6 +690,7 @@ export function WorkflowEditor({
} }
const updateNodeData = (nodeId: string, data: WorkflowNodeData) => { const updateNodeData = (nodeId: string, data: WorkflowNodeData) => {
pushCurrentSnapshotToHistory()
const nextData = { const nextData = {
...data, ...data,
label: data.name ?? data.nodeType ?? nodeId, label: data.name ?? data.nodeType ?? nodeId,
@@ -561,6 +742,7 @@ export function WorkflowEditor({
) )
const updateEdgeCondition = (edgeId: string, condition?: WorkflowEdgeCondition) => { const updateEdgeCondition = (edgeId: string, condition?: WorkflowEdgeCondition) => {
pushCurrentSnapshotToHistory()
const updateEdge = (edge: WorkflowFlowEdge) => ({ const updateEdge = (edge: WorkflowFlowEdge) => ({
...edge, ...edge,
label: condition ? "条件" : undefined, label: condition ? "条件" : undefined,
@@ -721,10 +903,13 @@ export function WorkflowEditor({
connectionMode={ConnectionMode.Loose} connectionMode={ConnectionMode.Loose}
connectionRadius={34} connectionRadius={34}
connectOnClick connectOnClick
onNodesChange={onNodesChange} onNodesChange={onWorkflowNodesChange}
onEdgesChange={onEdgesChange} onEdgesChange={onWorkflowEdgesChange}
onConnect={onConnect} onConnect={onConnect}
onConnectEnd={onConnectEnd} onConnectEnd={onConnectEnd}
onNodeDragStart={onNodeDragStart}
onNodeDrag={onNodeDrag}
onNodeDragStop={onNodeDragStop}
onInit={setFlowInstance} onInit={setFlowInstance}
onNodeClick={(event, node) => { onNodeClick={(event, node) => {
event.stopPropagation() 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" className="!bottom-4 !left-4 overflow-hidden !rounded-xl !border !border-border/70 !bg-background/95 !shadow-lg"
showInteractive={false} showInteractive={false}
/> />
<WorkflowHelperLines lines={helperLines} />
</ReactFlow> </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 ? ( {propertyPanelNode || propertyPanelEdge ? (
<aside <aside
className={[ className={[
@@ -1035,6 +1229,16 @@ function getEventClientPoint(event: MouseEvent | TouchEvent) {
return { x: event.clientX, y: event.clientY } 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) { function getEdgeEndpointOffset(position: Position, amount: number) {
switch (position) { switch (position) {
case Position.Left: case Position.Left:
@@ -1372,7 +1576,7 @@ function WorkflowValidationBadge({
valid: boolean valid: boolean
}) { }) {
return ( return (
<div className="absolute left-3 top-3 flex gap-2"> <div className="flex gap-2">
{valid ? ( {valid ? (
<Badge variant="default"></Badge> <Badge variant="default"></Badge>
) : ( ) : (
@@ -1404,3 +1608,75 @@ function WorkflowValidationBadge({
</div> </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", () => { describe("getAvailableVariables", () => {
it("exposes start outputs to retrieve node", async () => { it("exposes start outputs to retrieve node", async () => {
const { getAvailableVariables } = await loadModule() const { getAvailableVariables } = await loadModule()
@@ -3,6 +3,34 @@ export type WorkflowNodePosition = {
y: number 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 = { export type WorkflowEditorNode = {
id: string id: string
type?: string type?: string
@@ -103,6 +131,193 @@ export type WorkflowDraftValidation = {
errors: string[] 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( export function validateWorkflowDraft(
draft: WorkflowDraft, draft: WorkflowDraft,
nodeSpecs: WorkflowNodeSpec[] = [] nodeSpecs: WorkflowNodeSpec[] = []