fix: regression in select-all (#16969)

* bug: select-all

* set->[] in interaction store, clear select-all on cancel

* feedback

---------

Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
This commit is contained in:
Min Idzelis 2025-03-19 11:55:50 -04:00 committed by GitHub
parent 1a0a9ef36c
commit 9398b0d4b3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 82 additions and 82 deletions

View File

@ -52,7 +52,7 @@
{#if isShowConfirmation}
<DeleteAssetDialog
size={getOwnedAssets().size}
size={getOwnedAssets().length}
onConfirm={handleDelete}
onCancel={() => (isShowConfirmation = false)}
/>

View File

@ -28,7 +28,7 @@
await downloadArchive(filename, { assetIds: assets.map((asset) => asset.id) });
};
let menuItemIcon = $derived(getAssets().size === 1 ? mdiFileDownloadOutline : mdiFolderDownloadOutline);
let menuItemIcon = $derived(getAssets().length === 1 ? mdiFileDownloadOutline : mdiFolderDownloadOutline);
</script>
<svelte:window use:shortcut={{ shortcut: { key: 'd', shift: true }, onShortcut: handleDownloadFiles }} />

View File

@ -23,7 +23,7 @@
const removeFromAlbum = async () => {
const isConfirmed = await dialogController.show({
prompt: $t('remove_assets_album_confirmation', { values: { count: getAssets().size } }),
prompt: $t('remove_assets_album_confirmation', { values: { count: getAssets().length } }),
});
if (!isConfirmed) {

View File

@ -20,7 +20,7 @@
const handleRemove = async () => {
const isConfirmed = await dialogController.show({
title: $t('remove_assets_title'),
prompt: $t('remove_assets_shared_link_confirmation', { values: { count: getAssets().size } }),
prompt: $t('remove_assets_shared_link_confirmation', { values: { count: getAssets().length } }),
confirmText: $t('remove'),
});

View File

@ -1,6 +1,6 @@
<script lang="ts">
import Icon from '$lib/components/elements/icon.svelte';
import { AssetBucket } from '$lib/stores/assets-store.svelte';
import { AssetBucket, assetSnapshot, assetsSnapshot } from '$lib/stores/assets-store.svelte';
import { navigate } from '$lib/utils/navigation';
import { getDateLocaleString } from '$lib/utils/timeline-util';
import type { AssetResponseDto } from '@immich/sdk';
@ -71,9 +71,7 @@
assetInteraction.removeGroupFromMultiselectGroup(groupTitle);
}
};
const snapshotAssetArray = (assets: AssetResponseDto[]) => {
return assets.map((a) => $state.snapshot(a));
};
const assetMouseEventHandler = (groupTitle: string, asset: AssetResponseDto | null) => {
// Show multi select icon on hover on date group
hoveredDateGroup = groupTitle;
@ -121,8 +119,8 @@
<div
transition:fly={{ x: -24, duration: 200, opacity: 0.5 }}
class="inline-block px-2 hover:cursor-pointer"
onclick={() => handleSelectGroup(dateGroup.groupTitle, snapshotAssetArray(dateGroup.getAssets()))}
onkeydown={() => handleSelectGroup(dateGroup.groupTitle, snapshotAssetArray(dateGroup.getAssets()))}
onclick={() => handleSelectGroup(dateGroup.groupTitle, assetsSnapshot(dateGroup.getAssets()))}
onkeydown={() => handleSelectGroup(dateGroup.groupTitle, assetsSnapshot(dateGroup.getAssets()))}
>
{#if assetInteraction.selectedGroup.has(dateGroup.groupTitle)}
<Icon path={mdiCheckCircle} size="24" color="#4250af" />
@ -160,10 +158,10 @@
{showArchiveIcon}
{asset}
{groupIndex}
focussed={assetInteraction.isFocussedAsset(asset)}
focussed={assetInteraction.isFocussedAsset(asset.id)}
onClick={(asset) => onClick(dateGroup.getAssets(), dateGroup.groupTitle, asset)}
onSelect={(asset) => assetSelectHandler(asset, dateGroup.getAssets(), dateGroup.groupTitle)}
onMouseEvent={() => assetMouseEventHandler(dateGroup.groupTitle, $state.snapshot(asset))}
onMouseEvent={() => assetMouseEventHandler(dateGroup.groupTitle, assetSnapshot(asset))}
selected={assetInteraction.hasSelectedAsset(asset.id) || dateGroup.bucket.store.albumAssets.has(asset.id)}
selectionCandidate={assetInteraction.hasSelectionCandidate(asset.id)}
handleFocus={() => assetOnFocusHandler(asset)}

View File

@ -4,7 +4,7 @@
import type { Action } from '$lib/components/asset-viewer/actions/action';
import { AppRoute, AssetAction } from '$lib/constants';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { AssetBucket, AssetStore } from '$lib/stores/assets-store.svelte';
import { AssetBucket, assetsSnapshot, AssetStore } from '$lib/stores/assets-store.svelte';
import { showDeleteModal } from '$lib/stores/preferences.store';
import { isSearchEnabled } from '$lib/stores/search.store';
import { featureFlags } from '$lib/stores/server-config.store';
@ -286,7 +286,7 @@
};
const onDelete = () => {
const hasTrashedAsset = assetInteraction.selectedAssetsArray.some((asset) => asset.isTrashed);
const hasTrashedAsset = assetInteraction.selectedAssets.some((asset) => asset.isTrashed);
if ($showDeleteModal && (!isTrashEnabled || hasTrashedAsset)) {
isShowDeleteConfirmation = true;
@ -304,7 +304,7 @@
};
const onStackAssets = async () => {
const ids = await stackAssets(assetInteraction.selectedAssetsArray);
const ids = await stackAssets(assetInteraction.selectedAssets);
if (ids) {
assetStore.removeAssets(ids);
onEscape();
@ -312,8 +312,8 @@
};
const toggleArchive = async () => {
await archiveAssets(assetInteraction.selectedAssetsArray, !assetInteraction.isAllArchived);
assetStore.updateAssets(assetInteraction.selectedAssetsArray);
await archiveAssets(assetInteraction.selectedAssets, !assetInteraction.isAllArchived);
assetStore.updateAssets(assetInteraction.selectedAssets);
deselectAllAssets();
};
@ -450,7 +450,7 @@
if (assetInteraction.selectedGroup.has(group)) {
assetInteraction.removeGroupFromMultiselectGroup(group);
for (const asset of assets) {
assetInteraction.removeAssetFromMultiselectGroup(asset);
assetInteraction.removeAssetFromMultiselectGroup(asset.id);
}
} else {
assetInteraction.addGroupToMultiselectGroup(group);
@ -471,15 +471,15 @@
return;
}
const rangeSelection = assetInteraction.assetSelectionCandidates.size > 0;
const rangeSelection = assetInteraction.assetSelectionCandidates.length > 0;
const deselect = assetInteraction.hasSelectedAsset(asset.id);
// Select/deselect already loaded assets
if (deselect) {
for (const candidate of assetInteraction.assetSelectionCandidates) {
assetInteraction.removeAssetFromMultiselectGroup(candidate);
assetInteraction.removeAssetFromMultiselectGroup(candidate.id);
}
assetInteraction.removeAssetFromMultiselectGroup(asset);
assetInteraction.removeAssetFromMultiselectGroup(asset.id);
} else {
for (const candidate of assetInteraction.assetSelectionCandidates) {
handleSelectAsset(candidate);
@ -510,7 +510,7 @@
await assetStore.loadBucket(bucket.bucketDate);
for (const asset of bucket.getAssets()) {
if (deselect) {
assetInteraction.removeAssetFromMultiselectGroup(asset);
assetInteraction.removeAssetFromMultiselectGroup(asset.id);
} else {
handleSelectAsset(asset);
}
@ -553,7 +553,7 @@
return;
}
const assets = assetStore.getAssets();
const assets = assetsSnapshot(assetStore.getAssets());
let start = assets.findIndex((a) => a.id === startAsset.id);
let end = assets.findIndex((a) => a.id === endAsset.id);
@ -602,7 +602,7 @@
let isTrashEnabled = $derived($featureFlags.loaded && $featureFlags.trash);
let isEmpty = $derived(assetStore.isInitialized && assetStore.buckets.length === 0);
let idsSelectedAssets = $derived(assetInteraction.selectedAssetsArray.map(({ id }) => id));
let idsSelectedAssets = $derived(assetInteraction.selectedAssets.map(({ id }) => id));
$effect(() => {
if (isEmpty) {

View File

@ -4,8 +4,8 @@
export interface AssetControlContext {
// Wrap assets in a function, because context isn't reactive.
getAssets: () => Set<AssetResponseDto>; // All assets includes partners' assets
getOwnedAssets: () => Set<AssetResponseDto>; // Only assets owned by the user
getAssets: () => AssetResponseDto[]; // All assets includes partners' assets
getOwnedAssets: () => AssetResponseDto[]; // Only assets owned by the user
clearSelect: () => void;
}
@ -20,7 +20,7 @@
import type { Snippet } from 'svelte';
interface Props {
assets: Set<AssetResponseDto>;
assets: AssetResponseDto[];
clearSelect: () => void;
ownerId?: string | undefined;
children?: Snippet;
@ -30,8 +30,7 @@
setContext({
getAssets: () => assets,
getOwnedAssets: () =>
ownerId === undefined ? assets : new Set([...assets].filter((asset) => asset.ownerId === ownerId)),
getOwnedAssets: () => (ownerId === undefined ? assets : assets.filter((asset) => asset.ownerId === ownerId)),
clearSelect,
});
</script>
@ -39,8 +38,8 @@
<ControlAppBar onClose={clearSelect} backIcon={mdiClose} tailwindClasses="bg-white shadow-md">
{#snippet leading()}
<div class="font-medium text-immich-primary dark:text-immich-dark-primary">
<p class="block sm:hidden">{assets.size}</p>
<p class="hidden sm:block">{$t('selected_count', { values: { count: assets.size } })}</p>
<p class="block sm:hidden">{assets.length}</p>
<p class="hidden sm:block">{$t('selected_count', { values: { count: assets.length } })}</p>
</div>
{/snippet}
{#snippet trailing()}

View File

@ -15,7 +15,6 @@
import CoordinatesInput from '$lib/components/shared-components/coordinates-input.svelte';
import Map from '$lib/components/shared-components/map/map.svelte';
import { get } from 'svelte/store';
interface Point {
lng: number;
lat: number;

View File

@ -169,9 +169,9 @@
// Select/deselect already loaded assets
if (deselect) {
for (const candidate of assetInteraction.assetSelectionCandidates) {
assetInteraction.removeAssetFromMultiselectGroup(candidate);
assetInteraction.removeAssetFromMultiselectGroup(candidate.id);
}
assetInteraction.removeAssetFromMultiselectGroup(asset);
assetInteraction.removeAssetFromMultiselectGroup(asset.id);
} else {
for (const candidate of assetInteraction.assetSelectionCandidates) {
assetInteraction.selectAsset(candidate);
@ -217,7 +217,7 @@
};
const onDelete = () => {
const hasTrashedAsset = assetInteraction.selectedAssetsArray.some((asset) => asset.isTrashed);
const hasTrashedAsset = assetInteraction.selectedAssets.some((asset) => asset.isTrashed);
if ($showDeleteModal && (!isTrashEnabled || hasTrashedAsset)) {
isShowDeleteConfirmation = true;
@ -245,7 +245,7 @@
};
const toggleArchive = async () => {
const ids = await archiveAssets(assetInteraction.selectedAssetsArray, !assetInteraction.isAllArchived);
const ids = await archiveAssets(assetInteraction.selectedAssets, !assetInteraction.isAllArchived);
if (ids) {
assets = assets.filter((asset) => !ids.includes(asset.id));
deselectAllAssets();
@ -407,7 +407,7 @@
};
let isTrashEnabled = $derived($featureFlags.loaded && $featureFlags.trash);
let idsSelectedAssets = $derived(assetInteraction.selectedAssetsArray.map(({ id }) => id));
let idsSelectedAssets = $derived(assetInteraction.selectedAssets.map(({ id }) => id));
$effect(() => {
if (!lastAssetMouseEvent) {
@ -438,7 +438,7 @@
{#if isShowDeleteConfirmation}
<DeleteAssetDialog
size={assetInteraction.selectedAssets.size}
size={assetInteraction.selectedAssets.length}
onCancel={() => (isShowDeleteConfirmation = false)}
onConfirm={() => handlePromiseError(trashOrDelete(true))}
/>
@ -480,7 +480,7 @@
{asset}
selected={assetInteraction.hasSelectedAsset(asset.id)}
selectionCandidate={assetInteraction.hasSelectionCandidate(asset.id)}
focussed={assetInteraction.isFocussedAsset(asset)}
focussed={assetInteraction.isFocussedAsset(asset.id)}
thumbnailWidth={layout.width}
thumbnailHeight={layout.height}
/>

View File

@ -4,43 +4,43 @@ import { SvelteSet } from 'svelte/reactivity';
import { fromStore } from 'svelte/store';
export class AssetInteraction {
readonly selectedAssets = new SvelteSet<AssetResponseDto>();
selectedAssets = $state<AssetResponseDto[]>([]);
hasSelectedAsset(assetId: string) {
return [...this.selectedAssets.values()].some((asset) => asset.id === assetId);
return this.selectedAssets.some((asset) => asset.id === assetId);
}
readonly selectedGroup = new SvelteSet<string>();
assetSelectionCandidates = $state(new SvelteSet<AssetResponseDto>());
selectedGroup = new SvelteSet<string>();
assetSelectionCandidates = $state<AssetResponseDto[]>([]);
hasSelectionCandidate(assetId: string) {
return [...this.assetSelectionCandidates.values()].some((asset) => asset.id === assetId);
return this.assetSelectionCandidates.some((asset) => asset.id === assetId);
}
assetSelectionStart = $state<AssetResponseDto | null>(null);
focussedAssetId = $state<string | null>(null);
selectionActive = $derived(this.selectedAssets.size > 0);
selectedAssetsArray = $derived([...this.selectedAssets]);
selectionActive = $derived(this.selectedAssets.length > 0);
private user = fromStore<UserAdminResponseDto | undefined>(user);
private userId = $derived(this.user.current?.id);
isAllTrashed = $derived(this.selectedAssetsArray.every((asset) => asset.isTrashed));
isAllArchived = $derived(this.selectedAssetsArray.every((asset) => asset.isArchived));
isAllFavorite = $derived(this.selectedAssetsArray.every((asset) => asset.isFavorite));
isAllUserOwned = $derived(this.selectedAssetsArray.every((asset) => asset.ownerId === this.userId));
isAllTrashed = $derived(this.selectedAssets.every((asset) => asset.isTrashed));
isAllArchived = $derived(this.selectedAssets.every((asset) => asset.isArchived));
isAllFavorite = $derived(this.selectedAssets.every((asset) => asset.isFavorite));
isAllUserOwned = $derived(this.selectedAssets.every((asset) => asset.ownerId === this.userId));
selectAsset(asset: AssetResponseDto) {
this.selectedAssets.add(asset);
if (!this.hasSelectedAsset(asset.id)) {
this.selectedAssets.push(asset);
}
}
selectAssets(assets: AssetResponseDto[]) {
for (const asset of assets) {
this.selectedAssets.add(asset);
this.selectAsset(asset);
}
}
removeAssetFromMultiselectGroup(asset: AssetResponseDto) {
const selectedAsset = [...this.selectedAssets.values()].find((a) => a.id === asset.id);
if (selectedAsset) {
this.selectedAssets.delete(selectedAsset);
removeAssetFromMultiselectGroup(assetId: string) {
const index = this.selectedAssets.findIndex((a) => a.id == assetId);
if (index !== -1) {
this.selectedAssets.splice(index, 1);
}
}
@ -57,24 +57,24 @@ export class AssetInteraction {
}
setAssetSelectionCandidates(assets: AssetResponseDto[]) {
this.assetSelectionCandidates = new SvelteSet(assets);
this.assetSelectionCandidates = assets;
}
clearAssetSelectionCandidates() {
this.assetSelectionCandidates.clear();
this.assetSelectionCandidates = [];
}
clearMultiselect() {
// Multi-selection
this.selectedAssets.clear();
this.selectedAssets = [];
this.selectedGroup.clear();
// Range selection
this.assetSelectionCandidates.clear();
this.assetSelectionCandidates = [];
this.assetSelectionStart = null;
}
isFocussedAsset(asset: AssetResponseDto) {
return this.focussedAssetId === asset.id;
isFocussedAsset(assetId: string) {
return this.focussedAssetId === assetId;
}
}

View File

@ -57,6 +57,13 @@ function updateObject(target: any, source: any): boolean {
return updated;
}
export function assetSnapshot(asset: AssetResponseDto) {
return $state.snapshot(asset);
}
export function assetsSnapshot(assets: AssetResponseDto[]) {
return assets.map((a) => $state.snapshot(a));
}
class IntersectingAsset {
// --- public ---
readonly #group: AssetDateGroup;
@ -284,9 +291,11 @@ export class AssetBucket {
get lastDateGroup() {
return this.dateGroups.at(-1);
}
getFirstAsset() {
return this.dateGroups[0]?.getFirstAsset();
}
getAssets() {
// eslint-disable-next-line unicorn/no-array-reduce
return this.dateGroups.reduce(

View File

@ -5,7 +5,7 @@ import { NotificationType, notificationController } from '$lib/components/shared
import { AppRoute } from '$lib/constants';
import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { isSelectingAllAssets, type AssetStore } from '$lib/stores/assets-store.svelte';
import { assetsSnapshot, isSelectingAllAssets, type AssetStore } from '$lib/stores/assets-store.svelte';
import { downloadManager } from '$lib/stores/download';
import { preferences } from '$lib/stores/user.store';
import { downloadRequest, getKey, withError } from '$lib/utils';
@ -367,7 +367,7 @@ export const getAssetType = (type: AssetTypeEnum) => {
}
};
export const getSelectedAssets = (assets: Set<AssetResponseDto>, user: UserResponseDto | null): string[] => {
export const getSelectedAssets = (assets: AssetResponseDto[], user: UserResponseDto | null): string[] => {
const ids = [...assets].filter((a) => user && a.ownerId === user.id).map((a) => a.id);
const numberOfIssues = [...assets].filter((a) => user && a.ownerId !== user.id).length;
@ -474,15 +474,10 @@ export const selectAllAssets = async (assetStore: AssetStore, assetInteraction:
await assetStore.loadBucket(bucket.bucketDate);
if (!get(isSelectingAllAssets)) {
assetInteraction.clearMultiselect();
break; // Cancelled
}
assetInteraction.selectAssets(bucket.getAssets().map((a) => $state.snapshot(a)));
// We use setTimeout to allow the UI to update. Otherwise, this may
// cause a long delay between the start of 'select all' and the
// effective update of the UI, depending on the number of assets
// to select
await delay(0);
assetInteraction.selectAssets(assetsSnapshot(bucket.getAssets()));
}
} catch (error) {
const $t = get(t);

View File

@ -249,7 +249,7 @@
album = await getAlbumInfo({ id: album.id, withoutAssets: true });
};
const handleAddAssets = async () => {
const assetIds = timelineInteraction.selectedAssetsArray.map((asset) => asset.id);
const assetIds = timelineInteraction.selectedAssets.map((asset) => asset.id);
try {
const results = await addAssetsToAlbum({
@ -364,7 +364,7 @@
};
const updateThumbnailUsingCurrentSelection = async () => {
if (assetInteraction.selectedAssets.size === 1) {
if (assetInteraction.selectedAssets.length === 1) {
const [firstAsset] = assetInteraction.selectedAssets;
assetInteraction.clearMultiselect();
await updateThumbnail(firstAsset.id);
@ -479,7 +479,7 @@
{#if assetInteraction.isAllUserOwned}
<ChangeDate menuItem />
<ChangeLocation menuItem />
{#if assetInteraction.selectedAssets.size === 1}
{#if assetInteraction.selectedAssets.length === 1}
<MenuOption
text={$t('set_as_album_cover')}
icon={mdiImageOutline}
@ -574,7 +574,7 @@
{#if !timelineInteraction.selectionActive}
{$t('add_to_album')}
{:else}
{$t('selected_count', { values: { count: timelineInteraction.selectedAssets.size } })}
{$t('selected_count', { values: { count: timelineInteraction.selectedAssets.length } })}
{/if}
</p>
{/snippet}

View File

@ -149,7 +149,7 @@
});
const handleUnmerge = () => {
assetStore.removeAssets(assetInteraction.selectedAssetsArray.map((a) => a.id));
assetStore.removeAssets(assetInteraction.selectedAssets.map((a) => a.id));
assetInteraction.clearMultiselect();
viewMode = PersonPageViewMode.VIEW_ASSETS;
};
@ -368,7 +368,7 @@
{#if viewMode === PersonPageViewMode.UNASSIGN_ASSETS}
<UnMergeFaceSelector
assetIds={assetInteraction.selectedAssetsArray.map((a) => a.id)}
assetIds={assetInteraction.selectedAssets.map((a) => a.id)}
personAssets={person}
onClose={() => (viewMode = PersonPageViewMode.VIEW_ASSETS)}
onConfirm={handleUnmerge}

View File

@ -39,7 +39,7 @@
const assetInteraction = new AssetInteraction();
let selectedAssets = $derived(assetInteraction.selectedAssetsArray);
let selectedAssets = $derived(assetInteraction.selectedAssets);
let isAssetStackSelected = $derived(selectedAssets.length === 1 && !!selectedAssets[0].stack);
let isLinkActionAvailable = $derived.by(() => {
const isLivePhoto = selectedAssets.length === 1 && !!selectedAssets[0].livePhotoVideoId;
@ -97,7 +97,7 @@
></FavoriteAction>
<ButtonContextMenu icon={mdiDotsVertical} title={$t('menu')}>
<DownloadAction menuItem />
{#if assetInteraction.selectedAssets.size > 1 || isAssetStackSelected}
{#if assetInteraction.selectedAssets.length > 1 || isAssetStackSelected}
<StackAction
unstack={isAssetStackSelected}
onStack={(assetIds) => assetStore.removeAssets(assetIds)}
@ -107,7 +107,7 @@
{#if isLinkActionAvailable}
<LinkLivePhotoAction
menuItem
unlink={assetInteraction.selectedAssets.size === 1}
unlink={assetInteraction.selectedAssets.length === 1}
onLink={handleLink}
onUnlink={handleUnlink}
/>

View File

@ -85,7 +85,7 @@
}
if (assetInteraction.selectionActive) {
assetInteraction.selectedAssets.clear();
assetInteraction.selectedAssets = [];
return;
}
if (!$preventRaceConditionSearchBar) {