From a893cddf8f7397c71980808bdd6d3dd4c38edf72 Mon Sep 17 00:00:00 2001 From: peterkogo Date: Wed, 8 Jan 2025 16:05:28 +0100 Subject: [PATCH 1/5] Port enhanced selection logic from svelte to react --- .changeset/perfect-fans-grow.md | 5 + packages/react/src/container/Pane/index.tsx | 105 +++++++++++--------- 2 files changed, 62 insertions(+), 48 deletions(-) create mode 100644 .changeset/perfect-fans-grow.md diff --git a/.changeset/perfect-fans-grow.md b/.changeset/perfect-fans-grow.md new file mode 100644 index 00000000..2d4c738e --- /dev/null +++ b/.changeset/perfect-fans-grow.md @@ -0,0 +1,5 @@ +--- +'@xyflow/react': 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..99d81da3 100644 --- a/packages/react/src/container/Pane/index.tsx +++ b/packages/react/src/container/Pane/index.tsx @@ -39,6 +39,20 @@ type PaneProps = { > >; +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; +} + const wrapHandler = ( handler: React.MouseEventHandler | undefined, containerRef: React.MutableRefObject @@ -73,27 +87,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) { @@ -135,12 +142,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 +162,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 +191,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(); + let edgesSelectable = defaultEdgeOptions?.selectable ?? true; + selectedEdgeIds.current = new Set(); - 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 (let nodeId of selectedNodeIds.current) { + let connections = connectionLookup.get(nodeId); + if (!connections) continue; + for (let { edgeId } of connections.values()) { + let 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 +239,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, From 4947f683b7530f8e6684865ab53ea38633de0f4d Mon Sep 17 00:00:00 2001 From: peterkogo Date: Wed, 8 Jan 2025 16:09:20 +0100 Subject: [PATCH 2/5] Move areSetsEqual to system --- ...ct-fans-grow.md => twelve-bottles-turn.md} | 1 + packages/react/src/container/Pane/index.tsx | 23 +++++++------------ packages/system/src/utils/general.ts | 14 +++++++++++ 3 files changed, 23 insertions(+), 15 deletions(-) rename .changeset/{perfect-fans-grow.md => twelve-bottles-turn.md} (85%) diff --git a/.changeset/perfect-fans-grow.md b/.changeset/twelve-bottles-turn.md similarity index 85% rename from .changeset/perfect-fans-grow.md rename to .changeset/twelve-bottles-turn.md index 2d4c738e..89b28a52 100644 --- a/.changeset/perfect-fans-grow.md +++ b/.changeset/twelve-bottles-turn.md @@ -1,5 +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 99d81da3..3b7c94bb 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'; @@ -39,20 +46,6 @@ type PaneProps = { > >; -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; -} - const wrapHandler = ( handler: React.MouseEventHandler | undefined, containerRef: React.MutableRefObject 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; +} From 228ea770a327fb613d96f74283492879c1710a82 Mon Sep 17 00:00:00 2001 From: peterkogo Date: Wed, 8 Jan 2025 17:01:57 +0100 Subject: [PATCH 3/5] let let be const --- packages/react/src/container/Pane/index.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/react/src/container/Pane/index.tsx b/packages/react/src/container/Pane/index.tsx index 3b7c94bb..ac66b665 100644 --- a/packages/react/src/container/Pane/index.tsx +++ b/packages/react/src/container/Pane/index.tsx @@ -193,15 +193,15 @@ export function Pane({ ) ); - let edgesSelectable = defaultEdgeOptions?.selectable ?? true; selectedEdgeIds.current = new Set(); + const edgesSelectable = defaultEdgeOptions?.selectable ?? true; // We look for all edges connected to the selected nodes - for (let nodeId of selectedNodeIds.current) { - let connections = connectionLookup.get(nodeId); + for (const nodeId of selectedNodeIds.current) { + const connections = connectionLookup.get(nodeId); if (!connections) continue; - for (let { edgeId } of connections.values()) { - let edge = edgeLookup.get(edgeId); + for (const { edgeId } of connections.values()) { + const edge = edgeLookup.get(edgeId); if (edge && (edge.selectable ?? edgesSelectable)) { selectedEdgeIds.current.add(edgeId); } From 748c140146f94303393473c4ed2f0e85e66f2ea0 Mon Sep 17 00:00:00 2001 From: peterkogo Date: Wed, 8 Jan 2025 17:12:12 +0100 Subject: [PATCH 4/5] fixes --- packages/react/src/container/Pane/index.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/react/src/container/Pane/index.tsx b/packages/react/src/container/Pane/index.tsx index ac66b665..3432247b 100644 --- a/packages/react/src/container/Pane/index.tsx +++ b/packages/react/src/container/Pane/index.tsx @@ -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 ( @@ -208,12 +208,12 @@ export function Pane({ } } - if (areSetsEqual(prevSelectedNodeIds, selectedNodeIds.current)) { + if (!areSetsEqual(prevSelectedNodeIds, selectedNodeIds.current)) { const changes = getSelectionChanges(nodeLookup, selectedNodeIds.current, true) as NodeChange[]; triggerNodeChanges(changes); } - if (areSetsEqual(prevSelectedEdgeIds, selectedEdgeIds.current)) { + if (!areSetsEqual(prevSelectedEdgeIds, selectedEdgeIds.current)) { const changes = getSelectionChanges(edgeLookup, selectedEdgeIds.current) as EdgeChange[]; triggerEdgeChanges(changes); } From 78f9d4e6458e6d7249deb982e4fe0e17d5608619 Mon Sep 17 00:00:00 2001 From: moklick Date: Thu, 9 Jan 2025 16:08:52 +0100 Subject: [PATCH 5/5] refactor(useNodeConnections): change param names --- .../UseNodeConnections/MultiHandleNode.tsx | 2 +- .../UseNodeConnections/SingleHandleNode.tsx | 2 +- .../src/examples/UseNodesData/ResultNode.tsx | 2 +- .../examples/UseNodesData/UppercaseNode.tsx | 2 +- .../examples/usenodesdata/ResultNode.svelte | 4 ++-- .../usenodesdata/UppercaseNode.svelte | 4 ++-- .../react/src/hooks/useNodeConnections.ts | 20 ++++++++++--------- .../src/lib/hooks/useNodeConnections.ts | 18 ++++++++--------- 8 files changed, 28 insertions(+), 26 deletions(-) diff --git a/examples/react/src/examples/UseNodeConnections/MultiHandleNode.tsx b/examples/react/src/examples/UseNodeConnections/MultiHandleNode.tsx index 3397242a..5d64f81d 100644 --- a/examples/react/src/examples/UseNodeConnections/MultiHandleNode.tsx +++ b/examples/react/src/examples/UseNodeConnections/MultiHandleNode.tsx @@ -12,7 +12,7 @@ function CustomHandle({ nodeId, ...handleProps }: HandleProps & { nodeId: string [nodeId] ); const connections = useNodeConnections({ - type: handleProps.type, + handleType: handleProps.type, handleId: handleProps.id, onConnect, onDisconnect, diff --git a/examples/react/src/examples/UseNodeConnections/SingleHandleNode.tsx b/examples/react/src/examples/UseNodeConnections/SingleHandleNode.tsx index 070bbf2a..9361f833 100644 --- a/examples/react/src/examples/UseNodeConnections/SingleHandleNode.tsx +++ b/examples/react/src/examples/UseNodeConnections/SingleHandleNode.tsx @@ -15,7 +15,7 @@ function CustomHandle({ nodeId, ...handleProps }: HandleProps & { nodeId: string [nodeId] ); const connections = useNodeConnections({ - type: handleProps.type, + handleType: handleProps.type, handleId: handleProps.id, onConnect, onDisconnect, diff --git a/examples/react/src/examples/UseNodesData/ResultNode.tsx b/examples/react/src/examples/UseNodesData/ResultNode.tsx index 45ec4087..0caa228d 100644 --- a/examples/react/src/examples/UseNodesData/ResultNode.tsx +++ b/examples/react/src/examples/UseNodesData/ResultNode.tsx @@ -4,7 +4,7 @@ import { isTextNode, type MyNode } from '.'; function ResultNode() { const connections = useNodeConnections({ - type: 'target', + handleType: 'target', }); const nodesData = useNodesData(connections.map((connection) => connection.source)); const textNodes = nodesData.filter(isTextNode); diff --git a/examples/react/src/examples/UseNodesData/UppercaseNode.tsx b/examples/react/src/examples/UseNodesData/UppercaseNode.tsx index a6f0b67b..792b7c55 100644 --- a/examples/react/src/examples/UseNodesData/UppercaseNode.tsx +++ b/examples/react/src/examples/UseNodesData/UppercaseNode.tsx @@ -5,7 +5,7 @@ import { isTextNode, type TextNode, type MyNode } from '.'; function UppercaseNode({ id }: NodeProps) { const { updateNodeData } = useReactFlow(); const connections = useNodeConnections({ - type: 'target', + handleType: 'target', }); const nodesData = useNodesData(connections[0]?.source); const textNode = isTextNode(nodesData) ? nodesData : null; diff --git a/examples/svelte/src/routes/examples/usenodesdata/ResultNode.svelte b/examples/svelte/src/routes/examples/usenodesdata/ResultNode.svelte index 9aa05fc2..701d0506 100644 --- a/examples/svelte/src/routes/examples/usenodesdata/ResultNode.svelte +++ b/examples/svelte/src/routes/examples/usenodesdata/ResultNode.svelte @@ -14,8 +14,8 @@ $$restProps; const connections = useNodeConnections({ - nodeId: id, - type: 'target' + id: id, + handleType: 'target' }); $: nodeData = useNodesData($connections.map((connection) => connection.source)); diff --git a/examples/svelte/src/routes/examples/usenodesdata/UppercaseNode.svelte b/examples/svelte/src/routes/examples/usenodesdata/UppercaseNode.svelte index 5ca38032..600b7ec8 100644 --- a/examples/svelte/src/routes/examples/usenodesdata/UppercaseNode.svelte +++ b/examples/svelte/src/routes/examples/usenodesdata/UppercaseNode.svelte @@ -17,8 +17,8 @@ const { updateNodeData } = useSvelteFlow(); const connections = useNodeConnections({ - nodeId: id, - type: 'target' + id: id, + handleType: 'target' }); $: nodeData = useNodesData($connections[0]?.source); diff --git a/packages/react/src/hooks/useNodeConnections.ts b/packages/react/src/hooks/useNodeConnections.ts index b0481851..c83a7366 100644 --- a/packages/react/src/hooks/useNodeConnections.ts +++ b/packages/react/src/hooks/useNodeConnections.ts @@ -14,9 +14,9 @@ import { useNodeId } from '../contexts/NodeIdContext'; const error014 = errorMessages['error014'](); type UseNodeConnectionsParams = { - type?: HandleType; + id?: string; + handleType?: HandleType; handleId?: string; - nodeId?: string; onConnect?: (connections: Connection[]) => void; onDisconnect?: (connections: Connection[]) => void; }; @@ -25,22 +25,22 @@ type UseNodeConnectionsParams = { * Hook to retrieve all edges connected to a node. Can be filtered by handle type and id. * * @public - * @param param.nodeId - node id - optional if called inside a custom node - * @param param.type - filter by handle type 'source' or 'target' + * @param param.id - node id - optional if called inside a custom node + * @param param.handleType - filter by handle type 'source' or 'target' * @param param.handleId - filter by handle id (this is only needed if the node has multiple handles of the same type) * @param param.onConnect - gets called when a connection is established * @param param.onDisconnect - gets called when a connection is removed * @returns an array with connections */ export function useNodeConnections({ - type, + id, + handleType, handleId, - nodeId, onConnect, onDisconnect, }: UseNodeConnectionsParams = {}): NodeConnection[] { - const _nodeId = useNodeId(); - const currentNodeId = nodeId ?? _nodeId; + const nodeId = useNodeId(); + const currentNodeId = id ?? nodeId; if (!currentNodeId) { throw new Error(error014); @@ -50,7 +50,9 @@ export function useNodeConnections({ const connections = useStore( (state) => - state.connectionLookup.get(`${currentNodeId}${type ? (handleId ? `-${type}-${handleId}` : `-${type}`) : ''}`), + state.connectionLookup.get( + `${currentNodeId}${handleType ? (handleId ? `-${handleType}-${handleId}` : `-${handleType}`) : ''}` + ), areConnectionMapsEqual ); diff --git a/packages/svelte/src/lib/hooks/useNodeConnections.ts b/packages/svelte/src/lib/hooks/useNodeConnections.ts index fbebdd92..e3c28759 100644 --- a/packages/svelte/src/lib/hooks/useNodeConnections.ts +++ b/packages/svelte/src/lib/hooks/useNodeConnections.ts @@ -1,3 +1,4 @@ +import { getContext } from 'svelte'; import { derived } from 'svelte/store'; import { areConnectionMapsEqual, @@ -7,14 +8,13 @@ import { } from '@xyflow/system'; import { useStore } from '$lib/store'; -import { getContext } from 'svelte'; const error014 = errorMessages['error014'](); type UseNodeConnectionsParams = { - type?: HandleType; + id?: string; + handleType?: HandleType; handleId?: string; - nodeId?: string; // TODO: Svelte 5 // onConnect?: (connections: Connection[]) => void; // onDisconnect?: (connections: Connection[]) => void; @@ -26,18 +26,18 @@ const initialConnections: NodeConnection[] = []; * Hook to retrieve all edges connected to a node. Can be filtered by handle type and id. * * @public - * @param param.nodeId - node id - optional if called inside a custom node - * @param param.type - filter by handle type 'source' or 'target' + * @param param.id - node id - optional if called inside a custom node + * @param param.handleType - filter by handle type 'source' or 'target' * @param param.handleId - filter by handle id (this is only needed if the node has multiple handles of the same type) * @todo @param param.onConnect - gets called when a connection is established * @todo @param param.onDisconnect - gets called when a connection is removed * @returns an array with connections */ -export function useNodeConnections({ type, nodeId, handleId }: UseNodeConnectionsParams = {}) { +export function useNodeConnections({ id, handleType, handleId }: UseNodeConnectionsParams = {}) { const { edges, connectionLookup } = useStore(); - const _nodeId = getContext('svelteflow__node_id'); - const currentNodeId = nodeId ?? _nodeId; + const nodeId = getContext('svelteflow__node_id'); + const currentNodeId = id ?? nodeId; if (!currentNodeId) { throw new Error(error014); @@ -49,7 +49,7 @@ export function useNodeConnections({ type, nodeId, handleId }: UseNodeConnection [edges, connectionLookup], ([, connectionLookup], set) => { const nextConnections = connectionLookup.get( - `${currentNodeId}${type ? (handleId ? `-${type}-${handleId}` : `-${type}`) : ''}` + `${currentNodeId}${handleType ? (handleId ? `-${handleType}-${handleId}` : `-${handleType}`) : ''}` ); if (!areConnectionMapsEqual(nextConnections, prevConnections)) {