diff --git a/browser_tests/tests/subgraph-duplicate-ids.spec.ts b/browser_tests/tests/subgraph-duplicate-ids.spec.ts index 1fb77bed8d..17e458f3b3 100644 --- a/browser_tests/tests/subgraph-duplicate-ids.spec.ts +++ b/browser_tests/tests/subgraph-duplicate-ids.spec.ts @@ -5,13 +5,6 @@ import { comfyPageFixture as test } from '../fixtures/ComfyPage' test.describe('Subgraph duplicate ID remapping', { tag: ['@subgraph'] }, () => { 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 ({ comfyPage }) => { diff --git a/browser_tests/tests/subgraph.spec.ts b/browser_tests/tests/subgraph.spec.ts index cc78b49539..c937573dfc 100644 --- a/browser_tests/tests/subgraph.spec.ts +++ b/browser_tests/tests/subgraph.spec.ts @@ -61,7 +61,10 @@ test.describe('Subgraph Operations', { tag: ['@slow', '@subgraph'] }, () => { await subgraphNode.navigateIntoSubgraph() 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.nextFrame() @@ -77,7 +80,10 @@ test.describe('Subgraph Operations', { tag: ['@slow', '@subgraph'] }, () => { await subgraphNode.navigateIntoSubgraph() 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.nextFrame() diff --git a/browser_tests/tests/vueNodes/widgets/load/uploadWidgets.spec.ts-snapshots/vue-nodes-upload-widgets-chromium-linux.png b/browser_tests/tests/vueNodes/widgets/load/uploadWidgets.spec.ts-snapshots/vue-nodes-upload-widgets-chromium-linux.png index a3ff647343..b4f2e3de58 100644 Binary files a/browser_tests/tests/vueNodes/widgets/load/uploadWidgets.spec.ts-snapshots/vue-nodes-upload-widgets-chromium-linux.png and b/browser_tests/tests/vueNodes/widgets/load/uploadWidgets.spec.ts-snapshots/vue-nodes-upload-widgets-chromium-linux.png differ diff --git a/src/components/rightSidePanel/parameters/WidgetItem.vue b/src/components/rightSidePanel/parameters/WidgetItem.vue index 33768adad5..58c3f8db5e 100644 --- a/src/components/rightSidePanel/parameters/WidgetItem.vue +++ b/src/components/rightSidePanel/parameters/WidgetItem.vue @@ -83,10 +83,7 @@ const favoriteNode = computed(() => ) const widgetValue = computed({ - get: () => { - widget.vueTrack?.() - return widget.value - }, + get: () => widget.value, set: (newValue: string | number | boolean | object) => { emit('update:widgetValue', newValue) } diff --git a/src/composables/graph/useGraphNodeManager.test.ts b/src/composables/graph/useGraphNodeManager.test.ts index ba22d98cd2..3669f06e4b 100644 --- a/src/composables/graph/useGraphNodeManager.test.ts +++ b/src/composables/graph/useGraphNodeManager.test.ts @@ -1,49 +1,76 @@ import { setActivePinia } from 'pinia' import { createTestingPinia } from '@pinia/testing' -import { describe, expect, it, vi } from 'vitest' -import { nextTick, watch } from 'vue' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { computed, nextTick, watch } from 'vue' import { useGraphNodeManager } from '@/composables/graph/useGraphNodeManager' -import { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph' +import { BaseWidget, LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph' import { NodeSlotType } from '@/lib/litegraph/src/types/globalEnums' - -setActivePinia(createTestingPinia()) - -function createTestGraph() { - const graph = new LGraph() - const node = new LGraphNode('test') - node.addInput('input', 'INT') - node.addWidget('number', 'testnum', 2, () => undefined, {}) - graph.add(node) - - const { vueNodeData } = useGraphNodeManager(graph) - const onReactivityUpdate = vi.fn() - watch(vueNodeData, onReactivityUpdate) - - return [node, graph, onReactivityUpdate] as const -} +import { useWidgetValueStore } from '@/stores/widgetValueStore' describe('Node Reactivity', () => { - it('should trigger on callback', async () => { - const [node, , onReactivityUpdate] = createTestGraph() - - node.widgets![0].callback!(2) - await nextTick() - expect(onReactivityUpdate).toHaveBeenCalledTimes(1) + beforeEach(() => { + setActivePinia(createTestingPinia({ stubActions: false })) }) - it('should remain reactive after a connection is made', async () => { - const [node, graph, onReactivityUpdate] = createTestGraph() + function createTestGraph() { + const graph = new LGraph() + const node = new LGraphNode('test') + node.addInput('input', 'INT') + node.addWidget('number', 'testnum', 2, () => undefined, {}) + graph.add(node) + + const { vueNodeData } = useGraphNodeManager(graph) + + return { node, graph, vueNodeData } + } + + it('widget values are reactive through the store', async () => { + const { node } = createTestGraph() + const store = useWidgetValueStore() + const widget = node.widgets![0] + + // Verify widget is a BaseWidget with correct value and node assignment + expect(widget).toBeInstanceOf(BaseWidget) + expect(widget.value).toBe(2) + expect((widget as BaseWidget).node.id).toBe(node.id) + + // Initial value should be in store after setNodeId was called + expect(store.getWidget(node.id, 'testnum')?.value).toBe(2) + + const onValueChange = vi.fn() + const widgetValue = computed( + () => store.getWidget(node.id, 'testnum')?.value + ) + watch(widgetValue, onValueChange) + + widget.value = 42 + await nextTick() + + expect(widgetValue.value).toBe(42) + expect(onValueChange).toHaveBeenCalledTimes(1) + }) + + it('widget values remain reactive after a connection is made', async () => { + const { node, graph } = createTestGraph() + const store = useWidgetValueStore() + const onValueChange = vi.fn() graph.trigger('node:slot-links:changed', { - nodeId: '1', + nodeId: String(node.id), slotType: NodeSlotType.INPUT }) await nextTick() - onReactivityUpdate.mockClear() - node.widgets![0].callback!(2) + const widgetValue = computed( + () => store.getWidget(node.id, 'testnum')?.value + ) + watch(widgetValue, onValueChange) + + node.widgets![0].value = 99 await nextTick() - expect(onReactivityUpdate).toHaveBeenCalledTimes(1) + + expect(onValueChange).toHaveBeenCalledTimes(1) + expect(widgetValue.value).toBe(99) }) }) diff --git a/src/composables/graph/useGraphNodeManager.ts b/src/composables/graph/useGraphNodeManager.ts index 8958655ffa..3ad3a06304 100644 --- a/src/composables/graph/useGraphNodeManager.ts +++ b/src/composables/graph/useGraphNodeManager.ts @@ -3,7 +3,7 @@ * Provides event-driven reactivity with performance optimizations */ import { reactiveComputed } from '@vueuse/core' -import { customRef, reactive, shallowReactive } from 'vue' +import { reactive, shallowReactive } from 'vue' import { useChainCallback } from '@/composables/functional/useChainCallback' import { isProxyWidget } from '@/core/graph/subgraph/proxyWidget' @@ -11,10 +11,7 @@ import type { INodeInputSlot, INodeOutputSlot } from '@/lib/litegraph/src/interfaces' -import type { - IBaseWidget, - IWidgetOptions -} from '@/lib/litegraph/src/types/widgets' +import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets' import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations' import { LayoutSource } from '@/renderer/core/layout/types' import type { NodeId } from '@/renderer/core/layout/types' @@ -41,19 +38,37 @@ export interface WidgetSlotMetadata { linked: boolean } +/** + * Minimal render-specific widget data extracted from LiteGraph widgets. + * Value and metadata (label, hidden, disabled, etc.) are accessed via widgetValueStore. + */ export interface SafeWidgetData { + nodeId?: NodeId name: string type: string - value: WidgetValue - borderStyle?: string + /** Callback to invoke when widget value changes (wraps LiteGraph callback + triggerDraw) */ callback?: ((value: unknown) => void) | undefined + /** Control widget for seed randomization/increment/decrement */ controlWidget?: SafeControlWidget + /** Whether widget has custom layout size computation */ hasLayoutSize?: boolean + /** Whether widget is a DOM widget */ isDOMWidget?: boolean - label?: string + /** Node type (for subgraph promoted widgets) */ nodeType?: string - options?: IWidgetOptions + /** + * 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 + /** Input slot metadata (index and link status) */ slotMetadata?: WidgetSlotMetadata } @@ -95,23 +110,6 @@ export interface GraphNodeManager { cleanup(): void } -function widgetWithVueTrack( - widget: IBaseWidget -): asserts widget is IBaseWidget & { vueTrack: () => void } { - if (widget.vueTrack) return - - customRef((track, trigger) => { - widget.callback = useChainCallback(widget.callback, trigger) - widget.vueTrack = track - return { get() {}, set() {} } - }) -} -function useReactiveWidgetValue(widget: IBaseWidget) { - widgetWithVueTrack(widget) - widget.vueTrack() - return widget.value -} - function getControlWidget(widget: IBaseWidget): SafeControlWidget | undefined { const cagWidget = widget.linkedWidgets?.find( (w) => w.name == 'control_after_generate' @@ -133,26 +131,18 @@ function getNodeType(node: LGraphNode, widget: IBaseWidget) { * Shared widget enhancements used by both safeWidgetMapper and Right Side Panel */ interface SharedWidgetEnhancements { - /** Reactive widget value that updates when the widget changes */ - value: WidgetValue /** Control widget for seed randomization/increment/decrement */ controlWidget?: SafeControlWidget /** Input specification from node definition */ spec?: InputSpec /** Node type (for subgraph promoted widgets) */ nodeType?: string - /** Border style for promoted/advanced widgets */ - borderStyle?: string - /** Widget label */ - label?: string - /** Widget options */ - options?: IWidgetOptions } /** * Extracts common widget enhancements shared across different rendering contexts. - * This function centralizes the logic for extracting metadata and reactive values - * from widgets, ensuring consistency between Nodes 2.0 and Right Side Panel. + * This function centralizes the logic for extracting metadata from widgets. + * Note: Value and metadata (label, options, hidden, etc.) are accessed via widgetValueStore. */ export function getSharedWidgetEnhancements( node: LGraphNode, @@ -161,17 +151,9 @@ export function getSharedWidgetEnhancements( const nodeDefStore = useNodeDefStore() return { - value: useReactiveWidgetValue(widget), controlWidget: getControlWidget(widget), spec: nodeDefStore.getInputSpecForWidget(node, widget.name), - nodeType: getNodeType(node, widget), - borderStyle: widget.promoted - ? 'ring ring-component-node-widget-promoted' - : widget.advanced - ? 'ring ring-component-node-widget-advanced' - : undefined, - label: widget.label, - options: widget.options as IWidgetOptions + nodeType: getNodeType(node, widget) } } @@ -212,7 +194,7 @@ function safeWidgetMapper( ): (widget: IBaseWidget) => SafeWidgetData { return function (widget) { try { - // Get shared enhancements used by both Nodes 2.0 and Right Side Panel + // Get shared enhancements (controlWidget, spec, nodeType) const sharedEnhancements = getSharedWidgetEnhancements(node, widget) const slotInfo = slotMetadata.get(widget.name) @@ -228,20 +210,41 @@ function safeWidgetMapper( 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 { - name: widget.name, + nodeId, + name, type: widget.type, ...sharedEnhancements, callback, hasLayoutSize: typeof widget.computeLayoutSize === 'function', isDOMWidget: isDOMWidget(widget), + options, slotMetadata: slotInfo } } catch (error) { return { name: widget.name || 'unknown', - type: widget.type || 'text', - value: undefined + type: widget.type || 'text' } } } diff --git a/src/core/graph/subgraph/proxyWidget.test.ts b/src/core/graph/subgraph/proxyWidget.test.ts index 956e4b9b48..188aba33ac 100644 --- a/src/core/graph/subgraph/proxyWidget.test.ts +++ b/src/core/graph/subgraph/proxyWidget.test.ts @@ -1,4 +1,6 @@ -import { describe, expect, test, vi } from 'vitest' +import { createTestingPinia } from '@pinia/testing' +import { setActivePinia } from 'pinia' +import { beforeEach, describe, expect, test, vi } from 'vitest' import { registerProxyWidgets } from '@/core/graph/subgraph/proxyWidget' import { promoteWidget } from '@/core/graph/subgraph/proxyWidgetUtils' @@ -43,6 +45,10 @@ function setupSubgraph( } describe('Subgraph proxyWidgets', () => { + beforeEach(() => { + setActivePinia(createTestingPinia({ stubActions: false })) + }) + test('Can add simple widget', () => { const [subgraphNode, innerNodes] = setupSubgraph(1) innerNodes[0].addWidget('text', 'stringWidget', 'value', () => {}) diff --git a/src/core/graph/subgraph/proxyWidget.ts b/src/core/graph/subgraph/proxyWidget.ts index 60f6d456aa..1370dfe499 100644 --- a/src/core/graph/subgraph/proxyWidget.ts +++ b/src/core/graph/subgraph/proxyWidget.ts @@ -115,9 +115,11 @@ const onConfigure = function ( if (isActiveGraph && w instanceof DOMWidgetImpl) setWidget(w) return [w] }) - this.widgets = this.widgets.filter( - (w) => !isProxyWidget(w) && !parsed.some(([, name]) => w.name === name) - ) + this.widgets = this.widgets.filter((w) => { + if (isProxyWidget(w)) return false + const widgetName = w.name + return !parsed.some(([, name]) => widgetName === name) + }) this.widgets.push(...newWidgets) canvasStore.canvas?.setDirty(true, true) @@ -152,6 +154,7 @@ function newProxyWidget( computedHeight: undefined, isProxyWidget: true, last_y: undefined, + label: name, name, node: subgraphNode, onRemove: undefined, diff --git a/src/core/graph/subgraph/proxyWidgetUtils.ts b/src/core/graph/subgraph/proxyWidgetUtils.ts index 2cd8685a67..ad99f74f2b 100644 --- a/src/core/graph/subgraph/proxyWidgetUtils.ts +++ b/src/core/graph/subgraph/proxyWidgetUtils.ts @@ -57,15 +57,20 @@ export function demoteWidget( widget.promoted = false } +function getWidgetName(w: IBaseWidget): string { + return isProxyWidget(w) ? w._overlay.widgetName : w.name +} + export function matchesWidgetItem([nodeId, widgetName]: [string, string]) { - return ([n, w]: WidgetItem) => n.id == nodeId && w.name === widgetName + return ([n, w]: WidgetItem) => + n.id == nodeId && getWidgetName(w) === widgetName } export function matchesPropertyItem([n, w]: WidgetItem) { return ([nodeId, widgetName]: [string, string]) => - n.id == nodeId && w.name === widgetName + n.id == nodeId && getWidgetName(w) === widgetName } export function widgetItemToProperty([n, w]: WidgetItem): [string, string] { - return [`${n.id}`, w.name] + return [`${n.id}`, getWidgetName(w)] } function getParentNodes(): SubgraphNode[] { diff --git a/src/extensions/core/groupNode.ts b/src/extensions/core/groupNode.ts index ed7dcbb205..b5c4181dc7 100644 --- a/src/extensions/core/groupNode.ts +++ b/src/extensions/core/groupNode.ts @@ -1412,7 +1412,7 @@ export class GroupNodeHandler { handlerGroupData.oldToNewWidgetMap[Number(n)]?.[w] const widget = this.widgets.find((wg) => wg.name === widgetName) if (widget) { - widget.type = 'hidden' + widget.hidden = true widget.computeSize = () => [0, -4] } } diff --git a/src/extensions/core/previewAny.ts b/src/extensions/core/previewAny.ts index d06ba15843..f6afeb311c 100644 --- a/src/extensions/core/previewAny.ts +++ b/src/extensions/core/previewAny.ts @@ -19,14 +19,14 @@ useExtensionService().registerExtension({ const showValueWidget = ComfyWidgets['MARKDOWN']( this, - 'preview', + 'preview_markdown', ['MARKDOWN', {}], app ).widget as DOMWidget const showValueWidgetPlain = ComfyWidgets['STRING']( this, - 'preview', + 'preview_text', ['STRING', { multiline: true }], app ).widget as DOMWidget @@ -48,6 +48,7 @@ useExtensionService().registerExtension({ showValueWidgetPlain.options.hidden = value } + showValueWidget.label = 'Preview' showValueWidget.hidden = true showValueWidget.options.hidden = true showValueWidget.options.read_only = true @@ -55,6 +56,7 @@ useExtensionService().registerExtension({ showValueWidget.element.disabled = true showValueWidget.serialize = false + showValueWidgetPlain.label = 'Preview' showValueWidgetPlain.hidden = false showValueWidgetPlain.options.hidden = false showValueWidgetPlain.options.read_only = true @@ -71,7 +73,7 @@ useExtensionService().registerExtension({ : onExecuted.apply(this, [message]) const previewWidgets = - this.widgets?.filter((w) => w.name === 'preview') ?? [] + this.widgets?.filter((w) => w.name.startsWith('preview_')) ?? [] for (const previewWidget of previewWidgets) { const text = message.text ?? '' diff --git a/src/extensions/core/uploadAudio.ts b/src/extensions/core/uploadAudio.ts index c1450632b8..86ecd9c4ae 100644 --- a/src/extensions/core/uploadAudio.ts +++ b/src/extensions/core/uploadAudio.ts @@ -23,6 +23,7 @@ import { getNodeByLocatorId } from '@/utils/graphTraversalUtil' import { api } from '../../scripts/api' import { app } from '../../scripts/app' +import { useWidgetValueStore } from '@/stores/widgetValueStore' function updateUIWidget( audioUIWidget: DOMWidget, @@ -137,9 +138,16 @@ app.registerExtension({ } } - let value = '' - audioUIWidget.options.getValue = () => value - audioUIWidget.options.setValue = (v) => (value = v) + audioUIWidget.options.getValue = () => + (useWidgetValueStore().getWidget(node.id, inputName) + ?.value as string) ?? '' + audioUIWidget.options.setValue = (v) => { + const widgetState = useWidgetValueStore().getWidget( + node.id, + inputName + ) + if (widgetState) widgetState.value = v + } return { widget: audioUIWidget } } diff --git a/src/lib/litegraph/src/LGraph.ts b/src/lib/litegraph/src/LGraph.ts index e96b61c5cb..c82f3a19e1 100644 --- a/src/lib/litegraph/src/LGraph.ts +++ b/src/lib/litegraph/src/LGraph.ts @@ -4,6 +4,7 @@ import { SUBGRAPH_INPUT_ID, SUBGRAPH_OUTPUT_ID } from '@/lib/litegraph/src/constants' +import { isNodeBindable } from '@/lib/litegraph/src/utils/type' import type { UUID } from '@/lib/litegraph/src/utils/uuid' import { createUuidv4, zeroUuid } from '@/lib/litegraph/src/utils/uuid' import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations' @@ -158,7 +159,6 @@ export class LGraph static STATUS_STOPPED = 1 static STATUS_RUNNING = 2 - static deduplicateSubgraphIds = false /** List of LGraph properties that are manually handled by {@link LGraph.configure}. */ static readonly ConfigureProperties = new Set([ @@ -962,6 +962,14 @@ export class LGraph 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 this._version++ @@ -2437,7 +2445,7 @@ export class LGraph this.subgraphs.get(subgraph.id)?.configure(subgraph) } - if (this.isRootGraph && LGraph.deduplicateSubgraphIds) { + if (this.isRootGraph) { const reservedNodeIds = nodesData ?.map((n) => n.id) .filter((id): id is number => typeof id === 'number') diff --git a/src/lib/litegraph/src/LGraphCanvas.ts b/src/lib/litegraph/src/LGraphCanvas.ts index 130a27809b..e05b3f6a50 100644 --- a/src/lib/litegraph/src/LGraphCanvas.ts +++ b/src/lib/litegraph/src/LGraphCanvas.ts @@ -8347,7 +8347,7 @@ export class LGraphCanvas implements CustomEventDispatcher callback: LGraphCanvas.onMenuNodeCollapse }) } - if (node.widgets?.some((w) => w.advanced)) { + if (node.hasAdvancedWidgets()) { options.push({ content: node.showAdvanced ? 'Hide Advanced' : 'Show Advanced', callback: LGraphCanvas.onMenuToggleAdvanced diff --git a/src/lib/litegraph/src/LGraphNode.test.ts b/src/lib/litegraph/src/LGraphNode.test.ts index 5b498dac34..6ee5049a1c 100644 --- a/src/lib/litegraph/src/LGraphNode.test.ts +++ b/src/lib/litegraph/src/LGraphNode.test.ts @@ -1,3 +1,5 @@ +import { createTestingPinia } from '@pinia/testing' +import { setActivePinia } from 'pinia' import { afterEach, beforeEach, describe, expect, vi } from 'vitest' import type { @@ -42,6 +44,7 @@ describe('LGraphNode', () => { let origLiteGraph: typeof LiteGraph beforeEach(() => { + setActivePinia(createTestingPinia({ stubActions: false })) origLiteGraph = Object.assign({}, LiteGraph) // @ts-expect-error Intended: Force remove an otherwise readonly non-optional property delete origLiteGraph.Classes diff --git a/src/lib/litegraph/src/LGraphNode.ts b/src/lib/litegraph/src/LGraphNode.ts index baf6a44190..c329518234 100644 --- a/src/lib/litegraph/src/LGraphNode.ts +++ b/src/lib/litegraph/src/LGraphNode.ts @@ -10,7 +10,11 @@ import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMuta import { LayoutSource } from '@/renderer/core/layout/types' import { adjustColor } 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 type { DragAndScale } from './DragAndScale' @@ -1957,6 +1961,14 @@ export class LGraphNode this.widgets ||= [] const widget = toConcreteWidget(custom_widget, this, false) ?? custom_widget this.widgets.push(widget) + + // Only register with store if node has a valid ID (is already in a graph). + // If the node isn't in a graph yet (id === -1), registration happens + // when the node is added via LGraph.add() -> node.onAdded. + if (this.id !== -1 && isNodeBindable(widget)) { + widget.setNodeId(this.id) + } + return widget } @@ -3499,7 +3511,7 @@ export class LGraphNode * Toggles advanced mode of the node, showing advanced widgets */ toggleAdvanced() { - if (!this.widgets?.some((w) => w.advanced)) return + if (!this.hasAdvancedWidgets()) return if (!this.graph) throw new NullGraphError() this.graph._version++ this.showAdvanced = !this.showAdvanced @@ -3877,6 +3889,21 @@ export class LGraphNode return !isHidden } + /** + * Returns all widgets that should participate in layout calculations. + * Filters out hidden widgets only (not collapsed/advanced). + */ + getLayoutWidgets(): IBaseWidget[] { + return this.widgets?.filter((w) => !w.hidden) ?? [] + } + + /** + * Returns `true` if the node has any advanced widgets. + */ + hasAdvancedWidgets(): boolean { + return this.widgets?.some((w) => w.advanced) ?? false + } + updateComputedDisabled() { if (!this.widgets) return for (const widget of this.widgets) @@ -4087,7 +4114,7 @@ export class LGraphNode w: IBaseWidget }[] = [] - const visibleWidgets = this.widgets.filter((w) => !w.hidden) + const visibleWidgets = this.getLayoutWidgets() for (const w of visibleWidgets) { if (w.computeSize) { diff --git a/src/lib/litegraph/src/LGraphNode.widgetOrder.test.ts b/src/lib/litegraph/src/LGraphNode.widgetOrder.test.ts index 21f7a7cd8b..4e7b8f44ef 100644 --- a/src/lib/litegraph/src/LGraphNode.widgetOrder.test.ts +++ b/src/lib/litegraph/src/LGraphNode.widgetOrder.test.ts @@ -1,3 +1,5 @@ +import { createTestingPinia } from '@pinia/testing' +import { setActivePinia } from 'pinia' import { beforeEach, describe, expect, it } from 'vitest' import { LGraphNode } from '@/lib/litegraph/src/litegraph' @@ -8,6 +10,7 @@ describe('LGraphNode widget ordering', () => { let node: LGraphNode beforeEach(() => { + setActivePinia(createTestingPinia({ stubActions: false })) node = new LGraphNode('TestNode') }) diff --git a/src/lib/litegraph/src/types/widgets.ts b/src/lib/litegraph/src/types/widgets.ts index 553bea06d1..25e86ff701 100644 --- a/src/lib/litegraph/src/types/widgets.ts +++ b/src/lib/litegraph/src/types/widgets.ts @@ -1,10 +1,19 @@ import type { Bounds } from '@/renderer/core/layout/types' 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' -export interface IWidgetOptions { +export interface NodeBindable { + setNodeId(nodeId: NodeId): void +} + +export interface IWidgetOptions { on?: string off?: string max?: number @@ -301,7 +310,7 @@ export type TWidgetValue = IWidget['value'] export interface IBaseWidget< TValue = boolean | number | string | object | undefined, TType extends string = string, - TOptions extends IWidgetOptions = IWidgetOptions + TOptions extends IWidgetOptions = IWidgetOptions > { [symbol: symbol]: boolean diff --git a/src/lib/litegraph/src/utils/type.ts b/src/lib/litegraph/src/utils/type.ts index 84891fb7f5..cc7b99f593 100644 --- a/src/lib/litegraph/src/utils/type.ts +++ b/src/lib/litegraph/src/utils/type.ts @@ -1,6 +1,7 @@ import { without } from 'es-toolkit' 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. @@ -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 { if (!isStrings(types)) return undefined diff --git a/src/lib/litegraph/src/widgets/BaseWidget.test.ts b/src/lib/litegraph/src/widgets/BaseWidget.test.ts new file mode 100644 index 0000000000..cc9a5ce497 --- /dev/null +++ b/src/lib/litegraph/src/widgets/BaseWidget.test.ts @@ -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 = {} +): 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 + + 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) + }) + }) +}) diff --git a/src/lib/litegraph/src/widgets/BaseWidget.ts b/src/lib/litegraph/src/widgets/BaseWidget.ts index 689dd205fd..15a7d92b44 100644 --- a/src/lib/litegraph/src/widgets/BaseWidget.ts +++ b/src/lib/litegraph/src/widgets/BaseWidget.ts @@ -2,6 +2,7 @@ import { t } from '@/i18n' import { drawTextInArea } from '@/lib/litegraph/src/draw' import { Rectangle } from '@/lib/litegraph/src/infrastructure/Rectangle' import type { Point } from '@/lib/litegraph/src/interfaces' +import type { NodeId } from '@/lib/litegraph/src/LGraphNode' import type { CanvasPointer, LGraphCanvas, @@ -10,7 +11,13 @@ import type { } from '@/lib/litegraph/src/litegraph' import { LiteGraph } from '@/lib/litegraph/src/litegraph' import type { CanvasPointerEvent } from '@/lib/litegraph/src/types/events' -import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets' +import type { + IBaseWidget, + NodeBindable, + TWidgetType +} from '@/lib/litegraph/src/types/widgets' +import type { WidgetState } from '@/stores/widgetValueStore' +import { useWidgetValueStore } from '@/stores/widgetValueStore' export interface DrawWidgetOptions { /** The width of the node where this widget will be displayed. */ @@ -34,9 +41,9 @@ export interface WidgetEventOptions { canvas: LGraphCanvas } -export abstract class BaseWidget< - TWidget extends IBaseWidget = IBaseWidget -> implements IBaseWidget { +export abstract class BaseWidget + implements IBaseWidget, NodeBindable +{ /** From node edge to widget edge */ static margin = 15 /** From widget edge to tip of arrow button */ @@ -66,17 +73,39 @@ export abstract class BaseWidget< linkedWidgets?: IBaseWidget[] name: string options: TWidget['options'] - label?: string type: TWidget['type'] y: number = 0 last_y?: number width?: number - disabled?: boolean computedDisabled?: boolean + tooltip?: string + + private _state: Omit & + Partial> + + get label(): string | undefined { + return this._state.label + } + set label(value: string | undefined) { + this._state.label = value + } + hidden?: 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 callback?( value: TWidget['value'], @@ -97,13 +126,22 @@ export abstract class BaseWidget< canvas: LGraphCanvas ): boolean - private _value?: TWidget['value'] get value(): TWidget['value'] { - return this._value + return this._state.value as TWidget['value'] + } + set value(value: TWidget['value']) { + this._state.value = value } - set value(value: TWidget['value']) { - this._value = value + /** + * Associates this widget with a node ID and registers it in the WidgetValueStore. + * Once set, value reads/writes will be delegated to the store. + */ + setNodeId(nodeId: NodeId): void { + this._state = useWidgetValueStore().registerWidget({ + ...this._state, + nodeId + }) } constructor(widget: TWidget & { node: LGraphNode }) @@ -141,12 +179,26 @@ export abstract class BaseWidget< displayValue, // @ts-expect-error Prevent naming conflicts with custom nodes. labelBaseline, + label, + disabled, promoted, + value, linkedWidgets, ...safeValues } = widget 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() { diff --git a/src/platform/settings/composables/useLitegraphSettings.ts b/src/platform/settings/composables/useLitegraphSettings.ts index a056b7c58a..ffdb58c92b 100644 --- a/src/platform/settings/composables/useLitegraphSettings.ts +++ b/src/platform/settings/composables/useLitegraphSettings.ts @@ -2,7 +2,6 @@ import { watchEffect } from 'vue' import { CanvasPointer, - LGraph, LGraphNode, LiteGraph } from '@/lib/litegraph/src/litegraph' @@ -163,10 +162,4 @@ export const useLitegraphSettings = () => { 'Comfy.EnableWorkflowViewRestore' ) }) - - watchEffect(() => { - LGraph.deduplicateSubgraphIds = settingStore.get( - 'Comfy.Graph.DeduplicateSubgraphNodeIds' - ) - }) } diff --git a/src/platform/settings/constants/coreSettings.ts b/src/platform/settings/constants/coreSettings.ts index e75e6445c6..05c2758585 100644 --- a/src/platform/settings/constants/coreSettings.ts +++ b/src/platform/settings/constants/coreSettings.ts @@ -1211,7 +1211,8 @@ export const CORE_SETTINGS: SettingParams[] = [ tooltip: 'Automatically reassign duplicate node IDs in subgraphs when loading a workflow.', type: 'boolean', - defaultValue: false, + deprecated: true, + defaultValue: true, experimental: true, versionAdded: '1.40.0' } diff --git a/src/renderer/extensions/vueNodes/components/LGraphNodePreview.vue b/src/renderer/extensions/vueNodes/components/LGraphNodePreview.vue index af7ae07cf0..51bc75e8ec 100644 --- a/src/renderer/extensions/vueNodes/components/LGraphNodePreview.vue +++ b/src/renderer/extensions/vueNodes/components/LGraphNodePreview.vue @@ -57,6 +57,7 @@ const nodeData = computed(() => { const widgets = Object.entries(nodeDef.inputs || {}) .filter(([_, input]) => widgetStore.inputIsWidget(input)) .map(([name, input]) => ({ + nodeId: '-1', name, type: input.widgetType || input.type, value: diff --git a/src/renderer/extensions/vueNodes/components/NodeHeader.vue b/src/renderer/extensions/vueNodes/components/NodeHeader.vue index 5109eaf6de..4f9bd75602 100644 --- a/src/renderer/extensions/vueNodes/components/NodeHeader.vue +++ b/src/renderer/extensions/vueNodes/components/NodeHeader.vue @@ -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(() => { // 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 diff --git a/src/renderer/extensions/vueNodes/components/NodeWidgets.test.ts b/src/renderer/extensions/vueNodes/components/NodeWidgets.test.ts index 78f6c370a6..2174004779 100644 --- a/src/renderer/extensions/vueNodes/components/NodeWidgets.test.ts +++ b/src/renderer/extensions/vueNodes/components/NodeWidgets.test.ts @@ -13,15 +13,12 @@ describe('NodeWidgets', () => { const createMockWidget = ( overrides: Partial = {} ): 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 diff --git a/src/renderer/extensions/vueNodes/components/NodeWidgets.vue b/src/renderer/extensions/vueNodes/components/NodeWidgets.vue index 5f6eecc177..c18db79371 100644 --- a/src/renderer/extensions/vueNodes/components/NodeWidgets.vue +++ b/src/renderer/extensions/vueNodes/components/NodeWidgets.vue @@ -26,10 +26,7 @@ :key="`widget-${index}-${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' ) diff --git a/src/renderer/extensions/vueNodes/widgets/components/WidgetSelectDefault.vue b/src/renderer/extensions/vueNodes/widgets/components/WidgetSelectDefault.vue index b0ed8ecc40..5705d60a7a 100644 --- a/src/renderer/extensions/vueNodes/widgets/components/WidgetSelectDefault.vue +++ b/src/renderer/extensions/vueNodes/widgets/components/WidgetSelectDefault.vue @@ -53,7 +53,8 @@ const props = defineProps() const modelValue = defineModel({ default(props: Props) { - return props.widget.options?.values?.[0] ?? '' + const values = props.widget.options?.values + return (Array.isArray(values) ? values[0] : undefined) ?? '' } }) diff --git a/src/renderer/extensions/vueNodes/widgets/components/WidgetSelectDropdown.vue b/src/renderer/extensions/vueNodes/widgets/components/WidgetSelectDropdown.vue index 4c16594e60..3c6ca71cc7 100644 --- a/src/renderer/extensions/vueNodes/widgets/components/WidgetSelectDropdown.vue +++ b/src/renderer/extensions/vueNodes/widgets/components/WidgetSelectDropdown.vue @@ -59,7 +59,8 @@ provide( const modelValue = defineModel({ default(props: Props) { - return props.widget.options?.values?.[0] ?? '' + const values = props.widget.options?.values + return (Array.isArray(values) ? values[0] : undefined) ?? '' } }) diff --git a/src/renderer/extensions/vueNodes/widgets/composables/useMarkdownWidget.ts b/src/renderer/extensions/vueNodes/widgets/composables/useMarkdownWidget.ts index c54d13bdfe..75017de0d3 100644 --- a/src/renderer/extensions/vueNodes/widgets/composables/useMarkdownWidget.ts +++ b/src/renderer/extensions/vueNodes/widgets/composables/useMarkdownWidget.ts @@ -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(() => { diff --git a/src/renderer/extensions/vueNodes/widgets/composables/useStringWidget.ts b/src/renderer/extensions/vueNodes/widgets/composables/useStringWidget.ts index 1ace7e9e2a..e4f9f9be23 100644 --- a/src/renderer/extensions/vueNodes/widgets/composables/useStringWidget.ts +++ b/src/renderer/extensions/vueNodes/widgets/composables/useStringWidget.ts @@ -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) }) diff --git a/src/renderer/extensions/vueNodes/widgets/composables/useWidgetRenderer.test.ts b/src/renderer/extensions/vueNodes/widgets/composables/useWidgetRenderer.test.ts index 3fc529a059..3e679a0cc6 100644 --- a/src/renderer/extensions/vueNodes/widgets/composables/useWidgetRenderer.test.ts +++ b/src/renderer/extensions/vueNodes/widgets/composables/useWidgetRenderer.test.ts @@ -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 = { type: 'text', - options: { precision: 5 } + options: { canvasOnly: false } } expect(shouldRenderAsVue(widget)).toBe(true) }) diff --git a/src/scripts/domWidget.ts b/src/scripts/domWidget.ts index a1b57496dc..4578696dcc 100644 --- a/src/scripts/domWidget.ts +++ b/src/scripts/domWidget.ts @@ -153,7 +153,7 @@ abstract class BaseDOMWidgetImpl } isVisible(): boolean { - return !['hidden'].includes(this.type) && this.node.isWidgetVisible(this) + return !this.hidden && this.node.isWidgetVisible(this) } override draw( diff --git a/src/scripts/metadata/png.ts b/src/scripts/metadata/png.ts index 526d9b2d75..2d47effcb2 100644 --- a/src/scripts/metadata/png.ts +++ b/src/scripts/metadata/png.ts @@ -34,7 +34,6 @@ async function decompressZlib( return result } -/** @knipIgnoreUnusedButUsedByCustomNodes */ export async function getFromPngBuffer( buffer: ArrayBuffer ): Promise> { diff --git a/src/stores/widgetValueStore.test.ts b/src/stores/widgetValueStore.test.ts new file mode 100644 index 0000000000..d102969ebc --- /dev/null +++ b/src/stores/widgetValueStore.test.ts @@ -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( + nodeId: string, + name: string, + type: string, + value: T, + extra: Partial< + Omit, 'nodeId' | 'name' | 'type' | 'value'> + > = {} +): WidgetState { + 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() + }) + }) +}) diff --git a/src/stores/widgetValueStore.ts b/src/stores/widgetValueStore.ts new file mode 100644 index 0000000000..87844db9d9 --- /dev/null +++ b/src/stores/widgetValueStore.ts @@ -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, + | 'name' + | 'type' + | 'value' + | 'options' + | 'label' + | 'serialize' + | 'disabled' + | 'promoted' +> { + nodeId: NodeId +} + +export const useWidgetValueStore = defineStore('widgetValue', () => { + const widgetStates = ref(new Map()) + + function makeKey(nodeId: NodeId, widgetName: string): WidgetKey { + return `${nodeId}:${widgetName}` + } + + function registerWidget( + state: WidgetState + ): WidgetState { + const key = makeKey(state.nodeId, state.name) + widgetStates.value.set(key, state) + return widgetStates.value.get(key) as WidgetState + } + + 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 + } +})