diff --git a/README.md b/README.md index 802887c5..ea3ff932 100644 --- a/README.md +++ b/README.md @@ -101,6 +101,7 @@ const BasicFlow = () => ; - `onSelectionDragStart(evt: MouseEvent, nodes: Node[])`: called when user starts to drag a selection - `onSelectionDrag(evt: MouseEvent, nodes: Node[])`: called when user drags a selection - `onSelectionDragStop(evt: MouseEvent, nodes: Node[])`: called when user stops to drag a selection +- `onSelectionContextMenu(event: MouseEvent, nodes: Node[])`: called when user does a right-click on a selection - `onPaneClick(event: MouseEvent)`: called when user clicks directly on the canvas - `onPaneContextMenu(event: MouseEvent)`: called when user does a right-click on the canvas - `onPaneScroll(event: WheelEvent)`: called when user scrolls pane (only works when `zoomOnScroll` is set to `false) @@ -581,6 +582,12 @@ Returns all direct child nodes of the passed node. `getOutgoers = (node: Node, elements: Elements): Node[]` +### getIncomers + +Returns all direct incoming nodes of the passed node. + +`getOutgoers = (node: Node, elements: Elements): Node[]` + ### getConnectedEdges Returns all edges that are connected to the passed nodes. diff --git a/cypress/integration/flow/graph-utils.spec.js b/cypress/integration/flow/graph-utils.spec.js index 128f3e6d..00e355a7 100644 --- a/cypress/integration/flow/graph-utils.spec.js +++ b/cypress/integration/flow/graph-utils.spec.js @@ -1,4 +1,4 @@ -import { isNode, isEdge, getOutgoers, removeElements, addEdge } from '../../../src/utils/graph.ts'; +import { isNode, isEdge, getOutgoers, getIncomers, removeElements, addEdge } from '../../../src/utils/graph.ts'; const nodes = [ { id: '1', type: 'input', data: { label: 'Node 1' }, position: { x: 250, y: 5 } }, @@ -10,6 +10,7 @@ const nodes = [ const edges = [ { id: 'e1-2', source: '1', target: '2', animated: true }, { id: 'e1-3', source: '1', target: '3' }, + { id: 'e2-3', source: '2', target: '3' }, ]; const elements = [...nodes, ...edges]; @@ -29,10 +30,18 @@ describe('Graph Utils Testing', () => { const outgoers = getOutgoers(nodes[0], elements); expect(outgoers.length).to.be.equal(2); - const noOutgoers = getOutgoers(nodes[1], elements); + const noOutgoers = getOutgoers(nodes[2], elements); expect(noOutgoers.length).to.be.equal(0); }); + it('tests getIncomers function', () => { + const incomers = getIncomers(nodes[2], elements); + expect(incomers.length).to.be.equal(2); + + const noIncomers = getIncomers(nodes[0], elements); + expect(noIncomers.length).to.be.equal(0); + }); + describe('tests addEdge function', () => { it('adds edge', () => { const newEdge = { source: '2', target: '3' }; diff --git a/example/src/Overview/index.js b/example/src/Overview/index.js index 045d5684..20246191 100644 --- a/example/src/Overview/index.js +++ b/example/src/Overview/index.js @@ -7,6 +7,10 @@ const onNodeDragStop = (event, node) => console.log('drag stop', node); const onSelectionDrag = (event, nodes) => console.log('selection drag', nodes); const onSelectionDragStart = (event, nodes) => console.log('selection drag start', nodes); const onSelectionDragStop = (event, nodes) => console.log('selection drag stop', nodes); +const onSelectionContextMenu = (event, nodes) => { + event.preventDefault(); + console.log('selection context menu', nodes); +}; const onElementClick = (event, element) => console.log(`${isNode(element) ? 'node' : 'edge'} click:`, element); const onSelectionChange = (elements) => console.log('selection change', elements); const onLoad = (reactFlowInstance) => { @@ -126,6 +130,7 @@ const OverviewFlow = () => { onSelectionDragStart={onSelectionDragStart} onSelectionDrag={onSelectionDrag} onSelectionDragStop={onSelectionDragStop} + onSelectionContextMenu={onSelectionContextMenu} onSelectionChange={onSelectionChange} onMoveEnd={onMoveEnd} onLoad={onLoad} diff --git a/example/src/Provider/provider.css b/example/src/Provider/provider.css index 8bb8ebf2..aa719acc 100644 --- a/example/src/Provider/provider.css +++ b/example/src/Provider/provider.css @@ -1,14 +1,12 @@ .providerflow { + flex-direction: column; display: flex; height: 100%; } .providerflow aside { - width: 20%; - max-width: 250px; - flex-grow: 1; border-right: 1px solid #eee; - padding: 10px; + padding: 15px 10px; font-size: 12px; background: #fcfcfc; } @@ -33,4 +31,15 @@ .providerflow .selectall { margin-top: 10px; +} + +@media screen and (min-width: 768px) { + .providerflow { + flex-direction: row; + } + + .providerflow aside { + width: 20%; + max-width: 250px; + } } \ No newline at end of file diff --git a/src/components/Nodes/wrapNode.tsx b/src/components/Nodes/wrapNode.tsx index 54be5214..4634eb90 100644 --- a/src/components/Nodes/wrapNode.tsx +++ b/src/components/Nodes/wrapNode.tsx @@ -51,6 +51,7 @@ export default (NodeComponent: ComponentType) => { const updateNodeDimensions = useStoreActions((actions) => actions.updateNodeDimensions); const setSelectedElements = useStoreActions((actions) => actions.setSelectedElements); const updateNodePos = useStoreActions((actions) => actions.updateNodePos); + const unsetNodesSelection = useStoreActions((actions) => actions.unsetNodesSelection); const nodeElement = useRef(null); const [offset, setOffset] = useState({ x: 0, y: 0 }); @@ -104,6 +105,7 @@ export default (NodeComponent: ComponentType) => { (event: MouseEvent) => { if (!isDraggable) { if (isSelectable) { + unsetNodesSelection(); setSelectedElements({ id: node.id, type: node.type } as Node); } @@ -132,6 +134,7 @@ export default (NodeComponent: ComponentType) => { } if (selectNodesOnDrag && isSelectable) { + unsetNodesSelection(); setSelectedElements({ id: node.id, type: node.type } as Node); } }, diff --git a/src/components/NodesSelection/index.tsx b/src/components/NodesSelection/index.tsx index 1aaa2053..c46e7b9e 100644 --- a/src/components/NodesSelection/index.tsx +++ b/src/components/NodesSelection/index.tsx @@ -3,141 +3,123 @@ * made a selectio with on or several nodes */ -import React, { useState, MouseEvent } from 'react'; -import ReactDraggable from 'react-draggable'; +import React, { useMemo, useCallback, MouseEvent } from 'react'; +import ReactDraggable, { DraggableData } from 'react-draggable'; import { useStoreState, useStoreActions } from '../../store/hooks'; import { isNode } from '../../utils/graph'; -import { Node, XYPosition } from '../../types'; - -type StartPositions = { [key: string]: XYPosition }; - -function getStartPositions(nodes: Node[]): StartPositions { - const startPositions: StartPositions = {}; - - return nodes.reduce((res, node) => { - const startPosition = { - x: node.__rf.position.x || node.position.x, - y: node.__rf.position.y || node.position.y, - }; - - res[node.id] = startPosition; - - return res; - }, startPositions); -} +import { Node } from '../../types'; export interface NodesSelectionProps { onSelectionDragStart?: (event: MouseEvent, nodes: Node[]) => void; onSelectionDrag?: (event: MouseEvent, nodes: Node[]) => void; onSelectionDragStop?: (event: MouseEvent, nodes: Node[]) => void; + onSelectionContextMenu?: (event: MouseEvent, nodes: Node[]) => void; } -export default ({ onSelectionDragStart, onSelectionDrag, onSelectionDragStop }: NodesSelectionProps) => { - const [offset, setOffset] = useState({ x: 0, y: 0 }); - const [startPositions, setStartPositions] = useState({}); - +export default ({ + onSelectionDragStart, + onSelectionDrag, + onSelectionDragStop, + onSelectionContextMenu, +}: NodesSelectionProps) => { const [tX, tY, tScale] = useStoreState((state) => state.transform); const selectedNodesBbox = useStoreState((state) => state.selectedNodesBbox); + const selectionActive = useStoreState((state) => state.selectionActive); const selectedElements = useStoreState((state) => state.selectedElements); const snapToGrid = useStoreState((state) => state.snapToGrid); const snapGrid = useStoreState((state) => state.snapGrid); const nodes = useStoreState((state) => state.nodes); - const updateNodePos = useStoreActions((actions) => actions.updateNodePos); + const updateNodePosDiff = useStoreActions((actions) => actions.updateNodePosDiff); const grid = (snapToGrid ? snapGrid : [1, 1])! as [number, number]; - if (!selectedElements) { - return null; - } + const selectedNodes = useMemo( + () => + selectedElements + ? selectedElements + .filter(isNode) + .map((selectedNode) => nodes.find((node) => node.id === selectedNode.id)! as Node) + : [], + [selectedElements] + ); - const onStart = (event: MouseEvent) => { - const scaledClient: XYPosition = { - x: event.clientX / tScale, - y: event.clientY / tScale, - }; - const offsetX: number = scaledClient.x - selectedNodesBbox.x - tX; - const offsetY: number = scaledClient.y - selectedNodesBbox.y - tY; - const selectedNodes = selectedElements - ? selectedElements - .filter(isNode) - .map((selectedNode) => nodes.find((node) => node.id === selectedNode.id)! as Node) - : []; - - const nextStartPositions = getStartPositions(selectedNodes); - - if (onSelectionDragStart) { - onSelectionDragStart(event, selectedNodes); - } - - if (nextStartPositions) { - setOffset({ x: offsetX, y: offsetY }); - setStartPositions(nextStartPositions); - } - }; - - const onDrag = (event: MouseEvent) => { - const scaledClient: XYPosition = { - x: event.clientX / tScale, - y: event.clientY / tScale, - }; - - if (selectedElements) { - const selectedNodes = selectedElements ? selectedElements.filter(isNode) : []; + const onStart = useCallback( + (event: MouseEvent) => { + onSelectionDragStart?.(event, selectedNodes); + }, + [onSelectionDragStart, selectedNodes] + ); + const onDrag = useCallback( + (event: MouseEvent, data: DraggableData) => { if (onSelectionDrag) { - const selectionNodes = selectedNodes.map( - (selectedNode) => nodes.find((node) => node.id === selectedNode.id)! as Node - ); - - onSelectionDrag(event, selectionNodes); + onSelectionDrag(event, selectedNodes); } - selectedNodes.forEach((node) => { - const pos: XYPosition = { - x: startPositions[node.id].x + scaledClient.x - selectedNodesBbox.x - offset.x - tX, - y: startPositions[node.id].y + scaledClient.y - selectedNodesBbox.y - offset.y - tY, - }; - - updateNodePos({ id: node.id, pos }); + selectedNodes?.forEach((node) => { + updateNodePosDiff({ + id: node.id, + diff: { + x: data.deltaX, + y: data.deltaY, + }, + }); }); - } - }; + }, + [onSelectionDrag, selectedNodes, updateNodePosDiff] + ); - const onStop = (event: MouseEvent) => { - if (selectedElements && onSelectionDragStop) { + const onStop = useCallback( + (event: MouseEvent) => { + onSelectionDragStop?.(event, selectedNodes); + }, + [selectedNodes, onSelectionDragStop] + ); + + const onContextMenu = useCallback( + (event: MouseEvent) => { const selectedNodes = selectedElements ? selectedElements.filter(isNode).map((selectedNode) => nodes.find((node) => node.id === selectedNode.id)!) : []; - onSelectionDragStop(event, selectedNodes); - } - }; + onSelectionContextMenu?.(event, selectedNodes); + }, + [onSelectionContextMenu] + ); + + const style = useMemo( + () => ({ + transform: `translate(${tX}px,${tY}px) scale(${tScale})`, + }), + [tX, tY, tScale] + ); + + const innerStyle = useMemo( + () => ({ + width: selectedNodesBbox.width, + height: selectedNodesBbox.height, + top: selectedNodesBbox.y, + left: selectedNodesBbox.x, + }), + [selectedNodesBbox] + ); + + if (!selectedElements || selectionActive) { + return null; + } return ( -
+
onStart(event as MouseEvent)} - onDrag={(event) => onDrag(event as MouseEvent)} + onDrag={(event, data) => onDrag(event as MouseEvent, data)} onStop={(event) => onStop(event as MouseEvent)} > -
+
); diff --git a/src/container/GraphView/index.tsx b/src/container/GraphView/index.tsx index 88e2013c..1d806186 100644 --- a/src/container/GraphView/index.tsx +++ b/src/container/GraphView/index.tsx @@ -51,6 +51,7 @@ export interface GraphViewProps { onSelectionDragStart?: (event: MouseEvent, nodes: Node[]) => void; onSelectionDrag?: (event: MouseEvent, nodes: Node[]) => void; onSelectionDragStop?: (event: MouseEvent, nodes: Node[]) => void; + onSelectionContextMenu?: (event: MouseEvent, nodes: Node[]) => void; selectionKeyCode: number; nodeTypes: NodeTypesType; edgeTypes: EdgeTypesType; @@ -93,6 +94,7 @@ const GraphView = ({ onSelectionDragStart, onSelectionDrag, onSelectionDragStop, + onSelectionContextMenu, connectionLineType, connectionLineStyle, selectionKeyCode, @@ -292,6 +294,7 @@ const GraphView = ({ onSelectionDragStart={onSelectionDragStart} onSelectionDrag={onSelectionDrag} onSelectionDragStop={onSelectionDragStop} + onSelectionContextMenu={onSelectionContextMenu} /> )}
, 'on onSelectionDragStart?: (event: MouseEvent, nodes: Node[]) => void; onSelectionDrag?: (event: MouseEvent, nodes: Node[]) => void; onSelectionDragStop?: (event: MouseEvent, nodes: Node[]) => void; + onSelectionContextMenu?: (event: MouseEvent, nodes: Node[]) => void; onPaneScroll?: (event?: WheelEvent) => void; onPaneClick?: (event: MouseEvent) => void; onPaneContextMenu?: (event: MouseEvent) => void; @@ -112,6 +113,7 @@ const ReactFlow = ({ onSelectionDragStart, onSelectionDrag, onSelectionDragStop, + onSelectionContextMenu, connectionLineType, connectionLineStyle, deleteKeyCode, @@ -191,6 +193,7 @@ const ReactFlow = ({ onSelectionDragStart={onSelectionDragStart} onSelectionDrag={onSelectionDrag} onSelectionDragStop={onSelectionDragStop} + onSelectionContextMenu={onSelectionContextMenu} /> {onSelectionChange && } {children} diff --git a/src/hooks/useElementUpdater.ts b/src/hooks/useElementUpdater.ts index f0109bf5..f3f7fdc1 100644 --- a/src/hooks/useElementUpdater.ts +++ b/src/hooks/useElementUpdater.ts @@ -11,7 +11,7 @@ const useElementUpdater = (propElements: Elements): void => { useEffect(() => { const nextElements: Elements = propElements.map((propElement) => { - const existingElement = stateElements.find((el) => el.id === propElement.id); + const existingElement = stateElements.find((el) => el.id === propElement.id?.toString()); if (existingElement) { const data = !isEqual(existingElement.data, propElement.data) @@ -41,7 +41,7 @@ const useElementUpdater = (propElements: Elements): void => { if (typeof propElement.isHidden !== 'undefined') { elementProps.isHidden = propElement.isHidden; } - + if (typeof propElement.type !== 'undefined') { elementProps.type = propElement.type; } diff --git a/src/store/index.ts b/src/store/index.ts index 9827092c..ec39cac5 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -25,6 +25,7 @@ import { HandleType, SetConnectionId, NodePosUpdate, + NodeDiffUpdate, FitViewParams, TranslateExtent, } from '../types'; @@ -101,6 +102,7 @@ export interface StoreModel { updateNodeDimensions: Action; updateNodePos: Action; + updateNodePosDiff: Action; setSelection: Action; @@ -255,6 +257,20 @@ export const storeModel: StoreModel = { }); }), + updateNodePosDiff: action((state, { id, diff }) => { + state.elements.forEach((n) => { + if (n.id === id && isNode(n)) { + n.__rf = { + ...n.__rf, + position: { + x: n.__rf.position.x + diff.x, + y: n.__rf.position.y + diff.y, + }, + }; + } + }); + }), + setUserSelection: action((state, mousePos) => { state.userSelectionRect = { width: 0, @@ -296,9 +312,9 @@ export const storeModel: StoreModel = { }), unsetUserSelection: action((state) => { - const selectedNodes = getNodesInside(state.nodes, state.userSelectionRect, state.transform); + const selectedNodes = state.selectedElements?.filter(isNode); - if (!selectedNodes.length) { + if (!selectedNodes) { state.selectionActive = false; state.userSelectionRect = { ...state.userSelectionRect, draw: false }; state.nodesSelectionActive = false; diff --git a/src/types/index.ts b/src/types/index.ts index 7d02712a..a9471d46 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -267,6 +267,11 @@ export type NodePosUpdate = { pos: XYPosition; }; +export type NodeDiffUpdate = { + id: ElementId; + diff: XYPosition; +}; + export type FlowTransform = { x: number; y: number; diff --git a/src/utils/graph.ts b/src/utils/graph.ts index 1d104749..6f341ac9 100644 --- a/src/utils/graph.ts +++ b/src/utils/graph.ts @@ -1,6 +1,7 @@ +import { Store } from 'easy-peasy'; + import store, { StoreModel } from '../store'; import { ElementId, Node, Edge, Elements, Transform, XYPosition, Rect, FitViewParams, Box, Connection } from '../types'; -import { Store } from 'easy-peasy'; export const isEdge = (element: Node | Connection | Edge): element is Edge => 'id' in element && 'source' in element && 'target' in element; @@ -17,6 +18,15 @@ export const getOutgoers = (node: Node, elements: Elements): Node[] => { return elements.filter((e) => outgoerIds.includes(e.id)) as Node[]; }; +export const getIncomers = (node: Node, elements: Elements): Node[] => { + if (!isNode(node)) { + return []; + } + + const incomersIds = elements.filter((e) => isEdge(e) && e.target === node.id).map((e) => (e as Edge).source); + return elements.filter((e) => incomersIds.includes(e.id)) as Node[]; +}; + export const removeElements = (elementsToRemove: Elements, elements: Elements): Elements => { const nodeIdsToRemove = elementsToRemove.map((n) => n.id);