Merge pull request #4572 from xyflow/fix-extent

fix node extent
This commit is contained in:
Moritz Klack
2024-09-05 16:39:23 +02:00
committed by GitHub
17 changed files with 264 additions and 116 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@xyflow/svelte': patch
---
Added ability to set global nodeExtent
+7
View File
@@ -0,0 +1,7 @@
---
'@xyflow/react': patch
'@xyflow/svelte': patch
'@xyflow/system': patch
---
Fix extent on nodes not working properly
+27 -9
View File
@@ -13,9 +13,9 @@ import {
MiniMap, MiniMap,
Background, Background,
Panel, Panel,
NodeOrigin,
useUpdateNodeInternals, useUpdateNodeInternals,
ReactFlowProvider, ReactFlowProvider,
CoordinateExtent,
} from '@xyflow/react'; } from '@xyflow/react';
import DebugNode from './DebugNode'; 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 defaultViewport = { x: 0, y: 0, zoom: 1.5 };
const initialNodes: Node[] = [ const initialNodes: Node[] = [
{
id: 'extent',
position: { x: 0, y: 0 },
width: 1000,
height: 1000,
data: { label: 'Extent' },
origin: [0, 0],
zIndex: -1,
},
{ {
id: '1', id: '1',
type: 'input', type: 'input',
data: { label: 'Node 1' }, data: { label: 'Node 1' },
position: { x: 250, y: 5 }, position: { x: -200, y: -500 },
className: 'light', className: 'light',
origin: [0.5, 0.5], origin: [0.5, 0.5],
}, },
@@ -40,7 +49,7 @@ const initialNodes: Node[] = [
data: { label: 'Node 4' }, data: { label: 'Node 4' },
position: { x: 100, y: 200 }, position: { x: 100, y: 200 },
className: 'light', className: 'light',
origin: [0.5, 0.5], origin: [0, 0],
style: { style: {
backgroundColor: 'rgba(255,50, 50, 0.5)', backgroundColor: 'rgba(255,50, 50, 0.5)',
width: 500, width: 500,
@@ -50,14 +59,13 @@ const initialNodes: Node[] = [
{ {
id: '4a', id: '4a',
data: { label: 'Node 4a' }, data: { label: 'Node 4a' },
position: { x: 15, y: 15 }, position: { x: -15, y: -15 },
className: 'light', className: 'light',
parentId: '4', parentId: '4',
origin: [0.5, 0.5], origin: [0, 0],
extent: [ extent: [
[0, 0], [0, 0],
[100, 100], [300, 100],
], ],
}, },
{ {
@@ -98,9 +106,13 @@ const initialNodes: Node[] = [
{ {
id: '5a', id: '5a',
data: { label: 'Node 5a' }, data: { label: 'Node 5a' },
position: { x: 0, y: 0 }, position: { x: -100, y: -100 },
className: 'light', className: 'light',
parentId: '5', parentId: '5',
// extent: [
// [0, 0],
// [300, 300],
// ],
extent: 'parent', extent: 'parent',
}, },
{ {
@@ -151,6 +163,11 @@ const nodeTypes = {
default: DebugNode, default: DebugNode,
}; };
const nodeExtent: CoordinateExtent = [
[0, 0],
[1000, 1000],
];
const Subflow = () => { const Subflow = () => {
const [rfInstance, setRfInstance] = useState<ReactFlowInstance | null>(null); const [rfInstance, setRfInstance] = useState<ReactFlowInstance | null>(null);
const updateNodeInternals = useUpdateNodeInternals(); const updateNodeInternals = useUpdateNodeInternals();
@@ -220,6 +237,7 @@ const Subflow = () => {
nodeTypes={nodeTypes} nodeTypes={nodeTypes}
fitView fitView
nodeOrigin={[0, 0]} nodeOrigin={[0, 0]}
nodeExtent={nodeExtent}
> >
<MiniMap /> <MiniMap />
<Controls /> <Controls />
@@ -239,7 +257,7 @@ const Subflow = () => {
}; };
export default () => ( export default () => (
<ReactFlowProvider> <ReactFlowProvider nodeExtent={nodeExtent}>
<Subflow /> <Subflow />
</ReactFlowProvider> </ReactFlowProvider>
); );
@@ -2,7 +2,6 @@ import { type MouseEvent, type KeyboardEvent } from 'react';
import cc from 'classcat'; import cc from 'classcat';
import { shallow } from 'zustand/shallow'; import { shallow } from 'zustand/shallow';
import { import {
clampPosition,
elementSelectionKeys, elementSelectionKeys,
errorMessages, errorMessages,
getNodeDimensions, getNodeDimensions,
@@ -87,10 +86,6 @@ export function NodeWrapper<NodeType extends Node>({
const nodeDimensions = getNodeDimensions(node); const nodeDimensions = getNodeDimensions(node);
const inlineDimensions = getNodeInlineStyleDimensions(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; const hasPointerEvents = isSelectable || isDraggable || onClick || onMouseEnter || onMouseMove || onMouseLeave;
@@ -146,7 +141,7 @@ export function NodeWrapper<NodeType extends Node>({
store.setState({ store.setState({
ariaLiveMessage: `Moved selected node ${event.key ariaLiveMessage: `Moved selected node ${event.key
.replace('Arrow', '') .replace('Arrow', '')
.toLowerCase()}. New position, x: ${~~clampedPosition.x}, y: ${~~clampedPosition.y}`, .toLowerCase()}. New position, x: ${~~internals.positionAbsolute.x}, y: ${~~internals.positionAbsolute.y}`,
}); });
moveSelectedNodes({ moveSelectedNodes({
@@ -177,7 +172,7 @@ export function NodeWrapper<NodeType extends Node>({
ref={nodeRef} ref={nodeRef}
style={{ style={{
zIndex: internals.z, 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', pointerEvents: hasPointerEvents ? 'all' : 'none',
visibility: hasDimensions ? 'visible' : 'hidden', visibility: hasDimensions ? 'visible' : 'hidden',
...node.style, ...node.style,
@@ -202,8 +197,8 @@ export function NodeWrapper<NodeType extends Node>({
id={id} id={id}
data={node.data} data={node.data}
type={nodeType} type={nodeType}
positionAbsoluteX={clampedPosition.x} positionAbsoluteX={internals.positionAbsolute.x}
positionAbsoluteY={clampedPosition.y} positionAbsoluteY={internals.positionAbsolute.y}
selected={node.selected} selected={node.selected}
selectable={isSelectable} selectable={isSelectable}
draggable={isDraggable} draggable={isDraggable}
@@ -4,7 +4,7 @@ import { Provider } from '../../contexts/StoreContext';
import { createStore } from '../../store'; import { createStore } from '../../store';
import { BatchProvider } from '../BatchProvider'; import { BatchProvider } from '../BatchProvider';
import type { Node, Edge } from '../../types'; import type { Node, Edge } from '../../types';
import { NodeOrigin } from '@xyflow/system'; import { CoordinateExtent, NodeOrigin } from '@xyflow/system';
export type ReactFlowProviderProps = { export type ReactFlowProviderProps = {
initialNodes?: Node[]; initialNodes?: Node[];
@@ -15,6 +15,7 @@ export type ReactFlowProviderProps = {
initialHeight?: number; initialHeight?: number;
fitView?: boolean; fitView?: boolean;
nodeOrigin?: NodeOrigin; nodeOrigin?: NodeOrigin;
nodeExtent?: CoordinateExtent;
children: ReactNode; children: ReactNode;
}; };
@@ -27,6 +28,7 @@ export function ReactFlowProvider({
initialHeight: height, initialHeight: height,
fitView, fitView,
nodeOrigin, nodeOrigin,
nodeExtent,
children, children,
}: ReactFlowProviderProps) { }: ReactFlowProviderProps) {
const [store] = useState(() => const [store] = useState(() =>
@@ -39,6 +41,7 @@ export function ReactFlowProvider({
height, height,
fitView, fitView,
nodeOrigin, nodeOrigin,
nodeExtent,
}) })
); );
@@ -3,7 +3,7 @@ import { useContext, type ReactNode } from 'react';
import StoreContext from '../../contexts/StoreContext'; import StoreContext from '../../contexts/StoreContext';
import { ReactFlowProvider } from '../../components/ReactFlowProvider'; import { ReactFlowProvider } from '../../components/ReactFlowProvider';
import type { Node, Edge } from '../../types'; import type { Node, Edge } from '../../types';
import { NodeOrigin } from '@xyflow/system'; import { CoordinateExtent, NodeOrigin } from '@xyflow/system';
export function Wrapper({ export function Wrapper({
children, children,
@@ -15,6 +15,7 @@ export function Wrapper({
height, height,
fitView, fitView,
nodeOrigin, nodeOrigin,
nodeExtent,
}: { }: {
children: ReactNode; children: ReactNode;
nodes?: Node[]; nodes?: Node[];
@@ -25,6 +26,7 @@ export function Wrapper({
height?: number; height?: number;
fitView?: boolean; fitView?: boolean;
nodeOrigin?: NodeOrigin; nodeOrigin?: NodeOrigin;
nodeExtent?: CoordinateExtent;
}) { }) {
const isWrapped = useContext(StoreContext); const isWrapped = useContext(StoreContext);
@@ -44,6 +46,7 @@ export function Wrapper({
initialHeight={height} initialHeight={height}
fitView={fitView} fitView={fitView}
nodeOrigin={nodeOrigin} nodeOrigin={nodeOrigin}
nodeExtent={nodeExtent}
> >
{children} {children}
</ReactFlowProvider> </ReactFlowProvider>
@@ -160,7 +160,15 @@ function ReactFlow<NodeType extends Node = Node, EdgeType extends Edge = Edge>(
data-testid="rf__wrapper" data-testid="rf__wrapper"
id={id} id={id}
> >
<Wrapper nodes={nodes} edges={edges} width={width} height={height} fitView={fitView} nodeOrigin={nodeOrigin}> <Wrapper
nodes={nodes}
edges={edges}
width={width}
height={height}
fitView={fitView}
nodeOrigin={nodeOrigin}
nodeExtent={nodeExtent}
>
<GraphView<NodeType, EdgeType> <GraphView<NodeType, EdgeType>
onInit={onInit} onInit={onInit}
onNodeClick={onNodeClick} onNodeClick={onNodeClick}
+30 -19
View File
@@ -1,6 +1,5 @@
import { createWithEqualityFn } from 'zustand/traditional'; import { createWithEqualityFn } from 'zustand/traditional';
import { import {
clampPosition,
getFitViewNodes, getFitViewNodes,
fitView as fitViewSystem, fitView as fitViewSystem,
adoptUserNodes, adoptUserNodes,
@@ -15,6 +14,7 @@ import {
ParentExpandChild, ParentExpandChild,
initialConnection, initialConnection,
NodeOrigin, NodeOrigin,
CoordinateExtent,
} from '@xyflow/system'; } from '@xyflow/system';
import { applyEdgeChanges, applyNodeChanges, createSelectionChange, getSelectionChanges } from '../utils/changes'; import { applyEdgeChanges, applyNodeChanges, createSelectionChange, getSelectionChanges } from '../utils/changes';
@@ -30,6 +30,7 @@ const createStore = ({
height, height,
fitView, fitView,
nodeOrigin, nodeOrigin,
nodeExtent,
}: { }: {
nodes?: Node[]; nodes?: Node[];
edges?: Edge[]; edges?: Edge[];
@@ -39,10 +40,11 @@ const createStore = ({
height?: number; height?: number;
fitView?: boolean; fitView?: boolean;
nodeOrigin?: NodeOrigin; nodeOrigin?: NodeOrigin;
nodeExtent?: CoordinateExtent;
}) => }) =>
createWithEqualityFn<ReactFlowState>( createWithEqualityFn<ReactFlowState>(
(set, get) => ({ (set, get) => ({
...getInitialState({ nodes, edges, width, height, fitView, nodeOrigin, defaultNodes, defaultEdges }), ...getInitialState({ nodes, edges, width, height, fitView, nodeOrigin, nodeExtent, defaultNodes, defaultEdges }),
setNodes: (nodes: Node[]) => { setNodes: (nodes: Node[]) => {
const { nodeLookup, parentLookup, nodeOrigin, elevateNodesOnSelect } = get(); const { nodeLookup, parentLookup, nodeOrigin, elevateNodesOnSelect } = get();
// setNodes() is called exclusively in response to user actions: // 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 // When this happens, we take the note objects passed by the user and extend them with fields
// relevant for internal React Flow operations. // 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 }); set({ nodes });
}, },
@@ -87,6 +94,7 @@ const createStore = ({
fitViewOnInitOptions, fitViewOnInitOptions,
domNode, domNode,
nodeOrigin, nodeOrigin,
nodeExtent,
debug, debug,
fitViewSync, fitViewSync,
} = get(); } = get();
@@ -96,14 +104,15 @@ const createStore = ({
nodeLookup, nodeLookup,
parentLookup, parentLookup,
domNode, domNode,
nodeOrigin nodeOrigin,
nodeExtent
); );
if (!updatedInternals) { if (!updatedInternals) {
return; return;
} }
updateAbsolutePositions(nodeLookup, parentLookup, { nodeOrigin }); updateAbsolutePositions(nodeLookup, parentLookup, { nodeOrigin, nodeExtent });
if (params.triggerFitView) { if (params.triggerFitView) {
// we call fitView once initially after all dimensions are set // we call fitView once initially after all dimensions are set
@@ -277,24 +286,26 @@ const createStore = ({
triggerNodeChanges(nodeChanges); triggerNodeChanges(nodeChanges);
triggerEdgeChanges(edgeChanges); triggerEdgeChanges(edgeChanges);
}, },
setNodeExtent: (nodeExtent) => { setNodeExtent: (nextNodeExtent) => {
const { nodeLookup } = get(); const { nodes, nodeLookup, parentLookup, nodeOrigin, elevateNodesOnSelect, nodeExtent } = get();
for (const [, node] of nodeLookup) { if (
const positionAbsolute = clampPosition(node.internals.positionAbsolute, nodeExtent); nextNodeExtent[0][0] === nodeExtent[0][0] &&
nextNodeExtent[0][1] === nodeExtent[0][1] &&
nodeLookup.set(node.id, { nextNodeExtent[1][0] === nodeExtent[1][0] &&
...node, nextNodeExtent[1][1] === nodeExtent[1][1]
internals: { ) {
...node.internals, return;
positionAbsolute,
},
});
} }
set({ adoptUserNodes(nodes, nodeLookup, parentLookup, {
nodeExtent, nodeOrigin,
nodeExtent: nextNodeExtent,
elevateNodesOnSelect,
checkEquality: false,
}); });
set({ nodeExtent: nextNodeExtent });
}, },
panBy: (delta): Promise<boolean> => { panBy: (delta): Promise<boolean> => {
const { transform, width, height, panZoom, translateExtent } = get(); const { transform, width, height, panZoom, translateExtent } = get();
+7 -1
View File
@@ -9,6 +9,7 @@ import {
getInternalNodesBounds, getInternalNodesBounds,
NodeOrigin, NodeOrigin,
initialConnection, initialConnection,
CoordinateExtent,
} from '@xyflow/system'; } from '@xyflow/system';
import type { Edge, InternalNode, Node, ReactFlowStore } from '../types'; import type { Edge, InternalNode, Node, ReactFlowStore } from '../types';
@@ -22,6 +23,7 @@ const getInitialState = ({
height, height,
fitView, fitView,
nodeOrigin, nodeOrigin,
nodeExtent,
}: { }: {
nodes?: Node[]; nodes?: Node[];
edges?: Edge[]; edges?: Edge[];
@@ -31,18 +33,22 @@ const getInitialState = ({
height?: number; height?: number;
fitView?: boolean; fitView?: boolean;
nodeOrigin?: NodeOrigin; nodeOrigin?: NodeOrigin;
nodeExtent?: CoordinateExtent;
} = {}): ReactFlowStore => { } = {}): ReactFlowStore => {
const nodeLookup = new Map<string, InternalNode>(); const nodeLookup = new Map<string, InternalNode>();
const parentLookup = new Map(); const parentLookup = new Map();
const connectionLookup = new Map(); const connectionLookup = new Map();
const edgeLookup = new Map(); const edgeLookup = new Map();
const storeEdges = defaultEdges ?? edges ?? []; const storeEdges = defaultEdges ?? edges ?? [];
const storeNodes = defaultNodes ?? nodes ?? []; const storeNodes = defaultNodes ?? nodes ?? [];
const storeNodeOrigin = nodeOrigin ?? [0, 0]; const storeNodeOrigin = nodeOrigin ?? [0, 0];
const storeNodeExtent = nodeExtent ?? infiniteExtent;
updateConnectionLookup(connectionLookup, edgeLookup, storeEdges); updateConnectionLookup(connectionLookup, edgeLookup, storeEdges);
adoptUserNodes(storeNodes, nodeLookup, parentLookup, { adoptUserNodes(storeNodes, nodeLookup, parentLookup, {
nodeOrigin: storeNodeOrigin, nodeOrigin: storeNodeOrigin,
nodeExtent: storeNodeExtent,
elevateNodesOnSelect: false, elevateNodesOnSelect: false,
}); });
@@ -76,7 +82,7 @@ const getInitialState = ({
minZoom: 0.5, minZoom: 0.5,
maxZoom: 2, maxZoom: 2,
translateExtent: infiniteExtent, translateExtent: infiniteExtent,
nodeExtent: infiniteExtent, nodeExtent: storeNodeExtent,
nodesSelectionActive: false, nodesSelectionActive: false,
userSelectionActive: false, userSelectionActive: false,
userSelectionRect: null, userSelectionRect: null,
@@ -53,6 +53,7 @@
export let onMoveEnd: $$Props['onMoveEnd'] = undefined; export let onMoveEnd: $$Props['onMoveEnd'] = undefined;
export let isValidConnection: $$Props['isValidConnection'] = undefined; export let isValidConnection: $$Props['isValidConnection'] = undefined;
export let translateExtent: $$Props['translateExtent'] = undefined; export let translateExtent: $$Props['translateExtent'] = undefined;
export let nodeExtent: $$Props['nodeExtent'] = undefined;
export let onlyRenderVisibleElements: $$Props['onlyRenderVisibleElements'] = undefined; export let onlyRenderVisibleElements: $$Props['onlyRenderVisibleElements'] = undefined;
export let panOnScrollMode: $$Props['panOnScrollMode'] = PanOnScrollMode.Free; export let panOnScrollMode: $$Props['panOnScrollMode'] = PanOnScrollMode.Free;
export let preventScrolling: $$Props['preventScrolling'] = true; export let preventScrolling: $$Props['preventScrolling'] = true;
@@ -102,7 +103,8 @@
width, width,
height, height,
fitView, fitView,
nodeOrigin nodeOrigin,
nodeExtent
}); });
onMount(() => { onMount(() => {
@@ -206,6 +206,12 @@ export type SvelteFlowProps = DOMAttributes<HTMLDivElement> & {
* @example [[-1000, -10000], [1000, 1000]] * @example [[-1000, -10000], [1000, 1000]]
*/ */
translateExtent?: CoordinateExtent; 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. /** Disabling this prop will allow the user to scroll the page even when their pointer is over the flow.
* @default true * @default true
*/ */
+9 -4
View File
@@ -37,7 +37,8 @@ export function createStore({
width, width,
height, height,
fitView: fitViewOnCreate, fitView: fitViewOnCreate,
nodeOrigin nodeOrigin,
nodeExtent
}: { }: {
nodes?: Node[]; nodes?: Node[];
edges?: Edge[]; edges?: Edge[];
@@ -45,6 +46,7 @@ export function createStore({
height?: number; height?: number;
fitView?: boolean; fitView?: boolean;
nodeOrigin?: NodeOrigin; nodeOrigin?: NodeOrigin;
nodeExtent?: CoordinateExtent;
}): SvelteFlowStore { }): SvelteFlowStore {
const store = getInitialStore({ const store = getInitialStore({
nodes, nodes,
@@ -52,7 +54,8 @@ export function createStore({
width, width,
height, height,
fitView: fitViewOnCreate, fitView: fitViewOnCreate,
nodeOrigin nodeOrigin,
nodeExtent
}); });
function setNodeTypes(nodeTypes: NodeTypes) { function setNodeTypes(nodeTypes: NodeTypes) {
@@ -482,7 +485,8 @@ export function createStoreContext({
width, width,
height, height,
fitView, fitView,
nodeOrigin nodeOrigin,
nodeExtent
}: { }: {
nodes?: Node[]; nodes?: Node[];
edges?: Edge[]; edges?: Edge[];
@@ -490,8 +494,9 @@ export function createStoreContext({
height?: number; height?: number;
fitView?: boolean; fitView?: boolean;
nodeOrigin?: NodeOrigin; 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, { setContext(key, {
getStore: () => store getStore: () => store
+12 -5
View File
@@ -75,7 +75,8 @@ export const getInitialStore = ({
width, width,
height, height,
fitView, fitView,
nodeOrigin nodeOrigin,
nodeExtent
}: { }: {
nodes?: Node[]; nodes?: Node[];
edges?: Edge[]; edges?: Edge[];
@@ -83,17 +84,23 @@ export const getInitialStore = ({
height?: number; height?: number;
fitView?: boolean; fitView?: boolean;
nodeOrigin?: NodeOrigin; nodeOrigin?: NodeOrigin;
nodeExtent?: CoordinateExtent;
}) => { }) => {
const nodeLookup: NodeLookup = new Map(); const nodeLookup: NodeLookup = new Map();
const parentLookup = new Map(); const parentLookup = new Map();
const connectionLookup = new Map();
const edgeLookup = new Map();
const storeNodeOrigin = nodeOrigin ?? [0, 0]; const storeNodeOrigin = nodeOrigin ?? [0, 0];
const storeNodeExtent = nodeExtent ?? infiniteExtent;
adoptUserNodes(nodes, nodeLookup, parentLookup, { adoptUserNodes(nodes, nodeLookup, parentLookup, {
nodeExtent: storeNodeExtent,
nodeOrigin: storeNodeOrigin, nodeOrigin: storeNodeOrigin,
elevateNodesOnSelect: false, elevateNodesOnSelect: false,
checkEquality: false checkEquality: false
}); });
const connectionLookup = new Map();
const edgeLookup = new Map();
updateConnectionLookup(connectionLookup, edgeLookup, edges); updateConnectionLookup(connectionLookup, edgeLookup, edges);
let viewport: Viewport = { x: 0, y: 0, zoom: 1 }; let viewport: Viewport = { x: 0, y: 0, zoom: 1 };
@@ -107,7 +114,7 @@ export const getInitialStore = ({
return { return {
flowId: writable<string | null>(null), flowId: writable<string | null>(null),
nodes: createNodesStore(nodes, nodeLookup, parentLookup, storeNodeOrigin), nodes: createNodesStore(nodes, nodeLookup, parentLookup, storeNodeOrigin, storeNodeExtent),
nodeLookup: readable<NodeLookup<InternalNode>>(nodeLookup), nodeLookup: readable<NodeLookup<InternalNode>>(nodeLookup),
parentLookup: readable<ParentLookup<InternalNode>>(parentLookup), parentLookup: readable<ParentLookup<InternalNode>>(parentLookup),
edgeLookup: readable<EdgeLookup<Edge>>(edgeLookup), edgeLookup: readable<EdgeLookup<Edge>>(edgeLookup),
@@ -121,7 +128,7 @@ export const getInitialStore = ({
maxZoom: writable<number>(2), maxZoom: writable<number>(2),
nodeOrigin: writable<NodeOrigin>(storeNodeOrigin), nodeOrigin: writable<NodeOrigin>(storeNodeOrigin),
nodeDragThreshold: writable<number>(1), nodeDragThreshold: writable<number>(1),
nodeExtent: writable<CoordinateExtent>(infiniteExtent), nodeExtent: writable<CoordinateExtent>(storeNodeExtent),
translateExtent: writable<CoordinateExtent>(infiniteExtent), translateExtent: writable<CoordinateExtent>(infiniteExtent),
autoPanOnNodeDrag: writable<boolean>(true), autoPanOnNodeDrag: writable<boolean>(true),
autoPanOnConnect: writable<boolean>(true), autoPanOnConnect: writable<boolean>(true),
+6 -2
View File
@@ -15,7 +15,9 @@ import {
type EdgeLookup, type EdgeLookup,
type NodeLookup, type NodeLookup,
type ParentLookup, type ParentLookup,
type NodeOrigin type NodeOrigin,
infiniteExtent,
type CoordinateExtent
} from '@xyflow/system'; } from '@xyflow/system';
import type { DefaultEdgeOptions, DefaultNodeOptions, Edge, InternalNode, Node } from '$lib/types'; import type { DefaultEdgeOptions, DefaultNodeOptions, Edge, InternalNode, Node } from '$lib/types';
@@ -131,7 +133,8 @@ export const createNodesStore = (
nodes: Node[], nodes: Node[],
nodeLookup: NodeLookup<InternalNode>, nodeLookup: NodeLookup<InternalNode>,
parentLookup: ParentLookup<InternalNode>, parentLookup: ParentLookup<InternalNode>,
nodeOrigin: NodeOrigin = [0, 0] nodeOrigin: NodeOrigin = [0, 0],
nodeExtent: CoordinateExtent = infiniteExtent
): { ): {
subscribe: (this: void, run: Subscriber<Node[]>) => Unsubscriber; subscribe: (this: void, run: Subscriber<Node[]>) => Unsubscriber;
update: (this: void, updater: Updater<Node[]>) => void; update: (this: void, updater: Updater<Node[]>) => void;
@@ -148,6 +151,7 @@ export const createNodesStore = (
adoptUserNodes(nds, nodeLookup, parentLookup, { adoptUserNodes(nds, nodeLookup, parentLookup, {
elevateNodesOnSelect, elevateNodesOnSelect,
nodeOrigin, nodeOrigin,
nodeExtent,
defaults, defaults,
checkEquality: false checkEquality: false
}); });
+25 -3
View File
@@ -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 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) => ({ export const clampPosition = (
x: clamp(position.x, extent[0][0], extent[1][0]), position: XYPosition = { x: 0, y: 0 },
y: clamp(position.y, extent[0][1], extent[1][1]), extent: CoordinateExtent,
dimensions: Partial<Dimensions>
) => ({
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<NodeType extends NodeBase>(
childPosition: XYPosition,
childDimensions: Dimensions,
parent: InternalNodeBase<NodeType>
) {
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 * Calculates the velocity of panning when the mouse is close to the edge of the canvas
* @internal * @internal
+8 -29
View File
@@ -297,24 +297,6 @@ export async function fitView<Params extends FitViewParamsBase<NodeBase>, Option
return Promise.resolve(true); 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<NodeType extends NodeBase>(
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. * 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<NodeType extends NodeBase>({
const node = nodeLookup.get(nodeId)!; const node = nodeLookup.get(nodeId)!;
const parentNode = node.parentId ? nodeLookup.get(node.parentId) : undefined; const parentNode = node.parentId ? nodeLookup.get(node.parentId) : undefined;
const { x: parentX, y: parentY } = parentNode ? parentNode.internals.positionAbsolute : { x: 0, y: 0 }; 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 (node.extent === 'parent' && !node.expandParent) {
if (!parentNode) { if (!parentNode) {
onError?.('005', errorMessages['error005']()); onError?.('005', errorMessages['error005']());
} else { } else {
const nodeWidth = node.measured.width;
const nodeHeight = node.measured.height;
const parentWidth = parentNode.measured.width; const parentWidth = parentNode.measured.width;
const parentHeight = parentNode.measured.height; const parentHeight = parentNode.measured.height;
if (nodeWidth && nodeHeight && parentWidth && parentHeight) { if (parentWidth && parentHeight) {
currentExtent = [ extent = [
[parentX, parentY], [parentX, parentY],
[parentX + parentWidth - nodeWidth, parentY + parentHeight - nodeHeight], [parentX + parentWidth, parentY + parentHeight],
]; ];
} }
} }
} else if (parentNode && isCoordinateExtent(node.extent)) { } else if (parentNode && isCoordinateExtent(node.extent)) {
currentExtent = [ extent = [
[node.extent[0][0] + parentX, node.extent[0][1] + parentY], [node.extent[0][0] + parentX, node.extent[0][1] + parentY],
[node.extent[1][0] + parentX, node.extent[1][1] + parentY], [node.extent[1][0] + parentX, node.extent[1][1] + parentY],
]; ];
} }
const positionAbsolute = isCoordinateExtent(currentExtent) const positionAbsolute = isCoordinateExtent(extent)
? clampPosition(nextPosition, currentExtent) ? clampPosition(nextPosition, extent, node.measured)
: nextPosition; : nextPosition;
return { return {
position: { position: {
// TODO: is there a better way to do this?
x: positionAbsolute.x - parentX + node.measured.width! * origin[0], x: positionAbsolute.x - parentX + node.measured.width! * origin[0],
y: positionAbsolute.y - parentY + node.measured.height! * origin[1], y: positionAbsolute.y - parentY + node.measured.height! * origin[1],
}, },
+98 -31
View File
@@ -1,3 +1,4 @@
import { infiniteExtent } from '..';
import { import {
NodeBase, NodeBase,
CoordinateExtent, CoordinateExtent,
@@ -17,37 +18,60 @@ import {
ParentLookup, ParentLookup,
} from '../types'; } from '../types';
import { getDimensions, getHandleBounds } from './dom'; 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 { getNodePositionWithOrigin } from './graph';
import { ParentExpandChild } from './types'; import { ParentExpandChild } from './types';
const defaultOptions = { const defaultOptions = {
nodeOrigin: [0, 0] as NodeOrigin, nodeOrigin: [0, 0] as NodeOrigin,
nodeExtent: infiniteExtent,
elevateNodesOnSelect: true, elevateNodesOnSelect: true,
defaults: {}, defaults: {},
}; };
const adoptUserNodesDefaultOptions = { const adoptUserNodesDefaultOptions = {
...defaultOptions, ...defaultOptions,
checkEquality: true, checkEquality: true,
}; };
function mergeObjects<T extends Record<string, any>>(base: T, incoming?: Partial<T>): 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<NodeType extends NodeBase>( export function updateAbsolutePositions<NodeType extends NodeBase>(
nodeLookup: NodeLookup<InternalNodeBase<NodeType>>, nodeLookup: NodeLookup<InternalNodeBase<NodeType>>,
parentLookup: ParentLookup<InternalNodeBase<NodeType>>, parentLookup: ParentLookup<InternalNodeBase<NodeType>>,
options?: UpdateNodesOptions<NodeType> options?: UpdateNodesOptions<NodeType>
) { ) {
const _options = { ...defaultOptions, ...options }; const _options = mergeObjects(defaultOptions, options);
for (const node of nodeLookup.values()) { for (const node of nodeLookup.values()) {
if (!node.parentId) { if (!node.parentId) {
continue; continue;
} }
updateChildPosition(node, nodeLookup, parentLookup, _options); updateChildNode(node, nodeLookup, parentLookup, _options);
} }
} }
type UpdateNodesOptions<NodeType extends NodeBase> = { type UpdateNodesOptions<NodeType extends NodeBase> = {
nodeOrigin?: NodeOrigin; nodeOrigin?: NodeOrigin;
nodeExtent?: CoordinateExtent;
elevateNodesOnSelect?: boolean; elevateNodesOnSelect?: boolean;
defaults?: Partial<NodeType>; defaults?: Partial<NodeType>;
checkEquality?: boolean; checkEquality?: boolean;
@@ -59,18 +83,23 @@ export function adoptUserNodes<NodeType extends NodeBase>(
parentLookup: ParentLookup<InternalNodeBase<NodeType>>, parentLookup: ParentLookup<InternalNodeBase<NodeType>>,
options?: UpdateNodesOptions<NodeType> options?: UpdateNodesOptions<NodeType>
) { ) {
const _options = { ...adoptUserNodesDefaultOptions, ...options }; const _options = mergeObjects(adoptUserNodesDefaultOptions, options);
const tmpLookup = new Map(nodeLookup); const tmpLookup = new Map(nodeLookup);
const selectedNodeZ: number = _options?.elevateNodesOnSelect ? 1000 : 0;
nodeLookup.clear(); nodeLookup.clear();
parentLookup.clear(); parentLookup.clear();
const selectedNodeZ: number = options?.elevateNodesOnSelect ? 1000 : 0;
for (const userNode of nodes) { for (const userNode of nodes) {
let internalNode = tmpLookup.get(userNode.id); let internalNode = tmpLookup.get(userNode.id);
if (_options.checkEquality && userNode === internalNode?.internals.userNode) { if (_options.checkEquality && userNode === internalNode?.internals.userNode) {
nodeLookup.set(userNode.id, internalNode); nodeLookup.set(userNode.id, internalNode);
} else { } else {
const positionWithOrigin = getNodePositionWithOrigin(userNode, _options.nodeOrigin);
const extent = isCoordinateExtent(userNode.extent) ? userNode.extent : _options.nodeExtent;
const clampedPosition = clampPosition(positionWithOrigin, extent, getNodeDimensions(userNode));
internalNode = { internalNode = {
..._options.defaults, ..._options.defaults,
...userNode, ...userNode,
@@ -79,30 +108,50 @@ export function adoptUserNodes<NodeType extends NodeBase>(
height: userNode.measured?.height, height: userNode.measured?.height,
}, },
internals: { 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 // 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, handleBounds: !userNode.measured ? undefined : internalNode?.internals.handleBounds,
z: calculateZ(userNode, selectedNodeZ), z: calculateZ(userNode, selectedNodeZ),
userNode, userNode,
}, },
}; };
nodeLookup.set(userNode.id, internalNode); nodeLookup.set(userNode.id, internalNode);
} }
if (userNode.parentId) { if (userNode.parentId) {
updateChildPosition(internalNode, nodeLookup, parentLookup, options); updateChildNode(internalNode, nodeLookup, parentLookup, options);
} }
} }
} }
function updateChildPosition<NodeType extends NodeBase>( function updateParentLookup<NodeType extends NodeBase>(
node: InternalNodeBase<NodeType>,
parentLookup: ParentLookup<InternalNodeBase<NodeType>>
) {
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<NodeType extends NodeBase>(
node: InternalNodeBase<NodeType>, node: InternalNodeBase<NodeType>,
nodeLookup: NodeLookup<InternalNodeBase<NodeType>>, nodeLookup: NodeLookup<InternalNodeBase<NodeType>>,
parentLookup: ParentLookup<InternalNodeBase<NodeType>>, parentLookup: ParentLookup<InternalNodeBase<NodeType>>,
options?: UpdateNodesOptions<NodeType> options?: UpdateNodesOptions<NodeType>
) { ) {
const _options = { ...defaultOptions, ...options }; const { elevateNodesOnSelect, nodeOrigin, nodeExtent } = mergeObjects(defaultOptions, options);
const parentId = node.parentId!; const parentId = node.parentId!;
const parentNode = nodeLookup.get(parentId); const parentNode = nodeLookup.get(parentId);
@@ -113,25 +162,17 @@ function updateChildPosition<NodeType extends NodeBase>(
return; return;
} }
// update the parentLookup updateParentLookup(node, parentLookup);
const childNodes = parentLookup.get(parentId);
if (childNodes) {
childNodes.set(node.id, node);
} else {
parentLookup.set(parentId, new Map([[node.id, node]]));
}
const selectedNodeZ: number = options?.elevateNodesOnSelect ? 1000 : 0; const selectedNodeZ = elevateNodesOnSelect ? 1000 : 0;
const { x, y, z } = calculateChildXYZ(node, parentNode, nodeOrigin, nodeExtent, selectedNodeZ);
const { x, y, z } = calculateChildXYZ(node, parentNode, _options.nodeOrigin!, selectedNodeZ); const { positionAbsolute } = node.internals;
const positionChanged = x !== positionAbsolute.x || y !== positionAbsolute.y;
const currPosition = node.internals.positionAbsolute;
const positionChanged = x !== currPosition.x || y !== currPosition.y;
if (positionChanged || z !== node.internals.z) { if (positionChanged || z !== node.internals.z) {
node.internals = { node.internals = {
...node.internals, ...node.internals,
positionAbsolute: positionChanged ? { x, y } : currPosition, positionAbsolute: positionChanged ? { x, y } : positionAbsolute,
z, z,
}; };
} }
@@ -145,15 +186,32 @@ function calculateChildXYZ<NodeType extends NodeBase>(
childNode: InternalNodeBase<NodeType>, childNode: InternalNodeBase<NodeType>,
parentNode: InternalNodeBase<NodeType>, parentNode: InternalNodeBase<NodeType>,
nodeOrigin: NodeOrigin, nodeOrigin: NodeOrigin,
nodeExtent: CoordinateExtent,
selectedNodeZ: number 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 childZ = calculateZ(childNode, selectedNodeZ);
const parentZ = parentNode.internals.z ?? 0; const parentZ = parentNode.internals.z ?? 0;
return { return {
x: parentNode.internals.positionAbsolute.x + position.x, x: absolutePosition.x,
y: parentNode.internals.positionAbsolute.y + position.y, y: absolutePosition.y,
z: parentZ > childZ ? parentZ : childZ, z: parentZ > childZ ? parentZ : childZ,
}; };
} }
@@ -249,7 +307,8 @@ export function updateNodeInternals<NodeType extends InternalNodeBase>(
nodeLookup: NodeLookup<NodeType>, nodeLookup: NodeLookup<NodeType>,
parentLookup: ParentLookup<NodeType>, parentLookup: ParentLookup<NodeType>,
domNode: HTMLElement | null, domNode: HTMLElement | null,
nodeOrigin?: NodeOrigin nodeOrigin?: NodeOrigin,
nodeExtent?: CoordinateExtent
): { changes: (NodeDimensionChange | NodePositionChange)[]; updatedInternals: boolean } { ): { changes: (NodeDimensionChange | NodePositionChange)[]; updatedInternals: boolean } {
const viewportNode = domNode?.querySelector('.xyflow__viewport'); const viewportNode = domNode?.querySelector('.xyflow__viewport');
let updatedInternals = false; let updatedInternals = false;
@@ -287,18 +346,26 @@ export function updateNodeInternals<NodeType extends InternalNodeBase>(
if (doUpdate) { if (doUpdate) {
const nodeBounds = update.nodeElement.getBoundingClientRect(); 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.measured = dimensions;
node.internals = { node.internals = {
...node.internals, ...node.internals,
positionAbsolute: getNodePositionWithOrigin(node, nodeOrigin), positionAbsolute,
handleBounds: { handleBounds: {
source: getHandleBounds('source', update.nodeElement, nodeBounds, zoom, node.id), source: getHandleBounds('source', update.nodeElement, nodeBounds, zoom, node.id),
target: getHandleBounds('target', update.nodeElement, nodeBounds, zoom, node.id), target: getHandleBounds('target', update.nodeElement, nodeBounds, zoom, node.id),
}, },
}; };
if (node.parentId) { if (node.parentId) {
updateChildPosition(node, nodeLookup, parentLookup, { nodeOrigin }); updateChildNode(node, nodeLookup, parentLookup, { nodeOrigin });
} }
updatedInternals = true; updatedInternals = true;