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 } from './types'; import { getResizeDirection, ResizeControlVariant } from '@xyflow/system'; 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(null); const startValues = useRef(initStartValues); const prevValues = useRef(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() .on('start', (event: ResizeDragEvent) => { const { nodeLookup, transform, snapGrid, snapToGrid } = store.getState(); const node = nodeLookup.get(id); const { xSnapped, ySnapped } = getPointerPosition(event.sourceEvent, { transform, snapGrid, snapToGrid }); prevValues.current = { width: node?.computed?.width ?? 0, height: node?.computed?.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 { nodeLookup, transform, snapGrid, snapToGrid, triggerNodeChanges } = store.getState(); const { xSnapped, ySnapped } = getPointerPosition(event.sourceEvent, { transform, snapGrid, snapToGrid }); const node = nodeLookup.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 = getResizeDirection({ 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, onResizeStart, onResize, onResizeEnd, ]); const positionClassNames = controlPosition.split('-'); const colorStyleProp = variant === ResizeControlVariant.Line ? 'borderColor' : 'backgroundColor'; const controlStyle = color ? { ...style, [colorStyleProp]: color } : style; return (
{children}
); } export function ResizeControlLine(props: ResizeControlLineProps) { return ; } export default memo(ResizeControl);