Feature - Implemented virtual scroll on web (#573)

This PR implemented a virtual scroll on the web, as seen in this article.

[Building the Google Photos Web UI](https://medium.com/google-design/google-photos-45b714dfbed1)
This commit is contained in:
Alex 2022-09-04 08:34:39 -05:00 committed by GitHub
parent bd92dde117
commit 552340add7
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
58 changed files with 2197 additions and 698 deletions

View file

@ -0,0 +1,157 @@
<script lang="ts">
import { assetStore } from '$lib/stores/assets.store';
import CheckCircle from 'svelte-material-icons/CheckCircle.svelte';
import CircleOutline from 'svelte-material-icons/CircleOutline.svelte';
import { fly } from 'svelte/transition';
import { AssetResponseDto } from '@api';
import lodash from 'lodash-es';
import moment from 'moment';
import ImmichThumbnail from '../shared-components/immich-thumbnail.svelte';
import { createEventDispatcher } from 'svelte';
import {
assetInteractionStore,
assetsInAlbumStoreState,
isMultiSelectStoreState,
selectedAssets,
selectedGroup
} from '$lib/stores/asset-interaction.store';
export let assets: AssetResponseDto[];
export let bucketDate: string;
export let bucketHeight: number;
const dispatch = createEventDispatcher();
let isMouseOverGroup = false;
let actualBucketHeight: number;
let hoveredDateGroup: string = '';
$: assetsGroupByDate = lodash
.chain(assets)
.groupBy((a) => moment(a.createdAt).format('ddd, MMM DD YYYY'))
.sortBy((group) => assets.indexOf(group[0]))
.value();
$: {
if (actualBucketHeight && actualBucketHeight != 0 && actualBucketHeight != bucketHeight) {
assetStore.updateBucketHeight(bucketDate, actualBucketHeight);
}
}
const assetClickHandler = (
asset: AssetResponseDto,
assetsInDateGroup: AssetResponseDto[],
dateGroupTitle: string
) => {
if ($isMultiSelectStoreState) {
assetSelectHandler(asset, assetsInDateGroup, dateGroupTitle);
} else {
assetInteractionStore.setViewingAsset(asset);
}
};
const selectAssetGroupHandler = (
selectAssetGroupHandler: AssetResponseDto[],
dateGroupTitle: string
) => {
if ($selectedGroup.has(dateGroupTitle)) {
assetInteractionStore.removeGroupFromMultiselectGroup(dateGroupTitle);
selectAssetGroupHandler.forEach((asset) => {
assetInteractionStore.removeAssetFromMultiselectGroup(asset);
});
} else {
assetInteractionStore.addGroupToMultiselectGroup(dateGroupTitle);
selectAssetGroupHandler.forEach((asset) => {
assetInteractionStore.addAssetToMultiselectGroup(asset);
});
}
};
const assetSelectHandler = (
asset: AssetResponseDto,
assetsInDateGroup: AssetResponseDto[],
dateGroupTitle: string
) => {
if ($selectedAssets.has(asset)) {
assetInteractionStore.removeAssetFromMultiselectGroup(asset);
} else {
assetInteractionStore.addAssetToMultiselectGroup(asset);
}
// Check if all assets are selected in a group to toggle the group selection's icon
let selectedAssetsInGroupCount = 0;
assetsInDateGroup.forEach((asset) => {
if ($selectedAssets.has(asset)) {
selectedAssetsInGroupCount++;
}
});
// if all assets are selected in a group, add the group to selected group
if (selectedAssetsInGroupCount == assetsInDateGroup.length) {
assetInteractionStore.addGroupToMultiselectGroup(dateGroupTitle);
} else {
assetInteractionStore.removeGroupFromMultiselectGroup(dateGroupTitle);
}
};
const assetMouseEventHandler = (dateGroupTitle: string) => {
// Show multi select icon on hover on date group
hoveredDateGroup = dateGroupTitle;
};
</script>
<section
id="asset-group-by-date"
class="flex flex-wrap gap-5 mt-5"
bind:clientHeight={actualBucketHeight}
>
{#each assetsGroupByDate as assetsInDateGroup, groupIndex (assetsInDateGroup[0].id)}
{@const dateGroupTitle = moment(assetsInDateGroup[0].createdAt).format('ddd, MMM DD YYYY')}
<!-- Asset Group By Date -->
<div
class="flex flex-col"
on:mouseenter={() => (isMouseOverGroup = true)}
on:mouseleave={() => (isMouseOverGroup = false)}
>
<!-- Date group title -->
<p class="font-medium text-sm text-immich-fg mb-2 flex place-items-center h-6">
{#if (hoveredDateGroup == dateGroupTitle && isMouseOverGroup) || $selectedGroup.has(dateGroupTitle)}
<div
transition:fly={{ x: -24, duration: 200, opacity: 0.5 }}
class="inline-block px-2 hover:cursor-pointer"
on:click={() => selectAssetGroupHandler(assetsInDateGroup, dateGroupTitle)}
>
{#if $selectedGroup.has(dateGroupTitle)}
<CheckCircle size="24" color="#4250af" />
{:else}
<CircleOutline size="24" color="#757575" />
{/if}
</div>
{/if}
<span>
{dateGroupTitle}
</span>
</p>
<!-- Image grid -->
<div class="flex flex-wrap gap-[2px]">
{#each assetsInDateGroup as asset (asset.id)}
<ImmichThumbnail
{asset}
{groupIndex}
on:click={() => assetClickHandler(asset, assetsInDateGroup, dateGroupTitle)}
on:select={() => assetSelectHandler(asset, assetsInDateGroup, dateGroupTitle)}
on:mouse-event={() => assetMouseEventHandler(dateGroupTitle)}
selected={$selectedAssets.has(asset)}
disabled={$assetsInAlbumStoreState.findIndex((a) => a.id == asset.id) != -1}
/>
{/each}
</div>
</div>
{/each}
</section>
<style>
#asset-group-by-date {
contain: layout;
}
</style>

View file

@ -0,0 +1,119 @@
<script lang="ts">
import { onMount } from 'svelte';
import IntersectionObserver from '../asset-viewer/intersection-observer.svelte';
import { assetGridState, assetStore, loadingBucketState } from '$lib/stores/assets.store';
import { api, TimeGroupEnum } from '@api';
import AssetDateGroup from './asset-date-group.svelte';
import Portal from '../shared-components/portal/portal.svelte';
import AssetViewer from '../asset-viewer/asset-viewer.svelte';
import {
assetInteractionStore,
isViewingAssetStoreState,
viewingAssetStoreState
} from '$lib/stores/asset-interaction.store';
let viewportHeight = 0;
let viewportWidth = 0;
let assetGridElement: HTMLElement;
onMount(async () => {
const { data: assetCountByTimebucket } = await api.assetApi.getAssetCountByTimeBucket({
timeGroup: TimeGroupEnum.Month
});
assetStore.setInitialState(viewportHeight, viewportWidth, assetCountByTimebucket);
// Get asset bucket if bucket height is smaller than viewport height
let bucketsToFetchInitially: string[] = [];
let initialBucketsHeight = 0;
$assetGridState.buckets.every((bucket) => {
if (initialBucketsHeight < viewportHeight) {
initialBucketsHeight += bucket.bucketHeight;
bucketsToFetchInitially.push(bucket.bucketDate);
return true;
} else {
return false;
}
});
bucketsToFetchInitially.forEach((bucketDate) => {
assetStore.getAssetsByBucket(bucketDate);
});
});
function intersectedHandler(event: CustomEvent) {
const el = event.detail as HTMLElement;
const target = el.firstChild as HTMLElement;
if (target) {
const bucketDate = target.id.split('_')[1];
assetStore.getAssetsByBucket(bucketDate);
}
}
const navigateToPreviousAsset = () => {
assetInteractionStore.navigateAsset('previous');
};
const navigateToNextAsset = () => {
assetInteractionStore.navigateAsset('next');
};
</script>
<section
id="asset-grid"
class="overflow-y-auto pl-4"
bind:clientHeight={viewportHeight}
bind:clientWidth={viewportWidth}
bind:this={assetGridElement}
>
{#if assetGridElement}
<section id="virtual-timeline" style:height={$assetGridState.timelineHeight + 'px'}>
{#each $assetGridState.buckets as bucket, bucketIndex (bucketIndex)}
<IntersectionObserver
on:intersected={intersectedHandler}
on:hidden={async () => {
// If bucket is hidden and in loading state, cancel the request
if ($loadingBucketState[bucket.bucketDate]) {
await assetStore.cancelBucketRequest(bucket.cancelToken, bucket.bucketDate);
}
}}
let:intersecting
top={750}
bottom={750}
root={assetGridElement}
>
<div id={'bucket_' + bucket.bucketDate} style:height={bucket.bucketHeight + 'px'}>
{#if intersecting}
<AssetDateGroup
assets={bucket.assets}
bucketDate={bucket.bucketDate}
bucketHeight={bucket.bucketHeight}
/>
{/if}
</div>
</IntersectionObserver>
{/each}
</section>
{/if}
</section>
<Portal target="body">
{#if $isViewingAssetStoreState}
<AssetViewer
asset={$viewingAssetStoreState}
on:navigate-previous={navigateToPreviousAsset}
on:navigate-next={navigateToNextAsset}
on:close={() => {
assetInteractionStore.setIsViewingAsset(false);
}}
/>
{/if}
</Portal>
<style>
#asset-grid {
contain: layout;
}
</style>