From 65a1b5e07a5551b9b5db0bd79af946d963cb5e92 Mon Sep 17 00:00:00 2001 From: moklick Date: Thu, 22 Oct 2020 00:03:30 +0200 Subject: [PATCH 1/7] feat(elements): multiselect --- src/components/Edges/wrapEdge.tsx | 4 ++-- src/components/Nodes/wrapNode.tsx | 8 ++++---- src/container/FlowRenderer/index.tsx | 7 ++++++- src/container/GraphView/index.tsx | 3 +++ src/container/ReactFlow/index.tsx | 3 +++ src/hooks/useGlobalKeyHandler.ts | 12 +++++++++++- src/store/index.ts | 29 ++++++++++++++++++++++++++++ 7 files changed, 58 insertions(+), 8 deletions(-) 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 7c22aa36..9a9c89d4 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,7 +94,7 @@ export default (NodeComponent: ComponentType) => { if (!isDraggable) { if (isSelectable) { unsetNodesSelection(); - setSelectedElements({ id: node.id, type: node.type } as Node); + addSelectedElements({ id: node.id, type: node.type } as Node); } onClick?.(event, node); @@ -109,7 +109,7 @@ export default (NodeComponent: ComponentType) => { if (selectNodesOnDrag && isSelectable) { unsetNodesSelection(); - setSelectedElements({ id: node.id, type: node.type } as Node); + addSelectedElements({ id: node.id, type: node.type } as Node); } }, [node, selectNodesOnDrag, isSelectable, onNodeDragStart] @@ -134,7 +134,7 @@ export default (NodeComponent: ComponentType) => { // 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); + addSelectedElements({ id: node.id, type: node.type } as Node); } onClick?.(event as MouseEvent, node); diff --git a/src/container/FlowRenderer/index.tsx b/src/container/FlowRenderer/index.tsx index da3f8e1b..0db73284 100644 --- a/src/container/FlowRenderer/index.tsx +++ b/src/container/FlowRenderer/index.tsx @@ -36,6 +36,7 @@ const FlowRenderer = ({ onMoveStart, onMoveEnd, selectionKeyCode, + multiSelectionKeyCode, zoomOnScroll, zoomOnDoubleClick, paneMoveable, @@ -48,12 +49,15 @@ const FlowRenderer = ({ onSelectionContextMenu, }: FlowRendererProps) => { const zoomPane = useRef(null); + const unsetNodesSelection = useStoreActions((actions) => actions.unsetNodesSelection); + const resetSelectedElements = useStoreActions((actions) => actions.resetSelectedElements); const nodesSelectionActive = useStoreState((state) => state.nodesSelectionActive); + const selectionKeyPressed = useKeyPress(selectionKeyCode); useResizeHandler(zoomPane); - useGlobalKeyHandler({ onElementsRemove, deleteKeyCode }); + useGlobalKeyHandler({ onElementsRemove, deleteKeyCode, multiSelectionKeyCode }); useD3Zoom({ zoomPane, @@ -73,6 +77,7 @@ const FlowRenderer = ({ (event: MouseEvent) => { onPaneClick?.(event); unsetNodesSelection(); + resetSelectedElements(); }, [onPaneClick] ); diff --git a/src/container/GraphView/index.tsx b/src/container/GraphView/index.tsx index 0dcafc0f..85a4b266 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; @@ -96,6 +97,7 @@ const GraphView = ({ connectionLineStyle, connectionLineComponent, selectionKeyCode, + multiSelectionKeyCode, onElementsRemove, deleteKeyCode, elements, @@ -245,6 +247,7 @@ const GraphView = ({ onElementsRemove={onElementsRemove} deleteKeyCode={deleteKeyCode} selectionKeyCode={selectionKeyCode} + multiSelectionKeyCode={multiSelectionKeyCode} onMove={onMove} onMoveStart={onMoveStart} onMoveEnd={onMoveEnd} diff --git a/src/container/ReactFlow/index.tsx b/src/container/ReactFlow/index.tsx index 0593fa8f..8de2008c 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; @@ -132,6 +133,7 @@ const ReactFlow = ({ connectionLineComponent, deleteKeyCode = 8, selectionKeyCode = 16, + multiSelectionKeyCode = 91, snapToGrid = false, snapGrid = [15, 15], onlyRenderVisibleNodes = true, @@ -182,6 +184,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..c229b09d 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 39d64b3b..eeae8f79 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -82,6 +82,8 @@ export interface StoreModel { nodesConnectable: boolean; elementsSelectable: boolean; + multiSelectionActive: boolean; + reactFlowVersion: string; onConnect?: OnConnectFunc; @@ -104,8 +106,10 @@ export interface StoreModel { setSelection: Action; unsetNodesSelection: Action; + resetSelectedElements: Action; setSelectedElements: Action; + addSelectedElements: Thunk; updateTransform: Action; @@ -141,6 +145,8 @@ export interface StoreModel { zoom: Thunk; zoomIn: Thunk; zoomOut: Thunk; + + setMultiSelectionActive: Action; } export const storeModel: StoreModel = { @@ -187,6 +193,8 @@ export const storeModel: StoreModel = { nodesConnectable: true, elementsSelectable: true, + multiSelectionActive: false, + reactFlowVersion: typeof __REACT_FLOW_VERSION__ !== 'undefined' ? __REACT_FLOW_VERSION__ : '-', setOnConnect: action((state, onConnect) => { @@ -332,6 +340,9 @@ export const storeModel: StoreModel = { unsetNodesSelection: action((state) => { state.nodesSelectionActive = false; + }), + + resetSelectedElements: action((state) => { state.selectedElements = null; }), @@ -343,6 +354,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; @@ -509,6 +534,10 @@ export const storeModel: StoreModel = { zoomOut: thunk((actions) => { actions.zoom(-0.2); }), + + setMultiSelectionActive: action((state, isActive) => { + state.multiSelectionActive = isActive; + }) }; const nodeEnv: string = process.env.NODE_ENV as string; From 0774d35846ba759a5de6574de5c5d0b3ab935ba9 Mon Sep 17 00:00:00 2001 From: moklick Date: Thu, 5 Nov 2020 14:32:10 +0100 Subject: [PATCH 2/7] feat(nodes): multi drag --- src/components/Nodes/wrapNode.tsx | 16 ++++++++++------ src/components/NodesSelection/index.tsx | 20 +++++++------------- src/store/index.ts | 10 +++++----- src/types/index.ts | 1 - 4 files changed, 22 insertions(+), 25 deletions(-) diff --git a/src/components/Nodes/wrapNode.tsx b/src/components/Nodes/wrapNode.tsx index 5c77efab..2b01850b 100644 --- a/src/components/Nodes/wrapNode.tsx +++ b/src/components/Nodes/wrapNode.tsx @@ -94,13 +94,16 @@ export default (NodeComponent: ComponentType) => { if (!isDraggable) { if (isSelectable) { unsetNodesSelection(); - addSelectedElements({ 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,16 +112,18 @@ export default (NodeComponent: ComponentType) => { if (selectNodesOnDrag && isSelectable) { unsetNodesSelection(); - addSelectedElements({ 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( (_, data) => { updateNodePosDiff({ - id, diff: { x: data.deltaX, y: data.deltaY, @@ -143,7 +148,6 @@ export default (NodeComponent: ComponentType) => { } updateNodePosDiff({ - id, isDragging: false, }); 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/store/index.ts b/src/store/index.ts index 6c0e7c81..e9a93bd9 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -111,7 +111,7 @@ export interface StoreModel { resetSelectedElements: Action; setSelectedElements: Action; - addSelectedElements: Thunk; + addSelectedElements: Thunk; updateTransform: Action; @@ -263,9 +263,9 @@ export const storeModel: StoreModel = { }); }), - updateNodePosDiff: action((state, { id, diff = null, isDragging = true }) => { + updateNodePosDiff: action((state, { diff = null, isDragging = true }) => { state.elements.forEach((n) => { - if (n.id === id && isNode(n)) { + if (isNode(n) && state.selectedElements?.find((sNode) => sNode.id === n.id)) { if (diff) { n.__rf.position = { x: n.__rf.position.x + diff.x, @@ -363,7 +363,7 @@ export const storeModel: StoreModel = { const selectedElementsArr = Array.isArray(elements) ? elements : [elements]; if (multiSelectionActive) { - const nextElements = selectedElements ? [...selectedElements, ...selectedElementsArr] : selectedElementsArr; + const nextElements = selectedElements ? [...selectedElements, ...selectedElementsArr] : selectedElementsArr; actions.setSelectedElements(nextElements); return; @@ -545,7 +545,7 @@ export const storeModel: StoreModel = { 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..12ae1d54 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -308,7 +308,6 @@ export type NodePosUpdate = { }; export type NodeDiffUpdate = { - id: ElementId; diff?: XYPosition; isDragging?: boolean; }; From 7e55f8c07fb637e383f82610a548bcc402f446e5 Mon Sep 17 00:00:00 2001 From: moklick Date: Thu, 5 Nov 2020 14:36:26 +0100 Subject: [PATCH 3/7] fix(nodes): always drag draggable node even if not selected --- src/components/Nodes/wrapNode.tsx | 2 ++ src/store/index.ts | 4 ++-- src/types/index.ts | 1 + 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/components/Nodes/wrapNode.tsx b/src/components/Nodes/wrapNode.tsx index 2b01850b..11f9dda1 100644 --- a/src/components/Nodes/wrapNode.tsx +++ b/src/components/Nodes/wrapNode.tsx @@ -124,6 +124,7 @@ export default (NodeComponent: ComponentType) => { const onDrag = useCallback( (_, data) => { updateNodePosDiff({ + id, diff: { x: data.deltaX, y: data.deltaY, @@ -148,6 +149,7 @@ export default (NodeComponent: ComponentType) => { } updateNodePosDiff({ + id: node.id, isDragging: false, }); diff --git a/src/store/index.ts b/src/store/index.ts index e9a93bd9..36417d4f 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -263,9 +263,9 @@ export const storeModel: StoreModel = { }); }), - updateNodePosDiff: action((state, { diff = null, isDragging = true }) => { + updateNodePosDiff: action((state, { id = null, diff = null, isDragging = true }) => { state.elements.forEach((n) => { - if (isNode(n) && state.selectedElements?.find((sNode) => sNode.id === n.id)) { + 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, diff --git a/src/types/index.ts b/src/types/index.ts index 12ae1d54..583d9391 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -308,6 +308,7 @@ export type NodePosUpdate = { }; export type NodeDiffUpdate = { + id?: ElementId; diff?: XYPosition; isDragging?: boolean; }; From 84334e4b3270c692b01cbf1ca73d90c116152a10 Mon Sep 17 00:00:00 2001 From: moklick Date: Thu, 5 Nov 2020 15:24:20 +0100 Subject: [PATCH 4/7] test(nodes): multi select by click --- cypress/integration/flow/basic.spec.js | 13 +++++++++++++ 1 file changed, 13 insertions(+) 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'); }); From 4a49149aeebc8befdc6f8c3b22bd15998c39bac3 Mon Sep 17 00:00:00 2001 From: moklick Date: Thu, 5 Nov 2020 15:27:56 +0100 Subject: [PATCH 5/7] test(interaction): zoom out before check double click zoom --- cypress/integration/flow/interaction.spec.js | 1 + 1 file changed, 1 insertion(+) 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') From fe3db783f1a39e67443ae0350cd5c7b349303cab Mon Sep 17 00:00:00 2001 From: moklick Date: Thu, 5 Nov 2020 15:32:23 +0100 Subject: [PATCH 6/7] refactor(global-keyhandler): cleanup --- src/hooks/useGlobalKeyHandler.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/hooks/useGlobalKeyHandler.ts b/src/hooks/useGlobalKeyHandler.ts index c229b09d..a6319b3d 100644 --- a/src/hooks/useGlobalKeyHandler.ts +++ b/src/hooks/useGlobalKeyHandler.ts @@ -11,7 +11,7 @@ interface HookParams { onElementsRemove?: (elements: Elements) => void; } -export default ({ deleteKeyCode, multiSelectionKeyCode, onElementsRemove }: HookParams): void => { +export default ({ deleteKeyCode, multiSelectionKeyCode, onElementsRemove }: HookParams): void => { const selectedElements = useStoreState((state) => state.selectedElements); const edges = useStoreState((state) => state.edges); @@ -41,5 +41,5 @@ export default ({ deleteKeyCode, multiSelectionKeyCode, onElementsRemove }: Hoo useEffect(() => { setMultiSelectionActive(multiSelectionKeyPressed); - }, [multiSelectionKeyPressed]) + }, [multiSelectionKeyPressed]); }; From 0a77162ddb58750195dd83948d84b20954441288 Mon Sep 17 00:00:00 2001 From: moklick Date: Thu, 5 Nov 2020 15:36:35 +0100 Subject: [PATCH 7/7] refactor(nodes): handle selection when selectNodesOnDrag=false --- src/components/Nodes/wrapNode.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/Nodes/wrapNode.tsx b/src/components/Nodes/wrapNode.tsx index 11f9dda1..5e69704b 100644 --- a/src/components/Nodes/wrapNode.tsx +++ b/src/components/Nodes/wrapNode.tsx @@ -139,7 +139,7 @@ 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) { + if (isSelectable && !selectNodesOnDrag && !selected) { addSelectedElements({ id: node.id, type: node.type } as Node); } @@ -155,7 +155,7 @@ export default (NodeComponent: ComponentType) => { onNodeDragStop?.(event as MouseEvent, node); }, - [node, isSelectable, selectNodesOnDrag, onClick, onNodeDragStop, isDragging] + [node, isSelectable, selectNodesOnDrag, onClick, onNodeDragStop, isDragging, selected] ); useEffect(() => {