merg main

This commit is contained in:
peterkogo
2025-05-14 11:25:48 +02:00
31 changed files with 363 additions and 106 deletions
@@ -32,6 +32,7 @@ function ResizeControl({
maxWidth = Number.MAX_VALUE,
maxHeight = Number.MAX_VALUE,
keepAspectRatio = false,
resizeDirection,
shouldResize,
onResizeStart,
onResize,
@@ -117,11 +118,12 @@ function ResizeControl({
}
if (change.width !== undefined && change.height !== undefined) {
const setAttributes = !resizeDirection ? true : resizeDirection === 'horizontal' ? 'width' : 'height';
const dimensionChange: NodeDimensionChange = {
id,
type: 'dimensions',
resizing: true,
setAttributes: true,
setAttributes,
dimensions: {
width: change.width,
height: change.height,
@@ -166,6 +168,7 @@ function ResizeControl({
maxHeight,
},
keepAspectRatio,
resizeDirection,
onResizeStart,
onResize,
onResizeEnd,
@@ -3,6 +3,7 @@ import type {
ControlPosition,
ControlLinePosition,
ResizeControlVariant,
ResizeControlDirection,
ShouldResize,
OnResizeStart,
OnResize,
@@ -97,6 +98,11 @@ export type ResizeControlProps = Pick<
* @example ResizeControlVariant.Handle, ResizeControlVariant.Line
*/
variant?: ResizeControlVariant;
/**
* The direction the user can resize the node.
* If not provided, the user can resize in any direction.
*/
resizeDirection?: ResizeControlDirection;
className?: string;
style?: CSSProperties;
children?: ReactNode;
@@ -105,6 +111,6 @@ export type ResizeControlProps = Pick<
/**
* @expand
*/
export type ResizeControlLineProps = ResizeControlProps & {
export type ResizeControlLineProps = Omit<ResizeControlProps, 'resizeDirection'> & {
position?: ControlLinePosition;
};
@@ -40,28 +40,27 @@ export function BatchProvider<NodeType extends Node = Node, EdgeType extends Edg
next = typeof payload === 'function' ? payload(next) : payload;
}
const changes = getElementsDiffChanges({
items: next,
lookup: nodeLookup,
}) as NodeChange<NodeType>[];
if (hasDefaultNodes) {
setNodes(next);
} else {
// When a controlled flow is used we need to collect the changes
const changes = getElementsDiffChanges({
items: next,
lookup: nodeLookup,
}) as NodeChange<NodeType>[];
}
// We only want to fire onNodesChange if there are changes to the nodes
if (changes.length > 0) {
onNodesChange?.(changes);
} else if (fitViewQueued) {
// If there are no changes to the nodes, we still need to call setNodes
// to trigger a re-render and fitView.
window.requestAnimationFrame(() => {
const { fitViewQueued, nodes, setNodes } = store.getState();
if (fitViewQueued) {
setNodes(nodes);
}
});
}
// We only want to fire onNodesChange if there are changes to the nodes
if (changes.length > 0) {
onNodesChange?.(changes);
} else if (fitViewQueued) {
// If there are no changes to the nodes, we still need to call setNodes
// to trigger a re-render and fitView.
window.requestAnimationFrame(() => {
const { fitViewQueued, nodes, setNodes } = store.getState();
if (fitViewQueued) {
setNodes(nodes);
}
});
}
}, []);
@@ -3,18 +3,49 @@ import { useState, type ReactNode } from 'react';
import { Provider } from '../../contexts/StoreContext';
import { createStore } from '../../store';
import { BatchProvider } from '../BatchProvider';
import type { Node, Edge } from '../../types';
import type { Node, Edge, FitViewOptions } from '../../types';
import { CoordinateExtent, NodeOrigin } from '@xyflow/system';
export type ReactFlowProviderProps = {
/** These nodes are used to initialize the flow. They are not dynamic. */
initialNodes?: Node[];
/** These edges are used to initialize the flow. They are not dynamic. */
initialEdges?: Edge[];
/** These nodes are used to initialize the flow. They are not dynamic. */
defaultNodes?: Node[];
/** These edges are used to initialize the flow. They are not dynamic. */
defaultEdges?: Edge[];
/** The initial width is necessary to be able to use fitView on the server */
initialWidth?: number;
/** The initial height is necessary to be able to use fitView on the server */
initialHeight?: number;
/** When `true`, the flow will be zoomed and panned to fit all the nodes initially provided. */
fitView?: boolean;
/**
* You can provide an object of options to customize the initial fitView behavior.
*/
initialFitViewOptions?: FitViewOptions;
/** Initial minimum zoom level */
initialMinZoom?: number;
/** Initial maximum zoom level */
initialMaxZoom?: number;
/**
* The origin of the node to use when placing it in the flow or looking up its `x` and `y`
* position. An origin of `[0, 0]` means that a node's top left corner will be placed at the `x`
* and `y` position.
* @default [0, 0]
* @example
* [0, 0] // default, top left
* [0.5, 0.5] // center
* [1, 1] // bottom right
*/
nodeOrigin?: NodeOrigin;
/**
* By default, nodes can be placed on an infinite flow. 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;
children: ReactNode;
};
@@ -60,6 +91,9 @@ export function ReactFlowProvider({
defaultEdges,
initialWidth: width,
initialHeight: height,
initialMinZoom: minZoom,
initialMaxZoom: maxZoom,
initialFitViewOptions: fitViewOptions,
fitView,
nodeOrigin,
nodeExtent,
@@ -74,6 +108,9 @@ export function ReactFlowProvider({
width,
height,
fitView,
minZoom,
maxZoom,
fitViewOptions,
nodeOrigin,
nodeExtent,
})
@@ -67,7 +67,7 @@ const MarkerDefinitions = ({ defaultColor, rfId }: MarkerDefinitionsProps) => {
}
return (
<svg className="react-flow__marker">
<svg className="react-flow__marker" aria-hidden="true">
<defs>
{markers.map((marker: MarkerProps) => (
<Marker
@@ -2,7 +2,7 @@ import { useContext, type ReactNode } from 'react';
import StoreContext from '../../contexts/StoreContext';
import { ReactFlowProvider } from '../../components/ReactFlowProvider';
import type { Node, Edge } from '../../types';
import type { Node, Edge, FitViewOptions } from '../../types';
import { CoordinateExtent, NodeOrigin } from '@xyflow/system';
export function Wrapper({
@@ -14,6 +14,9 @@ export function Wrapper({
width,
height,
fitView,
fitViewOptions,
minZoom,
maxZoom,
nodeOrigin,
nodeExtent,
}: {
@@ -25,6 +28,9 @@ export function Wrapper({
width?: number;
height?: number;
fitView?: boolean;
fitViewOptions?: FitViewOptions;
minZoom?: number;
maxZoom?: number;
nodeOrigin?: NodeOrigin;
nodeExtent?: CoordinateExtent;
}) {
@@ -47,6 +53,9 @@ export function Wrapper({
initialWidth={width}
initialHeight={height}
fitView={fitView}
initialFitViewOptions={fitViewOptions}
initialMinZoom={minZoom}
initialMaxZoom={maxZoom}
nodeOrigin={nodeOrigin}
nodeExtent={nodeExtent}
>
@@ -177,6 +177,9 @@ function ReactFlow<NodeType extends Node = Node, EdgeType extends Edge = Edge>(
width={width}
height={height}
fitView={fitView}
fitViewOptions={fitViewOptions}
minZoom={minZoom}
maxZoom={maxZoom}
nodeOrigin={nodeOrigin}
nodeExtent={nodeExtent}
>
@@ -9,15 +9,17 @@ export type UseNodesInitializedOptions = {
};
const selector = (options: UseNodesInitializedOptions) => (s: ReactFlowState) => {
if (!options.includeHiddenNodes) {
return s.nodesInitialized;
}
if (s.nodeLookup.size === 0) {
return false;
}
for (const [, { hidden, internals }] of s.nodeLookup) {
if (options.includeHiddenNodes || !hidden) {
if (internals.handleBounds === undefined || !nodeHasDimensions(internals.userNode)) {
return false;
}
for (const [, { internals }] of s.nodeLookup) {
if (internals.handleBounds === undefined || !nodeHasDimensions(internals.userNode)) {
return false;
}
}
+2 -1
View File
@@ -8,6 +8,7 @@ import {
isRectObject,
NodeRemoveChange,
nodeToRect,
withResolvers,
type Rect,
} from '@xyflow/system';
@@ -280,7 +281,7 @@ export function useReactFlow<NodeType extends Node = Node, EdgeType extends Edge
fitView: async (options: FitViewOptions<NodeType> | undefined) => {
// We either create a new Promise or reuse the existing one
// Even if fitView is called multiple times in a row, we only end up with a single Promise
const fitViewResolver = store.getState().fitViewResolver ?? Promise.withResolvers<boolean>();
const fitViewResolver = store.getState().fitViewResolver ?? withResolvers<boolean>();
// We schedule a fitView by setting fitViewQueued and triggering a setNodes
store.setState({ fitViewQueued: true, fitViewOptions: options, fitViewResolver });
+28 -5
View File
@@ -18,7 +18,7 @@ import {
import { applyEdgeChanges, applyNodeChanges, createSelectionChange, getSelectionChanges } from '../utils/changes';
import getInitialState from './initialState';
import type { ReactFlowState, Node, Edge, UnselectNodesAndEdgesParams } from '../types';
import type { ReactFlowState, Node, Edge, UnselectNodesAndEdgesParams, FitViewOptions } from '../types';
const createStore = ({
nodes,
@@ -28,6 +28,9 @@ const createStore = ({
width,
height,
fitView,
fitViewOptions,
minZoom,
maxZoom,
nodeOrigin,
nodeExtent,
}: {
@@ -38,6 +41,9 @@ const createStore = ({
width?: number;
height?: number;
fitView?: boolean;
fitViewOptions?: FitViewOptions;
minZoom?: number;
maxZoom?: number;
nodeOrigin?: NodeOrigin;
nodeExtent?: CoordinateExtent;
}) =>
@@ -70,7 +76,20 @@ const createStore = ({
}
return {
...getInitialState({ nodes, edges, width, height, fitView, nodeOrigin, nodeExtent, defaultNodes, defaultEdges }),
...getInitialState({
nodes,
edges,
width,
height,
fitView,
fitViewOptions,
minZoom,
maxZoom,
nodeOrigin,
nodeExtent,
defaultNodes,
defaultEdges,
}),
setNodes: (nodes: Node[]) => {
const { nodeLookup, parentLookup, nodeOrigin, elevateNodesOnSelect, fitViewQueued } = get();
/*
@@ -91,9 +110,9 @@ const createStore = ({
if (fitViewQueued && nodesInitialized) {
resolveFitView();
set({ nodes, fitViewQueued: false, fitViewOptions: undefined });
set({ nodes, nodesInitialized, fitViewQueued: false, fitViewOptions: undefined });
} else {
set({ nodes });
set({ nodes, nodesInitialized });
}
},
setEdges: (edges: Edge[]) => {
@@ -297,7 +316,11 @@ const createStore = ({
get().panZoom?.setClickDistance(clickDistance);
},
resetSelectedElements: () => {
const { edges, nodes, triggerNodeChanges, triggerEdgeChanges } = get();
const { edges, nodes, triggerNodeChanges, triggerEdgeChanges, elementsSelectable } = get();
if (!elementsSelectable) {
return;
}
const nodeChanges = nodes.reduce<NodeSelectionChange[]>(
(res, node) => (node.selected ? [...res, createSelectionChange(node.id, false)] : res),
+20 -6
View File
@@ -12,7 +12,7 @@ import {
CoordinateExtent,
} from '@xyflow/system';
import type { Edge, InternalNode, Node, ReactFlowStore } from '../types';
import type { Edge, FitViewOptions, InternalNode, Node, ReactFlowStore } from '../types';
const getInitialState = ({
nodes,
@@ -22,6 +22,9 @@ const getInitialState = ({
width,
height,
fitView,
fitViewOptions,
minZoom = 0.5,
maxZoom = 2,
nodeOrigin,
nodeExtent,
}: {
@@ -32,6 +35,9 @@ const getInitialState = ({
width?: number;
height?: number;
fitView?: boolean;
fitViewOptions?: FitViewOptions;
minZoom?: number;
maxZoom?: number;
nodeOrigin?: NodeOrigin;
nodeExtent?: CoordinateExtent;
} = {}): ReactFlowStore => {
@@ -46,7 +52,7 @@ const getInitialState = ({
const storeNodeExtent = nodeExtent ?? infiniteExtent;
updateConnectionLookup(connectionLookup, edgeLookup, storeEdges);
adoptUserNodes(storeNodes, nodeLookup, parentLookup, {
const nodesInitialized = adoptUserNodes(storeNodes, nodeLookup, parentLookup, {
nodeOrigin: storeNodeOrigin,
nodeExtent: storeNodeExtent,
elevateNodesOnSelect: false,
@@ -59,7 +65,14 @@ const getInitialState = ({
filter: (node) => !!((node.width || node.initialWidth) && (node.height || node.initialHeight)),
});
const { x, y, zoom } = getViewportForBounds(bounds, width, height, 0.5, 2, 0.1);
const { x, y, zoom } = getViewportForBounds(
bounds,
width,
height,
minZoom,
maxZoom,
fitViewOptions?.padding ?? 0.1
);
transform = [x, y, zoom];
}
@@ -69,6 +82,7 @@ const getInitialState = ({
height: 0,
transform,
nodes: storeNodes,
nodesInitialized,
nodeLookup,
parentLookup,
edges: storeEdges,
@@ -79,8 +93,8 @@ const getInitialState = ({
hasDefaultNodes: defaultNodes !== undefined,
hasDefaultEdges: defaultEdges !== undefined,
panZoom: null,
minZoom: 0.5,
maxZoom: 2,
minZoom,
maxZoom,
translateExtent: infiniteExtent,
nodeExtent: storeNodeExtent,
nodesSelectionActive: false,
@@ -109,7 +123,7 @@ const getInitialState = ({
multiSelectionActive: false,
fitViewQueued: fitView ?? false,
fitViewOptions: undefined,
fitViewOptions,
fitViewResolver: null,
connection: { ...initialConnection },
+3 -30
View File
@@ -85,22 +85,11 @@ export interface ReactFlowProps<NodeType extends Node = Node, EdgeType extends E
defaultEdges?: EdgeType[];
/**
* Defaults to be applied to all new edges that are added to the flow.
*
* Properties on a new edge will override these defaults if they exist.
* @example
* const defaultEdgeOptions = {
* type: 'customEdgeType',
* animated: true,
* interactionWidth: 10,
* data: { label: 'custom label' },
* hidden: false,
* deletable: true,
* selected: false,
* focusable: true,
* markerStart: EdgeMarker.ArrowClosed,
* markerEnd: EdgeMarker.ArrowClosed,
* zIndex: 12,
* ariaLabel: 'custom aria label'
* animated: true
* }
*/
defaultEdgeOptions?: DefaultEdgeOptions;
@@ -137,7 +126,6 @@ export interface ReactFlowProps<NodeType extends Node = Node, EdgeType extends E
/**
* This handler is called when the source or target of a reconnectable edge is dragged from the
* current node. It will fire even if the edge's source or target do not end up changing.
*
* You can use the `reconnectEdge` utility to convert the connection to a new edge.
*/
onReconnect?: OnReconnect<EdgeType>;
@@ -148,7 +136,6 @@ export interface ReactFlowProps<NodeType extends Node = Node, EdgeType extends E
/**
* This event fires when the user releases the source or target of an editable edge. It is called
* even if an edge update does not occur.
*
*/
onReconnectEnd?: (event: MouseEvent | TouchEvent, edge: EdgeType, handleType: HandleType) => void;
/**
@@ -209,7 +196,6 @@ export interface ReactFlowProps<NodeType extends Node = Node, EdgeType extends E
onSelectionContextMenu?: (event: ReactMouseEvent, nodes: NodeType[]) => void;
/**
* When a connection line is completed and two nodes are connected by the user, this event fires with the new connection.
*
* You can use the `addEdge` utility to convert the connection to a complete edge.
* @example // Use helper function to update edges onConnect
* import ReactFlow, { addEdge } from '@xyflow/react';
@@ -277,9 +263,7 @@ export interface ReactFlowProps<NodeType extends Node = Node, EdgeType extends E
onBeforeDelete?: OnBeforeDelete<NodeType, EdgeType>;
/**
* Custom node types to be available in a flow.
*
* React Flow matches a node's type to a component in the `nodeTypes` object.
* @TODO check if @default is correct
* @default {
* input: InputNode,
* default: DefaultNode,
@@ -294,9 +278,7 @@ export interface ReactFlowProps<NodeType extends Node = Node, EdgeType extends E
nodeTypes?: NodeTypes;
/**
* Custom edge types to be available in a flow.
*
* React Flow matches an edge's type to a component in the `edgeTypes` object.
* @TODO check if @default is correct
* @default {
* default: BezierEdge,
* straight: StraightEdge,
@@ -312,7 +294,6 @@ export interface ReactFlowProps<NodeType extends Node = Node, EdgeType extends E
edgeTypes?: EdgeTypes;
/**
* The type of edge path to use for connection lines.
*
* Although created edges can be of any type, React Flow needs to know what type of path to render for the connection line before the edge is created!
* @default ConnectionLineType.Bezier
*/
@@ -333,7 +314,7 @@ export interface ReactFlowProps<NodeType extends Node = Node, EdgeType extends E
/**
* If set, pressing the key or chord will delete any selected nodes and edges. Passing an array
* represents multiple keys that can be pressed.
*
* For example, `["Delete", "Backspace"]` will delete selected elements when either key is pressed.
* @default 'Backspace'
*/
@@ -450,7 +431,6 @@ export interface ReactFlowProps<NodeType extends Node = Node, EdgeType extends E
selectNodesOnDrag?: boolean;
/**
* Enabling this prop allows users to pan the viewport by clicking and dragging.
*
* You can also set this prop to an array of numbers to limit which mouse buttons can activate panning.
* @default true
* @example [0, 2] // allows panning with the left and right mouse buttons
@@ -489,7 +469,6 @@ export interface ReactFlowProps<NodeType extends Node = Node, EdgeType extends E
onViewportChange?: (viewport: Viewport) => void;
/**
* By default, the viewport extends infinitely. 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.
* @default [[-∞, -∞], [+∞, +∞]]
* @example [[-1000, -10000], [1000, 1000]]
@@ -502,7 +481,6 @@ export interface ReactFlowProps<NodeType extends Node = Node, EdgeType extends E
preventScrolling?: boolean;
/**
* By default, nodes can be placed on an infinite flow. 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]]
*/
@@ -524,21 +502,18 @@ export interface ReactFlowProps<NodeType extends Node = Node, EdgeType extends E
zoomOnPinch?: boolean;
/**
* Controls if the viewport should pan by scrolling inside the container.
*
* Can be limited to a specific direction with `panOnScrollMode`.
* @default false
*/
panOnScroll?: boolean;
/**
* Controls how fast viewport should be panned on scroll.
*
* Use together with `panOnScroll` prop.
* @default 0.5
*/
panOnScrollSpeed?: number;
/**
* This prop is used to limit the direction of panning when `panOnScroll` is enabled.
*
* The `"free"` option allows panning in any direction.
* @default "free"
* @example "horizontal" | "vertical"
@@ -671,10 +646,8 @@ export interface ReactFlowProps<NodeType extends Node = Node, EdgeType extends E
isValidConnection?: IsValidConnection<EdgeType>;
/**
* With a threshold greater than zero you can delay node drag events.
*
* If threshold equals 1, you need to drag the node 1 pixel before a drag event is fired.
*
* 1 is the default value, so clicks don't trigger drag events.
* 1 is the default value, so that clicks don't trigger drag events.
* @default 1
*/
nodeDragThreshold?: number;
+3 -1
View File
@@ -1,5 +1,6 @@
import {
ConnectionMode,
withResolvers,
type ConnectionState,
type CoordinateExtent,
type InternalNodeUpdate,
@@ -53,6 +54,7 @@ export type ReactFlowStore<NodeType extends Node = Node, EdgeType extends Edge =
height: number;
transform: Transform;
nodes: NodeType[];
nodesInitialized: boolean;
nodeLookup: NodeLookup<InternalNode<NodeType>>;
parentLookup: ParentLookup<InternalNode<NodeType>>;
edges: EdgeType[];
@@ -121,7 +123,7 @@ export type ReactFlowStore<NodeType extends Node = Node, EdgeType extends Edge =
fitViewQueued: boolean;
fitViewOptions: FitViewOptions | undefined;
fitViewResolver: PromiseWithResolvers<boolean> | null;
fitViewResolver: ReturnType<typeof withResolvers<boolean>> | null;
onNodesDelete?: OnNodesDelete<NodeType>;
onEdgesDelete?: OnEdgesDelete<EdgeType>;
+6 -2
View File
@@ -130,8 +130,12 @@ function applyChange(change: any, element: any): any {
element.measured.height = change.dimensions.height;
if (change.setAttributes) {
element.width = change.dimensions.width;
element.height = change.dimensions.height;
if (change.setAttributes === true || change.setAttributes === 'width') {
element.width = change.dimensions.width;
}
if (change.setAttributes === true || change.setAttributes === 'height') {
element.height = change.dimensions.height;
}
}
}