mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-03-02 11:40:00 +00:00
Add Vue String widget and multiline textarea widgets (#4112)
This commit is contained in:
45
src/components/graph/widgets/StringWidget.vue
Normal file
45
src/components/graph/widgets/StringWidget.vue
Normal 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>
|
||||
@@ -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
|
||||
|
||||
65
src/composables/widgets/useStringWidgetVue.ts
Normal file
65
src/composables/widgets/useStringWidgetVue.ts
Normal 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
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
93
tests-ui/composables/useStringWidgetVue.test.ts
Normal file
93
tests-ui/composables/useStringWidgetVue.test.ts
Normal 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'
|
||||
)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user