mirror of
https://github.com/immich-app/immich.git
synced 2025-10-29 17:52:36 -04:00
fix: back/forward navigation won't reset scroll in timeline (#22838)
* fix: back/forward navigation won't reset scroll in timeline Fixes a bug where navigating to/from the asser-viewer from timeline causes the scroll position to be reset. * Fix back after assetviewer next/prev navigation * Bug fix from review * review comments
This commit is contained in:
parent
382481735a
commit
c666dc6c67
@ -107,7 +107,7 @@ export class StorageRepository {
|
||||
const file = await fs.open(filepath);
|
||||
try {
|
||||
const { buffer } = await file.read(options);
|
||||
return buffer;
|
||||
return buffer as Buffer;
|
||||
} finally {
|
||||
await file.close();
|
||||
}
|
||||
|
||||
@ -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 { navigate } from '$lib/utils/navigation';
|
||||
import { isAssetViewerRoute } 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;
|
||||
}
|
||||
|
||||
const { clientHeight, scrollTop } = scrollableElement;
|
||||
return assetTop >= scrollTop && assetTop < scrollTop + clientHeight;
|
||||
// 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 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));
|
||||
// note: only modified once in afterNavigate()
|
||||
let initialLoadWasAssetViewer: boolean | null = null;
|
||||
// only modified in beforeNavigate()
|
||||
let hasNavigatedToOrFromAssetViewer: boolean = false;
|
||||
|
||||
afterNavigate((nav) => {
|
||||
const { complete } = nav;
|
||||
complete.then(completeNav, completeNav);
|
||||
// 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);
|
||||
|
||||
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);
|
||||
}
|
||||
};
|
||||
// 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;
|
||||
}
|
||||
|
||||
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}
|
||||
|
||||
@ -311,12 +311,14 @@ export class MonthGroup {
|
||||
if (viewerAsset) {
|
||||
if (!viewerAsset.position) {
|
||||
console.warn('No position for asset');
|
||||
break;
|
||||
return;
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
*assetsIterator(options?: { startDayGroup?: DayGroup; startAsset?: TimelineAsset; direction?: Direction }) {
|
||||
|
||||
@ -234,7 +234,6 @@ export class TimelineManager extends VirtualScrollManager {
|
||||
await this.initTask.reset();
|
||||
await this.#init(options);
|
||||
this.updateViewportGeometry(false);
|
||||
this.#createScrubberMonths();
|
||||
}
|
||||
|
||||
async #init(options: TimelineManagerOptions) {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user