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',
};
const CustomNode: FC<NodeProps> = ({ data, xPos, yPos }) => {
const CustomNode: FC<NodeProps> = ({ data, positionAbsolute }) => {
return (
<>
<Handle type="target" position={Position.Top} />
@@ -18,7 +18,7 @@ const CustomNode: FC<NodeProps> = ({ data, xPos, yPos }) => {
<div>
Position:{' '}
<strong>
{xPos.toFixed(2)},{yPos.toFixed(2)}
{positionAbsolute.x.toFixed(2)},{positionAbsolute.y.toFixed(2)}
</strong>
</div>
</div>
@@ -27,7 +27,7 @@ const initialNodes: Node[] = [
type: 'input',
data: { label: 'Node 1' },
position: { x: 250, y: 5 },
size: nodeSize,
...nodeSize,
handles: [
{
type: 'source',
@@ -41,23 +41,19 @@ const initialNodes: Node[] = [
id: '2',
data: { label: 'Node 2' },
position: { x: 100, y: 100 },
size: nodeSize,
...nodeSize,
handles: [
{
type: 'source',
position: Position.Bottom,
x: nodeSize.width * 0.5,
y: nodeSize.height,
width: 1,
height: 1,
},
{
type: 'target',
position: Position.Top,
x: nodeSize.width * 0.5,
y: 0,
width: 1,
height: 1,
},
],
},
@@ -65,23 +61,19 @@ const initialNodes: Node[] = [
id: '3',
data: { label: 'Node 3' },
position: { x: 400, y: 100 },
size: nodeSize,
...nodeSize,
handles: [
{
type: 'source',
position: Position.Bottom,
x: nodeSize.width * 0.5,
y: nodeSize.height,
width: 1,
height: 1,
},
{
type: 'target',
position: Position.Top,
x: nodeSize.width * 0.5,
y: 0,
width: 1,
height: 1,
},
],
},
@@ -11,10 +11,8 @@
data: { label: 'Node 0' },
sourcePosition: Position.Right,
targetPosition: Position.Left,
size: {
width: 100,
height: 40,
},
width: 100,
height: 40,
handles: [
{ type: 'source', x: 100, y: 20, position: Position.Right },
{ type: 'target', x: 0, y: 20, position: Position.Left },
@@ -26,10 +24,8 @@
data: { label: 'A' },
sourcePosition: Position.Right,
targetPosition: Position.Left,
size: {
width: 100,
height: 40,
},
width: 100,
height: 40,
handles: [
{ type: 'source', x: 100, y: 20, position: Position.Right },
{ type: 'target', x: 0, y: 20, position: Position.Left },
@@ -41,10 +37,8 @@
data: { label: 'B' },
sourcePosition: Position.Right,
targetPosition: Position.Left,
size: {
width: 100,
height: 40,
},
width: 100,
height: 40,
handles: [
{ type: 'source', x: 100, y: 20, position: Position.Right },
{ type: 'target', x: 0, y: 20, position: Position.Left },
@@ -56,10 +50,8 @@
data: { label: 'C' },
sourcePosition: Position.Right,
targetPosition: Position.Left,
size: {
width: 100,
height: 40,
},
width: 100,
height: 40,
handles: [
{ type: 'source', x: 100, y: 20, position: Position.Right },
{ type: 'target', x: 0, y: 20, position: Position.Left },
+11 -1
View File
@@ -1,6 +1,6 @@
# 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
@@ -8,5 +8,15 @@ This Vite app is used internally to test the library.
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 Backgrounds from '../examples/Backgrounds';
import ColorMode from '../examples/ColorMode';
import ControlledUncontrolled from '../examples/ControlledUncontrolled';
import ControlledViewport from '../examples/ControlledViewport';
import CustomConnectionLine from '../examples/CustomConnectionLine';
@@ -62,6 +63,11 @@ const routes: IRoute[] = [
path: 'backgrounds',
component: Backgrounds,
},
{
name: 'Color Mode',
path: 'color-mode',
component: ColorMode,
},
{
name: 'Cancel Connection',
path: 'cancel-connection',
+5 -5
View File
@@ -2,10 +2,10 @@ import { BackgroundProps, ControlProps, Edge, MiniMapProps, Node, PanelProps, Re
declare global {
interface FlowConfig {
flowProps: Omit<ReactFlowProps, 'nodes' | 'edges'> & { nodes: Node[]; edges: Edge[] };
panelProps: PanelProps;
backgroundProps: BackgroundProps;
controlsProps: ControlProps;
minimapProps: MiniMapProps;
flowProps?: Omit<ReactFlowProps, 'nodes' | 'edges'> & { nodes: Node[]; edges: Edge[] };
panelProps?: PanelProps;
backgroundProps?: BackgroundProps;
controlsProps?: ControlProps;
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) {
const type = event.dataTransfer.getData('application/reactflow');
const position = reactFlowInstance.screenToFlowCoordinate({
const position = reactFlowInstance.screenToFlowPosition({
x: event.clientX,
y: event.clientY,
});
@@ -66,7 +66,7 @@ const DnDFlow = () => {
return (
<div className={styles.dndflow}>
<ReactFlowProvider nodes={initialNodes} edges={[]}>
<ReactFlowProvider initialNodes={initialNodes} initialEdges={[]}>
<div className={styles.wrapper}>
<ReactFlow
nodes={nodes}
@@ -5,7 +5,6 @@ import CustomNode from './CustomNode';
import FloatingEdge from './FloatingEdge';
import CustomConnectionLine from './CustomConnectionLine';
import '@xyflow/react/dist/style.css';
import './style.css';
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
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
const {
width: intersectionNodeWidth,
height: intersectionNodeHeight,
positionAbsolute: intersectionNodePosition,
} = intersectionNode;
const targetPosition = targetNode.positionAbsolute!;
const { width: intersectionNodeWidth, height: intersectionNodeHeight } = intersectionNode;
const intersectionNodePosition = intersectionNode.computed?.positionAbsolute!;
const targetPosition = targetNode.computed?.positionAbsolute!;
const w = intersectionNodeWidth! / 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
function getEdgePosition(node: Node, intersectionPoint: XYPosition) {
const n = { ...node.positionAbsolute, ...node };
const n = { ...node.computed?.positionAbsolute, ...node };
const nx = Math.round(n.x!);
const ny = Math.round(n.y!);
const px = Math.round(intersectionPoint.x);
@@ -41,13 +38,13 @@ function getEdgePosition(node: Node, intersectionPoint: XYPosition) {
if (px <= nx + 1) {
return Position.Left;
}
if (px >= nx + n.width! - 1) {
if (px >= nx + n.computed?.width! - 1) {
return Position.Right;
}
if (py <= ny + 1) {
return Position.Top;
}
if (py >= n.y! + n.height! - 1) {
if (py >= n.y! + n.computed?.height! - 1) {
return Position.Bottom;
}
@@ -196,6 +196,7 @@ const EdgesFlow = () => {
onEdgeMouseEnter={onEdgeMouseEnter}
onEdgeMouseMove={onEdgeMouseMove}
onEdgeMouseLeave={onEdgeMouseLeave}
onDelete={console.log}
>
<MiniMap />
<Controls />
@@ -42,13 +42,13 @@ function getEdgePosition(node: Node, intersectionPoint: XYPosition) {
if (px <= nx + 1) {
return Position.Left;
}
if (px >= nx + (n.width ?? 0) - 1) {
if (px >= nx + (n.computed?.width ?? 0) - 1) {
return Position.Right;
}
if (py <= ny + 1) {
return Position.Top;
}
if (py >= n.y + (n.height ?? 0) - 1) {
if (py >= n.y + (n.computed?.height ?? 0) - 1) {
return Position.Bottom;
}
@@ -16,8 +16,6 @@ import CustomResizer from './CustomResizer';
import VerticalResizer from './VerticalResizer';
import HorizontalResizer from './HorizontalResizer';
import '@xyflow/react/dist/style.css';
const nodeTypes = {
defaultResizer: DefaultResizer,
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 onPaneClick = (event: ReactMouseEvent) => console.log('pane click', 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 onSelectionDragStart = (_: ReactMouseEvent, nodes: Node[]) => console.log('selection drag start', nodes);
const onSelectionDragStop = (_: ReactMouseEvent, nodes: Node[]) => console.log('selection drag stop', nodes);
@@ -230,9 +230,9 @@ const OverviewFlow = () => {
onEdgesDelete={onEdgesDelete}
onPaneMouseMove={onPaneMouseMove}
>
<MiniMap nodeStrokeColor={nodeStrokeColor} nodeColor={nodeColor} nodeBorderRadius={2} />
<MiniMap nodeBorderRadius={2} />
<Controls />
<Background color="#aaa" gap={25} />
<Background gap={25} />
</ReactFlow>
);
};
+1 -7
View File
@@ -1,7 +1,6 @@
import { useState, useCallback } from 'react';
import {
ReactFlow,
ReactFlowInstance,
Edge,
Node,
NodeChange,
@@ -18,11 +17,6 @@ import {
import { getNodesAndEdges } from './utils';
const onInit = (reactFlowInstance: ReactFlowInstance) => {
reactFlowInstance.fitView();
console.log(reactFlowInstance.getNodes());
};
const { nodes: initialNodes, edges: initialEdges } = getNodesAndEdges(25, 25);
const StressFlow = () => {
@@ -64,11 +58,11 @@ const StressFlow = () => {
<ReactFlow
nodes={nodes}
edges={edges}
onInit={onInit}
onConnect={onConnect}
onNodesChange={onNodesChange}
onEdgesChange={onEdgeChange}
minZoom={0.2}
fitView
>
<MiniMap />
<Controls />
@@ -20,8 +20,6 @@ export function getNodesAndEdges(xElements = 10, yElements = 10): ElementsCollec
style: { width: 50, height: 30, fontSize: 11 },
data,
position,
width: 50,
height: 30,
};
initialNodes.push(node);
@@ -11,13 +11,13 @@ const idStyle: CSSProperties = {
left: 2,
};
const DebugNode: FC<NodeProps> = ({ zIndex, xPos, yPos, id }) => {
const DebugNode: FC<NodeProps> = ({ zIndex, positionAbsolute, id }) => {
return (
<>
<Handle type="target" position={Position.Top} />
<div style={idStyle}>{id}</div>
<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>
<Handle type="source" position={Position.Bottom} />
</>
+2 -2
View File
@@ -18,8 +18,8 @@ type FlowProps = {
};
export default ({ flowConfig }: FlowProps) => {
const [nodes, setNodes] = useState(flowConfig.flowProps.nodes);
const [edges, setEdges] = useState(flowConfig.flowProps.edges);
const [nodes, setNodes] = useState(flowConfig.flowProps?.nodes);
const [edges, setEdges] = useState(flowConfig.flowProps?.edges);
const props = { ...flowConfig.flowProps, nodes, edges };
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 { createRoot } from 'react-dom/client';
import App from './App';
import App from './App/index';
import '@xyflow/react/dist/style.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.
@@ -8,3 +8,13 @@ This Vite app is used internally to test the library.
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 {
flowProps: Omit<SvelteFlowProps, 'nodes' | 'edges'> & { nodes: Node[]; edges: Edge[] };
panelProps: PanelProps;
backgroundProps: BackgroundProps;
controlsProps: ControlsProps;
minimapProps: MiniMapProps;
flowProps?: Omit<SvelteFlowProps, 'nodes' | 'edges'> & { nodes: Node[]; edges: Edge[] };
panelProps?: PanelProps;
backgroundProps?: BackgroundProps;
controlsProps?: ControlsProps;
minimapProps?: MiniMapProps;
}
}
@@ -4,6 +4,7 @@
const routes = [
'add-node-on-drop',
'colormode',
'custom-connection-line',
'customnode',
'dagre',
@@ -12,6 +13,7 @@
'figma',
'interaction',
'intersections',
'node-toolbar',
'overview',
'stress',
'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">
import { SvelteFlow, useSvelteFlow } from '@xyflow/svelte';
import type { Edge, Node } from '@xyflow/svelte';
import { writable } from 'svelte/store';
import { SvelteFlow, useSvelteFlow, type Edge, type Node } from '@xyflow/svelte';
import '@xyflow/svelte/dist/style.css';
@@ -22,7 +21,7 @@
let id = 1;
const getId = () => `${id++}`;
const { screenToFlowCoordinate, flowToScreenCoordinate } = useSvelteFlow();
const { screenToFlowPosition, flowToScreenPosition } = useSvelteFlow();
function handleConnectEnd({ detail: { event } }: { detail: { event: MouseEvent | TouchEvent } }) {
// See of connection landed inside the flow pane
@@ -34,7 +33,7 @@
y: event.clientY
};
const doubleTransformedPosition = flowToScreenCoordinate(screenToFlowCoordinate(position));
const doubleTransformedPosition = flowToScreenPosition(screenToFlowPosition(position));
console.log(
'Is transforming in both directions (screen-flow, flow-screen) the same?',
position.x === doubleTransformedPosition.x && position.y === doubleTransformedPosition.y
@@ -43,8 +42,8 @@
const newNode: Node = {
id,
data: { label: `Node ${id}` },
// project the screen coordinates to pane coordinates
position: screenToFlowCoordinate(position),
// project the screen position to pane position
position: screenToFlowPosition(position),
// set the origin of the new node so it is centered
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();
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
autoPanOnNodeDrag
connectionMode={ConnectionMode.Strict}
@@ -1,16 +1,15 @@
<script lang="ts">
import { Handle, Position, type NodeProps } from '@xyflow/svelte';
import { Handle, Position, type NodeProps, type XYPosition } from '@xyflow/svelte';
type $$Props = NodeProps;
export let data: { label: string } = { label: 'Node' };
export let xPos: number = 0;
export let yPos: number = 0;
export let positionAbsolute: XYPosition = { x: 0, y: 0 };
</script>
<div class="custom">
<div>{data.label}</div>
<div>{~~xPos}, {~~yPos}</div>
<div>{~~positionAbsolute.x}, {~~positionAbsolute.y}</div>
<Handle type="target" position={Position.Top} />
@@ -1,24 +1,16 @@
<script lang="ts">
import { Handle, Position, type NodeProps } from '@xyflow/svelte';
import { Handle, Position, type NodeProps, type XYPosition } from '@xyflow/svelte';
type $$Props = NodeProps;
export let id: string;
export let xPos: number = 0;
export let yPos: number = 0;
export let positionAbsolute: XYPosition = { x: 0, y: 0 };
export let zIndex: number = 0;
</script>
<Handle type="target" position={Position.Top} />
<div>{id}</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>
<Handle type="source" position={Position.Bottom} />
<style>
.custom {
background: #ffcc00;
padding: 10px;
}
</style>
@@ -7,8 +7,8 @@
export let flowConfig: FlowConfig;
// Create writables here so it is easier to create test cases
const nodes = writable(flowConfig.flowProps.nodes);
const edges = writable(flowConfig.flowProps.edges);
const nodes = writable(flowConfig.flowProps?.nodes);
const edges = writable(flowConfig.flowProps?.edges);
const flowProps = { ...flowConfig.flowProps, nodes, edges };
</script>
+2 -2
View File
@@ -8,8 +8,8 @@
"scripts": {
"preinstall": "npx only-allow pnpm",
"dev": "turbo run dev --parallel --concurrency 12",
"dev:svelte": "turbo run dev --filter=svelte --filter=system",
"dev:react": "turbo run dev --filter=react",
"dev:svelte": "turbo run dev --filter=svelte-examples...",
"dev:react": "turbo run dev --filter=react-examples...",
"test:svelte": "pnpm --filter=playwright run test:svelte",
"test:svelte:ui": "pnpm --filter=playwright run test:svelte:ui",
"test:react": "pnpm --filter=playwright run test:react",
@@ -26,6 +26,6 @@ type DotPatternProps = {
export function DotPattern({ radius, className }: DotPatternProps) {
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/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 { shallow } from 'zustand/shallow';
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({
style,
className,
nodeStrokeColor = 'transparent',
nodeColor = '#e2e2e2',
nodeStrokeColor,
nodeColor,
nodeClassName = '',
nodeBorderRadius = 5,
nodeStrokeWidth = 2,
nodeStrokeWidth,
// We need to rename the prop to be `CapitalCase` so that JSX will render it as
// a component properly.
nodeComponent,
maskColor = 'rgb(240, 240, 240, 0.6)',
maskColor,
maskStrokeColor = 'none',
maskStrokeWidth = 1,
position = 'bottom-right',
@@ -118,7 +118,7 @@ function MiniMap({
const onSvgNodeClick = onNodeClick
? 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);
}, [])
: undefined;
@@ -126,7 +126,15 @@ function MiniMap({
return (
<Panel
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])}
data-testid="rf__minimap"
>
@@ -153,7 +161,6 @@ function MiniMap({
className="react-flow__minimap-mask"
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`}
fill={maskColor}
fillRule="evenodd"
stroke={maskStrokeColor}
strokeWidth={maskStrokeWidth}
@@ -31,9 +31,11 @@ function MiniMapNode({
ry={borderRadius}
width={width}
height={height}
fill={fill}
stroke={strokeColor}
strokeWidth={strokeWidth}
style={{
fill,
stroke: strokeColor,
strokeWidth,
}}
shapeRendering={shapeRendering}
onClick={onClick ? (event) => onClick(event, id) : undefined}
/>
@@ -12,15 +12,18 @@ import type { MiniMapNodes, GetMiniMapNodeAttribute } from './types';
declare const window: any;
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);
function MiniMapNodes({
nodeStrokeColor = 'transparent',
nodeColor = '#e2e2e2',
nodeStrokeColor,
nodeColor,
nodeClassName = '',
nodeBorderRadius = 5,
nodeStrokeWidth = 2,
nodeStrokeWidth,
// We need to rename the prop to be `CapitalCase` so that JSX will render it as
// a component properly.
nodeComponent: NodeComponent = MiniMapNode,
@@ -38,20 +41,22 @@ function MiniMapNodes({
<>
{nodes.map((node) => {
const { x, y } = getNodePositionWithOrigin(node, node.origin || nodeOrigin).positionAbsolute;
const color = nodeColor === undefined ? undefined : nodeColorFunc(node);
const strokeColor = nodeStrokeColor === undefined ? undefined : nodeStrokeColorFunc(node);
return (
<NodeComponent
key={node.id}
x={x}
y={y}
width={node.width!}
height={node.height!}
width={node.computed?.width ?? node.width ?? 0}
height={node.computed?.height ?? node.height ?? 0}
style={node.style}
selected={!!node.selected}
className={nodeClassNameFunc(node)}
color={nodeColorFunc(node)}
color={color}
borderRadius={nodeBorderRadius}
strokeColor={nodeStrokeColorFunc(node)}
strokeColor={strokeColor}
strokeWidth={nodeStrokeWidth}
shapeRendering={shapeRendering}
onClick={onClick}
@@ -42,10 +42,10 @@ export type MiniMapNodeProps = {
height: number;
borderRadius: number;
className: string;
color: string;
color?: string;
shapeRendering: string;
strokeColor: string;
strokeWidth: number;
strokeColor?: string;
strokeWidth?: number;
style?: CSSProperties;
selected: boolean;
onClick?: (event: MouseEvent, id: string) => void;
@@ -65,13 +65,13 @@ function ResizeControl({
const dragHandler = drag<HTMLDivElement, unknown>()
.on('start', (event: ResizeDragEvent) => {
const { nodes, transform, snapGrid, snapToGrid } = store.getState();
const node = nodes.find((n) => n.id === id);
const { nodeLookup, transform, snapGrid, snapToGrid } = store.getState();
const node = nodeLookup.get(id);
const { xSnapped, ySnapped } = getPointerPosition(event.sourceEvent, { transform, snapGrid, snapToGrid });
prevValues.current = {
width: node?.width ?? 0,
height: node?.height ?? 0,
width: node?.computed?.width ?? 0,
height: node?.computed?.height ?? 0,
x: node?.position.x ?? 0,
y: node?.position.y ?? 0,
};
@@ -86,9 +86,9 @@ function ResizeControl({
onResizeStart?.(event, { ...prevValues.current });
})
.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 node = nodes.find((n) => n.id === id);
const node = nodeLookup.get(id);
if (node) {
const changes: NodeChange[] = [];
@@ -1,17 +1,17 @@
import { useCallback, CSSProperties } from 'react';
import cc from 'classcat';
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 { useStore } from '../../hooks/useStore';
import { useNodeId } from '../../contexts/NodeIdContext';
import NodeToolbarPortal from './NodeToolbarPortal';
import { Align, NodeToolbarProps } from './types';
import { NodeToolbarProps } from './types';
const nodeEqualityFn = (a: Node | undefined, b: Node | undefined) =>
a?.positionAbsolute?.x === b?.positionAbsolute?.x &&
a?.positionAbsolute?.y === b?.positionAbsolute?.y &&
a?.computed?.positionAbsolute?.x === b?.computed?.positionAbsolute?.x &&
a?.computed?.positionAbsolute?.y === b?.computed?.positionAbsolute?.y &&
a?.width === b?.width &&
a?.height === b?.height &&
a?.selected === b?.selected &&
@@ -22,53 +22,15 @@ const nodesEqualityFn = (a: Node[], b: Node[]) => {
};
const storeSelector = (state: ReactFlowState) => ({
transform: state.transform,
viewport: {
x: state.transform[0],
y: state.transform[1],
zoom: state.transform[2],
},
nodeOrigin: state.nodeOrigin,
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({
nodeId,
children,
@@ -87,17 +49,19 @@ function NodeToolbar({
const nodeIds = Array.isArray(nodeId) ? nodeId : [nodeId || contextNodeId || ''];
return nodeIds.reduce<Node[]>((acc, id) => {
const node = state.nodes.find((n) => n.id === id);
const node = state.nodeLookup.get(id);
if (node) {
acc.push(node);
}
return acc;
}, [] as Node[]);
}, []);
},
[nodeId, contextNodeId]
);
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 =
typeof isVisible === 'boolean' ? isVisible : nodes.length === 1 && nodes[0].selected && selectedNodesCount === 1;
@@ -110,14 +74,19 @@ function NodeToolbar({
const wrapperStyle: CSSProperties = {
position: 'absolute',
transform: getTransform(nodeRect, transform, position, offset, align),
transform: getNodeToolbarTransform(nodeRect, viewport, position, offset, align),
zIndex,
...style,
};
return (
<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}
</div>
</NodeToolbarPortal>
@@ -1,5 +1,5 @@
import type { HTMLAttributes } from 'react';
import type { Position } from '@xyflow/system';
import type { Position, Align } from '@xyflow/system';
export type NodeToolbarProps = HTMLAttributes<HTMLDivElement> & {
nodeId?: string | string[];
@@ -8,5 +8,3 @@ export type NodeToolbarProps = HTMLAttributes<HTMLDivElement> & {
offset?: number;
align?: Align;
};
export type Align = 'center' | 'start' | 'end';
@@ -43,7 +43,7 @@ const ConnectionLine = ({
const { fromNode, handleId, toX, toY, connectionMode } = useStore(
useCallback(
(s: ReactFlowStore) => ({
fromNode: s.nodes.find((n) => n.id === nodeId),
fromNode: s.nodeLookup.get(nodeId),
handleId: s.connectionStartHandle?.handleId,
toX: (s.connectionPosition.x - s.transform[0]) / 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 fromHandleX = fromHandle ? fromHandle.x + fromHandle.width / 2 : (fromNode.width ?? 0) / 2;
const fromHandleY = fromHandle ? fromHandle.y + fromHandle.height / 2 : fromNode.height ?? 0;
const fromX = (fromNode.positionAbsolute?.x ?? 0) + fromHandleX;
const fromY = (fromNode.positionAbsolute?.y ?? 0) + fromHandleY;
const fromHandleX = fromHandle ? fromHandle.x + fromHandle.width / 2 : (fromNode.computed?.width ?? 0) / 2;
const fromHandleY = fromHandle ? fromHandle.y + fromHandle.height / 2 : fromNode.computed?.height ?? 0;
const fromX = (fromNode.computed?.positionAbsolute?.x ?? 0) + fromHandleX;
const fromY = (fromNode.computed?.positionAbsolute?.y ?? 0) + fromHandleY;
const fromPosition = fromHandle?.position;
const toPosition = fromPosition ? oppositePosition[fromPosition] : null;
@@ -1,7 +1,6 @@
import { isNumeric } from '@xyflow/system';
import type { BaseEdgeProps } from '../../types';
import EdgeText from './EdgeText';
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 { shallow } from 'zustand/shallow';
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 [updating, setUpdating] = useState<boolean>(false);
const store = useStoreApi();
const edgePosition = useStore((state) => {
const sourceNode = state.nodes.find((n) => n.id === source);
const targetNode = state.nodes.find((n) => n.id === target);
const edgePosition = useStore(
useCallback(
(state) => {
const sourceNode = state.nodeLookup.get(source);
const targetNode = state.nodeLookup.get(target);
if (!sourceNode || !targetNode) {
return null;
}
if (!sourceNode || !targetNode) {
return null;
}
const pos = getEdgePosition({
id,
sourceNode,
targetNode,
sourceHandle: sourceHandleId || null,
targetHandle: targetHandleId || null,
connectionMode: state.connectionMode,
onError: state.onError,
});
return pos;
}, shallow);
return getEdgePosition({
id,
sourceNode,
targetNode,
sourceHandle: sourceHandleId || null,
targetHandle: targetHandleId || null,
connectionMode: state.connectionMode,
onError: state.onError,
});
},
[source, target]
),
shallow
);
const markerStartUrl = useMemo(() => `url(#${getMarkerId(markerStart, rfId)})`, [markerStart, rfId]);
const markerEndUrl = useMemo(() => `url(#${getMarkerId(markerEnd, rfId)})`, [markerEnd, rfId]);
@@ -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 cc from 'classcat';
import { shallow } from 'zustand/shallow';
+3 -3
View File
@@ -12,7 +12,7 @@ export function getMouseHandler(
return handler === undefined
? handler
: (event: MouseEvent) => {
const node = getState().nodes.find((n) => n.id === id)!;
const node = getState().nodeLookup.get(id)!;
handler(event, { ...node });
};
}
@@ -35,8 +35,8 @@ export function handleNodeClick({
unselect?: boolean;
nodeRef?: RefObject<HTMLDivElement>;
}) {
const { addSelectedNodes, unselectNodesAndEdges, multiSelectionActive, nodes, onError } = store.getState();
const node = nodes.find((n) => n.id === id)!;
const { addSelectedNodes, unselectNodesAndEdges, multiSelectionActive, nodeLookup, onError } = store.getState();
const node = nodeLookup.get(id);
if (!node) {
onError?.('012', errorMessages['error012'](id));
@@ -52,8 +52,9 @@ export default (NodeComponent: ComponentType<NodeProps>) => {
disableKeyboardA11y,
ariaLabel,
rfId,
sizeWidth,
sizeHeight,
positionAbsolute,
width,
height,
}: WrapNodeProps) => {
const store = useStoreApi();
const nodeRef = useRef<HTMLDivElement>(null);
@@ -146,7 +147,7 @@ export default (NodeComponent: ComponentType<NodeProps>) => {
if (targetPosChanged) {
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]);
@@ -186,8 +187,8 @@ export default (NodeComponent: ComponentType<NodeProps>) => {
transform: `translate(${xPosOrigin}px,${yPosOrigin}px)`,
pointerEvents: hasPointerEvents ? 'all' : 'none',
visibility: initialized ? 'visible' : 'hidden',
width: sizeWidth,
height: sizeHeight,
width,
height,
...style,
}}
data-id={id}
@@ -209,8 +210,9 @@ export default (NodeComponent: ComponentType<NodeProps>) => {
id={id}
data={data}
type={type}
xPos={xPos}
yPos={yPos}
width={width}
height={height}
positionAbsolute={positionAbsolute}
selected={selected}
isConnectable={isConnectable}
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 { 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 store = useStoreApi();
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 { StoreApi } from 'zustand';
import { shallow } from 'zustand/shallow';
@@ -38,6 +43,7 @@ type StoreUpdaterProps = Pick<
| 'fitViewOptions'
| 'onNodesDelete'
| 'onEdgesDelete'
| 'onDelete'
| 'onNodeDragStart'
| 'onNodeDrag'
| 'onNodeDragStop'
@@ -70,10 +76,10 @@ const selector = (s: ReactFlowState) => ({
reset: s.reset,
});
function useStoreUpdater<T>(value: T | undefined, setStoreState: (param: T) => void) {
function useStoreUpdater<T>(value: T | undefined, setStoreAction: (param: T) => void) {
useEffect(() => {
if (typeof value !== 'undefined') {
setStoreState(value);
setStoreAction(value);
}
}, [value]);
}
@@ -123,6 +129,7 @@ const StoreUpdater = ({
fitViewOptions,
onNodesDelete,
onEdgesDelete,
onDelete,
onNodeDrag,
onNodeDragStart,
onNodeDragStop,
@@ -187,6 +194,7 @@ const StoreUpdater = ({
useDirectStoreUpdater('fitViewOnInitOptions', fitViewOptions, store.setState);
useDirectStoreUpdater('onNodesDelete', onNodesDelete, store.setState);
useDirectStoreUpdater('onEdgesDelete', onEdgesDelete, store.setState);
useDirectStoreUpdater('onDelete', onDelete, store.setState);
useDirectStoreUpdater('onNodeDrag', onNodeDrag, store.setState);
useDirectStoreUpdater('onNodeDragStart', onNodeDragStart, store.setState);
useDirectStoreUpdater('onNodeDragStop', onNodeDragStop, store.setState);
@@ -63,6 +63,8 @@ const EdgeRenderer = ({
children,
}: EdgeRendererProps) => {
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);
return (
@@ -70,7 +72,7 @@ const EdgeRenderer = ({
{edgeTree.map(({ level, edges, isMaxLevel }) => (
<svg key={level} style={{ zIndex: level }} className="react-flow__edges react-flow__container">
{isMaxLevel && <MarkerDefinitions defaultColor={defaultMarkerColor} rfId={rfId} />}
<g>
<>
{edges.map((edge) => {
let edgeType = edge.type || 'default';
@@ -132,7 +134,7 @@ const EdgeRenderer = ({
/>
);
})}
</g>
</>
</svg>
))}
{children}
@@ -48,11 +48,16 @@ const NodeRenderer = (props: NodeRendererProps) => {
}
const observer = new ResizeObserver((entries: ResizeObserverEntry[]) => {
const updates = entries.map((entry: ResizeObserverEntry) => ({
id: entry.target.getAttribute('data-id') as string,
nodeElement: entry.target as HTMLDivElement,
forceUpdate: true,
}));
const updates = new Map();
entries.forEach((entry: ResizeObserverEntry) => {
const id = entry.target.getAttribute('data-id') as string;
updates.set(id, {
id,
nodeElement: entry.target as HTMLDivElement,
forceUpdate: true,
});
});
updateNodeDimensions(updates);
});
@@ -86,19 +91,19 @@ const NodeRenderer = (props: NodeRendererProps) => {
const isFocusable = !!(node.focusable || (nodesFocusable && typeof node.focusable === 'undefined'));
const clampedPosition = props.nodeExtent
? clampPosition(node.positionAbsolute, props.nodeExtent)
: node.positionAbsolute;
? clampPosition(node.computed?.positionAbsolute, props.nodeExtent)
: node.computed?.positionAbsolute;
const posX = clampedPosition?.x ?? 0;
const posY = clampedPosition?.y ?? 0;
const posOrigin = getPositionWithOrigin({
x: posX,
y: posY,
width: node.width ?? 0,
height: node.height ?? 0,
width: node.computed?.width ?? node.width ?? 0,
height: node.computed?.height ?? node.height ?? 0,
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 (
<NodeComponent
@@ -106,8 +111,8 @@ const NodeRenderer = (props: NodeRendererProps) => {
id={node.id}
className={node.className}
style={node.style}
sizeWidth={node.size?.width}
sizeHeight={node.size?.height}
width={node.width ?? undefined}
height={node.height ?? undefined}
type={nodeType}
data={node.data}
sourcePosition={node.sourcePosition || Position.Bottom}
@@ -117,6 +122,7 @@ const NodeRenderer = (props: NodeRendererProps) => {
yPos={posY}
xPosOrigin={posOrigin.x}
yPosOrigin={posOrigin.y}
positionAbsolute={clampedPosition || { x: 0, y: 0 }}
onClick={props.onNodeClick}
onMouseEnter={props.onNodeMouseEnter}
onMouseMove={props.onNodeMouseMove}
@@ -23,6 +23,7 @@ import A11yDescriptions from '../../components/A11yDescriptions';
import GraphView from '../GraphView';
import Wrapper from './Wrapper';
import type { EdgeTypes, NodeTypes, ReactFlowProps, ReactFlowRefType } from '../../types';
import useColorModeClass from '../../hooks/useColorModeClass';
const defaultNodeTypes: NodeTypes = {
input: InputNode,
@@ -82,6 +83,7 @@ const ReactFlow = forwardRef<ReactFlowRefType, ReactFlowProps>(
onNodeDragStop,
onNodesDelete,
onEdgesDelete,
onDelete,
onSelectionChange,
onSelectionDragStart,
onSelectionDrag,
@@ -168,18 +170,20 @@ const ReactFlow = forwardRef<ReactFlowRefType, ReactFlowProps>(
onViewportChange,
width,
height,
colorMode = 'light',
...rest
},
ref
) => {
const rfId = id || '1';
const colorModeClassName = useColorModeClass(colorMode);
return (
<div
{...rest}
style={{ ...style, ...wrapperStyle }}
ref={ref}
className={cc(['react-flow', className])}
className={cc(['react-flow', className, colorModeClassName])}
data-testid="rf__wrapper"
id={id}
>
@@ -281,6 +285,7 @@ const ReactFlow = forwardRef<ReactFlowRefType, ReactFlowProps>(
fitViewOptions={fitViewOptions}
onNodesDelete={onNodesDelete}
onEdgesDelete={onEdgesDelete}
onDelete={onDelete}
onNodeDragStart={onNodeDragStart}
onNodeDrag={onNodeDrag}
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) => {
return store.getState().nodes.find((n) => n.id === id);
return store.getState().nodeLookup.get(id);
}, []);
const getEdges = useCallback<Instance.GetEdges<EdgeData>>(() => {
@@ -127,6 +127,7 @@ export default function useReactFlow<NodeData = any, EdgeData = any>(): ReactFlo
onEdgesDelete,
onNodesChange,
onEdgesChange,
onDelete,
} = store.getState();
const { matchingNodes, matchingEdges } = getElementsToRemove<Node, Edge>({
nodesToRemove: nodesDeleted || [],
@@ -171,6 +172,8 @@ export default function useReactFlow<NodeData = any, EdgeData = any>(): ReactFlo
onNodesChange(nodeChanges);
}
}
onDelete?.({ nodes: matchingNodes, edges: 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
): [Rect | null, Node<NodeData> | null | undefined, boolean] => {
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) {
[null, null, isRect];
@@ -203,7 +206,7 @@ export default function useReactFlow<NodeData = any, EdgeData = any>(): ReactFlo
}
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;
}
@@ -8,17 +8,16 @@ function useUpdateNodeInternals(): UpdateNodeInternals {
return useCallback<UpdateNodeInternals>((id: string | string[]) => {
const { domNode, updateNodeDimensions } = store.getState();
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;
if (nodeElement) {
res.push({ id: updateId, nodeElement, forceUpdate: true });
updates.set(updateId, { id: updateId, nodeElement, forceUpdate: true });
}
return res;
}, []);
});
requestAnimationFrame(() => updateNodeDimensions(updates));
}, []);
@@ -23,8 +23,11 @@ function useUpdateNodePositions() {
const yDiff = params.y * yVelo * factor;
const nodeUpdates = selectedNodes.map((node) => {
if (node.positionAbsolute) {
let nextPosition = { x: node.positionAbsolute.x + xDiff, y: node.positionAbsolute.y + yDiff };
if (node.computed?.positionAbsolute) {
let nextPosition = {
x: node.computed?.positionAbsolute.x + xDiff,
y: node.computed?.positionAbsolute.y + yDiff,
};
if (snapToGrid) {
nextPosition = snapPosition(nextPosition, snapGrid);
@@ -40,7 +43,10 @@ function useUpdateNodePositions() {
);
node.position = position;
node.positionAbsolute = positionAbsolute;
if (!node.computed) {
node.computed = {};
}
node.computed.positionAbsolute = positionAbsolute;
}
return node;
+3 -3
View File
@@ -12,8 +12,8 @@ function useVisibleEdges(onlyRenderVisible: boolean, elevateEdgesOnSelect: boole
const visibleEdges =
onlyRenderVisible && s.width && s.height
? s.edges.filter((e) => {
const sourceNode = s.nodes.find((n) => n.id === e.source);
const targetNode = s.nodes.find((n) => n.id === e.target);
const sourceNode = s.nodeLookup.get(e.source);
const targetNode = s.nodeLookup.get(e.target);
return (
sourceNode &&
@@ -29,7 +29,7 @@ function useVisibleEdges(onlyRenderVisible: boolean, elevateEdgesOnSelect: boole
})
: s.edges;
return groupEdgesByZLevel(visibleEdges, s.nodes, elevateEdgesOnSelect);
return groupEdgesByZLevel(visibleEdges, s.nodeLookup, elevateEdgesOnSelect);
},
[onlyRenderVisible, elevateEdgesOnSelect]
),
+2
View File
@@ -73,6 +73,8 @@ export {
type Box,
type Transform,
type CoordinateExtent,
type ColorMode,
type ColorModeClass,
} from '@xyflow/system';
// system utils
+36 -13
View File
@@ -41,18 +41,19 @@ const createRFStore = ({
(set, get) => ({
...getInitialState({ nodes, edges, width, height, fitView }),
setNodes: (nodes: Node[]) => {
const { nodes: storeNodes, nodeOrigin, elevateNodesOnSelect } = get();
const nextNodes = updateNodes(nodes, storeNodes, { nodeOrigin, elevateNodesOnSelect });
const { nodeLookup, nodeOrigin, elevateNodesOnSelect } = get();
// 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 });
},
getNodes: () => {
return get().nodes;
},
setEdges: (edges: Edge[]) => {
const { defaultEdgeOptions = {} } = get();
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[]) => {
const hasDefaultNodes = typeof nodes !== 'undefined';
const hasDefaultEdges = typeof edges !== 'undefined';
@@ -68,7 +69,7 @@ const createRFStore = ({
};
if (hasDefaultNodes) {
nextState.nodes = updateNodes(nodes, [], {
nextState.nodes = updateNodes(nodes, new Map(), {
nodeOrigin: get().nodeOrigin,
elevateNodesOnSelect: get().elevateNodesOnSelect,
});
@@ -79,14 +80,27 @@ const createRFStore = ({
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) => {
const { onNodesChange, fitView, nodes, fitViewOnInit, fitViewDone, fitViewOnInitOptions, domNode, nodeOrigin } =
get();
const {
onNodesChange,
fitView,
nodes,
nodeLookup,
fitViewOnInit,
fitViewDone,
fitViewOnInitOptions,
domNode,
nodeOrigin,
} = get();
const changes: NodeDimensionChange[] = [];
const updatedNodes = updateNodeDimensionsSystem(
updates,
nodes,
nodeLookup,
domNode,
nodeOrigin,
(id: string, dimensions: Dimensions) => {
@@ -102,8 +116,9 @@ const createRFStore = ({
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;
if (!fitViewDone && fitViewOnInit) {
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 });
if (changes?.length > 0) {
@@ -127,7 +147,7 @@ const createRFStore = ({
};
if (positionChanged) {
change.positionAbsolute = node.positionAbsolute;
change.positionAbsolute = node.computed?.positionAbsolute;
change.position = node.position;
}
@@ -138,12 +158,12 @@ const createRFStore = ({
},
triggerNodeChanges: (changes) => {
const { onNodesChange, nodes, hasDefaultNodes, nodeOrigin, elevateNodesOnSelect } = get();
const { onNodesChange, nodeLookup, nodes, hasDefaultNodes, nodeOrigin, elevateNodesOnSelect } = get();
if (changes?.length) {
if (hasDefaultNodes) {
const updatedNodes = applyNodeChanges(changes, nodes);
const nextNodes = updateNodes(updatedNodes, nodes, {
const nextNodes = updateNodes(updatedNodes, nodeLookup, {
nodeOrigin,
elevateNodesOnSelect,
});
@@ -256,7 +276,10 @@ const createRFStore = ({
return {
...node,
positionAbsolute,
computed: {
...node.computed,
positionAbsolute,
},
};
}),
});
+4 -6
View File
@@ -22,16 +22,13 @@ const getInitialState = ({
height?: number;
fitView?: boolean;
} = {}): 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];
if (fitView && width && height) {
const nodesWithDimensions = nextNodes.map((node) => ({
...node,
width: node.size?.width,
height: node.size?.height,
}));
const nodesWithDimensions = nextNodes.filter((node) => node.width && node.height);
const bounds = getNodesBounds(nodesWithDimensions, [0, 0]);
const { x, y, zoom } = getViewportForBounds(bounds, width, height, 0.5, 2, 0.1);
transform = [x, y, zoom];
@@ -43,6 +40,7 @@ const getInitialState = ({
height: 0,
transform,
nodes: nextNodes,
nodeLookup,
edges: edges,
onNodesChange: 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 */
@import '../../../system/src/styles/init.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/style.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,
OnError,
IsValidConnection,
ColorMode,
} from '@xyflow/system';
import type {
@@ -34,6 +35,7 @@ import type {
FitViewOptions,
OnNodesDelete,
OnEdgesDelete,
OnDelete,
OnNodesChange,
OnEdgesChange,
NodeDragHandler,
@@ -70,6 +72,7 @@ export type ReactFlowProps = Omit<HTMLAttributes<HTMLDivElement>, 'onError'> & {
onEdgesChange?: OnEdgesChange;
onNodesDelete?: OnNodesDelete;
onEdgesDelete?: OnEdgesDelete;
onDelete?: OnDelete;
onSelectionDragStart?: SelectionDragHandler;
onSelectionDrag?: SelectionDragHandler;
onSelectionDragStop?: SelectionDragHandler;
@@ -153,6 +156,7 @@ export type ReactFlowProps = Omit<HTMLAttributes<HTMLDivElement>, 'onError'> & {
nodeDragThreshold?: number;
width?: number;
height?: number;
colorMode?: ColorMode;
};
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 OnEdgesDelete = (edges: Edge[]) => void;
export type OnDelete = (params: { nodes: Node[]; edges: Edge[] }) => void;
export type NodeTypes = { [key: string]: ComponentType<NodeProps> };
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 { NodeBase } from '@xyflow/system';
import type { NodeBase, XYPosition } from '@xyflow/system';
export type Node<NodeData = any, NodeType extends string | undefined = string | undefined> = NodeBase<
NodeData,
@@ -24,6 +24,7 @@ export type WrapNodeProps<NodeData = any> = Pick<
yPos: number;
xPosOrigin: number;
yPosOrigin: number;
positionAbsolute: XYPosition;
initialized: boolean;
isSelectable: boolean;
isDraggable: boolean;
@@ -40,6 +41,6 @@ export type WrapNodeProps<NodeData = any> = Pick<
noPanClassName: string;
rfId: string;
disableKeyboardA11y: boolean;
sizeWidth?: number;
sizeHeight?: number;
width?: number;
height?: number;
};
+4 -2
View File
@@ -38,6 +38,7 @@ import type {
OnEdgesDelete,
OnSelectionChangeFunc,
UnselectNodesAndEdgesParams,
OnDelete,
} from '.';
export type ReactFlowStore = {
@@ -46,6 +47,7 @@ export type ReactFlowStore = {
height: number;
transform: Transform;
nodes: Node[];
nodeLookup: Map<string, Node>;
edges: Edge[];
onNodesChange: OnNodesChange | null;
onEdgesChange: OnEdgesChange | null;
@@ -117,6 +119,7 @@ export type ReactFlowStore = {
onNodesDelete?: OnNodesDelete;
onEdgesDelete?: OnEdgesDelete;
onDelete?: OnDelete;
onError?: OnError;
// event handlers
@@ -138,10 +141,9 @@ export type ReactFlowStore = {
export type ReactFlowActions = {
setNodes: (nodes: Node[]) => void;
getNodes: () => Node[];
setEdges: (edges: Edge[]) => void;
setDefaultNodesAndEdges: (nodes?: Node[], edges?: Edge[]) => void;
updateNodeDimensions: (updates: NodeDimensionUpdate[]) => void;
updateNodeDimensions: (updates: Map<string, NodeDimensionUpdate>) => void;
updateNodePositions: UpdateNodePositions;
resetSelectedElements: () => 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);
if (parent) {
const extendWidth = updateItem.position.x + updateItem.width - parent.width;
const extendHeight = updateItem.position.y + updateItem.height - parent.height;
if (!parent.computed) {
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) {
parent.style = { ...parent.style } || {};
parent.style.width = parent.style.width ?? parent.width;
parent.style.height = parent.style.height ?? parent.height;
parent.style.width = parent.style.width ?? parent.computed.width;
parent.style.height = parent.style.height ?? parent.computed.height;
if (extendWidth > 0) {
parent.style.width += extendWidth;
@@ -36,30 +39,45 @@ export function handleParentExpand(res: any[], updateItem: any) {
updateItem.position.y = 0;
}
parent.width = parent.style.width;
parent.height = parent.style.height;
parent.computed.width = parent.style.width;
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[] {
// 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')) {
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);
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);
return res;
}
const updateItem = { ...item };
for (const currentChange of currentChanges) {
for (const currentChange of nextChanges) {
if (currentChange) {
switch (currentChange.type) {
case 'select': {
@@ -72,7 +90,10 @@ function applyChanges(changes: any[], elements: any[]): any[] {
}
if (typeof currentChange.positionAbsolute !== 'undefined') {
updateItem.positionAbsolute = currentChange.positionAbsolute;
if (!updateItem.computed) {
updateItem.computed = {};
}
updateItem.computed.positionAbsolute = currentChange.positionAbsolute;
}
if (typeof currentChange.dragging !== 'undefined') {
@@ -86,8 +107,11 @@ function applyChanges(changes: any[], elements: any[]): any[] {
}
case 'dimensions': {
if (typeof currentChange.dimensions !== 'undefined') {
updateItem.width = currentChange.dimensions.width;
updateItem.height = currentChange.dimensions.height;
if (!updateItem.computed) {
updateItem.computed = {};
}
updateItem.computed.width = currentChange.dimensions.width;
updateItem.computed.height = currentChange.dimensions.height;
}
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
- 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",
"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.",
"keywords": [
"svelte",
@@ -30,6 +30,7 @@ export default function drag(domNode: Element, params: UseDragParams) {
return {
nodes: get(store.nodes),
nodeLookup: get(store.nodeLookup),
edges: get(store.edges),
nodeExtent: get(store.nodeExtent),
snapGrid: snapGrid ? snapGrid : [0, 0],
@@ -1,11 +1,27 @@
export default function (node: Element, target = 'body') {
const targetEl = document.querySelector(target);
type PortalOptions = {
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) {
targetEl.appendChild(node);
}
}
export default function (node: Element, { target, domNode }: PortalOptions) {
tryToMount(node, domNode, target);
return {
async update({ target, domNode }: PortalOptions) {
tryToMount(node, domNode, target);
},
destroy() {
if (node.parentNode) {
node.parentNode.removeChild(node);
@@ -23,6 +23,7 @@
</script>
<path
{id}
d={path}
class={cc(['svelte-flow__edge-path', className])}
marker-start={markerStart}
@@ -1,9 +1,12 @@
<script lang="ts">
import portal from '$lib/actions/portal';
import { useStore } from '$lib/store';
type $$Props = {};
const { domNode } = useStore();
</script>
<div use:portal={'.svelte-flow__edgelabel-renderer'}>
<div use:portal={{ target: '.svelte-flow__edgelabel-renderer', domNode: $domNode }}>
<slot />
</div>
@@ -1,5 +1,5 @@
<script lang="ts">
import { shortcut } from '@svelte-put/shortcut';
import { shortcut, type ShortcutModifierDefinition } from '@svelte-put/shortcut';
import { useStore } from '$lib/store';
import type { KeyHandlerProps } from './types';
@@ -12,40 +12,66 @@
export let multiSelectionKey: $$Props['multiSelectionKey'] = isMacOs() ? 'Meta' : 'Control';
export let deleteKey: $$Props['deleteKey'] = 'Backspace';
export let panActivationKey: $$Props['panActivationKey'] = ' ';
export let zoomActivationKey: $$Props['zoomActivationKey'] = isMacOs() ? 'Meta' : 'Control';
const {
selectionKeyPressed,
multiselectionKeyPressed,
deleteKeyPressed,
panActivationKeyPressed
panActivationKeyPressed,
zoomActivationKeyPressed
} = useStore();
function isKeyObject(key?: KeyDefinition): key is KeyDefinitionObject {
function isKeyObject(key?: KeyDefinition | null): key is KeyDefinitionObject {
return typeof key === 'object';
}
$: selectionKeyString = typeof selectionKey === 'string' ? selectionKey : selectionKey!.key;
$: selectionKeyModifier = isKeyObject(selectionKey) ? selectionKey?.modifier : [];
function getModifier(key?: KeyDefinition | null): ShortcutModifierDefinition {
return isKeyObject(key) ? key.modifier || [] : [];
}
$: multiSelectionKeyString =
typeof multiSelectionKey === 'string' ? multiSelectionKey : multiSelectionKey!.key;
$: multiSelectionKeyModifier = isKeyObject(multiSelectionKey) ? multiSelectionKey?.modifier : [];
function getKeyString(key?: KeyDefinition | null): string {
if (key === null || key === undefined) {
// 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;
$: deleteKeyModifier = isKeyObject(deleteKey) ? deleteKey?.modifier : [];
return isKeyObject(key) ? key.key : key;
}
$: panActivationKeyString =
typeof panActivationKey === 'string' ? panActivationKey : panActivationKey!.key;
$: panActivationKeyModifier = isKeyObject(panActivationKey) ? panActivationKey?.modifier : [];
$: selectionKeyDefinition = {
key: getKeyString(selectionKey),
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>
<svelte:window
use:shortcut={{
trigger: [
{
key: selectionKeyString,
modifier: selectionKeyModifier,
callback: () => selectionKeyPressed.set(true)
...selectionKeyDefinition,
callback: () => selectionKeyDefinition.key && selectionKeyPressed.set(true)
}
],
type: 'keydown'
@@ -53,9 +79,8 @@
use:shortcut={{
trigger: [
{
key: selectionKeyString,
modifier: selectionKeyModifier,
callback: () => selectionKeyPressed.set(false)
...selectionKeyDefinition,
callback: () => selectionKeyDefinition.key && selectionKeyPressed.set(false)
}
],
type: 'keyup'
@@ -63,9 +88,8 @@
use:shortcut={{
trigger: [
{
key: multiSelectionKeyString,
modifier: multiSelectionKeyModifier,
callback: () => multiselectionKeyPressed.set(true)
...multiSelectionKeyDefinition,
callback: () => multiSelectionKeyDefinition.key && multiselectionKeyPressed.set(true)
}
],
type: 'keydown'
@@ -73,9 +97,8 @@
use:shortcut={{
trigger: [
{
key: multiSelectionKeyString,
modifier: multiSelectionKeyModifier,
callback: () => multiselectionKeyPressed.set(false)
...multiSelectionKeyDefinition,
callback: () => multiSelectionKeyDefinition.key && multiselectionKeyPressed.set(false)
}
],
type: 'keyup'
@@ -83,11 +106,8 @@
use:shortcut={{
trigger: [
{
key: deleteKeyString,
modifier: deleteKeyModifier,
callback: () => {
deleteKeyPressed.set(true);
}
...deleteKeyDefinition,
callback: () => deleteKeyDefinition.key && deleteKeyPressed.set(true)
}
],
type: 'keydown'
@@ -95,9 +115,8 @@
use:shortcut={{
trigger: [
{
key: deleteKeyString,
modifier: deleteKeyModifier,
callback: () => deleteKeyPressed.set(false)
...deleteKeyDefinition,
callback: () => deleteKeyDefinition.key && deleteKeyPressed.set(false)
}
],
type: 'keyup'
@@ -105,9 +124,8 @@
use:shortcut={{
trigger: [
{
key: panActivationKeyString,
modifier: panActivationKeyModifier,
callback: () => panActivationKeyPressed.set(true)
...panActivationKeyDefinition,
callback: () => panActivationKeyDefinition.key && panActivationKeyPressed.set(true)
}
],
type: 'keydown'
@@ -115,9 +133,26 @@
use:shortcut={{
trigger: [
{
key: panActivationKeyString,
modifier: panActivationKeyModifier,
callback: () => panActivationKeyPressed.set(false)
...panActivationKeyDefinition,
callback: () => panActivationKeyDefinition.key && 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'
@@ -1,8 +1,9 @@
import type { KeyDefinition } from '$lib/types';
export type KeyHandlerProps = {
selectionKey?: KeyDefinition;
multiSelectionKey?: KeyDefinition;
deleteKey?: KeyDefinition;
panActivationKey?: KeyDefinition;
selectionKey?: KeyDefinition | null;
multiSelectionKey?: KeyDefinition | null;
deleteKey?: KeyDefinition | null;
panActivationKey?: KeyDefinition | null;
zoomActivationKey?: KeyDefinition | null;
};
@@ -1,15 +1,30 @@
<script lang="ts">
import { getNodesBounds } from '@xyflow/system';
import { createEventDispatcher } from 'svelte';
import { useStore } from '$lib/store';
import { Selection } from '$lib/components/Selection';
import drag from '$lib/actions/drag';
import type { Node } from '$lib/types';
const store = useStore();
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);
$: bounds = getNodesBounds(selectedNodes);
function onContextMenu(event: MouseEvent | TouchEvent) {
dispatch('selectioncontextmenu', { nodes: selectedNodes, event });
}
function onClick(event: MouseEvent | TouchEvent) {
dispatch('selectionclick', { nodes: selectedNodes, event });
}
</script>
{#if selectedNodes && $selectionRectMode === 'nodes'}
@@ -17,6 +32,8 @@
class="selection-wrapper nopan"
style="width: {bounds.width}px; height: {bounds.height}px; transform: translate({bounds.x}px, {bounds.y}px)"
use:drag={{ disabled: false, store }}
on:contextmenu={onContextMenu}
on:click={onClick}
>
<Selection width="100%" height="100%" x={0} y={0} />
</div>
@@ -27,11 +27,15 @@
export let style: NodeWrapperProps['style'] = undefined;
export let type: NodeWrapperProps['type'] = 'default';
export let isParent: NodeWrapperProps['isParent'] = false;
export let positionAbsolute: NodeWrapperProps['positionAbsolute'] = undefined;
export let positionOrigin: NodeWrapperProps['positionOrigin'] = undefined;
export let positionX: NodeWrapperProps['positionX'];
export let positionY: NodeWrapperProps['positionY'];
export let positionOriginX: NodeWrapperProps['positionOriginX'];
export let positionOriginY: NodeWrapperProps['positionOriginY'];
export let sourcePosition: NodeWrapperProps['sourcePosition'] = undefined;
export let targetPosition: NodeWrapperProps['targetPosition'] = undefined;
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 initialized: NodeWrapperProps['initialized'] = false;
let className: string = '';
@@ -87,13 +91,18 @@
if (doUpdate) {
requestAnimationFrame(() =>
updateNodeDimensions([
{
id,
nodeElement: nodeRef,
forceUpdate: true
}
])
updateNodeDimensions(
new Map([
[
id,
{
id,
nodeElement: nodeRef,
forceUpdate: true
}
]
])
)
);
}
@@ -124,8 +133,6 @@
dispatch('nodeclick', { node, event });
}
// @todo: add selectable state
</script>
<!-- svelte-ignore a11y-click-events-have-key-events -->
@@ -161,11 +168,11 @@
class:nopan={draggable}
class:parent={isParent}
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="{style} {node.size?.width ? `;width=${node.size?.width}px` : ''} {node.size?.height
? `;height=${node.size?.height}px;`
: ''}"
style="{style ?? ''}; {!width ? '' : `width:${width}px;`} {!height
? ''
: `height:${height}px;`}"
on:click={onSelectNodeHandler}
on:mouseenter={(event) => dispatch('nodemouseenter', { node, event })}
on:mouseleave={(event) => dispatch('nodemouseleave', { node, event })}
@@ -184,8 +191,9 @@
{dragging}
{dragHandle}
isConnectable={connectable}
xPos={positionAbsolute?.x ?? 0}
yPos={positionAbsolute?.y ?? 0}
positionAbsolute={{ x: positionX, y: positionY }}
{width}
{height}
on:connectstart
on:connect
on:connectend
@@ -1,4 +1,3 @@
import type { XYPosition } from '@xyflow/system';
import type { Node } from '$lib/types';
export type NodeWrapperProps = Pick<
@@ -9,19 +8,22 @@ export type NodeWrapperProps = Pick<
| 'data'
| 'draggable'
| 'dragging'
| 'positionAbsolute'
| 'selected'
| 'selectable'
| 'style'
| 'type'
| 'width'
| 'height'
| 'sourcePosition'
| 'targetPosition'
| 'dragHandle'
| 'hidden'
> & {
positionOrigin?: XYPosition;
width?: number;
height?: number;
type: string;
positionX: number;
positionY: number;
positionOriginX: number;
positionOriginY: number;
'on:nodeclick'?: (event: MouseEvent) => void;
resizeObserver?: ResizeObserver | null;
isParent?: boolean;
@@ -11,24 +11,29 @@
// unused props - we need to list them here in order to prevent warnings
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 type: $$Props['type'] = undefined;
export let zIndex: $$Props['zIndex'] = undefined;
export let dragging: $$Props['dragging'] = false;
export let dragHandle: $$Props['dragHandle'] = undefined;
export let xPos: $$Props['xPos'] = 0;
export let yPos: $$Props['yPos'] = 0;
export let positionAbsolute: $$Props['positionAbsolute'] = {
x: 0,
y: 0
};
export let isConnectable: $$Props['isConnectable'] = undefined;
// @todo: there must be a better way to do this
id;
width;
height;
selected;
type;
zIndex;
dragging;
dragHandle;
xPos;
yPos;
positionAbsolute;
isConnectable;
</script>
@@ -5,6 +5,8 @@
// unused props - we need to list them here in order to prevent warnings
export let id: $$Props['id'] = '';
export let width: $$Props['width'] = undefined;
export let height: $$Props['height'] = undefined;
export let data: $$Props['data'] = {};
export let selected: $$Props['selected'] = undefined;
export let sourcePosition: $$Props['sourcePosition'] = undefined;
@@ -13,12 +15,16 @@
export let zIndex: $$Props['zIndex'] = undefined;
export let dragging: $$Props['dragging'] = false;
export let dragHandle: $$Props['dragHandle'] = undefined;
export let xPos: $$Props['xPos'] = 0;
export let yPos: $$Props['yPos'] = 0;
export let positionAbsolute: $$Props['positionAbsolute'] = {
x: 0,
y: 0
};
export let isConnectable: $$Props['isConnectable'] = undefined;
// @todo: there must be a better way to do this
id;
width;
height;
data;
selected;
sourcePosition;
@@ -27,7 +33,6 @@
zIndex;
dragging;
dragHandle;
xPos;
yPos;
positionAbsolute;
isConnectable;
</script>
@@ -10,26 +10,31 @@
// unused props - we need to list them here in order to prevent warnings
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 targetPosition: $$Props['targetPosition'] = undefined;
export let type: $$Props['type'] = undefined;
export let zIndex: $$Props['zIndex'] = undefined;
export let dragging: $$Props['dragging'] = false;
export let dragHandle: $$Props['dragHandle'] = undefined;
export let xPos: $$Props['xPos'] = 0;
export let yPos: $$Props['yPos'] = 0;
export let positionAbsolute: $$Props['positionAbsolute'] = {
x: 0,
y: 0
};
export let isConnectable: $$Props['isConnectable'] = undefined;
// @todo: there must be a better way to do this
id;
width;
height;
selected;
targetPosition;
type;
zIndex;
dragging;
dragHandle;
xPos;
yPos;
positionAbsolute;
isConnectable;
</script>
@@ -10,26 +10,28 @@
// unused props - we need to list them here in order to prevent warnings
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 sourcePosition: $$Props['sourcePosition'] = undefined;
export let type: $$Props['type'] = undefined;
export let zIndex: $$Props['zIndex'] = undefined;
export let dragging: $$Props['dragging'] = false;
export let dragHandle: $$Props['dragHandle'] = undefined;
export let xPos: $$Props['xPos'] = 0;
export let yPos: $$Props['yPos'] = 0;
export let positionAbsolute: $$Props['positionAbsolute'] = { x: 0, y: 0 };
export let isConnectable: $$Props['isConnectable'] = undefined;
// @todo: there must be a better way to do this
id;
width;
height;
selected;
sourcePosition;
type;
zIndex;
dragging;
dragHandle;
xPos;
yPos;
positionAbsolute;
isConnectable;
</script>
@@ -17,11 +17,18 @@
typeof ResizeObserver === 'undefined'
? null
: new ResizeObserver((entries: ResizeObserverEntry[]) => {
const updates = entries.map((entry: ResizeObserverEntry) => ({
id: entry.target.getAttribute('data-id') as string,
nodeElement: entry.target as HTMLDivElement,
forceUpdate: true
}));
const updates = new Map();
entries.forEach((entry: ResizeObserverEntry) => {
const id = entry.target.getAttribute('data-id') as string;
updates.set(id, {
id,
nodeElement: entry.target as HTMLDivElement,
forceUpdate: true
});
});
updateNodeDimensions(updates);
});
@@ -33,10 +40,10 @@
<div class="svelte-flow__nodes">
{#each $visibleNodes as node (node.id)}
{@const posOrigin = getPositionWithOrigin({
x: node.positionAbsolute?.x ?? 0,
y: node.positionAbsolute?.y ?? 0,
width: (node.size?.width || node.width) ?? 0,
height: (node.size?.height || node.height) ?? 0,
x: node.computed?.positionAbsolute?.x ?? 0,
y: node.computed?.positionAbsolute?.y ?? 0,
width: node.computed?.width ?? node.width ?? 0,
height: node.computed?.height ?? node.height ?? 0,
origin: node.origin
})}
<NodeWrapper
@@ -54,18 +61,23 @@
node.connectable ||
($nodesConnectable && typeof node.connectable === 'undefined')
)}
positionAbsolute={node.positionAbsolute}
positionOrigin={posOrigin}
positionX={node.computed?.positionAbsolute?.x ?? 0}
positionY={node.computed?.positionAbsolute?.y ?? 0}
positionOriginX={posOrigin.x ?? 0}
positionOriginY={posOrigin.y ?? 0}
isParent={!!node[internalsSymbol]?.isParent}
style={node.style}
class={node.class}
type={node.type}
type={node.type ?? 'default'}
sourcePosition={node.sourcePosition}
targetPosition={node.targetPosition}
dragging={node.dragging}
zIndex={node[internalsSymbol]?.z ?? 0}
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}
on:nodeclick
on:nodemouseenter
@@ -15,8 +15,14 @@
import { Attribution } from '$lib/components/Attribution';
import { key, useStore, createStoreContext } from '$lib/store';
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 { useColorModeClass } from '$lib/hooks/useColorModeClass';
type $$Props = SvelteFlowProps;
@@ -34,6 +40,8 @@
export let selectionKey: $$Props['selectionKey'] = undefined;
export let selectionMode: $$Props['selectionMode'] = 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 nodesConnectable: $$Props['nodesConnectable'] = undefined;
export let nodeDragThreshold: $$Props['nodeDragThreshold'] = undefined;
@@ -61,12 +69,14 @@
export let selectionOnDrag: $$Props['selectionOnDrag'] = undefined;
export let autoPanOnConnect: $$Props['autoPanOnConnect'] = 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 proOptions: $$Props['proOptions'] = undefined;
export let defaultEdgeOptions: $$Props['defaultEdgeOptions'] = undefined;
export let width: $$Props['width'] = undefined;
export let height: $$Props['height'] = undefined;
export let colorMode: $$Props['colorMode'] = 'light';
export let defaultMarkerColor = '#b1b1b7';
@@ -137,7 +147,8 @@
isValidConnection,
autoPanOnConnect,
autoPanOnNodeDrag,
onError,
onerror,
ondelete,
connectionMode,
nodeDragThreshold
};
@@ -152,6 +163,8 @@
maxZoom,
translateExtent
});
$: colorModeClass = useColorModeClass(colorMode);
</script>
<div
@@ -159,14 +172,20 @@
bind:clientWidth
bind:clientHeight
{style}
class={cc(['svelte-flow', className])}
class={cc(['svelte-flow', className, $colorModeClass])}
data-testid="svelte-flow__wrapper"
on:dragover
on:drop
{...$$restProps}
role="application"
>
<KeyHandler {selectionKey} {deleteKey} {panActivationKey} />
<KeyHandler
{selectionKey}
{deleteKey}
{panActivationKey}
{multiSelectionKey}
{zoomActivationKey}
/>
<Zoom
{initialViewport}
{onMoveStart}
@@ -204,7 +223,7 @@
on:nodedragstop
on:nodecontextmenu
/>
<NodeSelection />
<NodeSelection on:selectionclick on:selectioncontextmenu />
</ViewportComponent>
<UserSelection />
</Pane>
@@ -14,7 +14,8 @@ import type {
OnError,
ConnectionMode,
PanelPosition,
ProOptions
ProOptions,
ColorMode
} from '@xyflow/system';
import type {
@@ -24,7 +25,8 @@ import type {
KeyDefinition,
EdgeTypes,
DefaultEdgeOptions,
FitViewOptions
FitViewOptions,
OnDelete
} from '$lib/types';
import type { Writable } from 'svelte/store';
@@ -34,9 +36,11 @@ export type SvelteFlowProps = DOMAttributes<HTMLDivElement> & {
edges: Writable<Edge[]>;
nodeTypes?: NodeTypes;
edgeTypes?: EdgeTypes;
selectionKey?: KeyDefinition;
panActivationKey?: KeyDefinition;
deleteKey?: KeyDefinition;
selectionKey?: KeyDefinition | null;
panActivationKey?: KeyDefinition | null;
deleteKey?: KeyDefinition | null;
multiSelectionKey?: KeyDefinition | null;
zoomActivationKey?: KeyDefinition | null;
fitView?: boolean;
fitViewOptions?: FitViewOptions;
nodeOrigin?: NodeOrigin;
@@ -72,6 +76,7 @@ export type SvelteFlowProps = DOMAttributes<HTMLDivElement> & {
defaultEdgeOptions?: DefaultEdgeOptions;
width?: number;
height?: number;
colorMode?: ColorMode;
class?: string;
style?: string;
@@ -82,5 +87,6 @@ export type SvelteFlowProps = DOMAttributes<HTMLDivElement> & {
onMoveStart?: OnMoveStart;
onMove?: OnMove;
onMoveEnd?: OnMoveEnd;
onError?: OnError;
onerror?: OnError;
ondelete?: OnDelete;
};
@@ -1,6 +1,6 @@
import type { SvelteFlowStore } from '$lib/store/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';
// this is helper function for updating the store
@@ -62,7 +62,8 @@ export type UpdatableStoreProps = {
autoPanOnConnect?: UnwrapWritable<SvelteFlowStore['autoPanOnConnect']>;
autoPanOnNodeDrag?: UnwrapWritable<SvelteFlowStore['autoPanOnNodeDrag']>;
connectionMode?: UnwrapWritable<SvelteFlowStore['connectionMode']>;
onError?: UnwrapWritable<SvelteFlowStore['onError']>;
onerror?: UnwrapWritable<SvelteFlowStore['onerror']>;
ondelete?: UnwrapWritable<SvelteFlowStore['ondelete']>;
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,
translateExtent,
lib,
panActivationKeyPressed
panActivationKeyPressed,
zoomActivationKeyPressed
} = useStore();
$: viewPort = initialViewport || { x: 0, y: 0, zoom: 1 };
@@ -55,7 +56,7 @@
panOnDrag: _panOnDrag,
panOnScrollSpeed: 0.5,
panOnScrollMode: panOnScrollMode || PanOnScrollMode.Free,
zoomActivationKeyPressed: false,
zoomActivationKeyPressed: $zoomActivationKeyPressed,
preventScrolling: typeof preventScrolling === 'boolean' ? preventScrolling : true,
noPanClassName: 'nopan',
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) => {
if (!isRect && (n.id === node.id || !n.positionAbsolute)) {
if (!isRect && (n.id === node.id || !n.computed?.positionAbsolute)) {
return false;
}
@@ -1,5 +1,5 @@
import { get } from 'svelte/store';
import type { UpdateNodeInternals, NodeDimensionUpdate } from '@xyflow/system';
import type { UpdateNodeInternals } from '@xyflow/system';
import { useStore } from '$lib/store';
@@ -9,17 +9,17 @@ export function useUpdateNodeInternals(): UpdateNodeInternals {
// @todo: do we want to add this to system?
const updateInternals = (id: string | string[]) => {
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(
`.svelte-flow__node[data-id="${updateId}"]`
) as HTMLDivElement;
if (nodeElement) {
res.push({ id: updateId, nodeElement, forceUpdate: true });
updates.set(updateId, { id: updateId, nodeElement, forceUpdate: true });
}
return res;
}, []);
});
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/Background';
export * from '$lib/plugins/Minimap';
export * from '$lib/plugins/NodeToolbar';
// store
export { useStore } from '$lib/store';
@@ -74,7 +75,9 @@ export {
type Rect,
type Box,
type Transform,
type CoordinateExtent
type CoordinateExtent,
type ColorMode,
type ColorModeClass
} from '@xyflow/system';
// system utils
@@ -20,7 +20,7 @@
export let position: $$Props['position'] = 'bottom-right';
export let ariaLabel: $$Props['ariaLabel'] = 'Mini map';
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 nodeBorderRadius: $$Props['nodeBorderRadius'] = 5;
export let nodeStrokeWidth: $$Props['nodeStrokeWidth'] = 2;
@@ -51,7 +51,7 @@
translateExtent
} = useStore();
const nodeColorFunc = getAttrFunction(nodeColor);
const nodeColorFunc = nodeColor === undefined ? undefined : getAttrFunction(nodeColor);
const nodeStrokeColorFunc = getAttrFunction(nodeStrokeColor);
const nodeClassFunc = getAttrFunction(nodeClass);
const shapeRendering =
@@ -114,15 +114,15 @@
{#if ariaLabel}<title id={labelledBy}>{ariaLabel}</title>{/if}
{#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}
<MinimapNode
x={pos.x}
y={pos.y}
width={node.width}
height={node.height}
width={node.computed?.width ?? node.width ?? 0}
height={node.computed?.height ?? node.height ?? 0}
selected={node.selected}
color={nodeColorFunc(node)}
color={nodeColorFunc?.(node)}
borderRadius={nodeBorderRadius}
strokeColor={nodeStrokeColorFunc(node)}
strokeWidth={nodeStrokeWidth}
@@ -142,27 +142,3 @@
</svg>
{/if}
</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 height: number = 0;
export let borderRadius: number = 5;
export let color: string;
export let color: string | undefined = undefined;
export let shapeRendering: string;
export let strokeColor: string;
export let strokeColor: string | undefined = undefined;
export let strokeWidth: number = 2;
export let selected: boolean = false;
let className: string = '';
@@ -24,8 +24,8 @@
ry={borderRadius}
{width}
{height}
fill={color}
stroke={strokeColor}
stroke-width={strokeWidth}
style={`${color ? `fill: ${color};` : ''}${strokeColor ? `stroke: ${strokeColor};` : ''}${
strokeWidth ? `stroke-width: ${strokeWidth};` : ''
}`}
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