refactor(widget): Phase 3 - integrate widget types into stores

- widgetValueStore uses WidgetId for Map keys, getWidgetId() for generation

- widgetValueStore.getWidget() supports (nodeId, name) and WidgetIdentity

- WidgetState extends WidgetRuntimeState with MutableWidgetIdentity

- NodeId imports from LiteGraph for compatibility (number | string)

- Added MutableWidgetIdentity for stores with late binding

- domWidgetStore keeps UUID keys (different identity system)

- favoritedWidgetsStore unchanged (uses nodeLocatorId)

Amp-Thread-ID: https://ampcode.com/threads/T-019c2eff-f52d-71b9-975c-5a4f7de2ac5f
Co-authored-by: Amp <amp@ampcode.com>
This commit is contained in:
Alexander Brown
2026-02-05 10:21:21 -08:00
parent 9f711be746
commit 8c049e0496
7 changed files with 133 additions and 92 deletions

View File

@@ -1,5 +1,9 @@
/**
* Stores all DOM widgets that are used in the canvas.
*
* Note: This store uses UUID strings as keys (from BaseDOMWidget.id),
* not WidgetId (`nodeId:name`). DOM widgets have their own identity
* system separate from the canonical widget types.
*/
import { defineStore } from 'pinia'
import { computed, markRaw, ref } from 'vue'
@@ -9,7 +13,6 @@ import type { PositionConfig } from '@/composables/element/useAbsolutePosition'
import type { BaseDOMWidget } from '@/scripts/domWidget'
export interface DomWidgetState extends PositionConfig {
// Raw widget instance
widget: Raw<BaseDOMWidget<object | string>>
visible: boolean
readonly: boolean

View File

@@ -2,11 +2,13 @@ import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it } from 'vitest'
import type { NodeId } from '@/types/widget'
import type { WidgetState } from './widgetValueStore'
import { useWidgetValueStore } from './widgetValueStore'
function widget<T>(
nodeId: string,
nodeId: NodeId,
name: string,
type: string,
value: T,
@@ -14,7 +16,16 @@ function widget<T>(
Omit<WidgetState<T>, 'nodeId' | 'name' | 'type' | 'value'>
> = {}
): WidgetState<T> {
return { nodeId, name, type, value, options: {}, ...extra }
return {
nodeId,
name,
type,
value,
options: {},
hidden: false,
disabled: false,
...extra
}
}
describe('useWidgetValueStore', () => {
@@ -25,47 +36,43 @@ 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(999, 'widget')).toBeUndefined()
})
it('widgetState.value can be read and written directly', () => {
const store = useWidgetValueStore()
const state = store.registerWidget(
widget('node-1', 'seed', 'number', 100)
)
const state = store.registerWidget(widget(1, 'seed', 'number', 100))
expect(state.value).toBe(100)
state.value = 200
expect(store.getWidget('node-1', 'seed')?.value).toBe(200)
expect(store.getWidget(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(widget(1, 'text', 'string', 'hello'))
store.registerWidget(widget(1, 'number', 'number', 42))
store.registerWidget(widget(1, 'boolean', 'toggle', true))
store.registerWidget(widget(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(1, 'text')?.value).toBe('hello')
expect(store.getWidget(1, 'number')?.value).toBe(42)
expect(store.getWidget(1, 'boolean')?.value).toBe(true)
expect(store.getWidget(1, 'array')?.value).toEqual([1, 2, 3])
})
})
describe('widget registration', () => {
it('registers a widget with minimal properties', () => {
const store = useWidgetValueStore()
const state = store.registerWidget(
widget('node-1', 'seed', 'number', 12345)
)
const state = store.registerWidget(widget(1, 'seed', 'number', 12345))
expect(state.nodeId).toBe('node-1')
expect(state.nodeId).toBe(1)
expect(state.name).toBe('seed')
expect(state.type).toBe('number')
expect(state.value).toBe(12345)
expect(state.hidden).toBeUndefined()
expect(state.disabled).toBeUndefined()
expect(state.hidden).toBe(false)
expect(state.disabled).toBe(false)
expect(state.advanced).toBeUndefined()
expect(state.promoted).toBeUndefined()
expect(state.serialize).toBeUndefined()
@@ -75,7 +82,7 @@ describe('useWidgetValueStore', () => {
it('registers a widget with all properties', () => {
const store = useWidgetValueStore()
const state = store.registerWidget(
widget('node-1', 'prompt', 'string', 'test', {
widget(1, 'prompt', 'string', 'test', {
label: 'Prompt Text',
hidden: true,
disabled: true,
@@ -99,26 +106,35 @@ describe('useWidgetValueStore', () => {
describe('widget getters', () => {
it('getWidget returns widget state', () => {
const store = useWidgetValueStore()
store.registerWidget(widget('node-1', 'seed', 'number', 100))
store.registerWidget(widget(1, 'seed', 'number', 100))
const state = store.getWidget('node-1', 'seed')
const state = store.getWidget(1, 'seed')
expect(state).toBeDefined()
expect(state?.name).toBe('seed')
expect(state?.value).toBe(100)
})
it('getWidget accepts WidgetIdentity', () => {
const store = useWidgetValueStore()
store.registerWidget(widget(1, 'seed', 'number', 100))
const state = store.getWidget({ nodeId: 1, name: 'seed' })
expect(state).toBeDefined()
expect(state?.value).toBe(100)
})
it('getWidget returns undefined for missing widget', () => {
const store = useWidgetValueStore()
expect(store.getWidget('missing', 'widget')).toBeUndefined()
expect(store.getWidget(999, '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(widget(1, 'seed', 'number', 1))
store.registerWidget(widget(1, 'steps', 'number', 20))
store.registerWidget(widget(2, 'cfg', 'number', 7))
const widgets = store.getNodeWidgets('node-1')
const widgets = store.getNodeWidgets(1)
expect(widgets).toHaveLength(2)
expect(widgets.map((w) => w.name).sort()).toEqual(['seed', 'steps'])
})
@@ -127,58 +143,48 @@ describe('useWidgetValueStore', () => {
describe('direct property mutation', () => {
it('hidden can be set directly via getWidget', () => {
const store = useWidgetValueStore()
const state = store.registerWidget(
widget('node-1', 'seed', 'number', 100)
)
const state = store.registerWidget(widget(1, 'seed', 'number', 100))
state.hidden = true
expect(store.getWidget('node-1', 'seed')?.hidden).toBe(true)
expect(store.getWidget(1, 'seed')?.hidden).toBe(true)
state.hidden = false
expect(store.getWidget('node-1', 'seed')?.hidden).toBe(false)
expect(store.getWidget(1, 'seed')?.hidden).toBe(false)
})
it('disabled can be set directly via getWidget', () => {
const store = useWidgetValueStore()
const state = store.registerWidget(
widget('node-1', 'seed', 'number', 100)
)
const state = store.registerWidget(widget(1, 'seed', 'number', 100))
state.disabled = true
expect(store.getWidget('node-1', 'seed')?.disabled).toBe(true)
expect(store.getWidget(1, 'seed')?.disabled).toBe(true)
})
it('advanced can be set directly via getWidget', () => {
const store = useWidgetValueStore()
const state = store.registerWidget(
widget('node-1', 'seed', 'number', 100)
)
const state = store.registerWidget(widget(1, 'seed', 'number', 100))
state.advanced = true
expect(store.getWidget('node-1', 'seed')?.advanced).toBe(true)
expect(store.getWidget(1, 'seed')?.advanced).toBe(true)
})
it('promoted can be set directly via getWidget', () => {
const store = useWidgetValueStore()
const state = store.registerWidget(
widget('node-1', 'seed', 'number', 100)
)
const state = store.registerWidget(widget(1, 'seed', 'number', 100))
state.promoted = true
expect(store.getWidget('node-1', 'seed')?.promoted).toBe(true)
expect(store.getWidget(1, 'seed')?.promoted).toBe(true)
})
it('label can be set directly via getWidget', () => {
const store = useWidgetValueStore()
const state = store.registerWidget(
widget('node-1', 'seed', 'number', 100)
)
const state = store.registerWidget(widget(1, 'seed', 'number', 100))
state.label = 'Random Seed'
expect(store.getWidget('node-1', 'seed')?.label).toBe('Random Seed')
expect(store.getWidget(1, 'seed')?.label).toBe('Random Seed')
state.label = undefined
expect(store.getWidget('node-1', 'seed')?.label).toBeUndefined()
expect(store.getWidget(1, 'seed')?.label).toBeUndefined()
})
})
})

View File

@@ -1,58 +1,51 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import type { NodeId } from '@/lib/litegraph/src/LGraphNode'
import type { IWidgetOptions } from '@/lib/litegraph/src/types/widgets'
import type {
IBaseWidget,
IWidgetOptions
} from '@/lib/litegraph/src/types/widgets'
/**
* Widget state is keyed by `nodeId:widgetName` without graph context.
* This is intentional: nodes viewed at different subgraph depths share
* the same widget state, enabling synchronized values across the hierarchy.
*/
type WidgetKey = `${NodeId}:${string}`
NodeId,
WidgetId,
WidgetIdentity,
WidgetRuntimeState
} from '@/types/widget'
import { getWidgetId } from '@/types/widget'
/**
* Strips graph/subgraph prefixes from a scoped node ID to get the bare node ID.
* e.g., "graph1:subgraph2:42" → "42"
*/
export function stripGraphPrefix(scopedId: NodeId | string): NodeId {
return String(scopedId).replace(/^(.*:)+/, '') as NodeId
const stripped = String(scopedId).replace(/^(.*:)+/, '')
const num = Number(stripped)
return Number.isNaN(num) ? stripped : num
}
/**
* Extended widget state for the store, including type info and options.
* Extends WidgetRuntimeState with additional fields needed for serialization.
*/
export interface WidgetState<
TValue = unknown,
TType extends string = string,
TOptions extends IWidgetOptions<unknown> = IWidgetOptions<unknown>
> extends Pick<
IBaseWidget<TValue, TType, TOptions>,
| 'name'
| 'type'
| 'value'
| 'options'
| 'label'
| 'serialize'
| 'disabled'
| 'hidden'
| 'advanced'
| 'promoted'
> {
nodeId: NodeId
> extends WidgetRuntimeState {
value: TValue
type?: string
options?: TOptions
serialize?: boolean
}
export const useWidgetValueStore = defineStore('widgetValue', () => {
const widgetStates = ref(new Map<WidgetKey, WidgetState>())
function makeKey(nodeId: NodeId, widgetName: string): WidgetKey {
return `${nodeId}:${widgetName}`
}
/**
* Widget state is keyed by WidgetId (`nodeId:widgetName`) without graph context.
* This is intentional: nodes viewed at different subgraph depths share
* the same widget state, enabling synchronized values across the hierarchy.
*/
const widgetStates = ref(new Map<WidgetId, WidgetState>())
function registerWidget<TValue = unknown>(
state: WidgetState<TValue>
): WidgetState<TValue> {
const key = makeKey(state.nodeId, state.name)
const key = getWidgetId(state)
widgetStates.value.set(key, state)
return widgetStates.value.get(key) as WidgetState<TValue>
}
@@ -64,11 +57,21 @@ export const useWidgetValueStore = defineStore('widgetValue', () => {
.map(([, state]) => state)
}
function getWidget(identity: WidgetIdentity): WidgetState | undefined
function getWidget(
nodeId: NodeId,
widgetName: string
): WidgetState | undefined
function getWidget(
nodeIdOrIdentity: NodeId | WidgetIdentity,
widgetName?: string
): WidgetState | undefined {
return widgetStates.value.get(makeKey(nodeId, widgetName))
if (typeof nodeIdOrIdentity === 'object') {
return widgetStates.value.get(getWidgetId(nodeIdOrIdentity))
}
return widgetStates.value.get(
getWidgetId({ nodeId: nodeIdOrIdentity, name: widgetName! })
)
}
return {

View File

@@ -7,11 +7,23 @@
import type { NodeId, WidgetId } from './primitives'
import { widgetId } from './primitives'
/**
* Immutable widget identity for public APIs and type safety.
*/
export interface WidgetIdentity {
readonly nodeId: NodeId
readonly name: string
}
/**
* Mutable widget identity for internal state management.
* Used by stores that need to update identity after construction.
*/
export interface MutableWidgetIdentity {
nodeId: NodeId
name: string
}
export function getWidgetId(widget: WidgetIdentity): WidgetId {
return widgetId(widget.nodeId, widget.name)
}

View File

@@ -7,7 +7,7 @@
export type { NodeId, WidgetId } from './primitives'
export { widgetId } from './primitives'
export type { WidgetIdentity } from './identity'
export type { MutableWidgetIdentity, WidgetIdentity } from './identity'
export { getWidgetId } from './identity'
export type {

View File

@@ -4,10 +4,15 @@
* @module widget/primitives
*/
export type NodeId = number
import type { NodeId as LiteGraphNodeId } from '@/lib/litegraph/src/LGraphNode'
/**
* NodeId matches LiteGraph's type for compatibility with the graph system.
*/
export type NodeId = LiteGraphNodeId
export type WidgetId = `${NodeId}:${string}`
export function widgetId(nodeId: NodeId, name: string): WidgetId {
return `${nodeId}:${name}`
return `${nodeId}:${name}` as WidgetId
}

View File

@@ -4,9 +4,13 @@
* @module widget/state
*/
import type { WidgetIdentity } from './identity'
import type { MutableWidgetIdentity, WidgetIdentity } from './identity'
export interface WidgetRuntimeState extends WidgetIdentity {
/**
* Runtime state for widgets, with mutable identity for late binding.
* Use this in stores where nodeId may be set after construction.
*/
export interface WidgetRuntimeState extends MutableWidgetIdentity {
value: unknown
hidden: boolean
disabled: boolean
@@ -15,11 +19,19 @@ export interface WidgetRuntimeState extends WidgetIdentity {
promoted?: boolean
}
/**
* Layout state for DOM widgets positioned on the canvas.
*
* Note: Currently not used by domWidgetStore, which uses its own DomWidgetState
* interface that extends PositionConfig (pos/size arrays) plus BaseDOMWidget ref.
* This interface is provided for future use or alternative implementations.
*/
export interface WidgetLayoutState extends WidgetIdentity {
visible: boolean
x: number
y: number
w: number
h: number
hideOnZoom?: boolean
zIndex: number
active: boolean
}