mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-30 11:11:53 +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:
@@ -18,6 +18,9 @@
|
|||||||
<ScrollPanel class="h-0 grow">
|
<ScrollPanel class="h-0 grow">
|
||||||
<slot name="body" />
|
<slot name="body" />
|
||||||
</ScrollPanel>
|
</ScrollPanel>
|
||||||
|
<div v-if="slots.footer">
|
||||||
|
<slot name="footer" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -41,44 +41,102 @@
|
|||||||
</TabList>
|
</TabList>
|
||||||
</template>
|
</template>
|
||||||
<template #body>
|
<template #body>
|
||||||
<VirtualGrid
|
<div v-if="displayAssets.length" class="relative size-full">
|
||||||
v-if="displayAssets.length"
|
<VirtualGrid
|
||||||
:items="mediaAssetsWithKey"
|
v-if="displayAssets.length"
|
||||||
:grid-style="{
|
:items="mediaAssetsWithKey"
|
||||||
display: 'grid',
|
:grid-style="{
|
||||||
gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))',
|
display: 'grid',
|
||||||
padding: '0.5rem',
|
gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))',
|
||||||
gap: '0.5rem'
|
padding: '0.5rem',
|
||||||
}"
|
gap: '0.5rem'
|
||||||
>
|
}"
|
||||||
<template #item="{ item }">
|
>
|
||||||
<MediaAssetCard
|
<template #item="{ item }">
|
||||||
:asset="item"
|
<MediaAssetCard
|
||||||
:selected="selectedAsset?.id === item.id"
|
:asset="item"
|
||||||
:show-output-count="shouldShowOutputCount(item)"
|
:selected="isSelected(item.id)"
|
||||||
:output-count="getOutputCount(item)"
|
:show-output-count="shouldShowOutputCount(item)"
|
||||||
@click="handleAssetSelect(item)"
|
:output-count="getOutputCount(item)"
|
||||||
@zoom="handleZoomClick(item)"
|
:show-delete-button="!isInFolderView"
|
||||||
@output-count-click="enterFolderView(item)"
|
@click="handleAssetSelect(item)"
|
||||||
@asset-deleted="refreshAssets"
|
@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>
|
</div>
|
||||||
</VirtualGrid>
|
<div v-else>
|
||||||
<div v-else-if="loading">
|
<NoResultsPlaceholder
|
||||||
<ProgressSpinner class="absolute left-1/2 w-[50px] -translate-x-1/2" />
|
icon="pi pi-info-circle"
|
||||||
|
:title="
|
||||||
|
$t(
|
||||||
|
activeTab === 'input'
|
||||||
|
? 'sideToolbar.noImportedFiles'
|
||||||
|
: 'sideToolbar.noGeneratedFiles'
|
||||||
|
)
|
||||||
|
"
|
||||||
|
:message="$t('sideToolbar.noFilesFoundMessage')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-else>
|
</template>
|
||||||
<NoResultsPlaceholder
|
<template #footer>
|
||||||
icon="pi pi-info-circle"
|
<div
|
||||||
:title="
|
v-if="hasSelection && activeTab === 'output'"
|
||||||
$t(
|
class="flex h-18 w-full items-center justify-between px-4"
|
||||||
activeTab === 'input'
|
>
|
||||||
? 'sideToolbar.noImportedFiles'
|
<div>
|
||||||
: 'sideToolbar.noGeneratedFiles'
|
<TextButton
|
||||||
)
|
v-if="isHoveringSelectionCount"
|
||||||
"
|
:label="$t('mediaAsset.selection.deselectAll')"
|
||||||
:message="$t('sideToolbar.noFilesFoundMessage')"
|
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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</AssetsSidebarTemplate>
|
</AssetsSidebarTemplate>
|
||||||
@@ -91,9 +149,10 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import ProgressSpinner from 'primevue/progressspinner'
|
import ProgressSpinner from 'primevue/progressspinner'
|
||||||
import { useToast } from 'primevue/usetoast'
|
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 IconTextButton from '@/components/button/IconTextButton.vue'
|
||||||
|
import TextButton from '@/components/button/TextButton.vue'
|
||||||
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
|
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
|
||||||
import VirtualGrid from '@/components/common/VirtualGrid.vue'
|
import VirtualGrid from '@/components/common/VirtualGrid.vue'
|
||||||
import ResultGallery from '@/components/sidebar/tabs/queue/ResultGallery.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 { t } from '@/i18n'
|
||||||
import MediaAssetCard from '@/platform/assets/components/MediaAssetCard.vue'
|
import MediaAssetCard from '@/platform/assets/components/MediaAssetCard.vue'
|
||||||
import { useMediaAssets } from '@/platform/assets/composables/media/useMediaAssets'
|
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 { getOutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema'
|
||||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||||
import { ResultItemImpl } from '@/stores/queueStore'
|
import { ResultItemImpl } from '@/stores/queueStore'
|
||||||
@@ -110,7 +171,6 @@ import { formatDuration, getMediaTypeFromFilename } from '@/utils/formatUtil'
|
|||||||
import AssetsSidebarTemplate from './AssetSidebarTemplate.vue'
|
import AssetsSidebarTemplate from './AssetSidebarTemplate.vue'
|
||||||
|
|
||||||
const activeTab = ref<'input' | 'output'>('input')
|
const activeTab = ref<'input' | 'output'>('input')
|
||||||
const selectedAsset = ref<AssetItem | null>(null)
|
|
||||||
const folderPromptId = ref<string | null>(null)
|
const folderPromptId = ref<string | null>(null)
|
||||||
const folderExecutionTime = ref<number | undefined>(undefined)
|
const folderExecutionTime = ref<number | undefined>(undefined)
|
||||||
const isInFolderView = computed(() => folderPromptId.value !== null)
|
const isInFolderView = computed(() => folderPromptId.value !== null)
|
||||||
@@ -137,6 +197,23 @@ const toast = useToast()
|
|||||||
const inputAssets = useMediaAssets('input')
|
const inputAssets = useMediaAssets('input')
|
||||||
const outputAssets = useMediaAssets('output')
|
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(() =>
|
const currentAssets = computed(() =>
|
||||||
activeTab.value === 'input' ? inputAssets : outputAssets
|
activeTab.value === 'input' ? inputAssets : outputAssets
|
||||||
)
|
)
|
||||||
@@ -213,17 +290,15 @@ const refreshAssets = async () => {
|
|||||||
watch(
|
watch(
|
||||||
activeTab,
|
activeTab,
|
||||||
() => {
|
() => {
|
||||||
|
clearSelection()
|
||||||
void refreshAssets()
|
void refreshAssets()
|
||||||
},
|
},
|
||||||
{ immediate: true }
|
{ immediate: true }
|
||||||
)
|
)
|
||||||
|
|
||||||
const handleAssetSelect = (asset: AssetItem) => {
|
const handleAssetSelect = (asset: AssetItem) => {
|
||||||
if (selectedAsset.value?.id === asset.id) {
|
const index = displayAssets.value.findIndex((a) => a.id === asset.id)
|
||||||
selectedAsset.value = null
|
handleAssetClick(asset, index, displayAssets.value)
|
||||||
} else {
|
|
||||||
selectedAsset.value = asset
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleZoomClick = (asset: AssetItem) => {
|
const handleZoomClick = (asset: AssetItem) => {
|
||||||
@@ -272,6 +347,20 @@ const exitFolderView = () => {
|
|||||||
folderPromptId.value = null
|
folderPromptId.value = null
|
||||||
folderExecutionTime.value = undefined
|
folderExecutionTime.value = undefined
|
||||||
folderAssets.value = []
|
folderAssets.value = []
|
||||||
|
clearSelection()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
activateSelection()
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
deactivateSelection()
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleDeselectAll = () => {
|
||||||
|
clearSelection()
|
||||||
|
isHoveringSelectionCount.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
const copyJobId = async () => {
|
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>
|
</script>
|
||||||
|
|||||||
@@ -1779,6 +1779,8 @@
|
|||||||
"mediaAsset": {
|
"mediaAsset": {
|
||||||
"deleteAssetTitle": "Delete this asset?",
|
"deleteAssetTitle": "Delete this asset?",
|
||||||
"deleteAssetDescription": "This asset will be permanently removed.",
|
"deleteAssetDescription": "This asset will be permanently removed.",
|
||||||
|
"deleteSelectedTitle": "Delete selected assets?",
|
||||||
|
"deleteSelectedDescription": "{count} asset(s) will be permanently removed.",
|
||||||
"assetDeletedSuccessfully": "Asset deleted successfully",
|
"assetDeletedSuccessfully": "Asset deleted successfully",
|
||||||
"deletingImportedFilesCloudOnly": "Deleting imported files is only supported in cloud version",
|
"deletingImportedFilesCloudOnly": "Deleting imported files is only supported in cloud version",
|
||||||
"failedToDeleteAsset": "Failed to delete asset",
|
"failedToDeleteAsset": "Failed to delete asset",
|
||||||
@@ -1787,6 +1789,16 @@
|
|||||||
"jobIdCopyFailed": "Failed to copy Job ID",
|
"jobIdCopyFailed": "Failed to copy Job ID",
|
||||||
"copied": "Copied",
|
"copied": "Copied",
|
||||||
"error": "Error"
|
"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": {
|
"actionbar": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<IconGroup>
|
<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" />
|
<i class="icon-[lucide--trash-2] size-4" />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
<IconButton size="sm" @click="handleDownload">
|
<IconButton size="sm" @click="handleDownload">
|
||||||
@@ -14,6 +14,7 @@
|
|||||||
<template #default="{ close }">
|
<template #default="{ close }">
|
||||||
<MediaAssetMoreMenu
|
<MediaAssetMoreMenu
|
||||||
:close="close"
|
:close="close"
|
||||||
|
:show-delete-button="showDeleteButton"
|
||||||
@inspect="emit('inspect')"
|
@inspect="emit('inspect')"
|
||||||
@asset-deleted="emit('asset-deleted')"
|
@asset-deleted="emit('asset-deleted')"
|
||||||
/>
|
/>
|
||||||
@@ -34,6 +35,10 @@ import { useMediaAssetActions } from '../composables/useMediaAssetActions'
|
|||||||
import { MediaAssetKey } from '../schemas/mediaAssetSchema'
|
import { MediaAssetKey } from '../schemas/mediaAssetSchema'
|
||||||
import MediaAssetMoreMenu from './MediaAssetMoreMenu.vue'
|
import MediaAssetMoreMenu from './MediaAssetMoreMenu.vue'
|
||||||
|
|
||||||
|
const { showDeleteButton } = defineProps<{
|
||||||
|
showDeleteButton?: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
menuStateChanged: [isOpen: boolean]
|
menuStateChanged: [isOpen: boolean]
|
||||||
inspect: []
|
inspect: []
|
||||||
@@ -47,10 +52,12 @@ const assetType = computed(() => {
|
|||||||
return context?.value?.type || asset.value?.tags?.[0] || 'output'
|
return context?.value?.type || asset.value?.tags?.[0] || 'output'
|
||||||
})
|
})
|
||||||
|
|
||||||
const showDeleteButton = computed(() => {
|
const shouldShowDeleteButton = computed(() => {
|
||||||
return (
|
const propAllows = showDeleteButton ?? true
|
||||||
|
const typeAllows =
|
||||||
assetType.value === 'output' || (assetType.value === 'input' && isCloud)
|
assetType.value === 'output' || (assetType.value === 'input' && isCloud)
|
||||||
)
|
|
||||||
|
return propAllows && typeAllows
|
||||||
})
|
})
|
||||||
|
|
||||||
const handleDelete = async () => {
|
const handleDelete = async () => {
|
||||||
|
|||||||
@@ -15,9 +15,6 @@
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
rounded="lg"
|
rounded="lg"
|
||||||
:class="containerClasses"
|
:class="containerClasses"
|
||||||
@click="handleCardClick"
|
|
||||||
@keydown.enter="handleCardClick"
|
|
||||||
@keydown.space.prevent="handleCardClick"
|
|
||||||
>
|
>
|
||||||
<template #top>
|
<template #top>
|
||||||
<CardTop
|
<CardTop
|
||||||
@@ -50,6 +47,7 @@
|
|||||||
<!-- Actions overlay (top-left) - show on hover or when menu is open -->
|
<!-- Actions overlay (top-left) - show on hover or when menu is open -->
|
||||||
<template v-if="showActionsOverlay" #top-left>
|
<template v-if="showActionsOverlay" #top-left>
|
||||||
<MediaAssetActions
|
<MediaAssetActions
|
||||||
|
:show-delete-button="showDeleteButton ?? true"
|
||||||
@menu-state-changed="isMenuOpen = $event"
|
@menu-state-changed="isMenuOpen = $event"
|
||||||
@inspect="handleZoomClick"
|
@inspect="handleZoomClick"
|
||||||
@asset-deleted="handleAssetDelete"
|
@asset-deleted="handleAssetDelete"
|
||||||
@@ -174,12 +172,20 @@ function getBottomComponent(kind: MediaKind) {
|
|||||||
return mediaComponents.bottom[kind] || mediaComponents.bottom.image
|
return mediaComponents.bottom[kind] || mediaComponents.bottom.image
|
||||||
}
|
}
|
||||||
|
|
||||||
const { asset, loading, selected, showOutputCount, outputCount } = defineProps<{
|
const {
|
||||||
|
asset,
|
||||||
|
loading,
|
||||||
|
selected,
|
||||||
|
showOutputCount,
|
||||||
|
outputCount,
|
||||||
|
showDeleteButton
|
||||||
|
} = defineProps<{
|
||||||
asset?: AssetItem
|
asset?: AssetItem
|
||||||
loading?: boolean
|
loading?: boolean
|
||||||
selected?: boolean
|
selected?: boolean
|
||||||
showOutputCount?: boolean
|
showOutputCount?: boolean
|
||||||
outputCount?: number
|
outputCount?: number
|
||||||
|
showDeleteButton?: boolean
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -312,12 +318,6 @@ const showFileFormatChip = computed(
|
|||||||
(!isVideoPlaying.value || isCardOrOverlayHovered.value)
|
(!isVideoPlaying.value || isCardOrOverlayHovered.value)
|
||||||
)
|
)
|
||||||
|
|
||||||
const handleCardClick = () => {
|
|
||||||
if (adaptedAsset.value) {
|
|
||||||
actions.selectAsset(adaptedAsset.value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleOverlayMouseEnter = () => {
|
const handleOverlayMouseEnter = () => {
|
||||||
isOverlayHovered.value = true
|
isOverlayHovered.value = true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -75,10 +75,10 @@
|
|||||||
</template>
|
</template>
|
||||||
</IconTextButton>
|
</IconTextButton>
|
||||||
|
|
||||||
<MediaAssetButtonDivider v-if="showCopyJobId && showDeleteButton" />
|
<MediaAssetButtonDivider v-if="showCopyJobId && shouldShowDeleteButton" />
|
||||||
|
|
||||||
<IconTextButton
|
<IconTextButton
|
||||||
v-if="showDeleteButton"
|
v-if="shouldShowDeleteButton"
|
||||||
type="transparent"
|
type="transparent"
|
||||||
class="dark-theme:text-white"
|
class="dark-theme:text-white"
|
||||||
label="Delete"
|
label="Delete"
|
||||||
@@ -101,8 +101,9 @@ import { useMediaAssetActions } from '../composables/useMediaAssetActions'
|
|||||||
import { MediaAssetKey } from '../schemas/mediaAssetSchema'
|
import { MediaAssetKey } from '../schemas/mediaAssetSchema'
|
||||||
import MediaAssetButtonDivider from './MediaAssetButtonDivider.vue'
|
import MediaAssetButtonDivider from './MediaAssetButtonDivider.vue'
|
||||||
|
|
||||||
const { close } = defineProps<{
|
const { close, showDeleteButton } = defineProps<{
|
||||||
close: () => void
|
close: () => void
|
||||||
|
showDeleteButton?: boolean
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -124,13 +125,12 @@ const showCopyJobId = computed(() => {
|
|||||||
return assetType.value !== 'input'
|
return assetType.value !== 'input'
|
||||||
})
|
})
|
||||||
|
|
||||||
// Delete button should be shown for:
|
const shouldShowDeleteButton = computed(() => {
|
||||||
// - All output files (can be deleted via history)
|
const propAllows = showDeleteButton ?? true
|
||||||
// - Input files only in cloud environment
|
const typeAllows =
|
||||||
const showDeleteButton = computed(() => {
|
|
||||||
return (
|
|
||||||
assetType.value === 'output' || (assetType.value === 'input' && isCloud)
|
assetType.value === 'output' || (assetType.value === 'input' && isCloud)
|
||||||
)
|
|
||||||
|
return propAllows && typeAllows
|
||||||
})
|
})
|
||||||
|
|
||||||
const handleInspect = () => {
|
const handleInspect = () => {
|
||||||
|
|||||||
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 { useDialogStore } from '@/stores/dialogStore'
|
||||||
|
|
||||||
import type { AssetItem } from '../schemas/assetSchema'
|
import type { AssetItem } from '../schemas/assetSchema'
|
||||||
import type { AssetMeta } from '../schemas/mediaAssetSchema'
|
|
||||||
import { MediaAssetKey } from '../schemas/mediaAssetSchema'
|
import { MediaAssetKey } from '../schemas/mediaAssetSchema'
|
||||||
import { assetService } from '../services/assetService'
|
import { assetService } from '../services/assetService'
|
||||||
|
|
||||||
@@ -21,10 +20,6 @@ export function useMediaAssetActions() {
|
|||||||
const dialogStore = useDialogStore()
|
const dialogStore = useDialogStore()
|
||||||
const mediaContext = inject(MediaAssetKey, null)
|
const mediaContext = inject(MediaAssetKey, null)
|
||||||
|
|
||||||
const selectAsset = (asset: AssetMeta) => {
|
|
||||||
console.log('Asset selected:', asset)
|
|
||||||
}
|
|
||||||
|
|
||||||
const downloadAsset = () => {
|
const downloadAsset = () => {
|
||||||
const asset = mediaContext?.asset.value
|
const asset = mediaContext?.asset.value
|
||||||
if (!asset) return
|
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
|
* Show confirmation dialog and delete asset if confirmed
|
||||||
* @param asset The asset to delete
|
* @param asset The asset to delete
|
||||||
@@ -204,11 +235,85 @@ export function useMediaAssetActions() {
|
|||||||
console.log('Opening more outputs for asset:', assetId)
|
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 {
|
return {
|
||||||
selectAsset,
|
|
||||||
downloadAsset,
|
downloadAsset,
|
||||||
|
downloadMultipleAssets,
|
||||||
confirmDelete,
|
confirmDelete,
|
||||||
deleteAsset,
|
deleteAsset,
|
||||||
|
deleteMultipleAssets,
|
||||||
playAsset,
|
playAsset,
|
||||||
copyJobId,
|
copyJobId,
|
||||||
addWorkflow,
|
addWorkflow,
|
||||||
|
|||||||
Reference in New Issue
Block a user