feat: synthetic widgets getter for SubgraphNode (proxy-widget-v2) (#8856)

## Summary

Replace the Proxy-based proxy widget system with a store-driven
architecture where `promotionStore` and `widgetValueStore` are the
single sources of truth for subgraph widget promotion and widget values,
and `SubgraphNode.widgets` is a synthetic getter composing lightweight
`PromotedWidgetView` objects from store state.

## Motivation

The subgraph widget promotion system previously scattered state across
multiple unsynchronized layers:

- **Persistence**: `node.properties.proxyWidgets` (tuples on the
LiteGraph node)
- **Runtime**: Proxy-based `proxyWidget.ts` with `Overlay` objects,
`DisconnectedWidget` singleton, and `isProxyWidget` type guards
- **UI**: Each Vue component independently calling `parseProxyWidgets()`
via `customRef` hacks
- **Mutation flags**: Imperative `widget.promoted = true/false` set on
`subgraph-opened` events

This led to 4+ independent parsings of the same data, complex cache
invalidation, and no reactive contract between the promotion state and
the rendering layer. Widget values were similarly owned by LiteGraph
with no Vue-reactive backing.

The core principle driving these changes: **Vue owns truth**. Pinia
stores are the canonical source; LiteGraph objects delegate to stores
via getters/setters; Vue components react to store state directly.

## Changes

### New stores (single sources of truth)

- **`promotionStore`** — Reactive `Map<NodeId, PromotionEntry[]>`
tracking which interior widgets are promoted on which SubgraphNode
instances. Graph-scoped by root graph ID to prevent cross-workflow state
collision. Replaces `properties.proxyWidgets` parsing, `customRef`
hacks, `widget.promoted` mutation, and the `subgraph-opened` event
listener.
- **`widgetValueStore`** — Graph-scoped `Map<WidgetKey, WidgetState>`
that is the canonical owner of widget values. `BaseWidget.value`
delegates to this store via getter/setter when a node ID is assigned.
Eliminates the need for Proxy-based value forwarding.

### Synthetic widgets getter (SubgraphNode)

`SubgraphNode.widgets` is now a getter that reads
`promotionStore.getPromotions(rootGraphId, nodeId)` and returns cached
`PromotedWidgetView` objects. No stubs, no Proxies, no fake widgets
persisted in the array. The setter is a no-op — mutations go through
`promotionStore`.

### PromotedWidgetView

A class behind a `createPromotedWidgetView` factory, implementing the
`PromotedWidgetView` interface. Delegates value/type/options/drawing to
the resolved interior widget and stores. Owns positional state (`y`,
`computedHeight`) for canvas layout. Cached by
`PromotedWidgetViewManager` for object-identity stability across frames.

### DOM widget promotion

Promoted DOM widgets (textarea, image upload, etc.) render on the
SubgraphNode surface via `positionOverride` in `domWidgetStore`.
`DomWidgets.vue` checks for overrides and uses the SubgraphNode's
coordinates instead of the interior node's.

### Promoted previews

New `usePromotedPreviews` composable resolves image/audio/video preview
widgets from promoted entries, enabling SubgraphNodes to display
previews of interior preview nodes.

### Deleted

- `proxyWidget.ts` (257 lines) — Proxy handler, `Overlay`,
`newProxyWidget`, `isProxyWidget`
- `DisconnectedWidget.ts` (39 lines) — Singleton Proxy target
- `useValueTransform.ts` (32 lines) — Replaced by store delegation

### Key architectural changes

- `BaseWidget.value` getter/setter delegates to `widgetValueStore` when
node ID is set
- `LGraph.add()` reordered: `node.graph` assigned before widget
`setNodeId` (enables store registration)
- `LGraph.clear()` cleans up graph-scoped stores to prevent stale
entries across workflow switches
- `promotionStore` and `widgetValueStore` state nested under root graph
UUID for multi-workflow isolation
- `SubgraphNode.serialize()` writes promotions back to
`properties.proxyWidgets` for persistence compatibility
- Legacy `-1` promotion entries resolved and migrated on first load with
dev warning

## Test coverage

- **3,700+ lines of new/updated tests** across 36 test files
- **Unit**: `promotionStore.test.ts`, `widgetValueStore.test.ts`,
`promotedWidgetView.test.ts` (921 lines),
`subgraphNodePromotion.test.ts`, `proxyWidgetUtils.test.ts`,
`DomWidgets.test.ts`, `PromotedWidgetViewManager.test.ts`,
`usePromotedPreviews.test.ts`, `resolvePromotedWidget.test.ts`,
`subgraphPseudoWidgetCache.test.ts`
- **E2E**: `subgraphPromotion.spec.ts` (622 lines) — promote/demote,
manual/auto promotion, paste preservation, seed control augmentation,
image preview promotion; `imagePreview.spec.ts` extended with
multi-promoted-preview coverage
- **Fixtures**: 2 new subgraph workflow fixtures for preview promotion
scenarios

## Review focus

- Graph-scoped store keying (`rootGraphId`) — verify isolation across
workflows/tabs and cleanup on `LGraph.clear()`
- `PromotedWidgetView` positional stability — `_arrangeWidgets` writes
to `y`/`computedHeight` on cached objects; getter returns fresh array
but stable object references
- DOM widget position override lifecycle — overrides set on promote,
cleared on demote/removal/subgraph navigation
- Legacy `-1` entry migration — resolved and written back on first load;
unresolvable entries dropped with dev warning
- Serialization round-trip — `promotionStore` state →
`properties.proxyWidgets` on serialize, hydrated back on configure

## Diff breakdown (excluding lockfile)

- 153 files changed, ~7,500 insertions, ~1,900 deletions (excluding
pnpm-lock.yaml churn)
- ~3,700 lines are tests
- ~300 lines deleted (proxyWidget.ts, DisconnectedWidget.ts,
useValueTransform.ts)

<!-- Fixes #ISSUE_NUMBER -->

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8856-feat-synthetic-widgets-getter-for-SubgraphNode-proxy-widget-v2-3076d73d365081c7b517f5ec7cb514f3)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: GitHub Action <action@github.com>
This commit is contained in:
Alexander Brown
2026-02-23 13:33:41 -08:00
committed by GitHub
parent d7546e68ef
commit c25f9a0e93
128 changed files with 7295 additions and 1931 deletions

View File

@@ -1,6 +1,7 @@
<template>
<div
class="subgraph-breadcrumb flex w-auto drop-shadow-[var(--interface-panel-drop-shadow)] items-center"
data-testid="subgraph-breadcrumb"
class="subgraph-breadcrumb flex w-auto drop-shadow-(--interface-panel-drop-shadow) items-center"
:class="{
'subgraph-breadcrumb-collapse': collapseTabs,
'subgraph-breadcrumb-overflow': overflowingTabs

View File

@@ -1,5 +1,6 @@
<template>
<div
data-testid="missing-nodes-warning"
class="comfy-missing-nodes flex w-[490px] flex-col border-t border-border-default"
:class="isCloud ? 'border-b' : ''"
>

View File

@@ -0,0 +1,209 @@
import { mount } from '@vue/test-utils'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import DomWidgets from '@/components/graph/DomWidgets.vue'
import { Rectangle } from '@/lib/litegraph/src/infrastructure/Rectangle'
import { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { LGraphCanvas } from '@/lib/litegraph/src/LGraphCanvas'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import type { BaseDOMWidget } from '@/scripts/domWidget'
import { useDomWidgetStore } from '@/stores/domWidgetStore'
import { createTestingPinia } from '@pinia/testing'
type TestWidget = BaseDOMWidget<object | string>
function createNode(
graph: LGraph,
id: number,
title: string,
pos: [number, number]
) {
const node = new LGraphNode(title)
node.id = id
node.pos = [...pos]
node.size = [240, 120]
graph.add(node)
return node
}
function createWidget(id: string, node: LGraphNode, y = 12): TestWidget {
return {
id,
node,
name: 'test_widget',
type: 'custom',
value: '',
options: {},
y,
width: 120,
computedHeight: 40,
margin: 10,
isVisible: () => true
} as unknown as TestWidget
}
function createCanvas(graph: LGraph): LGraphCanvas {
return {
graph,
low_quality: false,
read_only: false,
isNodeVisible: vi.fn(() => true)
} as unknown as LGraphCanvas
}
function drawFrame(canvas: LGraphCanvas) {
canvas.onDrawForeground?.({} as CanvasRenderingContext2D, new Rectangle())
}
describe('DomWidgets transition grace characterization', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
it('applies transition grace for exactly one frame when override exists but is not active', () => {
const canvasStore = useCanvasStore()
const domWidgetStore = useDomWidgetStore()
const graphA = new LGraph()
const graphB = new LGraph()
const interiorNode = createNode(graphA, 1, 'interior', [100, 200])
const overrideNode = createNode(graphB, 2, 'override', [600, 700])
const widget = createWidget('widget-transition', interiorNode, 14)
const overrideWidget = createWidget('override-widget', overrideNode, 22)
domWidgetStore.registerWidget(widget)
domWidgetStore.setPositionOverride(widget.id, {
node: overrideNode,
widget: overrideWidget
})
domWidgetStore.deactivateWidget(widget.id)
const widgetState = domWidgetStore.widgetStates.get(widget.id)
if (!widgetState) throw new Error('Widget state not registered')
widgetState.visible = true
widgetState.pos = [321, 654]
const canvas = createCanvas(graphA)
canvasStore.canvas = canvas
mount(DomWidgets, {
global: {
stubs: {
DomWidget: true
}
}
})
drawFrame(canvas)
expect(widgetState.visible).toBe(true)
expect(widgetState.pos).toEqual([321, 654])
drawFrame(canvas)
expect(widgetState.visible).toBe(false)
})
it('uses override positioning while override node is in current graph even when widget is inactive', () => {
const canvasStore = useCanvasStore()
const domWidgetStore = useDomWidgetStore()
const graphA = new LGraph()
const graphB = new LGraph()
const interiorNode = createNode(graphA, 1, 'interior', [10, 20])
const overrideNode = createNode(graphB, 2, 'override', [300, 400])
const widget = createWidget('widget-override-active', interiorNode, 8)
const overrideWidget = createWidget(
'override-position-source',
overrideNode,
18
)
domWidgetStore.registerWidget(widget)
domWidgetStore.setPositionOverride(widget.id, {
node: overrideNode,
widget: overrideWidget
})
domWidgetStore.deactivateWidget(widget.id)
const widgetState = domWidgetStore.widgetStates.get(widget.id)
if (!widgetState) throw new Error('Widget state not registered')
const canvas = createCanvas(graphB)
canvasStore.canvas = canvas
mount(DomWidgets, {
global: {
stubs: {
DomWidget: true
}
}
})
drawFrame(canvas)
expect(widgetState.visible).toBe(true)
expect(widgetState.pos).toEqual([310, 428])
})
it('cleans orphaned transition-grace ids after widget removal', () => {
const canvasStore = useCanvasStore()
const domWidgetStore = useDomWidgetStore()
const graphA = new LGraph()
const graphB = new LGraph()
const interiorNode = createNode(graphA, 1, 'interior', [0, 0])
const overrideNode = createNode(graphB, 2, 'override', [200, 200])
const canvas = createCanvas(graphA)
canvasStore.canvas = canvas
mount(DomWidgets, {
global: {
stubs: {
DomWidget: true
}
}
})
const oldWidget = createWidget('shared-widget-id', interiorNode, 10)
const overrideWidget = createWidget(
'shared-override-widget',
overrideNode,
14
)
domWidgetStore.registerWidget(oldWidget)
domWidgetStore.setPositionOverride(oldWidget.id, {
node: overrideNode,
widget: overrideWidget
})
domWidgetStore.deactivateWidget(oldWidget.id)
drawFrame(canvas)
domWidgetStore.unregisterWidget(oldWidget.id)
drawFrame(canvas)
const replacementWidget = createWidget('shared-widget-id', interiorNode, 10)
domWidgetStore.registerWidget(replacementWidget)
domWidgetStore.setPositionOverride(replacementWidget.id, {
node: overrideNode,
widget: overrideWidget
})
domWidgetStore.deactivateWidget(replacementWidget.id)
const replacementState = domWidgetStore.widgetStates.get(
replacementWidget.id
)
if (!replacementState) throw new Error('Replacement widget missing state')
replacementState.visible = true
replacementState.pos = [999, 999]
drawFrame(canvas)
expect(replacementState.visible).toBe(true)
expect(replacementState.pos).toEqual([999, 999])
})
})

View File

@@ -15,11 +15,13 @@ import { whenever } from '@vueuse/core'
import { computed } from 'vue'
import DomWidget from '@/components/graph/widgets/DomWidget.vue'
import { getDomWidgetZIndex } from '@/components/graph/widgets/domWidgetZIndex'
import { useChainCallback } from '@/composables/functional/useChainCallback'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useDomWidgetStore } from '@/stores/domWidgetStore'
const domWidgetStore = useDomWidgetStore()
const overrideTransitionGrace = new Set<string>()
const widgetStates = computed(() => [...domWidgetStore.widgetStates.values()])
@@ -29,37 +31,76 @@ const updateWidgets = () => {
const lowQuality = lgCanvas.low_quality
const currentGraph = lgCanvas.graph
const seenWidgetIds = new Set<string>()
for (const widgetState of widgetStates.value) {
const widget = widgetState.widget
seenWidgetIds.add(widget.id)
// Early exit for non-visible widgets
if (!widget.isVisible() || !widgetState.active) {
// Use position override only when the override node (SubgraphNode) is
// in the current graph. When the user enters the subgraph, the override
// node is no longer visible — fall back to the widget's own node.
// Use graph reference equality (IDs are not unique across graphs).
const override = widgetState.positionOverride
const useOverride = !!override && currentGraph === override.node.graph
const inOverrideTransitionGap =
!!override && !useOverride && !widgetState.active
const useTransitionGrace =
inOverrideTransitionGap && !overrideTransitionGrace.has(widget.id)
if (useTransitionGrace) {
overrideTransitionGrace.add(widget.id)
} else if (!inOverrideTransitionGap) {
overrideTransitionGrace.delete(widget.id)
}
// Early exit for non-visible widgets.
// When a position override is active (widget promoted to SubgraphNode),
// the interior widget's `active` flag is false (its node is in the
// subgraph, not the current graph) — bypass that check.
if (
!widget.isVisible() ||
(!widgetState.active && !useOverride && !useTransitionGrace)
) {
widgetState.visible = false
continue
}
// Check if the widget's node is in the current graph
const node = widget.node
const isInCorrectGraph = currentGraph?.nodes.includes(node)
// During graph transitions, hold the previous position for one frame
// so promoted widgets don't briefly disappear before activation flips.
if (useTransitionGrace) continue
const posNode = useOverride ? override.node : widget.node
const posWidget = useOverride ? override.widget : widget
const isInCorrectGraph = posNode.graph === currentGraph
const nodeVisible = lgCanvas.isNodeVisible(posNode)
widgetState.visible =
!!isInCorrectGraph &&
lgCanvas.isNodeVisible(node) &&
isInCorrectGraph &&
nodeVisible &&
!(widget.options.hideOnZoom && lowQuality)
if (widgetState.visible && node) {
if (widgetState.visible) {
const margin = widget.margin
widgetState.pos = [node.pos[0] + margin, node.pos[1] + margin + widget.y]
widgetState.size = [
(widget.width ?? node.width) - margin * 2,
(widget.computedHeight ?? 50) - margin * 2
widgetState.pos = [
posNode.pos[0] + margin,
posNode.pos[1] + margin + posWidget.y
]
// TODO: optimize this logic as it's O(n), where n is the number of nodes
widgetState.zIndex = lgCanvas.graph?.nodes.indexOf(node) ?? -1
widgetState.size = [
(posWidget.width ?? posNode.width) - margin * 2,
(posWidget.computedHeight ?? 50) - margin * 2
]
widgetState.zIndex = getDomWidgetZIndex(posNode, currentGraph)
widgetState.readonly = lgCanvas.read_only
}
}
for (const widgetId of overrideTransitionGrace) {
if (!seenWidgetIds.has(widgetId)) {
overrideTransitionGrace.delete(widgetId)
}
}
}
const canvasStore = useCanvasStore()

View File

@@ -68,7 +68,11 @@ const updateDomClipping = () => {
return
}
const isSelected = selectedNode === widgetState.widget.node
const override = widgetState.positionOverride
const overrideInGraph =
override && lgCanvas.graph?.getNodeById(override.node.id)
const ownerNode = overrideInGraph ? override.node : widgetState.widget.node
const isSelected = selectedNode === ownerNode
const renderArea = selectedNode?.renderArea
const offset = lgCanvas.ds.offset
const scale = lgCanvas.ds.scale
@@ -143,8 +147,17 @@ onMounted(() => {
widget.options.selectOn ?? ['focus', 'click'],
() => {
const lgCanvas = canvasStore.canvas
lgCanvas?.selectNode(widgetState.widget.node)
lgCanvas?.bringToFront(widgetState.widget.node)
if (!lgCanvas) return
const override = widgetState.positionOverride
const overrideInGraph =
override && lgCanvas.graph?.getNodeById(override.node.id)
const ownerNode = overrideInGraph
? override.node
: widgetState.widget.node
lgCanvas.selectNode(ownerNode)
lgCanvas.bringToFront(ownerNode)
}
)
})

View File

@@ -0,0 +1,37 @@
import { describe, expect, it } from 'vitest'
import { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { getDomWidgetZIndex } from './domWidgetZIndex'
describe('getDomWidgetZIndex', () => {
it('follows graph node ordering when node.order is stale', () => {
const graph = new LGraph()
const first = new LGraphNode('first')
const second = new LGraphNode('second')
graph.add(first)
graph.add(second)
first.order = 0
second.order = 1
const nodes = (graph as unknown as { _nodes: LGraphNode[] })._nodes
nodes.splice(nodes.indexOf(first), 1)
nodes.push(first)
expect(first.order).toBe(0)
expect(second.order).toBe(1)
expect(getDomWidgetZIndex(first, graph)).toBe(1)
expect(getDomWidgetZIndex(second, graph)).toBe(0)
})
it('falls back to node.order when node is not in current graph', () => {
const graph = new LGraph()
const node = new LGraphNode('orphan')
node.order = 7
expect(getDomWidgetZIndex(node, graph)).toBe(7)
expect(getDomWidgetZIndex(node, undefined)).toBe(7)
})
})

View File

@@ -0,0 +1,13 @@
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
export function getDomWidgetZIndex(
node: LGraphNode,
currentGraph: LGraphNode['graph'] | undefined
): number {
if (!currentGraph) return node.order ?? -1
const graphOrder = currentGraph.nodes.indexOf(node)
if (graphOrder === -1) return node.order ?? -1
return graphOrder
}

View File

@@ -8,7 +8,6 @@ import Tab from '@/components/tab/Tab.vue'
import TabList from '@/components/tab/TabList.vue'
import Button from '@/components/ui/button/Button.vue'
import { useGraphHierarchy } from '@/composables/graph/useGraphHierarchy'
import type { ProxyWidgetsProperty } from '@/core/schemas/proxyWidget'
import { st } from '@/i18n'
import { SubgraphNode } from '@/lib/litegraph/src/litegraph'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
@@ -229,12 +228,6 @@ function handleTitleEdit(newTitle: string) {
function handleTitleCancel() {
isEditing.value = false
}
function handleProxyWidgetsUpdate(value: ProxyWidgetsProperty) {
if (!selectedSingleNode.value) return
;(selectedSingleNode.value as SubgraphNode).properties.proxyWidgets = value
canvasStore.canvas?.setDirty(true, true)
}
</script>
<template>
@@ -330,7 +323,6 @@ function handleProxyWidgetsUpdate(value: ProxyWidgetsProperty) {
<TabSubgraphInputs
v-if="activeTab === 'parameters' && isSingleSubgraphNode"
:node="selectedSingleNode as SubgraphNode"
@update:proxy-widgets="handleProxyWidgetsUpdate"
/>
<TabNormalInputs
v-else-if="activeTab === 'parameters'"

View File

@@ -3,10 +3,10 @@ import { computed, inject, provide, ref, shallowRef, watchEffect } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { isProxyWidget } from '@/core/graph/subgraph/proxyWidget'
import { parseProxyWidgets } from '@/core/schemas/proxyWidget'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import type { LGraphGroup, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { SubgraphNode } from '@/lib/litegraph/src/litegraph'
import { usePromotionStore } from '@/stores/promotionStore'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
@@ -69,27 +69,28 @@ const { t } = useI18n()
const getNodeParentGroup = inject(GetNodeParentGroupKey, null)
const promotionStore = usePromotionStore()
function isWidgetShownOnParents(
widgetNode: LGraphNode,
widget: IBaseWidget
): boolean {
if (!parents.length) return false
const proxyWidgets = parseProxyWidgets(parents[0].properties.proxyWidgets)
// For proxy widgets (already promoted), check using overlay information
if (isProxyWidget(widget)) {
return proxyWidgets.some(
([nodeId, widgetName]) =>
widget._overlay.nodeId == nodeId &&
widget._overlay.widgetName === widgetName
return parents.some((parent) => {
if (isPromotedWidgetView(widget)) {
return promotionStore.isPromoted(
parent.rootGraph.id,
parent.id,
widget.sourceNodeId,
widget.sourceWidgetName
)
}
return promotionStore.isPromoted(
parent.rootGraph.id,
parent.id,
String(widgetNode.id),
widget.name
)
}
// For regular widgets (not yet promoted), check using node ID and widget name
return proxyWidgets.some(
([nodeId, widgetName]) =>
widgetNode.id == nodeId && widget.name === widgetName
)
})
}
const isEmpty = computed(() => widgets.value.length === 0)

View File

@@ -3,25 +3,22 @@ import { useMounted, watchDebounced } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import {
computed,
customRef,
nextTick,
onBeforeUnmount,
onMounted,
ref,
shallowRef,
triggerRef,
useTemplateRef,
watch
} from 'vue'
import { useI18n } from 'vue-i18n'
import { isProxyWidget } from '@/core/graph/subgraph/proxyWidget'
import { parseProxyWidgets } from '@/core/schemas/proxyWidget'
import type { ProxyWidgetsProperty } from '@/core/schemas/proxyWidget'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import FormSearchInput from '@/renderer/extensions/vueNodes/widgets/components/form/FormSearchInput.vue'
import { DraggableList } from '@/scripts/ui/draggableList'
import { usePromotionStore } from '@/stores/promotionStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import { searchWidgets } from '../shared'
@@ -32,12 +29,9 @@ const { node } = defineProps<{
node: SubgraphNode
}>()
const emit = defineEmits<{
'update:proxyWidgets': [value: ProxyWidgetsProperty]
}>()
const { t } = useI18n()
const canvasStore = useCanvasStore()
const promotionStore = usePromotionStore()
const rightSidePanelStore = useRightSidePanelStore()
const { focusedSection, searchQuery } = storeToRefs(rightSidePanelStore)
@@ -46,18 +40,9 @@ const draggableList = ref<DraggableList | undefined>(undefined)
const sectionWidgetsRef = useTemplateRef('sectionWidgetsRef')
const advancedInputsSectionRef = useTemplateRef('advancedInputsSectionRef')
// Use customRef to track proxyWidgets changes
const proxyWidgets = customRef<ProxyWidgetsProperty>((track, trigger) => ({
get() {
track()
return parseProxyWidgets(node.properties.proxyWidgets)
},
set(value?: ProxyWidgetsProperty) {
trigger()
if (!value) return
emit('update:proxyWidgets', value)
}
}))
const promotionEntries = computed(() =>
promotionStore.getPromotions(node.rootGraph.id, node.id)
)
watch(
focusedSection,
@@ -81,22 +66,18 @@ watch(
)
const widgetsList = computed((): NodeWidgetsList => {
const proxyWidgetsOrder = proxyWidgets.value
const entries = promotionEntries.value
const { widgets = [] } = node
// Map proxyWidgets to actual proxy widgets in the correct order
const result: NodeWidgetsList = []
for (const [nodeId, widgetName] of proxyWidgetsOrder) {
// Find the proxy widget that matches this nodeId and widgetName
for (const { interiorNodeId, widgetName } of entries) {
const widget = widgets.find((w) => {
// Check if this is a proxy widget with _overlay
if (isProxyWidget(w)) {
if (isPromotedWidgetView(w)) {
return (
String(w._overlay.nodeId) === nodeId &&
w._overlay.widgetName === widgetName
String(w.sourceNodeId) === interiorNodeId &&
w.sourceWidgetName === widgetName
)
}
// For non-proxy widgets (like linked widgets), match by name
return w.name === widgetName
})
if (widget) {
@@ -108,9 +89,7 @@ const widgetsList = computed((): NodeWidgetsList => {
const advancedInputsWidgets = computed((): NodeWidgetsList => {
const interiorNodes = node.subgraph.nodes
const proxyWidgetsValue = parseProxyWidgets(node.properties.proxyWidgets)
// Get all widgets from interior nodes
const allInteriorWidgets = interiorNodes.flatMap((interiorNode) => {
const { widgets = [] } = interiorNode
return widgets
@@ -118,13 +97,15 @@ const advancedInputsWidgets = computed((): NodeWidgetsList => {
.map((widget) => ({ node: interiorNode, widget }))
})
// Filter out widgets that are already promoted using tuple matching
return allInteriorWidgets.filter(({ node: interiorNode, widget }) => {
return !proxyWidgetsValue.some(
([nodeId, widgetName]) =>
interiorNode.id == nodeId && widget.name === widgetName
)
})
return allInteriorWidgets.filter(
({ node: interiorNode, widget }) =>
!promotionStore.isPromoted(
node.rootGraph.id,
node.id,
String(interiorNode.id),
widget.name
)
)
})
const parents = computed<SubgraphNode[]>(() => [node])
@@ -181,13 +162,13 @@ function setDraggableState() {
this.draggableItem as HTMLElement
)
// Update proxyWidgets order
const pw = proxyWidgets.value
const [w] = pw.splice(oldPosition, 1)
pw.splice(newPosition, 0, w)
proxyWidgets.value = pw
promotionStore.movePromotion(
node.rootGraph.id,
node.id,
oldPosition,
newPosition
)
canvasStore.canvas?.setDirty(true, true)
triggerRef(proxyWidgets)
}
}

View File

@@ -5,11 +5,12 @@ import { useI18n } from 'vue-i18n'
import MoreButton from '@/components/button/MoreButton.vue'
import Button from '@/components/ui/button/Button.vue'
import { isProxyWidget } from '@/core/graph/subgraph/proxyWidget'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import { resolvePromotedWidgetSource } from '@/core/graph/subgraph/resolvePromotedWidgetSource'
import {
demoteWidget,
promoteWidget
} from '@/core/graph/subgraph/proxyWidgetUtils'
} from '@/core/graph/subgraph/promotionUtils'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
@@ -80,26 +81,14 @@ async function handleRename() {
function handleHideInput() {
if (!parents?.length) return
// For proxy widgets (already promoted), we need to find the original interior node and widget
if (isProxyWidget(widget)) {
const subgraph = parents[0].subgraph
const interiorNode = subgraph.getNodeById(parseInt(widget._overlay.nodeId))
if (!interiorNode) {
console.error('Could not find interior node for proxy widget')
if (isPromotedWidgetView(widget)) {
const sourceWidget = resolvePromotedWidgetSource(node, widget)
if (!sourceWidget) {
console.error('Could not resolve source widget for promoted widget')
return
}
const originalWidget = interiorNode.widgets?.find(
(w) => w.name === widget._overlay.widgetName
)
if (!originalWidget) {
console.error('Could not find original widget for proxy widget')
return
}
demoteWidget(interiorNode, originalWidget, parents)
demoteWidget(sourceWidget.node, sourceWidget.widget, parents)
} else {
// For regular widgets (not yet promoted), use them directly
demoteWidget(node, widget, parents)

View File

@@ -0,0 +1,204 @@
import { createTestingPinia } from '@pinia/testing'
import { mount } from '@vue/test-utils'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import WidgetItem from './WidgetItem.vue'
const { mockGetInputSpecForWidget, StubWidgetComponent } = vi.hoisted(() => ({
mockGetInputSpecForWidget: vi.fn(),
StubWidgetComponent: {
name: 'StubWidget',
props: ['widget', 'modelValue', 'nodeId', 'nodeType'],
template: '<div class="stub-widget" />'
}
}))
vi.mock('@/stores/nodeDefStore', () => ({
useNodeDefStore: () => ({
getInputSpecForWidget: mockGetInputSpecForWidget
})
}))
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: () => ({
canvas: { setDirty: vi.fn() }
})
}))
vi.mock('@/stores/workspace/favoritedWidgetsStore', () => ({
useFavoritedWidgetsStore: () => ({
isFavorited: vi.fn().mockReturnValue(false),
toggleFavorite: vi.fn()
})
}))
vi.mock('@/composables/graph/useGraphNodeManager', () => ({
getControlWidget: vi.fn(() => undefined)
}))
vi.mock('@/core/graph/subgraph/resolvePromotedWidgetSource', () => ({
resolvePromotedWidgetSource: vi.fn(() => undefined)
}))
vi.mock(
'@/renderer/extensions/vueNodes/widgets/registry/widgetRegistry',
() => ({
getComponent: () => StubWidgetComponent,
shouldExpand: () => false
})
)
vi.mock(
'@/renderer/extensions/vueNodes/widgets/components/WidgetLegacy.vue',
() => ({
default: StubWidgetComponent
})
)
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
rightSidePanel: {
fallbackNodeTitle: 'Untitled'
}
}
}
})
function createMockNode(overrides: Partial<LGraphNode> = {}): LGraphNode {
return {
id: 1,
type: 'TestNode',
isSubgraphNode: () => false,
graph: { rootGraph: { id: 'test-graph-id' } },
...overrides
} as unknown as LGraphNode
}
function createMockWidget(overrides: Partial<IBaseWidget> = {}): IBaseWidget {
return {
name: 'test_widget',
type: 'combo',
value: 'option_a',
y: 0,
options: {
values: ['option_a', 'option_b', 'option_c']
},
...overrides
} as IBaseWidget
}
/**
* Creates a mock PromotedWidgetView that mirrors the real class:
* properties like name, type, value, options are prototype getters,
* NOT own properties — so object spread loses them.
*/
function createMockPromotedWidgetView(
sourceOptions: IBaseWidget['options'] = {
values: ['model_a.safetensors', 'model_b.safetensors']
}
): IBaseWidget {
class MockPromotedWidgetView {
readonly sourceNodeId = '42'
readonly sourceWidgetName = 'ckpt_name'
readonly serialize = false
get name(): string {
return 'ckpt_name'
}
get type(): string {
return 'combo'
}
get value(): unknown {
return 'model_a.safetensors'
}
get options(): IBaseWidget['options'] {
return sourceOptions
}
get label(): string | undefined {
return undefined
}
get y(): number {
return 0
}
}
return new MockPromotedWidgetView() as unknown as IBaseWidget
}
function mountWidgetItem(
widget: IBaseWidget,
node: LGraphNode = createMockNode()
) {
return mount(WidgetItem, {
props: { widget, node },
global: {
plugins: [i18n],
stubs: {
EditableText: { template: '<span />' },
WidgetActions: { template: '<span />' }
}
}
})
}
describe('WidgetItem', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
vi.clearAllMocks()
})
describe('promoted widget options', () => {
it('passes options from a regular widget to the widget component', () => {
const widget = createMockWidget({
options: { values: ['a', 'b', 'c'] }
})
const wrapper = mountWidgetItem(widget)
const stub = wrapper.findComponent(StubWidgetComponent)
expect(stub.props('widget').options).toEqual({
values: ['a', 'b', 'c']
})
})
it('passes options from a PromotedWidgetView to the widget component', () => {
const expectedOptions = {
values: ['model_a.safetensors', 'model_b.safetensors']
}
const widget = createMockPromotedWidgetView(expectedOptions)
const wrapper = mountWidgetItem(widget)
const stub = wrapper.findComponent(StubWidgetComponent)
expect(stub.props('widget').options).toEqual(expectedOptions)
})
it('passes type from a PromotedWidgetView to the widget component', () => {
const widget = createMockPromotedWidgetView()
const wrapper = mountWidgetItem(widget)
const stub = wrapper.findComponent(StubWidgetComponent)
expect(stub.props('widget').type).toBe('combo')
})
it('passes name from a PromotedWidgetView to the widget component', () => {
const widget = createMockPromotedWidgetView()
const wrapper = mountWidgetItem(widget)
const stub = wrapper.findComponent(StubWidgetComponent)
expect(stub.props('widget').name).toBe('ckpt_name')
})
it('passes value from a PromotedWidgetView to the widget component', () => {
const widget = createMockPromotedWidgetView()
const wrapper = mountWidgetItem(widget)
const stub = wrapper.findComponent(StubWidgetComponent)
expect(stub.props('widget').value).toBe('model_a.safetensors')
})
})
})

View File

@@ -3,8 +3,8 @@ import { computed, customRef, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import EditableText from '@/components/common/EditableText.vue'
import { getSharedWidgetEnhancements } from '@/composables/graph/useGraphNodeManager'
import { isProxyWidget } from '@/core/graph/subgraph/proxyWidget'
import { getControlWidget } from '@/composables/graph/useGraphNodeManager'
import { resolvePromotedWidgetSource } from '@/core/graph/subgraph/resolvePromotedWidgetSource'
import { st } from '@/i18n'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
@@ -15,8 +15,13 @@ import {
getComponent,
shouldExpand
} from '@/renderer/extensions/vueNodes/widgets/registry/widgetRegistry'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import {
useWidgetValueStore,
stripGraphPrefix
} from '@/stores/widgetValueStore'
import { useFavoritedWidgetsStore } from '@/stores/workspace/favoritedWidgetsStore'
import { getNodeByExecutionId } from '@/utils/graphTraversalUtil'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import { resolveNodeDisplayName } from '@/utils/nodeTitleUtil'
import { cn } from '@/utils/tailwindUtil'
import { renameWidget } from '@/utils/widgetUtil'
@@ -50,6 +55,8 @@ const emit = defineEmits<{
const { t } = useI18n()
const canvasStore = useCanvasStore()
const nodeDefStore = useNodeDefStore()
const widgetValueStore = useWidgetValueStore()
const favoritedWidgetsStore = useFavoritedWidgetsStore()
const isEditing = ref(false)
@@ -58,18 +65,32 @@ const widgetComponent = computed(() => {
return component || WidgetLegacy
})
const enhancedWidget = computed(() => {
// Get shared enhancements (reactive value, controlWidget, spec, nodeType, etc.)
const enhancements = getSharedWidgetEnhancements(node, widget)
return { ...widget, ...enhancements }
function resolveSourceWidget(): { node: LGraphNode; widget: IBaseWidget } {
const source = resolvePromotedWidgetSource(node, widget)
return source ?? { node, widget }
}
const simplifiedWidget = computed((): SimplifiedWidget => {
const { node: sourceNode, widget: sourceWidget } = resolveSourceWidget()
const graphId = node.graph?.rootGraph?.id
const bareNodeId = stripGraphPrefix(String(sourceNode.id))
const widgetState = graphId
? widgetValueStore.getWidget(graphId, bareNodeId, sourceWidget.name)
: undefined
return {
name: widget.name,
type: widget.type,
value: widgetState?.value ?? widget.value,
label: widgetState?.label ?? widget.label,
options: widgetState?.options ?? widget.options,
spec: nodeDefStore.getInputSpecForWidget(sourceNode, sourceWidget.name),
controlWidget: getControlWidget(sourceWidget)
}
})
const sourceNodeName = computed((): string | null => {
let sourceNode: LGraphNode | null = node
if (isProxyWidget(widget)) {
const { graph, nodeId } = widget._overlay
sourceNode = getNodeByExecutionId(graph, nodeId)
}
const sourceNode = resolvePromotedWidgetSource(node, widget)?.node ?? node
if (!sourceNode) return null
const fallbackNodeTitle = t('rightSidePanel.fallbackNodeTitle')
return resolveNodeDisplayName(sourceNode, {
@@ -176,7 +197,7 @@ const displayLabel = customRef((track, trigger) => {
<component
:is="widgetComponent"
v-model="widgetValue"
:widget="enhancedWidget"
:widget="simplifiedWidget"
:node-id="String(node.id)"
:node-type="node.type"
:class="cn('col-span-1', shouldExpand(widget.type) && 'min-h-36')"

View File

@@ -1,63 +1,43 @@
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import {
computed,
customRef,
onBeforeUnmount,
onMounted,
ref,
triggerRef,
watch
} from 'vue'
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import {
demoteWidget,
getPromotableWidgets,
getWidgetName,
isRecommendedWidget,
matchesPropertyItem,
matchesWidgetItem,
promoteWidget,
pruneDisconnected,
widgetItemToProperty
} from '@/core/graph/subgraph/proxyWidgetUtils'
import type { WidgetItem } from '@/core/graph/subgraph/proxyWidgetUtils'
import { parseProxyWidgets } from '@/core/schemas/proxyWidget'
import type { ProxyWidgetsProperty } from '@/core/schemas/proxyWidget'
pruneDisconnected
} from '@/core/graph/subgraph/promotionUtils'
import type { WidgetItem } from '@/core/graph/subgraph/promotionUtils'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import FormSearchInput from '@/renderer/extensions/vueNodes/widgets/components/form/FormSearchInput.vue'
import { DraggableList } from '@/scripts/ui/draggableList'
import { useLitegraphService } from '@/services/litegraphService'
import { usePromotionStore } from '@/stores/promotionStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import SubgraphNodeWidget from './SubgraphNodeWidget.vue'
const { t } = useI18n()
const canvasStore = useCanvasStore()
const promotionStore = usePromotionStore()
const rightSidePanelStore = useRightSidePanelStore()
const { searchQuery } = storeToRefs(rightSidePanelStore)
const draggableList = ref<DraggableList | undefined>(undefined)
const draggableItems = ref()
const proxyWidgets = customRef<ProxyWidgetsProperty>((track, trigger) => ({
get() {
track()
const node = activeNode.value
if (!node) return []
return parseProxyWidgets(node.properties.proxyWidgets)
},
set(value?: ProxyWidgetsProperty) {
trigger()
const node = activeNode.value
if (!value) return
if (!node) {
console.error('Attempted to toggle widgets with no node selected')
return
}
node.properties.proxyWidgets = value
}
}))
const promotionEntries = computed(() => {
const node = activeNode.value
if (!node) return []
return promotionStore.getPromotions(node.rootGraph.id, node.id)
})
const activeNode = computed(() => {
const node = canvasStore.selectedItems[0]
@@ -67,21 +47,27 @@ const activeNode = computed(() => {
const activeWidgets = computed<WidgetItem[]>({
get() {
if (!activeNode.value) return []
const node = activeNode.value
function mapWidgets([id, name]: [string, string]): WidgetItem[] {
if (id === '-1') {
const widget = node.widgets.find((w) => w.name === name)
if (!node) return []
return promotionEntries.value.flatMap(
({ interiorNodeId, widgetName }): WidgetItem[] => {
if (interiorNodeId === '-1') {
const widget = node.widgets.find((w) => w.name === widgetName)
if (!widget) return []
return [
[{ id: -1, title: t('subgraphStore.linked'), type: '' }, widget]
]
}
const wNode = node.subgraph._nodes_by_id[interiorNodeId]
if (!wNode) return []
const widget = getPromotableWidgets(wNode).find(
(w) => w.name === widgetName
)
if (!widget) return []
return [[{ id: -1, title: '(Linked)', type: '' }, widget]]
return [[wNode, widget]]
}
const wNode = node.subgraph._nodes_by_id[id]
if (!wNode?.widgets) return []
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
@@ -89,7 +75,14 @@ const activeWidgets = computed<WidgetItem[]>({
console.error('Attempted to toggle widgets with no node selected')
return
}
proxyWidgets.value = value.map(widgetItemToProperty)
promotionStore.setPromotions(
node.rootGraph.id,
node.id,
value.map(([n, w]) => ({
interiorNodeId: String(n.id),
widgetName: getWidgetName(w)
}))
)
}
})
@@ -110,9 +103,14 @@ const interiorWidgets = computed<WidgetItem[]>(() => {
const candidateWidgets = computed<WidgetItem[]>(() => {
const node = activeNode.value
if (!node) return []
const widgets = proxyWidgets.value
return interiorWidgets.value.filter(
(widgetItem: WidgetItem) => !widgets.some(matchesPropertyItem(widgetItem))
([n, w]: WidgetItem) =>
!promotionStore.isPromoted(
node.rootGraph.id,
node.id,
String(n.id),
w.name
)
)
})
const filteredCandidates = computed<WidgetItem[]>(() => {
@@ -145,47 +143,56 @@ function toKey(item: WidgetItem) {
return `${item[0].id}: ${item[1].name}`
}
function nodeWidgets(n: LGraphNode): WidgetItem[] {
if (!n.widgets) return []
return n.widgets.map((w: IBaseWidget) => [n, w])
return getPromotableWidgets(n).map((w) => [n, w])
}
function demote([node, widget]: WidgetItem) {
const subgraphNode = activeNode.value
if (!subgraphNode) return []
if (!subgraphNode) return
demoteWidget(node, widget, [subgraphNode])
triggerRef(proxyWidgets)
promotionStore.demote(
subgraphNode.rootGraph.id,
subgraphNode.id,
String(node.id),
getWidgetName(widget)
)
}
function promote([node, widget]: WidgetItem) {
const subgraphNode = activeNode.value
if (!subgraphNode) return []
if (!subgraphNode) return
promoteWidget(node, widget, [subgraphNode])
triggerRef(proxyWidgets)
promotionStore.promote(
subgraphNode.rootGraph.id,
subgraphNode.id,
String(node.id),
widget.name
)
}
function showAll() {
const node = activeNode.value
if (!node) return
const widgets = proxyWidgets.value
const toAdd: ProxyWidgetsProperty =
filteredCandidates.value.map(widgetItemToProperty)
widgets.push(...toAdd)
proxyWidgets.value = widgets
for (const [n, w] of filteredCandidates.value) {
promotionStore.promote(node.rootGraph.id, node.id, String(n.id), w.name)
}
}
function hideAll() {
const node = activeNode.value
if (!node) return
proxyWidgets.value = proxyWidgets.value.filter(
(propertyItem) =>
!filteredActive.value.some(matchesWidgetItem(propertyItem)) ||
propertyItem[0] === '-1'
)
for (const [n, w] of filteredActive.value) {
if (String(n.id) === '-1') continue
promotionStore.demote(
node.rootGraph.id,
node.id,
String(n.id),
getWidgetName(w)
)
}
}
function showRecommended() {
const node = activeNode.value
if (!node) return
const widgets = proxyWidgets.value
const toAdd: ProxyWidgetsProperty =
recommendedWidgets.value.map(widgetItemToProperty)
widgets.push(...toAdd)
proxyWidgets.value = widgets
for (const [n, w] of recommendedWidgets.value) {
promotionStore.promote(node.rootGraph.id, node.id, String(n.id), w.name)
}
}
function setDraggableState() {