mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-02-20 23:04:06 +00:00
Add expandable output stacks to the assets list view. Monolith ver. of https://github.com/Comfy-Org/ComfyUI_frontend/pull/8298 and its children List view currently collapses multi-output jobs into a single row, which makes sibling outputs easy to miss and causes selection/zoom behavior to drift once items are expanded elsewhere. This change adds a stack toggle to list rows, expands child outputs derived from job data, and keeps list-view selection and gallery navigation aligned with the expanded list. Output mapping and “load full outputs” checks are centralized so folder view and stacks share the same helper, and job-detail parsing now yields previewable outputs for the list view. Asset actions now prefer metadata prompt IDs to support the composite IDs used by stacked outputs. ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-8283-Add-expandable-output-stacks-to-assets-list-view-2f16d73d365081a99fc6f1519ac2e57c) by [Unito](https://www.unito.io) --------- Co-authored-by: GitHub Action <action@github.com> Co-authored-by: Johnpaul Chiwetelu <49923152+Myestery@users.noreply.github.com>
272 lines
8.3 KiB
Vue
272 lines
8.3 KiB
Vue
<template>
|
|
<div class="flex h-full flex-col">
|
|
<div
|
|
v-if="isQueuePanelV2Enabled && activeJobItems.length"
|
|
class="flex max-h-[50%] scrollbar-custom flex-col gap-2 overflow-y-auto px-2"
|
|
>
|
|
<AssetsListItem
|
|
v-for="job in activeJobItems"
|
|
:key="job.id"
|
|
:class="
|
|
cn(
|
|
'w-full shrink-0 text-text-primary transition-colors hover:bg-secondary-background-hover',
|
|
'cursor-default'
|
|
)
|
|
"
|
|
:preview-url="job.iconImageUrl"
|
|
:preview-alt="job.title"
|
|
:icon-name="job.iconName"
|
|
:icon-class="getJobIconClass(job)"
|
|
:primary-text="job.title"
|
|
:secondary-text="job.meta"
|
|
:progress-total-percent="job.progressTotalPercent"
|
|
:progress-current-percent="job.progressCurrentPercent"
|
|
@mouseenter="onJobEnter(job.id)"
|
|
@mouseleave="onJobLeave(job.id)"
|
|
@click.stop
|
|
>
|
|
<template v-if="hoveredJobId === job.id" #actions>
|
|
<Button
|
|
v-if="canCancelJob"
|
|
:variant="cancelAction.variant"
|
|
size="icon"
|
|
:aria-label="cancelAction.label"
|
|
@click.stop="runCancelJob()"
|
|
>
|
|
<i :class="cancelAction.icon" class="size-4" />
|
|
</Button>
|
|
</template>
|
|
</AssetsListItem>
|
|
</div>
|
|
|
|
<div
|
|
v-if="assetItems.length"
|
|
:class="cn('px-2', activeJobItems.length && 'mt-2')"
|
|
>
|
|
<div
|
|
class="flex items-center p-2 text-sm font-normal leading-normal text-muted-foreground font-inter"
|
|
>
|
|
{{
|
|
t(
|
|
assetType === 'input'
|
|
? 'sideToolbar.importedAssetsHeader'
|
|
: 'sideToolbar.generatedAssetsHeader'
|
|
)
|
|
}}
|
|
</div>
|
|
</div>
|
|
|
|
<VirtualGrid
|
|
class="flex-1"
|
|
:items="assetItems"
|
|
:grid-style="listGridStyle"
|
|
@approach-end="emit('approach-end')"
|
|
>
|
|
<template #item="{ item }">
|
|
<div class="relative">
|
|
<LoadingOverlay
|
|
:loading="assetsStore.isAssetDeleting(item.asset.id)"
|
|
size="sm"
|
|
>
|
|
<i class="pi pi-trash text-xs" />
|
|
</LoadingOverlay>
|
|
<AssetsListItem
|
|
role="button"
|
|
tabindex="0"
|
|
:aria-label="
|
|
t('assetBrowser.ariaLabel.assetCard', {
|
|
name: item.asset.name,
|
|
type: getMediaTypeFromFilename(item.asset.name)
|
|
})
|
|
"
|
|
:class="
|
|
cn(
|
|
getAssetCardClass(isSelected(item.asset.id)),
|
|
item.isChild && 'pl-6'
|
|
)
|
|
"
|
|
:preview-url="item.asset.preview_url"
|
|
:preview-alt="item.asset.name"
|
|
:icon-name="
|
|
iconForMediaType(getMediaTypeFromFilename(item.asset.name))
|
|
"
|
|
:primary-text="getAssetPrimaryText(item.asset)"
|
|
:secondary-text="getAssetSecondaryText(item.asset)"
|
|
:stack-count="getStackCount(item.asset)"
|
|
:stack-indicator-label="t('mediaAsset.actions.seeMoreOutputs')"
|
|
:stack-expanded="isStackExpanded(item.asset)"
|
|
@mouseenter="onAssetEnter(item.asset.id)"
|
|
@mouseleave="onAssetLeave(item.asset.id)"
|
|
@contextmenu.prevent.stop="emit('context-menu', $event, item.asset)"
|
|
@click.stop="emit('select-asset', item.asset, selectableAssets)"
|
|
@stack-toggle="void toggleStack(item.asset)"
|
|
>
|
|
<template v-if="hoveredAssetId === item.asset.id" #actions>
|
|
<Button
|
|
variant="secondary"
|
|
size="icon"
|
|
:aria-label="t('mediaAsset.actions.moreOptions')"
|
|
@click.stop="emit('context-menu', $event, item.asset)"
|
|
>
|
|
<i class="icon-[lucide--ellipsis] size-4" />
|
|
</Button>
|
|
</template>
|
|
</AssetsListItem>
|
|
</div>
|
|
</template>
|
|
</VirtualGrid>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { computed, ref } from 'vue'
|
|
import { useI18n } from 'vue-i18n'
|
|
|
|
import LoadingOverlay from '@/components/common/LoadingOverlay.vue'
|
|
import VirtualGrid from '@/components/common/VirtualGrid.vue'
|
|
import Button from '@/components/ui/button/Button.vue'
|
|
import { useJobActions } from '@/composables/queue/useJobActions'
|
|
import type { JobListItem } from '@/composables/queue/useJobList'
|
|
import { useJobList } from '@/composables/queue/useJobList'
|
|
import AssetsListItem from '@/platform/assets/components/AssetsListItem.vue'
|
|
import type { OutputStackListItem } from '@/platform/assets/composables/useOutputStacks'
|
|
import { getOutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema'
|
|
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
|
import { iconForMediaType } from '@/platform/assets/utils/mediaIconUtil'
|
|
import { useAssetsStore } from '@/stores/assetsStore'
|
|
import { isActiveJobState } from '@/utils/queueUtil'
|
|
import {
|
|
formatDuration,
|
|
formatSize,
|
|
getMediaTypeFromFilename,
|
|
truncateFilename
|
|
} from '@/utils/formatUtil'
|
|
import { iconForJobState } from '@/utils/queueDisplay'
|
|
import { cn } from '@/utils/tailwindUtil'
|
|
import { useSettingStore } from '@/platform/settings/settingStore'
|
|
|
|
const {
|
|
assetItems,
|
|
selectableAssets,
|
|
isSelected,
|
|
isStackExpanded,
|
|
toggleStack,
|
|
assetType = 'output'
|
|
} = defineProps<{
|
|
assetItems: OutputStackListItem[]
|
|
selectableAssets: AssetItem[]
|
|
isSelected: (assetId: string) => boolean
|
|
isStackExpanded: (asset: AssetItem) => boolean
|
|
toggleStack: (asset: AssetItem) => Promise<void>
|
|
assetType?: 'input' | 'output'
|
|
}>()
|
|
|
|
const assetsStore = useAssetsStore()
|
|
|
|
const emit = defineEmits<{
|
|
(e: 'select-asset', asset: AssetItem, assets?: AssetItem[]): void
|
|
(e: 'context-menu', event: MouseEvent, asset: AssetItem): void
|
|
(e: 'approach-end'): void
|
|
}>()
|
|
|
|
const { t } = useI18n()
|
|
const { jobItems } = useJobList()
|
|
const settingStore = useSettingStore()
|
|
|
|
const isQueuePanelV2Enabled = computed(() =>
|
|
settingStore.get('Comfy.Queue.QPOV2')
|
|
)
|
|
const hoveredJobId = ref<string | null>(null)
|
|
const hoveredAssetId = ref<string | null>(null)
|
|
const activeJobItems = computed(() =>
|
|
jobItems.value.filter((item) => isActiveJobState(item.state))
|
|
)
|
|
const hoveredJob = computed(() =>
|
|
hoveredJobId.value
|
|
? (activeJobItems.value.find((job) => job.id === hoveredJobId.value) ??
|
|
null)
|
|
: null
|
|
)
|
|
const { cancelAction, canCancelJob, runCancelJob } = useJobActions(hoveredJob)
|
|
|
|
const listGridStyle = {
|
|
display: 'grid',
|
|
gridTemplateColumns: 'minmax(0, 1fr)',
|
|
padding: '0 0.5rem',
|
|
gap: '0.5rem'
|
|
}
|
|
|
|
function getAssetPrimaryText(asset: AssetItem): string {
|
|
return truncateFilename(asset.name)
|
|
}
|
|
|
|
function getAssetSecondaryText(asset: AssetItem): string {
|
|
const metadata = getOutputAssetMetadata(asset.user_metadata)
|
|
if (typeof metadata?.executionTimeInSeconds === 'number') {
|
|
return `${metadata.executionTimeInSeconds.toFixed(2)}s`
|
|
}
|
|
|
|
const duration = asset.user_metadata?.duration
|
|
if (typeof duration === 'number') {
|
|
return formatDuration(duration)
|
|
}
|
|
|
|
if (typeof asset.size === 'number') {
|
|
return formatSize(asset.size)
|
|
}
|
|
|
|
return ''
|
|
}
|
|
|
|
function getStackCount(asset: AssetItem): number | undefined {
|
|
const metadata = getOutputAssetMetadata(asset.user_metadata)
|
|
if (typeof metadata?.outputCount === 'number') {
|
|
return metadata.outputCount
|
|
}
|
|
|
|
if (Array.isArray(metadata?.allOutputs)) {
|
|
return metadata.allOutputs.length
|
|
}
|
|
|
|
return undefined
|
|
}
|
|
|
|
function getAssetCardClass(selected: boolean): string {
|
|
return cn(
|
|
'w-full text-text-primary transition-colors hover:bg-secondary-background-hover',
|
|
'cursor-pointer',
|
|
selected &&
|
|
'bg-secondary-background-hover ring-1 ring-inset ring-modal-card-border-highlighted'
|
|
)
|
|
}
|
|
|
|
function onJobEnter(jobId: string) {
|
|
hoveredJobId.value = jobId
|
|
}
|
|
|
|
function onJobLeave(jobId: string) {
|
|
if (hoveredJobId.value === jobId) {
|
|
hoveredJobId.value = null
|
|
}
|
|
}
|
|
|
|
function onAssetEnter(assetId: string) {
|
|
hoveredAssetId.value = assetId
|
|
}
|
|
|
|
function onAssetLeave(assetId: string) {
|
|
if (hoveredAssetId.value === assetId) {
|
|
hoveredAssetId.value = null
|
|
}
|
|
}
|
|
|
|
function getJobIconClass(job: JobListItem): string | undefined {
|
|
const classes = []
|
|
const iconName = job.iconName ?? iconForJobState(job.state)
|
|
if (!job.iconImageUrl && iconName === iconForJobState('pending')) {
|
|
classes.push('animate-spin')
|
|
}
|
|
return classes.length ? classes.join(' ') : undefined
|
|
}
|
|
</script>
|