Jump Bar Testing (#1302)

* Implemented a basic jump bar for the library view. This currently just interacts with existing pagination controls and is not inlined with infinite scroll yet. This is a first pass implementation.

* Refactored time estimates into the reading service.

* Cleaned up when the jump bar is shown to mimic pagination controls

* Cleanup up code in reader service.

* Scroll to card when selecting a jump key that is shown on the current page.

* Ensure estimated times always has the smaller number on left hand side.

* Fixed a bug with a missing vertical rule

* Fixed an off by 1 pixel for search overlay
This commit is contained in:
Joseph Milazzo 2022-05-30 16:50:12 -05:00 committed by GitHub
parent 64c0b5a71e
commit 742cfd3293
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
20 changed files with 319 additions and 120 deletions

View file

@ -0,0 +1,5 @@
export interface JumpKey {
size: number;
key: string;
title: string;
}

View file

@ -3,6 +3,7 @@ import { Injectable } from '@angular/core';
import { of } from 'rxjs';
import { map, take } from 'rxjs/operators';
import { environment } from 'src/environments/environment';
import { JumpKey } from '../_models/jumpbar/jump-key';
import { Library, LibraryType } from '../_models/library';
import { SearchResultGroup } from '../_models/search/search-result-group';
@ -58,6 +59,10 @@ export class LibraryService {
return this.httpClient.get<string[]>(this.baseUrl + 'library/list' + query);
}
getJumpBar(libraryId: number) {
return this.httpClient.get<JumpKey[]>(this.baseUrl + 'library/jump-bar?libraryId=' + libraryId);
}
getLibraries() {
return this.httpClient.get<Library[]>(this.baseUrl + 'library');
}

View file

@ -17,62 +17,79 @@
<ng-container [ngTemplateOutlet]="paginationTemplate" [ngTemplateOutletContext]="{ id: 'top' }"></ng-container>
<ng-container [ngTemplateOutlet]="cardTemplate"></ng-container>
<div>
<ng-container [ngTemplateOutlet]="cardTemplate"></ng-container>
</div>
<ng-container [ngTemplateOutlet]="paginationTemplate" [ngTemplateOutletContext]="{ id: 'bottom' }"></ng-container>
<ng-template #cardTemplate>
<div class="card-container row g-0 mt-3 mb-3">
<div class="card col-auto mt-2 mb-2" *ngFor="let item of items; trackBy:trackByIdentity; index as i">
<ng-container [ngTemplateOutlet]="itemTemplate" [ngTemplateOutletContext]="{ $implicit: item, idx: i }"></ng-container>
<div class="card-container row g-0 mt-3 mb-3" >
<div class="card col-auto mt-2 mb-2" *ngFor="let item of items; trackBy:trackByIdentity; index as i" id="jumpbar-index--{{i}}" [attr.jumpbar-index]="i">
<ng-container [ngTemplateOutlet]="itemTemplate" [ngTemplateOutletContext]="{ $implicit: item, idx: i }"></ng-container>
</div>
</div>
</div>
<p *ngIf="items.length === 0 && !isLoading">
<ng-container [ngTemplateOutlet]="noDataTemplate"></ng-container>
</p>
<p *ngIf="items.length === 0 && !isLoading">
<ng-container [ngTemplateOutlet]="noDataTemplate"></ng-container>
</p>
</ng-template>
<ng-template #paginationTemplate let-id="id">
<div class="d-flex justify-content-center mb-0" *ngIf="pagination && items.length > 0">
<ngb-pagination
*ngIf="pagination.totalPages > 1"
[maxSize]="8"
[rotate]="true"
[ellipses]="false"
[(page)]="pagination.currentPage"
[pageSize]="pagination.itemsPerPage"
(pageChange)="onPageChange($event)"
[collectionSize]="pagination.totalItems">
<ng-container *ngIf="pagination && items.length > 0 && id == 'bottom' && pagination.totalPages > 1 ">
<div class="jump-bar d-flex justify-content-center">
<button *ngFor="let jumpKey of jumpBarKeys" class="btn btn-link" (click)="scrollTo(jumpKey)">
{{jumpKey.title}}
</button>
</div>
</ng-container>
<div class="d-flex justify-content-center mb-0" *ngIf="pagination && items.length > 0">
<ngb-pagination
*ngIf="pagination.totalPages > 1"
[maxSize]="8"
[rotate]="true"
[ellipses]="false"
[(page)]="pagination.currentPage"
[pageSize]="pagination.itemsPerPage"
(pageChange)="onPageChange($event)"
[collectionSize]="pagination.totalItems">
<ng-template ngbPaginationPages let-page let-pages="pages" *ngIf="pagination.totalItems / pagination.itemsPerPage > 20">
<li class="ngb-custom-pages-item" *ngIf="pagination.totalPages > 1">
<div class="d-flex flex-nowrap px-2">
<label
id="paginationInputLabel-{{id}}"
for="paginationInput-{{id}}"
class="col-form-label me-2 ms-1 form-label"
>Page</label>
<input #i
type="text"
inputmode="numeric"
pattern="[0-9]*"
class="form-control custom-pages-input"
id="paginationInput-{{id}}"
[value]="page"
(keyup.enter)="selectPageStr(i.value)"
(blur)="selectPageStr(i.value)"
(input)="formatInput($any($event).target)"
attr.aria-labelledby="paginationInputLabel-{{id}} paginationDescription-{{id}}"
[ngStyle]="{width: (0.5 + pagination.currentPage + '').length + 'rem'} "
/>
<span id="paginationDescription-{{id}}" class="col-form-label text-nowrap px-2">
of {{pagination.totalPages}}</span>
</div>
</li>
</ng-template>
<ng-template ngbPaginationPages let-page let-pages="pages" *ngIf="pagination.totalItems / pagination.itemsPerPage > 20">
<li class="ngb-custom-pages-item" *ngIf="pagination.totalPages > 1">
<div class="d-flex flex-nowrap px-2">
<label
id="paginationInputLabel-{{id}}"
for="paginationInput-{{id}}"
class="col-form-label me-2 ms-1 form-label"
>Page</label>
<input #i
type="text"
inputmode="numeric"
pattern="[0-9]*"
class="form-control custom-pages-input"
id="paginationInput-{{id}}"
[value]="page"
(keyup.enter)="selectPageStr(i.value)"
(blur)="selectPageStr(i.value)"
(input)="formatInput($any($event).target)"
attr.aria-labelledby="paginationInputLabel-{{id}} paginationDescription-{{id}}"
[ngStyle]="{width: (0.5 + pagination.currentPage + '').length + 'rem'} "
/>
<span id="paginationDescription-{{id}}" class="col-form-label text-nowrap px-2">
of {{pagination.totalPages}}</span>
</div>
</li>
</ng-template>
</ngb-pagination>
</div>
</ngb-pagination>
</div>
<ng-container *ngIf="pagination && items.length > 0 && id == 'top' && pagination.totalPages > 1">
<div class="jump-bar d-flex justify-content-center">
<button *ngFor="let jumpKey of jumpBarKeys" class="btn btn-link" (click)="scrollTo(jumpKey)">
{{jumpKey.title}}
</button>
</div>
</ng-container>
</ng-template>
<div class="mx-auto" *ngIf="isLoading" style="width: 200px;">

View file

@ -1,11 +1,14 @@
import { Component, ContentChild, EventEmitter, Input, OnDestroy, OnInit, Output, TemplateRef } from '@angular/core';
import { Subject } from 'rxjs';
import { DOCUMENT } from '@angular/common';
import { AfterViewInit, Component, ContentChild, EventEmitter, Inject, Input, OnDestroy, OnInit, Output, Renderer2, TemplateRef, ViewChild, ViewContainerRef } from '@angular/core';
import { from, Subject } from 'rxjs';
import { FilterSettings } from 'src/app/metadata-filter/filter-settings';
import { Breakpoint, UtilityService } from 'src/app/shared/_services/utility.service';
import { JumpKey } from 'src/app/_models/jumpbar/jump-key';
import { Library } from 'src/app/_models/library';
import { Pagination } from 'src/app/_models/pagination';
import { FilterEvent, FilterItem, SeriesFilter } from 'src/app/_models/series-filter';
import { ActionItem } from 'src/app/_services/action-factory.service';
import { ScrollService } from 'src/app/_services/scroll.service';
import { SeriesService } from 'src/app/_services/series.service';
const FILTER_PAG_REGEX = /[^0-9]/g;
@ -15,12 +18,15 @@ const FILTER_PAG_REGEX = /[^0-9]/g;
templateUrl: './card-detail-layout.component.html',
styleUrls: ['./card-detail-layout.component.scss']
})
export class CardDetailLayoutComponent implements OnInit, OnDestroy {
export class CardDetailLayoutComponent implements OnInit, OnDestroy, AfterViewInit {
@Input() header: string = '';
@Input() isLoading: boolean = false;
@Input() items: any[] = [];
@Input() pagination!: Pagination;
// Filter Code
@Input() filterOpen!: EventEmitter<boolean>;
/**
* Should filtering be shown on the page
*/
@ -31,6 +37,9 @@ export class CardDetailLayoutComponent implements OnInit, OnDestroy {
@Input() actions: ActionItem<any>[] = [];
@Input() trackByIdentity!: (index: number, item: any) => string;
@Input() filterSettings!: FilterSettings;
@Input() jumpBarKeys: Array<JumpKey> = [];
@Output() itemClicked: EventEmitter<any> = new EventEmitter();
@Output() pageChange: EventEmitter<Pagination> = new EventEmitter();
@Output() applyFilter: EventEmitter<FilterEvent> = new EventEmitter();
@ -39,15 +48,13 @@ export class CardDetailLayoutComponent implements OnInit, OnDestroy {
@ContentChild('noData') noDataTemplate!: TemplateRef<any>;
// Filter Code
@Input() filterOpen!: EventEmitter<boolean>;
filter!: SeriesFilter;
libraries: Array<FilterItem<Library>> = [];
updateApplied: number = 0;
intersectionObserver: IntersectionObserver = new IntersectionObserver((entries) => this.handleIntersection(entries), { threshold: 0.01 });
private onDestory: Subject<void> = new Subject();
@ -55,7 +62,8 @@ export class CardDetailLayoutComponent implements OnInit, OnDestroy {
return Breakpoint;
}
constructor(private seriesService: SeriesService, public utilityService: UtilityService) {
constructor(private seriesService: SeriesService, public utilityService: UtilityService, @Inject(DOCUMENT) private document: Document,
private scrollService: ScrollService) {
this.filter = this.seriesService.createSeriesFilter();
}
@ -72,11 +80,28 @@ export class CardDetailLayoutComponent implements OnInit, OnDestroy {
}
}
ngAfterViewInit() {
const parent = this.document.querySelector('.card-container');
if (parent == null) return;
console.log('card divs', this.document.querySelectorAll('div[id^="jumpbar-index--"]'));
console.log('cards: ', this.document.querySelectorAll('.card'));
Array.from(this.document.querySelectorAll('div')).forEach(elem => this.intersectionObserver.observe(elem));
}
ngOnDestroy() {
this.intersectionObserver.disconnect();
this.onDestory.next();
this.onDestory.complete();
}
handleIntersection(entries: IntersectionObserverEntry[]) {
console.log('interception: ', entries.filter(e => e.target.hasAttribute('no-observe')));
}
onPageChange(page: number) {
this.pageChange.emit(this.pagination);
}
@ -101,4 +126,50 @@ export class CardDetailLayoutComponent implements OnInit, OnDestroy {
this.updateApplied++;
}
// onScroll() {
// }
// onScrollDown() {
// console.log('scrolled down');
// }
// onScrollUp() {
// console.log('scrolled up');
// }
scrollTo(jumpKey: JumpKey) {
// TODO: Figure out how to do this
let targetIndex = 0;
for(var i = 0; i < this.jumpBarKeys.length; i++) {
if (this.jumpBarKeys[i].key === jumpKey.key) break;
targetIndex += this.jumpBarKeys[i].size;
}
//console.log('scrolling to card that starts with ', jumpKey.key, + ' with index of ', targetIndex);
// Basic implementation based on itemsPerPage being the same.
//var minIndex = this.pagination.currentPage * this.pagination.itemsPerPage;
var targetPage = Math.max(Math.ceil(targetIndex / this.pagination.itemsPerPage), 1);
//console.log('We are on page ', this.pagination.currentPage, ' and our target page is ', targetPage);
if (targetPage === this.pagination.currentPage) {
// Scroll to the element
const elem = this.document.querySelector(`div[id="jumpbar-index--${targetIndex}"`);
if (elem !== null) {
elem.scrollIntoView({
behavior: 'smooth'
});
}
return;
}
this.selectPageStr(targetPage + '');
// if (minIndex > targetIndex) {
// // We need to scroll forward (potentially to another page)
// } else if (minIndex < targetIndex) {
// // We need to scroll back (potentially to another page)
// }
}
}

View file

@ -59,6 +59,9 @@ import { CardDetailDrawerComponent } from './card-detail-drawer/card-detail-draw
NgbTooltipModule, // Card item
NgbCollapseModule,
NgbRatingModule,
//ScrollingModule,
//InfiniteScrollModule,
NgbOffcanvasModule, // Series Detail, action of cards
@ -68,6 +71,8 @@ import { CardDetailDrawerComponent } from './card-detail-drawer/card-detail-draw
NgbProgressbarModule,
NgxFileDropModule, // Cover Chooser
PipeModule, // filter for BulkAddToCollectionComponent
SharedModule, // IconAndTitleComponent

View file

@ -24,6 +24,7 @@
[pagination]="pagination"
[filterSettings]="filterSettings"
[filterOpen]="filterOpen"
[jumpBarKeys]="jumpKeys"
(applyFilter)="updateFilter($event)"
(pageChange)="onPageChange($event)"
>

View file

@ -18,6 +18,7 @@ import { SeriesService } from '../_services/series.service';
import { NavService } from '../_services/nav.service';
import { FilterUtilitiesService } from '../shared/_services/filter-utilities.service';
import { FilterSettings } from '../metadata-filter/filter-settings';
import { JumpKey } from '../_models/jumpbar/jump-key';
@Component({
selector: 'app-library-detail',
@ -39,6 +40,8 @@ export class LibraryDetailComponent implements OnInit, OnDestroy {
filterActive: boolean = false;
filterActiveCheck!: SeriesFilter;
jumpKeys: Array<JumpKey> = [];
tabs: Array<{title: string, fragment: string, icon: string}> = [
{title: 'Library', fragment: '', icon: 'fa-landmark'},
{title: 'Recommended', fragment: 'recomended', icon: 'fa-award'},
@ -100,6 +103,11 @@ export class LibraryDetailComponent implements OnInit, OnDestroy {
this.libraryName = names[this.libraryId];
this.titleService.setTitle('Kavita - ' + this.libraryName);
});
this.libraryService.getJumpBar(this.libraryId).subscribe(barDetails => {
console.log('JumpBar: ', barDetails);
this.jumpKeys = barDetails;
});
this.actions = this.actionFactoryService.getLibraryActions(this.handleAction.bind(this));
this.pagination = this.filterUtilityService.pagination(this.route.snapshot);

View file

@ -448,13 +448,13 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy {
this.attachIntersectionObserverElem(event.target);
if (imagePage === this.pageNum) {
Promise.all(Array.from(document.querySelectorAll('img'))
Promise.all(Array.from(this.document.querySelectorAll('img'))
.filter((img: any) => !img.complete)
.map((img: any) => new Promise(resolve => { img.onload = img.onerror = resolve; })))
.then(() => {
this.debugLog('[Initialization] All images have loaded from initial prefetch, initFinished = true');
this.debugLog('[Image Load] ! Loaded current page !', this.pageNum);
this.currentPageElem = document.querySelector('img#page-' + this.pageNum);
this.currentPageElem = this.document.querySelector('img#page-' + this.pageNum);
// There needs to be a bit of time before we scroll
if (this.currentPageElem && !this.isElementVisible(this.currentPageElem)) {
this.scrollToCurrentPage();

View file

@ -99,7 +99,7 @@ form {
.dropdown {
width: 100vw;
height: calc(100vh - 57px); //header offset
height: calc(100vh - 56px); //header offset
background: var(--dropdown-overlay-color);
position: fixed;
justify-content: center;

View file

@ -65,9 +65,10 @@
<app-icon-and-title [clickable]="false" fontClasses="fa-solid fa-book-open">
{{series.wordCount | compactNumber}} Words
</app-icon-and-title>
</div>
</div>
<div class="vr d-none d-lg-block m-2"></div>
</ng-container>
<div class="vr d-none d-lg-block m-2"></div>
</ng-container>
<ng-template #showPages>
<div class="d-none d-md-block col-lg-1 col-md-4 col-sm-4 col-4 mb-2">
@ -75,6 +76,7 @@
{{series.pages | number:''}} Pages
</app-icon-and-title>
</div>
<div class="vr d-none d-lg-block m-2"></div>
</ng-template>