From 09de5b198ac1ce4ace5a148659d9b2aa863da67c Mon Sep 17 00:00:00 2001 From: midzelis Date: Sun, 12 Oct 2025 13:32:36 +0000 Subject: [PATCH] Calculate ratios and determine compensation strategy: height comp for above/partiality visible, month-scroll comp within a fully visible month. --- .../lib/components/timeline/Timeline.svelte | 31 +++------- .../timeline/TimelineDateGroup.svelte | 2 +- .../internal/load-support.svelte.ts | 3 - .../timeline-manager/month-group.svelte.ts | 15 +++-- .../timeline-manager.svelte.spec.ts | 2 + .../timeline-manager.svelte.ts | 59 +++++++++++++++---- 6 files changed, 70 insertions(+), 42 deletions(-) diff --git a/web/src/lib/components/timeline/Timeline.svelte b/web/src/lib/components/timeline/Timeline.svelte index a559cc878a..74e6ac67ae 100644 --- a/web/src/lib/components/timeline/Timeline.svelte +++ b/web/src/lib/components/timeline/Timeline.svelte @@ -128,20 +128,8 @@ timelineManager.scrollableElement = scrollableElement; }); - const scrollTo = (top: number) => { - if (scrollableElement) { - scrollableElement.scrollTo({ top }); - } - }; - - const scrollTop = (top: number) => { - if (scrollableElement) { - scrollableElement.scrollTop = top; - } - }; - const scrollToTop = () => { - scrollTo(0); + timelineManager.scrollTo(0); }; const getAssetHeight = (assetId: string, monthGroup: MonthGroup) => monthGroup.findAssetAbsolutePosition(assetId); @@ -168,8 +156,7 @@ return true; } - scrollTo(height); - updateSlidingWindow(); + timelineManager.scrollTo(height); return true; }; @@ -179,8 +166,7 @@ return false; } const height = getAssetHeight(asset.id, monthGroup); - scrollTo(height); - updateSlidingWindow(); + timelineManager.scrollTo(height); return true; }; @@ -235,7 +221,6 @@ const updateIsScrolling = () => (timelineManager.scrolling = true); // note: don't throttle, debounch, or otherwise do this function async - it causes flicker - const updateSlidingWindow = () => timelineManager.updateSlidingWindow(scrollableElement?.scrollTop || 0); const topSectionResizeObserver: OnResizeCallback = ({ height }) => (timelineManager.topSectionHeight = height); @@ -267,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 @@ -279,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, @@ -421,7 +406,7 @@ onSelect(asset); if (singleSelect) { - scrollTop(0); + timelineManager.scrollTo(0); return; } @@ -587,9 +572,9 @@ style:margin-right={(usingMobileDevice ? 0 : scrubberWidth) + 'px'} tabindex="-1" bind:clientHeight={timelineManager.viewportHeight} - bind:clientWidth={null, (v: number) => ((timelineManager.viewportWidth = v), updateSlidingWindow())} + bind:clientWidth={timelineManager.viewportWidth} bind:this={scrollableElement} - onscroll={() => (handleTimelineScroll(), updateSlidingWindow(), updateIsScrolling())} + onscroll={() => (handleTimelineScroll(), timelineManager.updateSlidingWindow(), updateIsScrolling())} >
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 dabcb10479..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,6 +65,7 @@ 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', () => { 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 1da357e233..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([]); @@ -48,7 +55,8 @@ export class TimelineManager { scrubberMonths: ScrubberMonth[] = $state([]); scrubberTimelineHeight: number = $state(0); - topIntersectingMonthGroup: MonthGroup | undefined = $state(); + + viewportTopMonthIntersection: ViewportTopMonthIntersection | undefined; visibleWindow = $derived.by(() => ({ top: this.#scrollTop, @@ -87,7 +95,7 @@ export class TimelineManager { #resetScrolling = debounce(() => (this.#scrolling = false), 1000); #resetSuspendTransitions = debounce(() => (this.suspendTransitions = false), 1000); #updatingIntersections = false; - #element: HTMLElement | undefined; + #scrollableElement: HTMLElement | undefined = $state(); constructor() {} @@ -102,15 +110,17 @@ export class TimelineManager { } set scrollableElement(element: HTMLElement | undefined) { - this.#element = element; + this.#scrollableElement = element; } scrollTo(top: number) { - this.#element?.scrollTo({ top }); + this.#scrollableElement?.scrollTo({ top }); + this.updateSlidingWindow(); } scrollBy(y: number) { - this.#element?.scrollBy(0, y); + this.#scrollableElement?.scrollBy(0, y); + this.updateSlidingWindow(); } #setHeaderHeight(value: number) { @@ -176,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() { @@ -238,13 +249,31 @@ 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.#updatingIntersections || !this.isInitialized || this.visibleWindow.bottom === this.visibleWindow.top) { return; @@ -255,7 +284,16 @@ export class TimelineManager { updateIntersectionMonthGroup(this, month); } - this.topIntersectingMonthGroup = this.months.find((month) => month.actuallyIntersecting); + 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; } @@ -388,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(); } }