Compare commits

...

1 Commits

Author SHA1 Message Date
pythongosssss
4c09bb7488 fix: add link to slot snapping 2026-05-01 02:34:57 -07:00
5 changed files with 411 additions and 2 deletions

View File

@@ -2,6 +2,7 @@ import type { Locator, Page } from '@playwright/test'
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
import { getSlotKey } from '@/renderer/core/layout/slots/slotIdentifier'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import {
comfyExpect as expect,
comfyPageFixture as test
@@ -85,6 +86,48 @@ async function getSlotCenter(
return await getCenter(locator)
}
async function enterBasicSubgraphWithKSampler(comfyPage: ComfyPage) {
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
await subgraphNode.navigateIntoSubgraph()
await fitToViewInstant(comfyPage)
return (await comfyPage.nodeOps.getNodeRefsByType('KSampler', true))[0]
}
async function getSubgraphSlotLinkCount(
page: Page,
direction: 'input' | 'output',
slotName: string
): Promise<number> {
return await page.evaluate(
([dir, name]) => {
const graph = window.app!.canvas.graph!
if (dir === 'input' && 'inputs' in graph) {
return graph.inputs.find((s) => s.name === name)?.linkIds?.length ?? 0
}
if (dir === 'output' && 'outputs' in graph) {
return graph.outputs.find((s) => s.name === name)?.linkIds?.length ?? 0
}
return 0
},
[direction, slotName] as const
)
}
async function getKSamplerInputName(
page: Page,
nodeId: NodeId,
slotIndex: number
): Promise<string | null> {
return await page.evaluate(
([id, idx]) => {
const graph = window.app!.canvas.graph!
return graph.getNodeById(id)?.inputs?.[idx]?.name ?? null
},
[nodeId, slotIndex] as const
)
}
async function connectSlots(
page: Page,
from: { nodeId: NodeId; index: number },
@@ -1131,5 +1174,125 @@ test.describe(
await expect.poll(() => positiveInput.getLinkCount()).toBe(1)
await expect.poll(() => negativeInput.getLinkCount()).toBe(0)
})
test('Dropping a link onto an existing subgraph output slot connects it', async ({
comfyPage,
comfyMouse
}) => {
const ksamplerNode = await enterBasicSubgraphWithKSampler(comfyPage)
const ksamplerLatentOutputCenter = await getSlotCenter(
comfyPage.page,
ksamplerNode.id,
0,
false
)
const slotPos = await comfyPage.subgraph
.getOutputSlot('LATENT')
.getPosition()
const outputCountBefore = await comfyPage.subgraph.getSlotCount('output')
await comfyMouse.move(ksamplerLatentOutputCenter)
await comfyMouse.drag(slotPos)
await comfyMouse.drop()
await expect
.poll(() => comfyPage.subgraph.getSlotCount('output'))
.toBe(outputCountBefore)
await expect
.poll(() =>
getSubgraphSlotLinkCount(comfyPage.page, 'output', 'LATENT')
)
.toBe(1)
})
test('Dropping a link onto the empty subgraph output slot creates a new slot', async ({
comfyPage,
comfyMouse
}) => {
const ksamplerNode = await enterBasicSubgraphWithKSampler(comfyPage)
const ksamplerLatentOutputCenter = await getSlotCenter(
comfyPage.page,
ksamplerNode.id,
0,
false
)
const emptyOutputPos = await comfyPage.subgraph
.getOutputSlot()
.getOpenSlotPosition()
const outputCountBefore = await comfyPage.subgraph.getSlotCount('output')
await comfyMouse.move(ksamplerLatentOutputCenter)
await comfyMouse.drag(emptyOutputPos)
await comfyMouse.drop()
await expect
.poll(() => comfyPage.subgraph.getSlotCount('output'))
.toBe(outputCountBefore + 1)
})
test('Dropping a link onto an existing subgraph input slot connects it', async ({
comfyPage,
comfyMouse
}) => {
const ksamplerNode = await enterBasicSubgraphWithKSampler(comfyPage)
// Sanity-check the workflow asset's slot ordering before relying on it.
// The 'negative' input must be unconnected so the new link is observable
// on the 'positive' boundary slot.
expect(
await getKSamplerInputName(comfyPage.page, ksamplerNode.id, 2)
).toBe('negative')
const ksamplerNegativeCenter = await getSlotCenter(
comfyPage.page,
ksamplerNode.id,
2,
true
)
const positiveInputPos = await comfyPage.subgraph
.getInputSlot('positive')
.getPosition()
const positiveLinkCountBefore = await getSubgraphSlotLinkCount(
comfyPage.page,
'input',
'positive'
)
await comfyMouse.move(ksamplerNegativeCenter)
await comfyMouse.drag(positiveInputPos)
await comfyMouse.drop()
await expect
.poll(() =>
getSubgraphSlotLinkCount(comfyPage.page, 'input', 'positive')
)
.toBe(positiveLinkCountBefore + 1)
})
test('Dropping a link onto the empty subgraph input slot creates a new slot', async ({
comfyPage,
comfyMouse
}) => {
const ksamplerNode = await enterBasicSubgraphWithKSampler(comfyPage)
expect(
await getKSamplerInputName(comfyPage.page, ksamplerNode.id, 2)
).toBe('negative')
const ksamplerNegativeCenter = await getSlotCenter(
comfyPage.page,
ksamplerNode.id,
2,
true
)
const emptyInputPos = await comfyPage.subgraph
.getInputSlot()
.getOpenSlotPosition()
const inputCountBefore = await comfyPage.subgraph.getSlotCount('input')
await comfyMouse.move(ksamplerNegativeCenter)
await comfyMouse.drag(emptyInputPos)
await comfyMouse.drop()
await expect
.poll(() => comfyPage.subgraph.getSlotCount('input'))
.toBe(inputCountBefore + 1)
})
}
)

View File

@@ -0,0 +1,90 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { LGraph, LinkConnector } from '@/lib/litegraph/src/litegraph'
import {
createTestSubgraph,
resetSubgraphFixtureState
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
import type { CanvasPointerEvent } from '@/lib/litegraph/src/types/events'
import { LinkConnectorAdapter } from '@/renderer/core/canvas/links/linkConnectorAdapter'
const dropEvent = (x: number, y: number) =>
({
canvasX: x,
canvasY: y
}) as Partial<CanvasPointerEvent> as CanvasPointerEvent
const buildAdapter = (network: LGraph) => {
const linkConnector = new LinkConnector(vi.fn())
const adapter = new LinkConnectorAdapter(network, linkConnector)
const dropOnIoNode = vi
.spyOn(linkConnector, 'dropOnIoNode')
.mockImplementation(() => {})
const dropOnNothing = vi
.spyOn(linkConnector, 'dropOnNothing')
.mockImplementation(() => {})
return { adapter, dropOnIoNode, dropOnNothing }
}
describe('LinkConnectorAdapter#dropOnCanvas', () => {
beforeEach(resetSubgraphFixtureState)
it('routes drops over the input boundary to dropOnIoNode', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'in', type: 'NUMBER' }]
})
subgraph.inputNode.arrange()
const { adapter, dropOnIoNode, dropOnNothing } = buildAdapter(subgraph)
const r = subgraph.inputNode.boundingRect
const event = dropEvent(r[0] + r[2] / 2, r[1] + r[3] / 2)
adapter.dropOnCanvas(event)
expect(dropOnIoNode).toHaveBeenCalledTimes(1)
expect(dropOnIoNode).toHaveBeenCalledWith(subgraph.inputNode, event)
expect(dropOnNothing).not.toHaveBeenCalled()
})
it('routes drops over the output boundary to dropOnIoNode', () => {
const subgraph = createTestSubgraph({
outputs: [{ name: 'out', type: 'NUMBER' }]
})
subgraph.outputNode.arrange()
const { adapter, dropOnIoNode, dropOnNothing } = buildAdapter(subgraph)
const r = subgraph.outputNode.boundingRect
const event = dropEvent(r[0] + r[2] / 2, r[1] + r[3] / 2)
adapter.dropOnCanvas(event)
expect(dropOnIoNode).toHaveBeenCalledTimes(1)
expect(dropOnIoNode).toHaveBeenCalledWith(subgraph.outputNode, event)
expect(dropOnNothing).not.toHaveBeenCalled()
})
it('falls through to dropOnNothing when the pointer is outside any IO node', () => {
const subgraph = createTestSubgraph({
outputs: [{ name: 'out', type: 'NUMBER' }]
})
subgraph.outputNode.arrange()
const { adapter, dropOnIoNode, dropOnNothing } = buildAdapter(subgraph)
const event = dropEvent(-9999, -9999)
adapter.dropOnCanvas(event)
expect(dropOnIoNode).not.toHaveBeenCalled()
expect(dropOnNothing).toHaveBeenCalledTimes(1)
expect(dropOnNothing).toHaveBeenCalledWith(event)
})
it('always calls dropOnNothing when the network is not a subgraph', () => {
const rootGraph = new LGraph()
const { adapter, dropOnIoNode, dropOnNothing } = buildAdapter(rootGraph)
const event = dropEvent(0, 0)
adapter.dropOnCanvas(event)
expect(dropOnIoNode).not.toHaveBeenCalled()
expect(dropOnNothing).toHaveBeenCalledTimes(1)
expect(dropOnNothing).toHaveBeenCalledWith(event)
})
})

View File

@@ -0,0 +1,102 @@
import { beforeEach, describe, expect, it } from 'vitest'
import { LGraph } from '@/lib/litegraph/src/litegraph'
import {
createTestSubgraph,
resetSubgraphFixtureState
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
import { applySubgraphIoHoverHighlight } from '@/renderer/core/canvas/links/linkDropOrchestrator'
const inputNodeCenter = (subgraph: ReturnType<typeof createTestSubgraph>) => {
const r = subgraph.inputNode.boundingRect
return { x: r[0] + r[2] / 2, y: r[1] + r[3] / 2 }
}
const outputNodeCenter = (subgraph: ReturnType<typeof createTestSubgraph>) => {
const r = subgraph.outputNode.boundingRect
return { x: r[0] + r[2] / 2, y: r[1] + r[3] / 2 }
}
describe('applySubgraphIoHoverHighlight', () => {
beforeEach(resetSubgraphFixtureState)
it('returns false and is a no-op for a non-subgraph root graph', () => {
const graph = new LGraph()
expect(applySubgraphIoHoverHighlight(graph, 0, 0)).toBe(false)
})
it('forwards the pointer to both IO nodes when hovering inside one', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'in', type: 'NUMBER' }],
outputs: [{ name: 'out', type: 'NUMBER' }]
})
subgraph.inputNode.arrange()
subgraph.outputNode.arrange()
const center = inputNodeCenter(subgraph)
const changed = applySubgraphIoHoverHighlight(subgraph, center.x, center.y)
expect(changed).toBe(true)
expect(subgraph.inputNode.isPointerOver).toBe(true)
expect(subgraph.outputNode.isPointerOver).toBe(false)
})
it('returns false on subsequent calls when hover state is unchanged', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'in', type: 'NUMBER' }]
})
subgraph.inputNode.arrange()
const center = inputNodeCenter(subgraph)
applySubgraphIoHoverHighlight(subgraph, center.x, center.y)
const second = applySubgraphIoHoverHighlight(subgraph, center.x, center.y)
expect(second).toBe(false)
})
it('returns true and clears hover state when the pointer leaves the IO node', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'in', type: 'NUMBER' }]
})
subgraph.inputNode.arrange()
const center = inputNodeCenter(subgraph)
applySubgraphIoHoverHighlight(subgraph, center.x, center.y)
const left = applySubgraphIoHoverHighlight(subgraph, -9999, -9999)
expect(left).toBe(true)
expect(subgraph.inputNode.isPointerOver).toBe(false)
expect(subgraph.inputNode.allSlots.every((s) => !s.isPointerOver)).toBe(
true
)
})
it('updates per-slot hover flags when entering a slot', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'in', type: 'NUMBER' }]
})
subgraph.inputNode.arrange()
const slot = subgraph.inputNode.slots[0]
const r = slot.boundingRect
const slotCenter = { x: r[0] + r[2] / 2, y: r[1] + r[3] / 2 }
applySubgraphIoHoverHighlight(subgraph, slotCenter.x, slotCenter.y)
expect(slot.isPointerOver).toBe(true)
})
it('detects transitions on the output IO node independently', () => {
const subgraph = createTestSubgraph({
outputs: [{ name: 'out', type: 'NUMBER' }]
})
subgraph.outputNode.arrange()
const center = outputNodeCenter(subgraph)
const changed = applySubgraphIoHoverHighlight(subgraph, center.x, center.y)
expect(changed).toBe(true)
expect(subgraph.outputNode.isPointerOver).toBe(true)
expect(subgraph.inputNode.isPointerOver).toBe(false)
})
})

View File

@@ -1,11 +1,15 @@
import type { LGraph } from '@/lib/litegraph/src/LGraph'
import type { NodeId } from '@/lib/litegraph/src/LGraphNode'
import type { SubgraphInputNode } from '@/lib/litegraph/src/subgraph/SubgraphInputNode'
import type { SubgraphOutputNode } from '@/lib/litegraph/src/subgraph/SubgraphOutputNode'
import type { CanvasPointerEvent } from '@/lib/litegraph/src/types/events'
import type { LinkConnectorAdapter } from '@/renderer/core/canvas/links/linkConnectorAdapter'
import { useSlotLinkDragUIState } from '@/renderer/core/canvas/links/slotLinkDragUIState'
import type { SlotDropCandidate } from '@/renderer/core/canvas/links/slotLinkDragUIState'
import { getSlotKey } from '@/renderer/core/layout/slots/slotIdentifier'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import type { SlotLinkDragContext } from '@/renderer/extensions/vueNodes/composables/slotLinkDragContext'
import { isSubgraph } from '@/utils/typeGuardUtil'
interface DropResolutionContext {
adapter: LinkConnectorAdapter | null
@@ -117,3 +121,41 @@ export const resolveNodeSurfaceSlotCandidate = (
return { layout, compatible: true }
}
type IoNode = SubgraphInputNode | SubgraphOutputNode
interface IoHoverSnapshot {
node: boolean
slots: boolean[]
}
const captureIoHover = (ioNode: IoNode): IoHoverSnapshot => ({
node: ioNode.isPointerOver,
slots: ioNode.allSlots.map((slot) => slot.isPointerOver)
})
const ioHoverChanged = (before: IoHoverSnapshot, ioNode: IoNode): boolean => {
if (before.node !== ioNode.isPointerOver) return true
const slots = ioNode.allSlots
if (before.slots.length !== slots.length) return true
return slots.some((slot, i) => before.slots[i] !== slot.isPointerOver)
}
export const applySubgraphIoHoverHighlight = (
graph: LGraph,
canvasX: number,
canvasY: number
): boolean => {
if (!isSubgraph(graph)) return false
const event = { canvasX, canvasY } as Partial<CanvasPointerEvent>
const hoverEvent = event as CanvasPointerEvent
let changed = false
for (const ioNode of [graph.inputNode, graph.outputNode]) {
const before = captureIoHover(ioNode)
ioNode.onPointerMove(hoverEvent)
if (ioHoverChanged(before, ioNode)) changed = true
}
return changed
}

View File

@@ -20,6 +20,7 @@ import {
import { createLinkConnectorAdapter } from '@/renderer/core/canvas/links/linkConnectorAdapter'
import type { LinkConnectorAdapter } from '@/renderer/core/canvas/links/linkConnectorAdapter'
import {
applySubgraphIoHoverHighlight,
resolveNodeSurfaceSlotCandidate,
resolveSlotTargetCandidate
} from '@/renderer/core/canvas/links/linkDropOrchestrator'
@@ -406,8 +407,19 @@ export function useSlotLinkInteraction({
}
}
const shouldRedraw = candidateChanged || snapPosChanged
if (shouldRedraw) app.canvas?.setDirty(true, true)
let ioHoverChanged = false
const graph = activeAdapter ? app.canvas?.graph : null
if (graph) {
ioHoverChanged = applySubgraphIoHoverHighlight(
graph,
state.pointer.canvas.x,
state.pointer.canvas.y
)
}
if (candidateChanged || snapPosChanged || ioHoverChanged) {
app.canvas?.setDirty(true, true)
}
}
const raf = createRafBatch(processPointerMoveFrame)