[feat] Add BadgedNumberInput Vue widget with state indicators

- Create BadgedNumberInput.vue component using PrimeVue InputNumber
- Add badges for random, lock, increment, decrement states
- Implement useBadgedNumberInput composable with proper DOM widget integration
- Register BADGED_NUMBER widget type in widgets registry
- Include comprehensive unit tests for widget functionality
- Widget spans node width with padding and rounded corners as specified
This commit is contained in:
bymyself
2025-06-08 23:21:44 -07:00
parent 344c6f6244
commit 471018a962
4 changed files with 311 additions and 1 deletions

View File

@@ -0,0 +1,144 @@
<template>
<div class="badged-number-input relative w-full">
<InputGroup class="w-full rounded-lg">
<!-- State badge prefix -->
<InputGroupAddon v-if="badgeState !== 'normal'" class="!p-1 rounded-l-lg">
<Badge
:value="badgeIcon"
:severity="badgeSeverity"
class="text-xs font-medium"
:title="badgeTooltip"
/>
</InputGroupAddon>
<!-- Number input -->
<InputNumber
v-model="numericValue"
:min="min"
:max="max"
:step="step"
:placeholder="placeholder"
:disabled="disabled"
:class="{
'rounded-r-lg': badgeState !== 'normal',
'rounded-lg': badgeState === 'normal'
}"
class="flex-1"
show-buttons
button-layout="horizontal"
:increment-button-icon="'pi pi-plus'"
:decrement-button-icon="'pi pi-minus'"
/>
</InputGroup>
</div>
</template>
<script setup lang="ts">
import Badge from 'primevue/badge'
import InputGroup from 'primevue/inputgroup'
import InputGroupAddon from 'primevue/inputgroupaddon'
import InputNumber from 'primevue/inputnumber'
import { computed } from 'vue'
import type { ComponentWidget } from '@/scripts/domWidget'
type BadgeState = 'normal' | 'random' | 'lock' | 'increment' | 'decrement'
const modelValue = defineModel<string>({ required: true })
const {
widget,
badgeState = 'normal',
disabled = false
} = defineProps<{
widget: ComponentWidget<string>
badgeState?: BadgeState
disabled?: boolean
}>()
// Convert string model value to/from number for the InputNumber component
const numericValue = computed({
get: () => parseFloat(modelValue.value) || 0,
set: (value: number) => {
modelValue.value = value.toString()
}
})
// Extract options from input spec
const inputSpec = widget.inputSpec
const min = (inputSpec as any).min ?? 0
const max = (inputSpec as any).max ?? 100
const step = (inputSpec as any).step ?? 1
const placeholder = (inputSpec as any).placeholder ?? 'Enter number'
// Badge configuration
const badgeIcon = computed(() => {
switch (badgeState) {
case 'random':
return '🎲'
case 'lock':
return '🔒'
case 'increment':
return '⬆️'
case 'decrement':
return '⬇️'
default:
return ''
}
})
const badgeSeverity = computed(() => {
switch (badgeState) {
case 'random':
return 'info'
case 'lock':
return 'warn'
case 'increment':
return 'success'
case 'decrement':
return 'danger'
default:
return 'info'
}
})
const badgeTooltip = computed(() => {
switch (badgeState) {
case 'random':
return 'Random mode: Value randomizes after each run'
case 'lock':
return 'Locked: Value never changes'
case 'increment':
return 'Auto-increment: Value increases after each run'
case 'decrement':
return 'Auto-decrement: Value decreases after each run'
default:
return ''
}
})
</script>
<style scoped>
.badged-number-input {
padding: 4px;
}
/* Ensure proper styling for the input group */
:deep(.p-inputgroup) {
border-radius: 0.5rem;
}
:deep(.p-inputnumber) {
flex: 1;
}
:deep(.p-inputnumber-input) {
border-radius: inherit;
}
/* Badge styling */
:deep(.p-badge) {
font-size: 0.75rem;
padding: 0.25rem 0.5rem;
}
</style>

View File

@@ -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<string>(defaultValue.toString())
// Create the widget instance
const widget = new ComponentWidgetImpl<
string | object,
Omit<
InstanceType<typeof BadgedNumberInput>['$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 }

View File

@@ -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, ComfyWidgetConstructor> = {
STRING: transformWidgetConstructorV2ToV1(useStringWidget()),
MARKDOWN: transformWidgetConstructorV2ToV1(useMarkdownWidget()),
COMBO: transformWidgetConstructorV2ToV1(useComboWidget()),
IMAGEUPLOAD: useImageUploadWidget()
IMAGEUPLOAD: useImageUploadWidget(),
BADGED_NUMBER: transformWidgetConstructorV2ToV1(useBadgedNumberInput())
}

View File

@@ -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> = {}): 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)
})
})