Merge pull request #1 from wbkd/refactor-state

refactor(state): use easy-peasy
This commit is contained in:
Moritz
2019-08-20 17:58:30 +02:00
committed by GitHub
24 changed files with 434 additions and 435 deletions

61
package-lock.json generated
View File

@@ -3057,6 +3057,20 @@
"readable-stream": "^2.0.2"
}
},
"easy-peasy": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/easy-peasy/-/easy-peasy-3.0.2.tgz",
"integrity": "sha512-qcsr81F6rZN5hEZ1lYRDUHtatxn5Qb4cF5uj5cNXKEXLXmATCxktNC53B2XvccA7K8zSCwUeoFckrCOvLeINWA==",
"requires": {
"immer-peasy": "3.1.3",
"memoizerific": "^1.11.3",
"prop-types": "^15.6.2",
"redux": "^4.0.4",
"redux-thunk": "^2.3.0",
"shallowequal": "^1.1.0",
"ts-toolbelt": "^3.8.0"
}
},
"ecc-jsbn": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz",
@@ -4523,6 +4537,11 @@
"integrity": "sha512-4vf7I2LYV/HaWerSo3XmlMkp5eZ83i+/CDluXi/IGTs/O1sejBNhTtnxzmRZfvOUqj7lZjqHkeTvpgSFDlWZTg==",
"dev": true
},
"immer-peasy": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/immer-peasy/-/immer-peasy-3.1.3.tgz",
"integrity": "sha512-WzoZ96A93jOmcDOLNChMWAqy+ZU8vEYQx2DcKjgo7P5SToiJs+GL+5yQbWzH8X02Lhvv6xrGgVNa1xbki66Eow=="
},
"import-cwd": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/import-cwd/-/import-cwd-2.1.0.tgz",
@@ -5207,6 +5226,11 @@
"integrity": "sha1-wyq9C9ZSXZsFFkW7TyasXcmKDb8=",
"dev": true
},
"map-or-similar": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/map-or-similar/-/map-or-similar-1.5.0.tgz",
"integrity": "sha1-beJlMXSt+12e3DPGnT6Sobdvrwg="
},
"map-visit": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/map-visit/-/map-visit-1.0.0.tgz",
@@ -5287,6 +5311,14 @@
"integrity": "sha512-iV3XNKw06j5Q7mi6h+9vbx23Tv7JkjEVgKHW4pimwyDGWm0OIQntJJ+u1C6mg6mK1EaTv42XQ7w76yuzH7M2cA==",
"dev": true
},
"memoizerific": {
"version": "1.11.3",
"resolved": "https://registry.npmjs.org/memoizerific/-/memoizerific-1.11.3.tgz",
"integrity": "sha1-fIekZGREwy11Q4VwkF8tvRsagFo=",
"requires": {
"map-or-similar": "^1.5.0"
}
},
"merge-source-map": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/merge-source-map/-/merge-source-map-1.0.4.tgz",
@@ -7090,6 +7122,20 @@
"readable-stream": "^2.0.2"
}
},
"redux": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/redux/-/redux-4.0.4.tgz",
"integrity": "sha512-vKv4WdiJxOWKxK0yRoaK3Y4pxxB0ilzVx6dszU2W8wLxlb2yikRph4iV/ymtdJ6ZxpBLFbyrxklnT5yBbQSl3Q==",
"requires": {
"loose-envify": "^1.4.0",
"symbol-observable": "^1.2.0"
}
},
"redux-thunk": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-2.3.0.tgz",
"integrity": "sha512-km6dclyFnmcvxhAcrQV2AkZmPQjzPDjgVlQtR0EQjxZPyJ0BnMf3in1ryuR8A2qU0HldVRfxYXbFSKlI3N7Slw=="
},
"regenerate": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.0.tgz",
@@ -7700,6 +7746,11 @@
"integrity": "sha1-QV9CcC1z2BAzApLMXuhurhoRoXA=",
"dev": true
},
"shallowequal": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/shallowequal/-/shallowequal-1.1.0.tgz",
"integrity": "sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ=="
},
"shebang-command": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-1.2.0.tgz",
@@ -8179,6 +8230,11 @@
"util.promisify": "~1.0.0"
}
},
"symbol-observable": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/symbol-observable/-/symbol-observable-1.2.0.tgz",
"integrity": "sha512-e900nM8RRtGhlV36KGEU9k65K3mPb1WV70OdjfxlG2EAuM1noi/E/BaW/uMhL7bPEssK8QV57vN3esixjUvcXQ=="
},
"symbol-tree": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
@@ -8321,6 +8377,11 @@
"integrity": "sha1-yy4SAwZ+DI3h9hQJS5/kVwTqYAM=",
"dev": true
},
"ts-toolbelt": {
"version": "3.8.1",
"resolved": "https://registry.npmjs.org/ts-toolbelt/-/ts-toolbelt-3.8.1.tgz",
"integrity": "sha512-XDAWEHVscqto6pG0gPSbYaEfEBcl9V0JK5pQIQJ3M+6sYnxK5ZOPyyRA/aq78WxKlLvBSXCPJ1fPX+7qCCg2Zg=="
},
"tty-browserify": {
"version": "0.0.0",
"resolved": "https://registry.npmjs.org/tty-browserify/-/tty-browserify-0.0.0.tgz",

View File

@@ -10,6 +10,7 @@
"classnames": "^2.2.6",
"d3-selection": "^1.4.0",
"d3-zoom": "^1.7.3",
"easy-peasy": "^3.0.2",
"fast-deep-equal": "^3.0.0-beta.1",
"react": "^16.9.0",
"react-dom": "^16.9.0",

View File

@@ -1,27 +0,0 @@
import React, { createContext, useState, memo, useMemo } from 'react';
export const ConnectionContext = createContext({});
export const Provider = memo(({ onConnect, children }) => {
const [sourceId, setSourceId] = useState(null);
const [position, setPosition] = useState({ x: 0, y: 0 });
const connectionContext = useMemo(() => ({
sourceId,
setSourceId,
position,
setPosition,
onConnect
}), [sourceId, position.x, position.y]);
return (
<ConnectionContext.Provider value={connectionContext}>
{children}
</ConnectionContext.Provider>
);
});
Provider.displayName = 'ConnectionProvider';
Provider.whyDidYouRender = false;
export const { Consumer } = ConnectionContext;

View File

@@ -1,14 +1,14 @@
import React, { memo } from 'react';
import cx from 'classnames';
import { setSelectedElements } from '../../state/actions';
import { isInputNode } from '../../utils';
import store from '../../store';
export default EdgeComponent => {
const EdgeWrapper = memo((props) => {
const {
source, target, animated, type,
dispatch, selected, onClick
selected, onClick
} = props;
const edgeClasses = cx('react-graph__edge', { selected, animated });
const onEdgeClick = (evt) => {
@@ -16,7 +16,7 @@ export default EdgeComponent => {
return false;
}
dispatch(setSelectedElements({ source, target }));
store.dispatch.setSelectedElements({ source, target });
onClick({ source, target, type });
};

View File

@@ -1,7 +1,6 @@
import React, { memo, useContext } from 'react';
import React, { memo } from 'react';
import { useStoreState } from 'easy-peasy';
import { GraphContext } from '../GraphContext';
import { ConnectionContext } from '../ConnectionContext';
import ConnectionLine from '../ConnectionLine';
import { isEdge } from '../graph-utils';
@@ -36,10 +35,10 @@ function getEdgePositions(sourceNode, targetNode) {
};
}
function renderEdge(e, props, graphContext) {
function renderEdge(e, props, state) {
const edgeType = e.type || 'default';
const sourceNode = graphContext.state.nodes.find(n => n.id === e.source);
const targetNode = graphContext.state.nodes.find(n => n.id === e.target);
const sourceNode = state.nodes.find(n => n.id === e.source);
const targetNode = state.nodes.find(n => n.id === e.target);
if (!sourceNode) {
throw new Error(`couldn't create edge for source id: ${e.source}`);
@@ -51,7 +50,7 @@ function renderEdge(e, props, graphContext) {
const EdgeComponent = props.edgeTypes[edgeType] || props.edgeTypes.default;
const { sourceX, sourceY, targetX, targetY } = getEdgePositions(sourceNode, targetNode);
const selected = graphContext.state.selectedElements
const selected = state.selectedElements
.filter(isEdge)
.find(elm => elm.source === e.source && elm.target === e.target);
@@ -62,7 +61,6 @@ function renderEdge(e, props, graphContext) {
type={e.type}
onClick={props.onElementClick}
selected={selected}
dispatch={graphContext.dispatch}
animated={e.animated}
style={e.style}
source={e.source}
@@ -76,8 +74,14 @@ function renderEdge(e, props, graphContext) {
}
const EdgeRenderer = memo((props) => {
const graphContext = useContext(GraphContext);
const { position, sourceId : connectionSourceId } = useContext(ConnectionContext);
const state = useStoreState(s => ({
nodes: s.nodes,
edges: s.edges,
transform: s.transform,
selectedElements: s.selectedElements,
connectionSourceId: s.connectionSourceId,
position: s.connectionPosition
}));
const {
width, height, connectionLineStyle, connectionLineType
} = props;
@@ -86,7 +90,7 @@ const EdgeRenderer = memo((props) => {
return null;
}
const { transform, edges, nodes } = graphContext.state;
const { transform, edges, nodes, connectionSourceId, position } = state;
const transformStyle = `translate(${transform[0]},${transform[1]}) scale(${transform[2]})`;
return (
@@ -96,7 +100,7 @@ const EdgeRenderer = memo((props) => {
className="react-graph__edges"
>
<g transform={transformStyle}>
{edges.map(e => renderEdge(e, props, graphContext))}
{edges.map(e => renderEdge(e, props, state))}
{connectionSourceId && (
<ConnectionLine
nodes={nodes}
@@ -113,4 +117,6 @@ const EdgeRenderer = memo((props) => {
);
});
EdgeRenderer.displayName = 'EdgeRenderer';
export default EdgeRenderer;

View File

@@ -1,12 +1,12 @@
import { useEffect, useContext, memo } from 'react';
import { useEffect, memo } from 'react';
import { useStoreState, useStoreActions } from 'easy-peasy';
import useKeyPress from '../hooks/useKeyPress';
import { setNodesSelection } from '../state/actions';
import { GraphContext } from '../GraphContext';
import { isEdge, getConnectedEdges } from '../graph-utils';
export default memo((props) => {
const { state, dispatch } = useContext(GraphContext);
const state = useStoreState(s => ({ selectedElements: s.selectedElements, edges: s.edges }))
const setNodesSelection = useStoreActions(a => a.setNodesSelection);
const removePressed = useKeyPress('Backspace');
useEffect(() => {
@@ -20,7 +20,7 @@ export default memo((props) => {
}
props.onElementsRemove(elementsToRemove);
dispatch(setNodesSelection({ isActive: false }));
setNodesSelection({ isActive: false });
}
}, [removePressed])

View File

@@ -1,73 +0,0 @@
import React, { createContext, useEffect, useReducer, useMemo } from 'react';
import PropTypes from 'prop-types';
import isEqual from 'fast-deep-equal';
import { reducer, initialState } from '../state';
import { setNodes, setEdges } from '../state/actions';
import { parseElement, isNode, isEdge } from '../graph-utils';
export const GraphContext = createContext({});
export const Provider = ({
elements,
children,
}) => {
const [state, dispatch] = useReducer(reducer, initialState);
useEffect(() => {
const nodes = elements.filter(isNode);
const edges = elements.filter(isEdge).map(parseElement, state.transform);
const nextNodes = nodes.map(propNode => {
const existingNode = state.nodes.find(n => n.id === propNode.id);
if (existingNode) {
const data = !isEqual(existingNode.data, propNode.data) ?
{ ...existingNode.data, ...propNode.data } : existingNode.data;
return {
...existingNode,
data
};
}
return parseElement(propNode, state.transform);
});
const nodesChanged = !isEqual(state.nodes, nextNodes);
const edgesChanged = !isEqual(state.edges, edges);
if (nodesChanged) {
dispatch(setNodes(nextNodes));
}
if (edgesChanged) {
dispatch(setEdges(edges));
}
});
const graphContext = useMemo(() => ({
state,
dispatch
}), [state]);
return (
<GraphContext.Provider
value={graphContext}
>
{children}
</GraphContext.Provider>
);
};
export const { Consumer } = GraphContext;
Provider.displayName = 'GraphProvider';
Provider.propTypes = {
elements: PropTypes.arrayOf(PropTypes.object)
};
Provider.defaultProps = {
elements: [],
};

View File

@@ -1,25 +1,33 @@
import React, { useEffect, useContext, useRef, memo } from 'react';
import React, { useEffect, useRef, memo } from 'react';
import { useStoreState, useStoreActions } from 'easy-peasy';
import { Provider } from '../ConnectionContext';
import { GraphContext } from '../GraphContext';
import NodeRenderer from '../NodeRenderer';
import EdgeRenderer from '../EdgeRenderer';
import UserSelection from '../UserSelection';
import NodesSelection from '../NodesSelection';
import { setNodesSelection } from '../state/actions';
import { updateSize, fitView, zoomIn, zoomOut } from '../state/view-actions';
import useKeyPress from '../hooks/useKeyPress';
import useD3Zoom from '../hooks/useD3Zoom';
import { getDimensions } from '../utils';
import { fitView, zoomIn, zoomOut } from '../graph-utils';
const GraphView = memo((props) => {
const zoomPane = useRef();
const rendererNode = useRef();
const { state, dispatch } = useContext(GraphContext);
const state = useStoreState(s => ({
width: s.width,
height: s.height,
nodes: s.nodes,
edges: s.edges,
d3Initialised: s.d3Initialised,
nodesSelectionActive: s.nodesSelectionActive
}));
const updateSize = useStoreActions(actions => actions.updateSize);
const setNodesSelection = useStoreActions(actions => actions.setNodesSelection);
const shiftPressed = useKeyPress('Shift');
const updateDimensions = () => {
const size = getDimensions(rendererNode.current);
dispatch(updateSize(size));
updateSize(size);
};
useEffect(() => {
@@ -36,37 +44,33 @@ const GraphView = memo((props) => {
useEffect(() => {
if (state.d3Initialised) {
props.onLoad({
nodes: state.nodes,
edges: state.edges,
fitView: opts => dispatch(fitView(opts)),
zoomIn: () => dispatch(zoomIn()),
zoomOut: () => dispatch(zoomOut())
fitView,
zoomIn,
zoomOut
});
}
}, [state.d3Initialised]);
return (
<div className="react-graph__renderer" ref={rendererNode}>
<Provider onConnect={props.onConnect}>
<NodeRenderer
nodeTypes={props.nodeTypes}
onElementClick={props.onElementClick}
onNodeDragStop={props.onNodeDragStop}
/>
<EdgeRenderer
width={state.width}
height={state.height}
edgeTypes={props.edgeTypes}
onElementClick={props.onElementClick}
connectionLineType={props.connectionLineType}
connectionLineStyle={props.connectionLineStyle}
/>
</Provider>
<NodeRenderer
nodeTypes={props.nodeTypes}
onElementClick={props.onElementClick}
onNodeDragStop={props.onNodeDragStop}
/>
<EdgeRenderer
width={state.width}
height={state.height}
edgeTypes={props.edgeTypes}
onElementClick={props.onElementClick}
connectionLineType={props.connectionLineType}
connectionLineStyle={props.connectionLineStyle}
/>
{shiftPressed && <UserSelection />}
{state.nodesSelectionActive && <NodesSelection />}
<div
className="react-graph__zoompane"
onClick={() => dispatch(setNodesSelection({ isActive: false }))}
onClick={() => setNodesSelection({ isActive: false })}
ref={zoomPane}
/>
</div>

View File

@@ -1,12 +1,16 @@
import React, { memo, useContext } from 'react';
import { useStoreActions, useStoreState } from 'easy-peasy';
import BaseHandle from './BaseHandle';
import NodeIdContext from '../NodeIdContext'
import { ConnectionContext } from '../../ConnectionContext';
export default memo((props) => {
const SourceHandle = memo((props) => {
const nodeId = useContext(NodeIdContext);
const { setPosition, setSourceId, onConnect } = useContext(ConnectionContext);
const { setPosition, setSourceId } = useStoreActions(a => ({
setPosition: a.setConnectionPosition,
setSourceId: a.setConnectionSourceId
}));
const onConnect = useStoreState(s => s.onConnect);
return (
<BaseHandle
@@ -19,3 +23,7 @@ export default memo((props) => {
/>
);
});
SourceHandle.displayName = 'SourceHandle';
export default SourceHandle;

View File

@@ -1,12 +1,16 @@
import React, { memo, useContext } from 'react';
import { useStoreActions, useStoreState } from 'easy-peasy';
import BaseHandle from './BaseHandle';
import { ConnectionContext } from '../../ConnectionContext';
import NodeIdContext from '../NodeIdContext'
const TargetHandle = memo((props) => {
const nodeId = useContext(NodeIdContext);
const { setPosition, setSourceId, onConnect } = useContext(ConnectionContext);
const { setPosition, setSourceId } = useStoreActions(a => ({
setPosition: a.setConnectionPosition,
setSourceId: a.setConnectionSourceId
}));
const onConnect = useStoreState(s => s.onConnect);
return (
<BaseHandle

View File

@@ -2,9 +2,9 @@ import React, { useEffect, useRef, useState, memo } from 'react';
import ReactDraggable from 'react-draggable';
import cx from 'classnames';
import { updateNodeData, updateNodePos, setSelectedElements } from '../../state/actions';
import { getDimensions, isInputNode } from '../../utils';
import { Provider } from '../NodeIdContext';
import store from '../../store';
const isHandle = e => (
e.target.className &&
@@ -29,7 +29,7 @@ const getHandleBounds = (sel, nodeElement, parentBounds, k) => {
};
};
const onStart = (evt, { dispatch, setOffset, onClick, id, type, data, position, transform }) => {
const onStart = (evt, { setOffset, onClick, id, type, data, position, transform }) => {
if (isInputNode(evt) || isHandle(evt)) {
return false;
}
@@ -42,22 +42,22 @@ const onStart = (evt, { dispatch, setOffset, onClick, id, type, data, position,
const offsetY = scaledClient.y - position.y - transform[1];
const node = { id, type, position, data };
dispatch(setSelectedElements({ id, type }));
store.dispatch.setSelectedElements({ id, type });
setOffset({ x: offsetX, y: offsetY });
onClick(node);
};
const onDrag = (evt, { dispatch, setDragging, id, offset, transform }) => {
const onDrag = (evt, { setDragging, id, offset, transform }) => {
const scaledClient = {
x: evt.clientX * (1 / transform[2]),
y: evt.clientY * (1 / transform[2])
};
setDragging(true);
dispatch(updateNodePos(id, {
store.dispatch.updateNodePos({ id, pos: {
x: scaledClient.x - transform[0] - offset.x,
y: scaledClient.y - transform[1] - offset.y
}));
}});
};
const onStop = ({ onNodeDragStop, setDragging, isDragging, id, type, position, data }) => {
@@ -78,7 +78,7 @@ export default NodeComponent => {
const [isDragging, setDragging] = useState(false);
const {
id, type, data, transform, xPos, yPos, selected,
dispatch, onClick, onNodeDragStop, style
onClick, onNodeDragStop, style
} = props;
const position = { x: xPos, y: yPos };
@@ -93,13 +93,13 @@ export default NodeComponent => {
target: getHandleBounds('.target', nodeElement.current, bounds, transform[2])
};
dispatch(updateNodeData(id, { ...dimensions, handleBounds }));
store.dispatch.updateNodeData({ id, ...dimensions, handleBounds });
}, []);
return (
<ReactDraggable.DraggableCore
onStart={evt => onStart(evt, { dispatch, onClick, id, type, data, setOffset, transform, position })}
onDrag={evt => onDrag(evt, { dispatch, setDragging, id, offset, transform })}
onStart={evt => onStart(evt, { onClick, id, type, data, setOffset, transform, position })}
onDrag={evt => onDrag(evt, { setDragging, id, offset, transform })}
onStop={() => onStop({ onNodeDragStop, isDragging, setDragging, id, type, position, data })}
scale={transform[2]}
>

View File

@@ -1,9 +1,9 @@
import React, { memo, useContext } from 'react';
import React, { memo } from 'react';
import { useStoreState } from 'easy-peasy';
import { GraphContext } from '../GraphContext';
import { isNode } from '../graph-utils';
function renderNode(d, props, graphContext) {
function renderNode(d, props, state) {
const nodeType = d.type || 'default';
if (!props.nodeTypes[nodeType]) {
@@ -11,7 +11,7 @@ function renderNode(d, props, graphContext) {
}
const NodeComponent = props.nodeTypes[nodeType] || props.nodeTypes.default;
const selected = graphContext.state.selectedElements
const selected = state.selectedElements
.filter(isNode)
.map(e => e.id)
.includes(d.id);
@@ -26,9 +26,7 @@ function renderNode(d, props, graphContext) {
yPos={d.__rg.position.y}
onClick={props.onElementClick}
onNodeDragStop={props.onNodeDragStop}
dispatch={graphContext.dispatch}
transform={graphContext.state.transform}
getNodeById={graphContext.getNodeById}
transform={state.transform}
selected={selected}
style={d.style}
/>
@@ -36,8 +34,13 @@ function renderNode(d, props, graphContext) {
}
const NodeRenderer = memo((props) => {
const graphContext = useContext(GraphContext);
const { transform, nodes } = graphContext.state;
const state = useStoreState(s => ({
nodes: s.nodes,
transform: s.transform,
selectedElements: s.selectedElements
}));
const { transform, nodes } = state;
const transformStyle = { transform : `translate(${transform[0]}px,${transform[1]}px) scale(${transform[2]})` };
return (
@@ -45,7 +48,7 @@ const NodeRenderer = memo((props) => {
className="react-graph__nodes"
style={transformStyle}
>
{nodes.map(d => renderNode(d, props, graphContext))}
{nodes.map(d => renderNode(d, props, state))}
</div>
);
});

View File

@@ -1,9 +1,8 @@
import React, { useContext, useState, memo } from 'react';
import React, { useState, memo } from 'react';
import ReactDraggable from 'react-draggable';
import { useStoreState, useStoreActions } from 'easy-peasy';
import { GraphContext } from '../GraphContext';
import { isNode } from '../graph-utils';
import { updateNodePos } from '../state/actions';
function getStartPositions(elements) {
return elements
@@ -21,10 +20,14 @@ function getStartPositions(elements) {
}
export default memo(() => {
const graphContext = useContext(GraphContext);
const [offset, setOffset] = useState({ x: 0, y: 0 });
const [startPositions, setStartPositions] = useState({});
const { state, dispatch } = graphContext;
const state = useStoreState(s => ({
transform: s.transform,
selectedNodesBbox: s.selectedNodesBbox,
selectedElements: s.selectedElements
}));
const updateNodePos = useStoreActions(a => a.updateNodePos);
const [x, y, k] = state.transform;
const position = state.selectedNodesBbox;
@@ -48,10 +51,10 @@ export default memo(() => {
};
state.selectedElements.filter(isNode).forEach(node => {
dispatch(updateNodePos(node.id, {
updateNodePos({ id: node.id, pos: {
x: startPositions[node.id].x + scaledClient.x - position.x - offset.x - x ,
y: startPositions[node.id].y + scaledClient.y - position.y - offset.y - y
}));
}});
});
};
@@ -59,7 +62,7 @@ export default memo(() => {
<div
className="react-graph__nodesselection"
style={{
transform: `translate(${state.transform[0]}px,${state.transform[1]}px) scale(${state.transform[2]})`
transform: `translate(${x}px,${y}px) scale(${k})`
}}
>
<ReactDraggable
@@ -79,4 +82,4 @@ export default memo(() => {
</ReactDraggable>
</div>
);
});
});

View File

@@ -1,22 +1,26 @@
import React, { useRef, useContext, useEffect } from 'react';
import React, { useRef, useEffect } from 'react';
import { useStoreState } from 'easy-peasy';
import classnames from 'classnames';
import { isFunction } from '../../utils'
import { getNodesInside } from '../../graph-utils';
import { GraphContext } from '../../GraphContext';
const baseStyle = {
position: 'absolute',
zIndex: 5,
bottom: 10,
right: 10,
width: 200,
width: 200
};
export default ({ style = {}, className, bgColor = '#f8f8f8', nodeColor = '#ddd' }) => {
const canvasNode = useRef(null);
const { state } = useContext(GraphContext);
const mapClasses = classnames('react-graph__minimap', className);
const state = useStoreState(s => ({
width: s.width,
height: s.height,
nodes: s.nodes,
transform: s.transform,
})); const mapClasses = classnames('react-graph__minimap', className);
const nodePositions = state.nodes.map(n => n.__rg.position);
const width = style.width || baseStyle.width;
const height = (state.height / (state.width || 1)) * width;

View File

@@ -0,0 +1,59 @@
import { useEffect, memo } from 'react';
import { useStoreState, useStoreActions } from 'easy-peasy';
import isEqual from 'fast-deep-equal';
import { parseElement, isNode, isEdge } from '../graph-utils';
const ElementUpdater = (props) => {
const state = useStoreState(s => ({
nodes: s.nodes,
edges: s.edges,
transform: s.transform
}));
const setNodes = useStoreActions(a => a.setNodes);
const setEdges = useStoreActions(a => a.setEdges);
const setOnConnect = useStoreActions(a => a.setOnConnect);
useEffect(() => {
const nodes = props.elements.filter(isNode);
const edges = props.elements.filter(isEdge).map(parseElement);
const nextNodes = nodes.map(propNode => {
const existingNode = state.nodes.find(n => n.id === propNode.id);
if (existingNode) {
const data = !isEqual(existingNode.data, propNode.data) ?
{ ...existingNode.data, ...propNode.data } : existingNode.data;
return {
...existingNode,
data
};
}
return parseElement(propNode, state.transform);
});
const nodesChanged = !isEqual(state.nodes, nextNodes);
const edgesChanged = !isEqual(state.edges, edges);
if (nodesChanged) {
setNodes(nextNodes);
}
if (edgesChanged) {
setEdges(edges);
}
});
useEffect(() => {
setOnConnect(props.onConnect);
}, []);
return null;
};
ElementUpdater.displayName = 'ElementUpdater';
export default memo(ElementUpdater);

View File

@@ -1,4 +1,6 @@
import React, { useMemo } from 'react';
import React, { useMemo, useEffect } from 'react';
import isEqual from 'fast-deep-equal';
import { StoreProvider } from 'easy-peasy';
if (process.env.NODE_ENV !== 'production') {
const whyDidYouRender = require('@welldone-software/why-did-you-render');
@@ -7,7 +9,6 @@ if (process.env.NODE_ENV !== 'production') {
import GraphView from '../GraphView';
import GlobalKeyHandler from '../GlobalKeyHandler';
import { Provider } from '../GraphContext';
import DefaultNode from '../NodeRenderer/NodeTypes/DefaultNode';
import InputNode from '../NodeRenderer/NodeTypes/InputNode';
@@ -18,6 +19,8 @@ import BezierEdge from '../EdgeRenderer/EdgeTypes/BezierEdge';
import StraightEdge from '../EdgeRenderer/EdgeTypes/StraightEdge';
import StepEdge from '../EdgeRenderer/EdgeTypes/StepEdge';
import { createEdgeTypes } from '../EdgeRenderer/utils';
import store from '../store';
import ElementUpdater from './ElementUpdater';
import '../style.css';
@@ -31,7 +34,8 @@ const ReactGraph = ({
return (
<div style={style} className="react-graph">
<Provider elements={elements}>
<StoreProvider store={store}>
<ElementUpdater elements={elements} onConnect={onConnect} />
<GraphView
onLoad={onLoad}
onMove={onMove}
@@ -41,13 +45,12 @@ const ReactGraph = ({
edgeTypes={edgeTypesParsed}
connectionLineType={connectionLineType}
connectionLineStyle={connectionLineStyle}
onConnect={onConnect}
/>
<GlobalKeyHandler
onElementsRemove={onElementsRemove}
/>
{children}
</Provider>
</StoreProvider>
</div>
);
};

View File

@@ -1,7 +1,5 @@
import React, { useEffect, useRef, useState, useContext, memo } from 'react';
import { GraphContext } from '../GraphContext';
import { updateSelection, setSelection, setNodesSelection } from '../state/actions';
import React, { useEffect, useRef, useState, memo } from 'react';
import { useStoreActions } from 'easy-peasy';
const initialRect = {
startX: 0,
@@ -25,7 +23,9 @@ function getMousePosition(evt) {
export default memo(() => {
const selectionPane = useRef(null);
const [rect, setRect] = useState(initialRect);
const { dispatch } = useContext(GraphContext);
const setSelection = useStoreActions(a => a.setSelection);
const updateSelection = useStoreActions(a => a.updateSelection);
const setNodesSelection = useStoreActions(a => a.setNodesSelection);
useEffect(() => {
function onMouseDown(evt) {
@@ -40,7 +40,7 @@ export default memo(() => {
draw: true
}));
dispatch(setSelection(true));
setSelection(true);
}
function onMouseMove(evt) {
@@ -60,7 +60,7 @@ export default memo(() => {
height: negativeY ? currentRect.startY - mousePos.y : mousePos.y - currentRect.startY,
};
dispatch(updateSelection(nextRect));
updateSelection(nextRect);
return nextRect;
});
@@ -68,8 +68,8 @@ export default memo(() => {
function onMouseUp() {
setRect((currentRect) => {
dispatch(setNodesSelection({ isActive: true, selection: currentRect }));
dispatch(setSelection(false));
setNodesSelection({ isActive: true, selection: currentRect });
setSelection(false);
return {
...currentRect,

102
src/actions.js Normal file
View File

@@ -0,0 +1,102 @@
import { action } from 'easy-peasy';
import isEqual from 'fast-deep-equal';
import { getBoundingBox, getNodesInside, getConnectedEdges } from './graph-utils';
export default {
setOnConnect: action((state, onConnect) => {
state.onConnect = onConnect;
}),
setNodes: action((state, nodes) => {
state.nodes = nodes;
}),
setEdges: action((state, edges) => {
state.edges = edges;
}),
updateNodeData: action((state, { id, ...data }) => {
state.nodes.forEach((n) => {
if (n.id === id) {
n.__rg = {
...n.__rg,
...data
};
}
});
}),
updateNodePos: action((state, { id, pos }) => {
state.nodes.forEach((n) => {
if (n.id === id) {
n.__rg = {
...n.__rg,
position: pos
};
}
});
}),
setSelection: action((state, isActive) => {
state.selectionActive = isActive;
}),
setNodesSelection: action((state, { isActive, selection }) => {
if (!isActive) {
state.nodesSelectionActive = false;
state.selectedElements = [];
return;
}
const selectedNodes = getNodesInside(state.nodes, selection, state.transform);
const selectedNodesBbox = getBoundingBox(selectedNodes);
state.selection = selection;
state.nodesSelectionActive = true;
state.selectedNodesBbox = selectedNodesBbox;
state.nodesSelectionActive = true;
}),
setSelectedElements: action((state, elements) => {
const selectedElementsArr = Array.isArray(elements) ? elements : [elements];
const selectedElementsUpdated = !isEqual(selectedElementsArr, state.selectedElements);
const selectedElements = selectedElementsUpdated ? selectedElementsArr : state.selectedElements;
state.selectedElements = selectedElements;
}),
updateSelection: action((state, selection) => {
const selectedNodes = getNodesInside(state.nodes, selection, state.transform);
const selectedEdges = getConnectedEdges(selectedNodes, state.edges);
const nextSelectedElements = [...selectedNodes, ...selectedEdges];
const selectedElementsUpdated = !isEqual(nextSelectedElements, state.selectedElements);
state.selection = selection;
state.selectedElements = selectedElementsUpdated ? nextSelectedElements: state.selectedElements
}),
updateTransform: action((state, transform) => {
state.transform = [transform.x, transform.y, transform.k];
}),
updateSize: action((state, size) => {
state.width = size.width;
state.height = size.height;
}),
initD3: action((state, { zoom, selection }) => {
state.d3Zoom = zoom;
state.d3Selection = selection;
state.d3Initialised = true;
}),
setConnectionPosition: action((state, position) => {
state.connectionPosition = position;
}),
setConnectionSourceId: action((state, sourceId) => {
state.connectionSourceId = sourceId;
})
};

View File

@@ -1,3 +1,6 @@
import { zoomIdentity } from 'd3-zoom';
import store from './store';
export const isEdge = element => element.source && element.target;
export const isNode = element => !element.source && !element.target;
@@ -142,11 +145,37 @@ export const getConnectedEdges = (nodes, edges) => {
return edges.filter(e => nodeIds.includes(e.source) || nodeIds.includes(e.target))
};
export const fitView = ({ padding = 0 } = {}) => {
const state = store.getState();
const bounds = getBoundingBox(state.nodes);
const maxBoundsSize = Math.max(bounds.width, bounds.height);
const k = Math.min(state.width, state.height) / (maxBoundsSize + (maxBoundsSize * padding));
const boundsCenterX = bounds.x + (bounds.width / 2);
const boundsCenterY = bounds.y + (bounds.height / 2);
const transform = [(state.width / 2) - (boundsCenterX * k), (state.height / 2) - (boundsCenterY * k)];
const fittedTransform = zoomIdentity.translate(transform[0], transform[1]).scale(k);
state.d3Selection.call(state.d3Zoom.transform, fittedTransform);
};
export const zoomIn = () => {
const state = store.getState();
state.d3Zoom.scaleTo(state.d3Selection, state.transform[2] + 0.2);
};
export const zoomOut = () => {
const state = store.getState();
state.d3Zoom.scaleTo(state.d3Selection, state.transform[2] - 0.2);
};
export default {
isEdge,
separateElements,
getBoundingBox,
graphPosToZoomedPos,
getConnectedEdges,
parseElement
parseElement,
fitView,
zoomIn,
zoomOut
};

View File

@@ -1,18 +1,26 @@
import { useEffect, useContext } from 'react';
import { useEffect } from 'react';
import * as d3Zoom from 'd3-zoom';
import { select, event } from 'd3-selection';
import { updateTransform, initD3 } from '../state/view-actions';
import { GraphContext } from '../GraphContext';
import { useStoreState, useStoreActions } from 'easy-peasy';
const d3ZoomInstance = d3Zoom.zoom().scaleExtent([0.5, 2]);
export default function useD3Zoom(zoomPane, onMove, shiftPressed) {
const { state, dispatch } = useContext(GraphContext);
const state = useStoreState(s => ({
transform: s.transform,
d3Selection: s.d3Selection,
d3Zoom: s.d3Zoom,
edges: s.edged,
d3Initialised: s.d3Initialised,
nodesSelectionActive: s.nodesSelectionActive
}));
const initD3 = useStoreActions(actions => actions.initD3);
const updateTransform = useStoreActions(actions => actions.updateTransform);
useEffect(() => {
const selection = select(zoomPane.current).call(d3ZoomInstance);
dispatch(initD3({ zoom: d3ZoomInstance, selection }));
initD3({ zoom: d3ZoomInstance, selection });
}, []);
useEffect(() => {
@@ -24,7 +32,7 @@ export default function useD3Zoom(zoomPane, onMove, shiftPressed) {
return false;
}
dispatch(updateTransform(event.transform));
updateTransform(event.transform);
onMove();
});

View File

@@ -1,52 +0,0 @@
import {
SET_NODES, SET_EDGES, UPDATE_NODE_DATA, UPDATE_NODE_POS,
UPDATE_SELECTION, SET_SELECTION, SET_NODES_SELECTION,
SET_SELECTED_ELEMENTS, REMOVE_NODES,
} from './index';
export const setNodes = nodes => ({
type: SET_NODES,
payload: { nodes }
});
export const setEdges = edges => ({
type: SET_EDGES,
payload: { edges }
});
export const updateNodeData = (id, data) => ({
type: UPDATE_NODE_DATA,
payload: { id, data }
});
export const updateNodePos = (id, pos) => ({
type: UPDATE_NODE_POS,
payload: { id, pos }
});
export const setSelection = isActive => ({
type: SET_SELECTION,
payload: { selectionActive: isActive }
});
export const setNodesSelection = ({ isActive, selection }) => ({
type: SET_NODES_SELECTION,
payload: { nodesSelectionActive: isActive, selection }
});
export const setSelectedElements = elements => ({
type: SET_SELECTED_ELEMENTS,
payload: {
selectedElements: Array.isArray(elements) ? elements : [elements]
}
});
export const removeNodes = ids => ({
type: REMOVE_NODES,
payload: { ids: Array.isArray(ids) ? ids : [ids] }
});
export const updateSelection = selection => ({
type: UPDATE_SELECTION,
payload: { selection }
});

View File

@@ -1,142 +0,0 @@
import { zoomIdentity } from 'd3-zoom';
import isEqual from 'fast-deep-equal';
import { getBoundingBox, getNodesInside, getConnectedEdges } from '../graph-utils';
export const SET_EDGES = 'SET_EDGES';
export const SET_NODES = 'SET_NODES';
export const UPDATE_NODE_DATA = 'UPDATE_NODE_DATA';
export const UPDATE_NODE_POS = 'UPDATE_NODE_POS';
export const UPDATE_TRANSFORM = 'UPDATE_TRANSFORM';
export const UPDATE_SIZE = 'UPDATE_SIZE';
export const INIT_D3 = 'INIT_D3';
export const FIT_VIEW = 'FIT_VIEW';
export const ZOOM_IN = 'ZOOM_IN';
export const ZOOM_OUT = 'ZOOM_OUT';
export const UPDATE_SELECTION = 'UPDATE_SELECTION';
export const SET_SELECTION = 'SET_SELECTION';
export const SET_NODES_SELECTION = 'SET_NODES_SELECTION';
export const SET_SELECTED_ELEMENTS = 'SET_SELECTED_ELEMENTS';
export const REMOVE_NODES = 'REMOVE_NODES';
export const initialState = {
width: 0,
height: 0,
transform: [0, 0, 1],
nodes: [],
edges: [],
selectedElements: [],
selectedNodesBbox: { x: 0, y: 0, width: 0, height: 0 },
d3Zoom: null,
d3Selection: null,
d3Initialised: false,
nodesSelectionActive: false,
selectionActive: false,
selection: {}
};
export const reducer = (state, action) => {
switch (action.type) {
case UPDATE_NODE_DATA: {
return {
...state,
nodes: state.nodes.map((n) => {
if (n.id === action.payload.id) {
n.__rg = {
...n.__rg,
...action.payload.data
};
}
return n;
})
};
}
case UPDATE_NODE_POS: {
return {
...state,
nodes: state.nodes.map((n) => {
if (n.id === action.payload.id) {
n.__rg = {
...n.__rg,
position: action.payload.pos
};
}
return n;
})
};
}
case FIT_VIEW: {
const bounds = getBoundingBox(state.nodes);
const maxBoundsSize = Math.max(bounds.width, bounds.height);
const k = Math.min(state.width, state.height) / (maxBoundsSize + (maxBoundsSize * action.payload.padding));
const boundsCenterX = bounds.x + (bounds.width / 2);
const boundsCenterY = bounds.y + (bounds.height / 2);
const transform = [(state.width / 2) - (boundsCenterX * k), (state.height / 2) - (boundsCenterY * k)];
const fittedTransform = zoomIdentity.translate(transform[0], transform[1]).scale(k);
state.d3Selection.call(state.d3Zoom.transform, fittedTransform);
return state;
}
case ZOOM_IN: {
const { transform } = state;
state.d3Zoom.scaleTo(state.d3Selection, transform[2] + 0.2);
return state;
}
case ZOOM_OUT: {
const { transform } = state;
state.d3Zoom.scaleTo(state.d3Selection, transform[2] - 0.2);
return state;
}
case UPDATE_SELECTION: {
const selectedNodes = getNodesInside(state.nodes, action.payload.selection, state.transform);
const selectedEdges = getConnectedEdges(selectedNodes, state.edges);
const nextSelectedElements = [...selectedNodes, ...selectedEdges];
const selectedElementsUpdated = !isEqual(nextSelectedElements, state.selectedElements);
return {
...state,
...action.payload,
selectedElements: selectedElementsUpdated ? nextSelectedElements: state.selectedElements
};
}
case SET_NODES_SELECTION: {
if (!action.payload.nodesSelectionActive) {
return { ...state, nodesSelectionActive: false, selectedElements: [] };
}
const selectedNodes = getNodesInside(state.nodes, action.payload.selection, state.transform);
const selectedNodesBbox = getBoundingBox(selectedNodes);
return { ...state, ...action.payload, selectedNodesBbox };
}
case SET_SELECTED_ELEMENTS: {
const selectedElementsUpdated = !isEqual(action.payload.selectedElements, state.selectedElements);
const selectedElements = selectedElementsUpdated ? action.payload.selectedElements : state.selectedElements;
return { ...state, selectedElements, odesSelectionActive: false };
}
// unused
case REMOVE_NODES: {
const { ids } = action.payload;
const nextEdges = state.edges.filter(e => !ids.includes(e.target) && !ids.includes(e.source));
const nextNodes = state.nodes.filter(n => !ids.includes(n.id));
return { ...state, nodes: nextNodes, edges: nextEdges };
}
case SET_NODES:
case SET_EDGES:
case UPDATE_TRANSFORM:
case INIT_D3:
case UPDATE_SIZE:
case SET_SELECTION:
return { ...state, ...action.payload };
default:
return state;
}
};

View File

@@ -1,32 +0,0 @@
import {
UPDATE_TRANSFORM, UPDATE_SIZE, INIT_D3,
FIT_VIEW, ZOOM_IN, ZOOM_OUT
} from './index';
export const updateTransform = transform => ({
type: UPDATE_TRANSFORM,
payload: { transform: [transform.x, transform.y, transform.k] }
});
export const updateSize = size => ({
type: UPDATE_SIZE,
payload: size
});
export const initD3 = ({ zoom, selection }) => ({
type: INIT_D3,
payload: { d3Zoom: zoom, d3Selection: selection, d3Initialised: true }
});
export const fitView = ({ padding = 0 } = {}) => ({
type: FIT_VIEW,
payload: { padding }
});
export const zoomIn = () => ({
type: ZOOM_IN
});
export const zoomOut = () => ({
type: ZOOM_OUT
});

30
src/store.js Normal file
View File

@@ -0,0 +1,30 @@
import { createStore } from 'easy-peasy';
import actions from './actions';
const store = createStore({
width: 0,
height: 0,
transform: [0, 0, 1],
nodes: [],
edges: [],
selectedElements: [],
selectedNodesBbox: { x: 0, y: 0, width: 0, height: 0 },
d3Zoom: null,
d3Selection: null,
d3Initialised: false,
nodesSelectionActive: false,
selectionActive: false,
selection: {},
connectionSourceId: null,
connectionPosition: { x: 0, y: 0 },
onConnect: () => {},
...actions
});
export default store;