mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-02-10 18:10:08 +00:00
Queue preview gallery (#519)
* Custom preview event * Plub event * Basic gallery * Gallery nits * Navigate with keyboard keys
This commit is contained in:
@@ -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()
|
||||
|
||||
96
src/components/sidebar/tabs/queue/ResultGallery.vue
Normal file
96
src/components/sidebar/tabs/queue/ResultGallery.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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':
|
||||
|
||||
Reference in New Issue
Block a user