Merge pull request #277 from wbkd/develop

Develop
This commit is contained in:
Moritz
2020-06-02 16:05:48 +02:00
committed by GitHub
26 changed files with 1855 additions and 400 deletions
+64 -19
View File
@@ -37,7 +37,7 @@ React Flow is a library for building node-based graphs. You can easily implement
In order to make this library as flexible as possible we dont do any state updates besides the positions. This means that you need to pass the functions to remove an element or connect nodes by yourself. You can implement your own ones or use the helper functions that come with the library. In order to make this library as flexible as possible we dont do any state updates besides the positions. This means that you need to pass the functions to remove an element or connect nodes by yourself. You can implement your own ones or use the helper functions that come with the library.
## Installation # Installation
``` ```
npm install react-flow-renderer npm install react-flow-renderer
@@ -61,17 +61,17 @@ const elements = [
const BasicFlow = () => <ReactFlow elements={elements} />; const BasicFlow = () => <ReactFlow elements={elements} />;
``` ```
# ReactFlow Component Prop Types # React Flow Component Prop Types
- `elements`: array of [nodes](#nodes) and [edges](#edges) *(required)* - `elements`: array of [nodes](#nodes) and [edges](#edges) *(required)*
- `onElementClick`: element click handler - `onElementClick(element: Node | Edge)`: element click callback
- `onElementsRemove`: element remove handler - `onElementsRemove(elements: Elements)`: element remove callback
- `onNodeDragStart`: node drag start handler - `onNodeDragStart(node: Node)`: node drag start callback
- `onNodeDragStop`: node drag stop handler - `onNodeDragStop(node: Node)`: node drag stop callback
- `onConnect`: connect handler - `onConnect({ source, target })`: connect callback
- `onLoad`: editor load handler - `onLoad(reactFlowInstance)`: editor load callback
- `onMove`: move handler - `onMove()`: move callback
- `onSelectionChange`: fired when element selection changes - `onSelectionChange(elements: Elements)`: fired when element selection changes
- `nodeTypes`: object with [node types](#node-types--custom-nodes) - `nodeTypes`: object with [node types](#node-types--custom-nodes)
- `edgeTypes`: object with [edge types](#edge-types--custom-edges) - `edgeTypes`: object with [edge types](#edge-types--custom-edges)
- `style`: css properties - `style`: css properties
@@ -85,6 +85,50 @@ const BasicFlow = () => <ReactFlow elements={elements} />;
- `onlyRenderVisibleNodes`: default: `true` - `onlyRenderVisibleNodes`: default: `true`
- `isInteractive`: default: `true`. If the graph is not interactive you can't drag any nodes - `isInteractive`: default: `true`. If the graph is not interactive you can't drag any nodes
- `selectNodesOnDrag`: default: `true` - `selectNodesOnDrag`: default: `true`
- `minZoom`: default: `0.5`
- `maxZoom`: default: `2`
- `defaultZoom`: default: `1`
## React Flow Instance
You can receive a `reactFlowInstance` by using the `onLoad` callback:
```javascript
import React from 'react';
import ReactFlow from 'react-flow-renderer';
const onLoad = (reactFlowInstance) => {
reactFlowInstance.fitView();
}
const BasicFlow = () => <ReactFlow onLoad={onLoad} elements={[]} />;
```
`reactFlowInstance` has the following functions:
### project
Transforms pixel coordinates to the internal React Flow coordinate system
`project = (position: XYPosition): XYPosition`
### fitView
Fits view port so that all nodes are visible
`fitView = ({ padding }): void`
### zoomIn
`zoomIn = (): void`
### zoomOut
`zoomOut = (): void`
### getElements
`getElements = (): Elements`
# Nodes # Nodes
@@ -167,6 +211,10 @@ const targetHandleWithValidation = (
- `style`: css properties - `style`: css properties
- `className`: additional class name - `className`: additional class name
### Validation
The handle receives the additional class names `connecting` when the connection line is above the handle and `valid` if the connection is valid. You can find an example which uses these classes [here](/example/src/Validation/index.js).
### Multiple Handles ### Multiple Handles
If you need multiple source or target handles you can achieve this by creating a custom node. Normally you just use the id of a node for the `source` or `target` of an edge. If you have multiple source or target handles you need to pass an id to these handles. These ids get then added to the node id, so that you can connect a specific handle. If you have a node with an id = `1` and a handle with an id = `a` you can connect this handle by using the id = `1__a`. If you need multiple source or target handles you can achieve this by creating a custom node. Normally you just use the id of a node for the `source` or `target` of an edge. If you have multiple source or target handles you need to pass an id to these handles. These ids get then added to the node id, so that you can connect a specific handle. If you have a node with an id = `1` and a handle with an id = `a` you can connect this handle by using the id = `1__a`.
@@ -341,10 +389,12 @@ The React Flow wrapper has the className `react-flow`. If you want to change the
* `.react-flow__nodesselection` - Nodes selection * `.react-flow__nodesselection` - Nodes selection
* `.react-flow__nodesselection-rect ` - Nodes selection rect * `.react-flow__nodesselection-rect ` - Nodes selection rect
* `.react-flow__handle` - Handle component * `.react-flow__handle` - Handle component
* `.bottom` is added when position = 'bottom' * `.react-flow__handle-bottom` is added when position = 'bottom'
* `.top` is added when position = 'top' * `.react-flow__handle-top` is added when position = 'top'
* `.left` is added when position = 'left' * `.react-flow__handle-left` is added when position = 'left'
* `.right` is added when position = 'right' * `.react-flow__handle-right` is added when position = 'right'
* `.react-flow__handle-connecting` is added when connection line is above a handle
* `.react-flow__handle-valid` is added when connection line is above a handle and the connection is valid
* `.react-flow__background` - Background component * `.react-flow__background` - Background component
* `.react-flow__minimap` - Mini map component * `.react-flow__minimap` - Mini map component
* `.react-flow__controls` - Controls component * `.react-flow__controls` - Controls component
@@ -394,11 +444,6 @@ Returns elements array with added edge
`addEdge = (edgeParams: Edge, elements: Elements): Elements` `addEdge = (edgeParams: Edge, elements: Elements): Elements`
### project
Transforms pixel coordinates to the internal React Flow coordinate system
`project = (position: XYPosition): XYPosition`
You can use these function as seen in [this example](/example/src/Overview/index.js#L40-L41) or use your own ones. You can use these function as seen in [this example](/example/src/Overview/index.js#L40-L41) or use your own ones.
+2 -2
View File
@@ -127,8 +127,8 @@ describe('Basic Graph Rendering', () => {
cy.window().then((win) => { cy.window().then((win) => {
cy.get('.react-flow__zoompane') cy.get('.react-flow__zoompane')
.trigger('mousedown', { which: 1, view: win }) .trigger('mousedown', 'topLeft', { which: 1, view: win })
.trigger('mousemove', newPosition) .trigger('mousemove', 'bottomLeft')
.trigger('mouseup', { force: true, view: win }) .trigger('mouseup', { force: true, view: win })
.then(() => { .then(() => {
const styleAfterDrag = Cypress.$('.react-flow__nodes').css('transform'); const styleAfterDrag = Cypress.$('.react-flow__nodes').css('transform');
+4 -1
View File
@@ -3,7 +3,7 @@ import React, { useState } from 'react';
import ReactFlow, { removeElements, addEdge, isNode, Background } from 'react-flow-renderer'; import ReactFlow, { removeElements, addEdge, isNode, Background } from 'react-flow-renderer';
const onNodeDragStop = node => console.log('drag stop', node); const onNodeDragStop = node => console.log('drag stop', node);
const onLoad = graphInstance => console.log('graph loaded:', graphInstance); const onLoad = reactFlowInstance => console.log('graph loaded:', reactFlowInstance);
const onElementClick = element => console.log('click', element); const onElementClick = element => console.log('click', element);
const initialElements = [ const initialElements = [
@@ -48,6 +48,9 @@ const BasicFlow = () => {
onConnect={onConnect} onConnect={onConnect}
onNodeDragStop={onNodeDragStop} onNodeDragStop={onNodeDragStop}
className="react-flow-basic-example" className="react-flow-basic-example"
defaultZoom={1.5}
minZoom={0.2}
maxZoom={4}
> >
<Background variant="lines" /> <Background variant="lines" />
+1 -1
View File
@@ -6,7 +6,7 @@ import ColorSelectorNode from './ColorSelectorNode';
const onNodeDragStop = node => console.log('drag stop', node); const onNodeDragStop = node => console.log('drag stop', node);
const onElementClick = element => console.log('click', element); const onElementClick = element => console.log('click', element);
const onLoad = (graph) => console.log('graph loaded:', graph); const onLoad = reactFlowInstance => console.log('graph loaded:', reactFlowInstance);
const initBgColor = '#f0e742'; const initBgColor = '#f0e742';
+1 -1
View File
@@ -6,7 +6,7 @@ import CustomEdge from './CustomEdge';
const onNodeDragStop = node => console.log('drag stop', node); const onNodeDragStop = node => console.log('drag stop', node);
const onElementClick = element => console.log('click', element); const onElementClick = element => console.log('click', element);
const onLoad = (graph) => graph.fitView(); const onLoad = reactFlowInstance => reactFlowInstance.fitView();
const initialElements = [ const initialElements = [
{ id: '1', type: 'input', data: { label: 'Input 1' }, position: { x: 250, y: 0 } }, { id: '1', type: 'input', data: { label: 'Input 1' }, position: { x: 250, y: 0 } },
+1 -1
View File
@@ -3,7 +3,7 @@ import React, { useState } from 'react';
import ReactFlow, { removeElements, addEdge, MiniMap, Controls, Background } from 'react-flow-renderer'; import ReactFlow, { removeElements, addEdge, MiniMap, Controls, Background } from 'react-flow-renderer';
const onNodeDragStop = node => console.log('drag stop', node); const onNodeDragStop = node => console.log('drag stop', node);
const onLoad = graphInstance => console.log('graph loaded:', graphInstance); const onLoad = reactFlowInstance => console.log('graph loaded:', reactFlowInstance);
const onElementClick = element => console.log('click', element); const onElementClick = element => console.log('click', element);
const EmptyFlow = () => { const EmptyFlow = () => {
+1 -3
View File
@@ -2,9 +2,7 @@ import React, { useState } from 'react';
import ReactFlow, { removeElements, addEdge } from 'react-flow-renderer'; import ReactFlow, { removeElements, addEdge } from 'react-flow-renderer';
const onLoad = (graph) => { const onLoad = reactFlowInstance => reactFlowInstance.fitView();
graph.fitView();
};
const initialElements = [ const initialElements = [
{ id: '1', sourcePosition: 'right', type: 'input', className: 'dark-node', data: { label: 'Input' }, position: { x: 0, y: 80 } }, { id: '1', sourcePosition: 'right', type: 'input', className: 'dark-node', data: { label: 'Input' }, position: { x: 0, y: 80 } },
+3 -3
View File
@@ -6,9 +6,9 @@ const onNodeDragStart = node => console.log('drag start', node);
const onNodeDragStop = node => console.log('drag stop', node); const onNodeDragStop = node => console.log('drag stop', node);
const onElementClick = element => console.log('click', element); const onElementClick = element => console.log('click', element);
const onSelectionChange = elements => console.log('selection change', elements); const onSelectionChange = elements => console.log('selection change', elements);
const onLoad = (graph) => { const onLoad = (reactFlowInstance) => {
console.log('graph loaded:', graph); console.log('graph loaded:', reactFlowInstance);
graph.fitView(); reactFlowInstance.fitView();
}; };
const initialElements = [ const initialElements = [
+5 -4
View File
@@ -3,10 +3,11 @@ import React, { useState } from 'react';
import ReactFlow, { removeElements, addEdge, MiniMap, isNode, Controls, Background } from 'react-flow-renderer'; import ReactFlow, { removeElements, addEdge, MiniMap, isNode, Controls, Background } from 'react-flow-renderer';
import { getElements } from './utils'; import { getElements } from './utils';
const onLoad = graph => { const onLoad = reactFlowInstance => {
console.log('graph loaded:', graph); reactFlowInstance.fitView();
graph.fitView();
}; console.log(reactFlowInstance.getElements());
}
const initialElements = getElements(10, 10); const initialElements = getElements(10, 10);
+60
View File
@@ -0,0 +1,60 @@
import React, { useState } from 'react';
import ReactFlow, { addEdge, Handle } from 'react-flow-renderer';
import './validation.css';
const initialElements = [
{ id: '0', type: 'custominput', position: { x: 0, y: 150 } },
{ id: 'A', type: 'customnode', position: { x: 250, y: 0 } },
{ id: 'B', type: 'customnode', position: { x: 250, y: 150 } },
{ id: 'C', type: 'customnode', position: { x: 250, y:300 } },
];
const CustomInput = () => (
<>
<div>Only connectable with B</div>
<Handle
type="source"
position="right"
isValidConnection={connection => connection.target === 'B'}
/>
</>
);
const CustomNode = ({ id }) => (
<>
<Handle
type="target"
position="left"
isValidConnection={_ => id === 'B'}
/>
<div>{id}</div>
<Handle
type="source"
position="right"
isValidConnection={_ => id === 'B'}
/>
</>
);
const HorizontalFlow = () => {
const [elements, setElements] = useState(initialElements);
const onConnect = (params) => setElements(els => addEdge(params, els));
return (
<ReactFlow
elements={elements}
onConnect={onConnect}
selectNodesOnDrag={false}
onLoad={reactFlowInstance => reactFlowInstance.fitView()}
className="validationflow"
nodeTypes={{
custominput: CustomInput,
customnode: CustomNode
}}
/>
);
}
export default HorizontalFlow;
+31
View File
@@ -0,0 +1,31 @@
.validationflow .react-flow__node {
width: 150px;
border-radius: 5px;
padding: 10px;
color: #555;
border: 1px solid #ddd;
text-align: center;
font-size: 12px;
}
.validationflow .react-flow__node-customnode {
background: #e6e6e9;
border: 1px solid #ddd;
}
.react-flow__node-custominput .react-flow__handle {
background: #e6e6e9;
}
.validationflow .react-flow__node-custominput {
background: #fff;
}
.validationflow .react-flow__handle-connecting {
background: #ff6060;
}
.validationflow .react-flow__handle-valid {
background: #55dd99;
}
+8
View File
@@ -141,6 +141,14 @@ nav a.active:before {
color: #f8f8f8; color: #f8f8f8;
} }
.react-flow__node-selectorNode {
font-size: 12px;
background: #eee;
border: 1px solid #555;
border-radius: 5px;
text-align: center;
}
@media screen and (min-width: 768px) { @media screen and (min-width: 768px) {
nav { nav {
position: relative; position: relative;
+5
View File
@@ -9,6 +9,7 @@ import Stress from './Stress';
import Inactive from './Inactive'; import Inactive from './Inactive';
import Empty from './Empty'; import Empty from './Empty';
import Edges from './Edges'; import Edges from './Edges';
import Validation from './Validation';
import Horizontal from './Horizontal'; import Horizontal from './Horizontal';
import './index.css'; import './index.css';
@@ -33,6 +34,10 @@ const routes = [{
path: '/horizontal', path: '/horizontal',
component: Horizontal, component: Horizontal,
label: 'Horizontal' label: 'Horizontal'
}, {
path: '/validation',
component: Validation,
label: 'Validation'
}, { }, {
path: '/stress', path: '/stress',
component: Stress, component: Stress,
+1488 -252
View File
File diff suppressed because it is too large Load Diff
+6 -6
View File
@@ -23,7 +23,7 @@
"release": "release-it" "release": "release-it"
}, },
"dependencies": { "dependencies": {
"@welldone-software/why-did-you-render": "^4.2.2", "@welldone-software/why-did-you-render": "^4.2.5",
"classnames": "^2.2.6", "classnames": "^2.2.6",
"d3-selection": "^1.4.1", "d3-selection": "^1.4.1",
"d3-zoom": "^1.8.3", "d3-zoom": "^1.8.3",
@@ -33,9 +33,9 @@
"resize-observer": "^1.0.0" "resize-observer": "^1.0.0"
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "^7.9.6", "@babel/core": "^7.10.2",
"@babel/preset-env": "^7.9.6", "@babel/preset-env": "^7.10.2",
"@babel/preset-react": "^7.9.4", "@babel/preset-react": "^7.10.1",
"@svgr/rollup": "^5.4.0", "@svgr/rollup": "^5.4.0",
"@types/classnames": "^2.2.10", "@types/classnames": "^2.2.10",
"@types/d3": "^5.7.2", "@types/d3": "^5.7.2",
@@ -44,13 +44,13 @@
"autoprefixer": "^9.8.0", "autoprefixer": "^9.8.0",
"babel-loader": "^8.1.0", "babel-loader": "^8.1.0",
"babel-preset-react-app": "^9.1.2", "babel-preset-react-app": "^9.1.2",
"cypress": "^4.6.0", "cypress": "^4.7.0",
"postcss-nested": "^4.2.1", "postcss-nested": "^4.2.1",
"prettier": "2.0.5", "prettier": "2.0.5",
"prop-types": "^15.7.2", "prop-types": "^15.7.2",
"react": "^16.13.1", "react": "^16.13.1",
"release-it": "^13.6.1", "release-it": "^13.6.1",
"rollup": "^2.10.9", "rollup": "^2.12.0",
"rollup-plugin-babel": "^4.4.0", "rollup-plugin-babel": "^4.4.0",
"rollup-plugin-bundle-size": "^1.0.3", "rollup-plugin-bundle-size": "^1.0.3",
"rollup-plugin-commonjs": "^10.1.0", "rollup-plugin-commonjs": "^10.1.0",
+9 -11
View File
@@ -1,10 +1,11 @@
import React, { useEffect, useState, CSSProperties } from 'react'; import React, { useEffect, useState, CSSProperties } from 'react';
import cx from 'classnames'; import cx from 'classnames';
import { ElementId, Node, Transform, HandleElement, Position, ConnectionLineType } from '../../types'; import { ElementId, Node, Transform, HandleElement, Position, ConnectionLineType, HandleType } from '../../types';
interface ConnectionLineProps { interface ConnectionLineProps {
connectionSourceId: ElementId; connectionNodeId: ElementId;
connectionHandleType: HandleType;
connectionPositionX: number; connectionPositionX: number;
connectionPositionY: number; connectionPositionY: number;
connectionLineType: ConnectionLineType; connectionLineType: ConnectionLineType;
@@ -16,7 +17,8 @@ interface ConnectionLineProps {
} }
export default ({ export default ({
connectionSourceId, connectionNodeId,
connectionHandleType,
connectionLineStyle, connectionLineStyle,
connectionPositionX, connectionPositionX,
connectionPositionY, connectionPositionY,
@@ -27,8 +29,8 @@ export default ({
isInteractive, isInteractive,
}: ConnectionLineProps) => { }: ConnectionLineProps) => {
const [sourceNode, setSourceNode] = useState<Node | null>(null); const [sourceNode, setSourceNode] = useState<Node | null>(null);
const hasHandleId = connectionSourceId.includes('__'); const hasHandleId = connectionNodeId.includes('__');
const sourceIdSplitted = connectionSourceId.split('__'); const sourceIdSplitted = connectionNodeId.split('__');
const nodeId = sourceIdSplitted[0]; const nodeId = sourceIdSplitted[0];
const handleId = hasHandleId ? sourceIdSplitted[1] : null; const handleId = hasHandleId ? sourceIdSplitted[1] : null;
@@ -42,14 +44,10 @@ export default ({
} }
const connectionLineClasses: string = cx('react-flow__connection', className); const connectionLineClasses: string = cx('react-flow__connection', className);
const hasSource = sourceNode.__rg.handleBounds.source !== null;
// output nodes don't have source handles so we need to use the target one
const handleKey = hasSource ? 'source' : 'target';
const sourceHandle = handleId const sourceHandle = handleId
? sourceNode.__rg.handleBounds[handleKey].find((d: HandleElement) => d.id === handleId) ? sourceNode.__rg.handleBounds[connectionHandleType].find((d: HandleElement) => d.id === handleId)
: sourceNode.__rg.handleBounds[handleKey][0]; : sourceNode.__rg.handleBounds[connectionHandleType][0];
const sourceHandleX = sourceHandle ? sourceHandle.x + sourceHandle.width / 2 : sourceNode.__rg.width / 2; const sourceHandleX = sourceHandle ? sourceHandle.x + sourceHandle.width / 2 : sourceNode.__rg.width / 2;
const sourceHandleY = sourceHandle ? sourceHandle.y + sourceHandle.height / 2 : sourceNode.__rg.height; const sourceHandleY = sourceHandle ? sourceHandle.y + sourceHandle.height / 2 : sourceNode.__rg.height;
const sourceX = sourceNode.__rg.position.x + sourceHandleX; const sourceX = sourceNode.__rg.position.x + sourceHandleX;
+14 -14
View File
@@ -1,17 +1,17 @@
import React, { memo, MouseEvent as ReactMouseEvent, CSSProperties } from 'react'; import React, { memo, MouseEvent as ReactMouseEvent, CSSProperties } from 'react';
import cx from 'classnames'; import cx from 'classnames';
import { HandleType, ElementId, Position, XYPosition, OnConnectFunc, Connection } from '../../types'; import { HandleType, ElementId, Position, XYPosition, OnConnectFunc, Connection, SetConnectionId } from '../../types';
type ValidConnectionFunc = (connection: Connection) => boolean; type ValidConnectionFunc = (connection: Connection) => boolean;
type SetSourceIdFunc = (nodeId: ElementId | null) => void; type SetSourceIdFunc = (params: SetConnectionId) => void;
interface BaseHandleProps { interface BaseHandleProps {
type: HandleType; type: HandleType;
nodeId: ElementId; nodeId: ElementId;
onConnect: OnConnectFunc; onConnect: OnConnectFunc;
position: Position; position: Position;
setSourceId: SetSourceIdFunc; setConnectionNodeId: SetSourceIdFunc;
setPosition: (pos: XYPosition) => void; setPosition: (pos: XYPosition) => void;
isValidConnection: ValidConnectionFunc; isValidConnection: ValidConnectionFunc;
id?: ElementId | boolean; id?: ElementId | boolean;
@@ -29,8 +29,8 @@ type Result = {
function onMouseDown( function onMouseDown(
evt: ReactMouseEvent, evt: ReactMouseEvent,
nodeId: ElementId, nodeId: ElementId,
setSourceId: SetSourceIdFunc, setConnectionNodeId: SetSourceIdFunc,
setPosition: (pos: XYPosition) => any, setPosition: (pos: XYPosition) => void,
onConnect: OnConnectFunc, onConnect: OnConnectFunc,
isTarget: boolean, isTarget: boolean,
isValidConnection: ValidConnectionFunc isValidConnection: ValidConnectionFunc
@@ -48,15 +48,15 @@ function onMouseDown(
x: evt.clientX - containerBounds.left, x: evt.clientX - containerBounds.left,
y: evt.clientY - containerBounds.top, y: evt.clientY - containerBounds.top,
}); });
setSourceId(nodeId); setConnectionNodeId({ connectionNodeId: nodeId, connectionHandleType: isTarget ? 'target' : 'source' });
function resetRecentHandle(): void { function resetRecentHandle(): void {
if (!recentHoveredHandle) { if (!recentHoveredHandle) {
return; return;
} }
recentHoveredHandle.classList.remove('valid'); recentHoveredHandle.classList.remove('react-flow__handle-valid');
recentHoveredHandle.classList.remove('connecting'); recentHoveredHandle.classList.remove('react-flow__handle-connecting');
} }
// checks if element below mouse is a handle and returns connection in form of an object { source: 123, target: 312 } // checks if element below mouse is a handle and returns connection in form of an object { source: 123, target: 312 }
@@ -107,8 +107,8 @@ function onMouseDown(
if (!isOwnHandle && elementBelow) { if (!isOwnHandle && elementBelow) {
recentHoveredHandle = elementBelow; recentHoveredHandle = elementBelow;
elementBelow.classList.add('connecting'); elementBelow.classList.add('react-flow__handle-connecting');
elementBelow.classList.toggle('valid', isValid); elementBelow.classList.toggle('react-flow__handle-valid', isValid);
} }
} }
@@ -120,7 +120,7 @@ function onMouseDown(
} }
resetRecentHandle(); resetRecentHandle();
setSourceId(null); setConnectionNodeId({ connectionNodeId: null, connectionHandleType: null });
document.removeEventListener('mousemove', onMouseMove); document.removeEventListener('mousemove', onMouseMove);
document.removeEventListener('mouseup', onMouseUp); document.removeEventListener('mouseup', onMouseUp);
@@ -136,7 +136,7 @@ const BaseHandle = memo(
nodeId, nodeId,
onConnect, onConnect,
position, position,
setSourceId, setConnectionNodeId,
setPosition, setPosition,
className, className,
id = false, id = false,
@@ -144,7 +144,7 @@ const BaseHandle = memo(
...rest ...rest
}: BaseHandleProps) => { }: BaseHandleProps) => {
const isTarget = type === 'target'; const isTarget = type === 'target';
const handleClasses = cx('react-flow__handle', 'nodrag', className, position, { const handleClasses = cx('react-flow__handle', `react-flow__handle-${position}`, 'nodrag', className, {
source: !isTarget, source: !isTarget,
target: isTarget, target: isTarget,
}); });
@@ -157,7 +157,7 @@ const BaseHandle = memo(
data-handlepos={position} data-handlepos={position}
className={handleClasses} className={handleClasses}
onMouseDown={(evt) => onMouseDown={(evt) =>
onMouseDown(evt, nodeIdWithHandleId, setSourceId, setPosition, onConnect, isTarget, isValidConnection) onMouseDown(evt, nodeIdWithHandleId, setConnectionNodeId, setPosition, onConnect, isTarget, isValidConnection)
} }
{...rest} {...rest}
/> />
+2 -2
View File
@@ -18,7 +18,7 @@ const Handle = memo(
}: HandleProps) => { }: HandleProps) => {
const nodeId = useContext(NodeIdContext) as ElementId; const nodeId = useContext(NodeIdContext) as ElementId;
const setPosition = useStoreActions((a) => a.setConnectionPosition); const setPosition = useStoreActions((a) => a.setConnectionPosition);
const setSourceId = useStoreActions((a) => a.setConnectionSourceId); const setConnectionNodeId = useStoreActions((a) => a.setConnectionNodeId);
const onConnectAction = useStoreState((s) => s.onConnect); const onConnectAction = useStoreState((s) => s.onConnect);
const onConnectExtended = (params: Connection) => { const onConnectExtended = (params: Connection) => {
onConnectAction(params); onConnectAction(params);
@@ -29,7 +29,7 @@ const Handle = memo(
<BaseHandle <BaseHandle
nodeId={nodeId} nodeId={nodeId}
setPosition={setPosition} setPosition={setPosition}
setSourceId={setSourceId} setConnectionNodeId={setConnectionNodeId}
onConnect={onConnectExtended} onConnect={onConnectExtended}
type={type} type={type}
position={position} position={position}
+6 -3
View File
@@ -190,7 +190,8 @@ const EdgeRenderer = memo((props: EdgeRendererProps) => {
const [tX, tY, tScale] = useStoreState((s) => s.transform); const [tX, tY, tScale] = useStoreState((s) => s.transform);
const edges = useStoreState((s) => s.edges); const edges = useStoreState((s) => s.edges);
const nodes = useStoreState((s) => s.nodes); const nodes = useStoreState((s) => s.nodes);
const connectionSourceId = useStoreState((s) => s.connectionSourceId); const connectionNodeId = useStoreState((s) => s.connectionNodeId);
const connectionHandleType = useStoreState((s) => s.connectionHandleType);
const connectionPosition = useStoreState((s) => s.connectionPosition); const connectionPosition = useStoreState((s) => s.connectionPosition);
const selectedElements = useStoreState((s) => s.selectedElements); const selectedElements = useStoreState((s) => s.selectedElements);
const isInteractive = useStoreState((s) => s.isInteractive); const isInteractive = useStoreState((s) => s.isInteractive);
@@ -202,15 +203,17 @@ const EdgeRenderer = memo((props: EdgeRendererProps) => {
} }
const transformStyle = `translate(${tX},${tY}) scale(${tScale})`; const transformStyle = `translate(${tX},${tY}) scale(${tScale})`;
const renderConnectionLine = connectionNodeId && connectionHandleType;
return ( return (
<svg width={width} height={height} className="react-flow__edges"> <svg width={width} height={height} className="react-flow__edges">
<g transform={transformStyle}> <g transform={transformStyle}>
{edges.map((e: Edge) => renderEdge(e, props, nodes, selectedElements, isInteractive))} {edges.map((e: Edge) => renderEdge(e, props, nodes, selectedElements, isInteractive))}
{connectionSourceId && ( {renderConnectionLine && (
<ConnectionLine <ConnectionLine
nodes={nodes} nodes={nodes}
connectionSourceId={connectionSourceId} connectionNodeId={connectionNodeId!}
connectionHandleType={connectionHandleType!}
connectionPositionX={connectionPosition.x} connectionPositionX={connectionPosition.x}
connectionPositionY={connectionPosition.y} connectionPositionY={connectionPosition.y}
transform={[tX, tY, tScale]} transform={[tX, tY, tScale]}
+19 -1
View File
@@ -11,7 +11,7 @@ import useD3Zoom from '../../hooks/useD3Zoom';
import useGlobalKeyHandler from '../../hooks/useGlobalKeyHandler'; import useGlobalKeyHandler from '../../hooks/useGlobalKeyHandler';
import useElementUpdater from '../../hooks/useElementUpdater'; import useElementUpdater from '../../hooks/useElementUpdater';
import { getDimensions } from '../../utils'; import { getDimensions } from '../../utils';
import { fitView, zoomIn, zoomOut, project } from '../../utils/graph'; import { fitView, zoomIn, zoomOut, project, getElements } from '../../utils/graph';
import { import {
Elements, Elements,
NodeTypesType, NodeTypesType,
@@ -43,6 +43,9 @@ export interface GraphViewProps {
onlyRenderVisibleNodes: boolean; onlyRenderVisibleNodes: boolean;
isInteractive: boolean; isInteractive: boolean;
selectNodesOnDrag: boolean; selectNodesOnDrag: boolean;
minZoom: number;
maxZoom: number;
defaultZoom: number;
} }
const GraphView = memo( const GraphView = memo(
@@ -66,6 +69,9 @@ const GraphView = memo(
onlyRenderVisibleNodes, onlyRenderVisibleNodes,
isInteractive, isInteractive,
selectNodesOnDrag, selectNodesOnDrag,
minZoom,
maxZoom,
defaultZoom,
}: GraphViewProps) => { }: GraphViewProps) => {
const zoomPane = useRef<HTMLDivElement>(null); const zoomPane = useRef<HTMLDivElement>(null);
const rendererNode = useRef<HTMLDivElement>(null); const rendererNode = useRef<HTMLDivElement>(null);
@@ -79,6 +85,9 @@ const GraphView = memo(
const setOnConnect = useStoreActions((a) => a.setOnConnect); const setOnConnect = useStoreActions((a) => a.setOnConnect);
const setSnapGrid = useStoreActions((actions) => actions.setSnapGrid); const setSnapGrid = useStoreActions((actions) => actions.setSnapGrid);
const setInteractive = useStoreActions((actions) => actions.setInteractive); const setInteractive = useStoreActions((actions) => actions.setInteractive);
const updateTransform = useStoreActions((actions) => actions.updateTransform);
const setMinMaxZoom = useStoreActions((actions) => actions.setMinMaxZoom);
const selectionKeyPressed = useKeyPress(selectionKeyCode); const selectionKeyPressed = useKeyPress(selectionKeyCode);
const rendererClasses = classnames('react-flow__renderer', { 'is-interactive': isInteractive }); const rendererClasses = classnames('react-flow__renderer', { 'is-interactive': isInteractive });
@@ -106,6 +115,10 @@ const GraphView = memo(
setOnConnect(onConnect); setOnConnect(onConnect);
} }
if (defaultZoom !== 1) {
updateTransform({ x: 0, y: 0, k: defaultZoom });
}
return () => { return () => {
window.onresize = null; window.onresize = null;
}; };
@@ -120,6 +133,7 @@ const GraphView = memo(
zoomIn, zoomIn,
zoomOut, zoomOut,
project, project,
getElements,
}); });
} }
}, [d3Initialised, onLoad]); }, [d3Initialised, onLoad]);
@@ -132,6 +146,10 @@ const GraphView = memo(
setInteractive(isInteractive); setInteractive(isInteractive);
}, [isInteractive]); }, [isInteractive]);
useEffect(() => {
setMinMaxZoom({ minZoom, maxZoom });
}, [minZoom, maxZoom]);
useGlobalKeyHandler({ onElementsRemove, deleteKeyCode }); useGlobalKeyHandler({ onElementsRemove, deleteKeyCode });
useElementUpdater(elements); useElementUpdater(elements);
+12
View File
@@ -54,6 +54,9 @@ export interface ReactFlowProps extends Omit<HTMLAttributes<HTMLDivElement>, 'on
onlyRenderVisibleNodes: boolean; onlyRenderVisibleNodes: boolean;
isInteractive: boolean; isInteractive: boolean;
selectNodesOnDrag: boolean; selectNodesOnDrag: boolean;
minZoom: number;
maxZoom: number;
defaultZoom: number;
} }
const ReactFlow = ({ const ReactFlow = ({
@@ -80,6 +83,9 @@ const ReactFlow = ({
onlyRenderVisibleNodes, onlyRenderVisibleNodes,
isInteractive, isInteractive,
selectNodesOnDrag, selectNodesOnDrag,
minZoom,
maxZoom,
defaultZoom,
}: ReactFlowProps) => { }: ReactFlowProps) => {
const nodeTypesParsed = useMemo(() => createNodeTypes(nodeTypes), []); const nodeTypesParsed = useMemo(() => createNodeTypes(nodeTypes), []);
const edgeTypesParsed = useMemo(() => createEdgeTypes(edgeTypes), []); const edgeTypesParsed = useMemo(() => createEdgeTypes(edgeTypes), []);
@@ -107,6 +113,9 @@ const ReactFlow = ({
onlyRenderVisibleNodes={onlyRenderVisibleNodes} onlyRenderVisibleNodes={onlyRenderVisibleNodes}
isInteractive={isInteractive} isInteractive={isInteractive}
selectNodesOnDrag={selectNodesOnDrag} selectNodesOnDrag={selectNodesOnDrag}
minZoom={minZoom}
maxZoom={maxZoom}
defaultZoom={defaultZoom}
/> />
{onSelectionChange && <SelectionListener onSelectionChange={onSelectionChange} />} {onSelectionChange && <SelectionListener onSelectionChange={onSelectionChange} />}
{children} {children}
@@ -136,6 +145,9 @@ ReactFlow.defaultProps = {
onlyRenderVisibleNodes: true, onlyRenderVisibleNodes: true,
isInteractive: true, isInteractive: true,
selectNodesOnDrag: true, selectNodesOnDrag: true,
minZoom: 0.5,
maxZoom: 2,
defaultZoom: 1,
}; };
export default ReactFlow; export default ReactFlow;
+11 -10
View File
@@ -10,10 +10,6 @@ interface UseD3ZoomParams {
onMove?: () => void; onMove?: () => void;
} }
const d3ZoomInstance = zoom()
.scaleExtent([0.5, 2])
.filter(() => !event.button);
export default ({ zoomPane, onMove, selectionKeyPressed }: UseD3ZoomParams): void => { export default ({ zoomPane, onMove, selectionKeyPressed }: UseD3ZoomParams): void => {
const transform = useStoreState((s) => s.transform); const transform = useStoreState((s) => s.transform);
const d3Selection = useStoreState((s) => s.d3Selection); const d3Selection = useStoreState((s) => s.d3Selection);
@@ -24,16 +20,21 @@ export default ({ zoomPane, onMove, selectionKeyPressed }: UseD3ZoomParams): voi
useEffect(() => { useEffect(() => {
if (zoomPane.current) { if (zoomPane.current) {
const selection = select(zoomPane.current).call(d3ZoomInstance); const nextD3ZoomInstance = zoom();
initD3({ zoom: d3ZoomInstance, selection }); const selection = select(zoomPane.current).call(nextD3ZoomInstance);
initD3({ zoom: nextD3ZoomInstance, selection });
} }
}, []); }, []);
useEffect(() => { useEffect(() => {
if (!d3Zoom) {
return;
}
if (selectionKeyPressed) { if (selectionKeyPressed) {
d3ZoomInstance.on('zoom', null); d3Zoom.on('zoom', null);
} else { } else {
d3ZoomInstance.on('zoom', () => { d3Zoom.on('zoom', () => {
if (event.sourceEvent && event.sourceEvent.target !== zoomPane.current) { if (event.sourceEvent && event.sourceEvent.target !== zoomPane.current) {
return; return;
} }
@@ -53,7 +54,7 @@ export default ({ zoomPane, onMove, selectionKeyPressed }: UseD3ZoomParams): voi
} }
return () => { return () => {
d3ZoomInstance.on('zoom', null); d3Zoom.on('zoom', null);
}; };
}, [selectionKeyPressed]); }, [selectionKeyPressed, d3Zoom]);
}; };
+33 -5
View File
@@ -17,6 +17,8 @@ import {
XYPosition, XYPosition,
OnConnectFunc, OnConnectFunc,
SelectionRect, SelectionRect,
HandleType,
SetConnectionId,
} from '../types'; } from '../types';
type TransformXYK = { type TransformXYK = {
@@ -45,6 +47,11 @@ type D3Init = {
selection: D3Selection<Element, unknown, null, undefined>; selection: D3Selection<Element, unknown, null, undefined>;
}; };
type SetMinMaxZoom = {
minZoom: number;
maxZoom: number;
};
type SetSnapGrid = { type SetSnapGrid = {
snapToGrid: boolean; snapToGrid: boolean;
snapGrid: [number, number]; snapGrid: [number, number];
@@ -62,6 +69,8 @@ export interface StoreModel {
d3Zoom: ZoomBehavior<Element, unknown> | null; d3Zoom: ZoomBehavior<Element, unknown> | null;
d3Selection: D3Selection<Element, unknown, null, undefined> | null; d3Selection: D3Selection<Element, unknown, null, undefined> | null;
d3Initialised: boolean; d3Initialised: boolean;
minZoom: number;
maxZoom: number;
nodesSelectionActive: boolean; nodesSelectionActive: boolean;
selectionActive: boolean; selectionActive: boolean;
@@ -69,7 +78,8 @@ export interface StoreModel {
userSelectionRect: SelectionRect; userSelectionRect: SelectionRect;
connectionSourceId: ElementId | null; connectionNodeId: ElementId | null;
connectionHandleType: HandleType | null;
connectionPosition: XYPosition; connectionPosition: XYPosition;
snapToGrid: boolean; snapToGrid: boolean;
@@ -101,11 +111,13 @@ export interface StoreModel {
initD3: Action<StoreModel, D3Init>; initD3: Action<StoreModel, D3Init>;
setMinMaxZoom: Action<StoreModel, SetMinMaxZoom>;
setSnapGrid: Action<StoreModel, SetSnapGrid>; setSnapGrid: Action<StoreModel, SetSnapGrid>;
setConnectionPosition: Action<StoreModel, XYPosition>; setConnectionPosition: Action<StoreModel, XYPosition>;
setConnectionSourceId: Action<StoreModel, ElementId | null>; setConnectionNodeId: Action<StoreModel, SetConnectionId>;
setInteractive: Action<StoreModel, boolean>; setInteractive: Action<StoreModel, boolean>;
@@ -126,6 +138,8 @@ const storeModel: StoreModel = {
d3Zoom: null, d3Zoom: null,
d3Selection: null, d3Selection: null,
d3Initialised: false, d3Initialised: false,
minZoom: 0.5,
maxZoom: 2,
nodesSelectionActive: false, nodesSelectionActive: false,
selectionActive: false, selectionActive: false,
@@ -140,7 +154,8 @@ const storeModel: StoreModel = {
height: 0, height: 0,
draw: false, draw: false,
}, },
connectionSourceId: null, connectionNodeId: null,
connectionHandleType: 'source',
connectionPosition: { x: 0, y: 0 }, connectionPosition: { x: 0, y: 0 },
snapGrid: [16, 16], snapGrid: [16, 16],
@@ -322,16 +337,29 @@ const storeModel: StoreModel = {
initD3: action((state, { zoom, selection }) => { initD3: action((state, { zoom, selection }) => {
state.d3Zoom = zoom; state.d3Zoom = zoom;
state.d3Zoom.scaleExtent([state.minZoom, state.maxZoom]);
state.d3Selection = selection; state.d3Selection = selection;
state.d3Initialised = true; state.d3Initialised = true;
}), }),
setMinMaxZoom: action((state, { minZoom, maxZoom }) => {
state.minZoom = minZoom;
state.maxZoom = maxZoom;
if (state.d3Zoom) {
state.d3Zoom.scaleExtent([minZoom, maxZoom]);
}
}),
setConnectionPosition: action((state, position) => { setConnectionPosition: action((state, position) => {
state.connectionPosition = position; state.connectionPosition = position;
}), }),
setConnectionSourceId: action((state, sourceId) => { setConnectionNodeId: action((state, { connectionNodeId, connectionHandleType }) => {
state.connectionSourceId = sourceId; state.connectionNodeId = connectionNodeId;
state.connectionHandleType = connectionHandleType;
}), }),
setSnapGrid: action((state, { snapToGrid, snapGrid }) => { setSnapGrid: action((state, { snapToGrid, snapGrid }) => {
+49 -61
View File
@@ -5,27 +5,21 @@
overflow: hidden; overflow: hidden;
} }
.react-flow__renderer { .react-flow__renderer,
width: 100%; .react-flow__zoompane,
height: 100%;
position: absolute;
}
.react-flow__zoompane {
width: 100%;
height: 100%;
position: absolute;
top: 0;
left: 0;
z-index: 1;
}
.react-flow__selectionpane { .react-flow__selectionpane {
width: 100%; width: 100%;
height: 100%; height: 100%;
position: absolute; position: absolute;
top: 0; top: 0;
left: 0; left: 0;
}
.react-flow__zoompane {
z-index: 1;
}
.react-flow__selectionpane {
z-index: 2; z-index: 2;
} }
@@ -92,23 +86,31 @@
} }
.react-flow__nodes { .react-flow__nodes {
position: absolute;
width: 100%; width: 100%;
height: 100%; height: 100%;
position: absolute;
z-index: 3;
pointer-events: none; pointer-events: none;
transform-origin: 0 0; transform-origin: 0 0;
z-index: 3;
} }
.react-flow__node { .react-flow__node {
position: absolute; position: absolute;
color: #222;
text-align: center;
user-select: none; user-select: none;
pointer-events: all; pointer-events: all;
transform-origin: 0 0; transform-origin: 0 0;
cursor: grab; cursor: grab;
background: #eee; }
.react-flow__node-default,
.react-flow__node-input,
.react-flow__node-output {
padding: 10px;
border-radius: 5px;
width: 150px;
font-size: 12px;
color: #222;
text-align: center;
&.selected, &.selected,
&.selected:hover { &.selected:hover {
@@ -118,34 +120,19 @@
&:hover { &:hover {
box-shadow: 0 1px 5px 2px rgba(0, 0, 0, 0.08); box-shadow: 0 1px 5px 2px rgba(0, 0, 0, 0.08);
} }
.react-flow__handle {
cursor: crosshair;
}
} }
.react-flow__node-default { .react-flow__node-default {
padding: 10px;
border-radius: 5px;
width: 150px;
background: #ff6060; background: #ff6060;
font-size: 12px;
} }
.react-flow__node-input { .react-flow__node-input {
padding: 10px;
border-radius: 5px;
width: 150px;
background: #9999ff; background: #9999ff;
font-size: 12px;
} }
.react-flow__node-output { .react-flow__node-output {
padding: 10px;
border-radius: 5px;
width: 150px;
background: #55dd99; background: #55dd99;
font-size: 12px;
} }
.react-flow__nodesselection { .react-flow__nodesselection {
@@ -171,29 +158,30 @@
width: 10px; width: 10px;
height: 8px; height: 8px;
background: rgba(255, 255, 255, 0.4); background: rgba(255, 255, 255, 0.4);
cursor: crosshair;
&.bottom {
top: auto;
left: 50%;
bottom: 0;
transform: translate(-50%, 0);
}
&.top {
left: 50%;
top: 0;
transform: translate(-50%, 0);
}
&.left {
top: 50%;
left: 0;
transform: translate(0, -50%);
}
&.right {
right: 0;
top: 50%;
transform: translate(0, -50%);
}
} }
.react-flow__handle-bottom {
top: auto;
left: 50%;
bottom: 0;
transform: translate(-50%, 0);
}
.react-flow__handle-top {
left: 50%;
top: 0;
transform: translate(-50%, 0);
}
.react-flow__handle-left {
top: 50%;
left: 0;
transform: translate(0, -50%);
}
.react-flow__handle-right {
right: 0;
top: 50%;
transform: translate(0, -50%);
}
+6
View File
@@ -144,6 +144,7 @@ type OnLoadParams = {
zoomOut: () => void; zoomOut: () => void;
fitView: FitViewFunc; fitView: FitViewFunc;
project: ProjectFunc; project: ProjectFunc;
getElements: () => Elements;
}; };
export type OnLoadFunc = (params: OnLoadParams) => void; export type OnLoadFunc = (params: OnLoadParams) => void;
@@ -160,6 +161,11 @@ export enum ConnectionLineType {
export type OnConnectFunc = (connection: Connection) => void; export type OnConnectFunc = (connection: Connection) => void;
export type SetConnectionId = {
connectionNodeId: ElementId | null;
connectionHandleType: HandleType | null;
};
export interface HandleElement extends XYPosition, Dimensions { export interface HandleElement extends XYPosition, Dimensions {
id?: ElementId | null; id?: ElementId | null;
position: Position; position: Position;
+14
View File
@@ -210,3 +210,17 @@ const zoom = (amount: number): void => {
export const zoomIn = (): void => zoom(0.2); export const zoomIn = (): void => zoom(0.2);
export const zoomOut = (): void => zoom(-0.2); export const zoomOut = (): void => zoom(-0.2);
export const getElements = (): Elements => {
const { nodes, edges } = store.getState();
return [
...nodes.map((node) => {
const n = { ...node };
delete n.__rg;
return n;
}),
...edges.map((e) => ({ ...e })),
];
};