Merge branch 'next' into refactor/edge-rendering

This commit is contained in:
moklick
2023-12-17 11:04:32 +01:00
187 changed files with 5192 additions and 2186 deletions
@@ -7,7 +7,7 @@ const sourceHandleStyleB: CSSProperties = {
left: 'auto', left: 'auto',
}; };
const CustomNode: FC<NodeProps> = ({ data, xPos, yPos }) => { const CustomNode: FC<NodeProps> = ({ data, positionAbsoluteX, positionAbsoluteY }) => {
return ( return (
<> <>
<Handle type="target" position={Position.Top} /> <Handle type="target" position={Position.Top} />
@@ -18,7 +18,7 @@ const CustomNode: FC<NodeProps> = ({ data, xPos, yPos }) => {
<div> <div>
Position:{' '} Position:{' '}
<strong> <strong>
{xPos.toFixed(2)},{yPos.toFixed(2)} {positionAbsoluteX.toFixed(2)},{positionAbsoluteY.toFixed(2)}
</strong> </strong>
</div> </div>
</div> </div>
@@ -27,7 +27,7 @@ const initialNodes: Node[] = [
type: 'input', type: 'input',
data: { label: 'Node 1' }, data: { label: 'Node 1' },
position: { x: 250, y: 5 }, position: { x: 250, y: 5 },
size: nodeSize, ...nodeSize,
handles: [ handles: [
{ {
type: 'source', type: 'source',
@@ -41,23 +41,19 @@ const initialNodes: Node[] = [
id: '2', id: '2',
data: { label: 'Node 2' }, data: { label: 'Node 2' },
position: { x: 100, y: 100 }, position: { x: 100, y: 100 },
size: nodeSize, ...nodeSize,
handles: [ handles: [
{ {
type: 'source', type: 'source',
position: Position.Bottom, position: Position.Bottom,
x: nodeSize.width * 0.5, x: nodeSize.width * 0.5,
y: nodeSize.height, y: nodeSize.height,
width: 1,
height: 1,
}, },
{ {
type: 'target', type: 'target',
position: Position.Top, position: Position.Top,
x: nodeSize.width * 0.5, x: nodeSize.width * 0.5,
y: 0, y: 0,
width: 1,
height: 1,
}, },
], ],
}, },
@@ -65,23 +61,19 @@ const initialNodes: Node[] = [
id: '3', id: '3',
data: { label: 'Node 3' }, data: { label: 'Node 3' },
position: { x: 400, y: 100 }, position: { x: 400, y: 100 },
size: nodeSize, ...nodeSize,
handles: [ handles: [
{ {
type: 'source', type: 'source',
position: Position.Bottom, position: Position.Bottom,
x: nodeSize.width * 0.5, x: nodeSize.width * 0.5,
y: nodeSize.height, y: nodeSize.height,
width: 1,
height: 1,
}, },
{ {
type: 'target', type: 'target',
position: Position.Top, position: Position.Top,
x: nodeSize.width * 0.5, x: nodeSize.width * 0.5,
y: 0, y: 0,
width: 1,
height: 1,
}, },
], ],
}, },
@@ -11,10 +11,8 @@
data: { label: 'Node 0' }, data: { label: 'Node 0' },
sourcePosition: Position.Right, sourcePosition: Position.Right,
targetPosition: Position.Left, targetPosition: Position.Left,
size: { width: 100,
width: 100, height: 40,
height: 40,
},
handles: [ handles: [
{ type: 'source', x: 100, y: 20, position: Position.Right }, { type: 'source', x: 100, y: 20, position: Position.Right },
{ type: 'target', x: 0, y: 20, position: Position.Left }, { type: 'target', x: 0, y: 20, position: Position.Left },
@@ -26,10 +24,8 @@
data: { label: 'A' }, data: { label: 'A' },
sourcePosition: Position.Right, sourcePosition: Position.Right,
targetPosition: Position.Left, targetPosition: Position.Left,
size: { width: 100,
width: 100, height: 40,
height: 40,
},
handles: [ handles: [
{ type: 'source', x: 100, y: 20, position: Position.Right }, { type: 'source', x: 100, y: 20, position: Position.Right },
{ type: 'target', x: 0, y: 20, position: Position.Left }, { type: 'target', x: 0, y: 20, position: Position.Left },
@@ -41,10 +37,8 @@
data: { label: 'B' }, data: { label: 'B' },
sourcePosition: Position.Right, sourcePosition: Position.Right,
targetPosition: Position.Left, targetPosition: Position.Left,
size: { width: 100,
width: 100, height: 40,
height: 40,
},
handles: [ handles: [
{ type: 'source', x: 100, y: 20, position: Position.Right }, { type: 'source', x: 100, y: 20, position: Position.Right },
{ type: 'target', x: 0, y: 20, position: Position.Left }, { type: 'target', x: 0, y: 20, position: Position.Left },
@@ -56,10 +50,8 @@
data: { label: 'C' }, data: { label: 'C' },
sourcePosition: Position.Right, sourcePosition: Position.Right,
targetPosition: Position.Left, targetPosition: Position.Left,
size: { width: 100,
width: 100, height: 40,
height: 40,
},
handles: [ handles: [
{ type: 'source', x: 100, y: 20, position: Position.Right }, { type: 'source', x: 100, y: 20, position: Position.Right },
{ type: 'target', x: 0, y: 20, position: Position.Left }, { type: 'target', x: 0, y: 20, position: Position.Left },
+20 -8
View File
@@ -1,5 +1,6 @@
import Basic from '../examples/Basic'; import Basic from '../examples/Basic';
import Backgrounds from '../examples/Backgrounds'; import Backgrounds from '../examples/Backgrounds';
import ColorMode from '../examples/ColorMode';
import ControlledUncontrolled from '../examples/ControlledUncontrolled'; import ControlledUncontrolled from '../examples/ControlledUncontrolled';
import ControlledViewport from '../examples/ControlledViewport'; import ControlledViewport from '../examples/ControlledViewport';
import CustomConnectionLine from '../examples/CustomConnectionLine'; import CustomConnectionLine from '../examples/CustomConnectionLine';
@@ -20,7 +21,6 @@ import Interaction from '../examples/Interaction';
import Intersection from '../examples/Intersection'; import Intersection from '../examples/Intersection';
import Layouting from '../examples/Layouting'; import Layouting from '../examples/Layouting';
import MultiFlows from '../examples/MultiFlows'; import MultiFlows from '../examples/MultiFlows';
import NestedNodes from '../examples/NestedNodes';
import NodeResizer from '../examples/NodeResizer'; import NodeResizer from '../examples/NodeResizer';
import NodeTypeChange from '../examples/NodeTypeChange'; import NodeTypeChange from '../examples/NodeTypeChange';
import NodeTypesObjectChange from '../examples/NodeTypesObjectChange'; import NodeTypesObjectChange from '../examples/NodeTypesObjectChange';
@@ -43,7 +43,9 @@ import CancelConnection from '../examples/CancelConnection';
import InteractiveMinimap from '../examples/InteractiveMinimap'; import InteractiveMinimap from '../examples/InteractiveMinimap';
import UseOnSelectionChange from '../examples/UseOnSelectionChange'; import UseOnSelectionChange from '../examples/UseOnSelectionChange';
import NodeToolbar from '../examples/NodeToolbar'; import NodeToolbar from '../examples/NodeToolbar';
import useNodesInitialized from '../examples/UseNodesInit'; import UseNodesInitialized from '../examples/UseNodesInit';
import UseNodesData from '../examples/UseNodesData';
import UseHandleConnections from '../examples/UseHandleConnections';
export interface IRoute { export interface IRoute {
name: string; name: string;
@@ -62,6 +64,11 @@ const routes: IRoute[] = [
path: 'backgrounds', path: 'backgrounds',
component: Backgrounds, component: Backgrounds,
}, },
{
name: 'Color Mode',
path: 'color-mode',
component: ColorMode,
},
{ {
name: 'Cancel Connection', name: 'Cancel Connection',
path: 'cancel-connection', path: 'cancel-connection',
@@ -177,11 +184,6 @@ const routes: IRoute[] = [
path: 'multiflows', path: 'multiflows',
component: MultiFlows, component: MultiFlows,
}, },
{
name: 'Nested Nodes',
path: 'nested-nodes',
component: NestedNodes,
},
{ {
name: 'Node Type Change', name: 'Node Type Change',
path: 'nodetype-change', path: 'nodetype-change',
@@ -255,7 +257,7 @@ const routes: IRoute[] = [
{ {
name: 'useNodesInitialized', name: 'useNodesInitialized',
path: 'use-nodes-initialized', path: 'use-nodes-initialized',
component: useNodesInitialized, component: UseNodesInitialized,
}, },
{ {
name: 'useOnSelectionChange', name: 'useOnSelectionChange',
@@ -267,6 +269,16 @@ const routes: IRoute[] = [
path: 'usereactflow', path: 'usereactflow',
component: UseReactFlow, component: UseReactFlow,
}, },
{
name: 'useHandleConnections',
path: 'usehandleconnections',
component: UseHandleConnections,
},
{
name: 'useNodesData',
path: 'usenodesdata',
component: UseNodesData,
},
{ {
name: 'useUpdateNodeInternals', name: 'useUpdateNodeInternals',
path: 'useupdatenodeinternals', path: 'useupdatenodeinternals',
@@ -0,0 +1,90 @@
import { ChangeEventHandler, useCallback, useState } from 'react';
import {
ReactFlow,
addEdge,
Node,
useNodesState,
useEdgesState,
OnConnect,
Edge,
MiniMap,
Background,
Controls,
Panel,
ColorMode,
Position,
} from '@xyflow/react';
import './style.css';
const nodeDefaults = {
sourcePosition: Position.Right,
targetPosition: Position.Left,
};
const initialNodes: Node[] = [
{ id: 'A', type: 'input', position: { x: 0, y: 150 }, data: { label: 'A' }, ...nodeDefaults },
{ id: 'B', position: { x: 250, y: 0 }, data: { label: 'B' }, ...nodeDefaults },
{ id: 'C', position: { x: 250, y: 150 }, data: { label: 'C' }, ...nodeDefaults },
{ id: 'D', position: { x: 250, y: 300 }, data: { label: 'D' }, ...nodeDefaults },
];
const initialEdges: Edge[] = [
{
id: 'A-B',
source: 'A',
target: 'B',
},
{
id: 'A-C',
source: 'A',
target: 'C',
},
{
id: 'A-D',
source: 'A',
target: 'D',
},
];
const ColorModeFlow = () => {
const [colorMode, setColorMode] = useState<ColorMode>('light');
const [nodes, , onNodesChange] = useNodesState(initialNodes);
const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges);
const onConnect: OnConnect = useCallback(
(params) => {
console.log('on connect', params);
setEdges((eds) => addEdge(params, eds));
},
[setEdges]
);
const onChange: ChangeEventHandler<HTMLSelectElement> = (evt) => setColorMode(evt.target.value as ColorMode);
return (
<ReactFlow
nodes={nodes}
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onConnect={onConnect}
colorMode={colorMode}
fitView
>
<MiniMap />
<Background />
<Controls />
<Panel position="top-right">
<select onChange={onChange} data-testid="colormode-select">
<option value="light">light</option>
<option value="dark">dark</option>
<option value="system">system</option>
</select>
</Panel>
</ReactFlow>
);
};
export default ColorModeFlow;
@@ -1,4 +1,4 @@
import React, { memo, FC, CSSProperties, useCallback } from 'react'; import React, { memo, FC, CSSProperties, useCallback, useEffect } from 'react';
import { Handle, Position, NodeProps, Connection, Edge, useOnViewportChange, Viewport } from '@xyflow/react'; import { Handle, Position, NodeProps, Connection, Edge, useOnViewportChange, Viewport } from '@xyflow/react';
const targetHandleStyle: CSSProperties = { background: '#555' }; const targetHandleStyle: CSSProperties = { background: '#555' };
@@ -5,7 +5,6 @@ import CustomNode from './CustomNode';
import FloatingEdge from './FloatingEdge'; import FloatingEdge from './FloatingEdge';
import CustomConnectionLine from './CustomConnectionLine'; import CustomConnectionLine from './CustomConnectionLine';
import '@xyflow/react/dist/style.css';
import './style.css'; import './style.css';
const initialNodes: Node[] = [ const initialNodes: Node[] = [
@@ -4,12 +4,13 @@ import { Node, Position, MarkerType, XYPosition } from '@xyflow/react';
// of the line between the center of the intersectionNode and the target node // of the line between the center of the intersectionNode and the target node
function getNodeIntersection(intersectionNode: Node, targetNode: Node) { function getNodeIntersection(intersectionNode: Node, targetNode: Node) {
// https://math.stackexchange.com/questions/1724792/an-algorithm-for-finding-the-intersection-point-between-a-center-of-vision-and-a // https://math.stackexchange.com/questions/1724792/an-algorithm-for-finding-the-intersection-point-between-a-center-of-vision-and-a
const { const {
width: intersectionNodeWidth, width: intersectionNodeWidth,
height: intersectionNodeHeight, height: intersectionNodeHeight,
positionAbsolute: intersectionNodePosition, positionAbsolute: intersectionNodePosition,
} = intersectionNode; } = intersectionNode.computed || {};
const targetPosition = targetNode.positionAbsolute!; const targetPosition = targetNode.computed?.positionAbsolute!;
const w = intersectionNodeWidth! / 2; const w = intersectionNodeWidth! / 2;
const h = intersectionNodeHeight! / 2; const h = intersectionNodeHeight! / 2;
@@ -32,7 +33,7 @@ function getNodeIntersection(intersectionNode: Node, targetNode: Node) {
// returns the position (top,right,bottom or right) passed node compared to the intersection point // returns the position (top,right,bottom or right) passed node compared to the intersection point
function getEdgePosition(node: Node, intersectionPoint: XYPosition) { function getEdgePosition(node: Node, intersectionPoint: XYPosition) {
const n = { ...node.positionAbsolute, ...node }; const n = { ...node.computed?.positionAbsolute, ...node };
const nx = Math.round(n.x!); const nx = Math.round(n.x!);
const ny = Math.round(n.y!); const ny = Math.round(n.y!);
const px = Math.round(intersectionPoint.x); const px = Math.round(intersectionPoint.x);
@@ -41,13 +42,13 @@ function getEdgePosition(node: Node, intersectionPoint: XYPosition) {
if (px <= nx + 1) { if (px <= nx + 1) {
return Position.Left; return Position.Left;
} }
if (px >= nx + n.width! - 1) { if (px >= nx + n.computed?.width! - 1) {
return Position.Right; return Position.Right;
} }
if (py <= ny + 1) { if (py <= ny + 1) {
return Position.Top; return Position.Top;
} }
if (py >= n.y! + n.height! - 1) { if (py >= n.y! + n.computed?.height! - 1) {
return Position.Bottom; return Position.Bottom;
} }
@@ -5,11 +5,11 @@ import { Position, XYPosition, Node, Edge } from '@xyflow/react';
function getNodeIntersection(intersectionNode: Node, targetNode: Node): XYPosition { function getNodeIntersection(intersectionNode: Node, targetNode: Node): XYPosition {
// https://math.stackexchange.com/questions/1724792/an-algorithm-for-finding-the-intersection-point-between-a-center-of-vision-and-a // https://math.stackexchange.com/questions/1724792/an-algorithm-for-finding-the-intersection-point-between-a-center-of-vision-and-a
const { const { position: intersectionNodePosition } = intersectionNode;
width: intersectionNodeWidth, const { width: intersectionNodeWidth, height: intersectionNodeHeight } = intersectionNode.computed ?? {
height: intersectionNodeHeight, width: 0,
position: intersectionNodePosition, height: 0,
} = intersectionNode; };
const targetPosition = targetNode.position; const targetPosition = targetNode.position;
const w = (intersectionNodeWidth ?? 0) / 2; const w = (intersectionNodeWidth ?? 0) / 2;
@@ -42,13 +42,13 @@ function getEdgePosition(node: Node, intersectionPoint: XYPosition) {
if (px <= nx + 1) { if (px <= nx + 1) {
return Position.Left; return Position.Left;
} }
if (px >= nx + (n.width ?? 0) - 1) { if (px >= nx + (n.computed?.width ?? 0) - 1) {
return Position.Right; return Position.Right;
} }
if (py <= ny + 1) { if (py <= ny + 1) {
return Position.Top; return Position.Top;
} }
if (py >= n.y + (n.height ?? 0) - 1) { if (py >= n.y + (n.computed?.height ?? 0) - 1) {
return Position.Bottom; return Position.Bottom;
} }
+4 -2
View File
@@ -48,8 +48,10 @@ const initialEdges: Edge[] = [
const setHidden = (hidden: boolean) => (els: any[]) => const setHidden = (hidden: boolean) => (els: any[]) =>
els.map((e: any) => { els.map((e: any) => {
e.hidden = hidden; return {
return e; ...e,
hidden,
};
}); });
const HiddenFlow = () => { const HiddenFlow = () => {
@@ -56,16 +56,16 @@ const LayoutFlow = () => {
const layoutedNodes = nodes.map((node) => { const layoutedNodes = nodes.map((node) => {
const nodeWithPosition = dagreGraph.node(node.id); const nodeWithPosition = dagreGraph.node(node.id);
node.targetPosition = isHorizontal ? Position.Left : Position.Top;
node.sourcePosition = isHorizontal ? Position.Right : Position.Bottom;
// we need to pass a slightly different position in order to notify react flow about the change
// @TODO how can we change the position handling so that we dont need this hack?
node.position = {
x: nodeWithPosition.x + Math.random() / 1000,
y: nodeWithPosition.y,
};
return node; return {
...node,
targetPosition: isHorizontal ? Position.Left : Position.Top,
sourcePosition: isHorizontal ? Position.Right : Position.Bottom,
position: {
x: nodeWithPosition.x,
y: nodeWithPosition.y,
},
};
}); });
setNodes(layoutedNodes); setNodes(layoutedNodes);
@@ -1,187 +0,0 @@
import { useState, MouseEvent, useCallback } from 'react';
import {
ReactFlow,
Controls,
MiniMap,
Background,
addEdge,
useNodesState,
useEdgesState,
Node,
Edge,
ReactFlowInstance,
Connection,
} from '@xyflow/react';
const onNodeDragStop = (_: MouseEvent, node: Node) => console.log('drag stop', node);
const onNodeClick = (_: MouseEvent, node: Node) => console.log('click', node);
const onEdgeClick = (_: MouseEvent, edge: Edge) => console.log('click', edge);
const initialNodes: Node[] = [
{
id: '1',
type: 'input',
data: { label: 'Node 1' },
position: { x: 250, y: 5 },
className: 'light',
},
{
id: '2',
data: { label: 'Node 2' },
position: { x: 100, y: 100 },
className: 'light',
style: { backgroundColor: 'rgba(255, 0, 0, 0.8)', width: 200, height: 200 },
},
{
id: '2a',
data: { label: 'Node 2a' },
position: { x: 10, y: 50 },
parentNode: '2',
},
{
id: '3',
data: { label: 'Node 3' },
position: { x: 320, y: 100 },
className: 'light',
},
{
id: '4',
data: { label: 'Node 4' },
position: { x: 320, y: 200 },
className: 'light',
style: { backgroundColor: 'rgba(255, 0, 0, 0.7)', width: 300, height: 300 },
},
{
id: '4a',
data: { label: 'Node 4a' },
position: { x: 15, y: 65 },
className: 'light',
parentNode: '4',
extent: 'parent',
},
{
id: '4b',
data: { label: 'Node 4b' },
position: { x: 15, y: 120 },
className: 'light',
style: {
backgroundColor: 'rgba(255, 0, 255, 0.7)',
height: 150,
width: 270,
},
parentNode: '4',
},
{
id: '4b1',
data: { label: 'Node 4b1' },
position: { x: 20, y: 40 },
className: 'light',
parentNode: '4b',
},
{
id: '4b2',
data: { label: 'Node 4b2' },
position: { x: 100, y: 100 },
className: 'light',
parentNode: '4b',
},
];
const initialEdges: Edge[] = [
{ id: 'e1-2', source: '1', target: '2', animated: true },
{ id: 'e1-3', source: '1', target: '3' },
{ id: 'e2a-4a', source: '2a', target: '4a' },
{ id: 'e3-4', source: '3', target: '4' },
{ id: 'e3-4b', source: '3', target: '4b' },
{ id: 'e4a-4b1', source: '4a', target: '4b1' },
{ id: 'e4a-4b2', source: '4a', target: '4b2' },
{ id: 'e4b1-4b2', source: '4b1', target: '4b2' },
];
const NestedFlow = () => {
const [rfInstance, setRfInstance] = useState<ReactFlowInstance | null>(null);
const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes);
const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges);
const onConnect = useCallback(
(connection: Connection) => {
setEdges((eds) => addEdge(connection, eds));
},
[setEdges]
);
const onInit = useCallback((reactFlowInstance: ReactFlowInstance) => setRfInstance(reactFlowInstance), []);
const updatePos = () => {
setNodes((nds) => {
return nds.map((n) => {
n.position = {
x: Math.random() * 400,
y: Math.random() * 400,
};
return n;
});
});
};
const logToObject = () => console.log(rfInstance?.toObject());
const resetTransform = () => rfInstance?.setViewport({ x: 0, y: 0, zoom: 1 });
const toggleClassnames = () => {
setNodes((nds) => {
return nds.map((n) => {
n.className = n.className === 'light' ? 'dark' : 'light';
return n;
});
});
};
const toggleChildNodes = () => {
setNodes((nds) => {
return nds.map((n) => {
n.hidden = !!n.parentNode && !n.hidden;
return n;
});
});
};
return (
<ReactFlow
nodes={nodes}
edges={edges}
onInit={onInit}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onNodeClick={onNodeClick}
onEdgeClick={onEdgeClick}
onConnect={onConnect}
onNodeDragStop={onNodeDragStop}
className="react-flow-basic-example"
minZoom={0.2}
maxZoom={4}
onlyRenderVisibleElements={false}
>
<MiniMap pannable />
<Controls />
<Background />
<div style={{ position: 'absolute', right: 10, top: 10, zIndex: 4 }}>
<button onClick={resetTransform} style={{ marginRight: 5 }}>
reset transform
</button>
<button onClick={updatePos} style={{ marginRight: 5 }}>
change pos
</button>
<button onClick={toggleClassnames} style={{ marginRight: 5 }}>
toggle classnames
</button>
<button style={{ marginRight: 5 }} onClick={toggleChildNodes}>
toggleChildNodes
</button>
<button onClick={logToObject}>toObject</button>
</div>
</ReactFlow>
);
};
export default NestedFlow;
@@ -16,8 +16,6 @@ import CustomResizer from './CustomResizer';
import VerticalResizer from './VerticalResizer'; import VerticalResizer from './VerticalResizer';
import HorizontalResizer from './HorizontalResizer'; import HorizontalResizer from './HorizontalResizer';
import '@xyflow/react/dist/style.css';
const nodeTypes = { const nodeTypes = {
defaultResizer: DefaultResizer, defaultResizer: DefaultResizer,
customResizer: CustomResizer, customResizer: CustomResizer,
@@ -23,7 +23,7 @@ const onNodeDragStop = (_: ReactMouseEvent, node: Node, nodes: Node[]) => consol
const onNodeDoubleClick = (_: ReactMouseEvent, node: Node) => console.log('node double click', node); const onNodeDoubleClick = (_: ReactMouseEvent, node: Node) => console.log('node double click', node);
const onPaneClick = (event: ReactMouseEvent) => console.log('pane click', event); const onPaneClick = (event: ReactMouseEvent) => console.log('pane click', event);
const onPaneScroll = (event?: ReactMouseEvent) => console.log('pane scroll', event); const onPaneScroll = (event?: ReactMouseEvent) => console.log('pane scroll', event);
const onPaneContextMenu = (event: ReactMouseEvent) => console.log('pane context menu', event); const onPaneContextMenu = (event: ReactMouseEvent | MouseEvent) => console.log('pane context menu', event);
const onSelectionDrag = (_: ReactMouseEvent, nodes: Node[]) => console.log('selection drag', nodes); const onSelectionDrag = (_: ReactMouseEvent, nodes: Node[]) => console.log('selection drag', nodes);
const onSelectionDragStart = (_: ReactMouseEvent, nodes: Node[]) => console.log('selection drag start', nodes); const onSelectionDragStart = (_: ReactMouseEvent, nodes: Node[]) => console.log('selection drag start', nodes);
const onSelectionDragStop = (_: ReactMouseEvent, nodes: Node[]) => console.log('selection drag stop', nodes); const onSelectionDragStop = (_: ReactMouseEvent, nodes: Node[]) => console.log('selection drag stop', nodes);
@@ -230,9 +230,9 @@ const OverviewFlow = () => {
onEdgesDelete={onEdgesDelete} onEdgesDelete={onEdgesDelete}
onPaneMouseMove={onPaneMouseMove} onPaneMouseMove={onPaneMouseMove}
> >
<MiniMap nodeStrokeColor={nodeStrokeColor} nodeColor={nodeColor} nodeBorderRadius={2} /> <MiniMap nodeBorderRadius={2} />
<Controls /> <Controls />
<Background color="#aaa" gap={25} /> <Background gap={25} />
</ReactFlow> </ReactFlow>
); );
}; };
+167 -2
View File
@@ -1,4 +1,4 @@
import { useState, useCallback } from 'react'; import { useState, useCallback, useRef, useEffect } from 'react';
import { import {
ReactFlow, ReactFlow,
Edge, Edge,
@@ -16,6 +16,7 @@ import {
} from '@xyflow/react'; } from '@xyflow/react';
import { getNodesAndEdges } from './utils'; import { getNodesAndEdges } from './utils';
import { FrameRecorder, generateMouseEventParamsTargetingNode, nextFrame } from './performanceUtils';
const { nodes: initialNodes, edges: initialEdges } = getNodesAndEdges(25, 25); const { nodes: initialNodes, edges: initialEdges } = getNodesAndEdges(25, 25);
@@ -25,6 +26,166 @@ const StressFlow = () => {
const onConnect = useCallback((connection: Connection) => { const onConnect = useCallback((connection: Connection) => {
setEdges((eds) => addEdge(connection, eds)); setEdges((eds) => addEdge(connection, eds));
}, []); }, []);
const dragInViewport = async () => {
// Note: selecting specifically node 18, as its normally located in the right part of the viewport
// which means dragging it left is safe to do without scrolling the viewport.
const nodeElement = document.querySelector('.react-flow__node[data-id="18"]');
if (!nodeElement) throw new Error('Node with id 18 not found');
const frameRecorder = new FrameRecorder();
// Hold down the mouse
frameRecorder.setStage('mousedown');
const mouseDownEvent = generateMouseEventParamsTargetingNode(nodeElement);
nodeElement.dispatchEvent(new MouseEvent('mousedown', mouseDownEvent));
await nextFrame();
// Start at the node position and move the mouse 5px to the left on every frame
frameRecorder.setStage('mousemove');
let currentXPosition = mouseDownEvent.clientX;
for (let iteration = 0; iteration < 20; ++iteration) {
const movementX = -5;
currentXPosition += movementX;
nodeElement.dispatchEvent(
new MouseEvent('mousemove', {
...mouseDownEvent,
clientX: currentXPosition,
screenX: currentXPosition,
movementX,
})
);
await nextFrame();
}
// Release the mouse
frameRecorder.setStage('mouseup');
nodeElement.dispatchEvent(
new MouseEvent('mouseup', {
...mouseDownEvent,
clientX: currentXPosition,
screenX: currentXPosition,
})
);
await nextFrame();
// Log the results
await frameRecorder.endRecordingAsync();
console.log('Frame durations:', frameRecorder.getFrames());
console.log(
'Frame durations for Observable (copy and paste to https://observablehq.com/@iamakulov/long-frame-visualizer):',
frameRecorder.getFramesForObservable()
);
};
const dragOutsideViewport = async () => {
const randomNodeIndex = Math.floor(Math.random() * nodes.length);
const nodeElement = document.querySelector(`.react-flow__node[data-id="${nodes[randomNodeIndex].id}"]`);
if (!nodeElement) throw new Error('Node not found');
const frameRecorder = new FrameRecorder();
// Hold down the mouse
frameRecorder.setStage('mousedown');
const mouseDownEvent = generateMouseEventParamsTargetingNode(nodeElement);
nodeElement.dispatchEvent(new MouseEvent('mousedown', mouseDownEvent));
await nextFrame();
// Move the mouse to the top of the viewport (so that the viewport starts
// scrolling up). Then, wiggle the mouse up and down to keep the viewport
// scrolling.
frameRecorder.setStage('mousemove');
let currentYPosition = 50;
for (let iteration = 0; iteration < 20; ++iteration) {
const movementY = Math.random() > 0.5 ? +2 : -2;
currentYPosition += movementY;
nodeElement.dispatchEvent(
new MouseEvent('mousemove', {
...mouseDownEvent,
clientY: currentYPosition,
screenY: currentYPosition,
movementY,
})
);
await nextFrame();
}
// Release the mouse
frameRecorder.setStage('mouseup');
nodeElement.dispatchEvent(
new MouseEvent('mouseup', {
...mouseDownEvent,
clientY: currentYPosition,
screenY: currentYPosition,
})
);
await nextFrame();
// Log the results
await frameRecorder.endRecordingAsync();
console.log('Frame durations:', frameRecorder.getFrames());
console.log(
'Frame durations for Observable (copy and paste to https://observablehq.com/@iamakulov/long-frame-visualizer):',
frameRecorder.getFramesForObservable()
);
};
const selectNode = async () => {
const randomNodeIndex = Math.floor(Math.random() * nodes.length);
const nodeElement = document.querySelector(`.react-flow__node[data-id="${nodes[randomNodeIndex].id}"]`);
if (!nodeElement) throw new Error('Node not found');
const frameRecorder = new FrameRecorder();
const mouseEvent = generateMouseEventParamsTargetingNode(nodeElement);
// mousedown
frameRecorder.setStage('mousedown');
nodeElement.dispatchEvent(new MouseEvent('mousedown', mouseEvent));
await nextFrame();
// click
frameRecorder.setStage('click');
nodeElement.dispatchEvent(new MouseEvent('click', mouseEvent));
await nextFrame();
// mouseup
frameRecorder.setStage('mouseup');
nodeElement.dispatchEvent(new MouseEvent('mouseup', mouseEvent));
await nextFrame();
// Log the results
await frameRecorder.endRecordingAsync();
console.log('Frame durations:', frameRecorder.getFrames());
console.log(
'Frame durations for Observable (copy and paste to https://observablehq.com/@iamakulov/long-frame-visualizer):',
frameRecorder.getFramesForObservable()
);
};
const [key, setKey] = useState(0);
const frameRecorderRef = useRef<FrameRecorder | null>(null);
function remount() {
frameRecorderRef.current = new FrameRecorder();
setKey((k) => k + 1);
}
useEffect(() => {
const frameRecorder = frameRecorderRef.current;
if (!frameRecorder) return;
frameRecorder.endRecordingAsync().then(() => {
console.log('Frame durations:', frameRecorder.getFrames());
console.log(
'Frame durations for Observable (copy and paste to https://observablehq.com/@iamakulov/long-frame-visualizer):',
frameRecorder.getFramesForObservable()
);
frameRecorderRef.current = null;
});
}, [key]);
const updatePos = () => { const updatePos = () => {
setNodes((nds) => { setNodes((nds) => {
return nds.map((n) => { return nds.map((n) => {
@@ -56,6 +217,7 @@ const StressFlow = () => {
return ( return (
<ReactFlow <ReactFlow
key={key}
nodes={nodes} nodes={nodes}
edges={edges} edges={edges}
onConnect={onConnect} onConnect={onConnect}
@@ -64,11 +226,14 @@ const StressFlow = () => {
minZoom={0.2} minZoom={0.2}
fitView fitView
> >
<MiniMap />
<Controls /> <Controls />
<Background /> <Background />
<Panel position="top-right"> <Panel position="top-right">
<button onClick={selectNode}>select node</button>
<button onClick={dragInViewport}>drag node within the viewport</button>
<button onClick={dragOutsideViewport}>drag node outside of the viewport</button>
<button onClick={remount}>re-mount</button>
<button onClick={updatePos}>change pos</button> <button onClick={updatePos}>change pos</button>
<button onClick={updateElements}>update elements</button> <button onClick={updateElements}>update elements</button>
</Panel> </Panel>
@@ -0,0 +1,150 @@
type Frame = {
duration: number;
stage: string;
};
/**
* Measures and outputs the duration of every frame that happens between the
* instance is created and `endRecording()` is called.
*
* Usage:
*
* ```ts
* const recorder = new FrameRecorder();
*
* // Do some performance-intensive stuff
*
* await recorder.endRecordingAsync();
*
* console.log(recorder.getFrames());
* console.log(recorder.getFramesForObservable()); // → paste into https://observablehq.com/@iamakulov/long-frame-visualizer
* ```
*/
export class FrameRecorder {
private frames: Frame[] = [];
private animationFrameId: number;
private stage: string = '<no stage>';
constructor() {
let lastFrameTimestamp = performance.now();
const measureFrame = () => {
const timestamp = performance.now();
// Visualize the frames in the Performance pane (see the collapsed
// “Timings” section) so its easier to see what exactly each frame
// captured
performance.measure(`frame (${this.stage})`, {
start: lastFrameTimestamp,
end: timestamp,
});
this.frames.push({
duration: timestamp - lastFrameTimestamp,
stage: this.stage,
});
lastFrameTimestamp = timestamp;
this.animationFrameId = requestAnimationFrame(measureFrame);
};
this.animationFrameId = requestAnimationFrame(measureFrame);
}
// The method is explicitly marked `async` in its name to make sure the caller
// doesnt forget to `await` it. (Otherwise, some events might be lost.)
async endRecordingAsync() {
this.setStage('waiting for idle');
await new Promise((resolve) => requestIdleCallback(resolve));
requestAnimationFrame(() => {
cancelAnimationFrame(this.animationFrameId);
});
}
/**
* Adds an optional annotation to all subsequent frames. Useful to
* differentiate frames from different events e.g. you can call
* `setState("mousedown")` before dispatching a mousedown event, and then
* `setState("mouseup")` before a mouseup one.
*
* When used, will affect both `getFramesForObservable()` and `getFrames()`.
*/
setStage(stage: string) {
this.stage = stage;
}
getFramesForObservable() {
return this.frames.map((frame, index) => ({ ...frame, index }));
}
getFrames() {
// Group frames by stage so you could see which frames originated from `mousedown` vs `mousemove` vs `mouseup` events
const framesPerStage: Record<string, number[]> = {};
for (const frame of this.frames) {
const stage = frame.stage;
if (!framesPerStage[stage]) {
framesPerStage[stage] = [];
}
framesPerStage[stage].push(frame.duration);
}
// If theres only one stage, return the frames directly
return framesPerStage;
}
}
/**
* Returns a promise that resolves when the next frame starts, and everything
* that was already scheduled in the event queue has been processed.
*/
export function nextFrame() {
return new Promise((resolve) => setTimeout(resolve, 0));
}
/**
* Generates params for a new MouseEvent() that will target the given node.
*/
export function generateMouseEventParamsTargetingNode(node: Element) {
const nodePosition = node.getBoundingClientRect();
// Lets make the event (eg click) happen 5px to the right and 5px to the
// bottom of the nodes top-left corner
const positionRelativeToNode = {
left: 5,
top: 5,
};
return {
clientX: Math.round(nodePosition.left + positionRelativeToNode.left),
clientY: Math.round(nodePosition.top + positionRelativeToNode.top),
movementX: 0,
movementY: 0,
offsetX: positionRelativeToNode.left,
offsetY: positionRelativeToNode.top,
screenX: Math.round(nodePosition.left + positionRelativeToNode.left),
screenY: Math.round(nodePosition.top + positionRelativeToNode.top),
// Required boilerplate
altKey: false,
bubbles: true,
button: 0,
buttons: 1,
cancelBubble: false,
cancelable: true,
composed: true,
ctrlKey: false,
currentTarget: null,
defaultPrevented: false,
detail: 1,
eventPhase: 0,
fromElement: null,
isTrusted: true,
metaKey: false,
relatedTarget: null,
returnValue: true,
shiftKey: false,
view: window,
which: 1,
};
}
@@ -11,13 +11,13 @@ const idStyle: CSSProperties = {
left: 2, left: 2,
}; };
const DebugNode: FC<NodeProps> = ({ zIndex, xPos, yPos, id }) => { const DebugNode: FC<NodeProps> = ({ zIndex, positionAbsoluteX, positionAbsoluteY, id }) => {
return ( return (
<> <>
<Handle type="target" position={Position.Top} /> <Handle type="target" position={Position.Top} />
<div style={idStyle}>{id}</div> <div style={idStyle}>{id}</div>
<div style={infoStyle}> <div style={infoStyle}>
x:{Math.round(xPos || 0)} y:{Math.round(yPos || 0)} z:{zIndex} x:{Math.round(positionAbsoluteX)} y:{Math.round(positionAbsoluteY)} z:{zIndex}
</div> </div>
<Handle type="source" position={Position.Bottom} /> <Handle type="source" position={Position.Bottom} />
</> </>
@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { ReactFlow, Node, Edge, useNodesState, useEdgesState } from '@xyflow/react'; import { ReactFlow, Node, Edge, useNodesState, useEdgesState, ReactFlowProvider, useReactFlow } from '@xyflow/react';
import styles from './updatenode.module.css'; import styles from './updatenode.module.css';
@@ -13,6 +13,7 @@ const initialEdges: Edge[] = [{ id: 'e1-2', source: '1', target: '2' }];
const UpdateNode = () => { const UpdateNode = () => {
const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes); const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes);
const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges); const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges);
const { updateNode } = useReactFlow();
const [nodeName, setNodeName] = useState<string>('Node 1'); const [nodeName, setNodeName] = useState<string>('Node 1');
const [nodeBg, setNodeBg] = useState<string>('#eee'); const [nodeBg, setNodeBg] = useState<string>('#eee');
@@ -23,9 +24,12 @@ const UpdateNode = () => {
nds.map((n) => { nds.map((n) => {
if (n.id === '1') { if (n.id === '1') {
// it's important that you create a new object here in order to notify react flow about the change // it's important that you create a new object here in order to notify react flow about the change
n.data = { return {
...n.data, ...n,
label: nodeName, data: {
...n.data,
label: nodeName,
},
}; };
} }
@@ -39,7 +43,10 @@ const UpdateNode = () => {
nds.map((n) => { nds.map((n) => {
if (n.id === '1') { if (n.id === '1') {
// it's important that you create a new object here in order to notify react flow about the change // it's important that you create a new object here in order to notify react flow about the change
n.style = { ...n.style, backgroundColor: nodeBg }; return {
...n,
style: { ...n.style, backgroundColor: nodeBg },
};
} }
return n; return n;
@@ -51,8 +58,10 @@ const UpdateNode = () => {
setNodes((nds) => setNodes((nds) =>
nds.map((n) => { nds.map((n) => {
if (n.id === '1' || n.id === 'e1-2') { if (n.id === '1' || n.id === 'e1-2') {
// when you update a simple type you can just update the value return {
n.hidden = nodeHidden; ...n,
hidden: nodeHidden,
};
} }
return n; return n;
@@ -80,9 +89,19 @@ const UpdateNode = () => {
<label>hidden:</label> <label>hidden:</label>
<input type="checkbox" checked={nodeHidden} onChange={(evt) => setNodeHidden(evt.target.checked)} /> <input type="checkbox" checked={nodeHidden} onChange={(evt) => setNodeHidden(evt.target.checked)} />
</div> </div>
<button
onClick={() => updateNode('1', (node) => ({ position: { x: node.position.x + 10, y: node.position.y } }))}
>
update position
</button>
</div> </div>
</ReactFlow> </ReactFlow>
); );
}; };
export default UpdateNode; export default () => (
<ReactFlowProvider>
<UpdateNode />
</ReactFlowProvider>
);
@@ -0,0 +1,39 @@
import { memo, FC, useEffect, useCallback } from 'react';
import { Handle, Position, NodeProps, useHandleConnections, Connection, HandleComponentProps } from '@xyflow/react';
function CustomHandle({ nodeId, ...handleProps }: HandleComponentProps & { nodeId: string }) {
const onConnect = useCallback(
(connections: Connection[]) => console.log('onConnect handler, node id:', nodeId, connections),
[nodeId]
);
const onDisconnect = useCallback(
(connections: Connection[]) => console.log('onDisconnect handler, node id:', nodeId, connections),
[nodeId]
);
const connections = useHandleConnections({
type: handleProps.type,
id: handleProps.id,
onConnect,
onDisconnect,
});
useEffect(() => {
console.log('useEffect, node id:', nodeId, handleProps.type, connections);
}, [connections]);
return <Handle {...handleProps} />;
}
const CustomNode: FC<NodeProps> = ({ id }) => {
return (
<div style={{ background: '#333', color: '#fff', padding: 10, fontSize: 12, borderRadius: 10 }}>
<CustomHandle nodeId={id} type="target" position={Position.Left} />
<div>node {id}</div>
<CustomHandle nodeId={id} type="source" position={Position.Right} id="a" style={{ top: 10 }} />
<CustomHandle nodeId={id} type="source" position={Position.Right} id="b" style={{ top: 20 }} />
</div>
);
};
export default memo(CustomNode);
@@ -0,0 +1,41 @@
import { memo, FC, useEffect, useCallback } from 'react';
import { Handle, Position, NodeProps, useHandleConnections, Connection, HandleComponentProps } from '@xyflow/react';
function CustomHandle({ nodeId, ...handleProps }: HandleComponentProps & { nodeId: string }) {
const onConnect = useCallback(
(connections: Connection[]) => {
console.log('onConnect handler, node id:', nodeId, connections);
},
[nodeId]
);
const onDisconnect = useCallback(
(connections: Connection[]) => {
console.log('onDisconnect handler, node id:', nodeId, connections);
},
[nodeId]
);
const connections = useHandleConnections({
type: handleProps.type,
id: handleProps.id,
onConnect,
onDisconnect,
});
useEffect(() => {
console.log('useEffect, node id:', nodeId, handleProps.type, connections);
}, [connections]);
return <Handle {...handleProps} />;
}
const CustomNode: FC<NodeProps> = ({ id }) => {
return (
<div style={{ background: '#333', color: '#fff', padding: 10, fontSize: 12, borderRadius: 10 }}>
<CustomHandle nodeId={id} type="target" position={Position.Left} />
<div>node {id}</div>
<CustomHandle nodeId={id} type="source" position={Position.Right} />
</div>
);
};
export default memo(CustomNode);
@@ -0,0 +1,118 @@
import { useCallback } from 'react';
import {
ReactFlow,
MiniMap,
Controls,
addEdge,
Connection,
useNodesState,
useEdgesState,
Background,
} from '@xyflow/react';
import MultiHandleNode from './MultiHandleNode';
import SingleHandleNode from './SingleHandleNode';
const nodeTypes = {
multi: MultiHandleNode,
single: SingleHandleNode,
};
const initNodes = [
{
id: '1',
type: 'single',
data: {},
position: { x: 0, y: 0 },
},
{
id: '2',
type: 'single',
data: {},
position: { x: 200, y: -100 },
},
{
id: '3',
type: 'single',
data: {},
position: { x: 200, y: 100 },
},
{
id: '4',
type: 'multi',
data: {},
position: { x: 400, y: 0 },
},
{
id: '5',
type: 'multi',
data: {},
position: { x: 600, y: -100 },
},
{
id: '6',
type: 'multi',
data: {},
position: { x: 600, y: 100 },
},
];
const initEdges = [
{
id: 'e1-2',
source: '1',
target: '2',
},
{
id: 'e1-3',
source: '1',
target: '3',
},
{
id: 'e4a-5',
source: '4',
sourceHandle: 'a',
target: '5',
},
{
id: 'e4b-5',
source: '4',
sourceHandle: 'b',
target: '6',
},
];
const defaultEdgeOptions = {
animated: true,
};
const CustomNodeFlow = () => {
const [nodes, setNodes, onNodesChange] = useNodesState(initNodes);
const [edges, setEdges, onEdgesChange] = useEdgesState(initEdges);
const onConnect = useCallback((connection: Connection) => setEdges((eds) => addEdge(connection, eds)), [setEdges]);
return (
<ReactFlow
nodes={nodes}
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onConnect={onConnect}
nodeTypes={nodeTypes}
fitView
minZoom={0.3}
maxZoom={2}
colorMode="dark"
defaultEdgeOptions={defaultEdgeOptions}
>
<MiniMap />
<Controls />
<Background />
</ReactFlow>
);
};
export default CustomNodeFlow;
@@ -0,0 +1,22 @@
import { memo } from 'react';
import { Handle, Position, useHandleConnections, useNodesData } from '@xyflow/react';
function ResultNode() {
const connections = useHandleConnections({
type: 'target',
});
const nodesData = useNodesData(connections.map((connection) => connection.source));
return (
<div style={{ background: '#eee', color: '#222', padding: 10, fontSize: 12, borderRadius: 10 }}>
<Handle type="target" position={Position.Left} />
<div>
incoming texts:{' '}
{nodesData?.filter((nodeData) => nodeData.text !== undefined).map(({ text }, i) => <div key={i}>{text}</div>) ||
'none'}
</div>
</div>
);
}
export default memo(ResultNode);
@@ -0,0 +1,20 @@
import { memo, ChangeEventHandler } from 'react';
import { Position, NodeProps, Handle, useReactFlow } from '@xyflow/react';
function TextNode({ id, data }: NodeProps) {
const { updateNodeData } = useReactFlow();
const onChange: ChangeEventHandler<HTMLInputElement> = (evt) => updateNodeData(id, { text: evt.target.value });
return (
<div style={{ background: '#eee', color: '#222', padding: 10, fontSize: 12, borderRadius: 10 }}>
<div>node {id}</div>
<div>
<input onChange={onChange} value={data.text} />
</div>
<Handle type="source" position={Position.Right} />
</div>
);
}
export default memo(TextNode);
@@ -0,0 +1,24 @@
import { memo, useEffect } from 'react';
import { Position, NodeProps, useReactFlow, Handle, useHandleConnections, useNodesData } from '@xyflow/react';
function UppercaseNode({ id }: NodeProps) {
const { updateNodeData } = useReactFlow();
const connections = useHandleConnections({
type: 'target',
});
const nodeData = useNodesData(connections[0]?.source);
useEffect(() => {
updateNodeData(id, { text: nodeData?.text.toUpperCase() });
}, [nodeData]);
return (
<div style={{ background: '#eee', color: '#222', padding: 10, fontSize: 12, borderRadius: 10 }}>
<Handle type="target" position={Position.Left} isConnectable={connections.length === 0} />
<div>uppercase transform</div>
<Handle type="source" position={Position.Right} />
</div>
);
}
export default memo(UppercaseNode);
@@ -0,0 +1,102 @@
import { useCallback } from 'react';
import {
ReactFlow,
Controls,
addEdge,
Connection,
useNodesState,
useEdgesState,
Background,
Node,
Edge,
} from '@xyflow/react';
import TextNode from './TextNode';
import ResultNode from './ResultNode';
import UppercaseNode from './UppercaseNode';
export type TextNode = Node<{ text: string }, 'text'>;
export type ResultNode = Node<{}, 'result'>;
export type UppercaseNode = Node<{}, 'uppercase'>;
export type MyNode = Node<{ text: string }, 'text'> | Node<{}, 'result'> | Node<{}, 'uppercase'>;
const nodeTypes = {
text: TextNode,
result: ResultNode,
uppercase: UppercaseNode,
};
const initNodes: MyNode[] = [
{
id: '1',
type: 'text',
data: {
text: 'hello',
},
position: { x: -100, y: -50 },
},
{
id: '1a',
type: 'uppercase',
data: {},
position: { x: 100, y: 0 },
},
{
id: '2',
type: 'text',
data: {
text: 'world',
},
position: { x: 0, y: 100 },
},
{
id: '3',
type: 'result',
data: {},
position: { x: 300, y: 50 },
},
];
const initEdges: Edge[] = [
{
id: 'e1-1a',
source: '1',
target: '1a',
},
{
id: 'e1a-3',
source: '1a',
target: '3',
},
{
id: 'e2-3',
source: '2',
target: '3',
},
];
const CustomNodeFlow = () => {
const [nodes, setNodes, onNodesChange] = useNodesState(initNodes);
const [edges, setEdges, onEdgesChange] = useEdgesState(initEdges);
const onConnect = useCallback((connection: Connection) => setEdges((eds) => addEdge(connection, eds)), [setEdges]);
return (
<ReactFlow
nodes={nodes}
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onConnect={onConnect}
nodeTypes={nodeTypes}
fitView
>
<Controls />
<Background />
</ReactFlow>
);
};
export default CustomNodeFlow;
@@ -0,0 +1,19 @@
import { memo, FC } from 'react';
import { Handle, Position, NodeProps, NodeToolbar } from '@xyflow/react';
const CustomNode: FC<NodeProps> = ({ id, data }) => {
return (
<>
<NodeToolbar isVisible={data.toolbarVisible} position={data.toolbarPosition} align={data.toolbarAlign}>
<button>delete</button>
<button>copy</button>
<button>expand</button>
</NodeToolbar>
<div>{data.label}</div>
<Handle type="target" position={Position.Left} />
<Handle type="source" position={Position.Right} />
</>
);
};
export default memo(CustomNode);
@@ -0,0 +1,50 @@
import { Position, type Node } from '@xyflow/react';
import ToolbarNode from './components/ToolbarNode';
const positions = ['top', 'right', 'bottom', 'left'];
const alignments = ['start', 'center', 'end'];
const nodes: Node[] = [
{
id: 'default-node',
type: 'ToolbarNode',
data: { label: 'toolbar top', toolbarPosition: Position.Top },
position: { x: 0, y: -200 },
className: 'react-flow__node-default',
},
];
positions.forEach((position, posIndex) => {
alignments.forEach((align, alignIndex) => {
const id = `node-${align}-${position}`;
nodes.push({
id,
type: 'ToolbarNode',
data: {
label: `toolbar ${position} ${align}`,
toolbarPosition: position as Position,
toolbarAlign: align,
toolbarVisible: true,
},
className: 'react-flow__node-default',
position: { x: posIndex * 300, y: alignIndex * 100 },
});
});
});
export default {
flowProps: {
fitView: true,
nodeTypes: {
ToolbarNode,
},
nodes,
edges: [
{
id: 'first-edge',
source: 'default-node',
target: 'node-start-top',
},
],
},
} satisfies FlowConfig;
+1 -1
View File
@@ -1,7 +1,7 @@
import { StrictMode } from 'react'; import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client'; import { createRoot } from 'react-dom/client';
import App from './App'; import App from './App/index';
import '@xyflow/react/dist/style.css'; import '@xyflow/react/dist/style.css';
import './index.css'; import './index.css';
@@ -4,18 +4,22 @@
const routes = [ const routes = [
'add-node-on-drop', 'add-node-on-drop',
'color-mode',
'custom-connection-line', 'custom-connection-line',
'customnode', 'customnode',
'dagre', 'dagre',
'drag-n-drop', 'drag-n-drop',
'edges', 'edges',
'figma', 'figma',
'handle-connect',
'interaction', 'interaction',
'intersections', 'intersections',
'node-toolbar',
'overview', 'overview',
'stress', 'stress',
'subflows', 'subflows',
'two-way-viewport', 'two-way-viewport',
'usenodesdata',
'usesvelteflow', 'usesvelteflow',
'useupdatenodeinternals', 'useupdatenodeinternals',
'validation' 'validation'
@@ -0,0 +1,30 @@
<script lang="ts">
import { NodeToolbar, type NodeProps, Handle, Position } from '@xyflow/svelte';
type $$Props = NodeProps;
export let data: $$Props['data'];
</script>
<NodeToolbar
isVisible={data.toolbarVisible}
position={data.toolbarPosition}
align={data.toolbarAlign}
>
<button>delete</button>
<button>copy</button>
<button>expand</button>
</NodeToolbar>
<div class="node">
<div>{data.label}</div>
<Handle type="target" position={Position.Left} />
<Handle type="source" position={Position.Right} />
</div>
<style>
.node {
width: 200px;
height: 50px;
border: solid 1px black;
}
</style>
@@ -0,0 +1,50 @@
import { Position, type Node } from '@xyflow/svelte';
import ToolbarNode from './components/ToolbarNode.svelte';
const positions = ['top', 'right', 'bottom', 'left'];
const alignments = ['start', 'center', 'end'];
const nodes: Node[] = [
{
id: 'default-node',
type: 'ToolbarNode',
data: { label: 'toolbar top', toolbarPosition: Position.Top },
position: { x: 0, y: -200 },
class: 'react-flow__node-default'
}
];
positions.forEach((position, posIndex) => {
alignments.forEach((align, alignIndex) => {
const id = `node-${align}-${position}`;
nodes.push({
id,
type: 'ToolbarNode',
data: {
label: `toolbar ${position} ${align}`,
toolbarPosition: position as Position,
toolbarAlign: align,
toolbarVisible: true
},
class: 'react-flow__node-default',
position: { x: posIndex * 300, y: alignIndex * 100 }
});
});
});
export default {
flowProps: {
fitView: true,
nodeTypes: {
ToolbarNode
},
nodes,
edges: [
{
id: 'first-edge',
source: 'default-node',
target: 'node-start-top'
}
]
}
} satisfies FlowConfig;
@@ -1,6 +1,12 @@
<script lang="ts"> <script lang="ts">
import { writable } from 'svelte/store'; import { writable } from 'svelte/store';
import { SvelteFlow, useSvelteFlow, type Edge, type Node } from '@xyflow/svelte'; import {
SvelteFlow,
useSvelteFlow,
type Edge,
type Node,
type OnConnectEnd
} from '@xyflow/svelte';
import '@xyflow/svelte/dist/style.css'; import '@xyflow/svelte/dist/style.css';
@@ -16,16 +22,18 @@
const nodes = writable<Node[]>(initialNodes); const nodes = writable<Node[]>(initialNodes);
const edges = writable<Edge[]>([]); const edges = writable<Edge[]>([]);
let connectingNodeId: string = '0'; let connectingNodeId: string | null = '0';
let rect: DOMRectReadOnly; let rect: DOMRectReadOnly;
let id = 1; let id = 1;
const getId = () => `${id++}`; const getId = () => `${id++}`;
const { screenToFlowPosition, flowToScreenPosition } = useSvelteFlow(); const { screenToFlowPosition, flowToScreenPosition } = useSvelteFlow();
function handleConnectEnd({ detail: { event } }: { detail: { event: MouseEvent | TouchEvent } }) { const handleConnectEnd: OnConnectEnd = (event) => {
if (!connectingNodeId) return;
// See of connection landed inside the flow pane // See of connection landed inside the flow pane
const targetIsPane = event.target?.classList.contains('svelte-flow__pane'); const targetIsPane = (event.target as HTMLDivElement)?.classList.contains('svelte-flow__pane');
if (targetIsPane) { if (targetIsPane) {
const id = getId(); const id = getId();
const position = { const position = {
@@ -58,7 +66,7 @@
$nodes = $nodes; $nodes = $nodes;
$edges = $edges; $edges = $edges;
} }
} };
</script> </script>
<svelte:window /> <svelte:window />
@@ -69,11 +77,11 @@
{edges} {edges}
fitView fitView
fitViewOptions={{ padding: 2 }} fitViewOptions={{ padding: 2 }}
on:connectstart={({ detail: { nodeId } }) => { onconnectstart={(_, { nodeId }) => {
// Memorize the nodeId you start draggin a connection line from a node // Memorize the nodeId you start draggin a connection line from a node
connectingNodeId = nodeId; connectingNodeId = nodeId;
}} }}
on:connectend={handleConnectEnd} onconnectend={handleConnectEnd}
/> />
</div> </div>
@@ -0,0 +1,54 @@
<script lang="ts">
import { writable } from 'svelte/store';
import {
SvelteFlow,
Controls,
Background,
BackgroundVariant,
Position,
MiniMap,
Panel,
type ColorMode
} from '@xyflow/svelte';
import '@xyflow/svelte/dist/style.css';
const nodeDefaults = {
sourcePosition: Position.Right,
targetPosition: Position.Left
};
const nodes = writable([
{
id: 'A',
position: { x: 0, y: 150 },
data: { label: 'A' },
...nodeDefaults
},
{ id: 'B', position: { x: 250, y: 0 }, data: { label: 'B' }, ...nodeDefaults },
{ id: 'C', position: { x: 250, y: 150 }, data: { label: 'C' }, ...nodeDefaults },
{ id: 'D', position: { x: 250, y: 300 }, data: { label: 'D' }, ...nodeDefaults }
]);
const edges = writable([
{ id: 'A-B', source: 'A', target: 'B' },
{ id: 'A-C', source: 'A', target: 'C' },
{ id: 'A-D', source: 'A', target: 'D' }
]);
let colorMode: ColorMode = 'light';
</script>
<SvelteFlow {nodes} {edges} {colorMode} fitView>
<Controls />
<Background variant={BackgroundVariant.Dots} />
<MiniMap />
<Panel>
<select bind:value={colorMode} data-testid="colormode-select">
<option value="light">light</option>
<option value="dark">dark</option>
<option value="system">system</option>
</select>
</Panel>
</SvelteFlow>
@@ -85,9 +85,9 @@
{nodes} {nodes}
{edges} {edges}
{nodeTypes} {nodeTypes}
style="--background-color: {$bgColor}" style="--xy-background-color: {$bgColor}"
fitView fitView
on:connect={onConnect} onconnect={onConnect}
> >
<Controls /> <Controls />
<Background variant={BackgroundVariant.Dots} /> <Background variant={BackgroundVariant.Dots} />
@@ -10,7 +10,7 @@
</script> </script>
<div class="custom"> <div class="custom">
<Handle type="target" position={Position.Left} on:connect /> <Handle type="target" position={Position.Left} />
<div> <div>
Custom Color Picker Node: <strong>{$colorStore}</strong> Custom Color Picker Node: <strong>{$colorStore}</strong>
</div> </div>
@@ -20,14 +20,8 @@
on:input={(evt) => colorStore.set(evt.currentTarget.value)} on:input={(evt) => colorStore.set(evt.currentTarget.value)}
value={$colorStore} value={$colorStore}
/> />
<Handle type="source" position={Position.Right} id="a" style="top: 20px;" on:connect /> <Handle type="source" position={Position.Right} id="a" style="top: 20px;" />
<Handle <Handle type="source" position={Position.Right} id="b" style="top: auto; bottom: 10px;" />
type="source"
position={Position.Right}
id="b"
style="top: auto; bottom: 10px;"
on:connect
/>
</div> </div>
<style> <style>
@@ -6,10 +6,13 @@
Background, Background,
BackgroundVariant, BackgroundVariant,
MiniMap, MiniMap,
MarkerType MarkerType,
type Connection
} from '@xyflow/svelte'; } from '@xyflow/svelte';
import '@xyflow/svelte/dist/style.css'; import '@xyflow/svelte/dist/style.css';
import ButtonEdge from './ButtonEdge.svelte';
import CustomBezierEdge from './CustomBezierEdge.svelte';
const nodes = writable([ const nodes = writable([
{ {
@@ -110,13 +113,14 @@
id: 'e5-8', id: 'e5-8',
source: '5', source: '5',
target: '8', target: '8',
data: { text: 'custom edge' } type: 'button'
}, },
{ {
id: 'e5-9', id: 'e5-9',
source: '5', source: '5',
target: '9', target: '9',
data: { text: 'custom edge 2' } type: 'customBezier',
label: 'custom bezier'
}, },
{ {
id: 'e5-6', id: 'e5-6',
@@ -143,9 +147,34 @@
} }
} }
]); ]);
const edgeTypes = {
button: ButtonEdge,
customBezier: CustomBezierEdge
};
function getEdgeId(connection: Connection) {
return `edge-${connection.source}-${connection.target}}`;
}
$: console.log('edges', $edges);
</script> </script>
<SvelteFlow {nodes} {edges} fitView nodeDragThreshold={2} ondelete={console.log}> <SvelteFlow
{nodes}
{edges}
{edgeTypes}
fitView
nodeDragThreshold={2}
onedgecreate={(connection) => {
console.log('on edge create', connection);
return {
...connection,
id: getEdgeId(connection)
};
}}
>
<Controls /> <Controls />
<Background variant={BackgroundVariant.Dots} /> <Background variant={BackgroundVariant.Dots} />
<MiniMap /> <MiniMap />
@@ -0,0 +1,92 @@
<script lang="ts">
import { getBezierPath, BaseEdge, type EdgeProps, EdgeLabelRenderer } from '@xyflow/svelte';
type $$Props = EdgeProps;
export let id: $$Props['id'] = '';
export let source: $$Props['source'] = '';
export let target: $$Props['target'] = '';
export let animated: $$Props['animated'] = undefined;
export let selected: $$Props['selected'] = undefined;
export let label: $$Props['label'] = undefined;
export let labelStyle: $$Props['labelStyle'] = undefined;
export let data: $$Props['data'] = undefined;
export let style: $$Props['style'] = undefined;
export let markerStart: $$Props['markerStart'] = undefined;
export let markerEnd: $$Props['markerEnd'] = undefined;
export let interactionWidth: $$Props['interactionWidth'] = undefined;
export let sourceX: $$Props['sourceX'];
export let sourceY: $$Props['sourceY'];
export let sourcePosition: $$Props['sourcePosition'];
export let sourceHandleId: $$Props['sourceHandleId'] = undefined;
export let targetX: $$Props['targetX'];
export let targetY: $$Props['targetY'];
export let targetPosition: $$Props['targetPosition'];
export let targetHandleId: $$Props['targetHandleId'] = undefined;
$: [edgePath, labelX, labelY] = getBezierPath({
sourceX,
sourceY,
sourcePosition,
targetX,
targetY,
targetPosition
});
source;
target;
animated;
selected;
data;
label;
labelStyle;
markerStart;
interactionWidth;
sourceHandleId;
targetHandleId;
</script>
<BaseEdge path={edgePath} {markerEnd} {style} />
<EdgeLabelRenderer>
<div
class="edgeButtonContainer nodrag nopan"
style:transform="translate(-50%, -50%) translate({labelX}px,{labelY}px)"
>
<button
class="edgeButton"
on:click={(event) => {
event.stopPropagation();
alert(`remove ${id}`);
}}
>
×
</button>
</div>
</EdgeLabelRenderer>
<style>
.edgeButtonContainer {
position: absolute;
font-size: 12pt;
/* everything inside EdgeLabelRenderer has no pointer events by default */
/* if you have an interactive element, set pointer-events: all */
pointer-events: all;
}
.edgeButton {
width: 20px;
height: 20px;
background: #eee;
border: 1px solid #fff;
cursor: pointer;
border-radius: 50%;
font-size: 12px;
line-height: 1;
}
.edgeButton:hover {
box-shadow: 0 0 6px 2px rgba(0, 0, 0, 0.08);
}
</style>
@@ -0,0 +1,55 @@
<script lang="ts">
import { BezierEdge, type EdgeProps } from '@xyflow/svelte';
type $$Props = EdgeProps;
export let id: $$Props['id'] = '';
export let source: $$Props['source'] = '';
export let target: $$Props['target'] = '';
export let animated: $$Props['animated'] = undefined;
export let selected: $$Props['selected'] = undefined;
export let label: $$Props['label'] = undefined;
export let labelStyle: $$Props['labelStyle'] = undefined;
export let data: $$Props['data'] = undefined;
export let style: $$Props['style'] = undefined;
export let markerStart: $$Props['markerStart'] = undefined;
export let markerEnd: $$Props['markerEnd'] = undefined;
export let interactionWidth: $$Props['interactionWidth'] = undefined;
export let sourceX: $$Props['sourceX'];
export let sourceY: $$Props['sourceY'];
export let sourcePosition: $$Props['sourcePosition'];
export let sourceHandleId: $$Props['sourceHandleId'] = undefined;
export let targetX: $$Props['targetX'];
export let targetY: $$Props['targetY'];
export let targetPosition: $$Props['targetPosition'];
export let targetHandleId: $$Props['targetHandleId'] = undefined;
id;
source;
target;
animated;
selected;
data;
sourcePosition;
targetPosition;
sourceHandleId;
targetHandleId;
</script>
<BezierEdge
{sourceX}
{sourceY}
{sourcePosition}
{targetX}
{targetY}
{targetPosition}
{label}
{labelStyle}
{markerStart}
{markerEnd}
{style}
{interactionWidth}
pathOptions={{ curvature: 1 }}
/>
@@ -0,0 +1,94 @@
<script lang="ts">
import { writable } from 'svelte/store';
import {
SvelteFlow,
Controls,
Background,
BackgroundVariant,
MiniMap,
type Node,
type NodeTypes,
type Edge
} from '@xyflow/svelte';
import SingleHandleNode from './SingleHandleNode.svelte';
import MultiHandleNode from './MultiHandleNode.svelte';
import '@xyflow/svelte/dist/style.css';
const nodeTypes: NodeTypes = {
single: SingleHandleNode,
multi: MultiHandleNode
};
const nodes = writable<Node[]>([
{
id: '1',
type: 'single',
data: {},
position: { x: 0, y: 0 }
},
{
id: '2',
type: 'single',
data: {},
position: { x: 200, y: -100 }
},
{
id: '3',
type: 'single',
data: {},
position: { x: 200, y: 100 }
},
{
id: '4',
type: 'multi',
data: {},
position: { x: 400, y: 0 }
},
{
id: '5',
type: 'multi',
data: {},
position: { x: 600, y: -100 }
},
{
id: '6',
type: 'multi',
data: {},
position: { x: 600, y: 100 }
}
]);
const edges = writable<Edge[]>([
{
id: 'e1-2',
source: '1',
target: '2'
},
{
id: 'e1-3',
source: '1',
target: '3'
},
{
id: 'e4a-5',
source: '4',
sourceHandle: 'a',
target: '5'
},
{
id: 'e4b-5',
source: '4',
sourceHandle: 'b',
target: '6'
}
]);
</script>
<SvelteFlow {nodes} {edges} {nodeTypes} fitView colorMode="dark">
<Controls />
<Background variant={BackgroundVariant.Dots} />
<MiniMap />
</SvelteFlow>
@@ -0,0 +1,110 @@
<script lang="ts">
import {
Handle,
Position,
type NodeProps,
type Connection,
useHandleConnections
} from '@xyflow/svelte';
type $$Props = NodeProps;
export let id: $$Props['id'];
function onConnectTarget(connection: Connection[]) {
console.log('connect target', connection);
}
function onConnectSource(handleId: string, connection: Connection[]) {
console.log('connect source', handleId, connection);
}
function onDisconnectTarget(connection: Connection[]) {
console.log('disconnect target', connection);
}
function onDisconnectSource(handleId: string, connection: Connection[]) {
console.log('disconnect source', handleId, connection);
}
const connections = useHandleConnections({ nodeId: id, type: 'target' });
$: {
console.log('connections', id, $connections);
}
export let data: $$Props['data'];
export let targetPosition: $$Props['targetPosition'] = Position.Top;
export let sourcePosition: $$Props['sourcePosition'] = Position.Bottom;
export let width: $$Props['width'] = undefined;
export let height: $$Props['height'] = undefined;
export let selected: $$Props['selected'] = undefined;
export let type: $$Props['type'] = undefined;
export let zIndex: $$Props['zIndex'] = undefined;
export let dragging: $$Props['dragging'] = false;
export let dragHandle: $$Props['dragHandle'] = undefined;
export let positionAbsolute: $$Props['positionAbsolute'] = {
x: 0,
y: 0
};
export let isConnectable: $$Props['isConnectable'] = undefined;
data;
targetPosition;
sourcePosition;
width;
height;
selected;
type;
zIndex;
dragging;
dragHandle;
positionAbsolute;
isConnectable;
</script>
<div class="custom">
<Handle
type="target"
position={Position.Left}
onconnect={onConnectTarget}
ondisconnect={onDisconnectTarget}
/>
<div>node {id}</div>
<Handle
id="a"
type="source"
position={Position.Right}
onconnect={(connections) => onConnectSource('a', connections)}
ondisconnect={(connections) => onDisconnectSource('a', connections)}
class="source-a"
/>
<Handle
id="b"
type="source"
position={Position.Right}
onconnect={(connections) => onConnectSource('b', connections)}
ondisconnect={(connections) => onDisconnectSource('b', connections)}
class="source-b"
/>
</div>
<style>
.custom {
background-color: #333;
padding: 10px;
border-radius: 10px;
color: #fff;
}
.custom :global(.source-a) {
top: 5px;
transform: translate(50%, 0);
}
.custom :global(.source-b) {
bottom: 5px;
top: auto;
transform: translate(50%, 0);
}
</style>
@@ -0,0 +1,77 @@
<script lang="ts">
import { Handle, Position, type NodeProps, type Connection } from '@xyflow/svelte';
type $$Props = NodeProps;
export let id: $$Props['id'];
function onConnectTarget(connection: Connection[]) {
console.log('connect target', connection);
}
function onConnectSource(connection: Connection[]) {
console.log('connect source', connection);
}
function onDisconnectTarget(connection: Connection[]) {
console.log('disconnect target', connection);
}
function onDisconnectSource(connection: Connection[]) {
console.log('disconnect source', connection);
}
export let data: $$Props['data'];
export let targetPosition: $$Props['targetPosition'] = Position.Top;
export let sourcePosition: $$Props['sourcePosition'] = Position.Bottom;
export let width: $$Props['width'] = undefined;
export let height: $$Props['height'] = undefined;
export let selected: $$Props['selected'] = undefined;
export let type: $$Props['type'] = undefined;
export let zIndex: $$Props['zIndex'] = undefined;
export let dragging: $$Props['dragging'] = false;
export let dragHandle: $$Props['dragHandle'] = undefined;
export let positionAbsolute: $$Props['positionAbsolute'] = {
x: 0,
y: 0
};
export let isConnectable: $$Props['isConnectable'] = undefined;
data;
targetPosition;
sourcePosition;
width;
height;
selected;
type;
zIndex;
dragging;
dragHandle;
positionAbsolute;
isConnectable;
</script>
<div class="custom">
<Handle
type="target"
position={Position.Left}
onconnect={onConnectTarget}
ondisconnect={onDisconnectTarget}
/>
<div>node {id}</div>
<Handle
type="source"
position={Position.Right}
onconnect={onConnectSource}
ondisconnect={onDisconnectSource}
/>
</div>
<style>
.custom {
background-color: #333;
padding: 10px;
border-radius: 10px;
color: #fff;
}
</style>
@@ -0,0 +1,63 @@
<script lang="ts">
import { writable } from 'svelte/store';
import {
SvelteFlow,
Background,
type Edge,
type Node,
Position,
type NodeTypes
} from '@xyflow/svelte';
import '@xyflow/svelte/dist/style.css';
import CustomNode from './CustomNode.svelte';
import SelectedNodesToolbar from './SelectedNodesToolbar.svelte';
const nodeTypes: NodeTypes = {
custom: CustomNode
};
const positions = ['top', 'right', 'bottom', 'left'];
const alignments = ['start', 'center', 'end'];
const initialNodes: Node[] = [
{
id: 'default-node',
type: 'custom',
data: { label: 'toolbar top', toolbarPosition: Position.Top },
position: { x: 0, y: -200 },
class: 'react-flow__node-default'
}
];
positions.forEach((position, posIndex) => {
alignments.forEach((align, alignIndex) => {
const id = `node-${align}-${position}`;
initialNodes.push({
id,
type: 'custom',
data: {
label: `toolbar ${position} ${align}`,
toolbarPosition: position as Position,
toolbarAlign: align,
toolbarVisible: true
},
class: 'react-flow__node-default',
position: { x: posIndex * 300, y: alignIndex * 100 }
});
});
});
const initialEdges: Edge[] = [];
const nodes = writable<Node[]>(initialNodes);
const edges = writable<Edge[]>(initialEdges);
</script>
<div style="height: 100vh;">
<SvelteFlow {nodes} {edges} {nodeTypes} fitView>
<Background />
<SelectedNodesToolbar />
</SvelteFlow>
</div>
@@ -0,0 +1,31 @@
<script lang="ts">
import { NodeToolbar, type NodeProps, Handle, Position } from '@xyflow/svelte';
type $$Props = NodeProps;
export let data: $$Props['data'];
</script>
<NodeToolbar
isVisible={data.toolbarVisible}
position={data.toolbarPosition}
align={data.toolbarAlign}
>
<button>delete</button>
<button>copy</button>
<button>expand</button>
</NodeToolbar>
<div class="node">
<div>{data.label}</div>
<Handle type="target" position={Position.Left} />
<Handle type="source" position={Position.Right} />
</div>
<style>
.node {
width: 180px;
height: 50px;
border: solid 1px black;
background: white;
}
</style>
@@ -0,0 +1,12 @@
<script lang="ts">
import { NodeToolbar, useNodes } from '@xyflow/svelte';
const nodes = useNodes();
$: selectedNodeIds = $nodes.filter((node) => node.selected).map((node) => node.id);
$: isVisible = selectedNodeIds.length > 1;
</script>
<NodeToolbar nodeId={selectedNodeIds} {isVisible}>
<button>Selection action</button>
</NodeToolbar>
@@ -12,7 +12,8 @@
type EdgeTypes, type EdgeTypes,
type Node, type Node,
type Edge, type Edge,
ConnectionMode ConnectionMode,
useSvelteFlow
} from '@xyflow/svelte'; } from '@xyflow/svelte';
import CustomNode from './CustomNode.svelte'; import CustomNode from './CustomNode.svelte';
@@ -150,9 +151,9 @@
on:nodemouseenter={(event) => console.log('on node enter', event)} on:nodemouseenter={(event) => console.log('on node enter', event)}
on:nodemouseleave={(event) => console.log('on node leave', event)} on:nodemouseleave={(event) => console.log('on node leave', event)}
on:edgeclick={(event) => console.log('edge click', event)} on:edgeclick={(event) => console.log('edge click', event)}
on:connectstart={(event) => console.log('on connect start', event)} onconnectstart={(event) => console.log('on connect start', event)}
on:connect={(event) => console.log('on connect', event)} onconnect={(event) => console.log('on connect', event)}
on:connectend={(event) => console.log('on connect end', event)} onconnectend={(event) => console.log('on connect end', event)}
on:paneclick={(event) => console.log('on pane click', event)} on:paneclick={(event) => console.log('on pane click', event)}
on:panecontextmenu={(event) => { on:panecontextmenu={(event) => {
console.log('on pane contextmenu', event); console.log('on pane contextmenu', event);
@@ -174,6 +175,8 @@
event.preventDefault(); event.preventDefault();
console.log('on edge contextmenu', edge); console.log('on edge contextmenu', edge);
}} }}
on:selectionclick={(event) => console.log('on selection click', event)}
on:selectioncontextmenu={(event) => console.log('on selection contextmenu', event)}
autoPanOnConnect autoPanOnConnect
autoPanOnNodeDrag autoPanOnNodeDrag
connectionMode={ConnectionMode.Strict} connectionMode={ConnectionMode.Strict}
@@ -4,13 +4,13 @@
type $$Props = NodeProps; type $$Props = NodeProps;
export let data: { label: string } = { label: 'Node' }; export let data: { label: string } = { label: 'Node' };
export let xPos: number = 0; export let positionAbsoluteX: number = 0;
export let yPos: number = 0; export let positionAbsoluteY: number = 0;
</script> </script>
<div class="custom"> <div class="custom">
<div>{data.label}</div> <div>{data.label}</div>
<div>{~~xPos}, {~~yPos}</div> <div>{~~positionAbsoluteX}, {~~positionAbsoluteY}</div>
<Handle type="target" position={Position.Top} /> <Handle type="target" position={Position.Top} />
@@ -1,24 +1,16 @@
<script lang="ts"> <script lang="ts">
import { Handle, Position, type NodeProps } from '@xyflow/svelte'; import { Handle, Position, type NodeProps, type XYPosition } from '@xyflow/svelte';
type $$Props = NodeProps; type $$Props = NodeProps;
export let id: string; export let id: string;
export let xPos: number = 0; export let positionAbsolute: XYPosition = { x: 0, y: 0 };
export let yPos: number = 0;
export let zIndex: number = 0; export let zIndex: number = 0;
</script> </script>
<Handle type="target" position={Position.Top} /> <Handle type="target" position={Position.Top} />
<div>{id}</div> <div>{id}</div>
<div> <div>
x:{Math.round(xPos || 0)} y:{Math.round(yPos || 0)} z:{zIndex} x:{Math.round(positionAbsolute.x)} y:{Math.round(positionAbsolute.y)} z:{zIndex}
</div> </div>
<Handle type="source" position={Position.Bottom} /> <Handle type="source" position={Position.Bottom} />
<style>
.custom {
background: #ffcc00;
padding: 10px;
}
</style>
@@ -0,0 +1,80 @@
<script lang="ts">
import { writable } from 'svelte/store';
import {
SvelteFlow,
Controls,
Background,
BackgroundVariant,
MiniMap,
type Node,
type NodeTypes,
type Edge
} from '@xyflow/svelte';
import '@xyflow/svelte/dist/style.css';
import TextNode from './TextNode.svelte';
import UppercaseNode from './UppercaseNode.svelte';
import ResultNode from './ResultNode.svelte';
const nodeTypes: NodeTypes = {
text: TextNode,
uppercase: UppercaseNode,
result: ResultNode
};
const nodes = writable<Node[]>([
{
id: '1',
type: 'text',
data: {
text: 'hello'
},
position: { x: -100, y: -50 }
},
{
id: '1a',
type: 'uppercase',
data: {},
position: { x: 100, y: 0 }
},
{
id: '2',
type: 'text',
data: {
text: 'world'
},
position: { x: 0, y: 100 }
},
{
id: '3',
type: 'result',
data: {},
position: { x: 300, y: 50 }
}
]);
const edges = writable<Edge[]>([
{
id: 'e1-1a',
source: '1',
target: '1a'
},
{
id: 'e1a-3',
source: '1a',
target: '3'
},
{
id: 'e2-3',
source: '2',
target: '3'
}
]);
</script>
<SvelteFlow {nodes} {edges} {nodeTypes} fitView>
<Controls />
<Background variant={BackgroundVariant.Dots} />
<MiniMap />
</SvelteFlow>
@@ -0,0 +1,37 @@
<script lang="ts">
import {
Handle,
Position,
useHandleConnections,
useNodesData,
type NodeProps
} from '@xyflow/svelte';
type $$Props = NodeProps;
export let id: $$Props['id'];
const connections = useHandleConnections({
nodeId: id,
type: 'target'
});
$: nodeData = useNodesData($connections.map((connection) => connection.source));
</script>
<div class="custom">
<Handle type="target" position={Position.Left} />
<div>incoming texts:</div>
{#each $nodeData as data}
<div>{data.text}</div>
{/each}
</div>
<style>
.custom {
background-color: #eee;
padding: 10px;
border-radius: 10px;
}
</style>
@@ -0,0 +1,29 @@
<script lang="ts">
import { Handle, Position, type NodeProps, useSvelteFlow } from '@xyflow/svelte';
type $$Props = NodeProps;
export let id: $$Props['id'];
export let data: $$Props['data'];
const { updateNodeData } = useSvelteFlow();
</script>
<div class="custom">
<div>node {id}</div>
<div>
<input
value={data.text}
on:input={(evt) => updateNodeData(id, { text: evt.currentTarget.value })}
/>
</div>
<Handle type="source" position={Position.Right} />
</div>
<style>
.custom {
background-color: #eee;
padding: 10px;
border-radius: 10px;
}
</style>
@@ -0,0 +1,40 @@
<script lang="ts">
import {
Handle,
Position,
useHandleConnections,
useNodesData,
useSvelteFlow,
type NodeProps
} from '@xyflow/svelte';
type $$Props = NodeProps;
export let id: $$Props['id'];
const { updateNodeData } = useSvelteFlow();
const connections = useHandleConnections({
nodeId: id,
type: 'target'
});
$: nodeData = useNodesData($connections[0]?.source);
$: {
updateNodeData(id, { text: $nodeData?.text?.toUpperCase() || '' });
}
</script>
<div class="custom">
<Handle type="target" position={Position.Left} isConnectable={$connections.length === 0} />
<div>uppercase transform</div>
<Handle type="source" position={Position.Right} />
</div>
<style>
.custom {
background-color: #eee;
padding: 10px;
border-radius: 10px;
}
</style>
@@ -15,15 +15,9 @@
}; };
</script> </script>
<Handle type="target" position={Position.Top} on:connect /> <Handle type="target" position={Position.Top} />
<button on:click={onClick}>add handle</button> <button on:click={onClick}>add handle</button>
{#each Array.from({ length: handleCount }) as handle, i} {#each Array.from({ length: handleCount }) as handle, i}
<Handle <Handle type="source" position={Position.Bottom} id={`${i}`} style={`left: ${i * 10}px;`} />
type="source"
position={Position.Bottom}
id={`${i}`}
style={`left: ${i * 10}px;`}
on:connect
/>
{/each} {/each}
@@ -6,7 +6,9 @@
Background, Background,
BackgroundVariant, BackgroundVariant,
MiniMap, MiniMap,
type NodeTypes type NodeTypes,
useSvelteFlow,
Panel
} from '@xyflow/svelte'; } from '@xyflow/svelte';
import CustomNode from './CustomNode.svelte'; import CustomNode from './CustomNode.svelte';
@@ -50,6 +52,12 @@
target: '3' target: '3'
} }
]); ]);
const { updateNode } = useSvelteFlow();
const updateNodePosition = () => {
updateNode('1', (node) => ({ position: { x: node.position.x + 10, y: node.position.y } }));
};
</script> </script>
<main> <main>
@@ -57,6 +65,8 @@
<Controls /> <Controls />
<Background variant={BackgroundVariant.Dots} /> <Background variant={BackgroundVariant.Dots} />
<MiniMap /> <MiniMap />
<Panel><button on:click={updateNodePosition}>update node</button></Panel>
</SvelteFlow> </SvelteFlow>
</main> </main>
@@ -55,8 +55,8 @@ function Background({
{ {
...style, ...style,
...containerStyle, ...containerStyle,
'--background-color-props': bgColor, '--xy-background-color-props': bgColor,
'--background-pattern-color-props': color, '--xy-background-pattern-color-props': color,
} as CSSProperties } as CSSProperties
} }
ref={ref} ref={ref}
@@ -26,6 +26,6 @@ type DotPatternProps = {
export function DotPattern({ radius, className }: DotPatternProps) { export function DotPattern({ radius, className }: DotPatternProps) {
return ( return (
<circle cx={radius} cy={radius} r={radius} className={cc(['react-flow__background-pattern', 'dot', className])} /> <circle cx={radius} cy={radius} r={radius} className={cc(['react-flow__background-pattern', 'dots', className])} />
); );
} }
@@ -1,6 +1,6 @@
/* eslint-disable @typescript-eslint/ban-ts-comment */ /* eslint-disable @typescript-eslint/ban-ts-comment */
/* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/no-explicit-any */
import { memo, useEffect, useRef, type MouseEvent, useCallback } from 'react'; import { memo, useEffect, useRef, type MouseEvent, useCallback, CSSProperties } from 'react';
import cc from 'classcat'; import cc from 'classcat';
import { shallow } from 'zustand/shallow'; import { shallow } from 'zustand/shallow';
import { getNodesBounds, getBoundsOfRects, XYMinimap, type Rect, type XYMinimapInstance } from '@xyflow/system'; import { getNodesBounds, getBoundsOfRects, XYMinimap, type Rect, type XYMinimapInstance } from '@xyflow/system';
@@ -40,15 +40,15 @@ const ARIA_LABEL_KEY = 'react-flow__minimap-desc';
function MiniMap({ function MiniMap({
style, style,
className, className,
nodeStrokeColor = 'transparent', nodeStrokeColor,
nodeColor = '#e2e2e2', nodeColor,
nodeClassName = '', nodeClassName = '',
nodeBorderRadius = 5, nodeBorderRadius = 5,
nodeStrokeWidth = 2, nodeStrokeWidth,
// We need to rename the prop to be `CapitalCase` so that JSX will render it as // We need to rename the prop to be `CapitalCase` so that JSX will render it as
// a component properly. // a component properly.
nodeComponent, nodeComponent,
maskColor = 'rgb(240, 240, 240, 0.6)', maskColor,
maskStrokeColor = 'none', maskStrokeColor = 'none',
maskStrokeWidth = 1, maskStrokeWidth = 1,
position = 'bottom-right', position = 'bottom-right',
@@ -126,7 +126,15 @@ function MiniMap({
return ( return (
<Panel <Panel
position={position} position={position}
style={style} style={
{
...style,
'--xy-minimap-mask-color-props': typeof maskColor === 'string' ? maskColor : 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,
} as CSSProperties
}
className={cc(['react-flow__minimap', className])} className={cc(['react-flow__minimap', className])}
data-testid="rf__minimap" data-testid="rf__minimap"
> >
@@ -153,7 +161,6 @@ function MiniMap({
className="react-flow__minimap-mask" className="react-flow__minimap-mask"
d={`M${x - offset},${y - offset}h${width + offset * 2}v${height + offset * 2}h${-width - offset * 2}z 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`} M${viewBB.x},${viewBB.y}h${viewBB.width}v${viewBB.height}h${-viewBB.width}z`}
fill={maskColor}
fillRule="evenodd" fillRule="evenodd"
stroke={maskStrokeColor} stroke={maskStrokeColor}
strokeWidth={maskStrokeWidth} strokeWidth={maskStrokeWidth}
@@ -31,9 +31,11 @@ function MiniMapNode({
ry={borderRadius} ry={borderRadius}
width={width} width={width}
height={height} height={height}
fill={fill} style={{
stroke={strokeColor} fill,
strokeWidth={strokeWidth} stroke: strokeColor,
strokeWidth,
}}
shapeRendering={shapeRendering} shapeRendering={shapeRendering}
onClick={onClick ? (event) => onClick(event, id) : undefined} onClick={onClick ? (event) => onClick(event, id) : undefined}
/> />
@@ -1,32 +1,32 @@
/* eslint-disable @typescript-eslint/ban-ts-comment */ /* eslint-disable @typescript-eslint/ban-ts-comment */
/* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/no-explicit-any */
import { memo } from 'react'; import { ComponentType, memo } from 'react';
import { NodeOrigin, getNodePositionWithOrigin } from '@xyflow/system';
import { shallow } from 'zustand/shallow'; import { shallow } from 'zustand/shallow';
import { getNodePositionWithOrigin } from '@xyflow/system';
import { useStore } from '../../hooks/useStore'; import { useStore } from '../../hooks/useStore';
import type { ReactFlowState } from '../../types'; import type { ReactFlowState } from '../../types';
import MiniMapNode from './MiniMapNode'; import MiniMapNode from './MiniMapNode';
import type { MiniMapNodes, GetMiniMapNodeAttribute } from './types'; import type { MiniMapNodes as MiniMapNodesProps, GetMiniMapNodeAttribute, MiniMapNodeProps } from './types';
declare const window: any; declare const window: any;
const selector = (s: ReactFlowState) => s.nodeOrigin; const selector = (s: ReactFlowState) => s.nodeOrigin;
const selectorNodes = (s: ReactFlowState) => s.nodes.filter((node) => !node.hidden && node.width && node.height); const selectorNodeIds = (s: ReactFlowState) => s.nodes.map((node) => node.id);
const getAttrFunction = (func: any): GetMiniMapNodeAttribute => (func instanceof Function ? func : () => func); const getAttrFunction = (func: any): GetMiniMapNodeAttribute => (func instanceof Function ? func : () => func);
function MiniMapNodes({ function MiniMapNodes({
nodeStrokeColor = 'transparent', nodeStrokeColor,
nodeColor = '#e2e2e2', nodeColor,
nodeClassName = '', nodeClassName = '',
nodeBorderRadius = 5, nodeBorderRadius = 5,
nodeStrokeWidth = 2, nodeStrokeWidth,
// We need to rename the prop to be `CapitalCase` so that JSX will render it as // We need to rename the prop to be `CapitalCase` so that JSX will render it as
// a component properly. // a component properly.
nodeComponent: NodeComponent = MiniMapNode, nodeComponent: NodeComponent = MiniMapNode,
onClick, onClick,
}: MiniMapNodes) { }: MiniMapNodesProps) {
const nodes = useStore(selectorNodes, shallow); const nodeIds = useStore(selectorNodeIds, shallow);
const nodeOrigin = useStore(selector); const nodeOrigin = useStore(selector);
const nodeColorFunc = getAttrFunction(nodeColor); const nodeColorFunc = getAttrFunction(nodeColor);
const nodeStrokeColorFunc = getAttrFunction(nodeStrokeColor); const nodeStrokeColorFunc = getAttrFunction(nodeStrokeColor);
@@ -36,31 +36,78 @@ function MiniMapNodes({
return ( return (
<> <>
{nodes.map((node) => { {nodeIds.map((nodeId) => (
const { x, y } = getNodePositionWithOrigin(node, node.origin || nodeOrigin).positionAbsolute; // The split of responsibilities between MiniMapNodes and
// NodeComponentWrapper may appear weird. However, its designed to
return ( // minimize the cost of updates when individual nodes change.
<NodeComponent //
key={node.id} // For more details, see a similar commit in `NodeRenderer/index.tsx`.
x={x} <NodeComponentWrapper
y={y} key={nodeId}
width={node.width!} id={nodeId}
height={node.height!} nodeOrigin={nodeOrigin}
style={node.style} nodeColorFunc={nodeColorFunc}
selected={!!node.selected} nodeStrokeColorFunc={nodeStrokeColorFunc}
className={nodeClassNameFunc(node)} nodeClassNameFunc={nodeClassNameFunc}
color={nodeColorFunc(node)} nodeBorderRadius={nodeBorderRadius}
borderRadius={nodeBorderRadius} nodeStrokeWidth={nodeStrokeWidth}
strokeColor={nodeStrokeColorFunc(node)} NodeComponent={NodeComponent}
strokeWidth={nodeStrokeWidth} onClick={onClick}
shapeRendering={shapeRendering} shapeRendering={shapeRendering}
onClick={onClick} />
id={node.id} ))}
/>
);
})}
</> </>
); );
} }
const NodeComponentWrapper = memo(function NodeComponentWrapper({
id,
nodeOrigin,
nodeColorFunc,
nodeStrokeColorFunc,
nodeClassNameFunc,
nodeBorderRadius,
nodeStrokeWidth,
shapeRendering,
NodeComponent,
onClick,
}: {
id: string;
nodeOrigin: NodeOrigin;
nodeColorFunc: GetMiniMapNodeAttribute;
nodeStrokeColorFunc: GetMiniMapNodeAttribute;
nodeClassNameFunc: GetMiniMapNodeAttribute;
nodeBorderRadius: number;
nodeStrokeWidth?: number;
NodeComponent: ComponentType<MiniMapNodeProps>;
onClick: MiniMapNodesProps['onClick'];
shapeRendering: string;
}) {
const node = useStore((s) => s.nodeLookup.get(id));
if (!node || node.hidden || !(node.computed?.width || node.width) || !(node.computed?.height || node.height)) {
return null;
}
const positionOrigin = getNodePositionWithOrigin(node, node.origin || nodeOrigin).positionAbsolute;
return (
<NodeComponent
x={positionOrigin.x}
y={positionOrigin.y}
width={node.computed?.width ?? node.width ?? 0}
height={node.computed?.height ?? node.height ?? 0}
style={node.style}
selected={!!node.selected}
className={nodeClassNameFunc(node)}
color={nodeColorFunc(node)}
borderRadius={nodeBorderRadius}
strokeColor={nodeStrokeColorFunc(node)}
strokeWidth={nodeStrokeWidth}
shapeRendering={shapeRendering}
onClick={onClick}
id={node.id}
/>
);
});
export default memo(MiniMapNodes); export default memo(MiniMapNodes);
@@ -42,10 +42,10 @@ export type MiniMapNodeProps = {
height: number; height: number;
borderRadius: number; borderRadius: number;
className: string; className: string;
color: string; color?: string;
shapeRendering: string; shapeRendering: string;
strokeColor: string; strokeColor?: string;
strokeWidth: number; strokeWidth?: number;
style?: CSSProperties; style?: CSSProperties;
selected: boolean; selected: boolean;
onClick?: (event: MouseEvent, id: string) => void; onClick?: (event: MouseEvent, id: string) => void;
@@ -70,8 +70,8 @@ function ResizeControl({
const { xSnapped, ySnapped } = getPointerPosition(event.sourceEvent, { transform, snapGrid, snapToGrid }); const { xSnapped, ySnapped } = getPointerPosition(event.sourceEvent, { transform, snapGrid, snapToGrid });
prevValues.current = { prevValues.current = {
width: node?.width ?? 0, width: node?.computed?.width ?? 0,
height: node?.height ?? 0, height: node?.computed?.height ?? 0,
x: node?.position.x ?? 0, x: node?.position.x ?? 0,
y: node?.position.y ?? 0, y: node?.position.y ?? 0,
}; };
@@ -1,74 +1,40 @@
import { useCallback, CSSProperties } from 'react'; import { useCallback, CSSProperties } from 'react';
import cc from 'classcat'; import cc from 'classcat';
import { shallow } from 'zustand/shallow'; import { shallow } from 'zustand/shallow';
import { getNodesBounds, Transform, Rect, Position, internalsSymbol } from '@xyflow/system'; import { getNodesBounds, Rect, Position, internalsSymbol, getNodeToolbarTransform } from '@xyflow/system';
import { Node, ReactFlowState } from '../../types'; import { Node, ReactFlowState } from '../../types';
import { useStore } from '../../hooks/useStore'; import { useStore } from '../../hooks/useStore';
import { useNodeId } from '../../contexts/NodeIdContext'; import { useNodeId } from '../../contexts/NodeIdContext';
import NodeToolbarPortal from './NodeToolbarPortal'; import NodeToolbarPortal from './NodeToolbarPortal';
import { Align, NodeToolbarProps } from './types'; import { NodeToolbarProps } from './types';
const nodeEqualityFn = (a: Node | undefined, b: Node | undefined) => const nodeEqualityFn = (a?: Node, b?: Node) =>
a?.positionAbsolute?.x === b?.positionAbsolute?.x && a?.computed?.positionAbsolute?.x !== b?.computed?.positionAbsolute?.x ||
a?.positionAbsolute?.y === b?.positionAbsolute?.y && a?.computed?.positionAbsolute?.y !== b?.computed?.positionAbsolute?.y ||
a?.width === b?.width && a?.computed?.width !== b?.computed?.width ||
a?.height === b?.height && a?.computed?.height !== b?.computed?.height ||
a?.selected === b?.selected && a?.selected !== b?.selected ||
a?.[internalsSymbol]?.z === b?.[internalsSymbol]?.z; a?.[internalsSymbol]?.z !== b?.[internalsSymbol]?.z;
const nodesEqualityFn = (a: Node[], b: Node[]) => { const nodesEqualityFn = (a: Node[], b: Node[]) => {
return a.length === b.length && a.every((node, i) => nodeEqualityFn(node, b[i])); if (a.length !== b.length) {
return false;
}
return !a.some((node, i) => nodeEqualityFn(node, b[i]));
}; };
const storeSelector = (state: ReactFlowState) => ({ const storeSelector = (state: ReactFlowState) => ({
transform: state.transform, viewport: {
x: state.transform[0],
y: state.transform[1],
zoom: state.transform[2],
},
nodeOrigin: state.nodeOrigin, nodeOrigin: state.nodeOrigin,
selectedNodesCount: state.nodes.filter((node) => node.selected).length, selectedNodesCount: state.nodes.filter((node) => node.selected).length,
}); });
function getTransform(nodeRect: Rect, transform: Transform, position: Position, offset: number, align: Align): string {
let alignmentOffset = 0.5;
if (align === 'start') {
alignmentOffset = 0;
} else if (align === 'end') {
alignmentOffset = 1;
}
// position === Position.Top
// we set the x any y position of the toolbar based on the nodes position
let pos = [
(nodeRect.x + nodeRect.width * alignmentOffset) * transform[2] + transform[0],
nodeRect.y * transform[2] + transform[1] - offset,
];
// and than shift it based on the alignment. The shift values are in %.
let shift = [-100 * alignmentOffset, -100];
switch (position) {
case Position.Right:
pos = [
(nodeRect.x + nodeRect.width) * transform[2] + transform[0] + offset,
(nodeRect.y + nodeRect.height * alignmentOffset) * transform[2] + transform[1],
];
shift = [0, -100 * alignmentOffset];
break;
case Position.Bottom:
pos[1] = (nodeRect.y + nodeRect.height) * transform[2] + transform[1] + offset;
shift[1] = 0;
break;
case Position.Left:
pos = [
nodeRect.x * transform[2] + transform[0] - offset,
(nodeRect.y + nodeRect.height * alignmentOffset) * transform[2] + transform[1],
];
shift = [-100, -100 * alignmentOffset];
break;
}
return `translate(${pos[0]}px, ${pos[1]}px) translate(${shift[0]}%, ${shift[1]}%)`;
}
function NodeToolbar({ function NodeToolbar({
nodeId, nodeId,
children, children,
@@ -97,7 +63,9 @@ function NodeToolbar({
[nodeId, contextNodeId] [nodeId, contextNodeId]
); );
const nodes = useStore(nodesSelector, nodesEqualityFn); const nodes = useStore(nodesSelector, nodesEqualityFn);
const { transform, nodeOrigin, selectedNodesCount } = useStore(storeSelector, shallow); const { viewport, nodeOrigin, selectedNodesCount } = useStore(storeSelector, shallow);
// if isVisible is not set, we show the toolbar only if its node is selected and no other node is selected
const isActive = const isActive =
typeof isVisible === 'boolean' ? isVisible : nodes.length === 1 && nodes[0].selected && selectedNodesCount === 1; typeof isVisible === 'boolean' ? isVisible : nodes.length === 1 && nodes[0].selected && selectedNodesCount === 1;
@@ -110,14 +78,19 @@ function NodeToolbar({
const wrapperStyle: CSSProperties = { const wrapperStyle: CSSProperties = {
position: 'absolute', position: 'absolute',
transform: getTransform(nodeRect, transform, position, offset, align), transform: getNodeToolbarTransform(nodeRect, viewport, position, offset, align),
zIndex, zIndex,
...style, ...style,
}; };
return ( return (
<NodeToolbarPortal> <NodeToolbarPortal>
<div style={wrapperStyle} className={cc(['react-flow__node-toolbar', className])} {...rest}> <div
style={wrapperStyle}
className={cc(['react-flow__node-toolbar', className])}
{...rest}
data-id={nodes.reduce((acc, node) => `${acc}${node.id} `, '').trim()}
>
{children} {children}
</div> </div>
</NodeToolbarPortal> </NodeToolbarPortal>
@@ -1,5 +1,5 @@
import type { HTMLAttributes } from 'react'; import type { HTMLAttributes } from 'react';
import type { Position } from '@xyflow/system'; import type { Position, Align } from '@xyflow/system';
export type NodeToolbarProps = HTMLAttributes<HTMLDivElement> & { export type NodeToolbarProps = HTMLAttributes<HTMLDivElement> & {
nodeId?: string | string[]; nodeId?: string | string[];
@@ -8,5 +8,3 @@ export type NodeToolbarProps = HTMLAttributes<HTMLDivElement> & {
offset?: number; offset?: number;
align?: Align; align?: Align;
}; };
export type Align = 'center' | 'start' | 'end';
@@ -65,10 +65,10 @@ const ConnectionLine = ({
} }
const fromHandle = handleId ? handleBounds.find((d) => d.id === handleId) : handleBounds[0]; const fromHandle = handleId ? handleBounds.find((d) => d.id === handleId) : handleBounds[0];
const fromHandleX = fromHandle ? fromHandle.x + fromHandle.width / 2 : (fromNode.width ?? 0) / 2; const fromHandleX = fromHandle ? fromHandle.x + fromHandle.width / 2 : (fromNode.computed?.width ?? 0) / 2;
const fromHandleY = fromHandle ? fromHandle.y + fromHandle.height / 2 : fromNode.height ?? 0; const fromHandleY = fromHandle ? fromHandle.y + fromHandle.height / 2 : fromNode.computed?.height ?? 0;
const fromX = (fromNode.positionAbsolute?.x ?? 0) + fromHandleX; const fromX = (fromNode.computed?.positionAbsolute?.x ?? 0) + fromHandleX;
const fromY = (fromNode.positionAbsolute?.y ?? 0) + fromHandleY; const fromY = (fromNode.computed?.positionAbsolute?.y ?? 0) + fromHandleY;
const fromPosition = fromHandle?.position; const fromPosition = fromHandle?.position;
const toPosition = fromPosition ? oppositePosition[fromPosition] : null; const toPosition = fromPosition ? oppositePosition[fromPosition] : null;
@@ -0,0 +1,310 @@
import { memo, useState, useMemo, useRef, type KeyboardEvent, useCallback } from 'react';
import cc from 'classcat';
import { shallow } from 'zustand/shallow';
import {
getMarkerId,
elementSelectionKeys,
XYHandle,
type Connection,
getEdgePosition,
errorMessages,
} from '@xyflow/system';
import { useStoreApi, useStore } from '../../hooks/useStore';
import { ARIA_EDGE_DESC_KEY } from '../A11yDescriptions';
import { EdgeAnchor } from '../Edges/EdgeAnchor';
import { getMouseHandler } from '../Edges/utils';
import type { EdgeWrapperProps } from '../../types';
import { builtinEdgeTypes } from './utils';
function EdgeWrapper({
id,
className,
type,
data,
onClick,
onEdgeDoubleClick,
selected,
animated,
label,
labelStyle,
labelShowBg,
labelBgStyle,
labelBgPadding,
labelBgBorderRadius,
style,
source,
target,
isSelectable,
hidden,
sourceHandleId,
targetHandleId,
onContextMenu,
onMouseEnter,
onMouseMove,
onMouseLeave,
edgeUpdaterRadius,
onEdgeUpdate,
onEdgeUpdateStart,
onEdgeUpdateEnd,
markerEnd,
markerStart,
rfId,
ariaLabel,
isFocusable,
isUpdatable,
pathOptions,
interactionWidth,
edgeTypes,
zIndex,
onError,
}: EdgeWrapperProps): JSX.Element | null {
let edgeType = type || 'default';
let EdgeComponent = edgeTypes?.[edgeType] || builtinEdgeTypes[edgeType];
if (EdgeComponent === undefined) {
onError?.('011', errorMessages['error011'](edgeType));
edgeType = 'default';
EdgeComponent = builtinEdgeTypes.default;
}
const edgeRef = useRef<SVGGElement>(null);
const [updateHover, setUpdateHover] = useState<boolean>(false);
const [updating, setUpdating] = useState<boolean>(false);
const store = useStoreApi();
const edgePosition = useStore(
useCallback(
(state) => {
const sourceNode = state.nodeLookup.get(source);
const targetNode = state.nodeLookup.get(target);
if (!sourceNode || !targetNode) {
return null;
}
return getEdgePosition({
id,
sourceNode,
targetNode,
sourceHandle: sourceHandleId || null,
targetHandle: targetHandleId || null,
connectionMode: state.connectionMode,
onError: state.onError,
});
},
[source, target]
),
shallow
);
const markerStartUrl = useMemo(() => `url(#${getMarkerId(markerStart, rfId)})`, [markerStart, rfId]);
const markerEndUrl = useMemo(() => `url(#${getMarkerId(markerEnd, rfId)})`, [markerEnd, rfId]);
if (hidden || !edgePosition) {
return null;
}
const onEdgeClick = (event: React.MouseEvent<SVGGElement, MouseEvent>): void => {
const { edges, addSelectedEdges, unselectNodesAndEdges, multiSelectionActive } = store.getState();
const edge = edges.find((e) => e.id === id);
if (!edge) {
return;
}
if (isSelectable) {
store.setState({ nodesSelectionActive: false });
if (edge.selected && multiSelectionActive) {
unselectNodesAndEdges({ nodes: [], edges: [edge] });
edgeRef.current?.blur();
} else {
addSelectedEdges([id]);
}
}
if (onClick) {
onClick(event, edge);
}
};
const onEdgeDoubleClickHandler = getMouseHandler(id, store.getState, onEdgeDoubleClick);
const onEdgeContextMenu = getMouseHandler(id, store.getState, onContextMenu);
const onEdgeMouseEnter = getMouseHandler(id, store.getState, onMouseEnter);
const onEdgeMouseMove = getMouseHandler(id, store.getState, onMouseMove);
const onEdgeMouseLeave = getMouseHandler(id, store.getState, onMouseLeave);
const handleEdgeUpdater = (event: React.MouseEvent<SVGGElement, MouseEvent>, isSourceHandle: boolean) => {
// avoid triggering edge updater if mouse btn is not left
if (event.button !== 0) {
return;
}
const {
autoPanOnConnect,
domNode,
edges,
isValidConnection,
connectionMode,
connectionRadius,
lib,
onConnectStart,
onConnectEnd,
cancelConnection,
nodes,
panBy,
updateConnection,
} = store.getState();
const nodeId = isSourceHandle ? target : source;
const handleId = (isSourceHandle ? targetHandleId : sourceHandleId) || null;
const handleType = isSourceHandle ? 'target' : 'source';
const isTarget = isSourceHandle;
const edge = edges.find((e) => e.id === id)!;
setUpdating(true);
onEdgeUpdateStart?.(event, edge, handleType);
const _onEdgeUpdateEnd = (evt: MouseEvent | TouchEvent) => {
setUpdating(false);
onEdgeUpdateEnd?.(evt, edge, handleType);
};
const onConnectEdge = (connection: Connection) => onEdgeUpdate?.(edge, connection);
XYHandle.onPointerDown(event.nativeEvent, {
autoPanOnConnect,
connectionMode,
connectionRadius,
domNode,
handleId,
nodeId,
nodes,
isTarget,
edgeUpdaterType: handleType,
lib,
cancelConnection,
panBy,
isValidConnection,
onConnect: onConnectEdge,
onConnectStart,
onConnectEnd,
onEdgeUpdateEnd: _onEdgeUpdateEnd,
updateConnection,
getTransform: () => store.getState().transform,
});
};
const onEdgeUpdaterSourceMouseDown = (event: React.MouseEvent<SVGGElement, MouseEvent>): void =>
handleEdgeUpdater(event, true);
const onEdgeUpdaterTargetMouseDown = (event: React.MouseEvent<SVGGElement, MouseEvent>): void =>
handleEdgeUpdater(event, false);
const onEdgeUpdaterMouseEnter = () => setUpdateHover(true);
const onEdgeUpdaterMouseOut = () => setUpdateHover(false);
const inactive = !isSelectable && !onClick;
const onKeyDown = (event: KeyboardEvent) => {
if (elementSelectionKeys.includes(event.key) && isSelectable) {
const { unselectNodesAndEdges, addSelectedEdges, edges } = store.getState();
const unselect = event.key === 'Escape';
if (unselect) {
edgeRef.current?.blur();
unselectNodesAndEdges({ edges: [edges.find((e) => e.id === id)!] });
} else {
addSelectedEdges([id]);
}
}
};
return (
<svg style={{ zIndex }}>
<g
className={cc([
'react-flow__edge',
`react-flow__edge-${type}`,
className,
{ selected, animated, inactive, updating: updateHover },
])}
onClick={onEdgeClick}
onDoubleClick={onEdgeDoubleClickHandler}
onContextMenu={onEdgeContextMenu}
onMouseEnter={onEdgeMouseEnter}
onMouseMove={onEdgeMouseMove}
onMouseLeave={onEdgeMouseLeave}
onKeyDown={isFocusable ? onKeyDown : undefined}
tabIndex={isFocusable ? 0 : undefined}
role={isFocusable ? 'button' : 'img'}
data-id={id}
data-testid={`rf__edge-${id}`}
aria-label={ariaLabel === null ? undefined : ariaLabel ? ariaLabel : `Edge from ${source} to ${target}`}
aria-describedby={isFocusable ? `${ARIA_EDGE_DESC_KEY}-${rfId}` : undefined}
ref={edgeRef}
>
{!updating && (
<EdgeComponent
id={id}
source={source}
target={target}
selected={selected}
animated={animated}
label={label}
labelStyle={labelStyle}
labelShowBg={labelShowBg}
labelBgStyle={labelBgStyle}
labelBgPadding={labelBgPadding}
labelBgBorderRadius={labelBgBorderRadius}
data={data}
style={style}
sourceX={edgePosition.sourceX}
sourceY={edgePosition.sourceY}
targetX={edgePosition.targetX}
targetY={edgePosition.targetY}
sourcePosition={edgePosition.sourcePosition}
targetPosition={edgePosition.targetPosition}
sourceHandleId={sourceHandleId}
targetHandleId={targetHandleId}
markerStart={markerStartUrl}
markerEnd={markerEndUrl}
pathOptions={pathOptions}
interactionWidth={interactionWidth}
/>
)}
{isUpdatable && (
<>
{(isUpdatable === 'source' || isUpdatable === true) && (
<EdgeAnchor
position={edgePosition.sourcePosition}
centerX={edgePosition.sourceX}
centerY={edgePosition.sourceY}
radius={edgeUpdaterRadius}
onMouseDown={onEdgeUpdaterSourceMouseDown}
onMouseEnter={onEdgeUpdaterMouseEnter}
onMouseOut={onEdgeUpdaterMouseOut}
type="source"
/>
)}
{(isUpdatable === 'target' || isUpdatable === true) && (
<EdgeAnchor
position={edgePosition.targetPosition}
centerX={edgePosition.targetX}
centerY={edgePosition.targetY}
radius={edgeUpdaterRadius}
onMouseDown={onEdgeUpdaterTargetMouseDown}
onMouseEnter={onEdgeUpdaterMouseEnter}
onMouseOut={onEdgeUpdaterMouseOut}
type="target"
/>
)}
</>
)}
</g>
</svg>
);
}
EdgeWrapper.displayName = 'EdgeWrapper';
export default memo(EdgeWrapper);
@@ -0,0 +1,17 @@
import type { ComponentType } from 'react';
import type { EdgeProps, EdgeTypes } from '../../types';
import {
BezierEdgeInternal,
StraightEdgeInternal,
StepEdgeInternal,
SmoothStepEdgeInternal,
SimpleBezierEdgeInternal,
} from '../Edges';
export const builtinEdgeTypes: EdgeTypes = {
default: BezierEdgeInternal as ComponentType<EdgeProps>,
straight: StraightEdgeInternal as ComponentType<EdgeProps>,
step: StepEdgeInternal as ComponentType<EdgeProps>,
smoothstep: SmoothStepEdgeInternal as ComponentType<EdgeProps>,
simplebezier: SimpleBezierEdgeInternal as ComponentType<EdgeProps>,
};
@@ -4,56 +4,67 @@ import { Position, getBezierPath } from '@xyflow/system';
import BaseEdge from './BaseEdge'; import BaseEdge from './BaseEdge';
import type { BezierEdgeProps } from '../../types'; import type { BezierEdgeProps } from '../../types';
const BezierEdge = memo( function createBezierEdge(params: { isInternal: boolean }) {
({ // eslint-disable-next-line react/display-name
sourceX, return memo(
sourceY, ({
targetX, id,
targetY,
sourcePosition = Position.Bottom,
targetPosition = Position.Top,
label,
labelStyle,
labelShowBg,
labelBgStyle,
labelBgPadding,
labelBgBorderRadius,
style,
markerEnd,
markerStart,
pathOptions,
interactionWidth,
}: BezierEdgeProps) => {
const [path, labelX, labelY] = getBezierPath({
sourceX, sourceX,
sourceY, sourceY,
sourcePosition,
targetX, targetX,
targetY, targetY,
targetPosition, sourcePosition = Position.Bottom,
curvature: pathOptions?.curvature, targetPosition = Position.Top,
}); label,
labelStyle,
labelShowBg,
labelBgStyle,
labelBgPadding,
labelBgBorderRadius,
style,
markerEnd,
markerStart,
pathOptions,
interactionWidth,
}: BezierEdgeProps) => {
const [path, labelX, labelY] = getBezierPath({
sourceX,
sourceY,
sourcePosition,
targetX,
targetY,
targetPosition,
curvature: pathOptions?.curvature,
});
return ( const _id = params.isInternal ? undefined : id;
<BaseEdge
path={path} return (
labelX={labelX} <BaseEdge
labelY={labelY} id={_id}
label={label} path={path}
labelStyle={labelStyle} labelX={labelX}
labelShowBg={labelShowBg} labelY={labelY}
labelBgStyle={labelBgStyle} label={label}
labelBgPadding={labelBgPadding} labelStyle={labelStyle}
labelBgBorderRadius={labelBgBorderRadius} labelShowBg={labelShowBg}
style={style} labelBgStyle={labelBgStyle}
markerEnd={markerEnd} labelBgPadding={labelBgPadding}
markerStart={markerStart} labelBgBorderRadius={labelBgBorderRadius}
interactionWidth={interactionWidth} style={style}
/> markerEnd={markerEnd}
); markerStart={markerStart}
} interactionWidth={interactionWidth}
); />
);
}
);
}
const BezierEdge = createBezierEdge({ isInternal: false });
const BezierEdgeInternal = createBezierEdge({ isInternal: true });
BezierEdge.displayName = 'BezierEdge'; BezierEdge.displayName = 'BezierEdge';
BezierEdgeInternal.displayName = 'BezierEdgeInternal';
export default BezierEdge; export { BezierEdge, BezierEdgeInternal };
@@ -2,7 +2,7 @@ import { memo } from 'react';
import { Position, getBezierEdgeCenter } from '@xyflow/system'; import { Position, getBezierEdgeCenter } from '@xyflow/system';
import BaseEdge from './BaseEdge'; import BaseEdge from './BaseEdge';
import type { EdgeProps } from '../../types'; import type { SimpleBezierEdgeProps } from '../../types';
export interface GetSimpleBezierPathParams { export interface GetSimpleBezierPathParams {
sourceX: number; sourceX: number;
@@ -71,54 +71,65 @@ export function getSimpleBezierPath({
]; ];
} }
const SimpleBezierEdge = memo( function createSimpleBezierEdge(params: { isInternal: boolean }) {
({ // eslint-disable-next-line react/display-name
sourceX, return memo(
sourceY, ({
targetX, id,
targetY,
sourcePosition = Position.Bottom,
targetPosition = Position.Top,
label,
labelStyle,
labelShowBg,
labelBgStyle,
labelBgPadding,
labelBgBorderRadius,
style,
markerEnd,
markerStart,
interactionWidth,
}: EdgeProps) => {
const [path, labelX, labelY] = getSimpleBezierPath({
sourceX, sourceX,
sourceY, sourceY,
sourcePosition,
targetX, targetX,
targetY, targetY,
targetPosition, sourcePosition = Position.Bottom,
}); targetPosition = Position.Top,
label,
labelStyle,
labelShowBg,
labelBgStyle,
labelBgPadding,
labelBgBorderRadius,
style,
markerEnd,
markerStart,
interactionWidth,
}: SimpleBezierEdgeProps) => {
const [path, labelX, labelY] = getSimpleBezierPath({
sourceX,
sourceY,
sourcePosition,
targetX,
targetY,
targetPosition,
});
return ( const _id = params.isInternal ? undefined : id;
<BaseEdge
path={path} return (
labelX={labelX} <BaseEdge
labelY={labelY} id={_id}
label={label} path={path}
labelStyle={labelStyle} labelX={labelX}
labelShowBg={labelShowBg} labelY={labelY}
labelBgStyle={labelBgStyle} label={label}
labelBgPadding={labelBgPadding} labelStyle={labelStyle}
labelBgBorderRadius={labelBgBorderRadius} labelShowBg={labelShowBg}
style={style} labelBgStyle={labelBgStyle}
markerEnd={markerEnd} labelBgPadding={labelBgPadding}
markerStart={markerStart} labelBgBorderRadius={labelBgBorderRadius}
interactionWidth={interactionWidth} style={style}
/> markerEnd={markerEnd}
); markerStart={markerStart}
} interactionWidth={interactionWidth}
); />
);
}
);
}
const SimpleBezierEdge = createSimpleBezierEdge({ isInternal: false });
const SimpleBezierEdgeInternal = createSimpleBezierEdge({ isInternal: true });
SimpleBezierEdge.displayName = 'SimpleBezierEdge'; SimpleBezierEdge.displayName = 'SimpleBezierEdge';
SimpleBezierEdgeInternal.displayName = 'SimpleBezierEdgeInternal';
export default SimpleBezierEdge; export { SimpleBezierEdge, SimpleBezierEdgeInternal };
@@ -4,57 +4,68 @@ import { Position, getSmoothStepPath } from '@xyflow/system';
import BaseEdge from './BaseEdge'; import BaseEdge from './BaseEdge';
import type { SmoothStepEdgeProps } from '../../types'; import type { SmoothStepEdgeProps } from '../../types';
const SmoothStepEdge = memo( function createSmoothStepEdge(params: { isInternal: boolean }) {
({ // eslint-disable-next-line react/display-name
sourceX, return memo(
sourceY, ({
targetX, id,
targetY,
label,
labelStyle,
labelShowBg,
labelBgStyle,
labelBgPadding,
labelBgBorderRadius,
style,
sourcePosition = Position.Bottom,
targetPosition = Position.Top,
markerEnd,
markerStart,
pathOptions,
interactionWidth,
}: SmoothStepEdgeProps) => {
const [path, labelX, labelY] = getSmoothStepPath({
sourceX, sourceX,
sourceY, sourceY,
sourcePosition,
targetX, targetX,
targetY, targetY,
targetPosition, label,
borderRadius: pathOptions?.borderRadius, labelStyle,
offset: pathOptions?.offset, labelShowBg,
}); labelBgStyle,
labelBgPadding,
labelBgBorderRadius,
style,
sourcePosition = Position.Bottom,
targetPosition = Position.Top,
markerEnd,
markerStart,
pathOptions,
interactionWidth,
}: SmoothStepEdgeProps) => {
const [path, labelX, labelY] = getSmoothStepPath({
sourceX,
sourceY,
sourcePosition,
targetX,
targetY,
targetPosition,
borderRadius: pathOptions?.borderRadius,
offset: pathOptions?.offset,
});
return ( const _id = params.isInternal ? undefined : id;
<BaseEdge
path={path} return (
labelX={labelX} <BaseEdge
labelY={labelY} id={_id}
label={label} path={path}
labelStyle={labelStyle} labelX={labelX}
labelShowBg={labelShowBg} labelY={labelY}
labelBgStyle={labelBgStyle} label={label}
labelBgPadding={labelBgPadding} labelStyle={labelStyle}
labelBgBorderRadius={labelBgBorderRadius} labelShowBg={labelShowBg}
style={style} labelBgStyle={labelBgStyle}
markerEnd={markerEnd} labelBgPadding={labelBgPadding}
markerStart={markerStart} labelBgBorderRadius={labelBgBorderRadius}
interactionWidth={interactionWidth} style={style}
/> markerEnd={markerEnd}
); markerStart={markerStart}
} interactionWidth={interactionWidth}
); />
);
}
);
}
const SmoothStepEdge = createSmoothStepEdge({ isInternal: false });
const SmoothStepEdgeInternal = createSmoothStepEdge({ isInternal: true });
SmoothStepEdge.displayName = 'SmoothStepEdge'; SmoothStepEdge.displayName = 'SmoothStepEdge';
SmoothStepEdgeInternal.displayName = 'SmoothStepEdgeInternal';
export default SmoothStepEdge; export { SmoothStepEdge, SmoothStepEdgeInternal };
@@ -1,15 +1,30 @@
import { memo, useMemo } from 'react'; import { memo, useMemo } from 'react';
import SmoothStepEdge from './SmoothStepEdge'; import { SmoothStepEdge } from './SmoothStepEdge';
import type { SmoothStepEdgeProps } from '../../types'; import type { StepEdgeProps } from '../../types';
const StepEdge = memo((props: SmoothStepEdgeProps) => ( function createStepEdge(params: { isInternal: boolean }) {
<SmoothStepEdge // eslint-disable-next-line react/display-name
{...props} return memo(({ id, ...props }: StepEdgeProps) => {
pathOptions={useMemo(() => ({ borderRadius: 0, offset: props.pathOptions?.offset }), [props.pathOptions?.offset])} const _id = params.isInternal ? undefined : id;
/>
)); return (
<SmoothStepEdge
{...props}
id={_id}
pathOptions={useMemo(
() => ({ borderRadius: 0, offset: props.pathOptions?.offset }),
[props.pathOptions?.offset]
)}
/>
);
});
}
const StepEdge = createStepEdge({ isInternal: false });
const StepEdgeInternal = createStepEdge({ isInternal: true });
StepEdge.displayName = 'StepEdge'; StepEdge.displayName = 'StepEdge';
StepEdgeInternal.displayName = 'StepEdgeInternal';
export default StepEdge; export { StepEdge, StepEdgeInternal };
@@ -2,47 +2,58 @@ import { memo } from 'react';
import { getStraightPath } from '@xyflow/system'; import { getStraightPath } from '@xyflow/system';
import BaseEdge from './BaseEdge'; import BaseEdge from './BaseEdge';
import type { EdgeProps } from '../../types'; import type { StraightEdgeProps } from '../../types';
const StraightEdge = memo( function createStraightEdge(params: { isInternal: boolean }) {
({ // eslint-disable-next-line react/display-name
sourceX, return memo(
sourceY, ({
targetX, id,
targetY, sourceX,
label, sourceY,
labelStyle, targetX,
labelShowBg, targetY,
labelBgStyle, label,
labelBgPadding, labelStyle,
labelBgBorderRadius, labelShowBg,
style, labelBgStyle,
markerEnd, labelBgPadding,
markerStart, labelBgBorderRadius,
interactionWidth, style,
}: EdgeProps) => { markerEnd,
const [path, labelX, labelY] = getStraightPath({ sourceX, sourceY, targetX, targetY }); markerStart,
interactionWidth,
}: StraightEdgeProps) => {
const [path, labelX, labelY] = getStraightPath({ sourceX, sourceY, targetX, targetY });
return ( const _id = params.isInternal ? undefined : id;
<BaseEdge
path={path} return (
labelX={labelX} <BaseEdge
labelY={labelY} id={_id}
label={label} path={path}
labelStyle={labelStyle} labelX={labelX}
labelShowBg={labelShowBg} labelY={labelY}
labelBgStyle={labelBgStyle} label={label}
labelBgPadding={labelBgPadding} labelStyle={labelStyle}
labelBgBorderRadius={labelBgBorderRadius} labelShowBg={labelShowBg}
style={style} labelBgStyle={labelBgStyle}
markerEnd={markerEnd} labelBgPadding={labelBgPadding}
markerStart={markerStart} labelBgBorderRadius={labelBgBorderRadius}
interactionWidth={interactionWidth} style={style}
/> markerEnd={markerEnd}
); markerStart={markerStart}
} interactionWidth={interactionWidth}
); />
);
}
);
}
const StraightEdge = createStraightEdge({ isInternal: false });
const StraightEdgeInternal = createStraightEdge({ isInternal: true });
StraightEdge.displayName = 'StraightEdge'; StraightEdge.displayName = 'StraightEdge';
StraightEdgeInternal.displayName = 'StraightEdgeInternal';
export default StraightEdge; export { StraightEdge, StraightEdgeInternal };
+9 -5
View File
@@ -1,5 +1,9 @@
export { default as SimpleBezierEdge } from './SimpleBezierEdge'; // We distinguish between internal and exported edges
export { default as SmoothStepEdge } from './SmoothStepEdge'; // The internal edges are used directly like custom edges and always get an id, source and target props
export { default as StepEdge } from './StepEdge'; // If you import an edge from the library, the id is optional and source and target are not used at all
export { default as StraightEdge } from './StraightEdge';
export { default as BezierEdge } from './BezierEdge'; export { SimpleBezierEdge, SimpleBezierEdgeInternal } from './SimpleBezierEdge';
export { SmoothStepEdge, SmoothStepEdgeInternal } from './SmoothStepEdge';
export { StepEdge, StepEdgeInternal } from './StepEdge';
export { StraightEdge, StraightEdgeInternal } from './StraightEdge';
export { BezierEdge, BezierEdgeInternal } from './BezierEdge';
@@ -1,293 +0,0 @@
import { memo, useState, useMemo, useRef, type ComponentType, type KeyboardEvent, useCallback } from 'react';
import cc from 'classcat';
import { shallow } from 'zustand/shallow';
import { getMarkerId, elementSelectionKeys, XYHandle, type Connection, getEdgePosition } from '@xyflow/system';
import { useStoreApi, useStore } from '../../hooks/useStore';
import { ARIA_EDGE_DESC_KEY } from '../A11yDescriptions';
import { EdgeAnchor } from './EdgeAnchor';
import { getMouseHandler } from './utils';
import type { EdgeProps, WrapEdgeProps } from '../../types';
export default (EdgeComponent: ComponentType<EdgeProps>) => {
const EdgeWrapper = ({
id,
className,
type,
data,
onClick,
onEdgeDoubleClick,
selected,
animated,
label,
labelStyle,
labelShowBg,
labelBgStyle,
labelBgPadding,
labelBgBorderRadius,
style,
zIndex,
source,
target,
isSelectable,
hidden,
sourceHandleId,
targetHandleId,
onContextMenu,
onMouseEnter,
onMouseMove,
onMouseLeave,
edgeUpdaterRadius,
onEdgeUpdate,
onEdgeUpdateStart,
onEdgeUpdateEnd,
markerEnd,
markerStart,
rfId,
ariaLabel,
isFocusable,
isUpdatable,
pathOptions,
interactionWidth,
}: WrapEdgeProps): JSX.Element | null => {
const edgeRef = useRef<SVGGElement>(null);
const [updateHover, setUpdateHover] = useState<boolean>(false);
const [updating, setUpdating] = useState<boolean>(false);
const store = useStoreApi();
const edgePosition = useStore(
useCallback(
(state) => {
const sourceNode = state.nodeLookup.get(source);
const targetNode = state.nodeLookup.get(target);
if (!sourceNode || !targetNode) {
return null;
}
return getEdgePosition({
id,
sourceNode,
targetNode,
sourceHandle: sourceHandleId || null,
targetHandle: targetHandleId || null,
connectionMode: state.connectionMode,
onError: state.onError,
});
},
[source, target]
),
shallow
);
const markerStartUrl = useMemo(() => `url(#${getMarkerId(markerStart, rfId)})`, [markerStart, rfId]);
const markerEndUrl = useMemo(() => `url(#${getMarkerId(markerEnd, rfId)})`, [markerEnd, rfId]);
if (hidden || !edgePosition) {
return null;
}
const onEdgeClick = (event: React.MouseEvent<SVGGElement, MouseEvent>): void => {
const { edges, addSelectedEdges, unselectNodesAndEdges, multiSelectionActive } = store.getState();
const edge = edges.find((e) => e.id === id);
if (!edge) {
return;
}
if (isSelectable) {
store.setState({ nodesSelectionActive: false });
if (edge.selected && multiSelectionActive) {
unselectNodesAndEdges({ nodes: [], edges: [edge] });
edgeRef.current?.blur();
} else {
addSelectedEdges([id]);
}
}
if (onClick) {
onClick(event, edge);
}
};
const onEdgeDoubleClickHandler = getMouseHandler(id, store.getState, onEdgeDoubleClick);
const onEdgeContextMenu = getMouseHandler(id, store.getState, onContextMenu);
const onEdgeMouseEnter = getMouseHandler(id, store.getState, onMouseEnter);
const onEdgeMouseMove = getMouseHandler(id, store.getState, onMouseMove);
const onEdgeMouseLeave = getMouseHandler(id, store.getState, onMouseLeave);
const handleEdgeUpdater = (event: React.MouseEvent<SVGGElement, MouseEvent>, isSourceHandle: boolean) => {
// avoid triggering edge updater if mouse btn is not left
if (event.button !== 0) {
return;
}
const {
autoPanOnConnect,
domNode,
edges,
isValidConnection,
connectionMode,
connectionRadius,
lib,
onConnectStart,
onConnectEnd,
cancelConnection,
nodes,
panBy,
updateConnection,
} = store.getState();
const nodeId = isSourceHandle ? target : source;
const handleId = (isSourceHandle ? targetHandleId : sourceHandleId) || null;
const handleType = isSourceHandle ? 'target' : 'source';
const isTarget = isSourceHandle;
const edge = edges.find((e) => e.id === id)!;
setUpdating(true);
onEdgeUpdateStart?.(event, edge, handleType);
const _onEdgeUpdateEnd = (evt: MouseEvent | TouchEvent) => {
setUpdating(false);
onEdgeUpdateEnd?.(evt, edge, handleType);
};
const onConnectEdge = (connection: Connection) => onEdgeUpdate?.(edge, connection);
XYHandle.onPointerDown(event.nativeEvent, {
autoPanOnConnect,
connectionMode,
connectionRadius,
domNode,
handleId,
nodeId,
nodes,
isTarget,
edgeUpdaterType: handleType,
lib,
cancelConnection,
panBy,
isValidConnection,
onConnect: onConnectEdge,
onConnectStart,
onConnectEnd,
onEdgeUpdateEnd: _onEdgeUpdateEnd,
updateConnection,
getTransform: () => store.getState().transform,
});
};
const onEdgeUpdaterSourceMouseDown = (event: React.MouseEvent<SVGGElement, MouseEvent>): void =>
handleEdgeUpdater(event, true);
const onEdgeUpdaterTargetMouseDown = (event: React.MouseEvent<SVGGElement, MouseEvent>): void =>
handleEdgeUpdater(event, false);
const onEdgeUpdaterMouseEnter = () => setUpdateHover(true);
const onEdgeUpdaterMouseOut = () => setUpdateHover(false);
const inactive = !isSelectable && !onClick;
const onKeyDown = (event: KeyboardEvent) => {
if (elementSelectionKeys.includes(event.key) && isSelectable) {
const { unselectNodesAndEdges, addSelectedEdges, edges } = store.getState();
const unselect = event.key === 'Escape';
if (unselect) {
edgeRef.current?.blur();
unselectNodesAndEdges({ edges: [edges.find((e) => e.id === id)!] });
} else {
addSelectedEdges([id]);
}
}
};
return (
<svg style={{ zIndex }}>
<g
className={cc([
'react-flow__edge',
`react-flow__edge-${type}`,
className,
{ selected, animated, inactive, updating: updateHover },
])}
onClick={onEdgeClick}
onDoubleClick={onEdgeDoubleClickHandler}
onContextMenu={onEdgeContextMenu}
onMouseEnter={onEdgeMouseEnter}
onMouseMove={onEdgeMouseMove}
onMouseLeave={onEdgeMouseLeave}
onKeyDown={isFocusable ? onKeyDown : undefined}
tabIndex={isFocusable ? 0 : undefined}
role={isFocusable ? 'button' : 'img'}
data-id={id}
data-testid={`rf__edge-${id}`}
aria-label={ariaLabel === null ? undefined : ariaLabel ? ariaLabel : `Edge from ${source} to ${target}`}
aria-describedby={isFocusable ? `${ARIA_EDGE_DESC_KEY}-${rfId}` : undefined}
ref={edgeRef}
>
{!updating && (
<EdgeComponent
id={id}
source={source}
target={target}
selected={selected}
animated={animated}
label={label}
labelStyle={labelStyle}
labelShowBg={labelShowBg}
labelBgStyle={labelBgStyle}
labelBgPadding={labelBgPadding}
labelBgBorderRadius={labelBgBorderRadius}
data={data}
style={style}
sourceX={edgePosition.sourceX}
sourceY={edgePosition.sourceY}
targetX={edgePosition.targetX}
targetY={edgePosition.targetY}
sourcePosition={edgePosition.sourcePosition}
targetPosition={edgePosition.targetPosition}
sourceHandleId={sourceHandleId}
targetHandleId={targetHandleId}
markerStart={markerStartUrl}
markerEnd={markerEndUrl}
pathOptions={pathOptions}
interactionWidth={interactionWidth}
/>
)}
{isUpdatable && (
<>
{(isUpdatable === 'source' || isUpdatable === true) && (
<EdgeAnchor
position={edgePosition.sourcePosition}
centerX={edgePosition.sourceX}
centerY={edgePosition.sourceY}
radius={edgeUpdaterRadius}
onMouseDown={onEdgeUpdaterSourceMouseDown}
onMouseEnter={onEdgeUpdaterMouseEnter}
onMouseOut={onEdgeUpdaterMouseOut}
type="source"
/>
)}
{(isUpdatable === 'target' || isUpdatable === true) && (
<EdgeAnchor
position={edgePosition.targetPosition}
centerX={edgePosition.targetX}
centerY={edgePosition.targetY}
radius={edgeUpdaterRadius}
onMouseDown={onEdgeUpdaterTargetMouseDown}
onMouseEnter={onEdgeUpdaterMouseEnter}
onMouseOut={onEdgeUpdaterMouseOut}
type="target"
/>
)}
</>
)}
</g>
</svg>
);
};
EdgeWrapper.displayName = 'EdgeWrapper';
return memo(EdgeWrapper);
};
@@ -171,7 +171,7 @@ const Handle = forwardRef<HTMLDivElement, HandleComponentProps>(
lib, lib,
}); });
if (isValid) { if (isValid && connection) {
onConnectExtended(connection); onConnectExtended(connection);
} }
@@ -0,0 +1,256 @@
import { useEffect, useRef, memo, type MouseEvent, type KeyboardEvent } from 'react';
import cc from 'classcat';
import {
clampPosition,
elementSelectionKeys,
errorMessages,
getPositionWithOrigin,
internalsSymbol,
isInputDOMNode,
} from '@xyflow/system';
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 { handleNodeClick } from '../Nodes/utils';
import type { NodeWrapperProps } from '../../types';
import { arrowKeyDiffs, builtinNodeTypes } from './utils';
const NodeWrapper = ({
id,
onClick,
onMouseEnter,
onMouseMove,
onMouseLeave,
onContextMenu,
onDoubleClick,
nodesDraggable,
elementsSelectable,
nodesConnectable,
nodesFocusable,
resizeObserver,
noDragClassName,
noPanClassName,
disableKeyboardA11y,
rfId,
nodeTypes,
nodeExtent,
nodeOrigin,
onError,
}: NodeWrapperProps) => {
const node = useStore((s) => s.nodeLookup.get(id)!);
let nodeType = node.type || 'default';
let NodeComponent = nodeTypes?.[nodeType] || builtinNodeTypes[nodeType];
if (NodeComponent === undefined) {
onError?.('003', errorMessages['error003'](nodeType));
nodeType = 'default';
NodeComponent = builtinNodeTypes.default;
}
const isDraggable = !!(node.draggable || (nodesDraggable && typeof node.draggable === 'undefined'));
const isSelectable = !!(node.selectable || (elementsSelectable && typeof node.selectable === 'undefined'));
const isConnectable = !!(node.connectable || (nodesConnectable && typeof node.connectable === 'undefined'));
const isFocusable = !!(node.focusable || (nodesFocusable && typeof node.focusable === 'undefined'));
const store = useStoreApi();
const nodeRef = useRef<HTMLDivElement>(null);
const prevSourcePosition = useRef(node.sourcePosition);
const prevTargetPosition = useRef(node.targetPosition);
const prevType = useRef(nodeType);
const updatePositions = useUpdateNodePositions();
useEffect(() => {
if (nodeRef.current && !node.hidden) {
const currNode = nodeRef.current;
resizeObserver?.observe(currNode);
return () => resizeObserver?.unobserve(currNode);
}
}, [node.hidden]);
useEffect(() => {
// when the user programmatically changes the source or handle position, we re-initialize the node
const typeChanged = prevType.current !== nodeType;
const sourcePosChanged = prevSourcePosition.current !== node.sourcePosition;
const targetPosChanged = prevTargetPosition.current !== node.targetPosition;
if (nodeRef.current && (typeChanged || sourcePosChanged || targetPosChanged)) {
if (typeChanged) {
prevType.current = nodeType;
}
if (sourcePosChanged) {
prevSourcePosition.current = node.sourcePosition;
}
if (targetPosChanged) {
prevTargetPosition.current = node.targetPosition;
}
store.getState().updateNodeDimensions(new Map([[id, { id, nodeElement: nodeRef.current, forceUpdate: true }]]));
}
}, [id, nodeType, node.sourcePosition, node.targetPosition]);
const dragging = useDrag({
nodeRef,
disabled: node.hidden || !isDraggable,
noDragClassName,
handleSelector: node.dragHandle,
nodeId: id,
isSelectable,
});
if (node.hidden) {
return null;
}
const width = node.width ?? undefined;
const height = node.height ?? undefined;
const computedWidth = node.computed?.width;
const computedHeight = node.computed?.height;
const positionAbsolute = nodeExtent
? clampPosition(node.computed?.positionAbsolute, nodeExtent)
: node.computed?.positionAbsolute || { x: 0, y: 0 };
const positionAbsoluteOrigin = getPositionWithOrigin({
x: positionAbsolute.x,
y: positionAbsolute.y,
width: computedWidth ?? width ?? 0,
height: computedHeight ?? height ?? 0,
origin: node.origin || nodeOrigin,
});
const initialized = (!!computedWidth && !!computedHeight) || (!!width && !!height);
const zIndex = node[internalsSymbol]?.z ?? 0;
const isParent = !!node[internalsSymbol]?.isParent;
const hasPointerEvents = isSelectable || isDraggable || onClick || onMouseEnter || onMouseMove || onMouseLeave;
const onMouseEnterHandler =
onMouseEnter === undefined ? undefined : (event: MouseEvent) => onMouseEnter(event, { ...node });
const onMouseMoveHandler =
onMouseMove === undefined ? undefined : (event: MouseEvent) => onMouseMove(event, { ...node });
const onMouseLeaveHandler =
onMouseLeave === undefined ? undefined : (event: MouseEvent) => onMouseLeave(event, { ...node });
const onContextMenuHandler =
onContextMenu === undefined ? undefined : (event: MouseEvent) => onContextMenu(event, { ...node });
const onDoubleClickHandler =
onDoubleClick === undefined ? undefined : (event: MouseEvent) => onDoubleClick(event, { ...node });
const onSelectNodeHandler = (event: MouseEvent) => {
const { selectNodesOnDrag, nodeDragThreshold } = store.getState();
if (isSelectable && (!selectNodesOnDrag || !isDraggable || nodeDragThreshold > 0)) {
// this handler gets called by XYDrag on drag start when selectNodesOnDrag=true
// here we only need to call it when selectNodesOnDrag=false
handleNodeClick({
id,
store,
nodeRef,
});
}
if (onClick) {
onClick(event, { ...node });
}
};
const onKeyDown = (event: KeyboardEvent) => {
if (isInputDOMNode(event.nativeEvent)) {
return;
}
if (elementSelectionKeys.includes(event.key) && isSelectable) {
const unselect = event.key === 'Escape';
handleNodeClick({
id,
store,
unselect,
nodeRef,
});
} else if (
!disableKeyboardA11y &&
isDraggable &&
node.selected &&
Object.prototype.hasOwnProperty.call(arrowKeyDiffs, event.key)
) {
store.setState({
ariaLiveMessage: `Moved selected node ${event.key
.replace('Arrow', '')
.toLowerCase()}. New position, x: ${~~positionAbsolute.x}, y: ${~~positionAbsolute.y}`,
});
updatePositions({
x: arrowKeyDiffs[event.key].x,
y: arrowKeyDiffs[event.key].y,
isShiftPressed: event.shiftKey,
});
}
};
return (
<div
className={cc([
'react-flow__node',
`react-flow__node-${nodeType}`,
{
// this is overwritable by passing `nopan` as a class name
[noPanClassName]: isDraggable,
},
node.className,
{
selected: node.selected,
selectable: isSelectable,
parent: isParent,
dragging,
},
])}
ref={nodeRef}
style={{
zIndex,
transform: `translate(${positionAbsoluteOrigin.x}px,${positionAbsoluteOrigin.y}px)`,
pointerEvents: hasPointerEvents ? 'all' : 'none',
visibility: initialized ? 'visible' : 'hidden',
width,
height,
...node.style,
}}
data-id={id}
data-testid={`rf__node-${id}`}
onMouseEnter={onMouseEnterHandler}
onMouseMove={onMouseMoveHandler}
onMouseLeave={onMouseLeaveHandler}
onContextMenu={onContextMenuHandler}
onClick={onSelectNodeHandler}
onDoubleClick={onDoubleClickHandler}
onKeyDown={isFocusable ? onKeyDown : undefined}
tabIndex={isFocusable ? 0 : undefined}
role={isFocusable ? 'button' : undefined}
aria-describedby={disableKeyboardA11y ? undefined : `${ARIA_NODE_DESC_KEY}-${rfId}`}
aria-label={node.ariaLabel}
>
<Provider value={id}>
<NodeComponent
id={id}
data={node.data}
type={nodeType}
width={computedWidth}
height={computedHeight}
positionAbsoluteX={positionAbsolute.x}
positionAbsoluteY={positionAbsolute.y}
selected={node.selected}
isConnectable={isConnectable}
sourcePosition={node.sourcePosition}
targetPosition={node.targetPosition}
dragging={dragging}
dragHandle={node.dragHandle}
zIndex={zIndex}
/>
</Provider>
</div>
);
};
NodeWrapper.displayName = 'NodeWrapper';
export default memo(NodeWrapper);
@@ -0,0 +1,22 @@
import type { ComponentType } from 'react';
import type { NodeProps, XYPosition } from '@xyflow/system';
import InputNode from '../Nodes/InputNode';
import DefaultNode from '../Nodes/DefaultNode';
import GroupNode from '../Nodes/GroupNode';
import OutputNode from '../Nodes/OutputNode';
import type { NodeTypes } from '../../types';
export const arrowKeyDiffs: Record<string, XYPosition> = {
ArrowUp: { x: 0, y: -1 },
ArrowDown: { x: 0, y: 1 },
ArrowLeft: { x: -1, y: 0 },
ArrowRight: { x: 1, y: 0 },
};
export const builtinNodeTypes: NodeTypes = {
input: InputNode as ComponentType<NodeProps>,
default: DefaultNode as ComponentType<NodeProps>,
output: OutputNode as ComponentType<NodeProps>,
group: GroupNode as ComponentType<NodeProps>,
};
+2 -15
View File
@@ -1,22 +1,9 @@
import type { MouseEvent, RefObject } from 'react'; import type { RefObject } from 'react';
import type { StoreApi } from 'zustand'; import type { StoreApi } from 'zustand';
import type { Node, ReactFlowState } from '../../types'; import type { ReactFlowState } from '../../types';
import { errorMessages } from '@xyflow/system'; import { errorMessages } from '@xyflow/system';
export function getMouseHandler(
id: string,
getState: StoreApi<ReactFlowState>['getState'],
handler?: (event: MouseEvent, node: Node) => void
) {
return handler === undefined
? handler
: (event: MouseEvent) => {
const node = getState().nodeLookup.get(id)!;
handler(event, { ...node });
};
}
// this handler is called by // this handler is called by
// 1. the click handler when node is not draggable or selectNodesOnDrag = false // 1. the click handler when node is not draggable or selectNodesOnDrag = false
// or // or
@@ -1,230 +0,0 @@
import { useEffect, useRef, memo, type ComponentType, type MouseEvent, type KeyboardEvent } from 'react';
import cc from 'classcat';
import { elementSelectionKeys, isInputDOMNode, type NodeProps, type XYPosition } from '@xyflow/system';
import { 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 { getMouseHandler, handleNodeClick } from './utils';
import type { WrapNodeProps } from '../../types';
export const arrowKeyDiffs: Record<string, XYPosition> = {
ArrowUp: { x: 0, y: -1 },
ArrowDown: { x: 0, y: 1 },
ArrowLeft: { x: -1, y: 0 },
ArrowRight: { x: 1, y: 0 },
};
export default (NodeComponent: ComponentType<NodeProps>) => {
const NodeWrapper = ({
id,
type,
data,
xPos,
yPos,
xPosOrigin,
yPosOrigin,
selected,
onClick,
onMouseEnter,
onMouseMove,
onMouseLeave,
onContextMenu,
onDoubleClick,
style,
className,
isDraggable,
isSelectable,
isConnectable,
isFocusable,
sourcePosition,
targetPosition,
hidden,
resizeObserver,
dragHandle,
zIndex,
isParent,
noDragClassName,
noPanClassName,
initialized,
disableKeyboardA11y,
ariaLabel,
rfId,
sizeWidth,
sizeHeight,
}: WrapNodeProps) => {
const store = useStoreApi();
const nodeRef = useRef<HTMLDivElement>(null);
const prevSourcePosition = useRef(sourcePosition);
const prevTargetPosition = useRef(targetPosition);
const prevType = useRef(type);
const hasPointerEvents = isSelectable || isDraggable || onClick || onMouseEnter || onMouseMove || onMouseLeave;
const updatePositions = useUpdateNodePositions();
const onMouseEnterHandler = getMouseHandler(id, store.getState, onMouseEnter);
const onMouseMoveHandler = getMouseHandler(id, store.getState, onMouseMove);
const onMouseLeaveHandler = getMouseHandler(id, store.getState, onMouseLeave);
const onContextMenuHandler = getMouseHandler(id, store.getState, onContextMenu);
const onDoubleClickHandler = getMouseHandler(id, store.getState, onDoubleClick);
const onSelectNodeHandler = (event: MouseEvent) => {
const { selectNodesOnDrag, nodeDragThreshold } = store.getState();
if (isSelectable && (!selectNodesOnDrag || !isDraggable || nodeDragThreshold > 0)) {
// this handler gets called by XYDrag on drag start when selectNodesOnDrag=true
// here we only need to call it when selectNodesOnDrag=false
handleNodeClick({
id,
store,
nodeRef,
});
}
if (onClick) {
const node = store.getState().nodes.find((n) => n.id === id)!;
onClick(event, { ...node });
}
};
const onKeyDown = (event: KeyboardEvent) => {
if (isInputDOMNode(event.nativeEvent)) {
return;
}
if (elementSelectionKeys.includes(event.key) && isSelectable) {
const unselect = event.key === 'Escape';
handleNodeClick({
id,
store,
unselect,
nodeRef,
});
} else if (
!disableKeyboardA11y &&
isDraggable &&
selected &&
Object.prototype.hasOwnProperty.call(arrowKeyDiffs, event.key)
) {
store.setState({
ariaLiveMessage: `Moved selected node ${event.key
.replace('Arrow', '')
.toLowerCase()}. New position, x: ${~~xPos}, y: ${~~yPos}`,
});
updatePositions({
x: arrowKeyDiffs[event.key].x,
y: arrowKeyDiffs[event.key].y,
isShiftPressed: event.shiftKey,
});
}
};
useEffect(() => {
if (nodeRef.current && !hidden) {
const currNode = nodeRef.current;
resizeObserver?.observe(currNode);
return () => resizeObserver?.unobserve(currNode);
}
}, [hidden]);
useEffect(() => {
// when the user programmatically changes the source or handle position, we re-initialize the node
const typeChanged = prevType.current !== type;
const sourcePosChanged = prevSourcePosition.current !== sourcePosition;
const targetPosChanged = prevTargetPosition.current !== targetPosition;
if (nodeRef.current && (typeChanged || sourcePosChanged || targetPosChanged)) {
if (typeChanged) {
prevType.current = type;
}
if (sourcePosChanged) {
prevSourcePosition.current = sourcePosition;
}
if (targetPosChanged) {
prevTargetPosition.current = targetPosition;
}
store.getState().updateNodeDimensions(new Map([[id, { id, nodeElement: nodeRef.current, forceUpdate: true }]]));
}
}, [id, type, sourcePosition, targetPosition]);
const dragging = useDrag({
nodeRef,
disabled: hidden || !isDraggable,
noDragClassName,
handleSelector: dragHandle,
nodeId: id,
isSelectable,
});
if (hidden) {
return null;
}
return (
<div
className={cc([
'react-flow__node',
`react-flow__node-${type}`,
{
// this is overwritable by passing `nopan` as a class name
[noPanClassName]: isDraggable,
},
className,
{
selected,
selectable: isSelectable,
parent: isParent,
dragging,
},
])}
ref={nodeRef}
style={{
zIndex,
transform: `translate(${xPosOrigin}px,${yPosOrigin}px)`,
pointerEvents: hasPointerEvents ? 'all' : 'none',
visibility: initialized ? 'visible' : 'hidden',
width: sizeWidth,
height: sizeHeight,
...style,
}}
data-id={id}
data-testid={`rf__node-${id}`}
onMouseEnter={onMouseEnterHandler}
onMouseMove={onMouseMoveHandler}
onMouseLeave={onMouseLeaveHandler}
onContextMenu={onContextMenuHandler}
onClick={onSelectNodeHandler}
onDoubleClick={onDoubleClickHandler}
onKeyDown={isFocusable ? onKeyDown : undefined}
tabIndex={isFocusable ? 0 : undefined}
role={isFocusable ? 'button' : undefined}
aria-describedby={disableKeyboardA11y ? undefined : `${ARIA_NODE_DESC_KEY}-${rfId}`}
aria-label={ariaLabel}
>
<Provider value={id}>
<NodeComponent
id={id}
data={data}
type={type}
xPos={xPos}
yPos={yPos}
selected={selected}
isConnectable={isConnectable}
sourcePosition={sourcePosition}
targetPosition={targetPosition}
dragging={dragging}
dragHandle={dragHandle}
zIndex={zIndex}
/>
</Provider>
</div>
);
};
NodeWrapper.displayName = 'NodeWrapper';
return memo(NodeWrapper);
};
@@ -10,9 +10,9 @@ import { getNodesBounds } from '@xyflow/system';
import { useStore, useStoreApi } from '../../hooks/useStore'; import { useStore, useStoreApi } from '../../hooks/useStore';
import useDrag from '../../hooks/useDrag'; import useDrag from '../../hooks/useDrag';
import { arrowKeyDiffs } from '../Nodes/wrapNode';
import useUpdateNodePositions from '../../hooks/useUpdateNodePositions'; import useUpdateNodePositions from '../../hooks/useUpdateNodePositions';
import type { Node, ReactFlowState } from '../../types'; import type { Node, ReactFlowState } from '../../types';
import { arrowKeyDiffs } from '../NodeWrapper/utils';
export type NodesSelectionProps = { export type NodesSelectionProps = {
onSelectionContextMenu?: (event: MouseEvent, nodes: Node[]) => void; onSelectionContextMenu?: (event: MouseEvent, nodes: Node[]) => void;
@@ -3,67 +3,72 @@
* We distinguish between values we can update directly with `useDirectStoreUpdater` (like `snapGrid`) * We distinguish between values we can update directly with `useDirectStoreUpdater` (like `snapGrid`)
* and values that have a dedicated setter function in the store (like `setNodes`). * and values that have a dedicated setter function in the store (like `setNodes`).
*/ */
import { useEffect } from 'react'; import { useEffect, useRef } from 'react';
import { StoreApi } from 'zustand';
import { shallow } from 'zustand/shallow'; import { shallow } from 'zustand/shallow';
import { devWarn, type CoordinateExtent } from '@xyflow/system'; import { type CoordinateExtent } from '@xyflow/system';
import { useStore, useStoreApi } from '../../hooks/useStore'; import { useStore, useStoreApi } from '../../hooks/useStore';
import type { Node, Edge, ReactFlowState, ReactFlowProps, ReactFlowStore } from '../../types'; import type { Node, Edge, ReactFlowState, ReactFlowProps, FitViewOptions } from '../../types';
type StoreUpdaterProps = Pick< // these fields exist in the global store and we need to keep them up to date
ReactFlowProps, const reactFlowFieldsToTrack = [
| 'nodes' 'nodes',
| 'edges' 'edges',
| 'defaultNodes' 'defaultNodes',
| 'defaultEdges' 'defaultEdges',
| 'onConnect' 'onConnect',
| 'onConnectStart' 'onConnectStart',
| 'onConnectEnd' 'onConnectEnd',
| 'onClickConnectStart' 'onClickConnectStart',
| 'onClickConnectEnd' 'onClickConnectEnd',
| 'nodesDraggable' 'nodesDraggable',
| 'nodesConnectable' 'nodesConnectable',
| 'nodesFocusable' 'nodesFocusable',
| 'edgesFocusable' 'edgesFocusable',
| 'edgesUpdatable' 'edgesUpdatable',
| 'minZoom' 'elevateNodesOnSelect',
| 'maxZoom' 'minZoom',
| 'nodeExtent' 'maxZoom',
| 'onNodesChange' 'nodeExtent',
| 'onEdgesChange' 'onNodesChange',
| 'elementsSelectable' 'onEdgesChange',
| 'connectionMode' 'elementsSelectable',
| 'snapToGrid' 'connectionMode',
| 'snapGrid' 'snapGrid',
| 'translateExtent' 'snapToGrid',
| 'connectOnClick' 'translateExtent',
| 'defaultEdgeOptions' 'connectOnClick',
| 'fitView' 'defaultEdgeOptions',
| 'fitViewOptions' 'fitView',
| 'onNodesDelete' 'fitViewOptions',
| 'onEdgesDelete' 'onNodesDelete',
| 'onDelete' 'onEdgesDelete',
| 'onNodeDragStart' 'onDelete',
| 'onNodeDrag' 'onNodeDrag',
| 'onNodeDragStop' 'onNodeDragStart',
| 'onSelectionDragStart' 'onNodeDragStop',
| 'onSelectionDrag' 'onSelectionDrag',
| 'onSelectionDragStop' 'onSelectionDragStart',
| 'onMove' 'onSelectionDragStop',
| 'onMoveStart' 'onMoveStart',
| 'onMoveEnd' 'onMove',
| 'noPanClassName' 'onMoveEnd',
| 'nodeOrigin' 'noPanClassName',
| 'elevateNodesOnSelect' 'nodeOrigin',
| 'autoPanOnConnect' 'autoPanOnConnect',
| 'autoPanOnNodeDrag' 'autoPanOnNodeDrag',
| 'onError' 'onError',
| 'connectionRadius' 'connectionRadius',
| 'isValidConnection' 'isValidConnection',
| 'selectNodesOnDrag' 'selectNodesOnDrag',
| 'nodeDragThreshold' 'nodeDragThreshold',
> & { rfId: string }; ] as const;
type ReactFlowFieldsToTrack = (typeof reactFlowFieldsToTrack)[number];
type StoreUpdaterProps = Pick<ReactFlowProps, ReactFlowFieldsToTrack> & { rfId: string };
// rfId doesn't exist in ReactFlowProps, but it's one of the fields we want to update
const fieldsToTrack = [...reactFlowFieldsToTrack, 'rfId'] as const;
const selector = (s: ReactFlowState) => ({ const selector = (s: ReactFlowState) => ({
setNodes: s.setNodes, setNodes: s.setNodes,
@@ -76,80 +81,7 @@ const selector = (s: ReactFlowState) => ({
reset: s.reset, reset: s.reset,
}); });
function useStoreUpdater<T>(value: T | undefined, setStoreAction: (param: T) => void) { const StoreUpdater = (props: StoreUpdaterProps) => {
useEffect(() => {
if (typeof value !== 'undefined') {
setStoreAction(value);
}
}, [value]);
}
// updates with values in store that don't have a dedicated setter function
function useDirectStoreUpdater(
key: keyof ReactFlowStore,
value: unknown,
setState: StoreApi<ReactFlowState>['setState']
) {
useEffect(() => {
if (typeof value !== 'undefined') {
setState({ [key]: value });
}
}, [value]);
}
const StoreUpdater = ({
nodes,
edges,
defaultNodes,
defaultEdges,
onConnect,
onConnectStart,
onConnectEnd,
onClickConnectStart,
onClickConnectEnd,
nodesDraggable,
nodesConnectable,
nodesFocusable,
edgesFocusable,
edgesUpdatable,
elevateNodesOnSelect,
minZoom,
maxZoom,
nodeExtent,
onNodesChange,
onEdgesChange,
elementsSelectable,
connectionMode,
snapGrid,
snapToGrid,
translateExtent,
connectOnClick,
defaultEdgeOptions,
fitView,
fitViewOptions,
onNodesDelete,
onEdgesDelete,
onDelete,
onNodeDrag,
onNodeDragStart,
onNodeDragStop,
onSelectionDrag,
onSelectionDragStart,
onSelectionDragStop,
onMoveStart,
onMove,
onMoveEnd,
noPanClassName,
nodeOrigin,
rfId,
autoPanOnConnect,
autoPanOnNodeDrag,
onError,
connectionRadius,
isValidConnection,
selectNodesOnDrag,
nodeDragThreshold,
}: StoreUpdaterProps) => {
const { const {
setNodes, setNodes,
setEdges, setEdges,
@@ -163,64 +95,44 @@ const StoreUpdater = ({
const store = useStoreApi(); const store = useStoreApi();
useEffect(() => { useEffect(() => {
const edgesWithDefaults = defaultEdges?.map((e) => ({ ...e, ...defaultEdgeOptions })); const edgesWithDefaults = props.defaultEdges?.map((e) => ({ ...e, ...props.defaultEdgeOptions }));
setDefaultNodesAndEdges(defaultNodes, edgesWithDefaults); setDefaultNodesAndEdges(props.defaultNodes, edgesWithDefaults);
return () => { return () => {
reset(); reset();
}; };
}, []); }, []);
useDirectStoreUpdater('defaultEdgeOptions', defaultEdgeOptions, store.setState); const previousFields = useRef<Partial<StoreUpdaterProps>>({});
useDirectStoreUpdater('connectionMode', connectionMode, store.setState);
useDirectStoreUpdater('onConnect', onConnect, store.setState);
useDirectStoreUpdater('onConnectStart', onConnectStart, store.setState);
useDirectStoreUpdater('onConnectEnd', onConnectEnd, store.setState);
useDirectStoreUpdater('onClickConnectStart', onClickConnectStart, store.setState);
useDirectStoreUpdater('onClickConnectEnd', onClickConnectEnd, store.setState);
useDirectStoreUpdater('nodesDraggable', nodesDraggable, store.setState);
useDirectStoreUpdater('nodesConnectable', nodesConnectable, store.setState);
useDirectStoreUpdater('nodesFocusable', nodesFocusable, store.setState);
useDirectStoreUpdater('edgesFocusable', edgesFocusable, store.setState);
useDirectStoreUpdater('edgesUpdatable', edgesUpdatable, store.setState);
useDirectStoreUpdater('elementsSelectable', elementsSelectable, store.setState);
useDirectStoreUpdater('elevateNodesOnSelect', elevateNodesOnSelect, store.setState);
useDirectStoreUpdater('snapToGrid', snapToGrid, store.setState);
useDirectStoreUpdater('snapGrid', snapGrid, store.setState);
useDirectStoreUpdater('onNodesChange', onNodesChange, store.setState);
useDirectStoreUpdater('onEdgesChange', onEdgesChange, store.setState);
useDirectStoreUpdater('connectOnClick', connectOnClick, store.setState);
useDirectStoreUpdater('fitViewOnInit', fitView, store.setState);
useDirectStoreUpdater('fitViewOnInitOptions', fitViewOptions, store.setState);
useDirectStoreUpdater('onNodesDelete', onNodesDelete, store.setState);
useDirectStoreUpdater('onEdgesDelete', onEdgesDelete, store.setState);
useDirectStoreUpdater('onDelete', onDelete, store.setState);
useDirectStoreUpdater('onNodeDrag', onNodeDrag, store.setState);
useDirectStoreUpdater('onNodeDragStart', onNodeDragStart, store.setState);
useDirectStoreUpdater('onNodeDragStop', onNodeDragStop, store.setState);
useDirectStoreUpdater('onSelectionDrag', onSelectionDrag, store.setState);
useDirectStoreUpdater('onSelectionDragStart', onSelectionDragStart, store.setState);
useDirectStoreUpdater('onSelectionDragStop', onSelectionDragStop, store.setState);
useDirectStoreUpdater('onMove', onMove, store.setState);
useDirectStoreUpdater('onMoveStart', onMoveStart, store.setState);
useDirectStoreUpdater('onMoveEnd', onMoveEnd, store.setState);
useDirectStoreUpdater('noPanClassName', noPanClassName, store.setState);
useDirectStoreUpdater('nodeOrigin', nodeOrigin, store.setState);
useDirectStoreUpdater('rfId', rfId, store.setState);
useDirectStoreUpdater('autoPanOnConnect', autoPanOnConnect, store.setState);
useDirectStoreUpdater('autoPanOnNodeDrag', autoPanOnNodeDrag, store.setState);
useDirectStoreUpdater('onError', onError || devWarn, store.setState);
useDirectStoreUpdater('connectionRadius', connectionRadius, store.setState);
useDirectStoreUpdater('isValidConnection', isValidConnection, store.setState);
useDirectStoreUpdater('selectNodesOnDrag', selectNodesOnDrag, store.setState);
useDirectStoreUpdater('nodeDragThreshold', nodeDragThreshold, store.setState);
useStoreUpdater<Node[]>(nodes, setNodes); useEffect(
useStoreUpdater<Edge[]>(edges, setEdges); () => {
useStoreUpdater<number>(minZoom, setMinZoom); for (const fieldName of fieldsToTrack) {
useStoreUpdater<number>(maxZoom, setMaxZoom); const fieldValue = props[fieldName];
useStoreUpdater<CoordinateExtent>(translateExtent, setTranslateExtent); const previousFieldValue = previousFields.current[fieldName];
useStoreUpdater<CoordinateExtent>(nodeExtent, setNodeExtent);
if (fieldValue === previousFieldValue) continue;
if (typeof props[fieldName] === 'undefined') continue;
// Custom handling with dedicated setters for some fields
if (fieldName === 'nodes') setNodes(fieldValue as Node[]);
else if (fieldName === 'edges') setEdges(fieldValue as Edge[]);
else if (fieldName === 'minZoom') setMinZoom(fieldValue as number);
else if (fieldName === 'maxZoom') setMaxZoom(fieldValue as number);
else if (fieldName === 'translateExtent') setTranslateExtent(fieldValue as CoordinateExtent);
else if (fieldName === 'nodeExtent') setNodeExtent(fieldValue as CoordinateExtent);
// Renamed fields
else if (fieldName === 'fitView') store.setState({ fitViewOnInit: fieldValue as boolean });
else if (fieldName === 'fitViewOptions') store.setState({ fitViewOnInitOptions: fieldValue as FitViewOptions });
// General case
else store.setState({ [fieldName]: fieldValue });
}
previousFields.current = props;
},
// Only re-run the effect if one of the fields we track changes
fieldsToTrack.map((fieldName) => props[fieldName])
);
return null; return null;
}; };
@@ -1,13 +1,13 @@
import { memo, ReactNode } from 'react'; import { memo, ReactNode } from 'react';
import { shallow } from 'zustand/shallow'; import { shallow } from 'zustand/shallow';
import cc from 'classcat'; import cc from 'classcat';
import { errorMessages } from '@xyflow/system';
import { useStore } from '../../hooks/useStore'; import { useStore } from '../../hooks/useStore';
import useVisibleEdges from '../../hooks/useVisibleEdges'; import useVisibleEdges from '../../hooks/useVisibleEdges';
import MarkerDefinitions from './MarkerDefinitions'; import MarkerDefinitions from './MarkerDefinitions';
import { GraphViewProps } from '../GraphView'; import { GraphViewProps } from '../GraphView';
import type { EdgeTypesWrapped, ReactFlowState } from '../../types'; import type { ReactFlowState } from '../../types';
import EdgeWrapper from '../../components/EdgeWrapper';
type EdgeRendererProps = Pick< type EdgeRendererProps = Pick<
GraphViewProps, GraphViewProps,
@@ -27,8 +27,8 @@ type EdgeRendererProps = Pick<
| 'elevateEdgesOnSelect' | 'elevateEdgesOnSelect'
| 'rfId' | 'rfId'
| 'disableKeyboardA11y' | 'disableKeyboardA11y'
| 'edgeTypes'
> & { > & {
edgeTypes: EdgeTypesWrapped;
elevateEdgesOnSelect: boolean; elevateEdgesOnSelect: boolean;
children: ReactNode; children: ReactNode;
}; };
@@ -72,14 +72,6 @@ const EdgeRenderer = ({
</svg> </svg>
{edges.map((edge) => { {edges.map((edge) => {
let edgeType = edge.type || 'default';
if (!edgeTypes[edgeType]) {
onError?.('011', errorMessages['error011'](edgeType));
edgeType = 'default';
}
const EdgeComponent = edgeTypes[edgeType];
const isFocusable = !!(edge.focusable || (edgesFocusable && typeof edge.focusable === 'undefined')); const isFocusable = !!(edge.focusable || (edgesFocusable && typeof edge.focusable === 'undefined'));
const isUpdatable = const isUpdatable =
typeof onEdgeUpdate !== 'undefined' && typeof onEdgeUpdate !== 'undefined' &&
@@ -87,15 +79,16 @@ const EdgeRenderer = ({
const isSelectable = !!(edge.selectable || (elementsSelectable && typeof edge.selectable === 'undefined')); const isSelectable = !!(edge.selectable || (elementsSelectable && typeof edge.selectable === 'undefined'));
return ( return (
<EdgeComponent <EdgeWrapper
key={edge.id} key={edge.id}
id={edge.id} id={edge.id}
className={cc([edge.className, noPanClassName])} className={cc([edge.className, noPanClassName])}
type={edgeType} type={edge.type}
data={edge.data} data={edge.data}
selected={!!edge.selected} selected={!!edge.selected}
animated={!!edge.animated} animated={!!edge.animated}
hidden={!!edge.hidden} hidden={!!edge.hidden}
zIndex={edge.zIndex}
label={edge.label} label={edge.label}
labelStyle={edge.labelStyle} labelStyle={edge.labelStyle}
labelShowBg={edge.labelShowBg} labelShowBg={edge.labelShowBg}
@@ -103,7 +96,6 @@ const EdgeRenderer = ({
labelBgPadding={edge.labelBgPadding} labelBgPadding={edge.labelBgPadding}
labelBgBorderRadius={edge.labelBgBorderRadius} labelBgBorderRadius={edge.labelBgBorderRadius}
style={edge.style} style={edge.style}
zIndex={edge.zIndex}
source={edge.source} source={edge.source}
target={edge.target} target={edge.target}
sourceHandleId={edge.sourceHandle} sourceHandleId={edge.sourceHandle}
@@ -127,10 +119,11 @@ const EdgeRenderer = ({
isUpdatable={isUpdatable} isUpdatable={isUpdatable}
pathOptions={'pathOptions' in edge ? edge.pathOptions : undefined} pathOptions={'pathOptions' in edge ? edge.pathOptions : undefined}
interactionWidth={edge.interactionWidth} interactionWidth={edge.interactionWidth}
onError={onError}
edgeTypes={edgeTypes}
/> />
); );
})} })}
{children} {children}
</div> </div>
); );
@@ -1,31 +0,0 @@
import type { ComponentType } from 'react';
import { BezierEdge, SmoothStepEdge, StepEdge, StraightEdge, SimpleBezierEdge } from '../../components/Edges';
import wrapEdge from '../../components/Edges/wrapEdge';
import type { EdgeProps, EdgeTypes, EdgeTypesWrapped } from '../../types';
export type CreateEdgeTypes = (edgeTypes: EdgeTypes) => EdgeTypesWrapped;
export function createEdgeTypes(edgeTypes: EdgeTypes): EdgeTypesWrapped {
const standardTypes: EdgeTypesWrapped = {
default: wrapEdge((edgeTypes.default || BezierEdge) as ComponentType<EdgeProps>),
straight: wrapEdge((edgeTypes.bezier || StraightEdge) as ComponentType<EdgeProps>),
step: wrapEdge((edgeTypes.step || StepEdge) as ComponentType<EdgeProps>),
smoothstep: wrapEdge((edgeTypes.step || SmoothStepEdge) as ComponentType<EdgeProps>),
simplebezier: wrapEdge((edgeTypes.simplebezier || SimpleBezierEdge) as ComponentType<EdgeProps>),
};
const wrappedTypes = {} as EdgeTypesWrapped;
const specialTypes: EdgeTypesWrapped = Object.keys(edgeTypes)
.filter((k) => !['default', 'bezier'].includes(k))
.reduce((res, key) => {
res[key] = wrapEdge((edgeTypes[key] || BezierEdge) as ComponentType<EdgeProps>);
return res;
}, wrappedTypes);
return {
...standardTypes,
...specialTypes,
};
}
@@ -8,19 +8,15 @@ import useOnInitHandler from '../../hooks/useOnInitHandler';
import useViewportSync from '../../hooks/useViewportSync'; import useViewportSync from '../../hooks/useViewportSync';
import ConnectionLine from '../../components/ConnectionLine'; import ConnectionLine from '../../components/ConnectionLine';
import type { ReactFlowProps } from '../../types'; import type { ReactFlowProps } from '../../types';
import { createNodeTypes } from '../NodeRenderer/utils'; import useNodeOrEdgeTypesWarning from './useNodeOrEdgeTypesWarning';
import { createEdgeTypes } from '../EdgeRenderer/utils';
import { useNodeOrEdgeTypes } from './utils';
export type GraphViewProps = Omit< export type GraphViewProps = Omit<
ReactFlowProps, ReactFlowProps,
'onSelectionChange' | 'nodes' | 'edges' | 'nodeTypes' | 'edgeTypes' | 'onMove' | 'onMoveStart' | 'onMoveEnd' 'onSelectionChange' | 'nodes' | 'edges' | 'onMove' | 'onMoveStart' | 'onMoveEnd'
> & > &
Required< Required<
Pick< Pick<
ReactFlowProps, ReactFlowProps,
| 'nodeTypes'
| 'edgeTypes'
| 'selectionKeyCode' | 'selectionKeyCode'
| 'deleteKeyCode' | 'deleteKeyCode'
| 'multiSelectionKeyCode' | 'multiSelectionKeyCode'
@@ -108,8 +104,8 @@ const GraphView = ({
viewport, viewport,
onViewportChange, onViewportChange,
}: GraphViewProps) => { }: GraphViewProps) => {
const nodeTypesWrapped = useNodeOrEdgeTypes(nodeTypes, createNodeTypes); useNodeOrEdgeTypesWarning(nodeTypes);
const edgeTypesWrapped = useNodeOrEdgeTypes(edgeTypes, createEdgeTypes); useNodeOrEdgeTypesWarning(edgeTypes);
useOnInitHandler(onInit); useOnInitHandler(onInit);
useViewportSync(viewport); useViewportSync(viewport);
@@ -154,7 +150,7 @@ const GraphView = ({
> >
<ViewportWrapper> <ViewportWrapper>
<EdgeRenderer <EdgeRenderer
edgeTypes={edgeTypesWrapped} edgeTypes={edgeTypes}
onEdgeClick={onEdgeClick} onEdgeClick={onEdgeClick}
onEdgeDoubleClick={onEdgeDoubleClick} onEdgeDoubleClick={onEdgeDoubleClick}
onEdgeUpdate={onEdgeUpdate} onEdgeUpdate={onEdgeUpdate}
@@ -182,7 +178,7 @@ const GraphView = ({
<div className="react-flow__edgelabel-renderer" /> <div className="react-flow__edgelabel-renderer" />
<NodeRenderer <NodeRenderer
nodeTypes={nodeTypesWrapped} nodeTypes={nodeTypes}
onNodeClick={onNodeClick} onNodeClick={onNodeClick}
onNodeDoubleClick={onNodeDoubleClick} onNodeDoubleClick={onNodeDoubleClick}
onNodeMouseEnter={onNodeMouseEnter} onNodeMouseEnter={onNodeMouseEnter}
@@ -0,0 +1,27 @@
import { useEffect, useRef } from 'react';
import { errorMessages } from '@xyflow/system';
import type { EdgeTypes, NodeTypes } from '../../types';
import { useStoreApi } from '../../hooks/useStore';
const emptyTypes = {};
/*
* This hook warns the user if node or edgeTypes change.
*/
export function useNodeOrEdgeTypesWarning(nodeOrEdgeTypes?: NodeTypes): void;
export function useNodeOrEdgeTypesWarning(nodeOrEdgeTypes?: EdgeTypes): void;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export default function useNodeOrEdgeTypesWarning(nodeOrEdgeTypes: any = emptyTypes): any {
const updateCount = useRef(0);
const store = useStoreApi();
useEffect(() => {
if (process.env.NODE_ENV === 'development') {
if (updateCount.current > 1) {
store.getState().onError?.('002', errorMessages['error002']());
}
updateCount.current += 1;
}
}, [nodeOrEdgeTypes]);
}
@@ -1,31 +0,0 @@
import { useMemo, useRef } from 'react';
import { shallow } from 'zustand/shallow';
import { errorMessages } from '@xyflow/system';
import { CreateEdgeTypes } from '../EdgeRenderer/utils';
import { CreateNodeTypes } from '../NodeRenderer/utils';
import type { EdgeTypes, EdgeTypesWrapped, NodeTypes, NodeTypesWrapped } from '../../types';
import { useStoreApi } from '../../hooks/useStore';
export function useNodeOrEdgeTypes(nodeOrEdgeTypes: NodeTypes, createTypes: CreateNodeTypes): NodeTypesWrapped;
export function useNodeOrEdgeTypes(nodeOrEdgeTypes: EdgeTypes, createTypes: CreateEdgeTypes): EdgeTypesWrapped;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function useNodeOrEdgeTypes(nodeOrEdgeTypes: any, createTypes: any): any {
const typesKeysRef = useRef<string[] | null>(null);
const store = useStoreApi();
const typesParsed = useMemo(() => {
if (process.env.NODE_ENV === 'development') {
const typeKeys = Object.keys(nodeOrEdgeTypes);
if (shallow(typesKeysRef.current, typeKeys)) {
store.getState().onError?.('002', errorMessages['error002']());
}
typesKeysRef.current = typeKeys;
}
return createTypes(nodeOrEdgeTypes);
}, [nodeOrEdgeTypes]);
return typesParsed;
}
@@ -1,14 +1,15 @@
import { memo, useMemo, useEffect, useRef, type ComponentType } from 'react'; import { memo } from 'react';
import { shallow } from 'zustand/shallow'; import { shallow } from 'zustand/shallow';
import { internalsSymbol, errorMessages, Position, clampPosition, getPositionWithOrigin } from '@xyflow/system';
import useVisibleNodes from '../../hooks/useVisibleNodes'; import useVisibleNodesIds from '../../hooks/useVisibleNodes';
import { useStore } from '../../hooks/useStore'; import { useStore } from '../../hooks/useStore';
import { containerStyle } from '../../styles/utils'; import { containerStyle } from '../../styles/utils';
import { GraphViewProps } from '../GraphView'; import { GraphViewProps } from '../GraphView';
import type { NodeTypesWrapped, ReactFlowState, WrapNodeProps } from '../../types'; import type { ReactFlowState } from '../../types';
import useResizeObserver from './useResizeObserver';
import NodeWrapper from '../../components/NodeWrapper';
type NodeRendererProps = Pick< export type NodeRendererProps = Pick<
GraphViewProps, GraphViewProps,
| 'onNodeClick' | 'onNodeClick'
| 'onNodeDoubleClick' | 'onNodeDoubleClick'
@@ -23,126 +24,71 @@ type NodeRendererProps = Pick<
| 'disableKeyboardA11y' | 'disableKeyboardA11y'
| 'nodeOrigin' | 'nodeOrigin'
| 'nodeExtent' | 'nodeExtent'
> & { | 'nodeTypes'
nodeTypes: NodeTypesWrapped; >;
};
const selector = (s: ReactFlowState) => ({ const selector = (s: ReactFlowState) => ({
nodesDraggable: s.nodesDraggable, nodesDraggable: s.nodesDraggable,
nodesConnectable: s.nodesConnectable, nodesConnectable: s.nodesConnectable,
nodesFocusable: s.nodesFocusable, nodesFocusable: s.nodesFocusable,
elementsSelectable: s.elementsSelectable, elementsSelectable: s.elementsSelectable,
updateNodeDimensions: s.updateNodeDimensions,
onError: s.onError, onError: s.onError,
}); });
const NodeRenderer = (props: NodeRendererProps) => { const NodeRenderer = (props: NodeRendererProps) => {
const { nodesDraggable, nodesConnectable, nodesFocusable, elementsSelectable, updateNodeDimensions, onError } = const { nodesDraggable, nodesConnectable, nodesFocusable, elementsSelectable, onError } = useStore(selector, shallow);
useStore(selector, shallow); const nodeIds = useVisibleNodesIds(props.onlyRenderVisibleElements);
const nodes = useVisibleNodes(props.onlyRenderVisibleElements); const resizeObserver = useResizeObserver();
const resizeObserverRef = useRef<ResizeObserver>();
const resizeObserver = useMemo(() => {
if (typeof ResizeObserver === 'undefined') {
return null;
}
const observer = new ResizeObserver((entries: ResizeObserverEntry[]) => {
const updates = new Map();
entries.forEach((entry: ResizeObserverEntry) => {
const id = entry.target.getAttribute('data-id') as string;
updates.set(id, {
id,
nodeElement: entry.target as HTMLDivElement,
forceUpdate: true,
});
});
updateNodeDimensions(updates);
});
resizeObserverRef.current = observer;
return observer;
}, []);
useEffect(() => {
return () => {
resizeObserverRef?.current?.disconnect();
};
}, []);
return ( return (
<div className="react-flow__nodes" style={containerStyle}> <div className="react-flow__nodes" style={containerStyle}>
{nodes.map((node) => { {nodeIds.map((nodeId) => {
let nodeType = node.type || 'default';
if (!props.nodeTypes[nodeType]) {
onError?.('003', errorMessages['error003'](nodeType));
nodeType = 'default';
}
const NodeComponent = (props.nodeTypes[nodeType] || props.nodeTypes.default) as ComponentType<WrapNodeProps>;
const isDraggable = !!(node.draggable || (nodesDraggable && typeof node.draggable === 'undefined'));
const isSelectable = !!(node.selectable || (elementsSelectable && typeof node.selectable === 'undefined'));
const isConnectable = !!(node.connectable || (nodesConnectable && typeof node.connectable === 'undefined'));
const isFocusable = !!(node.focusable || (nodesFocusable && typeof node.focusable === 'undefined'));
const clampedPosition = props.nodeExtent
? clampPosition(node.positionAbsolute, props.nodeExtent)
: node.positionAbsolute;
const posX = clampedPosition?.x ?? 0;
const posY = clampedPosition?.y ?? 0;
const posOrigin = getPositionWithOrigin({
x: posX,
y: posY,
width: node.width ?? 0,
height: node.height ?? 0,
origin: node.origin || props.nodeOrigin,
});
const initialized = (!!node.width && !!node.height) || (!!node.size?.width && !!node.size?.height);
return ( return (
<NodeComponent // The split of responsibilities between NodeRenderer and
key={node.id} // NodeComponentWrapper may appear weird. However, its designed to
id={node.id} // minimize the cost of updates when individual nodes change.
className={node.className} //
style={node.style} // For example, when youre dragging a single node, that node gets
sizeWidth={node.size?.width} // updated multiple times per second. If `NodeRenderer` were to update
sizeHeight={node.size?.height} // every time, it would have to re-run the `nodes.map()` loop every
type={nodeType} // time. This gets pricey with hundreds of nodes, especially if every
data={node.data} // loop cycle does more than just rendering a JSX element!
sourcePosition={node.sourcePosition || Position.Bottom} //
targetPosition={node.targetPosition || Position.Top} // As a result of this choice, we took the following implementation
hidden={node.hidden} // decisions:
xPos={posX} // - NodeRenderer subscribes *only* to node IDs and therefore
yPos={posY} // rerender *only* when visible nodes are added or removed.
xPosOrigin={posOrigin.x} // - NodeRenderer performs all operations the result of which can be
yPosOrigin={posOrigin.y} // shared between nodes (such as creating the `ResizeObserver`
// instance, or subscribing to `selector`). This means extra prop
// drilling into `NodeComponentWrapper`, but it means we need to run
// these operations only once instead of once per node.
// - Any operations that youd normally write inside `nodes.map` are
// moved into `NodeComponentWrapper`. This ensures they are
// memorized so if `NodeRenderer` *has* to rerender, it only
// needs to regenerate the list of nodes, nothing else.
<NodeWrapper
key={nodeId}
id={nodeId}
nodeTypes={props.nodeTypes}
nodeExtent={props.nodeExtent}
nodeOrigin={props.nodeOrigin}
onClick={props.onNodeClick} onClick={props.onNodeClick}
onMouseEnter={props.onNodeMouseEnter} onMouseEnter={props.onNodeMouseEnter}
onMouseMove={props.onNodeMouseMove} onMouseMove={props.onNodeMouseMove}
onMouseLeave={props.onNodeMouseLeave} onMouseLeave={props.onNodeMouseLeave}
onContextMenu={props.onNodeContextMenu} onContextMenu={props.onNodeContextMenu}
onDoubleClick={props.onNodeDoubleClick} onDoubleClick={props.onNodeDoubleClick}
selected={!!node.selected}
isDraggable={isDraggable}
isSelectable={isSelectable}
isConnectable={isConnectable}
isFocusable={isFocusable}
resizeObserver={resizeObserver}
dragHandle={node.dragHandle}
zIndex={node[internalsSymbol]?.z ?? 0}
isParent={!!node[internalsSymbol]?.isParent}
noDragClassName={props.noDragClassName} noDragClassName={props.noDragClassName}
noPanClassName={props.noPanClassName} noPanClassName={props.noPanClassName}
initialized={initialized}
rfId={props.rfId} rfId={props.rfId}
disableKeyboardA11y={props.disableKeyboardA11y} disableKeyboardA11y={props.disableKeyboardA11y}
ariaLabel={node.ariaLabel} resizeObserver={resizeObserver}
nodesDraggable={nodesDraggable}
nodesConnectable={nodesConnectable}
nodesFocusable={nodesFocusable}
elementsSelectable={elementsSelectable}
onError={onError}
/> />
); );
})} })}
@@ -0,0 +1,44 @@
import { useEffect, useMemo, useRef } from 'react';
import { ReactFlowState } from '../../types';
import { useStore } from '../../hooks/useStore';
const selector = (s: ReactFlowState) => s.updateNodeDimensions;
export default function useResizeObserver() {
const updateNodeDimensions = useStore(selector);
const resizeObserverRef = useRef<ResizeObserver>();
const resizeObserver = useMemo(() => {
if (typeof ResizeObserver === 'undefined') {
return null;
}
const observer = new ResizeObserver((entries: ResizeObserverEntry[]) => {
const updates = new Map();
entries.forEach((entry: ResizeObserverEntry) => {
const id = entry.target.getAttribute('data-id') as string;
updates.set(id, {
id,
nodeElement: entry.target as HTMLDivElement,
forceUpdate: true,
});
});
updateNodeDimensions(updates);
});
resizeObserverRef.current = observer;
return observer;
}, []);
useEffect(() => {
return () => {
resizeObserverRef?.current?.disconnect();
};
}, []);
return resizeObserver;
}
@@ -1,34 +0,0 @@
import type { ComponentType } from 'react';
import type { NodeProps } from '@xyflow/system';
import DefaultNode from '../../components/Nodes/DefaultNode';
import InputNode from '../../components/Nodes/InputNode';
import OutputNode from '../../components/Nodes/OutputNode';
import GroupNode from '../../components/Nodes/GroupNode';
import wrapNode from '../../components/Nodes/wrapNode';
import type { NodeTypes, NodeTypesWrapped } from '../../types';
export type CreateNodeTypes = (nodeTypes: NodeTypes) => NodeTypesWrapped;
export function createNodeTypes(nodeTypes: NodeTypes): NodeTypesWrapped {
const standardTypes: NodeTypesWrapped = {
input: wrapNode((nodeTypes.input || InputNode) as ComponentType<NodeProps>),
default: wrapNode((nodeTypes.default || DefaultNode) as ComponentType<NodeProps>),
output: wrapNode((nodeTypes.output || OutputNode) as ComponentType<NodeProps>),
group: wrapNode((nodeTypes.group || GroupNode) as ComponentType<NodeProps>),
};
const wrappedTypes = {} as NodeTypesWrapped;
const specialTypes: NodeTypesWrapped = Object.keys(nodeTypes)
.filter((k) => !['input', 'default', 'output', 'group'].includes(k))
.reduce((res, key) => {
res[key] = wrapNode((nodeTypes[key] || DefaultNode) as ComponentType<NodeProps>);
return res;
}, wrappedTypes);
return {
...standardTypes,
...specialTypes,
};
}
@@ -12,32 +12,14 @@ import {
} from '@xyflow/system'; } from '@xyflow/system';
import Attribution from '../../components/Attribution'; import Attribution from '../../components/Attribution';
import { BezierEdge, SmoothStepEdge, StepEdge, StraightEdge, SimpleBezierEdge } from '../../components/Edges';
import DefaultNode from '../../components/Nodes/DefaultNode';
import InputNode from '../../components/Nodes/InputNode';
import OutputNode from '../../components/Nodes/OutputNode';
import GroupNode from '../../components/Nodes/GroupNode';
import SelectionListener from '../../components/SelectionListener'; import SelectionListener from '../../components/SelectionListener';
import StoreUpdater from '../../components/StoreUpdater'; import StoreUpdater from '../../components/StoreUpdater';
import A11yDescriptions from '../../components/A11yDescriptions'; import A11yDescriptions from '../../components/A11yDescriptions';
import GraphView from '../GraphView'; import GraphView from '../GraphView';
import Wrapper from './Wrapper'; import Wrapper from './Wrapper';
import type { EdgeTypes, NodeTypes, ReactFlowProps, ReactFlowRefType } from '../../types'; import type { ReactFlowProps, ReactFlowRefType } from '../../types';
import useColorModeClass from '../../hooks/useColorModeClass';
const defaultNodeTypes: NodeTypes = {
input: InputNode,
default: DefaultNode,
output: OutputNode,
group: GroupNode,
};
const defaultEdgeTypes: EdgeTypes = {
default: BezierEdge,
straight: StraightEdge,
step: StepEdge,
smoothstep: SmoothStepEdge,
simplebezier: SimpleBezierEdge,
};
const initNodeOrigin: NodeOrigin = [0, 0]; const initNodeOrigin: NodeOrigin = [0, 0];
const initSnapGrid: [number, number] = [15, 15]; const initSnapGrid: [number, number] = [15, 15];
@@ -59,8 +41,8 @@ const ReactFlow = forwardRef<ReactFlowRefType, ReactFlowProps>(
defaultNodes, defaultNodes,
defaultEdges, defaultEdges,
className, className,
nodeTypes = defaultNodeTypes, nodeTypes,
edgeTypes = defaultEdgeTypes, edgeTypes,
onNodeClick, onNodeClick,
onEdgeClick, onEdgeClick,
onInit, onInit,
@@ -169,18 +151,20 @@ const ReactFlow = forwardRef<ReactFlowRefType, ReactFlowProps>(
onViewportChange, onViewportChange,
width, width,
height, height,
colorMode = 'light',
...rest ...rest
}, },
ref ref
) => { ) => {
const rfId = id || '1'; const rfId = id || '1';
const colorModeClassName = useColorModeClass(colorMode);
return ( return (
<div <div
{...rest} {...rest}
style={{ ...style, ...wrapperStyle }} style={{ ...style, ...wrapperStyle }}
ref={ref} ref={ref}
className={cc(['react-flow', className])} className={cc(['react-flow', className, colorModeClassName])}
data-testid="rf__wrapper" data-testid="rf__wrapper"
id={id} id={id}
> >
@@ -0,0 +1,35 @@
import { useEffect, useState } from 'react';
import type { ColorMode, ColorModeClass } from '@xyflow/system';
function getMediaQuery() {
if (typeof window === 'undefined' || !window.matchMedia) {
return null;
}
return window.matchMedia('(prefers-color-scheme: dark)');
}
export default function useColorModeClass(colorMode: ColorMode): ColorModeClass {
const [colorModeClass, setColorModeClass] = useState<ColorModeClass | null>(
colorMode === 'system' ? null : colorMode
);
useEffect(() => {
if (colorMode !== 'system') {
setColorModeClass(colorMode);
return;
}
const mediaQuery = getMediaQuery();
const updateColorModeClass = () => setColorModeClass(mediaQuery?.matches ? 'dark' : 'light');
updateColorModeClass();
mediaQuery?.addEventListener('change', updateColorModeClass);
return () => {
mediaQuery?.removeEventListener('change', updateColorModeClass);
};
}, [colorMode]);
return colorModeClass !== null ? colorModeClass : getMediaQuery()?.matches ? 'dark' : 'light';
}
@@ -0,0 +1,54 @@
import { useEffect, useMemo, useRef } from 'react';
import { Connection, HandleType, areConnectionMapsEqual, handleConnectionChange } from '@xyflow/system';
import { useStore } from './useStore';
import { useNodeId } from '../contexts/NodeIdContext';
type useHandleConnectionsParams = {
type: HandleType;
id?: string | null;
nodeId?: string;
onConnect?: (connections: Connection[]) => void;
onDisconnect?: (connections: Connection[]) => void;
};
/**
* Hook to check if a <Handle /> is connected to another <Handle /> and get the connections.
*
* @public
* @param param.type - handle type 'source' or 'target'
* @param param.id - the handle id (this is only needed if the node has multiple handles of the same type)
* @param param.nodeId - node id - if not provided, the node id from the NodeIdContext is used
* @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
*/
export function useHandleConnections({
type,
id = null,
nodeId,
onConnect,
onDisconnect,
}: useHandleConnectionsParams): Connection[] {
const _nodeId = useNodeId();
const prevConnections = useRef<Map<string, Connection> | null>(null);
const currentNodeId = nodeId || _nodeId;
const connections = useStore(
(state) => state.connectionLookup.get(`${currentNodeId}-${type}-${id}`),
areConnectionMapsEqual
);
useEffect(() => {
// @todo dicuss if onConnect/onDisconnect should be called when the component mounts/unmounts
if (prevConnections.current && prevConnections.current !== connections) {
const _connections = connections ?? new Map();
handleConnectionChange(prevConnections.current, _connections, onDisconnect);
handleConnectionChange(_connections, prevConnections.current, onConnect);
}
prevConnections.current = connections ?? new Map();
}, [connections, onConnect, onDisconnect]);
return useMemo(() => Array.from(connections?.values() ?? []), [connections]);
}
+40
View File
@@ -0,0 +1,40 @@
import { useCallback } from 'react';
import { shallow } from 'zustand/shallow';
import { useStore } from '../hooks/useStore';
import type { Node } from '../types';
export function useNodesData<NodeType extends Node = Node>(nodeId: string): NodeType['data'] | null;
export function useNodesData<NodeType extends Node = Node>(nodeIds: string[]): NodeType['data'][];
export function useNodesData<NodeType extends Node = Node>(
nodeIds: string[],
guard: (node: Node) => node is NodeType
): NodeType['data'][];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function useNodesData(nodeIds: any): any {
const nodesData = useStore(
useCallback(
(s) => {
if (!Array.isArray(nodeIds)) {
return s.nodeLookup.get(nodeIds)?.data || null;
}
const data = [];
for (const nodeId of nodeIds) {
const nodeData = s.nodeLookup.get(nodeId)?.data;
if (nodeData) {
data.push(nodeData);
}
}
return data;
},
[nodeIds]
),
shallow
);
return nodesData;
}
+35 -2
View File
@@ -10,7 +10,7 @@ import {
} from '@xyflow/system'; } from '@xyflow/system';
import useViewportHelper from './useViewportHelper'; import useViewportHelper from './useViewportHelper';
import { useStoreApi } from '../hooks/useStore'; import { useStoreApi } from './useStore';
import type { import type {
ReactFlowInstance, ReactFlowInstance,
Instance, Instance,
@@ -24,6 +24,7 @@ import type {
Node, Node,
Edge, Edge,
} from '../types'; } from '../types';
import { isNode } from '../utils';
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */ /* eslint-disable-next-line @typescript-eslint/no-explicit-any */
export default function useReactFlow<NodeData = any, EdgeData = any>(): ReactFlowInstance<NodeData, EdgeData> { export default function useReactFlow<NodeData = any, EdgeData = any>(): ReactFlowInstance<NodeData, EdgeData> {
@@ -206,7 +207,7 @@ export default function useReactFlow<NodeData = any, EdgeData = any>(): ReactFlo
} }
return (nodes || store.getState().nodes).filter((n) => { return (nodes || store.getState().nodes).filter((n) => {
if (!isRect && (n.id === node!.id || !n.positionAbsolute)) { if (!isRect && (n.id === node!.id || !n.computed?.positionAbsolute)) {
return false; return false;
} }
@@ -271,6 +272,36 @@ export default function useReactFlow<NodeData = any, EdgeData = any>(): ReactFlo
return getOutgoersBase(node, nodes, edges); return getOutgoersBase(node, nodes, edges);
}, []); }, []);
const updateNode = useCallback<Instance.UpdateNode>(
(id, nodeUpdate, options = { replace: true }) => {
setNodes((prevNodes) =>
prevNodes.map((node) => {
if (node.id === id) {
const nextNode = typeof nodeUpdate === 'function' ? nodeUpdate(node as Node) : nodeUpdate;
return options.replace && isNode(nextNode) ? nextNode : { ...node, ...nextNode };
}
return node;
})
);
},
[setNodes]
);
const updateNodeData = useCallback<Instance.UpdateNodeData>(
(id, dataUpdate, options = { replace: false }) => {
updateNode(
id,
(node) => {
const nextData = typeof dataUpdate === 'function' ? dataUpdate(node) : dataUpdate;
return options.replace ? { ...node, data: nextData } : { ...node, data: { ...node.data, ...nextData } };
},
options
);
},
[updateNode]
);
return useMemo(() => { return useMemo(() => {
return { return {
...viewportHelper, ...viewportHelper,
@@ -289,6 +320,8 @@ export default function useReactFlow<NodeData = any, EdgeData = any>(): ReactFlo
getConnectedEdges, getConnectedEdges,
getIncomers, getIncomers,
getOutgoers, getOutgoers,
updateNode,
updateNodeData,
}; };
}, [ }, [
viewportHelper, viewportHelper,
@@ -23,8 +23,11 @@ function useUpdateNodePositions() {
const yDiff = params.y * yVelo * factor; const yDiff = params.y * yVelo * factor;
const nodeUpdates = selectedNodes.map((node) => { const nodeUpdates = selectedNodes.map((node) => {
if (node.positionAbsolute) { if (node.computed?.positionAbsolute) {
let nextPosition = { x: node.positionAbsolute.x + xDiff, y: node.positionAbsolute.y + yDiff }; let nextPosition = {
x: node.computed?.positionAbsolute.x + xDiff,
y: node.computed?.positionAbsolute.y + yDiff,
};
if (snapToGrid) { if (snapToGrid) {
nextPosition = snapPosition(nextPosition, snapGrid); nextPosition = snapPosition(nextPosition, snapGrid);
@@ -40,7 +43,10 @@ function useUpdateNodePositions() {
); );
node.position = position; node.position = position;
node.positionAbsolute = positionAbsolute; if (!node.computed) {
node.computed = {};
}
node.computed.positionAbsolute = positionAbsolute;
} }
return node; return node;
+14 -13
View File
@@ -1,21 +1,22 @@
import { useCallback } from 'react';
import { getNodesInside } from '@xyflow/system'; import { getNodesInside } from '@xyflow/system';
import { shallow } from 'zustand/shallow';
import { useStore } from '../hooks/useStore'; import { useStore } from '../hooks/useStore';
import type { Node, ReactFlowState } from '../types'; import type { Node, ReactFlowState } from '../types';
import { useCallback } from 'react';
function useVisibleNodes(onlyRenderVisible: boolean) { const selector = (onlyRenderVisible: boolean) => (s: ReactFlowState) => {
const nodes = useStore( return onlyRenderVisible
useCallback( ? getNodesInside<Node>(s.nodes, { x: 0, y: 0, width: s.width, height: s.height }, s.transform, true).map(
(s: ReactFlowState) => (node) => node.id
onlyRenderVisible )
? getNodesInside<Node>(s.nodes, { x: 0, y: 0, width: s.width, height: s.height }, s.transform, true) : Array.from(s.nodeLookup.keys());
: s.nodes, };
[onlyRenderVisible]
)
);
return nodes; function useVisibleNodeIds(onlyRenderVisible: boolean) {
const nodeIds = useStore(useCallback(selector(onlyRenderVisible), [onlyRenderVisible]), shallow);
return nodeIds;
} }
export default useVisibleNodes; export default useVisibleNodeIds;
+10 -6
View File
@@ -1,11 +1,11 @@
export { default as ReactFlow } from './container/ReactFlow'; export { default as ReactFlow } from './container/ReactFlow';
export { default as Handle } from './components/Handle'; export { default as Handle, type HandleComponentProps } from './components/Handle';
export { default as EdgeText } from './components/Edges/EdgeText'; export { default as EdgeText } from './components/Edges/EdgeText';
export { default as StraightEdge } from './components/Edges/StraightEdge'; export { StraightEdge } from './components/Edges/StraightEdge';
export { default as StepEdge } from './components/Edges/StepEdge'; export { StepEdge } from './components/Edges/StepEdge';
export { default as BezierEdge } from './components/Edges/BezierEdge'; export { BezierEdge } from './components/Edges/BezierEdge';
export { default as SimpleBezierEdge, getSimpleBezierPath } from './components/Edges/SimpleBezierEdge'; export { SimpleBezierEdge, getSimpleBezierPath } from './components/Edges/SimpleBezierEdge';
export { default as SmoothStepEdge } from './components/Edges/SmoothStepEdge'; export { SmoothStepEdge } from './components/Edges/SmoothStepEdge';
export { default as BaseEdge } from './components/Edges/BaseEdge'; export { default as BaseEdge } from './components/Edges/BaseEdge';
export { default as ReactFlowProvider } from './components/ReactFlowProvider'; export { default as ReactFlowProvider } from './components/ReactFlowProvider';
export { default as Panel, type PanelProps } from './components/Panel'; export { default as Panel, type PanelProps } from './components/Panel';
@@ -22,6 +22,8 @@ export { useStore, useStoreApi } from './hooks/useStore';
export { default as useOnViewportChange, type UseOnViewportChangeOptions } from './hooks/useOnViewportChange'; export { default as useOnViewportChange, type UseOnViewportChangeOptions } from './hooks/useOnViewportChange';
export { default as useOnSelectionChange, type UseOnSelectionChangeOptions } from './hooks/useOnSelectionChange'; export { default as useOnSelectionChange, type UseOnSelectionChangeOptions } from './hooks/useOnSelectionChange';
export { default as useNodesInitialized, type UseNodesInitializedOptions } from './hooks/useNodesInitialized'; export { default as useNodesInitialized, type UseNodesInitializedOptions } from './hooks/useNodesInitialized';
export { useHandleConnections } from './hooks/useHandleConnections';
export { useNodesData } from './hooks/useNodesData';
export { useNodeId } from './contexts/NodeIdContext'; export { useNodeId } from './contexts/NodeIdContext';
export { applyNodeChanges, applyEdgeChanges, handleParentExpand } from './utils/changes'; export { applyNodeChanges, applyEdgeChanges, handleParentExpand } from './utils/changes';
@@ -73,6 +75,8 @@ export {
type Box, type Box,
type Transform, type Transform,
type CoordinateExtent, type CoordinateExtent,
type ColorMode,
type ColorModeClass,
} from '@xyflow/system'; } from '@xyflow/system';
// system utils // system utils
+26 -11
View File
@@ -2,11 +2,12 @@ import { createWithEqualityFn } from 'zustand/traditional';
import { import {
clampPosition, clampPosition,
fitView as fitViewSystem, fitView as fitViewSystem,
updateNodes, adoptUserProvidedNodes,
updateAbsolutePositions, updateAbsolutePositions,
panBy as panBySystem, panBy as panBySystem,
Dimensions, Dimensions,
updateNodeDimensions as updateNodeDimensionsSystem, updateNodeDimensions as updateNodeDimensionsSystem,
updateConnectionLookup,
} from '@xyflow/system'; } from '@xyflow/system';
import { applyNodeChanges, createSelectionChange, getSelectionChanges } from '../utils/changes'; import { applyNodeChanges, createSelectionChange, getSelectionChanges } from '../utils/changes';
@@ -42,15 +43,24 @@ const createRFStore = ({
...getInitialState({ nodes, edges, width, height, fitView }), ...getInitialState({ nodes, edges, width, height, fitView }),
setNodes: (nodes: Node[]) => { setNodes: (nodes: Node[]) => {
const { nodeLookup, nodeOrigin, elevateNodesOnSelect } = get(); const { nodeLookup, nodeOrigin, elevateNodesOnSelect } = get();
// Whenver new nodes are set, we need to calculate the absolute positions of the nodes // setNodes() is called exclusively in response to user actions:
// and update the nodeLookup. // - either when the `<ReactFlow nodes>` prop is updated in the controlled ReactFlow setup,
const nextNodes = updateNodes(nodes, nodeLookup, { nodeOrigin, elevateNodesOnSelect }); // - or when the user calls something like `reactFlowInstance.setNodes()` in an uncontrolled ReactFlow setup.
//
// 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: nextNodes }); set({ nodes: nodesWithInternalData });
}, },
setEdges: (edges: Edge[]) => { setEdges: (edges: Edge[]) => {
const { defaultEdgeOptions = {} } = get(); const { defaultEdgeOptions = {}, connectionLookup } = get();
set({ edges: edges.map((e) => ({ ...defaultEdgeOptions, ...e })) }); const nextEdges = edges.map((e) => ({ ...defaultEdgeOptions, ...e }));
updateConnectionLookup(connectionLookup, nextEdges);
set({ edges: nextEdges });
}, },
// when the user works with an uncontrolled flow, // when the user works with an uncontrolled flow,
// we set a flag `hasDefaultNodes` / `hasDefaultEdges` // we set a flag `hasDefaultNodes` / `hasDefaultEdges`
@@ -69,7 +79,8 @@ const createRFStore = ({
}; };
if (hasDefaultNodes) { if (hasDefaultNodes) {
nextState.nodes = updateNodes(nodes, new Map(), { const { nodeLookup } = get();
nextState.nodes = adoptUserProvidedNodes(nodes, nodeLookup, {
nodeOrigin: get().nodeOrigin, nodeOrigin: get().nodeOrigin,
elevateNodesOnSelect: get().elevateNodesOnSelect, elevateNodesOnSelect: get().elevateNodesOnSelect,
}); });
@@ -147,7 +158,7 @@ const createRFStore = ({
}; };
if (positionChanged) { if (positionChanged) {
change.positionAbsolute = node.positionAbsolute; change.positionAbsolute = node.computed?.positionAbsolute;
change.position = node.position; change.position = node.position;
} }
@@ -163,7 +174,7 @@ const createRFStore = ({
if (changes?.length) { if (changes?.length) {
if (hasDefaultNodes) { if (hasDefaultNodes) {
const updatedNodes = applyNodeChanges(changes, nodes); const updatedNodes = applyNodeChanges(changes, nodes);
const nextNodes = updateNodes(updatedNodes, nodeLookup, { const nextNodes = adoptUserProvidedNodes(updatedNodes, nodeLookup, {
nodeOrigin, nodeOrigin,
elevateNodesOnSelect, elevateNodesOnSelect,
}); });
@@ -276,7 +287,10 @@ const createRFStore = ({
return { return {
...node, ...node,
positionAbsolute, computed: {
...node.computed,
positionAbsolute,
},
}; };
}), }),
}); });
@@ -323,6 +337,7 @@ const createRFStore = ({
set(currentConnection); set(currentConnection);
}, },
reset: () => { reset: () => {
// @todo: what should we do about this? Do we still need it? // @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 // if you are on a SPA with multiple flows, we want to make sure that the store gets resetted
+11 -9
View File
@@ -1,10 +1,11 @@
import { import {
infiniteExtent, infiniteExtent,
ConnectionMode, ConnectionMode,
updateNodes, adoptUserProvidedNodes,
getNodesBounds, getNodesBounds,
getViewportForBounds, getViewportForBounds,
Transform, Transform,
updateConnectionLookup,
} from '@xyflow/system'; } from '@xyflow/system';
import type { Edge, Node, ReactFlowStore } from '../types'; import type { Edge, Node, ReactFlowStore } from '../types';
@@ -22,17 +23,17 @@ const getInitialState = ({
height?: number; height?: number;
fitView?: boolean; fitView?: boolean;
} = {}): ReactFlowStore => { } = {}): ReactFlowStore => {
const nodeLookup = new Map<string, Node>(); const nodeLookup = new Map();
const nextNodes = updateNodes(nodes, nodeLookup, { nodeOrigin: [0, 0], elevateNodesOnSelect: false }); const connectionLookup = updateConnectionLookup(new Map(), edges);
const nextNodes = adoptUserProvidedNodes(nodes, nodeLookup, {
nodeOrigin: [0, 0],
elevateNodesOnSelect: false,
});
let transform: Transform = [0, 0, 1]; let transform: Transform = [0, 0, 1];
if (fitView && width && height) { if (fitView && width && height) {
const nodesWithDimensions = nextNodes.map((node) => ({ const nodesWithDimensions = nextNodes.filter((node) => node.width && node.height);
...node,
width: node.size?.width,
height: node.size?.height,
}));
const bounds = getNodesBounds(nodesWithDimensions, [0, 0]); const bounds = getNodesBounds(nodesWithDimensions, [0, 0]);
const { x, y, zoom } = getViewportForBounds(bounds, width, height, 0.5, 2, 0.1); const { x, y, zoom } = getViewportForBounds(bounds, width, height, 0.5, 2, 0.1);
transform = [x, y, zoom]; transform = [x, y, zoom];
@@ -46,6 +47,7 @@ const getInitialState = ({
nodes: nextNodes, nodes: nextNodes,
nodeLookup, nodeLookup,
edges: edges, edges: edges,
connectionLookup,
onNodesChange: null, onNodesChange: null,
onEdgesChange: null, onEdgesChange: null,
hasDefaultNodes: false, hasDefaultNodes: false,
@@ -65,7 +67,7 @@ const getInitialState = ({
paneDragging: false, paneDragging: false,
noPanClassName: 'nopan', noPanClassName: 'nopan',
nodeOrigin: [0, 0], nodeOrigin: [0, 0],
nodeDragThreshold: 0, nodeDragThreshold: 1,
snapGrid: [15, 15], snapGrid: [15, 15],
snapToGrid: false, snapToGrid: false,
+18
View File
@@ -1,3 +1,21 @@
/* this will be exported as base.css and can be used for a basic styling */ /* this will be exported as base.css and can be used for a basic styling */
@import '../../../system/src/styles/init.css'; @import '../../../system/src/styles/init.css';
@import '../../../system/src/styles/base.css'; @import '../../../system/src/styles/base.css';
.react-flow {
--edge-label-background-color-default: #ffffff;
--edge-label-color-default: inherit;
}
.react-flow.dark {
--edge-label-background-color-default: #141414;
--edge-label-color-default: #f8f8f8;
}
.react-flow__edge-textbg {
fill: var(--edge-label-background-color, var(--edge-label-background-color-default));
}
.react-flow__edge-text {
fill: var(--edge-label-color, var(--edge-label-color-default));
}
+18
View File
@@ -2,3 +2,21 @@
@import '../../../system/src/styles/init.css'; @import '../../../system/src/styles/init.css';
@import '../../../system/src/styles/style.css'; @import '../../../system/src/styles/style.css';
@import '../../../system/src/styles/node-resizer.css'; @import '../../../system/src/styles/node-resizer.css';
.react-flow {
--edge-label-background-color-default: #ffffff;
--edge-label-color-default: inherit;
}
.react-flow.dark {
--edge-label-background-color-default: #141414;
--edge-label-color-default: #f8f8f8;
}
.react-flow__edge-textbg {
fill: var(--edge-label-background-color, var(--edge-label-background-color-default));
}
.react-flow__edge-text {
fill: var(--edge-label-color, var(--edge-label-color-default));
}

Some files were not shown because too many files have changed in this diff Show More