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,5 +1,5 @@
<script lang="ts">
import IntersectionObserver from '$lib/components/asset-viewer/intersection-observer.svelte';
import { intersectionObserver } from '$lib/actions/intersection-observer';
import Icon from '$lib/components/elements/icon.svelte';
import { ProjectionType } from '$lib/constants';
import { getAssetThumbnailUrl, isSharedLink } from '$lib/utils';
@ -18,18 +18,23 @@
mdiMotionPlayOutline,
mdiRotate360,
} from '@mdi/js';
import { createEventDispatcher } from 'svelte';
import { fade } from 'svelte/transition';
import ImageThumbnail from './image-thumbnail.svelte';
import VideoThumbnail from './video-thumbnail.svelte';
import { currentUrlReplaceAssetId } from '$lib/utils/navigation';
import { AssetStore } from '$lib/stores/assets.store';
const dispatch = createEventDispatcher<{
select: { asset: AssetResponseDto };
'mouse-event': { isMouseOver: boolean; selectedGroupIndex: number };
}>();
import type { DateGroup } from '$lib/utils/timeline-util';
import { generateId } from '$lib/utils/generate-id';
import { onDestroy } from 'svelte';
import { TUNABLES } from '$lib/utils/tunables';
import { thumbhash } from '$lib/actions/thumbhash';
export let asset: AssetResponseDto;
export let dateGroup: DateGroup | undefined = undefined;
export let assetStore: AssetStore | undefined = undefined;
export let groupIndex = 0;
export let thumbnailSize: number | undefined = undefined;
export let thumbnailWidth: number | undefined = undefined;
@ -40,72 +45,181 @@
export let readonly = false;
export let showArchiveIcon = false;
export let showStackedIcon = true;
export let onClick: ((asset: AssetResponseDto, event: Event) => void) | undefined = undefined;
export let intersectionConfig: {
root?: HTMLElement;
bottom?: string;
top?: string;
left?: string;
priority?: number;
disabled?: boolean;
} = {};
export let retrieveElement: boolean = false;
export let onIntersected: (() => void) | undefined = undefined;
export let onClick: ((asset: AssetResponseDto) => void) | undefined = undefined;
export let onRetrieveElement: ((elment: HTMLElement) => void) | undefined = undefined;
export let onSelect: ((asset: AssetResponseDto) => void) | undefined = undefined;
export let onMouseEvent: ((event: { isMouseOver: boolean; selectedGroupIndex: number }) => void) | undefined =
undefined;
let className = '';
export { className as class };
let {
IMAGE_THUMBNAIL: { THUMBHASH_FADE_DURATION },
} = TUNABLES;
const componentId = generateId();
let element: HTMLElement | undefined;
let mouseOver = false;
let intersecting = false;
let lastRetrievedElement: HTMLElement | undefined;
let loaded = false;
$: dispatch('mouse-event', { isMouseOver: mouseOver, selectedGroupIndex: groupIndex });
$: if (!retrieveElement) {
lastRetrievedElement = undefined;
}
$: if (retrieveElement && element && lastRetrievedElement !== element) {
lastRetrievedElement = element;
onRetrieveElement?.(element);
}
$: [width, height] = ((): [number, number] => {
if (thumbnailSize) {
return [thumbnailSize, thumbnailSize];
}
$: width = thumbnailSize || thumbnailWidth || 235;
$: height = thumbnailSize || thumbnailHeight || 235;
$: display = intersecting;
if (thumbnailWidth && thumbnailHeight) {
return [thumbnailWidth, thumbnailHeight];
}
return [235, 235];
})();
const onIconClickedHandler = (e: MouseEvent) => {
e.stopPropagation();
e.preventDefault();
const onIconClickedHandler = (e?: MouseEvent) => {
e?.stopPropagation();
e?.preventDefault();
if (!disabled) {
dispatch('select', { asset });
onSelect?.(asset);
}
};
const callClickHandlers = () => {
if (selected) {
onIconClickedHandler();
return;
}
onClick?.(asset);
};
const handleClick = (e: MouseEvent) => {
if (e.ctrlKey || e.metaKey) {
return;
}
e.stopPropagation();
e.preventDefault();
callClickHandlers();
};
if (selected) {
onIconClickedHandler(e);
return;
}
onClick?.(asset, e);
const _onMouseEnter = () => {
mouseOver = true;
onMouseEvent?.({ isMouseOver: true, selectedGroupIndex: groupIndex });
};
const onMouseEnter = () => {
mouseOver = true;
if (dateGroup && assetStore) {
assetStore.taskManager.queueScrollSensitiveTask({ componentId, task: () => _onMouseEnter() });
} else {
_onMouseEnter();
}
};
const onMouseLeave = () => {
mouseOver = false;
if (dateGroup && assetStore) {
assetStore.taskManager.queueScrollSensitiveTask({ componentId, task: () => (mouseOver = false) });
} else {
mouseOver = false;
}
};
const _onIntersect = () => {
intersecting = true;
onIntersected?.();
};
const onIntersect = () => {
if (intersecting === true) {
return;
}
if (dateGroup && assetStore) {
assetStore.taskManager.intersectedThumbnail(componentId, dateGroup, asset, () => void _onIntersect());
} else {
void _onIntersect();
}
};
const onSeparate = () => {
if (intersecting === false) {
return;
}
if (dateGroup && assetStore) {
assetStore.taskManager.seperatedThumbnail(componentId, dateGroup, asset, () => (intersecting = false));
} else {
intersecting = false;
}
};
onDestroy(() => {
assetStore?.taskManager.removeAllTasksForComponent(componentId);
});
</script>
<IntersectionObserver once={false} on:intersected let:intersecting>
<a
href={currentUrlReplaceAssetId(asset.id)}
style:width="{width}px"
style:height="{height}px"
class="group focus-visible:outline-none flex overflow-hidden {disabled
? 'bg-gray-300'
: 'bg-immich-primary/20 dark:bg-immich-dark-primary/20'}"
class:cursor-not-allowed={disabled}
on:mouseenter={onMouseEnter}
on:mouseleave={onMouseLeave}
tabindex={0}
on:click={handleClick}
>
{#if intersecting}
<div
bind:this={element}
use:intersectionObserver={{
...intersectionConfig,
onIntersect,
onSeparate,
}}
data-asset={asset.id}
data-int={intersecting}
style:width="{width}px"
style:height="{height}px"
class="group focus-visible:outline-none flex overflow-hidden {disabled
? 'bg-gray-300'
: 'bg-immich-primary/20 dark:bg-immich-dark-primary/20'}"
>
{#if !loaded && asset.thumbhash}
<canvas
use:thumbhash={{ base64ThumbHash: asset.thumbhash }}
class="absolute object-cover z-10"
style:width="{width}px"
style:height="{height}px"
out:fade={{ duration: THUMBHASH_FADE_DURATION }}
></canvas>
{/if}
{#if display}
<!-- svelte queries for all links on afterNavigate, leading to performance problems in asset-grid which updates
the navigation url on scroll. Replace this with button for now. -->
<div
class:cursor-not-allowed={disabled}
class:cursor-pointer={!disabled}
on:mouseenter={onMouseEnter}
on:mouseleave={onMouseLeave}
on:keypress={(evt) => {
if (evt.key === 'Enter') {
callClickHandlers();
}
}}
tabindex={0}
on:click={handleClick}
role="link"
>
{#if mouseOver}
<!-- lazy show the url on mouse over-->
<a
class="absolute z-30 {className} top-[41px]"
style:cursor="unset"
style:width="{width}px"
style:height="{height}px"
href={currentUrlReplaceAssetId(asset.id)}
on:click={(evt) => evt.preventDefault()}
tabindex={0}
>
</a>
{/if}
<div class="absolute z-20 {className}" style:width="{width}px" style:height="{height}px">
<!-- Select asset button -->
{#if !readonly && (mouseOver || selected || selectionCandidate)}
@ -189,11 +303,11 @@
altText={$getAltText(asset)}
widthStyle="{width}px"
heightStyle="{height}px"
thumbhash={asset.thumbhash}
curve={selected}
onComplete={() => (loaded = true)}
/>
{:else}
<div class="flex h-full w-full items-center justify-center p-4">
<div class="absolute flex h-full w-full items-center justify-center p-4 z-10">
<Icon path={mdiImageBrokenVariant} size="48" />
</div>
{/if}
@ -201,6 +315,7 @@
{#if asset.type === AssetTypeEnum.Video}
<div class="absolute top-0 h-full w-full">
<VideoThumbnail
{assetStore}
url={getAssetPlaybackUrl({ id: asset.id, checksum: asset.checksum })}
enablePlayback={mouseOver && $playVideoThumbnailOnHover}
curve={selected}
@ -213,6 +328,7 @@
{#if asset.type === AssetTypeEnum.Image && asset.livePhotoVideoId}
<div class="absolute top-0 h-full w-full">
<VideoThumbnail
{assetStore}
url={getAssetPlaybackUrl({ id: asset.livePhotoVideoId, checksum: asset.checksum })}
pauseIcon={mdiMotionPauseOutline}
playIcon={mdiMotionPlayOutline}
@ -230,6 +346,6 @@
out:fade={{ duration: 100 }}
/>
{/if}
{/if}
</a>
</IntersectionObserver>
</div>
{/if}
</div>