diff --git a/examples/vite-app/src/examples/NodeResizer/CustomResizer.tsx b/examples/vite-app/src/examples/NodeResizer/CustomResizer.tsx index 7ad2e3a3..996e24c5 100644 --- a/examples/vite-app/src/examples/NodeResizer/CustomResizer.tsx +++ b/examples/vite-app/src/examples/NodeResizer/CustomResizer.tsx @@ -13,7 +13,7 @@ const controlStyle = { const CustomNode: FC = ({ id, data }) => { return ( <> - + diff --git a/examples/vite-app/src/examples/NodeResizer/CustomResizer2.tsx b/examples/vite-app/src/examples/NodeResizer/CustomResizer2.tsx index c25a01b6..27833556 100644 --- a/examples/vite-app/src/examples/NodeResizer/CustomResizer2.tsx +++ b/examples/vite-app/src/examples/NodeResizer/CustomResizer2.tsx @@ -7,10 +7,10 @@ import '@reactflow/node-resizer/dist/style.css'; const CustomNode: FC = ({ id, data }) => { return ( <> - - + + -
{data.label}
+
{data.label}
); diff --git a/examples/vite-app/src/examples/NodeResizer/NodeResizerNode.tsx b/examples/vite-app/src/examples/NodeResizer/NodeResizerNode.tsx index 3fab7d5d..3a68cd9f 100644 --- a/examples/vite-app/src/examples/NodeResizer/NodeResizerNode.tsx +++ b/examples/vite-app/src/examples/NodeResizer/NodeResizerNode.tsx @@ -4,10 +4,10 @@ import { Handle, Position, NodeProps } from 'reactflow'; import { NodeResizer } from '@reactflow/node-resizer'; import '@reactflow/node-resizer/dist/style.css'; -const CustomNode: FC = ({ id, data }) => { +const CustomNode: FC = ({ id, data, selected }) => { return ( <> - +
{data.label}
diff --git a/examples/vite-app/src/examples/NodeResizer/ResizeIcon.tsx b/examples/vite-app/src/examples/NodeResizer/ResizeIcon.tsx index f37b9a5d..8db1cb92 100644 --- a/examples/vite-app/src/examples/NodeResizer/ResizeIcon.tsx +++ b/examples/vite-app/src/examples/NodeResizer/ResizeIcon.tsx @@ -5,11 +5,11 @@ function ResizeIcon() { width="8" height="8" viewBox="0 0 24 24" - stroke-width="2" + strokeWidth="2" stroke="currentColor" fill="none" - stroke-linecap="round" - stroke-linejoin="round" + strokeLinecap="round" + strokeLinejoin="round" style={{ position: 'absolute', right: 2, bottom: 2 }} > diff --git a/examples/vite-app/src/examples/NodeResizer/index.tsx b/examples/vite-app/src/examples/NodeResizer/index.tsx index 3f3b982a..43f978ac 100644 --- a/examples/vite-app/src/examples/NodeResizer/index.tsx +++ b/examples/vite-app/src/examples/NodeResizer/index.tsx @@ -1,5 +1,5 @@ -import { useCallback } from 'react'; -import ReactFlow, { Controls, addEdge, Position, Connection, useNodesState, useEdgesState } from 'reactflow'; +import { CSSProperties, useCallback, useState } from 'react'; +import ReactFlow, { Controls, addEdge, Position, Connection, useNodesState, useEdgesState, Panel } from 'reactflow'; import NodeResizerNode from './NodeResizerNode'; import CustomResizer from './CustomResizer'; @@ -32,25 +32,40 @@ const initialNodes = [ type: 'resizer', data: { label: 'default resizer' }, position: { x: 250, y: 0 }, - style: { padding: 10, border: '1px solid #222', fontSize: 10 }, + style: { + width: 200, + height: 150, + border: '1px solid #222', + fontSize: 10, + }, }, { id: '3', type: 'customResizer', data: { label: 'resize control with child component' }, position: { x: 250, y: 150 }, - style: { padding: 10, border: '1px solid #222', fontSize: 10, width: 100 }, + style: { border: '1px solid #222', fontSize: 10, width: 100 }, + parentNode: '2', }, { id: '4', type: 'customResizer2', data: { label: 'resize controls' }, position: { x: 100, y: 150 }, - style: { padding: 10, border: '1px solid #222', fontSize: 10 }, + style: { border: '1px solid #222', fontSize: 10 }, + parentNode: '2', + }, + { + id: '5', + type: 'customResizer2', + data: { label: 'min width and height' }, + position: { x: 100, y: 150 }, + style: { border: '1px solid #222', fontSize: 10 }, }, ]; const CustomNodeFlow = () => { + const [snapToGrid, setSnapToGrid] = useState(false); const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes); const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges); @@ -67,12 +82,14 @@ const CustomNodeFlow = () => { onEdgesChange={onEdgesChange} onConnect={onConnect} nodeTypes={nodeTypes} - fitView - minZoom={0.3} - maxZoom={2} - snapToGrid + minZoom={-5} + maxZoom={5} + snapToGrid={snapToGrid} > + + + ); }; diff --git a/packages/core/src/components/Handle/index.tsx b/packages/core/src/components/Handle/index.tsx index c47035a9..a2d0502f 100644 --- a/packages/core/src/components/Handle/index.tsx +++ b/packages/core/src/components/Handle/index.tsx @@ -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( 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; diff --git a/packages/core/src/contexts/NodeIdContext.ts b/packages/core/src/contexts/NodeIdContext.ts index c43a1c3b..28a14b78 100644 --- a/packages/core/src/contexts/NodeIdContext.ts +++ b/packages/core/src/contexts/NodeIdContext.ts @@ -1,7 +1,12 @@ -import { createContext } from 'react'; +import { createContext, useContext } from 'react'; export const NodeIdContext = createContext(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; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 60a8451f..c4a5b19e 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -22,7 +22,6 @@ export { getNodePositionWithOrigin, } from './utils/graph'; export { applyNodeChanges, applyEdgeChanges } from './utils/changes'; -export { createNodeInternals } from './store/utils'; export { getMarkerEnd } from './components/Edges/utils'; export { default as ReactFlowProvider } from './components/ReactFlowProvider'; export { default as Panel } from './components/Panel'; @@ -40,5 +39,6 @@ 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'; diff --git a/packages/core/src/store/index.ts b/packages/core/src/store/index.ts index e05f2048..bdb02137 100644 --- a/packages/core/src/store/index.ts +++ b/packages/core/src/store/index.ts @@ -17,6 +17,7 @@ import type { NodePositionChange, NodeDragItem, UnselectNodesAndEdgesParams, + NodeChange, } from '../types'; const createRFStore = () => @@ -102,47 +103,41 @@ const createRFStore = () => onNodesChange?.(changes); } }, - updateNodePositions: ( - nodeDragItems: NodeDragItem[] | Node[], - positionChanged = true, - dragging = false, - applyChanges = true - ) => { - const { onNodesChange, nodeInternals, hasDefaultNodes, nodeOrigin } = get(); + updateNodePositions: (nodeDragItems: NodeDragItem[] | Node[], positionChanged = true, dragging = false) => { + const { triggerNodeChanges } = get(); - if (hasDefaultNodes || onNodesChange) { - const changes = nodeDragItems.map((node) => { - const change: NodePositionChange = { - id: node.id, - type: 'position', - dragging, - }; + 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 }); - } - - if (applyChanges) { - onNodesChange?.(changes); - } + if (positionChanged) { + change.positionAbsolute = node.positionAbsolute; + change.position = node.position; } - return changes; - } + return change; + }); - return null; + triggerNodeChanges(changes); }, + + triggerNodeChanges: (changes: NodeChange[]) => { + const { onNodesChange, nodeInternals, hasDefaultNodes, nodeOrigin } = get(); + + 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[]; diff --git a/packages/core/src/types/changes.ts b/packages/core/src/types/changes.ts index ec2f6693..375c8bf2 100644 --- a/packages/core/src/types/changes.ts +++ b/packages/core/src/types/changes.ts @@ -7,8 +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 = { diff --git a/packages/core/src/types/general.ts b/packages/core/src/types/general.ts index 2daed27b..472df101 100644 --- a/packages/core/src/types/general.ts +++ b/packages/core/src/types/general.ts @@ -215,12 +215,7 @@ export type ReactFlowActions = { setEdges: (edges: Edge[]) => void; setDefaultNodesAndEdges: (nodes?: Node[], edges?: Edge[]) => void; updateNodeDimensions: (updates: NodeDimensionUpdate[]) => void; - updateNodePositions: ( - nodeDragItems: NodeDragItem[] | Node[], - positionChanged: boolean, - dragging: boolean, - applyChanges?: boolean - ) => NodePositionChange[] | null; + updateNodePositions: (nodeDragItems: NodeDragItem[] | Node[], positionChanged: boolean, dragging: boolean) => void; resetSelectedElements: () => void; unselectNodesAndEdges: (params?: UnselectNodesAndEdgesParams) => void; addSelectedNodes: (nodeIds: string[]) => void; @@ -231,6 +226,7 @@ export type ReactFlowActions = { setNodeExtent: (nodeExtent: CoordinateExtent) => void; cancelConnection: () => void; reset: () => void; + triggerNodeChanges: (changes: NodeChange[]) => void; }; export type ReactFlowState = ReactFlowStore & ReactFlowActions; diff --git a/packages/core/src/types/nodes.ts b/packages/core/src/types/nodes.ts index 39d8755c..c27e79cf 100644 --- a/packages/core/src/types/nodes.ts +++ b/packages/core/src/types/nodes.ts @@ -31,6 +31,7 @@ export type Node = { positionAbsolute?: XYPosition; ariaLabel?: string; focusable?: boolean; + resizing?: boolean; // only used internally [internalsSymbol]?: { diff --git a/packages/core/src/utils/changes.ts b/packages/core/src/utils/changes.ts index edfd0719..5deb3fdc 100644 --- a/packages/core/src/utils/changes.ts +++ b/packages/core/src/utils/changes.ts @@ -94,6 +94,10 @@ function applyChanges(changes: any[], elements: any[]): any[] { updateItem.style = { ...(updateItem.style || {}), ...currentChange.dimensions }; } + if (typeof currentChange.resizing === 'boolean') { + updateItem.resizing = currentChange.resizing; + } + if (updateItem.expandParent) { handleParentExpand(res, updateItem); } diff --git a/packages/node-resizer/src/NodeResizer.tsx b/packages/node-resizer/src/NodeResizer.tsx index 75267d86..1b2aee1f 100644 --- a/packages/node-resizer/src/NodeResizer.tsx +++ b/packages/node-resizer/src/NodeResizer.tsx @@ -6,11 +6,17 @@ 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) => ( @@ -21,10 +27,18 @@ export default function NodeResizer({ nodeId={nodeId} position={c} variant={ResizeControlVariant.Line} + color={color} /> ))} {handleControls.map((c) => ( - + ))} ); diff --git a/packages/node-resizer/src/ResizeControl.tsx b/packages/node-resizer/src/ResizeControl.tsx index ec2e46dd..b7c42e59 100644 --- a/packages/node-resizer/src/ResizeControl.tsx +++ b/packages/node-resizer/src/ResizeControl.tsx @@ -7,21 +7,26 @@ import { useGetPointerPosition, NodeChange, NodeDimensionChange, - applyNodeChanges, - createNodeInternals, + useNodeId, + NodePositionChange, } from '@reactflow/core'; -import type { Dimensions, Node, XYPosition } from '@reactflow/core'; +import type { Dimensions, XYPosition } from '@reactflow/core'; import { ResizeDragEvent, ResizeControlProps, ResizeControlLineProps, ResizeControlVariant } from './types'; function ResizeControl({ nodeId, - position = 'bottom-right', + 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(null); const startValues = useRef({ @@ -32,17 +37,20 @@ function ResizeControl({ nodeX: 0, nodeY: 0, }); + const prevValues = useRef({ 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) { + if (!resizeControlRef.current || !id) { return; } const selection = select(resizeControlRef.current); const dragHandler = drag() .on('start', (event: ResizeDragEvent) => { - const node = store.getState().nodeInternals.get(nodeId); + const node = store.getState().nodeInternals.get(id); const { xSnapped, ySnapped } = getPointerPosition(event); startValues.current = { @@ -53,15 +61,22 @@ function ResizeControl({ 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 { updateNodePositions, nodeInternals, onNodesChange, hasDefaultNodes, nodeOrigin } = store.getState(); + const { nodeInternals, triggerNodeChanges } = store.getState(); const { xSnapped, ySnapped } = getPointerPosition(event); - const node = nodeInternals.get(nodeId); - const enableX = position.includes('right') || position.includes('left'); - const enableY = position.includes('bottom') || position.includes('top'); - const invertX = position.includes('left'); - const invertY = position.includes('top'); + 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[] = []; @@ -73,57 +88,70 @@ function ResizeControl({ nodeX: startNodeX, nodeY: startNodeY, } = startValues.current; - const distX = enableX ? xSnapped - startX : 0; - const distY = enableY ? ySnapped - startY : 0; - const width = startWidth + (invertX ? -distX : distX); - const height = startHeight + (invertY ? -distY : distY); + + 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 + distX : startNodeX; - const y = invertY ? startNodeY + distY : startNodeY; + const x = invertX ? startNodeX - (width - startWidth) : startNodeX; + const y = invertY ? startNodeY - (height - startHeight) : startNodeY; - if (x !== node.position.x || y !== node.position.y) { - const positionChanges = updateNodePositions( - [ - { - id: nodeId, - position: { x, y }, - } as Node, - ], - true, - false, - false - ); + // only transform the node if the width or height changes + const isXPosChange = x !== prevX && isWidthChange; + const isYPosChange = y !== prevY && isHeightChange; - if (positionChanges?.length) { - changes.push(positionChanges[0]); - } + 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 (width !== node.width || height !== node.height) { + if (isWidthChange || isHeightChange) { const dimensionChange: NodeDimensionChange = { - id: nodeId, + id: id, type: 'dimensions', updateStyle: true, + resizing: true, dimensions: { - width: width !== node.width ? width : node.width, - height: height !== node.height ? height : node.height, + width: width, + height: height, }, }; changes.push(dimensionChange); + prevValues.current.width = width; + prevValues.current.height = height; } - if (changes.length) { - if (hasDefaultNodes) { - const nodes = applyNodeChanges(changes, Array.from(nodeInternals.values())); - const nextNodeInternals = createNodeInternals(nodes, nodeInternals, nodeOrigin); - store.setState({ nodeInternals: nextNodeInternals }); - } - - onNodesChange?.(changes); - } + triggerNodeChanges(changes); } + }) + .on('end', () => { + const { triggerNodeChanges } = store.getState(); + + const dimensionChange: NodeDimensionChange = { + id: id, + type: 'dimensions', + resizing: true, + }; + + triggerNodeChanges([dimensionChange]); }); selection.call(dragHandler); @@ -131,15 +159,17 @@ function ResizeControl({ return () => { selection.on('.drag', null); }; - }, [nodeId, position, getPointerPosition]); + }, [id, controlPosition, getPointerPosition]); - const positionClassNames = position?.split('-'); + const positionClassNames = controlPosition.split('-'); + const colorStyleProp = variant === ResizeControlVariant.Line ? 'borderColor' : 'backgroundColor'; + const controlStyle = color ? { ...style, [colorStyleProp]: color } : style; return (
{children}
diff --git a/packages/node-resizer/src/style.css b/packages/node-resizer/src/style.css index 71077758..0df9c1fb 100644 --- a/packages/node-resizer/src/style.css +++ b/packages/node-resizer/src/style.css @@ -1,9 +1,5 @@ .react-flow__resize-control { position: absolute; - background-color: rgba(195, 195, 195, 1); - border: 1px solid white; - width: 4px; - height: 4px; } .react-flow__resize-control.left, @@ -28,6 +24,11 @@ /* 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%); } @@ -62,9 +63,11 @@ /* line styles */ .react-flow__resize-control.line { - border: none; - background: none; + border-color: #3367d9; + border-width: 0; + border-style: solid; } + .react-flow__resize-control.line.left, .react-flow__resize-control.line.right { width: 1px; @@ -75,11 +78,11 @@ .react-flow__resize-control.line.left { left: 0; - border-left: 1px solid rgba(195, 195, 195, 1); + border-left-width: 1px; } .react-flow__resize-control.line.right { left: 100%; - border-right: 1px solid rgba(195, 195, 195, 1); + border-right-width: 1px; } .react-flow__resize-control.line.top, @@ -92,9 +95,9 @@ .react-flow__resize-control.line.top { top: 0; - border-top: 1px solid rgba(195, 195, 195, 1); + border-top-width: 1px; } .react-flow__resize-control.line.bottom { - border-bottom: 1px solid rgba(195, 195, 195, 1); + border-bottom-width: 1px; top: 100%; } diff --git a/packages/node-resizer/src/types.ts b/packages/node-resizer/src/types.ts index 9368e434..000ad536 100644 --- a/packages/node-resizer/src/types.ts +++ b/packages/node-resizer/src/types.ts @@ -2,11 +2,13 @@ import type { CSSProperties, ReactNode } from 'react'; import type { D3DragEvent, SubjectPosition } from 'd3-drag'; export type NodeResizerProps = { - nodeId: string; + nodeId?: string; + color?: string; handleClassName?: string; handleStyle?: CSSProperties; lineClassName?: string; lineStyle?: CSSProperties; + isVisible?: boolean; }; export type ControlLinePosition = 'top' | 'bottom' | 'left' | 'right'; @@ -19,12 +21,15 @@ export enum ResizeControlVariant { } export type ResizeControlProps = { - nodeId: string; - position: ControlPosition; + nodeId?: string; + position?: ControlPosition; variant?: ResizeControlVariant; + color?: string; className?: string; style?: CSSProperties; children?: ReactNode; + minWidth?: number; + minHeight?: number; }; export type ResizeControlLineProps = ResizeControlProps & {