diff --git a/web/src/lib/actions/scroll-memory.ts b/web/src/lib/actions/scroll-memory.ts new file mode 100644 index 0000000000..1c19fdd8ab --- /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 0000000000..762cfdccf7 --- /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 9be2db2691..6822035b19 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'; import type { Snippet } from 'svelte'; interface Props { @@ -16,6 +17,7 @@ description?: string | undefined; scrollbar?: boolean; admin?: boolean; + use?: ActionArray; header?: Snippet; sidebar?: Snippet; buttons?: Snippet; @@ -29,6 +31,7 @@ description = undefined, scrollbar = true, admin = false, + use = [], header, sidebar, buttons, @@ -73,7 +76,7 @@ {/if} -

+
{@render children?.()}
diff --git a/web/src/lib/constants.ts b/web/src/lib/constants.ts index f47d4a8c87..8d4fb809a5 100644 --- a/web/src/lib/constants.ts +++ b/web/src/lib/constants.ts @@ -84,6 +84,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 29079a48b8..239c6cc38a 100644 --- a/web/src/routes/(user)/albums/+page.svelte +++ b/web/src/routes/(user)/albums/+page.svelte @@ -1,5 +1,6 @@ - + {#snippet buttons()}
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 bf3f3509c4..5c63d8e1a3 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 13dac30691..0b51a7e240 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'; @@ -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(); let personMerge2 = $state(); @@ -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) { @@ -77,6 +80,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; @@ -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')); @@ -323,6 +359,23 @@ { + 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} 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 d9b7c6a08f..502ce715bd 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 @@