mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-03-14 17:37:46 +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:
@@ -57,6 +57,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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -26,10 +26,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"
|
||||
>
|
||||
@@ -94,6 +91,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'
|
||||
|
||||
@@ -142,6 +143,7 @@ const showAdvanced = computed(
|
||||
const { getWidgetTooltip, createTooltipConfig } = useNodeTooltips(
|
||||
nodeType.value
|
||||
)
|
||||
const widgetValueStore = useWidgetValueStore()
|
||||
|
||||
interface ProcessedWidget {
|
||||
name: string
|
||||
@@ -152,11 +154,15 @@ interface ProcessedWidget {
|
||||
updateHandler: (value: WidgetValue) => void
|
||||
tooltipConfig: TooltipOptions
|
||||
slotMetadata?: WidgetSlotMetadata
|
||||
hidden: boolean
|
||||
advanced: boolean
|
||||
hasLayoutSize: boolean
|
||||
}
|
||||
|
||||
const processedWidgets = computed((): ProcessedWidget[] => {
|
||||
if (!nodeData?.widgets) return []
|
||||
|
||||
const nodeId = nodeData.id
|
||||
const { widgets } = nodeData
|
||||
const result: ProcessedWidget[] = []
|
||||
|
||||
@@ -167,33 +173,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'
|
||||
: widget.options?.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)
|
||||
@@ -204,10 +224,13 @@ const processedWidgets = computed((): ProcessedWidget[] => {
|
||||
type: widget.type,
|
||||
vueComponent,
|
||||
simplified,
|
||||
value: widget.value,
|
||||
value,
|
||||
updateHandler,
|
||||
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 => {
|
||||
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'
|
||||
)
|
||||
|
||||
@@ -53,7 +53,8 @@ const props = defineProps<Props>()
|
||||
|
||||
const modelValue = defineModel<string | undefined>({
|
||||
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>({
|
||||
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 { 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(() => {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user