mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-20 06:20:11 +00:00
refactor: rename ResultGallery to MediaLightbox and address code review
- Rename ResultGallery to MediaLightbox across all references - Replace window keydown listener with @keydown on dialog element - Remove redundant toBeVisible check in Playwright test - Remove change detector tests (renders close button, prevents default) - Dispatch keyboard events on dialog element in unit tests - Wire zoom event to lightbox in MediaAssetCard story - Add MediaLightbox Storybook story - Sort button icon sizes (icon-sm, icon, icon-lg)
This commit is contained in:
@@ -3,7 +3,7 @@ import { expect } from '@playwright/test'
|
||||
import type { ComfyPage } from '../fixtures/ComfyPage'
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
|
||||
test.describe('ResultGallery', { tag: ['@slow'] }, () => {
|
||||
test.describe('MediaLightbox', { tag: ['@slow'] }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
@@ -39,9 +39,7 @@ test.describe('ResultGallery', { tag: ['@slow'] }, () => {
|
||||
|
||||
// Hover to reveal zoom button, then click it
|
||||
await assetCard.hover()
|
||||
const zoomButton = assetCard.getByLabel('Zoom in')
|
||||
await expect(zoomButton).toBeVisible()
|
||||
await zoomButton.click()
|
||||
await assetCard.getByLabel('Zoom in').click()
|
||||
|
||||
const gallery = comfyPage.page.getByRole('dialog')
|
||||
await expect(gallery).toBeVisible()
|
||||
|
||||
@@ -60,7 +60,7 @@ const mountComponent = (
|
||||
stubs: {
|
||||
QueueOverlayExpanded: QueueOverlayExpandedStub,
|
||||
QueueOverlayActive: true,
|
||||
ResultGallery: true
|
||||
MediaLightbox: true
|
||||
},
|
||||
directives: {
|
||||
tooltip: () => {}
|
||||
|
||||
@@ -45,7 +45,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ResultGallery
|
||||
<MediaLightbox
|
||||
v-model:active-index="galleryActiveIndex"
|
||||
:all-gallery-items="galleryItems"
|
||||
/>
|
||||
@@ -57,7 +57,7 @@ import { useI18n } from 'vue-i18n'
|
||||
|
||||
import QueueOverlayActive from '@/components/queue/QueueOverlayActive.vue'
|
||||
import QueueOverlayExpanded from '@/components/queue/QueueOverlayExpanded.vue'
|
||||
import ResultGallery from '@/components/sidebar/tabs/queue/ResultGallery.vue'
|
||||
import MediaLightbox from '@/components/sidebar/tabs/queue/MediaLightbox.vue'
|
||||
import { useJobList } from '@/composables/queue/useJobList'
|
||||
import type { JobListItem } from '@/composables/queue/useJobList'
|
||||
import { useQueueClearHistoryDialog } from '@/composables/queue/useQueueClearHistoryDialog'
|
||||
|
||||
@@ -170,7 +170,7 @@
|
||||
</div>
|
||||
</template>
|
||||
</SidebarTabTemplate>
|
||||
<ResultGallery
|
||||
<MediaLightbox
|
||||
v-model:active-index="galleryActiveIndex"
|
||||
:all-gallery-items="galleryItems"
|
||||
/>
|
||||
@@ -220,7 +220,7 @@ import AssetsSidebarGridView from '@/components/sidebar/tabs/AssetsSidebarGridVi
|
||||
import AssetsSidebarListView from '@/components/sidebar/tabs/AssetsSidebarListView.vue'
|
||||
import SidebarTabTemplate from '@/components/sidebar/tabs/SidebarTabTemplate.vue'
|
||||
import Skeleton from '@/components/ui/skeleton/Skeleton.vue'
|
||||
import ResultGallery from '@/components/sidebar/tabs/queue/ResultGallery.vue'
|
||||
import MediaLightbox from '@/components/sidebar/tabs/queue/MediaLightbox.vue'
|
||||
import Tab from '@/components/tab/Tab.vue'
|
||||
import TabList from '@/components/tab/TabList.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
|
||||
@@ -58,7 +58,7 @@
|
||||
:entries="jobMenuEntries"
|
||||
@action="onJobMenuAction"
|
||||
/>
|
||||
<ResultGallery
|
||||
<MediaLightbox
|
||||
v-model:active-index="galleryActiveIndex"
|
||||
:all-gallery-items="galleryItems"
|
||||
/>
|
||||
@@ -83,7 +83,7 @@ import { useQueueClearHistoryDialog } from '@/composables/queue/useQueueClearHis
|
||||
import { useResultGallery } from '@/composables/queue/useResultGallery'
|
||||
import { useErrorHandling } from '@/composables/useErrorHandling'
|
||||
import SidebarTabTemplate from '@/components/sidebar/tabs/SidebarTabTemplate.vue'
|
||||
import ResultGallery from '@/components/sidebar/tabs/queue/ResultGallery.vue'
|
||||
import MediaLightbox from '@/components/sidebar/tabs/queue/MediaLightbox.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
|
||||
@@ -8,7 +8,7 @@ enableAutoUnmount(afterEach)
|
||||
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import type { ResultItemImpl } from '@/stores/queueStore'
|
||||
|
||||
import ResultGallery from './ResultGallery.vue'
|
||||
import MediaLightbox from './MediaLightbox.vue'
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
@@ -38,7 +38,7 @@ type MockResultItem = Partial<ResultItemImpl> & {
|
||||
isAudio?: boolean
|
||||
}
|
||||
|
||||
describe('ResultGallery', () => {
|
||||
describe('MediaLightbox', () => {
|
||||
const mockComfyImage = {
|
||||
name: 'ComfyImage',
|
||||
template: '<div class="mock-comfy-image" data-testid="comfy-image"></div>',
|
||||
@@ -108,7 +108,7 @@ describe('ResultGallery', () => {
|
||||
})
|
||||
|
||||
const mountGallery = (props = {}) => {
|
||||
return mount(ResultGallery, {
|
||||
return mount(MediaLightbox, {
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
components: {
|
||||
@@ -138,13 +138,6 @@ describe('ResultGallery', () => {
|
||||
expect(dialog.attributes('aria-modal')).toBe('true')
|
||||
})
|
||||
|
||||
it('renders close button', async () => {
|
||||
const wrapper = mountGallery()
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.find('[aria-label="Close"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('shows navigation buttons when multiple items', async () => {
|
||||
const wrapper = mountGallery()
|
||||
await nextTick()
|
||||
@@ -189,9 +182,9 @@ describe('ResultGallery', () => {
|
||||
const wrapper = mountGallery({ activeIndex: 0 })
|
||||
await nextTick()
|
||||
|
||||
window.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'ArrowRight', cancelable: true })
|
||||
)
|
||||
await wrapper
|
||||
.find('[role="dialog"]')
|
||||
.trigger('keydown', { key: 'ArrowRight' })
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.emitted('update:activeIndex')?.[0]).toEqual([1])
|
||||
@@ -201,9 +194,9 @@ describe('ResultGallery', () => {
|
||||
const wrapper = mountGallery({ activeIndex: 1 })
|
||||
await nextTick()
|
||||
|
||||
window.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'ArrowLeft', cancelable: true })
|
||||
)
|
||||
await wrapper
|
||||
.find('[role="dialog"]')
|
||||
.trigger('keydown', { key: 'ArrowLeft' })
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.emitted('update:activeIndex')?.[0]).toEqual([0])
|
||||
@@ -213,9 +206,9 @@ describe('ResultGallery', () => {
|
||||
const wrapper = mountGallery({ activeIndex: 0 })
|
||||
await nextTick()
|
||||
|
||||
window.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'ArrowLeft', cancelable: true })
|
||||
)
|
||||
await wrapper
|
||||
.find('[role="dialog"]')
|
||||
.trigger('keydown', { key: 'ArrowLeft' })
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.emitted('update:activeIndex')?.[0]).toEqual([2])
|
||||
@@ -225,25 +218,12 @@ describe('ResultGallery', () => {
|
||||
const wrapper = mountGallery({ activeIndex: 0 })
|
||||
await nextTick()
|
||||
|
||||
window.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'Escape', cancelable: true })
|
||||
)
|
||||
await wrapper
|
||||
.find('[role="dialog"]')
|
||||
.trigger('keydown', { key: 'Escape' })
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.emitted('update:activeIndex')?.[0]).toEqual([-1])
|
||||
})
|
||||
|
||||
it('prevents default on arrow keys', async () => {
|
||||
mountGallery({ activeIndex: 0 })
|
||||
await nextTick()
|
||||
|
||||
const event = new KeyboardEvent('keydown', {
|
||||
key: 'ArrowRight',
|
||||
cancelable: true
|
||||
})
|
||||
window.dispatchEvent(event)
|
||||
|
||||
expect(event.defaultPrevented).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -11,6 +11,7 @@
|
||||
data-mask
|
||||
@mousedown="onMaskMouseDown"
|
||||
@mouseup="onMaskMouseUp"
|
||||
@keydown="handleKeyDown"
|
||||
>
|
||||
<!-- Close Button -->
|
||||
<Button
|
||||
@@ -67,7 +68,6 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useEventListener } from '@vueuse/core'
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
|
||||
import ComfyImage from '@/components/common/ComfyImage.vue'
|
||||
@@ -133,9 +133,7 @@ function onMaskMouseUp(event: MouseEvent) {
|
||||
}
|
||||
}
|
||||
|
||||
useEventListener(window, 'keydown', (event: KeyboardEvent) => {
|
||||
if (!galleryVisible.value) return
|
||||
|
||||
function handleKeyDown(event: KeyboardEvent) {
|
||||
const actions: Record<string, () => void> = {
|
||||
ArrowLeft: () => navigateImage(-1),
|
||||
ArrowRight: () => navigateImage(1),
|
||||
@@ -147,5 +145,5 @@ useEventListener(window, 'keydown', (event: KeyboardEvent) => {
|
||||
event.preventDefault()
|
||||
action()
|
||||
}
|
||||
})
|
||||
}
|
||||
</script>
|
||||
@@ -28,9 +28,9 @@ export const buttonVariants = cva({
|
||||
sm: 'h-6 rounded-sm px-2 py-1 text-xs',
|
||||
md: 'h-8 rounded-lg p-2 text-xs',
|
||||
lg: 'h-10 rounded-lg px-4 py-2 text-sm',
|
||||
'icon-sm': 'size-5 p-0',
|
||||
icon: 'size-8',
|
||||
'icon-lg': 'size-10',
|
||||
'icon-sm': 'size-5 p-0',
|
||||
unset: ''
|
||||
}
|
||||
},
|
||||
@@ -59,9 +59,9 @@ const sizes = [
|
||||
'sm',
|
||||
'md',
|
||||
'lg',
|
||||
'icon-sm',
|
||||
'icon',
|
||||
'icon-lg',
|
||||
'icon-sm'
|
||||
'icon-lg'
|
||||
] as const satisfies Array<ButtonVariants['size']>
|
||||
|
||||
export const FOR_STORIES = { variants, sizes } as const
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
|
||||
import ResultGallery from '@/components/sidebar/tabs/queue/ResultGallery.vue'
|
||||
import MediaLightbox from '@/components/sidebar/tabs/queue/MediaLightbox.vue'
|
||||
import { getMediaTypeFromFilename } from '@/utils/formatUtil'
|
||||
|
||||
import { useMediaAssetGalleryStore } from '../composables/useMediaAssetGalleryStore'
|
||||
import type { AssetItem } from '../schemas/assetSchema'
|
||||
@@ -10,16 +11,26 @@ const meta: Meta<typeof MediaAssetCard> = {
|
||||
title: 'Platform/Assets/MediaAssetCard',
|
||||
component: MediaAssetCard,
|
||||
decorators: [
|
||||
() => ({
|
||||
components: { ResultGallery },
|
||||
(_story, context) => ({
|
||||
components: { MediaLightbox },
|
||||
setup() {
|
||||
const galleryStore = useMediaAssetGalleryStore()
|
||||
;(context.args as Record<string, unknown>).onZoom = (
|
||||
asset: AssetItem
|
||||
) => {
|
||||
const kind = getMediaTypeFromFilename(asset.name)
|
||||
galleryStore.openSingle({
|
||||
...asset,
|
||||
kind,
|
||||
src: asset.preview_url || ''
|
||||
})
|
||||
}
|
||||
return { galleryStore }
|
||||
},
|
||||
template: `
|
||||
<div>
|
||||
<story />
|
||||
<ResultGallery
|
||||
<MediaLightbox
|
||||
v-model:active-index="galleryStore.activeIndex"
|
||||
:all-gallery-items="galleryStore.items"
|
||||
/>
|
||||
|
||||
134
src/platform/assets/components/MediaLightbox.stories.ts
Normal file
134
src/platform/assets/components/MediaLightbox.stories.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import MediaLightbox from '@/components/sidebar/tabs/queue/MediaLightbox.vue'
|
||||
import type { ResultItemImpl } from '@/stores/queueStore'
|
||||
|
||||
type MockItem = Pick<
|
||||
ResultItemImpl,
|
||||
'filename' | 'url' | 'isImage' | 'isVideo' | 'isAudio'
|
||||
>
|
||||
|
||||
const SAMPLE_IMAGES: MockItem[] = [
|
||||
{
|
||||
filename: 'landscape.jpg',
|
||||
url: 'https://i.imgur.com/OB0y6MR.jpg',
|
||||
isImage: true,
|
||||
isVideo: false,
|
||||
isAudio: false
|
||||
},
|
||||
{
|
||||
filename: 'portrait.jpg',
|
||||
url: 'https://i.imgur.com/CzXTtJV.jpg',
|
||||
isImage: true,
|
||||
isVideo: false,
|
||||
isAudio: false
|
||||
},
|
||||
{
|
||||
filename: 'nature.jpg',
|
||||
url: 'https://farm9.staticflickr.com/8505/8441256181_4e98d8bff5_z_d.jpg',
|
||||
isImage: true,
|
||||
isVideo: false,
|
||||
isAudio: false
|
||||
}
|
||||
]
|
||||
|
||||
const meta: Meta<typeof MediaLightbox> = {
|
||||
title: 'Platform/Assets/MediaLightbox',
|
||||
component: MediaLightbox
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const MultipleImages: Story = {
|
||||
render: () => ({
|
||||
components: { MediaLightbox },
|
||||
setup() {
|
||||
const activeIndex = ref(0)
|
||||
const items = SAMPLE_IMAGES as ResultItemImpl[]
|
||||
return { activeIndex, items }
|
||||
},
|
||||
template: `
|
||||
<div>
|
||||
<p class="mb-4 text-sm text-muted-foreground">
|
||||
Use arrow keys to navigate, Escape to close. Click backdrop to close.
|
||||
</p>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
v-for="(item, i) in items"
|
||||
:key="i"
|
||||
class="rounded border px-3 py-1 text-sm"
|
||||
@click="activeIndex = i"
|
||||
>
|
||||
Open {{ item.filename }}
|
||||
</button>
|
||||
</div>
|
||||
<MediaLightbox
|
||||
v-model:active-index="activeIndex"
|
||||
:all-gallery-items="items"
|
||||
/>
|
||||
</div>
|
||||
`
|
||||
})
|
||||
}
|
||||
|
||||
export const SingleImage: Story = {
|
||||
render: () => ({
|
||||
components: { MediaLightbox },
|
||||
setup() {
|
||||
const activeIndex = ref(0)
|
||||
const items = [SAMPLE_IMAGES[0]] as ResultItemImpl[]
|
||||
return { activeIndex, items }
|
||||
},
|
||||
template: `
|
||||
<div>
|
||||
<p class="mb-4 text-sm text-muted-foreground">
|
||||
Single image — no navigation buttons shown.
|
||||
</p>
|
||||
<button
|
||||
class="rounded border px-3 py-1 text-sm"
|
||||
@click="activeIndex = 0"
|
||||
>
|
||||
Open lightbox
|
||||
</button>
|
||||
<MediaLightbox
|
||||
v-model:active-index="activeIndex"
|
||||
:all-gallery-items="items"
|
||||
/>
|
||||
</div>
|
||||
`
|
||||
})
|
||||
}
|
||||
|
||||
export const Closed: Story = {
|
||||
render: () => ({
|
||||
components: { MediaLightbox },
|
||||
setup() {
|
||||
const activeIndex = ref(-1)
|
||||
const items = SAMPLE_IMAGES as ResultItemImpl[]
|
||||
return { activeIndex, items }
|
||||
},
|
||||
template: `
|
||||
<div>
|
||||
<p class="mb-4 text-sm text-muted-foreground">
|
||||
Lightbox is closed (activeIndex = -1). Click a button to open.
|
||||
</p>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
v-for="(item, i) in items"
|
||||
:key="i"
|
||||
class="rounded border px-3 py-1 text-sm"
|
||||
@click="activeIndex = i"
|
||||
>
|
||||
{{ item.filename }}
|
||||
</button>
|
||||
</div>
|
||||
<MediaLightbox
|
||||
v-model:active-index="activeIndex"
|
||||
:all-gallery-items="items"
|
||||
/>
|
||||
</div>
|
||||
`
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user