mirror of
https://github.com/immich-app/immich.git
synced 2025-09-29 15:31:13 -04:00
fix: back/forward navigation won't reset scroll in timeline
This commit is contained in:
parent
25dbb60574
commit
0a18d9c35e
@ -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) {
|
||||
|
@ -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
|
||||
|
@ -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>
|
||||
|
@ -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}
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
}}
|
||||
|
@ -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()) {
|
||||
|
@ -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() {
|
||||
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -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];
|
||||
|
Loading…
x
Reference in New Issue
Block a user