mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-07-09 16:48:06 +00:00
Compare commits
4 Commits
v1.47.7
...
move-image
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c5d93bcb1c | ||
|
|
46a64637f0 | ||
|
|
11ade0d457 | ||
|
|
045a39f09b |
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 |
@@ -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",
|
||||
|
||||
@@ -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.'
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user