diff --git a/src/extensions/core/imageCompare.ts b/src/extensions/core/imageCompare.ts index 1f8d3cf2fe..be2ec4a2e9 100644 --- a/src/extensions/core/imageCompare.ts +++ b/src/extensions/core/imageCompare.ts @@ -26,22 +26,18 @@ useExtensionService().registerExtension({ const { a_images: aImages, b_images: bImages } = output 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 toUrl = (params: Record) => + api.apiURL(`/view?${new URLSearchParams(params)}${rand}`) + + const beforeImages = + aImages && aImages.length > 0 ? aImages.map(toUrl) : [] + const afterImages = + bImages && bImages.length > 0 ? bImages.map(toUrl) : [] const widget = node.widgets?.find((w) => w.type === 'imagecompare') if (widget) { - widget.value = { - before: beforeUrl, - after: afterUrl - } + widget.value = { beforeImages, afterImages } widget.callback?.(widget.value) } } diff --git a/src/locales/en/main.json b/src/locales/en/main.json index 7c30c8e65e..b365db5a3e 100644 --- a/src/locales/en/main.json +++ b/src/locales/en/main.json @@ -1796,7 +1796,12 @@ "errorNotSupported": "Clipboard API not supported in your browser" }, "imageCompare": { - "noImages": "No images to compare" + "noImages": "No images to compare", + "batchLabelA": "A:", + "batchLabelB": "B:" + }, + "batch": { + "index": "{current} / {total}" }, "load3d": { "switchCamera": "Switch Camera", diff --git a/src/renderer/extensions/vueNodes/widgets/components/BatchNavigation.vue b/src/renderer/extensions/vueNodes/widgets/components/BatchNavigation.vue new file mode 100644 index 0000000000..d12fcd7337 --- /dev/null +++ b/src/renderer/extensions/vueNodes/widgets/components/BatchNavigation.vue @@ -0,0 +1,38 @@ + + + diff --git a/src/renderer/extensions/vueNodes/widgets/components/WidgetImageCompare.test.ts b/src/renderer/extensions/vueNodes/widgets/components/WidgetImageCompare.test.ts index e1eb34e1b9..fa50249d46 100644 --- a/src/renderer/extensions/vueNodes/widgets/components/WidgetImageCompare.test.ts +++ b/src/renderer/extensions/vueNodes/widgets/components/WidgetImageCompare.test.ts @@ -24,7 +24,12 @@ describe('WidgetImageCompare Display', () => { return mount(WidgetImageCompare, { global: { mocks: { - $t: (key: string) => key + $t: (key: string, params?: Record) => { + if (key === 'batch.index' && params) { + return `${params.current} / ${params.total}` + } + return key + } } }, props: { @@ -37,8 +42,8 @@ describe('WidgetImageCompare Display', () => { describe('Component Rendering', () => { 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' + beforeImages: ['https://example.com/before.jpg'], + afterImages: ['https://example.com/after.jpg'] } const widget = createMockWidget(value) const wrapper = mountComponent(widget) @@ -46,7 +51,7 @@ describe('WidgetImageCompare Display', () => { const images = wrapper.findAll('img') expect(images).toHaveLength(2) - // In the new implementation: after image is first (background), before image is second (overlay) + // 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') @@ -60,8 +65,8 @@ describe('WidgetImageCompare Display', () => { it('handles alt text correctly - custom, default, and empty', () => { // Test custom alt text const customAltValue: ImageCompareValue = { - before: 'https://example.com/before.jpg', - after: 'https://example.com/after.jpg', + beforeImages: ['https://example.com/before.jpg'], + afterImages: ['https://example.com/after.jpg'], beforeAlt: 'Original design', afterAlt: 'Updated design' } @@ -73,8 +78,8 @@ describe('WidgetImageCompare Display', () => { // Test default alt text const defaultAltValue: ImageCompareValue = { - before: 'https://example.com/before.jpg', - after: 'https://example.com/after.jpg' + beforeImages: ['https://example.com/before.jpg'], + afterImages: ['https://example.com/after.jpg'] } const defaultWrapper = mountComponent(createMockWidget(defaultAltValue)) const defaultImages = defaultWrapper.findAll('img') @@ -83,8 +88,8 @@ describe('WidgetImageCompare Display', () => { // Test empty string alt text (falls back to default) const emptyAltValue: ImageCompareValue = { - before: 'https://example.com/before.jpg', - after: 'https://example.com/after.jpg', + beforeImages: ['https://example.com/before.jpg'], + afterImages: ['https://example.com/after.jpg'], beforeAlt: '', afterAlt: '' } @@ -96,12 +101,10 @@ describe('WidgetImageCompare Display', () => { it('handles partial image URLs gracefully', () => { // Only before image provided - const beforeOnlyValue: ImageCompareValue = { - before: 'https://example.com/before.jpg', - after: '' - } const beforeOnlyWrapper = mountComponent( - createMockWidget(beforeOnlyValue) + createMockWidget({ + beforeImages: ['https://example.com/before.jpg'] + }) ) const beforeOnlyImages = beforeOnlyWrapper.findAll('img') expect(beforeOnlyImages).toHaveLength(1) @@ -110,11 +113,11 @@ describe('WidgetImageCompare Display', () => { ) // Only after image provided - const afterOnlyValue: ImageCompareValue = { - before: '', - after: 'https://example.com/after.jpg' - } - const afterOnlyWrapper = mountComponent(createMockWidget(afterOnlyValue)) + const afterOnlyWrapper = mountComponent( + createMockWidget({ + afterImages: ['https://example.com/after.jpg'] + }) + ) const afterOnlyImages = afterOnlyWrapper.findAll('img') expect(afterOnlyImages).toHaveLength(1) expect(afterOnlyImages[0].attributes('src')).toBe( @@ -139,8 +142,8 @@ describe('WidgetImageCompare Display', () => { describe('Readonly Mode', () => { it('renders normally in readonly mode', () => { const value: ImageCompareValue = { - before: 'https://example.com/before.jpg', - after: 'https://example.com/after.jpg' + beforeImages: ['https://example.com/before.jpg'], + afterImages: ['https://example.com/after.jpg'] } const widget = createMockWidget(value) const wrapper = mountComponent(widget, true) @@ -160,8 +163,11 @@ describe('WidgetImageCompare Display', () => { expect(wrapper.text()).toContain('imageCompare.noImages') }) - it('shows no images message when both URLs are empty', () => { - const value: ImageCompareValue = { before: '', after: '' } + it('shows no images message when both arrays are empty', () => { + const value: ImageCompareValue = { + beforeImages: [], + afterImages: [] + } const widget = createMockWidget(value) const wrapper = mountComponent(widget) @@ -181,42 +187,40 @@ describe('WidgetImageCompare Display', () => { }) it('handles special content - long URLs, special characters, and long alt text', () => { - // Test very long URLs const longUrl = 'https://example.com/' + 'a'.repeat(1000) + '.jpg' - const longUrlValue: ImageCompareValue = { - before: longUrl, - after: longUrl - } - const longUrlWrapper = mountComponent(createMockWidget(longUrlValue)) + const longUrlWrapper = mountComponent( + createMockWidget({ + beforeImages: [longUrl], + afterImages: [longUrl] + }) + ) const longUrlImages = longUrlWrapper.findAll('img') expect(longUrlImages[0].attributes('src')).toBe(longUrl) expect(longUrlImages[1].attributes('src')).toBe(longUrl) - // Test special characters in URLs const specialUrl = 'https://example.com/path with spaces & symbols!@#$.jpg' - const specialUrlValue: ImageCompareValue = { - before: specialUrl, - after: specialUrl - } const specialUrlWrapper = mountComponent( - createMockWidget(specialUrlValue) + createMockWidget({ + beforeImages: [specialUrl], + afterImages: [specialUrl] + }) ) const specialUrlImages = specialUrlWrapper.findAll('img') expect(specialUrlImages[0].attributes('src')).toBe(specialUrl) expect(specialUrlImages[1].attributes('src')).toBe(specialUrl) - // Test very long alt text const longAlt = 'Very long alt text that exceeds normal length: ' + 'description '.repeat(50) - const longAltValue: ImageCompareValue = { - before: 'https://example.com/before.jpg', - after: 'https://example.com/after.jpg', - beforeAlt: longAlt, - afterAlt: longAlt - } - const longAltWrapper = mountComponent(createMockWidget(longAltValue)) + const longAltWrapper = mountComponent( + createMockWidget({ + beforeImages: ['https://example.com/before.jpg'], + afterImages: ['https://example.com/after.jpg'], + beforeAlt: longAlt, + afterAlt: longAlt + }) + ) const longAltImages = longAltWrapper.findAll('img') expect(longAltImages[0].attributes('alt')).toBe(longAlt) expect(longAltImages[1].attributes('alt')).toBe(longAlt) @@ -226,16 +230,14 @@ describe('WidgetImageCompare Display', () => { describe('Template Structure', () => { 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' + beforeImages: ['https://example.com/before.jpg'], + afterImages: ['https://example.com/after.jpg'] } const widget = createMockWidget(value) const wrapper = mountComponent(widget) const images = wrapper.findAll('img') - // 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') }) @@ -243,26 +245,26 @@ describe('WidgetImageCompare Display', () => { describe('Integration', () => { it('works with various URL types - data URLs and blob URLs', () => { - // Test data URLs const dataUrl = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==' - const dataUrlValue: ImageCompareValue = { - before: dataUrl, - after: dataUrl - } - const dataUrlWrapper = mountComponent(createMockWidget(dataUrlValue)) + const dataUrlWrapper = mountComponent( + createMockWidget({ + beforeImages: [dataUrl], + afterImages: [dataUrl] + }) + ) const dataUrlImages = dataUrlWrapper.findAll('img') expect(dataUrlImages[0].attributes('src')).toBe(dataUrl) expect(dataUrlImages[1].attributes('src')).toBe(dataUrl) - // Test blob URLs const blobUrl = 'blob:http://example.com/12345678-1234-1234-1234-123456789012' - const blobUrlValue: ImageCompareValue = { - before: blobUrl, - after: blobUrl - } - const blobUrlWrapper = mountComponent(createMockWidget(blobUrlValue)) + const blobUrlWrapper = mountComponent( + createMockWidget({ + beforeImages: [blobUrl], + afterImages: [blobUrl] + }) + ) const blobUrlImages = blobUrlWrapper.findAll('img') expect(blobUrlImages[0].attributes('src')).toBe(blobUrl) expect(blobUrlImages[1].attributes('src')).toBe(blobUrl) @@ -272,8 +274,8 @@ describe('WidgetImageCompare Display', () => { 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' + beforeImages: ['https://example.com/before.jpg'], + afterImages: ['https://example.com/after.jpg'] } const widget = createMockWidget(value) const wrapper = mountComponent(widget) @@ -291,4 +293,140 @@ describe('WidgetImageCompare Display', () => { expect(slider.exists()).toBe(false) }) }) + + describe('Batch Navigation', () => { + const beforeImages = [ + 'https://example.com/a1.jpg', + 'https://example.com/a2.jpg', + 'https://example.com/a3.jpg' + ] + const afterImages = [ + 'https://example.com/b1.jpg', + 'https://example.com/b2.jpg' + ] + + it('shows batch nav when either side has multiple images', () => { + const value: ImageCompareValue = { beforeImages, afterImages } + const wrapper = mountComponent(createMockWidget(value)) + + expect(wrapper.find('[data-testid="batch-nav"]').exists()).toBe(true) + + const beforeBatch = wrapper.find('[data-testid="before-batch"]') + const afterBatch = wrapper.find('[data-testid="after-batch"]') + expect(beforeBatch.find('[data-testid="batch-counter"]').text()).toBe( + '1 / 3' + ) + expect(afterBatch.find('[data-testid="batch-counter"]').text()).toBe( + '1 / 2' + ) + }) + + it('hides batch nav for single images', () => { + const value: ImageCompareValue = { + beforeImages: ['https://example.com/a1.jpg'], + afterImages: ['https://example.com/b1.jpg'] + } + const wrapper = mountComponent(createMockWidget(value)) + + expect(wrapper.find('[data-testid="batch-nav"]').exists()).toBe(false) + }) + + it('hides batch nav when no batch arrays are provided', () => { + const wrapper = mountComponent(createMockWidget({} as ImageCompareValue)) + + expect(wrapper.find('[data-testid="batch-nav"]').exists()).toBe(false) + }) + + it('navigates before images with prev/next buttons', async () => { + const value: ImageCompareValue = { beforeImages, afterImages } + const wrapper = mountComponent(createMockWidget(value)) + const beforeBatch = wrapper.find('[data-testid="before-batch"]') + + // Initially shows first before image + expect(wrapper.findAll('img')[1].attributes('src')).toBe( + 'https://example.com/a1.jpg' + ) + + // Click next on before + await beforeBatch.find('[data-testid="batch-next"]').trigger('click') + expect(wrapper.findAll('img')[1].attributes('src')).toBe( + 'https://example.com/a2.jpg' + ) + expect(beforeBatch.find('[data-testid="batch-counter"]').text()).toBe( + '2 / 3' + ) + + // Click next again + await beforeBatch.find('[data-testid="batch-next"]').trigger('click') + expect(wrapper.findAll('img')[1].attributes('src')).toBe( + 'https://example.com/a3.jpg' + ) + expect(beforeBatch.find('[data-testid="batch-counter"]').text()).toBe( + '3 / 3' + ) + + // Next button should be disabled at last index + expect( + beforeBatch.find('[data-testid="batch-next"]').attributes('disabled') + ).toBeDefined() + + // Click prev + await beforeBatch.find('[data-testid="batch-prev"]').trigger('click') + expect(wrapper.findAll('img')[1].attributes('src')).toBe( + 'https://example.com/a2.jpg' + ) + }) + + it('navigates after images independently from before images', async () => { + const value: ImageCompareValue = { beforeImages, afterImages } + const wrapper = mountComponent(createMockWidget(value)) + const afterBatch = wrapper.find('[data-testid="after-batch"]') + + // Navigate after to index 1 + await afterBatch.find('[data-testid="batch-next"]').trigger('click') + expect(afterBatch.find('[data-testid="batch-counter"]').text()).toBe( + '2 / 2' + ) + + // After image should be b2, before image should still be a1 + const images = wrapper.findAll('img') + expect(images[0].attributes('src')).toBe('https://example.com/b2.jpg') + expect(images[1].attributes('src')).toBe('https://example.com/a1.jpg') + }) + + it('disables prev button at first index', () => { + const value: ImageCompareValue = { beforeImages, afterImages } + const wrapper = mountComponent(createMockWidget(value)) + + expect( + wrapper + .find('[data-testid="before-batch"]') + .find('[data-testid="batch-prev"]') + .attributes('disabled') + ).toBeDefined() + expect( + wrapper + .find('[data-testid="after-batch"]') + .find('[data-testid="batch-prev"]') + .attributes('disabled') + ).toBeDefined() + }) + + it('only shows controls for the side with multiple images', () => { + const value: ImageCompareValue = { + beforeImages, + afterImages: ['https://example.com/b1.jpg'] + } + const wrapper = mountComponent(createMockWidget(value)) + + expect(wrapper.find('[data-testid="batch-nav"]').exists()).toBe(true) + expect( + wrapper + .find('[data-testid="before-batch"]') + .find('[data-testid="batch-counter"]') + .exists() + ).toBe(true) + expect(wrapper.find('[data-testid="after-batch"]').exists()).toBe(false) + }) + }) }) diff --git a/src/renderer/extensions/vueNodes/widgets/components/WidgetImageCompare.vue b/src/renderer/extensions/vueNodes/widgets/components/WidgetImageCompare.vue index 65271a2151..536d2db067 100644 --- a/src/renderer/extensions/vueNodes/widgets/components/WidgetImageCompare.vue +++ b/src/renderer/extensions/vueNodes/widgets/components/WidgetImageCompare.vue @@ -1,6 +1,33 @@