Allow reordering of linked subgraph widgets (#5981)

Widgets which are promoted by linking to a subgraphInput node are now
also displayed in the subgraph configuration window. They can now be
reordered by drag and drop along with proxyWidgets


![linked-reorder](https://github.com/user-attachments/assets/e1b8d590-211a-4d84-9f84-3a5fd5a7aa6c)

Known Issues:
- "Hide All" will incorrectly remove physically linked widgets

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5981-Allow-reordering-of-linked-subgraph-widgets-2866d73d365081d9b27cf4a9c3078007)
by [Unito](https://www.unito.io)
This commit is contained in:
AustinMroz
2025-10-09 13:50:15 -07:00
committed by GitHub
parent 13db1e484b
commit 4e08ed64f0
5 changed files with 89 additions and 65 deletions

View File

@@ -64,15 +64,21 @@ const activeNode = computed(() => {
const activeWidgets = computed<WidgetItem[]>({
get() {
if (!activeNode.value) return []
const node = activeNode.value
if (!node) return []
return proxyWidgets.value.flatMap(([id, name]: [string, string]) => {
function mapWidgets([id, name]: [string, string]): WidgetItem[] {
if (id === '-1') {
const widget = node.widgets.find((w) => w.name === name)
if (!widget) return []
return [[{ id: -1, title: '(Linked)', type: '' }, widget]]
}
const wNode = node.subgraph._nodes_by_id[id]
if (!wNode?.widgets) return []
const w = wNode.widgets.find((w) => w.name === name)
if (!w) return []
return [[wNode, w]]
})
const widget = wNode.widgets.find((w) => w.name === name)
if (!widget) return []
return [[wNode, widget]]
}
return proxyWidgets.value.flatMap(mapWidgets)
},
set(value: WidgetItem[]) {
const node = activeNode.value
@@ -80,9 +86,7 @@ const activeWidgets = computed<WidgetItem[]>({
console.error('Attempted to toggle widgets with no node selected')
return
}
//map back to id/name
const widgets: ProxyWidgetsProperty = value.map(widgetItemToProperty)
proxyWidgets.value = widgets
proxyWidgets.value = value.map(widgetItemToProperty)
}
})
@@ -165,10 +169,10 @@ function showAll() {
function hideAll() {
const node = activeNode.value
if (!node) return //Not reachable
//Not great from a nesting perspective, but path is cold
//and it cleans up potential error states
proxyWidgets.value = proxyWidgets.value.filter(
(widgetItem) => !filteredActive.value.some(matchesWidgetItem(widgetItem))
(propertyItem) =>
!filteredActive.value.some(matchesWidgetItem(propertyItem)) ||
propertyItem[0] === '-1'
)
}
function showRecommended() {
@@ -258,20 +262,16 @@ onBeforeUnmount(() => {
>
</div>
<div ref="draggableItems">
<div
<SubgraphNodeWidget
v-for="[node, widget] in filteredActive"
:key="toKey([node, widget])"
class="draggable-item w-full"
style=""
>
<SubgraphNodeWidget
:node-title="node.title"
:widget-name="widget.name"
:is-shown="true"
:is-draggable="!debouncedQuery"
@toggle-visibility="demote([node, widget])"
/>
</div>
:node-title="node.title"
:widget-name="widget.name"
:is-shown="true"
:is-draggable="!debouncedQuery"
:is-physical="node.id === -1"
@toggle-visibility="demote([node, widget])"
/>
</div>
</div>
<div v-if="filteredCandidates.length" class="pt-1 pb-4">
@@ -286,17 +286,13 @@ onBeforeUnmount(() => {
{{ $t('subgraphStore.showAll') }}</a
>
</div>
<div
<SubgraphNodeWidget
v-for="[node, widget] in filteredCandidates"
:key="toKey([node, widget])"
class="w-full"
>
<SubgraphNodeWidget
:node-title="node.title"
:widget-name="widget.name"
@toggle-visibility="promote([node, widget])"
/>
</div>
:node-title="node.title"
:widget-name="widget.name"
@toggle-visibility="promote([node, widget])"
/>
</div>
<div
v-if="recommendedWidgets.length"

View File

@@ -8,6 +8,7 @@ const props = defineProps<{
widgetName: string
isShown?: boolean
isDraggable?: boolean
isPhysical?: boolean
}>()
defineEmits<{
(e: 'toggleVisibility'): void
@@ -17,11 +18,17 @@ function classes() {
return cn(
'flex py-1 pr-4 pl-0 break-all rounded items-center gap-1',
'bg-node-component-surface',
props.isDraggable
? 'drag-handle cursor-grab [.is-draggable]:cursor-grabbing'
: ''
props.isDraggable &&
'draggable-item drag-handle cursor-grab [&.is-draggable]:cursor-grabbing'
)
}
function getIcon() {
return props.isPhysical
? 'icon-[lucide--link]'
: props.isDraggable
? 'icon-[lucide--eye]'
: 'icon-[lucide--eye-off]'
}
</script>
<template>
<div :class="classes()">
@@ -40,7 +47,8 @@ function classes() {
<Button
size="small"
text
:icon="isDraggable ? 'icon-[lucide--eye]' : 'icon-[lucide--eye-off]'"
:icon="getIcon()"
:disabled="isPhysical"
severity="secondary"
@click.stop="$emit('toggleVisibility')"
/>

View File

@@ -1,5 +1,6 @@
import { demoteWidget } from '@/core/graph/subgraph/proxyWidgetUtils'
import { parseProxyWidgets } from '@/core/schemas/proxyWidget'
import type { NodeProperty } from '@/lib/litegraph/src/LGraphNode'
import type {
LGraph,
LGraphCanvas,
@@ -75,15 +76,17 @@ const onConfigure = function (
const canvasStore = useCanvasStore()
//Must give value to proxyWidgets prior to defining or it won't serialize
this.properties.proxyWidgets ??= []
let proxyWidgets = this.properties.proxyWidgets
originalOnConfigure?.call(this, serialisedNode)
Object.defineProperty(this.properties, 'proxyWidgets', {
get: () => {
return proxyWidgets
},
set: (property: string) => {
get: () =>
this.widgets.map((w) =>
isProxyWidget(w)
? [w._overlay.nodeId, w._overlay.widgetName]
: ['-1', w.name]
),
set: (property: NodeProperty) => {
const parsed = parseProxyWidgets(property)
const { deactivateWidget, setWidget } = useDomWidgetStore()
const isActiveGraph = useCanvasStore().canvas?.graph === this.graph
@@ -92,21 +95,34 @@ const onConfigure = function (
if (w instanceof DOMWidgetImpl) deactivateWidget(w.id)
}
}
this.widgets = this.widgets.filter((w) => !isProxyWidget(w))
for (const [nodeId, widgetName] of parsed) {
const w = addProxyWidget(this, `${nodeId}`, widgetName)
const newWidgets = parsed.flatMap(([nodeId, widgetName]) => {
if (nodeId === '-1') {
const widget = this.widgets.find((w) => w.name === widgetName)
return widget ? [widget] : []
}
const w = newProxyWidget(this, nodeId, widgetName)
if (isActiveGraph && w instanceof DOMWidgetImpl) setWidget(w)
}
proxyWidgets = property
return [w]
})
this.widgets = this.widgets.filter(
(w) => !isProxyWidget(w) && !parsed.some(([, name]) => w.name === name)
)
this.widgets.push(...newWidgets)
canvasStore.canvas?.setDirty(true, true)
this._setConcreteSlots()
this.arrange()
}
})
this.properties.proxyWidgets = proxyWidgets
if (serialisedNode.properties?.proxyWidgets)
this.properties.proxyWidgets = serialisedNode.properties.proxyWidgets
serialisedNode.widgets_values?.forEach((v, index) => {
if (v !== null) this.widgets[index].value = v
})
}
function addProxyWidget(
function newProxyWidget(
subgraphNode: SubgraphNode,
nodeId: string,
widgetName: string
@@ -130,7 +146,7 @@ function addProxyWidget(
width: undefined,
y: 0
}
return addProxyFromOverlay(subgraphNode, overlay)
return newProxyFromOverlay(subgraphNode, overlay)
}
function resolveLinkedWidget(
overlay: Overlay
@@ -141,7 +157,7 @@ function resolveLinkedWidget(
return [n, n.widgets?.find((w: IBaseWidget) => w.name === widgetName)]
}
function addProxyFromOverlay(subgraphNode: SubgraphNode, overlay: Overlay) {
function newProxyFromOverlay(subgraphNode: SubgraphNode, overlay: Overlay) {
const { updatePreviews } = useLitegraphService()
let [linkedNode, linkedWidget] = resolveLinkedWidget(overlay)
let backingWidget = linkedWidget ?? disconnectedWidget
@@ -213,6 +229,5 @@ function addProxyFromOverlay(subgraphNode: SubgraphNode, overlay: Overlay) {
}
}
const w = new Proxy(disconnectedWidget, handler)
subgraphNode.widgets.push(w)
return w
}

View File

@@ -9,13 +9,15 @@ import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets.ts'
import { useLitegraphService } from '@/services/litegraphService'
import { useSubgraphNavigationStore } from '@/stores/subgraphNavigationStore'
export type WidgetItem = [LGraphNode, IBaseWidget]
type PartialNode = Pick<LGraphNode, 'title' | 'id' | 'type'>
export type WidgetItem = [PartialNode, IBaseWidget]
function getProxyWidgets(node: SubgraphNode) {
return parseProxyWidgets(node.properties.proxyWidgets)
}
export function promoteWidget(
node: LGraphNode,
node: PartialNode,
widget: IBaseWidget,
parents: SubgraphNode[]
) {
@@ -30,7 +32,7 @@ export function promoteWidget(
}
export function demoteWidget(
node: LGraphNode,
node: PartialNode,
widget: IBaseWidget,
parents: SubgraphNode[]
) {

View File

@@ -1,11 +1,9 @@
import { describe, expect, test, vi } from 'vitest'
import { registerProxyWidgets } from '@/core/graph/subgraph/proxyWidget'
import {
type LGraphCanvas,
LGraphNode,
type SubgraphNode
} from '@/lib/litegraph/src/litegraph'
import { parseProxyWidgets } from '@/core/schemas/proxyWidget'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { LGraphCanvas, SubgraphNode } from '@/lib/litegraph/src/litegraph'
import {
createTestSubgraph,
@@ -66,14 +64,19 @@ describe('Subgraph proxyWidgets', () => {
subgraphNode.widgets[1].name
)
})
test('Will not modify existing widgets', () => {
test('Will serialize existing widgets', () => {
const [subgraphNode, innerNodes] = setupSubgraph(1)
innerNodes[0].addWidget('text', 'stringWidget', 'value', () => {})
innerNodes[0].addWidget('text', 'istringWidget', 'value', () => {})
subgraphNode.addWidget('text', 'stringWidget', 'value', () => {})
subgraphNode.properties.proxyWidgets = [['1', 'stringWidget']]
const proxyWidgets = parseProxyWidgets(subgraphNode.properties.proxyWidgets)
proxyWidgets.push(['1', 'istringWidget'])
subgraphNode.properties.proxyWidgets = proxyWidgets
expect(subgraphNode.widgets.length).toBe(2)
subgraphNode.properties.proxyWidgets = []
expect(subgraphNode.widgets.length).toBe(1)
expect(subgraphNode.widgets[0].name).toBe('stringWidget')
subgraphNode.properties.proxyWidgets = [proxyWidgets[1], proxyWidgets[0]]
expect(subgraphNode.widgets[0].name).toBe('1: istringWidget')
})
test('Will mirror changes to value', () => {
const [subgraphNode, innerNodes] = setupSubgraph(1)