From d4d773d9c62f49706bccd1d5435e75dd912d3643 Mon Sep 17 00:00:00 2001 From: moklick Date: Mon, 11 Dec 2023 18:31:37 +0100 Subject: [PATCH] feat(svelte): add useHandleConnections, useNodesData and useUpdateNodeData --- .../src/examples/UseNodesData/ResultNode.tsx | 5 +- .../examples/UseNodesData/UppercaseNode.tsx | 6 +- .../react/src/examples/UseNodesData/index.tsx | 7 +- .../src/components/Header/Header.svelte | 2 + .../examples/customnode/CustomNode.svelte | 2 +- .../examples/handle-connect/+page.svelte | 94 +++++++++++++++ .../handle-connect/MultiHandleNode.svelte | 110 ++++++++++++++++++ .../handle-connect/SingleHandleNode.svelte | 77 ++++++++++++ .../routes/examples/usenodesdata/+page.svelte | 80 +++++++++++++ .../examples/usenodesdata/ResultNode.svelte | 38 ++++++ .../examples/usenodesdata/TextNode.svelte | 29 +++++ .../usenodesdata/UppercaseNode.svelte | 40 +++++++ .../react/src/components/Handle/index.tsx | 2 +- .../react/src/hooks/useHandleConnections.ts | 54 +-------- packages/react/src/hooks/useNodesData.ts | 23 ++-- packages/react/src/store/index.ts | 3 +- packages/react/src/store/initialState.ts | 7 +- packages/react/src/store/utils.ts | 21 ---- .../src/lib/components/Handle/Handle.svelte | 30 ++++- .../components/KeyHandler/KeyHandler.svelte | 2 +- .../src/lib/hooks/useHandleConnections.ts | 30 +++++ packages/svelte/src/lib/hooks/useNodesData.ts | 64 ++++++++++ .../svelte/src/lib/hooks/useUpdateNodeData.ts | 22 ++++ packages/svelte/src/lib/index.ts | 3 + .../svelte/src/lib/store/initial-store.ts | 10 +- packages/svelte/src/lib/store/utils.ts | 12 +- packages/svelte/src/lib/types/general.ts | 2 + packages/system/src/types/general.ts | 2 + packages/system/src/utils/connections.ts | 53 +++++++++ packages/system/src/utils/index.ts | 1 + packages/system/src/utils/store.ts | 26 ++++- packages/system/src/xyhandle/XYHandle.ts | 12 +- 32 files changed, 761 insertions(+), 108 deletions(-) create mode 100644 examples/svelte/src/routes/examples/handle-connect/+page.svelte create mode 100644 examples/svelte/src/routes/examples/handle-connect/MultiHandleNode.svelte create mode 100644 examples/svelte/src/routes/examples/handle-connect/SingleHandleNode.svelte create mode 100644 examples/svelte/src/routes/examples/usenodesdata/+page.svelte create mode 100644 examples/svelte/src/routes/examples/usenodesdata/ResultNode.svelte create mode 100644 examples/svelte/src/routes/examples/usenodesdata/TextNode.svelte create mode 100644 examples/svelte/src/routes/examples/usenodesdata/UppercaseNode.svelte create mode 100644 packages/svelte/src/lib/hooks/useHandleConnections.ts create mode 100644 packages/svelte/src/lib/hooks/useNodesData.ts create mode 100644 packages/svelte/src/lib/hooks/useUpdateNodeData.ts create mode 100644 packages/system/src/utils/connections.ts diff --git a/examples/react/src/examples/UseNodesData/ResultNode.tsx b/examples/react/src/examples/UseNodesData/ResultNode.tsx index 09603eac..324da1fe 100644 --- a/examples/react/src/examples/UseNodesData/ResultNode.tsx +++ b/examples/react/src/examples/UseNodesData/ResultNode.tsx @@ -5,7 +5,7 @@ function ResultNode() { const connections = useHandleConnections({ handleType: 'target', }); - const nodesData = useNodesData<{ text: string }>(connections.map((connection) => connection.source)); + const nodesData = useNodesData(connections.map((connection) => connection.source)); useEffect(() => { console.log('incoming data changed', nodesData); @@ -16,7 +16,8 @@ function ResultNode() {
incoming texts:{' '} - {nodesData?.filter((nodeData) => nodeData.text).map(({ text }, i) =>
{text}
) || 'none'} + {nodesData?.filter((nodeData) => nodeData.text !== undefined).map(({ text }, i) =>
{text}
) || + 'none'}
); diff --git a/examples/react/src/examples/UseNodesData/UppercaseNode.tsx b/examples/react/src/examples/UseNodesData/UppercaseNode.tsx index 7f0b964f..06cf84a8 100644 --- a/examples/react/src/examples/UseNodesData/UppercaseNode.tsx +++ b/examples/react/src/examples/UseNodesData/UppercaseNode.tsx @@ -5,7 +5,7 @@ function UppercaseNode({ id }: NodeProps) { const connections = useHandleConnections({ handleType: 'target', }); - const nodeData = useNodesData<{ text: string }>(connections[0]?.source); + const nodeData = useNodesData(connections[0]?.source); const updateNodeData = useUpdateNodeData(); useEffect(() => { @@ -14,9 +14,9 @@ function UppercaseNode({ id }: NodeProps) { return (
- -
uppercase transform
+
uppercase transform
+
); } diff --git a/examples/react/src/examples/UseNodesData/index.tsx b/examples/react/src/examples/UseNodesData/index.tsx index 040ca35a..7bb1a94e 100644 --- a/examples/react/src/examples/UseNodesData/index.tsx +++ b/examples/react/src/examples/UseNodesData/index.tsx @@ -15,13 +15,18 @@ import TextNode from './TextNode'; import ResultNode from './ResultNode'; import UppercaseNode from './UppercaseNode'; +export type TextNode = Node<{ text: string }, 'text'>; +export type ResultNode = Node<{}, 'result'>; +export type UppercaseNode = Node<{}, 'uppercase'>; +export type MyNode = Node<{ text: string }, 'text'> | Node<{}, 'result'> | Node<{}, 'uppercase'>; + const nodeTypes = { text: TextNode, result: ResultNode, uppercase: UppercaseNode, }; -const initNodes: Node[] = [ +const initNodes: MyNode[] = [ { id: '1', type: 'text', diff --git a/examples/svelte/src/components/Header/Header.svelte b/examples/svelte/src/components/Header/Header.svelte index ad34d289..9d67f049 100644 --- a/examples/svelte/src/components/Header/Header.svelte +++ b/examples/svelte/src/components/Header/Header.svelte @@ -11,6 +11,7 @@ 'drag-n-drop', 'edges', 'figma', + 'handle-connect', 'interaction', 'intersections', 'node-toolbar', @@ -18,6 +19,7 @@ 'stress', 'subflows', 'two-way-viewport', + 'usenodesdata', 'usesvelteflow', 'useupdatenodeinternals', 'validation' diff --git a/examples/svelte/src/routes/examples/customnode/CustomNode.svelte b/examples/svelte/src/routes/examples/customnode/CustomNode.svelte index 51ba33ea..25d37d32 100644 --- a/examples/svelte/src/routes/examples/customnode/CustomNode.svelte +++ b/examples/svelte/src/routes/examples/customnode/CustomNode.svelte @@ -1,6 +1,6 @@ + + + + + + diff --git a/examples/svelte/src/routes/examples/handle-connect/MultiHandleNode.svelte b/examples/svelte/src/routes/examples/handle-connect/MultiHandleNode.svelte new file mode 100644 index 00000000..ebc85c74 --- /dev/null +++ b/examples/svelte/src/routes/examples/handle-connect/MultiHandleNode.svelte @@ -0,0 +1,110 @@ + + +
+ +
node {id}
+ onConnectSource('a', connections)} + ondisconnect={(connections) => onDisconnectSource('a', connections)} + class="source-a" + /> + onConnectSource('b', connections)} + ondisconnect={(connections) => onDisconnectSource('b', connections)} + class="source-b" + /> +
+ + diff --git a/examples/svelte/src/routes/examples/handle-connect/SingleHandleNode.svelte b/examples/svelte/src/routes/examples/handle-connect/SingleHandleNode.svelte new file mode 100644 index 00000000..7c1e026d --- /dev/null +++ b/examples/svelte/src/routes/examples/handle-connect/SingleHandleNode.svelte @@ -0,0 +1,77 @@ + + +
+ +
node {id}
+ +
+ + diff --git a/examples/svelte/src/routes/examples/usenodesdata/+page.svelte b/examples/svelte/src/routes/examples/usenodesdata/+page.svelte new file mode 100644 index 00000000..7b2d2204 --- /dev/null +++ b/examples/svelte/src/routes/examples/usenodesdata/+page.svelte @@ -0,0 +1,80 @@ + + + + + + + diff --git a/examples/svelte/src/routes/examples/usenodesdata/ResultNode.svelte b/examples/svelte/src/routes/examples/usenodesdata/ResultNode.svelte new file mode 100644 index 00000000..c4150f42 --- /dev/null +++ b/examples/svelte/src/routes/examples/usenodesdata/ResultNode.svelte @@ -0,0 +1,38 @@ + + +
+ +
incoming texts:
+ + {#each $nodeData as data} +
{data.text}
+ {/each} +
+ + diff --git a/examples/svelte/src/routes/examples/usenodesdata/TextNode.svelte b/examples/svelte/src/routes/examples/usenodesdata/TextNode.svelte new file mode 100644 index 00000000..142a14bb --- /dev/null +++ b/examples/svelte/src/routes/examples/usenodesdata/TextNode.svelte @@ -0,0 +1,29 @@ + + +
+
node {id}
+
+ updateNodeData(id, { text: evt.currentTarget.value })} + /> +
+ +
+ + diff --git a/examples/svelte/src/routes/examples/usenodesdata/UppercaseNode.svelte b/examples/svelte/src/routes/examples/usenodesdata/UppercaseNode.svelte new file mode 100644 index 00000000..3e311f1f --- /dev/null +++ b/examples/svelte/src/routes/examples/usenodesdata/UppercaseNode.svelte @@ -0,0 +1,40 @@ + + +
+ +
uppercase transform
+ +
+ + diff --git a/packages/react/src/components/Handle/index.tsx b/packages/react/src/components/Handle/index.tsx index 29231b71..a2c0101f 100644 --- a/packages/react/src/components/Handle/index.tsx +++ b/packages/react/src/components/Handle/index.tsx @@ -171,7 +171,7 @@ const Handle = forwardRef( lib, }); - if (isValid) { + if (isValid && connection) { onConnectExtended(connection); } diff --git a/packages/react/src/hooks/useHandleConnections.ts b/packages/react/src/hooks/useHandleConnections.ts index f692ba37..58987aec 100644 --- a/packages/react/src/hooks/useHandleConnections.ts +++ b/packages/react/src/hooks/useHandleConnections.ts @@ -1,5 +1,5 @@ import { useEffect, useMemo, useRef } from 'react'; -import { Connection, HandleType } from '@xyflow/system'; +import { Connection, HandleType, areConnectionMapsEqual, handleConnectionChange } from '@xyflow/system'; import { useStore } from './useStore'; import { useNodeId } from '../contexts/NodeIdContext'; @@ -52,55 +52,3 @@ export function useHandleConnections({ return useMemo(() => Array.from(connections?.values() ?? []), [connections]); } - -/** - * @internal - */ -function areConnectionMapsEqual(a?: Map, b?: Map) { - if (!a && !b) { - return true; - } - - if (!a || !b || a.size !== b.size) { - return false; - } - - if (!a.size && !b.size) { - return true; - } - - for (const key of a.keys()) { - if (!b.has(key)) { - return false; - } - } - - return true; -} - -/** - * We call the callback for all connections in a that are not in b - * - * @internal - */ -function handleConnectionChange( - a: Map, - b: Map, - cb?: (diff: Connection[]) => void -) { - if (!cb) { - return; - } - - const diff: Connection[] = []; - - a.forEach((connection, key) => { - if (!b?.has(key)) { - diff.push(connection); - } - }); - - if (diff.length) { - cb(diff); - } -} diff --git a/packages/react/src/hooks/useNodesData.ts b/packages/react/src/hooks/useNodesData.ts index 69927642..4c242ad3 100644 --- a/packages/react/src/hooks/useNodesData.ts +++ b/packages/react/src/hooks/useNodesData.ts @@ -2,9 +2,14 @@ import { useCallback } from 'react'; import { shallow } from 'zustand/shallow'; import { useStore } from '../hooks/useStore'; +import type { Node } from '../types'; -export function useNodesData(nodeId: string): NodeData | null; -export function useNodesData(nodeIds: string[]): NodeData[]; +export function useNodesData(nodeId: string): NodeType['data'] | null; +export function useNodesData(nodeIds: string[]): NodeType['data'][]; +export function useNodesData( + nodeIds: string[], + guard: (node: Node) => node is NodeType +): NodeType['data'][]; // eslint-disable-next-line @typescript-eslint/no-explicit-any export function useNodesData(nodeIds: any): any { const nodesData = useStore( @@ -14,15 +19,17 @@ export function useNodesData(nodeIds: any): any { return s.nodeLookup.get(nodeIds)?.data || null; } - return nodeIds.reduce((res, id) => { - const node = s.nodeLookup.get(id); + const data = []; - if (node) { - res.push(node.data); + for (const nodeId of nodeIds) { + const nodeData = s.nodeLookup.get(nodeId)?.data; + + if (nodeData) { + data.push(nodeData); } + } - return res; - }, []); + return data; }, [nodeIds] ), diff --git a/packages/react/src/store/index.ts b/packages/react/src/store/index.ts index 0b308752..c5c21d7a 100644 --- a/packages/react/src/store/index.ts +++ b/packages/react/src/store/index.ts @@ -7,10 +7,11 @@ import { panBy as panBySystem, Dimensions, updateNodeDimensions as updateNodeDimensionsSystem, + updateConnectionLookup, } from '@xyflow/system'; import { applyNodeChanges, createSelectionChange, getSelectionChanges } from '../utils/changes'; -import { updateConnectionLookup, updateNodesAndEdgesSelections } from './utils'; +import { updateNodesAndEdgesSelections } from './utils'; import getInitialState from './initialState'; import type { ReactFlowState, diff --git a/packages/react/src/store/initialState.ts b/packages/react/src/store/initialState.ts index f167a9aa..8d07617a 100644 --- a/packages/react/src/store/initialState.ts +++ b/packages/react/src/store/initialState.ts @@ -5,11 +5,10 @@ import { getNodesBounds, getViewportForBounds, Transform, - Connection, + updateConnectionLookup, } from '@xyflow/system'; import type { Edge, Node, ReactFlowStore } from '../types'; -import { updateConnectionLookup } from './utils'; const getInitialState = ({ nodes = [], @@ -24,8 +23,8 @@ const getInitialState = ({ height?: number; fitView?: boolean; } = {}): ReactFlowStore => { - const nodeLookup = new Map(); - const connectionLookup = updateConnectionLookup(new Map>(), edges); + const nodeLookup = new Map(); + const connectionLookup = updateConnectionLookup(new Map(), edges); const nextNodes = updateNodes(nodes, nodeLookup, { nodeOrigin: [0, 0], elevateNodesOnSelect: false }); let transform: Transform = [0, 0, 1]; diff --git a/packages/react/src/store/utils.ts b/packages/react/src/store/utils.ts index 7ad5ad43..86cddde7 100644 --- a/packages/react/src/store/utils.ts +++ b/packages/react/src/store/utils.ts @@ -1,6 +1,5 @@ import type { StoreApi } from 'zustand'; import type { Edge, EdgeSelectionChange, Node, NodeSelectionChange, ReactFlowState } from '../types'; -import { Connection } from '@xyflow/system'; export function handleControlledSelectionChange( changes: NodeSelectionChange[] | EdgeSelectionChange[], @@ -43,23 +42,3 @@ export function updateNodesAndEdgesSelections({ changedNodes, changedEdges, get, onEdgesChange?.(changedEdges); } } - -export function updateConnectionLookup(lookup: Map>, edges: Edge[]) { - lookup.clear(); - - edges.forEach(({ source, target, sourceHandle = null, targetHandle = null }) => { - if (source && target) { - const sourceKey = `${source}-source-${sourceHandle}`; - const targetKey = `${target}-target-${targetHandle}`; - - const prevSource = lookup.get(sourceKey) || new Map(); - const prevTarget = lookup.get(targetKey) || new Map(); - const connection = { source, target, sourceHandle, targetHandle }; - - lookup.set(sourceKey, prevSource.set(`${target}-${targetHandle}`, connection)); - lookup.set(targetKey, prevTarget.set(`${source}-${sourceHandle}`, connection)); - } - }); - - return lookup; -} diff --git a/packages/svelte/src/lib/components/Handle/Handle.svelte b/packages/svelte/src/lib/components/Handle/Handle.svelte index e4b6a823..06a6198e 100644 --- a/packages/svelte/src/lib/components/Handle/Handle.svelte +++ b/packages/svelte/src/lib/components/Handle/Handle.svelte @@ -6,7 +6,9 @@ XYHandle, isMouseEvent, type Connection, - type HandleType + type HandleType, + areConnectionMapsEqual, + handleConnectionChange } from '@xyflow/system'; import { useStore } from '$lib/store'; @@ -20,6 +22,8 @@ export let position: $$Props['position'] = Position.Top; export let style: $$Props['style'] = undefined; export let isConnectable: $$Props['isConnectable'] = undefined; + export let onconnect: $$Props['onconnect'] = undefined; + export let ondisconnect: $$Props['ondisconnect'] = undefined; // export let isConnectableStart: $$Props['isConnectableStart'] = undefined; // export let isConnectableEnd: $$Props['isConnectableEnd'] = undefined; @@ -59,7 +63,9 @@ panBy, cancelConnection, updateConnection, - autoPanOnConnect + autoPanOnConnect, + edges, + connectionLookup } = store; function onPointerDown(event: MouseEvent | TouchEvent) { @@ -108,6 +114,26 @@ } } + let prevConnections: Map | null = null; + let connections: Map | undefined; + + $: if (onconnect || ondisconnect) { + // connectionLookup is not reactive, so we use edges to get notified about updates + $edges; + connections = $connectionLookup.get(`${nodeId}-${type}-${id || null}`); + } + + $: { + if (prevConnections && !areConnectionMapsEqual(connections, prevConnections)) { + const _connections = connections ?? new Map(); + + handleConnectionChange(prevConnections, _connections, ondisconnect); + handleConnectionChange(_connections, prevConnections, onconnect); + } + + prevConnections = connections ?? new Map(); + } + // @todo implement connectablestart, connectableend diff --git a/packages/svelte/src/lib/components/KeyHandler/KeyHandler.svelte b/packages/svelte/src/lib/components/KeyHandler/KeyHandler.svelte index f059ff8e..f52d696a 100644 --- a/packages/svelte/src/lib/components/KeyHandler/KeyHandler.svelte +++ b/packages/svelte/src/lib/components/KeyHandler/KeyHandler.svelte @@ -107,7 +107,7 @@ trigger: [ { ...deleteKeyDefinition, - callback: () => deleteKeyDefinition.key && deleteKeyPressed.set(true) + callback: (event) => deleteKeyDefinition.key && deleteKeyPressed.set(true) } ], type: 'keydown' diff --git a/packages/svelte/src/lib/hooks/useHandleConnections.ts b/packages/svelte/src/lib/hooks/useHandleConnections.ts new file mode 100644 index 00000000..c8df0dae --- /dev/null +++ b/packages/svelte/src/lib/hooks/useHandleConnections.ts @@ -0,0 +1,30 @@ +import { derived } from 'svelte/store'; +import { areConnectionMapsEqual, type Connection, type HandleType } from '@xyflow/system'; + +import { useStore } from '$lib/store'; + +export type useHandleConnectionsParams = { + nodeId: string; + type: HandleType; + id?: string | null; +}; + +const initialConnections: Connection[] = []; + +export function useHandleConnections({ nodeId, type, id = null }: useHandleConnectionsParams) { + const { edges, connectionLookup } = useStore(); + let prevConnections: Map | undefined = undefined; + + return derived( + [edges, connectionLookup], + ([, connectionLookup], set) => { + const nextConnections = connectionLookup.get(`${nodeId}-${type}-${id || null}`); + + if (!areConnectionMapsEqual(nextConnections, prevConnections)) { + prevConnections = nextConnections; + set(Array.from(prevConnections?.values() || [])); + } + }, + initialConnections + ); +} diff --git a/packages/svelte/src/lib/hooks/useNodesData.ts b/packages/svelte/src/lib/hooks/useNodesData.ts new file mode 100644 index 00000000..f50bc8b8 --- /dev/null +++ b/packages/svelte/src/lib/hooks/useNodesData.ts @@ -0,0 +1,64 @@ +import { derived, type Readable } from 'svelte/store'; + +import type { Node } from '$lib/types'; +import { useStore } from '$lib/store'; + +function areNodesDataEqual(a: Node['data'][] | null, b: Node['data'][] | null) { + if ((!a && !b) || (!a?.length && !b?.length)) { + true; + } + + if (!a || !b || a.length !== b.length) { + return false; + } + + for (let i = 0; i < a.length; i++) { + if (a[i] !== b[i]) { + return false; + } + } + + return true; +} + +export function useNodesData( + nodeId: string +): Readable; +export function useNodesData( + nodeIds: string[] +): Readable; +export function useNodesData( + nodeIds: string[], + guard: (node: Node) => node is NodeType +): Readable; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function useNodesData(nodeIds: any): any { + const { nodes, nodeLookup } = useStore(); + let prevNodesData: (Node['data'] | null)[] | null = null; + + return derived([nodes, nodeLookup], ([, nodeLookup], set) => { + let nextNodesData: (Node['data'] | null)[] | null = null; + const nodeIdArray = Array.isArray(nodeIds); + + if (!nodeIdArray) { + nextNodesData = [nodeLookup.get(nodeIds)?.data || null]; + } else { + const data = []; + + for (const nodeId of nodeIds) { + const nodeData = nodeLookup.get(nodeId)?.data; + + if (nodeData) { + data.push(nodeData); + } + } + + nextNodesData = data; + } + + if (!areNodesDataEqual(nextNodesData, prevNodesData)) { + prevNodesData = nextNodesData; + set(nodeIdArray ? nextNodesData : nextNodesData[0]); + } + }); +} diff --git a/packages/svelte/src/lib/hooks/useUpdateNodeData.ts b/packages/svelte/src/lib/hooks/useUpdateNodeData.ts new file mode 100644 index 00000000..cb45dd10 --- /dev/null +++ b/packages/svelte/src/lib/hooks/useUpdateNodeData.ts @@ -0,0 +1,22 @@ +import { useStore } from '$lib/store'; + +export function useUpdateNodeData(): (id: string, data: unknown) => void { + const { nodes } = useStore(); + + const updateNodeData = (id: string, data: unknown) => { + nodes.update((nds) => + nds.map((node) => { + if (node.id === id) { + return { + ...node, + data + }; + } + + return node; + }) + ); + }; + + return updateNodeData; +} diff --git a/packages/svelte/src/lib/index.ts b/packages/svelte/src/lib/index.ts index a9107415..71841ec7 100644 --- a/packages/svelte/src/lib/index.ts +++ b/packages/svelte/src/lib/index.ts @@ -27,6 +27,9 @@ export * from '$lib/hooks/useSvelteFlow'; export * from '$lib/hooks/useUpdateNodeInternals'; export * from '$lib/hooks/useConnection'; export * from '$lib/hooks/useNodesEdges'; +export * from '$lib/hooks/useHandleConnections'; +export * from '$lib/hooks/useNodesData'; +export * from '$lib/hooks/useUpdateNodeData'; // types export type { diff --git a/packages/svelte/src/lib/store/initial-store.ts b/packages/svelte/src/lib/store/initial-store.ts index 9f51abc3..ceec1c7b 100644 --- a/packages/svelte/src/lib/store/initial-store.ts +++ b/packages/svelte/src/lib/store/initial-store.ts @@ -17,7 +17,9 @@ import { type Viewport, updateNodes, getNodesBounds, - getViewportForBounds + getViewportForBounds, + updateConnectionLookup, + type ConnectionLookup } from '@xyflow/system'; import DefaultNode from '$lib/components/nodes/DefaultNode.svelte'; @@ -72,11 +74,12 @@ export const getInitialStore = ({ height?: number; fitView?: boolean; }) => { - const nodeLookup = new Map(); + const nodeLookup = new Map(); const nextNodes = updateNodes(nodes, nodeLookup, { nodeOrigin: [0, 0], elevateNodesOnSelect: false }); + const connectionLookup = updateConnectionLookup(new Map(), edges); let viewport: Viewport = { x: 0, y: 0, zoom: 1 }; @@ -91,8 +94,9 @@ export const getInitialStore = ({ nodes: createNodesStore(nextNodes, nodeLookup), nodeLookup: readable>(nodeLookup), visibleNodes: readable([]), - edges: createEdgesStore(edges), + edges: createEdgesStore(edges, connectionLookup), edgeTree: readable[]>([]), + connectionLookup: readable(connectionLookup), height: writable(500), width: writable(500), minZoom: writable(0.5), diff --git a/packages/svelte/src/lib/store/utils.ts b/packages/svelte/src/lib/store/utils.ts index a11bf1f6..b9292ca7 100644 --- a/packages/svelte/src/lib/store/utils.ts +++ b/packages/svelte/src/lib/store/utils.ts @@ -6,7 +6,13 @@ import { type Writable, get } from 'svelte/store'; -import { updateNodes, type Viewport, type PanZoomInstance } from '@xyflow/system'; +import { + updateNodes, + type Viewport, + type PanZoomInstance, + type ConnectionLookup, + updateConnectionLookup +} from '@xyflow/system'; import type { DefaultEdgeOptions, DefaultNodeOptions, Edge, Node } from '$lib/types'; @@ -168,6 +174,7 @@ export const createNodesStore = ( export const createEdgesStore = ( edges: Edge[], + connectionLookup: ConnectionLookup, defaultOptions?: DefaultEdgeOptions ): Writable & { setDefaultOptions: (opts: DefaultEdgeOptions) => void } => { const { subscribe, set, update } = writable([]); @@ -176,6 +183,9 @@ export const createEdgesStore = ( const _set: typeof set = (eds: Edge[]) => { const nextEdges = defaults ? eds.map((edge) => ({ ...defaults, ...edge })) : eds; + + updateConnectionLookup(connectionLookup, nextEdges); + value = nextEdges; set(value); }; diff --git a/packages/svelte/src/lib/types/general.ts b/packages/svelte/src/lib/types/general.ts index 62666705..5d33a97c 100644 --- a/packages/svelte/src/lib/types/general.ts +++ b/packages/svelte/src/lib/types/general.ts @@ -31,6 +31,8 @@ export type HandleComponentProps = { isConnectable?: boolean; isConnectableStart?: boolean; isConnectableEnd?: boolean; + onconnect?: (connections: Connection[]) => void; + ondisconnect?: (connections: Connection[]) => void; }; export type FitViewOptions = FitViewOptionsBase; diff --git a/packages/system/src/types/general.ts b/packages/system/src/types/general.ts index e601ee08..1591cca4 100644 --- a/packages/system/src/types/general.ts +++ b/packages/system/src/types/general.ts @@ -139,3 +139,5 @@ export type UpdateConnection = (params: { export type ColorModeClass = 'light' | 'dark'; export type ColorMode = ColorModeClass | 'system'; + +export type ConnectionLookup = Map>; diff --git a/packages/system/src/utils/connections.ts b/packages/system/src/utils/connections.ts new file mode 100644 index 00000000..ded405c3 --- /dev/null +++ b/packages/system/src/utils/connections.ts @@ -0,0 +1,53 @@ +import { Connection } from '../types'; + +/** + * @internal + */ +export function areConnectionMapsEqual(a?: Map, b?: Map) { + if (!a && !b) { + return true; + } + + if (!a || !b || a.size !== b.size) { + return false; + } + + if (!a.size && !b.size) { + return true; + } + + for (const key of a.keys()) { + if (!b.has(key)) { + return false; + } + } + + return true; +} + +/** + * We call the callback for all connections in a that are not in b + * + * @internal + */ +export function handleConnectionChange( + a: Map, + b: Map, + cb?: (diff: Connection[]) => void +) { + if (!cb) { + return; + } + + const diff: Connection[] = []; + + a.forEach((connection, key) => { + if (!b?.has(key)) { + diff.push(connection); + } + }); + + if (diff.length) { + cb(diff); + } +} diff --git a/packages/system/src/utils/index.ts b/packages/system/src/utils/index.ts index 4937033c..cd66e951 100644 --- a/packages/system/src/utils/index.ts +++ b/packages/system/src/utils/index.ts @@ -1,3 +1,4 @@ +export * from './connections'; export * from './dom'; export * from './edges'; export * from './graph'; diff --git a/packages/system/src/utils/store.ts b/packages/system/src/utils/store.ts index eca6b8de..86434a72 100644 --- a/packages/system/src/utils/store.ts +++ b/packages/system/src/utils/store.ts @@ -9,6 +9,8 @@ import { Transform, XYPosition, XYZPosition, + ConnectionLookup, + EdgeBase, } from '../types'; import { getDimensions, getHandleBounds } from './dom'; import { isNumeric } from './general'; @@ -71,11 +73,13 @@ export function updateNodes( defaults: {}, } ): NodeType[] { + const tmpLookup = new Map(nodeLookup); + nodeLookup.clear(); const parentNodes: ParentNodes = {}; const selectedNodeZ: number = options?.elevateNodesOnSelect ? 1000 : 0; const nextNodes = nodes.map((n) => { - const currentStoreNode = nodeLookup.get(n.id); + const currentStoreNode = tmpLookup.get(n.id); const node: NodeType = { ...options.defaults, ...n, @@ -233,3 +237,23 @@ export function panBy({ return transformChanged; } + +export function updateConnectionLookup(lookup: ConnectionLookup, edges: EdgeBase[]) { + lookup.clear(); + + edges.forEach(({ source, target, sourceHandle = null, targetHandle = null }) => { + if (source && target) { + const sourceKey = `${source}-source-${sourceHandle}`; + const targetKey = `${target}-target-${targetHandle}`; + + const prevSource = lookup.get(sourceKey) || new Map(); + const prevTarget = lookup.get(targetKey) || new Map(); + const connection = { source, target, sourceHandle, targetHandle }; + + lookup.set(sourceKey, prevSource.set(`${target}-${targetHandle}`, connection)); + lookup.set(targetKey, prevTarget.set(`${source}-${sourceHandle}`, connection)); + } + }); + + return lookup; +} diff --git a/packages/system/src/xyhandle/XYHandle.ts b/packages/system/src/xyhandle/XYHandle.ts index 6ec86528..826a1c80 100644 --- a/packages/system/src/xyhandle/XYHandle.ts +++ b/packages/system/src/xyhandle/XYHandle.ts @@ -58,12 +58,10 @@ export type XYHandleInstance = { type Result = { handleDomNode: Element | null; isValid: boolean; - connection: Connection; + connection: Connection | null; endHandle: ConnectingHandle | null; }; -const nullConnection: Connection = { source: null, target: null, sourceHandle: null, targetHandle: null }; - const alwaysValid = () => true; let connectionStartHandle: ConnectingHandle | null = null; @@ -197,7 +195,7 @@ function onPointerDown( return resetRecentHandle(prevActiveHandle, lib); } - if (connection.source !== connection.target && handleDomNode) { + if (connection?.source !== connection?.target && handleDomNode) { resetRecentHandle(prevActiveHandle, lib); prevActiveHandle = handleDomNode; handleDomNode.classList.add('connecting', `${lib}-flow__handle-connecting`); @@ -269,7 +267,7 @@ function isValidHandle( const result: Result = { handleDomNode: handleToCheck, isValid: false, - connection: nullConnection, + connection: null, endHandle: null, }; @@ -280,6 +278,10 @@ function isValidHandle( const connectable = handleToCheck.classList.contains('connectable'); const connectableEnd = handleToCheck.classList.contains('connectableend'); + if (!handleNodeId) { + return result; + } + const connection: Connection = { source: isTarget ? handleNodeId : fromNodeId, sourceHandle: isTarget ? handleId : fromHandleId,