diff --git a/examples/react/src/App/routes.ts b/examples/react/src/App/routes.ts index 76ab08e4..444586ca 100644 --- a/examples/react/src/App/routes.ts +++ b/examples/react/src/App/routes.ts @@ -28,6 +28,7 @@ import NodeTypesObjectChange from '../examples/NodeTypesObjectChange'; import Overview from '../examples/Overview'; import Provider from '../examples/Provider'; import SaveRestore from '../examples/SaveRestore'; +import SetNodesBatching from '../examples/SetNodesBatching'; import Stress from '../examples/Stress'; import Subflow from '../examples/Subflow'; import SwitchFlow from '../examples/Switch'; @@ -226,6 +227,11 @@ const routes: IRoute[] = [ path: 'save-restore', component: SaveRestore, }, + { + name: 'SetNodes Batching', + path: 'setnodes-batching', + component: SetNodesBatching, + }, { name: 'Stress', path: 'stress', diff --git a/examples/react/src/examples/CustomMiniMapNode/index.tsx b/examples/react/src/examples/CustomMiniMapNode/index.tsx index 0b62196f..7ecf1c54 100644 --- a/examples/react/src/examples/CustomMiniMapNode/index.tsx +++ b/examples/react/src/examples/CustomMiniMapNode/index.tsx @@ -27,9 +27,9 @@ const buttonStyle: CSSProperties = { zIndex: 4, }; -const CustomMiniMapNode = ({ x, y, width, height, color }: MiniMapNodeProps) => ( - -); +const CustomMiniMapNode = ({ x, y, width, height }: MiniMapNodeProps) => { + return ; +}; const CustomMiniMapNodeFlow = () => { const [nodes, setNodes, onNodesChange] = useNodesState([]); 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/react/src/examples/SetNodesBatching/index.tsx b/examples/react/src/examples/SetNodesBatching/index.tsx new file mode 100644 index 00000000..2dc602eb --- /dev/null +++ b/examples/react/src/examples/SetNodesBatching/index.tsx @@ -0,0 +1,70 @@ +import { useCallback } from 'react'; +import { + ReactFlow, + MiniMap, + Background, + BackgroundVariant, + Controls, + ReactFlowProvider, + Node, + Edge, + useReactFlow, + Panel, +} from '@xyflow/react'; + +const a = { id: 'a', data: { label: 'A' }, position: { x: 250, y: 5 } }; +const b = { id: 'b', data: { label: 'B' }, position: { x: 100, y: 100 } }; +const c = { id: 'c', data: { label: 'C' }, position: { x: 400, y: 100 } }; + +const SetNotesBatchingFlow = () => { + const { setNodes, updateNode } = useReactFlow(); + + const triggerMultipleSetNodes = useCallback(() => { + setNodes([a]); + setNodes((nodes) => [...nodes, b]); + setNodes((nodes) => [...nodes, c]); + setNodes((nodes) => + nodes.map((node) => + node.id === 'a' ? { ...node, position: { x: node.position.x + 20, y: node.position.y + 20 } } : node + ) + ); + }, []); + + const triggerMultipleUpdateNodes = useCallback(() => { + triggerMultipleSetNodes(); + updateNode('a', (a) => ({ position: { x: a.position.x + 20, y: a.position.y + 20 } })); + updateNode('b', (b) => ({ position: { x: b.position.x + 20, y: b.position.y + 20 } })); + updateNode('c', (c) => ({ position: { x: c.position.x + 20, y: c.position.y + 20 } })); + updateNode('a', (a) => ({ data: { ...a.data, label: `A ${Date.now()}` } })); + updateNode('b', (b) => ({ data: { ...b.data, label: `B ${Date.now()}` } })); + updateNode('c', (c) => ({ data: { ...c.data, label: `C ${Date.now()}` } })); + }, []); + + return ( + + + + + + + + + + + ); +}; + +export default function App() { + return ( + + + + ); +} diff --git a/examples/react/src/examples/Subflow/index.tsx b/examples/react/src/examples/Subflow/index.tsx index 19dc119a..8ea28e34 100644 --- a/examples/react/src/examples/Subflow/index.tsx +++ b/examples/react/src/examples/Subflow/index.tsx @@ -90,7 +90,7 @@ const initialNodes: Node[] = [ data: { label: 'Node 5' }, position: { x: 650, y: 250 }, className: 'light', - style: { width: 400, height: 150 }, + style: { width: 100, height: 100 }, zIndex: 1000, }, { @@ -161,9 +161,12 @@ const Subflow = () => { setNodes((nds) => { return nds.map((n) => { if (!n.parentNode) { - n.position = { - x: Math.random() * 400, - y: Math.random() * 400, + return { + ...n, + position: { + x: Math.random() * 400, + y: Math.random() * 400, + }, }; } @@ -178,8 +181,10 @@ const Subflow = () => { const toggleClassnames = () => { setNodes((nds) => { return nds.map((n) => { - n.className = n.className === 'light' ? 'dark' : 'light'; - return n; + return { + ...n, + className: n.className === 'light' ? 'dark' : 'light', + }; }); }); }; @@ -187,8 +192,10 @@ const Subflow = () => { const toggleChildNodes = () => { setNodes((nds) => { return nds.map((n) => { - n.hidden = !!n.parentNode && !n.hidden; - return n; + return { + ...n, + hidden: !!n.parentNode && !n.hidden, + }; }); }); }; @@ -215,19 +222,12 @@ const Subflow = () => { - - - - + + + + + ); diff --git a/examples/react/src/examples/UseNodesData/TextNode.tsx b/examples/react/src/examples/UseNodesData/TextNode.tsx index 113d41fb..dd9f25d1 100644 --- a/examples/react/src/examples/UseNodesData/TextNode.tsx +++ b/examples/react/src/examples/UseNodesData/TextNode.tsx @@ -14,8 +14,16 @@ function TextNode({ id, data }: NodeProps) { return (
node {id}
-
- updateText(evt.target.value)} value={text} /> +
+ + updateText(evt.target.value)} value={text} style={{ display: 'block' }} /> + + + updateNodeData(id, { text: evt.target.value })} + value={data.text} + style={{ display: 'block' }} + />
diff --git a/examples/react/src/examples/UseNodesData/index.tsx b/examples/react/src/examples/UseNodesData/index.tsx index b9bc68f1..41c537ab 100644 --- a/examples/react/src/examples/UseNodesData/index.tsx +++ b/examples/react/src/examples/UseNodesData/index.tsx @@ -41,7 +41,6 @@ const initNodes: MyNode[] = [ data: {}, position: { x: 100, y: 0 }, }, - { id: '2', type: 'text', 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..e2e23ca4 100644 --- a/packages/react/CHANGELOG.md +++ b/packages/react/CHANGELOG.md @@ -1,5 +1,14 @@ # @xyflow/react +## 12.0.0-next.9 + +### Patch changes + +- a better `NodeResizer` that works with subflows. Child nodes do not move when parent node gets resized and parent extent is taken into account +- refactor `setNodes` batching +- re-measure nodes when necessary +- don't trigger drag start / end when node is not draggable + ## 12.0.0-next.8 ### Patch changes diff --git a/packages/react/package.json b/packages/react/package.json index 097cba0d..ddb6d327 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -1,6 +1,6 @@ { "name": "@xyflow/react", - "version": "12.0.0-next.8", + "version": "12.0.0-next.9", "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/MiniMap/MiniMap.tsx b/packages/react/src/additional-components/MiniMap/MiniMap.tsx index 60caca1b..c4bbf13b 100644 --- a/packages/react/src/additional-components/MiniMap/MiniMap.tsx +++ b/packages/react/src/additional-components/MiniMap/MiniMap.tsx @@ -49,9 +49,10 @@ function MiniMapComponent({ // We need to rename the prop to be `CapitalCase` so that JSX will render it as // a component properly. nodeComponent, + bgColor, maskColor, - maskStrokeColor = 'none', - maskStrokeWidth = 1, + maskStrokeColor, + maskStrokeWidth, position = 'bottom-right', onClick, onNodeClick, @@ -130,7 +131,11 @@ function MiniMapComponent({ style={ { ...style, - '--xy-minimap-mask-color-props': typeof maskColor === 'string' ? maskColor : undefined, + '--xy-minimap-background-color-props': typeof bgColor === 'string' ? bgColor : undefined, + '--xy-minimap-mask-background-color-props': typeof maskColor === 'string' ? maskColor : undefined, + '--xy-minimap-mask-stroke-color-props': typeof maskStrokeColor === 'string' ? maskStrokeColor : undefined, + '--xy-minimap-mask-stroke-width-props': + typeof maskStrokeWidth === 'number' ? maskStrokeWidth * viewScale : undefined, '--xy-minimap-node-background-color-props': typeof nodeColor === 'string' ? nodeColor : undefined, '--xy-minimap-node-stroke-color-props': typeof nodeStrokeColor === 'string' ? nodeStrokeColor : undefined, '--xy-minimap-node-stroke-width-props': typeof nodeStrokeWidth === 'string' ? nodeStrokeWidth : undefined, @@ -143,6 +148,7 @@ function MiniMapComponent({ width={elementWidth} height={elementHeight} viewBox={`${x} ${y} ${width} ${height}`} + className="react-flow__minimap-svg" role="img" aria-labelledby={labelledBy} ref={svg} @@ -163,8 +169,6 @@ function MiniMapComponent({ d={`M${x - offset},${y - offset}h${width + offset * 2}v${height + offset * 2}h${-width - offset * 2}z M${viewBB.x},${viewBB.y}h${viewBB.width}v${viewBB.height}h${-viewBB.width}z`} fillRule="evenodd" - stroke={maskStrokeColor} - strokeWidth={maskStrokeWidth} pointerEvents="none" /> diff --git a/packages/react/src/additional-components/MiniMap/types.ts b/packages/react/src/additional-components/MiniMap/types.ts index 31e67428..4bfd1a51 100644 --- a/packages/react/src/additional-components/MiniMap/types.ts +++ b/packages/react/src/additional-components/MiniMap/types.ts @@ -19,6 +19,8 @@ export type MiniMapProps = Omit; + /** Background color of minimap */ + bgColor?: string; /** Color of mask representing viewport */ maskColor?: string; /** Stroke color of mask representing viewport */ 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/EdgeWrapper/index.tsx b/packages/react/src/components/EdgeWrapper/index.tsx index b1fdb4a8..cbe01b8c 100644 --- a/packages/react/src/components/EdgeWrapper/index.tsx +++ b/packages/react/src/components/EdgeWrapper/index.tsx @@ -101,12 +101,12 @@ export function EdgeWrapper({ ); const markerStartUrl = useMemo( - () => (edge.markerStart ? `url(#${getMarkerId(edge.markerStart, rfId)})` : undefined), + () => (edge.markerStart ? `url('#${getMarkerId(edge.markerStart, rfId)}')` : undefined), [edge.markerStart, rfId] ); const markerEndUrl = useMemo( - () => (edge.markerEnd ? `url(#${getMarkerId(edge.markerEnd, rfId)})` : undefined), + () => (edge.markerEnd ? `url('#${getMarkerId(edge.markerEnd, rfId)}')` : undefined), [edge.markerEnd, rfId] ); diff --git a/packages/react/src/components/NodeWrapper/index.tsx b/packages/react/src/components/NodeWrapper/index.tsx index 017dd0f9..4199d158 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 { Node, NodeWrapperProps } from '../../types'; @@ -79,16 +79,33 @@ export function NodeWrapper({ const prevTargetPosition = useRef(node.targetPosition); const prevType = useRef(nodeType); - const updatePositions = useUpdateNodePositions(); + const width = node.width ?? undefined; + const height = node.height ?? undefined; + const computedWidth = node.computed?.width; + const computedHeight = node.computed?.height; + const initialized = (!!computedWidth && !!computedHeight) || (!!width && !!height); + const hasHandleBounds = !!node[internalsSymbol]?.handleBounds; + + const moveSelectedNodes = useMoveSelectedNodes(); + + useEffect(() => { + return () => { + if (nodeRef.current) { + resizeObserver?.unobserve(nodeRef.current); + } + }; + }, []); useEffect(() => { if (nodeRef.current && !node.hidden) { const currNode = nodeRef.current; - resizeObserver?.observe(currNode); - return () => resizeObserver?.unobserve(currNode); + if (!initialized || !hasHandleBounds) { + resizeObserver?.unobserve(currNode); + resizeObserver?.observe(currNode); + } } - }, [node.hidden]); + }, [node.hidden, initialized, hasHandleBounds]); useEffect(() => { // when the user programmatically changes the source or handle position, we re-initialize the node @@ -123,11 +140,6 @@ export function NodeWrapper({ return null; } - const width = node.width ?? undefined; - const height = node.height ?? undefined; - const computedWidth = node.computed?.width; - const computedHeight = node.computed?.height; - const positionAbsoluteOrigin = getPositionWithOrigin({ x: positionAbsoluteX, y: positionAbsoluteY, @@ -135,7 +147,6 @@ export function NodeWrapper({ height: computedHeight ?? height ?? 0, origin: node.origin || nodeOrigin, }); - const initialized = (!!computedWidth && !!computedHeight) || (!!width && !!height); const hasPointerEvents = isSelectable || isDraggable || onClick || onMouseEnter || onMouseMove || onMouseLeave; const onMouseEnterHandler = onMouseEnter ? (event: MouseEvent) => onMouseEnter(event, { ...node }) : undefined; @@ -188,10 +199,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 9ded77b8..c0423827 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'; @@ -39,7 +39,7 @@ export function NodesSelection({ }: NodesSelectionProps) { const store = useStoreApi(); const { width, height, transformString, userSelectionActive } = useStore(selector, shallow); - const updatePositions = useUpdateNodePositions(); + const moveSelectedNodes = useMoveSelectedNodes(); const nodeRef = useRef(null); @@ -68,10 +68,9 @@ export function NodesSelection({ 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/components/ReactFlowProvider/index.tsx b/packages/react/src/components/ReactFlowProvider/index.tsx index 34999c6f..3d2577e7 100644 --- a/packages/react/src/components/ReactFlowProvider/index.tsx +++ b/packages/react/src/components/ReactFlowProvider/index.tsx @@ -10,6 +10,8 @@ export function ReactFlowProvider({ children, initialNodes, initialEdges, + defaultNodes, + defaultEdges, initialWidth, initialHeight, fitView, @@ -17,16 +19,19 @@ export function ReactFlowProvider({ children: ReactNode; initialNodes?: Node[]; initialEdges?: Edge[]; + defaultNodes?: Node[]; + defaultEdges?: Edge[]; initialWidth?: number; initialHeight?: number; fitView?: boolean; }) { const storeRef = useRef> | null>(null); - if (!storeRef.current) { storeRef.current = createRFStore({ nodes: initialNodes, edges: initialEdges, + defaultNodes, + defaultEdges, width: initialWidth, height: initialHeight, fitView, diff --git a/packages/react/src/components/StoreUpdater/index.tsx b/packages/react/src/components/StoreUpdater/index.tsx index 9c77f5e0..85159721 100644 --- a/packages/react/src/components/StoreUpdater/index.tsx +++ b/packages/react/src/components/StoreUpdater/index.tsx @@ -9,7 +9,7 @@ import { infiniteExtent, type CoordinateExtent } from '@xyflow/system'; import { useStore, useStoreApi } from '../../hooks/useStore'; import type { Node, Edge, ReactFlowState, ReactFlowProps, FitViewOptions } from '../../types'; -import { initNodeOrigin } from '../../container/ReactFlow'; +import { defaultNodeOrigin } from '../../container/ReactFlow/init-values'; // these fields exist in the global store and we need to keep them up to date const reactFlowFieldsToTrack = [ @@ -81,50 +81,53 @@ const fieldsToTrack = [...reactFlowFieldsToTrack, 'rfId'] as const; const selector = (s: ReactFlowState) => ({ setNodes: s.setNodes, setEdges: s.setEdges, - setDefaultNodesAndEdges: s.setDefaultNodesAndEdges, setMinZoom: s.setMinZoom, setMaxZoom: s.setMaxZoom, setTranslateExtent: s.setTranslateExtent, setNodeExtent: s.setNodeExtent, reset: s.reset, + setDefaultNodesAndEdges: s.setDefaultNodesAndEdges, }); +const initPrevValues = { + // these are values that are also passed directly to other components + // than the StoreUpdater. We can reduce the number of setStore calls + // by setting the same values here as prev fields. + translateExtent: infiniteExtent, + nodeOrigin: defaultNodeOrigin, + minZoom: 0.5, + maxZoom: 2, + elementsSelectable: true, + noPanClassName: 'nopan', + rfId: '1', +}; + export function StoreUpdater( props: StoreUpdaterProps ) { const { setNodes, setEdges, - setDefaultNodesAndEdges, setMinZoom, setMaxZoom, setTranslateExtent, setNodeExtent, reset, + setDefaultNodesAndEdges, } = useStore(selector, shallow); const store = useStoreApi(); useEffect(() => { - const edgesWithDefaults = props.defaultEdges?.map((e) => ({ ...e, ...props.defaultEdgeOptions })); - setDefaultNodesAndEdges(props.defaultNodes, edgesWithDefaults); + setDefaultNodesAndEdges(props.defaultNodes, props.defaultEdges); return () => { + // when we reset the store we also need to reset the previous fields + previousFields.current = initPrevValues; reset(); }; }, []); - const previousFields = useRef>>({ - // these are values that are also passed directly to other components - // than the StoreUpdater. We can reduce the number of setStore calls - // by setting the same values here as prev fields. - translateExtent: infiniteExtent, - nodeOrigin: initNodeOrigin, - minZoom: 0.5, - maxZoom: 2, - elementsSelectable: true, - noPanClassName: 'nopan', - rfId: '1', - }); + const previousFields = useRef>>(initPrevValues); useEffect( () => { diff --git a/packages/react/src/container/GraphView/index.tsx b/packages/react/src/container/GraphView/index.tsx index f1965b04..80c25dea 100644 --- a/packages/react/src/container/GraphView/index.tsx +++ b/packages/react/src/container/GraphView/index.tsx @@ -27,7 +27,6 @@ export type GraphViewProps( nodesDraggable, nodesConnectable, nodesFocusable, - nodeOrigin = initNodeOrigin, + nodeOrigin = defaultNodeOrigin, edgesFocusable, edgesUpdatable, elementsSelectable = true, - defaultViewport = initDefaultViewport, + defaultViewport = initViewport, minZoom = 0.5, maxZoom = 2, translateExtent = infiniteExtent, @@ -230,7 +220,7 @@ function ReactFlow( viewport={viewport} onViewportChange={onViewportChange} /> - nodes={nodes} edges={edges} defaultNodes={defaultNodes} diff --git a/packages/react/src/container/ReactFlow/init-values.ts b/packages/react/src/container/ReactFlow/init-values.ts new file mode 100644 index 00000000..75e7fcaa --- /dev/null +++ b/packages/react/src/container/ReactFlow/init-values.ts @@ -0,0 +1,4 @@ +import { type NodeOrigin, Viewport } from '@xyflow/system'; + +export const defaultNodeOrigin: NodeOrigin = [0, 0]; +export const defaultViewport: Viewport = { x: 0, y: 0, zoom: 1 }; diff --git a/packages/react/src/hooks/useHandleConnections.ts b/packages/react/src/hooks/useHandleConnections.ts index 4f62615e..20b65a2d 100644 --- a/packages/react/src/hooks/useHandleConnections.ts +++ b/packages/react/src/hooks/useHandleConnections.ts @@ -1,5 +1,11 @@ import { useEffect, useMemo, useRef } from 'react'; -import { Connection, HandleType, areConnectionMapsEqual, handleConnectionChange } from '@xyflow/system'; +import { + Connection, + HandleConnection, + HandleType, + areConnectionMapsEqual, + handleConnectionChange, +} from '@xyflow/system'; import { useStore } from './useStore'; import { useNodeId } from '../contexts/NodeIdContext'; @@ -21,7 +27,7 @@ type useHandleConnectionsParams = { * @param param.id - the handle id (this is only needed if the node has multiple handles of the same type) * @param param.onConnect - gets called when a connection is established * @param param.onDisconnect - gets called when a connection is removed - * @returns an array with connections + * @returns an array with handle connections */ export function useHandleConnections({ type, @@ -29,10 +35,11 @@ export function useHandleConnections({ nodeId, onConnect, onDisconnect, -}: useHandleConnectionsParams): Connection[] { +}: useHandleConnectionsParams): HandleConnection[] { const _nodeId = useNodeId(); - const prevConnections = useRef | null>(null); - const currentNodeId = nodeId || _nodeId; + const currentNodeId = nodeId ?? _nodeId; + + const prevConnections = useRef | null>(null); const connections = useStore( (state) => state.connectionLookup.get(`${currentNodeId}-${type}-${id}`), diff --git a/packages/react/src/hooks/useKeyPress.ts b/packages/react/src/hooks/useKeyPress.ts index 77966207..9f738d06 100644 --- a/packages/react/src/hooks/useKeyPress.ts +++ b/packages/react/src/hooks/useKeyPress.ts @@ -92,6 +92,12 @@ export function useKeyPress( } else { pressedKeys.current.delete(event[keyOrCode]); } + + // fix for Mac: when cmd key is pressed, keyup is not triggered for any other key, see: https://stackoverflow.com/questions/27380018/when-cmd-key-is-kept-pressed-keyup-is-not-triggered-for-any-other-key + if (event.key === 'Meta') { + pressedKeys.current.clear(); + } + modifierPressed.current = false; }; 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/hooks/useReactFlow.ts b/packages/react/src/hooks/useReactFlow.ts index bdd05aa0..02080731 100644 --- a/packages/react/src/hooks/useReactFlow.ts +++ b/packages/react/src/hooks/useReactFlow.ts @@ -1,18 +1,9 @@ -import { useCallback, useMemo, useRef } from 'react'; +import { useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { getElementsToRemove, getOverlappingArea, isRectObject, nodeToRect, type Rect } from '@xyflow/system'; import useViewportHelper from './useViewportHelper'; import { useStoreApi } from './useStore'; -import type { - ReactFlowInstance, - Instance, - NodeAddChange, - EdgeAddChange, - Node, - Edge, - NodeChange, - EdgeChange, -} from '../types'; +import type { ReactFlowInstance, Instance, Node, Edge } from '../types'; import { getElementsDiffChanges, isNode } from '../utils'; /** @@ -46,82 +37,109 @@ export function useReactFlow e.id === id) as EdgeType; }, []); - // this is used to handle multiple syncronous setNodes calls - const setNodesData = useRef(); - const setNodesTimeout = useRef>(); - const setNodes = useCallback>((payload) => { - const { nodes = [], setNodes, hasDefaultNodes, onNodesChange, nodeLookup } = store.getState(); - const nextNodes = typeof payload === 'function' ? payload((setNodesData.current as NodeType[]) || nodes) : payload; + type SetElementsQueue = { + nodes: (NodeType[] | ((nodes: NodeType[]) => NodeType[]))[]; + edges: (EdgeType[] | ((edges: EdgeType[]) => EdgeType[]))[]; + }; - setNodesData.current = nextNodes; + // A reference of all the batched updates to process before the next render. We + // want a mutable reference here so multiple synchronous calls to `setNodes` etc + // can be batched together. + const setElementsQueue = useRef({ nodes: [], edges: [] }); + // Because we're using a ref above, we need some way to let React know when to + // actually process the queue. We flip this bit of state to `true` any time we + // mutate the queue and then flip it back to `false` after flushing the queue. + const [shouldFlushQueue, setShouldFlushQueue] = useState(false); - if (setNodesTimeout.current) { - clearTimeout(setNodesTimeout.current); + // Layout effects are guaranteed to run before the next render which means we + // shouldn't run into any issues with stale state or weird issues that come from + // rendering things one frame later than expected (we used to use `setTimeout`). + useLayoutEffect(() => { + // Because we need to flip the state back to false after flushing, this should + // trigger the hook again (!). If the hook is being run again we know that any + // updates should have been processed by now and we can safely clear the queue + // and bail early. + if (!shouldFlushQueue) { + setElementsQueue.current = { nodes: [], edges: [] }; + return; } - // if there are multiple synchronous setNodes calls, we only want to call onNodesChange once - // for this, we use a timeout to wait for the last call and store updated nodes in setNodesData - // this is not perfect, but should work in most cases - setNodesTimeout.current = setTimeout(() => { - if (hasDefaultNodes) { - setNodes(nextNodes); - } else if (onNodesChange) { - const changes: NodeChange[] = getElementsDiffChanges({ items: setNodesData.current, lookup: nodeLookup }); - onNodesChange(changes); + if (setElementsQueue.current.nodes.length) { + const { nodes = [], setNodes, hasDefaultNodes, onNodesChange, nodeLookup } = store.getState(); + + // This is essentially an `Array.reduce` in imperative clothing. Processing + // this queue is a relatively hot path so we'd like to avoid the overhead of + // array methods where we can. + let next = nodes as NodeType[]; + for (const payload of setElementsQueue.current.nodes) { + next = typeof payload === 'function' ? payload(next) : payload; } - setNodesData.current = undefined; - }, 0); + if (hasDefaultNodes) { + setNodes(next); + } else if (onNodesChange) { + onNodesChange( + getElementsDiffChanges({ + items: next, + lookup: nodeLookup, + }) + ); + } + + setElementsQueue.current.nodes = []; + } + + if (setElementsQueue.current.edges.length) { + const { edges = [], setEdges, hasDefaultEdges, onEdgesChange, edgeLookup } = store.getState(); + + let next = edges as EdgeType[]; + for (const payload of setElementsQueue.current.edges) { + next = typeof payload === 'function' ? payload(next) : payload; + } + + if (hasDefaultEdges) { + setEdges(next); + } else if (onEdgesChange) { + onEdgesChange( + getElementsDiffChanges({ + items: next, + lookup: edgeLookup, + }) + ); + } + + setElementsQueue.current.edges = []; + } + + // Beacuse we're using reactive state to trigger this effect, we need to flip + // it back to false. + setShouldFlushQueue(false); + }, [shouldFlushQueue]); + + const setNodes = useCallback>((payload) => { + setElementsQueue.current.nodes.push(payload); + setShouldFlushQueue(true); }, []); - // this is used to handle multiple syncronous setEdges calls - const setEdgesData = useRef(); - const setEdgesTimeout = useRef>(); const setEdges = useCallback>((payload) => { - const { edges = [], setEdges, hasDefaultEdges, onEdgesChange, edgeLookup } = store.getState(); - const nextEdges = typeof payload === 'function' ? payload((setEdgesData.current as EdgeType[]) || edges) : payload; - - setEdgesData.current = nextEdges; - - if (setEdgesTimeout.current) { - clearTimeout(setEdgesTimeout.current); - } - - setEdgesTimeout.current = setTimeout(() => { - if (hasDefaultEdges) { - setEdges(nextEdges); - } else if (onEdgesChange) { - const changes: EdgeChange[] = getElementsDiffChanges({ items: nextEdges, lookup: edgeLookup }); - onEdgesChange(changes); - } - - setEdgesData.current = undefined; - }, 0); + setElementsQueue.current.edges.push(payload); + setShouldFlushQueue(true); }, []); const addNodes = useCallback>((payload) => { - const nodes = Array.isArray(payload) ? payload : [payload]; - const { nodes: currentNodes, hasDefaultNodes, onNodesChange, setNodes } = store.getState(); + const newNodes = Array.isArray(payload) ? payload : [payload]; - if (hasDefaultNodes) { - const nextNodes = [...currentNodes, ...nodes]; - setNodes(nextNodes); - } else if (onNodesChange) { - const changes = nodes.map((node) => ({ item: node, type: 'add' } as NodeAddChange)); - onNodesChange(changes); - } + // Queueing a functional update means that we won't worry about other calls + // to `setNodes` that might happen elsewhere. + setElementsQueue.current.nodes.push((nodes) => [...nodes, ...newNodes]); + setShouldFlushQueue(true); }, []); const addEdges = useCallback>((payload) => { - const nextEdges = Array.isArray(payload) ? payload : [payload]; - const { edges = [], setEdges, hasDefaultEdges, onEdgesChange } = store.getState(); + const newEdges = Array.isArray(payload) ? payload : [payload]; - if (hasDefaultEdges) { - setEdges([...edges, ...nextEdges]); - } else if (onEdgesChange) { - const changes = nextEdges.map((edge) => ({ item: edge, type: 'add' } as EdgeAddChange)); - onEdgesChange(changes); - } + setElementsQueue.current.edges.push((edges) => [...edges, ...newEdges]); + setShouldFlushQueue(true); }, []); const toObject = useCallback>(() => { diff --git a/packages/react/src/store/index.ts b/packages/react/src/store/index.ts index 13311a1b..71a51a1e 100644 --- a/packages/react/src/store/index.ts +++ b/packages/react/src/store/index.ts @@ -10,8 +10,7 @@ import { updateConnectionLookup, } from '@xyflow/system'; -import { applyNodeChanges, createSelectionChange, getSelectionChanges } from '../utils/changes'; -import { updateNodesAndEdgesSelections } from './utils'; +import { applyEdgeChanges, applyNodeChanges, createSelectionChange, getSelectionChanges } from '../utils/changes'; import getInitialState from './initialState'; import type { ReactFlowState, @@ -28,19 +27,23 @@ import type { const createRFStore = ({ nodes, edges, + defaultNodes, + defaultEdges, width, height, fitView, }: { nodes?: Node[]; edges?: Edge[]; + defaultNodes?: Node[]; + defaultEdges?: Edge[]; width?: number; height?: number; fitView?: boolean; }) => createWithEqualityFn( (set, get) => ({ - ...getInitialState({ nodes, edges, width, height, fitView }), + ...getInitialState({ nodes, edges, width, height, fitView, defaultNodes, defaultEdges }), setNodes: (nodes: Node[]) => { const { nodeLookup, nodeOrigin, elevateNodesOnSelect } = get(); // setNodes() is called exclusively in response to user actions: @@ -49,7 +52,6 @@ const createRFStore = ({ // // When this happens, we take the note objects passed by the user and extend them with fields // relevant for internal React Flow operations. - // TODO: consider updating the types to reflect the distinction between user-provided nodes and internal nodes. const nodesWithInternalData = adoptUserProvidedNodes(nodes, nodeLookup, { nodeOrigin, elevateNodesOnSelect }); set({ nodes: nodesWithInternalData }); @@ -61,37 +63,17 @@ const createRFStore = ({ set({ edges }); }, - // when the user works with an uncontrolled flow, - // we set a flag `hasDefaultNodes` / `hasDefaultEdges` setDefaultNodesAndEdges: (nodes?: Node[], edges?: Edge[]) => { - const hasDefaultNodes = typeof nodes !== 'undefined'; - const hasDefaultEdges = typeof edges !== 'undefined'; - - const nextState: { - nodes?: Node[]; - edges?: Edge[]; - hasDefaultNodes: boolean; - hasDefaultEdges: boolean; - } = { - hasDefaultNodes, - hasDefaultEdges, - }; - - if (hasDefaultNodes) { - const { nodeLookup, nodeOrigin, elevateNodesOnSelect } = get(); - nextState.nodes = adoptUserProvidedNodes(nodes, nodeLookup, { - nodeOrigin, - elevateNodesOnSelect, - }); + if (nodes) { + const { setNodes } = get(); + setNodes(nodes); + set({ hasDefaultNodes: true }); } - if (hasDefaultEdges) { - const { connectionLookup, edgeLookup } = get(); - updateConnectionLookup(connectionLookup, edgeLookup, edges); - - nextState.edges = edges; + if (edges) { + const { setEdges } = get(); + setEdges(edges); + set({ hasDefaultEdges: true }); } - - set(nextState); }, // Every node gets registerd at a ResizeObserver. Whenever a node // changes its dimensions, this function is called to measure the @@ -151,99 +133,82 @@ 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; }); get().triggerNodeChanges(changes); }, - triggerNodeChanges: (changes) => { - const { onNodesChange, nodeLookup, nodes, hasDefaultNodes, nodeOrigin, elevateNodesOnSelect } = get(); + const { onNodesChange, setNodes, nodes, hasDefaultNodes } = get(); if (changes?.length) { if (hasDefaultNodes) { const updatedNodes = applyNodeChanges(changes, nodes); - const nextNodes = adoptUserProvidedNodes(updatedNodes, nodeLookup, { - nodeOrigin, - elevateNodesOnSelect, - }); - set({ nodes: nextNodes }); + setNodes(updatedNodes); } onNodesChange?.(changes); } }, + triggerEdgeChanges: (changes) => { + const { onEdgesChange, setEdges, edges, hasDefaultEdges } = get(); + if (changes?.length) { + if (hasDefaultEdges) { + const updatedEdges = applyEdgeChanges(changes, edges); + setEdges(updatedEdges); + } + + onEdgesChange?.(changes); + } + }, addSelectedNodes: (selectedNodeIds) => { - const { multiSelectionActive, edges, nodes } = get(); - let changedNodes: NodeSelectionChange[]; - let changedEdges: EdgeSelectionChange[] | null = null; + const { multiSelectionActive, edges, nodes, triggerNodeChanges, triggerEdgeChanges } = get(); if (multiSelectionActive) { - changedNodes = selectedNodeIds.map((nodeId) => createSelectionChange(nodeId, true)) as NodeSelectionChange[]; - } else { - changedNodes = getSelectionChanges(nodes, new Set([...selectedNodeIds]), true); - changedEdges = getSelectionChanges(edges); + const nodeChanges = selectedNodeIds.map((nodeId) => createSelectionChange(nodeId, true)); + triggerNodeChanges(nodeChanges as NodeSelectionChange[]); + return; } - updateNodesAndEdgesSelections({ - changedNodes, - changedEdges, - get, - set, - }); + triggerNodeChanges(getSelectionChanges(nodes, new Set([...selectedNodeIds]), true)); + triggerEdgeChanges(getSelectionChanges(edges)); }, addSelectedEdges: (selectedEdgeIds) => { - const { multiSelectionActive, edges, nodes } = get(); - let changedEdges: EdgeSelectionChange[]; - let changedNodes: NodeSelectionChange[] | null = null; + const { multiSelectionActive, edges, nodes, triggerNodeChanges, triggerEdgeChanges } = get(); if (multiSelectionActive) { - changedEdges = selectedEdgeIds.map((edgeId) => createSelectionChange(edgeId, true)) as EdgeSelectionChange[]; - } else { - changedEdges = getSelectionChanges(edges, new Set([...selectedEdgeIds])); - changedNodes = getSelectionChanges(nodes, new Set(), true); + const changedEdges = selectedEdgeIds.map((edgeId) => createSelectionChange(edgeId, true)); + triggerEdgeChanges(changedEdges as EdgeSelectionChange[]); + return; } - updateNodesAndEdgesSelections({ - changedNodes, - changedEdges, - get, - set, - }); + triggerEdgeChanges(getSelectionChanges(edges, new Set([...selectedEdgeIds]))); + triggerNodeChanges(getSelectionChanges(nodes, new Set(), true)); }, unselectNodesAndEdges: ({ nodes, edges }: UnselectNodesAndEdgesParams = {}) => { - const { edges: storeEdges, nodes: storeNodes } = get(); + const { edges: storeEdges, nodes: storeNodes, triggerNodeChanges, triggerEdgeChanges } = get(); const nodesToUnselect = nodes ? nodes : storeNodes; const edgesToUnselect = edges ? edges : storeEdges; - const changedNodes = nodesToUnselect.map((n) => { + const nodeChanges = nodesToUnselect.map((n) => { n.selected = false; return createSelectionChange(n.id, false); - }) as NodeSelectionChange[]; - const changedEdges = edgesToUnselect.map((edge) => - createSelectionChange(edge.id, false) - ) as EdgeSelectionChange[]; - - updateNodesAndEdgesSelections({ - changedNodes, - changedEdges, - get, - set, }); + const edgeChanges = edgesToUnselect.map((edge) => createSelectionChange(edge.id, false)); + + triggerNodeChanges(nodeChanges as NodeSelectionChange[]); + triggerEdgeChanges(edgeChanges as EdgeSelectionChange[]); }, setMinZoom: (minZoom) => { const { panZoom, maxZoom } = get(); @@ -263,21 +228,19 @@ const createRFStore = ({ set({ translateExtent }); }, resetSelectedElements: () => { - const { edges, nodes } = get(); + const { edges, nodes, triggerNodeChanges, triggerEdgeChanges } = get(); - const nodesToUnselect = nodes - .filter((e) => e.selected) - .map((n) => createSelectionChange(n.id, false)) as NodeSelectionChange[]; - const edgesToUnselect = edges - .filter((e) => e.selected) - .map((e) => createSelectionChange(e.id, false)) as EdgeSelectionChange[]; + const nodeChanges = nodes.reduce( + (res, node) => (node.selected ? [...res, createSelectionChange(node.id, false) as NodeSelectionChange] : res), + [] + ); + const edgeChanges = edges.reduce( + (res, edge) => (edge.selected ? [...res, createSelectionChange(edge.id, false) as EdgeSelectionChange] : res), + [] + ); - updateNodesAndEdgesSelections({ - changedNodes: nodesToUnselect, - changedEdges: edgesToUnselect, - get, - set, - }); + triggerNodeChanges(nodeChanges); + triggerEdgeChanges(edgeChanges); }, setNodeExtent: (nodeExtent) => { const { nodes } = get(); @@ -340,13 +303,7 @@ const createRFStore = ({ set(currentConnection); }, - reset: () => { - // @todo: what should we do about this? Do we still need it? - // if you are on a SPA with multiple flows, we want to make sure that the store gets resetted - // when you switch pages. Does this reset solves this? Currently it always gets called. This - // leads to an emtpy nodes array at the beginning. - // set({ ...getInitialState() }); - }, + reset: () => set({ ...getInitialState() }), }), Object.is ); diff --git a/packages/react/src/store/initialState.ts b/packages/react/src/store/initialState.ts index aace0bb4..5a1f9562 100644 --- a/packages/react/src/store/initialState.ts +++ b/packages/react/src/store/initialState.ts @@ -12,14 +12,18 @@ import { import type { Edge, Node, ReactFlowStore } from '../types'; const getInitialState = ({ - nodes = [], - edges = [], + nodes, + edges, + defaultNodes, + defaultEdges, width, height, fitView, }: { nodes?: Node[]; edges?: Edge[]; + defaultNodes?: Node[]; + defaultEdges?: Edge[]; width?: number; height?: number; fitView?: boolean; @@ -27,9 +31,11 @@ const getInitialState = ({ const nodeLookup = new Map(); const connectionLookup = new Map(); const edgeLookup = new Map(); + const storeEdges = defaultEdges ?? edges ?? []; + const storeNodes = defaultNodes ?? nodes ?? []; - updateConnectionLookup(connectionLookup, edgeLookup, edges); - const nextNodes = adoptUserProvidedNodes(nodes, nodeLookup, { + updateConnectionLookup(connectionLookup, edgeLookup, storeEdges); + const nextNodes = adoptUserProvidedNodes(storeNodes, nodeLookup, { nodeOrigin: [0, 0], elevateNodesOnSelect: false, }); @@ -51,13 +57,13 @@ const getInitialState = ({ transform, nodes: nextNodes, nodeLookup, - edges, + edges: storeEdges, edgeLookup, connectionLookup, onNodesChange: null, onEdgesChange: null, - hasDefaultNodes: false, - hasDefaultEdges: false, + hasDefaultNodes: defaultNodes !== undefined, + hasDefaultEdges: defaultEdges !== undefined, panZoom: null, minZoom: 0.5, maxZoom: 2, diff --git a/packages/react/src/store/utils.ts b/packages/react/src/store/utils.ts deleted file mode 100644 index 86cddde7..00000000 --- a/packages/react/src/store/utils.ts +++ /dev/null @@ -1,44 +0,0 @@ -import type { StoreApi } from 'zustand'; -import type { Edge, EdgeSelectionChange, Node, NodeSelectionChange, ReactFlowState } from '../types'; - -export function handleControlledSelectionChange( - changes: NodeSelectionChange[] | EdgeSelectionChange[], - items: NodeOrEdge[] -): NodeOrEdge[] { - return items.map((item) => { - const change = changes.find((change) => change.id === item.id); - - if (change) { - item.selected = change.selected; - } - - return item; - }); -} - -type UpdateNodesAndEdgesParams = { - changedNodes: NodeSelectionChange[] | null; - changedEdges: EdgeSelectionChange[] | null; - get: StoreApi['getState']; - set: StoreApi['setState']; -}; - -export function updateNodesAndEdgesSelections({ changedNodes, changedEdges, get, set }: UpdateNodesAndEdgesParams) { - const { nodes, edges, onNodesChange, onEdgesChange, hasDefaultNodes, hasDefaultEdges } = get(); - - if (changedNodes?.length) { - if (hasDefaultNodes) { - set({ nodes: handleControlledSelectionChange(changedNodes, nodes) }); - } - - onNodesChange?.(changedNodes); - } - - if (changedEdges?.length) { - if (hasDefaultEdges) { - set({ edges: handleControlledSelectionChange(changedEdges, edges) }); - } - - onEdgesChange?.(changedEdges); - } -} diff --git a/packages/react/src/types/component-props.ts b/packages/react/src/types/component-props.ts index ce6e94ad..650d0b0c 100644 --- a/packages/react/src/types/component-props.ts +++ b/packages/react/src/types/component-props.ts @@ -347,11 +347,11 @@ export interface ReactFlowProps = { @@ -165,6 +166,7 @@ export type ReactFlowActions = { updateConnection: UpdateConnection; reset: () => void; triggerNodeChanges: (changes: NodeChange[]) => void; + triggerEdgeChanges: (changes: EdgeChange[]) => void; panBy: PanBy; fitView: (nodes: NodeType[], options?: FitViewOptions) => boolean; }; diff --git a/packages/react/src/utils/changes.ts b/packages/react/src/utils/changes.ts index 310b784c..fa6f6f30 100644 --- a/packages/react/src/utils/changes.ts +++ b/packages/react/src/utils/changes.ts @@ -219,11 +219,13 @@ export function applyEdgeChanges( return applyChanges(changes, edges) as EdgeType[]; } -export const createSelectionChange = (id: string, selected: boolean): NodeSelectionChange | EdgeSelectionChange => ({ - id, - type: 'select', - selected, -}); +export function createSelectionChange(id: string, selected: boolean): NodeSelectionChange | EdgeSelectionChange { + return { + id, + type: 'select', + selected, + }; +} export function getSelectionChanges( items: any[], diff --git a/packages/svelte/CHANGELOG.md b/packages/svelte/CHANGELOG.md index b534df65..2f34e1a3 100644 --- a/packages/svelte/CHANGELOG.md +++ b/packages/svelte/CHANGELOG.md @@ -1,5 +1,16 @@ # @xyflow/svelte +## 0.0.36 + +## Patch changes + +- a better NodeResizer (child nodes do not move when parent node gets resized) +- fix `on:panecontextmenu` +- add `role="button"` to `` to prevent a11y warnings +- don't delete node when input is focused and user presses Backspace + Ctrl (or any other mod key) +- `useHandleConnections`: use context node id when no node id is passed +- don't trigger drag start / end when node is not draggable + ## 0.0.35 ## Minor changes diff --git a/packages/svelte/package.json b/packages/svelte/package.json index ac688280..6fe2bec2 100644 --- a/packages/svelte/package.json +++ b/packages/svelte/package.json @@ -1,6 +1,6 @@ { "name": "@xyflow/svelte", - "version": "0.0.35", + "version": "0.0.36", "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/components/EdgeLabel/EdgeLabel.svelte b/packages/svelte/src/lib/components/EdgeLabel/EdgeLabel.svelte index 81039d0d..bc12b604 100644 --- a/packages/svelte/src/lib/components/EdgeLabel/EdgeLabel.svelte +++ b/packages/svelte/src/lib/components/EdgeLabel/EdgeLabel.svelte @@ -19,6 +19,7 @@ class="svelte-flow__edge-label" style:transform="translate(-50%, -50%) translate({x}px,{y}px)" style={'pointer-events: all;' + style} + role="button" on:click={() => { if (id) handleEdgeSelect(id); }} diff --git a/packages/svelte/src/lib/components/Handle/Handle.svelte b/packages/svelte/src/lib/components/Handle/Handle.svelte index f00ccc44..f8a43dd4 100644 --- a/packages/svelte/src/lib/components/Handle/Handle.svelte +++ b/packages/svelte/src/lib/components/Handle/Handle.svelte @@ -6,7 +6,7 @@ Position, XYHandle, isMouseEvent, - type Connection, + type HandleConnection, areConnectionMapsEqual, handleConnectionChange } from '@xyflow/system'; @@ -103,8 +103,8 @@ } } - let prevConnections: Map | null = null; - let connections: Map | undefined; + let prevConnections: Map | null = null; + let connections: Map | undefined; $: if (onconnect || ondisconnect) { // connectionLookup is not reactive, so we use edges to get notified about updates diff --git a/packages/svelte/src/lib/components/KeyHandler/KeyHandler.svelte b/packages/svelte/src/lib/components/KeyHandler/KeyHandler.svelte index f6697cb2..4c4f138e 100644 --- a/packages/svelte/src/lib/components/KeyHandler/KeyHandler.svelte +++ b/packages/svelte/src/lib/components/KeyHandler/KeyHandler.svelte @@ -113,7 +113,15 @@ { ...deleteKeyDefinition, enabled: deleteKeyDefinition.key !== null, - callback: (detail) => !isInputDOMNode(detail.originalEvent) && deleteKeyPressed.set(true) + callback: (detail) => { + const isModifierKey = + detail.originalEvent.ctrlKey || + detail.originalEvent.metaKey || + detail.originalEvent.shiftKey; + if (!isModifierKey && !isInputDOMNode(detail.originalEvent)) { + deleteKeyPressed.set(true); + } + } } ], type: 'keydown' diff --git a/packages/svelte/src/lib/container/SvelteFlow/SvelteFlow.svelte b/packages/svelte/src/lib/container/SvelteFlow/SvelteFlow.svelte index 7f3baf36..6a80ef62 100644 --- a/packages/svelte/src/lib/container/SvelteFlow/SvelteFlow.svelte +++ b/packages/svelte/src/lib/container/SvelteFlow/SvelteFlow.svelte @@ -215,7 +215,12 @@ panOnScroll={panOnScroll === undefined ? false : panOnScroll} panOnDrag={panOnDrag === undefined ? true : panOnDrag} > - + & { */ nodeDragThreshold?: number; /** Minimum zoom level - * @default 0.1 + * @default 0.5 */ minZoom?: number; /** Maximum zoom level - * @default 1 + * @default 2 */ maxZoom?: number; /** Sets the initial position and zoom of the viewport. diff --git a/packages/svelte/src/lib/hooks/useHandleConnections.ts b/packages/svelte/src/lib/hooks/useHandleConnections.ts index 27e61c11..7aec3858 100644 --- a/packages/svelte/src/lib/hooks/useHandleConnections.ts +++ b/packages/svelte/src/lib/hooks/useHandleConnections.ts @@ -1,15 +1,16 @@ import { derived } from 'svelte/store'; -import { areConnectionMapsEqual, type Connection, type HandleType } from '@xyflow/system'; +import { areConnectionMapsEqual, type HandleConnection, type HandleType } from '@xyflow/system'; import { useStore } from '$lib/store'; +import { getContext } from 'svelte'; export type useHandleConnectionsParams = { - nodeId: string; type: HandleType; + nodeId?: string; id?: string | null; }; -const initialConnections: Connection[] = []; +const initialConnections: HandleConnection[] = []; /** * Hook to check if a is connected to another and get the connections. @@ -20,14 +21,18 @@ const initialConnections: Connection[] = []; * @param param.id - the handle id (this is only needed if the node has multiple handles of the same type) * @returns an array with connections */ -export function useHandleConnections({ nodeId, type, id = null }: useHandleConnectionsParams) { +export function useHandleConnections({ type, nodeId, id = null }: useHandleConnectionsParams) { const { edges, connectionLookup } = useStore(); - let prevConnections: Map | undefined = undefined; + + const _nodeId = getContext('svelteflow__node_id'); + const currentNodeId = nodeId ?? _nodeId; + + let prevConnections: Map | undefined = undefined; return derived( [edges, connectionLookup], ([, connectionLookup], set) => { - const nextConnections = connectionLookup.get(`${nodeId}-${type}-${id || null}`); + const nextConnections = connectionLookup.get(`${currentNodeId}-${type}-${id || null}`); if (!areConnectionMapsEqual(nextConnections, prevConnections)) { prevConnections = nextConnections; diff --git a/packages/svelte/src/lib/plugins/Minimap/Minimap.svelte b/packages/svelte/src/lib/plugins/Minimap/Minimap.svelte index 7b2896d8..031235b6 100644 --- a/packages/svelte/src/lib/plugins/Minimap/Minimap.svelte +++ b/packages/svelte/src/lib/plugins/Minimap/Minimap.svelte @@ -83,7 +83,7 @@ @@ -92,12 +92,14 @@ width={elementWidth} height={elementHeight} viewBox="{x} {y} {viewboxWidth} {viewboxHeight}" + class="svelte-flow__minimap-svg" role="img" aria-labelledby={labelledBy} - style:--xy-minimap-background-color-props={bgColor} - style:--xy-minimap-mask-color-props={maskColor} + style:--xy-minimap-mask-background-color-props={maskColor} style:--xy-minimap-mask-stroke-color-props={maskStrokeColor} - style:--xy-minimap-mask-stroke-width-props={maskStrokeWidth} + style:--xy-minimap-mask-stroke-width-props={maskStrokeWidth + ? maskStrokeWidth * viewScale + : undefined} use:interactive={{ panZoom: $panZoom, viewport, 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 e4fc6875..f08cdd5b 100644 --- a/packages/system/package.json +++ b/packages/system/package.json @@ -1,6 +1,6 @@ { "name": "@xyflow/system", - "version": "0.0.16", + "version": "0.0.17", "description": "xyflow core system that powers React Flow and Svelte Flow.", "keywords": [ "node-based UI", diff --git a/packages/system/src/styles/init.css b/packages/system/src/styles/init.css index 14519c74..270a6ab2 100644 --- a/packages/system/src/styles/init.css +++ b/packages/system/src/styles/init.css @@ -12,6 +12,8 @@ --xy-minimap-background-color-default: #fff; --xy-minimap-mask-background-color-default: rgb(240, 240, 240, 0.6); + --xy-minimap-mask-stroke-color-default: transparent; + --xy-minimap-mask-stroke-width-default: 1; --xy-minimap-node-background-color-default: #e2e2e2; --xy-minimap-node-stroke-color-default: transparent; --xy-minimap-node-stroke-width-default: 2; @@ -34,6 +36,8 @@ --xy-minimap-background-color-default: #141414; --xy-minimap-mask-background-color-default: rgb(60, 60, 60, 0.6); + --xy-minimap-mask-stroke-color-default: transparent; + --xy-minimap-mask-stroke-width-default: 1; --xy-minimap-node-background-color-default: #2b2b2b; --xy-minimap-node-stroke-color-default: transparent; --xy-minimap-node-stroke-width-default: 2; @@ -310,13 +314,28 @@ svg.xy-flow__connectionline { } .xy-flow__minimap { - background: var(--xy-minimap-background-color, var(--xy-minimap-background-color-default)); + background: var( + --xy-minimap-background-color-props, + var(--xy-minimap-background-color, var(--xy-minimap-background-color-default)) + ); + + &-svg { + display: block; + } &-mask { fill: var( --xy-minimap-mask-background-color-props, var(--xy-minimap-mask-background-color, var(--xy-minimap-mask-background-color-default)) ); + stroke: var( + --xy-minimap-mask-stroke-color-props, + var(--xy-minimap-mask-stroke-color, var(--xy-minimap-mask-stroke-color-default)) + ); + stroke-width: var( + --xy-minimap-mask-stroke-width-props, + var(--xy-minimap-mask-stroke-width, var(--xy-minimap-mask-stroke-width-default)) + ); } &-node { diff --git a/packages/system/src/types/general.ts b/packages/system/src/types/general.ts index 8ac3d6be..b7aa248e 100644 --- a/packages/system/src/types/general.ts +++ b/packages/system/src/types/general.ts @@ -28,6 +28,10 @@ export type Connection = { targetHandle: string | null; }; +export type HandleConnection = Connection & { + edgeId: string; +}; + export type ConnectionStatus = 'valid' | 'invalid'; export enum ConnectionMode { @@ -123,11 +127,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: { @@ -140,7 +140,7 @@ export type UpdateConnection = (params: { export type ColorModeClass = 'light' | 'dark'; export type ColorMode = ColorModeClass | 'system'; -export type ConnectionLookup = Map>; +export type ConnectionLookup = Map>; export type OnBeforeDeleteBase = ({ nodes, diff --git a/packages/system/src/utils/connections.ts b/packages/system/src/utils/connections.ts index ded405c3..b99e6c14 100644 --- a/packages/system/src/utils/connections.ts +++ b/packages/system/src/utils/connections.ts @@ -1,9 +1,9 @@ -import { Connection } from '../types'; +import { HandleConnection } from '../types'; /** * @internal */ -export function areConnectionMapsEqual(a?: Map, b?: Map) { +export function areConnectionMapsEqual(a?: Map, b?: Map) { if (!a && !b) { return true; } @@ -31,15 +31,15 @@ export function areConnectionMapsEqual(a?: Map, b?: Map, - b: Map, - cb?: (diff: Connection[]) => void + a: Map, + b: Map, + cb?: (diff: HandleConnection[]) => void ) { if (!cb) { return; } - const diff: Connection[] = []; + const diff: HandleConnection[] = []; a.forEach((connection, key) => { if (!b?.has(key)) { diff --git a/packages/system/src/utils/dom.ts b/packages/system/src/utils/dom.ts index 3cf5cd61..9312bc07 100644 --- a/packages/system/src/utils/dom.ts +++ b/packages/system/src/utils/dom.ts @@ -37,11 +37,9 @@ export function isInputDOMNode(event: KeyboardEvent): boolean { // using composed path for handling shadow dom const target = (event.composedPath?.()?.[0] || event.target) as HTMLElement; const isInput = inputTags.includes(target?.nodeName) || target?.hasAttribute('contenteditable'); - // we want to be able to do a multi selection event if we are in an input field - const isModifierKey = event.ctrlKey || event.metaKey || event.shiftKey; // when an input field is focused we don't want to trigger deletion or movement of nodes - return (isInput && !isModifierKey) || !!target?.closest('.nokey'); + return isInput || !!target?.closest('.nokey'); } export const isMouseEvent = (event: MouseEvent | TouchEvent): event is MouseEvent => 'clientX' in event; diff --git a/packages/system/src/utils/store.ts b/packages/system/src/utils/store.ts index 51cb1329..c04c7e51 100644 --- a/packages/system/src/utils/store.ts +++ b/packages/system/src/utils/store.ts @@ -94,8 +94,8 @@ export function adoptUserProvidedNodes( ...n, computed: { positionAbsolute: n.position, - width: n.computed?.width || currentStoreNode?.computed?.width, - height: n.computed?.height || currentStoreNode?.computed?.height, + width: n.computed?.width, + height: n.computed?.height, }, }; const z = (isNumeric(n.zIndex) ? n.zIndex : 0) + (n.selected ? selectedNodeZ : 0); @@ -260,7 +260,7 @@ export function updateConnectionLookup(connectionLookup: ConnectionLookup, edgeL const prevSource = connectionLookup.get(sourceKey) || new Map(); const prevTarget = connectionLookup.get(targetKey) || new Map(); - const connection = { source, target, sourceHandle, targetHandle }; + const connection = { edgeId: edge.id, source, target, sourceHandle, targetHandle }; edgeLookup.set(edge.id, edge); connectionLookup.set(sourceKey, prevSource.set(`${target}-${targetHandle}`, connection)); diff --git a/packages/system/src/xydrag/XYDrag.ts b/packages/system/src/xydrag/XYDrag.ts index ffd19f37..91ed7fb8 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)) { @@ -238,7 +238,7 @@ export function XYDrag voi const onNodeOrSelectionDragStart = nodeId ? onNodeDragStart : wrapSelectionDragFunc(onSelectionDragStart); - if (dragItems && (onDragStart || onNodeOrSelectionDragStart)) { + if (dragItems.length > 0 && (onDragStart || onNodeOrSelectionDragStart)) { const [currentNode, currentNodes] = getEventHandlerParams({ nodeId, dragItems, @@ -298,11 +298,11 @@ export function XYDrag voi dragStarted = false; cancelAnimationFrame(autoPanId); - if (dragItems) { + if (dragItems.length > 0) { 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, }; }