diff --git a/src/composables/graph/useGraphNodeManager.ts b/src/composables/graph/useGraphNodeManager.ts index d207d8bcc5..2c28ca1a6d 100644 --- a/src/composables/graph/useGraphNodeManager.ts +++ b/src/composables/graph/useGraphNodeManager.ts @@ -3,7 +3,8 @@ * Provides event-driven reactivity with performance optimizations */ import { reactiveComputed } from '@vueuse/core' -import { reactive, shallowReactive } from 'vue' +import { reactive, ref, shallowReactive, watch } from 'vue' +import type { Ref } from 'vue' import { useChainCallback } from '@/composables/functional/useChainCallback' import type { @@ -19,7 +20,6 @@ import { isDOMWidget } from '@/scripts/domWidget' import { useNodeDefStore } from '@/stores/nodeDefStore' import type { WidgetValue, - SafeControlWidget, ControlWidgetOptions } from '@/types/simplifiedWidget' @@ -41,14 +41,14 @@ export interface WidgetSlotMetadata { export interface SafeWidgetData { name: string type: string - value: WidgetValue + value: () => Ref label?: string options?: Record callback?: ((value: unknown) => void) | undefined spec?: InputSpec slotMetadata?: WidgetSlotMetadata isDOMWidget?: boolean - controlWidget?: SafeControlWidget + controlWidget?: () => Ref } export interface VueNodeData { @@ -84,8 +84,40 @@ export interface GraphNodeManager { cleanup(): void } +/** + * Validates that a value is a valid WidgetValue type + */ +function validateWidgetValue(value: unknown): WidgetValue { + if (value === null || value === undefined || value === void 0) { + return undefined + } + if ( + typeof value === 'string' || + typeof value === 'number' || + typeof value === 'boolean' + ) { + return value + } + if (typeof value === 'object') { + // Check if it's a File array + if ( + Array.isArray(value) && + value.length > 0 && + value.every((item): item is File => item instanceof File) + ) { + return value + } + // Otherwise it's a generic object + return value + } + // If none of the above, return undefined + console.warn(`Invalid widget value type: ${typeof value}`, value) + return undefined +} + function validateControlWidgetValue(val: unknown): ControlWidgetOptions { //TODO: Is there a way to do this without repeating? + //NOTE: global is not currently allowed switch (val) { case 'fixed': return 'fixed' @@ -96,15 +128,21 @@ function validateControlWidgetValue(val: unknown): ControlWidgetOptions { } return 'randomize' } -function getControlWidget(widget: IBaseWidget): SafeControlWidget | undefined { +function getControlWidget( + widget: IBaseWidget +): (() => Ref) | undefined { const cagWidget = widget.linkedWidgets?.find( (w) => w.name == 'control_after_generate' ) if (!cagWidget) return - return { - value: validateControlWidgetValue(cagWidget.value), - update: (value) => (cagWidget.value = validateControlWidgetValue(value)) - } + const cagRef = ref( + validateControlWidgetValue(cagWidget.value) + ) + watch(cagRef, (value) => { + cagWidget.value = value + cagWidget.callback?.(value) + }) + return () => cagRef } export function safeWidgetMapper( @@ -114,18 +152,27 @@ export function safeWidgetMapper( const nodeDefStore = useNodeDefStore() return function (widget) { try { - // TODO: Use widget.getReactiveData() once TypeScript types are updated - let value = widget.value - // For combo widgets, if value is undefined, use the first option as default if ( - value === undefined && + widget.value === undefined && widget.type === 'combo' && widget.options?.values && Array.isArray(widget.options.values) && widget.options.values.length > 0 ) { - value = widget.options.values[0] + widget.value = widget.options.values[0] + } + if (!widget.valueRef) { + const valueRef = ref(widget.value) + watch(valueRef, (newValue) => { + widget.value = newValue + widget.callback?.(newValue) + }) + widget.callback = useChainCallback(widget.callback, () => { + if (valueRef.value !== widget.value) + valueRef.value = validateWidgetValue(widget.value) ?? undefined + }) + widget.valueRef = () => valueRef } const spec = nodeDefStore.getInputSpecForWidget(node, widget.name) const slotInfo = slotMetadata.get(widget.name) @@ -133,10 +180,9 @@ export function safeWidgetMapper( return { name: widget.name, type: widget.type, - value: value, + value: widget.valueRef, label: widget.label, options: widget.options ? { ...widget.options } : undefined, - callback: widget.callback, spec, slotMetadata: slotInfo, isDOMWidget: isDOMWidget(widget), @@ -146,7 +192,7 @@ export function safeWidgetMapper( return { name: widget.name || 'unknown', type: widget.type || 'text', - value: undefined + value: () => ref() } } } @@ -222,6 +268,15 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager { reactiveWidgets.splice(0, reactiveWidgets.length, ...v) } }) + const reactiveInputs = shallowReactive(node.inputs ?? []) + Object.defineProperty(node, 'inputs', { + get() { + return reactiveInputs + }, + set(v) { + reactiveInputs.splice(0, reactiveInputs.length, ...v) + } + }) const safeWidgets = reactiveComputed(() => { node.inputs?.forEach((input, index) => { @@ -256,7 +311,7 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager { badges, hasErrors: !!node.has_errors, widgets: safeWidgets, - inputs: node.inputs ? [...node.inputs] : undefined, + inputs: reactiveInputs, outputs: node.outputs ? [...node.outputs] : undefined, flags: node.flags ? { ...node.flags } : undefined, color: node.color || undefined, @@ -269,128 +324,6 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager { return nodeRefs.get(id) } - /** - * Validates that a value is a valid WidgetValue type - */ - const validateWidgetValue = (value: unknown): WidgetValue => { - if (value === null || value === undefined || value === void 0) { - return undefined - } - if ( - typeof value === 'string' || - typeof value === 'number' || - typeof value === 'boolean' - ) { - return value - } - if (typeof value === 'object') { - // Check if it's a File array - if ( - Array.isArray(value) && - value.length > 0 && - value.every((item): item is File => item instanceof File) - ) { - return value - } - // Otherwise it's a generic object - return value - } - // If none of the above, return undefined - console.warn(`Invalid widget value type: ${typeof value}`, value) - return undefined - } - - /** - * Updates Vue state when widget values change - */ - const updateVueWidgetState = ( - nodeId: string, - widgetName: string, - value: unknown - ): void => { - try { - const currentData = vueNodeData.get(nodeId) - if (!currentData?.widgets) return - - const updatedWidgets = currentData.widgets.map((w) => - w.name === widgetName ? { ...w, value: validateWidgetValue(value) } : w - ) - // Create a completely new object to ensure Vue reactivity triggers - const updatedData = { - ...currentData, - widgets: updatedWidgets - } - - vueNodeData.set(nodeId, updatedData) - } catch (error) { - // Ignore widget update errors to prevent cascade failures - } - } - - /** - * Creates a wrapped callback for a widget that maintains LiteGraph/Vue sync - */ - const createWrappedWidgetCallback = ( - widget: { value?: unknown; name: string }, // LiteGraph widget with minimal typing - originalCallback: ((value: unknown) => void) | undefined, - nodeId: string - ) => { - let updateInProgress = false - - return (value: unknown) => { - if (updateInProgress) return - updateInProgress = true - - try { - // 1. Update the widget value in LiteGraph (critical for LiteGraph state) - // Validate that the value is of an acceptable type - if ( - value !== null && - value !== undefined && - typeof value !== 'string' && - typeof value !== 'number' && - typeof value !== 'boolean' && - typeof value !== 'object' - ) { - console.warn(`Invalid widget value type: ${typeof value}`) - updateInProgress = false - return - } - - // Always update widget.value to ensure sync - widget.value = value - - // 2. Call the original callback if it exists - if (originalCallback) { - originalCallback.call(widget, value) - } - - // 3. Update Vue state to maintain synchronization - updateVueWidgetState(nodeId, widget.name, value) - } finally { - updateInProgress = false - } - } - } - - /** - * Sets up widget callbacks for a node - */ - const setupNodeWidgetCallbacks = (node: LGraphNode) => { - if (!node.widgets) return - - const nodeId = String(node.id) - - node.widgets.forEach((widget) => { - const originalCallback = widget.callback - widget.callback = createWrappedWidgetCallback( - widget, - originalCallback, - nodeId - ) - }) - } - const syncWithGraph = () => { if (!graph?._nodes) return @@ -411,9 +344,6 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager { // Store non-reactive reference nodeRefs.set(id, node) - // Set up widget callbacks BEFORE extracting data (critical order) - setupNodeWidgetCallbacks(node) - // Extract and store safe data for Vue vueNodeData.set(id, extractVueNodeData(node)) }) @@ -432,9 +362,6 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager { // Store non-reactive reference to original node nodeRefs.set(id, node) - // Set up widget callbacks BEFORE extracting data (critical order) - setupNodeWidgetCallbacks(node) - // Extract initial data for Vue (may be incomplete during graph configure) vueNodeData.set(id, extractVueNodeData(node)) diff --git a/src/extensions/core/widgetInputs.ts b/src/extensions/core/widgetInputs.ts index 2c2376b11f..29450745c9 100644 --- a/src/extensions/core/widgetInputs.ts +++ b/src/extensions/core/widgetInputs.ts @@ -253,6 +253,7 @@ export class PrimitiveNode extends LGraphNode { undefined, inputData ) + if (this.widgets?.[1]) widget.linkedWidgets = [this.widgets[1]] let filter = this.widgets_values?.[2] if (filter && this.widgets && this.widgets.length === 3) { this.widgets[2].value = filter diff --git a/src/lib/litegraph/src/types/widgets.ts b/src/lib/litegraph/src/types/widgets.ts index 5f0d1b9a35..a203484f4c 100644 --- a/src/lib/litegraph/src/types/widgets.ts +++ b/src/lib/litegraph/src/types/widgets.ts @@ -1,3 +1,5 @@ +import type { Ref } from 'vue' + import type { CanvasColour, Point, RequiredProps, Size } from '../interfaces' import type { CanvasPointer, LGraphCanvas, LGraphNode } from '../litegraph' import type { CanvasPointerEvent } from './events' @@ -267,6 +269,7 @@ export interface IBaseWidget< /** Widget type (see {@link TWidgetType}) */ type: TType value?: TValue + valueRef?: () => Ref /** * Whether the widget value should be serialized on node serialization. diff --git a/src/renderer/extensions/vueNodes/components/LGraphNodePreview.vue b/src/renderer/extensions/vueNodes/components/LGraphNodePreview.vue index 1e39d01322..c57606a89a 100644 --- a/src/renderer/extensions/vueNodes/components/LGraphNodePreview.vue +++ b/src/renderer/extensions/vueNodes/components/LGraphNodePreview.vue @@ -23,7 +23,7 @@ diff --git a/src/renderer/extensions/vueNodes/widgets/components/WidgetChart.vue b/src/renderer/extensions/vueNodes/widgets/components/WidgetChart.vue index cadd4754bf..46df05778b 100644 --- a/src/renderer/extensions/vueNodes/widgets/components/WidgetChart.vue +++ b/src/renderer/extensions/vueNodes/widgets/components/WidgetChart.vue @@ -21,12 +21,12 @@ import type { SimplifiedWidget } from '@/types/simplifiedWidget' type ChartWidgetOptions = NonNullable -const value = defineModel({ required: true }) - const props = defineProps<{ widget: SimplifiedWidget }>() +const value = props.widget.value() + const chartType = computed(() => props.widget.options?.type ?? 'line') const chartData = computed(() => value.value || { labels: [], datasets: [] }) diff --git a/src/renderer/extensions/vueNodes/widgets/components/WidgetColorPicker.test.ts b/src/renderer/extensions/vueNodes/widgets/components/WidgetColorPicker.test.ts index 32a937f89a..70f7e240d0 100644 --- a/src/renderer/extensions/vueNodes/widgets/components/WidgetColorPicker.test.ts +++ b/src/renderer/extensions/vueNodes/widgets/components/WidgetColorPicker.test.ts @@ -3,6 +3,7 @@ import ColorPicker from 'primevue/colorpicker' import type { ColorPickerProps } from 'primevue/colorpicker' import PrimeVue from 'primevue/config' import { describe, expect, it } from 'vitest' +import { ref, watch } from 'vue' import type { SimplifiedWidget } from '@/types/simplifiedWidget' @@ -14,13 +15,16 @@ describe('WidgetColorPicker Value Binding', () => { value: string = '#000000', options: Partial = {}, callback?: (value: string) => void - ): SimplifiedWidget => ({ - name: 'test_color_picker', - type: 'color', - value, - options, - callback - }) + ): SimplifiedWidget => { + const valueRef = ref(value) + if (callback) watch(valueRef, (v) => callback(v)) + return { + name: 'test_color_picker', + type: 'color', + value: () => valueRef, + options + } + } const mountComponent = ( widget: SimplifiedWidget, @@ -49,80 +53,61 @@ describe('WidgetColorPicker Value Binding', () => { ) => { const colorPicker = wrapper.findComponent({ name: 'ColorPicker' }) await colorPicker.setValue(value) - return wrapper.emitted('update:modelValue') } - describe('Vue Event Emission', () => { - it('emits Vue event when color changes', async () => { - const widget = createMockWidget('#ff0000') + describe('Value Binding', () => { + it('triggers callback when color changes', async () => { + const callback = vi.fn() + const widget = createMockWidget('#ff0000', {}, callback) const wrapper = mountComponent(widget, '#ff0000') - const emitted = await setColorPickerValue(wrapper, '#00ff00') - - expect(emitted).toBeDefined() - expect(emitted![0]).toContain('#00ff00') + await setColorPickerValue(wrapper, '#00ff00') + expect(callback).toHaveBeenCalledExactlyOnceWith('#00ff00') }) it('handles different color formats', async () => { - const widget = createMockWidget('#ffffff') + const callback = vi.fn() + const widget = createMockWidget('#ffffff', {}, callback) const wrapper = mountComponent(widget, '#ffffff') - const emitted = await setColorPickerValue(wrapper, '#123abc') - - expect(emitted).toBeDefined() - expect(emitted![0]).toContain('#123abc') - }) - - it('handles missing callback gracefully', async () => { - const widget = createMockWidget('#000000', {}, undefined) - const wrapper = mountComponent(widget, '#000000') - - const emitted = await setColorPickerValue(wrapper, '#ff00ff') - - // Should still emit Vue event - expect(emitted).toBeDefined() - expect(emitted![0]).toContain('#ff00ff') + await setColorPickerValue(wrapper, '#123abc') + expect(callback).toHaveBeenCalledExactlyOnceWith('#123abc') }) - it('normalizes bare hex without # to #hex on emit', async () => { - const widget = createMockWidget('ff0000') + it('normalizes bare hex without # to #hex', async () => { + const callback = vi.fn() + const widget = createMockWidget('ff0000', {}, callback) const wrapper = mountComponent(widget, 'ff0000') - const emitted = await setColorPickerValue(wrapper, '00ff00') - expect(emitted).toBeDefined() - expect(emitted![0]).toContain('#00ff00') + await setColorPickerValue(wrapper, '00ff00') + expect(callback).toHaveBeenCalledExactlyOnceWith('#00ff00') }) - it('normalizes rgb() strings to #hex on emit', async (context) => { - context.skip('needs diagnosis') - const widget = createMockWidget('#000000') + it('normalizes rgb() strings to #hex', async () => { + const callback = vi.fn() + const widget = createMockWidget('#000000', { format: 'rgb' }, callback) const wrapper = mountComponent(widget, '#000000') - const emitted = await setColorPickerValue(wrapper, 'rgb(255, 0, 0)') - expect(emitted).toBeDefined() - expect(emitted![0]).toContain('#ff0000') + await setColorPickerValue(wrapper, 'rgb(255, 0, 0)') + expect(callback).toHaveBeenCalledExactlyOnceWith('#ff0000') }) - it('normalizes hsb() strings to #hex on emit', async () => { - const widget = createMockWidget('#000000', { format: 'hsb' }) + it('normalizes hsb() strings to #hex', async () => { + const callback = vi.fn() + const widget = createMockWidget('#000000', { format: 'hsb' }, callback) const wrapper = mountComponent(widget, '#000000') - const emitted = await setColorPickerValue(wrapper, 'hsb(120, 100, 100)') - expect(emitted).toBeDefined() - expect(emitted![0]).toContain('#00ff00') + await setColorPickerValue(wrapper, 'hsb(120, 100, 100)') + expect(callback).toHaveBeenCalledExactlyOnceWith('#00ff00') }) - it('normalizes HSB object values to #hex on emit', async () => { - const widget = createMockWidget('#000000', { format: 'hsb' }) + it('normalizes HSB object values to #hex', async () => { + const callback = vi.fn() + const widget = createMockWidget('#000000', { format: 'hsb' }, callback) const wrapper = mountComponent(widget, '#000000') - const emitted = await setColorPickerValue(wrapper, { - h: 240, - s: 100, - b: 100 - }) - expect(emitted).toBeDefined() - expect(emitted![0]).toContain('#0000ff') + await setColorPickerValue(wrapper, { h: 240, s: 100, b: 100 }) + expect(callback).toHaveBeenCalledExactlyOnceWith('#0000ff') }) }) @@ -265,15 +250,15 @@ describe('WidgetColorPicker Value Binding', () => { }) it('handles invalid color formats gracefully', async () => { - const widget = createMockWidget('invalid-color') + const callback = vi.fn() + const widget = createMockWidget('invalid-color', {}, callback) const wrapper = mountComponent(widget, 'invalid-color') const colorText = wrapper.find('[data-testid="widget-color-text"]') expect(colorText.text()).toBe('#000000') - const emitted = await setColorPickerValue(wrapper, 'invalid-color') - expect(emitted).toBeDefined() - expect(emitted![0]).toContain('#000000') + await setColorPickerValue(wrapper, 'invalid-color') + expect(callback).toHaveBeenCalledExactlyOnceWith('#000000') }) it('handles widget with no options', () => { diff --git a/src/renderer/extensions/vueNodes/widgets/components/WidgetColorPicker.vue b/src/renderer/extensions/vueNodes/widgets/components/WidgetColorPicker.vue index b87f6b6164..bf932c1f54 100644 --- a/src/renderer/extensions/vueNodes/widgets/components/WidgetColorPicker.vue +++ b/src/renderer/extensions/vueNodes/widgets/components/WidgetColorPicker.vue @@ -14,7 +14,6 @@ :pt="{ preview: '!w-full !h-full !border-none' }" - @update:model-value="onPickerUpdate" /> import ColorPicker from 'primevue/colorpicker' -import { computed, ref, watch } from 'vue' +import { computed } from 'vue' import type { SimplifiedWidget } from '@/types/simplifiedWidget' import { isColorFormat, toHexFromFormat } from '@/utils/colorUtil' -import type { ColorFormat, HSB } from '@/utils/colorUtil' +import type { ColorFormat } from '@/utils/colorUtil' import { cn } from '@/utils/tailwindUtil' import { PANEL_EXCLUDED_PROPS, @@ -45,39 +44,23 @@ type WidgetOptions = { format?: ColorFormat } & Record const props = defineProps<{ widget: SimplifiedWidget - modelValue: string }>() -const emit = defineEmits<{ - 'update:modelValue': [value: string] -}>() +const modelValue = props.widget.value() const format = computed(() => { const optionFormat = props.widget.options?.format return isColorFormat(optionFormat) ? optionFormat : 'hex' }) -type PickerValue = string | HSB -const localValue = ref( - toHexFromFormat( - props.modelValue || '#000000', - isColorFormat(props.widget.options?.format) - ? props.widget.options.format - : 'hex' - ) -) - -watch( - () => props.modelValue, - (newVal) => { - localValue.value = toHexFromFormat(newVal || '#000000', format.value) +const localValue = computed({ + get() { + return toHexFromFormat(modelValue.value || '#000000', format.value) + }, + set(v) { + modelValue.value = toHexFromFormat(v, format.value) } -) - -function onPickerUpdate(val: unknown) { - localValue.value = val as PickerValue - emit('update:modelValue', toHexFromFormat(val, format.value)) -} +}) // ColorPicker specific excluded props include panel/overlay classes const COLOR_PICKER_EXCLUDED_PROPS = [...PANEL_EXCLUDED_PROPS] as const diff --git a/src/renderer/extensions/vueNodes/widgets/components/WidgetGalleria.test.ts b/src/renderer/extensions/vueNodes/widgets/components/WidgetGalleria.test.ts index 399edb5144..fcdbf214ea 100644 --- a/src/renderer/extensions/vueNodes/widgets/components/WidgetGalleria.test.ts +++ b/src/renderer/extensions/vueNodes/widgets/components/WidgetGalleria.test.ts @@ -4,6 +4,7 @@ import Galleria from 'primevue/galleria' import type { GalleriaProps } from 'primevue/galleria' import { describe, expect, it } from 'vitest' import { createI18n } from 'vue-i18n' +import { ref } from 'vue' import type { SimplifiedWidget } from '@/types/simplifiedWidget' @@ -52,7 +53,7 @@ function createMockWidget( return { name: 'test_galleria', type: 'array', - value, + value: () => ref(value), options } } diff --git a/src/renderer/extensions/vueNodes/widgets/components/WidgetGalleria.vue b/src/renderer/extensions/vueNodes/widgets/components/WidgetGalleria.vue index e32acb7817..5408728f34 100644 --- a/src/renderer/extensions/vueNodes/widgets/components/WidgetGalleria.vue +++ b/src/renderer/extensions/vueNodes/widgets/components/WidgetGalleria.vue @@ -68,12 +68,12 @@ export interface GalleryImage { export type GalleryValue = string[] | GalleryImage[] -const value = defineModel({ required: true }) - const props = defineProps<{ widget: SimplifiedWidget }>() +const value = props.widget.value() + const activeIndex = ref(0) const { t } = useI18n() diff --git a/src/renderer/extensions/vueNodes/widgets/components/WidgetImageCompare.test.ts b/src/renderer/extensions/vueNodes/widgets/components/WidgetImageCompare.test.ts index 847ffc6574..0addbe09ff 100644 --- a/src/renderer/extensions/vueNodes/widgets/components/WidgetImageCompare.test.ts +++ b/src/renderer/extensions/vueNodes/widgets/components/WidgetImageCompare.test.ts @@ -2,6 +2,7 @@ import { mount } from '@vue/test-utils' import PrimeVue from 'primevue/config' import ImageCompare from 'primevue/imagecompare' import { describe, expect, it } from 'vitest' +import { ref } from 'vue' import type { SimplifiedWidget } from '@/types/simplifiedWidget' @@ -15,7 +16,7 @@ describe('WidgetImageCompare Display', () => { ): SimplifiedWidget => ({ name: 'test_imagecompare', type: 'object', - value, + value: () => ref(value), options }) diff --git a/src/renderer/extensions/vueNodes/widgets/components/WidgetImageCompare.vue b/src/renderer/extensions/vueNodes/widgets/components/WidgetImageCompare.vue index 34516a120e..15205ffbad 100644 --- a/src/renderer/extensions/vueNodes/widgets/components/WidgetImageCompare.vue +++ b/src/renderer/extensions/vueNodes/widgets/components/WidgetImageCompare.vue @@ -44,24 +44,24 @@ const props = defineProps<{ }>() const beforeImage = computed(() => { - const value = props.widget.value + const value = props.widget.value().value return typeof value === 'string' ? value : value?.before || '' }) const afterImage = computed(() => { - const value = props.widget.value + const value = props.widget.value().value return typeof value === 'string' ? '' : value?.after || '' }) const beforeAlt = computed(() => { - const value = props.widget.value + const value = props.widget.value().value return typeof value === 'object' && value?.beforeAlt ? value.beforeAlt : 'Before image' }) const afterAlt = computed(() => { - const value = props.widget.value + const value = props.widget.value().value return typeof value === 'object' && value?.afterAlt ? value.afterAlt : 'After image' diff --git a/src/renderer/extensions/vueNodes/widgets/components/WidgetInputNumber.vue b/src/renderer/extensions/vueNodes/widgets/components/WidgetInputNumber.vue index 0894114134..57a4b00c57 100644 --- a/src/renderer/extensions/vueNodes/widgets/components/WidgetInputNumber.vue +++ b/src/renderer/extensions/vueNodes/widgets/components/WidgetInputNumber.vue @@ -1,17 +1,20 @@ @@ -20,11 +25,15 @@ import { isCloud } from '@/platform/distribution/types' import { useSettingStore } from '@/platform/settings/settingStore' import WidgetSelectDefault from '@/renderer/extensions/vueNodes/widgets/components/WidgetSelectDefault.vue' import WidgetSelectDropdown from '@/renderer/extensions/vueNodes/widgets/components/WidgetSelectDropdown.vue' +import WidgetWithControl from '@/renderer/extensions/vueNodes/widgets/components/WidgetWithControl.vue' import type { LayoutMode } from '@/renderer/extensions/vueNodes/widgets/components/form/dropdown/types' import type { ResultItemType } from '@/schemas/apiSchema' import { isComboInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2' import type { ComboInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2' -import type { SimplifiedWidget } from '@/types/simplifiedWidget' +import type { + SimplifiedControlWidget, + SimplifiedWidget +} from '@/types/simplifiedWidget' import type { AssetKind } from '@/types/widgetTypes' const props = defineProps<{ @@ -32,7 +41,7 @@ const props = defineProps<{ nodeType?: string }>() -const modelValue = defineModel() +const modelValue = props.widget.value() const comboSpec = computed(() => { if (props.widget.spec && isComboInputSpec(props.widget.spec)) { diff --git a/src/renderer/extensions/vueNodes/widgets/components/WidgetSelectDefault.vue b/src/renderer/extensions/vueNodes/widgets/components/WidgetSelectDefault.vue index 1a8c2bdb5b..aabbb57468 100644 --- a/src/renderer/extensions/vueNodes/widgets/components/WidgetSelectDefault.vue +++ b/src/renderer/extensions/vueNodes/widgets/components/WidgetSelectDefault.vue @@ -1,27 +1,32 @@ + + diff --git a/src/renderer/extensions/vueNodes/widgets/composables/useControlButtonIcon.ts b/src/renderer/extensions/vueNodes/widgets/composables/useControlButtonIcon.ts deleted file mode 100644 index ec8259b3fc..0000000000 --- a/src/renderer/extensions/vueNodes/widgets/composables/useControlButtonIcon.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { computed } from 'vue' -import type { Ref } from 'vue' - -import { NumberControlMode } from './useStepperControl' - -export function useControlButtonIcon(controlMode: Ref) { - return computed(() => { - switch (controlMode.value) { - case NumberControlMode.INCREMENT: - return 'pi pi-plus' - case NumberControlMode.DECREMENT: - return 'pi pi-minus' - case NumberControlMode.FIXED: - return 'icon-[lucide--pencil-off]' - case NumberControlMode.LINK_TO_GLOBAL: - return 'pi pi-link' - default: - return 'icon-[lucide--shuffle]' - } - }) -} diff --git a/src/types/simplifiedWidget.ts b/src/types/simplifiedWidget.ts index 498e77703c..23a44eaf0d 100644 --- a/src/types/simplifiedWidget.ts +++ b/src/types/simplifiedWidget.ts @@ -2,6 +2,8 @@ * Simplified widget interface for Vue-based node rendering * Removes all DOM manipulation and positioning concerns */ +import type { Ref } from 'vue' + import type { InputSpec as InputSpecV2 } from '@/schemas/nodeDef/nodeDefSchemaV2' /** Valid types for widget values */ @@ -20,10 +22,7 @@ export type ControlWidgetOptions = | 'increment' | 'decrement' | 'randomize' -export type SafeControlWidget = { - value: ControlWidgetOptions - update: (value: unknown) => void -} + | 'global' export interface SimplifiedWidget< T extends WidgetValue = WidgetValue, @@ -36,7 +35,7 @@ export interface SimplifiedWidget< type: string /** Current value of the widget */ - value: T + value: () => Ref /** Localized display label (falls back to name if not provided) */ label?: string @@ -44,9 +43,6 @@ export interface SimplifiedWidget< /** Widget options including filtered PrimeVue props */ options?: O - /** Callback fired when value changes */ - callback?: (value: T) => void - /** Optional input specification backing this widget */ spec?: InputSpecV2 @@ -56,5 +52,7 @@ export interface SimplifiedWidget< /** Optional method to compute widget size requirements */ computeSize?: () => { minHeight: number; maxHeight?: number } - controlWidget?: SafeControlWidget + controlWidget?: () => Ref } +export type SimplifiedControlWidget = + SimplifiedWidget & Required, 'controlWidget'>> diff --git a/src/views/LinearView.vue b/src/views/LinearView.vue index 3c81f3b395..e01ba3da86 100644 --- a/src/views/LinearView.vue +++ b/src/views/LinearView.vue @@ -3,7 +3,7 @@ import { storeToRefs } from 'pinia' import Button from 'primevue/button' import Splitter from 'primevue/splitter' import SplitterPanel from 'primevue/splitterpanel' -import { computed } from 'vue' +import { computed, ref } from 'vue' import ExtensionSlot from '@/components/common/ExtensionSlot.vue' import CurrentUserButton from '@/components/topbar/CurrentUserButton.vue' @@ -64,7 +64,7 @@ const isDesktop = isElectron() const batchCountWidget = { options: { step2: 1, precision: 1, min: 1, max: 100 }, - value: 1, + value: () => ref(1), name: t('Number of generations'), type: 'number' } diff --git a/tests-ui/renderer/extensions/vueNodes/components/NodeWidgets.test.ts b/tests-ui/renderer/extensions/vueNodes/components/NodeWidgets.test.ts index 78f6c370a6..ece9889d5e 100644 --- a/tests-ui/renderer/extensions/vueNodes/components/NodeWidgets.test.ts +++ b/tests-ui/renderer/extensions/vueNodes/components/NodeWidgets.test.ts @@ -1,6 +1,7 @@ import { createTestingPinia } from '@pinia/testing' import { mount } from '@vue/test-utils' import { describe, expect, it } from 'vitest' +import { ref } from 'vue' import type { SafeWidgetData, @@ -15,11 +16,10 @@ describe('NodeWidgets', () => { ): SafeWidgetData => ({ name: 'test_widget', type: 'combo', - value: 'test_value', + value: () => ref('test_value'), options: { values: ['option1', 'option2'] }, - callback: undefined, spec: undefined, label: undefined, isDOMWidget: false, diff --git a/tests-ui/renderer/extensions/vueNodes/widgets/components/WidgetSelect.test.ts b/tests-ui/renderer/extensions/vueNodes/widgets/components/WidgetSelect.test.ts index 4d52d47e26..ee30f92b06 100644 --- a/tests-ui/renderer/extensions/vueNodes/widgets/components/WidgetSelect.test.ts +++ b/tests-ui/renderer/extensions/vueNodes/widgets/components/WidgetSelect.test.ts @@ -4,6 +4,7 @@ import PrimeVue from 'primevue/config' import Select from 'primevue/select' import type { SelectProps } from 'primevue/select' import { beforeEach, describe, expect, it, vi } from 'vitest' +import { ref, watch } from 'vue' import type { ComboInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2' import type { SimplifiedWidget } from '@/types/simplifiedWidget' @@ -51,17 +52,20 @@ describe('WidgetSelect Value Binding', () => { > = {}, callback?: (value: string | undefined) => void, spec?: ComboInputSpec - ): SimplifiedWidget => ({ - name: 'test_select', - type: 'combo', - value, - options: { - values: ['option1', 'option2', 'option3'], - ...options - }, - callback, - spec - }) + ): SimplifiedWidget => { + const valueRef = ref(value) + if (callback) watch(valueRef, (v) => callback(v)) + return { + name: 'test_select', + type: 'combo', + value: () => valueRef, + options: { + values: ['option1', 'option2', 'option3'], + ...options + }, + spec + } + } const mountComponent = ( widget: SimplifiedWidget, @@ -81,67 +85,57 @@ describe('WidgetSelect Value Binding', () => { }) } - const setSelectValueAndEmit = async ( + const setSelectValue = async ( wrapper: ReturnType, value: string ) => { const select = wrapper.findComponent({ name: 'Select' }) await select.setValue(value) - return wrapper.emitted('update:modelValue') } - describe('Vue Event Emission', () => { - it('emits Vue event when selection changes', async () => { - const widget = createMockWidget('option1') + describe('Widget Value Callbacks', () => { + it('triggers callback when selection changes', async () => { + const callback = vi.fn() + const widget = createMockWidget('option1', {}, callback) const wrapper = mountComponent(widget, 'option1') - const emitted = await setSelectValueAndEmit(wrapper, 'option2') + await setSelectValue(wrapper, 'option2') - expect(emitted).toBeDefined() - expect(emitted![0]).toContain('option2') + expect(callback).toHaveBeenCalledExactlyOnceWith('option2') }) - it('emits string value for different options', async () => { - const widget = createMockWidget('option1') + it('handles string value for different options', async () => { + const callback = vi.fn() + const widget = createMockWidget('option1', {}, callback) const wrapper = mountComponent(widget, 'option1') - const emitted = await setSelectValueAndEmit(wrapper, 'option3') - - expect(emitted).toBeDefined() - // Should emit the string value - expect(emitted![0]).toContain('option3') + await setSelectValue(wrapper, 'option3') + expect(callback).toHaveBeenCalledExactlyOnceWith('option3') }) it('handles custom option values', async () => { const customOptions = ['custom_a', 'custom_b', 'custom_c'] - const widget = createMockWidget('custom_a', { values: customOptions }) + const callback = vi.fn() + const widget = createMockWidget( + 'custom_a', + { values: customOptions }, + callback + ) const wrapper = mountComponent(widget, 'custom_a') - const emitted = await setSelectValueAndEmit(wrapper, 'custom_b') - - expect(emitted).toBeDefined() - expect(emitted![0]).toContain('custom_b') - }) - - it('handles missing callback gracefully', async () => { - const widget = createMockWidget('option1', {}, undefined) - const wrapper = mountComponent(widget, 'option1') - - const emitted = await setSelectValueAndEmit(wrapper, 'option2') + await setSelectValue(wrapper, 'custom_b') - // Should emit Vue event - expect(emitted).toBeDefined() - expect(emitted![0]).toContain('option2') + expect(callback).toHaveBeenCalledExactlyOnceWith('custom_b') }) it('handles value changes gracefully', async () => { - const widget = createMockWidget('option1') + const callback = vi.fn() + const widget = createMockWidget('option1', {}, callback) const wrapper = mountComponent(widget, 'option1') - const emitted = await setSelectValueAndEmit(wrapper, 'option2') + await setSelectValue(wrapper, 'option2') - expect(emitted).toBeDefined() - expect(emitted![0]).toContain('option2') + expect(callback).toHaveBeenCalledExactlyOnceWith('option2') }) }) @@ -172,43 +166,43 @@ describe('WidgetSelect Value Binding', () => { 'option@#$%', 'option/with\\slashes' ] - const widget = createMockWidget(specialOptions[0], { - values: specialOptions - }) + const callback = vi.fn() + const widget = createMockWidget( + specialOptions[0], + { + values: specialOptions + }, + callback + ) const wrapper = mountComponent(widget, specialOptions[0]) - const emitted = await setSelectValueAndEmit(wrapper, specialOptions[1]) + await setSelectValue(wrapper, specialOptions[1]) - expect(emitted).toBeDefined() - expect(emitted![0]).toContain(specialOptions[1]) + expect(callback).toHaveBeenCalledExactlyOnceWith(specialOptions[1]) }) }) describe('Edge Cases', () => { it('handles selection of non-existent option gracefully', async () => { - const widget = createMockWidget('option1') + const callback = vi.fn() + const widget = createMockWidget('option1', {}, callback) const wrapper = mountComponent(widget, 'option1') - const emitted = await setSelectValueAndEmit( - wrapper, - 'non_existent_option' - ) + await setSelectValue(wrapper, 'non_existent_option') - // Should still emit Vue event with the value - expect(emitted).toBeDefined() - expect(emitted![0]).toContain('non_existent_option') + // Should still trigger callback with the value + expect(callback).toHaveBeenCalledExactlyOnceWith('non_existent_option') }) it('handles numeric string options correctly', async () => { + const callback = vi.fn() const numericOptions = ['1', '2', '10', '100'] - const widget = createMockWidget('1', { values: numericOptions }) + const widget = createMockWidget('1', { values: numericOptions }, callback) const wrapper = mountComponent(widget, '1') - const emitted = await setSelectValueAndEmit(wrapper, '100') + await setSelectValue(wrapper, '100') - // Should maintain string type in emitted event - expect(emitted).toBeDefined() - expect(emitted![0]).toContain('100') + expect(callback).toHaveBeenCalledExactlyOnceWith('100') }) }) diff --git a/tests-ui/tests/renderer/extensions/vueNodes/widgets/components/WidgetSelect.asset-mode.test.ts b/tests-ui/tests/renderer/extensions/vueNodes/widgets/components/WidgetSelect.asset-mode.test.ts index 82224c7104..a68bd19904 100644 --- a/tests-ui/tests/renderer/extensions/vueNodes/widgets/components/WidgetSelect.asset-mode.test.ts +++ b/tests-ui/tests/renderer/extensions/vueNodes/widgets/components/WidgetSelect.asset-mode.test.ts @@ -2,6 +2,7 @@ import { createTestingPinia } from '@pinia/testing' import { flushPromises, mount } from '@vue/test-utils' import PrimeVue from 'primevue/config' import { beforeEach, describe, expect, it, vi } from 'vitest' +import { ref } from 'vue' import type { SimplifiedWidget } from '@/types/simplifiedWidget' @@ -34,7 +35,7 @@ describe('WidgetSelect asset mode', () => { const createWidget = (): SimplifiedWidget => ({ name: 'ckpt_name', type: 'combo', - value: undefined, + value: () => ref(), options: { values: [] } diff --git a/tests-ui/tests/renderer/extensions/vueNodes/widgets/components/WidgetSelectDropdown.test.ts b/tests-ui/tests/renderer/extensions/vueNodes/widgets/components/WidgetSelectDropdown.test.ts index 730ceb0bf8..cceee3795d 100644 --- a/tests-ui/tests/renderer/extensions/vueNodes/widgets/components/WidgetSelectDropdown.test.ts +++ b/tests-ui/tests/renderer/extensions/vueNodes/widgets/components/WidgetSelectDropdown.test.ts @@ -3,6 +3,7 @@ import { mount } from '@vue/test-utils' import type { VueWrapper } from '@vue/test-utils' import PrimeVue from 'primevue/config' import type { ComponentPublicInstance } from 'vue' +import { nextTick, ref, watch } from 'vue' import { describe, expect, it, vi } from 'vitest' import type { ComboInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2' @@ -24,17 +25,22 @@ describe('WidgetSelectDropdown custom label mapping', () => { values?: string[] getOptionLabel?: (value: string | null) => string } = {}, - spec?: ComboInputSpec - ): SimplifiedWidget => ({ - name: 'test_image_select', - type: 'combo', - value, - options: { - values: ['img_001.png', 'photo_abc.jpg', 'hash789.png'], - ...options - }, - spec - }) + spec?: ComboInputSpec, + callback?: (value: string | undefined) => void + ): SimplifiedWidget => { + const valueRef = ref(value) + if (callback) watch(valueRef, (v) => callback(v)) + return { + name: 'test_image_select', + type: 'combo', + value: () => valueRef, + options: { + values: ['img_001.png', 'photo_abc.jpg', 'hash789.png'], + ...options + }, + spec + } + } const mountComponent = ( widget: SimplifiedWidget, @@ -102,26 +108,29 @@ describe('WidgetSelectDropdown custom label mapping', () => { expect(getOptionLabel).toHaveBeenCalledWith('hash789.png') }) - it('emits original values when items with custom labels are selected', async () => { + it('triggers callback with original values when items with custom labels are selected', async () => { const getOptionLabel = vi.fn((value: string | null) => { if (!value) return 'No file' return `Custom: ${value}` }) - const widget = createMockWidget('img_001.png', { - getOptionLabel - }) + const callback = vi.fn() + const widget = createMockWidget( + 'img_001.png', + { + getOptionLabel + }, + undefined, + callback + ) const wrapper = mountComponent(widget, 'img_001.png') // Simulate selecting an item const selectedSet = new Set(['input-1']) // index 1 = photo_abc.jpg wrapper.vm.updateSelectedItems(selectedSet) - // Should emit the original value, not the custom label - expect(wrapper.emitted('update:modelValue')).toBeDefined() - expect(wrapper.emitted('update:modelValue')![0]).toEqual([ - 'photo_abc.jpg' - ]) + await nextTick() + expect(callback).toHaveBeenCalledWith('photo_abc.jpg') }) it('falls back to original value when label mapping fails', () => {