Skip to content

Commit

Permalink
feat(web): improve range selection (#3193)
Browse files Browse the repository at this point in the history
* Improve range selection

* Add comments

* Add PR feedback

* Remove focus outline from select asset button

---------

Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
  • Loading branch information
brighteyed and alextran1502 authored Jul 12, 2023
1 parent ea64fdd commit 93462aa
Show file tree
Hide file tree
Showing 5 changed files with 163 additions and 76 deletions.
4 changes: 1 addition & 3 deletions web/src/lib/components/assets/thumbnail/thumbnail.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down
66 changes: 5 additions & 61 deletions web/src/lib/components/photos-page/asset-date-group.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
Expand All @@ -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;
Expand All @@ -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 = [];
Expand Down Expand Up @@ -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;
Expand All @@ -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
Expand Down
86 changes: 76 additions & 10 deletions web/src/lib/components/photos-page/asset-grid.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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) {
Expand All @@ -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];
Expand Down Expand Up @@ -230,6 +295,7 @@
{isAlbumSelectionMode}
on:shift={handleScrollTimeline}
on:selectAssetCandidates={handleSelectAssetCandidates}
on:selectAssets={handleSelectAssets}
assets={bucket.assets}
bucketDate={bucket.bucketDate}
bucketHeight={bucket.bucketHeight}
Expand Down
32 changes: 30 additions & 2 deletions web/src/lib/stores/asset-interaction.store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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) => {
Expand All @@ -47,6 +61,9 @@ function createAssetInteractionStore() {
_assetSelectionCandidates = assets;
});

assetSelectionStart.subscribe((asset) => {
_assetSelectionStart = asset;
});
// Methods

/**
Expand Down Expand Up @@ -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);
Expand All @@ -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 {
Expand All @@ -188,6 +215,7 @@ function createAssetInteractionStore() {
removeGroupFromMultiselectGroup,
setAssetSelectionCandidates,
clearAssetSelectionCandidates,
setAssetSelectionStart,
clearMultiselect,
};
}
Expand Down
51 changes: 51 additions & 0 deletions web/src/lib/utils/timeline-util.ts
Original file line number Diff line number Diff line change
@@ -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();
}

0 comments on commit 93462aa

Please sign in to comment.