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
5 changed files with 163 additions and 76 deletions
|
@ -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}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
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 a new issue