mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-20 14:30:41 +00:00
feat: add WidgetValueStore for centralized widget value management (#8594)
## Summary Implements Phase 1 of the **Vue-owns-truth** pattern for widget values. Widget values are now canonical in a Pinia store; `widget.value` delegates to the store while preserving full backward compatibility. ## Changes - **New store**: `src/stores/widgetValueStore.ts` - centralized widget value storage with `get/set/remove/removeNode` API - **BaseWidget integration**: `widget.value` getter/setter now delegates to store when widget is associated with a node - **LGraphNode wiring**: `addCustomWidget()` automatically calls `widget.setNodeId(this.id)` to wire widgets to their nodes - **Test fixes**: Added Pinia setup to test files that use widgets ## Why This foundation enables: - Vue components to reactively bind to widget values via `computed(() => store.get(...))` - Future Yjs/CRDT backing for real-time collaboration - Cleaner separation between Vue state and LiteGraph rendering ## Backward Compatibility | Extension Pattern | Status | |-------------------|--------| | `widget.value = x` | ✅ Works unchanged | | `node.widgets[i].value` | ✅ Works unchanged | | `widget.callback` | ✅ Still fires | | `node.onWidgetChanged` | ✅ Still fires | ## Testing - ✅ 4252 unit tests pass - ✅ Build succeeds ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-8594-feat-add-WidgetValueStore-for-centralized-widget-value-management-2fc6d73d36508160886fcb9f3ebd941e) by [Unito](https://www.unito.io) --------- Co-authored-by: Amp <amp@ampcode.com> Co-authored-by: github-actions <github-actions@github.com> Co-authored-by: GitHub Action <action@github.com>
This commit is contained in:
@@ -5,13 +5,6 @@ import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
|||||||
test.describe('Subgraph duplicate ID remapping', { tag: ['@subgraph'] }, () => {
|
test.describe('Subgraph duplicate ID remapping', { tag: ['@subgraph'] }, () => {
|
||||||
const WORKFLOW = 'subgraphs/subgraph-nested-duplicate-ids'
|
const WORKFLOW = 'subgraphs/subgraph-nested-duplicate-ids'
|
||||||
|
|
||||||
test.beforeEach(async ({ comfyPage }) => {
|
|
||||||
await comfyPage.settings.setSetting(
|
|
||||||
'Comfy.Graph.DeduplicateSubgraphNodeIds',
|
|
||||||
true
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
test('All node IDs are globally unique after loading', async ({
|
test('All node IDs are globally unique after loading', async ({
|
||||||
comfyPage
|
comfyPage
|
||||||
}) => {
|
}) => {
|
||||||
|
|||||||
@@ -61,7 +61,10 @@ test.describe('Subgraph Operations', { tag: ['@slow', '@subgraph'] }, () => {
|
|||||||
await subgraphNode.navigateIntoSubgraph()
|
await subgraphNode.navigateIntoSubgraph()
|
||||||
|
|
||||||
const initialCount = await getSubgraphSlotCount(comfyPage, 'inputs')
|
const initialCount = await getSubgraphSlotCount(comfyPage, 'inputs')
|
||||||
const vaeEncodeNode = await comfyPage.nodeOps.getNodeRefById('2')
|
const [vaeEncodeNode] = await comfyPage.nodeOps.getNodeRefsByType(
|
||||||
|
'VAEEncode',
|
||||||
|
true
|
||||||
|
)
|
||||||
|
|
||||||
await comfyPage.subgraph.connectFromInput(vaeEncodeNode, 0)
|
await comfyPage.subgraph.connectFromInput(vaeEncodeNode, 0)
|
||||||
await comfyPage.nextFrame()
|
await comfyPage.nextFrame()
|
||||||
@@ -77,7 +80,10 @@ test.describe('Subgraph Operations', { tag: ['@slow', '@subgraph'] }, () => {
|
|||||||
await subgraphNode.navigateIntoSubgraph()
|
await subgraphNode.navigateIntoSubgraph()
|
||||||
|
|
||||||
const initialCount = await getSubgraphSlotCount(comfyPage, 'outputs')
|
const initialCount = await getSubgraphSlotCount(comfyPage, 'outputs')
|
||||||
const vaeEncodeNode = await comfyPage.nodeOps.getNodeRefById('2')
|
const [vaeEncodeNode] = await comfyPage.nodeOps.getNodeRefsByType(
|
||||||
|
'VAEEncode',
|
||||||
|
true
|
||||||
|
)
|
||||||
|
|
||||||
await comfyPage.subgraph.connectToOutput(vaeEncodeNode, 0)
|
await comfyPage.subgraph.connectToOutput(vaeEncodeNode, 0)
|
||||||
await comfyPage.nextFrame()
|
await comfyPage.nextFrame()
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 79 KiB After Width: | Height: | Size: 79 KiB |
@@ -83,10 +83,7 @@ const favoriteNode = computed(() =>
|
|||||||
)
|
)
|
||||||
|
|
||||||
const widgetValue = computed({
|
const widgetValue = computed({
|
||||||
get: () => {
|
get: () => widget.value,
|
||||||
widget.vueTrack?.()
|
|
||||||
return widget.value
|
|
||||||
},
|
|
||||||
set: (newValue: string | number | boolean | object) => {
|
set: (newValue: string | number | boolean | object) => {
|
||||||
emit('update:widgetValue', newValue)
|
emit('update:widgetValue', newValue)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,49 +1,76 @@
|
|||||||
import { setActivePinia } from 'pinia'
|
import { setActivePinia } from 'pinia'
|
||||||
import { createTestingPinia } from '@pinia/testing'
|
import { createTestingPinia } from '@pinia/testing'
|
||||||
import { describe, expect, it, vi } from 'vitest'
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
import { nextTick, watch } from 'vue'
|
import { computed, nextTick, watch } from 'vue'
|
||||||
|
|
||||||
import { useGraphNodeManager } from '@/composables/graph/useGraphNodeManager'
|
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'
|
import { NodeSlotType } from '@/lib/litegraph/src/types/globalEnums'
|
||||||
|
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('Node Reactivity', () => {
|
describe('Node Reactivity', () => {
|
||||||
it('should trigger on callback', async () => {
|
beforeEach(() => {
|
||||||
const [node, , onReactivityUpdate] = createTestGraph()
|
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||||
|
|
||||||
node.widgets![0].callback!(2)
|
|
||||||
await nextTick()
|
|
||||||
expect(onReactivityUpdate).toHaveBeenCalledTimes(1)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should remain reactive after a connection is made', async () => {
|
function createTestGraph() {
|
||||||
const [node, graph, onReactivityUpdate] = 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', {
|
graph.trigger('node:slot-links:changed', {
|
||||||
nodeId: '1',
|
nodeId: String(node.id),
|
||||||
slotType: NodeSlotType.INPUT
|
slotType: NodeSlotType.INPUT
|
||||||
})
|
})
|
||||||
await nextTick()
|
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()
|
await nextTick()
|
||||||
expect(onReactivityUpdate).toHaveBeenCalledTimes(1)
|
|
||||||
|
expect(onValueChange).toHaveBeenCalledTimes(1)
|
||||||
|
expect(widgetValue.value).toBe(99)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
* Provides event-driven reactivity with performance optimizations
|
* Provides event-driven reactivity with performance optimizations
|
||||||
*/
|
*/
|
||||||
import { reactiveComputed } from '@vueuse/core'
|
import { reactiveComputed } from '@vueuse/core'
|
||||||
import { customRef, reactive, shallowReactive } from 'vue'
|
import { reactive, shallowReactive } from 'vue'
|
||||||
|
|
||||||
import { useChainCallback } from '@/composables/functional/useChainCallback'
|
import { useChainCallback } from '@/composables/functional/useChainCallback'
|
||||||
import { isProxyWidget } from '@/core/graph/subgraph/proxyWidget'
|
import { isProxyWidget } from '@/core/graph/subgraph/proxyWidget'
|
||||||
@@ -11,10 +11,7 @@ import type {
|
|||||||
INodeInputSlot,
|
INodeInputSlot,
|
||||||
INodeOutputSlot
|
INodeOutputSlot
|
||||||
} from '@/lib/litegraph/src/interfaces'
|
} from '@/lib/litegraph/src/interfaces'
|
||||||
import type {
|
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||||
IBaseWidget,
|
|
||||||
IWidgetOptions
|
|
||||||
} from '@/lib/litegraph/src/types/widgets'
|
|
||||||
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
|
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
|
||||||
import { LayoutSource } from '@/renderer/core/layout/types'
|
import { LayoutSource } from '@/renderer/core/layout/types'
|
||||||
import type { NodeId } from '@/renderer/core/layout/types'
|
import type { NodeId } from '@/renderer/core/layout/types'
|
||||||
@@ -41,19 +38,37 @@ export interface WidgetSlotMetadata {
|
|||||||
linked: boolean
|
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 {
|
export interface SafeWidgetData {
|
||||||
|
nodeId?: NodeId
|
||||||
name: string
|
name: string
|
||||||
type: string
|
type: string
|
||||||
value: WidgetValue
|
/** Callback to invoke when widget value changes (wraps LiteGraph callback + triggerDraw) */
|
||||||
borderStyle?: string
|
|
||||||
callback?: ((value: unknown) => void) | undefined
|
callback?: ((value: unknown) => void) | undefined
|
||||||
|
/** Control widget for seed randomization/increment/decrement */
|
||||||
controlWidget?: SafeControlWidget
|
controlWidget?: SafeControlWidget
|
||||||
|
/** Whether widget has custom layout size computation */
|
||||||
hasLayoutSize?: boolean
|
hasLayoutSize?: boolean
|
||||||
|
/** Whether widget is a DOM widget */
|
||||||
isDOMWidget?: boolean
|
isDOMWidget?: boolean
|
||||||
label?: string
|
/** Node type (for subgraph promoted widgets) */
|
||||||
nodeType?: string
|
nodeType?: string
|
||||||
options?: IWidgetOptions
|
/**
|
||||||
|
* Widget options needed for render decisions.
|
||||||
|
* Note: Most metadata should be accessed via widgetValueStore.getWidget().
|
||||||
|
*/
|
||||||
|
options?: {
|
||||||
|
canvasOnly?: boolean
|
||||||
|
advanced?: boolean
|
||||||
|
hidden?: boolean
|
||||||
|
read_only?: boolean
|
||||||
|
}
|
||||||
|
/** Input specification from node definition */
|
||||||
spec?: InputSpec
|
spec?: InputSpec
|
||||||
|
/** Input slot metadata (index and link status) */
|
||||||
slotMetadata?: WidgetSlotMetadata
|
slotMetadata?: WidgetSlotMetadata
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,23 +110,6 @@ export interface GraphNodeManager {
|
|||||||
cleanup(): void
|
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 {
|
function getControlWidget(widget: IBaseWidget): SafeControlWidget | undefined {
|
||||||
const cagWidget = widget.linkedWidgets?.find(
|
const cagWidget = widget.linkedWidgets?.find(
|
||||||
(w) => w.name == 'control_after_generate'
|
(w) => w.name == 'control_after_generate'
|
||||||
@@ -133,26 +131,18 @@ function getNodeType(node: LGraphNode, widget: IBaseWidget) {
|
|||||||
* Shared widget enhancements used by both safeWidgetMapper and Right Side Panel
|
* Shared widget enhancements used by both safeWidgetMapper and Right Side Panel
|
||||||
*/
|
*/
|
||||||
interface SharedWidgetEnhancements {
|
interface SharedWidgetEnhancements {
|
||||||
/** Reactive widget value that updates when the widget changes */
|
|
||||||
value: WidgetValue
|
|
||||||
/** Control widget for seed randomization/increment/decrement */
|
/** Control widget for seed randomization/increment/decrement */
|
||||||
controlWidget?: SafeControlWidget
|
controlWidget?: SafeControlWidget
|
||||||
/** Input specification from node definition */
|
/** Input specification from node definition */
|
||||||
spec?: InputSpec
|
spec?: InputSpec
|
||||||
/** Node type (for subgraph promoted widgets) */
|
/** Node type (for subgraph promoted widgets) */
|
||||||
nodeType?: string
|
nodeType?: string
|
||||||
/** Border style for promoted/advanced widgets */
|
|
||||||
borderStyle?: string
|
|
||||||
/** Widget label */
|
|
||||||
label?: string
|
|
||||||
/** Widget options */
|
|
||||||
options?: IWidgetOptions
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extracts common widget enhancements shared across different rendering contexts.
|
* Extracts common widget enhancements shared across different rendering contexts.
|
||||||
* This function centralizes the logic for extracting metadata and reactive values
|
* This function centralizes the logic for extracting metadata from widgets.
|
||||||
* from widgets, ensuring consistency between Nodes 2.0 and Right Side Panel.
|
* Note: Value and metadata (label, options, hidden, etc.) are accessed via widgetValueStore.
|
||||||
*/
|
*/
|
||||||
export function getSharedWidgetEnhancements(
|
export function getSharedWidgetEnhancements(
|
||||||
node: LGraphNode,
|
node: LGraphNode,
|
||||||
@@ -161,17 +151,9 @@ export function getSharedWidgetEnhancements(
|
|||||||
const nodeDefStore = useNodeDefStore()
|
const nodeDefStore = useNodeDefStore()
|
||||||
|
|
||||||
return {
|
return {
|
||||||
value: useReactiveWidgetValue(widget),
|
|
||||||
controlWidget: getControlWidget(widget),
|
controlWidget: getControlWidget(widget),
|
||||||
spec: nodeDefStore.getInputSpecForWidget(node, widget.name),
|
spec: nodeDefStore.getInputSpecForWidget(node, widget.name),
|
||||||
nodeType: getNodeType(node, widget),
|
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 as IWidgetOptions
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -212,7 +194,7 @@ function safeWidgetMapper(
|
|||||||
): (widget: IBaseWidget) => SafeWidgetData {
|
): (widget: IBaseWidget) => SafeWidgetData {
|
||||||
return function (widget) {
|
return function (widget) {
|
||||||
try {
|
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 sharedEnhancements = getSharedWidgetEnhancements(node, widget)
|
||||||
const slotInfo = slotMetadata.get(widget.name)
|
const slotInfo = slotMetadata.get(widget.name)
|
||||||
|
|
||||||
@@ -228,20 +210,41 @@ function safeWidgetMapper(
|
|||||||
node.widgets?.forEach((w) => w.triggerDraw?.())
|
node.widgets?.forEach((w) => w.triggerDraw?.())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Extract only render-critical options (canvasOnly, advanced, read_only)
|
||||||
|
const options = widget.options
|
||||||
|
? {
|
||||||
|
canvasOnly: widget.options.canvasOnly,
|
||||||
|
advanced: widget.advanced,
|
||||||
|
hidden: widget.options.hidden,
|
||||||
|
read_only: widget.options.read_only
|
||||||
|
}
|
||||||
|
: 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 {
|
return {
|
||||||
name: widget.name,
|
nodeId,
|
||||||
|
name,
|
||||||
type: widget.type,
|
type: widget.type,
|
||||||
...sharedEnhancements,
|
...sharedEnhancements,
|
||||||
callback,
|
callback,
|
||||||
hasLayoutSize: typeof widget.computeLayoutSize === 'function',
|
hasLayoutSize: typeof widget.computeLayoutSize === 'function',
|
||||||
isDOMWidget: isDOMWidget(widget),
|
isDOMWidget: isDOMWidget(widget),
|
||||||
|
options,
|
||||||
slotMetadata: slotInfo
|
slotMetadata: slotInfo
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return {
|
return {
|
||||||
name: widget.name || 'unknown',
|
name: widget.name || 'unknown',
|
||||||
type: widget.type || 'text',
|
type: widget.type || 'text'
|
||||||
value: undefined
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { registerProxyWidgets } from '@/core/graph/subgraph/proxyWidget'
|
||||||
import { promoteWidget } from '@/core/graph/subgraph/proxyWidgetUtils'
|
import { promoteWidget } from '@/core/graph/subgraph/proxyWidgetUtils'
|
||||||
@@ -43,6 +45,10 @@ function setupSubgraph(
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe('Subgraph proxyWidgets', () => {
|
describe('Subgraph proxyWidgets', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||||
|
})
|
||||||
|
|
||||||
test('Can add simple widget', () => {
|
test('Can add simple widget', () => {
|
||||||
const [subgraphNode, innerNodes] = setupSubgraph(1)
|
const [subgraphNode, innerNodes] = setupSubgraph(1)
|
||||||
innerNodes[0].addWidget('text', 'stringWidget', 'value', () => {})
|
innerNodes[0].addWidget('text', 'stringWidget', 'value', () => {})
|
||||||
|
|||||||
@@ -115,9 +115,11 @@ const onConfigure = function (
|
|||||||
if (isActiveGraph && w instanceof DOMWidgetImpl) setWidget(w)
|
if (isActiveGraph && w instanceof DOMWidgetImpl) setWidget(w)
|
||||||
return [w]
|
return [w]
|
||||||
})
|
})
|
||||||
this.widgets = this.widgets.filter(
|
this.widgets = this.widgets.filter((w) => {
|
||||||
(w) => !isProxyWidget(w) && !parsed.some(([, name]) => w.name === name)
|
if (isProxyWidget(w)) return false
|
||||||
)
|
const widgetName = w.name
|
||||||
|
return !parsed.some(([, name]) => widgetName === name)
|
||||||
|
})
|
||||||
this.widgets.push(...newWidgets)
|
this.widgets.push(...newWidgets)
|
||||||
|
|
||||||
canvasStore.canvas?.setDirty(true, true)
|
canvasStore.canvas?.setDirty(true, true)
|
||||||
@@ -152,6 +154,7 @@ function newProxyWidget(
|
|||||||
computedHeight: undefined,
|
computedHeight: undefined,
|
||||||
isProxyWidget: true,
|
isProxyWidget: true,
|
||||||
last_y: undefined,
|
last_y: undefined,
|
||||||
|
label: name,
|
||||||
name,
|
name,
|
||||||
node: subgraphNode,
|
node: subgraphNode,
|
||||||
onRemove: undefined,
|
onRemove: undefined,
|
||||||
|
|||||||
@@ -57,15 +57,20 @@ export function demoteWidget(
|
|||||||
widget.promoted = false
|
widget.promoted = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getWidgetName(w: IBaseWidget): string {
|
||||||
|
return isProxyWidget(w) ? w._overlay.widgetName : w.name
|
||||||
|
}
|
||||||
|
|
||||||
export function matchesWidgetItem([nodeId, widgetName]: [string, string]) {
|
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) {
|
export function matchesPropertyItem([n, w]: WidgetItem) {
|
||||||
return ([nodeId, widgetName]: [string, string]) =>
|
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] {
|
export function widgetItemToProperty([n, w]: WidgetItem): [string, string] {
|
||||||
return [`${n.id}`, w.name]
|
return [`${n.id}`, getWidgetName(w)]
|
||||||
}
|
}
|
||||||
|
|
||||||
function getParentNodes(): SubgraphNode[] {
|
function getParentNodes(): SubgraphNode[] {
|
||||||
|
|||||||
@@ -1412,7 +1412,7 @@ export class GroupNodeHandler {
|
|||||||
handlerGroupData.oldToNewWidgetMap[Number(n)]?.[w]
|
handlerGroupData.oldToNewWidgetMap[Number(n)]?.[w]
|
||||||
const widget = this.widgets.find((wg) => wg.name === widgetName)
|
const widget = this.widgets.find((wg) => wg.name === widgetName)
|
||||||
if (widget) {
|
if (widget) {
|
||||||
widget.type = 'hidden'
|
widget.hidden = true
|
||||||
widget.computeSize = () => [0, -4]
|
widget.computeSize = () => [0, -4]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,14 +19,14 @@ useExtensionService().registerExtension({
|
|||||||
|
|
||||||
const showValueWidget = ComfyWidgets['MARKDOWN'](
|
const showValueWidget = ComfyWidgets['MARKDOWN'](
|
||||||
this,
|
this,
|
||||||
'preview',
|
'preview_markdown',
|
||||||
['MARKDOWN', {}],
|
['MARKDOWN', {}],
|
||||||
app
|
app
|
||||||
).widget as DOMWidget<HTMLTextAreaElement, string>
|
).widget as DOMWidget<HTMLTextAreaElement, string>
|
||||||
|
|
||||||
const showValueWidgetPlain = ComfyWidgets['STRING'](
|
const showValueWidgetPlain = ComfyWidgets['STRING'](
|
||||||
this,
|
this,
|
||||||
'preview',
|
'preview_text',
|
||||||
['STRING', { multiline: true }],
|
['STRING', { multiline: true }],
|
||||||
app
|
app
|
||||||
).widget as DOMWidget<HTMLTextAreaElement, string>
|
).widget as DOMWidget<HTMLTextAreaElement, string>
|
||||||
@@ -48,6 +48,7 @@ useExtensionService().registerExtension({
|
|||||||
showValueWidgetPlain.options.hidden = value
|
showValueWidgetPlain.options.hidden = value
|
||||||
}
|
}
|
||||||
|
|
||||||
|
showValueWidget.label = 'Preview'
|
||||||
showValueWidget.hidden = true
|
showValueWidget.hidden = true
|
||||||
showValueWidget.options.hidden = true
|
showValueWidget.options.hidden = true
|
||||||
showValueWidget.options.read_only = true
|
showValueWidget.options.read_only = true
|
||||||
@@ -55,6 +56,7 @@ useExtensionService().registerExtension({
|
|||||||
showValueWidget.element.disabled = true
|
showValueWidget.element.disabled = true
|
||||||
showValueWidget.serialize = false
|
showValueWidget.serialize = false
|
||||||
|
|
||||||
|
showValueWidgetPlain.label = 'Preview'
|
||||||
showValueWidgetPlain.hidden = false
|
showValueWidgetPlain.hidden = false
|
||||||
showValueWidgetPlain.options.hidden = false
|
showValueWidgetPlain.options.hidden = false
|
||||||
showValueWidgetPlain.options.read_only = true
|
showValueWidgetPlain.options.read_only = true
|
||||||
@@ -71,7 +73,7 @@ useExtensionService().registerExtension({
|
|||||||
: onExecuted.apply(this, [message])
|
: onExecuted.apply(this, [message])
|
||||||
|
|
||||||
const previewWidgets =
|
const previewWidgets =
|
||||||
this.widgets?.filter((w) => w.name === 'preview') ?? []
|
this.widgets?.filter((w) => w.name.startsWith('preview_')) ?? []
|
||||||
|
|
||||||
for (const previewWidget of previewWidgets) {
|
for (const previewWidget of previewWidgets) {
|
||||||
const text = message.text ?? ''
|
const text = message.text ?? ''
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import { getNodeByLocatorId } from '@/utils/graphTraversalUtil'
|
|||||||
|
|
||||||
import { api } from '../../scripts/api'
|
import { api } from '../../scripts/api'
|
||||||
import { app } from '../../scripts/app'
|
import { app } from '../../scripts/app'
|
||||||
|
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||||
|
|
||||||
function updateUIWidget(
|
function updateUIWidget(
|
||||||
audioUIWidget: DOMWidget<HTMLAudioElement, string>,
|
audioUIWidget: DOMWidget<HTMLAudioElement, string>,
|
||||||
@@ -137,9 +138,16 @@ app.registerExtension({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let value = ''
|
audioUIWidget.options.getValue = () =>
|
||||||
audioUIWidget.options.getValue = () => value
|
(useWidgetValueStore().getWidget(node.id, inputName)
|
||||||
audioUIWidget.options.setValue = (v) => (value = v)
|
?.value as string) ?? ''
|
||||||
|
audioUIWidget.options.setValue = (v) => {
|
||||||
|
const widgetState = useWidgetValueStore().getWidget(
|
||||||
|
node.id,
|
||||||
|
inputName
|
||||||
|
)
|
||||||
|
if (widgetState) widgetState.value = v
|
||||||
|
}
|
||||||
|
|
||||||
return { widget: audioUIWidget }
|
return { widget: audioUIWidget }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
SUBGRAPH_INPUT_ID,
|
SUBGRAPH_INPUT_ID,
|
||||||
SUBGRAPH_OUTPUT_ID
|
SUBGRAPH_OUTPUT_ID
|
||||||
} from '@/lib/litegraph/src/constants'
|
} from '@/lib/litegraph/src/constants'
|
||||||
|
import { isNodeBindable } from '@/lib/litegraph/src/utils/type'
|
||||||
import type { UUID } from '@/lib/litegraph/src/utils/uuid'
|
import type { UUID } from '@/lib/litegraph/src/utils/uuid'
|
||||||
import { createUuidv4, zeroUuid } from '@/lib/litegraph/src/utils/uuid'
|
import { createUuidv4, zeroUuid } from '@/lib/litegraph/src/utils/uuid'
|
||||||
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
|
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
|
||||||
@@ -158,7 +159,6 @@ export class LGraph
|
|||||||
|
|
||||||
static STATUS_STOPPED = 1
|
static STATUS_STOPPED = 1
|
||||||
static STATUS_RUNNING = 2
|
static STATUS_RUNNING = 2
|
||||||
static deduplicateSubgraphIds = false
|
|
||||||
|
|
||||||
/** List of LGraph properties that are manually handled by {@link LGraph.configure}. */
|
/** List of LGraph properties that are manually handled by {@link LGraph.configure}. */
|
||||||
static readonly ConfigureProperties = new Set([
|
static readonly ConfigureProperties = new Set([
|
||||||
@@ -962,6 +962,14 @@ export class LGraph
|
|||||||
node.flags.ghost = true
|
node.flags.ghost = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 (isNodeBindable(widget)) widget.setNodeId(node.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
node.graph = this
|
node.graph = this
|
||||||
this._version++
|
this._version++
|
||||||
|
|
||||||
@@ -2437,7 +2445,7 @@ export class LGraph
|
|||||||
this.subgraphs.get(subgraph.id)?.configure(subgraph)
|
this.subgraphs.get(subgraph.id)?.configure(subgraph)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.isRootGraph && LGraph.deduplicateSubgraphIds) {
|
if (this.isRootGraph) {
|
||||||
const reservedNodeIds = nodesData
|
const reservedNodeIds = nodesData
|
||||||
?.map((n) => n.id)
|
?.map((n) => n.id)
|
||||||
.filter((id): id is number => typeof id === 'number')
|
.filter((id): id is number => typeof id === 'number')
|
||||||
|
|||||||
@@ -8347,7 +8347,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
|||||||
callback: LGraphCanvas.onMenuNodeCollapse
|
callback: LGraphCanvas.onMenuNodeCollapse
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if (node.widgets?.some((w) => w.advanced)) {
|
if (node.hasAdvancedWidgets()) {
|
||||||
options.push({
|
options.push({
|
||||||
content: node.showAdvanced ? 'Hide Advanced' : 'Show Advanced',
|
content: node.showAdvanced ? 'Hide Advanced' : 'Show Advanced',
|
||||||
callback: LGraphCanvas.onMenuToggleAdvanced
|
callback: LGraphCanvas.onMenuToggleAdvanced
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { createTestingPinia } from '@pinia/testing'
|
||||||
|
import { setActivePinia } from 'pinia'
|
||||||
import { afterEach, beforeEach, describe, expect, vi } from 'vitest'
|
import { afterEach, beforeEach, describe, expect, vi } from 'vitest'
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
@@ -42,6 +44,7 @@ describe('LGraphNode', () => {
|
|||||||
let origLiteGraph: typeof LiteGraph
|
let origLiteGraph: typeof LiteGraph
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||||
origLiteGraph = Object.assign({}, LiteGraph)
|
origLiteGraph = Object.assign({}, LiteGraph)
|
||||||
// @ts-expect-error Intended: Force remove an otherwise readonly non-optional property
|
// @ts-expect-error Intended: Force remove an otherwise readonly non-optional property
|
||||||
delete origLiteGraph.Classes
|
delete origLiteGraph.Classes
|
||||||
|
|||||||
@@ -10,7 +10,11 @@ import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMuta
|
|||||||
import { LayoutSource } from '@/renderer/core/layout/types'
|
import { LayoutSource } from '@/renderer/core/layout/types'
|
||||||
import { adjustColor } from '@/utils/colorUtil'
|
import { adjustColor } from '@/utils/colorUtil'
|
||||||
import type { ColorAdjustOptions } from '@/utils/colorUtil'
|
import type { ColorAdjustOptions } from '@/utils/colorUtil'
|
||||||
import { commonType, toClass } from '@/lib/litegraph/src/utils/type'
|
import {
|
||||||
|
commonType,
|
||||||
|
isNodeBindable,
|
||||||
|
toClass
|
||||||
|
} from '@/lib/litegraph/src/utils/type'
|
||||||
|
|
||||||
import { SUBGRAPH_OUTPUT_ID } from '@/lib/litegraph/src/constants'
|
import { SUBGRAPH_OUTPUT_ID } from '@/lib/litegraph/src/constants'
|
||||||
import type { DragAndScale } from './DragAndScale'
|
import type { DragAndScale } from './DragAndScale'
|
||||||
@@ -1957,6 +1961,14 @@ export class LGraphNode
|
|||||||
this.widgets ||= []
|
this.widgets ||= []
|
||||||
const widget = toConcreteWidget(custom_widget, this, false) ?? custom_widget
|
const widget = toConcreteWidget(custom_widget, this, false) ?? custom_widget
|
||||||
this.widgets.push(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 && isNodeBindable(widget)) {
|
||||||
|
widget.setNodeId(this.id)
|
||||||
|
}
|
||||||
|
|
||||||
return widget
|
return widget
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3499,7 +3511,7 @@ export class LGraphNode
|
|||||||
* Toggles advanced mode of the node, showing advanced widgets
|
* Toggles advanced mode of the node, showing advanced widgets
|
||||||
*/
|
*/
|
||||||
toggleAdvanced() {
|
toggleAdvanced() {
|
||||||
if (!this.widgets?.some((w) => w.advanced)) return
|
if (!this.hasAdvancedWidgets()) return
|
||||||
if (!this.graph) throw new NullGraphError()
|
if (!this.graph) throw new NullGraphError()
|
||||||
this.graph._version++
|
this.graph._version++
|
||||||
this.showAdvanced = !this.showAdvanced
|
this.showAdvanced = !this.showAdvanced
|
||||||
@@ -3877,6 +3889,21 @@ export class LGraphNode
|
|||||||
return !isHidden
|
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() {
|
updateComputedDisabled() {
|
||||||
if (!this.widgets) return
|
if (!this.widgets) return
|
||||||
for (const widget of this.widgets)
|
for (const widget of this.widgets)
|
||||||
@@ -4087,7 +4114,7 @@ export class LGraphNode
|
|||||||
w: IBaseWidget
|
w: IBaseWidget
|
||||||
}[] = []
|
}[] = []
|
||||||
|
|
||||||
const visibleWidgets = this.widgets.filter((w) => !w.hidden)
|
const visibleWidgets = this.getLayoutWidgets()
|
||||||
|
|
||||||
for (const w of visibleWidgets) {
|
for (const w of visibleWidgets) {
|
||||||
if (w.computeSize) {
|
if (w.computeSize) {
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { createTestingPinia } from '@pinia/testing'
|
||||||
|
import { setActivePinia } from 'pinia'
|
||||||
import { beforeEach, describe, expect, it } from 'vitest'
|
import { beforeEach, describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||||
@@ -8,6 +10,7 @@ describe('LGraphNode widget ordering', () => {
|
|||||||
let node: LGraphNode
|
let node: LGraphNode
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||||
node = new LGraphNode('TestNode')
|
node = new LGraphNode('TestNode')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,19 @@
|
|||||||
import type { Bounds } from '@/renderer/core/layout/types'
|
import type { Bounds } from '@/renderer/core/layout/types'
|
||||||
|
|
||||||
import type { CanvasColour, Point, RequiredProps, Size } from '../interfaces'
|
import type { CanvasColour, Point, RequiredProps, Size } from '../interfaces'
|
||||||
import type { CanvasPointer, LGraphCanvas, LGraphNode } from '../litegraph'
|
import type {
|
||||||
|
CanvasPointer,
|
||||||
|
LGraphCanvas,
|
||||||
|
LGraphNode,
|
||||||
|
NodeId
|
||||||
|
} from '../litegraph'
|
||||||
import type { CanvasPointerEvent } from './events'
|
import type { CanvasPointerEvent } from './events'
|
||||||
|
|
||||||
export interface IWidgetOptions<TValues = unknown[]> {
|
export interface NodeBindable {
|
||||||
|
setNodeId(nodeId: NodeId): void
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IWidgetOptions<TValues = unknown> {
|
||||||
on?: string
|
on?: string
|
||||||
off?: string
|
off?: string
|
||||||
max?: number
|
max?: number
|
||||||
@@ -301,7 +310,7 @@ export type TWidgetValue = IWidget['value']
|
|||||||
export interface IBaseWidget<
|
export interface IBaseWidget<
|
||||||
TValue = boolean | number | string | object | undefined,
|
TValue = boolean | number | string | object | undefined,
|
||||||
TType extends string = string,
|
TType extends string = string,
|
||||||
TOptions extends IWidgetOptions<unknown> = IWidgetOptions<unknown>
|
TOptions extends IWidgetOptions = IWidgetOptions
|
||||||
> {
|
> {
|
||||||
[symbol: symbol]: boolean
|
[symbol: symbol]: boolean
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { without } from 'es-toolkit'
|
import { without } from 'es-toolkit'
|
||||||
|
|
||||||
import type { IColorable, ISlotType } from '@/lib/litegraph/src/interfaces'
|
import type { IColorable, ISlotType } from '@/lib/litegraph/src/interfaces'
|
||||||
|
import type { NodeBindable } from '@/lib/litegraph/src/types/widgets'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Converts a plain object to a class instance if it is not already an instance of the class.
|
* Converts a plain object to a class instance if it is not already an instance of the class.
|
||||||
@@ -29,6 +30,15 @@ export function isColorable(obj: unknown): obj is IColorable {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isNodeBindable(widget: unknown): widget is NodeBindable {
|
||||||
|
return (
|
||||||
|
typeof widget === 'object' &&
|
||||||
|
widget !== null &&
|
||||||
|
'setNodeId' in widget &&
|
||||||
|
typeof widget.setNodeId === 'function'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export function commonType(...types: ISlotType[]): ISlotType | undefined {
|
export function commonType(...types: ISlotType[]): ISlotType | undefined {
|
||||||
if (!isStrings(types)) return undefined
|
if (!isStrings(types)) return undefined
|
||||||
|
|
||||||
|
|||||||
193
src/lib/litegraph/src/widgets/BaseWidget.test.ts
Normal file
193
src/lib/litegraph/src/widgets/BaseWidget.test.ts
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
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?.disabled).toBe(true)
|
||||||
|
expect(state?.promoted).toBe(true)
|
||||||
|
|
||||||
|
expect(widget.hidden).toBe(true)
|
||||||
|
expect(widget.advanced).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?.disabled).toBe(true)
|
||||||
|
expect(state?.promoted).toBe(true)
|
||||||
|
expect(state?.options).toEqual({ min: 0, max: 100 })
|
||||||
|
|
||||||
|
expect(widget.hidden).toBe(true)
|
||||||
|
expect(widget.advanced).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
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?.disabled).toBe(false)
|
||||||
|
expect(state?.promoted).toBe(false)
|
||||||
|
expect(state?.label).toBeUndefined()
|
||||||
|
|
||||||
|
expect(widget.hidden).toBeUndefined()
|
||||||
|
expect(widget.advanced).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.disabled = undefined
|
||||||
|
|
||||||
|
const state = store.getWidget(1, 'testWidget')
|
||||||
|
expect(state?.disabled).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -2,6 +2,7 @@ import { t } from '@/i18n'
|
|||||||
import { drawTextInArea } from '@/lib/litegraph/src/draw'
|
import { drawTextInArea } from '@/lib/litegraph/src/draw'
|
||||||
import { Rectangle } from '@/lib/litegraph/src/infrastructure/Rectangle'
|
import { Rectangle } from '@/lib/litegraph/src/infrastructure/Rectangle'
|
||||||
import type { Point } from '@/lib/litegraph/src/interfaces'
|
import type { Point } from '@/lib/litegraph/src/interfaces'
|
||||||
|
import type { NodeId } from '@/lib/litegraph/src/LGraphNode'
|
||||||
import type {
|
import type {
|
||||||
CanvasPointer,
|
CanvasPointer,
|
||||||
LGraphCanvas,
|
LGraphCanvas,
|
||||||
@@ -10,7 +11,13 @@ import type {
|
|||||||
} from '@/lib/litegraph/src/litegraph'
|
} from '@/lib/litegraph/src/litegraph'
|
||||||
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||||
import type { CanvasPointerEvent } from '@/lib/litegraph/src/types/events'
|
import type { CanvasPointerEvent } from '@/lib/litegraph/src/types/events'
|
||||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
import type {
|
||||||
|
IBaseWidget,
|
||||||
|
NodeBindable,
|
||||||
|
TWidgetType
|
||||||
|
} from '@/lib/litegraph/src/types/widgets'
|
||||||
|
import type { WidgetState } from '@/stores/widgetValueStore'
|
||||||
|
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||||
|
|
||||||
export interface DrawWidgetOptions {
|
export interface DrawWidgetOptions {
|
||||||
/** The width of the node where this widget will be displayed. */
|
/** The width of the node where this widget will be displayed. */
|
||||||
@@ -34,9 +41,9 @@ export interface WidgetEventOptions {
|
|||||||
canvas: LGraphCanvas
|
canvas: LGraphCanvas
|
||||||
}
|
}
|
||||||
|
|
||||||
export abstract class BaseWidget<
|
export abstract class BaseWidget<TWidget extends IBaseWidget = IBaseWidget>
|
||||||
TWidget extends IBaseWidget = IBaseWidget
|
implements IBaseWidget, NodeBindable
|
||||||
> implements IBaseWidget {
|
{
|
||||||
/** From node edge to widget edge */
|
/** From node edge to widget edge */
|
||||||
static margin = 15
|
static margin = 15
|
||||||
/** From widget edge to tip of arrow button */
|
/** From widget edge to tip of arrow button */
|
||||||
@@ -66,17 +73,39 @@ export abstract class BaseWidget<
|
|||||||
linkedWidgets?: IBaseWidget[]
|
linkedWidgets?: IBaseWidget[]
|
||||||
name: string
|
name: string
|
||||||
options: TWidget['options']
|
options: TWidget['options']
|
||||||
label?: string
|
|
||||||
type: TWidget['type']
|
type: TWidget['type']
|
||||||
y: number = 0
|
y: number = 0
|
||||||
last_y?: number
|
last_y?: number
|
||||||
width?: number
|
width?: number
|
||||||
disabled?: boolean
|
|
||||||
computedDisabled?: boolean
|
computedDisabled?: boolean
|
||||||
|
tooltip?: string
|
||||||
|
|
||||||
|
private _state: Omit<WidgetState, 'nodeId'> &
|
||||||
|
Partial<Pick<WidgetState, 'nodeId'>>
|
||||||
|
|
||||||
|
get label(): string | undefined {
|
||||||
|
return this._state.label
|
||||||
|
}
|
||||||
|
set label(value: string | undefined) {
|
||||||
|
this._state.label = value
|
||||||
|
}
|
||||||
|
|
||||||
hidden?: boolean
|
hidden?: boolean
|
||||||
advanced?: boolean
|
advanced?: boolean
|
||||||
promoted?: boolean
|
|
||||||
tooltip?: string
|
get disabled(): boolean | undefined {
|
||||||
|
return this._state.disabled
|
||||||
|
}
|
||||||
|
set disabled(value: boolean | undefined) {
|
||||||
|
this._state.disabled = value ?? false
|
||||||
|
}
|
||||||
|
|
||||||
|
get promoted(): boolean | undefined {
|
||||||
|
return this._state.promoted
|
||||||
|
}
|
||||||
|
set promoted(value: boolean | undefined) {
|
||||||
|
this._state.promoted = value ?? false
|
||||||
|
}
|
||||||
element?: HTMLElement
|
element?: HTMLElement
|
||||||
callback?(
|
callback?(
|
||||||
value: TWidget['value'],
|
value: TWidget['value'],
|
||||||
@@ -97,13 +126,22 @@ export abstract class BaseWidget<
|
|||||||
canvas: LGraphCanvas
|
canvas: LGraphCanvas
|
||||||
): boolean
|
): boolean
|
||||||
|
|
||||||
private _value?: TWidget['value']
|
|
||||||
get 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 = useWidgetValueStore().registerWidget({
|
||||||
|
...this._state,
|
||||||
|
nodeId
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
constructor(widget: TWidget & { node: LGraphNode })
|
constructor(widget: TWidget & { node: LGraphNode })
|
||||||
@@ -141,12 +179,26 @@ export abstract class BaseWidget<
|
|||||||
displayValue,
|
displayValue,
|
||||||
// @ts-expect-error Prevent naming conflicts with custom nodes.
|
// @ts-expect-error Prevent naming conflicts with custom nodes.
|
||||||
labelBaseline,
|
labelBaseline,
|
||||||
|
label,
|
||||||
|
disabled,
|
||||||
promoted,
|
promoted,
|
||||||
|
value,
|
||||||
linkedWidgets,
|
linkedWidgets,
|
||||||
...safeValues
|
...safeValues
|
||||||
} = widget
|
} = widget
|
||||||
|
|
||||||
Object.assign(this, safeValues)
|
Object.assign(this, safeValues)
|
||||||
|
|
||||||
|
this._state = {
|
||||||
|
name: this.name,
|
||||||
|
type: this.type as TWidgetType,
|
||||||
|
value,
|
||||||
|
label,
|
||||||
|
disabled: disabled ?? false,
|
||||||
|
promoted: promoted ?? false,
|
||||||
|
serialize: this.serialize,
|
||||||
|
options: this.options
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
get outline_color() {
|
get outline_color() {
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { watchEffect } from 'vue'
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
CanvasPointer,
|
CanvasPointer,
|
||||||
LGraph,
|
|
||||||
LGraphNode,
|
LGraphNode,
|
||||||
LiteGraph
|
LiteGraph
|
||||||
} from '@/lib/litegraph/src/litegraph'
|
} from '@/lib/litegraph/src/litegraph'
|
||||||
@@ -163,10 +162,4 @@ export const useLitegraphSettings = () => {
|
|||||||
'Comfy.EnableWorkflowViewRestore'
|
'Comfy.EnableWorkflowViewRestore'
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
watchEffect(() => {
|
|
||||||
LGraph.deduplicateSubgraphIds = settingStore.get(
|
|
||||||
'Comfy.Graph.DeduplicateSubgraphNodeIds'
|
|
||||||
)
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1211,7 +1211,8 @@ export const CORE_SETTINGS: SettingParams[] = [
|
|||||||
tooltip:
|
tooltip:
|
||||||
'Automatically reassign duplicate node IDs in subgraphs when loading a workflow.',
|
'Automatically reassign duplicate node IDs in subgraphs when loading a workflow.',
|
||||||
type: 'boolean',
|
type: 'boolean',
|
||||||
defaultValue: false,
|
deprecated: true,
|
||||||
|
defaultValue: true,
|
||||||
experimental: true,
|
experimental: true,
|
||||||
versionAdded: '1.40.0'
|
versionAdded: '1.40.0'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ const nodeData = computed<VueNodeData>(() => {
|
|||||||
const widgets = Object.entries(nodeDef.inputs || {})
|
const widgets = Object.entries(nodeDef.inputs || {})
|
||||||
.filter(([_, input]) => widgetStore.inputIsWidget(input))
|
.filter(([_, input]) => widgetStore.inputIsWidget(input))
|
||||||
.map(([name, input]) => ({
|
.map(([name, input]) => ({
|
||||||
|
nodeId: '-1',
|
||||||
name,
|
name,
|
||||||
type: input.widgetType || input.type,
|
type: input.widgetType || input.type,
|
||||||
value:
|
value:
|
||||||
|
|||||||
@@ -108,6 +108,7 @@ import NodeBadge from '@/renderer/extensions/vueNodes/components/NodeBadge.vue'
|
|||||||
import { useNodeTooltips } from '@/renderer/extensions/vueNodes/composables/useNodeTooltips'
|
import { useNodeTooltips } from '@/renderer/extensions/vueNodes/composables/useNodeTooltips'
|
||||||
import { applyLightThemeColor } from '@/renderer/extensions/vueNodes/utils/nodeStyleUtils'
|
import { applyLightThemeColor } from '@/renderer/extensions/vueNodes/utils/nodeStyleUtils'
|
||||||
import { app } from '@/scripts/app'
|
import { app } from '@/scripts/app'
|
||||||
|
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||||
import {
|
import {
|
||||||
getLocatorIdFromNodeData,
|
getLocatorIdFromNodeData,
|
||||||
getNodeByLocatorId
|
getNodeByLocatorId
|
||||||
@@ -214,12 +215,14 @@ const nodeBadges = computed<NodeBadgeProps[]>(() => {
|
|||||||
|
|
||||||
// For dynamic pricing, also track widget values and input connections
|
// For dynamic pricing, also track widget values and input connections
|
||||||
if (isDynamicPricing.value) {
|
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
|
const relevantNames = relevantPricingWidgets.value
|
||||||
if (relevantNames.length > 0) {
|
const widgetStore = useWidgetValueStore()
|
||||||
nodeData?.widgets?.forEach((w) => {
|
if (relevantNames.length > 0 && nodeData?.id != null) {
|
||||||
if (relevantNames.includes(w.name)) w.value
|
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
|
// Access input connections for regular inputs
|
||||||
const inputNames = relevantInputNames.value
|
const inputNames = relevantInputNames.value
|
||||||
|
|||||||
@@ -13,15 +13,12 @@ describe('NodeWidgets', () => {
|
|||||||
const createMockWidget = (
|
const createMockWidget = (
|
||||||
overrides: Partial<SafeWidgetData> = {}
|
overrides: Partial<SafeWidgetData> = {}
|
||||||
): SafeWidgetData => ({
|
): SafeWidgetData => ({
|
||||||
|
nodeId: 'test_node',
|
||||||
name: 'test_widget',
|
name: 'test_widget',
|
||||||
type: 'combo',
|
type: 'combo',
|
||||||
value: 'test_value',
|
options: undefined,
|
||||||
options: {
|
|
||||||
values: ['option1', 'option2']
|
|
||||||
},
|
|
||||||
callback: undefined,
|
callback: undefined,
|
||||||
spec: undefined,
|
spec: undefined,
|
||||||
label: undefined,
|
|
||||||
isDOMWidget: false,
|
isDOMWidget: false,
|
||||||
slotMetadata: undefined,
|
slotMetadata: undefined,
|
||||||
...overrides
|
...overrides
|
||||||
|
|||||||
@@ -26,10 +26,7 @@
|
|||||||
:key="`widget-${index}-${widget.name}`"
|
:key="`widget-${index}-${widget.name}`"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
v-if="
|
v-if="!widget.hidden && (!widget.advanced || showAdvanced)"
|
||||||
!widget.simplified.options?.hidden &&
|
|
||||||
(!widget.simplified.options?.advanced || showAdvanced)
|
|
||||||
"
|
|
||||||
class="lg-node-widget group col-span-full grid grid-cols-subgrid items-stretch"
|
class="lg-node-widget group col-span-full grid grid-cols-subgrid items-stretch"
|
||||||
:data-widget-name="widget.name"
|
:data-widget-name="widget.name"
|
||||||
>
|
>
|
||||||
@@ -94,6 +91,10 @@ import {
|
|||||||
shouldExpand,
|
shouldExpand,
|
||||||
shouldRenderAsVue
|
shouldRenderAsVue
|
||||||
} from '@/renderer/extensions/vueNodes/widgets/registry/widgetRegistry'
|
} from '@/renderer/extensions/vueNodes/widgets/registry/widgetRegistry'
|
||||||
|
import {
|
||||||
|
stripGraphPrefix,
|
||||||
|
useWidgetValueStore
|
||||||
|
} from '@/stores/widgetValueStore'
|
||||||
import type { SimplifiedWidget, WidgetValue } from '@/types/simplifiedWidget'
|
import type { SimplifiedWidget, WidgetValue } from '@/types/simplifiedWidget'
|
||||||
import { cn } from '@/utils/tailwindUtil'
|
import { cn } from '@/utils/tailwindUtil'
|
||||||
|
|
||||||
@@ -142,6 +143,7 @@ const showAdvanced = computed(
|
|||||||
const { getWidgetTooltip, createTooltipConfig } = useNodeTooltips(
|
const { getWidgetTooltip, createTooltipConfig } = useNodeTooltips(
|
||||||
nodeType.value
|
nodeType.value
|
||||||
)
|
)
|
||||||
|
const widgetValueStore = useWidgetValueStore()
|
||||||
|
|
||||||
interface ProcessedWidget {
|
interface ProcessedWidget {
|
||||||
name: string
|
name: string
|
||||||
@@ -152,11 +154,15 @@ interface ProcessedWidget {
|
|||||||
updateHandler: (value: WidgetValue) => void
|
updateHandler: (value: WidgetValue) => void
|
||||||
tooltipConfig: TooltipOptions
|
tooltipConfig: TooltipOptions
|
||||||
slotMetadata?: WidgetSlotMetadata
|
slotMetadata?: WidgetSlotMetadata
|
||||||
|
hidden: boolean
|
||||||
|
advanced: boolean
|
||||||
|
hasLayoutSize: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const processedWidgets = computed((): ProcessedWidget[] => {
|
const processedWidgets = computed((): ProcessedWidget[] => {
|
||||||
if (!nodeData?.widgets) return []
|
if (!nodeData?.widgets) return []
|
||||||
|
|
||||||
|
const nodeId = nodeData.id
|
||||||
const { widgets } = nodeData
|
const { widgets } = nodeData
|
||||||
const result: ProcessedWidget[] = []
|
const result: ProcessedWidget[] = []
|
||||||
|
|
||||||
@@ -167,33 +173,47 @@ const processedWidgets = computed((): ProcessedWidget[] => {
|
|||||||
getComponent(widget.type) ||
|
getComponent(widget.type) ||
|
||||||
(widget.isDOMWidget ? WidgetDOM : WidgetLegacy)
|
(widget.isDOMWidget ? WidgetDOM : WidgetLegacy)
|
||||||
|
|
||||||
const { slotMetadata, options } = widget
|
const { slotMetadata } = widget
|
||||||
|
|
||||||
// Core feature: Disable Vue widgets when their input slots are connected
|
// Get metadata from store (registered during BaseWidget.setNodeId)
|
||||||
// This prevents conflicting input sources - when a slot is linked to another
|
const bareWidgetId = stripGraphPrefix(widget.nodeId ?? nodeId)
|
||||||
// node's output, the widget should be read-only to avoid data conflicts
|
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
|
const widgetOptions = slotMetadata?.linked
|
||||||
? { ...options, disabled: true }
|
? { ...storeOptions, disabled: true }
|
||||||
: options
|
: storeOptions
|
||||||
|
|
||||||
|
// Derive border style from store metadata
|
||||||
|
const borderStyle =
|
||||||
|
widgetState?.promoted && String(widgetState?.nodeId) === String(nodeId)
|
||||||
|
? 'ring ring-component-node-widget-promoted'
|
||||||
|
: widget.options?.advanced
|
||||||
|
? 'ring ring-component-node-widget-advanced'
|
||||||
|
: undefined
|
||||||
|
|
||||||
const simplified: SimplifiedWidget = {
|
const simplified: SimplifiedWidget = {
|
||||||
name: widget.name,
|
name: widget.name,
|
||||||
type: widget.type,
|
type: widget.type,
|
||||||
value: widget.value,
|
value,
|
||||||
borderStyle: widget.borderStyle,
|
borderStyle,
|
||||||
callback: widget.callback,
|
callback: widget.callback,
|
||||||
controlWidget: widget.controlWidget,
|
controlWidget: widget.controlWidget,
|
||||||
label: widget.label,
|
label: widgetState?.label,
|
||||||
nodeType: widget.nodeType,
|
nodeType: widget.nodeType,
|
||||||
options: widgetOptions,
|
options: widgetOptions,
|
||||||
spec: widget.spec
|
spec: widget.spec
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateHandler(value: WidgetValue) {
|
function updateHandler(newValue: WidgetValue) {
|
||||||
// Update the widget value directly
|
// Update value in store
|
||||||
widget.value = value
|
if (widgetState) widgetState.value = newValue
|
||||||
|
// Invoke LiteGraph callback wrapper (handles triggerDraw, etc.)
|
||||||
widget.callback?.(value)
|
widget.callback?.(newValue)
|
||||||
}
|
}
|
||||||
|
|
||||||
const tooltipText = getWidgetTooltip(widget)
|
const tooltipText = getWidgetTooltip(widget)
|
||||||
@@ -204,10 +224,13 @@ const processedWidgets = computed((): ProcessedWidget[] => {
|
|||||||
type: widget.type,
|
type: widget.type,
|
||||||
vueComponent,
|
vueComponent,
|
||||||
simplified,
|
simplified,
|
||||||
value: widget.value,
|
value,
|
||||||
updateHandler,
|
updateHandler,
|
||||||
tooltipConfig,
|
tooltipConfig,
|
||||||
slotMetadata
|
slotMetadata,
|
||||||
|
hidden: widget.options?.hidden ?? false,
|
||||||
|
advanced: widget.options?.advanced ?? false,
|
||||||
|
hasLayoutSize: widget.hasLayoutSize ?? false
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -215,15 +238,9 @@ const processedWidgets = computed((): ProcessedWidget[] => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const gridTemplateRows = computed((): string => {
|
const gridTemplateRows = computed((): string => {
|
||||||
if (!nodeData?.widgets) return ''
|
// Use processedWidgets directly since it already has store-based hidden/advanced
|
||||||
const processedNames = new Set(toValue(processedWidgets).map((w) => w.name))
|
return toValue(processedWidgets)
|
||||||
return nodeData.widgets
|
.filter((w) => !w.hidden && (!w.advanced || showAdvanced.value))
|
||||||
.filter(
|
|
||||||
(w) =>
|
|
||||||
processedNames.has(w.name) &&
|
|
||||||
!w.options?.hidden &&
|
|
||||||
(!w.options?.advanced || showAdvanced.value)
|
|
||||||
)
|
|
||||||
.map((w) =>
|
.map((w) =>
|
||||||
shouldExpand(w.type) || w.hasLayoutSize ? 'auto' : 'min-content'
|
shouldExpand(w.type) || w.hasLayoutSize ? 'auto' : 'min-content'
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -53,7 +53,8 @@ const props = defineProps<Props>()
|
|||||||
|
|
||||||
const modelValue = defineModel<string | undefined>({
|
const modelValue = defineModel<string | undefined>({
|
||||||
default(props: Props) {
|
default(props: Props) {
|
||||||
return props.widget.options?.values?.[0] ?? ''
|
const values = props.widget.options?.values
|
||||||
|
return (Array.isArray(values) ? values[0] : undefined) ?? ''
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -59,7 +59,8 @@ provide(
|
|||||||
|
|
||||||
const modelValue = defineModel<string | undefined>({
|
const modelValue = defineModel<string | undefined>({
|
||||||
default(props: Props) {
|
default(props: Props) {
|
||||||
return props.widget.options?.values?.[0] ?? ''
|
const values = props.widget.options?.values
|
||||||
|
return (Array.isArray(values) ? values[0] : undefined) ?? ''
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,10 @@ import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
|||||||
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||||
import { app } from '@/scripts/app'
|
import { app } from '@/scripts/app'
|
||||||
import type { ComfyWidgetConstructorV2 } from '@/scripts/widgets'
|
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(
|
function addMarkdownWidget(
|
||||||
node: LGraphNode,
|
node: LGraphNode,
|
||||||
name: string,
|
name: string,
|
||||||
@@ -36,6 +39,8 @@ function addMarkdownWidget(
|
|||||||
editable: false
|
editable: false
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const widgetStore = useWidgetValueStore()
|
||||||
|
|
||||||
const inputEl = editor.options.element as HTMLElement
|
const inputEl = editor.options.element as HTMLElement
|
||||||
inputEl.classList.add('comfy-markdown')
|
inputEl.classList.add('comfy-markdown')
|
||||||
const textarea = document.createElement('textarea')
|
const textarea = document.createElement('textarea')
|
||||||
@@ -43,16 +48,28 @@ function addMarkdownWidget(
|
|||||||
|
|
||||||
const widget = node.addDOMWidget(name, 'MARKDOWN', inputEl, {
|
const widget = node.addDOMWidget(name, 'MARKDOWN', inputEl, {
|
||||||
getValue(): string {
|
getValue(): string {
|
||||||
return textarea.value
|
return (
|
||||||
|
(widgetStore.getWidget(node.id, name)?.value as string) ??
|
||||||
|
textarea.value
|
||||||
|
)
|
||||||
},
|
},
|
||||||
setValue(v: string) {
|
setValue(v: string) {
|
||||||
textarea.value = v
|
textarea.value = v
|
||||||
editor.commands.setContent(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]
|
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.addEventListener('dblclick', () => {
|
||||||
inputEl.classList.add('editing')
|
inputEl.classList.add('editing')
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
|
|||||||
@@ -4,14 +4,18 @@ import { isStringInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
|||||||
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||||
import { app } from '@/scripts/app'
|
import { app } from '@/scripts/app'
|
||||||
import type { ComfyWidgetConstructorV2 } from '@/scripts/widgets'
|
import type { ComfyWidgetConstructorV2 } from '@/scripts/widgets'
|
||||||
|
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||||
|
|
||||||
const TRACKPAD_DETECTION_THRESHOLD = 50
|
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(
|
function addMultilineWidget(
|
||||||
node: LGraphNode,
|
node: LGraphNode,
|
||||||
name: string,
|
name: string,
|
||||||
opts: { defaultVal: string; placeholder?: string }
|
opts: { defaultVal: string; placeholder?: string }
|
||||||
) {
|
) {
|
||||||
|
const widgetStore = useWidgetValueStore()
|
||||||
const inputEl = document.createElement('textarea')
|
const inputEl = document.createElement('textarea')
|
||||||
inputEl.className = 'comfy-multiline-input'
|
inputEl.className = 'comfy-multiline-input'
|
||||||
inputEl.value = opts.defaultVal
|
inputEl.value = opts.defaultVal
|
||||||
@@ -20,17 +24,24 @@ function addMultilineWidget(
|
|||||||
|
|
||||||
const widget = node.addDOMWidget(name, 'customtext', inputEl, {
|
const widget = node.addDOMWidget(name, 'customtext', inputEl, {
|
||||||
getValue(): string {
|
getValue(): string {
|
||||||
return inputEl.value
|
const widgetState = widgetStore.getWidget(node.id, name)
|
||||||
|
|
||||||
|
return (widgetState?.value as string) ?? inputEl.value
|
||||||
},
|
},
|
||||||
setValue(v: string) {
|
setValue(v: string) {
|
||||||
inputEl.value = v
|
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]
|
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)
|
widget.callback?.(widget.value)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -123,7 +123,7 @@ describe('widgetRegistry', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('should return false for widgets without a type', () => {
|
it('should return false for widgets without a type', () => {
|
||||||
const widget = { options: {} }
|
const widget = {}
|
||||||
expect(shouldRenderAsVue(widget)).toBe(false)
|
expect(shouldRenderAsVue(widget)).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -136,7 +136,7 @@ describe('widgetRegistry', () => {
|
|||||||
it('should respect options while checking type', () => {
|
it('should respect options while checking type', () => {
|
||||||
const widget: Partial<SafeWidgetData> = {
|
const widget: Partial<SafeWidgetData> = {
|
||||||
type: 'text',
|
type: 'text',
|
||||||
options: { precision: 5 }
|
options: { canvasOnly: false }
|
||||||
}
|
}
|
||||||
expect(shouldRenderAsVue(widget)).toBe(true)
|
expect(shouldRenderAsVue(widget)).toBe(true)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -153,7 +153,7 @@ abstract class BaseDOMWidgetImpl<V extends object | string>
|
|||||||
}
|
}
|
||||||
|
|
||||||
isVisible(): boolean {
|
isVisible(): boolean {
|
||||||
return !['hidden'].includes(this.type) && this.node.isWidgetVisible(this)
|
return !this.hidden && this.node.isWidgetVisible(this)
|
||||||
}
|
}
|
||||||
|
|
||||||
override draw(
|
override draw(
|
||||||
|
|||||||
@@ -34,7 +34,6 @@ async function decompressZlib(
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @knipIgnoreUnusedButUsedByCustomNodes */
|
|
||||||
export async function getFromPngBuffer(
|
export async function getFromPngBuffer(
|
||||||
buffer: ArrayBuffer
|
buffer: ArrayBuffer
|
||||||
): Promise<Record<string, string>> {
|
): Promise<Record<string, string>> {
|
||||||
|
|||||||
155
src/stores/widgetValueStore.test.ts
Normal file
155
src/stores/widgetValueStore.test.ts
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
import { createTestingPinia } from '@pinia/testing'
|
||||||
|
import { setActivePinia } from 'pinia'
|
||||||
|
import { beforeEach, describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
import type { WidgetState } from './widgetValueStore'
|
||||||
|
import { useWidgetValueStore } from './widgetValueStore'
|
||||||
|
|
||||||
|
function widget<T>(
|
||||||
|
nodeId: string,
|
||||||
|
name: string,
|
||||||
|
type: string,
|
||||||
|
value: T,
|
||||||
|
extra: Partial<
|
||||||
|
Omit<WidgetState<T>, 'nodeId' | 'name' | 'type' | 'value'>
|
||||||
|
> = {}
|
||||||
|
): WidgetState<T> {
|
||||||
|
return { nodeId, name, type, value, options: {}, ...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('missing', 'widget')).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('widgetState.value can be read and written directly', () => {
|
||||||
|
const store = useWidgetValueStore()
|
||||||
|
const state = store.registerWidget(
|
||||||
|
widget('node-1', 'seed', 'number', 100)
|
||||||
|
)
|
||||||
|
expect(state.value).toBe(100)
|
||||||
|
|
||||||
|
state.value = 200
|
||||||
|
expect(store.getWidget('node-1', 'seed')?.value).toBe(200)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('stores different value types', () => {
|
||||||
|
const store = useWidgetValueStore()
|
||||||
|
store.registerWidget(widget('node-1', 'text', 'string', 'hello'))
|
||||||
|
store.registerWidget(widget('node-1', 'number', 'number', 42))
|
||||||
|
store.registerWidget(widget('node-1', 'boolean', 'toggle', true))
|
||||||
|
store.registerWidget(widget('node-1', 'array', 'combo', [1, 2, 3]))
|
||||||
|
|
||||||
|
expect(store.getWidget('node-1', 'text')?.value).toBe('hello')
|
||||||
|
expect(store.getWidget('node-1', 'number')?.value).toBe(42)
|
||||||
|
expect(store.getWidget('node-1', 'boolean')?.value).toBe(true)
|
||||||
|
expect(store.getWidget('node-1', 'array')?.value).toEqual([1, 2, 3])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('widget registration', () => {
|
||||||
|
it('registers a widget with minimal properties', () => {
|
||||||
|
const store = useWidgetValueStore()
|
||||||
|
const state = store.registerWidget(
|
||||||
|
widget('node-1', 'seed', 'number', 12345)
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(state.nodeId).toBe('node-1')
|
||||||
|
expect(state.name).toBe('seed')
|
||||||
|
expect(state.type).toBe('number')
|
||||||
|
expect(state.value).toBe(12345)
|
||||||
|
expect(state.disabled).toBeUndefined()
|
||||||
|
expect(state.promoted).toBeUndefined()
|
||||||
|
expect(state.serialize).toBeUndefined()
|
||||||
|
expect(state.options).toEqual({})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('registers a widget with all properties', () => {
|
||||||
|
const store = useWidgetValueStore()
|
||||||
|
const state = store.registerWidget(
|
||||||
|
widget('node-1', 'prompt', 'string', 'test', {
|
||||||
|
label: 'Prompt Text',
|
||||||
|
disabled: true,
|
||||||
|
promoted: true,
|
||||||
|
serialize: false,
|
||||||
|
options: { multiline: true }
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(state.label).toBe('Prompt Text')
|
||||||
|
expect(state.disabled).toBe(true)
|
||||||
|
expect(state.promoted).toBe(true)
|
||||||
|
expect(state.serialize).toBe(false)
|
||||||
|
expect(state.options).toEqual({ multiline: true })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('widget getters', () => {
|
||||||
|
it('getWidget returns widget state', () => {
|
||||||
|
const store = useWidgetValueStore()
|
||||||
|
store.registerWidget(widget('node-1', 'seed', 'number', 100))
|
||||||
|
|
||||||
|
const state = store.getWidget('node-1', 'seed')
|
||||||
|
expect(state).toBeDefined()
|
||||||
|
expect(state?.name).toBe('seed')
|
||||||
|
expect(state?.value).toBe(100)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('getWidget returns undefined for missing widget', () => {
|
||||||
|
const store = useWidgetValueStore()
|
||||||
|
expect(store.getWidget('missing', 'widget')).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('getNodeWidgets returns all widgets for a node', () => {
|
||||||
|
const store = useWidgetValueStore()
|
||||||
|
store.registerWidget(widget('node-1', 'seed', 'number', 1))
|
||||||
|
store.registerWidget(widget('node-1', 'steps', 'number', 20))
|
||||||
|
store.registerWidget(widget('node-2', 'cfg', 'number', 7))
|
||||||
|
|
||||||
|
const widgets = store.getNodeWidgets('node-1')
|
||||||
|
expect(widgets).toHaveLength(2)
|
||||||
|
expect(widgets.map((w) => w.name).sort()).toEqual(['seed', 'steps'])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('direct property mutation', () => {
|
||||||
|
it('disabled can be set directly via getWidget', () => {
|
||||||
|
const store = useWidgetValueStore()
|
||||||
|
const state = store.registerWidget(
|
||||||
|
widget('node-1', 'seed', 'number', 100)
|
||||||
|
)
|
||||||
|
|
||||||
|
state.disabled = true
|
||||||
|
expect(store.getWidget('node-1', 'seed')?.disabled).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('promoted can be set directly via getWidget', () => {
|
||||||
|
const store = useWidgetValueStore()
|
||||||
|
const state = store.registerWidget(
|
||||||
|
widget('node-1', 'seed', 'number', 100)
|
||||||
|
)
|
||||||
|
|
||||||
|
state.promoted = true
|
||||||
|
expect(store.getWidget('node-1', 'seed')?.promoted).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('label can be set directly via getWidget', () => {
|
||||||
|
const store = useWidgetValueStore()
|
||||||
|
const state = store.registerWidget(
|
||||||
|
widget('node-1', 'seed', 'number', 100)
|
||||||
|
)
|
||||||
|
|
||||||
|
state.label = 'Random Seed'
|
||||||
|
expect(store.getWidget('node-1', 'seed')?.label).toBe('Random Seed')
|
||||||
|
|
||||||
|
state.label = undefined
|
||||||
|
expect(store.getWidget('node-1', 'seed')?.label).toBeUndefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
77
src/stores/widgetValueStore.ts
Normal file
77
src/stores/widgetValueStore.ts
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
import type { NodeId } from '@/lib/litegraph/src/LGraphNode'
|
||||||
|
import type {
|
||||||
|
IBaseWidget,
|
||||||
|
IWidgetOptions
|
||||||
|
} from '@/lib/litegraph/src/types/widgets'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Widget state is keyed by `nodeId:widgetName` without graph context.
|
||||||
|
* This is intentional: nodes viewed at different subgraph depths share
|
||||||
|
* the same widget state, enabling synchronized values across the hierarchy.
|
||||||
|
*/
|
||||||
|
type WidgetKey = `${NodeId}:${string}`
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Strips graph/subgraph prefixes from a scoped node ID to get the bare node ID.
|
||||||
|
* e.g., "graph1:subgraph2:42" → "42"
|
||||||
|
*/
|
||||||
|
export function stripGraphPrefix(scopedId: NodeId | string): NodeId {
|
||||||
|
return String(scopedId).replace(/^(.*:)+/, '') as NodeId
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WidgetState<
|
||||||
|
TValue = unknown,
|
||||||
|
TType extends string = string,
|
||||||
|
TOptions extends IWidgetOptions = IWidgetOptions
|
||||||
|
> extends Pick<
|
||||||
|
IBaseWidget<TValue, TType, TOptions>,
|
||||||
|
| 'name'
|
||||||
|
| 'type'
|
||||||
|
| 'value'
|
||||||
|
| 'options'
|
||||||
|
| 'label'
|
||||||
|
| 'serialize'
|
||||||
|
| 'disabled'
|
||||||
|
| 'promoted'
|
||||||
|
> {
|
||||||
|
nodeId: NodeId
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useWidgetValueStore = defineStore('widgetValue', () => {
|
||||||
|
const widgetStates = ref(new Map<WidgetKey, WidgetState>())
|
||||||
|
|
||||||
|
function makeKey(nodeId: NodeId, widgetName: string): WidgetKey {
|
||||||
|
return `${nodeId}:${widgetName}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function registerWidget<TValue = unknown>(
|
||||||
|
state: WidgetState<TValue>
|
||||||
|
): WidgetState<TValue> {
|
||||||
|
const key = makeKey(state.nodeId, state.name)
|
||||||
|
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(
|
||||||
|
nodeId: NodeId,
|
||||||
|
widgetName: string
|
||||||
|
): WidgetState | undefined {
|
||||||
|
return widgetStates.value.get(makeKey(nodeId, widgetName))
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
registerWidget,
|
||||||
|
getWidget,
|
||||||
|
getNodeWidgets
|
||||||
|
}
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user