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

@@ -6,8 +6,15 @@ import { computed, markRaw, ref } from 'vue'
import type { Raw } from 'vue'
import type { PositionConfig } from '@/composables/element/useAbsolutePosition'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import type { BaseDOMWidget } from '@/scripts/domWidget'
interface PositionOverride {
node: Raw<LGraphNode>
widget: Raw<IBaseWidget>
}
export interface DomWidgetState extends PositionConfig {
// Raw widget instance
widget: Raw<BaseDOMWidget<object | string>>
@@ -16,6 +23,8 @@ export interface DomWidgetState extends PositionConfig {
zIndex: number
/** If the widget belongs to the current graph/subgraph. */
active: boolean
/** Override positioning to render on a different node (e.g. SubgraphNode). */
positionOverride?: PositionOverride
}
export const useDomWidgetStore = defineStore('domWidget', () => {
@@ -28,10 +37,7 @@ export const useDomWidgetStore = defineStore('domWidget', () => {
[...widgetStates.value.values()].filter((state) => !state.active)
)
// Register a widget with the store
const registerWidget = <V extends object | string>(
widget: BaseDOMWidget<V>
) => {
function registerWidget<V extends object | string>(widget: BaseDOMWidget<V>) {
widgetStates.value.set(widget.id, {
widget: markRaw(widget) as unknown as Raw<BaseDOMWidget<object | string>>,
visible: true,
@@ -43,29 +49,49 @@ export const useDomWidgetStore = defineStore('domWidget', () => {
})
}
// Unregister a widget from the store
const unregisterWidget = (widgetId: string) => {
function unregisterWidget(widgetId: string) {
widgetStates.value.delete(widgetId)
}
const activateWidget = (widgetId: string) => {
function activateWidget(widgetId: string) {
const state = widgetStates.value.get(widgetId)
if (state) state.active = true
}
const deactivateWidget = (widgetId: string) => {
function deactivateWidget(widgetId: string) {
const state = widgetStates.value.get(widgetId)
if (state) state.active = false
}
const setWidget = (widget: BaseDOMWidget) => {
function setWidget(widget: BaseDOMWidget) {
const state = widgetStates.value.get(widget.id)
if (!state) return
state.active = true
state.widget = widget
}
const clear = () => {
function setPositionOverride(widgetId: string, override: PositionOverride) {
const state = widgetStates.value.get(widgetId)
if (!state) return
const current = state.positionOverride
if (
current &&
current.node === override.node &&
current.widget === override.widget
)
return
state.positionOverride = {
node: markRaw(override.node),
widget: markRaw(override.widget)
}
}
function clearPositionOverride(widgetId: string) {
const state = widgetStates.value.get(widgetId)
if (state) state.positionOverride = undefined
}
function clear() {
widgetStates.value.clear()
}
@@ -78,6 +104,8 @@ export const useDomWidgetStore = defineStore('domWidget', () => {
activateWidget,
deactivateWidget,
setWidget,
setPositionOverride,
clearPositionOverride,
clear
}
})

View File

@@ -4,7 +4,8 @@ import { defineStore } from 'pinia'
import { computed, ref, watchEffect } from 'vue'
import { t } from '@/i18n'
import { isProxyWidget } from '@/core/graph/subgraph/proxyWidget'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import { resolvePromotedWidgetSource } from '@/core/graph/subgraph/resolvePromotedWidgetSource'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { transformNodeDefV1ToV2 } from '@/schemas/nodeDef/migration'
@@ -429,14 +430,12 @@ export const useNodeDefStore = defineStore('nodeDef', () => {
return nodeDef.inputs[widgetName]
}
const widget = node.widgets?.find((w) => w.name === widgetName)
//TODO: resolve spec for linked
if (!widget || !isProxyWidget(widget)) return undefined
if (!widget || !isPromotedWidgetView(widget)) return undefined
const { nodeId, widgetName: subWidgetName } = widget._overlay
const subNode = node.subgraph.getNodeById(nodeId)
if (!subNode) return undefined
const sourceWidget = resolvePromotedWidgetSource(node, widget)
if (!sourceWidget) return undefined
return getInputSpecForWidget(subNode, subWidgetName)
return getInputSpecForWidget(sourceWidget.node, sourceWidget.widget.name)
}
/**

View File

@@ -0,0 +1,310 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it } from 'vitest'
import type { NodeId } from '@/lib/litegraph/src/LGraphNode'
import type { UUID } from '@/lib/litegraph/src/utils/uuid'
import { usePromotionStore } from './promotionStore'
describe(usePromotionStore, () => {
let store: ReturnType<typeof usePromotionStore>
const graphA = 'graph-a' as UUID
const graphB = 'graph-b' as UUID
const nodeId = 1 as NodeId
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
store = usePromotionStore()
})
describe('getPromotions', () => {
it('returns empty array for unknown node', () => {
expect(store.getPromotions(graphA, nodeId)).toEqual([])
})
it('returns entries after setPromotions', () => {
const entries = [
{ interiorNodeId: '10', widgetName: 'seed' },
{ interiorNodeId: '11', widgetName: 'steps' }
]
store.setPromotions(graphA, nodeId, entries)
expect(store.getPromotions(graphA, nodeId)).toEqual(entries)
})
it('returns a defensive copy', () => {
store.setPromotions(graphA, nodeId, [
{ interiorNodeId: '10', widgetName: 'seed' }
])
const result = store.getPromotions(graphA, nodeId)
result.push({ interiorNodeId: '11', widgetName: 'steps' })
expect(store.getPromotions(graphA, nodeId)).toEqual([
{ interiorNodeId: '10', widgetName: 'seed' }
])
})
})
describe('isPromoted', () => {
it('returns false when nothing is promoted', () => {
expect(store.isPromoted(graphA, nodeId, '10', 'seed')).toBe(false)
})
it('returns true for a promoted entry', () => {
store.promote(graphA, nodeId, '10', 'seed')
expect(store.isPromoted(graphA, nodeId, '10', 'seed')).toBe(true)
})
it('returns false for a different widget on the same node', () => {
store.promote(graphA, nodeId, '10', 'seed')
expect(store.isPromoted(graphA, nodeId, '10', 'steps')).toBe(false)
})
})
describe('isPromotedByAny', () => {
const nodeA = 1 as NodeId
const nodeB = 2 as NodeId
it('returns false when nothing is promoted', () => {
expect(store.isPromotedByAny(graphA, '10', 'seed')).toBe(false)
})
it('returns true when promoted by one parent', () => {
store.promote(graphA, nodeA, '10', 'seed')
expect(store.isPromotedByAny(graphA, '10', 'seed')).toBe(true)
})
it('returns true when promoted by multiple parents', () => {
store.promote(graphA, nodeA, '10', 'seed')
store.promote(graphA, nodeB, '10', 'seed')
expect(store.isPromotedByAny(graphA, '10', 'seed')).toBe(true)
})
it('returns false after demoting from all parents', () => {
store.promote(graphA, nodeA, '10', 'seed')
store.promote(graphA, nodeB, '10', 'seed')
store.demote(graphA, nodeA, '10', 'seed')
store.demote(graphA, nodeB, '10', 'seed')
expect(store.isPromotedByAny(graphA, '10', 'seed')).toBe(false)
})
it('returns true when still promoted by one parent after partial demote', () => {
store.promote(graphA, nodeA, '10', 'seed')
store.promote(graphA, nodeB, '10', 'seed')
store.demote(graphA, nodeA, '10', 'seed')
expect(store.isPromotedByAny(graphA, '10', 'seed')).toBe(true)
})
it('returns false for different widget on same node', () => {
store.promote(graphA, nodeA, '10', 'seed')
expect(store.isPromotedByAny(graphA, '10', 'steps')).toBe(false)
})
})
describe('setPromotions', () => {
it('replaces existing entries', () => {
store.promote(graphA, nodeId, '10', 'seed')
store.setPromotions(graphA, nodeId, [
{ interiorNodeId: '11', widgetName: 'steps' }
])
expect(store.isPromoted(graphA, nodeId, '10', 'seed')).toBe(false)
expect(store.isPromoted(graphA, nodeId, '11', 'steps')).toBe(true)
})
it('clears entries when set to empty array', () => {
store.promote(graphA, nodeId, '10', 'seed')
store.setPromotions(graphA, nodeId, [])
expect(store.getPromotions(graphA, nodeId)).toEqual([])
})
it('preserves order', () => {
const entries = [
{ interiorNodeId: '10', widgetName: 'seed' },
{ interiorNodeId: '11', widgetName: 'steps' },
{ interiorNodeId: '12', widgetName: 'cfg' }
]
store.setPromotions(graphA, nodeId, entries)
expect(store.getPromotions(graphA, nodeId)).toEqual(entries)
})
})
describe('promote', () => {
it('adds a new entry', () => {
store.promote(graphA, nodeId, '10', 'seed')
expect(store.getPromotions(graphA, nodeId)).toHaveLength(1)
})
it('does not duplicate existing entries', () => {
store.promote(graphA, nodeId, '10', 'seed')
store.promote(graphA, nodeId, '10', 'seed')
expect(store.getPromotions(graphA, nodeId)).toHaveLength(1)
})
it('appends to existing entries', () => {
store.promote(graphA, nodeId, '10', 'seed')
store.promote(graphA, nodeId, '11', 'steps')
expect(store.getPromotions(graphA, nodeId)).toHaveLength(2)
})
})
describe('demote', () => {
it('removes an existing entry', () => {
store.promote(graphA, nodeId, '10', 'seed')
store.demote(graphA, nodeId, '10', 'seed')
expect(store.getPromotions(graphA, nodeId)).toEqual([])
})
it('is a no-op for non-existent entries', () => {
store.promote(graphA, nodeId, '10', 'seed')
store.demote(graphA, nodeId, '99', 'nonexistent')
expect(store.getPromotions(graphA, nodeId)).toHaveLength(1)
})
it('preserves other entries', () => {
store.promote(graphA, nodeId, '10', 'seed')
store.promote(graphA, nodeId, '11', 'steps')
store.demote(graphA, nodeId, '10', 'seed')
expect(store.getPromotions(graphA, nodeId)).toEqual([
{ interiorNodeId: '11', widgetName: 'steps' }
])
})
})
describe('movePromotion', () => {
it('moves an entry from one index to another', () => {
store.promote(graphA, nodeId, '10', 'seed')
store.promote(graphA, nodeId, '11', 'steps')
store.promote(graphA, nodeId, '12', 'cfg')
store.movePromotion(graphA, nodeId, 0, 2)
expect(store.getPromotions(graphA, nodeId)).toEqual([
{ interiorNodeId: '11', widgetName: 'steps' },
{ interiorNodeId: '12', widgetName: 'cfg' },
{ interiorNodeId: '10', widgetName: 'seed' }
])
})
it('is a no-op for out-of-bounds indices', () => {
store.promote(graphA, nodeId, '10', 'seed')
store.movePromotion(graphA, nodeId, 0, 5)
expect(store.getPromotions(graphA, nodeId)).toEqual([
{ interiorNodeId: '10', widgetName: 'seed' }
])
})
it('is a no-op when fromIndex equals toIndex', () => {
store.promote(graphA, nodeId, '10', 'seed')
store.promote(graphA, nodeId, '11', 'steps')
store.movePromotion(graphA, nodeId, 1, 1)
expect(store.getPromotions(graphA, nodeId)).toEqual([
{ interiorNodeId: '10', widgetName: 'seed' },
{ interiorNodeId: '11', widgetName: 'steps' }
])
})
})
describe('ref-counted isPromotedByAny', () => {
const nodeA = 1 as NodeId
const nodeB = 2 as NodeId
it('tracks across setPromotions calls', () => {
store.setPromotions(graphA, nodeA, [
{ interiorNodeId: '10', widgetName: 'seed' }
])
expect(store.isPromotedByAny(graphA, '10', 'seed')).toBe(true)
store.setPromotions(graphA, nodeB, [
{ interiorNodeId: '10', widgetName: 'seed' }
])
expect(store.isPromotedByAny(graphA, '10', 'seed')).toBe(true)
// Remove from A — still promoted by B
store.setPromotions(graphA, nodeA, [])
expect(store.isPromotedByAny(graphA, '10', 'seed')).toBe(true)
// Remove from B — now gone
store.setPromotions(graphA, nodeB, [])
expect(store.isPromotedByAny(graphA, '10', 'seed')).toBe(false)
})
it('handles replacement via setPromotions correctly', () => {
store.setPromotions(graphA, nodeA, [
{ interiorNodeId: '10', widgetName: 'seed' },
{ interiorNodeId: '11', widgetName: 'steps' }
])
expect(store.isPromotedByAny(graphA, '10', 'seed')).toBe(true)
expect(store.isPromotedByAny(graphA, '11', 'steps')).toBe(true)
// Replace with different entries
store.setPromotions(graphA, nodeA, [
{ interiorNodeId: '11', widgetName: 'steps' },
{ interiorNodeId: '12', widgetName: 'cfg' }
])
expect(store.isPromotedByAny(graphA, '10', 'seed')).toBe(false)
expect(store.isPromotedByAny(graphA, '11', 'steps')).toBe(true)
expect(store.isPromotedByAny(graphA, '12', 'cfg')).toBe(true)
})
it('stays consistent through movePromotion', () => {
store.promote(graphA, nodeA, '10', 'seed')
store.promote(graphA, nodeA, '11', 'steps')
store.movePromotion(graphA, nodeA, 0, 1)
expect(store.isPromotedByAny(graphA, '10', 'seed')).toBe(true)
expect(store.isPromotedByAny(graphA, '11', 'steps')).toBe(true)
})
})
describe('multi-node isolation', () => {
const nodeA = 1 as NodeId
const nodeB = 2 as NodeId
it('keeps promotions separate per subgraph node', () => {
store.promote(graphA, nodeA, '10', 'seed')
store.promote(graphA, nodeB, '20', 'cfg')
expect(store.getPromotions(graphA, nodeA)).toEqual([
{ interiorNodeId: '10', widgetName: 'seed' }
])
expect(store.getPromotions(graphA, nodeB)).toEqual([
{ interiorNodeId: '20', widgetName: 'cfg' }
])
})
it('demoting from one node does not affect another', () => {
store.promote(graphA, nodeA, '10', 'seed')
store.promote(graphA, nodeB, '10', 'seed')
store.demote(graphA, nodeA, '10', 'seed')
expect(store.isPromoted(graphA, nodeA, '10', 'seed')).toBe(false)
expect(store.isPromoted(graphA, nodeB, '10', 'seed')).toBe(true)
})
})
describe('graph isolation', () => {
it('isolates promotions by graph id', () => {
store.promote(graphA, nodeId, '10', 'seed')
store.promote(graphB, nodeId, '20', 'steps')
expect(store.getPromotions(graphA, nodeId)).toEqual([
{ interiorNodeId: '10', widgetName: 'seed' }
])
expect(store.getPromotions(graphB, nodeId)).toEqual([
{ interiorNodeId: '20', widgetName: 'steps' }
])
})
it('clearGraph only removes one graph namespace', () => {
store.promote(graphA, nodeId, '10', 'seed')
store.promote(graphB, nodeId, '20', 'steps')
store.clearGraph(graphA)
expect(store.getPromotions(graphA, nodeId)).toEqual([])
expect(store.getPromotions(graphB, nodeId)).toEqual([
{ interiorNodeId: '20', widgetName: 'steps' }
])
expect(store.isPromotedByAny(graphA, '10', 'seed')).toBe(false)
expect(store.isPromotedByAny(graphB, '20', 'steps')).toBe(true)
})
})
})

View File

@@ -0,0 +1,186 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import type { NodeId } from '@/lib/litegraph/src/LGraphNode'
import type { UUID } from '@/lib/litegraph/src/utils/uuid'
interface PromotionEntry {
interiorNodeId: string
widgetName: string
}
export const usePromotionStore = defineStore('promotion', () => {
const graphPromotions = ref(new Map<UUID, Map<NodeId, PromotionEntry[]>>())
const graphRefCounts = ref(new Map<UUID, Map<string, number>>())
function _getPromotionsForGraph(
graphId: UUID
): Map<NodeId, PromotionEntry[]> {
const promotions = graphPromotions.value.get(graphId)
if (promotions) return promotions
const nextPromotions = new Map<NodeId, PromotionEntry[]>()
graphPromotions.value.set(graphId, nextPromotions)
return nextPromotions
}
function _getRefCountsForGraph(graphId: UUID): Map<string, number> {
const refCounts = graphRefCounts.value.get(graphId)
if (refCounts) return refCounts
const nextRefCounts = new Map<string, number>()
graphRefCounts.value.set(graphId, nextRefCounts)
return nextRefCounts
}
function _makeKey(interiorNodeId: string, widgetName: string): string {
return `${interiorNodeId}:${widgetName}`
}
function _incrementKeys(graphId: UUID, entries: PromotionEntry[]): void {
const refCounts = _getRefCountsForGraph(graphId)
for (const e of entries) {
const key = _makeKey(e.interiorNodeId, e.widgetName)
refCounts.set(key, (refCounts.get(key) ?? 0) + 1)
}
}
function _decrementKeys(graphId: UUID, entries: PromotionEntry[]): void {
const refCounts = _getRefCountsForGraph(graphId)
for (const e of entries) {
const key = _makeKey(e.interiorNodeId, e.widgetName)
const count = (refCounts.get(key) ?? 1) - 1
if (count <= 0) {
refCounts.delete(key)
} else {
refCounts.set(key, count)
}
}
}
function getPromotionsRef(
graphId: UUID,
subgraphNodeId: NodeId
): PromotionEntry[] {
return _getPromotionsForGraph(graphId).get(subgraphNodeId) ?? []
}
function getPromotions(
graphId: UUID,
subgraphNodeId: NodeId
): PromotionEntry[] {
return [...getPromotionsRef(graphId, subgraphNodeId)]
}
function isPromoted(
graphId: UUID,
subgraphNodeId: NodeId,
interiorNodeId: string,
widgetName: string
): boolean {
return getPromotionsRef(graphId, subgraphNodeId).some(
(e) => e.interiorNodeId === interiorNodeId && e.widgetName === widgetName
)
}
function isPromotedByAny(
graphId: UUID,
interiorNodeId: string,
widgetName: string
): boolean {
const refCounts = _getRefCountsForGraph(graphId)
return (refCounts.get(_makeKey(interiorNodeId, widgetName)) ?? 0) > 0
}
function setPromotions(
graphId: UUID,
subgraphNodeId: NodeId,
entries: PromotionEntry[]
): void {
const promotions = _getPromotionsForGraph(graphId)
const oldEntries = promotions.get(subgraphNodeId) ?? []
_decrementKeys(graphId, oldEntries)
_incrementKeys(graphId, entries)
if (entries.length === 0) {
promotions.delete(subgraphNodeId)
} else {
promotions.set(subgraphNodeId, [...entries])
}
}
function promote(
graphId: UUID,
subgraphNodeId: NodeId,
interiorNodeId: string,
widgetName: string
): void {
if (isPromoted(graphId, subgraphNodeId, interiorNodeId, widgetName)) return
const entries = getPromotionsRef(graphId, subgraphNodeId)
setPromotions(graphId, subgraphNodeId, [
...entries,
{ interiorNodeId, widgetName }
])
}
function demote(
graphId: UUID,
subgraphNodeId: NodeId,
interiorNodeId: string,
widgetName: string
): void {
const entries = getPromotionsRef(graphId, subgraphNodeId)
setPromotions(
graphId,
subgraphNodeId,
entries.filter(
(e) =>
!(e.interiorNodeId === interiorNodeId && e.widgetName === widgetName)
)
)
}
function movePromotion(
graphId: UUID,
subgraphNodeId: NodeId,
fromIndex: number,
toIndex: number
): void {
const promotions = _getPromotionsForGraph(graphId)
const currentEntries = promotions.get(subgraphNodeId)
if (!currentEntries?.length) return
const entries = [...currentEntries]
if (
fromIndex < 0 ||
fromIndex >= entries.length ||
toIndex < 0 ||
toIndex >= entries.length ||
fromIndex === toIndex
)
return
const [entry] = entries.splice(fromIndex, 1)
entries.splice(toIndex, 0, entry)
// Reordering does not change membership, so ref-counts remain valid.
promotions.set(subgraphNodeId, entries)
}
function clearGraph(graphId: UUID): void {
graphPromotions.value.delete(graphId)
graphRefCounts.value.delete(graphId)
}
return {
getPromotionsRef,
getPromotions,
isPromoted,
isPromotedByAny,
setPromotions,
promote,
demote,
movePromotion,
clearGraph
}
})

View File

@@ -2,6 +2,7 @@ import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it } from 'vitest'
import type { UUID } from '@/lib/litegraph/src/utils/uuid'
import type { WidgetState } from './widgetValueStore'
import { useWidgetValueStore } from './widgetValueStore'
@@ -18,6 +19,8 @@ function widget<T>(
}
describe('useWidgetValueStore', () => {
const graphA = 'graph-a' as UUID
const graphB = 'graph-b' as UUID
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
@@ -25,31 +28,37 @@ describe('useWidgetValueStore', () => {
describe('widgetState.value access', () => {
it('getWidget returns undefined for unregistered widget', () => {
const store = useWidgetValueStore()
expect(store.getWidget('missing', 'widget')).toBeUndefined()
expect(store.getWidget(graphA, 'missing', 'widget')).toBeUndefined()
})
it('widgetState.value can be read and written directly', () => {
const store = useWidgetValueStore()
const state = store.registerWidget(
graphA,
widget('node-1', 'seed', 'number', 100)
)
expect(state.value).toBe(100)
state.value = 200
expect(store.getWidget('node-1', 'seed')?.value).toBe(200)
expect(store.getWidget(graphA, 'node-1', 'seed')?.value).toBe(200)
})
it('stores different value types', () => {
const store = useWidgetValueStore()
store.registerWidget(widget('node-1', 'text', 'string', 'hello'))
store.registerWidget(widget('node-1', 'number', 'number', 42))
store.registerWidget(widget('node-1', 'boolean', 'toggle', true))
store.registerWidget(widget('node-1', 'array', 'combo', [1, 2, 3]))
store.registerWidget(graphA, widget('node-1', 'text', 'string', 'hello'))
store.registerWidget(graphA, widget('node-1', 'number', 'number', 42))
store.registerWidget(graphA, widget('node-1', 'boolean', 'toggle', true))
store.registerWidget(
graphA,
widget('node-1', 'array', 'combo', [1, 2, 3])
)
expect(store.getWidget('node-1', 'text')?.value).toBe('hello')
expect(store.getWidget('node-1', 'number')?.value).toBe(42)
expect(store.getWidget('node-1', 'boolean')?.value).toBe(true)
expect(store.getWidget('node-1', 'array')?.value).toEqual([1, 2, 3])
expect(store.getWidget(graphA, 'node-1', 'text')?.value).toBe('hello')
expect(store.getWidget(graphA, 'node-1', 'number')?.value).toBe(42)
expect(store.getWidget(graphA, 'node-1', 'boolean')?.value).toBe(true)
expect(store.getWidget(graphA, 'node-1', 'array')?.value).toEqual([
1, 2, 3
])
})
})
@@ -57,6 +66,7 @@ describe('useWidgetValueStore', () => {
it('registers a widget with minimal properties', () => {
const store = useWidgetValueStore()
const state = store.registerWidget(
graphA,
widget('node-1', 'seed', 'number', 12345)
)
@@ -65,7 +75,6 @@ describe('useWidgetValueStore', () => {
expect(state.type).toBe('number')
expect(state.value).toBe(12345)
expect(state.disabled).toBeUndefined()
expect(state.promoted).toBeUndefined()
expect(state.serialize).toBeUndefined()
expect(state.options).toEqual({})
})
@@ -73,10 +82,10 @@ describe('useWidgetValueStore', () => {
it('registers a widget with all properties', () => {
const store = useWidgetValueStore()
const state = store.registerWidget(
graphA,
widget('node-1', 'prompt', 'string', 'test', {
label: 'Prompt Text',
disabled: true,
promoted: true,
serialize: false,
options: { multiline: true }
})
@@ -84,7 +93,6 @@ describe('useWidgetValueStore', () => {
expect(state.label).toBe('Prompt Text')
expect(state.disabled).toBe(true)
expect(state.promoted).toBe(true)
expect(state.serialize).toBe(false)
expect(state.options).toEqual({ multiline: true })
})
@@ -93,9 +101,9 @@ describe('useWidgetValueStore', () => {
describe('widget getters', () => {
it('getWidget returns widget state', () => {
const store = useWidgetValueStore()
store.registerWidget(widget('node-1', 'seed', 'number', 100))
store.registerWidget(graphA, widget('node-1', 'seed', 'number', 100))
const state = store.getWidget('node-1', 'seed')
const state = store.getWidget(graphA, 'node-1', 'seed')
expect(state).toBeDefined()
expect(state?.name).toBe('seed')
expect(state?.value).toBe(100)
@@ -103,16 +111,16 @@ describe('useWidgetValueStore', () => {
it('getWidget returns undefined for missing widget', () => {
const store = useWidgetValueStore()
expect(store.getWidget('missing', 'widget')).toBeUndefined()
expect(store.getWidget(graphA, 'missing', 'widget')).toBeUndefined()
})
it('getNodeWidgets returns all widgets for a node', () => {
const store = useWidgetValueStore()
store.registerWidget(widget('node-1', 'seed', 'number', 1))
store.registerWidget(widget('node-1', 'steps', 'number', 20))
store.registerWidget(widget('node-2', 'cfg', 'number', 7))
store.registerWidget(graphA, widget('node-1', 'seed', 'number', 1))
store.registerWidget(graphA, widget('node-1', 'steps', 'number', 20))
store.registerWidget(graphA, widget('node-2', 'cfg', 'number', 7))
const widgets = store.getNodeWidgets('node-1')
const widgets = store.getNodeWidgets(graphA, 'node-1')
expect(widgets).toHaveLength(2)
expect(widgets.map((w) => w.name).sort()).toEqual(['seed', 'steps'])
})
@@ -122,34 +130,50 @@ describe('useWidgetValueStore', () => {
it('disabled can be set directly via getWidget', () => {
const store = useWidgetValueStore()
const state = store.registerWidget(
graphA,
widget('node-1', 'seed', 'number', 100)
)
state.disabled = true
expect(store.getWidget('node-1', 'seed')?.disabled).toBe(true)
})
it('promoted can be set directly via getWidget', () => {
const store = useWidgetValueStore()
const state = store.registerWidget(
widget('node-1', 'seed', 'number', 100)
)
state.promoted = true
expect(store.getWidget('node-1', 'seed')?.promoted).toBe(true)
expect(store.getWidget(graphA, 'node-1', 'seed')?.disabled).toBe(true)
})
it('label can be set directly via getWidget', () => {
const store = useWidgetValueStore()
const state = store.registerWidget(
graphA,
widget('node-1', 'seed', 'number', 100)
)
state.label = 'Random Seed'
expect(store.getWidget('node-1', 'seed')?.label).toBe('Random Seed')
expect(store.getWidget(graphA, 'node-1', 'seed')?.label).toBe(
'Random Seed'
)
state.label = undefined
expect(store.getWidget('node-1', 'seed')?.label).toBeUndefined()
expect(store.getWidget(graphA, 'node-1', 'seed')?.label).toBeUndefined()
})
})
describe('graph isolation', () => {
it('isolates widget states by graph', () => {
const store = useWidgetValueStore()
store.registerWidget(graphA, widget('node-1', 'seed', 'number', 1))
store.registerWidget(graphB, widget('node-1', 'seed', 'number', 2))
expect(store.getWidget(graphA, 'node-1', 'seed')?.value).toBe(1)
expect(store.getWidget(graphB, 'node-1', 'seed')?.value).toBe(2)
})
it('clearGraph only removes one graph namespace', () => {
const store = useWidgetValueStore()
store.registerWidget(graphA, widget('node-1', 'seed', 'number', 1))
store.registerWidget(graphB, widget('node-1', 'seed', 'number', 2))
store.clearGraph(graphA)
expect(store.getWidget(graphA, 'node-1', 'seed')).toBeUndefined()
expect(store.getWidget(graphB, 'node-1', 'seed')?.value).toBe(2)
})
})
})

View File

@@ -1,7 +1,8 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { reactive, ref } from 'vue'
import type { NodeId } from '@/lib/litegraph/src/LGraphNode'
import type { UUID } from '@/lib/litegraph/src/utils/uuid'
import type {
IBaseWidget,
IWidgetOptions
@@ -28,50 +29,61 @@ export interface WidgetState<
TOptions extends IWidgetOptions = IWidgetOptions
> extends Pick<
IBaseWidget<TValue, TType, TOptions>,
| 'name'
| 'type'
| 'value'
| 'options'
| 'label'
| 'serialize'
| 'disabled'
| 'promoted'
'name' | 'type' | 'value' | 'options' | 'label' | 'serialize' | 'disabled'
> {
nodeId: NodeId
}
export const useWidgetValueStore = defineStore('widgetValue', () => {
const widgetStates = ref(new Map<WidgetKey, WidgetState>())
const graphWidgetStates = ref(new Map<UUID, Map<WidgetKey, WidgetState>>())
function getWidgetStateMap(graphId: UUID): Map<WidgetKey, WidgetState> {
const widgetStates = graphWidgetStates.value.get(graphId)
if (widgetStates) return widgetStates
const nextWidgetStates = reactive(new Map<WidgetKey, WidgetState>())
graphWidgetStates.value.set(graphId, nextWidgetStates)
return nextWidgetStates
}
function makeKey(nodeId: NodeId, widgetName: string): WidgetKey {
return `${nodeId}:${widgetName}`
}
function registerWidget<TValue = unknown>(
graphId: UUID,
state: WidgetState<TValue>
): WidgetState<TValue> {
const widgetStates = getWidgetStateMap(graphId)
const key = makeKey(state.nodeId, state.name)
widgetStates.value.set(key, state)
return widgetStates.value.get(key) as WidgetState<TValue>
widgetStates.set(key, state)
return widgetStates.get(key) as WidgetState<TValue>
}
function getNodeWidgets(nodeId: NodeId): WidgetState[] {
function getNodeWidgets(graphId: UUID, nodeId: NodeId): WidgetState[] {
const widgetStates = getWidgetStateMap(graphId)
const prefix = `${nodeId}:`
return [...widgetStates.value]
return [...widgetStates]
.filter(([key]) => key.startsWith(prefix))
.map(([, state]) => state)
}
function getWidget(
graphId: UUID,
nodeId: NodeId,
widgetName: string
): WidgetState | undefined {
return widgetStates.value.get(makeKey(nodeId, widgetName))
return getWidgetStateMap(graphId).get(makeKey(nodeId, widgetName))
}
function clearGraph(graphId: UUID): void {
graphWidgetStates.value.delete(graphId)
}
return {
registerWidget,
getWidget,
getNodeWidgets
getNodeWidgets,
clearGraph
}
})