feat(web): Scroll to asset in gridview; increase gridview perf; reduce memory; scrollbar ticks in fixed position (#10646)

* Squashed

* Change strategy - now pre-measure buckets offscreen, so don't need to worry about sub-bucket scroll preservation

* Reduce jank on scroll, delay DOM updates until after scroll

* css opt, log measure time

* Trickle out queue while scrolling, flush when stopped

* yay

* Cleanup cleanup...

* everybody...

* everywhere...

* Clean up cleanup!

* Everybody do their share

* CLEANUP!

* package-lock ?

* dynamic measure, todo

* Fix web test

* type lint

* fix e2e

* e2e test

* Better scrollbar

* Tuning, and more tunables

* Tunable tweaks, more tunables

* Scrollbar dots and viewport events

* lint

* Tweaked tunnables, use requestIdleCallback for garbage tasks, bug fixes

* New tunables, and don't update url by default

* Bug fixes

* Bug fix, with debug

* Fix flickr, fix graybox bug, reduced debug

* Refactor/cleanup

* Fix

* naming

* Final cleanup

* review comment

* Forgot to update this after naming change

* scrubber works, with debug

* cleanup

* Rename scrollbar to scrubber

* rename  to

* left over rename and change to previous album bar

* bugfix addassets, comments

* missing destroy(), cleanup

---------

Co-authored-by: Alex <alex.tran1502@gmail.com>
This commit is contained in:
Min Idzelis 2024-08-21 22:15:21 -04:00 committed by GitHub
parent 07538299cf
commit 837b1e4929
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
50 changed files with 2947 additions and 843 deletions

View file

@ -1,84 +1,69 @@
<script lang="ts">
import { intersectionObserver } from '$lib/actions/intersection-observer';
import Icon from '$lib/components/elements/icon.svelte';
import Skeleton from '$lib/components/photos-page/skeleton.svelte';
import type { AssetInteractionStore } from '$lib/stores/asset-interaction.store';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import type { AssetStore, Viewport } from '$lib/stores/assets.store';
import { locale } from '$lib/stores/preferences.store';
import { getAssetRatio } from '$lib/utils/asset-utils';
import {
calculateWidth,
formatGroupTitle,
fromLocalDateTime,
splitBucketIntoDateGroups,
} from '$lib/utils/timeline-util';
import { AssetBucket, type AssetStore, type Viewport } from '$lib/stores/assets.store';
import { navigate } from '$lib/utils/navigation';
import { findTotalOffset, type DateGroup, type ScrollTargetListener } from '$lib/utils/timeline-util';
import type { AssetResponseDto } from '@immich/sdk';
import { mdiCheckCircle, mdiCircleOutline } from '@mdi/js';
import justifiedLayout from 'justified-layout';
import { createEventDispatcher } from 'svelte';
import { createEventDispatcher, onDestroy } from 'svelte';
import { fly } from 'svelte/transition';
import Thumbnail from '../assets/thumbnail/thumbnail.svelte';
import { TUNABLES } from '$lib/utils/tunables';
import { generateId } from '$lib/utils/generate-id';
export let assets: AssetResponseDto[];
export let bucketDate: string;
export let bucketHeight: number;
export let element: HTMLElement | undefined = undefined;
export let isSelectionMode = false;
export let viewport: Viewport;
export let singleSelect = false;
export let withStacked = false;
export let showArchiveIcon = false;
export let assetGridElement: HTMLElement | undefined = undefined;
export let renderThumbsAtBottomMargin: string | undefined = undefined;
export let renderThumbsAtTopMargin: string | undefined = undefined;
export let assetStore: AssetStore;
export let bucket: AssetBucket;
export let assetInteractionStore: AssetInteractionStore;
export let onScrollTarget: ScrollTargetListener | undefined = undefined;
export let onAssetInGrid: ((asset: AssetResponseDto) => void) | undefined = undefined;
const componentId = generateId();
$: bucketDate = bucket.bucketDate;
$: dateGroups = bucket.dateGroups;
const {
DATEGROUP: { INTERSECTION_DISABLED, INTERSECTION_ROOT_TOP, INTERSECTION_ROOT_BOTTOM },
} = TUNABLES;
/* TODO figure out a way to calculate this*/
const TITLE_HEIGHT = 51;
const { selectedGroup, selectedAssets, assetSelectionCandidates, isMultiSelectState } = assetInteractionStore;
const dispatch = createEventDispatcher<{
select: { title: string; assets: AssetResponseDto[] };
selectAssets: AssetResponseDto;
selectAssetCandidates: AssetResponseDto | null;
shift: { heightDelta: number };
}>();
let isMouseOverGroup = false;
let actualBucketHeight: number;
let hoveredDateGroup = '';
$: assetsGroupByDate = splitBucketIntoDateGroups(assets, $locale);
$: geometry = (() => {
const geometry = [];
for (let group of assetsGroupByDate) {
const justifiedLayoutResult = justifiedLayout(
group.map((assetGroup) => getAssetRatio(assetGroup)),
{
boxSpacing: 2,
containerWidth: Math.floor(viewport.width),
containerPadding: 0,
targetRowHeightTolerance: 0.15,
targetRowHeight: 235,
},
);
geometry.push({
...justifiedLayoutResult,
containerWidth: calculateWidth(justifiedLayoutResult.boxes),
});
const onClick = (assets: AssetResponseDto[], groupTitle: string, asset: AssetResponseDto) => {
if (isSelectionMode || $isMultiSelectState) {
assetSelectHandler(asset, assets, groupTitle);
return;
}
return geometry;
})();
void navigate({ targetRoute: 'current', assetId: asset.id });
};
$: {
if (actualBucketHeight && actualBucketHeight !== 0 && actualBucketHeight != bucketHeight) {
const heightDelta = assetStore.updateBucket(bucketDate, actualBucketHeight);
if (heightDelta !== 0) {
scrollTimeline(heightDelta);
}
const onRetrieveElement = (dateGroup: DateGroup, asset: AssetResponseDto, element: HTMLElement) => {
if (assetGridElement && onScrollTarget) {
const offset = findTotalOffset(element, assetGridElement) - TITLE_HEIGHT;
onScrollTarget({ bucket, dateGroup, asset, offset });
}
}
function scrollTimeline(heightDelta: number) {
dispatch('shift', {
heightDelta,
});
}
};
const handleSelectGroup = (title: string, assets: AssetResponseDto[]) => dispatch('select', { title, assets });
@ -104,93 +89,149 @@
dispatch('selectAssetCandidates', asset);
}
};
onDestroy(() => {
$assetStore.taskManager.removeAllTasksForComponent(componentId);
});
</script>
<section id="asset-group-by-date" class="flex flex-wrap gap-x-12" bind:clientHeight={actualBucketHeight}>
{#each assetsGroupByDate as groupAssets, groupIndex (groupAssets[0].id)}
{@const asset = groupAssets[0]}
{@const groupTitle = formatGroupTitle(fromLocalDateTime(asset.localDateTime).startOf('day'))}
<!-- Asset Group By Date -->
<section id="asset-group-by-date" class="flex flex-wrap gap-x-12" data-bucket-date={bucketDate} bind:this={element}>
{#each dateGroups as dateGroup, groupIndex (dateGroup.date)}
{@const display =
dateGroup.intersecting || !!dateGroup.assets.some((asset) => asset.id === $assetStore.pendingScrollAssetId)}
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div
class="flex flex-col"
on:mouseenter={() => {
isMouseOverGroup = true;
assetMouseEventHandler(groupTitle, null);
}}
on:mouseleave={() => {
isMouseOverGroup = false;
assetMouseEventHandler(groupTitle, null);
id="date-group"
use:intersectionObserver={{
onIntersect: () => {
$assetStore.taskManager.intersectedDateGroup(componentId, dateGroup, () =>
assetStore.updateBucketDateGroup(bucket, dateGroup, { intersecting: true }),
);
},
onSeparate: () => {
$assetStore.taskManager.seperatedDateGroup(componentId, dateGroup, () =>
assetStore.updateBucketDateGroup(bucket, dateGroup, { intersecting: false }),
);
},
top: INTERSECTION_ROOT_TOP,
bottom: INTERSECTION_ROOT_BOTTOM,
root: assetGridElement,
disabled: INTERSECTION_DISABLED,
}}
data-display={display}
data-date-group={dateGroup.date}
style:height={dateGroup.height + 'px'}
style:width={dateGroup.geometry.containerWidth + 'px'}
style:overflow={'clip'}
>
<!-- Date group title -->
<div
class="flex z-[100] sticky top-0 pt-7 pb-5 h-6 place-items-center text-xs font-medium text-immich-fg bg-immich-bg dark:bg-immich-dark-bg dark:text-immich-dark-fg md:text-sm"
style="width: {geometry[groupIndex].containerWidth}px"
>
{#if !singleSelect && ((hoveredDateGroup == groupTitle && isMouseOverGroup) || $selectedGroup.has(groupTitle))}
{#if !display}
<Skeleton height={dateGroup.height + 'px'} title={dateGroup.groupTitle} />
{/if}
{#if display}
<!-- Asset Group By Date -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div
on:mouseenter={() =>
$assetStore.taskManager.queueScrollSensitiveTask({
componentId,
task: () => {
isMouseOverGroup = true;
assetMouseEventHandler(dateGroup.groupTitle, null);
},
})}
on:mouseleave={() => {
$assetStore.taskManager.queueScrollSensitiveTask({
componentId,
task: () => {
isMouseOverGroup = false;
assetMouseEventHandler(dateGroup.groupTitle, null);
},
});
}}
>
<!-- Date group title -->
<div
transition:fly={{ x: -24, duration: 200, opacity: 0.5 }}
class="inline-block px-2 hover:cursor-pointer"
on:click={() => handleSelectGroup(groupTitle, groupAssets)}
on:keydown={() => handleSelectGroup(groupTitle, groupAssets)}
class="flex z-[100] sticky top-[-1px] pt-[calc(1.75rem+1px)] pb-5 h-6 place-items-center text-xs font-medium text-immich-fg bg-immich-bg dark:bg-immich-dark-bg dark:text-immich-dark-fg md:text-sm"
style:width={dateGroup.geometry.containerWidth + 'px'}
>
{#if $selectedGroup.has(groupTitle)}
<Icon path={mdiCheckCircle} size="24" color="#4250af" />
{:else}
<Icon path={mdiCircleOutline} size="24" color="#757575" />
{#if !singleSelect && ((hoveredDateGroup == dateGroup.groupTitle && isMouseOverGroup) || $selectedGroup.has(dateGroup.groupTitle))}
<div
transition:fly={{ x: -24, duration: 200, opacity: 0.5 }}
class="inline-block px-2 hover:cursor-pointer"
on:click={() => handleSelectGroup(dateGroup.groupTitle, dateGroup.assets)}
on:keydown={() => handleSelectGroup(dateGroup.groupTitle, dateGroup.assets)}
>
{#if $selectedGroup.has(dateGroup.groupTitle)}
<Icon path={mdiCheckCircle} size="24" color="#4250af" />
{:else}
<Icon path={mdiCircleOutline} size="24" color="#757575" />
{/if}
</div>
{/if}
<span class="w-full truncate first-letter:capitalize" title={dateGroup.groupTitle}>
{dateGroup.groupTitle}
</span>
</div>
{/if}
<span class="w-full truncate first-letter:capitalize" title={groupTitle}>
{groupTitle}
</span>
</div>
<!-- Image grid -->
<div
class="relative"
style="height: {geometry[groupIndex].containerHeight}px;width: {geometry[groupIndex].containerWidth}px"
>
{#each groupAssets as asset, index (asset.id)}
{@const box = geometry[groupIndex].boxes[index]}
<!-- Image grid -->
<div
class="absolute"
style="width: {box.width}px; height: {box.height}px; top: {box.top}px; left: {box.left}px"
class="relative overflow-clip"
style:height={dateGroup.geometry.containerHeight + 'px'}
style:width={dateGroup.geometry.containerWidth + 'px'}
>
<Thumbnail
showStackedIcon={withStacked}
{showArchiveIcon}
{asset}
{groupIndex}
onClick={(asset, event) => {
if (isSelectionMode || $isMultiSelectState) {
event.preventDefault();
assetSelectHandler(asset, groupAssets, groupTitle);
return;
}
assetViewingStore.setAsset(asset);
}}
on:select={() => assetSelectHandler(asset, groupAssets, groupTitle)}
on:mouse-event={() => assetMouseEventHandler(groupTitle, asset)}
selected={$selectedAssets.has(asset) || $assetStore.albumAssets.has(asset.id)}
selectionCandidate={$assetSelectionCandidates.has(asset)}
disabled={$assetStore.albumAssets.has(asset.id)}
thumbnailWidth={box.width}
thumbnailHeight={box.height}
/>
{#each dateGroup.assets as asset, index (asset.id)}
{@const box = dateGroup.geometry.boxes[index]}
<!-- update ASSET_GRID_PADDING-->
<div
use:intersectionObserver={{
onIntersect: () => onAssetInGrid?.(asset),
top: `-${TITLE_HEIGHT}px`,
bottom: `-${viewport.height - TITLE_HEIGHT - 1}px`,
right: `-${viewport.width - 1}px`,
root: assetGridElement,
}}
data-asset-id={asset.id}
class="absolute"
style:width={box.width + 'px'}
style:height={box.height + 'px'}
style:top={box.top + 'px'}
style:left={box.left + 'px'}
>
<Thumbnail
{dateGroup}
{assetStore}
intersectionConfig={{
root: assetGridElement,
bottom: renderThumbsAtBottomMargin,
top: renderThumbsAtTopMargin,
}}
retrieveElement={$assetStore.pendingScrollAssetId === asset.id}
onRetrieveElement={(element) => onRetrieveElement(dateGroup, asset, element)}
showStackedIcon={withStacked}
{showArchiveIcon}
{asset}
{groupIndex}
onClick={(asset) => onClick(dateGroup.assets, dateGroup.groupTitle, asset)}
onSelect={(asset) => assetSelectHandler(asset, dateGroup.assets, dateGroup.groupTitle)}
onMouseEvent={() => assetMouseEventHandler(dateGroup.groupTitle, asset)}
selected={$selectedAssets.has(asset) || $assetStore.albumAssets.has(asset.id)}
selectionCandidate={$assetSelectionCandidates.has(asset)}
disabled={$assetStore.albumAssets.has(asset.id)}
thumbnailWidth={box.width}
thumbnailHeight={box.height}
/>
</div>
{/each}
</div>
{/each}
</div>
</div>
{/if}
</div>
{/each}
</section>
<style>
#asset-group-by-date {
contain: layout;
contain: layout paint style;
}
</style>