mirror of
https://github.com/immich-app/immich.git
synced 2025-10-31 18:47:09 -04:00
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:
parent
e8ca7f235c
commit
146973b072
@ -89,10 +89,10 @@
|
|||||||
|
|
||||||
let { isViewing: showAssetViewer, asset: viewingAsset, gridScrollTarget } = assetViewingStore;
|
let { isViewing: showAssetViewer, asset: viewingAsset, gridScrollTarget } = assetViewingStore;
|
||||||
|
|
||||||
let element: HTMLElement | undefined = $state();
|
let scrollableElement: HTMLElement | undefined = $state();
|
||||||
|
|
||||||
let timelineElement: 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.
|
// 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.
|
// Note: There may be multiple months visible within the viewport at any given time.
|
||||||
let viewportTopMonthScrollPercent = $state(0);
|
let viewportTopMonthScrollPercent = $state(0);
|
||||||
@ -124,29 +124,22 @@
|
|||||||
timelineManager.setLayoutOptions(layoutOptions);
|
timelineManager.setLayoutOptions(layoutOptions);
|
||||||
});
|
});
|
||||||
|
|
||||||
const scrollTo = (top: number) => {
|
$effect(() => {
|
||||||
if (element) {
|
timelineManager.scrollableElement = scrollableElement;
|
||||||
element.scrollTo({ top });
|
});
|
||||||
}
|
|
||||||
};
|
|
||||||
const scrollTop = (top: number) => {
|
|
||||||
if (element) {
|
|
||||||
element.scrollTop = top;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const scrollToTop = () => {
|
const scrollToTop = () => {
|
||||||
scrollTo(0);
|
timelineManager.scrollTo(0);
|
||||||
};
|
};
|
||||||
|
|
||||||
const getAssetHeight = (assetId: string, monthGroup: MonthGroup) => monthGroup.findAssetAbsolutePosition(assetId);
|
const getAssetHeight = (assetId: string, monthGroup: MonthGroup) => monthGroup.findAssetAbsolutePosition(assetId);
|
||||||
|
|
||||||
const assetIsVisible = (assetTop: number): boolean => {
|
const assetIsVisible = (assetTop: number): boolean => {
|
||||||
if (!element) {
|
if (!scrollableElement) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { clientHeight, scrollTop } = element;
|
const { clientHeight, scrollTop } = scrollableElement;
|
||||||
return assetTop >= scrollTop && assetTop < scrollTop + clientHeight;
|
return assetTop >= scrollTop && assetTop < scrollTop + clientHeight;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -163,8 +156,7 @@
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
scrollTo(height);
|
timelineManager.scrollTo(height);
|
||||||
updateSlidingWindow();
|
|
||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -174,8 +166,7 @@
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const height = getAssetHeight(asset.id, monthGroup);
|
const height = getAssetHeight(asset.id, monthGroup);
|
||||||
scrollTo(height);
|
timelineManager.scrollTo(height);
|
||||||
updateSlidingWindow();
|
|
||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -189,7 +180,7 @@
|
|||||||
// if the asset is not found, scroll to the top
|
// if the asset is not found, scroll to the top
|
||||||
scrollToTop();
|
scrollToTop();
|
||||||
}
|
}
|
||||||
showSkeleton = false;
|
invisible = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
beforeNavigate(() => (timelineManager.suspendTransitions = true));
|
beforeNavigate(() => (timelineManager.suspendTransitions = true));
|
||||||
@ -216,7 +207,7 @@
|
|||||||
} else {
|
} else {
|
||||||
scrollToTop();
|
scrollToTop();
|
||||||
}
|
}
|
||||||
showSkeleton = false;
|
invisible = false;
|
||||||
}, 500);
|
}, 500);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@ -230,13 +221,12 @@
|
|||||||
|
|
||||||
const updateIsScrolling = () => (timelineManager.scrolling = true);
|
const updateIsScrolling = () => (timelineManager.scrolling = true);
|
||||||
// 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 topSectionResizeObserver: OnResizeCallback = ({ height }) => (timelineManager.topSectionHeight = height);
|
const topSectionResizeObserver: OnResizeCallback = ({ height }) => (timelineManager.topSectionHeight = height);
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
if (!enableRouting) {
|
if (!enableRouting) {
|
||||||
showSkeleton = false;
|
invisible = false;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -246,11 +236,13 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
const getMaxScroll = () => {
|
const getMaxScroll = () => {
|
||||||
if (!element || !timelineElement) {
|
if (!scrollableElement || !timelineElement) {
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
return (
|
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 delta = monthGroup.height * monthGroupScrollPercent;
|
||||||
const scrollToTop = (topOffset + delta) * maxScrollPercent;
|
const scrollToTop = (topOffset + delta) * maxScrollPercent;
|
||||||
|
|
||||||
scrollTop(scrollToTop);
|
timelineManager.scrollTo(scrollToTop);
|
||||||
};
|
};
|
||||||
|
|
||||||
// note: don't throttle, debounce, or otherwise make this function async - it causes flicker
|
// 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
|
// edge case - scroll limited due to size of content, must adjust - use use the overall percent instead
|
||||||
const maxScroll = getMaxScroll();
|
const maxScroll = getMaxScroll();
|
||||||
const offset = maxScroll * overallScrollPercent;
|
const offset = maxScroll * overallScrollPercent;
|
||||||
scrollTop(offset);
|
timelineManager.scrollTo(offset);
|
||||||
} else {
|
} else {
|
||||||
const monthGroup = timelineManager.months.find(
|
const monthGroup = timelineManager.months.find(
|
||||||
({ yearMonth: { year, month } }) => year === scrubberMonth.year && month === scrubberMonth.month,
|
({ yearMonth: { year, month } }) => year === scrubberMonth.year && month === scrubberMonth.month,
|
||||||
@ -288,26 +280,26 @@
|
|||||||
const handleTimelineScroll = () => {
|
const handleTimelineScroll = () => {
|
||||||
isInLeadOutSection = false;
|
isInLeadOutSection = false;
|
||||||
|
|
||||||
if (!element) {
|
if (!scrollableElement) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (timelineManager.timelineHeight < timelineManager.viewportHeight * 2) {
|
if (timelineManager.timelineHeight < timelineManager.viewportHeight * 2) {
|
||||||
// edge case - scroll limited due to size of content, must adjust - use the overall percent instead
|
// edge case - scroll limited due to size of content, must adjust - use the overall percent instead
|
||||||
const maxScroll = getMaxScroll();
|
const maxScroll = getMaxScroll();
|
||||||
timelineScrollPercent = Math.min(1, element.scrollTop / maxScroll);
|
timelineScrollPercent = Math.min(1, scrollableElement.scrollTop / maxScroll);
|
||||||
|
|
||||||
viewportTopMonth = undefined;
|
viewportTopMonth = undefined;
|
||||||
viewportTopMonthScrollPercent = 0;
|
viewportTopMonthScrollPercent = 0;
|
||||||
} else {
|
} else {
|
||||||
let top = element.scrollTop;
|
let top = scrollableElement.scrollTop;
|
||||||
if (top < timelineManager.topSectionHeight) {
|
if (top < timelineManager.topSectionHeight) {
|
||||||
// in the lead-in area
|
// in the lead-in area
|
||||||
viewportTopMonth = undefined;
|
viewportTopMonth = undefined;
|
||||||
viewportTopMonthScrollPercent = 0;
|
viewportTopMonthScrollPercent = 0;
|
||||||
const maxScroll = getMaxScroll();
|
const maxScroll = getMaxScroll();
|
||||||
|
|
||||||
timelineScrollPercent = Math.min(1, element.scrollTop / maxScroll);
|
timelineScrollPercent = Math.min(1, scrollableElement.scrollTop / maxScroll);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -414,7 +406,7 @@
|
|||||||
onSelect(asset);
|
onSelect(asset);
|
||||||
|
|
||||||
if (singleSelect) {
|
if (singleSelect) {
|
||||||
scrollTop(0);
|
timelineManager.scrollTo(0);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -564,10 +556,10 @@
|
|||||||
if (evt.key === 'ArrowUp') {
|
if (evt.key === 'ArrowUp') {
|
||||||
amount = -amount;
|
amount = -amount;
|
||||||
if (shiftKeyIsDown) {
|
if (shiftKeyIsDown) {
|
||||||
element?.scrollBy({ top: amount, behavior: 'smooth' });
|
scrollableElement?.scrollBy({ top: amount, behavior: 'smooth' });
|
||||||
}
|
}
|
||||||
} else if (evt.key === 'ArrowDown') {
|
} 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'}
|
style:margin-right={(usingMobileDevice ? 0 : scrubberWidth) + 'px'}
|
||||||
tabindex="-1"
|
tabindex="-1"
|
||||||
bind:clientHeight={timelineManager.viewportHeight}
|
bind:clientHeight={timelineManager.viewportHeight}
|
||||||
bind:clientWidth={null, (v: number) => ((timelineManager.viewportWidth = v), updateSlidingWindow())}
|
bind:clientWidth={timelineManager.viewportWidth}
|
||||||
bind:this={element}
|
bind:this={scrollableElement}
|
||||||
onscroll={() => (handleTimelineScroll(), updateSlidingWindow(), updateIsScrolling())}
|
onscroll={() => (handleTimelineScroll(), timelineManager.updateSlidingWindow(), updateIsScrolling())}
|
||||||
>
|
>
|
||||||
<section
|
<section
|
||||||
bind:this={timelineElement}
|
bind:this={timelineElement}
|
||||||
id="virtual-timeline"
|
id="virtual-timeline"
|
||||||
class:invisible={showSkeleton}
|
class:invisible
|
||||||
style:height={timelineManager.timelineHeight + 'px'}
|
style:height={timelineManager.timelineHeight + 'px'}
|
||||||
>
|
>
|
||||||
<section
|
<section
|
||||||
use:resizeObserver={topSectionResizeObserver}
|
use:resizeObserver={topSectionResizeObserver}
|
||||||
class:invisible={showSkeleton}
|
class:invisible
|
||||||
style:position="absolute"
|
style:position="absolute"
|
||||||
style:left="0"
|
style:left="0"
|
||||||
style:right="0"
|
style:right="0"
|
||||||
@ -615,10 +607,7 @@
|
|||||||
style:transform={`translate3d(0,${absoluteHeight}px,0)`}
|
style:transform={`translate3d(0,${absoluteHeight}px,0)`}
|
||||||
style:width="100%"
|
style:width="100%"
|
||||||
>
|
>
|
||||||
<Skeleton
|
<Skeleton {invisible} height={monthGroup.height} title={monthGroup.monthGroupTitle} />
|
||||||
height={monthGroup.height - monthGroup.timelineManager.headerHeight}
|
|
||||||
title={monthGroup.monthGroupTitle}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
{:else if display}
|
{:else if display}
|
||||||
<div
|
<div
|
||||||
@ -658,7 +647,7 @@
|
|||||||
|
|
||||||
<Portal target="body">
|
<Portal target="body">
|
||||||
{#if $showAssetViewer}
|
{#if $showAssetViewer}
|
||||||
<TimelineAssetViewer bind:showSkeleton {timelineManager} {removeAction} {withStacked} {isShared} {album} {person} />
|
<TimelineAssetViewer bind:invisible {timelineManager} {removeAction} {withStacked} {isShared} {album} {person} />
|
||||||
{/if}
|
{/if}
|
||||||
</Portal>
|
</Portal>
|
||||||
|
|
||||||
|
|||||||
@ -13,7 +13,7 @@
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
timelineManager: TimelineManager;
|
timelineManager: TimelineManager;
|
||||||
showSkeleton: boolean;
|
invisible: boolean;
|
||||||
withStacked?: boolean;
|
withStacked?: boolean;
|
||||||
isShared?: boolean;
|
isShared?: boolean;
|
||||||
album?: AlbumResponseDto | null;
|
album?: AlbumResponseDto | null;
|
||||||
@ -30,7 +30,7 @@
|
|||||||
|
|
||||||
let {
|
let {
|
||||||
timelineManager,
|
timelineManager,
|
||||||
showSkeleton = $bindable(false),
|
invisible = $bindable(false),
|
||||||
removeAction,
|
removeAction,
|
||||||
withStacked = false,
|
withStacked = false,
|
||||||
isShared = false,
|
isShared = false,
|
||||||
@ -81,7 +81,7 @@
|
|||||||
|
|
||||||
const handleClose = async (asset: { id: string }) => {
|
const handleClose = async (asset: { id: string }) => {
|
||||||
assetViewingStore.showAssetViewer(false);
|
assetViewingStore.showAssetViewer(false);
|
||||||
showSkeleton = true;
|
invisible = true;
|
||||||
$gridScrollTarget = { at: asset.id };
|
$gridScrollTarget = { at: asset.id };
|
||||||
await navigate({ targetRoute: 'current', assetId: null, assetGridRouteSearchParams: $gridScrollTarget });
|
await navigate({ targetRoute: 'current', assetId: null, assetGridRouteSearchParams: $gridScrollTarget });
|
||||||
};
|
};
|
||||||
|
|||||||
@ -14,7 +14,7 @@
|
|||||||
|
|
||||||
import { fromTimelinePlainDate, getDateLocaleString } from '$lib/utils/timeline-util';
|
import { fromTimelinePlainDate, getDateLocaleString } from '$lib/utils/timeline-util';
|
||||||
import { Icon } from '@immich/ui';
|
import { Icon } from '@immich/ui';
|
||||||
import type { Snippet } from 'svelte';
|
import { type Snippet } from 'svelte';
|
||||||
import { flip } from 'svelte/animate';
|
import { flip } from 'svelte/animate';
|
||||||
import { scale } from 'svelte/transition';
|
import { scale } from 'svelte/transition';
|
||||||
|
|
||||||
|
|||||||
@ -2,24 +2,21 @@
|
|||||||
interface Props {
|
interface Props {
|
||||||
height: number;
|
height: number;
|
||||||
title?: string;
|
title?: string;
|
||||||
|
invisible?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { height = 0, title }: Props = $props();
|
let { height = 0, title, invisible = false }: Props = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="overflow-clip" style:height={height + 'px'}>
|
<div class={['overflow-clip', { invisible }]} style:height={height + 'px'}>
|
||||||
{#if title}
|
{#if title}
|
||||||
<div
|
<div
|
||||||
class="flex pt-7 pb-5 h-6 place-items-center text-xs font-medium text-immich-fg bg-light dark:text-immich-dark-fg md:text-sm"
|
class="flex pt-7 pb-5 max-md:pt-5 max-md:pb-3 h-6 place-items-center text-xs font-medium text-immich-fg dark:text-immich-dark-fg md:text-sm"
|
||||||
>
|
>
|
||||||
{title}
|
{title}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
<div
|
<div class="animate-pulse h-full w-full" data-skeleton="true"></div>
|
||||||
class="animate-pulse absolute h-full ms-[10px] me-[10px]"
|
|
||||||
style:width="calc(100% - 20px)"
|
|
||||||
data-skeleton="true"
|
|
||||||
></div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@ -47,4 +44,7 @@
|
|||||||
0s linear 0.1s forwards delayedVisibility,
|
0s linear 0.1s forwards delayedVisibility,
|
||||||
pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
||||||
}
|
}
|
||||||
|
.invisible [data-skeleton] {
|
||||||
|
visibility: hidden !important;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@ -4,7 +4,6 @@ import { getTimeBucket } from '@immich/sdk';
|
|||||||
import type { MonthGroup } from '../month-group.svelte';
|
import type { MonthGroup } from '../month-group.svelte';
|
||||||
import type { TimelineManager } from '../timeline-manager.svelte';
|
import type { TimelineManager } from '../timeline-manager.svelte';
|
||||||
import type { TimelineManagerOptions } from '../types';
|
import type { TimelineManagerOptions } from '../types';
|
||||||
import { layoutMonthGroup } from './layout-support.svelte';
|
|
||||||
|
|
||||||
export async function loadFromTimeBuckets(
|
export async function loadFromTimeBuckets(
|
||||||
timelineManager: TimelineManager,
|
timelineManager: TimelineManager,
|
||||||
@ -55,6 +54,4 @@ export async function loadFromTimeBuckets(
|
|||||||
)}`,
|
)}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
layoutMonthGroup(timelineManager, monthGroup);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -36,6 +36,7 @@ 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
|
||||||
@ -241,7 +242,6 @@ export class MonthGroup {
|
|||||||
if (this.#height === height) {
|
if (this.#height === height) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let needsIntersectionUpdate = false;
|
|
||||||
const timelineManager = this.timelineManager;
|
const timelineManager = this.timelineManager;
|
||||||
const index = timelineManager.months.indexOf(this);
|
const index = timelineManager.months.indexOf(this);
|
||||||
const heightDelta = height - this.#height;
|
const heightDelta = height - this.#height;
|
||||||
@ -261,11 +261,21 @@ export class MonthGroup {
|
|||||||
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 (needsIntersectionUpdate) {
|
if (!timelineManager.viewportTopMonthIntersection) {
|
||||||
timelineManager.updateIntersections();
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import { AbortError } from '$lib/utils';
|
|||||||
import { fromISODateTimeUTCToObject } from '$lib/utils/timeline-util';
|
import { fromISODateTimeUTCToObject } from '$lib/utils/timeline-util';
|
||||||
import { type AssetResponseDto, type TimeBucketAssetResponseDto } from '@immich/sdk';
|
import { type AssetResponseDto, type TimeBucketAssetResponseDto } from '@immich/sdk';
|
||||||
import { timelineAssetFactory, toResponseDto } from '@test-data/factories/asset-factory';
|
import { timelineAssetFactory, toResponseDto } from '@test-data/factories/asset-factory';
|
||||||
|
import { tick } from 'svelte';
|
||||||
import { TimelineManager } from './timeline-manager.svelte';
|
import { TimelineManager } from './timeline-manager.svelte';
|
||||||
import type { TimelineAsset } from './types';
|
import type { TimelineAsset } from './types';
|
||||||
|
|
||||||
@ -64,11 +65,12 @@ describe('TimelineManager', () => {
|
|||||||
|
|
||||||
sdkMock.getTimeBucket.mockImplementation(({ timeBucket }) => Promise.resolve(bucketAssetsResponse[timeBucket]));
|
sdkMock.getTimeBucket.mockImplementation(({ timeBucket }) => Promise.resolve(bucketAssetsResponse[timeBucket]));
|
||||||
await timelineManager.updateViewport({ width: 1588, height: 1000 });
|
await timelineManager.updateViewport({ width: 1588, height: 1000 });
|
||||||
|
await tick();
|
||||||
});
|
});
|
||||||
|
|
||||||
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(3);
|
expect(sdkMock.getTimeBucket).toHaveBeenCalledTimes(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('calculates month height', () => {
|
it('calculates month height', () => {
|
||||||
@ -82,13 +84,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: 48 }),
|
expect.objectContaining({ year: 2024, month: 1, height: 286 }),
|
||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('calculates timeline height', () => {
|
it('calculates timeline height', () => {
|
||||||
expect(timelineManager.timelineHeight).toBe(12_209.5);
|
expect(timelineManager.timelineHeight).toBe(12_447.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 { debounce, isEqual } from 'lodash-es';
|
import { clamp, 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';
|
||||||
@ -37,6 +37,13 @@ import type {
|
|||||||
Viewport,
|
Viewport,
|
||||||
} from './types';
|
} 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 {
|
export class TimelineManager {
|
||||||
isInitialized = $state(false);
|
isInitialized = $state(false);
|
||||||
months: MonthGroup[] = $state([]);
|
months: MonthGroup[] = $state([]);
|
||||||
@ -49,6 +56,8 @@ export class TimelineManager {
|
|||||||
scrubberMonths: ScrubberMonth[] = $state([]);
|
scrubberMonths: ScrubberMonth[] = $state([]);
|
||||||
scrubberTimelineHeight: number = $state(0);
|
scrubberTimelineHeight: number = $state(0);
|
||||||
|
|
||||||
|
viewportTopMonthIntersection: ViewportTopMonthIntersection | undefined;
|
||||||
|
|
||||||
visibleWindow = $derived.by(() => ({
|
visibleWindow = $derived.by(() => ({
|
||||||
top: this.#scrollTop,
|
top: this.#scrollTop,
|
||||||
bottom: this.#scrollTop + this.viewportHeight,
|
bottom: this.#scrollTop + this.viewportHeight,
|
||||||
@ -85,6 +94,8 @@ 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);
|
||||||
|
#updatingIntersections = false;
|
||||||
|
#scrollableElement: HTMLElement | undefined = $state();
|
||||||
|
|
||||||
constructor() {}
|
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) {
|
#setHeaderHeight(value: number) {
|
||||||
if (this.#headerHeight == value) {
|
if (this.#headerHeight == value) {
|
||||||
return false;
|
return false;
|
||||||
@ -161,7 +186,8 @@ export class TimelineManager {
|
|||||||
const changed = value !== this.#viewportWidth;
|
const changed = value !== this.#viewportWidth;
|
||||||
this.#viewportWidth = value;
|
this.#viewportWidth = value;
|
||||||
this.suspendTransitions = true;
|
this.suspendTransitions = true;
|
||||||
void this.#updateViewportGeometry(changed);
|
this.#updateViewportGeometry(changed);
|
||||||
|
this.updateSlidingWindow();
|
||||||
}
|
}
|
||||||
|
|
||||||
get viewportWidth() {
|
get viewportWidth() {
|
||||||
@ -223,20 +249,52 @@ export class TimelineManager {
|
|||||||
this.#websocketSupport = undefined;
|
this.#websocketSupport = undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
updateSlidingWindow(scrollTop: number) {
|
updateSlidingWindow() {
|
||||||
|
const scrollTop = this.#scrollableElement?.scrollTop ?? 0;
|
||||||
if (this.#scrollTop !== scrollTop) {
|
if (this.#scrollTop !== scrollTop) {
|
||||||
this.#scrollTop = scrollTop;
|
this.#scrollTop = scrollTop;
|
||||||
this.updateIntersections();
|
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() {
|
updateIntersections() {
|
||||||
if (!this.isInitialized || this.visibleWindow.bottom === this.visibleWindow.top) {
|
if (this.#updatingIntersections || !this.isInitialized || this.visibleWindow.bottom === this.visibleWindow.top) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
this.#updatingIntersections = true;
|
||||||
|
|
||||||
for (const month of this.months) {
|
for (const month of this.months) {
|
||||||
updateIntersectionMonthGroup(this, month);
|
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) {
|
clearDeferredLayout(month: MonthGroup) {
|
||||||
@ -368,7 +426,8 @@ export class TimelineManager {
|
|||||||
await loadFromTimeBuckets(this, monthGroup, this.#options, signal);
|
await loadFromTimeBuckets(this, monthGroup, this.#options, signal);
|
||||||
}, cancelable);
|
}, cancelable);
|
||||||
if (executionStatus === 'LOADED') {
|
if (executionStatus === 'LOADED') {
|
||||||
updateIntersectionMonthGroup(this, monthGroup);
|
updateGeometry(this, monthGroup, { invalidateHeight: false });
|
||||||
|
this.updateIntersections();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user