diff --git a/web/src/lib/components/photos-page/asset-grid.svelte b/web/src/lib/components/photos-page/asset-grid.svelte index 0e9053a5ea..c9710fa539 100644 --- a/web/src/lib/components/photos-page/asset-grid.svelte +++ b/web/src/lib/components/photos-page/asset-grid.svelte @@ -165,7 +165,13 @@ const updateIsScrolling = () => (assetStore.scrolling = true); // note: don't throttle, debounch, or otherwise do this function async - it causes flicker const updateSlidingWindow = () => assetStore.updateSlidingWindow(element?.scrollTop || 0); - const compensateScrollCallback = (delta: number) => element?.scrollBy(0, delta); + const compensateScrollCallback = ({ delta, top }: { delta?: number; top?: number }) => { + if (delta) { + element?.scrollBy(0, delta); + } else if (top) { + element?.scrollTo({ top }); + } + }; const topSectionResizeObserver: OnResizeCallback = ({ height }) => (assetStore.topSectionHeight = height); onMount(() => { diff --git a/web/src/lib/stores/assets-store.svelte.ts b/web/src/lib/stores/assets-store.svelte.ts index 05ca8eb5b3..44664ded8e 100644 --- a/web/src/lib/stores/assets-store.svelte.ts +++ b/web/src/lib/stores/assets-store.svelte.ts @@ -10,7 +10,7 @@ import { import { formatDateGroupTitle, fromLocalDateTime } from '$lib/utils/timeline-util'; import { TUNABLES } from '$lib/utils/tunables'; import { getAssetInfo, getTimeBucket, getTimeBuckets, TimeBucketSize, type AssetResponseDto } from '@immich/sdk'; -import { debounce, isEqual, throttle } from 'lodash-es'; +import { clamp, debounce, isEqual, throttle } from 'lodash-es'; import { DateTime } from 'luxon'; import { t } from 'svelte-i18n'; @@ -218,6 +218,7 @@ export type ViewportXY = Viewport & { export class AssetBucket { // --- public --- #intersecting: boolean = $state(false); + actuallyIntersecting: boolean = $state(false); isLoaded: boolean = $state(false); dateGroups: AssetDateGroup[] = $state([]); readonly store: AssetStore; @@ -233,6 +234,7 @@ export class AssetBucket { #top: number = $state(0); #initialCount: number = 0; + percent: number = $state(0); // --- should be private, but is used by AssetStore --- bucketCount: number = $derived( @@ -423,7 +425,7 @@ export class AssetBucket { } set bucketHeight(height: number) { - const { store } = this; + const { store, percent } = this; const index = store.buckets.indexOf(this); const bucketHeightDelta = height - this.#bucketHeight; const prevBucket = store.buckets[index - 1]; @@ -444,8 +446,14 @@ export class AssetBucket { // if the bucket is 'before' the last intersecting bucket in the sliding window // then adjust the scroll position by the delta, to compensate for the bucket // size adjustment - if (currentIndex > 0 && index <= currentIndex) { - store.compensateScrollCallback?.(bucketHeightDelta); + if (currentIndex > 0) { + if (index < currentIndex) { + store.compensateScrollCallback?.({ delta: bucketHeightDelta }); + } else if (currentIndex == currentIndex) { + this.store.updateIntersections(); + const top = this.#top + height * percent; + store.compensateScrollCallback?.({ top }); + } } } } @@ -539,7 +547,7 @@ export class AssetStore { scrubberTimelineHeight: number = $state(0); // -- should be private, but used by AssetBucket - compensateScrollCallback: ((delta: number) => void) | undefined; + compensateScrollCallback: (({ delta, top }: { delta?: number; top?: number }) => void) | undefined; topIntersectingBucket: AssetBucket | undefined = $state(); visibleWindow = $derived.by(() => ({ @@ -704,29 +712,51 @@ export class AssetStore { let topIntersectingBucket = undefined; for (const bucket of this.buckets) { this.#updateIntersection(bucket); - if (!topIntersectingBucket && bucket.intersecting) { + if (!topIntersectingBucket && bucket.actuallyIntersecting) { topIntersectingBucket = bucket; } } if (this.topIntersectingBucket !== topIntersectingBucket) { this.topIntersectingBucket = topIntersectingBucket; } + for (const bucket of this.buckets) { + if (bucket === this.topIntersectingBucket) { + this.topIntersectingBucket.percent = clamp( + (this.visibleWindow.top - this.topIntersectingBucket.top) / this.topIntersectingBucket.bucketHeight, + 0, + 1, + ); + } else { + bucket.percent = 0; + } + } } - #updateIntersection(bucket: AssetBucket) { + #calculateIntersecting(bucket: AssetBucket, expandTop: number, expandBottom: number) { const bucketTop = bucket.top; const bucketBottom = bucketTop + bucket.bucketHeight; - const topWindow = this.visibleWindow.top - INTERSECTION_EXPAND_TOP; - const bottomWindow = this.visibleWindow.bottom + INTERSECTION_EXPAND_BOTTOM; + const topWindow = this.visibleWindow.top - expandTop; + const bottomWindow = this.visibleWindow.bottom + expandBottom; // a bucket intersections if // 1) bucket's bottom is in the visible range -or- // 2) bucket's bottom is in the visible range -or- // 3) bucket's top is above visible range and bottom is below visible range - bucket.intersecting = + return ( (bucketTop >= topWindow && bucketTop < bottomWindow) || (bucketBottom >= topWindow && bucketBottom < bottomWindow) || - (bucketTop < topWindow && bucketBottom >= bottomWindow); + (bucketTop < topWindow && bucketBottom >= bottomWindow) + ); + } + + #updateIntersection(bucket: AssetBucket) { + const actuallyIntersecting = this.#calculateIntersecting(bucket, 0, 0); + let preIntersecting = false; + if (!actuallyIntersecting) { + preIntersecting = this.#calculateIntersecting(bucket, INTERSECTION_EXPAND_TOP, INTERSECTION_EXPAND_BOTTOM); + } + bucket.intersecting = actuallyIntersecting || preIntersecting; + bucket.actuallyIntersecting = actuallyIntersecting; } #processPendingChanges = throttle(() => { @@ -743,7 +773,7 @@ export class AssetStore { this.#pendingChanges = []; }, 2500); - setCompensateScrollCallback(compensateScrollCallback?: (delta: number) => void) { + setCompensateScrollCallback(compensateScrollCallback?: ({ delta, top }: { delta?: number; top?: number }) => void) { this.compensateScrollCallback = compensateScrollCallback; }