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

@@ -0,0 +1,14 @@
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
export interface PromotedWidgetView extends IBaseWidget {
readonly node: SubgraphNode
readonly sourceNodeId: string
readonly sourceWidgetName: string
}
export function isPromotedWidgetView(
widget: IBaseWidget
): widget is PromotedWidgetView {
return 'sourceNodeId' in widget && 'sourceWidgetName' in widget
}

View File

@@ -0,0 +1,921 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, test, vi } from 'vitest'
// Barrel import must come first to avoid circular dependency
// (promotedWidgetView → widgetMap → BaseWidget → LegacyWidget → barrel)
import {
CanvasPointer,
LGraphNode,
LiteGraph
} from '@/lib/litegraph/src/litegraph'
import type {
CanvasPointerEvent,
SubgraphNode
} from '@/lib/litegraph/src/litegraph'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { createPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetView'
import type { PromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetView'
import { usePromotionStore } from '@/stores/promotionStore'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import {
createTestSubgraph,
createTestSubgraphNode
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: () => ({})
}))
const mockDomWidgetStore = vi.hoisted(() => ({
widgetStates: new Map(),
setPositionOverride: vi.fn(),
clearPositionOverride: vi.fn()
}))
vi.mock('@/stores/domWidgetStore', () => ({
useDomWidgetStore: () => mockDomWidgetStore
}))
vi.mock('@/services/litegraphService', () => ({
useLitegraphService: () => ({ updatePreviews: () => ({}) })
}))
function setupSubgraph(
innerNodeCount: number = 0
): [SubgraphNode, LGraphNode[]] {
const subgraph = createTestSubgraph()
const subgraphNode = createTestSubgraphNode(subgraph)
subgraphNode._internalConfigureAfterSlots()
const graph = subgraphNode.graph!
graph.add(subgraphNode)
const innerNodes: LGraphNode[] = []
for (let i = 0; i < innerNodeCount; i++) {
const innerNode = new LGraphNode(`InnerNode${i}`)
subgraph.add(innerNode)
innerNodes.push(innerNode)
}
return [subgraphNode, innerNodes]
}
function setPromotions(
subgraphNode: SubgraphNode,
entries: [string, string][]
) {
usePromotionStore().setPromotions(
subgraphNode.rootGraph.id,
subgraphNode.id,
entries.map(([interiorNodeId, widgetName]) => ({
interiorNodeId,
widgetName
}))
)
}
function firstInnerNode(innerNodes: LGraphNode[]): LGraphNode {
const innerNode = innerNodes[0]
if (!innerNode) throw new Error('Expected at least one inner node')
return innerNode
}
describe(createPromotedWidgetView, () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
mockDomWidgetStore.widgetStates.clear()
vi.clearAllMocks()
})
test('exposes sourceNodeId and sourceWidgetName', () => {
const [subgraphNode] = setupSubgraph()
const view = createPromotedWidgetView(subgraphNode, '42', 'myWidget')
expect(view.sourceNodeId).toBe('42')
expect(view.sourceWidgetName).toBe('myWidget')
})
test('name defaults to widgetName when no displayName given', () => {
const [subgraphNode] = setupSubgraph()
const view = createPromotedWidgetView(subgraphNode, '1', 'myWidget')
expect(view.name).toBe('myWidget')
})
test('name uses displayName when provided', () => {
const [subgraphNode] = setupSubgraph()
const view = createPromotedWidgetView(
subgraphNode,
'1',
'myWidget',
'Custom Label'
)
expect(view.name).toBe('Custom Label')
})
test('node getter returns the subgraphNode', () => {
const [subgraphNode] = setupSubgraph()
const view = createPromotedWidgetView(subgraphNode, '1', 'myWidget')
// node is defined via Object.defineProperty at runtime but not on the TS interface
expect((view as unknown as Record<string, unknown>).node).toBe(subgraphNode)
})
test('serialize is false', () => {
const [subgraphNode] = setupSubgraph()
const view = createPromotedWidgetView(subgraphNode, '1', 'myWidget')
expect(view.serialize).toBe(false)
})
test('computedDisabled is false and setter is a no-op', () => {
const [subgraphNode] = setupSubgraph()
const view = createPromotedWidgetView(subgraphNode, '1', 'myWidget')
expect(view.computedDisabled).toBe(false)
view.computedDisabled = true
expect(view.computedDisabled).toBe(false)
})
test('positional properties are writable and independent', () => {
const [subgraphNode] = setupSubgraph()
const view = createPromotedWidgetView(subgraphNode, '1', 'myWidget')
expect(view.y).toBe(0)
view.y = 100
view.last_y = 90
view.computedHeight = 30
expect(view.y).toBe(100)
expect(view.last_y).toBe(90)
expect(view.computedHeight).toBe(30)
})
test('type delegates to interior widget', () => {
const [subgraphNode, innerNodes] = setupSubgraph(1)
const innerNode = firstInnerNode(innerNodes)
innerNode.addWidget('combo', 'picker', 'a', () => {}, {
values: ['a', 'b']
})
const view = createPromotedWidgetView(
subgraphNode,
String(innerNode.id),
'picker'
)
expect(view.type).toBe('combo')
})
test('type falls back to button when interior widget is missing', () => {
const [subgraphNode] = setupSubgraph()
const view = createPromotedWidgetView(subgraphNode, '999', 'missing')
expect(view.type).toBe('button')
})
test('options delegates to interior widget', () => {
const [subgraphNode, innerNodes] = setupSubgraph(1)
const innerNode = firstInnerNode(innerNodes)
const opts = { values: ['a', 'b'] as string[] }
innerNode.addWidget('combo', 'picker', 'a', () => {}, opts)
const view = createPromotedWidgetView(
subgraphNode,
String(innerNode.id),
'picker'
)
expect(view.options).toBe(opts)
})
test('options falls back to empty object when interior widget is missing', () => {
const [subgraphNode] = setupSubgraph()
const view = createPromotedWidgetView(subgraphNode, '999', 'missing')
expect(view.options).toEqual({})
})
test('linkedWidgets delegates to interior widget', () => {
const [subgraphNode, innerNodes] = setupSubgraph(1)
const innerNode = firstInnerNode(innerNodes)
const seedWidget = innerNode.addWidget('number', 'seed', 1, () => {})
const controlWidget = innerNode.addWidget(
'combo',
'control_after_generate',
'randomize',
() => {},
{
values: ['fixed', 'increment', 'decrement', 'randomize']
}
)
seedWidget.linkedWidgets = [controlWidget]
const view = createPromotedWidgetView(
subgraphNode,
String(innerNode.id),
'seed'
)
expect(view.linkedWidgets).toBe(seedWidget.linkedWidgets)
expect(view.linkedWidgets?.[0].name).toBe('control_after_generate')
})
test('value is store-backed via widgetValueStore', () => {
const [subgraphNode, innerNodes] = setupSubgraph(1)
const innerNode = firstInnerNode(innerNodes)
innerNode.addWidget('text', 'myWidget', 'initial', () => {})
const view = createPromotedWidgetView(
subgraphNode,
String(innerNode.id),
'myWidget'
)
// Value should read from the store (which was populated by addWidget)
expect(view.value).toBe('initial')
// Setting value through the view updates the store
view.value = 'updated'
expect(view.value).toBe('updated')
// The interior widget reads from the same store
expect(innerNode.widgets![0].value).toBe('updated')
})
test('value falls back to interior widget when store entry is missing', () => {
const [subgraphNode, innerNodes] = setupSubgraph(1)
const innerNode = firstInnerNode(innerNodes)
const fallbackWidgetShape = {
name: 'myWidget',
type: 'text',
value: 'initial',
options: {}
} satisfies Pick<IBaseWidget, 'name' | 'type' | 'value' | 'options'>
const fallbackWidget = fallbackWidgetShape as unknown as IBaseWidget
innerNode.widgets = [fallbackWidget]
const widgetValueStore = useWidgetValueStore()
vi.spyOn(widgetValueStore, 'getWidget').mockReturnValue(undefined)
const view = createPromotedWidgetView(
subgraphNode,
String(innerNode.id),
'myWidget'
)
expect(view.value).toBe('initial')
view.value = 'updated'
expect(fallbackWidget.value).toBe('updated')
})
test('label falls back to displayName then widgetName', () => {
const [subgraphNode, innerNodes] = setupSubgraph(1)
const innerNode = firstInnerNode(innerNodes)
innerNode.addWidget('text', 'myWidget', 'val', () => {})
const store = useWidgetValueStore()
const bareId = String(innerNode.id)
// No displayName → falls back to widgetName
const view1 = createPromotedWidgetView(subgraphNode, bareId, 'myWidget')
// Store label is undefined → falls back to displayName/widgetName
const state = store.getWidget(
subgraphNode.rootGraph.id,
bareId as never,
'myWidget'
)
state!.label = undefined
expect(view1.label).toBe('myWidget')
// With displayName → falls back to displayName
const view2 = createPromotedWidgetView(
subgraphNode,
bareId,
'myWidget',
'Custom'
)
expect(view2.label).toBe('Custom')
})
test('callback forwards to interior widget', () => {
const [subgraphNode, innerNodes] = setupSubgraph(1)
const innerNode = firstInnerNode(innerNodes)
const callbackSpy = vi.fn()
innerNode.addWidget('text', 'myWidget', 'val', callbackSpy)
const view = createPromotedWidgetView(
subgraphNode,
String(innerNode.id),
'myWidget'
)
view.callback!('newVal')
expect(callbackSpy).toHaveBeenCalled()
expect(callbackSpy.mock.calls[0][0]).toBe('newVal')
})
test('callback is safe when interior widget is missing', () => {
const [subgraphNode] = setupSubgraph()
const view = createPromotedWidgetView(subgraphNode, '999', 'missing')
expect(() => view.callback!('val')).not.toThrow()
})
test('computeSize delegates to interior widget computeSize', () => {
const [subgraphNode, innerNodes] = setupSubgraph(1)
const innerNode = firstInnerNode(innerNodes)
const widget = innerNode.addWidget('text', 'legacySize', 'val', () => {})
const computeSize = vi.fn<(width?: number) => [number, number]>(
(width?: number) => [width ?? 0, 37]
)
widget.computeSize = computeSize
const view = createPromotedWidgetView(
subgraphNode,
String(innerNode.id),
'legacySize'
)
expect(typeof view.computeSize).toBe('function')
expect(view.computeSize?.(210)).toEqual([210, 37])
expect(computeSize).toHaveBeenCalledWith(210)
})
test('onPointerDown falls back to legacy mouse callback', () => {
const [subgraphNode, innerNodes] = setupSubgraph(1)
subgraphNode.pos = [10, 20]
const innerNode = firstInnerNode(innerNodes)
const mouse = vi.fn(() => true)
const legacyWidget = {
name: 'legacyMouse',
type: 'mystery-legacy',
value: 'val',
options: {},
mouse
} as unknown as IBaseWidget
innerNode.widgets = [legacyWidget]
const view = createPromotedWidgetView(
subgraphNode,
String(innerNode.id),
'legacyMouse'
)
const pointer = new CanvasPointer(document.createElement('div'))
pointer.eDown = {
canvasX: 110,
canvasY: 120,
deltaX: 0,
deltaY: 0,
safeOffsetX: 0,
safeOffsetY: 0
} as CanvasPointerEvent
const handled = view.onPointerDown?.(
pointer,
subgraphNode,
{} as Parameters<NonNullable<typeof view.onPointerDown>>[2]
)
expect(handled).toBe(true)
expect(mouse).toHaveBeenCalledWith(pointer.eDown, [100, 100], subgraphNode)
pointer.eUp = {
canvasX: 130,
canvasY: 140,
deltaX: 0,
deltaY: 0,
safeOffsetX: 0,
safeOffsetY: 0
} as CanvasPointerEvent
pointer.finally?.()
expect(mouse).toHaveBeenCalledWith(pointer.eUp, [120, 120], subgraphNode)
})
})
describe('SubgraphNode.widgets getter', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
test('returns empty array when no proxyWidgets', () => {
const [subgraphNode] = setupSubgraph()
expect(subgraphNode.widgets).toEqual([])
})
test('caches view objects across getter calls (stable references)', () => {
const [subgraphNode, innerNodes] = setupSubgraph(1)
innerNodes[0].addWidget('text', 'widgetA', 'a', () => {})
setPromotions(subgraphNode, [['1', 'widgetA']])
const first = subgraphNode.widgets[0]
const second = subgraphNode.widgets[0]
expect(first).toBe(second)
})
test('memoizes promotion list by reference', () => {
const [subgraphNode, innerNodes] = setupSubgraph(1)
innerNodes[0].addWidget('text', 'widgetA', 'a', () => {})
setPromotions(subgraphNode, [['1', 'widgetA']])
const views1 = subgraphNode.widgets
expect(views1).toHaveLength(1)
// Same reference → same result (memoized)
const views2 = subgraphNode.widgets
expect(views2[0]).toBe(views1[0])
// New store value with same content → same cached view object
setPromotions(subgraphNode, [['1', 'widgetA']])
const views3 = subgraphNode.widgets
expect(views3[0]).toBe(views1[0])
})
test('cleans stale cache entries when promotions shrink', () => {
const [subgraphNode, innerNodes] = setupSubgraph(1)
innerNodes[0].addWidget('text', 'widgetA', 'a', () => {})
innerNodes[0].addWidget('text', 'widgetB', 'b', () => {})
setPromotions(subgraphNode, [
['1', 'widgetA'],
['1', 'widgetB']
])
expect(subgraphNode.widgets).toHaveLength(2)
const viewA = subgraphNode.widgets[0]
// Remove widgetA from promotion list
setPromotions(subgraphNode, [['1', 'widgetB']])
expect(subgraphNode.widgets).toHaveLength(1)
expect(subgraphNode.widgets[0].name).toBe('widgetB')
// Re-adding widgetA creates a new view (old one was cleaned)
setPromotions(subgraphNode, [
['1', 'widgetB'],
['1', 'widgetA']
])
const newViewA = subgraphNode.widgets[1]
expect(newViewA).not.toBe(viewA)
})
test('deduplicates entries with same nodeId:widgetName', () => {
const [subgraphNode, innerNodes] = setupSubgraph(1)
innerNodes[0].addWidget('text', 'widgetA', 'a', () => {})
setPromotions(subgraphNode, [
['1', 'widgetA'],
['1', 'widgetA']
])
expect(subgraphNode.widgets).toHaveLength(1)
})
test('setter is a no-op', () => {
const [subgraphNode, innerNodes] = setupSubgraph(1)
innerNodes[0].addWidget('text', 'widgetA', 'a', () => {})
setPromotions(subgraphNode, [['1', 'widgetA']])
// Assigning to widgets does nothing
subgraphNode.widgets = []
expect(subgraphNode.widgets).toHaveLength(1)
})
test('migrates legacy -1 entries via _resolveLegacyEntry', () => {
const [subgraphNode, innerNodes] = setupSubgraph(1)
innerNodes[0].addWidget('text', 'stringWidget', 'value', () => {})
// Simulate a slot-connected widget so legacy resolution works
const subgraph = subgraphNode.subgraph
subgraph.addInput('stringWidget', '*')
subgraphNode._internalConfigureAfterSlots()
// The _internalConfigureAfterSlots would have set up the slot-connected
// widget via _setWidget if there's a link. For unit testing legacy
// migration, we need to set up the input._widget manually.
const input = subgraphNode.inputs.find((i) => i.name === 'stringWidget')
if (input) {
input._widget = createPromotedWidgetView(
subgraphNode,
String(innerNodes[0].id),
'stringWidget'
)
}
// Set legacy -1 format via properties and re-run hydration
subgraphNode.properties.proxyWidgets = [['-1', 'stringWidget']]
subgraphNode._internalConfigureAfterSlots()
// Migration should have rewritten the store with resolved IDs
const entries = usePromotionStore().getPromotions(
subgraphNode.rootGraph.id,
subgraphNode.id
)
expect(entries).toStrictEqual([
{
interiorNodeId: String(innerNodes[0].id),
widgetName: 'stringWidget'
}
])
})
test('hydrate promotions from serialize/configure round-trip', () => {
const [subgraphNode, innerNodes] = setupSubgraph(1)
const innerNode = firstInnerNode(innerNodes)
innerNode.addWidget('text', 'widgetA', 'a', () => {})
setPromotions(subgraphNode, [[String(innerNode.id), 'widgetA']])
const serialized = subgraphNode.serialize()
const restoredNode = createTestSubgraphNode(subgraphNode.subgraph, {
id: 99
})
restoredNode.configure({
...serialized,
id: restoredNode.id,
type: subgraphNode.subgraph.id
})
const restoredEntries = usePromotionStore().getPromotions(
restoredNode.rootGraph.id,
restoredNode.id
)
expect(restoredEntries).toStrictEqual([
{
interiorNodeId: String(innerNode.id),
widgetName: 'widgetA'
}
])
})
test('clone output preserves proxyWidgets for promotion hydration', () => {
const [subgraphNode, innerNodes] = setupSubgraph(1)
const innerNode = firstInnerNode(innerNodes)
innerNode.addWidget('text', 'widgetA', 'a', () => {})
setPromotions(subgraphNode, [[String(innerNode.id), 'widgetA']])
const createNodeSpy = vi
.spyOn(LiteGraph, 'createNode')
.mockImplementation(() =>
createTestSubgraphNode(subgraphNode.subgraph, { id: 999 })
)
const clonedNode = subgraphNode.clone()
expect(clonedNode).toBeTruthy()
createNodeSpy.mockRestore()
if (!clonedNode) throw new Error('Expected clone to return a node')
const clonedSerialized = clonedNode.serialize()
expect(clonedSerialized.properties?.proxyWidgets).toStrictEqual([
[String(innerNode.id), 'widgetA']
])
const hydratedClone = createTestSubgraphNode(subgraphNode.subgraph, {
id: 100
})
hydratedClone.configure({
...clonedSerialized,
id: hydratedClone.id,
type: subgraphNode.subgraph.id
})
const hydratedEntries = usePromotionStore().getPromotions(
hydratedClone.rootGraph.id,
hydratedClone.id
)
expect(hydratedEntries).toStrictEqual([
{
interiorNodeId: String(innerNode.id),
widgetName: 'widgetA'
}
])
})
})
describe('widgets getter caching', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
test('preserves view identities when promotion order changes', () => {
const [subgraphNode, innerNodes] = setupSubgraph(1)
innerNodes[0].addWidget('text', 'widgetA', 'a', () => {})
innerNodes[0].addWidget('text', 'widgetB', 'b', () => {})
setPromotions(subgraphNode, [
['1', 'widgetA'],
['1', 'widgetB']
])
const [viewA, viewB] = subgraphNode.widgets
setPromotions(subgraphNode, [
['1', 'widgetB'],
['1', 'widgetA']
])
expect(subgraphNode.widgets[0]).toBe(viewB)
expect(subgraphNode.widgets[1]).toBe(viewA)
})
test('deduplicates by key while preserving first-occurrence order', () => {
const [subgraphNode, innerNodes] = setupSubgraph(1)
innerNodes[0].addWidget('text', 'widgetA', 'a', () => {})
innerNodes[0].addWidget('text', 'widgetB', 'b', () => {})
setPromotions(subgraphNode, [
['1', 'widgetB'],
['1', 'widgetA'],
['1', 'widgetB'],
['1', 'widgetA']
])
expect(subgraphNode.widgets).toHaveLength(2)
expect(subgraphNode.widgets[0].name).toBe('widgetB')
expect(subgraphNode.widgets[1].name).toBe('widgetA')
})
test('returns same array reference when promotions unchanged', () => {
const [subgraphNode, innerNodes] = setupSubgraph(1)
innerNodes[0].addWidget('text', 'widgetA', 'a', () => {})
setPromotions(subgraphNode, [['1', 'widgetA']])
const result1 = subgraphNode.widgets
const result2 = subgraphNode.widgets
expect(result1).toBe(result2)
})
test('returns new array after promotion change', () => {
const [subgraphNode, innerNodes] = setupSubgraph(1)
innerNodes[0].addWidget('text', 'widgetA', 'a', () => {})
innerNodes[0].addWidget('text', 'widgetB', 'b', () => {})
setPromotions(subgraphNode, [['1', 'widgetA']])
const result1 = subgraphNode.widgets
setPromotions(subgraphNode, [
['1', 'widgetA'],
['1', 'widgetB']
])
const result2 = subgraphNode.widgets
expect(result1).not.toBe(result2)
expect(result2).toHaveLength(2)
})
test('invalidates cache on removeWidget', () => {
const [subgraphNode, innerNodes] = setupSubgraph(1)
innerNodes[0].addWidget('text', 'widgetA', 'a', () => {})
innerNodes[0].addWidget('text', 'widgetB', 'b', () => {})
setPromotions(subgraphNode, [
['1', 'widgetA'],
['1', 'widgetB']
])
const result1 = subgraphNode.widgets
expect(result1).toHaveLength(2)
subgraphNode.removeWidget(result1[0])
const result2 = subgraphNode.widgets
expect(result2).toHaveLength(1)
expect(result1).not.toBe(result2)
})
})
describe('promote/demote cycle', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
test('promoting adds to store and widgets reflects it', () => {
const [subgraphNode, innerNodes] = setupSubgraph(1)
innerNodes[0].addWidget('text', 'widgetA', 'a', () => {})
expect(subgraphNode.widgets).toHaveLength(0)
setPromotions(subgraphNode, [['1', 'widgetA']])
expect(subgraphNode.widgets).toHaveLength(1)
const view = subgraphNode.widgets[0] as PromotedWidgetView
expect(view.sourceNodeId).toBe('1')
expect(view.sourceWidgetName).toBe('widgetA')
})
test('demoting via removeWidget removes from store', () => {
const [subgraphNode, innerNodes] = setupSubgraph(1)
innerNodes[0].addWidget('text', 'widgetA', 'a', () => {})
innerNodes[0].addWidget('text', 'widgetB', 'b', () => {})
setPromotions(subgraphNode, [
['1', 'widgetA'],
['1', 'widgetB']
])
const viewA = subgraphNode.widgets[0]
subgraphNode.removeWidget(viewA)
expect(subgraphNode.widgets).toHaveLength(1)
expect(subgraphNode.widgets[0].name).toBe('widgetB')
const entries = usePromotionStore().getPromotions(
subgraphNode.rootGraph.id,
subgraphNode.id
)
expect(entries).toStrictEqual([
{ interiorNodeId: '1', widgetName: 'widgetB' }
])
})
test('full promote → demote → re-promote cycle', () => {
const [subgraphNode, innerNodes] = setupSubgraph(1)
innerNodes[0].addWidget('text', 'widgetA', 'a', () => {})
// Promote
setPromotions(subgraphNode, [['1', 'widgetA']])
expect(subgraphNode.widgets).toHaveLength(1)
const view1 = subgraphNode.widgets[0]
// Demote
subgraphNode.removeWidget(view1)
expect(subgraphNode.widgets).toHaveLength(0)
// Re-promote — creates a new view since the cache was cleared
setPromotions(subgraphNode, [['1', 'widgetA']])
expect(subgraphNode.widgets).toHaveLength(1)
expect(subgraphNode.widgets[0]).not.toBe(view1)
expect(
(subgraphNode.widgets[0] as PromotedWidgetView).sourceWidgetName
).toBe('widgetA')
})
})
describe('disconnected state', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
test('view resolves type when interior widget exists', () => {
const [subgraphNode, innerNodes] = setupSubgraph(1)
innerNodes[0].addWidget('number', 'numWidget', 42, () => {})
setPromotions(subgraphNode, [['1', 'numWidget']])
expect(subgraphNode.widgets[0].type).toBe('number')
})
test('view falls back to button type when interior node is removed', () => {
const [subgraphNode, innerNodes] = setupSubgraph(1)
innerNodes[0].addWidget('text', 'myWidget', 'val', () => {})
setPromotions(subgraphNode, [['1', 'myWidget']])
expect(subgraphNode.widgets[0].type).toBe('text')
// Remove the interior node from the subgraph
subgraphNode.subgraph.remove(innerNodes[0])
expect(subgraphNode.widgets[0].type).toBe('button')
})
test('view recovers when interior widget is re-added', () => {
const [subgraphNode, innerNodes] = setupSubgraph(1)
innerNodes[0].addWidget('text', 'myWidget', 'val', () => {})
setPromotions(subgraphNode, [['1', 'myWidget']])
// Remove widget
innerNodes[0].widgets!.pop()
expect(subgraphNode.widgets[0].type).toBe('button')
// Re-add widget
innerNodes[0].addWidget('text', 'myWidget', 'val', () => {})
expect(subgraphNode.widgets[0].type).toBe('text')
})
test('options returns empty object when disconnected', () => {
const [subgraphNode] = setupSubgraph()
setPromotions(subgraphNode, [['999', 'ghost']])
expect(subgraphNode.widgets[0].options).toEqual({})
})
test('tooltip returns undefined when disconnected', () => {
const [subgraphNode] = setupSubgraph()
setPromotions(subgraphNode, [['999', 'ghost']])
expect(subgraphNode.widgets[0].tooltip).toBeUndefined()
})
})
function createFakeCanvasContext() {
return new Proxy({} as CanvasRenderingContext2D, {
get: () => vi.fn(() => ({ width: 10 }))
})
}
describe('DOM widget promotion', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
vi.clearAllMocks()
})
function createMockDOMWidget(node: LGraphNode, name: string) {
const widget = node.addWidget('text', name, 'val', () => {})
// Add 'element' and 'id' to make it a BaseDOMWidget
Object.defineProperties(widget, {
element: { value: document.createElement('div'), enumerable: true },
id: { value: `dom-widget-${name}`, enumerable: true }
})
return widget
}
function createMockComponentWidget(node: LGraphNode, name: string) {
const widget = node.addWidget('custom', name, 'val', () => {})
Object.defineProperties(widget, {
component: { value: {}, enumerable: true },
id: { value: `comp-widget-${name}`, enumerable: true }
})
return widget
}
test('draw registers position override for DOM widgets', () => {
const [subgraphNode, innerNodes] = setupSubgraph(1)
createMockDOMWidget(innerNodes[0], 'textarea')
setPromotions(subgraphNode, [['1', 'textarea']])
const view = subgraphNode.widgets[0]
view.draw!(createFakeCanvasContext(), subgraphNode, 200, 0, 30)
expect(mockDomWidgetStore.setPositionOverride).toHaveBeenCalledWith(
'dom-widget-textarea',
{ node: subgraphNode, widget: view }
)
})
test('draw registers position override for component widgets', () => {
const [subgraphNode, innerNodes] = setupSubgraph(1)
createMockComponentWidget(innerNodes[0], 'compWidget')
setPromotions(subgraphNode, [['1', 'compWidget']])
const view = subgraphNode.widgets[0]
view.draw!(createFakeCanvasContext(), subgraphNode, 200, 0, 30)
expect(mockDomWidgetStore.setPositionOverride).toHaveBeenCalledWith(
'comp-widget-compWidget',
{ node: subgraphNode, widget: view }
)
})
test('draw does not register override for non-DOM widgets', () => {
const [subgraphNode, innerNodes] = setupSubgraph(1)
innerNodes[0].addWidget('text', 'textWidget', 'val', () => {})
setPromotions(subgraphNode, [['1', 'textWidget']])
const view = subgraphNode.widgets[0]
view.draw!(createFakeCanvasContext(), subgraphNode, 200, 0, 30, true)
expect(mockDomWidgetStore.setPositionOverride).not.toHaveBeenCalled()
})
test('draw does not mutate interior node pos or size for non-DOM widgets', () => {
const [subgraphNode, innerNodes] = setupSubgraph(1)
const interiorNode = firstInnerNode(innerNodes)
interiorNode.pos = [10, 20]
interiorNode.size = [300, 120]
interiorNode.addWidget('text', 'textWidget', 'val', () => {})
setPromotions(subgraphNode, [[String(interiorNode.id), 'textWidget']])
const originalPos = [...interiorNode.pos]
const originalSize = [...interiorNode.size]
const view = subgraphNode.widgets[0]
view.draw!(createFakeCanvasContext(), subgraphNode, 200, 0, 30)
expect(Array.from(interiorNode.pos)).toEqual(originalPos)
expect(Array.from(interiorNode.size)).toEqual(originalSize)
})
test('computeLayoutSize delegates to interior DOM widget', () => {
const [subgraphNode, innerNodes] = setupSubgraph(1)
const domWidget = createMockDOMWidget(innerNodes[0], 'textarea')
domWidget.computeLayoutSize = vi.fn(() => ({
minHeight: 100,
maxHeight: 300,
minWidth: 0
}))
setPromotions(subgraphNode, [['1', 'textarea']])
const view = subgraphNode.widgets[0]
const result = view.computeLayoutSize!(subgraphNode)
expect(result).toEqual({ minHeight: 100, maxHeight: 300, minWidth: 0 })
})
test('demoting clears position override for DOM widget', () => {
const [subgraphNode, innerNodes] = setupSubgraph(1)
createMockDOMWidget(innerNodes[0], 'textarea')
setPromotions(subgraphNode, [['1', 'textarea']])
const view = subgraphNode.widgets[0]
subgraphNode.removeWidget(view)
expect(mockDomWidgetStore.clearPositionOverride).toHaveBeenCalledWith(
'dom-widget-textarea'
)
})
test('onRemoved clears position overrides for all promoted DOM widgets', () => {
const [subgraphNode, innerNodes] = setupSubgraph(1)
createMockDOMWidget(innerNodes[0], 'widgetA')
createMockDOMWidget(innerNodes[0], 'widgetB')
setPromotions(subgraphNode, [
['1', 'widgetA'],
['1', 'widgetB']
])
// Access widgets to populate cache
expect(subgraphNode.widgets).toHaveLength(2)
subgraphNode.onRemoved()
expect(mockDomWidgetStore.clearPositionOverride).toHaveBeenCalledWith(
'dom-widget-widgetA'
)
expect(mockDomWidgetStore.clearPositionOverride).toHaveBeenCalledWith(
'dom-widget-widgetB'
)
})
})

View File

@@ -0,0 +1,367 @@
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/LGraphNode'
import type { LGraphCanvas } from '@/lib/litegraph/src/LGraphCanvas'
import type { CanvasPointer } from '@/lib/litegraph/src/CanvasPointer'
import type { Point } from '@/lib/litegraph/src/interfaces'
import type { CanvasPointerEvent } from '@/lib/litegraph/src/types/events'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
import type { BaseWidget } from '@/lib/litegraph/src/widgets/BaseWidget'
import { toConcreteWidget } from '@/lib/litegraph/src/widgets/widgetMap'
import { t } from '@/i18n'
import { useDomWidgetStore } from '@/stores/domWidgetStore'
import {
stripGraphPrefix,
useWidgetValueStore
} from '@/stores/widgetValueStore'
import type { PromotedWidgetView as IPromotedWidgetView } from './promotedWidgetTypes'
export type { PromotedWidgetView } from './promotedWidgetTypes'
export { isPromotedWidgetView } from './promotedWidgetTypes'
function resolve(
subgraphNode: SubgraphNode,
nodeId: string,
widgetName: string
): { node: LGraphNode; widget: IBaseWidget } | undefined {
const node = subgraphNode.subgraph.getNodeById(nodeId)
if (!node) return undefined
const widget = node.widgets?.find((w: IBaseWidget) => w.name === widgetName)
return widget ? { node, widget } : undefined
}
function isWidgetValue(value: unknown): value is IBaseWidget['value'] {
if (value === undefined) return true
if (typeof value === 'string') return true
if (typeof value === 'number') return true
if (typeof value === 'boolean') return true
return value !== null && typeof value === 'object'
}
type LegacyMouseWidget = IBaseWidget & {
mouse: (e: CanvasPointerEvent, pos: Point, node: LGraphNode) => unknown
}
function hasLegacyMouse(widget: IBaseWidget): widget is LegacyMouseWidget {
return 'mouse' in widget && typeof widget.mouse === 'function'
}
export function createPromotedWidgetView(
subgraphNode: SubgraphNode,
nodeId: string,
widgetName: string,
displayName?: string
): IPromotedWidgetView {
return new PromotedWidgetView(subgraphNode, nodeId, widgetName, displayName)
}
class PromotedWidgetView implements IPromotedWidgetView {
[symbol: symbol]: boolean
readonly sourceNodeId: string
readonly sourceWidgetName: string
readonly serialize = false
last_y?: number
computedHeight?: number
private readonly graphId: string
private readonly bareNodeId: NodeId
private yValue = 0
private projectedSourceNode?: LGraphNode
private projectedSourceWidget?: IBaseWidget
private projectedWidget?: BaseWidget
constructor(
private readonly subgraphNode: SubgraphNode,
nodeId: string,
widgetName: string,
private readonly displayName?: string
) {
this.sourceNodeId = nodeId
this.sourceWidgetName = widgetName
this.graphId = subgraphNode.rootGraph.id
this.bareNodeId = stripGraphPrefix(nodeId)
}
get node(): SubgraphNode {
return this.subgraphNode
}
get name(): string {
return this.displayName ?? this.sourceWidgetName
}
get y(): number {
return this.yValue
}
set y(value: number) {
this.yValue = value
this.syncDomOverride()
}
get computedDisabled(): false {
return false
}
set computedDisabled(_value: boolean | undefined) {}
get type(): IBaseWidget['type'] {
return this.resolve()?.widget.type ?? 'button'
}
get options(): IBaseWidget['options'] {
return this.resolve()?.widget.options ?? {}
}
get tooltip(): string | undefined {
return this.resolve()?.widget.tooltip
}
get linkedWidgets(): IBaseWidget[] | undefined {
return this.resolve()?.widget.linkedWidgets
}
get value(): IBaseWidget['value'] {
const state = this.getWidgetState()
if (state && isWidgetValue(state.value)) return state.value
return this.resolve()?.widget.value
}
set value(value: IBaseWidget['value']) {
const state = this.getWidgetState()
if (state) {
state.value = value
return
}
const resolved = this.resolve()
if (resolved && isWidgetValue(value)) {
resolved.widget.value = value
}
}
get label(): string | undefined {
const state = this.getWidgetState()
return state?.label ?? this.displayName ?? this.sourceWidgetName
}
set label(value: string | undefined) {
const state = this.getWidgetState()
if (state) state.label = value
}
get hidden(): boolean {
return this.resolve()?.widget.hidden ?? false
}
get computeLayoutSize(): IBaseWidget['computeLayoutSize'] {
const resolved = this.resolve()
const computeLayoutSize = resolved?.widget.computeLayoutSize
if (!computeLayoutSize) return undefined
return (node: LGraphNode) => computeLayoutSize.call(resolved.widget, node)
}
get computeSize(): IBaseWidget['computeSize'] {
const resolved = this.resolve()
const computeSize = resolved?.widget.computeSize
if (!computeSize) return undefined
return (width?: number) => computeSize.call(resolved.widget, width)
}
draw(
ctx: CanvasRenderingContext2D,
_node: LGraphNode,
widgetWidth: number,
y: number,
H: number,
lowQuality?: boolean
): void {
const resolved = this.resolve()
if (!resolved) {
drawDisconnectedPlaceholder(ctx, widgetWidth, y, H)
return
}
if (isBaseDOMWidget(resolved.widget)) return this.syncDomOverride(resolved)
const projected = this.getProjectedWidget(resolved)
if (!projected || typeof projected.drawWidget !== 'function') return
const originalY = projected.y
const originalComputedHeight = projected.computedHeight
projected.y = this.y
projected.computedHeight = this.computedHeight
projected.value = this.value
projected.drawWidget(ctx, {
width: widgetWidth,
showText: !lowQuality,
suppressPromotedOutline: true
})
projected.y = originalY
projected.computedHeight = originalComputedHeight
}
onPointerDown(
pointer: CanvasPointer,
_node: LGraphNode,
canvas: LGraphCanvas
): boolean {
const resolved = this.resolve()
if (!resolved) return false
const interior = resolved.widget
if (typeof interior.onPointerDown === 'function') {
const handled = interior.onPointerDown(pointer, this.subgraphNode, canvas)
if (handled) return true
}
const concrete = toConcreteWidget(interior, this.subgraphNode, false)
if (concrete)
return this.bindConcretePointerHandlers(pointer, canvas, concrete)
if (hasLegacyMouse(interior))
return this.handleLegacyMouse(pointer, interior)
return false
}
callback(
value: unknown,
canvas?: LGraphCanvas,
node?: LGraphNode,
pos?: Point,
e?: CanvasPointerEvent
) {
this.resolve()?.widget.callback?.(value, canvas, node, pos, e)
}
private resolve(): { node: LGraphNode; widget: IBaseWidget } | undefined {
return resolve(this.subgraphNode, this.sourceNodeId, this.sourceWidgetName)
}
private getWidgetState() {
return useWidgetValueStore().getWidget(
this.graphId,
this.bareNodeId,
this.sourceWidgetName
)
}
private getProjectedWidget(resolved: {
node: LGraphNode
widget: IBaseWidget
}): BaseWidget | undefined {
const shouldRebuild =
!this.projectedWidget ||
this.projectedSourceNode !== resolved.node ||
this.projectedSourceWidget !== resolved.widget
if (!shouldRebuild) return this.projectedWidget
const concrete = toConcreteWidget(resolved.widget, resolved.node, false)
if (!concrete) {
this.projectedWidget = undefined
this.projectedSourceNode = undefined
this.projectedSourceWidget = undefined
return undefined
}
this.projectedWidget = concrete.createCopyForNode(this.subgraphNode)
this.projectedSourceNode = resolved.node
this.projectedSourceWidget = resolved.widget
return this.projectedWidget
}
private bindConcretePointerHandlers(
pointer: CanvasPointer,
canvas: LGraphCanvas,
concrete: BaseWidget
): boolean {
const downEvent = pointer.eDown
if (!downEvent) return false
pointer.onClick = () =>
concrete.onClick({
e: downEvent,
node: this.subgraphNode,
canvas
})
pointer.onDrag = (eMove) =>
concrete.onDrag?.({
e: eMove,
node: this.subgraphNode,
canvas
})
return true
}
private handleLegacyMouse(
pointer: CanvasPointer,
interior: LegacyMouseWidget
): boolean {
const downEvent = pointer.eDown
if (!downEvent) return false
const downPosition: Point = [
downEvent.canvasX - this.subgraphNode.pos[0],
downEvent.canvasY - this.subgraphNode.pos[1]
]
interior.mouse(downEvent, downPosition, this.subgraphNode)
pointer.finally = () => {
const upEvent = pointer.eUp
if (!upEvent) return
const upPosition: Point = [
upEvent.canvasX - this.subgraphNode.pos[0],
upEvent.canvasY - this.subgraphNode.pos[1]
]
interior.mouse(upEvent, upPosition, this.subgraphNode)
}
return true
}
private syncDomOverride(
resolved:
| { node: LGraphNode; widget: IBaseWidget }
| undefined = this.resolve()
) {
if (!resolved || !isBaseDOMWidget(resolved.widget)) return
useDomWidgetStore().setPositionOverride(resolved.widget.id, {
node: this.subgraphNode,
widget: this
})
}
}
/** Checks if a widget is a BaseDOMWidget (DOMWidget or ComponentWidget). */
function isBaseDOMWidget(
widget: IBaseWidget
): widget is IBaseWidget & { id: string } {
return 'id' in widget && ('element' in widget || 'component' in widget)
}
function drawDisconnectedPlaceholder(
ctx: CanvasRenderingContext2D,
width: number,
y: number,
H: number
) {
ctx.save()
ctx.fillStyle = '#333'
ctx.fillRect(15, y, width - 30, H)
ctx.fillStyle = '#999'
ctx.font = '11px monospace'
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
ctx.fillText(t('subgraphStore.disconnected'), width / 2, y + H / 2)
ctx.restore()
}

View File

@@ -0,0 +1,235 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import {
createTestSubgraph,
createTestSubgraphNode
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
import { usePromotionStore } from '@/stores/promotionStore'
const updatePreviewsMock = vi.hoisted(() => vi.fn())
vi.mock('@/services/litegraphService', () => ({
useLitegraphService: () => ({ updatePreviews: updatePreviewsMock })
}))
import {
CANVAS_IMAGE_PREVIEW_WIDGET,
getPromotableWidgets,
isPreviewPseudoWidget,
promoteRecommendedWidgets,
pruneDisconnected
} from './promotionUtils'
function widget(
overrides: Partial<
Pick<IBaseWidget, 'name' | 'serialize' | 'type' | 'options'>
>
): IBaseWidget {
return { name: 'widget', ...overrides } as unknown as IBaseWidget
}
describe('isPreviewPseudoWidget', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
vi.restoreAllMocks()
})
it('returns true for $$-prefixed widget names', () => {
expect(
isPreviewPseudoWidget(widget({ name: '$$canvas-image-preview' }))
).toBe(true)
expect(isPreviewPseudoWidget(widget({ name: '$$anything' }))).toBe(true)
})
it('returns true for serialize:false with type "preview"', () => {
expect(
isPreviewPseudoWidget(
widget({ name: 'videopreview', serialize: false, type: 'preview' })
)
).toBe(true)
})
it('returns true for options.serialize:false with type "preview" (VHS pattern)', () => {
expect(
isPreviewPseudoWidget(
widget({
name: 'videopreview',
type: 'preview',
options: { serialize: false }
})
)
).toBe(true)
})
it('returns true for serialize:false with type "video"', () => {
expect(
isPreviewPseudoWidget(
widget({ name: 'vid', serialize: false, type: 'video' })
)
).toBe(true)
})
it('returns true for serialize:false with type "audioUI"', () => {
expect(
isPreviewPseudoWidget(
widget({ name: 'audio', serialize: false, type: 'audioUI' })
)
).toBe(true)
})
it('returns false for type "preview" when serialize is not false', () => {
expect(
isPreviewPseudoWidget(
widget({ name: 'videopreview', serialize: true, type: 'preview' })
)
).toBe(false)
})
it('returns false for regular widgets', () => {
expect(
isPreviewPseudoWidget(widget({ name: 'seed', type: 'number' }))
).toBe(false)
})
it('returns false for serialize:false with unknown type', () => {
expect(
isPreviewPseudoWidget(
widget({ name: 'text', serialize: false, type: 'customtext' })
)
).toBe(false)
})
})
describe('pruneDisconnected', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
vi.restoreAllMocks()
})
it('removes disconnected entries and emits a dev warning', () => {
const subgraph = createTestSubgraph()
const subgraphNode = createTestSubgraphNode(subgraph)
const interiorNode = new LGraphNode('TestNode')
subgraphNode.subgraph.add(interiorNode)
interiorNode.addWidget('text', 'kept', 'value', () => {})
const store = usePromotionStore()
store.setPromotions(subgraphNode.rootGraph.id, subgraphNode.id, [
{ interiorNodeId: String(interiorNode.id), widgetName: 'kept' },
{ interiorNodeId: String(interiorNode.id), widgetName: 'missing-widget' },
{ interiorNodeId: '9999', widgetName: 'missing-node' }
])
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
pruneDisconnected(subgraphNode)
expect(
store.getPromotions(subgraphNode.rootGraph.id, subgraphNode.id)
).toEqual([{ interiorNodeId: String(interiorNode.id), widgetName: 'kept' }])
expect(warnSpy).toHaveBeenCalledOnce()
})
it('keeps virtual canvas preview promotions for PreviewImage nodes', () => {
const subgraph = createTestSubgraph()
const subgraphNode = createTestSubgraphNode(subgraph)
const interiorNode = new LGraphNode('PreviewImage')
interiorNode.type = 'PreviewImage'
subgraphNode.subgraph.add(interiorNode)
const store = usePromotionStore()
store.setPromotions(subgraphNode.rootGraph.id, subgraphNode.id, [
{
interiorNodeId: String(interiorNode.id),
widgetName: CANVAS_IMAGE_PREVIEW_WIDGET
}
])
pruneDisconnected(subgraphNode)
expect(
store.getPromotions(subgraphNode.rootGraph.id, subgraphNode.id)
).toEqual([
{
interiorNodeId: String(interiorNode.id),
widgetName: CANVAS_IMAGE_PREVIEW_WIDGET
}
])
})
})
describe('getPromotableWidgets', () => {
it('adds virtual canvas preview widget for PreviewImage nodes', () => {
const node = new LGraphNode('PreviewImage')
node.type = 'PreviewImage'
const widgets = getPromotableWidgets(node)
expect(
widgets.some((widget) => widget.name === CANVAS_IMAGE_PREVIEW_WIDGET)
).toBe(true)
})
it('adds virtual canvas preview widget for SaveImage nodes', () => {
const node = new LGraphNode('SaveImage')
node.type = 'SaveImage'
const widgets = getPromotableWidgets(node)
expect(
widgets.some((widget) => widget.name === CANVAS_IMAGE_PREVIEW_WIDGET)
).toBe(true)
})
it('does not add virtual canvas preview widget for non-image nodes', () => {
const node = new LGraphNode('TextNode')
node.addOutput('TEXT', 'STRING')
const widgets = getPromotableWidgets(node)
expect(
widgets.some((widget) => widget.name === CANVAS_IMAGE_PREVIEW_WIDGET)
).toBe(false)
})
it('does not add virtual canvas preview widget for ImageInvert nodes', () => {
const node = new LGraphNode('ImageInvert')
node.type = 'ImageInvert'
const widgets = getPromotableWidgets(node)
expect(
widgets.some((widget) => widget.name === CANVAS_IMAGE_PREVIEW_WIDGET)
).toBe(false)
})
})
describe('promoteRecommendedWidgets', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
updatePreviewsMock.mockReset()
})
it('skips deferred updatePreviews when a preview widget already exists', () => {
const subgraph = createTestSubgraph()
const subgraphNode = createTestSubgraphNode(subgraph)
const interiorNode = new LGraphNode('TestNode')
subgraph.add(interiorNode)
const previewWidget = interiorNode.addWidget(
'custom',
'videopreview',
'value',
() => {}
)
previewWidget.type = 'preview'
previewWidget.serialize = false
promoteRecommendedWidgets(subgraphNode)
expect(updatePreviewsMock).not.toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,285 @@
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import { t } from '@/i18n'
import type {
IContextMenuValue,
LGraphNode
} from '@/lib/litegraph/src/litegraph'
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets.ts'
import { useToastStore } from '@/platform/updates/common/toastStore'
import {
CANVAS_IMAGE_PREVIEW_WIDGET,
supportsVirtualCanvasImagePreview
} from '@/composables/node/useNodeCanvasImagePreview'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useLitegraphService } from '@/services/litegraphService'
import { usePromotionStore } from '@/stores/promotionStore'
import { useSubgraphNavigationStore } from '@/stores/subgraphNavigationStore'
type PartialNode = Pick<LGraphNode, 'title' | 'id' | 'type'>
export type WidgetItem = [PartialNode, IBaseWidget]
export { CANVAS_IMAGE_PREVIEW_WIDGET }
export function getWidgetName(w: IBaseWidget): string {
return isPromotedWidgetView(w) ? w.sourceWidgetName : w.name
}
/** Known non-$$ preview widget types added by core or popular extensions. */
const PREVIEW_WIDGET_TYPES = new Set(['preview', 'video', 'audioUI'])
/**
* Returns true for pseudo-widgets that display media previews and should
* be auto-promoted when their node is inside a subgraph.
* Matches the core `$$` convention as well as custom-node patterns
* (e.g. VHS `videopreview` with type `"preview"`).
*/
export function isPreviewPseudoWidget(widget: IBaseWidget): boolean {
if (widget.name.startsWith('$$')) return true
// Custom nodes may set serialize on the widget or in options
if (widget.serialize !== false && widget.options?.serialize !== false)
return false
if (typeof widget.type === 'string' && PREVIEW_WIDGET_TYPES.has(widget.type))
return true
return false
}
export function promoteWidget(
node: PartialNode,
widget: IBaseWidget,
parents: SubgraphNode[]
) {
const store = usePromotionStore()
const nodeId = String(
isPromotedWidgetView(widget) ? widget.sourceNodeId : node.id
)
const widgetName = getWidgetName(widget)
for (const parent of parents) {
store.promote(parent.rootGraph.id, parent.id, nodeId, widgetName)
}
}
export function demoteWidget(
node: PartialNode,
widget: IBaseWidget,
parents: SubgraphNode[]
) {
const store = usePromotionStore()
const nodeId = String(
isPromotedWidgetView(widget) ? widget.sourceNodeId : node.id
)
const widgetName = getWidgetName(widget)
for (const parent of parents) {
store.demote(parent.rootGraph.id, parent.id, nodeId, widgetName)
}
}
function getParentNodes(): SubgraphNode[] {
const { navigationStack } = useSubgraphNavigationStore()
const subgraph = navigationStack.at(-1)
if (!subgraph) {
useToastStore().add({
severity: 'error',
summary: t('g.error'),
detail: t('subgraphStore.promoteOutsideSubgraph'),
life: 2000
})
return []
}
const parentGraph = navigationStack.at(-2) ?? subgraph.rootGraph
return parentGraph.nodes.filter(
(node): node is SubgraphNode =>
node.type === subgraph.id && node.isSubgraphNode()
)
}
export function addWidgetPromotionOptions(
options: (IContextMenuValue<unknown> | null)[],
widget: IBaseWidget,
node: LGraphNode
) {
const store = usePromotionStore()
const parents = getParentNodes()
const nodeId = String(node.id)
const widgetName = getWidgetName(widget)
const promotableParents = parents.filter(
(s) => !store.isPromoted(s.rootGraph.id, s.id, nodeId, widgetName)
)
if (promotableParents.length > 0)
options.unshift({
content: t('subgraphStore.promoteWidget', {
name: widget.label ?? widget.name
}),
callback: () => {
promoteWidget(node, widget, promotableParents)
widget.callback?.(widget.value)
}
})
else {
options.unshift({
content: t('subgraphStore.unpromoteWidget', {
name: widget.label ?? widget.name
}),
callback: () => {
demoteWidget(node, widget, parents)
widget.callback?.(widget.value)
}
})
}
}
export function tryToggleWidgetPromotion() {
const canvas = useCanvasStore().getCanvas()
const [x, y] = canvas.graph_mouse
const node = canvas.graph?.getNodeOnPos(x, y, canvas.visible_nodes)
if (!node) return
const widget = node.getWidgetOnPos(x, y, true)
const parents = getParentNodes()
if (!parents.length || !widget) return
const store = usePromotionStore()
const nodeId = String(node.id)
const widgetName = getWidgetName(widget)
const promotableParents = parents.filter(
(s) => !store.isPromoted(s.rootGraph.id, s.id, nodeId, widgetName)
)
if (promotableParents.length > 0)
promoteWidget(node, widget, promotableParents)
else demoteWidget(node, widget, parents)
}
const recommendedNodes = [
'CLIPTextEncode',
'LoadImage',
'SaveImage',
'PreviewImage'
]
const recommendedWidgetNames = ['seed']
export function isRecommendedWidget([node, widget]: WidgetItem) {
return (
!widget.computedDisabled &&
(recommendedNodes.includes(node.type) ||
recommendedWidgetNames.includes(widget.name))
)
}
function supportsVirtualPreviewWidget(node: LGraphNode): boolean {
return supportsVirtualCanvasImagePreview(node)
}
function createVirtualCanvasImagePreviewWidget(): IBaseWidget {
return {
name: CANVAS_IMAGE_PREVIEW_WIDGET,
type: 'IMAGE_PREVIEW',
options: { serialize: false },
serialize: false,
y: 0,
computedDisabled: false
}
}
export function getPromotableWidgets(node: LGraphNode): IBaseWidget[] {
const widgets = [...(node.widgets ?? [])]
const hasCanvasPreviewWidget = widgets.some(
(widget) => widget.name === CANVAS_IMAGE_PREVIEW_WIDGET
)
const supportsVirtualPreview = supportsVirtualPreviewWidget(node)
if (!hasCanvasPreviewWidget && supportsVirtualPreview) {
widgets.push(createVirtualCanvasImagePreviewWidget())
}
return widgets
}
function nodeWidgets(n: LGraphNode): WidgetItem[] {
return getPromotableWidgets(n).map((w: IBaseWidget) => [n, w])
}
export function promoteRecommendedWidgets(subgraphNode: SubgraphNode) {
const store = usePromotionStore()
const { updatePreviews } = useLitegraphService()
const interiorNodes = subgraphNode.subgraph.nodes
for (const node of interiorNodes) {
node.updateComputedDisabled()
const hasPreviewWidget = () =>
node.widgets?.some(isPreviewPseudoWidget) ?? false
function promotePreviewWidget() {
const widget = node.widgets?.find(isPreviewPseudoWidget)
if (!widget) return
if (
store.isPromoted(
subgraphNode.rootGraph.id,
subgraphNode.id,
String(node.id),
widget.name
)
)
return
promoteWidget(node, widget, [subgraphNode])
}
// Promote preview widgets that already exist (e.g. custom node DOM widgets
// like VHS videopreview that are created in onNodeCreated).
promotePreviewWidget()
// If a preview widget already exists in this frame, there's nothing to
// defer. Core $$ preview widgets are the lazy path that needs updatePreviews.
if (hasPreviewWidget()) continue
// Also schedule a deferred check: core $$ widgets are created lazily by
// updatePreviews when node outputs are first loaded.
requestAnimationFrame(() => updatePreviews(node, promotePreviewWidget))
}
const filteredWidgets: WidgetItem[] = interiorNodes
.flatMap(nodeWidgets)
.filter(isRecommendedWidget)
for (const [n, w] of filteredWidgets) {
store.promote(
subgraphNode.rootGraph.id,
subgraphNode.id,
String(n.id),
getWidgetName(w)
)
}
subgraphNode.computeSize(subgraphNode.size)
}
export function pruneDisconnected(subgraphNode: SubgraphNode) {
const store = usePromotionStore()
const subgraph = subgraphNode.subgraph
const entries = store.getPromotions(
subgraphNode.rootGraph.id,
subgraphNode.id
)
const removedEntries: Array<{ interiorNodeId: string; widgetName: string }> =
[]
const validEntries = entries.filter((entry) => {
const node = subgraph.getNodeById(entry.interiorNodeId)
if (!node) {
removedEntries.push(entry)
return false
}
const hasWidget = getPromotableWidgets(node).some(
(iw) => iw.name === entry.widgetName
)
if (!hasWidget) {
removedEntries.push(entry)
}
return hasWidget
})
if (removedEntries.length > 0 && import.meta.env.DEV) {
console.warn(
'[proxyWidgetUtils] Pruned disconnected promotions',
removedEntries,
{
graphId: subgraphNode.rootGraph.id,
subgraphNodeId: subgraphNode.id
}
)
}
store.setPromotions(subgraphNode.rootGraph.id, subgraphNode.id, validEntries)
}

View File

@@ -1,235 +0,0 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, test, vi } from 'vitest'
import { registerProxyWidgets } from '@/core/graph/subgraph/proxyWidget'
import { promoteWidget } from '@/core/graph/subgraph/proxyWidgetUtils'
import { parseProxyWidgets } from '@/core/schemas/proxyWidget'
import { LGraph, LGraphNode, SubgraphNode } from '@/lib/litegraph/src/litegraph'
import type { LGraphCanvas } from '@/lib/litegraph/src/litegraph'
import {
createTestSubgraph,
createTestSubgraphNode
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
const canvasEl: Partial<HTMLCanvasElement> = { addEventListener() {} }
const canvas: Partial<LGraphCanvas> = { canvas: canvasEl as HTMLCanvasElement }
registerProxyWidgets(canvas as LGraphCanvas)
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: () => ({})
}))
vi.mock('@/stores/domWidgetStore', () => ({
useDomWidgetStore: () => ({ widgetStates: new Map() })
}))
vi.mock('@/services/litegraphService', () => ({
useLitegraphService: () => ({ updatePreviews: () => ({}) })
}))
function setupSubgraph(
innerNodeCount: number = 0
): [SubgraphNode, LGraphNode[]] {
const subgraph = createTestSubgraph()
const subgraphNode = createTestSubgraphNode(subgraph)
subgraphNode._internalConfigureAfterSlots()
const graph = subgraphNode.graph!
graph.add(subgraphNode)
const innerNodes = []
for (let i = 0; i < innerNodeCount; i++) {
const innerNode = new LGraphNode(`InnerNode${i}`)
subgraph.add(innerNode)
innerNodes.push(innerNode)
}
return [subgraphNode, innerNodes]
}
describe('Subgraph proxyWidgets', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
test('Can add simple widget', () => {
const [subgraphNode, innerNodes] = setupSubgraph(1)
innerNodes[0].addWidget('text', 'stringWidget', 'value', () => {})
subgraphNode.properties.proxyWidgets = [['1', 'stringWidget']]
expect(subgraphNode.widgets.length).toBe(1)
expect(subgraphNode.properties.proxyWidgets).toStrictEqual([
['1', 'stringWidget']
])
})
test('Can add multiple widgets with same name', () => {
const [subgraphNode, innerNodes] = setupSubgraph(2)
for (const innerNode of innerNodes)
innerNode.addWidget('text', 'stringWidget', 'value', () => {})
subgraphNode.properties.proxyWidgets = [
['1', 'stringWidget'],
['2', 'stringWidget']
]
expect(subgraphNode.widgets.length).toBe(2)
expect(subgraphNode.widgets[0].name).not.toEqual(
subgraphNode.widgets[1].name
)
})
test('Will serialize existing widgets', () => {
const [subgraphNode, innerNodes] = setupSubgraph(1)
innerNodes[0].addWidget('text', 'istringWidget', 'value', () => {})
subgraphNode.addWidget('text', 'stringWidget', 'value', () => {})
const proxyWidgets = parseProxyWidgets(subgraphNode.properties.proxyWidgets)
proxyWidgets.push(['1', 'istringWidget'])
subgraphNode.properties.proxyWidgets = proxyWidgets
expect(subgraphNode.widgets.length).toBe(2)
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)
innerNodes[0].addWidget('text', 'stringWidget', 'value', () => {})
subgraphNode.properties.proxyWidgets = [['1', 'stringWidget']]
expect(subgraphNode.widgets.length).toBe(1)
expect(subgraphNode.widgets[0].value).toBe('value')
innerNodes[0].widgets![0].value = 'test'
expect(subgraphNode.widgets[0].value).toBe('test')
subgraphNode.widgets[0].value = 'test2'
expect(innerNodes[0].widgets![0].value).toBe('test2')
})
test('Will not modify position or sizing of existing widgets', () => {
const [subgraphNode, innerNodes] = setupSubgraph(1)
innerNodes[0].addWidget('text', 'stringWidget', 'value', () => {})
subgraphNode.properties.proxyWidgets = [['1', 'stringWidget']]
if (!innerNodes[0].widgets) throw new Error('node has no widgets')
innerNodes[0].widgets[0].y = 10
innerNodes[0].widgets[0].last_y = 11
innerNodes[0].widgets[0].computedHeight = 12
subgraphNode.widgets[0].y = 20
subgraphNode.widgets[0].last_y = 21
subgraphNode.widgets[0].computedHeight = 22
expect(innerNodes[0].widgets[0].y).toBe(10)
expect(innerNodes[0].widgets[0].last_y).toBe(11)
expect(innerNodes[0].widgets[0].computedHeight).toBe(12)
})
test('Can detach and re-attach widgets', () => {
const [subgraphNode, innerNodes] = setupSubgraph(1)
innerNodes[0].addWidget('text', 'stringWidget', 'value', () => {})
subgraphNode.properties.proxyWidgets = [['1', 'stringWidget']]
if (!innerNodes[0].widgets) throw new Error('node has no widgets')
expect(subgraphNode.widgets[0].value).toBe('value')
const poppedWidget = innerNodes[0].widgets.pop()
//simulate new draw frame
subgraphNode.widgets[0].computedHeight = 10
expect(subgraphNode.widgets[0].value).toBe(undefined)
innerNodes[0].widgets.push(poppedWidget!)
subgraphNode.widgets[0].computedHeight = 10
expect(subgraphNode.widgets[0].value).toBe('value')
})
test('Proxy widget label shows widgetName, not "nodeId: widgetName"', () => {
const [subgraphNode, innerNodes] = setupSubgraph(1)
innerNodes[0].addWidget('text', 'seed', 'value', () => {})
subgraphNode.properties.proxyWidgets = [['1', 'seed']]
const proxyWidget = subgraphNode.widgets[0]
expect(proxyWidget.label).toBe('seed')
expect(proxyWidget.name).toBe('1: seed')
})
test('Proxy widget label reflects linked widget label', () => {
const [subgraphNode, innerNodes] = setupSubgraph(1)
innerNodes[0].addWidget('text', 'seed', 'value', () => {})
subgraphNode.properties.proxyWidgets = [['1', 'seed']]
const proxyWidget = subgraphNode.widgets[0]
expect(proxyWidget.label).toBe('seed')
innerNodes[0].widgets![0].label = 'My Inner Label'
// Trigger re-resolve of linked widget
proxyWidget.computedHeight = 10
expect(proxyWidget.label).toBe('My Inner Label')
})
test('Proxy widget user rename takes priority over linked widget label', () => {
const [subgraphNode, innerNodes] = setupSubgraph(1)
innerNodes[0].addWidget('text', 'seed', 'value', () => {})
subgraphNode.properties.proxyWidgets = [['1', 'seed']]
const proxyWidget = subgraphNode.widgets[0]
proxyWidget.label = 'My Custom Seed'
expect(proxyWidget.label).toBe('My Custom Seed')
innerNodes[0].widgets![0].label = 'Inner Override'
proxyWidget.computedHeight = 10
expect(proxyWidget.label).toBe('My Custom Seed')
})
test('Proxy widget label resets to linked widget on undefined', () => {
const [subgraphNode, innerNodes] = setupSubgraph(1)
innerNodes[0].addWidget('text', 'seed', 'value', () => {})
subgraphNode.properties.proxyWidgets = [['1', 'seed']]
const proxyWidget = subgraphNode.widgets[0]
proxyWidget.label = 'Custom'
expect(proxyWidget.label).toBe('Custom')
proxyWidget.label = undefined
innerNodes[0].widgets![0].label = 'Inner Label'
proxyWidget.computedHeight = 10
expect(proxyWidget.label).toBe('Inner Label')
})
test('Proxy widget labels are correct when loaded from serialized data', () => {
// Intentionally constructs SubgraphNode via constructor (not setupSubgraph)
// to exercise the deserialization/onConfigure path from blueprint JSON.
const subgraph = createTestSubgraph()
const innerNode = new LGraphNode('InnerNode')
subgraph.add(innerNode)
innerNode.addWidget('text', 'seed', 'value', () => {})
innerNode.addWidget('text', 'steps', 'value', () => {})
const parentGraph = new LGraph()
const subgraphNode = new SubgraphNode(parentGraph, subgraph, {
id: 1,
type: subgraph.id,
pos: [100, 100],
size: [200, 100],
inputs: [],
outputs: [],
properties: {
proxyWidgets: [
['1', 'seed'],
['1', 'steps']
]
},
flags: {},
mode: 0,
order: 0
})
expect(subgraphNode.widgets).toHaveLength(2)
expect(subgraphNode.widgets[0].label).toBe('seed')
expect(subgraphNode.widgets[0].name).toBe('1: seed')
expect(subgraphNode.widgets[1].label).toBe('steps')
expect(subgraphNode.widgets[1].name).toBe('1: steps')
})
test('Prevents duplicate promotion', () => {
const [subgraphNode, innerNodes] = setupSubgraph(1)
innerNodes[0].addWidget('text', 'stringWidget', 'value', () => {})
const widget = innerNodes[0].widgets![0]
// Promote once
promoteWidget(innerNodes[0], widget, [subgraphNode])
expect(subgraphNode.widgets.length).toBe(1)
expect(subgraphNode.properties.proxyWidgets).toHaveLength(1)
// Try to promote again - should not create duplicate
promoteWidget(innerNodes[0], widget, [subgraphNode])
expect(subgraphNode.widgets.length).toBe(1)
expect(subgraphNode.properties.proxyWidgets).toHaveLength(1)
expect(subgraphNode.properties.proxyWidgets).toStrictEqual([
['1', 'stringWidget']
])
})
})

View File

@@ -1,257 +0,0 @@
import {
demoteWidget,
promoteRecommendedWidgets
} from '@/core/graph/subgraph/proxyWidgetUtils'
import { parseProxyWidgets } from '@/core/schemas/proxyWidget'
import type { NodeProperty } from '@/lib/litegraph/src/LGraphNode'
import type {
LGraph,
LGraphCanvas,
LGraphNode
} from '@/lib/litegraph/src/litegraph'
import { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
import type { ISerialisedNode } from '@/lib/litegraph/src/types/serialisation'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets.ts'
import { disconnectedWidget } from '@/lib/litegraph/src/widgets/DisconnectedWidget'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { DOMWidgetImpl } from '@/scripts/domWidget'
import { useLitegraphService } from '@/services/litegraphService'
import { useDomWidgetStore } from '@/stores/domWidgetStore'
import { getNodeByExecutionId } from '@/utils/graphTraversalUtil'
/**
* @typedef {object} Overlay - Each proxy Widget has an associated overlay object
* Accessing a property which exists in the overlay object will
* instead result in the action being performed on the overlay object
* 3 properties are added for locating the proxied widget
* @property {LGraph} graph - The graph the widget resides in. Used for widget lookup
* @property {string} nodeId - The NodeId the proxy Widget is located on
* @property {string} widgetName - The name of the linked widget
*
* @property {boolean} isProxyWidget - Always true, used as type guard
* @property {LGraphNode} node - not included on IBaseWidget, but required for overlay
*/
type Overlay = Partial<IBaseWidget> & {
graph: LGraph
nodeId: string
widgetName: string
isProxyWidget: boolean
node?: LGraphNode
}
// A ProxyWidget can be treated like a normal widget.
// the _overlay property can be used to directly access the Overlay object
/**
* @typedef {object} ProxyWidget - a reference to a widget that can
* be displayed and owned by a separate node
* @property {Overlay} _overlay - a special property to access the overlay of the widget
* Any property that exists in the overlay will be accessed instead of the property
* on the linked widget
*/
type ProxyWidget = IBaseWidget & { _overlay: Overlay }
export function isProxyWidget(w: IBaseWidget): w is ProxyWidget {
return (w as { _overlay?: Overlay })?._overlay?.isProxyWidget ?? false
}
export function isDisconnectedWidget(w: ProxyWidget) {
return w instanceof disconnectedWidget.constructor
}
export function registerProxyWidgets(canvas: LGraphCanvas) {
//NOTE: canvasStore hasn't been initialized yet
canvas.canvas.addEventListener<'subgraph-opened'>('subgraph-opened', (e) => {
const { subgraph, fromNode } = e.detail
const proxyWidgets = parseProxyWidgets(fromNode.properties.proxyWidgets)
for (const node of subgraph.nodes) {
for (const widget of node.widgets ?? []) {
widget.promoted = proxyWidgets.some(
([n, w]) => node.id == n && widget.name == w
)
}
}
})
canvas.canvas.addEventListener<'subgraph-converted'>(
'subgraph-converted',
(e) => promoteRecommendedWidgets(e.detail.subgraphNode)
)
SubgraphNode.prototype.onConfigure = onConfigure
}
const originalOnConfigure = SubgraphNode.prototype.onConfigure
const onConfigure = function (
this: LGraphNode,
serialisedNode: ISerialisedNode
) {
if (!this.isSubgraphNode())
throw new Error("Can't add proxyWidgets to non-subgraphNode")
const canvasStore = useCanvasStore()
//Must give value to proxyWidgets prior to defining or it won't serialize
this.properties.proxyWidgets ??= []
originalOnConfigure?.call(this, serialisedNode)
Object.defineProperty(this.properties, 'proxyWidgets', {
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
if (isActiveGraph) {
for (const w of this.widgets.filter((w) => isProxyWidget(w))) {
if (w instanceof DOMWidgetImpl) deactivateWidget(w.id)
}
}
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)
return [w]
})
this.widgets = this.widgets.filter((w) => {
if (isProxyWidget(w)) return false
const widgetName = w.name
return !parsed.some(([, name]) => widgetName === name)
})
this.widgets.push(...newWidgets)
canvasStore.canvas?.setDirty(true, true)
this._setConcreteSlots()
this.arrange()
}
})
if (serialisedNode.properties?.proxyWidgets) {
this.properties.proxyWidgets = serialisedNode.properties.proxyWidgets
const parsed = parseProxyWidgets(serialisedNode.properties.proxyWidgets)
serialisedNode.widgets_values?.forEach((v, index) => {
if (parsed[index]?.[0] !== '-1') return
const widget = this.widgets.find((w) => w.name == parsed[index][1])
if (v !== null && widget) widget.value = v
})
}
}
function newProxyWidget(
subgraphNode: SubgraphNode,
nodeId: string,
widgetName: string
) {
const name = `${nodeId}: ${widgetName}`
const overlay = {
//items specific for proxy management
nodeId,
graph: subgraphNode.subgraph,
widgetName,
//Items which normally exist on widgets
afterQueued: undefined,
computedHeight: undefined,
isProxyWidget: true,
last_y: undefined,
name,
node: subgraphNode,
onRemove: undefined,
promoted: undefined,
serialize: false,
width: undefined,
y: 0
}
return newProxyFromOverlay(subgraphNode, overlay)
}
function resolveLinkedWidget(
overlay: Overlay
): [LGraphNode | undefined, IBaseWidget | undefined] {
const { graph, nodeId, widgetName } = overlay
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
if (widget && isProxyWidget(widget) && isDisconnectedWidget(widget))
widget.computedHeight = 20
return [n, widget]
}
function newProxyFromOverlay(subgraphNode: SubgraphNode, overlay: Overlay) {
const { updatePreviews } = useLitegraphService()
let [linkedNode, linkedWidget] = resolveLinkedWidget(overlay)
let backingWidget = linkedWidget ?? disconnectedWidget
if (overlay.widgetName.startsWith('$$')) {
overlay.node = new Proxy(subgraphNode, {
get(_t, p) {
if (p !== 'imgs') return Reflect.get(subgraphNode, p)
if (!linkedNode) return []
return linkedNode.imgs
}
})
}
/**
* A set of handlers which define widget interaction
* Many arguments are shared between function calls
* @param {IBaseWidget} _t - The "target" the call is originally made on.
* This argument is never used, but must be defined for typechecking
* @param {string} property - The name of the accessed value.
* Checked for conditional logic, but never changed
* @param {object} receiver - The object the result is set to
* and the value used as 'this' if property is a get/set method
* @param {unknown} value - only used on set calls. The thing being assigned
*/
let userLabel: string | undefined
const handler = {
get(_t: IBaseWidget, property: string, receiver: object) {
let redirectedTarget: object = backingWidget
let redirectedReceiver = receiver
if (property == '_overlay') return overlay
else if (property == 'value') redirectedReceiver = backingWidget
else if (property == 'label')
return userLabel ?? linkedWidget?.label ?? overlay.widgetName
if (Object.prototype.hasOwnProperty.call(overlay, property)) {
redirectedTarget = overlay
redirectedReceiver = overlay
}
return Reflect.get(redirectedTarget, property, redirectedReceiver)
},
set(_t: IBaseWidget, property: string, value: unknown) {
if (property == 'label') {
userLabel = value as string | undefined
return true
}
let redirectedTarget: object = backingWidget
if (property == 'computedHeight') {
if (overlay.widgetName.startsWith('$$') && linkedNode) {
updatePreviews(linkedNode)
}
if (linkedNode && linkedWidget?.computedDisabled) {
demoteWidget(linkedNode, linkedWidget, [subgraphNode])
}
//update linkage regularly, but no more than once per frame
;[linkedNode, linkedWidget] = resolveLinkedWidget(overlay)
backingWidget = linkedWidget ?? disconnectedWidget
}
if (Object.prototype.hasOwnProperty.call(overlay, property)) {
redirectedTarget = overlay
}
return Reflect.set(redirectedTarget, property, value, redirectedTarget)
},
getPrototypeOf() {
return Reflect.getPrototypeOf(backingWidget)
},
ownKeys() {
return Reflect.ownKeys(backingWidget)
},
has(_t: IBaseWidget, property: string) {
let redirectedTarget: object = backingWidget
if (Object.prototype.hasOwnProperty.call(overlay, property)) {
redirectedTarget = overlay
}
return Reflect.has(redirectedTarget, property)
}
}
const w = new Proxy(disconnectedWidget, handler)
return w
}

View File

@@ -1,188 +0,0 @@
import { parseProxyWidgets } from '@/core/schemas/proxyWidget'
import type { ProxyWidgetsProperty } from '@/core/schemas/proxyWidget'
import {
isProxyWidget,
isDisconnectedWidget
} from '@/core/graph/subgraph/proxyWidget'
import { t } from '@/i18n'
import type {
IContextMenuValue,
LGraphNode
} from '@/lib/litegraph/src/litegraph'
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets.ts'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useLitegraphService } from '@/services/litegraphService'
import { useSubgraphNavigationStore } from '@/stores/subgraphNavigationStore'
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: 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
}
export function demoteWidget(
node: PartialNode,
widget: IBaseWidget,
parents: SubgraphNode[]
) {
for (const parent of parents) {
const proxyWidgets = getProxyWidgets(parent).filter(
(widgetItem) => !matchesPropertyItem([node, widget])(widgetItem)
)
parent.properties.proxyWidgets = proxyWidgets
}
widget.promoted = false
}
function getWidgetName(w: IBaseWidget): string {
return isProxyWidget(w) ? w._overlay.widgetName : w.name
}
export function matchesWidgetItem([nodeId, widgetName]: [string, string]) {
return ([n, w]: WidgetItem) =>
n.id == nodeId && getWidgetName(w) === widgetName
}
export function matchesPropertyItem([n, w]: WidgetItem) {
return ([nodeId, widgetName]: [string, string]) =>
n.id == nodeId && getWidgetName(w) === widgetName
}
export function widgetItemToProperty([n, w]: WidgetItem): [string, string] {
return [`${n.id}`, getWidgetName(w)]
}
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,
//or by adding a new event for parent listeners to collect from
const { navigationStack } = useSubgraphNavigationStore()
const subgraph = navigationStack.at(-1)
if (!subgraph) {
useToastStore().add({
severity: 'error',
summary: t('g.error'),
detail: t('subgraphStore.promoteOutsideSubgraph'),
life: 2000
})
return []
}
const parentGraph = navigationStack.at(-2) ?? subgraph.rootGraph
return parentGraph.nodes.filter(
(node): node is SubgraphNode =>
node.type === subgraph.id && node.isSubgraphNode()
)
}
export function addWidgetPromotionOptions(
options: (IContextMenuValue<unknown> | null)[],
widget: IBaseWidget,
node: LGraphNode
) {
const parents = getParentNodes()
const promotableParents = parents.filter(
(s) => !getProxyWidgets(s).some(matchesPropertyItem([node, widget]))
)
if (promotableParents.length > 0)
options.unshift({
content: `Promote Widget: ${widget.label ?? widget.name}`,
callback: () => {
promoteWidget(node, widget, promotableParents)
widget.callback?.(widget.value)
}
})
else {
options.unshift({
content: `Un-Promote Widget: ${widget.label ?? widget.name}`,
callback: () => {
demoteWidget(node, widget, parents)
widget.callback?.(widget.value)
}
})
}
}
export function tryToggleWidgetPromotion() {
const canvas = useCanvasStore().getCanvas()
const [x, y] = canvas.graph_mouse
const node = canvas.graph?.getNodeOnPos(x, y, canvas.visible_nodes)
if (!node) return
const widget = node.getWidgetOnPos(x, y, true)
const parents = getParentNodes()
if (!parents.length || !widget) return
const promotableParents = parents.filter(
(s) => !getProxyWidgets(s).some(matchesPropertyItem([node, widget]))
)
if (promotableParents.length > 0)
promoteWidget(node, widget, promotableParents)
else demoteWidget(node, widget, parents)
}
const recommendedNodes = [
'CLIPTextEncode',
'LoadImage',
'SaveImage',
'PreviewImage'
]
const recommendedWidgetNames = ['seed']
export function isRecommendedWidget([node, widget]: WidgetItem) {
return (
!widget.computedDisabled &&
(recommendedNodes.includes(node.type) ||
recommendedWidgetNames.includes(widget.name))
)
}
function nodeWidgets(n: LGraphNode): WidgetItem[] {
return n.widgets?.map((w: IBaseWidget) => [n, w]) ?? []
}
export function promoteRecommendedWidgets(subgraphNode: SubgraphNode) {
const { updatePreviews } = useLitegraphService()
const interiorNodes = subgraphNode.subgraph.nodes
for (const node of interiorNodes) {
node.updateComputedDisabled()
function checkWidgets() {
updatePreviews(node)
const widget = node.widgets?.find((w) => w.name.startsWith('$$'))
if (!widget) return
const pw = getProxyWidgets(subgraphNode)
if (pw.some(matchesPropertyItem([node, widget]))) return
promoteWidget(node, widget, [subgraphNode])
}
requestAnimationFrame(() => updatePreviews(node, checkWidgets))
}
const filteredWidgets: WidgetItem[] = interiorNodes
.flatMap(nodeWidgets)
.filter(isRecommendedWidget)
const proxyWidgets: ProxyWidgetsProperty =
filteredWidgets.map(widgetItemToProperty)
subgraphNode.properties.proxyWidgets = proxyWidgets
subgraphNode.computeSize(subgraphNode.size)
}
export function pruneDisconnected(subgraphNode: SubgraphNode) {
subgraphNode.properties.proxyWidgets = subgraphNode.widgets
.filter(isProxyWidget)
.filter((w) => !isDisconnectedWidget(w))
.map((w) => [w._overlay.nodeId, w._overlay.widgetName])
}

View File

@@ -0,0 +1,29 @@
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
interface ResolvedPromotedWidgetSource {
node: LGraphNode
widget: IBaseWidget
}
export function resolvePromotedWidgetSource(
hostNode: LGraphNode,
widget: IBaseWidget
): ResolvedPromotedWidgetSource | undefined {
if (!isPromotedWidgetView(widget)) return undefined
if (!hostNode.isSubgraphNode()) return undefined
const sourceNode = hostNode.subgraph.getNodeById(widget.sourceNodeId)
if (!sourceNode) return undefined
const sourceWidget = sourceNode.widgets?.find(
(entry) => entry.name === widget.sourceWidgetName
)
if (!sourceWidget) return undefined
return {
node: sourceNode,
widget: sourceWidget
}
}

View File

@@ -0,0 +1,287 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, test, vi } from 'vitest'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { SubgraphNode } from '@/lib/litegraph/src/litegraph'
import { usePromotionStore } from '@/stores/promotionStore'
import {
createTestSubgraph,
createTestSubgraphNode
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: () => ({})
}))
vi.mock('@/stores/domWidgetStore', () => ({
useDomWidgetStore: () => ({ widgetStates: new Map() })
}))
vi.mock('@/services/litegraphService', () => ({
useLitegraphService: () => ({ updatePreviews: () => ({}) })
}))
function setupSubgraph(
innerNodeCount: number = 0
): [SubgraphNode, LGraphNode[]] {
const subgraph = createTestSubgraph()
const subgraphNode = createTestSubgraphNode(subgraph)
subgraphNode._internalConfigureAfterSlots()
const graph = subgraphNode.graph!
graph.add(subgraphNode)
const innerNodes = []
for (let i = 0; i < innerNodeCount; i++) {
const innerNode = new LGraphNode(`InnerNode${i}`)
subgraph.add(innerNode)
innerNodes.push(innerNode)
}
return [subgraphNode, innerNodes]
}
describe('Subgraph proxyWidgets', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
test('Can add simple widget', () => {
const [subgraphNode, innerNodes] = setupSubgraph(1)
innerNodes[0].addWidget('text', 'stringWidget', 'value', () => {})
usePromotionStore().setPromotions(
subgraphNode.rootGraph.id,
subgraphNode.id,
[{ interiorNodeId: '1', widgetName: 'stringWidget' }]
)
expect(subgraphNode.widgets.length).toBe(1)
expect(
usePromotionStore().getPromotions(
subgraphNode.rootGraph.id,
subgraphNode.id
)
).toStrictEqual([{ interiorNodeId: '1', widgetName: 'stringWidget' }])
})
test('Can add multiple widgets with same name', () => {
const [subgraphNode, innerNodes] = setupSubgraph(2)
for (const innerNode of innerNodes)
innerNode.addWidget('text', 'stringWidget', 'value', () => {})
usePromotionStore().setPromotions(
subgraphNode.rootGraph.id,
subgraphNode.id,
[
{ interiorNodeId: '1', widgetName: 'stringWidget' },
{ interiorNodeId: '2', widgetName: 'stringWidget' }
]
)
expect(subgraphNode.widgets.length).toBe(2)
// Both views share the widget name; they're distinguished by sourceNodeId
expect(subgraphNode.widgets[0].name).toBe('stringWidget')
expect(subgraphNode.widgets[1].name).toBe('stringWidget')
})
test('Will reflect proxyWidgets order changes', () => {
const [subgraphNode, innerNodes] = setupSubgraph(1)
const store = usePromotionStore()
innerNodes[0].addWidget('text', 'widgetA', 'value', () => {})
innerNodes[0].addWidget('text', 'widgetB', 'value', () => {})
store.setPromotions(subgraphNode.rootGraph.id, subgraphNode.id, [
{ interiorNodeId: '1', widgetName: 'widgetA' },
{ interiorNodeId: '1', widgetName: 'widgetB' }
])
expect(subgraphNode.widgets.length).toBe(2)
expect(subgraphNode.widgets[0].name).toBe('widgetA')
expect(subgraphNode.widgets[1].name).toBe('widgetB')
// Reorder
store.setPromotions(subgraphNode.rootGraph.id, subgraphNode.id, [
{ interiorNodeId: '1', widgetName: 'widgetB' },
{ interiorNodeId: '1', widgetName: 'widgetA' }
])
expect(subgraphNode.widgets[0].name).toBe('widgetB')
expect(subgraphNode.widgets[1].name).toBe('widgetA')
})
test('Will mirror changes to value', () => {
const [subgraphNode, innerNodes] = setupSubgraph(1)
innerNodes[0].addWidget('text', 'stringWidget', 'value', () => {})
usePromotionStore().setPromotions(
subgraphNode.rootGraph.id,
subgraphNode.id,
[{ interiorNodeId: '1', widgetName: 'stringWidget' }]
)
expect(subgraphNode.widgets.length).toBe(1)
expect(subgraphNode.widgets[0].value).toBe('value')
innerNodes[0].widgets![0].value = 'test'
expect(subgraphNode.widgets[0].value).toBe('test')
subgraphNode.widgets[0].value = 'test2'
expect(innerNodes[0].widgets![0].value).toBe('test2')
})
test('Will not modify position or sizing of existing widgets', () => {
const [subgraphNode, innerNodes] = setupSubgraph(1)
innerNodes[0].addWidget('text', 'stringWidget', 'value', () => {})
usePromotionStore().setPromotions(
subgraphNode.rootGraph.id,
subgraphNode.id,
[{ interiorNodeId: '1', widgetName: 'stringWidget' }]
)
if (!innerNodes[0].widgets) throw new Error('node has no widgets')
innerNodes[0].widgets[0].y = 10
innerNodes[0].widgets[0].last_y = 11
innerNodes[0].widgets[0].computedHeight = 12
subgraphNode.widgets[0].y = 20
subgraphNode.widgets[0].last_y = 21
subgraphNode.widgets[0].computedHeight = 22
expect(innerNodes[0].widgets[0].y).toBe(10)
expect(innerNodes[0].widgets[0].last_y).toBe(11)
expect(innerNodes[0].widgets[0].computedHeight).toBe(12)
})
test('Renders placeholder when interior widget is detached', () => {
const [subgraphNode, innerNodes] = setupSubgraph(1)
innerNodes[0].addWidget('text', 'stringWidget', 'value', () => {})
usePromotionStore().setPromotions(
subgraphNode.rootGraph.id,
subgraphNode.id,
[{ interiorNodeId: '1', widgetName: 'stringWidget' }]
)
if (!innerNodes[0].widgets) throw new Error('node has no widgets')
// View resolves the interior widget's type
expect(subgraphNode.widgets[0].type).toBe('text')
// Remove interior widget — view falls back to disconnected state
innerNodes[0].widgets.pop()
expect(subgraphNode.widgets[0].type).toBe('button')
// Re-add — view resolves again
innerNodes[0].addWidget('text', 'stringWidget', 'value', () => {})
expect(subgraphNode.widgets[0].type).toBe('text')
})
test('Prevents duplicate promotion', () => {
const [subgraphNode, innerNodes] = setupSubgraph(1)
const store = usePromotionStore()
innerNodes[0].addWidget('text', 'stringWidget', 'value', () => {})
// Promote once
store.promote(
subgraphNode.rootGraph.id,
subgraphNode.id,
String(innerNodes[0].id),
'stringWidget'
)
expect(subgraphNode.widgets.length).toBe(1)
expect(
store.getPromotions(subgraphNode.rootGraph.id, subgraphNode.id)
).toHaveLength(1)
// Try to promote again - should not create duplicate
store.promote(
subgraphNode.rootGraph.id,
subgraphNode.id,
String(innerNodes[0].id),
'stringWidget'
)
expect(subgraphNode.widgets.length).toBe(1)
expect(
store.getPromotions(subgraphNode.rootGraph.id, subgraphNode.id)
).toHaveLength(1)
expect(
store.getPromotions(subgraphNode.rootGraph.id, subgraphNode.id)
).toStrictEqual([{ interiorNodeId: '1', widgetName: 'stringWidget' }])
})
test('removeWidget removes from promotion list and view cache', () => {
const [subgraphNode, innerNodes] = setupSubgraph(1)
const store = usePromotionStore()
innerNodes[0].addWidget('text', 'widgetA', 'a', () => {})
innerNodes[0].addWidget('text', 'widgetB', 'b', () => {})
store.setPromotions(subgraphNode.rootGraph.id, subgraphNode.id, [
{ interiorNodeId: '1', widgetName: 'widgetA' },
{ interiorNodeId: '1', widgetName: 'widgetB' }
])
expect(subgraphNode.widgets).toHaveLength(2)
const widgetToRemove = subgraphNode.widgets[0]
subgraphNode.removeWidget(widgetToRemove)
expect(subgraphNode.widgets).toHaveLength(1)
expect(subgraphNode.widgets[0].name).toBe('widgetB')
expect(
store.getPromotions(subgraphNode.rootGraph.id, subgraphNode.id)
).toStrictEqual([{ interiorNodeId: '1', widgetName: 'widgetB' }])
})
test('removeWidgetByName removes from promotion list', () => {
const [subgraphNode, innerNodes] = setupSubgraph(1)
innerNodes[0].addWidget('text', 'widgetA', 'a', () => {})
innerNodes[0].addWidget('text', 'widgetB', 'b', () => {})
usePromotionStore().setPromotions(
subgraphNode.rootGraph.id,
subgraphNode.id,
[
{ interiorNodeId: '1', widgetName: 'widgetA' },
{ interiorNodeId: '1', widgetName: 'widgetB' }
]
)
subgraphNode.removeWidgetByName('widgetA')
expect(subgraphNode.widgets).toHaveLength(1)
expect(subgraphNode.widgets[0].name).toBe('widgetB')
})
test('removeWidget cleans up input references', () => {
const [subgraphNode, innerNodes] = setupSubgraph(1)
innerNodes[0].addWidget('text', 'stringWidget', 'value', () => {})
usePromotionStore().setPromotions(
subgraphNode.rootGraph.id,
subgraphNode.id,
[{ interiorNodeId: '1', widgetName: 'stringWidget' }]
)
const view = subgraphNode.widgets[0]
// Simulate an input referencing the widget
subgraphNode.addInput('stringWidget', '*')
const input = subgraphNode.inputs[subgraphNode.inputs.length - 1]
input._widget = view
subgraphNode.removeWidget(view)
expect(input._widget).toBeUndefined()
expect(subgraphNode.widgets).toHaveLength(0)
})
test('serialize does not produce widgets_values for promoted views', () => {
const [subgraphNode, innerNodes] = setupSubgraph(1)
innerNodes[0].addWidget('text', 'stringWidget', 'value', () => {})
usePromotionStore().setPromotions(
subgraphNode.rootGraph.id,
subgraphNode.id,
[{ interiorNodeId: '1', widgetName: 'stringWidget' }]
)
expect(subgraphNode.widgets).toHaveLength(1)
const serialized = subgraphNode.serialize()
// SubgraphNode doesn't set serialize_widgets, so widgets_values is absent.
// Even if it were set, views have serialize: false and would be skipped.
expect(serialized.widgets_values).toBeUndefined()
})
test('serialize preserves proxyWidgets in properties', () => {
const [subgraphNode, innerNodes] = setupSubgraph(1)
innerNodes[0].addWidget('text', 'widgetA', 'a', () => {})
innerNodes[0].addWidget('text', 'widgetB', 'b', () => {})
usePromotionStore().setPromotions(
subgraphNode.rootGraph.id,
subgraphNode.id,
[
{ interiorNodeId: '1', widgetName: 'widgetA' },
{ interiorNodeId: '1', widgetName: 'widgetB' }
]
)
const serialized = subgraphNode.serialize()
expect(serialized.properties?.proxyWidgets).toStrictEqual([
['1', 'widgetA'],
['1', 'widgetB']
])
})
})

View File

@@ -0,0 +1,56 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it } from 'vitest'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import {
createTestSubgraph,
createTestSubgraphNode
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
import { usePromotionStore } from '@/stores/promotionStore'
import { hasUnpromotedWidgets } from './unpromotedWidgetUtils'
describe('hasUnpromotedWidgets', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
it('returns true when subgraph has at least one enabled unpromoted widget', () => {
const subgraph = createTestSubgraph()
const subgraphNode = createTestSubgraphNode(subgraph)
const interiorNode = new LGraphNode('InnerNode')
subgraph.add(interiorNode)
interiorNode.addWidget('text', 'seed', '123', () => {})
expect(hasUnpromotedWidgets(subgraphNode)).toBe(true)
})
it('returns false when all enabled widgets are already promoted', () => {
const subgraph = createTestSubgraph()
const subgraphNode = createTestSubgraphNode(subgraph)
const interiorNode = new LGraphNode('InnerNode')
subgraph.add(interiorNode)
interiorNode.addWidget('text', 'seed', '123', () => {})
usePromotionStore().promote(
subgraphNode.rootGraph.id,
subgraphNode.id,
String(interiorNode.id),
'seed'
)
expect(hasUnpromotedWidgets(subgraphNode)).toBe(false)
})
it('ignores computed-disabled widgets', () => {
const subgraph = createTestSubgraph()
const subgraphNode = createTestSubgraphNode(subgraph)
const interiorNode = new LGraphNode('InnerNode')
subgraph.add(interiorNode)
const widget = interiorNode.addWidget('text', 'seed', '123', () => {})
widget.computedDisabled = true
expect(hasUnpromotedWidgets(subgraphNode)).toBe(false)
})
})

View File

@@ -0,0 +1,20 @@
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
import { usePromotionStore } from '@/stores/promotionStore'
export function hasUnpromotedWidgets(subgraphNode: SubgraphNode): boolean {
const promotionStore = usePromotionStore()
const { id: subgraphNodeId, rootGraph, subgraph } = subgraphNode
return subgraph.nodes.some((interiorNode) =>
(interiorNode.widgets ?? []).some(
(widget) =>
!widget.computedDisabled &&
!promotionStore.isPromoted(
rootGraph.id,
subgraphNodeId,
String(interiorNode.id),
widget.name
)
)
)
}

View File

@@ -4,7 +4,7 @@ import { fromZodError } from 'zod-validation-error'
import type { NodeProperty } from '@/lib/litegraph/src/LGraphNode'
const proxyWidgetsPropertySchema = z.array(z.tuple([z.string(), z.string()]))
export type ProxyWidgetsProperty = z.infer<typeof proxyWidgetsPropertySchema>
type ProxyWidgetsProperty = z.infer<typeof proxyWidgetsPropertySchema>
export function parseProxyWidgets(
property: NodeProperty | undefined