Merge pull request #2563 from wbkd/feat/node-toolbar
feat(node-toolbar): add node-toolbar component
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
extends: ['@reactflow/eslint-config'],
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
# @reactflow/node-toolbar
|
||||
@@ -0,0 +1,10 @@
|
||||
# @reactflow/node-toolbar
|
||||
|
||||
A toolbar component for React Flow that can be attached to a node.
|
||||
|
||||
## Installation
|
||||
|
||||
```sh
|
||||
npm install @reactflow/node-toolbar
|
||||
```
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
{
|
||||
"name": "@reactflow/node-toolbar",
|
||||
"version": "1.0.0",
|
||||
"description": "A toolbar component for React Flow that can be attached to a node.",
|
||||
"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-toolbar"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "concurrently \"rollup --config node:@reactflow/rollup-config --watch\" pnpm:css-watch",
|
||||
"build": "rollup --config node:@reactflow/rollup-config --environment NODE_ENV:production",
|
||||
"lint": "eslint --ext .js,.jsx,.ts,.tsx src",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.18.9",
|
||||
"@reactflow/core": "workspace:*",
|
||||
"classcat": "^5.0.3",
|
||||
"zustand": "^4.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=17",
|
||||
"react-dom": ">=17"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@reactflow/eslint-config": "workspace:^0.0.0",
|
||||
"@reactflow/rollup-config": "workspace:*",
|
||||
"@reactflow/tsconfig": "workspace:*",
|
||||
"@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": "ReactFlowNodeToolbar"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { useCallback, CSSProperties } from 'react';
|
||||
import {
|
||||
Node,
|
||||
ReactFlowState,
|
||||
useStore,
|
||||
getRectOfNodes,
|
||||
Transform,
|
||||
Rect,
|
||||
Position,
|
||||
internalsSymbol,
|
||||
} from '@reactflow/core';
|
||||
import cc from 'classcat';
|
||||
import shallow from 'zustand/shallow';
|
||||
|
||||
import NodeToolbarPortal from './NodeToolbarPortal';
|
||||
import { NodeToolbarProps } from './types';
|
||||
|
||||
type SelectedNode = Node | undefined;
|
||||
|
||||
const nodeEqualityFn = (a: SelectedNode, b: SelectedNode) =>
|
||||
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 storeSelector = (state: ReactFlowState) => ({
|
||||
transform: state.transform,
|
||||
nodeOrigin: state.nodeOrigin,
|
||||
selectedNodesCount: Array.from(state.nodeInternals.values()).filter((node) => node.selected).length,
|
||||
});
|
||||
|
||||
function getTransform(nodeRect: Rect, transform: Transform, position: Position, offset: number): string {
|
||||
// position === Position.Top
|
||||
let xPos = (nodeRect.x + nodeRect.width / 2) * transform[2] + transform[0];
|
||||
let yPos = nodeRect.y * transform[2] + transform[1] - offset;
|
||||
let xShift = -50;
|
||||
let yShift = -100;
|
||||
|
||||
switch (position) {
|
||||
case Position.Right:
|
||||
xPos = (nodeRect.x + nodeRect.width) * transform[2] + transform[0] + offset;
|
||||
yPos = (nodeRect.y + nodeRect.height / 2) * transform[2] + transform[1];
|
||||
xShift = 0;
|
||||
yShift = -50;
|
||||
break;
|
||||
case Position.Bottom:
|
||||
yPos = (nodeRect.y + nodeRect.height) * transform[2] + transform[1] + offset;
|
||||
yShift = 0;
|
||||
break;
|
||||
case Position.Left:
|
||||
xPos = nodeRect.x * transform[2] + transform[0] - offset;
|
||||
yPos = (nodeRect.y + nodeRect.height / 2) * transform[2] + transform[1];
|
||||
xShift = -100;
|
||||
yShift = -50;
|
||||
break;
|
||||
}
|
||||
|
||||
return `translate(${xPos}px, ${yPos}px) translate(${xShift}%, ${yShift}%)`;
|
||||
}
|
||||
|
||||
function NodeToolbar({
|
||||
nodeId,
|
||||
children,
|
||||
className,
|
||||
style,
|
||||
isVisible,
|
||||
position = Position.Top,
|
||||
offset = 10,
|
||||
...rest
|
||||
}: NodeToolbarProps) {
|
||||
const nodeSelector = useCallback((state: ReactFlowState): SelectedNode => state.nodeInternals.get(nodeId), [nodeId]);
|
||||
const node = useStore(nodeSelector, nodeEqualityFn);
|
||||
const { transform, nodeOrigin, selectedNodesCount } = useStore(storeSelector, shallow);
|
||||
const isActive = typeof isVisible === 'boolean' ? isVisible : node?.selected && selectedNodesCount === 1;
|
||||
|
||||
if (!isActive || !node) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const nodeRect: Rect = getRectOfNodes([node], nodeOrigin);
|
||||
|
||||
const wrapperStyle: CSSProperties = {
|
||||
position: 'absolute',
|
||||
transform: getTransform(nodeRect, transform, position, offset),
|
||||
zIndex: (node[internalsSymbol]?.z || 1) + 1,
|
||||
...style,
|
||||
};
|
||||
|
||||
return (
|
||||
<NodeToolbarPortal>
|
||||
<div style={wrapperStyle} className={cc(['react-flow__node-toolbar', className])} {...rest}>
|
||||
{children}
|
||||
</div>
|
||||
</NodeToolbarPortal>
|
||||
);
|
||||
}
|
||||
|
||||
export default NodeToolbar;
|
||||
@@ -0,0 +1,17 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { ReactFlowState, useStore } from '@reactflow/core';
|
||||
|
||||
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,9 @@
|
||||
import { Position } from '@reactflow/core';
|
||||
import type { HTMLAttributes } from 'react';
|
||||
|
||||
export type NodeToolbarProps = HTMLAttributes<HTMLDivElement> & {
|
||||
nodeId: string;
|
||||
isVisible?: boolean;
|
||||
position?: Position;
|
||||
offset?: number;
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"extends": "@reactflow/tsconfig/react.json",
|
||||
"display": "@reactflow/node-toolbar",
|
||||
"include": ["**/*.ts", "**/*.tsx"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
@@ -38,7 +38,8 @@
|
||||
"@reactflow/background": "workspace:*",
|
||||
"@reactflow/controls": "workspace:*",
|
||||
"@reactflow/core": "workspace:*",
|
||||
"@reactflow/minimap": "workspace:*"
|
||||
"@reactflow/minimap": "workspace:*",
|
||||
"@reactflow/node-toolbar": "workspace:*"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=17",
|
||||
@@ -58,7 +59,8 @@
|
||||
"@reactflow/background": "ReactFlowBackground",
|
||||
"@reactflow/controls": "ReactFlowControls",
|
||||
"@reactflow/core": "ReactFlowCore",
|
||||
"@reactflow/minimap": "ReactFlowMinimap"
|
||||
"@reactflow/minimap": "ReactFlowMinimap",
|
||||
"@reactflow/node-toolbar": "ReactFlowNodeToolbar"
|
||||
},
|
||||
"name": "ReactFlow"
|
||||
}
|
||||
|
||||
@@ -2,5 +2,6 @@ export * from '@reactflow/core';
|
||||
export * from '@reactflow/minimap';
|
||||
export * from '@reactflow/controls';
|
||||
export * from '@reactflow/background';
|
||||
export * from '@reactflow/node-toolbar';
|
||||
|
||||
export { ReactFlow as default } from '@reactflow/core';
|
||||
|
||||
Reference in New Issue
Block a user