diff --git a/packages/workflow-renderer/src/workflow-block/use-action-menu-swell.test.tsx b/packages/workflow-renderer/src/workflow-block/use-action-menu-swell.test.tsx new file mode 100644 index 00000000000..47f97de5898 --- /dev/null +++ b/packages/workflow-renderer/src/workflow-block/use-action-menu-swell.test.tsx @@ -0,0 +1,162 @@ +/** + * @vitest-environment jsdom + * + * The action bar floats above the card, so the pointer crosses a gap to reach + * it and the card's own `pointerleave` fires on the way out. The hook tracks + * the pointer across that gap — and the tracking listener is the only thing + * still watching once the pointer is off the node, because no further + * `pointerleave` can arrive. + */ + +import { act, useEffect } from 'react' +import { sleep } from '@sim/utils/helpers' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeAll, describe, expect, it } from 'vitest' +import { useActionMenuSwell } from './use-action-menu-swell' + +beforeAll(() => { + globalThis.ResizeObserver = class { + observe() {} + unobserve() {} + disconnect() {} + } as unknown as typeof ResizeObserver +}) + +/** Clears the hook's 100ms hover-leave delay and its 40ms swell close with margin. */ +const SETTLE_PASS_MS = 160 +const ACTION_MENU_RECT = { left: 100, right: 240, top: 40, bottom: 70 } +/** Comfortably outside the bar's hover band, which extends 28px above `top`. */ +const AWAY_POINT = { clientX: 600, clientY: 400 } + +let container: HTMLDivElement | null = null +let root: Root | null = null + +interface HarnessProps { + onState: (state: { swellOpen: boolean }) => void +} + +/** + * Mounts the hook under a `.react-flow__node` ancestor, since the hook binds + * its hover listeners to that element rather than to its own root. + */ +function Harness({ onState }: HarnessProps) { + const { rootRef, swellOpen } = useActionMenuSwell({ enabled: true, forceOpen: false }) + + useEffect(() => { + onState({ swellOpen }) + }, [onState, swellOpen]) + + return ( +
+
+
+ ) +} + +function mount(onState: (state: { swellOpen: boolean }) => void) { + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + act(() => { + root?.render() + }) + + const node = container.querySelector('.react-flow__node') + const menu = container.querySelector('[data-testid="action-menu"]') + if (!node || !menu) throw new Error('harness did not mount') + + menu.getBoundingClientRect = () => + ({ + ...ACTION_MENU_RECT, + width: ACTION_MENU_RECT.right - ACTION_MENU_RECT.left, + height: ACTION_MENU_RECT.bottom - ACTION_MENU_RECT.top, + x: ACTION_MENU_RECT.left, + y: ACTION_MENU_RECT.top, + toJSON: () => ({}), + }) as DOMRect + + return { node, menu } +} + +function pointerEvent(type: string, init: PointerEventInit) { + return new MouseEvent(type, { bubbles: true, ...init }) as unknown as PointerEvent +} + +/** + * Drains the two chained timers the retract runs on: the 100ms hover-leave + * delay, then the 40ms swell close that its state change schedules. They need + * separate `act` passes — effects only flush when `act` exits, so the second + * timer is not even created until the first pass is over. + */ +async function settle() { + for (let pass = 0; pass < 2; pass++) { + await act(async () => { + await sleep(SETTLE_PASS_MS) + }) + } +} + +afterEach(() => { + act(() => { + root?.unmount() + }) + root = null + container?.remove() + container = null +}) + +describe('useActionMenuSwell', () => { + it('retracts after the pointer crosses the bar and keeps going', async () => { + let state = { swellOpen: false } + const { node } = mount((next) => { + state = next + }) + + act(() => { + node.dispatchEvent(pointerEvent('pointerenter', {})) + }) + expect(state.swellOpen).toBe(true) + + act(() => { + node.dispatchEvent(pointerEvent('pointerleave', {})) + }) + + /* + * One move lands inside the bar's hover band on the way out. This is the + * regression: it used to cancel the retract AND tear down the tracker, so + * nothing was left to close the bar once the pointer carried on. + */ + act(() => { + window.dispatchEvent(pointerEvent('pointermove', { clientX: 150, clientY: 50 })) + }) + expect(state.swellOpen).toBe(true) + + act(() => { + window.dispatchEvent(pointerEvent('pointermove', AWAY_POINT)) + }) + + await settle() + + expect(state.swellOpen).toBe(false) + }) + + it('stays open while the pointer rests on the bar', async () => { + let state = { swellOpen: false } + const { node } = mount((next) => { + state = next + }) + + act(() => { + node.dispatchEvent(pointerEvent('pointerenter', {})) + node.dispatchEvent(pointerEvent('pointerleave', {})) + }) + + act(() => { + window.dispatchEvent(pointerEvent('pointermove', { clientX: 150, clientY: 50 })) + }) + + await settle() + + expect(state.swellOpen).toBe(true) + }) +}) diff --git a/packages/workflow-renderer/src/workflow-block/use-action-menu-swell.ts b/packages/workflow-renderer/src/workflow-block/use-action-menu-swell.ts index 0854eea9147..c1df54d0a98 100644 --- a/packages/workflow-renderer/src/workflow-block/use-action-menu-swell.ts +++ b/packages/workflow-renderer/src/workflow-block/use-action-menu-swell.ts @@ -59,11 +59,15 @@ export function useActionMenuSwell({ useEffect(() => { if (!enabled || suspendInteraction) return - const clearHoverLeave = () => { + const cancelHoverLeaveTimeout = () => { if (hoverLeaveTimeoutRef.current !== null) { window.clearTimeout(hoverLeaveTimeoutRef.current) hoverLeaveTimeoutRef.current = null } + } + + const clearHoverLeave = () => { + cancelHoverLeaveTimeout() if (hoverMoveRef.current) { window.removeEventListener('pointermove', hoverMoveRef.current) hoverMoveRef.current = null @@ -111,33 +115,56 @@ export function useActionMenuSwell({ openHover() } + /** + * Arms the retract timer, unless one is already counting down. Never + * touches the tracking listener: once the pointer has left the node, that + * listener is the only thing still watching it, so tearing it down here + * would strand the menu open with nothing left to close it. + */ + const armHoverLeave = () => { + if (hoverLeaveTimeoutRef.current !== null) return + hoverLeaveTimeoutRef.current = window.setTimeout(() => { + hoverLeaveTimeoutRef.current = null + if (hoverMoveRef.current) { + window.removeEventListener('pointermove', hoverMoveRef.current) + hoverMoveRef.current = null + } + setIsHovered(false) + }, ACTION_MENU_HOVER_LEAVE_DELAY_MS) + } + const scheduleHoverLeave = (event: PointerEvent) => { if (isOtherNodeTarget(event.relatedTarget)) { closeHoverImmediately() return } - if (hoverLeaveTimeoutRef.current !== null) { - window.clearTimeout(hoverLeaveTimeoutRef.current) - } if (!hoverMoveRef.current) { - const onMove = (event: PointerEvent) => { - if (isOtherNodeTarget(event.target)) { + /* + * The bar floats above the card, so the pointer crosses a gap to reach + * it and the card's own `pointerleave` fires on the way. This tracks + * the pointer across that gap: inside the bar's hover band the retract + * is cancelled, outside it is re-armed. Both directions matter — no + * further `pointerleave` can arrive once the pointer is off the node, + * so re-arming here is the only way the menu ever closes again. + */ + const onMove = (moveEvent: PointerEvent) => { + if (isOtherNodeTarget(moveEvent.target)) { closeHoverImmediately() return } - if (isPointerOverActionMenu(event)) openHover() + if (isPointerOverActionMenu(moveEvent)) { + cancelHoverLeaveTimeout() + setIsHovered(true) + setSwellOpen(true) + return + } + armHoverLeave() } hoverMoveRef.current = onMove window.addEventListener('pointermove', onMove, { passive: true }) } - hoverLeaveTimeoutRef.current = window.setTimeout(() => { - hoverLeaveTimeoutRef.current = null - if (hoverMoveRef.current) { - window.removeEventListener('pointermove', hoverMoveRef.current) - hoverMoveRef.current = null - } - setIsHovered(false) - }, ACTION_MENU_HOVER_LEAVE_DELAY_MS) + cancelHoverLeaveTimeout() + armHoverLeave() } /* diff --git a/packages/workflow-renderer/src/workflow-block/workflow-block-border-connectable.test.tsx b/packages/workflow-renderer/src/workflow-block/workflow-block-border-connectable.test.tsx new file mode 100644 index 00000000000..13aba144cf6 --- /dev/null +++ b/packages/workflow-renderer/src/workflow-block/workflow-block-border-connectable.test.tsx @@ -0,0 +1,206 @@ +/** + * @vitest-environment jsdom + * + * A "port" on a card is a swell painted into the border silhouette, not a DOM + * node, so nothing about drawing one forces a handle to exist behind it. These + * cover the two directions independently: a card that mounts no source handle + * must not raise a swell under its own hover, and a card that mounts no target + * handle must not raise one under a connection dragged from somewhere else. + */ +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' +import { WorkflowBlockBorder, type WorkflowBorderPort } from '../index' + +const CARD = { left: 200, top: 100, width: 350, height: 120 } +const CARD_RECT = { + left: CARD.left, + top: CARD.top, + right: CARD.left + CARD.width, + bottom: CARD.top + CARD.height, + width: CARD.width, + height: CARD.height, + x: CARD.left, + y: CARD.top, + toJSON: () => ({}), +} as DOMRect + +/** Where a source knob sits. */ +const RIGHT_EDGE = { x: CARD.left + CARD.width, y: CARD.top + CARD.height / 2 } +/** Where a target knob sits. */ +const LEFT_EDGE = { x: CARD.left, y: CARD.top + CARD.height / 2 } + +/* + * The phantom swell these cover is the pointer-following one, which forms + * anywhere on the perimeter; leaving knobs out keeps it from magnetizing into + * one and handing the handle off, which is a different path. + */ +const PORTS: WorkflowBorderPort[] = [] + +const mountedRoots = new Set() +const mountedHosts = new Set() + +beforeAll(() => { + window.matchMedia = ((query: string) => ({ + matches: false, + media: query, + addEventListener: () => {}, + removeEventListener: () => {}, + addListener: () => {}, + removeListener: () => {}, + onchange: null, + dispatchEvent: () => false, + })) as unknown as typeof window.matchMedia +}) + +beforeEach(() => { + vi.useFakeTimers() + vi.stubGlobal( + 'ResizeObserver', + class { + observe() {} + unobserve() {} + disconnect() {} + } + ) + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => + window.setTimeout(() => callback(performance.now()), 16) + ) + vi.stubGlobal('cancelAnimationFrame', (frameId: number) => window.clearTimeout(frameId)) +}) + +afterEach(() => { + act(() => { + mountedRoots.forEach((root) => root.unmount()) + }) + mountedRoots.clear() + mountedHosts.forEach((host) => host.remove()) + mountedHosts.clear() + vi.unstubAllGlobals() + vi.useRealTimers() +}) + +interface MountOptions { + connectionNodeId?: string | null + canStartConnection?: boolean + canReceiveConnection?: boolean +} + +function mountBorder({ + connectionNodeId = null, + canStartConnection, + canReceiveConnection, +}: MountOptions) { + const onCursorHandleChange = vi.fn() + const host = document.createElement('div') + document.body.appendChild(host) + const root = createRoot(host) + mountedRoots.add(root) + mountedHosts.add(host) + + act(() => { + root.render( + connectionNodeId} + ports={PORTS} + canStartConnection={canStartConnection} + canReceiveConnection={canReceiveConnection} + hasRing={false} + ringStyles='' + width={CARD.width} + height={CARD.height} + onCursorHandleChange={onCursorHandleChange} + /> + ) + }) + + /* + * The tracker measures the swell host — the SVG's parent — and jsdom lays + * nothing out, so the card's box has to be supplied. + */ + const svg = host.querySelector('svg') + const swellHost = svg?.parentElement + if (!swellHost) throw new Error('border did not mount') + swellHost.getBoundingClientRect = () => CARD_RECT + host.getBoundingClientRect = () => CARD_RECT + document.elementFromPoint = () => swellHost + + onCursorHandleChange.mockClear() + return { swellHost, onCursorHandleChange } +} + +function pointerEvent(type: string, x: number, y: number) { + return new MouseEvent(type, { + bubbles: true, + clientX: x, + clientY: y, + }) as unknown as PointerEvent +} + +/** + * Handles the border actually offered. A `null` call is the tracker reporting + * "no swell", which is the very state these assert, so counting bare calls + * would read a correct teardown as a phantom port. + */ +function offeredHandles(spy: ReturnType) { + return spy.mock.calls.filter(([handle]) => handle != null) +} + +/** Runs the spring far enough for the swell to reach the drawn threshold. */ +function advanceSprings() { + act(() => { + vi.advanceTimersByTime(500) + }) +} + +describe('WorkflowBlockBorder connectability', () => { + it('raises a swell under its own hover by default', () => { + const { swellHost, onCursorHandleChange } = mountBorder({}) + + act(() => { + swellHost.dispatchEvent(pointerEvent('pointerenter', RIGHT_EDGE.x, RIGHT_EDGE.y)) + window.dispatchEvent(pointerEvent('pointermove', RIGHT_EDGE.x, RIGHT_EDGE.y)) + }) + advanceSprings() + + expect(offeredHandles(onCursorHandleChange).length).toBeGreaterThan(0) + }) + + it('raises no swell under its own hover when it can start no connection', () => { + const { swellHost, onCursorHandleChange } = mountBorder({ canStartConnection: false }) + + act(() => { + swellHost.dispatchEvent(pointerEvent('pointerenter', RIGHT_EDGE.x, RIGHT_EDGE.y)) + window.dispatchEvent(pointerEvent('pointermove', RIGHT_EDGE.x, RIGHT_EDGE.y)) + }) + advanceSprings() + + expect(offeredHandles(onCursorHandleChange)).toHaveLength(0) + }) + + it('raises a swell under a connection dragged from another card by default', () => { + const { onCursorHandleChange } = mountBorder({ connectionNodeId: 'other-block' }) + + act(() => { + window.dispatchEvent(pointerEvent('pointermove', LEFT_EDGE.x, LEFT_EDGE.y)) + }) + advanceSprings() + + expect(offeredHandles(onCursorHandleChange).length).toBeGreaterThan(0) + }) + + it('raises no swell under a foreign drag when it can receive no connection', () => { + const { onCursorHandleChange } = mountBorder({ + connectionNodeId: 'other-block', + canReceiveConnection: false, + }) + + act(() => { + window.dispatchEvent(pointerEvent('pointermove', LEFT_EDGE.x, LEFT_EDGE.y)) + }) + advanceSprings() + + expect(offeredHandles(onCursorHandleChange)).toHaveLength(0) + }) +}) diff --git a/packages/workflow-renderer/src/workflow-block/workflow-block-border.tsx b/packages/workflow-renderer/src/workflow-block/workflow-block-border.tsx index e649c30241d..85bca72b6fa 100644 --- a/packages/workflow-renderer/src/workflow-block/workflow-block-border.tsx +++ b/packages/workflow-renderer/src/workflow-block/workflow-block-border.tsx @@ -150,6 +150,18 @@ interface WorkflowBlockBorderProps { cursorSwellEnabled?: boolean /** Limits pointer-following swells to specific card sides. */ cursorSwellSides?: readonly WorkflowCardSide[] + /** + * Whether hovering this card may raise a swell to drag a connection OUT of + * it. False for cards that mount no source handle, so the swell never + * promises an edge the card cannot start. + */ + canStartConnection?: boolean + /** + * Whether this card may raise a swell while a connection dragged from + * another card passes over it. False for cards that mount no target handle, + * so the swell never promises a drop the card cannot accept. + */ + canReceiveConnection?: boolean radius?: number hasRing: boolean ringStyles: string @@ -879,6 +891,8 @@ export function WorkflowBlockBorder({ ports, cursorSwellEnabled = true, cursorSwellSides, + canStartConnection = true, + canReceiveConnection = true, radius = 16, hasRing, ringStyles, @@ -1435,6 +1449,13 @@ export function WorkflowBlockBorder({ // Still over the node chrome (action tab / bridge) — keep listening so // returning to an edge immediately restores the swell. if (isPointerOverTrackingRoot(clientX, clientY)) { + /* + * Only the in-band path below recomputes the magnetized port, so + * leaving the band upward onto the action bar left the last knob + * pinned at hover amplitude — a port puffed out with the pointer + * nowhere near it. + */ + hoveredPortRef.current = null cursorHoverAllowedRef.current = false cursorAmplitudeRef.current.target = 0 startAnimation() @@ -1568,7 +1589,7 @@ export function WorkflowBlockBorder({ updatePointerTargetRef.current = updatePointerTarget resetPointerTrackingRef.current = stopPointerTracking - if (cursorSwellEnabled) { + if (cursorSwellEnabled && canStartConnection) { trackingRoot.addEventListener('pointerenter', onPointerEnter) trackingRoot.addEventListener('pointerleave', onPointerLeave) trackingRoot.addEventListener('pointerdown', onPointerDown, true) @@ -1609,13 +1630,19 @@ export function WorkflowBlockBorder({ if (frameRef.current !== null) cancelAnimationFrame(frameRef.current) frameRef.current = null } - }, [cursorSwellEnabled, cursorSwellSides]) + }, [canStartConnection, cursorSwellEnabled, cursorSwellSides]) useEffect(() => { if (!cursorSwellEnabled) { resetPointerTrackingRef.current() return } + /* + * Nothing to reset on this exit: the tracker is shared with this card's own + * hover, a separate capability, and clearing it would undo the layout + * effect's `:hover` bootstrap for a card that mounted under the pointer. + */ + if (!canReceiveConnection) return /* * A connection drag captures the pointer on the origin card's handle, so @@ -1675,7 +1702,7 @@ export function WorkflowBlockBorder({ } resetPointerTrackingRef.current() } - }, [cursorSwellEnabled, getConnectionNodeId, nodeId]) + }, [canReceiveConnection, cursorSwellEnabled, getConnectionNodeId, nodeId]) const ring = resolveRing(ringStyles) const { d: path } = renderedPath diff --git a/packages/workflow-renderer/src/workflow-block/workflow-block-view.tsx b/packages/workflow-renderer/src/workflow-block/workflow-block-view.tsx index de8e59df2b6..71c7b5ab6d9 100644 --- a/packages/workflow-renderer/src/workflow-block/workflow-block-view.tsx +++ b/packages/workflow-renderer/src/workflow-block/workflow-block-view.tsx @@ -64,6 +64,8 @@ const TAB_LENGTH_HEADER_ONLY_PX = 10 /** The error knob is deliberately the shortest of the connection knobs — it is * a secondary output, and a full-length tab crowds the card's bottom corner. */ const TAB_HEIGHT_RATIO = 0.5 +const DEFAULT_TARGET_SIDE: WorkflowConnectionSide = 'left' +const DEFAULT_SOURCE_SIDE: WorkflowConnectionSide = 'right' const CARD_CORNER_RADIUS_PX = 16 const CORNER_SLACK_PX = 4 const ACTION_MENU_RIGHT_INSET_PX = 24 @@ -741,16 +743,14 @@ export function WorkflowBlockView({ const rowTabLength = clampTabLength( branchRowCount <= 2 ? TAB_LENGTH_SMALL_PX : TAB_LENGTH_SMALL_PX - (branchRowCount - 2) * 2 ) - const defaultTargetSide: WorkflowConnectionSide = 'left' - const defaultSourceSide: WorkflowConnectionSide = 'right' const borderPorts = useMemo(() => { const ports: WorkflowBorderPort[] = [] if (shouldShowDefaultHandles) { ports.push({ id: WORKFLOW_TARGET_HANDLE_ID, - side: defaultTargetSide, + side: DEFAULT_TARGET_SIDE, position: 'center', - plateau: mainTabLength(defaultTargetSide), + plateau: mainTabLength(DEFAULT_TARGET_SIDE), color: tabFill(WORKFLOW_TARGET_HANDLE_ID), }) } @@ -780,9 +780,9 @@ export function WorkflowBlockView({ } else if (type !== 'response') { ports.push({ id: WORKFLOW_SOURCE_HANDLE_ID, - side: defaultSourceSide, + side: DEFAULT_SOURCE_SIDE, position: 'center', - plateau: mainTabLength(defaultSourceSide), + plateau: mainTabLength(DEFAULT_SOURCE_SIDE), color: tabFill(WORKFLOW_SOURCE_HANDLE_ID), }) } @@ -805,8 +805,6 @@ export function WorkflowBlockView({ conditionRows, actionMenuSwellOpen, actionMenuWidth, - defaultSourceSide, - defaultTargetSide, highlightedHandles, routerRows, rowTabLength, @@ -874,6 +872,8 @@ export function WorkflowBlockView({ } isSelected={usesSelectedVisuals} height={blockHeight} + canStartConnection={supportsCursorHandle} + canReceiveConnection={shouldShowDefaultHandles} onCursorHandleChange={supportsCursorHandle ? onCursorHandleChange : undefined} onActionMenuReadyChange={setActionMenuSwellReady} />