diff --git a/examples/react/src/examples/UpdatableEdge/index.tsx b/examples/react/src/examples/UpdatableEdge/index.tsx index 754efa56..5dacdc9c 100644 --- a/examples/react/src/examples/UpdatableEdge/index.tsx +++ b/examples/react/src/examples/UpdatableEdge/index.tsx @@ -6,7 +6,6 @@ import { addEdge, applyNodeChanges, applyEdgeChanges, - ReactFlowInstance, Connection, Edge, Node, @@ -97,7 +96,6 @@ const initialEdges: Edge[] = [ { id: 'e5-6', source: '5', target: '6', label: 'This edge can be updated from both sides' }, ]; -const onInit = (reactFlowInstance: ReactFlowInstance) => reactFlowInstance.fitView(); const onEdgeUpdateStart = (_: ReactMouseEvent, edge: Edge, handleType: HandleType) => console.log(`start update ${handleType} handle`, edge); const onEdgeUpdateEnd = (_: MouseEvent | TouchEvent, edge: Edge, handleType: HandleType) => @@ -111,7 +109,6 @@ const UpdatableEdge = () => { const onConnect = (connection: Connection) => setEdges((els) => addEdge(connection, els)); const onNodesChange = useCallback((changes: NodeChange[]) => { - console.log(changes); setNodes((ns) => applyNodeChanges(changes, ns)); }, []); @@ -125,12 +122,12 @@ const UpdatableEdge = () => { edges={edges} onNodesChange={onNodesChange} onEdgesChange={onEdgesChange} - onInit={onInit} snapToGrid={true} onEdgeUpdate={onEdgeUpdate} onConnect={onConnect} onEdgeUpdateStart={onEdgeUpdateStart} onEdgeUpdateEnd={onEdgeUpdateEnd} + fitView > diff --git a/packages/react/src/components/EdgeWrapper/EdgeUpdateAnchors.tsx b/packages/react/src/components/EdgeWrapper/EdgeUpdateAnchors.tsx new file mode 100644 index 00000000..8f6ddfe3 --- /dev/null +++ b/packages/react/src/components/EdgeWrapper/EdgeUpdateAnchors.tsx @@ -0,0 +1,137 @@ +// Updatable edges have a anchors around their handles to update the edge. +import { XYHandle, type Connection, EdgePosition } from '@xyflow/system'; + +import { EdgeAnchor } from '../Edges/EdgeAnchor'; +import type { EdgeWrapperProps, Edge } from '../../types/edges'; +import { useStoreApi } from '../../hooks/useStore'; + +type EdgeUpdateAnchorsProps = { + edge: Edge; + isUpdatable: boolean | 'source' | 'target'; + edgeUpdaterRadius: EdgeWrapperProps['edgeUpdaterRadius']; + sourceHandleId: EdgeWrapperProps['sourceHandleId']; + targetHandleId: EdgeWrapperProps['targetHandleId']; + onEdgeUpdate: EdgeWrapperProps['onEdgeUpdate']; + onEdgeUpdateStart: EdgeWrapperProps['onEdgeUpdateStart']; + onEdgeUpdateEnd: EdgeWrapperProps['onEdgeUpdateEnd']; + setUpdateHover: (hover: boolean) => void; + setUpdating: (updating: boolean) => void; +} & EdgePosition; + +function EdgeUpdateAnchors({ + isUpdatable, + edgeUpdaterRadius, + edge, + targetHandleId, + sourceHandleId, + sourceX, + sourceY, + targetX, + targetY, + sourcePosition, + targetPosition, + onEdgeUpdate, + onEdgeUpdateStart, + onEdgeUpdateEnd, + setUpdating, + setUpdateHover, +}: EdgeUpdateAnchorsProps) { + const store = useStoreApi(); + + const handleEdgeUpdater = (event: React.MouseEvent, isSourceHandle: boolean) => { + // avoid triggering edge updater if mouse btn is not left + if (event.button !== 0) { + return; + } + + const { + autoPanOnConnect, + domNode, + isValidConnection, + connectionMode, + connectionRadius, + lib, + onConnectStart, + onConnectEnd, + cancelConnection, + nodes, + panBy, + updateConnection, + } = store.getState(); + const nodeId = isSourceHandle ? edge.target : edge.source; + const handleId = (isSourceHandle ? targetHandleId : sourceHandleId) || null; + const handleType = isSourceHandle ? 'target' : 'source'; + + const isTarget = isSourceHandle; + + setUpdating(true); + onEdgeUpdateStart?.(event, edge, handleType); + + const _onEdgeUpdateEnd = (evt: MouseEvent | TouchEvent) => { + setUpdating(false); + onEdgeUpdateEnd?.(evt, edge, handleType); + }; + + const onConnectEdge = (connection: Connection) => onEdgeUpdate?.(edge, connection); + + XYHandle.onPointerDown(event.nativeEvent, { + autoPanOnConnect, + connectionMode, + connectionRadius, + domNode, + handleId, + nodeId, + nodes, + isTarget, + edgeUpdaterType: handleType, + lib, + cancelConnection, + panBy, + isValidConnection, + onConnect: onConnectEdge, + onConnectStart, + onConnectEnd, + onEdgeUpdateEnd: _onEdgeUpdateEnd, + updateConnection, + getTransform: () => store.getState().transform, + }); + }; + + const onEdgeUpdaterSourceMouseDown = (event: React.MouseEvent): void => + handleEdgeUpdater(event, true); + const onEdgeUpdaterTargetMouseDown = (event: React.MouseEvent): void => + handleEdgeUpdater(event, false); + const onEdgeUpdaterMouseEnter = () => setUpdateHover(true); + const onEdgeUpdaterMouseOut = () => setUpdateHover(false); + + return ( + <> + {(isUpdatable === 'source' || isUpdatable === true) && ( + + )} + {(isUpdatable === 'target' || isUpdatable === true) && ( + + )} + + ); +} + +export default EdgeUpdateAnchors; diff --git a/packages/react/src/components/EdgeWrapper/index.tsx b/packages/react/src/components/EdgeWrapper/index.tsx index 929c87e0..926860ff 100644 --- a/packages/react/src/components/EdgeWrapper/index.tsx +++ b/packages/react/src/components/EdgeWrapper/index.tsx @@ -4,39 +4,24 @@ import { shallow } from 'zustand/shallow'; import { getMarkerId, elementSelectionKeys, - XYHandle, - type Connection, getEdgePosition, errorMessages, + getElevatedEdgeZIndex, } from '@xyflow/system'; import { useStoreApi, useStore } from '../../hooks/useStore'; import { ARIA_EDGE_DESC_KEY } from '../A11yDescriptions'; -import { EdgeAnchor } from '../Edges/EdgeAnchor'; -import { getMouseHandler } from '../Edges/utils'; import type { EdgeWrapperProps } from '../../types'; -import { builtinEdgeTypes } from './utils'; +import { builtinEdgeTypes, nullPosition } from './utils'; +import EdgeUpdateAnchors from './EdgeUpdateAnchors'; function EdgeWrapper({ id, - className, - type, - data, + edgesFocusable, + edgesUpdatable, + elementsSelectable, onClick, - onEdgeDoubleClick, - selected, - animated, - label, - labelStyle, - labelShowBg, - labelBgStyle, - labelBgPadding, - labelBgBorderRadius, - style, - source, - target, - isSelectable, - hidden, + onDoubleClick, sourceHandleId, targetHandleId, onContextMenu, @@ -47,18 +32,17 @@ function EdgeWrapper({ onEdgeUpdate, onEdgeUpdateStart, onEdgeUpdateEnd, - markerEnd, - markerStart, rfId, - ariaLabel, - isFocusable, - isUpdatable, - pathOptions, - interactionWidth, edgeTypes, + elevateEdgesOnSelect, + noPanClassName, onError, }: EdgeWrapperProps): JSX.Element | null { - let edgeType = type || 'default'; + let edge = useStore((s) => s.edgeLookup.get(id)!); + const defaultEdgeOptions = useStore((s) => s.defaultEdgeOptions); + edge = defaultEdgeOptions ? { ...defaultEdgeOptions, ...edge } : edge; + + let edgeType = edge.type || 'default'; let EdgeComponent = edgeTypes?.[edgeType] || builtinEdgeTypes[edgeType]; if (EdgeComponent === undefined) { @@ -67,49 +51,74 @@ function EdgeWrapper({ EdgeComponent = builtinEdgeTypes.default; } + const isFocusable = !!(edge.focusable || (edgesFocusable && typeof edge.focusable === 'undefined')); + const isUpdatable = + typeof onEdgeUpdate !== 'undefined' && + (edge.updatable || (edgesUpdatable && typeof edge.updatable === 'undefined')); + const isSelectable = !!(edge.selectable || (elementsSelectable && typeof edge.selectable === 'undefined')); + const edgeRef = useRef(null); const [updateHover, setUpdateHover] = useState(false); const [updating, setUpdating] = useState(false); const store = useStoreApi(); - const edgePosition = useStore( + + const { zIndex, sourceX, sourceY, targetX, targetY, sourcePosition, targetPosition } = useStore( useCallback( - (state) => { - const sourceNode = state.nodeLookup.get(source); - const targetNode = state.nodeLookup.get(target); + (store) => { + const sourceNode = store.nodeLookup.get(edge.source); + const targetNode = store.nodeLookup.get(edge.target); if (!sourceNode || !targetNode) { - return null; + return { + zIndex: edge.zIndex, + ...nullPosition, + }; } - return getEdgePosition({ + const edgePosition = getEdgePosition({ id, sourceNode, targetNode, sourceHandle: sourceHandleId || null, targetHandle: targetHandleId || null, - connectionMode: state.connectionMode, - onError: state.onError, + connectionMode: store.connectionMode, + onError, }); + + const zIndex = getElevatedEdgeZIndex({ + selected: edge.selected, + zIndex: edge.zIndex, + sourceNode, + targetNode, + elevateOnSelect: elevateEdgesOnSelect, + }); + + return { + zIndex, + ...(edgePosition || nullPosition), + }; }, - [source, target] + [edge.source, edge.target, edge.selected, edge.zIndex, elevateEdgesOnSelect] ), shallow ); - const markerStartUrl = useMemo(() => `url(#${getMarkerId(markerStart, rfId)})`, [markerStart, rfId]); - const markerEndUrl = useMemo(() => `url(#${getMarkerId(markerEnd, rfId)})`, [markerEnd, rfId]); + const markerStartUrl = useMemo( + () => (edge.markerStart ? `url(#${getMarkerId(edge.markerStart, rfId)})` : undefined), + [edge.markerStart, rfId] + ); - if (hidden || !edgePosition) { + const markerEndUrl = useMemo( + () => (edge.markerEnd ? `url(#${getMarkerId(edge.markerEnd, rfId)})` : undefined), + [edge.markerEnd, rfId] + ); + + if (edge.hidden || !sourceX || !sourceY || !targetX || !targetY) { return null; } const onEdgeClick = (event: React.MouseEvent): void => { - const { edges, addSelectedEdges, unselectNodesAndEdges, multiSelectionActive } = store.getState(); - const edge = edges.find((e) => e.id === id); - - if (!edge) { - return; - } + const { addSelectedEdges, unselectNodesAndEdges, multiSelectionActive } = store.getState(); if (isSelectable) { store.setState({ nodesSelectionActive: false }); @@ -127,91 +136,40 @@ function EdgeWrapper({ } }; - const onEdgeDoubleClickHandler = getMouseHandler(id, store.getState, onEdgeDoubleClick); - const onEdgeContextMenu = getMouseHandler(id, store.getState, onContextMenu); - const onEdgeMouseEnter = getMouseHandler(id, store.getState, onMouseEnter); - const onEdgeMouseMove = getMouseHandler(id, store.getState, onMouseMove); - const onEdgeMouseLeave = getMouseHandler(id, store.getState, onMouseLeave); - - const handleEdgeUpdater = (event: React.MouseEvent, isSourceHandle: boolean) => { - // avoid triggering edge updater if mouse btn is not left - if (event.button !== 0) { - return; - } - - const { - autoPanOnConnect, - domNode, - edges, - isValidConnection, - connectionMode, - connectionRadius, - lib, - onConnectStart, - onConnectEnd, - cancelConnection, - nodes, - panBy, - updateConnection, - } = store.getState(); - const nodeId = isSourceHandle ? target : source; - const handleId = (isSourceHandle ? targetHandleId : sourceHandleId) || null; - const handleType = isSourceHandle ? 'target' : 'source'; - - const isTarget = isSourceHandle; - const edge = edges.find((e) => e.id === id)!; - - setUpdating(true); - onEdgeUpdateStart?.(event, edge, handleType); - - const _onEdgeUpdateEnd = (evt: MouseEvent | TouchEvent) => { - setUpdating(false); - onEdgeUpdateEnd?.(evt, edge, handleType); - }; - - const onConnectEdge = (connection: Connection) => onEdgeUpdate?.(edge, connection); - - XYHandle.onPointerDown(event.nativeEvent, { - autoPanOnConnect, - connectionMode, - connectionRadius, - domNode, - handleId, - nodeId, - nodes, - isTarget, - edgeUpdaterType: handleType, - lib, - cancelConnection, - panBy, - isValidConnection, - onConnect: onConnectEdge, - onConnectStart, - onConnectEnd, - onEdgeUpdateEnd: _onEdgeUpdateEnd, - updateConnection, - getTransform: () => store.getState().transform, - }); - }; - - const onEdgeUpdaterSourceMouseDown = (event: React.MouseEvent): void => - handleEdgeUpdater(event, true); - const onEdgeUpdaterTargetMouseDown = (event: React.MouseEvent): void => - handleEdgeUpdater(event, false); - - const onEdgeUpdaterMouseEnter = () => setUpdateHover(true); - const onEdgeUpdaterMouseOut = () => setUpdateHover(false); - - const inactive = !isSelectable && !onClick; + const onEdgeDoubleClick = onDoubleClick + ? (event: React.MouseEvent) => { + onDoubleClick(event, { ...edge }); + } + : undefined; + const onEdgeContextMenu = onContextMenu + ? (event: React.MouseEvent) => { + onContextMenu(event, { ...edge }); + } + : undefined; + const onEdgeMouseEnter = onMouseEnter + ? (event: React.MouseEvent) => { + onMouseEnter(event, { ...edge }); + } + : undefined; + const onEdgeMouseMove = onMouseMove + ? (event: React.MouseEvent) => { + onMouseMove(event, { ...edge }); + } + : undefined; + const onEdgeMouseLeave = onMouseLeave + ? (event: React.MouseEvent) => { + onMouseLeave(event, { ...edge }); + } + : undefined; const onKeyDown = (event: KeyboardEvent) => { if (elementSelectionKeys.includes(event.key) && isSelectable) { - const { unselectNodesAndEdges, addSelectedEdges, edges } = store.getState(); + const { unselectNodesAndEdges, addSelectedEdges } = store.getState(); const unselect = event.key === 'Escape'; if (unselect) { edgeRef.current?.blur(); - unselectNodesAndEdges({ edges: [edges.find((e) => e.id === id)!] }); + unselectNodesAndEdges({ edges: [edge] }); } else { addSelectedEdges([id]); } @@ -219,86 +177,88 @@ function EdgeWrapper({ }; return ( - - {!updating && ( - - )} - {isUpdatable && ( - <> - {(isUpdatable === 'source' || isUpdatable === true) && ( - - )} - {(isUpdatable === 'target' || isUpdatable === true) && ( - - )} - - )} - + + + {!updating && ( + + )} + {isUpdatable && ( + + )} + + ); } diff --git a/packages/react/src/components/EdgeWrapper/utils.ts b/packages/react/src/components/EdgeWrapper/utils.ts index 294592c9..0ac2a2e8 100644 --- a/packages/react/src/components/EdgeWrapper/utils.ts +++ b/packages/react/src/components/EdgeWrapper/utils.ts @@ -15,3 +15,12 @@ export const builtinEdgeTypes: EdgeTypes = { smoothstep: SmoothStepEdgeInternal as ComponentType, simplebezier: SimpleBezierEdgeInternal as ComponentType, }; + +export const nullPosition = { + sourceX: null, + sourceY: null, + targetX: null, + targetY: null, + sourcePosition: null, + targetPosition: null, +}; diff --git a/packages/react/src/components/Edges/utils.ts b/packages/react/src/components/Edges/utils.ts deleted file mode 100644 index a51b7613..00000000 --- a/packages/react/src/components/Edges/utils.ts +++ /dev/null @@ -1,20 +0,0 @@ -import type { MouseEvent as ReactMouseEvent } from 'react'; -import type { StoreApi } from 'zustand'; - -import type { Edge, ReactFlowState } from '../../types'; - -export function getMouseHandler( - id: string, - getState: StoreApi['getState'], - handler?: (event: ReactMouseEvent, edge: Edge) => void -) { - return handler === undefined - ? handler - : (event: ReactMouseEvent) => { - const edge = getState().edges.find((e) => e.id === id); - - if (edge) { - handler(event, { ...edge }); - } - }; -} diff --git a/packages/react/src/components/NodeWrapper/index.tsx b/packages/react/src/components/NodeWrapper/index.tsx index de7ce1d0..5db82d5b 100644 --- a/packages/react/src/components/NodeWrapper/index.tsx +++ b/packages/react/src/components/NodeWrapper/index.tsx @@ -17,7 +17,7 @@ import useUpdateNodePositions from '../../hooks/useUpdateNodePositions'; import { handleNodeClick } from '../Nodes/utils'; import type { NodeWrapperProps } from '../../types'; import { arrowKeyDiffs, builtinNodeTypes } from './utils'; - +import { shallow } from 'zustand/shallow'; const NodeWrapper = ({ id, onClick, @@ -40,7 +40,23 @@ const NodeWrapper = ({ nodeOrigin, onError, }: NodeWrapperProps) => { - const node = useStore((s) => s.nodeLookup.get(id)!); + const { node, positionAbsoluteX, positionAbsoluteY, zIndex, isParent } = useStore((s) => { + const node = s.nodeLookup.get(id)!; + + const positionAbsolute = nodeExtent + ? clampPosition(node.computed?.positionAbsolute, nodeExtent) + : node.computed?.positionAbsolute || { x: 0, y: 0 }; + + return { + node, + // we are mutating positionAbsolute, z and isParent attributes for sub flows + // so we we need to force a re-render when some change + positionAbsoluteX: positionAbsolute.x, + positionAbsoluteY: positionAbsolute.y, + zIndex: node[internalsSymbol]?.z ?? 0, + isParent: !!node[internalsSymbol]?.isParent, + }; + }, shallow); let nodeType = node.type || 'default'; let NodeComponent = nodeTypes?.[nodeType] || builtinNodeTypes[nodeType]; @@ -110,31 +126,22 @@ const NodeWrapper = ({ const height = node.height ?? undefined; const computedWidth = node.computed?.width; const computedHeight = node.computed?.height; - const positionAbsolute = nodeExtent - ? clampPosition(node.computed?.positionAbsolute, nodeExtent) - : node.computed?.positionAbsolute || { x: 0, y: 0 }; + const positionAbsoluteOrigin = getPositionWithOrigin({ - x: positionAbsolute.x, - y: positionAbsolute.y, + x: positionAbsoluteX, + y: positionAbsoluteY, width: computedWidth ?? width ?? 0, height: computedHeight ?? height ?? 0, origin: node.origin || nodeOrigin, }); const initialized = (!!computedWidth && !!computedHeight) || (!!width && !!height); - const zIndex = node[internalsSymbol]?.z ?? 0; - const isParent = !!node[internalsSymbol]?.isParent; const hasPointerEvents = isSelectable || isDraggable || onClick || onMouseEnter || onMouseMove || onMouseLeave; - const onMouseEnterHandler = - onMouseEnter === undefined ? undefined : (event: MouseEvent) => onMouseEnter(event, { ...node }); - const onMouseMoveHandler = - onMouseMove === undefined ? undefined : (event: MouseEvent) => onMouseMove(event, { ...node }); - const onMouseLeaveHandler = - onMouseLeave === undefined ? undefined : (event: MouseEvent) => onMouseLeave(event, { ...node }); - const onContextMenuHandler = - onContextMenu === undefined ? undefined : (event: MouseEvent) => onContextMenu(event, { ...node }); - const onDoubleClickHandler = - onDoubleClick === undefined ? undefined : (event: MouseEvent) => onDoubleClick(event, { ...node }); + const onMouseEnterHandler = onMouseEnter ? (event: MouseEvent) => onMouseEnter(event, { ...node }) : undefined; + const onMouseMoveHandler = onMouseMove ? (event: MouseEvent) => onMouseMove(event, { ...node }) : undefined; + const onMouseLeaveHandler = onMouseLeave ? (event: MouseEvent) => onMouseLeave(event, { ...node }) : undefined; + const onContextMenuHandler = onContextMenu ? (event: MouseEvent) => onContextMenu(event, { ...node }) : undefined; + const onDoubleClickHandler = onDoubleClick ? (event: MouseEvent) => onDoubleClick(event, { ...node }) : undefined; const onSelectNodeHandler = (event: MouseEvent) => { const { selectNodesOnDrag, nodeDragThreshold } = store.getState(); @@ -177,7 +184,7 @@ const NodeWrapper = ({ store.setState({ ariaLiveMessage: `Moved selected node ${event.key .replace('Arrow', '') - .toLowerCase()}. New position, x: ${~~positionAbsolute.x}, y: ${~~positionAbsolute.y}`, + .toLowerCase()}. New position, x: ${~~positionAbsoluteX}, y: ${~~positionAbsoluteY}`, }); updatePositions({ @@ -236,8 +243,8 @@ const NodeWrapper = ({ type={nodeType} width={computedWidth} height={computedHeight} - positionAbsoluteX={positionAbsolute.x} - positionAbsoluteY={positionAbsolute.y} + positionAbsoluteX={positionAbsoluteX} + positionAbsoluteY={positionAbsoluteY} selected={node.selected} isConnectable={isConnectable} sourcePosition={node.sourcePosition} diff --git a/packages/react/src/container/EdgeRenderer/MarkerDefinitions.tsx b/packages/react/src/container/EdgeRenderer/MarkerDefinitions.tsx index 8a0524ac..a4696e5b 100644 --- a/packages/react/src/container/EdgeRenderer/MarkerDefinitions.tsx +++ b/packages/react/src/container/EdgeRenderer/MarkerDefinitions.tsx @@ -51,32 +51,38 @@ const markerSelector = return markers; }; +const markersEqual = (a: MarkerProps[], b: MarkerProps[]) => + // the id includes all marker options, so we just need to look at that part of the marker + !(a.length !== b.length || a.some((m, i) => m.id !== b[i].id)); + // when you have multiple flows on a page and you hide the first one, the other ones have no markers anymore // when they do have markers with the same ids. To prevent this the user can pass a unique id to the react flow wrapper // that we can then use for creating our unique marker ids const MarkerDefinitions = ({ defaultColor, rfId }: MarkerDefinitionsProps) => { - const markers = useStore( - useCallback(markerSelector({ defaultColor, rfId }), [defaultColor, rfId]), - // the id includes all marker options, so we just need to look at that part of the marker - (a, b) => !(a.length !== b.length || a.some((m, i) => m.id !== b[i].id)) - ); + const markers = useStore(useCallback(markerSelector({ defaultColor, rfId }), [defaultColor, rfId]), markersEqual); + + if (!markers.length) { + return null; + } return ( - - {markers.map((marker: MarkerProps) => ( - - ))} - + + + {markers.map((marker: MarkerProps) => ( + + ))} + + ); }; diff --git a/packages/react/src/container/EdgeRenderer/index.tsx b/packages/react/src/container/EdgeRenderer/index.tsx index 99b53d34..83489d24 100644 --- a/packages/react/src/container/EdgeRenderer/index.tsx +++ b/packages/react/src/container/EdgeRenderer/index.tsx @@ -1,13 +1,12 @@ import { memo, ReactNode } from 'react'; import { shallow } from 'zustand/shallow'; -import cc from 'classcat'; import { useStore } from '../../hooks/useStore'; -import useVisibleEdges from '../../hooks/useVisibleEdges'; +import useVisibleEdgeIds from '../../hooks/useVisibleEdgeIds'; import MarkerDefinitions from './MarkerDefinitions'; import { GraphViewProps } from '../GraphView'; -import type { ReactFlowState } from '../../types'; import EdgeWrapper from '../../components/EdgeWrapper'; +import type { ReactFlowState } from '../../types'; type EdgeRendererProps = Pick< GraphViewProps, @@ -29,7 +28,6 @@ type EdgeRendererProps = Pick< | 'disableKeyboardA11y' | 'edgeTypes' > & { - elevateEdgesOnSelect: boolean; children: ReactNode; }; @@ -63,76 +61,40 @@ const EdgeRenderer = ({ children, }: EdgeRendererProps) => { const { edgesFocusable, edgesUpdatable, elementsSelectable, onError } = useStore(selector, shallow); - // we are grouping edges by zIndex here in order to be able to render them in the correct order - // each zIndex gets its own svg element - const edgeTree = useVisibleEdges(onlyRenderVisibleElements, elevateEdgesOnSelect); + const edgeIds = useVisibleEdgeIds(onlyRenderVisibleElements); return ( - <> - {edgeTree.map(({ level, edges, isMaxLevel }) => ( - - {isMaxLevel && } - <> - {edges.map((edge) => { - const isFocusable = !!(edge.focusable || (edgesFocusable && typeof edge.focusable === 'undefined')); - const isUpdatable = - typeof onEdgeUpdate !== 'undefined' && - (edge.updatable || (edgesUpdatable && typeof edge.updatable === 'undefined')); - const isSelectable = !!( - edge.selectable || - (elementsSelectable && typeof edge.selectable === 'undefined') - ); +
+ - return ( -
); }; diff --git a/packages/react/src/container/NodeRenderer/index.tsx b/packages/react/src/container/NodeRenderer/index.tsx index fa877984..e61b8bc5 100644 --- a/packages/react/src/container/NodeRenderer/index.tsx +++ b/packages/react/src/container/NodeRenderer/index.tsx @@ -1,7 +1,7 @@ import { memo } from 'react'; import { shallow } from 'zustand/shallow'; -import useVisibleNodesIds from '../../hooks/useVisibleNodes'; +import useVisibleNodesIds from '../../hooks/useVisibleNodeIds'; import { useStore } from '../../hooks/useStore'; import { containerStyle } from '../../styles/utils'; import { GraphViewProps } from '../GraphView'; diff --git a/packages/react/src/container/Pane/index.tsx b/packages/react/src/container/Pane/index.tsx index d5d951ca..6acd7770 100644 --- a/packages/react/src/container/Pane/index.tsx +++ b/packages/react/src/container/Pane/index.tsx @@ -10,7 +10,7 @@ import { getNodesInside, getEventPosition, SelectionMode } from '@xyflow/system' import UserSelection from '../../components/UserSelection'; import { containerStyle } from '../../styles/utils'; import { useStore, useStoreApi } from '../../hooks/useStore'; -import { getSelectionChanges, getConnectedEdges } from '../../utils'; +import { getSelectionChanges } from '../../utils'; import type { ReactFlowProps, ReactFlowState, NodeChange, EdgeChange } from '../../types'; type PaneProps = { @@ -156,19 +156,30 @@ const Pane = memo( true, nodeOrigin ); - const selectedEdgeIds = getConnectedEdges(selectedNodes, edges).map((e) => e.id); - const selectedNodeIds = selectedNodes.map((n) => n.id); - if (prevSelectedNodesCount.current !== selectedNodeIds.length) { - prevSelectedNodesCount.current = selectedNodeIds.length; - const changes = getSelectionChanges(nodes, selectedNodeIds) as NodeChange[]; + const selectedEdgeIds = new Set(); + const selectedNodeIds = new Set(); + + for (const selectedNode of selectedNodes) { + selectedNodeIds.add(selectedNode.id); + + for (const edge of edges) { + if (edge.source === selectedNode.id || edge.target === selectedNode.id) { + selectedEdgeIds.add(edge.id); + } + } + } + + if (prevSelectedNodesCount.current !== selectedNodeIds.size) { + prevSelectedNodesCount.current = selectedNodeIds.size; + const changes = getSelectionChanges(nodes, selectedNodeIds, true) as NodeChange[]; if (changes.length) { onNodesChange?.(changes); } } - if (prevSelectedEdgesCount.current !== selectedEdgeIds.length) { - prevSelectedEdgesCount.current = selectedEdgeIds.length; + if (prevSelectedEdgesCount.current !== selectedEdgeIds.size) { + prevSelectedEdgesCount.current = selectedEdgeIds.size; const changes = getSelectionChanges(edges, selectedEdgeIds) as EdgeChange[]; if (changes.length) { onEdgesChange?.(changes); diff --git a/packages/react/src/hooks/useVisibleEdgeIds.ts b/packages/react/src/hooks/useVisibleEdgeIds.ts new file mode 100644 index 00000000..eaa4eb2d --- /dev/null +++ b/packages/react/src/hooks/useVisibleEdgeIds.ts @@ -0,0 +1,49 @@ +import { useCallback } from 'react'; +import { shallow } from 'zustand/shallow'; +import { isEdgeVisible } from '@xyflow/system'; + +import { useStore } from './useStore'; +import { type ReactFlowState } from '../types'; + +function useVisibleEdgeIds(onlyRenderVisible: boolean): string[] { + const edgeIds = useStore( + useCallback( + (s: ReactFlowState) => { + if (!onlyRenderVisible) { + return s.edges.map((edge) => edge.id); + } + + const visibleEdgeIds = []; + + if (s.width && s.height) { + for (const edge of s.edges) { + const sourceNode = s.nodeLookup.get(edge.source); + const targetNode = s.nodeLookup.get(edge.target); + + if ( + sourceNode && + targetNode && + isEdgeVisible({ + sourceNode, + targetNode, + width: s.width, + height: s.height, + transform: s.transform, + }) + ) { + visibleEdgeIds.push(edge.id); + } + } + } + + return visibleEdgeIds; + }, + [onlyRenderVisible] + ), + shallow + ); + + return edgeIds; +} + +export default useVisibleEdgeIds; diff --git a/packages/react/src/hooks/useVisibleEdges.ts b/packages/react/src/hooks/useVisibleEdges.ts deleted file mode 100644 index 67d02c92..00000000 --- a/packages/react/src/hooks/useVisibleEdges.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { useCallback } from 'react'; -import { GroupedEdges, groupEdgesByZLevel, isEdgeVisible } from '@xyflow/system'; - -import { useStore } from '../hooks/useStore'; -import { Edge, type ReactFlowState } from '../types'; -import { shallow } from 'zustand/shallow'; - -function useVisibleEdges(onlyRenderVisible: boolean, elevateEdgesOnSelect: boolean): GroupedEdges[] { - const edges = useStore( - useCallback( - (s: ReactFlowState) => { - const visibleEdges = - onlyRenderVisible && s.width && s.height - ? s.edges.filter((e) => { - const sourceNode = s.nodeLookup.get(e.source); - const targetNode = s.nodeLookup.get(e.target); - - return ( - sourceNode && - targetNode && - isEdgeVisible({ - sourceNode, - targetNode, - width: s.width, - height: s.height, - transform: s.transform, - }) - ); - }) - : s.edges; - - return groupEdgesByZLevel(visibleEdges, s.nodeLookup, elevateEdgesOnSelect); - }, - [onlyRenderVisible, elevateEdgesOnSelect] - ), - (groupA, groupB) => { - const unEqual = groupA.some( - (item, index) => - item.isMaxLevel !== groupB[index].isMaxLevel || - item.level !== groupB[index].level || - !shallow(item.edges, groupB[index].edges) - ); - - return !unEqual; - } - ); - - return edges; -} - -export default useVisibleEdges; diff --git a/packages/react/src/hooks/useVisibleNodes.ts b/packages/react/src/hooks/useVisibleNodeIds.ts similarity index 93% rename from packages/react/src/hooks/useVisibleNodes.ts rename to packages/react/src/hooks/useVisibleNodeIds.ts index 2f67af02..23b29096 100644 --- a/packages/react/src/hooks/useVisibleNodes.ts +++ b/packages/react/src/hooks/useVisibleNodeIds.ts @@ -1,7 +1,7 @@ import { getNodesInside } from '@xyflow/system'; import { shallow } from 'zustand/shallow'; -import { useStore } from '../hooks/useStore'; +import { useStore } from './useStore'; import type { Node, ReactFlowState } from '../types'; import { useCallback } from 'react'; diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index ae82e78b..fdf20c9f 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -77,6 +77,7 @@ export { type CoordinateExtent, type ColorMode, type ColorModeClass, + type HandleType, } from '@xyflow/system'; // system utils diff --git a/packages/react/src/store/index.ts b/packages/react/src/store/index.ts index 166e9eed..13311a1b 100644 --- a/packages/react/src/store/index.ts +++ b/packages/react/src/store/index.ts @@ -55,12 +55,11 @@ const createRFStore = ({ set({ nodes: nodesWithInternalData }); }, setEdges: (edges: Edge[]) => { - const { defaultEdgeOptions = {}, connectionLookup } = get(); - const nextEdges = edges.map((e) => ({ ...defaultEdgeOptions, ...e })); + const { connectionLookup, edgeLookup } = get(); - updateConnectionLookup(connectionLookup, nextEdges); + updateConnectionLookup(connectionLookup, edgeLookup, edges); - set({ edges: nextEdges }); + set({ edges }); }, // when the user works with an uncontrolled flow, // we set a flag `hasDefaultNodes` / `hasDefaultEdges` @@ -79,13 +78,16 @@ const createRFStore = ({ }; if (hasDefaultNodes) { - const { nodeLookup } = get(); + const { nodeLookup, nodeOrigin, elevateNodesOnSelect } = get(); nextState.nodes = adoptUserProvidedNodes(nodes, nodeLookup, { - nodeOrigin: get().nodeOrigin, - elevateNodesOnSelect: get().elevateNodesOnSelect, + nodeOrigin, + elevateNodesOnSelect, }); } if (hasDefaultEdges) { + const { connectionLookup, edgeLookup } = get(); + updateConnectionLookup(connectionLookup, edgeLookup, edges); + nextState.edges = edges; } @@ -193,8 +195,8 @@ const createRFStore = ({ if (multiSelectionActive) { changedNodes = selectedNodeIds.map((nodeId) => createSelectionChange(nodeId, true)) as NodeSelectionChange[]; } else { - changedNodes = getSelectionChanges(nodes, selectedNodeIds); - changedEdges = getSelectionChanges(edges, []); + changedNodes = getSelectionChanges(nodes, new Set([...selectedNodeIds]), true); + changedEdges = getSelectionChanges(edges); } updateNodesAndEdgesSelections({ @@ -212,8 +214,8 @@ const createRFStore = ({ if (multiSelectionActive) { changedEdges = selectedEdgeIds.map((edgeId) => createSelectionChange(edgeId, true)) as EdgeSelectionChange[]; } else { - changedEdges = getSelectionChanges(edges, selectedEdgeIds); - changedNodes = getSelectionChanges(nodes, []); + changedEdges = getSelectionChanges(edges, new Set([...selectedEdgeIds])); + changedNodes = getSelectionChanges(nodes, new Set(), true); } updateNodesAndEdgesSelections({ diff --git a/packages/react/src/store/initialState.ts b/packages/react/src/store/initialState.ts index d9b44567..87a4fc9e 100644 --- a/packages/react/src/store/initialState.ts +++ b/packages/react/src/store/initialState.ts @@ -24,7 +24,10 @@ const getInitialState = ({ fitView?: boolean; } = {}): ReactFlowStore => { const nodeLookup = new Map(); - const connectionLookup = updateConnectionLookup(new Map(), edges); + const connectionLookup = new Map(); + const edgeLookup = new Map(); + + updateConnectionLookup(connectionLookup, edgeLookup, edges); const nextNodes = adoptUserProvidedNodes(nodes, nodeLookup, { nodeOrigin: [0, 0], elevateNodesOnSelect: false, @@ -46,7 +49,8 @@ const getInitialState = ({ transform, nodes: nextNodes, nodeLookup, - edges: edges, + edges, + edgeLookup, connectionLookup, onNodesChange: null, onEdgesChange: null, diff --git a/packages/react/src/types/edges.ts b/packages/react/src/types/edges.ts index 5af61d1e..a1a06725 100644 --- a/packages/react/src/types/edges.ts +++ b/packages/react/src/types/edges.ts @@ -34,9 +34,8 @@ export type EdgeUpdatable = boolean | HandleType; export type DefaultEdge = EdgeBase & { style?: CSSProperties; className?: string; - sourceNode?: Node; - targetNode?: Node; updatable?: EdgeUpdatable; + focusable?: boolean; } & EdgeLabelOptions; type SmoothStepEdgeType = DefaultEdge & { @@ -58,9 +57,14 @@ export type Edge = DefaultEdge | SmoothStepEdgeType | BezierEdgeT export type EdgeMouseHandler = (event: ReactMouseEvent, edge: Edge) => void; -export type EdgeWrapperProps = Omit, 'sourceHandle' | 'targetHandle'> & { +export type EdgeWrapperProps = { + id: string; + edgesFocusable: boolean; + edgesUpdatable: boolean; + elementsSelectable: boolean; + noPanClassName: string; onClick?: EdgeMouseHandler; - onEdgeDoubleClick?: EdgeMouseHandler; + onDoubleClick?: EdgeMouseHandler; sourceHandleId?: string | null; targetHandleId?: string | null; onEdgeUpdate?: OnEdgeUpdateFunc; @@ -72,12 +76,9 @@ export type EdgeWrapperProps = Omit, 'sourceHandle' | 'targetHa onEdgeUpdateStart?: (event: ReactMouseEvent, edge: Edge, handleType: HandleType) => void; onEdgeUpdateEnd?: (event: MouseEvent | TouchEvent, edge: Edge, handleType: HandleType) => void; rfId?: string; - isFocusable: boolean; - isUpdatable: EdgeUpdatable; - isSelectable: boolean; - pathOptions?: BezierPathOptions | SmoothStepPathOptions; edgeTypes?: EdgeTypes; onError?: OnError; + elevateEdgesOnSelect?: boolean; }; export type DefaultEdgeOptions = DefaultEdgeOptionsBase; @@ -93,13 +94,14 @@ export type EdgeProps = Pick< Edge, 'id' | 'animated' | 'data' | 'style' | 'selected' | 'source' | 'target' > & - Pick & + Pick & EdgePosition & EdgeLabelOptions & { markerStart?: string; markerEnd?: string; // @TODO: how can we get better types for pathOptions? pathOptions?: any; + interactionWidth?: number; }; export type BaseEdgeProps = Pick & diff --git a/packages/react/src/types/nodes.ts b/packages/react/src/types/nodes.ts index 00c87704..27ad338e 100644 --- a/packages/react/src/types/nodes.ts +++ b/packages/react/src/types/nodes.ts @@ -2,6 +2,7 @@ import type { CSSProperties, MouseEvent as ReactMouseEvent } from 'react'; import type { CoordinateExtent, NodeBase, NodeOrigin, OnError } from '@xyflow/system'; import { NodeTypes } from './general'; +// eslint-disable-next-line @typescript-eslint/no-explicit-any export type Node = NodeBase< NodeData, NodeType diff --git a/packages/react/src/types/store.ts b/packages/react/src/types/store.ts index 5aebb99d..bf5a5c11 100644 --- a/packages/react/src/types/store.ts +++ b/packages/react/src/types/store.ts @@ -24,7 +24,9 @@ import { type OnMoveEnd, type IsValidConnection, type UpdateConnection, - Connection, + type EdgeLookup, + type ConnectionLookup, + type NodeLookup, } from '@xyflow/system'; import type { @@ -48,10 +50,10 @@ export type ReactFlowStore = { height: number; transform: Transform; nodes: Node[]; - nodeLookup: Map; + nodeLookup: NodeLookup; edges: Edge[]; - connectionLookup: Map>; - + edgeLookup: EdgeLookup; + connectionLookup: ConnectionLookup; onNodesChange: OnNodesChange | null; onEdgesChange: OnEdgesChange | null; hasDefaultNodes: boolean; diff --git a/packages/react/src/utils/changes.ts b/packages/react/src/utils/changes.ts index bef54947..52db313b 100644 --- a/packages/react/src/utils/changes.ts +++ b/packages/react/src/utils/changes.ts @@ -1,5 +1,5 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ -import type { Node, Edge, EdgeChange, NodeChange } from '../types'; +import type { Node, Edge, EdgeChange, NodeChange, NodeSelectionChange, EdgeSelectionChange } from '../types'; export function handleParentExpand(res: any[], updateItem: any) { const parent = res.find((e) => e.id === updateItem.parentNode); @@ -57,12 +57,11 @@ function applyChanges(changes: any[], elements: any[]): any[] { let remainingChanges = changes; const updatedElements: any[] = []; - for (let i = 0; i < elements.length; i++) { + for (const item of elements) { const nextChanges: any[] = []; const _remainingChanges: any[] = []; - const item = elements[i]; - remainingChanges.forEach((c) => { + for (const c of remainingChanges) { if (c.type === 'add') { updatedElements.push(c.item); } else if (c.id === item.id) { @@ -70,7 +69,8 @@ function applyChanges(changes: any[], elements: any[]): any[] { } else { _remainingChanges.push(c); } - }); + } + remainingChanges = _remainingChanges; if (nextChanges.length === 0) { @@ -150,24 +150,33 @@ export function applyEdgeChanges(changes: EdgeChange[], edges: E return applyChanges(changes, edges) as Edge[]; } -export const createSelectionChange = (id: string, selected: boolean) => ({ +export const createSelectionChange = (id: string, selected: boolean): NodeSelectionChange | EdgeSelectionChange => ({ id, type: 'select', selected, }); -export function getSelectionChanges(items: any[], selectedIds: string[]) { - return items.reduce((res, item) => { - const willBeSelected = selectedIds.includes(item.id); +export function getSelectionChanges( + items: any[], + selectedIds: Set = new Set(), + mutateItem = false +): NodeSelectionChange[] | EdgeSelectionChange[] { + const changes: NodeSelectionChange[] | EdgeSelectionChange[] = []; - if (!item.selected && willBeSelected) { - item.selected = true; - res.push(createSelectionChange(item.id, true)); - } else if (item.selected && !willBeSelected) { - item.selected = false; - res.push(createSelectionChange(item.id, false)); + for (const item of items) { + const willBeSelected = selectedIds.has(item.id); + + // we don't want to set all items to selected=false on the first selection + if (!(item.selected === undefined && !willBeSelected) && item.selected !== willBeSelected) { + if (mutateItem) { + // this hack is needed for nodes. When the user dragged a node, it's selected. + // When another node gets dragged, we need to deselect the previous one, + // in order to have only one selected node at a time - the onNodesChange callback comes too late here :/ + item.selected = willBeSelected; + } + changes.push(createSelectionChange(item.id, willBeSelected)); } + } - return res; - }, []); + return changes; } diff --git a/packages/svelte/src/lib/components/EdgeWrapper/EdgeWrapper.svelte b/packages/svelte/src/lib/components/EdgeWrapper/EdgeWrapper.svelte index aad6d2b1..c68ea1d5 100644 --- a/packages/svelte/src/lib/components/EdgeWrapper/EdgeWrapper.svelte +++ b/packages/svelte/src/lib/components/EdgeWrapper/EdgeWrapper.svelte @@ -18,6 +18,7 @@ export let target: $$Props['target'] = ''; export let data: $$Props['data'] = {}; export let style: $$Props['style'] = undefined; + export let zIndex: $$Props['zIndex'] = undefined; export let animated: $$Props['animated'] = false; export let selected: $$Props['selected'] = false; @@ -95,42 +96,44 @@ {#if !hidden} - - - + + + + + {/if} diff --git a/packages/svelte/src/lib/container/EdgeRenderer/EdgeRenderer.svelte b/packages/svelte/src/lib/container/EdgeRenderer/EdgeRenderer.svelte index 27bc7d72..663b72ea 100644 --- a/packages/svelte/src/lib/container/EdgeRenderer/EdgeRenderer.svelte +++ b/packages/svelte/src/lib/container/EdgeRenderer/EdgeRenderer.svelte @@ -9,7 +9,7 @@ const { elementsSelectable, - edgeTree, + visibleEdges, edges: { setDefaultOptions } } = useStore(); @@ -18,57 +18,47 @@ }); -{#each $edgeTree as group (group.level)} - - {#if group.isMaxLevel} {/if} - - {#each group.edges as edge (edge.id)} - {@const edgeType = edge.type || 'default'} - {@const selectable = !!( - edge.selectable || - ($elementsSelectable && typeof edge.selectable === 'undefined') - )} - - - {/each} - +
+ + -{/each} - + {#each $visibleEdges as edge (edge.id)} + {@const edgeType = edge.type || 'default'} + {@const selectable = !!( + edge.selectable || + ($elementsSelectable && typeof edge.selectable === 'undefined') + )} + +
diff --git a/packages/svelte/src/lib/store/index.ts b/packages/svelte/src/lib/store/index.ts index 873d9b6f..1e76263c 100644 --- a/packages/svelte/src/lib/store/index.ts +++ b/packages/svelte/src/lib/store/index.ts @@ -24,7 +24,7 @@ import type { EdgeTypes, NodeTypes, Node, Edge, FitViewOptions, ConnectionData } import { initialEdgeTypes, initialNodeTypes, getInitialStore } from './initial-store'; import type { SvelteFlowStore } from './types'; import { syncNodeStores, syncEdgeStores, syncViewportStores } from './utils'; -import { getEdgeTree } from './edge-tree'; +import { getVisibleEdges } from './visible-edges'; import { getVisibleNodes } from './visible-nodes'; import { getDerivedConnectionProps } from './derived-connection-props'; @@ -357,8 +357,8 @@ export function createStore({ ...store, // derived state - edgeTree: getEdgeTree(store), connection: getDerivedConnectionProps(store, currentConnection), + visibleEdges: getVisibleEdges(store), visibleNodes: getVisibleNodes(store), markers: derived( [store.edges, store.defaultMarkerColor, store.flowId], diff --git a/packages/svelte/src/lib/store/initial-store.ts b/packages/svelte/src/lib/store/initial-store.ts index 97010d51..ead5652d 100644 --- a/packages/svelte/src/lib/store/initial-store.ts +++ b/packages/svelte/src/lib/store/initial-store.ts @@ -4,25 +4,25 @@ import { SelectionMode, ConnectionMode, ConnectionLineType, + devWarn, + adoptUserProvidedNodes, + getNodesBounds, + getViewportForBounds, + updateConnectionLookup, type SelectionRect, type SnapGrid, type MarkerProps, type PanZoomInstance, type CoordinateExtent, type IsValidConnection, - type GroupedEdges, type NodeOrigin, type OnError, - devWarn, type Viewport, - adoptUserProvidedNodes, - getNodesBounds, - getViewportForBounds, - updateConnectionLookup, type ConnectionLookup, type OnConnect, type OnConnectStart, - type OnConnectEnd + type OnConnectEnd, + type NodeLookup } from '@xyflow/system'; import DefaultNode from '$lib/components/nodes/DefaultNode.svelte'; @@ -82,7 +82,9 @@ export const getInitialStore = ({ nodeOrigin: [0, 0], elevateNodesOnSelect: false }); - const connectionLookup = updateConnectionLookup(new Map(), edges); + const connectionLookup = new Map(); + const edgeLookup = new Map(); + updateConnectionLookup(connectionLookup, edgeLookup, edges); let viewport: Viewport = { x: 0, y: 0, zoom: 1 }; @@ -95,10 +97,10 @@ export const getInitialStore = ({ return { flowId: writable(null), nodes: createNodesStore(nextNodes, nodeLookup), - nodeLookup: readable>(nodeLookup), + nodeLookup: readable(nodeLookup), visibleNodes: readable([]), - edges: createEdgesStore(edges, connectionLookup), - edgeTree: readable[]>([]), + edges: createEdgesStore(edges, connectionLookup, edgeLookup), + visibleEdges: readable([]), connectionLookup: readable(connectionLookup), height: writable(500), width: writable(500), diff --git a/packages/svelte/src/lib/store/utils.ts b/packages/svelte/src/lib/store/utils.ts index f8c1aef0..0f72d746 100644 --- a/packages/svelte/src/lib/store/utils.ts +++ b/packages/svelte/src/lib/store/utils.ts @@ -1,17 +1,19 @@ import { writable, + get, type Unsubscriber, type Subscriber, type Updater, - type Writable, - get + type Writable } from 'svelte/store'; import { adoptUserProvidedNodes, updateConnectionLookup, type Viewport, type PanZoomInstance, - type ConnectionLookup + type ConnectionLookup, + type EdgeLookup, + type NodeLookup } from '@xyflow/system'; import type { DefaultEdgeOptions, DefaultNodeOptions, Edge, Node } from '$lib/types'; @@ -125,7 +127,7 @@ export type NodeStoreOptions = { // The user only passes in relative positions, so we need to calculate the absolute positions based on the parent nodes. export const createNodesStore = ( nodes: Node[], - nodeLookup: Map + nodeLookup: NodeLookup ): { subscribe: (this: void, run: Subscriber) => Unsubscriber; update: (this: void, updater: Updater) => void; @@ -175,6 +177,7 @@ export const createNodesStore = ( export const createEdgesStore = ( edges: Edge[], connectionLookup: ConnectionLookup, + edgeLookup: EdgeLookup, defaultOptions?: DefaultEdgeOptions ): Writable & { setDefaultOptions: (opts: DefaultEdgeOptions) => void } => { const { subscribe, set, update } = writable([]); @@ -184,7 +187,7 @@ export const createEdgesStore = ( const _set: typeof set = (eds: Edge[]) => { const nextEdges = defaults ? eds.map((edge) => ({ ...defaults, ...edge })) : eds; - updateConnectionLookup(connectionLookup, nextEdges); + updateConnectionLookup(connectionLookup, edgeLookup, nextEdges); value = nextEdges; set(value); diff --git a/packages/svelte/src/lib/store/edge-tree.ts b/packages/svelte/src/lib/store/visible-edges.ts similarity index 83% rename from packages/svelte/src/lib/store/edge-tree.ts rename to packages/svelte/src/lib/store/visible-edges.ts index cd5448ab..d1b4a467 100644 --- a/packages/svelte/src/lib/store/edge-tree.ts +++ b/packages/svelte/src/lib/store/visible-edges.ts @@ -1,10 +1,10 @@ import { derived } from 'svelte/store'; -import { groupEdgesByZLevel, isEdgeVisible, getEdgePosition } from '@xyflow/system'; +import { isEdgeVisible, getEdgePosition, getElevatedEdgeZIndex } from '@xyflow/system'; import type { EdgeLayouted } from '$lib/types'; import type { SvelteFlowStoreState } from './types'; -export function getEdgeTree(store: SvelteFlowStoreState) { +export function getVisibleEdges(store: SvelteFlowStoreState) { const visibleEdges = derived( [ store.edges, @@ -64,6 +64,13 @@ export function getEdgeTree(store: SvelteFlowStoreState) { if (edgePosition) { res.push({ ...edge, + zIndex: getElevatedEdgeZIndex({ + selected: edge.selected, + zIndex: edge.zIndex, + sourceNode, + targetNode, + elevateOnSelect: false + }), ...edgePosition }); } @@ -71,9 +78,7 @@ export function getEdgeTree(store: SvelteFlowStoreState) { return res; }, []); - const groupedEdges = groupEdgesByZLevel(layoutedEdges, nodeLookup, false); - - return groupedEdges; + return layoutedEdges; } ); } diff --git a/packages/svelte/src/lib/types/edges.ts b/packages/svelte/src/lib/types/edges.ts index 3f1f0e8d..ecff808c 100644 --- a/packages/svelte/src/lib/types/edges.ts +++ b/packages/svelte/src/lib/types/edges.ts @@ -12,7 +12,7 @@ import type { import type { Node } from '$lib/types'; -export type DefaultEdge = Omit, 'focusable'> & { +export type DefaultEdge = EdgeBase & { label?: string; labelStyle?: string; style?: string; @@ -75,7 +75,7 @@ export type StraightEdgeProps = Omit< export type EdgeTypes = Record>>; -export type DefaultEdgeOptions = Omit, 'focusable'>; +export type DefaultEdgeOptions = DefaultEdgeOptionsBase; export type EdgeLayouted = Pick< Edge, @@ -98,6 +98,7 @@ export type EdgeLayouted = Pick< | 'ariaLabel' | 'hidden' | 'class' + | 'zIndex' > & EdgePosition & { sourceNode?: Node; diff --git a/packages/system/src/styles/init.css b/packages/system/src/styles/init.css index 7b716a9d..118d60f7 100644 --- a/packages/system/src/styles/init.css +++ b/packages/system/src/styles/init.css @@ -92,11 +92,6 @@ outline: none; } -.xy-flow .xy-flow__edges { - pointer-events: none; - overflow: visible; -} - .xy-flow__edge-path { stroke: var(--xy-edge-stroke, var(--xy-edge-stroke-default)); stroke-width: var(--xy-edge-stroke-width, var(--xy-edge-stroke-width-default)); diff --git a/packages/system/src/styles/style.css b/packages/system/src/styles/style.css index 880ebaf7..6cd4978d 100644 --- a/packages/system/src/styles/style.css +++ b/packages/system/src/styles/style.css @@ -43,6 +43,12 @@ --xy-controls-box-shadow-default: 0 0 2px 1px rgba(0, 0, 0, 0.08); } +.xy-flow__edges svg { + overflow: visible; + position: absolute; + pointer-events: none; +} + .xy-flow__edge { &.updating { .xy-flow__edge-path { diff --git a/packages/system/src/types/edges.ts b/packages/system/src/types/edges.ts index 65c8e641..c2318f0c 100644 --- a/packages/system/src/types/edges.ts +++ b/packages/system/src/types/edges.ts @@ -19,7 +19,6 @@ export type EdgeBase = { zIndex?: number; ariaLabel?: string; interactionWidth?: number; - focusable?: boolean; }; export type SmoothStepPathOptions = { @@ -37,7 +36,7 @@ export type BezierPathOptions = { export type DefaultEdgeOptionsBase = Omit< EdgeType, - 'id' | 'source' | 'target' | 'sourceHandle' | 'targetHandle' | 'sourceNode' | 'targetNode' + 'id' | 'source' | 'target' | 'sourceHandle' | 'targetHandle' | 'selected' >; export enum ConnectionLineType { @@ -77,3 +76,5 @@ export type EdgePosition = { sourcePosition: Position; targetPosition: Position; }; + +export type EdgeLookup = Map; diff --git a/packages/system/src/types/nodes.ts b/packages/system/src/types/nodes.ts index 74b294fe..35410026 100644 --- a/packages/system/src/types/nodes.ts +++ b/packages/system/src/types/nodes.ts @@ -107,3 +107,5 @@ export type OnSelectionDrag = (event: MouseEvent, nodes: NodeBase[]) => void; export type NodeHandle = Optional; export type Align = 'center' | 'start' | 'end'; + +export type NodeLookup = Map; diff --git a/packages/system/src/utils/edges/general.ts b/packages/system/src/utils/edges/general.ts index 9ffd8e24..57d93b5a 100644 --- a/packages/system/src/utils/edges/general.ts +++ b/packages/system/src/utils/edges/general.ts @@ -1,6 +1,6 @@ import { Connection, Transform, errorMessages, internalsSymbol, isEdgeBase } from '../..'; import { EdgeBase, NodeBase } from '../../types'; -import { isNumeric, getOverlappingArea, boxToRect, nodeToBox, getBoundsOfBoxes, devWarn } from '../general'; +import { getOverlappingArea, boxToRect, nodeToBox, getBoundsOfBoxes, devWarn } from '../general'; // this is used for straight edges and simple smoothstep edges (LTR, RTL, BTT, TTB) export function getEdgeCenter({ @@ -23,63 +23,29 @@ export function getEdgeCenter({ return [centerX, centerY, xOffset, yOffset]; } -const defaultEdgeTree = [{ level: 0, isMaxLevel: true, edges: [] }]; - -export type GroupedEdges = { - edges: EdgeType[]; - level: number; - isMaxLevel: boolean; +export type GetEdgeZIndexParams = { + sourceNode: NodeBase; + targetNode: NodeBase; + selected?: boolean; + zIndex?: number; + elevateOnSelect?: boolean; }; -export function groupEdgesByZLevel( - edges: EdgeType[], - nodeLookup: Map, - elevateEdgesOnSelect = false -): GroupedEdges[] { - let maxLevel = -1; - - const levelLookup = edges.reduce>((tree, edge) => { - const hasZIndex = isNumeric(edge.zIndex); - let z = hasZIndex ? edge.zIndex! : 0; - - if (elevateEdgesOnSelect) { - const targetNode = nodeLookup.get(edge.target); - const sourceNode = nodeLookup.get(edge.source); - const edgeOrConnectedNodeSelected = edge.selected || targetNode?.selected || sourceNode?.selected; - const selectedZIndex = Math.max( - sourceNode?.[internalsSymbol]?.z || 0, - targetNode?.[internalsSymbol]?.z || 0, - 1000 - ); - z = (hasZIndex ? edge.zIndex! : 0) + (edgeOrConnectedNodeSelected ? selectedZIndex : 0); - } - - if (tree[z]) { - tree[z].push(edge); - } else { - tree[z] = [edge]; - } - - maxLevel = z > maxLevel ? z : maxLevel; - - return tree; - }, {}); - - const edgeTree = Object.entries(levelLookup).map(([key, edges]) => { - const level = +key; - - return { - edges, - level, - isMaxLevel: level === maxLevel, - }; - }); - - if (edgeTree.length === 0) { - return defaultEdgeTree; +export function getElevatedEdgeZIndex({ + sourceNode, + targetNode, + selected = false, + zIndex = 0, + elevateOnSelect = false, +}: GetEdgeZIndexParams): number { + if (!elevateOnSelect) { + return zIndex; } - return edgeTree; + const edgeOrConnectedNodeSelected = selected || targetNode.selected || sourceNode.selected; + const selectedZIndex = Math.max(sourceNode[internalsSymbol]?.z || 0, targetNode[internalsSymbol]?.z || 0, 1000); + + return zIndex + (edgeOrConnectedNodeSelected ? selectedZIndex : 0); } type IsEdgeVisibleParams = { diff --git a/packages/system/src/utils/edges/positions.ts b/packages/system/src/utils/edges/positions.ts index 35e07834..f05b547e 100644 --- a/packages/system/src/utils/edges/positions.ts +++ b/packages/system/src/utils/edges/positions.ts @@ -1,7 +1,7 @@ import { EdgePosition } from '../../types/edges'; import { ConnectionMode, OnError } from '../../types/general'; -import { NodeBase, NodeHandle, NodeHandleBounds } from '../../types/nodes'; -import { Position, Rect, XYPosition } from '../../types/utils'; +import { NodeBase, NodeHandle } from '../../types/nodes'; +import { Position } from '../../types/utils'; import { errorMessages, internalsSymbol } from '../../constants'; import { HandleElement } from '../../types'; @@ -15,21 +15,28 @@ export type GetEdgePositionParams = { onError?: OnError; }; -export function getEdgePosition(params: GetEdgePositionParams): EdgePosition | null { - const [sourceNodeRect, sourceHandleBounds, isSourceValid] = getHandleDataByNode(params.sourceNode); - const [targetNodeRect, targetHandleBounds, isTargetValid] = getHandleDataByNode(params.targetNode); +function isNodeInitialized(node: NodeBase): boolean { + return !!node?.[internalsSymbol]?.handleBounds && !!node?.computed?.width; +} - if (!isSourceValid || !isTargetValid) { +export function getEdgePosition(params: GetEdgePositionParams): EdgePosition | null { + const { sourceNode, targetNode } = params; + + if (!isNodeInitialized(sourceNode) || !isNodeInitialized(targetNode)) { return null; } - // when connection type is loose we can define all handles as sources and connect source -> source - const targetNodeHandles = + const sourceHandleBounds = sourceNode[internalsSymbol]?.handleBounds || toHandleBounds(sourceNode.handles); + const targetHandleBounds = targetNode[internalsSymbol]?.handleBounds || toHandleBounds(targetNode.handles); + + const sourceHandle = getHandle(sourceHandleBounds?.source ?? [], params.sourceHandle); + const targetHandle = getHandle( + // when connection type is loose we can define all handles as sources and connect source -> source params.connectionMode === ConnectionMode.Strict - ? targetHandleBounds!.target - : (targetHandleBounds!.target ?? []).concat(targetHandleBounds!.source ?? []); - const sourceHandle = getHandle(sourceHandleBounds!.source!, params.sourceHandle); - const targetHandle = getHandle(targetNodeHandles!, params.targetHandle); + ? targetHandleBounds?.target ?? [] + : (targetHandleBounds?.target ?? []).concat(targetHandleBounds?.source ?? []), + params.targetHandle + ); const sourcePosition = sourceHandle?.position || Position.Bottom; const targetPosition = targetHandle?.position || Position.Top; @@ -46,8 +53,8 @@ export function getEdgePosition(params: GetEdgePositionParams): EdgePosition | n return null; } - const { x: sourceX, y: sourceY } = getHandlePosition(sourcePosition, sourceNodeRect, sourceHandle); - const { x: targetX, y: targetY } = getHandlePosition(targetPosition, targetNodeRect, targetHandle); + const [sourceX, sourceY] = getHandlePosition(sourcePosition, sourceNode, sourceHandle); + const [targetX, targetY] = getHandlePosition(targetPosition, targetNode, targetHandle); return { sourceX, @@ -64,79 +71,41 @@ function toHandleBounds(handles?: NodeHandle[]) { return null; } - return handles.reduce( - (res, item) => { - item.width = item.width || 1; - item.height = item.height || 1; + const source = []; + const target = []; - if (item.type === 'source') { - res.source?.push(item as HandleElement); - } + for (const handle of handles) { + handle.width = handle.width || 1; + handle.height = handle.height || 1; - if (item.type === 'target') { - res.target?.push(item as HandleElement); - } - - return res; - }, - { - source: [], - target: [], + if (handle.type === 'source') { + source.push(handle as HandleElement); + } else if (handle.type === 'target') { + target.push(handle as HandleElement); } - ); + } + + return { + source, + target, + }; } -function getHandleDataByNode(node?: NodeBase): [Rect, NodeHandleBounds | null, boolean] { - const handleBounds = node?.[internalsSymbol]?.handleBounds || toHandleBounds(node?.handles) || null; - const nodeWidth = node?.computed?.width || node?.width; - const nodeHeight = node?.computed?.height || node?.height; - - const isValid = - handleBounds && - nodeWidth && - nodeHeight && - typeof node?.computed?.positionAbsolute?.x !== 'undefined' && - typeof node?.computed?.positionAbsolute?.y !== 'undefined'; - - return [ - { - x: node?.computed?.positionAbsolute?.x || 0, - y: node?.computed?.positionAbsolute?.y || 0, - width: nodeWidth || 0, - height: nodeHeight || 0, - }, - handleBounds, - !!isValid, - ]; -} - -function getHandlePosition(position: Position, nodeRect: Rect, handle: HandleElement | null = null): XYPosition { - const x = (handle?.x || 0) + nodeRect.x; - const y = (handle?.y || 0) + nodeRect.y; - const width = handle?.width || nodeRect.width; - const height = handle?.height || nodeRect.height; +function getHandlePosition(position: Position, node: NodeBase, handle: HandleElement | null = null): number[] { + const x = (handle?.x ?? 0) + (node.computed?.positionAbsolute?.x ?? 0); + const y = (handle?.y ?? 0) + (node.computed?.positionAbsolute?.y ?? 0); + const width = handle?.width || (node?.computed?.width ?? node?.width ?? 0); + const height = handle?.height || (node?.computed?.height ?? node?.height ?? 0); switch (position) { case Position.Top: - return { - x: x + width / 2, - y, - }; + return [x + width / 2, y]; case Position.Right: - return { - x: x + width, - y: y + height / 2, - }; + return [x + width, y + height / 2]; case Position.Bottom: - return { - x: x + width / 2, - y: y + height, - }; + return [x + width / 2, y + height]; case Position.Left: - return { - x, - y: y + height / 2, - }; + return [x, y + height / 2]; } } diff --git a/packages/system/src/utils/store.ts b/packages/system/src/utils/store.ts index 96950710..8efb84c2 100644 --- a/packages/system/src/utils/store.ts +++ b/packages/system/src/utils/store.ts @@ -11,6 +11,7 @@ import { XYZPosition, ConnectionLookup, EdgeBase, + EdgeLookup, } from '../types'; import { getDimensions, getHandleBounds } from './dom'; import { isNumeric } from './general'; @@ -42,10 +43,13 @@ export function updateAbsolutePositions( parentNode?.origin || nodeOrigin ); - node.computed!.positionAbsolute = { - x, - y, - }; + const positionChanged = x !== node.computed?.positionAbsolute?.x || y !== node.computed?.positionAbsolute?.y; + node.computed!.positionAbsolute = positionChanged + ? { + x, + y, + } + : node.computed?.positionAbsolute; node[internalsSymbol]!.z = z; @@ -244,22 +248,22 @@ export function panBy({ return transformChanged; } -export function updateConnectionLookup(lookup: ConnectionLookup, edges: EdgeBase[]) { - lookup.clear(); +export function updateConnectionLookup(connectionLookup: ConnectionLookup, edgeLookup: EdgeLookup, edges: EdgeBase[]) { + connectionLookup.clear(); + edgeLookup.clear(); - edges.forEach(({ source, target, sourceHandle = null, targetHandle = null }) => { - if (source && target) { - const sourceKey = `${source}-source-${sourceHandle}`; - const targetKey = `${target}-target-${targetHandle}`; + for (const edge of edges) { + const { source, target, sourceHandle = null, targetHandle = null } = edge; - const prevSource = lookup.get(sourceKey) || new Map(); - const prevTarget = lookup.get(targetKey) || new Map(); - const connection = { source, target, sourceHandle, targetHandle }; + const sourceKey = `${source}-source-${sourceHandle}`; + const targetKey = `${target}-target-${targetHandle}`; - lookup.set(sourceKey, prevSource.set(`${target}-${targetHandle}`, connection)); - lookup.set(targetKey, prevTarget.set(`${source}-${sourceHandle}`, connection)); - } - }); + const prevSource = connectionLookup.get(sourceKey) || new Map(); + const prevTarget = connectionLookup.get(targetKey) || new Map(); + const connection = { source, target, sourceHandle, targetHandle }; - return lookup; + edgeLookup.set(edge.id, edge); + connectionLookup.set(sourceKey, prevSource.set(`${target}-${targetHandle}`, connection)); + connectionLookup.set(targetKey, prevTarget.set(`${source}-${sourceHandle}`, connection)); + } }