Graph Prop: isInteractive (#56)

* feat(props): add isInteractive

* refactor(selection): dont allow if not interactive

* feat(renderer): add class if is interactive

* chore(inactive): add tests

* refactor(inactive): no connection line, no edge selection

closes #49
This commit is contained in:
Moritz
2019-10-23 18:42:17 +02:00
committed by GitHub
parent cf4bd7199a
commit 873fb50b19
28 changed files with 2528 additions and 377 deletions
+1
View File
@@ -52,6 +52,7 @@ const BasicGraph = () => (
- `snapToGrid`: default: `false` - `snapToGrid`: default: `false`
- `snapGrid`: [x, y] array - default: `[16, 16]` - `snapGrid`: [x, y] array - default: `[16, 16]`
- `onlyRenderVisibleNodes`: default: `true` - `onlyRenderVisibleNodes`: default: `true`
- `isInteractive`: default: `true`. If the graph is not interactive you can't drag any nodes
## Nodes ## Nodes
+71
View File
@@ -0,0 +1,71 @@
describe('Inactive Graph Rendering', () => {
it('renders a graph', () => {
cy.visit('/inactive');
cy.get('.react-flow__renderer');
cy.get('.react-flow__node').should('have.length', 4);
cy.get('.react-flow__edge').should('have.length', 2);
cy.get('.react-flow__node')
.children('div')
.children('.react-flow__handle');
});
it('tries to select a node by click', () => {
cy.get('.react-flow__node:first')
.click()
.should('have.not.class', 'selected');
});
it('tries to select an edge by click', () => {
cy.get('.react-flow__edge:first')
.click()
.should('have.not.class', 'selected');
});
it('tries to do a selection', () => {
cy.get('body')
.type('{shift}', { release: false })
.get('.react-flow__selectionpane')
.should('not.exist');
});
it('tries to drag a node', () => {
const styleBeforeDrag = Cypress.$('.react-flow__node:first').css('transform');
cy.drag('.react-flow__node:first', { x: 500, y: 25 }).then($el => {
const styleAfterDrag = $el.css('transform');
expect(styleBeforeDrag).to.equal(styleAfterDrag);
});
});
it('tries to connect nodes', () => {
cy.get('.react-flow__node')
.contains('Node 3')
.find('.react-flow__handle.source')
.trigger('mousedown', { which: 1 });
cy.get('.react-flow__node')
.contains('Node 4')
.find('.react-flow__handle.target')
.trigger('mousemove')
.trigger('mouseup', { force: true });
cy.get('.react-flow__edge').should('have.length', 2);
});
it('toggles interactive mode', () => {
cy.get('.react-flow__interactive').click();
});
it('selects a node by click', () => {
cy.get('.react-flow__node:first')
.click()
.should('have.class', 'selected');
});
it('selects an edge by click', () => {
cy.get('.react-flow__edge:first')
.click()
.should('have.class', 'selected');
});
});
+1110 -82
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
+1110 -82
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
+3 -15
View File
@@ -1,16 +1,4 @@
import React, { CSSProperties } from 'react'; import React from 'react';
import { Node, Transform, ElementId, NodeComponentProps } from '../../types'; import { NodeComponentProps, WrapNodeProps } from '../../types';
interface WrapNodeProps { declare const _default: (NodeComponent: React.ComponentType<NodeComponentProps>) => React.MemoExoticComponent<({ id, type, data, transform, xPos, yPos, selected, onClick, onNodeDragStop, style, isInteractive, }: WrapNodeProps) => JSX.Element>;
id: ElementId;
type: string;
data: any;
selected: boolean;
transform: Transform;
xPos: number;
yPos: number;
onClick: (node: Node) => void | undefined;
onNodeDragStop: (node: Node) => void;
style?: CSSProperties;
}
declare const _default: (NodeComponent: React.ComponentType<NodeComponentProps>) => React.MemoExoticComponent<({ id, type, data, transform, xPos, yPos, selected, onClick, onNodeDragStop, style, }: WrapNodeProps) => JSX.Element>;
export default _default; export default _default;
+1 -1
View File
@@ -1,3 +1,3 @@
import React from 'react'; import React from 'react';
declare const _default: React.MemoExoticComponent<() => JSX.Element>; declare const _default: React.MemoExoticComponent<() => JSX.Element | null>;
export default _default; export default _default;
+2 -1
View File
@@ -21,6 +21,7 @@ export interface GraphViewProps {
snapToGrid: boolean; snapToGrid: boolean;
snapGrid: [number, number]; snapGrid: [number, number];
onlyRenderVisibleNodes: boolean; onlyRenderVisibleNodes: boolean;
isInteractive: boolean;
} }
declare const GraphView: React.MemoExoticComponent<({ nodeTypes, edgeTypes, onMove, onLoad, onElementClick, onNodeDragStop, connectionLineType, connectionLineStyle, selectionKeyCode, onElementsRemove, deleteKeyCode, elements, showBackground, backgroundGap, backgroundColor, backgroundType, onConnect, snapToGrid, snapGrid, onlyRenderVisibleNodes }: GraphViewProps) => JSX.Element>; declare const GraphView: React.MemoExoticComponent<({ nodeTypes, edgeTypes, onMove, onLoad, onElementClick, onNodeDragStop, connectionLineType, connectionLineStyle, selectionKeyCode, onElementsRemove, deleteKeyCode, elements, showBackground, backgroundGap, backgroundColor, backgroundType, onConnect, snapToGrid, snapGrid, onlyRenderVisibleNodes, isInteractive, }: GraphViewProps) => JSX.Element>;
export default GraphView; export default GraphView;
+3 -1
View File
@@ -22,9 +22,10 @@ export interface ReactFlowProps extends Omit<HTMLAttributes<HTMLDivElement>, 'on
snapToGrid: boolean; snapToGrid: boolean;
snapGrid: [number, number]; snapGrid: [number, number];
onlyRenderVisibleNodes: boolean; onlyRenderVisibleNodes: boolean;
isInteractive: boolean;
} }
declare const ReactFlow: { declare const ReactFlow: {
({ style, onElementClick, elements, children, nodeTypes, edgeTypes, onLoad, onMove, onElementsRemove, onConnect, onNodeDragStop, connectionLineType, connectionLineStyle, deleteKeyCode, selectionKeyCode, showBackground, backgroundGap, backgroundType, backgroundColor, snapToGrid, snapGrid, onlyRenderVisibleNodes }: ReactFlowProps): JSX.Element; ({ style, onElementClick, elements, children, nodeTypes, edgeTypes, onLoad, onMove, onElementsRemove, onConnect, onNodeDragStop, connectionLineType, connectionLineStyle, deleteKeyCode, selectionKeyCode, showBackground, backgroundGap, backgroundType, backgroundColor, snapToGrid, snapGrid, onlyRenderVisibleNodes, isInteractive, }: ReactFlowProps): JSX.Element;
displayName: string; displayName: string;
defaultProps: { defaultProps: {
onElementClick: () => void; onElementClick: () => void;
@@ -54,6 +55,7 @@ declare const ReactFlow: {
snapToGrid: boolean; snapToGrid: boolean;
snapGrid: number[]; snapGrid: number[];
onlyRenderVisibleNodes: boolean; onlyRenderVisibleNodes: boolean;
isInteractive: boolean;
}; };
}; };
export default ReactFlow; export default ReactFlow;
+2
View File
@@ -45,6 +45,7 @@ export declare const useStoreActions: <Result>(mapActions: (actions: import("eas
}>; }>;
setConnectionPosition: import("easy-peasy").Action<StoreModel, import("../types").XYPosition>; setConnectionPosition: import("easy-peasy").Action<StoreModel, import("../types").XYPosition>;
setConnectionSourceId: import("easy-peasy").Action<StoreModel, string | null>; setConnectionSourceId: import("easy-peasy").Action<StoreModel, string | null>;
setInteractive: import("easy-peasy").Action<StoreModel, boolean>;
}, "1">) => Result) => Result; }, "1">) => Result) => Result;
export declare const useStoreDispatch: () => import("easy-peasy").Dispatch<StoreModel, import("redux").Action<any>>; export declare const useStoreDispatch: () => import("easy-peasy").Dispatch<StoreModel, import("redux").Action<any>>;
export declare const useStoreState: <Result>(mapState: (state: import("easy-peasy").IntermediateStateMapper<{ export declare const useStoreState: <Result>(mapState: (state: import("easy-peasy").IntermediateStateMapper<{
@@ -65,5 +66,6 @@ export declare const useStoreState: <Result>(mapState: (state: import("easy-peas
connectionPosition: import("../types").XYPosition; connectionPosition: import("../types").XYPosition;
snapToGrid: boolean; snapToGrid: boolean;
snapGrid: [number, number]; snapGrid: [number, number];
isInteractive: boolean;
onConnect: import("../types").OnConnectFunc; onConnect: import("../types").OnConnectFunc;
}, "1">) => Result, dependencies?: any[] | undefined) => Result; }, "1">) => Result, dependencies?: any[] | undefined) => Result;
+5
View File
@@ -49,6 +49,7 @@ export interface StoreModel {
connectionPosition: XYPosition; connectionPosition: XYPosition;
snapToGrid: boolean; snapToGrid: boolean;
snapGrid: [number, number]; snapGrid: [number, number];
isInteractive: boolean;
onConnect: OnConnectFunc; onConnect: OnConnectFunc;
setOnConnect: Action<StoreModel, OnConnectFunc>; setOnConnect: Action<StoreModel, OnConnectFunc>;
setNodes: Action<StoreModel, Node[]>; setNodes: Action<StoreModel, Node[]>;
@@ -65,6 +66,7 @@ export interface StoreModel {
setSnapGrid: Action<StoreModel, SetSnapGrid>; setSnapGrid: Action<StoreModel, SetSnapGrid>;
setConnectionPosition: Action<StoreModel, XYPosition>; setConnectionPosition: Action<StoreModel, XYPosition>;
setConnectionSourceId: Action<StoreModel, ElementId | null>; setConnectionSourceId: Action<StoreModel, ElementId | null>;
setInteractive: Action<StoreModel, boolean>;
} }
declare const store: { declare const store: {
getState: () => import("easy-peasy").IntermediateStateMapper<{ getState: () => import("easy-peasy").IntermediateStateMapper<{
@@ -85,6 +87,7 @@ declare const store: {
connectionPosition: XYPosition; connectionPosition: XYPosition;
snapToGrid: boolean; snapToGrid: boolean;
snapGrid: [number, number]; snapGrid: [number, number];
isInteractive: boolean;
onConnect: OnConnectFunc; onConnect: OnConnectFunc;
}, "1">; }, "1">;
subscribe: (listener: () => void) => import("redux").Unsubscribe; subscribe: (listener: () => void) => import("redux").Unsubscribe;
@@ -106,6 +109,7 @@ declare const store: {
connectionPosition: XYPosition; connectionPosition: XYPosition;
snapToGrid: boolean; snapToGrid: boolean;
snapGrid: [number, number]; snapGrid: [number, number];
isInteractive: boolean;
onConnect: OnConnectFunc; onConnect: OnConnectFunc;
}, "1">, import("redux").AnyAction>) => void; }, "1">, import("redux").AnyAction>) => void;
dispatch: import("easy-peasy").Dispatch<StoreModel, import("redux").Action<any>>; dispatch: import("easy-peasy").Dispatch<StoreModel, import("redux").Action<any>>;
@@ -133,6 +137,7 @@ declare const store: {
setSnapGrid: Action<StoreModel, SetSnapGrid>; setSnapGrid: Action<StoreModel, SetSnapGrid>;
setConnectionPosition: Action<StoreModel, XYPosition>; setConnectionPosition: Action<StoreModel, XYPosition>;
setConnectionSourceId: Action<StoreModel, string | null>; setConnectionSourceId: Action<StoreModel, string | null>;
setInteractive: Action<StoreModel, boolean>;
}, "1">; }, "1">;
getListeners: () => import("easy-peasy").ListenerMapper<{ getListeners: () => import("easy-peasy").ListenerMapper<{
selectedNodesBbox: Rect; selectedNodesBbox: Rect;
+13
View File
@@ -82,6 +82,19 @@ export interface NodeComponentProps {
onNodeDragStop?: () => any; onNodeDragStop?: () => any;
style?: CSSProperties; style?: CSSProperties;
} }
export interface WrapNodeProps {
id: ElementId;
type: string;
data: any;
selected: boolean;
transform: Transform;
xPos: number;
yPos: number;
isInteractive: boolean;
onClick: (node: Node) => void | undefined;
onNodeDragStop: (node: Node) => void;
style?: CSSProperties;
}
export declare type FitViewParams = { export declare type FitViewParams = {
padding: number; padding: number;
}; };
+46
View File
@@ -0,0 +1,46 @@
import React, { useState } from 'react';
import Graph, { MiniMap, Controls } from 'react-flow';
const initialElements = [
{ id: '1', type: 'input', data: { label: 'Node 1' }, position: { x: 250, y: 5 } },
{ id: '2', data: { label: 'Node 2' }, position: { x: 100, y: 100 } },
{ id: '3', data: { label: 'Node 3' }, position: { x: 400, y: 100 } },
{ id: '4', data: { label: 'Node 4' }, position: { x: 400, y: 200 } },
{ id: 'e1-2', source: '1', target: '2', animated: true },
{ id: 'e1-3', source: '1', target: '3' },
];
const EmptyGraph = () => {
const [isInteractive, setIsInteractive] = useState(false);
const onToggleInteractive = (evt) => {
setIsInteractive(evt.target.checked);
};
return (
<Graph
elements={initialElements}
style={{ width: '100%', height: '100%' }}
backgroundType="lines"
isInteractive={isInteractive}
>
<MiniMap />
<Controls />
<div
style={{ position: 'absolute', right: 10, top: 10, zIndex: 4 }}
>
<label>
interactive
<input
type="checkbox"
checked={isInteractive}
onChange={onToggleInteractive}
className="react-flow__interactive"
/>
</label>
</div>
</Graph>
);
}
export default EmptyGraph;
+4
View File
@@ -1,3 +1,7 @@
body {
font-family: sans-serif;
}
html, body { html, body {
margin: 0; margin: 0;
} }
+4
View File
@@ -5,6 +5,7 @@ import { BrowserRouter as Router, Route, Switch } from 'react-router-dom';
import CustomNodes from './CustomNodes'; import CustomNodes from './CustomNodes';
import Basic from './Basic'; import Basic from './Basic';
import Empty from './Empty'; import Empty from './Empty';
import Inactive from './Inactive';
import './index.css'; import './index.css';
@@ -17,6 +18,9 @@ ReactDOM.render((
<Route path="/empty"> <Route path="/empty">
<Empty /> <Empty />
</Route> </Route>
<Route path="/inactive">
<Inactive />
</Route>
<Route path="/"> <Route path="/">
<CustomNodes /> <CustomNodes />
</Route> </Route>
+6 -10
View File
@@ -10,6 +10,7 @@ interface ConnectionLineProps {
connectionLineType?: string | null; connectionLineType?: string | null;
nodes: Node[]; nodes: Node[];
transform: Transform; transform: Transform;
isInteractive: boolean;
connectionLineStyle?: SVGAttributes<{}>; connectionLineStyle?: SVGAttributes<{}>;
className?: string; className?: string;
} }
@@ -23,6 +24,7 @@ export default ({
nodes = [], nodes = [],
className, className,
transform, transform,
isInteractive,
}: ConnectionLineProps) => { }: ConnectionLineProps) => {
const [sourceNode, setSourceNode] = useState<Node | null>(null); const [sourceNode, setSourceNode] = useState<Node | null>(null);
const hasHandleId = connectionSourceId.includes('__'); const hasHandleId = connectionSourceId.includes('__');
@@ -35,23 +37,17 @@ export default ({
setSourceNode(nextSourceNode); setSourceNode(nextSourceNode);
}, []); }, []);
if (!sourceNode) { if (!sourceNode || !isInteractive) {
return null; return null;
} }
const edgeClasses: string = cx('react-flow__edge', 'connection', className); const edgeClasses: string = cx('react-flow__edge', 'connection', className);
const sourceHandle = handleId const sourceHandle = handleId
? sourceNode.__rg.handleBounds.source.find( ? sourceNode.__rg.handleBounds.source.find((d: HandleElement) => d.id === handleId)
(d: HandleElement) => d.id === handleId
)
: sourceNode.__rg.handleBounds.source[0]; : sourceNode.__rg.handleBounds.source[0];
const sourceHandleX = sourceHandle const sourceHandleX = sourceHandle ? sourceHandle.x + sourceHandle.width / 2 : sourceNode.__rg.width / 2;
? sourceHandle.x + sourceHandle.width / 2 const sourceHandleY = sourceHandle ? sourceHandle.y + sourceHandle.height / 2 : sourceNode.__rg.height;
: sourceNode.__rg.width / 2;
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;
const sourceY = sourceNode.__rg.position.y + sourceHandleY; const sourceY = sourceNode.__rg.position.y + sourceHandleY;
+3 -11
View File
@@ -13,23 +13,15 @@ interface EdgeWrapperProps {
onClick: (edge: Edge) => void; onClick: (edge: Edge) => void;
animated: boolean; animated: boolean;
selected: boolean; selected: boolean;
isInteractive: boolean;
} }
export default (EdgeComponent: ComponentType<EdgeCompProps>) => { export default (EdgeComponent: ComponentType<EdgeCompProps>) => {
const EdgeWrapper = memo( const EdgeWrapper = memo(
({ ({ id, source, target, type, animated, selected, onClick, isInteractive, ...rest }: EdgeWrapperProps) => {
id,
source,
target,
type,
animated,
selected,
onClick,
...rest
}: EdgeWrapperProps) => {
const edgeClasses = cx('react-flow__edge', { selected, animated }); const edgeClasses = cx('react-flow__edge', { selected, animated });
const onEdgeClick = (evt: MouseEvent): void => { const onEdgeClick = (evt: MouseEvent): void => {
if (isInputDOMNode(evt)) { if (isInputDOMNode(evt) || !isInteractive) {
return; return;
} }
+12 -72
View File
@@ -1,11 +1,4 @@
import React, { import React, { useEffect, useRef, useState, memo, ComponentType } from 'react';
useEffect,
useRef,
useState,
memo,
ComponentType,
CSSProperties,
} from 'react';
import { DraggableCore, DraggableEvent } from 'react-draggable'; import { DraggableCore, DraggableEvent } from 'react-draggable';
import cx from 'classnames'; import cx from 'classnames';
import { ResizeObserver } from 'resize-observer'; import { ResizeObserver } from 'resize-observer';
@@ -21,21 +14,9 @@ import {
Transform, Transform,
ElementId, ElementId,
NodeComponentProps, NodeComponentProps,
WrapNodeProps,
} from '../../types'; } from '../../types';
interface WrapNodeProps {
id: ElementId;
type: string;
data: any;
selected: boolean;
transform: Transform;
xPos: number;
yPos: number;
onClick: (node: Node) => void | undefined;
onNodeDragStop: (node: Node) => void;
style?: CSSProperties;
}
const isHandle = (evt: MouseEvent | DraggableEvent) => { const isHandle = (evt: MouseEvent | DraggableEvent) => {
const target = evt.target as HTMLElement; const target = evt.target as HTMLElement;
@@ -65,17 +46,13 @@ const getHandleBounds = (
const bounds = handle.getBoundingClientRect(); const bounds = handle.getBoundingClientRect();
const dimensions = getDimensions(handle); const dimensions = getDimensions(handle);
const nodeIdAttr = handle.getAttribute('data-nodeid'); const nodeIdAttr = handle.getAttribute('data-nodeid');
const handlePosition = (handle.getAttribute( const handlePosition = (handle.getAttribute('data-handlepos') as unknown) as Position;
'data-handlepos'
) as unknown) as Position;
const nodeIdSplitted = nodeIdAttr ? nodeIdAttr.split('__') : null; const nodeIdSplitted = nodeIdAttr ? nodeIdAttr.split('__') : null;
let handleId = null; let handleId = null;
if (nodeIdSplitted) { if (nodeIdSplitted) {
handleId = (nodeIdSplitted.length handleId = (nodeIdSplitted.length ? nodeIdSplitted[1] : nodeIdSplitted) as string;
? nodeIdSplitted[1]
: nodeIdSplitted) as string;
} }
return { return {
@@ -171,6 +148,7 @@ export default (NodeComponent: ComponentType<NodeComponentProps>) => {
onClick, onClick,
onNodeDragStop, onNodeDragStop,
style, style,
isInteractive,
}: WrapNodeProps) => { }: WrapNodeProps) => {
const nodeElement = useRef<HTMLDivElement>(null); const nodeElement = useRef<HTMLDivElement>(null);
const [offset, setOffset] = useState({ x: 0, y: 0 }); const [offset, setOffset] = useState({ x: 0, y: 0 });
@@ -192,18 +170,8 @@ export default (NodeComponent: ComponentType<NodeComponentProps>) => {
const bounds = nodeElement.current.getBoundingClientRect(); const bounds = nodeElement.current.getBoundingClientRect();
const dimensions = getDimensions(nodeElement.current); const dimensions = getDimensions(nodeElement.current);
const handleBounds = { const handleBounds = {
source: getHandleBounds( source: getHandleBounds('.source', nodeElement.current, bounds, storeState.transform[2]),
'.source', target: getHandleBounds('.target', nodeElement.current, bounds, storeState.transform[2]),
nodeElement.current,
bounds,
storeState.transform[2]
),
target: getHandleBounds(
'.target',
nodeElement.current,
bounds,
storeState.transform[2]
),
}; };
store.dispatch.updateNodeData({ id, ...dimensions, handleBounds }); store.dispatch.updateNodeData({ id, ...dimensions, handleBounds });
}; };
@@ -232,43 +200,15 @@ export default (NodeComponent: ComponentType<NodeComponentProps>) => {
return ( return (
<DraggableCore <DraggableCore
onStart={evt => onStart={evt => onStart(evt as MouseEvent, onClick, id, type, data, setOffset, transform, position)}
onStart( onDrag={evt => onDrag(evt as MouseEvent, setDragging, id, offset, transform)}
evt as MouseEvent, onStop={() => onStop(onNodeDragStop, isDragging, setDragging, id, type, position, data)}
onClick,
id,
type,
data,
setOffset,
transform,
position
)
}
onDrag={evt =>
onDrag(evt as MouseEvent, setDragging, id, offset, transform)
}
onStop={() =>
onStop(
onNodeDragStop,
isDragging,
setDragging,
id,
type,
position,
data
)
}
scale={transform[2]} scale={transform[2]}
disabled={!isInteractive}
> >
<div className={nodeClasses} ref={nodeElement} style={nodeStyle}> <div className={nodeClasses} ref={nodeElement} style={nodeStyle}>
<Provider value={id}> <Provider value={id}>
<NodeComponent <NodeComponent id={id} data={data} type={type} style={style} selected={selected} />
id={id}
data={data}
type={type}
style={style}
selected={selected}
/>
</Provider> </Provider>
</div> </div>
</DraggableCore> </DraggableCore>
+11 -7
View File
@@ -3,6 +3,10 @@ import React, { useEffect, useRef, useState, memo } from 'react';
import { useStoreActions } from '../../store/hooks'; import { useStoreActions } from '../../store/hooks';
import { SelectionRect } from '../../types'; import { SelectionRect } from '../../types';
type UserSelectionProps = {
isInteractive: boolean;
};
const initialRect: SelectionRect = { const initialRect: SelectionRect = {
startX: 0, startX: 0,
startY: 0, startY: 0,
@@ -27,13 +31,17 @@ function getMousePosition(evt: MouseEvent) {
}; };
} }
export default memo(() => { export default memo(({ isInteractive }: UserSelectionProps) => {
const selectionPane = useRef<HTMLDivElement>(null); const selectionPane = useRef<HTMLDivElement>(null);
const [rect, setRect] = useState(initialRect); const [rect, setRect] = useState(initialRect);
const setSelection = useStoreActions(a => a.setSelection); const setSelection = useStoreActions(a => a.setSelection);
const updateSelection = useStoreActions(a => a.updateSelection); const updateSelection = useStoreActions(a => a.updateSelection);
const setNodesSelection = useStoreActions(a => a.setNodesSelection); const setNodesSelection = useStoreActions(a => a.setNodesSelection);
if (!isInteractive) {
return null;
}
useEffect(() => { useEffect(() => {
function onMouseDown(evt: MouseEvent): void { function onMouseDown(evt: MouseEvent): void {
const mousePos = getMousePosition(evt); const mousePos = getMousePosition(evt);
@@ -70,12 +78,8 @@ export default memo(() => {
...currentRect, ...currentRect,
x: negativeX ? mousePos.x : currentRect.x, x: negativeX ? mousePos.x : currentRect.x,
y: negativeY ? mousePos.y : currentRect.y, y: negativeY ? mousePos.y : currentRect.y,
width: negativeX width: negativeX ? currentRect.startX - mousePos.x : mousePos.x - currentRect.startX,
? currentRect.startX - mousePos.x height: negativeY ? currentRect.startY - mousePos.y : mousePos.y - currentRect.startY,
: mousePos.x - currentRect.startX,
height: negativeY
? currentRect.startY - mousePos.y
: mousePos.y - currentRect.startY,
}; };
updateSelection(nextRect); updateSelection(nextRect);
+21 -4
View File
@@ -113,7 +113,13 @@ function getEdgePositions(
}; };
} }
function renderEdge(edge: Edge, props: EdgeRendererProps, nodes: Node[], selectedElements: Elements) { function renderEdge(
edge: Edge,
props: EdgeRendererProps,
nodes: Node[],
selectedElements: Elements,
isInteractive: boolean
) {
const [sourceId, sourceHandleId] = edge.source.split('__'); const [sourceId, sourceHandleId] = edge.source.split('__');
const [targetId, targetHandleId] = edge.target.split('__'); const [targetId, targetHandleId] = edge.target.split('__');
@@ -128,7 +134,7 @@ function renderEdge(edge: Edge, props: EdgeRendererProps, nodes: Node[], selecte
throw new Error(`couldn't create edge for target id: ${targetId}`); throw new Error(`couldn't create edge for target id: ${targetId}`);
} }
if (!sourceNode.__rg.width || !sourceNode.__rg.height) { if (!sourceNode.__rg.width || !sourceNode.__rg.height) {
return null; return null;
} }
@@ -171,6 +177,7 @@ function renderEdge(edge: Edge, props: EdgeRendererProps, nodes: Node[], selecte
targetY={targetY} targetY={targetY}
sourcePosition={sourcePosition} sourcePosition={sourcePosition}
targetPosition={targetPosition} targetPosition={targetPosition}
isInteractive={isInteractive}
/> />
); );
} }
@@ -183,7 +190,16 @@ const EdgeRenderer = memo((props: EdgeRendererProps) => {
connectionSourceId, connectionSourceId,
connectionPosition: { x, y }, connectionPosition: { x, y },
selectedElements, selectedElements,
} = useStoreState(s => s); isInteractive,
} = useStoreState(s => ({
transform: s.transform,
edges: s.edges,
nodes: s.nodes,
connectionSourceId: s.connectionSourceId,
connectionPosition: s.connectionPosition,
selectedElements: s.selectedElements,
isInteractive: s.isInteractive,
}));
const { width, height, connectionLineStyle, connectionLineType } = props; const { width, height, connectionLineStyle, connectionLineType } = props;
@@ -197,7 +213,7 @@ const EdgeRenderer = memo((props: EdgeRendererProps) => {
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))} {edges.map((e: Edge) => renderEdge(e, props, nodes, selectedElements, isInteractive))}
{connectionSourceId && ( {connectionSourceId && (
<ConnectionLine <ConnectionLine
nodes={nodes} nodes={nodes}
@@ -207,6 +223,7 @@ const EdgeRenderer = memo((props: EdgeRendererProps) => {
transform={transform} transform={transform}
connectionLineStyle={connectionLineStyle} connectionLineStyle={connectionLineStyle}
connectionLineType={connectionLineType} connectionLineType={connectionLineType}
isInteractive={isInteractive}
/> />
)} )}
</g> </g>
+17 -25
View File
@@ -1,4 +1,5 @@
import React, { useEffect, useRef, memo, SVGAttributes } from 'react'; import React, { useEffect, useRef, memo, SVGAttributes } from 'react';
import classnames from 'classnames';
import { useStoreState, useStoreActions } from '../../store/hooks'; import { useStoreState, useStoreActions } from '../../store/hooks';
import NodeRenderer from '../NodeRenderer'; import NodeRenderer from '../NodeRenderer';
@@ -12,13 +13,7 @@ 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 } from '../../utils/graph'; import { fitView, zoomIn, zoomOut } from '../../utils/graph';
import { import { Elements, NodeTypesType, EdgeTypesType, GridType, OnLoadFunc } from '../../types';
Elements,
NodeTypesType,
EdgeTypesType,
GridType,
OnLoadFunc,
} from '../../types';
export interface GraphViewProps { export interface GraphViewProps {
elements: Elements; elements: Elements;
@@ -40,7 +35,8 @@ export interface GraphViewProps {
backgroundType: GridType; backgroundType: GridType;
snapToGrid: boolean; snapToGrid: boolean;
snapGrid: [number, number]; snapGrid: [number, number];
onlyRenderVisibleNodes: boolean onlyRenderVisibleNodes: boolean;
isInteractive: boolean;
} }
const GraphView = memo( const GraphView = memo(
@@ -64,7 +60,8 @@ const GraphView = memo(
onConnect, onConnect,
snapToGrid, snapToGrid,
snapGrid, snapGrid,
onlyRenderVisibleNodes onlyRenderVisibleNodes,
isInteractive,
}: GraphViewProps) => { }: GraphViewProps) => {
const zoomPane = useRef<HTMLDivElement>(null); const zoomPane = useRef<HTMLDivElement>(null);
const rendererNode = useRef<HTMLDivElement>(null); const rendererNode = useRef<HTMLDivElement>(null);
@@ -77,13 +74,12 @@ const GraphView = memo(
nodesSelectionActive: s.nodesSelectionActive, nodesSelectionActive: s.nodesSelectionActive,
})); }));
const updateSize = useStoreActions(actions => actions.updateSize); const updateSize = useStoreActions(actions => actions.updateSize);
const setNodesSelection = useStoreActions( const setNodesSelection = useStoreActions(actions => actions.setNodesSelection);
actions => actions.setNodesSelection
);
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 selectionKeyPressed = useKeyPress(selectionKeyCode); const selectionKeyPressed = useKeyPress(selectionKeyCode);
const rendererClasses = classnames('react-flow__renderer', { 'is-interactive': isInteractive });
const onZoomPaneClick = () => setNodesSelection({ isActive: false }); const onZoomPaneClick = () => setNodesSelection({ isActive: false });
@@ -122,17 +118,17 @@ const GraphView = memo(
setSnapGrid({ snapToGrid, snapGrid }); setSnapGrid({ snapToGrid, snapGrid });
}, [snapToGrid]); }, [snapToGrid]);
useEffect(() => {
setInteractive(isInteractive);
}, [isInteractive]);
useGlobalKeyHandler({ onElementsRemove, deleteKeyCode }); useGlobalKeyHandler({ onElementsRemove, deleteKeyCode });
useElementUpdater(elements); useElementUpdater(elements);
return ( return (
<div className="react-flow__renderer" ref={rendererNode}> <div className={rendererClasses} ref={rendererNode}>
{showBackground && ( {showBackground && (
<BackgroundGrid <BackgroundGrid gap={backgroundGap} color={backgroundColor} backgroundType={backgroundType} />
gap={backgroundGap}
color={backgroundColor}
backgroundType={backgroundType}
/>
)} )}
<NodeRenderer <NodeRenderer
nodeTypes={nodeTypes} nodeTypes={nodeTypes}
@@ -148,13 +144,9 @@ const GraphView = memo(
connectionLineType={connectionLineType} connectionLineType={connectionLineType}
connectionLineStyle={connectionLineStyle} connectionLineStyle={connectionLineStyle}
/> />
{selectionKeyPressed && <UserSelection />} {selectionKeyPressed && <UserSelection isInteractive={isInteractive} />}
{state.nodesSelectionActive && <NodesSelection />} {state.nodesSelectionActive && <NodesSelection />}
<div <div className="react-flow__zoompane" onClick={onZoomPaneClick} ref={zoomPane} />
className="react-flow__zoompane"
onClick={onZoomPaneClick}
ref={zoomPane}
/>
</div> </div>
); );
} }
+22 -6
View File
@@ -2,7 +2,7 @@ import React, { memo, ComponentType } from 'react';
import { useStoreState } from '../../store/hooks'; import { useStoreState } from '../../store/hooks';
import { getNodesInside } from '../../utils/graph'; import { getNodesInside } from '../../utils/graph';
import { Node, Transform, NodeTypesType, NodeComponentProps, Elements } from '../../types'; import { Node, Transform, NodeTypesType, WrapNodeProps, Elements } from '../../types';
interface NodeRendererProps { interface NodeRendererProps {
nodeTypes: NodeTypesType; nodeTypes: NodeTypesType;
@@ -11,9 +11,15 @@ interface NodeRendererProps {
onlyRenderVisibleNodes?: boolean; onlyRenderVisibleNodes?: boolean;
} }
function renderNode(node: Node, props: NodeRendererProps, transform: Transform, selectedElements: Elements) { function renderNode(
node: Node,
props: NodeRendererProps,
transform: Transform,
selectedElements: Elements,
isInteractive: boolean
) {
const nodeType = node.type || 'default'; const nodeType = node.type || 'default';
const NodeComponent = (props.nodeTypes[nodeType] || props.nodeTypes.default) as ComponentType<NodeComponentProps>; const NodeComponent = (props.nodeTypes[nodeType] || props.nodeTypes.default) as ComponentType<WrapNodeProps>;
if (!props.nodeTypes[nodeType]) { if (!props.nodeTypes[nodeType]) {
console.warn(`No node type found for type "${nodeType}". Using fallback type "default".`); console.warn(`No node type found for type "${nodeType}". Using fallback type "default".`);
} }
@@ -33,23 +39,33 @@ function renderNode(node: Node, props: NodeRendererProps, transform: Transform,
transform={transform} transform={transform}
selected={isSelected} selected={isSelected}
style={node.style} style={node.style}
isInteractive={isInteractive}
/> />
); );
} }
const NodeRenderer = memo(({ onlyRenderVisibleNodes = true, ...props }: NodeRendererProps) => { const NodeRenderer = memo(({ onlyRenderVisibleNodes = true, ...props }: NodeRendererProps) => {
const { nodes, transform, selectedElements, width, height } = useStoreState(s => s); const { nodes, transform, selectedElements, width, height, isInteractive } = useStoreState(s => ({
nodes: s.nodes,
transform: s.transform,
selectedElements: s.selectedElements,
width: s.width,
height: s.height,
isInteractive: s.isInteractive,
}));
const [tx, ty, tScale] = transform; const [tx, ty, tScale] = transform;
const transformStyle = { const transformStyle = {
transform: `translate(${tx}px,${ty}px) scale(${tScale})`, transform: `translate(${tx}px,${ty}px) scale(${tScale})`,
}; };
const renderNodes = onlyRenderVisibleNodes ? getNodesInside(nodes, { x: 0, y: 0, width, height }, transform, true) : nodes; const renderNodes = onlyRenderVisibleNodes
? getNodesInside(nodes, { x: 0, y: 0, width, height }, transform, true)
: nodes;
return ( return (
<div className="react-flow__nodes" style={transformStyle}> <div className="react-flow__nodes" style={transformStyle}>
{renderNodes.map(node => renderNode(node, props, transform, selectedElements))} {renderNodes.map(node => renderNode(node, props, transform, selectedElements, isInteractive))}
</div> </div>
); );
}); });
+9 -12
View File
@@ -18,18 +18,11 @@ import StraightEdge from '../../components/Edges/StraightEdge';
import StepEdge from '../../components/Edges/StepEdge'; import StepEdge from '../../components/Edges/StepEdge';
import { createEdgeTypes } from '../EdgeRenderer/utils'; import { createEdgeTypes } from '../EdgeRenderer/utils';
import store from '../../store'; import store from '../../store';
import { import { Elements, NodeTypesType, EdgeTypesType, GridType, OnLoadFunc } from '../../types';
Elements,
NodeTypesType,
EdgeTypesType,
GridType,
OnLoadFunc,
} from '../../types';
import '../../style.css'; import '../../style.css';
export interface ReactFlowProps export interface ReactFlowProps extends Omit<HTMLAttributes<HTMLDivElement>, 'onLoad'> {
extends Omit<HTMLAttributes<HTMLDivElement>, 'onLoad'> {
elements: Elements; elements: Elements;
onElementClick: () => void; onElementClick: () => void;
onElementsRemove: (elements: Elements) => void; onElementsRemove: (elements: Elements) => void;
@@ -49,7 +42,8 @@ export interface ReactFlowProps
backgroundType: GridType; backgroundType: GridType;
snapToGrid: boolean; snapToGrid: boolean;
snapGrid: [number, number]; snapGrid: [number, number];
onlyRenderVisibleNodes: boolean onlyRenderVisibleNodes: boolean;
isInteractive: boolean;
} }
const ReactFlow = ({ const ReactFlow = ({
@@ -74,7 +68,8 @@ const ReactFlow = ({
backgroundColor, backgroundColor,
snapToGrid, snapToGrid,
snapGrid, snapGrid,
onlyRenderVisibleNodes onlyRenderVisibleNodes,
isInteractive,
}: ReactFlowProps) => { }: ReactFlowProps) => {
const nodeTypesParsed = useMemo(() => createNodeTypes(nodeTypes), []); const nodeTypesParsed = useMemo(() => createNodeTypes(nodeTypes), []);
const edgeTypesParsed = useMemo(() => createEdgeTypes(edgeTypes), []); const edgeTypesParsed = useMemo(() => createEdgeTypes(edgeTypes), []);
@@ -103,6 +98,7 @@ const ReactFlow = ({
snapToGrid={snapToGrid} snapToGrid={snapToGrid}
snapGrid={snapGrid} snapGrid={snapGrid}
onlyRenderVisibleNodes={onlyRenderVisibleNodes} onlyRenderVisibleNodes={onlyRenderVisibleNodes}
isInteractive={isInteractive}
/> />
{children} {children}
</StoreProvider> </StoreProvider>
@@ -139,7 +135,8 @@ ReactFlow.defaultProps = {
backgroundType: GridType.Dots, backgroundType: GridType.Dots,
snapToGrid: false, snapToGrid: false,
snapGrid: [16, 16], snapGrid: [16, 16],
onlyRenderVisibleNodes: true onlyRenderVisibleNodes: true,
isInteractive: true,
}; };
export default ReactFlow; export default ReactFlow;
+2 -9
View File
@@ -9,11 +9,7 @@ const d3ZoomInstance = d3Zoom
.scaleExtent([0.5, 2]) .scaleExtent([0.5, 2])
.filter(() => !event.button); .filter(() => !event.button);
export default ( export default (zoomPane: MutableRefObject<Element | null>, onMove: () => void, shiftPressed: boolean): void => {
zoomPane: MutableRefObject<Element | null>,
onMove: () => void,
shiftPressed: boolean
): void => {
const state = useStoreState(s => ({ const state = useStoreState(s => ({
transform: s.transform, transform: s.transform,
d3Selection: s.d3Selection, d3Selection: s.d3Selection,
@@ -35,10 +31,7 @@ export default (
d3ZoomInstance.on('zoom', null); d3ZoomInstance.on('zoom', null);
} else { } else {
d3ZoomInstance.on('zoom', () => { d3ZoomInstance.on('zoom', () => {
if ( if (event.sourceEvent && event.sourceEvent.target !== zoomPane.current) {
event.sourceEvent &&
event.sourceEvent.target !== zoomPane.current
) {
return; return;
} }
+17 -29
View File
@@ -2,11 +2,7 @@ import { createStore, Action, action } from 'easy-peasy';
import isEqual from 'fast-deep-equal'; import isEqual from 'fast-deep-equal';
import { Selection as D3Selection, ZoomBehavior } from 'd3'; import { Selection as D3Selection, ZoomBehavior } from 'd3';
import { import { getNodesInside, getConnectedEdges, getRectOfNodes } from '../utils/graph';
getNodesInside,
getConnectedEdges,
getRectOfNodes,
} from '../utils/graph';
import { import {
ElementId, ElementId,
Elements, Elements,
@@ -80,6 +76,8 @@ export interface StoreModel {
snapToGrid: boolean; snapToGrid: boolean;
snapGrid: [number, number]; snapGrid: [number, number];
isInteractive: boolean;
onConnect: OnConnectFunc; onConnect: OnConnectFunc;
setOnConnect: Action<StoreModel, OnConnectFunc>; setOnConnect: Action<StoreModel, OnConnectFunc>;
@@ -111,6 +109,8 @@ export interface StoreModel {
setConnectionPosition: Action<StoreModel, XYPosition>; setConnectionPosition: Action<StoreModel, XYPosition>;
setConnectionSourceId: Action<StoreModel, ElementId | null>; setConnectionSourceId: Action<StoreModel, ElementId | null>;
setInteractive: Action<StoreModel, boolean>;
} }
const storeModel: StoreModel = { const storeModel: StoreModel = {
@@ -136,6 +136,8 @@ const storeModel: StoreModel = {
snapGrid: [16, 16], snapGrid: [16, 16],
snapToGrid: true, snapToGrid: true,
isInteractive: true,
onConnect: () => {}, onConnect: () => {},
setOnConnect: action((state, onConnect) => { setOnConnect: action((state, onConnect) => {
@@ -195,11 +197,7 @@ const storeModel: StoreModel = {
return; return;
} }
const selectedNodes = getNodesInside( const selectedNodes = getNodesInside(state.nodes, selection, state.transform);
state.nodes,
selection,
state.transform
);
if (!selectedNodes.length) { if (!selectedNodes.length) {
state.nodesSelectionActive = false; state.nodesSelectionActive = false;
@@ -218,35 +216,21 @@ const storeModel: StoreModel = {
setSelectedElements: action((state, elements) => { setSelectedElements: action((state, elements) => {
const selectedElementsArr = Array.isArray(elements) ? elements : [elements]; const selectedElementsArr = Array.isArray(elements) ? elements : [elements];
const selectedElementsUpdated = !isEqual( const selectedElementsUpdated = !isEqual(selectedElementsArr, state.selectedElements);
selectedElementsArr, const selectedElements = selectedElementsUpdated ? selectedElementsArr : state.selectedElements;
state.selectedElements
);
const selectedElements = selectedElementsUpdated
? selectedElementsArr
: state.selectedElements;
state.selectedElements = selectedElements; state.selectedElements = selectedElements;
}), }),
updateSelection: action((state, selection) => { updateSelection: action((state, selection) => {
const selectedNodes = getNodesInside( const selectedNodes = getNodesInside(state.nodes, selection, state.transform);
state.nodes,
selection,
state.transform
);
const selectedEdges = getConnectedEdges(selectedNodes, state.edges); const selectedEdges = getConnectedEdges(selectedNodes, state.edges);
const nextSelectedElements = [...selectedNodes, ...selectedEdges]; const nextSelectedElements = [...selectedNodes, ...selectedEdges];
const selectedElementsUpdated = !isEqual( const selectedElementsUpdated = !isEqual(nextSelectedElements, state.selectedElements);
nextSelectedElements,
state.selectedElements
);
state.selection = selection; state.selection = selection;
state.selectedElements = selectedElementsUpdated state.selectedElements = selectedElementsUpdated ? nextSelectedElements : state.selectedElements;
? nextSelectedElements
: state.selectedElements;
}), }),
updateTransform: action((state, transform) => { updateTransform: action((state, transform) => {
@@ -276,6 +260,10 @@ const storeModel: StoreModel = {
state.snapToGrid = snapToGrid; state.snapToGrid = snapToGrid;
state.snapGrid = snapGrid; state.snapGrid = snapGrid;
}), }),
setInteractive: action((state, isInteractive) => {
state.isInteractive = isInteractive;
}),
}; };
const store = createStore(storeModel); const store = createStore(storeModel);
+17 -8
View File
@@ -67,7 +67,9 @@
} }
@keyframes dashdraw { @keyframes dashdraw {
from {stroke-dashoffset: 10} from {
stroke-dashoffset: 10;
}
} }
.react-flow__nodes { .react-flow__nodes {
@@ -79,21 +81,30 @@
transform-origin: 0 0; transform-origin: 0 0;
} }
.is-interactive {
.react-flow__node {
cursor: grab;
&:hover > * {
box-shadow: 0 1px 5px 2px rgba(0, 0, 0, 0.08);
}
}
.react-flow__handle {
cursor: crosshair;
}
}
.react-flow__node { .react-flow__node {
position: absolute; position: absolute;
color: #222; color: #222;
font-family: sans-serif; font-family: sans-serif;
font-size: 12px; font-size: 12px;
text-align: center; text-align: center;
cursor: grab;
user-select: none; user-select: none;
pointer-events: all; pointer-events: all;
transform-origin: 0 0; transform-origin: 0 0;
&:hover > * {
box-shadow: 0 1px 5px 2px rgba(0, 0, 0, 0.08);
}
&.selected > * { &.selected > * {
box-shadow: 0 0 0 2px #555; box-shadow: 0 0 0 2px #555;
} }
@@ -104,7 +115,6 @@
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 { &.bottom {
top: auto; top: auto;
@@ -123,7 +133,6 @@
top: 50%; top: 50%;
left: 0; left: 0;
transform: translate(0, -50%); transform: translate(0, -50%);
} }
&.right { &.right {
+14
View File
@@ -99,6 +99,20 @@ export interface NodeComponentProps {
style?: CSSProperties; style?: CSSProperties;
} }
export interface WrapNodeProps {
id: ElementId;
type: string;
data: any;
selected: boolean;
transform: Transform;
xPos: number;
yPos: number;
isInteractive: boolean;
onClick: (node: Node) => void | undefined;
onNodeDragStop: (node: Node) => void;
style?: CSSProperties;
}
export type FitViewParams = { export type FitViewParams = {
padding: number; padding: number;
}; };