diff --git a/examples/react/src/App/routes.ts b/examples/react/src/App/routes.ts index 4bab00f5..d38f5449 100644 --- a/examples/react/src/App/routes.ts +++ b/examples/react/src/App/routes.ts @@ -21,7 +21,6 @@ import Interaction from '../examples/Interaction'; import Intersection from '../examples/Intersection'; import Layouting from '../examples/Layouting'; import MultiFlows from '../examples/MultiFlows'; -import NestedNodes from '../examples/NestedNodes'; import NodeResizer from '../examples/NodeResizer'; import NodeTypeChange from '../examples/NodeTypeChange'; import NodeTypesObjectChange from '../examples/NodeTypesObjectChange'; @@ -185,11 +184,6 @@ const routes: IRoute[] = [ path: 'multiflows', component: MultiFlows, }, - { - name: 'Nested Nodes', - path: 'nested-nodes', - component: NestedNodes, - }, { name: 'Node Type Change', path: 'nodetype-change', diff --git a/examples/react/src/examples/EasyConnect/utils.tsx b/examples/react/src/examples/EasyConnect/utils.tsx index d7f3e689..6d87795b 100644 --- a/examples/react/src/examples/EasyConnect/utils.tsx +++ b/examples/react/src/examples/EasyConnect/utils.tsx @@ -4,8 +4,12 @@ import { Node, Position, MarkerType, XYPosition } from '@xyflow/react'; // of the line between the center of the intersectionNode and the target node function getNodeIntersection(intersectionNode: Node, targetNode: Node) { // https://math.stackexchange.com/questions/1724792/an-algorithm-for-finding-the-intersection-point-between-a-center-of-vision-and-a - const { width: intersectionNodeWidth, height: intersectionNodeHeight } = intersectionNode; - const intersectionNodePosition = intersectionNode.computed?.positionAbsolute!; + + const { + width: intersectionNodeWidth, + height: intersectionNodeHeight, + positionAbsolute: intersectionNodePosition, + } = intersectionNode.computed || {}; const targetPosition = targetNode.computed?.positionAbsolute!; const w = intersectionNodeWidth! / 2; diff --git a/examples/react/src/examples/FloatingEdges/utils.ts b/examples/react/src/examples/FloatingEdges/utils.ts index 0a2d6c03..c8a6e1c1 100644 --- a/examples/react/src/examples/FloatingEdges/utils.ts +++ b/examples/react/src/examples/FloatingEdges/utils.ts @@ -5,11 +5,11 @@ import { Position, XYPosition, Node, Edge } from '@xyflow/react'; function getNodeIntersection(intersectionNode: Node, targetNode: Node): XYPosition { // https://math.stackexchange.com/questions/1724792/an-algorithm-for-finding-the-intersection-point-between-a-center-of-vision-and-a - const { - width: intersectionNodeWidth, - height: intersectionNodeHeight, - position: intersectionNodePosition, - } = intersectionNode; + const { position: intersectionNodePosition } = intersectionNode; + const { width: intersectionNodeWidth, height: intersectionNodeHeight } = intersectionNode.computed ?? { + width: 0, + height: 0, + }; const targetPosition = targetNode.position; const w = (intersectionNodeWidth ?? 0) / 2; diff --git a/examples/react/src/examples/Hidden/index.tsx b/examples/react/src/examples/Hidden/index.tsx index 586f300f..f00d8bae 100644 --- a/examples/react/src/examples/Hidden/index.tsx +++ b/examples/react/src/examples/Hidden/index.tsx @@ -48,8 +48,10 @@ const initialEdges: Edge[] = [ const setHidden = (hidden: boolean) => (els: any[]) => els.map((e: any) => { - e.hidden = hidden; - return e; + return { + ...e, + hidden, + }; }); const HiddenFlow = () => { diff --git a/examples/react/src/examples/Layouting/index.tsx b/examples/react/src/examples/Layouting/index.tsx index 4ec3e14c..7383e41e 100644 --- a/examples/react/src/examples/Layouting/index.tsx +++ b/examples/react/src/examples/Layouting/index.tsx @@ -56,16 +56,16 @@ const LayoutFlow = () => { const layoutedNodes = nodes.map((node) => { const nodeWithPosition = dagreGraph.node(node.id); - node.targetPosition = isHorizontal ? Position.Left : Position.Top; - node.sourcePosition = isHorizontal ? Position.Right : Position.Bottom; - // we need to pass a slightly different position in order to notify react flow about the change - // @TODO how can we change the position handling so that we dont need this hack? - node.position = { - x: nodeWithPosition.x + Math.random() / 1000, - y: nodeWithPosition.y, - }; - return node; + return { + ...node, + targetPosition: isHorizontal ? Position.Left : Position.Top, + sourcePosition: isHorizontal ? Position.Right : Position.Bottom, + position: { + x: nodeWithPosition.x, + y: nodeWithPosition.y, + }, + }; }); setNodes(layoutedNodes); diff --git a/examples/react/src/examples/NestedNodes/index.tsx b/examples/react/src/examples/NestedNodes/index.tsx deleted file mode 100644 index 56bc3ca8..00000000 --- a/examples/react/src/examples/NestedNodes/index.tsx +++ /dev/null @@ -1,187 +0,0 @@ -import { useState, MouseEvent, useCallback } from 'react'; -import { - ReactFlow, - Controls, - MiniMap, - Background, - addEdge, - useNodesState, - useEdgesState, - Node, - Edge, - ReactFlowInstance, - Connection, -} from '@xyflow/react'; - -const onNodeDragStop = (_: MouseEvent, node: Node) => console.log('drag stop', node); -const onNodeClick = (_: MouseEvent, node: Node) => console.log('click', node); -const onEdgeClick = (_: MouseEvent, edge: Edge) => console.log('click', edge); - -const initialNodes: Node[] = [ - { - id: '1', - type: 'input', - data: { label: 'Node 1' }, - position: { x: 250, y: 5 }, - className: 'light', - }, - { - id: '2', - data: { label: 'Node 2' }, - position: { x: 100, y: 100 }, - className: 'light', - style: { backgroundColor: 'rgba(255, 0, 0, 0.8)', width: 200, height: 200 }, - }, - { - id: '2a', - data: { label: 'Node 2a' }, - position: { x: 10, y: 50 }, - parentNode: '2', - }, - { - id: '3', - data: { label: 'Node 3' }, - position: { x: 320, y: 100 }, - className: 'light', - }, - { - id: '4', - data: { label: 'Node 4' }, - position: { x: 320, y: 200 }, - className: 'light', - style: { backgroundColor: 'rgba(255, 0, 0, 0.7)', width: 300, height: 300 }, - }, - { - id: '4a', - data: { label: 'Node 4a' }, - position: { x: 15, y: 65 }, - className: 'light', - parentNode: '4', - extent: 'parent', - }, - { - id: '4b', - data: { label: 'Node 4b' }, - position: { x: 15, y: 120 }, - className: 'light', - style: { - backgroundColor: 'rgba(255, 0, 255, 0.7)', - height: 150, - width: 270, - }, - parentNode: '4', - }, - { - id: '4b1', - data: { label: 'Node 4b1' }, - position: { x: 20, y: 40 }, - className: 'light', - parentNode: '4b', - }, - { - id: '4b2', - data: { label: 'Node 4b2' }, - position: { x: 100, y: 100 }, - className: 'light', - parentNode: '4b', - }, -]; - -const initialEdges: Edge[] = [ - { id: 'e1-2', source: '1', target: '2', animated: true }, - { id: 'e1-3', source: '1', target: '3' }, - { id: 'e2a-4a', source: '2a', target: '4a' }, - { id: 'e3-4', source: '3', target: '4' }, - { id: 'e3-4b', source: '3', target: '4b' }, - { id: 'e4a-4b1', source: '4a', target: '4b1' }, - { id: 'e4a-4b2', source: '4a', target: '4b2' }, - { id: 'e4b1-4b2', source: '4b1', target: '4b2' }, -]; - -const NestedFlow = () => { - const [rfInstance, setRfInstance] = useState(null); - const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes); - const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges); - - const onConnect = useCallback( - (connection: Connection) => { - setEdges((eds) => addEdge(connection, eds)); - }, - [setEdges] - ); - const onInit = useCallback((reactFlowInstance: ReactFlowInstance) => setRfInstance(reactFlowInstance), []); - - const updatePos = () => { - setNodes((nds) => { - return nds.map((n) => { - n.position = { - x: Math.random() * 400, - y: Math.random() * 400, - }; - - return n; - }); - }); - }; - - const logToObject = () => console.log(rfInstance?.toObject()); - const resetTransform = () => rfInstance?.setViewport({ x: 0, y: 0, zoom: 1 }); - - const toggleClassnames = () => { - setNodes((nds) => { - return nds.map((n) => { - n.className = n.className === 'light' ? 'dark' : 'light'; - return n; - }); - }); - }; - - const toggleChildNodes = () => { - setNodes((nds) => { - return nds.map((n) => { - n.hidden = !!n.parentNode && !n.hidden; - return n; - }); - }); - }; - - return ( - - - - - -
- - - - - -
-
- ); -}; - -export default NestedFlow; diff --git a/examples/react/src/examples/Stress/index.tsx b/examples/react/src/examples/Stress/index.tsx index c1c24d86..9091a687 100644 --- a/examples/react/src/examples/Stress/index.tsx +++ b/examples/react/src/examples/Stress/index.tsx @@ -64,7 +64,6 @@ const StressFlow = () => { minZoom={0.2} fitView > - diff --git a/examples/react/src/examples/Subflow/DebugNode.tsx b/examples/react/src/examples/Subflow/DebugNode.tsx index 46bae224..6a8a2d26 100644 --- a/examples/react/src/examples/Subflow/DebugNode.tsx +++ b/examples/react/src/examples/Subflow/DebugNode.tsx @@ -11,13 +11,13 @@ const idStyle: CSSProperties = { left: 2, }; -const DebugNode: FC = ({ zIndex, positionAbsolute, id }) => { +const DebugNode: FC = ({ zIndex, positionAbsoluteX, positionAbsoluteY, id }) => { return ( <>
{id}
- x:{Math.round(positionAbsolute.x)} y:{Math.round(positionAbsolute.y)} z:{zIndex} + x:{Math.round(positionAbsoluteX)} y:{Math.round(positionAbsoluteY)} z:{zIndex}
diff --git a/examples/react/src/examples/UpdateNode/index.tsx b/examples/react/src/examples/UpdateNode/index.tsx index d6e8a67a..c32fca34 100644 --- a/examples/react/src/examples/UpdateNode/index.tsx +++ b/examples/react/src/examples/UpdateNode/index.tsx @@ -24,9 +24,12 @@ const UpdateNode = () => { nds.map((n) => { if (n.id === '1') { // it's important that you create a new object here in order to notify react flow about the change - n.data = { - ...n.data, - label: nodeName, + return { + ...n, + data: { + ...n.data, + label: nodeName, + }, }; } @@ -40,7 +43,10 @@ const UpdateNode = () => { nds.map((n) => { if (n.id === '1') { // it's important that you create a new object here in order to notify react flow about the change - n.style = { ...n.style, backgroundColor: nodeBg }; + return { + ...n, + style: { ...n.style, backgroundColor: nodeBg }, + }; } return n; @@ -52,8 +58,10 @@ const UpdateNode = () => { setNodes((nds) => nds.map((n) => { if (n.id === '1' || n.id === 'e1-2') { - // when you update a simple type you can just update the value - n.hidden = nodeHidden; + return { + ...n, + hidden: nodeHidden, + }; } return n; diff --git a/examples/react/src/examples/UseHandleConnections/MultiHandleNode.tsx b/examples/react/src/examples/UseHandleConnections/MultiHandleNode.tsx index e04f3448..4301e647 100644 --- a/examples/react/src/examples/UseHandleConnections/MultiHandleNode.tsx +++ b/examples/react/src/examples/UseHandleConnections/MultiHandleNode.tsx @@ -12,8 +12,8 @@ function CustomHandle({ nodeId, ...handleProps }: HandleComponentProps & { nodeI [nodeId] ); const connections = useHandleConnections({ - handleType: handleProps.type, - handleId: handleProps.id, + type: handleProps.type, + id: handleProps.id, onConnect, onDisconnect, }); diff --git a/examples/react/src/examples/UseHandleConnections/SingleHandleNode.tsx b/examples/react/src/examples/UseHandleConnections/SingleHandleNode.tsx index 444499fb..6163f108 100644 --- a/examples/react/src/examples/UseHandleConnections/SingleHandleNode.tsx +++ b/examples/react/src/examples/UseHandleConnections/SingleHandleNode.tsx @@ -15,8 +15,8 @@ function CustomHandle({ nodeId, ...handleProps }: HandleComponentProps & { nodeI [nodeId] ); const connections = useHandleConnections({ - handleType: handleProps.type, - handleId: handleProps.id, + type: handleProps.type, + id: handleProps.id, onConnect, onDisconnect, }); diff --git a/examples/svelte/src/routes/examples/overview/CustomNode.svelte b/examples/svelte/src/routes/examples/overview/CustomNode.svelte index 1197ce62..faf6a2a5 100644 --- a/examples/svelte/src/routes/examples/overview/CustomNode.svelte +++ b/examples/svelte/src/routes/examples/overview/CustomNode.svelte @@ -1,15 +1,16 @@
{data.label}
-
{~~positionAbsolute.x}, {~~positionAbsolute.y}
+
{~~positionAbsoluteX}, {~~positionAbsoluteY}
diff --git a/packages/react/src/additional-components/MiniMap/MiniMapNodes.tsx b/packages/react/src/additional-components/MiniMap/MiniMapNodes.tsx index 80ac09be..162d0b41 100644 --- a/packages/react/src/additional-components/MiniMap/MiniMapNodes.tsx +++ b/packages/react/src/additional-components/MiniMap/MiniMapNodes.tsx @@ -1,21 +1,18 @@ /* eslint-disable @typescript-eslint/ban-ts-comment */ /* eslint-disable @typescript-eslint/no-explicit-any */ -import { memo } from 'react'; +import { ComponentType, memo } from 'react'; +import { NodeOrigin, getNodePositionWithOrigin } from '@xyflow/system'; import { shallow } from 'zustand/shallow'; -import { getNodePositionWithOrigin } from '@xyflow/system'; import { useStore } from '../../hooks/useStore'; import type { ReactFlowState } from '../../types'; import MiniMapNode from './MiniMapNode'; -import type { MiniMapNodes, GetMiniMapNodeAttribute } from './types'; +import type { MiniMapNodes as MiniMapNodesProps, GetMiniMapNodeAttribute, MiniMapNodeProps } from './types'; declare const window: any; const selector = (s: ReactFlowState) => s.nodeOrigin; -const selectorNodes = (s: ReactFlowState) => - s.nodes.filter( - (node) => !node.hidden && (node.computed?.width || node.width) && (node.computed?.height || node.height) - ); +const selectorNodeIds = (s: ReactFlowState) => s.nodes.map((node) => node.id); const getAttrFunction = (func: any): GetMiniMapNodeAttribute => (func instanceof Function ? func : () => func); function MiniMapNodes({ @@ -28,8 +25,8 @@ function MiniMapNodes({ // a component properly. nodeComponent: NodeComponent = MiniMapNode, onClick, -}: MiniMapNodes) { - const nodes = useStore(selectorNodes, shallow); +}: MiniMapNodesProps) { + const nodeIds = useStore(selectorNodeIds, shallow); const nodeOrigin = useStore(selector); const nodeColorFunc = getAttrFunction(nodeColor); const nodeStrokeColorFunc = getAttrFunction(nodeStrokeColor); @@ -39,33 +36,78 @@ function MiniMapNodes({ return ( <> - {nodes.map((node) => { - const { x, y } = getNodePositionWithOrigin(node, node.origin || nodeOrigin).positionAbsolute; - const color = nodeColor === undefined ? undefined : nodeColorFunc(node); - const strokeColor = nodeStrokeColor === undefined ? undefined : nodeStrokeColorFunc(node); - - return ( - - ); - })} + {nodeIds.map((nodeId) => ( + // The split of responsibilities between MiniMapNodes and + // NodeComponentWrapper may appear weird. However, it’s designed to + // minimize the cost of updates when individual nodes change. + // + // For more details, see a similar commit in `NodeRenderer/index.tsx`. + + ))} ); } +const NodeComponentWrapper = memo(function NodeComponentWrapper({ + id, + nodeOrigin, + nodeColorFunc, + nodeStrokeColorFunc, + nodeClassNameFunc, + nodeBorderRadius, + nodeStrokeWidth, + shapeRendering, + NodeComponent, + onClick, +}: { + id: string; + nodeOrigin: NodeOrigin; + nodeColorFunc: GetMiniMapNodeAttribute; + nodeStrokeColorFunc: GetMiniMapNodeAttribute; + nodeClassNameFunc: GetMiniMapNodeAttribute; + nodeBorderRadius: number; + nodeStrokeWidth?: number; + NodeComponent: ComponentType; + onClick: MiniMapNodesProps['onClick']; + shapeRendering: string; +}) { + const node = useStore((s) => s.nodeLookup.get(id)); + if (!node || node.hidden || !(node.computed?.width || node.width) || !(node.computed?.height || node.height)) { + return null; + } + + const positionOrigin = getNodePositionWithOrigin(node, node.origin || nodeOrigin).positionAbsolute; + + return ( + + ); +}); + export default memo(MiniMapNodes); diff --git a/packages/react/src/additional-components/NodeToolbar/NodeToolbar.tsx b/packages/react/src/additional-components/NodeToolbar/NodeToolbar.tsx index b7028a76..6f4ad13c 100644 --- a/packages/react/src/additional-components/NodeToolbar/NodeToolbar.tsx +++ b/packages/react/src/additional-components/NodeToolbar/NodeToolbar.tsx @@ -9,16 +9,20 @@ import { useNodeId } from '../../contexts/NodeIdContext'; import NodeToolbarPortal from './NodeToolbarPortal'; import { NodeToolbarProps } from './types'; -const nodeEqualityFn = (a: Node | undefined, b: Node | undefined) => - a?.computed?.positionAbsolute?.x === b?.computed?.positionAbsolute?.x && - a?.computed?.positionAbsolute?.y === b?.computed?.positionAbsolute?.y && - a?.width === b?.width && - a?.height === b?.height && - a?.selected === b?.selected && - a?.[internalsSymbol]?.z === b?.[internalsSymbol]?.z; +const nodeEqualityFn = (a?: Node, b?: Node) => + a?.computed?.positionAbsolute?.x !== b?.computed?.positionAbsolute?.x || + a?.computed?.positionAbsolute?.y !== b?.computed?.positionAbsolute?.y || + a?.computed?.width !== b?.computed?.width || + a?.computed?.height !== b?.computed?.height || + a?.selected !== b?.selected || + a?.[internalsSymbol]?.z !== b?.[internalsSymbol]?.z; const nodesEqualityFn = (a: Node[], b: Node[]) => { - return a.length === b.length && a.every((node, i) => nodeEqualityFn(node, b[i])); + if (a.length !== b.length) { + return false; + } + + return !a.some((node, i) => nodeEqualityFn(node, b[i])); }; const storeSelector = (state: ReactFlowState) => ({ diff --git a/packages/react/src/components/EdgeWrapper/index.tsx b/packages/react/src/components/EdgeWrapper/index.tsx new file mode 100644 index 00000000..929c87e0 --- /dev/null +++ b/packages/react/src/components/EdgeWrapper/index.tsx @@ -0,0 +1,307 @@ +import { memo, useState, useMemo, useRef, type KeyboardEvent, useCallback } from 'react'; +import cc from 'classcat'; +import { shallow } from 'zustand/shallow'; +import { + getMarkerId, + elementSelectionKeys, + XYHandle, + type Connection, + getEdgePosition, + errorMessages, +} 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'; + +function EdgeWrapper({ + id, + className, + type, + data, + onClick, + onEdgeDoubleClick, + selected, + animated, + label, + labelStyle, + labelShowBg, + labelBgStyle, + labelBgPadding, + labelBgBorderRadius, + style, + source, + target, + isSelectable, + hidden, + sourceHandleId, + targetHandleId, + onContextMenu, + onMouseEnter, + onMouseMove, + onMouseLeave, + edgeUpdaterRadius, + onEdgeUpdate, + onEdgeUpdateStart, + onEdgeUpdateEnd, + markerEnd, + markerStart, + rfId, + ariaLabel, + isFocusable, + isUpdatable, + pathOptions, + interactionWidth, + edgeTypes, + onError, +}: EdgeWrapperProps): JSX.Element | null { + let edgeType = type || 'default'; + let EdgeComponent = edgeTypes?.[edgeType] || builtinEdgeTypes[edgeType]; + + if (EdgeComponent === undefined) { + onError?.('011', errorMessages['error011'](edgeType)); + edgeType = 'default'; + EdgeComponent = builtinEdgeTypes.default; + } + + const edgeRef = useRef(null); + const [updateHover, setUpdateHover] = useState(false); + const [updating, setUpdating] = useState(false); + const store = useStoreApi(); + const edgePosition = useStore( + useCallback( + (state) => { + const sourceNode = state.nodeLookup.get(source); + const targetNode = state.nodeLookup.get(target); + + if (!sourceNode || !targetNode) { + return null; + } + + return getEdgePosition({ + id, + sourceNode, + targetNode, + sourceHandle: sourceHandleId || null, + targetHandle: targetHandleId || null, + connectionMode: state.connectionMode, + onError: state.onError, + }); + }, + [source, target] + ), + shallow + ); + + const markerStartUrl = useMemo(() => `url(#${getMarkerId(markerStart, rfId)})`, [markerStart, rfId]); + const markerEndUrl = useMemo(() => `url(#${getMarkerId(markerEnd, rfId)})`, [markerEnd, rfId]); + + if (hidden || !edgePosition) { + 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; + } + + if (isSelectable) { + store.setState({ nodesSelectionActive: false }); + + if (edge.selected && multiSelectionActive) { + unselectNodesAndEdges({ nodes: [], edges: [edge] }); + edgeRef.current?.blur(); + } else { + addSelectedEdges([id]); + } + } + + if (onClick) { + onClick(event, edge); + } + }; + + 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 onKeyDown = (event: KeyboardEvent) => { + if (elementSelectionKeys.includes(event.key) && isSelectable) { + const { unselectNodesAndEdges, addSelectedEdges, edges } = store.getState(); + const unselect = event.key === 'Escape'; + + if (unselect) { + edgeRef.current?.blur(); + unselectNodesAndEdges({ edges: [edges.find((e) => e.id === id)!] }); + } else { + addSelectedEdges([id]); + } + } + }; + + return ( + + {!updating && ( + + )} + {isUpdatable && ( + <> + {(isUpdatable === 'source' || isUpdatable === true) && ( + + )} + {(isUpdatable === 'target' || isUpdatable === true) && ( + + )} + + )} + + ); +} + +EdgeWrapper.displayName = 'EdgeWrapper'; + +export default memo(EdgeWrapper); diff --git a/packages/react/src/components/EdgeWrapper/utils.ts b/packages/react/src/components/EdgeWrapper/utils.ts new file mode 100644 index 00000000..294592c9 --- /dev/null +++ b/packages/react/src/components/EdgeWrapper/utils.ts @@ -0,0 +1,17 @@ +import type { ComponentType } from 'react'; +import type { EdgeProps, EdgeTypes } from '../../types'; +import { + BezierEdgeInternal, + StraightEdgeInternal, + StepEdgeInternal, + SmoothStepEdgeInternal, + SimpleBezierEdgeInternal, +} from '../Edges'; + +export const builtinEdgeTypes: EdgeTypes = { + default: BezierEdgeInternal as ComponentType, + straight: StraightEdgeInternal as ComponentType, + step: StepEdgeInternal as ComponentType, + smoothstep: SmoothStepEdgeInternal as ComponentType, + simplebezier: SimpleBezierEdgeInternal as ComponentType, +}; diff --git a/packages/react/src/components/Edges/wrapEdge.tsx b/packages/react/src/components/Edges/wrapEdge.tsx deleted file mode 100644 index b0a876b0..00000000 --- a/packages/react/src/components/Edges/wrapEdge.tsx +++ /dev/null @@ -1,290 +0,0 @@ -import { memo, useState, useMemo, useRef, type ComponentType, type KeyboardEvent, useCallback } from 'react'; -import cc from 'classcat'; -import { shallow } from 'zustand/shallow'; -import { getMarkerId, elementSelectionKeys, XYHandle, type Connection, getEdgePosition } from '@xyflow/system'; - -import { useStoreApi, useStore } from '../../hooks/useStore'; -import { ARIA_EDGE_DESC_KEY } from '../A11yDescriptions'; -import { EdgeAnchor } from './EdgeAnchor'; -import { getMouseHandler } from './utils'; -import type { EdgeProps, WrapEdgeProps } from '../../types'; - -export default (EdgeComponent: ComponentType) => { - const EdgeWrapper = ({ - id, - className, - type, - data, - onClick, - onEdgeDoubleClick, - selected, - animated, - label, - labelStyle, - labelShowBg, - labelBgStyle, - labelBgPadding, - labelBgBorderRadius, - style, - source, - target, - isSelectable, - hidden, - sourceHandleId, - targetHandleId, - onContextMenu, - onMouseEnter, - onMouseMove, - onMouseLeave, - edgeUpdaterRadius, - onEdgeUpdate, - onEdgeUpdateStart, - onEdgeUpdateEnd, - markerEnd, - markerStart, - rfId, - ariaLabel, - isFocusable, - isUpdatable, - pathOptions, - interactionWidth, - }: WrapEdgeProps): JSX.Element | null => { - const edgeRef = useRef(null); - const [updateHover, setUpdateHover] = useState(false); - const [updating, setUpdating] = useState(false); - const store = useStoreApi(); - const edgePosition = useStore( - useCallback( - (state) => { - const sourceNode = state.nodeLookup.get(source); - const targetNode = state.nodeLookup.get(target); - - if (!sourceNode || !targetNode) { - return null; - } - - return getEdgePosition({ - id, - sourceNode, - targetNode, - sourceHandle: sourceHandleId || null, - targetHandle: targetHandleId || null, - connectionMode: state.connectionMode, - onError: state.onError, - }); - }, - [source, target] - ), - shallow - ); - - const markerStartUrl = useMemo(() => `url(#${getMarkerId(markerStart, rfId)})`, [markerStart, rfId]); - const markerEndUrl = useMemo(() => `url(#${getMarkerId(markerEnd, rfId)})`, [markerEnd, rfId]); - - if (hidden || !edgePosition) { - 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; - } - - if (isSelectable) { - store.setState({ nodesSelectionActive: false }); - - if (edge.selected && multiSelectionActive) { - unselectNodesAndEdges({ nodes: [], edges: [edge] }); - edgeRef.current?.blur(); - } else { - addSelectedEdges([id]); - } - } - - if (onClick) { - onClick(event, edge); - } - }; - - 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 onKeyDown = (event: KeyboardEvent) => { - if (elementSelectionKeys.includes(event.key) && isSelectable) { - const { unselectNodesAndEdges, addSelectedEdges, edges } = store.getState(); - const unselect = event.key === 'Escape'; - - if (unselect) { - edgeRef.current?.blur(); - unselectNodesAndEdges({ edges: [edges.find((e) => e.id === id)!] }); - } else { - addSelectedEdges([id]); - } - } - }; - - return ( - - {!updating && ( - - )} - {isUpdatable && ( - <> - {(isUpdatable === 'source' || isUpdatable === true) && ( - - )} - {(isUpdatable === 'target' || isUpdatable === true) && ( - - )} - - )} - - ); - }; - - EdgeWrapper.displayName = 'EdgeWrapper'; - - return memo(EdgeWrapper); -}; diff --git a/packages/react/src/components/NodeWrapper/index.tsx b/packages/react/src/components/NodeWrapper/index.tsx new file mode 100644 index 00000000..de7ce1d0 --- /dev/null +++ b/packages/react/src/components/NodeWrapper/index.tsx @@ -0,0 +1,256 @@ +import { useEffect, useRef, memo, type MouseEvent, type KeyboardEvent } from 'react'; +import cc from 'classcat'; +import { + clampPosition, + elementSelectionKeys, + errorMessages, + getPositionWithOrigin, + internalsSymbol, + isInputDOMNode, +} from '@xyflow/system'; + +import { useStore, useStoreApi } from '../../hooks/useStore'; +import { Provider } from '../../contexts/NodeIdContext'; +import { ARIA_NODE_DESC_KEY } from '../A11yDescriptions'; +import useDrag from '../../hooks/useDrag'; +import useUpdateNodePositions from '../../hooks/useUpdateNodePositions'; +import { handleNodeClick } from '../Nodes/utils'; +import type { NodeWrapperProps } from '../../types'; +import { arrowKeyDiffs, builtinNodeTypes } from './utils'; + +const NodeWrapper = ({ + id, + onClick, + onMouseEnter, + onMouseMove, + onMouseLeave, + onContextMenu, + onDoubleClick, + nodesDraggable, + elementsSelectable, + nodesConnectable, + nodesFocusable, + resizeObserver, + noDragClassName, + noPanClassName, + disableKeyboardA11y, + rfId, + nodeTypes, + nodeExtent, + nodeOrigin, + onError, +}: NodeWrapperProps) => { + const node = useStore((s) => s.nodeLookup.get(id)!); + + let nodeType = node.type || 'default'; + let NodeComponent = nodeTypes?.[nodeType] || builtinNodeTypes[nodeType]; + + if (NodeComponent === undefined) { + onError?.('003', errorMessages['error003'](nodeType)); + nodeType = 'default'; + NodeComponent = builtinNodeTypes.default; + } + + const isDraggable = !!(node.draggable || (nodesDraggable && typeof node.draggable === 'undefined')); + const isSelectable = !!(node.selectable || (elementsSelectable && typeof node.selectable === 'undefined')); + const isConnectable = !!(node.connectable || (nodesConnectable && typeof node.connectable === 'undefined')); + const isFocusable = !!(node.focusable || (nodesFocusable && typeof node.focusable === 'undefined')); + + const store = useStoreApi(); + const nodeRef = useRef(null); + const prevSourcePosition = useRef(node.sourcePosition); + const prevTargetPosition = useRef(node.targetPosition); + const prevType = useRef(nodeType); + + const updatePositions = useUpdateNodePositions(); + + useEffect(() => { + if (nodeRef.current && !node.hidden) { + const currNode = nodeRef.current; + resizeObserver?.observe(currNode); + + return () => resizeObserver?.unobserve(currNode); + } + }, [node.hidden]); + + useEffect(() => { + // when the user programmatically changes the source or handle position, we re-initialize the node + const typeChanged = prevType.current !== nodeType; + const sourcePosChanged = prevSourcePosition.current !== node.sourcePosition; + const targetPosChanged = prevTargetPosition.current !== node.targetPosition; + + if (nodeRef.current && (typeChanged || sourcePosChanged || targetPosChanged)) { + if (typeChanged) { + prevType.current = nodeType; + } + if (sourcePosChanged) { + prevSourcePosition.current = node.sourcePosition; + } + if (targetPosChanged) { + prevTargetPosition.current = node.targetPosition; + } + store.getState().updateNodeDimensions(new Map([[id, { id, nodeElement: nodeRef.current, forceUpdate: true }]])); + } + }, [id, nodeType, node.sourcePosition, node.targetPosition]); + + const dragging = useDrag({ + nodeRef, + disabled: node.hidden || !isDraggable, + noDragClassName, + handleSelector: node.dragHandle, + nodeId: id, + isSelectable, + }); + + if (node.hidden) { + return null; + } + + const width = node.width ?? undefined; + 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, + 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 onSelectNodeHandler = (event: MouseEvent) => { + const { selectNodesOnDrag, nodeDragThreshold } = store.getState(); + + if (isSelectable && (!selectNodesOnDrag || !isDraggable || nodeDragThreshold > 0)) { + // this handler gets called by XYDrag on drag start when selectNodesOnDrag=true + // here we only need to call it when selectNodesOnDrag=false + handleNodeClick({ + id, + store, + nodeRef, + }); + } + + if (onClick) { + onClick(event, { ...node }); + } + }; + + const onKeyDown = (event: KeyboardEvent) => { + if (isInputDOMNode(event.nativeEvent)) { + return; + } + + if (elementSelectionKeys.includes(event.key) && isSelectable) { + const unselect = event.key === 'Escape'; + + handleNodeClick({ + id, + store, + unselect, + nodeRef, + }); + } else if ( + !disableKeyboardA11y && + isDraggable && + node.selected && + Object.prototype.hasOwnProperty.call(arrowKeyDiffs, event.key) + ) { + store.setState({ + ariaLiveMessage: `Moved selected node ${event.key + .replace('Arrow', '') + .toLowerCase()}. New position, x: ${~~positionAbsolute.x}, y: ${~~positionAbsolute.y}`, + }); + + updatePositions({ + x: arrowKeyDiffs[event.key].x, + y: arrowKeyDiffs[event.key].y, + isShiftPressed: event.shiftKey, + }); + } + }; + + return ( +
+ + + +
+ ); +}; + +NodeWrapper.displayName = 'NodeWrapper'; + +export default memo(NodeWrapper); diff --git a/packages/react/src/components/NodeWrapper/utils.tsx b/packages/react/src/components/NodeWrapper/utils.tsx new file mode 100644 index 00000000..6ae5f994 --- /dev/null +++ b/packages/react/src/components/NodeWrapper/utils.tsx @@ -0,0 +1,22 @@ +import type { ComponentType } from 'react'; +import type { NodeProps, XYPosition } from '@xyflow/system'; + +import InputNode from '../Nodes/InputNode'; +import DefaultNode from '../Nodes/DefaultNode'; +import GroupNode from '../Nodes/GroupNode'; +import OutputNode from '../Nodes/OutputNode'; +import type { NodeTypes } from '../../types'; + +export const arrowKeyDiffs: Record = { + ArrowUp: { x: 0, y: -1 }, + ArrowDown: { x: 0, y: 1 }, + ArrowLeft: { x: -1, y: 0 }, + ArrowRight: { x: 1, y: 0 }, +}; + +export const builtinNodeTypes: NodeTypes = { + input: InputNode as ComponentType, + default: DefaultNode as ComponentType, + output: OutputNode as ComponentType, + group: GroupNode as ComponentType, +}; diff --git a/packages/react/src/components/Nodes/utils.ts b/packages/react/src/components/Nodes/utils.ts index b9a82189..26b1f246 100644 --- a/packages/react/src/components/Nodes/utils.ts +++ b/packages/react/src/components/Nodes/utils.ts @@ -1,22 +1,9 @@ -import type { MouseEvent, RefObject } from 'react'; +import type { RefObject } from 'react'; import type { StoreApi } from 'zustand'; -import type { Node, ReactFlowState } from '../../types'; +import type { ReactFlowState } from '../../types'; import { errorMessages } from '@xyflow/system'; -export function getMouseHandler( - id: string, - getState: StoreApi['getState'], - handler?: (event: MouseEvent, node: Node) => void -) { - return handler === undefined - ? handler - : (event: MouseEvent) => { - const node = getState().nodeLookup.get(id)!; - handler(event, { ...node }); - }; -} - // this handler is called by // 1. the click handler when node is not draggable or selectNodesOnDrag = false // or diff --git a/packages/react/src/components/Nodes/wrapNode.tsx b/packages/react/src/components/Nodes/wrapNode.tsx deleted file mode 100644 index cc1537d1..00000000 --- a/packages/react/src/components/Nodes/wrapNode.tsx +++ /dev/null @@ -1,232 +0,0 @@ -import { useEffect, useRef, memo, type ComponentType, type MouseEvent, type KeyboardEvent } from 'react'; -import cc from 'classcat'; -import { elementSelectionKeys, isInputDOMNode, type NodeProps, type XYPosition } from '@xyflow/system'; - -import { useStoreApi } from '../../hooks/useStore'; -import { Provider } from '../../contexts/NodeIdContext'; -import { ARIA_NODE_DESC_KEY } from '../A11yDescriptions'; -import useDrag from '../../hooks/useDrag'; -import useUpdateNodePositions from '../../hooks/useUpdateNodePositions'; -import { getMouseHandler, handleNodeClick } from './utils'; -import type { WrapNodeProps } from '../../types'; - -export const arrowKeyDiffs: Record = { - ArrowUp: { x: 0, y: -1 }, - ArrowDown: { x: 0, y: 1 }, - ArrowLeft: { x: -1, y: 0 }, - ArrowRight: { x: 1, y: 0 }, -}; - -export default (NodeComponent: ComponentType) => { - const NodeWrapper = ({ - id, - type, - data, - xPos, - yPos, - xPosOrigin, - yPosOrigin, - selected, - onClick, - onMouseEnter, - onMouseMove, - onMouseLeave, - onContextMenu, - onDoubleClick, - style, - className, - isDraggable, - isSelectable, - isConnectable, - isFocusable, - sourcePosition, - targetPosition, - hidden, - resizeObserver, - dragHandle, - zIndex, - isParent, - noDragClassName, - noPanClassName, - initialized, - disableKeyboardA11y, - ariaLabel, - rfId, - positionAbsolute, - width, - height, - }: WrapNodeProps) => { - const store = useStoreApi(); - const nodeRef = useRef(null); - const prevSourcePosition = useRef(sourcePosition); - const prevTargetPosition = useRef(targetPosition); - const prevType = useRef(type); - const hasPointerEvents = isSelectable || isDraggable || onClick || onMouseEnter || onMouseMove || onMouseLeave; - const updatePositions = useUpdateNodePositions(); - - const onMouseEnterHandler = getMouseHandler(id, store.getState, onMouseEnter); - const onMouseMoveHandler = getMouseHandler(id, store.getState, onMouseMove); - const onMouseLeaveHandler = getMouseHandler(id, store.getState, onMouseLeave); - const onContextMenuHandler = getMouseHandler(id, store.getState, onContextMenu); - const onDoubleClickHandler = getMouseHandler(id, store.getState, onDoubleClick); - const onSelectNodeHandler = (event: MouseEvent) => { - const { selectNodesOnDrag, nodeDragThreshold } = store.getState(); - - if (isSelectable && (!selectNodesOnDrag || !isDraggable || nodeDragThreshold > 0)) { - // this handler gets called by XYDrag on drag start when selectNodesOnDrag=true - // here we only need to call it when selectNodesOnDrag=false - handleNodeClick({ - id, - store, - nodeRef, - }); - } - - if (onClick) { - const node = store.getState().nodes.find((n) => n.id === id)!; - onClick(event, { ...node }); - } - }; - - const onKeyDown = (event: KeyboardEvent) => { - if (isInputDOMNode(event.nativeEvent)) { - return; - } - - if (elementSelectionKeys.includes(event.key) && isSelectable) { - const unselect = event.key === 'Escape'; - - handleNodeClick({ - id, - store, - unselect, - nodeRef, - }); - } else if ( - !disableKeyboardA11y && - isDraggable && - selected && - Object.prototype.hasOwnProperty.call(arrowKeyDiffs, event.key) - ) { - store.setState({ - ariaLiveMessage: `Moved selected node ${event.key - .replace('Arrow', '') - .toLowerCase()}. New position, x: ${~~xPos}, y: ${~~yPos}`, - }); - - updatePositions({ - x: arrowKeyDiffs[event.key].x, - y: arrowKeyDiffs[event.key].y, - isShiftPressed: event.shiftKey, - }); - } - }; - - useEffect(() => { - if (nodeRef.current && !hidden) { - const currNode = nodeRef.current; - resizeObserver?.observe(currNode); - - return () => resizeObserver?.unobserve(currNode); - } - }, [hidden]); - - useEffect(() => { - // when the user programmatically changes the source or handle position, we re-initialize the node - const typeChanged = prevType.current !== type; - const sourcePosChanged = prevSourcePosition.current !== sourcePosition; - const targetPosChanged = prevTargetPosition.current !== targetPosition; - - if (nodeRef.current && (typeChanged || sourcePosChanged || targetPosChanged)) { - if (typeChanged) { - prevType.current = type; - } - if (sourcePosChanged) { - prevSourcePosition.current = sourcePosition; - } - if (targetPosChanged) { - prevTargetPosition.current = targetPosition; - } - store.getState().updateNodeDimensions(new Map([[id, { id, nodeElement: nodeRef.current, forceUpdate: true }]])); - } - }, [id, type, sourcePosition, targetPosition]); - - const dragging = useDrag({ - nodeRef, - disabled: hidden || !isDraggable, - noDragClassName, - handleSelector: dragHandle, - nodeId: id, - isSelectable, - }); - - if (hidden) { - return null; - } - - return ( -
- - - -
- ); - }; - - NodeWrapper.displayName = 'NodeWrapper'; - - return memo(NodeWrapper); -}; diff --git a/packages/react/src/components/NodesSelection/index.tsx b/packages/react/src/components/NodesSelection/index.tsx index ec88fccd..e79ed782 100644 --- a/packages/react/src/components/NodesSelection/index.tsx +++ b/packages/react/src/components/NodesSelection/index.tsx @@ -10,9 +10,9 @@ import { getNodesBounds } from '@xyflow/system'; import { useStore, useStoreApi } from '../../hooks/useStore'; import useDrag from '../../hooks/useDrag'; -import { arrowKeyDiffs } from '../Nodes/wrapNode'; import useUpdateNodePositions from '../../hooks/useUpdateNodePositions'; import type { Node, ReactFlowState } from '../../types'; +import { arrowKeyDiffs } from '../NodeWrapper/utils'; export type NodesSelectionProps = { onSelectionContextMenu?: (event: MouseEvent, nodes: Node[]) => void; diff --git a/packages/react/src/container/EdgeRenderer/index.tsx b/packages/react/src/container/EdgeRenderer/index.tsx index 24825fe6..99b53d34 100644 --- a/packages/react/src/container/EdgeRenderer/index.tsx +++ b/packages/react/src/container/EdgeRenderer/index.tsx @@ -1,13 +1,13 @@ import { memo, ReactNode } from 'react'; import { shallow } from 'zustand/shallow'; import cc from 'classcat'; -import { errorMessages } from '@xyflow/system'; import { useStore } from '../../hooks/useStore'; import useVisibleEdges from '../../hooks/useVisibleEdges'; import MarkerDefinitions from './MarkerDefinitions'; import { GraphViewProps } from '../GraphView'; -import type { EdgeTypesWrapped, ReactFlowState } from '../../types'; +import type { ReactFlowState } from '../../types'; +import EdgeWrapper from '../../components/EdgeWrapper'; type EdgeRendererProps = Pick< GraphViewProps, @@ -27,8 +27,8 @@ type EdgeRendererProps = Pick< | 'elevateEdgesOnSelect' | 'rfId' | 'disableKeyboardA11y' + | 'edgeTypes' > & { - edgeTypes: EdgeTypesWrapped; elevateEdgesOnSelect: boolean; children: ReactNode; }; @@ -74,14 +74,6 @@ const EdgeRenderer = ({ {isMaxLevel && } <> {edges.map((edge) => { - let edgeType = edge.type || 'default'; - - if (!edgeTypes[edgeType]) { - onError?.('011', errorMessages['error011'](edgeType)); - edgeType = 'default'; - } - - const EdgeComponent = edgeTypes[edgeType]; const isFocusable = !!(edge.focusable || (edgesFocusable && typeof edge.focusable === 'undefined')); const isUpdatable = typeof onEdgeUpdate !== 'undefined' && @@ -92,7 +84,7 @@ const EdgeRenderer = ({ ); return ( - ); })} diff --git a/packages/react/src/container/EdgeRenderer/utils.ts b/packages/react/src/container/EdgeRenderer/utils.ts deleted file mode 100644 index 433bb0f0..00000000 --- a/packages/react/src/container/EdgeRenderer/utils.ts +++ /dev/null @@ -1,37 +0,0 @@ -import type { ComponentType } from 'react'; - -import { - BezierEdgeInternal, - SmoothStepEdgeInternal, - StepEdgeInternal, - StraightEdgeInternal, - SimpleBezierEdgeInternal, -} from '../../components/Edges'; -import wrapEdge from '../../components/Edges/wrapEdge'; -import type { EdgeProps, EdgeTypes, EdgeTypesWrapped } from '../../types'; - -export type CreateEdgeTypes = (edgeTypes: EdgeTypes) => EdgeTypesWrapped; - -export function createEdgeTypes(edgeTypes: EdgeTypes): EdgeTypesWrapped { - const standardTypes: EdgeTypesWrapped = { - default: wrapEdge((edgeTypes.default || BezierEdgeInternal) as ComponentType), - straight: wrapEdge((edgeTypes.bezier || StraightEdgeInternal) as ComponentType), - step: wrapEdge((edgeTypes.step || StepEdgeInternal) as ComponentType), - smoothstep: wrapEdge((edgeTypes.step || SmoothStepEdgeInternal) as ComponentType), - simplebezier: wrapEdge((edgeTypes.simplebezier || SimpleBezierEdgeInternal) as ComponentType), - }; - - const wrappedTypes = {} as EdgeTypesWrapped; - const specialTypes: EdgeTypesWrapped = Object.keys(edgeTypes) - .filter((k) => !['default', 'bezier'].includes(k)) - .reduce((res, key) => { - res[key] = wrapEdge((edgeTypes[key] || BezierEdgeInternal) as ComponentType); - - return res; - }, wrappedTypes); - - return { - ...standardTypes, - ...specialTypes, - }; -} diff --git a/packages/react/src/container/GraphView/index.tsx b/packages/react/src/container/GraphView/index.tsx index 014d754f..116b4d26 100644 --- a/packages/react/src/container/GraphView/index.tsx +++ b/packages/react/src/container/GraphView/index.tsx @@ -8,19 +8,15 @@ import useOnInitHandler from '../../hooks/useOnInitHandler'; import useViewportSync from '../../hooks/useViewportSync'; import ConnectionLine from '../../components/ConnectionLine'; import type { ReactFlowProps } from '../../types'; -import { createNodeTypes } from '../NodeRenderer/utils'; -import { createEdgeTypes } from '../EdgeRenderer/utils'; -import { useNodeOrEdgeTypes } from './utils'; +import useNodeOrEdgeTypesWarning from './useNodeOrEdgeTypesWarning'; export type GraphViewProps = Omit< ReactFlowProps, - 'onSelectionChange' | 'nodes' | 'edges' | 'nodeTypes' | 'edgeTypes' | 'onMove' | 'onMoveStart' | 'onMoveEnd' + 'onSelectionChange' | 'nodes' | 'edges' | 'onMove' | 'onMoveStart' | 'onMoveEnd' > & Required< Pick< ReactFlowProps, - | 'nodeTypes' - | 'edgeTypes' | 'selectionKeyCode' | 'deleteKeyCode' | 'multiSelectionKeyCode' @@ -108,8 +104,8 @@ const GraphView = ({ viewport, onViewportChange, }: GraphViewProps) => { - const nodeTypesWrapped = useNodeOrEdgeTypes(nodeTypes, createNodeTypes); - const edgeTypesWrapped = useNodeOrEdgeTypes(edgeTypes, createEdgeTypes); + useNodeOrEdgeTypesWarning(nodeTypes); + useNodeOrEdgeTypesWarning(edgeTypes); useOnInitHandler(onInit); useViewportSync(viewport); @@ -154,7 +150,7 @@ const GraphView = ({ > { + if (process.env.NODE_ENV === 'development') { + if (updateCount.current > 1) { + store.getState().onError?.('002', errorMessages['error002']()); + } + updateCount.current += 1; + } + }, [nodeOrEdgeTypes]); +} diff --git a/packages/react/src/container/GraphView/utils.ts b/packages/react/src/container/GraphView/utils.ts deleted file mode 100644 index 15dbaafe..00000000 --- a/packages/react/src/container/GraphView/utils.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { useMemo, useRef } from 'react'; -import { shallow } from 'zustand/shallow'; -import { errorMessages } from '@xyflow/system'; - -import { CreateEdgeTypes } from '../EdgeRenderer/utils'; -import { CreateNodeTypes } from '../NodeRenderer/utils'; -import type { EdgeTypes, EdgeTypesWrapped, NodeTypes, NodeTypesWrapped } from '../../types'; -import { useStoreApi } from '../../hooks/useStore'; - -export function useNodeOrEdgeTypes(nodeOrEdgeTypes: NodeTypes, createTypes: CreateNodeTypes): NodeTypesWrapped; -export function useNodeOrEdgeTypes(nodeOrEdgeTypes: EdgeTypes, createTypes: CreateEdgeTypes): EdgeTypesWrapped; -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export function useNodeOrEdgeTypes(nodeOrEdgeTypes: any, createTypes: any): any { - const typesKeysRef = useRef(null); - const store = useStoreApi(); - - const typesParsed = useMemo(() => { - if (process.env.NODE_ENV === 'development') { - const typeKeys = Object.keys(nodeOrEdgeTypes); - - if (shallow(typesKeysRef.current, typeKeys)) { - store.getState().onError?.('002', errorMessages['error002']()); - } - - typesKeysRef.current = typeKeys; - } - return createTypes(nodeOrEdgeTypes); - }, [nodeOrEdgeTypes]); - - return typesParsed; -} diff --git a/packages/react/src/container/NodeRenderer/index.tsx b/packages/react/src/container/NodeRenderer/index.tsx index 73808da1..fa877984 100644 --- a/packages/react/src/container/NodeRenderer/index.tsx +++ b/packages/react/src/container/NodeRenderer/index.tsx @@ -1,14 +1,15 @@ -import { memo, useMemo, useEffect, useRef, type ComponentType } from 'react'; +import { memo } from 'react'; import { shallow } from 'zustand/shallow'; -import { internalsSymbol, errorMessages, Position, clampPosition, getPositionWithOrigin } from '@xyflow/system'; -import useVisibleNodes from '../../hooks/useVisibleNodes'; +import useVisibleNodesIds from '../../hooks/useVisibleNodes'; import { useStore } from '../../hooks/useStore'; import { containerStyle } from '../../styles/utils'; import { GraphViewProps } from '../GraphView'; -import type { NodeTypesWrapped, ReactFlowState, WrapNodeProps } from '../../types'; +import type { ReactFlowState } from '../../types'; +import useResizeObserver from './useResizeObserver'; +import NodeWrapper from '../../components/NodeWrapper'; -type NodeRendererProps = Pick< +export type NodeRendererProps = Pick< GraphViewProps, | 'onNodeClick' | 'onNodeDoubleClick' @@ -23,127 +24,71 @@ type NodeRendererProps = Pick< | 'disableKeyboardA11y' | 'nodeOrigin' | 'nodeExtent' -> & { - nodeTypes: NodeTypesWrapped; -}; + | 'nodeTypes' +>; const selector = (s: ReactFlowState) => ({ nodesDraggable: s.nodesDraggable, nodesConnectable: s.nodesConnectable, nodesFocusable: s.nodesFocusable, elementsSelectable: s.elementsSelectable, - updateNodeDimensions: s.updateNodeDimensions, onError: s.onError, }); const NodeRenderer = (props: NodeRendererProps) => { - const { nodesDraggable, nodesConnectable, nodesFocusable, elementsSelectable, updateNodeDimensions, onError } = - useStore(selector, shallow); - const nodes = useVisibleNodes(props.onlyRenderVisibleElements); - const resizeObserverRef = useRef(); - - const resizeObserver = useMemo(() => { - if (typeof ResizeObserver === 'undefined') { - return null; - } - - const observer = new ResizeObserver((entries: ResizeObserverEntry[]) => { - const updates = new Map(); - - entries.forEach((entry: ResizeObserverEntry) => { - const id = entry.target.getAttribute('data-id') as string; - updates.set(id, { - id, - nodeElement: entry.target as HTMLDivElement, - forceUpdate: true, - }); - }); - - updateNodeDimensions(updates); - }); - - resizeObserverRef.current = observer; - - return observer; - }, []); - - useEffect(() => { - return () => { - resizeObserverRef?.current?.disconnect(); - }; - }, []); + const { nodesDraggable, nodesConnectable, nodesFocusable, elementsSelectable, onError } = useStore(selector, shallow); + const nodeIds = useVisibleNodesIds(props.onlyRenderVisibleElements); + const resizeObserver = useResizeObserver(); return (
- {nodes.map((node) => { - let nodeType = node.type || 'default'; - - if (!props.nodeTypes[nodeType]) { - onError?.('003', errorMessages['error003'](nodeType)); - - nodeType = 'default'; - } - - const NodeComponent = (props.nodeTypes[nodeType] || props.nodeTypes.default) as ComponentType; - const isDraggable = !!(node.draggable || (nodesDraggable && typeof node.draggable === 'undefined')); - const isSelectable = !!(node.selectable || (elementsSelectable && typeof node.selectable === 'undefined')); - const isConnectable = !!(node.connectable || (nodesConnectable && typeof node.connectable === 'undefined')); - const isFocusable = !!(node.focusable || (nodesFocusable && typeof node.focusable === 'undefined')); - - const clampedPosition = props.nodeExtent - ? clampPosition(node.computed?.positionAbsolute, props.nodeExtent) - : node.computed?.positionAbsolute; - - const posX = clampedPosition?.x ?? 0; - const posY = clampedPosition?.y ?? 0; - const posOrigin = getPositionWithOrigin({ - x: posX, - y: posY, - width: node.computed?.width ?? node.width ?? 0, - height: node.computed?.height ?? node.height ?? 0, - origin: node.origin || props.nodeOrigin, - }); - const initialized = (!!node.computed?.width && !!node.computed?.height) || (!!node.width && !!node.height); - + {nodeIds.map((nodeId) => { return ( -