Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(web): persist scroll position on navigation back to album #11388

Merged
merged 10 commits into from
Nov 25, 2024
87 changes: 87 additions & 0 deletions web/src/lib/actions/scroll-memory.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { navigating } from '$app/stores';
import { AppRoute, SessionStorageKey } from '$lib/constants';
import { handlePromiseError } from '$lib/utils';

interface Options {
/**
* {@link AppRoute} for subpages that scroll state should be kept while visiting.
*
* This must be kept the same in all subpages of this route for the scroll memory clearer to work.
*/
routeStartsWith: AppRoute;
/**
* Function to clear additional data/state before scrolling (ex infinite scroll).
*/
beforeClear?: () => void;
}

interface PageOptions extends Options {
/**
* Function to save additional data/state before scrolling (ex infinite scroll).
*/
beforeSave?: () => void;
/**
* Function to load additional data/state before scrolling (ex infinite scroll).
*/
beforeScroll?: () => Promise<void>;
}

/**
* @param node The scroll slot element, typically from {@link UserPageLayout}
*/
export function scrollMemory(
node: HTMLElement,
{ routeStartsWith, beforeSave, beforeClear, beforeScroll }: PageOptions,
) {
const unsubscribeNavigating = navigating.subscribe((navigation) => {
const existingScroll = sessionStorage.getItem(SessionStorageKey.SCROLL_POSITION);
if (navigation?.to && !existingScroll) {
// Save current scroll information when going into a subpage.
if (navigation.to.url.pathname.startsWith(routeStartsWith)) {
beforeSave?.();
sessionStorage.setItem(SessionStorageKey.SCROLL_POSITION, node.scrollTop.toString());
} else {
beforeClear?.();
sessionStorage.removeItem(SessionStorageKey.SCROLL_POSITION);
}
}
});

handlePromiseError(
(async () => {
await beforeScroll?.();

const newScroll = sessionStorage.getItem(SessionStorageKey.SCROLL_POSITION);
if (newScroll) {
node.scroll({
top: Number.parseFloat(newScroll),
behavior: 'instant',
});
}
beforeClear?.();
sessionStorage.removeItem(SessionStorageKey.SCROLL_POSITION);
})(),
);

return {
destroy() {
unsubscribeNavigating();
},
};
}

export function scrollMemoryClearer(_node: HTMLElement, { routeStartsWith, beforeClear }: Options) {
const unsubscribeNavigating = navigating.subscribe((navigation) => {
// Forget scroll position from main page if going somewhere else.
if (navigation?.to && !navigation?.to.url.pathname.startsWith(routeStartsWith)) {
beforeClear?.();
sessionStorage.removeItem(SessionStorageKey.SCROLL_POSITION);
}
});

return {
destroy() {
unsubscribeNavigating();
},
};
}
67 changes: 67 additions & 0 deletions web/src/lib/actions/use-actions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
/**
* @license Apache-2.0
* https://github.com/hperrin/svelte-material-ui/blob/master/packages/common/src/internal/useActions.ts
*/

export type SvelteActionReturnType<P> = {
update?: (newParams?: P) => void;
destroy?: () => void;
} | void;

export type SvelteHTMLActionType<P> = (node: HTMLElement, params?: P) => SvelteActionReturnType<P>;

// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type HTMLActionEntry<P = any> = SvelteHTMLActionType<P> | [SvelteHTMLActionType<P>, P];

export type HTMLActionArray = HTMLActionEntry[];

export type SvelteSVGActionType<P> = (node: SVGElement, params?: P) => SvelteActionReturnType<P>;

// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type SVGActionEntry<P = any> = SvelteSVGActionType<P> | [SvelteSVGActionType<P>, P];

export type SVGActionArray = SVGActionEntry[];

export type ActionArray = HTMLActionArray | SVGActionArray;

export function useActions(node: HTMLElement | SVGElement, actions: ActionArray) {
const actionReturns: SvelteActionReturnType<unknown>[] = [];

if (actions) {
for (const actionEntry of actions) {
const action = Array.isArray(actionEntry) ? actionEntry[0] : actionEntry;
if (Array.isArray(actionEntry) && actionEntry.length > 1) {
actionReturns.push(action(node as HTMLElement & SVGElement, actionEntry[1]));
} else {
actionReturns.push(action(node as HTMLElement & SVGElement));
}
}
}

return {
update(actions: ActionArray) {
if ((actions?.length || 0) != actionReturns.length) {
throw new Error('You must not change the length of an actions array.');
}

if (actions) {
for (const [i, returnEntry] of actionReturns.entries()) {
if (returnEntry && returnEntry.update) {
const actionEntry = actions[i];
if (Array.isArray(actionEntry) && actionEntry.length > 1) {
returnEntry.update(actionEntry[1]);
} else {
returnEntry.update();
}
}
}
}
},

destroy() {
for (const returnEntry of actionReturns) {
returnEntry?.destroy?.();
}
},
};
}
5 changes: 4 additions & 1 deletion web/src/lib/components/layouts/user-page-layout.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import NavigationBar from '../shared-components/navigation-bar/navigation-bar.svelte';
import SideBar from '../shared-components/side-bar/side-bar.svelte';
import AdminSideBar from '../shared-components/side-bar/admin-side-bar.svelte';
import { useActions, type ActionArray } from '$lib/actions/use-actions';
import type { Snippet } from 'svelte';

interface Props {
Expand All @@ -16,6 +17,7 @@
description?: string | undefined;
scrollbar?: boolean;
admin?: boolean;
use?: ActionArray;
header?: Snippet;
sidebar?: Snippet;
buttons?: Snippet;
Expand All @@ -29,6 +31,7 @@
description = undefined,
scrollbar = true,
admin = false,
use = [],
header,
sidebar,
buttons,
Expand Down Expand Up @@ -73,7 +76,7 @@
</div>
{/if}

<div class="{scrollbarClass} scrollbar-stable absolute {hasTitleClass} w-full overflow-y-auto">
<div class="{scrollbarClass} scrollbar-stable absolute {hasTitleClass} w-full overflow-y-auto" use:useActions={use}>
{@render children?.()}
</div>
</section>
Expand Down
5 changes: 5 additions & 0 deletions web/src/lib/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,11 @@ export enum QueryParameter {
PATH = 'path',
}

export enum SessionStorageKey {
INFINITE_SCROLL_PAGE = 'infiniteScrollPage',
SCROLL_POSITION = 'scrollPosition',
}

export enum OpenSettingQueryParameterValue {
OAUTH = 'oauth',
JOB = 'job',
Expand Down
4 changes: 3 additions & 1 deletion web/src/routes/(user)/albums/+page.svelte
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
<script lang="ts">
import type { PageData } from './$types';
import { scrollMemory } from '$lib/actions/scroll-memory';
import { AlbumFilter, albumViewSettings } from '$lib/stores/preferences.store';
import { createAlbumAndRedirect } from '$lib/utils/album-utils';
import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte';
Expand All @@ -8,6 +9,7 @@
import EmptyPlaceholder from '$lib/components/shared-components/empty-placeholder.svelte';
import GroupTab from '$lib/components/elements/group-tab.svelte';
import SearchBar from '$lib/components/elements/search-bar.svelte';
import { AppRoute } from '$lib/constants';
import { t } from 'svelte-i18n';

interface Props {
Expand All @@ -20,7 +22,7 @@
let albumGroups: string[] = $state([]);
</script>

<UserPageLayout title={data.meta.title}>
<UserPageLayout title={data.meta.title} use={[[scrollMemory, { routeStartsWith: AppRoute.ALBUMS }]]}>
{#snippet buttons()}
<div class="flex place-items-center gap-2">
<AlbumsControls {albumGroups} bind:searchQuery />
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
<script lang="ts">
import { afterNavigate, goto, onNavigate } from '$app/navigation';
import { scrollMemoryClearer } from '$lib/actions/scroll-memory';
import AlbumDescription from '$lib/components/album-page/album-description.svelte';
import AlbumOptions from '$lib/components/album-page/album-options.svelte';
import AlbumSummary from '$lib/components/album-page/album-summary.svelte';
Expand Down Expand Up @@ -430,7 +431,7 @@
});
</script>

<div class="flex overflow-hidden">
<div class="flex overflow-hidden" use:scrollMemoryClearer={{ routeStartsWith: AppRoute.ALBUMS }}>
<div class="relative w-full shrink">
{#if $isMultiSelectState}
<AssetSelectControlBar assets={$selectedAssets} clearSelect={() => assetInteractionStore.clearMultiselect()}>
Expand Down
55 changes: 54 additions & 1 deletion web/src/routes/(user)/people/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { focusTrap } from '$lib/actions/focus-trap';
import { scrollMemory } from '$lib/actions/scroll-memory';
import Button from '$lib/components/elements/buttons/button.svelte';
import LinkButton from '$lib/components/elements/buttons/link-button.svelte';
import Icon from '$lib/components/elements/icon.svelte';
Expand All @@ -17,7 +18,7 @@
notificationController,
NotificationType,
} from '$lib/components/shared-components/notification/notification';
import { ActionQueryParameterValue, AppRoute, QueryParameter } from '$lib/constants';
import { ActionQueryParameterValue, AppRoute, QueryParameter, SessionStorageKey } from '$lib/constants';
import { locale } from '$lib/stores/preferences.store';
import { websocketEvents } from '$lib/stores/websocket';
import { handlePromiseError } from '$lib/utils';
Expand Down Expand Up @@ -50,6 +51,7 @@
let showSetBirthDateModal = $state(false);
let showMergeModal = $state(false);
let personName = $state('');
let currentPage = $state(1);
let nextPage = $state(data.people.hasNextPage ? 2 : null);
let personMerge1 = $state<PersonResponseDto>();
let personMerge2 = $state<PersonResponseDto>();
Expand All @@ -68,6 +70,7 @@
handlePromiseError(searchPeopleElement.searchPeople(true, searchName));
}
}

return websocketEvents.on('on_person_thumbnail', (personId: string) => {
for (const person of people) {
if (person.id === personId) {
Expand All @@ -77,6 +80,36 @@
});
});

const loadInitialScroll = () =>
new Promise<void>((resolve) => {
// Load up to previously loaded page when returning.
let newNextPage = sessionStorage.getItem(SessionStorageKey.INFINITE_SCROLL_PAGE);
if (newNextPage && nextPage) {
let startingPage = nextPage,
pagesToLoad = Number.parseInt(newNextPage) - nextPage;

if (pagesToLoad) {
handlePromiseError(
Promise.all(
Array.from({ length: pagesToLoad }).map((_, i) => {
return getAllPeople({ withHidden: true, page: startingPage + i });
}),
).then((pages) => {
for (const page of pages) {
people = people.concat(page.people);
}
currentPage = startingPage + pagesToLoad - 1;
nextPage = pages.at(-1)?.hasNextPage ? startingPage + pagesToLoad : null;
resolve(); // wait until extra pages are loaded
}),
);
} else {
resolve();
}
sessionStorage.removeItem(SessionStorageKey.INFINITE_SCROLL_PAGE);
}
});

const loadNextPage = async () => {
if (!nextPage) {
return;
Expand All @@ -85,6 +118,9 @@
try {
const { people: newPeople, hasNextPage } = await getAllPeople({ withHidden: true, page: nextPage });
people = people.concat(newPeople);
if (nextPage !== null) {
currentPage = nextPage;
}
nextPage = hasNextPage ? nextPage + 1 : null;
} catch (error) {
handleError(error, $t('errors.failed_to_load_people'));
Expand Down Expand Up @@ -323,6 +359,23 @@
<UserPageLayout
title={$t('people')}
description={countVisiblePeople === 0 && !searchName ? undefined : `(${countVisiblePeople.toLocaleString($locale)})`}
use={[
[
scrollMemory,
{
routeStartsWith: AppRoute.PEOPLE,
beforeSave: () => {
if (currentPage) {
sessionStorage.setItem(SessionStorageKey.INFINITE_SCROLL_PAGE, currentPage.toString());
}
},
beforeClear: () => {
sessionStorage.removeItem(SessionStorageKey.INFINITE_SCROLL_PAGE);
},
beforeLoad: loadInitialScroll,
},
],
]}
>
{#snippet buttons()}
{#if people.length > 0}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
<script lang="ts">
import { afterNavigate, goto } from '$app/navigation';
import { page } from '$app/stores';
import { scrollMemoryClearer } from '$lib/actions/scroll-memory';
import ImageThumbnail from '$lib/components/assets/thumbnail/image-thumbnail.svelte';
import EditNameInput from '$lib/components/faces-page/edit-name-input.svelte';
import MergeFaceSelector from '$lib/components/faces-page/merge-face-selector.svelte';
Expand All @@ -25,7 +26,7 @@
NotificationType,
notificationController,
} from '$lib/components/shared-components/notification/notification';
import { AppRoute, PersonPageViewMode, QueryParameter } from '$lib/constants';
import { AppRoute, PersonPageViewMode, QueryParameter, SessionStorageKey } from '$lib/constants';
import { createAssetInteractionStore } from '$lib/stores/asset-interaction.store';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { AssetStore } from '$lib/stores/assets.store';
Expand Down Expand Up @@ -164,7 +165,7 @@
type: NotificationType.Info,
});

await goto(previousRoute, { replaceState: true });
await goto(previousRoute);
} catch (error) {
handleError(error, $t('errors.unable_to_hide_person'));
}
Expand Down Expand Up @@ -431,7 +432,15 @@
{/if}
</header>

<main class="relative h-screen overflow-hidden bg-immich-bg tall:ml-4 pt-[var(--navbar-height)] dark:bg-immich-dark-bg">
<main
class="relative h-screen overflow-hidden bg-immich-bg tall:ml-4 pt-[var(--navbar-height)] dark:bg-immich-dark-bg"
use:scrollMemoryClearer={{
routeStartsWith: AppRoute.PEOPLE,
beforeClear: () => {
sessionStorage.removeItem(SessionStorageKey.INFINITE_SCROLL_PAGE);
},
}}
>
{#key person.id}
<AssetGrid
enableRouting={true}
Expand Down
Loading