mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-27 09:45:13 +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:
129
src/platform/assets/composables/useAssetSelection.ts
Normal file
129
src/platform/assets/composables/useAssetSelection.ts
Normal 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
|
||||
}
|
||||
}
|
||||
81
src/platform/assets/composables/useAssetSelectionStore.ts
Normal file
81
src/platform/assets/composables/useAssetSelectionStore.ts
Normal 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
|
||||
}
|
||||
})
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user