mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-20 14:30:41 +00:00
refactor: replace PrimeVue Galleria with custom overlay in ResultGallery
This commit is contained in:
@@ -1,14 +1,27 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import Galleria from 'primevue/galleria'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createApp, nextTick } from 'vue'
|
||||
import { nextTick } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import type { ResultItemImpl } from '@/stores/queueStore'
|
||||
|
||||
import ResultGallery from './ResultGallery.vue'
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
g: {
|
||||
close: 'Close',
|
||||
previous: 'Previous',
|
||||
next: 'Next'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
type MockResultItem = Partial<ResultItemImpl> & {
|
||||
filename: string
|
||||
subfolder: string
|
||||
@@ -19,10 +32,10 @@ type MockResultItem = Partial<ResultItemImpl> & {
|
||||
url?: string
|
||||
isImage?: boolean
|
||||
isVideo?: boolean
|
||||
isAudio?: boolean
|
||||
}
|
||||
|
||||
describe('ResultGallery', () => {
|
||||
// Mock ComfyImage and ResultVideo components
|
||||
const mockComfyImage = {
|
||||
name: 'ComfyImage',
|
||||
template: '<div class="mock-comfy-image" data-testid="comfy-image"></div>',
|
||||
@@ -36,7 +49,13 @@ describe('ResultGallery', () => {
|
||||
props: ['result']
|
||||
}
|
||||
|
||||
// Sample gallery items - using mock instances with only required properties
|
||||
const mockResultAudio = {
|
||||
name: 'ResultAudio',
|
||||
template:
|
||||
'<div class="mock-result-audio" data-testid="result-audio"></div>',
|
||||
props: ['result']
|
||||
}
|
||||
|
||||
const mockGalleryItems: MockResultItem[] = [
|
||||
{
|
||||
filename: 'image1.jpg',
|
||||
@@ -46,6 +65,7 @@ describe('ResultGallery', () => {
|
||||
mediaType: 'images',
|
||||
isImage: true,
|
||||
isVideo: false,
|
||||
isAudio: false,
|
||||
url: 'image1.jpg',
|
||||
id: '1'
|
||||
},
|
||||
@@ -57,23 +77,29 @@ describe('ResultGallery', () => {
|
||||
mediaType: 'images',
|
||||
isImage: true,
|
||||
isVideo: false,
|
||||
isAudio: false,
|
||||
url: 'image2.jpg',
|
||||
id: '2'
|
||||
},
|
||||
{
|
||||
filename: 'image3.jpg',
|
||||
subfolder: 'outputs',
|
||||
type: 'output',
|
||||
nodeId: '789' as NodeId,
|
||||
mediaType: 'images',
|
||||
isImage: true,
|
||||
isVideo: false,
|
||||
isAudio: false,
|
||||
url: 'image3.jpg',
|
||||
id: '3'
|
||||
}
|
||||
]
|
||||
|
||||
beforeEach(() => {
|
||||
const app = createApp({})
|
||||
app.use(PrimeVue)
|
||||
|
||||
// Create mock elements for Galleria to find
|
||||
document.body.innerHTML = `
|
||||
<div id="app"></div>
|
||||
`
|
||||
document.body.innerHTML = '<div id="app"></div>'
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
// Clean up any elements added to body
|
||||
document.body.innerHTML = ''
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
@@ -81,11 +107,11 @@ describe('ResultGallery', () => {
|
||||
const mountGallery = (props = {}) => {
|
||||
return mount(ResultGallery, {
|
||||
global: {
|
||||
plugins: [PrimeVue],
|
||||
plugins: [i18n],
|
||||
components: {
|
||||
Galleria,
|
||||
ComfyImage: mockComfyImage,
|
||||
ResultVideo: mockResultVideo
|
||||
ResultVideo: mockResultVideo,
|
||||
ResultAudio: mockResultAudio
|
||||
},
|
||||
stubs: {
|
||||
teleport: true
|
||||
@@ -100,85 +126,121 @@ describe('ResultGallery', () => {
|
||||
})
|
||||
}
|
||||
|
||||
it('renders Galleria component with correct props', async () => {
|
||||
it('renders overlay with role="dialog" and aria-modal', async () => {
|
||||
const wrapper = mountGallery()
|
||||
await nextTick()
|
||||
|
||||
await nextTick() // Wait for component to mount
|
||||
const dialog = wrapper.find('[role="dialog"]')
|
||||
expect(dialog.exists()).toBe(true)
|
||||
expect(dialog.attributes('aria-modal')).toBe('true')
|
||||
})
|
||||
|
||||
const galleria = wrapper.findComponent(Galleria)
|
||||
expect(galleria.exists()).toBe(true)
|
||||
expect(galleria.props('value')).toEqual(mockGalleryItems)
|
||||
expect(galleria.props('showIndicators')).toBe(false)
|
||||
expect(galleria.props('showItemNavigators')).toBe(true)
|
||||
expect(galleria.props('fullScreen')).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()
|
||||
|
||||
expect(wrapper.find('[aria-label="Previous"]').exists()).toBe(true)
|
||||
expect(wrapper.find('[aria-label="Next"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('hides navigation buttons for single item', async () => {
|
||||
const wrapper = mountGallery({
|
||||
allGalleryItems: [mockGalleryItems[0]] as ResultItemImpl[]
|
||||
})
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.find('[aria-label="Previous"]').exists()).toBe(false)
|
||||
expect(wrapper.find('[aria-label="Next"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('shows gallery when activeIndex changes from -1', async () => {
|
||||
const wrapper = mountGallery({ activeIndex: -1 })
|
||||
|
||||
// Initially galleryVisible should be false
|
||||
type GalleryVM = typeof wrapper.vm & {
|
||||
galleryVisible: boolean
|
||||
}
|
||||
const vm = wrapper.vm as GalleryVM
|
||||
expect(vm.galleryVisible).toBe(false)
|
||||
expect(wrapper.find('[data-mask]').exists()).toBe(false)
|
||||
|
||||
// Change activeIndex
|
||||
await wrapper.setProps({ activeIndex: 0 })
|
||||
await nextTick()
|
||||
|
||||
// galleryVisible should become true
|
||||
expect(vm.galleryVisible).toBe(true)
|
||||
expect(wrapper.find('[data-mask]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('should render the component properly', () => {
|
||||
// This is a meta-test to confirm the component mounts properly
|
||||
const wrapper = mountGallery()
|
||||
|
||||
// We can't directly test the compiled CSS, but we can verify the component renders
|
||||
expect(wrapper.exists()).toBe(true)
|
||||
|
||||
// Verify that the Galleria component exists and is properly mounted
|
||||
const galleria = wrapper.findComponent(Galleria)
|
||||
expect(galleria.exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('ensures correct configuration for mobile viewport', async () => {
|
||||
// Mock window.matchMedia to simulate mobile viewport
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
writable: true,
|
||||
value: vi.fn().mockImplementation((query) => ({
|
||||
matches: query.includes('max-width: 768px'),
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn()
|
||||
}))
|
||||
})
|
||||
|
||||
it('emits update:activeIndex with -1 when close button clicked', async () => {
|
||||
const wrapper = mountGallery()
|
||||
await nextTick()
|
||||
|
||||
// Verify mobile media query is working
|
||||
expect(window.matchMedia('(max-width: 768px)').matches).toBe(true)
|
||||
await wrapper.find('[aria-label="Close"]').trigger('click')
|
||||
await nextTick()
|
||||
|
||||
// Check if the component renders with Galleria
|
||||
const galleria = wrapper.findComponent(Galleria)
|
||||
expect(galleria.exists()).toBe(true)
|
||||
|
||||
// Check that our PT props for positioning work correctly
|
||||
interface GalleriaPT {
|
||||
prevButton?: { style?: string }
|
||||
nextButton?: { style?: string }
|
||||
}
|
||||
const pt = galleria.props('pt') as GalleriaPT
|
||||
expect(pt?.prevButton?.style).toContain('position: fixed')
|
||||
expect(pt?.nextButton?.style).toContain('position: fixed')
|
||||
expect(wrapper.emitted('update:activeIndex')?.[0]).toEqual([-1])
|
||||
})
|
||||
|
||||
// Additional tests for interaction could be added once we can reliably
|
||||
// test Galleria component in fullscreen mode
|
||||
describe('keyboard navigation', () => {
|
||||
it('navigates to next item on ArrowRight', async () => {
|
||||
const wrapper = mountGallery({ activeIndex: 0 })
|
||||
await nextTick()
|
||||
|
||||
window.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'ArrowRight', cancelable: true })
|
||||
)
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.emitted('update:activeIndex')?.[0]).toEqual([1])
|
||||
})
|
||||
|
||||
it('navigates to previous item on ArrowLeft', async () => {
|
||||
const wrapper = mountGallery({ activeIndex: 1 })
|
||||
await nextTick()
|
||||
|
||||
window.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'ArrowLeft', cancelable: true })
|
||||
)
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.emitted('update:activeIndex')?.[0]).toEqual([0])
|
||||
})
|
||||
|
||||
it('wraps to last item on ArrowLeft from first', async () => {
|
||||
const wrapper = mountGallery({ activeIndex: 0 })
|
||||
await nextTick()
|
||||
|
||||
window.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'ArrowLeft', cancelable: true })
|
||||
)
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.emitted('update:activeIndex')?.[0]).toEqual([2])
|
||||
})
|
||||
|
||||
it('closes gallery on Escape', async () => {
|
||||
const wrapper = mountGallery({ activeIndex: 0 })
|
||||
await nextTick()
|
||||
|
||||
window.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'Escape', cancelable: true })
|
||||
)
|
||||
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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,57 +1,79 @@
|
||||
<template>
|
||||
<Galleria
|
||||
v-model:visible="galleryVisible"
|
||||
:active-index="activeIndex"
|
||||
:value="allGalleryItems"
|
||||
:show-indicators="false"
|
||||
change-item-on-indicator-hover
|
||||
:show-item-navigators="hasMultiple"
|
||||
full-screen
|
||||
:circular="hasMultiple"
|
||||
:show-thumbnails="false"
|
||||
:pt="{
|
||||
mask: {
|
||||
onMousedown: onMaskMouseDown,
|
||||
onMouseup: onMaskMouseUp,
|
||||
'data-mask': true
|
||||
},
|
||||
prevButton: {
|
||||
style: 'position: fixed !important'
|
||||
},
|
||||
nextButton: {
|
||||
style: 'position: fixed !important'
|
||||
}
|
||||
}"
|
||||
@update:visible="handleVisibilityChange"
|
||||
@update:active-index="handleActiveIndexChange"
|
||||
>
|
||||
<template #item="{ item }">
|
||||
<ComfyImage
|
||||
v-if="item.isImage"
|
||||
:key="item.url"
|
||||
:src="item.url"
|
||||
:contain="false"
|
||||
:alt="item.filename"
|
||||
class="size-auto max-h-[90vh] max-w-[90vw] object-contain"
|
||||
/>
|
||||
<ResultVideo v-else-if="item.isVideo" :result="item" />
|
||||
<ResultAudio v-else-if="item.isAudio" :result="item" />
|
||||
</template>
|
||||
</Galleria>
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="galleryVisible"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
class="fixed inset-0 z-9999 flex items-center justify-center bg-black/90"
|
||||
data-mask
|
||||
@mousedown="onMaskMouseDown"
|
||||
@mouseup="onMaskMouseUp"
|
||||
>
|
||||
<!-- Close Button -->
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon-lg"
|
||||
class="absolute top-4 right-4 z-10 rounded-full"
|
||||
:aria-label="$t('g.close')"
|
||||
@click="close"
|
||||
>
|
||||
<i class="icon-[lucide--x] size-5" />
|
||||
</Button>
|
||||
|
||||
<!-- Previous Button -->
|
||||
<Button
|
||||
v-if="hasMultiple"
|
||||
variant="secondary"
|
||||
size="icon-lg"
|
||||
class="fixed top-1/2 left-4 z-10 -translate-y-1/2 rounded-full"
|
||||
:aria-label="$t('g.previous')"
|
||||
@click="navigateImage(-1)"
|
||||
>
|
||||
<i class="icon-[lucide--chevron-left] size-6" />
|
||||
</Button>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="flex max-h-full max-w-full items-center justify-center">
|
||||
<template v-if="activeItem">
|
||||
<ComfyImage
|
||||
v-if="activeItem.isImage"
|
||||
:key="activeItem.url"
|
||||
:src="activeItem.url"
|
||||
:contain="false"
|
||||
:alt="activeItem.filename"
|
||||
class="size-auto max-h-[90vh] max-w-[90vw] object-contain"
|
||||
/>
|
||||
<ResultVideo v-else-if="activeItem.isVideo" :result="activeItem" />
|
||||
<ResultAudio v-else-if="activeItem.isAudio" :result="activeItem" />
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Next Button -->
|
||||
<Button
|
||||
v-if="hasMultiple"
|
||||
variant="secondary"
|
||||
size="icon-lg"
|
||||
class="fixed top-1/2 right-4 z-10 -translate-y-1/2 rounded-full"
|
||||
:aria-label="$t('g.next')"
|
||||
@click="navigateImage(1)"
|
||||
>
|
||||
<i class="icon-[lucide--chevron-right] size-6" />
|
||||
</Button>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Galleria from 'primevue/galleria'
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { useEventListener } from '@vueuse/core'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import ComfyImage from '@/components/common/ComfyImage.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import type { ResultItemImpl } from '@/stores/queueStore'
|
||||
|
||||
import ResultAudio from './ResultAudio.vue'
|
||||
import ResultVideo from './ResultVideo.vue'
|
||||
|
||||
const galleryVisible = ref(false)
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:activeIndex', value: number): void
|
||||
}>()
|
||||
@@ -61,91 +83,58 @@ const props = defineProps<{
|
||||
activeIndex: number
|
||||
}>()
|
||||
|
||||
const galleryVisible = ref(false)
|
||||
const hasMultiple = computed(() => props.allGalleryItems.length > 1)
|
||||
|
||||
let maskMouseDownTarget: EventTarget | null = null
|
||||
|
||||
const onMaskMouseDown = (event: MouseEvent) => {
|
||||
maskMouseDownTarget = event.target
|
||||
}
|
||||
|
||||
const onMaskMouseUp = (event: MouseEvent) => {
|
||||
const maskEl = document.querySelector('[data-mask]')
|
||||
if (
|
||||
galleryVisible.value &&
|
||||
maskMouseDownTarget === event.target &&
|
||||
maskMouseDownTarget === maskEl
|
||||
) {
|
||||
galleryVisible.value = false
|
||||
handleVisibilityChange(false)
|
||||
}
|
||||
}
|
||||
const activeItem = computed(() => props.allGalleryItems[props.activeIndex])
|
||||
|
||||
watch(
|
||||
() => props.activeIndex,
|
||||
(index) => {
|
||||
if (index !== -1) {
|
||||
galleryVisible.value = true
|
||||
}
|
||||
}
|
||||
galleryVisible.value = index !== -1
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
const handleVisibilityChange = (visible: boolean) => {
|
||||
if (!visible) {
|
||||
emit('update:activeIndex', -1)
|
||||
}
|
||||
function close() {
|
||||
galleryVisible.value = false
|
||||
emit('update:activeIndex', -1)
|
||||
}
|
||||
|
||||
const handleActiveIndexChange = (index: number) => {
|
||||
emit('update:activeIndex', index)
|
||||
}
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (!galleryVisible.value) return
|
||||
|
||||
switch (event.key) {
|
||||
case 'ArrowLeft':
|
||||
navigateImage(-1)
|
||||
break
|
||||
case 'ArrowRight':
|
||||
navigateImage(1)
|
||||
break
|
||||
case 'Escape':
|
||||
galleryVisible.value = false
|
||||
handleVisibilityChange(false)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
const navigateImage = (direction: number) => {
|
||||
function navigateImage(direction: number) {
|
||||
const newIndex =
|
||||
(props.activeIndex + direction + props.allGalleryItems.length) %
|
||||
props.allGalleryItems.length
|
||||
emit('update:activeIndex', newIndex)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
})
|
||||
let maskMouseDownTarget: EventTarget | null = null
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('keydown', handleKeyDown)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style>
|
||||
/* PrimeVue's galleria teleports the fullscreen gallery out of subtree so we
|
||||
cannot use scoped style here. */
|
||||
.p-galleria-close-button {
|
||||
/* Set z-index so the close button doesn't get hidden behind the image when image is large */
|
||||
z-index: 1;
|
||||
function onMaskMouseDown(event: MouseEvent) {
|
||||
maskMouseDownTarget = event.target
|
||||
}
|
||||
|
||||
/* Mobile/tablet specific fixes */
|
||||
@media screen and (max-width: 768px) {
|
||||
.p-galleria-prev-button,
|
||||
.p-galleria-next-button {
|
||||
z-index: 2;
|
||||
function onMaskMouseUp(event: MouseEvent) {
|
||||
if (
|
||||
maskMouseDownTarget === event.target &&
|
||||
(event.target as HTMLElement)?.hasAttribute('data-mask')
|
||||
) {
|
||||
close()
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
useEventListener(window, 'keydown', (event: KeyboardEvent) => {
|
||||
if (!galleryVisible.value) return
|
||||
|
||||
const actions: Record<string, () => void> = {
|
||||
ArrowLeft: () => navigateImage(-1),
|
||||
ArrowRight: () => navigateImage(1),
|
||||
Escape: () => close()
|
||||
}
|
||||
|
||||
const action = actions[event.key]
|
||||
if (action) {
|
||||
event.preventDefault()
|
||||
action()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -29,6 +29,7 @@ export const buttonVariants = cva({
|
||||
md: 'h-8 rounded-lg p-2 text-xs',
|
||||
lg: 'h-10 rounded-lg px-4 py-2 text-sm',
|
||||
icon: 'size-8',
|
||||
'icon-lg': 'size-10',
|
||||
'icon-sm': 'size-5 p-0',
|
||||
unset: ''
|
||||
}
|
||||
@@ -54,7 +55,7 @@ const variants = [
|
||||
'overlay-white',
|
||||
'gradient'
|
||||
] as const satisfies Array<ButtonVariants['variant']>
|
||||
const sizes = ['sm', 'md', 'lg', 'icon', 'icon-sm'] as const satisfies Array<
|
||||
const sizes = ['sm', 'md', 'lg', 'icon', 'icon-lg', 'icon-sm'] as const satisfies Array<
|
||||
ButtonVariants['size']
|
||||
>
|
||||
|
||||
|
||||
@@ -139,6 +139,7 @@
|
||||
"graphNavigation": "Graph navigation",
|
||||
"dropYourFileOr": "Drop your file or",
|
||||
"back": "Back",
|
||||
"previous": "Previous",
|
||||
"next": "Next",
|
||||
"submit": "Submit",
|
||||
"install": "Install",
|
||||
|
||||
Reference in New Issue
Block a user