diff --git a/src/components/graph/widgets/BadgedNumberInput.vue b/src/components/graph/widgets/BadgedNumberInput.vue new file mode 100644 index 000000000..2d2f85042 --- /dev/null +++ b/src/components/graph/widgets/BadgedNumberInput.vue @@ -0,0 +1,144 @@ + + + + + diff --git a/src/composables/widgets/useBadgedNumberInput.ts b/src/composables/widgets/useBadgedNumberInput.ts new file mode 100644 index 000000000..9fe95866b --- /dev/null +++ b/src/composables/widgets/useBadgedNumberInput.ts @@ -0,0 +1,91 @@ +import type { LGraphNode } from '@comfyorg/litegraph' +import { ref } from 'vue' + +import BadgedNumberInput from '@/components/graph/widgets/BadgedNumberInput.vue' +import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2' +import { ComponentWidgetImpl, addWidget } from '@/scripts/domWidget' +import type { ComfyWidgetConstructorV2 } from '@/scripts/widgets' + +const PADDING = 8 + +type BadgeState = 'normal' | 'random' | 'lock' | 'increment' | 'decrement' + +interface BadgedNumberInputOptions { + defaultValue?: number + badgeState?: BadgeState + disabled?: boolean + minHeight?: number + serialize?: boolean +} + +export const useBadgedNumberInput = ( + options: BadgedNumberInputOptions = {} +) => { + const { + defaultValue = 0, + badgeState = 'normal', + disabled = false, + minHeight = 40, + serialize = true + } = options + + const widgetConstructor: ComfyWidgetConstructorV2 = ( + node: LGraphNode, + inputSpec: InputSpec + ) => { + // Initialize widget value as string to conform to ComponentWidgetImpl requirements + const widgetValue = ref(defaultValue.toString()) + + // Create the widget instance + const widget = new ComponentWidgetImpl< + string | object, + Omit< + InstanceType['$props'], + 'widget' | 'modelValue' + > + >({ + node, + name: inputSpec.name, + component: BadgedNumberInput, + inputSpec, + props: { + badgeState, + disabled + }, + options: { + // Required: getter for widget value - return as string + getValue: () => widgetValue.value as string | object, + + // Required: setter for widget value - accept number, string or object + setValue: (value: string | object | number) => { + let stringValue: string + if (typeof value === 'object') { + stringValue = JSON.stringify(value) + } else { + stringValue = String(value) + } + const numValue = parseFloat(stringValue) + if (!isNaN(numValue)) { + widgetValue.value = numValue.toString() + } + }, + + // Optional: minimum height for the widget + getMinHeight: () => minHeight + PADDING, + + // Optional: whether to serialize this widget's value + serialize + } + }) + + // Register the widget with the node + addWidget(node, widget) + + return widget + } + + return widgetConstructor +} + +// Export types for use in other modules +export type { BadgeState, BadgedNumberInputOptions } diff --git a/src/scripts/widgets.ts b/src/scripts/widgets.ts index 90f5e54f7..d8a4b2c46 100644 --- a/src/scripts/widgets.ts +++ b/src/scripts/widgets.ts @@ -5,6 +5,7 @@ import type { IStringWidget } from '@comfyorg/litegraph/dist/types/widgets' +import { useBadgedNumberInput } from '@/composables/widgets/useBadgedNumberInput' import { useBooleanWidget } from '@/composables/widgets/useBooleanWidget' import { useComboWidget } from '@/composables/widgets/useComboWidget' import { useFloatWidget } from '@/composables/widgets/useFloatWidget' @@ -289,5 +290,6 @@ export const ComfyWidgets: Record = { STRING: transformWidgetConstructorV2ToV1(useStringWidget()), MARKDOWN: transformWidgetConstructorV2ToV1(useMarkdownWidget()), COMBO: transformWidgetConstructorV2ToV1(useComboWidget()), - IMAGEUPLOAD: useImageUploadWidget() + IMAGEUPLOAD: useImageUploadWidget(), + BADGED_NUMBER: transformWidgetConstructorV2ToV1(useBadgedNumberInput()) } diff --git a/tests-ui/tests/composables/useBadgedNumberInput.test.ts b/tests-ui/tests/composables/useBadgedNumberInput.test.ts new file mode 100644 index 000000000..b0216e597 --- /dev/null +++ b/tests-ui/tests/composables/useBadgedNumberInput.test.ts @@ -0,0 +1,73 @@ +import type { LGraphNode } from '@comfyorg/litegraph' +import { describe, expect, it, vi } from 'vitest' + +import { useBadgedNumberInput } from '@/composables/widgets/useBadgedNumberInput' +import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2' + +// Mock dependencies +vi.mock('@/scripts/domWidget', () => ({ + ComponentWidgetImpl: vi.fn().mockImplementation((config) => ({ + ...config, + value: config.options.getValue(), + setValue: config.options.setValue, + options: config.options, + props: config.props + })), + addWidget: vi.fn() +})) + +describe('useBadgedNumberInput', () => { + const createMockNode = (): LGraphNode => + ({ + id: 1, + title: 'Test Node', + widgets: [], + addWidget: vi.fn() + }) as any + + const createInputSpec = (overrides: Partial = {}): InputSpec => ({ + name: 'test_input', + type: 'number', + ...overrides + }) + + it('creates widget constructor with default options', () => { + const constructor = useBadgedNumberInput() + expect(constructor).toBeDefined() + expect(typeof constructor).toBe('function') + }) + + it('creates widget with default options', () => { + const constructor = useBadgedNumberInput() + const node = createMockNode() + const inputSpec = createInputSpec() + + const widget = constructor(node, inputSpec) + + expect(widget).toBeDefined() + expect(widget.name).toBe(inputSpec.name) + }) + + it('creates widget with custom badge state', () => { + const constructor = useBadgedNumberInput({ badgeState: 'random' }) + const node = createMockNode() + const inputSpec = createInputSpec() + + const widget = constructor(node, inputSpec) + + expect(widget).toBeDefined() + // Widget is created with the props, but accessing them requires the mock structure + expect((widget as any).props.badgeState).toBe('random') + }) + + it('creates widget with disabled state', () => { + const constructor = useBadgedNumberInput({ disabled: true }) + const node = createMockNode() + const inputSpec = createInputSpec() + + const widget = constructor(node, inputSpec) + + expect(widget).toBeDefined() + expect((widget as any).props.disabled).toBe(true) + }) +})