1
0
Fork 0
mirror of https://github.com/immich-app/immich.git synced 2025-01-06 11:56:46 +01:00

feat(web): select a range of assets (#3086)

The shift key can be held to select a range of assets.

Fixes: #2862
This commit is contained in:
Thomas 2023-07-03 10:56:58 +01:00 committed by GitHub
parent 2099b04057
commit 8fd4edb206
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
5 changed files with 154 additions and 32 deletions

View file

@ -3,14 +3,15 @@
import { timeToSeconds } from '$lib/utils/time-to-seconds'; import { timeToSeconds } from '$lib/utils/time-to-seconds';
import { api, AssetResponseDto, AssetTypeEnum, ThumbnailFormat } from '@api'; import { api, AssetResponseDto, AssetTypeEnum, ThumbnailFormat } from '@api';
import { createEventDispatcher } from 'svelte'; import { createEventDispatcher } from 'svelte';
import ArchiveArrowDownOutline from 'svelte-material-icons/ArchiveArrowDownOutline.svelte';
import CheckCircle from 'svelte-material-icons/CheckCircle.svelte'; import CheckCircle from 'svelte-material-icons/CheckCircle.svelte';
import Heart from 'svelte-material-icons/Heart.svelte';
import ImageBrokenVariant from 'svelte-material-icons/ImageBrokenVariant.svelte';
import MotionPauseOutline from 'svelte-material-icons/MotionPauseOutline.svelte'; import MotionPauseOutline from 'svelte-material-icons/MotionPauseOutline.svelte';
import MotionPlayOutline from 'svelte-material-icons/MotionPlayOutline.svelte'; import MotionPlayOutline from 'svelte-material-icons/MotionPlayOutline.svelte';
import Heart from 'svelte-material-icons/Heart.svelte'; import { fade } from 'svelte/transition';
import ArchiveArrowDownOutline from 'svelte-material-icons/ArchiveArrowDownOutline.svelte';
import ImageThumbnail from './image-thumbnail.svelte'; import ImageThumbnail from './image-thumbnail.svelte';
import VideoThumbnail from './video-thumbnail.svelte'; import VideoThumbnail from './video-thumbnail.svelte';
import ImageBrokenVariant from 'svelte-material-icons/ImageBrokenVariant.svelte';
const dispatch = createEventDispatcher(); const dispatch = createEventDispatcher();
@ -21,6 +22,7 @@
export let thumbnailHeight: number | undefined = undefined; export let thumbnailHeight: number | undefined = undefined;
export let format: ThumbnailFormat = ThumbnailFormat.Webp; export let format: ThumbnailFormat = ThumbnailFormat.Webp;
export let selected = false; export let selected = false;
export let selectionCandidate = false;
export let disabled = false; export let disabled = false;
export let readonly = false; export let readonly = false;
export let publicSharedKey: string | undefined = undefined; export let publicSharedKey: string | undefined = undefined;
@ -30,7 +32,7 @@
$: dispatch('mouse-event', { isMouseOver: mouseOver, selectedGroupIndex: groupIndex }); $: dispatch('mouse-event', { isMouseOver: mouseOver, selectedGroupIndex: groupIndex });
$: [width, height] = (() => { $: [width, height] = ((): [number, number] => {
if (thumbnailSize) { if (thumbnailSize) {
return [thumbnailSize, thumbnailSize]; return [thumbnailSize, thumbnailSize];
} }
@ -42,12 +44,19 @@
return [235, 235]; return [235, 235];
})(); })();
const thumbnailClickedHandler = () => { const thumbnailClickedHandler = (e: Event) => {
if (!disabled) { if (!disabled) {
e.preventDefault();
dispatch('click', { asset }); dispatch('click', { asset });
} }
}; };
const thumbnailKeyDownHandler = (e: KeyboardEvent) => {
if (e.key === 'Enter') {
thumbnailClickedHandler(e);
}
};
const onIconClickedHandler = (e: MouseEvent) => { const onIconClickedHandler = (e: MouseEvent) => {
e.stopPropagation(); e.stopPropagation();
if (!disabled) { if (!disabled) {
@ -68,21 +77,23 @@
on:mouseenter={() => (mouseOver = true)} on:mouseenter={() => (mouseOver = true)}
on:mouseleave={() => (mouseOver = false)} on:mouseleave={() => (mouseOver = false)}
on:click={thumbnailClickedHandler} on:click={thumbnailClickedHandler}
on:keydown={thumbnailClickedHandler} on:keydown={thumbnailKeyDownHandler}
> >
{#if intersecting} {#if intersecting}
<div class="absolute w-full h-full z-20"> <div class="absolute w-full h-full z-20">
<!-- Select asset button --> <!-- Select asset button -->
{#if !readonly} {#if !readonly && (mouseOver || selected || selectionCandidate)}
<button <button
on:click={onIconClickedHandler} on:click={onIconClickedHandler}
class="absolute p-2 group-hover:block" on:keydown|preventDefault
class:group-hover:block={!disabled} on:keyup|preventDefault
class:hidden={!selected} class="absolute p-2"
class:cursor-not-allowed={disabled} class:cursor-not-allowed={disabled}
role="checkbox" role="checkbox"
aria-checked={selected} aria-checked={selected}
{disabled} {disabled}
in:fade={{ duration: 100 }}
out:fade={{ duration: 100 }}
> >
{#if disabled} {#if disabled}
<CheckCircle size="24" class="text-zinc-800" /> <CheckCircle size="24" class="text-zinc-800" />
@ -153,6 +164,13 @@
</div> </div>
{/if} {/if}
</div> </div>
{#if selectionCandidate}
<div
class="absolute w-full h-full top-0 bg-immich-primary opacity-40"
in:fade={{ duration: 100 }}
out:fade={{ duration: 100 }}
/>
{/if}
{/if} {/if}
</div> </div>
</IntersectionObserver> </IntersectionObserver>

View file

@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import { import {
assetInteractionStore, assetInteractionStore,
assetSelectionCandidates,
assetsInAlbumStoreState, assetsInAlbumStoreState,
isMultiSelectStoreState, isMultiSelectStoreState,
selectedAssets, selectedAssets,
@ -8,15 +9,15 @@
} from '$lib/stores/asset-interaction.store'; } from '$lib/stores/asset-interaction.store';
import { assetStore } from '$lib/stores/assets.store'; import { assetStore } from '$lib/stores/assets.store';
import { locale } from '$lib/stores/preferences.store'; import { locale } from '$lib/stores/preferences.store';
import { getAssetRatio } from '$lib/utils/asset-utils';
import type { AssetResponseDto } from '@api'; import type { AssetResponseDto } from '@api';
import justifiedLayout from 'justified-layout'; import justifiedLayout from 'justified-layout';
import lodash from 'lodash-es'; import lodash from 'lodash-es';
import { createEventDispatcher } from 'svelte';
import CheckCircle from 'svelte-material-icons/CheckCircle.svelte'; import CheckCircle from 'svelte-material-icons/CheckCircle.svelte';
import CircleOutline from 'svelte-material-icons/CircleOutline.svelte'; import CircleOutline from 'svelte-material-icons/CircleOutline.svelte';
import { fly } from 'svelte/transition'; import { fly } from 'svelte/transition';
import { getAssetRatio } from '$lib/utils/asset-utils';
import Thumbnail from '../assets/thumbnail/thumbnail.svelte'; import Thumbnail from '../assets/thumbnail/thumbnail.svelte';
import { createEventDispatcher } from 'svelte';
export let assets: AssetResponseDto[]; export let assets: AssetResponseDto[];
export let bucketDate: string; export let bucketDate: string;
@ -130,18 +131,19 @@
dateGroupTitle: string, dateGroupTitle: string,
) => { ) => {
if ($selectedAssets.has(asset)) { if ($selectedAssets.has(asset)) {
for (const candidate of $assetSelectionCandidates || []) {
assetInteractionStore.removeAssetFromMultiselectGroup(candidate);
}
assetInteractionStore.removeAssetFromMultiselectGroup(asset); assetInteractionStore.removeAssetFromMultiselectGroup(asset);
} else { } else {
for (const candidate of $assetSelectionCandidates || []) {
assetInteractionStore.addAssetToMultiselectGroup(candidate);
}
assetInteractionStore.addAssetToMultiselectGroup(asset); assetInteractionStore.addAssetToMultiselectGroup(asset);
} }
// Check if all assets are selected in a group to toggle the group selection's icon // Check if all assets are selected in a group to toggle the group selection's icon
let selectedAssetsInGroupCount = 0; let selectedAssetsInGroupCount = assetsInDateGroup.filter((asset) => $selectedAssets.has(asset)).length;
assetsInDateGroup.forEach((asset) => {
if ($selectedAssets.has(asset)) {
selectedAssetsInGroupCount++;
}
});
// if all assets are selected in a group, add the group to selected group // if all assets are selected in a group, add the group to selected group
if (selectedAssetsInGroupCount == assetsInDateGroup.length) { if (selectedAssetsInGroupCount == assetsInDateGroup.length) {
@ -151,9 +153,13 @@
} }
}; };
const assetMouseEventHandler = (dateGroupTitle: string) => { const assetMouseEventHandler = (dateGroupTitle: string, asset: AssetResponseDto | null) => {
// Show multi select icon on hover on date group // Show multi select icon on hover on date group
hoveredDateGroup = dateGroupTitle; hoveredDateGroup = dateGroupTitle;
if ($isMultiSelectStoreState) {
dispatch('selectAssetCandidates', { asset });
}
}; };
</script> </script>
@ -171,9 +177,12 @@
class="flex flex-col mt-5" class="flex flex-col mt-5"
on:mouseenter={() => { on:mouseenter={() => {
isMouseOverGroup = true; isMouseOverGroup = true;
assetMouseEventHandler(dateGroupTitle); assetMouseEventHandler(dateGroupTitle, null);
}}
on:mouseleave={() => {
isMouseOverGroup = false;
assetMouseEventHandler(dateGroupTitle, null);
}} }}
on:mouseleave={() => (isMouseOverGroup = false)}
> >
<!-- Date group title --> <!-- Date group title -->
<p <p
@ -216,9 +225,10 @@
{groupIndex} {groupIndex}
on:click={() => assetClickHandler(asset, assetsInDateGroup, dateGroupTitle)} on:click={() => assetClickHandler(asset, assetsInDateGroup, dateGroupTitle)}
on:select={() => assetSelectHandler(asset, assetsInDateGroup, dateGroupTitle)} on:select={() => assetSelectHandler(asset, assetsInDateGroup, dateGroupTitle)}
on:mouse-event={() => assetMouseEventHandler(dateGroupTitle)} on:mouse-event={() => assetMouseEventHandler(dateGroupTitle, asset)}
selected={$selectedAssets.has(asset) || $assetsInAlbumStoreState.findIndex((a) => a.id == asset.id) != -1} selected={$selectedAssets.has(asset) || $assetsInAlbumStoreState.some(({ id }) => id === asset.id)}
disabled={$assetsInAlbumStoreState.findIndex((a) => a.id == asset.id) != -1} selectionCandidate={$assetSelectionCandidates.has(asset)}
disabled={$assetsInAlbumStoreState.some(({ id }) => id === asset.id)}
thumbnailWidth={box.width} thumbnailWidth={box.width}
thumbnailHeight={box.height} thumbnailHeight={box.height}
/> />

View file

@ -1,12 +1,15 @@
<script lang="ts"> <script lang="ts">
import { BucketPosition } from '$lib/models/asset-grid-state';
import { import {
assetInteractionStore, assetInteractionStore,
isMultiSelectStoreState,
isViewingAssetStoreState, isViewingAssetStoreState,
selectedAssets,
viewingAssetStoreState, viewingAssetStoreState,
} from '$lib/stores/asset-interaction.store'; } from '$lib/stores/asset-interaction.store';
import { assetGridState, assetStore, loadingBucketState } from '$lib/stores/assets.store'; import { assetGridState, assetStore, loadingBucketState } from '$lib/stores/assets.store';
import type { UserResponseDto } from '@api'; import type { UserResponseDto } from '@api';
import { AssetCountByTimeBucketResponseDto, AssetResponseDto, TimeGroupEnum, api } from '@api'; import { api, AssetCountByTimeBucketResponseDto, AssetResponseDto, TimeGroupEnum } from '@api';
import { onDestroy, onMount } from 'svelte'; import { onDestroy, onMount } from 'svelte';
import AssetViewer from '../asset-viewer/asset-viewer.svelte'; import AssetViewer from '../asset-viewer/asset-viewer.svelte';
import IntersectionObserver from '../asset-viewer/intersection-observer.svelte'; import IntersectionObserver from '../asset-viewer/intersection-observer.svelte';
@ -16,7 +19,6 @@
OnScrollbarDragDetail, OnScrollbarDragDetail,
} from '../shared-components/scrollbar/scrollbar.svelte'; } from '../shared-components/scrollbar/scrollbar.svelte';
import AssetDateGroup from './asset-date-group.svelte'; import AssetDateGroup from './asset-date-group.svelte';
import { BucketPosition } from '$lib/models/asset-grid-state';
import MemoryLane from './memory-lane.svelte'; import MemoryLane from './memory-lane.svelte';
export let user: UserResponseDto | undefined = undefined; export let user: UserResponseDto | undefined = undefined;
@ -111,8 +113,80 @@
navigateToNextAsset(); navigateToNextAsset();
assetStore.removeAsset(asset.id); assetStore.removeAsset(asset.id);
}; };
let lastAssetMouseEvent: AssetResponseDto | null = null;
$: if (!lastAssetMouseEvent) {
assetInteractionStore.clearAssetSelectionCandidates();
}
let shiftKeyIsDown = false;
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Shift') {
e.preventDefault();
shiftKeyIsDown = true;
}
};
const onKeyUp = (e: KeyboardEvent) => {
if (e.key === 'Shift') {
e.preventDefault();
shiftKeyIsDown = false;
}
};
$: if (!shiftKeyIsDown) {
assetInteractionStore.clearAssetSelectionCandidates();
}
$: if (shiftKeyIsDown && lastAssetMouseEvent) {
selectAssetCandidates(lastAssetMouseEvent);
}
const getLastSelectedAsset = () => {
let value;
for (value of $selectedAssets);
return value;
};
const handleSelectAssetCandidates = (e: CustomEvent) => {
const asset = e.detail.asset;
if (asset) {
selectAssetCandidates(asset);
}
lastAssetMouseEvent = asset;
};
const selectAssetCandidates = (asset: AssetResponseDto) => {
if (!shiftKeyIsDown) {
return;
}
const lastSelectedAsset = getLastSelectedAsset();
if (!lastSelectedAsset) {
return;
}
let start = $assetGridState.assets.indexOf(asset);
let end = $assetGridState.assets.indexOf(lastSelectedAsset);
if (start > end) {
[start, end] = [end, start];
}
assetInteractionStore.setAssetSelectionCandidates($assetGridState.assets.slice(start, end + 1));
};
const onSelectStart = (e: Event) => {
if ($isMultiSelectStoreState && shiftKeyIsDown) {
e.preventDefault();
}
};
</script> </script>
<svelte:window on:keydown={onKeyDown} on:keyup={onKeyUp} on:selectstart={onSelectStart} />
{#if bucketInfo && viewportHeight && $assetGridState.timelineHeight > viewportHeight} {#if bucketInfo && viewportHeight && $assetGridState.timelineHeight > viewportHeight}
<Scrollbar <Scrollbar
scrollbarHeight={viewportHeight} scrollbarHeight={viewportHeight}
@ -155,6 +229,7 @@
<AssetDateGroup <AssetDateGroup
{isAlbumSelectionMode} {isAlbumSelectionMode}
on:shift={handleScrollTimeline} on:shift={handleScrollTimeline}
on:selectAssetCandidates={handleSelectAssetCandidates}
assets={bucket.assets} assets={bucket.assets}
bucketDate={bucket.bucketDate} bucketDate={bucket.bucketDate}
bucketHeight={bucket.bucketHeight} bucketHeight={bucket.bucketHeight}

View file

@ -12,6 +12,7 @@ export const assetsInAlbumStoreState = writable<AssetResponseDto[]>([]);
export const selectedAssets = writable<Set<AssetResponseDto>>(new Set()); export const selectedAssets = writable<Set<AssetResponseDto>>(new Set());
export const selectedGroup = writable<Set<string>>(new Set()); export const selectedGroup = writable<Set<string>>(new Set());
export const isMultiSelectStoreState = derived(selectedAssets, ($selectedAssets) => $selectedAssets.size > 0); export const isMultiSelectStoreState = derived(selectedAssets, ($selectedAssets) => $selectedAssets.size > 0);
export const assetSelectionCandidates = writable<Set<AssetResponseDto>>(new Set());
function createAssetInteractionStore() { function createAssetInteractionStore() {
let _assetGridState = new AssetGridState(); let _assetGridState = new AssetGridState();
@ -19,6 +20,7 @@ function createAssetInteractionStore() {
let _selectedAssets: Set<AssetResponseDto>; let _selectedAssets: Set<AssetResponseDto>;
let _selectedGroup: Set<string>; let _selectedGroup: Set<string>;
let _assetsInAlbums: AssetResponseDto[]; let _assetsInAlbums: AssetResponseDto[];
let _assetSelectionCandidates: Set<AssetResponseDto>;
// Subscriber // Subscriber
assetGridState.subscribe((state) => { assetGridState.subscribe((state) => {
@ -41,6 +43,10 @@ function createAssetInteractionStore() {
_assetsInAlbums = assets; _assetsInAlbums = assets;
}); });
assetSelectionCandidates.subscribe((assets) => {
_assetSelectionCandidates = assets;
});
// Methods // Methods
/** /**
@ -117,14 +123,26 @@ function createAssetInteractionStore() {
selectedGroup.set(_selectedGroup); selectedGroup.set(_selectedGroup);
}; };
const setAssetSelectionCandidates = (assets: AssetResponseDto[]) => {
_assetSelectionCandidates = new Set(assets);
assetSelectionCandidates.set(_assetSelectionCandidates);
};
const clearAssetSelectionCandidates = () => {
_assetSelectionCandidates.clear();
assetSelectionCandidates.set(_assetSelectionCandidates);
};
const clearMultiselect = () => { const clearMultiselect = () => {
_selectedAssets.clear(); _selectedAssets.clear();
_selectedGroup.clear(); _selectedGroup.clear();
_assetSelectionCandidates.clear();
_assetsInAlbums = []; _assetsInAlbums = [];
selectedAssets.set(_selectedAssets); selectedAssets.set(_selectedAssets);
selectedGroup.set(_selectedGroup); selectedGroup.set(_selectedGroup);
assetsInAlbumStoreState.set(_assetsInAlbums); assetsInAlbumStoreState.set(_assetsInAlbums);
assetSelectionCandidates.set(_assetSelectionCandidates);
}; };
return { return {
@ -136,6 +154,8 @@ function createAssetInteractionStore() {
removeAssetFromMultiselectGroup, removeAssetFromMultiselectGroup,
addGroupToMultiselectGroup, addGroupToMultiselectGroup,
removeGroupFromMultiselectGroup, removeGroupFromMultiselectGroup,
setAssetSelectionCandidates,
clearAssetSelectionCandidates,
clearMultiselect, clearMultiselect,
}; };
} }

View file

@ -1,6 +1,5 @@
import { AssetGridState, BucketPosition } from '$lib/models/asset-grid-state'; import { AssetGridState, BucketPosition } from '$lib/models/asset-grid-state';
import { api, AssetCountByTimeBucketResponseDto } from '@api'; import { api, AssetCountByTimeBucketResponseDto } from '@api';
import { flatMap, sumBy } from 'lodash-es';
import { writable } from 'svelte/store'; import { writable } from 'svelte/store';
/** /**
@ -60,7 +59,7 @@ function createAssetStore() {
// Update timeline height based on calculated bucket height // Update timeline height based on calculated bucket height
assetGridState.update((state) => { assetGridState.update((state) => {
state.timelineHeight = sumBy(state.buckets, (d) => d.bucketHeight); state.timelineHeight = state.buckets.reduce((acc, b) => acc + b.bucketHeight, 0);
return state; return state;
}); });
}; };
@ -101,7 +100,7 @@ function createAssetStore() {
const bucketIndex = state.buckets.findIndex((b) => b.bucketDate === bucket); const bucketIndex = state.buckets.findIndex((b) => b.bucketDate === bucket);
state.buckets[bucketIndex].assets = assets; state.buckets[bucketIndex].assets = assets;
state.buckets[bucketIndex].position = position; state.buckets[bucketIndex].position = position;
state.assets = flatMap(state.buckets, (b) => b.assets); state.assets = state.buckets.flatMap((b) => b.assets);
return state; return state;
}); });
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
@ -123,7 +122,7 @@ function createAssetStore() {
if (state.buckets[bucketIndex].assets.length === 0) { if (state.buckets[bucketIndex].assets.length === 0) {
_removeBucket(state.buckets[bucketIndex].bucketDate); _removeBucket(state.buckets[bucketIndex].bucketDate);
} }
state.assets = flatMap(state.buckets, (b) => b.assets); state.assets = state.buckets.flatMap((b) => b.assets);
return state; return state;
}); });
}; };
@ -132,7 +131,7 @@ function createAssetStore() {
assetGridState.update((state) => { assetGridState.update((state) => {
const bucketIndex = state.buckets.findIndex((b) => b.bucketDate === bucketDate); const bucketIndex = state.buckets.findIndex((b) => b.bucketDate === bucketDate);
state.buckets.splice(bucketIndex, 1); state.buckets.splice(bucketIndex, 1);
state.assets = flatMap(state.buckets, (b) => b.assets); state.assets = state.buckets.flatMap((b) => b.assets);
return state; return state;
}); });
}; };
@ -180,7 +179,7 @@ function createAssetStore() {
const assetIndex = state.buckets[bucketIndex].assets.findIndex((a) => a.id === assetId); const assetIndex = state.buckets[bucketIndex].assets.findIndex((a) => a.id === assetId);
state.buckets[bucketIndex].assets[assetIndex].isFavorite = isFavorite; state.buckets[bucketIndex].assets[assetIndex].isFavorite = isFavorite;
state.assets = flatMap(state.buckets, (b) => b.assets); state.assets = state.buckets.flatMap((b) => b.assets);
return state; return state;
}); });
}; };