feat: add bulk context menu for multi-asset selection (#7923)

This commit is contained in:
Jin Yi
2026-01-09 15:43:17 +09:00
committed by GitHub
parent 92f21c14d4
commit 43c162a862
5 changed files with 101 additions and 11 deletions

View File

@@ -114,11 +114,15 @@
:output-count="getOutputCount(item)" :output-count="getOutputCount(item)"
:show-delete-button="shouldShowDeleteButton" :show-delete-button="shouldShowDeleteButton"
:open-context-menu-id="openContextMenuId" :open-context-menu-id="openContextMenuId"
:selected-assets="getSelectedAssets(displayAssets)"
:has-selection="hasSelection"
@click="handleAssetSelect(item)" @click="handleAssetSelect(item)"
@zoom="handleZoomClick(item)" @zoom="handleZoomClick(item)"
@output-count-click="enterFolderView(item)" @output-count-click="enterFolderView(item)"
@asset-deleted="refreshAssets" @asset-deleted="refreshAssets"
@context-menu-opened="openContextMenuId = item.id" @context-menu-opened="openContextMenuId = item.id"
@bulk-download="handleBulkDownload"
@bulk-delete="handleBulkDelete"
/> />
</template> </template>
</VirtualGrid> </VirtualGrid>
@@ -134,7 +138,6 @@
<div ref="selectionCountButtonRef" class="inline-flex w-48"> <div ref="selectionCountButtonRef" class="inline-flex w-48">
<Button <Button
variant="secondary" variant="secondary"
size="lg"
:class="cn(isCompact && 'text-left')" :class="cn(isCompact && 'text-left')"
@click="handleDeselectAll" @click="handleDeselectAll"
> >
@@ -243,11 +246,6 @@ const shouldShowDeleteButton = computed(() => {
return true return true
}) })
const getOutputCount = (item: AssetItem): number => {
const count = item.user_metadata?.outputCount
return typeof count === 'number' && count > 0 ? count : 1
}
const shouldShowOutputCount = (item: AssetItem): boolean => { const shouldShowOutputCount = (item: AssetItem): boolean => {
if (activeTab.value !== 'output' || isInFolderView.value) { if (activeTab.value !== 'output' || isInFolderView.value) {
return false return false
@@ -285,6 +283,8 @@ const {
hasSelection, hasSelection,
clearSelection, clearSelection,
getSelectedAssets, getSelectedAssets,
getOutputCount,
getTotalOutputCount,
activate: activateSelection, activate: activateSelection,
deactivate: deactivateSelection deactivate: deactivateSelection
} = useAssetSelection() } = useAssetSelection()
@@ -316,7 +316,7 @@ const isHoveringSelectionCount = useElementHover(selectionCountButtonRef)
// Total output count for all selected assets // Total output count for all selected assets
const totalOutputCount = computed(() => { const totalOutputCount = computed(() => {
const selectedAssets = getSelectedAssets(displayAssets.value) const selectedAssets = getSelectedAssets(displayAssets.value)
return selectedAssets.reduce((sum, asset) => sum + getOutputCount(asset), 0) return getTotalOutputCount(selectedAssets)
}) })
const currentAssets = computed(() => const currentAssets = computed(() =>
@@ -537,6 +537,16 @@ const handleDeleteSelected = async () => {
clearSelection() clearSelection()
} }
const handleBulkDownload = (assets: AssetItem[]) => {
downloadMultipleAssets(assets)
clearSelection()
}
const handleBulkDelete = async (assets: AssetItem[]) => {
await deleteMultipleAssets(assets)
clearSelection()
}
const handleClearQueue = async () => { const handleClearQueue = async () => {
await commandStore.execute('Comfy.ClearPendingTasks') await commandStore.execute('Comfy.ClearPendingTasks')
} }

View File

@@ -2410,9 +2410,12 @@
}, },
"selection": { "selection": {
"selectedCount": "Assets Selected: {count}", "selectedCount": "Assets Selected: {count}",
"multipleSelectedAssets": "Multiple assets selected",
"deselectAll": "Deselect all", "deselectAll": "Deselect all",
"downloadSelected": "Download", "downloadSelected": "Download",
"downloadSelectedAll": "Download all",
"deleteSelected": "Delete", "deleteSelected": "Delete",
"deleteSelectedAll": "Delete all",
"downloadStarted": "Downloading {count} files...", "downloadStarted": "Downloading {count} files...",
"downloadsStarted": "Started downloading {count} file(s)", "downloadsStarted": "Started downloading {count} file(s)",
"assetsDeletedSuccessfully": "{count} asset(s) deleted successfully", "assetsDeletedSuccessfully": "{count} asset(s) deleted successfully",

View File

@@ -127,8 +127,12 @@
:asset-type="assetType" :asset-type="assetType"
:file-kind="fileKind" :file-kind="fileKind"
:show-delete-button="showDeleteButton" :show-delete-button="showDeleteButton"
:selected-assets="selectedAssets"
:is-bulk-mode="hasSelection && (selectedAssets?.length ?? 0) > 1"
@zoom="handleZoomClick" @zoom="handleZoomClick"
@asset-deleted="emit('asset-deleted')" @asset-deleted="emit('asset-deleted')"
@bulk-download="emit('bulk-download', $event)"
@bulk-delete="emit('bulk-delete', $event)"
/> />
</template> </template>
@@ -174,7 +178,9 @@ const {
showOutputCount, showOutputCount,
outputCount, outputCount,
showDeleteButton, showDeleteButton,
openContextMenuId openContextMenuId,
selectedAssets,
hasSelection
} = defineProps<{ } = defineProps<{
asset?: AssetItem asset?: AssetItem
loading?: boolean loading?: boolean
@@ -183,6 +189,8 @@ const {
outputCount?: number outputCount?: number
showDeleteButton?: boolean showDeleteButton?: boolean
openContextMenuId?: string | null openContextMenuId?: string | null
selectedAssets?: AssetItem[]
hasSelection?: boolean
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
@@ -191,6 +199,8 @@ const emit = defineEmits<{
'output-count-click': [] 'output-count-click': []
'asset-deleted': [] 'asset-deleted': []
'context-menu-opened': [] 'context-menu-opened': []
'bulk-download': [assets: AssetItem[]]
'bulk-delete': [assets: AssetItem[]]
}>() }>()
const cardContainerRef = ref<HTMLElement>() const cardContainerRef = ref<HTMLElement>()

View File

@@ -15,11 +15,10 @@
<template #item="{ item, props }"> <template #item="{ item, props }">
<Button <Button
variant="secondary" variant="secondary"
size="sm"
class="w-full justify-start" class="w-full justify-start"
v-bind="props.action" v-bind="props.action"
> >
<i :class="item.icon" class="size-4" /> <i v-if="item.icon" :class="item.icon" class="size-4" />
<span>{{ <span>{{
typeof item.label === 'function' ? item.label() : (item.label ?? '') typeof item.label === 'function' ? item.label() : (item.label ?? '')
}}</span> }}</span>
@@ -45,16 +44,27 @@ import { useMediaAssetActions } from '../composables/useMediaAssetActions'
import type { AssetItem } from '../schemas/assetSchema' import type { AssetItem } from '../schemas/assetSchema'
import type { AssetContext, MediaKind } from '../schemas/mediaAssetSchema' import type { AssetContext, MediaKind } from '../schemas/mediaAssetSchema'
const { asset, assetType, fileKind, showDeleteButton } = defineProps<{ const {
asset,
assetType,
fileKind,
showDeleteButton,
selectedAssets,
isBulkMode
} = defineProps<{
asset: AssetItem asset: AssetItem
assetType: AssetContext['type'] assetType: AssetContext['type']
fileKind: MediaKind fileKind: MediaKind
showDeleteButton?: boolean showDeleteButton?: boolean
selectedAssets?: AssetItem[]
isBulkMode?: boolean
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
zoom: [] zoom: []
'asset-deleted': [] 'asset-deleted': []
'bulk-download': [assets: AssetItem[]]
'bulk-delete': [assets: AssetItem[]]
}>() }>()
const contextMenu = ref<InstanceType<typeof ContextMenu>>() const contextMenu = ref<InstanceType<typeof ContextMenu>>()
@@ -112,6 +122,45 @@ const contextMenuItems = computed<MenuItem[]>(() => {
const items: MenuItem[] = [] const items: MenuItem[] = []
// Check if current asset is part of the selection
const isCurrentAssetSelected = selectedAssets?.some(
(selectedAsset) => selectedAsset.id === asset.id
)
// Bulk mode: Show selected count and bulk actions only if current asset is selected
if (
isBulkMode &&
selectedAssets &&
selectedAssets.length > 0 &&
isCurrentAssetSelected
) {
// Header item showing selected count
items.push({
label: t('mediaAsset.selection.multipleSelectedAssets'),
disabled: true
})
// Bulk Download
items.push({
label: t('mediaAsset.selection.downloadSelectedAll'),
icon: 'icon-[lucide--download]',
command: () => emit('bulk-download', selectedAssets)
})
// Bulk Delete (if allowed)
if (shouldShowDeleteButton.value) {
items.push({
label: t('mediaAsset.selection.deleteSelectedAll'),
icon: 'icon-[lucide--trash-2]',
command: () => emit('bulk-delete', selectedAssets)
})
}
return items
}
// Individual mode: Show all menu options
// Inspect (if not 3D) // Inspect (if not 3D)
if (fileKind !== '3D') { if (fileKind !== '3D') {
items.push({ items.push({

View File

@@ -88,6 +88,22 @@ export function useAssetSelection() {
return allAssets.filter((asset) => selectionStore.isSelected(asset.id)) return allAssets.filter((asset) => selectionStore.isSelected(asset.id))
} }
/**
* Get the output count for a single asset
* Same logic as in AssetsSidebarTab.vue
*/
function getOutputCount(item: AssetItem): number {
const count = item.user_metadata?.outputCount
return typeof count === 'number' && count > 0 ? count : 1
}
/**
* Get the total output count for given assets
*/
function getTotalOutputCount(assets: AssetItem[]): number {
return assets.reduce((sum, asset) => sum + getOutputCount(asset), 0)
}
/** /**
* Activate key event listeners (when sidebar opens) * Activate key event listeners (when sidebar opens)
*/ */
@@ -116,6 +132,8 @@ export function useAssetSelection() {
selectAll, selectAll,
clearSelection: () => selectionStore.clearSelection(), clearSelection: () => selectionStore.clearSelection(),
getSelectedAssets, getSelectedAssets,
getOutputCount,
getTotalOutputCount,
reset: () => selectionStore.reset(), reset: () => selectionStore.reset(),
// Lifecycle management // Lifecycle management