diff --git a/.changeset/sdk-self-loop-measured-height.md b/.changeset/sdk-self-loop-measured-height.md new file mode 100644 index 000000000..c70e9109c --- /dev/null +++ b/.changeset/sdk-self-loop-measured-height.md @@ -0,0 +1,5 @@ +--- +'@workflowbuilder/sdk': minor +--- + +Self-connecting edges use the measured source-node height for loop geometry and label placement. `SelfConnectingEdge` reads that height from the React Flow store when `nodeHeight` is omitted, so a custom edge that delegates to it no longer draws the loop as if the node had no height; `useSelfLoopNodeHeight` is exported alongside it. diff --git a/packages/sdk/src/features/diagram/edges/label-edge/label-edge.spec.tsx b/packages/sdk/src/features/diagram/edges/label-edge/label-edge.spec.tsx new file mode 100644 index 000000000..221ed9f26 --- /dev/null +++ b/packages/sdk/src/features/diagram/edges/label-edge/label-edge.spec.tsx @@ -0,0 +1,87 @@ +import { render, screen } from '@testing-library/react'; +import type { EdgeProps } from '@xyflow/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { WorkflowBuilderEdge } from '../../../../node/node-data'; +import { LabelEdge } from './label-edge'; + +const { nodeLookup } = vi.hoisted(() => ({ nodeLookup: new Map() })); + +vi.mock('@xyflow/react', () => ({ + getSmoothStepPath: () => ['M 0 0', 0, 0], + useStore: (selector: (state: { nodeLookup: Map }) => unknown) => selector({ nodeLookup }), +})); + +vi.mock('../edge-label-renderer/edge-label-renderer', () => ({ + EdgeLabel: ({ id, labelY }: { id: string; labelY: number }) => ( + + ), +})); + +vi.mock('../enhanced-base-edge/enhanced-base-edge', () => ({ + EnhancedBaseEdge: ({ id, path }: { id: string; path: string }) => ( + + + + ), +})); + +vi.mock('./use-label-edge-hover', () => ({ + useLabelEdgeHover: () => ({ + style: {}, + hovered: false, + onMouseEnter: vi.fn(), + onMouseLeave: vi.fn(), + }), +})); + +const selfConnectingEdgeProps = { + id: 'self-loop', + source: 'node-1', + target: 'node-1', + sourceX: 100, + sourceY: 300, + targetX: 200, + targetY: 300, + sourcePosition: 'right', + targetPosition: 'left', + data: { label: 'Loop' }, +} as EdgeProps; + +beforeEach(() => { + nodeLookup.clear(); +}); + +describe('LabelEdge', () => { + it('uses the measured source height for self-loop geometry and label placement', () => { + nodeLookup.set('node-1', { measured: { height: 80 } }); + + const { container } = render(); + + expect(container.querySelector('[data-edge-id="self-loop"]')?.getAttribute('d')).toContain('Q 125 120 109 120'); + expect(screen.getByTestId('edge-label').dataset.labelY).toBe('120'); + }); + + it('falls back to the explicit node height when no measurement exists', () => { + nodeLookup.set('node-1', { measured: {}, height: 40 }); + + render(); + + expect(screen.getByTestId('edge-label').dataset.labelY).toBe('160'); + }); + + it('does not read the node lookup for a regular edge', () => { + const get = vi.spyOn(nodeLookup, 'get'); + + render(); + + expect(get).not.toHaveBeenCalled(); + expect(screen.getByTestId('edge-label').dataset.labelY).toBe('0'); + }); + + it('treats an unknown source node as zero height', () => { + render(); + + expect(screen.getByTestId('edge-label').dataset.labelY).toBe('200'); + }); +}); diff --git a/packages/sdk/src/features/diagram/edges/label-edge/label-edge.tsx b/packages/sdk/src/features/diagram/edges/label-edge/label-edge.tsx index f25c5c69f..143c7eb0d 100644 --- a/packages/sdk/src/features/diagram/edges/label-edge/label-edge.tsx +++ b/packages/sdk/src/features/diagram/edges/label-edge/label-edge.tsx @@ -1,4 +1,4 @@ -import { type EdgeProps, getSmoothStepPath, useReactFlow } from '@xyflow/react'; +import { type EdgeProps, getSmoothStepPath } from '@xyflow/react'; import { Icon } from '@workflow-builder/icons'; @@ -6,7 +6,7 @@ import type { WorkflowBuilderEdge } from '../../../../node/node-data'; import { EdgeLabel } from '../edge-label-renderer/edge-label-renderer'; import { EDGE_CURVE_RADIUS, EDGE_OFFSET, SELF_CONNECTING_EDGE_LABEL_OFFSET } from '../edge.consts'; import { EnhancedBaseEdge } from '../enhanced-base-edge/enhanced-base-edge'; -import { SelfConnectingEdge } from '../self-connecting-edge/self-connecting-edge'; +import { SelfConnectingEdge, useSelfLoopNodeHeight } from '../self-connecting-edge/self-connecting-edge'; import { useLabelEdgeHover } from './use-label-edge-hover'; /** @@ -34,7 +34,7 @@ export function LabelEdge({ source, target, }: EdgeProps) { - const { getNode } = useReactFlow(); + const nodeHeight = useSelfLoopNodeHeight(source, target); const { style, hovered, onMouseEnter, onMouseLeave } = useLabelEdgeHover({ id, isSelected: selected, @@ -65,8 +65,6 @@ export function LabelEdge({ }; if (source === target) { - const sourceNode = getNode(source); - const nodeHeight = sourceNode?.height ?? 0; const selfConnectingLabelY = sourceY - (nodeHeight + SELF_CONNECTING_EDGE_LABEL_OFFSET); return ( @@ -83,6 +81,7 @@ export function LabelEdge({ target={target} sourcePosition={sourcePosition} targetPosition={targetPosition} + nodeHeight={nodeHeight} /> diff --git a/packages/sdk/src/features/diagram/edges/self-connecting-edge/self-connecting-edge.spec.tsx b/packages/sdk/src/features/diagram/edges/self-connecting-edge/self-connecting-edge.spec.tsx new file mode 100644 index 000000000..35a9d370b --- /dev/null +++ b/packages/sdk/src/features/diagram/edges/self-connecting-edge/self-connecting-edge.spec.tsx @@ -0,0 +1,62 @@ +import { render } from '@testing-library/react'; +import type { EdgeProps } from '@xyflow/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { WorkflowBuilderEdge } from '../../../../node/node-data'; +import { SelfConnectingEdge } from './self-connecting-edge'; + +const { nodeLookup } = vi.hoisted(() => ({ nodeLookup: new Map() })); + +vi.mock('@xyflow/react', () => ({ + useStore: (selector: (state: { nodeLookup: Map }) => unknown) => selector({ nodeLookup }), +})); + +vi.mock('@workflowbuilder/ui', () => ({ + useEdgeStyle: () => ({}), +})); + +vi.mock('../enhanced-base-edge/enhanced-base-edge', () => ({ + EnhancedBaseEdge: ({ id, path }: { id: string; path: string }) => ( + + + + ), +})); + +const loopProps = { + id: 'self-loop', + source: 'node-1', + target: 'node-1', + sourceX: 100, + sourceY: 300, + targetX: 200, + targetY: 300, + hovered: false, +} as unknown as EdgeProps & { hovered: boolean }; + +function loopApexY(container: HTMLElement) { + const d = container.querySelector('[data-edge-id="self-loop"]')?.getAttribute('d') ?? ''; + return Math.min(...[...d.matchAll(/L \d+ (\d+)/g)].map((match) => Number(match[1]))); +} + +beforeEach(() => { + nodeLookup.clear(); +}); + +describe('SelfConnectingEdge', () => { + it('reads the measured source height when nodeHeight is omitted', () => { + nodeLookup.set('node-1', { measured: { height: 80 } }); + + const { container } = render(); + + expect(loopApexY(container)).toBe(300 - (80 + 100)); + }); + + it('prefers an explicit nodeHeight over the store', () => { + nodeLookup.set('node-1', { measured: { height: 80 } }); + + const { container } = render(); + + expect(loopApexY(container)).toBe(300 - (20 + 100)); + }); +}); diff --git a/packages/sdk/src/features/diagram/edges/self-connecting-edge/self-connecting-edge.tsx b/packages/sdk/src/features/diagram/edges/self-connecting-edge/self-connecting-edge.tsx index 6181f2838..45c5761ca 100644 --- a/packages/sdk/src/features/diagram/edges/self-connecting-edge/self-connecting-edge.tsx +++ b/packages/sdk/src/features/diagram/edges/self-connecting-edge/self-connecting-edge.tsx @@ -1,15 +1,38 @@ import { type EdgeState, useEdgeStyle } from '@workflowbuilder/ui'; -import type { EdgeProps } from '@xyflow/react'; +import { type EdgeProps, useStore as useReactFlowStore } from '@xyflow/react'; import type { WorkflowBuilderEdge } from '../../../../node/node-data'; import { EDGE_CURVE_RADIUS, SELF_CONNECTING_EDGE_LABEL_OFFSET } from '../edge.consts'; import { EnhancedBaseEdge } from '../enhanced-base-edge/enhanced-base-edge'; type SelfConnectingEdgeProps = EdgeProps & { + /** + * Height of the source node, used to size the loop. Omit it to read the + * measured height from the React Flow store (the edge then has to render + * inside React Flow, which is where edges live anyway). Pass a value when + * a sibling element must stay in lockstep with the loop, as + * {@link LabelEdge} does for its label. + */ nodeHeight?: number; hovered: boolean; }; +/** + * Measured height of the source node of a self-loop, or `0` for a regular + * edge and for a node React Flow has not measured yet. Subscribes to the + * React Flow store, so the loop follows the node when it grows or shrinks. + * + * @category Hooks + */ +export function useSelfLoopNodeHeight(source: string, target: string) { + return useReactFlowStore((state) => { + if (source !== target) return 0; + const node = state.nodeLookup.get(source); + // xyflow writes measured.height on every remeasure; height only when a resize sets attributes. + return node?.measured?.height ?? node?.height ?? 0; + }); +} + type Point = { x: number; y: number; @@ -59,15 +82,18 @@ export function SelfConnectingEdge({ targetY, selected, hovered, - nodeHeight = 0, + source, + target, + nodeHeight, }: SelfConnectingEdgeProps) { + const measuredNodeHeight = useSelfLoopNodeHeight(source, target); const edgeState: EdgeState = selected ? 'selected' : 'default'; const style = useEdgeStyle({ state: edgeState, isHovered: hovered }); const path = createSelfConnectingPath( { x: sourceX, y: sourceY }, { x: targetX, y: targetY }, - nodeHeight, + nodeHeight ?? measuredNodeHeight, EDGE_CURVE_RADIUS, ); diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index e8a970338..d5ef1e83a 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -135,7 +135,10 @@ export type { DiagramContainerProps } from './features/diagram/diagram'; export { EnhancedBaseEdge } from './features/diagram/edges/enhanced-base-edge/enhanced-base-edge'; export { EdgeLabel } from './features/diagram/edges/edge-label-renderer/edge-label-renderer'; export { LabelEdge } from './features/diagram/edges/label-edge/label-edge'; -export { SelfConnectingEdge } from './features/diagram/edges/self-connecting-edge/self-connecting-edge'; +export { + SelfConnectingEdge, + useSelfLoopNodeHeight, +} from './features/diagram/edges/self-connecting-edge/self-connecting-edge'; export { NodeSection } from './features/diagram/nodes/components/node-section/node-section'; export { OptionalNodeContent } from './features/plugins-core/components/diagram/optional-node-content'; export { ProjectSelection } from './features/app-bar/components/project-selection/project-selection';