diff --git a/.changeset/polite-pumas-mate.md b/.changeset/polite-pumas-mate.md new file mode 100644 index 00000000..01b6d8db --- /dev/null +++ b/.changeset/polite-pumas-mate.md @@ -0,0 +1,6 @@ +--- +'@reactflow/node-resizer': minor +'@reactflow/core': patch +--- + +node resizer: add maxWidth, maxHeight and keepAspectRatio props diff --git a/examples/vite-app/src/examples/NodeResizer/CustomResizer.tsx b/examples/vite-app/src/examples/NodeResizer/CustomResizer.tsx index 6cf58fd9..a36d9f18 100644 --- a/examples/vite-app/src/examples/NodeResizer/CustomResizer.tsx +++ b/examples/vite-app/src/examples/NodeResizer/CustomResizer.tsx @@ -10,10 +10,21 @@ const controlStyle = { border: 'none', }; -const CustomNode: FC = ({ id, data }) => { +const CustomResizerNode: FC = ({ data }) => { return ( <> - + @@ -24,4 +35,4 @@ const CustomNode: FC = ({ id, data }) => { ); }; -export default memo(CustomNode); +export default memo(CustomResizerNode); diff --git a/examples/vite-app/src/examples/NodeResizer/CustomResizer2.tsx b/examples/vite-app/src/examples/NodeResizer/CustomResizer2.tsx deleted file mode 100644 index 63ff061b..00000000 --- a/examples/vite-app/src/examples/NodeResizer/CustomResizer2.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import { memo, FC } from 'react'; -import { Handle, Position, NodeProps } from 'reactflow'; - -import { NodeResizeControl } from '@reactflow/node-resizer'; -import '@reactflow/node-resizer/dist/style.css'; - -const CustomNode: FC = ({ id, data }) => { - return ( - <> - - - -
{data.label}
- - - ); -}; - -export default memo(CustomNode); diff --git a/examples/vite-app/src/examples/NodeResizer/CustomResizer3.tsx b/examples/vite-app/src/examples/NodeResizer/CustomResizer3.tsx deleted file mode 100644 index 422f2982..00000000 --- a/examples/vite-app/src/examples/NodeResizer/CustomResizer3.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import { memo, FC } from 'react'; -import { Handle, Position, NodeProps } from 'reactflow'; - -import { NodeResizeControl, OnResize, ShouldResize } from '@reactflow/node-resizer'; -import '@reactflow/node-resizer/dist/style.css'; - -const shouldResize: ShouldResize = (event, params) => { - console.log('before resize', params); - - if (params.width > 100) { - return false; - } - - return true; -}; - -const onResize: OnResize = (event, params) => { - console.log('resize', params.direction); -}; - -const CustomNode: FC = ({ id, data }) => { - return ( - <> - - - -
{data.label}
- - - ); -}; - -export default memo(CustomNode); diff --git a/examples/vite-app/src/examples/NodeResizer/DefaultResizer.tsx b/examples/vite-app/src/examples/NodeResizer/DefaultResizer.tsx new file mode 100644 index 00000000..3d9649a5 --- /dev/null +++ b/examples/vite-app/src/examples/NodeResizer/DefaultResizer.tsx @@ -0,0 +1,29 @@ +import { memo, FC } from 'react'; +import { Handle, Position, NodeProps } from 'reactflow'; +import { NodeResizer } from '@reactflow/node-resizer'; + +import '@reactflow/node-resizer/dist/style.css'; + +const DefaultResizerNode: FC = ({ data, selected }) => { + return ( + <> + + +
{data.label}
+ + + ); +}; + +export default memo(DefaultResizerNode); diff --git a/examples/vite-app/src/examples/NodeResizer/HorizontalResizer.tsx b/examples/vite-app/src/examples/NodeResizer/HorizontalResizer.tsx new file mode 100644 index 00000000..2798ca3f --- /dev/null +++ b/examples/vite-app/src/examples/NodeResizer/HorizontalResizer.tsx @@ -0,0 +1,43 @@ +import { memo, FC } from 'react'; +import { Handle, Position, NodeProps } from 'reactflow'; +import { NodeResizeControl } from '@reactflow/node-resizer'; + +import '@reactflow/node-resizer/dist/style.css'; + +const HorizontalResizerNode: FC = ({ data }) => { + return ( + <> + + + +
{data.label}
+ + + ); +}; + +export default memo(HorizontalResizerNode); diff --git a/examples/vite-app/src/examples/NodeResizer/NodeResizerNode.tsx b/examples/vite-app/src/examples/NodeResizer/NodeResizerNode.tsx deleted file mode 100644 index 03e4960f..00000000 --- a/examples/vite-app/src/examples/NodeResizer/NodeResizerNode.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import { memo, FC } from 'react'; -import { Handle, Position, NodeProps } from 'reactflow'; - -import { NodeResizer, ShouldResize, OnResize, OnResizeEnd, OnResizeStart } from '@reactflow/node-resizer'; -import '@reactflow/node-resizer/dist/style.css'; - -const onResizeStart: OnResizeStart = (_, params) => { - console.log('resize start', params); -}; - -const onResize: OnResize = (_, params) => { - console.log('resize', params); -}; - -const onResizeEnd: OnResizeEnd = (_, params) => { - console.log('resize end', params); -}; - -const shouldResize: ShouldResize = (_, params) => { - console.log('should resize', params); - - return true; -}; - -const CustomNode: FC = ({ data, selected }) => { - return ( - <> - - -
{data.label}
- - - ); -}; - -export default memo(CustomNode); diff --git a/examples/vite-app/src/examples/NodeResizer/VerticalResizer.tsx b/examples/vite-app/src/examples/NodeResizer/VerticalResizer.tsx new file mode 100644 index 00000000..a3d6515e --- /dev/null +++ b/examples/vite-app/src/examples/NodeResizer/VerticalResizer.tsx @@ -0,0 +1,43 @@ +import { memo, FC } from 'react'; +import { Handle, Position, NodeProps } from 'reactflow'; + +import { NodeResizeControl } from '@reactflow/node-resizer'; +import '@reactflow/node-resizer/dist/style.css'; + +const CustomNode: FC = ({ id, data }) => { + return ( + <> + + + +
{data.label}
+ + + ); +}; + +export default memo(CustomNode); diff --git a/examples/vite-app/src/examples/NodeResizer/index.tsx b/examples/vite-app/src/examples/NodeResizer/index.tsx index 01bb0851..d94a8d8b 100644 --- a/examples/vite-app/src/examples/NodeResizer/index.tsx +++ b/examples/vite-app/src/examples/NodeResizer/index.tsx @@ -1,73 +1,110 @@ import { useCallback, useState } from 'react'; -import ReactFlow, { Controls, addEdge, Position, Connection, useNodesState, useEdgesState, Panel } from 'reactflow'; +import ReactFlow, { Controls, addEdge, Connection, useNodesState, useEdgesState, Panel, Node, Edge } from 'reactflow'; -import NodeResizerNode from './NodeResizerNode'; +import DefaultResizer from './DefaultResizer'; import CustomResizer from './CustomResizer'; -import CustomResizer2 from './CustomResizer2'; -import CustomResizer3 from './CustomResizer3'; +import VerticalResizer from './VerticalResizer'; +import HorizontalResizer from './HorizontalResizer'; const nodeTypes = { - resizer: NodeResizerNode, + defaultResizer: DefaultResizer, customResizer: CustomResizer, - customResizer2: CustomResizer2, - customResizer3: CustomResizer3, + verticalResizer: VerticalResizer, + horizontalResizer: HorizontalResizer, }; -const initialEdges = [ - { - id: 'e1-2', - source: '1', - target: '2', - }, -]; +const nodeStyle = { + border: '1px solid #222', + fontSize: 10, + backgroundColor: '#ddd', +}; -const initialNodes = [ +const initialEdges: Edge[] = []; + +const initialNodes: Node[] = [ { id: '1', - type: 'input', - data: { label: 'An input node' }, + type: 'defaultResizer', + data: { label: 'default resizer' }, position: { x: 0, y: 0 }, - sourcePosition: Position.Right, + style: { ...nodeStyle }, }, { - id: '2', - type: 'resizer', - data: { label: 'default resizer' }, + id: '1a', + type: 'defaultResizer', + data: { + label: 'default resizer with min and max dimensions', + minWidth: 100, + minHeight: 80, + maxWidth: 200, + maxHeight: 200, + }, + position: { x: 0, y: 60 }, + style: { ...nodeStyle, width: 100, height: 80 }, + }, + { + id: '1b', + type: 'defaultResizer', + data: { + label: 'default resizer with initial size and aspect ratio', + keepAspectRatio: true, + minWidth: 100, + minHeight: 60, + maxWidth: 400, + maxHeight: 400, + }, position: { x: 250, y: 0 }, style: { - width: 200, - height: 150, - border: '1px solid #222', - fontSize: 10, + width: 174, + height: 123, + ...nodeStyle, }, }, { - id: '3', + id: '2', type: 'customResizer', - data: { label: 'resize control with child component' }, + data: { label: 'custom resize icon' }, + position: { x: 0, y: 200 }, + style: { width: 100, height: 60, ...nodeStyle }, + }, + { + id: '3', + type: 'verticalResizer', + data: { label: 'vertical resizer' }, position: { x: 250, y: 200 }, - style: { border: '1px solid #222', fontSize: 10, width: 100 }, + style: { ...nodeStyle }, + }, + { + id: '3a', + type: 'verticalResizer', + data: { + label: 'vertical resizer with min/maxHeight and aspect ratio', + minHeight: 50, + maxHeight: 200, + keepAspectRatio: true, + }, + position: { x: 400, y: 200 }, + style: { ...nodeStyle, height: 50 }, }, { id: '4', - type: 'customResizer2', - data: { label: 'resize controls' }, - position: { x: 100, y: 150 }, - style: { border: '1px solid #222', fontSize: 10 }, + type: 'horizontalResizer', + data: { + label: 'horizontal resizer with aspect ratio', + keepAspectRatio: true, + minHeight: 20, + maxHeight: 80, + maxWidth: 300, + }, + position: { x: 250, y: 300 }, + style: { ...nodeStyle }, }, { - id: '5', - type: 'customResizer2', - data: { label: 'min width and height' }, - position: { x: 250, y: 250 }, - style: { border: '1px solid #222', fontSize: 10 }, - }, - { - id: '6', - type: 'customResizer3', - data: { label: 'resize controls' }, - position: { x: 400, y: 200 }, - style: { border: '1px solid #222', fontSize: 10 }, + id: '4a', + type: 'horizontalResizer', + data: { label: 'horizontal resizer with maxWidth', maxWidth: 300 }, + position: { x: 250, y: 400 }, + style: { ...nodeStyle }, }, ]; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index c4a5b19e..158c9094 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -8,7 +8,7 @@ export { default as SimpleBezierEdge, getSimpleBezierPath } from './components/E export { default as SmoothStepEdge, getSmoothStepPath } from './components/Edges/SmoothStepEdge'; export { default as BaseEdge } from './components/Edges/BaseEdge'; -export { internalsSymbol, rectToBox, boxToRect, getBoundsOfRects } from './utils'; +export { internalsSymbol, rectToBox, boxToRect, getBoundsOfRects, clamp } from './utils'; export { isNode, isEdge, diff --git a/packages/node-resizer/src/NodeResizer.tsx b/packages/node-resizer/src/NodeResizer.tsx index c7a3b8a6..485b8efc 100644 --- a/packages/node-resizer/src/NodeResizer.tsx +++ b/packages/node-resizer/src/NodeResizer.tsx @@ -14,6 +14,9 @@ export default function NodeResizer({ color, minWidth = 10, minHeight = 10, + maxWidth = Number.MAX_VALUE, + maxHeight = Number.MAX_VALUE, + keepAspectRatio = false, shouldResize, onResizeStart, onResize, @@ -36,7 +39,10 @@ export default function NodeResizer({ color={color} minWidth={minWidth} minHeight={minHeight} + maxWidth={maxWidth} + maxHeight={maxHeight} onResizeStart={onResizeStart} + keepAspectRatio={keepAspectRatio} shouldResize={shouldResize} onResize={onResize} onResizeEnd={onResizeEnd} @@ -52,7 +58,10 @@ export default function NodeResizer({ color={color} minWidth={minWidth} minHeight={minHeight} + maxWidth={maxWidth} + maxHeight={maxHeight} onResizeStart={onResizeStart} + keepAspectRatio={keepAspectRatio} shouldResize={shouldResize} onResize={onResize} onResizeEnd={onResizeEnd} diff --git a/packages/node-resizer/src/ResizeControl.tsx b/packages/node-resizer/src/ResizeControl.tsx index 3397537d..6757b2a3 100644 --- a/packages/node-resizer/src/ResizeControl.tsx +++ b/packages/node-resizer/src/ResizeControl.tsx @@ -9,6 +9,7 @@ import { NodeDimensionChange, useNodeId, NodePositionChange, + clamp, } from '@reactflow/core'; import { ResizeDragEvent, ResizeControlProps, ResizeControlLineProps, ResizeControlVariant } from './types'; @@ -20,6 +21,7 @@ const initStartValues = { ...initPrevValues, pointerX: 0, pointerY: 0, + aspectRatio: 1, }; function ResizeControl({ @@ -32,6 +34,9 @@ function ResizeControl({ color, minWidth = 10, minHeight = 10, + maxWidth = Number.MAX_VALUE, + maxHeight = Number.MAX_VALUE, + keepAspectRatio = false, shouldResize, onResizeStart, onResize, @@ -53,6 +58,12 @@ function ResizeControl({ } 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() .on('start', (event: ResizeDragEvent) => { const node = store.getState().nodeInternals.get(id); @@ -69,6 +80,7 @@ function ResizeControl({ ...prevValues.current, pointerX: xSnapped, pointerY: ySnapped, + aspectRatio: prevValues.current.width / prevValues.current.height, }; onResizeStart?.(event, { ...prevValues.current }); @@ -77,10 +89,6 @@ function ResizeControl({ 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[] = []; @@ -91,13 +99,43 @@ function ResizeControl({ 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); - const width = Math.max(startWidth + (invertX ? -distX : distX), minWidth); - const height = Math.max(startHeight + (invertY ? -distY : distY), minHeight); + + 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; @@ -183,7 +221,7 @@ function ResizeControl({ return () => { selection.on('.drag', null); }; - }, [id, controlPosition, minWidth, minHeight, getPointerPosition]); + }, [id, controlPosition, minWidth, minHeight, maxWidth, maxHeight, keepAspectRatio, getPointerPosition]); const positionClassNames = controlPosition.split('-'); const colorStyleProp = variant === ResizeControlVariant.Line ? 'borderColor' : 'backgroundColor'; diff --git a/packages/node-resizer/src/types.ts b/packages/node-resizer/src/types.ts index 75d40ace..ad9114b2 100644 --- a/packages/node-resizer/src/types.ts +++ b/packages/node-resizer/src/types.ts @@ -29,6 +29,9 @@ export type NodeResizerProps = { isVisible?: boolean; minWidth?: number; minHeight?: number; + maxWidth?: number; + maxHeight?: number; + keepAspectRatio?: boolean; shouldResize?: ShouldResize; onResizeStart?: OnResizeStart; onResize?: OnResize; @@ -46,7 +49,17 @@ export enum ResizeControlVariant { export type ResizeControlProps = Pick< NodeResizerProps, - 'nodeId' | 'color' | 'minWidth' | 'minHeight' | 'shouldResize' | 'onResizeStart' | 'onResize' | 'onResizeEnd' + | 'nodeId' + | 'color' + | 'minWidth' + | 'minHeight' + | 'maxWidth' + | 'maxHeight' + | 'keepAspectRatio' + | 'shouldResize' + | 'onResizeStart' + | 'onResize' + | 'onResizeEnd' > & { position?: ControlPosition; variant?: ResizeControlVariant;