|
|
|
@ -428,11 +428,22 @@ export function WorkflowCanvas() { |
|
|
|
const [isSplitting, setIsSplitting] = useState(false); |
|
|
|
const [isBuildingWorkflow, setIsBuildingWorkflow] = useState(false); |
|
|
|
const [isMacOSClient, setIsMacOSClient] = useState(false); |
|
|
|
const boxSelectionActiveRef = useRef(false); |
|
|
|
const boxSelectionEndTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); |
|
|
|
|
|
|
|
useEffect(() => { |
|
|
|
setIsMacOSClient(/Mac|iPod|iPhone|iPad/.test(navigator.platform)); |
|
|
|
}, []); |
|
|
|
|
|
|
|
useEffect(() => { |
|
|
|
return () => { |
|
|
|
if (boxSelectionEndTimerRef.current) { |
|
|
|
clearTimeout(boxSelectionEndTimerRef.current); |
|
|
|
} |
|
|
|
document.documentElement.classList.remove("box-selection-active"); |
|
|
|
}; |
|
|
|
}, []); |
|
|
|
|
|
|
|
useEffect(() => { |
|
|
|
let cancelled = false; |
|
|
|
fetch("/api/env-status") |
|
|
|
@ -2283,10 +2294,79 @@ export function WorkflowCanvas() { |
|
|
|
// Uses statistical outlier detection to identify and deselect nodes that are clearly
|
|
|
|
// outside the actual selection area.
|
|
|
|
const handleSelectionChange = useCallback(({ nodes: selectedNodes }: OnSelectionChangeParams) => { |
|
|
|
if (selectedNodes.length <= 1) return; |
|
|
|
if (!boxSelectionActiveRef.current) return; |
|
|
|
if (selectedNodes.length === 0) return; |
|
|
|
|
|
|
|
const getNodeGroupId = (node: Node): string | undefined => |
|
|
|
(node as Node & { groupId?: string }).groupId; |
|
|
|
|
|
|
|
const selectedGroupNodeIds = new Set<string>(); |
|
|
|
const selectedGroupIds = new Set<string>(); |
|
|
|
selectedNodes.forEach((node) => { |
|
|
|
if (node.type !== "group") return; |
|
|
|
selectedGroupNodeIds.add(node.id); |
|
|
|
const groupId = (node.data as { groupId?: string }).groupId; |
|
|
|
if (groupId) selectedGroupIds.add(groupId); |
|
|
|
}); |
|
|
|
|
|
|
|
selectedNodes.forEach((node) => { |
|
|
|
if (node.type === "group") return; |
|
|
|
const groupId = getNodeGroupId(node); |
|
|
|
if (groupId) selectedGroupIds.add(groupId); |
|
|
|
if (node.parentId) selectedGroupNodeIds.add(node.parentId); |
|
|
|
}); |
|
|
|
|
|
|
|
const groupNodesToSelect = nodes.filter((node) => { |
|
|
|
if (node.type !== "group") return false; |
|
|
|
const groupId = (node.data as { groupId?: string }).groupId; |
|
|
|
return selectedGroupNodeIds.has(node.id) || Boolean(groupId && selectedGroupIds.has(groupId)); |
|
|
|
}); |
|
|
|
groupNodesToSelect.forEach((node) => { |
|
|
|
selectedGroupNodeIds.add(node.id); |
|
|
|
const groupId = (node.data as { groupId?: string }).groupId; |
|
|
|
if (groupId) selectedGroupIds.add(groupId); |
|
|
|
}); |
|
|
|
|
|
|
|
const groupChildNodes = selectedNodes.filter((node) => { |
|
|
|
if (node.type === "group") return false; |
|
|
|
const groupId = getNodeGroupId(node); |
|
|
|
return Boolean( |
|
|
|
(groupId && selectedGroupIds.has(groupId)) || |
|
|
|
(node.parentId && selectedGroupNodeIds.has(node.parentId)) |
|
|
|
); |
|
|
|
}); |
|
|
|
const groupChildNodeIds = new Set(groupChildNodes.map((node) => node.id)); |
|
|
|
const groupNodeIdsToSelect = new Set(groupNodesToSelect.map((node) => node.id)); |
|
|
|
const outlierCandidateNodes = [ |
|
|
|
...selectedNodes.filter((node) => !groupChildNodeIds.has(node.id)), |
|
|
|
...groupNodesToSelect.filter((node) => !selectedNodes.some((selectedNode) => selectedNode.id === node.id)), |
|
|
|
]; |
|
|
|
if (outlierCandidateNodes.length <= 1) { |
|
|
|
const selectionChanges = [ |
|
|
|
...groupChildNodes.map((node) => ({ |
|
|
|
type: 'select' as const, |
|
|
|
id: node.id, |
|
|
|
selected: false, |
|
|
|
})), |
|
|
|
...groupNodesToSelect |
|
|
|
.filter((node) => !node.selected) |
|
|
|
.map((node) => ({ |
|
|
|
type: 'select' as const, |
|
|
|
id: node.id, |
|
|
|
selected: true, |
|
|
|
})), |
|
|
|
]; |
|
|
|
|
|
|
|
if (selectionChanges.length > 0) { |
|
|
|
onNodesChange( |
|
|
|
selectionChanges |
|
|
|
); |
|
|
|
} |
|
|
|
return; |
|
|
|
} |
|
|
|
|
|
|
|
// Get positions of all selected nodes
|
|
|
|
const positions = selectedNodes.map(n => ({ |
|
|
|
const positions = outlierCandidateNodes.map(n => ({ |
|
|
|
id: n.id, |
|
|
|
x: n.position.x, |
|
|
|
y: n.position.y, |
|
|
|
@ -2314,16 +2394,52 @@ export function WorkflowCanvas() { |
|
|
|
p.x < minX || p.x > maxX || p.y < minY || p.y > maxY |
|
|
|
); |
|
|
|
|
|
|
|
if (outliers.length > 0) { |
|
|
|
onNodesChange( |
|
|
|
outliers.map(o => ({ |
|
|
|
const nodesToDeselect = new Map<string, string>(); |
|
|
|
outliers.forEach((node) => nodesToDeselect.set(node.id, node.id)); |
|
|
|
groupChildNodes.forEach((node) => nodesToDeselect.set(node.id, node.id)); |
|
|
|
const groupOutlierIds = new Set(outliers.map((node) => node.id)); |
|
|
|
|
|
|
|
const selectionChanges = [ |
|
|
|
...[...nodesToDeselect.keys()].map((id) => ({ |
|
|
|
type: 'select' as const, |
|
|
|
id: o.id, |
|
|
|
id, |
|
|
|
selected: false, |
|
|
|
})) |
|
|
|
})), |
|
|
|
...groupNodesToSelect |
|
|
|
.filter((node) => !node.selected && groupNodeIdsToSelect.has(node.id) && !groupOutlierIds.has(node.id)) |
|
|
|
.map((node) => ({ |
|
|
|
type: 'select' as const, |
|
|
|
id: node.id, |
|
|
|
selected: true, |
|
|
|
})), |
|
|
|
]; |
|
|
|
|
|
|
|
if (selectionChanges.length > 0) { |
|
|
|
onNodesChange( |
|
|
|
selectionChanges |
|
|
|
); |
|
|
|
} |
|
|
|
}, [onNodesChange]); |
|
|
|
}, [nodes, onNodesChange]); |
|
|
|
|
|
|
|
const handleSelectionStart = useCallback(() => { |
|
|
|
if (boxSelectionEndTimerRef.current) { |
|
|
|
clearTimeout(boxSelectionEndTimerRef.current); |
|
|
|
boxSelectionEndTimerRef.current = null; |
|
|
|
} |
|
|
|
boxSelectionActiveRef.current = true; |
|
|
|
document.documentElement.classList.add("box-selection-active"); |
|
|
|
}, []); |
|
|
|
|
|
|
|
const handleSelectionEnd = useCallback(() => { |
|
|
|
if (boxSelectionEndTimerRef.current) { |
|
|
|
clearTimeout(boxSelectionEndTimerRef.current); |
|
|
|
} |
|
|
|
boxSelectionEndTimerRef.current = setTimeout(() => { |
|
|
|
boxSelectionActiveRef.current = false; |
|
|
|
boxSelectionEndTimerRef.current = null; |
|
|
|
document.documentElement.classList.remove("box-selection-active"); |
|
|
|
}, 0); |
|
|
|
}, []); |
|
|
|
|
|
|
|
const handleDragOver = useCallback((event: DragEvent<HTMLDivElement>) => { |
|
|
|
event.preventDefault(); |
|
|
|
@ -2592,6 +2708,8 @@ export function WorkflowCanvas() { |
|
|
|
onMoveEnd={() => { isPanningRef.current = false; document.documentElement.classList.remove("canvas-interacting"); }} |
|
|
|
onNodeDragStart={() => { isDraggingNodeRef.current = true; document.documentElement.classList.add("canvas-interacting"); }} |
|
|
|
onNodeDragStop={(event, node) => { isDraggingNodeRef.current = false; document.documentElement.classList.remove("canvas-interacting"); handleNodeDragStop(event, node); }} |
|
|
|
onSelectionStart={handleSelectionStart} |
|
|
|
onSelectionEnd={handleSelectionEnd} |
|
|
|
onSelectionChange={handleSelectionChange} |
|
|
|
nodeTypes={nodeTypes} |
|
|
|
edgeTypes={edgeTypes} |
|
|
|
|