fix: back/forward navigation won't reset scroll in timeline

This commit is contained in:
midzelis 2025-09-26 00:08:44 +00:00
parent 25dbb60574
commit 0a18d9c35e
9 changed files with 122 additions and 80 deletions

View File

@ -1,6 +1,5 @@
<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 HotModuleReload from '$lib/elements/HotModuleReload.svelte';
import type { PhotostreamManager } from '$lib/managers/photostream-manager/PhotostreamManager.svelte';
@ -121,51 +120,41 @@
return height;
};
const assetIsVisible = (assetTop: number): boolean => {
if (!element) {
return false;
}
const { clientHeight, scrollTop } = element;
return assetTop >= scrollTop && assetTop < scrollTop + clientHeight;
};
export const scrollToAssetId = (assetId: string) => {
const monthGroup = timelineManager.getSegmentForAssetId(assetId);
export const scrollToAssetId = async (assetId: string) => {
const monthGroup = await timelineManager.findSegmentForAssetId(assetId);
if (!monthGroup) {
return false;
}
const height = getAssetHeight(assetId, monthGroup);
// If the asset is already visible, then don't scroll.
if (assetIsVisible(height)) {
return true;
}
scrollTo(height);
return true;
};
const completeNav = () => {
const scrollTarget = $gridScrollTarget?.at;
let scrolled = false;
if (scrollTarget) {
scrolled = scrollToAssetId(scrollTarget);
export const completeAfterNavigate = 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 = element?.getBoundingClientRect();
if (rect) {
timelineManager.viewportHeight = rect.height;
timelineManager.viewportWidth = rect.width;
}
}
if (!scrolled) {
// if the asset is not found, scroll to the top
scrollTo(0);
if (scrollToAssetQueryParam) {
const scrollTarget = $gridScrollTarget?.at;
let scrolled = false;
if (scrollTarget) {
scrolled = await scrollToAssetId(scrollTarget);
}
if (!scrolled) {
// if the asset is not found, scroll to the top
scrollTo(0);
}
}
showSkeleton = false;
};
beforeNavigate(() => (timelineManager.suspendTransitions = true));
afterNavigate((nav) => {
const { complete } = nav;
complete.then(completeNav, completeNav);
});
const updateIsScrolling = () => (timelineManager.scrolling = true);
// Yes, updateSlideWindow() is called by the onScroll event. However, if you also just scrolled
// by explicitly invoking element.scrollX functions, there may be a delay with enough time to
@ -191,11 +180,11 @@
// this handler will run the navigation/scroll-to-asset handler when hmr is performed,
// preventing skeleton from showing after hmr
const finishHmr = () => {
const asset = $page.url.searchParams.get('at');
const asset = page.url.searchParams.get('at');
if (asset) {
$gridScrollTarget = { at: asset };
}
void completeNav();
void completeAfterNavigate({ scrollToAssetQueryParam: true });
};
const assetGridUpdate = payload.updates.some((update) => update.path.endsWith('Photostream.svelte'));
if (assetGridUpdate) {

View File

@ -43,7 +43,7 @@
enableRouting,
timelineManager = $bindable(),
showSkeleton = $bindable(true),
showSkeleton = true,
isShowDeleteConfirmation = $bindable(false),
segment,
skeleton,
@ -160,7 +160,10 @@
handleScrollTop?.(scrollToTop);
};
let baseTimelineViewer: Photostream | undefined = $state();
export const scrollToAsset = (asset: TimelineAsset) => baseTimelineViewer?.scrollToAssetId(asset.id) ?? false;
export const scrollToAsset = async (asset: TimelineAsset) =>
(await baseTimelineViewer?.scrollToAssetId(asset.id)) ?? false;
export const completeAfterNavigate = (args: { scrollToAssetQueryParam: boolean }) =>
baseTimelineViewer?.completeAfterNavigate(args);
</script>
<Photostream

View File

@ -1,4 +1,6 @@
<script lang="ts">
import { afterNavigate, beforeNavigate } from '$app/navigation';
import { page } from '$app/state';
import Thumbnail from '$lib/components/assets/thumbnail/thumbnail.svelte';
import MonthSegment from '$lib/components/timeline/MonthSegment.svelte';
import PhotostreamWithScrubber from '$lib/components/timeline/PhotostreamWithScrubber.svelte';
@ -14,6 +16,7 @@
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { isAssetViewerRoute, navigate } from '$lib/utils/navigation';
import { getSegmentIdentifier, getTimes } from '$lib/utils/timeline-util';
import { type AlbumResponseDto, type PersonResponseDto } from '@immich/sdk';
import { DateTime } from 'luxon';
@ -71,11 +74,51 @@
customThumbnailLayout,
}: Props = $props();
let { isViewing: showAssetViewer, asset: viewingAsset } = assetViewingStore;
let { isViewing: showAssetViewer, asset: viewingAsset, gridScrollTarget } = assetViewingStore;
let viewer: PhotostreamWithScrubber | undefined = $state();
let showSkeleton: boolean = $state(true);
// tri-state boolean
let initialLoadWasAssetViewer: boolean | null = null;
let hasNavigatedToOrFromAssetViewer: boolean = false;
let timelineScrollPositionInitialized = false;
beforeNavigate(({ from, to }) => {
timelineManager.suspendTransitions = true;
hasNavigatedToOrFromAssetViewer = isAssetViewerRoute(to) || isAssetViewerRoute(from);
});
const completeAfterNavigate = () => {
const assetViewerPage = !!(page.route.id?.endsWith('/[[assetId=id]]') && page.params.assetId);
let isInitial = false;
// Set initial load state only once
if (initialLoadWasAssetViewer === null) {
initialLoadWasAssetViewer = assetViewerPage && !hasNavigatedToOrFromAssetViewer;
isInitial = true;
}
let scrollToAssetQueryParam = false;
if (
!timelineScrollPositionInitialized &&
((isInitial && !assetViewerPage) || // Direct timeline load
(!isInitial && hasNavigatedToOrFromAssetViewer)) // Navigated from asset viewer
) {
scrollToAssetQueryParam = true;
timelineScrollPositionInitialized = true;
}
return viewer?.completeAfterNavigate({ scrollToAssetQueryParam });
};
afterNavigate(({ complete }) => void complete.then(completeAfterNavigate, completeAfterNavigate));
const onViewerClose = async (asset: { id: string }) => {
assetViewingStore.showAssetViewer(false);
showSkeleton = true;
$gridScrollTarget = { at: asset.id };
await navigate({ targetRoute: 'current', assetId: null, assetGridRouteSearchParams: $gridScrollTarget });
};
$effect(() => {
if ($showAssetViewer) {
const { localDateTime } = getTimes($viewingAsset.fileCreatedAt, DateTime.local().offset / 60);
@ -85,7 +128,7 @@
</script>
<TimelineKeyboardActions
scrollToAsset={(asset) => viewer?.scrollToAsset(asset) ?? false}
scrollToAsset={async (asset) => (await viewer?.scrollToAsset(asset)) ?? Promise.resolve(false)}
{timelineManager}
{assetInteraction}
bind:isShowDeleteConfirmation
@ -165,6 +208,6 @@
<Portal target="body">
{#if $showAssetViewer}
<TimelineAssetViewer bind:showSkeleton {timelineManager} {removeAction} {withStacked} {isShared} {album} {person} />
<TimelineAssetViewer {timelineManager} {removeAction} {withStacked} {isShared} {album} {person} {onViewerClose} />
{/if}
</Portal>

View File

@ -9,15 +9,16 @@
import { toTimelineAsset } from '$lib/utils/timeline-util';
import { getAssetInfo, type AlbumResponseDto, type PersonResponseDto } from '@immich/sdk';
let { asset: viewingAsset, gridScrollTarget, mutex, preloadAssets } = assetViewingStore;
let { asset: viewingAsset, mutex, preloadAssets } = assetViewingStore;
interface Props {
timelineManager: TimelineManager;
showSkeleton: boolean;
withStacked?: boolean;
isShared?: boolean;
album?: AlbumResponseDto | null;
person?: PersonResponseDto | null;
onViewerClose?: (asset: { id: string }) => Promise<void>;
removeAction?:
| AssetAction.UNARCHIVE
@ -30,12 +31,12 @@
let {
timelineManager,
showSkeleton = $bindable(false),
removeAction,
withStacked = false,
isShared = false,
album = null,
person = null,
onViewerClose = () => Promise.resolve(void 0),
}: Props = $props();
const handlePrevious = async () => {
@ -79,13 +80,6 @@
}
};
const handleClose = async (asset: { id: string }) => {
assetViewingStore.showAssetViewer(false);
showSkeleton = true;
$gridScrollTarget = { at: asset.id };
await navigate({ targetRoute: 'current', assetId: null, assetGridRouteSearchParams: $gridScrollTarget });
};
const handlePreAction = async (action: Action) => {
switch (action.type) {
case removeAction:
@ -97,7 +91,7 @@
case AssetAction.SET_VISIBILITY_TIMELINE: {
// find the next asset to show or close the viewer
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
(await handleNext()) || (await handlePrevious()) || (await handleClose(action.asset));
(await handleNext()) || (await handlePrevious()) || (await onViewerClose?.(action.asset));
// delete after find the next one
timelineManager.removeAssets([action.asset.id]);
@ -172,6 +166,6 @@
onPrevious={handlePrevious}
onNext={handleNext}
onRandom={handleRandom}
onClose={handleClose}
onClose={onViewerClose}
/>
{/await}

View File

@ -7,8 +7,10 @@
type RelativeResult,
} from '$lib/components/shared-components/change-date.svelte';
import {
setFocusToAsset as setFocusAssetInit,
setFocusTo as setFocusToInit,
setFocusToAsset as setFocusAssetUtil,
setFocusTo as setFocusToUtil,
type FocusDirection,
type FocusInterval,
} from '$lib/components/timeline/actions/focus-actions';
import { AppRoute } from '$lib/constants';
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
@ -32,7 +34,7 @@
assetInteraction: AssetInteraction;
isShowDeleteConfirmation: boolean;
onEscape?: () => void;
scrollToAsset: (asset: TimelineAsset) => boolean;
scrollToAsset: (asset: TimelineAsset) => Promise<boolean>;
}
let {
@ -147,8 +149,10 @@
}
});
const setFocusTo = setFocusToInit.bind(undefined, scrollToAsset, timelineManager);
const setFocusAsset = setFocusAssetInit.bind(undefined, scrollToAsset);
const setFocusTo = (direction: FocusDirection, interval: FocusInterval) =>
setFocusToUtil(scrollToAsset, timelineManager, direction, interval);
const setFocusAsset = (asset: TimelineAsset) => setFocusAssetUtil(scrollToAsset, asset);
let shortcutList = $derived(
(() => {
@ -212,7 +216,7 @@
(DateTime.fromISO(dateString.date) as DateTime<true>).toObject(),
);
if (asset) {
setFocusAsset(asset);
void setFocusAsset(asset);
}
}
}}

View File

@ -21,19 +21,26 @@ export const focusPreviousAsset = () =>
const queryHTMLElement = (query: string) => document.querySelector(query) as HTMLElement;
export const setFocusToAsset = (scrollToAsset: (asset: TimelineAsset) => boolean, asset: TimelineAsset) => {
const scrolled = scrollToAsset(asset);
export const setFocusToAsset = async (
scrollToAsset: (asset: TimelineAsset) => Promise<boolean>,
asset: TimelineAsset,
) => {
const scrolled = await scrollToAsset(asset);
if (scrolled) {
const element = queryHTMLElement(`[data-thumbnail-focus-container][data-asset="${asset.id}"]`);
element?.focus();
}
};
export type FocusDirection = 'earlier' | 'later';
export type FocusInterval = 'day' | 'month' | 'year' | 'asset';
export const setFocusTo = async (
scrollToAsset: (asset: TimelineAsset) => boolean,
scrollToAsset: (asset: TimelineAsset) => Promise<boolean>,
store: TimelineManager,
direction: 'earlier' | 'later',
interval: 'day' | 'month' | 'year' | 'asset',
direction: FocusDirection,
interval: FocusInterval,
) => {
if (tracker.isActive()) {
// there are unfinished running invocations, so return early
@ -65,7 +72,10 @@ export const setFocusTo = async (
return;
}
const scrolled = scrollToAsset(asset);
const scrolled = await scrollToAsset(asset);
if (!invocation.isStillValid()) {
return;
}
if (scrolled) {
await tick();
if (!invocation.isStillValid()) {

View File

@ -269,13 +269,14 @@ export abstract class PhotostreamManager {
return this.months.find((segment) => identifier.matches(segment));
}
getSegmentForAssetId(assetId: string) {
findSegmentForAssetId(assetId: string): Promise<PhotostreamSegment | undefined> {
for (const month of this.months) {
const asset = month.assets.find((asset) => asset.id === assetId);
if (asset) {
return month;
return Promise.resolve(month);
}
}
return Promise.resolve(void 0);
}
refreshLayout() {

View File

@ -1,5 +1,8 @@
import { sdkMock } from '$lib/__mocks__/sdk.mock';
import { getMonthGroupByDate } from '$lib/managers/timeline-manager/internal/search-support.svelte';
import {
findMonthGroupForAsset,
getMonthGroupByDate,
} from '$lib/managers/timeline-manager/internal/search-support.svelte';
import { AbortError } from '$lib/utils';
import { fromISODateTimeUTCToObject, getSegmentIdentifier } from '$lib/utils/timeline-util';
import { type AssetResponseDto, type TimeBucketAssetResponseDto } from '@immich/sdk';
@ -556,10 +559,10 @@ describe('TimelineManager', () => {
);
timelineManager.addAssets([assetOne, assetTwo]);
expect(timelineManager.getMonthGroupByAssetId(assetTwo.id)?.yearMonth.year).toEqual(2024);
expect(timelineManager.getMonthGroupByAssetId(assetTwo.id)?.yearMonth.month).toEqual(2);
expect(timelineManager.getMonthGroupByAssetId(assetOne.id)?.yearMonth.year).toEqual(2024);
expect(timelineManager.getMonthGroupByAssetId(assetOne.id)?.yearMonth.month).toEqual(1);
expect(findMonthGroupForAsset(timelineManager, assetTwo.id)?.monthGroup.yearMonth.year).toEqual(2024);
expect(findMonthGroupForAsset(timelineManager, assetTwo.id)?.monthGroup.yearMonth.month).toEqual(2);
expect(findMonthGroupForAsset(timelineManager, assetOne.id)?.monthGroup.yearMonth.year).toEqual(2024);
expect(findMonthGroupForAsset(timelineManager, assetOne.id)?.monthGroup.yearMonth.month).toEqual(1);
});
it('ignores removed months', () => {
@ -576,8 +579,8 @@ describe('TimelineManager', () => {
timelineManager.addAssets([assetOne, assetTwo]);
timelineManager.removeAssets([assetTwo.id]);
expect(timelineManager.getMonthGroupByAssetId(assetOne.id)?.yearMonth.year).toEqual(2024);
expect(timelineManager.getMonthGroupByAssetId(assetOne.id)?.yearMonth.month).toEqual(1);
expect(findMonthGroupForAsset(timelineManager, assetOne.id)?.monthGroup.yearMonth.year).toEqual(2024);
expect(findMonthGroupForAsset(timelineManager, assetOne.id)?.monthGroup.yearMonth.month).toEqual(1);
});
});
});

View File

@ -187,7 +187,7 @@ export class TimelineManager extends PhotostreamManager {
addAssetsToMonthGroups(this, [...notUpdated], { order: this.#options.order ?? AssetOrder.Desc });
}
async findMonthGroupForAsset(id: string) {
async findSegmentForAssetId(id: string) {
if (!this.isInitialized) {
await this.initTask.waitUntilCompletion();
}
@ -218,11 +218,6 @@ export class TimelineManager extends PhotostreamManager {
return getMonthGroupByDate(this, yearMonth);
}
getMonthGroupByAssetId(assetId: string) {
const monthGroupInfo = findMonthGroupForAssetUtil(this, assetId);
return monthGroupInfo?.monthGroup;
}
async getRandomMonthGroup() {
const random = Math.floor(Math.random() * this.months.length);
const month = this.months[random];