@ -1,6 +1,6 @@
< script lang = "ts" >
import { afterNavigate , beforeNavigate } from '$app/navigation';
import { page } from '$app/stores ';
import { page } from '$app/state ';
import { resizeObserver , type OnResizeCallback } from '$lib/actions/resize-observer';
import Scrubber from '$lib/components/timeline/Scrubber.svelte';
import TimelineAssetViewer from '$lib/components/timeline/TimelineAssetViewer.svelte';
@ -10,6 +10,7 @@
import Portal from '$lib/elements/Portal.svelte';
import Skeleton from '$lib/elements/Skeleton.svelte';
import type { DayGroup } from '$lib/managers/timeline-manager/day-group.svelte';
import { isIntersecting } from '$lib/managers/timeline-manager/internal/intersection-support.svelte';
import type { MonthGroup } from '$lib/managers/timeline-manager/month-group.svelte';
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
import type { TimelineAsset , TimelineManagerOptions , ViewportTopMonth } from '$lib/managers/timeline-manager/types';
@ -18,7 +19,7 @@
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { isSelectingAllAssets } from '$lib/stores/assets-store.svelte';
import { mobileDevice } from '$lib/stores/mobile-device.svelte';
import { naviga te } from '$lib/utils/navigation';
import { isAssetViewerRou te } from '$lib/utils/navigation';
import { getTimes , type ScrubberListener } from '$lib/utils/timeline-util';
import { type AlbumResponseDto , type PersonResponseDto } from '@immich/sdk';
import { DateTime } from 'luxon';
@ -129,38 +130,57 @@
timelineManager.scrollableElement = scrollableElement;
});
const scrollToTop = () => {
timelineManager.scrollTo(0);
};
const getAssetPosition = (assetId: string, monthGroup: MonthGroup) => monthGroup.findAssetAbsolutePosition(assetId);
const getAssetHeight = (assetId: string, monthGroup: MonthGroup) => monthGroup.findAssetAbsolutePosition(assetId);
const scrollToAssetPosition = (assetId: string, monthGroup: MonthGroup) => {
const position = getAssetPosition(assetId, monthGroup);
const assetIsVisible = (assetTop: number): boolean => {
if (!scrollableElement) {
return false;
if (!position) {
return;
}
// Need to update window positions/intersections because < Portal > may have
// gone from invisible to visible.
timelineManager.updateSlidingWindow();
const assetTop = position.top;
const assetBottom = position.top + position.height;
const visibleTop = timelineManager.visibleWindow.top;
const visibleBottom = timelineManager.visibleWindow.bottom;
// Check if the asset is already at least partially visible in the viewport
if (isIntersecting(assetTop, assetBottom, visibleTop, visibleBottom)) {
return;
}
const { clientHeight , scrollTop } = scrollableElement;
return assetTop >= scrollTop & & assetTop < scrollTop + clientHeight ;
const currentTop = scrollableElement?.scrollTop || 0;
const viewportHeight = visibleBottom - visibleTop;
// Calculate the minimum scroll needed to bring the asset into view.
// Compare two alignment strategies and choose whichever requires less scroll distance:
// 1. Align asset top with viewport top
// 2. Align asset bottom with viewport bottom
// Option 1: Scroll so the top of the asset is at the top of the viewport
const scrollToAlignTop = assetTop;
const distanceToAlignTop = Math.abs(scrollToAlignTop - currentTop);
// Option 2: Scroll so the bottom of the asset is at the bottom of the viewport
const scrollToAlignBottom = assetBottom - viewportHeight;
const distanceToAlignBottom = Math.abs(scrollToAlignBottom - currentTop);
// Choose whichever option requires the minimum scroll distance
const scrollTarget = distanceToAlignTop < distanceToAlignBottom ? scrollToAlignTop : scrollToAlignBottom ;
timelineManager.scrollTo(scrollTarget);
};
const scrollToAssetId = async (assetId: string) => {
const scrollAndLoadAsset = async (assetId: string) => {
const monthGroup = await timelineManager.findMonthGroupForAsset(assetId);
if (!monthGroup) {
return false;
}
const height = getAssetHeight(assetId, monthGroup);
// If the asset is already visible, then don't scroll.
if (assetIsVisible(height)) {
// need to update window positions/intersections because since the < Portal >
// went from invisible to visible
timelineManager.updateSlidingWindow();
return true;
}
timelineManager.scrollTo(height);
scrollToAssetPosition(assetId, monthGroup);
return true;
};
@ -169,52 +189,69 @@
if (!monthGroup) {
return false;
}
const height = getAssetHeight(asset.id, monthGroup);
timelineManager.scrollTo(height);
scrollToAssetPosition(asset.id, monthGroup);
return true;
};
const completeNav = async () => {
const scrollTarget = $gridScrollTarget?.at;
let scrolled = false;
if (scrollTarget) {
scrolled = await scrollToAssetId(scrollTarget);
export const scrollAfterNavigate = async ({ scrollToAssetQueryParam } : { scrollToAssetQueryParam : boolean } ) => {
if (timelineManager.viewportHeight === 0 || timelineManager.viewportWidth === 0) {
// this can happen if you do the following navigation order
// /photos?at=< id > , /photos/< id > , http://example.com, browser back, browser back
const rect = scrollableElement?.getBoundingClientRect();
if (rect) {
timelineManager.viewportHeight = rect.height;
timelineManager.viewportWidth = rect.width;
}
}
if (!scrolled) {
// if the asset is not found, scroll to the top
scrollToTop();
if (scrollToAssetQueryParam) {
const scrollTarget = $gridScrollTarget?.at;
let scrolled = false;
if (scrollTarget) {
scrolled = await scrollAndLoadAsset(scrollTarget);
}
if (!scrolled) {
// if the asset is not found, scroll to the top
timelineManager.scrollTo(0);
}
}
invisible = false;
};
beforeNavigate(() => (timelineManager.suspendTransitions = true));
afterNavigate((nav) => {
const { complete } = nav;
complete.then(completeNav, completeNav);
// note: only modified once in afterNavigate()
let initialLoadWasAssetViewer: boolean | null = null;
// only modified in beforeNavigate()
let hasNavigatedToOrFromAssetViewer: boolean = false;
// beforeNavigate is only called AFTER a svelte route has already been loaded
// and a new route is being navigated to. It will never be called on direct
// navigations by the browser.
beforeNavigate(({ from , to } ) => {
timelineManager.suspendTransitions = true;
const isNavigatingToAssetViewer = isAssetViewerRoute(to);
const isNavigatingFromAssetViewer = isAssetViewerRoute(from);
hasNavigatedToOrFromAssetViewer = isNavigatingToAssetViewer !== isNavigatingFromAssetViewer;
});
const handleAfterUpdate = (payload: UpdatePayload) => {
const timelineUpdate = payload.updates.some(
(update) => update.path.endsWith('Timeline.svelte') || update.path.endsWith('assets-store.ts'),
);
// afterNavigate is only called after navigation to a new URL, { complete } will resolve
// after successful navigation.
afterNavigate(({ complete } ) => {
void complete.finally(() => {
const isAssetViewerPage = isAssetViewerRoute(page);
// Set initial load state only once - if initialLoadWasAssetViewer is null, then
// this is a direct browser navigation.
const isDirectNavigation = initialLoadWasAssetViewer === null;
if (isDirectNavigation) {
initialLoadWasAssetViewer = isAssetViewerPage & & !hasNavigatedToOrFromAssetViewer;
}
if (timelineUpdate) {
setTimeout(() => {
const asset = $page.url.searchParams.get('at');
if (asset) {
$gridScrollTarget = { at : asset } ;
void navigate(
{ targetRoute : 'current' , assetId : null , assetGridRouteSearchParams : $gridScrollTarget } ,
{ replaceState : true , forceNavigate : true } ,
);
} else {
scrollToTop();
}
invisible = false;
}, 500);
}
};
const isDirectTimelineLoad = isDirectNavigation & & !isAssetViewerPage;
const isNavigatingFromAssetViewer = !isDirectNavigation & & hasNavigatedToOrFromAssetViewer;
const scrollToAssetQueryParam = isDirectTimelineLoad || isNavigatingFromAssetViewer;
void scrollAfterNavigate({ scrollToAssetQueryParam } );
});
});
const updateIsScrolling = () => (timelineManager.scrolling = true);
// note: don't throttle, debounch, or otherwise do this function async - it causes flicker
@ -235,6 +272,7 @@
timelineManager.scrollTo(scrollToTop);
};
// note: don't throttle, debounce, or otherwise make this function async - it causes flicker
// this function scrolls the timeline to the specified month group and offset, based on scrubber interaction
const onScrub: ScrubberListener = (scrubberData) => {
@ -500,7 +538,21 @@
< svelte:document onkeydown = { onKeyDown } onkeyup= { onKeyUp } />
< HotModuleReload onAfterUpdate = { handleAfterUpdate } onBeforeUpdate= {() => timelineManager . destroy ()} />
< HotModuleReload
onAfterUpdate={() => {
const asset = page.url.searchParams.get('at');
if (asset) {
$gridScrollTarget = { at : asset } ;
}
void scrollAfterNavigate({ scrollToAssetQueryParam : true } );
}}
onBeforeUpdate={( payload : UpdatePayload ) => {
const timelineUpdate = payload.updates.some((update) => update.path.endsWith('Timeline.svelte'));
if (timelineUpdate) {
timelineManager.destroy();
}
}}
/>
< TimelineKeyboardActions
scrollToAsset={( asset ) => scrollToAsset ( asset ) ?? false }