[backport core/1.32] Feat: Load Image (from Outputs) support in Vue Nodes (#6871)

Backport of #6836 to `core/1.32`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6871-backport-core-1-32-Feat-Load-Image-from-Outputs-support-in-Vue-Nodes-2b46d73d36508121b3b3c9dac9e3de5a)
by [Unito](https://www.unito.io)

Co-authored-by: Alexander Brown <drjkl@comfy.org>
This commit is contained in:
Comfy Org PR Bot
2025-11-24 06:01:37 +09:00
committed by GitHub
parent d4d6ed0bb5
commit c26438bd0c
9 changed files with 39 additions and 53 deletions

View File

@@ -5,7 +5,7 @@
<SlotConnectionDot <SlotConnectionDot
ref="connectionDotRef" ref="connectionDotRef"
:color="slotColor" :color="slotColor"
:class="cn('-translate-x-1/2', 'w-3', errorClassesDot)" :class="cn('-translate-x-1/2 w-3', errorClassesDot)"
@pointerdown="onPointerDown" @pointerdown="onPointerDown"
/> />
@@ -48,6 +48,7 @@ interface InputSlotProps {
connected?: boolean connected?: boolean
compatible?: boolean compatible?: boolean
dotOnly?: boolean dotOnly?: boolean
socketless?: boolean
} }
const props = defineProps<InputSlotProps>() const props = defineProps<InputSlotProps>()
@@ -121,7 +122,8 @@ const slotWrapperClass = computed(() =>
'lg-slot--connected': props.connected, 'lg-slot--connected': props.connected,
'lg-slot--compatible': props.compatible, 'lg-slot--compatible': props.compatible,
'opacity-40': shouldDim.value 'opacity-40': shouldDim.value
} },
props.socketless && 'pointer-events-none invisible'
) )
) )

View File

@@ -40,6 +40,7 @@
}" }"
:node-id="nodeData?.id != null ? String(nodeData.id) : ''" :node-id="nodeData?.id != null ? String(nodeData.id) : ''"
:index="widget.slotMetadata.index" :index="widget.slotMetadata.index"
:socketless="widget.simplified.spec?.socketless"
dot-only dot-only
/> />
</div> </div>

View File

@@ -82,21 +82,11 @@ describe('WidgetButton Interactions', () => {
expect(button.exists()).toBe(true) expect(button.exists()).toBe(true)
}) })
it('renders widget label when name is provided', () => { it('renders widget text when name is provided', () => {
const widget = createMockWidget() const widget = createMockWidget()
const wrapper = mountComponent(widget) const wrapper = mountComponent(widget)
const label = wrapper.find('label') expect(wrapper.text()).toBe('test_button')
expect(label.exists()).toBe(true)
expect(label.text()).toBe('test_button')
})
it('does not render label when widget name is empty', () => {
const widget = createMockWidget({}, undefined, '')
const wrapper = mountComponent(widget)
const label = wrapper.find('label')
expect(label.exists()).toBe(false)
}) })
it('sets button size to small', () => { it('sets button size to small', () => {

View File

@@ -1,14 +1,15 @@
<template> <template>
<div class="flex flex-col gap-1"> <div class="flex flex-col gap-1">
<label v-if="widget.name" class="text-secondary text-sm">{{
widget.name
}}</label>
<Button <Button
v-bind="filteredProps" v-bind="filteredProps"
:aria-label="widget.name || widget.label" :aria-label="widget.name || widget.label"
size="small" size="small"
@click="handleClick" @click="handleClick"
/> >
<template v-if="widget.name">
{{ widget.name }}
</template>
</Button>
</div> </div>
</template> </template>

View File

@@ -136,8 +136,8 @@ const outputItems = computed<DropdownItem[]>(() => {
}) })
}) })
return Array.from(outputs).map((output, index) => ({ return Array.from(outputs).map((output) => ({
id: `output-${index}`, id: `output-${output}`,
mediaSrc: getMediaUrl(output.replace(' [output]', ''), 'output'), mediaSrc: getMediaUrl(output.replace(' [output]', ''), 'output'),
name: output, name: output,
label: getDisplayLabel(output), label: getDisplayLabel(output),
@@ -215,16 +215,14 @@ const layoutMode = ref<LayoutMode>(props.defaultLayoutMode ?? 'grid')
watch( watch(
modelValue, modelValue,
(currentValue) => { (currentValue) => {
if (currentValue !== undefined) { 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() selectedSet.value.clear()
return
}
const item = dropdownItems.value.find((item) => item.name === currentValue)
if (item) {
selectedSet.value.clear()
selectedSet.value.add(item.id)
} }
}, },
{ immediate: true } { immediate: true }

View File

@@ -32,24 +32,13 @@ const selectedItems = computed(() => {
return props.items.filter((item) => props.selected.has(item.id)) 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 text-component-node-foreground-secondary',
{
'rotate-180': props.isOpen
}
)
)
const theButtonStyle = computed(() => const theButtonStyle = computed(() =>
cn( cn(
'border-0 bg-component-node-widget-background outline-none text-text-secondary', 'border-0 bg-component-node-widget-background outline-none text-text-secondary',
{ props.disabled
'hover:bg-component-node-widget-background-hovered cursor-pointer': ? 'cursor-not-allowed'
!props.disabled, : 'hover:bg-component-node-widget-background-hovered cursor-pointer',
'cursor-not-allowed': props.disabled, selectedItems.value.length > 0 && 'text-text-primary'
'text-text-primary': selectedItems.value.length > 0
}
) )
) )
</script> </script>
@@ -78,13 +67,21 @@ const theButtonStyle = computed(() =>
> >
<span class="min-w-0 flex-1 px-1 py-2 text-left truncate"> <span class="min-w-0 flex-1 px-1 py-2 text-left truncate">
<span v-if="!selectedItems.length"> <span v-if="!selectedItems.length">
{{ props.placeholder }} {{ placeholder }}
</span> </span>
<span v-else> <span v-else>
{{ selectedItems.map((item) => item.label ?? item.name).join(', ') }} {{ selectedItems.map((item) => item.label ?? item.name).join(', ') }}
</span> </span>
</span> </span>
<i class="icon-[lucide--chevron-down]" :class="chevronClass" /> <i
class="icon-[lucide--chevron-down]"
:class="
cn(
'mr-2 size-4 transition-transform duration-200 flex-shrink-0 text-component-node-foreground-secondary',
isOpen && 'rotate-180'
)
"
/>
</button> </button>
<!-- Open File --> <!-- Open File -->
<label <label

View File

@@ -236,9 +236,7 @@ export function useRemoteWidget<
* Add a refresh button to the node that, when clicked, will force the widget to refresh * Add a refresh button to the node that, when clicked, will force the widget to refresh
*/ */
function addRefreshButton() { function addRefreshButton() {
node.addWidget('button', 'refresh', 'refresh', widget.refresh, { node.addWidget('button', 'refresh', 'refresh', widget.refresh)
canvasOnly: true
})
} }
/** /**
@@ -263,8 +261,7 @@ export function useRemoteWidget<
autoRefreshEnabled = value autoRefreshEnabled = value
}, },
{ {
serialize: false, serialize: false
canvasOnly: true
} }
) )

View File

@@ -29,6 +29,7 @@ export const zBaseInputOptions = z
defaultInput: z.boolean().optional(), defaultInput: z.boolean().optional(),
forceInput: z.boolean().optional(), forceInput: z.boolean().optional(),
tooltip: z.string().optional(), tooltip: z.string().optional(),
socketless: z.boolean().optional(),
hidden: z.boolean().optional(), hidden: z.boolean().optional(),
advanced: z.boolean().optional(), advanced: z.boolean().optional(),
widgetType: z.string().optional(), widgetType: z.string().optional(),

View File

@@ -610,8 +610,7 @@ describe('useRemoteWidget', () => {
false, false,
expect.any(Function), expect.any(Function),
{ {
serialize: false, serialize: false
canvasOnly: true
} }
) )
}) })