diff --git a/.changeset/twelve-bottles-turn.md b/.changeset/twelve-bottles-turn.md new file mode 100644 index 00000000..89b28a52 --- /dev/null +++ b/.changeset/twelve-bottles-turn.md @@ -0,0 +1,6 @@ +--- +'@xyflow/react': patch +'@xyflow/system': patch +--- + +Optimize selections and take into account if edges connected to selected nodes are actually selectable. diff --git a/packages/react/src/container/Pane/index.tsx b/packages/react/src/container/Pane/index.tsx index 48c23eba..3432247b 100644 --- a/packages/react/src/container/Pane/index.tsx +++ b/packages/react/src/container/Pane/index.tsx @@ -10,7 +10,14 @@ import { } from 'react'; import { shallow } from 'zustand/shallow'; import cc from 'classcat'; -import { getNodesInside, getEventPosition, SelectionMode, type NodeChange, type EdgeChange } from '@xyflow/system'; +import { + getNodesInside, + getEventPosition, + SelectionMode, + areSetsEqual, + type NodeChange, + type EdgeChange, +} from '@xyflow/system'; import { UserSelection } from '../../components/UserSelection'; import { containerStyle } from '../../styles/utils'; @@ -73,27 +80,20 @@ export function Pane({ onPaneMouseLeave, children, }: PaneProps) { - const container = useRef(null); const store = useStoreApi(); - const prevSelectedNodesCount = useRef(0); - const prevSelectedEdgesCount = useRef(0); - const containerBounds = useRef(); - const edgeIdLookup = useRef>>(new Map()); - const { userSelectionActive, elementsSelectable, dragging } = useStore(selector, shallow); const hasActiveSelection = elementsSelectable && (isSelecting || userSelectionActive); + const container = useRef(null); + const containerBounds = useRef(); + + const selectedNodeIds = useRef>(new Set()); + const selectedEdgeIds = useRef>(new Set()); + // Used to prevent click events when the user lets go of the selectionKey during a selection const selectionInProgress = useRef(false); const selectionStarted = useRef(false); - const resetUserSelection = () => { - store.setState({ userSelectionActive: false, userSelectionRect: null }); - - prevSelectedNodesCount.current = 0; - prevSelectedEdgesCount.current = 0; - }; - const onClick = (event: ReactMouseEvent) => { // We prevent click events when the user let go of the selectionKey during a selection if (selectionInProgress.current) { @@ -118,7 +118,7 @@ export function Pane({ const onWheel = onPaneScroll ? (event: React.WheelEvent) => onPaneScroll(event) : undefined; const onPointerDown = (event: ReactPointerEvent): void => { - const { resetSelectedElements, domNode, edgeLookup } = store.getState(); + const { resetSelectedElements, domNode } = store.getState(); containerBounds.current = domNode?.getBoundingClientRect(); if ( @@ -135,12 +135,6 @@ export function Pane({ selectionStarted.current = true; selectionInProgress.current = false; - edgeIdLookup.current = new Map(); - - for (const [id, edge] of edgeLookup) { - edgeIdLookup.current.set(edge.source, edgeIdLookup.current.get(edge.source)?.add(id) || new Set([id])); - edgeIdLookup.current.set(edge.target, edgeIdLookup.current.get(edge.target)?.add(id) || new Set([id])); - } const { x, y } = getEventPosition(event.nativeEvent, containerBounds.current); @@ -161,8 +155,16 @@ export function Pane({ }; const onPointerMove = (event: ReactPointerEvent): void => { - const { userSelectionRect, edgeLookup, transform, nodeLookup, triggerNodeChanges, triggerEdgeChanges } = - store.getState(); + const { + userSelectionRect, + transform, + nodeLookup, + edgeLookup, + connectionLookup, + triggerNodeChanges, + triggerEdgeChanges, + defaultEdgeOptions, + } = store.getState(); if (!containerBounds.current || !userSelectionRect) { return; @@ -182,38 +184,37 @@ export function Pane({ height: Math.abs(mouseY - startY), }; - const selectedNodes = getNodesInside( - nodeLookup, - nextUserSelectRect, - transform, - selectionMode === SelectionMode.Partial, - true + const prevSelectedNodeIds = selectedNodeIds.current; + const prevSelectedEdgeIds = selectedEdgeIds.current; + + selectedNodeIds.current = new Set( + getNodesInside(nodeLookup, nextUserSelectRect, transform, selectionMode === SelectionMode.Partial, true).map( + (node) => node.id + ) ); - const selectedEdgeIds = new Set(); - const selectedNodeIds = new Set(); + selectedEdgeIds.current = new Set(); + const edgesSelectable = defaultEdgeOptions?.selectable ?? true; - for (const selectedNode of selectedNodes) { - selectedNodeIds.add(selectedNode.id); - - const edgeIds = edgeIdLookup.current.get(selectedNode.id); - - if (edgeIds) { - for (const edgeId of edgeIds) { - selectedEdgeIds.add(edgeId); + // We look for all edges connected to the selected nodes + for (const nodeId of selectedNodeIds.current) { + const connections = connectionLookup.get(nodeId); + if (!connections) continue; + for (const { edgeId } of connections.values()) { + const edge = edgeLookup.get(edgeId); + if (edge && (edge.selectable ?? edgesSelectable)) { + selectedEdgeIds.current.add(edgeId); } } } - if (prevSelectedNodesCount.current !== selectedNodeIds.size) { - prevSelectedNodesCount.current = selectedNodeIds.size; - const changes = getSelectionChanges(nodeLookup, selectedNodeIds, true) as NodeChange[]; + if (!areSetsEqual(prevSelectedNodeIds, selectedNodeIds.current)) { + const changes = getSelectionChanges(nodeLookup, selectedNodeIds.current, true) as NodeChange[]; triggerNodeChanges(changes); } - if (prevSelectedEdgesCount.current !== selectedEdgeIds.size) { - prevSelectedEdgesCount.current = selectedEdgeIds.size; - const changes = getSelectionChanges(edgeLookup, selectedEdgeIds) as EdgeChange[]; + if (!areSetsEqual(prevSelectedEdgeIds, selectedEdgeIds.current)) { + const changes = getSelectionChanges(edgeLookup, selectedEdgeIds.current) as EdgeChange[]; triggerEdgeChanges(changes); } @@ -231,17 +232,18 @@ export function Pane({ (event.target as Element)?.releasePointerCapture?.(event.pointerId); const { userSelectionRect } = store.getState(); + // We only want to trigger click functions when in selection mode if // the user did not move the mouse. if (!userSelectionActive && userSelectionRect && event.target === container.current) { onClick?.(event); } - if (prevSelectedNodesCount.current > 0) { - store.setState({ nodesSelectionActive: true }); - } - - resetUserSelection(); + store.setState({ + userSelectionActive: false, + userSelectionRect: null, + nodesSelectionActive: selectedNodeIds.current.size > 0, + }); onSelectionEnd?.(event); // If the user kept holding the selectionKey during the selection, diff --git a/packages/system/src/utils/general.ts b/packages/system/src/utils/general.ts index 088209a3..7f631a59 100644 --- a/packages/system/src/utils/general.ts +++ b/packages/system/src/utils/general.ts @@ -263,3 +263,17 @@ export function evaluateAbsolutePosition( return positionAbsolute; } + +export function areSetsEqual(a: Set, b: Set) { + if (a.size !== b.size) { + return false; + } + + for (const item of a) { + if (!b.has(item)) { + return false; + } + } + + return true; +}