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

@ -3,8 +3,8 @@ import { render } from '@testing-library/svelte';
describe('ImageThumbnail component', () => {
beforeAll(() => {
Object.defineProperty(HTMLImageElement.prototype, 'decode', {
value: vi.fn(),
Object.defineProperty(HTMLImageElement.prototype, 'complete', {
value: true,
});
});
@ -12,13 +12,11 @@ describe('ImageThumbnail component', () => {
const sut = render(ImageThumbnail, {
url: 'http://localhost/img.png',
altText: 'test',
thumbhash: '1QcSHQRnh493V4dIh4eXh1h4kJUI',
base64ThumbHash: '1QcSHQRnh493V4dIh4eXh1h4kJUI',
widthStyle: '250px',
});
const [_, thumbhash] = sut.getAllByRole('img');
expect(thumbhash.getAttribute('src')).toContain(
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABcAAAAgCAYAAAD5VeO1AAAMRklEQVR4AQBdAKL/', // truncated
);
const thumbhash = sut.getByTestId('thumbhash');
expect(thumbhash).not.toBeFalsy();
});
});

View file

@ -1,17 +1,19 @@
<script lang="ts">
import { onMount, tick } from 'svelte';
import { decodeBase64 } from '$lib/utils';
import { onMount } from 'svelte';
import { fade } from 'svelte/transition';
import { thumbHashToDataURL } from 'thumbhash';
import { mdiEyeOffOutline } from '@mdi/js';
import { thumbhash } from '$lib/actions/thumbhash';
import Icon from '$lib/components/elements/icon.svelte';
import { TUNABLES } from '$lib/utils/tunables';
import { mdiEyeOffOutline, mdiImageBrokenVariant } from '@mdi/js';
export let url: string;
export let altText: string | undefined;
export let title: string | null = null;
export let heightStyle: string | undefined = undefined;
export let widthStyle: string;
export let thumbhash: string | null = null;
export let base64ThumbHash: string | null = null;
export let curve = false;
export let shadow = false;
export let circle = false;
@ -19,37 +21,58 @@
export let border = false;
export let preload = true;
export let hiddenIconClass = 'text-white';
export let onComplete: (() => void) | undefined = undefined;
let {
IMAGE_THUMBNAIL: { THUMBHASH_FADE_DURATION },
} = TUNABLES;
let loaded = false;
let errored = false;
let complete = false;
let img: HTMLImageElement;
onMount(async () => {
await img.decode();
await tick();
complete = true;
const setLoaded = () => {
loaded = true;
onComplete?.();
};
const setErrored = () => {
errored = true;
onComplete?.();
};
onMount(() => {
if (img.complete) {
setLoaded();
}
});
</script>
<img
bind:this={img}
loading={preload ? 'eager' : 'lazy'}
style:width={widthStyle}
style:height={heightStyle}
style:filter={hidden ? 'grayscale(50%)' : 'none'}
style:opacity={hidden ? '0.5' : '1'}
src={url}
alt={altText}
{title}
class="object-cover transition duration-300 {border
? 'border-[3px] border-immich-dark-primary/80 hover:border-immich-primary'
: ''}"
class:rounded-xl={curve}
class:shadow-lg={shadow}
class:rounded-full={circle}
class:aspect-square={circle || !heightStyle}
class:opacity-0={!thumbhash && !complete}
draggable="false"
/>
{#if errored}
<div class="absolute flex h-full w-full items-center justify-center p-4 z-10">
<Icon path={mdiImageBrokenVariant} size="48" />
</div>
{:else}
<img
bind:this={img}
on:load={setLoaded}
on:error={setErrored}
loading={preload ? 'eager' : 'lazy'}
style:width={widthStyle}
style:height={heightStyle}
style:filter={hidden ? 'grayscale(50%)' : 'none'}
style:opacity={hidden ? '0.5' : '1'}
src={url}
alt={loaded || errored ? altText : ''}
{title}
class="object-cover {border ? 'border-[3px] border-immich-dark-primary/80 hover:border-immich-primary' : ''}"
class:rounded-xl={curve}
class:shadow-lg={shadow}
class:rounded-full={circle}
class:aspect-square={circle || !heightStyle}
class:opacity-0={!thumbhash && !loaded}
draggable="false"
/>
{/if}
{#if hidden}
<div class="absolute left-1/2 top-1/2 translate-x-[-50%] translate-y-[-50%] transform">
@ -57,18 +80,18 @@
</div>
{/if}
{#if thumbhash && !complete}
<img
{#if base64ThumbHash && (!loaded || errored)}
<canvas
use:thumbhash={{ base64ThumbHash }}
data-testid="thumbhash"
style:width={widthStyle}
style:height={heightStyle}
src={thumbHashToDataURL(decodeBase64(thumbhash))}
alt={altText}
{title}
class="absolute top-0 object-cover"
class:rounded-xl={curve}
class:shadow-lg={shadow}
class:rounded-full={circle}
draggable="false"
out:fade={{ duration: 300 }}
out:fade={{ duration: THUMBHASH_FADE_DURATION }}
/>
{/if}

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>

View file

@ -3,7 +3,11 @@
import LoadingSpinner from '$lib/components/shared-components/loading-spinner.svelte';
import { mdiAlertCircleOutline, mdiPauseCircleOutline, mdiPlayCircleOutline } from '@mdi/js';
import Icon from '$lib/components/elements/icon.svelte';
import { AssetStore } from '$lib/stores/assets.store';
import { generateId } from '$lib/utils/generate-id';
import { onDestroy } from 'svelte';
export let assetStore: AssetStore | undefined = undefined;
export let url: string;
export let durationInSeconds = 0;
export let enablePlayback = false;
@ -13,6 +17,7 @@
export let playIcon = mdiPlayCircleOutline;
export let pauseIcon = mdiPauseCircleOutline;
const componentId = generateId();
let remainingSeconds = durationInSeconds;
let loading = true;
let error = false;
@ -27,6 +32,43 @@
player.src = '';
}
}
const onMouseEnter = () => {
if (assetStore) {
assetStore.taskManager.queueScrollSensitiveTask({
componentId,
task: () => {
if (playbackOnIconHover) {
enablePlayback = true;
}
},
});
} else {
if (playbackOnIconHover) {
enablePlayback = true;
}
}
};
const onMouseLeave = () => {
if (assetStore) {
assetStore.taskManager.queueScrollSensitiveTask({
componentId,
task: () => {
if (playbackOnIconHover) {
enablePlayback = false;
}
},
});
} else {
if (playbackOnIconHover) {
enablePlayback = false;
}
}
};
onDestroy(() => {
assetStore?.taskManager.removeAllTasksForComponent(componentId);
});
</script>
<div class="absolute right-0 top-0 z-20 flex place-items-center gap-1 text-xs font-medium text-white">
@ -37,19 +79,7 @@
{/if}
<!-- svelte-ignore a11y-no-static-element-interactions -->
<span
class="pr-2 pt-2"
on:mouseenter={() => {
if (playbackOnIconHover) {
enablePlayback = true;
}
}}
on:mouseleave={() => {
if (playbackOnIconHover) {
enablePlayback = false;
}
}}
>
<span class="pr-2 pt-2" on:mouseenter={onMouseEnter} on:mouseleave={onMouseLeave}>
{#if enablePlayback}
{#if loading}
<LoadingSpinner />