mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-07-02 21:28:08 +00:00
Compare commits
8 Commits
codex/cove
...
fix/subgra
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
886f2e39bf | ||
|
|
3006932287 | ||
|
|
7c1195e389 | ||
|
|
f370700fbd | ||
|
|
3cee74a0c5 | ||
|
|
f5167ec744 | ||
|
|
180fed69d8 | ||
|
|
0d657402e7 |
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
87
src/lib/litegraph/src/canvas/LinkConnectorDropEvents.test.ts
Normal file
87
src/lib/litegraph/src/canvas/LinkConnectorDropEvents.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
@@ -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': {
|
||||
|
||||
100
src/lib/litegraph/src/subgraph/SubgraphIOSlotLinks.test.ts
Normal file
100
src/lib/litegraph/src/subgraph/SubgraphIOSlotLinks.test.ts
Normal 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'
|
||||
)
|
||||
}
|
||||
)
|
||||
})
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
352
src/renderer/core/canvas/links/useSubgraphDragBridge.test.ts
Normal file
352
src/renderer/core/canvas/links/useSubgraphDragBridge.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
360
src/renderer/core/canvas/links/useSubgraphDragBridge.ts
Normal file
360
src/renderer/core/canvas/links/useSubgraphDragBridge.ts
Normal 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
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user