Compare commits

...

5 Commits

Author SHA1 Message Date
GitHub Action
f370700fbd [automated] Apply ESLint and Oxfmt fixes 2026-03-12 22:10:53 +00:00
Christian Byrne
3cee74a0c5 fix: address CodeRabbit review feedback
- Remove unconditional canvas dirty during stable hover (perf)
  The `|| node.isPointerOver` condition caused setDirty on every
  pointer-move frame while hovering, even when state hadn't changed.
2026-03-12 15:06:35 -07:00
Arthur R Longbottom
f5167ec744 fix: strengthen test assertions and add missing docstrings
Replace weak negative assertions with explicit call-name filtering
to catch false passes. Add docstrings to inner bridge functions.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 15:05:54 -07:00
Arthur R Longbottom
180fed69d8 test: add unit tests for subgraph IO slot link events
- SubgraphInput.connect() fires node:slot-links:changed for widget inputs
- SubgraphInputNode._disconnectNodeInput() fires on disconnect
- LinkConnector.dropOnNothing() dispatches before-drop-on-canvas before
  dropped-on-canvas, and skips downstream when intercepted
- Remove duplicate raf.flush() in finishInteraction
2026-03-12 15:05:54 -07:00
Arthur R Longbottom
0d657402e7 fix: add visual feedback for subgraph IO drag interactions
Bridge canvas-initiated subgraph IO drags to Vue slot drag state and
vice versa, restoring slot dimming, proximity snap, dot highlights,
and drop-to-connect across the canvas/Vue rendering boundary.

Fixes #9010
2026-03-12 15:05:54 -07:00
11 changed files with 616 additions and 12 deletions

View File

@@ -162,6 +162,7 @@ import { useWorkflowStore } from '@/platform/workflow/management/stores/workflow
import { useWorkflowAutoSave } from '@/platform/workflow/persistence/composables/useWorkflowAutoSave'
import { useWorkflowPersistenceV2 as useWorkflowPersistence } from '@/platform/workflow/persistence/composables/useWorkflowPersistenceV2'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useSubgraphDragBridge } from '@/renderer/core/canvas/links/useSubgraphDragBridge'
import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions'
import TransformPane from '@/renderer/core/layout/transform/TransformPane.vue'
import MiniMap from '@/renderer/extensions/minimap/MiniMap.vue'
@@ -448,6 +449,7 @@ useContextMenuTranslation()
useCopy()
usePaste()
useWorkflowAutoSave()
useSubgraphDragBridge()
// Start watching for locale change after the initial value is loaded.
watch(

View File

@@ -445,6 +445,7 @@ export class LinkConnector {
this.state.connectingTo = 'input'
this._setLegacyLinks(false)
this.events.dispatch('connecting', { connectingTo: 'input' })
}
dragNewFromSubgraphOutput(
@@ -466,6 +467,7 @@ export class LinkConnector {
this.state.connectingTo = 'output'
this._setLegacyLinks(true)
this.events.dispatch('connecting', { connectingTo: 'output' })
}
/**
@@ -878,7 +880,10 @@ export class LinkConnector {
(link) => link instanceof MovingInputLink && link.disconnectOnDrop
).forEach((link) => (link as MovingLinkBase).disconnect())
if (this.renderLinks.length === 0) return
// For external event only.
const intercepted = this.events.dispatch('before-drop-on-canvas', event)
if (intercepted === false) return
const mayContinue = this.events.dispatch('dropped-on-canvas', event)
if (mayContinue === false) return

View File

@@ -0,0 +1,87 @@
import { beforeEach, describe, expect, test, vi } from 'vitest'
import { LinkConnector } from '@/lib/litegraph/src/litegraph'
import {
createMockCanvasPointerEvent,
createMockLGraphNode,
createMockLinkNetwork,
createMockNodeOutputSlot
} from '@/utils/__tests__/litegraphTestUtils'
const mockSetConnectingLinks = vi.fn()
type RenderLinkItem = LinkConnector['renderLinks'][number]
function createMockRenderLink(): RenderLinkItem {
const partial: Partial<RenderLinkItem> = {
toType: 'input',
fromPos: [0, 0],
fromSlotIndex: 0,
fromDirection: 0,
network: createMockLinkNetwork(),
node: createMockLGraphNode(),
fromSlot: createMockNodeOutputSlot(),
dragDirection: 0,
canConnectToInput: vi.fn().mockReturnValue(false),
canConnectToOutput: vi.fn().mockReturnValue(false),
canConnectToReroute: vi.fn().mockReturnValue(false),
connectToInput: vi.fn(),
connectToOutput: vi.fn(),
connectToSubgraphInput: vi.fn(),
connectToRerouteOutput: vi.fn(),
connectToSubgraphOutput: vi.fn(),
connectToRerouteInput: vi.fn()
}
return partial as RenderLinkItem
}
describe('LinkConnector.dropOnNothing event dispatch', () => {
let connector: LinkConnector
beforeEach(() => {
connector = new LinkConnector(mockSetConnectingLinks)
vi.clearAllMocks()
})
test('dispatches before-drop-on-canvas before dropped-on-canvas', () => {
connector.renderLinks.push(createMockRenderLink())
const callOrder: string[] = []
connector.events.addEventListener('before-drop-on-canvas', () => {
callOrder.push('before-drop-on-canvas')
})
connector.events.addEventListener('dropped-on-canvas', () => {
callOrder.push('dropped-on-canvas')
})
connector.dropOnNothing(createMockCanvasPointerEvent(100, 100))
expect(callOrder).toEqual(['before-drop-on-canvas', 'dropped-on-canvas'])
})
test('skips dropped-on-canvas when before-drop-on-canvas is intercepted', () => {
connector.renderLinks.push(createMockRenderLink())
const droppedListener = vi.fn()
connector.events.addEventListener('before-drop-on-canvas', (e) => {
e.preventDefault()
})
connector.events.addEventListener('dropped-on-canvas', droppedListener)
connector.dropOnNothing(createMockCanvasPointerEvent(100, 100))
expect(droppedListener).not.toHaveBeenCalled()
})
test('does not dispatch events when renderLinks is empty', () => {
const beforeListener = vi.fn()
const droppedListener = vi.fn()
connector.events.addEventListener('before-drop-on-canvas', beforeListener)
connector.events.addEventListener('dropped-on-canvas', droppedListener)
connector.dropOnNothing(createMockCanvasPointerEvent(100, 100))
expect(beforeListener).not.toHaveBeenCalled()
expect(droppedListener).not.toHaveBeenCalled()
})
})

View File

@@ -15,6 +15,10 @@ import type { IWidget } from '@/lib/litegraph/src/types/widgets'
export interface LinkConnectorEventMap {
reset: boolean
connecting: {
connectingTo: 'input' | 'output'
}
'before-drop-links': {
renderLinks: RenderLink[]
event: CanvasPointerEvent
@@ -44,6 +48,7 @@ export interface LinkConnectorEventMap {
node: SubgraphInputNode | SubgraphOutputNode
event: CanvasPointerEvent
}
'before-drop-on-canvas': CanvasPointerEvent
'dropped-on-canvas': CanvasPointerEvent
'dropped-on-widget': {

View File

@@ -0,0 +1,100 @@
import { describe, expect, vi } from 'vitest'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { NodeSlotType } from '@/lib/litegraph/src/types/globalEnums'
import { subgraphTest } from './__fixtures__/subgraphFixtures'
describe('SubgraphInput.connect triggers node:slot-links:changed', () => {
subgraphTest(
'fires connected event when connecting to a widget input',
({ simpleSubgraph }) => {
const subgraph = simpleSubgraph
const triggerSpy = vi.spyOn(subgraph, 'trigger')
const node = new LGraphNode('Target')
node.addInput('prompt', 'STRING')
node.inputs[0].widget = { name: 'prompt' }
subgraph.add(node)
subgraph.inputNode.slots[0].connect(node.inputs[0], node)
expect(triggerSpy).toHaveBeenCalledWith('node:slot-links:changed', {
nodeId: node.id,
slotType: NodeSlotType.INPUT,
slotIndex: 0,
connected: true,
linkId: expect.any(Number)
})
}
)
subgraphTest(
'does not fire event when connecting to a non-widget input',
({ simpleSubgraph }) => {
const subgraph = simpleSubgraph
const triggerSpy = vi.spyOn(subgraph, 'trigger')
const node = new LGraphNode('Target')
node.addInput('in', 'number')
subgraph.add(node)
subgraph.inputNode.slots[0].connect(node.inputs[0], node)
expect(triggerSpy.mock.calls.map((c) => c[0])).not.toContain(
'node:slot-links:changed'
)
}
)
})
describe('SubgraphInputNode._disconnectNodeInput triggers node:slot-links:changed', () => {
subgraphTest(
'fires disconnected event when disconnecting a widget input',
({ simpleSubgraph }) => {
const subgraph = simpleSubgraph
const node = new LGraphNode('Target')
node.addInput('prompt', 'STRING')
node.inputs[0].widget = { name: 'prompt' }
subgraph.add(node)
const link = subgraph.inputNode.slots[0].connect(node.inputs[0], node)
expect(link).toBeDefined()
const triggerSpy = vi.spyOn(subgraph, 'trigger')
subgraph.inputNode._disconnectNodeInput(node, node.inputs[0], link!)
expect(triggerSpy).toHaveBeenCalledWith('node:slot-links:changed', {
nodeId: node.id,
slotType: NodeSlotType.INPUT,
slotIndex: 0,
connected: false,
linkId: link!.id
})
}
)
subgraphTest(
'does not fire event when disconnecting a non-widget input',
({ simpleSubgraph }) => {
const subgraph = simpleSubgraph
const node = new LGraphNode('Target')
node.addInput('in', 'number')
subgraph.add(node)
const link = subgraph.inputNode.slots[0].connect(node.inputs[0], node)
expect(link).toBeDefined()
const triggerSpy = vi.spyOn(subgraph, 'trigger')
subgraph.inputNode._disconnectNodeInput(node, node.inputs[0], link!)
expect(triggerSpy.mock.calls.map((c) => c[0])).not.toContain(
'node:slot-links:changed'
)
}
)
})

View File

@@ -134,6 +134,16 @@ export class SubgraphInput extends SubgraphSlot {
}
subgraph._version++
if (slot.widget) {
subgraph.trigger('node:slot-links:changed', {
nodeId: node.id,
slotType: NodeSlotType.INPUT,
slotIndex: inputIndex,
connected: true,
linkId: link.id
})
}
node.onConnectionsChange?.(NodeSlotType.INPUT, inputIndex, true, link, slot)
subgraph.afterChange()

View File

@@ -177,7 +177,17 @@ export class SubgraphInputNode
}
}
const linkId = input.link
input.link = null
if (input.widget) {
subgraph.trigger('node:slot-links:changed', {
nodeId: node.id,
slotType: NodeSlotType.INPUT,
slotIndex: node.inputs.indexOf(input),
connected: false,
linkId: linkId
})
}
subgraph.setDirtyCanvas(false, true)
if (!link) return

View File

@@ -140,7 +140,6 @@ export class LinkConnectorAdapter {
/** Drops moving links onto the canvas (no target). */
dropOnCanvas(event: CanvasPointerEvent): void {
//Add extra check for connection to subgraphInput/subgraphOutput
if (isSubgraph(this.network)) {
const { canvasX, canvasY } = event
const ioNode = this.network.getIoNodeOnPos?.(canvasX, canvasY)

View File

@@ -0,0 +1,355 @@
import { tryOnScopeDispose, whenever } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import type { LGraphCanvas } from '@/lib/litegraph/src/LGraphCanvas'
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/LGraphNode'
import type { LinkConnector } from '@/lib/litegraph/src/canvas/LinkConnector'
import type { RenderLink } from '@/lib/litegraph/src/canvas/RenderLink'
import type { SlotDropCandidate } from '@/renderer/core/canvas/links/slotLinkDragUIState'
import { LinkDirection } from '@/lib/litegraph/src/types/globalEnums'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { createLinkConnectorAdapter } from '@/renderer/core/canvas/links/linkConnectorAdapter'
import {
resolveNodeSurfaceSlotCandidate,
resolveSlotTargetCandidate
} from '@/renderer/core/canvas/links/linkDropOrchestrator'
import { useSlotLinkDragUIState } from '@/renderer/core/canvas/links/slotLinkDragUIState'
import { getSlotKey } from '@/renderer/core/layout/slots/slotIdentifier'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import { createSlotLinkDragContext } from '@/renderer/extensions/vueNodes/composables/slotLinkDragContext'
import { resolvePointerTarget } from '@/renderer/extensions/vueNodes/composables/useSlotLinkInteraction'
import { useNodeSlotRegistryStore } from '@/renderer/extensions/vueNodes/stores/nodeSlotRegistryStore'
import { app } from '@/scripts/app'
import { createRafBatch } from '@/utils/rafBatch'
const SNAP_CLASS = 'lg-slot--snap-target'
/**
* Bridges canvas-initiated subgraph IO drags to Vue slot drag state.
*
* When a drag starts from a SubgraphInput or SubgraphOutput node
* (canvas-drawn), the Vue slot components need to know about it so they
* can dim incompatible slots, snap links, and highlight compatible targets.
*/
export function useSubgraphDragBridge() {
const canvasStore = useCanvasStore()
const { canvas } = storeToRefs(canvasStore)
const {
state: dragState,
beginDrag,
endDrag,
setCandidate,
setCompatibleForKey
} = useSlotLinkDragUIState()
let cleanup: (() => void) | undefined
whenever(canvas, (lgCanvas) => {
cleanup?.()
cleanup = setupBridge(lgCanvas)
})
tryOnScopeDispose(() => {
cleanup?.()
cleanup = undefined
})
/** Wires up LinkConnector event listeners and returns a cleanup function. */
function setupBridge(lgCanvas: LGraphCanvas): () => void {
const linkConnector: LinkConnector = lgCanvas.linkConnector
let teardownDrag: (() => void) | undefined
let isBridgeDrag = false
const onConnecting = (
event: CustomEvent<{ connectingTo: 'input' | 'output' }>
) => {
teardownDrag?.()
teardownDrag = undefined
const { connectingTo } = event.detail
const adapter = createLinkConnectorAdapter()
if (!adapter) return
const renderLink = adapter.renderLinks[0]
if (!renderLink) return
const sourceType: 'input' | 'output' =
connectingTo === 'input' ? 'output' : 'input'
isBridgeDrag = true
beginDrag(
{
nodeId: String(renderLink.node.id),
slotIndex: renderLink.fromSlotIndex,
type: sourceType,
direction: renderLink.fromDirection ?? LinkDirection.RIGHT,
position: {
x: renderLink.fromPos[0],
y: renderLink.fromPos[1]
}
},
-1
)
const allKeys = layoutStore.getAllSlotKeys()
for (const key of allKeys) {
const slotLayout = layoutStore.getSlotLayout(key)
if (!slotLayout) continue
if (slotLayout.type !== connectingTo) continue
const ok =
connectingTo === 'input'
? adapter.isInputValidDrop(slotLayout.nodeId, slotLayout.index)
: adapter.isOutputValidDrop(slotLayout.nodeId, slotLayout.index)
setCompatibleForKey(key, ok)
}
teardownDrag = startPointerTracking(lgCanvas, linkConnector)
}
const onBeforeDropOnCanvas = (event: CustomEvent) => {
if (!isBridgeDrag) return
const candidate = dragState.candidate
if (!candidate?.compatible) return
const adapter = createLinkConnectorAdapter()
if (!adapter) return
const connected = connectToCandidate(
adapter.renderLinks,
adapter.network,
candidate,
linkConnector
)
if (connected) event.preventDefault()
}
const onReset = () => {
if (!isBridgeDrag) return
teardownDrag?.()
teardownDrag = undefined
isBridgeDrag = false
endDrag()
}
linkConnector.events.addEventListener('connecting', onConnecting)
linkConnector.events.addEventListener(
'before-drop-on-canvas',
onBeforeDropOnCanvas
)
linkConnector.events.addEventListener('reset', onReset)
return () => {
linkConnector.events.removeEventListener('connecting', onConnecting)
linkConnector.events.removeEventListener(
'before-drop-on-canvas',
onBeforeDropOnCanvas
)
linkConnector.events.removeEventListener('reset', onReset)
teardownDrag?.()
teardownDrag = undefined
if (isBridgeDrag) {
isBridgeDrag = false
endDrag()
}
}
}
/**
* Tracks pointer movement during a bridge drag to resolve Vue slot
* candidates, update snap positions, and toggle snap-target highlights.
* Returns a cleanup function that removes the listener and RAF batch.
*/
function startPointerTracking(
lgCanvas: LGraphCanvas,
linkConnector: LinkConnector
): () => void {
const ownerDoc = lgCanvas.getCanvasWindow().document
const session = createSlotLinkDragContext()
const slotRegistry = useNodeSlotRegistryStore()
let pendingMove: { clientX: number; clientY: number } | null = null
let highlightedSlotEl: HTMLElement | null = null
/** Resolves the Vue slot under the pointer and updates snap/highlight state. */
const processFrame = () => {
const data = pendingMove
if (!data) return
pendingMove = null
const adapter = createLinkConnectorAdapter()
if (!adapter) return
const graph = adapter.network
const target = resolvePointerTarget(data.clientX, data.clientY, null)
let hoveredSlotKey: string | null = null
let hoveredNodeId: NodeId | null = null
if (target === session.lastPointerEventTarget) {
hoveredSlotKey = session.lastPointerTargetSlotKey
hoveredNodeId = session.lastPointerTargetNodeId
} else if (target instanceof HTMLElement) {
const elWithSlot = target
.closest('.lg-slot, .lg-node-widget')
?.querySelector<HTMLElement>('[data-slot-key]')
const elWithNode = target.closest<HTMLElement>('[data-node-id]')
hoveredSlotKey = elWithSlot?.dataset['slotKey'] ?? null
hoveredNodeId = elWithNode?.dataset['nodeId'] ?? null
session.lastPointerEventTarget = target
session.lastPointerTargetSlotKey = hoveredSlotKey
session.lastPointerTargetNodeId = hoveredNodeId
}
const hoverChanged =
hoveredSlotKey !== session.lastHoverSlotKey ||
hoveredNodeId !== session.lastHoverNodeId
let candidate = dragState.candidate
if (hoverChanged) {
const context = { adapter, graph, session }
const slotCandidate = resolveSlotTargetCandidate(target, context)
const nodeCandidate = resolveNodeSurfaceSlotCandidate(target, context)
candidate = slotCandidate?.compatible ? slotCandidate : nodeCandidate
session.lastHoverSlotKey = hoveredSlotKey
session.lastHoverNodeId = hoveredNodeId
if (slotCandidate) {
const key = getSlotKey(
slotCandidate.layout.nodeId,
slotCandidate.layout.index,
slotCandidate.layout.type === 'input'
)
setCompatibleForKey(key, !!slotCandidate.compatible)
}
if (nodeCandidate && !slotCandidate?.compatible) {
const key = getSlotKey(
nodeCandidate.layout.nodeId,
nodeCandidate.layout.index,
nodeCandidate.layout.type === 'input'
)
setCompatibleForKey(key, !!nodeCandidate.compatible)
}
}
const newCandidate = candidate?.compatible ? candidate : null
const newCandidateKey = newCandidate
? getSlotKey(
newCandidate.layout.nodeId,
newCandidate.layout.index,
newCandidate.layout.type === 'input'
)
: null
const candidateChanged = newCandidateKey !== session.lastCandidateKey
if (candidateChanged) {
setCandidate(newCandidate)
session.lastCandidateKey = newCandidateKey
updateSnapTargetHighlight(newCandidate)
}
const snapPos = newCandidate
? ([newCandidate.layout.position.x, newCandidate.layout.position.y] as [
number,
number
])
: undefined
const currentSnap = linkConnector.state.snapLinksPos
const snapPosChanged = snapPos
? !currentSnap ||
currentSnap[0] !== snapPos[0] ||
currentSnap[1] !== snapPos[1]
: !!currentSnap
if (snapPosChanged) {
linkConnector.state.snapLinksPos = snapPos
}
if (candidateChanged || snapPosChanged) {
app.canvas?.setDirty(true, true)
}
}
/** Toggles the `lg-slot--snap-target` CSS class on the candidate's slot element. */
function updateSnapTargetHighlight(candidate: SlotDropCandidate | null) {
if (highlightedSlotEl) {
highlightedSlotEl.classList.remove(SNAP_CLASS)
highlightedSlotEl = null
}
if (!candidate) return
const key = getSlotKey(
candidate.layout.nodeId,
candidate.layout.index,
candidate.layout.type === 'input'
)
const entry = slotRegistry
.getNode(candidate.layout.nodeId)
?.slots.get(key)
const groupEl = entry?.el?.parentElement
if (groupEl) {
groupEl.classList.add(SNAP_CLASS)
highlightedSlotEl = groupEl
}
}
const raf = createRafBatch(processFrame)
/** Buffers the latest pointer position and schedules a RAF frame. */
const onPointerMove = (e: PointerEvent) => {
pendingMove = { clientX: e.clientX, clientY: e.clientY }
raf.schedule()
}
ownerDoc.addEventListener('pointermove', onPointerMove, { capture: true })
return () => {
ownerDoc.removeEventListener('pointermove', onPointerMove, {
capture: true
})
raf.cancel()
if (highlightedSlotEl) {
highlightedSlotEl.classList.remove(SNAP_CLASS)
highlightedSlotEl = null
}
session.dispose()
}
}
}
/**
* Connects render links to the snapped Vue slot candidate.
* Returns `true` if at least one link was connected.
*/
function connectToCandidate(
links: ReadonlyArray<RenderLink>,
network: { getNodeById(id: NodeId): LGraphNode | null },
candidate: SlotDropCandidate,
linkConnector: LinkConnector
): boolean {
const node = network.getNodeById(candidate.layout.nodeId)
if (!node) return false
let connected = false
if (candidate.layout.type === 'input') {
const input = node.inputs?.[candidate.layout.index]
if (!input) return false
for (const link of links) {
if (link.toType !== 'input') continue
if (!link.canConnectToInput(node, input)) continue
link.connectToInput(node, input, linkConnector.events)
connected = true
}
} else {
const output = node.outputs?.[candidate.layout.index]
if (!output) return false
for (const link of links) {
if (link.toType !== 'output') continue
if (!link.canConnectToOutput(node, output)) continue
link.connectToOutput(node, output, linkConnector.events)
connected = true
}
}
return connected
}

View File

@@ -52,7 +52,7 @@ const slotClass = computed(() =>
'border border-solid border-node-component-slot-dot-outline',
props.multi
? 'h-6 w-3'
: 'size-3 cursor-crosshair group-hover/slot:scale-125 group-hover/slot:[--node-component-slot-dot-outline-opacity-mult:5]'
: 'size-3 cursor-crosshair group-hover/slot:scale-125 group-hover/slot:[--node-component-slot-dot-outline-opacity-mult:5] group-[.lg-slot--snap-target]/slot:scale-125 group-[.lg-slot--snap-target]/slot:[--node-component-slot-dot-outline-opacity-mult:5]'
)
)
</script>

View File

@@ -11,6 +11,7 @@ import type {
INodeInputSlot,
INodeOutputSlot
} from '@/lib/litegraph/src/interfaces'
import type { CanvasPointerEvent } from '@/lib/litegraph/src/types/events'
import { LinkDirection } from '@/lib/litegraph/src/types/globalEnums'
import {
clearCanvasPointerHistory,
@@ -32,6 +33,7 @@ import { createSlotLinkDragContext } from '@/renderer/extensions/vueNodes/compos
import { augmentToCanvasPointerEvent } from '@/renderer/extensions/vueNodes/utils/eventUtils'
import { app } from '@/scripts/app'
import { createRafBatch } from '@/utils/rafBatch'
import { isSubgraph } from '@/utils/typeGuardUtil'
interface SlotInteractionOptions {
nodeId: string
@@ -386,14 +388,44 @@ export function useSlotLinkInteraction({
dragContext.lastCandidateKey = newCandidateKey
}
let subgraphIOSnapPos: [number, number] | null = null
let subgraphIOHoverChanged = false
const graph = app.canvas?.graph
if (isSubgraph(graph)) {
const pointerEvent = { canvasX, canvasY } as CanvasPointerEvent
for (const node of [graph.inputNode, graph.outputNode]) {
if (!node) continue
const wasPointerOver = node.isPointerOver
node.onPointerMove(pointerEvent)
if (wasPointerOver !== node.isPointerOver) {
subgraphIOHoverChanged = true
}
if (node.isPointerOver) {
const slot = node.getSlotInPosition(canvasX, canvasY)
if (slot && slot.isPointerOver) {
subgraphIOSnapPos = slot.pos
}
}
}
}
let snapPosChanged = false
if (activeAdapter) {
const snapX = newCandidate
? newCandidate.layout.position.x
: state.pointer.canvas.x
const snapY = newCandidate
? newCandidate.layout.position.y
: state.pointer.canvas.y
const snapX = subgraphIOSnapPos
? subgraphIOSnapPos[0]
: newCandidate
? newCandidate.layout.position.x
: state.pointer.canvas.x
const snapY = subgraphIOSnapPos
? subgraphIOSnapPos[1]
: newCandidate
? newCandidate.layout.position.y
: state.pointer.canvas.y
const currentSnap = activeAdapter.linkConnector.state.snapLinksPos
snapPosChanged =
!currentSnap || currentSnap[0] !== snapX || currentSnap[1] !== snapY
@@ -402,7 +434,8 @@ export function useSlotLinkInteraction({
}
}
const shouldRedraw = candidateChanged || snapPosChanged
const shouldRedraw =
candidateChanged || snapPosChanged || subgraphIOHoverChanged
if (shouldRedraw) app.canvas?.setDirty(true, true)
}
const raf = createRafBatch(processPointerMoveFrame)
@@ -511,8 +544,6 @@ export function useSlotLinkInteraction({
raf.flush()
raf.flush()
if (!state.source) {
cleanupInteraction()
app.canvas?.setDirty(true, true)