diff --git a/packages/system/src/constants.ts b/packages/system/src/constants.ts index 74938c66..cd21d78b 100644 --- a/packages/system/src/constants.ts +++ b/packages/system/src/constants.ts @@ -29,3 +29,5 @@ export const infiniteExtent: CoordinateExtent = [ [Number.NEGATIVE_INFINITY, Number.NEGATIVE_INFINITY], [Number.POSITIVE_INFINITY, Number.POSITIVE_INFINITY], ]; + +export const elementSelectionKeys = ['Enter', ' ', 'Escape']; diff --git a/packages/system/src/utils/dom.ts b/packages/system/src/utils/dom.ts new file mode 100644 index 00000000..4aa51174 --- /dev/null +++ b/packages/system/src/utils/dom.ts @@ -0,0 +1,91 @@ +import type { Transform, XYPosition, SnapGrid, Dimensions, NodeOrigin, HandleElement, Position } from '../types'; +import { snapPosition, pointToRendererPoint } from './general'; + +export type GetPointerPositionParams = { + transform: Transform; + snapGrid?: SnapGrid; + snapToGrid?: boolean; +}; + +export function getPointerPosition( + event: MouseEvent | TouchEvent, + { snapGrid = [0, 0], snapToGrid = false, transform }: GetPointerPositionParams +): XYPosition & { xSnapped: number; ySnapped: number } { + 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, + }; +} + +export const getDimensions = (node: HTMLDivElement): Dimensions => ({ + width: node.offsetWidth, + height: node.offsetHeight, +}); + +export const getHostForElement = (element: HTMLElement): Document | ShadowRoot => + (element.getRootNode?.() as Document | ShadowRoot) || window?.document; + +const inputTags = ['INPUT', 'SELECT', 'TEXTAREA']; + +export function isInputDOMNode(event: KeyboardEvent): boolean { + // using composed path for handling shadow dom + const target = (event.composedPath?.()?.[0] || event.target) as HTMLElement; + const isInput = inputTags.includes(target?.nodeName) || target?.hasAttribute('contenteditable'); + // we want to be able to do a multi selection event if we are in an input field + const isModifierKey = event.ctrlKey || event.metaKey || event.shiftKey; + + // when an input field is focused we don't want to trigger deletion or movement of nodes + return (isInput && !isModifierKey) || !!target?.closest('.nokey'); +} + +export const isMouseEvent = (event: MouseEvent | TouchEvent): event is MouseEvent => 'clientX' in event; + +export const getEventPosition = (event: MouseEvent | TouchEvent, bounds?: DOMRect) => { + 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), + }; +}; + +export const getHandleBounds = ( + selector: string, + nodeElement: HTMLDivElement, + zoom: number, + nodeOrigin: NodeOrigin = [0, 0] +): HandleElement[] | null => { + const handles = nodeElement.querySelectorAll(selector); + + if (!handles || !handles.length) { + return null; + } + + const handlesArray = Array.from(handles) as HTMLDivElement[]; + const nodeBounds = nodeElement.getBoundingClientRect(); + const nodeOffset = { + x: nodeBounds.width * nodeOrigin[0], + y: nodeBounds.height * nodeOrigin[1], + }; + + return handlesArray.map((handle): HandleElement => { + const handleBounds = handle.getBoundingClientRect(); + + return { + id: handle.getAttribute('data-handleid'), + position: handle.getAttribute('data-handlepos') as unknown as Position, + x: (handleBounds.left - nodeBounds.left - nodeOffset.x) / zoom, + y: (handleBounds.top - nodeBounds.top - nodeOffset.y) / zoom, + ...getDimensions(handle), + }; + }); +}; diff --git a/packages/system/src/utils/edges/general.ts b/packages/system/src/utils/edges/general.ts index 3184933b..fce5c81a 100644 --- a/packages/system/src/utils/edges/general.ts +++ b/packages/system/src/utils/edges/general.ts @@ -1,6 +1,6 @@ -import { Transform, internalsSymbol } from '../..'; +import { Connection, Transform, errorMessages, internalsSymbol, isEdgeBase } from '../..'; import { BaseEdge, BaseNode } from '../../types'; -import { isNumeric, getOverlappingArea, boxToRect, nodeToBox, getBoundsOfBoxes } from '../utils'; +import { isNumeric, getOverlappingArea, boxToRect, nodeToBox, getBoundsOfBoxes, devWarn } from '../general'; // this is used for straight edges and simple smoothstep edges (LTR, RTL, BTT, TTB) export function getEdgeCenter({ @@ -107,3 +107,82 @@ export function isEdgeVisible({ sourceNode, targetNode, width, height, transform return getOverlappingArea(viewRect, boxToRect(edgeBox)) > 0; } + +const getEdgeId = ({ source, sourceHandle, target, targetHandle }: Connection | BaseEdge): string => + `xyflow__edge-${source}${sourceHandle || ''}-${target}${targetHandle || ''}`; + +const connectionExists = (edge: BaseEdge, edges: BaseEdge[]) => { + 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)) + ); +}; + +export const addEdgeBase = ( + edgeParams: EdgeType | Connection, + edges: EdgeType[] +): EdgeType[] => { + if (!edgeParams.source || !edgeParams.target) { + devWarn('006', errorMessages['error006']()); + + return edges; + } + + let edge: EdgeType; + if (isEdgeBase(edgeParams)) { + edge = { ...edgeParams }; + } else { + edge = { + ...edgeParams, + id: getEdgeId(edgeParams), + } as EdgeType; + } + + if (connectionExists(edge, edges)) { + return edges; + } + + return edges.concat(edge); +}; + +export type UpdateEdgeOptions = { + shouldReplaceId?: boolean; +}; + +export const updateEdgeBase = ( + oldEdge: EdgeType, + newConnection: Connection, + edges: EdgeType[], + options: UpdateEdgeOptions = { shouldReplaceId: true } +): EdgeType[] => { + 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) as EdgeType; + + 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, + } as EdgeType; + + return edges.filter((e) => e.id !== oldEdgeId).concat(edge); +}; diff --git a/packages/system/src/utils/general.ts b/packages/system/src/utils/general.ts new file mode 100644 index 00000000..bf14ea92 --- /dev/null +++ b/packages/system/src/utils/general.ts @@ -0,0 +1,175 @@ +import type { + Dimensions, + XYPosition, + CoordinateExtent, + Box, + Rect, + BaseNode, + NodeOrigin, + SnapGrid, + Transform, +} from '../types'; +import { getNodePositionWithOrigin } from './graph'; + +export const clamp = (val: number, min = 0, max = 1): number => Math.min(Math.max(val, min), max); + +export const clampPosition = (position: XYPosition = { x: 0, y: 0 }, extent: CoordinateExtent) => ({ + x: clamp(position.x, extent[0][0], extent[1][0]), + y: clamp(position.y, extent[0][1], extent[1][1]), +}); + +// returns a number between 0 and 1 that represents the velocity of the movement +// when the mouse is close to the edge of the canvas +const calcAutoPanVelocity = (value: number, min: number, max: number): number => { + if (value < min) { + return clamp(Math.abs(value - min), 1, 50) / 50; + } else if (value > max) { + return -clamp(Math.abs(value - max), 1, 50) / 50; + } + + return 0; +}; + +export const calcAutoPan = (pos: XYPosition, bounds: Dimensions): number[] => { + const xMovement = calcAutoPanVelocity(pos.x, 35, bounds.width - 35) * 20; + const yMovement = calcAutoPanVelocity(pos.y, 35, bounds.height - 35) * 20; + + return [xMovement, yMovement]; +}; + +export const getBoundsOfBoxes = (box1: Box, box2: Box): Box => ({ + 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), +}); + +export const rectToBox = ({ x, y, width, height }: Rect): Box => ({ + x, + y, + x2: x + width, + y2: y + height, +}); + +export const boxToRect = ({ x, y, x2, y2 }: Box): Rect => ({ + x, + y, + width: x2 - x, + height: y2 - y, +}); + +export const nodeToRect = (node: BaseNode, nodeOrigin: NodeOrigin = [0, 0]): Rect => { + const { positionAbsolute } = getNodePositionWithOrigin(node, node.origin || nodeOrigin); + + return { + ...positionAbsolute, + width: node.width || 0, + height: node.height || 0, + }; +}; + +export const nodeToBox = (node: BaseNode, nodeOrigin: NodeOrigin = [0, 0]): Box => { + const { positionAbsolute } = getNodePositionWithOrigin(node, node.origin || nodeOrigin); + + return { + ...positionAbsolute, + x2: positionAbsolute.x + (node.width || 0), + y2: positionAbsolute.y + (node.height || 0), + }; +}; + +export const getBoundsOfRects = (rect1: Rect, rect2: Rect): Rect => + boxToRect(getBoundsOfBoxes(rectToBox(rect1), rectToBox(rect2))); + +export const getOverlappingArea = (rectA: Rect, rectB: Rect): number => { + 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 +export const isRectObject = (obj: any): obj is Rect => + isNumeric(obj.width) && isNumeric(obj.height) && isNumeric(obj.x) && isNumeric(obj.y); + +/* eslint-disable-next-line @typescript-eslint/no-explicit-any */ +export const isNumeric = (n: any): n is number => !isNaN(n) && isFinite(n); + +// used for a11y key board controls for nodes and edges + +export const devWarn = (id: string, message: string) => { + if (process.env.NODE_ENV === 'development') { + console.warn(`[React Flow]: ${message} Help: https://reactflow.dev/error#${id}`); + } +}; + +export const getPositionWithOrigin = ({ + x, + y, + width, + height, + origin = [0, 0], +}: { + x: number; + y: number; + width: number; + height: number; + origin?: NodeOrigin; +}): XYPosition => { + if (!width || !height || origin[0] < 0 || origin[1] < 0 || origin[0] > 1 || origin[1] > 1) { + return { x, y }; + } + + return { + x: x - width * origin[0], + y: y - height * origin[1], + }; +}; + +export function snapPosition(position: XYPosition, snapGrid: SnapGrid = [1, 1]): XYPosition { + return { + x: snapGrid[0] * Math.round(position.x / snapGrid[0]), + y: snapGrid[1] * Math.round(position.y / snapGrid[1]), + }; +} + +export const pointToRendererPoint = ( + { x, y }: XYPosition, + [tx, ty, tScale]: Transform, + snapToGrid = false, + snapGrid: SnapGrid = [1, 1] +): XYPosition => { + const position: XYPosition = { + x: (x - tx) / tScale, + y: (y - ty) / tScale, + }; + + return snapToGrid ? snapPosition(position, snapGrid) : position; +}; + +export const rendererPointToPoint = ({ x, y }: XYPosition, [tx, ty, tScale]: Transform): XYPosition => { + return { + x: x * tScale + tx, + y: y * tScale + ty, + }; +}; + +export const getTransformForBounds = ( + bounds: Rect, + width: number, + height: number, + minZoom: number, + maxZoom: number, + padding = 0.1 +): Transform => { + 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, clampedZoom]; +}; diff --git a/packages/system/src/utils/graph.ts b/packages/system/src/utils/graph.ts index 4fe5e118..fa311b9b 100644 --- a/packages/system/src/utils/graph.ts +++ b/packages/system/src/utils/graph.ts @@ -3,14 +3,14 @@ import { boxToRect, clamp, clampPosition, - devWarn, getBoundsOfBoxes, getOverlappingArea, isNumeric, rectToBox, nodeToRect, - getEventPosition, -} from './utils'; + pointToRendererPoint, + getTransformForBounds, +} from './general'; import { type Connection, type Transform, @@ -21,7 +21,6 @@ import { type BaseEdge, type FitViewParamsBase, type FitViewOptionsBase, - SnapGrid, NodeDragItem, CoordinateExtent, OnError, @@ -62,106 +61,6 @@ export const getIncomersBase = incomersIds.includes(n.id)); }; -const getEdgeId = ({ source, sourceHandle, target, targetHandle }: Connection | BaseEdge): string => - `xyflow__edge-${source}${sourceHandle || ''}-${target}${targetHandle || ''}`; - -const connectionExists = (edge: BaseEdge, edges: BaseEdge[]) => { - 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)) - ); -}; - -export const addEdgeBase = ( - edgeParams: EdgeType | Connection, - edges: EdgeType[] -): EdgeType[] => { - if (!edgeParams.source || !edgeParams.target) { - devWarn('006', errorMessages['error006']()); - - return edges; - } - - let edge: EdgeType; - if (isEdgeBase(edgeParams)) { - edge = { ...edgeParams }; - } else { - edge = { - ...edgeParams, - id: getEdgeId(edgeParams), - } as EdgeType; - } - - if (connectionExists(edge, edges)) { - return edges; - } - - return edges.concat(edge); -}; - -export type UpdateEdgeOptions = { - shouldReplaceId?: boolean; -}; - -export const updateEdgeBase = ( - oldEdge: EdgeType, - newConnection: Connection, - edges: EdgeType[], - options: UpdateEdgeOptions = { shouldReplaceId: true } -): EdgeType[] => { - 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) as EdgeType; - - 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, - } as EdgeType; - - return edges.filter((e) => e.id !== oldEdgeId).concat(edge); -}; - -export const pointToRendererPoint = ( - { x, y }: XYPosition, - [tx, ty, tScale]: Transform, - snapToGrid: boolean, - snapGrid: SnapGrid -): XYPosition => { - const position: XYPosition = { - x: (x - tx) / tScale, - y: (y - ty) / tScale, - }; - - return snapToGrid ? snapPosition(position, snapGrid) : position; -}; - -export const rendererPointToPoint = ({ x, y }: XYPosition, [tx, ty, tScale]: Transform): XYPosition => { - return { - x: x * tScale + tx, - y: y * tScale + ty, - }; -}; - export const getNodePositionWithOrigin = ( node: BaseNode | undefined, nodeOrigin: NodeOrigin = [0, 0] @@ -230,8 +129,7 @@ export const getNodesInside = ( nodeOrigin: NodeOrigin = [0, 0] ): NodeType[] => { const paneRect = { - x: (rect.x - tx) / tScale, - y: (rect.y - ty) / tScale, + ...pointToRendererPoint(rect, [tx, ty, tScale]), width: rect.width / tScale, height: rect.height / tScale, }; @@ -269,26 +167,6 @@ export const getConnectedEdgesBase = nodeIds.includes(edge.source) || nodeIds.includes(edge.target)); }; -export const getTransformForBounds = ( - bounds: Rect, - width: number, - height: number, - minZoom: number, - maxZoom: number, - padding = 0.1 -): Transform => { - 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, clampedZoom]; -}; - export function fitView, Options extends FitViewOptionsBase>( { nodes, width, height, panZoom, minZoom, maxZoom, nodeOrigin = [0, 0] }: Params, options?: Options @@ -325,33 +203,6 @@ export function fitView, Options exte return false; } -export type GetPointerPositionParams = { - transform: Transform; - snapGrid?: SnapGrid; - snapToGrid?: boolean; -}; - -export function getPointerPosition( - event: MouseEvent | TouchEvent, - { snapGrid = [0, 0], snapToGrid = false, transform }: GetPointerPositionParams -): XYPosition & { xSnapped: number; ySnapped: number } { - const { x, y } = getEventPosition(event); - - const pointerPos = { - x: (x - transform[0]) / transform[2], - y: (y - transform[1]) / transform[2], - }; - - 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, - }; -} - export function calcNextPosition( node: NodeDragItem | NodeType, nextPosition: XYPosition, @@ -409,9 +260,42 @@ export function calcNextPosition( }; } -export function snapPosition(position: XYPosition, snapGrid: SnapGrid = [1, 1]) { +// helper function to get arrays of nodes and edges that can be deleted +// you can pass in a list of nodes and edges that should be deleted +// and the function only returns elements that are deletable and also handles connected nodes and child nodes +export function getElementsToRemove({ + nodesToRemove, + edgesToRemove, + nodes, + edges, +}: { + nodesToRemove: Partial[]; + edgesToRemove: Partial[]; + nodes: NodeType[]; + edges: EdgeType[]; +}): { + matchingNodes: NodeType[]; + matchingEdges: EdgeType[]; +} { + const nodeIds = nodesToRemove.map((node) => node.id); + const edgeIds = edgesToRemove.map((edge) => edge.id); + + const matchingNodes = nodes.reduce((res, node) => { + const parentHit = !nodeIds.includes(node.id) && node.parentNode && res.find((n) => n.id === node.parentNode); + const deletable = typeof node.deletable === 'boolean' ? node.deletable : true; + if (deletable && (nodeIds.includes(node.id) || parentHit)) { + res.push(node); + } + + return res; + }, []); + const deletableEdges = edges.filter((e) => (typeof e.deletable === 'boolean' ? e.deletable : true)); + const initialHitEdges = deletableEdges.filter((e) => edgeIds.includes(e.id)); + const connectedEdges = getConnectedEdgesBase(matchingNodes, deletableEdges); + const matchingEdges = [...initialHitEdges, ...connectedEdges]; + return { - x: snapGrid[0] * Math.round(position.x / snapGrid[0]), - y: snapGrid[1] * Math.round(position.y / snapGrid[1]), + matchingEdges, + matchingNodes, }; } diff --git a/packages/system/src/utils/index.ts b/packages/system/src/utils/index.ts index 3a67b341..a482f65a 100644 --- a/packages/system/src/utils/index.ts +++ b/packages/system/src/utils/index.ts @@ -1,4 +1,5 @@ -export * from './graph'; -export * from './utils'; -export * from './marker'; +export * from './dom'; export * from './edges'; +export * from './graph'; +export * from './general'; +export * from './marker'; diff --git a/packages/system/src/utils/utils.ts b/packages/system/src/utils/utils.ts deleted file mode 100644 index dc7281f0..00000000 --- a/packages/system/src/utils/utils.ts +++ /dev/null @@ -1,234 +0,0 @@ -import type { - Dimensions, - XYPosition, - CoordinateExtent, - Box, - Rect, - BaseNode, - BaseEdge, - NodeOrigin, - HandleElement, - Position, -} from '../types'; -import { getConnectedEdgesBase, getNodePositionWithOrigin } from './graph'; - -export const getDimensions = (node: HTMLDivElement): Dimensions => ({ - width: node.offsetWidth, - height: node.offsetHeight, -}); - -export const clamp = (val: number, min = 0, max = 1): number => Math.min(Math.max(val, min), max); - -export const clampPosition = (position: XYPosition = { x: 0, y: 0 }, extent: CoordinateExtent) => ({ - x: clamp(position.x, extent[0][0], extent[1][0]), - y: clamp(position.y, extent[0][1], extent[1][1]), -}); - -// returns a number between 0 and 1 that represents the velocity of the movement -// when the mouse is close to the edge of the canvas -const calcAutoPanVelocity = (value: number, min: number, max: number): number => { - if (value < min) { - return clamp(Math.abs(value - min), 1, 50) / 50; - } else if (value > max) { - return -clamp(Math.abs(value - max), 1, 50) / 50; - } - - return 0; -}; - -export const calcAutoPan = (pos: XYPosition, bounds: Dimensions): number[] => { - const xMovement = calcAutoPanVelocity(pos.x, 35, bounds.width - 35) * 20; - const yMovement = calcAutoPanVelocity(pos.y, 35, bounds.height - 35) * 20; - - return [xMovement, yMovement]; -}; - -export const getHostForElement = (element: HTMLElement): Document | ShadowRoot => - (element.getRootNode?.() as Document | ShadowRoot) || window?.document; - -export const getBoundsOfBoxes = (box1: Box, box2: Box): Box => ({ - 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), -}); - -export const rectToBox = ({ x, y, width, height }: Rect): Box => ({ - x, - y, - x2: x + width, - y2: y + height, -}); - -export const boxToRect = ({ x, y, x2, y2 }: Box): Rect => ({ - x, - y, - width: x2 - x, - height: y2 - y, -}); - -export const nodeToRect = (node: BaseNode, nodeOrigin: NodeOrigin = [0, 0]): Rect => { - const { positionAbsolute } = getNodePositionWithOrigin(node, node.origin || nodeOrigin); - - return { - ...positionAbsolute, - width: node.width || 0, - height: node.height || 0, - }; -}; - -export const nodeToBox = (node: BaseNode, nodeOrigin: NodeOrigin = [0, 0]): Box => { - const { positionAbsolute } = getNodePositionWithOrigin(node, node.origin || nodeOrigin); - - return { - ...positionAbsolute, - x2: positionAbsolute.x + (node.width || 0), - y2: positionAbsolute.y + (node.height || 0), - }; -}; - -export const getBoundsOfRects = (rect1: Rect, rect2: Rect): Rect => - boxToRect(getBoundsOfBoxes(rectToBox(rect1), rectToBox(rect2))); - -export const getOverlappingArea = (rectA: Rect, rectB: Rect): number => { - 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 -export const isRectObject = (obj: any): obj is Rect => - isNumeric(obj.width) && isNumeric(obj.height) && isNumeric(obj.x) && isNumeric(obj.y); - -/* eslint-disable-next-line @typescript-eslint/no-explicit-any */ -export const isNumeric = (n: any): n is number => !isNaN(n) && isFinite(n); - -// used for a11y key board controls for nodes and edges -export const elementSelectionKeys = ['Enter', ' ', 'Escape']; - -export const devWarn = (id: string, message: string) => { - if (process.env.NODE_ENV === 'development') { - console.warn(`[React Flow]: ${message} Help: https://reactflow.dev/error#${id}`); - } -}; - -export function isInputDOMNode(event: KeyboardEvent): boolean { - // using composed path for handling shadow dom - const target = (event.composedPath?.()?.[0] || event.target) as HTMLElement; - - const isInput = ['INPUT', 'SELECT', 'TEXTAREA'].includes(target?.nodeName) || target?.hasAttribute('contenteditable'); - // we want to be able to do a multi selection event if we are in an input field - const isModifierKey = event.ctrlKey || event.metaKey || event.shiftKey; - - // when an input field is focused we don't want to trigger deletion or movement of nodes - return (isInput && !isModifierKey) || !!target?.closest('.nokey'); -} - -export const isMouseEvent = (event: MouseEvent | TouchEvent): event is MouseEvent => 'clientX' in event; - -export const getEventPosition = (event: MouseEvent | TouchEvent, bounds?: DOMRect) => { - 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), - }; -}; - -// helper function to get arrays of nodes and edges that can be deleted -// you can pass in a list of nodes and edges that should be deleted -// and the function only returns elements that are deletable and also handles connected nodes and child nodes -export function getElementsToRemove({ - nodesToRemove, - edgesToRemove, - nodes, - edges, -}: { - nodesToRemove: Partial[]; - edgesToRemove: Partial[]; - nodes: NodeType[]; - edges: EdgeType[]; -}): { - matchingNodes: NodeType[]; - matchingEdges: EdgeType[]; -} { - const nodeIds = nodesToRemove.map((node) => node.id); - const edgeIds = edgesToRemove.map((edge) => edge.id); - - const matchingNodes = nodes.reduce((res, node) => { - const parentHit = !nodeIds.includes(node.id) && node.parentNode && res.find((n) => n.id === node.parentNode); - const deletable = typeof node.deletable === 'boolean' ? node.deletable : true; - if (deletable && (nodeIds.includes(node.id) || parentHit)) { - res.push(node); - } - - return res; - }, []); - const deletableEdges = edges.filter((e) => (typeof e.deletable === 'boolean' ? e.deletable : true)); - const initialHitEdges = deletableEdges.filter((e) => edgeIds.includes(e.id)); - const connectedEdges = getConnectedEdgesBase(matchingNodes, deletableEdges); - const matchingEdges = [...initialHitEdges, ...connectedEdges]; - - return { - matchingEdges, - matchingNodes, - }; -} - -export const getPositionWithOrigin = ({ - x, - y, - width, - height, - origin = [0, 0], -}: { - x: number; - y: number; - width: number; - height: number; - origin?: NodeOrigin; -}): XYPosition => { - if (!width || !height || origin[0] < 0 || origin[1] < 0 || origin[0] > 1 || origin[1] > 1) { - return { x, y }; - } - - return { - x: x - width * origin[0], - y: y - height * origin[1], - }; -}; - -export const getHandleBounds = ( - selector: string, - nodeElement: HTMLDivElement, - zoom: number, - nodeOrigin: NodeOrigin = [0, 0] -): HandleElement[] | null => { - const handles = nodeElement.querySelectorAll(selector); - - if (!handles || !handles.length) { - return null; - } - - const handlesArray = Array.from(handles) as HTMLDivElement[]; - const nodeBounds = nodeElement.getBoundingClientRect(); - const nodeOffset = { - x: nodeBounds.width * nodeOrigin[0], - y: nodeBounds.height * nodeOrigin[1], - }; - - return handlesArray.map((handle): HandleElement => { - const handleBounds = handle.getBoundingClientRect(); - - return { - id: handle.getAttribute('data-handleid'), - position: handle.getAttribute('data-handlepos') as unknown as Position, - x: (handleBounds.left - nodeBounds.left - nodeOffset.x) / zoom, - y: (handleBounds.top - nodeBounds.top - nodeOffset.y) / zoom, - ...getDimensions(handle), - }; - }); -};