Skip to content

Commit 25bad2e

Browse files
authored
Improvements to IO Node Ghosts & Quick-Place (#1440)
## Description <!-- Please provide a brief description of the changes made in this pull request. Include any relevant context or reasoning for the changes. --> IO Ghost nodes now: - Measure their own size and position themselves correctly (instead of working with an assumed default) - End up in the same position when placed - Have the connection line drawn to their handle, not the top of the node - No longer append "Input" or "Output" to the name when placed To enable this a custom connection line component was added. This component renders for all in-progress connections, except when a GhostNode is active. This is used in conjunction with a GhostEdge to draw the line to the ghost node's handle instead of the cursor. Additionally, Edges: - The default drawn edge is now similar in style to our placed edges (i.e. thicker) - Now color a slight green whilst drawn when valid (e.g. task -> task) - Now color a slight red whilst drawn when invalid (e.g. input -> output) - Now color a dashed blue whilst drawn when placing a ghost Input - Now color a dashed purple whilst drawn when placing a ghost Output Finally, some additional code improvements were made to the Ghost Node logic. e.g. consolidating refs and removing and expensive `nodes.find` statement. Note: due to our existing connection architecture the IO Node handles cannot have ids, but for the ghost node connections they need an id. Hence, there is a ternary to separate the two cases. In future we should revise (e.g. as per node manager stack) and all handles should have ids. ## Related Issue and Pull requests <!-- Link to any related issues using the format #<issue-number> --> ## Type of Change <!-- Please delete options that are not relevant --> - [x] Bug fix - [x] Improvement ## Checklist <!-- Please ensure the following are completed before submitting the PR --> - [ ] I have tested this does not break current pipelines / runs functionality - [ ] I have tested the changes on staging ## Screenshots (if applicable) <!-- Include any screenshots that might help explain the changes or provide visual context --> ## Test Instructions <!-- Detail steps and prerequisites for testing the changes in this PR --> Confirm that edges can still be drawn as expected Confirm that ghost nodes position and move and behave as expected Confirm that ghost nodes place in the same location as they were when dropped Confirm that drawing a connection goes green when valid and red when invalid ## Additional Comments <!-- Add any additional context or information that reviewers might need to know regarding this PR -->
1 parent 4e580c1 commit 25bad2e

File tree

9 files changed

+240
-75
lines changed

9 files changed

+240
-75
lines changed
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import {
2+
type ConnectionLineComponent,
3+
type ConnectionLineComponentProps,
4+
getBezierPath,
5+
type Node,
6+
useConnection,
7+
useNodes,
8+
} from "@xyflow/react";
9+
10+
import { EdgeColor } from "./utils";
11+
12+
export const ConnectionLine: ConnectionLineComponent = ({
13+
fromX,
14+
fromY,
15+
fromPosition,
16+
toX,
17+
toY,
18+
toPosition,
19+
connectionStatus,
20+
}: ConnectionLineComponentProps) => {
21+
const nodes = useNodes();
22+
const sourceNode = useConnection((connection) => connection.fromNode);
23+
const targetNode = useConnection((connection) => connection.toNode);
24+
25+
const hasGhostNode = nodes.some((node) => node.type === "ghost");
26+
27+
if (hasGhostNode) {
28+
return null;
29+
}
30+
31+
const [path] = getBezierPath({
32+
sourceX: fromX,
33+
sourceY: fromY,
34+
sourcePosition: fromPosition,
35+
targetX: toX,
36+
targetY: toY,
37+
targetPosition: toPosition,
38+
});
39+
40+
let color =
41+
connectionStatus === "valid" ? EdgeColor.Valid : EdgeColor.Incomplete;
42+
43+
if (sourceNode && targetNode && !isValidConnection(sourceNode, targetNode)) {
44+
color = EdgeColor.Invalid;
45+
}
46+
47+
const markerId = `connection-line-arrow-${color}`;
48+
49+
return (
50+
<g>
51+
<svg style={{ height: 0 }}>
52+
<defs>
53+
<marker
54+
id={markerId}
55+
markerWidth="12"
56+
markerHeight="12"
57+
refX="7"
58+
refY="6"
59+
orient="auto"
60+
markerUnits="userSpaceOnUse"
61+
>
62+
<path
63+
d="M2,2 Q10,6 2,10 Q4,6 2,2"
64+
fill={color}
65+
stroke={color}
66+
strokeWidth="1"
67+
strokeLinejoin="round"
68+
/>
69+
</marker>
70+
</defs>
71+
</svg>
72+
<path
73+
d={path}
74+
className="react-flow__edge-path"
75+
style={{
76+
stroke: color,
77+
strokeWidth: 4,
78+
}}
79+
markerEnd={`url(#${markerId})`}
80+
/>
81+
</g>
82+
);
83+
};
84+
85+
const isValidConnection = (fromNode: Node, toNode: Node) => {
86+
if (fromNode.id === toNode.id) {
87+
return false;
88+
}
89+
90+
// IO nodes can't connect to each other
91+
const isFromIO = fromNode.type === "input" || fromNode.type === "output";
92+
const isToIO = toNode.type === "input" || toNode.type === "output";
93+
94+
if (isFromIO && isToIO) {
95+
return false;
96+
}
97+
98+
return true;
99+
};

src/components/shared/ReactFlow/FlowCanvas/Edges/SmoothEdge.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import type { EdgeProps } from "@xyflow/react";
22
import { getBezierPath } from "@xyflow/react";
33

4+
import { EdgeColor } from "./utils";
5+
46
const SmoothEdge = ({
57
id,
68
sourceX,
@@ -21,7 +23,7 @@ const SmoothEdge = ({
2123
targetPosition,
2224
});
2325

24-
const edgeColor = selected ? "#38bdf8" : "#6b7280";
26+
const edgeColor = selected ? EdgeColor.Selected : EdgeColor.Neutral;
2527
const markerIdSuffix = selected ? "selected" : "default";
2628

2729
return (
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
export enum EdgeColor {
2+
Incomplete = "#b1b1b7",
3+
Neutral = "#6b7280",
4+
Valid = "#8db88d",
5+
Invalid = "#d47a7a",
6+
Input = "#3b82f6",
7+
Output = "#a855f7",
8+
Selected = "#38bdf8",
9+
}

src/components/shared/ReactFlow/FlowCanvas/FlowCanvas.tsx

Lines changed: 42 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import {
22
type Connection,
3+
type Edge,
34
type FinalConnectionState,
45
type Node,
56
type NodeChange,
@@ -10,6 +11,7 @@ import {
1011
type ReactFlowProps,
1112
SelectionMode,
1213
useConnection,
14+
useEdgesState,
1315
useNodesState,
1416
useStoreApi,
1517
type XYPosition,
@@ -35,7 +37,6 @@ import { hydrateComponentReference } from "@/services/componentService";
3537
import {
3638
type ComponentSpec,
3739
type InputSpec,
38-
isGraphImplementation,
3940
isNotMaterializedComponentReference,
4041
type TaskSpec,
4142
} from "@/utils/componentSpec";
@@ -53,10 +54,14 @@ import { useNodesOverlay } from "../NodesOverlay/NodesOverlayProvider";
5354
import { getBulkUpdateConfirmationDetails } from "./ConfirmationDialogs/BulkUpdateConfirmationDialog";
5455
import { getDeleteConfirmationDetails } from "./ConfirmationDialogs/DeleteConfirmation";
5556
import { getReplaceConfirmationDetails } from "./ConfirmationDialogs/ReplaceConfirmation";
57+
import { ConnectionLine } from "./Edges/ConnectionLine";
5658
import SmoothEdge from "./Edges/SmoothEdge";
5759
import GhostNode from "./GhostNode/GhostNode";
5860
import type { GhostNodeData } from "./GhostNode/types";
59-
import { computeDropPositionFromRefs } from "./GhostNode/utils";
61+
import {
62+
computeDropPositionFromRefs,
63+
createGhostEdge,
64+
} from "./GhostNode/utils";
6065
import IONode from "./IONode/IONode";
6166
import SelectionToolbar from "./SelectionToolbar";
6267
import { handleGroupNodes } from "./Subgraphs/create/handleGroupNodes";
@@ -134,10 +139,16 @@ const FlowCanvas = ({
134139
const isSubgraphNavigationEnabled = useBetaFlagValue("subgraph-navigation");
135140
const isPartialSelectionEnabled = useBetaFlagValue("partial-selection");
136141

137-
const { edges, onEdgesChange } = useComponentSpecToEdges(currentSubgraphSpec);
142+
const store = useStoreApi();
143+
const { edges: specEdges, onEdgesChange } =
144+
useComponentSpecToEdges(currentSubgraphSpec);
138145
const [nodes, setNodes, onNodesChange] = useNodesState<Node>([]);
146+
const [edges, setEdges] = useEdgesState<Edge>(specEdges);
139147

140148
const isConnecting = useConnection((connection) => connection.inProgress);
149+
const connectionSourceHandle = useConnection(
150+
(connection) => connection.fromHandle,
151+
);
141152

142153
const {
143154
handlers: confirmationHandlers,
@@ -150,8 +161,7 @@ const FlowCanvas = ({
150161
const notify = useToastNotification();
151162

152163
const latestFlowPosRef = useRef<XYPosition>(null);
153-
const ghostNodePositionRef = useRef<XYPosition | null>(null);
154-
const ghostNodeTypeRef = useRef<GhostNodeData["ioType"] | null>(null);
164+
const ghostNodeRef = useRef<Node<GhostNodeData> | null>(null);
155165
const shouldCreateIONodeRef = useRef(false);
156166

157167
const [showToolbar, setShowToolbar] = useState(false);
@@ -263,11 +273,22 @@ const FlowCanvas = ({
263273

264274
const { ghostNode, shouldCreateIONode } = useGhostNode({
265275
readOnly,
266-
metaKeyPressed,
276+
active: metaKeyPressed,
267277
isConnecting,
268278
implementation: currentSubgraphSpec.implementation,
269279
});
270280

281+
useEffect(() => {
282+
if (!!ghostNode && connectionSourceHandle) {
283+
const ghostEdge = createGhostEdge(connectionSourceHandle);
284+
setEdges([...specEdges, ghostEdge]);
285+
286+
return;
287+
}
288+
289+
setEdges(specEdges);
290+
}, [ghostNode, connectionSourceHandle, specEdges, setEdges]);
291+
271292
const nodesForRender = useMemo<Node[]>(
272293
() => (ghostNode ? [...nodes, ghostNode] : nodes),
273294
[nodes, ghostNode],
@@ -278,8 +299,7 @@ const FlowCanvas = ({
278299
}, [shouldCreateIONode]);
279300

280301
useEffect(() => {
281-
ghostNodePositionRef.current = ghostNode?.position ?? null;
282-
ghostNodeTypeRef.current = ghostNode?.data.ioType ?? null;
302+
ghostNodeRef.current = ghostNode;
283303
}, [ghostNode]);
284304

285305
const selectedNodes = useMemo(
@@ -392,32 +412,26 @@ const FlowCanvas = ({
392412
);
393413

394414
const handleGhostDrop = useCallback(
395-
(fromHandle: FinalConnectionState["fromHandle"] | null) => {
396-
if (
397-
!fromHandle ||
398-
!fromHandle.nodeId ||
399-
!fromHandle.id ||
400-
!isGraphImplementation(currentSubgraphSpec.implementation)
401-
) {
415+
(finalConnectionState: FinalConnectionState | null) => {
416+
const fromNode = finalConnectionState?.fromNode;
417+
const fromHandle = finalConnectionState?.fromHandle;
418+
419+
if (!fromNode || !fromHandle?.id) {
420+
return false;
421+
}
422+
423+
if (!FAST_PLACE_NODE_TYPES.has(fromNode.type)) {
402424
return false;
403425
}
404426

405427
const position = computeDropPositionFromRefs(
406-
ghostNodePositionRef.current,
428+
ghostNodeRef.current,
407429
latestFlowPosRef.current,
408-
ghostNodeTypeRef.current,
409430
fromHandle.type,
431+
store.getState(),
410432
);
411-
if (!position) {
412-
return false;
413-
}
414433

415-
const fromNode = nodes.find((node) => node.id === fromHandle.nodeId);
416-
if (
417-
!fromNode ||
418-
!fromNode.type ||
419-
!FAST_PLACE_NODE_TYPES.has(fromNode.type)
420-
) {
434+
if (!position) {
421435
return false;
422436
}
423437

@@ -470,7 +484,7 @@ const FlowCanvas = ({
470484
return;
471485
}
472486

473-
handleGhostDrop(connectionState.fromHandle ?? null);
487+
handleGhostDrop(connectionState);
474488
},
475489
[handleGhostDrop],
476490
);
@@ -948,8 +962,6 @@ const FlowCanvas = ({
948962
fitView,
949963
);
950964

951-
const store = useStoreApi();
952-
953965
const onCopy = useCallback(() => {
954966
// Copy selected nodes to clipboard
955967
if (selectedNodes.length > 0) {
@@ -1083,6 +1095,7 @@ const FlowCanvas = ({
10831095
onSelectionEnd={handleSelectionEnd}
10841096
nodesConnectable={readOnly ? false : nodesConnectable}
10851097
connectOnClick={!readOnly}
1098+
connectionLineComponent={ConnectionLine}
10861099
proOptions={{ hideAttribution: true }}
10871100
className={cn(
10881101
(rest.selectionOnDrag || (shiftKeyPressed && !isConnecting)) &&

src/components/shared/ReactFlow/FlowCanvas/GhostNode/GhostNode.tsx

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { Node, NodeProps } from "@xyflow/react";
1+
import { type Node, type NodeProps, Position } from "@xyflow/react";
22
import { memo, useMemo } from "react";
33

44
import { cn } from "@/lib/utils";
@@ -9,11 +9,12 @@ import { GHOST_NODE_BASE_OFFSET_X, GHOST_NODE_BASE_OFFSET_Y } from "./utils";
99

1010
type GhostNodeProps = NodeProps<Node<GhostNodeData>>;
1111

12-
const GhostNode = ({ data }: GhostNodeProps) => {
12+
const GhostNode = ({ data, id }: GhostNodeProps) => {
1313
const { ioType, label, dataType, value, defaultValue } = data;
1414

15-
const side = ioType === "input" ? "left" : "right";
16-
const transformOrigin = side === "left" ? "center right" : "center left";
15+
const side = ioType === "input" ? Position.Left : Position.Right;
16+
const transformOrigin =
17+
side === Position.Left ? "center right" : "center left";
1718
const offsetX = GHOST_NODE_BASE_OFFSET_X;
1819
const offsetY = GHOST_NODE_BASE_OFFSET_Y;
1920

@@ -32,7 +33,7 @@ const GhostNode = ({ data }: GhostNodeProps) => {
3233
<div
3334
className={cn(
3435
"pointer-events-none select-none opacity-60",
35-
side === "left" && "-translate-x-full",
36+
side === Position.Left && "-translate-x-full",
3637
)}
3738
style={{
3839
filter: "brightness(0.9) saturate(0.7)",
@@ -42,6 +43,7 @@ const GhostNode = ({ data }: GhostNodeProps) => {
4243
>
4344
<div className="rounded-lg border-2 border-dashed border-blue-400/60 bg-white/40 p-1">
4445
<IONode
46+
id={id}
4547
type={ioType}
4648
data={ghostNodeData}
4749
selected={false}

0 commit comments

Comments
 (0)