Implement workflow history management and helper line calculations for improved node alignment and undo/redo functionality
This commit is contained in:
@@ -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[] = []
|
||||||
|
|||||||
Reference in New Issue
Block a user