) => void;
+ cancelConnection: () => void;
+ panBy: (delta: XYPosition) => void;
+ edgeUpdaterType?: HandleType;
+ onEdgeUpdateEnd?: (evt: MouseEvent | TouchEvent) => void;
+}): void {
+ // when react-flow is used inside a shadow root we can't use document
+ const doc = getHostForElement(event.target as HTMLElement);
+ let autoPanId = 0;
+ let prevClosestHandle: ConnectionHandle | null;
+
+ const { x, y } = getEventPosition(event);
+ const clickedHandle = doc?.elementFromPoint(x, y);
+ const handleType = getHandleType(edgeUpdaterType, clickedHandle);
+ const containerBounds = domNode?.getBoundingClientRect();
+
+ if (!containerBounds || !handleType) {
+ return;
+ }
+
+ let prevActiveHandle: Element;
+ let connectionPosition = getEventPosition(event, containerBounds);
+ let autoPanStarted = false;
+ let connection: Connection | null = null;
+ let isValid = false;
+ let handleDomNode: Element | null = null;
+
+ const autoPanOnConnect = true;
+
+ const handleLookup = getHandleLookup({
+ nodes,
+ nodeId,
+ handleId,
+ handleType
+ });
+
+ // when the user is moving the mouse close to the edge of the canvas while connecting we move the canvas
+ const autoPan = (): void => {
+ // @todd add prop
+ if (!autoPanOnConnect) {
+ return;
+ }
+ const [xMovement, yMovement] = calcAutoPan(connectionPosition, containerBounds);
+
+ panBy({ x: xMovement, y: yMovement });
+ autoPanId = requestAnimationFrame(autoPan);
+ };
+
+ updateConnection({
+ position: connectionPosition,
+ nodeId,
+ handleId,
+ handleType,
+ status: null
+ });
+
+ // @todo add prop
+ // onConnectStart?.(event, { nodeId, handleId, handleType });
+
+ function onPointerMove(event: MouseEvent | TouchEvent) {
+ const transform = get(transformStore);
+ connectionPosition = getEventPosition(event, containerBounds);
+
+ prevClosestHandle = getClosestHandle(
+ pointToRendererPoint(connectionPosition, transform, false, [1, 1]),
+ connectionRadius,
+ handleLookup
+ );
+
+ if (!autoPanStarted) {
+ autoPan();
+ autoPanStarted = true;
+ }
+
+ const result = isValidHandle(
+ event,
+ prevClosestHandle,
+ connectionMode,
+ nodeId,
+ handleId,
+ isTarget ? 'target' : 'source',
+ isValidConnection,
+ doc
+ );
+
+ handleDomNode = result.handleDomNode;
+ connection = result.connection;
+ isValid = result.isValid;
+
+ updateConnection({
+ position:
+ prevClosestHandle && isValid
+ ? rendererPointToPoint(
+ {
+ x: prevClosestHandle.x,
+ y: prevClosestHandle.y
+ },
+ transform
+ )
+ : connectionPosition,
+ status: getConnectionStatus(!!prevClosestHandle, isValid)
+ });
+
+ if (!prevClosestHandle && !isValid && !handleDomNode) {
+ return resetRecentHandle(prevActiveHandle);
+ }
+
+ if (connection.source !== connection.target && handleDomNode) {
+ resetRecentHandle(prevActiveHandle);
+ prevActiveHandle = handleDomNode;
+ // @todo: remove the old class names "react-flow__handle-" in the next major version
+ handleDomNode.classList.add('connecting');
+ handleDomNode.classList.toggle('valid', isValid);
+ }
+ }
+
+ function onPointerUp(event: MouseEvent | TouchEvent) {
+ if ((prevClosestHandle || handleDomNode) && connection && isValid) {
+ onConnect?.(connection);
+ }
+
+ // it's important to get a fresh reference from the store here
+ // in order to get the latest state of onConnectEnd
+ // @todo add onConnectEnd prop
+ // getState().onConnectEnd?.(event);
+
+ if (edgeUpdaterType) {
+ onEdgeUpdateEnd?.(event);
+ }
+
+ resetRecentHandle(prevActiveHandle);
+ cancelConnection();
+ cancelAnimationFrame(autoPanId);
+ autoPanStarted = false;
+ isValid = false;
+ connection = null;
+ handleDomNode = null;
+
+ doc.removeEventListener('mousemove', onPointerMove as EventListener);
+ doc.removeEventListener('mouseup', onPointerUp as EventListener);
+
+ doc.removeEventListener('touchmove', onPointerMove as EventListener);
+ doc.removeEventListener('touchend', onPointerUp as EventListener);
+ }
+
+ doc.addEventListener('mousemove', onPointerMove as EventListener);
+ doc.addEventListener('mouseup', onPointerUp as EventListener);
+
+ doc.addEventListener('touchmove', onPointerMove as EventListener);
+ doc.addEventListener('touchend', onPointerUp as EventListener);
+}
diff --git a/packages/svelte/src/lib/components/Handle/index.svelte b/packages/svelte/src/lib/components/Handle/index.svelte
index 3658bce5..496c0581 100644
--- a/packages/svelte/src/lib/components/Handle/index.svelte
+++ b/packages/svelte/src/lib/components/Handle/index.svelte
@@ -1,21 +1,77 @@
+ class:connectable={isConnectable}
+ on:mousedown={onPointerDown}
+ on:touchstart={onPointerDown}
+>
@@ -46,6 +106,11 @@
border-radius: 100%;
}
+ .connectable {
+ pointer-events: all;
+ cursor: crosshair;
+ }
+
.bottom {
top: 100%;
left: 50%;
diff --git a/packages/svelte/src/lib/components/Handle/utils.ts b/packages/svelte/src/lib/components/Handle/utils.ts
new file mode 100644
index 00000000..adcc231d
--- /dev/null
+++ b/packages/svelte/src/lib/components/Handle/utils.ts
@@ -0,0 +1,188 @@
+import { internalsSymbol, ConnectionMode, type ConnectionStatus } from '@reactflow/system';
+import type { Connection, HandleType, XYPosition, Node, NodeHandleBounds } from '@reactflow/system';
+import { getEventPosition } from '@reactflow/utils';
+
+export type ConnectionHandle = {
+ id: string | null;
+ type: HandleType;
+ nodeId: string;
+ x: number;
+ y: number;
+};
+
+export type ValidConnectionFunc = (connection: Connection) => boolean;
+
+// 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(
+ node: Node,
+ handleBounds: NodeHandleBounds,
+ type: HandleType,
+ currentHandle: string
+): ConnectionHandle[] {
+ return (handleBounds[type] || []).reduce((res, h) => {
+ if (`${node.id}-${h.id}-${type}` !== currentHandle) {
+ res.push({
+ id: h.id || null,
+ type,
+ nodeId: node.id,
+ x: (node.positionAbsolute?.x ?? 0) + h.x + h.width / 2,
+ y: (node.positionAbsolute?.y ?? 0) + h.y + h.height / 2
+ });
+ }
+ return res;
+ }, []);
+}
+
+export function getClosestHandle(
+ pos: XYPosition,
+ connectionRadius: number,
+ handles: ConnectionHandle[]
+): ConnectionHandle | null {
+ let closestHandle: ConnectionHandle | null = null;
+ let minDistance = Infinity;
+
+ handles.forEach((handle) => {
+ const distance = Math.sqrt(Math.pow(handle.x - pos.x, 2) + Math.pow(handle.y - pos.y, 2));
+ if (distance <= connectionRadius && distance < minDistance) {
+ minDistance = distance;
+ closestHandle = handle;
+ }
+ });
+
+ return closestHandle;
+}
+
+type Result = {
+ handleDomNode: Element | null;
+ isValid: boolean;
+ connection: Connection;
+};
+
+const nullConnection: Connection = {
+ source: null,
+ target: null,
+ sourceHandle: null,
+ targetHandle: null
+};
+
+// checks if and returns connection in fom of an object { source: 123, target: 312 }
+export function isValidHandle(
+ event: MouseEvent | TouchEvent,
+ handle: Pick | null,
+ connectionMode: ConnectionMode,
+ fromNodeId: string,
+ fromHandleId: string | null,
+ fromType: string,
+ isValidConnection: ValidConnectionFunc,
+ doc: Document | ShadowRoot
+) {
+ const isTarget = fromType === 'target';
+ const handleDomNode = doc.querySelector(
+ `.react-flow__handle[data-id="${handle?.nodeId}-${handle?.id}-${handle?.type}"]`
+ );
+ const { x, y } = getEventPosition(event);
+ const handleBelow = doc.elementFromPoint(x, y);
+ const handleToCheck = handleBelow?.classList.contains('react-flow__handle')
+ ? handleBelow
+ : handleDomNode;
+
+ const result: Result = {
+ handleDomNode: handleToCheck,
+ isValid: false,
+ connection: nullConnection
+ };
+
+ if (handleToCheck) {
+ const handleType = getHandleType(undefined, handleToCheck);
+ const handleNodeId = handleToCheck.getAttribute('data-nodeid');
+ const handleId = handleToCheck.getAttribute('data-handleid');
+
+ const connection: Connection = {
+ source: isTarget ? handleNodeId : fromNodeId,
+ sourceHandle: isTarget ? handleId : fromHandleId,
+ target: isTarget ? fromNodeId : handleNodeId,
+ targetHandle: isTarget ? fromHandleId : handleId
+ };
+
+ result.connection = connection;
+
+ // in strict mode we don't allow target to target or source to source connections
+ const isValid =
+ connectionMode === ConnectionMode.Strict
+ ? (isTarget && handleType === 'source') || (!isTarget && handleType === 'target')
+ : handleNodeId !== fromNodeId || handleId !== fromHandleId;
+
+ if (isValid) {
+ result.isValid = isValidConnection(connection);
+ }
+ }
+
+ return result;
+}
+
+type GetHandleLookupParams = {
+ nodes: Node[];
+ nodeId: string;
+ handleId: string | null;
+ handleType: string;
+};
+
+export function getHandleLookup({ nodes, nodeId, handleId, handleType }: GetHandleLookupParams) {
+ return nodes.reduce((res, node) => {
+ if (node[internalsSymbol]) {
+ const { handleBounds } = node[internalsSymbol];
+ let sourceHandles: ConnectionHandle[] = [];
+ let targetHandles: ConnectionHandle[] = [];
+
+ if (handleBounds) {
+ sourceHandles = getHandles(
+ node,
+ handleBounds,
+ 'source',
+ `${nodeId}-${handleId}-${handleType}`
+ );
+ targetHandles = getHandles(
+ node,
+ handleBounds,
+ 'target',
+ `${nodeId}-${handleId}-${handleType}`
+ );
+ }
+
+ res.push(...sourceHandles, ...targetHandles);
+ }
+ return res;
+ }, []);
+}
+
+export function getHandleType(
+ edgeUpdaterType: HandleType | undefined,
+ handleDomNode: Element | null
+): HandleType | null {
+ if (edgeUpdaterType) {
+ return edgeUpdaterType;
+ } else if (handleDomNode?.classList.contains('target')) {
+ return 'target';
+ } else if (handleDomNode?.classList.contains('source')) {
+ return 'source';
+ }
+
+ return null;
+}
+
+export function resetRecentHandle(handleDomNode: Element): void {
+ handleDomNode?.classList.remove('valid', 'connecting');
+}
+
+export function getConnectionStatus(isInsideConnectionRadius: boolean, isHandleValid: boolean) {
+ let connectionStatus = null;
+
+ if (isHandleValid) {
+ connectionStatus = 'valid';
+ } else if (isInsideConnectionRadius && !isHandleValid) {
+ connectionStatus = 'invalid';
+ }
+
+ return connectionStatus as ConnectionStatus;
+}
diff --git a/packages/svelte/src/lib/container/SvelteFlow.svelte b/packages/svelte/src/lib/container/SvelteFlow.svelte
index c0c23433..e49ec925 100644
--- a/packages/svelte/src/lib/container/SvelteFlow.svelte
+++ b/packages/svelte/src/lib/container/SvelteFlow.svelte
@@ -11,6 +11,7 @@
import UserSelection from '$lib/components/UserSelection/index.svelte';
import NodeSelection from '$lib/components/NodeSelection/index.svelte';
import { KeyHandler } from '$lib/components/KeyHandler';
+ import { ConnectionLine } from '$lib/components/ConnectionLine';
import type { SvelteFlowProps } from '$lib/types';
type $$Props = SvelteFlowProps;
@@ -26,7 +27,7 @@
let className: $$Props['class'] = undefined;
export { className as class };
- let domNode: Element;
+ let domNode: HTMLDivElement;
const store = createStore({
fitView,
@@ -41,6 +42,7 @@
const { width, height } = domNode.getBoundingClientRect();
store.widthStore.set(width);
store.heightStore.set(height);
+ store.domNodeStore.set(domNode);
// @todo: is this a svelte way for two way binding?
store.nodesStore.subscribe((ns) => {
@@ -69,6 +71,7 @@
+
diff --git a/packages/svelte/src/lib/store/index.ts b/packages/svelte/src/lib/store/index.ts
index 8839d3b9..6856f03e 100644
--- a/packages/svelte/src/lib/store/index.ts
+++ b/packages/svelte/src/lib/store/index.ts
@@ -12,19 +12,32 @@ import {
type D3SelectionInstance,
type ViewportHelperFunctionOptions,
type SelectionRect,
- type Node as RFNode
+ type Node as RFNode,
+ type Connection,
+ ConnectionMode,
+ type XYPosition,
+ type CoordinateExtent,
+ ConnectionLineType
} from '@reactflow/system';
-import { fitView, getConnectedEdges, getD3Transition, getDimensions } from '@reactflow/utils';
+import {
+ fitView as fitViewUtil,
+ getConnectedEdges,
+ getD3Transition,
+ getDimensions,
+ addEdge as addEdgeUtil
+} from '@reactflow/utils';
+import { zoomIdentity } from 'd3-zoom';
import { getHandleBounds } from '../../utils';
import { getEdgePositions, getHandle, getNodeData } from '$lib/container/EdgeRenderer/utils';
import DefaultNode from '$lib/components/nodes/DefaultNode.svelte';
import InputNode from '$lib/components/nodes/InputNode.svelte';
import OutputNode from '$lib/components/nodes/OutputNode.svelte';
-import type { EdgeTypes, NodeTypes, Node, Edge, WrapEdgeProps } from '$lib/types';
+import type { EdgeTypes, NodeTypes, Node, Edge, WrapEdgeProps, ConnectionData } from '$lib/types';
import BezierEdge from '$lib/components/edges/BezierEdge.svelte';
import StraightEdge from '$lib/components/edges/StraightEdge.svelte';
import SmoothStepEdge from '$lib/components/edges/SmoothStepEdge.svelte';
+import { getBezierPath, getSmoothStepPath, getStraightPath } from '@reactflow/edge-utils';
export const key = Symbol();
@@ -58,8 +71,14 @@ type SvelteFlowStore = {
deleteKeyPressedStore: Writable;
nodeTypesStore: Writable;
edgeTypesStore: Writable;
+ domNodeStore: Writable;
+ connectionRadiusStore: Writable;
+ connectionModeStore: Writable;
+ connectionStore: Writable;
+ connectionPathStore: Readable;
setNodes: (nodes: Node[]) => void;
setEdges: (edges: Edge[]) => void;
+ addEdge: (edge: Edge | Connection) => void;
zoomIn: (options?: ViewportHelperFunctionOptions) => void;
zoomOut: (options?: ViewportHelperFunctionOptions) => void;
fitView: (options?: ViewportHelperFunctionOptions) => boolean;
@@ -71,6 +90,17 @@ type SvelteFlowStore = {
updateNodeDimensions: (updates: NodeDimensionUpdate[]) => void;
resetSelectedElements: () => void;
addSelectedNodes: (ids: string[]) => void;
+ panBy: (delta: XYPosition) => void;
+ updateConnection: (connection: Partial) => void;
+ cancelConnection: () => void;
+};
+
+const initConnectionData = {
+ nodeId: null,
+ handleId: null,
+ handleType: null,
+ position: null,
+ status: null
};
export function createStore({
@@ -112,6 +142,11 @@ export function createStore({
default: edgeTypes.default || BezierEdge
});
const transformStore = writable(transform);
+ const connectionModeStore = writable(ConnectionMode.Strict);
+ const domNodeStore = writable(null);
+ const connectionStore = writable(initConnectionData);
+ const connectionRadiusStore = writable(25);
+ const connectionLineTypeStore = writable(ConnectionLineType.Bezier);
let fitViewOnInitDone = false;
@@ -172,10 +207,84 @@ export function createStore({
.filter((e) => e !== null) as WrapEdgeProps[];
});
+ const oppositePosition = {
+ [Position.Left]: Position.Right,
+ [Position.Right]: Position.Left,
+ [Position.Top]: Position.Bottom,
+ [Position.Bottom]: Position.Top
+ };
+
+ const connectionPathStore = derived(
+ [connectionStore, connectionLineTypeStore, connectionModeStore, nodesStore, transformStore],
+ ([
+ $connectionStore,
+ $connectionLineTypeStore,
+ $connectionModeStore,
+ $nodesStore,
+ $transformStore
+ ]) => {
+ if (!$connectionStore.nodeId) {
+ return null;
+ }
+
+ const fromNode = $nodesStore.find((n) => n.id === $connectionStore.nodeId);
+ const fromHandleBounds = fromNode?.[internalsSymbol]?.handleBounds;
+ const handleBoundsStrict = fromHandleBounds?.[$connectionStore.handleType || 'source'] || [];
+ const handleBoundsLoose = handleBoundsStrict
+ ? handleBoundsStrict
+ : fromHandleBounds?.[$connectionStore.handleType === 'source' ? 'target' : 'source']!;
+ const handleBounds =
+ $connectionModeStore === ConnectionMode.Strict ? handleBoundsStrict : handleBoundsLoose;
+ const fromHandle = $connectionStore.handleId
+ ? handleBounds.find((d) => d.id === $connectionStore.handleId)
+ : handleBounds[0];
+ const fromHandleX = fromHandle
+ ? fromHandle.x + fromHandle.width / 2
+ : (fromNode?.width ?? 0) / 2;
+ const fromHandleY = fromHandle ? fromHandle.y + fromHandle.height / 2 : fromNode?.height ?? 0;
+ const fromX = (fromNode?.positionAbsolute?.x ?? 0) + fromHandleX;
+ const fromY = (fromNode?.positionAbsolute?.y ?? 0) + fromHandleY;
+ const fromPosition = fromHandle?.position;
+ const toPosition = fromPosition ? oppositePosition[fromPosition] : undefined;
+
+ const pathParams = {
+ sourceX: fromX,
+ sourceY: fromY,
+ sourcePosition: fromPosition,
+ targetX: (($connectionStore.position?.x ?? 0) - $transformStore[0]) / $transformStore[2],
+ targetY: (($connectionStore.position?.y ?? 0) - $transformStore[1]) / $transformStore[2],
+ targetPosition: toPosition
+ };
+
+ let path = '';
+
+ if ($connectionLineTypeStore === ConnectionLineType.Bezier) {
+ // we assume the destination position is opposite to the source position
+ [path] = getBezierPath(pathParams);
+ } else if ($connectionLineTypeStore === ConnectionLineType.Step) {
+ [path] = getSmoothStepPath({
+ ...pathParams,
+ borderRadius: 0
+ });
+ } else if ($connectionLineTypeStore === ConnectionLineType.SmoothStep) {
+ [path] = getSmoothStepPath(pathParams);
+ } else {
+ [path] = getStraightPath(pathParams);
+ }
+
+ return path;
+ }
+ );
+
function setEdges(edges: Edge[]) {
edgesStore.set(edges);
}
+ function addEdge(edgeParams: Edge | Connection) {
+ const edges = get(edgesStore);
+ edgesStore.set(addEdgeUtil(edgeParams, edges));
+ }
+
function setNodes(nodes: Node[]) {
nodesStore.update((currentNodes) => {
const nextNodes = nodes.map((n) => {
@@ -254,7 +363,7 @@ export function createStore({
const { zoom: d3Zoom, selection: d3Selection } = get(d3Store);
fitViewOnInitDone =
- fitViewOnInitDone || (fitViewOnInit && !!d3Zoom && !!d3Selection && _fitView());
+ fitViewOnInitDone || (fitViewOnInit && !!d3Zoom && !!d3Selection && fitView());
nodesStore.set(nextNodes);
}
@@ -274,14 +383,14 @@ export function createStore({
}
}
- function _fitView() {
+ function fitView() {
const { zoom: d3Zoom, selection: d3Selection } = get(d3Store);
if (!d3Zoom || !d3Selection) {
return false;
}
- return fitView(
+ return fitViewUtil(
{
nodes: get(nodesStore) as RFNode[],
width: get(widthStore),
@@ -373,6 +482,52 @@ export function createStore({
);
}
+ function panBy(delta: XYPosition) {
+ const { zoom: d3Zoom, selection: d3Selection } = get(d3Store);
+ const transform = get(transformStore);
+ const width = get(widthStore);
+ const height = get(heightStore);
+
+ if (!d3Zoom || !d3Selection || (!delta.x && !delta.y)) {
+ return;
+ }
+
+ const nextTransform = zoomIdentity
+ .translate(transform[0] + delta.x, transform[1] + delta.y)
+ .scale(transform[2]);
+
+ const extent: CoordinateExtent = [
+ [0, 0],
+ [width, height]
+ ];
+
+ const constrainedTransform = d3Zoom?.constrain()(nextTransform, extent, [
+ [Number.NEGATIVE_INFINITY, Number.NEGATIVE_INFINITY],
+ [Number.POSITIVE_INFINITY, Number.POSITIVE_INFINITY]
+ ]);
+ d3Zoom.transform(d3Selection, constrainedTransform);
+ }
+
+ function updateConnection(connectionUpdate: Partial | null) {
+ const currentConnectionData = get(connectionStore);
+ const nextConnectionData = currentConnectionData
+ ? {
+ ...initConnectionData,
+ ...currentConnectionData,
+ ...connectionUpdate
+ }
+ : {
+ ...initConnectionData,
+ ...connectionUpdate
+ };
+
+ connectionStore.set(nextConnectionData);
+ }
+
+ function cancelConnection() {
+ updateConnection(initConnectionData);
+ }
+
return {
nodesStore,
edgesStore,
@@ -391,15 +546,24 @@ export function createStore({
selectionMode,
nodeTypesStore,
edgeTypesStore,
+ connectionModeStore,
+ domNodeStore,
+ connectionStore,
+ connectionRadiusStore,
+ connectionPathStore,
setNodes,
setEdges,
+ addEdge,
updateNodePositions,
updateNodeDimensions,
zoomIn,
zoomOut,
- fitView: _fitView,
+ fitView,
resetSelectedElements,
- addSelectedNodes
+ addSelectedNodes,
+ panBy,
+ updateConnection,
+ cancelConnection
};
}
diff --git a/packages/svelte/src/lib/types/general.ts b/packages/svelte/src/lib/types/general.ts
index f19e96d1..e723c5e4 100644
--- a/packages/svelte/src/lib/types/general.ts
+++ b/packages/svelte/src/lib/types/general.ts
@@ -1,11 +1,21 @@
import type { Node, NodeTypes } from './nodes';
-import type { Edge } from './edges';
import type { ShortcutModifierDefinition } from '@svelte-put/shortcut';
+import type { Edge, HandleType, XYPosition } from '@reactflow/system';
export type KeyModifier = ShortcutModifierDefinition;
export type KeyDefinitionObject = { key: string; modifier?: KeyModifier };
export type KeyDefinition = string | KeyDefinitionObject;
+export type ConnectionData = {
+ position: XYPosition | null;
+ nodeId: string | null;
+ handleId: string | null;
+ handleType: HandleType | null;
+ status: string | null;
+};
+
+export type ConnectionLineProps = {};
+
export type SvelteFlowProps = {
nodes: Node[];
edges: Edge[];