diff --git a/.changeset/brave-plums-work.md b/.changeset/brave-plums-work.md new file mode 100644 index 00000000..8d6cd559 --- /dev/null +++ b/.changeset/brave-plums-work.md @@ -0,0 +1,7 @@ +--- +'@xyflow/react': minor +'@xyflow/svelte': minor +'@xyflow/system': patch +--- + +Add EdgeToolbar component 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..daac31f3 --- /dev/null +++ b/examples/react/src/examples/EdgeToolbar/CustomEdge.tsx @@ -0,0 +1,40 @@ +import { getBezierPath, BaseEdge, EdgeProps, useReactFlow, getStraightPath, getSmoothStepPath } from '@xyflow/react'; +import { EdgeToolbar } from '@xyflow/react'; + +const getPath = (props: EdgeProps) => { + switch (props.data!.type) { + case 'smoothstep': + return getSmoothStepPath(props); + case 'straight': + return getStraightPath(props); + default: + return getBezierPath(props); + } +}; + +export function CustomEdge(props: EdgeProps) { + const [edgePath, centerX, centerY] = getPath(props); + const { setEdges } = useReactFlow(); + + const deleteEdge = () => { + setEdges((edges) => edges.filter((edge) => edge.id !== props.id)); + }; + + 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..31d37ffa --- /dev/null +++ b/examples/react/src/examples/EdgeToolbar/index.tsx @@ -0,0 +1,80 @@ +import { + Background, + BackgroundVariant, + Controls, + Edge, + EdgeTypes, + MiniMap, + Node, + Position, + ReactFlow, +} from '@xyflow/react'; + +import { CustomEdge } from './CustomEdge'; + +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: 100, y: 150 }, + className: 'react-flow__node-default', + }, + { + id: '3', + data: { label: 'Node 3', toolbarPosition: Position.Top }, + position: { x: 200, y: 0 }, + className: 'react-flow__node-default', + }, +]; + +const initialEdges: Edge[] = [ + { + id: 'e1-2', + source: '1', + target: '2', + type: 'custom', + data: { type: 'smoothstep', align: ['left', 'bottom'] }, + }, + { + id: 'e3-2', + source: '3', + target: '2', + type: 'custom', + data: { type: 'bezier', align: ['right', 'bottom'] }, + }, + { + id: 'e1-3', + source: '1', + target: '3', + type: 'custom', + data: { type: 'straight', align: ['center', 'center'] }, + }, +]; + +export default function EdgeToolbarExample() { + return ( + + + + + + ); +} diff --git a/examples/svelte/src/components/Header/Header.svelte b/examples/svelte/src/components/Header/Header.svelte index 0ebe84e9..46ea8114 100644 --- a/examples/svelte/src/components/Header/Header.svelte +++ b/examples/svelte/src/components/Header/Header.svelte @@ -13,6 +13,7 @@ 'detached-handle', 'drag-n-drop', 'edges', + 'edge-toolbar', 'figma', 'handle-connect', 'interaction', diff --git a/examples/svelte/src/routes/examples/edge-toolbar/+page.svelte b/examples/svelte/src/routes/examples/edge-toolbar/+page.svelte new file mode 100644 index 00000000..40240013 --- /dev/null +++ b/examples/svelte/src/routes/examples/edge-toolbar/+page.svelte @@ -0,0 +1,60 @@ + + + + + diff --git a/examples/svelte/src/routes/examples/edge-toolbar/CustomEdge.svelte b/examples/svelte/src/routes/examples/edge-toolbar/CustomEdge.svelte new file mode 100644 index 00000000..24876c64 --- /dev/null +++ b/examples/svelte/src/routes/examples/edge-toolbar/CustomEdge.svelte @@ -0,0 +1,26 @@ + + + + + + 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..9f097741 --- /dev/null +++ b/packages/react/src/additional-components/EdgeToolbar/EdgeToolbar.tsx @@ -0,0 +1,79 @@ +import { useCallback } from 'react'; +import cc from 'classcat'; +import { shallow } from 'zustand/shallow'; +import { getEdgeToolbarTransform } from '@xyflow/system'; + +import { EdgeLabelRenderer } from '../../components/EdgeLabelRenderer'; +import { useStore } from '../../hooks/useStore'; +import { Edge, ReactFlowState } from '../../types'; +import type { EdgeToolbarProps } from './types'; + +const zoomSelector = (state: ReactFlowState) => state.transform[2]; + +/** + * This component can render a toolbar or tooltip to one side of a custom edge. This + * toolbar doesn't scale with the viewport so that the content stays the same size. + * + * @public + * @example + * ```jsx + * import { EdgeToolbar, BaseEdge, getBezierPath, type EdgeProps } from "@xyflow/react"; + * + * export function CustomEdge({ id, data, ...props }: EdgeProps) { + * const [edgePath, centerX, centerY] = getBezierPath(props); + * + * return ( + * <> + * + * + * + * + * + * ); + * } + * ``` + */ +export function EdgeToolbar({ + edgeId, + x, + y, + children, + className, + style, + isVisible, + alignX = 'center', + alignY = 'center', + ...rest +}: EdgeToolbarProps) { + const edgeSelector = useCallback((state: ReactFlowState): Edge | undefined => state.edgeLookup.get(edgeId), [edgeId]); + const edge = useStore(edgeSelector, shallow); + const isActive = typeof isVisible === 'boolean' ? isVisible : edge?.selected; + const zoom = useStore(zoomSelector); + + if (!isActive) { + return null; + } + + const zIndex = (edge?.zIndex ?? 0) + 1; + const transform = getEdgeToolbarTransform(x, y, zoom, alignX, alignY); + + return ( + +
+ {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..fb7bdfae --- /dev/null +++ b/packages/react/src/additional-components/EdgeToolbar/types.ts @@ -0,0 +1,10 @@ +import type { HTMLAttributes, ReactNode } from 'react'; +import type { EdgeToolbarBaseProps } from '@xyflow/system'; + +/** + * @inline + */ +export type EdgeToolbarProps = EdgeToolbarBaseProps & + HTMLAttributes & { + children?: ReactNode; + }; 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/svelte/src/lib/index.ts b/packages/svelte/src/lib/index.ts index 981d1442..815803f7 100644 --- a/packages/svelte/src/lib/index.ts +++ b/packages/svelte/src/lib/index.ts @@ -22,6 +22,7 @@ export * from '$lib/plugins/Controls'; export * from '$lib/plugins/Background'; export * from '$lib/plugins/Minimap'; export * from '$lib/plugins/NodeToolbar'; +export * from '$lib/plugins/EdgeToolbar'; export * from '$lib/plugins/NodeResizer'; // store diff --git a/packages/svelte/src/lib/plugins/EdgeToolbar/EdgeToolbar.svelte b/packages/svelte/src/lib/plugins/EdgeToolbar/EdgeToolbar.svelte new file mode 100644 index 00000000..290878a4 --- /dev/null +++ b/packages/svelte/src/lib/plugins/EdgeToolbar/EdgeToolbar.svelte @@ -0,0 +1,40 @@ + + +{#if store.domNode && isActive} + +
+ {@render children?.()} +
+
+{/if} diff --git a/packages/svelte/src/lib/plugins/EdgeToolbar/index.ts b/packages/svelte/src/lib/plugins/EdgeToolbar/index.ts new file mode 100644 index 00000000..602e9a61 --- /dev/null +++ b/packages/svelte/src/lib/plugins/EdgeToolbar/index.ts @@ -0,0 +1,2 @@ +export { default as EdgeToolbar } from './EdgeToolbar.svelte'; +export * from './types'; diff --git a/packages/svelte/src/lib/plugins/EdgeToolbar/types.ts b/packages/svelte/src/lib/plugins/EdgeToolbar/types.ts new file mode 100644 index 00000000..b1a63938 --- /dev/null +++ b/packages/svelte/src/lib/plugins/EdgeToolbar/types.ts @@ -0,0 +1,7 @@ +import type { EdgeToolbarBaseProps } from '@xyflow/system'; +import type { Snippet } from 'svelte'; +import type { HTMLAttributes } from 'svelte/elements'; + +export type EdgeToolbarProps = EdgeToolbarBaseProps & { + children?: Snippet; +} & HTMLAttributes; diff --git a/packages/system/src/types/edges.ts b/packages/system/src/types/edges.ts index 42678f8c..20e43eda 100644 --- a/packages/system/src/types/edges.ts +++ b/packages/system/src/types/edges.ts @@ -129,3 +129,34 @@ export type EdgePosition = { }; export type EdgeLookup = Map; + +export type EdgeToolbarBaseProps = { + /** + * An edge toolbar must be attached to an edge. + */ + edgeId: string; + /** + * The `x` position of the edge label. + */ + x: number; + /** + * The `y` position of the edge label. + */ + y: number; + /** If `true`, edge toolbar is visible even if edge is not selected. + * @default false + */ + isVisible?: boolean; + /** + * Align the vertical toolbar position relative to the passed x position. + * @default "center" + * @example 'left', 'center', 'right' + */ + alignX?: 'left' | 'center' | 'right'; + /** + * Align the horizontal toolbar position relative to the passed y position. + * @default "center" + * @example 'top', 'center', 'bottom' + */ + alignY?: 'top' | 'center' | 'bottom'; +}; diff --git a/packages/system/src/utils/edge-toolbar.ts b/packages/system/src/utils/edge-toolbar.ts new file mode 100644 index 00000000..512e318f --- /dev/null +++ b/packages/system/src/utils/edge-toolbar.ts @@ -0,0 +1,23 @@ +const alignXToPercent: Record<'left' | 'center' | 'right', number> = { + left: 0, + center: 50, + right: 100, +}; + +const alignYToPercent: Record<'top' | 'center' | 'bottom', number> = { + top: 0, + center: 50, + bottom: 100, +}; + +export function getEdgeToolbarTransform( + x: number, + y: number, + zoom: number, + alignX: 'left' | 'center' | 'right' = 'center', + alignY: 'top' | 'center' | 'bottom' = 'center' +): string { + return `translate(${x}px, ${y}px) scale(${1 / zoom}) translate(${-(alignXToPercent[alignX] ?? 50)}%, ${-( + alignYToPercent[alignY] ?? 50 + )}%)`; +} 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';