mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-22 21:38:52 +00:00
## Summary Fix subgraph promoted widget identity/rendering so on-node widgets stay correct through configure/hydration churn, duplicate names, and linked+independent coexistence. ## Changes - **Subgraph promotion reconciliation**: stabilize linked-entry identity by subgraph slot id, preserve deterministic linked representative selection, and prune stale alias/fallback entries without dropping legitimate independent promotions. - **Promoted view resolution**: bind slot mapping by promoted view object identity (`getSlotFromWidget` / `getWidgetFromSlot`) to avoid same-name collisions. - **On-node widget rendering**: harden `NodeWidgets` identity and dedup to avoid visual aliasing, prefer visible duplicates over hidden stale entries, include type/source execution identity, and avoid collapsing transient unresolved entries. - **Mapping correctness**: update `useGraphNodeManager` promoted source mapping to resolve by input target only when the promoted view is actually bound to that input. - **Subgraph input uniqueness**: ensure empty-slot promotion creates unique input names (`seed`, `seed_1`, etc.) for same-name multi-source promotions. - **Safety fix**: guard against undefined canvas in slot-link interaction. - **Tests/fixtures**: add focused regressions for fixture path `subgraph_complex_promotion_1`, linked+independent same-name cases, duplicate-name identity mapping, dedup behavior, and input-name uniqueness. ## Review Focus Validate behavior around transient configure/hydration states (`-1` id to concrete id), duplicate-name promotions, linked representative recovery, and that dedup never hides legitimate widgets while still removing true duplicates. ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-9896-fix-stabilize-subgraph-promoted-widget-identity-and-rendering-3226d73d365081c8a1e8d0a5a22e826d) by [Unito](https://www.unito.io) --------- Co-authored-by: Amp <amp@ampcode.com>
272 lines
7.3 KiB
TypeScript
272 lines
7.3 KiB
TypeScript
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'
|
|
import type { LinkConnector } from '@/lib/litegraph/src/canvas/LinkConnector'
|
|
import { SUBGRAPH_INPUT_ID } from '@/lib/litegraph/src/constants'
|
|
import type {
|
|
DefaultConnectionColors,
|
|
INodeInputSlot,
|
|
INodeOutputSlot,
|
|
ISlotType,
|
|
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'
|
|
|
|
import { EmptySubgraphInput } from './EmptySubgraphInput'
|
|
import { SubgraphIONodeBase } from './SubgraphIONodeBase'
|
|
import type { SubgraphInput } from './SubgraphInput'
|
|
import type { SubgraphOutput } from './SubgraphOutput'
|
|
|
|
export class SubgraphInputNode
|
|
extends SubgraphIONodeBase<SubgraphInput>
|
|
implements Positionable
|
|
{
|
|
readonly id: NodeId = SUBGRAPH_INPUT_ID
|
|
|
|
readonly emptySlot: EmptySubgraphInput = new EmptySubgraphInput(this)
|
|
|
|
get slots() {
|
|
return this.subgraph.inputs
|
|
}
|
|
|
|
override get allSlots(): SubgraphInput[] {
|
|
return [...this.slots, this.emptySlot]
|
|
}
|
|
|
|
get slotAnchorX() {
|
|
const [x, , width] = this.boundingRect
|
|
return x + width - SubgraphIONodeBase.roundedRadius
|
|
}
|
|
|
|
override onPointerDown(
|
|
e: CanvasPointerEvent,
|
|
pointer: CanvasPointer,
|
|
linkConnector: LinkConnector
|
|
): 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)
|
|
}
|
|
}
|
|
|
|
/** @inheritdoc */
|
|
override renameSlot(slot: SubgraphInput, name: string): void {
|
|
this.subgraph.renameInput(slot, name)
|
|
}
|
|
|
|
/** @inheritdoc */
|
|
override removeSlot(slot: SubgraphInput): void {
|
|
this.subgraph.removeInput(slot)
|
|
}
|
|
|
|
canConnectTo(
|
|
inputNode: NodeLike,
|
|
input: INodeInputSlot,
|
|
fromSlot: SubgraphInput
|
|
): boolean {
|
|
return inputNode.canConnectTo(this, input, fromSlot)
|
|
}
|
|
|
|
connectSlots(
|
|
fromSlot: SubgraphInput,
|
|
inputNode: LGraphNode,
|
|
input: INodeInputSlot,
|
|
afterRerouteId: RerouteId | undefined
|
|
): LLink {
|
|
const { subgraph } = this
|
|
|
|
const outputIndex = this.slots.indexOf(fromSlot)
|
|
const inputIndex = inputNode.inputs.indexOf(input)
|
|
|
|
if (outputIndex === -1 || inputIndex === -1)
|
|
throw new Error('Invalid slot indices.')
|
|
|
|
return new LLink(
|
|
++subgraph.state.lastLinkId,
|
|
input.type || fromSlot.type,
|
|
this.id,
|
|
outputIndex,
|
|
inputNode.id,
|
|
inputIndex,
|
|
afterRerouteId
|
|
)
|
|
}
|
|
|
|
// #region Legacy LGraphNode compatibility
|
|
|
|
connectByType(
|
|
slot: number,
|
|
target_node: LGraphNode,
|
|
target_slotType: ISlotType,
|
|
optsIn?: { afterRerouteId?: RerouteId }
|
|
): LLink | undefined {
|
|
const inputSlot = target_node.findInputByType(target_slotType)
|
|
if (!inputSlot) return
|
|
|
|
if (slot === -1) {
|
|
// This indicates a connection is being made from the "Empty" slot.
|
|
// We need to create a new, concrete input on the subgraph that matches the target.
|
|
const existingNames = this.subgraph.inputs.map((input) => input.name)
|
|
const uniqueName = nextUniqueName(inputSlot.slot.name, existingNames)
|
|
const newSubgraphInput = this.subgraph.addInput(
|
|
uniqueName,
|
|
String(inputSlot.slot.type ?? '')
|
|
)
|
|
const newSlotIndex = this.slots.indexOf(newSubgraphInput)
|
|
if (newSlotIndex === -1) {
|
|
console.error('Could not find newly created subgraph input slot.')
|
|
return
|
|
}
|
|
slot = newSlotIndex
|
|
}
|
|
|
|
return this.slots[slot].connect(
|
|
inputSlot.slot,
|
|
target_node,
|
|
optsIn?.afterRerouteId
|
|
)
|
|
}
|
|
|
|
findOutputSlot(name: string): SubgraphInput | undefined {
|
|
return this.slots.find((output) => output.name === name)
|
|
}
|
|
|
|
findOutputByType(type: ISlotType): SubgraphInput | undefined {
|
|
return findFreeSlotOfType(
|
|
this.slots,
|
|
type,
|
|
(slot) => slot.linkIds.length > 0
|
|
)?.slot
|
|
}
|
|
|
|
// #endregion Legacy LGraphNode compatibility
|
|
|
|
_disconnectNodeInput(
|
|
node: LGraphNode,
|
|
input: INodeInputSlot,
|
|
link: LLink | undefined
|
|
): void {
|
|
const { subgraph } = this
|
|
|
|
// Break floating links
|
|
if (input._floatingLinks?.size) {
|
|
for (const link of input._floatingLinks) {
|
|
subgraph.removeFloatingLink(link)
|
|
}
|
|
}
|
|
|
|
input.link = null
|
|
subgraph.setDirtyCanvas(false, true)
|
|
|
|
if (!link) return
|
|
|
|
const subgraphInputIndex = link.origin_slot
|
|
link.disconnect(subgraph, 'output')
|
|
subgraph._version++
|
|
|
|
const subgraphInput = this.slots.at(subgraphInputIndex)
|
|
if (!subgraphInput) {
|
|
console.warn(
|
|
'disconnectNodeInput: subgraphInput not found',
|
|
this,
|
|
subgraphInputIndex
|
|
)
|
|
return
|
|
}
|
|
|
|
// search in the inputs list for this link
|
|
const index = subgraphInput.linkIds.indexOf(link.id)
|
|
if (index !== -1) {
|
|
subgraphInput.linkIds.splice(index, 1)
|
|
} else {
|
|
console.warn(
|
|
'disconnectNodeInput: link ID not found in subgraphInput linkIds',
|
|
link.id
|
|
)
|
|
}
|
|
const slotIndex = node.inputs.findIndex((inp) => inp === input)
|
|
if (slotIndex !== -1) {
|
|
node.onConnectionsChange?.(
|
|
NodeSlotType.INPUT,
|
|
slotIndex,
|
|
false,
|
|
link,
|
|
subgraphInput
|
|
)
|
|
}
|
|
}
|
|
|
|
override drawProtected(
|
|
ctx: CanvasRenderingContext2D,
|
|
colorContext: DefaultConnectionColors,
|
|
fromSlot?:
|
|
| INodeInputSlot
|
|
| INodeOutputSlot
|
|
| SubgraphInput
|
|
| SubgraphOutput,
|
|
editorAlpha?: number
|
|
): void {
|
|
const { roundedRadius } = SubgraphIONodeBase
|
|
const transform = ctx.getTransform()
|
|
|
|
const [x, y, width, height] = this.boundingRect
|
|
ctx.translate(x, y)
|
|
|
|
// Draw top rounded part
|
|
ctx.strokeStyle = this.sideStrokeStyle
|
|
ctx.lineWidth = this.sideLineWidth
|
|
ctx.beginPath()
|
|
ctx.arc(
|
|
width - roundedRadius,
|
|
roundedRadius,
|
|
roundedRadius,
|
|
Math.PI * 1.5,
|
|
0
|
|
)
|
|
|
|
// Straight line to bottom
|
|
ctx.moveTo(width, roundedRadius)
|
|
ctx.lineTo(width, height - roundedRadius)
|
|
|
|
// Bottom rounded part
|
|
ctx.arc(
|
|
width - roundedRadius,
|
|
height - roundedRadius,
|
|
roundedRadius,
|
|
0,
|
|
Math.PI * 0.5
|
|
)
|
|
ctx.stroke()
|
|
|
|
// Restore context
|
|
ctx.setTransform(transform)
|
|
|
|
this.drawSlots(ctx, colorContext, fromSlot, editorAlpha)
|
|
}
|
|
}
|