From ef0e5a0e0b2f8b86f0ca9fea145acf434aef82d5 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 13 Aug 2026 17:26:38 -0700 Subject: [PATCH 1/2] fix(canvas): stop phantom ports and a latched-open action bar Ports surface on hover from a swell painted on the card border, and that swell was raised with no regard for whether a handle exists behind it. A Response block mounts no source handle, so hovering its edge raised a knob no edge could ever leave from. A trigger mounts no target handle, yet still swelled under a connection dragged from another card, offering a drop it cannot accept. Gate each direction on the handle that backs it, and limit a trigger's own swell to its source edge the way the subflow start node already does. The action bar latched open for the same interaction. Leaving the card arms a retract and installs a pointermove listener to track the pointer across the gap up to the bar; re-entering the bar's band called openHover(), which cancelled the retract AND removed that listener. No further pointerleave can arrive once the pointer is off the node, so nothing was left to close the bar. Keep the listener installed and re-arm the retract when the pointer moves back out. Also clear the magnetized port when the pointer leaves the tracking band onto the action bar: only the in-band path recomputed it, so the last knob stayed pinned at hover amplitude with the pointer nowhere near it. --- .../use-action-menu-swell.test.tsx | 159 ++++++++++++++++++ .../workflow-block/use-action-menu-swell.ts | 57 +++++-- .../workflow-block/workflow-block-border.tsx | 29 +++- .../workflow-block/workflow-block-view.tsx | 9 + 4 files changed, 235 insertions(+), 19 deletions(-) create mode 100644 packages/workflow-renderer/src/workflow-block/use-action-menu-swell.test.tsx 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..535b45343c0 --- /dev/null +++ b/packages/workflow-renderer/src/workflow-block/use-action-menu-swell.test.tsx @@ -0,0 +1,159 @@ +/** + * @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 { 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 +}) + +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 new Promise((resolve) => setTimeout(resolve, 160)) + }) + } +} + +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) + + /* Leaving the card arms the retract and installs the gap tracker. */ + 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.tsx b/packages/workflow-renderer/src/workflow-block/workflow-block-border.tsx index e649c30241d..1e6a6b06a3e 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)) { + /* + * Drop the magnetized port too. Only the in-band path below + * recomputes it, so leaving the band upward onto the action bar left + * the last knob pinned at hover amplitude — the card kept 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,10 +1630,10 @@ export function WorkflowBlockBorder({ if (frameRef.current !== null) cancelAnimationFrame(frameRef.current) frameRef.current = null } - }, [cursorSwellEnabled, cursorSwellSides]) + }, [canStartConnection, cursorSwellEnabled, cursorSwellSides]) useEffect(() => { - if (!cursorSwellEnabled) { + if (!cursorSwellEnabled || !canReceiveConnection) { resetPointerTrackingRef.current() return } @@ -1675,7 +1696,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..ce09d5b54b6 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,12 @@ 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 +/** + * Cursor-swell sides for a card that takes no input. Matching the subflow + * start node, only the source edge may swell, so a trigger never raises a knob + * on the edge where every other card shows its input. + */ +const SOURCE_ONLY_CURSOR_SIDES = ['right'] as const const CARD_CORNER_RADIUS_PX = 16 const CORNER_SLACK_PX = 4 const ACTION_MENU_RIGHT_INSET_PX = 24 @@ -874,6 +880,9 @@ export function WorkflowBlockView({ } isSelected={usesSelectedVisuals} height={blockHeight} + cursorSwellSides={shouldShowDefaultHandles ? undefined : SOURCE_ONLY_CURSOR_SIDES} + canStartConnection={supportsCursorHandle} + canReceiveConnection={shouldShowDefaultHandles} onCursorHandleChange={supportsCursorHandle ? onCursorHandleChange : undefined} onActionMenuReadyChange={setActionMenuSwellReady} /> From 961df0172849d94ae8b1384855146d0d6fff7ec1 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 13 Aug 2026 17:42:29 -0700 Subject: [PATCH 2/2] fix(canvas): scope the receive gate, cover both swell directions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gating the foreign-drag listener also ran the shared pointer-tracking reset, which belongs to the card's own hover. A trigger sets canReceiveConnection false while canStartConnection stays true, so the reset undid the layout effect's :hover bootstrap and left a card that mounted under the pointer with no source swell until the pointer left and came back. Skip the listener instead; the effect's own cleanup already covers a true-to-false flip. Drop the trigger-only cursorSwellSides restriction. A swell on a trigger's input edge resolves to a source handle, so an edge genuinely can be made there — it was a behavior change beyond the bug, not a phantom. Cover both directions of the swell gate, and use the shared sleep helper in the action-bar test. Hoist the constant connection sides out of the render body so they stop riding the borderPorts dep array. --- .../use-action-menu-swell.test.tsx | 7 +- ...workflow-block-border-connectable.test.tsx | 206 ++++++++++++++++++ .../workflow-block/workflow-block-border.tsx | 16 +- .../workflow-block/workflow-block-view.tsx | 21 +- 4 files changed, 228 insertions(+), 22 deletions(-) create mode 100644 packages/workflow-renderer/src/workflow-block/workflow-block-border-connectable.test.tsx 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 index 535b45343c0..47f97de5898 100644 --- 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 @@ -7,7 +7,9 @@ * 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' @@ -20,6 +22,8 @@ beforeAll(() => { } 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 } @@ -87,7 +91,7 @@ function pointerEvent(type: string, init: PointerEventInit) { async function settle() { for (let pass = 0; pass < 2; pass++) { await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 160)) + await sleep(SETTLE_PASS_MS) }) } } @@ -113,7 +117,6 @@ describe('useActionMenuSwell', () => { }) expect(state.swellOpen).toBe(true) - /* Leaving the card arms the retract and installs the gap tracker. */ act(() => { node.dispatchEvent(pointerEvent('pointerleave', {})) }) 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 1e6a6b06a3e..85bca72b6fa 100644 --- a/packages/workflow-renderer/src/workflow-block/workflow-block-border.tsx +++ b/packages/workflow-renderer/src/workflow-block/workflow-block-border.tsx @@ -1450,10 +1450,10 @@ export function WorkflowBlockBorder({ // returning to an edge immediately restores the swell. if (isPointerOverTrackingRoot(clientX, clientY)) { /* - * Drop the magnetized port too. Only the in-band path below - * recomputes it, so leaving the band upward onto the action bar left - * the last knob pinned at hover amplitude — the card kept a port - * puffed out with the pointer nowhere near it. + * 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 @@ -1633,10 +1633,16 @@ export function WorkflowBlockBorder({ }, [canStartConnection, cursorSwellEnabled, cursorSwellSides]) useEffect(() => { - if (!cursorSwellEnabled || !canReceiveConnection) { + 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 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 ce09d5b54b6..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,12 +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 -/** - * Cursor-swell sides for a card that takes no input. Matching the subflow - * start node, only the source edge may swell, so a trigger never raises a knob - * on the edge where every other card shows its input. - */ -const SOURCE_ONLY_CURSOR_SIDES = ['right'] as const +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 @@ -747,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), }) } @@ -786,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), }) } @@ -811,8 +805,6 @@ export function WorkflowBlockView({ conditionRows, actionMenuSwellOpen, actionMenuWidth, - defaultSourceSide, - defaultTargetSide, highlightedHandles, routerRows, rowTabLength, @@ -880,7 +872,6 @@ export function WorkflowBlockView({ } isSelected={usesSelectedVisuals} height={blockHeight} - cursorSwellSides={shouldShowDefaultHandles ? undefined : SOURCE_ONLY_CURSOR_SIDES} canStartConnection={supportsCursorHandle} canReceiveConnection={shouldShowDefaultHandles} onCursorHandleChange={supportsCursorHandle ? onCursorHandleChange : undefined}