mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-22 05:19:03 +00:00
Compare commits
1 Commits
ext-api/i-
...
pysssss/su
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
580f7555f4 |
@@ -631,4 +631,52 @@ test.describe('Subgraph Slots', { tag: ['@slow', '@subgraph'] }, () => {
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('IO slot link drag edge panning', () => {
|
||||
const cases = [
|
||||
{ side: 'input', slot: 'positive', edge: 'right', sign: -1 },
|
||||
{ side: 'output', slot: 'LATENT', edge: 'left', sign: 1 }
|
||||
] as const
|
||||
|
||||
for (const { side, slot: slotName, edge, sign } of cases) {
|
||||
test(`Pans canvas while dragging a new link from a subgraph ${side} slot near the ${edge} edge`, async ({
|
||||
comfyPage,
|
||||
comfyMouse
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
const node = await comfyPage.nodeOps.getNodeRefById('2')
|
||||
await node.navigateIntoSubgraph()
|
||||
|
||||
const slot =
|
||||
side === 'input'
|
||||
? comfyPage.subgraph.getInputSlot(slotName)
|
||||
: comfyPage.subgraph.getOutputSlot(slotName)
|
||||
const slotPos = await slot.getPosition()
|
||||
const canvasBox = await comfyPage.canvas.boundingBox()
|
||||
if (!canvasBox) throw new Error('canvas has no bounding box')
|
||||
|
||||
try {
|
||||
const [initialOffsetX] = await comfyPage.canvasOps.getOffset()
|
||||
await comfyMouse.move(slotPos)
|
||||
await comfyMouse.drag({
|
||||
x:
|
||||
edge === 'left'
|
||||
? canvasBox.x + 5
|
||||
: canvasBox.x + canvasBox.width - 5,
|
||||
y: slotPos.y
|
||||
})
|
||||
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const diff =
|
||||
(await comfyPage.canvasOps.getOffset())[0] - initialOffsetX
|
||||
return Math.abs(diff) > 20 ? Math.sign(diff) : null
|
||||
})
|
||||
.toBe(sign)
|
||||
} finally {
|
||||
await comfyMouse.drop()
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { LGraph, LGraphCanvas } from '@/lib/litegraph/src/litegraph'
|
||||
import { createMockCanvasRenderingContext2D } from '@/utils/__tests__/litegraphTestUtils'
|
||||
import { LGraph, LGraphCanvas, LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import {
|
||||
createMockCanvasPointerEvent,
|
||||
createMockCanvasRenderingContext2D
|
||||
} from '@/utils/__tests__/litegraphTestUtils'
|
||||
|
||||
import { createTestSubgraph } from './subgraph/__fixtures__/subgraphHelpers'
|
||||
|
||||
vi.mock('@/renderer/core/layout/store/layoutStore', () => ({
|
||||
layoutStore: {
|
||||
@@ -142,4 +147,121 @@ describe('LGraphCanvas link drag auto-pan', () => {
|
||||
expect(canvas.ds.offset[0]).toBe(offsetBefore[0])
|
||||
expect(canvas.ds.offset[1]).toBe(offsetBefore[1])
|
||||
})
|
||||
|
||||
describe('subgraph IO slots', () => {
|
||||
function openSubgraph(
|
||||
kind: 'input' | 'output',
|
||||
slotNames: string[] = ['slot0']
|
||||
) {
|
||||
const subgraph = createTestSubgraph(
|
||||
kind === 'input'
|
||||
? { inputs: slotNames.map((name) => ({ name, type: 'STRING' })) }
|
||||
: { outputs: slotNames.map((name) => ({ name, type: 'STRING' })) }
|
||||
)
|
||||
canvas.setGraph(subgraph)
|
||||
const ioNode = kind === 'input' ? subgraph.inputNode : subgraph.outputNode
|
||||
ioNode.arrange()
|
||||
return ioNode
|
||||
}
|
||||
|
||||
const cases = [
|
||||
{ kind: 'input', slot: 'first', mouseX: 5, sign: 1 },
|
||||
{ kind: 'output', slot: 'first', mouseX: 795, sign: -1 },
|
||||
{ kind: 'input', slot: 'empty', mouseX: 5, sign: 1 },
|
||||
{ kind: 'output', slot: 'empty', mouseX: 795, sign: -1 }
|
||||
] as const
|
||||
|
||||
it.each(cases)(
|
||||
'starts a link drag and pans in the right direction when dragging a subgraph $kind $slot slot near the edge',
|
||||
({ kind, slot: slotKind, mouseX, sign }) => {
|
||||
const ioNode = openSubgraph(kind)
|
||||
canvas.mouse[0] = mouseX
|
||||
canvas.mouse[1] = 300
|
||||
|
||||
const slot = slotKind === 'empty' ? ioNode.emptySlot : ioNode.slots[0]
|
||||
const [sx, sy, sw, sh] = slot.boundingRect
|
||||
canvas['_processPrimaryButton'](
|
||||
createMockCanvasPointerEvent(sx + sw / 2, sy + sh / 2, { button: 0 }),
|
||||
undefined
|
||||
)
|
||||
|
||||
// Fire the drag-start callback that onPointerDown wired up — this is
|
||||
// what actually creates the link. Without it, we'd only be testing
|
||||
// that auto-pan starts on mousedown, not the full drag flow.
|
||||
canvas.pointer.onDragStart!(canvas.pointer)
|
||||
expect(canvas.linkConnector.isConnecting).toBe(true)
|
||||
|
||||
const before = canvas.ds.offset[0]
|
||||
vi.advanceTimersByTime(16)
|
||||
expect(Math.sign(canvas.ds.offset[0] - before)).toBe(sign)
|
||||
}
|
||||
)
|
||||
|
||||
it('uses getSlotInPosition to route the link drag through the correct slot when multiple are present', () => {
|
||||
const ioNode = openSubgraph('input', ['in0', 'in1', 'in2'])
|
||||
const dragSpy = vi.spyOn(canvas.linkConnector, 'dragNewFromSubgraphInput')
|
||||
|
||||
const targetSlot = ioNode.slots[1]
|
||||
const [sx, sy, sw, sh] = targetSlot.boundingRect
|
||||
canvas.mouse[0] = sx + sw / 2
|
||||
canvas.mouse[1] = sy + sh / 2
|
||||
|
||||
canvas['_processPrimaryButton'](
|
||||
createMockCanvasPointerEvent(sx + sw / 2, sy + sh / 2, { button: 0 }),
|
||||
undefined
|
||||
)
|
||||
canvas.pointer.onDragStart!(canvas.pointer)
|
||||
|
||||
expect(dragSpy).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
ioNode,
|
||||
targetSlot
|
||||
)
|
||||
})
|
||||
|
||||
it('does not select the IO node on a left-click without drag (matches regular-node slot click behavior)', () => {
|
||||
const ioNode = openSubgraph('input')
|
||||
const slot = ioNode.slots[0]
|
||||
const [sx, sy, sw, sh] = slot.boundingRect
|
||||
|
||||
canvas['_processPrimaryButton'](
|
||||
createMockCanvasPointerEvent(sx + sw / 2, sy + sh / 2, { button: 0 }),
|
||||
undefined
|
||||
)
|
||||
|
||||
expect(canvas.pointer.onClick).toBeUndefined()
|
||||
|
||||
canvas.pointer.finally?.()
|
||||
expect(ioNode.selected).toBe(false)
|
||||
})
|
||||
|
||||
it('right-click on an IO slot opens the context menu without arming drag handlers or auto-pan', () => {
|
||||
const ioNode = openSubgraph('input')
|
||||
const ContextMenuSpy = vi
|
||||
.spyOn(LiteGraph, 'ContextMenu')
|
||||
.mockImplementation(function ContextMenuStub() {
|
||||
return Object.create(LiteGraph.ContextMenu.prototype)
|
||||
})
|
||||
|
||||
try {
|
||||
const [sx, sy, sw, sh] = ioNode.slots[0].boundingRect
|
||||
const event = createMockCanvasPointerEvent(sx + sw / 2, sy + sh / 2, {
|
||||
button: 2
|
||||
})
|
||||
const result = ioNode.onPointerDown(
|
||||
event,
|
||||
canvas.pointer,
|
||||
canvas.linkConnector
|
||||
)
|
||||
|
||||
expect(result).toBe(false)
|
||||
expect(ContextMenuSpy).toHaveBeenCalledTimes(1)
|
||||
expect(canvas['_autoPan']).toBeNull()
|
||||
expect(canvas.pointer.onDragStart).toBeUndefined()
|
||||
expect(canvas.pointer.onDragEnd).toBeUndefined()
|
||||
} finally {
|
||||
ContextMenuSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2479,7 +2479,10 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
) {
|
||||
if (!ioNode.containsPoint([x, y])) return false
|
||||
|
||||
ioNode.onPointerDown(e, pointer, linkConnector)
|
||||
if (ioNode.onPointerDown(e, pointer, linkConnector)) {
|
||||
canvas._linkConnectorDrop()
|
||||
return true
|
||||
}
|
||||
pointer.onClick ??= () => canvas.processSelect(ioNode, e)
|
||||
pointer.onDragStart ??= () =>
|
||||
canvas._startDraggingItems(ioNode, pointer, true)
|
||||
|
||||
@@ -96,10 +96,25 @@ export abstract class SubgraphIONodeBase<
|
||||
return this.pinned ? false : snapPoint(this.pos, snapTo)
|
||||
}
|
||||
|
||||
abstract onPointerDown(
|
||||
onPointerDown(
|
||||
e: CanvasPointerEvent,
|
||||
pointer: CanvasPointer,
|
||||
linkConnector: LinkConnector
|
||||
): boolean {
|
||||
const slot = this.getSlotInPosition(e.canvasX, e.canvasY)
|
||||
if (!slot) return false
|
||||
if (e.button === 0) {
|
||||
pointer.onDragStart = () => this.beginLinkDrag(linkConnector, slot)
|
||||
pointer.onDoubleClick = () => this.handleSlotDoubleClick(slot, e)
|
||||
return true
|
||||
}
|
||||
if (e.button === 2) this.showSlotContextMenu(slot, e)
|
||||
return false
|
||||
}
|
||||
|
||||
protected abstract beginLinkDrag(
|
||||
linkConnector: LinkConnector,
|
||||
slot: TSlot
|
||||
): void
|
||||
|
||||
// #region Hoverable
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { CanvasPointer } from '@/lib/litegraph/src/CanvasPointer'
|
||||
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/LGraphNode'
|
||||
import { LLink } from '@/lib/litegraph/src/LLink'
|
||||
import type { RerouteId } from '@/lib/litegraph/src/Reroute'
|
||||
@@ -12,7 +11,6 @@ import type {
|
||||
Positionable
|
||||
} from '@/lib/litegraph/src/interfaces'
|
||||
import type { NodeLike } from '@/lib/litegraph/src/types/NodeLike'
|
||||
import type { CanvasPointerEvent } from '@/lib/litegraph/src/types/events'
|
||||
import { NodeSlotType } from '@/lib/litegraph/src/types/globalEnums'
|
||||
import { findFreeSlotOfType } from '@/lib/litegraph/src/utils/collections'
|
||||
import { nextUniqueName } from '@/lib/litegraph/src/strings'
|
||||
@@ -43,35 +41,11 @@ export class SubgraphInputNode
|
||||
return x + width - SubgraphIONodeBase.roundedRadius
|
||||
}
|
||||
|
||||
override onPointerDown(
|
||||
e: CanvasPointerEvent,
|
||||
pointer: CanvasPointer,
|
||||
linkConnector: LinkConnector
|
||||
protected override beginLinkDrag(
|
||||
linkConnector: LinkConnector,
|
||||
slot: SubgraphInput
|
||||
): void {
|
||||
// Left-click handling for dragging connections
|
||||
if (e.button === 0) {
|
||||
for (const slot of this.allSlots) {
|
||||
// Check if click is within the full slot area (including label)
|
||||
if (slot.boundingRect.containsXy(e.canvasX, e.canvasY)) {
|
||||
pointer.onDragStart = () => {
|
||||
linkConnector.dragNewFromSubgraphInput(this.subgraph, this, slot)
|
||||
}
|
||||
pointer.onDragEnd = (eUp) => {
|
||||
linkConnector.dropLinks(this.subgraph, eUp)
|
||||
}
|
||||
pointer.onDoubleClick = () => {
|
||||
this.handleSlotDoubleClick(slot, e)
|
||||
}
|
||||
pointer.finally = () => {
|
||||
linkConnector.reset(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Check for right-click
|
||||
} else if (e.button === 2) {
|
||||
const slot = this.getSlotInPosition(e.canvasX, e.canvasY)
|
||||
if (slot) this.showSlotContextMenu(slot, e)
|
||||
}
|
||||
linkConnector.dragNewFromSubgraphInput(this.subgraph, this, slot)
|
||||
}
|
||||
|
||||
/** @inheritdoc */
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { CanvasPointer } from '@/lib/litegraph/src/CanvasPointer'
|
||||
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { LLink } from '@/lib/litegraph/src/LLink'
|
||||
import type { RerouteId } from '@/lib/litegraph/src/Reroute'
|
||||
@@ -12,7 +11,6 @@ import type {
|
||||
Positionable
|
||||
} from '@/lib/litegraph/src/interfaces'
|
||||
import type { NodeLike } from '@/lib/litegraph/src/types/NodeLike'
|
||||
import type { CanvasPointerEvent } from '@/lib/litegraph/src/types/events'
|
||||
import type { SubgraphIO } from '@/lib/litegraph/src/types/serialisation'
|
||||
import { findFreeSlotOfType } from '@/lib/litegraph/src/utils/collections'
|
||||
|
||||
@@ -42,35 +40,11 @@ export class SubgraphOutputNode
|
||||
return x + SubgraphIONodeBase.roundedRadius
|
||||
}
|
||||
|
||||
override onPointerDown(
|
||||
e: CanvasPointerEvent,
|
||||
pointer: CanvasPointer,
|
||||
linkConnector: LinkConnector
|
||||
protected override beginLinkDrag(
|
||||
linkConnector: LinkConnector,
|
||||
slot: SubgraphOutput
|
||||
): void {
|
||||
// Left-click handling for dragging connections
|
||||
if (e.button === 0) {
|
||||
for (const slot of this.allSlots) {
|
||||
// Check if click is within the full slot area (including label)
|
||||
if (slot.boundingRect.containsXy(e.canvasX, e.canvasY)) {
|
||||
pointer.onDragStart = () => {
|
||||
linkConnector.dragNewFromSubgraphOutput(this.subgraph, this, slot)
|
||||
}
|
||||
pointer.onDragEnd = (eUp) => {
|
||||
linkConnector.dropLinks(this.subgraph, eUp)
|
||||
}
|
||||
pointer.onDoubleClick = () => {
|
||||
this.handleSlotDoubleClick(slot, e)
|
||||
}
|
||||
pointer.finally = () => {
|
||||
linkConnector.reset(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Check for right-click
|
||||
} else if (e.button === 2) {
|
||||
const slot = this.getSlotInPosition(e.canvasX, e.canvasY)
|
||||
if (slot) this.showSlotContextMenu(slot, e)
|
||||
}
|
||||
linkConnector.dragNewFromSubgraphOutput(this.subgraph, this, slot)
|
||||
}
|
||||
|
||||
/** @inheritdoc */
|
||||
|
||||
Reference in New Issue
Block a user