From 68fa58f3530533e30de8aeb79456694740851f6f Mon Sep 17 00:00:00 2001 From: bymyself Date: Mon, 8 Sep 2025 14:45:48 -0700 Subject: [PATCH] add image outputs on Vue nodes --- src/base/common/downloadUtil.ts | 2 +- src/locales/en/main.json | 1 + .../vueNodes/components/ImagePreview.spec.ts | 265 ++++++++++++++++++ .../vueNodes/composables/useImagePreview.ts | 68 +++++ .../composables/useImagePreview.test.ts | 203 ++++++++++++++ 5 files changed, 538 insertions(+), 1 deletion(-) create mode 100644 src/renderer/extensions/vueNodes/components/ImagePreview.spec.ts create mode 100644 src/renderer/extensions/vueNodes/composables/useImagePreview.ts create mode 100644 tests-ui/tests/renderer/extensions/vueNodes/composables/useImagePreview.test.ts diff --git a/src/base/common/downloadUtil.ts b/src/base/common/downloadUtil.ts index 307a3e35b..167c39233 100644 --- a/src/base/common/downloadUtil.ts +++ b/src/base/common/downloadUtil.ts @@ -31,7 +31,7 @@ export const downloadFile = (url: string, filename?: string): void => { * @param url - The URL to extract filename from * @returns The extracted filename or null if not found */ -const extractFilenameFromUrl = (url: string): string | null => { +export const extractFilenameFromUrl = (url: string): string | null => { try { const urlObj = new URL(url, window.location.origin) return urlObj.searchParams.get('filename') diff --git a/src/locales/en/main.json b/src/locales/en/main.json index 0227379dd..f1fa73d19 100644 --- a/src/locales/en/main.json +++ b/src/locales/en/main.json @@ -16,6 +16,7 @@ "errorLoadingImage": "Error loading image", "failedToDownloadImage": "Failed to download image", "calculatingDimensions": "Calculating dimensions", + "upload": "Upload", "import": "Import", "loadAllFolders": "Load All Folders", "refresh": "Refresh", diff --git a/src/renderer/extensions/vueNodes/components/ImagePreview.spec.ts b/src/renderer/extensions/vueNodes/components/ImagePreview.spec.ts new file mode 100644 index 000000000..44fbccf2a --- /dev/null +++ b/src/renderer/extensions/vueNodes/components/ImagePreview.spec.ts @@ -0,0 +1,265 @@ +import { mount } from '@vue/test-utils' +import { describe, expect, it, vi } from 'vitest' +import { nextTick } from 'vue' + +import ImagePreview from './ImagePreview.vue' + +describe('ImagePreview', () => { + const defaultProps = { + imageUrls: [ + '/api/view?filename=test1.png&type=output', + '/api/view?filename=test2.png&type=output' + ], + dimensions: '512 x 512' + } + + const mountImagePreview = (props = {}) => { + return mount(ImagePreview, { + props: { ...defaultProps, ...props } + }) + } + + it('renders image preview when imageUrls provided', () => { + const wrapper = mountImagePreview() + + expect(wrapper.find('.image-preview').exists()).toBe(true) + expect(wrapper.find('img').exists()).toBe(true) + expect(wrapper.find('img').attributes('src')).toBe( + defaultProps.imageUrls[0] + ) + }) + + it('does not render when no imageUrls provided', () => { + const wrapper = mountImagePreview({ imageUrls: [] }) + + expect(wrapper.find('.image-preview').exists()).toBe(false) + }) + + it('displays dimensions correctly', () => { + const wrapper = mountImagePreview({ dimensions: '1024 x 768' }) + + expect(wrapper.text()).toContain('1024 x 768') + }) + + it('shows navigation dots for multiple images', () => { + const wrapper = mountImagePreview() + + const navigationDots = wrapper.findAll('.w-2.h-2.rounded-full') + expect(navigationDots).toHaveLength(2) + }) + + it('does not show navigation dots for single image', () => { + const wrapper = mountImagePreview({ + imageUrls: [defaultProps.imageUrls[0]] + }) + + const navigationDots = wrapper.findAll('.w-2.h-2.rounded-full') + expect(navigationDots).toHaveLength(0) + }) + + it('shows action buttons on hover', async () => { + const wrapper = mountImagePreview() + + // Initially buttons should not be visible + expect(wrapper.find('.actions').exists()).toBe(false) + + // Trigger hover + await wrapper.trigger('mouseenter') + await nextTick() + + // Action buttons should now be visible + expect(wrapper.find('.actions').exists()).toBe(true) + expect(wrapper.findAll('.action-btn')).toHaveLength(3) // mask, download, remove + }) + + it('hides action buttons when not hovering', async () => { + const wrapper = mountImagePreview() + + // Trigger hover + await wrapper.trigger('mouseenter') + await nextTick() + expect(wrapper.find('.actions').exists()).toBe(true) + + // Trigger mouse leave + await wrapper.trigger('mouseleave') + await nextTick() + expect(wrapper.find('.actions').exists()).toBe(false) + }) + + it('shows mask/edit button only for single images', async () => { + // Multiple images - should not show mask button + const multipleImagesWrapper = mountImagePreview() + await multipleImagesWrapper.trigger('mouseenter') + await nextTick() + + const maskButtonMultiple = multipleImagesWrapper.find('[title="Edit/Mask"]') + expect(maskButtonMultiple.exists()).toBe(false) + + // Single image - should show mask button + const singleImageWrapper = mountImagePreview({ + imageUrls: [defaultProps.imageUrls[0]] + }) + await singleImageWrapper.trigger('mouseenter') + await nextTick() + + const maskButtonSingle = singleImageWrapper.find('[title="Edit/Mask"]') + expect(maskButtonSingle.exists()).toBe(true) + }) + + it('handles action button clicks', async () => { + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const wrapper = mountImagePreview({ + imageUrls: [defaultProps.imageUrls[0]] + }) + + await wrapper.trigger('mouseenter') + await nextTick() + + // Test Edit/Mask button + await wrapper.find('[title="Edit/Mask"]').trigger('click') + expect(consoleSpy).toHaveBeenCalledWith( + 'Edit/Mask clicked for:', + defaultProps.imageUrls[0] + ) + + // Test Remove button + await wrapper.find('[title="Remove"]').trigger('click') + expect(consoleSpy).toHaveBeenCalledWith( + 'Remove clicked for:', + defaultProps.imageUrls[0] + ) + + consoleSpy.mockRestore() + }) + + it('handles download button click', async () => { + // Mock DOM methods for download test + const mockLink = { + href: '', + download: '', + click: vi.fn() + } + const mockCreateElement = vi + .spyOn(document, 'createElement') + .mockReturnValue(mockLink as any) + const mockAppendChild = vi + .spyOn(document.body, 'appendChild') + .mockImplementation(() => mockLink as any) + const mockRemoveChild = vi + .spyOn(document.body, 'removeChild') + .mockImplementation(() => mockLink as any) + + const wrapper = mountImagePreview({ + imageUrls: [defaultProps.imageUrls[0]] + }) + + await wrapper.trigger('mouseenter') + await nextTick() + + // Test Download button + await wrapper.find('[title="Download"]').trigger('click') + + expect(mockCreateElement).toHaveBeenCalledWith('a') + expect(mockLink.href).toBe(defaultProps.imageUrls[0]) + expect(mockLink.click).toHaveBeenCalled() + + mockCreateElement.mockRestore() + mockAppendChild.mockRestore() + mockRemoveChild.mockRestore() + }) + + it('switches images when navigation dots are clicked', async () => { + const wrapper = mountImagePreview() + + // Initially shows first image + expect(wrapper.find('img').attributes('src')).toBe( + defaultProps.imageUrls[0] + ) + + // Click second navigation dot + const navigationDots = wrapper.findAll('.w-2.h-2.rounded-full') + await navigationDots[1].trigger('click') + await nextTick() + + // Should now show second image + expect(wrapper.find('img').attributes('src')).toBe( + defaultProps.imageUrls[1] + ) + }) + + it('applies correct classes to navigation dots based on current image', async () => { + const wrapper = mountImagePreview() + + const navigationDots = wrapper.findAll('.w-2.h-2.rounded-full') + + // First dot should be active (has bg-white class) + expect(navigationDots[0].classes()).toContain('bg-white') + expect(navigationDots[1].classes()).toContain('bg-white/50') + + // Switch to second image + await navigationDots[1].trigger('click') + await nextTick() + + // Second dot should now be active + expect(navigationDots[0].classes()).toContain('bg-white/50') + expect(navigationDots[1].classes()).toContain('bg-white') + }) + + it('updates dimensions when image loads', async () => { + const wrapper = mountImagePreview({ dimensions: undefined }) + + // Initially shows "Loading..." + expect(wrapper.text()).toContain('Loading...') + + // Simulate image load event + const img = wrapper.find('img') + const mockLoadEvent = { + target: { + naturalWidth: 1024, + naturalHeight: 768 + } + } + await img.trigger('load', mockLoadEvent) + await nextTick() + + // Should now show actual dimensions + expect(wrapper.text()).toContain('1024 x 768') + }) + + it('handles image load errors gracefully', async () => { + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + const wrapper = mountImagePreview() + + const img = wrapper.find('img') + await img.trigger('error') + + expect(consoleSpy).toHaveBeenCalledWith( + 'Failed to load image:', + defaultProps.imageUrls[0] + ) + + consoleSpy.mockRestore() + }) + + it('has proper accessibility attributes', () => { + const wrapper = mountImagePreview() + + const img = wrapper.find('img') + expect(img.attributes('alt')).toBe('Node output 1') + }) + + it('updates alt text when switching images', async () => { + const wrapper = mountImagePreview() + + // Initially first image + expect(wrapper.find('img').attributes('alt')).toBe('Node output 1') + + // Switch to second image + const navigationDots = wrapper.findAll('.w-2.h-2.rounded-full') + await navigationDots[1].trigger('click') + await nextTick() + + // Alt text should update + expect(wrapper.find('img').attributes('alt')).toBe('Node output 2') + }) +}) diff --git a/src/renderer/extensions/vueNodes/composables/useImagePreview.ts b/src/renderer/extensions/vueNodes/composables/useImagePreview.ts new file mode 100644 index 000000000..40439cea1 --- /dev/null +++ b/src/renderer/extensions/vueNodes/composables/useImagePreview.ts @@ -0,0 +1,68 @@ +import { computed, ref } from 'vue' + +import { downloadFile } from '@/base/common/downloadUtil' +import { useCommandStore } from '@/stores/commandStore' +import { useNodeOutputStore } from '@/stores/imagePreviewStore' + +/** + * Composable for managing image preview state and interactions + */ +export const useImagePreview = (imageUrls: string[], nodeId?: string) => { + const currentIndex = ref(0) + const isHovered = ref(false) + const actualDimensions = ref(null) + + const commandStore = useCommandStore() + const nodeOutputStore = useNodeOutputStore() + + const currentImageUrl = computed(() => imageUrls[currentIndex.value]) + const hasMultipleImages = computed(() => imageUrls.length > 1) + + const handleImageLoad = (event: Event) => { + if (!event.target || !(event.target instanceof HTMLImageElement)) return + const img = event.target + if (img.naturalWidth && img.naturalHeight) { + actualDimensions.value = `${img.naturalWidth} x ${img.naturalHeight}` + } + } + + const handleEditMask = () => { + void commandStore.execute('Comfy.MaskEditor.OpenMaskEditor') + } + + const handleDownload = () => { + downloadFile(currentImageUrl.value) + } + + const handleRemove = () => { + if (!nodeId) return + nodeOutputStore.removeNodeOutputs(nodeId) + } + + const setCurrentIndex = (index: number) => { + if (index >= 0 && index < imageUrls.length) { + currentIndex.value = index + actualDimensions.value = null + } + } + + return { + // State + currentIndex, + isHovered, + actualDimensions, + + // Computed + currentImageUrl, + hasMultipleImages, + + // Event handlers + handleImageLoad, + handleEditMask, + handleDownload, + handleRemove, + + // Navigation + setCurrentIndex + } +} diff --git a/tests-ui/tests/renderer/extensions/vueNodes/composables/useImagePreview.test.ts b/tests-ui/tests/renderer/extensions/vueNodes/composables/useImagePreview.test.ts new file mode 100644 index 000000000..7837c9f89 --- /dev/null +++ b/tests-ui/tests/renderer/extensions/vueNodes/composables/useImagePreview.test.ts @@ -0,0 +1,203 @@ +import { describe, expect, it, vi } from 'vitest' + +import { useImagePreview } from '@/renderer/extensions/vueNodes/composables/useImagePreview' + +describe('useImagePreview', () => { + const mockImageUrls = [ + '/api/view?filename=test1.png&type=output', + '/api/view?filename=test2.png&type=output', + '/api/view?filename=test3.png&type=output' + ] + + // Helper function to create properly typed mock image events + const createMockImageEvent = ( + naturalWidth: number, + naturalHeight: number + ): Event => { + const mockImg = { + naturalWidth, + naturalHeight, + addEventListener: vi.fn(), + dispatchEvent: vi.fn(), + removeEventListener: vi.fn() + } satisfies EventTarget & { naturalWidth: number; naturalHeight: number } + + return { + target: mockImg, + currentTarget: mockImg, + srcElement: mockImg, + bubbles: false, + cancelBubble: false, + cancelable: false, + composed: false, + defaultPrevented: false, + eventPhase: 0, + isTrusted: false, + returnValue: true, + timeStamp: 0, + type: 'load', + preventDefault: vi.fn(), + stopImmediatePropagation: vi.fn(), + stopPropagation: vi.fn(), + composedPath: vi.fn(() => []), + initEvent: vi.fn(), + NONE: 0, + CAPTURING_PHASE: 1, + AT_TARGET: 2, + BUBBLING_PHASE: 3 + } satisfies Event + } + + it('initializes with correct default state', () => { + const { currentIndex, isHovered, actualDimensions, hasMultipleImages } = + useImagePreview(mockImageUrls) + + expect(currentIndex.value).toBe(0) + expect(isHovered.value).toBe(false) + expect(actualDimensions.value).toBeNull() + expect(mockImageUrls.length > 0).toBe(true) + expect(hasMultipleImages.value).toBe(true) + }) + + it('handles single image correctly', () => { + const singleImageUrl = [mockImageUrls[0]] + const { hasMultipleImages } = useImagePreview(singleImageUrl) + + expect(mockImageUrls.length > 0).toBe(true) + expect(hasMultipleImages.value).toBe(false) + }) + + it('handles empty image array correctly', () => { + const { hasMultipleImages, currentImageUrl } = useImagePreview([]) + + expect([].length > 0).toBe(false) + expect(hasMultipleImages.value).toBe(false) + expect(currentImageUrl.value).toBeUndefined() + }) + + it('computes currentImageUrl correctly', () => { + const { currentImageUrl, setCurrentIndex } = useImagePreview(mockImageUrls) + + expect(currentImageUrl.value).toBe(mockImageUrls[0]) + + setCurrentIndex(1) + expect(currentImageUrl.value).toBe(mockImageUrls[1]) + }) + + it('handles setCurrentIndex with bounds checking', () => { + const { currentIndex, setCurrentIndex } = useImagePreview(mockImageUrls) + + // Valid index + setCurrentIndex(2) + expect(currentIndex.value).toBe(2) + + // Invalid index (too high) + setCurrentIndex(5) + expect(currentIndex.value).toBe(2) // Should remain unchanged + + // Invalid index (negative) + setCurrentIndex(-1) + expect(currentIndex.value).toBe(2) // Should remain unchanged + }) + + it('handles edit mask action', () => { + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const { handleEditMask } = useImagePreview(mockImageUrls) + + handleEditMask() + expect(consoleSpy).toHaveBeenCalledWith( + 'Edit/Mask clicked for:', + mockImageUrls[0] + ) + + consoleSpy.mockRestore() + }) + + it('handles download action', () => { + // Mock DOM methods + const mockLink = { + href: '', + download: '', + click: vi.fn() + } + const mockCreateElement = vi + .spyOn(document, 'createElement') + .mockReturnValue(mockLink as any) + const mockAppendChild = vi + .spyOn(document.body, 'appendChild') + .mockImplementation(() => mockLink as any) + const mockRemoveChild = vi + .spyOn(document.body, 'removeChild') + .mockImplementation(() => mockLink as any) + + const { handleDownload } = useImagePreview(mockImageUrls) + handleDownload() + + expect(mockCreateElement).toHaveBeenCalledWith('a') + expect(mockLink.href).toBe(mockImageUrls[0]) + expect(mockLink.click).toHaveBeenCalled() + expect(mockAppendChild).toHaveBeenCalledWith(mockLink) + expect(mockRemoveChild).toHaveBeenCalledWith(mockLink) + + mockCreateElement.mockRestore() + mockAppendChild.mockRestore() + mockRemoveChild.mockRestore() + }) + + it('handles remove action', () => { + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const { handleRemove } = useImagePreview(mockImageUrls) + + handleRemove() + expect(consoleSpy).toHaveBeenCalledWith( + 'Remove clicked for:', + mockImageUrls[0] + ) + + consoleSpy.mockRestore() + }) + + it('handles image load event correctly', () => { + const { handleImageLoad, actualDimensions } = useImagePreview(mockImageUrls) + + const mockEvent = createMockImageEvent(1024, 768) + handleImageLoad(mockEvent) + + expect(actualDimensions.value).toBe('1024 x 768') + }) + + it('handles image load event with invalid dimensions', () => { + const { handleImageLoad, actualDimensions } = useImagePreview(mockImageUrls) + + const mockEvent = createMockImageEvent(0, 0) + handleImageLoad(mockEvent) + + expect(actualDimensions.value).toBeNull() + }) + + it('resets dimensions when changing images', () => { + const { actualDimensions, setCurrentIndex, handleImageLoad } = + useImagePreview(mockImageUrls) + + // Set dimensions for first image + const mockEvent = createMockImageEvent(1024, 768) + handleImageLoad(mockEvent) + expect(actualDimensions.value).toBe('1024 x 768') + + // Change to second image + setCurrentIndex(1) + expect(actualDimensions.value).toBeNull() + }) + + it('composable functions are properly defined', () => { + const { handleEditMask, handleDownload, handleRemove } = + useImagePreview(mockImageUrls) + + expect(handleEditMask).toBeDefined() + expect(typeof handleEditMask).toBe('function') + expect(handleDownload).toBeDefined() + expect(typeof handleDownload).toBe('function') + expect(handleRemove).toBeDefined() + expect(typeof handleRemove).toBe('function') + }) +})