Compare commits

...

4 Commits

Author SHA1 Message Date
PabloWiedemann
c5d93bcb1c Merge remote-tracking branch 'origin/main' into move-image-widget-buttons 2026-07-08 16:08:15 -07:00
PabloWiedemann
46a64637f0 test: restore unrelated screenshot baselines to main
The blanket --update-snapshots regeneration also rewrote two screenshots
unrelated to this PR (rightClickMenu, vue-nodes pan). Restore them to
main's baselines so this PR only updates the image-preview screenshots.
2026-07-08 16:08:13 -07:00
github-actions
11ade0d457 [automated] Update test expectations 2026-07-08 21:38:50 +00:00
PabloWiedemann
045a39f09b feat: move preview image controls off the media, add prev/next navigation
Remove the floating overlay buttons and pagination dots from the node
image preview. Controls (grid/mask/download plus prev/next navigation
and a position counter) now sit in a row below the image, keeping UI off
the media output. Arrow-key navigation is preserved.
2026-07-08 14:09:37 -07:00
6 changed files with 124 additions and 123 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 KiB

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 89 KiB

After

Width:  |  Height:  |  Size: 92 KiB

View File

@@ -30,6 +30,7 @@
"content": "content",
"audioProgress": "Audio progress",
"viewImageOfTotal": "View image {index} of {total}",
"imageIndexOfTotal": "{index} / {total}",
"viewVideoOfTotal": "View video {index} of {total}",
"imageLightbox": "Image preview",
"imagePreview": "Image preview - Use arrow keys to navigate between images",

View File

@@ -17,7 +17,7 @@ const meta: Meta<typeof ImagePreview> = {
docs: {
description: {
component:
'Node output image preview with navigation dots, keyboard controls, and hover action buttons (download, remove, edit/mask).'
'Node output image preview. Controls (download, edit/mask, prev/next navigation) sit in a row below the image, kept off the media itself; arrow keys also navigate between images.'
}
}
},

View File

@@ -28,7 +28,10 @@ const i18n = createI18n({
editOrMaskImage: 'Edit or mask image',
downloadImage: 'Download image',
removeImage: 'Remove image',
previousImage: 'Previous image',
nextImage: 'Next image',
viewImageOfTotal: 'View image {index} of {total}',
imageIndexOfTotal: '{index} / {total}',
imagePreview:
'Image preview - Use arrow keys to navigate between images',
errorLoadingImage: 'Error loading image',
@@ -111,26 +114,30 @@ describe('ImagePreview', () => {
screen.getByText('Calculating dimensions')
})
it('shows navigation dots for multiple images in gallery mode', async () => {
it('shows prev/next navigation buttons for multiple images in gallery mode', async () => {
renderImagePreview()
const user = userEvent.setup()
await switchToGallery(user)
const navigationDots = screen.getAllByRole('button', {
name: /View image/
})
expect(navigationDots).toHaveLength(2)
expect(
screen.getByRole('button', { name: 'Previous image' })
).toBeInTheDocument()
expect(
screen.getByRole('button', { name: 'Next image' })
).toBeInTheDocument()
})
it('does not show navigation dots for single image', () => {
it('does not show prev/next navigation for a single image', () => {
renderImagePreview({
imageUrls: [defaultProps.imageUrls[0]]
})
const navigationDots = screen.queryAllByRole('button', {
name: /View image/
})
expect(navigationDots).toHaveLength(0)
expect(
screen.queryByRole('button', { name: 'Previous image' })
).not.toBeInTheDocument()
expect(
screen.queryByRole('button', { name: 'Next image' })
).not.toBeInTheDocument()
})
it('does not show mask/edit button for multiple images in gallery mode', async () => {
@@ -188,7 +195,7 @@ describe('ImagePreview', () => {
expect(downloadFile).toHaveBeenCalledWith(defaultProps.imageUrls[0])
})
it('switches images when navigation dots are clicked', async () => {
it('switches images with the next button', async () => {
renderImagePreview()
const user = userEvent.setup()
await switchToGallery(user)
@@ -199,11 +206,7 @@ describe('ImagePreview', () => {
defaultProps.imageUrls[0]
)
// Click second navigation dot
const navigationDots = screen.getAllByRole('button', {
name: /View image/
})
await user.click(navigationDots[1])
await user.click(screen.getByRole('button', { name: 'Next image' }))
await nextTick()
expect(screen.getByRole('img')).toHaveAttribute(
@@ -212,25 +215,31 @@ describe('ImagePreview', () => {
)
})
it('marks active navigation dot with aria-current', async () => {
it('wraps to the last image with the previous button', async () => {
renderImagePreview()
const user = userEvent.setup()
await switchToGallery(user)
const navigationDots = screen.getAllByRole('button', {
name: /View image/
})
// First dot should be active
expect(navigationDots[0]).toHaveAttribute('aria-current', 'true')
expect(navigationDots[1]).not.toHaveAttribute('aria-current')
await user.click(navigationDots[1])
await user.click(screen.getByRole('button', { name: 'Previous image' }))
await nextTick()
// Second dot should now be active
expect(navigationDots[0]).not.toHaveAttribute('aria-current')
expect(navigationDots[1]).toHaveAttribute('aria-current', 'true')
expect(screen.getByRole('img')).toHaveAttribute(
'src',
defaultProps.imageUrls[1]
)
})
it('shows the current image position in the counter', async () => {
renderImagePreview()
const user = userEvent.setup()
await switchToGallery(user)
screen.getByText('1 / 2')
await user.click(screen.getByRole('button', { name: 'Next image' }))
await nextTick()
screen.getByText('2 / 2')
})
it('has proper accessibility attributes', () => {
@@ -248,11 +257,7 @@ describe('ImagePreview', () => {
expect(screen.getByRole('img')).toHaveAttribute('alt', 'View image 1 of 2')
// Switch to second image
const navigationDots = screen.getAllByRole('button', {
name: /View image/
})
await user.click(navigationDots[1])
await user.click(screen.getByRole('button', { name: 'Next image' }))
await nextTick()
expect(screen.getByRole('img')).toHaveAttribute('alt', 'View image 2 of 2')
@@ -432,7 +437,7 @@ describe('ImagePreview', () => {
expect(mainImg).toHaveAttribute('src', defaultProps.imageUrls[1])
})
it('shows back-to-grid button next to navigation dots', async () => {
it('shows back-to-grid button in gallery mode', async () => {
renderImagePreview()
const user = userEvent.setup()
await switchToGallery(user)
@@ -502,9 +507,8 @@ describe('ImagePreview', () => {
container.querySelector('[aria-busy="true"]')
).not.toBeInTheDocument()
// Click second navigation dot to cycle
const dots = screen.getAllByRole('button', { name: /View image/ })
await user.click(dots[1])
// Advance to the next (identical) image
await user.click(screen.getByRole('button', { name: 'Next image' }))
await nextTick()
// Advance past the delayed loader timeout

View File

@@ -1,7 +1,7 @@
<template>
<div
v-if="imageUrls.length > 0"
class="image-preview group relative flex size-full min-h-55 min-w-16 flex-col justify-center px-2"
class="image-preview relative flex size-full min-h-55 min-w-16 flex-col justify-center px-2"
@keydown="handleKeyDown"
>
<!-- Grid View -->
@@ -47,7 +47,7 @@
<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"
class="relative flex min-h-0 w-full flex-1 cursor-pointer overflow-hidden rounded-sm bg-transparent"
tabindex="0"
role="region"
:aria-roledescription="$t('g.imageGallery')"
@@ -101,44 +101,6 @@
@load="handleImageLoad"
@error="handleImageError"
/>
<!-- Floating Action Buttons (appear on hover and focus) -->
<div
class="actions invisible absolute top-2 right-2 flex gap-1 group-focus-within/panel:visible group-hover/panel:visible"
>
<!-- Mask/Edit Button -->
<button
v-if="!hasMultipleImages && !imageError && !currentImageIsHdr"
:class="actionButtonClass"
:title="$t('g.editOrMaskImage')"
:aria-label="$t('g.editOrMaskImage')"
@click="handleEditMask"
>
<i-comfy:mask class="size-4" />
</button>
<!-- Download Button -->
<button
v-if="!imageError"
:class="actionButtonClass"
:title="$t('g.downloadImage')"
:aria-label="$t('g.downloadImage')"
@click="handleDownload"
>
<i class="icon-[lucide--download] size-4" />
</button>
<!-- Back to Grid Button -->
<button
v-if="hasMultipleImages"
:class="actionButtonClass"
:title="$t('g.viewGrid')"
:aria-label="$t('g.viewGrid')"
@click="viewMode = 'grid'"
>
<i class="icon-[lucide--layout-grid] size-4" />
</button>
</div>
</div>
<!-- Image Dimensions (gallery mode only) -->
@@ -161,35 +123,71 @@
</span>
</div>
<!-- Multiple Images Navigation (gallery mode only) -->
<!-- Controls (below image, kept off the media) -->
<div
v-if="viewMode === 'gallery' && hasMultipleImages"
class="flex flex-wrap items-center justify-center gap-1 pt-4"
v-if="viewMode === 'gallery' && (hasMultipleImages || !imageError)"
class="flex items-center justify-between gap-2 pt-2"
>
<!-- 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>
<!-- Action buttons -->
<div class="flex items-center gap-1">
<button
v-if="hasMultipleImages"
:class="controlButtonClass"
:title="$t('g.viewGrid')"
:aria-label="$t('g.viewGrid')"
@click="viewMode = 'grid'"
>
<i class="icon-[lucide--layout-grid] size-4" />
</button>
<button
v-if="!hasMultipleImages && !imageError && !currentImageIsHdr"
:class="controlButtonClass"
:title="$t('g.editOrMaskImage')"
:aria-label="$t('g.editOrMaskImage')"
@click="handleEditMask"
>
<i-comfy:mask class="size-4" />
</button>
<button
v-if="!imageError"
:class="controlButtonClass"
:title="$t('g.downloadImage')"
:aria-label="$t('g.downloadImage')"
@click="handleDownload"
>
<i class="icon-[lucide--download] size-4" />
</button>
</div>
<!-- Navigation Dots -->
<button
v-for="(_, index) in imageUrls"
:key="index"
:class="getNavigationDotClass(index)"
:aria-current="index === currentIndex ? 'true' : undefined"
:aria-label="
$t('g.viewImageOfTotal', {
index: index + 1,
total: imageUrls.length
})
"
@click="setCurrentIndex(index)"
/>
<!-- Previous / Next navigation -->
<div v-if="hasMultipleImages" class="flex items-center gap-1">
<button
:class="controlButtonClass"
:title="$t('g.previousImage')"
:aria-label="$t('g.previousImage')"
@click="goToPrevious"
>
<i class="icon-[lucide--chevron-left] size-4" />
</button>
<span
class="min-w-10 text-center text-xs text-base-foreground/70 tabular-nums"
>
{{
$t('g.imageIndexOfTotal', {
index: currentIndex + 1,
total: imageUrls.length
})
}}
</span>
<button
:class="controlButtonClass"
:title="$t('g.nextImage')"
:aria-label="$t('g.nextImage')"
@click="goToNext"
>
<i class="icon-[lucide--chevron-right] size-4" />
</button>
</div>
</div>
</div>
</template>
@@ -210,7 +208,6 @@ import type { NodeId } from '@/types/nodeId'
import { isHdrImageUrl } from '@/utils/hdrFormatUtil'
import { getGridThumbnailUrl } from '@/utils/imageUtil'
import { resolveNode } from '@/utils/litegraphUtil'
import { cn } from '@comfyorg/tailwind-utils'
interface ImagePreviewProps {
/** Array of image URLs to display */
@@ -226,8 +223,8 @@ const maskEditor = useMaskEditor()
const nodeOutputStore = useNodeOutputStore()
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 shadow-interface 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'
const controlButtonClass =
'flex size-8 shrink-0 cursor-pointer items-center justify-center rounded-lg border-0 bg-transparent text-base-foreground/60 transition-colors duration-200 hover:bg-base-foreground/10 hover:text-base-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-base-foreground'
type ViewMode = 'gallery' | 'grid'
@@ -356,6 +353,18 @@ function setCurrentIndex(index: number) {
}
}
function goToPrevious() {
setCurrentIndex(
currentIndex.value > 0 ? currentIndex.value - 1 : imageUrls.length - 1
)
}
function goToNext() {
setCurrentIndex(
currentIndex.value < imageUrls.length - 1 ? currentIndex.value + 1 : 0
)
}
async function openImageInGallery(index: number) {
setCurrentIndex(index)
viewMode.value = 'gallery'
@@ -372,15 +381,6 @@ function handleGridClick(index: number) {
void openImageInGallery(index)
}
function getNavigationDotClass(index: number) {
return cn(
'size-2 cursor-pointer rounded-full border-0 p-0 transition-all duration-200',
index === currentIndex.value
? 'bg-base-foreground'
: 'bg-base-foreground/50 hover:bg-base-foreground/80'
)
}
function handleKeyDown(event: KeyboardEvent) {
if (
event.key === 'Escape' &&
@@ -397,15 +397,11 @@ function handleKeyDown(event: KeyboardEvent) {
switch (event.key) {
case 'ArrowLeft':
event.preventDefault()
setCurrentIndex(
currentIndex.value > 0 ? currentIndex.value - 1 : imageUrls.length - 1
)
goToPrevious()
break
case 'ArrowRight':
event.preventDefault()
setCurrentIndex(
currentIndex.value < imageUrls.length - 1 ? currentIndex.value + 1 : 0
)
goToNext()
break
case 'Home':
event.preventDefault()