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:
Rizumu Ayaka
2025-09-27 03:04:39 +08:00
committed by GitHub
parent 9f19d8fb4b
commit c96f719f91
19 changed files with 1270 additions and 51 deletions

View File

@@ -9,6 +9,8 @@ import type { INodeOutputSlot } from '@/lib/litegraph/src/interfaces'
import type { INodeInputSlot } from '@/lib/litegraph/src/interfaces'
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
import { LayoutSource } from '@/renderer/core/layout/types'
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import type { WidgetValue } from '@/types/simplifiedWidget'
import type { LGraph, LGraphNode } from '../../lib/litegraph/src/litegraph'
@@ -20,6 +22,7 @@ export interface SafeWidgetData {
label?: string
options?: Record<string, unknown>
callback?: ((value: unknown) => void) | undefined
spec?: InputSpec
}
export interface VueNodeData {
@@ -53,6 +56,7 @@ export interface GraphNodeManager {
export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
// Get layout mutations composable
const { createNode, deleteNode, setSource } = useLayoutMutations()
const nodeDefStore = useNodeDefStore()
// Safe reactive data extracted from LiteGraph nodes
const vueNodeData = reactive(new Map<string, VueNodeData>())
@@ -82,6 +86,7 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
) {
value = widget.options.values[0]
}
const spec = nodeDefStore.getInputSpecForWidget(node, widget.name)
return {
name: widget.name,
@@ -89,15 +94,14 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
value: value,
label: widget.label,
options: widget.options ? { ...widget.options } : undefined,
callback: widget.callback
callback: widget.callback,
spec
}
} catch (error) {
return {
name: widget.name || 'unknown',
type: widget.type || 'text',
value: undefined, // Already a valid WidgetValue
options: undefined,
callback: undefined
value: undefined
}
}
})

View File

@@ -1872,7 +1872,15 @@
"copyTooltip": "Copy message to clipboard"
},
"widgets": {
"selectModel": "Select model"
"selectModel": "Select model",
"uploadSelect": {
"placeholder": "Select...",
"placeholderImage": "Select image...",
"placeholderAudio": "Select audio...",
"placeholderVideo": "Select video...",
"placeholderModel": "Select model...",
"placeholderUnknown": "Select media..."
}
},
"nodeHelpPage": {
"inputs": "Inputs",

View File

@@ -137,7 +137,8 @@ const processedWidgets = computed((): ProcessedWidget[] => {
value: widget.value,
label: widget.label,
options: widget.options,
callback: widget.callback
callback: widget.callback,
spec: widget.spec
}
const updateHandler = (value: unknown) => {

View File

@@ -4,9 +4,12 @@ import Select from 'primevue/select'
import type { SelectProps } from 'primevue/select'
import { describe, expect, it } from 'vitest'
import type { ComboInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import WidgetSelect from './WidgetSelect.vue'
import WidgetSelectDefault from './WidgetSelectDefault.vue'
import WidgetSelectDropdown from './WidgetSelectDropdown.vue'
describe('WidgetSelect Value Binding', () => {
const createMockWidget = (
@@ -14,7 +17,8 @@ describe('WidgetSelect Value Binding', () => {
options: Partial<
SelectProps & { values?: string[]; return_index?: boolean }
> = {},
callback?: (value: string | number | undefined) => void
callback?: (value: string | number | undefined) => void,
spec?: ComboInputSpec
): SimplifiedWidget<string | number | undefined> => ({
name: 'test_select',
type: 'combo',
@@ -23,7 +27,8 @@ describe('WidgetSelect Value Binding', () => {
values: ['option1', 'option2', 'option3'],
...options
},
callback
callback,
spec
})
const mountComponent = (
@@ -184,4 +189,44 @@ describe('WidgetSelect Value Binding', () => {
expect(emitted![0]).toContain('100')
})
})
describe('Spec-aware rendering', () => {
it('uses dropdown variant when combo spec enables image uploads', () => {
const spec: ComboInputSpec = {
type: 'COMBO',
name: 'test_select',
image_upload: true
}
const widget = createMockWidget('option1', {}, undefined, spec)
const wrapper = mountComponent(widget, 'option1')
expect(wrapper.findComponent(WidgetSelectDropdown).exists()).toBe(true)
expect(wrapper.findComponent(WidgetSelectDefault).exists()).toBe(false)
})
it('uses dropdown variant for audio uploads', () => {
const spec: ComboInputSpec = {
type: 'COMBO',
name: 'test_select',
audio_upload: true
}
const widget = createMockWidget('clip.wav', {}, undefined, spec)
const wrapper = mountComponent(widget, 'clip.wav')
const dropdown = wrapper.findComponent(WidgetSelectDropdown)
expect(dropdown.exists()).toBe(true)
expect(dropdown.props('assetKind')).toBe('audio')
expect(dropdown.props('allowUpload')).toBe(false)
})
it('keeps default select when no spec or media hints are present', () => {
const widget = createMockWidget('plain', {
values: ['plain', 'text']
})
const wrapper = mountComponent(widget, 'plain')
expect(wrapper.findComponent(WidgetSelectDefault).exists()).toBe(true)
expect(wrapper.findComponent(WidgetSelectDropdown).exists()).toBe(false)
})
})
})

View File

@@ -1,33 +1,32 @@
<template>
<WidgetLayoutField :widget>
<Select
v-model="localValue"
:options="selectOptions"
v-bind="combinedProps"
:disabled="readonly"
class="w-full text-xs bg-[#F9F8F4] dark-theme:bg-[#0E0E12] border-[#E1DED5] dark-theme:border-[#15161C] !rounded-lg"
size="small"
:pt="{
option: 'text-xs'
}"
@update:model-value="onChange"
/>
</WidgetLayoutField>
<WidgetSelectDropdown
v-if="isDropdownUIWidget"
v-bind="props"
:asset-kind="assetKind"
:allow-upload="allowUpload"
:upload-folder="uploadFolder"
@update:model-value="handleUpdateModelValue"
/>
<WidgetSelectDefault
v-else
v-bind="props"
@update:model-value="handleUpdateModelValue"
/>
</template>
<script setup lang="ts">
import Select from 'primevue/select'
import { computed } from 'vue'
import { useWidgetValue } from '@/composables/graph/useWidgetValue'
import { useTransformCompatOverlayProps } from '@/composables/useTransformCompatOverlayProps'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import type { ResultItemType } from '@/schemas/apiSchema'
import {
PANEL_EXCLUDED_PROPS,
filterWidgetProps
} from '@/utils/widgetPropFilter'
type ComboInputSpec,
isComboInputSpec
} from '@/schemas/nodeDef/nodeDefSchemaV2'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import type { AssetKind } from '@/types/widgetTypes'
import WidgetLayoutField from './layout/WidgetLayoutField.vue'
import WidgetSelectDefault from './WidgetSelectDefault.vue'
import WidgetSelectDropdown from './WidgetSelectDropdown.vue'
const props = defineProps<{
widget: SimplifiedWidget<string | number | undefined>
@@ -39,30 +38,64 @@ const emit = defineEmits<{
'update:modelValue': [value: string | number | undefined]
}>()
// Use the composable for consistent widget value handling
const { localValue, onChange } = useWidgetValue({
widget: props.widget,
modelValue: props.modelValue,
defaultValue: props.widget.options?.values?.[0] || '',
emit
function handleUpdateModelValue(value: string | number | undefined) {
emit('update:modelValue', value)
}
const comboSpec = computed<ComboInputSpec | undefined>(() => {
if (props.widget.spec && isComboInputSpec(props.widget.spec)) {
return props.widget.spec
}
return undefined
})
// Transform compatibility props for overlay positioning
const transformCompatProps = useTransformCompatOverlayProps()
const combinedProps = computed(() => ({
...filterWidgetProps(props.widget.options, PANEL_EXCLUDED_PROPS),
...transformCompatProps.value
}))
// Extract select options from widget options
const selectOptions = computed(() => {
const options = props.widget.options
if (options?.values && Array.isArray(options.values)) {
return options.values
const specDescriptor = computed<{
kind: AssetKind
allowUpload: boolean
folder: ResultItemType | undefined
}>(() => {
const spec = comboSpec.value
if (!spec) {
return {
kind: 'unknown',
allowUpload: false,
folder: undefined
}
}
return []
const {
image_upload,
animated_image_upload,
video_upload,
image_folder,
audio_upload
} = spec
let kind: AssetKind = 'unknown'
if (video_upload) {
kind = 'video'
} else if (image_upload || animated_image_upload) {
kind = 'image'
} else if (audio_upload) {
kind = 'audio'
}
// TODO: add support for models (checkpoints, VAE, LoRAs, etc.) -- get widgetType from spec
const allowUpload =
image_upload === true ||
animated_image_upload === true ||
audio_upload === true
return {
kind,
allowUpload,
folder: image_folder
}
})
const assetKind = computed(() => specDescriptor.value.kind)
const isDropdownUIWidget = computed(() => assetKind.value !== 'unknown')
const allowUpload = computed(() => specDescriptor.value.allowUpload)
const uploadFolder = computed<ResultItemType>(() => {
return specDescriptor.value.folder ?? 'input'
})
</script>

View File

@@ -0,0 +1,68 @@
<template>
<WidgetLayoutField :widget>
<Select
v-model="localValue"
:options="selectOptions"
v-bind="combinedProps"
:disabled="readonly"
class="w-full text-xs bg-[#F9F8F4] dark-theme:bg-[#0E0E12] border-[#E1DED5] dark-theme:border-[#15161C] !rounded-lg"
size="small"
:pt="{
option: 'text-xs'
}"
@update:model-value="onChange"
/>
</WidgetLayoutField>
</template>
<script setup lang="ts">
import Select from 'primevue/select'
import { computed } from 'vue'
import { useWidgetValue } from '@/composables/graph/useWidgetValue'
import { useTransformCompatOverlayProps } from '@/composables/useTransformCompatOverlayProps'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import {
PANEL_EXCLUDED_PROPS,
filterWidgetProps
} from '@/utils/widgetPropFilter'
import WidgetLayoutField from './layout/WidgetLayoutField.vue'
const props = defineProps<{
widget: SimplifiedWidget<string | number | undefined>
modelValue: string | number | undefined
readonly?: boolean
}>()
const emit = defineEmits<{
'update:modelValue': [value: string | number | undefined]
}>()
// Use the composable for consistent widget value handling
const { localValue, onChange } = useWidgetValue({
widget: props.widget,
modelValue: props.modelValue,
defaultValue: props.widget.options?.values?.[0] || '',
emit
})
// Transform compatibility props for overlay positioning
const transformCompatProps = useTransformCompatOverlayProps()
const combinedProps = computed(() => ({
...filterWidgetProps(props.widget.options, PANEL_EXCLUDED_PROPS),
...transformCompatProps.value
}))
// Extract select options from widget options
const selectOptions = computed(() => {
const options = props.widget.options
if (options?.values && Array.isArray(options.values)) {
return options.values
}
return []
})
</script>

View File

@@ -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>

View File

@@ -0,0 +1,233 @@
<script setup lang="ts">
import { refDebounced } from '@vueuse/core'
import Popover from 'primevue/popover'
import { computed, ref, useTemplateRef, watch } from 'vue'
import { t } from '@/i18n'
import { useToastStore } from '@/platform/updates/common/toastStore'
import FormDropdownInput from './FormDropdownInput.vue'
import FormDropdownMenu from './FormDropdownMenu.vue'
import { defaultSearcher, getDefaultSortOptions } from './shared'
import type {
DropdownItem,
FilterOption,
LayoutMode,
OptionId,
SelectedKey,
SortOption
} from './types'
interface Props {
items: DropdownItem[]
placeholder?: string
/**
* If true, allows multiple selections. If a number is provided,
* it specifies the maximum number of selections allowed.
*/
multiple?: boolean | number
uploadable?: boolean
disabled?: boolean
filterOptions?: FilterOption[]
sortOptions?: SortOption[]
isSelected?: (
selected: Set<SelectedKey>,
item: DropdownItem,
index: number
) => boolean
searcher?: (
query: string,
items: DropdownItem[],
onCleanup: (cleanupFn: () => void) => void
) => Promise<DropdownItem[]>
}
const props = withDefaults(defineProps<Props>(), {
placeholder: t('widgets.uploadSelect.placeholder'),
multiple: false,
uploadable: false,
disabled: false,
filterOptions: () => [],
sortOptions: () => getDefaultSortOptions(),
isSelected: (selected, item, _index) => selected.has(item.id),
searcher: defaultSearcher
})
const selected = defineModel<Set<SelectedKey>>('selected', {
default: new Set()
})
const filterSelected = defineModel<OptionId>('filterSelected', { default: '' })
const sortSelected = defineModel<OptionId>('sortSelected', {
default: 'default'
})
const layoutMode = defineModel<LayoutMode>('layoutMode', {
default: 'grid'
})
const files = defineModel<File[]>('files', { default: [] })
const searchQuery = defineModel<string>('searchQuery', { default: '' })
const debouncedSearchQuery = refDebounced(searchQuery, 700, {
maxWait: 700
})
const isQuerying = ref(false)
const toastStore = useToastStore()
const popoverRef = ref<InstanceType<typeof Popover>>()
const triggerRef = useTemplateRef('triggerRef')
const isOpen = ref(false)
const maxSelectable = computed(() => {
if (props.multiple === true) return Infinity
if (typeof props.multiple === 'number') return props.multiple
return 1
})
const filteredItems = ref<DropdownItem[]>([])
watch(searchQuery, (value) => {
isQuerying.value = value !== debouncedSearchQuery.value
})
watch(
debouncedSearchQuery,
(_, __, onCleanup) => {
let isCleanup = false
let cleanupFn: undefined | (() => void)
onCleanup(() => {
isCleanup = true
cleanupFn?.()
})
void props
.searcher(
debouncedSearchQuery.value,
props.items,
(cb) => (cleanupFn = cb)
)
.then((result) => {
if (!isCleanup) filteredItems.value = result
})
.finally(() => {
if (!isCleanup) isQuerying.value = false
})
},
{ immediate: true }
)
const defaultSorter = computed<SortOption['sorter']>(() => {
const sorter = props.sortOptions.find(
(option) => option.id === 'default'
)?.sorter
return sorter || (({ items }) => items.slice())
})
const selectedSorter = computed<SortOption['sorter']>(() => {
if (sortSelected.value === 'default') return defaultSorter.value
const sorter = props.sortOptions.find(
(option) => option.id === sortSelected.value
)?.sorter
return sorter || defaultSorter.value
})
const sortedItems = computed(() => {
return selectedSorter.value({ items: filteredItems.value }) || []
})
function internalIsSelected(item: DropdownItem, index: number): boolean {
return props.isSelected?.(selected.value, item, index) ?? false
}
const toggleDropdown = (event: Event) => {
if (props.disabled) return
if (popoverRef.value && triggerRef.value) {
popoverRef.value.toggle(event, triggerRef.value)
isOpen.value = !isOpen.value
}
}
const closeDropdown = () => {
if (popoverRef.value) {
popoverRef.value.hide()
isOpen.value = false
}
}
function handleFileChange(event: Event) {
if (props.disabled) return
const input = event.target as HTMLInputElement
if (input.files) {
files.value = Array.from(input.files)
}
// Clear the input value to allow re-selecting the same file
input.value = ''
}
function handleSelection(item: DropdownItem, index: number) {
if (props.disabled) return
const sel = selected.value
if (internalIsSelected(item, index)) {
sel.delete(item.id)
} else {
if (sel.size < maxSelectable.value) {
sel.add(item.id)
} else if (maxSelectable.value === 1) {
sel.clear()
sel.add(item.id)
} else {
toastStore.addAlert(`Maximum selection limit reached`)
return
}
}
selected.value = new Set(sel)
if (maxSelectable.value === 1) {
closeDropdown()
}
}
</script>
<template>
<div ref="triggerRef">
<FormDropdownInput
:files="files"
:is-open="isOpen"
:placeholder="placeholder"
:items="items"
:max-selectable="maxSelectable"
:selected="selected"
:uploadable="uploadable"
:disabled="disabled"
@select-click="toggleDropdown"
@file-change="handleFileChange"
/>
<Popover
ref="popoverRef"
:dismissable="true"
:close-on-escape="true"
unstyled
:pt="{
root: {
class: 'absolute z-50'
},
content: {
class: ['bg-transparent border-none p-0 pt-2 rounded-lg shadow-lg']
}
}"
@hide="isOpen = false"
>
<FormDropdownMenu
v-model:filter-selected="filterSelected"
v-model:layout-mode="layoutMode"
v-model:sort-selected="sortSelected"
v-model:search-query="searchQuery"
:filter-options="filterOptions"
:sort-options="sortOptions"
:disabled="disabled"
:is-querying="isQuerying"
:items="sortedItems"
:is-selected="internalIsSelected"
:max-selectable="maxSelectable"
@close="closeDropdown"
@item-click="handleSelection"
/>
</Popover>
</div>
</template>

View File

@@ -0,0 +1,99 @@
<script setup lang="ts">
import { computed } from 'vue'
import { cn } from '@/utils/tailwindUtil'
import { WidgetInputBaseClass } from '../../layout'
import type { DropdownItem, SelectedKey } from './types'
interface Props {
isOpen?: boolean
placeholder?: string
files: File[]
items: DropdownItem[]
selected: Set<SelectedKey>
maxSelectable: number
uploadable: boolean
disabled: boolean
}
const props = withDefaults(defineProps<Props>(), {
isOpen: false,
placeholder: 'Select...'
})
const emit = defineEmits<{
(e: 'select-click', event: MouseEvent): void
(e: 'file-change', event: Event): void
}>()
const selectedItems = computed(() => {
return props.items.filter((item) => props.selected.has(item.id))
})
const chevronClass = computed(() =>
cn('mr-2 size-4 transition-transform duration-200 flex-shrink-0', {
'rotate-180': props.isOpen
})
)
const theButtonStyle = computed(() => [
'bg-transparent border-0 outline-none text-zinc-400',
{
'hover:bg-zinc-500/30 hover:text-black hover:dark-theme:text-white cursor-pointer':
!props.disabled,
'cursor-not-allowed': props.disabled
}
])
</script>
<template>
<div
:class="
cn(WidgetInputBaseClass, 'flex text-base leading-none', {
'opacity-50 cursor-not-allowed !outline-zinc-300/10': disabled
})
"
>
<!-- Dropdown -->
<button
:class="
cn(theButtonStyle, 'flex justify-between items-center flex-1 h-8', {
'rounded-l-lg': uploadable,
'rounded-lg': !uploadable
})
"
@click="emit('select-click', $event)"
>
<span class="px-4 py-2 min-w-0 text-left">
<span v-if="!selectedItems.length" class="min-w-0">
{{ props.placeholder }}
</span>
<span v-else class="line-clamp-1 min-w-0 break-all">
{{ selectedItems.map((item) => (item as any)?.name).join(', ') }}
</span>
</span>
<i-lucide:chevron-down :class="chevronClass" />
</button>
<!-- Open File -->
<label
v-if="uploadable"
:class="
cn(
theButtonStyle,
'relative',
'size-8 flex justify-center items-center border-l rounded-r-lg border-zinc-300/10'
)
"
>
<i-lucide:folder-search class="size-4" />
<input
type="file"
class="opacity-0 absolute inset-0 -z-1"
:multiple="maxSelectable > 1"
:disabled="disabled"
@change="emit('file-change', $event)"
/>
</label>
</div>
</template>

View File

@@ -0,0 +1,96 @@
<script setup lang="ts">
import { cn } from '@/utils/tailwindUtil'
import FormDropdownMenuActions from './FormDropdownMenuActions.vue'
import FormDropdownMenuFilter from './FormDropdownMenuFilter.vue'
import FormDropdownMenuItem from './FormDropdownMenuItem.vue'
import type {
DropdownItem,
FilterOption,
LayoutMode,
OptionId,
SortOption
} from './types'
interface Props {
items: DropdownItem[]
isSelected: (item: DropdownItem, index: number) => boolean
isQuerying: boolean
filterOptions: FilterOption[]
sortOptions: SortOption[]
}
defineProps<Props>()
const emit = defineEmits<{
(e: 'item-click', item: DropdownItem, index: number): void
}>()
// Define models for two-way binding
const filterSelected = defineModel<OptionId>('filterSelected')
const layoutMode = defineModel<LayoutMode>('layoutMode')
const sortSelected = defineModel<OptionId>('sortSelected')
const searchQuery = defineModel<string>('searchQuery')
// Handle item selection
</script>
<template>
<div
class="w-103 h-[640px] pt-4 bg-white dark-theme:bg-charcoal-800 rounded-lg outline outline-offset-[-1px] outline-sand-100 dark-theme:outline-zinc-800 flex flex-col"
>
<!-- Filter -->
<FormDropdownMenuFilter
v-if="filterOptions.length > 0"
v-model:filter-selected="filterSelected"
:filter-options="filterOptions"
/>
<!-- Actions -->
<FormDropdownMenuActions
v-model:layout-mode="layoutMode"
v-model:sort-selected="sortSelected"
v-model:search-query="searchQuery"
:sort-options="sortOptions"
:is-querying="isQuerying"
/>
<!-- List -->
<div class="flex overflow-hidden relative h-full">
<div
:class="
cn(
'h-full max-h-full grid gap-x-2 gap-y-4 overflow-y-auto px-4 pt-4 pb-4 w-full',
{
'grid-cols-4': layoutMode === 'grid',
'grid-cols-1 gap-y-2': layoutMode === 'list',
'grid-cols-1 gap-y-1': layoutMode === 'list-small'
}
)
"
>
<div
class="absolute top-0 inset-x-3 h-5 bg-gradient-to-b from-white dark-theme:from-neutral-900 to-transparent pointer-events-none z-10"
/>
<div
v-if="items.length === 0"
class="flex justify-center items-center absolute inset-0"
>
<i-lucide:circle-off
title="No items"
class="size-30 text-zinc-500/20"
/>
</div>
<!-- Item -->
<FormDropdownMenuItem
v-for="(item, index) in items"
:key="item.id"
:index="index"
:selected="isSelected(item, index)"
:image-src="item.imageSrc"
:name="item.name"
:metadata="item.metadata"
:layout="layoutMode"
@click="emit('item-click', item, index)"
/>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,174 @@
<script setup lang="ts">
import Popover from 'primevue/popover'
import { ref, useTemplateRef } from 'vue'
import { cn } from '@/utils/tailwindUtil'
import type { LayoutMode, OptionId, SortOption } from './types'
defineProps<{
isQuerying: boolean
sortOptions: SortOption[]
}>()
const layoutMode = defineModel<LayoutMode>('layoutMode')
const searchQuery = defineModel<string>('searchQuery')
const sortSelected = defineModel<OptionId>('sortSelected')
const actionButtonStyle =
'h-8 bg-zinc-500/20 rounded-lg outline outline-1 outline-offset-[-1px] outline-sand-100 dark-theme:outline-neutral-700 transition-all duration-150'
const resetInputStyle = 'bg-transparent border-0 outline-0 ring-0 text-left'
const layoutSwitchItemStyle =
'size-6 flex justify-center items-center rounded-sm cursor-pointer transition-all duration-150 hover:scale-108 hover:text-black hover:dark-theme:text-white active:scale-95'
const sortPopoverRef = useTemplateRef('sortPopoverRef')
const sortTriggerRef = useTemplateRef('sortTriggerRef')
const isSortPopoverOpen = ref(false)
function toggleSortPopover(event: Event) {
if (!sortPopoverRef.value || !sortTriggerRef.value) return
isSortPopoverOpen.value = !isSortPopoverOpen.value
sortPopoverRef.value.toggle(event, sortTriggerRef.value)
}
function closeSortPopover() {
isSortPopoverOpen.value = false
sortPopoverRef.value?.hide()
}
function handleSortSelected(item: SortOption) {
sortSelected.value = item.id
closeSortPopover()
}
</script>
<template>
<div class="flex gap-2 text-zinc-400 px-4">
<label
:class="
cn(
actionButtonStyle,
'flex-1 flex px-2 items-center text-base leading-none cursor-text',
searchQuery?.trim() !== '' ? 'text-black dark-theme:text-white' : '',
'hover:!outline-blue-500/80',
'focus-within:!outline-blue-500/80'
)
"
>
<i-lucide:loader-circle
v-if="isQuerying"
class="mr-2 size-4 animate-spin"
/>
<i-lucide:search v-else class="mr-2 size-4" />
<input
v-model="searchQuery"
type="text"
:class="resetInputStyle"
placeholder="Search"
/>
</label>
<!-- Sort Select -->
<button
ref="sortTriggerRef"
:class="
cn(
resetInputStyle,
actionButtonStyle,
'relative w-8 flex justify-center items-center cursor-pointer',
'hover:!outline-blue-500/80',
'active:!scale-95'
)
"
@click="toggleSortPopover"
>
<div
v-if="sortSelected !== 'default'"
class="size-2 absolute top-[-2px] left-[-2px] bg-blue-500 rounded-full"
/>
<i-lucide:arrow-up-down class="size-4" />
</button>
<!-- Sort Popover -->
<Popover
ref="sortPopoverRef"
:dismissable="true"
:close-on-escape="true"
unstyled
:pt="{
root: {
class: 'absolute z-50'
},
content: {
class: ['bg-transparent border-none p-0 pt-2 rounded-lg shadow-lg']
}
}"
@hide="isSortPopoverOpen = false"
>
<div
:class="
cn(
'flex flex-col gap-2 p-2 min-w-32',
'bg-zinc-200 dark-theme:bg-charcoal-700',
'rounded-lg outline outline-offset-[-1px] outline-sand-200 dark-theme:outline-zinc-700'
)
"
>
<button
v-for="item of sortOptions"
:key="item.name"
:class="
cn(
resetInputStyle,
'flex justify-between items-center h-6 cursor-pointer',
'hover:!text-blue-500'
)
"
@click="handleSortSelected(item)"
>
<span>{{ item.name }}</span>
<i-lucide:check v-if="sortSelected === item.id" class="size-4" />
</button>
</div>
</Popover>
<!-- Layout Switch -->
<div
:class="
cn(
actionButtonStyle,
'flex justify-center items-center p-1 gap-1 hover:!outline-blue-500/80'
)
"
>
<button
:class="
cn(
resetInputStyle,
layoutSwitchItemStyle,
layoutMode === 'list'
? 'bg-neutral-500/50 text-black dark-theme:text-white'
: ''
)
"
@click="layoutMode = 'list'"
>
<i-lucide:list class="size-4" />
</button>
<button
:class="
cn(
resetInputStyle,
layoutSwitchItemStyle,
layoutMode === 'grid'
? 'bg-neutral-500/50 text-black dark-theme:text-white'
: ''
)
"
@click="layoutMode = 'grid'"
>
<i-lucide:layout-grid class="size-4" />
</button>
</div>
</div>
</template>

View File

@@ -0,0 +1,34 @@
<script setup lang="ts">
import { cn } from '@/utils/tailwindUtil'
import type { FilterOption, OptionId } from './types'
defineProps<{
filterOptions: FilterOption[]
}>()
const filterSelected = defineModel<OptionId>('filterSelected')
</script>
<template>
<div class="flex gap-1 text-zinc-400 px-4 mb-4">
<div
v-for="option in filterOptions"
:key="option.id"
:class="
cn(
'px-4 py-2 rounded-md inline-flex justify-center items-center cursor-pointer select-none',
'transition-all duration-150',
'hover:text-black hover:dark-theme:text-white hover:bg-zinc-500/10',
'active:scale-95',
filterSelected === option.id
? '!bg-zinc-500/20 text-black dark-theme:text-white'
: 'bg-transparent'
)
"
@click="filterSelected = option.id"
>
{{ option.name }}
</div>
</div>
</template>

View File

@@ -0,0 +1,124 @@
<script setup lang="ts">
import { ref } from 'vue'
import { cn } from '@/utils/tailwindUtil'
import type { LayoutMode } from './types'
interface Props {
index: number
selected: boolean
imageSrc: string
name: string
metadata?: string
layout?: LayoutMode
}
const props = defineProps<Props>()
const emit = defineEmits<{
click: [index: number]
imageLoad: [event: Event]
}>()
const actualDimensions = ref<string | null>(null)
function handleClick() {
emit('click', props.index)
}
function handleImageLoad(event: Event) {
emit('imageLoad', event)
if (!event.target || !(event.target instanceof HTMLImageElement)) return
const img = event.target
if (img.naturalWidth && img.naturalHeight) {
actualDimensions.value = `${img.naturalWidth} x ${img.naturalHeight}`
}
}
</script>
<template>
<div
:class="
cn(
'flex gap-1 select-none group/item cursor-pointer',
'transition-all duration-150',
{
'flex-col text-center': layout === 'grid',
'flex-row text-left max-h-16 bg-zinc-500/20 rounded-lg hover:scale-102 active:scale-98':
layout === 'list',
'flex-row text-left hover:bg-zinc-500/20 rounded-lg':
layout === 'list-small',
// selection
'ring-2 ring-blue-500': layout === 'list' && selected
}
)
"
@click="handleClick"
>
<!-- Image -->
<div
v-if="layout !== 'list-small'"
:class="
cn(
'relative',
'w-full aspect-square overflow-hidden outline-1 outline-offset-[-1px] outline-zinc-300/10',
'transition-all duration-150',
{
'min-w-16 max-w-16 rounded-l-lg': layout === 'list',
'rounded-sm group-hover/item:scale-108 group-active/item:scale-95':
layout === 'grid',
// selection
'ring-2 ring-blue-500': layout === 'grid' && selected
}
)
"
>
<!-- Selected Icon -->
<div
v-if="selected"
class="rounded-full bg-blue-500 border-1 border-white size-4 absolute top-1 left-1"
>
<i-lucide:check class="size-3 text-white -translate-y-[0.5px]" />
</div>
<img
v-if="imageSrc"
:src="imageSrc"
class="size-full object-cover"
@load="handleImageLoad"
/>
<div
v-else
class="size-full bg-gradient-to-tr from-blue-400 via-teal-500 to-green-400"
/>
</div>
<!-- Name -->
<div
:class="
cn('flex gap-1', {
'flex-col': layout === 'grid',
'flex-col px-4 py-1 w-full justify-center': layout === 'list',
'flex-row p-2 items-center justify-between w-full':
layout === 'list-small'
})
"
>
<span
:class="
cn(
'block text-[15px] line-clamp-2 wrap-break-word',
'transition-colors duration-150',
// selection
!!selected && 'text-blue-500'
)
"
>
{{ name }}
</span>
<!-- Meta Data -->
<span class="block text-xs text-slate-400">{{
metadata || actualDimensions
}}</span>
</div>
</div>
</template>

View File

@@ -0,0 +1,28 @@
import type { DropdownItem, SortOption } from './types'
export async function defaultSearcher(query: string, items: DropdownItem[]) {
if (query.trim() === '') return items
const words = query.trim().toLowerCase().split(' ')
return items.filter((item) => {
const name = item.name.toLowerCase()
return words.every((word) => name.includes(word))
})
}
export function getDefaultSortOptions(): SortOption[] {
return [
{
name: 'Default',
id: 'default',
sorter: ({ items }) => items.slice()
},
{
name: 'A-Z',
id: 'a-z',
sorter: ({ items }) =>
items.slice().sort((a, b) => {
return a.name.localeCompare(b.name)
})
}
]
}

View File

@@ -0,0 +1,21 @@
export type OptionId = string | number | symbol
export type SelectedKey = OptionId
export interface DropdownItem {
id: SelectedKey
imageSrc: string
name: string
metadata: string
}
export interface SortOption {
id: OptionId
name: string
sorter: (ctx: { items: readonly DropdownItem[] }) => DropdownItem[]
}
export interface FilterOption {
id: OptionId
name: string
}
export type LayoutMode = 'list' | 'grid' | 'list-small'

View File

@@ -77,6 +77,7 @@ export const zComboInputOptions = zBaseInputOptions.extend({
image_folder: resultItemType.optional(),
allow_batch: z.boolean().optional(),
video_upload: z.boolean().optional(),
audio_upload: z.boolean().optional(),
animated_image_upload: z.boolean().optional(),
options: z.array(zComboOption).optional(),
remote: zRemoteWidgetConfig.optional(),

View File

@@ -352,6 +352,16 @@ export const useNodeDefStore = defineStore('nodeDef', () => {
return nodeDef
}
function getInputSpecForWidget(
node: LGraphNode,
widgetName: string
): InputSpecV2 | undefined {
const nodeDef = fromLGraphNode(node)
if (!nodeDef) return undefined
return nodeDef.inputs[widgetName]
}
/**
* Registers a node definition filter.
* @param filter - The filter to register
@@ -424,6 +434,7 @@ export const useNodeDefStore = defineStore('nodeDef', () => {
updateNodeDefs,
addNodeDef,
fromLGraphNode,
getInputSpecForWidget,
registerNodeDefFilter,
unregisterNodeDefFilter
}

View File

@@ -2,6 +2,7 @@
* Simplified widget interface for Vue-based node rendering
* Removes all DOM manipulation and positioning concerns
*/
import type { InputSpec as InputSpecV2 } from '@/schemas/nodeDef/nodeDefSchemaV2'
/** Valid types for widget values */
export type WidgetValue =
@@ -36,6 +37,9 @@ export interface SimplifiedWidget<
/** Callback fired when value changes */
callback?: (value: T) => void
/** Optional input specification backing this widget */
spec?: InputSpecV2
/** Optional serialization method for custom value handling */
serializeValue?: () => any

View File

@@ -1,3 +1,5 @@
import type { InjectionKey } from 'vue'
export type AssetKind = 'image' | 'video' | 'audio' | 'model' | 'unknown'
export const OnCloseKey: InjectionKey<() => void> = Symbol()