mirror of
https://github.com/immich-app/immich
synced 2025-11-14 17:36:12 +00:00
feat(web): persist scroll position on navigation back to album (#11388)
Co-authored-by: Calum Dingwall <caburum@users.noreply.github.com> Co-authored-by: Alex <alex.tran1502@gmail.com>
This commit is contained in:
parent
51de108d43
commit
d277096d58
8 changed files with 234 additions and 7 deletions
87
web/src/lib/actions/scroll-memory.ts
Normal file
87
web/src/lib/actions/scroll-memory.ts
Normal file
|
|
@ -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
web/src/lib/actions/use-actions.ts
Normal file
67
web/src/lib/actions/use-actions.ts
Normal file
|
|
@ -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?.();
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -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 @@
|
|||
</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>
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue