From 24cbf4f68cbeeb21f6cb7f656bf309d6b30302fe Mon Sep 17 00:00:00 2001 From: pythongosssss <125205205+pythongosssss@users.noreply.github.com> Date: Tue, 27 Jan 2026 15:05:09 -0800 Subject: [PATCH] add support for dynamic combos on subgraphs --- src/core/graph/subgraph/proxyWidget.ts | 29 +++- src/core/graph/subgraph/proxyWidgetUtils.ts | 171 +++++++++++++++++--- src/core/graph/widgets/dynamicWidgets.ts | 16 ++ src/lib/litegraph/src/types/widgets.ts | 10 ++ 4 files changed, 205 insertions(+), 21 deletions(-) diff --git a/src/core/graph/subgraph/proxyWidget.ts b/src/core/graph/subgraph/proxyWidget.ts index 60f6d456a..075faeee1 100644 --- a/src/core/graph/subgraph/proxyWidget.ts +++ b/src/core/graph/subgraph/proxyWidget.ts @@ -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 & { 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 diff --git a/src/core/graph/subgraph/proxyWidgetUtils.ts b/src/core/graph/subgraph/proxyWidgetUtils.ts index eafb0f1dd..9f50a8840 100644 --- a/src/core/graph/subgraph/proxyWidgetUtils.ts +++ b/src/core/graph/subgraph/proxyWidgetUtils.ts @@ -16,31 +16,106 @@ import { useCanvasStore } from '@/renderer/core/canvas/canvasStore' import { useLitegraphService } from '@/services/litegraphService' import { useSubgraphNavigationStore } from '@/stores/subgraphNavigationStore' -type PartialNode = Pick +type PartialNode = Pick 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', diff --git a/src/core/graph/widgets/dynamicWidgets.ts b/src/core/graph/widgets/dynamicWidgets.ts index 7e3f7fd71..86c335470 100644 --- a/src/core/graph/widgets/dynamicWidgets.ts +++ b/src/core/graph/widgets/dynamicWidgets.ts @@ -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. diff --git a/src/lib/litegraph/src/types/widgets.ts b/src/lib/litegraph/src/types/widgets.ts index d3c249ba3..12a21e372 100644 --- a/src/lib/litegraph/src/types/widgets.ts +++ b/src/lib/litegraph/src/types/widgets.ts @@ -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,