From 8db6fb7733cd4aeeae48f4e99714d835b754d7dc Mon Sep 17 00:00:00 2001 From: Dante Date: Thu, 12 Mar 2026 18:10:42 +0900 Subject: [PATCH] feat: replace PrimeVue Galleria/Skeleton with custom DisplayCarousel and ImagePreview (#9712) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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 스크린샷 2026-03-12 오후 2 01 51 스크린샷 2026-03-12 오후 2 04 47 스크린샷 2026-03-12 오후 2 04 49 스크린샷 2026-03-12 오후 2 05 39 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.6 --- src/locales/en/main.json | 4 + .../components/ImagePreview.stories.ts | 61 +++ .../vueNodes/components/ImagePreview.test.ts | 24 +- .../vueNodes/components/ImagePreview.vue | 38 +- .../components/DisplayCarousel.stories.ts | 159 ++++++ .../components/DisplayCarousel.test.ts | 455 ++++++++++++++++++ .../widgets/components/DisplayCarousel.vue | 408 ++++++++++++++++ 7 files changed, 1121 insertions(+), 28 deletions(-) create mode 100644 src/renderer/extensions/vueNodes/components/ImagePreview.stories.ts create mode 100644 src/renderer/extensions/vueNodes/widgets/components/DisplayCarousel.stories.ts create mode 100644 src/renderer/extensions/vueNodes/widgets/components/DisplayCarousel.test.ts create mode 100644 src/renderer/extensions/vueNodes/widgets/components/DisplayCarousel.vue diff --git a/src/locales/en/main.json b/src/locales/en/main.json index 3d5761eff1..0c06fb3c93 100644 --- a/src/locales/en/main.json +++ b/src/locales/en/main.json @@ -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", diff --git a/src/renderer/extensions/vueNodes/components/ImagePreview.stories.ts b/src/renderer/extensions/vueNodes/components/ImagePreview.stories.ts new file mode 100644 index 0000000000..1e2796b0bc --- /dev/null +++ b/src/renderer/extensions/vueNodes/components/ImagePreview.stories.ts @@ -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 = { + 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: + '
' + }) + ] +} + +export default meta +type Story = StoryObj + +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` + ) + } +} diff --git a/src/renderer/extensions/vueNodes/components/ImagePreview.test.ts b/src/renderer/extensions/vueNodes/components/ImagePreview.test.ts index b781dc6fda..756e3ca8f0 100644 --- a/src/renderer/extensions/vueNodes/components/ImagePreview.test.ts +++ b/src/renderer/extensions/vueNodes/components/ImagePreview.test.ts @@ -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() diff --git a/src/renderer/extensions/vueNodes/components/ImagePreview.vue b/src/renderer/extensions/vueNodes/components/ImagePreview.vue index 23da04810b..157d0328a7 100644 --- a/src/renderer/extensions/vueNodes/components/ImagePreview.vue +++ b/src/renderer/extensions/vueNodes/components/ImagePreview.vue @@ -7,7 +7,7 @@ @@ -119,15 +124,16 @@