Merge pull request #4397 from xyflow/enhance-connection-more

Streamline Connections
This commit is contained in:
Moritz Klack
2024-06-27 17:16:42 +02:00
committed by GitHub
26 changed files with 459 additions and 541 deletions

View File

@@ -1,9 +1,24 @@
<script lang="ts">
import { useConnection } from '@xyflow/svelte';
import { getBezierPath, useConnection } from '@xyflow/svelte';
const connection = useConnection();
let path: string | null = null;
$: if ($connection.inProgress) {
const { from, to, fromPosition, toPosition } = $connection;
const pathParams = {
sourceX: from.x,
sourceY: from.y,
sourcePosition: fromPosition,
targetX: to.x,
targetY: to.y,
targetPosition: toPosition
};
[path] = getBezierPath(pathParams);
}
</script>
{#if $connection.path}
<path d={$connection.path} fill="none" stroke={$connection.startHandle?.handleId} />
{#if $connection.inProgress}
<path d={path} fill="none" stroke={$connection.fromHandle.id} />
{/if}

View File

@@ -1,140 +1,18 @@
import { CSSProperties, useCallback } from 'react';
import { CSSProperties } from 'react';
import { shallow } from 'zustand/shallow';
import cc from 'classcat';
import {
Position,
ConnectionLineType,
ConnectionMode,
getBezierPath,
getSmoothStepPath,
type HandleType,
getConnectionStatus,
getStraightPath,
} from '@xyflow/system';
import { useStore } from '../../hooks/useStore';
import { getSimpleBezierPath } from '../Edges/SimpleBezierEdge';
import type { ConnectionLineComponent, ReactFlowState, ReactFlowStore } from '../../types';
function getConnectionStatus(isValid: boolean | null) {
if (isValid === null) {
return null;
}
return isValid ? 'valid' : 'invalid';
}
type ConnectionLineProps = {
nodeId: string;
handleType: HandleType;
type: ConnectionLineType;
style?: CSSProperties;
CustomComponent?: ConnectionLineComponent;
isValid: boolean | null;
};
const oppositePosition = {
[Position.Left]: Position.Right,
[Position.Right]: Position.Left,
[Position.Top]: Position.Bottom,
[Position.Bottom]: Position.Top,
};
const ConnectionLine = ({
nodeId,
handleType,
style,
type = ConnectionLineType.Bezier,
CustomComponent,
isValid,
}: ConnectionLineProps) => {
const { fromNode, startHandle, endHandle, toX, toY, connectionMode } = useStore(
useCallback(
(s: ReactFlowStore) => ({
fromNode: s.nodeLookup.get(nodeId),
startHandle: s.connection.fromHandle,
endHandle: s.connection.toHandle,
toX: (s.connection.position.x - s.transform[0]) / s.transform[2],
toY: (s.connection.position.y - s.transform[1]) / s.transform[2],
connectionMode: s.connectionMode,
}),
[nodeId]
),
shallow
);
const fromHandleBounds = fromNode?.internals.handleBounds;
let handleBounds = fromHandleBounds?.[handleType];
if (connectionMode === ConnectionMode.Loose) {
handleBounds = handleBounds ? handleBounds : fromHandleBounds?.[handleType === 'source' ? 'target' : 'source'];
}
if (!fromNode || !handleBounds) {
return null;
}
const handleId = startHandle?.handleId;
const fromHandle = handleId ? handleBounds.find((d) => d.id === handleId) : handleBounds[0];
const fromHandleX = fromHandle ? fromHandle.x + fromHandle.width / 2 : (fromNode.measured.width ?? 0) / 2;
const fromHandleY = fromHandle ? fromHandle.y + fromHandle.height / 2 : fromNode.measured.height ?? 0;
const fromX = fromNode.internals.positionAbsolute.x + fromHandleX;
const fromY = fromNode.internals.positionAbsolute.y + fromHandleY;
const fromPosition = fromHandle?.position;
const toPosition =
isValid && endHandle?.position ? endHandle.position : fromPosition ? oppositePosition[fromPosition] : null;
if (!fromPosition || !toPosition) {
return null;
}
if (CustomComponent) {
return (
<CustomComponent
connectionLineType={type}
connectionLineStyle={style}
fromNode={fromNode.internals.userNode}
fromHandle={fromHandle}
fromX={fromX}
fromY={fromY}
toX={toX}
toY={toY}
fromPosition={fromPosition}
toPosition={toPosition}
connectionStatus={getConnectionStatus(isValid)}
/>
);
}
let dAttr = '';
const pathParams = {
sourceX: fromX,
sourceY: fromY,
sourcePosition: fromPosition,
targetX: toX,
targetY: toY,
targetPosition: toPosition,
};
if (type === ConnectionLineType.Bezier) {
// we assume the destination position is opposite to the source position
[dAttr] = getBezierPath(pathParams);
} else if (type === ConnectionLineType.Step) {
[dAttr] = getSmoothStepPath({
...pathParams,
borderRadius: 0,
});
} else if (type === ConnectionLineType.SmoothStep) {
[dAttr] = getSmoothStepPath(pathParams);
} else if (type === ConnectionLineType.SimpleBezier) {
[dAttr] = getSimpleBezierPath(pathParams);
} else {
dAttr = `M${fromX},${fromY} ${toX},${toY}`;
}
return <path d={dAttr} fill="none" className="react-flow__connection-path" style={style} />;
};
ConnectionLine.displayName = 'ConnectionLine';
import type { ConnectionLineComponent, ReactFlowState } from '../../types';
import { useConnection } from '../../hooks/useConnection';
type ConnectionLineWrapperProps = {
type: ConnectionLineType;
@@ -144,19 +22,18 @@ type ConnectionLineWrapperProps = {
};
const selector = (s: ReactFlowState) => ({
nodeId: s.connection.fromHandle?.nodeId,
handleType: s.connection.fromHandle?.type,
nodesConnectable: s.nodesConnectable,
isValid: s.connection.isValid,
inProgress: s.connection.inProgress,
width: s.width,
height: s.height,
});
export function ConnectionLineWrapper({ containerStyle, style, type, component }: ConnectionLineWrapperProps) {
const { nodeId, handleType, nodesConnectable, width, height, isValid } = useStore(selector, shallow);
const renderConnectionLine = !!(nodeId && handleType && width && nodesConnectable);
const { nodesConnectable, width, height, isValid, inProgress } = useStore(selector, shallow);
const renderConnection = !!(width && nodesConnectable && inProgress);
if (!renderConnectionLine) {
if (!renderConnection) {
return null;
}
@@ -168,15 +45,78 @@ export function ConnectionLineWrapper({ containerStyle, style, type, component }
className="react-flow__connectionline react-flow__container"
>
<g className={cc(['react-flow__connection', getConnectionStatus(isValid)])}>
<ConnectionLine
nodeId={nodeId}
handleType={handleType}
style={style}
type={type}
CustomComponent={component}
isValid={isValid}
/>
<ConnectionLine style={style} type={type} CustomComponent={component} isValid={isValid} />
</g>
</svg>
);
}
type ConnectionLineProps = {
type: ConnectionLineType;
style?: CSSProperties;
CustomComponent?: ConnectionLineComponent;
isValid: boolean | null;
};
const ConnectionLine = ({ style, type = ConnectionLineType.Bezier, CustomComponent, isValid }: ConnectionLineProps) => {
const { inProgress, from, fromNode, fromHandle, fromPosition, to, toNode, toHandle, toPosition } = useConnection();
if (!inProgress) {
return;
}
if (CustomComponent) {
return (
<CustomComponent
connectionLineType={type}
connectionLineStyle={style}
fromNode={fromNode}
fromHandle={fromHandle}
fromX={from.x}
fromY={from.y}
toX={to.x}
toY={to.y}
fromPosition={fromPosition}
toPosition={toPosition}
connectionStatus={getConnectionStatus(isValid)}
toNode={toNode}
toHandle={toHandle}
/>
);
}
let path = '';
const pathParams = {
sourceX: from.x,
sourceY: from.y,
sourcePosition: fromPosition,
targetX: to.x,
targetY: to.y,
targetPosition: toPosition,
};
switch (type) {
case ConnectionLineType.Bezier:
[path] = getBezierPath(pathParams);
break;
case ConnectionLineType.SimpleBezier:
[path] = getSimpleBezierPath(pathParams);
break;
case ConnectionLineType.Step:
[path] = getSmoothStepPath({
...pathParams,
borderRadius: 0,
});
break;
case ConnectionLineType.SmoothStep:
[path] = getSmoothStepPath(pathParams);
break;
default:
[path] = getStraightPath(pathParams);
}
return <path d={path} fill="none" className="react-flow__connection-path" style={style} />;
};
ConnectionLine.displayName = 'ConnectionLine';

View File

@@ -43,17 +43,16 @@ const connectingSelector =
const { fromHandle, toHandle, isValid } = connection;
const connectingTo = toHandle?.nodeId === nodeId && toHandle?.handleId === handleId && toHandle?.type === type;
const connectingTo = toHandle?.nodeId === nodeId && toHandle?.id === handleId && toHandle?.type === type;
return {
connectingFrom: fromHandle?.nodeId === nodeId && fromHandle?.handleId === handleId && fromHandle?.type === type,
connectingFrom: fromHandle?.nodeId === nodeId && fromHandle?.id === handleId && fromHandle?.type === type,
connectingTo,
clickConnecting:
clickHandle?.nodeId === nodeId && clickHandle?.handleId === handleId && clickHandle?.type === type,
clickConnecting: clickHandle?.nodeId === nodeId && clickHandle?.id === handleId && clickHandle?.type === type,
isPossibleEndHandle:
connectionMode === ConnectionMode.Strict
? fromHandle?.type !== type
: nodeId !== fromHandle?.nodeId || handleId !== fromHandle?.handleId,
: nodeId !== fromHandle?.nodeId || handleId !== fromHandle?.id,
connectionInProcess: !!fromHandle,
valid: connectingTo && isValid,
};
@@ -167,7 +166,7 @@ function HandleComponent(
if (!connectionClickStartHandle) {
onClickConnectStart?.(event.nativeEvent, { nodeId, handleId, handleType: type });
store.setState({ connectionClickStartHandle: { nodeId, type, handleId } });
store.setState({ connectionClickStartHandle: { nodeId, type, id: handleId } });
return;
}
@@ -181,7 +180,7 @@ function HandleComponent(
},
connectionMode,
fromNodeId: connectionClickStartHandle.nodeId,
fromHandleId: connectionClickStartHandle.handleId || null,
fromHandleId: connectionClickStartHandle.id || null,
fromType: connectionClickStartHandle.type,
isValidConnection: isValidConnectionHandler,
flowId,

View File

@@ -2,19 +2,19 @@ import { shallow } from 'zustand/shallow';
import { useStore } from './useStore';
import type { ReactFlowStore } from '../types/store';
import { ConnectionState } from '@xyflow/system';
const selector = (s: ReactFlowStore) => ({
...s.connection,
inProgress: s.connection.fromHandle !== null,
});
import { ConnectionState, pointToRendererPoint } from '@xyflow/system';
const selector = (s: ReactFlowStore): ConnectionState => {
return s.connection.inProgress
? { ...s.connection, to: pointToRendererPoint(s.connection.to, s.transform) }
: { ...s.connection };
};
/**
* Hook for accessing the ongoing connection.
* Hook for accessing the connection state.
*
* @public
* @returns ongoing connection
* @returns ConnectionState
*/
export function useConnection(): ConnectionState & { inProgress: boolean } {
export function useConnection(): ConnectionState {
return useStore(selector, shallow);
}

View File

@@ -12,6 +12,7 @@ import {
EdgeSelectionChange,
NodeSelectionChange,
ParentExpandChild,
initialConnection,
NodeOrigin,
} from '@xyflow/system';
@@ -310,27 +311,12 @@ const createStore = ({
);
},
cancelConnection: () => {
const { connection } = get();
set({
connection: {
position: connection.position,
fromHandle: null,
toHandle: null,
isValid: null,
},
connection: { ...initialConnection },
});
},
updateConnection: (params) => {
const { connection } = get();
const currentConnection = {
connection: {
...params,
position: params.position ?? connection.position,
},
};
set(currentConnection);
updateConnection: (connection) => {
set({ connection });
},
reset: () => set({ ...getInitialState() }),

View File

@@ -8,6 +8,7 @@ import {
devWarn,
getInternalNodesBounds,
NodeOrigin,
initialConnection,
} from '@xyflow/system';
import type { Edge, InternalNode, Node, ReactFlowStore } from '../types';
@@ -104,12 +105,7 @@ const getInitialState = ({
multiSelectionActive: false,
connection: {
fromHandle: null,
toHandle: null,
position: { x: 0, y: 0 },
isValid: null,
},
connection: { ...initialConnection },
connectionClickStartHandle: null,
connectOnClick: true,

View File

@@ -10,7 +10,7 @@ import type {
HandleType,
Connection,
ConnectionLineType,
HandleElement,
Handle,
EdgePosition,
StepPathOptions,
OnError,
@@ -193,8 +193,8 @@ export type OnReconnect<EdgeType extends Edge = Edge> = (oldEdge: EdgeType, newC
export type ConnectionLineComponentProps = {
connectionLineStyle?: CSSProperties;
connectionLineType: ConnectionLineType;
fromNode?: Node;
fromHandle?: HandleElement;
fromNode: Node;
fromHandle: Handle;
fromX: number;
fromY: number;
toX: number;
@@ -202,6 +202,8 @@ export type ConnectionLineComponentProps = {
fromPosition: Position;
toPosition: Position;
connectionStatus: 'valid' | 'invalid' | null;
toNode: Node | null;
toHandle: Handle | null;
};
export type ConnectionLineComponent = ComponentType<ConnectionLineComponentProps>;

View File

@@ -10,7 +10,7 @@ import {
type OnViewportChange,
type SelectionRect,
type SnapGrid,
type ConnectingHandle,
type Handle,
type Transform,
type PanZoomInstance,
type PanBy,
@@ -80,7 +80,7 @@ export type ReactFlowStore<NodeType extends Node = Node, EdgeType extends Edge =
connection: ConnectionState;
connectionMode: ConnectionMode;
connectionClickStartHandle: ConnectingHandle | null;
connectionClickStartHandle: (Pick<Handle, 'nodeId' | 'id'> & Required<Pick<Handle, 'type'>>) | null;
snapToGrid: boolean;
snapGrid: SnapGrid;

View File

@@ -2,21 +2,59 @@
import cc from 'classcat';
import { useStore } from '$lib/store';
import {
ConnectionLineType,
getBezierPath,
getConnectionStatus,
getSmoothStepPath,
getStraightPath
} from '@xyflow/system';
export let containerStyle: string = '';
export let style: string = '';
export let isCustomComponent: boolean = false;
const { width, height, connection } = useStore();
const { width, height, connection, connectionLineType } = useStore();
let path: string | null = null;
$: if ($connection.inProgress && !isCustomComponent) {
const { from, to, fromPosition, toPosition } = $connection;
const pathParams = {
sourceX: from.x,
sourceY: from.y,
sourcePosition: fromPosition,
targetX: to.x,
targetY: to.y,
targetPosition: toPosition
};
switch ($connectionLineType) {
case ConnectionLineType.Bezier:
[path] = getBezierPath(pathParams);
break;
case ConnectionLineType.Step:
[path] = getSmoothStepPath({
...pathParams,
borderRadius: 0
});
break;
case ConnectionLineType.SmoothStep:
[path] = getSmoothStepPath(pathParams);
break;
default:
[path] = getStraightPath(pathParams);
}
}
</script>
{#if $connection.path}
{#if $connection.inProgress}
<svg width={$width} height={$height} class="svelte-flow__connectionline" style={containerStyle}>
<g class={cc(['svelte-flow__connection', $connection.status])}>
<g class={cc(['svelte-flow__connection', getConnectionStatus($connection.isValid)])}>
<slot name="connectionLine" />
<!-- slot fallbacks do not work if slots are forwarded in parent -->
{#if !isCustomComponent}
<path d={$connection.path} {style} fill="none" class="svelte-flow__connection-path" />
<path d={path} {style} fill="none" class="svelte-flow__connection-path" />
{/if}
</g>
</svg>

View File

@@ -103,7 +103,7 @@
$onConnectEndAction?.(event);
},
getTransform: () => [$viewport.x, $viewport.y, $viewport.zoom],
getFromHandle: () => $connection.startHandle
getFromHandle: () => $connection.fromHandle
});
}
}
@@ -128,21 +128,20 @@
prevConnections = connections ?? new Map();
}
$: connectionInProcess = !!$connection.startHandle;
$: connectionInProcess = !!$connection.fromHandle;
$: connectingFrom =
$connection.startHandle?.nodeId === nodeId &&
$connection.startHandle?.type === type &&
$connection.startHandle?.handleId === handleId;
$connection.fromHandle?.nodeId === nodeId &&
$connection.fromHandle?.type === type &&
$connection.fromHandle?.id === handleId;
$: connectingTo =
$connection.endHandle?.nodeId === nodeId &&
$connection.endHandle?.type === type &&
$connection.endHandle?.handleId === handleId;
$connection.toHandle?.nodeId === nodeId &&
$connection.toHandle?.type === type &&
$connection.toHandle?.id === handleId;
$: isPossibleEndHandle =
$connectionMode === ConnectionMode.Strict
? $connection.startHandle?.type !== type
: nodeId !== $connection.startHandle?.nodeId ||
handleId !== $connection.startHandle?.handleId;
$: valid = connectingTo && $connection.status === 'valid';
? $connection.fromHandle?.type !== type
: nodeId !== $connection.fromHandle?.nodeId || handleId !== $connection.fromHandle?.id;
$: valid = connectingTo && $connection.isValid;
</script>
<!--

View File

@@ -1,7 +1,7 @@
import type { Readable } from 'svelte/store';
import { useStore } from '$lib/store';
import type { ConnectionProps } from '$lib/store/derived-connection-props';
import type { ConnectionState } from '@xyflow/system';
/**
* Hook for receiving the current connection.
@@ -9,7 +9,7 @@ import type { ConnectionProps } from '$lib/store/derived-connection-props';
* @public
* @returns current connection as a readable store
*/
export function useConnection(): Readable<ConnectionProps> {
export function useConnection(): Readable<ConnectionState> {
const { connection } = useStore();
return connection;

View File

@@ -1,130 +0,0 @@
import { derived, type Writable } from 'svelte/store';
import {
getBezierPath,
getSmoothStepPath,
getStraightPath,
ConnectionLineType,
ConnectionMode,
Position,
type HandleElement
} from '@xyflow/system';
import type { SvelteFlowStoreState } from './types';
import type { ConnectionData } from '$lib/types';
export type ConnectionProps = {
path: string | null;
sourceX: number | null;
sourceY: number | null;
sourcePosition: Position | undefined | null;
targetX: number | null;
targetY: number | null;
targetPosition: Position | undefined | null;
pointerPosition: ConnectionData['connectionPosition'] | null;
startHandle: ConnectionData['connectionStartHandle'] | null;
endHandle: ConnectionData['connectionEndHandle'] | null;
status: ConnectionData['connectionStatus'] | null;
};
export const initConnectionProps = {
path: null,
sourceX: null,
sourceY: null,
sourcePosition: null,
targetX: null,
targetY: null,
targetPosition: null,
pointerPosition: null,
startHandle: null,
endHandle: null,
status: null
};
const oppositePosition = {
[Position.Left]: Position.Right,
[Position.Right]: Position.Left,
[Position.Top]: Position.Bottom,
[Position.Bottom]: Position.Top
};
export function getDerivedConnectionProps(
store: SvelteFlowStoreState,
currentConnection: Writable<ConnectionData>
) {
return derived(
[
currentConnection,
store.connectionLineType,
store.connectionMode,
store.nodeLookup,
store.viewport
],
([connection, connectionLineType, connectionMode, nodeLookup, viewport]) => {
if (!connection.connectionStartHandle?.nodeId) {
return initConnectionProps;
}
// TODO: it should bail out if the node is not found
const fromNode = nodeLookup.get(connection.connectionStartHandle?.nodeId);
const fromHandleBounds = fromNode?.internals.handleBounds;
const handleBoundsStrict =
fromHandleBounds?.[connection.connectionStartHandle.type || 'source'] || [];
const handleBoundsLoose: HandleElement[] | undefined | null = handleBoundsStrict
? handleBoundsStrict
: fromHandleBounds?.[
connection?.connectionStartHandle?.type === 'source' ? 'target' : 'source'
];
const handleBounds =
connectionMode === ConnectionMode.Strict ? handleBoundsStrict : handleBoundsLoose;
const fromHandle = connection.connectionStartHandle?.handleId
? handleBounds?.find((d) => d.id === connection.connectionStartHandle?.handleId)
: handleBounds?.[0];
const fromHandleX = fromHandle
? fromHandle.x + fromHandle.width / 2
: (fromNode?.measured.width ?? 0) / 2;
const fromHandleY = fromHandle
? fromHandle.y + fromHandle.height / 2
: fromNode?.measured.height ?? 0;
const fromX = (fromNode?.internals.positionAbsolute.x ?? 0) + fromHandleX;
const fromY = (fromNode?.internals.positionAbsolute.y ?? 0) + fromHandleY;
const fromPosition = fromHandle?.position;
const toPosition =
connection.connectionEndHandle?.position ??
(fromPosition ? oppositePosition[fromPosition] : undefined);
const pathParams = {
sourceX: fromX,
sourceY: fromY,
sourcePosition: fromPosition,
targetX: ((connection.connectionPosition?.x ?? 0) - viewport.x) / viewport.zoom,
targetY: ((connection.connectionPosition?.y ?? 0) - viewport.y) / viewport.zoom,
targetPosition: toPosition
};
let path = '';
if (connectionLineType === ConnectionLineType.Bezier) {
// we assume the destination position is opposite to the source position
[path] = getBezierPath(pathParams);
} else if (connectionLineType === ConnectionLineType.Step) {
[path] = getSmoothStepPath({
...pathParams,
borderRadius: 0
});
} else if (connectionLineType === ConnectionLineType.SmoothStep) {
[path] = getSmoothStepPath(pathParams);
} else {
[path] = getStraightPath(pathParams);
}
return {
path,
...pathParams,
pointerPosition: connection.connectionPosition,
startHandle: connection.connectionStartHandle,
endHandle: connection.connectionEndHandle,
status: connection.connectionStatus
};
}
);
}

View File

@@ -7,6 +7,9 @@ import {
panBy as panBySystem,
updateNodeInternals as updateNodeInternalsSystem,
addEdge as addEdgeUtil,
initialConnection,
errorMessages,
pointToRendererPoint,
type UpdateNodePositions,
type InternalNodeUpdate,
type ViewportHelperFunctionOptions,
@@ -14,18 +17,16 @@ import {
type XYPosition,
type CoordinateExtent,
type UpdateConnection,
errorMessages,
type NodeOrigin,
type ConnectionState
type ConnectionState,
type NodeOrigin
} from '@xyflow/system';
import type { EdgeTypes, NodeTypes, Node, Edge, FitViewOptions, ConnectionData } from '$lib/types';
import type { EdgeTypes, NodeTypes, Node, Edge, FitViewOptions } from '$lib/types';
import { initialEdgeTypes, initialNodeTypes, getInitialStore } from './initial-store';
import type { SvelteFlowStore } from './types';
import { syncNodeStores, syncEdgeStores, syncViewportStores } from './utils';
import { getVisibleEdges } from './visible-edges';
import { getVisibleNodes } from './visible-nodes';
import { getDerivedConnectionProps } from './derived-connection-props';
export const key = Symbol();
@@ -341,27 +342,13 @@ export function createStore({
});
}
const initConnectionUpdateData = {
connectionStartHandle: null,
connectionEndHandle: null,
connectionPosition: null,
connectionStatus: null
};
// by creating an internal, unexposed store and using a derived store
// we prevent using slow get() calls
const currentConnection = writable<ConnectionData>(initConnectionUpdateData);
const _connection = writable<ConnectionState>(initialConnection);
const updateConnection: UpdateConnection = (newConnection: ConnectionState) => {
currentConnection.set({
connectionStartHandle: newConnection.fromHandle,
connectionEndHandle: newConnection.toHandle,
connectionPosition: newConnection.position,
connectionStatus: newConnection.isValid ? 'valid' : 'invalid'
});
_connection.set({ ...newConnection });
};
function cancelConnection() {
currentConnection.set(initConnectionUpdateData);
_connection.set(initialConnection);
}
function reset() {
@@ -380,9 +367,16 @@ export function createStore({
...store,
// derived state
connection: getDerivedConnectionProps(store, currentConnection),
visibleEdges: getVisibleEdges(store),
visibleNodes: getVisibleNodes(store),
connection: derived([_connection, store.viewport], ([connection, viewport]) => {
return connection.inProgress
? {
...connection,
to: pointToRendererPoint(connection.to, [viewport.x, viewport.y, viewport.zoom])
}
: { ...connection };
}),
markers: derived(
[store.edges, store.defaultMarkerColor, store.flowId],
([edges, defaultColor, id]) => createMarkerIds(edges, { defaultColor, id })

View File

@@ -8,6 +8,7 @@ import {
adoptUserNodes,
getViewportForBounds,
updateConnectionLookup,
initialConnection,
type SelectionRect,
type SnapGrid,
type MarkerProps,
@@ -22,6 +23,7 @@ import {
type OnConnectEnd,
type NodeLookup,
type EdgeLookup,
type ConnectionState,
type ParentLookup,
getInternalNodesBounds
} from '@xyflow/system';
@@ -52,7 +54,6 @@ import type {
InternalNode
} from '$lib/types';
import { createNodesStore, createEdgesStore } from './utils';
import { initConnectionProps, type ConnectionProps } from './derived-connection-props';
export const initialNodeTypes = {
input: InputNode,
@@ -143,7 +144,7 @@ export const getInitialStore = ({
viewport: writable<Viewport>(viewport),
connectionMode: writable<ConnectionMode>(ConnectionMode.Strict),
domNode: writable<HTMLDivElement | null>(null),
connection: readable<ConnectionProps>(initConnectionProps),
connection: readable<ConnectionState>(initialConnection),
connectionLineType: writable<ConnectionLineType>(ConnectionLineType.Bezier),
connectionRadius: writable<number>(20),
isValidConnection: writable<IsValidConnection>(() => true),

View File

@@ -2,7 +2,7 @@ import type { ShortcutModifierDefinition } from '@svelte-put/shortcut';
import type {
FitViewOptionsBase,
XYPosition,
ConnectingHandle,
Handle,
Connection,
OnBeforeDeleteBase,
HandleProps as HandlePropsSystem
@@ -17,8 +17,8 @@ export type KeyDefinition = string | KeyDefinitionObject;
export type ConnectionData = {
connectionPosition: XYPosition | null;
connectionStartHandle: ConnectingHandle | null;
connectionEndHandle: ConnectingHandle | null;
connectionStartHandle: Handle | null;
connectionEndHandle: Handle | null;
connectionStatus: string | null;
};

View File

@@ -6,9 +6,9 @@ import type { ZoomBehavior } from 'd3-zoom';
// eslint-disable-next-line @typescript-eslint/no-unused-vars
import type { Transition } from 'd3-transition';
import type { XYPosition, Rect } from './utils';
import type { XYPosition, Rect, Position } from './utils';
import type { InternalNodeBase, NodeBase, NodeDragItem } from './nodes';
import type { ConnectingHandle, HandleType } from './handles';
import type { Handle, HandleType } from './handles';
import { PanZoomInstance } from './panzoom';
import { EdgeBase } from '..';
@@ -132,21 +132,50 @@ export type OnError = (id: string, message: string) => void;
export type UpdateNodePositions = (dragItems: Map<string, NodeDragItem | InternalNodeBase>, dragging?: boolean) => void;
export type PanBy = (delta: XYPosition) => boolean;
export type NoConnectionInProgress = {
position: XYPosition;
export const initialConnection: NoConnection = {
inProgress: false,
isValid: null,
from: null,
fromHandle: null,
fromPosition: null,
fromNode: null,
to: null,
toHandle: null,
toPosition: null,
toNode: null,
};
export type NoConnection = {
inProgress: false;
isValid: null;
from: null;
fromHandle: null;
fromPosition: null;
fromNode: null;
to: null;
toHandle: null;
toPosition: null;
toNode: null;
};
export type ConnectionInProgress = {
position: XYPosition;
inProgress: true;
isValid: boolean | null;
fromHandle: ConnectingHandle;
toHandle: ConnectingHandle | null;
from: XYPosition;
fromHandle: Handle;
fromPosition: Position;
fromNode: NodeBase;
to: XYPosition;
toHandle: Handle | null;
toPosition: Position;
toNode: NodeBase | null;
};
export type ConnectionState = ConnectionInProgress | NoConnectionInProgress;
export type ConnectionState = ConnectionInProgress | NoConnection;
export type UpdateConnection = (params: ConnectionState) => void;

View File

@@ -2,29 +2,15 @@ import type { Position, IsValidConnection } from '.';
export type HandleType = 'source' | 'target';
export type HandleElement = {
export type Handle = {
id?: string | null;
nodeId: string;
x: number;
y: number;
position: Position;
type: HandleType;
width: number;
height: number;
position: Position;
type?: HandleType;
};
export type ConnectingHandle = {
nodeId: string;
type: HandleType;
handleId?: string | null;
position?: Position | null;
};
export type ConnectionHandle = {
id: string | null;
type: HandleType;
nodeId: string;
x: number;
y: number;
};
export type HandleProps = {

View File

@@ -1,4 +1,4 @@
import type { XYPosition, Position, CoordinateExtent, HandleElement } from '.';
import type { XYPosition, Position, CoordinateExtent, Handle } from '.';
import { Optional } from '../utils/types';
/**
@@ -111,8 +111,8 @@ export type NodeProps<NodeType extends NodeBase> = Pick<
};
export type NodeHandleBounds = {
source: HandleElement[] | null;
target: HandleElement[] | null;
source: Handle[] | null;
target: Handle[] | null;
};
export type InternalNodeUpdate = {
@@ -149,7 +149,7 @@ export type NodeOrigin = [number, number];
export type OnSelectionDrag = (event: MouseEvent, nodes: NodeBase[]) => void;
export type NodeHandle = Optional<HandleElement, 'width' | 'height'>;
export type NodeHandle = Omit<Optional<Handle, 'width' | 'height'>, 'nodeId'>;
export type Align = 'center' | 'start' | 'end';

View File

@@ -5,6 +5,13 @@ export enum Position {
Bottom = 'bottom',
}
export const oppositePosition = {
[Position.Left]: Position.Right,
[Position.Right]: Position.Left,
[Position.Top]: Position.Bottom,
[Position.Bottom]: Position.Top,
};
export type XYPosition = {
x: number;
y: number;

View File

@@ -51,3 +51,7 @@ export function handleConnectionChange(
cb(diff);
}
}
export function getConnectionStatus(isValid: boolean | null) {
return isValid === null ? null : isValid ? 'valid' : 'invalid';
}

View File

@@ -1,4 +1,4 @@
import type { Transform, XYPosition, SnapGrid, Dimensions, HandleElement, Position } from '../types';
import type { Transform, XYPosition, SnapGrid, Dimensions, Position, Handle } from '../types';
import { snapPosition, pointToRendererPoint } from './general';
export type GetPointerPositionParams = {
@@ -59,22 +59,25 @@ export const getEventPosition = (event: MouseEvent | TouchEvent, bounds?: DOMRec
// We store them in the internals object of the node in order to avoid
// unnecessary recalculations.
export const getHandleBounds = (
selector: string,
type: 'source' | 'target',
nodeElement: HTMLDivElement,
nodeBounds: DOMRect,
zoom: number
): HandleElement[] | null => {
const handles = nodeElement.querySelectorAll(selector);
zoom: number,
nodeId: string
): Handle[] | null => {
const handles = nodeElement.querySelectorAll(`.${type}`);
if (!handles || !handles.length) {
return null;
}
return Array.from(handles).map((handle): HandleElement => {
return Array.from(handles).map((handle): Handle => {
const handleBounds = handle.getBoundingClientRect();
return {
id: handle.getAttribute('data-handleid'),
type,
nodeId,
position: handle.getAttribute('data-handlepos') as unknown as Position,
x: (handleBounds.left - nodeBounds.left) / zoom,
y: (handleBounds.top - nodeBounds.top) / zoom,

View File

@@ -1,9 +1,9 @@
import { EdgePosition } from '../../types/edges';
import { ConnectionMode, OnError } from '../../types/general';
import { InternalNodeBase, NodeHandle } from '../../types/nodes';
import { Position } from '../../types/utils';
import { Position, XYPosition } from '../../types/utils';
import { errorMessages } from '../../constants';
import { HandleElement } from '../../types';
import { Handle } from '../../types';
import { getNodeDimensions } from '../general';
export type GetEdgePositionParams = {
@@ -58,14 +58,14 @@ export function getEdgePosition(params: GetEdgePositionParams): EdgePosition | n
const sourcePosition = sourceHandle?.position || Position.Bottom;
const targetPosition = targetHandle?.position || Position.Top;
const [sourceX, sourceY] = getHandlePosition(sourceNode, sourceHandle, sourcePosition);
const [targetX, targetY] = getHandlePosition(targetNode, targetHandle, targetPosition);
const source = getHandlePosition(sourceNode, sourceHandle, sourcePosition);
const target = getHandlePosition(targetNode, targetHandle, targetPosition);
return {
sourceX,
sourceY,
targetX,
targetY,
sourceX: source.x,
sourceY: source.y,
targetX: target.x,
targetY: target.y,
sourcePosition,
targetPosition,
};
@@ -84,9 +84,9 @@ function toHandleBounds(handles?: NodeHandle[]) {
handle.height = handle.height ?? 1;
if (handle.type === 'source') {
source.push(handle as HandleElement);
source.push(handle as Handle);
} else if (handle.type === 'target') {
target.push(handle as HandleElement);
target.push(handle as Handle);
}
}
@@ -98,27 +98,33 @@ function toHandleBounds(handles?: NodeHandle[]) {
export function getHandlePosition(
node: InternalNodeBase,
handle: HandleElement | null,
fallbackPosition: Position = Position.Left
): number[] {
handle: Handle | null,
fallbackPosition: Position = Position.Left,
center = false
): XYPosition {
const x = (handle?.x ?? 0) + node.internals.positionAbsolute.x;
const y = (handle?.y ?? 0) + node.internals.positionAbsolute.y;
const { width, height } = handle ?? getNodeDimensions(node);
if (center) {
return { x: x + width / 2, y: y + height / 2 };
}
const position = handle?.position ?? fallbackPosition;
switch (position) {
case Position.Top:
return [x + width / 2, y];
return { x: x + width / 2, y };
case Position.Right:
return [x + width, y + height / 2];
return { x: x + width, y: y + height / 2 };
case Position.Bottom:
return [x + width / 2, y + height];
return { x: x + width / 2, y: y + height };
case Position.Left:
return [x, y + height / 2];
return { x, y: y + height / 2 };
}
}
function getHandle(bounds: HandleElement[], handleId?: string | null): HandleElement | null {
function getHandle(bounds: Handle[], handleId?: string | null): Handle | null {
if (!bounds) {
return null;
}

View File

@@ -288,8 +288,8 @@ export function updateNodeInternals<NodeType extends InternalNodeBase>(
...node.internals,
positionAbsolute: getNodePositionWithOrigin(node, nodeOrigin),
handleBounds: {
source: getHandleBounds('.source', update.nodeElement, nodeBounds, zoom),
target: getHandleBounds('.target', update.nodeElement, nodeBounds, zoom),
source: getHandleBounds('source', update.nodeElement, nodeBounds, zoom, node.id),
target: getHandleBounds('target', update.nodeElement, nodeBounds, zoom, node.id),
},
};
if (node.parentId) {

View File

@@ -1,75 +1,25 @@
import { pointToRendererPoint, rendererPointToPoint, getHostForElement, calcAutoPan, getEventPosition } from '../utils';
import {
pointToRendererPoint,
getHostForElement,
calcAutoPan,
getEventPosition,
getHandlePosition,
rendererPointToPoint,
} from '../utils';
import {
ConnectionMode,
type OnConnect,
type OnConnectStart,
type HandleType,
type Connection,
type PanBy,
type Transform,
type ConnectingHandle,
type OnConnectEnd,
type UpdateConnection,
type IsValidConnection,
type ConnectionHandle,
NodeLookup,
Position,
oppositePosition,
ConnectionInProgress,
type Handle,
type Connection,
} from '../types';
import { getClosestHandle, isConnectionValid, getHandleLookup, getHandleType } from './utils';
export type OnPointerDownParams = {
autoPanOnConnect: boolean;
connectionMode: ConnectionMode;
connectionRadius: number;
domNode: HTMLDivElement | null;
handleId: string | null;
nodeId: string;
isTarget: boolean;
nodeLookup: NodeLookup;
lib: string;
flowId: string | null;
edgeUpdaterType?: HandleType;
updateConnection: UpdateConnection;
panBy: PanBy;
cancelConnection: () => void;
onConnectStart?: OnConnectStart;
onConnect?: OnConnect;
onConnectEnd?: OnConnectEnd;
isValidConnection?: IsValidConnection;
onReconnectEnd?: (evt: MouseEvent | TouchEvent) => void;
getTransform: () => Transform;
getFromHandle: () => ConnectingHandle | null;
};
export type IsValidParams = {
handle: Pick<ConnectionHandle, 'nodeId' | 'id' | 'type'> | null;
connectionMode: ConnectionMode;
fromNodeId: string;
fromHandleId: string | null;
fromType: HandleType;
isValidConnection?: IsValidConnection;
doc: Document | ShadowRoot;
lib: string;
flowId: string | null;
};
export type XYHandleInstance = {
onPointerDown: (event: MouseEvent | TouchEvent, params: OnPointerDownParams) => void;
isValid: (event: MouseEvent | TouchEvent, params: IsValidParams) => Result;
};
type Result = {
handleDomNode: Element | null;
isValid: boolean;
connection: Connection | null;
toHandle: ConnectingHandle | null;
};
import { IsValidParams, OnPointerDownParams, Result, XYHandleInstance } from './types';
const alwaysValid = () => true;
let fromHandle: ConnectingHandle | null = null;
function onPointerDown(
event: MouseEvent | TouchEvent,
{
@@ -99,7 +49,7 @@ function onPointerDown(
// when xyflow is used inside a shadow root we can't use document
const doc = getHostForElement(event.target as HTMLElement);
let autoPanId = 0;
let closestHandle: ConnectionHandle | null;
let closestHandle: Handle | null;
const { x, y } = getEventPosition(event);
const clickedHandle = doc?.elementFromPoint(x, y);
@@ -113,10 +63,10 @@ function onPointerDown(
let position = getEventPosition(event, containerBounds);
let autoPanStarted = false;
let connection: Connection | null = null;
let isValid = false;
let isValid: boolean | null = false;
let handleDomNode: Element | null = null;
const handleLookup = getHandleLookup({
const [handleLookup, fromHandleInternal] = getHandleLookup({
nodeLookup,
nodeId,
handleId,
@@ -135,19 +85,34 @@ function onPointerDown(
}
// Stays the same for all consecutive pointermove events
fromHandle = {
const fromHandle: Handle = {
...fromHandleInternal,
nodeId,
handleId,
type: handleType,
position: (clickedHandle?.getAttribute('data-handlepos') as Position) ?? Position.Top,
position: fromHandleInternal.position,
};
updateConnection({
position,
const fromNodeInternal = nodeLookup.get(nodeId)!;
const from = getHandlePosition(fromNodeInternal, fromHandle, Position.Left, true);
const newConnection: ConnectionInProgress = {
inProgress: true,
isValid: null,
from,
fromHandle,
fromPosition: fromHandle.position,
fromNode: fromNodeInternal.internals.userNode,
to: position,
toHandle: null,
});
toPosition: oppositePosition[fromHandle.position],
toNode: null,
};
updateConnection(newConnection);
let previousConnection: ConnectionInProgress = newConnection;
onConnectStart?.(event, { nodeId, handleId, handleType });
@@ -180,27 +145,41 @@ function onPointerDown(
doc,
lib,
flowId,
handleLookup,
});
handleDomNode = result.handleDomNode;
connection = result.connection;
isValid = result.isValid;
isValid = isConnectionValid(!!closestHandle, result.isValid);
updateConnection({
fromHandle,
position:
const newConnection: ConnectionInProgress = {
// from stays the same
...previousConnection,
isValid,
to:
closestHandle && isValid
? rendererPointToPoint(
{
x: closestHandle.x,
y: closestHandle.y,
},
transform
)
? rendererPointToPoint({ x: closestHandle.x, y: closestHandle.y }, transform)
: position,
isValid: isConnectionValid(!!closestHandle, isValid),
toHandle: result.toHandle,
});
toPosition: isValid && result.toHandle ? result.toHandle.position : oppositePosition[fromHandle.position],
toNode: result.toHandle ? nodeLookup.get(result.toHandle.nodeId)!.internals.userNode : null,
};
// we don't want to trigger an update when the connection
// is snapped to the same handle as before
if (
isValid &&
closestHandle &&
previousConnection.toHandle &&
newConnection.toHandle &&
previousConnection.toHandle.nodeId === newConnection.toHandle.nodeId &&
previousConnection.toHandle.id === newConnection.toHandle.id
) {
return;
}
updateConnection(newConnection);
previousConnection = newConnection;
}
function onPointerUp(event: MouseEvent | TouchEvent) {
@@ -222,7 +201,6 @@ function onPointerDown(
isValid = false;
connection = null;
handleDomNode = null;
fromHandle = null;
doc.removeEventListener('mousemove', onPointerMove as EventListener);
doc.removeEventListener('mouseup', onPointerUp as EventListener);
@@ -251,6 +229,7 @@ function isValidHandle(
lib,
flowId,
isValidConnection = alwaysValid,
handleLookup,
}: IsValidParams
) {
const isTarget = fromType === 'target';
@@ -301,12 +280,17 @@ function isValidHandle(
result.isValid = isValid && isValidConnection(connection);
result.toHandle = {
nodeId: handleNodeId as string,
handleId,
type: handleType as HandleType,
position: handleToCheck.getAttribute('data-handlepos') as Position,
};
if (handleLookup) {
const toHandle = handleLookup.find(
(h) => h.id === handleId && h.nodeId === handleNodeId && h.type === handleType
);
if (toHandle) {
result.toHandle = {
...toHandle,
};
}
}
}
return result;

View File

@@ -0,0 +1,63 @@
import {
ConnectionMode,
type Connection,
type OnConnect,
type OnConnectStart,
type HandleType,
type PanBy,
type Transform,
type Handle,
type OnConnectEnd,
type UpdateConnection,
type IsValidConnection,
NodeLookup,
} from '../types';
export type OnPointerDownParams = {
autoPanOnConnect: boolean;
connectionMode: ConnectionMode;
connectionRadius: number;
domNode: HTMLDivElement | null;
handleId: string | null;
nodeId: string;
isTarget: boolean;
nodeLookup: NodeLookup;
lib: string;
flowId: string | null;
edgeUpdaterType?: HandleType;
updateConnection: UpdateConnection;
panBy: PanBy;
cancelConnection: () => void;
onConnectStart?: OnConnectStart;
onConnect?: OnConnect;
onConnectEnd?: OnConnectEnd;
isValidConnection?: IsValidConnection;
onReconnectEnd?: (evt: MouseEvent | TouchEvent) => void;
getTransform: () => Transform;
getFromHandle: () => Handle | null;
};
export type IsValidParams = {
handle: Pick<Handle, 'nodeId' | 'id' | 'type'> | null;
connectionMode: ConnectionMode;
fromNodeId: string;
fromHandleId: string | null;
fromType: HandleType;
isValidConnection?: IsValidConnection;
doc: Document | ShadowRoot;
lib: string;
flowId: string | null;
handleLookup?: Handle[];
};
export type XYHandleInstance = {
onPointerDown: (event: MouseEvent | TouchEvent, params: OnPointerDownParams) => void;
isValid: (event: MouseEvent | TouchEvent, params: IsValidParams) => Result;
};
export type Result = {
handleDomNode: Element | null;
isValid: boolean;
connection: Connection | null;
toHandle: Handle | null;
};

View File

@@ -3,40 +3,34 @@ import {
type HandleType,
type NodeHandleBounds,
type XYPosition,
type ConnectionHandle,
type Handle,
InternalNodeBase,
NodeLookup,
} from '../types';
// this functions collects all handles and adds an absolute position
// so that we can later find the closest handle to the mouse position
export function getHandles(
function getHandles(
node: InternalNodeBase,
handleBounds: NodeHandleBounds,
type: HandleType,
currentHandle: string
): ConnectionHandle[] {
return (handleBounds[type] || []).reduce<ConnectionHandle[]>((res, handle) => {
if (`${node.id}-${handle.id}-${type}` !== currentHandle) {
const [x, y] = getHandlePosition(node, handle);
res.push({
id: handle.id || null,
type,
nodeId: node.id,
x,
y,
});
currentHandle: { nodeId: string; handleId: string | null; handleType: HandleType }
): [Handle[], Handle | null] {
let excludedHandle = null;
const handles = (handleBounds[type] || []).reduce<Handle[]>((res, handle) => {
if (node.id === currentHandle.nodeId && type === currentHandle.handleType && handle.id === currentHandle.handleId) {
excludedHandle = handle;
} else {
const handleXY = getHandlePosition(node, handle, handle.position, true);
res.push({ ...handle, ...handleXY });
}
return res;
}, []);
return [handles, excludedHandle];
}
export function getClosestHandle(
pos: XYPosition,
connectionRadius: number,
handles: ConnectionHandle[]
): ConnectionHandle | null {
let closestHandles: ConnectionHandle[] = [];
export function getClosestHandle(pos: XYPosition, connectionRadius: number, handles: Handle[]): Handle | null {
let closestHandles: Handle[] = [];
let minDistance = Infinity;
for (const handle of handles) {
@@ -66,7 +60,7 @@ type GetHandleLookupParams = {
nodeLookup: NodeLookup;
nodeId: string;
handleId: string | null;
handleType: string;
handleType: HandleType;
};
export function getHandleLookup({
@@ -74,19 +68,21 @@ export function getHandleLookup({
nodeId,
handleId,
handleType,
}: GetHandleLookupParams): ConnectionHandle[] {
const connectionHandles: ConnectionHandle[] = [];
}: GetHandleLookupParams): [Handle[], Handle] {
const connectionHandles: Handle[] = [];
const currentHandle = { nodeId, handleId, handleType };
let excludedHandle: Handle | null = null;
for (const [, node] of nodeLookup) {
for (const node of nodeLookup.values()) {
if (node.internals.handleBounds) {
const id = `${nodeId}-${handleId}-${handleType}`;
const sourceHandles = getHandles(node, node.internals.handleBounds, 'source', id);
const targetHandles = getHandles(node, node.internals.handleBounds, 'target', id);
const [sourceHandles, excludedSource] = getHandles(node, node.internals.handleBounds, 'source', currentHandle);
const [targetHandles, excludedTarget] = getHandles(node, node.internals.handleBounds, 'target', currentHandle);
excludedHandle = excludedHandle ? excludedHandle : excludedSource ?? excludedTarget;
connectionHandles.push(...sourceHandles, ...targetHandles);
}
}
return connectionHandles;
return [connectionHandles, excludedHandle!];
}
export function getHandleType(