diff --git a/.changeset/rude-rockets-hope.md b/.changeset/rude-rockets-hope.md new file mode 100644 index 00000000..d3099e49 --- /dev/null +++ b/.changeset/rude-rockets-hope.md @@ -0,0 +1,5 @@ +--- +'@xyflow/svelte': patch +--- + +Added ability to set global nodeExtent diff --git a/.changeset/twenty-ads-breathe.md b/.changeset/twenty-ads-breathe.md new file mode 100644 index 00000000..f63a3add --- /dev/null +++ b/.changeset/twenty-ads-breathe.md @@ -0,0 +1,7 @@ +--- +'@xyflow/react': patch +'@xyflow/svelte': patch +'@xyflow/system': patch +--- + +Fix extent on nodes not working properly diff --git a/examples/react/src/examples/Subflow/index.tsx b/examples/react/src/examples/Subflow/index.tsx index 74654870..0f59a97e 100644 --- a/examples/react/src/examples/Subflow/index.tsx +++ b/examples/react/src/examples/Subflow/index.tsx @@ -13,9 +13,9 @@ import { MiniMap, Background, Panel, - NodeOrigin, useUpdateNodeInternals, ReactFlowProvider, + CoordinateExtent, } from '@xyflow/react'; import DebugNode from './DebugNode'; @@ -27,11 +27,20 @@ const onEdgeClick = (_: MouseEvent, edge: Edge) => console.log('click', edge); const defaultViewport = { x: 0, y: 0, zoom: 1.5 }; const initialNodes: Node[] = [ + { + id: 'extent', + position: { x: 0, y: 0 }, + width: 1000, + height: 1000, + data: { label: 'Extent' }, + origin: [0, 0], + zIndex: -1, + }, { id: '1', type: 'input', data: { label: 'Node 1' }, - position: { x: 250, y: 5 }, + position: { x: -200, y: -500 }, className: 'light', origin: [0.5, 0.5], }, @@ -40,7 +49,7 @@ const initialNodes: Node[] = [ data: { label: 'Node 4' }, position: { x: 100, y: 200 }, className: 'light', - origin: [0.5, 0.5], + origin: [0, 0], style: { backgroundColor: 'rgba(255,50, 50, 0.5)', width: 500, @@ -50,14 +59,13 @@ const initialNodes: Node[] = [ { id: '4a', data: { label: 'Node 4a' }, - position: { x: 15, y: 15 }, + position: { x: -15, y: -15 }, className: 'light', parentId: '4', - origin: [0.5, 0.5], - + origin: [0, 0], extent: [ [0, 0], - [100, 100], + [300, 100], ], }, { @@ -98,9 +106,13 @@ const initialNodes: Node[] = [ { id: '5a', data: { label: 'Node 5a' }, - position: { x: 0, y: 0 }, + position: { x: -100, y: -100 }, className: 'light', parentId: '5', + // extent: [ + // [0, 0], + // [300, 300], + // ], extent: 'parent', }, { @@ -151,6 +163,11 @@ const nodeTypes = { default: DebugNode, }; +const nodeExtent: CoordinateExtent = [ + [0, 0], + [1000, 1000], +]; + const Subflow = () => { const [rfInstance, setRfInstance] = useState(null); const updateNodeInternals = useUpdateNodeInternals(); @@ -220,6 +237,7 @@ const Subflow = () => { nodeTypes={nodeTypes} fitView nodeOrigin={[0, 0]} + nodeExtent={nodeExtent} > @@ -239,7 +257,7 @@ const Subflow = () => { }; export default () => ( - + ); diff --git a/packages/react/src/components/NodeWrapper/index.tsx b/packages/react/src/components/NodeWrapper/index.tsx index adc6f393..5f528486 100644 --- a/packages/react/src/components/NodeWrapper/index.tsx +++ b/packages/react/src/components/NodeWrapper/index.tsx @@ -2,7 +2,6 @@ import { type MouseEvent, type KeyboardEvent } from 'react'; import cc from 'classcat'; import { shallow } from 'zustand/shallow'; import { - clampPosition, elementSelectionKeys, errorMessages, getNodeDimensions, @@ -87,10 +86,6 @@ export function NodeWrapper({ const nodeDimensions = getNodeDimensions(node); const inlineDimensions = getNodeInlineStyleDimensions(node); - // TODO: clamping should happen earlier - const clampedPosition = nodeExtent - ? clampPosition(internals.positionAbsolute, nodeExtent) - : internals.positionAbsolute; const hasPointerEvents = isSelectable || isDraggable || onClick || onMouseEnter || onMouseMove || onMouseLeave; @@ -146,7 +141,7 @@ export function NodeWrapper({ store.setState({ ariaLiveMessage: `Moved selected node ${event.key .replace('Arrow', '') - .toLowerCase()}. New position, x: ${~~clampedPosition.x}, y: ${~~clampedPosition.y}`, + .toLowerCase()}. New position, x: ${~~internals.positionAbsolute.x}, y: ${~~internals.positionAbsolute.y}`, }); moveSelectedNodes({ @@ -177,7 +172,7 @@ export function NodeWrapper({ ref={nodeRef} style={{ zIndex: internals.z, - transform: `translate(${clampedPosition.x}px,${clampedPosition.y}px)`, + transform: `translate(${internals.positionAbsolute.x}px,${internals.positionAbsolute.y}px)`, pointerEvents: hasPointerEvents ? 'all' : 'none', visibility: hasDimensions ? 'visible' : 'hidden', ...node.style, @@ -202,8 +197,8 @@ export function NodeWrapper({ id={id} data={node.data} type={nodeType} - positionAbsoluteX={clampedPosition.x} - positionAbsoluteY={clampedPosition.y} + positionAbsoluteX={internals.positionAbsolute.x} + positionAbsoluteY={internals.positionAbsolute.y} selected={node.selected} selectable={isSelectable} draggable={isDraggable} diff --git a/packages/react/src/components/ReactFlowProvider/index.tsx b/packages/react/src/components/ReactFlowProvider/index.tsx index 4843afeb..b29f5f17 100644 --- a/packages/react/src/components/ReactFlowProvider/index.tsx +++ b/packages/react/src/components/ReactFlowProvider/index.tsx @@ -4,7 +4,7 @@ import { Provider } from '../../contexts/StoreContext'; import { createStore } from '../../store'; import { BatchProvider } from '../BatchProvider'; import type { Node, Edge } from '../../types'; -import { NodeOrigin } from '@xyflow/system'; +import { CoordinateExtent, NodeOrigin } from '@xyflow/system'; export type ReactFlowProviderProps = { initialNodes?: Node[]; @@ -15,6 +15,7 @@ export type ReactFlowProviderProps = { initialHeight?: number; fitView?: boolean; nodeOrigin?: NodeOrigin; + nodeExtent?: CoordinateExtent; children: ReactNode; }; @@ -27,6 +28,7 @@ export function ReactFlowProvider({ initialHeight: height, fitView, nodeOrigin, + nodeExtent, children, }: ReactFlowProviderProps) { const [store] = useState(() => @@ -39,6 +41,7 @@ export function ReactFlowProvider({ height, fitView, nodeOrigin, + nodeExtent, }) ); diff --git a/packages/react/src/container/ReactFlow/Wrapper.tsx b/packages/react/src/container/ReactFlow/Wrapper.tsx index ce1b5a74..5856809a 100644 --- a/packages/react/src/container/ReactFlow/Wrapper.tsx +++ b/packages/react/src/container/ReactFlow/Wrapper.tsx @@ -3,7 +3,7 @@ import { useContext, type ReactNode } from 'react'; import StoreContext from '../../contexts/StoreContext'; import { ReactFlowProvider } from '../../components/ReactFlowProvider'; import type { Node, Edge } from '../../types'; -import { NodeOrigin } from '@xyflow/system'; +import { CoordinateExtent, NodeOrigin } from '@xyflow/system'; export function Wrapper({ children, @@ -15,6 +15,7 @@ export function Wrapper({ height, fitView, nodeOrigin, + nodeExtent, }: { children: ReactNode; nodes?: Node[]; @@ -25,6 +26,7 @@ export function Wrapper({ height?: number; fitView?: boolean; nodeOrigin?: NodeOrigin; + nodeExtent?: CoordinateExtent; }) { const isWrapped = useContext(StoreContext); @@ -44,6 +46,7 @@ export function Wrapper({ initialHeight={height} fitView={fitView} nodeOrigin={nodeOrigin} + nodeExtent={nodeExtent} > {children} diff --git a/packages/react/src/container/ReactFlow/index.tsx b/packages/react/src/container/ReactFlow/index.tsx index d6e3e5a4..962b6846 100644 --- a/packages/react/src/container/ReactFlow/index.tsx +++ b/packages/react/src/container/ReactFlow/index.tsx @@ -160,7 +160,15 @@ function ReactFlow( data-testid="rf__wrapper" id={id} > - + onInit={onInit} onNodeClick={onNodeClick} diff --git a/packages/react/src/store/index.ts b/packages/react/src/store/index.ts index c45d8f2d..ea06fb8b 100644 --- a/packages/react/src/store/index.ts +++ b/packages/react/src/store/index.ts @@ -1,6 +1,5 @@ import { createWithEqualityFn } from 'zustand/traditional'; import { - clampPosition, getFitViewNodes, fitView as fitViewSystem, adoptUserNodes, @@ -15,6 +14,7 @@ import { ParentExpandChild, initialConnection, NodeOrigin, + CoordinateExtent, } from '@xyflow/system'; import { applyEdgeChanges, applyNodeChanges, createSelectionChange, getSelectionChanges } from '../utils/changes'; @@ -30,6 +30,7 @@ const createStore = ({ height, fitView, nodeOrigin, + nodeExtent, }: { nodes?: Node[]; edges?: Edge[]; @@ -39,10 +40,11 @@ const createStore = ({ height?: number; fitView?: boolean; nodeOrigin?: NodeOrigin; + nodeExtent?: CoordinateExtent; }) => createWithEqualityFn( (set, get) => ({ - ...getInitialState({ nodes, edges, width, height, fitView, nodeOrigin, defaultNodes, defaultEdges }), + ...getInitialState({ nodes, edges, width, height, fitView, nodeOrigin, nodeExtent, defaultNodes, defaultEdges }), setNodes: (nodes: Node[]) => { const { nodeLookup, parentLookup, nodeOrigin, elevateNodesOnSelect } = get(); // setNodes() is called exclusively in response to user actions: @@ -51,7 +53,12 @@ const createStore = ({ // // When this happens, we take the note objects passed by the user and extend them with fields // relevant for internal React Flow operations. - adoptUserNodes(nodes, nodeLookup, parentLookup, { nodeOrigin, elevateNodesOnSelect, checkEquality: true }); + adoptUserNodes(nodes, nodeLookup, parentLookup, { + nodeOrigin, + nodeExtent, + elevateNodesOnSelect, + checkEquality: true, + }); set({ nodes }); }, @@ -87,6 +94,7 @@ const createStore = ({ fitViewOnInitOptions, domNode, nodeOrigin, + nodeExtent, debug, fitViewSync, } = get(); @@ -96,14 +104,15 @@ const createStore = ({ nodeLookup, parentLookup, domNode, - nodeOrigin + nodeOrigin, + nodeExtent ); if (!updatedInternals) { return; } - updateAbsolutePositions(nodeLookup, parentLookup, { nodeOrigin }); + updateAbsolutePositions(nodeLookup, parentLookup, { nodeOrigin, nodeExtent }); if (params.triggerFitView) { // we call fitView once initially after all dimensions are set @@ -277,24 +286,26 @@ const createStore = ({ triggerNodeChanges(nodeChanges); triggerEdgeChanges(edgeChanges); }, - setNodeExtent: (nodeExtent) => { - const { nodeLookup } = get(); + setNodeExtent: (nextNodeExtent) => { + const { nodes, nodeLookup, parentLookup, nodeOrigin, elevateNodesOnSelect, nodeExtent } = get(); - for (const [, node] of nodeLookup) { - const positionAbsolute = clampPosition(node.internals.positionAbsolute, nodeExtent); - - nodeLookup.set(node.id, { - ...node, - internals: { - ...node.internals, - positionAbsolute, - }, - }); + if ( + nextNodeExtent[0][0] === nodeExtent[0][0] && + nextNodeExtent[0][1] === nodeExtent[0][1] && + nextNodeExtent[1][0] === nodeExtent[1][0] && + nextNodeExtent[1][1] === nodeExtent[1][1] + ) { + return; } - set({ - nodeExtent, + adoptUserNodes(nodes, nodeLookup, parentLookup, { + nodeOrigin, + nodeExtent: nextNodeExtent, + elevateNodesOnSelect, + checkEquality: false, }); + + set({ nodeExtent: nextNodeExtent }); }, panBy: (delta): Promise => { const { transform, width, height, panZoom, translateExtent } = get(); diff --git a/packages/react/src/store/initialState.ts b/packages/react/src/store/initialState.ts index 279506ba..af52f2a2 100644 --- a/packages/react/src/store/initialState.ts +++ b/packages/react/src/store/initialState.ts @@ -9,6 +9,7 @@ import { getInternalNodesBounds, NodeOrigin, initialConnection, + CoordinateExtent, } from '@xyflow/system'; import type { Edge, InternalNode, Node, ReactFlowStore } from '../types'; @@ -22,6 +23,7 @@ const getInitialState = ({ height, fitView, nodeOrigin, + nodeExtent, }: { nodes?: Node[]; edges?: Edge[]; @@ -31,18 +33,22 @@ const getInitialState = ({ height?: number; fitView?: boolean; nodeOrigin?: NodeOrigin; + nodeExtent?: CoordinateExtent; } = {}): ReactFlowStore => { const nodeLookup = new Map(); const parentLookup = new Map(); const connectionLookup = new Map(); const edgeLookup = new Map(); + const storeEdges = defaultEdges ?? edges ?? []; const storeNodes = defaultNodes ?? nodes ?? []; const storeNodeOrigin = nodeOrigin ?? [0, 0]; + const storeNodeExtent = nodeExtent ?? infiniteExtent; updateConnectionLookup(connectionLookup, edgeLookup, storeEdges); adoptUserNodes(storeNodes, nodeLookup, parentLookup, { nodeOrigin: storeNodeOrigin, + nodeExtent: storeNodeExtent, elevateNodesOnSelect: false, }); @@ -76,7 +82,7 @@ const getInitialState = ({ minZoom: 0.5, maxZoom: 2, translateExtent: infiniteExtent, - nodeExtent: infiniteExtent, + nodeExtent: storeNodeExtent, nodesSelectionActive: false, userSelectionActive: false, userSelectionRect: null, diff --git a/packages/svelte/src/lib/container/SvelteFlow/SvelteFlow.svelte b/packages/svelte/src/lib/container/SvelteFlow/SvelteFlow.svelte index 87f061d0..4fc060a6 100644 --- a/packages/svelte/src/lib/container/SvelteFlow/SvelteFlow.svelte +++ b/packages/svelte/src/lib/container/SvelteFlow/SvelteFlow.svelte @@ -53,6 +53,7 @@ export let onMoveEnd: $$Props['onMoveEnd'] = undefined; export let isValidConnection: $$Props['isValidConnection'] = undefined; export let translateExtent: $$Props['translateExtent'] = undefined; + export let nodeExtent: $$Props['nodeExtent'] = undefined; export let onlyRenderVisibleElements: $$Props['onlyRenderVisibleElements'] = undefined; export let panOnScrollMode: $$Props['panOnScrollMode'] = PanOnScrollMode.Free; export let preventScrolling: $$Props['preventScrolling'] = true; @@ -102,7 +103,8 @@ width, height, fitView, - nodeOrigin + nodeOrigin, + nodeExtent }); onMount(() => { diff --git a/packages/svelte/src/lib/container/SvelteFlow/types.ts b/packages/svelte/src/lib/container/SvelteFlow/types.ts index b5e81331..0963424e 100644 --- a/packages/svelte/src/lib/container/SvelteFlow/types.ts +++ b/packages/svelte/src/lib/container/SvelteFlow/types.ts @@ -206,6 +206,12 @@ export type SvelteFlowProps = DOMAttributes & { * @example [[-1000, -10000], [1000, 1000]] */ translateExtent?: CoordinateExtent; + /** By default the nodes can be placed anywhere. You can use this prop to set a boundary. + * + * The first pair of coordinates is the top left boundary and the second pair is the bottom right. + * @example [[-1000, -10000], [1000, 1000]] + */ + nodeExtent?: CoordinateExtent; /** Disabling this prop will allow the user to scroll the page even when their pointer is over the flow. * @default true */ diff --git a/packages/svelte/src/lib/store/index.ts b/packages/svelte/src/lib/store/index.ts index 9ed41bc0..24279a45 100644 --- a/packages/svelte/src/lib/store/index.ts +++ b/packages/svelte/src/lib/store/index.ts @@ -37,7 +37,8 @@ export function createStore({ width, height, fitView: fitViewOnCreate, - nodeOrigin + nodeOrigin, + nodeExtent }: { nodes?: Node[]; edges?: Edge[]; @@ -45,6 +46,7 @@ export function createStore({ height?: number; fitView?: boolean; nodeOrigin?: NodeOrigin; + nodeExtent?: CoordinateExtent; }): SvelteFlowStore { const store = getInitialStore({ nodes, @@ -52,7 +54,8 @@ export function createStore({ width, height, fitView: fitViewOnCreate, - nodeOrigin + nodeOrigin, + nodeExtent }); function setNodeTypes(nodeTypes: NodeTypes) { @@ -482,7 +485,8 @@ export function createStoreContext({ width, height, fitView, - nodeOrigin + nodeOrigin, + nodeExtent }: { nodes?: Node[]; edges?: Edge[]; @@ -490,8 +494,9 @@ export function createStoreContext({ height?: number; fitView?: boolean; nodeOrigin?: NodeOrigin; + nodeExtent?: CoordinateExtent; }) { - const store = createStore({ nodes, edges, width, height, fitView, nodeOrigin }); + const store = createStore({ nodes, edges, width, height, fitView, nodeOrigin, nodeExtent }); setContext(key, { getStore: () => store diff --git a/packages/svelte/src/lib/store/initial-store.ts b/packages/svelte/src/lib/store/initial-store.ts index 5c9b9f64..142460a7 100644 --- a/packages/svelte/src/lib/store/initial-store.ts +++ b/packages/svelte/src/lib/store/initial-store.ts @@ -75,7 +75,8 @@ export const getInitialStore = ({ width, height, fitView, - nodeOrigin + nodeOrigin, + nodeExtent }: { nodes?: Node[]; edges?: Edge[]; @@ -83,17 +84,23 @@ export const getInitialStore = ({ height?: number; fitView?: boolean; nodeOrigin?: NodeOrigin; + nodeExtent?: CoordinateExtent; }) => { const nodeLookup: NodeLookup = new Map(); const parentLookup = new Map(); + const connectionLookup = new Map(); + const edgeLookup = new Map(); + const storeNodeOrigin = nodeOrigin ?? [0, 0]; + const storeNodeExtent = nodeExtent ?? infiniteExtent; + adoptUserNodes(nodes, nodeLookup, parentLookup, { + nodeExtent: storeNodeExtent, nodeOrigin: storeNodeOrigin, elevateNodesOnSelect: false, checkEquality: false }); - const connectionLookup = new Map(); - const edgeLookup = new Map(); + updateConnectionLookup(connectionLookup, edgeLookup, edges); let viewport: Viewport = { x: 0, y: 0, zoom: 1 }; @@ -107,7 +114,7 @@ export const getInitialStore = ({ return { flowId: writable(null), - nodes: createNodesStore(nodes, nodeLookup, parentLookup, storeNodeOrigin), + nodes: createNodesStore(nodes, nodeLookup, parentLookup, storeNodeOrigin, storeNodeExtent), nodeLookup: readable>(nodeLookup), parentLookup: readable>(parentLookup), edgeLookup: readable>(edgeLookup), @@ -121,7 +128,7 @@ export const getInitialStore = ({ maxZoom: writable(2), nodeOrigin: writable(storeNodeOrigin), nodeDragThreshold: writable(1), - nodeExtent: writable(infiniteExtent), + nodeExtent: writable(storeNodeExtent), translateExtent: writable(infiniteExtent), autoPanOnNodeDrag: writable(true), autoPanOnConnect: writable(true), diff --git a/packages/svelte/src/lib/store/utils.ts b/packages/svelte/src/lib/store/utils.ts index e0b53c46..30af8f73 100644 --- a/packages/svelte/src/lib/store/utils.ts +++ b/packages/svelte/src/lib/store/utils.ts @@ -15,7 +15,9 @@ import { type EdgeLookup, type NodeLookup, type ParentLookup, - type NodeOrigin + type NodeOrigin, + infiniteExtent, + type CoordinateExtent } from '@xyflow/system'; import type { DefaultEdgeOptions, DefaultNodeOptions, Edge, InternalNode, Node } from '$lib/types'; @@ -131,7 +133,8 @@ export const createNodesStore = ( nodes: Node[], nodeLookup: NodeLookup, parentLookup: ParentLookup, - nodeOrigin: NodeOrigin = [0, 0] + nodeOrigin: NodeOrigin = [0, 0], + nodeExtent: CoordinateExtent = infiniteExtent ): { subscribe: (this: void, run: Subscriber) => Unsubscriber; update: (this: void, updater: Updater) => void; @@ -148,6 +151,7 @@ export const createNodesStore = ( adoptUserNodes(nds, nodeLookup, parentLookup, { elevateNodesOnSelect, nodeOrigin, + nodeExtent, defaults, checkEquality: false }); diff --git a/packages/system/src/utils/general.ts b/packages/system/src/utils/general.ts index 7d539fb5..533c83ee 100644 --- a/packages/system/src/utils/general.ts +++ b/packages/system/src/utils/general.ts @@ -16,11 +16,33 @@ import { getNodePositionWithOrigin, isInternalNodeBase } from './graph'; export const clamp = (val: number, min = 0, max = 1): number => Math.min(Math.max(val, min), max); -export const clampPosition = (position: XYPosition = { x: 0, y: 0 }, extent: CoordinateExtent) => ({ - x: clamp(position.x, extent[0][0], extent[1][0]), - y: clamp(position.y, extent[0][1], extent[1][1]), +export const clampPosition = ( + position: XYPosition = { x: 0, y: 0 }, + extent: CoordinateExtent, + dimensions: Partial +) => ({ + x: clamp(position.x, extent[0][0], extent[1][0] - (dimensions?.width ?? 0)), + y: clamp(position.y, extent[0][1], extent[1][1] - (dimensions?.height ?? 0)), }); +export function clampPositionToParent( + childPosition: XYPosition, + childDimensions: Dimensions, + parent: InternalNodeBase +) { + const { width: parentWidth, height: parentHeight } = getNodeDimensions(parent); + const { x: parentX, y: parentY } = parent.internals.positionAbsolute; + + return clampPosition( + childPosition, + [ + [parentX, parentY], + [parentX + parentWidth, parentY + parentHeight], + ], + childDimensions + ); +} + /** * Calculates the velocity of panning when the mouse is close to the edge of the canvas * @internal diff --git a/packages/system/src/utils/graph.ts b/packages/system/src/utils/graph.ts index 0bf8c784..ddec917c 100644 --- a/packages/system/src/utils/graph.ts +++ b/packages/system/src/utils/graph.ts @@ -297,24 +297,6 @@ export async function fitView, Option return Promise.resolve(true); } -/** - * This function clamps the passed extend by the node's width and height. - * This is needed to prevent the node from being dragged outside of its extent. - * - * @param node - * @param extent - * @returns - */ -function clampNodeExtent( - node: NodeType, - extent?: CoordinateExtent | 'parent' -): CoordinateExtent | 'parent' | undefined { - if (!extent || extent === 'parent') { - return extent; - } - return [extent[0], [extent[1][0] - (node.measured?.width ?? 0), extent[1][1] - (node.measured?.height ?? 0)]]; -} - /** * This function calculates the next position of a node, taking into account the node's extent, parent node, and origin. * @@ -339,40 +321,37 @@ export function calculateNodePosition({ const node = nodeLookup.get(nodeId)!; const parentNode = node.parentId ? nodeLookup.get(node.parentId) : undefined; const { x: parentX, y: parentY } = parentNode ? parentNode.internals.positionAbsolute : { x: 0, y: 0 }; - const origin = node.origin ?? nodeOrigin; - let currentExtent = clampNodeExtent(node, node.extent || nodeExtent); + const origin = node.origin ?? nodeOrigin; + let extent = nodeExtent; if (node.extent === 'parent' && !node.expandParent) { if (!parentNode) { onError?.('005', errorMessages['error005']()); } else { - const nodeWidth = node.measured.width; - const nodeHeight = node.measured.height; const parentWidth = parentNode.measured.width; const parentHeight = parentNode.measured.height; - if (nodeWidth && nodeHeight && parentWidth && parentHeight) { - currentExtent = [ + if (parentWidth && parentHeight) { + extent = [ [parentX, parentY], - [parentX + parentWidth - nodeWidth, parentY + parentHeight - nodeHeight], + [parentX + parentWidth, parentY + parentHeight], ]; } } } else if (parentNode && isCoordinateExtent(node.extent)) { - currentExtent = [ + extent = [ [node.extent[0][0] + parentX, node.extent[0][1] + parentY], [node.extent[1][0] + parentX, node.extent[1][1] + parentY], ]; } - const positionAbsolute = isCoordinateExtent(currentExtent) - ? clampPosition(nextPosition, currentExtent) + const positionAbsolute = isCoordinateExtent(extent) + ? clampPosition(nextPosition, extent, node.measured) : nextPosition; return { position: { - // TODO: is there a better way to do this? x: positionAbsolute.x - parentX + node.measured.width! * origin[0], y: positionAbsolute.y - parentY + node.measured.height! * origin[1], }, diff --git a/packages/system/src/utils/store.ts b/packages/system/src/utils/store.ts index f2cf1bfd..73b6afc9 100644 --- a/packages/system/src/utils/store.ts +++ b/packages/system/src/utils/store.ts @@ -1,3 +1,4 @@ +import { infiniteExtent } from '..'; import { NodeBase, CoordinateExtent, @@ -17,37 +18,60 @@ import { ParentLookup, } from '../types'; import { getDimensions, getHandleBounds } from './dom'; -import { getBoundsOfRects, getNodeDimensions, isNumeric, nodeToRect } from './general'; +import { + clampPosition, + clampPositionToParent, + getBoundsOfRects, + getNodeDimensions, + isCoordinateExtent, + isNumeric, + nodeToRect, +} from './general'; import { getNodePositionWithOrigin } from './graph'; import { ParentExpandChild } from './types'; const defaultOptions = { nodeOrigin: [0, 0] as NodeOrigin, + nodeExtent: infiniteExtent, elevateNodesOnSelect: true, defaults: {}, }; + const adoptUserNodesDefaultOptions = { ...defaultOptions, checkEquality: true, }; +function mergeObjects>(base: T, incoming?: Partial): T { + const result = { ...base }; + for (const key in incoming) { + if (incoming[key] !== undefined) { + // typecast is safe here, because we check for undefined + result[key] = (incoming as T)[key]!; + } + } + + return result; +} + export function updateAbsolutePositions( nodeLookup: NodeLookup>, parentLookup: ParentLookup>, options?: UpdateNodesOptions ) { - const _options = { ...defaultOptions, ...options }; + const _options = mergeObjects(defaultOptions, options); for (const node of nodeLookup.values()) { if (!node.parentId) { continue; } - updateChildPosition(node, nodeLookup, parentLookup, _options); + updateChildNode(node, nodeLookup, parentLookup, _options); } } type UpdateNodesOptions = { nodeOrigin?: NodeOrigin; + nodeExtent?: CoordinateExtent; elevateNodesOnSelect?: boolean; defaults?: Partial; checkEquality?: boolean; @@ -59,18 +83,23 @@ export function adoptUserNodes( parentLookup: ParentLookup>, options?: UpdateNodesOptions ) { - const _options = { ...adoptUserNodesDefaultOptions, ...options }; + const _options = mergeObjects(adoptUserNodesDefaultOptions, options); const tmpLookup = new Map(nodeLookup); + const selectedNodeZ: number = _options?.elevateNodesOnSelect ? 1000 : 0; + nodeLookup.clear(); parentLookup.clear(); - const selectedNodeZ: number = options?.elevateNodesOnSelect ? 1000 : 0; - for (const userNode of nodes) { let internalNode = tmpLookup.get(userNode.id); + if (_options.checkEquality && userNode === internalNode?.internals.userNode) { nodeLookup.set(userNode.id, internalNode); } else { + const positionWithOrigin = getNodePositionWithOrigin(userNode, _options.nodeOrigin); + const extent = isCoordinateExtent(userNode.extent) ? userNode.extent : _options.nodeExtent; + const clampedPosition = clampPosition(positionWithOrigin, extent, getNodeDimensions(userNode)); + internalNode = { ..._options.defaults, ...userNode, @@ -79,30 +108,50 @@ export function adoptUserNodes( height: userNode.measured?.height, }, internals: { - positionAbsolute: getNodePositionWithOrigin(userNode, _options.nodeOrigin), + positionAbsolute: clampedPosition, // if user re-initializes the node or removes `measured` for whatever reason, we reset the handleBounds so that the node gets re-measured handleBounds: !userNode.measured ? undefined : internalNode?.internals.handleBounds, z: calculateZ(userNode, selectedNodeZ), userNode, }, }; + nodeLookup.set(userNode.id, internalNode); } if (userNode.parentId) { - updateChildPosition(internalNode, nodeLookup, parentLookup, options); + updateChildNode(internalNode, nodeLookup, parentLookup, options); } } } -function updateChildPosition( +function updateParentLookup( + node: InternalNodeBase, + parentLookup: ParentLookup> +) { + if (!node.parentId) { + return; + } + + const childNodes = parentLookup.get(node.parentId); + + if (childNodes) { + childNodes.set(node.id, node); + } else { + parentLookup.set(node.parentId, new Map([[node.id, node]])); + } +} + +/** + * Updates positionAbsolute and zIndex of a child node and the parentLookup. + */ +function updateChildNode( node: InternalNodeBase, nodeLookup: NodeLookup>, parentLookup: ParentLookup>, options?: UpdateNodesOptions ) { - const _options = { ...defaultOptions, ...options }; - + const { elevateNodesOnSelect, nodeOrigin, nodeExtent } = mergeObjects(defaultOptions, options); const parentId = node.parentId!; const parentNode = nodeLookup.get(parentId); @@ -113,25 +162,17 @@ function updateChildPosition( return; } - // update the parentLookup - const childNodes = parentLookup.get(parentId); - if (childNodes) { - childNodes.set(node.id, node); - } else { - parentLookup.set(parentId, new Map([[node.id, node]])); - } + updateParentLookup(node, parentLookup); - const selectedNodeZ: number = options?.elevateNodesOnSelect ? 1000 : 0; - - const { x, y, z } = calculateChildXYZ(node, parentNode, _options.nodeOrigin!, selectedNodeZ); - - const currPosition = node.internals.positionAbsolute; - const positionChanged = x !== currPosition.x || y !== currPosition.y; + const selectedNodeZ = elevateNodesOnSelect ? 1000 : 0; + const { x, y, z } = calculateChildXYZ(node, parentNode, nodeOrigin, nodeExtent, selectedNodeZ); + const { positionAbsolute } = node.internals; + const positionChanged = x !== positionAbsolute.x || y !== positionAbsolute.y; if (positionChanged || z !== node.internals.z) { node.internals = { ...node.internals, - positionAbsolute: positionChanged ? { x, y } : currPosition, + positionAbsolute: positionChanged ? { x, y } : positionAbsolute, z, }; } @@ -145,15 +186,32 @@ function calculateChildXYZ( childNode: InternalNodeBase, parentNode: InternalNodeBase, nodeOrigin: NodeOrigin, + nodeExtent: CoordinateExtent, selectedNodeZ: number ) { - const position = getNodePositionWithOrigin(childNode, nodeOrigin); + const { x: parentX, y: parentY } = parentNode.internals.positionAbsolute; + const childDimensions = getNodeDimensions(childNode); + const positionWithOrigin = getNodePositionWithOrigin(childNode, nodeOrigin); + const clampedPosition = isCoordinateExtent(childNode.extent) + ? clampPosition(positionWithOrigin, childNode.extent, childDimensions) + : positionWithOrigin; + + let absolutePosition = clampPosition( + { x: parentX + clampedPosition.x, y: parentY + clampedPosition.y }, + nodeExtent, + childDimensions + ); + + if (childNode.extent === 'parent') { + absolutePosition = clampPositionToParent(absolutePosition, childDimensions, parentNode); + } + const childZ = calculateZ(childNode, selectedNodeZ); const parentZ = parentNode.internals.z ?? 0; return { - x: parentNode.internals.positionAbsolute.x + position.x, - y: parentNode.internals.positionAbsolute.y + position.y, + x: absolutePosition.x, + y: absolutePosition.y, z: parentZ > childZ ? parentZ : childZ, }; } @@ -249,7 +307,8 @@ export function updateNodeInternals( nodeLookup: NodeLookup, parentLookup: ParentLookup, domNode: HTMLElement | null, - nodeOrigin?: NodeOrigin + nodeOrigin?: NodeOrigin, + nodeExtent?: CoordinateExtent ): { changes: (NodeDimensionChange | NodePositionChange)[]; updatedInternals: boolean } { const viewportNode = domNode?.querySelector('.xyflow__viewport'); let updatedInternals = false; @@ -287,18 +346,26 @@ export function updateNodeInternals( if (doUpdate) { const nodeBounds = update.nodeElement.getBoundingClientRect(); + const extent = isCoordinateExtent(node.extent) ? node.extent : nodeExtent; + let { positionAbsolute } = node.internals; + + if (node.parentId && node.extent === 'parent') { + positionAbsolute = clampPositionToParent(positionAbsolute, dimensions, nodeLookup.get(node.parentId)!); + } else if (extent) { + positionAbsolute = clampPosition(positionAbsolute, extent, dimensions); + } node.measured = dimensions; node.internals = { ...node.internals, - positionAbsolute: getNodePositionWithOrigin(node, nodeOrigin), + positionAbsolute, handleBounds: { source: getHandleBounds('source', update.nodeElement, nodeBounds, zoom, node.id), target: getHandleBounds('target', update.nodeElement, nodeBounds, zoom, node.id), }, }; if (node.parentId) { - updateChildPosition(node, nodeLookup, parentLookup, { nodeOrigin }); + updateChildNode(node, nodeLookup, parentLookup, { nodeOrigin }); } updatedInternals = true;