diff --git a/src/components/rightSidePanel/parameters/SectionWidgets.vue b/src/components/rightSidePanel/parameters/SectionWidgets.vue index b1905a5f0..7bddd4053 100644 --- a/src/components/rightSidePanel/parameters/SectionWidgets.vue +++ b/src/components/rightSidePanel/parameters/SectionWidgets.vue @@ -14,7 +14,8 @@ import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets' import { useCanvasStore } from '@/renderer/core/canvas/canvasStore' import PropertiesAccordionItem from '../layout/PropertiesAccordionItem.vue' -import { GetNodeParentGroupKey } from '../shared' +import { GetNodeParentGroupKey, getWidgetGroupKey } from '../shared' +import WidgetGroup from './WidgetGroup.vue' import WidgetItem from './WidgetItem.vue' const { @@ -84,6 +85,34 @@ function isWidgetShownOnParents( const isEmpty = computed(() => widgets.value.length === 0) +type WidgetEntry = { widget: IBaseWidget; node: LGraphNode } +type WidgetGroup = { + key: string + items: WidgetEntry[] +} + +/** + * Group widgets by their group key (for dynamic widget grouping). + * Widgets with the same group key are placed together in a single group. + */ +const groupedWidgets = computed((): WidgetGroup[] => { + const groups: WidgetGroup[] = [] + const keyToGroup = new Map() + + for (const entry of widgets.value) { + const key = getWidgetGroupKey(entry.widget) + let group = keyToGroup.get(key) + if (!group) { + group = { key, items: [] } + keyToGroup.set(key, group) + groups.push(group) + } + group.items.push(entry) + } + + return groups +}) + const displayLabel = computed( () => label ?? (node ? node.title : t('rightSidePanel.inputs')) ) @@ -167,17 +196,22 @@ defineExpose({ class="space-y-2 rounded-lg px-4 pt-1 relative" > - + > + + diff --git a/src/components/rightSidePanel/parameters/TabSubgraphInputs.vue b/src/components/rightSidePanel/parameters/TabSubgraphInputs.vue index a6fe94635..c9e93ca36 100644 --- a/src/components/rightSidePanel/parameters/TabSubgraphInputs.vue +++ b/src/components/rightSidePanel/parameters/TabSubgraphInputs.vue @@ -24,7 +24,7 @@ import FormSearchInput from '@/renderer/extensions/vueNodes/widgets/components/f import { DraggableList } from '@/scripts/ui/draggableList' import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore' -import { searchWidgets } from '../shared' +import { getWidgetGroupKey, searchWidgets } from '../shared' import type { NodeWidgetsList } from '../shared' import SectionWidgets from './SectionWidgets.vue' @@ -103,6 +103,57 @@ const widgetsList = computed((): NodeWidgetsList => { return result }) +/** + * Get the group key for a widget by its proxyWidgets entry. + * Returns the parent widget name if this is a child, otherwise the widget's own name. + */ +function getGroupKeyForEntry(widgetName: string): string { + const { widgets = [] } = node + + // Find the actual widget to check dynamicWidgetParent + const widget = widgets.find((w) => { + if (isProxyWidget(w)) { + return w._overlay.widgetName === widgetName + } + return w.name === widgetName + }) + + if (widget) { + return getWidgetGroupKey(widget) + } + return widgetName +} + +type ProxyWidgetGroup = { + key: string + indices: number[] +} + +/** + * Build a list of groups from proxyWidgets. + * Each group contains the indices of widgets that belong together. + * Groups are ordered by the first occurrence of their members. + */ +function buildProxyWidgetGroups(pw: [string, string][]): ProxyWidgetGroup[] { + const groups: ProxyWidgetGroup[] = [] + const keyToGroup = new Map() + + for (let i = 0; i < pw.length; i++) { + const [, widgetName] = pw[i] + const key = getGroupKeyForEntry(widgetName) + + let group = keyToGroup.get(key) + if (!group) { + group = { key, indices: [] } + keyToGroup.set(key, group) + groups.push(group) + } + group.indices.push(i) + } + + return groups +} + const advancedInputsWidgets = computed((): NodeWidgetsList => { const interiorNodes = node.subgraph.nodes const proxyWidgetsValue = parseProxyWidgets(node.properties.proxyWidgets) @@ -178,11 +229,42 @@ function setDraggableState() { this.draggableItem as HTMLElement ) - // Update proxyWidgets order + // Build groups from proxyWidgets + // Each draggable item corresponds to a group (container or single widget) const pw = proxyWidgets.value - const [w] = pw.splice(oldPosition, 1) - pw.splice(newPosition, 0, w) - proxyWidgets.value = pw + const groups = buildProxyWidgetGroups(pw) + + if (oldPosition >= groups.length || newPosition >= groups.length) { + console.error('[TabSubgraphInputs] position out of bounds') + return + } + + // Get the group being moved + const movedGroup = groups[oldPosition] + const movedIndices = movedGroup.indices + + // Extract the entries being moved (in their original order) + const movedEntries: [string, string][] = movedIndices.map((i) => pw[i]) + + const newPw: [string, string][] = [] + const reorderedGroups = [...groups] + reorderedGroups.splice(oldPosition, 1) + reorderedGroups.splice(newPosition, 0, movedGroup) + + // Flatten back to proxyWidgets, preserving entry order within each group + for (const group of reorderedGroups) { + if (group === movedGroup) { + // Use the entries we extracted earlier + newPw.push(...movedEntries) + } else { + // Add entries from this group in their original order + for (const idx of group.indices) { + newPw.push(pw[idx]) + } + } + } + + proxyWidgets.value = newPw canvasStore.canvas?.setDirty(true, true) triggerRef(proxyWidgets) } diff --git a/src/components/rightSidePanel/parameters/WidgetGroup.vue b/src/components/rightSidePanel/parameters/WidgetGroup.vue new file mode 100644 index 000000000..db508daa4 --- /dev/null +++ b/src/components/rightSidePanel/parameters/WidgetGroup.vue @@ -0,0 +1,26 @@ + + + diff --git a/src/components/rightSidePanel/parameters/WidgetItem.vue b/src/components/rightSidePanel/parameters/WidgetItem.vue index 6a2eca748..4155473e1 100644 --- a/src/components/rightSidePanel/parameters/WidgetItem.vue +++ b/src/components/rightSidePanel/parameters/WidgetItem.vue @@ -23,7 +23,6 @@ import WidgetActions from './WidgetActions.vue' const { widget, node, - isDraggable = false, hiddenFavoriteIndicator = false, showNodeName = false, parents = [], @@ -31,7 +30,6 @@ const { } = defineProps<{ widget: IBaseWidget node: LGraphNode - isDraggable?: boolean hiddenFavoriteIndicator?: boolean showNodeName?: boolean parents?: SubgraphNode[] @@ -104,22 +102,11 @@ const displayLabel = customRef((track, trigger) => { diff --git a/src/components/rightSidePanel/shared.test.ts b/src/components/rightSidePanel/shared.test.ts index 37a8f3aeb..a7f8f8057 100644 --- a/src/components/rightSidePanel/shared.test.ts +++ b/src/components/rightSidePanel/shared.test.ts @@ -2,7 +2,11 @@ import { LGraphGroup } from '@/lib/litegraph/src/LGraphGroup' import { LGraphNode } from '@/lib/litegraph/src/LGraphNode' import type { Positionable } from '@/lib/litegraph/src/interfaces' import { describe, expect, it, beforeEach } from 'vitest' -import { flatAndCategorizeSelectedItems, searchWidgets } from './shared' +import { + flatAndCategorizeSelectedItems, + getWidgetGroupKey, + searchWidgets +} from './shared' import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets' describe('searchWidgets', () => { @@ -188,3 +192,109 @@ describe('flatAndCategorizeSelectedItems', () => { expect(result.all).not.toContain(unknownItem) }) }) + +describe('getWidgetGroupKey', () => { + it('should return parent name for child widgets', () => { + const widget = { + name: 'dynamic_combo.w1', + type: 'number', + dynamicWidgetParent: 'dynamic_combo' + } as IBaseWidget + + expect(getWidgetGroupKey(widget)).toBe('dynamic_combo') + }) + + it('should return widget name for parent widgets (dynamic combo roots)', () => { + const widget = { + name: 'dynamic_combo', + type: 'combo', + dynamicWidgetRoot: true + } as IBaseWidget + + expect(getWidgetGroupKey(widget)).toBe('dynamic_combo') + }) + + it('should return widget name for regular widgets', () => { + const widget = { + name: 'regular_widget', + type: 'number' + } as IBaseWidget + + expect(getWidgetGroupKey(widget)).toBe('regular_widget') + }) + + it('should return widget name if dynamicWidgetParent is empty string', () => { + const widget = { + name: 'some_widget', + type: 'number', + dynamicWidgetParent: '' + } as IBaseWidget + + // Empty string is falsy, so widget is treated as its own group + expect(getWidgetGroupKey(widget)).toBe('some_widget') + }) + + it('should use _overlay.widgetName for proxy widgets (parent)', () => { + // Proxy widgets have names with node ID prefix like "1: dynamic_combo" + const proxyWidget = { + name: '1: dynamic_combo', + type: 'combo', + dynamicWidgetRoot: true, + _overlay: { widgetName: 'dynamic_combo', nodeId: '1' } + } as unknown as IBaseWidget + + // Should return base name so it matches children's dynamicWidgetParent + expect(getWidgetGroupKey(proxyWidget)).toBe('dynamic_combo') + }) + + it('should group proxy parent and children together', () => { + // Parent proxy widget + const parentProxy = { + name: '1: dynamic_combo', + type: 'combo', + dynamicWidgetRoot: true, + _overlay: { widgetName: 'dynamic_combo', nodeId: '1' } + } as unknown as IBaseWidget + + // Child proxy widget + const childProxy = { + name: '1: dynamic_combo.w1', + type: 'number', + dynamicWidgetParent: 'dynamic_combo', + _overlay: { widgetName: 'dynamic_combo.w1', nodeId: '1' } + } as unknown as IBaseWidget + + // Both should return 'dynamic_combo' so they're in the same group + expect(getWidgetGroupKey(parentProxy)).toBe('dynamic_combo') + expect(getWidgetGroupKey(childProxy)).toBe('dynamic_combo') + }) + + it('should group disconnected child widgets using overlay.dynamicWidgetParent', () => { + // Disconnected child widget - dynamicWidgetParent is stored in overlay + // because the backing widget (disconnectedWidget) doesn't have this property + const disconnectedChild = { + name: '1: dynamic_combo.child_widget', + type: 'button', // disconnectedWidget type + // No dynamicWidgetParent on widget itself + _overlay: { + widgetName: 'dynamic_combo.child_widget', + nodeId: '1', + dynamicWidgetParent: 'dynamic_combo' // Stored in overlay + } + } as unknown as IBaseWidget + + // Should use overlay.dynamicWidgetParent + expect(getWidgetGroupKey(disconnectedChild)).toBe('dynamic_combo') + }) + + it('should not group widgets without dynamicWidgetParent in overlay', () => { + // Regular widget without dynamicWidgetParent in widget or overlay + const regularWidget = { + name: 'some_widget', + type: 'number', + _overlay: { widgetName: 'some_widget', nodeId: '1' } + } as unknown as IBaseWidget + + expect(getWidgetGroupKey(regularWidget)).toBe('some_widget') + }) +}) diff --git a/src/components/rightSidePanel/shared.ts b/src/components/rightSidePanel/shared.ts index 0d70d6d34..7ef4b6655 100644 --- a/src/components/rightSidePanel/shared.ts +++ b/src/components/rightSidePanel/shared.ts @@ -250,6 +250,41 @@ function repeatItems(items: T[]): T[] { return result } +/** + * Get the base widget name, stripping any node ID prefix. + * Proxy widgets on SubgraphNodes have names like "1: widgetName". + */ +function getBaseWidgetName(widget: IBaseWidget): string { + // Check if it's a proxy widget with _overlay + const overlay = (widget as { _overlay?: { widgetName?: string } })._overlay + if (overlay?.widgetName) { + return overlay.widgetName + } + return widget.name +} + +export function getWidgetGroupKey(widget: IBaseWidget): string { + // Check dynamicWidgetParent on the widget (works for connected widgets) + if (widget.dynamicWidgetParent) { + return widget.dynamicWidgetParent + } + + // For proxy widgets, check the overlay for dynamicWidgetParent + // This handles disconnected widgets where the backing widget doesn't have the property + // as the actual widget doesn't exist, and is the disconnected widget. + const overlay = ( + widget as { + _overlay?: { dynamicWidgetParent?: string; widgetName?: string } + } + )._overlay + if (overlay?.dynamicWidgetParent) { + return overlay.dynamicWidgetParent + } + + // Use base name to match children's dynamicWidgetParent values + return getBaseWidgetName(widget) +} + export function computedSectionDataList(nodes: MaybeRefOrGetter) { const settingStore = useSettingStore() diff --git a/src/core/graph/subgraph/proxyWidget.ts b/src/core/graph/subgraph/proxyWidget.ts index 075faeee1..2f0e72d2a 100644 --- a/src/core/graph/subgraph/proxyWidget.ts +++ b/src/core/graph/subgraph/proxyWidget.ts @@ -42,6 +42,8 @@ type Overlay = Partial & { hidden?: boolean /** Flag to trigger re-resolution when source node's widgets change */ needsResolve?: boolean + /** Cached dynamicWidgetParent for grouping when widget is disconnected */ + dynamicWidgetParent?: string } // A ProxyWidget can be treated like a normal widget. // the _overlay property can be used to directly access the Overlay object @@ -147,11 +149,21 @@ function newProxyWidget( widgetName: string ) { const name = `${nodeId}: ${widgetName}` - const overlay = { + + // Determine dynamicWidgetParent from widget name pattern (parentName.childName) + // This ensures grouping works even when the backing widget is disconnected + let dynamicWidgetParent: string | undefined + const dotIndex = widgetName.indexOf('.') + if (dotIndex !== -1) { + dynamicWidgetParent = widgetName.slice(0, dotIndex) + } + + const overlay: Overlay = { //items specific for proxy management nodeId, graph: subgraphNode.subgraph, widgetName, + dynamicWidgetParent, //Items which normally exist on widgets afterQueued: undefined, computedHeight: undefined, @@ -177,6 +189,12 @@ function resolveLinkedWidget( // Slightly hacky. Force recursive resolution of nested widgets if (widget && isProxyWidget(widget) && isDisconnectedWidget(widget)) widget.computedHeight = 20 + + // Cache dynamicWidgetParent in overlay for use when widget becomes disconnected + if (widget?.dynamicWidgetParent) { + overlay.dynamicWidgetParent = widget.dynamicWidgetParent + } + return [n, widget] }