mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
fix(canvas): stop phantom ports and a latched-open action bar (#6688)
* 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. * fix(canvas): scope the receive gate, cover both swell directions 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.
This commit is contained in:
@@ -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 (
|
||||
<div className='react-flow__node'>
|
||||
<div ref={rootRef} data-testid='action-menu' />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function mount(onState: (state: { swellOpen: boolean }) => void) {
|
||||
container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
root = createRoot(container)
|
||||
act(() => {
|
||||
root?.render(<Harness onState={onState} />)
|
||||
})
|
||||
|
||||
const node = container.querySelector<HTMLElement>('.react-flow__node')
|
||||
const menu = container.querySelector<HTMLElement>('[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)
|
||||
})
|
||||
})
|
||||
@@ -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,25 +115,14 @@ export function useActionMenuSwell({
|
||||
openHover()
|
||||
}
|
||||
|
||||
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)) {
|
||||
closeHoverImmediately()
|
||||
return
|
||||
}
|
||||
if (isPointerOverActionMenu(event)) openHover()
|
||||
}
|
||||
hoverMoveRef.current = onMove
|
||||
window.addEventListener('pointermove', onMove, { passive: true })
|
||||
}
|
||||
/**
|
||||
* 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) {
|
||||
@@ -140,6 +133,40 @@ export function useActionMenuSwell({
|
||||
}, ACTION_MENU_HOVER_LEAVE_DELAY_MS)
|
||||
}
|
||||
|
||||
const scheduleHoverLeave = (event: PointerEvent) => {
|
||||
if (isOtherNodeTarget(event.relatedTarget)) {
|
||||
closeHoverImmediately()
|
||||
return
|
||||
}
|
||||
if (!hoverMoveRef.current) {
|
||||
/*
|
||||
* 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(moveEvent)) {
|
||||
cancelHoverLeaveTimeout()
|
||||
setIsHovered(true)
|
||||
setSwellOpen(true)
|
||||
return
|
||||
}
|
||||
armHoverLeave()
|
||||
}
|
||||
hoverMoveRef.current = onMove
|
||||
window.addEventListener('pointermove', onMove, { passive: true })
|
||||
}
|
||||
cancelHoverLeaveTimeout()
|
||||
armHoverLeave()
|
||||
}
|
||||
|
||||
/*
|
||||
* A stationary pointer fires no `pointerenter`, so re-subscribing while it
|
||||
* already rests on the card (the listeners are torn down for the duration
|
||||
|
||||
+206
@@ -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<Root>()
|
||||
const mountedHosts = new Set<HTMLDivElement>()
|
||||
|
||||
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(
|
||||
<WorkflowBlockBorder
|
||||
nodeId='block-1'
|
||||
getConnectionNodeId={() => 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<typeof vi.fn>) {
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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<WorkflowBorderPort[]>(() => {
|
||||
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}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user