mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-01-26 10:59:53 +00:00
feat: add output stacks to list view
This commit is contained in:
@@ -1,7 +1,9 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import type { JobAction } from '@/composables/queue/useJobActions'
|
||||
import type { JobListItem } from '@/composables/queue/useJobList'
|
||||
import { useOutputStacks } from '@/platform/assets/composables/useOutputStacks'
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
import { setMockJobActions } from '@/storybook/mocks/useJobActions'
|
||||
import { setMockJobItems } from '@/storybook/mocks/useJobList'
|
||||
@@ -138,16 +140,33 @@ function renderAssetsSidebarListView(args: StoryArgs) {
|
||||
setup() {
|
||||
setMockJobItems(args.jobs)
|
||||
setMockJobActions(args.actionsByJobId ?? {})
|
||||
const { assetItems, selectableAssets, isStackExpanded, toggleStack } =
|
||||
useOutputStacks({
|
||||
assets: computed(() => args.assets)
|
||||
})
|
||||
const selectedIds = new Set(args.selectedAssetIds ?? [])
|
||||
function isSelected(assetId: string) {
|
||||
return selectedIds.has(assetId)
|
||||
}
|
||||
|
||||
return { args, isSelected }
|
||||
return {
|
||||
args,
|
||||
assetItems,
|
||||
selectableAssets,
|
||||
isSelected,
|
||||
isStackExpanded,
|
||||
toggleStack
|
||||
}
|
||||
},
|
||||
template: `
|
||||
<div class="h-[520px] w-[320px] overflow-hidden rounded-lg border border-panel-border">
|
||||
<AssetsSidebarListView :assets="args.assets" :is-selected="isSelected" />
|
||||
<AssetsSidebarListView
|
||||
:asset-items="assetItems"
|
||||
:selectable-assets="selectableAssets"
|
||||
:is-selected="isSelected"
|
||||
:is-stack-expanded="isStackExpanded"
|
||||
:toggle-stack="toggleStack"
|
||||
/>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="assets.length"
|
||||
v-if="assetItems.length"
|
||||
:class="cn('px-2', activeJobItems.length && 'mt-2')"
|
||||
>
|
||||
<div
|
||||
@@ -72,7 +72,12 @@
|
||||
type: getMediaTypeFromFilename(item.asset.name)
|
||||
})
|
||||
"
|
||||
:class="getAssetCardClass(isSelected(item.asset.id))"
|
||||
:class="
|
||||
cn(
|
||||
getAssetCardClass(isSelected(item.asset.id)),
|
||||
item.isChild && 'pl-6'
|
||||
)
|
||||
"
|
||||
:preview-url="item.asset.preview_url"
|
||||
:preview-alt="item.asset.name"
|
||||
:icon-name="
|
||||
@@ -80,10 +85,14 @@
|
||||
"
|
||||
: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)"
|
||||
@click.stop="emit('select-asset', item.asset, selectableAssets)"
|
||||
@stack-toggle="toggleStack(item.asset)"
|
||||
>
|
||||
<template v-if="hoveredAssetId === item.asset.id" #actions>
|
||||
<Button
|
||||
@@ -111,6 +120,7 @@ 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'
|
||||
@@ -125,17 +135,23 @@ import { iconForJobState } from '@/utils/queueDisplay'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const {
|
||||
assets,
|
||||
assetItems,
|
||||
selectableAssets,
|
||||
isSelected,
|
||||
isStackExpanded,
|
||||
toggleStack,
|
||||
assetType = 'output'
|
||||
} = defineProps<{
|
||||
assets: AssetItem[]
|
||||
assetItems: OutputStackListItem[]
|
||||
selectableAssets: AssetItem[]
|
||||
isSelected: (assetId: string) => boolean
|
||||
isStackExpanded: (asset: AssetItem) => boolean
|
||||
toggleStack: (asset: AssetItem) => void
|
||||
assetType?: 'input' | 'output'
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'select-asset', asset: AssetItem): void
|
||||
(e: 'select-asset', asset: AssetItem, assets?: AssetItem[]): void
|
||||
(e: 'context-menu', event: MouseEvent, asset: AssetItem): void
|
||||
(e: 'approach-end'): void
|
||||
}>()
|
||||
@@ -144,9 +160,6 @@ const { t } = useI18n()
|
||||
const { jobItems } = useJobList()
|
||||
const hoveredJobId = ref<string | null>(null)
|
||||
const hoveredAssetId = ref<string | null>(null)
|
||||
|
||||
type AssetListItem = { key: string; asset: AssetItem }
|
||||
|
||||
const activeJobItems = computed(() =>
|
||||
jobItems.value.filter((item) => isActiveJobState(item.state))
|
||||
)
|
||||
@@ -158,13 +171,6 @@ const hoveredJob = computed(() =>
|
||||
)
|
||||
const { cancelAction, canCancelJob, runCancelJob } = useJobActions(hoveredJob)
|
||||
|
||||
const assetItems = computed<AssetListItem[]>(() =>
|
||||
assets.map((asset) => ({
|
||||
key: `asset-${asset.id}`,
|
||||
asset
|
||||
}))
|
||||
)
|
||||
|
||||
const listGridStyle = {
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'minmax(0, 1fr)',
|
||||
@@ -194,6 +200,19 @@ function getAssetSecondaryText(asset: AssetItem): string {
|
||||
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',
|
||||
|
||||
@@ -98,8 +98,11 @@
|
||||
<div v-else class="relative size-full" @click="handleEmptySpaceClick">
|
||||
<AssetsSidebarListView
|
||||
v-if="isListView"
|
||||
:assets="displayAssets"
|
||||
:asset-items="listViewAssetItems"
|
||||
:is-selected="isSelected"
|
||||
:selectable-assets="listViewSelectableAssets"
|
||||
:is-stack-expanded="isListViewStackExpanded"
|
||||
:toggle-stack="toggleListViewStack"
|
||||
:asset-type="activeTab"
|
||||
@select-asset="handleAssetSelect"
|
||||
@context-menu="handleAssetContextMenu"
|
||||
@@ -225,6 +228,7 @@ import { useMediaAssets } from '@/platform/assets/composables/media/useMediaAsse
|
||||
import { useAssetSelection } from '@/platform/assets/composables/useAssetSelection'
|
||||
import { useMediaAssetActions } from '@/platform/assets/composables/useMediaAssetActions'
|
||||
import { useMediaAssetFiltering } from '@/platform/assets/composables/useMediaAssetFiltering'
|
||||
import { useOutputStacks } from '@/platform/assets/composables/useOutputStacks'
|
||||
import { getOutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema'
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
import type { MediaKind } from '@/platform/assets/schemas/mediaAssetSchema'
|
||||
@@ -309,6 +313,7 @@ const {
|
||||
hasSelection,
|
||||
clearSelection,
|
||||
getSelectedAssets,
|
||||
reconcileSelection,
|
||||
getOutputCount,
|
||||
getTotalOutputCount,
|
||||
activate: activateSelection,
|
||||
@@ -378,7 +383,21 @@ const displayAssets = computed(() => {
|
||||
return filteredAssets.value
|
||||
})
|
||||
|
||||
const selectedAssets = computed(() => getSelectedAssets(displayAssets.value))
|
||||
const {
|
||||
assetItems: listViewAssetItems,
|
||||
selectableAssets: listViewSelectableAssets,
|
||||
isStackExpanded: isListViewStackExpanded,
|
||||
toggleStack: toggleListViewStack
|
||||
} = useOutputStacks({
|
||||
assets: computed(() => displayAssets.value)
|
||||
})
|
||||
|
||||
const visibleAssets = computed(() => {
|
||||
if (!isListView.value) return displayAssets.value
|
||||
return listViewSelectableAssets.value
|
||||
})
|
||||
|
||||
const selectedAssets = computed(() => getSelectedAssets(visibleAssets.value))
|
||||
|
||||
const isBulkMode = computed(
|
||||
() => hasSelection.value && selectedAssets.value.length > 1
|
||||
@@ -398,7 +417,10 @@ const showEmptyState = computed(
|
||||
activeJobsCount.value === 0
|
||||
)
|
||||
|
||||
watch(displayAssets, (newAssets) => {
|
||||
watch(visibleAssets, (newAssets) => {
|
||||
// Alternative: keep hidden selections and surface them in UI; for now prune
|
||||
// so selection stays consistent with what this view can act on.
|
||||
reconcileSelection(newAssets)
|
||||
if (currentGalleryAssetId.value && galleryActiveIndex.value !== -1) {
|
||||
const newIndex = newAssets.findIndex(
|
||||
(asset) => asset.id === currentGalleryAssetId.value
|
||||
@@ -416,7 +438,7 @@ watch(galleryActiveIndex, (index) => {
|
||||
})
|
||||
|
||||
const galleryItems = computed(() => {
|
||||
return displayAssets.value.map((asset) => {
|
||||
return visibleAssets.value.map((asset) => {
|
||||
const mediaType = getMediaTypeFromFilename(asset.name)
|
||||
const resultItem = new ResultItemImpl({
|
||||
filename: asset.name,
|
||||
@@ -456,9 +478,10 @@ watch(
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
const handleAssetSelect = (asset: AssetItem) => {
|
||||
const index = displayAssets.value.findIndex((a) => a.id === asset.id)
|
||||
handleAssetClick(asset, index, displayAssets.value)
|
||||
const handleAssetSelect = (asset: AssetItem, assets?: AssetItem[]) => {
|
||||
const assetList = assets ?? visibleAssets.value
|
||||
const index = assetList.findIndex((a) => a.id === asset.id)
|
||||
handleAssetClick(asset, index, assetList)
|
||||
}
|
||||
|
||||
function handleAssetContextMenu(event: MouseEvent, asset: AssetItem) {
|
||||
@@ -531,7 +554,7 @@ const handleZoomClick = (asset: AssetItem) => {
|
||||
}
|
||||
|
||||
currentGalleryAssetId.value = asset.id
|
||||
const index = displayAssets.value.findIndex((a) => a.id === asset.id)
|
||||
const index = visibleAssets.value.findIndex((a) => a.id === asset.id)
|
||||
if (index !== -1) {
|
||||
galleryActiveIndex.value = index
|
||||
}
|
||||
|
||||
@@ -74,13 +74,46 @@
|
||||
<div v-if="$slots.actions" class="relative z-1 flex items-center gap-2">
|
||||
<slot name="actions" />
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="typeof stackCount === 'number' && stackCount > 1"
|
||||
class="relative z-1 flex shrink-0 items-center"
|
||||
>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="md"
|
||||
class="gap-1 font-bold"
|
||||
:aria-label="stackIndicatorLabel || undefined"
|
||||
:aria-expanded="stackExpanded"
|
||||
@click.stop="emit('stack-toggle')"
|
||||
>
|
||||
<i aria-hidden="true" class="icon-[lucide--layers] size-4" />
|
||||
<span class="text-xs leading-none">{{ stackCount }}</span>
|
||||
<i
|
||||
aria-hidden="true"
|
||||
:class="
|
||||
cn(
|
||||
stackExpanded
|
||||
? 'icon-[lucide--chevron-down]'
|
||||
: 'icon-[lucide--chevron-right]',
|
||||
'size-3'
|
||||
)
|
||||
"
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useProgressBarBackground } from '@/composables/useProgressBarBackground'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const emit = defineEmits<{
|
||||
'stack-toggle': []
|
||||
}>()
|
||||
|
||||
const {
|
||||
previewUrl,
|
||||
previewAlt = '',
|
||||
@@ -90,6 +123,9 @@ const {
|
||||
iconWrapperClass,
|
||||
primaryText,
|
||||
secondaryText,
|
||||
stackCount,
|
||||
stackIndicatorLabel,
|
||||
stackExpanded = false,
|
||||
progressTotalPercent,
|
||||
progressCurrentPercent
|
||||
} = defineProps<{
|
||||
@@ -101,6 +137,9 @@ const {
|
||||
iconWrapperClass?: string
|
||||
primaryText?: string
|
||||
secondaryText?: string
|
||||
stackCount?: number
|
||||
stackIndicatorLabel?: string
|
||||
stackExpanded?: boolean
|
||||
progressTotalPercent?: number
|
||||
progressCurrentPercent?: number
|
||||
}>()
|
||||
|
||||
@@ -45,7 +45,7 @@ export function useMediaAssetActions() {
|
||||
): Promise<void> => {
|
||||
if (assetType === 'output') {
|
||||
const promptId =
|
||||
asset.id || getOutputAssetMetadata(asset.user_metadata)?.promptId
|
||||
getOutputAssetMetadata(asset.user_metadata)?.promptId || asset.id
|
||||
if (!promptId) {
|
||||
throw new Error('Unable to extract prompt ID from asset')
|
||||
}
|
||||
@@ -203,9 +203,10 @@ export function useMediaAssetActions() {
|
||||
const targetAsset = asset ?? mediaContext?.asset.value
|
||||
if (!targetAsset) return
|
||||
|
||||
// Try asset.id first (OSS), then fall back to metadata (Cloud)
|
||||
const metadata = getOutputAssetMetadata(targetAsset.user_metadata)
|
||||
const promptId = targetAsset.id || metadata?.promptId
|
||||
const promptId =
|
||||
metadata?.promptId ||
|
||||
(getAssetType(targetAsset) === 'output' ? targetAsset.id : undefined)
|
||||
|
||||
if (!promptId) {
|
||||
toast.add({
|
||||
|
||||
@@ -5,7 +5,7 @@ import { getOutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataS
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
import { resolveOutputAssetItems } from '@/platform/assets/utils/outputAssetUtil'
|
||||
|
||||
type OutputStackListItem = {
|
||||
export type OutputStackListItem = {
|
||||
key: string
|
||||
asset: AssetItem
|
||||
isChild?: boolean
|
||||
|
||||
Reference in New Issue
Block a user