Add Vue String widget and multiline textarea widgets (#4112)

This commit is contained in:
Christian Byrne
2025-06-09 00:44:48 -07:00
committed by GitHub
parent 0858356dcf
commit 593ac576da
5 changed files with 259 additions and 2 deletions

View File

@@ -0,0 +1,45 @@
<template>
<div class="w-full px-2">
<!-- Single line text input -->
<InputText
v-if="!isMultiline"
v-model="modelValue"
:placeholder="placeholder"
class="w-full rounded-lg px-3 py-2 text-sm"
/>
<!-- Multi-line textarea -->
<Textarea
v-else
v-model="modelValue"
:placeholder="placeholder"
:auto-resize="true"
:rows="3"
class="w-full rounded-lg px-3 py-2 text-sm resize-none"
/>
</div>
</template>
<script setup lang="ts">
import InputText from 'primevue/inputtext'
import Textarea from 'primevue/textarea'
import { computed } from 'vue'
import type { StringInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import type { ComponentWidget } from '@/scripts/domWidget'
const modelValue = defineModel<string>({ required: true })
const { widget } = defineProps<{
widget: ComponentWidget<string>
}>()
const inputSpec = widget.inputSpec as StringInputSpec
const isMultiline = computed(() => inputSpec.multiline === true)
const placeholder = computed(
() =>
inputSpec.placeholder ??
inputSpec.default ??
inputSpec.defaultVal ??
inputSpec.name
)
</script>

View File

@@ -91,6 +91,55 @@ function addMultilineWidget(
return widget
}
function addSingleLineWidget(
node: LGraphNode,
name: string,
opts: { defaultVal: string; placeholder?: string }
) {
const inputEl = document.createElement('input')
inputEl.className = 'comfy-text-input'
inputEl.type = 'text'
inputEl.value = opts.defaultVal
inputEl.placeholder = opts.placeholder || name
const widget = node.addDOMWidget(name, 'text', inputEl, {
getValue(): string {
return inputEl.value
},
setValue(v: string) {
inputEl.value = v
}
})
widget.inputEl = inputEl
widget.options.minNodeSize = [200, 40]
inputEl.addEventListener('input', () => {
widget.callback?.(widget.value)
})
// Allow middle mouse button panning
inputEl.addEventListener('pointerdown', (event: PointerEvent) => {
if (event.button === 1) {
app.canvas.processMouseDown(event)
}
})
inputEl.addEventListener('pointermove', (event: PointerEvent) => {
if ((event.buttons & 4) === 4) {
app.canvas.processMouseMove(event)
}
})
inputEl.addEventListener('pointerup', (event: PointerEvent) => {
if (event.button === 1) {
app.canvas.processMouseUp(event)
}
})
return widget
}
export const useStringWidget = () => {
const widgetConstructor: ComfyWidgetConstructorV2 = (
node: LGraphNode,
@@ -108,7 +157,10 @@ export const useStringWidget = () => {
defaultVal,
placeholder: inputSpec.placeholder
})
: node.addWidget('text', inputSpec.name, defaultVal, () => {}, {})
: addSingleLineWidget(node, inputSpec.name, {
defaultVal,
placeholder: inputSpec.placeholder
})
if (typeof inputSpec.dynamicPrompts === 'boolean') {
widget.dynamicPrompts = inputSpec.dynamicPrompts

View File

@@ -0,0 +1,65 @@
import type { LGraphNode } from '@comfyorg/litegraph'
import { ref } from 'vue'
import StringWidget from '@/components/graph/widgets/StringWidget.vue'
import {
type InputSpec,
isStringInputSpec
} from '@/schemas/nodeDef/nodeDefSchemaV2'
import { ComponentWidgetImpl, addWidget } from '@/scripts/domWidget'
import { type ComfyWidgetConstructorV2 } from '@/scripts/widgetTypes'
const PADDING = 8
export const useStringWidgetVue = (options: { defaultValue?: string } = {}) => {
const widgetConstructor: ComfyWidgetConstructorV2 = (
node: LGraphNode,
inputSpec: InputSpec
) => {
if (!isStringInputSpec(inputSpec)) {
throw new Error(`Invalid input data: ${inputSpec}`)
}
// Initialize widget value
const widgetValue = ref<string>(
inputSpec.default ?? options.defaultValue ?? ''
)
// Create the Vue-based widget instance
const widget = new ComponentWidgetImpl<string>({
node,
name: inputSpec.name,
component: StringWidget,
inputSpec,
options: {
// Required: getter for widget value
getValue: () => widgetValue.value,
// Required: setter for widget value
setValue: (value: string) => {
widgetValue.value = value
},
// Optional: minimum height for the widget
getMinHeight: () => {
return inputSpec.multiline ? 80 + PADDING : 40 + PADDING
},
// Optional: whether to serialize this widget's value
serialize: true
}
})
// Add dynamic prompts support if specified
if (typeof inputSpec.dynamicPrompts === 'boolean') {
widget.dynamicPrompts = inputSpec.dynamicPrompts
}
// Register the widget with the node
addWidget(node, widget as any)
return widget
}
return widgetConstructor
}

View File

@@ -11,6 +11,7 @@ import { useComboWidget } from '@/composables/widgets/useComboWidget'
import { useImageUploadWidget } from '@/composables/widgets/useImageUploadWidget'
import { useMarkdownWidget } from '@/composables/widgets/useMarkdownWidget'
import { useStringWidget } from '@/composables/widgets/useStringWidget'
import { useStringWidgetVue } from '@/composables/widgets/useStringWidgetVue'
import { t } from '@/i18n'
import { transformInputSpecV1ToV2 } from '@/schemas/nodeDef/migration'
import type { InputSpec } from '@/schemas/nodeDefSchema'
@@ -282,7 +283,8 @@ export const ComfyWidgets: Record<string, ComfyWidgetConstructor> = {
useBadgedNumberInput({ mode: 'float' })
),
BOOLEAN: transformWidgetConstructorV2ToV1(useBooleanWidget()),
STRING: transformWidgetConstructorV2ToV1(useStringWidget()),
STRING: transformWidgetConstructorV2ToV1(useStringWidgetVue()),
STRING_DOM: transformWidgetConstructorV2ToV1(useStringWidget()), // Fallback to DOM-based implementation
MARKDOWN: transformWidgetConstructorV2ToV1(useMarkdownWidget()),
COMBO: transformWidgetConstructorV2ToV1(useComboWidget()),
IMAGEUPLOAD: useImageUploadWidget(),

View File

@@ -0,0 +1,93 @@
import { LGraphNode } from '@comfyorg/litegraph'
import { describe, expect, it } from 'vitest'
import { useStringWidgetVue } from '@/composables/widgets/useStringWidgetVue'
import type { StringInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
describe('useStringWidgetVue', () => {
it('creates widget constructor with correct default value', () => {
const constructor = useStringWidgetVue({ defaultValue: 'test' })
expect(constructor).toBeDefined()
expect(typeof constructor).toBe('function')
})
it('creates widget for single-line string input', () => {
const constructor = useStringWidgetVue()
const node = new LGraphNode('test')
const inputSpec: StringInputSpec = {
type: 'STRING',
name: 'test_input',
default: 'default_value',
placeholder: 'Enter text...'
}
const widget = constructor(node, inputSpec)
expect(widget).toBeDefined()
expect(widget.name).toBe('test_input')
expect(widget.value).toBe('default_value')
})
it('creates widget for multiline string input', () => {
const constructor = useStringWidgetVue()
const node = new LGraphNode('test')
const inputSpec: StringInputSpec = {
type: 'STRING',
name: 'multiline_input',
default: 'default\nvalue',
placeholder: 'Enter multiline text...',
multiline: true
}
const widget = constructor(node, inputSpec)
expect(widget).toBeDefined()
expect(widget.name).toBe('multiline_input')
expect(widget.value).toBe('default\nvalue')
})
it('handles placeholder fallback correctly', () => {
const constructor = useStringWidgetVue()
const node = new LGraphNode('test')
const inputSpec: StringInputSpec = {
type: 'STRING',
name: 'no_placeholder_input',
default: 'default_value'
}
const widget = constructor(node, inputSpec)
expect(widget).toBeDefined()
expect(widget.name).toBe('no_placeholder_input')
expect(widget.value).toBe('default_value')
})
it('supports dynamic prompts configuration', () => {
const constructor = useStringWidgetVue()
const node = new LGraphNode('test')
const inputSpec: StringInputSpec = {
type: 'STRING',
name: 'dynamic_input',
default: 'value',
dynamicPrompts: true
}
const widget = constructor(node, inputSpec)
expect(widget).toBeDefined()
expect(widget.dynamicPrompts).toBe(true)
})
it('throws error for invalid input spec', () => {
const constructor = useStringWidgetVue()
const node = new LGraphNode('test')
const invalidInputSpec = {
type: 'INT',
name: 'invalid_input'
} as any
expect(() => constructor(node, invalidInputSpec)).toThrow(
'Invalid input data'
)
})
})