From 124df00f60762518fc0b390af1702e70f74c8494 Mon Sep 17 00:00:00 2001 From: moklick Date: Fri, 1 Dec 2023 12:10:01 +0100 Subject: [PATCH 01/14] feat(react): connection lookup draft --- examples/react/src/App/routes.ts | 6 + .../examples/CustomNode/ColorSelectorNode.tsx | 2 +- .../MultiHandleNode.tsx | 40 ++++++ .../SingleHandleNode.tsx | 42 +++++++ .../UseHandleConnectionStatus/index.tsx | 118 ++++++++++++++++++ .../src/hooks/useHandleConnectionStatus.ts | 85 +++++++++++++ packages/react/src/index.ts | 1 + packages/react/src/store/index.ts | 10 +- packages/react/src/store/initialState.ts | 4 + packages/react/src/store/utils.ts | 24 ++++ packages/react/src/types/store.ts | 2 + 11 files changed, 330 insertions(+), 4 deletions(-) create mode 100644 examples/react/src/examples/UseHandleConnectionStatus/MultiHandleNode.tsx create mode 100644 examples/react/src/examples/UseHandleConnectionStatus/SingleHandleNode.tsx create mode 100644 examples/react/src/examples/UseHandleConnectionStatus/index.tsx create mode 100644 packages/react/src/hooks/useHandleConnectionStatus.ts diff --git a/examples/react/src/App/routes.ts b/examples/react/src/App/routes.ts index 2665abfa..18307c79 100644 --- a/examples/react/src/App/routes.ts +++ b/examples/react/src/App/routes.ts @@ -45,6 +45,7 @@ import InteractiveMinimap from '../examples/InteractiveMinimap'; import UseOnSelectionChange from '../examples/UseOnSelectionChange'; import NodeToolbar from '../examples/NodeToolbar'; import useNodesInitialized from '../examples/UseNodesInit'; +import useHandleConnectionStatus from '../examples/UseHandleConnectionStatus'; export interface IRoute { name: string; @@ -273,6 +274,11 @@ const routes: IRoute[] = [ path: 'usereactflow', component: UseReactFlow, }, + { + name: 'useHandleConnectionStatus', + path: 'usehandleconnectionstatus', + component: useHandleConnectionStatus, + }, { name: 'useUpdateNodeInternals', path: 'useupdatenodeinternals', diff --git a/examples/react/src/examples/CustomNode/ColorSelectorNode.tsx b/examples/react/src/examples/CustomNode/ColorSelectorNode.tsx index aebc8e0e..6857e1ac 100644 --- a/examples/react/src/examples/CustomNode/ColorSelectorNode.tsx +++ b/examples/react/src/examples/CustomNode/ColorSelectorNode.tsx @@ -1,4 +1,4 @@ -import React, { memo, FC, CSSProperties, useCallback } from 'react'; +import React, { memo, FC, CSSProperties, useCallback, useEffect } from 'react'; import { Handle, Position, NodeProps, Connection, Edge, useOnViewportChange, Viewport } from '@xyflow/react'; const targetHandleStyle: CSSProperties = { background: '#555' }; diff --git a/examples/react/src/examples/UseHandleConnectionStatus/MultiHandleNode.tsx b/examples/react/src/examples/UseHandleConnectionStatus/MultiHandleNode.tsx new file mode 100644 index 00000000..f8a31143 --- /dev/null +++ b/examples/react/src/examples/UseHandleConnectionStatus/MultiHandleNode.tsx @@ -0,0 +1,40 @@ +import { memo, FC, useEffect, useCallback } from 'react'; +import { Handle, Position, NodeProps, useHandleConnectionStatus, Connection } from '@xyflow/react'; +import { HandleComponentProps } from '@xyflow/react/dist/esm/components/Handle'; + +function CustomHandle({ nodeId, ...handleProps }: HandleComponentProps & { nodeId: string }) { + const onConnect = useCallback( + (connections: Connection[]) => console.log('onConnect handler, node id:', nodeId, connections), + [nodeId] + ); + + const onDisconnect = useCallback( + (connections: Connection[]) => console.log('onDisconnect handler, node id:', nodeId, connections), + [nodeId] + ); + const status = useHandleConnectionStatus({ + handleType: handleProps.type, + handleId: handleProps.id, + onConnect, + onDisconnect, + }); + + useEffect(() => { + console.log('useEffect, node id:', nodeId, handleProps.type, status); + }, [status]); + + return ; +} + +const CustomNode: FC = ({ id }) => { + return ( +
+ +
node {id}
+ + +
+ ); +}; + +export default memo(CustomNode); diff --git a/examples/react/src/examples/UseHandleConnectionStatus/SingleHandleNode.tsx b/examples/react/src/examples/UseHandleConnectionStatus/SingleHandleNode.tsx new file mode 100644 index 00000000..85d4f9e1 --- /dev/null +++ b/examples/react/src/examples/UseHandleConnectionStatus/SingleHandleNode.tsx @@ -0,0 +1,42 @@ +import { memo, FC, useEffect, useCallback } from 'react'; +import { Handle, Position, NodeProps, useHandleConnectionStatus, Connection } from '@xyflow/react'; +import { HandleComponentProps } from '@xyflow/react/dist/esm/components/Handle'; + +function CustomHandle({ nodeId, ...handleProps }: HandleComponentProps & { nodeId: string }) { + const onConnect = useCallback( + (connections: Connection[]) => { + console.log('onConnect handler, node id:', nodeId, connections); + }, + [nodeId] + ); + const onDisconnect = useCallback( + (connections: Connection[]) => { + console.log('onDisconnect handler, node id:', nodeId, connections); + }, + [nodeId] + ); + const status = useHandleConnectionStatus({ + handleType: handleProps.type, + handleId: handleProps.id, + onConnect, + onDisconnect, + }); + + useEffect(() => { + // console.log('useEffect, node id:', nodeId, handleProps.type, status); + }, [status]); + + return ; +} + +const CustomNode: FC = ({ id }) => { + return ( +
+ +
node {id}
+ +
+ ); +}; + +export default memo(CustomNode); diff --git a/examples/react/src/examples/UseHandleConnectionStatus/index.tsx b/examples/react/src/examples/UseHandleConnectionStatus/index.tsx new file mode 100644 index 00000000..c6aa4a68 --- /dev/null +++ b/examples/react/src/examples/UseHandleConnectionStatus/index.tsx @@ -0,0 +1,118 @@ +import { useCallback } from 'react'; +import { + ReactFlow, + MiniMap, + Controls, + addEdge, + Connection, + useNodesState, + useEdgesState, + Background, +} from '@xyflow/react'; + +import MultiHandleNode from './MultiHandleNode'; +import SingleHandleNode from './SingleHandleNode'; + +const nodeTypes = { + multi: MultiHandleNode, + single: SingleHandleNode, +}; + +const initNodes = [ + { + id: '1', + type: 'single', + data: {}, + position: { x: 0, y: 0 }, + }, + { + id: '2', + type: 'single', + data: {}, + position: { x: 200, y: -100 }, + }, + { + id: '3', + type: 'single', + data: {}, + position: { x: 200, y: 100 }, + }, + + { + id: '4', + type: 'multi', + data: {}, + position: { x: 400, y: 0 }, + }, + { + id: '5', + type: 'multi', + data: {}, + position: { x: 600, y: -100 }, + }, + { + id: '6', + type: 'multi', + data: {}, + position: { x: 600, y: 100 }, + }, +]; + +const initEdges = [ + { + id: 'e1-2', + source: '1', + target: '2', + }, + { + id: 'e1-3', + source: '1', + target: '3', + }, + + { + id: 'e4a-5', + source: '4', + sourceHandle: 'a', + target: '5', + }, + { + id: 'e4b-5', + source: '4', + sourceHandle: 'b', + target: '6', + }, +]; + +const defaultEdgeOptions = { + animated: true, +}; + +const CustomNodeFlow = () => { + const [nodes, setNodes, onNodesChange] = useNodesState(initNodes); + const [edges, setEdges, onEdgesChange] = useEdgesState(initEdges); + + const onConnect = useCallback((connection: Connection) => setEdges((eds) => addEdge(connection, eds)), [setEdges]); + + return ( + + + + + + ); +}; + +export default CustomNodeFlow; diff --git a/packages/react/src/hooks/useHandleConnectionStatus.ts b/packages/react/src/hooks/useHandleConnectionStatus.ts new file mode 100644 index 00000000..062d5d93 --- /dev/null +++ b/packages/react/src/hooks/useHandleConnectionStatus.ts @@ -0,0 +1,85 @@ +import { useEffect, useMemo, useRef } from 'react'; +import { Connection, HandleType } from '@xyflow/system'; + +import { useStore } from './useStore'; +import { useNodeId } from '../contexts/NodeIdContext'; + +type useHandleConnectionStatusParams = { + handleType: HandleType; + nodeId?: string; + handleId?: string | null; + onConnect?: (connections: Connection[]) => void; + onDisconnect?: (connections: Connection[]) => void; +}; + +function connectionsEqual(a: Connection[] | null, b: Connection[] | null) { + if (!a && !b) { + return true; + } + + if (!a || !b) { + return false; + } + + if (a.length !== b.length) { + return false; + } + + return a.every((connA) => + b.find( + (connB) => + connA.source === connB.source && + connA.target === connB.target && + connA.sourceHandle === connB.sourceHandle && + connA.targetHandle === connB.targetHandle + ) + ); +} + +export function useHandleConnectionStatus({ + handleType, + nodeId, + handleId = null, + onConnect, + onDisconnect, +}: useHandleConnectionStatusParams): { + connected: boolean; + connections: Connection[] | null; +} { + const _nodeId = useNodeId(); + const prevConnections = useRef(null); + const currentNodeId = nodeId || _nodeId; + + const connections = useStore( + (state) => state.connectionLookup.get(`${currentNodeId}-${handleType}-${handleId}`) || null, + connectionsEqual + ); + + useEffect(() => { + // we don't want to trigger the handlers for the initial render + if (prevConnections.current && prevConnections.current !== connections) { + if (prevConnections.current?.length > (connections?.length ?? 0)) { + const disconnect = prevConnections.current.filter( + (prevConnection) => !connections?.find((connection) => connection.source === prevConnection.source) + ); + onDisconnect?.(disconnect); + } else if (connections?.length) { + const connect = connections.filter( + (connection) => + !prevConnections.current?.find((prevConnection) => prevConnection.source === connection.source) + ); + onConnect?.(connect); + } + } + + prevConnections.current = connections ?? []; + }, [connections, onConnect, onDisconnect]); + + return useMemo( + () => ({ + connected: !!connections, + connections, + }), + [connections] + ); +} diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 2a693638..d9013b7c 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -22,6 +22,7 @@ export { useStore, useStoreApi } from './hooks/useStore'; export { default as useOnViewportChange, type UseOnViewportChangeOptions } from './hooks/useOnViewportChange'; export { default as useOnSelectionChange, type UseOnSelectionChangeOptions } from './hooks/useOnSelectionChange'; export { default as useNodesInitialized, type UseNodesInitializedOptions } from './hooks/useNodesInitialized'; +export { useHandleConnectionStatus } from './hooks/useHandleConnectionStatus'; export { useNodeId } from './contexts/NodeIdContext'; export { applyNodeChanges, applyEdgeChanges, handleParentExpand } from './utils/changes'; diff --git a/packages/react/src/store/index.ts b/packages/react/src/store/index.ts index 0f50f385..0237491a 100644 --- a/packages/react/src/store/index.ts +++ b/packages/react/src/store/index.ts @@ -10,7 +10,7 @@ import { } from '@xyflow/system'; import { applyNodeChanges, createSelectionChange, getSelectionChanges } from '../utils/changes'; -import { updateNodesAndEdgesSelections } from './utils'; +import { updateConnectionLookup, updateNodesAndEdgesSelections } from './utils'; import getInitialState from './initialState'; import type { ReactFlowState, @@ -49,8 +49,12 @@ const createRFStore = ({ set({ nodes: nextNodes }); }, setEdges: (edges: Edge[]) => { - const { defaultEdgeOptions = {} } = get(); - set({ edges: edges.map((e) => ({ ...defaultEdgeOptions, ...e })) }); + const { defaultEdgeOptions = {}, connectionLookup } = get(); + const nextEdges = edges.map((e) => ({ ...defaultEdgeOptions, ...e })); + + updateConnectionLookup(connectionLookup, nextEdges); + + set({ edges: nextEdges }); }, // when the user works with an uncontrolled flow, // we set a flag `hasDefaultNodes` / `hasDefaultEdges` diff --git a/packages/react/src/store/initialState.ts b/packages/react/src/store/initialState.ts index 48e5434f..1c843fe4 100644 --- a/packages/react/src/store/initialState.ts +++ b/packages/react/src/store/initialState.ts @@ -5,9 +5,11 @@ import { getNodesBounds, getViewportForBounds, Transform, + Connection, } from '@xyflow/system'; import type { Edge, Node, ReactFlowStore } from '../types'; +import { updateConnectionLookup } from './utils'; const getInitialState = ({ nodes = [], @@ -23,6 +25,7 @@ const getInitialState = ({ fitView?: boolean; } = {}): ReactFlowStore => { 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]; @@ -42,6 +45,7 @@ const getInitialState = ({ nodes: nextNodes, nodeLookup, edges: edges, + connectionLookup, onNodesChange: null, onEdgesChange: null, hasDefaultNodes: false, diff --git a/packages/react/src/store/utils.ts b/packages/react/src/store/utils.ts index 86cddde7..ac9e43fa 100644 --- a/packages/react/src/store/utils.ts +++ b/packages/react/src/store/utils.ts @@ -1,5 +1,6 @@ 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[], @@ -42,3 +43,26 @@ export function updateNodesAndEdgesSelections({ changedNodes, changedEdges, get, onEdgesChange?.(changedEdges); } } + +export function updateConnectionLookup(lookup: Map, edges: Edge[]) { + lookup.clear(); + + edges.forEach((edge) => { + const { source, target, sourceHandle = null, targetHandle = null } = edge; + + if (source && target) { + const sourceKey = `${source}-source-${sourceHandle}`; + const targetKey = `${target}-target-${targetHandle}`; + + const prevSource = lookup.get(sourceKey); + const prevTarget = lookup.get(targetKey); + + const connection = { source, target, sourceHandle, targetHandle }; + + lookup.set(sourceKey, prevSource ? [...prevSource, connection] : [connection]); + lookup.set(targetKey, prevTarget ? [...prevTarget, connection] : [connection]); + } + }); + + return lookup; +} diff --git a/packages/react/src/types/store.ts b/packages/react/src/types/store.ts index c473cbd5..3ee1175d 100644 --- a/packages/react/src/types/store.ts +++ b/packages/react/src/types/store.ts @@ -24,6 +24,7 @@ import { type OnMoveEnd, type IsValidConnection, type UpdateConnection, + Connection, } from '@xyflow/system'; import type { @@ -49,6 +50,7 @@ export type ReactFlowStore = { nodes: Node[]; nodeLookup: Map; edges: Edge[]; + connectionLookup: Map; onNodesChange: OnNodesChange | null; onEdgesChange: OnEdgesChange | null; hasDefaultNodes: boolean; From b6743a3cf3112fa44d6e6ecf225b6ab93b3c4e4e Mon Sep 17 00:00:00 2001 From: moklick Date: Mon, 4 Dec 2023 14:23:42 +0100 Subject: [PATCH 02/14] refactor(react/handle-connection-state): cleanup --- .../src/hooks/useHandleConnectionStatus.ts | 54 ++++++------------- packages/react/src/store/index.ts | 1 + packages/react/src/store/utils.ts | 4 +- packages/react/src/types/store.ts | 1 + packages/react/src/utils/general.ts | 30 +++++++++++ 5 files changed, 50 insertions(+), 40 deletions(-) diff --git a/packages/react/src/hooks/useHandleConnectionStatus.ts b/packages/react/src/hooks/useHandleConnectionStatus.ts index 062d5d93..42980ee7 100644 --- a/packages/react/src/hooks/useHandleConnectionStatus.ts +++ b/packages/react/src/hooks/useHandleConnectionStatus.ts @@ -3,6 +3,7 @@ import { Connection, HandleType } from '@xyflow/system'; import { useStore } from './useStore'; import { useNodeId } from '../contexts/NodeIdContext'; +import { areConnectionsEqual, isSameConnection } from '../utils/general'; type useHandleConnectionStatusParams = { handleType: HandleType; @@ -12,30 +13,6 @@ type useHandleConnectionStatusParams = { onDisconnect?: (connections: Connection[]) => void; }; -function connectionsEqual(a: Connection[] | null, b: Connection[] | null) { - if (!a && !b) { - return true; - } - - if (!a || !b) { - return false; - } - - if (a.length !== b.length) { - return false; - } - - return a.every((connA) => - b.find( - (connB) => - connA.source === connB.source && - connA.target === connB.target && - connA.sourceHandle === connB.sourceHandle && - connA.targetHandle === connB.targetHandle - ) - ); -} - export function useHandleConnectionStatus({ handleType, nodeId, @@ -52,23 +29,26 @@ export function useHandleConnectionStatus({ const connections = useStore( (state) => state.connectionLookup.get(`${currentNodeId}-${handleType}-${handleId}`) || null, - connectionsEqual + areConnectionsEqual ); useEffect(() => { - // we don't want to trigger the handlers for the initial render + // @todo dicuss if onConnect/onDisconnect should be called when the component mounts/unmounts if (prevConnections.current && prevConnections.current !== connections) { - if (prevConnections.current?.length > (connections?.length ?? 0)) { - const disconnect = prevConnections.current.filter( - (prevConnection) => !connections?.find((connection) => connection.source === prevConnection.source) - ); - onDisconnect?.(disconnect); - } else if (connections?.length) { - const connect = connections.filter( - (connection) => - !prevConnections.current?.find((prevConnection) => prevConnection.source === connection.source) - ); - onConnect?.(connect); + const disconnectedConnections = prevConnections.current.filter( + (prevConnection) => !connections?.find((connection) => isSameConnection(connection, prevConnection)) + ); + + const newConnections = connections?.filter( + (connection) => !prevConnections.current?.find((prevConnection) => isSameConnection(prevConnection, connection)) + ); + + if (disconnectedConnections.length) { + onDisconnect?.(disconnectedConnections); + } + + if (newConnections?.length) { + onConnect?.(newConnections); } } diff --git a/packages/react/src/store/index.ts b/packages/react/src/store/index.ts index 0237491a..0b308752 100644 --- a/packages/react/src/store/index.ts +++ b/packages/react/src/store/index.ts @@ -330,6 +330,7 @@ const createRFStore = ({ set(currentConnection); }, + reset: () => { // @todo: what should we do about this? Do we still need it? // if you are on a SPA with multiple flows, we want to make sure that the store gets resetted diff --git a/packages/react/src/store/utils.ts b/packages/react/src/store/utils.ts index ac9e43fa..2df777fd 100644 --- a/packages/react/src/store/utils.ts +++ b/packages/react/src/store/utils.ts @@ -47,9 +47,7 @@ export function updateNodesAndEdgesSelections({ changedNodes, changedEdges, get, export function updateConnectionLookup(lookup: Map, edges: Edge[]) { lookup.clear(); - edges.forEach((edge) => { - const { source, target, sourceHandle = null, targetHandle = null } = edge; - + edges.forEach(({ source, target, sourceHandle = null, targetHandle = null }) => { if (source && target) { const sourceKey = `${source}-source-${sourceHandle}`; const targetKey = `${target}-target-${targetHandle}`; diff --git a/packages/react/src/types/store.ts b/packages/react/src/types/store.ts index 3ee1175d..d14a1640 100644 --- a/packages/react/src/types/store.ts +++ b/packages/react/src/types/store.ts @@ -51,6 +51,7 @@ export type ReactFlowStore = { nodeLookup: Map; edges: Edge[]; connectionLookup: Map; + onNodesChange: OnNodesChange | null; onEdgesChange: OnEdgesChange | null; hasDefaultNodes: boolean; diff --git a/packages/react/src/utils/general.ts b/packages/react/src/utils/general.ts index e13247c1..018cd358 100644 --- a/packages/react/src/utils/general.ts +++ b/packages/react/src/utils/general.ts @@ -6,6 +6,7 @@ import { getIncomersBase, updateEdgeBase, getConnectedEdgesBase, + Connection, } from '@xyflow/system'; import type { Edge, Node } from '../types'; @@ -17,3 +18,32 @@ export const getIncomers = getIncomersBase; export const addEdge = addEdgeBase; export const updateEdge = updateEdgeBase; export const getConnectedEdges = getConnectedEdgesBase; + +export function isSameConnection(a: Connection, b: Connection) { + return ( + a.source === b.source && + a.target === b.target && + a.sourceHandle === b.sourceHandle && + a.targetHandle === b.targetHandle + ); +} + +export function areConnectionsEqual(a: Connection[] | null | undefined, b: Connection[] | null | undefined) { + if (!a && !b) { + return true; + } + + if (!a || !b) { + return false; + } + + if (a.length !== b.length) { + return false; + } + + if (!a.length && !b.length) { + return true; + } + + return !a.some((connA) => !b.find((connB) => isSameConnection(connA, connB))); +} From a58f9156971cfe1105eb69be86c7d16e83a28f86 Mon Sep 17 00:00:00 2001 From: moklick Date: Wed, 6 Dec 2023 17:49:34 +0100 Subject: [PATCH 03/14] refactor(connectionlookup): use maps instead of arrays --- .../SingleHandleNode.tsx | 2 +- .../src/hooks/useHandleConnectionStatus.ts | 90 ++++++++++++++----- packages/react/src/store/initialState.ts | 2 +- packages/react/src/store/utils.ts | 11 ++- packages/react/src/types/store.ts | 2 +- packages/react/src/utils/general.ts | 30 ------- 6 files changed, 76 insertions(+), 61 deletions(-) diff --git a/examples/react/src/examples/UseHandleConnectionStatus/SingleHandleNode.tsx b/examples/react/src/examples/UseHandleConnectionStatus/SingleHandleNode.tsx index 85d4f9e1..ebbe584c 100644 --- a/examples/react/src/examples/UseHandleConnectionStatus/SingleHandleNode.tsx +++ b/examples/react/src/examples/UseHandleConnectionStatus/SingleHandleNode.tsx @@ -23,7 +23,7 @@ function CustomHandle({ nodeId, ...handleProps }: HandleComponentProps & { nodeI }); useEffect(() => { - // console.log('useEffect, node id:', nodeId, handleProps.type, status); + console.log('useEffect, node id:', nodeId, handleProps.type, status); }, [status]); return ; diff --git a/packages/react/src/hooks/useHandleConnectionStatus.ts b/packages/react/src/hooks/useHandleConnectionStatus.ts index 42980ee7..470cc077 100644 --- a/packages/react/src/hooks/useHandleConnectionStatus.ts +++ b/packages/react/src/hooks/useHandleConnectionStatus.ts @@ -3,7 +3,6 @@ import { Connection, HandleType } from '@xyflow/system'; import { useStore } from './useStore'; import { useNodeId } from '../contexts/NodeIdContext'; -import { areConnectionsEqual, isSameConnection } from '../utils/general'; type useHandleConnectionStatusParams = { handleType: HandleType; @@ -13,10 +12,69 @@ type useHandleConnectionStatusParams = { onDisconnect?: (connections: Connection[]) => void; }; +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); + } +} + +/** + * Hook to check if a is connected to another and get the connections. + * + * @public + * @param param.handleType - 'source' or 'target' + * @param param.handleId - the handle id (this is only needed if the node has multiple handles of the same type) + * @param param.nodeId - node id - if not provided, the node id from the NodeIdContext is used + * @param param.onConnect - gets called when a connection is established + * @param param.onDisconnect - gets called when a connection is removed + * @returns a `connected` boolean and a connections array + */ export function useHandleConnectionStatus({ handleType, - nodeId, handleId = null, + nodeId, onConnect, onDisconnect, }: useHandleConnectionStatusParams): { @@ -24,41 +82,29 @@ export function useHandleConnectionStatus({ connections: Connection[] | null; } { const _nodeId = useNodeId(); - const prevConnections = useRef(null); + const prevConnections = useRef | null>(null); const currentNodeId = nodeId || _nodeId; const connections = useStore( - (state) => state.connectionLookup.get(`${currentNodeId}-${handleType}-${handleId}`) || null, - areConnectionsEqual + (state) => state.connectionLookup.get(`${currentNodeId}-${handleType}-${handleId}`), + areConnectionMapsEqual ); useEffect(() => { // @todo dicuss if onConnect/onDisconnect should be called when the component mounts/unmounts if (prevConnections.current && prevConnections.current !== connections) { - const disconnectedConnections = prevConnections.current.filter( - (prevConnection) => !connections?.find((connection) => isSameConnection(connection, prevConnection)) - ); - - const newConnections = connections?.filter( - (connection) => !prevConnections.current?.find((prevConnection) => isSameConnection(prevConnection, connection)) - ); - - if (disconnectedConnections.length) { - onDisconnect?.(disconnectedConnections); - } - - if (newConnections?.length) { - onConnect?.(newConnections); - } + const _connections = connections ?? new Map(); + handleConnectionChange(prevConnections.current, _connections, onDisconnect); + handleConnectionChange(_connections, prevConnections.current, onConnect); } - prevConnections.current = connections ?? []; + prevConnections.current = connections ?? new Map(); }, [connections, onConnect, onDisconnect]); return useMemo( () => ({ connected: !!connections, - connections, + connections: Array.from(connections?.values() ?? []), }), [connections] ); diff --git a/packages/react/src/store/initialState.ts b/packages/react/src/store/initialState.ts index 1c843fe4..f167a9aa 100644 --- a/packages/react/src/store/initialState.ts +++ b/packages/react/src/store/initialState.ts @@ -25,7 +25,7 @@ const getInitialState = ({ fitView?: boolean; } = {}): ReactFlowStore => { const nodeLookup = new Map(); - const connectionLookup = updateConnectionLookup(new Map(), edges); + 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 2df777fd..7ad5ad43 100644 --- a/packages/react/src/store/utils.ts +++ b/packages/react/src/store/utils.ts @@ -44,7 +44,7 @@ export function updateNodesAndEdgesSelections({ changedNodes, changedEdges, get, } } -export function updateConnectionLookup(lookup: Map, edges: Edge[]) { +export function updateConnectionLookup(lookup: Map>, edges: Edge[]) { lookup.clear(); edges.forEach(({ source, target, sourceHandle = null, targetHandle = null }) => { @@ -52,13 +52,12 @@ export function updateConnectionLookup(lookup: Map, edges: const sourceKey = `${source}-source-${sourceHandle}`; const targetKey = `${target}-target-${targetHandle}`; - const prevSource = lookup.get(sourceKey); - const prevTarget = lookup.get(targetKey); - + const prevSource = lookup.get(sourceKey) || new Map(); + const prevTarget = lookup.get(targetKey) || new Map(); const connection = { source, target, sourceHandle, targetHandle }; - lookup.set(sourceKey, prevSource ? [...prevSource, connection] : [connection]); - lookup.set(targetKey, prevTarget ? [...prevTarget, connection] : [connection]); + lookup.set(sourceKey, prevSource.set(`${target}-${targetHandle}`, connection)); + lookup.set(targetKey, prevTarget.set(`${source}-${sourceHandle}`, connection)); } }); diff --git a/packages/react/src/types/store.ts b/packages/react/src/types/store.ts index d14a1640..5aebb99d 100644 --- a/packages/react/src/types/store.ts +++ b/packages/react/src/types/store.ts @@ -50,7 +50,7 @@ export type ReactFlowStore = { nodes: Node[]; nodeLookup: Map; edges: Edge[]; - connectionLookup: Map; + connectionLookup: Map>; onNodesChange: OnNodesChange | null; onEdgesChange: OnEdgesChange | null; diff --git a/packages/react/src/utils/general.ts b/packages/react/src/utils/general.ts index 018cd358..e13247c1 100644 --- a/packages/react/src/utils/general.ts +++ b/packages/react/src/utils/general.ts @@ -6,7 +6,6 @@ import { getIncomersBase, updateEdgeBase, getConnectedEdgesBase, - Connection, } from '@xyflow/system'; import type { Edge, Node } from '../types'; @@ -18,32 +17,3 @@ export const getIncomers = getIncomersBase; export const addEdge = addEdgeBase; export const updateEdge = updateEdgeBase; export const getConnectedEdges = getConnectedEdgesBase; - -export function isSameConnection(a: Connection, b: Connection) { - return ( - a.source === b.source && - a.target === b.target && - a.sourceHandle === b.sourceHandle && - a.targetHandle === b.targetHandle - ); -} - -export function areConnectionsEqual(a: Connection[] | null | undefined, b: Connection[] | null | undefined) { - if (!a && !b) { - return true; - } - - if (!a || !b) { - return false; - } - - if (a.length !== b.length) { - return false; - } - - if (!a.length && !b.length) { - return true; - } - - return !a.some((connA) => !b.find((connB) => isSameConnection(connA, connB))); -} From 959935dfb52811f7f65af4331a6ddbfcf7d6c8f7 Mon Sep 17 00:00:00 2001 From: moklick Date: Thu, 7 Dec 2023 13:23:25 +0100 Subject: [PATCH 04/14] feat(hooks): add useNodesData, useUpdateNodeData, simplify useHandleConnections --- examples/react/src/App/routes.ts | 18 ++-- .../MultiHandleNode.tsx | 9 +- .../SingleHandleNode.tsx | 9 +- .../index.tsx | 0 .../src/examples/UseNodesData/ResultNode.tsx | 25 +++++ .../src/examples/UseNodesData/TextNode.tsx | 20 ++++ .../react/src/examples/UseNodesData/index.tsx | 83 ++++++++++++++++ ...ctionStatus.ts => useHandleConnections.ts} | 97 +++++++++---------- packages/react/src/hooks/useNodesData.ts | 33 +++++++ packages/react/src/hooks/useUpdateNodeData.ts | 13 +++ packages/react/src/index.ts | 6 +- packages/system/src/types/general.ts | 4 +- 12 files changed, 246 insertions(+), 71 deletions(-) rename examples/react/src/examples/{UseHandleConnectionStatus => UseHandleConnections}/MultiHandleNode.tsx (82%) rename examples/react/src/examples/{UseHandleConnectionStatus => UseHandleConnections}/SingleHandleNode.tsx (81%) rename examples/react/src/examples/{UseHandleConnectionStatus => UseHandleConnections}/index.tsx (100%) create mode 100644 examples/react/src/examples/UseNodesData/ResultNode.tsx create mode 100644 examples/react/src/examples/UseNodesData/TextNode.tsx create mode 100644 examples/react/src/examples/UseNodesData/index.tsx rename packages/react/src/hooks/{useHandleConnectionStatus.ts => useHandleConnections.ts} (86%) create mode 100644 packages/react/src/hooks/useNodesData.ts create mode 100644 packages/react/src/hooks/useUpdateNodeData.ts diff --git a/examples/react/src/App/routes.ts b/examples/react/src/App/routes.ts index 18307c79..4bab00f5 100644 --- a/examples/react/src/App/routes.ts +++ b/examples/react/src/App/routes.ts @@ -44,8 +44,9 @@ import CancelConnection from '../examples/CancelConnection'; import InteractiveMinimap from '../examples/InteractiveMinimap'; import UseOnSelectionChange from '../examples/UseOnSelectionChange'; import NodeToolbar from '../examples/NodeToolbar'; -import useNodesInitialized from '../examples/UseNodesInit'; -import useHandleConnectionStatus from '../examples/UseHandleConnectionStatus'; +import UseNodesInitialized from '../examples/UseNodesInit'; +import UseNodesData from '../examples/UseNodesData'; +import UseHandleConnections from '../examples/UseHandleConnections'; export interface IRoute { name: string; @@ -262,7 +263,7 @@ const routes: IRoute[] = [ { name: 'useNodesInitialized', path: 'use-nodes-initialized', - component: useNodesInitialized, + component: UseNodesInitialized, }, { name: 'useOnSelectionChange', @@ -275,9 +276,14 @@ const routes: IRoute[] = [ component: UseReactFlow, }, { - name: 'useHandleConnectionStatus', - path: 'usehandleconnectionstatus', - component: useHandleConnectionStatus, + name: 'useHandleConnections', + path: 'usehandleconnections', + component: UseHandleConnections, + }, + { + name: 'useNodesData', + path: 'usenodesdata', + component: UseNodesData, }, { name: 'useUpdateNodeInternals', diff --git a/examples/react/src/examples/UseHandleConnectionStatus/MultiHandleNode.tsx b/examples/react/src/examples/UseHandleConnections/MultiHandleNode.tsx similarity index 82% rename from examples/react/src/examples/UseHandleConnectionStatus/MultiHandleNode.tsx rename to examples/react/src/examples/UseHandleConnections/MultiHandleNode.tsx index f8a31143..e04f3448 100644 --- a/examples/react/src/examples/UseHandleConnectionStatus/MultiHandleNode.tsx +++ b/examples/react/src/examples/UseHandleConnections/MultiHandleNode.tsx @@ -1,6 +1,5 @@ import { memo, FC, useEffect, useCallback } from 'react'; -import { Handle, Position, NodeProps, useHandleConnectionStatus, Connection } from '@xyflow/react'; -import { HandleComponentProps } from '@xyflow/react/dist/esm/components/Handle'; +import { Handle, Position, NodeProps, useHandleConnections, Connection, HandleComponentProps } from '@xyflow/react'; function CustomHandle({ nodeId, ...handleProps }: HandleComponentProps & { nodeId: string }) { const onConnect = useCallback( @@ -12,7 +11,7 @@ function CustomHandle({ nodeId, ...handleProps }: HandleComponentProps & { nodeI (connections: Connection[]) => console.log('onDisconnect handler, node id:', nodeId, connections), [nodeId] ); - const status = useHandleConnectionStatus({ + const connections = useHandleConnections({ handleType: handleProps.type, handleId: handleProps.id, onConnect, @@ -20,8 +19,8 @@ function CustomHandle({ nodeId, ...handleProps }: HandleComponentProps & { nodeI }); useEffect(() => { - console.log('useEffect, node id:', nodeId, handleProps.type, status); - }, [status]); + console.log('useEffect, node id:', nodeId, handleProps.type, connections); + }, [connections]); return ; } diff --git a/examples/react/src/examples/UseHandleConnectionStatus/SingleHandleNode.tsx b/examples/react/src/examples/UseHandleConnections/SingleHandleNode.tsx similarity index 81% rename from examples/react/src/examples/UseHandleConnectionStatus/SingleHandleNode.tsx rename to examples/react/src/examples/UseHandleConnections/SingleHandleNode.tsx index ebbe584c..444499fb 100644 --- a/examples/react/src/examples/UseHandleConnectionStatus/SingleHandleNode.tsx +++ b/examples/react/src/examples/UseHandleConnections/SingleHandleNode.tsx @@ -1,6 +1,5 @@ import { memo, FC, useEffect, useCallback } from 'react'; -import { Handle, Position, NodeProps, useHandleConnectionStatus, Connection } from '@xyflow/react'; -import { HandleComponentProps } from '@xyflow/react/dist/esm/components/Handle'; +import { Handle, Position, NodeProps, useHandleConnections, Connection, HandleComponentProps } from '@xyflow/react'; function CustomHandle({ nodeId, ...handleProps }: HandleComponentProps & { nodeId: string }) { const onConnect = useCallback( @@ -15,7 +14,7 @@ function CustomHandle({ nodeId, ...handleProps }: HandleComponentProps & { nodeI }, [nodeId] ); - const status = useHandleConnectionStatus({ + const connections = useHandleConnections({ handleType: handleProps.type, handleId: handleProps.id, onConnect, @@ -23,8 +22,8 @@ function CustomHandle({ nodeId, ...handleProps }: HandleComponentProps & { nodeI }); useEffect(() => { - console.log('useEffect, node id:', nodeId, handleProps.type, status); - }, [status]); + console.log('useEffect, node id:', nodeId, handleProps.type, connections); + }, [connections]); return ; } diff --git a/examples/react/src/examples/UseHandleConnectionStatus/index.tsx b/examples/react/src/examples/UseHandleConnections/index.tsx similarity index 100% rename from examples/react/src/examples/UseHandleConnectionStatus/index.tsx rename to examples/react/src/examples/UseHandleConnections/index.tsx diff --git a/examples/react/src/examples/UseNodesData/ResultNode.tsx b/examples/react/src/examples/UseNodesData/ResultNode.tsx new file mode 100644 index 00000000..09603eac --- /dev/null +++ b/examples/react/src/examples/UseNodesData/ResultNode.tsx @@ -0,0 +1,25 @@ +import { memo, useEffect } from 'react'; +import { Handle, Position, useHandleConnections, useNodesData } from '@xyflow/react'; + +function ResultNode() { + const connections = useHandleConnections({ + handleType: 'target', + }); + const nodesData = useNodesData<{ text: string }>(connections.map((connection) => connection.source)); + + useEffect(() => { + console.log('incoming data changed', nodesData); + }, [nodesData]); + + return ( +
+ +
+ incoming texts:{' '} + {nodesData?.filter((nodeData) => nodeData.text).map(({ text }, i) =>
{text}
) || 'none'} +
+
+ ); +} + +export default memo(ResultNode); diff --git a/examples/react/src/examples/UseNodesData/TextNode.tsx b/examples/react/src/examples/UseNodesData/TextNode.tsx new file mode 100644 index 00000000..a7388cb0 --- /dev/null +++ b/examples/react/src/examples/UseNodesData/TextNode.tsx @@ -0,0 +1,20 @@ +import { memo, ChangeEventHandler } from 'react'; +import { Position, NodeProps, useUpdateNodeData, Handle } from '@xyflow/react'; + +function TextNode({ id, data }: NodeProps) { + const updateNodeData = useUpdateNodeData(); + + const onChange: ChangeEventHandler = (evt) => updateNodeData(id, { text: evt.target.value }); + + return ( +
+
node {id}
+
+ +
+ +
+ ); +} + +export default memo(TextNode); diff --git a/examples/react/src/examples/UseNodesData/index.tsx b/examples/react/src/examples/UseNodesData/index.tsx new file mode 100644 index 00000000..67e68a2f --- /dev/null +++ b/examples/react/src/examples/UseNodesData/index.tsx @@ -0,0 +1,83 @@ +import { useCallback } from 'react'; +import { + ReactFlow, + Controls, + addEdge, + Connection, + useNodesState, + useEdgesState, + Background, + Node, + Edge, +} from '@xyflow/react'; + +import TextNode from './TextNode'; +import ResultNode from './ResultNode'; + +const nodeTypes = { + text: TextNode, + result: ResultNode, +}; + +const initNodes: Node[] = [ + { + id: '1', + type: 'text', + data: { + text: 'hello', + }, + position: { x: 0, y: 0 }, + }, + { + id: '2', + type: 'text', + data: { + text: 'world', + }, + position: { x: 0, y: 100 }, + }, + + { + id: '3', + type: 'result', + data: {}, + position: { x: 300, y: 50 }, + }, +]; + +const initEdges: Edge[] = [ + { + id: 'e1-3', + source: '1', + target: '3', + }, + { + id: 'e2-3', + source: '2', + target: '3', + }, +]; + +const CustomNodeFlow = () => { + const [nodes, setNodes, onNodesChange] = useNodesState(initNodes); + const [edges, setEdges, onEdgesChange] = useEdgesState(initEdges); + + const onConnect = useCallback((connection: Connection) => setEdges((eds) => addEdge(connection, eds)), [setEdges]); + + return ( + + + + + ); +}; + +export default CustomNodeFlow; diff --git a/packages/react/src/hooks/useHandleConnectionStatus.ts b/packages/react/src/hooks/useHandleConnections.ts similarity index 86% rename from packages/react/src/hooks/useHandleConnectionStatus.ts rename to packages/react/src/hooks/useHandleConnections.ts index 470cc077..f692ba37 100644 --- a/packages/react/src/hooks/useHandleConnectionStatus.ts +++ b/packages/react/src/hooks/useHandleConnections.ts @@ -4,7 +4,7 @@ import { Connection, HandleType } from '@xyflow/system'; import { useStore } from './useStore'; import { useNodeId } from '../contexts/NodeIdContext'; -type useHandleConnectionStatusParams = { +type useHandleConnectionsParams = { handleType: HandleType; nodeId?: string; handleId?: string | null; @@ -12,6 +12,50 @@ type useHandleConnectionStatusParams = { onDisconnect?: (connections: Connection[]) => void; }; +/** + * Hook to check if a is connected to another and get the connections. + * + * @public + * @param param.handleType - 'source' or 'target' + * @param param.handleId - the handle id (this is only needed if the node has multiple handles of the same type) + * @param param.nodeId - node id - if not provided, the node id from the NodeIdContext is used + * @param param.onConnect - gets called when a connection is established + * @param param.onDisconnect - gets called when a connection is removed + * @returns an array with connections + */ +export function useHandleConnections({ + handleType, + handleId = null, + nodeId, + onConnect, + onDisconnect, +}: useHandleConnectionsParams): Connection[] { + const _nodeId = useNodeId(); + const prevConnections = useRef | null>(null); + const currentNodeId = nodeId || _nodeId; + + const connections = useStore( + (state) => state.connectionLookup.get(`${currentNodeId}-${handleType}-${handleId}`), + areConnectionMapsEqual + ); + + useEffect(() => { + // @todo dicuss if onConnect/onDisconnect should be called when the component mounts/unmounts + if (prevConnections.current && prevConnections.current !== connections) { + const _connections = connections ?? new Map(); + handleConnectionChange(prevConnections.current, _connections, onDisconnect); + handleConnectionChange(_connections, prevConnections.current, onConnect); + } + + prevConnections.current = connections ?? new Map(); + }, [connections, onConnect, onDisconnect]); + + return useMemo(() => Array.from(connections?.values() ?? []), [connections]); +} + +/** + * @internal + */ function areConnectionMapsEqual(a?: Map, b?: Map) { if (!a && !b) { return true; @@ -36,6 +80,7 @@ function areConnectionMapsEqual(a?: Map, b?: Map is connected to another and get the connections. - * - * @public - * @param param.handleType - 'source' or 'target' - * @param param.handleId - the handle id (this is only needed if the node has multiple handles of the same type) - * @param param.nodeId - node id - if not provided, the node id from the NodeIdContext is used - * @param param.onConnect - gets called when a connection is established - * @param param.onDisconnect - gets called when a connection is removed - * @returns a `connected` boolean and a connections array - */ -export function useHandleConnectionStatus({ - handleType, - handleId = null, - nodeId, - onConnect, - onDisconnect, -}: useHandleConnectionStatusParams): { - connected: boolean; - connections: Connection[] | null; -} { - const _nodeId = useNodeId(); - const prevConnections = useRef | null>(null); - const currentNodeId = nodeId || _nodeId; - - const connections = useStore( - (state) => state.connectionLookup.get(`${currentNodeId}-${handleType}-${handleId}`), - areConnectionMapsEqual - ); - - useEffect(() => { - // @todo dicuss if onConnect/onDisconnect should be called when the component mounts/unmounts - if (prevConnections.current && prevConnections.current !== connections) { - const _connections = connections ?? new Map(); - handleConnectionChange(prevConnections.current, _connections, onDisconnect); - handleConnectionChange(_connections, prevConnections.current, onConnect); - } - - prevConnections.current = connections ?? new Map(); - }, [connections, onConnect, onDisconnect]); - - return useMemo( - () => ({ - connected: !!connections, - connections: Array.from(connections?.values() ?? []), - }), - [connections] - ); -} diff --git a/packages/react/src/hooks/useNodesData.ts b/packages/react/src/hooks/useNodesData.ts new file mode 100644 index 00000000..69927642 --- /dev/null +++ b/packages/react/src/hooks/useNodesData.ts @@ -0,0 +1,33 @@ +import { useCallback } from 'react'; +import { shallow } from 'zustand/shallow'; + +import { useStore } from '../hooks/useStore'; + +export function useNodesData(nodeId: string): NodeData | null; +export function useNodesData(nodeIds: string[]): NodeData[]; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function useNodesData(nodeIds: any): any { + const nodesData = useStore( + useCallback( + (s) => { + if (!Array.isArray(nodeIds)) { + return s.nodeLookup.get(nodeIds)?.data || null; + } + + return nodeIds.reduce((res, id) => { + const node = s.nodeLookup.get(id); + + if (node) { + res.push(node.data); + } + + return res; + }, []); + }, + [nodeIds] + ), + shallow + ); + + return nodesData; +} diff --git a/packages/react/src/hooks/useUpdateNodeData.ts b/packages/react/src/hooks/useUpdateNodeData.ts new file mode 100644 index 00000000..10cd71e2 --- /dev/null +++ b/packages/react/src/hooks/useUpdateNodeData.ts @@ -0,0 +1,13 @@ +import { useCallback } from 'react'; + +import useReactFlow from './useReactFlow'; + +export function useUpdateNodeData() { + const { setNodes } = useReactFlow(); + + const updateNodeData = useCallback((id: string, data: unknown) => { + setNodes((prevNodes) => prevNodes.map((node) => (node.id === id ? { ...node, data } : node))); + }, []); + + return updateNodeData; +} diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index d9013b7c..63b70c98 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -1,5 +1,5 @@ export { default as ReactFlow } from './container/ReactFlow'; -export { default as Handle } from './components/Handle'; +export { default as Handle, type HandleComponentProps } from './components/Handle'; export { default as EdgeText } from './components/Edges/EdgeText'; export { StraightEdge } from './components/Edges/StraightEdge'; export { StepEdge } from './components/Edges/StepEdge'; @@ -22,7 +22,9 @@ export { useStore, useStoreApi } from './hooks/useStore'; export { default as useOnViewportChange, type UseOnViewportChangeOptions } from './hooks/useOnViewportChange'; export { default as useOnSelectionChange, type UseOnSelectionChangeOptions } from './hooks/useOnSelectionChange'; export { default as useNodesInitialized, type UseNodesInitializedOptions } from './hooks/useNodesInitialized'; -export { useHandleConnectionStatus } from './hooks/useHandleConnectionStatus'; +export { useHandleConnections } from './hooks/useHandleConnections'; +export { useUpdateNodeData } from './hooks/useUpdateNodeData'; +export { useNodesData } from './hooks/useNodesData'; export { useNodeId } from './contexts/NodeIdContext'; export { applyNodeChanges, applyEdgeChanges, handleParentExpand } from './utils/changes'; diff --git a/packages/system/src/types/general.ts b/packages/system/src/types/general.ts index 22ad889e..e601ee08 100644 --- a/packages/system/src/types/general.ts +++ b/packages/system/src/types/general.ts @@ -22,8 +22,8 @@ export type SetCenter = (x: number, y: number, options?: SetCenterOptions) => vo export type FitBounds = (bounds: Rect, options?: FitBoundsOptions) => void; export type Connection = { - source: string | null; - target: string | null; + source: string; + target: string; sourceHandle: string | null; targetHandle: string | null; }; From 5d8c1bbff991e0d3acc6fd23d95008947c10fe1c Mon Sep 17 00:00:00 2001 From: moklick Date: Thu, 7 Dec 2023 14:09:56 +0100 Subject: [PATCH 05/14] chore(useNodesData): example --- .../examples/UseNodesData/UppercaseNode.tsx | 24 +++++++++++++++++++ .../react/src/examples/UseNodesData/index.tsx | 18 ++++++++++++-- 2 files changed, 40 insertions(+), 2 deletions(-) create mode 100644 examples/react/src/examples/UseNodesData/UppercaseNode.tsx diff --git a/examples/react/src/examples/UseNodesData/UppercaseNode.tsx b/examples/react/src/examples/UseNodesData/UppercaseNode.tsx new file mode 100644 index 00000000..7f0b964f --- /dev/null +++ b/examples/react/src/examples/UseNodesData/UppercaseNode.tsx @@ -0,0 +1,24 @@ +import { memo, useEffect } from 'react'; +import { Position, NodeProps, useUpdateNodeData, Handle, useHandleConnections, useNodesData } from '@xyflow/react'; + +function UppercaseNode({ id }: NodeProps) { + const connections = useHandleConnections({ + handleType: 'target', + }); + const nodeData = useNodesData<{ text: string }>(connections[0]?.source); + const updateNodeData = useUpdateNodeData(); + + useEffect(() => { + updateNodeData(id, { text: nodeData?.text.toUpperCase() }); + }, [nodeData]); + + return ( +
+ +
uppercase transform
+ +
+ ); +} + +export default memo(UppercaseNode); diff --git a/examples/react/src/examples/UseNodesData/index.tsx b/examples/react/src/examples/UseNodesData/index.tsx index 67e68a2f..040ca35a 100644 --- a/examples/react/src/examples/UseNodesData/index.tsx +++ b/examples/react/src/examples/UseNodesData/index.tsx @@ -13,10 +13,12 @@ import { import TextNode from './TextNode'; import ResultNode from './ResultNode'; +import UppercaseNode from './UppercaseNode'; const nodeTypes = { text: TextNode, result: ResultNode, + uppercase: UppercaseNode, }; const initNodes: Node[] = [ @@ -26,8 +28,15 @@ const initNodes: Node[] = [ data: { text: 'hello', }, - position: { x: 0, y: 0 }, + position: { x: -100, y: -50 }, }, + { + id: '1a', + type: 'uppercase', + data: {}, + position: { x: 100, y: 0 }, + }, + { id: '2', type: 'text', @@ -47,8 +56,13 @@ const initNodes: Node[] = [ const initEdges: Edge[] = [ { - id: 'e1-3', + id: 'e1-1a', source: '1', + target: '1a', + }, + { + id: 'e1a-3', + source: '1a', target: '3', }, { From d4d773d9c62f49706bccd1d5435e75dd912d3643 Mon Sep 17 00:00:00 2001 From: moklick Date: Mon, 11 Dec 2023 18:31:37 +0100 Subject: [PATCH 06/14] 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, From fcf151f6cb543a248f9d49ce884d67460134c8e0 Mon Sep 17 00:00:00 2001 From: moklick Date: Mon, 11 Dec 2023 19:35:31 +0100 Subject: [PATCH 07/14] fix(svelte): shortcut delete/input issue #3712 --- .../examples/usenodesdata/ResultNode.svelte | 2 +- .../svelte/src/lib/actions/shortcut/index.ts | 89 +++++++++++++++++++ .../components/KeyHandler/KeyHandler.svelte | 35 +++++--- 3 files changed, 113 insertions(+), 13 deletions(-) create mode 100644 packages/svelte/src/lib/actions/shortcut/index.ts diff --git a/examples/svelte/src/routes/examples/usenodesdata/ResultNode.svelte b/examples/svelte/src/routes/examples/usenodesdata/ResultNode.svelte index c4150f42..2664e8ef 100644 --- a/examples/svelte/src/routes/examples/usenodesdata/ResultNode.svelte +++ b/examples/svelte/src/routes/examples/usenodesdata/ResultNode.svelte @@ -17,7 +17,7 @@ type: 'target' }); - const nodeData = useNodesData($connections.map((connection) => connection.source)); + $: nodeData = useNodesData($connections.map((connection) => connection.source));
diff --git a/packages/svelte/src/lib/actions/shortcut/index.ts b/packages/svelte/src/lib/actions/shortcut/index.ts new file mode 100644 index 00000000..10c1e207 --- /dev/null +++ b/packages/svelte/src/lib/actions/shortcut/index.ts @@ -0,0 +1,89 @@ +// Shortcut action is copied and slightly adjusted from https://github.com/vnphanquang/svelte-put/tree/main/packages/actions/shortcut +// If this discussion https://github.com/vnphanquang/svelte-put/discussions/256 gets resolved, we will use the original action again. + +export interface ShortcutEventDetail { + event: KeyboardEvent; + trigger: ShortcutTrigger; +} + +export type ShortcutModifier = 'alt' | 'ctrl' | 'meta' | 'shift'; + +export type ShortcutModifierDefinition = + | ShortcutModifier + | ShortcutModifier[] + | ShortcutModifier[][]; + +export type ShortcutTrigger = { + enabled?: boolean; + modifier?: ShortcutModifierDefinition; + id?: string; + key: string; + callback?: (detail: ShortcutEventDetail) => void; + preventDefault?: boolean; +}; + +export type ShortcutParameter = { + enabled?: boolean; + trigger: Array | ShortcutTrigger; + type?: 'keydown' | 'keyup'; +}; + +export function shortcut(node: Window, param: ShortcutParameter) { + let { enabled = true, trigger, type = 'keydown' } = param; + + function handler(event: KeyboardEvent) { + const normalizedTriggers = Array.isArray(trigger) ? trigger : [trigger]; + const modifiedMap = { + alt: event.altKey, + ctrl: event.ctrlKey, + shift: event.shiftKey, + meta: event.metaKey + }; + for (const trigger of normalizedTriggers) { + const mergedTrigger = { + modifier: [], + preventDefault: false, + enabled: true, + ...trigger + }; + const { modifier, key, callback, preventDefault, enabled: triggerEnabled } = mergedTrigger; + if (triggerEnabled) { + if (modifier.length) { + const modifierDefs = (Array.isArray(modifier) ? modifier : [modifier]).map((def) => + typeof def === 'string' ? [def] : def + ); + const modified = modifierDefs.some((def) => + def.every((modifier) => modifiedMap[modifier]) + ); + if (!modified) continue; + } + if (event.key === key) { + if (preventDefault) event.preventDefault(); + const detail = { event, trigger: mergedTrigger }; + callback?.(detail); + } + } + } + } + + if (enabled) node.addEventListener(type, handler); + + return { + update: (update: ShortcutParameter) => { + const { enabled: newEnabled = true, type: newType = 'keydown' } = update; + + if (enabled && (!newEnabled || type !== newType)) { + node.removeEventListener(type, handler); + } else if (!enabled && newEnabled) { + node.addEventListener(newType, handler); + } + + enabled = newEnabled; + type = newType; + trigger = update.trigger; + }, + destroy: () => { + node.removeEventListener(type, handler); + } + }; +} diff --git a/packages/svelte/src/lib/components/KeyHandler/KeyHandler.svelte b/packages/svelte/src/lib/components/KeyHandler/KeyHandler.svelte index f52d696a..54f3a9c1 100644 --- a/packages/svelte/src/lib/components/KeyHandler/KeyHandler.svelte +++ b/packages/svelte/src/lib/components/KeyHandler/KeyHandler.svelte @@ -1,10 +1,10 @@
@@ -14,7 +14,7 @@
updateNodeData(id, { text: evt.currentTarget.value })} + on:input={(evt) => setNodeData(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 index 3e311f1f..b23481d8 100644 --- a/examples/svelte/src/routes/examples/usenodesdata/UppercaseNode.svelte +++ b/examples/svelte/src/routes/examples/usenodesdata/UppercaseNode.svelte @@ -5,14 +5,14 @@ type NodeProps, useHandleConnections, useNodesData, - useUpdateNodeData + useSetNodeData } from '@xyflow/svelte'; type $$Props = NodeProps; export let id: $$Props['id']; - const updateNodeData = useUpdateNodeData(); + const setNodeData = useSetNodeData(); const connections = useHandleConnections({ nodeId: id, type: 'target' @@ -21,7 +21,7 @@ $: nodeData = useNodesData($connections[0]?.source); $: { - updateNodeData(id, { text: $nodeData?.text?.toUpperCase() || '' }); + setNodeData(id, { text: $nodeData?.text?.toUpperCase() || '' }); } diff --git a/packages/react/src/hooks/useSetNodeData.ts b/packages/react/src/hooks/useSetNodeData.ts new file mode 100644 index 00000000..16d33f66 --- /dev/null +++ b/packages/react/src/hooks/useSetNodeData.ts @@ -0,0 +1,30 @@ +import { useCallback } from 'react'; + +import useReactFlow from './useReactFlow'; +import { Node } from '../types'; + +export function useSetNodeData() { + const { setNodes } = useReactFlow(); + + const setNodeData = useCallback( + function setNodesData( + id: string, + dataUpdate: object | ((node: NodeType) => object), + options: { replace: boolean } = { replace: true } + ) { + setNodes((prevNodes) => + prevNodes.map((node) => { + if (node.id === id) { + const nextData = typeof dataUpdate === 'function' ? dataUpdate(node as NodeType) : dataUpdate; + return options.replace ? { ...node, data: nextData } : { ...node, data: { ...node.data, ...nextData } }; + } + + return node; + }) + ); + }, + [setNodes] + ); + + return setNodeData; +} diff --git a/packages/react/src/hooks/useUpdateNodeData.ts b/packages/react/src/hooks/useUpdateNodeData.ts deleted file mode 100644 index 10cd71e2..00000000 --- a/packages/react/src/hooks/useUpdateNodeData.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { useCallback } from 'react'; - -import useReactFlow from './useReactFlow'; - -export function useUpdateNodeData() { - const { setNodes } = useReactFlow(); - - const updateNodeData = useCallback((id: string, data: unknown) => { - setNodes((prevNodes) => prevNodes.map((node) => (node.id === id ? { ...node, data } : node))); - }, []); - - return updateNodeData; -} diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 63b70c98..ab450904 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -23,7 +23,7 @@ export { default as useOnViewportChange, type UseOnViewportChangeOptions } from export { default as useOnSelectionChange, type UseOnSelectionChangeOptions } from './hooks/useOnSelectionChange'; export { default as useNodesInitialized, type UseNodesInitializedOptions } from './hooks/useNodesInitialized'; export { useHandleConnections } from './hooks/useHandleConnections'; -export { useUpdateNodeData } from './hooks/useUpdateNodeData'; +export { useSetNodeData } from './hooks/useSetNodeData'; export { useNodesData } from './hooks/useNodesData'; export { useNodeId } from './contexts/NodeIdContext'; diff --git a/packages/svelte/src/lib/hooks/useSetNodeData.ts b/packages/svelte/src/lib/hooks/useSetNodeData.ts new file mode 100644 index 00000000..51aaaacb --- /dev/null +++ b/packages/svelte/src/lib/hooks/useSetNodeData.ts @@ -0,0 +1,28 @@ +import { useStore } from '$lib/store'; +import type { Node } from '$lib/types'; + +export function useSetNodeData() { + const { nodes } = useStore(); + + const setNodeData = ( + id: string, + dataUpdate: object | ((node: NodeType) => object), + options: { replace: boolean } = { replace: true } + ) => { + nodes.update((nds) => + nds.map((node) => { + if (node.id === id) { + const nextData = + typeof dataUpdate === 'function' ? dataUpdate(node as NodeType) : dataUpdate; + return options.replace + ? { ...node, data: nextData } + : { ...node, data: { ...node.data, ...nextData } }; + } + + return node; + }) + ); + }; + + return setNodeData; +} diff --git a/packages/svelte/src/lib/hooks/useUpdateNodeData.ts b/packages/svelte/src/lib/hooks/useUpdateNodeData.ts deleted file mode 100644 index cb45dd10..00000000 --- a/packages/svelte/src/lib/hooks/useUpdateNodeData.ts +++ /dev/null @@ -1,22 +0,0 @@ -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 71841ec7..4675f2ca 100644 --- a/packages/svelte/src/lib/index.ts +++ b/packages/svelte/src/lib/index.ts @@ -29,7 +29,7 @@ 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'; +export * from '$lib/hooks/useSetNodeData'; // types export type { From 8ceb1addc186726990f7ba2471abe410bf0aa6d7 Mon Sep 17 00:00:00 2001 From: moklick Date: Tue, 12 Dec 2023 18:52:45 +0100 Subject: [PATCH 11/14] refactor(useSetNodeData): put function in useReactFlow/useSvelteFlow --- .../src/examples/UseNodesData/TextNode.tsx | 6 +- .../examples/UseNodesData/UppercaseNode.tsx | 6 +- .../examples/usenodesdata/TextNode.svelte | 6 +- .../usenodesdata/UppercaseNode.svelte | 6 +- packages/react/src/hooks/useReactFlow.ts | 35 +++- packages/react/src/hooks/useSetNodeData.ts | 30 --- packages/react/src/index.ts | 1 - packages/react/src/types/instance.ts | 13 ++ packages/react/src/utils/general.ts | 4 +- packages/svelte/package.json | 2 +- .../svelte/src/lib/actions/shortcut/index.ts | 89 --------- .../components/KeyHandler/KeyHandler.svelte | 4 +- .../svelte/src/lib/hooks/useSetNodeData.ts | 28 --- .../svelte/src/lib/hooks/useSvelteFlow.ts | 67 ++++--- packages/svelte/src/lib/index.ts | 1 - packages/svelte/src/lib/utils/index.ts | 4 +- packages/system/src/utils/graph.ts | 11 +- pnpm-lock.yaml | 188 ++++++++++++++---- 18 files changed, 254 insertions(+), 247 deletions(-) delete mode 100644 packages/react/src/hooks/useSetNodeData.ts delete mode 100644 packages/svelte/src/lib/actions/shortcut/index.ts delete mode 100644 packages/svelte/src/lib/hooks/useSetNodeData.ts diff --git a/examples/react/src/examples/UseNodesData/TextNode.tsx b/examples/react/src/examples/UseNodesData/TextNode.tsx index e787d824..6ca424e6 100644 --- a/examples/react/src/examples/UseNodesData/TextNode.tsx +++ b/examples/react/src/examples/UseNodesData/TextNode.tsx @@ -1,10 +1,10 @@ import { memo, ChangeEventHandler } from 'react'; -import { Position, NodeProps, useSetNodeData, Handle } from '@xyflow/react'; +import { Position, NodeProps, Handle, useReactFlow } from '@xyflow/react'; function TextNode({ id, data }: NodeProps) { - const setNodeData = useSetNodeData(); + const { updateNodeData } = useReactFlow(); - const onChange: ChangeEventHandler = (evt) => setNodeData(id, { text: evt.target.value }); + const onChange: ChangeEventHandler = (evt) => updateNodeData(id, { text: evt.target.value }); return (
diff --git a/examples/react/src/examples/UseNodesData/UppercaseNode.tsx b/examples/react/src/examples/UseNodesData/UppercaseNode.tsx index 47ecd87c..699dc37e 100644 --- a/examples/react/src/examples/UseNodesData/UppercaseNode.tsx +++ b/examples/react/src/examples/UseNodesData/UppercaseNode.tsx @@ -1,15 +1,15 @@ import { memo, useEffect } from 'react'; -import { Position, NodeProps, useSetNodeData, Handle, useHandleConnections, useNodesData } from '@xyflow/react'; +import { Position, NodeProps, useReactFlow, Handle, useHandleConnections, useNodesData } from '@xyflow/react'; function UppercaseNode({ id }: NodeProps) { + const { updateNodeData } = useReactFlow(); const connections = useHandleConnections({ type: 'target', }); const nodeData = useNodesData(connections[0]?.source); - const setNodeData = useSetNodeData(); useEffect(() => { - setNodeData(id, { text: nodeData?.text.toUpperCase() }); + updateNodeData(id, { text: nodeData?.text.toUpperCase() }); }, [nodeData]); return ( diff --git a/examples/svelte/src/routes/examples/usenodesdata/TextNode.svelte b/examples/svelte/src/routes/examples/usenodesdata/TextNode.svelte index 63f2d83b..c1642da2 100644 --- a/examples/svelte/src/routes/examples/usenodesdata/TextNode.svelte +++ b/examples/svelte/src/routes/examples/usenodesdata/TextNode.svelte @@ -1,12 +1,12 @@
@@ -14,7 +14,7 @@
setNodeData(id, { text: evt.currentTarget.value })} + on:input={(evt) => 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 index b23481d8..88c8a918 100644 --- a/examples/svelte/src/routes/examples/usenodesdata/UppercaseNode.svelte +++ b/examples/svelte/src/routes/examples/usenodesdata/UppercaseNode.svelte @@ -5,14 +5,14 @@ type NodeProps, useHandleConnections, useNodesData, - useSetNodeData + useSvelteFlow } from '@xyflow/svelte'; type $$Props = NodeProps; export let id: $$Props['id']; - const setNodeData = useSetNodeData(); + const { updateNodeData } = useSvelteFlow(); const connections = useHandleConnections({ nodeId: id, type: 'target' @@ -21,7 +21,7 @@ $: nodeData = useNodesData($connections[0]?.source); $: { - setNodeData(id, { text: $nodeData?.text?.toUpperCase() || '' }); + updateNodeData(id, { text: $nodeData?.text?.toUpperCase() || '' }); } diff --git a/packages/react/src/hooks/useReactFlow.ts b/packages/react/src/hooks/useReactFlow.ts index 44e29c14..9bb17eb9 100644 --- a/packages/react/src/hooks/useReactFlow.ts +++ b/packages/react/src/hooks/useReactFlow.ts @@ -10,7 +10,7 @@ import { } from '@xyflow/system'; import useViewportHelper from './useViewportHelper'; -import { useStoreApi } from '../hooks/useStore'; +import { useStoreApi } from './useStore'; import type { ReactFlowInstance, Instance, @@ -24,6 +24,7 @@ import type { Node, Edge, } from '../types'; +import { isNode } from '../utils'; /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ export default function useReactFlow(): ReactFlowInstance { @@ -271,6 +272,36 @@ export default function useReactFlow(): ReactFlo return getOutgoersBase(node, nodes, edges); }, []); + const updateNode = useCallback( + (id, nodeUpdate, options = { replace: true }) => { + setNodes((prevNodes) => + prevNodes.map((node) => { + if (node.id === id) { + const nextNode = typeof nodeUpdate === 'function' ? nodeUpdate(node as Node) : nodeUpdate; + return options.replace && isNode(nextNode) ? nextNode : { ...node, ...nextNode }; + } + + return node; + }) + ); + }, + [setNodes] + ); + + const updateNodeData = useCallback( + (id, dataUpdate, options = { replace: false }) => { + updateNode( + id, + (node) => { + const nextData = typeof dataUpdate === 'function' ? dataUpdate(node) : dataUpdate; + return options.replace ? { ...node, data: nextData } : { ...node, data: { ...node.data, ...nextData } }; + }, + options + ); + }, + [updateNode] + ); + return useMemo(() => { return { ...viewportHelper, @@ -289,6 +320,8 @@ export default function useReactFlow(): ReactFlo getConnectedEdges, getIncomers, getOutgoers, + updateNode, + updateNodeData, }; }, [ viewportHelper, diff --git a/packages/react/src/hooks/useSetNodeData.ts b/packages/react/src/hooks/useSetNodeData.ts deleted file mode 100644 index 16d33f66..00000000 --- a/packages/react/src/hooks/useSetNodeData.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { useCallback } from 'react'; - -import useReactFlow from './useReactFlow'; -import { Node } from '../types'; - -export function useSetNodeData() { - const { setNodes } = useReactFlow(); - - const setNodeData = useCallback( - function setNodesData( - id: string, - dataUpdate: object | ((node: NodeType) => object), - options: { replace: boolean } = { replace: true } - ) { - setNodes((prevNodes) => - prevNodes.map((node) => { - if (node.id === id) { - const nextData = typeof dataUpdate === 'function' ? dataUpdate(node as NodeType) : dataUpdate; - return options.replace ? { ...node, data: nextData } : { ...node, data: { ...node.data, ...nextData } }; - } - - return node; - }) - ); - }, - [setNodes] - ); - - return setNodeData; -} diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index ab450904..ae82e78b 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -23,7 +23,6 @@ export { default as useOnViewportChange, type UseOnViewportChangeOptions } from export { default as useOnSelectionChange, type UseOnSelectionChangeOptions } from './hooks/useOnSelectionChange'; export { default as useNodesInitialized, type UseNodesInitializedOptions } from './hooks/useNodesInitialized'; export { useHandleConnections } from './hooks/useHandleConnections'; -export { useSetNodeData } from './hooks/useSetNodeData'; export { useNodesData } from './hooks/useNodesData'; export { useNodeId } from './contexts/NodeIdContext'; diff --git a/packages/react/src/types/instance.ts b/packages/react/src/types/instance.ts index f229ec87..a1c822bb 100644 --- a/packages/react/src/types/instance.ts +++ b/packages/react/src/types/instance.ts @@ -45,6 +45,17 @@ export namespace Instance { export type getConnectedEdges = (id: string | (Node | { id: Node['id'] })[]) => Edge[]; export type getIncomers = (node: string | Node | { id: Node['id'] }) => Node[]; export type getOutgoers = (node: string | Node | { id: Node['id'] }) => Node[]; + + export type UpdateNode = ( + id: string, + dataUpdate: Partial | ((node: Node) => Partial), + options?: { replace: boolean } + ) => void; + export type UpdateNodeData = ( + id: string, + dataUpdate: object | ((node: Node) => object), + options?: { replace: boolean } + ) => void; } export type ReactFlowInstance = { @@ -60,5 +71,7 @@ export type ReactFlowInstance = { deleteElements: Instance.DeleteElements; getIntersectingNodes: Instance.GetIntersectingNodes; isNodeIntersecting: Instance.IsNodeIntersecting; + updateNode: Instance.UpdateNode; + updateNodeData: Instance.UpdateNodeData; viewportInitialized: boolean; } & Omit; diff --git a/packages/react/src/utils/general.ts b/packages/react/src/utils/general.ts index e13247c1..a106e182 100644 --- a/packages/react/src/utils/general.ts +++ b/packages/react/src/utils/general.ts @@ -10,8 +10,8 @@ import { import type { Edge, Node } from '../types'; -export const isNode = isNodeBase; -export const isEdge = isEdgeBase; +export const isNode = isNodeBase; +export const isEdge = isEdgeBase; export const getOutgoers = getOutgoersBase; export const getIncomers = getIncomersBase; export const addEdge = addEdgeBase; diff --git a/packages/svelte/package.json b/packages/svelte/package.json index 5ed9f8e9..60f4b8ed 100644 --- a/packages/svelte/package.json +++ b/packages/svelte/package.json @@ -41,7 +41,7 @@ "access": "public" }, "dependencies": { - "@svelte-put/shortcut": "^3.0.0", + "@svelte-put/shortcut": "^3.1.0", "@xyflow/system": "workspace:*", "classcat": "^5.0.4" }, diff --git a/packages/svelte/src/lib/actions/shortcut/index.ts b/packages/svelte/src/lib/actions/shortcut/index.ts deleted file mode 100644 index 10c1e207..00000000 --- a/packages/svelte/src/lib/actions/shortcut/index.ts +++ /dev/null @@ -1,89 +0,0 @@ -// Shortcut action is copied and slightly adjusted from https://github.com/vnphanquang/svelte-put/tree/main/packages/actions/shortcut -// If this discussion https://github.com/vnphanquang/svelte-put/discussions/256 gets resolved, we will use the original action again. - -export interface ShortcutEventDetail { - event: KeyboardEvent; - trigger: ShortcutTrigger; -} - -export type ShortcutModifier = 'alt' | 'ctrl' | 'meta' | 'shift'; - -export type ShortcutModifierDefinition = - | ShortcutModifier - | ShortcutModifier[] - | ShortcutModifier[][]; - -export type ShortcutTrigger = { - enabled?: boolean; - modifier?: ShortcutModifierDefinition; - id?: string; - key: string; - callback?: (detail: ShortcutEventDetail) => void; - preventDefault?: boolean; -}; - -export type ShortcutParameter = { - enabled?: boolean; - trigger: Array | ShortcutTrigger; - type?: 'keydown' | 'keyup'; -}; - -export function shortcut(node: Window, param: ShortcutParameter) { - let { enabled = true, trigger, type = 'keydown' } = param; - - function handler(event: KeyboardEvent) { - const normalizedTriggers = Array.isArray(trigger) ? trigger : [trigger]; - const modifiedMap = { - alt: event.altKey, - ctrl: event.ctrlKey, - shift: event.shiftKey, - meta: event.metaKey - }; - for (const trigger of normalizedTriggers) { - const mergedTrigger = { - modifier: [], - preventDefault: false, - enabled: true, - ...trigger - }; - const { modifier, key, callback, preventDefault, enabled: triggerEnabled } = mergedTrigger; - if (triggerEnabled) { - if (modifier.length) { - const modifierDefs = (Array.isArray(modifier) ? modifier : [modifier]).map((def) => - typeof def === 'string' ? [def] : def - ); - const modified = modifierDefs.some((def) => - def.every((modifier) => modifiedMap[modifier]) - ); - if (!modified) continue; - } - if (event.key === key) { - if (preventDefault) event.preventDefault(); - const detail = { event, trigger: mergedTrigger }; - callback?.(detail); - } - } - } - } - - if (enabled) node.addEventListener(type, handler); - - return { - update: (update: ShortcutParameter) => { - const { enabled: newEnabled = true, type: newType = 'keydown' } = update; - - if (enabled && (!newEnabled || type !== newType)) { - node.removeEventListener(type, handler); - } else if (!enabled && newEnabled) { - node.addEventListener(newType, handler); - } - - enabled = newEnabled; - type = newType; - trigger = update.trigger; - }, - destroy: () => { - node.removeEventListener(type, handler); - } - }; -} diff --git a/packages/svelte/src/lib/components/KeyHandler/KeyHandler.svelte b/packages/svelte/src/lib/components/KeyHandler/KeyHandler.svelte index 54f3a9c1..36206982 100644 --- a/packages/svelte/src/lib/components/KeyHandler/KeyHandler.svelte +++ b/packages/svelte/src/lib/components/KeyHandler/KeyHandler.svelte @@ -1,10 +1,10 @@ @@ -69,11 +77,11 @@ {edges} fitView fitViewOptions={{ padding: 2 }} - on:connectstart={({ detail: { nodeId } }) => { + onconnectstart={(_, { nodeId }) => { // Memorize the nodeId you start draggin a connection line from a node connectingNodeId = nodeId; }} - on:connectend={handleConnectEnd} + onconnectend={handleConnectEnd} />
diff --git a/examples/svelte/src/routes/examples/customnode/+page.svelte b/examples/svelte/src/routes/examples/customnode/+page.svelte index 123932f9..525b0706 100644 --- a/examples/svelte/src/routes/examples/customnode/+page.svelte +++ b/examples/svelte/src/routes/examples/customnode/+page.svelte @@ -87,7 +87,7 @@ {nodeTypes} style="--xy-background-color: {$bgColor}" fitView - on:connect={onConnect} + onconnect={onConnect} > diff --git a/examples/svelte/src/routes/examples/customnode/CustomNode.svelte b/examples/svelte/src/routes/examples/customnode/CustomNode.svelte index 25d37d32..ca01be5e 100644 --- a/examples/svelte/src/routes/examples/customnode/CustomNode.svelte +++ b/examples/svelte/src/routes/examples/customnode/CustomNode.svelte @@ -10,7 +10,7 @@
- +
Custom Color Picker Node: {$colorStore}
@@ -20,14 +20,8 @@ on:input={(evt) => colorStore.set(evt.currentTarget.value)} value={$colorStore} /> - - + +