mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-02 20:22:08 +00:00
feat: Add multi-select support for media assets (#6256)
## Summary
Implements file explorer-style multi-selection functionality for media
assets in the AssetsSidebarTab component.
## Changes
### Multi-Selection Interactions
- **Normal click**: Single selection (clears previous, selects new)
- **Shift + click**: Range selection (from last selected to current)
- **Ctrl/Cmd + click**: Toggle individual selection
### State Management
- Added `assetSelectionStore` to manage selected asset IDs using Set
- Created `useAssetSelection` composable for selection logic and
keyboard state
### UI Enhancements
- Display selection count in footer (output tab only)
- Interactive selection count that shows "Deselect all" on hover
- Added bulk action buttons for download/delete (UI only)
### Translation Keys
Added new keys under `mediaAsset.selection`:
- `selectedCount`: "{count} selected"
- `deselectAll`: "Deselect all"
- `downloadSelected`: "Download"
- `deleteSelected`: "Delete"
## Test Plan
- [x] Open Assets sidebar tab
- [x] Switch to Generated tab
- [x] Test single selection with normal click
- [x] Test range selection with Shift + click
- [x] Test toggle selection with Ctrl/Cmd + click
- [x] Verify selection count updates correctly
- [x] Test hover interaction on selection count
- [x] Click "Deselect all" to clear selection
- [x] Test bulk action buttons (UI only)
## Notes
- Bulk download/delete functionality to be implemented in separate PR
- Selection UI currently only shows in output (Generated) tab
[screen-capture.webm](https://github.com/user-attachments/assets/740315bd-9254-4af3-a0be-10846d810d65)
This commit is contained in:
@@ -41,44 +41,102 @@
|
||||
</TabList>
|
||||
</template>
|
||||
<template #body>
|
||||
<VirtualGrid
|
||||
v-if="displayAssets.length"
|
||||
:items="mediaAssetsWithKey"
|
||||
:grid-style="{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))',
|
||||
padding: '0.5rem',
|
||||
gap: '0.5rem'
|
||||
}"
|
||||
>
|
||||
<template #item="{ item }">
|
||||
<MediaAssetCard
|
||||
:asset="item"
|
||||
:selected="selectedAsset?.id === item.id"
|
||||
:show-output-count="shouldShowOutputCount(item)"
|
||||
:output-count="getOutputCount(item)"
|
||||
@click="handleAssetSelect(item)"
|
||||
@zoom="handleZoomClick(item)"
|
||||
@output-count-click="enterFolderView(item)"
|
||||
@asset-deleted="refreshAssets"
|
||||
<div v-if="displayAssets.length" class="relative size-full">
|
||||
<VirtualGrid
|
||||
v-if="displayAssets.length"
|
||||
:items="mediaAssetsWithKey"
|
||||
:grid-style="{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))',
|
||||
padding: '0.5rem',
|
||||
gap: '0.5rem'
|
||||
}"
|
||||
>
|
||||
<template #item="{ item }">
|
||||
<MediaAssetCard
|
||||
:asset="item"
|
||||
:selected="isSelected(item.id)"
|
||||
:show-output-count="shouldShowOutputCount(item)"
|
||||
:output-count="getOutputCount(item)"
|
||||
:show-delete-button="!isInFolderView"
|
||||
@click="handleAssetSelect(item)"
|
||||
@zoom="handleZoomClick(item)"
|
||||
@output-count-click="enterFolderView(item)"
|
||||
@asset-deleted="refreshAssets"
|
||||
/>
|
||||
</template>
|
||||
</VirtualGrid>
|
||||
<div v-else-if="loading">
|
||||
<ProgressSpinner
|
||||
class="absolute left-1/2 w-[50px] -translate-x-1/2"
|
||||
/>
|
||||
</template>
|
||||
</VirtualGrid>
|
||||
<div v-else-if="loading">
|
||||
<ProgressSpinner class="absolute left-1/2 w-[50px] -translate-x-1/2" />
|
||||
</div>
|
||||
<div v-else>
|
||||
<NoResultsPlaceholder
|
||||
icon="pi pi-info-circle"
|
||||
:title="
|
||||
$t(
|
||||
activeTab === 'input'
|
||||
? 'sideToolbar.noImportedFiles'
|
||||
: 'sideToolbar.noGeneratedFiles'
|
||||
)
|
||||
"
|
||||
:message="$t('sideToolbar.noFilesFoundMessage')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else>
|
||||
<NoResultsPlaceholder
|
||||
icon="pi pi-info-circle"
|
||||
:title="
|
||||
$t(
|
||||
activeTab === 'input'
|
||||
? 'sideToolbar.noImportedFiles'
|
||||
: 'sideToolbar.noGeneratedFiles'
|
||||
)
|
||||
"
|
||||
:message="$t('sideToolbar.noFilesFoundMessage')"
|
||||
/>
|
||||
</template>
|
||||
<template #footer>
|
||||
<div
|
||||
v-if="hasSelection && activeTab === 'output'"
|
||||
class="flex h-18 w-full items-center justify-between px-4"
|
||||
>
|
||||
<div>
|
||||
<TextButton
|
||||
v-if="isHoveringSelectionCount"
|
||||
:label="$t('mediaAsset.selection.deselectAll')"
|
||||
type="transparent"
|
||||
@click="handleDeselectAll"
|
||||
@mouseleave="isHoveringSelectionCount = false"
|
||||
/>
|
||||
<span
|
||||
v-else
|
||||
role="button"
|
||||
tabindex="0"
|
||||
:aria-label="$t('mediaAsset.selection.deselectAll')"
|
||||
class="cursor-pointer px-3 text-sm focus:ring-2 focus:ring-primary focus:outline-none"
|
||||
@mouseenter="isHoveringSelectionCount = true"
|
||||
@keydown.enter="handleDeselectAll"
|
||||
@keydown.space.prevent="handleDeselectAll"
|
||||
>
|
||||
{{
|
||||
$t('mediaAsset.selection.selectedCount', { count: selectedCount })
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<IconTextButton
|
||||
v-if="!isInFolderView"
|
||||
:label="$t('mediaAsset.selection.deleteSelected')"
|
||||
type="secondary"
|
||||
icon-position="right"
|
||||
@click="handleDeleteSelected"
|
||||
>
|
||||
<template #icon>
|
||||
<i class="icon-[lucide--trash-2] size-4" />
|
||||
</template>
|
||||
</IconTextButton>
|
||||
<IconTextButton
|
||||
:label="$t('mediaAsset.selection.downloadSelected')"
|
||||
type="secondary"
|
||||
icon-position="right"
|
||||
@click="handleDownloadSelected"
|
||||
>
|
||||
<template #icon>
|
||||
<i class="icon-[lucide--download] size-4" />
|
||||
</template>
|
||||
</IconTextButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</AssetsSidebarTemplate>
|
||||
@@ -91,9 +149,10 @@
|
||||
<script setup lang="ts">
|
||||
import ProgressSpinner from 'primevue/progressspinner'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
|
||||
import IconTextButton from '@/components/button/IconTextButton.vue'
|
||||
import TextButton from '@/components/button/TextButton.vue'
|
||||
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
|
||||
import VirtualGrid from '@/components/common/VirtualGrid.vue'
|
||||
import ResultGallery from '@/components/sidebar/tabs/queue/ResultGallery.vue'
|
||||
@@ -102,6 +161,8 @@ import TabList from '@/components/tab/TabList.vue'
|
||||
import { t } from '@/i18n'
|
||||
import MediaAssetCard from '@/platform/assets/components/MediaAssetCard.vue'
|
||||
import { useMediaAssets } from '@/platform/assets/composables/media/useMediaAssets'
|
||||
import { useAssetSelection } from '@/platform/assets/composables/useAssetSelection'
|
||||
import { useMediaAssetActions } from '@/platform/assets/composables/useMediaAssetActions'
|
||||
import { getOutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema'
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
import { ResultItemImpl } from '@/stores/queueStore'
|
||||
@@ -110,7 +171,6 @@ import { formatDuration, getMediaTypeFromFilename } from '@/utils/formatUtil'
|
||||
import AssetsSidebarTemplate from './AssetSidebarTemplate.vue'
|
||||
|
||||
const activeTab = ref<'input' | 'output'>('input')
|
||||
const selectedAsset = ref<AssetItem | null>(null)
|
||||
const folderPromptId = ref<string | null>(null)
|
||||
const folderExecutionTime = ref<number | undefined>(undefined)
|
||||
const isInFolderView = computed(() => folderPromptId.value !== null)
|
||||
@@ -137,6 +197,23 @@ const toast = useToast()
|
||||
const inputAssets = useMediaAssets('input')
|
||||
const outputAssets = useMediaAssets('output')
|
||||
|
||||
// Asset selection
|
||||
const {
|
||||
isSelected,
|
||||
handleAssetClick,
|
||||
hasSelection,
|
||||
selectedCount,
|
||||
clearSelection,
|
||||
getSelectedAssets,
|
||||
activate: activateSelection,
|
||||
deactivate: deactivateSelection
|
||||
} = useAssetSelection()
|
||||
|
||||
const { downloadMultipleAssets, deleteMultipleAssets } = useMediaAssetActions()
|
||||
|
||||
// Hover state for selection count
|
||||
const isHoveringSelectionCount = ref(false)
|
||||
|
||||
const currentAssets = computed(() =>
|
||||
activeTab.value === 'input' ? inputAssets : outputAssets
|
||||
)
|
||||
@@ -213,17 +290,15 @@ const refreshAssets = async () => {
|
||||
watch(
|
||||
activeTab,
|
||||
() => {
|
||||
clearSelection()
|
||||
void refreshAssets()
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
const handleAssetSelect = (asset: AssetItem) => {
|
||||
if (selectedAsset.value?.id === asset.id) {
|
||||
selectedAsset.value = null
|
||||
} else {
|
||||
selectedAsset.value = asset
|
||||
}
|
||||
const index = displayAssets.value.findIndex((a) => a.id === asset.id)
|
||||
handleAssetClick(asset, index, displayAssets.value)
|
||||
}
|
||||
|
||||
const handleZoomClick = (asset: AssetItem) => {
|
||||
@@ -272,6 +347,20 @@ const exitFolderView = () => {
|
||||
folderPromptId.value = null
|
||||
folderExecutionTime.value = undefined
|
||||
folderAssets.value = []
|
||||
clearSelection()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
activateSelection()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
deactivateSelection()
|
||||
})
|
||||
|
||||
const handleDeselectAll = () => {
|
||||
clearSelection()
|
||||
isHoveringSelectionCount.value = false
|
||||
}
|
||||
|
||||
const copyJobId = async () => {
|
||||
@@ -294,4 +383,16 @@ const copyJobId = async () => {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleDownloadSelected = () => {
|
||||
const selectedAssets = getSelectedAssets(displayAssets.value)
|
||||
downloadMultipleAssets(selectedAssets)
|
||||
clearSelection()
|
||||
}
|
||||
|
||||
const handleDeleteSelected = async () => {
|
||||
const selectedAssets = getSelectedAssets(displayAssets.value)
|
||||
await deleteMultipleAssets(selectedAssets)
|
||||
clearSelection()
|
||||
}
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user