diff --git a/browser_tests/assets/vueNodes/linked-int-widget.json b/browser_tests/assets/vueNodes/linked-int-widget.json new file mode 100644 index 0000000000..9aa7b0f9a9 --- /dev/null +++ b/browser_tests/assets/vueNodes/linked-int-widget.json @@ -0,0 +1,90 @@ +{ + "id": "95ea19ba-456c-46e8-aa40-dc3ff135b746", + "revision": 0, + "last_node_id": 11, + "last_link_id": 10, + "nodes": [ + { + "id": 10, + "type": "KSampler", + "pos": [494.3333740234375, 142.3333282470703], + "size": [444, 399], + "flags": {}, + "order": 1, + "mode": 0, + "inputs": [ + { + "name": "model", + "type": "MODEL", + "link": null + }, + { + "name": "positive", + "type": "CONDITIONING", + "link": null + }, + { + "name": "negative", + "type": "CONDITIONING", + "link": null + }, + { + "name": "latent_image", + "type": "LATENT", + "link": null + }, + { + "name": "seed", + "type": "INT", + "widget": { + "name": "seed" + }, + "link": 10 + } + ], + "outputs": [ + { + "name": "LATENT", + "type": "LATENT", + "links": null + } + ], + "properties": { + "Node name for S&R": "KSampler" + }, + "widgets_values": [67, "randomize", 20, 8, "euler", "simple", 1] + }, + { + "id": 11, + "type": "PrimitiveInt", + "pos": [24.333343505859375, 149.6666717529297], + "size": [444, 125], + "flags": {}, + "order": 0, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "INT", + "type": "INT", + "links": [10] + } + ], + "properties": { + "Node name for S&R": "PrimitiveInt" + }, + "widgets_values": [67, "randomize"] + } + ], + "links": [[10, 11, 0, 10, 4, "INT"]], + "groups": [], + "config": {}, + "extra": { + "ds": { + "scale": 1, + "offset": [0, 0] + }, + "frontendVersion": "1.28.6" + }, + "version": 0.4 +} diff --git a/browser_tests/fixtures/VueNodeHelpers.ts b/browser_tests/fixtures/VueNodeHelpers.ts index e69b407afd..afae66dc35 100644 --- a/browser_tests/fixtures/VueNodeHelpers.ts +++ b/browser_tests/fixtures/VueNodeHelpers.ts @@ -121,4 +121,24 @@ export class VueNodeHelpers { await this.page.waitForSelector('[data-node-id]') } } + + /** + * Get a specific widget by node title and widget name + */ + getWidgetByName(nodeTitle: string, widgetName: string): Locator { + return this.getNodeByTitle(nodeTitle).locator( + `_vue=[widget.name="${widgetName}"]` + ) + } + + /** + * Get controls for input number widgets (increment/decrement buttons and input) + */ + getInputNumberControls(widget: Locator) { + return { + input: widget.locator('input'), + incrementButton: widget.locator('button').first(), + decrementButton: widget.locator('button').last() + } + } } diff --git a/browser_tests/tests/vueNodes/widgets/int/integerWidget.spec.ts b/browser_tests/tests/vueNodes/widgets/int/integerWidget.spec.ts new file mode 100644 index 0000000000..bb956e3395 --- /dev/null +++ b/browser_tests/tests/vueNodes/widgets/int/integerWidget.spec.ts @@ -0,0 +1,42 @@ +import { + comfyExpect as expect, + comfyPageFixture as test +} from '../../../../fixtures/ComfyPage' + +test.describe('Vue Integer Widget', () => { + test.beforeEach(async ({ comfyPage }) => { + await comfyPage.setSetting('Comfy.VueNodes.Enabled', true) + await comfyPage.setup() + }) + + test('should be disabled and not allow changing value when link connected to slot', async ({ + comfyPage + }) => { + await comfyPage.loadWorkflow('vueNodes/linked-int-widget') + await comfyPage.vueNodes.waitForNodes() + + const seedWidget = comfyPage.vueNodes.getWidgetByName('KSampler', 'seed') + const controls = comfyPage.vueNodes.getInputNumberControls(seedWidget) + const initialValue = Number(await controls.input.inputValue()) + + // Verify widget is disabled when linked + await controls.incrementButton.click({ force: true }) + await expect(controls.input).toHaveValue(initialValue.toString()) + + await controls.decrementButton.click({ force: true }) + await expect(controls.input).toHaveValue(initialValue.toString()) + + await expect(seedWidget).toBeVisible() + + // Delete the node that is linked to the slot (freeing up the widget) + await comfyPage.vueNodes.getNodeByTitle('Int').click() + await comfyPage.vueNodes.deleteSelected() + + // Test widget works when unlinked + await controls.incrementButton.click() + await expect(controls.input).toHaveValue((initialValue + 1).toString()) + + await controls.decrementButton.click() + await expect(controls.input).toHaveValue(initialValue.toString()) + }) +}) diff --git a/src/composables/graph/useGraphNodeManager.ts b/src/composables/graph/useGraphNodeManager.ts index 7c19c53c4d..2be0798739 100644 --- a/src/composables/graph/useGraphNodeManager.ts +++ b/src/composables/graph/useGraphNodeManager.ts @@ -13,7 +13,19 @@ import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2' import { useNodeDefStore } from '@/stores/nodeDefStore' import type { WidgetValue } from '@/types/simplifiedWidget' -import type { LGraph, LGraphNode } from '../../lib/litegraph/src/litegraph' +import type { + LGraph, + LGraphNode, + LGraphTriggerAction, + LGraphTriggerEvent, + LGraphTriggerParam +} from '../../lib/litegraph/src/litegraph' +import { NodeSlotType } from '../../lib/litegraph/src/types/globalEnums' + +export interface WidgetSlotMetadata { + index: number + linked: boolean +} export interface SafeWidgetData { name: string @@ -23,6 +35,7 @@ export interface SafeWidgetData { options?: Record callback?: ((value: unknown) => void) | undefined spec?: InputSpec + slotMetadata?: WidgetSlotMetadata } export interface VueNodeData { @@ -66,6 +79,37 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager { // Non-reactive storage for original LiteGraph nodes const nodeRefs = new Map() + const refreshNodeSlots = (nodeId: string) => { + const nodeRef = nodeRefs.get(nodeId) + const currentData = vueNodeData.get(nodeId) + + if (!nodeRef || !currentData) return + + // Only extract slot-related data instead of full node re-extraction + const slotMetadata = new Map() + + nodeRef.inputs?.forEach((input, index) => { + if (!input?.widget?.name) return + slotMetadata.set(input.widget.name, { + index, + linked: input.link != null + }) + }) + + // Update only widgets with new slot metadata, keeping other widget data intact + const updatedWidgets = currentData.widgets?.map((widget) => { + const slotInfo = slotMetadata.get(widget.name) + return slotInfo ? { ...widget, slotMetadata: slotInfo } : widget + }) + + vueNodeData.set(nodeId, { + ...currentData, + widgets: updatedWidgets, + inputs: nodeRef.inputs ? [...nodeRef.inputs] : undefined, + outputs: nodeRef.outputs ? [...nodeRef.outputs] : undefined + }) + } + // Extract safe data from LiteGraph node for Vue consumption const extractVueNodeData = (node: LGraphNode): VueNodeData => { // Determine subgraph ID - null for root graph, string for subgraphs @@ -74,6 +118,16 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager { ? String(node.graph.id) : null // Extract safe widget data + const slotMetadata = new Map() + + node.inputs?.forEach((input, index) => { + if (!input?.widget?.name) return + slotMetadata.set(input.widget.name, { + index, + linked: input.link != null + }) + }) + const safeWidgets = node.widgets?.map((widget) => { try { // TODO: Use widget.getReactiveData() once TypeScript types are updated @@ -90,6 +144,7 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager { value = widget.options.values[0] } const spec = nodeDefStore.getInputSpecForWidget(node, widget.name) + const slotInfo = slotMetadata.get(widget.name) return { name: widget.name, @@ -98,7 +153,8 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager { label: widget.label, options: widget.options ? { ...widget.options } : undefined, callback: widget.callback, - spec + spec, + slotMetadata: slotInfo } } catch (error) { return { @@ -373,7 +429,7 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager { const createCleanupFunction = ( originalOnNodeAdded: ((node: LGraphNode) => void) | undefined, originalOnNodeRemoved: ((node: LGraphNode) => void) | undefined, - originalOnTrigger: ((action: string, param: unknown) => void) | undefined + originalOnTrigger: ((event: LGraphTriggerEvent) => void) | undefined ) => { return () => { // Restore original callbacks @@ -405,29 +461,19 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager { handleNodeRemoved(node, originalOnNodeRemoved) } - // Listen for property change events from instrumented nodes - graph.onTrigger = (action: string, param: unknown) => { - if ( - action === 'node:property:changed' && - param && - typeof param === 'object' - ) { - const event = param as { - nodeId: string | number - property: string - oldValue: unknown - newValue: unknown - } - - const nodeId = String(event.nodeId) + const triggerHandlers: { + [K in LGraphTriggerAction]: (event: LGraphTriggerParam) => void + } = { + 'node:property:changed': (propertyEvent) => { + const nodeId = String(propertyEvent.nodeId) const currentData = vueNodeData.get(nodeId) if (currentData) { - switch (event.property) { + switch (propertyEvent.property) { case 'title': vueNodeData.set(nodeId, { ...currentData, - title: String(event.newValue) + title: String(propertyEvent.newValue) }) break case 'flags.collapsed': @@ -435,7 +481,7 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager { ...currentData, flags: { ...currentData.flags, - collapsed: Boolean(event.newValue) + collapsed: Boolean(propertyEvent.newValue) } }) break @@ -444,22 +490,25 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager { ...currentData, flags: { ...currentData.flags, - pinned: Boolean(event.newValue) + pinned: Boolean(propertyEvent.newValue) } }) break case 'mode': vueNodeData.set(nodeId, { ...currentData, - mode: typeof event.newValue === 'number' ? event.newValue : 0 + mode: + typeof propertyEvent.newValue === 'number' + ? propertyEvent.newValue + : 0 }) break case 'color': vueNodeData.set(nodeId, { ...currentData, color: - typeof event.newValue === 'string' - ? event.newValue + typeof propertyEvent.newValue === 'string' + ? propertyEvent.newValue : undefined }) break @@ -467,40 +516,38 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager { vueNodeData.set(nodeId, { ...currentData, bgcolor: - typeof event.newValue === 'string' - ? event.newValue + typeof propertyEvent.newValue === 'string' + ? propertyEvent.newValue : undefined }) } } - } else if ( - action === 'node:slot-errors:changed' && - param && - typeof param === 'object' - ) { - const event = param as { nodeId: string | number } - const nodeId = String(event.nodeId) - const litegraphNode = nodeRefs.get(nodeId) - const currentData = vueNodeData.get(nodeId) - - if (litegraphNode && currentData) { - // Re-extract slot data with updated hasErrors properties - vueNodeData.set(nodeId, { - ...currentData, - inputs: litegraphNode.inputs - ? [...litegraphNode.inputs] - : undefined, - outputs: litegraphNode.outputs - ? [...litegraphNode.outputs] - : undefined - }) + }, + 'node:slot-errors:changed': (slotErrorsEvent) => { + refreshNodeSlots(String(slotErrorsEvent.nodeId)) + }, + 'node:slot-links:changed': (slotLinksEvent) => { + if (slotLinksEvent.slotType === NodeSlotType.INPUT) { + refreshNodeSlots(String(slotLinksEvent.nodeId)) } } + } - // Call original trigger handler if it exists - if (originalOnTrigger) { - originalOnTrigger(action, param) + graph.onTrigger = (event: LGraphTriggerEvent) => { + switch (event.type) { + case 'node:property:changed': + triggerHandlers['node:property:changed'](event) + break + case 'node:slot-errors:changed': + triggerHandlers['node:slot-errors:changed'](event) + break + case 'node:slot-links:changed': + triggerHandlers['node:slot-links:changed'](event) + break } + + // Chain to original handler + originalOnTrigger?.(event) } // Initialize state diff --git a/src/lib/litegraph/src/LGraph.ts b/src/lib/litegraph/src/LGraph.ts index bb629f628a..34532c2960 100644 --- a/src/lib/litegraph/src/LGraph.ts +++ b/src/lib/litegraph/src/LGraph.ts @@ -54,6 +54,12 @@ import { splitPositionables } from './subgraph/subgraphUtils' import { Alignment, LGraphEventMode } from './types/globalEnums' +import type { + LGraphTriggerAction, + LGraphTriggerEvent, + LGraphTriggerHandler, + LGraphTriggerParam +} from './types/graphTriggers' import type { ExportedSubgraph, ExposedWidget, @@ -65,6 +71,11 @@ import type { } from './types/serialisation' import { getAllNestedItems } from './utils/collections' +export type { + LGraphTriggerAction, + LGraphTriggerParam +} from './types/graphTriggers' + export interface LGraphState { lastGroupId: number lastNodeId: number @@ -254,7 +265,7 @@ export class LGraph onExecuteStep?(): void onNodeAdded?(node: LGraphNode): void onNodeRemoved?(node: LGraphNode): void - onTrigger?(action: string, param: unknown): void + onTrigger?: LGraphTriggerHandler onBeforeChange?(graph: LGraph, info?: LGraphNode): void onAfterChange?(graph: LGraph, info?: LGraphNode | null): void onConnectionChange?(node: LGraphNode): void @@ -1180,8 +1191,23 @@ export class LGraph } // ********** GLOBALS ***************** + trigger( + action: A, + param: LGraphTriggerParam + ): void + trigger(action: string, param: unknown): void trigger(action: string, param: unknown) { - this.onTrigger?.(action, param) + // Convert to discriminated union format for typed handlers + const validEventTypes = new Set([ + 'node:slot-links:changed', + 'node:slot-errors:changed', + 'node:property:changed' + ]) + + if (validEventTypes.has(action) && param && typeof param === 'object') { + this.onTrigger?.({ type: action, ...param } as LGraphTriggerEvent) + } + // Don't handle unknown events - just ignore them } /** @todo Clean up - never implemented. */ diff --git a/src/lib/litegraph/src/LGraphNode.ts b/src/lib/litegraph/src/LGraphNode.ts index 0bd20eab14..87ced28574 100644 --- a/src/lib/litegraph/src/LGraphNode.ts +++ b/src/lib/litegraph/src/LGraphNode.ts @@ -2852,7 +2852,17 @@ export class LGraphNode output.links ??= [] output.links.push(link.id) // connect in input - inputNode.inputs[inputIndex].link = link.id + const targetInput = inputNode.inputs[inputIndex] + targetInput.link = link.id + if (targetInput.widget) { + graph.trigger('node:slot-links:changed', { + nodeId: inputNode.id, + slotType: NodeSlotType.INPUT, + slotIndex: inputIndex, + connected: true, + linkId: link.id + }) + } // Reroutes const reroutes = LLink.getReroutes(graph, link) @@ -3009,6 +3019,15 @@ export class LGraphNode const input = target.inputs[link_info.target_slot] // remove there input.link = null + if (input.widget) { + graph.trigger('node:slot-links:changed', { + nodeId: target.id, + slotType: NodeSlotType.INPUT, + slotIndex: link_info.target_slot, + connected: false, + linkId: link_info.id + }) + } // remove the link from the links pool link_info.disconnect(graph, 'input') @@ -3045,6 +3064,15 @@ export class LGraphNode const input = target.inputs[link_info.target_slot] // remove other side link input.link = null + if (input.widget) { + graph.trigger('node:slot-links:changed', { + nodeId: target.id, + slotType: NodeSlotType.INPUT, + slotIndex: link_info.target_slot, + connected: false, + linkId: link_info.id + }) + } // link_info hasn't been modified so its ok target.onConnectionsChange?.( @@ -3114,6 +3142,15 @@ export class LGraphNode const link_id = this.inputs[slot].link if (link_id != null) { this.inputs[slot].link = null + if (input.widget) { + graph.trigger('node:slot-links:changed', { + nodeId: this.id, + slotType: NodeSlotType.INPUT, + slotIndex: slot, + connected: false, + linkId: link_id + }) + } // remove other side const link_info = graph._links.get(link_id) diff --git a/src/lib/litegraph/src/litegraph.ts b/src/lib/litegraph/src/litegraph.ts index 098c30e7a9..c283937b2c 100644 --- a/src/lib/litegraph/src/litegraph.ts +++ b/src/lib/litegraph/src/litegraph.ts @@ -98,7 +98,12 @@ export type { Positionable, Size } from './interfaces' -export { LGraph } from './LGraph' +export { + LGraph, + type LGraphTriggerAction, + type LGraphTriggerParam +} from './LGraph' +export type { LGraphTriggerEvent } from './types/graphTriggers' export { BadgePosition, LGraphBadge } from './LGraphBadge' export { LGraphCanvas } from './LGraphCanvas' export { LGraphGroup } from './LGraphGroup' diff --git a/src/lib/litegraph/src/types/graphTriggers.ts b/src/lib/litegraph/src/types/graphTriggers.ts new file mode 100644 index 0000000000..6b6492f16c --- /dev/null +++ b/src/lib/litegraph/src/types/graphTriggers.ts @@ -0,0 +1,38 @@ +import type { NodeId } from '../LGraphNode' +import type { NodeSlotType } from './globalEnums' + +interface NodePropertyChangedEvent { + type: 'node:property:changed' + nodeId: NodeId + property: string + oldValue: unknown + newValue: unknown +} + +interface NodeSlotErrorsChangedEvent { + type: 'node:slot-errors:changed' + nodeId: NodeId +} + +interface NodeSlotLinksChangedEvent { + type: 'node:slot-links:changed' + nodeId: NodeId + slotType: NodeSlotType + slotIndex: number + connected: boolean + linkId: number +} + +export type LGraphTriggerEvent = + | NodePropertyChangedEvent + | NodeSlotErrorsChangedEvent + | NodeSlotLinksChangedEvent + +export type LGraphTriggerAction = LGraphTriggerEvent['type'] + +export type LGraphTriggerParam = Extract< + LGraphTriggerEvent, + { type: A } +> + +export type LGraphTriggerHandler = (event: LGraphTriggerEvent) => void diff --git a/src/renderer/core/layout/sync/useSlotLayoutSync.ts b/src/renderer/core/layout/sync/useSlotLayoutSync.ts index 099f2497da..6d7812dd73 100644 --- a/src/renderer/core/layout/sync/useSlotLayoutSync.ts +++ b/src/renderer/core/layout/sync/useSlotLayoutSync.ts @@ -95,19 +95,19 @@ export function useSlotLayoutSync() { } } - graph.onTrigger = (action: string, param: any) => { + graph.onTrigger = (event) => { if ( - action === 'node:property:changed' && - param?.property === 'flags.collapsed' + event.type === 'node:property:changed' && + event.property === 'flags.collapsed' ) { - const node = graph.getNodeById(parseInt(String(param.nodeId))) + const node = graph.getNodeById(parseInt(String(event.nodeId))) if (node) { computeAndRegisterSlots(node) } } - if (origTrigger) { - origTrigger.call(graph, action, param) - } + + // Chain to original handler + origTrigger?.(event) } graph.onAfterChange = (graph: any, node?: any) => { diff --git a/src/renderer/extensions/vueNodes/components/NodeWidgets.vue b/src/renderer/extensions/vueNodes/components/NodeWidgets.vue index 51aa132b39..fa46c3b6a5 100644 --- a/src/renderer/extensions/vueNodes/components/NodeWidgets.vue +++ b/src/renderer/extensions/vueNodes/components/NodeWidgets.vue @@ -33,7 +33,7 @@ boundingRect: [0, 0, 0, 0] }" :node-id="nodeData?.id != null ? String(nodeData.id) : ''" - :index="getWidgetInputIndex(widget)" + :index="widget.slotMetadata?.index ?? 0" :dot-only="true" /> @@ -56,12 +56,12 @@ import { type Ref, computed, inject, onErrorCaptured, ref } from 'vue' import type { SafeWidgetData, - VueNodeData + VueNodeData, + WidgetSlotMetadata } from '@/composables/graph/useGraphNodeManager' import { useErrorHandling } from '@/composables/useErrorHandling' import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions' import { useNodeTooltips } from '@/renderer/extensions/vueNodes/composables/useNodeTooltips' -// Import widget components directly import WidgetInputText from '@/renderer/extensions/vueNodes/widgets/components/WidgetInputText.vue' import { getComponent, @@ -113,6 +113,7 @@ interface ProcessedWidget { value: WidgetValue updateHandler: (value: unknown) => void tooltipConfig: any + slotMetadata?: WidgetSlotMetadata } const processedWidgets = computed((): ProcessedWidget[] => { @@ -129,12 +130,24 @@ const processedWidgets = computed((): ProcessedWidget[] => { const vueComponent = getComponent(widget.type) || WidgetInputText + const slotMetadata = widget.slotMetadata + + let widgetOptions = widget.options + // 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 + if (slotMetadata?.linked) { + widgetOptions = widget.options + ? { ...widget.options, disabled: true } + : { disabled: true } + } + const simplified: SimplifiedWidget = { name: widget.name, type: widget.type, value: widget.value, label: widget.label, - options: widget.options, + options: widgetOptions, callback: widget.callback, spec: widget.spec } @@ -155,25 +168,11 @@ const processedWidgets = computed((): ProcessedWidget[] => { simplified, value: widget.value, updateHandler, - tooltipConfig + tooltipConfig, + slotMetadata }) } return result }) - -// TODO: Refactor to avoid O(n) lookup - consider storing input index on widget creation -// or restructuring data model to unify widgets and inputs -// Map a widget to its corresponding input slot index -const getWidgetInputIndex = (widget: ProcessedWidget): number => { - const inputs = nodeData?.inputs - if (!inputs) return 0 - - const idx = inputs.findIndex((input: any) => { - if (!input || typeof input !== 'object') return false - if (!('name' in input && 'type' in input)) return false - return 'widget' in input && input.widget?.name === widget.name - }) - return idx >= 0 ? idx : 0 -} diff --git a/src/renderer/extensions/vueNodes/widgets/components/WidgetInputNumberInput.vue b/src/renderer/extensions/vueNodes/widgets/components/WidgetInputNumberInput.vue index 46a2b10a76..bf8571475f 100644 --- a/src/renderer/extensions/vueNodes/widgets/components/WidgetInputNumberInput.vue +++ b/src/renderer/extensions/vueNodes/widgets/components/WidgetInputNumberInput.vue @@ -90,6 +90,7 @@ const buttonTooltip = computed(() => { :step="stepValue" :use-grouping="useGrouping" :class="cn(WidgetInputBaseClass, 'w-full text-xs')" + :aria-label="widget.name" :pt="{ incrementButton: '!rounded-r-lg bg-transparent border-none hover:bg-zinc-500/30 active:bg-zinc-500/40', diff --git a/tests-ui/tests/litegraph/core/__snapshots__/LGraph.test.ts.snap b/tests-ui/tests/litegraph/core/__snapshots__/LGraph.test.ts.snap index 30036328f4..18cc5c7138 100644 --- a/tests-ui/tests/litegraph/core/__snapshots__/LGraph.test.ts.snap +++ b/tests-ui/tests/litegraph/core/__snapshots__/LGraph.test.ts.snap @@ -283,6 +283,7 @@ LGraph { "nodes_actioning": [], "nodes_executedAction": [], "nodes_executing": [], + "onTrigger": undefined, "revision": 0, "runningtime": 0, "starttime": 0,