From 482b618f49f018c0bdc3a8626cf862afdb3a9dc3 Mon Sep 17 00:00:00 2001 From: Alessandro Date: Thu, 9 Oct 2025 16:00:06 +0200 Subject: [PATCH] initial implementation of edge toolbar --- examples/react/src/App/routes.ts | 6 ++ .../src/examples/EdgeToolbar/CustomEdge.tsx | 17 ++++ .../EdgeToolbar/SelectedNodesToolbar.tsx | 15 ++++ .../react/src/examples/EdgeToolbar/index.tsx | 86 +++++++++++++++++++ .../EdgeToolbar/EdgeToolbar.tsx | 86 +++++++++++++++++++ .../EdgeToolbar/index.tsx | 2 + .../EdgeToolbar/types.ts | 41 +++++++++ .../react/src/additional-components/index.ts | 1 + packages/system/src/utils/edge-toolbar.ts | 6 ++ packages/system/src/utils/index.ts | 1 + 10 files changed, 261 insertions(+) create mode 100644 examples/react/src/examples/EdgeToolbar/CustomEdge.tsx create mode 100644 examples/react/src/examples/EdgeToolbar/SelectedNodesToolbar.tsx create mode 100644 examples/react/src/examples/EdgeToolbar/index.tsx create mode 100644 packages/react/src/additional-components/EdgeToolbar/EdgeToolbar.tsx create mode 100644 packages/react/src/additional-components/EdgeToolbar/index.tsx create mode 100644 packages/react/src/additional-components/EdgeToolbar/types.ts create mode 100644 packages/system/src/utils/edge-toolbar.ts diff --git a/examples/react/src/App/routes.ts b/examples/react/src/App/routes.ts index 52f80979..886eecbd 100644 --- a/examples/react/src/App/routes.ts +++ b/examples/react/src/App/routes.ts @@ -50,6 +50,7 @@ import CancelConnection from '../examples/CancelConnection'; import InteractiveMinimap from '../examples/InteractiveMinimap'; import UseOnSelectionChange from '../examples/UseOnSelectionChange'; import NodeToolbar from '../examples/NodeToolbar'; +import EdgeToolbar from '../examples/EdgeToolbar'; import UseConnection from '../examples/UseConnection'; import UseNodesInitialized from '../examples/UseNodesInit'; import UseNodesData from '../examples/UseNodesData'; @@ -192,6 +193,11 @@ const routes: IRoute[] = [ path: 'edge-routing', component: EdgeRouting, }, + { + name: 'Edge Toolbar', + path: 'edge-toolbar', + component: EdgeToolbar, + }, { name: 'Empty', path: 'empty', diff --git a/examples/react/src/examples/EdgeToolbar/CustomEdge.tsx b/examples/react/src/examples/EdgeToolbar/CustomEdge.tsx new file mode 100644 index 00000000..c3e63e83 --- /dev/null +++ b/examples/react/src/examples/EdgeToolbar/CustomEdge.tsx @@ -0,0 +1,17 @@ +import { EdgeLabelRenderer, getBezierPath, BaseEdge, EdgeProps } from '@xyflow/react'; + +import { EdgeToolbar } from '@xyflow/react'; + +export function CustomEdge({ id, data, ...props }: EdgeProps) { + const [edgePath, labelX, labelY] = getBezierPath(props); + + return ( + <> + + + + + + + ); +} diff --git a/examples/react/src/examples/EdgeToolbar/SelectedNodesToolbar.tsx b/examples/react/src/examples/EdgeToolbar/SelectedNodesToolbar.tsx new file mode 100644 index 00000000..4996a45a --- /dev/null +++ b/examples/react/src/examples/EdgeToolbar/SelectedNodesToolbar.tsx @@ -0,0 +1,15 @@ +import { NodeToolbar, ReactFlowState, useStore } from '@xyflow/react'; + +const selectedNodesSelector = (state: ReactFlowState) => + state.nodes.filter((node) => node.selected).map((node) => node.id); + +export default function SelectedNodesToolbar() { + const selectedNodeIds = useStore(selectedNodesSelector); + const isVisible = selectedNodeIds.length > 1; + + return ( + + + + ); +} diff --git a/examples/react/src/examples/EdgeToolbar/index.tsx b/examples/react/src/examples/EdgeToolbar/index.tsx new file mode 100644 index 00000000..57faef24 --- /dev/null +++ b/examples/react/src/examples/EdgeToolbar/index.tsx @@ -0,0 +1,86 @@ +import { + ReactFlow, + MiniMap, + Background, + BackgroundVariant, + Controls, + Node, + Edge, + NodeTypes, + Position, + NodeOrigin, + EdgeTypes, +} from '@xyflow/react'; + +import { CustomEdge } from './CustomEdge'; +import SelectedNodesToolbar from './SelectedNodesToolbar'; + +const edgeTypes: EdgeTypes = { + custom: CustomEdge, +}; + +const initialNodes: Node[] = [ + { + id: '1', + data: { label: 'Node 1', toolbarPosition: Position.Top }, + position: { x: 0, y: 0 }, + className: 'react-flow__node-default', + }, + { + id: '2', + data: { label: 'Node 2', toolbarPosition: Position.Top }, + position: { x: 200, y: 0 }, + className: 'react-flow__node-default', + }, + { + id: '3', + data: { label: 'Node 3', toolbarPosition: Position.Top }, + position: { x: 100, y: 150 }, + className: 'react-flow__node-default', + }, +]; + +const initialEdges: Edge[] = [ + { + id: 'e1-2', + source: '1', + target: '2', + type: 'custom', + }, + { + id: 'e2-3', + source: '2', + target: '3', + type: 'custom', + }, + { + id: 'e3-1', + source: '3', + target: '1', + type: 'custom', + }, +]; + +const defaultEdgeOptions = { zIndex: 0 }; +const nodeOrigin: NodeOrigin = [0.5, 0.5]; + +export default function EdgeToolbarExample() { + return ( + + + + + + + ); +} diff --git a/packages/react/src/additional-components/EdgeToolbar/EdgeToolbar.tsx b/packages/react/src/additional-components/EdgeToolbar/EdgeToolbar.tsx new file mode 100644 index 00000000..6292eeea --- /dev/null +++ b/packages/react/src/additional-components/EdgeToolbar/EdgeToolbar.tsx @@ -0,0 +1,86 @@ +import { getEdgeToolbarTransform, Position } from '@xyflow/system'; +import cc from 'classcat'; +import { CSSProperties, useCallback } from 'react'; +import { shallow } from 'zustand/shallow'; + +import { EdgeLabelRenderer } from '../../components/EdgeLabelRenderer'; +import { useStore } from '../../hooks/useStore'; +import { Edge, ReactFlowState } from '../../types'; +import type { EdgeToolbarProps } from './types'; + +const storeSelector = (state: ReactFlowState) => ({ + x: state.transform[0], + y: state.transform[1], + zoom: state.transform[2], +}); + +export function EdgeToolbar({ + edgeId, + labelX, + labelY, + children, + className, + style, + isVisible, + position = Position.Top, + offset = 10, + align = 'center', + ...rest +}: EdgeToolbarProps) { + const edgeSelector = useCallback( + (state: ReactFlowState): Edge | undefined => { + const edge = state.edgeLookup.get(edgeId || ''); + if (edge) { + return edge; + } + + return undefined; + }, + [edgeId] + ); + + const edge = useStore(edgeSelector); + console.log('edge', edge, edgeId); + + const { x, y, zoom } = useStore(storeSelector, shallow); + + // if isVisible is not set, we show the toolbar only if its node is selected and no other node is selected + const isActive = typeof isVisible === 'boolean' ? isVisible : edge?.selected; + + if (!isActive || !edge) { + return null; + } + + // TODO: how to get the bounds of an edge? + // const edgeRect = getInternalEdgesBounds(edge.source, edge.target); + // TODO: how to get the z-index of an edge? + const zIndex = edge.zIndex ?? 0 + 1; + + const transform = getEdgeToolbarTransform(labelX, labelY, { x, y, zoom }); + console.log('transform', transform); + const wrapperStyle: CSSProperties = { + position: 'absolute', + // TODO: offset + // transform: `translate(-50%, -50%) translate(${labelX}px,${labelY}px)`, + transform, + zIndex, + ...style, + }; + + console.log('wrapperStyle', wrapperStyle); + + return ( + +
+ aaa + {children} +
+
+ ); +} diff --git a/packages/react/src/additional-components/EdgeToolbar/index.tsx b/packages/react/src/additional-components/EdgeToolbar/index.tsx new file mode 100644 index 00000000..5e1bc931 --- /dev/null +++ b/packages/react/src/additional-components/EdgeToolbar/index.tsx @@ -0,0 +1,2 @@ +export { EdgeToolbar } from './EdgeToolbar'; +export type { EdgeToolbarProps } from './types'; diff --git a/packages/react/src/additional-components/EdgeToolbar/types.ts b/packages/react/src/additional-components/EdgeToolbar/types.ts new file mode 100644 index 00000000..96261694 --- /dev/null +++ b/packages/react/src/additional-components/EdgeToolbar/types.ts @@ -0,0 +1,41 @@ +import type { HTMLAttributes } from 'react'; +import type { Position, Align } from '@xyflow/system'; + +/** + * @expand + */ +export type EdgeToolbarProps = HTMLAttributes & { + /** + * By passing in an array of edge id's you can render a single tooltip for a group or collection + * of edges. + */ + edgeId?: string; + /** If `true`, edge toolbar is visible even if edge is not selected. */ + isVisible?: boolean; + /** + * TODO: What and how?? Needed? + * Position of the toolbar relative to the edge. + * @default Position.Top + * @example Position.TopLeft, Position.TopRight, Position.BottomLeft, Position.BottomRight + */ + position?: Position; + /** + * The space between the edge and the toolbar, measured in pixels. + * @default 10 + */ + offset?: number; + /** + * Align the toolbar relative to the edge. + * @default "center" + * @example Align.Start, Align.Center, Align.End + */ + align?: Align; + /** + * The `x` position of the edge label. + */ + labelX: number; + /** + * The `y` position of the edge label. + */ + labelY: number; +}; diff --git a/packages/react/src/additional-components/index.ts b/packages/react/src/additional-components/index.ts index 94a1595e..787303a5 100644 --- a/packages/react/src/additional-components/index.ts +++ b/packages/react/src/additional-components/index.ts @@ -3,3 +3,4 @@ export * from './Controls'; export * from './MiniMap'; export * from './NodeResizer'; export * from './NodeToolbar'; +export * from './EdgeToolbar'; diff --git a/packages/system/src/utils/edge-toolbar.ts b/packages/system/src/utils/edge-toolbar.ts new file mode 100644 index 00000000..3fe2c5c2 --- /dev/null +++ b/packages/system/src/utils/edge-toolbar.ts @@ -0,0 +1,6 @@ +import { Position, type Viewport, type Align } from '..'; + +export function getEdgeToolbarTransform(labelX: number, labelY: number, viewport: Viewport): string { + // Position the toolbar at the edge label center (scaling is handled by EdgeLabelRenderer) + return `translate(${labelX}px, ${labelY}px)`; +} diff --git a/packages/system/src/utils/index.ts b/packages/system/src/utils/index.ts index 2640195d..caf8b026 100644 --- a/packages/system/src/utils/index.ts +++ b/packages/system/src/utils/index.ts @@ -5,6 +5,7 @@ export * from './graph'; export * from './general'; export * from './marker'; export * from './node-toolbar'; +export * from './edge-toolbar'; export * from './store'; export * from './types'; export * from './shallow-node-data';