|
|
@ -99,10 +99,6 @@ import { canConnectByNodeConnectionSpec } from "@/utils/nodeConnectionSpec"; |
|
|
|
|
|
|
|
|
const BOX_SELECTION_EXPAND_MIN_DISTANCE = 4; |
|
|
const BOX_SELECTION_EXPAND_MIN_DISTANCE = 4; |
|
|
const MULTI_SELECTION_BOUNDS_PADDING = 40; |
|
|
const MULTI_SELECTION_BOUNDS_PADDING = 40; |
|
|
const GROUP_NODE_Z_INDEX = 0; |
|
|
|
|
|
const DEFAULT_NODE_Z_INDEX = 1; |
|
|
|
|
|
const INITIAL_TOP_STACK_Z_INDEX = 100; |
|
|
|
|
|
const INITIAL_TOP_MEMBER_Z_INDEX = 1; |
|
|
|
|
|
|
|
|
|
|
|
type ScreenPoint = { |
|
|
type ScreenPoint = { |
|
|
x: number; |
|
|
x: number; |
|
|
@ -227,17 +223,15 @@ function getWorkflowNodeDimensions(node: Node): { width: number; height: number |
|
|
// wrapper is left untouched during a drag.
|
|
|
// wrapper is left untouched during a drag.
|
|
|
const reactFlowNodeDimensionsCache = new WeakMap<Node, Node>(); |
|
|
const reactFlowNodeDimensionsCache = new WeakMap<Node, Node>(); |
|
|
|
|
|
|
|
|
// Cache the visual-state-decorated (className + zIndex) node keyed on the RAW
|
|
|
// Cache the className-decorated node keyed on the RAW store node. `applyNodeChanges`
|
|
|
// store node. `applyNodeChanges` keeps the same reference for nodes that didn't
|
|
|
// keeps the same reference for nodes that didn't change this frame, so keying on the
|
|
|
// move this frame, so keying on the raw node lets us return the exact same
|
|
|
// raw node returns the exact same decorated object across frames as long as the
|
|
|
// decorated object across frames — as long as the computed className/zIndex
|
|
|
// dim/skip className is unchanged — keeping React Flow's per-node wrapper untouched.
|
|
|
// signature is unchanged. Store nodes never carry className/zIndex themselves
|
|
|
// (z-index is no longer derived here; React Flow owns it via elevateNodesOnSelect +
|
|
|
// (they're undefined), so without this cache the `{...node}` branch below minted
|
|
|
// parentId, so nodes carry no zIndex field and this overlay only touches className.)
|
|
|
// a new object for EVERY node on EVERY frame, breaking React Flow's per-node
|
|
|
|
|
|
// `adoptUserNodes` reference check and re-rendering every wrapper during a drag.
|
|
|
|
|
|
const nodeVisualStateCache = new WeakMap< |
|
|
const nodeVisualStateCache = new WeakMap< |
|
|
WorkflowNode, |
|
|
WorkflowNode, |
|
|
{ decorated: WorkflowNode; className: string; zIndex: number } |
|
|
{ decorated: WorkflowNode; className: string } |
|
|
>(); |
|
|
>(); |
|
|
|
|
|
|
|
|
function withReactFlowNodeDimensions<T extends Node>(node: T): T { |
|
|
function withReactFlowNodeDimensions<T extends Node>(node: T): T { |
|
|
@ -624,11 +618,6 @@ export function WorkflowCanvas() { |
|
|
const [isBuildingWorkflow, setIsBuildingWorkflow] = useState(false); |
|
|
const [isBuildingWorkflow, setIsBuildingWorkflow] = useState(false); |
|
|
const [isMiniMapOpen, setIsMiniMapOpen] = useState(false); |
|
|
const [isMiniMapOpen, setIsMiniMapOpen] = useState(false); |
|
|
const [zoomPercent, setZoomPercent] = useState(100); |
|
|
const [zoomPercent, setZoomPercent] = useState(100); |
|
|
const [draggingGroupId, setDraggingGroupId] = useState<string | null>(null); |
|
|
|
|
|
const [stackLayerMap, setStackLayerMap] = useState<Record<string, number>>({}); |
|
|
|
|
|
const [memberLayerMap, setMemberLayerMap] = useState<Record<string, number>>({}); |
|
|
|
|
|
const [topStackLayer, setTopStackLayer] = useState(INITIAL_TOP_STACK_Z_INDEX); |
|
|
|
|
|
const topMemberLayerRef = useRef(INITIAL_TOP_MEMBER_Z_INDEX); |
|
|
|
|
|
const canvasGridColor = "var(--canvas-grid)"; |
|
|
const canvasGridColor = "var(--canvas-grid)"; |
|
|
const miniMapMaskColor = "var(--surface-overlay)"; |
|
|
const miniMapMaskColor = "var(--surface-overlay)"; |
|
|
const miniMapNodeColor = "var(--text-muted)"; |
|
|
const miniMapNodeColor = "var(--text-muted)"; |
|
|
@ -643,41 +632,6 @@ export function WorkflowCanvas() { |
|
|
const lastCanvasPointerFlowPositionRef = useRef<ScreenPoint | null>(null); |
|
|
const lastCanvasPointerFlowPositionRef = useRef<ScreenPoint | null>(null); |
|
|
const [boxSelectionRect, setBoxSelectionRect] = useState<FlowRect | null>(null); |
|
|
const [boxSelectionRect, setBoxSelectionRect] = useState<FlowRect | null>(null); |
|
|
|
|
|
|
|
|
const bringNodeToFront = useCallback((node: Node) => { |
|
|
|
|
|
const nodeGroupId = node.type === "group" |
|
|
|
|
|
? (node.data as { groupId?: string }).groupId |
|
|
|
|
|
: (node as Node & { groupId?: string }).groupId; |
|
|
|
|
|
const groupNodeId = nodeGroupId ? `group-node-${nodeGroupId}` : null; |
|
|
|
|
|
|
|
|
|
|
|
if (node.type !== "group" && nodeGroupId && groupNodeId) { |
|
|
|
|
|
topMemberLayerRef.current += 1; |
|
|
|
|
|
const nextMemberLayer = topMemberLayerRef.current; |
|
|
|
|
|
setMemberLayerMap((layers) => ({ |
|
|
|
|
|
...layers, |
|
|
|
|
|
[node.id]: nextMemberLayer, |
|
|
|
|
|
})); |
|
|
|
|
|
|
|
|
|
|
|
setTopStackLayer((current) => { |
|
|
|
|
|
const nextStackLayer = current + 1; |
|
|
|
|
|
setStackLayerMap((layers) => ({ |
|
|
|
|
|
...layers, |
|
|
|
|
|
[groupNodeId]: nextStackLayer, |
|
|
|
|
|
})); |
|
|
|
|
|
return nextStackLayer; |
|
|
|
|
|
}); |
|
|
|
|
|
return; |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
setTopStackLayer((current) => { |
|
|
|
|
|
const nextStackLayer = current + 1; |
|
|
|
|
|
setStackLayerMap((layers) => ({ |
|
|
|
|
|
...layers, |
|
|
|
|
|
[node.id]: nextStackLayer, |
|
|
|
|
|
})); |
|
|
|
|
|
return nextStackLayer; |
|
|
|
|
|
}); |
|
|
|
|
|
}, []); |
|
|
|
|
|
|
|
|
|
|
|
const onNodesChange = useCallback((changes: NodeChange<WorkflowNode>[]) => { |
|
|
const onNodesChange = useCallback((changes: NodeChange<WorkflowNode>[]) => { |
|
|
if (!boxSelectionActiveRef.current) { |
|
|
if (!boxSelectionActiveRef.current) { |
|
|
storeOnNodesChange(changes); |
|
|
storeOnNodesChange(changes); |
|
|
@ -967,27 +921,14 @@ export function WorkflowCanvas() { |
|
|
} |
|
|
} |
|
|
}, [tutorialActive, nodes, setCenter]); |
|
|
}, [tutorialActive, nodes, setCenter]); |
|
|
|
|
|
|
|
|
// Apply visual state that React Flow can turn into DOM classes and z-index.
|
|
|
// Overlay dim/skip CSS classes that React Flow turns into DOM classes.
|
|
|
|
|
|
// z-index is owned by React Flow itself (elevateNodesOnSelect for the
|
|
|
|
|
|
// selected/dragged node + parentId auto-elevation for group members), so it is
|
|
|
|
|
|
// no longer derived here — this overlay only touches className.
|
|
|
const allNodes = useMemo(() => { |
|
|
const allNodes = useMemo(() => { |
|
|
const draggingGroupNodeId = draggingGroupId ? `group-node-${draggingGroupId}` : null; |
|
|
|
|
|
const groupStackLayerById = new Map<string, number>(); |
|
|
|
|
|
for (const node of nodes) { |
|
|
|
|
|
if (node.type !== "group") continue; |
|
|
|
|
|
const groupId = (node.data as { groupId?: string }).groupId; |
|
|
|
|
|
const stackLayer = stackLayerMap[node.id]; |
|
|
|
|
|
if (groupId && stackLayer !== undefined) { |
|
|
|
|
|
groupStackLayerById.set(groupId, stackLayer); |
|
|
|
|
|
} |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
return nodes.map((node) => { |
|
|
return nodes.map((node) => { |
|
|
// Never dim Switch or ConditionalSwitch nodes themselves
|
|
|
// Never dim Switch or ConditionalSwitch nodes themselves
|
|
|
const canDimNode = node.type !== "switch" && node.type !== "conditionalSwitch"; |
|
|
const canDimNode = node.type !== "switch" && node.type !== "conditionalSwitch"; |
|
|
const isDraggingGroupShell = Boolean(draggingGroupNodeId && node.id === draggingGroupNodeId); |
|
|
|
|
|
const isDraggingGroupMember = Boolean(draggingGroupId && node.type !== "group" && ( |
|
|
|
|
|
node.groupId === draggingGroupId || node.parentId === draggingGroupNodeId |
|
|
|
|
|
)); |
|
|
|
|
|
|
|
|
|
|
|
const isDimmed = canDimNode && dimmedNodeIds.has(node.id); |
|
|
const isDimmed = canDimNode && dimmedNodeIds.has(node.id); |
|
|
const isSkipped = canDimNode && skippedNodeIds.has(node.id); |
|
|
const isSkipped = canDimNode && skippedNodeIds.has(node.id); |
|
|
const extraClasses = [ |
|
|
const extraClasses = [ |
|
|
@ -998,38 +939,18 @@ export function WorkflowCanvas() { |
|
|
// Preserve existing className if any, add/remove dimmed/skipped classes
|
|
|
// Preserve existing className if any, add/remove dimmed/skipped classes
|
|
|
const baseClass = (node.className || "").replace(/\bswitch-dimmed\b/g, "").replace(/\bnode-skipped\b/g, "").trim(); |
|
|
const baseClass = (node.className || "").replace(/\bswitch-dimmed\b/g, "").replace(/\bnode-skipped\b/g, "").trim(); |
|
|
const newClass = extraClasses ? `${baseClass} ${extraClasses}`.trim() : baseClass; |
|
|
const newClass = extraClasses ? `${baseClass} ${extraClasses}`.trim() : baseClass; |
|
|
const nodeGroupId = (node as Node & { groupId?: string }).groupId; |
|
|
|
|
|
const groupStackLayer = nodeGroupId ? groupStackLayerById.get(nodeGroupId) : undefined; |
|
|
|
|
|
let zIndex = node.type === "group" |
|
|
|
|
|
? stackLayerMap[node.id] ?? GROUP_NODE_Z_INDEX |
|
|
|
|
|
: nodeGroupId && groupStackLayer !== undefined |
|
|
|
|
|
? groupStackLayer + (memberLayerMap[node.id] ?? DEFAULT_NODE_Z_INDEX) |
|
|
|
|
|
: stackLayerMap[node.id] ?? DEFAULT_NODE_Z_INDEX; |
|
|
|
|
|
|
|
|
|
|
|
if (isDraggingGroupShell) { |
|
|
|
|
|
zIndex = Math.max(zIndex, topStackLayer); |
|
|
|
|
|
} else if (isDraggingGroupMember) { |
|
|
|
|
|
zIndex = Math.max(zIndex, topStackLayer + (memberLayerMap[node.id] ?? DEFAULT_NODE_Z_INDEX)); |
|
|
|
|
|
} else if (node.dragging) { |
|
|
|
|
|
zIndex = Math.max(zIndex, topStackLayer + 1); |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
// Return a reference-stable decorated node. Keyed on the raw store node,
|
|
|
// Reference-stable: keyed on the raw store node, so an unchanged node yields
|
|
|
// so an unchanged node yields the identical object across frames and React
|
|
|
// the identical object across frames and React Flow leaves its wrapper alone.
|
|
|
// Flow leaves its wrapper untouched; only the node whose className/zIndex
|
|
|
|
|
|
// actually changed (e.g. the one being dragged) gets a new reference.
|
|
|
|
|
|
const cached = nodeVisualStateCache.get(node); |
|
|
const cached = nodeVisualStateCache.get(node); |
|
|
if (cached && cached.className === newClass && cached.zIndex === zIndex) { |
|
|
if (cached && cached.className === newClass) { |
|
|
return cached.decorated; |
|
|
return cached.decorated; |
|
|
} |
|
|
} |
|
|
const decorated = |
|
|
const decorated = node.className === newClass ? node : { ...node, className: newClass }; |
|
|
node.className === newClass && node.zIndex === zIndex |
|
|
nodeVisualStateCache.set(node, { decorated, className: newClass }); |
|
|
? node |
|
|
|
|
|
: { ...node, className: newClass, zIndex }; |
|
|
|
|
|
nodeVisualStateCache.set(node, { decorated, className: newClass, zIndex }); |
|
|
|
|
|
return decorated; |
|
|
return decorated; |
|
|
}); |
|
|
}); |
|
|
}, [nodes, dimmedNodeIds, draggingGroupId, memberLayerMap, skippedNodeIds, stackLayerMap, topStackLayer]); |
|
|
}, [nodes, dimmedNodeIds, skippedNodeIds]); |
|
|
|
|
|
|
|
|
const absoluteNodePositions = useMemo(() => { |
|
|
const absoluteNodePositions = useMemo(() => { |
|
|
const byId = new Map(allNodes.map((node) => [node.id, node])); |
|
|
const byId = new Map(allNodes.map((node) => [node.id, node])); |
|
|
@ -1269,7 +1190,6 @@ export function WorkflowCanvas() { |
|
|
}; |
|
|
}; |
|
|
|
|
|
|
|
|
window.getSelection()?.removeAllRanges(); |
|
|
window.getSelection()?.removeAllRanges(); |
|
|
bringNodeToFront(node); |
|
|
|
|
|
selectSingleNode(node.id); |
|
|
selectSingleNode(node.id); |
|
|
setZoomPercent(Math.round(nextZoom * 100)); |
|
|
setZoomPercent(Math.round(nextZoom * 100)); |
|
|
setCenter(center.x, center.y, { |
|
|
setCenter(center.x, center.y, { |
|
|
@ -1277,7 +1197,7 @@ export function WorkflowCanvas() { |
|
|
zoom: nextZoom, |
|
|
zoom: nextZoom, |
|
|
}); |
|
|
}); |
|
|
|
|
|
|
|
|
}, [absoluteNodePositions, bringNodeToFront, nodes, selectSingleNode, setCenter]); |
|
|
}, [absoluteNodePositions, nodes, selectSingleNode, setCenter]); |
|
|
|
|
|
|
|
|
const zoomNodeById = useCallback((nodeId: string) => { |
|
|
const zoomNodeById = useCallback((nodeId: string) => { |
|
|
zoomNodeIntoFocusById(nodeId); |
|
|
zoomNodeIntoFocusById(nodeId); |
|
|
@ -1414,6 +1334,15 @@ export function WorkflowCanvas() { |
|
|
[allNodes, batchNodes] |
|
|
[allNodes, batchNodes] |
|
|
); |
|
|
); |
|
|
|
|
|
|
|
|
|
|
|
// Pin every edge to zIndex 0 so it always renders below nodes. React Flow only
|
|
|
|
|
|
// auto-elevates an edge's zIndex when it's left undefined (edges touching grouped
|
|
|
|
|
|
// nodes get lifted to the node's band and end up above other nodes); setting an
|
|
|
|
|
|
// explicit value bypasses that. The ternary keeps unchanged edge references stable.
|
|
|
|
|
|
const reactFlowEdges = useMemo( |
|
|
|
|
|
() => edges.map((edge) => (edge.zIndex === 0 ? edge : { ...edge, zIndex: 0 })), |
|
|
|
|
|
[edges] |
|
|
|
|
|
); |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const handleConnect = useCallback( |
|
|
const handleConnect = useCallback( |
|
|
@ -3040,28 +2969,27 @@ export function WorkflowCanvas() { |
|
|
<NodeDoubleClickZoomProvider value={{ zoomNodeById }}> |
|
|
<NodeDoubleClickZoomProvider value={{ zoomNodeById }}> |
|
|
<ReactFlow |
|
|
<ReactFlow |
|
|
nodes={reactFlowNodes} |
|
|
nodes={reactFlowNodes} |
|
|
edges={edges} |
|
|
edges={reactFlowEdges} |
|
|
onNodesChange={handleReactFlowNodesChange} |
|
|
onNodesChange={handleReactFlowNodesChange} |
|
|
onEdgesChange={onEdgesChange} |
|
|
onEdgesChange={onEdgesChange} |
|
|
onConnect={handleConnect} |
|
|
onConnect={handleConnect} |
|
|
onConnectEnd={handleConnectEnd} |
|
|
onConnectEnd={handleConnectEnd} |
|
|
onMoveStart={() => { isPanningRef.current = true; setHoveredNodeId(null); document.documentElement.classList.add("canvas-interacting"); }} |
|
|
onMoveStart={() => { isPanningRef.current = true; setHoveredNodeId(null); document.documentElement.classList.add("canvas-interacting"); }} |
|
|
onMoveEnd={() => { isPanningRef.current = false; document.documentElement.classList.remove("canvas-interacting"); syncZoomPercent(); }} |
|
|
onMoveEnd={() => { isPanningRef.current = false; document.documentElement.classList.remove("canvas-interacting"); syncZoomPercent(); }} |
|
|
onNodeClick={(_event, node) => { |
|
|
|
|
|
bringNodeToFront(node); |
|
|
|
|
|
}} |
|
|
|
|
|
onNodeDragStart={(_event, node) => { |
|
|
onNodeDragStart={(_event, node) => { |
|
|
isDraggingNodeRef.current = true; |
|
|
isDraggingNodeRef.current = true; |
|
|
document.documentElement.classList.add("canvas-interacting"); |
|
|
document.documentElement.classList.add("canvas-interacting"); |
|
|
document.documentElement.classList.add("canvas-node-drag-active"); |
|
|
document.documentElement.classList.add("canvas-node-drag-active"); |
|
|
bringNodeToFront(node); |
|
|
// Selecting the dragged node lets React Flow's elevateNodesOnSelect
|
|
|
setDraggingGroupId(node.type === "group" ? (node.data as { groupId?: string }).groupId ?? null : null); |
|
|
// raise it to the top. selectNodesOnDrag is false, so React Flow
|
|
|
|
|
|
// won't select it for us; only select when it isn't already, to
|
|
|
|
|
|
// preserve an existing multi-selection drag.
|
|
|
|
|
|
if (!node.selected) selectSingleNode(node.id); |
|
|
}} |
|
|
}} |
|
|
onNodeDragStop={(event, node) => { |
|
|
onNodeDragStop={(event, node) => { |
|
|
isDraggingNodeRef.current = false; |
|
|
isDraggingNodeRef.current = false; |
|
|
document.documentElement.classList.remove("canvas-interacting"); |
|
|
document.documentElement.classList.remove("canvas-interacting"); |
|
|
document.documentElement.classList.remove("canvas-node-drag-active"); |
|
|
document.documentElement.classList.remove("canvas-node-drag-active"); |
|
|
setDraggingGroupId(null); |
|
|
|
|
|
handleNodeDragStop(event, node); |
|
|
handleNodeDragStop(event, node); |
|
|
}} |
|
|
}} |
|
|
onPaneClick={handlePaneClick} |
|
|
onPaneClick={handlePaneClick} |
|
|
@ -3082,7 +3010,7 @@ export function WorkflowCanvas() { |
|
|
selectionKeyCode={["Space", "Control", "Meta"]} |
|
|
selectionKeyCode={["Space", "Control", "Meta"]} |
|
|
panOnDrag={canvasPanOnDrag} |
|
|
panOnDrag={canvasPanOnDrag} |
|
|
selectNodesOnDrag={false} |
|
|
selectNodesOnDrag={false} |
|
|
elevateNodesOnSelect={false} |
|
|
elevateNodesOnSelect={true} |
|
|
nodesFocusable={false} |
|
|
nodesFocusable={false} |
|
|
nodeDragThreshold={5} |
|
|
nodeDragThreshold={5} |
|
|
nodeClickDistance={5} |
|
|
nodeClickDistance={5} |
|
|
|