diff --git a/src/extensions/core/imageCompare.ts b/src/extensions/core/imageCompare.ts new file mode 100644 index 0000000000..608590f1bc --- /dev/null +++ b/src/extensions/core/imageCompare.ts @@ -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).a_images as + | Record[] + | undefined + const bImages = (output as Record).b_images as + | Record[] + | 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) + } + } + } +}) diff --git a/src/extensions/core/index.ts b/src/extensions/core/index.ts index a007a365de..ba36f847a4 100644 --- a/src/extensions/core/index.ts +++ b/src/extensions/core/index.ts @@ -9,6 +9,7 @@ import './electronAdapter' import './groupNode' import './groupNodeManage' import './groupOptions' +import './imageCompare' import './load3d' import './maskeditor' import './nodeTemplates' diff --git a/src/locales/en/main.json b/src/locales/en/main.json index 217c5b480f..af70861a32 100644 --- a/src/locales/en/main.json +++ b/src/locales/en/main.json @@ -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", diff --git a/src/renderer/extensions/vueNodes/widgets/components/WidgetImageCompare.test.ts b/src/renderer/extensions/vueNodes/widgets/components/WidgetImageCompare.test.ts index 847ffc6574..e1eb34e1b9 100644 --- a/src/renderer/extensions/vueNodes/widgets/components/WidgetImageCompare.test.ts +++ b/src/renderer/extensions/vueNodes/widgets/components/WidgetImageCompare.test.ts @@ -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) + }) + }) }) diff --git a/src/renderer/extensions/vueNodes/widgets/components/WidgetImageCompare.vue b/src/renderer/extensions/vueNodes/widgets/components/WidgetImageCompare.vue index 34516a120e..8cb80341a8 100644 --- a/src/renderer/extensions/vueNodes/widgets/components/WidgetImageCompare.vue +++ b/src/renderer/extensions/vueNodes/widgets/components/WidgetImageCompare.vue @@ -1,32 +1,39 @@