diff --git a/web/src/lib/actions/scroll-memory.ts b/web/src/lib/actions/scroll-memory.ts new file mode 100644 index 0000000000000..1c19fdd8ab2d0 --- /dev/null +++ b/web/src/lib/actions/scroll-memory.ts @@ -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; +} + +/** + * @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(); + }, + }; +} diff --git a/web/src/lib/actions/use-actions.ts b/web/src/lib/actions/use-actions.ts new file mode 100644 index 0000000000000..762cfdccf775b --- /dev/null +++ b/web/src/lib/actions/use-actions.ts @@ -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

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

= (node: HTMLElement, params?: P) => SvelteActionReturnType

; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type HTMLActionEntry

= SvelteHTMLActionType

| [SvelteHTMLActionType

, P]; + +export type HTMLActionArray = HTMLActionEntry[]; + +export type SvelteSVGActionType

= (node: SVGElement, params?: P) => SvelteActionReturnType

; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type SVGActionEntry

= SvelteSVGActionType

| [SvelteSVGActionType

, P]; + +export type SVGActionArray = SVGActionEntry[]; + +export type ActionArray = HTMLActionArray | SVGActionArray; + +export function useActions(node: HTMLElement | SVGElement, actions: ActionArray) { + const actionReturns: SvelteActionReturnType[] = []; + + 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?.(); + } + }, + }; +} diff --git a/web/src/lib/components/layouts/user-page-layout.svelte b/web/src/lib/components/layouts/user-page-layout.svelte index ed232b80cda28..b61a6dccec921 100644 --- a/web/src/lib/components/layouts/user-page-layout.svelte +++ b/web/src/lib/components/layouts/user-page-layout.svelte @@ -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'; export let hideNavbar = false; export let showUploadButton = false; @@ -14,6 +15,7 @@ export let description: string | undefined = undefined; export let scrollbar = true; export let admin = false; + export let use: ActionArray = []; $: scrollbarClass = scrollbar ? 'immich-scrollbar p-2 pb-8' : 'scrollbar-hidden'; $: hasTitleClass = title ? 'top-16 h-[calc(100%-theme(spacing.16))]' : 'top-0 h-full'; @@ -55,7 +57,7 @@ {/if} -

+
diff --git a/web/src/lib/constants.ts b/web/src/lib/constants.ts index 3abea669e6013..84f23173c2ada 100644 --- a/web/src/lib/constants.ts +++ b/web/src/lib/constants.ts @@ -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', diff --git a/web/src/routes/(user)/albums/+page.svelte b/web/src/routes/(user)/albums/+page.svelte index 35402ce331d49..4f378ed0dabf7 100644 --- a/web/src/routes/(user)/albums/+page.svelte +++ b/web/src/routes/(user)/albums/+page.svelte @@ -1,5 +1,6 @@ - +
diff --git a/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte index 3df4a25b83e1f..7c6d2ee2f6c47 100644 --- a/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -1,5 +1,6 @@ -
+
{#if $isMultiSelectState} assetInteractionStore.clearMultiselect()}> diff --git a/web/src/routes/(user)/people/+page.svelte b/web/src/routes/(user)/people/+page.svelte index b6d25c48bf937..89773e65a497f 100644 --- a/web/src/routes/(user)/people/+page.svelte +++ b/web/src/routes/(user)/people/+page.svelte @@ -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'; @@ -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'; @@ -51,6 +52,7 @@ let showSetBirthDateModal = false; let showMergeModal = false; let personName = ''; + let currentPage = 1; let nextPage = data.people.hasNextPage ? 2 : null; let personMerge1: PersonResponseDto; let personMerge2: PersonResponseDto; @@ -67,6 +69,7 @@ searchName = getSearchedPeople; handlePromiseError(handleSearchPeople(true, searchName)); } + return websocketEvents.on('on_person_thumbnail', (personId: string) => { for (const person of people) { if (person.id === personId) { @@ -79,6 +82,36 @@ }); }); + const loadInitialScroll = () => + new Promise((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; @@ -87,6 +120,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')); @@ -311,6 +347,23 @@ { + if (currentPage) { + sessionStorage.setItem(SessionStorageKey.INFINITE_SCROLL_PAGE, currentPage.toString()); + } + }, + beforeClear: () => { + sessionStorage.removeItem(SessionStorageKey.INFINITE_SCROLL_PAGE); + }, + beforeLoad: loadInitialScroll, + }, + ], + ]} > {#if people.length > 0} diff --git a/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte index 037feaf35f6f1..f94182f1db4ed 100644 --- a/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -1,6 +1,7 @@