Merge pull request #2626 from wbkd/feat/resize-node

Feat: Add NodeResizer package
This commit is contained in:
Moritz Klack
2022-12-06 18:17:27 +01:00
committed by GitHub
36 changed files with 859 additions and 130 deletions
@@ -1,9 +1,9 @@
import { memo, useContext, HTMLAttributes, forwardRef, MouseEvent as ReactMouseEvent } from 'react';
import { memo, 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 { useNodeId } from '../../contexts/NodeIdContext';
import { checkElementBelowIsValid, handleMouseDown } from './handler';
import { getHostForElement } from '../../utils';
import { addEdge } from '../../utils/graph';
@@ -37,7 +37,9 @@ const Handle = forwardRef<HTMLDivElement, HandleComponentProps>(
ref
) => {
const store = useStoreApi();
const nodeId = useContext(NodeIdContext) as string;
// @fixme: remove type assertion and handle nodeId === null
const nodeId = useNodeId() as string;
const { connectionStartHandle, connectOnClick, noPanClassName } = useStore(selector, shallow);
const handleId = id || null;
@@ -57,6 +57,7 @@ const NodeRenderer = (props: NodeRendererProps) => {
nodeElement: entry.target as HTMLDivElement,
forceUpdate: true,
}));
updateNodeDimensions(updates);
});
+6 -1
View File
@@ -1,7 +1,12 @@
import { createContext } from 'react';
import { createContext, useContext } from 'react';
export const NodeIdContext = createContext<string | null>(null);
export const Provider = NodeIdContext.Provider;
export const Consumer = NodeIdContext.Consumer;
export const useNodeId = (): string | null => {
const nodeId = useContext(NodeIdContext);
return nodeId;
};
export default NodeIdContext;
+4 -22
View File
@@ -1,15 +1,14 @@
import { useEffect, useRef, useState, useCallback } from 'react';
import { useEffect, useRef, useState } from 'react';
import type { RefObject, MouseEvent } from 'react';
import { drag } from 'd3-drag';
import { select } from 'd3-selection';
import type { D3DragEvent, SubjectPosition } from 'd3';
import { useStoreApi } from '../../hooks/useStore';
import { getDragItems, getEventHandlerParams, hasSelector, calcNextPosition } from './utils';
import { handleNodeClick } from '../../components/Nodes/utils';
import type { NodeDragItem, Node, SelectionDragHandler } from '../../types';
import useGetPointerPosition from '../useGetPointerPosition';
import type { NodeDragItem, Node, SelectionDragHandler, UseDragEvent } from '../../types';
export type UseDragEvent = D3DragEvent<HTMLDivElement, null, SubjectPosition>;
export type UseDragData = { dx: number; dy: number };
type UseDragParams = {
@@ -40,24 +39,7 @@ function useDrag({
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,
};
}, []);
const getPointerPosition = useGetPointerPosition();
useEffect(() => {
if (nodeRef?.current) {
@@ -0,0 +1,31 @@
import { useCallback } from 'react';
import { useStoreApi } from './useStore';
import type { UseDragEvent } from '../types';
function useGetPointerPosition() {
const store = useStoreApi();
// 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,
};
}, []);
return getPointerPosition;
}
export default useGetPointerPosition;
+2
View File
@@ -38,5 +38,7 @@ 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 { default as useGetPointerPosition } from './hooks/useGetPointerPosition';
export { useNodeId } from './contexts/NodeIdContext';
export * from './types';
+30 -24
View File
@@ -17,6 +17,7 @@ import type {
NodePositionChange,
NodeDragItem,
UnselectNodesAndEdgesParams,
NodeChange,
} from '../types';
const createRFStore = () =>
@@ -103,35 +104,40 @@ const createRFStore = () =>
}
},
updateNodePositions: (nodeDragItems: NodeDragItem[] | Node[], positionChanged = true, dragging = false) => {
const { triggerNodeChanges } = get();
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;
});
triggerNodeChanges(changes);
},
triggerNodeChanges: (changes: NodeChange[]) => {
const { onNodesChange, nodeInternals, hasDefaultNodes, nodeOrigin } = 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, nodeOrigin);
set({ nodeInternals: nextNodeInternals });
}
onNodesChange?.(changes);
if (changes?.length) {
if (hasDefaultNodes) {
const nodes = applyNodeChanges(changes, Array.from(nodeInternals.values()));
const nextNodeInternals = createNodeInternals(nodes, nodeInternals, nodeOrigin);
set({ nodeInternals: nextNodeInternals });
}
onNodesChange?.(changes);
}
},
addSelectedNodes: (selectedNodeIds: string[]) => {
const { multiSelectionActive, nodeInternals, edges } = get();
let changedNodes: NodeSelectionChange[];
+3 -1
View File
@@ -7,7 +7,9 @@ import type { Edge } from './edges';
export type NodeDimensionChange = {
id: string;
type: 'dimensions';
dimensions: Dimensions;
dimensions?: Dimensions;
updateStyle?: boolean;
resizing?: boolean;
};
export type NodePositionChange = {
+5 -2
View File
@@ -1,9 +1,9 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import type { MouseEvent as ReactMouseEvent, ComponentType, MemoExoticComponent } from 'react';
import type { Selection as D3Selection, ZoomBehavior } from 'd3';
import type { D3DragEvent, Selection as D3Selection, SubjectPosition, ZoomBehavior } from 'd3';
import type { XYPosition, Rect, Transform, CoordinateExtent } from './utils';
import type { NodeChange, EdgeChange } from './changes';
import type { NodeChange, EdgeChange, NodePositionChange } from './changes';
import type {
Node,
NodeInternals,
@@ -226,6 +226,7 @@ export type ReactFlowActions = {
setNodeExtent: (nodeExtent: CoordinateExtent) => void;
cancelConnection: () => void;
reset: () => void;
triggerNodeChanges: (changes: NodeChange[]) => void;
};
export type ReactFlowState = ReactFlowStore & ReactFlowActions;
@@ -245,3 +246,5 @@ export type ProOptions = {
account?: string;
hideAttribution: boolean;
};
export type UseDragEvent = D3DragEvent<HTMLDivElement, null, SubjectPosition>;
+1
View File
@@ -31,6 +31,7 @@ export type Node<T = any> = {
positionAbsolute?: XYPosition;
ariaLabel?: string;
focusable?: boolean;
resizing?: boolean;
// only used internally
[internalsSymbol]?: {
+49 -40
View File
@@ -50,58 +50,67 @@ function applyChanges(changes: any[], elements: any[]): any[] {
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);
const currentChanges = changes.filter((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 (currentChanges.length === 0) {
res.push(item);
return res;
}
if (typeof currentChange.position !== 'undefined') {
updateItem.position = currentChange.position;
const updateItem = { ...item };
for (const currentChange of currentChanges) {
if (currentChange) {
switch (currentChange.type) {
case 'select': {
updateItem.selected = currentChange.selected;
break;
}
case 'position': {
if (typeof currentChange.position !== 'undefined') {
updateItem.position = currentChange.position;
}
if (typeof currentChange.positionAbsolute !== 'undefined') {
updateItem.positionAbsolute = currentChange.positionAbsolute;
if (typeof currentChange.positionAbsolute !== 'undefined') {
updateItem.positionAbsolute = currentChange.positionAbsolute;
}
if (typeof currentChange.dragging !== 'undefined') {
updateItem.dragging = currentChange.dragging;
}
if (updateItem.expandParent) {
handleParentExpand(res, updateItem);
}
break;
}
case 'dimensions': {
if (typeof currentChange.dimensions !== 'undefined') {
updateItem.width = currentChange.dimensions.width;
updateItem.height = currentChange.dimensions.height;
}
if (typeof currentChange.dragging !== 'undefined') {
updateItem.dragging = currentChange.dragging;
if (typeof currentChange.updateStyle !== 'undefined') {
updateItem.style = { ...(updateItem.style || {}), ...currentChange.dimensions };
}
if (typeof currentChange.resizing === 'boolean') {
updateItem.resizing = currentChange.resizing;
}
if (updateItem.expandParent) {
handleParentExpand(res, updateItem);
}
break;
}
if (updateItem.expandParent) {
handleParentExpand(res, updateItem);
case 'remove': {
return res;
}
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);
res.push(updateItem);
return res;
}, initElements);
}
+4
View File
@@ -0,0 +1,4 @@
module.exports = {
root: true,
extends: ['@reactflow/eslint-config'],
};
+1
View File
@@ -0,0 +1 @@
# @reactflow/node-resizer
+10
View File
@@ -0,0 +1,10 @@
# @reactflow/node-resizer
A resizer component for React Flow that can be attached to a node.
## Installation
```sh
npm install @reactflow/node-resizer
```
+71
View File
@@ -0,0 +1,71 @@
{
"name": "@reactflow/node-resizer",
"version": "0.0.0",
"description": "A helper component for resizing nodes.",
"keywords": [
"react",
"node-based UI",
"graph",
"diagram",
"workflow",
"react-flow"
],
"files": [
"dist"
],
"source": "src/index.tsx",
"main": "dist/umd/index.js",
"module": "dist/esm/index.js",
"types": "dist/esm/index.d.ts",
"sideEffects": [
"*.css"
],
"publishConfig": {
"access": "public"
},
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/wbkd/react-flow.git",
"directory": "packages/node-resizer"
},
"scripts": {
"dev": "concurrently \"rollup --config node:@reactflow/rollup-config --watch\" pnpm:css-watch",
"build": "rollup --config node:@reactflow/rollup-config --environment NODE_ENV:production && npm run css",
"css": "postcss src/*.css --config ../../tooling/postcss-config/postcss.config.js --dir dist",
"css-watch": "pnpm css --watch",
"lint": "eslint --ext .js,.jsx,.ts,.tsx src",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@reactflow/core": "workspace:*",
"classcat": "^5.0.4",
"d3-drag": "^3.0.0",
"d3-selection": "^3.0.0",
"zustand": "^4.1.1"
},
"peerDependencies": {
"react": ">=17",
"react-dom": ">=17"
},
"devDependencies": {
"@reactflow/eslint-config": "workspace:*",
"@reactflow/rollup-config": "workspace:*",
"@reactflow/tsconfig": "workspace:*",
"@types/d3-drag": "^3.0.1",
"@types/d3-selection": "^3.0.3",
"@types/node": "^18.7.16",
"@types/react": "^18.0.19",
"@types/react-dom": "^18.0.6",
"react": "^18.2.0",
"typescript": "^4.8.3"
},
"rollup": {
"globals": {
"zustand": "Zustand",
"zustand/shallow": "zustandShallow",
"classcat": "cc"
},
"name": "ReactFlowNodeResizer"
}
}
+45
View File
@@ -0,0 +1,45 @@
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,
}: 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}
/>
))}
{handleControls.map((c) => (
<ResizeControl
key={c}
className={handleClassName}
style={handleStyle}
nodeId={nodeId}
position={c}
color={color}
/>
))}
</>
);
}
+183
View File
@@ -0,0 +1,183 @@
import { useRef, useEffect, memo } from 'react';
import cc from 'classcat';
import { drag } from 'd3-drag';
import { select } from 'd3-selection';
import {
useStoreApi,
useGetPointerPosition,
NodeChange,
NodeDimensionChange,
useNodeId,
NodePositionChange,
} from '@reactflow/core';
import type { Dimensions, XYPosition } from '@reactflow/core';
import { ResizeDragEvent, ResizeControlProps, ResizeControlLineProps, ResizeControlVariant } from './types';
function ResizeControl({
nodeId,
position,
variant = ResizeControlVariant.Handle,
className,
style = {},
children,
color,
minWidth = 1,
minHeight = 1,
}: ResizeControlProps) {
const contextNodeId = useNodeId();
const id = typeof nodeId === 'string' ? nodeId : contextNodeId;
const store = useStoreApi();
const resizeControlRef = useRef<HTMLDivElement>(null);
const startValues = useRef<Dimensions & XYPosition & { nodeX: number; nodeY: number }>({
width: 0,
height: 0,
x: 0,
y: 0,
nodeX: 0,
nodeY: 0,
});
const prevValues = useRef<Dimensions & XYPosition>({ width: 0, height: 0, x: 0, y: 0 });
const getPointerPosition = useGetPointerPosition();
const defaultPosition = variant === ResizeControlVariant.Line ? 'right' : 'bottom-right';
const controlPosition = position ?? defaultPosition;
useEffect(() => {
if (!resizeControlRef.current || !id) {
return;
}
const selection = select(resizeControlRef.current);
const dragHandler = drag<HTMLDivElement, unknown>()
.on('start', (event: ResizeDragEvent) => {
const node = store.getState().nodeInternals.get(id);
const { xSnapped, ySnapped } = getPointerPosition(event);
startValues.current = {
width: node?.width ?? 0,
height: node?.height ?? 0,
nodeX: node?.position.x ?? 0,
nodeY: node?.position.y ?? 0,
x: xSnapped,
y: ySnapped,
};
prevValues.current = {
width: node?.width ?? 0,
height: node?.height ?? 0,
x: node?.position.x ?? 0,
y: node?.position.y ?? 0,
};
})
.on('drag', (event: ResizeDragEvent) => {
const { nodeInternals, triggerNodeChanges } = store.getState();
const { xSnapped, ySnapped } = getPointerPosition(event);
const node = nodeInternals.get(id);
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');
if (node) {
const changes: NodeChange[] = [];
const {
x: startX,
y: startY,
width: startWidth,
height: startHeight,
nodeX: startNodeX,
nodeY: startNodeY,
} = 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);
const width = Math.max(startWidth + (invertX ? -distX : distX), minWidth);
const height = Math.max(startHeight + (invertY ? -distY : distY), minHeight);
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;
}
triggerNodeChanges(changes);
}
})
.on('end', () => {
const { triggerNodeChanges } = store.getState();
const dimensionChange: NodeDimensionChange = {
id: id,
type: 'dimensions',
resizing: true,
};
triggerNodeChanges([dimensionChange]);
});
selection.call(dragHandler);
return () => {
selection.on('.drag', null);
};
}, [id, controlPosition, getPointerPosition]);
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);
+4
View File
@@ -0,0 +1,4 @@
export { default as NodeResizer } from './NodeResizer';
export { default as NodeResizeControl } from './ResizeControl';
export * from './types';
+103
View File
@@ -0,0 +1,103 @@
.react-flow__resize-control {
position: absolute;
}
.react-flow__resize-control.left,
.react-flow__resize-control.right {
cursor: ew-resize;
}
.react-flow__resize-control.top,
.react-flow__resize-control.bottom {
cursor: ns-resize;
}
.react-flow__resize-control.top.left,
.react-flow__resize-control.bottom.right {
cursor: nwse-resize;
}
.react-flow__resize-control.bottom.left,
.react-flow__resize-control.top.right {
cursor: nesw-resize;
}
/* handle styles */
.react-flow__resize-control.handle {
width: 4px;
height: 4px;
border: 1px solid #fff;
border-radius: 1px;
background-color: #3367d9;
transform: translate(-50%, -50%);
}
.react-flow__resize-control.handle.left {
left: 0;
top: 50%;
}
.react-flow__resize-control.handle.right {
left: 100%;
top: 50%;
}
.react-flow__resize-control.handle.top {
left: 50%;
top: 0;
}
.react-flow__resize-control.handle.bottom {
left: 50%;
top: 100%;
}
.react-flow__resize-control.handle.top.left {
left: 0;
}
.react-flow__resize-control.handle.bottom.left {
left: 0;
}
.react-flow__resize-control.handle.top.right {
left: 100%;
}
.react-flow__resize-control.handle.bottom.right {
left: 100%;
}
/* line styles */
.react-flow__resize-control.line {
border-color: #3367d9;
border-width: 0;
border-style: solid;
}
.react-flow__resize-control.line.left,
.react-flow__resize-control.line.right {
width: 1px;
transform: translate(-50%, 0);
top: 0;
height: 100%;
}
.react-flow__resize-control.line.left {
left: 0;
border-left-width: 1px;
}
.react-flow__resize-control.line.right {
left: 100%;
border-right-width: 1px;
}
.react-flow__resize-control.line.top,
.react-flow__resize-control.line.bottom {
height: 1px;
transform: translate(0, -50%);
left: 0;
width: 100%;
}
.react-flow__resize-control.line.top {
top: 0;
border-top-width: 1px;
}
.react-flow__resize-control.line.bottom {
border-bottom-width: 1px;
top: 100%;
}
+39
View File
@@ -0,0 +1,39 @@
import type { CSSProperties, ReactNode } from 'react';
import type { D3DragEvent, SubjectPosition } from 'd3-drag';
export type NodeResizerProps = {
nodeId?: string;
color?: string;
handleClassName?: string;
handleStyle?: CSSProperties;
lineClassName?: string;
lineStyle?: CSSProperties;
isVisible?: boolean;
};
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 = {
nodeId?: string;
position?: ControlPosition;
variant?: ResizeControlVariant;
color?: string;
className?: string;
style?: CSSProperties;
children?: ReactNode;
minWidth?: number;
minHeight?: number;
};
export type ResizeControlLineProps = ResizeControlProps & {
position: ControlLinePosition;
};
export type ResizeDragEvent = D3DragEvent<HTMLDivElement, null, SubjectPosition>;
+6
View File
@@ -0,0 +1,6 @@
{
"extends": "@reactflow/tsconfig/react.json",
"display": "@reactflow/node-resizer",
"include": ["**/*.ts", "**/*.tsx"],
"exclude": ["node_modules", "dist"]
}
+5 -2
View File
@@ -8,6 +8,7 @@ import {
Rect,
Position,
internalsSymbol,
useNodeId,
} from '@reactflow/core';
import cc from 'classcat';
import shallow from 'zustand/shallow';
@@ -72,9 +73,11 @@ function NodeToolbar({
offset = 10,
...rest
}: NodeToolbarProps) {
const contextNodeId = useNodeId();
const nodesSelector = useCallback(
(state: ReactFlowState): Node[] => {
const nodeIds: string[] = typeof nodeId === 'string' ? [nodeId] : nodeId;
const nodeIds = Array.isArray(nodeId) ? nodeId : [nodeId || contextNodeId || ''];
return nodeIds.reduce<Node[]>((acc, id) => {
const node = state.nodeInternals.get(id);
@@ -84,7 +87,7 @@ function NodeToolbar({
return acc;
}, [] as Node[]);
},
[nodeId]
[nodeId, contextNodeId]
);
const nodes = useStore(nodesSelector, nodesEqualityFn);
const { transform, nodeOrigin, selectedNodesCount } = useStore(storeSelector, shallow);
+1 -1
View File
@@ -2,7 +2,7 @@ import { Position } from '@reactflow/core';
import type { HTMLAttributes } from 'react';
export type NodeToolbarProps = HTMLAttributes<HTMLDivElement> & {
nodeId: string | string[];
nodeId?: string | string[];
isVisible?: boolean;
position?: Position;
offset?: number;