mirror of
https://github.com/immich-app/immich.git
synced 2025-01-12 15:32:36 +02:00
feat(web): improve range selection (#3193)
* Improve range selection * Add comments * Add PR feedback * Remove focus outline from select asset button --------- Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
This commit is contained in:
parent
ea64fdd7b4
commit
93462aafbc
@ -84,9 +84,7 @@
|
|||||||
{#if !readonly && (mouseOver || selected || selectionCandidate)}
|
{#if !readonly && (mouseOver || selected || selectionCandidate)}
|
||||||
<button
|
<button
|
||||||
on:click={onIconClickedHandler}
|
on:click={onIconClickedHandler}
|
||||||
on:keydown|preventDefault
|
class="absolute p-2 focus:outline-none"
|
||||||
on:keyup|preventDefault
|
|
||||||
class="absolute p-2"
|
|
||||||
class:cursor-not-allowed={disabled}
|
class:cursor-not-allowed={disabled}
|
||||||
role="checkbox"
|
role="checkbox"
|
||||||
aria-checked={selected}
|
aria-checked={selected}
|
||||||
|
@ -9,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 { formatGroupTitle, splitBucketIntoDateGroups } from '$lib/utils/timeline-util';
|
||||||
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 { DateTime } from 'luxon';
|
||||||
import { createEventDispatcher } from 'svelte';
|
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 { DateTime, Interval } from 'luxon';
|
|
||||||
import { getAssetRatio } from '$lib/utils/asset-utils';
|
|
||||||
import Thumbnail from '../assets/thumbnail/thumbnail.svelte';
|
import Thumbnail from '../assets/thumbnail/thumbnail.svelte';
|
||||||
|
|
||||||
export let assets: AssetResponseDto[];
|
export let assets: AssetResponseDto[];
|
||||||
@ -26,13 +26,6 @@
|
|||||||
export let isAlbumSelectionMode = false;
|
export let isAlbumSelectionMode = false;
|
||||||
export let viewportWidth: number;
|
export let viewportWidth: number;
|
||||||
|
|
||||||
const groupDateFormat: Intl.DateTimeFormatOptions = {
|
|
||||||
weekday: 'short',
|
|
||||||
month: 'short',
|
|
||||||
day: 'numeric',
|
|
||||||
year: 'numeric',
|
|
||||||
};
|
|
||||||
|
|
||||||
const dispatch = createEventDispatcher();
|
const dispatch = createEventDispatcher();
|
||||||
|
|
||||||
let isMouseOverGroup = false;
|
let isMouseOverGroup = false;
|
||||||
@ -45,11 +38,7 @@
|
|||||||
width: number;
|
width: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
$: assetsGroupByDate = lodash
|
$: assetsGroupByDate = splitBucketIntoDateGroups(assets, $locale);
|
||||||
.chain(assets)
|
|
||||||
.groupBy((a) => new Date(a.fileCreatedAt).toLocaleDateString($locale, groupDateFormat))
|
|
||||||
.sortBy((group) => assets.indexOf(group[0]))
|
|
||||||
.value();
|
|
||||||
|
|
||||||
$: geometry = (() => {
|
$: geometry = (() => {
|
||||||
const geometry = [];
|
const geometry = [];
|
||||||
@ -131,17 +120,7 @@
|
|||||||
assetsInDateGroup: AssetResponseDto[],
|
assetsInDateGroup: AssetResponseDto[],
|
||||||
dateGroupTitle: string,
|
dateGroupTitle: string,
|
||||||
) => {
|
) => {
|
||||||
if ($selectedAssets.has(asset)) {
|
dispatch('selectAssets', { asset });
|
||||||
for (const candidate of $assetSelectionCandidates || []) {
|
|
||||||
assetInteractionStore.removeAssetFromMultiselectGroup(candidate);
|
|
||||||
}
|
|
||||||
assetInteractionStore.removeAssetFromMultiselectGroup(asset);
|
|
||||||
} else {
|
|
||||||
for (const candidate of $assetSelectionCandidates || []) {
|
|
||||||
assetInteractionStore.addAssetToMultiselectGroup(candidate);
|
|
||||||
}
|
|
||||||
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 = assetsInDateGroup.filter((asset) => $selectedAssets.has(asset)).length;
|
let selectedAssetsInGroupCount = assetsInDateGroup.filter((asset) => $selectedAssets.has(asset)).length;
|
||||||
@ -162,41 +141,6 @@
|
|||||||
dispatch('selectAssetCandidates', { asset });
|
dispatch('selectAssetCandidates', { asset });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatGroupTitle = (date: DateTime): string => {
|
|
||||||
const today = DateTime.now().startOf('day');
|
|
||||||
|
|
||||||
// Today
|
|
||||||
if (today.hasSame(date, 'day')) {
|
|
||||||
return 'Today';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Yesterday
|
|
||||||
if (Interval.fromDateTimes(date, today).length('days') == 1) {
|
|
||||||
return 'Yesterday';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Last week
|
|
||||||
if (Interval.fromDateTimes(date, today).length('weeks') < 1) {
|
|
||||||
return date.toLocaleString({ weekday: 'long' });
|
|
||||||
}
|
|
||||||
|
|
||||||
// This year
|
|
||||||
if (today.hasSame(date, 'year')) {
|
|
||||||
return date.toLocaleString({
|
|
||||||
weekday: 'short',
|
|
||||||
month: 'short',
|
|
||||||
day: 'numeric',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return date.toLocaleString({
|
|
||||||
weekday: 'short',
|
|
||||||
month: 'short',
|
|
||||||
day: 'numeric',
|
|
||||||
year: 'numeric',
|
|
||||||
});
|
|
||||||
};
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<section
|
<section
|
||||||
|
@ -2,14 +2,19 @@
|
|||||||
import { BucketPosition } from '$lib/models/asset-grid-state';
|
import { BucketPosition } from '$lib/models/asset-grid-state';
|
||||||
import {
|
import {
|
||||||
assetInteractionStore,
|
assetInteractionStore,
|
||||||
|
assetSelectionCandidates,
|
||||||
|
assetSelectionStart,
|
||||||
isMultiSelectStoreState,
|
isMultiSelectStoreState,
|
||||||
isViewingAssetStoreState,
|
isViewingAssetStoreState,
|
||||||
selectedAssets,
|
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 { locale } from '$lib/stores/preferences.store';
|
||||||
|
import { formatGroupTitle, splitBucketIntoDateGroups } from '$lib/utils/timeline-util';
|
||||||
import type { UserResponseDto } from '@api';
|
import type { UserResponseDto } from '@api';
|
||||||
import { api, AssetCountByTimeBucketResponseDto, AssetResponseDto, TimeGroupEnum } from '@api';
|
import { api, AssetCountByTimeBucketResponseDto, AssetResponseDto, TimeGroupEnum } from '@api';
|
||||||
|
import { DateTime } from 'luxon';
|
||||||
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';
|
||||||
@ -144,12 +149,6 @@
|
|||||||
selectAssetCandidates(lastAssetMouseEvent);
|
selectAssetCandidates(lastAssetMouseEvent);
|
||||||
}
|
}
|
||||||
|
|
||||||
const getLastSelectedAsset = () => {
|
|
||||||
let value;
|
|
||||||
for (value of $selectedAssets);
|
|
||||||
return value;
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSelectAssetCandidates = (e: CustomEvent) => {
|
const handleSelectAssetCandidates = (e: CustomEvent) => {
|
||||||
const asset = e.detail.asset;
|
const asset = e.detail.asset;
|
||||||
if (asset) {
|
if (asset) {
|
||||||
@ -158,18 +157,84 @@
|
|||||||
lastAssetMouseEvent = asset;
|
lastAssetMouseEvent = asset;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleSelectAssets = async (e: CustomEvent) => {
|
||||||
|
const asset = e.detail.asset;
|
||||||
|
if (!asset) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rangeSelection = $assetSelectionCandidates.size > 0;
|
||||||
|
const deselect = $selectedAssets.has(asset);
|
||||||
|
|
||||||
|
// Select/deselect already loaded assets
|
||||||
|
if (deselect) {
|
||||||
|
for (const candidate of $assetSelectionCandidates || []) {
|
||||||
|
assetInteractionStore.removeAssetFromMultiselectGroup(candidate);
|
||||||
|
}
|
||||||
|
assetInteractionStore.removeAssetFromMultiselectGroup(asset);
|
||||||
|
} else {
|
||||||
|
for (const candidate of $assetSelectionCandidates || []) {
|
||||||
|
assetInteractionStore.addAssetToMultiselectGroup(candidate);
|
||||||
|
}
|
||||||
|
assetInteractionStore.addAssetToMultiselectGroup(asset);
|
||||||
|
}
|
||||||
|
|
||||||
|
assetInteractionStore.clearAssetSelectionCandidates();
|
||||||
|
|
||||||
|
if ($assetSelectionStart && rangeSelection) {
|
||||||
|
let startBucketIndex = $assetGridState.loadedAssets[$assetSelectionStart.id];
|
||||||
|
let endBucketIndex = $assetGridState.loadedAssets[asset.id];
|
||||||
|
|
||||||
|
if (endBucketIndex < startBucketIndex) {
|
||||||
|
[startBucketIndex, endBucketIndex] = [endBucketIndex, startBucketIndex];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Select/deselect assets in all intermediate buckets
|
||||||
|
for (let bucketIndex = startBucketIndex + 1; bucketIndex < endBucketIndex; bucketIndex++) {
|
||||||
|
const bucket = $assetGridState.buckets[bucketIndex];
|
||||||
|
await assetStore.getAssetsByBucket(bucket.bucketDate, BucketPosition.Unknown);
|
||||||
|
for (const asset of bucket.assets) {
|
||||||
|
if (deselect) {
|
||||||
|
assetInteractionStore.removeAssetFromMultiselectGroup(asset);
|
||||||
|
} else {
|
||||||
|
assetInteractionStore.addAssetToMultiselectGroup(asset);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update date group selection
|
||||||
|
for (let bucketIndex = startBucketIndex; bucketIndex <= endBucketIndex; bucketIndex++) {
|
||||||
|
const bucket = $assetGridState.buckets[bucketIndex];
|
||||||
|
|
||||||
|
// Split bucket into date groups and check each group
|
||||||
|
const assetsGroupByDate = splitBucketIntoDateGroups(bucket.assets, $locale);
|
||||||
|
|
||||||
|
for (const dateGroup of assetsGroupByDate) {
|
||||||
|
const dateGroupTitle = formatGroupTitle(DateTime.fromISO(dateGroup[0].fileCreatedAt).startOf('day'));
|
||||||
|
if (dateGroup.every((a) => $selectedAssets.has(a))) {
|
||||||
|
assetInteractionStore.addGroupToMultiselectGroup(dateGroupTitle);
|
||||||
|
} else {
|
||||||
|
assetInteractionStore.removeGroupFromMultiselectGroup(dateGroupTitle);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assetInteractionStore.setAssetSelectionStart(deselect ? null : asset);
|
||||||
|
};
|
||||||
|
|
||||||
const selectAssetCandidates = (asset: AssetResponseDto) => {
|
const selectAssetCandidates = (asset: AssetResponseDto) => {
|
||||||
if (!shiftKeyIsDown) {
|
if (!shiftKeyIsDown) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const lastSelectedAsset = getLastSelectedAsset();
|
const rangeStart = $assetSelectionStart;
|
||||||
if (!lastSelectedAsset) {
|
if (!rangeStart) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let start = $assetGridState.assets.indexOf(asset);
|
let start = $assetGridState.assets.indexOf(rangeStart);
|
||||||
let end = $assetGridState.assets.indexOf(lastSelectedAsset);
|
let end = $assetGridState.assets.indexOf(asset);
|
||||||
|
|
||||||
if (start > end) {
|
if (start > end) {
|
||||||
[start, end] = [end, start];
|
[start, end] = [end, start];
|
||||||
@ -230,6 +295,7 @@
|
|||||||
{isAlbumSelectionMode}
|
{isAlbumSelectionMode}
|
||||||
on:shift={handleScrollTimeline}
|
on:shift={handleScrollTimeline}
|
||||||
on:selectAssetCandidates={handleSelectAssetCandidates}
|
on:selectAssetCandidates={handleSelectAssetCandidates}
|
||||||
|
on:selectAssets={handleSelectAssets}
|
||||||
assets={bucket.assets}
|
assets={bucket.assets}
|
||||||
bucketDate={bucket.bucketDate}
|
bucketDate={bucket.bucketDate}
|
||||||
bucketHeight={bucket.bucketHeight}
|
bucketHeight={bucket.bucketHeight}
|
||||||
|
@ -7,12 +7,25 @@ import { assetGridState, assetStore } from './assets.store';
|
|||||||
export const viewingAssetStoreState = writable<AssetResponseDto>();
|
export const viewingAssetStoreState = writable<AssetResponseDto>();
|
||||||
export const isViewingAssetStoreState = writable<boolean>(false);
|
export const isViewingAssetStoreState = writable<boolean>(false);
|
||||||
|
|
||||||
// Multi-Selection mode
|
/**
|
||||||
|
* Multi-selection mode
|
||||||
|
*/
|
||||||
export const assetsInAlbumStoreState = writable<AssetResponseDto[]>([]);
|
export const assetsInAlbumStoreState = writable<AssetResponseDto[]>([]);
|
||||||
|
// Selected assets
|
||||||
export const selectedAssets = writable<Set<AssetResponseDto>>(new Set());
|
export const selectedAssets = writable<Set<AssetResponseDto>>(new Set());
|
||||||
|
// Selected date groups
|
||||||
export const selectedGroup = writable<Set<string>>(new Set());
|
export const selectedGroup = writable<Set<string>>(new Set());
|
||||||
|
// If any asset selected
|
||||||
export const isMultiSelectStoreState = derived(selectedAssets, ($selectedAssets) => $selectedAssets.size > 0);
|
export const isMultiSelectStoreState = derived(selectedAssets, ($selectedAssets) => $selectedAssets.size > 0);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Range selection
|
||||||
|
*/
|
||||||
|
// Candidates for the range selection. This set includes only loaded assets, so it improves highlight
|
||||||
|
// performance. From the user's perspective, range is highlighted almost immediately
|
||||||
export const assetSelectionCandidates = writable<Set<AssetResponseDto>>(new Set());
|
export const assetSelectionCandidates = writable<Set<AssetResponseDto>>(new Set());
|
||||||
|
// The beginning of the selection range
|
||||||
|
export const assetSelectionStart = writable<AssetResponseDto | null>(null);
|
||||||
|
|
||||||
function createAssetInteractionStore() {
|
function createAssetInteractionStore() {
|
||||||
let _assetGridState = new AssetGridState();
|
let _assetGridState = new AssetGridState();
|
||||||
@ -21,6 +34,7 @@ function createAssetInteractionStore() {
|
|||||||
let _selectedGroup: Set<string>;
|
let _selectedGroup: Set<string>;
|
||||||
let _assetsInAlbums: AssetResponseDto[];
|
let _assetsInAlbums: AssetResponseDto[];
|
||||||
let _assetSelectionCandidates: Set<AssetResponseDto>;
|
let _assetSelectionCandidates: Set<AssetResponseDto>;
|
||||||
|
let _assetSelectionStart: AssetResponseDto | null;
|
||||||
|
|
||||||
// Subscriber
|
// Subscriber
|
||||||
assetGridState.subscribe((state) => {
|
assetGridState.subscribe((state) => {
|
||||||
@ -47,6 +61,9 @@ function createAssetInteractionStore() {
|
|||||||
_assetSelectionCandidates = assets;
|
_assetSelectionCandidates = assets;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
assetSelectionStart.subscribe((asset) => {
|
||||||
|
_assetSelectionStart = asset;
|
||||||
|
});
|
||||||
// Methods
|
// Methods
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -155,6 +172,11 @@ function createAssetInteractionStore() {
|
|||||||
selectedGroup.set(_selectedGroup);
|
selectedGroup.set(_selectedGroup);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const setAssetSelectionStart = (asset: AssetResponseDto | null) => {
|
||||||
|
_assetSelectionStart = asset;
|
||||||
|
assetSelectionStart.set(_assetSelectionStart);
|
||||||
|
};
|
||||||
|
|
||||||
const setAssetSelectionCandidates = (assets: AssetResponseDto[]) => {
|
const setAssetSelectionCandidates = (assets: AssetResponseDto[]) => {
|
||||||
_assetSelectionCandidates = new Set(assets);
|
_assetSelectionCandidates = new Set(assets);
|
||||||
assetSelectionCandidates.set(_assetSelectionCandidates);
|
assetSelectionCandidates.set(_assetSelectionCandidates);
|
||||||
@ -166,15 +188,20 @@ function createAssetInteractionStore() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const clearMultiselect = () => {
|
const clearMultiselect = () => {
|
||||||
|
// Multi-selection
|
||||||
_selectedAssets.clear();
|
_selectedAssets.clear();
|
||||||
_selectedGroup.clear();
|
_selectedGroup.clear();
|
||||||
_assetSelectionCandidates.clear();
|
|
||||||
_assetsInAlbums = [];
|
_assetsInAlbums = [];
|
||||||
|
|
||||||
|
// Range selection
|
||||||
|
_assetSelectionCandidates.clear();
|
||||||
|
_assetSelectionStart = null;
|
||||||
|
|
||||||
selectedAssets.set(_selectedAssets);
|
selectedAssets.set(_selectedAssets);
|
||||||
selectedGroup.set(_selectedGroup);
|
selectedGroup.set(_selectedGroup);
|
||||||
assetsInAlbumStoreState.set(_assetsInAlbums);
|
assetsInAlbumStoreState.set(_assetsInAlbums);
|
||||||
assetSelectionCandidates.set(_assetSelectionCandidates);
|
assetSelectionCandidates.set(_assetSelectionCandidates);
|
||||||
|
assetSelectionStart.set(_assetSelectionStart);
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@ -188,6 +215,7 @@ function createAssetInteractionStore() {
|
|||||||
removeGroupFromMultiselectGroup,
|
removeGroupFromMultiselectGroup,
|
||||||
setAssetSelectionCandidates,
|
setAssetSelectionCandidates,
|
||||||
clearAssetSelectionCandidates,
|
clearAssetSelectionCandidates,
|
||||||
|
setAssetSelectionStart,
|
||||||
clearMultiselect,
|
clearMultiselect,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
51
web/src/lib/utils/timeline-util.ts
Normal file
51
web/src/lib/utils/timeline-util.ts
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
import type { AssetResponseDto } from '@api';
|
||||||
|
import lodash from 'lodash-es';
|
||||||
|
import { DateTime, Interval } from 'luxon';
|
||||||
|
|
||||||
|
export const groupDateFormat: Intl.DateTimeFormatOptions = {
|
||||||
|
weekday: 'short',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
year: 'numeric',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function formatGroupTitle(date: DateTime): string {
|
||||||
|
const today = DateTime.now().startOf('day');
|
||||||
|
|
||||||
|
// Today
|
||||||
|
if (today.hasSame(date, 'day')) {
|
||||||
|
return 'Today';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Yesterday
|
||||||
|
if (Interval.fromDateTimes(date, today).length('days') == 1) {
|
||||||
|
return 'Yesterday';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Last week
|
||||||
|
if (Interval.fromDateTimes(date, today).length('weeks') < 1) {
|
||||||
|
return date.toLocaleString({ weekday: 'long' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// This year
|
||||||
|
if (today.hasSame(date, 'year')) {
|
||||||
|
return date.toLocaleString({
|
||||||
|
weekday: 'short',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return date.toLocaleString(groupDateFormat);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function splitBucketIntoDateGroups(
|
||||||
|
assets: AssetResponseDto[],
|
||||||
|
locale: string | undefined,
|
||||||
|
): AssetResponseDto[][] {
|
||||||
|
return lodash
|
||||||
|
.chain(assets)
|
||||||
|
.groupBy((a) => new Date(a.fileCreatedAt).toLocaleDateString(locale, groupDateFormat))
|
||||||
|
.sortBy((group) => assets.indexOf(group[0]))
|
||||||
|
.value();
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user