diff --git a/booklore-ui/src/app/core/custom-reuse-strategy.ts b/booklore-ui/src/app/core/custom-reuse-strategy.ts index 7032a6281..f738f54c4 100644 --- a/booklore-ui/src/app/core/custom-reuse-strategy.ts +++ b/booklore-ui/src/app/core/custom-reuse-strategy.ts @@ -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(); + 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); } } diff --git a/booklore-ui/src/app/features/book/components/book-browser/book-browser-scroll.service.ts b/booklore-ui/src/app/features/book/components/book-browser/book-browser-scroll.service.ts new file mode 100644 index 000000000..244769107 --- /dev/null +++ b/booklore-ui/src/app/features/book/components/book-browser/book-browser-scroll.service.ts @@ -0,0 +1,25 @@ +import {Injectable} from '@angular/core'; + +@Injectable({ + providedIn: 'root' +}) +export class BookBrowserScrollService { + private scrollPositions = new Map(); + + 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 { + const paramValues = Object.values(params).join('-'); + return paramValues ? `${path}:${paramValues}` : path; + } +} diff --git a/booklore-ui/src/app/features/book/components/book-browser/book-browser.component.ts b/booklore-ui/src/app/features/book/components/book-browser/book-browser.component.ts index 7b99c2630..524f9b364 100644 --- a/booklore-ui/src/app/features/book/components/book-browser/book-browser.component.ts +++ b/booklore-ui/src/app/features/book/components/book-browser/book-browser.component.ts @@ -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 | undefined; entity$: Observable | 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(); 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;