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

View File

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

View File

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

View File

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