Merge pull request #5428 from Karl255/#5273-handle-click-target-fix

Allow detached handles
This commit is contained in:
Moritz Klack
2025-08-12 14:18:20 +02:00
committed by GitHub
13 changed files with 183 additions and 16 deletions

View File

@@ -0,0 +1,7 @@
---
'@xyflow/react': patch
'@xyflow/system': patch
'@xyflow/svelte': patch
---
Fix clicking on detached handle elements not initiating drawing of connections

View File

@@ -58,6 +58,7 @@ import AddNodeOnEdgeDrop from '../examples/AddNodeOnEdgeDrop';
import DevTools from '../examples/DevTools';
import Redux from '../examples/Redux';
import MovingHandles from '../examples/MovingHandles';
import DetachedHandle from '../examples/DetachedHandle';
export interface IRoute {
name: string;
@@ -146,6 +147,11 @@ const routes: IRoute[] = [
path: 'default-nodes',
component: DefaultNodes,
},
{
name: 'DetachedHandle',
path: 'detached-handle',
component: DetachedHandle,
},
{
name: 'DevTools',
path: 'devtools',

View File

@@ -0,0 +1,62 @@
import {
ReactFlow,
Node,
ReactFlowProvider,
Background,
BackgroundVariant,
NodeProps,
Handle,
Position,
} from '@xyflow/react';
import './style.css';
const initialNodes: Node[] = [
{
id: '1',
data: { label: 'Node 1' },
position: { x: 250, y: 5 },
},
{
id: '2',
data: { label: 'Node 2' },
position: { x: 50, y: 100 },
},
{
id: '3',
data: { label: 'Node 3' },
position: { x: 450, y: 100 },
},
];
const CustomNode = (_: NodeProps) => {
return (
<>
<Handle type="target" position={Position.Left} />
<div>Custom node</div>
<Handle type="source" position={Position.Right}>
<button className="detached-handle"></button>
</Handle>
</>
);
};
const nodeTypes = {
default: CustomNode,
};
const DetachedHandle = () => {
return (
<ReactFlow defaultNodes={initialNodes} defaultEdges={[]} connectionRadius={10} nodeTypes={nodeTypes} fitView>
<Background variant={BackgroundVariant.Lines} />
</ReactFlow>
);
};
export default function App() {
return (
<ReactFlowProvider>
<DetachedHandle />
</ReactFlowProvider>
);
}

View File

@@ -0,0 +1,10 @@
.detached-handle {
position: absolute;
top: 50%;
left: 1rem;
transform: translateY(-50%);
width: 2rem;
height: 2rem;
border: none;
border-radius: 50%;
}

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { page } from '$app/state';
const routes = [
'a11y',
@@ -9,6 +9,7 @@
'custom-connection-line',
'customnode',
'dagre',
'detached-handle',
'drag-n-drop',
'edges',
'figma',
@@ -36,7 +37,7 @@
<header>
<div class="logo">Svelte Flow</div>
<select onchange={onChange} value={$page.route.id}>
<select onchange={onChange} value={page.route.id}>
{#each routes as route}
<option value={`/examples/${route}`}>{route}</option>
{/each}

View File

@@ -0,0 +1,41 @@
<script lang="ts">
import {
SvelteFlow,
Background,
MiniMap,
Position,
type Node,
type NodeTypes,
type Edge
} from '@xyflow/svelte';
import CustomNode from './CustomNode.svelte';
import '@xyflow/svelte/dist/style.css';
const nodeTypes: NodeTypes = {
custom: CustomNode
};
let nodes = $state.raw<Node[]>([
{
id: '1',
type: 'custom',
data: { label: 'node 1' },
position: { x: 0, y: 0 },
sourcePosition: Position.Right
},
{
id: '2',
type: 'custom',
data: { label: 'node 2' },
position: { x: 250, y: 250 }
}
]);
let edges = $state.raw<Edge[]>([]);
</script>
<SvelteFlow bind:nodes bind:edges {nodeTypes} fitView>
<Background />
<MiniMap />
</SvelteFlow>

View File

@@ -0,0 +1,36 @@
<script lang="ts">
import { Handle, Position, type NodeProps, type Node } from '@xyflow/svelte';
let { data }: NodeProps<Node> = $props();
</script>
<div class="custom">
<div>
{data.label}
</div>
<Handle type="target" position={Position.Left} />
<Handle type="source" position={Position.Right}>
<button class="detached-handle">➡️</button>
</Handle>
</div>
<style>
.custom {
background-color: white;
padding: 10px;
border: 1px solid #777;
border-radius: 20px;
}
.detached-handle {
position: absolute;
top: 50%;
left: 1rem;
transform: translateY(-50%);
width: 2rem;
height: 2rem;
border: none;
border-radius: 50%;
}
</style>

View File

@@ -1,11 +1,11 @@
// Reconnectable edges have a anchors around their handles to reconnect the edge.
import {
XYHandle,
type EdgePosition,
type FinalConnectionState,
type HandleType,
type OnConnectStart,
type Connection,
EdgePosition,
FinalConnectionState,
HandleType,
OnConnectStart,
} from '@xyflow/system';
import { EdgeAnchor } from '../Edges/EdgeAnchor';
@@ -102,6 +102,7 @@ export function EdgeUpdateAnchors<EdgeType extends Edge = Edge>({
getTransform: () => store.getState().transform,
getFromHandle: () => store.getState().connection.fromHandle,
dragThreshold: store.getState().connectionDragThreshold,
handleDomNode: event.currentTarget,
});
};

View File

@@ -114,7 +114,6 @@ function HandleComponent(
onConnectAction?.(edgeParams);
onConnect?.(edgeParams);
};
const onPointerDown = (event: ReactMouseEvent<HTMLDivElement> | ReactTouchEvent<HTMLDivElement>) => {
if (!nodeId) {
return;
@@ -129,6 +128,7 @@ function HandleComponent(
const currentStore = store.getState();
XYHandle.onPointerDown(event.nativeEvent, {
handleDomNode: event.currentTarget,
autoPanOnConnect: currentStore.autoPanOnConnect,
connectionMode: currentStore.connectionMode,
connectionRadius: currentStore.connectionRadius,

View File

@@ -102,7 +102,8 @@
updateConnection,
getTransform: () => [store.viewport.x, store.viewport.y, store.viewport.zoom],
getFromHandle: () => store.connection.fromHandle,
dragThreshold: dragThreshold ?? store.connectionDragThreshold
dragThreshold: dragThreshold ?? store.connectionDragThreshold,
handleDomNode: event.currentTarget as HTMLElement
});
};
</script>

View File

@@ -111,7 +111,7 @@
function onpointerdown(event: MouseEvent | TouchEvent) {
const isMouseTriggered = isMouseEvent(event);
if ((isMouseTriggered && event.button === 0) || !isMouseTriggered) {
if (event.currentTarget && ((isMouseTriggered && event.button === 0) || !isMouseTriggered)) {
XYHandle.onPointerDown(event, {
handleId,
nodeId,
@@ -140,7 +140,8 @@
},
getTransform: () => [store.viewport.x, store.viewport.y, store.viewport.zoom],
getFromHandle: () => store.connection.fromHandle,
dragThreshold: store.connectionDragThreshold
dragThreshold: store.connectionDragThreshold,
handleDomNode: event.currentTarget as HTMLElement
});
}
}

View File

@@ -46,6 +46,7 @@ function onPointerDown(
getFromHandle,
autoPanSpeed,
dragThreshold = 1,
handleDomNode,
}: OnPointerDownParams
) {
// when xyflow is used inside a shadow root we can't use document
@@ -54,8 +55,7 @@ function onPointerDown(
let closestHandle: Handle | null;
const { x, y } = getEventPosition(event);
const clickedHandle = doc?.elementFromPoint(x, y);
const handleType = getHandleType(edgeUpdaterType, clickedHandle);
const handleType = getHandleType(edgeUpdaterType, handleDomNode);
const containerBounds = domNode?.getBoundingClientRect();
let connectionStarted = false;
@@ -72,7 +72,7 @@ function onPointerDown(
let autoPanStarted = false;
let connection: Connection | null = null;
let isValid: boolean | null = false;
let handleDomNode: Element | null = null;
let resultHandleDomNode: Element | null = null;
// when the user is moving the mouse close to the edge of the canvas while connecting we move the canvas
function autoPan(): void {
@@ -167,7 +167,7 @@ function onPointerDown(
nodeLookup,
});
handleDomNode = result.handleDomNode;
resultHandleDomNode = result.handleDomNode;
connection = result.connection;
isValid = isConnectionValid(!!closestHandle, result.isValid);
@@ -208,7 +208,7 @@ function onPointerDown(
function onPointerUp(event: MouseEvent | TouchEvent) {
if (connectionStarted) {
if ((closestHandle || handleDomNode) && connection && isValid) {
if ((closestHandle || resultHandleDomNode) && connection && isValid) {
onConnect?.(connection);
}
@@ -235,7 +235,7 @@ function onPointerDown(
autoPanStarted = false;
isValid = false;
connection = null;
handleDomNode = null;
resultHandleDomNode = null;
doc.removeEventListener('mousemove', onPointerMove as EventListener);
doc.removeEventListener('mouseup', onPointerUp as EventListener);

View File

@@ -38,6 +38,7 @@ export type OnPointerDownParams = {
getFromHandle: () => Handle | null;
autoPanSpeed?: number;
dragThreshold?: number;
handleDomNode: Element;
};
export type IsValidParams = {