Queue preview gallery (#519)

* Custom preview event

* Plub event

* Basic gallery

* Gallery nits

* Navigate with keyboard keys
This commit is contained in:
Chenlei Hu
2024-08-18 20:42:42 -04:00
committed by GitHub
parent 4e1f14139b
commit 22e2628479
5 changed files with 170 additions and 36 deletions

View File

@@ -30,6 +30,7 @@
:task="task"
:isFlatTask="isExpanded"
@contextmenu="handleContextMenu"
@preview="handlePreview"
/>
</div>
<div ref="loadMoreTrigger" style="height: 1px" />
@@ -45,6 +46,10 @@
</SideBarTabTemplate>
<ConfirmPopup />
<ContextMenu ref="menu" :model="menuItems" />
<ResultGallery
v-model:activeIndex="galleryActiveIndex"
:allGalleryItems="allGalleryItems"
/>
</template>
<script setup lang="ts">
@@ -58,6 +63,7 @@ import ConfirmPopup from 'primevue/confirmpopup'
import ContextMenu from 'primevue/contextmenu'
import type { MenuItem } from 'primevue/menuitem'
import TaskItem from './queue/TaskItem.vue'
import ResultGallery from './queue/ResultGallery.vue'
import SideBarTabTemplate from './SidebarTabTemplate.vue'
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
import { TaskItemImpl, useQueueStore } from '@/stores/queueStore'
@@ -72,6 +78,7 @@ const isExpanded = ref(false)
const visibleTasks = ref<TaskItemImpl[]>([])
const scrollContainer = ref<HTMLElement | null>(null)
const loadMoreTrigger = ref<HTMLElement | null>(null)
const galleryActiveIndex = ref(-1)
const ITEMS_PER_PAGE = 8
const SCROLL_THRESHOLD = 100 // pixels from bottom to trigger load
@@ -79,6 +86,12 @@ const SCROLL_THRESHOLD = 100 // pixels from bottom to trigger load
const allTasks = computed(() =>
isExpanded.value ? queueStore.flatTasks : queueStore.tasks
)
const allGalleryItems = computed(() =>
allTasks.value.flatMap((task: TaskItemImpl) => {
const previewOutput = task.previewOutput
return previewOutput ? [previewOutput] : []
})
)
const loadMoreItems = () => {
const currentLength = visibleTasks.value.length
@@ -189,6 +202,12 @@ const handleContextMenu = ({
menu.value?.show(event)
}
const handlePreview = (task: TaskItemImpl) => {
galleryActiveIndex.value = allGalleryItems.value.findIndex(
(item) => item.url === task.previewOutput?.url
)
}
onMounted(() => {
api.addEventListener('status', onStatus)
queueStore.update()

View File

@@ -0,0 +1,96 @@
<template>
<Galleria
v-model:visible="galleryVisible"
@update:visible="handleVisibilityChange"
:activeIndex="activeIndex"
@update:activeIndex="handleActiveIndexChange"
:value="allGalleryItems"
:showIndicators="false"
changeItemOnIndicatorHover
showItemNavigators
fullScreen
circular
:showThumbnails="false"
>
<template #item="{ item }">
<img :src="item.url" alt="gallery item" class="galleria-image" />
</template>
</Galleria>
</template>
<script setup lang="ts">
import { defineProps, ref, watch, onMounted, onUnmounted } from 'vue'
import Galleria from 'primevue/galleria'
import { ResultItemImpl } from '@/stores/queueStore'
const galleryVisible = ref(false)
const emit = defineEmits<{
(e: 'update:activeIndex', value: number): void
}>()
const props = defineProps<{
allGalleryItems: ResultItemImpl[]
activeIndex: number
}>()
watch(
() => props.activeIndex,
(index) => {
if (index !== -1) {
galleryVisible.value = true
}
}
)
const handleVisibilityChange = (visible: boolean) => {
if (!visible) {
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
break
}
}
const navigateImage = (direction: number) => {
const newIndex =
(props.activeIndex + direction + props.allGalleryItems.length) %
props.allGalleryItems.length
emit('update:activeIndex', newIndex)
}
onMounted(() => {
window.addEventListener('keydown', handleKeyDown)
})
onUnmounted(() => {
window.removeEventListener('keydown', handleKeyDown)
})
</script>
<style scoped>
.galleria-image {
max-width: 100%;
max-height: 100%;
object-fit: contain;
/* Set z-index so the close button doesn't get hidden behind the image when image is large */
z-index: -1;
}
</style>

View File

@@ -1,14 +1,18 @@
<template>
<div class="result-container" ref="resultContainer">
<Image
<template
v-if="result.mediaType === 'images' || result.mediaType === 'gifs'"
:src="result.url"
alt="Task Output"
width="100%"
height="100%"
preview
:pt="{ previewMask: { class: 'image-preview-mask' } }"
/>
>
<div class="image-preview-mask">
<Button
icon="pi pi-eye"
severity="secondary"
@click="emit('preview', result)"
rounded
/>
</div>
<img :src="result.url" class="task-output-image" />
</template>
<!-- TODO: handle more media types -->
<div v-else class="task-result-preview">
<i class="pi pi-file"></i>
@@ -19,18 +23,22 @@
<script setup lang="ts">
import { ResultItemImpl } from '@/stores/queueStore'
import Image from 'primevue/image'
import Button from 'primevue/button'
import { onMounted, ref } from 'vue'
const props = defineProps<{
result: ResultItemImpl
}>()
const emit = defineEmits<{
(e: 'preview', ResultItemImpl): void
}>()
const resultContainer = ref<HTMLElement | null>(null)
onMounted(() => {
if (props.result.mediaType === 'images') {
resultContainer.value.querySelectorAll('img').forEach((img) => {
resultContainer.value?.querySelectorAll('img').forEach((img) => {
img.draggable = true
})
}
@@ -39,43 +47,33 @@ onMounted(() => {
<style scoped>
.result-container {
width: 100%;
height: 100%;
aspect-ratio: 1 / 1;
overflow: hidden;
position: relative;
}
:deep(img) {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
.task-output-image {
width: 100%;
height: 100%;
object-fit: cover;
object-position: center;
}
.p-image-preview {
position: static;
display: contents;
}
:deep(.image-preview-mask) {
.image-preview-mask {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
width: auto;
height: auto;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
padding: 10px;
cursor: pointer;
background: rgba(0, 0, 0, 0.5);
color: var(--p-image-preview-mask-color);
transition:
opacity var(--p-image-transition-duration),
background var(--p-image-transition-duration);
border-radius: 50%;
transition: opacity 0.3s ease;
}
.result-container:hover .image-preview-mask {
opacity: 1;
}
</style>

View File

@@ -1,9 +1,13 @@
<template>
<div class="task-item" @contextmenu="handleContextMenu">
<div class="task-result-preview">
<div v-if="task.displayStatus === TaskItemDisplayStatus.Completed">
<ResultItem v-if="flatOutputs.length" :result="flatOutputs[0]" />
</div>
<template v-if="task.displayStatus === TaskItemDisplayStatus.Completed">
<ResultItem
v-if="flatOutputs.length"
:result="flatOutputs[0]"
@preview="handlePreview"
/>
</template>
<i
v-else-if="task.displayStatus === TaskItemDisplayStatus.Running"
class="pi pi-spin pi-spinner"
@@ -55,13 +59,18 @@ const props = defineProps<{
const flatOutputs = props.task.flatOutputs
const emit = defineEmits<{
(e: 'contextmenu', { task: TaskItemImpl, event: MouseEvent }): void
(e: 'contextmenu', value: { task: TaskItemImpl; event: MouseEvent }): void
(e: 'preview', value: TaskItemImpl): void
}>()
const handleContextMenu = (e: MouseEvent) => {
emit('contextmenu', { task: props.task, event: e })
}
const handlePreview = () => {
emit('preview', props.task)
}
const taskTagSeverity = (status: TaskItemDisplayStatus) => {
switch (status) {
case TaskItemDisplayStatus.Pending:

View File

@@ -38,7 +38,15 @@ export class ResultItemImpl {
get url(): string {
return api.apiURL(`/view?filename=${encodeURIComponent(this.filename)}&type=${this.type}&
subfolder=${encodeURIComponent(this.subfolder || '')}&t=${+new Date()}`)
subfolder=${encodeURIComponent(this.subfolder || '')}`)
}
get urlWithTimestamp(): string {
return `${this.url}&t=${+new Date()}`
}
get supportsPreview(): boolean {
return ['images', 'gifs'].includes(this.mediaType)
}
}
@@ -65,6 +73,10 @@ export class TaskItemImpl {
)
}
get previewOutput(): ResultItemImpl | undefined {
return this.flatOutputs.find((output) => output.supportsPreview)
}
get apiTaskType(): APITaskType {
switch (this.taskType) {
case 'Running':