UNPKG

@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
/* 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 */