(false);
const setOnConnect = useStoreActions((actions) => actions.setOnConnect);
@@ -93,6 +95,7 @@ const GraphView = ({
const setMinZoom = useStoreActions((actions) => actions.setMinZoom);
const setMaxZoom = useStoreActions((actions) => actions.setMaxZoom);
const setTranslateExtent = useStoreActions((actions) => actions.setTranslateExtent);
+ const setConnectionMode = useStoreActions((actions) => actions.setConnectionMode);
const currentStore = useStore();
const { zoomIn, zoomOut, zoomTo, transform, fitView, initialized } = useZoomPanHelper();
@@ -187,6 +190,12 @@ const GraphView = ({
}
}, [translateExtent]);
+ useEffect(() => {
+ if (typeof connectionMode !== 'undefined') {
+ setConnectionMode(connectionMode);
+ }
+ }, [connectionMode]);
+
return (
diff --git a/src/container/NodeRenderer/index.tsx b/src/container/NodeRenderer/index.tsx
index 42219b99..a835724a 100644
--- a/src/container/NodeRenderer/index.tsx
+++ b/src/container/NodeRenderer/index.tsx
@@ -1,7 +1,7 @@
import React, { memo, useMemo, ComponentType, MouseEvent } from 'react';
import { getNodesInside } from '../../utils/graph';
-import { useStoreState } from '../../store/hooks';
+import { useStoreState, useStoreActions } from '../../store/hooks';
import { Node, NodeTypesType, WrapNodeProps, Edge } from '../../types';
interface NodeRendererProps {
@@ -27,6 +27,7 @@ const NodeRenderer = (props: NodeRendererProps) => {
const elementsSelectable = useStoreState((state) => state.elementsSelectable);
const viewportBox = useStoreState((state) => state.viewportBox);
const nodes = useStoreState((state) => state.nodes);
+ const batchUpdateNodeDimensions = useStoreActions((actions) => actions.batchUpdateNodeDimensions);
const visibleNodes = props.onlyRenderVisibleElements ? getNodesInside(nodes, viewportBox, transform, true) : nodes;
@@ -37,6 +38,19 @@ const NodeRenderer = (props: NodeRendererProps) => {
[transform[0], transform[1], transform[2]]
);
+ const resizeObserver = useMemo(
+ () =>
+ new ResizeObserver((entries) => {
+ const updates = entries.map((entry) => ({
+ id: entry.target.getAttribute('data-id') as string,
+ nodeElement: entry.target as HTMLDivElement,
+ }));
+
+ batchUpdateNodeDimensions({ updates });
+ }),
+ []
+ );
+
return (
{visibleNodes.map((node) => {
@@ -81,6 +95,7 @@ const NodeRenderer = (props: NodeRendererProps) => {
isDraggable={isDraggable}
isSelectable={isSelectable}
isConnectable={isConnectable}
+ resizeObserver={resizeObserver}
/>
);
})}
diff --git a/src/container/ReactFlow/index.tsx b/src/container/ReactFlow/index.tsx
index 3afaac0d..19a69b30 100644
--- a/src/container/ReactFlow/index.tsx
+++ b/src/container/ReactFlow/index.tsx
@@ -19,6 +19,7 @@ import {
Node,
Edge,
Connection,
+ ConnectionMode,
ConnectionLineType,
ConnectionLineComponent,
FlowTransform,
@@ -28,6 +29,7 @@ import {
TranslateExtent,
KeyCode,
PanOnScrollMode,
+ OnEdgeUpdateFunc,
} from '../../types';
import '../../style.css';
@@ -73,6 +75,7 @@ export interface ReactFlowProps extends Omit, 'on
onPaneContextMenu?: (event: MouseEvent) => void;
nodeTypes?: NodeTypesType;
edgeTypes?: EdgeTypesType;
+ connectionMode?: ConnectionMode;
connectionLineType?: ConnectionLineType;
connectionLineStyle?: CSSProperties;
connectionLineComponent?: ConnectionLineComponent;
@@ -99,6 +102,7 @@ export interface ReactFlowProps extends Omit, 'on
panOnScrollSpeed?: number;
panOnScrollMode?: PanOnScrollMode;
zoomOnDoubleClick?: boolean;
+ onEdgeUpdate?: OnEdgeUpdateFunc;
}
const ReactFlow = ({
@@ -127,6 +131,7 @@ const ReactFlow = ({
onSelectionDrag,
onSelectionDragStop,
onSelectionContextMenu,
+ connectionMode,
connectionLineType = ConnectionLineType.Bezier,
connectionLineStyle,
connectionLineComponent,
@@ -157,6 +162,7 @@ const ReactFlow = ({
onPaneScroll,
onPaneContextMenu,
children,
+ onEdgeUpdate,
...rest
}: ReactFlowProps) => {
const nodeTypesParsed = useMemo(() => createNodeTypes(nodeTypes), []);
@@ -180,6 +186,7 @@ const ReactFlow = ({
onNodeDragStop={onNodeDragStop}
nodeTypes={nodeTypesParsed}
edgeTypes={edgeTypesParsed}
+ connectionMode={connectionMode}
connectionLineType={connectionLineType}
connectionLineStyle={connectionLineStyle}
connectionLineComponent={connectionLineComponent}
@@ -218,6 +225,7 @@ const ReactFlow = ({
onSelectionDrag={onSelectionDrag}
onSelectionDragStop={onSelectionDragStop}
onSelectionContextMenu={onSelectionContextMenu}
+ onEdgeUpdate={onEdgeUpdate}
/>
{onSelectionChange && }
diff --git a/src/container/ZoomPane/index.tsx b/src/container/ZoomPane/index.tsx
index a1b96a8f..1c18caef 100644
--- a/src/container/ZoomPane/index.tsx
+++ b/src/container/ZoomPane/index.tsx
@@ -189,7 +189,10 @@ const ZoomPane = ({
}
// when the target element is a node, we still allow zooming
- if (event.target.closest('.react-flow__node') && event.type !== 'wheel') {
+ if (
+ (event.target.closest('.react-flow__node') || event.target.closest('.react-flow__edgeupdater')) &&
+ event.type !== 'wheel'
+ ) {
return false;
}
diff --git a/src/index.ts b/src/index.ts
index 6ce7ecc2..b8555409 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -8,7 +8,16 @@ export { getBezierPath } from './components/Edges/BezierEdge';
export { getSmoothStepPath } from './components/Edges/SmoothStepEdge';
export { getMarkerEnd, getCenter as getEdgeCenter } from './components/Edges/utils';
-export { isNode, isEdge, removeElements, addEdge, getOutgoers, getIncomers, getConnectedEdges } from './utils/graph';
+export {
+ isNode,
+ isEdge,
+ removeElements,
+ addEdge,
+ getOutgoers,
+ getIncomers,
+ getConnectedEdges,
+ updateEdge,
+} from './utils/graph';
export { default as useZoomPanHelper } from './hooks/useZoomPanHelper';
export * from './additional-components';
diff --git a/src/store/index.ts b/src/store/index.ts
index 89280ade..e9f3973a 100644
--- a/src/store/index.ts
+++ b/src/store/index.ts
@@ -3,7 +3,7 @@ import isEqual from 'fast-deep-equal';
import { Selection as D3Selection, ZoomBehavior } from 'd3';
import { getDimensions } from '../utils';
-import { getNodesInside, getConnectedEdges, getRectOfNodes, isNode, isEdge } from '../utils/graph';
+import { getNodesInside, getConnectedEdges, getRectOfNodes, isNode, isEdge, parseElement } from '../utils/graph';
import { getHandleBounds } from '../components/Nodes/utils';
import {
@@ -26,6 +26,7 @@ import {
NodeDiffUpdate,
TranslateExtent,
SnapGrid,
+ ConnectionMode,
} from '../types';
type NodeDimensionUpdate = {
@@ -33,6 +34,10 @@ type NodeDimensionUpdate = {
nodeElement: HTMLDivElement;
};
+type NodeDimensionUpdates = {
+ updates: NodeDimensionUpdate[];
+};
+
type InitD3Zoom = {
d3Zoom: ZoomBehavior;
d3Selection: D3Selection;
@@ -66,6 +71,7 @@ export interface StoreModel {
connectionHandleId: ElementId | null;
connectionHandleType: HandleType | null;
connectionPosition: XYPosition;
+ connectionMode: ConnectionMode;
snapToGrid: boolean;
snapGrid: SnapGrid;
@@ -90,6 +96,7 @@ export interface StoreModel {
setElements: Action;
+ batchUpdateNodeDimensions: Action;
updateNodeDimensions: Action;
updateNodePos: Action;
@@ -131,6 +138,8 @@ export interface StoreModel {
unsetUserSelection: Action;
setMultiSelectionActive: Action;
+
+ setConnectionMode: Action;
}
export const storeModel: StoreModel = {
@@ -139,10 +148,10 @@ export const storeModel: StoreModel = {
transform: [0, 0, 1],
elements: [],
nodes: computed((state) => state.elements.filter(isNode)),
- viewportBox: computed((state) => ({ x: 0, y: 0, width: state.width, height: state.height })),
edges: computed((state) => state.elements.filter(isEdge)),
selectedElements: null,
selectedNodesBbox: { x: 0, y: 0, width: 0, height: 0 },
+ viewportBox: computed((state) => ({ x: 0, y: 0, width: state.width, height: state.height })),
d3Zoom: null,
d3Selection: null,
@@ -170,6 +179,7 @@ export const storeModel: StoreModel = {
connectionHandleId: null,
connectionHandleType: 'source',
connectionPosition: { x: 0, y: 0 },
+ connectionMode: ConnectionMode.Strict,
snapGrid: [15, 15],
snapToGrid: false,
@@ -195,35 +205,90 @@ export const storeModel: StoreModel = {
state.onConnectEnd = onConnectEnd;
}),
- setElements: action((state, elements) => {
- state.elements = elements;
+ setElements: action((state, propElements) => {
+ // remove deleted elements
+ for (let i = 0; i < state.elements.length; i++) {
+ const se = state.elements[i];
+ const elementExistsInProps = propElements.find((pe) => pe.id === se.id);
+
+ if (!elementExistsInProps) {
+ state.elements.splice(i, 1);
+ i--;
+ }
+ }
+
+ propElements.forEach((el) => {
+ const storeElementIndex = state.elements.findIndex((se) => se.id === el.id);
+
+ // update existing element
+ if (storeElementIndex !== -1) {
+ const storeElement = state.elements[storeElementIndex];
+
+ if (isNode(storeElement)) {
+ const propNode = el as Node;
+ const positionChanged =
+ storeElement.position.x !== propNode.position.x || storeElement.position.y !== propNode.position.y;
+ const typeChanged = typeof propNode.type !== 'undefined' && propNode.type !== storeElement.type;
+
+ state.elements[storeElementIndex] = {
+ ...storeElement,
+ ...propNode,
+ };
+
+ if (positionChanged) {
+ (state.elements[storeElementIndex] as Node).__rf.position = propNode.position;
+ }
+
+ if (typeChanged) {
+ // we reset the elements dimensions here in order to force a re-calculation of the bounds.
+ // When the type of a node changes it is possible that the number or positions of handles changes too.
+ (state.elements[storeElementIndex] as Node).__rf.width = null;
+ }
+ } else {
+ state.elements[storeElementIndex] = {
+ ...storeElement,
+ ...el,
+ };
+ }
+ } else {
+ // add new element
+ state.elements.push(parseElement(el));
+ }
+ });
+ }),
+
+ batchUpdateNodeDimensions: action((state, { updates }) => {
+ updates.forEach((update) => {
+ const dimensions = getDimensions(update.nodeElement);
+ const matchingIndex = state.elements.findIndex((n) => n.id === update.id);
+ const matchingNode = state.elements[matchingIndex] as Node;
+
+ if (
+ matchingIndex !== -1 &&
+ dimensions.width &&
+ dimensions.height &&
+ (matchingNode.__rf.width !== dimensions.width || matchingNode.__rf.height !== dimensions.height)
+ ) {
+ const handleBounds = getHandleBounds(update.nodeElement, state.transform[2]);
+
+ (state.elements[matchingIndex] as Node).__rf.width = dimensions.width;
+ (state.elements[matchingIndex] as Node).__rf.height = dimensions.height;
+ (state.elements[matchingIndex] as Node).__rf.handleBounds = handleBounds;
+ }
+ });
}),
updateNodeDimensions: action((state, { id, nodeElement }) => {
const dimensions = getDimensions(nodeElement);
- const matchingNode = state.nodes.find((n) => n.id === id);
+ const matchingIndex = state.elements.findIndex((n) => n.id === id);
- // only update when size change
- if (
- !matchingNode ||
- (matchingNode.__rf.width === dimensions.width && matchingNode.__rf.height === dimensions.height)
- ) {
- return;
+ if (matchingIndex !== -1 && dimensions.width && dimensions.height) {
+ const handleBounds = getHandleBounds(nodeElement, state.transform[2]);
+
+ (state.elements[matchingIndex] as Node).__rf.width = dimensions.width;
+ (state.elements[matchingIndex] as Node).__rf.height = dimensions.height;
+ (state.elements[matchingIndex] as Node).__rf.handleBounds = handleBounds;
}
-
- const bounds = nodeElement.getBoundingClientRect();
- const handleBounds = {
- source: getHandleBounds('.source', nodeElement, bounds, state.transform[2]),
- target: getHandleBounds('.target', nodeElement, bounds, state.transform[2]),
- };
-
- state.elements.forEach((n) => {
- if (n.id === id && isNode(n)) {
- n.__rf.width = dimensions.width;
- n.__rf.height = dimensions.height;
- n.__rf.handleBounds = handleBounds;
- }
- });
}),
updateNodePos: action((state, { id, pos }) => {
@@ -440,6 +505,10 @@ export const storeModel: StoreModel = {
setMultiSelectionActive: action((state, isActive) => {
state.multiSelectionActive = isActive;
}),
+
+ setConnectionMode: action((state, connectionMode) => {
+ state.connectionMode = connectionMode;
+ }),
};
const nodeEnv: string = (typeof __ENV__ !== 'undefined' && __ENV__) as string;
diff --git a/src/style.css b/src/style.css
index 7fb480ba..5f3a31b3 100644
--- a/src/style.css
+++ b/src/style.css
@@ -60,6 +60,12 @@
stroke-dasharray: 5;
animation: dashdraw 0.5s linear infinite;
}
+
+ &.updating {
+ .react-flow__edge-path {
+ stroke: #777;
+ }
+ }
}
@keyframes dashdraw {
@@ -236,3 +242,7 @@
top: 50%;
transform: translate(0, -50%);
}
+
+.react-flow__edgeupdater {
+ cursor: move;
+}
diff --git a/src/types/index.ts b/src/types/index.ts
index eb94c85d..50e68a15 100644
--- a/src/types/index.ts
+++ b/src/types/index.ts
@@ -111,7 +111,8 @@ export interface WrapEdgeProps {
arrowHeadType?: ArrowHeadType;
source: ElementId;
target: ElementId;
- sourceHandleId?: string;
+ sourceHandleId: ElementId | null;
+ targetHandleId: ElementId | null;
sourceX: number;
sourceY: number;
targetX: number;
@@ -121,6 +122,8 @@ export interface WrapEdgeProps {
elementsSelectable?: boolean;
markerEndId?: string;
isHidden?: boolean;
+ handleEdgeUpdate: boolean;
+ onConnectEdge: OnConnectFunc;
}
export interface EdgeProps {
@@ -224,6 +227,7 @@ export interface WrapNodeProps {
snapToGrid?: boolean;
snapGrid?: SnapGrid;
isDragging?: boolean;
+ resizeObserver: ResizeObserver;
}
export type FitViewParams = {
@@ -260,6 +264,11 @@ export interface Connection {
targetHandle: ElementId | null;
}
+export enum ConnectionMode {
+ Strict = 'strict',
+ Loose = 'loose',
+}
+
export enum ConnectionLineType {
Bezier = 'default',
Straight = 'straight',
@@ -283,6 +292,7 @@ export type ConnectionLineComponent = React.ComponentType void;
export type OnConnectStartParams = {
nodeId: ElementId | null;
+ handleId: ElementId | null;
handleType: HandleType | null;
};
export type OnConnectStartFunc = (event: ReactMouseEvent, params: OnConnectStartParams) => void;
@@ -346,3 +356,5 @@ export interface ZoomPanHelperFunctions {
fitView: (params?: FitViewParams) => void;
initialized: boolean;
}
+
+export type OnEdgeUpdateFunc = (oldEdge: Edge, newConnection: Connection) => void;
diff --git a/src/utils/graph.ts b/src/utils/graph.ts
index 177ec12c..772fd87c 100644
--- a/src/utils/graph.ts
+++ b/src/utils/graph.ts
@@ -87,6 +87,32 @@ export const addEdge = (edgeParams: Edge | Connection, elements: Elements): Elem
return elements.concat(edge);
};
+export const updateEdge = (oldEdge: Edge, newConnection: Connection, elements: Elements): Elements => {
+ if (!newConnection.source || !newConnection.target) {
+ console.warn("Can't create new edge. An edge needs a source and a target.");
+ return elements;
+ }
+
+ const foundEdge = elements.find((e) => isEdge(e) && e.id === oldEdge.id) as Edge;
+
+ if (!foundEdge) {
+ console.warn(`The old edge with id=${oldEdge.id} does not exist.`);
+ return elements;
+ }
+
+ // Remove old edge and create the new edge with parameters of old edge.
+ const edge = {
+ ...oldEdge,
+ id: getEdgeId(newConnection),
+ source: newConnection.source,
+ target: newConnection.target,
+ sourceHandle: newConnection.sourceHandle,
+ targetHandle: newConnection.targetHandle,
+ } as Edge;
+
+ return elements.filter((e) => e.id !== oldEdge.id).concat(edge);
+};
+
export const pointToRendererPoint = (
{ x, y }: XYPosition,
[tx, ty, tScale]: Transform,
diff --git a/src/utils/index.ts b/src/utils/index.ts
index caffa530..13832a31 100644
--- a/src/utils/index.ts
+++ b/src/utils/index.ts
@@ -1,6 +1,8 @@
import { DraggableEvent } from 'react-draggable';
import { MouseEvent as ReactMouseEvent } from 'react';
+import { Dimensions } from '../types';
+
export const isInputDOMNode = (e: ReactMouseEvent | DraggableEvent | KeyboardEvent) => {
const target = e?.target as HTMLElement;
@@ -9,7 +11,7 @@ export const isInputDOMNode = (e: ReactMouseEvent | DraggableEvent | KeyboardEve
);
};
-export const getDimensions = (node: HTMLDivElement) => ({
+export const getDimensions = (node: HTMLDivElement): Dimensions => ({
width: node.offsetWidth,
height: node.offsetHeight,
});