diff --git a/cypress/integration/flow/basic.spec.js b/cypress/integration/flow/basic.spec.js index 2d39054f..804d6e2b 100644 --- a/cypress/integration/flow/basic.spec.js +++ b/cypress/integration/flow/basic.spec.js @@ -15,6 +15,19 @@ describe('Basic Flow Rendering', () => { cy.get('.react-flow__background'); }); + it('selects two nodes by clicks', () => { + cy.get('body').type('{cmd}', { release: false }); + cy.get('.react-flow__node:first') + .click() + .should('have.class', 'selected') + .get('.react-flow__node:last') + .click() + .should('have.class', 'selected') + .get('.react-flow__node:first') + .should('have.class', 'selected'); + cy.get('body').type('{cmd}', { release: true }); + }); + it('selects a node by click', () => { cy.get('.react-flow__node:first').click().should('have.class', 'selected'); }); diff --git a/cypress/integration/flow/interaction.spec.js b/cypress/integration/flow/interaction.spec.js index af43c228..f8a1b734 100644 --- a/cypress/integration/flow/interaction.spec.js +++ b/cypress/integration/flow/interaction.spec.js @@ -135,6 +135,7 @@ describe('Interaction Flow Rendering', () => { }); it('zooms by double click', () => { + cy.get('.react-flow__controls-zoomout').click(); const styleBeforeZoom = Cypress.$('.react-flow__nodes').css('transform'); cy.get('.react-flow__renderer') diff --git a/src/components/Edges/wrapEdge.tsx b/src/components/Edges/wrapEdge.tsx index 2d682b73..d1e881b2 100644 --- a/src/components/Edges/wrapEdge.tsx +++ b/src/components/Edges/wrapEdge.tsx @@ -33,7 +33,7 @@ export default (EdgeComponent: ComponentType) => { markerEndId, isHidden, }: WrapEdgeProps) => { - const setSelectedElements = useStoreActions((actions) => actions.setSelectedElements); + const addSelectedElements = useStoreActions((actions) => actions.addSelectedElements); const inactive = !elementsSelectable && !onClick; const edgeClasses = cc([ @@ -46,7 +46,7 @@ export default (EdgeComponent: ComponentType) => { const onEdgeClick = useCallback( (event: React.MouseEvent): void => { if (elementsSelectable) { - setSelectedElements({ id, source, target }); + addSelectedElements({ id, source, target }); } if (onClick) { diff --git a/src/components/Nodes/wrapNode.tsx b/src/components/Nodes/wrapNode.tsx index a0ca4b0d..5e69704b 100644 --- a/src/components/Nodes/wrapNode.tsx +++ b/src/components/Nodes/wrapNode.tsx @@ -38,7 +38,7 @@ export default (NodeComponent: ComponentType) => { isDragging, }: WrapNodeProps) => { const updateNodeDimensions = useStoreActions((actions) => actions.updateNodeDimensions); - const setSelectedElements = useStoreActions((actions) => actions.setSelectedElements); + const addSelectedElements = useStoreActions((actions) => actions.addSelectedElements); const updateNodePosDiff = useStoreActions((actions) => actions.updateNodePosDiff); const unsetNodesSelection = useStoreActions((actions) => actions.unsetNodesSelection); @@ -94,13 +94,16 @@ export default (NodeComponent: ComponentType) => { if (!isDraggable) { if (isSelectable) { unsetNodesSelection(); - setSelectedElements({ id: node.id, type: node.type } as Node); + + if (!selected) { + addSelectedElements({ id: node.id, type: node.type } as Node); + } } onClick?.(event, node); } }, - [isSelectable, isDraggable, onClick, node] + [isSelectable, selected, isDraggable, onClick, node] ); const onDragStart = useCallback( @@ -109,10 +112,13 @@ export default (NodeComponent: ComponentType) => { if (selectNodesOnDrag && isSelectable) { unsetNodesSelection(); - setSelectedElements({ id: node.id, type: node.type } as Node); + + if (!selected) { + addSelectedElements({ id: node.id, type: node.type } as Node); + } } }, - [node, selectNodesOnDrag, isSelectable, onNodeDragStart] + [node, selected, selectNodesOnDrag, isSelectable, onNodeDragStart] ); const onDrag = useCallback( @@ -133,8 +139,8 @@ export default (NodeComponent: ComponentType) => { // onDragStop also gets called when user just clicks on a node. // Because of that we set dragging to true inside the onDrag handler and handle the click here if (!isDragging) { - if (isSelectable && !selectNodesOnDrag) { - setSelectedElements({ id: node.id, type: node.type } as Node); + if (isSelectable && !selectNodesOnDrag && !selected) { + addSelectedElements({ id: node.id, type: node.type } as Node); } onClick?.(event as MouseEvent, node); @@ -143,13 +149,13 @@ export default (NodeComponent: ComponentType) => { } updateNodePosDiff({ - id, + id: node.id, isDragging: false, }); onNodeDragStop?.(event as MouseEvent, node); }, - [node, isSelectable, selectNodesOnDrag, onClick, onNodeDragStop, isDragging] + [node, isSelectable, selectNodesOnDrag, onClick, onNodeDragStop, isDragging, selected] ); useEffect(() => { diff --git a/src/components/NodesSelection/index.tsx b/src/components/NodesSelection/index.tsx index c61dd638..f6029659 100644 --- a/src/components/NodesSelection/index.tsx +++ b/src/components/NodesSelection/index.tsx @@ -77,14 +77,11 @@ export default ({ onSelectionDrag(event, selectedNodes); } - selectedNodes?.forEach((node) => { - updateNodePosDiff({ - id: node.id, - diff: { - x: data.deltaX, - y: data.deltaY, - }, - }); + updateNodePosDiff({ + diff: { + x: data.deltaX, + y: data.deltaY, + }, }); }, [onSelectionDrag, selectedNodes, updateNodePosDiff] @@ -92,11 +89,8 @@ export default ({ const onStop = useCallback( (event: MouseEvent) => { - selectedNodes?.forEach((node) => { - updateNodePosDiff({ - id: node.id, - isDragging: false, - }); + updateNodePosDiff({ + isDragging: false, }); onSelectionDragStop?.(event, selectedNodes); diff --git a/src/container/FlowRenderer/index.tsx b/src/container/FlowRenderer/index.tsx index 726db470..12bec2f5 100644 --- a/src/container/FlowRenderer/index.tsx +++ b/src/container/FlowRenderer/index.tsx @@ -35,6 +35,7 @@ const FlowRenderer = ({ onMoveStart, onMoveEnd, selectionKeyCode, + multiSelectionKeyCode, elementsSelectable, zoomOnScroll, panOnScroll, @@ -50,15 +51,18 @@ const FlowRenderer = ({ onSelectionContextMenu, }: FlowRendererProps) => { const unsetNodesSelection = useStoreActions((actions) => actions.unsetNodesSelection); + const resetSelectedElements = useStoreActions((actions) => actions.resetSelectedElements); const nodesSelectionActive = useStoreState((state) => state.nodesSelectionActive); + const selectionKeyPressed = useKeyPress(selectionKeyCode); - useGlobalKeyHandler({ onElementsRemove, deleteKeyCode }); + useGlobalKeyHandler({ onElementsRemove, deleteKeyCode, multiSelectionKeyCode }); const onClick = useCallback( (event: MouseEvent) => { onPaneClick?.(event); unsetNodesSelection(); + resetSelectedElements(); }, [onPaneClick] ); diff --git a/src/container/GraphView/index.tsx b/src/container/GraphView/index.tsx index abd34865..eb68438d 100644 --- a/src/container/GraphView/index.tsx +++ b/src/container/GraphView/index.tsx @@ -55,6 +55,7 @@ export interface GraphViewProps { connectionLineStyle?: CSSProperties; connectionLineComponent?: ConnectionLineComponent; deleteKeyCode: number; + multiSelectionKeyCode: number; snapToGrid: boolean; snapGrid: [number, number]; onlyRenderVisibleNodes: boolean; @@ -98,6 +99,7 @@ const GraphView = ({ connectionLineStyle, connectionLineComponent, selectionKeyCode, + multiSelectionKeyCode, onElementsRemove, deleteKeyCode, elements, @@ -249,6 +251,7 @@ const GraphView = ({ onElementsRemove={onElementsRemove} deleteKeyCode={deleteKeyCode} selectionKeyCode={selectionKeyCode} + multiSelectionKeyCode={multiSelectionKeyCode} elementsSelectable={elementsSelectable} onMove={onMove} onMoveStart={onMoveStart} diff --git a/src/container/ReactFlow/index.tsx b/src/container/ReactFlow/index.tsx index c28c6639..533d2100 100644 --- a/src/container/ReactFlow/index.tsx +++ b/src/container/ReactFlow/index.tsx @@ -82,6 +82,7 @@ export interface ReactFlowProps extends Omit, 'on connectionLineComponent?: ConnectionLineComponent; deleteKeyCode?: number; selectionKeyCode?: number; + multiSelectionKeyCode?: number; snapToGrid?: boolean; snapGrid?: [number, number]; onlyRenderVisibleNodes?: boolean; @@ -134,6 +135,7 @@ const ReactFlow = ({ connectionLineComponent, deleteKeyCode = 8, selectionKeyCode = 16, + multiSelectionKeyCode = 91, snapToGrid = false, snapGrid = [15, 15], onlyRenderVisibleNodes = true, @@ -186,6 +188,7 @@ const ReactFlow = ({ selectionKeyCode={selectionKeyCode} onElementsRemove={onElementsRemove} deleteKeyCode={deleteKeyCode} + multiSelectionKeyCode={multiSelectionKeyCode} elements={elements} onConnect={onConnect} onConnectStart={onConnectStart} diff --git a/src/hooks/useGlobalKeyHandler.ts b/src/hooks/useGlobalKeyHandler.ts index 0fd256c8..a6319b3d 100644 --- a/src/hooks/useGlobalKeyHandler.ts +++ b/src/hooks/useGlobalKeyHandler.ts @@ -7,15 +7,20 @@ import { Elements } from '../types'; interface HookParams { deleteKeyCode: number; + multiSelectionKeyCode: number; onElementsRemove?: (elements: Elements) => void; } -export default ({ deleteKeyCode, onElementsRemove }: HookParams): void => { +export default ({ deleteKeyCode, multiSelectionKeyCode, onElementsRemove }: HookParams): void => { const selectedElements = useStoreState((state) => state.selectedElements); const edges = useStoreState((state) => state.edges); const unsetNodesSelection = useStoreActions((actions) => actions.unsetNodesSelection); + const setMultiSelectionActive = useStoreActions((actions) => actions.setMultiSelectionActive); + const resetSelectedElements = useStoreActions((actions) => actions.resetSelectedElements); + const deleteKeyPressed = useKeyPress(deleteKeyCode); + const multiSelectionKeyPressed = useKeyPress(multiSelectionKeyCode); useEffect(() => { if (onElementsRemove && deleteKeyPressed && selectedElements) { @@ -30,6 +35,11 @@ export default ({ deleteKeyCode, onElementsRemove }: HookParams): void => { onElementsRemove(elementsToRemove); unsetNodesSelection(); + resetSelectedElements(); } }, [deleteKeyPressed]); + + useEffect(() => { + setMultiSelectionActive(multiSelectionKeyPressed); + }, [multiSelectionKeyPressed]); }; diff --git a/src/store/index.ts b/src/store/index.ts index 33d0441c..36417d4f 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -84,6 +84,8 @@ export interface StoreModel { nodesConnectable: boolean; elementsSelectable: boolean; + multiSelectionActive: boolean; + reactFlowVersion: string; onConnect?: OnConnectFunc; @@ -106,8 +108,10 @@ export interface StoreModel { setSelection: Action; unsetNodesSelection: Action; + resetSelectedElements: Action; setSelectedElements: Action; + addSelectedElements: Thunk; updateTransform: Action; @@ -143,6 +147,8 @@ export interface StoreModel { zoom: Thunk; zoomIn: Thunk; zoomOut: Thunk; + + setMultiSelectionActive: Action; } export const storeModel: StoreModel = { @@ -191,6 +197,8 @@ export const storeModel: StoreModel = { nodesConnectable: true, elementsSelectable: true, + multiSelectionActive: false, + reactFlowVersion: typeof __REACT_FLOW_VERSION__ !== 'undefined' ? __REACT_FLOW_VERSION__ : '-', setOnConnect: action((state, onConnect) => { @@ -255,9 +263,9 @@ export const storeModel: StoreModel = { }); }), - updateNodePosDiff: action((state, { id, diff = null, isDragging = true }) => { + updateNodePosDiff: action((state, { id = null, diff = null, isDragging = true }) => { state.elements.forEach((n) => { - if (n.id === id && isNode(n)) { + if (isNode(n) && (id === n.id || state.selectedElements?.find((sNode) => sNode.id === n.id))) { if (diff) { n.__rf.position = { x: n.__rf.position.x + diff.x, @@ -336,6 +344,9 @@ export const storeModel: StoreModel = { unsetNodesSelection: action((state) => { state.nodesSelectionActive = false; + }), + + resetSelectedElements: action((state) => { state.selectedElements = null; }), @@ -347,6 +358,20 @@ export const storeModel: StoreModel = { state.selectedElements = selectedElements; }), + addSelectedElements: thunk((actions, elements, helpers) => { + const { multiSelectionActive, selectedElements } = helpers.getState(); + const selectedElementsArr = Array.isArray(elements) ? elements : [elements]; + + if (multiSelectionActive) { + const nextElements = selectedElements ? [...selectedElements, ...selectedElementsArr] : selectedElementsArr; + actions.setSelectedElements(nextElements); + + return; + } + + actions.setSelectedElements(elements); + }), + updateTransform: action((state, transform) => { state.transform[0] = transform.x; state.transform[1] = transform.y; @@ -517,6 +542,10 @@ export const storeModel: StoreModel = { zoomOut: thunk((actions) => { actions.zoom(-0.2); }), + + setMultiSelectionActive: action((state, isActive) => { + state.multiSelectionActive = isActive; + }), }; const nodeEnv: string = (typeof __ENV__ !== 'undefined' && __ENV__) as string; diff --git a/src/types/index.ts b/src/types/index.ts index 418eba96..583d9391 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -308,7 +308,7 @@ export type NodePosUpdate = { }; export type NodeDiffUpdate = { - id: ElementId; + id?: ElementId; diff?: XYPosition; isDragging?: boolean; };