This commit is contained in:
Min Idzelis 2025-10-17 11:16:22 -05:00 committed by GitHub
commit a7ddca724a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 106 additions and 57 deletions

View file

@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { afterNavigate, beforeNavigate } from '$app/navigation'; 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 { resizeObserver, type OnResizeCallback } from '$lib/actions/resize-observer';
import Scrubber from '$lib/components/timeline/Scrubber.svelte'; import Scrubber from '$lib/components/timeline/Scrubber.svelte';
import TimelineAssetViewer from '$lib/components/timeline/TimelineAssetViewer.svelte'; import TimelineAssetViewer from '$lib/components/timeline/TimelineAssetViewer.svelte';
@ -10,6 +10,7 @@
import Portal from '$lib/elements/Portal.svelte'; import Portal from '$lib/elements/Portal.svelte';
import Skeleton from '$lib/elements/Skeleton.svelte'; import Skeleton from '$lib/elements/Skeleton.svelte';
import type { DayGroup } from '$lib/managers/timeline-manager/day-group.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 type { MonthGroup } from '$lib/managers/timeline-manager/month-group.svelte';
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte'; import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
import type { TimelineAsset, TimelineManagerOptions, ViewportTopMonth } from '$lib/managers/timeline-manager/types'; import type { TimelineAsset, TimelineManagerOptions, ViewportTopMonth } from '$lib/managers/timeline-manager/types';
@ -18,7 +19,7 @@
import { assetViewingStore } from '$lib/stores/asset-viewing.store'; import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { isSelectingAllAssets } from '$lib/stores/assets-store.svelte'; import { isSelectingAllAssets } from '$lib/stores/assets-store.svelte';
import { mobileDevice } from '$lib/stores/mobile-device.svelte'; import { mobileDevice } from '$lib/stores/mobile-device.svelte';
import { navigate } from '$lib/utils/navigation'; import { isAssetViewerRoute } from '$lib/utils/navigation';
import { getTimes, type ScrubberListener } from '$lib/utils/timeline-util'; import { getTimes, type ScrubberListener } from '$lib/utils/timeline-util';
import { type AlbumResponseDto, type PersonResponseDto } from '@immich/sdk'; import { type AlbumResponseDto, type PersonResponseDto } from '@immich/sdk';
import { DateTime } from 'luxon'; import { DateTime } from 'luxon';
@ -129,35 +130,54 @@
timelineManager.scrollableElement = scrollableElement; timelineManager.scrollableElement = scrollableElement;
}); });
const scrollToTop = () => { const getAssetPosition = (assetId: string, monthGroup: MonthGroup) => monthGroup.findAssetAbsolutePosition(assetId);
timelineManager.scrollTo(0);
};
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 (!position) {
if (!scrollableElement) { return;
return false;
} }
const { clientHeight, scrollTop } = scrollableElement; const assetTop = position.top;
return assetTop >= scrollTop && assetTop < scrollTop + clientHeight; 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)) {
// Asset is already visible, no scroll needed
return;
}
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); const monthGroup = await timelineManager.findMonthGroupForAsset(assetId);
if (!monthGroup) { if (!monthGroup) {
return false; return false;
} }
scrollToAssetPosition(assetId, monthGroup);
const height = getAssetHeight(assetId, monthGroup);
// If the asset is already visible, then don't scroll.
if (assetIsVisible(height)) {
return true;
}
timelineManager.scrollTo(height);
return true; return true;
}; };
@ -166,53 +186,78 @@
if (!monthGroup) { if (!monthGroup) {
return false; return false;
} }
const height = getAssetHeight(asset.id, monthGroup); scrollToAssetPosition(asset.id, monthGroup);
timelineManager.scrollTo(height);
return true; return true;
}; };
const completeNav = async () => { 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 (scrollToAssetQueryParam) {
const scrollTarget = $gridScrollTarget?.at; const scrollTarget = $gridScrollTarget?.at;
let scrolled = false; let scrolled = false;
if (scrollTarget) { if (scrollTarget) {
scrolled = await scrollToAssetId(scrollTarget); scrolled = await scrollAndLoadAsset(scrollTarget);
} }
if (!scrolled) { if (!scrolled) {
// if the asset is not found, scroll to the top // if the asset is not found, scroll to the top
scrollToTop(); timelineManager.scrollTo(0);
}
} }
invisible = false; invisible = false;
}; };
beforeNavigate(() => (timelineManager.suspendTransitions = true)); beforeNavigate(({ from, to }) => {
timelineManager.suspendTransitions = true;
afterNavigate((nav) => { const toViewer = isAssetViewerRoute(to);
const { complete } = nav; const fromViewer = isAssetViewerRoute(from);
complete.then(completeNav, completeNav); hasNavigatedToOrFromAssetViewer = (toViewer && !fromViewer) || (fromViewer && !toViewer);
}); });
const handleAfterUpdate = (payload: UpdatePayload) => { // tri-state boolean
const timelineUpdate = payload.updates.some( let initialLoadWasAssetViewer: boolean | null = null;
(update) => update.path.endsWith('Timeline.svelte') || update.path.endsWith('assets-store.ts'), let hasNavigatedToOrFromAssetViewer: boolean = false;
);
if (timelineUpdate) { const completeAfterNavigate = () => {
setTimeout(() => { const assetViewerPage = !!(page.route.id?.endsWith('/[[assetId=id]]') && page.params.assetId);
const asset = $page.url.searchParams.get('at'); let isInitial = false;
if (asset) { // Set initial load state only once
$gridScrollTarget = { at: asset }; if (initialLoadWasAssetViewer === null) {
void navigate( initialLoadWasAssetViewer = assetViewerPage && !hasNavigatedToOrFromAssetViewer;
{ targetRoute: 'current', assetId: null, assetGridRouteSearchParams: $gridScrollTarget }, isInitial = true;
{ replaceState: true, forceNavigate: true },
);
} else {
scrollToTop();
} }
invisible = false; let scrollToAssetQueryParam = false;
}, 500); if (
(isInitial && !assetViewerPage) || // Direct timeline load
(!isInitial && hasNavigatedToOrFromAssetViewer) // Navigated from asset viewer
) {
scrollToAssetQueryParam = true;
} }
return scrollAfterNavigate({ scrollToAssetQueryParam });
}; };
afterNavigate(({ complete }) => void complete.then(completeAfterNavigate, completeAfterNavigate));
const handleAfterUpdate = () => {
const asset = page.url.searchParams.get('at');
if (asset) {
$gridScrollTarget = { at: asset };
}
void scrollAfterNavigate({ scrollToAssetQueryParam: true });
};
const handleBeforeUpdate = (payload: UpdatePayload) => {
const timelineUpdate = payload.updates.some((update) => update.path.endsWith('Timeline.svelte'));
if (timelineUpdate) {
timelineManager.destroy();
}
};
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
@ -232,6 +277,7 @@
timelineManager.scrollTo(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
// this function scrolls the timeline to the specified month group and offset, based on scrubber interaction // this function scrolls the timeline to the specified month group and offset, based on scrubber interaction
const onScrub: ScrubberListener = (scrubberData) => { const onScrub: ScrubberListener = (scrubberData) => {
@ -497,7 +543,7 @@
<svelte:document onkeydown={onKeyDown} onkeyup={onKeyUp} /> <svelte:document onkeydown={onKeyDown} onkeyup={onKeyUp} />
<HotModuleReload onAfterUpdate={handleAfterUpdate} onBeforeUpdate={() => timelineManager.destroy()} /> <HotModuleReload onAfterUpdate={handleAfterUpdate} onBeforeUpdate={handleBeforeUpdate} />
<TimelineKeyboardActions <TimelineKeyboardActions
scrollToAsset={(asset) => scrollToAsset(asset) ?? false} scrollToAsset={(asset) => scrollToAsset(asset) ?? false}

View file

@ -313,10 +313,13 @@ export class MonthGroup {
console.warn('No position for asset'); console.warn('No position for asset');
break; break;
} }
return this.top + group.top + viewerAsset.position.top + this.timelineManager.headerHeight; return {
top: this.top + group.top + viewerAsset.position.top + this.timelineManager.headerHeight,
height: viewerAsset.position.height,
};
} }
} }
return -1; return undefined;
} }
*assetsIterator(options?: { startDayGroup?: DayGroup; startAsset?: TimelineAsset; direction?: Direction }) { *assetsIterator(options?: { startDayGroup?: DayGroup; startAsset?: TimelineAsset; direction?: Direction }) {