From 1d8a9d6d87e046a01533714a1cdc394b00d4bb0b Mon Sep 17 00:00:00 2001 From: moklick Date: Mon, 13 Jul 2020 17:36:15 +0200 Subject: [PATCH] feat(graph): more adjustable interaction closes #329, closes #291 --- README.md | 6 +- cypress/integration/flow/inactive.spec.js | 22 ++++-- example/src/Inactive/index.js | 45 ------------ example/src/Interaction/index.js | 75 ++++++++++++++++++++ example/src/index.js | 7 +- src/additional-components/Controls/index.tsx | 2 +- src/components/ConnectionLine/index.tsx | 6 +- src/components/Edges/wrapEdge.tsx | 8 +-- src/components/Handle/index.tsx | 7 +- src/components/Nodes/DefaultNode.tsx | 16 +++-- src/components/Nodes/InputNode.tsx | 4 +- src/components/Nodes/OutputNode.tsx | 4 +- src/components/Nodes/wrapNode.tsx | 46 +++++++++--- src/components/UserSelection/index.tsx | 6 +- src/container/EdgeRenderer/index.tsx | 11 +-- src/container/GraphView/index.tsx | 30 +++++--- src/container/NodeRenderer/index.tsx | 17 +++-- src/container/ReactFlow/index.tsx | 16 +++-- src/store/index.ts | 27 ++++++- src/style.css | 11 ++- src/types/index.ts | 7 +- 21 files changed, 259 insertions(+), 114 deletions(-) delete mode 100644 example/src/Inactive/index.js create mode 100644 example/src/Interaction/index.js diff --git a/README.md b/README.md index 6ee73a3e..b9fefc6f 100644 --- a/README.md +++ b/README.md @@ -88,7 +88,9 @@ const BasicFlow = () => ; - `snapToGrid`: default: `false` - `snapGrid`: [x, y] array - default: `[16, 16]` - `onlyRenderVisibleNodes`: default: `true` -- `isInteractive`: default: `true`. If the graph is not interactive you can't drag any nodes +- `nodesDraggable`: default: `true` +- `nodesConnectable`: default: `true` +- `elementsSelectable`: default: `true` - `selectNodesOnDrag`: default: `true` - `minZoom`: default: `0.5` - `maxZoom`: default: `2` @@ -511,7 +513,7 @@ You can find all examples in the [example](example) folder or check out the live - [provider](https://react-flow.netlify.app/provider) - [edges](https://react-flow.netlify.app/edges) - [empty](https://react-flow.netlify.app/empty) -- [inactive](https://react-flow.netlify.app/inactive) +- [interaction](https://react-flow.netlify.app/interaction) - [provider](https://react-flow.netlify.app/provider) # Development diff --git a/cypress/integration/flow/inactive.spec.js b/cypress/integration/flow/inactive.spec.js index 751318f3..94b97b19 100644 --- a/cypress/integration/flow/inactive.spec.js +++ b/cypress/integration/flow/inactive.spec.js @@ -1,6 +1,6 @@ -describe('Inactive Graph Rendering', () => { +describe('Interaction Graph Rendering', () => { it('renders a graph', () => { - cy.visit('/inactive'); + cy.visit('/interaction'); cy.get('.react-flow__renderer'); cy.get('.react-flow__node').should('have.length', 4); @@ -20,12 +20,24 @@ describe('Inactive Graph Rendering', () => { it('tries to do a selection', () => { cy.get('body').type('{shift}', { release: false }).get('.react-flow__selectionpane').should('not.exist'); - cy.get('body').type('{shift}', { release: true }); }); - it('toggles interactive mode', () => { - cy.get('.react-flow__interactive').click(); + it('toggles draggable mode', () => { + cy.get('.react-flow__draggable').click(); + }); + + it('drags a node', () => { + const styleBeforeDrag = Cypress.$('.react-flow__node:first').css('transform'); + + cy.drag('.react-flow__node:first', { x: 325, y: 100 }).then(($el) => { + const styleAfterDrag = $el.css('transform'); + expect(styleBeforeDrag).to.not.equal(styleAfterDrag); + }); + }); + + it('toggles selectable mode', () => { + cy.get('.react-flow__selectable').click(); }); it('selects a node by click', () => { diff --git a/example/src/Inactive/index.js b/example/src/Inactive/index.js deleted file mode 100644 index 45980b3f..00000000 --- a/example/src/Inactive/index.js +++ /dev/null @@ -1,45 +0,0 @@ -import React, { useState } from 'react'; - -import ReactFlow, { MiniMap, Controls } from 'react-flow-renderer'; - -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 InactiveFlow = () => { - const [isInteractive, setIsInteractive] = useState(false); - const onToggleInteractive = (evt) => { - setIsInteractive(evt.target.checked); - }; - - return ( - - - - -
- -
-
- ); -} - -export default InactiveFlow; diff --git a/example/src/Interaction/index.js b/example/src/Interaction/index.js new file mode 100644 index 00000000..26281ec4 --- /dev/null +++ b/example/src/Interaction/index.js @@ -0,0 +1,75 @@ +import React, { useState } from 'react'; + +import ReactFlow, { addEdge, MiniMap, Controls } from 'react-flow-renderer'; + +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 InteractionFlow = () => { + const [elements, setElements] = useState(initialElements); + const onConnect = (params) => setElements((els) => addEdge(params, els)); + + const [isSelectable, setIsSelectable] = useState(false); + const [isDraggable, setIsDraggable] = useState(false); + const [isConnectable, setIsConnectable] = useState(false); + + return ( + + + + +
+
+ +
+
+ +
+
+ +
+
+
+ ); +}; + +export default InteractionFlow; diff --git a/example/src/index.js b/example/src/index.js index d03a5e12..a0bcd254 100644 --- a/example/src/index.js +++ b/example/src/index.js @@ -6,7 +6,7 @@ import Overview from './Overview'; import Basic from './Basic'; import CustomNode from './CustomNode'; import Stress from './Stress'; -import Inactive from './Inactive'; +import Interaction from './Interaction'; import Empty from './Empty'; import Edges from './Edges'; import Validation from './Validation'; @@ -60,8 +60,9 @@ const routes = [ component: Empty, }, { - path: '/inactive', - component: Inactive, + path: '/interaction', + component: Interaction, + label: 'Interaction', }, ]; diff --git a/src/additional-components/Controls/index.tsx b/src/additional-components/Controls/index.tsx index b6bbeb7b..0852f340 100644 --- a/src/additional-components/Controls/index.tsx +++ b/src/additional-components/Controls/index.tsx @@ -23,7 +23,7 @@ const Controls = ({ style, showZoom = true, showFitView = true, showInteractive const zoomIn = useStoreActions((actions) => actions.zoomIn); const zoomOut = useStoreActions((actions) => actions.zoomOut); - const isInteractive = useStoreState((s) => s.isInteractive); + const isInteractive = useStoreState((s) => s.nodesDraggable && s.nodesConnectable && s.elementsSelectable); const mapClasses = classnames('react-flow__controls', className); return ( diff --git a/src/components/ConnectionLine/index.tsx b/src/components/ConnectionLine/index.tsx index 10db3023..e21ce534 100644 --- a/src/components/ConnectionLine/index.tsx +++ b/src/components/ConnectionLine/index.tsx @@ -11,7 +11,7 @@ interface ConnectionLineProps { connectionLineType: ConnectionLineType; nodes: Node[]; transform: Transform; - isInteractive: boolean; + isConnectable: boolean; connectionLineStyle?: CSSProperties; className?: string; } @@ -26,7 +26,7 @@ export default ({ nodes = [], className, transform, - isInteractive, + isConnectable, }: ConnectionLineProps) => { const [sourceNode, setSourceNode] = useState(null); const hasHandleId = connectionNodeId.includes('__'); @@ -39,7 +39,7 @@ export default ({ setSourceNode(nextSourceNode); }, []); - if (!sourceNode || !isInteractive) { + if (!sourceNode || !isConnectable) { return null; } diff --git a/src/components/Edges/wrapEdge.tsx b/src/components/Edges/wrapEdge.tsx index 9b9d7640..2b45535e 100644 --- a/src/components/Edges/wrapEdge.tsx +++ b/src/components/Edges/wrapEdge.tsx @@ -17,7 +17,7 @@ interface EdgeWrapperProps { onClick?: (edge: Edge) => void; animated?: boolean; selected: boolean; - isInteractive: boolean; + elementsSelectable: boolean; } export default (EdgeComponent: ComponentType) => { @@ -30,7 +30,7 @@ export default (EdgeComponent: ComponentType) => { animated, selected, onClick, - isInteractive, + elementsSelectable, label, labelStyle, labelShowBg, @@ -41,10 +41,10 @@ export default (EdgeComponent: ComponentType) => { const setSelectedElements = useStoreActions((a) => a.setSelectedElements); const edgeClasses = cx('react-flow__edge', `react-flow__edge-${type}`, className, { selected, animated }); const edgeGroupStyle: CSSProperties = { - pointerEvents: isInteractive ? 'all' : 'none', + pointerEvents: elementsSelectable ? 'all' : 'none', }; const onEdgeClick = (): void => { - if (!isInteractive) { + if (!elementsSelectable) { return; } diff --git a/src/components/Handle/index.tsx b/src/components/Handle/index.tsx index 31e0832e..4c4d8875 100644 --- a/src/components/Handle/index.tsx +++ b/src/components/Handle/index.tsx @@ -1,4 +1,5 @@ import React, { memo, useContext } from 'react'; +import classnames from 'classnames'; import { useStoreActions, useStoreState } from '../../store/hooks'; import BaseHandle from './BaseHandle'; @@ -12,6 +13,7 @@ const Handle = memo( position = Position.Top, onConnect = () => {}, isValidConnection = () => true, + isConnectable = true, style, className, id, @@ -24,9 +26,12 @@ const Handle = memo( onConnectAction(params); onConnect(params); }; + const handleClasses = classnames(className, { connectable: isConnectable }); return ( ); } diff --git a/src/components/Nodes/DefaultNode.tsx b/src/components/Nodes/DefaultNode.tsx index 7f0fc4f4..23913c76 100644 --- a/src/components/Nodes/DefaultNode.tsx +++ b/src/components/Nodes/DefaultNode.tsx @@ -3,13 +3,15 @@ import React, { memo } from 'react'; import Handle from '../../components/Handle'; import { NodeProps, Position } from '../../types'; -const DefaultNode = memo(({ data, targetPosition = Position.Top, sourcePosition = Position.Bottom }: NodeProps) => ( - <> - - {data.label} - - -)); +const DefaultNode = memo( + ({ data, isConnectable, targetPosition = Position.Top, sourcePosition = Position.Bottom }: NodeProps) => ( + <> + + {data.label} + + + ) +); DefaultNode.displayName = 'DefaultNode'; diff --git a/src/components/Nodes/InputNode.tsx b/src/components/Nodes/InputNode.tsx index 4675493d..5e9123dc 100644 --- a/src/components/Nodes/InputNode.tsx +++ b/src/components/Nodes/InputNode.tsx @@ -3,10 +3,10 @@ import React, { memo } from 'react'; import Handle from '../../components/Handle'; import { NodeProps, Position } from '../../types'; -const InputNode = memo(({ data, sourcePosition = Position.Bottom }: NodeProps) => ( +const InputNode = memo(({ data, isConnectable, sourcePosition = Position.Bottom }: NodeProps) => ( <> {data.label} - + )); diff --git a/src/components/Nodes/OutputNode.tsx b/src/components/Nodes/OutputNode.tsx index 0da2f463..f77458f3 100644 --- a/src/components/Nodes/OutputNode.tsx +++ b/src/components/Nodes/OutputNode.tsx @@ -3,9 +3,9 @@ import React, { memo } from 'react'; import Handle from '../../components/Handle'; import { NodeProps, Position } from '../../types'; -const OutputNode = memo(({ data, targetPosition = Position.Top }: NodeProps) => ( +const OutputNode = memo(({ data, isConnectable, targetPosition = Position.Top }: NodeProps) => ( <> - + {data.label} )); diff --git a/src/components/Nodes/wrapNode.tsx b/src/components/Nodes/wrapNode.tsx index f052fcf5..df55ef58 100644 --- a/src/components/Nodes/wrapNode.tsx +++ b/src/components/Nodes/wrapNode.tsx @@ -1,4 +1,14 @@ -import React, { useEffect, useRef, useState, memo, ComponentType, CSSProperties, useMemo, MouseEvent } from 'react'; +import React, { + useEffect, + useRef, + useState, + memo, + ComponentType, + CSSProperties, + useMemo, + MouseEvent, + useCallback, +} from 'react'; import { DraggableCore } from 'react-draggable'; import cx from 'classnames'; import { ResizeObserver } from 'resize-observer'; @@ -28,6 +38,7 @@ interface OnDragStartParams { type: string; data: any; selectNodesOnDrag: boolean; + isSelectable: boolean; setOffset: (pos: XYPosition) => void; transform: Transform; position: XYPosition; @@ -46,6 +57,7 @@ const onStart = ({ transform, position, setSelectedElements, + isSelectable, }: OnDragStartParams): false | void => { const startEvt = getMouseEvent(evt); @@ -64,7 +76,7 @@ const onStart = ({ onNodeDragStart(node); } - if (selectNodesOnDrag) { + if (selectNodesOnDrag && isSelectable) { setSelectedElements({ id, type } as Node); } }; @@ -104,6 +116,7 @@ interface OnDragStopParams { position: XYPosition; data: any; selectNodesOnDrag: boolean; + isSelectable: boolean; setSelectedElements: (elms: Elements | Node | Edge) => void; onNodeDragStop?: (node: Node) => void; onClick?: (node: Node) => void; @@ -117,6 +130,7 @@ const onStop = ({ isDragging, setDragging, selectNodesOnDrag, + isSelectable, onNodeDragStop, onClick, setSelectedElements, @@ -127,8 +141,7 @@ const onStop = ({ position, data, } as Node; - - if (!isDragging) { + if (!isDragging && isSelectable) { if (!selectNodesOnDrag) { setSelectedElements({ id, type } as Node); } @@ -164,7 +177,9 @@ export default (NodeComponent: ComponentType) => { onNodeDragStop, style, className, - isInteractive, + isDraggable, + isSelectable, + isConnectable, selectNodesOnDrag, sourcePosition, targetPosition, @@ -177,7 +192,10 @@ export default (NodeComponent: ComponentType) => { const [offset, setOffset] = useState({ x: 0, y: 0 }); const [isDragging, setDragging] = useState(false); const position = { x: xPos, y: yPos }; - const nodeClasses = cx('react-flow__node', `react-flow__node-${type}`, className, { selected }); + const nodeClasses = cx('react-flow__node', `react-flow__node-${type}`, className, { + selected, + selectable: isSelectable, + }); const node = { id, type, position, data }; const onMouseEnterHandler = useMemo(() => { if (!onMouseEnter || isDragging) { @@ -211,10 +229,18 @@ export default (NodeComponent: ComponentType) => { return (evt: MouseEvent) => onContextMenu(evt, node); }, [onContextMenu]); + const onSelectNodeHandler = useCallback(() => { + if (!isDraggable && isSelectable) { + setSelectedElements({ id, type } as Node); + } + + return noop; + }, [isSelectable, isDraggable, id, type]); + const nodeStyle: CSSProperties = { zIndex: selected ? 10 : 3, transform: `translate(${xPos}px,${yPos}px)`, - pointerEvents: isInteractive ? 'all' : 'none', + pointerEvents: isSelectable || isDraggable ? 'all' : 'none', ...style, }; @@ -246,6 +272,7 @@ export default (NodeComponent: ComponentType) => { onStart({ evt: evt as MouseEvent, selectNodesOnDrag, + isSelectable, onNodeDragStart, id, type, @@ -261,6 +288,7 @@ export default (NodeComponent: ComponentType) => { onStop({ onNodeDragStop, selectNodesOnDrag, + isSelectable, onClick, isDragging, setDragging, @@ -272,7 +300,7 @@ export default (NodeComponent: ComponentType) => { }) } scale={transform[2]} - disabled={!isInteractive} + disabled={!isDraggable} cancel=".nodrag" >
) => { onMouseMove={onMouseMoveHandler} onMouseLeave={onMouseLeaveHandler} onContextMenu={onContextMenuHandler} + onClick={onSelectNodeHandler} > ) => { data={data} type={type} selected={selected} + isConnectable={isConnectable} sourcePosition={sourcePosition} targetPosition={targetPosition} /> diff --git a/src/components/UserSelection/index.tsx b/src/components/UserSelection/index.tsx index 6064d05e..dfc9ff82 100644 --- a/src/components/UserSelection/index.tsx +++ b/src/components/UserSelection/index.tsx @@ -8,7 +8,6 @@ import { useStoreActions, useStoreState } from '../../store/hooks'; import { XYPosition } from '../../types'; type UserSelectionProps = { - isInteractive: boolean; selectionKeyPressed: boolean; }; @@ -45,8 +44,9 @@ const SelectionRect = () => { ); }; -export default memo(({ isInteractive, selectionKeyPressed }: UserSelectionProps) => { +export default memo(({ selectionKeyPressed }: UserSelectionProps) => { const selectionActive = useStoreState((s) => s.selectionActive); + const elementsSelectable = useStoreState((s) => s.elementsSelectable); const setUserSelection = useStoreActions((a) => a.setUserSelection); const updateUserSelection = useStoreActions((a) => a.updateUserSelection); @@ -59,7 +59,7 @@ export default memo(({ isInteractive, selectionKeyPressed }: UserSelectionProps) } }, [selectionKeyPressed]); - if (!isInteractive || !renderUserSelectionPane) { + if (!elementsSelectable || !renderUserSelectionPane) { return null; } diff --git a/src/container/EdgeRenderer/index.tsx b/src/container/EdgeRenderer/index.tsx index 44275a60..3ecfd03a 100644 --- a/src/container/EdgeRenderer/index.tsx +++ b/src/container/EdgeRenderer/index.tsx @@ -118,7 +118,7 @@ function renderEdge( props: EdgeRendererProps, nodes: Node[], selectedElements: Elements | null, - isInteractive: boolean + elementsSelectable: boolean ) { const [sourceId, sourceHandleId] = edge.source.split('__'); const [targetId, targetHandleId] = edge.target.split('__'); @@ -181,7 +181,7 @@ function renderEdge( targetY={targetY} sourcePosition={sourcePosition} targetPosition={targetPosition} - isInteractive={isInteractive} + elementsSelectable={elementsSelectable} /> ); } @@ -194,7 +194,8 @@ const EdgeRenderer = memo((props: EdgeRendererProps) => { const connectionHandleType = useStoreState((s) => s.connectionHandleType); const connectionPosition = useStoreState((s) => s.connectionPosition); const selectedElements = useStoreState((s) => s.selectedElements); - const isInteractive = useStoreState((s) => s.isInteractive); + const nodesConnectable = useStoreState((s) => s.nodesConnectable); + const elementsSelectable = useStoreState((s) => s.elementsSelectable); const { width, height, connectionLineStyle, connectionLineType } = props; @@ -208,7 +209,7 @@ const EdgeRenderer = memo((props: EdgeRendererProps) => { return ( - {edges.map((e: Edge) => renderEdge(e, props, nodes, selectedElements, isInteractive))} + {edges.map((e: Edge) => renderEdge(e, props, nodes, selectedElements, elementsSelectable))} {renderConnectionLine && ( { transform={[tX, tY, tScale]} connectionLineStyle={connectionLineStyle} connectionLineType={connectionLineType} - isInteractive={isInteractive} + isConnectable={nodesConnectable} /> )} diff --git a/src/container/GraphView/index.tsx b/src/container/GraphView/index.tsx index b374d7d6..322884c9 100644 --- a/src/container/GraphView/index.tsx +++ b/src/container/GraphView/index.tsx @@ -1,5 +1,4 @@ import React, { useEffect, useRef, memo, CSSProperties, MouseEvent } from 'react'; -import classnames from 'classnames'; import { ResizeObserver } from 'resize-observer'; import { useStoreState, useStoreActions } from '../../store/hooks'; @@ -46,7 +45,9 @@ export interface GraphViewProps { snapToGrid: boolean; snapGrid: [number, number]; onlyRenderVisibleNodes: boolean; - isInteractive: boolean; + nodesDraggable: boolean; + nodesConnectable: boolean; + elementsSelectable: boolean; selectNodesOnDrag: boolean; minZoom: number; maxZoom: number; @@ -76,7 +77,9 @@ const GraphView = memo( snapToGrid, snapGrid, onlyRenderVisibleNodes, - isInteractive, + nodesDraggable, + nodesConnectable, + elementsSelectable, selectNodesOnDrag, minZoom, maxZoom, @@ -92,14 +95,15 @@ const GraphView = memo( const setNodesSelection = useStoreActions((actions) => actions.setNodesSelection); const setOnConnect = useStoreActions((a) => a.setOnConnect); const setSnapGrid = useStoreActions((actions) => actions.setSnapGrid); - const setInteractive = useStoreActions((actions) => actions.setInteractive); + const setNodesDraggable = useStoreActions((actions) => actions.setNodesDraggable); + const setNodesConnectable = useStoreActions((actions) => actions.setNodesConnectable); + const setElementsSelectable = useStoreActions((actions) => actions.setElementsSelectable); const updateTransform = useStoreActions((actions) => actions.updateTransform); const setMinMaxZoom = useStoreActions((actions) => actions.setMinMaxZoom); const fitView = useStoreActions((actions) => actions.fitView); const zoom = useStoreActions((actions) => actions.zoom); const selectionKeyPressed = useKeyPress(selectionKeyCode); - const rendererClasses = classnames('react-flow__renderer', { 'is-interactive': isInteractive }); const onZoomPaneClick = () => setNodesSelection({ isActive: false }); @@ -169,8 +173,16 @@ const GraphView = memo( }, [snapToGrid]); useEffect(() => { - setInteractive(isInteractive); - }, [isInteractive]); + setNodesDraggable(nodesDraggable); + }, [nodesDraggable]); + + useEffect(() => { + setNodesConnectable(nodesConnectable); + }, [nodesConnectable]); + + useEffect(() => { + setElementsSelectable(elementsSelectable); + }, [elementsSelectable]); useEffect(() => { setMinMaxZoom({ minZoom, maxZoom }); @@ -180,7 +192,7 @@ const GraphView = memo( useElementUpdater(elements); return ( -
+
- + {nodesSelectionActive && }
diff --git a/src/container/NodeRenderer/index.tsx b/src/container/NodeRenderer/index.tsx index 3ca43ab9..079d6d23 100644 --- a/src/container/NodeRenderer/index.tsx +++ b/src/container/NodeRenderer/index.tsx @@ -22,7 +22,9 @@ function renderNode( props: NodeRendererProps, transform: Transform, selectedElements: Elements | null, - isInteractive: boolean + nodesDraggable: boolean, + nodesConnectable: boolean, + elementsSelectable: boolean ) { const nodeType = node.type || 'default'; const NodeComponent = (props.nodeTypes[nodeType] || props.nodeTypes.default) as ComponentType; @@ -51,7 +53,9 @@ function renderNode( selected={isSelected} style={node.style} className={node.className} - isInteractive={isInteractive} + isDraggable={nodesDraggable} + isSelectable={elementsSelectable} + isConnectable={nodesConnectable} sourcePosition={node.sourcePosition} targetPosition={node.targetPosition} selectNodesOnDrag={props.selectNodesOnDrag} @@ -65,7 +69,10 @@ const NodeRenderer = memo(({ onlyRenderVisibleNodes = true, ...props }: NodeRend const selectedElements = useStoreState((s) => s.selectedElements); const width = useStoreState((s) => s.width); const height = useStoreState((s) => s.height); - const isInteractive = useStoreState((s) => s.isInteractive); + const nodesDraggable = useStoreState((s) => s.nodesDraggable); + const nodesConnectable = useStoreState((s) => s.nodesConnectable); + const elementsSelectable = useStoreState((s) => s.elementsSelectable); + const [tX, tY, tScale] = transform; const transformStyle = { transform: `translate(${tX}px,${tY}px) scale(${tScale})`, @@ -77,7 +84,9 @@ const NodeRenderer = memo(({ onlyRenderVisibleNodes = true, ...props }: NodeRend return (
- {renderNodes.map((node) => renderNode(node, props, transform, selectedElements, isInteractive))} + {renderNodes.map((node) => + renderNode(node, props, transform, selectedElements, nodesDraggable, nodesConnectable, elementsSelectable) + )}
); }); diff --git a/src/container/ReactFlow/index.tsx b/src/container/ReactFlow/index.tsx index ead29f93..343e2964 100644 --- a/src/container/ReactFlow/index.tsx +++ b/src/container/ReactFlow/index.tsx @@ -56,7 +56,9 @@ export interface ReactFlowProps extends Omit, 'on snapToGrid: boolean; snapGrid: [number, number]; onlyRenderVisibleNodes: boolean; - isInteractive: boolean; + nodesDraggable: boolean; + nodesConnectable: boolean; + elementsSelectable: boolean; selectNodesOnDrag: boolean; minZoom: number; maxZoom: number; @@ -89,7 +91,9 @@ const ReactFlow = ({ snapToGrid, snapGrid, onlyRenderVisibleNodes, - isInteractive, + nodesDraggable, + nodesConnectable, + elementsSelectable, selectNodesOnDrag, minZoom, maxZoom, @@ -123,7 +127,9 @@ const ReactFlow = ({ snapToGrid={snapToGrid} snapGrid={snapGrid} onlyRenderVisibleNodes={onlyRenderVisibleNodes} - isInteractive={isInteractive} + nodesDraggable={nodesDraggable} + nodesConnectable={nodesConnectable} + elementsSelectable={elementsSelectable} selectNodesOnDrag={selectNodesOnDrag} minZoom={minZoom} maxZoom={maxZoom} @@ -156,7 +162,9 @@ ReactFlow.defaultProps = { snapToGrid: false, snapGrid: [16, 16], onlyRenderVisibleNodes: true, - isInteractive: true, + nodesDraggable: true, + nodesConnectable: true, + elementsSelectable: true, selectNodesOnDrag: true, minZoom: 0.5, maxZoom: 2, diff --git a/src/store/index.ts b/src/store/index.ts index bb5486c2..5194a04b 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -79,7 +79,9 @@ export interface StoreModel { snapToGrid: boolean; snapGrid: [number, number]; - isInteractive: boolean; + nodesDraggable: boolean; + nodesConnectable: boolean; + elementsSelectable: boolean; reactFlowVersion: string; @@ -116,6 +118,9 @@ export interface StoreModel { setConnectionNodeId: Action; setInteractive: Action; + setNodesDraggable: Action; + setNodesConnectable: Action; + setElementsSelectable: Action; setUserSelection: Action; updateUserSelection: Action; @@ -162,7 +167,9 @@ export const storeModel: StoreModel = { snapGrid: [16, 16], snapToGrid: false, - isInteractive: true, + nodesDraggable: true, + nodesConnectable: true, + elementsSelectable: true, reactFlowVersion: typeof __REACT_FLOW_VERSION__ !== 'undefined' ? __REACT_FLOW_VERSION__ : '-', @@ -373,7 +380,21 @@ export const storeModel: StoreModel = { }), setInteractive: action((state, isInteractive) => { - state.isInteractive = isInteractive; + state.nodesDraggable = isInteractive; + state.nodesConnectable = isInteractive; + state.elementsSelectable = isInteractive; + }), + + setNodesDraggable: action((state, nodesDraggable) => { + state.nodesDraggable = nodesDraggable; + }), + + setNodesConnectable: action((state, nodesConnectable) => { + state.nodesConnectable = nodesConnectable; + }), + + setElementsSelectable: action((state, elementsSelectable) => { + state.elementsSelectable = elementsSelectable; }), fitView: action((state, payload = { padding: 0.1 }) => { diff --git a/src/style.css b/src/style.css index 731a6191..e2057f62 100644 --- a/src/style.css +++ b/src/style.css @@ -112,7 +112,11 @@ font-size: 12px; color: #222; text-align: center; +} +.react-flow__node-default.selectable, +.react-flow__node-input.selectable, +.react-flow__node-output.selectable { &.selected, &.selected:hover { box-shadow: 0 0 0 2px #555; @@ -158,7 +162,12 @@ width: 10px; height: 8px; background: rgba(255, 255, 255, 0.4); - cursor: crosshair; + pointer-events: none; + + &.connectable { + pointer-events: all; + cursor: crosshair; + } } .react-flow__handle-bottom { diff --git a/src/types/index.ts b/src/types/index.ts index 9fd3db49..666a6e2d 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -94,6 +94,7 @@ export interface NodeProps { type: string; data: any; selected: boolean; + isConnectable: boolean; targetPosition?: Position; sourcePosition?: Position; } @@ -102,6 +103,7 @@ export interface NodeComponentProps { id: ElementId; type: string; data: any; + isConnectable: boolean; selected?: boolean; transform?: Transform; xPos?: number; @@ -126,7 +128,9 @@ export interface WrapNodeProps { transform: Transform; xPos: number; yPos: number; - isInteractive: boolean; + isSelectable: boolean; + isDraggable: boolean; + isConnectable: boolean; selectNodesOnDrag: boolean; onClick?: (node: Node) => void; onMouseEnter?: (evt: MouseEvent, node: Node) => void; @@ -182,6 +186,7 @@ export interface HandleElement extends XYPosition, Dimensions { export interface HandleProps { type: HandleType; position: Position; + isConnectable?: boolean; onConnect?: OnConnectFunc; isValidConnection?: (connection: Connection) => boolean; id?: string;