mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-20 06:20:11 +00:00
feat: add grid view mode for multi-image batches in ImagePreview (#9241)
## Summary Add grid view mode for multi-image batches in ImagePreview (Nodes 2.0), replicating the Nodes 1.0 grid UX where all output images are visible as clickable thumbnails. ## Changes - **What**: Multi-image batches now default to a grid view showing all thumbnails. Clicking a thumbnail switches to gallery mode for that image. A persistent back-to-grid button sits next to navigation dots, and hover action bars provide gallery toggle, download, and remove. Replaced PrimeVue `Skeleton` with shadcn `Skeleton`. Added `viewGrid`, `viewGallery`, `imageCount`, `galleryThumbnail` i18n keys. ## Review Focus - Grid column count strategy: fixed breakpoints (2 cols ≤4, 3 cols ≤9, 4 cols 10+) vs CSS auto-fill - Default view mode: grid for multi-image, gallery for single — matches Nodes 1.0 behavior - `object-contain` on thumbnails to avoid cropping (with `aspect-square` containers for uniform cells) Fixes #9162 <!-- Pipeline-Ticket: f8f8effa-adff-4ede-b1d3-3c4f04b9c4a0 --> ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-9241-feat-add-grid-view-mode-for-multi-image-batches-in-ImagePreview-3136d73d36508166895ed6c635150434) by [Unito](https://www.unito.io) --------- Co-authored-by: Alexander Brown <drjkl@comfy.org> Co-authored-by: Alexander Brown <448862+DrJKL@users.noreply.github.com> Co-authored-by: Amp <amp@ampcode.com>
This commit is contained in:
@@ -34,6 +34,8 @@
|
||||
"imageLightbox": "Image preview",
|
||||
"imagePreview": "Image preview - Use arrow keys to navigate between images",
|
||||
"videoPreview": "Video preview - Use arrow keys to navigate between videos",
|
||||
"viewGrid": "Grid view",
|
||||
"imageGallery": "image gallery",
|
||||
"galleryImage": "Gallery image",
|
||||
"galleryThumbnail": "Gallery thumbnail",
|
||||
"previousImage": "Previous image",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import type { VueWrapper } from '@vue/test-utils'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
@@ -31,7 +31,9 @@ const i18n = createI18n({
|
||||
imageFailedToLoad: 'Image failed to load',
|
||||
imageDoesNotExist: 'Image does not exist',
|
||||
unknownFile: 'Unknown file',
|
||||
loading: 'Loading'
|
||||
loading: 'Loading',
|
||||
viewGrid: 'Grid view',
|
||||
galleryThumbnail: 'Gallery thumbnail'
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -69,6 +71,17 @@ describe('ImagePreview', () => {
|
||||
return wrapper
|
||||
}
|
||||
|
||||
/** Switch a multi-image wrapper from default grid mode to gallery mode */
|
||||
async function switchToGallery(wrapper: VueWrapper) {
|
||||
const thumbnails = wrapper.findAll('button[aria-label^="View image"]')
|
||||
await thumbnails[0].trigger('click')
|
||||
await nextTick()
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
wrapperRegistry.forEach((wrapper) => {
|
||||
wrapper.unmount()
|
||||
@@ -76,30 +89,23 @@ describe('ImagePreview', () => {
|
||||
wrapperRegistry.clear()
|
||||
})
|
||||
|
||||
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 calculating dimensions text initially', () => {
|
||||
const wrapper = mountImagePreview()
|
||||
it('displays calculating dimensions text in gallery mode', async () => {
|
||||
const wrapper = mountImagePreview({
|
||||
imageUrls: [defaultProps.imageUrls[0]]
|
||||
})
|
||||
|
||||
expect(wrapper.text()).toContain('Calculating dimensions')
|
||||
})
|
||||
|
||||
it('shows navigation dots for multiple images', () => {
|
||||
it('shows navigation dots for multiple images in gallery mode', async () => {
|
||||
const wrapper = mountImagePreview()
|
||||
await switchToGallery(wrapper)
|
||||
|
||||
const navigationDots = wrapper.findAll('[aria-label*="View image"]')
|
||||
expect(navigationDots).toHaveLength(2)
|
||||
@@ -114,113 +120,23 @@ describe('ImagePreview', () => {
|
||||
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 on the image wrapper (the element with role="img" has the hover handlers)
|
||||
const imageWrapper = wrapper.find('[role="img"]')
|
||||
await imageWrapper.trigger('mouseenter')
|
||||
await nextTick()
|
||||
|
||||
// Action buttons should now be visible
|
||||
expect(wrapper.find('.actions').exists()).toBe(true)
|
||||
// For multiple images: download and remove buttons (no mask button)
|
||||
expect(wrapper.find('[aria-label="Download image"]').exists()).toBe(true)
|
||||
expect(wrapper.find('[aria-label="Remove image"]').exists()).toBe(true)
|
||||
expect(wrapper.find('[aria-label="Edit or mask image"]').exists()).toBe(
|
||||
false
|
||||
)
|
||||
})
|
||||
|
||||
it('hides action buttons when not hovering', async () => {
|
||||
const wrapper = mountImagePreview()
|
||||
const imageWrapper = wrapper.find('[role="img"]')
|
||||
|
||||
// Trigger hover
|
||||
await imageWrapper.trigger('mouseenter')
|
||||
await nextTick()
|
||||
expect(wrapper.find('.actions').exists()).toBe(true)
|
||||
|
||||
// Trigger mouse leave
|
||||
await imageWrapper.trigger('mouseleave')
|
||||
await nextTick()
|
||||
expect(wrapper.find('.actions').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('shows action buttons on focus', async () => {
|
||||
const wrapper = mountImagePreview()
|
||||
|
||||
// Initially buttons should not be visible
|
||||
expect(wrapper.find('.actions').exists()).toBe(false)
|
||||
|
||||
// Trigger focusin on the image wrapper (useFocusWithin listens to focusin/focusout)
|
||||
const imageWrapper = wrapper.find('[role="img"]')
|
||||
await imageWrapper.trigger('focusin')
|
||||
await nextTick()
|
||||
|
||||
// Action buttons should now be visible
|
||||
expect(wrapper.find('.actions').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('hides action buttons on blur', async () => {
|
||||
const wrapper = mountImagePreview()
|
||||
const imageWrapper = wrapper.find('[role="img"]')
|
||||
|
||||
// Trigger focus
|
||||
await imageWrapper.trigger('focusin')
|
||||
await nextTick()
|
||||
expect(wrapper.find('.actions').exists()).toBe(true)
|
||||
|
||||
// Trigger focusout
|
||||
await imageWrapper.trigger('focusout')
|
||||
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
|
||||
// Multiple images in gallery mode - should not show mask button
|
||||
const multipleImagesWrapper = mountImagePreview()
|
||||
await multipleImagesWrapper.find('[role="img"]').trigger('mouseenter')
|
||||
await nextTick()
|
||||
await switchToGallery(multipleImagesWrapper)
|
||||
|
||||
const maskButtonMultiple = multipleImagesWrapper.find(
|
||||
'[aria-label="Edit or mask image"]'
|
||||
)
|
||||
expect(maskButtonMultiple.exists()).toBe(false)
|
||||
expect(
|
||||
multipleImagesWrapper.find('[aria-label="Edit or mask image"]').exists()
|
||||
).toBe(false)
|
||||
|
||||
// Single image - should show mask button
|
||||
const singleImageWrapper = mountImagePreview({
|
||||
imageUrls: [defaultProps.imageUrls[0]]
|
||||
})
|
||||
await singleImageWrapper.find('[role="img"]').trigger('mouseenter')
|
||||
await nextTick()
|
||||
|
||||
const maskButtonSingle = singleImageWrapper.find(
|
||||
'[aria-label="Edit or mask image"]'
|
||||
)
|
||||
expect(maskButtonSingle.exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('handles action button clicks', async () => {
|
||||
const wrapper = mountImagePreview({
|
||||
imageUrls: [defaultProps.imageUrls[0]]
|
||||
})
|
||||
|
||||
await wrapper.find('[role="img"]').trigger('mouseenter')
|
||||
await nextTick()
|
||||
|
||||
// Test Edit/Mask button - just verify it can be clicked without errors
|
||||
const editButton = wrapper.find('[aria-label="Edit or mask image"]')
|
||||
expect(editButton.exists()).toBe(true)
|
||||
await editButton.trigger('click')
|
||||
|
||||
// Test Remove button - just verify it can be clicked without errors
|
||||
const removeButton = wrapper.find('[aria-label="Remove image"]')
|
||||
expect(removeButton.exists()).toBe(true)
|
||||
await removeButton.trigger('click')
|
||||
expect(
|
||||
singleImageWrapper.find('[aria-label="Edit or mask image"]').exists()
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('handles download button click', async () => {
|
||||
@@ -228,20 +144,16 @@ describe('ImagePreview', () => {
|
||||
imageUrls: [defaultProps.imageUrls[0]]
|
||||
})
|
||||
|
||||
await wrapper.find('[role="img"]').trigger('mouseenter')
|
||||
await nextTick()
|
||||
|
||||
// Test Download button
|
||||
const downloadButton = wrapper.find('[aria-label="Download image"]')
|
||||
expect(downloadButton.exists()).toBe(true)
|
||||
await downloadButton.trigger('click')
|
||||
|
||||
// Verify the mocked downloadFile was called
|
||||
expect(downloadFile).toHaveBeenCalledWith(defaultProps.imageUrls[0])
|
||||
})
|
||||
|
||||
it('switches images when navigation dots are clicked', async () => {
|
||||
const wrapper = mountImagePreview()
|
||||
await switchToGallery(wrapper)
|
||||
|
||||
// Initially shows first image
|
||||
expect(wrapper.find('img').attributes('src')).toBe(
|
||||
@@ -253,14 +165,14 @@ describe('ImagePreview', () => {
|
||||
await navigationDots[1].trigger('click')
|
||||
await nextTick()
|
||||
|
||||
// Now should show second image
|
||||
const imgElement = wrapper.find('img')
|
||||
expect(imgElement.exists()).toBe(true)
|
||||
expect(imgElement.attributes('src')).toBe(defaultProps.imageUrls[1])
|
||||
expect(wrapper.find('img').attributes('src')).toBe(
|
||||
defaultProps.imageUrls[1]
|
||||
)
|
||||
})
|
||||
|
||||
it('marks active navigation dot with aria-current', async () => {
|
||||
const wrapper = mountImagePreview()
|
||||
await switchToGallery(wrapper)
|
||||
|
||||
const navigationDots = wrapper.findAll('[aria-label*="View image"]')
|
||||
|
||||
@@ -268,7 +180,6 @@ describe('ImagePreview', () => {
|
||||
expect(navigationDots[0].attributes('aria-current')).toBe('true')
|
||||
expect(navigationDots[1].attributes('aria-current')).toBeUndefined()
|
||||
|
||||
// Switch to second image
|
||||
await navigationDots[1].trigger('click')
|
||||
await nextTick()
|
||||
|
||||
@@ -277,38 +188,224 @@ describe('ImagePreview', () => {
|
||||
expect(navigationDots[1].attributes('aria-current')).toBe('true')
|
||||
})
|
||||
|
||||
it('loads image without errors', async () => {
|
||||
const wrapper = mountImagePreview()
|
||||
|
||||
const img = wrapper.find('img')
|
||||
expect(img.exists()).toBe(true)
|
||||
|
||||
// Just verify the image element is properly set up
|
||||
expect(img.attributes('src')).toBe(defaultProps.imageUrls[0])
|
||||
})
|
||||
|
||||
it('has proper accessibility attributes', () => {
|
||||
const wrapper = mountImagePreview()
|
||||
const wrapper = mountImagePreview({
|
||||
imageUrls: [defaultProps.imageUrls[0]]
|
||||
})
|
||||
|
||||
const img = wrapper.find('img')
|
||||
expect(img.attributes('alt')).toBe('Node output 1')
|
||||
expect(img.attributes('alt')).toBe('View image 1 of 1')
|
||||
})
|
||||
|
||||
it('updates alt text when switching images', async () => {
|
||||
const wrapper = mountImagePreview()
|
||||
await switchToGallery(wrapper)
|
||||
|
||||
// Initially first image
|
||||
expect(wrapper.find('img').attributes('alt')).toBe('Node output 1')
|
||||
expect(wrapper.find('img').attributes('alt')).toBe('View image 1 of 2')
|
||||
|
||||
// Switch to second image
|
||||
const navigationDots = wrapper.findAll('[aria-label*="View image"]')
|
||||
await navigationDots[1].trigger('click')
|
||||
await nextTick()
|
||||
|
||||
// Alt text should update
|
||||
const imgElement = wrapper.find('img')
|
||||
expect(imgElement.exists()).toBe(true)
|
||||
expect(imgElement.attributes('alt')).toBe('Node output 2')
|
||||
expect(wrapper.find('img').attributes('alt')).toBe('View image 2 of 2')
|
||||
})
|
||||
|
||||
describe('keyboard navigation', () => {
|
||||
it('navigates to next image with ArrowRight', async () => {
|
||||
const wrapper = mountImagePreview()
|
||||
await switchToGallery(wrapper)
|
||||
|
||||
await wrapper
|
||||
.find('.image-preview')
|
||||
.trigger('keydown', { key: 'ArrowRight' })
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.find('[data-testid="main-image"]').attributes('src')).toBe(
|
||||
defaultProps.imageUrls[1]
|
||||
)
|
||||
})
|
||||
|
||||
it('navigates to previous image with ArrowLeft', async () => {
|
||||
const wrapper = mountImagePreview()
|
||||
await switchToGallery(wrapper)
|
||||
|
||||
await wrapper
|
||||
.find('.image-preview')
|
||||
.trigger('keydown', { key: 'ArrowRight' })
|
||||
await nextTick()
|
||||
|
||||
await wrapper
|
||||
.find('.image-preview')
|
||||
.trigger('keydown', { key: 'ArrowLeft' })
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.find('[data-testid="main-image"]').attributes('src')).toBe(
|
||||
defaultProps.imageUrls[0]
|
||||
)
|
||||
})
|
||||
|
||||
it('wraps around from last to first with ArrowRight', async () => {
|
||||
const wrapper = mountImagePreview()
|
||||
await switchToGallery(wrapper)
|
||||
|
||||
await wrapper
|
||||
.find('.image-preview')
|
||||
.trigger('keydown', { key: 'ArrowRight' })
|
||||
await nextTick()
|
||||
await wrapper
|
||||
.find('.image-preview')
|
||||
.trigger('keydown', { key: 'ArrowRight' })
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.find('[data-testid="main-image"]').attributes('src')).toBe(
|
||||
defaultProps.imageUrls[0]
|
||||
)
|
||||
})
|
||||
|
||||
it('wraps around from first to last with ArrowLeft', async () => {
|
||||
const wrapper = mountImagePreview()
|
||||
await switchToGallery(wrapper)
|
||||
|
||||
await wrapper
|
||||
.find('.image-preview')
|
||||
.trigger('keydown', { key: 'ArrowLeft' })
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.find('[data-testid="main-image"]').attributes('src')).toBe(
|
||||
defaultProps.imageUrls[1]
|
||||
)
|
||||
})
|
||||
|
||||
it('navigates to first image with Home', async () => {
|
||||
const wrapper = mountImagePreview()
|
||||
await switchToGallery(wrapper)
|
||||
|
||||
await wrapper
|
||||
.find('.image-preview')
|
||||
.trigger('keydown', { key: 'ArrowRight' })
|
||||
await nextTick()
|
||||
|
||||
await wrapper.find('.image-preview').trigger('keydown', { key: 'Home' })
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.find('[data-testid="main-image"]').attributes('src')).toBe(
|
||||
defaultProps.imageUrls[0]
|
||||
)
|
||||
})
|
||||
|
||||
it('navigates to last image with End', async () => {
|
||||
const wrapper = mountImagePreview()
|
||||
await switchToGallery(wrapper)
|
||||
|
||||
await wrapper.find('.image-preview').trigger('keydown', { key: 'End' })
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.find('[data-testid="main-image"]').attributes('src')).toBe(
|
||||
defaultProps.imageUrls[1]
|
||||
)
|
||||
})
|
||||
|
||||
it('ignores arrow keys in grid mode', async () => {
|
||||
const wrapper = mountImagePreview()
|
||||
|
||||
const gridThumbnails = wrapper.findAll('button[aria-label^="View image"]')
|
||||
expect(gridThumbnails).toHaveLength(2)
|
||||
|
||||
await wrapper
|
||||
.find('.image-preview')
|
||||
.trigger('keydown', { key: 'ArrowRight' })
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.find('[role="region"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('ignores arrow keys for single image', async () => {
|
||||
const wrapper = mountImagePreview({
|
||||
imageUrls: [defaultProps.imageUrls[0]]
|
||||
})
|
||||
|
||||
const initialSrc = wrapper.find('img').attributes('src')
|
||||
await wrapper
|
||||
.find('.image-preview')
|
||||
.trigger('keydown', { key: 'ArrowRight' })
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.find('img').attributes('src')).toBe(initialSrc)
|
||||
})
|
||||
})
|
||||
|
||||
describe('grid view', () => {
|
||||
it('defaults to grid mode for multiple images', () => {
|
||||
const wrapper = mountImagePreview()
|
||||
|
||||
const gridThumbnails = wrapper.findAll('button[aria-label^="View image"]')
|
||||
expect(gridThumbnails).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('defaults to gallery mode for single image', () => {
|
||||
const wrapper = mountImagePreview({
|
||||
imageUrls: [defaultProps.imageUrls[0]]
|
||||
})
|
||||
|
||||
expect(wrapper.find('[role="region"]').exists()).toBe(true)
|
||||
const gridThumbnails = wrapper.findAll('button[aria-label^="View image"]')
|
||||
expect(gridThumbnails).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('switches to gallery mode when grid thumbnail is clicked', async () => {
|
||||
const wrapper = mountImagePreview()
|
||||
|
||||
const thumbnails = wrapper.findAll('button[aria-label^="View image"]')
|
||||
await thumbnails[1].trigger('click')
|
||||
await nextTick()
|
||||
|
||||
const mainImg = wrapper.find('[data-testid="main-image"]')
|
||||
expect(mainImg.exists()).toBe(true)
|
||||
expect(mainImg.attributes('src')).toBe(defaultProps.imageUrls[1])
|
||||
})
|
||||
|
||||
it('shows back-to-grid button next to navigation dots', async () => {
|
||||
const wrapper = mountImagePreview()
|
||||
await switchToGallery(wrapper)
|
||||
|
||||
const gridButton = wrapper.find('[aria-label="Grid view"]')
|
||||
expect(gridButton.exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('switches back to grid mode via back-to-grid button', async () => {
|
||||
const wrapper = mountImagePreview()
|
||||
await switchToGallery(wrapper)
|
||||
|
||||
const gridButton = wrapper.find('[aria-label="Grid view"]')
|
||||
await gridButton.trigger('click')
|
||||
await nextTick()
|
||||
|
||||
const gridThumbnails = wrapper.findAll('button[aria-label^="View image"]')
|
||||
expect(gridThumbnails).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('resets to grid mode when URLs change to multiple images', async () => {
|
||||
const wrapper = mountImagePreview()
|
||||
await switchToGallery(wrapper)
|
||||
|
||||
// Verify we're in gallery mode
|
||||
expect(wrapper.find('[role="region"]').exists()).toBe(true)
|
||||
|
||||
// Change URLs
|
||||
await wrapper.setProps({
|
||||
imageUrls: [
|
||||
'/api/view?filename=new1.png&type=output',
|
||||
'/api/view?filename=new2.png&type=output',
|
||||
'/api/view?filename=new3.png&type=output'
|
||||
]
|
||||
})
|
||||
await nextTick()
|
||||
|
||||
// Should be back in grid mode
|
||||
const gridThumbnails = wrapper.findAll('button[aria-label^="View image"]')
|
||||
expect(gridThumbnails).toHaveLength(3)
|
||||
})
|
||||
})
|
||||
|
||||
describe('batch cycling with identical URLs', () => {
|
||||
@@ -319,6 +416,7 @@ describe('ImagePreview', () => {
|
||||
const wrapper = mountImagePreview({
|
||||
imageUrls: [sameUrl, sameUrl, sameUrl]
|
||||
})
|
||||
await switchToGallery(wrapper)
|
||||
|
||||
// Simulate initial image load
|
||||
await wrapper.find('img').trigger('load')
|
||||
@@ -365,8 +463,7 @@ describe('ImagePreview', () => {
|
||||
await vi.advanceTimersByTimeAsync(300)
|
||||
await nextTick()
|
||||
|
||||
// Loading state should NOT have been reset - aria-busy should still be false
|
||||
// because the URLs are identical (just a new array reference)
|
||||
// Loading state should NOT have been reset
|
||||
expect(wrapper.find('[aria-busy="true"]').exists()).toBe(false)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
@@ -406,16 +503,13 @@ describe('ImagePreview', () => {
|
||||
it('should handle empty to non-empty URL transitions correctly', async () => {
|
||||
const wrapper = mountImagePreview({ imageUrls: [] })
|
||||
|
||||
// No preview initially
|
||||
expect(wrapper.find('.image-preview').exists()).toBe(false)
|
||||
|
||||
// Add URLs
|
||||
await wrapper.setProps({
|
||||
imageUrls: ['/api/view?filename=test.png&type=output']
|
||||
})
|
||||
await nextTick()
|
||||
|
||||
// Preview should appear
|
||||
expect(wrapper.find('.image-preview').exists()).toBe(true)
|
||||
expect(wrapper.find('img').exists()).toBe(true)
|
||||
})
|
||||
|
||||
@@ -4,18 +4,45 @@
|
||||
class="image-preview group relative flex size-full min-h-55 min-w-16 flex-col justify-center px-2"
|
||||
@keydown="handleKeyDown"
|
||||
>
|
||||
<!-- Image Wrapper -->
|
||||
<!-- Grid View -->
|
||||
<div
|
||||
ref="imageWrapperEl"
|
||||
class="relative flex min-h-0 w-full flex-1 cursor-pointer overflow-hidden rounded-sm bg-transparent"
|
||||
v-if="viewMode === 'grid'"
|
||||
data-testid="image-grid"
|
||||
class="group/panel relative grid w-full gap-1 overflow-hidden rounded-sm p-1"
|
||||
:style="{ gridTemplateColumns: `repeat(${gridCols}, 1fr)` }"
|
||||
>
|
||||
<button
|
||||
v-for="(url, index) in imageUrls"
|
||||
:key="index"
|
||||
class="focus-visible:ring-ring relative cursor-pointer overflow-hidden rounded-sm border-0 bg-transparent p-0 transition-opacity hover:opacity-80 focus-visible:ring-2 focus-visible:outline-none"
|
||||
:aria-label="
|
||||
$t('g.viewImageOfTotal', {
|
||||
index: index + 1,
|
||||
total: imageUrls.length
|
||||
})
|
||||
"
|
||||
@pointerdown="trackPointerStart"
|
||||
@click="handleGridThumbnailClick($event, index)"
|
||||
>
|
||||
<img
|
||||
:src="url"
|
||||
:alt="`${$t('g.galleryThumbnail')} ${index + 1}`"
|
||||
draggable="false"
|
||||
class="pointer-events-none size-full object-contain"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Gallery View (Image Wrapper) -->
|
||||
<div
|
||||
v-if="viewMode === 'gallery'"
|
||||
ref="galleryPanelEl"
|
||||
class="group/panel relative flex min-h-0 w-full flex-1 cursor-pointer overflow-hidden rounded-sm bg-transparent"
|
||||
tabindex="0"
|
||||
role="img"
|
||||
role="region"
|
||||
:aria-roledescription="$t('g.imageGallery')"
|
||||
:aria-label="$t('g.imagePreview')"
|
||||
:aria-busy="showLoader"
|
||||
@mouseenter="handleMouseEnter"
|
||||
@mouseleave="handleMouseLeave"
|
||||
@focusin="handleFocusIn"
|
||||
@focusout="handleFocusOut"
|
||||
>
|
||||
<!-- Error State -->
|
||||
<div
|
||||
@@ -38,22 +65,18 @@
|
||||
<!-- Main Image -->
|
||||
<img
|
||||
v-if="!imageError"
|
||||
data-testid="main-image"
|
||||
:src="currentImageUrl"
|
||||
:alt="imageAltText"
|
||||
:class="
|
||||
cn(
|
||||
'pointer-events-none absolute inset-0 block size-full object-contain transition-opacity',
|
||||
(isHovered || isFocused) && 'opacity-60'
|
||||
)
|
||||
"
|
||||
draggable="false"
|
||||
class="pointer-events-none absolute inset-0 block size-full object-contain"
|
||||
@load="handleImageLoad"
|
||||
@error="handleImageError"
|
||||
/>
|
||||
|
||||
<!-- Floating Action Buttons (appear on hover and focus) -->
|
||||
<div
|
||||
v-if="isHovered || isFocused"
|
||||
class="actions absolute top-2 right-2 flex gap-1"
|
||||
class="actions invisible absolute top-2 right-2 flex gap-1 group-focus-within/panel:visible group-hover/panel:visible"
|
||||
>
|
||||
<!-- Mask/Edit Button -->
|
||||
<button
|
||||
@@ -76,21 +99,25 @@
|
||||
<i class="icon-[lucide--download] size-4" />
|
||||
</button>
|
||||
|
||||
<!-- Close Button -->
|
||||
<!-- Back to Grid Button -->
|
||||
<button
|
||||
v-if="hasMultipleImages"
|
||||
:class="actionButtonClass"
|
||||
:title="$t('g.removeImage')"
|
||||
:aria-label="$t('g.removeImage')"
|
||||
@click="handleRemove"
|
||||
:title="$t('g.viewGrid')"
|
||||
:aria-label="$t('g.viewGrid')"
|
||||
@click="viewMode = 'grid'"
|
||||
>
|
||||
<i class="icon-[lucide--circle-x] size-4" />
|
||||
<i class="icon-[lucide--layout-grid] size-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Image Dimensions -->
|
||||
<div class="pt-2 text-center text-xs text-base-foreground">
|
||||
<span v-if="imageError" class="text-red-400">
|
||||
<!-- Image Dimensions (gallery mode only) -->
|
||||
<div
|
||||
v-if="viewMode === 'gallery'"
|
||||
class="pt-2 text-center text-xs text-base-foreground"
|
||||
>
|
||||
<span v-if="imageError" class="text-error">
|
||||
{{ $t('g.errorLoadingImage') }}
|
||||
</span>
|
||||
<span v-else-if="showLoader" class="text-base-foreground">
|
||||
@@ -100,11 +127,23 @@
|
||||
{{ actualDimensions || $t('g.calculatingDimensions') }}
|
||||
</span>
|
||||
</div>
|
||||
<!-- Multiple Images Navigation -->
|
||||
|
||||
<!-- Multiple Images Navigation (gallery mode only) -->
|
||||
<div
|
||||
v-if="hasMultipleImages"
|
||||
class="flex flex-wrap justify-center gap-1 pt-4"
|
||||
v-if="viewMode === 'gallery' && hasMultipleImages"
|
||||
class="flex flex-wrap items-center justify-center gap-1 pt-4"
|
||||
>
|
||||
<!-- Back to Grid button -->
|
||||
<button
|
||||
class="mr-1 flex cursor-pointer items-center justify-center rounded-sm border-0 bg-transparent p-0.5 text-base-foreground/50 transition-colors hover:text-base-foreground"
|
||||
:title="$t('g.viewGrid')"
|
||||
:aria-label="$t('g.viewGrid')"
|
||||
@click="viewMode = 'grid'"
|
||||
>
|
||||
<i class="icon-[lucide--layout-grid] size-3.5" />
|
||||
</button>
|
||||
|
||||
<!-- Navigation Dots -->
|
||||
<button
|
||||
v-for="(_, index) in imageUrls"
|
||||
:key="index"
|
||||
@@ -124,7 +163,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useTimeoutFn } from '@vueuse/core'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { downloadFile } from '@/base/common/downloadUtil'
|
||||
@@ -142,7 +181,7 @@ interface ImagePreviewProps {
|
||||
readonly nodeId?: string
|
||||
}
|
||||
|
||||
const props = defineProps<ImagePreviewProps>()
|
||||
const { imageUrls, nodeId } = defineProps<ImagePreviewProps>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const maskEditor = useMaskEditor()
|
||||
@@ -152,16 +191,19 @@ const toastStore = useToastStore()
|
||||
const actionButtonClass =
|
||||
'flex h-8 min-h-8 cursor-pointer items-center justify-center rounded-lg border-0 bg-base-foreground p-2 text-base-background transition-colors duration-200 hover:bg-base-foreground/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-base-foreground focus-visible:ring-offset-2'
|
||||
|
||||
// Component state
|
||||
type ViewMode = 'gallery' | 'grid'
|
||||
|
||||
function defaultViewMode(urls: readonly string[]): ViewMode {
|
||||
return urls.length > 1 ? 'grid' : 'gallery'
|
||||
}
|
||||
|
||||
const currentIndex = ref(0)
|
||||
const isHovered = ref(false)
|
||||
const isFocused = ref(false)
|
||||
const viewMode = ref<ViewMode>(defaultViewMode(imageUrls))
|
||||
const galleryPanelEl = ref<HTMLDivElement>()
|
||||
const actualDimensions = ref<string | null>(null)
|
||||
const imageError = ref(false)
|
||||
const showLoader = ref(false)
|
||||
|
||||
const imageWrapperEl = ref<HTMLDivElement>()
|
||||
|
||||
const { start: startDelayedLoader, stop: stopDelayedLoader } = useTimeoutFn(
|
||||
() => {
|
||||
showLoader.value = true
|
||||
@@ -171,14 +213,23 @@ const { start: startDelayedLoader, stop: stopDelayedLoader } = useTimeoutFn(
|
||||
{ immediate: false }
|
||||
)
|
||||
|
||||
// Computed values
|
||||
const currentImageUrl = computed(() => props.imageUrls[currentIndex.value])
|
||||
const hasMultipleImages = computed(() => props.imageUrls.length > 1)
|
||||
const imageAltText = computed(() => `Node output ${currentIndex.value + 1}`)
|
||||
const currentImageUrl = computed(() => imageUrls[currentIndex.value] ?? '')
|
||||
const hasMultipleImages = computed(() => imageUrls.length > 1)
|
||||
const imageAltText = computed(() =>
|
||||
t('g.viewImageOfTotal', {
|
||||
index: currentIndex.value + 1,
|
||||
total: imageUrls.length
|
||||
})
|
||||
)
|
||||
const gridCols = computed(() => {
|
||||
const count = imageUrls.length
|
||||
if (count <= 4) return 2
|
||||
if (count <= 9) return 3
|
||||
return 4
|
||||
})
|
||||
|
||||
// Watch for URL changes and reset state
|
||||
watch(
|
||||
() => props.imageUrls,
|
||||
() => imageUrls,
|
||||
(newUrls, oldUrls) => {
|
||||
// Only reset state if URLs actually changed (not just array reference)
|
||||
const urlsChanged =
|
||||
@@ -196,14 +247,14 @@ watch(
|
||||
// Reset loading and error states when URLs change
|
||||
actualDimensions.value = null
|
||||
|
||||
viewMode.value = defaultViewMode(newUrls)
|
||||
imageError.value = false
|
||||
if (newUrls.length > 0) startDelayedLoader()
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
// Event handlers
|
||||
const handleImageLoad = (event: Event) => {
|
||||
function handleImageLoad(event: Event) {
|
||||
if (!event.target || !(event.target instanceof HTMLImageElement)) return
|
||||
const img = event.target
|
||||
stopDelayedLoader()
|
||||
@@ -213,29 +264,29 @@ const handleImageLoad = (event: Event) => {
|
||||
actualDimensions.value = `${img.naturalWidth} x ${img.naturalHeight}`
|
||||
}
|
||||
|
||||
if (props.nodeId) {
|
||||
nodeOutputStore.syncLegacyNodeImgs(props.nodeId, img, currentIndex.value)
|
||||
if (nodeId) {
|
||||
nodeOutputStore.syncLegacyNodeImgs(nodeId, img, currentIndex.value)
|
||||
}
|
||||
}
|
||||
|
||||
const handleImageError = () => {
|
||||
function handleImageError() {
|
||||
stopDelayedLoader()
|
||||
showLoader.value = false
|
||||
imageError.value = true
|
||||
actualDimensions.value = null
|
||||
}
|
||||
|
||||
const handleEditMask = () => {
|
||||
if (!props.nodeId) return
|
||||
const node = resolveNode(Number(props.nodeId))
|
||||
function handleEditMask() {
|
||||
if (!nodeId) return
|
||||
const node = resolveNode(Number(nodeId))
|
||||
if (!node) return
|
||||
maskEditor.openMaskEditor(node)
|
||||
}
|
||||
|
||||
const handleDownload = () => {
|
||||
function handleDownload() {
|
||||
try {
|
||||
downloadFile(currentImageUrl.value)
|
||||
} catch (error) {
|
||||
} catch {
|
||||
toastStore.add({
|
||||
severity: 'error',
|
||||
summary: t('g.error'),
|
||||
@@ -244,46 +295,35 @@ const handleDownload = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const handleRemove = () => {
|
||||
if (!props.nodeId) return
|
||||
const node = resolveNode(Number(props.nodeId))
|
||||
nodeOutputStore.removeNodeOutputs(props.nodeId)
|
||||
if (node) {
|
||||
node.imgs = undefined
|
||||
const imageWidget = node.widgets?.find((w) => w.name === 'image')
|
||||
if (imageWidget) {
|
||||
imageWidget.value = ''
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const setCurrentIndex = (index: number) => {
|
||||
function setCurrentIndex(index: number) {
|
||||
if (currentIndex.value === index) return
|
||||
if (index >= 0 && index < props.imageUrls.length) {
|
||||
const urlChanged = props.imageUrls[index] !== currentImageUrl.value
|
||||
if (index >= 0 && index < imageUrls.length) {
|
||||
const urlChanged = imageUrls[index] !== currentImageUrl.value
|
||||
currentIndex.value = index
|
||||
imageError.value = false
|
||||
if (urlChanged) startDelayedLoader()
|
||||
}
|
||||
}
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
isHovered.value = true
|
||||
const CLICK_THRESHOLD = 3
|
||||
let pointerStartPos = { x: 0, y: 0 }
|
||||
|
||||
function trackPointerStart(event: PointerEvent) {
|
||||
pointerStartPos = { x: event.clientX, y: event.clientY }
|
||||
}
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
isHovered.value = false
|
||||
function handleGridThumbnailClick(event: MouseEvent, index: number) {
|
||||
const dx = event.clientX - pointerStartPos.x
|
||||
const dy = event.clientY - pointerStartPos.y
|
||||
if (Math.abs(dx) > CLICK_THRESHOLD || Math.abs(dy) > CLICK_THRESHOLD) return
|
||||
openImageInGallery(index)
|
||||
}
|
||||
|
||||
const handleFocusIn = () => {
|
||||
isFocused.value = true
|
||||
}
|
||||
|
||||
const handleFocusOut = (event: FocusEvent) => {
|
||||
// Only unfocus if focus is leaving the wrapper entirely
|
||||
if (!imageWrapperEl.value?.contains(event.relatedTarget as Node)) {
|
||||
isFocused.value = false
|
||||
}
|
||||
async function openImageInGallery(index: number) {
|
||||
setCurrentIndex(index)
|
||||
viewMode.value = 'gallery'
|
||||
await nextTick()
|
||||
galleryPanelEl.value?.focus()
|
||||
}
|
||||
|
||||
function getNavigationDotClass(index: number) {
|
||||
@@ -295,24 +335,30 @@ function getNavigationDotClass(index: number) {
|
||||
)
|
||||
}
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (props.imageUrls.length <= 1) return
|
||||
function handleKeyDown(event: KeyboardEvent) {
|
||||
if (
|
||||
event.key === 'Escape' &&
|
||||
viewMode.value === 'gallery' &&
|
||||
hasMultipleImages.value
|
||||
) {
|
||||
event.preventDefault()
|
||||
viewMode.value = 'grid'
|
||||
return
|
||||
}
|
||||
|
||||
if (imageUrls.length <= 1 || viewMode.value === 'grid') return
|
||||
|
||||
switch (event.key) {
|
||||
case 'ArrowLeft':
|
||||
event.preventDefault()
|
||||
setCurrentIndex(
|
||||
currentIndex.value > 0
|
||||
? currentIndex.value - 1
|
||||
: props.imageUrls.length - 1
|
||||
currentIndex.value > 0 ? currentIndex.value - 1 : imageUrls.length - 1
|
||||
)
|
||||
break
|
||||
case 'ArrowRight':
|
||||
event.preventDefault()
|
||||
setCurrentIndex(
|
||||
currentIndex.value < props.imageUrls.length - 1
|
||||
? currentIndex.value + 1
|
||||
: 0
|
||||
currentIndex.value < imageUrls.length - 1 ? currentIndex.value + 1 : 0
|
||||
)
|
||||
break
|
||||
case 'Home':
|
||||
@@ -321,12 +367,12 @@ const handleKeyDown = (event: KeyboardEvent) => {
|
||||
break
|
||||
case 'End':
|
||||
event.preventDefault()
|
||||
setCurrentIndex(props.imageUrls.length - 1)
|
||||
setCurrentIndex(imageUrls.length - 1)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
const getImageFilename = (url: string): string => {
|
||||
function getImageFilename(url: string): string {
|
||||
if (!url) return t('g.imageDoesNotExist')
|
||||
try {
|
||||
return new URL(url).searchParams.get('filename') || t('g.unknownFile')
|
||||
|
||||
@@ -245,6 +245,7 @@ const renderPreview = (
|
||||
}
|
||||
// Draw individual
|
||||
const img = imgs[imageIndex]
|
||||
if (!img) return
|
||||
let w = img.naturalWidth
|
||||
let h = img.naturalHeight
|
||||
|
||||
|
||||
Reference in New Issue
Block a user