|
@@ -21,6 +21,7 @@ import ReactFlow, {
|
|
|
PanOnScrollMode,
|
|
PanOnScrollMode,
|
|
|
BaseEdge,
|
|
BaseEdge,
|
|
|
EdgeProps,
|
|
EdgeProps,
|
|
|
|
|
+ useStore,
|
|
|
getSmoothStepPath,
|
|
getSmoothStepPath,
|
|
|
EdgeLabelRenderer,
|
|
EdgeLabelRenderer,
|
|
|
} from 'reactflow'
|
|
} from 'reactflow'
|
|
@@ -45,6 +46,7 @@ import { ToastContainer, ToastMessage } from '../components/workflow/Toast'
|
|
|
import { EditorHeader } from '../components/workflow/EditorHeader'
|
|
import { EditorHeader } from '../components/workflow/EditorHeader'
|
|
|
import { WorkflowSettingsModal } from '../components/workflow/WorkflowSettingsModal'
|
|
import { WorkflowSettingsModal } from '../components/workflow/WorkflowSettingsModal'
|
|
|
import { NodeConfigModal } from '../components/workflow/NodeConfigModal'
|
|
import { NodeConfigModal } from '../components/workflow/NodeConfigModal'
|
|
|
|
|
+import { copyNodesToClipboard, readNodeClipboard, offsetFor } from '../utils/nodeClipboard'
|
|
|
|
|
|
|
|
interface SelectedNodeData {
|
|
interface SelectedNodeData {
|
|
|
id: string
|
|
id: string
|
|
@@ -77,6 +79,22 @@ const nodeTypes = {
|
|
|
// Custom edge that routes around nodes for cleaner layout
|
|
// Custom edge that routes around nodes for cleaner layout
|
|
|
// For "false" and "done" edges, route further to the right before going down
|
|
// For "false" and "done" edges, route further to the right before going down
|
|
|
// For back-edges (target above source), route to the left
|
|
// For back-edges (target above source), route to the left
|
|
|
|
|
+type Box = { x: number; y: number; w: number; h: number }
|
|
|
|
|
+
|
|
|
|
|
+// Does a straight run from a to b pass through this box? Used to decide where a
|
|
|
|
|
+// line is drawn over a node, and only there.
|
|
|
|
|
+function segmentHitsBox(ax: number, ay: number, bx: number, by: number, b: Box): boolean {
|
|
|
|
|
+ // Both ends of every segment here are horizontal or vertical, which is all
|
|
|
|
|
+ // the routes below produce, so this stays a range check rather than a full
|
|
|
|
|
+ // line-rectangle clip.
|
|
|
|
|
+ const lo = (p: number, q: number) => Math.min(p, q)
|
|
|
|
|
+ const hi = (p: number, q: number) => Math.max(p, q)
|
|
|
|
|
+ return (
|
|
|
|
|
+ hi(ax, bx) >= b.x && lo(ax, bx) <= b.x + b.w &&
|
|
|
|
|
+ hi(ay, by) >= b.y && lo(ay, by) <= b.y + b.h
|
|
|
|
|
+ )
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
function SmartEdge({
|
|
function SmartEdge({
|
|
|
id,
|
|
id,
|
|
|
sourceX,
|
|
sourceX,
|
|
@@ -89,7 +107,28 @@ function SmartEdge({
|
|
|
markerEnd,
|
|
markerEnd,
|
|
|
data,
|
|
data,
|
|
|
label,
|
|
label,
|
|
|
|
|
+ selected,
|
|
|
}: EdgeProps) {
|
|
}: EdgeProps) {
|
|
|
|
|
+ // Every node's box, so the line can be cut where it would otherwise be drawn
|
|
|
|
|
+ // straight across one. Read from the store rather than passed in, because an
|
|
|
|
|
+ // edge is rendered independently of the nodes it flies over.
|
|
|
|
|
+ const nodeBoxes = useStore(
|
|
|
|
|
+ (store) => {
|
|
|
|
|
+ const boxes: Array<{ x: number; y: number; w: number; h: number }> = []
|
|
|
|
|
+ store.nodeInternals.forEach((n) => {
|
|
|
|
|
+ const p = n.positionAbsolute || n.position
|
|
|
|
|
+ if (!p) return
|
|
|
|
|
+ boxes.push({ x: p.x, y: p.y, w: n.width || 220, h: n.height || 80 })
|
|
|
|
|
+ })
|
|
|
|
|
+ return boxes
|
|
|
|
|
+ },
|
|
|
|
|
+ // Compared by value: the selector builds a new array each time, and
|
|
|
|
|
+ // ReactFlow's default identity check would re-render every edge on every
|
|
|
|
|
+ // store touch.
|
|
|
|
|
+ (a, b) =>
|
|
|
|
|
+ a.length === b.length &&
|
|
|
|
|
+ a.every((box, i) => box.x === b[i].x && box.y === b[i].y && box.w === b[i].w && box.h === b[i].h)
|
|
|
|
|
+ )
|
|
|
const handleType = data?.handleType || 'main'
|
|
const handleType = data?.handleType || 'main'
|
|
|
const isBackEdge = targetY < sourceY - 10 // Target is above source
|
|
const isBackEdge = targetY < sourceY - 10 // Target is above source
|
|
|
const isSideEdge = handleType === 'false' || handleType === 'done'
|
|
const isSideEdge = handleType === 'false' || handleType === 'done'
|
|
@@ -141,9 +180,111 @@ function SmartEdge({
|
|
|
labelY = ly
|
|
labelY = ly
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ // Only the boxes this edge could actually run into. Its own two ends are
|
|
|
|
|
+ // excluded by the padding: a line has to touch the node it starts and finishes
|
|
|
|
|
+ // at, and cutting it there would leave it floating.
|
|
|
|
|
+ const pad = 6
|
|
|
|
|
+ // The route as a list of corners, so what gets cut away is what the line
|
|
|
|
|
+ // actually covers. A bounding box would swallow the whole middle of the
|
|
|
|
|
+ // canvas for a back edge that in fact goes nowhere near it.
|
|
|
|
|
+ const corners: Array<[number, number]> = isBackEdge
|
|
|
|
|
+ ? [
|
|
|
|
|
+ [sourceX, sourceY], [sourceX, sourceY + 15],
|
|
|
|
|
+ [Math.min(sourceX, targetX) - 120, sourceY + 15],
|
|
|
|
|
+ [Math.min(sourceX, targetX) - 120, targetY - 15],
|
|
|
|
|
+ [targetX, targetY - 15], [targetX, targetY],
|
|
|
|
|
+ ]
|
|
|
|
|
+ : isSideEdge && isGoingDown
|
|
|
|
|
+ ? [
|
|
|
|
|
+ [sourceX, sourceY], [sourceX, sourceY + 25],
|
|
|
|
|
+ [Math.max(sourceX, targetX) + 130, sourceY + 25],
|
|
|
|
|
+ [Math.max(sourceX, targetX) + 130, targetY - 25],
|
|
|
|
|
+ [targetX, targetY - 25], [targetX, targetY],
|
|
|
|
|
+ ]
|
|
|
|
|
+ : [
|
|
|
|
|
+ // What smoothstep draws: down, across at the midpoint, down again.
|
|
|
|
|
+ [sourceX, sourceY],
|
|
|
|
|
+ [sourceX, (sourceY + targetY) / 2],
|
|
|
|
|
+ [targetX, (sourceY + targetY) / 2],
|
|
|
|
|
+ [targetX, targetY],
|
|
|
|
|
+ ]
|
|
|
|
|
+
|
|
|
|
|
+ // The two nodes this edge belongs to are never cut. A line has to reach the
|
|
|
|
|
+ // box it starts and ends at, and taking those stretches away would leave it
|
|
|
|
|
+ // floating with no visible source - the opposite of the point.
|
|
|
|
|
+ const holds = (b: Box, x: number, y: number) =>
|
|
|
|
|
+ x >= b.x - 4 && x <= b.x + b.w + 4 && y >= b.y - 4 && y <= b.y + b.h + 4
|
|
|
|
|
+
|
|
|
|
|
+ const inTheWay = nodeBoxes.filter((b) => {
|
|
|
|
|
+ if (holds(b, sourceX, sourceY) || holds(b, targetX, targetY)) return false
|
|
|
|
|
+ for (let i = 1; i < corners.length; i++) {
|
|
|
|
|
+ if (segmentHitsBox(corners[i - 1][0], corners[i - 1][1], corners[i][0], corners[i][1], b)) {
|
|
|
|
|
+ return true
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ return false
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ const minX = Math.min(...corners.map((c) => c[0]))
|
|
|
|
|
+ const maxX = Math.max(...corners.map((c) => c[0]))
|
|
|
|
|
+ const minY = Math.min(...corners.map((c) => c[1]))
|
|
|
|
|
+ const maxY = Math.max(...corners.map((c) => c[1]))
|
|
|
|
|
+
|
|
|
|
|
+ const maskId = `sb-cut-${id}`
|
|
|
|
|
+ const path = (
|
|
|
|
|
+ <BaseEdge
|
|
|
|
|
+ id={id}
|
|
|
|
|
+ path={edgePath}
|
|
|
|
|
+ style={style}
|
|
|
|
|
+ markerEnd={markerEnd}
|
|
|
|
|
+ // Cut away wherever the line would be drawn across a node. Hiding it
|
|
|
|
|
+ // there rather than routing around keeps the route honest - the line
|
|
|
|
|
+ // still says where it goes, it just does not scribble over the boxes.
|
|
|
|
|
+ interactionWidth={20}
|
|
|
|
|
+ {...(inTheWay.length > 0 ? { mask: `url(#${maskId})` } : {})}
|
|
|
|
|
+ />
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
return (
|
|
return (
|
|
|
<>
|
|
<>
|
|
|
- <BaseEdge id={id} path={edgePath} style={style} markerEnd={markerEnd} />
|
|
|
|
|
|
|
+ {inTheWay.length > 0 && (
|
|
|
|
|
+ <defs>
|
|
|
|
|
+ <mask id={maskId} maskUnits="userSpaceOnUse">
|
|
|
|
|
+ <rect
|
|
|
|
|
+ x={minX - 400}
|
|
|
|
|
+ y={minY - 400}
|
|
|
|
|
+ width={maxX - minX + 800}
|
|
|
|
|
+ height={maxY - minY + 800}
|
|
|
|
|
+ fill="white"
|
|
|
|
|
+ />
|
|
|
|
|
+ {inTheWay.map((b, i) => (
|
|
|
|
|
+ <rect
|
|
|
|
|
+ key={i}
|
|
|
|
|
+ x={b.x - pad}
|
|
|
|
|
+ y={b.y - pad}
|
|
|
|
|
+ width={b.w + pad * 2}
|
|
|
|
|
+ height={b.h + pad * 2}
|
|
|
|
|
+ rx={10}
|
|
|
|
|
+ fill="black"
|
|
|
|
|
+ />
|
|
|
|
|
+ ))}
|
|
|
|
|
+ </mask>
|
|
|
|
|
+ </defs>
|
|
|
|
|
+ )}
|
|
|
|
|
+ {/* A pale backing under the chosen line, so it still reads as one line
|
|
|
|
|
+ where it runs through a bundle of others. */}
|
|
|
|
|
+ {selected && (
|
|
|
|
|
+ <path
|
|
|
|
|
+ d={edgePath}
|
|
|
|
|
+ fill="none"
|
|
|
|
|
+ stroke="#0ea5e9"
|
|
|
|
|
+ strokeOpacity={0.25}
|
|
|
|
|
+ strokeWidth={12}
|
|
|
|
|
+ strokeLinecap="round"
|
|
|
|
|
+ {...(inTheWay.length > 0 ? { mask: `url(#${maskId})` } : {})}
|
|
|
|
|
+ />
|
|
|
|
|
+ )}
|
|
|
|
|
+ {path}
|
|
|
{label && (
|
|
{label && (
|
|
|
<EdgeLabelRenderer>
|
|
<EdgeLabelRenderer>
|
|
|
<div
|
|
<div
|
|
@@ -162,6 +303,31 @@ function SmartEdge({
|
|
|
)
|
|
)
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+// What a connection looks like, decided by the port it leaves from. Shared so
|
|
|
|
|
+// an edge made by dragging, by dropping on empty canvas, or by pasting is the
|
|
|
|
|
+// same edge.
|
|
|
|
|
+function edgeColourFor(sourceHandle: string): string {
|
|
|
|
|
+ if (sourceHandle === 'true' || sourceHandle === 'loop') return '#22c55e'
|
|
|
|
|
+ if (sourceHandle === 'false') return '#ef4444'
|
|
|
|
|
+ if (sourceHandle === 'done') return '#3b82f6'
|
|
|
|
|
+ return '#cbd5e1'
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function styleConnection(params: Connection | { source: string; sourceHandle: string | null; target: string; targetHandle: string | null }) {
|
|
|
|
|
+ const sourceHandle = params.sourceHandle || 'main'
|
|
|
|
|
+ const colour = edgeColourFor(sourceHandle)
|
|
|
|
|
+ return {
|
|
|
|
|
+ ...params,
|
|
|
|
|
+ type: 'smart',
|
|
|
|
|
+ style: { strokeWidth: 2, stroke: colour },
|
|
|
|
|
+ label: sourceHandle !== 'main' ? sourceHandle : undefined,
|
|
|
|
|
+ labelStyle: { fill: colour, fontWeight: 500, fontSize: 10 },
|
|
|
|
|
+ labelBgStyle: { fill: 'var(--color-bg)', fillOpacity: 0.9 },
|
|
|
|
|
+ data: { handleType: sourceHandle },
|
|
|
|
|
+ zIndex: 1,
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
const edgeTypes = {
|
|
const edgeTypes = {
|
|
|
smart: SmartEdge,
|
|
smart: SmartEdge,
|
|
|
}
|
|
}
|
|
@@ -190,6 +356,19 @@ function WorkflowEditorInner() {
|
|
|
const [showDeleteConfirm, setShowDeleteConfirm] = useState<string | null>(null)
|
|
const [showDeleteConfirm, setShowDeleteConfirm] = useState<string | null>(null)
|
|
|
const [toasts, setToasts] = useState<ToastMessage[]>([])
|
|
const [toasts, setToasts] = useState<ToastMessage[]>([])
|
|
|
const toastIdRef = useRef(0)
|
|
const toastIdRef = useRef(0)
|
|
|
|
|
+ // The canvas element, needed to turn a screen position into a flow position.
|
|
|
|
|
+ const flowWrapperRef = useRef<HTMLDivElement>(null)
|
|
|
|
|
+ // Where the pointer sits, so a paste lands under it rather than on top of
|
|
|
|
|
+ // what was copied.
|
|
|
|
|
+ const pointerRef = useRef<{ x: number; y: number } | null>(null)
|
|
|
|
|
+ // The port a connection is being dragged from. Kept in a ref because
|
|
|
|
|
+ // onConnectEnd fires outside React's event flow and would read stale state.
|
|
|
|
|
+ const connectingFromRef = useRef<{ nodeId: string; handleId: string | null } | null>(null)
|
|
|
|
|
+ // Set when a connection was dropped on empty canvas: the picker opens, and
|
|
|
|
|
+ // whatever is chosen is placed here and wired to the port it came from.
|
|
|
|
|
+ const [connectDrop, setConnectDrop] = useState<
|
|
|
|
|
+ { from: { nodeId: string; handleId: string | null }; at: { x: number; y: number } } | null
|
|
|
|
|
+ >(null)
|
|
|
|
|
|
|
|
// Execution state
|
|
// Execution state
|
|
|
const [executionState, setExecutionState] = useState<ExecutionState>({
|
|
const [executionState, setExecutionState] = useState<ExecutionState>({
|
|
@@ -1173,6 +1352,10 @@ function WorkflowEditorInner() {
|
|
|
return {
|
|
return {
|
|
|
...e,
|
|
...e,
|
|
|
selected: isSelected,
|
|
selected: isSelected,
|
|
|
|
|
+ // Lifted above the other lines. Picking one out of a bundle is
|
|
|
|
|
+ // pointless if the ones it runs under keep covering it, which is
|
|
|
|
|
+ // exactly where following it matters most.
|
|
|
|
|
+ zIndex: isSelected ? 20 : 1,
|
|
|
style: {
|
|
style: {
|
|
|
...e.style,
|
|
...e.style,
|
|
|
strokeWidth: isSelected ? 3 : 2,
|
|
strokeWidth: isSelected ? 3 : 2,
|
|
@@ -1189,32 +1372,40 @@ function WorkflowEditorInner() {
|
|
|
|
|
|
|
|
const onConnect = useCallback(
|
|
const onConnect = useCallback(
|
|
|
(params: Connection) => {
|
|
(params: Connection) => {
|
|
|
- let edgeColor = '#cbd5e1' // Lighter default for better visibility
|
|
|
|
|
- const sourceHandle = params.sourceHandle || 'main'
|
|
|
|
|
- if (sourceHandle === 'true' || sourceHandle === 'loop') {
|
|
|
|
|
- edgeColor = '#22c55e'
|
|
|
|
|
- } else if (sourceHandle === 'false') {
|
|
|
|
|
- edgeColor = '#ef4444'
|
|
|
|
|
- } else if (sourceHandle === 'done') {
|
|
|
|
|
- edgeColor = '#3b82f6'
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- const styledEdge = {
|
|
|
|
|
- ...params,
|
|
|
|
|
- type: 'smart',
|
|
|
|
|
- style: { strokeWidth: 2, stroke: edgeColor },
|
|
|
|
|
- label: sourceHandle !== 'main' ? sourceHandle : undefined,
|
|
|
|
|
- labelStyle: { fill: edgeColor, fontWeight: 500, fontSize: 10 },
|
|
|
|
|
- labelBgStyle: { fill: 'var(--color-bg)', fillOpacity: 0.9 },
|
|
|
|
|
- data: { handleType: sourceHandle },
|
|
|
|
|
- zIndex: 1,
|
|
|
|
|
- }
|
|
|
|
|
- setEdges((eds) => addEdge(styledEdge, eds))
|
|
|
|
|
|
|
+ setEdges((eds) => addEdge(styleConnection(params), eds))
|
|
|
setHasChanges(true)
|
|
setHasChanges(true)
|
|
|
},
|
|
},
|
|
|
[setEdges]
|
|
[setEdges]
|
|
|
)
|
|
)
|
|
|
|
|
|
|
|
|
|
+ // Remember which port a drag started from. onConnectEnd is a DOM event and
|
|
|
|
|
+ // does not carry it.
|
|
|
|
|
+ const onConnectStart = useCallback((_e: any, params: { nodeId: string | null; handleId: string | null; handleType: string | null }) => {
|
|
|
|
|
+ connectingFromRef.current =
|
|
|
|
|
+ params.nodeId && params.handleType === 'source'
|
|
|
|
|
+ ? { nodeId: params.nodeId, handleId: params.handleId }
|
|
|
|
|
+ : null
|
|
|
|
|
+ }, [])
|
|
|
|
|
+
|
|
|
|
|
+ // A link let go over blank canvas means "and then what?" - so offer the list,
|
|
|
|
|
+ // and wire up whatever is chosen. Dropped on a node, ReactFlow has already
|
|
|
|
|
+ // made the connection and there is nothing to ask.
|
|
|
|
|
+ const onConnectEnd = useCallback((event: MouseEvent | TouchEvent) => {
|
|
|
|
|
+ const from = connectingFromRef.current
|
|
|
|
|
+ connectingFromRef.current = null
|
|
|
|
|
+ if (!from || isViewingExecution) return
|
|
|
|
|
+
|
|
|
|
|
+ const target = event.target as HTMLElement | null
|
|
|
|
|
+ if (!target?.classList?.contains('react-flow__pane')) return
|
|
|
|
|
+
|
|
|
|
|
+ const point = 'changedTouches' in event
|
|
|
|
|
+ ? { x: event.changedTouches[0].clientX, y: event.changedTouches[0].clientY }
|
|
|
|
|
+ : { x: (event as MouseEvent).clientX, y: (event as MouseEvent).clientY }
|
|
|
|
|
+
|
|
|
|
|
+ setConnectDrop({ from, at: reactFlowInstance.screenToFlowPosition(point) })
|
|
|
|
|
+ setShowNodePicker(true)
|
|
|
|
|
+ }, [isViewingExecution, reactFlowInstance])
|
|
|
|
|
+
|
|
|
const handleSave = useCallback(() => {
|
|
const handleSave = useCallback(() => {
|
|
|
const workflowNodes = nodes.map((node) => ({
|
|
const workflowNodes = nodes.map((node) => ({
|
|
|
id: node.id,
|
|
id: node.id,
|
|
@@ -1286,6 +1477,118 @@ function WorkflowEditorInner() {
|
|
|
setContextMenu(null)
|
|
setContextMenu(null)
|
|
|
}, [nodes])
|
|
}, [nodes])
|
|
|
|
|
|
|
|
|
|
+ // Build a canvas node from a definition. Shared by adding, dropping and
|
|
|
|
|
+ // pasting, so a node arrives the same way however it got here.
|
|
|
|
|
+ const makeNode = useCallback((
|
|
|
|
|
+ nodeType: string,
|
|
|
|
|
+ nodeDef: NodeDefinition | undefined,
|
|
|
|
|
+ position: { x: number; y: number },
|
|
|
|
|
+ seed?: { id?: string; name?: string; config?: Record<string, any>; disabled?: boolean }
|
|
|
|
|
+ ): Node => {
|
|
|
|
|
+ const outputs: NodeOutput[] = nodeDef?.outputs?.length
|
|
|
|
|
+ ? nodeDef.outputs
|
|
|
|
|
+ : [{ name: 'main', displayName: 'Output', type: 'any' }]
|
|
|
|
|
+ const nodeId = seed?.id || `node_${Date.now()}_${Math.floor(Math.random() * 100000)}`
|
|
|
|
|
+ return {
|
|
|
|
|
+ id: nodeId,
|
|
|
|
|
+ type: 'workflowNode',
|
|
|
|
|
+ position,
|
|
|
|
|
+ data: {
|
|
|
|
|
+ label: seed?.name || nodeDef?.name || nodeType,
|
|
|
|
|
+ type: nodeType,
|
|
|
|
|
+ config: seed?.config ? { ...seed.config } : {},
|
|
|
|
|
+ disabled: seed?.disabled === true,
|
|
|
|
|
+ isTrigger: nodeDef?.isTrigger,
|
|
|
|
|
+ icon: nodeDef?.icon,
|
|
|
|
|
+ outputs,
|
|
|
|
|
+ dynamicOutputs: nodeDef?.dynamicOutputs,
|
|
|
|
|
+ inputs: nodeDef?.inputs,
|
|
|
|
|
+ nodeId,
|
|
|
|
|
+ onExecute: () => executeWorkflow(),
|
|
|
|
|
+ onExecuteTrigger: executeTrigger,
|
|
|
|
|
+ },
|
|
|
|
|
+ }
|
|
|
|
|
+ }, [executeTrigger])
|
|
|
|
|
+
|
|
|
|
|
+ // The middle of what the user is currently looking at. A node dropped at a
|
|
|
|
|
+ // fixed spot lands off screen as soon as anyone has panned away, which is
|
|
|
|
|
+ // indistinguishable from nothing having happened.
|
|
|
|
|
+ const centreOfView = useCallback(() => {
|
|
|
|
|
+ const bounds = flowWrapperRef.current?.getBoundingClientRect()
|
|
|
|
|
+ if (!bounds) return { x: 250, y: 250 }
|
|
|
|
|
+ return reactFlowInstance.screenToFlowPosition({
|
|
|
|
|
+ x: bounds.x + bounds.width / 2,
|
|
|
|
|
+ y: bounds.y + bounds.height / 2,
|
|
|
|
|
+ })
|
|
|
|
|
+ }, [reactFlowInstance])
|
|
|
|
|
+
|
|
|
|
|
+ // Paste whatever was last copied, here or in another workflow. Everything
|
|
|
|
|
+ // gets a fresh id, so pasting into the workflow it came from does not collide
|
|
|
|
|
+ // with the originals, and the connections between the copied nodes are
|
|
|
|
|
+ // rebuilt against the new ids.
|
|
|
|
|
+ const pasteNodes = useCallback((landAt: { x: number; y: number } | null) => {
|
|
|
|
|
+ const payload = readNodeClipboard()
|
|
|
|
|
+ if (!payload) return
|
|
|
|
|
+
|
|
|
|
|
+ const { dx, dy } = offsetFor(payload, landAt)
|
|
|
|
|
+ const idMap = new Map<string, string>()
|
|
|
|
|
+ const stamp = Date.now()
|
|
|
|
|
+
|
|
|
|
|
+ const pasted = payload.nodes.map((n, index) => {
|
|
|
|
|
+ const newId = `node_${stamp}_${index}`
|
|
|
|
|
+ idMap.set(n.id, newId)
|
|
|
|
|
+ return {
|
|
|
|
|
+ ...makeNode(n.type, nodeDefsMap[n.type], {
|
|
|
|
|
+ x: Math.round(n.position.x + dx),
|
|
|
|
|
+ y: Math.round(n.position.y + dy),
|
|
|
|
|
+ }, { id: newId, name: n.name, config: n.config, disabled: n.disabled }),
|
|
|
|
|
+ selected: true,
|
|
|
|
|
+ }
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ const pastedEdges = payload.edges
|
|
|
|
|
+ .filter((e) => idMap.has(e.source) && idMap.has(e.target))
|
|
|
|
|
+ .map((e, index) => ({
|
|
|
|
|
+ ...styleConnection({
|
|
|
|
|
+ source: idMap.get(e.source)!,
|
|
|
|
|
+ sourceHandle: e.sourceHandle,
|
|
|
|
|
+ target: idMap.get(e.target)!,
|
|
|
|
|
+ targetHandle: e.targetHandle,
|
|
|
|
|
+ }),
|
|
|
|
|
+ id: `edge_${stamp}_${index}`,
|
|
|
|
|
+ })) as Edge[]
|
|
|
|
|
+
|
|
|
|
|
+ // The originals lose their selection, so the pasted group is what a
|
|
|
|
|
+ // follow-up drag or delete acts on.
|
|
|
|
|
+ setNodes((nds) => [...nds.map((n) => (n.selected ? { ...n, selected: false } : n)), ...pasted])
|
|
|
|
|
+ if (pastedEdges.length > 0) setEdges((eds) => [...eds, ...pastedEdges])
|
|
|
|
|
+ setHasChanges(true)
|
|
|
|
|
+
|
|
|
|
|
+ const unknown = payload.nodes.filter((n) => !nodeDefsMap[n.type]).map((n) => n.type)
|
|
|
|
|
+ if (unknown.length > 0) {
|
|
|
|
|
+ // Pasting into an installation without that node would otherwise leave a
|
|
|
|
|
+ // node that looks fine and cannot run.
|
|
|
|
|
+ showToast('error', `Pasted ${pasted.length} node(s), but ${[...new Set(unknown)].join(', ')} is not installed here`)
|
|
|
|
|
+ } else {
|
|
|
|
|
+ showToast('success', `Pasted ${pasted.length} node(s)`)
|
|
|
|
|
+ }
|
|
|
|
|
+ }, [makeNode, nodeDefsMap, setNodes, setEdges, showToast])
|
|
|
|
|
+
|
|
|
|
|
+ const copySelection = useCallback((alsoCut: boolean) => {
|
|
|
|
|
+ const count = copyNodesToClipboard(nodes, edges)
|
|
|
|
|
+ if (count === 0) {
|
|
|
|
|
+ showToast('error', 'Select the nodes you want first')
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+ if (alsoCut) {
|
|
|
|
|
+ const ids = new Set(nodes.filter((n) => n.selected).map((n) => n.id))
|
|
|
|
|
+ setNodes((nds) => nds.filter((n) => !ids.has(n.id)))
|
|
|
|
|
+ setEdges((eds) => eds.filter((e) => !ids.has(e.source) && !ids.has(e.target)))
|
|
|
|
|
+ setHasChanges(true)
|
|
|
|
|
+ }
|
|
|
|
|
+ showToast('success', `${alsoCut ? 'Cut' : 'Copied'} ${count} node(s)`)
|
|
|
|
|
+ }, [nodes, edges, setNodes, setEdges, showToast])
|
|
|
|
|
+
|
|
|
// Keyboard shortcuts
|
|
// Keyboard shortcuts
|
|
|
useEffect(() => {
|
|
useEffect(() => {
|
|
|
const handleKeyDown = (e: KeyboardEvent) => {
|
|
const handleKeyDown = (e: KeyboardEvent) => {
|
|
@@ -1297,7 +1600,33 @@ function WorkflowEditorInner() {
|
|
|
return
|
|
return
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- if (showNodeConfig || showNodePicker || showDeleteConfirm) return
|
|
|
|
|
|
|
+ if (showNodeConfig || showDeleteConfirm) return
|
|
|
|
|
+
|
|
|
|
|
+ // Not while someone is typing - a search box in the picker is still a
|
|
|
|
|
+ // place where ctrl+c means copy this text.
|
|
|
|
|
+ const el = e.target as HTMLElement | null
|
|
|
|
|
+ const typing = !!el && (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' || el.isContentEditable)
|
|
|
|
|
+
|
|
|
|
|
+ if ((e.ctrlKey || e.metaKey) && !typing && !isViewingExecution) {
|
|
|
|
|
+ const key = e.key.toLowerCase()
|
|
|
|
|
+ if (key === 'c') { e.preventDefault(); copySelection(false); return }
|
|
|
|
|
+ if (key === 'x') { e.preventDefault(); copySelection(true); return }
|
|
|
|
|
+ if (key === 'v') { e.preventDefault(); pasteNodes(pointerRef.current); return }
|
|
|
|
|
+ if (key === 'd') {
|
|
|
|
|
+ // Duplicate is copy and paste in one press, and leaves the clipboard
|
|
|
|
|
+ // holding what was duplicated - which is what every editor does.
|
|
|
|
|
+ e.preventDefault()
|
|
|
|
|
+ if (copyNodesToClipboard(nodes, edges) > 0) pasteNodes(null)
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+ if (key === 'a') {
|
|
|
|
|
+ e.preventDefault()
|
|
|
|
|
+ setNodes((nds) => nds.map((n) => ({ ...n, selected: true })))
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if (showNodePicker) return
|
|
|
|
|
|
|
|
if (e.key === 'Delete') {
|
|
if (e.key === 'Delete') {
|
|
|
e.preventDefault()
|
|
e.preventDefault()
|
|
@@ -1340,7 +1669,7 @@ function WorkflowEditorInner() {
|
|
|
|
|
|
|
|
window.addEventListener('keydown', handleKeyDown)
|
|
window.addEventListener('keydown', handleKeyDown)
|
|
|
return () => window.removeEventListener('keydown', handleKeyDown)
|
|
return () => window.removeEventListener('keydown', handleKeyDown)
|
|
|
- }, [hasChanges, saveMutation.isPending, handleSave, focusedNodeId, selectedEdgeId, showNodeConfig, showNodePicker, showDeleteConfirm, deleteEdge, openNodeConfig, nodes, setNodes, setEdges])
|
|
|
|
|
|
|
+ }, [hasChanges, saveMutation.isPending, handleSave, focusedNodeId, selectedEdgeId, showNodeConfig, showNodePicker, showDeleteConfirm, deleteEdge, openNodeConfig, nodes, edges, setNodes, setEdges, copySelection, pasteNodes, isViewingExecution])
|
|
|
|
|
|
|
|
// Navigate to connected node based on arrow direction
|
|
// Navigate to connected node based on arrow direction
|
|
|
// Handles nodes with multiple outputs (IF: true/false, Loop: loop/done)
|
|
// Handles nodes with multiple outputs (IF: true/false, Loop: loop/done)
|
|
@@ -1401,34 +1730,49 @@ function WorkflowEditorInner() {
|
|
|
}
|
|
}
|
|
|
}, [focusedNodeId, nodes, edges, reactFlowInstance])
|
|
}, [focusedNodeId, nodes, edges, reactFlowInstance])
|
|
|
|
|
|
|
|
- const addNode = (nodeType: string, nodeDef: NodeDefinition) => {
|
|
|
|
|
- const outputs: NodeOutput[] = nodeDef.outputs?.length
|
|
|
|
|
- ? nodeDef.outputs
|
|
|
|
|
- : [{ name: 'main', displayName: 'Output', type: 'any' }]
|
|
|
|
|
- const nodeId = `node_${Date.now()}`
|
|
|
|
|
- const newNode: Node = {
|
|
|
|
|
- id: nodeId,
|
|
|
|
|
- type: 'workflowNode',
|
|
|
|
|
- position: { x: 250, y: nodes.length * 100 },
|
|
|
|
|
- data: {
|
|
|
|
|
- label: nodeDef.name,
|
|
|
|
|
- type: nodeType,
|
|
|
|
|
- config: {},
|
|
|
|
|
- isTrigger: nodeDef.isTrigger,
|
|
|
|
|
- icon: nodeDef.icon,
|
|
|
|
|
- outputs,
|
|
|
|
|
- dynamicOutputs: nodeDef.dynamicOutputs,
|
|
|
|
|
- inputs: nodeDef.inputs,
|
|
|
|
|
- nodeId,
|
|
|
|
|
- onExecute: () => executeWorkflow(),
|
|
|
|
|
- onExecuteTrigger: executeTrigger,
|
|
|
|
|
- },
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ const addNode = (nodeType: string, nodeDef: NodeDefinition, at?: { x: number; y: number }) => {
|
|
|
|
|
+ const centre = at || centreOfView()
|
|
|
|
|
+ // Placed by its middle rather than its corner, so it lands where the eye is.
|
|
|
|
|
+ const position = { x: Math.round(centre.x - 110), y: Math.round(centre.y - 40) }
|
|
|
|
|
+ const newNode = makeNode(nodeType, nodeDef, position)
|
|
|
setNodes((nds) => [...nds, newNode])
|
|
setNodes((nds) => [...nds, newNode])
|
|
|
|
|
+
|
|
|
|
|
+ // Chosen because a link was let go here: connect it to the port that link
|
|
|
|
|
+ // came from, which is the whole reason the list opened.
|
|
|
|
|
+ if (connectDrop) {
|
|
|
|
|
+ const targetHandle = nodeDef.inputs?.[0]?.name || 'data'
|
|
|
|
|
+ setEdges((eds) => addEdge(styleConnection({
|
|
|
|
|
+ source: connectDrop.from.nodeId,
|
|
|
|
|
+ sourceHandle: connectDrop.from.handleId,
|
|
|
|
|
+ target: newNode.id,
|
|
|
|
|
+ targetHandle,
|
|
|
|
|
+ }), eds))
|
|
|
|
|
+ setConnectDrop(null)
|
|
|
|
|
+ setShowNodePicker(false)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
setHasChanges(true)
|
|
setHasChanges(true)
|
|
|
- setShowNodePicker(false)
|
|
|
|
|
|
|
+ // Otherwise the picker stays open: adding one node is usually the first of
|
|
|
|
|
+ // several, and reopening it for each is the annoying part.
|
|
|
|
|
+ return newNode
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ // Dropping a node from the list onto the canvas puts it where it was let go.
|
|
|
|
|
+ const onCanvasDragOver = useCallback((event: React.DragEvent) => {
|
|
|
|
|
+ event.preventDefault()
|
|
|
|
|
+ event.dataTransfer.dropEffect = 'move'
|
|
|
|
|
+ }, [])
|
|
|
|
|
+
|
|
|
|
|
+ const onCanvasDrop = useCallback((event: React.DragEvent) => {
|
|
|
|
|
+ event.preventDefault()
|
|
|
|
|
+ if (isViewingExecution) return
|
|
|
|
|
+ const nodeType = event.dataTransfer.getData('application/smartbotic-node')
|
|
|
|
|
+ if (!nodeType) return
|
|
|
|
|
+ const nodeDef = nodeDefsMap[nodeType]
|
|
|
|
|
+ if (!nodeDef) return
|
|
|
|
|
+ addNode(nodeType, nodeDef, reactFlowInstance.screenToFlowPosition({ x: event.clientX, y: event.clientY }))
|
|
|
|
|
+ }, [isViewingExecution, nodeDefsMap, reactFlowInstance, connectDrop])
|
|
|
|
|
+
|
|
|
// Delete node
|
|
// Delete node
|
|
|
const deleteNode = useCallback((nodeId: string) => {
|
|
const deleteNode = useCallback((nodeId: string) => {
|
|
|
setNodes((nds) => nds.filter((n) => n.id !== nodeId))
|
|
setNodes((nds) => nds.filter((n) => n.id !== nodeId))
|
|
@@ -1707,6 +2051,38 @@ function WorkflowEditorInner() {
|
|
|
})
|
|
})
|
|
|
})
|
|
})
|
|
|
|
|
|
|
|
|
|
+ // A Configurator does not sit in the flow - it hangs off the side of the
|
|
|
|
|
+ // nodes it configures. Dagre has no idea about that and drops it in the
|
|
|
|
|
+ // column like anything else, so it is moved out to the left of whatever it
|
|
|
|
|
+ // feeds, level with the topmost one.
|
|
|
|
|
+ const CONFIG_GAP = NODE_WIDTH + 80
|
|
|
|
|
+ nodes.filter(n => n.data.type === 'configurator').forEach(cfg => {
|
|
|
|
|
+ const targets = edges
|
|
|
|
|
+ .filter(e => e.source === cfg.id && (e.targetHandle || 'data') === 'config')
|
|
|
|
|
+ .map(e => newPositions.get(e.target))
|
|
|
|
|
+ .filter((p): p is { x: number; y: number } => !!p)
|
|
|
|
|
+ if (targets.length === 0) return
|
|
|
|
|
+ const leftmost = Math.min(...targets.map(p => p.x))
|
|
|
|
|
+ const topmost = Math.min(...targets.map(p => p.y))
|
|
|
|
|
+ newPositions.set(cfg.id, {
|
|
|
|
|
+ x: snapToGrid(leftmost - CONFIG_GAP),
|
|
|
|
|
+ y: snapToGrid(topmost),
|
|
|
|
|
+ })
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ // Two Configurators that ended up on the same spot would hide one another.
|
|
|
|
|
+ const takenColumns = new Map<string, number>()
|
|
|
|
|
+ nodes.filter(n => n.data.type === 'configurator').forEach(cfg => {
|
|
|
|
|
+ const pos = newPositions.get(cfg.id)
|
|
|
|
|
+ if (!pos) return
|
|
|
|
|
+ const key = `${pos.x}:${pos.y}`
|
|
|
|
|
+ const seen = takenColumns.get(key) || 0
|
|
|
|
|
+ if (seen > 0) {
|
|
|
|
|
+ newPositions.set(cfg.id, { x: pos.x, y: snapToGrid(pos.y + seen * (NODE_HEIGHT + 40)) })
|
|
|
|
|
+ }
|
|
|
|
|
+ takenColumns.set(key, seen + 1)
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
// Check if any positions actually changed
|
|
// Check if any positions actually changed
|
|
|
const positionsChanged = nodes.some(n => {
|
|
const positionsChanged = nodes.some(n => {
|
|
|
const newPos = newPositions.get(n.id)
|
|
const newPos = newPositions.get(n.id)
|
|
@@ -2250,7 +2626,11 @@ function WorkflowEditorInner() {
|
|
|
<NodePickerSidebar
|
|
<NodePickerSidebar
|
|
|
nodeDefs={nodeDefs}
|
|
nodeDefs={nodeDefs}
|
|
|
onAddNode={addNode}
|
|
onAddNode={addNode}
|
|
|
- onClose={() => setShowNodePicker(false)}
|
|
|
|
|
|
|
+ connectingFrom={connectDrop ? nodes.find((n) => n.id === connectDrop.from.nodeId)?.data?.label || 'the previous node' : null}
|
|
|
|
|
+ onClose={() => {
|
|
|
|
|
+ setShowNodePicker(false)
|
|
|
|
|
+ setConnectDrop(null)
|
|
|
|
|
+ }}
|
|
|
/>
|
|
/>
|
|
|
)}
|
|
)}
|
|
|
|
|
|
|
@@ -2266,7 +2646,16 @@ function WorkflowEditorInner() {
|
|
|
)}
|
|
)}
|
|
|
|
|
|
|
|
{/* Flow canvas */}
|
|
{/* Flow canvas */}
|
|
|
- <div className={`flex-1 overflow-hidden ${(showExecutionPanel || isViewingExecution || pinnedExecution) ? 'w-2/3' : 'w-full'}`}>
|
|
|
|
|
|
|
+ <div
|
|
|
|
|
+ ref={flowWrapperRef}
|
|
|
|
|
+ onDragOver={onCanvasDragOver}
|
|
|
|
|
+ onDrop={onCanvasDrop}
|
|
|
|
|
+ onMouseMove={(e) => {
|
|
|
|
|
+ // Remembered so a paste lands under the pointer rather than on top
|
|
|
|
|
+ // of the nodes it was copied from.
|
|
|
|
|
+ pointerRef.current = reactFlowInstance.screenToFlowPosition({ x: e.clientX, y: e.clientY })
|
|
|
|
|
+ }}
|
|
|
|
|
+ className={`flex-1 overflow-hidden ${(showExecutionPanel || isViewingExecution || pinnedExecution) ? 'w-2/3' : 'w-full'}`}>
|
|
|
<ReactFlow
|
|
<ReactFlow
|
|
|
nodes={isViewingExecution && viewedNodes ? viewedNodes : nodes}
|
|
nodes={isViewingExecution && viewedNodes ? viewedNodes : nodes}
|
|
|
edges={isViewingExecution && viewedEdges ? viewedEdges : edges}
|
|
edges={isViewingExecution && viewedEdges ? viewedEdges : edges}
|
|
@@ -2314,6 +2703,8 @@ function WorkflowEditorInner() {
|
|
|
onEdgesChange(changes)
|
|
onEdgesChange(changes)
|
|
|
}}
|
|
}}
|
|
|
onConnect={isViewingExecution ? undefined : onConnect}
|
|
onConnect={isViewingExecution ? undefined : onConnect}
|
|
|
|
|
+ onConnectStart={isViewingExecution ? undefined : onConnectStart}
|
|
|
|
|
+ onConnectEnd={isViewingExecution ? undefined : onConnectEnd}
|
|
|
onNodeClick={onNodeClick}
|
|
onNodeClick={onNodeClick}
|
|
|
onNodeContextMenu={isViewingExecution ? undefined : onNodeContextMenu}
|
|
onNodeContextMenu={isViewingExecution ? undefined : onNodeContextMenu}
|
|
|
onNodeDoubleClick={isViewingExecution ? undefined : onNodeDoubleClick}
|
|
onNodeDoubleClick={isViewingExecution ? undefined : onNodeDoubleClick}
|