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:
Jin Yi
2025-10-29 16:26:44 +09:00
committed by GitHub
parent 22f307b468
commit 9651d2a5df
9 changed files with 510 additions and 72 deletions

View File

@@ -18,6 +18,9 @@
<ScrollPanel class="h-0 grow">
<slot name="body" />
</ScrollPanel>
<div v-if="slots.footer">
<slot name="footer" />
</div>
</div>
</template>

View File

@@ -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>

View File

@@ -1779,6 +1779,8 @@
"mediaAsset": {
"deleteAssetTitle": "Delete this asset?",
"deleteAssetDescription": "This asset will be permanently removed.",
"deleteSelectedTitle": "Delete selected assets?",
"deleteSelectedDescription": "{count} asset(s) will be permanently removed.",
"assetDeletedSuccessfully": "Asset deleted successfully",
"deletingImportedFilesCloudOnly": "Deleting imported files is only supported in cloud version",
"failedToDeleteAsset": "Failed to delete asset",
@@ -1787,6 +1789,16 @@
"jobIdCopyFailed": "Failed to copy Job ID",
"copied": "Copied",
"error": "Error"
},
"selection": {
"selectedCount": "Assets Selected: {count}",
"deselectAll": "Deselect all",
"downloadSelected": "Download",
"deleteSelected": "Delete",
"downloadStarted": "Downloading {count} files...",
"downloadsStarted": "Started downloading {count} file(s)",
"assetsDeletedSuccessfully": "{count} asset(s) deleted successfully",
"failedToDeleteAssets": "Failed to delete selected assets"
}
},
"actionbar": {

View File

@@ -1,6 +1,6 @@
<template>
<IconGroup>
<IconButton v-if="showDeleteButton" size="sm" @click="handleDelete">
<IconButton v-if="shouldShowDeleteButton" size="sm" @click="handleDelete">
<i class="icon-[lucide--trash-2] size-4" />
</IconButton>
<IconButton size="sm" @click="handleDownload">
@@ -14,6 +14,7 @@
<template #default="{ close }">
<MediaAssetMoreMenu
:close="close"
:show-delete-button="showDeleteButton"
@inspect="emit('inspect')"
@asset-deleted="emit('asset-deleted')"
/>
@@ -34,6 +35,10 @@ import { useMediaAssetActions } from '../composables/useMediaAssetActions'
import { MediaAssetKey } from '../schemas/mediaAssetSchema'
import MediaAssetMoreMenu from './MediaAssetMoreMenu.vue'
const { showDeleteButton } = defineProps<{
showDeleteButton?: boolean
}>()
const emit = defineEmits<{
menuStateChanged: [isOpen: boolean]
inspect: []
@@ -47,10 +52,12 @@ const assetType = computed(() => {
return context?.value?.type || asset.value?.tags?.[0] || 'output'
})
const showDeleteButton = computed(() => {
return (
const shouldShowDeleteButton = computed(() => {
const propAllows = showDeleteButton ?? true
const typeAllows =
assetType.value === 'output' || (assetType.value === 'input' && isCloud)
)
return propAllows && typeAllows
})
const handleDelete = async () => {

View File

@@ -15,9 +15,6 @@
variant="ghost"
rounded="lg"
:class="containerClasses"
@click="handleCardClick"
@keydown.enter="handleCardClick"
@keydown.space.prevent="handleCardClick"
>
<template #top>
<CardTop
@@ -50,6 +47,7 @@
<!-- Actions overlay (top-left) - show on hover or when menu is open -->
<template v-if="showActionsOverlay" #top-left>
<MediaAssetActions
:show-delete-button="showDeleteButton ?? true"
@menu-state-changed="isMenuOpen = $event"
@inspect="handleZoomClick"
@asset-deleted="handleAssetDelete"
@@ -174,12 +172,20 @@ function getBottomComponent(kind: MediaKind) {
return mediaComponents.bottom[kind] || mediaComponents.bottom.image
}
const { asset, loading, selected, showOutputCount, outputCount } = defineProps<{
const {
asset,
loading,
selected,
showOutputCount,
outputCount,
showDeleteButton
} = defineProps<{
asset?: AssetItem
loading?: boolean
selected?: boolean
showOutputCount?: boolean
outputCount?: number
showDeleteButton?: boolean
}>()
const emit = defineEmits<{
@@ -312,12 +318,6 @@ const showFileFormatChip = computed(
(!isVideoPlaying.value || isCardOrOverlayHovered.value)
)
const handleCardClick = () => {
if (adaptedAsset.value) {
actions.selectAsset(adaptedAsset.value)
}
}
const handleOverlayMouseEnter = () => {
isOverlayHovered.value = true
}

View File

@@ -75,10 +75,10 @@
</template>
</IconTextButton>
<MediaAssetButtonDivider v-if="showCopyJobId && showDeleteButton" />
<MediaAssetButtonDivider v-if="showCopyJobId && shouldShowDeleteButton" />
<IconTextButton
v-if="showDeleteButton"
v-if="shouldShowDeleteButton"
type="transparent"
class="dark-theme:text-white"
label="Delete"
@@ -101,8 +101,9 @@ import { useMediaAssetActions } from '../composables/useMediaAssetActions'
import { MediaAssetKey } from '../schemas/mediaAssetSchema'
import MediaAssetButtonDivider from './MediaAssetButtonDivider.vue'
const { close } = defineProps<{
const { close, showDeleteButton } = defineProps<{
close: () => void
showDeleteButton?: boolean
}>()
const emit = defineEmits<{
@@ -124,13 +125,12 @@ const showCopyJobId = computed(() => {
return assetType.value !== 'input'
})
// Delete button should be shown for:
// - All output files (can be deleted via history)
// - Input files only in cloud environment
const showDeleteButton = computed(() => {
return (
const shouldShowDeleteButton = computed(() => {
const propAllows = showDeleteButton ?? true
const typeAllows =
assetType.value === 'output' || (assetType.value === 'input' && isCloud)
)
return propAllows && typeAllows
})
const handleInspect = () => {

View File

@@ -0,0 +1,129 @@
import { useKeyModifier } from '@vueuse/core'
import { computed, ref } from 'vue'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { useAssetSelectionStore } from '@/platform/assets/composables/useAssetSelectionStore'
export function useAssetSelection() {
const selectionStore = useAssetSelectionStore()
// Track whether the asset selection is active (e.g., when sidebar is open)
const isActive = ref<boolean>(true)
// Key modifiers - raw values
const shiftKeyRaw = useKeyModifier('Shift')
const ctrlKeyRaw = useKeyModifier('Control')
const metaKeyRaw = useKeyModifier('Meta')
// Only respond to key modifiers when active
const shiftKey = computed(() => isActive.value && shiftKeyRaw.value)
const ctrlKey = computed(() => isActive.value && ctrlKeyRaw.value)
const metaKey = computed(() => isActive.value && metaKeyRaw.value)
const cmdOrCtrlKey = computed(() => ctrlKey.value || metaKey.value)
/**
* Handle asset click with modifier keys for selection
* @param asset The clicked asset
* @param index The index of the clicked asset in the current list
* @param allAssets All assets in the current view for range selection
*/
function handleAssetClick(
asset: AssetItem,
index: number,
allAssets: AssetItem[]
) {
// Input validation
if (!asset?.id || index < 0 || index >= allAssets.length) {
console.warn('Invalid asset selection parameters')
return
}
const assetId = asset.id
// Shift + Click: Range selection
if (shiftKey.value && selectionStore.lastSelectedIndex >= 0) {
const start = Math.min(selectionStore.lastSelectedIndex, index)
const end = Math.max(selectionStore.lastSelectedIndex, index)
// Batch operation for better performance
const rangeIds = allAssets.slice(start, end + 1).map((a) => a.id)
const existingIds = Array.from(selectionStore.selectedAssetIds)
const combinedIds = [...new Set([...existingIds, ...rangeIds])]
// Single update instead of multiple forEach operations
selectionStore.setSelection(combinedIds)
// Don't update lastSelectedIndex for shift selection
return
}
// Ctrl/Cmd + Click: Toggle individual selection
if (cmdOrCtrlKey.value) {
selectionStore.toggleSelection(assetId)
selectionStore.setLastSelectedIndex(index)
return
}
// Normal Click: Single selection
selectionStore.clearSelection()
selectionStore.addToSelection(assetId)
selectionStore.setLastSelectedIndex(index)
}
/**
* Select all assets in the current view
*/
function selectAll(allAssets: AssetItem[]) {
const allIds = allAssets.map((a) => a.id)
selectionStore.setSelection(allIds)
if (allAssets.length > 0) {
selectionStore.setLastSelectedIndex(allAssets.length - 1)
}
}
/**
* Get the actual asset objects for selected IDs
*/
function getSelectedAssets(allAssets: AssetItem[]): AssetItem[] {
return allAssets.filter((asset) => selectionStore.isSelected(asset.id))
}
/**
* Activate key event listeners (when sidebar opens)
*/
function activate() {
isActive.value = true
}
/**
* Deactivate key event listeners (when sidebar closes)
*/
function deactivate() {
isActive.value = false
// Reset selection state to ensure clean state when deactivated
selectionStore.reset()
}
return {
// Selection state
selectedIds: computed(() => selectionStore.selectedAssetIds),
selectedCount: computed(() => selectionStore.selectedCount),
hasSelection: computed(() => selectionStore.hasSelection),
isSelected: (assetId: string) => selectionStore.isSelected(assetId),
// Selection actions
handleAssetClick,
selectAll,
clearSelection: () => selectionStore.clearSelection(),
getSelectedAssets,
reset: () => selectionStore.reset(),
// Lifecycle management
activate,
deactivate,
// Key states (for UI feedback)
shiftKey,
cmdOrCtrlKey
}
}

View File

@@ -0,0 +1,81 @@
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
export const useAssetSelectionStore = defineStore('assetSelection', () => {
// State
const selectedAssetIds = ref<Set<string>>(new Set())
const lastSelectedIndex = ref<number>(-1)
// Getters
const selectedCount = computed(() => selectedAssetIds.value.size)
const hasSelection = computed(() => selectedAssetIds.value.size > 0)
const selectedIdsArray = computed(() => Array.from(selectedAssetIds.value))
// Actions
function addToSelection(assetId: string) {
selectedAssetIds.value.add(assetId)
}
function removeFromSelection(assetId: string) {
selectedAssetIds.value.delete(assetId)
}
function setSelection(assetIds: string[]) {
// Only update if there's an actual change to prevent unnecessary re-renders
const newSet = new Set(assetIds)
if (
newSet.size !== selectedAssetIds.value.size ||
!assetIds.every((id) => selectedAssetIds.value.has(id))
) {
selectedAssetIds.value = newSet
}
}
function clearSelection() {
selectedAssetIds.value.clear()
lastSelectedIndex.value = -1
}
function toggleSelection(assetId: string) {
if (isSelected(assetId)) {
removeFromSelection(assetId)
} else {
addToSelection(assetId)
}
}
function isSelected(assetId: string): boolean {
return selectedAssetIds.value.has(assetId)
}
function setLastSelectedIndex(index: number) {
lastSelectedIndex.value = index
}
// Reset function for cleanup
function reset() {
selectedAssetIds.value.clear()
lastSelectedIndex.value = -1
}
return {
// State
selectedAssetIds: computed(() => selectedAssetIds.value),
lastSelectedIndex: computed(() => lastSelectedIndex.value),
// Getters
selectedCount,
hasSelection,
selectedIdsArray,
// Actions
addToSelection,
removeFromSelection,
setSelection,
clearSelection,
toggleSelection,
isSelected,
setLastSelectedIndex,
reset
}
})

View File

@@ -12,7 +12,6 @@ import { useAssetsStore } from '@/stores/assetsStore'
import { useDialogStore } from '@/stores/dialogStore'
import type { AssetItem } from '../schemas/assetSchema'
import type { AssetMeta } from '../schemas/mediaAssetSchema'
import { MediaAssetKey } from '../schemas/mediaAssetSchema'
import { assetService } from '../services/assetService'
@@ -21,10 +20,6 @@ export function useMediaAssetActions() {
const dialogStore = useDialogStore()
const mediaContext = inject(MediaAssetKey, null)
const selectAsset = (asset: AssetMeta) => {
console.log('Asset selected:', asset)
}
const downloadAsset = () => {
const asset = mediaContext?.asset.value
if (!asset) return
@@ -54,6 +49,42 @@ export function useMediaAssetActions() {
}
}
/**
* Download multiple assets at once
* @param assets Array of assets to download
*/
const downloadMultipleAssets = (assets: AssetItem[]) => {
if (!assets || assets.length === 0) return
try {
assets.forEach((asset) => {
const assetType = asset.tags?.[0] || 'output'
const filename = asset.name
const downloadUrl = api.apiURL(
`/view?filename=${encodeURIComponent(filename)}&type=${assetType}`
)
downloadFile(downloadUrl, filename)
})
toast.add({
severity: 'success',
summary: t('g.success'),
detail: t('mediaAsset.selection.downloadsStarted', {
count: assets.length
}),
life: 2000
})
} catch (error) {
console.error('Failed to download assets:', error)
toast.add({
severity: 'error',
summary: t('g.error'),
detail: t('g.failedToDownloadImage'),
life: 3000
})
}
}
/**
* Show confirmation dialog and delete asset if confirmed
* @param asset The asset to delete
@@ -204,11 +235,85 @@ export function useMediaAssetActions() {
console.log('Opening more outputs for asset:', assetId)
}
/**
* Delete multiple assets with confirmation dialog
* @param assets Array of assets to delete
*/
const deleteMultipleAssets = async (assets: AssetItem[]) => {
if (!assets || assets.length === 0) return
const assetsStore = useAssetsStore()
return new Promise<void>((resolve) => {
dialogStore.showDialog({
key: 'delete-multiple-assets-confirmation',
title: t('mediaAsset.deleteSelectedTitle'),
component: ConfirmationDialogContent,
props: {
message: t('mediaAsset.deleteSelectedDescription', {
count: assets.length
}),
type: 'delete',
itemList: assets.map((asset) => asset.name),
onConfirm: async () => {
try {
// Delete all assets
await Promise.all(
assets.map(async (asset) => {
const assetType = asset.tags?.[0] || 'output'
if (assetType === 'output') {
const promptId =
asset.id ||
getOutputAssetMetadata(asset.user_metadata)?.promptId
if (promptId) {
await api.deleteItem('history', promptId)
}
} else if (isCloud) {
await assetService.deleteAsset(asset.id)
}
})
)
// Update stores after deletions
await assetsStore.updateHistory()
if (assets.some((a) => a.tags?.[0] === 'input')) {
await assetsStore.updateInputs()
}
toast.add({
severity: 'success',
summary: t('g.success'),
detail: t('mediaAsset.selection.assetsDeletedSuccessfully', {
count: assets.length
}),
life: 2000
})
} catch (error) {
console.error('Failed to delete assets:', error)
toast.add({
severity: 'error',
summary: t('g.error'),
detail: t('mediaAsset.selection.failedToDeleteAssets'),
life: 3000
})
}
resolve()
},
onCancel: () => {
resolve()
}
}
})
})
}
return {
selectAsset,
downloadAsset,
downloadMultipleAssets,
confirmDelete,
deleteAsset,
deleteMultipleAssets,
playAsset,
copyJobId,
addWorkflow,