Merge remote-tracking branch 'upstream/v11' into feat/cancel-connection

This commit is contained in:
Alireza Sheikholmolouki
2022-09-20 17:59:43 +03:00
283 changed files with 13011 additions and 61492 deletions
@@ -0,0 +1,43 @@
import { CSSProperties } from 'react';
import { useStore } from '../../hooks/useStore';
import { ReactFlowState } from '../../types';
const style: CSSProperties = { display: 'none' };
const ariaLiveStyle: CSSProperties = {
position: 'absolute',
width: 1,
height: 1,
margin: -1,
border: 0,
padding: 0,
overflow: 'hidden',
clip: 'rect(0px, 0px, 0px, 0px)',
clipPath: 'inset(100%)',
};
export const ARIA_NODE_DESC_KEY = 'react-flow__node-desc';
export const ARIA_EDGE_DESC_KEY = 'react-flow__edge-desc';
export const ARIA_LIVE_MESSAGE = 'react-flow__arai-live';
const selector = (s: ReactFlowState) => s.ariaLiveMessage;
function A11yDescriptions({ rfId }: { rfId: string }) {
const ariaLiveMessage = useStore(selector);
return (
<>
<div id={`${ARIA_NODE_DESC_KEY}-${rfId}`} style={style}>
Press enter or space to select a node. You can then use the arrow keys to move the node around, press delete to
remove it and press escape to cancel.
</div>
<div id={`${ARIA_EDGE_DESC_KEY}-${rfId}`} style={style}>
Press enter or space to select an edge. You can then press delete to remove it or press escape to cancel.
</div>
<div id={`${ARIA_LIVE_MESSAGE}-${rfId}`} aria-live="assertive" aria-atomic="true" style={ariaLiveStyle}>
{ariaLiveMessage}
</div>
</>
);
}
export default A11yDescriptions;
@@ -0,0 +1,27 @@
import Panel from '../Panel';
import { PanelPosition, ProOptions } from '../../types';
type AttributionProps = {
proOptions?: ProOptions;
position?: PanelPosition;
};
function Attribution({ proOptions, position = 'bottom-right' }: AttributionProps) {
if (proOptions?.hideAttribution) {
return null;
}
return (
<Panel
position={position}
className="react-flow__attribution"
data-message="Please only hide this attribution when you are subscribed to React Flow Pro: https://pro.reactflow.dev"
>
<a href="https://reactflow.dev" target="_blank" rel="noopener noreferrer" aria-label="React Flow attribution">
React Flow
</a>
</Panel>
);
}
export default Attribution;
@@ -0,0 +1,123 @@
import { CSSProperties, useCallback } from 'react';
import shallow from 'zustand/shallow';
import { useStore } from '../../hooks/useStore';
import { getBezierPath } from '../Edges/BezierEdge';
import { getSmoothStepPath } from '../Edges/SmoothStepEdge';
import { ConnectionLineType, ConnectionLineComponent, HandleType, Position, ReactFlowStore } from '../../types';
import { getSimpleBezierPath } from '../Edges/SimpleBezierEdge';
import { internalsSymbol } from '../../utils';
type ConnectionLineProps = {
connectionNodeId: string;
connectionHandleType: HandleType;
connectionLineType: ConnectionLineType;
isConnectable: boolean;
connectionLineStyle?: CSSProperties;
CustomConnectionLineComponent?: ConnectionLineComponent;
};
const oppositePosition = {
[Position.Left]: Position.Right,
[Position.Right]: Position.Left,
[Position.Top]: Position.Bottom,
[Position.Bottom]: Position.Top,
};
const ConnectionLine = ({
connectionNodeId,
connectionHandleType,
connectionLineStyle,
connectionLineType = ConnectionLineType.Bezier,
isConnectable,
CustomConnectionLineComponent,
}: ConnectionLineProps) => {
const { fromNode, handleId, toX, toY } = useStore(
useCallback(
(s: ReactFlowStore) => ({
fromNode: s.nodeInternals.get(connectionNodeId),
handleId: s.connectionHandleId,
toX: (s.connectionPosition.x - s.transform[0]) / s.transform[2],
toY: (s.connectionPosition.y - s.transform[1]) / s.transform[2],
}),
[connectionNodeId]
),
shallow
);
const fromHandleBounds = fromNode?.[internalsSymbol]?.handleBounds;
if (!fromNode || !isConnectable || !fromHandleBounds?.[connectionHandleType]) {
return null;
}
const handleBound = fromHandleBounds[connectionHandleType]!;
const fromHandle = handleId ? handleBound.find((d) => d.id === handleId) : handleBound[0];
const fromHandleX = fromHandle ? fromHandle.x + fromHandle.width / 2 : (fromNode?.width ?? 0) / 2;
const fromHandleY = fromHandle ? fromHandle.y + fromHandle.height / 2 : fromNode?.height ?? 0;
const fromX = (fromNode?.positionAbsolute?.x || 0) + fromHandleX;
const fromY = (fromNode?.positionAbsolute?.y || 0) + fromHandleY;
const fromPosition = fromHandle?.position;
if (!fromPosition) {
return null;
}
const toPosition: Position = oppositePosition[fromPosition];
if (CustomConnectionLineComponent) {
return (
<g className="react-flow__connection">
<CustomConnectionLineComponent
connectionLineType={connectionLineType}
connectionLineStyle={connectionLineStyle}
fromNode={fromNode}
fromHandle={fromHandle}
fromX={fromX}
fromY={fromY}
toX={toX}
toY={toY}
fromPosition={fromPosition}
toPosition={toPosition}
/>
</g>
);
}
let dAttr = '';
const pathParams = {
sourceX: fromX,
sourceY: fromY,
sourcePosition: fromPosition,
targetX: toX,
targetY: toY,
targetPosition: toPosition,
};
if (connectionLineType === ConnectionLineType.Bezier) {
// we assume the destination position is opposite to the source position
dAttr = getBezierPath(pathParams);
} else if (connectionLineType === ConnectionLineType.Step) {
dAttr = getSmoothStepPath({
...pathParams,
borderRadius: 0,
});
} else if (connectionLineType === ConnectionLineType.SmoothStep) {
dAttr = getSmoothStepPath(pathParams);
} else if (connectionLineType === ConnectionLineType.SimpleBezier) {
dAttr = getSimpleBezierPath(pathParams);
} else {
dAttr = `M${fromX},${fromY} ${toX},${toY}`;
}
return (
<g className="react-flow__connection">
<path d={dAttr} fill="none" className="react-flow__connection-path" style={connectionLineStyle} />
</g>
);
};
ConnectionLine.displayName = 'ConnectionLine';
export default ConnectionLine;
@@ -0,0 +1,48 @@
import EdgeText from './EdgeText';
import { BaseEdgeProps } from '../../types';
const BaseEdge = ({
path,
centerX,
centerY,
label,
labelStyle,
labelShowBg,
labelBgStyle,
labelBgPadding,
labelBgBorderRadius,
style,
markerEnd,
markerStart,
interactionWidth = 20,
}: BaseEdgeProps) => {
return (
<>
<path
style={style}
d={path}
fill="none"
className="react-flow__edge-path"
markerEnd={markerEnd}
markerStart={markerStart}
/>
{interactionWidth && <path d={path} fill="none" strokeOpacity={0} strokeWidth={interactionWidth} />}
{label ? (
<EdgeText
x={centerX}
y={centerY}
label={label}
labelStyle={labelStyle}
labelShowBg={labelShowBg}
labelBgStyle={labelBgStyle}
labelBgPadding={labelBgPadding}
labelBgBorderRadius={labelBgBorderRadius}
/>
) : null}
</>
);
};
BaseEdge.displayName = 'BaseEdge';
export default BaseEdge;
@@ -0,0 +1,183 @@
import { memo } from 'react';
import { BezierEdgeProps, Position } from '../../types';
import BaseEdge from './BaseEdge';
export interface GetBezierPathParams {
sourceX: number;
sourceY: number;
sourcePosition?: Position;
targetX: number;
targetY: number;
targetPosition?: Position;
curvature?: number;
}
interface GetControlWithCurvatureParams {
pos: Position;
x1: number;
y1: number;
x2: number;
y2: number;
c: number;
}
function calculateControlOffset(distance: number, curvature: number): number {
if (distance >= 0) {
return 0.5 * distance;
} else {
return curvature * 25 * Math.sqrt(-distance);
}
}
function getControlWithCurvature({ pos, x1, y1, x2, y2, c }: GetControlWithCurvatureParams): [number, number] {
let ctX: number, ctY: number;
switch (pos) {
case Position.Left:
{
ctX = x1 - calculateControlOffset(x1 - x2, c);
ctY = y1;
}
break;
case Position.Right:
{
ctX = x1 + calculateControlOffset(x2 - x1, c);
ctY = y1;
}
break;
case Position.Top:
{
ctX = x1;
ctY = y1 - calculateControlOffset(y1 - y2, c);
}
break;
case Position.Bottom:
{
ctX = x1;
ctY = y1 + calculateControlOffset(y2 - y1, c);
}
break;
}
return [ctX, ctY];
}
export function getBezierPath({
sourceX,
sourceY,
sourcePosition = Position.Bottom,
targetX,
targetY,
targetPosition = Position.Top,
curvature = 0.25,
}: GetBezierPathParams): string {
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,
});
return `M${sourceX},${sourceY} C${sourceControlX},${sourceControlY} ${targetControlX},${targetControlY} ${targetX},${targetY}`;
}
// @TODO: this function will recalculate the control points
// one option is to let getXXXPath() return center points
// but will introduce breaking changes
// the getCenter() of other types of edges might need to change, too
export function getBezierCenter({
sourceX,
sourceY,
sourcePosition = Position.Bottom,
targetX,
targetY,
targetPosition = Position.Top,
curvature = 0.25,
}: GetBezierPathParams): [number, number, number, number] {
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,
});
// 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 xOffset = Math.abs(centerX - sourceX);
const yOffset = Math.abs(centerY - sourceY);
return [centerX, centerY, xOffset, yOffset];
}
const BezierEdge = memo(
({
sourceX,
sourceY,
targetX,
targetY,
sourcePosition = Position.Bottom,
targetPosition = Position.Top,
label,
labelStyle,
labelShowBg,
labelBgStyle,
labelBgPadding,
labelBgBorderRadius,
style,
markerEnd,
markerStart,
pathOptions,
interactionWidth,
}: BezierEdgeProps) => {
const params = {
sourceX,
sourceY,
sourcePosition,
targetX,
targetY,
targetPosition,
curvature: pathOptions?.curvature,
};
const path = getBezierPath(params);
const [centerX, centerY] = getBezierCenter(params);
return (
<BaseEdge
path={path}
centerX={centerX}
centerY={centerY}
label={label}
labelStyle={labelStyle}
labelShowBg={labelShowBg}
labelBgStyle={labelBgStyle}
labelBgPadding={labelBgPadding}
labelBgBorderRadius={labelBgBorderRadius}
style={style}
markerEnd={markerEnd}
markerStart={markerStart}
interactionWidth={interactionWidth}
/>
);
}
);
BezierEdge.displayName = 'BezierEdge';
export default BezierEdge;
@@ -0,0 +1,52 @@
import { FC, MouseEvent as ReactMouseEvent, SVGAttributes } from 'react';
import cc from 'classcat';
import { Position } from '../../types';
const shiftX = (x: number, shift: number, position: Position): number => {
if (position === Position.Left) return x - shift;
if (position === Position.Right) return x + shift;
return x;
};
const shiftY = (y: number, shift: number, position: Position): number => {
if (position === Position.Top) return y - shift;
if (position === Position.Bottom) return y + shift;
return y;
};
export interface EdgeAnchorProps extends SVGAttributes<SVGGElement> {
position: Position;
centerX: number;
centerY: number;
radius?: number;
onMouseDown: (event: ReactMouseEvent<SVGGElement, MouseEvent>) => void;
onMouseEnter: (event: ReactMouseEvent<SVGGElement, MouseEvent>) => void;
onMouseOut: (event: ReactMouseEvent<SVGGElement, MouseEvent>) => void;
type: string;
}
const EdgeUpdaterClassName = 'react-flow__edgeupdater';
export const EdgeAnchor: FC<EdgeAnchorProps> = ({
position,
centerX,
centerY,
radius = 10,
onMouseDown,
onMouseEnter,
onMouseOut,
type,
}: EdgeAnchorProps) => (
<circle
onMouseDown={onMouseDown}
onMouseEnter={onMouseEnter}
onMouseOut={onMouseOut}
className={cc([EdgeUpdaterClassName, `${EdgeUpdaterClassName}-${type}`])}
cx={shiftX(centerX, radius, position)}
cy={shiftY(centerY, radius, position)}
r={radius}
stroke="transparent"
fill="transparent"
/>
);
@@ -0,0 +1,65 @@
import { memo, useRef, useState, useEffect, FC, PropsWithChildren } from 'react';
import cc from 'classcat';
import { EdgeTextProps, Rect } from '../../types';
const EdgeText: FC<PropsWithChildren<EdgeTextProps>> = ({
x,
y,
label,
labelStyle = {},
labelShowBg = true,
labelBgStyle = {},
labelBgPadding = [2, 4],
labelBgBorderRadius = 2,
children,
className,
...rest
}) => {
const edgeRef = useRef<SVGTextElement>(null);
const [edgeTextBbox, setEdgeTextBbox] = useState<Rect>({ x: 0, y: 0, width: 0, height: 0 });
const edgeTextClasses = cc(['react-flow__edge-textwrapper', className]);
useEffect(() => {
if (edgeRef.current) {
const textBbox = edgeRef.current.getBBox();
setEdgeTextBbox({
x: textBbox.x,
y: textBbox.y,
width: textBbox.width,
height: textBbox.height,
});
}
}, [label]);
if (typeof label === 'undefined' || !label) {
return null;
}
return (
<g
transform={`translate(${x - edgeTextBbox.width / 2} ${y - edgeTextBbox.height / 2})`}
className={edgeTextClasses}
{...rest}
>
{labelShowBg && (
<rect
width={edgeTextBbox.width + 2 * labelBgPadding[0]}
x={-labelBgPadding[0]}
y={-labelBgPadding[1]}
height={edgeTextBbox.height + 2 * labelBgPadding[1]}
className="react-flow__edge-textbg"
style={labelBgStyle}
rx={labelBgBorderRadius}
ry={labelBgBorderRadius}
/>
)}
<text className="react-flow__edge-text" y={edgeTextBbox.height / 2} dy="0.3em" ref={edgeRef} style={labelStyle}>
{label}
</text>
{children}
</g>
);
};
export default memo(EdgeText);
@@ -0,0 +1,155 @@
import { memo } from 'react';
import { EdgeProps, Position } from '../../types';
import BaseEdge from './BaseEdge';
export interface GetSimpleBezierPathParams {
sourceX: number;
sourceY: number;
sourcePosition?: Position;
targetX: number;
targetY: number;
targetPosition?: Position;
}
interface GetControlParams {
pos: Position;
x1: number;
y1: number;
x2: number;
y2: number;
}
function getControl({ pos, x1, y1, x2, y2 }: GetControlParams): [number, number] {
let ctX: number, ctY: number;
switch (pos) {
case Position.Left:
case Position.Right:
{
ctX = 0.5 * (x1 + x2);
ctY = y1;
}
break;
case Position.Top:
case Position.Bottom:
{
ctX = x1;
ctY = 0.5 * (y1 + y2);
}
break;
}
return [ctX, ctY];
}
export function getSimpleBezierPath({
sourceX,
sourceY,
sourcePosition = Position.Bottom,
targetX,
targetY,
targetPosition = Position.Top,
}: GetSimpleBezierPathParams): string {
const [sourceControlX, sourceControlY] = getControl({
pos: sourcePosition,
x1: sourceX,
y1: sourceY,
x2: targetX,
y2: targetY,
});
const [targetControlX, targetControlY] = getControl({
pos: targetPosition,
x1: targetX,
y1: targetY,
x2: sourceX,
y2: sourceY,
});
return `M${sourceX},${sourceY} C${sourceControlX},${sourceControlY} ${targetControlX},${targetControlY} ${targetX},${targetY}`;
}
// @TODO: this function will recalculate the control points
// one option is to let getXXXPath() return center points
// but will introduce breaking changes
// the getCenter() of other types of edges might need to change, too
export function getSimpleBezierCenter({
sourceX,
sourceY,
sourcePosition = Position.Bottom,
targetX,
targetY,
targetPosition = Position.Top,
}: GetSimpleBezierPathParams): [number, number, number, number] {
const [sourceControlX, sourceControlY] = getControl({
pos: sourcePosition,
x1: sourceX,
y1: sourceY,
x2: targetX,
y2: targetY,
});
const [targetControlX, targetControlY] = getControl({
pos: targetPosition,
x1: targetX,
y1: targetY,
x2: sourceX,
y2: sourceY,
});
// 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 xOffset = Math.abs(centerX - sourceX);
const yOffset = Math.abs(centerY - sourceY);
return [centerX, centerY, xOffset, yOffset];
}
const SimpleBezierEdge = memo(
({
sourceX,
sourceY,
targetX,
targetY,
sourcePosition = Position.Bottom,
targetPosition = Position.Top,
label,
labelStyle,
labelShowBg,
labelBgStyle,
labelBgPadding,
labelBgBorderRadius,
style,
markerEnd,
markerStart,
interactionWidth,
}: EdgeProps) => {
const params = {
sourceX,
sourceY,
sourcePosition,
targetX,
targetY,
targetPosition,
};
const path = getSimpleBezierPath(params);
const [centerX, centerY] = getSimpleBezierCenter(params);
return (
<BaseEdge
path={path}
centerX={centerX}
centerY={centerY}
label={label}
labelStyle={labelStyle}
labelShowBg={labelShowBg}
labelBgStyle={labelBgStyle}
labelBgPadding={labelBgPadding}
labelBgBorderRadius={labelBgBorderRadius}
style={style}
markerEnd={markerEnd}
markerStart={markerStart}
interactionWidth={interactionWidth}
/>
);
}
);
SimpleBezierEdge.displayName = 'SimpleBezierEdge';
export default SimpleBezierEdge;
@@ -0,0 +1,243 @@
import { memo } from 'react';
import { getCenter } from './utils';
import { SmoothStepEdgeProps, Position, XYPosition } from '../../types';
import BaseEdge from './BaseEdge';
export interface GetSmoothStepPathParams {
sourceX: number;
sourceY: number;
sourcePosition?: Position;
targetX: number;
targetY: number;
targetPosition?: Position;
borderRadius?: number;
centerX?: number;
centerY?: number;
offset?: number;
}
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,
}: {
source: XYPosition;
sourcePosition: Position;
target: XYPosition;
}): XYPosition => {
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: XYPosition, b: XYPosition) => 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,
}: {
source: XYPosition;
sourcePosition: Position;
target: XYPosition;
targetPosition: Position;
center: XYPosition;
offset: number;
}): XYPosition[] {
const sourceDir = handleDirections[sourcePosition];
const targetDir = handleDirections[targetPosition];
const sourceGapped: XYPosition = { x: source.x + sourceDir.x * offset, y: source.y + sourceDir.y * offset };
const targetGapped: XYPosition = { 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: XYPosition[] = [];
// opposite handle positions, default case
if (sourceDir[dirAccessor] * targetDir[dirAccessor] === -1) {
// --->
// |
// >---
const verticalSplit: XYPosition[] = [
{ x: center.x, y: sourceGapped.y },
{ x: center.x, y: targetGapped.y },
];
// |
// ---
// |
const horizontalSplit: XYPosition[] = [
{ x: sourceGapped.x, y: center.y },
{ x: targetGapped.x, y: center.y },
];
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: XYPosition[] = [{ x: sourceGapped.x, y: targetGapped.y }];
const targetSource: XYPosition[] = [{ 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;
}
// 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;
}
}
}
return [source, sourceGapped, ...points, targetGapped, target];
}
function getBend(a: XYPosition, b: XYPosition, c: XYPosition, size: number): string {
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}`;
}
export function getSmoothStepPath({
sourceX,
sourceY,
sourcePosition = Position.Bottom,
targetX,
targetY,
targetPosition = Position.Top,
borderRadius = 5,
centerX,
centerY,
offset = 20,
}: GetSmoothStepPathParams): string {
const [_centerX, _centerY] = getCenter({ sourceX, sourceY, targetX, targetY });
const cX = typeof centerX !== 'undefined' ? centerX : _centerX;
const cY = typeof centerY !== 'undefined' ? centerY : _centerY;
const points = getPoints({
source: { x: sourceX, y: sourceY },
sourcePosition,
target: { x: targetX, y: targetY },
targetPosition,
center: { x: cX, y: cY },
offset,
});
return points.reduce<string>((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;
}, '');
}
const SmoothStepEdge = memo(
({
sourceX,
sourceY,
targetX,
targetY,
label,
labelStyle,
labelShowBg,
labelBgStyle,
labelBgPadding,
labelBgBorderRadius,
style,
sourcePosition = Position.Bottom,
targetPosition = Position.Top,
markerEnd,
markerStart,
pathOptions,
interactionWidth,
}: SmoothStepEdgeProps) => {
const [centerX, centerY] = getCenter({ sourceX, sourceY, targetX, targetY, sourcePosition, targetPosition });
const path = getSmoothStepPath({
sourceX,
sourceY,
sourcePosition,
targetX,
targetY,
targetPosition,
borderRadius: pathOptions?.borderRadius,
offset: pathOptions?.offset,
});
return (
<BaseEdge
path={path}
centerX={centerX}
centerY={centerY}
label={label}
labelStyle={labelStyle}
labelShowBg={labelShowBg}
labelBgStyle={labelBgStyle}
labelBgPadding={labelBgPadding}
labelBgBorderRadius={labelBgBorderRadius}
style={style}
markerEnd={markerEnd}
markerStart={markerStart}
interactionWidth={interactionWidth}
/>
);
}
);
SmoothStepEdge.displayName = 'SmoothStepEdge';
export default SmoothStepEdge;
@@ -0,0 +1,15 @@
import { memo, useMemo } from 'react';
import { SmoothStepEdgeProps } from '../../types';
import SmoothStepEdge from './SmoothStepEdge';
const StepEdge = memo((props: SmoothStepEdgeProps) => (
<SmoothStepEdge
{...props}
pathOptions={useMemo(() => ({ borderRadius: 0, offset: props.pathOptions?.offset }), [props.pathOptions?.offset])}
/>
));
StepEdge.displayName = 'StepEdge';
export default StepEdge;
@@ -0,0 +1,51 @@
import { memo } from 'react';
import BaseEdge from './BaseEdge';
import { EdgeProps } from '../../types';
const StraightEdge = memo(
({
sourceX,
sourceY,
targetX,
targetY,
label,
labelStyle,
labelShowBg,
labelBgStyle,
labelBgPadding,
labelBgBorderRadius,
style,
markerEnd,
markerStart,
interactionWidth,
}: EdgeProps) => {
const yOffset = Math.abs(targetY - sourceY) / 2;
const centerY = targetY < sourceY ? targetY + yOffset : targetY - yOffset;
const xOffset = Math.abs(targetX - sourceX) / 2;
const centerX = targetX < sourceX ? targetX + xOffset : targetX - xOffset;
return (
<BaseEdge
path={`M ${sourceX},${sourceY}L ${targetX},${targetY}`}
centerX={centerX}
centerY={centerY}
label={label}
labelStyle={labelStyle}
labelShowBg={labelShowBg}
labelBgStyle={labelBgStyle}
labelBgPadding={labelBgPadding}
labelBgBorderRadius={labelBgBorderRadius}
style={style}
markerEnd={markerEnd}
markerStart={markerStart}
interactionWidth={interactionWidth}
/>
);
}
);
StraightEdge.displayName = 'StraightEdge';
export default StraightEdge;
@@ -0,0 +1,5 @@
export { default as SimpleBezierEdge } from './SimpleBezierEdge';
export { default as SmoothStepEdge } from './SmoothStepEdge';
export { default as StepEdge } from './StepEdge';
export { default as StraightEdge } from './StraightEdge';
export { default as BezierEdge } from './BezierEdge';
@@ -0,0 +1,73 @@
import { MouseEvent as ReactMouseEvent } from 'react';
import { StoreApi } from 'zustand';
import { Edge, MarkerType, Position, ReactFlowState } from '../../types';
export const getMarkerEnd = (markerType?: MarkerType, markerEndId?: string): string => {
if (typeof markerEndId !== 'undefined' && markerEndId) {
return `url(#${markerEndId})`;
}
return typeof markerType !== 'undefined' ? `url(#react-flow__${markerType})` : 'none';
};
export interface GetCenterParams {
sourceX: number;
sourceY: number;
targetX: number;
targetY: number;
sourcePosition?: Position;
targetPosition?: Position;
}
const LeftOrRight = [Position.Left, Position.Right];
export const getCenter = ({
sourceX,
sourceY,
targetX,
targetY,
sourcePosition = Position.Bottom,
targetPosition = Position.Top,
}: GetCenterParams): [number, number, number, number] => {
const sourceIsLeftOrRight = LeftOrRight.includes(sourcePosition);
const targetIsLeftOrRight = LeftOrRight.includes(targetPosition);
// we expect flows to be horizontal or vertical (all handles left or right respectively top or bottom)
// a mixed edge is when one the source is on the left and the target is on the top for example.
const mixedEdge = (sourceIsLeftOrRight && !targetIsLeftOrRight) || (targetIsLeftOrRight && !sourceIsLeftOrRight);
if (mixedEdge) {
const xOffset = sourceIsLeftOrRight ? Math.abs(targetX - sourceX) : 0;
const centerX = sourceX > targetX ? sourceX - xOffset : sourceX + xOffset;
const yOffset = sourceIsLeftOrRight ? 0 : Math.abs(targetY - sourceY);
const centerY = sourceY < targetY ? sourceY + yOffset : sourceY - yOffset;
return [centerX, centerY, xOffset, yOffset];
}
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];
};
export function getMouseHandler(
id: string,
getState: StoreApi<ReactFlowState>['getState'],
handler?: (event: ReactMouseEvent<SVGGElement, MouseEvent>, edge: Edge) => void
) {
return handler === undefined
? handler
: (event: ReactMouseEvent<SVGGElement, MouseEvent>) => {
const edge = getState().edges.find((e) => e.id === id);
if (edge) {
handler(event, { ...edge });
}
};
}
@@ -0,0 +1,229 @@
import { memo, ComponentType, useState, useMemo, KeyboardEvent, useRef } from 'react';
import cc from 'classcat';
import { useStoreApi } from '../../hooks/useStore';
import { ARIA_EDGE_DESC_KEY } from '../A11yDescriptions';
import { handleMouseDown } from '../Handle/handler';
import { EdgeAnchor } from './EdgeAnchor';
import { getMarkerId } from '../../utils/graph';
import { getMouseHandler } from './utils';
import { EdgeProps, WrapEdgeProps, Connection } from '../../types';
import { elementSelectionKeys } from '../../utils';
export default (EdgeComponent: ComponentType<EdgeProps>) => {
const EdgeWrapper = ({
id,
className,
type,
data,
onClick,
onEdgeDoubleClick,
selected,
animated,
label,
labelStyle,
labelShowBg,
labelBgStyle,
labelBgPadding,
labelBgBorderRadius,
style,
source,
target,
sourceX,
sourceY,
targetX,
targetY,
sourcePosition,
targetPosition,
elementsSelectable,
hidden,
sourceHandleId,
targetHandleId,
onContextMenu,
onMouseEnter,
onMouseMove,
onMouseLeave,
edgeUpdaterRadius,
onEdgeUpdate,
onEdgeUpdateStart,
onEdgeUpdateEnd,
markerEnd,
markerStart,
rfId,
ariaLabel,
disableKeyboardA11y,
pathOptions,
interactionWidth,
}: WrapEdgeProps): JSX.Element | null => {
const edgeRef = useRef<SVGGElement>(null);
const [updateHover, setUpdateHover] = useState<boolean>(false);
const [updating, setUpdating] = useState<boolean>(false);
const store = useStoreApi();
const markerStartUrl = useMemo(() => `url(#${getMarkerId(markerStart, rfId)})`, [markerStart, rfId]);
const markerEndUrl = useMemo(() => `url(#${getMarkerId(markerEnd, rfId)})`, [markerEnd, rfId]);
if (hidden) {
return null;
}
const onEdgeClick = (event: React.MouseEvent<SVGGElement, MouseEvent>): void => {
const { edges, addSelectedEdges } = store.getState();
if (elementsSelectable) {
store.setState({ nodesSelectionActive: false });
addSelectedEdges([id]);
}
if (onClick) {
const edge = edges.find((e) => e.id === id)!;
onClick(event, edge);
}
};
const onEdgeDoubleClickHandler = getMouseHandler(id, store.getState, onEdgeDoubleClick);
const onEdgeContextMenu = getMouseHandler(id, store.getState, onContextMenu);
const onEdgeMouseEnter = getMouseHandler(id, store.getState, onMouseEnter);
const onEdgeMouseMove = getMouseHandler(id, store.getState, onMouseMove);
const onEdgeMouseLeave = getMouseHandler(id, store.getState, onMouseLeave);
const handleEdgeUpdater = (event: React.MouseEvent<SVGGElement, MouseEvent>, isSourceHandle: boolean) => {
const nodeId = isSourceHandle ? target : source;
const handleId = (isSourceHandle ? targetHandleId : sourceHandleId) || null;
const handleType = isSourceHandle ? 'target' : 'source';
const isValidConnection = () => true;
const isTarget = isSourceHandle;
const edge = store.getState().edges.find((e) => e.id === id)!;
setUpdating(true);
onEdgeUpdateStart?.(event, edge, handleType);
const _onEdgeUpdateEnd = (evt: MouseEvent) => {
setUpdating(false);
onEdgeUpdateEnd?.(evt, edge, handleType);
};
const onConnectEdge = (connection: Connection) => onEdgeUpdate?.(edge, connection);
handleMouseDown({
event,
handleId,
nodeId,
onConnect: onConnectEdge,
isTarget,
getState: store.getState,
setState: store.setState,
isValidConnection,
elementEdgeUpdaterType: handleType,
onEdgeUpdateEnd: _onEdgeUpdateEnd,
});
};
const onEdgeUpdaterSourceMouseDown = (event: React.MouseEvent<SVGGElement, MouseEvent>): void =>
handleEdgeUpdater(event, true);
const onEdgeUpdaterTargetMouseDown = (event: React.MouseEvent<SVGGElement, MouseEvent>): void =>
handleEdgeUpdater(event, false);
const onEdgeUpdaterMouseEnter = () => setUpdateHover(true);
const onEdgeUpdaterMouseOut = () => setUpdateHover(false);
const inactive = !elementsSelectable && !onClick;
const handleEdgeUpdate = typeof onEdgeUpdate !== 'undefined';
const onKeyDown = (event: KeyboardEvent) => {
if (elementSelectionKeys.includes(event.key) && elementsSelectable) {
const { unselectNodesAndEdges, addSelectedEdges, edges } = store.getState();
const unselect = event.key === 'Escape';
if (unselect) {
edgeRef.current?.blur();
unselectNodesAndEdges({ edges: [edges.find((e) => e.id === id)!] });
} else {
addSelectedEdges([id]);
}
}
};
return (
<g
className={cc([
'react-flow__edge',
`react-flow__edge-${type}`,
className,
{ selected, animated, inactive, updating: updateHover },
])}
onClick={onEdgeClick}
onDoubleClick={onEdgeDoubleClickHandler}
onContextMenu={onEdgeContextMenu}
onMouseEnter={onEdgeMouseEnter}
onMouseMove={onEdgeMouseMove}
onMouseLeave={onEdgeMouseLeave}
onKeyDown={disableKeyboardA11y ? undefined : onKeyDown}
tabIndex={disableKeyboardA11y ? undefined : 0}
role={disableKeyboardA11y ? undefined : 'button'}
data-testid={`rf__edge-${id}`}
aria-label={ariaLabel === null ? undefined : ariaLabel ? ariaLabel : `Edge from ${source} to ${target}`}
aria-describedby={disableKeyboardA11y ? undefined : `${ARIA_EDGE_DESC_KEY}-${rfId}`}
ref={edgeRef}
>
{!updating && (
<EdgeComponent
id={id}
source={source}
target={target}
selected={selected}
animated={animated}
label={label}
labelStyle={labelStyle}
labelShowBg={labelShowBg}
labelBgStyle={labelBgStyle}
labelBgPadding={labelBgPadding}
labelBgBorderRadius={labelBgBorderRadius}
data={data}
style={style}
sourceX={sourceX}
sourceY={sourceY}
targetX={targetX}
targetY={targetY}
sourcePosition={sourcePosition}
targetPosition={targetPosition}
sourceHandleId={sourceHandleId}
targetHandleId={targetHandleId}
markerStart={markerStartUrl}
markerEnd={markerEndUrl}
pathOptions={pathOptions}
interactionWidth={interactionWidth}
/>
)}
{handleEdgeUpdate && (
<>
<EdgeAnchor
position={sourcePosition}
centerX={sourceX}
centerY={sourceY}
radius={edgeUpdaterRadius}
onMouseDown={onEdgeUpdaterSourceMouseDown}
onMouseEnter={onEdgeUpdaterMouseEnter}
onMouseOut={onEdgeUpdaterMouseOut}
type="source"
/>
<EdgeAnchor
position={targetPosition}
centerX={targetX}
centerY={targetY}
radius={edgeUpdaterRadius}
onMouseDown={onEdgeUpdaterTargetMouseDown}
onMouseEnter={onEdgeUpdaterMouseEnter}
onMouseOut={onEdgeUpdaterMouseOut}
type="target"
/>
</>
)}
</g>
);
};
EdgeWrapper.displayName = 'EdgeWrapper';
return memo(EdgeWrapper);
};
@@ -0,0 +1,197 @@
import { MouseEvent as ReactMouseEvent } from 'react';
import { StoreApi } from 'zustand';
import { getHostForElement } from '../../utils';
import { OnConnect, ConnectionMode, Connection, HandleType, ReactFlowState } from '../../types';
type ValidConnectionFunc = (connection: Connection) => boolean;
type Result = {
elementBelow: Element | null;
isValid: boolean;
connection: Connection;
isHoveringHandle: boolean;
};
// checks if element below mouse is a handle and returns connection in form of an object { source: 123, target: 312 }
export function checkElementBelowIsValid(
event: MouseEvent,
connectionMode: ConnectionMode,
isTarget: boolean,
nodeId: string,
handleId: string | null,
isValidConnection: ValidConnectionFunc,
doc: Document | ShadowRoot
) {
const elementBelow = doc.elementFromPoint(event.clientX, event.clientY);
const elementBelowIsTarget = elementBelow?.classList.contains('target') || false;
const elementBelowIsSource = elementBelow?.classList.contains('source') || false;
const result: Result = {
elementBelow,
isValid: false,
connection: { source: null, target: null, sourceHandle: null, targetHandle: null },
isHoveringHandle: false,
};
if (elementBelow && (elementBelowIsTarget || elementBelowIsSource)) {
result.isHoveringHandle = true;
const elementBelowNodeId = elementBelow.getAttribute('data-nodeid');
const elementBelowHandleId = elementBelow.getAttribute('data-handleid');
const connection: Connection = isTarget
? {
source: elementBelowNodeId,
sourceHandle: elementBelowHandleId,
target: nodeId,
targetHandle: handleId,
}
: {
source: nodeId,
sourceHandle: handleId,
target: elementBelowNodeId,
targetHandle: elementBelowHandleId,
};
result.connection = connection;
// in strict mode we don't allow target to target or source to source connections
const isValid =
connectionMode === ConnectionMode.Strict
? (isTarget && elementBelowIsSource) || (!isTarget && elementBelowIsTarget)
: true;
if (isValid) {
result.isValid = isValidConnection(connection);
}
}
return result;
}
function resetRecentHandle(hoveredHandle: Element): void {
hoveredHandle?.classList.remove('react-flow__handle-valid');
hoveredHandle?.classList.remove('react-flow__handle-connecting');
}
export function handleMouseDown({
event,
handleId,
nodeId,
onConnect,
isTarget,
getState,
setState,
isValidConnection,
elementEdgeUpdaterType,
onEdgeUpdateEnd,
}: {
event: ReactMouseEvent;
handleId: string | null;
nodeId: string;
onConnect: OnConnect;
isTarget: boolean;
getState: StoreApi<ReactFlowState>['getState'];
setState: StoreApi<ReactFlowState>['setState'];
isValidConnection: ValidConnectionFunc;
elementEdgeUpdaterType?: HandleType;
onEdgeUpdateEnd?: (evt: MouseEvent) => void;
}): void {
const reactFlowNode = (event.target as Element).closest('.react-flow');
// when react-flow is used inside a shadow root we can't use document
const doc = getHostForElement(event.target as HTMLElement);
if (!doc) {
return;
}
const elementBelow = doc.elementFromPoint(event.clientX, event.clientY);
const elementBelowIsTarget = elementBelow?.classList.contains('target');
const elementBelowIsSource = elementBelow?.classList.contains('source');
if (!reactFlowNode || (!elementBelowIsTarget && !elementBelowIsSource && !elementEdgeUpdaterType)) {
return;
}
const { onConnectStart, connectionMode } = getState();
const handleType = elementEdgeUpdaterType ? elementEdgeUpdaterType : elementBelowIsTarget ? 'target' : 'source';
const containerBounds = reactFlowNode.getBoundingClientRect();
let recentHoveredHandle: Element;
setState({
connectionPosition: {
x: event.clientX - containerBounds.left,
y: event.clientY - containerBounds.top,
},
connectionNodeId: nodeId,
connectionHandleId: handleId,
connectionHandleType: handleType,
});
onConnectStart?.(event, { nodeId, handleId, handleType });
function onMouseMove(event: MouseEvent) {
setState({
connectionPosition: {
x: event.clientX - containerBounds.left,
y: event.clientY - containerBounds.top,
},
});
const { connection, elementBelow, isValid, isHoveringHandle } = checkElementBelowIsValid(
event,
connectionMode,
isTarget,
nodeId,
handleId,
isValidConnection,
doc
);
if (!isHoveringHandle) {
return resetRecentHandle(recentHoveredHandle);
}
if (connection.source !== connection.target && elementBelow) {
resetRecentHandle(recentHoveredHandle);
recentHoveredHandle = elementBelow;
elementBelow.classList.add('react-flow__handle-connecting');
elementBelow.classList.toggle('react-flow__handle-valid', isValid);
}
}
function onMouseUp(event: MouseEvent) {
const { connection, isValid } = checkElementBelowIsValid(
event,
connectionMode,
isTarget,
nodeId,
handleId,
isValidConnection,
doc
);
if (isValid) {
onConnect?.(connection);
}
getState().onConnectEnd?.(event);
if (elementEdgeUpdaterType && onEdgeUpdateEnd) {
onEdgeUpdateEnd(event);
}
resetRecentHandle(recentHoveredHandle);
setState({
connectionNodeId: null,
connectionHandleId: null,
connectionHandleType: null,
});
doc.removeEventListener('mousemove', onMouseMove as EventListenerOrEventListenerObject);
doc.removeEventListener('mouseup', onMouseUp as EventListenerOrEventListenerObject);
}
doc.addEventListener('mousemove', onMouseMove as EventListenerOrEventListenerObject);
doc.addEventListener('mouseup', onMouseUp as EventListenerOrEventListenerObject);
}
@@ -0,0 +1,139 @@
import { memo, useContext, HTMLAttributes, forwardRef, MouseEvent as ReactMouseEvent } from 'react';
import cc from 'classcat';
import shallow from 'zustand/shallow';
import { useStore, useStoreApi } from '../../hooks/useStore';
import NodeIdContext from '../../contexts/NodeIdContext';
import { HandleProps, Connection, ReactFlowState, Position } from '../../types';
import { checkElementBelowIsValid, handleMouseDown } from './handler';
import { getHostForElement } from '../../utils';
import { addEdge } from '../../utils/graph';
const alwaysValid = () => true;
export type HandleComponentProps = HandleProps & Omit<HTMLAttributes<HTMLDivElement>, 'id'>;
const selector = (s: ReactFlowState) => ({
connectionStartHandle: s.connectionStartHandle,
connectOnClick: s.connectOnClick,
noPanClassName: s.noPanClassName,
});
const Handle = forwardRef<HTMLDivElement, HandleComponentProps>(
(
{
type = 'source',
position = Position.Top,
isValidConnection = alwaysValid,
isConnectable = true,
id,
onConnect,
children,
className,
onMouseDown,
...rest
},
ref
) => {
const store = useStoreApi();
const nodeId = useContext(NodeIdContext) as string;
const { connectionStartHandle, connectOnClick, noPanClassName } = useStore(selector, shallow);
const handleId = id || null;
const isTarget = type === 'target';
const onConnectExtended = (params: Connection) => {
const { defaultEdgeOptions, onConnect: onConnectAction, hasDefaultEdges } = store.getState();
const edgeParams = {
...defaultEdgeOptions,
...params,
};
if (hasDefaultEdges) {
const { edges } = store.getState();
store.setState({ edges: addEdge(edgeParams, edges) });
}
onConnectAction?.(edgeParams);
onConnect?.(edgeParams);
};
const onMouseDownHandler = (event: ReactMouseEvent<HTMLDivElement>) => {
if (event.button === 0) {
handleMouseDown({
event,
handleId,
nodeId,
onConnect: onConnectExtended,
isTarget,
getState: store.getState,
setState: store.setState,
isValidConnection,
});
}
onMouseDown?.(event);
};
const onClick = (event: ReactMouseEvent) => {
const { onClickConnectStart, onClickConnectEnd, connectionMode } = store.getState();
if (!connectionStartHandle) {
onClickConnectStart?.(event, { nodeId, handleId, handleType: type });
store.setState({ connectionStartHandle: { nodeId, type, handleId } });
return;
}
const doc = getHostForElement(event.target as HTMLElement);
const { connection, isValid } = checkElementBelowIsValid(
event as unknown as MouseEvent,
connectionMode,
connectionStartHandle.type === 'target',
connectionStartHandle.nodeId,
connectionStartHandle.handleId || null,
isValidConnection,
doc
);
if (isValid) {
onConnectExtended(connection);
}
onClickConnectEnd?.(event as unknown as MouseEvent);
store.setState({ connectionStartHandle: null });
};
return (
<div
data-handleid={handleId}
data-nodeid={nodeId}
data-handlepos={position}
className={cc([
'react-flow__handle',
`react-flow__handle-${position}`,
'nodrag',
noPanClassName,
className,
{
source: !isTarget,
target: isTarget,
connectable: isConnectable,
connecting:
connectionStartHandle?.nodeId === nodeId &&
connectionStartHandle?.handleId === handleId &&
connectionStartHandle?.type === type,
},
])}
onMouseDown={onMouseDownHandler}
onClick={connectOnClick ? onClick : undefined}
ref={ref}
{...rest}
>
{children}
</div>
);
}
);
Handle.displayName = 'Handle';
export default memo(Handle);
@@ -0,0 +1,23 @@
import { memo } from 'react';
import Handle from '../../components/Handle';
import { NodeProps, Position } from '../../types';
const DefaultNode = ({
data,
isConnectable,
targetPosition = Position.Top,
sourcePosition = Position.Bottom,
}: NodeProps) => {
return (
<>
<Handle type="target" position={targetPosition} isConnectable={isConnectable} />
{data?.label}
<Handle type="source" position={sourcePosition} isConnectable={isConnectable} />
</>
);
};
DefaultNode.displayName = 'DefaultNode';
export default memo(DefaultNode);
@@ -0,0 +1,5 @@
const GroupNode = () => null;
GroupNode.displayName = 'GroupNode';
export default GroupNode;
@@ -0,0 +1,15 @@
import { memo } from 'react';
import Handle from '../../components/Handle';
import { NodeProps, Position } from '../../types';
const InputNode = ({ data, isConnectable, sourcePosition = Position.Bottom }: NodeProps) => (
<>
{data?.label}
<Handle type="source" position={sourcePosition} isConnectable={isConnectable} />
</>
);
InputNode.displayName = 'InputNode';
export default memo(InputNode);
@@ -0,0 +1,15 @@
import { memo } from 'react';
import Handle from '../../components/Handle';
import { NodeProps, Position } from '../../types';
const OutputNode = ({ data, isConnectable, targetPosition = Position.Top }: NodeProps) => (
<>
<Handle type="target" position={targetPosition} isConnectable={isConnectable} />
{data?.label}
</>
);
OutputNode.displayName = 'OutputNode';
export default memo(OutputNode);
@@ -0,0 +1,78 @@
import { MouseEvent } from 'react';
import { StoreApi } from 'zustand';
import { HandleElement, Node, NodeOrigin, Position, ReactFlowState } from '../../types';
import { getDimensions } from '../../utils';
export const getHandleBounds = (
selector: string,
nodeElement: HTMLDivElement,
zoom: number,
nodeOrigin: NodeOrigin
): 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),
};
});
};
export function getMouseHandler(
id: string,
getState: StoreApi<ReactFlowState>['getState'],
handler?: (event: MouseEvent, node: Node) => void
) {
return handler === undefined
? handler
: (event: MouseEvent) => {
const node = getState().nodeInternals.get(id)!;
handler(event, { ...node });
};
}
// this handler is called by
// 1. the click handler when node is not draggable or selectNodesOnDrag = false
// or
// 2. the on drag start handler when node is draggable and selectNodesOnDrag = true
export function handleNodeClick({
id,
store,
unselect = false,
}: {
id: string;
store: {
getState: StoreApi<ReactFlowState>['getState'];
setState: StoreApi<ReactFlowState>['setState'];
};
unselect?: boolean;
}) {
const { addSelectedNodes, unselectNodesAndEdges, multiSelectionActive, nodeInternals } = store.getState();
const node = nodeInternals.get(id)!;
store.setState({ nodesSelectionActive: false });
if (!node.selected) {
addSelectedNodes([id]);
} else if (unselect || (node.selected && multiSelectionActive)) {
unselectNodesAndEdges({ nodes: [node] });
}
}
@@ -0,0 +1,208 @@
import { useEffect, useRef, memo, ComponentType, MouseEvent, KeyboardEvent } from 'react';
import cc from 'classcat';
import { useStoreApi } from '../../hooks/useStore';
import { Provider } from '../../contexts/NodeIdContext';
import { ARIA_NODE_DESC_KEY } from '../A11yDescriptions';
import useDrag from '../../hooks/useDrag';
import useUpdateNodePositions from '../../hooks/useUpdateNodePositions';
import { getMouseHandler, handleNodeClick } from './utils';
import { NodeProps, WrapNodeProps, XYPosition } from '../../types';
import { elementSelectionKeys } from '../../utils';
export const arrowKeyDiffs: Record<string, XYPosition> = {
ArrowUp: { x: 0, y: -10 },
ArrowDown: { x: 0, y: 10 },
ArrowLeft: { x: -10, y: 0 },
ArrowRight: { x: 10, y: 0 },
};
export default (NodeComponent: ComponentType<NodeProps>) => {
const NodeWrapper = ({
id,
type,
data,
xPos,
yPos,
xPosOrigin,
yPosOrigin,
selected,
onClick,
onMouseEnter,
onMouseMove,
onMouseLeave,
onContextMenu,
onDoubleClick,
style,
className,
isDraggable,
isSelectable,
isConnectable,
selectNodesOnDrag,
sourcePosition,
targetPosition,
hidden,
resizeObserver,
dragHandle,
zIndex,
isParent,
noDragClassName,
noPanClassName,
initialized,
disableKeyboardA11y,
ariaLabel,
rfId,
}: WrapNodeProps) => {
const store = useStoreApi();
const nodeRef = useRef<HTMLDivElement>(null);
const prevSourcePosition = useRef(sourcePosition);
const prevTargetPosition = useRef(targetPosition);
const prevType = useRef(type);
const hasPointerEvents = isSelectable || isDraggable || onClick || onMouseEnter || onMouseMove || onMouseLeave;
const updatePositions = useUpdateNodePositions();
const onMouseEnterHandler = getMouseHandler(id, store.getState, onMouseEnter);
const onMouseMoveHandler = getMouseHandler(id, store.getState, onMouseMove);
const onMouseLeaveHandler = getMouseHandler(id, store.getState, onMouseLeave);
const onContextMenuHandler = getMouseHandler(id, store.getState, onContextMenu);
const onDoubleClickHandler = getMouseHandler(id, store.getState, onDoubleClick);
const onSelectNodeHandler = (event: MouseEvent) => {
if (isSelectable && (!selectNodesOnDrag || !isDraggable)) {
// this handler gets called within the drag start event when selectNodesOnDrag=true
handleNodeClick({
id,
store,
});
}
if (onClick) {
const node = store.getState().nodeInternals.get(id)!;
onClick(event, { ...node });
}
};
const onKeyDown = (event: KeyboardEvent) => {
if (elementSelectionKeys.includes(event.key) && isSelectable) {
const unselect = event.key === 'Escape';
if (unselect) {
nodeRef.current?.blur();
}
handleNodeClick({
id,
store,
unselect,
});
} else if (selected && Object.prototype.hasOwnProperty.call(arrowKeyDiffs, event.key)) {
store.setState({
ariaLiveMessage: `Moved selected node ten pixels ${event.key
.replace('Arrow', '')
.toLowerCase()}. New position, x: ${~~xPos}, y: ${~~yPos}`,
});
updatePositions(arrowKeyDiffs[event.key]);
}
};
useEffect(() => {
if (nodeRef.current && !hidden) {
const currNode = nodeRef.current;
resizeObserver?.observe(currNode);
return () => resizeObserver?.unobserve(currNode);
}
}, [hidden]);
useEffect(() => {
// when the user programmatically changes the source or handle position, we re-initialize the node
const typeChanged = prevType.current !== type;
const sourcePosChanged = prevSourcePosition.current !== sourcePosition;
const targetPosChanged = prevTargetPosition.current !== targetPosition;
if (nodeRef.current && (typeChanged || sourcePosChanged || targetPosChanged)) {
if (typeChanged) {
prevType.current = type;
}
if (sourcePosChanged) {
prevSourcePosition.current = sourcePosition;
}
if (targetPosChanged) {
prevTargetPosition.current = targetPosition;
}
store.getState().updateNodeDimensions([{ id, nodeElement: nodeRef.current, forceUpdate: true }]);
}
}, [id, type, sourcePosition, targetPosition]);
const dragging = useDrag({
nodeRef,
disabled: hidden || !isDraggable,
noDragClassName,
handleSelector: dragHandle,
nodeId: id,
isSelectable,
selectNodesOnDrag,
});
if (hidden) {
return null;
}
return (
<div
className={cc([
'react-flow__node',
`react-flow__node-${type}`,
className,
{
selected,
selectable: isSelectable,
parent: isParent,
dragging,
[noPanClassName]: isDraggable,
},
])}
ref={nodeRef}
style={{
zIndex,
transform: `translate(${xPosOrigin}px,${yPosOrigin}px)`,
pointerEvents: hasPointerEvents ? 'all' : 'none',
visibility: initialized ? 'visible' : 'hidden',
...style,
}}
data-id={id}
data-testid={`rf__node-${id}`}
onMouseEnter={onMouseEnterHandler}
onMouseMove={onMouseMoveHandler}
onMouseLeave={onMouseLeaveHandler}
onContextMenu={onContextMenuHandler}
onClick={onSelectNodeHandler}
onDoubleClick={onDoubleClickHandler}
onKeyDown={disableKeyboardA11y ? undefined : onKeyDown}
tabIndex={disableKeyboardA11y ? undefined : 0}
role={disableKeyboardA11y ? undefined : 'button'}
aria-describedby={disableKeyboardA11y ? undefined : `${ARIA_NODE_DESC_KEY}-${rfId}`}
aria-label={ariaLabel}
>
<Provider value={id}>
<NodeComponent
id={id}
data={data}
type={type}
xPos={xPos}
yPos={yPos}
selected={selected}
isConnectable={isConnectable}
sourcePosition={sourcePosition}
targetPosition={targetPosition}
dragging={dragging}
dragHandle={dragHandle}
zIndex={zIndex}
/>
</Provider>
</div>
);
};
NodeWrapper.displayName = 'NodeWrapper';
return memo(NodeWrapper);
};
@@ -0,0 +1,93 @@
/**
* The nodes selection rectangle gets displayed when a user
* made a selection with on or several nodes
*/
import { memo, useRef, MouseEvent, KeyboardEvent, useEffect } from 'react';
import cc from 'classcat';
import shallow from 'zustand/shallow';
import { useStore, useStoreApi } from '../../hooks/useStore';
import { Node, ReactFlowState } from '../../types';
import { getRectOfNodes } from '../../utils/graph';
import useDrag from '../../hooks/useDrag';
import { arrowKeyDiffs } from '../Nodes/wrapNode';
import useUpdateNodePositions from '../../hooks/useUpdateNodePositions';
export interface NodesSelectionProps {
onSelectionContextMenu?: (event: MouseEvent, nodes: Node[]) => void;
noPanClassName?: string;
disableKeyboardA11y: boolean;
}
const selector = (s: ReactFlowState) => ({
transformString: `translate(${s.transform[0]}px,${s.transform[1]}px) scale(${s.transform[2]})`,
userSelectionActive: s.userSelectionActive,
...getRectOfNodes(Array.from(s.nodeInternals.values()).filter((n) => n.selected)),
});
const bboxSelector = (s: ReactFlowState) => {
const selectedNodes = Array.from(s.nodeInternals.values()).filter((n) => n.selected);
return getRectOfNodes(selectedNodes);
};
function NodesSelection({ onSelectionContextMenu, noPanClassName, disableKeyboardA11y }: NodesSelectionProps) {
const store = useStoreApi();
const { transformString, userSelectionActive } = useStore(selector, shallow);
const { width, height, x: left, y: top } = useStore(bboxSelector, shallow);
const updatePositions = useUpdateNodePositions();
const nodeRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!disableKeyboardA11y) {
nodeRef.current?.focus();
}
}, [disableKeyboardA11y]);
useDrag({
nodeRef,
});
if (userSelectionActive || !width || !height) {
return null;
}
const onContextMenu = onSelectionContextMenu
? (event: MouseEvent) => {
const selectedNodes = Array.from(store.getState().nodeInternals.values()).filter((n) => n.selected);
onSelectionContextMenu(event, selectedNodes);
}
: undefined;
const onKeyDown = (event: KeyboardEvent) => {
if (Object.prototype.hasOwnProperty.call(arrowKeyDiffs, event.key)) {
updatePositions(arrowKeyDiffs[event.key]);
}
};
return (
<div
className={cc(['react-flow__nodesselection', 'react-flow__container', noPanClassName])}
style={{
transform: transformString,
}}
>
<div
ref={nodeRef}
className="react-flow__nodesselection-rect"
onContextMenu={onContextMenu}
tabIndex={disableKeyboardA11y ? undefined : -1}
onKeyDown={disableKeyboardA11y ? undefined : onKeyDown}
style={{
width,
height,
top,
left,
}}
/>
</div>
);
}
export default memo(NodesSelection);
@@ -0,0 +1,21 @@
import { HTMLAttributes, ReactNode } from 'react';
import cc from 'classcat';
import { PanelPosition } from '../../types';
export type PanelProps = HTMLAttributes<HTMLDivElement> & {
position: PanelPosition;
children: ReactNode;
};
function Panel({ position, children, className, ...rest }: PanelProps) {
const positionClasses = `${position}`.split('-');
return (
<div className={cc(['react-flow__panel', className, ...positionClasses])} {...rest}>
{children}
</div>
);
}
export default Panel;
@@ -0,0 +1,20 @@
import { FC, PropsWithChildren, useRef } from 'react';
import { StoreApi } from 'zustand';
import { Provider } from '../../contexts/RFStoreContext';
import { createRFStore } from '../../store';
import { ReactFlowState } from '../../types';
const ReactFlowProvider: FC<PropsWithChildren> = ({ children }) => {
const storeRef = useRef<StoreApi<ReactFlowState> | null>(null);
if (!storeRef.current) {
storeRef.current = createRFStore();
}
return <Provider value={storeRef.current}>{children}</Provider>;
};
ReactFlowProvider.displayName = 'ReactFlowProvider';
export default ReactFlowProvider;
@@ -0,0 +1,44 @@
import { memo, useEffect } from 'react';
import shallow from 'zustand/shallow';
import { ReactFlowState, OnSelectionChangeFunc, Node, Edge } from '../../types';
import { useStore, useStoreApi } from '../../hooks/useStore';
interface SelectionListenerProps {
onSelectionChange: OnSelectionChangeFunc;
}
const selector = (s: ReactFlowState) => ({
selectedNodes: Array.from(s.nodeInternals.values()).filter((n) => n.selected),
selectedEdges: s.edges.filter((e) => e.selected),
});
type SelectorSlice = ReturnType<typeof selector>;
function areEqual(objA: SelectorSlice, objB: SelectorSlice) {
const selectedNodeIdsA = objA.selectedNodes.map((n: Node) => n.id);
const selectedNodeIdsB = objB.selectedNodes.map((n: Node) => n.id);
const selectedEdgeIdsA = objA.selectedEdges.map((e: Edge) => e.id);
const selectedEdgeIdsB = objB.selectedEdges.map((e: Edge) => e.id);
return shallow(selectedNodeIdsA, selectedNodeIdsB) && shallow(selectedEdgeIdsA, selectedEdgeIdsB);
}
// This is just a helper component for calling the onSelectionChange listener.
// @TODO: Now that we have the onNodesChange and on EdgesChange listeners, do we still need this component?
function SelectionListener({ onSelectionChange }: SelectionListenerProps) {
const store = useStoreApi();
const { selectedNodes, selectedEdges } = useStore(selector, areEqual);
useEffect(() => {
const params = { nodes: selectedNodes, edges: selectedEdges };
onSelectionChange(params);
store.getState().onSelectionChange?.(params);
}, [selectedNodes, selectedEdges]);
return null;
}
export default memo(SelectionListener);
@@ -0,0 +1,178 @@
import { useEffect } from 'react';
import { StoreApi } from 'zustand';
import shallow from 'zustand/shallow';
import { useStore, useStoreApi } from '../../hooks/useStore';
import { Node, Edge, ReactFlowState, CoordinateExtent, ReactFlowProps, ReactFlowStore } from '../../types';
type StoreUpdaterProps = Pick<
ReactFlowProps,
| 'nodes'
| 'edges'
| 'defaultNodes'
| 'defaultEdges'
| 'onConnect'
| 'onConnectStart'
| 'onConnectEnd'
| 'onClickConnectStart'
| 'onClickConnectEnd'
| 'nodesDraggable'
| 'nodesConnectable'
| 'minZoom'
| 'maxZoom'
| 'nodeExtent'
| 'onNodesChange'
| 'onEdgesChange'
| 'elementsSelectable'
| 'connectionMode'
| 'snapToGrid'
| 'snapGrid'
| 'translateExtent'
| 'connectOnClick'
| 'defaultEdgeOptions'
| 'fitView'
| 'fitViewOptions'
| 'onNodesDelete'
| 'onEdgesDelete'
| 'onNodeDragStart'
| 'onNodeDrag'
| 'onNodeDragStop'
| 'onSelectionDragStart'
| 'onSelectionDrag'
| 'onSelectionDragStop'
| 'noPanClassName'
| 'nodeOrigin'
| 'id'
>;
const selector = (s: ReactFlowState) => ({
setNodes: s.setNodes,
setEdges: s.setEdges,
setDefaultNodesAndEdges: s.setDefaultNodesAndEdges,
setMinZoom: s.setMinZoom,
setMaxZoom: s.setMaxZoom,
setTranslateExtent: s.setTranslateExtent,
setNodeExtent: s.setNodeExtent,
reset: s.reset,
});
function useStoreUpdater<T>(value: T | undefined, setStoreState: (param: T) => void) {
useEffect(() => {
if (typeof value !== 'undefined') {
setStoreState(value);
}
}, [value]);
}
// updates with values in store that don't have a dedicated setter function
function useDirectStoreUpdater(
key: keyof ReactFlowStore,
value: unknown,
setState: StoreApi<ReactFlowState>['setState']
) {
useEffect(() => {
if (typeof value !== 'undefined') {
setState({ [key]: value });
}
}, [value]);
}
const StoreUpdater = ({
nodes,
edges,
defaultNodes,
defaultEdges,
onConnect,
onConnectStart,
onConnectEnd,
onClickConnectStart,
onClickConnectEnd,
nodesDraggable,
nodesConnectable,
minZoom,
maxZoom,
nodeExtent,
onNodesChange,
onEdgesChange,
elementsSelectable,
connectionMode,
snapGrid,
snapToGrid,
translateExtent,
connectOnClick,
defaultEdgeOptions,
fitView,
fitViewOptions,
onNodesDelete,
onEdgesDelete,
onNodeDrag,
onNodeDragStart,
onNodeDragStop,
onSelectionDrag,
onSelectionDragStart,
onSelectionDragStop,
noPanClassName,
nodeOrigin,
id,
}: StoreUpdaterProps) => {
const {
setNodes,
setEdges,
setDefaultNodesAndEdges,
setMinZoom,
setMaxZoom,
setTranslateExtent,
setNodeExtent,
reset,
} = useStore(selector, shallow);
const store = useStoreApi();
useEffect(() => {
const edgesWithDefaults = defaultEdges?.map((e) => ({ ...e, ...defaultEdgeOptions }));
setDefaultNodesAndEdges(defaultNodes, edgesWithDefaults);
return () => {
reset();
};
}, []);
useDirectStoreUpdater('defaultEdgeOptions', defaultEdgeOptions, store.setState);
useDirectStoreUpdater('connectionMode', connectionMode, store.setState);
useDirectStoreUpdater('onConnect', onConnect, store.setState);
useDirectStoreUpdater('onConnectStart', onConnectStart, store.setState);
useDirectStoreUpdater('onConnectEnd', onConnectEnd, store.setState);
useDirectStoreUpdater('onClickConnectStart', onClickConnectStart, store.setState);
useDirectStoreUpdater('onClickConnectEnd', onClickConnectEnd, store.setState);
useDirectStoreUpdater('nodesDraggable', nodesDraggable, store.setState);
useDirectStoreUpdater('nodesConnectable', nodesConnectable, store.setState);
useDirectStoreUpdater('elementsSelectable', elementsSelectable, store.setState);
useDirectStoreUpdater('snapToGrid', snapToGrid, store.setState);
useDirectStoreUpdater('snapGrid', snapGrid, store.setState);
useDirectStoreUpdater('onNodesChange', onNodesChange, store.setState);
useDirectStoreUpdater('onEdgesChange', onEdgesChange, store.setState);
useDirectStoreUpdater('connectOnClick', connectOnClick, store.setState);
useDirectStoreUpdater('fitViewOnInit', fitView, store.setState);
useDirectStoreUpdater('fitViewOnInitOptions', fitViewOptions, store.setState);
useDirectStoreUpdater('onNodesDelete', onNodesDelete, store.setState);
useDirectStoreUpdater('onEdgesDelete', onEdgesDelete, store.setState);
useDirectStoreUpdater('onNodeDrag', onNodeDrag, store.setState);
useDirectStoreUpdater('onNodeDragStart', onNodeDragStart, store.setState);
useDirectStoreUpdater('onNodeDragStop', onNodeDragStop, store.setState);
useDirectStoreUpdater('onSelectionDrag', onSelectionDrag, store.setState);
useDirectStoreUpdater('onSelectionDragStart', onSelectionDragStart, store.setState);
useDirectStoreUpdater('onSelectionDragStop', onSelectionDragStop, store.setState);
useDirectStoreUpdater('noPanClassName', noPanClassName, store.setState);
useDirectStoreUpdater('nodeOrigin', nodeOrigin, store.setState);
useDirectStoreUpdater('rfId', id, store.setState);
useStoreUpdater<Node[]>(nodes, setNodes);
useStoreUpdater<Edge[]>(edges, setEdges);
useStoreUpdater<number>(minZoom, setMinZoom);
useStoreUpdater<number>(maxZoom, setMaxZoom);
useStoreUpdater<CoordinateExtent>(translateExtent, setTranslateExtent);
useStoreUpdater<CoordinateExtent>(nodeExtent, setNodeExtent);
return null;
};
export default StoreUpdater;
@@ -0,0 +1,163 @@
/**
* The user selection rectangle gets displayed when a user drags the mouse while pressing shift
*/
import { memo, useState, useRef } from 'react';
import shallow from 'zustand/shallow';
import { useStore, useStoreApi } from '../../hooks/useStore';
import { getSelectionChanges } from '../../utils/changes';
import { XYPosition, ReactFlowState, NodeChange, EdgeChange, Rect } from '../../types';
import { getConnectedEdges, getNodesInside } from '../../utils/graph';
type SelectionRect = Rect & {
startX: number;
startY: number;
draw: boolean;
};
type UserSelectionProps = {
selectionKeyPressed: boolean;
};
function getMousePosition(event: React.MouseEvent, containerBounds: DOMRect): XYPosition {
return {
x: event.clientX - containerBounds.left,
y: event.clientY - containerBounds.top,
};
}
const selector = (s: ReactFlowState) => ({
userSelectionActive: s.userSelectionActive,
elementsSelectable: s.elementsSelectable,
});
const initialRect: SelectionRect = {
startX: 0,
startY: 0,
x: 0,
y: 0,
width: 0,
height: 0,
draw: false,
};
const UserSelection = memo(({ selectionKeyPressed }: UserSelectionProps) => {
const store = useStoreApi();
const prevSelectedNodesCount = useRef<number>(0);
const prevSelectedEdgesCount = useRef<number>(0);
const containerBounds = useRef<DOMRect>();
const [userSelectionRect, setUserSelectionRect] = useState<SelectionRect>(initialRect);
const { userSelectionActive, elementsSelectable } = useStore(selector, shallow);
const renderUserSelectionPane = userSelectionActive || selectionKeyPressed;
if (!elementsSelectable || !renderUserSelectionPane) {
return null;
}
const resetUserSelection = () => {
setUserSelectionRect(initialRect);
store.setState({ userSelectionActive: false });
prevSelectedNodesCount.current = 0;
prevSelectedEdgesCount.current = 0;
};
const onMouseDown = (event: React.MouseEvent): void => {
const reactFlowNode = (event.target as Element).closest('.react-flow')!;
containerBounds.current = reactFlowNode.getBoundingClientRect();
const mousePos = getMousePosition(event, containerBounds.current!);
setUserSelectionRect({
width: 0,
height: 0,
startX: mousePos.x,
startY: mousePos.y,
x: mousePos.x,
y: mousePos.y,
draw: true,
});
store.setState({ userSelectionActive: true, nodesSelectionActive: false });
};
const onMouseMove = (event: React.MouseEvent): void => {
if (!selectionKeyPressed || !userSelectionRect.draw || !containerBounds.current) {
return;
}
const mousePos = getMousePosition(event, containerBounds.current!);
const startX = userSelectionRect.startX ?? 0;
const startY = userSelectionRect.startY ?? 0;
const nextUserSelectRect = {
...userSelectionRect,
x: mousePos.x < startX ? mousePos.x : startX,
y: mousePos.y < startY ? mousePos.y : startY,
width: Math.abs(mousePos.x - startX),
height: Math.abs(mousePos.y - startY),
};
const { nodeInternals, edges, transform, onNodesChange, onEdgesChange } = store.getState();
const nodes = Array.from(nodeInternals.values());
const selectedNodes = getNodesInside(nodeInternals, nextUserSelectRect, transform, false, true);
const selectedEdgeIds = getConnectedEdges(selectedNodes, edges).map((e) => e.id);
const selectedNodeIds = selectedNodes.map((n) => n.id);
if (prevSelectedNodesCount.current !== selectedNodeIds.length) {
prevSelectedNodesCount.current = selectedNodeIds.length;
const changes = getSelectionChanges(nodes, selectedNodeIds) as NodeChange[];
if (changes.length) {
onNodesChange?.(changes);
}
}
if (prevSelectedEdgesCount.current !== selectedEdgeIds.length) {
prevSelectedEdgesCount.current = selectedEdgeIds.length;
const changes = getSelectionChanges(edges, selectedEdgeIds) as EdgeChange[];
if (changes.length) {
onEdgesChange?.(changes);
}
}
setUserSelectionRect(nextUserSelectRect);
};
const onMouseUp = () => {
store.setState({ nodesSelectionActive: prevSelectedNodesCount.current > 0 });
resetUserSelection();
};
const onMouseLeave = () => {
store.setState({ nodesSelectionActive: false });
resetUserSelection();
};
return (
<div
className="react-flow__selectionpane react-flow__container"
onMouseDown={onMouseDown}
onMouseMove={onMouseMove}
onMouseUp={onMouseUp}
onMouseLeave={onMouseLeave}
>
{userSelectionRect.draw && (
<div
className="react-flow__selection react-flow__container"
style={{
width: userSelectionRect.width,
height: userSelectionRect.height,
transform: `translate(${userSelectionRect.x}px, ${userSelectionRect.y}px)`,
}}
/>
)}
</div>
);
});
UserSelection.displayName = 'UserSelection';
export default UserSelection;
@@ -0,0 +1,100 @@
import { memo, useCallback } from 'react';
import { useStore } from '../../hooks/useStore';
import { EdgeMarker, ReactFlowState } from '../../types';
import { getMarkerId } from '../../utils/graph';
import { useMarkerSymbol } from './MarkerSymbols';
interface MarkerProps extends EdgeMarker {
id: string;
}
interface MarkerDefinitionsProps {
defaultColor: string;
rfId?: string;
}
const Marker = ({
id,
type,
color,
width = 12.5,
height = 12.5,
markerUnits = 'strokeWidth',
strokeWidth,
orient = 'auto-start-reverse',
}: MarkerProps) => {
const Symbol = useMarkerSymbol(type);
if (!Symbol) {
return null;
}
return (
<marker
className="react-flow__arrowhead"
id={id}
markerWidth={`${width}`}
markerHeight={`${height}`}
viewBox="-10 -10 20 20"
markerUnits={markerUnits}
orient={orient}
refX="0"
refY="0"
>
<Symbol color={color} strokeWidth={strokeWidth} />
</marker>
);
};
const markerSelector =
({ defaultColor, rfId }: { defaultColor: string; rfId?: string }) =>
(s: ReactFlowState) => {
const ids: string[] = [];
return s.edges
.reduce<MarkerProps[]>((markers, edge) => {
[edge.markerStart, edge.markerEnd].forEach((marker) => {
if (marker && typeof marker === 'object') {
const markerId = getMarkerId(marker, rfId);
if (!ids.includes(markerId)) {
markers.push({ id: markerId, color: marker.color || defaultColor, ...marker });
ids.push(markerId);
}
}
});
return markers;
}, [])
.sort((a, b) => a.id.localeCompare(b.id));
};
// when you have multiple flows on a page and you hide the first one, the other ones have no markers anymore
// when they do have markers with the same ids. To prevent this the user can pass a unique id to the react flow wrapper
// that we can then use for creating our unique marker ids
const MarkerDefinitions = ({ defaultColor, rfId }: MarkerDefinitionsProps) => {
const markers = useStore(
useCallback(markerSelector({ defaultColor, rfId }), [defaultColor, rfId]),
// the id includes all marker options, so we just need to look at that part of the marker
(a, b) => !(a.length !== b.length || a.some((m, i) => m.id !== b[i].id))
);
return (
<defs>
{markers.map((marker: MarkerProps) => (
<Marker
id={marker.id}
key={marker.id}
type={marker.type}
color={marker.color}
width={marker.width}
height={marker.height}
markerUnits={marker.markerUnits}
strokeWidth={marker.strokeWidth}
orient={marker.orient}
/>
))}
</defs>
);
};
MarkerDefinitions.displayName = 'MarkerDefinitions';
export default memo(MarkerDefinitions);
@@ -0,0 +1,53 @@
import { useMemo } from 'react';
import { MarkerType, EdgeMarker } from '../../types';
import { devWarn } from '../../utils';
type SymbolProps = Omit<EdgeMarker, 'type'>;
const ArrowSymbol = ({ color = 'none', strokeWidth = 1 }: SymbolProps) => {
return (
<polyline
stroke={color}
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={strokeWidth}
fill="none"
points="-5,-4 0,0 -5,4"
/>
);
};
const ArrowClosedSymbol = ({ color = 'none', strokeWidth = 1 }: SymbolProps) => {
return (
<polyline
stroke={color}
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={strokeWidth}
fill={color}
points="-5,-4 0,0 -5,4 -5,-4"
/>
);
};
export const MarkerSymbols = {
[MarkerType.Arrow]: ArrowSymbol,
[MarkerType.ArrowClosed]: ArrowClosedSymbol,
};
export function useMarkerSymbol(type: MarkerType) {
const symbol = useMemo(() => {
const symbolExists = Object.prototype.hasOwnProperty.call(MarkerSymbols, type);
if (!symbolExists) {
devWarn(`Marker type "${type}" doesn't exist. Help: https://reactflow.dev/error#900`);
return null;
}
return MarkerSymbols[type];
}, [type]);
return symbol;
}
export default MarkerSymbols;
@@ -0,0 +1,216 @@
import { memo } from 'react';
import shallow from 'zustand/shallow';
import cc from 'classcat';
import { useStore } from '../../hooks/useStore';
import useVisibleEdges from '../../hooks/useVisibleEdges';
import ConnectionLine from '../../components/ConnectionLine/index';
import MarkerDefinitions from './MarkerDefinitions';
import { getEdgePositions, getHandle, getNodeData } from './utils';
import { Position, Edge, ConnectionMode, ReactFlowState } from '../../types';
import { GraphViewProps } from '../GraphView';
import { devWarn } from '../../utils';
interface EdgeRendererProps
extends Pick<
GraphViewProps,
| 'edgeTypes'
| 'connectionLineType'
| 'connectionLineType'
| 'connectionLineStyle'
| 'connectionLineComponent'
| 'connectionLineContainerStyle'
| 'connectionLineContainerStyle'
| 'onEdgeClick'
| 'onEdgeDoubleClick'
| 'defaultMarkerColor'
| 'onlyRenderVisibleElements'
| 'onEdgeUpdate'
| 'onEdgeContextMenu'
| 'onEdgeMouseEnter'
| 'onEdgeMouseMove'
| 'onEdgeMouseLeave'
| 'onEdgeUpdateStart'
| 'onEdgeUpdateEnd'
| 'edgeUpdaterRadius'
| 'noPanClassName'
| 'elevateEdgesOnSelect'
| 'rfId'
| 'disableKeyboardA11y'
> {
elevateEdgesOnSelect: boolean;
}
const selector = (s: ReactFlowState) => ({
connectionNodeId: s.connectionNodeId,
connectionHandleType: s.connectionHandleType,
nodesConnectable: s.nodesConnectable,
elementsSelectable: s.elementsSelectable,
width: s.width,
height: s.height,
connectionMode: s.connectionMode,
nodeInternals: s.nodeInternals,
});
const EdgeRenderer = (props: EdgeRendererProps) => {
const {
connectionNodeId,
connectionHandleType,
nodesConnectable,
elementsSelectable,
width,
height,
connectionMode,
nodeInternals,
} = useStore(selector, shallow);
const edgeTree = useVisibleEdges(props.onlyRenderVisibleElements, nodeInternals, props.elevateEdgesOnSelect);
if (!width) {
return null;
}
const {
connectionLineType,
defaultMarkerColor,
connectionLineStyle,
connectionLineComponent,
connectionLineContainerStyle,
} = props;
const renderConnectionLine = connectionNodeId && connectionHandleType;
return (
<>
{edgeTree.map(({ level, edges, isMaxLevel }) => (
<svg
key={level}
style={{ zIndex: level }}
width={width}
height={height}
className="react-flow__edges react-flow__container"
>
{isMaxLevel && <MarkerDefinitions defaultColor={defaultMarkerColor} rfId={props.rfId} />}
<g>
{edges.map((edge: Edge) => {
const [sourceNodeRect, sourceHandleBounds, sourceIsValid] = getNodeData(nodeInternals.get(edge.source)!);
const [targetNodeRect, targetHandleBounds, targetIsValid] = getNodeData(nodeInternals.get(edge.target)!);
if (!sourceIsValid || !targetIsValid) {
return null;
}
let edgeType = edge.type || 'default';
if (!props.edgeTypes[edgeType]) {
devWarn(
`Edge type "${edgeType}" not found. Using fallback type "default". Help: https://reactflow.dev/error#300`
);
edgeType = 'default';
}
const EdgeComponent = props.edgeTypes[edgeType] || props.edgeTypes.default;
// when connection type is loose we can define all handles as sources
const targetNodeHandles =
connectionMode === ConnectionMode.Strict
? targetHandleBounds!.target
: targetHandleBounds!.target || targetHandleBounds!.source;
const sourceHandle = getHandle(sourceHandleBounds!.source!, edge.sourceHandle || null);
const targetHandle = getHandle(targetNodeHandles!, edge.targetHandle || null);
const sourcePosition = sourceHandle?.position || Position.Bottom;
const targetPosition = targetHandle?.position || Position.Top;
if (!sourceHandle || !targetHandle) {
devWarn(
`Couldn't create edge for ${!sourceHandle ? 'source' : 'target'} handle id: ${
!sourceHandle ? edge.sourceHandle : edge.targetHandle
}; edge id: ${edge.id}. Help: https://reactflow.dev/error#800`
);
return null;
}
const { sourceX, sourceY, targetX, targetY } = getEdgePositions(
sourceNodeRect,
sourceHandle,
sourcePosition,
targetNodeRect,
targetHandle,
targetPosition
);
return (
<EdgeComponent
key={edge.id}
id={edge.id}
className={cc([edge.className, props.noPanClassName])}
type={edgeType}
data={edge.data}
selected={!!edge.selected}
animated={!!edge.animated}
hidden={!!edge.hidden}
label={edge.label}
labelStyle={edge.labelStyle}
labelShowBg={edge.labelShowBg}
labelBgStyle={edge.labelBgStyle}
labelBgPadding={edge.labelBgPadding}
labelBgBorderRadius={edge.labelBgBorderRadius}
style={edge.style}
source={edge.source}
target={edge.target}
sourceHandleId={edge.sourceHandle}
targetHandleId={edge.targetHandle}
markerEnd={edge.markerEnd}
markerStart={edge.markerStart}
sourceX={sourceX}
sourceY={sourceY}
targetX={targetX}
targetY={targetY}
sourcePosition={sourcePosition}
targetPosition={targetPosition}
elementsSelectable={elementsSelectable}
onEdgeUpdate={props.onEdgeUpdate}
onContextMenu={props.onEdgeContextMenu}
onMouseEnter={props.onEdgeMouseEnter}
onMouseMove={props.onEdgeMouseMove}
onMouseLeave={props.onEdgeMouseLeave}
onClick={props.onEdgeClick}
edgeUpdaterRadius={props.edgeUpdaterRadius}
onEdgeDoubleClick={props.onEdgeDoubleClick}
onEdgeUpdateStart={props.onEdgeUpdateStart}
onEdgeUpdateEnd={props.onEdgeUpdateEnd}
rfId={props.rfId}
ariaLabel={edge.ariaLabel}
disableKeyboardA11y={props.disableKeyboardA11y}
pathOptions={'pathOptions' in edge ? edge.pathOptions : undefined}
interactionWidth={edge.interactionWidth}
/>
);
})}
</g>
</svg>
))}
{renderConnectionLine && (
<svg
style={connectionLineContainerStyle}
width={width}
height={height}
className="react-flow__edges react-flow__connectionline react-flow__container"
>
<ConnectionLine
connectionNodeId={connectionNodeId!}
connectionHandleType={connectionHandleType!}
connectionLineStyle={connectionLineStyle}
connectionLineType={connectionLineType}
isConnectable={nodesConnectable}
CustomConnectionLineComponent={connectionLineComponent}
/>
</svg>
)}
</>
);
};
EdgeRenderer.displayName = 'EdgeRenderer';
export default memo(EdgeRenderer);
@@ -0,0 +1,190 @@
import { ComponentType } from 'react';
import { BezierEdge, SmoothStepEdge, StepEdge, StraightEdge, SimpleBezierEdge } from '../../components/Edges';
import wrapEdge from '../../components/Edges/wrapEdge';
import {
EdgeProps,
EdgeTypes,
EdgeTypesWrapped,
HandleElement,
NodeHandleBounds,
Node,
Position,
Rect,
Transform,
XYPosition,
} from '../../types';
import { internalsSymbol, rectToBox } from '../../utils';
export type CreateEdgeTypes = (edgeTypes: EdgeTypes) => EdgeTypesWrapped;
export function createEdgeTypes(edgeTypes: EdgeTypes): EdgeTypesWrapped {
const standardTypes: EdgeTypesWrapped = {
default: wrapEdge((edgeTypes.default || BezierEdge) as ComponentType<EdgeProps>),
straight: wrapEdge((edgeTypes.bezier || StraightEdge) as ComponentType<EdgeProps>),
step: wrapEdge((edgeTypes.step || StepEdge) as ComponentType<EdgeProps>),
smoothstep: wrapEdge((edgeTypes.step || SmoothStepEdge) as ComponentType<EdgeProps>),
simplebezier: wrapEdge((edgeTypes.simplebezier || SimpleBezierEdge) as ComponentType<EdgeProps>),
};
const wrappedTypes = {} as EdgeTypesWrapped;
const specialTypes: EdgeTypesWrapped = Object.keys(edgeTypes)
.filter((k) => !['default', 'bezier'].includes(k))
.reduce((res, key) => {
res[key] = wrapEdge((edgeTypes[key] || BezierEdge) as ComponentType<EdgeProps>);
return res;
}, wrappedTypes);
return {
...standardTypes,
...specialTypes,
};
}
export function getHandlePosition(position: Position, nodeRect: Rect, handle: HandleElement | null = null): XYPosition {
const x = (handle?.x || 0) + nodeRect.x;
const y = (handle?.y || 0) + nodeRect.y;
const width = handle?.width || nodeRect.width;
const height = handle?.height || nodeRect.height;
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,
};
}
}
export function getHandle(bounds: HandleElement[], handleId: string | null): HandleElement | null {
if (!bounds) {
return null;
}
// there is no handleId when there are no multiple handles/ handles with ids
// so we just pick the first one
let handle: HandleElement | null = null;
if (bounds.length === 1 || !handleId) {
handle = bounds[0];
} else if (handleId) {
handle = bounds.find((d) => d.id === handleId)!;
}
return typeof handle === 'undefined' ? null : handle;
}
interface EdgePositions {
sourceX: number;
sourceY: number;
targetX: number;
targetY: number;
}
export const getEdgePositions = (
sourceNodeRect: Rect,
sourceHandle: HandleElement,
sourcePosition: Position,
targetNodeRect: Rect,
targetHandle: HandleElement,
targetPosition: Position
): EdgePositions => {
const sourceHandlePos = getHandlePosition(sourcePosition, sourceNodeRect, sourceHandle);
const targetHandlePos = getHandlePosition(targetPosition, targetNodeRect, targetHandle);
return {
sourceX: sourceHandlePos.x,
sourceY: sourceHandlePos.y,
targetX: targetHandlePos.x,
targetY: targetHandlePos.y,
};
};
interface IsEdgeVisibleParams {
sourcePos: XYPosition;
targetPos: XYPosition;
sourceWidth: number;
sourceHeight: number;
targetWidth: number;
targetHeight: number;
width: number;
height: number;
transform: Transform;
}
export function isEdgeVisible({
sourcePos,
targetPos,
sourceWidth,
sourceHeight,
targetWidth,
targetHeight,
width,
height,
transform,
}: IsEdgeVisibleParams): boolean {
const edgeBox = {
x: Math.min(sourcePos.x, targetPos.x),
y: Math.min(sourcePos.y, targetPos.y),
x2: Math.max(sourcePos.x + sourceWidth, targetPos.x + targetWidth),
y2: Math.max(sourcePos.y + sourceHeight, targetPos.y + targetHeight),
};
if (edgeBox.x === edgeBox.x2) {
edgeBox.x2 += 1;
}
if (edgeBox.y === edgeBox.y2) {
edgeBox.y2 += 1;
}
const viewBox = rectToBox({
x: (0 - transform[0]) / transform[2],
y: (0 - transform[1]) / transform[2],
width: width / transform[2],
height: height / transform[2],
});
const xOverlap = Math.max(0, Math.min(viewBox.x2, edgeBox.x2) - Math.max(viewBox.x, edgeBox.x));
const yOverlap = Math.max(0, Math.min(viewBox.y2, edgeBox.y2) - Math.max(viewBox.y, edgeBox.y));
const overlappingArea = Math.ceil(xOverlap * yOverlap);
return overlappingArea > 0;
}
export function getNodeData(node: Node): [Rect, NodeHandleBounds | null, boolean] {
const handleBounds = node?.[internalsSymbol]?.handleBounds || null;
const isInvalid =
!node ||
!handleBounds ||
!node.width ||
!node.height ||
typeof node.positionAbsolute?.x === 'undefined' ||
typeof node.positionAbsolute?.y === 'undefined';
return [
{
x: node?.positionAbsolute?.x || 0,
y: node?.positionAbsolute?.y || 0,
width: node?.width || 0,
height: node?.height || 0,
},
handleBounds,
!isInvalid,
];
}
@@ -0,0 +1,34 @@
import { MouseEvent } from 'react';
import cc from 'classcat';
import { useStore } from '../../hooks/useStore';
import { containerStyle } from '../../styles';
import type { ReactFlowState } from '../../types';
import type { FlowRendererProps } from '.';
type PaneProps = Pick<FlowRendererProps, 'onClick' | 'onContextMenu' | 'onWheel'> & {
onMouseEnter?: (event: MouseEvent) => void;
onMouseMove?: (event: MouseEvent) => void;
onMouseLeave?: (event: MouseEvent) => void;
};
const selector = (s: ReactFlowState) => s.paneDragging;
function Pane({ onClick, onMouseEnter, onMouseMove, onMouseLeave, onContextMenu, onWheel }: PaneProps) {
const dragging = useStore(selector);
return (
<div
className={cc(['react-flow__pane', { dragging }])}
onClick={onClick}
onMouseEnter={onMouseEnter}
onMouseMove={onMouseMove}
onMouseLeave={onMouseLeave}
onContextMenu={onContextMenu}
onWheel={onWheel}
style={containerStyle}
/>
);
}
export default Pane;
@@ -0,0 +1,128 @@
import { memo, ReactNode, WheelEvent, MouseEvent } from 'react';
import { useStore, useStoreApi } from '../../hooks/useStore';
import useGlobalKeyHandler from '../../hooks/useGlobalKeyHandler';
import useKeyPress from '../../hooks/useKeyPress';
import { GraphViewProps } from '../GraphView';
import ZoomPane from '../ZoomPane';
import UserSelection from '../../components/UserSelection';
import NodesSelection from '../../components/NodesSelection';
import Pane from './Pane';
import { ReactFlowState } from '../../types';
export type FlowRendererProps = Omit<
GraphViewProps,
| 'snapToGrid'
| 'nodeTypes'
| 'edgeTypes'
| 'snapGrid'
| 'connectionLineType'
| 'connectionLineContainerStyle'
| 'arrowHeadColor'
| 'onlyRenderVisibleElements'
| 'selectNodesOnDrag'
| 'defaultMarkerColor'
| 'rfId'
| 'nodeOrigin'
> & {
children: ReactNode;
};
const selector = (s: ReactFlowState) => s.nodesSelectionActive;
const FlowRenderer = ({
children,
onPaneClick,
onPaneMouseEnter,
onPaneMouseMove,
onPaneMouseLeave,
onPaneContextMenu,
onPaneScroll,
deleteKeyCode,
onMove,
onMoveStart,
onMoveEnd,
selectionKeyCode,
multiSelectionKeyCode,
zoomActivationKeyCode,
elementsSelectable,
zoomOnScroll,
zoomOnPinch,
panOnScroll,
panOnScrollSpeed,
panOnScrollMode,
zoomOnDoubleClick,
panOnDrag,
defaultViewport,
translateExtent,
minZoom,
maxZoom,
preventScrolling,
onSelectionContextMenu,
noWheelClassName,
noPanClassName,
disableKeyboardA11y,
}: FlowRendererProps) => {
const store = useStoreApi();
const nodesSelectionActive = useStore(selector);
const selectionKeyPressed = useKeyPress(selectionKeyCode);
useGlobalKeyHandler({ deleteKeyCode, multiSelectionKeyCode });
const onClick = (event: MouseEvent) => {
onPaneClick?.(event);
store.getState().resetSelectedElements();
store.setState({ nodesSelectionActive: false });
};
const onContextMenu = onPaneContextMenu ? (event: MouseEvent) => onPaneContextMenu(event) : undefined;
const onWheel = onPaneScroll ? (event: WheelEvent) => onPaneScroll(event) : undefined;
return (
<ZoomPane
onMove={onMove}
onMoveStart={onMoveStart}
onMoveEnd={onMoveEnd}
selectionKeyPressed={selectionKeyPressed}
elementsSelectable={elementsSelectable}
zoomOnScroll={zoomOnScroll}
zoomOnPinch={zoomOnPinch}
panOnScroll={panOnScroll}
panOnScrollSpeed={panOnScrollSpeed}
panOnScrollMode={panOnScrollMode}
zoomOnDoubleClick={zoomOnDoubleClick}
panOnDrag={panOnDrag}
defaultViewport={defaultViewport}
translateExtent={translateExtent}
minZoom={minZoom}
maxZoom={maxZoom}
zoomActivationKeyCode={zoomActivationKeyCode}
preventScrolling={preventScrolling}
noWheelClassName={noWheelClassName}
noPanClassName={noPanClassName}
>
{children}
<UserSelection selectionKeyPressed={selectionKeyPressed} />
{nodesSelectionActive && (
<NodesSelection
onSelectionContextMenu={onSelectionContextMenu}
noPanClassName={noPanClassName}
disableKeyboardA11y={disableKeyboardA11y}
/>
)}
<Pane
onClick={onClick}
onMouseEnter={onPaneMouseEnter}
onMouseMove={onPaneMouseMove}
onMouseLeave={onPaneMouseLeave}
onContextMenu={onContextMenu}
onWheel={onWheel}
/>
</ZoomPane>
);
};
FlowRenderer.displayName = 'FlowRenderer';
export default memo(FlowRenderer);
@@ -0,0 +1,189 @@
import { memo } from 'react';
import FlowRenderer from '../FlowRenderer';
import NodeRenderer from '../NodeRenderer';
import EdgeRenderer from '../EdgeRenderer';
import ViewportWrapper from '../Viewport';
import useOnInitHandler from '../../hooks/useOnInitHandler';
import {
NodeTypesWrapped,
EdgeTypesWrapped,
ConnectionLineType,
KeyCode,
ReactFlowProps,
Viewport,
CoordinateExtent,
NodeOrigin,
} from '../../types';
export interface GraphViewProps
extends Omit<ReactFlowProps, 'onSelectionChange' | 'nodes' | 'edges' | 'nodeTypes' | 'edgeTypes'> {
nodeTypes: NodeTypesWrapped;
edgeTypes: EdgeTypesWrapped;
selectionKeyCode: KeyCode | null;
deleteKeyCode: KeyCode | null;
multiSelectionKeyCode: KeyCode | null;
connectionLineType: ConnectionLineType;
onlyRenderVisibleElements: boolean;
translateExtent: CoordinateExtent;
minZoom: number;
maxZoom: number;
defaultMarkerColor: string;
selectNodesOnDrag: boolean;
noDragClassName: string;
noWheelClassName: string;
noPanClassName: string;
defaultViewport: Viewport;
rfId: string;
disableKeyboardA11y: boolean;
nodeOrigin: NodeOrigin;
}
const GraphView = ({
nodeTypes,
edgeTypes,
onMove,
onMoveStart,
onMoveEnd,
onInit,
onNodeClick,
onEdgeClick,
onNodeDoubleClick,
onEdgeDoubleClick,
onNodeMouseEnter,
onNodeMouseMove,
onNodeMouseLeave,
onNodeContextMenu,
onSelectionContextMenu,
connectionLineType,
connectionLineStyle,
connectionLineComponent,
connectionLineContainerStyle,
selectionKeyCode,
multiSelectionKeyCode,
zoomActivationKeyCode,
deleteKeyCode,
onlyRenderVisibleElements,
elementsSelectable,
selectNodesOnDrag,
defaultViewport,
translateExtent,
minZoom,
maxZoom,
preventScrolling,
defaultMarkerColor,
zoomOnScroll,
zoomOnPinch,
panOnScroll,
panOnScrollSpeed,
panOnScrollMode,
zoomOnDoubleClick,
panOnDrag,
onPaneClick,
onPaneMouseEnter,
onPaneMouseMove,
onPaneMouseLeave,
onPaneScroll,
onPaneContextMenu,
onEdgeUpdate,
onEdgeContextMenu,
onEdgeMouseEnter,
onEdgeMouseMove,
onEdgeMouseLeave,
edgeUpdaterRadius,
onEdgeUpdateStart,
onEdgeUpdateEnd,
noDragClassName,
noWheelClassName,
noPanClassName,
elevateEdgesOnSelect,
disableKeyboardA11y,
nodeOrigin,
nodeExtent,
rfId,
}: GraphViewProps) => {
useOnInitHandler(onInit);
return (
<FlowRenderer
onPaneClick={onPaneClick}
onPaneMouseEnter={onPaneMouseEnter}
onPaneMouseMove={onPaneMouseMove}
onPaneMouseLeave={onPaneMouseLeave}
onPaneContextMenu={onPaneContextMenu}
onPaneScroll={onPaneScroll}
deleteKeyCode={deleteKeyCode}
selectionKeyCode={selectionKeyCode}
multiSelectionKeyCode={multiSelectionKeyCode}
zoomActivationKeyCode={zoomActivationKeyCode}
elementsSelectable={elementsSelectable}
onMove={onMove}
onMoveStart={onMoveStart}
onMoveEnd={onMoveEnd}
zoomOnScroll={zoomOnScroll}
zoomOnPinch={zoomOnPinch}
zoomOnDoubleClick={zoomOnDoubleClick}
panOnScroll={panOnScroll}
panOnScrollSpeed={panOnScrollSpeed}
panOnScrollMode={panOnScrollMode}
panOnDrag={panOnDrag}
defaultViewport={defaultViewport}
translateExtent={translateExtent}
minZoom={minZoom}
maxZoom={maxZoom}
onSelectionContextMenu={onSelectionContextMenu}
preventScrolling={preventScrolling}
noDragClassName={noDragClassName}
noWheelClassName={noWheelClassName}
noPanClassName={noPanClassName}
disableKeyboardA11y={disableKeyboardA11y}
>
<ViewportWrapper>
<EdgeRenderer
edgeTypes={edgeTypes}
onEdgeClick={onEdgeClick}
onEdgeDoubleClick={onEdgeDoubleClick}
connectionLineType={connectionLineType}
connectionLineStyle={connectionLineStyle}
connectionLineComponent={connectionLineComponent}
connectionLineContainerStyle={connectionLineContainerStyle}
onEdgeUpdate={onEdgeUpdate}
onlyRenderVisibleElements={onlyRenderVisibleElements}
onEdgeContextMenu={onEdgeContextMenu}
onEdgeMouseEnter={onEdgeMouseEnter}
onEdgeMouseMove={onEdgeMouseMove}
onEdgeMouseLeave={onEdgeMouseLeave}
onEdgeUpdateStart={onEdgeUpdateStart}
onEdgeUpdateEnd={onEdgeUpdateEnd}
edgeUpdaterRadius={edgeUpdaterRadius}
defaultMarkerColor={defaultMarkerColor}
noPanClassName={noPanClassName}
elevateEdgesOnSelect={!!elevateEdgesOnSelect}
disableKeyboardA11y={disableKeyboardA11y}
rfId={rfId}
/>
<NodeRenderer
nodeTypes={nodeTypes}
onNodeClick={onNodeClick}
onNodeDoubleClick={onNodeDoubleClick}
onNodeMouseEnter={onNodeMouseEnter}
onNodeMouseMove={onNodeMouseMove}
onNodeMouseLeave={onNodeMouseLeave}
onNodeContextMenu={onNodeContextMenu}
selectNodesOnDrag={selectNodesOnDrag}
onlyRenderVisibleElements={onlyRenderVisibleElements}
noPanClassName={noPanClassName}
noDragClassName={noDragClassName}
disableKeyboardA11y={disableKeyboardA11y}
nodeOrigin={nodeOrigin}
nodeExtent={nodeExtent}
rfId={rfId}
/>
</ViewportWrapper>
</FlowRenderer>
);
};
GraphView.displayName = 'GraphView';
export default memo(GraphView);
@@ -0,0 +1,144 @@
import { memo, useMemo, ComponentType, useEffect, useRef } from 'react';
import shallow from 'zustand/shallow';
import useVisibleNodes from '../../hooks/useVisibleNodes';
import { useStore } from '../../hooks/useStore';
import { clampPosition, devWarn, internalsSymbol } from '../../utils';
import { containerStyle } from '../../styles';
import { GraphViewProps } from '../GraphView';
import { Position, ReactFlowState, WrapNodeProps } from '../../types';
import { getPositionWithOrigin } from './utils';
type NodeRendererProps = Pick<
GraphViewProps,
| 'nodeTypes'
| 'selectNodesOnDrag'
| 'onNodeClick'
| 'onNodeDoubleClick'
| 'onNodeMouseEnter'
| 'onNodeMouseMove'
| 'onNodeMouseLeave'
| 'onNodeContextMenu'
| 'onlyRenderVisibleElements'
| 'noPanClassName'
| 'noDragClassName'
| 'rfId'
| 'disableKeyboardA11y'
| 'nodeOrigin'
| 'nodeExtent'
>;
const selector = (s: ReactFlowState) => ({
nodesDraggable: s.nodesDraggable,
nodesConnectable: s.nodesConnectable,
elementsSelectable: s.elementsSelectable,
updateNodeDimensions: s.updateNodeDimensions,
});
const NodeRenderer = (props: NodeRendererProps) => {
const { nodesDraggable, nodesConnectable, elementsSelectable, updateNodeDimensions } = useStore(selector, shallow);
const nodes = useVisibleNodes(props.onlyRenderVisibleElements);
const resizeObserverRef = useRef<ResizeObserver>();
const resizeObserver = useMemo(() => {
if (typeof ResizeObserver === 'undefined') {
return null;
}
const observer = new ResizeObserver((entries: ResizeObserverEntry[]) => {
const updates = entries.map((entry: ResizeObserverEntry) => ({
id: entry.target.getAttribute('data-id') as string,
nodeElement: entry.target as HTMLDivElement,
forceUpdate: true,
}));
updateNodeDimensions(updates);
});
resizeObserverRef.current = observer;
return observer;
}, []);
useEffect(() => {
return () => {
resizeObserverRef?.current?.disconnect();
};
}, []);
return (
<div className="react-flow__nodes" style={containerStyle}>
{nodes.map((node) => {
let nodeType = node.type || 'default';
if (!props.nodeTypes[nodeType]) {
devWarn(
`Node type "${nodeType}" not found. Using fallback type "default". Help: https://reactflow.dev/error#300`
);
nodeType = 'default';
}
const NodeComponent = (props.nodeTypes[nodeType] || props.nodeTypes.default) as ComponentType<WrapNodeProps>;
const isDraggable = !!(node.draggable || (nodesDraggable && typeof node.draggable === 'undefined'));
const isSelectable = !!(node.selectable || (elementsSelectable && typeof node.selectable === 'undefined'));
const isConnectable = !!(node.connectable || (nodesConnectable && typeof node.connectable === 'undefined'));
const clampedPosition = props.nodeExtent
? clampPosition(node.positionAbsolute, props.nodeExtent)
: node.positionAbsolute;
const posX = clampedPosition?.x ?? 0;
const posY = clampedPosition?.y ?? 0;
const posOrigin = getPositionWithOrigin({
x: posX,
y: posY,
width: node.width ?? 0,
height: node.height ?? 0,
origin: props.nodeOrigin,
});
return (
<NodeComponent
key={node.id}
id={node.id}
className={node.className}
style={node.style}
type={nodeType}
data={node.data}
sourcePosition={node.sourcePosition || Position.Bottom}
targetPosition={node.targetPosition || Position.Top}
hidden={node.hidden}
xPos={posX}
yPos={posY}
xPosOrigin={posOrigin.x}
yPosOrigin={posOrigin.y}
selectNodesOnDrag={props.selectNodesOnDrag}
onClick={props.onNodeClick}
onMouseEnter={props.onNodeMouseEnter}
onMouseMove={props.onNodeMouseMove}
onMouseLeave={props.onNodeMouseLeave}
onContextMenu={props.onNodeContextMenu}
onDoubleClick={props.onNodeDoubleClick}
selected={!!node.selected}
isDraggable={isDraggable}
isSelectable={isSelectable}
isConnectable={isConnectable}
resizeObserver={resizeObserver}
dragHandle={node.dragHandle}
zIndex={node[internalsSymbol]?.z ?? 0}
isParent={!!node[internalsSymbol]?.isParent}
noDragClassName={props.noDragClassName}
noPanClassName={props.noPanClassName}
initialized={!!node.width && !!node.height}
rfId={props.rfId}
disableKeyboardA11y={props.disableKeyboardA11y}
ariaLabel={node.ariaLabel}
/>
);
})}
</div>
);
};
NodeRenderer.displayName = 'NodeRenderer';
export default memo(NodeRenderer);
@@ -0,0 +1,62 @@
import { ComponentType } from 'react';
import DefaultNode from '../../components/Nodes/DefaultNode';
import InputNode from '../../components/Nodes/InputNode';
import OutputNode from '../../components/Nodes/OutputNode';
import GroupNode from '../../components/Nodes/GroupNode';
import wrapNode from '../../components/Nodes/wrapNode';
import { NodeTypes, NodeProps, NodeTypesWrapped, NodeOrigin, XYPosition } from '../../types';
import { devWarn } from '../../utils';
export type CreateNodeTypes = (nodeTypes: NodeTypes) => NodeTypesWrapped;
export function createNodeTypes(nodeTypes: NodeTypes): NodeTypesWrapped {
const standardTypes: NodeTypesWrapped = {
input: wrapNode((nodeTypes.input || InputNode) as ComponentType<NodeProps>),
default: wrapNode((nodeTypes.default || DefaultNode) as ComponentType<NodeProps>),
output: wrapNode((nodeTypes.output || OutputNode) as ComponentType<NodeProps>),
group: wrapNode((nodeTypes.group || GroupNode) as ComponentType<NodeProps>),
};
const wrappedTypes = {} as NodeTypesWrapped;
const specialTypes: NodeTypesWrapped = Object.keys(nodeTypes)
.filter((k) => !['input', 'default', 'output', 'group'].includes(k))
.reduce((res, key) => {
res[key] = wrapNode((nodeTypes[key] || DefaultNode) as ComponentType<NodeProps>);
return res;
}, wrappedTypes);
return {
...standardTypes,
...specialTypes,
};
}
export const getPositionWithOrigin = ({
x,
y,
width,
height,
origin,
}: {
x: number;
y: number;
width: number;
height: number;
origin: NodeOrigin;
}): XYPosition => {
if (!width || !height) {
return { x, y };
}
if (origin[0] < 0 || origin[1] < 0 || origin[0] > 1 || origin[1] > 1) {
devWarn('nodeOrigin must be between 0 and 1');
return { x, y };
}
return {
x: x - width * origin[0],
y: y - height * origin[1],
};
};
@@ -0,0 +1,26 @@
import { FC, PropsWithChildren } from 'react';
import { useStoreApi } from '../../hooks/useStore';
import ReactFlowProvider from '../../components/ReactFlowProvider';
const Wrapper: FC<PropsWithChildren> = ({ children }) => {
let isWrapped = true;
try {
useStoreApi();
} catch (e) {
isWrapped = false;
}
if (isWrapped) {
// we need to wrap it with a fragment because it's not allowed for children to be a ReactNode
// https://github.com/DefinitelyTyped/DefinitelyTyped/issues/18051
return <>{children}</>;
}
return <ReactFlowProvider>{children}</ReactFlowProvider>;
};
Wrapper.displayName = 'ReactFlowWrapper';
export default Wrapper;
@@ -0,0 +1,286 @@
import { CSSProperties, forwardRef } from 'react';
import cc from 'classcat';
import Attribution from '../../components/Attribution';
import { BezierEdge, SmoothStepEdge, StepEdge, StraightEdge, SimpleBezierEdge } from '../../components/Edges';
import DefaultNode from '../../components/Nodes/DefaultNode';
import InputNode from '../../components/Nodes/InputNode';
import OutputNode from '../../components/Nodes/OutputNode';
import GroupNode from '../../components/Nodes/GroupNode';
import SelectionListener from '../../components/SelectionListener';
import StoreUpdater from '../../components/StoreUpdater';
import {
ConnectionLineType,
ConnectionMode,
EdgeTypes,
EdgeTypesWrapped,
NodeOrigin,
NodeTypes,
NodeTypesWrapped,
PanOnScrollMode,
ReactFlowProps,
ReactFlowRefType,
Viewport,
} from '../../types';
import { createEdgeTypes } from '../EdgeRenderer/utils';
import GraphView from '../GraphView';
import { createNodeTypes } from '../NodeRenderer/utils';
import { useNodeOrEdgeTypes } from './utils';
import Wrapper from './Wrapper';
import A11yDescriptions from '../../components/A11yDescriptions';
import { infiniteExtent } from '../../store/initialState';
const defaultNodeTypes: NodeTypes = {
input: InputNode,
default: DefaultNode,
output: OutputNode,
group: GroupNode,
};
const defaultEdgeTypes: EdgeTypes = {
default: BezierEdge,
straight: StraightEdge,
step: StepEdge,
smoothstep: SmoothStepEdge,
simplebezier: SimpleBezierEdge,
};
const initNodeOrigin: NodeOrigin = [0, 0];
const initSnapGrid: [number, number] = [15, 15];
const initDefaultViewport: Viewport = { x: 0, y: 0, zoom: 1 };
const wrapperStyle: CSSProperties = {
width: '100%',
height: '100%',
position: 'relative',
overflow: 'hidden',
};
const ReactFlow = forwardRef<ReactFlowRefType, ReactFlowProps>(
(
{
nodes,
edges,
defaultNodes,
defaultEdges,
className,
nodeTypes = defaultNodeTypes,
edgeTypes = defaultEdgeTypes,
onNodeClick,
onEdgeClick,
onInit,
onMove,
onMoveStart,
onMoveEnd,
onConnect,
onConnectStart,
onConnectEnd,
onClickConnectStart,
onClickConnectEnd,
onNodeMouseEnter,
onNodeMouseMove,
onNodeMouseLeave,
onNodeContextMenu,
onNodeDoubleClick,
onNodeDragStart,
onNodeDrag,
onNodeDragStop,
onNodesDelete,
onEdgesDelete,
onSelectionChange,
onSelectionDragStart,
onSelectionDrag,
onSelectionDragStop,
onSelectionContextMenu,
connectionMode = ConnectionMode.Strict,
connectionLineType = ConnectionLineType.Bezier,
connectionLineStyle,
connectionLineComponent,
connectionLineContainerStyle,
deleteKeyCode = 'Backspace',
selectionKeyCode = 'Shift',
multiSelectionKeyCode = 'Meta',
zoomActivationKeyCode = 'Meta',
snapToGrid = false,
snapGrid = initSnapGrid,
onlyRenderVisibleElements = false,
selectNodesOnDrag = true,
nodesDraggable,
nodesConnectable,
nodeOrigin = initNodeOrigin,
elementsSelectable,
defaultViewport = initDefaultViewport,
minZoom = 0.5,
maxZoom = 2,
translateExtent = infiniteExtent,
preventScrolling = true,
nodeExtent,
defaultMarkerColor = '#b1b1b7',
zoomOnScroll = true,
zoomOnPinch = true,
panOnScroll = false,
panOnScrollSpeed = 0.5,
panOnScrollMode = PanOnScrollMode.Free,
zoomOnDoubleClick = true,
panOnDrag = true,
onPaneClick,
onPaneMouseEnter,
onPaneMouseMove,
onPaneMouseLeave,
onPaneScroll,
onPaneContextMenu,
children,
onEdgeUpdate,
onEdgeContextMenu,
onEdgeDoubleClick,
onEdgeMouseEnter,
onEdgeMouseMove,
onEdgeMouseLeave,
onEdgeUpdateStart,
onEdgeUpdateEnd,
edgeUpdaterRadius = 10,
onNodesChange,
onEdgesChange,
noDragClassName = 'nodrag',
noWheelClassName = 'nowheel',
noPanClassName = 'nopan',
fitView = false,
fitViewOptions,
connectOnClick = true,
attributionPosition,
proOptions,
defaultEdgeOptions,
elevateEdgesOnSelect = false,
disableKeyboardA11y = false,
style,
id = '1',
...rest
},
ref
) => {
const nodeTypesWrapped = useNodeOrEdgeTypes(nodeTypes, createNodeTypes) as NodeTypesWrapped;
const edgeTypesWrapped = useNodeOrEdgeTypes(edgeTypes, createEdgeTypes) as EdgeTypesWrapped;
return (
<div
{...rest}
style={{ ...style, ...wrapperStyle }}
ref={ref}
className={cc(['react-flow', className])}
data-testid="rf__wrapper"
>
<Wrapper>
<GraphView
onInit={onInit}
onMove={onMove}
onMoveStart={onMoveStart}
onMoveEnd={onMoveEnd}
onNodeClick={onNodeClick}
onEdgeClick={onEdgeClick}
onNodeMouseEnter={onNodeMouseEnter}
onNodeMouseMove={onNodeMouseMove}
onNodeMouseLeave={onNodeMouseLeave}
onNodeContextMenu={onNodeContextMenu}
onNodeDoubleClick={onNodeDoubleClick}
nodeTypes={nodeTypesWrapped}
edgeTypes={edgeTypesWrapped}
connectionLineType={connectionLineType}
connectionLineStyle={connectionLineStyle}
connectionLineComponent={connectionLineComponent}
connectionLineContainerStyle={connectionLineContainerStyle}
selectionKeyCode={selectionKeyCode}
deleteKeyCode={deleteKeyCode}
multiSelectionKeyCode={multiSelectionKeyCode}
zoomActivationKeyCode={zoomActivationKeyCode}
onlyRenderVisibleElements={onlyRenderVisibleElements}
selectNodesOnDrag={selectNodesOnDrag}
defaultViewport={defaultViewport}
translateExtent={translateExtent}
minZoom={minZoom}
maxZoom={maxZoom}
preventScrolling={preventScrolling}
zoomOnScroll={zoomOnScroll}
zoomOnPinch={zoomOnPinch}
zoomOnDoubleClick={zoomOnDoubleClick}
panOnScroll={panOnScroll}
panOnScrollSpeed={panOnScrollSpeed}
panOnScrollMode={panOnScrollMode}
panOnDrag={panOnDrag}
onPaneClick={onPaneClick}
onPaneMouseEnter={onPaneMouseEnter}
onPaneMouseMove={onPaneMouseMove}
onPaneMouseLeave={onPaneMouseLeave}
onPaneScroll={onPaneScroll}
onPaneContextMenu={onPaneContextMenu}
onSelectionContextMenu={onSelectionContextMenu}
onEdgeUpdate={onEdgeUpdate}
onEdgeContextMenu={onEdgeContextMenu}
onEdgeDoubleClick={onEdgeDoubleClick}
onEdgeMouseEnter={onEdgeMouseEnter}
onEdgeMouseMove={onEdgeMouseMove}
onEdgeMouseLeave={onEdgeMouseLeave}
onEdgeUpdateStart={onEdgeUpdateStart}
onEdgeUpdateEnd={onEdgeUpdateEnd}
edgeUpdaterRadius={edgeUpdaterRadius}
defaultMarkerColor={defaultMarkerColor}
noDragClassName={noDragClassName}
noWheelClassName={noWheelClassName}
noPanClassName={noPanClassName}
elevateEdgesOnSelect={elevateEdgesOnSelect}
rfId={id}
disableKeyboardA11y={disableKeyboardA11y}
nodeOrigin={nodeOrigin}
nodeExtent={nodeExtent}
/>
<StoreUpdater
nodes={nodes}
edges={edges}
defaultNodes={defaultNodes}
defaultEdges={defaultEdges}
onConnect={onConnect}
onConnectStart={onConnectStart}
onConnectEnd={onConnectEnd}
onClickConnectStart={onClickConnectStart}
onClickConnectEnd={onClickConnectEnd}
nodesDraggable={nodesDraggable}
nodesConnectable={nodesConnectable}
elementsSelectable={elementsSelectable}
minZoom={minZoom}
maxZoom={maxZoom}
nodeExtent={nodeExtent}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
snapToGrid={snapToGrid}
snapGrid={snapGrid}
connectionMode={connectionMode}
translateExtent={translateExtent}
connectOnClick={connectOnClick}
defaultEdgeOptions={defaultEdgeOptions}
fitView={fitView}
fitViewOptions={fitViewOptions}
onNodesDelete={onNodesDelete}
onEdgesDelete={onEdgesDelete}
onNodeDragStart={onNodeDragStart}
onNodeDrag={onNodeDrag}
onNodeDragStop={onNodeDragStop}
onSelectionDrag={onSelectionDrag}
onSelectionDragStart={onSelectionDragStart}
onSelectionDragStop={onSelectionDragStop}
noPanClassName={noPanClassName}
nodeOrigin={nodeOrigin}
id={id}
/>
{onSelectionChange && <SelectionListener onSelectionChange={onSelectionChange} />}
{children}
<Attribution proOptions={proOptions} position={attributionPosition} />
{!disableKeyboardA11y && <A11yDescriptions rfId={id} />}
</Wrapper>
</div>
);
}
);
ReactFlow.displayName = 'ReactFlow';
export default ReactFlow;
@@ -0,0 +1,30 @@
import { useMemo, useRef } from 'react';
import shallow from 'zustand/shallow';
import { EdgeTypes, EdgeTypesWrapped, NodeTypes, NodeTypesWrapped } from '../../types';
import { devWarn } from '../../utils';
import { CreateEdgeTypes } from '../EdgeRenderer/utils';
import { CreateNodeTypes } from '../NodeRenderer/utils';
export function useNodeOrEdgeTypes(nodeOrEdgeTypes: NodeTypes, createTypes: CreateNodeTypes): NodeTypesWrapped;
export function useNodeOrEdgeTypes(nodeOrEdgeTypes: EdgeTypes, createTypes: CreateEdgeTypes): EdgeTypesWrapped;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function useNodeOrEdgeTypes(nodeOrEdgeTypes: any, createTypes: any): any {
const typesKeysRef = useRef<string[] | null>(null);
const typesParsed = useMemo(() => {
if (process.env.NODE_ENV === 'development') {
const typeKeys = Object.keys(nodeOrEdgeTypes);
if (shallow(typesKeysRef.current, typeKeys)) {
devWarn(
"It looks like you have 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. Help: https://reactflow.dev/error#200"
);
}
typesKeysRef.current = typeKeys;
}
return createTypes(nodeOrEdgeTypes);
}, [nodeOrEdgeTypes]);
return typesParsed;
}
@@ -0,0 +1,22 @@
import { ReactNode } from 'react';
import { useStore } from '../../hooks/useStore';
import { ReactFlowState } from '../../types';
const selector = (s: ReactFlowState) => `translate(${s.transform[0]}px,${s.transform[1]}px) scale(${s.transform[2]})`;
type ViewportProps = {
children: ReactNode;
};
function Viewport({ children }: ViewportProps) {
const transform = useStore(selector);
return (
<div className="react-flow__viewport react-flow__container" style={{ transform }}>
{children}
</div>
);
}
export default Viewport;
@@ -0,0 +1,289 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { useEffect, useRef } from 'react';
import { D3ZoomEvent, zoom, zoomIdentity } from 'd3-zoom';
import { select, pointer } from 'd3-selection';
import shallow from 'zustand/shallow';
import { clamp } from '../../utils';
import useKeyPress from '../../hooks/useKeyPress';
import useResizeHandler from '../../hooks/useResizeHandler';
import { useStore, useStoreApi } from '../../hooks/useStore';
import { FlowRendererProps } from '../FlowRenderer';
import { containerStyle } from '../../styles';
import { Viewport, PanOnScrollMode, ReactFlowState } from '../../types';
type ZoomPaneProps = Omit<
FlowRendererProps,
'deleteKeyCode' | 'selectionKeyCode' | 'multiSelectionKeyCode' | 'noDragClassName' | 'disableKeyboardA11y'
> & { selectionKeyPressed: boolean };
const viewChanged = (prevViewport: Viewport, eventViewport: any): boolean =>
prevViewport.x !== eventViewport.x || prevViewport.y !== eventViewport.y || prevViewport.zoom !== eventViewport.k;
const eventToFlowTransform = (eventViewport: any): Viewport => ({
x: eventViewport.x,
y: eventViewport.y,
zoom: eventViewport.k,
});
const isWrappedWithClass = (event: any, className: string | undefined) => event.target.closest(`.${className}`);
const selector = (s: ReactFlowState) => ({
d3Zoom: s.d3Zoom,
d3Selection: s.d3Selection,
d3ZoomHandler: s.d3ZoomHandler,
});
const ZoomPane = ({
onMove,
onMoveStart,
onMoveEnd,
zoomOnScroll = true,
zoomOnPinch = true,
panOnScroll = false,
panOnScrollSpeed = 0.5,
panOnScrollMode = PanOnScrollMode.Free,
zoomOnDoubleClick = true,
selectionKeyPressed,
elementsSelectable,
panOnDrag = true,
defaultViewport,
translateExtent,
minZoom,
maxZoom,
zoomActivationKeyCode,
preventScrolling = true,
children,
noWheelClassName,
noPanClassName,
}: ZoomPaneProps) => {
const timerId = useRef<ReturnType<typeof setTimeout>>();
const store = useStoreApi();
const isZoomingOrPanning = useRef(false);
const zoomPane = useRef<HTMLDivElement>(null);
const prevTransform = useRef<Viewport>({ x: 0, y: 0, zoom: 0 });
const { d3Zoom, d3Selection, d3ZoomHandler } = useStore(selector, shallow);
const zoomActivationKeyPressed = useKeyPress(zoomActivationKeyCode);
useResizeHandler(zoomPane);
useEffect(() => {
if (zoomPane.current) {
const d3ZoomInstance = zoom().scaleExtent([minZoom, maxZoom]).translateExtent(translateExtent);
const selection = select(zoomPane.current as Element).call(d3ZoomInstance);
const clampedX = clamp(defaultViewport.x, translateExtent[0][0], translateExtent[1][0]);
const clampedY = clamp(defaultViewport.y, translateExtent[0][1], translateExtent[1][1]);
const clampedZoom = clamp(defaultViewport.zoom, minZoom, maxZoom);
const updatedTransform = zoomIdentity.translate(clampedX, clampedY).scale(clampedZoom);
d3ZoomInstance.transform(selection, updatedTransform);
store.setState({
d3Zoom: d3ZoomInstance,
d3Selection: selection,
d3ZoomHandler: selection.on('wheel.zoom'),
// we need to pass transform because zoom handler is not registered when we set the initial transform
transform: [clampedX, clampedY, clampedZoom],
domNode: zoomPane.current.closest('.react-flow') as HTMLDivElement,
});
}
}, []);
useEffect(() => {
if (d3Selection && d3Zoom) {
if (panOnScroll && !zoomActivationKeyPressed) {
d3Selection.on('wheel.zoom', (event: any) => {
if (isWrappedWithClass(event, noWheelClassName)) {
return false;
}
event.preventDefault();
event.stopImmediatePropagation();
const currentZoom = d3Selection.property('__zoom').k || 1;
if (event.ctrlKey && zoomOnPinch) {
const point = pointer(event);
// taken from https://github.com/d3/d3-zoom/blob/master/src/zoom.js
const pinchDelta = -event.deltaY * (event.deltaMode === 1 ? 0.05 : event.deltaMode ? 1 : 0.002) * 10;
const zoom = currentZoom * Math.pow(2, pinchDelta);
d3Zoom.scaleTo(d3Selection, zoom, point);
return;
}
// increase scroll speed in firefox
// firefox: deltaMode === 1; chrome: deltaMode === 0
const deltaNormalize = event.deltaMode === 1 ? 20 : 1;
const deltaX = panOnScrollMode === PanOnScrollMode.Vertical ? 0 : event.deltaX * deltaNormalize;
const deltaY = panOnScrollMode === PanOnScrollMode.Horizontal ? 0 : event.deltaY * deltaNormalize;
d3Zoom.translateBy(
d3Selection,
-(deltaX / currentZoom) * panOnScrollSpeed,
-(deltaY / currentZoom) * panOnScrollSpeed
);
});
} else if (typeof d3ZoomHandler !== 'undefined') {
d3Selection.on('wheel.zoom', function (event: any, d: any) {
if (!preventScrolling || isWrappedWithClass(event, noWheelClassName)) {
return null;
}
event.preventDefault();
d3ZoomHandler.call(this, event, d);
});
}
}
}, [
panOnScroll,
panOnScrollMode,
d3Selection,
d3Zoom,
d3ZoomHandler,
zoomActivationKeyPressed,
zoomOnPinch,
preventScrolling,
noWheelClassName,
]);
useEffect(() => {
if (d3Zoom) {
if (selectionKeyPressed && !isZoomingOrPanning.current) {
d3Zoom.on('zoom', null);
} else if (!selectionKeyPressed) {
d3Zoom.on('zoom', (event: D3ZoomEvent<HTMLDivElement, any>) => {
const { onViewportChange } = store.getState();
store.setState({ transform: [event.transform.x, event.transform.y, event.transform.k] });
if (onMove || onViewportChange) {
const flowTransform = eventToFlowTransform(event.transform);
onViewportChange?.(flowTransform);
onMove?.(event.sourceEvent as MouseEvent | TouchEvent, flowTransform);
}
});
}
}
}, [selectionKeyPressed, d3Zoom, onMove]);
useEffect(() => {
if (d3Zoom) {
d3Zoom.on('start', (event: D3ZoomEvent<HTMLDivElement, any>) => {
const { onViewportChangeStart } = store.getState();
isZoomingOrPanning.current = true;
if (event.sourceEvent?.type === 'mousedown') {
store.setState({ paneDragging: true });
}
if (onMoveStart || onViewportChangeStart) {
const flowTransform = eventToFlowTransform(event.transform);
prevTransform.current = flowTransform;
onViewportChangeStart?.(flowTransform);
onMoveStart?.(event.sourceEvent as MouseEvent | TouchEvent, flowTransform);
}
});
}
}, [d3Zoom, onMoveStart]);
useEffect(() => {
if (d3Zoom) {
d3Zoom.on('end', (event: D3ZoomEvent<HTMLDivElement, any>) => {
const { onViewportChangeEnd } = store.getState();
isZoomingOrPanning.current = false;
store.setState({ paneDragging: false });
if ((onMoveEnd || onViewportChangeEnd) && viewChanged(prevTransform.current, event.transform)) {
const flowTransform = eventToFlowTransform(event.transform);
prevTransform.current = flowTransform;
clearTimeout(timerId.current);
timerId.current = setTimeout(
() => {
onViewportChangeEnd?.(flowTransform);
onMoveEnd?.(event.sourceEvent as MouseEvent | TouchEvent, flowTransform);
},
panOnScroll ? 150 : 0
);
}
});
}
}, [d3Zoom, onMoveEnd, panOnScroll]);
useEffect(() => {
if (d3Zoom) {
d3Zoom.filter((event: any) => {
const zoomScroll = zoomActivationKeyPressed || zoomOnScroll;
const pinchZoom = zoomOnPinch && event.ctrlKey;
if (event.button === 1 && event.type === 'mousedown' && event.target.closest(`.react-flow__node`)) {
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 (selectionKeyPressed) {
return false;
}
// if zoom on double click is disabled, we prevent the double click event
if (!zoomOnDoubleClick && event.type === 'dblclick') {
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') {
return false;
}
if (!zoomOnPinch && event.ctrlKey && event.type === 'wheel') {
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;
}
// default filter for d3-zoom
return (!event.ctrlKey || event.type === 'wheel') && (!event.button || event.button <= 1);
});
}
}, [
d3Zoom,
zoomOnScroll,
zoomOnPinch,
panOnScroll,
zoomOnDoubleClick,
panOnDrag,
selectionKeyPressed,
elementsSelectable,
zoomActivationKeyPressed,
]);
return (
<div className="react-flow__renderer" ref={zoomPane} style={containerStyle}>
{children}
</div>
);
};
export default ZoomPane;
@@ -0,0 +1,7 @@
import { createContext } from 'react';
export const NodeIdContext = createContext<string | null>(null);
export const Provider = NodeIdContext.Provider;
export const Consumer = NodeIdContext.Consumer;
export default NodeIdContext;
@@ -0,0 +1,8 @@
import { createContext } from 'react';
import { createRFStore } from '../store';
const StoreContext = createContext<ReturnType<typeof createRFStore> | null>(null);
export const Provider = StoreContext.Provider;
export default StoreContext;
+13
View File
@@ -0,0 +1,13 @@
declare module '*.css' {
const content: { [className: string]: string };
export default content;
}
type SvgrComponent = React.FunctionComponent<React.SVGAttributes<SVGElement>>;
declare module '*.svg' {
const svgUrl: string;
const svgComponent: SvgrComponent;
export default svgUrl;
export { svgComponent as ReactComponent };
}
+207
View File
@@ -0,0 +1,207 @@
import { RefObject, useEffect, useRef, MouseEvent, useState, useCallback } from 'react';
import { D3DragEvent, drag, SubjectPosition } from 'd3-drag';
import { select } from 'd3-selection';
import { useStoreApi } from '../../hooks/useStore';
import { NodeDragItem, Node, SelectionDragHandler } from '../../types';
import { getDragItems, getEventHandlerParams, hasSelector, calcNextPosition } from './utils';
import { handleNodeClick } from '../../components/Nodes/utils';
export type UseDragEvent = D3DragEvent<HTMLDivElement, null, SubjectPosition>;
export type UseDragData = { dx: number; dy: number };
type UseDragParams = {
nodeRef: RefObject<Element>;
disabled?: boolean;
noDragClassName?: string;
handleSelector?: string;
nodeId?: string;
isSelectable?: boolean;
selectNodesOnDrag?: boolean;
};
function wrapSelectionDragFunc(selectionFunc?: SelectionDragHandler) {
return (event: MouseEvent, _: Node, nodes: Node[]) => selectionFunc?.(event, nodes);
}
function useDrag({
nodeRef,
disabled = false,
noDragClassName,
handleSelector,
nodeId,
isSelectable,
selectNodesOnDrag,
}: UseDragParams) {
const [dragging, setDragging] = useState<boolean>(false);
const store = useStoreApi();
const dragItems = useRef<NodeDragItem[]>();
const lastPos = useRef<{ x: number | null; y: number | null }>({ x: null, y: null });
// returns the pointer position projected to the RF coordinate system
const getPointerPosition = useCallback(({ sourceEvent }: UseDragEvent) => {
const { transform, snapGrid, snapToGrid } = store.getState();
const x = sourceEvent.touches ? sourceEvent.touches[0].clientX : sourceEvent.clientX;
const y = sourceEvent.touches ? sourceEvent.touches[0].clientY : sourceEvent.clientY;
const pointerPos = {
x: (x - transform[0]) / transform[2],
y: (y - transform[1]) / transform[2],
};
// we need the snapped position in order to be able to skip unnecessary drag events
return {
xSnapped: snapToGrid ? snapGrid[0] * Math.round(pointerPos.x / snapGrid[0]) : pointerPos.x,
ySnapped: snapToGrid ? snapGrid[1] * Math.round(pointerPos.y / snapGrid[1]) : pointerPos.y,
...pointerPos,
};
}, []);
useEffect(() => {
if (nodeRef?.current) {
const selection = select(nodeRef.current);
if (disabled) {
selection.on('.drag', null);
} else {
const dragHandler = drag()
.on('start', (event: UseDragEvent) => {
const {
nodeInternals,
multiSelectionActive,
unselectNodesAndEdges,
onNodeDragStart,
onSelectionDragStart,
} = store.getState();
const onStart = nodeId ? onNodeDragStart : wrapSelectionDragFunc(onSelectionDragStart);
if (!selectNodesOnDrag && !multiSelectionActive && nodeId) {
if (!nodeInternals.get(nodeId)?.selected) {
// we need to reset selected nodes when selectNodesOnDrag=false
unselectNodesAndEdges();
}
}
if (nodeId && isSelectable && selectNodesOnDrag) {
handleNodeClick({
id: nodeId,
store,
});
}
const pointerPos = getPointerPosition(event);
lastPos.current = pointerPos;
dragItems.current = getDragItems(nodeInternals, pointerPos, nodeId);
if (onStart && dragItems.current) {
const [currentNode, nodes] = getEventHandlerParams({
nodeId,
dragItems: dragItems.current,
nodeInternals,
});
onStart(event.sourceEvent as MouseEvent, currentNode, nodes);
}
})
.on('drag', (event: UseDragEvent) => {
const {
updateNodePositions,
nodeInternals,
nodeExtent,
onNodeDrag,
onSelectionDrag,
snapGrid,
snapToGrid,
} = store.getState();
const pointerPos = getPointerPosition(event);
// skip events without movement
if (
(lastPos.current.x !== pointerPos.xSnapped || lastPos.current.y !== pointerPos.ySnapped) &&
dragItems.current
) {
lastPos.current = {
x: pointerPos.xSnapped,
y: pointerPos.ySnapped,
};
dragItems.current = dragItems.current.map((n) => {
const nextPosition = { x: pointerPos.x - n.distance.x, y: pointerPos.y - n.distance.y };
if (snapToGrid) {
nextPosition.x = snapGrid[0] * Math.round(nextPosition.x / snapGrid[0]);
nextPosition.y = snapGrid[1] * Math.round(nextPosition.y / snapGrid[1]);
}
const updatedPos = calcNextPosition(n, nextPosition, nodeInternals, nodeExtent);
n.position = updatedPos.position;
n.positionAbsolute = updatedPos.positionAbsolute;
return n;
});
const onDrag = nodeId ? onNodeDrag : wrapSelectionDragFunc(onSelectionDrag);
updateNodePositions(dragItems.current, true, true);
setDragging(true);
if (onDrag) {
const [currentNode, nodes] = getEventHandlerParams({
nodeId,
dragItems: dragItems.current,
nodeInternals,
});
onDrag(event.sourceEvent as MouseEvent, currentNode, nodes);
}
}
})
.on('end', (event: UseDragEvent) => {
setDragging(false);
if (dragItems.current) {
const { updateNodePositions, nodeInternals, onNodeDragStop, onSelectionDragStop } = store.getState();
const onStop = nodeId ? onNodeDragStop : wrapSelectionDragFunc(onSelectionDragStop);
updateNodePositions(dragItems.current, false, false);
if (onStop) {
const [currentNode, nodes] = getEventHandlerParams({
nodeId,
dragItems: dragItems.current,
nodeInternals,
});
onStop(event.sourceEvent as MouseEvent, currentNode, nodes);
}
}
})
.filter((event: MouseEvent) => {
const target = event.target as HTMLDivElement;
const isDraggable =
!event.button &&
(!noDragClassName || !hasSelector(target, `.${noDragClassName}`, nodeRef)) &&
(!handleSelector || hasSelector(target, handleSelector, nodeRef));
return isDraggable;
});
selection.call(dragHandler);
return () => {
selection.on('.drag', null);
};
}
}
}, [
nodeRef,
disabled,
noDragClassName,
handleSelector,
isSelectable,
store,
nodeId,
selectNodesOnDrag,
getPointerPosition,
]);
return dragging;
}
export default useDrag;
+138
View File
@@ -0,0 +1,138 @@
import { RefObject } from 'react';
import { CoordinateExtent, Node, NodeDragItem, NodeInternals, XYPosition } from '../../types';
import { clampPosition, devWarn } from '../../utils';
export function isParentSelected(node: Node, nodeInternals: NodeInternals): boolean {
if (!node.parentNode) {
return false;
}
const parentNode = nodeInternals.get(node.parentNode);
if (!parentNode) {
return false;
}
if (parentNode.selected) {
return true;
}
return isParentSelected(parentNode, nodeInternals);
}
export function hasSelector(target: Element, selector: string, nodeRef: RefObject<Element>): boolean {
let current = target;
do {
if (current?.matches(selector)) return true;
if (current === nodeRef.current) return false;
current = current.parentElement as Element;
} while (current);
return false;
}
// looks for all selected nodes and created a NodeDragItem for each of them
export function getDragItems(nodeInternals: NodeInternals, mousePos: XYPosition, nodeId?: string): NodeDragItem[] {
return Array.from(nodeInternals.values())
.filter((n) => (n.selected || n.id === nodeId) && (!n.parentNode || !isParentSelected(n, nodeInternals)))
.map((n) => ({
id: n.id,
position: n.position || { x: 0, y: 0 },
positionAbsolute: n.positionAbsolute || { x: 0, y: 0 },
distance: {
x: mousePos.x - (n.positionAbsolute?.x ?? 0),
y: mousePos.y - (n.positionAbsolute?.y ?? 0),
},
delta: {
x: 0,
y: 0,
},
extent: n.extent,
parentNode: n.parentNode,
width: n.width,
height: n.height,
}));
}
export function calcNextPosition(
node: NodeDragItem | Node,
nextPosition: XYPosition,
nodeInternals: NodeInternals,
nodeExtent?: CoordinateExtent
): { position: XYPosition; positionAbsolute: XYPosition } {
let currentExtent = node.extent || nodeExtent;
if (node.extent === 'parent') {
if (node.parentNode && node.width && node.height) {
const parent = nodeInternals.get(node.parentNode);
currentExtent =
parent?.positionAbsolute && parent?.width && parent?.height
? [
[parent.positionAbsolute.x, parent.positionAbsolute.y],
[
parent.positionAbsolute.x + parent.width - node.width,
parent.positionAbsolute.y + parent.height - node.height,
],
]
: currentExtent;
} else {
devWarn('Only child nodes can use a parent extent. Help: https://reactflow.dev/error#500');
currentExtent = nodeExtent;
}
} else if (node.extent && node.parentNode) {
const parent = nodeInternals.get(node.parentNode);
const parentX = parent?.positionAbsolute?.x ?? 0;
const parentY = parent?.positionAbsolute?.y ?? 0;
currentExtent = [
[node.extent[0][0] + parentX, node.extent[0][1] + parentY],
[node.extent[1][0] + parentX, node.extent[1][1] + parentY],
];
}
let parentPosition = { x: 0, y: 0 };
if (node.parentNode) {
const parentNode = nodeInternals.get(node.parentNode);
parentPosition = { x: parentNode?.positionAbsolute?.x ?? 0, y: parentNode?.positionAbsolute?.y ?? 0 };
}
const positionAbsolute = currentExtent
? clampPosition(nextPosition, currentExtent as CoordinateExtent)
: nextPosition;
return {
position: {
x: positionAbsolute.x - parentPosition.x,
y: positionAbsolute.y - parentPosition.y,
},
positionAbsolute,
};
}
// 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)
export function getEventHandlerParams({
nodeId,
dragItems,
nodeInternals,
}: {
nodeId?: string;
dragItems: NodeDragItem[];
nodeInternals: NodeInternals;
}): [Node, Node[]] {
const extentedDragItems: Node[] = dragItems.map((n) => {
const node = nodeInternals.get(n.id)!;
return {
...node,
position: n.position,
positionAbsolute: n.positionAbsolute,
};
});
return [nodeId ? extentedDragItems.find((n) => n.id === nodeId)! : extentedDragItems[0], extentedDragItems];
}
+12
View File
@@ -0,0 +1,12 @@
import { useStore } from '../hooks/useStore';
import { Edge, ReactFlowState } from '../types';
const edgesSelector = (state: ReactFlowState) => state.edges;
function useEdges<EdgeData>(): Edge<EdgeData>[] {
const edges = useStore(edgesSelector);
return edges;
}
export default useEdges;
@@ -0,0 +1,103 @@
import { useEffect } from 'react';
import { useStoreApi } from '../hooks/useStore';
import useKeyPress from './useKeyPress';
import { getConnectedEdges } from '../utils/graph';
import { KeyCode, NodeChange, Node } from '../types';
interface HookParams {
deleteKeyCode: KeyCode | null;
multiSelectionKeyCode: KeyCode | null;
}
export default ({ deleteKeyCode, multiSelectionKeyCode }: HookParams): void => {
const store = useStoreApi();
const deleteKeyPressed = useKeyPress(deleteKeyCode);
const multiSelectionKeyPressed = useKeyPress(multiSelectionKeyCode);
useEffect(() => {
if (!deleteKeyPressed) {
return;
}
const {
nodeInternals,
edges,
hasDefaultNodes,
hasDefaultEdges,
onNodesDelete,
onEdgesDelete,
onNodesChange,
onEdgesChange,
} = store.getState();
const nodes = Array.from(nodeInternals.values());
const nodesToRemove = nodes.reduce<Node[]>((res, node) => {
const parentSelected = !node.selected && node.parentNode && res.find((n) => n.id === node.parentNode);
const deletable = typeof node.deletable === 'boolean' ? node.deletable : true;
if (deletable && (node.selected || parentSelected)) {
res.push(node);
}
return res;
}, []);
const deletableEdges = edges.filter((e) => (typeof e.deletable === 'boolean' ? e.deletable : true));
const selectedEdges = deletableEdges.filter((e) => e.selected);
if (nodesToRemove || selectedEdges) {
const connectedEdges = getConnectedEdges(nodesToRemove, deletableEdges);
const edgesToRemove = [...selectedEdges, ...connectedEdges];
const edgeIdsToRemove = edgesToRemove.reduce<string[]>((res, edge) => {
if (!res.includes(edge.id)) {
res.push(edge.id);
}
return res;
}, []);
if (hasDefaultEdges || hasDefaultNodes) {
if (hasDefaultEdges) {
store.setState({
edges: edges.filter((e) => !edgeIdsToRemove.includes(e.id)),
});
}
if (hasDefaultNodes) {
nodesToRemove.forEach((node) => {
nodeInternals.delete(node.id);
});
store.setState({
nodeInternals: new Map(nodeInternals),
});
}
}
if (edgeIdsToRemove.length > 0) {
onEdgesDelete?.(edgesToRemove);
if (onEdgesChange) {
onEdgesChange(
edgeIdsToRemove.map((id) => ({
id,
type: 'remove',
}))
);
}
}
if (nodesToRemove.length > 0) {
onNodesDelete?.(nodesToRemove);
if (onNodesChange) {
const nodeChanges: NodeChange[] = nodesToRemove.map((n) => ({ id: n.id, type: 'remove' }));
onNodesChange(nodeChanges);
}
}
store.setState({ nodesSelectionActive: false });
}
}, [deleteKeyPressed]);
useEffect(() => {
store.setState({ multiSelectionActive: multiSelectionKeyPressed });
}, [multiSelectionKeyPressed]);
};
+119
View File
@@ -0,0 +1,119 @@
import { useState, useEffect, useRef, useMemo } from 'react';
import { KeyCode } from '../types';
type Keys = Array<string>;
type PressedKeys = Set<string>;
type KeyOrCode = 'key' | 'code';
export interface UseKeyPressOptions {
target: Window | Document | HTMLElement | ShadowRoot | null;
}
const doc = typeof document !== 'undefined' ? document : null;
// the keycode can be a string 'a' or an array of strings ['a', 'a+d']
// a string means a single key 'a' or a combination when '+' is used 'a+d'
// an array means different possibilites. Explainer: ['a', 'd+s'] here the
// user can use the single key 'a' or the combination 'd' + 's'
export default (keyCode: KeyCode | null = null, options: UseKeyPressOptions = { target: doc }): boolean => {
const [keyPressed, setKeyPressed] = useState(false);
// we need to remember the pressed keys in order to support combinations
const pressedKeys = useRef<PressedKeys>(new Set([]));
// keyCodes = array with single keys [['a']] or key combinations [['a', 's']]
// keysToWatch = array with all keys flattened ['a', 'd', 'ShiftLeft']
// used to check if we store event.code or event.key. When the code is in the list of keysToWatch
// we use the code otherwise the key. Explainer: When you press the left "command" key, the code is "MetaLeft"
// and the key is "Meta". We want users to be able to pass keys and codes so we assume that the key is meant when
// we can't find it in the list of keysToWatch.
const [keyCodes, keysToWatch] = useMemo<[Array<Keys>, Keys]>(() => {
if (keyCode !== null) {
const keyCodeArr = Array.isArray(keyCode) ? keyCode : [keyCode];
const keys = keyCodeArr.filter((kc) => typeof kc === 'string').map((kc) => kc.split('+'));
const keysFlat = keys.reduce((res: Keys, item) => res.concat(...item), []);
return [keys, keysFlat];
}
return [[], []];
}, [keyCode]);
useEffect(() => {
if (keyCode !== null) {
const downHandler = (event: KeyboardEvent) => {
if (isInputDOMNode(event)) {
return false;
}
const keyOrCode = useKeyOrCode(event.code, keysToWatch);
pressedKeys.current.add(event[keyOrCode]);
if (isMatchingKey(keyCodes, pressedKeys.current, false)) {
event.preventDefault();
setKeyPressed(true);
}
};
const upHandler = (event: KeyboardEvent) => {
if (isInputDOMNode(event)) {
return false;
}
const keyOrCode = useKeyOrCode(event.code, keysToWatch);
if (isMatchingKey(keyCodes, pressedKeys.current, true)) {
setKeyPressed(false);
pressedKeys.current.clear();
} else {
pressedKeys.current.delete(event[keyOrCode]);
}
};
const resetHandler = () => {
pressedKeys.current.clear();
setKeyPressed(false);
};
options?.target?.addEventListener('keydown', downHandler as EventListenerOrEventListenerObject);
options?.target?.addEventListener('keyup', upHandler as EventListenerOrEventListenerObject);
window.addEventListener('blur', resetHandler);
return () => {
options?.target?.removeEventListener('keydown', downHandler as EventListenerOrEventListenerObject);
options?.target?.removeEventListener('keyup', upHandler as EventListenerOrEventListenerObject);
window.removeEventListener('blur', resetHandler);
};
}
}, [keyCode, setKeyPressed]);
return keyPressed;
};
// utils
function isMatchingKey(keyCodes: Array<Keys>, pressedKeys: PressedKeys, isUp: boolean): boolean {
return (
keyCodes
// we only want to compare same sizes of keyCode definitions
// and pressed keys. When the user specified 'Meta' as a key somewhere
// this would also be truthy without this filter when user presses 'Meta' + 'r'
.filter((keys) => isUp || keys.length === pressedKeys.size)
// since we want to support multiple possibilities only one of the
// combinations need to be part of the pressed keys
.some((keys) => keys.every((k) => pressedKeys.has(k)))
);
}
function useKeyOrCode(eventCode: string, keysToWatch: KeyCode): KeyOrCode {
return keysToWatch.includes(eventCode) ? 'code' : 'key';
}
function isInputDOMNode(event: KeyboardEvent): boolean {
// using composed path for handling shadow dom
const target = (event.composedPath?.()[0] || event.target) as HTMLElement;
return (
['INPUT', 'SELECT', 'TEXTAREA'].includes(target?.nodeName) ||
target?.hasAttribute('contenteditable') ||
!!target?.closest('.nokey')
);
}
+12
View File
@@ -0,0 +1,12 @@
import { useStore } from '../hooks/useStore';
import { Node, ReactFlowState } from '../types';
const nodesSelector = (state: ReactFlowState) => Array.from(state.nodeInternals.values());
function useNodes<NodeData>(): Node<NodeData>[] {
const nodes = useStore(nodesSelector);
return nodes;
}
export default useNodes;
@@ -0,0 +1,35 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { useState, useCallback, SetStateAction, Dispatch } from 'react';
import { applyNodeChanges, applyEdgeChanges } from '../utils/changes';
import { Node, NodeChange, Edge, EdgeChange } from '../types';
type ApplyChanges<ItemType, ChangesType> = (changes: ChangesType[], items: ItemType[]) => ItemType[];
type OnChange<ChangesType> = (changes: ChangesType[]) => void;
// returns a hook that can be used liked this:
// const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes);
function createUseItemsState(
applyChanges: ApplyChanges<Node, NodeChange>
): <NodeData = any>(
initialItems: Node<NodeData>[]
) => [Node<NodeData>[], Dispatch<SetStateAction<Node<NodeData>[]>>, OnChange<NodeChange>];
function createUseItemsState(
applyChanges: ApplyChanges<Edge, EdgeChange>
): <EdgeData = any>(
initialItems: Edge<EdgeData>[]
) => [Edge<EdgeData>[], Dispatch<SetStateAction<Edge<EdgeData>[]>>, OnChange<EdgeChange>];
function createUseItemsState(
applyChanges: ApplyChanges<any, any>
): (initialItems: any[]) => [any[], Dispatch<SetStateAction<any[]>>, OnChange<any>] {
return (initialItems: any[]) => {
const [items, setItems] = useState(initialItems);
const onItemsChange = useCallback((changes: any[]) => setItems((items: any) => applyChanges(changes, items)), []);
return [items, setItems, onItemsChange];
};
}
export const useNodesState = createUseItemsState(applyNodeChanges);
export const useEdgesState = createUseItemsState(applyEdgeChanges);
@@ -0,0 +1,19 @@
import { ReactFlowState } from '../types';
import { internalsSymbol } from '../utils';
import { useStore } from './useStore';
const selector = (s: ReactFlowState) => {
if (s.nodeInternals.size === 0) {
return false;
}
return Array.from(s.nodeInternals.values()).every((n) => n[internalsSymbol]?.handleBounds !== undefined);
};
function useNodesInitialized(): boolean {
const initialized = useStore(selector);
return initialized;
}
export default useNodesInitialized;
@@ -0,0 +1,18 @@
import { useEffect, useRef } from 'react';
import useReactFlow from './useReactFlow';
import { OnInit } from '../types';
function useOnInitHandler(onInit: OnInit | undefined) {
const rfInstance = useReactFlow();
const isInitialized = useRef<boolean>(false);
useEffect(() => {
if (!isInitialized.current && rfInstance.viewportInitialized && onInit) {
setTimeout(() => onInit(rfInstance), 1);
isInitialized.current = true;
}
}, [onInit, rfInstance.viewportInitialized]);
}
export default useOnInitHandler;
@@ -0,0 +1,18 @@
import { useEffect } from 'react';
import { useStoreApi } from './useStore';
import type { OnSelectionChangeFunc } from '../types';
export type UseOnSelectionChangeOptions = {
onChange?: OnSelectionChangeFunc;
};
function useOnSelectionChange({ onChange }: UseOnSelectionChangeOptions) {
const store = useStoreApi();
useEffect(() => {
store.setState({ onSelectionChange: onChange });
}, [onChange]);
}
export default useOnSelectionChange;
@@ -0,0 +1,28 @@
import { useEffect } from 'react';
import { useStoreApi } from './useStore';
import type { OnViewportChange } from '../types';
export type UseOnViewportChangeOptions = {
onStart?: OnViewportChange;
onChange?: OnViewportChange;
onEnd?: OnViewportChange;
};
function useOnViewportChange({ onStart, onChange, onEnd }: UseOnViewportChangeOptions) {
const store = useStoreApi();
useEffect(() => {
store.setState({ onViewportChangeStart: onStart });
}, [onStart]);
useEffect(() => {
store.setState({ onViewportChange: onChange });
}, [onChange]);
useEffect(() => {
store.setState({ onViewportChangeEnd: onEnd });
}, [onEnd]);
}
export default useOnViewportChange;
+128
View File
@@ -0,0 +1,128 @@
import { useCallback, useMemo } from 'react';
import useViewportHelper from './useViewportHelper';
import { useStoreApi } from '../hooks/useStore';
import {
ReactFlowInstance,
Instance,
NodeAddChange,
EdgeAddChange,
NodeResetChange,
EdgeResetChange,
NodeRemoveChange,
EdgeRemoveChange,
} from '../types';
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
export default function useReactFlow<NodeData = any, EdgeData = any>(): ReactFlowInstance<NodeData, EdgeData> {
const viewportHelper = useViewportHelper();
const store = useStoreApi();
const getNodes = useCallback<Instance.GetNodes<NodeData>>(() => {
const { nodeInternals } = store.getState();
const nodes = Array.from(nodeInternals.values());
return nodes.map((n) => ({ ...n }));
}, []);
const getNode = useCallback<Instance.GetNode<NodeData>>((id) => {
const { nodeInternals } = store.getState();
return nodeInternals.get(id);
}, []);
const getEdges = useCallback<Instance.GetEdges<EdgeData>>(() => {
const { edges = [] } = store.getState();
return edges.map((e) => ({ ...e }));
}, []);
const getEdge = useCallback<Instance.GetEdge<EdgeData>>((id) => {
const { edges = [] } = store.getState();
return edges.find((e) => e.id === id);
}, []);
const setNodes = useCallback<Instance.SetNodes<NodeData>>((payload) => {
const { nodeInternals, setNodes, hasDefaultNodes, onNodesChange } = store.getState();
const nodes = Array.from(nodeInternals.values());
const nextNodes = typeof payload === 'function' ? payload(nodes) : payload;
if (hasDefaultNodes) {
setNodes(nextNodes);
} else if (onNodesChange) {
const changes =
nextNodes.length === 0
? nodes.map((node) => ({ type: 'remove', id: node.id } as NodeRemoveChange))
: nextNodes.map((node) => ({ item: node, type: 'reset' } as NodeResetChange<NodeData>));
onNodesChange(changes);
}
}, []);
const setEdges = useCallback<Instance.SetEdges<EdgeData>>((payload) => {
const { edges = [], setEdges, hasDefaultEdges, onEdgesChange } = store.getState();
const nextEdges = typeof payload === 'function' ? payload(edges) : payload;
if (hasDefaultEdges) {
setEdges(nextEdges);
} else if (onEdgesChange) {
const changes =
nextEdges.length === 0
? edges.map((edge) => ({ type: 'remove', id: edge.id } as EdgeRemoveChange))
: nextEdges.map((edge) => ({ item: edge, type: 'reset' } as EdgeResetChange<EdgeData>));
onEdgesChange(changes);
}
}, []);
const addNodes = useCallback<Instance.AddNodes<NodeData>>((payload) => {
const nodes = Array.isArray(payload) ? payload : [payload];
const { nodeInternals, setNodes, hasDefaultNodes, onNodesChange } = store.getState();
if (hasDefaultNodes) {
const currentNodes = Array.from(nodeInternals.values());
const nextNodes = [...currentNodes, ...nodes];
setNodes(nextNodes);
} else if (onNodesChange) {
const changes = nodes.map((node) => ({ item: node, type: 'add' } as NodeAddChange<NodeData>));
onNodesChange(changes);
}
}, []);
const addEdges = useCallback<Instance.AddEdges<EdgeData>>((payload) => {
const nextEdges = Array.isArray(payload) ? payload : [payload];
const { edges = [], setEdges, hasDefaultEdges, onEdgesChange } = store.getState();
if (hasDefaultEdges) {
setEdges([...edges, ...nextEdges]);
} else if (onEdgesChange) {
const changes = nextEdges.map((edge) => ({ item: edge, type: 'add' } as EdgeAddChange<EdgeData>));
onEdgesChange(changes);
}
}, []);
const toObject = useCallback<Instance.ToObject<NodeData, EdgeData>>(() => {
const { nodeInternals, edges = [], transform } = store.getState();
const nodes = Array.from(nodeInternals.values());
const [x, y, zoom] = transform;
return {
nodes: nodes.map((n) => ({ ...n })),
edges: edges.map((e) => ({ ...e })),
viewport: {
x,
y,
zoom,
},
};
}, []);
return useMemo(() => {
return {
...viewportHelper,
getNodes,
getNode,
getEdges,
getEdge,
setNodes,
setEdges,
addNodes,
addEdges,
toObject,
};
}, [viewportHelper, getNodes, getNode, getEdges, getEdge, setNodes, setEdges, addNodes, addEdges, toObject]);
}
@@ -0,0 +1,46 @@
import { useEffect, MutableRefObject } from 'react';
import { useStoreApi } from '../hooks/useStore';
import { devWarn, getDimensions } from '../utils';
function useResizeHandler(rendererNode: MutableRefObject<HTMLDivElement | null>): void {
const store = useStoreApi();
useEffect(() => {
let resizeObserver: ResizeObserver;
const updateDimensions = () => {
if (!rendererNode.current) {
return;
}
const size = getDimensions(rendererNode.current);
if (size.height === 0 || size.width === 0) {
devWarn(
'The React Flow parent container needs a width and a height to render the graph. Help: https://reactflow.dev/error#400'
);
}
store.setState({ width: size.width || 500, height: size.height || 500 });
};
updateDimensions();
window.addEventListener('resize', updateDimensions);
if (rendererNode.current) {
resizeObserver = new ResizeObserver(() => updateDimensions());
resizeObserver.observe(rendererNode.current);
}
return () => {
window.removeEventListener('resize', updateDimensions);
if (resizeObserver && rendererNode.current) {
resizeObserver.unobserve(rendererNode.current!);
}
};
}, []);
}
export default useResizeHandler;
+43
View File
@@ -0,0 +1,43 @@
import { useContext, useMemo } from 'react';
import { StoreApi, useStore as useZustandStore } from 'zustand';
import StoreContext from '../contexts/RFStoreContext';
import { ReactFlowState } from '../types';
const errorMessage =
'[React Flow]: Seems like you have not used zustand provider as an ancestor. Help: https://reactflow.dev/error#100';
type ExtractState = StoreApi<ReactFlowState> extends { getState: () => infer T } ? T : never;
function useStore<StateSlice = ExtractState>(
selector: (state: ReactFlowState) => StateSlice,
equalityFn?: (a: StateSlice, b: StateSlice) => boolean
) {
const store = useContext(StoreContext);
if (store === null) {
throw new Error(errorMessage);
}
return useZustandStore(store, selector, equalityFn);
}
const useStoreApi = () => {
const store = useContext(StoreContext);
if (store === null) {
throw new Error(errorMessage);
}
return useMemo(
() => ({
getState: store.getState,
setState: store.setState,
subscribe: store.subscribe,
destroy: store.destroy,
}),
[store]
);
};
export { useStore, useStoreApi };
@@ -0,0 +1,19 @@
import { useCallback } from 'react';
import { useStoreApi } from '../hooks/useStore';
import { UpdateNodeInternals } from '../types';
function useUpdateNodeInternals(): UpdateNodeInternals {
const store = useStoreApi();
return useCallback<UpdateNodeInternals>((id: string) => {
const { domNode, updateNodeDimensions } = store.getState();
const nodeElement = domNode?.querySelector(`.react-flow__node[data-id="${id}"]`) as HTMLDivElement;
if (nodeElement) {
requestAnimationFrame(() => updateNodeDimensions([{ id, nodeElement, forceUpdate: true }]));
}
}, []);
}
export default useUpdateNodeInternals;
@@ -0,0 +1,37 @@
import { useCallback } from 'react';
import { useStoreApi } from '../hooks/useStore';
import { calcNextPosition } from './useDrag/utils';
import { XYPosition } from '../types';
function useUpdateNodePositions() {
const store = useStoreApi();
const updatePositions = useCallback((positionDiff: XYPosition) => {
const { nodeInternals, nodeExtent, updateNodePositions } = store.getState();
const selectedNodes = Array.from(nodeInternals.values()).filter((n) => n.selected);
const nodeUpdates = selectedNodes.map((n) => {
if (n.positionAbsolute) {
const updatedPos = calcNextPosition(
n,
{ x: n.positionAbsolute.x + positionDiff.x, y: n.positionAbsolute.y + positionDiff.y },
nodeInternals,
nodeExtent
);
n.position = updatedPos.position;
n.positionAbsolute = updatedPos.positionAbsolute;
}
return n;
});
updateNodePositions(nodeUpdates, true, true);
}, []);
return updatePositions;
}
export default useUpdateNodePositions;
+18
View File
@@ -0,0 +1,18 @@
import shallow from 'zustand/shallow';
import { useStore } from '../hooks/useStore';
import { Viewport, ReactFlowState } from '../types';
const viewportSelector = (state: ReactFlowState) => ({
x: state.transform[0],
y: state.transform[1],
zoom: state.transform[2],
});
function useViewport(): Viewport {
const viewport = useStore(viewportSelector, shallow);
return viewport;
}
export default useViewport;
@@ -0,0 +1,85 @@
import { useMemo } from 'react';
import { zoomIdentity } from 'd3-zoom';
import shallow from 'zustand/shallow';
import { useStoreApi, useStore } from '../hooks/useStore';
import { pointToRendererPoint, getTransformForBounds, getD3Transition } from '../utils/graph';
import { ViewportHelperFunctions, ReactFlowState, XYPosition } from '../types';
import { fitView as fitViewStore } from '../store/utils';
// eslint-disable-next-line @typescript-eslint/no-empty-function
const noop = () => {};
const initialViewportHelper: ViewportHelperFunctions = {
zoomIn: noop,
zoomOut: noop,
zoomTo: noop,
getZoom: () => 1,
setViewport: noop,
getViewport: () => ({ x: 0, y: 0, zoom: 1 }),
fitView: noop,
setCenter: noop,
fitBounds: noop,
project: (position: XYPosition) => position,
viewportInitialized: false,
};
const selector = (s: ReactFlowState) => ({
d3Zoom: s.d3Zoom,
d3Selection: s.d3Selection,
});
const useViewportHelper = (): ViewportHelperFunctions => {
const store = useStoreApi();
const { d3Zoom, d3Selection } = useStore(selector, shallow);
const viewportHelperFunctions = useMemo<ViewportHelperFunctions>(() => {
if (d3Selection && d3Zoom) {
return {
zoomIn: (options) => d3Zoom.scaleBy(getD3Transition(d3Selection, options?.duration), 1.2),
zoomOut: (options) => d3Zoom.scaleBy(getD3Transition(d3Selection, options?.duration), 1 / 1.2),
zoomTo: (zoomLevel, options) => d3Zoom.scaleTo(getD3Transition(d3Selection, options?.duration), zoomLevel),
getZoom: () => store.getState().transform[2],
setViewport: (transform, options) => {
const [x, y, zoom] = store.getState().transform;
const nextTransform = zoomIdentity
.translate(transform.x ?? x, transform.y ?? y)
.scale(transform.zoom ?? zoom);
d3Zoom.transform(getD3Transition(d3Selection, options?.duration), nextTransform);
},
getViewport: () => {
const [x, y, zoom] = store.getState().transform;
return { x, y, zoom };
},
fitView: (options) => fitViewStore(store.getState, options),
setCenter: (x, y, options) => {
const { width, height, maxZoom } = store.getState();
const nextZoom = typeof options?.zoom !== 'undefined' ? options.zoom : maxZoom;
const centerX = width / 2 - x * nextZoom;
const centerY = height / 2 - y * nextZoom;
const transform = zoomIdentity.translate(centerX, centerY).scale(nextZoom);
d3Zoom.transform(getD3Transition(d3Selection, options?.duration), transform);
},
fitBounds: (bounds, options) => {
const { width, height, minZoom, maxZoom } = store.getState();
const [x, y, zoom] = getTransformForBounds(bounds, width, height, minZoom, maxZoom, options?.padding ?? 0.1);
const transform = zoomIdentity.translate(x, y).scale(zoom);
d3Zoom.transform(getD3Transition(d3Selection, options?.duration), transform);
},
project: (position: XYPosition) => {
const { transform, snapToGrid, snapGrid } = store.getState();
return pointToRendererPoint(position, transform, snapToGrid, snapGrid);
},
viewportInitialized: true,
};
}
return initialViewportHelper;
}, [d3Zoom, d3Selection]);
return viewportHelperFunctions;
};
export default useViewportHelper;
@@ -0,0 +1,92 @@
import { useCallback } from 'react';
import { useStore } from '../hooks/useStore';
import { isEdgeVisible } from '../container/EdgeRenderer/utils';
import { ReactFlowState, NodeInternals, Edge } from '../types';
import { internalsSymbol, isNumeric } from '../utils';
const defaultEdgeTree = [{ level: 0, isMaxLevel: true, edges: [] }];
function groupEdgesByZLevel(edges: Edge[], nodeInternals: NodeInternals, elevateEdgesOnSelect = false) {
let maxLevel = -1;
const levelLookup = edges.reduce<Record<string, Edge[]>>((tree, edge) => {
const hasZIndex = isNumeric(edge.zIndex);
let z = hasZIndex ? edge.zIndex! : 0;
if (elevateEdgesOnSelect) {
z = hasZIndex
? edge.zIndex!
: Math.max(
nodeInternals.get(edge.source)?.[internalsSymbol]?.z || 0,
nodeInternals.get(edge.target)?.[internalsSymbol]?.z || 0
);
}
if (tree[z]) {
tree[z].push(edge);
} else {
tree[z] = [edge];
}
maxLevel = z > maxLevel ? z : maxLevel;
return tree;
}, {});
const edgeTree = Object.entries(levelLookup).map(([key, edges]) => {
const level = +key;
return {
edges,
level,
isMaxLevel: level === maxLevel,
};
});
if (edgeTree.length === 0) {
return defaultEdgeTree;
}
return edgeTree;
}
function useVisibleEdges(onlyRenderVisible: boolean, nodeInternals: NodeInternals, elevateEdgesOnSelect: boolean) {
const edges = useStore(
useCallback(
(s: ReactFlowState) => {
if (!onlyRenderVisible) {
return s.edges;
}
return s.edges.filter((e) => {
const sourceNode = nodeInternals.get(e.source);
const targetNode = nodeInternals.get(e.target);
return (
sourceNode?.width &&
sourceNode?.height &&
targetNode?.width &&
targetNode?.height &&
isEdgeVisible({
sourcePos: sourceNode.positionAbsolute || { x: 0, y: 0 },
targetPos: targetNode.positionAbsolute || { x: 0, y: 0 },
sourceWidth: sourceNode.width,
sourceHeight: sourceNode.height,
targetWidth: targetNode.width,
targetHeight: targetNode.height,
width: s.width,
height: s.height,
transform: s.transform,
})
);
});
},
[onlyRenderVisible, nodeInternals]
)
);
return groupEdgesByZLevel(edges, nodeInternals, elevateEdgesOnSelect);
}
export default useVisibleEdges;
@@ -0,0 +1,22 @@
import { useCallback } from 'react';
import { useStore } from '../hooks/useStore';
import { getNodesInside } from '../utils/graph';
import { ReactFlowState } from '../types';
function useVisibleNodes(onlyRenderVisible: boolean) {
const nodes = useStore(
useCallback(
(s: ReactFlowState) =>
onlyRenderVisible
? getNodesInside(s.nodeInternals, { x: 0, y: 0, width: s.width, height: s.height }, s.transform, true)
: Array.from(s.nodeInternals.values()),
[onlyRenderVisible]
)
);
return nodes;
}
export default useVisibleNodes;
+48
View File
@@ -0,0 +1,48 @@
export { default as ReactFlow } from './container/ReactFlow';
export { default as Handle } from './components/Handle';
export { default as EdgeText } from './components/Edges/EdgeText';
export { default as StraightEdge } from './components/Edges/StraightEdge';
export { default as StepEdge } from './components/Edges/StepEdge';
export {
default as BezierEdge,
getBezierPath,
getBezierCenter as getBezierEdgeCenter,
} from './components/Edges/BezierEdge';
export {
default as SimpleBezierEdge,
getSimpleBezierPath,
getSimpleBezierCenter as getSimpleBezierEdgeCenter,
} from './components/Edges/SimpleBezierEdge';
export { default as SmoothStepEdge, getSmoothStepPath } from './components/Edges/SmoothStepEdge';
export { default as BaseEdge } from './components/Edges/BaseEdge';
export { internalsSymbol, rectToBox, boxToRect, getBoundsOfRects } from './utils';
export {
isNode,
isEdge,
addEdge,
getOutgoers,
getIncomers,
getConnectedEdges,
updateEdge,
getTransformForBounds,
getRectOfNodes,
} from './utils/graph';
export { applyNodeChanges, applyEdgeChanges } from './utils/changes';
export { getMarkerEnd, getCenter as getEdgeCenter } from './components/Edges/utils';
export { default as ReactFlowProvider } from './components/ReactFlowProvider';
export { default as Panel } from './components/Panel';
export { default as useReactFlow } from './hooks/useReactFlow';
export { default as useUpdateNodeInternals } from './hooks/useUpdateNodeInternals';
export { default as useNodes } from './hooks/useNodes';
export { default as useEdges } from './hooks/useEdges';
export { default as useViewport } from './hooks/useViewport';
export { default as useKeyPress } from './hooks/useKeyPress';
export * from './hooks/useNodesEdgesState';
export { useStore, useStoreApi } from './hooks/useStore';
export { default as useOnViewportChange } from './hooks/useOnViewportChange';
export { default as useOnSelectionChange } from './hooks/useOnSelectionChange';
export { default as useNodesInitialized } from './hooks/useNodesInitialized';
export * from './types';
+249
View File
@@ -0,0 +1,249 @@
import { createStore } from 'zustand';
import { clampPosition, getDimensions, internalsSymbol } from '../utils';
import { applyNodeChanges } from '../utils/changes';
import {
ReactFlowState,
Node,
Edge,
NodeDimensionUpdate,
CoordinateExtent,
NodeDimensionChange,
EdgeSelectionChange,
NodeSelectionChange,
NodePositionChange,
NodeDragItem,
UnselectNodesAndEdgesParams,
} from '../types';
import { getHandleBounds } from '../components/Nodes/utils';
import { createSelectionChange, getSelectionChanges } from '../utils/changes';
import { createNodeInternals, fitView, updateNodesAndEdgesSelections } from './utils';
import initialState from './initialState';
const createRFStore = () =>
createStore<ReactFlowState>((set, get) => ({
...initialState,
setNodes: (nodes: Node[]) => {
set({ nodeInternals: createNodeInternals(nodes, get().nodeInternals) });
},
setEdges: (edges: Edge[]) => {
const { defaultEdgeOptions = {} } = get();
set({ edges: edges.map((e) => ({ ...defaultEdgeOptions, ...e })) });
},
setDefaultNodesAndEdges: (nodes?: Node[], edges?: Edge[]) => {
const hasDefaultNodes = typeof nodes !== 'undefined';
const hasDefaultEdges = typeof edges !== 'undefined';
const nodeInternals = hasDefaultNodes ? createNodeInternals(nodes, new Map()) : new Map();
const nextEdges = hasDefaultEdges ? edges : [];
set({ nodeInternals, edges: nextEdges, hasDefaultNodes, hasDefaultEdges });
},
updateNodeDimensions: (updates: NodeDimensionUpdate[]) => {
const {
onNodesChange,
nodeInternals,
fitViewOnInit,
fitViewOnInitDone,
fitViewOnInitOptions,
domNode,
nodeOrigin,
} = get();
const viewportNode = domNode?.querySelector('.react-flow__viewport');
if (!viewportNode) {
return;
}
const style = window.getComputedStyle(viewportNode);
const { m22: zoom } = new window.DOMMatrixReadOnly(style.transform);
const changes: NodeDimensionChange[] = updates.reduce<NodeDimensionChange[]>((res, update) => {
const node = nodeInternals.get(update.id);
if (node) {
const dimensions = getDimensions(update.nodeElement);
const doUpdate = !!(
dimensions.width &&
dimensions.height &&
(node.width !== dimensions.width || node.height !== dimensions.height || update.forceUpdate)
);
if (doUpdate) {
nodeInternals.set(node.id, {
...node,
[internalsSymbol]: {
...node[internalsSymbol],
handleBounds: {
source: getHandleBounds('.source', update.nodeElement, zoom, nodeOrigin),
target: getHandleBounds('.target', update.nodeElement, zoom, nodeOrigin),
},
},
...dimensions,
});
res.push({
id: node.id,
type: 'dimensions',
dimensions,
});
}
}
return res;
}, []);
const nextFitViewOnInitDone =
fitViewOnInitDone ||
(fitViewOnInit && !fitViewOnInitDone && fitView(get, { initial: true, ...fitViewOnInitOptions }));
set({ nodeInternals: new Map(nodeInternals), fitViewOnInitDone: nextFitViewOnInitDone });
if (changes?.length > 0) {
onNodesChange?.(changes);
}
},
updateNodePositions: (nodeDragItems: NodeDragItem[] | Node[], positionChanged = true, dragging = false) => {
const { onNodesChange, nodeInternals, hasDefaultNodes } = get();
if (hasDefaultNodes || onNodesChange) {
const changes = nodeDragItems.map((node) => {
const change: NodePositionChange = {
id: node.id,
type: 'position',
dragging,
};
if (positionChanged) {
change.positionAbsolute = node.positionAbsolute;
change.position = node.position;
}
return change;
});
if (changes?.length) {
if (hasDefaultNodes) {
const nodes = applyNodeChanges(changes, Array.from(nodeInternals.values()));
const nextNodeInternals = createNodeInternals(nodes, nodeInternals);
set({ nodeInternals: nextNodeInternals });
}
onNodesChange?.(changes);
}
}
},
addSelectedNodes: (selectedNodeIds: string[]) => {
const { multiSelectionActive, nodeInternals, edges } = get();
let changedNodes: NodeSelectionChange[];
let changedEdges: EdgeSelectionChange[] | null = null;
if (multiSelectionActive) {
changedNodes = selectedNodeIds.map((nodeId) => createSelectionChange(nodeId, true)) as NodeSelectionChange[];
} else {
changedNodes = getSelectionChanges(Array.from(nodeInternals.values()), selectedNodeIds);
changedEdges = getSelectionChanges(edges, []);
}
updateNodesAndEdgesSelections({
changedNodes,
changedEdges,
get,
set,
});
},
addSelectedEdges: (selectedEdgeIds: string[]) => {
const { multiSelectionActive, edges, nodeInternals } = get();
let changedEdges: EdgeSelectionChange[];
let changedNodes: NodeSelectionChange[] | null = null;
if (multiSelectionActive) {
changedEdges = selectedEdgeIds.map((edgeId) => createSelectionChange(edgeId, true)) as EdgeSelectionChange[];
} else {
changedEdges = getSelectionChanges(edges, selectedEdgeIds);
changedNodes = getSelectionChanges(Array.from(nodeInternals.values()), []);
}
updateNodesAndEdgesSelections({
changedNodes,
changedEdges,
get,
set,
});
},
unselectNodesAndEdges: ({ nodes, edges }: UnselectNodesAndEdgesParams = {}) => {
const { nodeInternals, edges: storeEdges } = get();
const nodesToUnselect = nodes ? nodes : Array.from(nodeInternals.values());
const edgesToUnselect = edges ? edges : storeEdges;
const changedNodes = nodesToUnselect.map((n) => {
n.selected = false;
return createSelectionChange(n.id, false);
}) as NodeSelectionChange[];
const changedEdges = edgesToUnselect.map((edge) =>
createSelectionChange(edge.id, false)
) as EdgeSelectionChange[];
updateNodesAndEdgesSelections({
changedNodes,
changedEdges,
get,
set,
});
},
setMinZoom: (minZoom: number) => {
const { d3Zoom, maxZoom } = get();
d3Zoom?.scaleExtent([minZoom, maxZoom]);
set({ minZoom });
},
setMaxZoom: (maxZoom: number) => {
const { d3Zoom, minZoom } = get();
d3Zoom?.scaleExtent([minZoom, maxZoom]);
set({ maxZoom });
},
setTranslateExtent: (translateExtent: CoordinateExtent) => {
const { d3Zoom } = get();
d3Zoom?.translateExtent(translateExtent);
set({ translateExtent });
},
resetSelectedElements: () => {
const { nodeInternals, edges } = get();
const nodes = Array.from(nodeInternals.values());
const nodesToUnselect = nodes
.filter((e) => e.selected)
.map((n) => createSelectionChange(n.id, false)) as NodeSelectionChange[];
const edgesToUnselect = edges
.filter((e) => e.selected)
.map((e) => createSelectionChange(e.id, false)) as EdgeSelectionChange[];
updateNodesAndEdgesSelections({
changedNodes: nodesToUnselect,
changedEdges: edgesToUnselect,
get,
set,
});
},
setNodeExtent: (nodeExtent: CoordinateExtent) => {
const { nodeInternals } = get();
nodeInternals.forEach((node) => {
node.positionAbsolute = clampPosition(node.position, nodeExtent);
});
set({
nodeExtent,
nodeInternals: new Map(nodeInternals),
});
},
cancelConnection: () =>
set({
connectionNodeId: initialState.connectionNodeId,
connectionHandleId: initialState.connectionHandleId,
}),
reset: () => set({ ...initialState }),
}));
export { createRFStore };
+56
View File
@@ -0,0 +1,56 @@
import { CoordinateExtent, ReactFlowStore, ConnectionMode } from '../types';
export const infiniteExtent: CoordinateExtent = [
[Number.NEGATIVE_INFINITY, Number.NEGATIVE_INFINITY],
[Number.POSITIVE_INFINITY, Number.POSITIVE_INFINITY],
];
const initialState: ReactFlowStore = {
rfId: '1',
width: 0,
height: 0,
transform: [0, 0, 1],
nodeInternals: new Map(),
edges: [],
onNodesChange: null,
onEdgesChange: null,
hasDefaultNodes: false,
hasDefaultEdges: false,
d3Zoom: null,
d3Selection: null,
d3ZoomHandler: undefined,
minZoom: 0.5,
maxZoom: 2,
translateExtent: infiniteExtent,
nodeExtent: infiniteExtent,
nodesSelectionActive: false,
userSelectionActive: false,
connectionNodeId: null,
connectionHandleId: null,
connectionHandleType: 'source',
connectionPosition: { x: 0, y: 0 },
connectionMode: ConnectionMode.Strict,
domNode: null,
paneDragging: false,
noPanClassName: 'nopan',
nodeOrigin: [0, 0],
snapGrid: [15, 15],
snapToGrid: false,
nodesDraggable: true,
nodesConnectable: true,
elementsSelectable: true,
fitViewOnInit: false,
fitViewOnInitDone: false,
fitViewOnInitOptions: undefined,
multiSelectionActive: false,
connectionStartHandle: null,
connectOnClick: true,
ariaLiveMessage: '',
};
export default initialState;
+191
View File
@@ -0,0 +1,191 @@
import { zoomIdentity } from 'd3-zoom';
import { StoreApi } from 'zustand';
import { internalsSymbol, isNumeric } from '../utils';
import { getD3Transition, getRectOfNodes, getTransformForBounds } from '../utils/graph';
import {
Edge,
EdgeSelectionChange,
Node,
NodeInternals,
NodeSelectionChange,
ReactFlowState,
XYZPosition,
FitViewOptions,
} from '../types';
type ParentNodes = Record<string, boolean>;
function calculateXYZPosition(
node: Node,
nodeInternals: NodeInternals,
parentNodes: ParentNodes,
result: XYZPosition
): XYZPosition {
if (!node.parentNode) {
return result;
}
const parentNode = nodeInternals.get(node.parentNode)!;
return calculateXYZPosition(parentNode, nodeInternals, parentNodes, {
x: (result.x ?? 0) + (parentNode.position?.x ?? 0),
y: (result.y ?? 0) + (parentNode.position?.y ?? 0),
z: (parentNode[internalsSymbol]?.z ?? 0) > (result.z ?? 0) ? parentNode[internalsSymbol]?.z ?? 0 : result.z ?? 0,
});
}
export function createNodeInternals(nodes: Node[], nodeInternals: NodeInternals): NodeInternals {
const nextNodeInternals = new Map<string, Node>();
const parentNodes: ParentNodes = {};
nodes.forEach((node) => {
const z = isNumeric(node.zIndex) ? node.zIndex : node.selected ? 1000 : 0;
const currInternals = nodeInternals.get(node.id);
const internals: Node = {
width: currInternals?.width,
height: currInternals?.height,
...node,
positionAbsolute: {
x: node.position.x,
y: node.position.y,
},
};
if (node.parentNode) {
internals.parentNode = node.parentNode;
parentNodes[node.parentNode] = true;
}
Object.defineProperty(internals, internalsSymbol, {
enumerable: false,
value: {
handleBounds: currInternals?.[internalsSymbol]?.handleBounds,
z,
},
});
nextNodeInternals.set(node.id, internals);
});
nextNodeInternals.forEach((node) => {
if (node.parentNode && !nextNodeInternals.has(node.parentNode)) {
throw new Error(`Parent node ${node.parentNode} not found`);
}
if (node.parentNode || parentNodes[node.id]) {
const { x, y, z } = calculateXYZPosition(node, nextNodeInternals, parentNodes, {
...node.position,
z: node[internalsSymbol]?.z ?? 0,
});
node.positionAbsolute = {
x,
y,
};
node[internalsSymbol]!.z = z;
if (parentNodes[node.id]) {
node[internalsSymbol]!.isParent = true;
}
}
});
return nextNodeInternals;
}
type InternalFitViewOptions = {
initial?: boolean;
} & FitViewOptions;
export function fitView(get: StoreApi<ReactFlowState>['getState'], options: InternalFitViewOptions = {}) {
const { nodeInternals, width, height, minZoom, maxZoom, d3Zoom, d3Selection, fitViewOnInitDone, fitViewOnInit } =
get();
if ((options.initial && !fitViewOnInitDone && fitViewOnInit) || !options.initial) {
if (d3Zoom && d3Selection) {
const nodes = Array.from(nodeInternals.values()).filter((n) =>
options.includeHiddenNodes ? n.width && n.height : !n.hidden
);
const nodesInitialized = nodes.every((n) => n.width && n.height);
if (nodes.length > 0 && nodesInitialized) {
const bounds = getRectOfNodes(nodes);
const [x, y, zoom] = getTransformForBounds(
bounds,
width,
height,
options.minZoom ?? minZoom,
options.maxZoom ?? maxZoom,
options.padding ?? 0.1
);
const nextTransform = zoomIdentity.translate(x, y).scale(zoom);
if (typeof options.duration === 'number' && options.duration > 0) {
d3Zoom.transform(getD3Transition(d3Selection, options.duration), nextTransform);
} else {
d3Zoom.transform(d3Selection, nextTransform);
}
return true;
}
}
}
return false;
}
export function handleControlledNodeSelectionChange(nodeChanges: NodeSelectionChange[], nodeInternals: NodeInternals) {
nodeChanges.forEach((change) => {
const node = nodeInternals.get(change.id);
if (node) {
nodeInternals.set(node.id, {
...node,
[internalsSymbol]: node[internalsSymbol],
selected: change.selected,
});
}
});
return new Map(nodeInternals);
}
export function handleControlledEdgeSelectionChange(edgeChanges: EdgeSelectionChange[], edges: Edge[]) {
return edges.map((e) => {
const change = edgeChanges.find((change) => change.id === e.id);
if (change) {
e.selected = change.selected;
}
return e;
});
}
type UpdateNodesAndEdgesParams = {
changedNodes: NodeSelectionChange[] | null;
changedEdges: EdgeSelectionChange[] | null;
get: StoreApi<ReactFlowState>['getState'];
set: StoreApi<ReactFlowState>['setState'];
};
export function updateNodesAndEdgesSelections({ changedNodes, changedEdges, get, set }: UpdateNodesAndEdgesParams) {
const { nodeInternals, edges, onNodesChange, onEdgesChange, hasDefaultNodes, hasDefaultEdges } = get();
if (changedNodes?.length) {
if (hasDefaultNodes) {
set({ nodeInternals: handleControlledNodeSelectionChange(changedNodes, nodeInternals) });
}
onNodesChange?.(changedNodes);
}
if (changedEdges?.length) {
if (hasDefaultEdges) {
set({ edges: handleControlledEdgeSelectionChange(changedEdges, edges) });
}
onEdgesChange?.(changedEdges);
}
}
+28
View File
@@ -0,0 +1,28 @@
/* this will be exported as base.css and can be used for a basic styling */
@import './init.css';
.react-flow__handle {
background-color: #333;
}
.react-flow__node-default,
.react-flow__node-input,
.react-flow__node-output,
.react-flow__node-group {
border-width: 1px;
border-style: solid;
border-color: #bbb;
&.selected,
&:focus,
&:focus-visible {
outline: none;
border: 1px solid #555;
}
}
.react-flow__nodesselection-rect,
.react-flow__selection {
background: rgba(150, 150, 180, 0.1);
border: 1px dotted rgba(155, 155, 155, 0.8);
}
+9
View File
@@ -0,0 +1,9 @@
import { CSSProperties } from 'react';
export const containerStyle: CSSProperties = {
position: 'absolute',
width: '100%',
height: '100%',
top: 0,
left: 0,
};
+217
View File
@@ -0,0 +1,217 @@
/* these are the necessary styles for React Flow, they get used by base.css and style.css */
.react-flow__container {
position: absolute;
width: 100%;
height: 100%;
top: 0;
left: 0;
}
.react-flow__pane {
z-index: 1;
cursor: grab;
&.dragging {
cursor: grabbing;
}
}
.react-flow__viewport {
transform-origin: 0 0;
z-index: 2;
pointer-events: none;
}
.react-flow__renderer {
z-index: 4;
}
.react-flow__selectionpane {
z-index: 5;
}
.react-flow__nodesselection-rect:focus,
.react-flow__nodesselection-rect:focus-visible {
outline: none;
}
.react-flow .react-flow__edges {
pointer-events: none;
overflow: visible;
}
.react-flow__edge-path,
.react-flow__connection-path {
stroke: #b1b1b7;
stroke-width: 1;
fill: none;
}
.react-flow__edge {
pointer-events: visibleStroke;
cursor: pointer;
&.animated path {
stroke-dasharray: 5;
animation: dashdraw 0.5s linear infinite;
}
&.inactive {
pointer-events: none;
}
&.selected,
&:focus,
&:focus-visible {
outline: none;
}
&.selected .react-flow__edge-path,
&:focus .react-flow__edge-path,
&:focus-visible .react-flow__edge-path {
stroke: #555;
}
&-textwrapper {
pointer-events: all;
}
&-textbg {
fill: white;
}
.react-flow__edge-text {
pointer-events: none;
user-select: none;
}
}
.react-flow__connection {
pointer-events: none;
&.animated {
stroke-dasharray: 5;
animation: dashdraw 0.5s linear infinite;
}
}
.react-flow__connectionline {
z-index: 1001;
}
.react-flow__nodes {
pointer-events: none;
transform-origin: 0 0;
}
.react-flow__node {
position: absolute;
user-select: none;
pointer-events: all;
transform-origin: 0 0;
box-sizing: border-box;
cursor: grab;
&.dragging {
cursor: grabbing;
}
}
.react-flow__nodesselection {
z-index: 3;
transform-origin: left top;
pointer-events: none;
&-rect {
position: absolute;
pointer-events: all;
cursor: grab;
}
}
.react-flow__handle {
position: absolute;
pointer-events: none;
min-width: 5px;
min-height: 5px;
&.connectable {
pointer-events: all;
cursor: crosshair;
}
&-bottom {
top: auto;
left: 50%;
bottom: -4px;
transform: translate(-50%, 0);
}
&-top {
left: 50%;
top: -4px;
transform: translate(-50%, 0);
}
&-left {
top: 50%;
left: -4px;
transform: translate(0, -50%);
}
&-right {
right: -4px;
top: 50%;
transform: translate(0, -50%);
}
}
.react-flow__edgeupdater {
cursor: move;
pointer-events: all;
}
.react-flow__panel {
position: absolute;
z-index: 1000;
margin: 15px;
&.top {
top: 0;
}
&.bottom {
bottom: 0;
}
&.left {
left: 0;
}
&.right {
right: 0;
}
&.center {
left: 50%;
transform: translateX(-50%);
}
}
.react-flow__attribution {
font-size: 10px;
background: rgba(255, 255, 255, 0.5);
padding: 2px 3px;
margin: 0;
a {
text-decoration: none;
color: #999;
}
}
@keyframes dashdraw {
from {
stroke-dashoffset: 10;
}
}
+66
View File
@@ -0,0 +1,66 @@
/* this gets exported as style.css and can be used for the default theming */
@import './init.css';
.react-flow__edge {
&.updating {
.react-flow__edge-path {
stroke: #777;
}
}
&-text {
font-size: 10px;
}
}
.react-flow__node-default,
.react-flow__node-input,
.react-flow__node-output,
.react-flow__node-group {
padding: 10px;
border-radius: 3px;
width: 150px;
font-size: 12px;
color: #222;
text-align: center;
border-width: 1px;
border-style: solid;
border-color: #1a192b;
background-color: white;
&.selectable {
&:hover {
box-shadow: 0 1px 4px 1px rgba(0, 0, 0, 0.08);
}
&.selected,
&:focus,
&:focus-visible {
box-shadow: 0 0 0 0.5px #1a192b;
outline: none;
}
}
}
.react-flow__node-group {
background-color: rgba(240, 240, 240, 0.25);
}
.react-flow__nodesselection-rect,
.react-flow__selection {
background: rgba(0, 89, 220, 0.08);
border: 1px dotted rgba(0, 89, 220, 0.8);
&:focus,
&:focus-visible {
outline: none;
}
}
.react-flow__handle {
width: 6px;
height: 6px;
background: #1a192b;
border: 1px solid white;
border-radius: 100%;
}
+60
View File
@@ -0,0 +1,60 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { XYPosition, Dimensions } from './utils';
import { Node } from './nodes';
import { Edge } from './edges';
export type NodeDimensionChange = {
id: string;
type: 'dimensions';
dimensions: Dimensions;
};
export type NodePositionChange = {
id: string;
type: 'position';
position?: XYPosition;
positionAbsolute?: XYPosition;
dragging?: boolean;
};
export type NodeSelectionChange = {
id: string;
type: 'select';
selected: boolean;
};
export type NodeRemoveChange = {
id: string;
type: 'remove';
};
export type NodeAddChange<NodeData = any> = {
item: Node<NodeData>;
type: 'add';
};
export type NodeResetChange<NodeData = any> = {
item: Node<NodeData>;
type: 'reset';
};
export type NodeChange =
| NodeDimensionChange
| NodePositionChange
| NodeSelectionChange
| NodeRemoveChange
| NodeAddChange
| NodeResetChange;
export type EdgeSelectionChange = NodeSelectionChange;
export type EdgeRemoveChange = NodeRemoveChange;
export type EdgeAddChange<EdgeData = any> = {
item: Edge<EdgeData>;
type: 'add';
};
export type EdgeResetChange<EdgeData = any> = {
item: Edge<EdgeData>;
type: 'reset';
};
export type EdgeChange = EdgeSelectionChange | EdgeRemoveChange | EdgeAddChange | EdgeResetChange;
+134
View File
@@ -0,0 +1,134 @@
import React, { CSSProperties, HTMLAttributes, MouseEvent as ReactMouseEvent, WheelEvent } from 'react';
import {
OnSelectionChangeFunc,
NodeTypes,
EdgeTypes,
Node,
Edge,
ConnectionMode,
ConnectionLineType,
ConnectionLineComponent,
OnConnectStart,
OnConnectEnd,
OnConnect,
CoordinateExtent,
KeyCode,
PanOnScrollMode,
OnEdgeUpdateFunc,
OnInit,
ProOptions,
PanelPosition,
DefaultEdgeOptions,
FitViewOptions,
OnNodesDelete,
OnEdgesDelete,
OnNodesChange,
OnEdgesChange,
OnMove,
OnMoveStart,
OnMoveEnd,
NodeDragHandler,
NodeMouseHandler,
SelectionDragHandler,
Viewport,
NodeOrigin,
} from '.';
import { HandleType } from './handles';
export interface ReactFlowProps extends HTMLAttributes<HTMLDivElement> {
nodes?: Node[];
edges?: Edge[];
defaultNodes?: Node[];
defaultEdges?: Edge[];
defaultEdgeOptions?: DefaultEdgeOptions;
onNodesChange?: OnNodesChange;
onEdgesChange?: OnEdgesChange;
onNodeClick?: NodeMouseHandler;
onEdgeClick?: (event: React.MouseEvent, node: Edge) => void;
onNodeDoubleClick?: NodeMouseHandler;
onNodeMouseEnter?: NodeMouseHandler;
onNodeMouseMove?: NodeMouseHandler;
onNodeMouseLeave?: NodeMouseHandler;
onNodeContextMenu?: NodeMouseHandler;
onNodeDragStart?: NodeDragHandler;
onNodeDrag?: NodeDragHandler;
onNodeDragStop?: NodeDragHandler;
onNodesDelete?: OnNodesDelete;
onEdgesDelete?: OnEdgesDelete;
onConnect?: OnConnect;
onConnectStart?: OnConnectStart;
onConnectEnd?: OnConnectEnd;
onClickConnectStart?: OnConnectStart;
onClickConnectEnd?: OnConnectEnd;
onInit?: OnInit;
onMove?: OnMove;
onMoveStart?: OnMoveStart;
onMoveEnd?: OnMoveEnd;
onSelectionChange?: OnSelectionChangeFunc;
onSelectionDragStart?: SelectionDragHandler;
onSelectionDrag?: SelectionDragHandler;
onSelectionDragStop?: SelectionDragHandler;
onSelectionContextMenu?: (event: ReactMouseEvent, nodes: Node[]) => void;
onPaneScroll?: (event?: WheelEvent) => void;
onPaneClick?: (event: ReactMouseEvent) => void;
onPaneContextMenu?: (event: ReactMouseEvent) => void;
onPaneMouseEnter?: (event: ReactMouseEvent) => void;
onPaneMouseMove?: (event: ReactMouseEvent) => void;
onPaneMouseLeave?: (event: ReactMouseEvent) => void;
nodeTypes?: NodeTypes;
edgeTypes?: EdgeTypes;
connectionMode?: ConnectionMode;
connectionLineType?: ConnectionLineType;
connectionLineStyle?: CSSProperties;
connectionLineComponent?: ConnectionLineComponent;
connectionLineContainerStyle?: CSSProperties;
deleteKeyCode?: KeyCode | null;
selectionKeyCode?: KeyCode | null;
multiSelectionKeyCode?: KeyCode | null;
zoomActivationKeyCode?: KeyCode | null;
snapToGrid?: boolean;
snapGrid?: [number, number];
onlyRenderVisibleElements?: boolean;
nodesDraggable?: boolean;
nodesConnectable?: boolean;
nodeOrigin?: NodeOrigin;
initNodeOrigin?: NodeOrigin;
elementsSelectable?: boolean;
selectNodesOnDrag?: boolean;
panOnDrag?: boolean;
minZoom?: number;
maxZoom?: number;
defaultViewport?: Viewport;
translateExtent?: CoordinateExtent;
preventScrolling?: boolean;
nodeExtent?: CoordinateExtent;
defaultMarkerColor?: string;
zoomOnScroll?: boolean;
zoomOnPinch?: boolean;
panOnScroll?: boolean;
panOnScrollSpeed?: number;
panOnScrollMode?: PanOnScrollMode;
zoomOnDoubleClick?: boolean;
onEdgeUpdate?: OnEdgeUpdateFunc;
onEdgeContextMenu?: (event: ReactMouseEvent, edge: Edge) => void;
onEdgeMouseEnter?: (event: ReactMouseEvent, edge: Edge) => void;
onEdgeMouseMove?: (event: ReactMouseEvent, edge: Edge) => void;
onEdgeMouseLeave?: (event: ReactMouseEvent, edge: Edge) => void;
onEdgeDoubleClick?: (event: ReactMouseEvent, edge: Edge) => void;
onEdgeUpdateStart?: (event: ReactMouseEvent, edge: Edge, handleType: HandleType) => void;
onEdgeUpdateEnd?: (event: MouseEvent, edge: Edge, handleType: HandleType) => void;
edgeUpdaterRadius?: number;
noDragClassName?: string;
noWheelClassName?: string;
noPanClassName?: string;
fitView?: boolean;
fitViewOptions?: FitViewOptions;
connectOnClick?: boolean;
attributionPosition?: PanelPosition;
proOptions?: ProOptions;
elevateEdgesOnSelect?: boolean;
disableKeyboardA11y?: boolean;
}
export type ReactFlowRefType = HTMLDivElement;
+197
View File
@@ -0,0 +1,197 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import React, { CSSProperties, ComponentType, HTMLAttributes, ReactNode } from 'react';
import { Connection } from './general';
import { HandleElement, HandleType } from './handles';
import { Node } from './nodes';
import { Position } from './utils';
// interface for the user edge items
type DefaultEdge<T = any> = {
id: string;
type?: string;
source: string;
target: string;
sourceHandle?: string | null;
targetHandle?: string | null;
label?: string | ReactNode;
labelStyle?: CSSProperties;
labelShowBg?: boolean;
labelBgStyle?: CSSProperties;
labelBgPadding?: [number, number];
labelBgBorderRadius?: number;
style?: CSSProperties;
animated?: boolean;
hidden?: boolean;
deletable?: boolean;
data?: T;
className?: string;
sourceNode?: Node;
targetNode?: Node;
selected?: boolean;
markerStart?: EdgeMarkerType;
markerEnd?: EdgeMarkerType;
zIndex?: number;
ariaLabel?: string;
interactionWidth?: number;
};
export type SmoothStepPathOptions = {
offset?: number;
borderRadius?: number;
};
type SmoothStepEdgeType<T> = DefaultEdge<T> & {
type: 'smoothstep';
pathOptions?: SmoothStepPathOptions;
};
export type BezierPathOptions = {
curvature?: number;
};
type BezierEdgeType<T> = DefaultEdge<T> & {
type: 'default';
pathOptions?: BezierPathOptions;
};
export type Edge<T = any> = DefaultEdge<T> | SmoothStepEdgeType<T> | BezierEdgeType<T>;
export type DefaultEdgeOptions = Omit<
Edge,
'id' | 'source' | 'target' | 'sourceHandle' | 'targetHandle' | 'sourceNode' | 'targetNode'
>;
// props that get passed to a custom edge
export type EdgeProps<T = any> = {
id: string;
source: string;
target: string;
sourceX: number;
sourceY: number;
targetX: number;
targetY: number;
selected?: boolean;
animated?: boolean;
sourcePosition: Position;
targetPosition: Position;
label?: string | ReactNode;
labelStyle?: CSSProperties;
labelShowBg?: boolean;
labelBgStyle?: CSSProperties;
labelBgPadding?: [number, number];
labelBgBorderRadius?: number;
style?: CSSProperties;
data?: T;
sourceHandleId?: string | null;
targetHandleId?: string | null;
markerStart?: string;
markerEnd?: string;
// @TODO: how can we get better types for pathOptions?
pathOptions?: any;
interactionWidth?: number;
};
export type BaseEdgeProps = Pick<
EdgeProps,
| 'label'
| 'labelStyle'
| 'labelShowBg'
| 'labelBgStyle'
| 'labelBgPadding'
| 'labelBgBorderRadius'
| 'style'
| 'markerStart'
| 'markerEnd'
| 'interactionWidth'
> & {
centerX: number;
centerY: number;
path: string;
};
export type EdgeMouseHandler = (event: React.MouseEvent, edge: Edge) => void;
export type WrapEdgeProps<T = any> = Omit<Edge<T>, 'sourceHandle' | 'targetHandle'> & {
onClick?: EdgeMouseHandler;
onEdgeDoubleClick?: EdgeMouseHandler;
sourceHandleId?: string | null;
targetHandleId?: string | null;
sourceX: number;
sourceY: number;
targetX: number;
targetY: number;
sourcePosition: Position;
targetPosition: Position;
elementsSelectable?: boolean;
onEdgeUpdate?: OnEdgeUpdateFunc;
onContextMenu?: EdgeMouseHandler;
onMouseEnter?: EdgeMouseHandler;
onMouseMove?: EdgeMouseHandler;
onMouseLeave?: EdgeMouseHandler;
edgeUpdaterRadius?: number;
onEdgeUpdateStart?: (event: React.MouseEvent, edge: Edge, handleType: HandleType) => void;
onEdgeUpdateEnd?: (event: MouseEvent, edge: Edge, handleType: HandleType) => void;
rfId?: string;
disableKeyboardA11y: boolean;
pathOptions?: BezierPathOptions | SmoothStepPathOptions;
};
export interface SmoothStepEdgeProps<T = any> extends EdgeProps<T> {
pathOptions?: SmoothStepPathOptions;
}
export interface BezierEdgeProps<T = any> extends EdgeProps<T> {
pathOptions?: BezierPathOptions;
}
export interface EdgeTextProps extends HTMLAttributes<SVGElement> {
x: number;
y: number;
label?: string | ReactNode;
labelStyle?: CSSProperties;
labelShowBg?: boolean;
labelBgStyle?: CSSProperties;
labelBgPadding?: [number, number];
labelBgBorderRadius?: number;
}
export enum ConnectionLineType {
Bezier = 'default',
Straight = 'straight',
Step = 'step',
SmoothStep = 'smoothstep',
SimpleBezier = 'simplebezier',
}
export type ConnectionLineComponentProps = {
connectionLineStyle?: CSSProperties;
connectionLineType: ConnectionLineType;
fromNode?: Node;
fromHandle?: HandleElement;
fromX: number;
fromY: number;
toX: number;
toY: number;
fromPosition: Position;
toPosition: Position;
};
export type ConnectionLineComponent = ComponentType<ConnectionLineComponentProps>;
export type OnEdgeUpdateFunc<T = any> = (oldEdge: Edge<T>, newConnection: Connection) => void;
export interface EdgeMarker {
type: MarkerType;
color?: string;
width?: number;
height?: number;
markerUnits?: string;
orient?: string;
strokeWidth?: number;
}
export type EdgeMarkerType = string | EdgeMarker;
export enum MarkerType {
Arrow = 'arrow',
ArrowClosed = 'arrowclosed',
}
+244
View File
@@ -0,0 +1,244 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { MouseEvent as ReactMouseEvent, ComponentType, MemoExoticComponent } from 'react';
import { Selection as D3Selection } from 'd3-selection';
import { ZoomBehavior } from 'd3-zoom';
import { XYPosition, Rect, Transform, CoordinateExtent } from './utils';
import { NodeChange, EdgeChange } from './changes';
import {
Node,
NodeInternals,
NodeDimensionUpdate,
NodeProps,
WrapNodeProps,
NodeDragItem,
NodeDragHandler,
SelectionDragHandler,
NodeOrigin,
} from './nodes';
import { Edge, EdgeProps, WrapEdgeProps } from './edges';
import { HandleType, StartHandle } from './handles';
import { DefaultEdgeOptions } from '.';
import { ReactFlowInstance } from './instance';
export type NodeTypes = { [key: string]: ComponentType<NodeProps> };
export type NodeTypesWrapped = { [key: string]: MemoExoticComponent<ComponentType<WrapNodeProps>> };
export type EdgeTypes = { [key: string]: ComponentType<EdgeProps> };
export type EdgeTypesWrapped = { [key: string]: MemoExoticComponent<ComponentType<WrapEdgeProps>> };
export type FitView = (fitViewOptions?: FitViewOptions) => void;
export type Project = (position: XYPosition) => XYPosition;
export type OnNodesChange = (changes: NodeChange[]) => void;
export type OnEdgesChange = (changes: EdgeChange[]) => void;
export type OnNodesDelete = (nodes: Node[]) => void;
export type OnEdgesDelete = (edges: Edge[]) => void;
export type OnMove = (event: MouseEvent | TouchEvent, viewport: Viewport) => void;
export type OnMoveStart = OnMove;
export type OnMoveEnd = OnMove;
export type ZoomInOut = (options?: ViewportHelperFunctionOptions) => void;
export type ZoomTo = (zoomLevel: number, options?: ViewportHelperFunctionOptions) => void;
export type GetZoom = () => number;
export type GetViewport = () => Viewport;
export type SetViewport = (viewport: Viewport, options?: ViewportHelperFunctionOptions) => void;
export type SetCenter = (x: number, y: number, options?: SetCenterOptions) => void;
export type FitBounds = (bounds: Rect, options?: FitBoundsOptions) => void;
export type OnInit<NodeData = any, EdgeData = any> = (reactFlowInstance: ReactFlowInstance<NodeData, EdgeData>) => void;
export interface Connection {
source: string | null;
target: string | null;
sourceHandle: string | null;
targetHandle: string | null;
}
export enum ConnectionMode {
Strict = 'strict',
Loose = 'loose',
}
export type OnConnect = (connection: Connection) => void;
export type FitViewOptions = {
padding?: number;
includeHiddenNodes?: boolean;
minZoom?: number;
maxZoom?: number;
duration?: number;
};
export type OnConnectStartParams = {
nodeId: string | null;
handleId: string | null;
handleType: HandleType | null;
};
export type OnConnectStart = (event: ReactMouseEvent, params: OnConnectStartParams) => void;
export type OnConnectEnd = (event: MouseEvent) => void;
export type Viewport = {
x: number;
y: number;
zoom: number;
};
export type KeyCode = string | Array<string>;
export type SnapGrid = [number, number];
export enum PanOnScrollMode {
Free = 'free',
Vertical = 'vertical',
Horizontal = 'horizontal',
}
export type ViewportHelperFunctionOptions = {
duration?: number;
};
export type SetCenterOptions = ViewportHelperFunctionOptions & {
zoom?: number;
};
export type FitBoundsOptions = ViewportHelperFunctionOptions & {
padding?: number;
};
export type UnselectNodesAndEdgesParams = {
nodes?: Node[];
edges?: Edge[];
};
export type OnViewportChange = (viewport: Viewport) => void;
export interface ViewportHelperFunctions {
zoomIn: ZoomInOut;
zoomOut: ZoomInOut;
zoomTo: ZoomTo;
getZoom: GetZoom;
setViewport: SetViewport;
getViewport: GetViewport;
fitView: FitView;
setCenter: SetCenter;
fitBounds: FitBounds;
project: Project;
viewportInitialized: boolean;
}
export type ReactFlowStore = {
rfId: string;
width: number;
height: number;
transform: Transform;
nodeInternals: NodeInternals;
edges: Edge[];
onNodesChange: OnNodesChange | null;
onEdgesChange: OnEdgesChange | null;
hasDefaultNodes: boolean;
hasDefaultEdges: boolean;
domNode: HTMLDivElement | null;
paneDragging: boolean;
noPanClassName: string;
d3Zoom: ZoomBehavior<Element, unknown> | null;
d3Selection: D3Selection<Element, unknown, null, undefined> | null;
d3ZoomHandler: ((this: Element, event: any, d: unknown) => void) | undefined;
minZoom: number;
maxZoom: number;
translateExtent: CoordinateExtent;
nodeExtent: CoordinateExtent;
nodeOrigin: NodeOrigin;
nodesSelectionActive: boolean;
userSelectionActive: boolean;
connectionNodeId: string | null;
connectionHandleId: string | null;
connectionHandleType: HandleType | null;
connectionPosition: XYPosition;
connectionMode: ConnectionMode;
snapToGrid: boolean;
snapGrid: SnapGrid;
nodesDraggable: boolean;
nodesConnectable: boolean;
elementsSelectable: boolean;
multiSelectionActive: boolean;
connectionStartHandle: StartHandle | null;
onNodeDragStart?: NodeDragHandler;
onNodeDrag?: NodeDragHandler;
onNodeDragStop?: NodeDragHandler;
onSelectionDragStart?: SelectionDragHandler;
onSelectionDrag?: SelectionDragHandler;
onSelectionDragStop?: SelectionDragHandler;
onConnect?: OnConnect;
onConnectStart?: OnConnectStart;
onConnectEnd?: OnConnectEnd;
onClickConnectStart?: OnConnectStart;
onClickConnectEnd?: OnConnectEnd;
connectOnClick: boolean;
defaultEdgeOptions?: DefaultEdgeOptions;
fitViewOnInit: boolean;
fitViewOnInitDone: boolean;
fitViewOnInitOptions: FitViewOptions | undefined;
onNodesDelete?: OnNodesDelete;
onEdgesDelete?: OnEdgesDelete;
// event handlers
onViewportChangeStart?: OnViewportChange;
onViewportChange?: OnViewportChange;
onViewportChangeEnd?: OnViewportChange;
onSelectionChange?: OnSelectionChangeFunc;
ariaLiveMessage: string;
};
export type ReactFlowActions = {
setNodes: (nodes: Node[]) => void;
setEdges: (edges: Edge[]) => void;
setDefaultNodesAndEdges: (nodes?: Node[], edges?: Edge[]) => void;
updateNodeDimensions: (updates: NodeDimensionUpdate[]) => void;
updateNodePositions: (nodeDragItems: NodeDragItem[] | Node[], positionChanged: boolean, dragging: boolean) => void;
resetSelectedElements: () => void;
unselectNodesAndEdges: (params?: UnselectNodesAndEdgesParams) => void;
addSelectedNodes: (nodeIds: string[]) => void;
addSelectedEdges: (edgeIds: string[]) => void;
setMinZoom: (minZoom: number) => void;
setMaxZoom: (maxZoom: number) => void;
setTranslateExtent: (translateExtent: CoordinateExtent) => void;
setNodeExtent: (nodeExtent: CoordinateExtent) => void;
reset: () => void;
};
export type ReactFlowState = ReactFlowStore & ReactFlowActions;
export type UpdateNodeInternals = (nodeId: string) => void;
export type OnSelectionChangeParams = {
nodes: Node[];
edges: Edge[];
};
export type OnSelectionChangeFunc = (params: OnSelectionChangeParams) => void;
export type PanelPosition = 'top-left' | 'top-center' | 'top-right' | 'bottom-left' | 'bottom-center' | 'bottom-right';
export type ProOptions = {
hideAttribution: boolean;
};
+24
View File
@@ -0,0 +1,24 @@
import { XYPosition, Position, Dimensions } from './utils';
import { OnConnect, Connection } from './general';
export type HandleType = 'source' | 'target';
export interface HandleElement extends XYPosition, Dimensions {
id?: string | null;
position: Position;
}
export interface StartHandle {
nodeId: string;
type: HandleType;
handleId?: string | null;
}
export interface HandleProps {
type: HandleType;
position: Position;
isConnectable?: boolean;
onConnect?: OnConnect;
isValidConnection?: (connection: Connection) => boolean;
id?: string;
}
+8
View File
@@ -0,0 +1,8 @@
export * from './general';
export * from './nodes';
export * from './edges';
export * from './handles';
export * from './changes';
export * from './utils';
export * from './instance';
export * from './component-props';
+40
View File
@@ -0,0 +1,40 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable @typescript-eslint/no-namespace */
import { ViewportHelperFunctions, Viewport } from './general';
import { Node } from './nodes';
import { Edge } from './edges';
export type ReactFlowJsonObject<NodeData = any, EdgeData = any> = {
nodes: Node<NodeData>[];
edges: Edge<EdgeData>[];
viewport: Viewport;
};
export namespace Instance {
export type GetNodes<NodeData> = () => Node<NodeData>[];
export type SetNodes<NodeData> = (
payload: Node<NodeData>[] | ((nodes: Node<NodeData>[]) => Node<NodeData>[])
) => void;
export type AddNodes<NodeData> = (payload: Node<NodeData>[] | Node<NodeData>) => void;
export type GetNode<NodeData> = (id: string) => Node<NodeData> | undefined;
export type GetEdges<EdgeData> = () => Edge<EdgeData>[];
export type SetEdges<EdgeData> = (
payload: Edge<EdgeData>[] | ((edges: Edge<EdgeData>[]) => Edge<EdgeData>[])
) => void;
export type GetEdge<EdgeData> = (id: string) => Edge<EdgeData> | undefined;
export type AddEdges<EdgeData> = (payload: Edge<EdgeData>[] | Edge<EdgeData>) => void;
export type ToObject<NodeData = any, EdgeData = any> = () => ReactFlowJsonObject<NodeData, EdgeData>;
}
export type ReactFlowInstance<NodeData = any, EdgeData = any> = {
getNodes: Instance.GetNodes<NodeData>;
setNodes: Instance.SetNodes<NodeData>;
addNodes: Instance.AddNodes<NodeData>;
getNode: Instance.GetNode<NodeData>;
getEdges: Instance.GetEdges<EdgeData>;
setEdges: Instance.SetEdges<EdgeData>;
addEdges: Instance.AddEdges<EdgeData>;
getEdge: Instance.GetEdge<EdgeData>;
toObject: Instance.ToObject<NodeData, EdgeData>;
viewportInitialized: boolean;
} & Omit<ViewportHelperFunctions, 'initialized'>;
+130
View File
@@ -0,0 +1,130 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { CSSProperties, MouseEvent as ReactMouseEvent } from 'react';
import { XYPosition, Position, CoordinateExtent } from './utils';
import { HandleElement } from './handles';
import { internalsSymbol } from '../utils';
// interface for the user node items
export interface Node<T = any> {
id: string;
position: XYPosition;
data: T;
type?: string;
style?: CSSProperties;
className?: string;
targetPosition?: Position;
sourcePosition?: Position;
hidden?: boolean;
selected?: boolean;
dragging?: boolean;
draggable?: boolean;
selectable?: boolean;
connectable?: boolean;
deletable?: boolean;
dragHandle?: string;
width?: number | null;
height?: number | null;
parentNode?: string;
zIndex?: number;
extent?: 'parent' | CoordinateExtent;
expandParent?: boolean;
positionAbsolute?: XYPosition;
ariaLabel?: string;
// only used internally
[internalsSymbol]?: {
z?: number;
handleBounds?: NodeHandleBounds;
isParent?: boolean;
};
}
// props that get passed to a custom node
export interface NodeProps<T = any> {
id: string;
type: string;
data: T;
selected: boolean;
isConnectable: boolean;
xPos: number;
yPos: number;
dragging: boolean;
zIndex: number;
targetPosition?: Position;
sourcePosition?: Position;
dragHandle?: string;
}
export type NodeMouseHandler = (event: ReactMouseEvent, node: Node) => void;
export type NodeDragHandler = (event: ReactMouseEvent, node: Node, nodes: Node[]) => void;
export type SelectionDragHandler = (event: ReactMouseEvent, nodes: Node[]) => void;
export interface WrapNodeProps<T = any> {
id: string;
type: string;
data: T;
selected: boolean;
isConnectable: boolean;
xPos: number;
yPos: number;
xPosOrigin: number;
yPosOrigin: number;
initialized: boolean;
isSelectable: boolean;
isDraggable: boolean;
selectNodesOnDrag: boolean;
onClick?: NodeMouseHandler;
onDoubleClick?: NodeMouseHandler;
onMouseEnter?: NodeMouseHandler;
onMouseMove?: NodeMouseHandler;
onMouseLeave?: NodeMouseHandler;
onContextMenu?: NodeMouseHandler;
style?: CSSProperties;
className?: string;
sourcePosition: Position;
targetPosition: Position;
hidden?: boolean;
resizeObserver: ResizeObserver | null;
dragHandle?: string;
zIndex: number;
isParent: boolean;
noDragClassName: string;
noPanClassName: string;
rfId: string;
disableKeyboardA11y: boolean;
ariaLabel?: string;
}
export type NodeHandleBounds = {
source: HandleElement[] | null;
target: HandleElement[] | null;
};
export type NodeDimensionUpdate = {
id: string;
nodeElement: HTMLDivElement;
forceUpdate?: boolean;
};
export type NodeInternals = Map<string, Node>;
export type NodeBounds = XYPosition & {
width: number | null;
height: number | null;
};
export type NodeDragItem = {
id: string;
position: XYPosition;
positionAbsolute: XYPosition;
// distance from the mouse cursor to the node when start dragging
distance: XYPosition;
width?: number | null;
height?: number | null;
extent?: 'parent' | CoordinateExtent;
parentNode?: string;
dragging?: boolean;
};
export type NodeOrigin = [number, number];
+29
View File
@@ -0,0 +1,29 @@
export enum Position {
Left = 'left',
Top = 'top',
Right = 'right',
Bottom = 'bottom',
}
export interface XYPosition {
x: number;
y: number;
}
export type XYZPosition = XYPosition & { z: number };
export interface Dimensions {
width: number;
height: number;
}
export interface Rect extends Dimensions, XYPosition {}
export interface Box extends XYPosition {
x2: number;
y2: number;
}
export type Transform = [number, number, number];
export type CoordinateExtent = [[number, number], [number, number]];
+137
View File
@@ -0,0 +1,137 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { Node, Edge, EdgeChange, NodeChange } from '../types';
function handleParentExpand(res: any[], updateItem: any) {
const parent = res.find((e) => e.id === updateItem.parentNode);
if (parent) {
const extendWidth = updateItem.position.x + updateItem.width - parent.width;
const extendHeight = updateItem.position.y + updateItem.height - parent.height;
if (extendWidth > 0 || extendHeight > 0 || updateItem.position.x < 0 || updateItem.position.y < 0) {
parent.style = { ...parent.style } || {};
parent.style.width = parent.style.width ?? parent.width;
parent.style.height = parent.style.height ?? parent.height;
if (extendWidth > 0) {
parent.style.width += extendWidth;
}
if (extendHeight > 0) {
parent.style.height += extendHeight;
}
if (updateItem.position.x < 0) {
const xDiff = Math.abs(updateItem.position.x);
parent.position.x = parent.position.x - xDiff;
parent.style.width += xDiff;
updateItem.position.x = 0;
}
if (updateItem.position.y < 0) {
const yDiff = Math.abs(updateItem.position.y);
parent.position.y = parent.position.y - yDiff;
parent.style.height += yDiff;
updateItem.position.y = 0;
}
parent.width = parent.style.width;
parent.height = parent.style.height;
}
}
}
function applyChanges(changes: any[], elements: any[]): any[] {
// we need this hack to handle the setNodes and setEdges function of the useReactFlow hook for controlled flows
if (changes.some((c) => c.type === 'reset')) {
return changes.filter((c) => c.type === 'reset').map((c) => c.item);
}
const initElements: any[] = changes.filter((c) => c.type === 'add').map((c) => c.item);
return elements.reduce((res: any[], item: any) => {
const currentChange = changes.find((c) => c.id === item.id);
if (currentChange) {
switch (currentChange.type) {
case 'select': {
res.push({ ...item, selected: currentChange.selected });
return res;
}
case 'position': {
const updateItem = { ...item };
if (typeof currentChange.position !== 'undefined') {
updateItem.position = currentChange.position;
}
if (typeof currentChange.positionAbsolute !== 'undefined') {
updateItem.positionAbsolute = currentChange.positionAbsolute;
}
if (typeof currentChange.dragging !== 'undefined') {
updateItem.dragging = currentChange.dragging;
}
if (updateItem.expandParent) {
handleParentExpand(res, updateItem);
}
res.push(updateItem);
return res;
}
case 'dimensions': {
const updateItem = { ...item };
if (typeof currentChange.dimensions !== 'undefined') {
updateItem.width = currentChange.dimensions.width;
updateItem.height = currentChange.dimensions.height;
}
if (updateItem.expandParent) {
handleParentExpand(res, updateItem);
}
res.push(updateItem);
return res;
}
case 'remove': {
return res;
}
}
}
res.push(item);
return res;
}, initElements);
}
export function applyNodeChanges<NodeData = any>(changes: NodeChange[], nodes: Node<NodeData>[]): Node<NodeData>[] {
return applyChanges(changes, nodes) as Node<NodeData>[];
}
export function applyEdgeChanges<EdgeData = any>(changes: EdgeChange[], edges: Edge<EdgeData>[]): Edge<EdgeData>[] {
return applyChanges(changes, edges) as Edge<EdgeData>[];
}
export const createSelectionChange = (id: string, selected: boolean) => ({
id,
type: 'select',
selected,
});
export function getSelectionChanges(items: any[], selectedIds: string[]) {
return items.reduce((res, item) => {
const willBeSelected = selectedIds.includes(item.id);
if (!item.selected && willBeSelected) {
item.selected = true;
res.push(createSelectionChange(item.id, true));
} else if (item.selected && !willBeSelected) {
item.selected = false;
res.push(createSelectionChange(item.id, false));
}
return res;
}, []);
}
+227
View File
@@ -0,0 +1,227 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { Selection as D3Selection } from 'd3-selection';
import { boxToRect, clamp, devWarn, getBoundsOfBoxes, rectToBox } from '../utils';
import { Node, Edge, Connection, EdgeMarkerType, Transform, XYPosition, Rect, NodeInternals } from '../types';
export const isEdge = (element: Node | Connection | Edge): element is Edge =>
'id' in element && 'source' in element && 'target' in element;
export const isNode = (element: Node | Connection | Edge): element is Node =>
'id' in element && !('source' in element) && !('target' in element);
export const getOutgoers = <T = any, U extends T = T>(node: Node<U>, nodes: Node<T>[], edges: Edge[]): Node<T>[] => {
if (!isNode(node)) {
return [];
}
const outgoerIds = edges.filter((e) => e.source === node.id).map((e) => e.target);
return nodes.filter((n) => outgoerIds.includes(n.id));
};
export const getIncomers = <T = any, U extends T = T>(node: Node<U>, nodes: Node<T>[], edges: Edge[]): Node<T>[] => {
if (!isNode(node)) {
return [];
}
const incomersIds = edges.filter((e) => e.target === node.id).map((e) => e.source);
return nodes.filter((n) => incomersIds.includes(n.id));
};
const getEdgeId = ({ source, sourceHandle, target, targetHandle }: Connection): string =>
`reactflow__edge-${source}${sourceHandle || ''}-${target}${targetHandle || ''}`;
export const getMarkerId = (marker: EdgeMarkerType | undefined, rfId?: string): string => {
if (typeof marker === 'undefined') {
return '';
}
if (typeof marker === 'string') {
return marker;
}
const idPrefix = rfId ? `${rfId}__` : '';
return `${idPrefix}${Object.keys(marker)
.sort()
.map((key: string) => `${key}=${(marker as any)[key]}`)
.join('&')}`;
};
const connectionExists = (edge: Edge, edges: Edge[]) => {
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 addEdge = (edgeParams: Edge | Connection, edges: Edge[]): Edge[] => {
if (!edgeParams.source || !edgeParams.target) {
devWarn("Can't create edge. An edge needs a source and a target. Help: https://reactflow.dev/error#600");
return edges;
}
let edge: Edge;
if (isEdge(edgeParams)) {
edge = { ...edgeParams };
} else {
edge = {
...edgeParams,
id: getEdgeId(edgeParams),
} as Edge;
}
if (connectionExists(edge, edges)) {
return edges;
}
return edges.concat(edge);
};
export const updateEdge = (oldEdge: Edge, newConnection: Connection, edges: Edge[]): Edge[] => {
if (!newConnection.source || !newConnection.target) {
devWarn("Can't create a new edge. An edge needs a source and a target. Help: https://reactflow.dev/error#600");
return edges;
}
const foundEdge = edges.find((e) => e.id === oldEdge.id) as Edge;
if (!foundEdge) {
devWarn(`The old edge with id=${oldEdge.id} does not exist. Help: https://reactflow.dev/error#700`);
return edges;
}
// Remove old edge and create the new edge with parameters of old edge.
const edge = {
...oldEdge,
id: getEdgeId(newConnection),
source: newConnection.source,
target: newConnection.target,
sourceHandle: newConnection.sourceHandle,
targetHandle: newConnection.targetHandle,
} as Edge;
return edges.filter((e) => e.id !== oldEdge.id).concat(edge);
};
export const pointToRendererPoint = (
{ x, y }: XYPosition,
[tx, ty, tScale]: Transform,
snapToGrid: boolean,
[snapX, snapY]: [number, number]
): XYPosition => {
const position: XYPosition = {
x: (x - tx) / tScale,
y: (y - ty) / tScale,
};
if (snapToGrid) {
return {
x: snapX * Math.round(position.x / snapX),
y: snapY * Math.round(position.y / snapY),
};
}
return position;
};
export const getRectOfNodes = (nodes: Node[]): Rect => {
if (nodes.length === 0) {
return { x: 0, y: 0, width: 0, height: 0 };
}
const box = nodes.reduce(
(currBox, { positionAbsolute, position, width, height }) =>
getBoundsOfBoxes(
currBox,
rectToBox({
x: positionAbsolute ? positionAbsolute.x : position.x,
y: positionAbsolute ? positionAbsolute.y : position.y,
width: width || 0,
height: height || 0,
})
),
{ x: Infinity, y: Infinity, x2: -Infinity, y2: -Infinity }
);
return boxToRect(box);
};
export const getNodesInside = (
nodeInternals: NodeInternals,
rect: Rect,
[tx, ty, tScale]: Transform = [0, 0, 1],
partially = false,
// set excludeNonSelectableNodes if you want to pay attention to the nodes "selectable" attribute
excludeNonSelectableNodes = false
): Node[] => {
const rBox = rectToBox({
x: (rect.x - tx) / tScale,
y: (rect.y - ty) / tScale,
width: rect.width / tScale,
height: rect.height / tScale,
});
const visibleNodes: Node[] = [];
nodeInternals.forEach((node) => {
const { positionAbsolute = { x: 0, y: 0 }, width, height, selectable = true } = node;
if (excludeNonSelectableNodes && !selectable) {
return false;
}
const nBox = rectToBox({ ...positionAbsolute, width: width || 0, height: height || 0 });
const xOverlap = Math.max(0, Math.min(rBox.x2, nBox.x2) - Math.max(rBox.x, nBox.x));
const yOverlap = Math.max(0, Math.min(rBox.y2, nBox.y2) - Math.max(rBox.y, nBox.y));
const overlappingArea = Math.ceil(xOverlap * yOverlap);
const notInitialized =
typeof width === 'undefined' || typeof height === 'undefined' || width === null || height === null;
const partiallyVisible = partially && overlappingArea > 0;
const area = (width || 0) * (height || 0);
const isVisible = notInitialized || partiallyVisible || overlappingArea >= area;
if (isVisible) {
visibleNodes.push(node);
}
});
return visibleNodes;
};
export const getConnectedEdges = (nodes: Node[], edges: Edge[]): Edge[] => {
const nodeIds = nodes.map((node) => node.id);
return edges.filter((edge) => 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 const getD3Transition = (selection: D3Selection<Element, unknown, null, undefined>, duration = 0) => {
return selection.transition().duration(duration);
};
+54
View File
@@ -0,0 +1,54 @@
import { Dimensions, XYPosition, CoordinateExtent, Box, Rect } from '../types';
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]),
});
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 getBoundsOfRects = (rect1: Rect, rect2: Rect): Rect =>
boxToRect(getBoundsOfBoxes(rectToBox(rect1), rectToBox(rect2)));
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
export const isNumeric = (n: any): n is number => !isNaN(n) && isFinite(n);
export const internalsSymbol = Symbol.for('internals');
// used for a11y key board controls for nodes and edges
export const elementSelectionKeys = ['Enter', ' ', 'Escape'];
export const devWarn = (message: string) => {
if (process.env.NODE_ENV === 'development') {
console.warn(`[React Flow]: ${message}`);
}
};