Răsfoiți Sursa

feat: editor gains copy and paste, drop-to-add, and readable edges

Copy and paste, including from one workflow into another. The clipboard is
in localStorage rather than component state, because the whole point is that
it survives leaving the page. Ctrl+C, Ctrl+X, Ctrl+V, Ctrl+D and Ctrl+A;
pasted nodes get fresh ids so pasting back into the same workflow does not
collide, and connections between copied nodes are rebuilt against the new
ids. Paste lands under the pointer, not on top of the originals. Pasting a
node type this installation does not have says so, rather than leaving a
node that looks fine and cannot run. None of it fires while the caret is in
a text box.

Letting a link go over blank canvas opens the node list, and whatever is
picked lands where it was dropped, already wired to the port the link came
from. The list says which node that is, so it is clear what is about to be
connected to what.

The node list stays open after a choice - adding one node is usually the
first of several. Its entries can be dragged onto the canvas and land where
they are let go. Categories start collapsed with a collapse/expand all
control: every category open was a wall of names to scroll past before
anything could be read, and the search box is focused already.

A node added by clicking now appears in the middle of what the user is
looking at. It used to go to a fixed spot, which is off screen as soon as
anyone has panned - indistinguishable from nothing having happened.

Auto-layout puts a Configurator to the left of the nodes it configures, and
staggers two that would land on each other. It does not sit in the flow, so
dagre dropping it in the column was never right.

Edges: a line is cut away where it would otherwise be drawn straight across
a node. Which stretch to cut comes from the actual route - the corners the
edge is drawn through - not a bounding box, which for a back edge routing
far to the left would have swallowed the whole middle of the canvas. The
two nodes an edge belongs to are never cut, or a line would float with no
visible source.

A selected edge is lifted above every other edge and given a pale backing,
so one picked out of a bundle can be followed end to end instead of
disappearing under the lines that cross it.

Verified in the bundled UI: copied a node in one workflow and pasted it into
another; dropped a link on empty canvas and confirmed the chosen node
arrived connected; dragged a node from the list onto the canvas; a clicked
node landed within 20px of the view centre; auto-layout moved the
Configurator 300px left of its leftmost target and level with the topmost;
6 of 26 edges masked, each cutting exactly the nodes its route crosses; the
selected edge moved to its own layer above the other 25.
fszontagh 1 lună în urmă
părinte
comite
fa96483910

+ 47 - 7
webui/src/components/workflow/NodePickerSidebar.tsx

@@ -1,15 +1,19 @@
 import { useState, useMemo } from 'react'
-import { X, Search, ChevronDown, ChevronRight, Plus } from 'lucide-react'
+import { X, Search, ChevronDown, ChevronRight, Plus, ChevronsDownUp, ChevronsUpDown, Link2, GripVertical } from 'lucide-react'
 import { NodeDefinition } from '../../api/workflows'
 import { getCategoryConfig, getSortedCategories } from '../../config/nodeCategories'
 
 interface NodePickerSidebarProps {
   nodeDefs: NodeDefinition[]
   onAddNode: (nodeType: string, nodeDef: NodeDefinition) => void
+  // Set when the list opened because a link was dropped on empty canvas: the
+  // name of the node that link came from, so it is clear what is about to be
+  // connected to what.
+  connectingFrom?: string | null
   onClose: () => void
 }
 
-export function NodePickerSidebar({ nodeDefs, onAddNode, onClose }: NodePickerSidebarProps) {
+export function NodePickerSidebar({ nodeDefs, onAddNode, connectingFrom, onClose }: NodePickerSidebarProps) {
   const [searchQuery, setSearchQuery] = useState('')
 
   // Group nodes by category
@@ -25,10 +29,10 @@ export function NodePickerSidebar({ nodeDefs, onAddNode, onClose }: NodePickerSi
     return grouped
   }, [nodeDefs])
 
-  // Initialize expanded categories from actual node categories (all expanded by default)
-  const [expandedCategories, setExpandedCategories] = useState<Set<string>>(() =>
-    new Set(Object.keys(nodesByCategory))
-  )
+  // Closed to begin with. Every category open is a long scroll before anything
+  // can be read, and the search box above is focused already for anyone who
+  // knows what they want.
+  const [expandedCategories, setExpandedCategories] = useState<Set<string>>(() => new Set())
 
   // Filter nodes based on search query
   const filteredNodesByCategory = useMemo(() => {
@@ -116,6 +120,34 @@ export function NodePickerSidebar({ nodeDefs, onAddNode, onClose }: NodePickerSi
         </div>
       </div>
 
+      {/* Expand or collapse the lot, and say what a choice will connect to */}
+      <div className="flex items-center justify-between px-4 py-2 border-b border-gray-200 dark:border-slate-700 shrink-0">
+        <button
+          onClick={() =>
+            setExpandedCategories((prev) =>
+              prev.size > 0 ? new Set() : new Set(Object.keys(nodesByCategory))
+            )
+          }
+          className="flex items-center gap-1.5 text-xs text-gray-600 dark:text-gray-400 hover:text-primary-600 dark:hover:text-primary-400"
+        >
+          {expandedCategories.size > 0 ? (
+            <><ChevronsDownUp className="w-3.5 h-3.5" /> Collapse all</>
+          ) : (
+            <><ChevronsUpDown className="w-3.5 h-3.5" /> Expand all</>
+          )}
+        </button>
+        <span className="text-[11px] text-gray-400 dark:text-gray-500">
+          click to add, or drag onto the canvas
+        </span>
+      </div>
+
+      {connectingFrom && (
+        <div className="flex items-start gap-1.5 px-4 py-2 text-[11px] text-primary-700 dark:text-primary-300 bg-primary-50 dark:bg-primary-900/20 border-b border-primary-200 dark:border-primary-800 shrink-0">
+          <Link2 className="w-3.5 h-3.5 shrink-0 mt-px" />
+          What you pick lands where you let go, connected to {connectingFrom}.
+        </div>
+      )}
+
       {/* Categories and nodes */}
       <div className="flex-1 overflow-y-auto">
         {filteredSortedCategories.length === 0 ? (
@@ -158,12 +190,20 @@ export function NodePickerSidebar({ nodeDefs, onAddNode, onClose }: NodePickerSi
                 {/* Nodes in category */}
                 {isExpanded && (
                   <div className="pb-2">
+                    {/* Dragged onto the canvas, a node lands where it is let go.
+                        Clicking still works for anyone who would rather not drag. */}
                     {nodes.map((node) => (
                       <button
                         key={node.id}
                         onClick={() => handleAddNode(node.id, node)}
-                        className="w-full px-4 py-2 pl-10 text-left hover:bg-gray-50 dark:hover:bg-slate-700 transition-colors"
+                        draggable
+                        onDragStart={(e) => {
+                          e.dataTransfer.setData('application/smartbotic-node', node.id)
+                          e.dataTransfer.effectAllowed = 'move'
+                        }}
+                        className="group w-full px-4 py-2 pl-10 text-left hover:bg-gray-50 dark:hover:bg-slate-700 transition-colors cursor-grab active:cursor-grabbing relative"
                       >
+                        <GripVertical className="absolute left-3 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-gray-300 dark:text-slate-600 opacity-0 group-hover:opacity-100" />
                         <div className="flex items-center gap-2">
                           <span className="font-medium text-gray-800 dark:text-gray-100 text-sm">
                             {node.name}

+ 441 - 50
webui/src/pages/WorkflowEditorPage.tsx

@@ -21,6 +21,7 @@ import ReactFlow, {
   PanOnScrollMode,
   BaseEdge,
   EdgeProps,
+  useStore,
   getSmoothStepPath,
   EdgeLabelRenderer,
 } from 'reactflow'
@@ -45,6 +46,7 @@ import { ToastContainer, ToastMessage } from '../components/workflow/Toast'
 import { EditorHeader } from '../components/workflow/EditorHeader'
 import { WorkflowSettingsModal } from '../components/workflow/WorkflowSettingsModal'
 import { NodeConfigModal } from '../components/workflow/NodeConfigModal'
+import { copyNodesToClipboard, readNodeClipboard, offsetFor } from '../utils/nodeClipboard'
 
 interface SelectedNodeData {
   id: string
@@ -77,6 +79,22 @@ const nodeTypes = {
 // Custom edge that routes around nodes for cleaner layout
 // For "false" and "done" edges, route further to the right before going down
 // 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({
   id,
   sourceX,
@@ -89,7 +107,28 @@ function SmartEdge({
   markerEnd,
   data,
   label,
+  selected,
 }: 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 isBackEdge = targetY < sourceY - 10 // Target is above source
   const isSideEdge = handleType === 'false' || handleType === 'done'
@@ -141,9 +180,111 @@ function SmartEdge({
     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 (
     <>
-      <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 && (
         <EdgeLabelRenderer>
           <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 = {
   smart: SmartEdge,
 }
@@ -190,6 +356,19 @@ function WorkflowEditorInner() {
   const [showDeleteConfirm, setShowDeleteConfirm] = useState<string | null>(null)
   const [toasts, setToasts] = useState<ToastMessage[]>([])
   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
   const [executionState, setExecutionState] = useState<ExecutionState>({
@@ -1173,6 +1352,10 @@ function WorkflowEditorInner() {
         return {
           ...e,
           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: {
             ...e.style,
             strokeWidth: isSelected ? 3 : 2,
@@ -1189,32 +1372,40 @@ function WorkflowEditorInner() {
 
   const onConnect = useCallback(
     (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)
     },
     [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 workflowNodes = nodes.map((node) => ({
       id: node.id,
@@ -1286,6 +1477,118 @@ function WorkflowEditorInner() {
     setContextMenu(null)
   }, [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
   useEffect(() => {
     const handleKeyDown = (e: KeyboardEvent) => {
@@ -1297,7 +1600,33 @@ function WorkflowEditorInner() {
         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') {
         e.preventDefault()
@@ -1340,7 +1669,7 @@ function WorkflowEditorInner() {
 
     window.addEventListener('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
   // Handles nodes with multiple outputs (IF: true/false, Loop: loop/done)
@@ -1401,34 +1730,49 @@ function WorkflowEditorInner() {
     }
   }, [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])
+
+    // 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)
-    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
   const deleteNode = useCallback((nodeId: string) => {
     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
     const positionsChanged = nodes.some(n => {
       const newPos = newPositions.get(n.id)
@@ -2250,7 +2626,11 @@ function WorkflowEditorInner() {
           <NodePickerSidebar
             nodeDefs={nodeDefs}
             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 */}
-        <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
             nodes={isViewingExecution && viewedNodes ? viewedNodes : nodes}
             edges={isViewingExecution && viewedEdges ? viewedEdges : edges}
@@ -2314,6 +2703,8 @@ function WorkflowEditorInner() {
               onEdgesChange(changes)
             }}
             onConnect={isViewingExecution ? undefined : onConnect}
+            onConnectStart={isViewingExecution ? undefined : onConnectStart}
+            onConnectEnd={isViewingExecution ? undefined : onConnectEnd}
             onNodeClick={onNodeClick}
             onNodeContextMenu={isViewingExecution ? undefined : onNodeContextMenu}
             onNodeDoubleClick={isViewingExecution ? undefined : onNodeDoubleClick}

+ 100 - 0
webui/src/utils/nodeClipboard.ts

@@ -0,0 +1,100 @@
+import { Node, Edge } from 'reactflow'
+
+/**
+ * Copying nodes, including from one workflow into another.
+ *
+ * The clipboard lives in localStorage rather than in component state, because
+ * the point of it is to survive leaving the page: copy in one workflow, open
+ * another, paste. Component state does not outlive that navigation, and the
+ * system clipboard cannot be read without a permission prompt in every browser
+ * that matters.
+ */
+
+const KEY = 'smartbotic.nodeClipboard'
+
+export interface ClipboardPayload {
+  // Enough of each node to rebuild it. Deliberately not the ReactFlow node:
+  // that carries callbacks and live execution state, none of which survives
+  // JSON and none of which belongs to the copy.
+  nodes: Array<{
+    id: string
+    type: string
+    name: string
+    config: Record<string, any>
+    position: { x: number; y: number }
+    disabled?: boolean
+  }>
+  // Only the connections with both ends inside the selection. A connection to
+  // something that was not copied has nothing to attach to on the other side.
+  edges: Array<{
+    source: string
+    sourceHandle: string | null
+    target: string
+    targetHandle: string | null
+  }>
+  copiedAt: number
+}
+
+export function copyNodesToClipboard(nodes: Node[], edges: Edge[]): number {
+  const picked = nodes.filter((n) => n.selected)
+  if (picked.length === 0) return 0
+
+  const ids = new Set(picked.map((n) => n.id))
+  const payload: ClipboardPayload = {
+    nodes: picked.map((n) => ({
+      id: n.id,
+      type: n.data?.type,
+      name: n.data?.label,
+      config: n.data?.config || {},
+      position: n.position,
+      disabled: n.data?.disabled === true,
+    })),
+    edges: edges
+      .filter((e) => ids.has(e.source) && ids.has(e.target))
+      .map((e) => ({
+        source: e.source,
+        sourceHandle: e.sourceHandle ?? null,
+        target: e.target,
+        targetHandle: e.targetHandle ?? null,
+      })),
+    copiedAt: Date.now(),
+  }
+
+  try {
+    localStorage.setItem(KEY, JSON.stringify(payload))
+  } catch {
+    // Out of quota, or storage blocked. Nothing was copied, and saying so is
+    // better than a paste that silently produces the previous copy.
+    return 0
+  }
+  return picked.length
+}
+
+export function readNodeClipboard(): ClipboardPayload | null {
+  try {
+    const raw = localStorage.getItem(KEY)
+    if (!raw) return null
+    const parsed = JSON.parse(raw)
+    if (!parsed || !Array.isArray(parsed.nodes) || parsed.nodes.length === 0) return null
+    return parsed as ClipboardPayload
+  } catch {
+    return null
+  }
+}
+
+/**
+ * Where a pasted group should land.
+ *
+ * Pasting on top of the originals is how two copies get mistaken for one, so
+ * the group keeps its internal shape and moves as a whole to the point asked
+ * for - the pointer, or the middle of the view.
+ */
+export function offsetFor(
+  payload: ClipboardPayload,
+  landAt: { x: number; y: number } | null
+): { dx: number; dy: number } {
+  if (!landAt) return { dx: 40, dy: 40 }
+  const minX = Math.min(...payload.nodes.map((n) => n.position.x))
+  const minY = Math.min(...payload.nodes.map((n) => n.position.y))
+  return { dx: landAt.x - minX, dy: landAt.y - minY }
+}