UNPKG

@xyflow/system

Version:

xyflow core system that powers React Flow and Svelte Flow.

1,250 lines (1,237 loc) 136 kB
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') {