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:
Christian Byrne
2026-03-23 17:49:44 -07:00
committed by GitHub
parent 6a9fb4e1d5
commit b4bb6d9d13
4 changed files with 378 additions and 235 deletions

View File

@@ -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",

View File

@@ -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)
})

View File

@@ -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')

View File

@@ -245,6 +245,7 @@ const renderPreview = (
}
// Draw individual
const img = imgs[imageIndex]
if (!img) return
let w = img.naturalWidth
let h = img.naturalHeight