mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-03-14 01:20:03 +00:00
feat: replace PrimeVue Galleria/Skeleton with custom DisplayCarousel and ImagePreview (#9712)
## Summary - Replace `primevue/galleria` with custom `DisplayCarousel` component featuring Single (carousel) and Grid display modes - Hover action buttons (mask, download, remove) appear on image hover/focus - Thumbnail strip with prev/next navigation; arrows at edges, thumbnails centered - Grid mode uses fixed 56px image tiles matching Figma spec - Replace `primevue/skeleton` and `useToast()` in `ImagePreview` with `Skeleton.vue` and `useToastStore()` - Rename `WidgetGalleria` → `DisplayCarousel` across registry, stories, and tests - Add Storybook stories for both `DisplayCarousel` and `ImagePreview` - Retain `WidgetGalleriaOriginal` with its own story for side-by-side comparison ## Test plan - [x] Unit tests pass (30 DisplayCarousel + 21 ImagePreview) - [x] `pnpm typecheck` clean - [x] `pnpm lint` clean - [x] `pnpm knip` clean - [x] Visual verification via Storybook: hover controls, nav, grid mode, single/grid toggle - [x] Manual Storybook check: Components/Display/DisplayCarousel, Components/Display/ImagePreview ## screenshot <img width="604" height="642" alt="스크린샷 2026-03-12 오후 2 01 51" src="https://github.com/user-attachments/assets/94df3070-9910-470b-a8f5-5507433ef6e6" /> <img width="609" height="651" alt="스크린샷 2026-03-12 오후 2 04 47" src="https://github.com/user-attachments/assets/3d9884b4-f1bd-4ef5-957a-c7cf7fdc04d8" /> <img width="729" height="681" alt="스크린샷 2026-03-12 오후 2 04 49" src="https://github.com/user-attachments/assets/715f9367-17a3-4d7b-b81f-a7cd6bd446bf" /> <img width="534" height="460" alt="스크린샷 2026-03-12 오후 2 05 39" src="https://github.com/user-attachments/assets/b810eee2-55cb-4dbd-aaca-6331527d13ca" /> 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,61 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
|
||||
import ImagePreview from './ImagePreview.vue'
|
||||
|
||||
const SAMPLE_URLS = [
|
||||
'https://picsum.photos/seed/preview1/800/600',
|
||||
'https://picsum.photos/seed/preview2/800/600',
|
||||
'https://picsum.photos/seed/preview3/800/600'
|
||||
]
|
||||
|
||||
const meta: Meta<typeof ImagePreview> = {
|
||||
title: 'Components/Display/ImagePreview',
|
||||
component: ImagePreview,
|
||||
tags: ['autodocs'],
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
'Node output image preview with navigation dots, keyboard controls, and hover action buttons (download, remove, edit/mask).'
|
||||
}
|
||||
}
|
||||
},
|
||||
decorators: [
|
||||
(story) => ({
|
||||
components: { story },
|
||||
template:
|
||||
'<div class="h-80 w-96 rounded-lg bg-component-node-background"><story /></div>'
|
||||
})
|
||||
]
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
imageUrls: [SAMPLE_URLS[0]]
|
||||
}
|
||||
}
|
||||
|
||||
export const MultipleImages: Story = {
|
||||
args: {
|
||||
imageUrls: SAMPLE_URLS
|
||||
}
|
||||
}
|
||||
|
||||
export const ErrorState: Story = {
|
||||
args: {
|
||||
imageUrls: ['https://invalid.example.com/no-image.png']
|
||||
}
|
||||
}
|
||||
|
||||
export const ManyImages: Story = {
|
||||
args: {
|
||||
imageUrls: Array.from(
|
||||
{ length: 8 },
|
||||
(_, i) => `https://picsum.photos/seed/many${i}/800/600`
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -101,7 +101,7 @@ describe('ImagePreview', () => {
|
||||
it('shows navigation dots for multiple images', () => {
|
||||
const wrapper = mountImagePreview()
|
||||
|
||||
const navigationDots = wrapper.findAll('.w-2.h-2.rounded-full')
|
||||
const navigationDots = wrapper.findAll('[aria-label*="View image"]')
|
||||
expect(navigationDots).toHaveLength(2)
|
||||
})
|
||||
|
||||
@@ -110,7 +110,7 @@ describe('ImagePreview', () => {
|
||||
imageUrls: [defaultProps.imageUrls[0]]
|
||||
})
|
||||
|
||||
const navigationDots = wrapper.findAll('.w-2.h-2.rounded-full')
|
||||
const navigationDots = wrapper.findAll('[aria-label*="View image"]')
|
||||
expect(navigationDots).toHaveLength(0)
|
||||
})
|
||||
|
||||
@@ -249,7 +249,7 @@ describe('ImagePreview', () => {
|
||||
)
|
||||
|
||||
// Click second navigation dot
|
||||
const navigationDots = wrapper.findAll('.w-2.h-2.rounded-full')
|
||||
const navigationDots = wrapper.findAll('[aria-label*="View image"]')
|
||||
await navigationDots[1].trigger('click')
|
||||
await nextTick()
|
||||
|
||||
@@ -259,22 +259,22 @@ describe('ImagePreview', () => {
|
||||
expect(imgElement.attributes('src')).toBe(defaultProps.imageUrls[1])
|
||||
})
|
||||
|
||||
it('applies correct classes to navigation dots based on current image', async () => {
|
||||
it('marks active navigation dot with aria-current', async () => {
|
||||
const wrapper = mountImagePreview()
|
||||
|
||||
const navigationDots = wrapper.findAll('.w-2.h-2.rounded-full')
|
||||
const navigationDots = wrapper.findAll('[aria-label*="View image"]')
|
||||
|
||||
// First dot should be active (has bg-white class)
|
||||
expect(navigationDots[0].classes()).toContain('bg-base-foreground')
|
||||
expect(navigationDots[1].classes()).toContain('bg-base-foreground/50')
|
||||
// First dot should be active
|
||||
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()
|
||||
|
||||
// Second dot should now be active
|
||||
expect(navigationDots[0].classes()).toContain('bg-base-foreground/50')
|
||||
expect(navigationDots[1].classes()).toContain('bg-base-foreground')
|
||||
expect(navigationDots[0].attributes('aria-current')).toBeUndefined()
|
||||
expect(navigationDots[1].attributes('aria-current')).toBe('true')
|
||||
})
|
||||
|
||||
it('loads image without errors', async () => {
|
||||
@@ -301,7 +301,7 @@ describe('ImagePreview', () => {
|
||||
expect(wrapper.find('img').attributes('alt')).toBe('Node output 1')
|
||||
|
||||
// Switch to second image
|
||||
const navigationDots = wrapper.findAll('.w-2.h-2.rounded-full')
|
||||
const navigationDots = wrapper.findAll('[aria-label*="View image"]')
|
||||
await navigationDots[1].trigger('click')
|
||||
await nextTick()
|
||||
|
||||
@@ -326,7 +326,7 @@ describe('ImagePreview', () => {
|
||||
expect(wrapper.find('[aria-busy="true"]').exists()).toBe(false)
|
||||
|
||||
// Click second navigation dot to cycle
|
||||
const dots = wrapper.findAll('.w-2.h-2.rounded-full')
|
||||
const dots = wrapper.findAll('[aria-label*="View image"]')
|
||||
await dots[1].trigger('click')
|
||||
await nextTick()
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<!-- Image Wrapper -->
|
||||
<div
|
||||
ref="imageWrapperEl"
|
||||
class="relative flex min-h-0 w-full flex-1 overflow-hidden rounded-[5px] bg-transparent"
|
||||
class="relative flex min-h-0 w-full flex-1 cursor-pointer overflow-hidden rounded-sm bg-transparent"
|
||||
tabindex="0"
|
||||
role="img"
|
||||
:aria-label="$t('g.imagePreview')"
|
||||
@@ -33,14 +33,19 @@
|
||||
</div>
|
||||
<!-- Loading State -->
|
||||
<div v-if="showLoader && !imageError" class="size-full">
|
||||
<Skeleton border-radius="5px" width="100%" height="100%" />
|
||||
<Skeleton class="size-full rounded-sm" />
|
||||
</div>
|
||||
<!-- Main Image -->
|
||||
<img
|
||||
v-if="!imageError"
|
||||
:src="currentImageUrl"
|
||||
:alt="imageAltText"
|
||||
class="pointer-events-none absolute inset-0 block size-full object-contain"
|
||||
:class="
|
||||
cn(
|
||||
'pointer-events-none absolute inset-0 block size-full object-contain transition-opacity',
|
||||
(isHovered || isFocused) && 'opacity-60'
|
||||
)
|
||||
"
|
||||
@load="handleImageLoad"
|
||||
@error="handleImageError"
|
||||
/>
|
||||
@@ -48,7 +53,7 @@
|
||||
<!-- Floating Action Buttons (appear on hover and focus) -->
|
||||
<div
|
||||
v-if="isHovered || isFocused"
|
||||
class="actions absolute top-2 right-2 flex gap-2.5"
|
||||
class="actions absolute top-2 right-2 flex gap-1"
|
||||
>
|
||||
<!-- Mask/Edit Button -->
|
||||
<button
|
||||
@@ -78,7 +83,7 @@
|
||||
:aria-label="$t('g.removeImage')"
|
||||
@click="handleRemove"
|
||||
>
|
||||
<i class="icon-[lucide--x] size-4" />
|
||||
<i class="icon-[lucide--circle-x] size-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -119,15 +124,16 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useTimeoutFn } from '@vueuse/core'
|
||||
import { useToast } from 'primevue'
|
||||
import Skeleton from 'primevue/skeleton'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { downloadFile } from '@/base/common/downloadUtil'
|
||||
import Skeleton from '@/components/ui/skeleton/Skeleton.vue'
|
||||
import { useMaskEditor } from '@/composables/maskeditor/useMaskEditor'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
interface ImagePreviewProps {
|
||||
/** Array of image URLs to display */
|
||||
@@ -141,9 +147,10 @@ const props = defineProps<ImagePreviewProps>()
|
||||
const { t } = useI18n()
|
||||
const maskEditor = useMaskEditor()
|
||||
const nodeOutputStore = useNodeOutputStore()
|
||||
const toastStore = useToastStore()
|
||||
|
||||
const actionButtonClass =
|
||||
'flex h-8 min-h-8 items-center justify-center gap-2.5 rounded-lg border-0 bg-button-surface px-2 py-2 text-button-surface-contrast shadow-sm transition-colors duration-200 hover:bg-button-hover-surface focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-button-surface-contrast focus-visible:ring-offset-2 focus-visible:ring-offset-transparent cursor-pointer'
|
||||
'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
|
||||
const currentIndex = ref(0)
|
||||
@@ -229,11 +236,10 @@ const handleDownload = () => {
|
||||
try {
|
||||
downloadFile(currentImageUrl.value)
|
||||
} catch (error) {
|
||||
useToast().add({
|
||||
toastStore.add({
|
||||
severity: 'error',
|
||||
summary: 'Error',
|
||||
detail: t('g.failedToDownloadImage'),
|
||||
group: 'image-preview'
|
||||
summary: t('g.error'),
|
||||
detail: t('g.failedToDownloadImage')
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -280,13 +286,13 @@ const handleFocusOut = (event: FocusEvent) => {
|
||||
}
|
||||
}
|
||||
|
||||
const getNavigationDotClass = (index: number) => {
|
||||
return [
|
||||
'w-2 h-2 rounded-full transition-all duration-200 border-0 cursor-pointer p-0',
|
||||
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'
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
|
||||
Reference in New Issue
Block a user