Merge pull request #3659 from xyflow/next

Svelte Flow: release 0.0.28
This commit is contained in:
Moritz Klack
2023-11-23 13:53:06 +01:00
committed by GitHub
133 changed files with 1789 additions and 606 deletions
+13
View File
@@ -0,0 +1,13 @@
# Examples for Testing & Development
These examples are used for feature development and E2E testing.
## Overview
SvelteKit App with Svelte Flow examples at [`examples/svelte`](./svelte)
Vite App with React Flow examples at [`examples/react`](./react)
Astro App used for SSR testing at [`examples/astro-xyflow`](./astro-xyflow)
## E2E
For furhter documentation of E2E tests have a look at [`/tests/playwright`](/tests/playwright).
@@ -7,7 +7,7 @@ const sourceHandleStyleB: CSSProperties = {
left: 'auto', left: 'auto',
}; };
const CustomNode: FC<NodeProps> = ({ data, xPos, yPos }) => { const CustomNode: FC<NodeProps> = ({ data, positionAbsolute }) => {
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)} {positionAbsolute.x.toFixed(2)},{positionAbsolute.y.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 },
+11 -1
View File
@@ -1,6 +1,6 @@
# React Flow examples # React Flow examples
This Vite app is used internally to test the library. This Vite app is used internally to develop and test the library.
## Start local dev server ## Start local dev server
@@ -8,5 +8,15 @@ This Vite app is used internally to test the library.
pnpm dev pnpm dev
``` ```
## Adding new example
Development of the library is done against `src/examples`. Feel free to add new implementations for features that you develop.
1. Create a new folder & flow at `src/examples/`
2. Register the new route in `src/App/routes.ts`
## Adding new E2E implementation
E2E testing is done against the flows implemented in `src/generic-tests`. Adding a new configuration file automatically adds a new route under [`http://localhost:3000/tests/generic/$foldername/$filename`](http://localhost:5173/tests/generic/nodes/general). For further documentation visit [`xyflow/tests/playwright`](/tests/playwright).
+6
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';
@@ -62,6 +63,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',
+5 -5
View File
@@ -2,10 +2,10 @@ import { BackgroundProps, ControlProps, Edge, MiniMapProps, Node, PanelProps, Re
declare global { declare global {
interface FlowConfig { interface FlowConfig {
flowProps: Omit<ReactFlowProps, 'nodes' | 'edges'> & { nodes: Node[]; edges: Edge[] }; flowProps?: Omit<ReactFlowProps, 'nodes' | 'edges'> & { nodes: Node[]; edges: Edge[] };
panelProps: PanelProps; panelProps?: PanelProps;
backgroundProps: BackgroundProps; backgroundProps?: BackgroundProps;
controlsProps: ControlProps; controlsProps?: ControlProps;
minimapProps: MiniMapProps; minimapProps?: MiniMapProps;
} }
} }
@@ -0,0 +1,88 @@
import { ChangeEventHandler, useCallback, useState } from 'react';
import {
ReactFlow,
addEdge,
Node,
useNodesState,
useEdgesState,
OnConnect,
Edge,
MiniMap,
Background,
Controls,
Panel,
ColorMode,
Position,
} from '@xyflow/react';
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;
@@ -49,7 +49,7 @@ const DnDFlow = () => {
if (reactFlowInstance) { if (reactFlowInstance) {
const type = event.dataTransfer.getData('application/reactflow'); const type = event.dataTransfer.getData('application/reactflow');
const position = reactFlowInstance.screenToFlowCoordinate({ const position = reactFlowInstance.screenToFlowPosition({
x: event.clientX, x: event.clientX,
y: event.clientY, y: event.clientY,
}); });
@@ -66,7 +66,7 @@ const DnDFlow = () => {
return ( return (
<div className={styles.dndflow}> <div className={styles.dndflow}>
<ReactFlowProvider nodes={initialNodes} edges={[]}> <ReactFlowProvider initialNodes={initialNodes} initialEdges={[]}>
<div className={styles.wrapper}> <div className={styles.wrapper}>
<ReactFlow <ReactFlow
nodes={nodes} nodes={nodes}
@@ -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,9 @@ 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, height: intersectionNodeHeight } = intersectionNode;
width: intersectionNodeWidth, const intersectionNodePosition = intersectionNode.computed?.positionAbsolute!;
height: intersectionNodeHeight, const targetPosition = targetNode.computed?.positionAbsolute!;
positionAbsolute: intersectionNodePosition,
} = intersectionNode;
const targetPosition = targetNode.positionAbsolute!;
const w = intersectionNodeWidth! / 2; const w = intersectionNodeWidth! / 2;
const h = intersectionNodeHeight! / 2; const h = intersectionNodeHeight! / 2;
@@ -32,7 +29,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 +38,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;
} }
@@ -196,6 +196,7 @@ const EdgesFlow = () => {
onEdgeMouseEnter={onEdgeMouseEnter} onEdgeMouseEnter={onEdgeMouseEnter}
onEdgeMouseMove={onEdgeMouseMove} onEdgeMouseMove={onEdgeMouseMove}
onEdgeMouseLeave={onEdgeMouseLeave} onEdgeMouseLeave={onEdgeMouseLeave}
onDelete={console.log}
> >
<MiniMap /> <MiniMap />
<Controls /> <Controls />
@@ -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;
} }
@@ -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>
); );
}; };
+1 -7
View File
@@ -1,7 +1,6 @@
import { useState, useCallback } from 'react'; import { useState, useCallback } from 'react';
import { import {
ReactFlow, ReactFlow,
ReactFlowInstance,
Edge, Edge,
Node, Node,
NodeChange, NodeChange,
@@ -18,11 +17,6 @@ import {
import { getNodesAndEdges } from './utils'; import { getNodesAndEdges } from './utils';
const onInit = (reactFlowInstance: ReactFlowInstance) => {
reactFlowInstance.fitView();
console.log(reactFlowInstance.getNodes());
};
const { nodes: initialNodes, edges: initialEdges } = getNodesAndEdges(25, 25); const { nodes: initialNodes, edges: initialEdges } = getNodesAndEdges(25, 25);
const StressFlow = () => { const StressFlow = () => {
@@ -64,11 +58,11 @@ const StressFlow = () => {
<ReactFlow <ReactFlow
nodes={nodes} nodes={nodes}
edges={edges} edges={edges}
onInit={onInit}
onConnect={onConnect} onConnect={onConnect}
onNodesChange={onNodesChange} onNodesChange={onNodesChange}
onEdgesChange={onEdgeChange} onEdgesChange={onEdgeChange}
minZoom={0.2} minZoom={0.2}
fitView
> >
<MiniMap /> <MiniMap />
<Controls /> <Controls />
@@ -20,8 +20,6 @@ export function getNodesAndEdges(xElements = 10, yElements = 10): ElementsCollec
style: { width: 50, height: 30, fontSize: 11 }, style: { width: 50, height: 30, fontSize: 11 },
data, data,
position, position,
width: 50,
height: 30,
}; };
initialNodes.push(node); initialNodes.push(node);
@@ -11,13 +11,13 @@ const idStyle: CSSProperties = {
left: 2, left: 2,
}; };
const DebugNode: FC<NodeProps> = ({ zIndex, xPos, yPos, id }) => { const DebugNode: FC<NodeProps> = ({ zIndex, positionAbsolute, 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(positionAbsolute.x)} y:{Math.round(positionAbsolute.y)} z:{zIndex}
</div> </div>
<Handle type="source" position={Position.Bottom} /> <Handle type="source" position={Position.Bottom} />
</> </>
+2 -2
View File
@@ -18,8 +18,8 @@ type FlowProps = {
}; };
export default ({ flowConfig }: FlowProps) => { export default ({ flowConfig }: FlowProps) => {
const [nodes, setNodes] = useState(flowConfig.flowProps.nodes); const [nodes, setNodes] = useState(flowConfig.flowProps?.nodes);
const [edges, setEdges] = useState(flowConfig.flowProps.edges); const [edges, setEdges] = useState(flowConfig.flowProps?.edges);
const props = { ...flowConfig.flowProps, nodes, edges }; const props = { ...flowConfig.flowProps, nodes, edges };
const onNodesChange: OnNodesChange = useCallback((changes) => setNodes((nds) => applyNodeChanges(changes, nds)), []); const onNodesChange: OnNodesChange = useCallback((changes) => setNodes((nds) => applyNodeChanges(changes, nds)), []);
@@ -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';
+11 -1
View File
@@ -1,4 +1,4 @@
# Svelte Flow examples # Svelte Flow examples
This Vite app is used internally to test the library. This Vite app is used internally to test the library.
@@ -8,3 +8,13 @@ This Vite app is used internally to test the library.
pnpm dev pnpm dev
``` ```
## Adding new example
Development of the library is done against `src/routes/examples`. Feel free to add new implementations for features that you develop.
1. Create a new folder & flow at `src/routes/examples/`
2. Register the new route in `src/components/Header/Header.svelte`
## Adding new E2E implementation
E2E testing is done against the flows implemented in `src/generic-tests`. Adding a new configuration file automatically adds a new route under [`http://localhost:5173/tests/generic/$foldername/$filename`](http://localhost:5173/tests/generic/nodes/general). For further documentation visit [`xyflow/tests/playwright`](/tests/playwright).
+5 -5
View File
@@ -20,11 +20,11 @@ declare global {
} }
interface FlowConfig { interface FlowConfig {
flowProps: Omit<SvelteFlowProps, 'nodes' | 'edges'> & { nodes: Node[]; edges: Edge[] }; flowProps?: Omit<SvelteFlowProps, 'nodes' | 'edges'> & { nodes: Node[]; edges: Edge[] };
panelProps: PanelProps; panelProps?: PanelProps;
backgroundProps: BackgroundProps; backgroundProps?: BackgroundProps;
controlsProps: ControlsProps; controlsProps?: ControlsProps;
minimapProps: MiniMapProps; minimapProps?: MiniMapProps;
} }
} }
@@ -4,6 +4,7 @@
const routes = [ const routes = [
'add-node-on-drop', 'add-node-on-drop',
'colormode',
'custom-connection-line', 'custom-connection-line',
'customnode', 'customnode',
'dagre', 'dagre',
@@ -12,6 +13,7 @@
'figma', 'figma',
'interaction', 'interaction',
'intersections', 'intersections',
'node-toolbar',
'overview', 'overview',
'stress', 'stress',
'subflows', 'subflows',
@@ -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,7 +1,6 @@
<script lang="ts"> <script lang="ts">
import { SvelteFlow, useSvelteFlow } from '@xyflow/svelte';
import type { Edge, Node } from '@xyflow/svelte';
import { writable } from 'svelte/store'; import { writable } from 'svelte/store';
import { SvelteFlow, useSvelteFlow, type Edge, type Node } from '@xyflow/svelte';
import '@xyflow/svelte/dist/style.css'; import '@xyflow/svelte/dist/style.css';
@@ -22,7 +21,7 @@
let id = 1; let id = 1;
const getId = () => `${id++}`; const getId = () => `${id++}`;
const { screenToFlowCoordinate, flowToScreenCoordinate } = useSvelteFlow(); const { screenToFlowPosition, flowToScreenPosition } = useSvelteFlow();
function handleConnectEnd({ detail: { event } }: { detail: { event: MouseEvent | TouchEvent } }) { function handleConnectEnd({ detail: { event } }: { detail: { event: MouseEvent | TouchEvent } }) {
// See of connection landed inside the flow pane // See of connection landed inside the flow pane
@@ -34,7 +33,7 @@
y: event.clientY y: event.clientY
}; };
const doubleTransformedPosition = flowToScreenCoordinate(screenToFlowCoordinate(position)); const doubleTransformedPosition = flowToScreenPosition(screenToFlowPosition(position));
console.log( console.log(
'Is transforming in both directions (screen-flow, flow-screen) the same?', 'Is transforming in both directions (screen-flow, flow-screen) the same?',
position.x === doubleTransformedPosition.x && position.y === doubleTransformedPosition.y position.x === doubleTransformedPosition.x && position.y === doubleTransformedPosition.y
@@ -43,8 +42,8 @@
const newNode: Node = { const newNode: Node = {
id, id,
data: { label: `Node ${id}` }, data: { label: `Node ${id}` },
// project the screen coordinates to pane coordinates // project the screen position to pane position
position: screenToFlowCoordinate(position), position: screenToFlowPosition(position),
// set the origin of the new node so it is centered // set the origin of the new node so it is centered
origin: [0.5, 0.0] origin: [0.5, 0.0]
}; };
@@ -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>
@@ -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>
@@ -174,6 +174,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}
@@ -1,16 +1,15 @@
<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 data: { label: string } = { label: 'Node' }; export let data: { label: string } = { label: 'Node' };
export let xPos: number = 0; export let positionAbsolute: XYPosition = { x: 0, y: 0 };
export let yPos: number = 0;
</script> </script>
<div class="custom"> <div class="custom">
<div>{data.label}</div> <div>{data.label}</div>
<div>{~~xPos}, {~~yPos}</div> <div>{~~positionAbsolute.x}, {~~positionAbsolute.y}</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>
@@ -7,8 +7,8 @@
export let flowConfig: FlowConfig; export let flowConfig: FlowConfig;
// Create writables here so it is easier to create test cases // Create writables here so it is easier to create test cases
const nodes = writable(flowConfig.flowProps.nodes); const nodes = writable(flowConfig.flowProps?.nodes);
const edges = writable(flowConfig.flowProps.edges); const edges = writable(flowConfig.flowProps?.edges);
const flowProps = { ...flowConfig.flowProps, nodes, edges }; const flowProps = { ...flowConfig.flowProps, nodes, edges };
</script> </script>
+2 -2
View File
@@ -8,8 +8,8 @@
"scripts": { "scripts": {
"preinstall": "npx only-allow pnpm", "preinstall": "npx only-allow pnpm",
"dev": "turbo run dev --parallel --concurrency 12", "dev": "turbo run dev --parallel --concurrency 12",
"dev:svelte": "turbo run dev --filter=svelte --filter=system", "dev:svelte": "turbo run dev --filter=svelte-examples...",
"dev:react": "turbo run dev --filter=react", "dev:react": "turbo run dev --filter=react-examples...",
"test:svelte": "pnpm --filter=playwright run test:svelte", "test:svelte": "pnpm --filter=playwright run test:svelte",
"test:svelte:ui": "pnpm --filter=playwright run test:svelte:ui", "test:svelte:ui": "pnpm --filter=playwright run test:svelte:ui",
"test:react": "pnpm --filter=playwright run test:react", "test:react": "pnpm --filter=playwright run test:react",
@@ -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',
@@ -118,7 +118,7 @@ function MiniMap({
const onSvgNodeClick = onNodeClick const onSvgNodeClick = onNodeClick
? useCallback((event: MouseEvent, nodeId: string) => { ? useCallback((event: MouseEvent, nodeId: string) => {
const node = store.getState().nodes.find((n) => n.id === nodeId)!; const node = store.getState().nodeLookup.get(nodeId)!;
onNodeClick(event, node); onNodeClick(event, node);
}, []) }, [])
: undefined; : undefined;
@@ -126,7 +126,15 @@ function MiniMap({
return ( return (
<Panel <Panel
position={position} position={position}
style={style} style={
{
...style,
'--minimap-mask-color-props': typeof maskColor === 'string' ? maskColor : undefined,
'--minimap-node-background-color-props': typeof nodeColor === 'string' ? nodeColor : undefined,
'--minimap-node-stroke-color-props': typeof nodeStrokeColor === 'string' ? nodeStrokeColor : undefined,
'--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}
/> />
@@ -12,15 +12,18 @@ import type { MiniMapNodes, GetMiniMapNodeAttribute } 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 selectorNodes = (s: ReactFlowState) =>
s.nodes.filter(
(node) => !node.hidden && (node.computed?.width || node.width) && (node.computed?.height || node.height)
);
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,
@@ -38,20 +41,22 @@ function MiniMapNodes({
<> <>
{nodes.map((node) => { {nodes.map((node) => {
const { x, y } = getNodePositionWithOrigin(node, node.origin || nodeOrigin).positionAbsolute; const { x, y } = getNodePositionWithOrigin(node, node.origin || nodeOrigin).positionAbsolute;
const color = nodeColor === undefined ? undefined : nodeColorFunc(node);
const strokeColor = nodeStrokeColor === undefined ? undefined : nodeStrokeColorFunc(node);
return ( return (
<NodeComponent <NodeComponent
key={node.id} key={node.id}
x={x} x={x}
y={y} y={y}
width={node.width!} width={node.computed?.width ?? node.width ?? 0}
height={node.height!} height={node.computed?.height ?? node.height ?? 0}
style={node.style} style={node.style}
selected={!!node.selected} selected={!!node.selected}
className={nodeClassNameFunc(node)} className={nodeClassNameFunc(node)}
color={nodeColorFunc(node)} color={color}
borderRadius={nodeBorderRadius} borderRadius={nodeBorderRadius}
strokeColor={nodeStrokeColorFunc(node)} strokeColor={strokeColor}
strokeWidth={nodeStrokeWidth} strokeWidth={nodeStrokeWidth}
shapeRendering={shapeRendering} shapeRendering={shapeRendering}
onClick={onClick} onClick={onClick}
@@ -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;
@@ -65,13 +65,13 @@ function ResizeControl({
const dragHandler = drag<HTMLDivElement, unknown>() const dragHandler = drag<HTMLDivElement, unknown>()
.on('start', (event: ResizeDragEvent) => { .on('start', (event: ResizeDragEvent) => {
const { nodes, transform, snapGrid, snapToGrid } = store.getState(); const { nodeLookup, transform, snapGrid, snapToGrid } = store.getState();
const node = nodes.find((n) => n.id === id); const node = nodeLookup.get(id);
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,
}; };
@@ -86,9 +86,9 @@ function ResizeControl({
onResizeStart?.(event, { ...prevValues.current }); onResizeStart?.(event, { ...prevValues.current });
}) })
.on('drag', (event: ResizeDragEvent) => { .on('drag', (event: ResizeDragEvent) => {
const { nodes, transform, snapGrid, snapToGrid, triggerNodeChanges } = store.getState(); const { nodeLookup, transform, snapGrid, snapToGrid, triggerNodeChanges } = store.getState();
const { xSnapped, ySnapped } = getPointerPosition(event.sourceEvent, { transform, snapGrid, snapToGrid }); const { xSnapped, ySnapped } = getPointerPosition(event.sourceEvent, { transform, snapGrid, snapToGrid });
const node = nodes.find((n) => n.id === id); const node = nodeLookup.get(id);
if (node) { if (node) {
const changes: NodeChange[] = []; const changes: NodeChange[] = [];
@@ -1,17 +1,17 @@
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 | undefined, b: Node | undefined) =>
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?.width === b?.width &&
a?.height === b?.height && a?.height === b?.height &&
a?.selected === b?.selected && a?.selected === b?.selected &&
@@ -22,53 +22,15 @@ const nodesEqualityFn = (a: Node[], b: Node[]) => {
}; };
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,
@@ -87,17 +49,19 @@ function NodeToolbar({
const nodeIds = Array.isArray(nodeId) ? nodeId : [nodeId || contextNodeId || '']; const nodeIds = Array.isArray(nodeId) ? nodeId : [nodeId || contextNodeId || ''];
return nodeIds.reduce<Node[]>((acc, id) => { return nodeIds.reduce<Node[]>((acc, id) => {
const node = state.nodes.find((n) => n.id === id); const node = state.nodeLookup.get(id);
if (node) { if (node) {
acc.push(node); acc.push(node);
} }
return acc; return acc;
}, [] as Node[]); }, []);
}, },
[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 +74,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';
@@ -43,7 +43,7 @@ const ConnectionLine = ({
const { fromNode, handleId, toX, toY, connectionMode } = useStore( const { fromNode, handleId, toX, toY, connectionMode } = useStore(
useCallback( useCallback(
(s: ReactFlowStore) => ({ (s: ReactFlowStore) => ({
fromNode: s.nodes.find((n) => n.id === nodeId), fromNode: s.nodeLookup.get(nodeId),
handleId: s.connectionStartHandle?.handleId, handleId: s.connectionStartHandle?.handleId,
toX: (s.connectionPosition.x - s.transform[0]) / s.transform[2], toX: (s.connectionPosition.x - s.transform[0]) / s.transform[2],
toY: (s.connectionPosition.y - s.transform[1]) / s.transform[2], toY: (s.connectionPosition.y - s.transform[1]) / s.transform[2],
@@ -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;
@@ -1,7 +1,6 @@
import { isNumeric } from '@xyflow/system'; import { isNumeric } from '@xyflow/system';
import type { BaseEdgeProps } from '../../types'; import type { BaseEdgeProps } from '../../types';
import EdgeText from './EdgeText'; import EdgeText from './EdgeText';
const BaseEdge = ({ const BaseEdge = ({
@@ -1,4 +1,4 @@
import { memo, useState, useMemo, useRef, type ComponentType, type KeyboardEvent } from 'react'; import { memo, useState, useMemo, useRef, type ComponentType, type KeyboardEvent, useCallback } from 'react';
import cc from 'classcat'; import cc from 'classcat';
import { shallow } from 'zustand/shallow'; import { shallow } from 'zustand/shallow';
import { getMarkerId, elementSelectionKeys, XYHandle, type Connection, getEdgePosition } from '@xyflow/system'; import { getMarkerId, elementSelectionKeys, XYHandle, type Connection, getEdgePosition } from '@xyflow/system';
@@ -53,26 +53,30 @@ export default (EdgeComponent: ComponentType<EdgeProps>) => {
const [updateHover, setUpdateHover] = useState<boolean>(false); const [updateHover, setUpdateHover] = useState<boolean>(false);
const [updating, setUpdating] = useState<boolean>(false); const [updating, setUpdating] = useState<boolean>(false);
const store = useStoreApi(); const store = useStoreApi();
const edgePosition = useStore((state) => { const edgePosition = useStore(
const sourceNode = state.nodes.find((n) => n.id === source); useCallback(
const targetNode = state.nodes.find((n) => n.id === target); (state) => {
const sourceNode = state.nodeLookup.get(source);
const targetNode = state.nodeLookup.get(target);
if (!sourceNode || !targetNode) { if (!sourceNode || !targetNode) {
return null; return null;
} }
const pos = getEdgePosition({ return getEdgePosition({
id, id,
sourceNode, sourceNode,
targetNode, targetNode,
sourceHandle: sourceHandleId || null, sourceHandle: sourceHandleId || null,
targetHandle: targetHandleId || null, targetHandle: targetHandleId || null,
connectionMode: state.connectionMode, connectionMode: state.connectionMode,
onError: state.onError, onError: state.onError,
}); });
},
return pos; [source, target]
}, shallow); ),
shallow
);
const markerStartUrl = useMemo(() => `url(#${getMarkerId(markerStart, rfId)})`, [markerStart, rfId]); const markerStartUrl = useMemo(() => `url(#${getMarkerId(markerStart, rfId)})`, [markerStart, rfId]);
const markerEndUrl = useMemo(() => `url(#${getMarkerId(markerEnd, rfId)})`, [markerEnd, rfId]); const markerEndUrl = useMemo(() => `url(#${getMarkerId(markerEnd, rfId)})`, [markerEnd, rfId]);
@@ -1,3 +1,8 @@
/*
* The Handle component is used to connect nodes. When the user mousedowns a handle, we start the connection process.
* The user can then drag the connection to another handle or node. When the user releases the mouse, we check if the
* connection is valid and if so, we call the onConnect callback.
*/
import { memo, HTMLAttributes, forwardRef, MouseEvent as ReactMouseEvent, TouchEvent as ReactTouchEvent } from 'react'; import { memo, HTMLAttributes, forwardRef, MouseEvent as ReactMouseEvent, TouchEvent as ReactTouchEvent } from 'react';
import cc from 'classcat'; import cc from 'classcat';
import { shallow } from 'zustand/shallow'; import { shallow } from 'zustand/shallow';
+3 -3
View File
@@ -12,7 +12,7 @@ export function getMouseHandler(
return handler === undefined return handler === undefined
? handler ? handler
: (event: MouseEvent) => { : (event: MouseEvent) => {
const node = getState().nodes.find((n) => n.id === id)!; const node = getState().nodeLookup.get(id)!;
handler(event, { ...node }); handler(event, { ...node });
}; };
} }
@@ -35,8 +35,8 @@ export function handleNodeClick({
unselect?: boolean; unselect?: boolean;
nodeRef?: RefObject<HTMLDivElement>; nodeRef?: RefObject<HTMLDivElement>;
}) { }) {
const { addSelectedNodes, unselectNodesAndEdges, multiSelectionActive, nodes, onError } = store.getState(); const { addSelectedNodes, unselectNodesAndEdges, multiSelectionActive, nodeLookup, onError } = store.getState();
const node = nodes.find((n) => n.id === id)!; const node = nodeLookup.get(id);
if (!node) { if (!node) {
onError?.('012', errorMessages['error012'](id)); onError?.('012', errorMessages['error012'](id));
@@ -52,8 +52,9 @@ export default (NodeComponent: ComponentType<NodeProps>) => {
disableKeyboardA11y, disableKeyboardA11y,
ariaLabel, ariaLabel,
rfId, rfId,
sizeWidth, positionAbsolute,
sizeHeight, width,
height,
}: WrapNodeProps) => { }: WrapNodeProps) => {
const store = useStoreApi(); const store = useStoreApi();
const nodeRef = useRef<HTMLDivElement>(null); const nodeRef = useRef<HTMLDivElement>(null);
@@ -146,7 +147,7 @@ export default (NodeComponent: ComponentType<NodeProps>) => {
if (targetPosChanged) { if (targetPosChanged) {
prevTargetPosition.current = targetPosition; prevTargetPosition.current = targetPosition;
} }
store.getState().updateNodeDimensions([{ id, nodeElement: nodeRef.current, forceUpdate: true }]); store.getState().updateNodeDimensions(new Map([[id, { id, nodeElement: nodeRef.current, forceUpdate: true }]]));
} }
}, [id, type, sourcePosition, targetPosition]); }, [id, type, sourcePosition, targetPosition]);
@@ -186,8 +187,8 @@ export default (NodeComponent: ComponentType<NodeProps>) => {
transform: `translate(${xPosOrigin}px,${yPosOrigin}px)`, transform: `translate(${xPosOrigin}px,${yPosOrigin}px)`,
pointerEvents: hasPointerEvents ? 'all' : 'none', pointerEvents: hasPointerEvents ? 'all' : 'none',
visibility: initialized ? 'visible' : 'hidden', visibility: initialized ? 'visible' : 'hidden',
width: sizeWidth, width,
height: sizeHeight, height,
...style, ...style,
}} }}
data-id={id} data-id={id}
@@ -209,8 +210,9 @@ export default (NodeComponent: ComponentType<NodeProps>) => {
id={id} id={id}
data={data} data={data}
type={type} type={type}
xPos={xPos} width={width}
yPos={yPos} height={height}
positionAbsolute={positionAbsolute}
selected={selected} selected={selected}
isConnectable={isConnectable} isConnectable={isConnectable}
sourcePosition={sourcePosition} sourcePosition={sourcePosition}
@@ -1,3 +1,9 @@
/*
* This is a helper component for calling the onSelectionChange listener.
* It will only be mounted if the user has passed an onSelectionChange listener
* or is using the useOnSelectionChange hook.
* @TODO: Now that we have the onNodesChange and on EdgesChange listeners, do we still need this component?
*/
import { memo, useEffect } from 'react'; import { memo, useEffect } from 'react';
import { shallow } from 'zustand/shallow'; import { shallow } from 'zustand/shallow';
@@ -24,8 +30,6 @@ function areEqual(a: SelectorSlice, b: SelectorSlice) {
); );
} }
// This is just a helper component for calling the onSelectionChange listener.
// @TODO: Now that we have the onNodesChange and on EdgesChange listeners, do we still need this component?
const SelectionListener = memo(({ onSelectionChange }: SelectionListenerProps) => { const SelectionListener = memo(({ onSelectionChange }: SelectionListenerProps) => {
const store = useStoreApi(); const store = useStoreApi();
const { selectedNodes, selectedEdges } = useStore(selector, areEqual); const { selectedNodes, selectedEdges } = useStore(selector, areEqual);
@@ -1,3 +1,8 @@
/*
* This component helps us to update the store with the vlues coming from the user.
* 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`).
*/
import { useEffect } from 'react'; import { useEffect } from 'react';
import { StoreApi } from 'zustand'; import { StoreApi } from 'zustand';
import { shallow } from 'zustand/shallow'; import { shallow } from 'zustand/shallow';
@@ -38,6 +43,7 @@ type StoreUpdaterProps = Pick<
| 'fitViewOptions' | 'fitViewOptions'
| 'onNodesDelete' | 'onNodesDelete'
| 'onEdgesDelete' | 'onEdgesDelete'
| 'onDelete'
| 'onNodeDragStart' | 'onNodeDragStart'
| 'onNodeDrag' | 'onNodeDrag'
| 'onNodeDragStop' | 'onNodeDragStop'
@@ -70,10 +76,10 @@ const selector = (s: ReactFlowState) => ({
reset: s.reset, reset: s.reset,
}); });
function useStoreUpdater<T>(value: T | undefined, setStoreState: (param: T) => void) { function useStoreUpdater<T>(value: T | undefined, setStoreAction: (param: T) => void) {
useEffect(() => { useEffect(() => {
if (typeof value !== 'undefined') { if (typeof value !== 'undefined') {
setStoreState(value); setStoreAction(value);
} }
}, [value]); }, [value]);
} }
@@ -123,6 +129,7 @@ const StoreUpdater = ({
fitViewOptions, fitViewOptions,
onNodesDelete, onNodesDelete,
onEdgesDelete, onEdgesDelete,
onDelete,
onNodeDrag, onNodeDrag,
onNodeDragStart, onNodeDragStart,
onNodeDragStop, onNodeDragStop,
@@ -187,6 +194,7 @@ const StoreUpdater = ({
useDirectStoreUpdater('fitViewOnInitOptions', fitViewOptions, store.setState); useDirectStoreUpdater('fitViewOnInitOptions', fitViewOptions, store.setState);
useDirectStoreUpdater('onNodesDelete', onNodesDelete, store.setState); useDirectStoreUpdater('onNodesDelete', onNodesDelete, store.setState);
useDirectStoreUpdater('onEdgesDelete', onEdgesDelete, store.setState); useDirectStoreUpdater('onEdgesDelete', onEdgesDelete, store.setState);
useDirectStoreUpdater('onDelete', onDelete, store.setState);
useDirectStoreUpdater('onNodeDrag', onNodeDrag, store.setState); useDirectStoreUpdater('onNodeDrag', onNodeDrag, store.setState);
useDirectStoreUpdater('onNodeDragStart', onNodeDragStart, store.setState); useDirectStoreUpdater('onNodeDragStart', onNodeDragStart, store.setState);
useDirectStoreUpdater('onNodeDragStop', onNodeDragStop, store.setState); useDirectStoreUpdater('onNodeDragStop', onNodeDragStop, store.setState);
@@ -63,6 +63,8 @@ const EdgeRenderer = ({
children, children,
}: EdgeRendererProps) => { }: EdgeRendererProps) => {
const { edgesFocusable, edgesUpdatable, elementsSelectable, onError } = useStore(selector, shallow); const { edgesFocusable, edgesUpdatable, elementsSelectable, onError } = useStore(selector, shallow);
// we are grouping edges by zIndex here in order to be able to render them in the correct order
// each zIndex gets its own svg element
const edgeTree = useVisibleEdges(onlyRenderVisibleElements, elevateEdgesOnSelect); const edgeTree = useVisibleEdges(onlyRenderVisibleElements, elevateEdgesOnSelect);
return ( return (
@@ -70,7 +72,7 @@ const EdgeRenderer = ({
{edgeTree.map(({ level, edges, isMaxLevel }) => ( {edgeTree.map(({ level, edges, isMaxLevel }) => (
<svg key={level} style={{ zIndex: level }} className="react-flow__edges react-flow__container"> <svg key={level} style={{ zIndex: level }} className="react-flow__edges react-flow__container">
{isMaxLevel && <MarkerDefinitions defaultColor={defaultMarkerColor} rfId={rfId} />} {isMaxLevel && <MarkerDefinitions defaultColor={defaultMarkerColor} rfId={rfId} />}
<g> <>
{edges.map((edge) => { {edges.map((edge) => {
let edgeType = edge.type || 'default'; let edgeType = edge.type || 'default';
@@ -132,7 +134,7 @@ const EdgeRenderer = ({
/> />
); );
})} })}
</g> </>
</svg> </svg>
))} ))}
{children} {children}
@@ -48,11 +48,16 @@ const NodeRenderer = (props: NodeRendererProps) => {
} }
const observer = new ResizeObserver((entries: ResizeObserverEntry[]) => { const observer = new ResizeObserver((entries: ResizeObserverEntry[]) => {
const updates = entries.map((entry: ResizeObserverEntry) => ({ const updates = new Map();
id: entry.target.getAttribute('data-id') as string,
nodeElement: entry.target as HTMLDivElement, entries.forEach((entry: ResizeObserverEntry) => {
forceUpdate: true, const id = entry.target.getAttribute('data-id') as string;
})); updates.set(id, {
id,
nodeElement: entry.target as HTMLDivElement,
forceUpdate: true,
});
});
updateNodeDimensions(updates); updateNodeDimensions(updates);
}); });
@@ -86,19 +91,19 @@ const NodeRenderer = (props: NodeRendererProps) => {
const isFocusable = !!(node.focusable || (nodesFocusable && typeof node.focusable === 'undefined')); const isFocusable = !!(node.focusable || (nodesFocusable && typeof node.focusable === 'undefined'));
const clampedPosition = props.nodeExtent const clampedPosition = props.nodeExtent
? clampPosition(node.positionAbsolute, props.nodeExtent) ? clampPosition(node.computed?.positionAbsolute, props.nodeExtent)
: node.positionAbsolute; : node.computed?.positionAbsolute;
const posX = clampedPosition?.x ?? 0; const posX = clampedPosition?.x ?? 0;
const posY = clampedPosition?.y ?? 0; const posY = clampedPosition?.y ?? 0;
const posOrigin = getPositionWithOrigin({ const posOrigin = getPositionWithOrigin({
x: posX, x: posX,
y: posY, y: posY,
width: node.width ?? 0, width: node.computed?.width ?? node.width ?? 0,
height: node.height ?? 0, height: node.computed?.height ?? node.height ?? 0,
origin: node.origin || props.nodeOrigin, origin: node.origin || props.nodeOrigin,
}); });
const initialized = (!!node.width && !!node.height) || (!!node.size?.width && !!node.size?.height); const initialized = (!!node.computed?.width && !!node.computed?.height) || (!!node.width && !!node.height);
return ( return (
<NodeComponent <NodeComponent
@@ -106,8 +111,8 @@ const NodeRenderer = (props: NodeRendererProps) => {
id={node.id} id={node.id}
className={node.className} className={node.className}
style={node.style} style={node.style}
sizeWidth={node.size?.width} width={node.width ?? undefined}
sizeHeight={node.size?.height} height={node.height ?? undefined}
type={nodeType} type={nodeType}
data={node.data} data={node.data}
sourcePosition={node.sourcePosition || Position.Bottom} sourcePosition={node.sourcePosition || Position.Bottom}
@@ -117,6 +122,7 @@ const NodeRenderer = (props: NodeRendererProps) => {
yPos={posY} yPos={posY}
xPosOrigin={posOrigin.x} xPosOrigin={posOrigin.x}
yPosOrigin={posOrigin.y} yPosOrigin={posOrigin.y}
positionAbsolute={clampedPosition || { x: 0, y: 0 }}
onClick={props.onNodeClick} onClick={props.onNodeClick}
onMouseEnter={props.onNodeMouseEnter} onMouseEnter={props.onNodeMouseEnter}
onMouseMove={props.onNodeMouseMove} onMouseMove={props.onNodeMouseMove}
@@ -23,6 +23,7 @@ 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 { EdgeTypes, NodeTypes, ReactFlowProps, ReactFlowRefType } from '../../types';
import useColorModeClass from '../../hooks/useColorModeClass';
const defaultNodeTypes: NodeTypes = { const defaultNodeTypes: NodeTypes = {
input: InputNode, input: InputNode,
@@ -82,6 +83,7 @@ const ReactFlow = forwardRef<ReactFlowRefType, ReactFlowProps>(
onNodeDragStop, onNodeDragStop,
onNodesDelete, onNodesDelete,
onEdgesDelete, onEdgesDelete,
onDelete,
onSelectionChange, onSelectionChange,
onSelectionDragStart, onSelectionDragStart,
onSelectionDrag, onSelectionDrag,
@@ -168,18 +170,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}
> >
@@ -281,6 +285,7 @@ const ReactFlow = forwardRef<ReactFlowRefType, ReactFlowProps>(
fitViewOptions={fitViewOptions} fitViewOptions={fitViewOptions}
onNodesDelete={onNodesDelete} onNodesDelete={onNodesDelete}
onEdgesDelete={onEdgesDelete} onEdgesDelete={onEdgesDelete}
onDelete={onDelete}
onNodeDragStart={onNodeDragStart} onNodeDragStart={onNodeDragStart}
onNodeDrag={onNodeDrag} onNodeDrag={onNodeDrag}
onNodeDragStop={onNodeDragStop} onNodeDragStop={onNodeDragStop}
@@ -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';
}
+6 -3
View File
@@ -35,7 +35,7 @@ export default function useReactFlow<NodeData = any, EdgeData = any>(): ReactFlo
}, []); }, []);
const getNode = useCallback<Instance.GetNode<NodeData>>((id) => { const getNode = useCallback<Instance.GetNode<NodeData>>((id) => {
return store.getState().nodes.find((n) => n.id === id); return store.getState().nodeLookup.get(id);
}, []); }, []);
const getEdges = useCallback<Instance.GetEdges<EdgeData>>(() => { const getEdges = useCallback<Instance.GetEdges<EdgeData>>(() => {
@@ -127,6 +127,7 @@ export default function useReactFlow<NodeData = any, EdgeData = any>(): ReactFlo
onEdgesDelete, onEdgesDelete,
onNodesChange, onNodesChange,
onEdgesChange, onEdgesChange,
onDelete,
} = store.getState(); } = store.getState();
const { matchingNodes, matchingEdges } = getElementsToRemove<Node, Edge>({ const { matchingNodes, matchingEdges } = getElementsToRemove<Node, Edge>({
nodesToRemove: nodesDeleted || [], nodesToRemove: nodesDeleted || [],
@@ -171,6 +172,8 @@ export default function useReactFlow<NodeData = any, EdgeData = any>(): ReactFlo
onNodesChange(nodeChanges); onNodesChange(nodeChanges);
} }
} }
onDelete?.({ nodes: matchingNodes, edges: matchingEdges });
} }
return { deletedNodes: matchingNodes, deletedEdges: matchingEdges }; return { deletedNodes: matchingNodes, deletedEdges: matchingEdges };
@@ -181,7 +184,7 @@ export default function useReactFlow<NodeData = any, EdgeData = any>(): ReactFlo
nodeOrRect: Node<NodeData> | { id: Node['id'] } | Rect nodeOrRect: Node<NodeData> | { id: Node['id'] } | Rect
): [Rect | null, Node<NodeData> | null | undefined, boolean] => { ): [Rect | null, Node<NodeData> | null | undefined, boolean] => {
const isRect = isRectObject(nodeOrRect); const isRect = isRectObject(nodeOrRect);
const node = isRect ? null : store.getState().nodes.find((n) => n.id === nodeOrRect.id); const node = isRect ? null : store.getState().nodeLookup.get(nodeOrRect.id);
if (!isRect && !node) { if (!isRect && !node) {
[null, null, isRect]; [null, null, isRect];
@@ -203,7 +206,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;
} }
@@ -8,17 +8,16 @@ function useUpdateNodeInternals(): UpdateNodeInternals {
return useCallback<UpdateNodeInternals>((id: string | string[]) => { return useCallback<UpdateNodeInternals>((id: string | string[]) => {
const { domNode, updateNodeDimensions } = store.getState(); const { domNode, updateNodeDimensions } = store.getState();
const updateIds = Array.isArray(id) ? id : [id]; const updateIds = Array.isArray(id) ? id : [id];
const updates = updateIds.reduce<NodeDimensionUpdate[]>((res, updateId) => { const updates = new Map<string, NodeDimensionUpdate>();
updateIds.forEach((updateId) => {
const nodeElement = domNode?.querySelector(`.react-flow__node[data-id="${updateId}"]`) as HTMLDivElement; const nodeElement = domNode?.querySelector(`.react-flow__node[data-id="${updateId}"]`) as HTMLDivElement;
if (nodeElement) { if (nodeElement) {
res.push({ id: updateId, nodeElement, forceUpdate: true }); updates.set(updateId, { id: updateId, nodeElement, forceUpdate: true });
} }
});
return res;
}, []);
requestAnimationFrame(() => updateNodeDimensions(updates)); requestAnimationFrame(() => updateNodeDimensions(updates));
}, []); }, []);
@@ -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;
+3 -3
View File
@@ -12,8 +12,8 @@ function useVisibleEdges(onlyRenderVisible: boolean, elevateEdgesOnSelect: boole
const visibleEdges = const visibleEdges =
onlyRenderVisible && s.width && s.height onlyRenderVisible && s.width && s.height
? s.edges.filter((e) => { ? s.edges.filter((e) => {
const sourceNode = s.nodes.find((n) => n.id === e.source); const sourceNode = s.nodeLookup.get(e.source);
const targetNode = s.nodes.find((n) => n.id === e.target); const targetNode = s.nodeLookup.get(e.target);
return ( return (
sourceNode && sourceNode &&
@@ -29,7 +29,7 @@ function useVisibleEdges(onlyRenderVisible: boolean, elevateEdgesOnSelect: boole
}) })
: s.edges; : s.edges;
return groupEdgesByZLevel(visibleEdges, s.nodes, elevateEdgesOnSelect); return groupEdgesByZLevel(visibleEdges, s.nodeLookup, elevateEdgesOnSelect);
}, },
[onlyRenderVisible, elevateEdgesOnSelect] [onlyRenderVisible, elevateEdgesOnSelect]
), ),
+2
View File
@@ -73,6 +73,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
+36 -13
View File
@@ -41,18 +41,19 @@ const createRFStore = ({
(set, get) => ({ (set, get) => ({
...getInitialState({ nodes, edges, width, height, fitView }), ...getInitialState({ nodes, edges, width, height, fitView }),
setNodes: (nodes: Node[]) => { setNodes: (nodes: Node[]) => {
const { nodes: storeNodes, nodeOrigin, elevateNodesOnSelect } = get(); const { nodeLookup, nodeOrigin, elevateNodesOnSelect } = get();
const nextNodes = updateNodes(nodes, storeNodes, { nodeOrigin, elevateNodesOnSelect }); // Whenver new nodes are set, we need to calculate the absolute positions of the nodes
// and update the nodeLookup.
const nextNodes = updateNodes(nodes, nodeLookup, { nodeOrigin, elevateNodesOnSelect });
set({ nodes: nextNodes }); set({ nodes: nextNodes });
}, },
getNodes: () => {
return get().nodes;
},
setEdges: (edges: Edge[]) => { setEdges: (edges: Edge[]) => {
const { defaultEdgeOptions = {} } = get(); const { defaultEdgeOptions = {} } = get();
set({ edges: edges.map((e) => ({ ...defaultEdgeOptions, ...e })) }); set({ edges: edges.map((e) => ({ ...defaultEdgeOptions, ...e })) });
}, },
// when the user works with an uncontrolled flow,
// we set a flag `hasDefaultNodes` / `hasDefaultEdges`
setDefaultNodesAndEdges: (nodes?: Node[], edges?: Edge[]) => { setDefaultNodesAndEdges: (nodes?: Node[], edges?: Edge[]) => {
const hasDefaultNodes = typeof nodes !== 'undefined'; const hasDefaultNodes = typeof nodes !== 'undefined';
const hasDefaultEdges = typeof edges !== 'undefined'; const hasDefaultEdges = typeof edges !== 'undefined';
@@ -68,7 +69,7 @@ const createRFStore = ({
}; };
if (hasDefaultNodes) { if (hasDefaultNodes) {
nextState.nodes = updateNodes(nodes, [], { nextState.nodes = updateNodes(nodes, new Map(), {
nodeOrigin: get().nodeOrigin, nodeOrigin: get().nodeOrigin,
elevateNodesOnSelect: get().elevateNodesOnSelect, elevateNodesOnSelect: get().elevateNodesOnSelect,
}); });
@@ -79,14 +80,27 @@ const createRFStore = ({
set(nextState); set(nextState);
}, },
// Every node gets registerd at a ResizeObserver. Whenever a node
// changes its dimensions, this function is called to measure the
// new dimensions and update the nodes.
updateNodeDimensions: (updates) => { updateNodeDimensions: (updates) => {
const { onNodesChange, fitView, nodes, fitViewOnInit, fitViewDone, fitViewOnInitOptions, domNode, nodeOrigin } = const {
get(); onNodesChange,
fitView,
nodes,
nodeLookup,
fitViewOnInit,
fitViewDone,
fitViewOnInitOptions,
domNode,
nodeOrigin,
} = get();
const changes: NodeDimensionChange[] = []; const changes: NodeDimensionChange[] = [];
const updatedNodes = updateNodeDimensionsSystem( const updatedNodes = updateNodeDimensionsSystem(
updates, updates,
nodes, nodes,
nodeLookup,
domNode, domNode,
nodeOrigin, nodeOrigin,
(id: string, dimensions: Dimensions) => { (id: string, dimensions: Dimensions) => {
@@ -102,8 +116,9 @@ const createRFStore = ({
return; return;
} }
const nextNodes = updateAbsolutePositions(updatedNodes, nodeOrigin); const nextNodes = updateAbsolutePositions(updatedNodes, nodeLookup, nodeOrigin);
// we call fitView once initially after all dimensions are set
let nextFitViewDone = fitViewDone; let nextFitViewDone = fitViewDone;
if (!fitViewDone && fitViewOnInit) { if (!fitViewDone && fitViewOnInit) {
nextFitViewDone = fitView(nextNodes, { nextFitViewDone = fitView(nextNodes, {
@@ -112,6 +127,11 @@ const createRFStore = ({
}); });
} }
// here we are cirmumventing the onNodesChange handler
// in order to be able to display nodes even if the user
// has not provided an onNodesChange handler.
// Nodes are only rendered if they have a width and height
// attribute which they get from this handler.
set({ nodes: nextNodes, fitViewDone: nextFitViewDone }); set({ nodes: nextNodes, fitViewDone: nextFitViewDone });
if (changes?.length > 0) { if (changes?.length > 0) {
@@ -127,7 +147,7 @@ const createRFStore = ({
}; };
if (positionChanged) { if (positionChanged) {
change.positionAbsolute = node.positionAbsolute; change.positionAbsolute = node.computed?.positionAbsolute;
change.position = node.position; change.position = node.position;
} }
@@ -138,12 +158,12 @@ const createRFStore = ({
}, },
triggerNodeChanges: (changes) => { triggerNodeChanges: (changes) => {
const { onNodesChange, nodes, hasDefaultNodes, nodeOrigin, elevateNodesOnSelect } = get(); const { onNodesChange, nodeLookup, nodes, hasDefaultNodes, nodeOrigin, elevateNodesOnSelect } = get();
if (changes?.length) { if (changes?.length) {
if (hasDefaultNodes) { if (hasDefaultNodes) {
const updatedNodes = applyNodeChanges(changes, nodes); const updatedNodes = applyNodeChanges(changes, nodes);
const nextNodes = updateNodes(updatedNodes, nodes, { const nextNodes = updateNodes(updatedNodes, nodeLookup, {
nodeOrigin, nodeOrigin,
elevateNodesOnSelect, elevateNodesOnSelect,
}); });
@@ -256,7 +276,10 @@ const createRFStore = ({
return { return {
...node, ...node,
positionAbsolute, computed: {
...node.computed,
positionAbsolute,
},
}; };
}), }),
}); });
+4 -6
View File
@@ -22,16 +22,13 @@ const getInitialState = ({
height?: number; height?: number;
fitView?: boolean; fitView?: boolean;
} = {}): ReactFlowStore => { } = {}): ReactFlowStore => {
const nextNodes = updateNodes(nodes, [], { nodeOrigin: [0, 0], elevateNodesOnSelect: false }); const nodeLookup = new Map<string, Node>();
const nextNodes = updateNodes(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];
@@ -43,6 +40,7 @@ const getInitialState = ({
height: 0, height: 0,
transform, transform,
nodes: nextNodes, nodes: nextNodes,
nodeLookup,
edges: edges, edges: edges,
onNodesChange: null, onNodesChange: null,
onEdgesChange: null, onEdgesChange: null,
+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));
}
@@ -19,6 +19,7 @@ import type {
SelectionMode, SelectionMode,
OnError, OnError,
IsValidConnection, IsValidConnection,
ColorMode,
} from '@xyflow/system'; } from '@xyflow/system';
import type { import type {
@@ -34,6 +35,7 @@ import type {
FitViewOptions, FitViewOptions,
OnNodesDelete, OnNodesDelete,
OnEdgesDelete, OnEdgesDelete,
OnDelete,
OnNodesChange, OnNodesChange,
OnEdgesChange, OnEdgesChange,
NodeDragHandler, NodeDragHandler,
@@ -70,6 +72,7 @@ export type ReactFlowProps = Omit<HTMLAttributes<HTMLDivElement>, 'onError'> & {
onEdgesChange?: OnEdgesChange; onEdgesChange?: OnEdgesChange;
onNodesDelete?: OnNodesDelete; onNodesDelete?: OnNodesDelete;
onEdgesDelete?: OnEdgesDelete; onEdgesDelete?: OnEdgesDelete;
onDelete?: OnDelete;
onSelectionDragStart?: SelectionDragHandler; onSelectionDragStart?: SelectionDragHandler;
onSelectionDrag?: SelectionDragHandler; onSelectionDrag?: SelectionDragHandler;
onSelectionDragStop?: SelectionDragHandler; onSelectionDragStop?: SelectionDragHandler;
@@ -153,6 +156,7 @@ export type ReactFlowProps = Omit<HTMLAttributes<HTMLDivElement>, 'onError'> & {
nodeDragThreshold?: number; nodeDragThreshold?: number;
width?: number; width?: number;
height?: number; height?: number;
colorMode?: ColorMode;
}; };
export type ReactFlowRefType = HTMLDivElement; export type ReactFlowRefType = HTMLDivElement;
+1
View File
@@ -21,6 +21,7 @@ export type OnEdgesChange = (changes: EdgeChange[]) => void;
export type OnNodesDelete = (nodes: Node[]) => void; export type OnNodesDelete = (nodes: Node[]) => void;
export type OnEdgesDelete = (edges: Edge[]) => void; export type OnEdgesDelete = (edges: Edge[]) => void;
export type OnDelete = (params: { nodes: Node[]; edges: Edge[] }) => void;
export type NodeTypes = { [key: string]: ComponentType<NodeProps> }; export type NodeTypes = { [key: string]: ComponentType<NodeProps> };
export type NodeTypesWrapped = { [key: string]: MemoExoticComponent<ComponentType<WrapNodeProps>> }; export type NodeTypesWrapped = { [key: string]: MemoExoticComponent<ComponentType<WrapNodeProps>> };
+4 -3
View File
@@ -1,5 +1,5 @@
import type { CSSProperties, MouseEvent as ReactMouseEvent } from 'react'; import type { CSSProperties, MouseEvent as ReactMouseEvent } from 'react';
import type { NodeBase } from '@xyflow/system'; import type { NodeBase, XYPosition } from '@xyflow/system';
export type Node<NodeData = any, NodeType extends string | undefined = string | undefined> = NodeBase< export type Node<NodeData = any, NodeType extends string | undefined = string | undefined> = NodeBase<
NodeData, NodeData,
@@ -24,6 +24,7 @@ export type WrapNodeProps<NodeData = any> = Pick<
yPos: number; yPos: number;
xPosOrigin: number; xPosOrigin: number;
yPosOrigin: number; yPosOrigin: number;
positionAbsolute: XYPosition;
initialized: boolean; initialized: boolean;
isSelectable: boolean; isSelectable: boolean;
isDraggable: boolean; isDraggable: boolean;
@@ -40,6 +41,6 @@ export type WrapNodeProps<NodeData = any> = Pick<
noPanClassName: string; noPanClassName: string;
rfId: string; rfId: string;
disableKeyboardA11y: boolean; disableKeyboardA11y: boolean;
sizeWidth?: number; width?: number;
sizeHeight?: number; height?: number;
}; };
+4 -2
View File
@@ -38,6 +38,7 @@ import type {
OnEdgesDelete, OnEdgesDelete,
OnSelectionChangeFunc, OnSelectionChangeFunc,
UnselectNodesAndEdgesParams, UnselectNodesAndEdgesParams,
OnDelete,
} from '.'; } from '.';
export type ReactFlowStore = { export type ReactFlowStore = {
@@ -46,6 +47,7 @@ export type ReactFlowStore = {
height: number; height: number;
transform: Transform; transform: Transform;
nodes: Node[]; nodes: Node[];
nodeLookup: Map<string, Node>;
edges: Edge[]; edges: Edge[];
onNodesChange: OnNodesChange | null; onNodesChange: OnNodesChange | null;
onEdgesChange: OnEdgesChange | null; onEdgesChange: OnEdgesChange | null;
@@ -117,6 +119,7 @@ export type ReactFlowStore = {
onNodesDelete?: OnNodesDelete; onNodesDelete?: OnNodesDelete;
onEdgesDelete?: OnEdgesDelete; onEdgesDelete?: OnEdgesDelete;
onDelete?: OnDelete;
onError?: OnError; onError?: OnError;
// event handlers // event handlers
@@ -138,10 +141,9 @@ export type ReactFlowStore = {
export type ReactFlowActions = { export type ReactFlowActions = {
setNodes: (nodes: Node[]) => void; setNodes: (nodes: Node[]) => void;
getNodes: () => Node[];
setEdges: (edges: Edge[]) => void; setEdges: (edges: Edge[]) => void;
setDefaultNodesAndEdges: (nodes?: Node[], edges?: Edge[]) => void; setDefaultNodesAndEdges: (nodes?: Node[], edges?: Edge[]) => void;
updateNodeDimensions: (updates: NodeDimensionUpdate[]) => void; updateNodeDimensions: (updates: Map<string, NodeDimensionUpdate>) => void;
updateNodePositions: UpdateNodePositions; updateNodePositions: UpdateNodePositions;
resetSelectedElements: () => void; resetSelectedElements: () => void;
unselectNodesAndEdges: (params?: UnselectNodesAndEdgesParams) => void; unselectNodesAndEdges: (params?: UnselectNodesAndEdgesParams) => void;
+36 -12
View File
@@ -5,14 +5,17 @@ export function handleParentExpand(res: any[], updateItem: any) {
const parent = res.find((e) => e.id === updateItem.parentNode); const parent = res.find((e) => e.id === updateItem.parentNode);
if (parent) { if (parent) {
const extendWidth = updateItem.position.x + updateItem.width - parent.width; if (!parent.computed) {
const extendHeight = updateItem.position.y + updateItem.height - parent.height; parent.computed = {};
}
const extendWidth = updateItem.position.x + updateItem.computed.width - parent.computed.width;
const extendHeight = updateItem.position.y + updateItem.computed.height - parent.computed.height;
if (extendWidth > 0 || extendHeight > 0 || updateItem.position.x < 0 || updateItem.position.y < 0) { if (extendWidth > 0 || extendHeight > 0 || updateItem.position.x < 0 || updateItem.position.y < 0) {
parent.style = { ...parent.style } || {}; parent.style = { ...parent.style } || {};
parent.style.width = parent.style.width ?? parent.width; parent.style.width = parent.style.width ?? parent.computed.width;
parent.style.height = parent.style.height ?? parent.height; parent.style.height = parent.style.height ?? parent.computed.height;
if (extendWidth > 0) { if (extendWidth > 0) {
parent.style.width += extendWidth; parent.style.width += extendWidth;
@@ -36,30 +39,45 @@ export function handleParentExpand(res: any[], updateItem: any) {
updateItem.position.y = 0; updateItem.position.y = 0;
} }
parent.width = parent.style.width; parent.computed.width = parent.style.width;
parent.height = parent.style.height; parent.computed.height = parent.style.height;
} }
} }
} }
// This function applies changes to nodes or edges that are triggered by React Flow internally.
// When you drag a node for example, React Flow will send a position change update.
// This function then applies the changes and returns the updated elements.
function applyChanges(changes: any[], elements: any[]): any[] { function applyChanges(changes: any[], elements: any[]): any[] {
// we need this hack to handle the setNodes and setEdges function of the useReactFlow hook for controlled flows // we need this hack to handle the setNodes and setEdges function of the useReactFlow hook for controlled flows
if (changes.some((c) => c.type === 'reset')) { if (changes.some((c) => c.type === 'reset')) {
return changes.filter((c) => c.type === 'reset').map((c) => c.item); return changes.filter((c) => c.type === 'reset').map((c) => c.item);
} }
let remainingChanges = changes;
const initElements: any[] = changes.filter((c) => c.type === 'add').map((c) => c.item); const initElements: any[] = changes.filter((c) => c.type === 'add').map((c) => c.item);
return elements.reduce((res: any[], item: any) => { return elements.reduce((res: any[], item: any) => {
const currentChanges = changes.filter((c) => c.id === item.id); const nextChanges: any[] = [];
const _remainingChanges: any[] = [];
if (currentChanges.length === 0) { remainingChanges.forEach((c) => {
if (c.id === item.id) {
nextChanges.push(c);
} else {
_remainingChanges.push(c);
}
});
remainingChanges = _remainingChanges;
if (nextChanges.length === 0) {
res.push(item); res.push(item);
return res; return res;
} }
const updateItem = { ...item }; const updateItem = { ...item };
for (const currentChange of currentChanges) { for (const currentChange of nextChanges) {
if (currentChange) { if (currentChange) {
switch (currentChange.type) { switch (currentChange.type) {
case 'select': { case 'select': {
@@ -72,7 +90,10 @@ function applyChanges(changes: any[], elements: any[]): any[] {
} }
if (typeof currentChange.positionAbsolute !== 'undefined') { if (typeof currentChange.positionAbsolute !== 'undefined') {
updateItem.positionAbsolute = currentChange.positionAbsolute; if (!updateItem.computed) {
updateItem.computed = {};
}
updateItem.computed.positionAbsolute = currentChange.positionAbsolute;
} }
if (typeof currentChange.dragging !== 'undefined') { if (typeof currentChange.dragging !== 'undefined') {
@@ -86,8 +107,11 @@ function applyChanges(changes: any[], elements: any[]): any[] {
} }
case 'dimensions': { case 'dimensions': {
if (typeof currentChange.dimensions !== 'undefined') { if (typeof currentChange.dimensions !== 'undefined') {
updateItem.width = currentChange.dimensions.width; if (!updateItem.computed) {
updateItem.height = currentChange.dimensions.height; updateItem.computed = {};
}
updateItem.computed.width = currentChange.dimensions.width;
updateItem.computed.height = currentChange.dimensions.height;
} }
if (typeof currentChange.updateStyle !== 'undefined') { if (typeof currentChange.updateStyle !== 'undefined') {
+15
View File
@@ -1,3 +1,18 @@
## 0.0.28
There are some breaking changes again (sorry!) but we are very close to the final API for Svelte Flow 1.0.0. The biggest change is that we group node attriubutes (`width`, `height`, `positionAbsolute`) that are added by the library under `node.computed`. This makes it easier to understand, that this stuff comes from the library itself. `node.width` and `node.height` is still an optional node option and can be used to set certain dimensions for SSR or on the client.
- add `<NodeToolbar />` component
- add `on:selectionclick` and `on:selectioncontextmenu` event handlers
- add `ondelete({ nodes, edges })` handler
- add `zoomActivationKey` prop
- add `width` and `height` prop to custom `NodeProps` type
- ⚠️ replace `xPos` and `yPos` with `positionAbsolute` prop to custom `NodeProps` type
- ⚠️ `node.width/height` and `node.positionAbsolute` can now be found under `node.computed.width/height/positionAbsolute`
- ⚠️ `node.width/height` is still optional an can be used for forcing certain dimensions and SSR
- refactor keys: you can now disable keys by setting them to `null` (e.g. `selectionKey={null}`)
- performance optimization with internal node lookup
## 0.0.27 ## 0.0.27
- add `selectionOnDrag` prop - can be used to create figma-like controls in combination with `panOnDrag={false}` / `panOnDrag={[1, 2]}` + `panOnScroll={true}` - add `selectionOnDrag` prop - can be used to create figma-like controls in combination with `panOnDrag={false}` / `panOnDrag={[1, 2]}` + `panOnScroll={true}`
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@xyflow/svelte", "name": "@xyflow/svelte",
"version": "0.0.27", "version": "0.0.28",
"description": "Svelte Flow - A highly customizable Svelte library for building node-based editors, workflow systems, diagrams and more.", "description": "Svelte Flow - A highly customizable Svelte library for building node-based editors, workflow systems, diagrams and more.",
"keywords": [ "keywords": [
"svelte", "svelte",
@@ -30,6 +30,7 @@ export default function drag(domNode: Element, params: UseDragParams) {
return { return {
nodes: get(store.nodes), nodes: get(store.nodes),
nodeLookup: get(store.nodeLookup),
edges: get(store.edges), edges: get(store.edges),
nodeExtent: get(store.nodeExtent), nodeExtent: get(store.nodeExtent),
snapGrid: snapGrid ? snapGrid : [0, 0], snapGrid: snapGrid ? snapGrid : [0, 0],
@@ -1,11 +1,27 @@
export default function (node: Element, target = 'body') { type PortalOptions = {
const targetEl = document.querySelector(target); target?: string;
domNode: Element | null;
};
function tryToMount(node: Element, domNode: Element | null, target: string | undefined) {
if (!domNode) {
return;
}
const targetEl = target ? domNode.querySelector(target) : domNode;
if (targetEl) { if (targetEl) {
targetEl.appendChild(node); targetEl.appendChild(node);
} }
}
export default function (node: Element, { target, domNode }: PortalOptions) {
tryToMount(node, domNode, target);
return { return {
async update({ target, domNode }: PortalOptions) {
tryToMount(node, domNode, target);
},
destroy() { destroy() {
if (node.parentNode) { if (node.parentNode) {
node.parentNode.removeChild(node); node.parentNode.removeChild(node);
@@ -23,6 +23,7 @@
</script> </script>
<path <path
{id}
d={path} d={path}
class={cc(['svelte-flow__edge-path', className])} class={cc(['svelte-flow__edge-path', className])}
marker-start={markerStart} marker-start={markerStart}
@@ -1,9 +1,12 @@
<script lang="ts"> <script lang="ts">
import portal from '$lib/actions/portal'; import portal from '$lib/actions/portal';
import { useStore } from '$lib/store';
type $$Props = {}; type $$Props = {};
const { domNode } = useStore();
</script> </script>
<div use:portal={'.svelte-flow__edgelabel-renderer'}> <div use:portal={{ target: '.svelte-flow__edgelabel-renderer', domNode: $domNode }}>
<slot /> <slot />
</div> </div>
@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { shortcut } from '@svelte-put/shortcut'; import { shortcut, type ShortcutModifierDefinition } from '@svelte-put/shortcut';
import { useStore } from '$lib/store'; import { useStore } from '$lib/store';
import type { KeyHandlerProps } from './types'; import type { KeyHandlerProps } from './types';
@@ -12,40 +12,66 @@
export let multiSelectionKey: $$Props['multiSelectionKey'] = isMacOs() ? 'Meta' : 'Control'; export let multiSelectionKey: $$Props['multiSelectionKey'] = isMacOs() ? 'Meta' : 'Control';
export let deleteKey: $$Props['deleteKey'] = 'Backspace'; export let deleteKey: $$Props['deleteKey'] = 'Backspace';
export let panActivationKey: $$Props['panActivationKey'] = ' '; export let panActivationKey: $$Props['panActivationKey'] = ' ';
export let zoomActivationKey: $$Props['zoomActivationKey'] = isMacOs() ? 'Meta' : 'Control';
const { const {
selectionKeyPressed, selectionKeyPressed,
multiselectionKeyPressed, multiselectionKeyPressed,
deleteKeyPressed, deleteKeyPressed,
panActivationKeyPressed panActivationKeyPressed,
zoomActivationKeyPressed
} = useStore(); } = useStore();
function isKeyObject(key?: KeyDefinition): key is KeyDefinitionObject { function isKeyObject(key?: KeyDefinition | null): key is KeyDefinitionObject {
return typeof key === 'object'; return typeof key === 'object';
} }
$: selectionKeyString = typeof selectionKey === 'string' ? selectionKey : selectionKey!.key; function getModifier(key?: KeyDefinition | null): ShortcutModifierDefinition {
$: selectionKeyModifier = isKeyObject(selectionKey) ? selectionKey?.modifier : []; return isKeyObject(key) ? key.modifier || [] : [];
}
$: multiSelectionKeyString = function getKeyString(key?: KeyDefinition | null): string {
typeof multiSelectionKey === 'string' ? multiSelectionKey : multiSelectionKey!.key; if (key === null || key === undefined) {
$: multiSelectionKeyModifier = isKeyObject(multiSelectionKey) ? multiSelectionKey?.modifier : []; // this is a workaround to check if a key is set
// if not we won't call the callback
return '';
}
$: deleteKeyString = typeof deleteKey === 'string' ? deleteKey : deleteKey!.key; return isKeyObject(key) ? key.key : key;
$: deleteKeyModifier = isKeyObject(deleteKey) ? deleteKey?.modifier : []; }
$: panActivationKeyString = $: selectionKeyDefinition = {
typeof panActivationKey === 'string' ? panActivationKey : panActivationKey!.key; key: getKeyString(selectionKey),
$: panActivationKeyModifier = isKeyObject(panActivationKey) ? panActivationKey?.modifier : []; modifier: getModifier(selectionKey)
};
$: multiSelectionKeyDefinition = {
key: getKeyString(multiSelectionKey),
modifier: getModifier(multiSelectionKey)
};
$: deleteKeyDefinition = {
key: getKeyString(deleteKey),
modifier: getModifier(deleteKey)
};
$: panActivationKeyDefinition = {
key: getKeyString(panActivationKey),
modifier: getModifier(panActivationKey)
};
$: zoomActivationKeyDefinition = {
key: getKeyString(zoomActivationKey),
modifier: getModifier(zoomActivationKey)
};
</script> </script>
<svelte:window <svelte:window
use:shortcut={{ use:shortcut={{
trigger: [ trigger: [
{ {
key: selectionKeyString, ...selectionKeyDefinition,
modifier: selectionKeyModifier, callback: () => selectionKeyDefinition.key && selectionKeyPressed.set(true)
callback: () => selectionKeyPressed.set(true)
} }
], ],
type: 'keydown' type: 'keydown'
@@ -53,9 +79,8 @@
use:shortcut={{ use:shortcut={{
trigger: [ trigger: [
{ {
key: selectionKeyString, ...selectionKeyDefinition,
modifier: selectionKeyModifier, callback: () => selectionKeyDefinition.key && selectionKeyPressed.set(false)
callback: () => selectionKeyPressed.set(false)
} }
], ],
type: 'keyup' type: 'keyup'
@@ -63,9 +88,8 @@
use:shortcut={{ use:shortcut={{
trigger: [ trigger: [
{ {
key: multiSelectionKeyString, ...multiSelectionKeyDefinition,
modifier: multiSelectionKeyModifier, callback: () => multiSelectionKeyDefinition.key && multiselectionKeyPressed.set(true)
callback: () => multiselectionKeyPressed.set(true)
} }
], ],
type: 'keydown' type: 'keydown'
@@ -73,9 +97,8 @@
use:shortcut={{ use:shortcut={{
trigger: [ trigger: [
{ {
key: multiSelectionKeyString, ...multiSelectionKeyDefinition,
modifier: multiSelectionKeyModifier, callback: () => multiSelectionKeyDefinition.key && multiselectionKeyPressed.set(false)
callback: () => multiselectionKeyPressed.set(false)
} }
], ],
type: 'keyup' type: 'keyup'
@@ -83,11 +106,8 @@
use:shortcut={{ use:shortcut={{
trigger: [ trigger: [
{ {
key: deleteKeyString, ...deleteKeyDefinition,
modifier: deleteKeyModifier, callback: () => deleteKeyDefinition.key && deleteKeyPressed.set(true)
callback: () => {
deleteKeyPressed.set(true);
}
} }
], ],
type: 'keydown' type: 'keydown'
@@ -95,9 +115,8 @@
use:shortcut={{ use:shortcut={{
trigger: [ trigger: [
{ {
key: deleteKeyString, ...deleteKeyDefinition,
modifier: deleteKeyModifier, callback: () => deleteKeyDefinition.key && deleteKeyPressed.set(false)
callback: () => deleteKeyPressed.set(false)
} }
], ],
type: 'keyup' type: 'keyup'
@@ -105,9 +124,8 @@
use:shortcut={{ use:shortcut={{
trigger: [ trigger: [
{ {
key: panActivationKeyString, ...panActivationKeyDefinition,
modifier: panActivationKeyModifier, callback: () => panActivationKeyDefinition.key && panActivationKeyPressed.set(true)
callback: () => panActivationKeyPressed.set(true)
} }
], ],
type: 'keydown' type: 'keydown'
@@ -115,9 +133,26 @@
use:shortcut={{ use:shortcut={{
trigger: [ trigger: [
{ {
key: panActivationKeyString, ...panActivationKeyDefinition,
modifier: panActivationKeyModifier, callback: () => panActivationKeyDefinition.key && panActivationKeyPressed.set(false)
callback: () => panActivationKeyPressed.set(false) }
],
type: 'keyup'
}}
use:shortcut={{
trigger: [
{
...zoomActivationKeyDefinition,
callback: () => zoomActivationKeyDefinition.key && zoomActivationKeyPressed.set(true)
}
],
type: 'keydown'
}}
use:shortcut={{
trigger: [
{
...zoomActivationKeyDefinition,
callback: () => zoomActivationKeyDefinition.key && zoomActivationKeyPressed.set(false)
} }
], ],
type: 'keyup' type: 'keyup'
@@ -1,8 +1,9 @@
import type { KeyDefinition } from '$lib/types'; import type { KeyDefinition } from '$lib/types';
export type KeyHandlerProps = { export type KeyHandlerProps = {
selectionKey?: KeyDefinition; selectionKey?: KeyDefinition | null;
multiSelectionKey?: KeyDefinition; multiSelectionKey?: KeyDefinition | null;
deleteKey?: KeyDefinition; deleteKey?: KeyDefinition | null;
panActivationKey?: KeyDefinition; panActivationKey?: KeyDefinition | null;
zoomActivationKey?: KeyDefinition | null;
}; };
@@ -1,15 +1,30 @@
<script lang="ts"> <script lang="ts">
import { getNodesBounds } from '@xyflow/system'; import { getNodesBounds } from '@xyflow/system';
import { createEventDispatcher } from 'svelte';
import { useStore } from '$lib/store'; import { useStore } from '$lib/store';
import { Selection } from '$lib/components/Selection'; import { Selection } from '$lib/components/Selection';
import drag from '$lib/actions/drag'; import drag from '$lib/actions/drag';
import type { Node } from '$lib/types';
const store = useStore(); const store = useStore();
const { selectionRectMode, nodes } = store; const { selectionRectMode, nodes } = store;
const dispatch = createEventDispatcher<{
selectioncontextmenu: { nodes: Node[]; event: MouseEvent | TouchEvent };
selectionclick: { nodes: Node[]; event: MouseEvent | TouchEvent };
}>();
$: selectedNodes = $nodes.filter((n) => n.selected); $: selectedNodes = $nodes.filter((n) => n.selected);
$: bounds = getNodesBounds(selectedNodes); $: bounds = getNodesBounds(selectedNodes);
function onContextMenu(event: MouseEvent | TouchEvent) {
dispatch('selectioncontextmenu', { nodes: selectedNodes, event });
}
function onClick(event: MouseEvent | TouchEvent) {
dispatch('selectionclick', { nodes: selectedNodes, event });
}
</script> </script>
{#if selectedNodes && $selectionRectMode === 'nodes'} {#if selectedNodes && $selectionRectMode === 'nodes'}
@@ -17,6 +32,8 @@
class="selection-wrapper nopan" class="selection-wrapper nopan"
style="width: {bounds.width}px; height: {bounds.height}px; transform: translate({bounds.x}px, {bounds.y}px)" style="width: {bounds.width}px; height: {bounds.height}px; transform: translate({bounds.x}px, {bounds.y}px)"
use:drag={{ disabled: false, store }} use:drag={{ disabled: false, store }}
on:contextmenu={onContextMenu}
on:click={onClick}
> >
<Selection width="100%" height="100%" x={0} y={0} /> <Selection width="100%" height="100%" x={0} y={0} />
</div> </div>
@@ -27,11 +27,15 @@
export let style: NodeWrapperProps['style'] = undefined; export let style: NodeWrapperProps['style'] = undefined;
export let type: NodeWrapperProps['type'] = 'default'; export let type: NodeWrapperProps['type'] = 'default';
export let isParent: NodeWrapperProps['isParent'] = false; export let isParent: NodeWrapperProps['isParent'] = false;
export let positionAbsolute: NodeWrapperProps['positionAbsolute'] = undefined; export let positionX: NodeWrapperProps['positionX'];
export let positionOrigin: NodeWrapperProps['positionOrigin'] = undefined; export let positionY: NodeWrapperProps['positionY'];
export let positionOriginX: NodeWrapperProps['positionOriginX'];
export let positionOriginY: NodeWrapperProps['positionOriginY'];
export let sourcePosition: NodeWrapperProps['sourcePosition'] = undefined; export let sourcePosition: NodeWrapperProps['sourcePosition'] = undefined;
export let targetPosition: NodeWrapperProps['targetPosition'] = undefined; export let targetPosition: NodeWrapperProps['targetPosition'] = undefined;
export let zIndex: NodeWrapperProps['zIndex']; export let zIndex: NodeWrapperProps['zIndex'];
export let width: NodeWrapperProps['width'] = undefined;
export let height: NodeWrapperProps['height'] = undefined;
export let dragHandle: NodeWrapperProps['dragHandle'] = undefined; export let dragHandle: NodeWrapperProps['dragHandle'] = undefined;
export let initialized: NodeWrapperProps['initialized'] = false; export let initialized: NodeWrapperProps['initialized'] = false;
let className: string = ''; let className: string = '';
@@ -87,13 +91,18 @@
if (doUpdate) { if (doUpdate) {
requestAnimationFrame(() => requestAnimationFrame(() =>
updateNodeDimensions([ updateNodeDimensions(
{ new Map([
id, [
nodeElement: nodeRef, id,
forceUpdate: true {
} id,
]) nodeElement: nodeRef,
forceUpdate: true
}
]
])
)
); );
} }
@@ -124,8 +133,6 @@
dispatch('nodeclick', { node, event }); dispatch('nodeclick', { node, event });
} }
// @todo: add selectable state
</script> </script>
<!-- svelte-ignore a11y-click-events-have-key-events --> <!-- svelte-ignore a11y-click-events-have-key-events -->
@@ -161,11 +168,11 @@
class:nopan={draggable} class:nopan={draggable}
class:parent={isParent} class:parent={isParent}
style:z-index={zIndex} style:z-index={zIndex}
style:transform="translate({positionOrigin?.x ?? 0}px, {positionOrigin?.y ?? 0}px)" style:transform="translate({positionOriginX}px, {positionOriginY}px)"
style:visibility={initialized ? 'visible' : 'hidden'} style:visibility={initialized ? 'visible' : 'hidden'}
style="{style} {node.size?.width ? `;width=${node.size?.width}px` : ''} {node.size?.height style="{style ?? ''}; {!width ? '' : `width:${width}px;`} {!height
? `;height=${node.size?.height}px;` ? ''
: ''}" : `height:${height}px;`}"
on:click={onSelectNodeHandler} on:click={onSelectNodeHandler}
on:mouseenter={(event) => dispatch('nodemouseenter', { node, event })} on:mouseenter={(event) => dispatch('nodemouseenter', { node, event })}
on:mouseleave={(event) => dispatch('nodemouseleave', { node, event })} on:mouseleave={(event) => dispatch('nodemouseleave', { node, event })}
@@ -184,8 +191,9 @@
{dragging} {dragging}
{dragHandle} {dragHandle}
isConnectable={connectable} isConnectable={connectable}
xPos={positionAbsolute?.x ?? 0} positionAbsolute={{ x: positionX, y: positionY }}
yPos={positionAbsolute?.y ?? 0} {width}
{height}
on:connectstart on:connectstart
on:connect on:connect
on:connectend on:connectend
@@ -1,4 +1,3 @@
import type { XYPosition } from '@xyflow/system';
import type { Node } from '$lib/types'; import type { Node } from '$lib/types';
export type NodeWrapperProps = Pick< export type NodeWrapperProps = Pick<
@@ -9,19 +8,22 @@ export type NodeWrapperProps = Pick<
| 'data' | 'data'
| 'draggable' | 'draggable'
| 'dragging' | 'dragging'
| 'positionAbsolute'
| 'selected' | 'selected'
| 'selectable' | 'selectable'
| 'style' | 'style'
| 'type' | 'type'
| 'width'
| 'height'
| 'sourcePosition' | 'sourcePosition'
| 'targetPosition' | 'targetPosition'
| 'dragHandle' | 'dragHandle'
| 'hidden' | 'hidden'
> & { > & {
positionOrigin?: XYPosition; width?: number;
height?: number;
type: string;
positionX: number;
positionY: number;
positionOriginX: number;
positionOriginY: number;
'on:nodeclick'?: (event: MouseEvent) => void; 'on:nodeclick'?: (event: MouseEvent) => void;
resizeObserver?: ResizeObserver | null; resizeObserver?: ResizeObserver | null;
isParent?: boolean; isParent?: boolean;
@@ -11,24 +11,29 @@
// unused props - we need to list them here in order to prevent warnings // unused props - we need to list them here in order to prevent warnings
export let id: $$Props['id'] = ''; export let id: $$Props['id'] = '';
export let width: $$Props['width'] = undefined;
export let height: $$Props['height'] = undefined;
export let selected: $$Props['selected'] = undefined; export let selected: $$Props['selected'] = undefined;
export let type: $$Props['type'] = undefined; export let type: $$Props['type'] = undefined;
export let zIndex: $$Props['zIndex'] = undefined; export let zIndex: $$Props['zIndex'] = undefined;
export let dragging: $$Props['dragging'] = false; export let dragging: $$Props['dragging'] = false;
export let dragHandle: $$Props['dragHandle'] = undefined; export let dragHandle: $$Props['dragHandle'] = undefined;
export let xPos: $$Props['xPos'] = 0; export let positionAbsolute: $$Props['positionAbsolute'] = {
export let yPos: $$Props['yPos'] = 0; x: 0,
y: 0
};
export let isConnectable: $$Props['isConnectable'] = undefined; export let isConnectable: $$Props['isConnectable'] = undefined;
// @todo: there must be a better way to do this // @todo: there must be a better way to do this
id; id;
width;
height;
selected; selected;
type; type;
zIndex; zIndex;
dragging; dragging;
dragHandle; dragHandle;
xPos; positionAbsolute;
yPos;
isConnectable; isConnectable;
</script> </script>
@@ -5,6 +5,8 @@
// unused props - we need to list them here in order to prevent warnings // unused props - we need to list them here in order to prevent warnings
export let id: $$Props['id'] = ''; export let id: $$Props['id'] = '';
export let width: $$Props['width'] = undefined;
export let height: $$Props['height'] = undefined;
export let data: $$Props['data'] = {}; export let data: $$Props['data'] = {};
export let selected: $$Props['selected'] = undefined; export let selected: $$Props['selected'] = undefined;
export let sourcePosition: $$Props['sourcePosition'] = undefined; export let sourcePosition: $$Props['sourcePosition'] = undefined;
@@ -13,12 +15,16 @@
export let zIndex: $$Props['zIndex'] = undefined; export let zIndex: $$Props['zIndex'] = undefined;
export let dragging: $$Props['dragging'] = false; export let dragging: $$Props['dragging'] = false;
export let dragHandle: $$Props['dragHandle'] = undefined; export let dragHandle: $$Props['dragHandle'] = undefined;
export let xPos: $$Props['xPos'] = 0; export let positionAbsolute: $$Props['positionAbsolute'] = {
export let yPos: $$Props['yPos'] = 0; x: 0,
y: 0
};
export let isConnectable: $$Props['isConnectable'] = undefined; export let isConnectable: $$Props['isConnectable'] = undefined;
// @todo: there must be a better way to do this // @todo: there must be a better way to do this
id; id;
width;
height;
data; data;
selected; selected;
sourcePosition; sourcePosition;
@@ -27,7 +33,6 @@
zIndex; zIndex;
dragging; dragging;
dragHandle; dragHandle;
xPos; positionAbsolute;
yPos;
isConnectable; isConnectable;
</script> </script>
@@ -10,26 +10,31 @@
// unused props - we need to list them here in order to prevent warnings // unused props - we need to list them here in order to prevent warnings
export let id: $$Props['id'] = ''; export let id: $$Props['id'] = '';
export let width: $$Props['width'] = undefined;
export let height: $$Props['height'] = undefined;
export let selected: $$Props['selected'] = undefined; export let selected: $$Props['selected'] = undefined;
export let targetPosition: $$Props['targetPosition'] = undefined; export let targetPosition: $$Props['targetPosition'] = undefined;
export let type: $$Props['type'] = undefined; export let type: $$Props['type'] = undefined;
export let zIndex: $$Props['zIndex'] = undefined; export let zIndex: $$Props['zIndex'] = undefined;
export let dragging: $$Props['dragging'] = false; export let dragging: $$Props['dragging'] = false;
export let dragHandle: $$Props['dragHandle'] = undefined; export let dragHandle: $$Props['dragHandle'] = undefined;
export let xPos: $$Props['xPos'] = 0; export let positionAbsolute: $$Props['positionAbsolute'] = {
export let yPos: $$Props['yPos'] = 0; x: 0,
y: 0
};
export let isConnectable: $$Props['isConnectable'] = undefined; export let isConnectable: $$Props['isConnectable'] = undefined;
// @todo: there must be a better way to do this // @todo: there must be a better way to do this
id; id;
width;
height;
selected; selected;
targetPosition; targetPosition;
type; type;
zIndex; zIndex;
dragging; dragging;
dragHandle; dragHandle;
xPos; positionAbsolute;
yPos;
isConnectable; isConnectable;
</script> </script>
@@ -10,26 +10,28 @@
// unused props - we need to list them here in order to prevent warnings // unused props - we need to list them here in order to prevent warnings
export let id: $$Props['id'] = ''; export let id: $$Props['id'] = '';
export let width: $$Props['width'] = undefined;
export let height: $$Props['height'] = undefined;
export let selected: $$Props['selected'] = undefined; export let selected: $$Props['selected'] = undefined;
export let sourcePosition: $$Props['sourcePosition'] = undefined; export let sourcePosition: $$Props['sourcePosition'] = undefined;
export let type: $$Props['type'] = undefined; export let type: $$Props['type'] = undefined;
export let zIndex: $$Props['zIndex'] = undefined; export let zIndex: $$Props['zIndex'] = undefined;
export let dragging: $$Props['dragging'] = false; export let dragging: $$Props['dragging'] = false;
export let dragHandle: $$Props['dragHandle'] = undefined; export let dragHandle: $$Props['dragHandle'] = undefined;
export let xPos: $$Props['xPos'] = 0; export let positionAbsolute: $$Props['positionAbsolute'] = { x: 0, y: 0 };
export let yPos: $$Props['yPos'] = 0;
export let isConnectable: $$Props['isConnectable'] = undefined; export let isConnectable: $$Props['isConnectable'] = undefined;
// @todo: there must be a better way to do this // @todo: there must be a better way to do this
id; id;
width;
height;
selected; selected;
sourcePosition; sourcePosition;
type; type;
zIndex; zIndex;
dragging; dragging;
dragHandle; dragHandle;
xPos; positionAbsolute;
yPos;
isConnectable; isConnectable;
</script> </script>
@@ -17,11 +17,18 @@
typeof ResizeObserver === 'undefined' typeof ResizeObserver === 'undefined'
? null ? null
: new ResizeObserver((entries: ResizeObserverEntry[]) => { : new ResizeObserver((entries: ResizeObserverEntry[]) => {
const updates = entries.map((entry: ResizeObserverEntry) => ({ const updates = new Map();
id: entry.target.getAttribute('data-id') as string,
nodeElement: entry.target as HTMLDivElement, entries.forEach((entry: ResizeObserverEntry) => {
forceUpdate: true const id = entry.target.getAttribute('data-id') as string;
}));
updates.set(id, {
id,
nodeElement: entry.target as HTMLDivElement,
forceUpdate: true
});
});
updateNodeDimensions(updates); updateNodeDimensions(updates);
}); });
@@ -33,10 +40,10 @@
<div class="svelte-flow__nodes"> <div class="svelte-flow__nodes">
{#each $visibleNodes as node (node.id)} {#each $visibleNodes as node (node.id)}
{@const posOrigin = getPositionWithOrigin({ {@const posOrigin = getPositionWithOrigin({
x: node.positionAbsolute?.x ?? 0, x: node.computed?.positionAbsolute?.x ?? 0,
y: node.positionAbsolute?.y ?? 0, y: node.computed?.positionAbsolute?.y ?? 0,
width: (node.size?.width || node.width) ?? 0, width: node.computed?.width ?? node.width ?? 0,
height: (node.size?.height || node.height) ?? 0, height: node.computed?.height ?? node.height ?? 0,
origin: node.origin origin: node.origin
})} })}
<NodeWrapper <NodeWrapper
@@ -54,18 +61,23 @@
node.connectable || node.connectable ||
($nodesConnectable && typeof node.connectable === 'undefined') ($nodesConnectable && typeof node.connectable === 'undefined')
)} )}
positionAbsolute={node.positionAbsolute} positionX={node.computed?.positionAbsolute?.x ?? 0}
positionOrigin={posOrigin} positionY={node.computed?.positionAbsolute?.y ?? 0}
positionOriginX={posOrigin.x ?? 0}
positionOriginY={posOrigin.y ?? 0}
isParent={!!node[internalsSymbol]?.isParent} isParent={!!node[internalsSymbol]?.isParent}
style={node.style} style={node.style}
class={node.class} class={node.class}
type={node.type} type={node.type ?? 'default'}
sourcePosition={node.sourcePosition} sourcePosition={node.sourcePosition}
targetPosition={node.targetPosition} targetPosition={node.targetPosition}
dragging={node.dragging} dragging={node.dragging}
zIndex={node[internalsSymbol]?.z ?? 0} zIndex={node[internalsSymbol]?.z ?? 0}
dragHandle={node.dragHandle} dragHandle={node.dragHandle}
initialized={(!!node.width && !!node.height) || (!!node.size?.width && !!node.size?.height)} width={node.width ?? undefined}
height={node.height ?? undefined}
initialized={(!!node.computed?.width && !!node.computed?.height) ||
(!!node.width && !!node.height)}
{resizeObserver} {resizeObserver}
on:nodeclick on:nodeclick
on:nodemouseenter on:nodemouseenter
@@ -15,8 +15,14 @@
import { Attribution } from '$lib/components/Attribution'; import { Attribution } from '$lib/components/Attribution';
import { key, useStore, createStoreContext } from '$lib/store'; import { key, useStore, createStoreContext } from '$lib/store';
import type { SvelteFlowProps } from './types'; import type { SvelteFlowProps } from './types';
import { updateStore, updateStoreByKeys, type UpdatableStoreProps } from './utils'; import {
updateStore,
updateStoreByKeys,
type UpdatableStoreProps,
getColorModeClass
} from './utils';
import { get } from 'svelte/store'; import { get } from 'svelte/store';
import { useColorModeClass } from '$lib/hooks/useColorModeClass';
type $$Props = SvelteFlowProps; type $$Props = SvelteFlowProps;
@@ -34,6 +40,8 @@
export let selectionKey: $$Props['selectionKey'] = undefined; export let selectionKey: $$Props['selectionKey'] = undefined;
export let selectionMode: $$Props['selectionMode'] = undefined; export let selectionMode: $$Props['selectionMode'] = undefined;
export let panActivationKey: $$Props['panActivationKey'] = undefined; export let panActivationKey: $$Props['panActivationKey'] = undefined;
export let multiSelectionKey: $$Props['multiSelectionKey'] = undefined;
export let zoomActivationKey: $$Props['zoomActivationKey'] = undefined;
export let nodesDraggable: $$Props['nodesDraggable'] = undefined; export let nodesDraggable: $$Props['nodesDraggable'] = undefined;
export let nodesConnectable: $$Props['nodesConnectable'] = undefined; export let nodesConnectable: $$Props['nodesConnectable'] = undefined;
export let nodeDragThreshold: $$Props['nodeDragThreshold'] = undefined; export let nodeDragThreshold: $$Props['nodeDragThreshold'] = undefined;
@@ -61,12 +69,14 @@
export let selectionOnDrag: $$Props['selectionOnDrag'] = undefined; export let selectionOnDrag: $$Props['selectionOnDrag'] = undefined;
export let autoPanOnConnect: $$Props['autoPanOnConnect'] = true; export let autoPanOnConnect: $$Props['autoPanOnConnect'] = true;
export let autoPanOnNodeDrag: $$Props['autoPanOnNodeDrag'] = true; export let autoPanOnNodeDrag: $$Props['autoPanOnNodeDrag'] = true;
export let onError: $$Props['onError'] = undefined; export let onerror: $$Props['onerror'] = undefined;
export let ondelete: $$Props['ondelete'] = undefined;
export let attributionPosition: $$Props['attributionPosition'] = undefined; export let attributionPosition: $$Props['attributionPosition'] = undefined;
export let proOptions: $$Props['proOptions'] = undefined; export let proOptions: $$Props['proOptions'] = undefined;
export let defaultEdgeOptions: $$Props['defaultEdgeOptions'] = undefined; export let defaultEdgeOptions: $$Props['defaultEdgeOptions'] = undefined;
export let width: $$Props['width'] = undefined; export let width: $$Props['width'] = undefined;
export let height: $$Props['height'] = undefined; export let height: $$Props['height'] = undefined;
export let colorMode: $$Props['colorMode'] = 'light';
export let defaultMarkerColor = '#b1b1b7'; export let defaultMarkerColor = '#b1b1b7';
@@ -137,7 +147,8 @@
isValidConnection, isValidConnection,
autoPanOnConnect, autoPanOnConnect,
autoPanOnNodeDrag, autoPanOnNodeDrag,
onError, onerror,
ondelete,
connectionMode, connectionMode,
nodeDragThreshold nodeDragThreshold
}; };
@@ -152,6 +163,8 @@
maxZoom, maxZoom,
translateExtent translateExtent
}); });
$: colorModeClass = useColorModeClass(colorMode);
</script> </script>
<div <div
@@ -159,14 +172,20 @@
bind:clientWidth bind:clientWidth
bind:clientHeight bind:clientHeight
{style} {style}
class={cc(['svelte-flow', className])} class={cc(['svelte-flow', className, $colorModeClass])}
data-testid="svelte-flow__wrapper" data-testid="svelte-flow__wrapper"
on:dragover on:dragover
on:drop on:drop
{...$$restProps} {...$$restProps}
role="application" role="application"
> >
<KeyHandler {selectionKey} {deleteKey} {panActivationKey} /> <KeyHandler
{selectionKey}
{deleteKey}
{panActivationKey}
{multiSelectionKey}
{zoomActivationKey}
/>
<Zoom <Zoom
{initialViewport} {initialViewport}
{onMoveStart} {onMoveStart}
@@ -204,7 +223,7 @@
on:nodedragstop on:nodedragstop
on:nodecontextmenu on:nodecontextmenu
/> />
<NodeSelection /> <NodeSelection on:selectionclick on:selectioncontextmenu />
</ViewportComponent> </ViewportComponent>
<UserSelection /> <UserSelection />
</Pane> </Pane>
@@ -14,7 +14,8 @@ import type {
OnError, OnError,
ConnectionMode, ConnectionMode,
PanelPosition, PanelPosition,
ProOptions ProOptions,
ColorMode
} from '@xyflow/system'; } from '@xyflow/system';
import type { import type {
@@ -24,7 +25,8 @@ import type {
KeyDefinition, KeyDefinition,
EdgeTypes, EdgeTypes,
DefaultEdgeOptions, DefaultEdgeOptions,
FitViewOptions FitViewOptions,
OnDelete
} from '$lib/types'; } from '$lib/types';
import type { Writable } from 'svelte/store'; import type { Writable } from 'svelte/store';
@@ -34,9 +36,11 @@ export type SvelteFlowProps = DOMAttributes<HTMLDivElement> & {
edges: Writable<Edge[]>; edges: Writable<Edge[]>;
nodeTypes?: NodeTypes; nodeTypes?: NodeTypes;
edgeTypes?: EdgeTypes; edgeTypes?: EdgeTypes;
selectionKey?: KeyDefinition; selectionKey?: KeyDefinition | null;
panActivationKey?: KeyDefinition; panActivationKey?: KeyDefinition | null;
deleteKey?: KeyDefinition; deleteKey?: KeyDefinition | null;
multiSelectionKey?: KeyDefinition | null;
zoomActivationKey?: KeyDefinition | null;
fitView?: boolean; fitView?: boolean;
fitViewOptions?: FitViewOptions; fitViewOptions?: FitViewOptions;
nodeOrigin?: NodeOrigin; nodeOrigin?: NodeOrigin;
@@ -72,6 +76,7 @@ export type SvelteFlowProps = DOMAttributes<HTMLDivElement> & {
defaultEdgeOptions?: DefaultEdgeOptions; defaultEdgeOptions?: DefaultEdgeOptions;
width?: number; width?: number;
height?: number; height?: number;
colorMode?: ColorMode;
class?: string; class?: string;
style?: string; style?: string;
@@ -82,5 +87,6 @@ export type SvelteFlowProps = DOMAttributes<HTMLDivElement> & {
onMoveStart?: OnMoveStart; onMoveStart?: OnMoveStart;
onMove?: OnMove; onMove?: OnMove;
onMoveEnd?: OnMoveEnd; onMoveEnd?: OnMoveEnd;
onError?: OnError; onerror?: OnError;
ondelete?: OnDelete;
}; };
@@ -1,6 +1,6 @@
import type { SvelteFlowStore } from '$lib/store/types'; import type { SvelteFlowStore } from '$lib/store/types';
import type { EdgeTypes, NodeTypes } from '$lib/types'; import type { EdgeTypes, NodeTypes } from '$lib/types';
import type { CoordinateExtent } from '@xyflow/system'; import type { ColorMode, CoordinateExtent } from '@xyflow/system';
import type { Writable } from 'svelte/store'; import type { Writable } from 'svelte/store';
// this is helper function for updating the store // this is helper function for updating the store
@@ -62,7 +62,8 @@ export type UpdatableStoreProps = {
autoPanOnConnect?: UnwrapWritable<SvelteFlowStore['autoPanOnConnect']>; autoPanOnConnect?: UnwrapWritable<SvelteFlowStore['autoPanOnConnect']>;
autoPanOnNodeDrag?: UnwrapWritable<SvelteFlowStore['autoPanOnNodeDrag']>; autoPanOnNodeDrag?: UnwrapWritable<SvelteFlowStore['autoPanOnNodeDrag']>;
connectionMode?: UnwrapWritable<SvelteFlowStore['connectionMode']>; connectionMode?: UnwrapWritable<SvelteFlowStore['connectionMode']>;
onError?: UnwrapWritable<SvelteFlowStore['onError']>; onerror?: UnwrapWritable<SvelteFlowStore['onerror']>;
ondelete?: UnwrapWritable<SvelteFlowStore['ondelete']>;
nodeDragThreshold?: UnwrapWritable<SvelteFlowStore['nodeDragThreshold']>; nodeDragThreshold?: UnwrapWritable<SvelteFlowStore['nodeDragThreshold']>;
}; };
@@ -76,3 +77,15 @@ export function updateStoreByKeys(store: SvelteFlowStore, keys: UpdatableStorePr
} }
}); });
} }
export function getColorModeClass(colorMode?: ColorMode) {
if (colorMode !== 'system') {
return colorMode;
}
if (!colorMode || typeof window === 'undefined' || !window.matchMedia) {
return 'light';
}
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
@@ -28,7 +28,8 @@
dragging, dragging,
translateExtent, translateExtent,
lib, lib,
panActivationKeyPressed panActivationKeyPressed,
zoomActivationKeyPressed
} = useStore(); } = useStore();
$: viewPort = initialViewport || { x: 0, y: 0, zoom: 1 }; $: viewPort = initialViewport || { x: 0, y: 0, zoom: 1 };
@@ -55,7 +56,7 @@
panOnDrag: _panOnDrag, panOnDrag: _panOnDrag,
panOnScrollSpeed: 0.5, panOnScrollSpeed: 0.5,
panOnScrollMode: panOnScrollMode || PanOnScrollMode.Free, panOnScrollMode: panOnScrollMode || PanOnScrollMode.Free,
zoomActivationKeyPressed: false, zoomActivationKeyPressed: $zoomActivationKeyPressed,
preventScrolling: typeof preventScrolling === 'boolean' ? preventScrolling : true, preventScrolling: typeof preventScrolling === 'boolean' ? preventScrolling : true,
noPanClassName: 'nopan', noPanClassName: 'nopan',
noWheelClassName: 'nowheel', noWheelClassName: 'nowheel',
@@ -0,0 +1,32 @@
import type { ColorMode, ColorModeClass } from '@xyflow/system';
import { readable, type Readable } from 'svelte/store';
function getMediaQuery() {
if (typeof window === 'undefined' || !window.matchMedia) {
return null;
}
return window.matchMedia('(prefers-color-scheme: dark)');
}
export function useColorModeClass(colorMode: ColorMode = 'light'): Readable<ColorModeClass> {
const colorModeClass = readable<ColorModeClass>('light', (set) => {
if (colorMode !== 'system') {
set(colorMode);
return;
}
const mediaQuery = getMediaQuery();
const updateColorModeClass = () => set(mediaQuery?.matches ? 'dark' : 'light');
set(mediaQuery?.matches ? 'dark' : 'light');
mediaQuery?.addEventListener('change', updateColorModeClass);
return () => {
mediaQuery?.removeEventListener('change', updateColorModeClass);
};
});
return colorModeClass;
}
@@ -141,7 +141,7 @@ export function useSvelteFlow(): {
} }
return (nodesToIntersect || get(nodes)).filter((n) => { return (nodesToIntersect || get(nodes)).filter((n) => {
if (!isRect && (n.id === node.id || !n.positionAbsolute)) { if (!isRect && (n.id === node.id || !n.computed?.positionAbsolute)) {
return false; return false;
} }
@@ -1,5 +1,5 @@
import { get } from 'svelte/store'; import { get } from 'svelte/store';
import type { UpdateNodeInternals, NodeDimensionUpdate } from '@xyflow/system'; import type { UpdateNodeInternals } from '@xyflow/system';
import { useStore } from '$lib/store'; import { useStore } from '$lib/store';
@@ -9,17 +9,17 @@ export function useUpdateNodeInternals(): UpdateNodeInternals {
// @todo: do we want to add this to system? // @todo: do we want to add this to system?
const updateInternals = (id: string | string[]) => { const updateInternals = (id: string | string[]) => {
const updateIds = Array.isArray(id) ? id : [id]; const updateIds = Array.isArray(id) ? id : [id];
const updates = updateIds.reduce<NodeDimensionUpdate[]>((res, updateId) => { const updates = new Map();
updateIds.forEach((updateId) => {
const nodeElement = get(domNode)?.querySelector( const nodeElement = get(domNode)?.querySelector(
`.svelte-flow__node[data-id="${updateId}"]` `.svelte-flow__node[data-id="${updateId}"]`
) as HTMLDivElement; ) as HTMLDivElement;
if (nodeElement) { if (nodeElement) {
res.push({ id: updateId, nodeElement, forceUpdate: true }); updates.set(updateId, { id: updateId, nodeElement, forceUpdate: true });
} }
});
return res;
}, []);
requestAnimationFrame(() => updateNodeDimensions(updates)); requestAnimationFrame(() => updateNodeDimensions(updates));
}; };
+4 -1
View File
@@ -14,6 +14,7 @@ export * from '$lib/components/Handle';
export * from '$lib/plugins/Controls'; export * from '$lib/plugins/Controls';
export * from '$lib/plugins/Background'; export * from '$lib/plugins/Background';
export * from '$lib/plugins/Minimap'; export * from '$lib/plugins/Minimap';
export * from '$lib/plugins/NodeToolbar';
// store // store
export { useStore } from '$lib/store'; export { useStore } from '$lib/store';
@@ -74,7 +75,9 @@ export {
type Rect, type Rect,
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
@@ -20,7 +20,7 @@
export let position: $$Props['position'] = 'bottom-right'; export let position: $$Props['position'] = 'bottom-right';
export let ariaLabel: $$Props['ariaLabel'] = 'Mini map'; export let ariaLabel: $$Props['ariaLabel'] = 'Mini map';
export let nodeStrokeColor: $$Props['nodeStrokeColor'] = 'transparent'; export let nodeStrokeColor: $$Props['nodeStrokeColor'] = 'transparent';
export let nodeColor: $$Props['nodeColor'] = '#e2e2e2'; export let nodeColor: $$Props['nodeColor'] = undefined;
export let nodeClass: $$Props['nodeClass'] = ''; export let nodeClass: $$Props['nodeClass'] = '';
export let nodeBorderRadius: $$Props['nodeBorderRadius'] = 5; export let nodeBorderRadius: $$Props['nodeBorderRadius'] = 5;
export let nodeStrokeWidth: $$Props['nodeStrokeWidth'] = 2; export let nodeStrokeWidth: $$Props['nodeStrokeWidth'] = 2;
@@ -51,7 +51,7 @@
translateExtent translateExtent
} = useStore(); } = useStore();
const nodeColorFunc = getAttrFunction(nodeColor); const nodeColorFunc = nodeColor === undefined ? undefined : getAttrFunction(nodeColor);
const nodeStrokeColorFunc = getAttrFunction(nodeStrokeColor); const nodeStrokeColorFunc = getAttrFunction(nodeStrokeColor);
const nodeClassFunc = getAttrFunction(nodeClass); const nodeClassFunc = getAttrFunction(nodeClass);
const shapeRendering = const shapeRendering =
@@ -114,15 +114,15 @@
{#if ariaLabel}<title id={labelledBy}>{ariaLabel}</title>{/if} {#if ariaLabel}<title id={labelledBy}>{ariaLabel}</title>{/if}
{#each $nodes as node (node.id)} {#each $nodes as node (node.id)}
{#if node.width && node.height} {#if (node.computed?.width || node?.width) && (node.computed?.height || node.height)}
{@const pos = getNodePositionWithOrigin(node).positionAbsolute} {@const pos = getNodePositionWithOrigin(node).positionAbsolute}
<MinimapNode <MinimapNode
x={pos.x} x={pos.x}
y={pos.y} y={pos.y}
width={node.width} width={node.computed?.width ?? node.width ?? 0}
height={node.height} height={node.computed?.height ?? node.height ?? 0}
selected={node.selected} selected={node.selected}
color={nodeColorFunc(node)} color={nodeColorFunc?.(node)}
borderRadius={nodeBorderRadius} borderRadius={nodeBorderRadius}
strokeColor={nodeStrokeColorFunc(node)} strokeColor={nodeStrokeColorFunc(node)}
strokeWidth={nodeStrokeWidth} strokeWidth={nodeStrokeWidth}
@@ -142,27 +142,3 @@
</svg> </svg>
{/if} {/if}
</Panel> </Panel>
<style>
svg {
background-color: var(
--minimap-background-color-props,
var(--minimap-background-color, var(--minimap-background-color-default))
);
}
.svelte-flow__minimap-mask {
fill: var(
--minimap-mask-color-props,
var(--minimap-mask-color, var(--minimap-mask-color-default))
);
stroke: var(
--minimap-mask-stroke-color-props,
var(--minimap-mask-stroke-color, var(--minimap-mask-stroke-color-default))
);
stroke-width: var(
--minimap-mask-stroke-width-props,
var(--minimap-mask-stroke-width, var(--minimap-mask-stroke-width-default))
);
}
</style>
@@ -6,9 +6,9 @@
export let width: number = 0; export let width: number = 0;
export let height: number = 0; export let height: number = 0;
export let borderRadius: number = 5; export let borderRadius: number = 5;
export let color: string; export let color: string | undefined = undefined;
export let shapeRendering: string; export let shapeRendering: string;
export let strokeColor: string; export let strokeColor: string | undefined = undefined;
export let strokeWidth: number = 2; export let strokeWidth: number = 2;
export let selected: boolean = false; export let selected: boolean = false;
let className: string = ''; let className: string = '';
@@ -24,8 +24,8 @@
ry={borderRadius} ry={borderRadius}
{width} {width}
{height} {height}
fill={color} style={`${color ? `fill: ${color};` : ''}${strokeColor ? `stroke: ${strokeColor};` : ''}${
stroke={strokeColor} strokeWidth ? `stroke-width: ${strokeWidth};` : ''
stroke-width={strokeWidth} }`}
shape-rendering={shapeRendering} shape-rendering={shapeRendering}
/> />
@@ -0,0 +1,95 @@
<script lang="ts">
import { getContext } from 'svelte';
import {
getNodesBounds,
Position,
type Rect,
internalsSymbol,
getNodeToolbarTransform
} from '@xyflow/system';
import portal from '$lib/actions/portal';
import type { Node } from '$lib/types';
import { useStore } from '$lib/store';
import type { NodeToolbarProps } from './types';
type $$Props = NodeToolbarProps;
export let nodeId: $$Props['nodeId'] = undefined;
export let position: $$Props['position'] = undefined;
export let align: $$Props['align'] = undefined;
export let offset: $$Props['offset'] = undefined;
export let isVisible: $$Props['isVisible'] = undefined;
const { domNode, viewport, nodeLookup, nodes, nodeOrigin } = useStore();
const contextNodeId = getContext<string>('svelteflow__node_id');
let transform: string;
let toolbarNodes: Node[] = [];
let _offset = offset !== undefined ? offset : 10;
let _position = position !== undefined ? position : Position.Top;
let _align = align !== undefined ? align : 'center';
$: {
// $nodes only needed to trigger updates, $nodeLookup is just a helper that does not trigger any updates
if ($nodes) {
const nodeIds = Array.isArray(nodeId) ? nodeId : [nodeId || contextNodeId];
toolbarNodes = nodeIds.reduce<Node[]>((res, nodeId) => {
const node = $nodeLookup.get(nodeId);
if (node) {
res.push(node);
}
return res;
}, []);
}
}
$: {
let nodeRect: Rect | undefined = undefined;
if (toolbarNodes.length === 1) {
const toolbarNode = toolbarNodes[0];
nodeRect = {
...toolbarNode.position,
width: toolbarNode.computed?.width ?? toolbarNode.width ?? 0,
height: toolbarNode.computed?.height ?? toolbarNode.height ?? 0
};
} else if (toolbarNodes.length > 1) {
nodeRect = getNodesBounds(toolbarNodes, $nodeOrigin);
}
if (nodeRect) {
transform = getNodeToolbarTransform(nodeRect, $viewport, _position, _offset, _align);
}
}
$: zIndex =
toolbarNodes.length === 0
? 1
: Math.max(...toolbarNodes.map((node) => (node[internalsSymbol]?.z || 5) + 1));
//FIXME: Possible performance bottleneck
$: selectedNodesCount = $nodes.filter((node) => node.selected).length;
// if isVisible is not set, we show the toolbar only if its node is selected and no other node is selected
$: isActive =
typeof isVisible === 'boolean'
? isVisible
: toolbarNodes.length === 1 && toolbarNodes[0].selected && selectedNodesCount === 1;
</script>
{#if $domNode && isActive && toolbarNodes}
<div
data-id={toolbarNodes.reduce((acc, node) => `${acc}${node.id} `, '').trim()}
class="svelte-flow__node-toolbar"
use:portal={{ domNode: $domNode }}
style:position="absolute"
style:transform
style:z-index={zIndex}
>
<slot />
</div>
{/if}
@@ -0,0 +1,2 @@
export { default as NodeToolbar } from './NodeToolbar.svelte';
export * from './types';
@@ -0,0 +1,9 @@
import type { Position, Align } from '@xyflow/system';
export type NodeToolbarProps = {
nodeId?: string | string[];
position?: Position;
align?: Align;
offset?: number;
isVisible?: boolean;
};

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