diff --git a/src/components/queue/QueueProgressOverlay.vue b/src/components/queue/QueueProgressOverlay.vue index 626709593..1024dc024 100644 --- a/src/components/queue/QueueProgressOverlay.vue +++ b/src/components/queue/QueueProgressOverlay.vue @@ -264,6 +264,7 @@ const focusAssetInSidebar = async (item: JobListItem) => { throw new Error('Asset not found in media assets panel') } assetSelectionStore.setSelection([assetId]) + assetSelectionStore.setLastSelectedAssetId(assetId) } const inspectJobAsset = wrapWithErrorHandlingAsync( diff --git a/src/platform/assets/composables/useAssetSelection.test.ts b/src/platform/assets/composables/useAssetSelection.test.ts new file mode 100644 index 000000000..609623253 --- /dev/null +++ b/src/platform/assets/composables/useAssetSelection.test.ts @@ -0,0 +1,89 @@ +import { createPinia, setActivePinia } from 'pinia' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { ref } from 'vue' + +import type { AssetItem } from '@/platform/assets/schemas/assetSchema' + +import { useAssetSelection } from './useAssetSelection' +import { useAssetSelectionStore } from './useAssetSelectionStore' + +vi.mock('@vueuse/core', () => ({ + useKeyModifier: vi.fn(() => ref(false)) +})) + +describe('useAssetSelection', () => { + beforeEach(() => { + setActivePinia(createPinia()) + }) + + it('prunes selection to visible assets', () => { + const selection = useAssetSelection() + const store = useAssetSelectionStore() + const assets: AssetItem[] = [ + { id: 'a', name: 'a.png', tags: [] }, + { id: 'b', name: 'b.png', tags: [] } + ] + + store.setSelection(['a', 'b']) + store.setLastSelectedIndex(1) + store.setLastSelectedAssetId('b') + + selection.reconcileSelection([assets[1]]) + + expect(Array.from(store.selectedAssetIds)).toEqual(['b']) + expect(store.lastSelectedIndex).toBe(0) + expect(store.lastSelectedAssetId).toBe('b') + }) + + it('clears selection when no visible assets remain', () => { + const selection = useAssetSelection() + const store = useAssetSelectionStore() + + store.setSelection(['a']) + store.setLastSelectedIndex(0) + store.setLastSelectedAssetId('a') + + selection.reconcileSelection([]) + + expect(store.selectedAssetIds.size).toBe(0) + expect(store.lastSelectedIndex).toBe(-1) + expect(store.lastSelectedAssetId).toBeNull() + }) + + it('recomputes the anchor index when assets reorder', () => { + const selection = useAssetSelection() + const store = useAssetSelectionStore() + const assets: AssetItem[] = [ + { id: 'a', name: 'a.png', tags: [] }, + { id: 'b', name: 'b.png', tags: [] } + ] + + store.setSelection(['a']) + store.setLastSelectedIndex(0) + store.setLastSelectedAssetId('a') + + selection.reconcileSelection([assets[1], assets[0]]) + + expect(store.lastSelectedIndex).toBe(1) + expect(store.lastSelectedAssetId).toBe('a') + }) + + it('clears anchor when the anchored asset is no longer visible', () => { + const selection = useAssetSelection() + const store = useAssetSelectionStore() + const assets: AssetItem[] = [ + { id: 'a', name: 'a.png', tags: [] }, + { id: 'b', name: 'b.png', tags: [] } + ] + + store.setSelection(['a', 'b']) + store.setLastSelectedIndex(0) + store.setLastSelectedAssetId('a') + + selection.reconcileSelection([assets[1]]) + + expect(Array.from(store.selectedAssetIds)).toEqual(['b']) + expect(store.lastSelectedIndex).toBe(-1) + expect(store.lastSelectedAssetId).toBeNull() + }) +}) diff --git a/src/platform/assets/composables/useAssetSelection.ts b/src/platform/assets/composables/useAssetSelection.ts index 290698d4d..baf946a35 100644 --- a/src/platform/assets/composables/useAssetSelection.ts +++ b/src/platform/assets/composables/useAssetSelection.ts @@ -21,6 +21,25 @@ export function useAssetSelection() { const metaKey = computed(() => isActive.value && metaKeyRaw.value) const cmdOrCtrlKey = computed(() => ctrlKey.value || metaKey.value) + function setAnchor(index: number, assetId: string | null) { + selectionStore.setLastSelectedIndex(index) + selectionStore.setLastSelectedAssetId(assetId) + } + + function syncAnchorFromAssets(assets: AssetItem[]) { + const anchorId = selectionStore.lastSelectedAssetId + const anchorIndex = anchorId + ? assets.findIndex((asset) => asset.id === anchorId) + : -1 + + if (anchorIndex !== -1) { + selectionStore.setLastSelectedIndex(anchorIndex) + return + } + + setAnchor(-1, null) + } + /** * Handle asset click with modifier keys for selection * @param asset The clicked asset @@ -60,14 +79,14 @@ export function useAssetSelection() { // Ctrl/Cmd + Click: Toggle individual selection if (cmdOrCtrlKey.value) { selectionStore.toggleSelection(assetId) - selectionStore.setLastSelectedIndex(index) + setAnchor(index, assetId) return } // Normal Click: Single selection selectionStore.clearSelection() selectionStore.addToSelection(assetId) - selectionStore.setLastSelectedIndex(index) + setAnchor(index, assetId) } /** @@ -77,7 +96,8 @@ export function useAssetSelection() { const allIds = allAssets.map((a) => a.id) selectionStore.setSelection(allIds) if (allAssets.length > 0) { - selectionStore.setLastSelectedIndex(allAssets.length - 1) + const lastIndex = allAssets.length - 1 + setAnchor(lastIndex, allAssets[lastIndex].id) } } @@ -88,6 +108,39 @@ export function useAssetSelection() { return allAssets.filter((asset) => selectionStore.isSelected(asset.id)) } + function reconcileSelection(assets: AssetItem[]) { + if (selectionStore.selectedAssetIds.size === 0) { + return + } + + if (assets.length === 0) { + selectionStore.clearSelection() + return + } + + const visibleIds = new Set(assets.map((asset) => asset.id)) + const nextSelectedIds: string[] = [] + + for (const id of selectionStore.selectedAssetIds) { + if (visibleIds.has(id)) { + nextSelectedIds.push(id) + } + } + + if (nextSelectedIds.length === selectionStore.selectedAssetIds.size) { + syncAnchorFromAssets(assets) + return + } + + if (nextSelectedIds.length === 0) { + selectionStore.clearSelection() + return + } + + selectionStore.setSelection(nextSelectedIds) + syncAnchorFromAssets(assets) + } + /** * Get the output count for a single asset * Same logic as in AssetsSidebarTab.vue @@ -117,7 +170,7 @@ export function useAssetSelection() { function deactivate() { isActive.value = false // Reset selection state to ensure clean state when deactivated - selectionStore.reset() + selectionStore.clearSelection() } return { @@ -132,10 +185,9 @@ export function useAssetSelection() { selectAll, clearSelection: () => selectionStore.clearSelection(), getSelectedAssets, + reconcileSelection, getOutputCount, getTotalOutputCount, - reset: () => selectionStore.reset(), - // Lifecycle management activate, deactivate, diff --git a/src/platform/assets/composables/useAssetSelectionStore.ts b/src/platform/assets/composables/useAssetSelectionStore.ts index 08edb7bf8..c15fe599b 100644 --- a/src/platform/assets/composables/useAssetSelectionStore.ts +++ b/src/platform/assets/composables/useAssetSelectionStore.ts @@ -5,6 +5,7 @@ export const useAssetSelectionStore = defineStore('assetSelection', () => { // State const selectedAssetIds = ref>(new Set()) const lastSelectedIndex = ref(-1) + const lastSelectedAssetId = ref(null) // Getters const selectedCount = computed(() => selectedAssetIds.value.size) @@ -34,6 +35,7 @@ export const useAssetSelectionStore = defineStore('assetSelection', () => { function clearSelection() { selectedAssetIds.value.clear() lastSelectedIndex.value = -1 + lastSelectedAssetId.value = null } function toggleSelection(assetId: string) { @@ -52,16 +54,15 @@ export const useAssetSelectionStore = defineStore('assetSelection', () => { lastSelectedIndex.value = index } - // Reset function for cleanup - function reset() { - selectedAssetIds.value.clear() - lastSelectedIndex.value = -1 + function setLastSelectedAssetId(assetId: string | null) { + lastSelectedAssetId.value = assetId } return { // State selectedAssetIds: computed(() => selectedAssetIds.value), lastSelectedIndex: computed(() => lastSelectedIndex.value), + lastSelectedAssetId: computed(() => lastSelectedAssetId.value), // Getters selectedCount, @@ -76,6 +77,6 @@ export const useAssetSelectionStore = defineStore('assetSelection', () => { toggleSelection, isSelected, setLastSelectedIndex, - reset + setLastSelectedAssetId } })