Compare commits

...

1 Commits

Author SHA1 Message Date
pythongosssss
580f7555f4 fix: add autopan to subgraph io links 2026-05-01 02:33:04 -07:00
6 changed files with 200 additions and 64 deletions

View File

@@ -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()
}
})
}
})
})

View File

@@ -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()
}
})
})
})

View File

@@ -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)

View File

@@ -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

View File

@@ -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 */

View File

@@ -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 */