mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-07-17 09:18:26 +00:00
Compare commits
2 Commits
feat/parti
...
feat/media
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f55a0d42d9 | ||
|
|
a81e20228c |
@@ -1,7 +1,10 @@
|
||||
<template>
|
||||
<div class="flex h-full flex-col">
|
||||
<!-- Assets Grid -->
|
||||
<!-- key on gridMode remounts the virtualizer so it re-measures cell size
|
||||
when switching density (it caches item height/width otherwise). -->
|
||||
<VirtualGrid
|
||||
:key="gridMode"
|
||||
class="flex-1"
|
||||
:items="assetItems"
|
||||
:grid-style="gridStyle"
|
||||
@@ -28,13 +31,22 @@ import { computed } from 'vue'
|
||||
|
||||
import VirtualGrid from '@/components/common/VirtualGrid.vue'
|
||||
import MediaAssetCard from '@/platform/assets/components/MediaAssetCard.vue'
|
||||
import { gridColumnsForMode } from '@/platform/assets/components/mediaAssetViewOptions'
|
||||
import type { MediaAssetViewMode } from '@/platform/assets/components/mediaAssetViewOptions'
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
|
||||
const { assets, isSelected, showOutputCount, getOutputCount } = defineProps<{
|
||||
const {
|
||||
assets,
|
||||
isSelected,
|
||||
showOutputCount,
|
||||
getOutputCount,
|
||||
gridMode = 'grid-small'
|
||||
} = defineProps<{
|
||||
assets: AssetItem[]
|
||||
isSelected: (assetId: string) => boolean
|
||||
showOutputCount: (asset: AssetItem) => boolean
|
||||
getOutputCount: (asset: AssetItem) => number
|
||||
gridMode?: MediaAssetViewMode
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -54,10 +66,10 @@ const assetItems = computed<AssetGridItem[]>(() =>
|
||||
}))
|
||||
)
|
||||
|
||||
const gridStyle = {
|
||||
const gridStyle = computed(() => ({
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(min(200px, 30vw), 1fr))',
|
||||
gridTemplateColumns: gridColumnsForMode(gridMode),
|
||||
padding: '0 0.5rem',
|
||||
gap: '0.5rem'
|
||||
}
|
||||
}))
|
||||
</script>
|
||||
|
||||
@@ -39,6 +39,7 @@
|
||||
v-model:sort-by="sortBy"
|
||||
v-model:view-mode="viewMode"
|
||||
v-model:media-type-filters="mediaTypeFilters"
|
||||
v-model:date-filter="dateFilter"
|
||||
bottom-divider
|
||||
:show-generation-time-sort="activeTab === 'output'"
|
||||
/>
|
||||
@@ -85,6 +86,7 @@
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
ref="assetPaneRef"
|
||||
class="relative size-full py-2"
|
||||
@click="handleEmptySpaceClick"
|
||||
>
|
||||
@@ -106,6 +108,7 @@
|
||||
:is-selected="isSelected"
|
||||
:show-output-count="shouldShowOutputCount"
|
||||
:get-output-count="getOutputCount"
|
||||
:grid-mode="viewMode"
|
||||
@select-asset="handleAssetSelect"
|
||||
@context-menu="handleAssetContextMenu"
|
||||
@approach-end="handleApproachEnd"
|
||||
@@ -180,9 +183,13 @@ import Button from '@/components/ui/button/Button.vue'
|
||||
import MediaAssetContextMenu from '@/platform/assets/components/MediaAssetContextMenu.vue'
|
||||
import MediaAssetFilterBar from '@/platform/assets/components/MediaAssetFilterBar.vue'
|
||||
import MediaAssetSelectionBar from '@/platform/assets/components/MediaAssetSelectionBar.vue'
|
||||
import type { MediaAssetViewMode } from '@/platform/assets/components/mediaAssetViewOptions'
|
||||
import { getAssetType } from '@/platform/assets/composables/media/assetMappers'
|
||||
import { useAssetsApi } from '@/platform/assets/composables/media/useAssetsApi'
|
||||
import { useAssetSelection } from '@/platform/assets/composables/useAssetSelection'
|
||||
import {
|
||||
shouldInterceptSelectAll,
|
||||
useAssetSelection
|
||||
} from '@/platform/assets/composables/useAssetSelection'
|
||||
import { useMediaAssetActions } from '@/platform/assets/composables/useMediaAssetActions'
|
||||
import { useMediaAssetFiltering } from '@/platform/assets/composables/useMediaAssetFiltering'
|
||||
import { useOutputStacks } from '@/platform/assets/composables/useOutputStacks'
|
||||
@@ -215,11 +222,16 @@ const folderJobId = ref<string | null>(null)
|
||||
const folderExecutionTime = ref<number | undefined>(undefined)
|
||||
const expectedFolderCount = ref(0)
|
||||
const isInFolderView = computed(() => folderJobId.value !== null)
|
||||
const viewMode = useStorage<'list' | 'grid'>(
|
||||
const viewMode = useStorage<MediaAssetViewMode>(
|
||||
'Comfy.Assets.Sidebar.ViewMode',
|
||||
'grid'
|
||||
'grid-small'
|
||||
)
|
||||
// Migrate the pre-split legacy 'grid' value to the dense grid.
|
||||
if (!['list', 'grid-small', 'grid-large'].includes(viewMode.value)) {
|
||||
viewMode.value = 'grid-small'
|
||||
}
|
||||
const isListView = computed(() => viewMode.value === 'list')
|
||||
const assetPaneRef = ref<HTMLElement>()
|
||||
|
||||
const contextMenuRef = ref<InstanceType<typeof MediaAssetContextMenu>>()
|
||||
const contextMenuAsset = ref<AssetItem | null>(null)
|
||||
@@ -260,6 +272,7 @@ const outputAssets = useAssetsApi('output')
|
||||
const {
|
||||
isSelected,
|
||||
handleAssetClick,
|
||||
selectAll,
|
||||
hasSelection,
|
||||
clearSelection,
|
||||
getSelectedAssets,
|
||||
@@ -316,7 +329,7 @@ const baseAssets = computed(() => {
|
||||
})
|
||||
|
||||
// Use media asset filtering composable
|
||||
const { searchQuery, sortBy, mediaTypeFilters, filteredAssets } =
|
||||
const { searchQuery, sortBy, mediaTypeFilters, dateFilter, filteredAssets } =
|
||||
useMediaAssetFiltering(baseAssets)
|
||||
|
||||
const displayAssets = computed(() => {
|
||||
@@ -418,8 +431,10 @@ watch(
|
||||
activeTab,
|
||||
() => {
|
||||
clearSelection()
|
||||
// Clear search when switching tabs
|
||||
// Clear search + filters when switching tabs so no stale filter hides results.
|
||||
searchQuery.value = ''
|
||||
mediaTypeFilters.value = []
|
||||
dateFilter.value = ''
|
||||
// Reset pagination state when tab changes
|
||||
void refreshAssets()
|
||||
},
|
||||
@@ -562,12 +577,27 @@ const exitFolderView = () => {
|
||||
searchQuery.value = ''
|
||||
}
|
||||
|
||||
function handleSelectAllKeydown(event: KeyboardEvent) {
|
||||
if (!shouldInterceptSelectAll(event, assetPaneRef.value)) return
|
||||
// Stop the event before the canvas keybinding handler runs: it is a
|
||||
// bubble-phase window listener whose Comfy.Canvas.SelectAll is scoped to
|
||||
// graph-canvas-container, which the asset pane lives inside — so without this
|
||||
// it would also select every node. Capture phase beats the bubble listener.
|
||||
event.preventDefault()
|
||||
event.stopImmediatePropagation()
|
||||
selectAll(visibleAssets.value)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
activateSelection()
|
||||
window.addEventListener('keydown', handleSelectAllKeydown, { capture: true })
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
deactivateSelection()
|
||||
window.removeEventListener('keydown', handleSelectAllKeydown, {
|
||||
capture: true
|
||||
})
|
||||
})
|
||||
|
||||
const handleDeselectAll = () => {
|
||||
|
||||
@@ -893,6 +893,8 @@
|
||||
"title": "Media Assets",
|
||||
"sortNewestFirst": "Newest first",
|
||||
"sortOldestFirst": "Oldest first",
|
||||
"sortAToZ": "Name (A → Z)",
|
||||
"sortZToA": "Name (Z → A)",
|
||||
"sortLongestFirst": "Generation time (longest first)",
|
||||
"sortFastestFirst": "Generation time (fastest first)",
|
||||
"filterImage": "Image",
|
||||
@@ -900,6 +902,20 @@
|
||||
"filterAudio": "Audio",
|
||||
"filter3D": "3D",
|
||||
"filterText": "Text",
|
||||
"filterBy": "Filter by...",
|
||||
"filterNoMatches": "No matches",
|
||||
"filterMediaType": "Media type",
|
||||
"filterDate": "Date",
|
||||
"filterGroupAttribute": "Attribute",
|
||||
"dateAll": "All time",
|
||||
"dateToday": "Today",
|
||||
"datePastWeek": "Past 7 days",
|
||||
"datePastMonth": "Past 30 days",
|
||||
"dateThisYear": "This year",
|
||||
"clearFilters": "Clear all",
|
||||
"removeFilter": "Remove {label} filter",
|
||||
"viewGridSmall": "Grid (small)",
|
||||
"viewGridLarge": "Grid (large)",
|
||||
"viewSettings": "View settings"
|
||||
},
|
||||
"backToAssets": "Back to all assets",
|
||||
@@ -984,7 +1000,6 @@
|
||||
"currentNode": "Current node:",
|
||||
"viewAllJobs": "View all jobs",
|
||||
"viewList": "List view",
|
||||
"viewGrid": "Grid view",
|
||||
"running": "running",
|
||||
"preview": "Preview",
|
||||
"interruptAll": "Interrupt all running jobs",
|
||||
@@ -3482,8 +3497,8 @@
|
||||
},
|
||||
"selection": {
|
||||
"selectedCount": "{count} selected",
|
||||
"unselectCount": "Unselect {count}",
|
||||
"multipleSelectedAssets": "Multiple assets selected",
|
||||
"deselectAll": "Deselect all",
|
||||
"downloadSelected": "Download",
|
||||
"downloadSelectedAll": "Download all",
|
||||
"deleteSelected": "Delete",
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
: $t('assetBrowser.ariaLabel.loadingAsset')
|
||||
"
|
||||
:tabindex="loading ? -1 : 0"
|
||||
:aria-pressed="selected"
|
||||
:class="
|
||||
cn(
|
||||
'flex cursor-pointer flex-col overflow-hidden rounded-lg p-2 transition-colors duration-200',
|
||||
@@ -54,20 +55,19 @@
|
||||
<i class="icon-[lucide--trash-2] size-5" />
|
||||
</LoadingOverlay>
|
||||
|
||||
<!-- Action buttons overlay (top-left) -->
|
||||
<!-- Action buttons overlay (top-right) -->
|
||||
<div
|
||||
v-if="showActionsOverlay"
|
||||
class="absolute top-2 left-2 flex flex-wrap justify-start gap-2"
|
||||
class="absolute top-2 right-2 flex flex-wrap justify-end gap-2"
|
||||
>
|
||||
<IconGroup background-class="bg-white">
|
||||
<Button
|
||||
v-if="canInspect"
|
||||
variant="overlay-white"
|
||||
size="icon"
|
||||
:aria-label="$t('mediaAsset.actions.zoom')"
|
||||
@click.stop="handleZoomClick"
|
||||
:aria-label="$t('mediaAsset.actions.download')"
|
||||
@click.stop="asset && actions.downloadAssets([asset])"
|
||||
>
|
||||
<i class="icon-[lucide--zoom-in] size-4" />
|
||||
<i class="icon-[lucide--download] size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="overlay-white"
|
||||
@@ -81,6 +81,15 @@
|
||||
</Button>
|
||||
</IconGroup>
|
||||
</div>
|
||||
|
||||
<!-- Selected check (top-left) -->
|
||||
<div
|
||||
v-if="selected"
|
||||
class="absolute top-2 left-2 flex size-6 items-center justify-center rounded-full bg-white text-black shadow-sm"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<i class="icon-[lucide--check] size-4" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bottom Area: Media Info -->
|
||||
@@ -101,34 +110,30 @@
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div
|
||||
v-else-if="asset && adaptedAsset"
|
||||
class="flex items-end justify-between gap-1.5"
|
||||
>
|
||||
<!-- Left side: Media name and metadata -->
|
||||
<div class="flex flex-col gap-1">
|
||||
<!-- Title -->
|
||||
<MediaTitle :file-name="fileName" />
|
||||
<!-- Metadata -->
|
||||
<div class="flex gap-1.5 text-xs text-muted-foreground">
|
||||
<span v-if="formattedDuration">{{ formattedDuration }}</span>
|
||||
<span v-if="metaInfo">{{ metaInfo }}</span>
|
||||
<div v-else-if="asset && adaptedAsset" class="flex flex-col gap-1">
|
||||
<!-- Title + output count -->
|
||||
<div class="flex items-center justify-between gap-1.5">
|
||||
<MediaTitle :file-name="fileName" class="min-w-0" />
|
||||
<!-- Output count -->
|
||||
<div v-if="showOutputCount" class="shrink-0">
|
||||
<Button
|
||||
v-tooltip.top.pt:pointer-events-none="
|
||||
$t('mediaAsset.actions.seeMoreOutputs')
|
||||
"
|
||||
:aria-label="$t('mediaAsset.actions.seeMoreOutputs')"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
@click.stop="handleOutputCountClick"
|
||||
>
|
||||
<i class="icon-[lucide--layers] size-4" />
|
||||
<span>{{ outputCount }}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right side: Output count -->
|
||||
<div v-if="showOutputCount" class="shrink-0">
|
||||
<Button
|
||||
v-tooltip.top.pt:pointer-events-none="
|
||||
$t('mediaAsset.actions.seeMoreOutputs')
|
||||
"
|
||||
:aria-label="$t('mediaAsset.actions.seeMoreOutputs')"
|
||||
variant="secondary"
|
||||
@click.stop="handleOutputCountClick"
|
||||
>
|
||||
<i class="icon-[lucide--layers] size-4" />
|
||||
<span>{{ outputCount }}</span>
|
||||
</Button>
|
||||
<!-- File details -->
|
||||
<div class="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<span v-if="formattedDuration">{{ formattedDuration }}</span>
|
||||
<span v-if="metaInfo">{{ metaInfo }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -285,16 +290,24 @@ const displayImageDimensions = computed(() =>
|
||||
resolveDisplayImageDimensions(asset, imageDimensions.value)
|
||||
)
|
||||
|
||||
// Get metadata info based on file kind
|
||||
const format = computed(() => {
|
||||
const suffix = getFilenameDetails(asset?.name ?? '').suffix
|
||||
return suffix ? suffix.toUpperCase() : ''
|
||||
})
|
||||
|
||||
// Meta line: "FORMAT dimensions" for images, otherwise "FORMAT size".
|
||||
const metaInfo = computed(() => {
|
||||
if (!asset) return ''
|
||||
const parts: string[] = []
|
||||
if (format.value) parts.push(format.value)
|
||||
if (fileKind.value === 'image' && displayImageDimensions.value) {
|
||||
return `${displayImageDimensions.value.width}x${displayImageDimensions.value.height}`
|
||||
parts.push(
|
||||
`${displayImageDimensions.value.width}x${displayImageDimensions.value.height}`
|
||||
)
|
||||
} else if (asset.size) {
|
||||
parts.push(formatSize(asset.size))
|
||||
}
|
||||
if (asset.size && ['video', 'audio', '3D'].includes(fileKind.value)) {
|
||||
return formatSize(asset.size)
|
||||
}
|
||||
return ''
|
||||
return parts.join(' ')
|
||||
})
|
||||
|
||||
const showActionsOverlay = computed(() => {
|
||||
|
||||
@@ -1,72 +1,118 @@
|
||||
<template>
|
||||
<SidebarTopArea :bottom-divider>
|
||||
<SearchInput
|
||||
:model-value="searchQuery"
|
||||
:placeholder="
|
||||
$t('g.searchPlaceholder', { subject: $t('sideToolbar.labels.assets') })
|
||||
"
|
||||
@update:model-value="handleSearchChange"
|
||||
<div>
|
||||
<SidebarTopArea>
|
||||
<MediaAssetSearchField
|
||||
v-model="searchQuery"
|
||||
:placeholder="
|
||||
$t('g.searchPlaceholder', {
|
||||
subject: $t('sideToolbar.labels.assets')
|
||||
})
|
||||
"
|
||||
/>
|
||||
<template #actions>
|
||||
<MediaAssetFilterMenu
|
||||
v-if="isCloud"
|
||||
v-model:media-type-filters="mediaTypeFilters"
|
||||
v-model:date-filter="dateFilter"
|
||||
:active="hasActiveFilters"
|
||||
/>
|
||||
<MediaAssetSettingsButton
|
||||
v-tooltip.top="{ value: $t('sideToolbar.mediaAssets.viewSettings') }"
|
||||
>
|
||||
<template #default>
|
||||
<MediaAssetSettingsMenu
|
||||
v-model:view-mode="viewMode"
|
||||
v-model:sort-by="sortBy"
|
||||
:show-sort-options="isCloud"
|
||||
:show-generation-time-sort
|
||||
/>
|
||||
</template>
|
||||
</MediaAssetSettingsButton>
|
||||
</template>
|
||||
</SidebarTopArea>
|
||||
|
||||
<MediaAssetFilterChips
|
||||
v-if="filterChips.length"
|
||||
:chips="filterChips"
|
||||
class="px-2 pb-2 2xl:px-4"
|
||||
@remove="removeChip"
|
||||
@clear="clearAllFilters"
|
||||
/>
|
||||
<template #actions>
|
||||
<MediaAssetFilterButton
|
||||
v-if="isCloud"
|
||||
v-tooltip.top="{ value: $t('assetBrowser.filterBy') }"
|
||||
>
|
||||
<template #default="{ close }">
|
||||
<MediaAssetFilterMenu
|
||||
:media-type-filters
|
||||
:close
|
||||
@update:media-type-filters="handleMediaTypeFiltersChange"
|
||||
/>
|
||||
</template>
|
||||
</MediaAssetFilterButton>
|
||||
<MediaAssetSettingsButton
|
||||
v-tooltip.top="{ value: $t('sideToolbar.mediaAssets.viewSettings') }"
|
||||
>
|
||||
<template #default>
|
||||
<MediaAssetSettingsMenu
|
||||
v-model:view-mode="viewMode"
|
||||
v-model:sort-by="sortBy"
|
||||
:show-sort-options="isCloud"
|
||||
:show-generation-time-sort
|
||||
/>
|
||||
</template>
|
||||
</MediaAssetSettingsButton>
|
||||
</template>
|
||||
</SidebarTopArea>
|
||||
|
||||
<div
|
||||
v-if="bottomDivider"
|
||||
class="border-t border-dashed border-comfy-input"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import SidebarTopArea from '@/components/sidebar/tabs/SidebarTopArea.vue'
|
||||
import SearchInput from '@/components/ui/search-input/SearchInput.vue'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
|
||||
import MediaAssetFilterButton from './MediaAssetFilterButton.vue'
|
||||
import {
|
||||
DATE_VALUES,
|
||||
MEDIA_TYPE_VALUES,
|
||||
labelKeyForValue
|
||||
} from './mediaAssetFilterFacets'
|
||||
import type { MediaAssetViewMode } from './mediaAssetViewOptions'
|
||||
import type { FilterChipDescriptor } from './MediaAssetFilterChips.vue'
|
||||
import MediaAssetFilterChips from './MediaAssetFilterChips.vue'
|
||||
import MediaAssetFilterMenu from './MediaAssetFilterMenu.vue'
|
||||
import MediaAssetSearchField from './MediaAssetSearchField.vue'
|
||||
import MediaAssetSettingsButton from './MediaAssetSettingsButton.vue'
|
||||
import MediaAssetSettingsMenu from './MediaAssetSettingsMenu.vue'
|
||||
import type { SortBy } from './MediaAssetSettingsMenu.vue'
|
||||
|
||||
const { showGenerationTimeSort = false, bottomDivider = false } = defineProps<{
|
||||
searchQuery: string
|
||||
showGenerationTimeSort?: boolean
|
||||
mediaTypeFilters: string[]
|
||||
bottomDivider?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:searchQuery': [value: string]
|
||||
'update:mediaTypeFilters': [value: string[]]
|
||||
}>()
|
||||
|
||||
const searchQuery = defineModel<string>('searchQuery', { required: true })
|
||||
const sortBy = defineModel<SortBy>('sortBy', { required: true })
|
||||
const viewMode = defineModel<'list' | 'grid'>('viewMode', { required: true })
|
||||
const viewMode = defineModel<MediaAssetViewMode>('viewMode', { required: true })
|
||||
const dateFilter = defineModel<string>('dateFilter', { required: true })
|
||||
const mediaTypeFilters = defineModel<string[]>('mediaTypeFilters', {
|
||||
required: true
|
||||
})
|
||||
|
||||
const handleSearchChange = (value: string | undefined) => {
|
||||
emit('update:searchQuery', value ?? '')
|
||||
const { t } = useI18n()
|
||||
|
||||
const filterChips = computed<FilterChipDescriptor[]>(() => {
|
||||
const chips: FilterChipDescriptor[] = mediaTypeFilters.value.map((type) => ({
|
||||
key: `media:${type}`,
|
||||
label: t(labelKeyForValue(MEDIA_TYPE_VALUES, type) ?? type)
|
||||
}))
|
||||
if (dateFilter.value) {
|
||||
chips.push({
|
||||
key: 'date',
|
||||
label: t(
|
||||
labelKeyForValue(DATE_VALUES, dateFilter.value) ?? dateFilter.value
|
||||
)
|
||||
})
|
||||
}
|
||||
return chips
|
||||
})
|
||||
|
||||
const hasActiveFilters = computed(() => filterChips.value.length > 0)
|
||||
|
||||
function removeChip(key: string) {
|
||||
if (key === 'date') {
|
||||
dateFilter.value = ''
|
||||
return
|
||||
}
|
||||
const type = key.slice('media:'.length)
|
||||
mediaTypeFilters.value = mediaTypeFilters.value.filter(
|
||||
(value) => value !== type
|
||||
)
|
||||
}
|
||||
|
||||
const handleMediaTypeFiltersChange = (value: string[]) => {
|
||||
emit('update:mediaTypeFilters', value)
|
||||
function clearAllFilters() {
|
||||
dateFilter.value = ''
|
||||
mediaTypeFilters.value = []
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
<template>
|
||||
<div class="inline-flex items-center">
|
||||
<Popover>
|
||||
<template #button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
:aria-label="$t('assetBrowser.filterBy')"
|
||||
>
|
||||
<i class="icon-[lucide--list-filter]" />
|
||||
</Button>
|
||||
</template>
|
||||
<template #default="{ close }">
|
||||
<slot :close />
|
||||
</template>
|
||||
</Popover>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import Popover from '@/components/ui/Popover.vue'
|
||||
</script>
|
||||
24
src/platform/assets/components/MediaAssetFilterChip.vue
Normal file
24
src/platform/assets/components/MediaAssetFilterChip.vue
Normal file
@@ -0,0 +1,24 @@
|
||||
<template>
|
||||
<span
|
||||
class="inline-flex items-center gap-1 rounded-md bg-secondary-background py-1 pr-1 pl-2 text-xs whitespace-nowrap"
|
||||
>
|
||||
<span>{{ label }}</span>
|
||||
<Button
|
||||
variant="textonly"
|
||||
size="icon"
|
||||
class="size-4 rounded-sm p-0 hover:bg-secondary-background-hover"
|
||||
:aria-label="$t('sideToolbar.mediaAssets.removeFilter', { label })"
|
||||
@click="emit('remove')"
|
||||
>
|
||||
<i class="icon-[lucide--x] size-3" />
|
||||
</Button>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
|
||||
defineProps<{ label: string }>()
|
||||
|
||||
const emit = defineEmits<{ remove: [] }>()
|
||||
</script>
|
||||
35
src/platform/assets/components/MediaAssetFilterChips.vue
Normal file
35
src/platform/assets/components/MediaAssetFilterChips.vue
Normal file
@@ -0,0 +1,35 @@
|
||||
<template>
|
||||
<div class="flex flex-wrap items-center gap-1.5">
|
||||
<MediaAssetFilterChip
|
||||
v-for="chip in chips"
|
||||
:key="chip.key"
|
||||
:label="chip.label"
|
||||
@remove="emit('remove', chip.key)"
|
||||
/>
|
||||
<Button
|
||||
variant="textonly"
|
||||
class="h-6 px-1.5 text-xs text-muted-foreground"
|
||||
@click="emit('clear')"
|
||||
>
|
||||
{{ $t('sideToolbar.mediaAssets.clearFilters') }}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
|
||||
import MediaAssetFilterChip from './MediaAssetFilterChip.vue'
|
||||
|
||||
export interface FilterChipDescriptor {
|
||||
key: string
|
||||
label: string
|
||||
}
|
||||
|
||||
defineProps<{ chips: FilterChipDescriptor[] }>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
remove: [key: string]
|
||||
clear: []
|
||||
}>()
|
||||
</script>
|
||||
@@ -1,94 +1,122 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import enMessages from '@/locales/en/main.json' with { type: 'json' }
|
||||
import MediaAssetFilterMenu from '@/platform/assets/components/MediaAssetFilterMenu.vue'
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({
|
||||
t: (key: string) => key
|
||||
})
|
||||
}))
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: enMessages }
|
||||
})
|
||||
|
||||
function renderMenu(mediaTypeFilters: string[] = []) {
|
||||
const onUpdate = vi.fn()
|
||||
interface Overrides {
|
||||
mediaTypeFilters?: string[]
|
||||
dateFilter?: string
|
||||
}
|
||||
|
||||
function renderMenu(overrides: Overrides = {}) {
|
||||
const onMedia = vi.fn()
|
||||
const onDate = vi.fn()
|
||||
const utils = render(MediaAssetFilterMenu, {
|
||||
props: {
|
||||
mediaTypeFilters,
|
||||
'onUpdate:mediaTypeFilters': onUpdate
|
||||
mediaTypeFilters: overrides.mediaTypeFilters ?? [],
|
||||
dateFilter: overrides.dateFilter ?? '',
|
||||
'onUpdate:mediaTypeFilters': onMedia,
|
||||
'onUpdate:dateFilter': onDate
|
||||
},
|
||||
global: {
|
||||
mocks: {
|
||||
$t: (key: string) => key
|
||||
}
|
||||
}
|
||||
global: { plugins: [i18n] }
|
||||
})
|
||||
return { ...utils, onUpdate, user: userEvent.setup() }
|
||||
return { ...utils, onMedia, onDate, user: userEvent.setup() }
|
||||
}
|
||||
|
||||
const labelByType: Record<string, string> = {
|
||||
image: 'sideToolbar.mediaAssets.filterImage',
|
||||
video: 'sideToolbar.mediaAssets.filterVideo',
|
||||
audio: 'sideToolbar.mediaAssets.filterAudio',
|
||||
'3d': 'sideToolbar.mediaAssets.filter3D',
|
||||
text: 'sideToolbar.mediaAssets.filterText'
|
||||
const CAT = {
|
||||
media: 'Media type',
|
||||
date: 'Date'
|
||||
}
|
||||
|
||||
function getCheckbox(type: keyof typeof labelByType): HTMLElement {
|
||||
return screen.getByRole('checkbox', { name: labelByType[type] })
|
||||
async function openMenu(user: ReturnType<typeof userEvent.setup>) {
|
||||
await user.click(screen.getByRole('button', { name: 'Filter by' }))
|
||||
}
|
||||
|
||||
function categoryItem(label: string) {
|
||||
return screen.getByRole('menuitem', { name: new RegExp(label) })
|
||||
}
|
||||
|
||||
describe('MediaAssetFilterMenu', () => {
|
||||
it('renders all media-type checkboxes', () => {
|
||||
renderMenu()
|
||||
|
||||
const checkboxes = screen.getAllByRole('checkbox')
|
||||
expect(checkboxes).toHaveLength(5)
|
||||
for (const type of Object.keys(labelByType)) {
|
||||
expect(getCheckbox(type)).toBeTruthy()
|
||||
}
|
||||
it('lists every filter category', async () => {
|
||||
const { user } = renderMenu()
|
||||
await openMenu(user)
|
||||
expect(categoryItem(CAT.media)).toBeTruthy()
|
||||
expect(categoryItem(CAT.date)).toBeTruthy()
|
||||
})
|
||||
|
||||
it('reflects checked state from the prop via aria-checked', () => {
|
||||
renderMenu(['image', '3d'])
|
||||
it('surfaces a matching value via flat search and applies it', async () => {
|
||||
const { onMedia, user } = renderMenu()
|
||||
await openMenu(user)
|
||||
await user.type(screen.getByRole('textbox'), 'video')
|
||||
|
||||
expect(getCheckbox('image').getAttribute('aria-checked')).toBe('true')
|
||||
expect(getCheckbox('3d').getAttribute('aria-checked')).toBe('true')
|
||||
expect(getCheckbox('video').getAttribute('aria-checked')).toBe('false')
|
||||
expect(getCheckbox('audio').getAttribute('aria-checked')).toBe('false')
|
||||
await user.click(screen.getByRole('menuitemcheckbox', { name: 'Video' }))
|
||||
expect(onMedia).toHaveBeenCalledWith(['video'])
|
||||
})
|
||||
|
||||
it('emits an array containing the new type when an unchecked box is clicked', async () => {
|
||||
const { onUpdate, user } = renderMenu([])
|
||||
await user.click(getCheckbox('video'))
|
||||
it('applies a date preset via flat search', async () => {
|
||||
const { onDate, user } = renderMenu()
|
||||
await openMenu(user)
|
||||
await user.type(screen.getByRole('textbox'), 'today')
|
||||
|
||||
expect(onUpdate).toHaveBeenCalledTimes(1)
|
||||
expect(onUpdate).toHaveBeenCalledWith(['video'])
|
||||
await user.click(screen.getByRole('menuitemcheckbox', { name: 'Today' }))
|
||||
expect(onDate).toHaveBeenCalledWith('today')
|
||||
})
|
||||
|
||||
it('emits an array without the type when a checked box is clicked again', async () => {
|
||||
const { onUpdate, user } = renderMenu(['image', 'audio'])
|
||||
await user.click(getCheckbox('audio'))
|
||||
it('toggles a date preset off when it is already applied', async () => {
|
||||
const { onDate, user } = renderMenu({ dateFilter: 'today' })
|
||||
await openMenu(user)
|
||||
await user.type(screen.getByRole('textbox'), 'today')
|
||||
|
||||
expect(onUpdate).toHaveBeenCalledWith(['image'])
|
||||
const row = screen.getByRole('menuitemcheckbox', { name: 'Today' })
|
||||
expect(row).toHaveAttribute('aria-checked', 'true')
|
||||
|
||||
await user.click(row)
|
||||
expect(onDate).toHaveBeenCalledWith('')
|
||||
})
|
||||
|
||||
it('appends to the existing filter list rather than replacing it', async () => {
|
||||
const { onUpdate, user } = renderMenu(['image'])
|
||||
await user.click(getCheckbox('video'))
|
||||
it('moves focus into the results with arrow keys after searching', async () => {
|
||||
const { user } = renderMenu()
|
||||
await openMenu(user)
|
||||
await user.type(screen.getByRole('textbox'), 'a')
|
||||
|
||||
expect(onUpdate).toHaveBeenCalledWith(['image', 'video'])
|
||||
const results = screen.getAllByRole('menuitemcheckbox')
|
||||
await user.keyboard('{ArrowDown}')
|
||||
expect(results[0]).toHaveFocus()
|
||||
|
||||
screen.getByRole('textbox').focus()
|
||||
await user.keyboard('{ArrowUp}')
|
||||
expect(results[results.length - 1]).toHaveFocus()
|
||||
})
|
||||
|
||||
it('toggles via keyboard (Enter and Space)', async () => {
|
||||
const { onUpdate, user } = renderMenu([])
|
||||
it('toggles a media value off when it is already applied', async () => {
|
||||
const { onMedia, user } = renderMenu({ mediaTypeFilters: ['video'] })
|
||||
await openMenu(user)
|
||||
await user.type(screen.getByRole('textbox'), 'video')
|
||||
|
||||
getCheckbox('image').focus()
|
||||
await user.keyboard('{Enter}')
|
||||
expect(onUpdate).toHaveBeenLastCalledWith(['image'])
|
||||
const row = screen.getByRole('menuitemcheckbox', { name: 'Video' })
|
||||
expect(row).toHaveAttribute('aria-checked', 'true')
|
||||
|
||||
getCheckbox('audio').focus()
|
||||
await user.keyboard(' ')
|
||||
expect(onUpdate).toHaveBeenLastCalledWith(['audio'])
|
||||
await user.click(row)
|
||||
expect(onMedia).toHaveBeenCalledWith([])
|
||||
})
|
||||
|
||||
it('shows Clear all and resets every facet when a filter is applied', async () => {
|
||||
const { onMedia, onDate, user } = renderMenu({
|
||||
mediaTypeFilters: ['image'],
|
||||
dateFilter: 'today'
|
||||
})
|
||||
await openMenu(user)
|
||||
await user.click(screen.getByRole('menuitem', { name: 'Clear all' }))
|
||||
expect(onMedia).toHaveBeenCalledWith([])
|
||||
expect(onDate).toHaveBeenCalledWith('')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,72 +1,327 @@
|
||||
<!--
|
||||
TODO: Extract checkbox pattern into reusable Checkbox component
|
||||
- Create src/components/input/Checkbox.vue with:
|
||||
- Hidden native <input type="checkbox"> for accessibility
|
||||
- Custom visual styling matching this implementation
|
||||
- Semantic tokens (--primary-background, --input-surface, etc.)
|
||||
- Use this Checkbox component in:
|
||||
- MediaAssetFilterMenu.vue (this file)
|
||||
- MultiSelect.vue option template
|
||||
- SingleSelect.vue if needed
|
||||
- Benefits: Consistent checkbox UI, better maintainability, reusable design system component
|
||||
-->
|
||||
<template>
|
||||
<div class="m-0 flex flex-col gap-0 p-0">
|
||||
<div
|
||||
v-for="filter in filters"
|
||||
:key="filter.type"
|
||||
class="flex h-10 min-w-32 cursor-pointer items-center gap-2 rounded-lg px-2 hover:bg-secondary-background-hover"
|
||||
tabindex="0"
|
||||
role="checkbox"
|
||||
:aria-checked="mediaTypeFilters.includes(filter.type)"
|
||||
@click="toggleMediaType(filter.type)"
|
||||
@keydown.enter.prevent="toggleMediaType(filter.type)"
|
||||
@keydown.space.prevent="toggleMediaType(filter.type)"
|
||||
>
|
||||
<div
|
||||
class="flex size-4 shrink-0 items-center justify-center rounded-sm p-0.5 transition-all duration-200"
|
||||
:class="
|
||||
mediaTypeFilters.includes(filter.type)
|
||||
? 'border-primary-background bg-primary-background'
|
||||
: 'bg-secondary-background'
|
||||
"
|
||||
<DropdownMenuRoot v-model:open="open">
|
||||
<DropdownMenuTrigger as-child>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
class="relative"
|
||||
:aria-label="$t('assetBrowser.filterBy')"
|
||||
>
|
||||
<i
|
||||
v-if="mediaTypeFilters.includes(filter.type)"
|
||||
class="icon-[lucide--check] text-xs font-bold text-white"
|
||||
<i class="icon-[lucide--list-filter]" />
|
||||
<span
|
||||
v-if="active"
|
||||
class="absolute top-1 right-1 size-1.5 rounded-full bg-primary-background"
|
||||
/>
|
||||
</div>
|
||||
<span class="text-sm">{{ $t(filter.label) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuPortal>
|
||||
<DropdownMenuContent
|
||||
side="bottom"
|
||||
align="start"
|
||||
:side-offset="6"
|
||||
:collision-padding="10"
|
||||
:style="contentStyle"
|
||||
:class="menuClass"
|
||||
@open-auto-focus.prevent="focusSearch"
|
||||
>
|
||||
<div class="flex h-10 items-center gap-2 px-3">
|
||||
<i
|
||||
class="icon-[lucide--search] size-4 shrink-0 text-muted-foreground"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<input
|
||||
ref="searchRef"
|
||||
v-model="query"
|
||||
type="text"
|
||||
:placeholder="$t('sideToolbar.mediaAssets.filterBy')"
|
||||
class="min-w-0 flex-1 border-none bg-transparent text-sm text-base-foreground outline-none placeholder:text-muted-foreground"
|
||||
@keydown="onSearchKeydown"
|
||||
/>
|
||||
</div>
|
||||
<DropdownMenuSeparator class="h-px bg-border-subtle" />
|
||||
|
||||
<div class="max-h-80 overflow-y-auto p-1">
|
||||
<template v-if="query.trim()">
|
||||
<template v-for="section in searchSections" :key="section.facet">
|
||||
<DropdownMenuLabel :class="groupLabelClass">
|
||||
{{ catLabel(section.facet) }}
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuCheckboxItem
|
||||
v-for="row in section.rows"
|
||||
:key="row.value"
|
||||
:model-value="isApplied(section.facet, row.value)"
|
||||
:class="rowClass"
|
||||
@select.prevent="toggleFacetValue(section.facet, row.value)"
|
||||
>
|
||||
<i :class="cn(CAT_ICON[section.facet], iconClass)" />
|
||||
<span :class="labelClass">{{ row.text }}</span>
|
||||
<i
|
||||
v-if="isApplied(section.facet, row.value)"
|
||||
:class="checkClass"
|
||||
/>
|
||||
</DropdownMenuCheckboxItem>
|
||||
</template>
|
||||
<div
|
||||
v-if="searchSections.length === 0"
|
||||
class="px-2 py-1.5 text-sm text-muted-foreground"
|
||||
>
|
||||
{{ $t('sideToolbar.mediaAssets.filterNoMatches') }}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<template v-for="group in groups" :key="group.key">
|
||||
<DropdownMenuLabel :class="groupLabelClass">
|
||||
{{ $t(group.label) }}
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSub v-for="cat in group.cats" :key="cat">
|
||||
<DropdownMenuSubTrigger
|
||||
:class="
|
||||
cn(
|
||||
rowClass,
|
||||
'data-[state=open]:bg-secondary-background-hover'
|
||||
)
|
||||
"
|
||||
>
|
||||
<i :class="cn(CAT_ICON[cat], iconClass)" />
|
||||
<span :class="labelClass">{{ catLabel(cat) }}</span>
|
||||
<span v-if="appliedCount(cat) > 0" :class="countClass">
|
||||
{{ appliedCount(cat) }}
|
||||
</span>
|
||||
<i
|
||||
class="icon-[lucide--chevron-right] size-4 shrink-0 text-muted-foreground"
|
||||
/>
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuPortal>
|
||||
<DropdownMenuSubContent
|
||||
:side-offset="2"
|
||||
:collision-padding="10"
|
||||
:style="contentStyle"
|
||||
:class="menuClass"
|
||||
>
|
||||
<div class="p-1">
|
||||
<DropdownMenuCheckboxItem
|
||||
v-for="opt in valuesFor(cat)"
|
||||
:key="opt.value"
|
||||
:model-value="isApplied(cat, opt.value)"
|
||||
:class="rowClass"
|
||||
@select.prevent="toggleFacetValue(cat, opt.value)"
|
||||
>
|
||||
<span :class="labelClass">{{ opt.text }}</span>
|
||||
<i
|
||||
v-if="isApplied(cat, opt.value)"
|
||||
:class="checkClass"
|
||||
/>
|
||||
</DropdownMenuCheckboxItem>
|
||||
</div>
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuPortal>
|
||||
</DropdownMenuSub>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<template v-if="anyApplied">
|
||||
<DropdownMenuSeparator class="h-px bg-border-subtle" />
|
||||
<DropdownMenuItem
|
||||
class="flex h-10 items-center px-3 text-sm text-muted-foreground outline-none data-highlighted:text-base-foreground"
|
||||
@select.prevent="clearAll"
|
||||
>
|
||||
{{ $t('sideToolbar.mediaAssets.clearFilters') }}
|
||||
</DropdownMenuItem>
|
||||
</template>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenuPortal>
|
||||
</DropdownMenuRoot>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const { mediaTypeFilters } = defineProps<{
|
||||
mediaTypeFilters: string[]
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import {
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuRoot,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger
|
||||
} from 'reka-ui'
|
||||
import { computed, nextTick, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useModalLiftedZIndex } from '@/composables/useModalLiftedZIndex'
|
||||
|
||||
import { DATE_VALUES, MEDIA_TYPE_VALUES } from './mediaAssetFilterFacets'
|
||||
import type { FacetValue } from './mediaAssetFilterFacets'
|
||||
|
||||
type FacetKey = 'media' | 'date'
|
||||
interface ValueOption {
|
||||
value: string
|
||||
text: string
|
||||
}
|
||||
|
||||
const { active = false } = defineProps<{
|
||||
active?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:mediaTypeFilters': [value: string[]]
|
||||
}>()
|
||||
const mediaTypeFilters = defineModel<string[]>('mediaTypeFilters', {
|
||||
required: true
|
||||
})
|
||||
const dateFilter = defineModel<string>('dateFilter', { required: true })
|
||||
|
||||
const filters = [
|
||||
{ type: 'image', label: 'sideToolbar.mediaAssets.filterImage' },
|
||||
{ type: 'video', label: 'sideToolbar.mediaAssets.filterVideo' },
|
||||
{ type: 'audio', label: 'sideToolbar.mediaAssets.filterAudio' },
|
||||
{ type: '3d', label: 'sideToolbar.mediaAssets.filter3D' },
|
||||
{ type: 'text', label: 'sideToolbar.mediaAssets.filterText' }
|
||||
const { t } = useI18n()
|
||||
|
||||
const open = ref(false)
|
||||
const contentStyle = useModalLiftedZIndex(open)
|
||||
const searchRef = ref<HTMLInputElement>()
|
||||
const query = ref('')
|
||||
|
||||
const menuClass =
|
||||
'data-[side=top]:animate-slideDownAndFade data-[side=right]:animate-slideLeftAndFade data-[side=bottom]:animate-slideUpAndFade data-[side=left]:animate-slideRightAndFade z-1700 flex w-56 flex-col rounded-lg border border-border-subtle bg-base-background shadow-sm will-change-[opacity,transform]'
|
||||
const groupLabelClass =
|
||||
'px-2 pt-2 pb-1 text-xs font-semibold tracking-wide text-muted-foreground uppercase'
|
||||
const rowClass =
|
||||
'flex h-8 w-full cursor-pointer items-center gap-2 rounded-sm px-2 text-sm outline-none data-highlighted:bg-secondary-background-hover'
|
||||
const iconClass = 'size-4 shrink-0 text-muted-foreground'
|
||||
const labelClass = 'min-w-0 flex-1 truncate text-left text-base-foreground'
|
||||
const checkClass = 'icon-[lucide--check] size-4 shrink-0 text-base-foreground'
|
||||
const countClass =
|
||||
'flex h-5 min-w-5 shrink-0 items-center justify-center rounded-full bg-secondary-background px-1 text-xs text-muted-foreground tabular-nums'
|
||||
|
||||
const CAT_ICON: Record<FacetKey, string> = {
|
||||
media: 'icon-[lucide--image]',
|
||||
date: 'icon-[lucide--calendar]'
|
||||
}
|
||||
const CAT_LABEL: Record<FacetKey, string> = {
|
||||
media: 'sideToolbar.mediaAssets.filterMediaType',
|
||||
date: 'sideToolbar.mediaAssets.filterDate'
|
||||
}
|
||||
const catLabel = (cat: FacetKey) => t(CAT_LABEL[cat])
|
||||
|
||||
const groups: { key: string; label: string; cats: FacetKey[] }[] = [
|
||||
{
|
||||
key: 'attribute',
|
||||
label: 'sideToolbar.mediaAssets.filterGroupAttribute',
|
||||
cats: ['media', 'date']
|
||||
}
|
||||
]
|
||||
|
||||
const toggleMediaType = (type: string) => {
|
||||
const isCurrentlySelected = mediaTypeFilters.includes(type)
|
||||
if (isCurrentlySelected) {
|
||||
emit(
|
||||
'update:mediaTypeFilters',
|
||||
mediaTypeFilters.filter((t) => t !== type)
|
||||
)
|
||||
} else {
|
||||
emit('update:mediaTypeFilters', [...mediaTypeFilters, type])
|
||||
// Values that clear a single-select facet — excluded from flat search results.
|
||||
const CLEAR_VALUES = new Set(['', 'all'])
|
||||
|
||||
function toOptions(values: FacetValue[]): ValueOption[] {
|
||||
return values.map((facetValue) => ({
|
||||
value: facetValue.value,
|
||||
text: t(facetValue.labelKey)
|
||||
}))
|
||||
}
|
||||
|
||||
function valuesFor(cat: FacetKey): ValueOption[] {
|
||||
switch (cat) {
|
||||
case 'media':
|
||||
return toOptions(MEDIA_TYPE_VALUES)
|
||||
case 'date':
|
||||
return toOptions(DATE_VALUES)
|
||||
}
|
||||
}
|
||||
|
||||
const searchSections = computed(() => {
|
||||
const q = query.value.trim().toLowerCase()
|
||||
if (!q) return []
|
||||
return groups
|
||||
.flatMap((g) => g.cats)
|
||||
.map((facet) => ({
|
||||
facet,
|
||||
rows: valuesFor(facet).filter(
|
||||
(o) => !CLEAR_VALUES.has(o.value) && o.text.toLowerCase().includes(q)
|
||||
)
|
||||
}))
|
||||
.filter((s) => s.rows.length)
|
||||
})
|
||||
|
||||
function isApplied(cat: FacetKey, value: string): boolean {
|
||||
switch (cat) {
|
||||
case 'media':
|
||||
return mediaTypeFilters.value.includes(value)
|
||||
case 'date':
|
||||
return dateFilter.value === value
|
||||
}
|
||||
}
|
||||
|
||||
function appliedCount(cat: FacetKey): number {
|
||||
switch (cat) {
|
||||
case 'media':
|
||||
return mediaTypeFilters.value.length
|
||||
case 'date':
|
||||
return dateFilter.value ? 1 : 0
|
||||
}
|
||||
}
|
||||
|
||||
function toggleFacetValue(cat: FacetKey, value: string) {
|
||||
switch (cat) {
|
||||
case 'date':
|
||||
dateFilter.value = dateFilter.value === value ? '' : value
|
||||
return
|
||||
case 'media':
|
||||
mediaTypeFilters.value = mediaTypeFilters.value.includes(value)
|
||||
? mediaTypeFilters.value.filter((v) => v !== value)
|
||||
: [...mediaTypeFilters.value, value]
|
||||
}
|
||||
}
|
||||
|
||||
const anyApplied = computed(
|
||||
() => mediaTypeFilters.value.length > 0 || !!dateFilter.value
|
||||
)
|
||||
|
||||
function clearAll() {
|
||||
mediaTypeFilters.value = []
|
||||
dateFilter.value = ''
|
||||
}
|
||||
|
||||
function menuItems(fromEl: HTMLElement): HTMLElement[] {
|
||||
const menu = fromEl.closest('[role="menu"]')
|
||||
return menu
|
||||
? Array.from(
|
||||
menu.querySelectorAll<HTMLElement>(
|
||||
'[role^="menuitem"]:not([aria-disabled="true"])'
|
||||
)
|
||||
)
|
||||
: []
|
||||
}
|
||||
|
||||
// The search box owns its keys: Down/Up hand focus into the results (reka drives
|
||||
// nav from there), and printable keys stay in the box so reka's menu typeahead
|
||||
// can't steal focus. Stopping propagation keeps global canvas keybindings out.
|
||||
// Escape / Enter still reach the menu.
|
||||
function onSearchKeydown(event: KeyboardEvent) {
|
||||
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
|
||||
const items = menuItems(event.currentTarget as HTMLElement)
|
||||
if (!items.length) return
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
const target =
|
||||
event.key === 'ArrowDown' ? items[0] : items[items.length - 1]
|
||||
target.focus()
|
||||
return
|
||||
}
|
||||
if (
|
||||
event.key.length === 1 &&
|
||||
!event.ctrlKey &&
|
||||
!event.metaKey &&
|
||||
!event.altKey
|
||||
) {
|
||||
event.stopPropagation()
|
||||
}
|
||||
}
|
||||
|
||||
function focusSearch() {
|
||||
query.value = ''
|
||||
void nextTick(() => searchRef.value?.focus())
|
||||
}
|
||||
</script>
|
||||
|
||||
33
src/platform/assets/components/MediaAssetSearchField.vue
Normal file
33
src/platform/assets/components/MediaAssetSearchField.vue
Normal file
@@ -0,0 +1,33 @@
|
||||
<template>
|
||||
<div
|
||||
class="focus-within:ring-secondary-foreground flex h-8 min-w-0 flex-1 items-center gap-2 rounded-lg bg-secondary-background px-3 focus-within:ring-1"
|
||||
>
|
||||
<i
|
||||
class="icon-[lucide--search] size-4 shrink-0 text-muted-foreground"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<input
|
||||
v-model="model"
|
||||
type="text"
|
||||
:placeholder="placeholder"
|
||||
class="min-w-0 flex-1 border-none bg-transparent text-sm text-base-foreground outline-none placeholder:text-muted-foreground"
|
||||
/>
|
||||
<Button
|
||||
v-if="model"
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
:aria-label="$t('g.clear')"
|
||||
@click="model = ''"
|
||||
>
|
||||
<i class="icon-[lucide--x] size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
|
||||
const { placeholder = '' } = defineProps<{ placeholder?: string }>()
|
||||
|
||||
const model = defineModel<string>({ required: true })
|
||||
</script>
|
||||
@@ -12,7 +12,7 @@ const i18n = createI18n({
|
||||
en: {
|
||||
mediaAsset: {
|
||||
selection: {
|
||||
deselectAll: 'Deselect all',
|
||||
unselectCount: 'Unselect {count}',
|
||||
downloadSelected: 'Download',
|
||||
deleteSelected: 'Delete',
|
||||
selectedCount: '{count} selected'
|
||||
@@ -39,7 +39,7 @@ describe('MediaAssetSelectionBar', () => {
|
||||
|
||||
it('emits deselect when the close button is clicked', async () => {
|
||||
const { emitted } = renderBar({ count: 2 })
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Deselect all' }))
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Unselect 2' }))
|
||||
expect(emitted().deselect).toHaveLength(1)
|
||||
})
|
||||
|
||||
|
||||
@@ -6,14 +6,14 @@
|
||||
>
|
||||
<Button
|
||||
v-tooltip.top="{
|
||||
value: $t('mediaAsset.selection.deselectAll'),
|
||||
value: $t('mediaAsset.selection.unselectCount', { count }),
|
||||
showDelay: 300
|
||||
}"
|
||||
variant="inverted"
|
||||
size="icon-lg"
|
||||
type="button"
|
||||
data-testid="assets-deselect-selected"
|
||||
:aria-label="$t('mediaAsset.selection.deselectAll')"
|
||||
:aria-label="$t('mediaAsset.selection.unselectCount', { count })"
|
||||
class="rounded-lg hover:bg-base-background/10"
|
||||
@click="emit('deselect')"
|
||||
>
|
||||
|
||||
@@ -2,28 +2,41 @@ import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { defineComponent, ref } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import enMessages from '@/locales/en/main.json' with { type: 'json' }
|
||||
import MediaAssetSettingsMenu from '@/platform/assets/components/MediaAssetSettingsMenu.vue'
|
||||
import type { SortBy } from '@/platform/assets/components/MediaAssetSettingsMenu.vue'
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: enMessages }
|
||||
})
|
||||
|
||||
const KEYS = {
|
||||
list: 'sideToolbar.queueProgressOverlay.viewList',
|
||||
grid: 'sideToolbar.queueProgressOverlay.viewGrid',
|
||||
newest: 'sideToolbar.mediaAssets.sortNewestFirst',
|
||||
oldest: 'sideToolbar.mediaAssets.sortOldestFirst',
|
||||
longest: 'sideToolbar.mediaAssets.sortLongestFirst',
|
||||
fastest: 'sideToolbar.mediaAssets.sortFastestFirst'
|
||||
list: 'List view',
|
||||
gridSmall: 'Grid (small)',
|
||||
gridLarge: 'Grid (large)',
|
||||
newest: 'Newest first',
|
||||
oldest: 'Oldest first',
|
||||
longest: 'Generation time (longest first)',
|
||||
fastest: 'Generation time (fastest first)',
|
||||
az: 'Name (A → Z)',
|
||||
za: 'Name (Z → A)'
|
||||
} as const
|
||||
|
||||
type ViewMode = 'list' | 'grid-small' | 'grid-large'
|
||||
|
||||
interface MountOptions {
|
||||
viewMode?: 'list' | 'grid'
|
||||
viewMode?: ViewMode
|
||||
sortBy?: SortBy
|
||||
showSortOptions?: boolean
|
||||
showGenerationTimeSort?: boolean
|
||||
}
|
||||
|
||||
function mountWithModels(options: MountOptions = {}) {
|
||||
const viewMode = ref<'list' | 'grid'>(options.viewMode ?? 'list')
|
||||
const viewMode = ref<ViewMode>(options.viewMode ?? 'list')
|
||||
const sortBy = ref<SortBy>(options.sortBy ?? 'newest')
|
||||
|
||||
const Host = defineComponent({
|
||||
@@ -48,9 +61,7 @@ function mountWithModels(options: MountOptions = {}) {
|
||||
|
||||
const utils = render(Host, {
|
||||
global: {
|
||||
mocks: {
|
||||
$t: (key: string) => key
|
||||
}
|
||||
plugins: [i18n]
|
||||
}
|
||||
})
|
||||
return { ...utils, viewMode, sortBy, user: userEvent.setup() }
|
||||
@@ -62,17 +73,24 @@ function getButton(label: string): HTMLElement {
|
||||
|
||||
describe('MediaAssetSettingsMenu', () => {
|
||||
describe('view-mode options (always visible)', () => {
|
||||
it('renders both list and grid view options', () => {
|
||||
it('renders list and both grid view options', () => {
|
||||
mountWithModels()
|
||||
expect(getButton(KEYS.list)).toBeTruthy()
|
||||
expect(getButton(KEYS.grid)).toBeTruthy()
|
||||
expect(getButton(KEYS.gridSmall)).toBeTruthy()
|
||||
expect(getButton(KEYS.gridLarge)).toBeTruthy()
|
||||
})
|
||||
|
||||
it('updates the v-model:viewMode when an option is clicked', async () => {
|
||||
const { viewMode, user } = mountWithModels({ viewMode: 'list' })
|
||||
await user.click(getButton(KEYS.grid))
|
||||
expect(viewMode.value).toBe('grid')
|
||||
})
|
||||
it.for([
|
||||
{ key: KEYS.gridSmall, expected: 'grid-small' },
|
||||
{ key: KEYS.gridLarge, expected: 'grid-large' }
|
||||
] as const)(
|
||||
'updates the v-model:viewMode to $expected when an option is clicked',
|
||||
async ({ key, expected }) => {
|
||||
const { viewMode, user } = mountWithModels({ viewMode: 'list' })
|
||||
await user.click(getButton(key))
|
||||
expect(viewMode.value).toBe(expected)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
describe('sort options (gated by showSortOptions)', () => {
|
||||
@@ -112,7 +130,9 @@ describe('MediaAssetSettingsMenu', () => {
|
||||
{ key: 'newest', expected: 'newest' },
|
||||
{ key: 'oldest', expected: 'oldest' },
|
||||
{ key: 'longest', expected: 'longest' },
|
||||
{ key: 'fastest', expected: 'fastest' }
|
||||
{ key: 'fastest', expected: 'fastest' },
|
||||
{ key: 'az', expected: 'az' },
|
||||
{ key: 'za', expected: 'za' }
|
||||
]
|
||||
|
||||
for (const { key, expected } of cases) {
|
||||
|
||||
@@ -18,15 +18,30 @@
|
||||
<Button
|
||||
variant="textonly"
|
||||
class="w-full"
|
||||
@click="handleViewModeChange('grid')"
|
||||
@click="handleViewModeChange('grid-small')"
|
||||
>
|
||||
<span class="flex items-center gap-2">
|
||||
<i class="icon-[lucide--layout-grid] size-4" />
|
||||
<span>{{ $t('sideToolbar.queueProgressOverlay.viewGrid') }}</span>
|
||||
<i class="icon-[lucide--grid-3x3] size-4" />
|
||||
<span>{{ $t('sideToolbar.mediaAssets.viewGridSmall') }}</span>
|
||||
</span>
|
||||
<i
|
||||
class="ml-auto icon-[lucide--check] size-4"
|
||||
:class="viewMode !== 'grid' && 'opacity-0'"
|
||||
:class="viewMode !== 'grid-small' && 'opacity-0'"
|
||||
/>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="textonly"
|
||||
class="w-full"
|
||||
@click="handleViewModeChange('grid-large')"
|
||||
>
|
||||
<span class="flex items-center gap-2">
|
||||
<i class="icon-[lucide--layout-grid] size-4" />
|
||||
<span>{{ $t('sideToolbar.mediaAssets.viewGridLarge') }}</span>
|
||||
</span>
|
||||
<i
|
||||
class="ml-auto icon-[lucide--check] size-4"
|
||||
:class="viewMode !== 'grid-large' && 'opacity-0'"
|
||||
/>
|
||||
</Button>
|
||||
|
||||
@@ -57,6 +72,22 @@
|
||||
/>
|
||||
</Button>
|
||||
|
||||
<Button variant="textonly" class="w-full" @click="handleSortChange('az')">
|
||||
<span>{{ $t('sideToolbar.mediaAssets.sortAToZ') }}</span>
|
||||
<i
|
||||
class="ml-auto icon-[lucide--check] size-4"
|
||||
:class="sortBy !== 'az' && 'opacity-0'"
|
||||
/>
|
||||
</Button>
|
||||
|
||||
<Button variant="textonly" class="w-full" @click="handleSortChange('za')">
|
||||
<span>{{ $t('sideToolbar.mediaAssets.sortZToA') }}</span>
|
||||
<i
|
||||
class="ml-auto icon-[lucide--check] size-4"
|
||||
:class="sortBy !== 'za' && 'opacity-0'"
|
||||
/>
|
||||
</Button>
|
||||
|
||||
<template v-if="showGenerationTimeSort">
|
||||
<Button
|
||||
variant="textonly"
|
||||
@@ -89,7 +120,9 @@
|
||||
<script setup lang="ts">
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
|
||||
export type SortBy = 'newest' | 'oldest' | 'longest' | 'fastest'
|
||||
import type { MediaAssetViewMode } from './mediaAssetViewOptions'
|
||||
|
||||
export type SortBy = 'newest' | 'oldest' | 'longest' | 'fastest' | 'az' | 'za'
|
||||
|
||||
const { showSortOptions = false, showGenerationTimeSort = false } =
|
||||
defineProps<{
|
||||
@@ -97,10 +130,10 @@ const { showSortOptions = false, showGenerationTimeSort = false } =
|
||||
showGenerationTimeSort?: boolean
|
||||
}>()
|
||||
|
||||
const viewMode = defineModel<'list' | 'grid'>('viewMode', { required: true })
|
||||
const viewMode = defineModel<MediaAssetViewMode>('viewMode', { required: true })
|
||||
const sortBy = defineModel<SortBy>('sortBy', { required: true })
|
||||
|
||||
function handleViewModeChange(value: 'list' | 'grid') {
|
||||
function handleViewModeChange(value: MediaAssetViewMode) {
|
||||
viewMode.value = value
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
<template>
|
||||
<p
|
||||
class="m-0 line-clamp-2 text-sm/tight break-all text-base-foreground"
|
||||
:title="fileName"
|
||||
>
|
||||
<p class="m-0 truncate text-sm/tight text-base-foreground" :title="fileName">
|
||||
{{ fileName }}
|
||||
</p>
|
||||
</template>
|
||||
|
||||
33
src/platform/assets/components/mediaAssetFilterFacets.ts
Normal file
33
src/platform/assets/components/mediaAssetFilterFacets.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* Single source of the media-assets filter facet values and their i18n label
|
||||
* keys, shared by the filter menu (which renders the option rows) and the
|
||||
* filter bar (which labels the applied-filter chips) so the two never drift.
|
||||
*/
|
||||
|
||||
export interface FacetValue {
|
||||
value: string
|
||||
labelKey: string
|
||||
}
|
||||
|
||||
export const MEDIA_TYPE_VALUES: FacetValue[] = [
|
||||
{ value: 'image', labelKey: 'sideToolbar.mediaAssets.filterImage' },
|
||||
{ value: 'video', labelKey: 'sideToolbar.mediaAssets.filterVideo' },
|
||||
{ value: 'audio', labelKey: 'sideToolbar.mediaAssets.filterAudio' },
|
||||
{ value: '3d', labelKey: 'sideToolbar.mediaAssets.filter3D' },
|
||||
{ value: 'text', labelKey: 'sideToolbar.mediaAssets.filterText' }
|
||||
]
|
||||
|
||||
export const DATE_VALUES: FacetValue[] = [
|
||||
{ value: '', labelKey: 'sideToolbar.mediaAssets.dateAll' },
|
||||
{ value: 'today', labelKey: 'sideToolbar.mediaAssets.dateToday' },
|
||||
{ value: 'week', labelKey: 'sideToolbar.mediaAssets.datePastWeek' },
|
||||
{ value: 'month', labelKey: 'sideToolbar.mediaAssets.datePastMonth' },
|
||||
{ value: 'year', labelKey: 'sideToolbar.mediaAssets.dateThisYear' }
|
||||
]
|
||||
|
||||
export function labelKeyForValue(
|
||||
values: FacetValue[],
|
||||
value: string
|
||||
): string | undefined {
|
||||
return values.find((facetValue) => facetValue.value === value)?.labelKey
|
||||
}
|
||||
8
src/platform/assets/components/mediaAssetViewOptions.ts
Normal file
8
src/platform/assets/components/mediaAssetViewOptions.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
/** Sidebar view mode: a list, or one of two grid tile sizes. */
|
||||
export type MediaAssetViewMode = 'list' | 'grid-small' | 'grid-large'
|
||||
|
||||
/** `grid-template-columns` for a grid view mode ('list' falls back to the dense grid). */
|
||||
export function gridColumnsForMode(mode: MediaAssetViewMode): string {
|
||||
const minWidth = mode === 'grid-large' ? 240 : 128
|
||||
return `repeat(auto-fill, minmax(min(${minWidth}px, 30vw), 1fr))`
|
||||
}
|
||||
@@ -22,7 +22,10 @@ vi.mock('@vueuse/core', async (importOriginal) => {
|
||||
}
|
||||
})
|
||||
|
||||
import { useAssetSelection } from './useAssetSelection'
|
||||
import {
|
||||
shouldInterceptSelectAll,
|
||||
useAssetSelection
|
||||
} from './useAssetSelection'
|
||||
import { useAssetSelectionStore } from './useAssetSelectionStore'
|
||||
|
||||
function createMockAssets(count: number): AssetItem[] {
|
||||
@@ -132,6 +135,51 @@ describe('useAssetSelection', () => {
|
||||
expect(isSelected('asset-1')).toBe(true)
|
||||
expect(selectedCount.value).toBe(1)
|
||||
})
|
||||
|
||||
it('deselects when clicking the only selected asset again', () => {
|
||||
const { handleAssetClick, isSelected, selectedCount } =
|
||||
useAssetSelection()
|
||||
const assets = createMockAssets(3)
|
||||
|
||||
handleAssetClick(assets[0], 0, assets)
|
||||
expect(selectedCount.value).toBe(1)
|
||||
|
||||
handleAssetClick(assets[0], 0, assets)
|
||||
expect(isSelected('asset-0')).toBe(false)
|
||||
expect(selectedCount.value).toBe(0)
|
||||
})
|
||||
|
||||
it('starts a new selection after deselecting the anchor', () => {
|
||||
const { handleAssetClick, isSelected, selectedCount } =
|
||||
useAssetSelection()
|
||||
const assets = createMockAssets(3)
|
||||
|
||||
handleAssetClick(assets[0], 0, assets)
|
||||
handleAssetClick(assets[0], 0, assets)
|
||||
mockShiftKey.value = true
|
||||
handleAssetClick(assets[2], 2, assets)
|
||||
|
||||
expect(isSelected('asset-0')).toBe(false)
|
||||
expect(isSelected('asset-2')).toBe(true)
|
||||
expect(selectedCount.value).toBe(1)
|
||||
})
|
||||
|
||||
it('collapses a multi-selection to the clicked asset rather than deselecting', () => {
|
||||
const { handleAssetClick, isSelected, selectedCount } =
|
||||
useAssetSelection()
|
||||
const assets = createMockAssets(3)
|
||||
|
||||
handleAssetClick(assets[0], 0, assets)
|
||||
mockCtrlKey.value = true
|
||||
handleAssetClick(assets[1], 1, assets)
|
||||
mockCtrlKey.value = false
|
||||
expect(selectedCount.value).toBe(2)
|
||||
|
||||
handleAssetClick(assets[0], 0, assets)
|
||||
expect(isSelected('asset-0')).toBe(true)
|
||||
expect(isSelected('asset-1')).toBe(false)
|
||||
expect(selectedCount.value).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('handleAssetClick - shift+click', () => {
|
||||
@@ -273,3 +321,61 @@ describe('useAssetSelection', () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('shouldInterceptSelectAll', () => {
|
||||
function interceptsOn(
|
||||
focusedTag: string,
|
||||
init: KeyboardEventInit = { key: 'a', metaKey: true },
|
||||
{ insidePane = true, contentEditable = false } = {}
|
||||
): boolean {
|
||||
const pane = document.createElement('div')
|
||||
document.body.append(pane)
|
||||
const focused = document.createElement(focusedTag)
|
||||
if (contentEditable) {
|
||||
Object.defineProperty(focused, 'isContentEditable', { value: true })
|
||||
}
|
||||
;(insidePane ? pane : document.body).append(focused)
|
||||
|
||||
let result = false
|
||||
const record = (event: Event) => {
|
||||
result = shouldInterceptSelectAll(event as KeyboardEvent, pane)
|
||||
}
|
||||
window.addEventListener('keydown', record, { capture: true })
|
||||
focused.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { ...init, bubbles: true })
|
||||
)
|
||||
window.removeEventListener('keydown', record, { capture: true })
|
||||
pane.remove()
|
||||
focused.remove()
|
||||
return result
|
||||
}
|
||||
|
||||
it('intercepts Cmd+A and Ctrl+A on non-text elements inside the pane', () => {
|
||||
expect(interceptsOn('div', { key: 'a', metaKey: true })).toBe(true)
|
||||
expect(interceptsOn('div', { key: 'A', ctrlKey: true })).toBe(true)
|
||||
})
|
||||
|
||||
it('requires the select-all chord', () => {
|
||||
expect(interceptsOn('div', { key: 'a' })).toBe(false)
|
||||
expect(interceptsOn('div', { key: 'b', metaKey: true })).toBe(false)
|
||||
})
|
||||
|
||||
it('leaves native select-all to text-entry elements', () => {
|
||||
for (const tag of ['input', 'textarea', 'select']) {
|
||||
expect(interceptsOn(tag, { key: 'a', metaKey: true })).toBe(false)
|
||||
}
|
||||
expect(
|
||||
interceptsOn(
|
||||
'div',
|
||||
{ key: 'a', metaKey: true },
|
||||
{ contentEditable: true }
|
||||
)
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('ignores the chord when focus is outside the pane', () => {
|
||||
expect(
|
||||
interceptsOn('div', { key: 'a', metaKey: true }, { insidePane: false })
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -8,6 +8,25 @@ import {
|
||||
getTotalAssetOutputCount
|
||||
} from '@/platform/assets/utils/outputAssetUtil'
|
||||
|
||||
/**
|
||||
* True when a Cmd/Ctrl+A keydown should become "select all assets": the chord
|
||||
* matches, focus is inside the asset pane, and the target is not a text-entry
|
||||
* element (those keep native select-all).
|
||||
*/
|
||||
export function shouldInterceptSelectAll(
|
||||
event: KeyboardEvent,
|
||||
pane: HTMLElement | undefined
|
||||
): boolean {
|
||||
if (event.key !== 'a' && event.key !== 'A') return false
|
||||
if (!event.metaKey && !event.ctrlKey) return false
|
||||
const target = event.target
|
||||
if (!(target instanceof HTMLElement) || !pane?.contains(target)) return false
|
||||
return !(
|
||||
target.isContentEditable ||
|
||||
['INPUT', 'TEXTAREA', 'SELECT'].includes(target.tagName)
|
||||
)
|
||||
}
|
||||
|
||||
export function useAssetSelection() {
|
||||
const selectionStore = useAssetSelectionStore()
|
||||
|
||||
@@ -83,7 +102,16 @@ export function useAssetSelection() {
|
||||
return
|
||||
}
|
||||
|
||||
// Normal Click: Single selection
|
||||
// Normal Click: deselect when it is already the sole selection, otherwise
|
||||
// collapse to a single selection.
|
||||
if (
|
||||
selectionStore.isSelected(assetId) &&
|
||||
selectionStore.selectedCount === 1
|
||||
) {
|
||||
selectionStore.clearSelection()
|
||||
setAnchor(-1, null)
|
||||
return
|
||||
}
|
||||
selectionStore.clearSelection()
|
||||
selectionStore.addToSelection(assetId)
|
||||
setAnchor(index, assetId)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import { useMediaAssetFiltering } from '@/platform/assets/composables/useMediaAssetFiltering'
|
||||
@@ -169,6 +169,92 @@ describe('useMediaAssetFiltering', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('alphabetical sort', () => {
|
||||
it('sorts A→Z by display name (case-insensitive)', () => {
|
||||
const assets = ref<AssetItem[]>([
|
||||
makeAsset({ id: 'b', name: 'banana.png' }),
|
||||
makeAsset({ id: 'a', name: 'Apple.png' }),
|
||||
makeAsset({ id: 'c', name: 'cherry.png' })
|
||||
])
|
||||
const { sortBy, filteredAssets } = useMediaAssetFiltering(assets)
|
||||
|
||||
sortBy.value = 'az'
|
||||
expect(ids(filteredAssets.value)).toEqual(['a', 'b', 'c'])
|
||||
})
|
||||
|
||||
it('sorts Z→A by display name (case-insensitive)', () => {
|
||||
const assets = ref<AssetItem[]>([
|
||||
makeAsset({ id: 'b', name: 'banana.png' }),
|
||||
makeAsset({ id: 'a', name: 'Apple.png' }),
|
||||
makeAsset({ id: 'c', name: 'cherry.png' })
|
||||
])
|
||||
const { sortBy, filteredAssets } = useMediaAssetFiltering(assets)
|
||||
|
||||
sortBy.value = 'za'
|
||||
expect(ids(filteredAssets.value)).toEqual(['c', 'b', 'a'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('date filter', () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
function datedAssets() {
|
||||
const now = Date.now()
|
||||
return ref<AssetItem[]>([
|
||||
makeAsset({ id: 'recent', name: 'a.png', createTime: now }),
|
||||
makeAsset({
|
||||
id: 'stale',
|
||||
name: 'b.png',
|
||||
createTime: now - 40 * 86_400_000
|
||||
})
|
||||
])
|
||||
}
|
||||
|
||||
it('returns every asset when no date preset is set', () => {
|
||||
const { filteredAssets } = useMediaAssetFiltering(datedAssets())
|
||||
|
||||
expect(ids(filteredAssets.value).sort()).toEqual(['recent', 'stale'])
|
||||
})
|
||||
|
||||
it('keeps only assets inside the selected preset window', () => {
|
||||
const { dateFilter, filteredAssets } =
|
||||
useMediaAssetFiltering(datedAssets())
|
||||
|
||||
dateFilter.value = 'month'
|
||||
expect(ids(filteredAssets.value)).toEqual(['recent'])
|
||||
})
|
||||
|
||||
it('today cuts off at local midnight', () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date(2026, 5, 15, 12, 0, 0))
|
||||
const midnight = new Date(2026, 5, 15, 0, 0, 0).getTime()
|
||||
const assets = ref<AssetItem[]>([
|
||||
makeAsset({ id: 'this-morning', name: 'a.png', createTime: midnight }),
|
||||
makeAsset({ id: 'yesterday', name: 'b.png', createTime: midnight - 1 })
|
||||
])
|
||||
const { dateFilter, filteredAssets } = useMediaAssetFiltering(assets)
|
||||
|
||||
dateFilter.value = 'today'
|
||||
expect(ids(filteredAssets.value)).toEqual(['this-morning'])
|
||||
})
|
||||
|
||||
it('year cuts off at January 1', () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date(2026, 5, 15))
|
||||
const janFirst = new Date(2026, 0, 1).getTime()
|
||||
const assets = ref<AssetItem[]>([
|
||||
makeAsset({ id: 'this-year', name: 'a.png', createTime: janFirst }),
|
||||
makeAsset({ id: 'last-year', name: 'b.png', createTime: janFirst - 1 })
|
||||
])
|
||||
const { dateFilter, filteredAssets } = useMediaAssetFiltering(assets)
|
||||
|
||||
dateFilter.value = 'year'
|
||||
expect(ids(filteredAssets.value)).toEqual(['this-year'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('composition', () => {
|
||||
it('applies media-type filter then sort', () => {
|
||||
const t1 = 1_000_000
|
||||
|
||||
@@ -7,7 +7,7 @@ import type { Ref } from 'vue'
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
import { getMediaTypeFromFilename } from '@/utils/formatUtil'
|
||||
|
||||
type SortOption = 'newest' | 'oldest' | 'longest' | 'fastest'
|
||||
type SortOption = 'newest' | 'oldest' | 'longest' | 'fastest' | 'az' | 'za'
|
||||
|
||||
/**
|
||||
* Get timestamp from asset (either create_time or created_at)
|
||||
@@ -26,6 +26,30 @@ const getAssetExecutionTime = (asset: AssetItem): number => {
|
||||
return (asset.user_metadata?.executionTimeInSeconds as number) ?? 0
|
||||
}
|
||||
|
||||
/** Case-insensitive display name used for alphabetical sorting. */
|
||||
const getAssetSortName = (asset: AssetItem): string => {
|
||||
return (asset.display_name || asset.name || '').toLowerCase()
|
||||
}
|
||||
|
||||
/** Inclusive lower bound (ms) for a date preset. */
|
||||
const datePresetStart = (preset: string): number => {
|
||||
const now = Date.now()
|
||||
const start = new Date(now)
|
||||
switch (preset) {
|
||||
case 'today':
|
||||
start.setHours(0, 0, 0, 0)
|
||||
return start.getTime()
|
||||
case 'week':
|
||||
return now - 7 * 86_400_000
|
||||
case 'month':
|
||||
return now - 30 * 86_400_000
|
||||
case 'year':
|
||||
return new Date(start.getFullYear(), 0, 1).getTime()
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Media Asset Filtering composable
|
||||
* Manages search, filter, and sort for media assets
|
||||
@@ -35,6 +59,7 @@ export function useMediaAssetFiltering(assets: Ref<AssetItem[]>) {
|
||||
const debouncedSearchQuery = refDebounced(searchQuery, 50)
|
||||
const sortBy = ref<SortOption>('newest')
|
||||
const mediaTypeFilters = ref<string[]>([])
|
||||
const dateFilter = ref('')
|
||||
|
||||
const fuseOptions = {
|
||||
keys: ['display_name', 'name'],
|
||||
@@ -67,24 +92,40 @@ export function useMediaAssetFiltering(assets: Ref<AssetItem[]>) {
|
||||
})
|
||||
})
|
||||
|
||||
const dateFiltered = computed(() => {
|
||||
if (!dateFilter.value) {
|
||||
return typeFiltered.value
|
||||
}
|
||||
const start = datePresetStart(dateFilter.value)
|
||||
return typeFiltered.value.filter((asset) => getAssetTime(asset) >= start)
|
||||
})
|
||||
|
||||
const filteredAssets = computed(() => {
|
||||
// Sort by create_time (output assets) or created_at (input assets)
|
||||
switch (sortBy.value) {
|
||||
case 'oldest':
|
||||
// Ascending order (oldest first)
|
||||
return sortByUtil(typeFiltered.value, [getAssetTime])
|
||||
return sortByUtil(dateFiltered.value, [getAssetTime])
|
||||
case 'longest':
|
||||
// Descending order (longest execution time first)
|
||||
return sortByUtil(typeFiltered.value, [
|
||||
return sortByUtil(dateFiltered.value, [
|
||||
(asset) => -getAssetExecutionTime(asset)
|
||||
])
|
||||
case 'fastest':
|
||||
// Ascending order (fastest execution time first)
|
||||
return sortByUtil(typeFiltered.value, [getAssetExecutionTime])
|
||||
return sortByUtil(dateFiltered.value, [getAssetExecutionTime])
|
||||
case 'az':
|
||||
return [...dateFiltered.value].sort((a, b) =>
|
||||
getAssetSortName(a).localeCompare(getAssetSortName(b))
|
||||
)
|
||||
case 'za':
|
||||
return [...dateFiltered.value].sort((a, b) =>
|
||||
getAssetSortName(b).localeCompare(getAssetSortName(a))
|
||||
)
|
||||
case 'newest':
|
||||
default:
|
||||
// Descending order (newest first) - negate for descending
|
||||
return sortByUtil(typeFiltered.value, [(asset) => -getAssetTime(asset)])
|
||||
return sortByUtil(dateFiltered.value, [(asset) => -getAssetTime(asset)])
|
||||
}
|
||||
})
|
||||
|
||||
@@ -92,6 +133,7 @@ export function useMediaAssetFiltering(assets: Ref<AssetItem[]>) {
|
||||
searchQuery,
|
||||
sortBy,
|
||||
mediaTypeFilters,
|
||||
dateFilter,
|
||||
filteredAssets
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user