mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-03-13 17:10:06 +00:00
feat: add ImageCompare node (#7538)
## Summary add ImageCompare node, which is high demand among custom nodes, such as rgthree, we should support as core node Need BE change https://github.com/comfyanonymous/ComfyUI/pull/11343 ## Screenshots (if applicable) https://github.com/user-attachments/assets/a37bdcd0-de59-4bdd-bfc7-1adbe92f5298 ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-7538-feat-add-ImageCompare-node-2cb6d73d36508163a7d5f4807aece01a) by [Unito](https://www.unito.io)
This commit is contained in:
48
src/extensions/core/imageCompare.ts
Normal file
48
src/extensions/core/imageCompare.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import type { NodeExecutionOutput } from '@/schemas/apiSchema'
|
||||
import { api } from '@/scripts/api'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useExtensionService } from '@/services/extensionService'
|
||||
|
||||
useExtensionService().registerExtension({
|
||||
name: 'Comfy.ImageCompare',
|
||||
|
||||
async nodeCreated(node) {
|
||||
if (node.constructor.comfyClass !== 'ImageCompare') return
|
||||
|
||||
const [oldWidth, oldHeight] = node.size
|
||||
node.setSize([Math.max(oldWidth, 400), Math.max(oldHeight, 350)])
|
||||
|
||||
const onExecuted = node.onExecuted
|
||||
|
||||
node.onExecuted = function (output: NodeExecutionOutput) {
|
||||
onExecuted?.call(this, output)
|
||||
|
||||
const aImages = (output as Record<string, unknown>).a_images as
|
||||
| Record<string, string>[]
|
||||
| undefined
|
||||
const bImages = (output as Record<string, unknown>).b_images as
|
||||
| Record<string, string>[]
|
||||
| undefined
|
||||
const rand = app.getRandParam()
|
||||
|
||||
const beforeUrl =
|
||||
aImages && aImages.length > 0
|
||||
? api.apiURL(`/view?${new URLSearchParams(aImages[0])}${rand}`)
|
||||
: ''
|
||||
const afterUrl =
|
||||
bImages && bImages.length > 0
|
||||
? api.apiURL(`/view?${new URLSearchParams(bImages[0])}${rand}`)
|
||||
: ''
|
||||
|
||||
const widget = node.widgets?.find((w) => w.type === 'imagecompare')
|
||||
|
||||
if (widget) {
|
||||
widget.value = {
|
||||
before: beforeUrl,
|
||||
after: afterUrl
|
||||
}
|
||||
widget.callback?.(widget.value)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -9,6 +9,7 @@ import './electronAdapter'
|
||||
import './groupNode'
|
||||
import './groupNodeManage'
|
||||
import './groupOptions'
|
||||
import './imageCompare'
|
||||
import './load3d'
|
||||
import './maskeditor'
|
||||
import './nodeTemplates'
|
||||
|
||||
@@ -1607,6 +1607,9 @@
|
||||
"errorMessage": "Failed to copy to clipboard",
|
||||
"errorNotSupported": "Clipboard API not supported in your browser"
|
||||
},
|
||||
"imageCompare": {
|
||||
"noImages": "No images to compare"
|
||||
},
|
||||
"load3d": {
|
||||
"switchCamera": "Switch Camera",
|
||||
"showGrid": "Show Grid",
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import ImageCompare from 'primevue/imagecompare'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
@@ -25,8 +23,9 @@ describe('WidgetImageCompare Display', () => {
|
||||
) => {
|
||||
return mount(WidgetImageCompare, {
|
||||
global: {
|
||||
plugins: [PrimeVue],
|
||||
components: { ImageCompare }
|
||||
mocks: {
|
||||
$t: (key: string) => key
|
||||
}
|
||||
},
|
||||
props: {
|
||||
widget,
|
||||
@@ -36,7 +35,7 @@ describe('WidgetImageCompare Display', () => {
|
||||
}
|
||||
|
||||
describe('Component Rendering', () => {
|
||||
it('renders imagecompare component with proper structure and styling', () => {
|
||||
it('renders with proper structure and styling when images are provided', () => {
|
||||
const value: ImageCompareValue = {
|
||||
before: 'https://example.com/before.jpg',
|
||||
after: 'https://example.com/after.jpg'
|
||||
@@ -44,21 +43,15 @@ describe('WidgetImageCompare Display', () => {
|
||||
const widget = createMockWidget(value)
|
||||
const wrapper = mountComponent(widget)
|
||||
|
||||
// Component exists
|
||||
const imageCompare = wrapper.findComponent({ name: 'ImageCompare' })
|
||||
expect(imageCompare.exists()).toBe(true)
|
||||
|
||||
// Renders both images with correct URLs
|
||||
const images = wrapper.findAll('img')
|
||||
expect(images).toHaveLength(2)
|
||||
expect(images[0].attributes('src')).toBe('https://example.com/before.jpg')
|
||||
expect(images[1].attributes('src')).toBe('https://example.com/after.jpg')
|
||||
|
||||
// Images have proper styling classes
|
||||
// In the new implementation: after image is first (background), before image is second (overlay)
|
||||
expect(images[0].attributes('src')).toBe('https://example.com/after.jpg')
|
||||
expect(images[1].attributes('src')).toBe('https://example.com/before.jpg')
|
||||
|
||||
images.forEach((img) => {
|
||||
expect(img.classes()).toContain('object-cover')
|
||||
expect(img.classes()).toContain('w-full')
|
||||
expect(img.classes()).toContain('h-full')
|
||||
expect(img.classes()).toContain('object-contain')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -74,8 +67,9 @@ describe('WidgetImageCompare Display', () => {
|
||||
}
|
||||
const customWrapper = mountComponent(createMockWidget(customAltValue))
|
||||
const customImages = customWrapper.findAll('img')
|
||||
expect(customImages[0].attributes('alt')).toBe('Original design')
|
||||
expect(customImages[1].attributes('alt')).toBe('Updated design')
|
||||
// DOM order: [after, before]
|
||||
expect(customImages[0].attributes('alt')).toBe('Updated design')
|
||||
expect(customImages[1].attributes('alt')).toBe('Original design')
|
||||
|
||||
// Test default alt text
|
||||
const defaultAltValue: ImageCompareValue = {
|
||||
@@ -84,8 +78,8 @@ describe('WidgetImageCompare Display', () => {
|
||||
}
|
||||
const defaultWrapper = mountComponent(createMockWidget(defaultAltValue))
|
||||
const defaultImages = defaultWrapper.findAll('img')
|
||||
expect(defaultImages[0].attributes('alt')).toBe('Before image')
|
||||
expect(defaultImages[1].attributes('alt')).toBe('After image')
|
||||
expect(defaultImages[0].attributes('alt')).toBe('After image')
|
||||
expect(defaultImages[1].attributes('alt')).toBe('Before image')
|
||||
|
||||
// Test empty string alt text (falls back to default)
|
||||
const emptyAltValue: ImageCompareValue = {
|
||||
@@ -96,29 +90,36 @@ describe('WidgetImageCompare Display', () => {
|
||||
}
|
||||
const emptyWrapper = mountComponent(createMockWidget(emptyAltValue))
|
||||
const emptyImages = emptyWrapper.findAll('img')
|
||||
expect(emptyImages[0].attributes('alt')).toBe('Before image')
|
||||
expect(emptyImages[1].attributes('alt')).toBe('After image')
|
||||
expect(emptyImages[0].attributes('alt')).toBe('After image')
|
||||
expect(emptyImages[1].attributes('alt')).toBe('Before image')
|
||||
})
|
||||
|
||||
it('handles missing and partial image URLs gracefully', () => {
|
||||
// Missing URLs
|
||||
const missingValue: ImageCompareValue = { before: '', after: '' }
|
||||
const missingWrapper = mountComponent(createMockWidget(missingValue))
|
||||
const missingImages = missingWrapper.findAll('img')
|
||||
expect(missingImages[0].attributes('src')).toBe('')
|
||||
expect(missingImages[1].attributes('src')).toBe('')
|
||||
|
||||
// Partial URLs
|
||||
const partialValue: ImageCompareValue = {
|
||||
it('handles partial image URLs gracefully', () => {
|
||||
// Only before image provided
|
||||
const beforeOnlyValue: ImageCompareValue = {
|
||||
before: 'https://example.com/before.jpg',
|
||||
after: ''
|
||||
}
|
||||
const partialWrapper = mountComponent(createMockWidget(partialValue))
|
||||
const partialImages = partialWrapper.findAll('img')
|
||||
expect(partialImages[0].attributes('src')).toBe(
|
||||
const beforeOnlyWrapper = mountComponent(
|
||||
createMockWidget(beforeOnlyValue)
|
||||
)
|
||||
const beforeOnlyImages = beforeOnlyWrapper.findAll('img')
|
||||
expect(beforeOnlyImages).toHaveLength(1)
|
||||
expect(beforeOnlyImages[0].attributes('src')).toBe(
|
||||
'https://example.com/before.jpg'
|
||||
)
|
||||
expect(partialImages[1].attributes('src')).toBe('')
|
||||
|
||||
// Only after image provided
|
||||
const afterOnlyValue: ImageCompareValue = {
|
||||
before: '',
|
||||
after: 'https://example.com/after.jpg'
|
||||
}
|
||||
const afterOnlyWrapper = mountComponent(createMockWidget(afterOnlyValue))
|
||||
const afterOnlyImages = afterOnlyWrapper.findAll('img')
|
||||
expect(afterOnlyImages).toHaveLength(1)
|
||||
expect(afterOnlyImages[0].attributes('src')).toBe(
|
||||
'https://example.com/after.jpg'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -129,75 +130,14 @@ describe('WidgetImageCompare Display', () => {
|
||||
const wrapper = mountComponent(widget)
|
||||
|
||||
const images = wrapper.findAll('img')
|
||||
expect(images).toHaveLength(1)
|
||||
expect(images[0].attributes('src')).toBe('https://example.com/single.jpg')
|
||||
expect(images[1].attributes('src')).toBe('')
|
||||
})
|
||||
|
||||
it('uses default alt text for string values', () => {
|
||||
const value = 'https://example.com/single.jpg'
|
||||
const widget = createMockWidget(value)
|
||||
const wrapper = mountComponent(widget)
|
||||
|
||||
const images = wrapper.findAll('img')
|
||||
expect(images[0].attributes('alt')).toBe('Before image')
|
||||
expect(images[1].attributes('alt')).toBe('After image')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Widget Options Handling', () => {
|
||||
it('passes through accessibility options', () => {
|
||||
const value: ImageCompareValue = {
|
||||
before: 'https://example.com/before.jpg',
|
||||
after: 'https://example.com/after.jpg'
|
||||
}
|
||||
const widget = createMockWidget(value, {
|
||||
tabindex: 1,
|
||||
ariaLabel: 'Compare images',
|
||||
ariaLabelledby: 'compare-label'
|
||||
})
|
||||
const wrapper = mountComponent(widget)
|
||||
|
||||
const imageCompare = wrapper.findComponent({ name: 'ImageCompare' })
|
||||
expect(imageCompare.props('tabindex')).toBe(1)
|
||||
expect(imageCompare.props('ariaLabel')).toBe('Compare images')
|
||||
expect(imageCompare.props('ariaLabelledby')).toBe('compare-label')
|
||||
})
|
||||
|
||||
it('uses default tabindex when not provided', () => {
|
||||
const value: ImageCompareValue = {
|
||||
before: 'https://example.com/before.jpg',
|
||||
after: 'https://example.com/after.jpg'
|
||||
}
|
||||
const widget = createMockWidget(value)
|
||||
const wrapper = mountComponent(widget)
|
||||
|
||||
const imageCompare = wrapper.findComponent({ name: 'ImageCompare' })
|
||||
expect(imageCompare.props('tabindex')).toBe(0)
|
||||
})
|
||||
|
||||
it('passes through PrimeVue specific options', () => {
|
||||
const value: ImageCompareValue = {
|
||||
before: 'https://example.com/before.jpg',
|
||||
after: 'https://example.com/after.jpg'
|
||||
}
|
||||
const widget = createMockWidget(value, {
|
||||
unstyled: true,
|
||||
pt: { root: { class: 'custom-class' } },
|
||||
ptOptions: { mergeSections: true }
|
||||
})
|
||||
const wrapper = mountComponent(widget)
|
||||
|
||||
const imageCompare = wrapper.findComponent({ name: 'ImageCompare' })
|
||||
expect(imageCompare.props('unstyled')).toBe(true)
|
||||
expect(imageCompare.props('pt')).toEqual({
|
||||
root: { class: 'custom-class' }
|
||||
})
|
||||
expect(imageCompare.props('ptOptions')).toEqual({ mergeSections: true })
|
||||
})
|
||||
})
|
||||
|
||||
describe('Readonly Mode', () => {
|
||||
it('renders normally in readonly mode (no interaction restrictions)', () => {
|
||||
it('renders normally in readonly mode', () => {
|
||||
const value: ImageCompareValue = {
|
||||
before: 'https://example.com/before.jpg',
|
||||
after: 'https://example.com/after.jpg'
|
||||
@@ -205,45 +145,39 @@ describe('WidgetImageCompare Display', () => {
|
||||
const widget = createMockWidget(value)
|
||||
const wrapper = mountComponent(widget, true)
|
||||
|
||||
// ImageCompare is display-only, readonly doesn't affect rendering
|
||||
const imageCompare = wrapper.findComponent({ name: 'ImageCompare' })
|
||||
expect(imageCompare.exists()).toBe(true)
|
||||
|
||||
const images = wrapper.findAll('img')
|
||||
expect(images).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('handles null or undefined widget value', () => {
|
||||
it('shows no images message when widget value is empty string', () => {
|
||||
const widget = createMockWidget('')
|
||||
const wrapper = mountComponent(widget)
|
||||
|
||||
const images = wrapper.findAll('img')
|
||||
expect(images[0].attributes('src')).toBe('')
|
||||
expect(images[1].attributes('src')).toBe('')
|
||||
expect(images[0].attributes('alt')).toBe('Before image')
|
||||
expect(images[1].attributes('alt')).toBe('After image')
|
||||
expect(images).toHaveLength(0)
|
||||
expect(wrapper.text()).toContain('imageCompare.noImages')
|
||||
})
|
||||
|
||||
it('handles empty object value', () => {
|
||||
it('shows no images message when both URLs are empty', () => {
|
||||
const value: ImageCompareValue = { before: '', after: '' }
|
||||
const widget = createMockWidget(value)
|
||||
const wrapper = mountComponent(widget)
|
||||
|
||||
const images = wrapper.findAll('img')
|
||||
expect(images).toHaveLength(0)
|
||||
expect(wrapper.text()).toContain('imageCompare.noImages')
|
||||
})
|
||||
|
||||
it('shows no images message for empty object value', () => {
|
||||
const value: ImageCompareValue = {} as ImageCompareValue
|
||||
const widget = createMockWidget(value)
|
||||
const wrapper = mountComponent(widget)
|
||||
|
||||
const images = wrapper.findAll('img')
|
||||
expect(images[0].attributes('src')).toBe('')
|
||||
expect(images[1].attributes('src')).toBe('')
|
||||
})
|
||||
|
||||
it('handles malformed object value', () => {
|
||||
const value = { randomProp: 'test', before: '', after: '' }
|
||||
const widget = createMockWidget(value)
|
||||
const wrapper = mountComponent(widget)
|
||||
|
||||
const images = wrapper.findAll('img')
|
||||
expect(images[0].attributes('src')).toBe('')
|
||||
expect(images[1].attributes('src')).toBe('')
|
||||
expect(images).toHaveLength(0)
|
||||
expect(wrapper.text()).toContain('imageCompare.noImages')
|
||||
})
|
||||
|
||||
it('handles special content - long URLs, special characters, and long alt text', () => {
|
||||
@@ -290,7 +224,7 @@ describe('WidgetImageCompare Display', () => {
|
||||
})
|
||||
|
||||
describe('Template Structure', () => {
|
||||
it('correctly assigns images to left and right template slots', () => {
|
||||
it('correctly renders after image as background and before image as overlay', () => {
|
||||
const value: ImageCompareValue = {
|
||||
before: 'https://example.com/before.jpg',
|
||||
after: 'https://example.com/after.jpg'
|
||||
@@ -299,10 +233,11 @@ describe('WidgetImageCompare Display', () => {
|
||||
const wrapper = mountComponent(widget)
|
||||
|
||||
const images = wrapper.findAll('img')
|
||||
// First image (before) should be in left template slot
|
||||
expect(images[0].attributes('src')).toBe('https://example.com/before.jpg')
|
||||
// Second image (after) should be in right template slot
|
||||
expect(images[1].attributes('src')).toBe('https://example.com/after.jpg')
|
||||
// After image is rendered first as background
|
||||
expect(images[0].attributes('src')).toBe('https://example.com/after.jpg')
|
||||
// Before image is rendered second as overlay with clipPath
|
||||
expect(images[1].attributes('src')).toBe('https://example.com/before.jpg')
|
||||
expect(images[1].classes()).toContain('absolute')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -333,4 +268,27 @@ describe('WidgetImageCompare Display', () => {
|
||||
expect(blobUrlImages[1].attributes('src')).toBe(blobUrl)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Slider Element', () => {
|
||||
it('renders slider divider when images are present', () => {
|
||||
const value: ImageCompareValue = {
|
||||
before: 'https://example.com/before.jpg',
|
||||
after: 'https://example.com/after.jpg'
|
||||
}
|
||||
const widget = createMockWidget(value)
|
||||
const wrapper = mountComponent(widget)
|
||||
|
||||
const slider = wrapper.find('[role="presentation"]')
|
||||
expect(slider.exists()).toBe(true)
|
||||
expect(slider.classes()).toContain('bg-white')
|
||||
})
|
||||
|
||||
it('does not render slider when no images', () => {
|
||||
const widget = createMockWidget('')
|
||||
const wrapper = mountComponent(widget)
|
||||
|
||||
const slider = wrapper.find('[role="presentation"]')
|
||||
expect(slider.exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,32 +1,39 @@
|
||||
<template>
|
||||
<ImageCompare
|
||||
:tabindex="widget.options?.tabindex ?? 0"
|
||||
:aria-label="widget.options?.ariaLabel"
|
||||
:aria-labelledby="widget.options?.ariaLabelledby"
|
||||
:pt="widget.options?.pt"
|
||||
:pt-options="widget.options?.ptOptions"
|
||||
:unstyled="widget.options?.unstyled"
|
||||
>
|
||||
<template #left>
|
||||
<img
|
||||
:src="beforeImage"
|
||||
:alt="beforeAlt"
|
||||
class="h-full w-full object-cover"
|
||||
/>
|
||||
</template>
|
||||
<template #right>
|
||||
<div ref="containerRef" class="relative size-full min-h-32 overflow-hidden">
|
||||
<div v-if="beforeImage || afterImage">
|
||||
<img
|
||||
v-if="afterImage"
|
||||
:src="afterImage"
|
||||
:alt="afterAlt"
|
||||
class="h-full w-full object-cover"
|
||||
draggable="false"
|
||||
class="size-full object-contain"
|
||||
/>
|
||||
</template>
|
||||
</ImageCompare>
|
||||
|
||||
<img
|
||||
v-if="beforeImage"
|
||||
:src="beforeImage"
|
||||
:alt="beforeAlt"
|
||||
draggable="false"
|
||||
class="absolute inset-0 size-full object-contain"
|
||||
:style="{ clipPath: `inset(0 ${100 - sliderPosition}% 0 0)` }"
|
||||
/>
|
||||
|
||||
<div
|
||||
class="pointer-events-none absolute inset-y-0 z-10 w-0.5 bg-white shadow-md"
|
||||
:style="{ left: `${sliderPosition}%` }"
|
||||
role="presentation"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-else class="flex size-full items-center justify-center">
|
||||
{{ $t('imageCompare.noImages') }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import ImageCompare from 'primevue/imagecompare'
|
||||
import { computed } from 'vue'
|
||||
import { useMouseInElement } from '@vueuse/core'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
|
||||
@@ -43,6 +50,17 @@ const props = defineProps<{
|
||||
widget: SimplifiedWidget<ImageCompareValue | string>
|
||||
}>()
|
||||
|
||||
const containerRef = ref<HTMLElement | null>(null)
|
||||
const sliderPosition = ref(50)
|
||||
|
||||
const { elementX, elementWidth, isOutside } = useMouseInElement(containerRef)
|
||||
|
||||
watch([elementX, elementWidth, isOutside], ([x, width, outside]) => {
|
||||
if (!outside && width > 0) {
|
||||
sliderPosition.value = Math.max(0, Math.min(100, (x / width) * 100))
|
||||
}
|
||||
})
|
||||
|
||||
const beforeImage = computed(() => {
|
||||
const value = props.widget.value
|
||||
return typeof value === 'string' ? value : value?.before || ''
|
||||
|
||||
Reference in New Issue
Block a user