@xyflow/system
Version:
xyflow core system that powers React Flow and Svelte Flow.
1,250 lines (1,237 loc) • 136 kB
JavaScript
import { drag } from 'd3-drag';
import { select, pointer } from 'd3-selection';
import { zoom, zoomIdentity, zoomTransform } from 'd3-zoom';
const errorMessages = {
error001: () => '[React Flow]: Seems like you have not used zustand provider as an ancestor. Help: https://reactflow.dev/error#001',
error002: () => "It looks like you've created a new nodeTypes or edgeTypes object. If this wasn't on purpose please define the nodeTypes/edgeTypes outside of the component or memoize them.",
error003: (nodeType) => `Node type "${nodeType}" not found. Using fallback type "default".`,
error004: () => 'The React Flow parent container needs a width and a height to render the graph.',
error005: () => 'Only child nodes can use a parent extent.',
error006: () => "Can't create edge. An edge needs a source and a target.",
error007: (id) => `The old edge with id=${id} does not exist.`,
error009: (type) => `Marker type "${type}" doesn't exist.`,
error008: (handleType, { id, sourceHandle, targetHandle }) => `Couldn't create edge for ${handleType} handle id: "${handleType === 'source' ? sourceHandle : targetHandle}", edge id: ${id}.`,
error010: () => 'Handle: No node id found. Make sure to only use a Handle inside a custom Node.',
error011: (edgeType) => `Edge type "${edgeType}" not found. Using fallback type "default".`,
error012: (id) => `Node with id "${id}" does not exist, it may have been removed. This can happen when a node is deleted before the "onNodeClick" handler is called.`,
error013: (lib = 'react') => `It seems that you haven't loaded the styles. Please import '@xyflow/${lib}/dist/style.css' or base.css to make sure everything is working properly.`,
error014: () => 'useNodeConnections: No node ID found. Call useNodeConnections inside a custom Node or provide a node ID.',
error015: () => 'It seems that you are trying to drag a node that is not initialized. Please use onNodesChange as explained in the docs.',
};
const infiniteExtent = [
[Number.NEGATIVE_INFINITY, Number.NEGATIVE_INFINITY],
[Number.POSITIVE_INFINITY, Number.POSITIVE_INFINITY],
];
const elementSelectionKeys = ['Enter', ' ', 'Escape'];
/**
* The `ConnectionMode` is used to set the mode of connection between nodes.
* The `Strict` mode is the default one and only allows source to target edges.
* `Loose` mode allows source to source and target to target edges as well.
*
* @public
*/
var ConnectionMode;
(function (ConnectionMode) {
ConnectionMode["Strict"] = "strict";
ConnectionMode["Loose"] = "loose";
})(ConnectionMode || (ConnectionMode = {}));
/**
* This enum is used to set the different modes of panning the viewport when the
* user scrolls. The `Free` mode allows the user to pan in any direction by scrolling
* with a device like a trackpad. The `Vertical` and `Horizontal` modes restrict
* scroll panning to only the vertical or horizontal axis, respectively.
*
* @public
*/
var PanOnScrollMode;
(function (PanOnScrollMode) {
PanOnScrollMode["Free"] = "free";
PanOnScrollMode["Vertical"] = "vertical";
PanOnScrollMode["Horizontal"] = "horizontal";
})(PanOnScrollMode || (PanOnScrollMode = {}));
var SelectionMode;
(function (SelectionMode) {
SelectionMode["Partial"] = "partial";
SelectionMode["Full"] = "full";
})(SelectionMode || (SelectionMode = {}));
const initialConnection = {
inProgress: false,
isValid: null,
from: null,
fromHandle: null,
fromPosition: null,
fromNode: null,
to: null,
toHandle: null,
toPosition: null,
toNode: null,
};
/**
* If you set the `connectionLineType` prop on your [`<ReactFlow />`](/api-reference/react-flow#connection-connectionLineType)
*component, it will dictate the style of connection line rendered when creating
*new edges.
*
* @public
*
* @remarks If you choose to render a custom connection line component, this value will be
*passed to your component as part of its [`ConnectionLineComponentProps`](/api-reference/types/connection-line-component-props).
*/
var ConnectionLineType;
(function (ConnectionLineType) {
ConnectionLineType["Bezier"] = "default";
ConnectionLineType["Straight"] = "straight";
ConnectionLineType["Step"] = "step";
ConnectionLineType["SmoothStep"] = "smoothstep";
ConnectionLineType["SimpleBezier"] = "simplebezier";
})(ConnectionLineType || (ConnectionLineType = {}));
/**
* Edges may optionally have a marker on either end. The MarkerType type enumerates
* the options available to you when configuring a given marker.
*
* @public
*/
var MarkerType;
(function (MarkerType) {
MarkerType["Arrow"] = "arrow";
MarkerType["ArrowClosed"] = "arrowclosed";
})(MarkerType || (MarkerType = {}));
/**
* While [`PanelPosition`](/api-reference/types/panel-position) can be used to place a
* component in the corners of a container, the `Position` enum is less precise and used
* primarily in relation to edges and handles.
*
* @public
*/
var Position;
(function (Position) {
Position["Left"] = "left";
Position["Top"] = "top";
Position["Right"] = "right";
Position["Bottom"] = "bottom";
})(Position || (Position = {}));
const oppositePosition = {
[Position.Left]: Position.Right,
[Position.Right]: Position.Left,
[Position.Top]: Position.Bottom,
[Position.Bottom]: Position.Top,
};
/**
* @internal
*/
function areConnectionMapsEqual(a, b) {
if (!a && !b) {
return true;
}
if (!a || !b || a.size !== b.size) {
return false;
}
if (!a.size && !b.size) {
return true;
}
for (const key of a.keys()) {
if (!b.has(key)) {
return false;
}
}
return true;
}
/**
* We call the callback for all connections in a that are not in b
*
* @internal
*/
function handleConnectionChange(a, b, cb) {
if (!cb) {
return;
}
const diff = [];
a.forEach((connection, key) => {
if (!b?.has(key)) {
diff.push(connection);
}
});
if (diff.length) {
cb(diff);
}
}
function getConnectionStatus(isValid) {
return isValid === null ? null : isValid ? 'valid' : 'invalid';
}
/* eslint-disable @typescript-eslint/no-explicit-any */
/**
* Test whether an object is useable as an Edge
* @public
* @remarks In TypeScript this is a type guard that will narrow the type of whatever you pass in to Edge if it returns true
* @param element - The element to test
* @returns A boolean indicating whether the element is an Edge
*/
const isEdgeBase = (element) => 'id' in element && 'source' in element && 'target' in element;
/**
* Test whether an object is useable as a Node
* @public
* @remarks In TypeScript this is a type guard that will narrow the type of whatever you pass in to Node if it returns true
* @param element - The element to test
* @returns A boolean indicating whether the element is an Node
*/
const isNodeBase = (element) => 'id' in element && 'position' in element && !('source' in element) && !('target' in element);
const isInternalNodeBase = (element) => 'id' in element && 'internals' in element && !('source' in element) && !('target' in element);
/**
* This util is used to tell you what nodes, if any, are connected to the given node
* as the _target_ of an edge.
* @public
* @param node - The node to get the connected nodes from
* @param nodes - The array of all nodes
* @param edges - The array of all edges
* @returns An array of nodes that are connected over eges where the source is the given node
*
* @example
* ```ts
*import { getOutgoers } from '@xyflow/react';
*
*const nodes = [];
*const edges = [];
*
*const outgoers = getOutgoers(
* { id: '1', position: { x: 0, y: 0 }, data: { label: 'node' } },
* nodes,
* edges,
*);
*```
*/
const getOutgoers = (node, nodes, edges) => {
if (!node.id) {
return [];
}
const outgoerIds = new Set();
edges.forEach((edge) => {
if (edge.source === node.id) {
outgoerIds.add(edge.target);
}
});
return nodes.filter((n) => outgoerIds.has(n.id));
};
/**
* This util is used to tell you what nodes, if any, are connected to the given node
* as the _source_ of an edge.
* @public
* @param node - The node to get the connected nodes from
* @param nodes - The array of all nodes
* @param edges - The array of all edges
* @returns An array of nodes that are connected over eges where the target is the given node
*
* @example
* ```ts
*import { getIncomers } from '@xyflow/react';
*
*const nodes = [];
*const edges = [];
*
*const incomers = getIncomers(
* { id: '1', position: { x: 0, y: 0 }, data: { label: 'node' } },
* nodes,
* edges,
*);
*```
*/
const getIncomers = (node, nodes, edges) => {
if (!node.id) {
return [];
}
const incomersIds = new Set();
edges.forEach((edge) => {
if (edge.target === node.id) {
incomersIds.add(edge.source);
}
});
return nodes.filter((n) => incomersIds.has(n.id));
};
const getNodePositionWithOrigin = (node, nodeOrigin = [0, 0]) => {
const { width, height } = getNodeDimensions(node);
const origin = node.origin ?? nodeOrigin;
const offsetX = width * origin[0];
const offsetY = height * origin[1];
return {
x: node.position.x - offsetX,
y: node.position.y - offsetY,
};
};
/**
* Returns the bounding box that contains all the given nodes in an array. This can
* be useful when combined with [`getViewportForBounds`](/api-reference/utils/get-viewport-for-bounds)
* to calculate the correct transform to fit the given nodes in a viewport.
* @public
* @remarks Useful when combined with {@link getViewportForBounds} to calculate the correct transform to fit the given nodes in a viewport.
* @param nodes - Nodes to calculate the bounds for
* @param params.nodeOrigin - Origin of the nodes: [0, 0] - top left, [0.5, 0.5] - center
* @returns Bounding box enclosing all nodes
*
* @remarks This function was previously called `getRectOfNodes`
*
* @example
* ```js
*import { getNodesBounds } from '@xyflow/react';
*
*const nodes = [
* {
* id: 'a',
* position: { x: 0, y: 0 },
* data: { label: 'a' },
* width: 50,
* height: 25,
* },
* {
* id: 'b',
* position: { x: 100, y: 100 },
* data: { label: 'b' },
* width: 50,
* height: 25,
* },
*];
*
*const bounds = getNodesBounds(nodes);
*```
*/
const getNodesBounds = (nodes, params = { nodeOrigin: [0, 0], nodeLookup: undefined }) => {
if (process.env.NODE_ENV === 'development' && !params.nodeLookup) {
console.warn('Please use `getNodesBounds` from `useReactFlow`/`useSvelteFlow` hook to ensure correct values for sub flows. If not possible, you have to provide a nodeLookup to support sub flows.');
}
if (nodes.length === 0) {
return { x: 0, y: 0, width: 0, height: 0 };
}
const box = nodes.reduce((currBox, nodeOrId) => {
const isId = typeof nodeOrId === 'string';
let currentNode = !params.nodeLookup && !isId ? nodeOrId : undefined;
if (params.nodeLookup) {
currentNode = isId
? params.nodeLookup.get(nodeOrId)
: !isInternalNodeBase(nodeOrId)
? params.nodeLookup.get(nodeOrId.id)
: nodeOrId;
}
const nodeBox = currentNode ? nodeToBox(currentNode, params.nodeOrigin) : { x: 0, y: 0, x2: 0, y2: 0 };
return getBoundsOfBoxes(currBox, nodeBox);
}, { x: Infinity, y: Infinity, x2: -Infinity, y2: -Infinity });
return boxToRect(box);
};
/**
* Determines a bounding box that contains all given nodes in an array
* @internal
*/
const getInternalNodesBounds = (nodeLookup, params = {}) => {
if (nodeLookup.size === 0) {
return { x: 0, y: 0, width: 0, height: 0 };
}
let box = { x: Infinity, y: Infinity, x2: -Infinity, y2: -Infinity };
nodeLookup.forEach((node) => {
if (params.filter === undefined || params.filter(node)) {
const nodeBox = nodeToBox(node);
box = getBoundsOfBoxes(box, nodeBox);
}
});
return boxToRect(box);
};
const getNodesInside = (nodes, rect, [tx, ty, tScale] = [0, 0, 1], partially = false,
// set excludeNonSelectableNodes if you want to pay attention to the nodes "selectable" attribute
excludeNonSelectableNodes = false) => {
const paneRect = {
...pointToRendererPoint(rect, [tx, ty, tScale]),
width: rect.width / tScale,
height: rect.height / tScale,
};
const visibleNodes = [];
for (const node of nodes.values()) {
const { measured, selectable = true, hidden = false } = node;
if ((excludeNonSelectableNodes && !selectable) || hidden) {
continue;
}
const width = measured.width ?? node.width ?? node.initialWidth ?? null;
const height = measured.height ?? node.height ?? node.initialHeight ?? null;
const overlappingArea = getOverlappingArea(paneRect, nodeToRect(node));
const area = (width ?? 0) * (height ?? 0);
const partiallyVisible = partially && overlappingArea > 0;
const forceInitialRender = !node.internals.handleBounds;
const isVisible = forceInitialRender || partiallyVisible || overlappingArea >= area;
if (isVisible || node.dragging) {
visibleNodes.push(node);
}
}
return visibleNodes;
};
/**
* This utility filters an array of edges, keeping only those where either the source or target
* node is present in the given array of nodes.
* @public
* @param nodes - Nodes you want to get the connected edges for
* @param edges - All edges
* @returns Array of edges that connect any of the given nodes with each other
*
* @example
* ```js
*import { getConnectedEdges } from '@xyflow/react';
*
*const nodes = [
* { id: 'a', position: { x: 0, y: 0 } },
* { id: 'b', position: { x: 100, y: 0 } },
*];
*
*const edges = [
* { id: 'a->c', source: 'a', target: 'c' },
* { id: 'c->d', source: 'c', target: 'd' },
*];
*
*const connectedEdges = getConnectedEdges(nodes, edges);
* // => [{ id: 'a->c', source: 'a', target: 'c' }]
*```
*/
const getConnectedEdges = (nodes, edges) => {
const nodeIds = new Set();
nodes.forEach((node) => {
nodeIds.add(node.id);
});
return edges.filter((edge) => nodeIds.has(edge.source) || nodeIds.has(edge.target));
};
function getFitViewNodes(nodeLookup, options) {
const fitViewNodes = new Map();
const optionNodeIds = options?.nodes ? new Set(options.nodes.map((node) => node.id)) : null;
nodeLookup.forEach((n) => {
const isVisible = n.measured.width && n.measured.height && (options?.includeHiddenNodes || !n.hidden);
if (isVisible && (!optionNodeIds || optionNodeIds.has(n.id))) {
fitViewNodes.set(n.id, n);
}
});
return fitViewNodes;
}
async function fitView({ nodes, width, height, panZoom, minZoom, maxZoom }, options) {
if (nodes.size === 0) {
return Promise.resolve(false);
}
const bounds = getInternalNodesBounds(nodes);
const viewport = getViewportForBounds(bounds, width, height, options?.minZoom ?? minZoom, options?.maxZoom ?? maxZoom, options?.padding ?? 0.1);
await panZoom.setViewport(viewport, { duration: options?.duration });
return Promise.resolve(true);
}
/**
* This function calculates the next position of a node, taking into account the node's extent, parent node, and origin.
*
* @internal
* @returns position, positionAbsolute
*/
function calculateNodePosition({ nodeId, nextPosition, nodeLookup, nodeOrigin = [0, 0], nodeExtent, onError, }) {
const node = nodeLookup.get(nodeId);
const parentNode = node.parentId ? nodeLookup.get(node.parentId) : undefined;
const { x: parentX, y: parentY } = parentNode ? parentNode.internals.positionAbsolute : { x: 0, y: 0 };
const origin = node.origin ?? nodeOrigin;
let extent = nodeExtent;
if (node.extent === 'parent' && !node.expandParent) {
if (!parentNode) {
onError?.('005', errorMessages['error005']());
}
else {
const parentWidth = parentNode.measured.width;
const parentHeight = parentNode.measured.height;
if (parentWidth && parentHeight) {
extent = [
[parentX, parentY],
[parentX + parentWidth, parentY + parentHeight],
];
}
}
}
else if (parentNode && isCoordinateExtent(node.extent)) {
extent = [
[node.extent[0][0] + parentX, node.extent[0][1] + parentY],
[node.extent[1][0] + parentX, node.extent[1][1] + parentY],
];
}
const positionAbsolute = isCoordinateExtent(extent)
? clampPosition(nextPosition, extent, node.measured)
: nextPosition;
if (node.measured.width === undefined || node.measured.height === undefined) {
onError?.('015', errorMessages['error015']());
}
return {
position: {
x: positionAbsolute.x - parentX + (node.measured.width ?? 0) * origin[0],
y: positionAbsolute.y - parentY + (node.measured.height ?? 0) * origin[1],
},
positionAbsolute,
};
}
/**
* Pass in nodes & edges to delete, get arrays of nodes and edges that actually can be deleted
* @internal
* @param param.nodesToRemove - The nodes to remove
* @param param.edgesToRemove - The edges to remove
* @param param.nodes - All nodes
* @param param.edges - All edges
* @param param.onBeforeDelete - Callback to check which nodes and edges can be deleted
* @returns nodes: nodes that can be deleted, edges: edges that can be deleted
*/
async function getElementsToRemove({ nodesToRemove = [], edgesToRemove = [], nodes, edges, onBeforeDelete, }) {
const nodeIds = new Set(nodesToRemove.map((node) => node.id));
const matchingNodes = [];
for (const node of nodes) {
if (node.deletable === false) {
continue;
}
const isIncluded = nodeIds.has(node.id);
const parentHit = !isIncluded && node.parentId && matchingNodes.find((n) => n.id === node.parentId);
if (isIncluded || parentHit) {
matchingNodes.push(node);
}
}
const edgeIds = new Set(edgesToRemove.map((edge) => edge.id));
const deletableEdges = edges.filter((edge) => edge.deletable !== false);
const connectedEdges = getConnectedEdges(matchingNodes, deletableEdges);
const matchingEdges = connectedEdges;
for (const edge of deletableEdges) {
const isIncluded = edgeIds.has(edge.id);
if (isIncluded && !matchingEdges.find((e) => e.id === edge.id)) {
matchingEdges.push(edge);
}
}
if (!onBeforeDelete) {
return {
edges: matchingEdges,
nodes: matchingNodes,
};
}
const onBeforeDeleteResult = await onBeforeDelete({
nodes: matchingNodes,
edges: matchingEdges,
});
if (typeof onBeforeDeleteResult === 'boolean') {
return onBeforeDeleteResult ? { edges: matchingEdges, nodes: matchingNodes } : { edges: [], nodes: [] };
}
return onBeforeDeleteResult;
}
const clamp = (val, min = 0, max = 1) => Math.min(Math.max(val, min), max);
const clampPosition = (position = { x: 0, y: 0 }, extent, dimensions) => ({
x: clamp(position.x, extent[0][0], extent[1][0] - (dimensions?.width ?? 0)),
y: clamp(position.y, extent[0][1], extent[1][1] - (dimensions?.height ?? 0)),
});
function clampPositionToParent(childPosition, childDimensions, parent) {
const { width: parentWidth, height: parentHeight } = getNodeDimensions(parent);
const { x: parentX, y: parentY } = parent.internals.positionAbsolute;
return clampPosition(childPosition, [
[parentX, parentY],
[parentX + parentWidth, parentY + parentHeight],
], childDimensions);
}
/**
* Calculates the velocity of panning when the mouse is close to the edge of the canvas
* @internal
* @param value - One dimensional poition of the mouse (x or y)
* @param min - Minimal position on canvas before panning starts
* @param max - Maximal position on canvas before panning starts
* @returns - A number between 0 and 1 that represents the velocity of panning
*/
const calcAutoPanVelocity = (value, min, max) => {
if (value < min) {
return clamp(Math.abs(value - min), 1, min) / min;
}
else if (value > max) {
return -clamp(Math.abs(value - max), 1, min) / min;
}
return 0;
};
const calcAutoPan = (pos, bounds, speed = 15, distance = 40) => {
const xMovement = calcAutoPanVelocity(pos.x, distance, bounds.width - distance) * speed;
const yMovement = calcAutoPanVelocity(pos.y, distance, bounds.height - distance) * speed;
return [xMovement, yMovement];
};
const getBoundsOfBoxes = (box1, box2) => ({
x: Math.min(box1.x, box2.x),
y: Math.min(box1.y, box2.y),
x2: Math.max(box1.x2, box2.x2),
y2: Math.max(box1.y2, box2.y2),
});
const rectToBox = ({ x, y, width, height }) => ({
x,
y,
x2: x + width,
y2: y + height,
});
const boxToRect = ({ x, y, x2, y2 }) => ({
x,
y,
width: x2 - x,
height: y2 - y,
});
const nodeToRect = (node, nodeOrigin = [0, 0]) => {
const { x, y } = isInternalNodeBase(node)
? node.internals.positionAbsolute
: getNodePositionWithOrigin(node, nodeOrigin);
return {
x,
y,
width: node.measured?.width ?? node.width ?? node.initialWidth ?? 0,
height: node.measured?.height ?? node.height ?? node.initialHeight ?? 0,
};
};
const nodeToBox = (node, nodeOrigin = [0, 0]) => {
const { x, y } = isInternalNodeBase(node)
? node.internals.positionAbsolute
: getNodePositionWithOrigin(node, nodeOrigin);
return {
x,
y,
x2: x + (node.measured?.width ?? node.width ?? node.initialWidth ?? 0),
y2: y + (node.measured?.height ?? node.height ?? node.initialHeight ?? 0),
};
};
const getBoundsOfRects = (rect1, rect2) => boxToRect(getBoundsOfBoxes(rectToBox(rect1), rectToBox(rect2)));
const getOverlappingArea = (rectA, rectB) => {
const xOverlap = Math.max(0, Math.min(rectA.x + rectA.width, rectB.x + rectB.width) - Math.max(rectA.x, rectB.x));
const yOverlap = Math.max(0, Math.min(rectA.y + rectA.height, rectB.y + rectB.height) - Math.max(rectA.y, rectB.y));
return Math.ceil(xOverlap * yOverlap);
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const isRectObject = (obj) => isNumeric(obj.width) && isNumeric(obj.height) && isNumeric(obj.x) && isNumeric(obj.y);
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
const isNumeric = (n) => !isNaN(n) && isFinite(n);
// used for a11y key board controls for nodes and edges
const devWarn = (id, message) => {
if (process.env.NODE_ENV === 'development') {
console.warn(`[React Flow]: ${message} Help: https://reactflow.dev/error#${id}`);
}
};
const snapPosition = (position, snapGrid = [1, 1]) => {
return {
x: snapGrid[0] * Math.round(position.x / snapGrid[0]),
y: snapGrid[1] * Math.round(position.y / snapGrid[1]),
};
};
const pointToRendererPoint = ({ x, y }, [tx, ty, tScale], snapToGrid = false, snapGrid = [1, 1]) => {
const position = {
x: (x - tx) / tScale,
y: (y - ty) / tScale,
};
return snapToGrid ? snapPosition(position, snapGrid) : position;
};
const rendererPointToPoint = ({ x, y }, [tx, ty, tScale]) => {
return {
x: x * tScale + tx,
y: y * tScale + ty,
};
};
/**
* Returns a viewport that encloses the given bounds with optional padding.
* @public
* @remarks You can determine bounds of nodes with {@link getNodesBounds} and {@link getBoundsOfRects}
* @param bounds - Bounds to fit inside viewport
* @param width - Width of the viewport
* @param height - Height of the viewport
* @param minZoom - Minimum zoom level of the resulting viewport
* @param maxZoom - Maximum zoom level of the resulting viewport
* @param padding - Optional padding around the bounds
* @returns A transforned {@link Viewport} that encloses the given bounds which you can pass to e.g. {@link setViewport}
* @example
* const { x, y, zoom } = getViewportForBounds(
*{ x: 0, y: 0, width: 100, height: 100},
*1200, 800, 0.5, 2);
*/
const getViewportForBounds = (bounds, width, height, minZoom, maxZoom, padding) => {
const xZoom = width / (bounds.width * (1 + padding));
const yZoom = height / (bounds.height * (1 + padding));
const zoom = Math.min(xZoom, yZoom);
const clampedZoom = clamp(zoom, minZoom, maxZoom);
const boundsCenterX = bounds.x + bounds.width / 2;
const boundsCenterY = bounds.y + bounds.height / 2;
const x = width / 2 - boundsCenterX * clampedZoom;
const y = height / 2 - boundsCenterY * clampedZoom;
return { x, y, zoom: clampedZoom };
};
const isMacOs = () => typeof navigator !== 'undefined' && navigator?.userAgent?.indexOf('Mac') >= 0;
function isCoordinateExtent(extent) {
return extent !== undefined && extent !== 'parent';
}
function getNodeDimensions(node) {
return {
width: node.measured?.width ?? node.width ?? node.initialWidth ?? 0,
height: node.measured?.height ?? node.height ?? node.initialHeight ?? 0,
};
}
function nodeHasDimensions(node) {
return ((node.measured?.width ?? node.width ?? node.initialWidth) !== undefined &&
(node.measured?.height ?? node.height ?? node.initialHeight) !== undefined);
}
/**
* Convert child position to aboslute position
*
* @internal
* @param position
* @param parentId
* @param nodeLookup
* @param nodeOrigin
* @returns an internal node with an absolute position
*/
function evaluateAbsolutePosition(position, dimensions = { width: 0, height: 0 }, parentId, nodeLookup, nodeOrigin) {
const positionAbsolute = { ...position };
const parent = nodeLookup.get(parentId);
if (parent) {
const origin = parent.origin || nodeOrigin;
positionAbsolute.x += parent.internals.positionAbsolute.x - (dimensions.width ?? 0) * origin[0];
positionAbsolute.y += parent.internals.positionAbsolute.y - (dimensions.height ?? 0) * origin[1];
}
return positionAbsolute;
}
function areSetsEqual(a, b) {
if (a.size !== b.size) {
return false;
}
for (const item of a) {
if (!b.has(item)) {
return false;
}
}
return true;
}
function getPointerPosition(event, { snapGrid = [0, 0], snapToGrid = false, transform, containerBounds }) {
const { x, y } = getEventPosition(event);
const pointerPos = pointToRendererPoint({ x: x - (containerBounds?.left ?? 0), y: y - (containerBounds?.top ?? 0) }, transform);
const { x: xSnapped, y: ySnapped } = snapToGrid ? snapPosition(pointerPos, snapGrid) : pointerPos;
// we need the snapped position in order to be able to skip unnecessary drag events
return {
xSnapped,
ySnapped,
...pointerPos,
};
}
const getDimensions = (node) => ({
width: node.offsetWidth,
height: node.offsetHeight,
});
const getHostForElement = (element) => element?.getRootNode?.() || window?.document;
const inputTags = ['INPUT', 'SELECT', 'TEXTAREA'];
function isInputDOMNode(event) {
// using composed path for handling shadow dom
const target = (event.composedPath?.()?.[0] || event.target);
if (target?.nodeType !== 1 /* Node.ELEMENT_NODE */)
return false;
const isInput = inputTags.includes(target.nodeName) || target.hasAttribute('contenteditable');
// when an input field is focused we don't want to trigger deletion or movement of nodes
return isInput || !!target.closest('.nokey');
}
const isMouseEvent = (event) => 'clientX' in event;
const getEventPosition = (event, bounds) => {
const isMouse = isMouseEvent(event);
const evtX = isMouse ? event.clientX : event.touches?.[0].clientX;
const evtY = isMouse ? event.clientY : event.touches?.[0].clientY;
return {
x: evtX - (bounds?.left ?? 0),
y: evtY - (bounds?.top ?? 0),
};
};
/*
* The handle bounds are calculated relative to the node element.
* We store them in the internals object of the node in order to avoid
* unnecessary recalculations.
*/
const getHandleBounds = (type, nodeElement, nodeBounds, zoom, nodeId) => {
const handles = nodeElement.querySelectorAll(`.${type}`);
if (!handles || !handles.length) {
return null;
}
return Array.from(handles).map((handle) => {
const handleBounds = handle.getBoundingClientRect();
return {
id: handle.getAttribute('data-handleid'),
type,
nodeId,
position: handle.getAttribute('data-handlepos'),
x: (handleBounds.left - nodeBounds.left) / zoom,
y: (handleBounds.top - nodeBounds.top) / zoom,
...getDimensions(handle),
};
});
};
function getBezierEdgeCenter({ sourceX, sourceY, targetX, targetY, sourceControlX, sourceControlY, targetControlX, targetControlY, }) {
/*
* cubic bezier t=0.5 mid point, not the actual mid point, but easy to calculate
* https://stackoverflow.com/questions/67516101/how-to-find-distance-mid-point-of-bezier-curve
*/
const centerX = sourceX * 0.125 + sourceControlX * 0.375 + targetControlX * 0.375 + targetX * 0.125;
const centerY = sourceY * 0.125 + sourceControlY * 0.375 + targetControlY * 0.375 + targetY * 0.125;
const offsetX = Math.abs(centerX - sourceX);
const offsetY = Math.abs(centerY - sourceY);
return [centerX, centerY, offsetX, offsetY];
}
function calculateControlOffset(distance, curvature) {
if (distance >= 0) {
return 0.5 * distance;
}
return curvature * 25 * Math.sqrt(-distance);
}
function getControlWithCurvature({ pos, x1, y1, x2, y2, c }) {
switch (pos) {
case Position.Left:
return [x1 - calculateControlOffset(x1 - x2, c), y1];
case Position.Right:
return [x1 + calculateControlOffset(x2 - x1, c), y1];
case Position.Top:
return [x1, y1 - calculateControlOffset(y1 - y2, c)];
case Position.Bottom:
return [x1, y1 + calculateControlOffset(y2 - y1, c)];
}
}
/**
* The `getBezierPath` util returns everything you need to render a bezier edge
*between two nodes.
* @public
* @param params.sourceX - The x position of the source handle
* @param params.sourceY - The y position of the source handle
* @param params.sourcePosition - The position of the source handle (default: Position.Bottom)
* @param params.targetX - The x position of the target handle
* @param params.targetY - The y position of the target handle
* @param params.targetPosition - The position of the target handle (default: Position.Top)
* @param params.curvature - The curvature of the bezier edge
* @returns A path string you can use in an SVG, the labelX and labelY position (center of path) and offsetX, offsetY between source handle and label
* @example
* ```js
* const source = { x: 0, y: 20 };
* const target = { x: 150, y: 100 };
*
* const [path, labelX, labelY, offsetX, offsetY] = getBezierPath({
* sourceX: source.x,
* sourceY: source.y,
* sourcePosition: Position.Right,
* targetX: target.x,
* targetY: target.y,
* targetPosition: Position.Left,
*});
*```
*
* @remarks This function returns a tuple (aka a fixed-size array) to make it easier to
*work with multiple edge paths at once.
*/
function getBezierPath({ sourceX, sourceY, sourcePosition = Position.Bottom, targetX, targetY, targetPosition = Position.Top, curvature = 0.25, }) {
const [sourceControlX, sourceControlY] = getControlWithCurvature({
pos: sourcePosition,
x1: sourceX,
y1: sourceY,
x2: targetX,
y2: targetY,
c: curvature,
});
const [targetControlX, targetControlY] = getControlWithCurvature({
pos: targetPosition,
x1: targetX,
y1: targetY,
x2: sourceX,
y2: sourceY,
c: curvature,
});
const [labelX, labelY, offsetX, offsetY] = getBezierEdgeCenter({
sourceX,
sourceY,
targetX,
targetY,
sourceControlX,
sourceControlY,
targetControlX,
targetControlY,
});
return [
`M${sourceX},${sourceY} C${sourceControlX},${sourceControlY} ${targetControlX},${targetControlY} ${targetX},${targetY}`,
labelX,
labelY,
offsetX,
offsetY,
];
}
// this is used for straight edges and simple smoothstep edges (LTR, RTL, BTT, TTB)
function getEdgeCenter({ sourceX, sourceY, targetX, targetY, }) {
const xOffset = Math.abs(targetX - sourceX) / 2;
const centerX = targetX < sourceX ? targetX + xOffset : targetX - xOffset;
const yOffset = Math.abs(targetY - sourceY) / 2;
const centerY = targetY < sourceY ? targetY + yOffset : targetY - yOffset;
return [centerX, centerY, xOffset, yOffset];
}
function getElevatedEdgeZIndex({ sourceNode, targetNode, selected = false, zIndex = 0, elevateOnSelect = false, }) {
if (!elevateOnSelect) {
return zIndex;
}
const edgeOrConnectedNodeSelected = selected || targetNode.selected || sourceNode.selected;
const selectedZIndex = Math.max(sourceNode.internals.z || 0, targetNode.internals.z || 0, 1000);
return zIndex + (edgeOrConnectedNodeSelected ? selectedZIndex : 0);
}
function isEdgeVisible({ sourceNode, targetNode, width, height, transform }) {
const edgeBox = getBoundsOfBoxes(nodeToBox(sourceNode), nodeToBox(targetNode));
if (edgeBox.x === edgeBox.x2) {
edgeBox.x2 += 1;
}
if (edgeBox.y === edgeBox.y2) {
edgeBox.y2 += 1;
}
const viewRect = {
x: -transform[0] / transform[2],
y: -transform[1] / transform[2],
width: width / transform[2],
height: height / transform[2],
};
return getOverlappingArea(viewRect, boxToRect(edgeBox)) > 0;
}
const getEdgeId = ({ source, sourceHandle, target, targetHandle }) => `xy-edge__${source}${sourceHandle || ''}-${target}${targetHandle || ''}`;
const connectionExists = (edge, edges) => {
return edges.some((el) => el.source === edge.source &&
el.target === edge.target &&
(el.sourceHandle === edge.sourceHandle || (!el.sourceHandle && !edge.sourceHandle)) &&
(el.targetHandle === edge.targetHandle || (!el.targetHandle && !edge.targetHandle)));
};
/**
* This util is a convenience function to add a new Edge to an array of edges. It also performs some validation to make sure you don't add an invalid edge or duplicate an existing one.
* @public
* @param edgeParams - Either an Edge or a Connection you want to add
* @param edges - The array of all current edges
* @returns A new array of edges with the new edge added
*
* @remarks If an edge with the same `target` and `source` already exists (and the same
*`targetHandle` and `sourceHandle` if those are set), then this util won't add
*a new edge even if the `id` property is different.
*
*/
const addEdge = (edgeParams, edges) => {
if (!edgeParams.source || !edgeParams.target) {
devWarn('006', errorMessages['error006']());
return edges;
}
let edge;
if (isEdgeBase(edgeParams)) {
edge = { ...edgeParams };
}
else {
edge = {
...edgeParams,
id: getEdgeId(edgeParams),
};
}
if (connectionExists(edge, edges)) {
return edges;
}
if (edge.sourceHandle === null) {
delete edge.sourceHandle;
}
if (edge.targetHandle === null) {
delete edge.targetHandle;
}
return edges.concat(edge);
};
/**
* A handy utility to update an existing [`Edge`](/api-reference/types/edge) with new properties.
*This searches your edge array for an edge with a matching `id` and updates its
*properties with the connection you provide.
* @public
* @param oldEdge - The edge you want to update
* @param newConnection - The new connection you want to update the edge with
* @param edges - The array of all current edges
* @param options.shouldReplaceId - should the id of the old edge be replaced with the new connection id
* @returns the updated edges array
*
* @example
* ```js
*const onReconnect = useCallback(
* (oldEdge: Edge, newConnection: Connection) => setEdges((els) => reconnectEdge(oldEdge, newConnection, els)),[]);
*```
*/
const reconnectEdge = (oldEdge, newConnection, edges, options = { shouldReplaceId: true }) => {
const { id: oldEdgeId, ...rest } = oldEdge;
if (!newConnection.source || !newConnection.target) {
devWarn('006', errorMessages['error006']());
return edges;
}
const foundEdge = edges.find((e) => e.id === oldEdge.id);
if (!foundEdge) {
devWarn('007', errorMessages['error007'](oldEdgeId));
return edges;
}
// Remove old edge and create the new edge with parameters of old edge.
const edge = {
...rest,
id: options.shouldReplaceId ? getEdgeId(newConnection) : oldEdgeId,
source: newConnection.source,
target: newConnection.target,
sourceHandle: newConnection.sourceHandle,
targetHandle: newConnection.targetHandle,
};
return edges.filter((e) => e.id !== oldEdgeId).concat(edge);
};
/**
* Calculates the straight line path between two points.
* @public
* @param params.sourceX - The x position of the source handle
* @param params.sourceY - The y position of the source handle
* @param params.targetX - The x position of the target handle
* @param params.targetY - The y position of the target handle
* @returns A path string you can use in an SVG, the labelX and labelY position (center of path) and offsetX, offsetY between source handle and label
* @example
* ```js
* const source = { x: 0, y: 20 };
* const target = { x: 150, y: 100 };
*
* const [path, labelX, labelY, offsetX, offsetY] = getStraightPath({
* sourceX: source.x,
* sourceY: source.y,
* sourcePosition: Position.Right,
* targetX: target.x,
* targetY: target.y,
* targetPosition: Position.Left,
* });
* ```
* @remarks This function returns a tuple (aka a fixed-size array) to make it easier to work with multiple edge paths at once.
*/
function getStraightPath({ sourceX, sourceY, targetX, targetY, }) {
const [labelX, labelY, offsetX, offsetY] = getEdgeCenter({
sourceX,
sourceY,
targetX,
targetY,
});
return [`M ${sourceX},${sourceY}L ${targetX},${targetY}`, labelX, labelY, offsetX, offsetY];
}
const handleDirections = {
[Position.Left]: { x: -1, y: 0 },
[Position.Right]: { x: 1, y: 0 },
[Position.Top]: { x: 0, y: -1 },
[Position.Bottom]: { x: 0, y: 1 },
};
const getDirection = ({ source, sourcePosition = Position.Bottom, target, }) => {
if (sourcePosition === Position.Left || sourcePosition === Position.Right) {
return source.x < target.x ? { x: 1, y: 0 } : { x: -1, y: 0 };
}
return source.y < target.y ? { x: 0, y: 1 } : { x: 0, y: -1 };
};
const distance = (a, b) => Math.sqrt(Math.pow(b.x - a.x, 2) + Math.pow(b.y - a.y, 2));
/*
* ith this function we try to mimic a orthogonal edge routing behaviour
* It's not as good as a real orthogonal edge routing but it's faster and good enough as a default for step and smooth step edges
*/
function getPoints({ source, sourcePosition = Position.Bottom, target, targetPosition = Position.Top, center, offset, }) {
const sourceDir = handleDirections[sourcePosition];
const targetDir = handleDirections[targetPosition];
const sourceGapped = { x: source.x + sourceDir.x * offset, y: source.y + sourceDir.y * offset };
const targetGapped = { x: target.x + targetDir.x * offset, y: target.y + targetDir.y * offset };
const dir = getDirection({
source: sourceGapped,
sourcePosition,
target: targetGapped,
});
const dirAccessor = dir.x !== 0 ? 'x' : 'y';
const currDir = dir[dirAccessor];
let points = [];
let centerX, centerY;
const sourceGapOffset = { x: 0, y: 0 };
const targetGapOffset = { x: 0, y: 0 };
const [defaultCenterX, defaultCenterY, defaultOffsetX, defaultOffsetY] = getEdgeCenter({
sourceX: source.x,
sourceY: source.y,
targetX: target.x,
targetY: target.y,
});
// opposite handle positions, default case
if (sourceDir[dirAccessor] * targetDir[dirAccessor] === -1) {
centerX = center.x ?? defaultCenterX;
centerY = center.y ?? defaultCenterY;
/*
* --->
* |
* >---
*/
const verticalSplit = [
{ x: centerX, y: sourceGapped.y },
{ x: centerX, y: targetGapped.y },
];
/*
* |
* ---
* |
*/
const horizontalSplit = [
{ x: sourceGapped.x, y: centerY },
{ x: targetGapped.x, y: centerY },
];
if (sourceDir[dirAccessor] === currDir) {
points = dirAccessor === 'x' ? verticalSplit : horizontalSplit;
}
else {
points = dirAccessor === 'x' ? horizontalSplit : verticalSplit;
}
}
else {
// sourceTarget means we take x from source and y from target, targetSource is the opposite
const sourceTarget = [{ x: sourceGapped.x, y: targetGapped.y }];
const targetSource = [{ x: targetGapped.x, y: sourceGapped.y }];
// this handles edges with same handle positions
if (dirAccessor === 'x') {
points = sourceDir.x === currDir ? targetSource : sourceTarget;
}
else {
points = sourceDir.y === currDir ? sourceTarget : targetSource;
}
if (sourcePosition === targetPosition) {
const diff = Math.abs(source[dirAccessor] - target[dirAccessor]);
// if an edge goes from right to right for example (sourcePosition === targetPosition) and the distance between source.x and target.x is less than the offset, the added point and the gapped source/target will overlap. This leads to a weird edge path. To avoid this we add a gapOffset to the source/target
if (diff <= offset) {
const gapOffset = Math.min(offset - 1, offset - diff);
if (sourceDir[dirAccessor] === currDir) {
sourceGapOffset[dirAccessor] = (sourceGapped[dirAccessor] > source[dirAccessor] ? -1 : 1) * gapOffset;
}
else {
targetGapOffset[dirAccessor] = (targetGapped[dirAccessor] > target[dirAccessor] ? -1 : 1) * gapOffset;
}
}
}
// these are conditions for handling mixed handle positions like Right -> Bottom for example
if (sourcePosition !== targetPosition) {
const dirAccessorOpposite = dirAccessor === 'x' ? 'y' : 'x';
const isSameDir = sourceDir[dirAccessor] === targetDir[dirAccessorOpposite];
const sourceGtTargetOppo = sourceGapped[dirAccessorOpposite] > targetGapped[dirAccessorOpposite];
const sourceLtTargetOppo = sourceGapped[dirAccessorOpposite] < targetGapped[dirAccessorOpposite];
const flipSourceTarget = (sourceDir[dirAccessor] === 1 && ((!isSameDir && sourceGtTargetOppo) || (isSameDir && sourceLtTargetOppo))) ||
(sourceDir[dirAccessor] !== 1 && ((!isSameDir && sourceLtTargetOppo) || (isSameDir && sourceGtTargetOppo)));
if (flipSourceTarget) {
points = dirAccessor === 'x' ? sourceTarget : targetSource;
}
}
const sourceGapPoint = { x: sourceGapped.x + sourceGapOffset.x, y: sourceGapped.y + sourceGapOffset.y };
const targetGapPoint = { x: targetGapped.x + targetGapOffset.x, y: targetGapped.y + targetGapOffset.y };
const maxXDistance = Math.max(Math.abs(sourceGapPoint.x - points[0].x), Math.abs(targetGapPoint.x - points[0].x));
const maxYDistance = Math.max(Math.abs(sourceGapPoint.y - points[0].y), Math.abs(targetGapPoint.y - points[0].y));
// we want to place the label on the longest segment of the edge
if (maxXDistance >= maxYDistance) {
centerX = (sourceGapPoint.x + targetGapPoint.x) / 2;
centerY = points[0].y;
}
else {
centerX = points[0].x;
centerY = (sourceGapPoint.y + targetGapPoint.y) / 2;
}
}
const pathPoints = [
source,
{ x: sourceGapped.x + sourceGapOffset.x, y: sourceGapped.y + sourceGapOffset.y },
...points,
{ x: targetGapped.x + targetGapOffset.x, y: targetGapped.y + targetGapOffset.y },
target,
];
return [pathPoints, centerX, centerY, defaultOffsetX, defaultOffsetY];
}
function getBend(a, b, c, size) {
const bendSize = Math.min(distance(a, b) / 2, distance(b, c) / 2, size);
const { x, y } = b;
// no bend
if ((a.x === x && x === c.x) || (a.y === y && y === c.y)) {
return `L${x} ${y}`;
}
// first segment is horizontal
if (a.y === y) {
const xDir = a.x < c.x ? -1 : 1;
const yDir = a.y < c.y ? 1 : -1;
return `L ${x + bendSize * xDir},${y}Q ${x},${y} ${x},${y + bendSize * yDir}`;
}
const xDir = a.x < c.x ? 1 : -1;
const yDir = a.y < c.y ? -1 : 1;
return `L ${x},${y + bendSize * yDir}Q ${x},${y} ${x + bendSize * xDir},${y}`;
}
/**
* The `getSmoothStepPath` util returns everything you need to render a stepped path
*between two nodes. The `borderRadius` property can be used to choose how rounded
*the corners of those steps are.
* @public
* @param params.sourceX - The x position of the source handle
* @param params.sourceY - The y position of the source handle
* @param params.sourcePosition - The position of the source handle (default: Position.Bottom)
* @param params.targetX - The x position of the target handle
* @param params.targetY - The y position of the target handle
* @param params.targetPosition - The position of the target handle (default: Position.Top)
* @returns A path string you can use in an SVG, the labelX and labelY position (center of path) and offsetX, offsetY between source handle and label
* @example
* ```js
* const source = { x: 0, y: 20 };
* const target = { x: 150, y: 100 };
*
* const [path, labelX, labelY, offsetX, offsetY] = getSmoothStepPath({
* sourceX: source.x,
* sourceY: source.y,
* sourcePosition: Position.Right,
* targetX: target.x,
* targetY: target.y,
* targetPosition: Position.Left,
* });
* ```
* @remarks This function returns a tuple (aka a fixed-size array) to make it easier to work with multiple edge paths at once.
*/
function getSmoothStepPath({ sourceX, sourceY, sourcePosition = Position.Bottom, targetX, targetY, targetPosition = Position.Top, borderRadius = 5, centerX, centerY, offset = 20, }) {
const [points, labelX, labelY, offsetX, offsetY] = getPoints({
source: { x: sourceX, y: sourceY },
sourcePosition,
target: { x: targetX, y: targetY },
targetPosition,
center: { x: centerX, y: centerY },
offset,
});
const path = points.reduce((res, p, i) => {
let segment = '';
if (i > 0 && i < points.length - 1) {
segment = getBend(points[i - 1], p, points[i + 1], borderRadius);
}
else {
segment = `${i === 0 ? 'M' : 'L'}${p.x} ${p.y}`;
}
res += segment;
return res;
}, '');
return [path, labelX, labelY, offsetX, offsetY];
}
function isNodeInitialized(node) {
return (node &&
!!(node.internals.handleBounds || node.handles?.length) &&
!!(node.measured.width || node.width || node.initialWidth));
}
function getEdgePosition(params) {
const { sourceNode, targetNode } = params;
if (!isNodeInitialized(sourceNode) || !isNodeInitialized(targetNode)) {
return null;
}
const sourceHandleBounds = sourceNode.internals.handleBounds || toHandleBounds(sourceNode.handles);
const targetHandleBounds = targetNode.internals.handleBounds || toHandleBounds(targetNode.handles);
const sourceHandle = getHandle$1(sourceHandleBounds?.source ?? [], params.sourceHandle);
const targetHandle = getHandle$1(
// when connection type is loose we can define all handles as sources and connect source -> source
params.connectionMode === ConnectionMode.Strict
? targetHandleBounds?.target ?? []
: (targetHandleBounds?.target ?? []).concat(targetHandleBounds?.source ?? []), params.targetHandle);
if (!sourceHandle || !targetHandle) {
params.onError?.('008', errorMessages['error008'](!sourceHandle ? 'source' : 'target', {
id: params.id,
sourceHandle: params.sourceHandle,
targetHandle: params.targetHandle,
}));
return null;
}
const sourcePosition = sourceHandle?.position || Position.Bottom;
const targetPosition = targetHandle?.position || Position.Top;
const source = getHandlePosition(sourceNode, sourceHandle, sourcePosition);
const target = getHandlePosition(targetNode, targetHandle, targetPosition);
return {
sourceX: source.x,
sourceY: source.y,
targetX: target.x,
targetY: target.y,
sourcePosition,
targetPosition,
};
}
function toHandleBounds(handles) {
if (!handles) {
return null;
}
const source = [];
const target = [];
for (const handle of handles) {
handle.width = handle.width ?? 1;
handle.height = handle.height ?? 1;
if (handle.type === 'source') {
source.push(handle);
}
else if (handle.type === 'target') {