mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-22 05:19:03 +00:00
Compare commits
1 Commits
glary/raf-
...
pysssss/su
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4c09bb7488 |
@@ -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)
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
90
src/renderer/core/canvas/links/linkConnectorAdapter.test.ts
Normal file
90
src/renderer/core/canvas/links/linkConnectorAdapter.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
102
src/renderer/core/canvas/links/linkDropOrchestrator.test.ts
Normal file
102
src/renderer/core/canvas/links/linkDropOrchestrator.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user