From 146973b072f2d1f87d2b81b6765b0c3ee1ad7c65 Mon Sep 17 00:00:00 2001 From: Min Idzelis Date: Mon, 13 Oct 2025 22:16:05 -0400 Subject: [PATCH] fix: re-add scroll compensation (efficiently) (#22848) * fix: re-add scroll compensation (efficient) * Rename showSkeleton to invisible. Adjust skeleton margins, invisible support. * Fix faulty logic, simplify * Calculate ratios and determine compensation strategy: height comp for above/partiality visible, month-scroll comp within a fully visible month. --------- Co-authored-by: Alex --- .../lib/components/timeline/Timeline.svelte | 77 ++++++++----------- .../timeline/TimelineAssetViewer.svelte | 6 +- .../timeline/TimelineDateGroup.svelte | 2 +- web/src/lib/elements/Skeleton.svelte | 16 ++-- .../internal/load-support.svelte.ts | 3 - .../timeline-manager/month-group.svelte.ts | 18 ++++- .../timeline-manager.svelte.spec.ts | 8 +- .../timeline-manager.svelte.ts | 69 +++++++++++++++-- 8 files changed, 128 insertions(+), 71 deletions(-) diff --git a/web/src/lib/components/timeline/Timeline.svelte b/web/src/lib/components/timeline/Timeline.svelte index eacc938f26..74e6ac67ae 100644 --- a/web/src/lib/components/timeline/Timeline.svelte +++ b/web/src/lib/components/timeline/Timeline.svelte @@ -89,10 +89,10 @@ let { isViewing: showAssetViewer, asset: viewingAsset, gridScrollTarget } = assetViewingStore; - let element: HTMLElement | undefined = $state(); + let scrollableElement: HTMLElement | undefined = $state(); let timelineElement: HTMLElement | undefined = $state(); - let showSkeleton = $state(true); + let invisible = $state(true); // The percentage of scroll through the month that is currently intersecting the top boundary of the viewport. // Note: There may be multiple months visible within the viewport at any given time. let viewportTopMonthScrollPercent = $state(0); @@ -124,29 +124,22 @@ timelineManager.setLayoutOptions(layoutOptions); }); - const scrollTo = (top: number) => { - if (element) { - element.scrollTo({ top }); - } - }; - const scrollTop = (top: number) => { - if (element) { - element.scrollTop = top; - } - }; + $effect(() => { + timelineManager.scrollableElement = scrollableElement; + }); const scrollToTop = () => { - scrollTo(0); + timelineManager.scrollTo(0); }; const getAssetHeight = (assetId: string, monthGroup: MonthGroup) => monthGroup.findAssetAbsolutePosition(assetId); const assetIsVisible = (assetTop: number): boolean => { - if (!element) { + if (!scrollableElement) { return false; } - const { clientHeight, scrollTop } = element; + const { clientHeight, scrollTop } = scrollableElement; return assetTop >= scrollTop && assetTop < scrollTop + clientHeight; }; @@ -163,8 +156,7 @@ return true; } - scrollTo(height); - updateSlidingWindow(); + timelineManager.scrollTo(height); return true; }; @@ -174,8 +166,7 @@ return false; } const height = getAssetHeight(asset.id, monthGroup); - scrollTo(height); - updateSlidingWindow(); + timelineManager.scrollTo(height); return true; }; @@ -189,7 +180,7 @@ // if the asset is not found, scroll to the top scrollToTop(); } - showSkeleton = false; + invisible = false; }; beforeNavigate(() => (timelineManager.suspendTransitions = true)); @@ -216,7 +207,7 @@ } else { scrollToTop(); } - showSkeleton = false; + invisible = false; }, 500); } }; @@ -230,13 +221,12 @@ const updateIsScrolling = () => (timelineManager.scrolling = true); // note: don't throttle, debounch, or otherwise do this function async - it causes flicker - const updateSlidingWindow = () => timelineManager.updateSlidingWindow(element?.scrollTop || 0); const topSectionResizeObserver: OnResizeCallback = ({ height }) => (timelineManager.topSectionHeight = height); onMount(() => { if (!enableRouting) { - showSkeleton = false; + invisible = false; } }); @@ -246,11 +236,13 @@ }; const getMaxScroll = () => { - if (!element || !timelineElement) { + if (!scrollableElement || !timelineElement) { return 0; } return ( - timelineManager.topSectionHeight + bottomSectionHeight + (timelineElement.clientHeight - element.clientHeight) + timelineManager.topSectionHeight + + bottomSectionHeight + + (timelineElement.clientHeight - scrollableElement.clientHeight) ); }; @@ -260,7 +252,7 @@ const delta = monthGroup.height * monthGroupScrollPercent; const scrollToTop = (topOffset + delta) * maxScrollPercent; - scrollTop(scrollToTop); + timelineManager.scrollTo(scrollToTop); }; // note: don't throttle, debounce, or otherwise make this function async - it causes flicker @@ -272,7 +264,7 @@ // edge case - scroll limited due to size of content, must adjust - use use the overall percent instead const maxScroll = getMaxScroll(); const offset = maxScroll * overallScrollPercent; - scrollTop(offset); + timelineManager.scrollTo(offset); } else { const monthGroup = timelineManager.months.find( ({ yearMonth: { year, month } }) => year === scrubberMonth.year && month === scrubberMonth.month, @@ -288,26 +280,26 @@ const handleTimelineScroll = () => { isInLeadOutSection = false; - if (!element) { + if (!scrollableElement) { return; } if (timelineManager.timelineHeight < timelineManager.viewportHeight * 2) { // edge case - scroll limited due to size of content, must adjust - use the overall percent instead const maxScroll = getMaxScroll(); - timelineScrollPercent = Math.min(1, element.scrollTop / maxScroll); + timelineScrollPercent = Math.min(1, scrollableElement.scrollTop / maxScroll); viewportTopMonth = undefined; viewportTopMonthScrollPercent = 0; } else { - let top = element.scrollTop; + let top = scrollableElement.scrollTop; if (top < timelineManager.topSectionHeight) { // in the lead-in area viewportTopMonth = undefined; viewportTopMonthScrollPercent = 0; const maxScroll = getMaxScroll(); - timelineScrollPercent = Math.min(1, element.scrollTop / maxScroll); + timelineScrollPercent = Math.min(1, scrollableElement.scrollTop / maxScroll); return; } @@ -414,7 +406,7 @@ onSelect(asset); if (singleSelect) { - scrollTop(0); + timelineManager.scrollTo(0); return; } @@ -564,10 +556,10 @@ if (evt.key === 'ArrowUp') { amount = -amount; if (shiftKeyIsDown) { - element?.scrollBy({ top: amount, behavior: 'smooth' }); + scrollableElement?.scrollBy({ top: amount, behavior: 'smooth' }); } } else if (evt.key === 'ArrowDown') { - element?.scrollBy({ top: amount, behavior: 'smooth' }); + scrollableElement?.scrollBy({ top: amount, behavior: 'smooth' }); } }} /> @@ -580,19 +572,19 @@ style:margin-right={(usingMobileDevice ? 0 : scrubberWidth) + 'px'} tabindex="-1" bind:clientHeight={timelineManager.viewportHeight} - bind:clientWidth={null, (v: number) => ((timelineManager.viewportWidth = v), updateSlidingWindow())} - bind:this={element} - onscroll={() => (handleTimelineScroll(), updateSlidingWindow(), updateIsScrolling())} + bind:clientWidth={timelineManager.viewportWidth} + bind:this={scrollableElement} + onscroll={() => (handleTimelineScroll(), timelineManager.updateSlidingWindow(), updateIsScrolling())} >
- + {:else if display}
{#if $showAssetViewer} - + {/if} diff --git a/web/src/lib/components/timeline/TimelineAssetViewer.svelte b/web/src/lib/components/timeline/TimelineAssetViewer.svelte index 60b839d7e1..9459eef25b 100644 --- a/web/src/lib/components/timeline/TimelineAssetViewer.svelte +++ b/web/src/lib/components/timeline/TimelineAssetViewer.svelte @@ -13,7 +13,7 @@ interface Props { timelineManager: TimelineManager; - showSkeleton: boolean; + invisible: boolean; withStacked?: boolean; isShared?: boolean; album?: AlbumResponseDto | null; @@ -30,7 +30,7 @@ let { timelineManager, - showSkeleton = $bindable(false), + invisible = $bindable(false), removeAction, withStacked = false, isShared = false, @@ -81,7 +81,7 @@ const handleClose = async (asset: { id: string }) => { assetViewingStore.showAssetViewer(false); - showSkeleton = true; + invisible = true; $gridScrollTarget = { at: asset.id }; await navigate({ targetRoute: 'current', assetId: null, assetGridRouteSearchParams: $gridScrollTarget }); }; diff --git a/web/src/lib/components/timeline/TimelineDateGroup.svelte b/web/src/lib/components/timeline/TimelineDateGroup.svelte index 5070b6b7c1..cd0dc9a212 100644 --- a/web/src/lib/components/timeline/TimelineDateGroup.svelte +++ b/web/src/lib/components/timeline/TimelineDateGroup.svelte @@ -14,7 +14,7 @@ import { fromTimelinePlainDate, getDateLocaleString } from '$lib/utils/timeline-util'; import { Icon } from '@immich/ui'; - import type { Snippet } from 'svelte'; + import { type Snippet } from 'svelte'; import { flip } from 'svelte/animate'; import { scale } from 'svelte/transition'; diff --git a/web/src/lib/elements/Skeleton.svelte b/web/src/lib/elements/Skeleton.svelte index 8ee05f4e61..10ded84818 100644 --- a/web/src/lib/elements/Skeleton.svelte +++ b/web/src/lib/elements/Skeleton.svelte @@ -2,24 +2,21 @@ interface Props { height: number; title?: string; + invisible?: boolean; } - let { height = 0, title }: Props = $props(); + let { height = 0, title, invisible = false }: Props = $props(); -
+
{#if title}
{title}
{/if} -
+
diff --git a/web/src/lib/managers/timeline-manager/internal/load-support.svelte.ts b/web/src/lib/managers/timeline-manager/internal/load-support.svelte.ts index 82a9e8083d..e6a80afc7f 100644 --- a/web/src/lib/managers/timeline-manager/internal/load-support.svelte.ts +++ b/web/src/lib/managers/timeline-manager/internal/load-support.svelte.ts @@ -4,7 +4,6 @@ import { getTimeBucket } from '@immich/sdk'; import type { MonthGroup } from '../month-group.svelte'; import type { TimelineManager } from '../timeline-manager.svelte'; import type { TimelineManagerOptions } from '../types'; -import { layoutMonthGroup } from './layout-support.svelte'; export async function loadFromTimeBuckets( timelineManager: TimelineManager, @@ -55,6 +54,4 @@ export async function loadFromTimeBuckets( )}`, ); } - - layoutMonthGroup(timelineManager, monthGroup); } diff --git a/web/src/lib/managers/timeline-manager/month-group.svelte.ts b/web/src/lib/managers/timeline-manager/month-group.svelte.ts index f9c34668b8..d244df9f81 100644 --- a/web/src/lib/managers/timeline-manager/month-group.svelte.ts +++ b/web/src/lib/managers/timeline-manager/month-group.svelte.ts @@ -36,6 +36,7 @@ export class MonthGroup { #initialCount: number = 0; #sortOrder: AssetOrder = AssetOrder.Desc; + percent: number = $state(0); assetsCount: number = $derived( this.isLoaded @@ -241,7 +242,6 @@ export class MonthGroup { if (this.#height === height) { return; } - let needsIntersectionUpdate = false; const timelineManager = this.timelineManager; const index = timelineManager.months.indexOf(this); const heightDelta = height - this.#height; @@ -261,11 +261,21 @@ export class MonthGroup { const newTop = monthGroup.#top + heightDelta; if (monthGroup.#top !== newTop) { monthGroup.#top = newTop; - needsIntersectionUpdate = true; } } - if (needsIntersectionUpdate) { - timelineManager.updateIntersections(); + if (!timelineManager.viewportTopMonthIntersection) { + return; + } + const { month, monthBottomViewportRatio, viewportTopRatioInMonth } = timelineManager.viewportTopMonthIntersection; + const currentIndex = month ? timelineManager.months.indexOf(month) : -1; + if (!month || currentIndex <= 0 || index > currentIndex) { + return; + } + if (index < currentIndex || monthBottomViewportRatio < 1) { + timelineManager.scrollBy(heightDelta); + } else if (index === currentIndex) { + const scrollTo = this.top + height * viewportTopRatioInMonth; + timelineManager.scrollTo(scrollTo); } } diff --git a/web/src/lib/managers/timeline-manager/timeline-manager.svelte.spec.ts b/web/src/lib/managers/timeline-manager/timeline-manager.svelte.spec.ts index ceafa5b0bd..7c448331ff 100644 --- a/web/src/lib/managers/timeline-manager/timeline-manager.svelte.spec.ts +++ b/web/src/lib/managers/timeline-manager/timeline-manager.svelte.spec.ts @@ -4,6 +4,7 @@ import { AbortError } from '$lib/utils'; import { fromISODateTimeUTCToObject } from '$lib/utils/timeline-util'; import { type AssetResponseDto, type TimeBucketAssetResponseDto } from '@immich/sdk'; import { timelineAssetFactory, toResponseDto } from '@test-data/factories/asset-factory'; +import { tick } from 'svelte'; import { TimelineManager } from './timeline-manager.svelte'; import type { TimelineAsset } from './types'; @@ -64,11 +65,12 @@ describe('TimelineManager', () => { sdkMock.getTimeBucket.mockImplementation(({ timeBucket }) => Promise.resolve(bucketAssetsResponse[timeBucket])); await timelineManager.updateViewport({ width: 1588, height: 1000 }); + await tick(); }); it('should load months in viewport', () => { expect(sdkMock.getTimeBuckets).toBeCalledTimes(1); - expect(sdkMock.getTimeBucket).toHaveBeenCalledTimes(3); + expect(sdkMock.getTimeBucket).toHaveBeenCalledTimes(2); }); it('calculates month height', () => { @@ -82,13 +84,13 @@ describe('TimelineManager', () => { expect.arrayContaining([ expect.objectContaining({ year: 2024, month: 3, height: 165.5 }), expect.objectContaining({ year: 2024, month: 2, height: 11_996 }), - expect.objectContaining({ year: 2024, month: 1, height: 48 }), + expect.objectContaining({ year: 2024, month: 1, height: 286 }), ]), ); }); it('calculates timeline height', () => { - expect(timelineManager.timelineHeight).toBe(12_209.5); + expect(timelineManager.timelineHeight).toBe(12_447.5); }); }); diff --git a/web/src/lib/managers/timeline-manager/timeline-manager.svelte.ts b/web/src/lib/managers/timeline-manager/timeline-manager.svelte.ts index f507f4de28..23cf677b40 100644 --- a/web/src/lib/managers/timeline-manager/timeline-manager.svelte.ts +++ b/web/src/lib/managers/timeline-manager/timeline-manager.svelte.ts @@ -5,7 +5,7 @@ import { authManager } from '$lib/managers/auth-manager.svelte'; import { CancellableTask } from '$lib/utils/cancellable-task'; import { toTimelineAsset, type TimelineDateTime, type TimelineYearMonth } from '$lib/utils/timeline-util'; -import { debounce, isEqual } from 'lodash-es'; +import { clamp, debounce, isEqual } from 'lodash-es'; import { SvelteDate, SvelteMap, SvelteSet } from 'svelte/reactivity'; import { updateIntersectionMonthGroup } from '$lib/managers/timeline-manager/internal/intersection-support.svelte'; @@ -37,6 +37,13 @@ import type { Viewport, } from './types'; +type ViewportTopMonthIntersection = { + month: MonthGroup | undefined; + // Where viewport top intersects month (0 = month top, 1 = month bottom) + viewportTopRatioInMonth: number; + // Where month bottom is in viewport (0 = viewport top, 1 = viewport bottom) + monthBottomViewportRatio: number; +}; export class TimelineManager { isInitialized = $state(false); months: MonthGroup[] = $state([]); @@ -49,6 +56,8 @@ export class TimelineManager { scrubberMonths: ScrubberMonth[] = $state([]); scrubberTimelineHeight: number = $state(0); + viewportTopMonthIntersection: ViewportTopMonthIntersection | undefined; + visibleWindow = $derived.by(() => ({ top: this.#scrollTop, bottom: this.#scrollTop + this.viewportHeight, @@ -85,6 +94,8 @@ export class TimelineManager { #suspendTransitions = $state(false); #resetScrolling = debounce(() => (this.#scrolling = false), 1000); #resetSuspendTransitions = debounce(() => (this.suspendTransitions = false), 1000); + #updatingIntersections = false; + #scrollableElement: HTMLElement | undefined = $state(); constructor() {} @@ -98,6 +109,20 @@ export class TimelineManager { } } + set scrollableElement(element: HTMLElement | undefined) { + this.#scrollableElement = element; + } + + scrollTo(top: number) { + this.#scrollableElement?.scrollTo({ top }); + this.updateSlidingWindow(); + } + + scrollBy(y: number) { + this.#scrollableElement?.scrollBy(0, y); + this.updateSlidingWindow(); + } + #setHeaderHeight(value: number) { if (this.#headerHeight == value) { return false; @@ -161,7 +186,8 @@ export class TimelineManager { const changed = value !== this.#viewportWidth; this.#viewportWidth = value; this.suspendTransitions = true; - void this.#updateViewportGeometry(changed); + this.#updateViewportGeometry(changed); + this.updateSlidingWindow(); } get viewportWidth() { @@ -223,20 +249,52 @@ export class TimelineManager { this.#websocketSupport = undefined; } - updateSlidingWindow(scrollTop: number) { + updateSlidingWindow() { + const scrollTop = this.#scrollableElement?.scrollTop ?? 0; if (this.#scrollTop !== scrollTop) { this.#scrollTop = scrollTop; this.updateIntersections(); } } + #calculateMonthBottomViewportRatio(month: MonthGroup | undefined) { + if (!month) { + return 0; + } + const windowHeight = this.visibleWindow.bottom - this.visibleWindow.top; + const bottomOfMonth = month.top + month.height; + const bottomOfMonthInViewport = bottomOfMonth - this.visibleWindow.top; + return clamp(bottomOfMonthInViewport / windowHeight, 0, 1); + } + + #calculateVewportTopRatioInMonth(month: MonthGroup | undefined) { + if (!month) { + return 0; + } + return clamp((this.visibleWindow.top - month.top) / month.height, 0, 1); + } + updateIntersections() { - if (!this.isInitialized || this.visibleWindow.bottom === this.visibleWindow.top) { + if (this.#updatingIntersections || !this.isInitialized || this.visibleWindow.bottom === this.visibleWindow.top) { return; } + this.#updatingIntersections = true; + for (const month of this.months) { updateIntersectionMonthGroup(this, month); } + + const month = this.months.find((month) => month.actuallyIntersecting); + const viewportTopRatioInMonth = this.#calculateVewportTopRatioInMonth(month); + const monthBottomViewportRatio = this.#calculateMonthBottomViewportRatio(month); + + this.viewportTopMonthIntersection = { + month, + monthBottomViewportRatio, + viewportTopRatioInMonth, + }; + + this.#updatingIntersections = false; } clearDeferredLayout(month: MonthGroup) { @@ -368,7 +426,8 @@ export class TimelineManager { await loadFromTimeBuckets(this, monthGroup, this.#options, signal); }, cancelable); if (executionStatus === 'LOADED') { - updateIntersectionMonthGroup(this, monthGroup); + updateGeometry(this, monthGroup, { invalidateHeight: false }); + this.updateIntersections(); } }