mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-29 10:42:44 +00:00
feat: dropdown widgets vue node ui (#5624)
- Load media dropdown widgets - Load models dropdown widgets I added a lot of feedback effects during interactions. I tried my best to break the Dropdown into small components. To make it more flexible, I provided many configurable props and v-model. <img width="1000" alt="CleanShot 2025-09-18 at 01 54 38" src="https://github.com/user-attachments/assets/1a413078-1547-44b8-8b48-1ce8f8e764b5" /> ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-5624-feat-dropdown-widgets-vue-node-ui-2716d73d36508115a52bc1fb6d6376d0) by [Unito](https://www.unito.io) --------- Co-authored-by: Christian Byrne <cbyrne@comfy.org>
This commit is contained in:
@@ -0,0 +1,233 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import { useWidgetValue } from '@/composables/graph/useWidgetValue'
|
||||
import { useTransformCompatOverlayProps } from '@/composables/useTransformCompatOverlayProps'
|
||||
import { t } from '@/i18n'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
import type { ResultItemType } from '@/schemas/apiSchema'
|
||||
import { api } from '@/scripts/api'
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
import type { AssetKind } from '@/types/widgetTypes'
|
||||
import {
|
||||
PANEL_EXCLUDED_PROPS,
|
||||
filterWidgetProps
|
||||
} from '@/utils/widgetPropFilter'
|
||||
|
||||
import FormDropdown from './form/dropdown/FormDropdown.vue'
|
||||
import type {
|
||||
DropdownItem,
|
||||
FilterOption,
|
||||
SelectedKey
|
||||
} from './form/dropdown/types'
|
||||
import WidgetLayoutField from './layout/WidgetLayoutField.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
widget: SimplifiedWidget<string | number | undefined>
|
||||
modelValue: string | number | undefined
|
||||
readonly?: boolean
|
||||
assetKind?: AssetKind
|
||||
allowUpload?: boolean
|
||||
uploadFolder?: ResultItemType
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: string | number | undefined]
|
||||
}>()
|
||||
|
||||
const { localValue, onChange } = useWidgetValue({
|
||||
widget: props.widget,
|
||||
modelValue: props.modelValue,
|
||||
defaultValue: props.widget.options?.values?.[0] || '',
|
||||
emit
|
||||
})
|
||||
|
||||
const toastStore = useToastStore()
|
||||
|
||||
const transformCompatProps = useTransformCompatOverlayProps()
|
||||
|
||||
const combinedProps = computed(() => ({
|
||||
...filterWidgetProps(props.widget.options, PANEL_EXCLUDED_PROPS),
|
||||
...transformCompatProps.value
|
||||
}))
|
||||
|
||||
const selectedSet = ref<Set<SelectedKey>>(new Set())
|
||||
const dropdownItems = computed<DropdownItem[]>(() => {
|
||||
const values = props.widget.options?.values || []
|
||||
|
||||
if (!Array.isArray(values)) {
|
||||
return []
|
||||
}
|
||||
|
||||
return values.map((value: string, index: number) => ({
|
||||
id: index,
|
||||
imageSrc: getMediaUrl(value),
|
||||
name: value,
|
||||
metadata: ''
|
||||
}))
|
||||
})
|
||||
|
||||
const mediaPlaceholder = computed(() => {
|
||||
const options = props.widget.options
|
||||
|
||||
if (options?.placeholder) {
|
||||
return options.placeholder
|
||||
}
|
||||
|
||||
switch (props.assetKind) {
|
||||
case 'image':
|
||||
return t('widgets.uploadSelect.placeholderImage')
|
||||
case 'video':
|
||||
return t('widgets.uploadSelect.placeholderVideo')
|
||||
case 'audio':
|
||||
return t('widgets.uploadSelect.placeholderAudio')
|
||||
case 'model':
|
||||
return t('widgets.uploadSelect.placeholderModel')
|
||||
case 'unknown':
|
||||
return t('widgets.uploadSelect.placeholderUnknown')
|
||||
}
|
||||
|
||||
return t('widgets.uploadSelect.placeholder')
|
||||
})
|
||||
|
||||
const uploadable = computed(() => props.allowUpload === true)
|
||||
|
||||
watch(
|
||||
localValue,
|
||||
(currentValue) => {
|
||||
if (currentValue !== undefined) {
|
||||
const item = dropdownItems.value.find(
|
||||
(item) => item.name === currentValue
|
||||
)
|
||||
if (item) {
|
||||
selectedSet.value.clear()
|
||||
selectedSet.value.add(item.id)
|
||||
}
|
||||
} else {
|
||||
selectedSet.value.clear()
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
function updateSelectedItems(selectedItems: Set<SelectedKey>) {
|
||||
let id: SelectedKey | undefined = undefined
|
||||
if (selectedItems.size > 0) {
|
||||
id = selectedItems.values().next().value!
|
||||
}
|
||||
if (id == null) {
|
||||
onChange(undefined)
|
||||
return
|
||||
}
|
||||
const name = dropdownItems.value.find((item) => item.id === id)?.name
|
||||
if (!name) {
|
||||
onChange(undefined)
|
||||
return
|
||||
}
|
||||
onChange(name)
|
||||
}
|
||||
|
||||
// Upload file function (copied from useNodeImageUpload.ts)
|
||||
const uploadFile = async (
|
||||
file: File,
|
||||
isPasted: boolean = false,
|
||||
formFields: Partial<{ type: ResultItemType }> = {}
|
||||
) => {
|
||||
const body = new FormData()
|
||||
body.append('image', file)
|
||||
if (isPasted) body.append('subfolder', 'pasted')
|
||||
if (formFields.type) body.append('type', formFields.type)
|
||||
|
||||
const resp = await api.fetchApi('/upload/image', {
|
||||
method: 'POST',
|
||||
body
|
||||
})
|
||||
|
||||
if (resp.status !== 200) {
|
||||
toastStore.addAlert(resp.status + ' - ' + resp.statusText)
|
||||
return null
|
||||
}
|
||||
|
||||
const data = await resp.json()
|
||||
return data.subfolder ? `${data.subfolder}/${data.name}` : data.name
|
||||
}
|
||||
|
||||
// Handle multiple file uploads
|
||||
const uploadFiles = async (files: File[]): Promise<string[]> => {
|
||||
const folder = props.uploadFolder ?? 'input'
|
||||
const uploadPromises = files.map((file) =>
|
||||
uploadFile(file, false, { type: folder })
|
||||
)
|
||||
const results = await Promise.all(uploadPromises)
|
||||
return results.filter((path): path is string => path !== null)
|
||||
}
|
||||
|
||||
async function handleFilesUpdate(files: File[]) {
|
||||
if (!files || files.length === 0) return
|
||||
|
||||
try {
|
||||
// 1. Upload files to server
|
||||
const uploadedPaths = await uploadFiles(files)
|
||||
|
||||
if (uploadedPaths.length === 0) {
|
||||
toastStore.addAlert('File upload failed')
|
||||
return
|
||||
}
|
||||
|
||||
// 2. Update widget options to include new files
|
||||
// This simulates what addToComboValues does but for SimplifiedWidget
|
||||
if (props.widget.options?.values) {
|
||||
uploadedPaths.forEach((path) => {
|
||||
const values = props.widget.options!.values as string[]
|
||||
if (!values.includes(path)) {
|
||||
values.push(path)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 3. Update widget value to the first uploaded file
|
||||
onChange(uploadedPaths[0])
|
||||
|
||||
// 4. Trigger callback to notify underlying LiteGraph widget
|
||||
if (props.widget.callback) {
|
||||
props.widget.callback(uploadedPaths[0])
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Upload error:', error)
|
||||
toastStore.addAlert(`Upload failed: ${error}`)
|
||||
}
|
||||
}
|
||||
|
||||
function getMediaUrl(filename: string): string {
|
||||
if (props.assetKind !== 'image') return ''
|
||||
// TODO: This needs to be adapted based on actual ComfyUI API structure
|
||||
return `/api/view?filename=${encodeURIComponent(filename)}&type=input`
|
||||
}
|
||||
|
||||
// TODO handle filter logic
|
||||
const filterSelected = ref('all')
|
||||
const filterOptions = ref<FilterOption[]>([
|
||||
{ id: 'all', name: 'All' },
|
||||
{ id: 'image', name: 'Inputs' },
|
||||
{ id: 'video', name: 'Outputs' }
|
||||
])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<WidgetLayoutField :widget>
|
||||
<FormDropdown
|
||||
v-model:selected="selectedSet"
|
||||
v-model:filter-selected="filterSelected"
|
||||
:items="dropdownItems"
|
||||
:placeholder="mediaPlaceholder"
|
||||
:multiple="false"
|
||||
:uploadable="uploadable"
|
||||
:disabled="readonly"
|
||||
:filter-options="filterOptions"
|
||||
v-bind="combinedProps"
|
||||
class="w-full"
|
||||
@update:selected="updateSelectedItems"
|
||||
@update:files="handleFilesUpdate"
|
||||
/>
|
||||
</WidgetLayoutField>
|
||||
</template>
|
||||
Reference in New Issue
Block a user