Add intermediate previews and graph reconnection

This commit is contained in:
2026-07-28 16:31:12 +02:00
parent 2084540ab1
commit 521829a7fc
14 changed files with 520 additions and 61 deletions
+139 -24
View File
@@ -1,4 +1,4 @@
import { useMemo, useState, type DragEvent } from "react";
import { useMemo, useRef, useState, type DragEvent } from "react";
import {
addEdge,
applyEdgeChanges,
@@ -9,6 +9,7 @@ import {
Controls,
MiniMap,
ReactFlow,
reconnectEdge,
type Connection,
type Edge,
type ReactFlowInstance
@@ -22,6 +23,11 @@ import type {
PipelineGraphNode
} from "../../api/dataflow";
import DataflowNode, { type DataflowFlowNode } from "./DataflowNode";
import {
closestProximityConnection,
graphEdgesFromFlow,
proximityPreviewEdge
} from "./graphInteractions";
import { FALLBACK_NODE_LIBRARY, newNode } from "./model";
const nodeTypes = { dataflow: DataflowNode };
@@ -48,6 +54,10 @@ export default function DataflowCanvas({
onSelectNode
}: DataflowCanvasProps) {
const [instance, setInstance] = useState<ReactFlowInstance<DataflowFlowNode, Edge> | null>(null);
const [proximityEdge, setProximityEdge] = useState<Edge | null>(null);
const [selectedEdgeId, setSelectedEdgeId] = useState<string | null>(null);
const reconnectSuccessful = useRef(true);
const reconnectingEdgeId = useRef<string | null>(null);
const errorNodeIds = useMemo(
() => new Set(diagnostics.filter((item) => item.severity === "error" && item.node_id).map((item) => item.node_id)),
[diagnostics]
@@ -92,9 +102,14 @@ export default function DataflowCanvas({
sourceHandle: edge.source_port ?? "output",
targetHandle: edge.target_port ?? "input",
type: "smoothstep",
className: "dataflow-edge"
className: "dataflow-edge",
selected: edge.id === selectedEdgeId
})),
[graph.edges]
[graph.edges, selectedEdgeId]
);
const displayedEdges = useMemo(
() => proximityEdge ? [...edges, proximityEdge] : edges,
[edges, proximityEdge]
);
const updateNodes = (nextNodes: DataflowFlowNode[]) => {
@@ -116,28 +131,32 @@ export default function DataflowCanvas({
const updateEdges = (nextEdges: Edge[]) => {
onGraphChange({
...graph,
edges: nextEdges.map((edge) => ({
id: edge.id,
source: edge.source,
target: edge.target,
source_port: edge.sourceHandle ?? "output",
target_port: edge.targetHandle ?? "input"
}))
edges: graphEdgesFromFlow(nextEdges)
});
};
const connectionError = (
connection: Connection | Edge,
ignoredEdgeId: string | null = reconnectingEdgeId.current
): string | null => definitionConnectionError(
ignoredEdgeId
? {
...graph,
edges: graph.edges.filter((edge) => edge.id !== ignoredEdgeId)
}
: graph,
nodeLibrary,
{
source: connection.source,
target: connection.target,
sourcePort: connection.sourceHandle,
targetPort: connection.targetHandle
}
);
const isValidConnection = (connection: Connection | Edge): boolean => {
if (readOnly || !connection.source || !connection.target) return false;
return definitionConnectionError(
graph,
nodeLibrary,
{
source: connection.source,
target: connection.target,
sourcePort: connection.sourceHandle,
targetPort: connection.targetHandle
}
) === null;
return connectionError(connection) === null;
};
const onConnect = (connection: Connection) => {
@@ -153,6 +172,26 @@ export default function DataflowCanvas({
updateEdges(next);
};
const onReconnect = (oldEdge: Edge, connection: Connection) => {
if (readOnly || connectionError(connection, oldEdge.id)) return;
reconnectSuccessful.current = true;
updateEdges(reconnectEdge(
oldEdge,
connection,
edges,
{ shouldReplaceId: false }
));
};
const proximityConnection = (draggedNode: DataflowFlowNode) => (
closestProximityConnection(
draggedNode,
nodes,
graph,
nodeLibrary
)
);
const onDrop = (event: DragEvent<HTMLDivElement>) => {
event.preventDefault();
if (readOnly || !instance) return;
@@ -177,7 +216,7 @@ export default function DataflowCanvas({
>
<ReactFlow<DataflowFlowNode, Edge>
nodes={nodes}
edges={edges}
edges={displayedEdges}
nodeTypes={nodeTypes}
onInit={setInstance}
onNodesChange={(changes) => {
@@ -189,14 +228,90 @@ export default function DataflowCanvas({
}}
onEdgesChange={(changes) => {
if (readOnly) return;
updateEdges(applyEdgeChanges(changes, edges));
const selectedChange = changes.find(
(change) => change.type === "select" && change.selected
);
if (selectedChange?.type === "select") {
setSelectedEdgeId(selectedChange.id);
} else if (changes.some(
(change) => (
change.type === "select"
&& change.id === selectedEdgeId
&& !change.selected
)
)) {
setSelectedEdgeId(null);
}
const graphChanges = changes.filter((change) => change.type !== "select");
if (graphChanges.length) {
if (graphChanges.some(
(change) => change.type === "remove" && change.id === selectedEdgeId
)) {
setSelectedEdgeId(null);
}
updateEdges(applyEdgeChanges(graphChanges, edges));
}
}}
onConnect={onConnect}
onReconnect={onReconnect}
onReconnectStart={(_event, edge) => {
reconnectSuccessful.current = false;
reconnectingEdgeId.current = edge.id;
setProximityEdge(null);
}}
onReconnectEnd={(_event, edge) => {
if (!reconnectSuccessful.current && !readOnly) {
updateEdges(edges.filter((candidate) => candidate.id !== edge.id));
setSelectedEdgeId(null);
}
reconnectSuccessful.current = true;
reconnectingEdgeId.current = null;
}}
onNodeDrag={(_event, node) => {
if (readOnly) return;
const connection = proximityConnection(node);
setProximityEdge(connection ? proximityPreviewEdge(connection) : null);
}}
onNodeDragStop={(_event, node) => {
setProximityEdge(null);
if (readOnly) return;
const connection = proximityConnection(node);
if (!connection) return;
const nextEdges = addEdge(
{
...connection,
id: `edge-${crypto.randomUUID()}`,
type: "smoothstep",
className: "dataflow-edge"
},
edges
);
onGraphChange({
...graph,
nodes: graph.nodes.map((graphNode) => (
graphNode.id === node.id
? { ...graphNode, position: node.position }
: graphNode
)),
edges: graphEdgesFromFlow(nextEdges)
});
}}
isValidConnection={isValidConnection}
onNodeClick={(_event, node) => onSelectNode(node.id)}
onPaneClick={() => onSelectNode(null)}
onNodeClick={(_event, node) => {
setSelectedEdgeId(null);
onSelectNode(node.id);
}}
onEdgeClick={(_event, edge) => {
setSelectedEdgeId(edge.id);
onSelectNode(null);
}}
onPaneClick={() => {
setSelectedEdgeId(null);
onSelectNode(null);
}}
nodesDraggable={!readOnly}
nodesConnectable={!readOnly}
edgesReconnectable={!readOnly}
elementsSelectable
deleteKeyCode={readOnly ? null : ["Backspace", "Delete"]}
connectionLineType={ConnectionLineType.SmoothStep}