mirror of
https://github.com/immich-app/immich
synced 2025-10-17 18:19:27 +00:00
feat: (perf) remove scroll compensation
This commit is contained in:
parent
efa21af6f9
commit
11fdcdea9b
6 changed files with 23 additions and 112 deletions
|
|
@ -134,26 +134,12 @@
|
||||||
element.scrollTop = top;
|
element.scrollTop = top;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
const scrollBy = (y: number) => {
|
|
||||||
if (element) {
|
|
||||||
element.scrollBy(0, y);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const scrollToTop = () => {
|
const scrollToTop = () => {
|
||||||
scrollTo(0);
|
scrollTo(0);
|
||||||
};
|
};
|
||||||
|
|
||||||
const getAssetHeight = (assetId: string, monthGroup: MonthGroup) => {
|
const getAssetHeight = (assetId: string, monthGroup: MonthGroup) => monthGroup.findAssetAbsolutePosition(assetId);
|
||||||
// the following method may trigger any layouts, so need to
|
|
||||||
// handle any scroll compensation that may have been set
|
|
||||||
const height = monthGroup!.findAssetAbsolutePosition(assetId);
|
|
||||||
|
|
||||||
while (timelineManager.scrollCompensation.monthGroup) {
|
|
||||||
handleScrollCompensation(timelineManager.scrollCompensation);
|
|
||||||
timelineManager.clearScrollCompensation();
|
|
||||||
}
|
|
||||||
return height;
|
|
||||||
};
|
|
||||||
|
|
||||||
const assetIsVisible = (assetTop: number): boolean => {
|
const assetIsVisible = (assetTop: number): boolean => {
|
||||||
if (!element) {
|
if (!element) {
|
||||||
|
|
@ -246,19 +232,6 @@
|
||||||
// note: don't throttle, debounch, or otherwise do this function async - it causes flicker
|
// note: don't throttle, debounch, or otherwise do this function async - it causes flicker
|
||||||
const updateSlidingWindow = () => timelineManager.updateSlidingWindow(element?.scrollTop || 0);
|
const updateSlidingWindow = () => timelineManager.updateSlidingWindow(element?.scrollTop || 0);
|
||||||
|
|
||||||
const handleScrollCompensation = ({ heightDelta, scrollTop }: { heightDelta?: number; scrollTop?: number }) => {
|
|
||||||
if (heightDelta !== undefined) {
|
|
||||||
scrollBy(heightDelta);
|
|
||||||
} else if (scrollTop !== undefined) {
|
|
||||||
scrollTo(scrollTop);
|
|
||||||
}
|
|
||||||
// Yes, updateSlideWindow() is called by the onScroll event triggered as a result of
|
|
||||||
// the above calls. However, this delay is enough time to set the intersecting property
|
|
||||||
// of the monthGroup to false, then true, which causes the DOM nodes to be recreated,
|
|
||||||
// causing bad perf, and also, disrupting focus of those elements.
|
|
||||||
updateSlidingWindow();
|
|
||||||
};
|
|
||||||
|
|
||||||
const topSectionResizeObserver: OnResizeCallback = ({ height }) => (timelineManager.topSectionHeight = height);
|
const topSectionResizeObserver: OnResizeCallback = ({ height }) => (timelineManager.topSectionHeight = height);
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
|
|
@ -666,7 +639,6 @@
|
||||||
onSelect={({ title, assets }) => handleGroupSelect(timelineManager, title, assets)}
|
onSelect={({ title, assets }) => handleGroupSelect(timelineManager, title, assets)}
|
||||||
onSelectAssetCandidates={handleSelectAssetCandidates}
|
onSelectAssetCandidates={handleSelectAssetCandidates}
|
||||||
onSelectAssets={handleSelectAssets}
|
onSelectAssets={handleSelectAssets}
|
||||||
onScrollCompensation={handleScrollCompensation}
|
|
||||||
{customLayout}
|
{customLayout}
|
||||||
{onThumbnailClick}
|
{onThumbnailClick}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,6 @@
|
||||||
onSelect: ({ title, assets }: { title: string; assets: TimelineAsset[] }) => void;
|
onSelect: ({ title, assets }: { title: string; assets: TimelineAsset[] }) => void;
|
||||||
onSelectAssets: (asset: TimelineAsset) => void;
|
onSelectAssets: (asset: TimelineAsset) => void;
|
||||||
onSelectAssetCandidates: (asset: TimelineAsset | null) => void;
|
onSelectAssetCandidates: (asset: TimelineAsset | null) => void;
|
||||||
onScrollCompensation: (compensation: { heightDelta?: number; scrollTop?: number }) => void;
|
|
||||||
onThumbnailClick?: (
|
onThumbnailClick?: (
|
||||||
asset: TimelineAsset,
|
asset: TimelineAsset,
|
||||||
timelineManager: TimelineManager,
|
timelineManager: TimelineManager,
|
||||||
|
|
@ -59,7 +58,7 @@
|
||||||
onSelect,
|
onSelect,
|
||||||
onSelectAssets,
|
onSelectAssets,
|
||||||
onSelectAssetCandidates,
|
onSelectAssetCandidates,
|
||||||
onScrollCompensation,
|
|
||||||
onThumbnailClick,
|
onThumbnailClick,
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
|
|
@ -134,13 +133,6 @@
|
||||||
});
|
});
|
||||||
return getDateLocaleString(date);
|
return getDateLocaleString(date);
|
||||||
};
|
};
|
||||||
|
|
||||||
$effect.root(() => {
|
|
||||||
if (timelineManager.scrollCompensation.monthGroup === monthGroup) {
|
|
||||||
onScrollCompensation(timelineManager.scrollCompensation);
|
|
||||||
timelineManager.clearScrollCompensation();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#each filterIntersecting(monthGroup.dayGroups) as dayGroup, groupIndex (dayGroup.day)}
|
{#each filterIntersecting(monthGroup.dayGroups) as dayGroup, groupIndex (dayGroup.day)}
|
||||||
|
|
|
||||||
|
|
@ -55,7 +55,7 @@ export function calculateMonthGroupIntersecting(
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Calculate intersection for viewer assets with additional parameters like header height and scroll compensation
|
* Calculate intersection for viewer assets with additional parameters like header height
|
||||||
*/
|
*/
|
||||||
export function calculateViewerAssetIntersecting(
|
export function calculateViewerAssetIntersecting(
|
||||||
timelineManager: TimelineManager,
|
timelineManager: TimelineManager,
|
||||||
|
|
@ -64,12 +64,8 @@ export function calculateViewerAssetIntersecting(
|
||||||
expandTop: number = INTERSECTION_EXPAND_TOP,
|
expandTop: number = INTERSECTION_EXPAND_TOP,
|
||||||
expandBottom: number = INTERSECTION_EXPAND_BOTTOM,
|
expandBottom: number = INTERSECTION_EXPAND_BOTTOM,
|
||||||
) {
|
) {
|
||||||
const scrollCompensationHeightDelta = timelineManager.scrollCompensation?.heightDelta ?? 0;
|
const topWindow = timelineManager.visibleWindow.top - timelineManager.headerHeight - expandTop;
|
||||||
|
const bottomWindow = timelineManager.visibleWindow.bottom + timelineManager.headerHeight + expandBottom;
|
||||||
const topWindow =
|
|
||||||
timelineManager.visibleWindow.top - timelineManager.headerHeight - expandTop + scrollCompensationHeightDelta;
|
|
||||||
const bottomWindow =
|
|
||||||
timelineManager.visibleWindow.bottom + timelineManager.headerHeight + expandBottom + scrollCompensationHeightDelta;
|
|
||||||
|
|
||||||
const positionBottom = positionTop + positionHeight;
|
const positionBottom = positionTop + positionHeight;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,6 @@ export class MonthGroup {
|
||||||
|
|
||||||
#initialCount: number = 0;
|
#initialCount: number = 0;
|
||||||
#sortOrder: AssetOrder = AssetOrder.Desc;
|
#sortOrder: AssetOrder = AssetOrder.Desc;
|
||||||
percent: number = $state(0);
|
|
||||||
|
|
||||||
assetsCount: number = $derived(
|
assetsCount: number = $derived(
|
||||||
this.isLoaded
|
this.isLoaded
|
||||||
|
|
@ -242,42 +241,31 @@ export class MonthGroup {
|
||||||
if (this.#height === height) {
|
if (this.#height === height) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const { timelineManager: store, percent } = this;
|
let needsIntersectionUpdate = false;
|
||||||
const index = store.months.indexOf(this);
|
const timelineManager = this.timelineManager;
|
||||||
|
const index = timelineManager.months.indexOf(this);
|
||||||
const heightDelta = height - this.#height;
|
const heightDelta = height - this.#height;
|
||||||
this.#height = height;
|
this.#height = height;
|
||||||
const prevMonthGroup = store.months[index - 1];
|
const prevMonthGroup = timelineManager.months[index - 1];
|
||||||
if (prevMonthGroup) {
|
if (prevMonthGroup) {
|
||||||
const newTop = prevMonthGroup.#top + prevMonthGroup.#height;
|
const newTop = prevMonthGroup.#top + prevMonthGroup.#height;
|
||||||
if (this.#top !== newTop) {
|
if (this.#top !== newTop) {
|
||||||
this.#top = newTop;
|
this.#top = newTop;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (let cursor = index + 1; cursor < store.months.length; cursor++) {
|
if (heightDelta === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (let cursor = index + 1; cursor < timelineManager.months.length; cursor++) {
|
||||||
const monthGroup = this.timelineManager.months[cursor];
|
const monthGroup = this.timelineManager.months[cursor];
|
||||||
const newTop = monthGroup.#top + heightDelta;
|
const newTop = monthGroup.#top + heightDelta;
|
||||||
if (monthGroup.#top !== newTop) {
|
if (monthGroup.#top !== newTop) {
|
||||||
monthGroup.#top = newTop;
|
monthGroup.#top = newTop;
|
||||||
|
needsIntersectionUpdate = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (store.topIntersectingMonthGroup) {
|
if (needsIntersectionUpdate) {
|
||||||
const currentIndex = store.months.indexOf(store.topIntersectingMonthGroup);
|
timelineManager.updateIntersections();
|
||||||
if (currentIndex > 0) {
|
|
||||||
if (index < currentIndex) {
|
|
||||||
store.scrollCompensation = {
|
|
||||||
heightDelta,
|
|
||||||
scrollTop: undefined,
|
|
||||||
monthGroup: this,
|
|
||||||
};
|
|
||||||
} else if (percent > 0) {
|
|
||||||
const top = this.top + height * percent;
|
|
||||||
store.scrollCompensation = {
|
|
||||||
heightDelta: undefined,
|
|
||||||
scrollTop: top,
|
|
||||||
monthGroup: this,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -68,7 +68,7 @@ describe('TimelineManager', () => {
|
||||||
|
|
||||||
it('should load months in viewport', () => {
|
it('should load months in viewport', () => {
|
||||||
expect(sdkMock.getTimeBuckets).toBeCalledTimes(1);
|
expect(sdkMock.getTimeBuckets).toBeCalledTimes(1);
|
||||||
expect(sdkMock.getTimeBucket).toHaveBeenCalledTimes(2);
|
expect(sdkMock.getTimeBucket).toHaveBeenCalledTimes(3);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('calculates month height', () => {
|
it('calculates month height', () => {
|
||||||
|
|
@ -82,13 +82,13 @@ describe('TimelineManager', () => {
|
||||||
expect.arrayContaining([
|
expect.arrayContaining([
|
||||||
expect.objectContaining({ year: 2024, month: 3, height: 165.5 }),
|
expect.objectContaining({ year: 2024, month: 3, height: 165.5 }),
|
||||||
expect.objectContaining({ year: 2024, month: 2, height: 11_996 }),
|
expect.objectContaining({ year: 2024, month: 2, height: 11_996 }),
|
||||||
expect.objectContaining({ year: 2024, month: 1, height: 286 }),
|
expect.objectContaining({ year: 2024, month: 1, height: 48 }),
|
||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('calculates timeline height', () => {
|
it('calculates timeline height', () => {
|
||||||
expect(timelineManager.timelineHeight).toBe(12_447.5);
|
expect(timelineManager.timelineHeight).toBe(12_209.5);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||||
import { CancellableTask } from '$lib/utils/cancellable-task';
|
import { CancellableTask } from '$lib/utils/cancellable-task';
|
||||||
import { toTimelineAsset, type TimelineDateTime, type TimelineYearMonth } from '$lib/utils/timeline-util';
|
import { toTimelineAsset, type TimelineDateTime, type TimelineYearMonth } from '$lib/utils/timeline-util';
|
||||||
|
|
||||||
import { clamp, debounce, isEqual } from 'lodash-es';
|
import { debounce, isEqual } from 'lodash-es';
|
||||||
import { SvelteDate, SvelteMap, SvelteSet } from 'svelte/reactivity';
|
import { SvelteDate, SvelteMap, SvelteSet } from 'svelte/reactivity';
|
||||||
|
|
||||||
import { updateIntersectionMonthGroup } from '$lib/managers/timeline-manager/internal/intersection-support.svelte';
|
import { updateIntersectionMonthGroup } from '$lib/managers/timeline-manager/internal/intersection-support.svelte';
|
||||||
|
|
@ -49,8 +49,6 @@ export class TimelineManager {
|
||||||
scrubberMonths: ScrubberMonth[] = $state([]);
|
scrubberMonths: ScrubberMonth[] = $state([]);
|
||||||
scrubberTimelineHeight: number = $state(0);
|
scrubberTimelineHeight: number = $state(0);
|
||||||
|
|
||||||
topIntersectingMonthGroup: MonthGroup | undefined = $state();
|
|
||||||
|
|
||||||
visibleWindow = $derived.by(() => ({
|
visibleWindow = $derived.by(() => ({
|
||||||
top: this.#scrollTop,
|
top: this.#scrollTop,
|
||||||
bottom: this.#scrollTop + this.viewportHeight,
|
bottom: this.#scrollTop + this.viewportHeight,
|
||||||
|
|
@ -87,15 +85,6 @@ export class TimelineManager {
|
||||||
#suspendTransitions = $state(false);
|
#suspendTransitions = $state(false);
|
||||||
#resetScrolling = debounce(() => (this.#scrolling = false), 1000);
|
#resetScrolling = debounce(() => (this.#scrolling = false), 1000);
|
||||||
#resetSuspendTransitions = debounce(() => (this.suspendTransitions = false), 1000);
|
#resetSuspendTransitions = debounce(() => (this.suspendTransitions = false), 1000);
|
||||||
scrollCompensation: {
|
|
||||||
heightDelta: number | undefined;
|
|
||||||
scrollTop: number | undefined;
|
|
||||||
monthGroup: MonthGroup | undefined;
|
|
||||||
} = $state({
|
|
||||||
heightDelta: 0,
|
|
||||||
scrollTop: 0,
|
|
||||||
monthGroup: undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
constructor() {}
|
constructor() {}
|
||||||
|
|
||||||
|
|
@ -241,38 +230,12 @@ export class TimelineManager {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
clearScrollCompensation() {
|
|
||||||
this.scrollCompensation = {
|
|
||||||
heightDelta: undefined,
|
|
||||||
scrollTop: undefined,
|
|
||||||
monthGroup: undefined,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
updateIntersections() {
|
updateIntersections() {
|
||||||
if (!this.isInitialized || this.visibleWindow.bottom === this.visibleWindow.top) {
|
if (!this.isInitialized || this.visibleWindow.bottom === this.visibleWindow.top) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let topIntersectingMonthGroup = undefined;
|
|
||||||
for (const month of this.months) {
|
for (const month of this.months) {
|
||||||
updateIntersectionMonthGroup(this, month);
|
updateIntersectionMonthGroup(this, month);
|
||||||
if (!topIntersectingMonthGroup && month.actuallyIntersecting) {
|
|
||||||
topIntersectingMonthGroup = month;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (topIntersectingMonthGroup !== undefined && this.topIntersectingMonthGroup !== topIntersectingMonthGroup) {
|
|
||||||
this.topIntersectingMonthGroup = topIntersectingMonthGroup;
|
|
||||||
}
|
|
||||||
for (const month of this.months) {
|
|
||||||
if (month === this.topIntersectingMonthGroup) {
|
|
||||||
this.topIntersectingMonthGroup.percent = clamp(
|
|
||||||
(this.visibleWindow.top - this.topIntersectingMonthGroup.top) / this.topIntersectingMonthGroup.height,
|
|
||||||
0,
|
|
||||||
1,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
month.percent = 0;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -401,10 +364,10 @@ export class TimelineManager {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await monthGroup.loader?.execute(async (signal: AbortSignal) => {
|
const executionStatus = await monthGroup.loader?.execute(async (signal: AbortSignal) => {
|
||||||
await loadFromTimeBuckets(this, monthGroup, this.#options, signal);
|
await loadFromTimeBuckets(this, monthGroup, this.#options, signal);
|
||||||
}, cancelable);
|
}, cancelable);
|
||||||
if (result === 'LOADED') {
|
if (executionStatus === 'LOADED') {
|
||||||
updateIntersectionMonthGroup(this, monthGroup);
|
updateIntersectionMonthGroup(this, monthGroup);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue