Compare commits

...

32 Commits

Author SHA1 Message Date
Alexander Brown
af905fc5b4 refactor: add kind field to LiteGraph widget types
Phase 5 of widget type consolidation:

- Add optional `kind` field to IBaseWidget and all widget interfaces
- BaseWidget class derives kind from type via legacyTypeToKind adapter
- All 20 widget subclasses override kind getter with literal types
- TextWidget has conditional logic: 'text'→'textarea', 'string'→'string'
- kind is optional for backward compatibility during migration

This enables gradual adoption of the new WidgetKind discriminated union
while maintaining compatibility with existing widget code.

Amp-Thread-ID: https://ampcode.com/threads/T-019c2f1d-026f-7542-bd7f-5f9c0205637b
Co-authored-by: Amp <amp@ampcode.com>
2026-02-05 15:32:29 -08:00
Alexander Brown
5f6f1079ce refactor: replace fake widgets with SlotWidgetRef
Add SlotWidgetRef type and createSlotWidgetRef/createMutableSlotWidgetRef helpers

- widgetInputs.ts: Use createSlotWidgetRef for primitive node connection

- dynamicWidgets.ts: Use createSlotWidgetRef for shim widgets

- rerouteNode.ts: Use createSlotWidgetRef for reroute widget config

- SubgraphNode.ts: Use createMutableSlotWidgetRef for prototype chaining

- Add 5 new tests for SlotWidgetRef functions

Amp-Thread-ID: https://ampcode.com/threads/T-019c2f0a-a61b-757c-b161-aa0416ef4271
Co-authored-by: Amp <amp@ampcode.com>
2026-02-05 15:32:28 -08:00
Alexander Brown
8c049e0496 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>
2026-02-05 15:32:27 -08:00
Alexander Brown
9f711be746 feat: add widget type adapters for LiteGraph interop
Phase 2 of widget consolidation:

- fromLiteGraphWidget(): converts IWidget to WidgetModel

- toLiteGraphWidget(): converts WidgetModel back to IWidget partial

- legacyTypeToKind/kindToLegacyType: type string mappings

- toWidgetIdentity(): adapter for fake widgets

- 26 tests including round-trip preservation

Amp-Thread-ID: https://ampcode.com/threads/T-019c2d33-6fcb-73eb-ba51-ffca2d0f5957
Co-authored-by: Amp <amp@ampcode.com>
2026-02-05 15:32:26 -08:00
Alexander Brown
7942c52784 feat: add canonical widget type definitions
- Create src/types/widget/ with discriminated union architecture
- Define WidgetModel as union of 22 widget kinds
- Derive WidgetKind, WidgetValueFor, WidgetOptionsFor utility types
- Add widgetId() and getWidgetId() pure functions
- Define WidgetRuntimeState and WidgetLayoutState for stores
- Add unit tests for type utilities and narrowing

Part of widget type consolidation Phase 1.

Amp-Thread-ID: https://ampcode.com/threads/T-019c2d2f-bea3-72cf-a948-23334d1aff3d
Co-authored-by: Amp <amp@ampcode.com>
2026-02-05 15:32:25 -08:00
github-actions
e26261ce9a [automated] Update test expectations 2026-02-05 22:47:41 +00:00
Alexander Brown
395940c180 Enforce calling the setter to keep the store synced as the source of truth. 2026-02-05 14:29:57 -08:00
Alexander Brown
88eb8644af refactor: remove unused widgetValueStore methods
Remove unregistration and filtering methods that are not currently used:
- unregisterWidget, unregisterNode
- getVisibleWidgets, getAdvancedWidgets, getPromotedWidgets
- widgetStates (no longer exported)

Amp-Thread-ID: https://ampcode.com/threads/T-019c2cf2-feaa-75e8-a16f-dae7f38ada8b
Co-authored-by: Amp <amp@ampcode.com>
2026-02-05 14:29:55 -08:00
Alexander Brown
2288c259d0 fix: use correct widget name for proxy widget matching
- Extract getWidgetName helper to properly resolve widget names
- For proxy widgets, use _overlay.widgetName instead of the proxy name
- Fix widget filtering in onConfigure to compare correctly
- Remove duplicate useWidgetValueStore().unregisterNode call from LGraph

Amp-Thread-ID: https://ampcode.com/threads/T-019c2ce0-df46-72df-adf8-74703d3f019c
Co-authored-by: Amp <amp@ampcode.com>
2026-02-05 14:29:53 -08:00
Alexander Brown
ffac1c1874 test: replace as any with proper BaseWidget type assertions
Amp-Thread-ID: https://ampcode.com/threads/T-019c2cbe-9402-7506-9e87-4cb01d1d1c88
Co-authored-by: Amp <amp@ampcode.com>
2026-02-05 14:29:51 -08:00
Alexander Brown
c89ec31282 refactor: use store-backed widget.hidden/advanced instead of legacy patterns
- groupNode.ts: widget.type = 'hidden' -> widget.hidden = true
- domWidget.ts: remove type === 'hidden' check, use this.hidden
- shared.ts: options.hidden/advanced -> hidden/advanced
- TabNormalInputs.vue: options.hidden/advanced -> hidden/advanced

All consumers now use WidgetValueStore-backed properties.

Amp-Thread-ID: https://ampcode.com/threads/T-019c2bfd-1372-72cc-a856-18008b2bcdd6
Co-authored-by: Amp <amp@ampcode.com>
2026-02-05 14:29:50 -08:00
Alexander Brown
644295caa4 fix: clean up widget state when nodes are removed
- Add unregisterNode to widgetValueStore to remove all widget state for a node

- Call unregisterNode from LGraph when nodes are removed

- Extract stripGraphPrefix helper for consistent graph prefix handling

- Add TODO comments for future consolidation of DOM widget helpers

Amp-Thread-ID: https://ampcode.com/threads/T-019c2bf1-e007-75ac-81be-bec7dcb641a8
Co-authored-by: Amp <amp@ampcode.com>
2026-02-05 14:29:47 -08:00
Alexander Brown
6f06ce7b2d Keep the widget array itself reactive, Add proxy support (to be cleaned up soon) 2026-02-05 14:29:45 -08:00
Alexander Brown
8473ecdc74 Re-bind the state to the reactive object proxy. 2026-02-05 14:29:44 -08:00
Alexander Brown
627ca0b3f9 YAGNI: useWidget 2026-02-05 14:29:43 -08:00
Alexander Brown
ea26a16bd2 refactor: consolidate BaseWidget fields with WidgetState
Replace duplicative private fields (_label, _hidden, _disabled, _advanced, _promoted, _internalValue) with a single _state: WidgetState object

Simplifies getter/setter pairs to direct property access

Amp-Thread-ID: https://ampcode.com/threads/T-019c2a35-b5b2-73bb-93aa-dae51ba31a6d
Co-authored-by: Amp <amp@ampcode.com>
2026-02-05 14:29:41 -08:00
Alexander Brown
a9b0def041 refactor: remove redundant setters from widgetValueStore
- Remove setHidden, setDisabled, setAdvanced, setPromoted, setLabel

- Callers now mutate WidgetState properties directly via getWidget()

- Update BaseWidget.ts and tests to use direct property assignment

- Refactor getNodeWidgets and unregisterNode to use filter/map

Amp-Thread-ID: https://ampcode.com/threads/T-019c2a2c-2990-738c-ab34-ad186f723ee4
Co-authored-by: Amp <amp@ampcode.com>
2026-02-05 14:29:40 -08:00
Alexander Brown
687c77f033 refactor: derive WidgetState from IBaseWidget via Pick
- WidgetState now extends Pick<IBaseWidget, ...> with generics

- Simplified registerWidget to take a single WidgetState argument

- Updated tests to use helper function for creating widget states

Amp-Thread-ID: https://ampcode.com/threads/T-019c2a18-b910-7429-bb2e-7d9aa0009540
Co-authored-by: Amp <amp@ampcode.com>
2026-02-05 14:29:38 -08:00
Alexander Brown
4ac436c5ef fix: sync DOM widget values with WidgetValueStore
DOM widgets were bypassing value/state management in BaseWidget.

- useStringWidget: read from store first, sync setValue to store

- useMarkdownWidget: same pattern applied

- uploadAudio: replace closure variable with store integration

Amp-Thread-ID: https://ampcode.com/threads/T-019c2a03-f6b7-70cc-9ccb-7e534dbbbd54
Co-authored-by: Amp <amp@ampcode.com>
2026-02-05 14:29:36 -08:00
Alexander Brown
6e3d6f6c97 Remove redundant state from Widget store. 2026-02-05 14:29:35 -08:00
Alexander Brown
616b2d68e0 Knip fix 2026-02-05 14:29:33 -08:00
Alexander Brown
ee8500d2d1 refactor: simplify SafeWidgetData by leveraging widgetValueStore
- Remove value field from SafeWidgetData (accessed via store)

- Remove label, borderStyle fields (derived from store metadata)

- Keep minimal options with render-critical flags only

- NodeWidgets.vue now reads value/metadata from widgetValueStore

- NodeHeader.vue uses store for pricing dependency tracking

Amp-Thread-ID: https://ampcode.com/threads/T-019c2643-f938-774e-9bdd-cc753d39aacd
Co-authored-by: Amp <amp@ampcode.com>
2026-02-05 14:29:32 -08:00
Alexander Brown
bb13bec8d6 refactor(litegraph): add widget filtering helper methods
- Add getLayoutWidgets() to LGraphNode for layout calculations
- Add hasAdvancedWidgets() to LGraphNode for advanced widget checks
- Update _arrangeWidgets() to use getLayoutWidgets()
- Update toggleAdvanced() and context menu to use hasAdvancedWidgets()

Part of Phase 2 WidgetValueStore expansion (Step 5).

Amp-Thread-ID: https://ampcode.com/threads/T-019c263e-978e-7324-b320-57e15bf6065c
Co-authored-by: Amp <amp@ampcode.com>
2026-02-05 14:29:30 -08:00
Alexander Brown
4a491c5734 feat: add useWidget composable and WidgetItemByKey component
- useWidget composable at src/composables/graph/useWidget.ts

- Returns reactive widget, value, isHidden, isDisabled, isAdvanced, isPromoted, label

- WidgetItemByKey.vue wrapper at src/components/rightSidePanel/parameters/

- 7 new tests for useWidget composable

Amp-Thread-ID: https://ampcode.com/threads/T-019c2639-ebe0-7088-858d-853102e1873c
Co-authored-by: Amp <amp@ampcode.com>
2026-02-05 14:29:29 -08:00
Alexander Brown
f9db91dac5 feat: implement automatic widget registration in setNodeId()
- setNodeId() now calls registerWidget() with all widget metadata

- Updated BaseWidget tests with 3 new tests for automatic registration

- Fixed IWidgetOptions type to use unknown instead of unknown[]

Amp-Thread-ID: https://ampcode.com/threads/T-019c2631-966c-73a0-a399-7fe85bd6d495
Co-authored-by: Amp <amp@ampcode.com>
2026-02-05 14:29:27 -08:00
Alexander Brown
36387faaa8 feat: integrate BaseWidget metadata properties with widgetValueStore
- Convert label/hidden/disabled/advanced/promoted to getter/setter pairs

- Getters read from store when registered, fallback to internal value

- Setters update both internal field and store

- Add BaseWidget.test.ts with 7 tests for store integration

Amp-Thread-ID: https://ampcode.com/threads/T-019c262b-bed5-714e-bfc8-c4d6949e2fed
Co-authored-by: Amp <amp@ampcode.com>
2026-02-05 14:29:26 -08:00
Alexander Brown
3988c4f820 feat: expand widgetValueStore with full widget state management
Add WidgetState interface and widgetStates Map alongside existing values
Map to support full widget lifecycle management:

- WidgetState: nodeId, name, type, value, label, hidden, disabled,
  advanced, promoted, options, serialize
- Registration: registerWidget(), unregisterWidget(), unregisterNode()
- Getters: getWidget(), getNodeWidgets(), getVisibleWidgets(),
  getAdvancedWidgets(), getPromotedWidgets()
- Setters: setHidden(), setDisabled(), setAdvanced(), setPromoted(),
  setLabel()

Existing get/set/remove/removeNode API preserved for backward compat.
set() now syncs value to WidgetState when widget is registered.

Amp-Thread-ID: https://ampcode.com/threads/T-019c2626-df19-75cf-b9c9-11fe9735083d
Co-authored-by: Amp <amp@ampcode.com>
2026-02-05 14:29:24 -08:00
Alexander Brown
b1e066ebf6 feat: integrate WidgetValueStore with BaseWidget for centralized reactivity
- BaseWidget.value getter/setter now delegates to WidgetValueStore when nodeId is set
- Widget registration deferred until node is added to graph (to get valid ID)
- LGraph.add() registers widgets with store after assigning node ID
- Removed vueTrack pattern from useGraphNodeManager - store provides reactivity
- Simplified useReactiveWidgetValue to return widget.value directly

This enables reactive widget values across Vue components without custom
tracking infrastructure. Extensions continue to work unchanged via the
preserved widget.value API.

Amp-Thread-ID: https://ampcode.com/threads/T-019c25f1-e920-70bb-afaf-59366bdab667
Co-authored-by: Amp <amp@ampcode.com>
2026-02-05 14:29:23 -08:00
Alexander Brown
61403ea426 test: add Pinia setup to proxyWidget tests for WidgetValueStore
Amp-Thread-ID: https://ampcode.com/threads/T-019c2570-5dc2-7518-b20b-2b87f14bb2f3
Co-authored-by: Amp <amp@ampcode.com>
2026-02-05 14:29:21 -08:00
Alexander Brown
85c6bd132c feat: LGraphNode.addCustomWidget wires widget to node ID
Amp-Thread-ID: https://ampcode.com/threads/T-019c2567-2a52-7010-a85e-79eb76be24a1
Co-authored-by: Amp <amp@ampcode.com>
2026-02-05 14:29:19 -08:00
Alexander Brown
fcd83d84a3 feat: integrate BaseWidget with WidgetValueStore
- Add _nodeId field and setNodeId() method

- Delegate value getter/setter to store when node ID set

- Seed store with current value on setNodeId() call

Amp-Thread-ID: https://ampcode.com/threads/T-019c2559-977f-77a2-9633-d8fed5b5a2ad
Co-authored-by: Amp <amp@ampcode.com>
2026-02-05 14:29:16 -08:00
Alexander Brown
80a5108269 feat: add WidgetValueStore for centralized widget values
Amp-Thread-ID: https://ampcode.com/threads/T-019c2554-9721-705e-82f9-7742306a49cc
Co-authored-by: Amp <amp@ampcode.com>
2026-02-05 14:29:13 -08:00
68 changed files with 2546 additions and 176 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 80 KiB

After

Width:  |  Height:  |  Size: 80 KiB

View File

@@ -33,10 +33,7 @@ const advancedWidgetsSectionDataList = computed((): NodeWidgetsListList => {
.map((node) => {
const { widgets = [] } = node
const advancedWidgets = widgets
.filter(
(w) =>
!(w.options?.canvasOnly || w.options?.hidden) && w.options?.advanced
)
.filter((w) => !(w.options?.canvasOnly || w.hidden) && w.advanced)
.map((widget) => ({ node, widget }))
return { widgets: advancedWidgets, node }
})

View File

@@ -82,10 +82,7 @@ const favoriteNode = computed(() =>
)
const widgetValue = computed({
get: () => {
widget.vueTrack?.()
return widget.value
},
get: () => widget.value,
set: (newValue: string | number | boolean | object) => {
emit('update:widgetValue', newValue)
}

View File

@@ -265,8 +265,8 @@ export function computedSectionDataList(nodes: MaybeRefOrGetter<LGraphNode[]>) {
(w) =>
!(
w.options?.canvasOnly ||
w.options?.hidden ||
(w.options?.advanced && !includesAdvanced.value)
w.hidden ||
(w.advanced && !includesAdvanced.value)
)
)
.map((widget) => ({ node, widget }))

View File

@@ -1,49 +1,76 @@
import { setActivePinia } from 'pinia'
import { createTestingPinia } from '@pinia/testing'
import { describe, expect, it, vi } from 'vitest'
import { nextTick, watch } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { computed, nextTick, watch } from 'vue'
import { useGraphNodeManager } from '@/composables/graph/useGraphNodeManager'
import { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { BaseWidget, LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { NodeSlotType } from '@/lib/litegraph/src/types/globalEnums'
setActivePinia(createTestingPinia())
function createTestGraph() {
const graph = new LGraph()
const node = new LGraphNode('test')
node.addInput('input', 'INT')
node.addWidget('number', 'testnum', 2, () => undefined, {})
graph.add(node)
const { vueNodeData } = useGraphNodeManager(graph)
const onReactivityUpdate = vi.fn()
watch(vueNodeData, onReactivityUpdate)
return [node, graph, onReactivityUpdate] as const
}
import { useWidgetValueStore } from '@/stores/widgetValueStore'
describe('Node Reactivity', () => {
it('should trigger on callback', async () => {
const [node, , onReactivityUpdate] = createTestGraph()
node.widgets![0].callback!(2)
await nextTick()
expect(onReactivityUpdate).toHaveBeenCalledTimes(1)
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
it('should remain reactive after a connection is made', async () => {
const [node, graph, onReactivityUpdate] = createTestGraph()
function createTestGraph() {
const graph = new LGraph()
const node = new LGraphNode('test')
node.addInput('input', 'INT')
node.addWidget('number', 'testnum', 2, () => undefined, {})
graph.add(node)
const { vueNodeData } = useGraphNodeManager(graph)
return { node, graph, vueNodeData }
}
it('widget values are reactive through the store', async () => {
const { node } = createTestGraph()
const store = useWidgetValueStore()
const widget = node.widgets![0]
// Verify widget is a BaseWidget with correct value and node assignment
expect(widget).toBeInstanceOf(BaseWidget)
expect(widget.value).toBe(2)
expect((widget as BaseWidget).node.id).toBe(node.id)
// Initial value should be in store after setNodeId was called
expect(store.getWidget(node.id, 'testnum')?.value).toBe(2)
const onValueChange = vi.fn()
const widgetValue = computed(
() => store.getWidget(node.id, 'testnum')?.value
)
watch(widgetValue, onValueChange)
widget.value = 42
await nextTick()
expect(widgetValue.value).toBe(42)
expect(onValueChange).toHaveBeenCalledTimes(1)
})
it('widget values remain reactive after a connection is made', async () => {
const { node, graph } = createTestGraph()
const store = useWidgetValueStore()
const onValueChange = vi.fn()
graph.trigger('node:slot-links:changed', {
nodeId: '1',
nodeId: String(node.id),
slotType: NodeSlotType.INPUT
})
await nextTick()
onReactivityUpdate.mockClear()
node.widgets![0].callback!(2)
const widgetValue = computed(
() => store.getWidget(node.id, 'testnum')?.value
)
watch(widgetValue, onValueChange)
node.widgets![0].value = 99
await nextTick()
expect(onReactivityUpdate).toHaveBeenCalledTimes(1)
expect(onValueChange).toHaveBeenCalledTimes(1)
expect(widgetValue.value).toBe(99)
})
})

View File

@@ -3,7 +3,7 @@
* Provides event-driven reactivity with performance optimizations
*/
import { reactiveComputed } from '@vueuse/core'
import { customRef, reactive, shallowReactive } from 'vue'
import { reactive, shallowReactive } from 'vue'
import { useChainCallback } from '@/composables/functional/useChainCallback'
import { isProxyWidget } from '@/core/graph/subgraph/proxyWidget'
@@ -11,10 +11,7 @@ import type {
INodeInputSlot,
INodeOutputSlot
} from '@/lib/litegraph/src/interfaces'
import type {
IBaseWidget,
IWidgetOptions
} from '@/lib/litegraph/src/types/widgets'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
import { LayoutSource } from '@/renderer/core/layout/types'
import type { NodeId } from '@/renderer/core/layout/types'
@@ -41,19 +38,32 @@ export interface WidgetSlotMetadata {
linked: boolean
}
/**
* Minimal render-specific widget data extracted from LiteGraph widgets.
* Value and metadata (label, hidden, disabled, etc.) are accessed via widgetValueStore.
*/
export interface SafeWidgetData {
nodeId?: NodeId
name: string
type: string
value: WidgetValue
borderStyle?: string
/** Callback to invoke when widget value changes (wraps LiteGraph callback + triggerDraw) */
callback?: ((value: unknown) => void) | undefined
/** Control widget for seed randomization/increment/decrement */
controlWidget?: SafeControlWidget
/** Whether widget has custom layout size computation */
hasLayoutSize?: boolean
/** Whether widget is a DOM widget */
isDOMWidget?: boolean
label?: string
/** Node type (for subgraph promoted widgets) */
nodeType?: string
options?: IWidgetOptions<unknown>
/**
* Widget options needed for render decisions.
* Note: Most metadata should be accessed via widgetValueStore.getWidget().
*/
options?: { canvasOnly?: boolean; advanced?: boolean; read_only?: boolean }
/** Input specification from node definition */
spec?: InputSpec
/** Input slot metadata (index and link status) */
slotMetadata?: WidgetSlotMetadata
}
@@ -94,23 +104,6 @@ export interface GraphNodeManager {
cleanup(): void
}
function widgetWithVueTrack(
widget: IBaseWidget
): asserts widget is IBaseWidget & { vueTrack: () => void } {
if (widget.vueTrack) return
customRef((track, trigger) => {
widget.callback = useChainCallback(widget.callback, trigger)
widget.vueTrack = track
return { get() {}, set() {} }
})
}
function useReactiveWidgetValue(widget: IBaseWidget) {
widgetWithVueTrack(widget)
widget.vueTrack()
return widget.value
}
function getControlWidget(widget: IBaseWidget): SafeControlWidget | undefined {
const cagWidget = widget.linkedWidgets?.find(
(w) => w.name == 'control_after_generate'
@@ -132,26 +125,18 @@ function getNodeType(node: LGraphNode, widget: IBaseWidget) {
* Shared widget enhancements used by both safeWidgetMapper and Right Side Panel
*/
interface SharedWidgetEnhancements {
/** Reactive widget value that updates when the widget changes */
value: WidgetValue
/** Control widget for seed randomization/increment/decrement */
controlWidget?: SafeControlWidget
/** Input specification from node definition */
spec?: InputSpec
/** Node type (for subgraph promoted widgets) */
nodeType?: string
/** Border style for promoted/advanced widgets */
borderStyle?: string
/** Widget label */
label?: string
/** Widget options */
options?: Record<string, any>
}
/**
* Extracts common widget enhancements shared across different rendering contexts.
* This function centralizes the logic for extracting metadata and reactive values
* from widgets, ensuring consistency between Nodes 2.0 and Right Side Panel.
* This function centralizes the logic for extracting metadata from widgets.
* Note: Value and metadata (label, options, hidden, etc.) are accessed via widgetValueStore.
*/
export function getSharedWidgetEnhancements(
node: LGraphNode,
@@ -160,17 +145,9 @@ export function getSharedWidgetEnhancements(
const nodeDefStore = useNodeDefStore()
return {
value: useReactiveWidgetValue(widget),
controlWidget: getControlWidget(widget),
spec: nodeDefStore.getInputSpecForWidget(node, widget.name),
nodeType: getNodeType(node, widget),
borderStyle: widget.promoted
? 'ring ring-component-node-widget-promoted'
: widget.advanced
? 'ring ring-component-node-widget-advanced'
: undefined,
label: widget.label,
options: widget.options
nodeType: getNodeType(node, widget)
}
}
@@ -211,7 +188,7 @@ function safeWidgetMapper(
): (widget: IBaseWidget) => SafeWidgetData {
return function (widget) {
try {
// Get shared enhancements used by both Nodes 2.0 and Right Side Panel
// Get shared enhancements (controlWidget, spec, nodeType)
const sharedEnhancements = getSharedWidgetEnhancements(node, widget)
const slotInfo = slotMetadata.get(widget.name)
@@ -227,20 +204,40 @@ function safeWidgetMapper(
node.widgets?.forEach((w) => w.triggerDraw?.())
}
// Extract only render-critical options (canvasOnly, advanced)
const options = widget.options
? {
canvasOnly: widget.options.canvasOnly,
advanced: widget.advanced
}
: undefined
const subgraphId = node.isSubgraphNode() && node.subgraph.id
const localId = isProxyWidget(widget)
? widget._overlay?.nodeId
: undefined
const nodeId =
subgraphId && localId ? `${subgraphId}:${localId}` : undefined
const name = isProxyWidget(widget)
? widget._overlay.widgetName
: widget.name
return {
name: widget.name,
nodeId,
name,
type: widget.type,
...sharedEnhancements,
callback,
hasLayoutSize: typeof widget.computeLayoutSize === 'function',
isDOMWidget: isDOMWidget(widget),
options,
slotMetadata: slotInfo
}
} catch (error) {
return {
nodeId: String(node.id),
name: widget.name || 'unknown',
type: widget.type || 'text',
value: undefined
type: widget.type || 'text'
}
}
}

View File

@@ -1,4 +1,6 @@
import { describe, expect, test, vi } from 'vitest'
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'
@@ -43,6 +45,10 @@ function setupSubgraph(
}
describe('Subgraph proxyWidgets', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
test('Can add simple widget', () => {
const [subgraphNode, innerNodes] = setupSubgraph(1)
innerNodes[0].addWidget('text', 'stringWidget', 'value', () => {})

View File

@@ -115,9 +115,11 @@ const onConfigure = function (
if (isActiveGraph && w instanceof DOMWidgetImpl) setWidget(w)
return [w]
})
this.widgets = this.widgets.filter(
(w) => !isProxyWidget(w) && !parsed.some(([, name]) => w.name === name)
)
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)
@@ -152,6 +154,7 @@ function newProxyWidget(
computedHeight: undefined,
isProxyWidget: true,
last_y: undefined,
label: name,
name,
node: subgraphNode,
onRemove: undefined,

View File

@@ -57,15 +57,20 @@ export function demoteWidget(
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 && w.name === widgetName
return ([n, w]: WidgetItem) =>
n.id == nodeId && getWidgetName(w) === widgetName
}
export function matchesPropertyItem([n, w]: WidgetItem) {
return ([nodeId, widgetName]: [string, string]) =>
n.id == nodeId && w.name === widgetName
n.id == nodeId && getWidgetName(w) === widgetName
}
export function widgetItemToProperty([n, w]: WidgetItem): [string, string] {
return [`${n.id}`, w.name]
return [`${n.id}`, getWidgetName(w)]
}
function getParentNodes(): SubgraphNode[] {

View File

@@ -18,9 +18,10 @@ import {
zAutogrowOptions,
zDynamicComboInputSpec
} from '@/schemas/nodeDefSchema'
import { useLitegraphService } from '@/services/litegraphService'
import { app } from '@/scripts/app'
import type { ComfyApp } from '@/scripts/app'
import { useLitegraphService } from '@/services/litegraphService'
import { createSlotWidgetRef } from '@/types/widget'
const INLINE_INPUTS = false
@@ -62,7 +63,7 @@ function ensureWidgetForInput(node: LGraphNode, input: INodeInputSlot) {
y: 0
})
input.alwaysVisible = true
input.widget = { name: input.name }
input.widget = createSlotWidgetRef(input.name)
}
function dynamicComboWidget(

View File

@@ -1412,7 +1412,7 @@ export class GroupNodeHandler {
handlerGroupData.oldToNewWidgetMap[Number(n)]?.[w]
const widget = this.widgets.find((wg) => wg.name === widgetName)
if (widget) {
widget.type = 'hidden'
widget.hidden = true
widget.computeSize = () => [0, -4]
}
}

View File

@@ -5,6 +5,7 @@ import {
LiteGraph
} from '@/lib/litegraph/src/litegraph'
import type { ISlotType } from '@/lib/litegraph/src/interfaces'
import { createSlotWidgetRef } from '@/types/widget'
import { app } from '../../scripts/app'
import { getWidgetConfig, mergeIfValid, setWidgetConfig } from './widgetInputs'
@@ -203,7 +204,7 @@ app.registerExtension({
for (const node of updateNodes) {
if (widgetConfig && outputType) {
node.inputs[0].widget = { name: 'value' }
node.inputs[0].widget = createSlotWidgetRef('value')
setWidgetConfig(node.inputs[0], [
widgetType ?? `${displayType}`,
widgetConfig

View File

@@ -23,6 +23,7 @@ import { getNodeByLocatorId } from '@/utils/graphTraversalUtil'
import { api } from '../../scripts/api'
import { app } from '../../scripts/app'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
function updateUIWidget(
audioUIWidget: DOMWidget<HTMLAudioElement, string>,
@@ -137,9 +138,16 @@ app.registerExtension({
}
}
let value = ''
audioUIWidget.options.getValue = () => value
audioUIWidget.options.setValue = (v) => (value = v)
audioUIWidget.options.getValue = () =>
(useWidgetValueStore().getWidget(node.id, inputName)
?.value as string) ?? ''
audioUIWidget.options.setValue = (v) => {
const widgetState = useWidgetValueStore().getWidget(
node.id,
inputName
)
if (widgetState) widgetState.value = v
}
return { widget: audioUIWidget }
}

View File

@@ -12,6 +12,7 @@ import type {
IBaseWidget,
TWidgetValue
} from '@/lib/litegraph/src/types/widgets'
import { isPrimitiveNode } from '@/renderer/utils/nodeTypeGuards'
import type { InputSpec } from '@/schemas/nodeDefSchema'
import { app } from '@/scripts/app'
import {
@@ -20,9 +21,9 @@ import {
isValidWidgetType
} from '@/scripts/widgets'
import { CONFIG, GET_CONFIG } from '@/services/litegraphService'
import { createSlotWidgetRef } from '@/types/widget'
import { mergeInputSpec } from '@/utils/nodeDefUtil'
import { applyTextReplacements } from '@/utils/searchAndReplace'
import { isPrimitiveNode } from '@/renderer/utils/nodeTypeGuards'
const replacePropertyName = 'Run widget replace on values'
export class PrimitiveNode extends LGraphNode {
@@ -189,7 +190,11 @@ export class PrimitiveNode extends LGraphNode {
let widget
if (!input.widget) {
if (!(input.type in ComfyWidgets)) return
widget = { name: input.name, [GET_CONFIG]: () => [input.type, {}] } //fake widget
// Create a SlotWidgetRef with GET_CONFIG for primitive node connection
widget = {
...createSlotWidgetRef(input.name),
[GET_CONFIG]: () => [input.type, {}]
}
} else {
widget = input.widget
}

View File

@@ -914,6 +914,16 @@ export class LGraph
}
}
// Register all widgets with the WidgetValueStore now that node has a valid ID.
// Widgets added before the node was in the graph deferred their setNodeId call.
if (node.widgets) {
for (const widget of node.widgets) {
if ('setNodeId' in widget && typeof widget.setNodeId === 'function') {
widget.setNodeId(node.id)
}
}
}
node.graph = this
this._version++

View File

@@ -8221,7 +8221,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
callback: LGraphCanvas.onMenuNodeCollapse
})
}
if (node.widgets?.some((w) => w.advanced)) {
if (node.hasAdvancedWidgets()) {
options.push({
content: node.showAdvanced ? 'Hide Advanced' : 'Show Advanced',
callback: LGraphCanvas.onMenuToggleAdvanced

View File

@@ -1,3 +1,5 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, vi } from 'vitest'
import type {
@@ -42,6 +44,7 @@ describe('LGraphNode', () => {
let origLiteGraph: typeof LiteGraph
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
origLiteGraph = Object.assign({}, LiteGraph)
// @ts-expect-error Intended: Force remove an otherwise readonly non-optional property
delete origLiteGraph.Classes

View File

@@ -1957,6 +1957,18 @@ export class LGraphNode
this.widgets ||= []
const widget = toConcreteWidget(custom_widget, this, false) ?? custom_widget
this.widgets.push(widget)
// Only register with store if node has a valid ID (is already in a graph).
// If the node isn't in a graph yet (id === -1), registration happens
// when the node is added via LGraph.add() -> node.onAdded.
if (
this.id !== -1 &&
'setNodeId' in widget &&
typeof widget.setNodeId === 'function'
) {
widget.setNodeId(this.id)
}
return widget
}
@@ -3499,7 +3511,7 @@ export class LGraphNode
* Toggles advanced mode of the node, showing advanced widgets
*/
toggleAdvanced() {
if (!this.widgets?.some((w) => w.advanced)) return
if (!this.hasAdvancedWidgets()) return
if (!this.graph) throw new NullGraphError()
this.graph._version++
this.showAdvanced = !this.showAdvanced
@@ -3877,6 +3889,21 @@ export class LGraphNode
return !isHidden
}
/**
* Returns all widgets that should participate in layout calculations.
* Filters out hidden widgets only (not collapsed/advanced).
*/
getLayoutWidgets(): IBaseWidget[] {
return this.widgets?.filter((w) => !w.hidden) ?? []
}
/**
* Returns `true` if the node has any advanced widgets.
*/
hasAdvancedWidgets(): boolean {
return this.widgets?.some((w) => w.advanced) ?? false
}
updateComputedDisabled() {
if (!this.widgets) return
for (const widget of this.widgets)
@@ -4087,7 +4114,7 @@ export class LGraphNode
w: IBaseWidget
}[] = []
const visibleWidgets = this.widgets.filter((w) => !w.hidden)
const visibleWidgets = this.getLayoutWidgets()
for (const w of visibleWidgets) {
if (w.computeSize) {

View File

@@ -1,3 +1,5 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it } from 'vitest'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
@@ -8,6 +10,7 @@ describe('LGraphNode widget ordering', () => {
let node: LGraphNode
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
node = new LGraphNode('TestNode')
})

View File

@@ -3,6 +3,7 @@ import { describe, expect, it, vi } from 'vitest'
import { LGraph } from '@/lib/litegraph/src/litegraph'
import type { IWidget } from '@/lib/litegraph/src/types/widgets'
import { createMutableSlotWidgetRef } from '@/types/widget'
import { subgraphTest } from './__fixtures__/subgraphFixtures'
import {
@@ -118,7 +119,7 @@ describe.skip('SubgraphNode Memory Management', () => {
} as Partial<IWidget> as IWidget
input._widget = mockWidget
input.widget = { name: 'promoted_widget' }
input.widget = createMutableSlotWidgetRef('promoted_widget')
subgraphNode.widgets.push(mockWidget)
expect(input._widget).toBe(mockWidget)

View File

@@ -29,8 +29,9 @@ import type {
} from '@/lib/litegraph/src/types/serialisation'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import type { UUID } from '@/lib/litegraph/src/utils/uuid'
import { BaseWidget } from '@/lib/litegraph/src/widgets/BaseWidget'
import { AssetWidget } from '@/lib/litegraph/src/widgets/AssetWidget'
import { BaseWidget } from '@/lib/litegraph/src/widgets/BaseWidget'
import { createMutableSlotWidgetRef } from '@/types/widget'
import { ExecutableNodeDTO } from './ExecutableNodeDTO'
import type { ExecutableLGraphNode, ExecutionId } from './ExecutableNodeDTO'
@@ -399,7 +400,7 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
// NOTE: This code creates linked chains of prototypes for passing across
// multiple levels of subgraphs. As part of this, it intentionally avoids
// creating new objects. Have care when making changes.
input.widget ??= { name: subgraphInput.name }
input.widget ??= createMutableSlotWidgetRef(subgraphInput.name)
input.widget.name = subgraphInput.name
if (inputWidget) Object.setPrototypeOf(input.widget, inputWidget)

View File

@@ -7,6 +7,7 @@ import type {
TWidgetType
} from '@/lib/litegraph/src/litegraph'
import { BaseWidget, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { createMutableSlotWidgetRef } from '@/types/widget'
import {
createEventCapture,
@@ -37,7 +38,7 @@ function createNodeWithWidget(
tooltip
})
node.widgets = [widget]
input.widget = { name: widget.name }
input.widget = createMutableSlotWidgetRef(widget.name)
return { node, widget, input }
}
@@ -202,8 +203,8 @@ describe.skip('SubgraphWidgetPromotion', () => {
})
multiWidgetNode.widgets = [widget1, widget2]
numInput.widget = { name: widget1.name }
strInput.widget = { name: widget2.name }
numInput.widget = createMutableSlotWidgetRef(widget1.name)
strInput.widget = createMutableSlotWidgetRef(widget2.name)
subgraph.add(multiWidgetNode)
// Connect both inputs
@@ -354,8 +355,8 @@ describe.skip('SubgraphWidgetPromotion', () => {
})
multiWidgetNode.widgets = [widget1, widget2]
numInput.widget = { name: widget1.name }
strInput.widget = { name: widget2.name }
numInput.widget = createMutableSlotWidgetRef(widget1.name)
strInput.widget = createMutableSlotWidgetRef(widget2.name)
subgraph.add(multiWidgetNode)
// Connect both inputs

View File

@@ -1,4 +1,5 @@
import type { Bounds } from '@/renderer/core/layout/types'
import type { WidgetKind } from '@/types/widget'
import type { CanvasColour, Point, RequiredProps, Size } from '../interfaces'
import type { CanvasPointer, LGraphCanvas, LGraphNode } from '../litegraph'
@@ -95,12 +96,14 @@ export type IWidget =
export interface IBooleanWidget extends IBaseWidget<boolean, 'toggle'> {
type: 'toggle'
kind?: 'boolean'
value: boolean
}
/** Any widget that uses a numeric backing */
export interface INumericWidget extends IBaseWidget<number, 'number'> {
type: 'number'
kind?: 'number'
value: number
}
@@ -110,6 +113,7 @@ export interface ISliderWidget extends IBaseWidget<
IWidgetSliderOptions
> {
type: 'slider'
kind?: 'slider'
value: number
marker?: number
}
@@ -120,6 +124,7 @@ export interface IKnobWidget extends IBaseWidget<
IWidgetKnobOptions
> {
type: 'knob'
kind?: 'knob'
value: number
options: IWidgetKnobOptions
}
@@ -131,6 +136,7 @@ export interface IStringComboWidget extends IBaseWidget<
RequiredProps<IWidgetOptions<string[]>, 'values'>
> {
type: 'combo'
kind?: 'combo'
value: string
}
@@ -146,6 +152,7 @@ export interface IComboWidget extends IBaseWidget<
RequiredProps<IWidgetOptions<ComboWidgetValues>, 'values'>
> {
type: 'combo'
kind?: 'combo'
value: string | number
}
@@ -156,6 +163,7 @@ export interface IStringWidget extends IBaseWidget<
IWidgetOptions<string[]>
> {
type: 'string' | 'text'
kind?: 'string' | 'textarea'
value: string
}
@@ -164,6 +172,7 @@ export interface IButtonWidget extends IBaseWidget<
'button'
> {
type: 'button'
kind?: 'button'
value: string | undefined
clicked: boolean
}
@@ -171,12 +180,14 @@ export interface IButtonWidget extends IBaseWidget<
/** A custom widget - accepts any value and has no built-in special handling */
interface ICustomWidget extends IBaseWidget<string | object, 'custom'> {
type: 'custom'
kind?: 'custom'
value: string | object
}
/** File upload widget for selecting and uploading files */
export interface IFileUploadWidget extends IBaseWidget<string, 'fileupload'> {
type: 'fileupload'
kind?: 'fileupload'
value: string
label?: string
}
@@ -184,18 +195,21 @@ export interface IFileUploadWidget extends IBaseWidget<string, 'fileupload'> {
/** Color picker widget for selecting colors */
export interface IColorWidget extends IBaseWidget<string, 'color'> {
type: 'color'
kind?: 'color'
value: string
}
/** Markdown widget for displaying formatted text */
export interface IMarkdownWidget extends IBaseWidget<string, 'markdown'> {
type: 'markdown'
kind?: 'markdown'
value: string
}
/** Image display widget */
interface IImageWidget extends IBaseWidget<string, 'image'> {
type: 'image'
kind?: 'image'
value: string
}
@@ -205,6 +219,7 @@ export interface ITreeSelectWidget extends IBaseWidget<
'treeselect'
> {
type: 'treeselect'
kind?: 'treeselect'
value: string | string[]
}
@@ -214,18 +229,21 @@ export interface IMultiSelectWidget extends IBaseWidget<
'multiselect'
> {
type: 'multiselect'
kind?: 'multiselect'
value: string[]
}
/** Chart widget for displaying data visualizations */
export interface IChartWidget extends IBaseWidget<object, 'chart'> {
type: 'chart'
kind?: 'chart'
value: object
}
/** Gallery widget for displaying multiple images */
export interface IGalleriaWidget extends IBaseWidget<string[], 'galleria'> {
type: 'galleria'
kind?: 'galleria'
value: string[]
}
@@ -235,6 +253,7 @@ export interface IImageCompareWidget extends IBaseWidget<
'imagecompare'
> {
type: 'imagecompare'
kind?: 'imagecompare'
value: string[]
}
@@ -245,12 +264,14 @@ export interface ISelectButtonWidget extends IBaseWidget<
RequiredProps<IWidgetOptions<string[]>, 'values'>
> {
type: 'selectbutton'
kind?: 'selectbutton'
value: string
}
/** Textarea widget for multi-line text input */
export interface ITextareaWidget extends IBaseWidget<string, 'textarea'> {
type: 'textarea'
kind?: 'textarea'
value: string
}
@@ -260,18 +281,21 @@ export interface IAssetWidget extends IBaseWidget<
IWidgetAssetOptions
> {
type: 'asset'
kind?: 'asset'
value: string
}
/** Image crop widget for cropping image */
export interface IImageCropWidget extends IBaseWidget<Bounds, 'imagecrop'> {
type: 'imagecrop'
kind?: 'imagecrop'
value: Bounds
}
/** Bounding box widget for defining regions with numeric inputs */
export interface IBoundingBoxWidget extends IBaseWidget<Bounds, 'boundingbox'> {
type: 'boundingbox'
kind?: 'boundingbox'
value: Bounds
}
@@ -305,6 +329,12 @@ export interface IBaseWidget<
label?: string
/** Widget type (see {@link TWidgetType}) */
type: TType
/**
* Canonical widget kind for the new type system.
* Maps type → kind: 'toggle' → 'boolean', 'text' → 'textarea'.
* @see WidgetKind
*/
kind?: WidgetKind
value?: TValue
vueTrack?: () => void

View File

@@ -8,6 +8,10 @@ export class AssetWidget
extends BaseWidget<IAssetWidget>
implements IAssetWidget
{
override get kind() {
return 'asset' as const
}
constructor(widget: IAssetWidget, node: LGraphNode) {
super(widget, node)
this.type ??= 'asset'

View File

@@ -0,0 +1,192 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it } from 'vitest'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { INumericWidget } from '@/lib/litegraph/src/types/widgets'
import { NumberWidget } from '@/lib/litegraph/src/widgets/NumberWidget'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
function createTestWidget(
node: LGraphNode,
overrides: Partial<INumericWidget> = {}
): NumberWidget {
return new NumberWidget(
{
type: 'number',
name: 'testWidget',
value: 42,
options: { min: 0, max: 100 },
y: 0,
...overrides
},
node
)
}
describe('BaseWidget store integration', () => {
let node: LGraphNode
let store: ReturnType<typeof useWidgetValueStore>
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
store = useWidgetValueStore()
node = new LGraphNode('TestNode')
node.id = 1
})
describe('metadata properties before registration', () => {
it('uses internal values when not registered', () => {
const widget = createTestWidget(node, {
label: 'My Label',
hidden: true,
disabled: true,
advanced: true,
promoted: true
})
expect(widget.label).toBe('My Label')
expect(widget.hidden).toBe(true)
expect(widget.disabled).toBe(true)
expect(widget.advanced).toBe(true)
expect(widget.promoted).toBe(true)
})
it('allows setting properties without store', () => {
const widget = createTestWidget(node)
widget.label = 'New Label'
widget.hidden = true
widget.disabled = true
widget.advanced = true
widget.promoted = true
expect(widget.label).toBe('New Label')
expect(widget.hidden).toBe(true)
expect(widget.disabled).toBe(true)
expect(widget.advanced).toBe(true)
expect(widget.promoted).toBe(true)
})
})
describe('metadata properties after registration', () => {
it('reads from store when registered', () => {
const widget = createTestWidget(node, {
name: 'storeWidget',
label: 'Store Label',
hidden: true,
disabled: true,
advanced: true,
promoted: true
})
widget.setNodeId(1)
expect(widget.label).toBe('Store Label')
expect(widget.hidden).toBe(true)
expect(widget.disabled).toBe(true)
expect(widget.advanced).toBe(true)
expect(widget.promoted).toBe(true)
})
it('writes to store when registered', () => {
const widget = createTestWidget(node, { name: 'writeWidget' })
widget.setNodeId(1)
widget.label = 'Updated Label'
widget.hidden = true
widget.disabled = true
widget.advanced = true
widget.promoted = true
const state = store.getWidget(1, 'writeWidget')
expect(state?.label).toBe('Updated Label')
expect(state?.hidden).toBe(true)
expect(state?.disabled).toBe(true)
expect(state?.advanced).toBe(true)
expect(state?.promoted).toBe(true)
})
it('syncs value with store', () => {
const widget = createTestWidget(node, { name: 'valueWidget', value: 42 })
widget.setNodeId(1)
widget.value = 99
expect(store.getWidget(1, 'valueWidget')?.value).toBe(99)
const state = store.getWidget(1, 'valueWidget')!
state.value = 55
expect(widget.value).toBe(55)
})
})
describe('automatic registration via setNodeId', () => {
it('registers widget with all metadata', () => {
const widget = createTestWidget(node, {
name: 'autoRegWidget',
value: 100,
label: 'Auto Label',
hidden: true,
disabled: true,
advanced: true,
promoted: true
})
widget.setNodeId(1)
const state = store.getWidget(1, 'autoRegWidget')
expect(state).toBeDefined()
expect(state?.nodeId).toBe(1)
expect(state?.name).toBe('autoRegWidget')
expect(state?.type).toBe('number')
expect(state?.value).toBe(100)
expect(state?.label).toBe('Auto Label')
expect(state?.hidden).toBe(true)
expect(state?.disabled).toBe(true)
expect(state?.advanced).toBe(true)
expect(state?.promoted).toBe(true)
expect(state?.options).toEqual({ min: 0, max: 100 })
})
it('registers widget with default metadata values', () => {
const widget = createTestWidget(node, { name: 'defaultsWidget' })
widget.setNodeId(1)
const state = store.getWidget(1, 'defaultsWidget')
expect(state).toBeDefined()
expect(state?.hidden).toBe(false)
expect(state?.disabled).toBe(false)
expect(state?.advanced).toBe(false)
expect(state?.promoted).toBe(false)
expect(state?.label).toBeUndefined()
})
it('registers widget value accessible via getWidget', () => {
const widget = createTestWidget(node, { name: 'valuesWidget', value: 77 })
widget.setNodeId(1)
expect(store.getWidget(1, 'valuesWidget')?.value).toBe(77)
})
})
describe('fallback behavior', () => {
it('uses internal value before registration', () => {
const widget = createTestWidget(node, {
name: 'fallbackWidget',
label: 'Internal'
})
// Widget not yet registered - should use internal value
expect(widget.label).toBe('Internal')
})
it('handles undefined values correctly', () => {
const widget = createTestWidget(node)
widget.setNodeId(1)
widget.hidden = undefined
widget.disabled = undefined
const state = store.getWidget(1, 'testWidget')
expect(state?.hidden).toBe(false)
expect(state?.disabled).toBe(false)
})
})
})

View File

@@ -2,6 +2,7 @@ import { t } from '@/i18n'
import { drawTextInArea } from '@/lib/litegraph/src/draw'
import { Rectangle } from '@/lib/litegraph/src/infrastructure/Rectangle'
import type { Point } from '@/lib/litegraph/src/interfaces'
import type { NodeId } from '@/lib/litegraph/src/LGraphNode'
import type {
CanvasPointer,
LGraphCanvas,
@@ -10,7 +11,14 @@ import type {
} from '@/lib/litegraph/src/litegraph'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import type { CanvasPointerEvent } from '@/lib/litegraph/src/types/events'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import type {
IBaseWidget,
TWidgetType
} from '@/lib/litegraph/src/types/widgets'
import type { WidgetState } from '@/stores/widgetValueStore'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import type { WidgetKind } from '@/types/widget'
import { legacyTypeToKind } from '@/types/widget/adapters'
export interface DrawWidgetOptions {
/** The width of the node where this widget will be displayed. */
@@ -66,17 +74,58 @@ export abstract class BaseWidget<
linkedWidgets?: IBaseWidget[]
name: string
options: TWidget['options']
label?: string
type: TWidget['type']
/**
* Canonical widget kind derived from legacy type.
* Subclasses may override with a literal for narrower typing.
*/
get kind(): WidgetKind {
return legacyTypeToKind(this.type as TWidgetType)
}
y: number = 0
last_y?: number
width?: number
disabled?: boolean
computedDisabled?: boolean
hidden?: boolean
advanced?: boolean
promoted?: boolean
tooltip?: string
private _state: WidgetState
get label(): string | undefined {
return this._state.label
}
set label(value: string | undefined) {
this._state.label = value
}
get hidden(): boolean | undefined {
return this._state.hidden
}
set hidden(value: boolean | undefined) {
this._state.hidden = value ?? false
}
get disabled(): boolean | undefined {
return this._state.disabled
}
set disabled(value: boolean | undefined) {
this._state.disabled = value ?? false
}
get advanced(): boolean | undefined {
return this._state.advanced
}
set advanced(value: boolean | undefined) {
this._state.advanced = value ?? false
}
get promoted(): boolean | undefined {
return this._state.promoted
}
set promoted(value: boolean | undefined) {
this._state.promoted = value ?? false
}
element?: HTMLElement
callback?(
value: TWidget['value'],
@@ -97,13 +146,20 @@ export abstract class BaseWidget<
canvas: LGraphCanvas
): boolean
private _value?: TWidget['value']
get value(): TWidget['value'] {
return this._value
return this._state.value as TWidget['value']
}
set value(value: TWidget['value']) {
this._state.value = value
}
set value(value: TWidget['value']) {
this._value = value
/**
* Associates this widget with a node ID and registers it in the WidgetValueStore.
* Once set, value reads/writes will be delegated to the store.
*/
setNodeId(nodeId: NodeId): void {
this._state.nodeId = nodeId
this._state = useWidgetValueStore().registerWidget(this._state)
}
constructor(widget: TWidget & { node: LGraphNode })
@@ -141,12 +197,31 @@ export abstract class BaseWidget<
displayValue,
// @ts-expect-error Prevent naming conflicts with custom nodes.
labelBaseline,
label,
hidden,
disabled,
advanced,
promoted,
value,
linkedWidgets,
...safeValues
} = widget
Object.assign(this, safeValues)
this._state = {
nodeId: undefined as unknown as NodeId,
name: this.name,
type: this.type as TWidgetType,
value,
label,
hidden: hidden ?? false,
disabled: disabled ?? false,
advanced: advanced ?? false,
promoted: promoted ?? false,
serialize: this.serialize,
options: this.options
}
}
get outline_color() {

View File

@@ -9,6 +9,10 @@ export class BooleanWidget
{
override type = 'toggle' as const
override get kind() {
return 'boolean' as const
}
override drawWidget(
ctx: CanvasRenderingContext2D,
{ width, showText = true }: DrawWidgetOptions

View File

@@ -12,6 +12,10 @@ export class BoundingBoxWidget
{
override type = 'boundingbox' as const
override get kind() {
return 'boundingbox' as const
}
drawWidget(ctx: CanvasRenderingContext2D, options: DrawWidgetOptions): void {
this.drawVueOnlyWarning(ctx, options, 'BoundingBox')
}

View File

@@ -9,6 +9,11 @@ export class ButtonWidget
implements IButtonWidget
{
override type = 'button' as const
override get kind() {
return 'button' as const
}
clicked: boolean
constructor(widget: IButtonWidget, node: LGraphNode) {

View File

@@ -14,6 +14,10 @@ export class ChartWidget
{
override type = 'chart' as const
override get kind() {
return 'chart' as const
}
drawWidget(ctx: CanvasRenderingContext2D, options: DrawWidgetOptions): void {
const { width } = options
const { y, height } = this

View File

@@ -28,6 +28,10 @@ export class ColorWidget
{
override type = 'color' as const
override get kind() {
return 'color' as const
}
drawWidget(ctx: CanvasRenderingContext2D, options: DrawWidgetOptions): void {
const { fillStyle, strokeStyle, textAlign } = ctx

View File

@@ -32,6 +32,10 @@ export class ComboWidget
{
override type = 'combo' as const
override get kind() {
return 'combo' as const
}
override get _displayValue() {
if (this.computedDisabled) return ''

View File

@@ -14,6 +14,10 @@ export class FileUploadWidget
{
override type = 'fileupload' as const
override get kind() {
return 'fileupload' as const
}
drawWidget(ctx: CanvasRenderingContext2D, options: DrawWidgetOptions): void {
const { width } = options
const { y, height } = this

View File

@@ -14,6 +14,10 @@ export class GalleriaWidget
{
override type = 'galleria' as const
override get kind() {
return 'galleria' as const
}
drawWidget(ctx: CanvasRenderingContext2D, options: DrawWidgetOptions): void {
const { width } = options
const { y, height } = this

View File

@@ -14,6 +14,10 @@ export class ImageCompareWidget
{
override type = 'imagecompare' as const
override get kind() {
return 'imagecompare' as const
}
drawWidget(ctx: CanvasRenderingContext2D, options: DrawWidgetOptions): void {
const { width } = options
const { y, height } = this

View File

@@ -12,6 +12,10 @@ export class ImageCropWidget
{
override type = 'imagecrop' as const
override get kind() {
return 'imagecrop' as const
}
drawWidget(ctx: CanvasRenderingContext2D, options: DrawWidgetOptions): void {
this.drawVueOnlyWarning(ctx, options, 'ImageCrop')
}

View File

@@ -9,6 +9,10 @@ import type { DrawWidgetOptions, WidgetEventOptions } from './BaseWidget'
export class KnobWidget extends BaseWidget<IKnobWidget> implements IKnobWidget {
override type = 'knob' as const
override get kind() {
return 'knob' as const
}
/**
* Compute the layout size of the widget.
* @returns The layout size of the widget.

View File

@@ -14,6 +14,10 @@ export class MarkdownWidget
{
override type = 'markdown' as const
override get kind() {
return 'markdown' as const
}
drawWidget(ctx: CanvasRenderingContext2D, options: DrawWidgetOptions): void {
const { width } = options
const { y, height } = this

View File

@@ -14,6 +14,10 @@ export class MultiSelectWidget
{
override type = 'multiselect' as const
override get kind() {
return 'multiselect' as const
}
drawWidget(ctx: CanvasRenderingContext2D, options: DrawWidgetOptions): void {
const { width } = options
const { y, height } = this

View File

@@ -10,6 +10,10 @@ export class NumberWidget
{
override type = 'number' as const
override get kind() {
return 'number' as const
}
override get _displayValue() {
if (this.computedDisabled) return ''
return Number(this.value).toFixed(

View File

@@ -14,6 +14,10 @@ export class SelectButtonWidget
{
override type = 'selectbutton' as const
override get kind() {
return 'selectbutton' as const
}
drawWidget(ctx: CanvasRenderingContext2D, options: DrawWidgetOptions): void {
const { width } = options
const { y, height } = this

View File

@@ -11,6 +11,10 @@ export class SliderWidget
{
override type = 'slider' as const
override get kind() {
return 'slider' as const
}
marker?: number
/**

View File

@@ -14,6 +14,10 @@ export class TextWidget
this.value = widget.value?.toString() ?? ''
}
override get kind(): 'string' | 'textarea' {
return this.type === 'text' ? 'textarea' : 'string'
}
/**
* Draws the widget
* @param ctx The canvas context

View File

@@ -12,6 +12,10 @@ export class TextareaWidget
{
override type = 'textarea' as const
override get kind() {
return 'textarea' as const
}
drawWidget(ctx: CanvasRenderingContext2D, options: DrawWidgetOptions): void {
this.drawVueOnlyWarning(ctx, options, 'Textarea')
}

View File

@@ -12,6 +12,10 @@ export class TreeSelectWidget
{
override type = 'treeselect' as const
override get kind() {
return 'treeselect' as const
}
drawWidget(ctx: CanvasRenderingContext2D, options: DrawWidgetOptions): void {
this.drawVueOnlyWarning(ctx, options, 'TreeSelect')
}

View File

@@ -56,6 +56,7 @@ const nodeData = computed<VueNodeData>(() => {
const widgets = Object.entries(nodeDef.inputs || {})
.filter(([_, input]) => widgetStore.inputIsWidget(input))
.map(([name, input]) => ({
nodeId: '-1',
name,
type: input.widgetType || input.type,
value:

View File

@@ -108,6 +108,7 @@ import NodeBadge from '@/renderer/extensions/vueNodes/components/NodeBadge.vue'
import { useNodeTooltips } from '@/renderer/extensions/vueNodes/composables/useNodeTooltips'
import { applyLightThemeColor } from '@/renderer/extensions/vueNodes/utils/nodeStyleUtils'
import { app } from '@/scripts/app'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import {
getLocatorIdFromNodeData,
getNodeByLocatorId
@@ -214,12 +215,14 @@ const nodeBadges = computed<NodeBadgeProps[]>(() => {
// For dynamic pricing, also track widget values and input connections
if (isDynamicPricing.value) {
// Access only the widget values that affect pricing
// Access only the widget values that affect pricing (from widgetValueStore)
const relevantNames = relevantPricingWidgets.value
if (relevantNames.length > 0) {
nodeData?.widgets?.forEach((w) => {
if (relevantNames.includes(w.name)) w.value
})
const widgetStore = useWidgetValueStore()
if (relevantNames.length > 0 && nodeData?.id != null) {
for (const name of relevantNames) {
// Access value from store to create reactive dependency
void widgetStore.getWidget(nodeData.id, name)?.value
}
}
// Access input connections for regular inputs
const inputNames = relevantInputNames.value

View File

@@ -13,15 +13,12 @@ describe('NodeWidgets', () => {
const createMockWidget = (
overrides: Partial<SafeWidgetData> = {}
): SafeWidgetData => ({
nodeId: 'test_node',
name: 'test_widget',
type: 'combo',
value: 'test_value',
options: {
values: ['option1', 'option2']
},
options: undefined,
callback: undefined,
spec: undefined,
label: undefined,
isDOMWidget: false,
slotMetadata: undefined,
...overrides

View File

@@ -25,10 +25,7 @@
:key="`widget-${index}-${widget.name}`"
>
<div
v-if="
!widget.simplified.options?.hidden &&
(!widget.simplified.options?.advanced || showAdvanced)
"
v-if="!widget.hidden && (!widget.advanced || showAdvanced)"
class="lg-node-widget group col-span-full grid grid-cols-subgrid items-stretch"
:data-widget-name="widget.name"
>
@@ -93,6 +90,10 @@ import {
shouldExpand,
shouldRenderAsVue
} from '@/renderer/extensions/vueNodes/widgets/registry/widgetRegistry'
import {
stripGraphPrefix,
useWidgetValueStore
} from '@/stores/widgetValueStore'
import type { SimplifiedWidget, WidgetValue } from '@/types/simplifiedWidget'
import { cn } from '@/utils/tailwindUtil'
@@ -141,6 +142,7 @@ const showAdvanced = computed(
const { getWidgetTooltip, createTooltipConfig } = useNodeTooltips(
nodeType.value
)
const widgetValueStore = useWidgetValueStore()
interface ProcessedWidget {
name: string
@@ -151,11 +153,18 @@ interface ProcessedWidget {
updateHandler: (value: WidgetValue) => void
tooltipConfig: TooltipOptions
slotMetadata?: WidgetSlotMetadata
/** Whether widget is hidden (from widgetValueStore) */
hidden: boolean
/** Whether widget is advanced (from widgetValueStore) */
advanced: boolean
/** Whether widget has custom layout size computation */
hasLayoutSize: boolean
}
const processedWidgets = computed((): ProcessedWidget[] => {
if (!nodeData?.widgets) return []
const nodeId = nodeData.id
const { widgets } = nodeData
const result: ProcessedWidget[] = []
@@ -166,33 +175,47 @@ const processedWidgets = computed((): ProcessedWidget[] => {
getComponent(widget.type) ||
(widget.isDOMWidget ? WidgetDOM : WidgetLegacy)
const { slotMetadata, options } = widget
const { slotMetadata } = widget
// Core feature: Disable Vue widgets when their input slots are connected
// This prevents conflicting input sources - when a slot is linked to another
// node's output, the widget should be read-only to avoid data conflicts
// Get metadata from store (registered during BaseWidget.setNodeId)
const bareWidgetId = stripGraphPrefix(widget.nodeId ?? nodeId)
const widgetState = widgetValueStore.getWidget(bareWidgetId, widget.name)
// Get value from store (falls back to undefined if not registered)
const value = widgetState?.value as WidgetValue
// Build options from store state, with slot-linked override for disabled
const storeOptions = widgetState?.options ?? {}
const widgetOptions = slotMetadata?.linked
? { ...options, disabled: true }
: options
? { ...storeOptions, disabled: true }
: storeOptions
// Derive border style from store metadata
const borderStyle =
widgetState?.promoted && String(widgetState?.nodeId) === String(nodeId)
? 'ring ring-component-node-widget-promoted'
: widgetState?.advanced
? 'ring ring-component-node-widget-advanced'
: undefined
const simplified: SimplifiedWidget = {
name: widget.name,
type: widget.type,
value: widget.value,
borderStyle: widget.borderStyle,
value,
borderStyle,
callback: widget.callback,
controlWidget: widget.controlWidget,
label: widget.label,
label: widgetState?.label,
nodeType: widget.nodeType,
options: widgetOptions,
spec: widget.spec
}
function updateHandler(value: WidgetValue) {
// Update the widget value directly
widget.value = value
widget.callback?.(value)
function updateHandler(newValue: WidgetValue) {
// Update value in store
if (widgetState) widgetState.value = newValue
// Invoke LiteGraph callback wrapper (handles triggerDraw, etc.)
widget.callback?.(newValue)
}
const tooltipText = getWidgetTooltip(widget)
@@ -203,10 +226,13 @@ const processedWidgets = computed((): ProcessedWidget[] => {
type: widget.type,
vueComponent,
simplified,
value: widget.value,
value,
updateHandler,
tooltipConfig,
slotMetadata
slotMetadata,
hidden: widgetState?.hidden ?? false,
advanced: widgetState?.advanced ?? false,
hasLayoutSize: widget.hasLayoutSize ?? false
})
}
@@ -214,15 +240,9 @@ const processedWidgets = computed((): ProcessedWidget[] => {
})
const gridTemplateRows = computed((): string => {
if (!nodeData?.widgets) return ''
const processedNames = new Set(toValue(processedWidgets).map((w) => w.name))
return nodeData.widgets
.filter(
(w) =>
processedNames.has(w.name) &&
!w.options?.hidden &&
(!w.options?.advanced || showAdvanced.value)
)
// Use processedWidgets directly since it already has store-based hidden/advanced
return toValue(processedWidgets)
.filter((w) => !w.hidden && (!w.advanced || showAdvanced.value))
.map((w) =>
shouldExpand(w.type) || w.hasLayoutSize ? 'auto' : 'min-content'
)

View File

@@ -11,7 +11,10 @@ import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import { app } from '@/scripts/app'
import type { ComfyWidgetConstructorV2 } from '@/scripts/widgets'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
// TODO: This widget manually syncs with widgetValueStore via getValue/setValue.
// Consolidate with useStringWidget into shared helpers (domWidgetHelpers.ts).
function addMarkdownWidget(
node: LGraphNode,
name: string,
@@ -36,6 +39,8 @@ function addMarkdownWidget(
editable: false
})
const widgetStore = useWidgetValueStore()
const inputEl = editor.options.element as HTMLElement
inputEl.classList.add('comfy-markdown')
const textarea = document.createElement('textarea')
@@ -43,16 +48,28 @@ function addMarkdownWidget(
const widget = node.addDOMWidget(name, 'MARKDOWN', inputEl, {
getValue(): string {
return textarea.value
return (
(widgetStore.getWidget(node.id, name)?.value as string) ??
textarea.value
)
},
setValue(v: string) {
textarea.value = v
editor.commands.setContent(v)
const widgetState = widgetStore.getWidget(node.id, name)
if (widgetState) widgetState.value = v
}
})
widget.inputEl = inputEl
widget.element = inputEl
widget.options.minNodeSize = [400, 200]
inputEl.addEventListener('input', (event) => {
if (event.target instanceof HTMLTextAreaElement) {
widget.value = event.target.value
}
widget.callback?.(widget.value)
})
inputEl.addEventListener('dblclick', () => {
inputEl.classList.add('editing')
setTimeout(() => {

View File

@@ -4,14 +4,18 @@ import { isStringInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import { app } from '@/scripts/app'
import type { ComfyWidgetConstructorV2 } from '@/scripts/widgets'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
const TRACKPAD_DETECTION_THRESHOLD = 50
// TODO: This widget manually syncs with widgetValueStore via getValue/setValue.
// Consolidate with useMarkdownWidget into shared helpers (domWidgetHelpers.ts).
function addMultilineWidget(
node: LGraphNode,
name: string,
opts: { defaultVal: string; placeholder?: string }
) {
const widgetStore = useWidgetValueStore()
const inputEl = document.createElement('textarea')
inputEl.className = 'comfy-multiline-input'
inputEl.value = opts.defaultVal
@@ -20,17 +24,24 @@ function addMultilineWidget(
const widget = node.addDOMWidget(name, 'customtext', inputEl, {
getValue(): string {
return inputEl.value
const widgetState = widgetStore.getWidget(node.id, name)
return (widgetState?.value as string) ?? inputEl.value
},
setValue(v: string) {
inputEl.value = v
const widgetState = widgetStore.getWidget(node.id, name)
if (widgetState) widgetState.value = v
}
})
widget.inputEl = inputEl
widget.element = inputEl
widget.options.minNodeSize = [400, 200]
inputEl.addEventListener('input', () => {
inputEl.addEventListener('input', (event) => {
if (event.target instanceof HTMLTextAreaElement) {
widget.value = event.target.value
}
widget.callback?.(widget.value)
})

View File

@@ -123,7 +123,7 @@ describe('widgetRegistry', () => {
})
it('should return false for widgets without a type', () => {
const widget = { options: {} }
const widget = {}
expect(shouldRenderAsVue(widget)).toBe(false)
})
@@ -136,7 +136,7 @@ describe('widgetRegistry', () => {
it('should respect options while checking type', () => {
const widget: Partial<SafeWidgetData> = {
type: 'text',
options: { precision: 5 }
options: { canvasOnly: false }
}
expect(shouldRenderAsVue(widget)).toBe(true)
})

View File

@@ -153,7 +153,7 @@ abstract class BaseDOMWidgetImpl<V extends object | string>
}
isVisible(): boolean {
return !['hidden'].includes(this.type) && this.node.isWidgetVisible(this)
return !this.hidden && this.node.isWidgetVisible(this)
}
override draw(

View File

@@ -34,7 +34,6 @@ async function decompressZlib(
return result
}
/** @knipIgnoreUnusedButUsedByCustomNodes */
export async function getFromPngBuffer(
buffer: ArrayBuffer
): Promise<Record<string, string>> {

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

@@ -0,0 +1,190 @@
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: NodeId,
name: string,
type: string,
value: T,
extra: Partial<
Omit<WidgetState<T>, 'nodeId' | 'name' | 'type' | 'value'>
> = {}
): WidgetState<T> {
return {
nodeId,
name,
type,
value,
options: {},
hidden: false,
disabled: false,
...extra
}
}
describe('useWidgetValueStore', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
describe('widgetState.value access', () => {
it('getWidget returns undefined for unregistered widget', () => {
const store = useWidgetValueStore()
expect(store.getWidget(999, 'widget')).toBeUndefined()
})
it('widgetState.value can be read and written directly', () => {
const store = useWidgetValueStore()
const state = store.registerWidget(widget(1, 'seed', 'number', 100))
expect(state.value).toBe(100)
state.value = 200
expect(store.getWidget(1, 'seed')?.value).toBe(200)
})
it('stores different value types', () => {
const store = useWidgetValueStore()
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(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(1, 'seed', 'number', 12345))
expect(state.nodeId).toBe(1)
expect(state.name).toBe('seed')
expect(state.type).toBe('number')
expect(state.value).toBe(12345)
expect(state.hidden).toBe(false)
expect(state.disabled).toBe(false)
expect(state.advanced).toBeUndefined()
expect(state.promoted).toBeUndefined()
expect(state.serialize).toBeUndefined()
expect(state.options).toEqual({})
})
it('registers a widget with all properties', () => {
const store = useWidgetValueStore()
const state = store.registerWidget(
widget(1, 'prompt', 'string', 'test', {
label: 'Prompt Text',
hidden: true,
disabled: true,
advanced: true,
promoted: true,
serialize: false,
options: { multiline: true }
})
)
expect(state.label).toBe('Prompt Text')
expect(state.hidden).toBe(true)
expect(state.disabled).toBe(true)
expect(state.advanced).toBe(true)
expect(state.promoted).toBe(true)
expect(state.serialize).toBe(false)
expect(state.options).toEqual({ multiline: true })
})
})
describe('widget getters', () => {
it('getWidget returns widget state', () => {
const store = useWidgetValueStore()
store.registerWidget(widget(1, 'seed', 'number', 100))
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(999, 'widget')).toBeUndefined()
})
it('getNodeWidgets returns all widgets for a node', () => {
const store = useWidgetValueStore()
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(1)
expect(widgets).toHaveLength(2)
expect(widgets.map((w) => w.name).sort()).toEqual(['seed', 'steps'])
})
})
describe('direct property mutation', () => {
it('hidden can be set directly via getWidget', () => {
const store = useWidgetValueStore()
const state = store.registerWidget(widget(1, 'seed', 'number', 100))
state.hidden = true
expect(store.getWidget(1, 'seed')?.hidden).toBe(true)
state.hidden = 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(1, 'seed', 'number', 100))
state.disabled = 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(1, 'seed', 'number', 100))
state.advanced = 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(1, 'seed', 'number', 100))
state.promoted = 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(1, 'seed', 'number', 100))
state.label = 'Random Seed'
expect(store.getWidget(1, 'seed')?.label).toBe('Random Seed')
state.label = undefined
expect(store.getWidget(1, 'seed')?.label).toBeUndefined()
})
})
})

View File

@@ -0,0 +1,82 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import type { IWidgetOptions } from '@/lib/litegraph/src/types/widgets'
import type {
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 {
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,
TOptions extends IWidgetOptions<unknown> = IWidgetOptions<unknown>
> extends WidgetRuntimeState {
value: TValue
type?: string
options?: TOptions
serialize?: boolean
}
export const useWidgetValueStore = defineStore('widgetValue', () => {
/**
* 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 = getWidgetId(state)
widgetStates.value.set(key, state)
return widgetStates.value.get(key) as WidgetState<TValue>
}
function getNodeWidgets(nodeId: NodeId): WidgetState[] {
const prefix = `${nodeId}:`
return [...widgetStates.value]
.filter(([key]) => key.startsWith(prefix))
.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 {
if (typeof nodeIdOrIdentity === 'object') {
return widgetStates.value.get(getWidgetId(nodeIdOrIdentity))
}
return widgetStates.value.get(
getWidgetId({ nodeId: nodeIdOrIdentity, name: widgetName! })
)
}
return {
registerWidget,
getWidget,
getNodeWidgets
}
})

View File

@@ -0,0 +1,419 @@
import { describe, expect, it } from 'vitest'
import type { IWidget } from '@/lib/litegraph/src/types/widgets'
import {
createMutableSlotWidgetRef,
createSlotWidgetRef,
fromLiteGraphWidget,
kindToLegacyType,
legacyTypeToKind,
toLiteGraphWidget,
toWidgetIdentity
} from './adapters'
import type { WidgetModel } from './model'
describe('legacyTypeToKind', () => {
it('maps toggle to boolean', () => {
expect(legacyTypeToKind('toggle')).toBe('boolean')
})
it('maps text to textarea', () => {
expect(legacyTypeToKind('text')).toBe('textarea')
})
it('maps number to number', () => {
expect(legacyTypeToKind('number')).toBe('number')
})
it('maps string to string', () => {
expect(legacyTypeToKind('string')).toBe('string')
})
it('preserves direct mappings', () => {
expect(legacyTypeToKind('slider')).toBe('slider')
expect(legacyTypeToKind('combo')).toBe('combo')
expect(legacyTypeToKind('button')).toBe('button')
expect(legacyTypeToKind('color')).toBe('color')
})
})
describe('kindToLegacyType', () => {
it('maps boolean to toggle', () => {
expect(kindToLegacyType('boolean')).toBe('toggle')
})
it('maps textarea to textarea', () => {
expect(kindToLegacyType('textarea')).toBe('textarea')
})
it('maps string to string', () => {
expect(kindToLegacyType('string')).toBe('string')
})
})
describe('fromLiteGraphWidget', () => {
const nodeId = 42
it('converts toggle widget to boolean', () => {
const lgWidget = {
name: 'enabled',
type: 'toggle',
value: true,
options: {},
y: 0
} as IWidget
const result = fromLiteGraphWidget(lgWidget, nodeId)
expect(result.kind).toBe('boolean')
expect(result.value).toBe(true)
expect(result.nodeId).toBe(42)
expect(result.name).toBe('enabled')
})
it('converts number widget with options', () => {
const lgWidget = {
name: 'steps',
type: 'number',
value: 20,
options: { min: 1, max: 100, step2: 1, precision: 0 },
y: 0
} as IWidget
const result = fromLiteGraphWidget(lgWidget, nodeId)
expect(result.kind).toBe('number')
expect(result.value).toBe(20)
if (result.kind === 'number') {
expect(result.options.min).toBe(1)
expect(result.options.max).toBe(100)
expect(result.options.step).toBe(1)
expect(result.options.precision).toBe(0)
}
})
it('converts slider widget', () => {
const lgWidget = {
name: 'strength',
type: 'slider',
value: 0.75,
options: { min: 0, max: 1, step2: 0.01 },
y: 0
} as IWidget
const result = fromLiteGraphWidget(lgWidget, nodeId)
expect(result.kind).toBe('slider')
expect(result.value).toBe(0.75)
if (result.kind === 'slider') {
expect(result.options.min).toBe(0)
expect(result.options.max).toBe(1)
expect(result.options.step).toBe(0.01)
}
})
it('converts combo widget with string array values', () => {
const lgWidget = {
name: 'sampler',
type: 'combo',
value: 'euler',
options: { values: ['euler', 'euler_a', 'dpm'] },
y: 0
} as IWidget
const result = fromLiteGraphWidget(lgWidget, nodeId)
expect(result.kind).toBe('combo')
expect(result.value).toBe('euler')
if (result.kind === 'combo') {
expect(result.options.values).toEqual(['euler', 'euler_a', 'dpm'])
}
})
it('converts text widget to textarea', () => {
const lgWidget = {
name: 'prompt',
type: 'text',
value: 'a cat',
options: { multiline: true },
y: 0
} as IWidget
const result = fromLiteGraphWidget(lgWidget, nodeId)
expect(result.kind).toBe('textarea')
expect(result.value).toBe('a cat')
})
it('converts string widget preserving multiline option', () => {
const lgWidget = {
name: 'label',
type: 'string',
value: 'test',
options: { multiline: false },
y: 0
} as IWidget
const result = fromLiteGraphWidget(lgWidget, nodeId)
expect(result.kind).toBe('string')
expect(result.value).toBe('test')
if (result.kind === 'string') {
expect(result.options.multiline).toBe(false)
}
})
it('preserves hidden/disabled/advanced/promoted flags', () => {
const lgWidget = {
name: 'test',
type: 'toggle',
value: false,
hidden: true,
disabled: true,
advanced: true,
promoted: true,
options: {},
y: 0
} as IWidget
const result = fromLiteGraphWidget(lgWidget, nodeId)
expect(result.hidden).toBe(true)
expect(result.disabled).toBe(true)
expect(result.advanced).toBe(true)
expect(result.promoted).toBe(true)
})
it('handles missing value gracefully', () => {
const lgWidget = {
name: 'empty',
type: 'string',
value: '',
options: {},
y: 0
} as IWidget
lgWidget.value = undefined as unknown as string
const result = fromLiteGraphWidget(lgWidget, nodeId)
expect(result.kind).toBe('string')
expect(result.value).toBe('')
})
})
describe('toLiteGraphWidget', () => {
it('converts boolean widget to toggle', () => {
const model: WidgetModel = {
nodeId: 1,
name: 'enabled',
kind: 'boolean',
value: true,
options: {}
}
const result = toLiteGraphWidget(model)
expect(result.type).toBe('toggle')
expect(result.value).toBe(true)
expect(result.name).toBe('enabled')
})
it('converts slider widget with options', () => {
const model: WidgetModel = {
nodeId: 1,
name: 'strength',
kind: 'slider',
value: 0.5,
options: { min: 0, max: 1, step: 0.1 }
}
const result = toLiteGraphWidget(model)
expect(result.type).toBe('slider')
expect(result.value).toBe(0.5)
expect(result.options?.min).toBe(0)
expect(result.options?.max).toBe(1)
expect(result.options?.step2).toBe(0.1)
})
it('converts number widget options correctly', () => {
const model: WidgetModel = {
nodeId: 1,
name: 'steps',
kind: 'number',
value: 20,
options: { min: 1, max: 100, step: 1, precision: 0 }
}
const result = toLiteGraphWidget(model)
expect(result.type).toBe('number')
expect(result.options?.step2).toBe(1)
expect(result.options?.precision).toBe(0)
})
it('preserves state flags', () => {
const model: WidgetModel = {
nodeId: 1,
name: 'test',
kind: 'boolean',
value: false,
hidden: true,
disabled: true,
advanced: true,
promoted: true,
options: {}
}
const result = toLiteGraphWidget(model)
expect(result.hidden).toBe(true)
expect(result.disabled).toBe(true)
expect(result.advanced).toBe(true)
expect(result.promoted).toBe(true)
})
})
describe('round-trip conversion', () => {
it('preserves boolean widget data', () => {
const original: WidgetModel = {
nodeId: 5,
name: 'toggle_test',
kind: 'boolean',
value: true,
options: {}
}
const lgWidget = toLiteGraphWidget(original) as IWidget
const roundTripped = fromLiteGraphWidget(lgWidget, original.nodeId)
expect(roundTripped.kind).toBe(original.kind)
expect(roundTripped.name).toBe(original.name)
expect(roundTripped.value).toBe(original.value)
expect(roundTripped.nodeId).toBe(original.nodeId)
})
it('preserves slider widget data', () => {
const original: WidgetModel = {
nodeId: 10,
name: 'slider_test',
kind: 'slider',
value: 0.5,
options: { min: 0, max: 1, step: 0.01 }
}
const lgWidget = toLiteGraphWidget(original) as IWidget
const roundTripped = fromLiteGraphWidget(lgWidget, original.nodeId)
expect(roundTripped.kind).toBe('slider')
expect(roundTripped.value).toBe(0.5)
if (roundTripped.kind === 'slider') {
expect(roundTripped.options.min).toBe(0)
expect(roundTripped.options.max).toBe(1)
expect(roundTripped.options.step).toBe(0.01)
}
})
it('preserves combo widget data', () => {
const original: WidgetModel = {
nodeId: 15,
name: 'combo_test',
kind: 'combo',
value: 'option_b',
options: { values: ['option_a', 'option_b', 'option_c'] }
}
const lgWidget = toLiteGraphWidget(original) as IWidget
const roundTripped = fromLiteGraphWidget(lgWidget, original.nodeId)
expect(roundTripped.kind).toBe('combo')
expect(roundTripped.value).toBe('option_b')
if (roundTripped.kind === 'combo') {
expect(roundTripped.options.values).toEqual([
'option_a',
'option_b',
'option_c'
])
}
})
it('preserves number widget precision', () => {
const original: WidgetModel = {
nodeId: 20,
name: 'number_test',
kind: 'number',
value: 42.5,
options: { min: 0, max: 100, step: 0.5, precision: 1 }
}
const lgWidget = toLiteGraphWidget(original) as IWidget
const roundTripped = fromLiteGraphWidget(lgWidget, original.nodeId)
expect(roundTripped.kind).toBe('number')
expect(roundTripped.value).toBe(42.5)
if (roundTripped.kind === 'number') {
expect(roundTripped.options.precision).toBe(1)
}
})
})
describe('toWidgetIdentity', () => {
it('creates identity from minimal widget', () => {
const fakeWidget = { name: 'seed' }
const result = toWidgetIdentity(fakeWidget, 42)
expect(result.nodeId).toBe(42)
expect(result.name).toBe('seed')
})
it('works with objects that have extra properties', () => {
const widgetLike = { name: 'cfg', value: 7.5, type: 'number' }
const result = toWidgetIdentity(widgetLike, 10)
expect(result.nodeId).toBe(10)
expect(result.name).toBe('cfg')
})
})
describe('createSlotWidgetRef', () => {
it('creates a frozen SlotWidgetRef', () => {
const ref = createSlotWidgetRef('my_widget')
expect(ref.name).toBe('my_widget')
expect(Object.isFrozen(ref)).toBe(true)
})
it('cannot be modified', () => {
const ref = createSlotWidgetRef('my_widget')
expect(() => {
;(ref as { name: string }).name = 'changed'
}).toThrow()
})
})
describe('createMutableSlotWidgetRef', () => {
it('creates a mutable SlotWidgetRef', () => {
const ref = createMutableSlotWidgetRef('my_widget')
expect(ref.name).toBe('my_widget')
expect(Object.isFrozen(ref)).toBe(false)
})
it('can be modified', () => {
const ref = createMutableSlotWidgetRef('my_widget')
ref.name = 'changed'
expect(ref.name).toBe('changed')
})
it('supports prototype chaining', () => {
const base = { value: 42 }
const ref = createMutableSlotWidgetRef('my_widget')
Object.setPrototypeOf(ref, base)
expect((ref as { name: string; value: number }).value).toBe(42)
expect(ref.name).toBe('my_widget')
})
})

View File

@@ -0,0 +1,464 @@
/**
* Adapters for converting between LiteGraph widget types and the new WidgetModel.
*
* @module widget/adapters
*/
import type { IWidget, TWidgetType } from '@/lib/litegraph/src/types/widgets'
import type { WidgetKind, WidgetModel } from './model'
import type { NodeId } from './primitives'
/**
* Maps legacy LiteGraph widget types to canonical WidgetKind.
* 'toggle' → 'boolean', 'text' → 'string' (handled specially for multiline)
*/
const LEGACY_TYPE_TO_KIND: Record<TWidgetType, WidgetKind> = {
toggle: 'boolean',
number: 'number',
slider: 'slider',
knob: 'knob',
combo: 'combo',
string: 'string',
text: 'textarea',
button: 'button',
custom: 'custom',
fileupload: 'fileupload',
color: 'color',
markdown: 'markdown',
image: 'image',
treeselect: 'treeselect',
multiselect: 'multiselect',
chart: 'chart',
galleria: 'galleria',
imagecompare: 'imagecompare',
selectbutton: 'selectbutton',
textarea: 'textarea',
asset: 'asset',
imagecrop: 'imagecrop',
boundingbox: 'boundingbox'
}
const KIND_TO_LEGACY_TYPE: Record<WidgetKind, TWidgetType> = {
boolean: 'toggle',
number: 'number',
slider: 'slider',
knob: 'knob',
combo: 'combo',
string: 'string',
textarea: 'textarea',
button: 'button',
custom: 'custom',
fileupload: 'fileupload',
color: 'color',
markdown: 'markdown',
image: 'image',
treeselect: 'treeselect',
multiselect: 'multiselect',
chart: 'chart',
galleria: 'galleria',
imagecompare: 'imagecompare',
selectbutton: 'selectbutton',
asset: 'asset',
imagecrop: 'imagecrop',
boundingbox: 'boundingbox'
}
export function legacyTypeToKind(type: TWidgetType): WidgetKind {
return LEGACY_TYPE_TO_KIND[type] ?? 'custom'
}
export function kindToLegacyType(kind: WidgetKind): TWidgetType {
return KIND_TO_LEGACY_TYPE[kind]
}
/**
* Converts a LiteGraph IWidget to a WidgetModel.
* Requires nodeId since IWidget does not carry it.
*/
export function fromLiteGraphWidget(
widget: IWidget,
nodeId: NodeId
): WidgetModel {
const kind = legacyTypeToKind(widget.type)
const base = {
nodeId,
name: widget.name,
hidden: widget.hidden,
disabled: widget.disabled,
advanced: widget.advanced,
promoted: widget.promoted
}
const baseOptions = {
label: widget.options?.property ?? widget.label,
tooltip: widget.tooltip,
readOnly: widget.options?.read_only,
serialize: widget.serialize
}
switch (kind) {
case 'boolean':
return {
...base,
kind: 'boolean',
value: Boolean(widget.value),
options: baseOptions
}
case 'number':
return {
...base,
kind: 'number',
value: Number(widget.value) || 0,
options: {
...baseOptions,
min: widget.options?.min,
max: widget.options?.max,
step: widget.options?.step2 ?? widget.options?.step,
precision: widget.options?.precision
}
}
case 'slider':
return {
...base,
kind: 'slider',
value: Number(widget.value) || 0,
options: {
...baseOptions,
min: widget.options?.min ?? 0,
max: widget.options?.max ?? 100,
step: widget.options?.step2 ?? widget.options?.step
}
}
case 'knob':
return {
...base,
kind: 'knob',
value: Number(widget.value) || 0,
options: {
...baseOptions,
min: widget.options?.min ?? 0,
max: widget.options?.max ?? 100,
step: widget.options?.step2 ?? widget.options?.step,
gradientStops: (widget.options as { gradient_stops?: string })
?.gradient_stops
}
}
case 'combo': {
const values = extractComboValues(widget.options?.values)
return {
...base,
kind: 'combo',
value: widget.value as string | number,
options: {
...baseOptions,
values,
getOptionLabel: widget.options?.getOptionLabel
}
}
}
case 'string':
return {
...base,
kind: 'string',
value: String(widget.value ?? ''),
options: {
...baseOptions,
multiline: widget.options?.multiline
}
}
case 'textarea':
return {
...base,
kind: 'textarea',
value: String(widget.value ?? ''),
options: {
...baseOptions,
multiline: true
}
}
case 'button':
return {
...base,
kind: 'button',
value: widget.value as string | undefined,
options: {
...baseOptions,
iconClass: widget.options?.iconClass
}
}
case 'fileupload':
return {
...base,
kind: 'fileupload',
value: String(widget.value ?? ''),
options: baseOptions
}
case 'color':
return {
...base,
kind: 'color',
value: String(widget.value ?? ''),
options: baseOptions
}
case 'markdown':
return {
...base,
kind: 'markdown',
value: String(widget.value ?? ''),
options: baseOptions
}
case 'image':
return {
...base,
kind: 'image',
value: String(widget.value ?? ''),
options: baseOptions
}
case 'asset':
return {
...base,
kind: 'asset',
value: String(widget.value ?? ''),
options: {
...baseOptions,
nodeType: widget.options?.nodeType
}
}
case 'treeselect':
return {
...base,
kind: 'treeselect',
value: widget.value as string | string[],
options: baseOptions
}
case 'multiselect':
return {
...base,
kind: 'multiselect',
value: (widget.value as string[]) ?? [],
options: baseOptions
}
case 'selectbutton': {
const values = (widget.options?.values as string[]) ?? []
return {
...base,
kind: 'selectbutton',
value: String(widget.value ?? ''),
options: { ...baseOptions, values }
}
}
case 'chart':
return {
...base,
kind: 'chart',
value: (widget.value as object) ?? {},
options: baseOptions
}
case 'galleria':
return {
...base,
kind: 'galleria',
value: (widget.value as string[]) ?? [],
options: baseOptions
}
case 'imagecompare':
return {
...base,
kind: 'imagecompare',
value: (widget.value as string[]) ?? [],
options: baseOptions
}
case 'imagecrop':
return {
...base,
kind: 'imagecrop',
value: (widget.value as {
x: number
y: number
width: number
height: number
}) ?? {
x: 0,
y: 0,
width: 0,
height: 0
},
options: baseOptions
}
case 'boundingbox':
return {
...base,
kind: 'boundingbox',
value: (widget.value as {
x: number
y: number
width: number
height: number
}) ?? {
x: 0,
y: 0,
width: 0,
height: 0
},
options: baseOptions
}
case 'custom':
default:
return {
...base,
kind: 'custom',
value: widget.value,
options: { ...baseOptions, ...widget.options }
}
}
}
function extractComboValues(values: unknown): Array<string | number> {
if (Array.isArray(values)) return values
if (values && typeof values === 'object') return Object.keys(values)
return []
}
/**
* Converts a WidgetModel back to a partial IWidget structure.
* Returns properties that can be spread onto an existing IWidget.
*/
export function toLiteGraphWidget(
model: WidgetModel
): Partial<IWidget> & { name: string; type: TWidgetType; value: unknown } {
const type = kindToLegacyType(model.kind)
const base = {
name: model.name,
type,
value: model.value,
hidden: model.hidden,
disabled: model.disabled,
advanced: model.advanced,
promoted: model.promoted
}
switch (model.kind) {
case 'number':
return {
...base,
options: {
min: model.options.min,
max: model.options.max,
step2: model.options.step,
precision: model.options.precision,
read_only: model.options.readOnly
}
} as Partial<IWidget> & {
name: string
type: TWidgetType
value: unknown
}
case 'slider':
return {
...base,
options: {
min: model.options.min,
max: model.options.max,
step2: model.options.step,
read_only: model.options.readOnly
}
} as Partial<IWidget> & {
name: string
type: TWidgetType
value: unknown
}
case 'knob':
return {
...base,
options: {
min: model.options.min,
max: model.options.max,
step2: model.options.step,
gradient_stops: model.options.gradientStops,
read_only: model.options.readOnly
}
} as Partial<IWidget> & {
name: string
type: TWidgetType
value: unknown
}
case 'combo':
return {
...base,
options: {
values: model.options.values,
getOptionLabel: model.options.getOptionLabel,
read_only: model.options.readOnly
}
} as Partial<IWidget> & {
name: string
type: TWidgetType
value: unknown
}
case 'string':
case 'textarea':
return {
...base,
options: {
multiline: model.options.multiline,
read_only: model.options.readOnly
}
} as Partial<IWidget> & {
name: string
type: TWidgetType
value: unknown
}
case 'selectbutton':
return {
...base,
options: {
values: model.options.values,
read_only: model.options.readOnly
}
} as Partial<IWidget> & {
name: string
type: TWidgetType
value: unknown
}
default:
return {
...base,
options: {
read_only: model.options?.readOnly
}
} as Partial<IWidget> & {
name: string
type: TWidgetType
value: unknown
}
}
}
/**
* Creates a WidgetIdentity from a minimal widget-like object.
* Used for adapting legacy fake widgets that only have { name: string }.
*/
export function toWidgetIdentity(
widget: { name: string },
nodeId: NodeId
): { nodeId: NodeId; name: string } {
return { nodeId, name: widget.name }
}
/**
* Creates a SlotWidgetRef from a widget name.
* Used to associate an input slot with a widget on the same node.
*/
export function createSlotWidgetRef(name: string): { readonly name: string } {
return Object.freeze({ name })
}
/**
* Creates a mutable slot widget ref for cases requiring prototype manipulation.
* @internal Used by SubgraphNode for prototype chain building.
*/
export function createMutableSlotWidgetRef(name: string): { name: string } {
return { name }
}

165
src/types/widget/cores.ts Normal file
View File

@@ -0,0 +1,165 @@
/**
* Per-kind widget core interfaces with discriminated `kind` field.
*
* @module widget/cores
*/
import type { Bounds } from '@/renderer/core/layout/types'
import type { WidgetIdentity } from './identity'
import type {
AssetWidgetOptions,
BoundingBoxWidgetOptions,
ButtonWidgetOptions,
ComboWidgetOptions,
CustomWidgetOptions,
FileUploadWidgetOptions,
ImageCropWidgetOptions,
KnobWidgetOptions,
MultiSelectWidgetOptions,
NumberWidgetOptions,
SelectButtonWidgetOptions,
SliderWidgetOptions,
StringWidgetOptions,
TreeSelectWidgetOptions,
WidgetOptionsBase
} from './options'
export interface WidgetCoreBase extends WidgetIdentity {
hidden?: boolean
disabled?: boolean
advanced?: boolean
promoted?: boolean
}
export interface AssetWidgetCore extends WidgetCoreBase {
kind: 'asset'
value: string
options: AssetWidgetOptions
}
export interface BooleanWidgetCore extends WidgetCoreBase {
kind: 'boolean'
value: boolean
options: WidgetOptionsBase
}
export interface BoundingBoxWidgetCore extends WidgetCoreBase {
kind: 'boundingbox'
value: Bounds
options: BoundingBoxWidgetOptions
}
export interface ButtonWidgetCore extends WidgetCoreBase {
kind: 'button'
value: string | undefined
options: ButtonWidgetOptions
}
export interface ChartWidgetCore extends WidgetCoreBase {
kind: 'chart'
value: object
options: WidgetOptionsBase
}
export interface ColorWidgetCore extends WidgetCoreBase {
kind: 'color'
value: string
options: WidgetOptionsBase
}
export interface ComboWidgetCore extends WidgetCoreBase {
kind: 'combo'
value: string | number
options: ComboWidgetOptions
}
export interface CustomWidgetCore extends WidgetCoreBase {
kind: 'custom'
value: unknown
options: CustomWidgetOptions
}
export interface FileUploadWidgetCore extends WidgetCoreBase {
kind: 'fileupload'
value: string
options: FileUploadWidgetOptions
}
export interface GalleriaWidgetCore extends WidgetCoreBase {
kind: 'galleria'
value: string[]
options: WidgetOptionsBase
}
export interface ImageWidgetCore extends WidgetCoreBase {
kind: 'image'
value: string
options: WidgetOptionsBase
}
export interface ImageCompareWidgetCore extends WidgetCoreBase {
kind: 'imagecompare'
value: string[]
options: WidgetOptionsBase
}
export interface ImageCropWidgetCore extends WidgetCoreBase {
kind: 'imagecrop'
value: Bounds
options: ImageCropWidgetOptions
}
export interface KnobWidgetCore extends WidgetCoreBase {
kind: 'knob'
value: number
options: KnobWidgetOptions
}
export interface MarkdownWidgetCore extends WidgetCoreBase {
kind: 'markdown'
value: string
options: WidgetOptionsBase
}
export interface MultiSelectWidgetCore extends WidgetCoreBase {
kind: 'multiselect'
value: string[]
options: MultiSelectWidgetOptions
}
export interface NumberWidgetCore extends WidgetCoreBase {
kind: 'number'
value: number
options: NumberWidgetOptions
}
export interface SelectButtonWidgetCore extends WidgetCoreBase {
kind: 'selectbutton'
value: string
options: SelectButtonWidgetOptions
}
export interface SliderWidgetCore extends WidgetCoreBase {
kind: 'slider'
value: number
options: SliderWidgetOptions
}
export interface StringWidgetCore extends WidgetCoreBase {
kind: 'string'
value: string
options: StringWidgetOptions
}
export interface TextareaWidgetCore extends WidgetCoreBase {
kind: 'textarea'
value: string
options: StringWidgetOptions
}
export interface TreeSelectWidgetCore extends WidgetCoreBase {
kind: 'treeselect'
value: string | string[]
options: TreeSelectWidgetOptions
}

View File

@@ -0,0 +1,38 @@
/**
* Widget identity types — the smallest shell in the matryoshka.
*
* @module widget/identity
*/
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
}
/**
* A reference from an input slot to a widget by name.
* Used on INodeInputSlot.widget to associate a slot with a widget on the same node.
* The nodeId is implicit (the slot's owning node).
*/
export interface SlotWidgetRef {
readonly name: string
}
export function getWidgetId(widget: WidgetIdentity): WidgetId {
return widgetId(widget.nodeId, widget.name)
}

82
src/types/widget/index.ts Normal file
View File

@@ -0,0 +1,82 @@
/**
* Canonical widget type definitions.
*
* @module widget
*/
export type { NodeId, WidgetId } from './primitives'
export { widgetId } from './primitives'
export type {
MutableWidgetIdentity,
SlotWidgetRef,
WidgetIdentity
} from './identity'
export { getWidgetId } from './identity'
export type {
AssetWidgetOptions,
BoundingBoxWidgetOptions,
ButtonWidgetOptions,
ComboWidgetOptions,
CustomWidgetOptions,
FileUploadWidgetOptions,
ImageCropWidgetOptions,
KnobWidgetOptions,
MultiSelectWidgetOptions,
NumberWidgetOptions,
SelectButtonWidgetOptions,
SliderWidgetOptions,
StringWidgetOptions,
TreeSelectWidgetOptions,
WidgetOptionsBase
} from './options'
export type {
AssetWidgetCore,
BooleanWidgetCore,
BoundingBoxWidgetCore,
ButtonWidgetCore,
ChartWidgetCore,
ColorWidgetCore,
ComboWidgetCore,
CustomWidgetCore,
FileUploadWidgetCore,
GalleriaWidgetCore,
ImageCompareWidgetCore,
ImageCropWidgetCore,
ImageWidgetCore,
KnobWidgetCore,
MarkdownWidgetCore,
MultiSelectWidgetCore,
NumberWidgetCore,
SelectButtonWidgetCore,
SliderWidgetCore,
StringWidgetCore,
TextareaWidgetCore,
TreeSelectWidgetCore,
WidgetCoreBase
} from './cores'
export type {
WidgetCoreFor,
WidgetKind,
WidgetModel,
WidgetOptionsFor,
WidgetValueFor
} from './model'
export { assertNever } from './model'
export type { WidgetRef } from './ref'
export type { WidgetLayoutState, WidgetRuntimeState } from './state'
export {
createMutableSlotWidgetRef,
createSlotWidgetRef,
fromLiteGraphWidget,
kindToLegacyType,
legacyTypeToKind,
toLiteGraphWidget,
toWidgetIdentity
} from './adapters'

75
src/types/widget/model.ts Normal file
View File

@@ -0,0 +1,75 @@
/**
* WidgetModel: the canonical discriminated union of all widget types.
*
* @module widget/model
*/
import type {
AssetWidgetCore,
BooleanWidgetCore,
BoundingBoxWidgetCore,
ButtonWidgetCore,
ChartWidgetCore,
ColorWidgetCore,
ComboWidgetCore,
CustomWidgetCore,
FileUploadWidgetCore,
GalleriaWidgetCore,
ImageCompareWidgetCore,
ImageCropWidgetCore,
ImageWidgetCore,
KnobWidgetCore,
MarkdownWidgetCore,
MultiSelectWidgetCore,
NumberWidgetCore,
SelectButtonWidgetCore,
SliderWidgetCore,
StringWidgetCore,
TextareaWidgetCore,
TreeSelectWidgetCore
} from './cores'
export type WidgetModel =
| AssetWidgetCore
| BooleanWidgetCore
| BoundingBoxWidgetCore
| ButtonWidgetCore
| ChartWidgetCore
| ColorWidgetCore
| ComboWidgetCore
| CustomWidgetCore
| FileUploadWidgetCore
| GalleriaWidgetCore
| ImageCompareWidgetCore
| ImageCropWidgetCore
| ImageWidgetCore
| KnobWidgetCore
| MarkdownWidgetCore
| MultiSelectWidgetCore
| NumberWidgetCore
| SelectButtonWidgetCore
| SliderWidgetCore
| StringWidgetCore
| TextareaWidgetCore
| TreeSelectWidgetCore
export type WidgetKind = WidgetModel['kind']
export type WidgetValueFor<K extends WidgetKind> = Extract<
WidgetModel,
{ kind: K }
>['value']
export type WidgetOptionsFor<K extends WidgetKind> = Extract<
WidgetModel,
{ kind: K }
>['options']
export type WidgetCoreFor<K extends WidgetKind> = Extract<
WidgetModel,
{ kind: K }
>
export function assertNever(x: never): never {
throw new Error(`Unexpected widget kind: ${x}`)
}

View File

@@ -0,0 +1,81 @@
/**
* Widget options types.
*
* @module widget/options
*/
import type { Bounds } from '@/renderer/core/layout/types'
export interface WidgetOptionsBase {
label?: string
tooltip?: string
readOnly?: boolean
serialize?: boolean
}
export interface NumberWidgetOptions extends WidgetOptionsBase {
min?: number
max?: number
step?: number
precision?: number
}
export interface SliderWidgetOptions extends WidgetOptionsBase {
min: number
max: number
step?: number
}
export interface KnobWidgetOptions extends WidgetOptionsBase {
min: number
max: number
step?: number
gradientStops?: string
}
export interface ComboWidgetOptions extends WidgetOptionsBase {
values: Array<string | number>
filterable?: boolean
getOptionLabel?: (value?: string | null) => string
}
export interface StringWidgetOptions extends WidgetOptionsBase {
multiline?: boolean
}
export interface ButtonWidgetOptions extends WidgetOptionsBase {
iconClass?: string
}
export interface FileUploadWidgetOptions extends WidgetOptionsBase {
accept?: string
}
export interface AssetWidgetOptions extends WidgetOptionsBase {
openModal?: (widget: unknown) => void
nodeType?: string
}
export interface TreeSelectWidgetOptions extends WidgetOptionsBase {
values?: Array<string | object>
}
export interface MultiSelectWidgetOptions extends WidgetOptionsBase {
values?: string[]
}
export interface SelectButtonWidgetOptions extends WidgetOptionsBase {
values: string[]
}
export interface ImageCropWidgetOptions extends WidgetOptionsBase {
aspectRatio?: number
}
export interface BoundingBoxWidgetOptions extends WidgetOptionsBase {
bounds?: Bounds
}
export interface CustomWidgetOptions extends WidgetOptionsBase {
[key: string]: unknown
}

View File

@@ -0,0 +1,18 @@
/**
* Primitive types for widget identification.
*
* @module widget/primitives
*/
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}` as WidgetId
}

12
src/types/widget/ref.ts Normal file
View File

@@ -0,0 +1,12 @@
/**
* WidgetRef: identity + kind for referencing widgets without full data.
*
* @module widget/ref
*/
import type { WidgetIdentity } from './identity'
import type { WidgetKind } from './model'
export interface WidgetRef extends WidgetIdentity {
kind: WidgetKind
}

37
src/types/widget/state.ts Normal file
View File

@@ -0,0 +1,37 @@
/**
* Widget state types for stores and DOM overlays.
*
* @module widget/state
*/
import type { MutableWidgetIdentity, WidgetIdentity } from './identity'
/**
* 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
label?: string
advanced?: boolean
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
zIndex: number
active: boolean
}

View File

@@ -0,0 +1,185 @@
import { describe, expect, it } from 'vitest'
import type {
BooleanWidgetCore,
SliderWidgetCore,
WidgetCoreFor,
WidgetIdentity,
WidgetKind,
WidgetModel,
WidgetOptionsFor,
WidgetValueFor
} from '.'
import { assertNever, getWidgetId, widgetId } from '.'
describe('widget primitives', () => {
describe('widgetId', () => {
it('creates id from nodeId and name', () => {
expect(widgetId(42, 'seed')).toBe('42:seed')
})
it('handles string names with special characters', () => {
expect(widgetId(1, 'my-widget')).toBe('1:my-widget')
expect(widgetId(1, 'my_widget')).toBe('1:my_widget')
expect(widgetId(1, 'my widget')).toBe('1:my widget')
})
it('handles zero nodeId', () => {
expect(widgetId(0, 'name')).toBe('0:name')
})
})
})
describe('widget identity', () => {
describe('getWidgetId', () => {
it('derives id from identity object', () => {
const identity: WidgetIdentity = { nodeId: 42, name: 'seed' }
expect(getWidgetId(identity)).toBe('42:seed')
})
it('works with any object implementing WidgetIdentity', () => {
const extended = { nodeId: 10, name: 'cfg', extra: 'ignored' }
expect(getWidgetId(extended)).toBe('10:cfg')
})
})
})
describe('widget model type utilities', () => {
it('WidgetKind derives all kinds from WidgetModel', () => {
const kinds: WidgetKind[] = [
'asset',
'boolean',
'boundingbox',
'button',
'chart',
'color',
'combo',
'custom',
'fileupload',
'galleria',
'image',
'imagecompare',
'imagecrop',
'knob',
'markdown',
'multiselect',
'number',
'selectbutton',
'slider',
'string',
'textarea',
'treeselect'
]
expect(kinds.length).toBe(22)
})
it('WidgetValueFor extracts correct value types', () => {
const boolValue: WidgetValueFor<'boolean'> = true
const sliderValue: WidgetValueFor<'slider'> = 0.5
const comboValue: WidgetValueFor<'combo'> = 'option1'
const comboNumValue: WidgetValueFor<'combo'> = 42
expect(boolValue).toBe(true)
expect(sliderValue).toBe(0.5)
expect(comboValue).toBe('option1')
expect(comboNumValue).toBe(42)
})
it('WidgetOptionsFor extracts correct options types', () => {
const sliderOptions: WidgetOptionsFor<'slider'> = { min: 0, max: 100 }
const comboOptions: WidgetOptionsFor<'combo'> = { values: ['a', 'b'] }
expect(sliderOptions.min).toBe(0)
expect(comboOptions.values).toEqual(['a', 'b'])
})
it('WidgetCoreFor extracts the full core type', () => {
const slider: WidgetCoreFor<'slider'> = {
nodeId: 1,
name: 'amount',
kind: 'slider',
value: 50,
options: { min: 0, max: 100 }
}
expect(slider.kind).toBe('slider')
expect(slider.value).toBe(50)
expect(slider.options.min).toBe(0)
})
})
describe('discriminated union narrowing', () => {
function getDefaultValue(widget: WidgetModel): unknown {
switch (widget.kind) {
case 'boolean':
return false
case 'slider':
return widget.options.min
case 'combo':
return widget.options.values[0]
case 'number':
return widget.options.min ?? 0
case 'string':
case 'textarea':
case 'markdown':
return ''
case 'color':
return '#000000'
case 'button':
return undefined
case 'fileupload':
case 'asset':
case 'image':
return ''
case 'chart':
return {}
case 'galleria':
case 'imagecompare':
case 'multiselect':
return []
case 'treeselect':
return ''
case 'selectbutton':
return widget.options.values[0]
case 'knob':
return widget.options.min
case 'imagecrop':
case 'boundingbox':
return { x: 0, y: 0, width: 0, height: 0 }
case 'custom':
return null
default:
return assertNever(widget)
}
}
it('narrows boolean widget correctly', () => {
const widget: BooleanWidgetCore = {
nodeId: 1,
name: 'enabled',
kind: 'boolean',
value: true,
options: {}
}
expect(getDefaultValue(widget)).toBe(false)
})
it('narrows slider widget and accesses options.min', () => {
const widget: SliderWidgetCore = {
nodeId: 1,
name: 'strength',
kind: 'slider',
value: 50,
options: { min: 10, max: 100 }
}
expect(getDefaultValue(widget)).toBe(10)
})
})
describe('assertNever', () => {
it('throws with unexpected value message', () => {
expect(() => assertNever('unexpected' as never)).toThrow(
'Unexpected widget kind: unexpected'
)
})
})