feat: add bulk asset selection support to large files utility page

pull/28732/head
Nopparuj-an 2026-05-30 13:27:19 +07:00
parent 95e57a24cb
commit c01e8f4eb4
2 changed files with 183 additions and 9 deletions

View File

@ -6,10 +6,23 @@
interface Props {
asset: AssetResponseDto;
onViewAsset: (asset: AssetResponseDto) => void;
selected: boolean;
selectionCandidate: boolean;
onSelect: (asset: AssetResponseDto) => void;
onClick: (asset: AssetResponseDto) => void;
onPreview?: (asset: AssetResponseDto) => void;
onMouseEvent?: (asset: AssetResponseDto | null) => void;
}
let { asset, onViewAsset }: Props = $props();
let {
asset,
selected,
selectionCandidate,
onSelect,
onClick,
onPreview = undefined,
onMouseEvent = undefined,
}: Props = $props();
let assetData = $derived(JSON.stringify(asset, null, 2));
@ -22,7 +35,16 @@
title={assetData}
>
<div class="relative w-full h-full overflow-hidden rounded-lg">
<Thumbnail asset={toTimelineAsset(asset)} readonly onClick={() => onViewAsset(asset)} thumbnailSize={boxWidth} />
<Thumbnail
asset={toTimelineAsset(asset)}
thumbnailSize={boxWidth}
onClick={() => onClick(asset)}
onSelect={() => onSelect(asset)}
onPreview={onPreview ? () => onPreview?.(asset) : undefined}
onMouseEvent={({ isMouseOver }) => onMouseEvent?.(isMouseOver ? asset : null)}
{selected}
{selectionCandidate}
/>
{#if !!asset.libraryId}
<div class="absolute bottom-1 end-3 px-4 py-1 rounded-xl text-xs transition-colors bg-red-500">External</div>

View File

@ -1,13 +1,21 @@
<script lang="ts">
import type { Action } from '$lib/components/asset-viewer/actions/action';
import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte';
import OnEvents from '$lib/components/OnEvents.svelte';
import AssetSelectControlBar from '$lib/components/timeline/AssetSelectControlBar.svelte';
import DeleteAssets from '$lib/components/timeline/actions/DeleteAssetsAction.svelte';
import LargeAssetData from '$lib/components/utilities-page/large-assets/large-asset-data.svelte';
import Portal from '$lib/elements/Portal.svelte';
import { assetMultiSelectManager } from '$lib/managers/asset-multi-select-manager.svelte';
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
import { getAssetBulkActions } from '$lib/services/asset.service';
import { handlePromiseError } from '$lib/utils';
import { getNextAsset, getPreviousAsset } from '$lib/utils/asset-utils';
import { navigate } from '$lib/utils/navigation';
import { toTimelineAsset } from '$lib/utils/timeline-util';
import type { AssetResponseDto } from '@immich/sdk';
import { ActionButton, CommandPaletteDefaultProvider, IconButton } from '@immich/ui';
import { mdiSelectAll } from '@mdi/js';
import { t } from 'svelte-i18n';
import type { PageData } from './$types';
@ -17,9 +25,12 @@
let { data }: Props = $props();
let assets = $derived(data.assets);
let assets = $state(data.assets);
let asset = $derived(data.asset);
let shiftKeyIsDown = $state(false);
let lastAssetMouseEvent = $state<AssetResponseDto | null>(null);
$effect(() => {
if (asset) {
assetViewerManager.setAsset(asset);
@ -36,29 +47,153 @@
return asset;
};
const onAssetsDelete = (assetIds: string[]) => {
assets = assets.filter(({ id }) => !assetIds.includes(id));
};
const onAction = (payload: Action) => {
if (payload.type == 'trash') {
assets = assets.filter((a) => a.id != payload.asset.id);
if (payload.type == 'trash' || payload.type == 'delete') {
onAssetsDelete([payload.asset.id]);
assetViewerManager.showAssetViewer(false);
}
};
const handleSelectAssets = (asset: AssetResponseDto) => {
const deselect = assetMultiSelectManager.hasSelectedAsset(asset.id);
const timelineAsset = toTimelineAsset(asset);
if (deselect) {
for (const candidate of assetMultiSelectManager.candidates) {
assetMultiSelectManager.removeAssetFromMultiselectGroup(candidate.id);
}
assetMultiSelectManager.removeAssetFromMultiselectGroup(asset.id);
} else {
for (const candidate of assetMultiSelectManager.candidates) {
assetMultiSelectManager.selectAsset(candidate);
}
assetMultiSelectManager.selectAsset(timelineAsset);
}
assetMultiSelectManager.clearCandidates();
assetMultiSelectManager.setAssetSelectionStart(deselect ? null : timelineAsset);
};
const selectAssetCandidates = (endAsset: AssetResponseDto) => {
if (!shiftKeyIsDown) {
return;
}
const startAsset = assetMultiSelectManager.startAsset;
if (!startAsset) {
return;
}
let start = assets.findIndex((a) => a.id === startAsset.id);
let end = assets.findIndex((a) => a.id === endAsset.id);
if (start > end) {
[start, end] = [end, start];
}
assetMultiSelectManager.setAssetSelectionCandidates(assets.slice(start, end + 1).map((a) => toTimelineAsset(a)));
};
const handleSelectAssetCandidates = (asset: AssetResponseDto | null) => {
if (asset) {
selectAssetCandidates(asset);
}
lastAssetMouseEvent = asset;
};
const onViewAsset = async (asset: AssetResponseDto) => {
await navigate({ targetRoute: 'current', assetId: asset.id });
};
const handlePreview = async (asset: AssetResponseDto) => {
await navigate({ targetRoute: 'current', assetId: asset.id });
};
const handleSelectAll = () => {
assetMultiSelectManager.selectAssets(assets.map((item) => toTimelineAsset(item)));
};
const handleOnClick = (clickedAsset: AssetResponseDto) => {
if (assetMultiSelectManager.selectionActive) {
handleSelectAssets(clickedAsset);
return;
}
void onViewAsset(clickedAsset);
};
const handleEscape = () => {
if (assetMultiSelectManager.selectionActive) {
assetMultiSelectManager.clear();
}
};
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Shift') {
event.preventDefault();
shiftKeyIsDown = true;
}
if (event.key === 'Escape') {
handleEscape();
}
};
const onKeyUp = (event: KeyboardEvent) => {
if (event.key === 'Shift') {
event.preventDefault();
shiftKeyIsDown = false;
}
};
const assetCursor = $derived({
current: assetViewerManager.asset!,
nextAsset: getNextAsset(assets, assetViewerManager.asset),
previousAsset: getPreviousAsset(assets, assetViewerManager.asset),
});
const onAlbumAddAssets = () => {
assetMultiSelectManager.clear();
};
$effect(() => {
if (!lastAssetMouseEvent) {
assetMultiSelectManager.clearCandidates();
}
});
$effect(() => {
if (!shiftKeyIsDown) {
assetMultiSelectManager.clearCandidates();
}
});
$effect(() => {
if (shiftKeyIsDown && lastAssetMouseEvent) {
selectAssetCandidates(lastAssetMouseEvent);
}
});
</script>
<UserPageLayout title={data.meta.title} scrollbar={true}>
<svelte:document onkeydown={onKeyDown} onkeyup={onKeyUp} />
<OnEvents {onAssetsDelete} {onAlbumAddAssets} />
<UserPageLayout title={data.meta.title} scrollbar={true} hideNavbar={assetMultiSelectManager.selectionActive}>
<div class="grid gap-2 grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-6">
{#if assets && data.assets.length > 0}
{#if assets && assets.length > 0}
{#each assets as asset (asset.id)}
<LargeAssetData {asset} {onViewAsset} />
<LargeAssetData
{asset}
selected={assetMultiSelectManager.hasSelectedAsset(asset.id)}
selectionCandidate={assetMultiSelectManager.hasSelectionCandidate(asset.id)}
onClick={handleOnClick}
onSelect={handleSelectAssets}
onPreview={assetMultiSelectManager.selectionActive ? handlePreview : undefined}
onMouseEvent={handleSelectAssetCandidates}
/>
{/each}
{:else}
<p class="text-center text-lg dark:text-white flex place-items-center place-content-center">
@ -68,6 +203,23 @@
</div>
</UserPageLayout>
{#if assetMultiSelectManager.selectionActive}
<AssetSelectControlBar>
{@const Actions = getAssetBulkActions($t)}
<CommandPaletteDefaultProvider name={$t('assets')} actions={Object.values(Actions)} />
<ActionButton action={Actions.AddToAlbum} />
<IconButton
shape="round"
color="secondary"
variant="ghost"
aria-label={$t('select_all')}
icon={mdiSelectAll}
onclick={handleSelectAll}
/>
<DeleteAssets onAssetDelete={onAssetsDelete} />
</AssetSelectControlBar>
{/if}
{#if assetViewerManager.isViewing}
{#await import('$lib/components/asset-viewer/asset-viewer.svelte') then { default: AssetViewer }}
<Portal target="body">