mirror of
https://github.com/immich-app/immich
synced 2025-11-07 17:27:20 +00:00
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:
parent
07538299cf
commit
837b1e4929
50 changed files with 2947 additions and 843 deletions
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue