Merge pull request #5344 from xyflow/feat/connection-drag-threshold

Add connectionDragThreshold
This commit is contained in:
Moritz Klack
2025-06-25 16:40:49 +02:00
committed by GitHub
18 changed files with 90 additions and 17 deletions
+7
View File
@@ -0,0 +1,7 @@
---
'@xyflow/react': minor
'@xyflow/svelte': minor
'@xyflow/system': minor
---
Add connectionDragThreshold prop
@@ -36,6 +36,7 @@ const ConnectionLineFlow = () => {
onEdgesChange={onEdgesChange} onEdgesChange={onEdgesChange}
connectionLineComponent={ConnectionLine} connectionLineComponent={ConnectionLine}
onConnect={onConnect} onConnect={onConnect}
connectionDragThreshold={25}
> >
<Background variant={BackgroundVariant.Lines} /> <Background variant={BackgroundVariant.Lines} />
</ReactFlow> </ReactFlow>
@@ -24,7 +24,14 @@
</script> </script>
<div style="height:100vh;"> <div style="height:100vh;">
<SvelteFlow bind:nodes bind:edges {nodeTypes} fitView connectionLineComponent={ConnectionLine}> <SvelteFlow
bind:nodes
bind:edges
{nodeTypes}
fitView
connectionLineComponent={ConnectionLine}
connectionDragThreshold={100}
>
<Background variant={BackgroundVariant.Lines} /> <Background variant={BackgroundVariant.Lines} />
</SvelteFlow> </SvelteFlow>
</div> </div>
@@ -1,5 +1,12 @@
// Reconnectable edges have a anchors around their handles to reconnect the edge. // Reconnectable edges have a anchors around their handles to reconnect the edge.
import { XYHandle, type Connection, EdgePosition, FinalConnectionState, HandleType } from '@xyflow/system'; import {
XYHandle,
type Connection,
EdgePosition,
FinalConnectionState,
HandleType,
OnConnectStart,
} from '@xyflow/system';
import { EdgeAnchor } from '../Edges/EdgeAnchor'; import { EdgeAnchor } from '../Edges/EdgeAnchor';
import type { EdgeWrapperProps, Edge } from '../../types/edges'; import type { EdgeWrapperProps, Edge } from '../../types/edges';
@@ -60,15 +67,17 @@ export function EdgeUpdateAnchors<EdgeType extends Edge = Edge>({
} = store.getState(); } = store.getState();
const isTarget = oppositeHandle.type === 'target'; const isTarget = oppositeHandle.type === 'target';
setReconnecting(true);
onReconnectStart?.(event, edge, oppositeHandle.type);
const _onReconnectEnd = (evt: MouseEvent | TouchEvent, connectionState: FinalConnectionState) => { const _onReconnectEnd = (evt: MouseEvent | TouchEvent, connectionState: FinalConnectionState) => {
setReconnecting(false); setReconnecting(false);
onReconnectEnd?.(evt, edge, oppositeHandle.type, connectionState); onReconnectEnd?.(evt, edge, oppositeHandle.type, connectionState);
}; };
const onConnectEdge = (connection: Connection) => onReconnect?.(edge, connection); const onConnectEdge = (connection: Connection) => onReconnect?.(edge, connection);
const _onConnectStart: OnConnectStart = (_event, params) => {
setReconnecting(true);
onReconnectStart?.(event, edge, oppositeHandle.type);
onConnectStart?.(_event, params);
};
XYHandle.onPointerDown(event.nativeEvent, { XYHandle.onPointerDown(event.nativeEvent, {
autoPanOnConnect, autoPanOnConnect,
@@ -86,12 +95,13 @@ export function EdgeUpdateAnchors<EdgeType extends Edge = Edge>({
panBy, panBy,
isValidConnection, isValidConnection,
onConnect: onConnectEdge, onConnect: onConnectEdge,
onConnectStart, onConnectStart: _onConnectStart,
onConnectEnd, onConnectEnd,
onReconnectEnd: _onReconnectEnd, onReconnectEnd: _onReconnectEnd,
updateConnection, updateConnection,
getTransform: () => store.getState().transform, getTransform: () => store.getState().transform,
getFromHandle: () => store.getState().connection.fromHandle, getFromHandle: () => store.getState().connection.fromHandle,
dragThreshold: store.getState().connectionDragThreshold,
}); });
}; };
@@ -149,6 +149,7 @@ function HandleComponent(
getTransform: () => store.getState().transform, getTransform: () => store.getState().transform,
getFromHandle: () => store.getState().connection.fromHandle, getFromHandle: () => store.getState().connection.fromHandle,
autoPanSpeed: currentStore.autoPanSpeed, autoPanSpeed: currentStore.autoPanSpeed,
dragThreshold: currentStore.connectionDragThreshold,
}); });
} }
@@ -65,6 +65,7 @@ const reactFlowFieldsToTrack = [
'isValidConnection', 'isValidConnection',
'selectNodesOnDrag', 'selectNodesOnDrag',
'nodeDragThreshold', 'nodeDragThreshold',
'connectionDragThreshold',
'onBeforeDelete', 'onBeforeDelete',
'debug', 'debug',
'autoPanSpeed', 'autoPanSpeed',
@@ -139,6 +139,7 @@ function ReactFlow<NodeType extends Node = Node, EdgeType extends Edge = Edge>(
style, style,
id, id,
nodeDragThreshold, nodeDragThreshold,
connectionDragThreshold,
viewport, viewport,
onViewportChange, onViewportChange,
width, width,
@@ -306,6 +307,7 @@ function ReactFlow<NodeType extends Node = Node, EdgeType extends Edge = Edge>(
isValidConnection={isValidConnection} isValidConnection={isValidConnection}
selectNodesOnDrag={selectNodesOnDrag} selectNodesOnDrag={selectNodesOnDrag}
nodeDragThreshold={nodeDragThreshold} nodeDragThreshold={nodeDragThreshold}
connectionDragThreshold={connectionDragThreshold}
onBeforeDelete={onBeforeDelete} onBeforeDelete={onBeforeDelete}
paneClickDistance={paneClickDistance} paneClickDistance={paneClickDistance}
debug={debug} debug={debug}
+1
View File
@@ -107,6 +107,7 @@ const getInitialState = ({
noPanClassName: 'nopan', noPanClassName: 'nopan',
nodeOrigin: storeNodeOrigin, nodeOrigin: storeNodeOrigin,
nodeDragThreshold: 1, nodeDragThreshold: 1,
connectionDragThreshold: 1,
snapGrid: [15, 15], snapGrid: [15, 15],
snapToGrid: false, snapToGrid: false,
@@ -658,6 +658,12 @@ export interface ReactFlowProps<NodeType extends Node = Node, EdgeType extends E
* @default 1 * @default 1
*/ */
nodeDragThreshold?: number; nodeDragThreshold?: number;
/**
* The threshold in pixels that the mouse must move before a connection line starts to drag.
* This is useful to prevent accidental connections when clicking on a handle.
* @default 1
*/
connectionDragThreshold?: number;
/** Sets a fixed width for the flow. */ /** Sets a fixed width for the flow. */
width?: number; width?: number;
/** Sets a fixed height for the flow. */ /** Sets a fixed height for the flow. */
+1
View File
@@ -76,6 +76,7 @@ export type ReactFlowStore<NodeType extends Node = Node, EdgeType extends Edge =
nodeExtent: CoordinateExtent; nodeExtent: CoordinateExtent;
nodeOrigin: NodeOrigin; nodeOrigin: NodeOrigin;
nodeDragThreshold: number; nodeDragThreshold: number;
connectionDragThreshold: number;
nodesSelectionActive: boolean; nodesSelectionActive: boolean;
userSelectionActive: boolean; userSelectionActive: boolean;
@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import { useStore } from '$lib/store'; import { useStore } from '$lib/store';
import type { Edge } from '$lib/types'; import type { Edge } from '$lib/types';
import { XYHandle, type HandleType } from '@xyflow/system'; import { XYHandle, type HandleType, type OnConnectStart } from '@xyflow/system';
import { getContext } from 'svelte'; import { getContext } from 'svelte';
import { EdgeLabel } from '../EdgeLabel'; import { EdgeLabel } from '../EdgeLabel';
import type { EdgeReconnectAnchorProps } from './types'; import type { EdgeReconnectAnchorProps } from './types';
@@ -12,6 +12,7 @@
position, position,
class: className, class: className,
size = 25, size = 25,
dragThreshold = 1,
children, children,
...rest ...rest
}: EdgeReconnectAnchorProps = $props(); }: EdgeReconnectAnchorProps = $props();
@@ -52,8 +53,11 @@
let newEdge: Edge | undefined; let newEdge: Edge | undefined;
let edge = edgeLookup.get(edgeId)!; let edge = edgeLookup.get(edgeId)!;
reconnecting = true; const _onConnectStart: OnConnectStart = (evt, params) => {
onreconnectstart?.(event, edge, type); reconnecting = true;
onreconnectstart?.(event, edge, type);
onconnectstart?.(evt, params);
};
const opposite = const opposite =
type === 'target' type === 'target'
@@ -79,7 +83,7 @@
cancelConnection, cancelConnection,
panBy, panBy,
isValidConnection, isValidConnection,
onConnectStart: onconnectstart, onConnectStart: _onConnectStart,
onConnectEnd: onconnectend, onConnectEnd: onconnectend,
onConnect: (connection) => { onConnect: (connection) => {
newEdge = { ...edge, ...connection }; newEdge = { ...edge, ...connection };
@@ -97,7 +101,8 @@
}, },
updateConnection, updateConnection,
getTransform: () => [store.viewport.x, store.viewport.y, store.viewport.zoom], getTransform: () => [store.viewport.x, store.viewport.y, store.viewport.zoom],
getFromHandle: () => store.connection.fromHandle getFromHandle: () => store.connection.fromHandle,
dragThreshold: dragThreshold ?? store.connectionDragThreshold
}); });
}; };
</script> </script>
@@ -10,4 +10,5 @@ export type EdgeReconnectAnchorProps = {
position?: XYPosition; position?: XYPosition;
size?: number; size?: number;
children?: Snippet; children?: Snippet;
dragThreshold?: number;
} & HTMLAttributes<HTMLDivElement>; } & HTMLAttributes<HTMLDivElement>;
@@ -139,7 +139,8 @@
store.onconnectend?.(event, connectionState); store.onconnectend?.(event, connectionState);
}, },
getTransform: () => [store.viewport.x, store.viewport.y, store.viewport.zoom], getTransform: () => [store.viewport.x, store.viewport.y, store.viewport.zoom],
getFromHandle: () => store.connection.fromHandle getFromHandle: () => store.connection.fromHandle,
dragThreshold: store.connectionDragThreshold
}); });
} }
} }
@@ -64,6 +64,7 @@
fitViewOptions, fitViewOptions,
nodeOrigin, nodeOrigin,
nodeDragThreshold, nodeDragThreshold,
connectionDragThreshold,
minZoom, minZoom,
maxZoom, maxZoom,
initialViewport, initialViewport,
@@ -170,6 +170,12 @@ export type SvelteFlowProps<
* @default 0 * @default 0
*/ */
nodeClickDistance?: number; nodeClickDistance?: number;
/**
* The threshold in pixels that the mouse must move before a connection line starts to drag.
* This is useful to prevent accidental connections when clicking on a handle.
* @default 1
*/
connectionDragThreshold?: number;
/** Minimum zoom level /** Minimum zoom level
* @default 0.5 * @default 0.5
*/ */
@@ -286,6 +286,7 @@ export function getInitialStore<NodeType extends Node = Node, EdgeType extends E
autoPanOnNodeDrag: boolean = $derived(signals.props.autoPanOnNodeDrag ?? true); autoPanOnNodeDrag: boolean = $derived(signals.props.autoPanOnNodeDrag ?? true);
autoPanOnConnect: boolean = $derived(signals.props.autoPanOnConnect ?? true); autoPanOnConnect: boolean = $derived(signals.props.autoPanOnConnect ?? true);
autoPanOnNodeFocus: boolean = $derived(signals.props.autoPanOnNodeFocus ?? true); autoPanOnNodeFocus: boolean = $derived(signals.props.autoPanOnNodeFocus ?? true);
connectionDragThreshold: number = $derived(signals.props.connectionDragThreshold ?? 1);
fitViewQueued: boolean = signals.props.fitView ?? false; fitViewQueued: boolean = signals.props.fitView ?? false;
fitViewOptions: FitViewOptions | undefined = signals.props.fitViewOptions; fitViewOptions: FitViewOptions | undefined = signals.props.fitViewOptions;
+25 -5
View File
@@ -45,6 +45,7 @@ function onPointerDown(
getTransform, getTransform,
getFromHandle, getFromHandle,
autoPanSpeed, autoPanSpeed,
dragThreshold = 1,
}: OnPointerDownParams }: OnPointerDownParams
) { ) {
// when xyflow is used inside a shadow root we can't use document // when xyflow is used inside a shadow root we can't use document
@@ -56,6 +57,7 @@ function onPointerDown(
const clickedHandle = doc?.elementFromPoint(x, y); const clickedHandle = doc?.elementFromPoint(x, y);
const handleType = getHandleType(edgeUpdaterType, clickedHandle); const handleType = getHandleType(edgeUpdaterType, clickedHandle);
const containerBounds = domNode?.getBoundingClientRect(); const containerBounds = domNode?.getBoundingClientRect();
let connectionStarted = false;
if (!containerBounds || !handleType) { if (!containerBounds || !handleType) {
return; return;
@@ -92,10 +94,9 @@ function onPointerDown(
}; };
const fromNodeInternal = nodeLookup.get(nodeId)!; const fromNodeInternal = nodeLookup.get(nodeId)!;
const from = getHandlePosition(fromNodeInternal, fromHandle, Position.Left, true); const from = getHandlePosition(fromNodeInternal, fromHandle, Position.Left, true);
const newConnection: ConnectionInProgress = { let previousConnection: ConnectionInProgress = {
inProgress: true, inProgress: true,
isValid: null, isValid: null,
@@ -110,12 +111,31 @@ function onPointerDown(
toNode: null, toNode: null,
}; };
updateConnection(newConnection); function startConnection() {
let previousConnection: ConnectionInProgress = newConnection; updateConnection(previousConnection);
onConnectStart?.(event, { nodeId, handleId, handleType });
}
onConnectStart?.(event, { nodeId, handleId, handleType }); if (dragThreshold === 0) {
startConnection();
}
function onPointerMove(event: MouseEvent | TouchEvent) { function onPointerMove(event: MouseEvent | TouchEvent) {
if (!connectionStarted) {
const { x: evtX, y: evtY } = getEventPosition(event);
const dx = evtX - x;
const dy = evtY - y;
const nextConnectionStarted = dx * dx + dy * dy > dragThreshold * dragThreshold;
if (!nextConnectionStarted) {
return;
}
startConnection();
connectionStarted = nextConnectionStarted;
}
if (!getFromHandle() || !fromHandle) { if (!getFromHandle() || !fromHandle) {
onPointerUp(event); onPointerUp(event);
return; return;
+1
View File
@@ -37,6 +37,7 @@ export type OnPointerDownParams = {
getTransform: () => Transform; getTransform: () => Transform;
getFromHandle: () => Handle | null; getFromHandle: () => Handle | null;
autoPanSpeed?: number; autoPanSpeed?: number;
dragThreshold?: number;
}; };
export type IsValidParams = { export type IsValidParams = {