refactor(packages): change package structure
This commit is contained in:
@@ -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';
|
||||
Reference in New Issue
Block a user