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.`, }; const infiniteExtent = [ [Number.NEGATIVE_INFINITY, Number.NEGATIVE_INFINITY], [Number.POSITIVE_INFINITY, Number.POSITIVE_INFINITY], ]; const elementSelectionKeys = ['Enter', ' ', 'Escape']; var ConnectionMode; (function (ConnectionMode) { ConnectionMode["Strict"] = "strict"; ConnectionMode["Loose"] = "loose"; })(ConnectionMode || (ConnectionMode = {})); 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, }; var ConnectionLineType; (function (ConnectionLineType) { ConnectionLineType["Bezier"] = "default"; ConnectionLineType["Straight"] = "straight"; ConnectionLineType["Step"] = "step"; ConnectionLineType["SmoothStep"] = "smoothstep"; ConnectionLineType["SimpleBezier"] = "simplebezier"; })(ConnectionLineType || (ConnectionLineType = {})); var MarkerType; (function (MarkerType) { MarkerType["Arrow"] = "arrow"; MarkerType["ArrowClosed"] = "arrowclosed"; })(MarkerType || (MarkerType = {})); 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); /** * Pass in a node, and get connected nodes where edge.source === node.id * @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 */ 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)); }; /** * Pass in a node, and get connected nodes where edge.target === node.id * @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 */ 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, }; }; /** * Internal function for determining a bounding box that contains all given nodes in an array. * @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 */ 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; }; /** * Get all connecting edges for a given set of nodes * @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 */ 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; return { position: { x: positionAbsolute.x - parentX + node.measured.width * origin[0], y: positionAbsolute.y - parentY + node.measured.height * 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) { let nextParentId = parentId; const positionAbsolute = { ...position }; while (nextParentId) { const parent = nodeLookup.get(nextParentId); nextParentId = parent?.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 getPointerPosition(event, { snapGrid = [0, 0], snapToGrid = false, transform }) { const { x, y } = getEventPosition(event); const pointerPos = pointToRendererPoint({ x, y }, 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); 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)]; } } /** * Get a bezier path from source to target handle * @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 * 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, }); */ 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 * @remarks 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 */ 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 reconnect an existing edge with new properties * @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 */ 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); }; /** * Get a straight path from source to target handle * @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 * 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, }); */ 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}`; } /** * Get a smooth step path from source to target handle * @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 * 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, }); */ 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') { target.push(handle); } } return { source, target, }; } function getHandlePosition(node, handle, fallbackPosition = Position.Left, center = false) { const x = (handle?.x ?? 0) + node.internals.positionAbsolute.x; const y = (handle?.y ?? 0) + node.internals.positionAbsolute.y; const { width, height } = handle ?? getNodeDimensions(node); if (center) { return { x: x + width / 2, y: y + height / 2 }; } const position = handle?.position ?? fallbackPosition; switch (position) { case Position.Top: return { x: x + width / 2, y }; case Position.Right: return { x: x + width, y: y + height / 2 }; case Position.Bottom: return { x: x + width / 2, y: y + height }; case Position.Left: return { x, y: y + height / 2 }; } } function getHandle$1(bounds, handleId) { if (!bounds) { return null; } // if no handleId is given, we use the first handle, otherwise we check for the id return (!handleId ? bounds[0] : bounds.find((d) => d.id === handleId)) || null; } function getMarkerId(marker, id) { if (!marker) { return ''; } if (typeof marker === 'string') { return marker; } const idPrefix = id ? `${id}__` : ''; return `${idPrefix}${Object.keys(marker) .sort() .map((key) => `${key}=${marker[key]}`) .join('&')}`; } function createMarkerIds(edges, { id, defaultColor, defaultMarkerStart, defaultMarkerEnd, }) { const ids = new Set(); return edges .reduce((markers, edge) => { [edge.markerStart || defaultMarkerStart, edge.markerEnd || defaultMarkerEnd].forEach((marker) => { if (marker && typeof marker === 'object') { const markerId = getMarkerId(marker, id); if (!ids.has(markerId)) { markers.push({ id: markerId, color: marker.color || defaultColor, ...marker }); ids.add(markerId); } } }); return markers; }, []) .sort((a, b) => a.id.localeCompare(b.id)); } function getNodeToolbarTransform(nodeRect, viewport, position, offset, align) { let alignmentOffset = 0.5; if (align === 'start') { alignmentOffset = 0; } else if (align === 'end') { alignmentOffset = 1; } // position === Position.Top // we set the x any y position of the toolbar based on the nodes position let pos = [ (nodeRect.x + nodeRect.width * alignmentOffset) * viewport.zoom + viewport.x, nodeRect.y * viewport.zoom + viewport.y - offset, ]; // and than shift it based on the alignment. The shift values are in %. let shift = [-100 * alignmentOffset, -100]; switch (position) { case Position.Right: pos = [ (nodeRect.x + nodeRect.width) * viewport.zoom + viewport.x + offset, (nodeRect.y + nodeRect.height * alignmentOffset) * viewport.zoom + viewport.y, ]; shift = [0, -100 * alignmentOffset]; break; case Position.Bottom: pos[1] = (nodeRect.y + nodeRect.height) * viewport.zoom + viewport.y + offset; shift[1] = 0; break; case Position.Left: pos = [ nodeRect.x * viewport.zoom + viewport.x - offset, (nodeRect.y + nodeRect.height * alignmentOffset) * viewport.zoom + viewport.y, ]; shift = [-100, -100 * alignmentOffset]; break; } return `translate(${pos[0]}px, ${pos[1]}px) translate(${shift[0]}%, ${shift[1]}%)`; } const defaultOptions = { nodeOrigin: [0, 0], nodeExtent: infiniteExtent, elevateNodesOnSelect: true, defaults: {}, }; const adoptUserNodesDefaultOptions = { ...defaultOptions, checkEquality: true, }; function mergeObjects(base, incoming) { const result = { ...base }; for (const key in incoming) { if (incoming[key] !== undefined) { // typecast is safe here, because we check for undefined result[key] = incoming[key]; } } return result; } function updateAbsolutePositions(nodeLookup, parentLookup, options) { const _options = mergeObjects(defaultOptions, options); for (const node of nodeLookup.values()) { if (!node.parentId) { continue; } updateChildNode(node, nodeLookup, parentLookup, _options); } } function adoptUserNodes(nodes, nodeLookup, parentLookup, options) { const _options = mergeObjects(adoptUserNodesDefaultOptions, options); const tmpLookup = new Map(nodeLookup); const selectedNodeZ = _options?.elevateNodesOnSelect ? 1000 : 0; nodeLookup.clear(); parentLookup.clear(); for (const userNode of nodes) { let internalNode = tmpLookup.get(userNode.id); if (_options.checkEquality && userNode === internalNode?.internals.userNode) { nodeLookup.set(userNode.id, internalNode); } else { const positionWithOrigin = getNodePositionWithOrigin(userNode, _options.nodeOrigin); const extent = isCoordinateExtent(userNode.extent) ? userNode.extent : _options.nodeExtent; const clampedPosition = clampPosition(positionWithOrigin, extent, getNodeDimensions(userNode)); internalNode = { ..._options.defaults, ...userNode, measured: { width: userNode.measured?.width, height: userNode.measured?.height, }, internals: { positionAbsolute: clampedPosition, // if user re-initializes the node or removes `measured` for whatever reason, we reset the handleBounds so that the node gets re-measured handleBounds: !userNode.measured ? undefined : internalNode?.internals.handleBounds, z: calculateZ(userNode, selectedNodeZ), userNode, }, }; nodeLookup.set(userNode.id, internalNode); } if (userNode.parentId) { updateChildNode(internalNode, nodeLookup, parentLookup, options); } } } function updateParentLookup(node, parentLookup) { if (!node.parentId) { return; } const childNodes = parentLookup.get(node.parentId); if (childNodes) { childNodes.set(node.id, node); } else { parentLookup.set(node.parentId, new Map([[node.id, node]])); } } /** * Updates positionAbsolute and zIndex of a child node and the parentLookup. */ function updateChildNode(node, nodeLookup, parentLookup, options) { const { elevateNodesOnSelect, nodeOrigin, nodeExtent } = mergeObjects(defaultOptions, options); const parentId = node.parentId; const parentNode = nodeLookup.get(parentId); if (!parentNode) { console.warn(`Parent node ${parentId} not found. Please make sure that parent nodes are in front of their child nodes in the nodes array.`); return; } updateParentLookup(node, parentLookup); const selectedNodeZ = elevateNodesOnSelect ? 1000 : 0; const { x, y, z } = calculateChildXYZ(node, parentNode, nodeOrigin, nodeExtent, selectedNodeZ); const { positionAbsolute } = node.internals; const positionChanged = x !== positionAbsolute.x || y !== positionAbsolute.y; if (positionChanged || z !== node.internals.z) { node.internals = { ...node.internals, positionAbsolute: positionChanged ? { x, y } : positionAbsolute, z, }; } } function calculateZ(node, selectedNodeZ) { return (isNumeric(node.zIndex) ? node.zIndex : 0) + (node.selected ? selectedNodeZ : 0); } function calculateChildXYZ(childNode, parentNode, nodeOrigin, nodeExtent, selectedNodeZ) { const { x: parentX, y: parentY } = parentNode.internals.positionAbsolute; const childDimensions = getNodeDimensions(childNode); const positionWithOrigin = getNodePositionWithOrigin(childNode, nodeOrigin); const clampedPosition = isCoordinateExtent(childNode.extent) ? clampPosition(positionWithOrigin, childNode.extent, childDimensions) : positionWithOrigin; let absolutePosition = clampPosition({ x: parentX + clampedPosition.x, y: parentY + clampedPosition.y }, nodeExtent, childDimensions); if (childNode.extent === 'parent') { absolutePosition = clampPositionToParent(absolutePosition, childDimensions, parentNode); } const childZ = calculateZ(childNode, selectedNodeZ); const parentZ = parentNode.internals.z ?? 0; return { x: absolutePosition.x, y: absolutePosition.y, z: parentZ > childZ ? parentZ : childZ, }; } function handleExpandParent(children, nodeLookup, parentLookup, nodeOrigin = [0, 0]) { const changes = []; const parentExpansions = new Map(); // determine the expanded rectangle the child nodes would take for each parent for (const child of children) { const parent = nodeLookup.get(child.parentId); if (!parent) { continue; } const parentRect = parentExpansions.get(child.parentId)?.expandedRect ?? nodeToRect(parent); const expandedRect = getBoundsOfRects(parentRect, child.rect); parentExpansions.set(child.parentId, { expandedRect, parent }); } if (parentExpansions.size > 0) { parentExpansions.forEach(({ expandedRect, parent }, parentId) => { // determine the position & dimensions of the parent const positionAbsolute = parent.internals.positionAbsolute; const dimensions = getNodeDimensions(parent); const origin = parent.origin ?? nodeOrigin; // determine how much the parent expands in width and position const xChange = expandedRect.x < positionAbsolute.x ? Math.round(Math.abs(positionAbsolute.x - expandedRect.x)) : 0; const yChange = expandedRect.y < positionAbsolute.y ? Math.round(Math.abs(positionAbsolute.y - expandedRect.y)) : 0; const newWidth = Math.max(dimensions.width, Math.round(expandedRect.width)); const newHeight = Math.max(dimensions.height, Math.round(expandedRect.height)); const widthChange = (newWidth - dimensions.width) * origin[0]; const heightChange = (newHeight - dimensions.height) * origin[1]; // We need to correct the position of the parent node if the origin is not [0,0] if (xChange > 0 || yChange > 0 || widthChange || heightChange) { changes.push({ id: parentId, type: 'position', position: { x: parent.position.x - xChange + widthChange, y: parent.position.y - yChange + heightChange, }, }); // We move all child nodes in the oppsite direction // so the x,y changes of the parent do not move the children parentLookup.get(parentId)?.forEach((childNode) => { if (!children.some((child) => child.id === childNode.id)) { changes.push({ id: childNode.id, type: 'position', position: { x: childNode.position.x + xChange, y: childNode.position.y + yChange, }, }); } }); } // We need to correct the dimensions of the parent node if the origin is not [0,0] if (dimensions.width < expandedRect.width || dimensions.height < expandedRect.height || xChange || yChange) { changes.push({ id: parentId, type: 'dimensions', setAttributes: true, dimensions: { width: newWidth + (xChange ? origin[0] * xChange - widthChange : 0), height: newHeight + (yChange ? origin[1] * yChange - heightChange : 0), }, }); } }); } return changes; } function updateNodeInternals(updates, nodeLookup, parentLookup, domNode, nodeOrigin, nodeExtent) { const viewportNode = domNode?.querySelector('.xyflow__viewport'); let updatedInternals = false; if (!viewportNode) { return { changes: [], updatedInternals }; } const changes = []; const style = window.getComputedStyle(viewportNode); const { m22: zoom } = new window.DOMMatrixReadOnly(style.transform); // in this array we collect nodes, that might trigger changes (like expanding parent) const parentExpandChildren = []; for (const update of updates.values()) { const node = nodeLookup.get(update.id); if (!node) { continue; } if (node.hidden) { node.internals = { ...node.internals, handleBounds: undefined, }; updatedInternals = true; } else { const dimensions = getDimensions(update.nodeElement); const dimensionChanged = node.measured.width !== dimensions.width || node.measured.height !== dimensions.height; const doUpdate = !!(dimensions.width && dimensions.height && (dimensionChanged || !node.internals.handleBounds || update.force)); if (doUpdate) { const nodeBounds = update.nodeElement.getBoundingClientRect(); const extent = isCoordinateExtent(node.extent) ? node.extent : nodeExtent; let { positionAbsolute } = node.internals; if (node.parentId && node.extent === 'parent') { positionAbsolute = clampPositionToParent(positionAbsolute, dimensions, nodeLookup.get(node.parentId)); } else if (extent) { positionAbsolute = clampPosition(positionAbsolute, extent, dimensions); } node.measured = dimensions; node.internals = { ...node.internals, positionAbsolute, handleBounds: { source: getHandleBounds('source', update.nodeElement, nodeBounds, zoom, node.id), target: getHandleBounds('target', update.nodeElement, nodeBounds, zoom, node.id), }, }; if (node.parentId) { updateChildNode(node, nodeLookup, parentLookup, { nodeOrigin }); } updatedInternals = true; if (dimensionChanged) { changes.push({ id: node.id, type: 'dimensions', dimensions, }); if (node.expandParent && node.parentId) { parentExpandChildren.push({ id: node.id, parentId: node.parentId, rect: nodeToRect(node, nodeOrigin), }); } } } } } if (parentExpandChildren.length > 0) { const parentExpandChanges = handleExpandParent(parentExpandChildren, nodeLookup, parentLookup, nodeOrigin); changes.push(...parentExpandChanges); } return { changes, updatedInternals }; } async function panBy({ delta, panZoom, transform, translateExtent, width, height, }) { if (!panZoom || (!delta.x && !delta.y)) { return Promise.resolve(false); } const nextViewport = await panZoom.setViewportConstrained({ x: transform[0] + delta.x, y: transform[1] + delta.y, zoom: transform[2], }, [ [0, 0], [width, height], ], translateExtent); const transformChanged = !!nextViewport && (nextViewport.x !== transform[0] || nextViewport.y !== transform[1] || nextViewport.k !== transform[2]); return Promise.resolve(transformChanged); } function updateConnectionLookup(connectionLookup, edgeLookup, edges) { connectionLookup.clear(); edgeLookup.clear(); for (const edge of edges) { const { source, target, sourceHandle = null, targetHandle = null } = edge; const sourceKey = `${source}-source-${sourceHandle}`; const targetKey = `${target}-target-${targetHandle}`; const prevSource = connectionLookup.get(sourceKey) || new Map(); const prevTarget = connectionLookup.get(targetKey) || new Map(); const connection = { edgeId: edge.id, source, target, sourceHandle, targetHandle }; edgeLookup.set(edge.id, edge); connectionLookup.set(sourceKey, prevSource.set(`${target}-${targetHandle}`, connection)); connectionLookup.set(targetKey, prevTarget.set(`${source}-${sourceHandle}`, connection)); } } function shallowNodeData(a, b) { if (a === null || b === null) { return false; } const _a = Array.isArray(a) ? a : [a]; const _b = Array.isArray(b) ? b : [b]; if (_a.length !== _b.length) { return false; } for (let i = 0; i < _a.length; i++) { if (_a[i].id !== _b[i].id || _a[i].type !== _b[i].type || !Object.is(_a[i].data, _b[i].data)) { return false; } } return true; } function isParentSelected(node, nodeLookup) { if (!node.parentId) { return false; } const parentNode = nodeLookup.get(node.parentId); if (!parentNode) { return false; } if (parentNode.selected) { return true; } return isParentSelected(parentNode, nodeLookup); } function hasSelector(target, selector, domNode) { let current = target; do { if (current?.matches(selector)) return true; if (current === domNode) return false; current = current.parentElement; } while (current); return false; } // looks for all selected nodes and created a NodeDragItem for each of them function getDragItems(nodeLookup, nodesDraggable, mousePos, nodeId) { const dragItems = new Map(); for (const [id, node] of nodeLookup) { if ((node.selected || node.id === nodeId) && (!node.parentId || !isParentSelected(node, nodeLookup)) && (node.draggable || (nodesDraggable && typeof node.draggable === 'undefined'))) { const internalNode = nodeLookup.get(id); if (internalNode) { dragItems.set(id, { id, position: internalNode.position || { x: 0, y: 0 }, distance: { x: mousePos.x - internalNode.internals.positionAbsolute.x, y: mousePos.y - internalNode.internals.positionAbsolute.y, }, extent: internalNode.extent, parentId: internalNode.parentId, origin: internalNode.origin, expandParent: internalNode.expandParent, internals: { positionAbsolute: internalNode.internals.positionAbsolute || { x: 0, y: 0 }, }, measured: { width: internalNode.measured.width ?? 0, height: internalNode.measured.height ?? 0, }, }); } } } return dragItems; } // returns two params: // 1. the dragged node (or the first of the list, if we are dragging a node selection) // 2. array of selected nodes (for multi selections) function getEventHandlerParams({ nodeId, dragItems, nodeLookup, dragging = true, }) { const nodesFromDragItems = []; for (const [id, dragItem] of dragItems) { const node = nodeLookup.get(id)?.internals.userNode; if (node) { nodesFromDragItems.push({ ...node, position: dragItem.position, dragging, }); } } if (!nodeId) { return [nodesFromDragItems[0], nodesFromDragItems]; } const node = nodeLookup.get(nodeId).internals.userNode; return [ { ...node, position: dragItems.get(nodeId)?.position || node.position, dragging, }, nodesFromDragItems, ]; } // eslint-disable-next-line @typescript-eslint/no-explicit-any function XYDrag({ onNodeMouseDown, getStoreItems, onDragStart, onDrag, onDragStop, }) { let lastPos = { x: null, y: null }; let autoPanId = 0; let dragItems = new Map(); let autoPanStarted = false; let mousePosition = { x: 0, y: 0 }; let containerBounds = null; let dragStarted = false; let d3Selection = null; let abortDrag = false; // prevents unintentional dragging on multitouch // public functions function update({ noDragClassName, handleSelector, domNode, isSelectable, nodeId, nodeClickDistance = 0, }) { d3Selection = select(domNode); function updateNodes({ x, y }, dragEvent) { const { nodeLookup, nodeExtent, snapGrid, snapToGrid, nodeOrigin, onNodeDrag, onSelectionDrag, onError, updateNodePositions, } = getStoreItems(); lastPos = { x, y }; let hasChange = false; let nodesBox = { x: 0, y: 0, x2: 0, y2: 0 }; if (dragItems.size > 1 && nodeExtent) { const rect = getInternalNodesBounds(dragItems); nodesBox = rectToBox(rect); } for (const [id, dragItem] of dragItems) { let nextPosition = { x: x - dragItem.distance.x, y: y - dragItem.distance.y }; if (snapToGrid) { nextPosition = snapPosition(nextPosition, snapGrid); } // if there is selection with multiple nodes and a node extent is set, we need to adjust the node extent for each node // based on its position so that the node stays at it's position relative to the selection. let adjustedNodeExtent = [ [nodeExtent[0][0], nodeExtent[0][1]], [nodeExtent[1][0], nodeExtent[1][1]], ]; if (dragItems.size > 1 && nodeExtent && !dragItem.extent) { const { positionAbsolute } = dragItem.internals; const x1 = positionAbsolute.x - nodesBox.x + nodeExtent[0][0]; const x2 = positionAbsolute.x + dragItem.measured.width - nodesBox.x2 + nodeExtent[1][0]; const y1 = positionAbsolute.y - nodesBox.y + nodeExtent[0][1]; const y2 = positionAbsolute.y + dragItem.measured.height - nodesBox.y2 + nodeExtent[1][1]; adjustedNodeExtent = [ [x1, y1], [x2, y2], ]; } const { position, positionAbsolute } = calculateNodePosition({ nodeId: id, nextPosition, nodeLookup, nodeExtent: adjustedNodeExtent, nodeOrigin, onError, }); // we want to make sure that we only fire a change event when there is a change hasChange = hasChange || dragItem.position.x !== position.x || dragItem.position.y !== position.y; dragItem.position = position; dragItem.internals.positionAbsolute = positionAbsolute; } if (!hasChange) { return; } updateNodePositions(dragItems, true); if (dragEvent && (onDrag || onNodeDrag || (!nodeId && onSelectionDrag))) { const [currentNode, currentNodes] = getEventHandlerParams({ nodeId, dragItems, nodeLookup, }); onDrag?.(dragEvent, dragItems, currentNode, currentNodes); onNodeDrag?.(dragEvent, currentNode, currentNodes); if (!nodeId) { onSelectionDrag?.(dragEvent, currentNodes); } } } async function autoPan() { if (!containerBounds) { return; } const { transform, panBy, autoPanSpeed } = getStoreItems(); const [xMovement, yMovement] = calcAutoPan(mousePosition, containerBounds, autoPanSpeed); if (xMovement !== 0 || yMovement !== 0) { lastPos.x = (lastPos.x ?? 0) - xMovement / transform[2]; lastPos.y = (lastPos.y ?? 0) - yMovement / transform[2]; if (await panBy({ x: xMovement, y: yMovement })) { updateNodes(lastPos, null); } } autoPanId = requestAnimationFrame(autoPan); } function startDrag(event) { const { nodeLookup, multiSelectionActive, nodesDraggable, transform, snapGrid, snapToGrid, selectNodesOnDrag, onNodeDragStart, onSelectionDragStart, unselectNodesAndEdges, } = getStoreItems(); dragStarted = true; if ((!selectNodesOnDrag || !isSelectable) && !multiSelectionActive && nodeId) { if (!nodeLookup.get(nodeId)?.selected) { // we need to reset selected nodes when selectNodesOnDrag=false unselectNodesAndEdges(); } } if (isSelectable && selectNodesOnDrag && nodeId) { onNodeMouseDown?.(nodeId); } const pointerPos = getPointerPosition(event.sourceEvent, { transform, snapGrid, snapToGrid }); lastPos = pointerPos; dragItems = getDragItems(nodeLookup, nodesDraggable, pointerPos, nodeId); if (dragItems.size > 0 && (onDragStart || onNodeDragStart || (!nodeId && onSelectionDragStart))) { const [currentNode, currentNodes] = getEventHandlerParams({ nodeId, dragItems, nodeLookup, }); onDragStart?.(event.sourceEvent, dragItems, currentNode, currentNodes); onNodeDragStart?.(event.sourceEvent, currentNode, currentNodes); if (!nodeId) { onSelectionDragStart?.(event.sourceEvent, currentNodes); } } } const d3DragInstance = drag() .clickDistance(nodeClickDistance) .on('start', (event) => { const { domNode, nodeDragThreshold, transform, snapGrid, snapToGrid } = getStoreItems(); abortDrag = false; if (nodeDragThreshold === 0) { startDrag(event); } const pointerPos = getPointerPosition(event.sourceEvent, { transform, snapGrid, snapToGrid }); lastPos = pointerPos; containerBounds = domNode?.getBoundingClientRect() || null; mousePosition = getEventPosition(event.sourceEvent, containerBounds); }) .on('drag', (event) => { const { autoPanOnNodeDrag, transform, snapGrid, snapToGrid, nodeDragThreshold } = getStoreItems(); const pointerPos = getPointerPosition(event.sourceEvent, { transform, snapGrid, snapToGrid }); if (event.sourceEvent.type === 'touchmove' && event.sourceEvent.touches.length > 1) { abortDrag = true; } if (abortDrag) { return; } if (!autoPanStarted && autoPanOnNodeDrag && dragStarted) { autoPanStarted = true; autoPan(); } if (!dragStarted) { const x = pointerPos.xSnapped - (lastPos.x ?? 0); const y = pointerPos.ySnapped - (lastPos.y ?? 0); const distance = Math.sqrt(x * x + y * y); if (distance > nodeDragThreshold) { startDrag(event); } } // skip events without movement if ((lastPos.x !== pointerPos.xSnapped || lastPos.y !== pointerPos.ySnapped) && dragItems && dragStarted) { // dragEvent = event.sourceEvent as MouseEvent; mousePosition = getEventPosition(event.sourceEvent, containerBounds); updateNodes(pointerPos, event.sourceEvent); } }) .on('end', (event) => { if (!dragStarted || abortDrag) { return; } autoPanStarted = false; dragStarted = false; cancelAnimationFrame(autoPanId); if (dragItems.size > 0) { const { nodeLookup, updateNodePositions, onNodeDragStop, onSelectionDragStop } = getStoreItems(); updateNodePositions(dragItems, false); if (onDragStop || onNodeDragStop || (!nodeId && onSelectionDragStop)) { const [currentNode, currentNodes] = getEventHandlerParams({ nodeId, dragItems, nodeLookup, dragging: false, }); onDragStop?.(event.sourceEvent, dragItems, currentNode, currentNodes); onNodeDragStop?.(event.sourceEvent, currentNode, currentNodes); if (!nodeId) { onSelectionDragStop?.(event.sourceEvent, currentNodes); } } } }) .filter((event) => { const target = event.target; const isDraggable = !event.button && (!noDragClassName || !hasSelector(target, `.${noDragClassName}`, domNode)) && (!handleSelector || hasSelector(target, handleSelector, domNode)); return isDraggable; }); d3Selection.call(d3DragInstance); } function destroy() { d3Selection?.on('.drag', null); } return { update, destroy, }; } function getNodesWithinDistance(position, nodeLookup, distance) { const nodes = []; const rect = { x: position.x - distance, y: position.y - distance, width: distance * 2, height: distance * 2, }; for (const node of nodeLookup.values()) { if (getOverlappingArea(rect, nodeToRect(node)) > 0) { nodes.push(node); } } return nodes; } // this distance is used for the area around the user pointer // while doing a connection for finding the closest nodes const ADDITIONAL_DISTANCE = 250; function getClosestHandle(position, connectionRadius, nodeLookup, fromHandle) { let closestHandles = []; let minDistance = Infinity; const closeNodes = getNodesWithinDistance(position, nodeLookup, connectionRadius + ADDITIONAL_DISTANCE); for (const node of closeNodes) { const allHandles = [...(node.internals.handleBounds?.source ?? []), ...(node.internals.handleBounds?.target ?? [])]; for (const handle of allHandles) { // if the handle is the same as the fromHandle we skip it if (fromHandle.nodeId === handle.nodeId && fromHandle.type === handle.type && fromHandle.id === handle.id) { continue; } // determine absolute position of the handle const { x, y } = getHandlePosition(node, handle, handle.position, true); const distance = Math.sqrt(Math.pow(x - position.x, 2) + Math.pow(y - position.y, 2)); if (distance > connectionRadius) { continue; } if (distance < minDistance) { closestHandles = [{ ...handle, x, y }]; minDistance = distance; } else if (distance === minDistance) { // when multiple handles are on the same distance we collect all of them closestHandles.push({ ...handle, x, y }); } } } if (!closestHandles.length) { return null; } // when multiple handles overlay each other we prefer the opposite handle if (closestHandles.length > 1) { const oppositeHandleType = fromHandle.type === 'source' ? 'target' : 'source'; return closestHandles.find((handle) => handle.type === oppositeHandleType) ?? closestHandles[0]; } return closestHandles[0]; } function getHandle(nodeId, handleType, handleId, nodeLookup, connectionMode, withAbsolutePosition = false) { const node = nodeLookup.get(nodeId); if (!node) { return null; } const handles = connectionMode === 'strict' ? node.internals.handleBounds?.[handleType] : [...(node.internals.handleBounds?.source ?? []), ...(node.internals.handleBounds?.target ?? [])]; const handle = (handleId ? handles?.find((h) => h.id === handleId) : handles?.[0]) ?? null; return handle && withAbsolutePosition ? { ...handle, ...getHandlePosition(node, handle, handle.position, true) } : handle; } function getHandleType(edgeUpdaterType, handleDomNode) { if (edgeUpdaterType) { return edgeUpdaterType; } else if (handleDomNode?.classList.contains('target')) { return 'target'; } else if (handleDomNode?.classList.contains('source')) { return 'source'; } return null; } function isConnectionValid(isInsideConnectionRadius, isHandleValid) { let isValid = null; if (isHandleValid) { isValid = true; } else if (isInsideConnectionRadius && !isHandleValid) { isValid = false; } return isValid; } const alwaysValid = () => true; function onPointerDown(event, { connectionMode, connectionRadius, handleId, nodeId, edgeUpdaterType, isTarget, domNode, nodeLookup, lib, autoPanOnConnect, flowId, panBy, cancelConnection, onConnectStart, onConnect, onConnectEnd, isValidConnection = alwaysValid, onReconnectEnd, updateConnection, getTransform, getFromHandle, autoPanSpeed, }) { // when xyflow is used inside a shadow root we can't use document const doc = getHostForElement(event.target); let autoPanId = 0; let closestHandle; const { x, y } = getEventPosition(event); const clickedHandle = doc?.elementFromPoint(x, y); const handleType = getHandleType(edgeUpdaterType, clickedHandle); const containerBounds = domNode?.getBoundingClientRect(); if (!containerBounds || !handleType) { return; } const fromHandleInternal = getHandle(nodeId, handleType, handleId, nodeLookup, connectionMode); if (!fromHandleInternal) { return; } let position = getEventPosition(event, containerBounds); let autoPanStarted = false; let connection = null; let isValid = false; let handleDomNode = null; // when the user is moving the mouse close to the edge of the canvas while connecting we move the canvas function autoPan() { if (!autoPanOnConnect || !containerBounds) { return; } const [x, y] = calcAutoPan(position, containerBounds, autoPanSpeed); panBy({ x, y }); autoPanId = requestAnimationFrame(autoPan); } // Stays the same for all consecutive pointermove events const fromHandle = { ...fromHandleInternal, nodeId, type: handleType, position: fromHandleInternal.position, }; const fromNodeInternal = nodeLookup.get(nodeId); const from = getHandlePosition(fromNodeInternal, fromHandle, Position.Left, true); const newConnection = { inProgress: true, isValid: null, from, fromHandle, fromPosition: fromHandle.position, fromNode: fromNodeInternal, to: position, toHandle: null, toPosition: oppositePosition[fromHandle.position], toNode: null, }; updateConnection(newConnection); let previousConnection = newConnection; onConnectStart?.(event, { nodeId, handleId, handleType }); function onPointerMove(event) { if (!getFromHandle() || !fromHandle) { onPointerUp(event); return; } const transform = getTransform(); position = getEventPosition(event, containerBounds); closestHandle = getClosestHandle(pointToRendererPoint(position, transform, false, [1, 1]), connectionRadius, nodeLookup, fromHandle); if (!autoPanStarted) { autoPan(); autoPanStarted = true; } const result = isValidHandle(event, { handle: closestHandle, connectionMode, fromNodeId: nodeId, fromHandleId: handleId, fromType: isTarget ? 'target' : 'source', isValidConnection, doc, lib, flowId, nodeLookup, }); handleDomNode = result.handleDomNode; connection = result.connection; isValid = isConnectionValid(!!closestHandle, result.isValid); const newConnection = { // from stays the same ...previousConnection, isValid, to: closestHandle && isValid ? rendererPointToPoint({ x: closestHandle.x, y: closestHandle.y }, transform) : position, toHandle: result.toHandle, toPosition: isValid && result.toHandle ? result.toHandle.position : oppositePosition[fromHandle.position], toNode: result.toHandle ? nodeLookup.get(result.toHandle.nodeId) : null, }; // we don't want to trigger an update when the connection // is snapped to the same handle as before if (isValid && closestHandle && previousConnection.toHandle && newConnection.toHandle && previousConnection.toHandle.type === newConnection.toHandle.type && previousConnection.toHandle.nodeId === newConnection.toHandle.nodeId && previousConnection.toHandle.id === newConnection.toHandle.id && previousConnection.to.x === newConnection.to.x && previousConnection.to.y === newConnection.to.y) { return; } updateConnection(newConnection); previousConnection = newConnection; } function onPointerUp(event) { if ((closestHandle || 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 // eslint-disable-next-line @typescript-eslint/no-unused-vars const { inProgress, ...connectionState } = previousConnection; const finalConnectionState = { ...connectionState, toPosition: previousConnection.toHandle ? previousConnection.toPosition : null, }; onConnectEnd?.(event, finalConnectionState); if (edgeUpdaterType) { onReconnectEnd?.(event, finalConnectionState); } cancelConnection(); cancelAnimationFrame(autoPanId); autoPanStarted = false; isValid = false; connection = null; handleDomNode = null; doc.removeEventListener('mousemove', onPointerMove); doc.removeEventListener('mouseup', onPointerUp); doc.removeEventListener('touchmove', onPointerMove); doc.removeEventListener('touchend', onPointerUp); } doc.addEventListener('mousemove', onPointerMove); doc.addEventListener('mouseup', onPointerUp); doc.addEventListener('touchmove', onPointerMove); doc.addEventListener('touchend', onPointerUp); } // checks if and returns connection in fom of an object { source: 123, target: 312 } function isValidHandle(event, { handle, connectionMode, fromNodeId, fromHandleId, fromType, doc, lib, flowId, isValidConnection = alwaysValid, nodeLookup, }) { const isTarget = fromType === 'target'; const handleDomNode = handle ? doc.querySelector(`.${lib}-flow__handle[data-id="${flowId}-${handle?.nodeId}-${handle?.id}-${handle?.type}"]`) : null; const { x, y } = getEventPosition(event); const handleBelow = doc.elementFromPoint(x, y); // we always want to prioritize the handle below the mouse cursor over the closest distance handle, // because it could be that the center of another handle is closer to the mouse pointer than the handle below the cursor const handleToCheck = handleBelow?.classList.contains(`${lib}-flow__handle`) ? handleBelow : handleDomNode; const result = { handleDomNode: handleToCheck, isValid: false, connection: null, toHandle: null, }; if (handleToCheck) { const handleType = getHandleType(undefined, handleToCheck); const handleNodeId = handleToCheck.getAttribute('data-nodeid'); const handleId = handleToCheck.getAttribute('data-handleid'); const connectable = handleToCheck.classList.contains('connectable'); const connectableEnd = handleToCheck.classList.contains('connectableend'); if (!handleNodeId || !handleType) { return result; } const connection = { source: isTarget ? handleNodeId : fromNodeId, sourceHandle: isTarget ? handleId : fromHandleId, target: isTarget ? fromNodeId : handleNodeId, targetHandle: isTarget ? fromHandleId : handleId, }; result.connection = connection; const isConnectable = connectable && connectableEnd; // in strict mode we don't allow target to target or source to source connections const isValid = isConnectable && (connectionMode === ConnectionMode.Strict ? (isTarget && handleType === 'source') || (!isTarget && handleType === 'target') : handleNodeId !== fromNodeId || handleId !== fromHandleId); result.isValid = isValid && isValidConnection(connection); result.toHandle = getHandle(handleNodeId, handleType, handleId, nodeLookup, connectionMode, false); } return result; } const XYHandle = { onPointerDown, isValid: isValidHandle, }; function XYMinimap({ domNode, panZoom, getTransform, getViewScale }) { const selection = select(domNode); function update({ translateExtent, width, height, zoomStep = 10, pannable = true, zoomable = true, inversePan = false, }) { // eslint-disable-next-line @typescript-eslint/no-explicit-any const zoomHandler = (event) => { const transform = getTransform(); if (event.sourceEvent.type !== 'wheel' || !panZoom) { return; } const pinchDelta = -event.sourceEvent.deltaY * (event.sourceEvent.deltaMode === 1 ? 0.05 : event.sourceEvent.deltaMode ? 1 : 0.002) * zoomStep; const nextZoom = transform[2] * Math.pow(2, pinchDelta); panZoom.scaleTo(nextZoom); }; let panStart = [0, 0]; // eslint-disable-next-line @typescript-eslint/no-explicit-any const panStartHandler = (event) => { if (event.sourceEvent.type === 'mousedown' || event.sourceEvent.type === 'touchstart') { panStart = [ event.sourceEvent.clientX ?? event.sourceEvent.touches[0].clientX, event.sourceEvent.clientY ?? event.sourceEvent.touches[0].clientY, ]; } }; // eslint-disable-next-line @typescript-eslint/no-explicit-any const panHandler = (event) => { const transform = getTransform(); if ((event.sourceEvent.type !== 'mousemove' && event.sourceEvent.type !== 'touchmove') || !panZoom) { return; } const panCurrent = [ event.sourceEvent.clientX ?? event.sourceEvent.touches[0].clientX, event.sourceEvent.clientY ?? event.sourceEvent.touches[0].clientY, ]; const panDelta = [panCurrent[0] - panStart[0], panCurrent[1] - panStart[1]]; panStart = panCurrent; const moveScale = getViewScale() * Math.max(transform[2], Math.log(transform[2])) * (inversePan ? -1 : 1); const position = { x: transform[0] - panDelta[0] * moveScale, y: transform[1] - panDelta[1] * moveScale, }; const extent = [ [0, 0], [width, height], ]; panZoom.setViewportConstrained({ x: position.x, y: position.y, zoom: transform[2], }, extent, translateExtent); }; const zoomAndPanHandler = zoom() .on('start', panStartHandler) // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore .on('zoom', pannable ? panHandler : null) // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore .on('zoom.wheel', zoomable ? zoomHandler : null); selection.call(zoomAndPanHandler, {}); } function destroy() { selection.on('zoom', null); } return { update, destroy, pointer, }; } /* eslint-disable @typescript-eslint/no-explicit-any */ const viewChanged = (prevViewport, eventViewport) => prevViewport.x !== eventViewport.x || prevViewport.y !== eventViewport.y || prevViewport.zoom !== eventViewport.k; const transformToViewport = (transform) => ({ x: transform.x, y: transform.y, zoom: transform.k, }); const viewportToTransform = ({ x, y, zoom }) => zoomIdentity.translate(x, y).scale(zoom); const isWrappedWithClass = (event, className) => event.target.closest(`.${className}`); const isRightClickPan = (panOnDrag, usedButton) => usedButton === 2 && Array.isArray(panOnDrag) && panOnDrag.includes(2); const getD3Transition = (selection, duration = 0, onEnd = () => { }) => { const hasDuration = typeof duration === 'number' && duration > 0; if (!hasDuration) { onEnd(); } return hasDuration ? selection.transition().duration(duration).on('end', onEnd) : selection; }; const wheelDelta = (event) => { const factor = event.ctrlKey && isMacOs() ? 10 : 1; return -event.deltaY * (event.deltaMode === 1 ? 0.05 : event.deltaMode ? 1 : 0.002) * factor; }; function createPanOnScrollHandler({ zoomPanValues, noWheelClassName, d3Selection, d3Zoom, panOnScrollMode, panOnScrollSpeed, zoomOnPinch, onPanZoomStart, onPanZoom, onPanZoomEnd, }) { return (event) => { if (isWrappedWithClass(event, noWheelClassName)) { return false; } event.preventDefault(); event.stopImmediatePropagation(); const currentZoom = d3Selection.property('__zoom').k || 1; // macos sets ctrlKey=true for pinch gesture on a trackpad if (event.ctrlKey && zoomOnPinch) { const point = pointer(event); const pinchDelta = wheelDelta(event); const zoom = currentZoom * Math.pow(2, pinchDelta); // @ts-ignore d3Zoom.scaleTo(d3Selection, zoom, point, event); return; } // increase scroll speed in firefox // firefox: deltaMode === 1; chrome: deltaMode === 0 const deltaNormalize = event.deltaMode === 1 ? 20 : 1; let deltaX = panOnScrollMode === PanOnScrollMode.Vertical ? 0 : event.deltaX * deltaNormalize; let deltaY = panOnScrollMode === PanOnScrollMode.Horizontal ? 0 : event.deltaY * deltaNormalize; // this enables vertical scrolling with shift + scroll on windows if (!isMacOs() && event.shiftKey && panOnScrollMode !== PanOnScrollMode.Vertical) { deltaX = event.deltaY * deltaNormalize; deltaY = 0; } d3Zoom.translateBy(d3Selection, -(deltaX / currentZoom) * panOnScrollSpeed, -(deltaY / currentZoom) * panOnScrollSpeed, // @ts-ignore { internal: true }); const nextViewport = transformToViewport(d3Selection.property('__zoom')); clearTimeout(zoomPanValues.panScrollTimeout); // for pan on scroll we need to handle the event calls on our own // we can't use the start, zoom and end events from d3-zoom // because start and move gets called on every scroll event and not once at the beginning if (!zoomPanValues.isPanScrolling) { zoomPanValues.isPanScrolling = true; onPanZoomStart?.(event, nextViewport); } if (zoomPanValues.isPanScrolling) { onPanZoom?.(event, nextViewport); zoomPanValues.panScrollTimeout = setTimeout(() => { onPanZoomEnd?.(event, nextViewport); zoomPanValues.isPanScrolling = false; }, 150); } }; } function createZoomOnScrollHandler({ noWheelClassName, preventScrolling, d3ZoomHandler }) { return function (event, d) { // we still want to enable pinch zooming even if preventScrolling is set to false const preventZoom = !preventScrolling && event.type === 'wheel' && !event.ctrlKey; if (preventZoom || isWrappedWithClass(event, noWheelClassName)) { return null; } event.preventDefault(); d3ZoomHandler.call(this, event, d); }; } function createPanZoomStartHandler({ zoomPanValues, onDraggingChange, onPanZoomStart }) { return (event) => { if (event.sourceEvent?.internal) { return; } const viewport = transformToViewport(event.transform); // we need to remember it here, because it's always 0 in the "zoom" event zoomPanValues.mouseButton = event.sourceEvent?.button || 0; zoomPanValues.isZoomingOrPanning = true; zoomPanValues.prevViewport = viewport; if (event.sourceEvent?.type === 'mousedown') { onDraggingChange(true); } if (onPanZoomStart) { onPanZoomStart?.(event.sourceEvent, viewport); } }; } function createPanZoomHandler({ zoomPanValues, panOnDrag, onPaneContextMenu, onTransformChange, onPanZoom, }) { return (event) => { zoomPanValues.usedRightMouseButton = !!(onPaneContextMenu && isRightClickPan(panOnDrag, zoomPanValues.mouseButton ?? 0)); if (!event.sourceEvent?.sync) { onTransformChange([event.transform.x, event.transform.y, event.transform.k]); } if (onPanZoom && !event.sourceEvent?.internal) { onPanZoom?.(event.sourceEvent, transformToViewport(event.transform)); } }; } function createPanZoomEndHandler({ zoomPanValues, panOnDrag, panOnScroll, onDraggingChange, onPanZoomEnd, onPaneContextMenu, }) { return (event) => { if (event.sourceEvent?.internal) { return; } zoomPanValues.isZoomingOrPanning = false; if (onPaneContextMenu && isRightClickPan(panOnDrag, zoomPanValues.mouseButton ?? 0) && !zoomPanValues.usedRightMouseButton && event.sourceEvent) { onPaneContextMenu(event.sourceEvent); } zoomPanValues.usedRightMouseButton = false; onDraggingChange(false); if (onPanZoomEnd && viewChanged(zoomPanValues.prevViewport, event.transform)) { const viewport = transformToViewport(event.transform); zoomPanValues.prevViewport = viewport; clearTimeout(zoomPanValues.timerId); zoomPanValues.timerId = setTimeout(() => { onPanZoomEnd?.(event.sourceEvent, viewport); }, // we need a setTimeout for panOnScroll to supress multiple end events fired during scroll panOnScroll ? 150 : 0); } }; } /* eslint-disable @typescript-eslint/no-explicit-any */ function createFilter({ zoomActivationKeyPressed, zoomOnScroll, zoomOnPinch, panOnDrag, panOnScroll, zoomOnDoubleClick, userSelectionActive, noWheelClassName, noPanClassName, lib, }) { return (event) => { const zoomScroll = zoomActivationKeyPressed || zoomOnScroll; const pinchZoom = zoomOnPinch && event.ctrlKey; if (event.button === 1 && event.type === 'mousedown' && (isWrappedWithClass(event, `${lib}-flow__node`) || isWrappedWithClass(event, `${lib}-flow__edge`))) { return true; } // if all interactions are disabled, we prevent all zoom events if (!panOnDrag && !zoomScroll && !panOnScroll && !zoomOnDoubleClick && !zoomOnPinch) { return false; } // during a selection we prevent all other interactions if (userSelectionActive) { return false; } // if the target element is inside an element with the nowheel class, we prevent zooming if (isWrappedWithClass(event, noWheelClassName) && event.type === 'wheel') { return false; } // if the target element is inside an element with the nopan class, we prevent panning if (isWrappedWithClass(event, noPanClassName) && (event.type !== 'wheel' || (panOnScroll && event.type === 'wheel' && !zoomActivationKeyPressed))) { return false; } if (!zoomOnPinch && event.ctrlKey && event.type === 'wheel') { return false; } if (!zoomOnPinch && event.type === 'touchstart' && event.touches?.length > 1) { event.preventDefault(); // if you manage to start with 2 touches, we prevent native zoom return false; } // when there is no scroll handling enabled, we prevent all wheel events if (!zoomScroll && !panOnScroll && !pinchZoom && event.type === 'wheel') { return false; } // if the pane is not movable, we prevent dragging it with mousestart or touchstart if (!panOnDrag && (event.type === 'mousedown' || event.type === 'touchstart')) { return false; } // if the pane is only movable using allowed clicks if (Array.isArray(panOnDrag) && !panOnDrag.includes(event.button) && event.type === 'mousedown') { return false; } // We only allow right clicks if pan on drag is set to right click const buttonAllowed = (Array.isArray(panOnDrag) && panOnDrag.includes(event.button)) || !event.button || event.button <= 1; // default filter for d3-zoom return (!event.ctrlKey || event.type === 'wheel') && buttonAllowed; }; } function XYPanZoom({ domNode, minZoom, maxZoom, paneClickDistance, translateExtent, viewport, onPanZoom, onPanZoomStart, onPanZoomEnd, onDraggingChange, }) { const zoomPanValues = { isZoomingOrPanning: false, usedRightMouseButton: false, prevViewport: { x: 0, y: 0, zoom: 0 }, mouseButton: 0, timerId: undefined, panScrollTimeout: undefined, isPanScrolling: false, }; const bbox = domNode.getBoundingClientRect(); const d3ZoomInstance = zoom() .clickDistance(!isNumeric(paneClickDistance) || paneClickDistance < 0 ? 0 : paneClickDistance) .scaleExtent([minZoom, maxZoom]) .translateExtent(translateExtent); const d3Selection = select(domNode).call(d3ZoomInstance); setViewportConstrained({ x: viewport.x, y: viewport.y, zoom: clamp(viewport.zoom, minZoom, maxZoom), }, [ [0, 0], [bbox.width, bbox.height], ], translateExtent); const d3ZoomHandler = d3Selection.on('wheel.zoom'); const d3DblClickZoomHandler = d3Selection.on('dblclick.zoom'); d3ZoomInstance.wheelDelta(wheelDelta); function setTransform(transform, options) { if (d3Selection) { return new Promise((resolve) => { d3ZoomInstance?.transform(getD3Transition(d3Selection, options?.duration, () => resolve(true)), transform); }); } return Promise.resolve(false); } // public functions function update({ noWheelClassName, noPanClassName, onPaneContextMenu, userSelectionActive, panOnScroll, panOnDrag, panOnScrollMode, panOnScrollSpeed, preventScrolling, zoomOnPinch, zoomOnScroll, zoomOnDoubleClick, zoomActivationKeyPressed, lib, onTransformChange, }) { if (userSelectionActive && !zoomPanValues.isZoomingOrPanning) { destroy(); } const isPanOnScroll = panOnScroll && !zoomActivationKeyPressed && !userSelectionActive; const wheelHandler = isPanOnScroll ? createPanOnScrollHandler({ zoomPanValues, noWheelClassName, d3Selection, d3Zoom: d3ZoomInstance, panOnScrollMode, panOnScrollSpeed, zoomOnPinch, onPanZoomStart, onPanZoom, onPanZoomEnd, }) : createZoomOnScrollHandler({ noWheelClassName, preventScrolling, d3ZoomHandler, }); d3Selection.on('wheel.zoom', wheelHandler, { passive: false }); if (!userSelectionActive) { // pan zoom start const startHandler = createPanZoomStartHandler({ zoomPanValues, onDraggingChange, onPanZoomStart, }); d3ZoomInstance.on('start', startHandler); // pan zoom const panZoomHandler = createPanZoomHandler({ zoomPanValues, panOnDrag, onPaneContextMenu: !!onPaneContextMenu, onPanZoom, onTransformChange, }); d3ZoomInstance.on('zoom', panZoomHandler); // pan zoom end const panZoomEndHandler = createPanZoomEndHandler({ zoomPanValues, panOnDrag, panOnScroll, onPaneContextMenu, onPanZoomEnd, onDraggingChange, }); d3ZoomInstance.on('end', panZoomEndHandler); } const filter = createFilter({ zoomActivationKeyPressed, panOnDrag, zoomOnScroll, panOnScroll, zoomOnDoubleClick, zoomOnPinch, userSelectionActive, noPanClassName, noWheelClassName, lib, }); d3ZoomInstance.filter(filter); // We cannot add zoomOnDoubleClick to the filter above because // double tapping on touch screens circumvents the filter and // dblclick.zoom is fired on the selection directly if (zoomOnDoubleClick) { d3Selection.on('dblclick.zoom', d3DblClickZoomHandler); } else { d3Selection.on('dblclick.zoom', null); } } function destroy() { d3ZoomInstance.on('zoom', null); } async function setViewportConstrained(viewport, extent, translateExtent) { const nextTransform = viewportToTransform(viewport); const contrainedTransform = d3ZoomInstance?.constrain()(nextTransform, extent, translateExtent); if (contrainedTransform) { await setTransform(contrainedTransform); } return new Promise((resolve) => resolve(contrainedTransform)); } async function setViewport(viewport, options) { const nextTransform = viewportToTransform(viewport); await setTransform(nextTransform, options); return new Promise((resolve) => resolve(nextTransform)); } function syncViewport(viewport) { if (d3Selection) { const nextTransform = viewportToTransform(viewport); const currentTransform = d3Selection.property('__zoom'); if (currentTransform.k !== viewport.zoom || currentTransform.x !== viewport.x || currentTransform.y !== viewport.y) { // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore d3ZoomInstance?.transform(d3Selection, nextTransform, null, { sync: true }); } } } function getViewport() { const transform = d3Selection ? zoomTransform(d3Selection.node()) : { x: 0, y: 0, k: 1 }; return { x: transform.x, y: transform.y, zoom: transform.k }; } function scaleTo(zoom, options) { if (d3Selection) { return new Promise((resolve) => { d3ZoomInstance?.scaleTo(getD3Transition(d3Selection, options?.duration, () => resolve(true)), zoom); }); } return Promise.resolve(false); } function scaleBy(factor, options) { if (d3Selection) { return new Promise((resolve) => { d3ZoomInstance?.scaleBy(getD3Transition(d3Selection, options?.duration, () => resolve(true)), factor); }); } return Promise.resolve(false); } function setScaleExtent(scaleExtent) { d3ZoomInstance?.scaleExtent(scaleExtent); } function setTranslateExtent(translateExtent) { d3ZoomInstance?.translateExtent(translateExtent); } function setClickDistance(distance) { const validDistance = !isNumeric(distance) || distance < 0 ? 0 : distance; d3ZoomInstance?.clickDistance(validDistance); } return { update, destroy, setViewport, setViewportConstrained, getViewport, scaleTo, scaleBy, setScaleExtent, setTranslateExtent, syncViewport, setClickDistance, }; } var ResizeControlVariant; (function (ResizeControlVariant) { ResizeControlVariant["Line"] = "line"; ResizeControlVariant["Handle"] = "handle"; })(ResizeControlVariant || (ResizeControlVariant = {})); const XY_RESIZER_HANDLE_POSITIONS = ['top-left', 'top-right', 'bottom-left', 'bottom-right']; const XY_RESIZER_LINE_POSITIONS = ['top', 'right', 'bottom', 'left']; /** * Get all connecting edges for a given set of nodes * @param width - new width of the node * @param prevWidth - previous width of the node * @param height - new height of the node * @param prevHeight - previous height of the node * @param affectsX - whether to invert the resize direction for the x axis * @param affectsY - whether to invert the resize direction for the y axis * @returns array of two numbers representing the direction of the resize for each axis, 0 = no change, 1 = increase, -1 = decrease */ function getResizeDirection({ width, prevWidth, height, prevHeight, affectsX, affectsY, }) { const deltaWidth = width - prevWidth; const deltaHeight = height - prevHeight; const direction = [deltaWidth > 0 ? 1 : deltaWidth < 0 ? -1 : 0, deltaHeight > 0 ? 1 : deltaHeight < 0 ? -1 : 0]; if (deltaWidth && affectsX) { direction[0] = direction[0] * -1; } if (deltaHeight && affectsY) { direction[1] = direction[1] * -1; } return direction; } /** * Parses the control position that is being dragged to dimensions that are being resized * @param controlPosition - position of the control that is being dragged * @returns isHorizontal, isVertical, affectsX, affectsY, */ function getControlDirection(controlPosition) { const isHorizontal = controlPosition.includes('right') || controlPosition.includes('left'); const isVertical = controlPosition.includes('bottom') || controlPosition.includes('top'); const affectsX = controlPosition.includes('left'); const affectsY = controlPosition.includes('top'); return { isHorizontal, isVertical, affectsX, affectsY, }; } function getLowerExtentClamp(lowerExtent, lowerBound) { return Math.max(0, lowerBound - lowerExtent); } function getUpperExtentClamp(upperExtent, upperBound) { return Math.max(0, upperExtent - upperBound); } function getSizeClamp(size, minSize, maxSize) { return Math.max(0, minSize - size, size - maxSize); } function xor(a, b) { return a ? !b : b; } /** * Calculates new width & height and x & y of node after resize based on pointer position * @description - Buckle up, this is a chunky one... If you want to determine the new dimensions of a node after a resize, * you have to account for all possible restrictions: min/max width/height of the node, the maximum extent the node is allowed * to move in (in this case: resize into) determined by the parent node, the minimal extent determined by child nodes * with expandParent or extent: 'parent' set and oh yeah, these things also have to work with keepAspectRatio! * The way this is done is by determining how much each of these restricting actually restricts the resize and then applying the * strongest restriction. Because the resize affects x, y and width, height and width, height of a opposing side with keepAspectRatio, * the resize amount is always kept in distX & distY amount (the distance in mouse movement) * Instead of clamping each value, we first calculate the biggest 'clamp' (for the lack of a better name) and then apply it to all values. * To complicate things nodeOrigin has to be taken into account as well. This is done by offsetting the nodes as if their origin is [0, 0], * then calculating the restrictions as usual * @param startValues - starting values of resize * @param controlDirection - dimensions affected by the resize * @param pointerPosition - the current pointer position corrected for snapping * @param boundaries - minimum and maximum dimensions of the node * @param keepAspectRatio - prevent changes of asprect ratio * @returns x, y, width and height of the node after resize */ function getDimensionsAfterResize(startValues, controlDirection, pointerPosition, boundaries, keepAspectRatio, nodeOrigin, extent, childExtent) { let { affectsX, affectsY } = controlDirection; const { isHorizontal, isVertical } = controlDirection; const isDiagonal = isHorizontal && isVertical; const { xSnapped, ySnapped } = pointerPosition; const { minWidth, maxWidth, minHeight, maxHeight } = boundaries; const { x: startX, y: startY, width: startWidth, height: startHeight, aspectRatio } = startValues; let distX = Math.floor(isHorizontal ? xSnapped - startValues.pointerX : 0); let distY = Math.floor(isVertical ? ySnapped - startValues.pointerY : 0); const newWidth = startWidth + (affectsX ? -distX : distX); const newHeight = startHeight + (affectsY ? -distY : distY); const originOffsetX = -nodeOrigin[0] * startWidth; const originOffsetY = -nodeOrigin[1] * startHeight; // Check if maxWidth, minWWidth, maxHeight, minHeight are restricting the resize let clampX = getSizeClamp(newWidth, minWidth, maxWidth); let clampY = getSizeClamp(newHeight, minHeight, maxHeight); // Check if extent is restricting the resize if (extent) { let xExtentClamp = 0; let yExtentClamp = 0; if (affectsX && distX < 0) { xExtentClamp = getLowerExtentClamp(startX + distX + originOffsetX, extent[0][0]); } else if (!affectsX && distX > 0) { xExtentClamp = getUpperExtentClamp(startX + newWidth + originOffsetX, extent[1][0]); } if (affectsY && distY < 0) { yExtentClamp = getLowerExtentClamp(startY + distY + originOffsetY, extent[0][1]); } else if (!affectsY && distY > 0) { yExtentClamp = getUpperExtentClamp(startY + newHeight + originOffsetY, extent[1][1]); } clampX = Math.max(clampX, xExtentClamp); clampY = Math.max(clampY, yExtentClamp); } // Check if the child extent is restricting the resize if (childExtent) { let xExtentClamp = 0; let yExtentClamp = 0; if (affectsX && distX > 0) { xExtentClamp = getUpperExtentClamp(startX + distX, childExtent[0][0]); } else if (!affectsX && distX < 0) { xExtentClamp = getLowerExtentClamp(startX + newWidth, childExtent[1][0]); } if (affectsY && distY > 0) { yExtentClamp = getUpperExtentClamp(startY + distY, childExtent[0][1]); } else if (!affectsY && distY < 0) { yExtentClamp = getLowerExtentClamp(startY + newHeight, childExtent[1][1]); } clampX = Math.max(clampX, xExtentClamp); clampY = Math.max(clampY, yExtentClamp); } // Check if the aspect ratio resizing of the other side is restricting the resize if (keepAspectRatio) { if (isHorizontal) { // Check if the max dimensions might be restricting the resize const aspectHeightClamp = getSizeClamp(newWidth / aspectRatio, minHeight, maxHeight) * aspectRatio; clampX = Math.max(clampX, aspectHeightClamp); // Check if the extent is restricting the resize if (extent) { let aspectExtentClamp = 0; if ((!affectsX && !affectsY) || (affectsX && !affectsY && isDiagonal)) { aspectExtentClamp = getUpperExtentClamp(startY + originOffsetY + newWidth / aspectRatio, extent[1][1]) * aspectRatio; } else { aspectExtentClamp = getLowerExtentClamp(startY + originOffsetY + (affectsX ? distX : -distX) / aspectRatio, extent[0][1]) * aspectRatio; } clampX = Math.max(clampX, aspectExtentClamp); } // Check if the child extent is restricting the resize if (childExtent) { let aspectExtentClamp = 0; if ((!affectsX && !affectsY) || (affectsX && !affectsY && isDiagonal)) { aspectExtentClamp = getLowerExtentClamp(startY + newWidth / aspectRatio, childExtent[1][1]) * aspectRatio; } else { aspectExtentClamp = getUpperExtentClamp(startY + (affectsX ? distX : -distX) / aspectRatio, childExtent[0][1]) * aspectRatio; } clampX = Math.max(clampX, aspectExtentClamp); } } // Do the same thing for vertical resizing if (isVertical) { const aspectWidthClamp = getSizeClamp(newHeight * aspectRatio, minWidth, maxWidth) / aspectRatio; clampY = Math.max(clampY, aspectWidthClamp); if (extent) { let aspectExtentClamp = 0; if ((!affectsX && !affectsY) || (affectsY && !affectsX && isDiagonal)) { aspectExtentClamp = getUpperExtentClamp(startX + newHeight * aspectRatio + originOffsetX, extent[1][0]) / aspectRatio; } else { aspectExtentClamp = getLowerExtentClamp(startX + (affectsY ? distY : -distY) * aspectRatio + originOffsetX, extent[0][0]) / aspectRatio; } clampY = Math.max(clampY, aspectExtentClamp); } if (childExtent) { let aspectExtentClamp = 0; if ((!affectsX && !affectsY) || (affectsY && !affectsX && isDiagonal)) { aspectExtentClamp = getLowerExtentClamp(startX + newHeight * aspectRatio, childExtent[1][0]) / aspectRatio; } else { aspectExtentClamp = getUpperExtentClamp(startX + (affectsY ? distY : -distY) * aspectRatio, childExtent[0][0]) / aspectRatio; } clampY = Math.max(clampY, aspectExtentClamp); } } } distY = distY + (distY < 0 ? clampY : -clampY); distX = distX + (distX < 0 ? clampX : -clampX); if (keepAspectRatio) { if (isDiagonal) { if (newWidth > newHeight * aspectRatio) { distY = (xor(affectsX, affectsY) ? -distX : distX) / aspectRatio; } else { distX = (xor(affectsX, affectsY) ? -distY : distY) * aspectRatio; } } else { if (isHorizontal) { distY = distX / aspectRatio; affectsY = affectsX; } else { distX = distY * aspectRatio; affectsX = affectsY; } } } const x = affectsX ? startX + distX : startX; const y = affectsY ? startY + distY : startY; return { width: startWidth + (affectsX ? -distX : distX), height: startHeight + (affectsY ? -distY : distY), x: nodeOrigin[0] * distX * (!affectsX ? 1 : -1) + x, y: nodeOrigin[1] * distY * (!affectsY ? 1 : -1) + y, }; } const initPrevValues = { width: 0, height: 0, x: 0, y: 0 }; const initStartValues = { ...initPrevValues, pointerX: 0, pointerY: 0, aspectRatio: 1, }; function nodeToParentExtent(node) { return [ [0, 0], [node.measured.width, node.measured.height], ]; } function nodeToChildExtent(child, parent, nodeOrigin) { const x = parent.position.x + child.position.x; const y = parent.position.y + child.position.y; const width = child.measured.width ?? 0; const height = child.measured.height ?? 0; const originOffsetX = nodeOrigin[0] * width; const originOffsetY = nodeOrigin[1] * height; return [ [x - originOffsetX, y - originOffsetY], [x + width - originOffsetX, y + height - originOffsetY], ]; } function XYResizer({ domNode, nodeId, getStoreItems, onChange, onEnd }) { const selection = select(domNode); function update({ controlPosition, boundaries, keepAspectRatio, onResizeStart, onResize, onResizeEnd, shouldResize, }) { let prevValues = { ...initPrevValues }; let startValues = { ...initStartValues }; const controlDirection = getControlDirection(controlPosition); let node = undefined; let childNodes = []; let parentNode = undefined; // Needed to fix expandParent let parentExtent = undefined; let childExtent = undefined; const dragHandler = drag() .on('start', (event) => { const { nodeLookup, transform, snapGrid, snapToGrid, nodeOrigin } = getStoreItems(); node = nodeLookup.get(nodeId); if (!node) { return; } const { xSnapped, ySnapped } = getPointerPosition(event.sourceEvent, { transform, snapGrid, snapToGrid }); prevValues = { width: node.measured.width ?? 0, height: node.measured.height ?? 0, x: node.position.x ?? 0, y: node.position.y ?? 0, }; startValues = { ...prevValues, pointerX: xSnapped, pointerY: ySnapped, aspectRatio: prevValues.width / prevValues.height, }; parentNode = undefined; if (node.parentId && (node.extent === 'parent' || node.expandParent)) { parentNode = nodeLookup.get(node.parentId); parentExtent = parentNode && node.extent === 'parent' ? nodeToParentExtent(parentNode) : undefined; } // Collect all child nodes to correct their relative positions when top/left changes // Determine largest minimal extent the parent node is allowed to resize to childNodes = []; childExtent = undefined; for (const [childId, child] of nodeLookup) { if (child.parentId === nodeId) { childNodes.push({ id: childId, position: { ...child.position }, extent: child.extent, }); if (child.extent === 'parent' || child.expandParent) { const extent = nodeToChildExtent(child, node, child.origin ?? nodeOrigin); if (childExtent) { childExtent = [ [Math.min(extent[0][0], childExtent[0][0]), Math.min(extent[0][1], childExtent[0][1])], [Math.max(extent[1][0], childExtent[1][0]), Math.max(extent[1][1], childExtent[1][1])], ]; } else { childExtent = extent; } } } } onResizeStart?.(event, { ...prevValues }); }) .on('drag', (event) => { const { transform, snapGrid, snapToGrid, nodeOrigin: storeNodeOrigin } = getStoreItems(); const pointerPosition = getPointerPosition(event.sourceEvent, { transform, snapGrid, snapToGrid }); const childChanges = []; if (!node) { return; } const { x: prevX, y: prevY, width: prevWidth, height: prevHeight } = prevValues; const change = {}; const nodeOrigin = node.origin ?? storeNodeOrigin; const { width, height, x, y } = getDimensionsAfterResize(startValues, controlDirection, pointerPosition, boundaries, keepAspectRatio, nodeOrigin, parentExtent, childExtent); const isWidthChange = width !== prevWidth; const isHeightChange = height !== prevHeight; const isXPosChange = x !== prevX && isWidthChange; const isYPosChange = y !== prevY && isHeightChange; if (!isXPosChange && !isYPosChange && !isWidthChange && !isHeightChange) { return; } if (isXPosChange || isYPosChange || nodeOrigin[0] === 1 || nodeOrigin[1] === 1) { change.x = isXPosChange ? x : prevValues.x; change.y = isYPosChange ? y : prevValues.y; prevValues.x = change.x; prevValues.y = change.y; // when top/left changes, correct the relative positions of child nodes // so that they stay in the same position if (childNodes.length > 0) { const xChange = x - prevX; const yChange = y - prevY; for (const childNode of childNodes) { childNode.position = { x: childNode.position.x - xChange + nodeOrigin[0] * (width - prevWidth), y: childNode.position.y - yChange + nodeOrigin[1] * (height - prevHeight), }; childChanges.push(childNode); } } } if (isWidthChange || isHeightChange) { change.width = isWidthChange ? width : prevValues.width; change.height = isHeightChange ? height : prevValues.height; prevValues.width = change.width; prevValues.height = change.height; } // Fix expandParent when resizing from top/left if (parentNode && node.expandParent) { const xLimit = nodeOrigin[0] * (change.width ?? 0); if (change.x && change.x < xLimit) { prevValues.x = xLimit; startValues.x = startValues.x - (change.x - xLimit); } const yLimit = nodeOrigin[1] * (change.height ?? 0); if (change.y && change.y < yLimit) { prevValues.y = yLimit; startValues.y = startValues.y - (change.y - yLimit); } } const direction = getResizeDirection({ width: prevValues.width, prevWidth, height: prevValues.height, prevHeight, affectsX: controlDirection.affectsX, affectsY: controlDirection.affectsY, }); const nextValues = { ...prevValues, direction }; const callResize = shouldResize?.(event, nextValues); if (callResize === false) { return; } onResize?.(event, nextValues); onChange(change, childChanges); }) .on('end', (event) => { onResizeEnd?.(event, { ...prevValues }); onEnd?.(); }); selection.call(dragHandler); } function destroy() { selection.on('.drag', null); } return { update, destroy, }; } export { ConnectionLineType, ConnectionMode, MarkerType, PanOnScrollMode, Position, ResizeControlVariant, SelectionMode, XYDrag, XYHandle, XYMinimap, XYPanZoom, XYResizer, XY_RESIZER_HANDLE_POSITIONS, XY_RESIZER_LINE_POSITIONS, addEdge, adoptUserNodes, areConnectionMapsEqual, boxToRect, calcAutoPan, calculateNodePosition, clamp, clampPosition, clampPositionToParent, createMarkerIds, devWarn, elementSelectionKeys, errorMessages, evaluateAbsolutePosition, fitView, getBezierEdgeCenter, getBezierPath, getBoundsOfBoxes, getBoundsOfRects, getConnectedEdges, getConnectionStatus, getDimensions, getEdgeCenter, getEdgePosition, getElementsToRemove, getElevatedEdgeZIndex, getEventPosition, getFitViewNodes, getHandleBounds, getHandlePosition, getHostForElement, getIncomers, getInternalNodesBounds, getMarkerId, getNodeDimensions, getNodePositionWithOrigin, getNodeToolbarTransform, getNodesBounds, getNodesInside, getOutgoers, getOverlappingArea, getPointerPosition, getSmoothStepPath, getStraightPath, getViewportForBounds, handleConnectionChange, handleExpandParent, infiniteExtent, initialConnection, isCoordinateExtent, isEdgeBase, isEdgeVisible, isInputDOMNode, isInternalNodeBase, isMacOs, isMouseEvent, isNodeBase, isNumeric, isRectObject, nodeHasDimensions, nodeToBox, nodeToRect, oppositePosition, panBy, pointToRendererPoint, reconnectEdge, rectToBox, rendererPointToPoint, shallowNodeData, snapPosition, updateAbsolutePositions, updateConnectionLookup, updateNodeInternals };