add support for dynamic combos on subgraphs

This commit is contained in:
pythongosssss
2026-01-27 15:05:09 -08:00
parent 440e25e232
commit 24cbf4f68c
4 changed files with 205 additions and 21 deletions

View File

@@ -1,5 +1,6 @@
import {
demoteWidget,
isDynamicComboChild,
promoteRecommendedWidgets
} from '@/core/graph/subgraph/proxyWidgetUtils'
import { parseProxyWidgets } from '@/core/schemas/proxyWidget'
@@ -37,6 +38,10 @@ type Overlay = Partial<IBaseWidget> & {
widgetName: string
isProxyWidget: boolean
node?: LGraphNode
/** Hidden state for disconnected dynamic combo children */
hidden?: boolean
/** Flag to trigger re-resolution when source node's widgets change */
needsResolve?: boolean
}
// A ProxyWidget can be treated like a normal widget.
// the _overlay property can be used to directly access the Overlay object
@@ -169,7 +174,7 @@ function resolveLinkedWidget(
const n = getNodeByExecutionId(graph, nodeId)
if (!n) return [undefined, undefined]
const widget = n.widgets?.find((w: IBaseWidget) => w.name === widgetName)
//Slightly hacky. Force recursive resolution of nested widgets
// Slightly hacky. Force recursive resolution of nested widgets
if (widget && isProxyWidget(widget) && isDisconnectedWidget(widget))
widget.computedHeight = 20
return [n, widget]
@@ -188,6 +193,18 @@ function newProxyFromOverlay(subgraphNode: SubgraphNode, overlay: Overlay) {
}
})
}
function updateHiddenState() {
const shouldHide =
backingWidget === disconnectedWidget &&
linkedNode !== undefined &&
isDynamicComboChild(linkedNode, overlay.widgetName)
if (overlay.hidden !== shouldHide) {
overlay.hidden = shouldHide
subgraphNode.setDirtyCanvas(true, true)
}
}
/**
* A set of handlers which define widget interaction
* Many arguments are shared between function calls
@@ -201,6 +218,13 @@ function newProxyFromOverlay(subgraphNode: SubgraphNode, overlay: Overlay) {
*/
const handler = {
get(_t: IBaseWidget, property: string, receiver: object) {
// Re-resolve when marked dirty (source node's widgets changed)
if (property === 'hidden' && overlay.needsResolve) {
;[linkedNode, linkedWidget] = resolveLinkedWidget(overlay)
backingWidget = linkedWidget ?? disconnectedWidget
overlay.needsResolve = false
updateHiddenState()
}
let redirectedTarget: object = backingWidget
let redirectedReceiver = receiver
if (property == '_overlay') return overlay
@@ -220,9 +244,10 @@ function newProxyFromOverlay(subgraphNode: SubgraphNode, overlay: Overlay) {
if (linkedNode && linkedWidget?.computedDisabled) {
demoteWidget(linkedNode, linkedWidget, [subgraphNode])
}
//update linkage regularly, but no more than once per frame
// Update linkage regularly, but no more than once per frame
;[linkedNode, linkedWidget] = resolveLinkedWidget(overlay)
backingWidget = linkedWidget ?? disconnectedWidget
updateHiddenState()
}
if (Object.prototype.hasOwnProperty.call(overlay, property)) {
redirectedTarget = overlay

View File

@@ -16,31 +16,106 @@ import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useLitegraphService } from '@/services/litegraphService'
import { useSubgraphNavigationStore } from '@/stores/subgraphNavigationStore'
type PartialNode = Pick<LGraphNode, 'title' | 'id' | 'type'>
type PartialNode = Pick<LGraphNode, 'title' | 'id' | 'type' | 'widgets'>
export type WidgetItem = [PartialNode, IBaseWidget]
function getProxyWidgets(node: SubgraphNode) {
return parseProxyWidgets(node.properties.proxyWidgets)
}
/**
* Find all child widgets of a dynamic combo parent by name.
*/
function getChildWidgets(
node: PartialNode,
parentWidgetName: string
): IBaseWidget[] {
return (
node.widgets?.filter((w) => w.dynamicWidgetParent === parentWidgetName) ??
[]
)
}
/**
* Check if a widget is a child of a dynamic combo root.
*/
export function isDynamicComboChild(
node: LGraphNode,
widgetName: string
): boolean {
const widget = node.widgets?.find((w) => w.name === widgetName)
if (widget) return !!widget.dynamicWidgetParent
// Widget doesn't exist (disconnected) - parse name to find parent
// because the widget doesnt exist, we dont have any concrete flag for if
// this is a child of a dynamic combo, so we need to parse the name to find the parent
const dotIndex = widgetName.indexOf('.')
if (dotIndex === -1) return false
const parentName = widgetName.slice(0, dotIndex)
const parentWidget = node.widgets?.find((w) => w.name === parentName)
return !!parentWidget?.dynamicWidgetRoot
}
/**
* Check if a widget is a child of a promoted dynamic combo.
*/
function isChildOfPromotedDynamicCombo(
node: LGraphNode,
widget: IBaseWidget
): boolean {
if (!widget.dynamicWidgetParent) return false
const parentWidget = node.widgets?.find(
(w) => w.name === widget.dynamicWidgetParent
)
return !!parentWidget?.promoted
}
/**
* Get a widget and all its dynamic combo children (if it's a root).
*/
function getWidgetWithChildren(
node: PartialNode,
widget: IBaseWidget
): IBaseWidget[] {
const widgets = [widget]
if (widget.dynamicWidgetRoot && node.widgets) {
widgets.push(...getChildWidgets(node, widget.name))
}
return widgets
}
/**
* Batch promote multiple widgets to proxy on all parent SubgraphNodes.
* Only adds widgets that don't already exist in proxyWidgets.
*/
function promoteWidgetsToProxy(
node: PartialNode,
widgets: IBaseWidget[],
parents: SubgraphNode[]
) {
for (const parent of parents) {
const existing = getProxyWidgets(parent)
const toAdd = widgets.filter(
(w) => !existing.some(matchesPropertyItem([node, w]))
)
if (!toAdd.length) continue
parent.properties.proxyWidgets = [
...existing,
...toAdd.map((w) => widgetItemToProperty([node, w]))
]
}
for (const w of widgets) {
w.promoted = true
}
}
export function promoteWidget(
node: PartialNode,
widget: IBaseWidget,
parents: SubgraphNode[]
) {
for (const parent of parents) {
const existingProxyWidgets = getProxyWidgets(parent)
// Prevent duplicate promotion
if (existingProxyWidgets.some(matchesPropertyItem([node, widget]))) {
continue
}
const proxyWidgets = [
...existingProxyWidgets,
widgetItemToProperty([node, widget])
]
parent.properties.proxyWidgets = proxyWidgets
}
widget.promoted = true
promoteWidgetsToProxy(node, getWidgetWithChildren(node, widget), parents)
}
export function demoteWidget(
@@ -48,13 +123,17 @@ export function demoteWidget(
widget: IBaseWidget,
parents: SubgraphNode[]
) {
const widgetsToDemote = getWidgetWithChildren(node, widget)
for (const parent of parents) {
const proxyWidgets = getProxyWidgets(parent).filter(
(widgetItem) => !matchesPropertyItem([node, widget])(widgetItem)
(widgetItem) =>
!widgetsToDemote.some((w) => matchesPropertyItem([node, w])(widgetItem))
)
parent.properties.proxyWidgets = proxyWidgets
}
widget.promoted = false
for (const w of widgetsToDemote) {
w.promoted = false
}
}
export function matchesWidgetItem([nodeId, widgetName]: [string, string]) {
@@ -68,7 +147,56 @@ export function widgetItemToProperty([n, w]: WidgetItem): [string, string] {
return [`${n.id}`, w.name]
}
function getParentNodes(): SubgraphNode[] {
/**
* Get all SubgraphNodes that contain the given node's graph.
* Returns empty array if node is in root graph or graph is undefined.
*/
function getSubgraphParents(node: LGraphNode): SubgraphNode[] {
const graph = node.graph
if (!graph || graph.isRootGraph) return []
return graph.rootGraph.nodes.filter(
(n): n is SubgraphNode => n.type === graph.id && n.isSubgraphNode()
)
}
/**
* Mark proxy widgets pointing to this node as needing re-checking.
* Called when a node's widgets change (e.g., dynamic combo value change).
*/
export function invalidateProxyWidgetsForNode(node: LGraphNode) {
const parents = getSubgraphParents(node)
const nodeId = `${node.id}`
for (const parent of parents) {
for (const widget of parent.widgets) {
if (isProxyWidget(widget) && widget._overlay.nodeId === nodeId) {
widget._overlay.needsResolve = true
}
}
}
}
/**
* Auto-promote child widgets of a dynamic combo when the parent is promoted.
*/
export function autoPromoteDynamicChildren(
node: LGraphNode,
parentWidget: IBaseWidget
) {
if (!parentWidget.promoted) return
const parents = getSubgraphParents(node)
if (!parents.length) return
const childWidgets = getChildWidgets(node, parentWidget.name)
promoteWidgetsToProxy(node, childWidgets, parents)
}
/**
* Get parent SubgraphNodes based on current navigation context.
*/
export function getParentNodes(): SubgraphNode[] {
//NOTE: support for determining parents of a subgraph is limited
//This function will require rework to properly support linked subgraphs
//Either by including actual parents in the navigation stack,
@@ -108,6 +236,8 @@ export function addWidgetPromotionOptions(
}
})
else {
if (isChildOfPromotedDynamicCombo(node, widget)) return
options.unshift({
content: `Un-Promote Widget: ${widget.label ?? widget.name}`,
callback: () => {
@@ -127,9 +257,12 @@ export function tryToggleWidgetPromotion() {
const promotableParents = parents.filter(
(s) => !getProxyWidgets(s).some(matchesPropertyItem([node, widget]))
)
if (promotableParents.length > 0)
if (promotableParents.length > 0) {
promoteWidget(node, widget, promotableParents)
else demoteWidget(node, widget, parents)
} else {
if (isChildOfPromotedDynamicCombo(node, widget)) return
demoteWidget(node, widget, parents)
}
}
const recommendedNodes = [
'CLIPTextEncode',

View File

@@ -2,6 +2,10 @@ import { remove } from 'es-toolkit'
import { shallowReactive } from 'vue'
import { useChainCallback } from '@/composables/functional/useChainCallback'
import {
autoPromoteDynamicChildren,
invalidateProxyWidgetsForNode
} from '@/core/graph/subgraph/proxyWidgetUtils'
import type {
ISlotType,
INodeInputSlot,
@@ -126,7 +130,13 @@ function dynamicComboWidget(
ensureWidgetForInput(node, newInput)
}
}
const childWidgets = node.widgets!.filter(isInGroup)
for (const child of childWidgets) {
child.dynamicWidgetParent = widget.name
}
})
widget.dynamicWidgetRoot = true
const inputInsertionPoint =
node.inputs.findIndex((i) => i.name === widget.name) + 1
@@ -179,6 +189,12 @@ function dynamicComboWidget(
if (!node.graph) return
node._setConcreteSlots()
node.arrange()
// Auto-promote new child widgets if parent dynamic combo is promoted
autoPromoteDynamicChildren(node, widget)
// Mark proxy widgets as needing re-checking for hidden state
invalidateProxyWidgetsForNode(node)
app.canvas?.setDirty(true, true)
}
//A little hacky, but onConfigure won't work.

View File

@@ -361,6 +361,16 @@ export interface IBaseWidget<
tooltip?: string
/**
* If true, this widget is a dynamic combo root that can have child widgets.
*/
dynamicWidgetRoot?: boolean
/**
* The name of the parent dynamic combo widget that owns this child widget.
*/
dynamicWidgetParent?: string
// TODO: Confirm this format
callback?(
value: unknown,