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:
Sergey Kondrikov 2023-07-12 05:12:19 +03:00 committed by GitHub
parent ea64fdd7b4
commit 93462aafbc
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
5 changed files with 163 additions and 76 deletions

View file

@ -84,9 +84,7 @@
{#if !readonly && (mouseOver || selected || selectionCandidate)}
<button
on:click={onIconClickedHandler}
on:keydown|preventDefault
on:keyup|preventDefault
class="absolute p-2"
class="absolute p-2 focus:outline-none"
class:cursor-not-allowed={disabled}
role="checkbox"
aria-checked={selected}

View file

@ -9,15 +9,15 @@
} from '$lib/stores/asset-interaction.store';
import { assetStore } from '$lib/stores/assets.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 justifiedLayout from 'justified-layout';
import lodash from 'lodash-es';
import { DateTime } from 'luxon';
import { createEventDispatcher } from 'svelte';
import CheckCircle from 'svelte-material-icons/CheckCircle.svelte';
import CircleOutline from 'svelte-material-icons/CircleOutline.svelte';
import { fly } from 'svelte/transition';
import { DateTime, Interval } from 'luxon';
import { getAssetRatio } from '$lib/utils/asset-utils';
import Thumbnail from '../assets/thumbnail/thumbnail.svelte';
export let assets: AssetResponseDto[];
@ -26,13 +26,6 @@
export let isAlbumSelectionMode = false;
export let viewportWidth: number;
const groupDateFormat: Intl.DateTimeFormatOptions = {
weekday: 'short',
month: 'short',
day: 'numeric',
year: 'numeric',
};
const dispatch = createEventDispatcher();
let isMouseOverGroup = false;
@ -45,11 +38,7 @@
width: number;
}
$: assetsGroupByDate = lodash
.chain(assets)
.groupBy((a) => new Date(a.fileCreatedAt).toLocaleDateString($locale, groupDateFormat))
.sortBy((group) => assets.indexOf(group[0]))
.value();
$: assetsGroupByDate = splitBucketIntoDateGroups(assets, $locale);
$: geometry = (() => {
const geometry = [];
@ -131,17 +120,7 @@
assetsInDateGroup: AssetResponseDto[],
dateGroupTitle: string,
) => {
if ($selectedAssets.has(asset)) {
for (const candidate of $assetSelectionCandidates || []) {
assetInteractionStore.removeAssetFromMultiselectGroup(candidate);
}
assetInteractionStore.removeAssetFromMultiselectGroup(asset);
} else {
for (const candidate of $assetSelectionCandidates || []) {
assetInteractionStore.addAssetToMultiselectGroup(candidate);
}
assetInteractionStore.addAssetToMultiselectGroup(asset);
}
dispatch('selectAssets', { asset });
// 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;
@ -162,41 +141,6 @@
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>
<section

View file

@ -2,14 +2,19 @@
import { BucketPosition } from '$lib/models/asset-grid-state';
import {
assetInteractionStore,
assetSelectionCandidates,
assetSelectionStart,
isMultiSelectStoreState,
isViewingAssetStoreState,
selectedAssets,
viewingAssetStoreState,
} from '$lib/stores/asset-interaction.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 { api, AssetCountByTimeBucketResponseDto, AssetResponseDto, TimeGroupEnum } from '@api';
import { DateTime } from 'luxon';
import { onDestroy, onMount } from 'svelte';
import AssetViewer from '../asset-viewer/asset-viewer.svelte';
import IntersectionObserver from '../asset-viewer/intersection-observer.svelte';
@ -144,12 +149,6 @@
selectAssetCandidates(lastAssetMouseEvent);
}
const getLastSelectedAsset = () => {
let value;
for (value of $selectedAssets);
return value;
};
const handleSelectAssetCandidates = (e: CustomEvent) => {
const asset = e.detail.asset;
if (asset) {
@ -158,18 +157,84 @@
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) => {
if (!shiftKeyIsDown) {
return;
}
const lastSelectedAsset = getLastSelectedAsset();
if (!lastSelectedAsset) {
const rangeStart = $assetSelectionStart;
if (!rangeStart) {
return;
}
let start = $assetGridState.assets.indexOf(asset);
let end = $assetGridState.assets.indexOf(lastSelectedAsset);
let start = $assetGridState.assets.indexOf(rangeStart);
let end = $assetGridState.assets.indexOf(asset);
if (start > end) {
[start, end] = [end, start];
@ -230,6 +295,7 @@
{isAlbumSelectionMode}
on:shift={handleScrollTimeline}
on:selectAssetCandidates={handleSelectAssetCandidates}
on:selectAssets={handleSelectAssets}
assets={bucket.assets}
bucketDate={bucket.bucketDate}
bucketHeight={bucket.bucketHeight}

View file

@ -7,12 +7,25 @@ import { assetGridState, assetStore } from './assets.store';
export const viewingAssetStoreState = writable<AssetResponseDto>();
export const isViewingAssetStoreState = writable<boolean>(false);
// Multi-Selection mode
/**
* Multi-selection mode
*/
export const assetsInAlbumStoreState = writable<AssetResponseDto[]>([]);
// Selected assets
export const selectedAssets = writable<Set<AssetResponseDto>>(new Set());
// Selected date groups
export const selectedGroup = writable<Set<string>>(new Set());
// If any asset selected
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());
// The beginning of the selection range
export const assetSelectionStart = writable<AssetResponseDto | null>(null);
function createAssetInteractionStore() {
let _assetGridState = new AssetGridState();
@ -21,6 +34,7 @@ function createAssetInteractionStore() {
let _selectedGroup: Set<string>;
let _assetsInAlbums: AssetResponseDto[];
let _assetSelectionCandidates: Set<AssetResponseDto>;
let _assetSelectionStart: AssetResponseDto | null;
// Subscriber
assetGridState.subscribe((state) => {
@ -47,6 +61,9 @@ function createAssetInteractionStore() {
_assetSelectionCandidates = assets;
});
assetSelectionStart.subscribe((asset) => {
_assetSelectionStart = asset;
});
// Methods
/**
@ -155,6 +172,11 @@ function createAssetInteractionStore() {
selectedGroup.set(_selectedGroup);
};
const setAssetSelectionStart = (asset: AssetResponseDto | null) => {
_assetSelectionStart = asset;
assetSelectionStart.set(_assetSelectionStart);
};
const setAssetSelectionCandidates = (assets: AssetResponseDto[]) => {
_assetSelectionCandidates = new Set(assets);
assetSelectionCandidates.set(_assetSelectionCandidates);
@ -166,15 +188,20 @@ function createAssetInteractionStore() {
};
const clearMultiselect = () => {
// Multi-selection
_selectedAssets.clear();
_selectedGroup.clear();
_assetSelectionCandidates.clear();
_assetsInAlbums = [];
// Range selection
_assetSelectionCandidates.clear();
_assetSelectionStart = null;
selectedAssets.set(_selectedAssets);
selectedGroup.set(_selectedGroup);
assetsInAlbumStoreState.set(_assetsInAlbums);
assetSelectionCandidates.set(_assetSelectionCandidates);
assetSelectionStart.set(_assetSelectionStart);
};
return {
@ -188,6 +215,7 @@ function createAssetInteractionStore() {
removeGroupFromMultiselectGroup,
setAssetSelectionCandidates,
clearAssetSelectionCandidates,
setAssetSelectionStart,
clearMultiselect,
};
}

View 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();
}