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 <alex.tran1502@gmail.com>
This commit is contained in:
Min Idzelis 2025-10-13 22:16:05 -04:00 committed by GitHub
parent e8ca7f235c
commit 146973b072
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 128 additions and 71 deletions

View file

@ -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())}
>
<section
bind:this={timelineElement}
id="virtual-timeline"
class:invisible={showSkeleton}
class:invisible
style:height={timelineManager.timelineHeight + 'px'}
>
<section
use:resizeObserver={topSectionResizeObserver}
class:invisible={showSkeleton}
class:invisible
style:position="absolute"
style:left="0"
style:right="0"
@ -615,10 +607,7 @@
style:transform={`translate3d(0,${absoluteHeight}px,0)`}
style:width="100%"
>
<Skeleton
height={monthGroup.height - monthGroup.timelineManager.headerHeight}
title={monthGroup.monthGroupTitle}
/>
<Skeleton {invisible} height={monthGroup.height} title={monthGroup.monthGroupTitle} />
</div>
{:else if display}
<div
@ -658,7 +647,7 @@
<Portal target="body">
{#if $showAssetViewer}
<TimelineAssetViewer bind:showSkeleton {timelineManager} {removeAction} {withStacked} {isShared} {album} {person} />
<TimelineAssetViewer bind:invisible {timelineManager} {removeAction} {withStacked} {isShared} {album} {person} />
{/if}
</Portal>

View file

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

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