mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-03-13 17:10:06 +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:
@@ -35,6 +35,10 @@
|
||||
"videoPreview": "Video preview - Use arrow keys to navigate between videos",
|
||||
"galleryImage": "Gallery image",
|
||||
"galleryThumbnail": "Gallery thumbnail",
|
||||
"previousImage": "Previous image",
|
||||
"nextImage": "Next image",
|
||||
"switchToGridView": "Switch to grid view",
|
||||
"switchToSingleView": "Switch to single view",
|
||||
"errorLoadingImage": "Error loading image",
|
||||
"errorLoadingVideo": "Error loading video",
|
||||
"failedToDownloadImage": "Failed to download image",
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
|
||||
import DisplayCarousel from './DisplayCarousel.vue'
|
||||
import type { GalleryImage, GalleryValue } from './DisplayCarousel.vue'
|
||||
|
||||
const SAMPLE_IMAGES = [
|
||||
'https://picsum.photos/seed/comfy1/600/400',
|
||||
'https://picsum.photos/seed/comfy2/600/400',
|
||||
'https://picsum.photos/seed/comfy3/600/400',
|
||||
'https://picsum.photos/seed/comfy4/600/400',
|
||||
'https://picsum.photos/seed/comfy5/600/400'
|
||||
]
|
||||
|
||||
const SAMPLE_IMAGE_OBJECTS: GalleryImage[] = [
|
||||
{
|
||||
itemImageSrc: 'https://picsum.photos/seed/obj1/600/400',
|
||||
thumbnailImageSrc: 'https://picsum.photos/seed/obj1/120/80',
|
||||
alt: 'Mountain landscape'
|
||||
},
|
||||
{
|
||||
itemImageSrc: 'https://picsum.photos/seed/obj2/600/400',
|
||||
thumbnailImageSrc: 'https://picsum.photos/seed/obj2/120/80',
|
||||
alt: 'Ocean view'
|
||||
},
|
||||
{
|
||||
itemImageSrc: 'https://picsum.photos/seed/obj3/600/400',
|
||||
thumbnailImageSrc: 'https://picsum.photos/seed/obj3/120/80',
|
||||
alt: 'Forest path'
|
||||
}
|
||||
]
|
||||
|
||||
const meta: Meta<typeof DisplayCarousel> = {
|
||||
title: 'Components/Display/DisplayCarousel',
|
||||
component: DisplayCarousel,
|
||||
tags: ['autodocs'],
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
'Image gallery with Single (carousel) and Grid display modes. Hover to reveal a toggle button that switches between modes. Grid mode shows images in a responsive grid; clicking an image switches back to single mode focused on that image.'
|
||||
}
|
||||
}
|
||||
},
|
||||
decorators: [
|
||||
(story) => ({
|
||||
components: { story },
|
||||
template:
|
||||
'<div class="w-80 rounded-xl bg-component-node-background p-4"><story /></div>'
|
||||
})
|
||||
]
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {
|
||||
render: () => ({
|
||||
components: { DisplayCarousel },
|
||||
setup() {
|
||||
const value = ref<GalleryValue>([...SAMPLE_IMAGES])
|
||||
const widget: SimplifiedWidget<GalleryValue, Record<string, unknown>> = {
|
||||
name: 'gallery',
|
||||
type: 'array',
|
||||
value: []
|
||||
}
|
||||
return { value, widget }
|
||||
},
|
||||
template: '<DisplayCarousel :widget="widget" v-model="value" />'
|
||||
})
|
||||
}
|
||||
|
||||
export const SingleImage: Story = {
|
||||
render: () => ({
|
||||
components: { DisplayCarousel },
|
||||
setup() {
|
||||
const value = ref<GalleryValue>([SAMPLE_IMAGES[0]])
|
||||
const widget: SimplifiedWidget<GalleryValue, Record<string, unknown>> = {
|
||||
name: 'gallery',
|
||||
type: 'array',
|
||||
value: []
|
||||
}
|
||||
return { value, widget }
|
||||
},
|
||||
template: '<DisplayCarousel :widget="widget" v-model="value" />'
|
||||
})
|
||||
}
|
||||
|
||||
export const WithImageObjects: Story = {
|
||||
render: () => ({
|
||||
components: { DisplayCarousel },
|
||||
setup() {
|
||||
const value = ref<GalleryValue>([...SAMPLE_IMAGE_OBJECTS])
|
||||
const widget: SimplifiedWidget<GalleryValue, Record<string, unknown>> = {
|
||||
name: 'gallery',
|
||||
type: 'array',
|
||||
value: []
|
||||
}
|
||||
return { value, widget }
|
||||
},
|
||||
template: '<DisplayCarousel :widget="widget" v-model="value" />'
|
||||
})
|
||||
}
|
||||
|
||||
export const GridFewImages: Story = {
|
||||
render: () => ({
|
||||
components: { DisplayCarousel },
|
||||
setup() {
|
||||
const value = ref<GalleryValue>([...SAMPLE_IMAGES.slice(0, 4)])
|
||||
const widget: SimplifiedWidget<GalleryValue, Record<string, unknown>> = {
|
||||
name: 'gallery',
|
||||
type: 'array',
|
||||
value: []
|
||||
}
|
||||
return { value, widget, displayMode: ref('grid') }
|
||||
},
|
||||
template: '<DisplayCarousel :widget="widget" v-model="value" />'
|
||||
})
|
||||
}
|
||||
|
||||
export const GridManyImages: Story = {
|
||||
render: () => ({
|
||||
components: { DisplayCarousel },
|
||||
setup() {
|
||||
const value = ref<GalleryValue>(
|
||||
Array.from(
|
||||
{ length: 25 },
|
||||
(_, i) => `https://picsum.photos/seed/grid${i}/200/200`
|
||||
)
|
||||
)
|
||||
const widget: SimplifiedWidget<GalleryValue, Record<string, unknown>> = {
|
||||
name: 'gallery',
|
||||
type: 'array',
|
||||
value: []
|
||||
}
|
||||
return { value, widget }
|
||||
},
|
||||
template: '<DisplayCarousel :widget="widget" v-model="value" />'
|
||||
})
|
||||
}
|
||||
|
||||
export const Empty: Story = {
|
||||
render: () => ({
|
||||
components: { DisplayCarousel },
|
||||
setup() {
|
||||
const value = ref<GalleryValue>([])
|
||||
const widget: SimplifiedWidget<GalleryValue, Record<string, unknown>> = {
|
||||
name: 'gallery',
|
||||
type: 'array',
|
||||
value: []
|
||||
}
|
||||
return { value, widget }
|
||||
},
|
||||
template: '<DisplayCarousel :widget="widget" v-model="value" />'
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,455 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick, ref } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
|
||||
import DisplayCarousel from './DisplayCarousel.vue'
|
||||
import type { GalleryImage, GalleryValue } from './DisplayCarousel.vue'
|
||||
import { createMockWidget } from './widgetTestUtils'
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
g: {
|
||||
galleryImage: 'Gallery image',
|
||||
galleryThumbnail: 'Gallery thumbnail',
|
||||
previousImage: 'Previous image',
|
||||
nextImage: 'Next image',
|
||||
switchToGridView: 'Switch to grid view',
|
||||
switchToSingleView: 'Switch to single view',
|
||||
viewImageOfTotal: 'View image {index} of {total}',
|
||||
editOrMaskImage: 'Edit or mask image',
|
||||
downloadImage: 'Download image',
|
||||
removeImage: 'Remove image'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const TEST_IMAGES_SMALL: readonly string[] = Object.freeze([
|
||||
'https://example.com/image0.jpg',
|
||||
'https://example.com/image1.jpg',
|
||||
'https://example.com/image2.jpg'
|
||||
])
|
||||
|
||||
const TEST_IMAGES_SINGLE: readonly string[] = Object.freeze([
|
||||
'https://example.com/single.jpg'
|
||||
])
|
||||
|
||||
const TEST_IMAGE_OBJECTS: readonly GalleryImage[] = Object.freeze([
|
||||
{
|
||||
itemImageSrc: 'https://example.com/image0.jpg',
|
||||
thumbnailImageSrc: 'https://example.com/thumb0.jpg',
|
||||
alt: 'Test image 0'
|
||||
},
|
||||
{
|
||||
itemImageSrc: 'https://example.com/image1.jpg',
|
||||
thumbnailImageSrc: 'https://example.com/thumb1.jpg',
|
||||
alt: 'Test image 1'
|
||||
}
|
||||
])
|
||||
|
||||
function createGalleriaWidget(
|
||||
value: GalleryValue = [],
|
||||
options: Record<string, unknown> = {}
|
||||
) {
|
||||
return createMockWidget<GalleryValue>({
|
||||
value,
|
||||
name: 'test_galleria',
|
||||
type: 'array',
|
||||
options
|
||||
})
|
||||
}
|
||||
|
||||
function mountComponent(
|
||||
widget: SimplifiedWidget<GalleryValue>,
|
||||
modelValue: GalleryValue
|
||||
) {
|
||||
return mount(DisplayCarousel, {
|
||||
global: {
|
||||
plugins: [createTestingPinia({ createSpy: vi.fn }), i18n]
|
||||
},
|
||||
props: {
|
||||
widget,
|
||||
modelValue
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function createImageStrings(count: number): string[] {
|
||||
return Array.from(
|
||||
{ length: count },
|
||||
(_, i) => `https://example.com/image${i}.jpg`
|
||||
)
|
||||
}
|
||||
|
||||
function createGalleriaWrapper(
|
||||
images: GalleryValue,
|
||||
options: Record<string, unknown> = {}
|
||||
) {
|
||||
const widget = createGalleriaWidget(images, options)
|
||||
return mountComponent(widget, images)
|
||||
}
|
||||
|
||||
function findThumbnails(wrapper: ReturnType<typeof mount>) {
|
||||
return wrapper.findAll('div').filter((div) => {
|
||||
return div.find('img').exists() && div.classes().includes('border-2')
|
||||
})
|
||||
}
|
||||
|
||||
function findImageContainer(wrapper: ReturnType<typeof mount>) {
|
||||
return wrapper.find('[tabindex="0"]')
|
||||
}
|
||||
|
||||
describe('DisplayCarousel Single Mode', () => {
|
||||
describe('Component Rendering', () => {
|
||||
it('renders main image', () => {
|
||||
const wrapper = createGalleriaWrapper([...TEST_IMAGES_SMALL])
|
||||
|
||||
const img = wrapper.find('img')
|
||||
expect(img.exists()).toBe(true)
|
||||
expect(img.attributes('src')).toBe(TEST_IMAGES_SMALL[0])
|
||||
})
|
||||
|
||||
it('displays empty gallery when no images provided', () => {
|
||||
const wrapper = createGalleriaWrapper([])
|
||||
|
||||
expect(wrapper.find('img').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('handles null value gracefully', () => {
|
||||
const widget = createGalleriaWidget([])
|
||||
const wrapper = mountComponent(widget, null as unknown as GalleryValue)
|
||||
|
||||
expect(wrapper.find('img').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('handles undefined value gracefully', () => {
|
||||
const widget = createGalleriaWidget([])
|
||||
const wrapper = mountComponent(
|
||||
widget,
|
||||
undefined as unknown as GalleryValue
|
||||
)
|
||||
|
||||
expect(wrapper.find('img').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('String Array Input', () => {
|
||||
it('converts string array to image objects and displays first', () => {
|
||||
const wrapper = createGalleriaWrapper([...TEST_IMAGES_SMALL])
|
||||
|
||||
const img = wrapper.find('img')
|
||||
expect(img.attributes('src')).toBe('https://example.com/image0.jpg')
|
||||
})
|
||||
|
||||
it('handles single string image', () => {
|
||||
const wrapper = createGalleriaWrapper([...TEST_IMAGES_SINGLE])
|
||||
|
||||
const img = wrapper.find('img')
|
||||
expect(img.attributes('src')).toBe('https://example.com/single.jpg')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Object Array Input', () => {
|
||||
it('preserves image objects and displays first', () => {
|
||||
const wrapper = createGalleriaWrapper([...TEST_IMAGE_OBJECTS])
|
||||
|
||||
const img = wrapper.find('img')
|
||||
expect(img.attributes('src')).toBe('https://example.com/image0.jpg')
|
||||
expect(img.attributes('alt')).toBe('Test image 0')
|
||||
})
|
||||
|
||||
it('handles mixed object properties with src fallback', () => {
|
||||
const images: GalleryImage[] = [
|
||||
{ src: 'https://example.com/image1.jpg', alt: 'First' }
|
||||
]
|
||||
const wrapper = createGalleriaWrapper(images)
|
||||
|
||||
const img = wrapper.find('img')
|
||||
expect(img.attributes('src')).toBe('https://example.com/image1.jpg')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Thumbnail Display', () => {
|
||||
it('shows thumbnails when multiple images present', () => {
|
||||
const wrapper = createGalleriaWrapper([...TEST_IMAGES_SMALL])
|
||||
|
||||
const thumbnailButtons = findThumbnails(wrapper)
|
||||
expect(thumbnailButtons).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('hides thumbnails for single image', () => {
|
||||
const wrapper = createGalleriaWrapper([...TEST_IMAGES_SINGLE])
|
||||
|
||||
const thumbnailButtons = findThumbnails(wrapper)
|
||||
expect(thumbnailButtons).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('thumbnails are not interactive', () => {
|
||||
const wrapper = createGalleriaWrapper([...TEST_IMAGES_SMALL])
|
||||
|
||||
const thumbnails = findThumbnails(wrapper)
|
||||
expect(thumbnails[0].element.tagName).not.toBe('BUTTON')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Navigation Buttons', () => {
|
||||
it('shows navigation buttons when multiple images present', () => {
|
||||
const wrapper = createGalleriaWrapper([...TEST_IMAGES_SMALL])
|
||||
|
||||
expect(wrapper.find('[aria-label="Previous image"]').exists()).toBe(true)
|
||||
expect(wrapper.find('[aria-label="Next image"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('hides navigation buttons for single image', () => {
|
||||
const wrapper = createGalleriaWrapper([...TEST_IMAGES_SINGLE])
|
||||
|
||||
expect(wrapper.find('[aria-label="Previous image"]').exists()).toBe(false)
|
||||
expect(wrapper.find('[aria-label="Next image"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('respects widget option to hide navigation buttons', () => {
|
||||
const wrapper = createGalleriaWrapper([...TEST_IMAGES_SMALL], {
|
||||
showItemNavigators: false
|
||||
})
|
||||
|
||||
expect(wrapper.find('[aria-label="Previous image"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('navigates to next image on next click', async () => {
|
||||
const wrapper = createGalleriaWrapper([...TEST_IMAGES_SMALL])
|
||||
|
||||
await wrapper.find('[aria-label="Next image"]').trigger('click')
|
||||
await nextTick()
|
||||
|
||||
const mainImg = wrapper.findAll('img')[0]
|
||||
expect(mainImg.attributes('src')).toBe('https://example.com/image1.jpg')
|
||||
})
|
||||
|
||||
it('navigates to previous image on prev click', async () => {
|
||||
const wrapper = createGalleriaWrapper([...TEST_IMAGES_SMALL])
|
||||
|
||||
// Go to second image first
|
||||
await wrapper.find('[aria-label="Next image"]').trigger('click')
|
||||
await nextTick()
|
||||
|
||||
// Go back
|
||||
await wrapper.find('[aria-label="Previous image"]').trigger('click')
|
||||
await nextTick()
|
||||
|
||||
const mainImg = wrapper.findAll('img')[0]
|
||||
expect(mainImg.attributes('src')).toBe('https://example.com/image0.jpg')
|
||||
})
|
||||
|
||||
it('wraps from first to last image on previous click', async () => {
|
||||
const wrapper = createGalleriaWrapper([...TEST_IMAGES_SMALL])
|
||||
|
||||
await wrapper.find('[aria-label="Previous image"]').trigger('click')
|
||||
await nextTick()
|
||||
|
||||
const mainImg = wrapper.findAll('img')[0]
|
||||
expect(mainImg.attributes('src')).toBe('https://example.com/image2.jpg')
|
||||
})
|
||||
|
||||
it('wraps from last to first image on next click', async () => {
|
||||
const wrapper = createGalleriaWrapper([...TEST_IMAGES_SMALL])
|
||||
|
||||
// Navigate to last image
|
||||
await wrapper.find('[aria-label="Next image"]').trigger('click')
|
||||
await wrapper.find('[aria-label="Next image"]').trigger('click')
|
||||
await nextTick()
|
||||
|
||||
// Next from last should wrap to first
|
||||
await wrapper.find('[aria-label="Next image"]').trigger('click')
|
||||
await nextTick()
|
||||
|
||||
const mainImg = wrapper.findAll('img')[0]
|
||||
expect(mainImg.attributes('src')).toBe('https://example.com/image0.jpg')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('DisplayCarousel Accessibility', () => {
|
||||
it('shows controls on focusin for keyboard users', async () => {
|
||||
const wrapper = createGalleriaWrapper([...TEST_IMAGES_SMALL])
|
||||
|
||||
await findImageContainer(wrapper).trigger('focusin')
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.find('[aria-label="Switch to grid view"]').exists()).toBe(
|
||||
true
|
||||
)
|
||||
expect(wrapper.find('[aria-label="Edit or mask image"]').exists()).toBe(
|
||||
true
|
||||
)
|
||||
})
|
||||
|
||||
it('hides controls on focusout when focus leaves component', async () => {
|
||||
const wrapper = createGalleriaWrapper([...TEST_IMAGES_SMALL])
|
||||
|
||||
await findImageContainer(wrapper).trigger('focusin')
|
||||
await nextTick()
|
||||
|
||||
// Focus leaves the image container entirely
|
||||
await findImageContainer(wrapper).trigger('focusout', {
|
||||
relatedTarget: null
|
||||
})
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.find('[aria-label="Switch to grid view"]').exists()).toBe(
|
||||
false
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('DisplayCarousel Grid Mode', () => {
|
||||
it('switches to grid mode via toggle button on hover', async () => {
|
||||
const wrapper = createGalleriaWrapper([...TEST_IMAGES_SMALL])
|
||||
|
||||
// Trigger focus on image container to reveal toggle button
|
||||
await findImageContainer(wrapper).trigger('focusin')
|
||||
await nextTick()
|
||||
|
||||
const toggleBtn = wrapper.find('[aria-label="Switch to grid view"]')
|
||||
expect(toggleBtn.exists()).toBe(true)
|
||||
|
||||
await toggleBtn.trigger('click')
|
||||
await nextTick()
|
||||
|
||||
// Grid mode should show all images as grid items
|
||||
const gridImages = wrapper.findAll('img')
|
||||
expect(gridImages).toHaveLength(TEST_IMAGES_SMALL.length)
|
||||
})
|
||||
|
||||
it('does not show grid toggle for single image', async () => {
|
||||
const wrapper = createGalleriaWrapper([...TEST_IMAGES_SINGLE])
|
||||
|
||||
await findImageContainer(wrapper).trigger('focusin')
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.find('[aria-label="Switch to grid view"]').exists()).toBe(
|
||||
false
|
||||
)
|
||||
})
|
||||
|
||||
it('switches back to single mode via toggle button', async () => {
|
||||
const wrapper = createGalleriaWrapper([...TEST_IMAGES_SMALL])
|
||||
|
||||
// Switch to grid via focus on image container
|
||||
await findImageContainer(wrapper).trigger('focusin')
|
||||
await nextTick()
|
||||
await wrapper.find('[aria-label="Switch to grid view"]').trigger('click')
|
||||
await nextTick()
|
||||
|
||||
// Focus the grid container to reveal toggle
|
||||
await findImageContainer(wrapper).trigger('focusin')
|
||||
await nextTick()
|
||||
|
||||
// Switch back to single
|
||||
const singleToggle = wrapper.find('[aria-label="Switch to single view"]')
|
||||
expect(singleToggle.exists()).toBe(true)
|
||||
|
||||
await singleToggle.trigger('click')
|
||||
await nextTick()
|
||||
|
||||
// Should be back in single mode with main image
|
||||
expect(wrapper.find('[aria-label="Previous image"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('clicking grid image switches to single mode focused on that image', async () => {
|
||||
const wrapper = createGalleriaWrapper([...TEST_IMAGES_SMALL])
|
||||
|
||||
// Switch to grid via focus on image container
|
||||
await findImageContainer(wrapper).trigger('focusin')
|
||||
await nextTick()
|
||||
await wrapper.find('[aria-label="Switch to grid view"]').trigger('click')
|
||||
await nextTick()
|
||||
|
||||
// Click second grid image
|
||||
const gridButtons = wrapper
|
||||
.findAll('button')
|
||||
.filter((btn) => btn.find('img').exists())
|
||||
await gridButtons[1].trigger('click')
|
||||
await nextTick()
|
||||
|
||||
// Should be in single mode showing the second image
|
||||
const mainImg = wrapper.findAll('img')[0]
|
||||
expect(mainImg.attributes('src')).toBe('https://example.com/image1.jpg')
|
||||
})
|
||||
|
||||
it('reverts to single mode when images reduce to one', async () => {
|
||||
const images = ref<GalleryValue>([...TEST_IMAGES_SMALL])
|
||||
const widget = createGalleriaWidget([...TEST_IMAGES_SMALL])
|
||||
const wrapper = mount(DisplayCarousel, {
|
||||
global: { plugins: [createTestingPinia({ createSpy: vi.fn }), i18n] },
|
||||
props: { widget, modelValue: images.value }
|
||||
})
|
||||
|
||||
// Switch to grid via focus on image container
|
||||
await findImageContainer(wrapper).trigger('focusin')
|
||||
await nextTick()
|
||||
await wrapper.find('[aria-label="Switch to grid view"]').trigger('click')
|
||||
await nextTick()
|
||||
|
||||
// Reduce to single image
|
||||
await wrapper.setProps({ modelValue: [TEST_IMAGES_SMALL[0]] })
|
||||
await nextTick()
|
||||
|
||||
// Should revert to single mode (no grid toggle visible)
|
||||
expect(wrapper.find('[aria-label="Switch to single view"]').exists()).toBe(
|
||||
false
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('DisplayCarousel Edge Cases', () => {
|
||||
it('handles empty array gracefully', () => {
|
||||
const wrapper = createGalleriaWrapper([])
|
||||
|
||||
expect(wrapper.find('img').exists()).toBe(false)
|
||||
expect(findThumbnails(wrapper)).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('filters out malformed image objects without valid src', () => {
|
||||
const malformedImages = [{}, { randomProp: 'value' }, null, undefined]
|
||||
const wrapper = createGalleriaWrapper(malformedImages as string[])
|
||||
|
||||
// All filtered out: null/undefined removed, then objects without src filtered
|
||||
expect(wrapper.find('img').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('handles very large image arrays', () => {
|
||||
const largeImageArray = createImageStrings(100)
|
||||
const wrapper = createGalleriaWrapper(largeImageArray)
|
||||
|
||||
const thumbnailButtons = findThumbnails(wrapper)
|
||||
expect(thumbnailButtons).toHaveLength(100)
|
||||
})
|
||||
|
||||
it('handles mixed string and object arrays gracefully', () => {
|
||||
const mixedArray = [
|
||||
'https://example.com/string.jpg',
|
||||
{ itemImageSrc: 'https://example.com/object.jpg' },
|
||||
'https://example.com/another-string.jpg'
|
||||
]
|
||||
expect(() => createGalleriaWrapper(mixedArray as string[])).not.toThrow()
|
||||
})
|
||||
|
||||
it('handles invalid URL strings without crashing', () => {
|
||||
const invalidUrls = ['not-a-url', 'http://', 'ftp://invalid']
|
||||
const wrapper = createGalleriaWrapper(invalidUrls)
|
||||
|
||||
expect(wrapper.find('img').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('filters out empty string URLs', () => {
|
||||
const wrapper = createGalleriaWrapper([''])
|
||||
expect(wrapper.find('img').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,408 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex max-w-full flex-col rounded-lg bg-component-node-widget-background"
|
||||
>
|
||||
<!-- Single Mode -->
|
||||
<template v-if="displayMode === 'single'">
|
||||
<div class="flex flex-col gap-1 p-4">
|
||||
<!-- Main Image Container -->
|
||||
<div
|
||||
ref="imageContainerEl"
|
||||
class="relative flex cursor-pointer items-center justify-center"
|
||||
tabindex="0"
|
||||
@mouseenter="isHovered = true"
|
||||
@mouseleave="isHovered = false"
|
||||
@focusin="isFocused = true"
|
||||
@focusout="handleFocusOut"
|
||||
>
|
||||
<img
|
||||
v-if="activeItem"
|
||||
:src="getItemSrc(activeItem)"
|
||||
:alt="getItemAlt(activeItem, activeIndex)"
|
||||
:class="
|
||||
cn(
|
||||
'h-auto w-full rounded-sm object-contain transition-opacity',
|
||||
showControls && 'opacity-50'
|
||||
)
|
||||
"
|
||||
@load="handleImageLoad"
|
||||
/>
|
||||
|
||||
<!-- Toggle to Grid (hover, top-left) -->
|
||||
<button
|
||||
v-if="showControls && galleryImages.length > 1"
|
||||
:class="toggleButtonClass"
|
||||
class="absolute top-2 left-2"
|
||||
:aria-label="t('g.switchToGridView')"
|
||||
@click="switchToGrid"
|
||||
>
|
||||
<i class="icon-[lucide--layout-grid] size-4" />
|
||||
</button>
|
||||
|
||||
<!-- Action Buttons (hover, top-right) -->
|
||||
<div
|
||||
v-if="showControls && activeItem"
|
||||
class="absolute top-2 right-2 flex gap-1"
|
||||
>
|
||||
<button
|
||||
:class="actionButtonClass"
|
||||
:aria-label="t('g.editOrMaskImage')"
|
||||
@click="handleEditMask"
|
||||
>
|
||||
<i-comfy:mask class="size-4" />
|
||||
</button>
|
||||
<button
|
||||
:class="actionButtonClass"
|
||||
:aria-label="t('g.downloadImage')"
|
||||
@click="handleDownload"
|
||||
>
|
||||
<i class="icon-[lucide--arrow-down-to-line] size-4" />
|
||||
</button>
|
||||
<button
|
||||
:class="actionButtonClass"
|
||||
:aria-label="t('g.removeImage')"
|
||||
@click="handleRemove"
|
||||
>
|
||||
<i class="icon-[lucide--x] size-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Image Dimensions -->
|
||||
<p
|
||||
:class="
|
||||
cn(
|
||||
'text-center text-xs text-component-node-foreground-secondary',
|
||||
!imageDimensions && 'invisible'
|
||||
)
|
||||
"
|
||||
>
|
||||
{{ imageDimensions || '\u00A0' }}
|
||||
</p>
|
||||
|
||||
<!-- Thumbnail Strip with Navigation -->
|
||||
<div
|
||||
v-if="showMultipleImages || showNavButtons"
|
||||
class="flex items-center justify-between"
|
||||
>
|
||||
<!-- Previous Button -->
|
||||
<button
|
||||
v-if="showNavButtons"
|
||||
:class="navButtonClass"
|
||||
:aria-label="t('g.previousImage')"
|
||||
@click="goToPrevious"
|
||||
>
|
||||
<i class="icon-[lucide--chevron-left] size-3.5" />
|
||||
</button>
|
||||
|
||||
<!-- Thumbnails -->
|
||||
<div
|
||||
v-if="showMultipleImages"
|
||||
class="flex min-w-0 flex-1 items-center gap-1 overflow-x-hidden scroll-smooth py-1"
|
||||
>
|
||||
<div
|
||||
v-for="(item, index) in galleryImages"
|
||||
:key="getItemSrc(item)"
|
||||
:ref="(el) => setThumbnailRef(el as HTMLElement | null, index)"
|
||||
:class="
|
||||
cn(
|
||||
'shrink-0 overflow-hidden rounded-lg p-1 transition-colors',
|
||||
index === activeIndex
|
||||
? 'border-2 border-base-foreground'
|
||||
: 'border-2 border-transparent'
|
||||
)
|
||||
"
|
||||
:aria-label="getItemAlt(item, index)"
|
||||
>
|
||||
<img
|
||||
:src="getItemThumbnail(item)"
|
||||
:alt="getItemAlt(item, index)"
|
||||
class="size-10 rounded-sm object-cover"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Next Button -->
|
||||
<button
|
||||
v-if="showNavButtons"
|
||||
:class="navButtonClass"
|
||||
:aria-label="t('g.nextImage')"
|
||||
@click="goToNext"
|
||||
>
|
||||
<i class="icon-[lucide--chevron-right] size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Grid Mode -->
|
||||
<template v-else>
|
||||
<div class="p-4">
|
||||
<div
|
||||
ref="gridContainerEl"
|
||||
class="relative h-72 overflow-x-hidden overflow-y-auto rounded-sm bg-component-node-background"
|
||||
tabindex="0"
|
||||
@mouseenter="isHovered = true"
|
||||
@mouseleave="isHovered = false"
|
||||
@focusin="isFocused = true"
|
||||
@focusout="handleFocusOut"
|
||||
>
|
||||
<!-- Toggle to Single (hover, top-left) -->
|
||||
<button
|
||||
v-if="showControls"
|
||||
:class="toggleButtonClass"
|
||||
class="absolute top-2 left-2 z-10"
|
||||
:aria-label="t('g.switchToSingleView')"
|
||||
@click="switchToSingle"
|
||||
>
|
||||
<i class="icon-[lucide--square] size-4" />
|
||||
</button>
|
||||
|
||||
<div class="flex flex-wrap content-start gap-1">
|
||||
<button
|
||||
v-for="(item, index) in galleryImages"
|
||||
:key="getItemSrc(item)"
|
||||
class="size-14 shrink-0 cursor-pointer overflow-hidden border-0 p-0"
|
||||
:aria-label="getItemAlt(item, index)"
|
||||
@mouseenter="hoveredGridIndex = index"
|
||||
@mouseleave="hoveredGridIndex = -1"
|
||||
@click="selectFromGrid(index)"
|
||||
>
|
||||
<img
|
||||
:src="getItemThumbnail(item)"
|
||||
:alt="getItemAlt(item, index)"
|
||||
:class="
|
||||
cn(
|
||||
'size-full object-cover transition-opacity',
|
||||
hoveredGridIndex === index && 'opacity-50'
|
||||
)
|
||||
"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { downloadFile } from '@/base/common/downloadUtil'
|
||||
import { useMaskEditor } from '@/composables/maskeditor/useMaskEditor'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
export interface GalleryImage {
|
||||
itemImageSrc?: string
|
||||
thumbnailImageSrc?: string
|
||||
src?: string
|
||||
alt?: string
|
||||
}
|
||||
|
||||
export type GalleryValue = string[] | GalleryImage[]
|
||||
|
||||
type DisplayMode = 'single' | 'grid'
|
||||
|
||||
interface GalleryOptions {
|
||||
showItemNavigators?: boolean
|
||||
}
|
||||
|
||||
const value = defineModel<GalleryValue>({ required: true })
|
||||
|
||||
const { widget, nodeId } = defineProps<{
|
||||
widget: SimplifiedWidget<GalleryValue>
|
||||
nodeId?: string
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const maskEditor = useMaskEditor()
|
||||
const nodeOutputStore = useNodeOutputStore()
|
||||
const toastStore = useToastStore()
|
||||
|
||||
const activeIndex = ref(0)
|
||||
const displayMode = ref<DisplayMode>('single')
|
||||
const isHovered = ref(false)
|
||||
const isFocused = ref(false)
|
||||
const hoveredGridIndex = ref(-1)
|
||||
const imageDimensions = ref<string | null>(null)
|
||||
const thumbnailRefs = ref<(HTMLElement | null)[]>([])
|
||||
const imageContainerEl = ref<HTMLDivElement>()
|
||||
const gridContainerEl = ref<HTMLDivElement>()
|
||||
|
||||
const showControls = computed(() => isHovered.value || isFocused.value)
|
||||
|
||||
const options = computed<GalleryOptions>(() => widget.options ?? {})
|
||||
|
||||
const galleryImages = computed<GalleryImage[]>(() => {
|
||||
if (!value.value || !Array.isArray(value.value)) return []
|
||||
|
||||
return value.value.flatMap((item) => {
|
||||
if (item === null || item === undefined) return []
|
||||
const image =
|
||||
typeof item === 'string'
|
||||
? { itemImageSrc: item, thumbnailImageSrc: item }
|
||||
: item
|
||||
return image.itemImageSrc || image.src ? [image] : []
|
||||
})
|
||||
})
|
||||
|
||||
const activeItem = computed(() => galleryImages.value[activeIndex.value])
|
||||
|
||||
const showMultipleImages = computed(() => galleryImages.value.length > 1)
|
||||
|
||||
const showNavButtons = computed(
|
||||
() => options.value.showItemNavigators !== false && showMultipleImages.value
|
||||
)
|
||||
|
||||
const actionButtonClass =
|
||||
'flex size-8 cursor-pointer items-center justify-center rounded-lg border-0 bg-base-foreground text-base-background shadow-md transition-colors hover:bg-base-foreground/90'
|
||||
|
||||
const toggleButtonClass = actionButtonClass
|
||||
|
||||
const navButtonClass =
|
||||
'flex size-8 shrink-0 cursor-pointer items-center justify-center rounded-lg border-0 bg-secondary-background text-component-node-foreground-secondary transition-colors'
|
||||
|
||||
watch(galleryImages, (images) => {
|
||||
thumbnailRefs.value = thumbnailRefs.value.slice(0, images.length)
|
||||
imageDimensions.value = null
|
||||
if (images.length === 0) {
|
||||
activeIndex.value = 0
|
||||
return
|
||||
}
|
||||
if (activeIndex.value >= images.length) {
|
||||
activeIndex.value = images.length - 1
|
||||
}
|
||||
if (images.length <= 1) {
|
||||
displayMode.value = 'single'
|
||||
}
|
||||
})
|
||||
|
||||
function getItemSrc(item: GalleryImage): string {
|
||||
return item.itemImageSrc || item.src || ''
|
||||
}
|
||||
|
||||
function getItemThumbnail(item: GalleryImage): string {
|
||||
return item.thumbnailImageSrc || item.itemImageSrc || item.src || ''
|
||||
}
|
||||
|
||||
function getItemAlt(item: GalleryImage, index: number): string {
|
||||
return (
|
||||
item.alt ||
|
||||
t('g.viewImageOfTotal', {
|
||||
index: index + 1,
|
||||
total: galleryImages.value.length
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
function handleFocusOut(event: FocusEvent) {
|
||||
const container =
|
||||
displayMode.value === 'single'
|
||||
? imageContainerEl.value
|
||||
: gridContainerEl.value
|
||||
if (!container?.contains(event.relatedTarget as Node)) {
|
||||
isFocused.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleImageLoad(event: Event) {
|
||||
if (!(event.target instanceof HTMLImageElement)) return
|
||||
const { naturalWidth, naturalHeight } = event.target
|
||||
if (naturalWidth && naturalHeight) {
|
||||
imageDimensions.value = `${naturalWidth} x ${naturalHeight}`
|
||||
}
|
||||
}
|
||||
|
||||
function setThumbnailRef(el: HTMLElement | null, index: number) {
|
||||
thumbnailRefs.value[index] = el
|
||||
}
|
||||
|
||||
function scrollToActive() {
|
||||
void nextTick(() => {
|
||||
const el = thumbnailRefs.value[activeIndex.value]
|
||||
if (el) {
|
||||
el.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'nearest',
|
||||
inline: 'center'
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function goToPrevious() {
|
||||
activeIndex.value =
|
||||
activeIndex.value > 0
|
||||
? activeIndex.value - 1
|
||||
: galleryImages.value.length - 1
|
||||
imageDimensions.value = null
|
||||
scrollToActive()
|
||||
}
|
||||
|
||||
function goToNext() {
|
||||
activeIndex.value =
|
||||
activeIndex.value < galleryImages.value.length - 1
|
||||
? activeIndex.value + 1
|
||||
: 0
|
||||
imageDimensions.value = null
|
||||
scrollToActive()
|
||||
}
|
||||
|
||||
function switchToGrid() {
|
||||
isHovered.value = false
|
||||
displayMode.value = 'grid'
|
||||
}
|
||||
|
||||
function switchToSingle() {
|
||||
isHovered.value = false
|
||||
displayMode.value = 'single'
|
||||
}
|
||||
|
||||
function selectFromGrid(index: number) {
|
||||
activeIndex.value = index
|
||||
imageDimensions.value = null
|
||||
isHovered.value = false
|
||||
displayMode.value = 'single'
|
||||
scrollToActive()
|
||||
}
|
||||
|
||||
function handleEditMask() {
|
||||
if (!nodeId) return
|
||||
const node = app.rootGraph?.getNodeById(Number(nodeId))
|
||||
if (!node) return
|
||||
maskEditor.openMaskEditor(node)
|
||||
}
|
||||
|
||||
function handleDownload() {
|
||||
const src = activeItem.value ? getItemSrc(activeItem.value) : ''
|
||||
if (!src) return
|
||||
try {
|
||||
downloadFile(src)
|
||||
} catch {
|
||||
toastStore.add({
|
||||
severity: 'error',
|
||||
summary: t('g.error'),
|
||||
detail: t('g.failedToDownloadImage')
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function handleRemove() {
|
||||
if (!nodeId) return
|
||||
const node = app.rootGraph?.getNodeById(Number(nodeId))
|
||||
nodeOutputStore.removeNodeOutputs(nodeId)
|
||||
if (node) {
|
||||
node.imgs = undefined
|
||||
const imageWidget = node.widgets?.find((w) => w.name === 'image')
|
||||
if (imageWidget) {
|
||||
imageWidget.value = ''
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
Reference in New Issue
Block a user