mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-30 19:21:54 +00:00
[feat] Add search functionality to Media Asset Panel (#6691)
## Summary Add search functionality to the Media Asset Panel, allowing users to search for assets by filename. ## Changes ### 1. Search Feature - Added SearchBox component to AssetsSidebarTab header - Implemented fuzzy search using Fuse.js - Works in both Imported and Generated tabs - Search also available in folder view ### 2. New Composable: `useMediaAssetFiltering` - Location: `src/platform/assets/composables/useMediaAssetFiltering.ts` - Encapsulates search logic in a reusable composable - Extensible structure for future filter and sort features - Debounced search (50ms) ### 3. UX Improvements - Search query automatically clears when switching tabs - Search query automatically clears when exiting folder view ## Testing - ✅ TypeScript type check passed - ✅ ESLint/Oxlint passed - ✅ Lint-staged pre-commit hooks passed ## Modified Files - `src/components/sidebar/tabs/AssetsSidebarTab.vue` - Added SearchBox - `src/platform/assets/composables/useMediaAssetFiltering.ts` - New file - `src/locales/en/main.json` - Added i18n key (`sideToolbar.searchAssets`) ## Future Plans - Add filter functionality (file type, date, etc.) - Add sort functionality - Switch to server-side search for OSS/Cloud (after Asset API and Job API release) ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-6691-feat-Add-search-functionality-to-Media-Asset-Panel-2ab6d73d3650817b8b95f3450179524f) by [Unito](https://www.unito.io) --------- Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -10,7 +10,7 @@
|
|||||||
>
|
>
|
||||||
<slot name="top" />
|
<slot name="top" />
|
||||||
</div>
|
</div>
|
||||||
<div v-if="slots.header" class="px-4">
|
<div v-if="slots.header" class="px-4 pb-4">
|
||||||
<slot name="header" />
|
<slot name="header" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -39,6 +39,14 @@
|
|||||||
<Tab value="input">{{ $t('sideToolbar.labels.imported') }}</Tab>
|
<Tab value="input">{{ $t('sideToolbar.labels.imported') }}</Tab>
|
||||||
<Tab value="output">{{ $t('sideToolbar.labels.generated') }}</Tab>
|
<Tab value="output">{{ $t('sideToolbar.labels.generated') }}</Tab>
|
||||||
</TabList>
|
</TabList>
|
||||||
|
<!-- Search Bar -->
|
||||||
|
<div class="pt-2">
|
||||||
|
<SearchBox
|
||||||
|
v-model="searchQuery"
|
||||||
|
:placeholder="$t('sideToolbar.searchAssets')"
|
||||||
|
size="lg"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<template #body>
|
<template #body>
|
||||||
<!-- Loading state -->
|
<!-- Loading state -->
|
||||||
@@ -66,7 +74,7 @@
|
|||||||
:grid-style="{
|
:grid-style="{
|
||||||
display: 'grid',
|
display: 'grid',
|
||||||
gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))',
|
gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))',
|
||||||
padding: '0.5rem',
|
padding: '0 0.5rem',
|
||||||
gap: '0.5rem'
|
gap: '0.5rem'
|
||||||
}"
|
}"
|
||||||
@approach-end="handleApproachEnd"
|
@approach-end="handleApproachEnd"
|
||||||
@@ -157,6 +165,7 @@ import IconTextButton from '@/components/button/IconTextButton.vue'
|
|||||||
import TextButton from '@/components/button/TextButton.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 SearchBox from '@/components/input/SearchBox.vue'
|
||||||
import ResultGallery from '@/components/sidebar/tabs/queue/ResultGallery.vue'
|
import ResultGallery from '@/components/sidebar/tabs/queue/ResultGallery.vue'
|
||||||
import Tab from '@/components/tab/Tab.vue'
|
import Tab from '@/components/tab/Tab.vue'
|
||||||
import TabList from '@/components/tab/TabList.vue'
|
import TabList from '@/components/tab/TabList.vue'
|
||||||
@@ -165,6 +174,7 @@ 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 { useAssetSelection } from '@/platform/assets/composables/useAssetSelection'
|
||||||
import { useMediaAssetActions } from '@/platform/assets/composables/useMediaAssetActions'
|
import { useMediaAssetActions } from '@/platform/assets/composables/useMediaAssetActions'
|
||||||
|
import { useMediaAssetFiltering } from '@/platform/assets/composables/useMediaAssetFiltering'
|
||||||
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'
|
||||||
@@ -228,13 +238,21 @@ const currentGalleryAssetId = ref<string | null>(null)
|
|||||||
|
|
||||||
const folderAssets = ref<AssetItem[]>([])
|
const folderAssets = ref<AssetItem[]>([])
|
||||||
|
|
||||||
const displayAssets = computed(() => {
|
// Base assets before search filtering
|
||||||
|
const baseAssets = computed(() => {
|
||||||
if (isInFolderView.value) {
|
if (isInFolderView.value) {
|
||||||
return folderAssets.value
|
return folderAssets.value
|
||||||
}
|
}
|
||||||
return mediaAssets.value
|
return mediaAssets.value
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Use media asset filtering composable
|
||||||
|
const { searchQuery, filteredAssets } = useMediaAssetFiltering(baseAssets)
|
||||||
|
|
||||||
|
const displayAssets = computed(() => {
|
||||||
|
return filteredAssets.value
|
||||||
|
})
|
||||||
|
|
||||||
watch(displayAssets, (newAssets) => {
|
watch(displayAssets, (newAssets) => {
|
||||||
if (currentGalleryAssetId.value && galleryActiveIndex.value !== -1) {
|
if (currentGalleryAssetId.value && galleryActiveIndex.value !== -1) {
|
||||||
const newIndex = newAssets.findIndex(
|
const newIndex = newAssets.findIndex(
|
||||||
@@ -293,6 +311,8 @@ watch(
|
|||||||
activeTab,
|
activeTab,
|
||||||
() => {
|
() => {
|
||||||
clearSelection()
|
clearSelection()
|
||||||
|
// Clear search when switching tabs
|
||||||
|
searchQuery.value = ''
|
||||||
// Reset pagination state when tab changes
|
// Reset pagination state when tab changes
|
||||||
void refreshAssets()
|
void refreshAssets()
|
||||||
},
|
},
|
||||||
@@ -350,6 +370,7 @@ const exitFolderView = () => {
|
|||||||
folderPromptId.value = null
|
folderPromptId.value = null
|
||||||
folderExecutionTime.value = undefined
|
folderExecutionTime.value = undefined
|
||||||
folderAssets.value = []
|
folderAssets.value = []
|
||||||
|
searchQuery.value = ''
|
||||||
clearSelection()
|
clearSelection()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -620,6 +620,7 @@
|
|||||||
"assets": "Assets",
|
"assets": "Assets",
|
||||||
"mediaAssets": "Media Assets",
|
"mediaAssets": "Media Assets",
|
||||||
"backToAssets": "Back to all assets",
|
"backToAssets": "Back to all assets",
|
||||||
|
"searchAssets": "Search assets...",
|
||||||
"labels": {
|
"labels": {
|
||||||
"queue": "Queue",
|
"queue": "Queue",
|
||||||
"nodes": "Nodes",
|
"nodes": "Nodes",
|
||||||
|
|||||||
37
src/platform/assets/composables/useMediaAssetFiltering.ts
Normal file
37
src/platform/assets/composables/useMediaAssetFiltering.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { refDebounced } from '@vueuse/core'
|
||||||
|
import Fuse from 'fuse.js'
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import type { Ref } from 'vue'
|
||||||
|
|
||||||
|
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Media Asset Filtering composable
|
||||||
|
* Manages search, filter, and sort for media assets
|
||||||
|
*/
|
||||||
|
export function useMediaAssetFiltering(assets: Ref<AssetItem[]>) {
|
||||||
|
const searchQuery = ref('')
|
||||||
|
const debouncedSearchQuery = refDebounced(searchQuery, 50)
|
||||||
|
|
||||||
|
const fuseOptions = {
|
||||||
|
keys: ['name'],
|
||||||
|
threshold: 0.4,
|
||||||
|
includeScore: true
|
||||||
|
}
|
||||||
|
|
||||||
|
const fuse = computed(() => new Fuse(assets.value, fuseOptions))
|
||||||
|
|
||||||
|
const filteredAssets = computed(() => {
|
||||||
|
if (!debouncedSearchQuery.value.trim()) {
|
||||||
|
return assets.value
|
||||||
|
}
|
||||||
|
|
||||||
|
const results = fuse.value.search(debouncedSearchQuery.value)
|
||||||
|
return results.map((result) => result.item)
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
searchQuery,
|
||||||
|
filteredAssets
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user