mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-02-27 02:04:09 +00:00
fix: open previewable assets from list preview click/double-click (#9077)
## Summary - emit `preview-click` from `AssetsListItem` when clicking the preview tile - wire assets sidebar rows and queue job-history rows so preview-tile click and row double-click open the viewer/gallery - gate job-history preview opening by `taskRef.previewOutput` (not `iconImageUrl`) and use preview output URL/type so video previews are supported - add/extend tests for preview click and double-click behavior in assets list and job history ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-9077-fix-open-previewable-assets-from-list-preview-click-double-click-30f6d73d3650810a873cfa2dc085bf97) by [Unito](https://www.unito.io)
This commit is contained in:
146
src/components/queue/job/JobAssetsList.test.ts
Normal file
146
src/components/queue/job/JobAssetsList.test.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { JobGroup, JobListItem } from '@/composables/queue/useJobList'
|
||||
import type { JobListItem as ApiJobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
|
||||
import { ResultItemImpl, TaskItemImpl } from '@/stores/queueStore'
|
||||
|
||||
import JobAssetsList from './JobAssetsList.vue'
|
||||
|
||||
vi.mock('vue-i18n', () => {
|
||||
return {
|
||||
createI18n: () => ({
|
||||
global: {
|
||||
t: (key: string) => key,
|
||||
te: () => true,
|
||||
d: (value: string) => value
|
||||
}
|
||||
}),
|
||||
useI18n: () => ({
|
||||
t: (key: string) => key
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const createResultItem = (
|
||||
filename: string,
|
||||
mediaType: string = 'images'
|
||||
): ResultItemImpl => {
|
||||
const item = new ResultItemImpl({
|
||||
filename,
|
||||
subfolder: '',
|
||||
type: 'output',
|
||||
nodeId: 'node-1',
|
||||
mediaType
|
||||
})
|
||||
Object.defineProperty(item, 'url', {
|
||||
get: () => `/api/view/${filename}`
|
||||
})
|
||||
return item
|
||||
}
|
||||
|
||||
const createTaskRef = (preview?: ResultItemImpl): TaskItemImpl => {
|
||||
const job: ApiJobListItem = {
|
||||
id: `task-${Math.random().toString(36).slice(2)}`,
|
||||
status: 'completed',
|
||||
create_time: Date.now(),
|
||||
preview_output: null,
|
||||
outputs_count: preview ? 1 : 0,
|
||||
priority: 0
|
||||
}
|
||||
const flatOutputs = preview ? [preview] : []
|
||||
return new TaskItemImpl(job, {}, flatOutputs)
|
||||
}
|
||||
|
||||
const buildJob = (overrides: Partial<JobListItem> = {}): JobListItem => ({
|
||||
id: 'job-1',
|
||||
title: 'Job 1',
|
||||
meta: 'meta',
|
||||
state: 'completed',
|
||||
taskRef: createTaskRef(createResultItem('job-1.png')),
|
||||
...overrides
|
||||
})
|
||||
|
||||
const mountJobAssetsList = (jobs: JobListItem[]) => {
|
||||
const displayedJobGroups: JobGroup[] = [
|
||||
{
|
||||
key: 'group-1',
|
||||
label: 'Group 1',
|
||||
items: jobs
|
||||
}
|
||||
]
|
||||
|
||||
return mount(JobAssetsList, {
|
||||
props: { displayedJobGroups }
|
||||
})
|
||||
}
|
||||
|
||||
describe('JobAssetsList', () => {
|
||||
it('emits viewItem on preview-click for completed jobs with preview', async () => {
|
||||
const job = buildJob()
|
||||
const wrapper = mountJobAssetsList([job])
|
||||
|
||||
const listItem = wrapper.findComponent({ name: 'AssetsListItem' })
|
||||
listItem.vm.$emit('preview-click')
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(wrapper.emitted('viewItem')).toEqual([[job]])
|
||||
})
|
||||
|
||||
it('emits viewItem on double-click for completed jobs with preview', async () => {
|
||||
const job = buildJob()
|
||||
const wrapper = mountJobAssetsList([job])
|
||||
|
||||
const listItem = wrapper.findComponent({ name: 'AssetsListItem' })
|
||||
await listItem.trigger('dblclick')
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(wrapper.emitted('viewItem')).toEqual([[job]])
|
||||
})
|
||||
|
||||
it('emits viewItem on double-click for completed video jobs without icon image', async () => {
|
||||
const job = buildJob({
|
||||
iconImageUrl: undefined,
|
||||
taskRef: createTaskRef(createResultItem('job-1.webm', 'video'))
|
||||
})
|
||||
const wrapper = mountJobAssetsList([job])
|
||||
|
||||
const listItem = wrapper.findComponent({ name: 'AssetsListItem' })
|
||||
expect(listItem.props('previewUrl')).toBe('/api/view/job-1.webm')
|
||||
expect(listItem.props('isVideoPreview')).toBe(true)
|
||||
|
||||
await listItem.trigger('dblclick')
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(wrapper.emitted('viewItem')).toEqual([[job]])
|
||||
})
|
||||
|
||||
it('emits viewItem on icon click for completed 3D jobs without preview tile', async () => {
|
||||
const job = buildJob({
|
||||
iconImageUrl: undefined,
|
||||
taskRef: createTaskRef(createResultItem('job-1.glb', 'model'))
|
||||
})
|
||||
const wrapper = mountJobAssetsList([job])
|
||||
|
||||
const listItem = wrapper.findComponent({ name: 'AssetsListItem' })
|
||||
|
||||
await listItem.find('i').trigger('click')
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(wrapper.emitted('viewItem')).toEqual([[job]])
|
||||
})
|
||||
|
||||
it('does not emit viewItem on double-click for non-completed jobs', async () => {
|
||||
const job = buildJob({
|
||||
state: 'running',
|
||||
taskRef: createTaskRef(createResultItem('job-1.png'))
|
||||
})
|
||||
const wrapper = mountJobAssetsList([job])
|
||||
|
||||
const listItem = wrapper.findComponent({ name: 'AssetsListItem' })
|
||||
await listItem.trigger('dblclick')
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(wrapper.emitted('viewItem')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -12,7 +12,8 @@
|
||||
v-for="job in group.items"
|
||||
:key="job.id"
|
||||
class="w-full shrink-0 cursor-default text-text-primary transition-colors hover:bg-secondary-background-hover"
|
||||
:preview-url="job.iconImageUrl"
|
||||
:preview-url="getJobPreviewUrl(job)"
|
||||
:is-video-preview="isVideoPreviewJob(job)"
|
||||
:preview-alt="job.title"
|
||||
:icon-name="job.iconName ?? iconForJobState(job.state)"
|
||||
:icon-class="getJobIconClass(job)"
|
||||
@@ -23,6 +24,8 @@
|
||||
@mouseenter="hoveredJobId = job.id"
|
||||
@mouseleave="onJobLeave(job.id)"
|
||||
@contextmenu.prevent.stop="$emit('menu', job, $event)"
|
||||
@dblclick.stop="emitViewItem(job)"
|
||||
@preview-click="emitViewItem(job)"
|
||||
@click.stop
|
||||
>
|
||||
<template v-if="hoveredJobId === job.id" #actions>
|
||||
@@ -78,7 +81,7 @@ import { isActiveJobState } from '@/utils/queueUtil'
|
||||
|
||||
defineProps<{ displayedJobGroups: JobGroup[] }>()
|
||||
|
||||
defineEmits<{
|
||||
const emit = defineEmits<{
|
||||
(e: 'cancelItem', item: JobListItem): void
|
||||
(e: 'deleteItem', item: JobListItem): void
|
||||
(e: 'menu', item: JobListItem, ev: MouseEvent): void
|
||||
@@ -100,6 +103,28 @@ const isCancelable = (job: JobListItem) =>
|
||||
const isFailedDeletable = (job: JobListItem) =>
|
||||
job.showClear !== false && job.state === 'failed'
|
||||
|
||||
const getPreviewOutput = (job: JobListItem) => job.taskRef?.previewOutput
|
||||
|
||||
const getJobPreviewUrl = (job: JobListItem) => {
|
||||
const preview = getPreviewOutput(job)
|
||||
if (preview?.isImage || preview?.isVideo) {
|
||||
return preview.url
|
||||
}
|
||||
return job.iconImageUrl
|
||||
}
|
||||
|
||||
const isVideoPreviewJob = (job: JobListItem) =>
|
||||
job.state === 'completed' && !!getPreviewOutput(job)?.isVideo
|
||||
|
||||
const isPreviewableCompletedJob = (job: JobListItem) =>
|
||||
job.state === 'completed' && !!getPreviewOutput(job)
|
||||
|
||||
const emitViewItem = (job: JobListItem) => {
|
||||
if (isPreviewableCompletedJob(job)) {
|
||||
emit('viewItem', job)
|
||||
}
|
||||
}
|
||||
|
||||
const getJobIconClass = (job: JobListItem): string | undefined => {
|
||||
const iconName = job.iconName ?? iconForJobState(job.state)
|
||||
if (!job.iconImageUrl && iconName === iconForJobState('pending')) {
|
||||
|
||||
@@ -106,4 +106,42 @@ describe('AssetsSidebarListView', () => {
|
||||
expect(assetListItem?.props('previewUrl')).toBe('')
|
||||
expect(assetListItem?.props('isVideoPreview')).toBe(false)
|
||||
})
|
||||
|
||||
it('emits preview-asset when item preview is clicked', async () => {
|
||||
const imageAsset = {
|
||||
...buildAsset('image-asset', 'image.png'),
|
||||
preview_url: '/api/view/image.png',
|
||||
user_metadata: {}
|
||||
} satisfies AssetItem
|
||||
|
||||
const wrapper = mountListView([buildOutputItem(imageAsset)])
|
||||
const listItems = wrapper.findAllComponents({ name: 'AssetsListItem' })
|
||||
const assetListItem = listItems.at(-1)
|
||||
|
||||
expect(assetListItem).toBeDefined()
|
||||
|
||||
assetListItem!.vm.$emit('preview-click')
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(wrapper.emitted('preview-asset')).toEqual([[imageAsset]])
|
||||
})
|
||||
|
||||
it('emits preview-asset when item is double-clicked', async () => {
|
||||
const imageAsset = {
|
||||
...buildAsset('image-asset-dbl', 'image.png'),
|
||||
preview_url: '/api/view/image.png',
|
||||
user_metadata: {}
|
||||
} satisfies AssetItem
|
||||
|
||||
const wrapper = mountListView([buildOutputItem(imageAsset)])
|
||||
const listItems = wrapper.findAllComponents({ name: 'AssetsListItem' })
|
||||
const assetListItem = listItems.at(-1)
|
||||
|
||||
expect(assetListItem).toBeDefined()
|
||||
|
||||
await assetListItem!.trigger('dblclick')
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(wrapper.emitted('preview-asset')).toEqual([[imageAsset]])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -56,6 +56,8 @@
|
||||
@mouseleave="onAssetLeave(item.asset.id)"
|
||||
@contextmenu.prevent.stop="emit('context-menu', $event, item.asset)"
|
||||
@click.stop="emit('select-asset', item.asset, selectableAssets)"
|
||||
@dblclick.stop="emit('preview-asset', item.asset)"
|
||||
@preview-click="emit('preview-asset', item.asset)"
|
||||
@stack-toggle="void toggleStack(item.asset)"
|
||||
>
|
||||
<template v-if="hoveredAssetId === item.asset.id" #actions>
|
||||
@@ -116,6 +118,7 @@ const assetsStore = useAssetsStore()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'select-asset', asset: AssetItem, assets?: AssetItem[]): void
|
||||
(e: 'preview-asset', asset: AssetItem): void
|
||||
(e: 'context-menu', event: MouseEvent, asset: AssetItem): void
|
||||
(e: 'approach-end'): void
|
||||
}>()
|
||||
|
||||
@@ -95,6 +95,7 @@
|
||||
:toggle-stack="toggleListViewStack"
|
||||
:asset-type="activeTab"
|
||||
@select-asset="handleAssetSelect"
|
||||
@preview-asset="handleZoomClick"
|
||||
@context-menu="handleAssetContextMenu"
|
||||
@approach-end="handleApproachEnd"
|
||||
/>
|
||||
@@ -216,10 +217,6 @@ import {
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
|
||||
// Lazy-loaded to avoid pulling THREE.js into the main bundle
|
||||
const Load3dViewerContent = defineAsyncComponent(
|
||||
() => import('@/components/load3d/Load3dViewerContent.vue')
|
||||
)
|
||||
import AssetsSidebarGridView from '@/components/sidebar/tabs/AssetsSidebarGridView.vue'
|
||||
import AssetsSidebarListView from '@/components/sidebar/tabs/AssetsSidebarListView.vue'
|
||||
import SidebarTabTemplate from '@/components/sidebar/tabs/SidebarTabTemplate.vue'
|
||||
@@ -251,6 +248,10 @@ import {
|
||||
} from '@/utils/formatUtil'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const Load3dViewerContent = defineAsyncComponent(
|
||||
() => import('@/components/load3d/Load3dViewerContent.vue')
|
||||
)
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const emit = defineEmits<{ assetSelected: [asset: AssetItem] }>()
|
||||
|
||||
@@ -67,7 +67,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { computed, defineAsyncComponent, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import JobFilterActions from '@/components/queue/job/JobFilterActions.vue'
|
||||
@@ -86,11 +86,17 @@ import SidebarTabTemplate from '@/components/sidebar/tabs/SidebarTabTemplate.vue
|
||||
import ResultGallery from '@/components/sidebar/tabs/queue/ResultGallery.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
import { useQueueStore } from '@/stores/queueStore'
|
||||
|
||||
const Load3dViewerContent = defineAsyncComponent(
|
||||
() => import('@/components/load3d/Load3dViewerContent.vue')
|
||||
)
|
||||
|
||||
const { t, n } = useI18n()
|
||||
const commandStore = useCommandStore()
|
||||
const dialogStore = useDialogStore()
|
||||
const executionStore = useExecutionStore()
|
||||
const queueStore = useQueueStore()
|
||||
const { showQueueClearHistoryDialog } = useQueueClearHistoryDialog()
|
||||
@@ -151,6 +157,24 @@ const {
|
||||
} = useResultGallery(() => filteredTasks.value)
|
||||
|
||||
const onViewItem = wrapWithErrorHandlingAsync(async (item: JobListItem) => {
|
||||
const previewOutput = item.taskRef?.previewOutput
|
||||
|
||||
if (previewOutput?.is3D) {
|
||||
dialogStore.showDialog({
|
||||
key: 'asset-3d-viewer',
|
||||
title: item.title,
|
||||
component: Load3dViewerContent,
|
||||
props: {
|
||||
modelUrl: previewOutput.url || ''
|
||||
},
|
||||
dialogComponentProps: {
|
||||
style: 'width: 80vw; height: 80vh;',
|
||||
maximizable: true
|
||||
}
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
await openResultGallery(item)
|
||||
})
|
||||
|
||||
|
||||
@@ -35,4 +35,29 @@ describe('AssetsListItem', () => {
|
||||
expect(wrapper.find('video').exists()).toBe(false)
|
||||
expect(wrapper.find('.icon-\\[lucide--play\\]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('emits preview-click when preview is clicked', async () => {
|
||||
const wrapper = mount(AssetsListItem, {
|
||||
props: {
|
||||
previewUrl: 'https://example.com/preview.jpg',
|
||||
previewAlt: 'image.png'
|
||||
}
|
||||
})
|
||||
|
||||
await wrapper.find('img').trigger('click')
|
||||
|
||||
expect(wrapper.emitted('preview-click')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('emits preview-click when fallback icon is clicked', async () => {
|
||||
const wrapper = mount(AssetsListItem, {
|
||||
props: {
|
||||
iconName: 'icon-[lucide--box]'
|
||||
}
|
||||
})
|
||||
|
||||
await wrapper.find('i').trigger('click')
|
||||
|
||||
expect(wrapper.emitted('preview-click')).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -35,7 +35,11 @@
|
||||
:icon-class="iconClass"
|
||||
:icon-aria-label="iconAriaLabel"
|
||||
>
|
||||
<div v-if="previewUrl" class="relative size-full">
|
||||
<div
|
||||
v-if="previewUrl"
|
||||
class="relative size-full"
|
||||
@click="emit('preview-click')"
|
||||
>
|
||||
<template v-if="isVideoPreview">
|
||||
<video
|
||||
:src="previewUrl"
|
||||
@@ -53,7 +57,11 @@
|
||||
class="size-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
<div v-else class="flex size-full items-center justify-center">
|
||||
<div
|
||||
v-else
|
||||
class="flex size-full items-center justify-center"
|
||||
@click="emit('preview-click')"
|
||||
>
|
||||
<i
|
||||
aria-hidden="true"
|
||||
:class="
|
||||
@@ -135,6 +143,7 @@ import VideoPlayOverlay from './VideoPlayOverlay.vue'
|
||||
|
||||
const emit = defineEmits<{
|
||||
'stack-toggle': []
|
||||
'preview-click': []
|
||||
}>()
|
||||
|
||||
const {
|
||||
|
||||
Reference in New Issue
Block a user