@xyflow/svelte
Version:
Svelte Flow - A highly customizable Svelte library for building node-based editors, workflow systems, diagrams and more.
335 lines (334 loc) • 16.1 kB
JavaScript
/* eslint-disable svelte/prefer-svelte-reactivity */
import { infiniteExtent, SelectionMode, ConnectionMode, devWarn, adoptUserNodes, getViewportForBounds, updateConnectionLookup, initialConnection, mergeAriaLabelConfig, getInternalNodesBounds, createMarkerIds, pointToRendererPoint, fitViewport } from '@xyflow/system';
import DefaultNode from '../components/nodes/DefaultNode.svelte';
import InputNode from '../components/nodes/InputNode.svelte';
import OutputNode from '../components/nodes/OutputNode.svelte';
import GroupNode from '../components/nodes/GroupNode.svelte';
import { BezierEdgeInternal, SmoothStepEdgeInternal, StraightEdgeInternal, StepEdgeInternal } from '../components/edges';
import { MediaQuery } from 'svelte/reactivity';
import { getLayoutedEdges, getVisibleNodes } from './visibleElements';
export const initialNodeTypes = {
input: InputNode,
output: OutputNode,
default: DefaultNode,
group: GroupNode
};
export const initialEdgeTypes = {
straight: StraightEdgeInternal,
smoothstep: SmoothStepEdgeInternal,
default: BezierEdgeInternal,
step: StepEdgeInternal
};
function getInitialViewport(
// This is just used to make sure adoptUserNodes is called before we calculate the viewport
_nodesInitialized, fitView, initialViewport, width, height, nodeLookup) {
if (fitView && !initialViewport && width && height) {
const bounds = getInternalNodesBounds(nodeLookup, {
filter: (node) => !!((node.width || node.initialWidth) && (node.height || node.initialHeight))
});
return getViewportForBounds(bounds, width, height, 0.5, 2, 0.1);
}
else {
return initialViewport ?? { x: 0, y: 0, zoom: 1 };
}
}
export function getInitialStore(signals) {
// We use a class here, because Svelte adds getters & setter for us.
// Inline classes have some performance implications but we just call it once (max twice).
class SvelteFlowStore {
flowId = $derived(signals.props.id ?? '1');
domNode = $state.raw(null);
panZoom = $state.raw(null);
width = $state.raw(signals.width ?? 0);
height = $state.raw(signals.height ?? 0);
zIndexMode = $state.raw(signals.props.zIndexMode ?? 'basic');
nodesInitialized = $derived.by(() => {
const nodesInitialized = adoptUserNodes(signals.nodes, this.nodeLookup, this.parentLookup, {
nodeExtent: this.nodeExtent,
nodeOrigin: this.nodeOrigin,
elevateNodesOnSelect: signals.props.elevateNodesOnSelect ?? true,
checkEquality: true,
zIndexMode: this.zIndexMode
});
if (this.fitViewQueued && nodesInitialized) {
if (this.fitViewOptions?.duration) {
this.resolveFitView();
}
else {
/**
* When no duration is set, viewport is set immediately which prevents an update
* I do not understand why, however we are setting state in a derived which is a no-go
*/
queueMicrotask(() => {
this.resolveFitView();
});
}
}
return nodesInitialized;
});
viewportInitialized = $derived(this.panZoom !== null);
_edges = $derived.by(() => {
updateConnectionLookup(this.connectionLookup, this.edgeLookup, signals.edges);
return signals.edges;
});
get nodes() {
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
this.nodesInitialized;
return signals.nodes;
}
set nodes(nodes) {
signals.nodes = nodes;
}
get edges() {
return this._edges;
}
set edges(edges) {
signals.edges = edges;
}
_prevSelectedNodes = [];
_prevSelectedNodeIds = new Set();
selectedNodes = $derived.by(() => {
const selectedNodesCount = this._prevSelectedNodeIds.size;
const selectedNodeIds = new Set();
const selectedNodes = this.nodes.filter((node) => {
if (node.selected) {
selectedNodeIds.add(node.id);
this._prevSelectedNodeIds.delete(node.id);
}
return node.selected;
});
// Either the number of selected nodes has changed or two nodes changed their selection state
// at the same time. However then the previously selected node will be inside _prevSelectedNodeIds
if (selectedNodesCount !== selectedNodeIds.size || this._prevSelectedNodeIds.size > 0) {
this._prevSelectedNodes = selectedNodes;
}
this._prevSelectedNodeIds = selectedNodeIds;
return this._prevSelectedNodes;
});
_prevSelectedEdges = [];
_prevSelectedEdgeIds = new Set();
selectedEdges = $derived.by(() => {
const selectedEdgesCount = this._prevSelectedEdgeIds.size;
const selectedEdgeIds = new Set();
const selectedEdges = this.edges.filter((edge) => {
if (edge.selected) {
selectedEdgeIds.add(edge.id);
this._prevSelectedEdgeIds.delete(edge.id);
}
return edge.selected;
});
// Either the number of selected edges has changed or two edges changed their selection state
// at the same time. However then the previously selected edge will be inside _prevSelectedEdgeIds
if (selectedEdgesCount !== selectedEdgeIds.size || this._prevSelectedEdgeIds.size > 0) {
this._prevSelectedEdges = selectedEdges;
}
this._prevSelectedEdgeIds = selectedEdgeIds;
return this._prevSelectedEdges;
});
selectionChangeHandlers = new Map();
nodeLookup = new Map();
parentLookup = new Map();
connectionLookup = new Map();
edgeLookup = new Map();
_prevVisibleEdges = new Map();
visible = $derived.by(() => {
const {
// We need to access this._nodes to trigger on changes
// eslint-disable-next-line @typescript-eslint/no-unused-vars
nodes, _edges: edges, _prevVisibleEdges: previousEdges, nodeLookup, connectionMode, onerror, onlyRenderVisibleElements, defaultEdgeOptions, zIndexMode } = this;
let visibleNodes;
let visibleEdges;
const options = {
edges,
defaultEdgeOptions,
previousEdges,
nodeLookup,
connectionMode,
elevateEdgesOnSelect: signals.props.elevateEdgesOnSelect ?? true,
zIndexMode,
onerror
};
if (onlyRenderVisibleElements) {
// We only subscribe to viewport, width, height if onlyRenderVisibleElements is true
const { viewport, width, height } = this;
const transform = [viewport.x, viewport.y, viewport.zoom];
visibleNodes = getVisibleNodes(nodeLookup, transform, width, height);
visibleEdges = getLayoutedEdges({
...options,
onlyRenderVisible: true,
visibleNodes,
transform,
width,
height
});
}
else {
visibleNodes = this.nodeLookup;
visibleEdges = getLayoutedEdges(options);
}
return {
nodes: visibleNodes,
edges: visibleEdges
};
});
nodesDraggable = $derived(signals.props.nodesDraggable ?? true);
nodesConnectable = $derived(signals.props.nodesConnectable ?? true);
elementsSelectable = $derived(signals.props.elementsSelectable ?? true);
nodesFocusable = $derived(signals.props.nodesFocusable ?? true);
edgesFocusable = $derived(signals.props.edgesFocusable ?? true);
disableKeyboardA11y = $derived(signals.props.disableKeyboardA11y ?? false);
minZoom = $derived(signals.props.minZoom ?? 0.5);
maxZoom = $derived(signals.props.maxZoom ?? 2);
nodeOrigin = $derived(signals.props.nodeOrigin ?? [0, 0]);
nodeExtent = $derived(signals.props.nodeExtent ?? infiniteExtent);
translateExtent = $derived(signals.props.translateExtent ?? infiniteExtent);
defaultEdgeOptions = $derived(signals.props.defaultEdgeOptions ?? {});
nodeDragThreshold = $derived(signals.props.nodeDragThreshold ?? 1);
autoPanOnNodeDrag = $derived(signals.props.autoPanOnNodeDrag ?? true);
autoPanOnConnect = $derived(signals.props.autoPanOnConnect ?? true);
autoPanOnNodeFocus = $derived(signals.props.autoPanOnNodeFocus ?? true);
autoPanSpeed = $derived(signals.props.autoPanSpeed ?? 15);
connectionDragThreshold = $derived(signals.props.connectionDragThreshold ?? 1);
fitViewQueued = signals.props.fitView ?? false;
fitViewOptions = signals.props.fitViewOptions;
fitViewResolver = null;
snapGrid = $derived(signals.props.snapGrid ?? null);
dragging = $state.raw(false);
selectionRect = $state.raw(null);
selectionKeyPressed = $state.raw(false);
multiselectionKeyPressed = $state.raw(false);
deleteKeyPressed = $state.raw(false);
panActivationKeyPressed = $state.raw(false);
zoomActivationKeyPressed = $state.raw(false);
selectionRectMode = $state.raw(null);
ariaLiveMessage = $state.raw('');
selectionMode = $derived(signals.props.selectionMode ?? SelectionMode.Partial);
nodeTypes = $derived({ ...initialNodeTypes, ...signals.props.nodeTypes });
edgeTypes = $derived({ ...initialEdgeTypes, ...signals.props.edgeTypes });
noPanClass = $derived(signals.props.noPanClass ?? 'nopan');
noDragClass = $derived(signals.props.noDragClass ?? 'nodrag');
noWheelClass = $derived(signals.props.noWheelClass ?? 'nowheel');
ariaLabelConfig = $derived(mergeAriaLabelConfig(signals.props.ariaLabelConfig));
// _viewport is the internal viewport.
// when binding to viewport, we operate on signals.viewport instead
_viewport = $state.raw(getInitialViewport(this.nodesInitialized, signals.props.fitView, signals.props.initialViewport, this.width, this.height, this.nodeLookup));
get viewport() {
return signals.viewport ?? this._viewport;
}
set viewport(newViewport) {
if (signals.viewport) {
signals.viewport = newViewport;
}
this._viewport = newViewport;
}
// _connection is viewport independent and originating from XYHandle
_connection = $state.raw(initialConnection);
// We derive a viewport dependent connection here
connection = $derived.by(() => {
if (!this._connection.inProgress) {
return this._connection;
}
return {
...this._connection,
to: pointToRendererPoint(this._connection.to, [
this.viewport.x,
this.viewport.y,
this.viewport.zoom
])
};
});
connectionMode = $derived(signals.props.connectionMode ?? ConnectionMode.Strict);
connectionRadius = $derived(signals.props.connectionRadius ?? 20);
isValidConnection = $derived(signals.props.isValidConnection ?? (() => true));
selectNodesOnDrag = $derived(signals.props.selectNodesOnDrag ?? true);
defaultMarkerColor = $derived(signals.props.defaultMarkerColor === undefined ? '#b1b1b7' : signals.props.defaultMarkerColor);
markers = $derived.by(() => {
return createMarkerIds(signals.edges, {
defaultColor: this.defaultMarkerColor,
id: this.flowId,
defaultMarkerStart: this.defaultEdgeOptions.markerStart,
defaultMarkerEnd: this.defaultEdgeOptions.markerEnd
});
});
onlyRenderVisibleElements = $derived(signals.props.onlyRenderVisibleElements ?? false);
onerror = $derived(signals.props.onflowerror ?? devWarn);
ondelete = $derived(signals.props.ondelete);
onbeforedelete = $derived(signals.props.onbeforedelete);
onbeforeconnect = $derived(signals.props.onbeforeconnect);
onconnect = $derived(signals.props.onconnect);
onconnectstart = $derived(signals.props.onconnectstart);
onconnectend = $derived(signals.props.onconnectend);
onbeforereconnect = $derived(signals.props.onbeforereconnect);
onreconnect = $derived(signals.props.onreconnect);
onreconnectstart = $derived(signals.props.onreconnectstart);
onreconnectend = $derived(signals.props.onreconnectend);
clickConnect = $derived(signals.props.clickConnect ?? true);
onclickconnectstart = $derived(signals.props.onclickconnectstart);
onclickconnectend = $derived(signals.props.onclickconnectend);
clickConnectStartHandle = $state.raw(null);
onselectiondrag = $derived(signals.props.onselectiondrag);
onselectiondragstart = $derived(signals.props.onselectiondragstart);
onselectiondragstop = $derived(signals.props.onselectiondragstop);
resolveFitView = async () => {
if (!this.panZoom) {
return;
}
await fitViewport({
nodes: this.nodeLookup,
width: this.width,
height: this.height,
panZoom: this.panZoom,
minZoom: this.minZoom,
maxZoom: this.maxZoom
}, this.fitViewOptions);
this.fitViewResolver?.resolve(true);
/**
* wait for the fitViewport to resolve before deleting the resolver,
* we want to reuse the old resolver if the user calls fitView again in the mean time
*/
this.fitViewQueued = false;
this.fitViewOptions = undefined;
this.fitViewResolver = null;
};
_prefersDark = new MediaQuery('(prefers-color-scheme: dark)', signals.props.colorModeSSR === 'dark');
colorMode = $derived(signals.props.colorMode === 'system'
? this._prefersDark.current
? 'dark'
: 'light'
: (signals.props.colorMode ?? 'light'));
constructor() {
if (process.env.NODE_ENV === 'development') {
warnIfDeeplyReactive(signals.nodes, 'nodes');
warnIfDeeplyReactive(signals.edges, 'edges');
}
}
resetStoreValues() {
this.dragging = false;
this.selectionRect = null;
this.selectionRectMode = null;
this.selectionKeyPressed = false;
this.multiselectionKeyPressed = false;
this.deleteKeyPressed = false;
this.panActivationKeyPressed = false;
this.zoomActivationKeyPressed = false;
this._connection = initialConnection;
this.clickConnectStartHandle = null;
this.viewport = signals.props.initialViewport ?? { x: 0, y: 0, zoom: 1 };
this.ariaLiveMessage = '';
}
}
return new SvelteFlowStore();
}
// Only way to check if an object is a proxy
// is to see if is failes to perform a structured clone
function warnIfDeeplyReactive(array, name) {
try {
if (array && array.length > 0) {
structuredClone(array[0]);
}
}
catch {
console.warn(`Use $state.raw for ${name} to prevent performance issues.`);
}
}
/* eslint-enable svelte/prefer-svelte-reactivity */