Merge pull request #663 from wbkd/feat/multiselect

Feat/multiselect
This commit is contained in:
Moritz Klack
2020-11-05 15:38:51 +01:00
committed by GitHub
11 changed files with 92 additions and 29 deletions
+13
View File
@@ -15,6 +15,19 @@ describe('Basic Flow Rendering', () => {
cy.get('.react-flow__background'); 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', () => { it('selects a node by click', () => {
cy.get('.react-flow__node:first').click().should('have.class', 'selected'); cy.get('.react-flow__node:first').click().should('have.class', 'selected');
}); });
@@ -135,6 +135,7 @@ describe('Interaction Flow Rendering', () => {
}); });
it('zooms by double click', () => { it('zooms by double click', () => {
cy.get('.react-flow__controls-zoomout').click();
const styleBeforeZoom = Cypress.$('.react-flow__nodes').css('transform'); const styleBeforeZoom = Cypress.$('.react-flow__nodes').css('transform');
cy.get('.react-flow__renderer') cy.get('.react-flow__renderer')
+2 -2
View File
@@ -33,7 +33,7 @@ export default (EdgeComponent: ComponentType<EdgeProps>) => {
markerEndId, markerEndId,
isHidden, isHidden,
}: WrapEdgeProps) => { }: WrapEdgeProps) => {
const setSelectedElements = useStoreActions((actions) => actions.setSelectedElements); const addSelectedElements = useStoreActions((actions) => actions.addSelectedElements);
const inactive = !elementsSelectable && !onClick; const inactive = !elementsSelectable && !onClick;
const edgeClasses = cc([ const edgeClasses = cc([
@@ -46,7 +46,7 @@ export default (EdgeComponent: ComponentType<EdgeProps>) => {
const onEdgeClick = useCallback( const onEdgeClick = useCallback(
(event: React.MouseEvent<SVGGElement, MouseEvent>): void => { (event: React.MouseEvent<SVGGElement, MouseEvent>): void => {
if (elementsSelectable) { if (elementsSelectable) {
setSelectedElements({ id, source, target }); addSelectedElements({ id, source, target });
} }
if (onClick) { if (onClick) {
+15 -9
View File
@@ -38,7 +38,7 @@ export default (NodeComponent: ComponentType<NodeComponentProps>) => {
isDragging, isDragging,
}: WrapNodeProps) => { }: WrapNodeProps) => {
const updateNodeDimensions = useStoreActions((actions) => actions.updateNodeDimensions); 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 updateNodePosDiff = useStoreActions((actions) => actions.updateNodePosDiff);
const unsetNodesSelection = useStoreActions((actions) => actions.unsetNodesSelection); const unsetNodesSelection = useStoreActions((actions) => actions.unsetNodesSelection);
@@ -94,13 +94,16 @@ export default (NodeComponent: ComponentType<NodeComponentProps>) => {
if (!isDraggable) { if (!isDraggable) {
if (isSelectable) { if (isSelectable) {
unsetNodesSelection(); unsetNodesSelection();
setSelectedElements({ id: node.id, type: node.type } as Node);
if (!selected) {
addSelectedElements({ id: node.id, type: node.type } as Node);
}
} }
onClick?.(event, node); onClick?.(event, node);
} }
}, },
[isSelectable, isDraggable, onClick, node] [isSelectable, selected, isDraggable, onClick, node]
); );
const onDragStart = useCallback( const onDragStart = useCallback(
@@ -109,10 +112,13 @@ export default (NodeComponent: ComponentType<NodeComponentProps>) => {
if (selectNodesOnDrag && isSelectable) { if (selectNodesOnDrag && isSelectable) {
unsetNodesSelection(); 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( const onDrag = useCallback(
@@ -133,8 +139,8 @@ export default (NodeComponent: ComponentType<NodeComponentProps>) => {
// onDragStop also gets called when user just clicks on a node. // 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 // Because of that we set dragging to true inside the onDrag handler and handle the click here
if (!isDragging) { if (!isDragging) {
if (isSelectable && !selectNodesOnDrag) { if (isSelectable && !selectNodesOnDrag && !selected) {
setSelectedElements({ id: node.id, type: node.type } as Node); addSelectedElements({ id: node.id, type: node.type } as Node);
} }
onClick?.(event as MouseEvent, node); onClick?.(event as MouseEvent, node);
@@ -143,13 +149,13 @@ export default (NodeComponent: ComponentType<NodeComponentProps>) => {
} }
updateNodePosDiff({ updateNodePosDiff({
id, id: node.id,
isDragging: false, isDragging: false,
}); });
onNodeDragStop?.(event as MouseEvent, node); onNodeDragStop?.(event as MouseEvent, node);
}, },
[node, isSelectable, selectNodesOnDrag, onClick, onNodeDragStop, isDragging] [node, isSelectable, selectNodesOnDrag, onClick, onNodeDragStop, isDragging, selected]
); );
useEffect(() => { useEffect(() => {
+7 -13
View File
@@ -77,14 +77,11 @@ export default ({
onSelectionDrag(event, selectedNodes); onSelectionDrag(event, selectedNodes);
} }
selectedNodes?.forEach((node) => { updateNodePosDiff({
updateNodePosDiff({ diff: {
id: node.id, x: data.deltaX,
diff: { y: data.deltaY,
x: data.deltaX, },
y: data.deltaY,
},
});
}); });
}, },
[onSelectionDrag, selectedNodes, updateNodePosDiff] [onSelectionDrag, selectedNodes, updateNodePosDiff]
@@ -92,11 +89,8 @@ export default ({
const onStop = useCallback( const onStop = useCallback(
(event: MouseEvent) => { (event: MouseEvent) => {
selectedNodes?.forEach((node) => { updateNodePosDiff({
updateNodePosDiff({ isDragging: false,
id: node.id,
isDragging: false,
});
}); });
onSelectionDragStop?.(event, selectedNodes); onSelectionDragStop?.(event, selectedNodes);
+5 -1
View File
@@ -35,6 +35,7 @@ const FlowRenderer = ({
onMoveStart, onMoveStart,
onMoveEnd, onMoveEnd,
selectionKeyCode, selectionKeyCode,
multiSelectionKeyCode,
elementsSelectable, elementsSelectable,
zoomOnScroll, zoomOnScroll,
panOnScroll, panOnScroll,
@@ -50,15 +51,18 @@ const FlowRenderer = ({
onSelectionContextMenu, onSelectionContextMenu,
}: FlowRendererProps) => { }: FlowRendererProps) => {
const unsetNodesSelection = useStoreActions((actions) => actions.unsetNodesSelection); const unsetNodesSelection = useStoreActions((actions) => actions.unsetNodesSelection);
const resetSelectedElements = useStoreActions((actions) => actions.resetSelectedElements);
const nodesSelectionActive = useStoreState((state) => state.nodesSelectionActive); const nodesSelectionActive = useStoreState((state) => state.nodesSelectionActive);
const selectionKeyPressed = useKeyPress(selectionKeyCode); const selectionKeyPressed = useKeyPress(selectionKeyCode);
useGlobalKeyHandler({ onElementsRemove, deleteKeyCode }); useGlobalKeyHandler({ onElementsRemove, deleteKeyCode, multiSelectionKeyCode });
const onClick = useCallback( const onClick = useCallback(
(event: MouseEvent) => { (event: MouseEvent) => {
onPaneClick?.(event); onPaneClick?.(event);
unsetNodesSelection(); unsetNodesSelection();
resetSelectedElements();
}, },
[onPaneClick] [onPaneClick]
); );
+3
View File
@@ -55,6 +55,7 @@ export interface GraphViewProps {
connectionLineStyle?: CSSProperties; connectionLineStyle?: CSSProperties;
connectionLineComponent?: ConnectionLineComponent; connectionLineComponent?: ConnectionLineComponent;
deleteKeyCode: number; deleteKeyCode: number;
multiSelectionKeyCode: number;
snapToGrid: boolean; snapToGrid: boolean;
snapGrid: [number, number]; snapGrid: [number, number];
onlyRenderVisibleNodes: boolean; onlyRenderVisibleNodes: boolean;
@@ -98,6 +99,7 @@ const GraphView = ({
connectionLineStyle, connectionLineStyle,
connectionLineComponent, connectionLineComponent,
selectionKeyCode, selectionKeyCode,
multiSelectionKeyCode,
onElementsRemove, onElementsRemove,
deleteKeyCode, deleteKeyCode,
elements, elements,
@@ -249,6 +251,7 @@ const GraphView = ({
onElementsRemove={onElementsRemove} onElementsRemove={onElementsRemove}
deleteKeyCode={deleteKeyCode} deleteKeyCode={deleteKeyCode}
selectionKeyCode={selectionKeyCode} selectionKeyCode={selectionKeyCode}
multiSelectionKeyCode={multiSelectionKeyCode}
elementsSelectable={elementsSelectable} elementsSelectable={elementsSelectable}
onMove={onMove} onMove={onMove}
onMoveStart={onMoveStart} onMoveStart={onMoveStart}
+3
View File
@@ -82,6 +82,7 @@ export interface ReactFlowProps extends Omit<HTMLAttributes<HTMLDivElement>, 'on
connectionLineComponent?: ConnectionLineComponent; connectionLineComponent?: ConnectionLineComponent;
deleteKeyCode?: number; deleteKeyCode?: number;
selectionKeyCode?: number; selectionKeyCode?: number;
multiSelectionKeyCode?: number;
snapToGrid?: boolean; snapToGrid?: boolean;
snapGrid?: [number, number]; snapGrid?: [number, number];
onlyRenderVisibleNodes?: boolean; onlyRenderVisibleNodes?: boolean;
@@ -134,6 +135,7 @@ const ReactFlow = ({
connectionLineComponent, connectionLineComponent,
deleteKeyCode = 8, deleteKeyCode = 8,
selectionKeyCode = 16, selectionKeyCode = 16,
multiSelectionKeyCode = 91,
snapToGrid = false, snapToGrid = false,
snapGrid = [15, 15], snapGrid = [15, 15],
onlyRenderVisibleNodes = true, onlyRenderVisibleNodes = true,
@@ -186,6 +188,7 @@ const ReactFlow = ({
selectionKeyCode={selectionKeyCode} selectionKeyCode={selectionKeyCode}
onElementsRemove={onElementsRemove} onElementsRemove={onElementsRemove}
deleteKeyCode={deleteKeyCode} deleteKeyCode={deleteKeyCode}
multiSelectionKeyCode={multiSelectionKeyCode}
elements={elements} elements={elements}
onConnect={onConnect} onConnect={onConnect}
onConnectStart={onConnectStart} onConnectStart={onConnectStart}
+11 -1
View File
@@ -7,15 +7,20 @@ import { Elements } from '../types';
interface HookParams { interface HookParams {
deleteKeyCode: number; deleteKeyCode: number;
multiSelectionKeyCode: number;
onElementsRemove?: (elements: Elements) => void; onElementsRemove?: (elements: Elements) => void;
} }
export default ({ deleteKeyCode, onElementsRemove }: HookParams): void => { export default ({ deleteKeyCode, multiSelectionKeyCode, onElementsRemove }: HookParams): void => {
const selectedElements = useStoreState((state) => state.selectedElements); const selectedElements = useStoreState((state) => state.selectedElements);
const edges = useStoreState((state) => state.edges); const edges = useStoreState((state) => state.edges);
const unsetNodesSelection = useStoreActions((actions) => actions.unsetNodesSelection); const unsetNodesSelection = useStoreActions((actions) => actions.unsetNodesSelection);
const setMultiSelectionActive = useStoreActions((actions) => actions.setMultiSelectionActive);
const resetSelectedElements = useStoreActions((actions) => actions.resetSelectedElements);
const deleteKeyPressed = useKeyPress(deleteKeyCode); const deleteKeyPressed = useKeyPress(deleteKeyCode);
const multiSelectionKeyPressed = useKeyPress(multiSelectionKeyCode);
useEffect(() => { useEffect(() => {
if (onElementsRemove && deleteKeyPressed && selectedElements) { if (onElementsRemove && deleteKeyPressed && selectedElements) {
@@ -30,6 +35,11 @@ export default ({ deleteKeyCode, onElementsRemove }: HookParams): void => {
onElementsRemove(elementsToRemove); onElementsRemove(elementsToRemove);
unsetNodesSelection(); unsetNodesSelection();
resetSelectedElements();
} }
}, [deleteKeyPressed]); }, [deleteKeyPressed]);
useEffect(() => {
setMultiSelectionActive(multiSelectionKeyPressed);
}, [multiSelectionKeyPressed]);
}; };
+31 -2
View File
@@ -84,6 +84,8 @@ export interface StoreModel {
nodesConnectable: boolean; nodesConnectable: boolean;
elementsSelectable: boolean; elementsSelectable: boolean;
multiSelectionActive: boolean;
reactFlowVersion: string; reactFlowVersion: string;
onConnect?: OnConnectFunc; onConnect?: OnConnectFunc;
@@ -106,8 +108,10 @@ export interface StoreModel {
setSelection: Action<StoreModel, boolean>; setSelection: Action<StoreModel, boolean>;
unsetNodesSelection: Action<StoreModel>; unsetNodesSelection: Action<StoreModel>;
resetSelectedElements: Action<StoreModel>;
setSelectedElements: Action<StoreModel, Elements | Node | Edge>; setSelectedElements: Action<StoreModel, Elements | Node | Edge>;
addSelectedElements: Thunk<StoreModel, Elements | Node | Edge>;
updateTransform: Action<StoreModel, TransformXYK>; updateTransform: Action<StoreModel, TransformXYK>;
@@ -143,6 +147,8 @@ export interface StoreModel {
zoom: Thunk<StoreModel, number, any, StoreModel>; zoom: Thunk<StoreModel, number, any, StoreModel>;
zoomIn: Thunk<StoreModel>; zoomIn: Thunk<StoreModel>;
zoomOut: Thunk<StoreModel>; zoomOut: Thunk<StoreModel>;
setMultiSelectionActive: Action<StoreModel, boolean>;
} }
export const storeModel: StoreModel = { export const storeModel: StoreModel = {
@@ -191,6 +197,8 @@ export const storeModel: StoreModel = {
nodesConnectable: true, nodesConnectable: true,
elementsSelectable: true, elementsSelectable: true,
multiSelectionActive: false,
reactFlowVersion: typeof __REACT_FLOW_VERSION__ !== 'undefined' ? __REACT_FLOW_VERSION__ : '-', reactFlowVersion: typeof __REACT_FLOW_VERSION__ !== 'undefined' ? __REACT_FLOW_VERSION__ : '-',
setOnConnect: action((state, onConnect) => { 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) => { 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) { if (diff) {
n.__rf.position = { n.__rf.position = {
x: n.__rf.position.x + diff.x, x: n.__rf.position.x + diff.x,
@@ -336,6 +344,9 @@ export const storeModel: StoreModel = {
unsetNodesSelection: action((state) => { unsetNodesSelection: action((state) => {
state.nodesSelectionActive = false; state.nodesSelectionActive = false;
}),
resetSelectedElements: action((state) => {
state.selectedElements = null; state.selectedElements = null;
}), }),
@@ -347,6 +358,20 @@ export const storeModel: StoreModel = {
state.selectedElements = selectedElements; 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) => { updateTransform: action((state, transform) => {
state.transform[0] = transform.x; state.transform[0] = transform.x;
state.transform[1] = transform.y; state.transform[1] = transform.y;
@@ -517,6 +542,10 @@ export const storeModel: StoreModel = {
zoomOut: thunk((actions) => { zoomOut: thunk((actions) => {
actions.zoom(-0.2); actions.zoom(-0.2);
}), }),
setMultiSelectionActive: action((state, isActive) => {
state.multiSelectionActive = isActive;
}),
}; };
const nodeEnv: string = (typeof __ENV__ !== 'undefined' && __ENV__) as string; const nodeEnv: string = (typeof __ENV__ !== 'undefined' && __ENV__) as string;
+1 -1
View File
@@ -308,7 +308,7 @@ export type NodePosUpdate = {
}; };
export type NodeDiffUpdate = { export type NodeDiffUpdate = {
id: ElementId; id?: ElementId;
diff?: XYPosition; diff?: XYPosition;
isDragging?: boolean; isDragging?: boolean;
}; };