mirror of
https://github.com/booklore-app/booklore.git
synced 2026-02-18 00:17:53 +01:00
feat(book-browser): preserve scroll position when navigating back from book details (#2449)
This commit is contained in:
@@ -1,39 +1,78 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { ActivatedRouteSnapshot, DetachedRouteHandle, RouteReuseStrategy } from '@angular/router';
|
||||
import {inject, Injectable} from '@angular/core';
|
||||
import {ActivatedRouteSnapshot, DetachedRouteHandle, RouteReuseStrategy} from '@angular/router';
|
||||
import {BookBrowserScrollService} from '../features/book/components/book-browser/book-browser-scroll.service';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class CustomReuseStrategy implements RouteReuseStrategy {
|
||||
private storedRoutes = new Map<string, DetachedRouteHandle>();
|
||||
private scrollService = inject(BookBrowserScrollService);
|
||||
|
||||
// Only detach the route if it's for the book details page
|
||||
shouldDetach(route: ActivatedRouteSnapshot): boolean {
|
||||
return route.routeConfig?.path === 'book/:id'; // Match the path of the route you want to reuse
|
||||
private readonly BOOK_BROWSER_PATHS = [
|
||||
'all-books',
|
||||
'unshelved-books',
|
||||
'library/:libraryId/books',
|
||||
'shelf/:shelfId/books',
|
||||
'magic-shelf/:magicShelfId/books'
|
||||
];
|
||||
|
||||
private readonly BOOK_DETAILS_PATH = 'book/:bookId';
|
||||
|
||||
private getRouteKey(route: ActivatedRouteSnapshot): string {
|
||||
const path = route.routeConfig?.path || '';
|
||||
return this.scrollService.createKey(path, route.params);
|
||||
}
|
||||
|
||||
private isBookBrowserRoute(route: ActivatedRouteSnapshot): boolean {
|
||||
const path = route.routeConfig?.path;
|
||||
return this.BOOK_BROWSER_PATHS.includes(path || '');
|
||||
}
|
||||
|
||||
private isBookDetailsRoute(route: ActivatedRouteSnapshot): boolean {
|
||||
return route.routeConfig?.path === this.BOOK_DETAILS_PATH;
|
||||
}
|
||||
|
||||
shouldDetach(route: ActivatedRouteSnapshot): boolean {
|
||||
return this.isBookBrowserRoute(route);
|
||||
}
|
||||
|
||||
// Store the route component instance when detaching
|
||||
store(route: ActivatedRouteSnapshot, handle: DetachedRouteHandle | null): void {
|
||||
if (handle) {
|
||||
// Save the handle if we are detaching this route
|
||||
this.storedRoutes.set(route.routeConfig?.path || '', handle);
|
||||
if (handle && this.isBookBrowserRoute(route)) {
|
||||
const key = this.getRouteKey(route);
|
||||
this.storedRoutes.set(key, handle);
|
||||
}
|
||||
}
|
||||
|
||||
// Check if we should attach the route (reuse it) when navigating back to it
|
||||
shouldAttach(route: ActivatedRouteSnapshot): boolean {
|
||||
// Attach the route only if there's a stored instance for this route
|
||||
return !!this.storedRoutes.get(route.routeConfig?.path || '');
|
||||
if (!this.isBookBrowserRoute(route)) {
|
||||
return false;
|
||||
}
|
||||
const key = this.getRouteKey(route);
|
||||
return this.storedRoutes.has(key);
|
||||
}
|
||||
|
||||
// Retrieve the stored route component instance
|
||||
retrieve(route: ActivatedRouteSnapshot): DetachedRouteHandle | null {
|
||||
return this.storedRoutes.get(route.routeConfig?.path || '') || null;
|
||||
const key = this.getRouteKey(route);
|
||||
const handle = this.storedRoutes.get(key) || null;
|
||||
|
||||
if (handle) {
|
||||
const savedPosition = this.scrollService.getPosition(key);
|
||||
if (savedPosition !== undefined) {
|
||||
setTimeout(() => {
|
||||
const scrollElement = document.querySelector('.virtual-scroller');
|
||||
if (scrollElement) {
|
||||
(scrollElement as HTMLElement).scrollTop = savedPosition;
|
||||
}
|
||||
}, 0);
|
||||
}
|
||||
}
|
||||
|
||||
return handle;
|
||||
}
|
||||
|
||||
// Determine if the route should be reused based on its configuration
|
||||
shouldReuseRoute(future: ActivatedRouteSnapshot, curr: ActivatedRouteSnapshot): boolean {
|
||||
// Reuse the route if the path and parameters match
|
||||
return future.routeConfig === curr.routeConfig && future.params['id'] === curr.params['id'];
|
||||
return future.routeConfig === curr.routeConfig &&
|
||||
JSON.stringify(future.params) === JSON.stringify(curr.params);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import {Injectable} from '@angular/core';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class BookBrowserScrollService {
|
||||
private scrollPositions = new Map<string, number>();
|
||||
|
||||
savePosition(key: string, position: number): void {
|
||||
this.scrollPositions.set(key, position);
|
||||
}
|
||||
|
||||
getPosition(key: string): number | undefined {
|
||||
return this.scrollPositions.get(key);
|
||||
}
|
||||
|
||||
clearPosition(key: string): void {
|
||||
this.scrollPositions.delete(key);
|
||||
}
|
||||
|
||||
createKey(path: string, params: Record<string, string>): string {
|
||||
const paramValues = Object.values(params).join('-');
|
||||
return paramValues ? `${path}:${paramValues}` : path;
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
import {AfterViewInit, Component, HostListener, inject, OnInit, ViewChild} from '@angular/core';
|
||||
import {ActivatedRoute} from '@angular/router';
|
||||
import {AfterViewInit, Component, HostListener, inject, OnDestroy, OnInit, ViewChild} from '@angular/core';
|
||||
import {ActivatedRoute, NavigationStart, Router} from '@angular/router';
|
||||
import {ConfirmationService, MenuItem, MessageService, PrimeTemplate} from 'primeng/api';
|
||||
import {PageTitleService} from '../../../../shared/service/page-title.service';
|
||||
import {BookService} from '../../service/book.service';
|
||||
import {debounceTime, filter, map, switchMap} from 'rxjs/operators';
|
||||
import {debounceTime, filter, map, switchMap, takeUntil} from 'rxjs/operators';
|
||||
import {BehaviorSubject, combineLatest, finalize, Observable, of, Subject} from 'rxjs';
|
||||
import {DynamicDialogRef} from 'primeng/dynamicdialog';
|
||||
import {Library} from '../../model/library.model';
|
||||
@@ -16,7 +16,7 @@ import {BookTableComponent} from './book-table/book-table.component';
|
||||
import {animate, style, transition, trigger} from '@angular/animations';
|
||||
import {Button} from 'primeng/button';
|
||||
import {AsyncPipe, NgClass, NgStyle} from '@angular/common';
|
||||
import {VirtualScrollerModule} from '@iharbeck/ngx-virtual-scroller';
|
||||
import {VirtualScrollerComponent, VirtualScrollerModule} from '@iharbeck/ngx-virtual-scroller';
|
||||
import {BookCardComponent} from './book-card/book-card.component';
|
||||
import {ProgressSpinner} from 'primeng/progressspinner';
|
||||
import {Menu} from 'primeng/menu';
|
||||
@@ -52,6 +52,7 @@ import {BookSelectionService, CheckboxClickEvent} from './book-selection.service
|
||||
import {BookBrowserQueryParamsService, VIEW_MODES} from './book-browser-query-params.service';
|
||||
import {BookBrowserEntityService} from './book-browser-entity.service';
|
||||
import {BookFilterOrchestrationService} from './book-filter-orchestration.service';
|
||||
import {BookBrowserScrollService} from './book-browser-scroll.service';
|
||||
|
||||
export enum EntityType {
|
||||
LIBRARY = 'Library',
|
||||
@@ -85,7 +86,7 @@ export enum EntityType {
|
||||
])
|
||||
]
|
||||
})
|
||||
export class BookBrowserComponent implements OnInit, AfterViewInit {
|
||||
export class BookBrowserComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
|
||||
protected userService = inject(UserService);
|
||||
protected coverScalePreferenceService = inject(CoverScalePreferenceService);
|
||||
@@ -98,6 +99,7 @@ export class BookBrowserComponent implements OnInit, AfterViewInit {
|
||||
protected bookSelectionService = inject(BookSelectionService);
|
||||
|
||||
private activatedRoute = inject(ActivatedRoute);
|
||||
private router = inject(Router);
|
||||
private messageService = inject(MessageService);
|
||||
private bookService = inject(BookService);
|
||||
private dialogHelperService = inject(BookDialogHelperService);
|
||||
@@ -110,6 +112,7 @@ export class BookBrowserComponent implements OnInit, AfterViewInit {
|
||||
private entityService = inject(BookBrowserEntityService);
|
||||
private filterOrchestrationService = inject(BookFilterOrchestrationService);
|
||||
private localStorageService = inject(LocalStorageService);
|
||||
private scrollService = inject(BookBrowserScrollService);
|
||||
|
||||
bookState$: Observable<BookState> | undefined;
|
||||
entity$: Observable<Library | Shelf | MagicShelf | null> | undefined;
|
||||
@@ -146,6 +149,7 @@ export class BookBrowserComponent implements OnInit, AfterViewInit {
|
||||
private readonly MOBILE_COLUMNS_STORAGE_KEY = 'mobileColumnsPreference';
|
||||
|
||||
private settingFiltersFromUrl = false;
|
||||
private destroy$ = new Subject<void>();
|
||||
protected metadataMenuItems: MenuItem[] | undefined;
|
||||
protected bulkReadActionsMenuItems: MenuItem[] | undefined;
|
||||
|
||||
@@ -159,6 +163,8 @@ export class BookBrowserComponent implements OnInit, AfterViewInit {
|
||||
bookTableComponent!: BookTableComponent;
|
||||
@ViewChild(BookFilterComponent, {static: false})
|
||||
bookFilterComponent!: BookFilterComponent;
|
||||
@ViewChild('scroll')
|
||||
virtualScroller: VirtualScrollerComponent | undefined;
|
||||
|
||||
@HostListener('window:resize')
|
||||
onResize(): void {
|
||||
@@ -257,6 +263,7 @@ export class BookBrowserComponent implements OnInit, AfterViewInit {
|
||||
this.setupUserStateSubscription();
|
||||
this.setupQueryParamSubscription();
|
||||
this.setupSearchTermSubscription();
|
||||
this.setupScrollPositionTracking();
|
||||
}
|
||||
|
||||
ngAfterViewInit(): void {
|
||||
@@ -267,6 +274,33 @@ export class BookBrowserComponent implements OnInit, AfterViewInit {
|
||||
}
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.destroy$.next();
|
||||
this.destroy$.complete();
|
||||
}
|
||||
|
||||
private getScrollPositionKey(): string {
|
||||
const path = this.activatedRoute.snapshot.routeConfig?.path ?? '';
|
||||
return this.scrollService.createKey(path, this.activatedRoute.snapshot.params);
|
||||
}
|
||||
|
||||
private setupScrollPositionTracking(): void {
|
||||
this.router.events.pipe(
|
||||
filter(event => event instanceof NavigationStart),
|
||||
takeUntil(this.destroy$)
|
||||
).subscribe(() => {
|
||||
this.saveScrollPosition();
|
||||
});
|
||||
}
|
||||
|
||||
private saveScrollPosition(): void {
|
||||
if (this.virtualScroller?.viewPortInfo) {
|
||||
const key = this.getScrollPositionKey();
|
||||
const position = this.virtualScroller.viewPortInfo.scrollStartPosition ?? 0;
|
||||
this.scrollService.savePosition(key, position);
|
||||
}
|
||||
}
|
||||
|
||||
private initializeEntityRouting(): void {
|
||||
const currentPath = this.activatedRoute.snapshot.routeConfig?.path;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user