Skip to content

Commit c96f719

Browse files
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 <[email protected]>
1 parent 9f19d8f commit c96f719

19 files changed

+1267
-48
lines changed

src/composables/graph/useGraphNodeManager.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ import type { INodeOutputSlot } from '@/lib/litegraph/src/interfaces'
99
import type { INodeInputSlot } from '@/lib/litegraph/src/interfaces'
1010
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
1111
import { LayoutSource } from '@/renderer/core/layout/types'
12+
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
13+
import { useNodeDefStore } from '@/stores/nodeDefStore'
1214
import type { WidgetValue } from '@/types/simplifiedWidget'
1315

1416
import type { LGraph, LGraphNode } from '../../lib/litegraph/src/litegraph'
@@ -20,6 +22,7 @@ export interface SafeWidgetData {
2022
label?: string
2123
options?: Record<string, unknown>
2224
callback?: ((value: unknown) => void) | undefined
25+
spec?: InputSpec
2326
}
2427

2528
export interface VueNodeData {
@@ -53,6 +56,7 @@ export interface GraphNodeManager {
5356
export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
5457
// Get layout mutations composable
5558
const { createNode, deleteNode, setSource } = useLayoutMutations()
59+
const nodeDefStore = useNodeDefStore()
5660
// Safe reactive data extracted from LiteGraph nodes
5761
const vueNodeData = reactive(new Map<string, VueNodeData>())
5862

@@ -82,22 +86,22 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
8286
) {
8387
value = widget.options.values[0]
8488
}
89+
const spec = nodeDefStore.getInputSpecForWidget(node, widget.name)
8590

8691
return {
8792
name: widget.name,
8893
type: widget.type,
8994
value: value,
9095
label: widget.label,
9196
options: widget.options ? { ...widget.options } : undefined,
92-
callback: widget.callback
97+
callback: widget.callback,
98+
spec
9399
}
94100
} catch (error) {
95101
return {
96102
name: widget.name || 'unknown',
97103
type: widget.type || 'text',
98-
value: undefined, // Already a valid WidgetValue
99-
options: undefined,
100-
callback: undefined
104+
value: undefined
101105
}
102106
}
103107
})

src/locales/en/main.json

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1872,7 +1872,15 @@
18721872
"copyTooltip": "Copy message to clipboard"
18731873
},
18741874
"widgets": {
1875-
"selectModel": "Select model"
1875+
"selectModel": "Select model",
1876+
"uploadSelect": {
1877+
"placeholder": "Select...",
1878+
"placeholderImage": "Select image...",
1879+
"placeholderAudio": "Select audio...",
1880+
"placeholderVideo": "Select video...",
1881+
"placeholderModel": "Select model...",
1882+
"placeholderUnknown": "Select media..."
1883+
}
18761884
},
18771885
"nodeHelpPage": {
18781886
"inputs": "Inputs",

src/renderer/extensions/vueNodes/components/NodeWidgets.vue

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -137,7 +137,8 @@ const processedWidgets = computed((): ProcessedWidget[] => {
137137
value: widget.value,
138138
label: widget.label,
139139
options: widget.options,
140-
callback: widget.callback
140+
callback: widget.callback,
141+
spec: widget.spec
141142
}
142143
143144
const updateHandler = (value: unknown) => {

src/renderer/extensions/vueNodes/widgets/components/WidgetSelect.test.ts

Lines changed: 47 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,17 +4,21 @@ import Select from 'primevue/select'
44
import type { SelectProps } from 'primevue/select'
55
import { describe, expect, it } from 'vitest'
66

7+
import type { ComboInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
78
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
89

910
import WidgetSelect from './WidgetSelect.vue'
11+
import WidgetSelectDefault from './WidgetSelectDefault.vue'
12+
import WidgetSelectDropdown from './WidgetSelectDropdown.vue'
1013

1114
describe('WidgetSelect Value Binding', () => {
1215
const createMockWidget = (
1316
value: string = 'option1',
1417
options: Partial<
1518
SelectProps & { values?: string[]; return_index?: boolean }
1619
> = {},
17-
callback?: (value: string | number | undefined) => void
20+
callback?: (value: string | number | undefined) => void,
21+
spec?: ComboInputSpec
1822
): SimplifiedWidget<string | number | undefined> => ({
1923
name: 'test_select',
2024
type: 'combo',
@@ -23,7 +27,8 @@ describe('WidgetSelect Value Binding', () => {
2327
values: ['option1', 'option2', 'option3'],
2428
...options
2529
},
26-
callback
30+
callback,
31+
spec
2732
})
2833

2934
const mountComponent = (
@@ -184,4 +189,44 @@ describe('WidgetSelect Value Binding', () => {
184189
expect(emitted![0]).toContain('100')
185190
})
186191
})
192+
193+
describe('Spec-aware rendering', () => {
194+
it('uses dropdown variant when combo spec enables image uploads', () => {
195+
const spec: ComboInputSpec = {
196+
type: 'COMBO',
197+
name: 'test_select',
198+
image_upload: true
199+
}
200+
const widget = createMockWidget('option1', {}, undefined, spec)
201+
const wrapper = mountComponent(widget, 'option1')
202+
203+
expect(wrapper.findComponent(WidgetSelectDropdown).exists()).toBe(true)
204+
expect(wrapper.findComponent(WidgetSelectDefault).exists()).toBe(false)
205+
})
206+
207+
it('uses dropdown variant for audio uploads', () => {
208+
const spec: ComboInputSpec = {
209+
type: 'COMBO',
210+
name: 'test_select',
211+
audio_upload: true
212+
}
213+
const widget = createMockWidget('clip.wav', {}, undefined, spec)
214+
const wrapper = mountComponent(widget, 'clip.wav')
215+
const dropdown = wrapper.findComponent(WidgetSelectDropdown)
216+
217+
expect(dropdown.exists()).toBe(true)
218+
expect(dropdown.props('assetKind')).toBe('audio')
219+
expect(dropdown.props('allowUpload')).toBe(false)
220+
})
221+
222+
it('keeps default select when no spec or media hints are present', () => {
223+
const widget = createMockWidget('plain', {
224+
values: ['plain', 'text']
225+
})
226+
const wrapper = mountComponent(widget, 'plain')
227+
228+
expect(wrapper.findComponent(WidgetSelectDefault).exists()).toBe(true)
229+
expect(wrapper.findComponent(WidgetSelectDropdown).exists()).toBe(false)
230+
})
231+
})
187232
})
Lines changed: 73 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,33 +1,32 @@
11
<template>
2-
<WidgetLayoutField :widget>
3-
<Select
4-
v-model="localValue"
5-
:options="selectOptions"
6-
v-bind="combinedProps"
7-
:disabled="readonly"
8-
class="w-full text-xs bg-[#F9F8F4] dark-theme:bg-[#0E0E12] border-[#E1DED5] dark-theme:border-[#15161C] !rounded-lg"
9-
size="small"
10-
:pt="{
11-
option: 'text-xs'
12-
}"
13-
@update:model-value="onChange"
14-
/>
15-
</WidgetLayoutField>
2+
<WidgetSelectDropdown
3+
v-if="isDropdownUIWidget"
4+
v-bind="props"
5+
:asset-kind="assetKind"
6+
:allow-upload="allowUpload"
7+
:upload-folder="uploadFolder"
8+
@update:model-value="handleUpdateModelValue"
9+
/>
10+
<WidgetSelectDefault
11+
v-else
12+
v-bind="props"
13+
@update:model-value="handleUpdateModelValue"
14+
/>
1615
</template>
1716

1817
<script setup lang="ts">
19-
import Select from 'primevue/select'
2018
import { computed } from 'vue'
2119
22-
import { useWidgetValue } from '@/composables/graph/useWidgetValue'
23-
import { useTransformCompatOverlayProps } from '@/composables/useTransformCompatOverlayProps'
24-
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
20+
import type { ResultItemType } from '@/schemas/apiSchema'
2521
import {
26-
PANEL_EXCLUDED_PROPS,
27-
filterWidgetProps
28-
} from '@/utils/widgetPropFilter'
22+
type ComboInputSpec,
23+
isComboInputSpec
24+
} from '@/schemas/nodeDef/nodeDefSchemaV2'
25+
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
26+
import type { AssetKind } from '@/types/widgetTypes'
2927
30-
import WidgetLayoutField from './layout/WidgetLayoutField.vue'
28+
import WidgetSelectDefault from './WidgetSelectDefault.vue'
29+
import WidgetSelectDropdown from './WidgetSelectDropdown.vue'
3130
3231
const props = defineProps<{
3332
widget: SimplifiedWidget<string | number | undefined>
@@ -39,30 +38,64 @@ const emit = defineEmits<{
3938
'update:modelValue': [value: string | number | undefined]
4039
}>()
4140
42-
// Use the composable for consistent widget value handling
43-
const { localValue, onChange } = useWidgetValue({
44-
widget: props.widget,
45-
modelValue: props.modelValue,
46-
defaultValue: props.widget.options?.values?.[0] || '',
47-
emit
41+
function handleUpdateModelValue(value: string | number | undefined) {
42+
emit('update:modelValue', value)
43+
}
44+
45+
const comboSpec = computed<ComboInputSpec | undefined>(() => {
46+
if (props.widget.spec && isComboInputSpec(props.widget.spec)) {
47+
return props.widget.spec
48+
}
49+
return undefined
4850
})
4951
50-
// Transform compatibility props for overlay positioning
51-
const transformCompatProps = useTransformCompatOverlayProps()
52+
const specDescriptor = computed<{
53+
kind: AssetKind
54+
allowUpload: boolean
55+
folder: ResultItemType | undefined
56+
}>(() => {
57+
const spec = comboSpec.value
58+
if (!spec) {
59+
return {
60+
kind: 'unknown',
61+
allowUpload: false,
62+
folder: undefined
63+
}
64+
}
5265
53-
const combinedProps = computed(() => ({
54-
...filterWidgetProps(props.widget.options, PANEL_EXCLUDED_PROPS),
55-
...transformCompatProps.value
56-
}))
66+
const {
67+
image_upload,
68+
animated_image_upload,
69+
video_upload,
70+
image_folder,
71+
audio_upload
72+
} = spec
5773
58-
// Extract select options from widget options
59-
const selectOptions = computed(() => {
60-
const options = props.widget.options
74+
let kind: AssetKind = 'unknown'
75+
if (video_upload) {
76+
kind = 'video'
77+
} else if (image_upload || animated_image_upload) {
78+
kind = 'image'
79+
} else if (audio_upload) {
80+
kind = 'audio'
81+
}
82+
// TODO: add support for models (checkpoints, VAE, LoRAs, etc.) -- get widgetType from spec
6183
62-
if (options?.values && Array.isArray(options.values)) {
63-
return options.values
84+
const allowUpload =
85+
image_upload === true ||
86+
animated_image_upload === true ||
87+
audio_upload === true
88+
return {
89+
kind,
90+
allowUpload,
91+
folder: image_folder
6492
}
93+
})
6594
66-
return []
95+
const assetKind = computed(() => specDescriptor.value.kind)
96+
const isDropdownUIWidget = computed(() => assetKind.value !== 'unknown')
97+
const allowUpload = computed(() => specDescriptor.value.allowUpload)
98+
const uploadFolder = computed<ResultItemType>(() => {
99+
return specDescriptor.value.folder ?? 'input'
67100
})
68101
</script>
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
<template>
2+
<WidgetLayoutField :widget>
3+
<Select
4+
v-model="localValue"
5+
:options="selectOptions"
6+
v-bind="combinedProps"
7+
:disabled="readonly"
8+
class="w-full text-xs bg-[#F9F8F4] dark-theme:bg-[#0E0E12] border-[#E1DED5] dark-theme:border-[#15161C] !rounded-lg"
9+
size="small"
10+
:pt="{
11+
option: 'text-xs'
12+
}"
13+
@update:model-value="onChange"
14+
/>
15+
</WidgetLayoutField>
16+
</template>
17+
18+
<script setup lang="ts">
19+
import Select from 'primevue/select'
20+
import { computed } from 'vue'
21+
22+
import { useWidgetValue } from '@/composables/graph/useWidgetValue'
23+
import { useTransformCompatOverlayProps } from '@/composables/useTransformCompatOverlayProps'
24+
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
25+
import {
26+
PANEL_EXCLUDED_PROPS,
27+
filterWidgetProps
28+
} from '@/utils/widgetPropFilter'
29+
30+
import WidgetLayoutField from './layout/WidgetLayoutField.vue'
31+
32+
const props = defineProps<{
33+
widget: SimplifiedWidget<string | number | undefined>
34+
modelValue: string | number | undefined
35+
readonly?: boolean
36+
}>()
37+
38+
const emit = defineEmits<{
39+
'update:modelValue': [value: string | number | undefined]
40+
}>()
41+
42+
// Use the composable for consistent widget value handling
43+
const { localValue, onChange } = useWidgetValue({
44+
widget: props.widget,
45+
modelValue: props.modelValue,
46+
defaultValue: props.widget.options?.values?.[0] || '',
47+
emit
48+
})
49+
50+
// Transform compatibility props for overlay positioning
51+
const transformCompatProps = useTransformCompatOverlayProps()
52+
53+
const combinedProps = computed(() => ({
54+
...filterWidgetProps(props.widget.options, PANEL_EXCLUDED_PROPS),
55+
...transformCompatProps.value
56+
}))
57+
58+
// Extract select options from widget options
59+
const selectOptions = computed(() => {
60+
const options = props.widget.options
61+
62+
if (options?.values && Array.isArray(options.values)) {
63+
return options.values
64+
}
65+
66+
return []
67+
})
68+
</script>

0 commit comments

Comments
 (0)