diff --git a/examples/react/src/examples/NodeResizer/index.tsx b/examples/react/src/examples/NodeResizer/index.tsx index 11ce29d2..42e83523 100644 --- a/examples/react/src/examples/NodeResizer/index.tsx +++ b/examples/react/src/examples/NodeResizer/index.tsx @@ -116,6 +116,42 @@ const initialNodes: Node[] = [ position: { x: 250, y: 400 }, style: { ...nodeStyle }, }, + { + id: '5', + type: 'defaultResizer', + data: { label: 'Parent', keepAspectRatio: true }, + position: { x: 700, y: 0 }, + style: { ...nodeStyle, width: 300, height: 400 }, + }, + { + id: '5a', + type: 'defaultResizer', + data: { + label: 'Child with extent: parent', + }, + position: { x: 50, y: 50 }, + parentNode: '5', + extent: 'parent', + style: { ...nodeStyle, width: 50, height: 100 }, + }, + { + id: '5b', + type: 'defaultResizer', + data: { label: 'Child with expandParent' }, + position: { x: 150, y: 100 }, + parentNode: '5', + expandParent: true, + style: { ...nodeStyle }, + }, + { + id: '5c', + type: 'defaultResizer', + data: { label: 'Child with expandParent & keepAspectRatio', keepAspectRatio: true }, + position: { x: 25, y: 200 }, + parentNode: '5', + expandParent: true, + style: { ...nodeStyle }, + }, ]; const CustomNodeFlow = () => { diff --git a/examples/svelte/src/routes/examples/node-resizer/+page.svelte b/examples/svelte/src/routes/examples/node-resizer/+page.svelte index 61257ee1..39778156 100644 --- a/examples/svelte/src/routes/examples/node-resizer/+page.svelte +++ b/examples/svelte/src/routes/examples/node-resizer/+page.svelte @@ -100,6 +100,29 @@ data: { label: 'horizontal resizer with maxWidth', maxWidth: 300 }, position: { x: 250, y: 400 }, style: nodeStyle + }, + { + id: '5', + type: 'defaultResizer', + data: { label: 'Parent' }, + position: { x: 700, y: 0 }, + style: nodeStyle + 'width: 300px; height: 300px' + }, + { + id: '5a', + type: 'defaultResizer', + data: { label: 'Child' }, + position: { x: 50, y: 50 }, + parentNode: '5', + style: nodeStyle + }, + { + id: '5b', + type: 'defaultResizer', + data: { label: 'Child' }, + position: { x: 100, y: 100 }, + parentNode: '5', + style: nodeStyle } ]); diff --git a/packages/react/CHANGELOG.md b/packages/react/CHANGELOG.md index 6931ea5b..54c5f9ec 100644 --- a/packages/react/CHANGELOG.md +++ b/packages/react/CHANGELOG.md @@ -1,5 +1,9 @@ # @xyflow/react +## 12.0.0-next.9 + +- a better NodeResizer that works with subflows. Child nodes do not move when parent node gets resized and parent extent is taken into account + ## 12.0.0-next.8 ### Patch changes diff --git a/packages/react/package.json b/packages/react/package.json index a7eb71c3..097cba0d 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -1,6 +1,6 @@ { "name": "@xyflow/react", - "version": "12.0.0-next.7", + "version": "12.0.0-next.8", "description": "React Flow - A highly customizable React library for building node-based editors and interactive flow charts.", "keywords": [ "react", diff --git a/packages/react/src/additional-components/NodeResizer/NodeResizeControl.tsx b/packages/react/src/additional-components/NodeResizer/NodeResizeControl.tsx index 2f16f150..c03c023e 100644 --- a/packages/react/src/additional-components/NodeResizer/NodeResizeControl.tsx +++ b/packages/react/src/additional-components/NodeResizer/NodeResizeControl.tsx @@ -1,6 +1,12 @@ import { useRef, useEffect, memo } from 'react'; import cc from 'classcat'; -import { XYResizer, ResizeControlVariant, type XYResizerInstance, type XYResizerChange } from '@xyflow/system'; +import { + XYResizer, + ResizeControlVariant, + type XYResizerInstance, + type XYResizerChange, + XYResizerChildChange, +} from '@xyflow/system'; import { useStoreApi } from '../../hooks/useStore'; import { useNodeId } from '../../contexts/NodeIdContext'; @@ -52,7 +58,7 @@ function ResizeControl({ snapToGrid, }; }, - onChange: (change: XYResizerChange) => { + onChange: (change: XYResizerChange, childChanges: XYResizerChildChange[]) => { const { triggerNodeChanges } = store.getState(); const changes: NodeChange[] = []; @@ -83,6 +89,16 @@ function ResizeControl({ changes.push(dimensionChange); } + + for (const childChange of childChanges) { + const positionChange: NodePositionChange = { + ...childChange, + type: 'position', + }; + + changes.push(positionChange); + } + triggerNodeChanges(changes); }, onEnd: () => { diff --git a/packages/react/src/components/NodeWrapper/index.tsx b/packages/react/src/components/NodeWrapper/index.tsx index 1e796de1..ebf65163 100644 --- a/packages/react/src/components/NodeWrapper/index.tsx +++ b/packages/react/src/components/NodeWrapper/index.tsx @@ -14,7 +14,7 @@ import { useStore, useStoreApi } from '../../hooks/useStore'; import { Provider } from '../../contexts/NodeIdContext'; import { ARIA_NODE_DESC_KEY } from '../A11yDescriptions'; import { useDrag } from '../../hooks/useDrag'; -import { useUpdateNodePositions } from '../../hooks/useUpdateNodePositions'; +import { useMoveSelectedNodes } from '../../hooks/useMoveSelectedNodes'; import { handleNodeClick } from '../Nodes/utils'; import { arrowKeyDiffs, builtinNodeTypes } from './utils'; import type { NodeWrapperProps } from '../../types'; @@ -79,7 +79,7 @@ export function NodeWrapper({ const prevTargetPosition = useRef(node.targetPosition); const prevType = useRef(nodeType); - const updatePositions = useUpdateNodePositions(); + const moveSelectedNodes = useMoveSelectedNodes(); useEffect(() => { if (nodeRef.current && !node.hidden) { @@ -188,10 +188,9 @@ export function NodeWrapper({ .toLowerCase()}. New position, x: ${~~positionAbsoluteX}, y: ${~~positionAbsoluteY}`, }); - updatePositions({ - x: arrowKeyDiffs[event.key].x, - y: arrowKeyDiffs[event.key].y, - isShiftPressed: event.shiftKey, + moveSelectedNodes({ + direction: arrowKeyDiffs[event.key], + factor: event.shiftKey ? 4 : 1, }); } }; diff --git a/packages/react/src/components/NodesSelection/index.tsx b/packages/react/src/components/NodesSelection/index.tsx index d3ed7a06..9a88c80b 100644 --- a/packages/react/src/components/NodesSelection/index.tsx +++ b/packages/react/src/components/NodesSelection/index.tsx @@ -10,7 +10,7 @@ import { getNodesBounds } from '@xyflow/system'; import { useStore, useStoreApi } from '../../hooks/useStore'; import { useDrag } from '../../hooks/useDrag'; -import { useUpdateNodePositions } from '../../hooks/useUpdateNodePositions'; +import { useMoveSelectedNodes } from '../../hooks/useMoveSelectedNodes'; import { arrowKeyDiffs } from '../NodeWrapper/utils'; import type { Node, ReactFlowState } from '../../types'; @@ -35,7 +35,7 @@ const selector = (s: ReactFlowState) => { export function NodesSelection({ onSelectionContextMenu, noPanClassName, disableKeyboardA11y }: NodesSelectionProps) { const store = useStoreApi(); const { width, height, transformString, userSelectionActive } = useStore(selector, shallow); - const updatePositions = useUpdateNodePositions(); + const moveSelectedNodes = useMoveSelectedNodes(); const nodeRef = useRef(null); @@ -64,10 +64,9 @@ export function NodesSelection({ onSelectionContextMenu, noPanClassName, disable const onKeyDown = (event: KeyboardEvent) => { if (Object.prototype.hasOwnProperty.call(arrowKeyDiffs, event.key)) { - updatePositions({ - x: arrowKeyDiffs[event.key].x, - y: arrowKeyDiffs[event.key].y, - isShiftPressed: event.shiftKey, + moveSelectedNodes({ + direction: arrowKeyDiffs[event.key], + factor: event.shiftKey ? 4 : 1, }); } }; diff --git a/packages/react/src/hooks/useUpdateNodePositions.ts b/packages/react/src/hooks/useMoveSelectedNodes.ts similarity index 68% rename from packages/react/src/hooks/useUpdateNodePositions.ts rename to packages/react/src/hooks/useMoveSelectedNodes.ts index 5f79df90..c627628a 100644 --- a/packages/react/src/hooks/useUpdateNodePositions.ts +++ b/packages/react/src/hooks/useMoveSelectedNodes.ts @@ -1,22 +1,22 @@ import { useCallback } from 'react'; -import { calculateNodePosition, snapPosition } from '@xyflow/system'; +import { calculateNodePosition, snapPosition, type XYPosition } from '@xyflow/system'; import { Node } from '../types'; -import { useStoreApi } from '../hooks/useStore'; +import { useStoreApi } from './useStore'; const selectedAndDraggable = (nodesDraggable: boolean) => (n: Node) => n.selected && (n.draggable || (nodesDraggable && typeof n.draggable === 'undefined')); /** - * Hook for updating node positions. + * Hook for updating node positions by passing a direction and factor * * @internal * @returns function for updating node positions */ -export function useUpdateNodePositions() { +export function useMoveSelectedNodes() { const store = useStoreApi(); - const updatePositions = useCallback((params: { x: number; y: number; isShiftPressed: boolean }) => { + const moveSelectedNodes = useCallback((params: { direction: XYPosition; factor: number }) => { const { nodeExtent, nodes, @@ -29,14 +29,13 @@ export function useUpdateNodePositions() { nodeOrigin, } = store.getState(); const selectedNodes = nodes.filter(selectedAndDraggable(nodesDraggable)); - // by default a node moves 5px on each key press, or 20px if shift is pressed - // if snap grid is enabled, we use that for the velocity. + // by default a node moves 5px on each key press + // if snap grid is enabled, we use that for the velocity const xVelo = snapToGrid ? snapGrid[0] : 5; const yVelo = snapToGrid ? snapGrid[1] : 5; - const factor = params.isShiftPressed ? 4 : 1; - const xDiff = params.x * xVelo * factor; - const yDiff = params.y * yVelo * factor; + const xDiff = params.direction.x * xVelo * params.factor; + const yDiff = params.direction.y * yVelo * params.factor; const nodeUpdates = selectedNodes.map((node) => { if (node.computed?.positionAbsolute) { @@ -65,8 +64,8 @@ export function useUpdateNodePositions() { return node; }); - updateNodePositions(nodeUpdates, true, false); + updateNodePositions(nodeUpdates); }, []); - return updatePositions; + return moveSelectedNodes; } diff --git a/packages/react/src/store/index.ts b/packages/react/src/store/index.ts index 13311a1b..173d5e5c 100644 --- a/packages/react/src/store/index.ts +++ b/packages/react/src/store/index.ts @@ -151,19 +151,16 @@ const createRFStore = ({ onNodesChange?.(changes); } }, - updateNodePositions: (nodeDragItems, positionChanged = true, dragging = false) => { + updateNodePositions: (nodeDragItems, dragging = false) => { const changes = nodeDragItems.map((node) => { const change: NodePositionChange = { id: node.id, type: 'position', + position: node.position, + positionAbsolute: node.computed?.positionAbsolute, dragging, }; - if (positionChanged) { - change.positionAbsolute = node.computed?.positionAbsolute; - change.position = node.position; - } - return change; }); diff --git a/packages/svelte/CHANGELOG.md b/packages/svelte/CHANGELOG.md index b534df65..9d6cac3d 100644 --- a/packages/svelte/CHANGELOG.md +++ b/packages/svelte/CHANGELOG.md @@ -1,5 +1,9 @@ # @xyflow/svelte +## 0.0.36 + +- a better NodeResizer (child nodes do not move when parent node gets resized) + ## 0.0.35 ## Minor changes diff --git a/packages/svelte/package.json b/packages/svelte/package.json index 5095441e..ac688280 100644 --- a/packages/svelte/package.json +++ b/packages/svelte/package.json @@ -1,6 +1,6 @@ { "name": "@xyflow/svelte", - "version": "0.0.34", + "version": "0.0.35", "description": "Svelte Flow - A highly customizable Svelte library for building node-based editors, workflow systems, diagrams and more.", "keywords": [ "svelte", diff --git a/packages/svelte/src/lib/plugins/NodeResizer/ResizeControl.svelte b/packages/svelte/src/lib/plugins/NodeResizer/ResizeControl.svelte index 4688b4e7..56caa8b5 100644 --- a/packages/svelte/src/lib/plugins/NodeResizer/ResizeControl.svelte +++ b/packages/svelte/src/lib/plugins/NodeResizer/ResizeControl.svelte @@ -7,7 +7,8 @@ ResizeControlVariant, type ControlPosition, type XYResizerInstance, - type XYResizerChange + type XYResizerChange, + type XYResizerChildChange } from '@xyflow/system'; import type { ResizeControlProps } from './types'; @@ -66,7 +67,7 @@ snapToGrid: !!$snapGrid }; }, - onChange: (change: XYResizerChange) => { + onChange: (change: XYResizerChange, childChanges: XYResizerChildChange[]) => { const node = $nodeLookup.get(id); if (node) { node.height = change.isHeightChange ? change.height : node.height; @@ -76,6 +77,13 @@ ? { x: change.x, y: change.y } : node.position; + for (const childChange of childChanges) { + const childNode = $nodeLookup.get(childChange.id); + if (childNode) { + childNode.position = childChange.position; + } + } + $nodes = $nodes; } } diff --git a/packages/svelte/src/lib/store/index.ts b/packages/svelte/src/lib/store/index.ts index 7d5db531..e6243fb9 100644 --- a/packages/svelte/src/lib/store/index.ts +++ b/packages/svelte/src/lib/store/index.ts @@ -64,28 +64,22 @@ export function createStore({ } const updateNodePositions: UpdateNodePositions = (nodeDragItems, dragging = false) => { - store.nodes.update((nds) => { - return nds.map((node) => { - const nodeDragItem = (nodeDragItems as Array).find( - (ndi) => ndi.id === node.id - ); + const nodeLookup = get(store.nodeLookup); - if (nodeDragItem) { - return { - ...node, - dragging, - position: nodeDragItem.position, - computed: { - ...node.computed, - positionAbsolute: nodeDragItem.computed?.positionAbsolute - }, - [internalsSymbol]: node[internalsSymbol] - }; - } + nodeDragItems.forEach((nodeDragItem) => { + const node = nodeLookup.get(nodeDragItem.id); - return node; - }); + if (node) { + node.position = nodeDragItem.position; + node.dragging = dragging; + node.computed = { + ...node.computed, + positionAbsolute: nodeDragItem.computed?.positionAbsolute + }; + } }); + + store.nodes.set(get(store.nodes)); }; function updateNodeDimensions(updates: Map) { diff --git a/packages/system/package.json b/packages/system/package.json index 4eecdb57..e4fc6875 100644 --- a/packages/system/package.json +++ b/packages/system/package.json @@ -1,6 +1,6 @@ { "name": "@xyflow/system", - "version": "0.0.15", + "version": "0.0.16", "description": "xyflow core system that powers React Flow and Svelte Flow.", "keywords": [ "node-based UI", diff --git a/packages/system/src/types/general.ts b/packages/system/src/types/general.ts index 8ac3d6be..a3b5879d 100644 --- a/packages/system/src/types/general.ts +++ b/packages/system/src/types/general.ts @@ -123,11 +123,7 @@ export type SelectionRect = Rect & { export type OnError = (id: string, message: string) => void; -export type UpdateNodePositions = ( - dragItems: NodeDragItem[] | NodeBase[], - positionChanged?: boolean, - dragging?: boolean -) => void; +export type UpdateNodePositions = (dragItems: NodeDragItem[] | NodeBase[], dragging?: boolean) => void; export type PanBy = (delta: XYPosition) => boolean; export type UpdateConnection = (params: { diff --git a/packages/system/src/xydrag/XYDrag.ts b/packages/system/src/xydrag/XYDrag.ts index ffd19f37..5c13f825 100644 --- a/packages/system/src/xydrag/XYDrag.ts +++ b/packages/system/src/xydrag/XYDrag.ts @@ -170,7 +170,7 @@ export function XYDrag voi return; } - updateNodePositions(dragItems, true, true); + updateNodePositions(dragItems, true); const onNodeOrSelectionDrag = nodeId ? onNodeDrag : wrapSelectionDragFunc(onSelectionDrag); if (dragEvent && (onDrag || onNodeOrSelectionDrag)) { @@ -302,7 +302,7 @@ export function XYDrag voi const { nodeLookup, updateNodePositions, onNodeDragStop, onSelectionDragStop } = getStoreItems(); const onNodeOrSelectionDragStop = nodeId ? onNodeDragStop : wrapSelectionDragFunc(onSelectionDragStop); - updateNodePositions(dragItems, false, false); + updateNodePositions(dragItems, false); if (onDragStop || onNodeOrSelectionDragStop) { const [currentNode, currentNodes] = getEventHandlerParams({ diff --git a/packages/system/src/xyresizer/XYResizer.ts b/packages/system/src/xyresizer/XYResizer.ts index 63f146d5..c02ca47c 100644 --- a/packages/system/src/xyresizer/XYResizer.ts +++ b/packages/system/src/xyresizer/XYResizer.ts @@ -1,9 +1,9 @@ import { drag } from 'd3-drag'; import { select } from 'd3-selection'; -import { getControlDirection, getDimensionsAfterResize, getPositionAfterResize, getResizeDirection } from './utils'; +import { getControlDirection, getDimensionsAfterResize, getResizeDirection } from './utils'; import { getPointerPosition } from '../utils'; -import type { NodeLookup, Transform } from '../types'; +import type { CoordinateExtent, NodeBase, NodeLookup, Transform, XYPosition } from '../types'; import type { OnResize, OnResizeEnd, OnResizeStart, ResizeDragEvent, ShouldResize, ControlPosition } from './types'; const initPrevValues = { width: 0, height: 0, x: 0, y: 0 }; @@ -28,6 +28,12 @@ const initChange = { export type XYResizerChange = typeof initChange; +export type XYResizerChildChange = { + id: string; + position: XYPosition; + extent?: 'parent' | CoordinateExtent; +}; + type XYResizerParams = { domNode: HTMLDivElement; nodeId: string; @@ -37,7 +43,7 @@ type XYResizerParams = { snapGrid?: [number, number]; snapToGrid: boolean; }; - onChange: (changes: XYResizerChange) => void; + onChange: (changes: XYResizerChange, childChanges: XYResizerChildChange[]) => void; onEnd?: () => void; }; @@ -61,6 +67,23 @@ export type XYResizerInstance = { destroy: () => void; }; +function nodeToParentExtent(node: NodeBase): CoordinateExtent { + return [ + [0, 0], + [node.computed!.width!, node.computed!.height!], + ]; +} + +function nodeToChildExtent(child: NodeBase, parent: NodeBase): CoordinateExtent { + return [ + [parent.position.x + child.position.x, parent.position.y + child.position.y], + [ + parent.position.x + child.position.x + child.computed!.width!, + parent.position.y + child.position.y + child.computed!.height!, + ], + ]; +} + export function XYResizer({ domNode, nodeId, getStoreItems, onChange }: XYResizerParams): XYResizerInstance { const selection = select(domNode); @@ -78,53 +101,97 @@ export function XYResizer({ domNode, nodeId, getStoreItems, onChange }: XYResize const controlDirection = getControlDirection(controlPosition); + let node: NodeBase | undefined = undefined; + let childNodes: XYResizerChildChange[] = []; + let parentNode: NodeBase | undefined = undefined; // Needed to fix expandParent + let parentExtent: CoordinateExtent | undefined = undefined; + let childExtent: CoordinateExtent | undefined = undefined; + const dragHandler = drag() .on('start', (event: ResizeDragEvent) => { const { nodeLookup, transform, snapGrid, snapToGrid } = getStoreItems(); - const node = nodeLookup.get(nodeId); - const { xSnapped, ySnapped } = getPointerPosition(event.sourceEvent, { transform, snapGrid, snapToGrid }); + node = nodeLookup.get(nodeId); - prevValues = { - width: node?.computed?.width ?? 0, - height: node?.computed?.height ?? 0, - x: node?.position.x ?? 0, - y: node?.position.y ?? 0, - }; + if (node) { + const { xSnapped, ySnapped } = getPointerPosition(event.sourceEvent, { transform, snapGrid, snapToGrid }); - startValues = { - ...prevValues, - pointerX: xSnapped, - pointerY: ySnapped, - aspectRatio: prevValues.width / prevValues.height, - }; + prevValues = { + width: node.computed?.width ?? 0, + height: node.computed?.height ?? 0, + x: node.position.x ?? 0, + y: node.position.y ?? 0, + }; - onResizeStart?.(event, { ...prevValues }); + startValues = { + ...prevValues, + pointerX: xSnapped, + pointerY: ySnapped, + aspectRatio: prevValues.width / prevValues.height, + }; + + parentNode = undefined; + if (node.extent === 'parent' || node.expandParent) { + parentNode = nodeLookup.get(node.parentNode!); + if (parentNode && node.extent === 'parent') { + parentExtent = nodeToParentExtent(parentNode); + } + } + + // Collect all child nodes to correct their relative positions when top/left changes + // Determine largest minimal extent the parent node is allowed to resize to + childNodes = []; + childExtent = undefined; + + for (const [childId, child] of nodeLookup) { + if (child.parentNode === nodeId) { + childNodes.push({ + id: childId, + position: { ...child.position }, + extent: child.extent, + }); + + if (child.extent === 'parent' || child.expandParent) { + const extent = nodeToChildExtent(child, node!); + + if (childExtent) { + childExtent = [ + [Math.min(extent[0][0], childExtent[0][0]), Math.min(extent[0][1], childExtent[0][1])], + [Math.max(extent[1][0], childExtent[1][0]), Math.max(extent[1][1], childExtent[1][1])], + ]; + } else { + childExtent = extent; + } + } + } + } + + onResizeStart?.(event, { ...prevValues }); + } }) .on('drag', (event: ResizeDragEvent) => { - const { nodeLookup, transform, snapGrid, snapToGrid } = getStoreItems(); + const { transform, snapGrid, snapToGrid } = getStoreItems(); const pointerPosition = getPointerPosition(event.sourceEvent, { transform, snapGrid, snapToGrid }); - const node = nodeLookup.get(nodeId); + const childChanges: XYResizerChildChange[] = []; if (node) { const change = { ...initChange }; const { x: prevX, y: prevY, width: prevWidth, height: prevHeight } = prevValues; - const { width, height } = getDimensionsAfterResize( + const { width, height, x, y } = getDimensionsAfterResize( startValues, controlDirection, pointerPosition, boundaries, - keepAspectRatio + keepAspectRatio, + parentExtent, + childExtent ); const isWidthChange = width !== prevWidth; const isHeightChange = height !== prevHeight; if (controlDirection.affectsX || controlDirection.affectsY) { - const { x, y } = getPositionAfterResize(startValues, controlDirection, width, height); - - // only transform the node if the width or height changes const isXPosChange = x !== prevX && isWidthChange; const isYPosChange = y !== prevY && isHeightChange; @@ -136,6 +203,31 @@ export function XYResizer({ domNode, nodeId, getStoreItems, onChange }: XYResize prevValues.x = change.x; prevValues.y = change.y; + + // Fix expandParent when resizing from top/left + if (parentNode && node.expandParent) { + if (change.x < 0) { + prevValues.x = 0; + startValues.x = startValues.x - change.x; + } + + if (change.y < 0) { + prevValues.y = 0; + startValues.y = startValues.y - change.y; + } + } + } + + if (childNodes.length > 0) { + const xChange = x - prevX; + const yChange = y - prevY; + for (const childNode of childNodes) { + childNode.position = { + x: childNode.position.x - xChange, + y: childNode.position.y - yChange, + }; + childChanges.push(childNode); + } } } @@ -144,8 +236,8 @@ export function XYResizer({ domNode, nodeId, getStoreItems, onChange }: XYResize change.isHeightChange = isHeightChange; change.width = width; change.height = height; - prevValues.width = width; - prevValues.height = height; + prevValues.width = change.width; + prevValues.height = change.height; } if (!change.isXPosChange && !change.isYPosChange && !isWidthChange && !isHeightChange) { @@ -170,7 +262,7 @@ export function XYResizer({ domNode, nodeId, getStoreItems, onChange }: XYResize } onResize?.(event, nextValues); - onChange(change); + onChange(change, childChanges); } }) .on('end', (event: ResizeDragEvent) => { diff --git a/packages/system/src/xyresizer/utils.ts b/packages/system/src/xyresizer/utils.ts index 0d7d8a9e..a651df3f 100644 --- a/packages/system/src/xyresizer/utils.ts +++ b/packages/system/src/xyresizer/utils.ts @@ -1,4 +1,5 @@ -import { clamp, getPointerPosition } from '../utils'; +import { CoordinateExtent } from '../types'; +import { getPointerPosition } from '../utils'; import { ControlPosition } from './types'; type GetResizeDirectionParams = { @@ -75,81 +76,192 @@ type StartValues = PrevValues & { aspectRatio: number; }; +function getLowerExtentClamp(lowerExtent: number, lowerBound: number) { + return Math.max(0, lowerBound - lowerExtent); +} + +function getUpperExtentClamp(upperExtent: number, upperBound: number) { + return Math.max(0, upperExtent - upperBound); +} + +function getSizeClamp(size: number, minSize: number, maxSize: number) { + return Math.max(0, minSize - size, size - maxSize); +} + +function xor(a: boolean, b: boolean) { + return a ? !b : b; +} + /** - * Calculates new width & height of node after resize based on pointer position + * Calculates new width & height and x & y of node after resize based on pointer position + * @description - Buckle up, this is a chunky one! If you want to determine the new dimensions of a node after a resize, + * you have to account for all possible restrictions: min/max width/height of the node, the maximum extent the node is allowed + * to move in (in this case: resize into) determined by the parent node, the minimal extent determined by child nodes + * with expandParent or extent: 'parent' set and oh yeah, these things also have to work with keepAspectRatio! + * The way this is done is by determining how much each of these restricting actually restricts the resize and then applying the + * strongest restriction. Because the resize affects x, y and width, height and width, height of a opposing side with keepAspectRatio, + * the resize amount is always kept in distX & distY amount (the distance in mouse movement) + * Instead of clamping each value, we first calculate the biggest 'clamp' (for the lack of a better name) and then apply it to all values. * @param startValues - starting values of resize * @param controlDirection - dimensions affected by the resize * @param pointerPosition - the current pointer position corrected for snapping * @param boundaries - minimum and maximum dimensions of the node * @param keepAspectRatio - prevent changes of asprect ratio - * @returns width: new width of node, height: new height of node + * @returns x, y, width and height of the node after resize */ export function getDimensionsAfterResize( startValues: StartValues, controlDirection: ReturnType, pointerPosition: ReturnType, boundaries: { minWidth: number; maxWidth: number; minHeight: number; maxHeight: number }, - keepAspectRatio: boolean + keepAspectRatio: boolean, + extent?: CoordinateExtent, + childExtent?: CoordinateExtent ) { - const { isHorizontal, isVertical, affectsX, affectsY } = controlDirection; + let { affectsX, affectsY } = controlDirection; + const { isHorizontal, isVertical } = controlDirection; + const isDiagonal = isHorizontal && isVertical; + const { xSnapped, ySnapped } = pointerPosition; const { minWidth, maxWidth, minHeight, maxHeight } = boundaries; - const { pointerX: startX, pointerY: startY, width: startWidth, height: startHeight, aspectRatio } = startValues; - const distX = Math.floor(isHorizontal ? xSnapped - startX : 0); - const distY = Math.floor(isVertical ? ySnapped - startY : 0); + const { x: startX, y: startY, width: startWidth, height: startHeight, aspectRatio } = startValues; + let distX = Math.floor(isHorizontal ? xSnapped - startValues.pointerX : 0); + let distY = Math.floor(isVertical ? ySnapped - startValues.pointerY : 0); - let width = clamp(startWidth + (affectsX ? -distX : distX), minWidth, maxWidth); - let height = clamp(startHeight + (affectsY ? -distY : distY), minHeight, maxHeight); + const newWidth = startWidth + (affectsX ? -distX : distX); + const newHeight = startHeight + (affectsY ? -distY : distY); - if (keepAspectRatio) { - const nextAspectRatio = width / height; - const isDiagonal = isHorizontal && isVertical; - const isOnlyHorizontal = isHorizontal && !isVertical; - const isOnlyVertical = isVertical && !isHorizontal; + // Check if maxWidth, minWWidth, maxHeight, minHeight are restricting the resize + let clampX = getSizeClamp(newWidth, minWidth, maxWidth); + let clampY = getSizeClamp(newHeight, minHeight, maxHeight); - width = (nextAspectRatio <= aspectRatio && isDiagonal) || isOnlyVertical ? height * aspectRatio : width; - height = (nextAspectRatio > aspectRatio && isDiagonal) || isOnlyHorizontal ? width / aspectRatio : height; - - if (width >= maxWidth) { - width = maxWidth; - height = maxWidth / aspectRatio; - } else if (width <= minWidth) { - width = minWidth; - height = minWidth / aspectRatio; + // Check if extent is restricting the resize + if (extent) { + let xExtentClamp = 0; + let yExtentClamp = 0; + if (affectsX && distX < 0) { + xExtentClamp = getLowerExtentClamp(startX + distX, extent[0][0]); + } else if (!affectsX && distX > 0) { + xExtentClamp = getUpperExtentClamp(startX + newWidth, extent[1][0]); } - if (height >= maxHeight) { - height = maxHeight; - width = maxHeight * aspectRatio; - } else if (height <= minHeight) { - height = minHeight; - width = minHeight * aspectRatio; + if (affectsY && distY < 0) { + yExtentClamp = getLowerExtentClamp(startY + distY, extent[0][1]); + } else if (!affectsY && distY > 0) { + yExtentClamp = getUpperExtentClamp(startY + newHeight, extent[1][1]); + } + + clampX = Math.max(clampX, xExtentClamp); + clampY = Math.max(clampY, yExtentClamp); + } + + // Check if the child extent is restricting the resize + if (childExtent) { + let xExtentClamp = 0; + let yExtentClamp = 0; + if (affectsX && distX > 0) { + xExtentClamp = getUpperExtentClamp(startX + distX, childExtent[0][0]); + } else if (!affectsX && distX < 0) { + xExtentClamp = getLowerExtentClamp(startX + newWidth, childExtent[1][0]); + } + + if (affectsY && distY > 0) { + yExtentClamp = getUpperExtentClamp(startY + distY, childExtent[0][1]); + } else if (!affectsY && distY < 0) { + yExtentClamp = getLowerExtentClamp(startY + newHeight, childExtent[1][1]); + } + + clampX = Math.max(clampX, xExtentClamp); + clampY = Math.max(clampY, yExtentClamp); + } + + // Check if the aspect ratio resizing of the other side is restricting the resize + if (keepAspectRatio) { + if (isHorizontal) { + // Check if the max dimensions might be restricting the resize + const aspectHeightClamp = getSizeClamp(newWidth / aspectRatio, minHeight, maxHeight) * aspectRatio; + clampX = Math.max(clampX, aspectHeightClamp); + + // Check if the extent is restricting the resize + if (extent) { + let aspectExtentClamp = 0; + if ((!affectsX && !affectsY) || (affectsX && !affectsY && isDiagonal)) { + aspectExtentClamp = getUpperExtentClamp(startY + newWidth / aspectRatio, extent[1][1]) * aspectRatio; + } else { + aspectExtentClamp = + getLowerExtentClamp(startY + (affectsX ? distX : -distX) / aspectRatio, extent[0][1]) * aspectRatio; + } + clampX = Math.max(clampX, aspectExtentClamp); + } + + // Check if the child extent is restricting the resize + if (childExtent) { + let aspectExtentClamp = 0; + if ((!affectsX && !affectsY) || (affectsX && !affectsY && isDiagonal)) { + aspectExtentClamp = getLowerExtentClamp(startY + newWidth / aspectRatio, childExtent[1][1]) * aspectRatio; + } else { + aspectExtentClamp = + getUpperExtentClamp(startY + (affectsX ? distX : -distX) / aspectRatio, childExtent[0][1]) * aspectRatio; + } + clampX = Math.max(clampX, aspectExtentClamp); + } + } + + // Do the same thing for vertical resizing + if (isVertical) { + const aspectWidthClamp = getSizeClamp(newHeight * aspectRatio, minWidth, maxWidth) / aspectRatio; + clampY = Math.max(clampY, aspectWidthClamp); + + if (extent) { + let aspectExtentClamp = 0; + if ((!affectsX && !affectsY) || (affectsY && !affectsX && isDiagonal)) { + aspectExtentClamp = getUpperExtentClamp(startX + newHeight * aspectRatio, extent[1][0]) / aspectRatio; + } else { + aspectExtentClamp = + getLowerExtentClamp(startX + (affectsY ? distY : -distY) * aspectRatio, extent[0][0]) / aspectRatio; + } + clampY = Math.max(clampY, aspectExtentClamp); + } + + if (childExtent) { + let aspectExtentClamp = 0; + if ((!affectsX && !affectsY) || (affectsY && !affectsX && isDiagonal)) { + aspectExtentClamp = getLowerExtentClamp(startX + newHeight * aspectRatio, childExtent[1][0]) / aspectRatio; + } else { + aspectExtentClamp = + getUpperExtentClamp(startX + (affectsY ? distY : -distY) * aspectRatio, childExtent[0][0]) / aspectRatio; + } + clampY = Math.max(clampY, aspectExtentClamp); + } + } + } + + distY = distY + (distY < 0 ? clampY : -clampY); + distX = distX + (distX < 0 ? clampX : -clampX); + + if (keepAspectRatio) { + if (isDiagonal) { + if (newWidth > newHeight * aspectRatio) { + distY = (xor(affectsX, affectsY) ? -distX : distX) / aspectRatio; + } else { + distX = (xor(affectsX, affectsY) ? -distY : distY) * aspectRatio; + } + } else { + if (isHorizontal) { + distY = distX / aspectRatio; + affectsY = affectsX; + } else { + distX = distY * aspectRatio; + affectsX = affectsY; + } } } return { - width, - height, - }; -} - -/** - * Determines new x & y position of node after resize based on new width & height - * @param startValues - starting values of resize - * @param controlDirection - dimensions affected by the resize - * @param width - new width of node - * @param height - new height of node - * @returns x: new x position of node, y: new y position of node - */ -export function getPositionAfterResize( - startValues: StartValues, - controlDirection: ReturnType, - width: number, - height: number -) { - return { - x: controlDirection.affectsX ? startValues.x - (width - startValues.width) : startValues.x, - y: controlDirection.affectsY ? startValues.y - (height - startValues.height) : startValues.y, + width: startWidth + (affectsX ? -distX : distX), + height: startHeight + (affectsY ? -distY : distY), + x: affectsX ? startX + distX : startX, + y: affectsY ? startY + distY : startY, }; }