feat(book-browser): preserve scroll position when navigating back from book details (#2449)

This commit is contained in:
ACX
2026-01-23 23:18:07 -07:00
committed by GitHub
parent 596bfc5798
commit a45a50b383
3 changed files with 120 additions and 22 deletions

View File

@@ -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);
}
}

View File

@@ -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;
}
}

View File

@@ -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;