Skip to content

Commit 22e2628

Browse files
authored
Queue preview gallery (#519)
* Custom preview event * Plub event * Basic gallery * Gallery nits * Navigate with keyboard keys
1 parent 4e1f141 commit 22e2628

File tree

5 files changed

+170
-36
lines changed

5 files changed

+170
-36
lines changed

src/components/sidebar/tabs/QueueSidebarTab.vue

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
:task="task"
3131
:isFlatTask="isExpanded"
3232
@contextmenu="handleContextMenu"
33+
@preview="handlePreview"
3334
/>
3435
</div>
3536
<div ref="loadMoreTrigger" style="height: 1px" />
@@ -45,6 +46,10 @@
4546
</SideBarTabTemplate>
4647
<ConfirmPopup />
4748
<ContextMenu ref="menu" :model="menuItems" />
49+
<ResultGallery
50+
v-model:activeIndex="galleryActiveIndex"
51+
:allGalleryItems="allGalleryItems"
52+
/>
4853
</template>
4954

5055
<script setup lang="ts">
@@ -58,6 +63,7 @@ import ConfirmPopup from 'primevue/confirmpopup'
5863
import ContextMenu from 'primevue/contextmenu'
5964
import type { MenuItem } from 'primevue/menuitem'
6065
import TaskItem from './queue/TaskItem.vue'
66+
import ResultGallery from './queue/ResultGallery.vue'
6167
import SideBarTabTemplate from './SidebarTabTemplate.vue'
6268
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
6369
import { TaskItemImpl, useQueueStore } from '@/stores/queueStore'
@@ -72,13 +78,20 @@ const isExpanded = ref(false)
7278
const visibleTasks = ref<TaskItemImpl[]>([])
7379
const scrollContainer = ref<HTMLElement | null>(null)
7480
const loadMoreTrigger = ref<HTMLElement | null>(null)
81+
const galleryActiveIndex = ref(-1)
7582
7683
const ITEMS_PER_PAGE = 8
7784
const SCROLL_THRESHOLD = 100 // pixels from bottom to trigger load
7885
7986
const allTasks = computed(() =>
8087
isExpanded.value ? queueStore.flatTasks : queueStore.tasks
8188
)
89+
const allGalleryItems = computed(() =>
90+
allTasks.value.flatMap((task: TaskItemImpl) => {
91+
const previewOutput = task.previewOutput
92+
return previewOutput ? [previewOutput] : []
93+
})
94+
)
8295
8396
const loadMoreItems = () => {
8497
const currentLength = visibleTasks.value.length
@@ -189,6 +202,12 @@ const handleContextMenu = ({
189202
menu.value?.show(event)
190203
}
191204
205+
const handlePreview = (task: TaskItemImpl) => {
206+
galleryActiveIndex.value = allGalleryItems.value.findIndex(
207+
(item) => item.url === task.previewOutput?.url
208+
)
209+
}
210+
192211
onMounted(() => {
193212
api.addEventListener('status', onStatus)
194213
queueStore.update()
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
<template>
2+
<Galleria
3+
v-model:visible="galleryVisible"
4+
@update:visible="handleVisibilityChange"
5+
:activeIndex="activeIndex"
6+
@update:activeIndex="handleActiveIndexChange"
7+
:value="allGalleryItems"
8+
:showIndicators="false"
9+
changeItemOnIndicatorHover
10+
showItemNavigators
11+
fullScreen
12+
circular
13+
:showThumbnails="false"
14+
>
15+
<template #item="{ item }">
16+
<img :src="item.url" alt="gallery item" class="galleria-image" />
17+
</template>
18+
</Galleria>
19+
</template>
20+
21+
<script setup lang="ts">
22+
import { defineProps, ref, watch, onMounted, onUnmounted } from 'vue'
23+
import Galleria from 'primevue/galleria'
24+
import { ResultItemImpl } from '@/stores/queueStore'
25+
26+
const galleryVisible = ref(false)
27+
28+
const emit = defineEmits<{
29+
(e: 'update:activeIndex', value: number): void
30+
}>()
31+
32+
const props = defineProps<{
33+
allGalleryItems: ResultItemImpl[]
34+
activeIndex: number
35+
}>()
36+
37+
watch(
38+
() => props.activeIndex,
39+
(index) => {
40+
if (index !== -1) {
41+
galleryVisible.value = true
42+
}
43+
}
44+
)
45+
46+
const handleVisibilityChange = (visible: boolean) => {
47+
if (!visible) {
48+
emit('update:activeIndex', -1)
49+
}
50+
}
51+
52+
const handleActiveIndexChange = (index: number) => {
53+
emit('update:activeIndex', index)
54+
}
55+
56+
const handleKeyDown = (event: KeyboardEvent) => {
57+
if (!galleryVisible.value) return
58+
59+
switch (event.key) {
60+
case 'ArrowLeft':
61+
navigateImage(-1)
62+
break
63+
case 'ArrowRight':
64+
navigateImage(1)
65+
break
66+
case 'Escape':
67+
galleryVisible.value = false
68+
break
69+
}
70+
}
71+
72+
const navigateImage = (direction: number) => {
73+
const newIndex =
74+
(props.activeIndex + direction + props.allGalleryItems.length) %
75+
props.allGalleryItems.length
76+
emit('update:activeIndex', newIndex)
77+
}
78+
79+
onMounted(() => {
80+
window.addEventListener('keydown', handleKeyDown)
81+
})
82+
83+
onUnmounted(() => {
84+
window.removeEventListener('keydown', handleKeyDown)
85+
})
86+
</script>
87+
88+
<style scoped>
89+
.galleria-image {
90+
max-width: 100%;
91+
max-height: 100%;
92+
object-fit: contain;
93+
/* Set z-index so the close button doesn't get hidden behind the image when image is large */
94+
z-index: -1;
95+
}
96+
</style>
Lines changed: 29 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,18 @@
11
<template>
22
<div class="result-container" ref="resultContainer">
3-
<Image
3+
<template
44
v-if="result.mediaType === 'images' || result.mediaType === 'gifs'"
5-
:src="result.url"
6-
alt="Task Output"
7-
width="100%"
8-
height="100%"
9-
preview
10-
:pt="{ previewMask: { class: 'image-preview-mask' } }"
11-
/>
5+
>
6+
<div class="image-preview-mask">
7+
<Button
8+
icon="pi pi-eye"
9+
severity="secondary"
10+
@click="emit('preview', result)"
11+
rounded
12+
/>
13+
</div>
14+
<img :src="result.url" class="task-output-image" />
15+
</template>
1216
<!-- TODO: handle more media types -->
1317
<div v-else class="task-result-preview">
1418
<i class="pi pi-file"></i>
@@ -19,18 +23,22 @@
1923

2024
<script setup lang="ts">
2125
import { ResultItemImpl } from '@/stores/queueStore'
22-
import Image from 'primevue/image'
26+
import Button from 'primevue/button'
2327
import { onMounted, ref } from 'vue'
2428
2529
const props = defineProps<{
2630
result: ResultItemImpl
2731
}>()
2832
33+
const emit = defineEmits<{
34+
(e: 'preview', ResultItemImpl): void
35+
}>()
36+
2937
const resultContainer = ref<HTMLElement | null>(null)
3038
3139
onMounted(() => {
3240
if (props.result.mediaType === 'images') {
33-
resultContainer.value.querySelectorAll('img').forEach((img) => {
41+
resultContainer.value?.querySelectorAll('img').forEach((img) => {
3442
img.draggable = true
3543
})
3644
}
@@ -39,43 +47,33 @@ onMounted(() => {
3947

4048
<style scoped>
4149
.result-container {
50+
width: 100%;
51+
height: 100%;
4252
aspect-ratio: 1 / 1;
4353
overflow: hidden;
54+
position: relative;
4455
}
4556
46-
:deep(img) {
47-
position: absolute;
48-
top: 50%;
49-
left: 50%;
50-
transform: translate(-50%, -50%);
57+
.task-output-image {
5158
width: 100%;
5259
height: 100%;
5360
object-fit: cover;
61+
object-position: center;
5462
}
5563
56-
.p-image-preview {
57-
position: static;
58-
display: contents;
59-
}
60-
61-
:deep(.image-preview-mask) {
64+
.image-preview-mask {
6265
position: absolute;
6366
left: 50%;
6467
top: 50%;
6568
transform: translate(-50%, -50%);
66-
width: auto;
67-
height: auto;
6869
display: flex;
6970
align-items: center;
7071
justify-content: center;
7172
opacity: 0;
72-
padding: 10px;
73-
cursor: pointer;
74-
background: rgba(0, 0, 0, 0.5);
75-
color: var(--p-image-preview-mask-color);
76-
transition:
77-
opacity var(--p-image-transition-duration),
78-
background var(--p-image-transition-duration);
79-
border-radius: 50%;
73+
transition: opacity 0.3s ease;
74+
}
75+
76+
.result-container:hover .image-preview-mask {
77+
opacity: 1;
8078
}
8179
</style>

src/components/sidebar/tabs/queue/TaskItem.vue

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,13 @@
11
<template>
22
<div class="task-item" @contextmenu="handleContextMenu">
33
<div class="task-result-preview">
4-
<div v-if="task.displayStatus === TaskItemDisplayStatus.Completed">
5-
<ResultItem v-if="flatOutputs.length" :result="flatOutputs[0]" />
6-
</div>
4+
<template v-if="task.displayStatus === TaskItemDisplayStatus.Completed">
5+
<ResultItem
6+
v-if="flatOutputs.length"
7+
:result="flatOutputs[0]"
8+
@preview="handlePreview"
9+
/>
10+
</template>
711
<i
812
v-else-if="task.displayStatus === TaskItemDisplayStatus.Running"
913
class="pi pi-spin pi-spinner"
@@ -55,13 +59,18 @@ const props = defineProps<{
5559
const flatOutputs = props.task.flatOutputs
5660
5761
const emit = defineEmits<{
58-
(e: 'contextmenu', { task: TaskItemImpl, event: MouseEvent }): void
62+
(e: 'contextmenu', value: { task: TaskItemImpl; event: MouseEvent }): void
63+
(e: 'preview', value: TaskItemImpl): void
5964
}>()
6065
6166
const handleContextMenu = (e: MouseEvent) => {
6267
emit('contextmenu', { task: props.task, event: e })
6368
}
6469
70+
const handlePreview = () => {
71+
emit('preview', props.task)
72+
}
73+
6574
const taskTagSeverity = (status: TaskItemDisplayStatus) => {
6675
switch (status) {
6776
case TaskItemDisplayStatus.Pending:

src/stores/queueStore.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,15 @@ export class ResultItemImpl {
3838

3939
get url(): string {
4040
return api.apiURL(`/view?filename=${encodeURIComponent(this.filename)}&type=${this.type}&
41-
subfolder=${encodeURIComponent(this.subfolder || '')}&t=${+new Date()}`)
41+
subfolder=${encodeURIComponent(this.subfolder || '')}`)
42+
}
43+
44+
get urlWithTimestamp(): string {
45+
return `${this.url}&t=${+new Date()}`
46+
}
47+
48+
get supportsPreview(): boolean {
49+
return ['images', 'gifs'].includes(this.mediaType)
4250
}
4351
}
4452

@@ -65,6 +73,10 @@ export class TaskItemImpl {
6573
)
6674
}
6775

76+
get previewOutput(): ResultItemImpl | undefined {
77+
return this.flatOutputs.find((output) => output.supportsPreview)
78+
}
79+
6880
get apiTaskType(): APITaskType {
6981
switch (this.taskType) {
7082
case 'Running':

0 commit comments

Comments
 (0)