mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-03-13 09:00:16 +00:00
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:
@@ -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
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
310
src/stores/promotionStore.test.ts
Normal file
310
src/stores/promotionStore.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
186
src/stores/promotionStore.ts
Normal file
186
src/stores/promotionStore.ts
Normal 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
|
||||
}
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user