refactor(packages): change package structure

This commit is contained in:
moklick
2023-06-05 15:40:18 +02:00
parent b2e296901e
commit 8354759f57
332 changed files with 980 additions and 3244 deletions
@@ -0,0 +1,89 @@
import { memo, useRef } from 'react';
import cc from 'classcat';
import { shallow } from 'zustand/shallow';
import { useStore } from '../../hooks/useStore';
import { type ReactFlowState } from '../../types';
import { BackgroundProps, BackgroundVariant } from './types';
import { DotPattern, LinePattern } from './Patterns';
const defaultColor = {
[BackgroundVariant.Dots]: '#91919a',
[BackgroundVariant.Lines]: '#eee',
[BackgroundVariant.Cross]: '#e2e2e2',
};
const defaultSize = {
[BackgroundVariant.Dots]: 1,
[BackgroundVariant.Lines]: 1,
[BackgroundVariant.Cross]: 6,
};
const selector = (s: ReactFlowState) => ({ transform: s.transform, patternId: `pattern-${s.rfId}` });
function Background({
id,
variant = BackgroundVariant.Dots,
// only used for dots and cross
gap = 20,
// only used for lines and cross
size,
lineWidth = 1,
offset = 2,
color,
style,
className,
}: BackgroundProps) {
const ref = useRef<SVGSVGElement>(null);
const { transform, patternId } = useStore(selector, shallow);
const patternColor = color || defaultColor[variant];
const patternSize = size || defaultSize[variant];
const isDots = variant === BackgroundVariant.Dots;
const isCross = variant === BackgroundVariant.Cross;
const gapXY: [number, number] = Array.isArray(gap) ? gap : [gap, gap];
const scaledGap: [number, number] = [gapXY[0] * transform[2] || 1, gapXY[1] * transform[2] || 1];
const scaledSize = patternSize * transform[2];
const patternDimensions: [number, number] = isCross ? [scaledSize, scaledSize] : scaledGap;
const patternOffset = isDots
? [scaledSize / offset, scaledSize / offset]
: [patternDimensions[0] / offset, patternDimensions[1] / offset];
return (
<svg
className={cc(['react-flow__background', className])}
style={{
...style,
position: 'absolute',
width: '100%',
height: '100%',
top: 0,
left: 0,
}}
ref={ref}
data-testid="rf__background"
>
<pattern
id={patternId + id}
x={transform[0] % scaledGap[0]}
y={transform[1] % scaledGap[1]}
width={scaledGap[0]}
height={scaledGap[1]}
patternUnits="userSpaceOnUse"
patternTransform={`translate(-${patternOffset[0]},-${patternOffset[1]})`}
>
{isDots ? (
<DotPattern color={patternColor} radius={scaledSize / offset} />
) : (
<LinePattern dimensions={patternDimensions} color={patternColor} lineWidth={lineWidth} />
)}
</pattern>
<rect x="0" y="0" width="100%" height="100%" fill={`url(#${patternId + id})`} />
</svg>
);
}
Background.displayName = 'Background';
export default memo(Background);
@@ -0,0 +1,30 @@
type LinePatternProps = {
dimensions: [number, number];
lineWidth?: number;
color: string;
};
export function LinePattern({
color,
dimensions,
lineWidth,
}: LinePatternProps) {
return (
<path
stroke={color}
strokeWidth={lineWidth}
d={`M${dimensions[0] / 2} 0 V${dimensions[1]} M0 ${dimensions[1] / 2} H${
dimensions[0]
}`}
/>
);
}
type DotPatternProps = {
radius: number;
color: string;
};
export function DotPattern({ color, radius }: DotPatternProps) {
return <circle cx={radius} cy={radius} r={radius} fill={color} />;
}
@@ -0,0 +1,2 @@
export { default as Background } from './Background';
export * from './types';
@@ -0,0 +1,19 @@
import { CSSProperties } from 'react';
export enum BackgroundVariant {
Lines = 'lines',
Dots = 'dots',
Cross = 'cross',
}
export type BackgroundProps = {
id?: string
color?: string;
className?: string;
gap?: number | [number, number];
size?: number;
offset?: number;
lineWidth?: number;
variant?: BackgroundVariant;
style?: CSSProperties;
};
@@ -0,0 +1,14 @@
import type { FC, PropsWithChildren } from 'react';
import cc from 'classcat';
import type { ControlButtonProps } from './types';
const ControlButton: FC<PropsWithChildren<ControlButtonProps>> = ({ children, className, ...rest }) => (
<button type="button" className={cc(['react-flow__controls-button', className])} {...rest}>
{children}
</button>
);
ControlButton.displayName = 'ControlButton';
export default ControlButton;
@@ -0,0 +1,133 @@
import { memo, useEffect, useState, type FC, type PropsWithChildren } from 'react';
import cc from 'classcat';
import { shallow } from 'zustand/shallow';
import { useStore, useStoreApi } from '../../hooks/useStore';
import useReactFlow from '../../hooks/useReactFlow';
import Panel from '../../components/Panel';
import { type ReactFlowState } from '../../types';
import PlusIcon from './Icons/Plus';
import MinusIcon from './Icons/Minus';
import FitviewIcon from './Icons/FitView';
import LockIcon from './Icons/Lock';
import UnlockIcon from './Icons/Unlock';
import ControlButton from './ControlButton';
import type { ControlProps } from './types';
const selector = (s: ReactFlowState) => ({
isInteractive: s.nodesDraggable || s.nodesConnectable || s.elementsSelectable,
minZoomReached: s.transform[2] <= s.minZoom,
maxZoomReached: s.transform[2] >= s.maxZoom,
});
const Controls: FC<PropsWithChildren<ControlProps>> = ({
style,
showZoom = true,
showFitView = true,
showInteractive = true,
fitViewOptions,
onZoomIn,
onZoomOut,
onFitView,
onInteractiveChange,
className,
children,
position = 'bottom-left',
}) => {
const store = useStoreApi();
const [isVisible, setIsVisible] = useState<boolean>(false);
const { isInteractive, minZoomReached, maxZoomReached } = useStore(selector, shallow);
const { zoomIn, zoomOut, fitView } = useReactFlow();
useEffect(() => {
setIsVisible(true);
}, []);
if (!isVisible) {
return null;
}
const onZoomInHandler = () => {
zoomIn();
onZoomIn?.();
};
const onZoomOutHandler = () => {
zoomOut();
onZoomOut?.();
};
const onFitViewHandler = () => {
fitView(fitViewOptions);
onFitView?.();
};
const onToggleInteractivity = () => {
store.setState({
nodesDraggable: !isInteractive,
nodesConnectable: !isInteractive,
elementsSelectable: !isInteractive,
});
onInteractiveChange?.(!isInteractive);
};
return (
<Panel
className={cc(['react-flow__controls', className])}
position={position}
style={style}
data-testid="rf__controls"
>
{showZoom && (
<>
<ControlButton
onClick={onZoomInHandler}
className="react-flow__controls-zoomin"
title="zoom in"
aria-label="zoom in"
disabled={maxZoomReached}
>
<PlusIcon />
</ControlButton>
<ControlButton
onClick={onZoomOutHandler}
className="react-flow__controls-zoomout"
title="zoom out"
aria-label="zoom out"
disabled={minZoomReached}
>
<MinusIcon />
</ControlButton>
</>
)}
{showFitView && (
<ControlButton
className="react-flow__controls-fitview"
onClick={onFitViewHandler}
title="fit view"
aria-label="fit view"
>
<FitviewIcon />
</ControlButton>
)}
{showInteractive && (
<ControlButton
className="react-flow__controls-interactive"
onClick={onToggleInteractivity}
title="toggle interactivity"
aria-label="toggle interactivity"
>
{isInteractive ? <UnlockIcon /> : <LockIcon />}
</ControlButton>
)}
{children}
</Panel>
);
};
Controls.displayName = 'Controls';
export default memo(Controls);
@@ -0,0 +1,9 @@
function FitViewIcon() {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 30">
<path d="M3.692 4.63c0-.53.4-.938.939-.938h5.215V0H4.708C2.13 0 0 2.054 0 4.63v5.216h3.692V4.631zM27.354 0h-5.2v3.692h5.17c.53 0 .984.4.984.939v5.215H32V4.631A4.624 4.624 0 0027.354 0zm.954 24.83c0 .532-.4.94-.939.94h-5.215v3.768h5.215c2.577 0 4.631-2.13 4.631-4.707v-5.139h-3.692v5.139zm-23.677.94c-.531 0-.939-.4-.939-.94v-5.138H0v5.139c0 2.577 2.13 4.707 4.708 4.707h5.138V25.77H4.631z" />
</svg>
);
}
export default FitViewIcon;
@@ -0,0 +1,9 @@
function LockIcon() {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 25 32">
<path d="M21.333 10.667H19.81V7.619C19.81 3.429 16.38 0 12.19 0 8 0 4.571 3.429 4.571 7.619v3.048H3.048A3.056 3.056 0 000 13.714v15.238A3.056 3.056 0 003.048 32h18.285a3.056 3.056 0 003.048-3.048V13.714a3.056 3.056 0 00-3.048-3.047zM12.19 24.533a3.056 3.056 0 01-3.047-3.047 3.056 3.056 0 013.047-3.048 3.056 3.056 0 013.048 3.048 3.056 3.056 0 01-3.048 3.047zm4.724-13.866H7.467V7.619c0-2.59 2.133-4.724 4.723-4.724 2.591 0 4.724 2.133 4.724 4.724v3.048z" />
</svg>
);
}
export default LockIcon;
@@ -0,0 +1,9 @@
function MinusIcon() {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 5">
<path d="M0 0h32v4.2H0z" />
</svg>
);
}
export default MinusIcon;
@@ -0,0 +1,9 @@
function PlusIcon() {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<path d="M32 18.133H18.133V32h-4.266V18.133H0v-4.266h13.867V0h4.266v13.867H32z" />
</svg>
);
}
export default PlusIcon;
@@ -0,0 +1,9 @@
function UnlockIcon() {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 25 32">
<path d="M21.333 10.667H19.81V7.619C19.81 3.429 16.38 0 12.19 0c-4.114 1.828-1.37 2.133.305 2.438 1.676.305 4.42 2.59 4.42 5.181v3.048H3.047A3.056 3.056 0 000 13.714v15.238A3.056 3.056 0 003.048 32h18.285a3.056 3.056 0 003.048-3.048V13.714a3.056 3.056 0 00-3.048-3.047zM12.19 24.533a3.056 3.056 0 01-3.047-3.047 3.056 3.056 0 013.047-3.048 3.056 3.056 0 013.048 3.048 3.056 3.056 0 01-3.048 3.047z" />
</svg>
);
}
export default UnlockIcon;
@@ -0,0 +1,3 @@
export { default as Controls } from './Controls';
export { default as ControlButton } from './ControlButton';
export * from './types';
@@ -0,0 +1,18 @@
import type { ButtonHTMLAttributes, HTMLAttributes } from 'react';
import type { PanelPosition } from '@xyflow/system';
import type { FitViewOptions } from '../../types';
export type ControlProps = HTMLAttributes<HTMLDivElement> & {
showZoom?: boolean;
showFitView?: boolean;
showInteractive?: boolean;
fitViewOptions?: FitViewOptions;
onZoomIn?: () => void;
onZoomOut?: () => void;
onFitView?: () => void;
onInteractiveChange?: (interactiveStatus: boolean) => void;
position?: PanelPosition;
};
export type ControlButtonProps = ButtonHTMLAttributes<HTMLButtonElement>;
@@ -0,0 +1,229 @@
/* eslint-disable @typescript-eslint/ban-ts-comment */
/* eslint-disable @typescript-eslint/no-explicit-any */
import { memo, useEffect, useRef, type MouseEvent } from 'react';
import cc from 'classcat';
import { shallow } from 'zustand/shallow';
import { type D3ZoomEvent, zoom } from 'd3-zoom';
import { select, pointer } from 'd3-selection';
import {
getRectOfNodes,
getBoundsOfRects,
getNodePositionWithOrigin,
CoordinateExtent,
type Rect,
} from '@xyflow/system';
import { useStore, useStoreApi } from '../../hooks/useStore';
import Panel from '../../components/Panel';
import type { ReactFlowState } from '../../types';
import MiniMapNode from './MiniMapNode';
import type { MiniMapProps, GetMiniMapNodeAttribute } from './types';
declare const window: any;
const defaultWidth = 200;
const defaultHeight = 150;
const selector = (s: ReactFlowState) => {
const nodes = s.getNodes();
const viewBB: Rect = {
x: -s.transform[0] / s.transform[2],
y: -s.transform[1] / s.transform[2],
width: s.width / s.transform[2],
height: s.height / s.transform[2],
};
return {
nodes: nodes.filter((node) => !node.hidden && node.width && node.height),
viewBB,
boundingRect: nodes.length > 0 ? getBoundsOfRects(getRectOfNodes(nodes, s.nodeOrigin), viewBB) : viewBB,
rfId: s.rfId,
nodeOrigin: s.nodeOrigin,
};
};
const getAttrFunction = (func: any): GetMiniMapNodeAttribute => (func instanceof Function ? func : () => func);
const ARIA_LABEL_KEY = 'react-flow__minimap-desc';
function MiniMap({
style,
className,
nodeStrokeColor = 'transparent',
nodeColor = '#e2e2e2',
nodeClassName = '',
nodeBorderRadius = 5,
nodeStrokeWidth = 2,
// We need to rename the prop to be `CapitalCase` so that JSX will render it as
// a component properly.
nodeComponent: NodeComponent = MiniMapNode,
maskColor = 'rgb(240, 240, 240, 0.6)',
maskStrokeColor = 'none',
maskStrokeWidth = 1,
position = 'bottom-right',
onClick,
onNodeClick,
pannable = false,
zoomable = false,
ariaLabel = 'React Flow mini map',
inversePan = false,
zoomStep = 10,
}: MiniMapProps) {
const store = useStoreApi();
const svg = useRef<SVGSVGElement>(null);
const { boundingRect, viewBB, nodes, rfId, nodeOrigin } = useStore(selector, shallow);
const elementWidth = (style?.width as number) ?? defaultWidth;
const elementHeight = (style?.height as number) ?? defaultHeight;
const nodeColorFunc = getAttrFunction(nodeColor);
const nodeStrokeColorFunc = getAttrFunction(nodeStrokeColor);
const nodeClassNameFunc = getAttrFunction(nodeClassName);
const scaledWidth = boundingRect.width / elementWidth;
const scaledHeight = boundingRect.height / elementHeight;
const viewScale = Math.max(scaledWidth, scaledHeight);
const viewWidth = viewScale * elementWidth;
const viewHeight = viewScale * elementHeight;
const offset = 5 * viewScale;
const x = boundingRect.x - (viewWidth - boundingRect.width) / 2 - offset;
const y = boundingRect.y - (viewHeight - boundingRect.height) / 2 - offset;
const width = viewWidth + offset * 2;
const height = viewHeight + offset * 2;
const shapeRendering = typeof window === 'undefined' || !!window.chrome ? 'crispEdges' : 'geometricPrecision';
const labelledBy = `${ARIA_LABEL_KEY}-${rfId}`;
const viewScaleRef = useRef(0);
viewScaleRef.current = viewScale;
useEffect(() => {
if (svg.current) {
const selection = select(svg.current as Element);
const zoomHandler = (event: D3ZoomEvent<SVGSVGElement, any>) => {
const { transform, panZoom } = store.getState();
if (event.sourceEvent.type !== 'wheel' || !panZoom) {
return;
}
const pinchDelta =
-event.sourceEvent.deltaY *
(event.sourceEvent.deltaMode === 1 ? 0.05 : event.sourceEvent.deltaMode ? 1 : 0.002) *
zoomStep;
const nextZoom = transform[2] * Math.pow(2, pinchDelta);
panZoom.scaleTo(nextZoom);
};
const panHandler = (event: D3ZoomEvent<HTMLDivElement, any>) => {
const { transform, panZoom, translateExtent, width, height } = store.getState();
if (event.sourceEvent.type !== 'mousemove' || !panZoom) {
return;
}
// @TODO: how to calculate the correct next position? Math.max(1, transform[2]) is a workaround.
const moveScale = viewScaleRef.current * Math.max(1, transform[2]) * (inversePan ? -1 : 1);
const position = {
x: transform[0] - event.sourceEvent.movementX * moveScale,
y: transform[1] - event.sourceEvent.movementY * moveScale,
};
const extent: CoordinateExtent = [
[0, 0],
[width, height],
];
panZoom.setViewportConstrained(
{
x: position.x,
y: position.y,
zoom: transform[2],
},
extent,
translateExtent
);
};
const zoomAndPanHandler = zoom()
// @ts-ignore
.on('zoom', pannable ? panHandler : null)
// @ts-ignore
.on('zoom.wheel', zoomable ? zoomHandler : null);
selection.call(zoomAndPanHandler, {});
return () => {
selection.on('zoom', null);
};
}
}, [pannable, zoomable, inversePan, zoomStep]);
const onSvgClick = onClick
? (event: MouseEvent) => {
const rfCoord = pointer(event);
onClick(event, { x: rfCoord[0], y: rfCoord[1] });
}
: undefined;
const onSvgNodeClick = onNodeClick
? (event: MouseEvent, nodeId: string) => {
const node = store.getState().nodeInternals.get(nodeId)!;
onNodeClick(event, node);
}
: undefined;
return (
<Panel
position={position}
style={style}
className={cc(['react-flow__minimap', className])}
data-testid="rf__minimap"
>
<svg
width={elementWidth}
height={elementHeight}
viewBox={`${x} ${y} ${width} ${height}`}
role="img"
aria-labelledby={labelledBy}
ref={svg}
onClick={onSvgClick}
>
{ariaLabel && <title id={labelledBy}>{ariaLabel}</title>}
{nodes.map((node) => {
const { x, y } = getNodePositionWithOrigin(node, node.origin || nodeOrigin).positionAbsolute;
return (
<NodeComponent
key={node.id}
x={x}
y={y}
width={node.width!}
height={node.height!}
style={node.style}
className={nodeClassNameFunc(node)}
color={nodeColorFunc(node)}
borderRadius={nodeBorderRadius}
strokeColor={nodeStrokeColorFunc(node)}
strokeWidth={nodeStrokeWidth}
shapeRendering={shapeRendering}
onClick={onSvgNodeClick}
id={node.id}
/>
);
})}
<path
className="react-flow__minimap-mask"
d={`M${x - offset},${y - offset}h${width + offset * 2}v${height + offset * 2}h${-width - offset * 2}z
M${viewBB.x},${viewBB.y}h${viewBB.width}v${viewBB.height}h${-viewBB.width}z`}
fill={maskColor}
fillRule="evenodd"
stroke={maskStrokeColor}
strokeWidth={maskStrokeWidth}
pointerEvents="none"
/>
</svg>
</Panel>
);
}
MiniMap.displayName = 'MiniMap';
export default memo(MiniMap);
@@ -0,0 +1,43 @@
import { memo } from 'react';
import type { MiniMapNodeProps } from './types';
import cc from 'classcat';
const MiniMapNode = ({
id,
x,
y,
width,
height,
style,
color,
strokeColor,
strokeWidth,
className,
borderRadius,
shapeRendering,
onClick,
}: MiniMapNodeProps) => {
const { background, backgroundColor } = style || {};
const fill = (color || background || backgroundColor) as string;
return (
<rect
className={cc(['react-flow__minimap-node', className])}
x={x}
y={y}
rx={borderRadius}
ry={borderRadius}
width={width}
height={height}
fill={fill}
stroke={strokeColor}
strokeWidth={strokeWidth}
shapeRendering={shapeRendering}
onClick={onClick ? (event) => onClick(event, id) : undefined}
/>
);
};
MiniMapNode.displayName = 'MiniMapNode';
export default memo(MiniMapNode);
@@ -0,0 +1,2 @@
export { default as MiniMap } from './MiniMap';
export * from './types';
@@ -0,0 +1,43 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import type { ComponentType, CSSProperties, HTMLAttributes, MouseEvent } from 'react';
import type { PanelPosition, XYPosition } from '@xyflow/system';
import type { Node } from '../../types';
export type GetMiniMapNodeAttribute<NodeData = any> = (node: Node<NodeData>) => string;
export type MiniMapProps<NodeData = any> = Omit<HTMLAttributes<SVGSVGElement>, 'onClick'> & {
nodeColor?: string | GetMiniMapNodeAttribute<NodeData>;
nodeStrokeColor?: string | GetMiniMapNodeAttribute<NodeData>;
nodeClassName?: string | GetMiniMapNodeAttribute<NodeData>;
nodeBorderRadius?: number;
nodeStrokeWidth?: number;
nodeComponent?: ComponentType<MiniMapNodeProps>;
maskColor?: string;
maskStrokeColor?: string;
maskStrokeWidth?: number;
position?: PanelPosition;
onClick?: (event: MouseEvent, position: XYPosition) => void;
onNodeClick?: (event: MouseEvent, node: Node<NodeData>) => void;
pannable?: boolean;
zoomable?: boolean;
ariaLabel?: string | null;
inversePan?: boolean;
zoomStep?: number;
};
export interface MiniMapNodeProps {
id: string;
x: number;
y: number;
width: number;
height: number;
borderRadius: number;
className: string;
color: string;
shapeRendering: string;
strokeColor: string;
strokeWidth: number;
style?: CSSProperties;
onClick?: (event: MouseEvent, id: string) => void;
}
@@ -0,0 +1,72 @@
import ResizeControl from './ResizeControl';
import { ControlPosition, NodeResizerProps, ResizeControlVariant, ControlLinePosition } from './types';
const handleControls: ControlPosition[] = ['top-left', 'top-right', 'bottom-left', 'bottom-right'];
const lineControls: ControlLinePosition[] = ['top', 'right', 'bottom', 'left'];
export default function NodeResizer({
nodeId,
isVisible = true,
handleClassName,
handleStyle,
lineClassName,
lineStyle,
color,
minWidth = 10,
minHeight = 10,
maxWidth = Number.MAX_VALUE,
maxHeight = Number.MAX_VALUE,
keepAspectRatio = false,
shouldResize,
onResizeStart,
onResize,
onResizeEnd,
}: NodeResizerProps) {
if (!isVisible) {
return null;
}
return (
<>
{lineControls.map((c) => (
<ResizeControl
key={c}
className={lineClassName}
style={lineStyle}
nodeId={nodeId}
position={c}
variant={ResizeControlVariant.Line}
color={color}
minWidth={minWidth}
minHeight={minHeight}
maxWidth={maxWidth}
maxHeight={maxHeight}
onResizeStart={onResizeStart}
keepAspectRatio={keepAspectRatio}
shouldResize={shouldResize}
onResize={onResize}
onResizeEnd={onResizeEnd}
/>
))}
{handleControls.map((c) => (
<ResizeControl
key={c}
className={handleClassName}
style={handleStyle}
nodeId={nodeId}
position={c}
color={color}
minWidth={minWidth}
minHeight={minHeight}
maxWidth={maxWidth}
maxHeight={maxHeight}
onResizeStart={onResizeStart}
keepAspectRatio={keepAspectRatio}
shouldResize={shouldResize}
onResize={onResize}
onResizeEnd={onResizeEnd}
/>
))}
</>
);
}
@@ -0,0 +1,245 @@
import { useRef, useEffect, memo } from 'react';
import cc from 'classcat';
import { drag } from 'd3-drag';
import { select } from 'd3-selection';
import { getPointerPosition, clamp } from '@xyflow/system';
import { useStoreApi } from '../../hooks/useStore';
import { useNodeId } from '../../contexts/NodeIdContext';
import type { NodeChange, NodeDimensionChange, NodePositionChange } from '../../types';
import {
type ResizeDragEvent,
type ResizeControlProps,
type ResizeControlLineProps,
ResizeControlVariant,
} from './types';
import { getDirection } from './utils';
const initPrevValues = { width: 0, height: 0, x: 0, y: 0 };
const initStartValues = {
...initPrevValues,
pointerX: 0,
pointerY: 0,
aspectRatio: 1,
};
function ResizeControl({
nodeId,
position,
variant = ResizeControlVariant.Handle,
className,
style = {},
children,
color,
minWidth = 10,
minHeight = 10,
maxWidth = Number.MAX_VALUE,
maxHeight = Number.MAX_VALUE,
keepAspectRatio = false,
shouldResize,
onResizeStart,
onResize,
onResizeEnd,
}: ResizeControlProps) {
const contextNodeId = useNodeId();
const id = typeof nodeId === 'string' ? nodeId : contextNodeId;
const store = useStoreApi();
const resizeControlRef = useRef<HTMLDivElement>(null);
const startValues = useRef<typeof initStartValues>(initStartValues);
const prevValues = useRef<typeof initPrevValues>(initPrevValues);
const defaultPosition = variant === ResizeControlVariant.Line ? 'right' : 'bottom-right';
const controlPosition = position ?? defaultPosition;
useEffect(() => {
if (!resizeControlRef.current || !id) {
return;
}
const selection = select(resizeControlRef.current);
const enableX = controlPosition.includes('right') || controlPosition.includes('left');
const enableY = controlPosition.includes('bottom') || controlPosition.includes('top');
const invertX = controlPosition.includes('left');
const invertY = controlPosition.includes('top');
const dragHandler = drag<HTMLDivElement, unknown>()
.on('start', (event: ResizeDragEvent) => {
const { nodeInternals, transform, snapGrid, snapToGrid } = store.getState();
const node = nodeInternals.get(id);
const { xSnapped, ySnapped } = getPointerPosition(event.sourceEvent, { transform, snapGrid, snapToGrid });
prevValues.current = {
width: node?.width ?? 0,
height: node?.height ?? 0,
x: node?.position.x ?? 0,
y: node?.position.y ?? 0,
};
startValues.current = {
...prevValues.current,
pointerX: xSnapped,
pointerY: ySnapped,
aspectRatio: prevValues.current.width / prevValues.current.height,
};
onResizeStart?.(event, { ...prevValues.current });
})
.on('drag', (event: ResizeDragEvent) => {
const { nodeInternals, transform, snapGrid, snapToGrid, triggerNodeChanges } = store.getState();
const { xSnapped, ySnapped } = getPointerPosition(event.sourceEvent, { transform, snapGrid, snapToGrid });
const node = nodeInternals.get(id);
if (node) {
const changes: NodeChange[] = [];
const {
pointerX: startX,
pointerY: startY,
width: startWidth,
height: startHeight,
x: startNodeX,
y: startNodeY,
aspectRatio,
} = startValues.current;
const { x: prevX, y: prevY, width: prevWidth, height: prevHeight } = prevValues.current;
const distX = Math.floor(enableX ? xSnapped - startX : 0);
const distY = Math.floor(enableY ? ySnapped - startY : 0);
let width = clamp(startWidth + (invertX ? -distX : distX), minWidth, maxWidth);
let height = clamp(startHeight + (invertY ? -distY : distY), minHeight, maxHeight);
if (keepAspectRatio) {
const nextAspectRatio = width / height;
const isDiagonal = enableX && enableY;
const isHorizontal = enableX && !enableY;
const isVertical = enableY && !enableX;
width = (nextAspectRatio <= aspectRatio && isDiagonal) || isVertical ? height * aspectRatio : width;
height = (nextAspectRatio > aspectRatio && isDiagonal) || isHorizontal ? width / aspectRatio : height;
if (width >= maxWidth) {
width = maxWidth;
height = maxWidth / aspectRatio;
} else if (width <= minWidth) {
width = minWidth;
height = minWidth / aspectRatio;
}
if (height >= maxHeight) {
height = maxHeight;
width = maxHeight * aspectRatio;
} else if (height <= minHeight) {
height = minHeight;
width = minHeight * aspectRatio;
}
}
const isWidthChange = width !== prevWidth;
const isHeightChange = height !== prevHeight;
if (invertX || invertY) {
const x = invertX ? startNodeX - (width - startWidth) : startNodeX;
const y = invertY ? startNodeY - (height - startHeight) : startNodeY;
// only transform the node if the width or height changes
const isXPosChange = x !== prevX && isWidthChange;
const isYPosChange = y !== prevY && isHeightChange;
if (isXPosChange || isYPosChange) {
const positionChange: NodePositionChange = {
id: node.id,
type: 'position',
position: {
x: isXPosChange ? x : prevX,
y: isYPosChange ? y : prevY,
},
};
changes.push(positionChange);
prevValues.current.x = positionChange.position!.x;
prevValues.current.y = positionChange.position!.y;
}
}
if (isWidthChange || isHeightChange) {
const dimensionChange: NodeDimensionChange = {
id: id,
type: 'dimensions',
updateStyle: true,
resizing: true,
dimensions: {
width: width,
height: height,
},
};
changes.push(dimensionChange);
prevValues.current.width = width;
prevValues.current.height = height;
}
if (changes.length === 0) {
return;
}
const direction = getDirection({
width: prevValues.current.width,
prevWidth,
height: prevValues.current.height,
prevHeight,
invertX,
invertY,
});
const nextValues = { ...prevValues.current, direction };
const callResize = shouldResize?.(event, nextValues);
if (callResize === false) {
return;
}
onResize?.(event, nextValues);
triggerNodeChanges(changes);
}
})
.on('end', (event: ResizeDragEvent) => {
const dimensionChange: NodeDimensionChange = {
id: id,
type: 'dimensions',
resizing: false,
};
onResizeEnd?.(event, { ...prevValues.current });
store.getState().triggerNodeChanges([dimensionChange]);
});
selection.call(dragHandler);
return () => {
selection.on('.drag', null);
};
}, [id, controlPosition, minWidth, minHeight, maxWidth, maxHeight, keepAspectRatio]);
const positionClassNames = controlPosition.split('-');
const colorStyleProp = variant === ResizeControlVariant.Line ? 'borderColor' : 'backgroundColor';
const controlStyle = color ? { ...style, [colorStyleProp]: color } : style;
return (
<div
className={cc(['react-flow__resize-control', 'nodrag', ...positionClassNames, variant, className])}
ref={resizeControlRef}
style={controlStyle}
>
{children}
</div>
);
}
export function ResizeControlLine(props: ResizeControlLineProps) {
return <ResizeControl {...props} variant={ResizeControlVariant.Line} />;
}
export default memo(ResizeControl);
@@ -0,0 +1,4 @@
export { default as NodeResizer } from './NodeResizer';
export { default as NodeResizeControl } from './ResizeControl';
export * from './types';
@@ -0,0 +1,75 @@
import type { CSSProperties, ReactNode } from 'react';
import type { D3DragEvent, SubjectPosition } from 'd3-drag';
export type ResizeParams = {
x: number;
y: number;
width: number;
height: number;
};
export type ResizeParamsWithDirection = ResizeParams & {
direction: number[];
};
type OnResizeHandler<Params = ResizeParams, Result = void> = (event: ResizeDragEvent, params: Params) => Result;
export type ShouldResize = OnResizeHandler<ResizeParamsWithDirection, boolean>;
export type OnResizeStart = OnResizeHandler;
export type OnResize = OnResizeHandler<ResizeParamsWithDirection>;
export type OnResizeEnd = OnResizeHandler;
export type NodeResizerProps = {
nodeId?: string;
color?: string;
handleClassName?: string;
handleStyle?: CSSProperties;
lineClassName?: string;
lineStyle?: CSSProperties;
isVisible?: boolean;
minWidth?: number;
minHeight?: number;
maxWidth?: number;
maxHeight?: number;
keepAspectRatio?: boolean;
shouldResize?: ShouldResize;
onResizeStart?: OnResizeStart;
onResize?: OnResize;
onResizeEnd?: OnResizeEnd;
};
export type ControlLinePosition = 'top' | 'bottom' | 'left' | 'right';
export type ControlPosition = ControlLinePosition | 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right';
export enum ResizeControlVariant {
Line = 'line',
Handle = 'handle',
}
export type ResizeControlProps = Pick<
NodeResizerProps,
| 'nodeId'
| 'color'
| 'minWidth'
| 'minHeight'
| 'maxWidth'
| 'maxHeight'
| 'keepAspectRatio'
| 'shouldResize'
| 'onResizeStart'
| 'onResize'
| 'onResizeEnd'
> & {
position?: ControlPosition;
variant?: ResizeControlVariant;
className?: string;
style?: CSSProperties;
children?: ReactNode;
};
export type ResizeControlLineProps = ResizeControlProps & {
position?: ControlLinePosition;
};
export type ResizeDragEvent = D3DragEvent<HTMLDivElement, null, SubjectPosition>;
@@ -0,0 +1,26 @@
type GetDirectionParams = {
width: number;
prevWidth: number;
height: number;
prevHeight: number;
invertX: boolean;
invertY: boolean;
};
// returns an array of two numbers (0, 1 or -1) representing the direction of the resize
// 0 = no change, 1 = increase, -1 = decrease
export function getDirection({ width, prevWidth, height, prevHeight, invertX, invertY }: GetDirectionParams) {
const deltaWidth = width - prevWidth;
const deltaHeight = height - prevHeight;
const direction = [deltaWidth > 0 ? 1 : deltaWidth < 0 ? -1 : 0, deltaHeight > 0 ? 1 : deltaHeight < 0 ? -1 : 0];
if (deltaWidth && invertX) {
direction[0] = direction[0] * -1;
}
if (deltaHeight && invertY) {
direction[1] = direction[1] * -1;
}
return direction;
}
@@ -0,0 +1,127 @@
import { useCallback, CSSProperties } from 'react';
import cc from 'classcat';
import { shallow } from 'zustand/shallow';
import { getRectOfNodes, Transform, Rect, Position, internalsSymbol } from '@xyflow/system';
import { Node, ReactFlowState } from '../../types';
import { useStore } from '../../hooks/useStore';
import { useNodeId } from '../../contexts/NodeIdContext';
import NodeToolbarPortal from './NodeToolbarPortal';
import { Align, NodeToolbarProps } from './types';
const nodeEqualityFn = (a: Node | undefined, b: Node | undefined) =>
a?.positionAbsolute?.x === b?.positionAbsolute?.x &&
a?.positionAbsolute?.y === b?.positionAbsolute?.y &&
a?.width === b?.width &&
a?.height === b?.height &&
a?.selected === b?.selected &&
a?.[internalsSymbol]?.z === b?.[internalsSymbol]?.z;
const nodesEqualityFn = (a: Node[], b: Node[]) => {
return a.length === b.length && a.every((node, i) => nodeEqualityFn(node, b[i]));
};
const storeSelector = (state: ReactFlowState) => ({
transform: state.transform,
nodeOrigin: state.nodeOrigin,
selectedNodesCount: state.getNodes().filter((node) => node.selected).length,
});
function getTransform(nodeRect: Rect, transform: Transform, position: Position, offset: number, align: Align): string {
let alignmentOffset = 0.5;
if (align === 'start') {
alignmentOffset = 0;
} else if (align === 'end') {
alignmentOffset = 1;
}
// position === Position.Top
// we set the x any y position of the toolbar based on the nodes position
let pos = [
(nodeRect.x + nodeRect.width * alignmentOffset) * transform[2] + transform[0],
nodeRect.y * transform[2] + transform[1] - offset,
];
// and than shift it based on the alignment. The shift values are in %.
let shift = [-100 * alignmentOffset, -100];
switch (position) {
case Position.Right:
pos = [
(nodeRect.x + nodeRect.width) * transform[2] + transform[0] + offset,
(nodeRect.y + nodeRect.height * alignmentOffset) * transform[2] + transform[1],
];
shift = [0, -100 * alignmentOffset];
break;
case Position.Bottom:
pos[1] = (nodeRect.y + nodeRect.height) * transform[2] + transform[1] + offset;
shift[1] = 0;
break;
case Position.Left:
pos = [
nodeRect.x * transform[2] + transform[0] - offset,
(nodeRect.y + nodeRect.height * alignmentOffset) * transform[2] + transform[1],
];
shift = [-100, -100 * alignmentOffset];
break;
}
return `translate(${pos[0]}px, ${pos[1]}px) translate(${shift[0]}%, ${shift[1]}%)`;
}
function NodeToolbar({
nodeId,
children,
className,
style,
isVisible,
position = Position.Top,
offset = 10,
align = 'center',
...rest
}: NodeToolbarProps) {
const contextNodeId = useNodeId();
const nodesSelector = useCallback(
(state: ReactFlowState): Node[] => {
const nodeIds = Array.isArray(nodeId) ? nodeId : [nodeId || contextNodeId || ''];
return nodeIds.reduce<Node[]>((acc, id) => {
const node = state.nodeInternals.get(id);
if (node) {
acc.push(node);
}
return acc;
}, [] as Node[]);
},
[nodeId, contextNodeId]
);
const nodes = useStore(nodesSelector, nodesEqualityFn);
const { transform, nodeOrigin, selectedNodesCount } = useStore(storeSelector, shallow);
const isActive =
typeof isVisible === 'boolean' ? isVisible : nodes.length === 1 && nodes[0].selected && selectedNodesCount === 1;
if (!isActive || !nodes.length) {
return null;
}
const nodeRect: Rect = getRectOfNodes(nodes, nodeOrigin);
const zIndex: number = Math.max(...nodes.map((node) => (node[internalsSymbol]?.z || 1) + 1));
const wrapperStyle: CSSProperties = {
position: 'absolute',
transform: getTransform(nodeRect, transform, position, offset, align),
zIndex,
...style,
};
return (
<NodeToolbarPortal>
<div style={wrapperStyle} className={cc(['react-flow__node-toolbar', className])} {...rest}>
{children}
</div>
</NodeToolbarPortal>
);
}
export default NodeToolbar;
@@ -0,0 +1,19 @@
import { ReactNode } from 'react';
import { createPortal } from 'react-dom';
import type { ReactFlowState } from '../../types';
import { useStore } from '../../hooks/useStore';
const selector = (state: ReactFlowState) => state.domNode?.querySelector('.react-flow__renderer');
function NodeToolbarPortal({ children }: { children: ReactNode }) {
const wrapperRef = useStore(selector);
if (!wrapperRef) {
return null;
}
return createPortal(children, wrapperRef);
}
export default NodeToolbarPortal;
@@ -0,0 +1,2 @@
export { default as NodeToolbar } from './NodeToolbar';
export * from './types';
@@ -0,0 +1,12 @@
import type { HTMLAttributes } from 'react';
import type { Position } from '@xyflow/system';
export type NodeToolbarProps = HTMLAttributes<HTMLDivElement> & {
nodeId?: string | string[];
isVisible?: boolean;
position?: Position;
offset?: number;
align?: Align;
};
export type Align = 'center' | 'start' | 'end';
@@ -0,0 +1,5 @@
export * from './Background';
export * from './Controls';
export * from './MiniMap';
export * from './NodeResizer';
export * from './NodeToolbar';