Compare commits

...

8 Commits

Author SHA1 Message Date
bymyself
886f2e39bf test: cover subgraph drag bridge and IO snap logic
Add unit tests for useSubgraphDragBridge (canvas->Vue slot drag bridging,
compatibility marking, candidate snap highlight, drop interception, cleanup)
and the subgraph IO snap branch in useSlotLinkInteraction. Raises patch
coverage on the two largest previously-untested files in the PR.
2026-06-26 15:11:41 -07:00
Alexander Brown
3006932287 Merge branch 'main' into fix/subgraph-io-drag-feedback 2026-05-18 16:06:55 -07:00
bymyself
7c1195e389 fix: guard subgraph drag bridge to SubgraphIONodeBase nodes only
Activate the canvas → Vue slot drag bridge only when the drag
originates from a SubgraphInput or SubgraphOutput node. Other
canvas-initiated drags (e.g., reroutes) no longer inappropriately
activate Vue slot drag UI state.

Addresses review feedback:
https://github.com/Comfy-Org/ComfyUI_frontend/pull/9838#discussion_r2927692347
2026-05-03 01:32:48 -07:00
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
13 changed files with 1270 additions and 12 deletions

View File

@@ -161,6 +161,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 { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import TransformPane from '@/renderer/core/layout/transform/TransformPane.vue'
@@ -463,6 +464,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

@@ -136,6 +136,16 @@ export class SubgraphInput extends SubgraphSlot {
}
subgraph.incrementVersion()
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

@@ -180,7 +180,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,352 @@
// Import the litegraph barrel first to establish the correct module init
// order and avoid the LGraph <-> Subgraph circular dependency.
import '@/lib/litegraph/src/litegraph'
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { effectScope, nextTick } from 'vue'
import { SubgraphIONodeBase } from '@/lib/litegraph/src/subgraph/SubgraphIONodeBase'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useSlotLinkDragUIState } from '@/renderer/core/canvas/links/slotLinkDragUIState'
import { useSubgraphDragBridge } from '@/renderer/core/canvas/links/useSubgraphDragBridge'
const { mockCreateAdapter, mockLayoutStore, mockRegistry, mockOrchestrator } =
vi.hoisted(() => ({
mockCreateAdapter: vi.fn(),
mockLayoutStore: {
getAllSlotKeys: vi.fn(() => [] as string[]),
getSlotLayout: vi.fn()
},
mockRegistry: { getNode: vi.fn() },
mockOrchestrator: {
resolveSlotTargetCandidate: vi.fn(),
resolveNodeSurfaceSlotCandidate: vi.fn()
}
}))
const setDirty = vi.fn()
vi.mock('@/scripts/app', () => ({
app: {
get canvas() {
return { setDirty }
}
}
}))
vi.mock('@/renderer/core/canvas/links/linkConnectorAdapter', () => ({
createLinkConnectorAdapter: () => mockCreateAdapter()
}))
vi.mock('@/renderer/core/layout/store/layoutStore', () => ({
layoutStore: mockLayoutStore
}))
vi.mock('@/renderer/extensions/vueNodes/stores/nodeSlotRegistryStore', () => ({
useNodeSlotRegistryStore: () => mockRegistry
}))
vi.mock('@/renderer/core/canvas/links/linkDropOrchestrator', () => ({
resolveSlotTargetCandidate: (...args: unknown[]) =>
mockOrchestrator.resolveSlotTargetCandidate(...args),
resolveNodeSurfaceSlotCandidate: (...args: unknown[]) =>
mockOrchestrator.resolveNodeSurfaceSlotCandidate(...args)
}))
vi.mock(
'@/renderer/extensions/vueNodes/composables/useSlotLinkInteraction',
() => ({
resolvePointerTarget: (...args: unknown[]) =>
mockResolvePointerTarget(...args)
})
)
const mockResolvePointerTarget = vi.fn()
// Run RAF batches synchronously so pointermove handling is deterministic.
vi.mock('@/utils/rafBatch', () => ({
createRafBatch: (run: () => void) => ({
schedule: run,
cancel: () => {},
flush: run,
isScheduled: () => false
})
}))
function createSubgraphIONode(id: string) {
const node = Object.create(
SubgraphIONodeBase.prototype
) as SubgraphIONodeBase<never>
Object.assign(node, { id })
return node
}
function createMockCanvas() {
const events = new EventTarget()
return {
linkConnector: {
events,
state: { snapLinksPos: undefined as [number, number] | undefined }
},
getCanvasWindow: () => ({ document })
}
}
type MockCanvas = ReturnType<typeof createMockCanvas>
function dispatchConnecting(
canvas: MockCanvas,
connectingTo: 'input' | 'output'
) {
canvas.linkConnector.events.dispatchEvent(
new CustomEvent('connecting', { detail: { connectingTo } })
)
}
describe('useSubgraphDragBridge', () => {
let scope: ReturnType<typeof effectScope>
let canvas: MockCanvas
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
vi.clearAllMocks()
mockLayoutStore.getAllSlotKeys.mockReturnValue([])
useSlotLinkDragUIState().endDrag()
canvas = createMockCanvas()
})
afterEach(() => {
scope?.stop()
})
async function mountBridge() {
scope = effectScope()
scope.run(() => useSubgraphDragBridge())
const canvasStore = useCanvasStore()
canvasStore.canvas = canvas as never
await nextTick()
}
it('does not begin a drag for non-subgraph source nodes', async () => {
const plainNode = { id: 'plain' }
mockCreateAdapter.mockReturnValue({
renderLinks: [{ node: plainNode, fromSlotIndex: 0, fromPos: [0, 0] }],
network: { getNodeById: vi.fn() },
isInputValidDrop: vi.fn(),
isOutputValidDrop: vi.fn()
})
await mountBridge()
dispatchConnecting(canvas, 'input')
expect(useSlotLinkDragUIState().state.active).toBe(false)
})
it('ignores connecting events when no adapter is available', async () => {
mockCreateAdapter.mockReturnValue(null)
await mountBridge()
dispatchConnecting(canvas, 'input')
expect(useSlotLinkDragUIState().state.active).toBe(false)
})
it('begins a drag and marks slot compatibility for subgraph IO drags', async () => {
const node = createSubgraphIONode('sg1')
mockLayoutStore.getAllSlotKeys.mockReturnValue(['n2-in-0', 'n3-out-0'])
mockLayoutStore.getSlotLayout.mockImplementation((key: string) => {
if (key === 'n2-in-0')
return { nodeId: 'n2', index: 0, type: 'input' as const }
if (key === 'n3-out-0')
return { nodeId: 'n3', index: 0, type: 'output' as const }
return undefined
})
const isInputValidDrop = vi.fn(() => true)
mockCreateAdapter.mockReturnValue({
renderLinks: [
{ node, fromSlotIndex: 2, fromDirection: 3, fromPos: [10, 20] }
],
network: { getNodeById: vi.fn() },
isInputValidDrop,
isOutputValidDrop: vi.fn(() => false)
})
await mountBridge()
dispatchConnecting(canvas, 'input')
const { state } = useSlotLinkDragUIState()
expect(state.active).toBe(true)
expect(state.source).toMatchObject({
nodeId: 'sg1',
slotIndex: 2,
type: 'output'
})
// Only the slot matching the connecting side ('input') is evaluated.
expect(isInputValidDrop).toHaveBeenCalledWith('n2', 0)
expect(state.compatible.get('n2-in-0')).toBe(true)
expect(state.compatible.has('n3-out-0')).toBe(false)
})
it('resolves a candidate and highlights the snap target on pointer move', async () => {
const node = createSubgraphIONode('sg1')
mockCreateAdapter.mockReturnValue({
renderLinks: [
{ node, fromSlotIndex: 0, fromDirection: 3, fromPos: [0, 0] }
],
network: { getNodeById: vi.fn() },
isInputValidDrop: vi.fn(() => true),
isOutputValidDrop: vi.fn(() => false)
})
const slotEl = document.createElement('div')
slotEl.className = 'lg-slot'
const slotKeyEl = document.createElement('div')
slotKeyEl.setAttribute('data-slot-key', 'n2-in-0')
slotEl.appendChild(slotKeyEl)
const nodeEl = document.createElement('div')
nodeEl.setAttribute('data-node-id', 'n2')
nodeEl.appendChild(slotEl)
mockResolvePointerTarget.mockReturnValue(slotKeyEl)
mockOrchestrator.resolveSlotTargetCandidate.mockReturnValue({
layout: {
nodeId: 'n2',
index: 0,
type: 'input',
position: { x: 100, y: 200 }
},
compatible: true
})
mockOrchestrator.resolveNodeSurfaceSlotCandidate.mockReturnValue(null)
const dotEl = document.createElement('div')
const groupEl = document.createElement('div')
groupEl.appendChild(dotEl)
mockRegistry.getNode.mockReturnValue({
slots: new Map([['n2-in-0', { el: dotEl }]])
})
await mountBridge()
dispatchConnecting(canvas, 'input')
document.dispatchEvent(
new PointerEvent('pointermove', { clientX: 50, clientY: 60 })
)
const { state } = useSlotLinkDragUIState()
expect(state.candidate?.layout.nodeId).toBe('n2')
expect(groupEl.classList.contains('lg-slot--snap-target')).toBe(true)
expect(canvas.linkConnector.state.snapLinksPos).toEqual([100, 200])
expect(setDirty).toHaveBeenCalled()
})
it('connects to the compatible candidate on before-drop-on-canvas', async () => {
const node = createSubgraphIONode('sg1')
const connectToInput = vi.fn()
const targetNode = { inputs: [{}], outputs: [] }
const renderLink = {
node,
fromSlotIndex: 0,
fromDirection: 3,
fromPos: [0, 0],
toType: 'input',
canConnectToInput: vi.fn(() => true),
connectToInput
}
mockCreateAdapter.mockReturnValue({
renderLinks: [renderLink],
network: { getNodeById: vi.fn(() => targetNode) },
isInputValidDrop: vi.fn(() => true),
isOutputValidDrop: vi.fn(() => false)
})
mockResolvePointerTarget.mockReturnValue(null)
mockOrchestrator.resolveSlotTargetCandidate.mockReturnValue(null)
mockOrchestrator.resolveNodeSurfaceSlotCandidate.mockReturnValue(null)
await mountBridge()
dispatchConnecting(canvas, 'input')
// Seed a compatible candidate directly into the shared drag state.
useSlotLinkDragUIState().setCandidate({
layout: {
nodeId: 'n2',
index: 0,
type: 'input',
position: { x: 0, y: 0 }
},
compatible: true
} as never)
const dropEvent = new CustomEvent('before-drop-on-canvas')
const preventDefault = vi.spyOn(dropEvent, 'preventDefault')
canvas.linkConnector.events.dispatchEvent(dropEvent)
expect(connectToInput).toHaveBeenCalledWith(
targetNode,
targetNode.inputs[0],
canvas.linkConnector.events
)
expect(preventDefault).toHaveBeenCalled()
})
it('does not intercept the drop when there is no compatible candidate', async () => {
const node = createSubgraphIONode('sg1')
mockCreateAdapter.mockReturnValue({
renderLinks: [
{ node, fromSlotIndex: 0, fromDirection: 3, fromPos: [0, 0] }
],
network: { getNodeById: vi.fn() },
isInputValidDrop: vi.fn(() => true),
isOutputValidDrop: vi.fn(() => false)
})
await mountBridge()
dispatchConnecting(canvas, 'input')
const dropEvent = new CustomEvent('before-drop-on-canvas')
const preventDefault = vi.spyOn(dropEvent, 'preventDefault')
canvas.linkConnector.events.dispatchEvent(dropEvent)
expect(preventDefault).not.toHaveBeenCalled()
})
it('ends the drag on reset', async () => {
const node = createSubgraphIONode('sg1')
mockCreateAdapter.mockReturnValue({
renderLinks: [
{ node, fromSlotIndex: 0, fromDirection: 3, fromPos: [0, 0] }
],
network: { getNodeById: vi.fn() },
isInputValidDrop: vi.fn(() => true),
isOutputValidDrop: vi.fn(() => false)
})
await mountBridge()
dispatchConnecting(canvas, 'input')
expect(useSlotLinkDragUIState().state.active).toBe(true)
canvas.linkConnector.events.dispatchEvent(new CustomEvent('reset'))
expect(useSlotLinkDragUIState().state.active).toBe(false)
})
it('ends an in-progress drag when the scope is disposed', async () => {
const node = createSubgraphIONode('sg1')
mockCreateAdapter.mockReturnValue({
renderLinks: [
{ node, fromSlotIndex: 0, fromDirection: 3, fromPos: [0, 0] }
],
network: { getNodeById: vi.fn() },
isInputValidDrop: vi.fn(() => true),
isOutputValidDrop: vi.fn(() => false)
})
await mountBridge()
dispatchConnecting(canvas, 'input')
expect(useSlotLinkDragUIState().state.active).toBe(true)
scope.stop()
expect(useSlotLinkDragUIState().state.active).toBe(false)
})
})

View File

@@ -0,0 +1,360 @@
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 { SubgraphIONodeBase } from '@/lib/litegraph/src/subgraph/SubgraphIONodeBase'
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
// Only bridge SubgraphInput/SubgraphOutput drags; other canvas drags
// (e.g., reroutes) should not activate Vue slot drag UI state.
if (!(renderLink.node instanceof SubgraphIONodeBase)) 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

@@ -0,0 +1,297 @@
import { fromPartial } from '@total-typescript/shoehorn'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const {
capturedHandlers,
mockDs,
mockSetDirty,
mockLinkConnector,
mockAdapter,
mockGraph,
inputNode,
outputNode,
isSubgraphMock
} = vi.hoisted(() => {
const inputNode = {
isPointerOver: false,
onPointerMove: vi.fn(),
getSlotInPosition: vi.fn()
}
const outputNode = {
isPointerOver: false,
onPointerMove: vi.fn(),
getSlotInPosition: vi.fn()
}
const mockLinkConnector = {
isConnecting: false,
state: { snapLinksPos: null as [number, number] | null },
events: {}
}
return {
capturedHandlers: {} as Record<string, (...args: unknown[]) => void>,
mockDs: { offset: [0, 0] as [number, number], scale: 1 },
mockSetDirty: vi.fn(),
mockLinkConnector,
mockAdapter: {
beginFromOutput: vi.fn(),
beginFromInput: vi.fn(),
reset: vi.fn(),
renderLinks: [] as unknown[],
linkConnector: mockLinkConnector,
isInputValidDrop: vi.fn(() => false),
isOutputValidDrop: vi.fn(() => false),
dropOnCanvas: vi.fn()
},
mockGraph: {
inputNode,
outputNode,
getNodeById: () => ({
id: 'node1',
inputs: [],
outputs: [{ name: 'out', type: '*', links: [], _floatingLinks: null }]
}),
getLink: () => null,
getReroute: () => null
},
inputNode,
outputNode,
isSubgraphMock: vi.fn((_graph?: unknown) => true)
}
})
vi.mock('@/utils/typeGuardUtil', () => ({
isSubgraph: (graph: unknown) => isSubgraphMock(graph)
}))
vi.mock('@/renderer/core/canvas/useAutoPan', () => ({
AutoPanController: class {
updatePointer = vi.fn()
start = vi.fn()
stop = vi.fn()
}
}))
vi.mock('@/scripts/app', () => ({
app: {
canvas: {
ds: mockDs,
graph: mockGraph,
linkConnector: mockLinkConnector,
canvas: {
getBoundingClientRect: () => ({
left: 0,
top: 0,
right: 800,
bottom: 600,
width: 800,
height: 600
})
},
setDirty: mockSetDirty
}
}
}))
vi.mock('@/renderer/core/canvas/links/linkConnectorAdapter', () => ({
createLinkConnectorAdapter: () => mockAdapter
}))
vi.mock('@/renderer/core/canvas/links/slotLinkDragUIState', () => {
const pointer = { client: { x: 0, y: 0 }, canvas: { x: 0, y: 0 } }
return {
useSlotLinkDragUIState: () => ({
state: {
active: false,
pointerId: null,
source: null,
pointer,
candidate: null,
compatible: new Map()
},
beginDrag: vi.fn(),
endDrag: vi.fn(),
updatePointerPosition: (
cx: number,
cy: number,
canX: number,
canY: number
) => {
pointer.client.x = cx
pointer.client.y = cy
pointer.canvas.x = canX
pointer.canvas.y = canY
},
setCandidate: vi.fn(),
setCompatibleForKey: vi.fn(),
clearCompatible: vi.fn()
})
}
})
vi.mock('@/composables/element/useCanvasPositionConversion', () => ({
useSharedCanvasPositionConversion: () => ({
clientPosToCanvasPos: (pos: [number, number]): [number, number] => [
pos[0],
pos[1]
]
})
}))
vi.mock('@/renderer/core/layout/store/layoutStore', () => ({
layoutStore: {
getSlotLayout: () => ({
nodeId: 'node1',
index: 0,
type: 'output',
position: { x: 100, y: 200 }
}),
getAllSlotKeys: () => [],
getRerouteLayout: () => null,
queryRerouteAtPoint: () => null
}
}))
vi.mock('@/renderer/core/layout/slots/slotIdentifier', () => ({
getSlotKey: (...args: unknown[]) => args.join('-')
}))
vi.mock('@/renderer/core/canvas/interaction/canvasPointerEvent', () => ({
toCanvasPointerEvent: (e: PointerEvent) => e,
clearCanvasPointerHistory: vi.fn()
}))
vi.mock(
'@/renderer/extensions/vueNodes/composables/slotLinkDragContext',
() => ({
createSlotLinkDragContext: () => ({
pendingPointerMove: null,
lastPointerEventTarget: null,
lastPointerTargetSlotKey: null,
lastPointerTargetNodeId: null,
lastHoverSlotKey: null,
lastHoverNodeId: null,
lastCandidateKey: null,
reset: vi.fn(),
dispose: vi.fn()
})
})
)
vi.mock('@/renderer/extensions/vueNodes/utils/eventUtils', () => ({
augmentToCanvasPointerEvent: vi.fn()
}))
vi.mock('@/renderer/core/canvas/links/linkDropOrchestrator', () => ({
resolveSlotTargetCandidate: () => null,
resolveNodeSurfaceSlotCandidate: () => null
}))
vi.mock('@vueuse/core', () => ({
useEventListener: (event: string, handler: (...args: unknown[]) => void) => {
capturedHandlers[event] = handler
return vi.fn()
},
tryOnScopeDispose: () => {}
}))
vi.mock('@/lib/litegraph/src/LLink', () => ({
LLink: { getReroutes: () => [] }
}))
vi.mock('@/lib/litegraph/src/types/globalEnums', () => ({
LinkDirection: { LEFT: 0, RIGHT: 1, NONE: -1 }
}))
// Run RAF batches synchronously so a pointermove immediately processes a frame.
vi.mock('@/utils/rafBatch', () => ({
createRafBatch: (fn: () => void) => ({
schedule: fn,
cancel: () => {},
flush: fn
})
}))
import { useSlotLinkInteraction } from '@/renderer/extensions/vueNodes/composables/useSlotLinkInteraction'
function pointerEvent(clientX: number, clientY: number): PointerEvent {
return fromPartial<PointerEvent>({
clientX,
clientY,
button: 0,
pointerId: 1,
target: document.createElement('div'),
preventDefault: vi.fn(),
stopPropagation: vi.fn()
})
}
function startDrag() {
const { onPointerDown } = useSlotLinkInteraction({
nodeId: 'node1',
index: 0,
type: 'output'
})
onPointerDown(pointerEvent(400, 300))
}
describe('useSlotLinkInteraction subgraph IO snap', () => {
beforeEach(() => {
for (const k of Object.keys(capturedHandlers)) delete capturedHandlers[k]
mockDs.offset = [0, 0]
mockDs.scale = 1
mockSetDirty.mockClear()
mockLinkConnector.state.snapLinksPos = null
isSubgraphMock.mockReturnValue(true)
for (const node of [inputNode, outputNode]) {
node.isPointerOver = false
node.onPointerMove.mockReset()
node.getSlotInPosition.mockReset()
}
})
afterEach(() => {
vi.restoreAllMocks()
})
it('snaps to a hovered subgraph IO slot position', () => {
inputNode.onPointerMove.mockImplementation(() => {
inputNode.isPointerOver = true
})
inputNode.getSlotInPosition.mockReturnValue({
isPointerOver: true,
pos: [123, 456]
})
startDrag()
capturedHandlers['pointermove'](pointerEvent(200, 250))
expect(inputNode.onPointerMove).toHaveBeenCalled()
expect(mockLinkConnector.state.snapLinksPos).toEqual([123, 456])
expect(mockSetDirty).toHaveBeenCalledWith(true, true)
})
it('marks canvas dirty when subgraph IO hover state changes without a slot', () => {
outputNode.onPointerMove.mockImplementation(() => {
outputNode.isPointerOver = true
})
outputNode.getSlotInPosition.mockReturnValue(null)
startDrag()
mockSetDirty.mockClear()
capturedHandlers['pointermove'](pointerEvent(200, 250))
// Pointer moved over the IO node but not onto a slot: redraw, no snap.
expect(mockSetDirty).toHaveBeenCalledWith(true, true)
expect(mockLinkConnector.state.snapLinksPos).toEqual([200, 250])
})
it('does not run subgraph IO logic for non-subgraph graphs', () => {
isSubgraphMock.mockReturnValue(false)
startDrag()
capturedHandlers['pointermove'](pointerEvent(200, 250))
expect(inputNode.onPointerMove).not.toHaveBeenCalled()
expect(outputNode.onPointerMove).not.toHaveBeenCalled()
})
})

View File

@@ -12,6 +12,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,
@@ -33,6 +34,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
@@ -390,14 +392,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
@@ -406,7 +438,8 @@ export function useSlotLinkInteraction({
}
}
const shouldRedraw = candidateChanged || snapPosChanged
const shouldRedraw =
candidateChanged || snapPosChanged || subgraphIOHoverChanged
if (shouldRedraw) app.canvas?.setDirty(true, true)
}
const raf = createRafBatch(processPointerMoveFrame)
@@ -517,8 +550,6 @@ export function useSlotLinkInteraction({
raf.flush()
raf.flush()
if (!state.source) {
cleanupInteraction()
app.canvas?.setDirty(true, true)