Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions src/extensions/core/imageCompare.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
import { useExtensionService } from '@/services/extensionService'

useExtensionService().registerExtension({
name: 'Comfy.ImageCompare',

async beforeRegisterNodeDef(_nodeType, nodeData) {
if (nodeData.name !== 'ImageCompare') return

// @ts-expect-error InputSpec is not typed correctly
nodeData.input.required.compare_view = ['IMAGECOMPARE']
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We would like to move away from this pattern as it is quite hard to maintain. We have the ability to add first-class input types, how would this look like if we just defined IMAGECOMPARE in the node defs in core?

},

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 (message: any) {
onExecuted?.apply(this, arguments as any)

const aImages = message.a_images ?? []
const bImages = message.b_images ?? []
const rand = app.getRandParam()

const beforeUrl =
aImages.length > 0
? api.apiURL(`/view?${new URLSearchParams(aImages[0])}${rand}`)
: ''
const afterUrl =
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)
}
}
}
})
1 change: 1 addition & 0 deletions src/extensions/core/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import './electronAdapter'
import './groupNode'
import './groupNodeManage'
import './groupOptions'
import './imageCompare'
import './load3d'
import './maskeditor'
import './nodeTemplates'
Expand Down
3 changes: 3 additions & 0 deletions src/locales/en/main.json
Original file line number Diff line number Diff line change
Expand Up @@ -1590,6 +1590,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",
Expand Down
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -25,8 +23,9 @@ describe('WidgetImageCompare Display', () => {
) => {
return mount(WidgetImageCompare, {
global: {
plugins: [PrimeVue],
components: { ImageCompare }
mocks: {
$t: (key: string) => key
}
},
props: {
widget,
Expand All @@ -36,29 +35,23 @@ 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'
}
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')
})
})
})
Expand All @@ -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 = {
Expand All @@ -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 = {
Expand All @@ -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'
)
})
})

Expand All @@ -129,121 +130,54 @@ 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'
}
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', () => {
const value: ImageCompareValue = {} as ImageCompareValue
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[0].attributes('src')).toBe('')
expect(images[1].attributes('src')).toBe('')
expect(images).toHaveLength(0)
expect(wrapper.text()).toContain('imageCompare.noImages')
})

it('handles malformed object value', () => {
const value = { randomProp: 'test', before: '', after: '' }
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('')
expect(images).toHaveLength(0)
expect(wrapper.text()).toContain('imageCompare.noImages')
})

it('handles special content - long URLs, special characters, and long alt text', () => {
Expand Down Expand Up @@ -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'
Expand All @@ -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')
})
})

Expand Down Expand Up @@ -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)
})
})
})
Loading