Calculate ratios and determine compensation strategy: height comp for above/partiality visible, month-scroll comp within a fully visible month.

This commit is contained in:
midzelis 2025-10-12 13:32:36 +00:00
parent 9cf6ff3e81
commit 09de5b198a
6 changed files with 70 additions and 42 deletions

View file

@ -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())}
>
<section
bind:this={timelineElement}

View file

@ -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';

View file

@ -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);
}

View file

@ -263,14 +263,19 @@ export class MonthGroup {
monthGroup.#top = newTop;
}
}
const intersectingMonth = timelineManager.topIntersectingMonthGroup;
const currentIndex = intersectingMonth ? timelineManager.months.indexOf(intersectingMonth) : -1;
if (!intersectingMonth || currentIndex <= 0) {
if (!timelineManager.viewportTopMonthIntersection) {
return;
}
if (index <= currentIndex) {
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);
}
}

View file

@ -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', () => {

View file

@ -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();
}
}