refactor: replace PrimeVue Galleria with custom overlay in ResultGallery

This commit is contained in:
Jin Yi
2026-03-17 14:10:02 +09:00
parent 469d6291a6
commit 81d0ca8781
4 changed files with 242 additions and 189 deletions

View File

@@ -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)
})
})
})

View File

@@ -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>

View File

@@ -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']
>

View File

@@ -139,6 +139,7 @@
"graphNavigation": "Graph navigation",
"dropYourFileOr": "Drop your file or",
"back": "Back",
"previous": "Previous",
"next": "Next",
"submit": "Submit",
"install": "Install",