Further dynamic input fixes (#8026)

- Fix deserialization of matchtype inputs spawned by autogrow.
- Rotate multitype slot indicators to align with design changes.
- Fix several instance of incorrect group matching
- MatchType reactively updates input type in vue
- Support the "hollow circle" optional input indicator in vue
- Custom combo sends index of selection to backend

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8026-Further-dynamic-input-fixes-2e76d73d3650819680fef327a94f4294)
by [Unito](https://www.unito.io)
This commit is contained in:
AustinMroz
2026-01-22 00:02:49 -08:00
committed by GitHub
parent df93277802
commit 4a5e7c8bcb
7 changed files with 156 additions and 72 deletions

View File

@@ -1,19 +0,0 @@
<svg viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
<defs>
<clipPath id="hollow">
<path
d="M -50 50
A 100 100, 0, 0, 1, 150 50
A 100 100, 0, 0, 1, -50 50
M 30 50
A 20 20, 0, 0, 0, 70 50
A 20 20, 0, 0, 0, 30 50"/>
</clipPath>
</defs>
<g clip-path="var(--shape)" stroke-width="4">
<path d="M 50 0 A 50 50, 0, 0, 1, 50 100" fill="var(--type1, red)"/>
<path d="M 50 100 A 50 50, 0, 0, 1, 50 0" fill="var(--type2, blue)"/>
<path d="M50 0L50 100" stroke="var(--inner-stroke, black)"/>
<path d="M50 2A48 48 0 0 1 50 98A48 48 0 0 1 50 2" fill="transparent" stroke="var(--outer-stroke, transparent)"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 693 B

View File

@@ -1,20 +0,0 @@
<svg viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
<defs>
<clipPath id="hollow">
<path
d="M-50 50
A100 100 0 0 1 150 50
A100 100 0 0 1 -50 50
M30 50
A20 20 0 0 0 70 50
A20 20 0 0 0 30 50"/>
</clipPath>
</defs>
<g clip-path="var(--shape)" stroke-width="4">
<path d="M50 0A50 50 0 0 1 93 75L50 50" fill="var(--type1, red)"/>
<path d="M93 75A50 50 0 0 1 7 75L50 50" fill="var(--type2, blue)"/>
<path d="M7 75A50 50 0 0 1 50 0L50 50" fill="var(--type3, green)"/>
<path d="M50 50L50 0M50 50L93 75M50 50L7 75" stroke="var(--inner-stroke, black)"/>
<path d="M50 2A48 48 0 0 1 50 98A48 48 0 0 1 50 2" fill="transparent" stroke="var(--outer-stroke, transparent)"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 763 B

View File

@@ -175,4 +175,32 @@ describe('Autogrow', () => {
await nextTick() await nextTick()
expect(node.inputs.length).toBe(5) expect(node.inputs.length).toBe(5)
}) })
test('Can deserialize a complex node', async () => {
const graph = new LGraph()
const node = testNode()
graph.add(node)
addAutogrow(node, { min: 1, input: inputsSpec, prefix: 'a' })
addAutogrow(node, { min: 1, input: inputsSpec, prefix: 'b' })
addNodeInput(node, { name: 'aa', isOptional: false, type: 'IMAGE' })
connectInput(node, 0, graph)
connectInput(node, 1, graph)
connectInput(node, 3, graph)
connectInput(node, 4, graph)
const serialized = graph.serialize()
graph.clear()
graph.configure(serialized)
const newNode = graph.nodes[0]!
expect(newNode.inputs.map((i) => i.name)).toStrictEqual([
'0.a0',
'0.a1',
'0.a2',
'1.b0',
'1.b1',
'1.b2',
'aa'
])
})
}) })

View File

@@ -1,4 +1,5 @@
import { remove } from 'es-toolkit' import { remove } from 'es-toolkit'
import { shallowReactive } from 'vue'
import { useChainCallback } from '@/composables/functional/useChainCallback' import { useChainCallback } from '@/composables/functional/useChainCallback'
import type { import type {
@@ -342,7 +343,9 @@ function applyMatchType(node: LGraphNode, inputSpec: InputSpecV2) {
//ensure outputs get updated //ensure outputs get updated
const index = node.inputs.length - 1 const index = node.inputs.length - 1
requestAnimationFrame(() => { requestAnimationFrame(() => {
const input = node.inputs.at(index)! const input = node.inputs[index]
if (!input) return
node.inputs[index] = shallowReactive(input)
node.onConnectionsChange?.( node.onConnectionsChange?.(
LiteGraph.INPUT, LiteGraph.INPUT,
index, index,
@@ -385,11 +388,7 @@ function addAutogrowGroup(
...autogrowOrdinalToName(ordinal, input.name, groupName, node) ...autogrowOrdinalToName(ordinal, input.name, groupName, node)
})) }))
const newInputs = namedSpecs const newInputs = namedSpecs.map((namedSpec) => {
.filter(
(namedSpec) => !node.inputs.some((inp) => inp.name === namedSpec.name)
)
.map((namedSpec) => {
addNodeInput(node, namedSpec) addNodeInput(node, namedSpec)
const input = spliceInputs(node, node.inputs.length - 1, 1)[0] const input = spliceInputs(node, node.inputs.length - 1, 1)[0]
if (inputSpecs.length !== 1 || (INLINE_INPUTS && !input.widget)) if (inputSpecs.length !== 1 || (INLINE_INPUTS && !input.widget))
@@ -397,8 +396,24 @@ function addAutogrowGroup(
return input return input
}) })
for (const newInput of newInputs) {
for (const existingInput of remove(
node.inputs,
(inp) => inp.name === newInput.name
)) {
//NOTE: link.target_slot is updated on spliceInputs call
newInput.link ??= existingInput.link
}
}
const targetName = autogrowOrdinalToName(
ordinal - 1,
inputSpecs.at(-1)!.name,
groupName,
node
).name
const lastIndex = node.inputs.findLastIndex((inp) => const lastIndex = node.inputs.findLastIndex((inp) =>
inp.name.startsWith(groupName) inp.name.startsWith(targetName)
) )
const insertionIndex = lastIndex === -1 ? node.inputs.length : lastIndex + 1 const insertionIndex = lastIndex === -1 ? node.inputs.length : lastIndex + 1
spliceInputs(node, insertionIndex, 0, ...newInputs) spliceInputs(node, insertionIndex, 0, ...newInputs)
@@ -427,13 +442,14 @@ function autogrowInputConnected(index: number, node: AutogrowNode) {
const input = node.inputs[index] const input = node.inputs[index]
const groupName = input.name.slice(0, input.name.lastIndexOf('.')) const groupName = input.name.slice(0, input.name.lastIndexOf('.'))
const lastInput = node.inputs.findLast((inp) => const lastInput = node.inputs.findLast((inp) =>
inp.name.startsWith(groupName) inp.name.startsWith(groupName + '.')
) )
const ordinal = resolveAutogrowOrdinal(input.name, groupName, node) const ordinal = resolveAutogrowOrdinal(input.name, groupName, node)
if ( if (
!lastInput || !lastInput ||
ordinal == undefined || ordinal == undefined ||
ordinal !== resolveAutogrowOrdinal(lastInput.name, groupName, node) (ordinal !== resolveAutogrowOrdinal(lastInput.name, groupName, node) &&
!app.configuringGraph)
) )
return return
addAutogrowGroup(ordinal + 1, groupName, node) addAutogrowGroup(ordinal + 1, groupName, node)
@@ -453,6 +469,7 @@ function autogrowInputDisconnected(index: number, node: AutogrowNode) {
inp.name.lastIndexOf('.') === groupName.length inp.name.lastIndexOf('.') === groupName.length
) )
const stride = inputSpecs.length const stride = inputSpecs.length
if (stride + index >= node.inputs.length) return
if (groupInputs.length % stride !== 0) { if (groupInputs.length % stride !== 0) {
console.error('Failed to group multi-input autogrow inputs') console.error('Failed to group multi-input autogrow inputs')
return return
@@ -473,10 +490,24 @@ function autogrowInputDisconnected(index: number, node: AutogrowNode) {
const curIndex = node.inputs.findIndex((inp) => inp === curInput) const curIndex = node.inputs.findIndex((inp) => inp === curInput)
if (curIndex === -1) throw new Error('missing input') if (curIndex === -1) throw new Error('missing input')
link.target_slot = curIndex link.target_slot = curIndex
node.onConnectionsChange?.(
LiteGraph.INPUT,
curIndex,
true,
link,
curInput
)
} }
const lastInput = groupInputs.at(column - stride) const lastInput = groupInputs.at(column - stride)
if (!lastInput) continue if (!lastInput) continue
lastInput.link = null lastInput.link = null
node.onConnectionsChange?.(
LiteGraph.INPUT,
node.inputs.length + column - stride,
false,
null,
lastInput
)
} }
const removalChecks = groupInputs.slice((min - 1) * stride) const removalChecks = groupInputs.slice((min - 1) * stride)
let i let i
@@ -564,5 +595,6 @@ function applyAutogrow(node: LGraphNode, inputSpecV2: InputSpecV2) {
prefix, prefix,
inputSpecs: inputsV2 inputSpecs: inputsV2
} }
for (let i = 0; i < min; i++) addAutogrowGroup(i, inputSpecV2.name, node) for (let i = 0; i === 0 || i < min; i++)
addAutogrowGroup(i, inputSpecV2.name, node)
} }

View File

@@ -98,6 +98,19 @@ function onNodeCreated(this: LGraphNode) {
} }
}) })
} }
const widgets = this.widgets!
widgets.push({
name: 'index',
type: 'hidden',
get value() {
return widgets.slice(2).findIndex((w) => w.value === comboWidget.value)
},
set value(_) {},
draw: () => undefined,
computeSize: () => [0, -4],
options: { hidden: true },
y: 0
})
addOption(this) addOption(this)
} }

View File

@@ -30,7 +30,7 @@ export interface IDrawOptions {
highlight?: boolean highlight?: boolean
} }
const ROTATION_OFFSET = -Math.PI / 2 const ROTATION_OFFSET = -Math.PI
/** Shared base class for {@link LGraphNode} input and output slots. */ /** Shared base class for {@link LGraphNode} input and output slots. */
export abstract class NodeSlot extends SlotBase implements INodeSlot { export abstract class NodeSlot extends SlotBase implements INodeSlot {

View File

@@ -13,18 +13,29 @@ const props = defineProps<{
multi?: boolean multi?: boolean
}>() }>()
const clipPath = computed(() => {
switch (props.slotData?.shape) {
case 6:
return 'url(#square)'
case 7:
return 'url(#hollow)'
default:
return undefined
}
})
const slotElRef = useTemplateRef('slot-el') const slotElRef = useTemplateRef('slot-el')
function getTypes() { const types = computed(() => {
if (props.hasError) return ['var(--color-error)'] if (props.hasError) return ['var(--color-error)']
//TODO Support connected/disconnected colors? //TODO Support connected/disconnected colors?
if (!props.slotData) return [getSlotColor()] if (!props.slotData) return [getSlotColor()]
if (props.slotData.type === '*') return ['']
const typesSet = new Set( const typesSet = new Set(
`${props.slotData.type}`.split(',').map(getSlotColor) `${props.slotData.type}`.split(',').map(getSlotColor)
) )
return [...typesSet].slice(0, 3) return [...typesSet].slice(0, 3)
} })
const types = getTypes()
defineExpose({ defineExpose({
slotElRef slotElRef
@@ -52,26 +63,65 @@ const slotClass = computed(() =>
" "
> >
<div <div
v-if="types.length === 1" v-if="types.length === 1 && slotData?.shape == undefined"
ref="slot-el" ref="slot-el"
:style="{ backgroundColor: types[0] }" :style="{ backgroundColor: types.length === 1 ? types[0] : undefined }"
:class="slotClass" :class="slotClass"
/> />
<div <svg
v-else v-else
ref="slot-el" ref="slot-el"
:style="{
'--type1': types[0],
'--type2': types[1],
'--type3': types[2]
}"
:class="slotClass" :class="slotClass"
viewBox="0 0 100 100"
xmlns="http://www.w3.org/2000/svg"
> >
<i-comfy:node-slot2 <defs>
v-if="types.length === 2" <clipPath id="square">
class="size-full -translate-y-1/2" <rect x="20" y="20" width="60" height="60" />
</clipPath>
<clipPath id="hollow">
<path
d="M-50 50
A100 100 0 0 1 150 50
A100 100 0 0 1 -50 50
M30 50
A20 20 0 0 0 70 50
A20 20 0 0 0 30 50"
/> />
<i-comfy:node-slot3 v-else class="size-full -translate-y-1/2" /> </clipPath>
</div> </defs>
<circle
v-if="types.length === 1"
:clip-path
cx="50"
cy="50"
r="50"
:fill="types[0]"
/>
<g v-else-if="types.length === 2" :clip-path stroke-width="4">
<path d="M0 50 A 50 50 0 0 1 100 50" :fill="types[0]" />
<path d="M100 50 A 50 50 0 0 1 0 50" :fill="types[1]" />
<path d="M0 50L100 50" stroke="var(--inner-stroke, black)" />
<path
d="M50 2A48 48 0 0 1 50 98A48 48 0 0 1 50 2"
fill="transparent"
stroke="var(--outer-stroke, transparent)"
/>
</g>
<g v-else :clip-path stroke-width="4">
<path d="M0 50A50 50 0 0 0 75 93L50 50" :fill="types[0]" />
<path d="M75 93A50 50 0 0 0 75 7L50 50" :fill="types[1]" />
<path d="M75 7A50 50 0 0 0 0 50L50 50" :fill="types[2]" />
<path
d="M50 50L0 50M50 50L75 93M50 50L75 7"
stroke="var(--inner-stroke, black)"
/>
<path
d="M50 2A48 48 0 0 1 50 98A48 48 0 0 1 50 2"
fill="transparent"
stroke="var(--outer-stroke, transparent)"
/>
</g>
</svg>
</div> </div>
</template> </template>