451 lines
16 KiB
TypeScript
451 lines
16 KiB
TypeScript
import {
|
|
infiniteExtent,
|
|
SelectionMode,
|
|
ConnectionMode,
|
|
devWarn,
|
|
adoptUserNodes,
|
|
getViewportForBounds,
|
|
updateConnectionLookup,
|
|
initialConnection,
|
|
type SelectionRect,
|
|
type SnapGrid,
|
|
type MarkerProps,
|
|
type PanZoomInstance,
|
|
type CoordinateExtent,
|
|
type NodeOrigin,
|
|
type OnError,
|
|
type Viewport,
|
|
type OnConnect,
|
|
type OnConnectStart,
|
|
type OnConnectEnd,
|
|
type NodeLookup,
|
|
type ConnectionState,
|
|
getInternalNodesBounds,
|
|
createMarkerIds,
|
|
type EdgeLookup,
|
|
type ConnectionLookup,
|
|
type ParentLookup,
|
|
pointToRendererPoint,
|
|
type ColorModeClass,
|
|
type Transform,
|
|
fitViewport,
|
|
type Handle,
|
|
type OnReconnect,
|
|
type OnReconnectStart,
|
|
type OnReconnectEnd
|
|
} from '@xyflow/system';
|
|
|
|
import DefaultNode from '$lib/components/nodes/DefaultNode.svelte';
|
|
import InputNode from '$lib/components/nodes/InputNode.svelte';
|
|
import OutputNode from '$lib/components/nodes/OutputNode.svelte';
|
|
import GroupNode from '$lib/components/nodes/GroupNode.svelte';
|
|
|
|
import {
|
|
BezierEdgeInternal,
|
|
SmoothStepEdgeInternal,
|
|
StraightEdgeInternal,
|
|
StepEdgeInternal
|
|
} from '$lib/components/edges';
|
|
|
|
import type {
|
|
NodeTypes,
|
|
EdgeTypes,
|
|
FitViewOptions,
|
|
OnDelete,
|
|
OnBeforeConnect,
|
|
OnBeforeDelete,
|
|
IsValidConnection,
|
|
Edge,
|
|
Node,
|
|
EdgeLayouted,
|
|
InternalNode,
|
|
OnBeforeReconnect,
|
|
OnSelectionChange,
|
|
OnSelectionDrag
|
|
} from '$lib/types';
|
|
|
|
import type { StoreSignals } from './types';
|
|
import { MediaQuery } from 'svelte/reactivity';
|
|
import { getLayoutedEdges, getVisibleNodes, type EdgeLayoutAllOptions } from './visibleElements';
|
|
|
|
export const initialNodeTypes = {
|
|
input: InputNode,
|
|
output: OutputNode,
|
|
default: DefaultNode,
|
|
group: GroupNode
|
|
};
|
|
|
|
export const initialEdgeTypes = {
|
|
straight: StraightEdgeInternal,
|
|
smoothstep: SmoothStepEdgeInternal,
|
|
default: BezierEdgeInternal,
|
|
step: StepEdgeInternal
|
|
};
|
|
|
|
export function getInitialStore<NodeType extends Node = Node, EdgeType extends Edge = Edge>(
|
|
signals: StoreSignals<NodeType, EdgeType>
|
|
) {
|
|
// 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: string = $derived(signals.props.id ?? '1');
|
|
domNode = $state<HTMLDivElement | null>(null);
|
|
panZoom: PanZoomInstance | null = $state(null);
|
|
width = $state<number>(signals.width ?? 0);
|
|
height = $state<number>(signals.height ?? 0);
|
|
|
|
nodesInitialized: boolean = $derived.by(() => {
|
|
const nodesInitialized = adoptUserNodes(signals.nodes, this.nodeLookup, this.parentLookup, {
|
|
nodeExtent: this.nodeExtent,
|
|
nodeOrigin: this.nodeOrigin,
|
|
elevateNodesOnSelect: signals.props.elevateNodesOnSelect ?? true,
|
|
checkEquality: true
|
|
});
|
|
|
|
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: boolean = $derived(this.panZoom !== null);
|
|
|
|
_edges: EdgeType[] = $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: NodeType[] = [];
|
|
_prevSelectedNodeIds = new Set<string>();
|
|
selectedNodes = $derived.by(() => {
|
|
const selectedNodesCount = this._prevSelectedNodeIds.size;
|
|
const selectedNodeIds = new Set<string>();
|
|
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: EdgeType[] = [];
|
|
_prevSelectedEdgeIds = new Set<string>();
|
|
selectedEdges = $derived.by(() => {
|
|
const selectedEdgesCount = this._prevSelectedEdgeIds.size;
|
|
const selectedEdgeIds = new Set<string>();
|
|
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<symbol, OnSelectionChange<NodeType, EdgeType>>();
|
|
|
|
nodeLookup: NodeLookup<InternalNode<NodeType>> = new Map();
|
|
parentLookup: ParentLookup<InternalNode<NodeType>> = new Map();
|
|
connectionLookup: ConnectionLookup = new Map();
|
|
edgeLookup: EdgeLookup<EdgeType> = new Map();
|
|
|
|
_prevVisibleEdges = new Map<string, EdgeLayouted<EdgeType>>();
|
|
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
|
|
} = this;
|
|
|
|
let visibleNodes: Map<string, InternalNode<NodeType>>;
|
|
let visibleEdges: Map<string, EdgeLayouted<EdgeType>>;
|
|
|
|
const options = {
|
|
edges,
|
|
defaultEdgeOptions,
|
|
previousEdges,
|
|
nodeLookup,
|
|
connectionMode,
|
|
elevateEdgesOnSelect: signals.props.elevateEdgesOnSelect ?? true,
|
|
onerror
|
|
};
|
|
|
|
if (onlyRenderVisibleElements) {
|
|
// We only subscribe to viewport, width, height if onlyRenderVisibleElements is true
|
|
const { viewport, width, height } = this;
|
|
const transform: 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 as EdgeLayoutAllOptions<NodeType, EdgeType>);
|
|
}
|
|
|
|
return {
|
|
nodes: visibleNodes,
|
|
edges: visibleEdges
|
|
};
|
|
});
|
|
|
|
nodesDraggable: boolean = $derived(signals.props.nodesDraggable ?? true);
|
|
nodesConnectable: boolean = $derived(signals.props.nodesConnectable ?? true);
|
|
elementsSelectable: boolean = $derived(signals.props.elementsSelectable ?? true);
|
|
nodesFocusable: boolean = $derived(signals.props.nodesFocusable ?? true);
|
|
edgesFocusable: boolean = $derived(signals.props.edgesFocusable ?? true);
|
|
|
|
disableKeyboardA11y: boolean = $derived(signals.props.disableKeyboardA11y ?? false);
|
|
|
|
minZoom: number = $derived(signals.props.minZoom ?? 0.5);
|
|
maxZoom: number = $derived(signals.props.maxZoom ?? 2);
|
|
|
|
nodeOrigin: NodeOrigin = $derived(signals.props.nodeOrigin ?? [0, 0]);
|
|
nodeExtent: CoordinateExtent = $derived(signals.props.nodeExtent ?? infiniteExtent);
|
|
translateExtent: CoordinateExtent = $derived(signals.props.translateExtent ?? infiniteExtent);
|
|
|
|
defaultEdgeOptions: Partial<Edge> = $derived(signals.props.defaultEdgeOptions ?? {});
|
|
|
|
nodeDragThreshold: number = $derived(signals.props.nodeDragThreshold ?? 1);
|
|
autoPanOnNodeDrag: boolean = $derived(signals.props.autoPanOnNodeDrag ?? true);
|
|
autoPanOnConnect: boolean = $derived(signals.props.autoPanOnConnect ?? true);
|
|
|
|
fitViewQueued: boolean = signals.props.fitView ?? false;
|
|
fitViewOptions: FitViewOptions | undefined = signals.props.fitViewOptions;
|
|
fitViewResolver: PromiseWithResolvers<boolean> | null = null;
|
|
|
|
snapGrid: SnapGrid | null = $derived(signals.props.snapGrid ?? null);
|
|
|
|
dragging: boolean = $state(false);
|
|
selectionRect: SelectionRect | null = $state(null);
|
|
|
|
selectionKeyPressed: boolean = $state(false);
|
|
multiselectionKeyPressed: boolean = $state(false);
|
|
deleteKeyPressed: boolean = $state(false);
|
|
panActivationKeyPressed: boolean = $state(false);
|
|
zoomActivationKeyPressed: boolean = $state(false);
|
|
selectionRectMode: string | null = $state(null);
|
|
ariaLiveMessage = $state<string>('');
|
|
selectionMode: SelectionMode = $derived(signals.props.selectionMode ?? SelectionMode.Partial);
|
|
|
|
nodeTypes: NodeTypes = $derived({ ...initialNodeTypes, ...signals.props.nodeTypes });
|
|
edgeTypes: EdgeTypes = $derived({ ...initialEdgeTypes, ...signals.props.edgeTypes });
|
|
|
|
noPanClass: string = $derived(signals.props.noPanClass ?? 'nopan');
|
|
noDragClass: string = $derived(signals.props.noDragClass ?? 'nodrag');
|
|
noWheelClass: string = $derived(signals.props.noWheelClass ?? 'nowheel');
|
|
|
|
// _viewport is the internal viewport.
|
|
// when binding to viewport, we operate on signals.viewport instead
|
|
_viewport: Viewport = $state(signals.props.initialViewport ?? { x: 0, y: 0, zoom: 1 });
|
|
get viewport() {
|
|
return signals.viewport ?? this._viewport;
|
|
}
|
|
set viewport(newViewport: Viewport) {
|
|
if (signals.viewport) {
|
|
signals.viewport = newViewport;
|
|
}
|
|
this._viewport = newViewport;
|
|
}
|
|
|
|
// _connection is viewport independent and originating from XYHandle
|
|
_connection: ConnectionState = $state(initialConnection);
|
|
// We derive a viewport dependent connection here
|
|
connection: ConnectionState = $derived.by(() => {
|
|
if (this._connection.inProgress) {
|
|
return {
|
|
...this._connection,
|
|
to: pointToRendererPoint(this._connection.to, [
|
|
this.viewport.x,
|
|
this.viewport.y,
|
|
this.viewport.zoom
|
|
])
|
|
};
|
|
} else {
|
|
return this._connection;
|
|
}
|
|
});
|
|
connectionMode: ConnectionMode = $derived(
|
|
signals.props.connectionMode ?? ConnectionMode.Strict
|
|
);
|
|
connectionRadius: number = $derived(signals.props.connectionRadius ?? 20);
|
|
isValidConnection: IsValidConnection = $derived(
|
|
signals.props.isValidConnection ?? (() => true)
|
|
);
|
|
|
|
selectNodesOnDrag: boolean = $derived(signals.props.selectNodesOnDrag ?? true);
|
|
|
|
defaultMarkerColor: string = $derived(signals.props.defaultMarkerColor ?? '#b1b1b7');
|
|
markers: MarkerProps[] = $derived.by(() => {
|
|
return createMarkerIds(signals.edges, {
|
|
defaultColor: this.defaultMarkerColor,
|
|
id: this.flowId,
|
|
defaultMarkerStart: this.defaultEdgeOptions.markerStart,
|
|
defaultMarkerEnd: this.defaultEdgeOptions.markerEnd
|
|
});
|
|
});
|
|
onlyRenderVisibleElements: boolean = $derived(signals.props.onlyRenderVisibleElements ?? false);
|
|
onerror: OnError = $derived(signals.props.onflowerror ?? devWarn);
|
|
|
|
ondelete?: OnDelete<NodeType, EdgeType> = $derived(signals.props.ondelete);
|
|
onbeforedelete?: OnBeforeDelete<NodeType, EdgeType> = $derived(signals.props.onbeforedelete);
|
|
|
|
onbeforeconnect?: OnBeforeConnect = $derived(signals.props.onbeforeconnect);
|
|
onconnect?: OnConnect = $derived(signals.props.onconnect);
|
|
onconnectstart?: OnConnectStart = $derived(signals.props.onconnectstart);
|
|
onconnectend?: OnConnectEnd = $derived(signals.props.onconnectend);
|
|
|
|
onbeforereconnect?: OnBeforeReconnect<EdgeType> = $derived(signals.props.onbeforereconnect);
|
|
onreconnect?: OnReconnect<EdgeType> = $derived(signals.props.onreconnect);
|
|
onreconnectstart?: OnReconnectStart<EdgeType> = $derived(signals.props.onreconnectstart);
|
|
onreconnectend?: OnReconnectEnd<EdgeType> = $derived(signals.props.onreconnectend);
|
|
|
|
clickConnect?: boolean = $derived(signals.props.clickConnect ?? true);
|
|
onclickconnectstart?: OnConnectStart = $derived(signals.props.onclickconnectstart);
|
|
onclickconnectend?: OnConnectEnd = $derived(signals.props.onclickconnectend);
|
|
clickConnectStartHandle: Pick<Handle, 'id' | 'nodeId' | 'type'> | null = $state(null);
|
|
|
|
onselectiondrag?: OnSelectionDrag<NodeType> = $derived(signals.props.onselectiondrag);
|
|
onselectiondragstart?: OnSelectionDrag<NodeType> = $derived(signals.props.onselectiondragstart);
|
|
onselectiondragstop?: OnSelectionDrag<NodeType> = $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: ColorModeClass = $derived(
|
|
signals.props.colorMode === 'system'
|
|
? this._prefersDark.current
|
|
? 'dark'
|
|
: 'light'
|
|
: (signals.props.colorMode ?? 'light')
|
|
);
|
|
|
|
constructor() {
|
|
// Process intial fitView here
|
|
if (signals.props.fitView && !signals.props.initialViewport && this.width && this.height) {
|
|
const bounds = getInternalNodesBounds(this.nodeLookup, {
|
|
filter: (node) =>
|
|
!!((node.width || node.initialWidth) && (node.height || node.initialHeight))
|
|
});
|
|
this.viewport = getViewportForBounds(bounds, this.width, this.height, 0.5, 2, 0.1);
|
|
}
|
|
|
|
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: unknown[] | undefined, name: string) {
|
|
try {
|
|
if (array && array.length > 0) {
|
|
structuredClone(array[0]);
|
|
}
|
|
} catch {
|
|
console.warn(`Use $state.raw for ${name} to prevent performance issues.`);
|
|
}
|
|
}
|