mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-02-07 08:30:06 +00:00
add image outputs on Vue nodes
This commit is contained in:
@@ -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')
|
||||
|
||||
@@ -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",
|
||||
|
||||
265
src/renderer/extensions/vueNodes/components/ImagePreview.spec.ts
Normal file
265
src/renderer/extensions/vueNodes/components/ImagePreview.spec.ts
Normal file
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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<string | null>(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
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user