mirror of
https://github.com/Kareadita/Kavita.git
synced 2026-04-24 01:59:29 -04:00
Webtoon Reader Continuous Reader Changes (#4598)
This commit is contained in:
parent
cb1d12046f
commit
0e56b30dc3
@ -73,7 +73,7 @@ If you are interested, you can use the promo code [`FIRSTTIME`](https://buy.stri
|
||||
**If you already contribute via OpenCollective, please reach out to majora2007 for a provisioned license.**
|
||||
|
||||
## Localization
|
||||
Thank you to [Weblate](https://hosted.weblate.org/engage/kavita/) who hosts our localization infrastructure pro bono. If you want to see Kavita in your language, please help us localize. Drop by the discord and signup for the `Translator` role.
|
||||
Thank you to [Weblate](https://hosted.weblate.org/engage/kavita/) who hosts our localization infrastructure pro bono. If you want to see Kavita in your language, please help us localize. Drop by the discord and sign up for the `Translator` role.
|
||||
|
||||
<a href="https://hosted.weblate.org/engage/kavita/">
|
||||
<img src="https://hosted.weblate.org/widget/kavita/horizontal-auto.svg" alt="Translation status" />
|
||||
|
||||
@ -15,21 +15,11 @@
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (atTop) {
|
||||
<div #topSpacer class="spacer top" role="alert" (click)="loadPrevChapter.emit(undefined)">
|
||||
<div class="empty-space"></div>
|
||||
<div>
|
||||
<button class="btn btn-icon mx-auto">
|
||||
<i class="fa fa-angle-double-up animate" aria-hidden="true"></i>
|
||||
</button>
|
||||
<span class="mx-auto text">{{t('continuous-reading-prev-chapter')}}</span>
|
||||
<button class="btn btn-icon mx-auto">
|
||||
<i class="fa fa-angle-double-up animate" aria-hidden="true"></i>
|
||||
</button>
|
||||
<span class="visually-hidden">{{t('continuous-reading-prev-chapter-alt')}}</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
<app-pull-to-load
|
||||
[direction]="'down'"
|
||||
[title]="t('continuous-reading-prev-chapter')"
|
||||
[scrollContainer]="scrollElement"
|
||||
(triggered)="loadPrevChapter.emit()" />
|
||||
|
||||
<div infinite-scroll [infiniteScrollDistance]="1" [infiniteScrollThrottle]="50">
|
||||
@for(item of webtoonImages | async; let index = $index; track item.src) {
|
||||
@ -48,18 +38,10 @@
|
||||
}
|
||||
</div>
|
||||
|
||||
<div #bottomSpacer class="spacer bottom" role="alert" (click)="loadNextChapter.emit(undefined)">
|
||||
<div>
|
||||
<button class="btn btn-icon mx-auto">
|
||||
<i class="fa fa-angle-double-down animate" aria-hidden="true"></i>
|
||||
</button>
|
||||
<span class="mx-auto text">{{t('continuous-reading-next-chapter')}}</span>
|
||||
<button class="btn btn-icon mx-auto">
|
||||
<i class="fa fa-angle-double-down animate" aria-hidden="true"></i>
|
||||
</button>
|
||||
<span class="visually-hidden">{{t('continuous-reading-next-chapter-alt')}}</span>
|
||||
</div>
|
||||
<div class="empty-space"></div>
|
||||
</div>
|
||||
<app-pull-to-load
|
||||
[direction]="'up'"
|
||||
[title]="t('continuous-reading-next-chapter')"
|
||||
[scrollContainer]="scrollElement"
|
||||
(triggered)="moveToNextChapter()" />
|
||||
|
||||
</ng-container>
|
||||
|
||||
@ -10,26 +10,6 @@
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.spacer {
|
||||
width: 100%;
|
||||
height: 18.75rem;
|
||||
cursor: pointer;
|
||||
|
||||
.animate {
|
||||
animation: move-up-down 1s linear infinite;
|
||||
}
|
||||
|
||||
.text {
|
||||
z-index: 101;
|
||||
|
||||
}
|
||||
|
||||
.empty-space {
|
||||
height: 12.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
img, .full-width {
|
||||
max-width: 100% !important;
|
||||
height: auto;
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
import {AsyncPipe, DOCUMENT} from '@angular/common';
|
||||
import {
|
||||
AfterViewInit,
|
||||
ChangeDetectionStrategy,
|
||||
ChangeDetectorRef,
|
||||
Component,
|
||||
@ -17,12 +16,10 @@ import {
|
||||
output,
|
||||
Renderer2,
|
||||
Signal,
|
||||
SimpleChanges,
|
||||
viewChild
|
||||
SimpleChanges
|
||||
} from '@angular/core';
|
||||
import {BehaviorSubject, fromEvent, map, Observable, of, ReplaySubject, tap} from 'rxjs';
|
||||
import {BehaviorSubject, fromEvent, map, Observable, of, ReplaySubject, Subject, tap} from 'rxjs';
|
||||
import {debounceTime} from 'rxjs/operators';
|
||||
import {ScrollService} from 'src/app/_services/scroll.service';
|
||||
import {ReaderService} from '../../../_services/reader.service';
|
||||
import {PAGING_DIRECTION} from '../../_models/reader-enums';
|
||||
import {WebtoonImage} from '../../_models/webtoon-image';
|
||||
@ -35,11 +32,8 @@ import {SafeStylePipe} from "../../../_pipes/safe-style.pipe";
|
||||
import {ReadingProfile} from "../../../_models/preferences/reading-profiles";
|
||||
import {BreakpointService} from "../../../_services/breakpoint.service";
|
||||
import {Queue} from "../../../shared/data-structures/queue";
|
||||
import {PullToLoadComponent} from "../../../shared/_components/pull-to-load/pull-to-load.component";
|
||||
|
||||
/**
|
||||
* How much additional space should pass, past the original bottom of the document height before we trigger the next chapter load
|
||||
*/
|
||||
const SPACER_SCROLL_INTO_PX = 200;
|
||||
/**
|
||||
* Default debounce time from scroll and scrollend event listeners
|
||||
*/
|
||||
@ -57,6 +51,10 @@ const INITIAL_LOAD_GRACE_PERIOD = 1000;
|
||||
* How many times the Webtoon reader will retry failed images
|
||||
*/
|
||||
const MAX_FAILED_IMG_RETRIES = 3;
|
||||
/**
|
||||
* How long to wait for an image load/error event before treating it as a failure
|
||||
*/
|
||||
const IMAGE_RETRY_TIMEOUT_MS = 10_000;
|
||||
/**
|
||||
* Bitwise enums for configuring how much debug information we want
|
||||
*/
|
||||
@ -84,19 +82,22 @@ const enum DEBUG_MODES {
|
||||
templateUrl: './infinite-scroller.component.html',
|
||||
styleUrls: ['./infinite-scroller.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [AsyncPipe, TranslocoDirective, InfiniteScrollDirective, SafeStylePipe]
|
||||
imports: [AsyncPipe, TranslocoDirective, InfiniteScrollDirective, SafeStylePipe, PullToLoadComponent]
|
||||
})
|
||||
export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy, AfterViewInit {
|
||||
export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy {
|
||||
private readonly document = inject<Document>(DOCUMENT);
|
||||
private readonly mangaReaderService = inject(MangaReaderService);
|
||||
private readonly readerService = inject(ReaderService);
|
||||
private readonly renderer = inject(Renderer2);
|
||||
private readonly scrollService = inject(ScrollService);
|
||||
private readonly injector = inject(Injector);
|
||||
private readonly cdRef = inject(ChangeDetectorRef);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
protected readonly breakpointService = inject(BreakpointService);
|
||||
|
||||
get scrollElement(): HTMLElement {
|
||||
return this.isFullscreenMode ? this.readerElemRef.nativeElement : this.document.body;
|
||||
}
|
||||
|
||||
/**
|
||||
* Current page number aka what's recorded on screen
|
||||
*/
|
||||
@ -116,6 +117,7 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy,
|
||||
@Input({required: true}) readerSettings$!: Observable<ReaderSetting>;
|
||||
@Input({required: true}) readingProfile!: ReadingProfile;
|
||||
@Input({required: true}) chapterId!: number;
|
||||
|
||||
readonly pageNumberChange = output<number>();
|
||||
readonly loadNextChapter = output<void>();
|
||||
readonly loadPrevChapter = output<void>();
|
||||
@ -124,13 +126,11 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy,
|
||||
@Input() bookmarkPage: ReplaySubject<number> = new ReplaySubject<number>();
|
||||
@Input() fullscreenToggled: ReplaySubject<boolean> = new ReplaySubject<boolean>();
|
||||
|
||||
readonly bottomSpacer = viewChild.required<ElementRef>('bottomSpacer');
|
||||
bottomSpacerIntersectionObserver: IntersectionObserver = new IntersectionObserver((entries) => this.handleBottomIntersection(entries),
|
||||
{ threshold: 1.0 });
|
||||
|
||||
darkness$: Observable<string> = of('brightness(100%)');
|
||||
|
||||
readerElemRef!: ElementRef<HTMLDivElement>;
|
||||
/** This will update the output to allow for throttling, since we hit the page change on scroll event **/
|
||||
private pageChangeSubject = new Subject<number>();
|
||||
|
||||
/**
|
||||
* Stores and emits all the src urls
|
||||
@ -185,10 +185,6 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy,
|
||||
* If the manga reader is in fullscreen. Some math changes based on this value.
|
||||
*/
|
||||
isFullscreenMode: boolean = false;
|
||||
/**
|
||||
* Keeps track of the previous scrolling height for restoring scroll position after we inject spacer block
|
||||
*/
|
||||
previousScrollHeightMinusTop: number = 0;
|
||||
/**
|
||||
* Tracks the first load, until all the initial prefetched images are loaded. We use this to reduce opacity so images can load without jerk.
|
||||
*/
|
||||
@ -200,7 +196,7 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy,
|
||||
/**
|
||||
* Debug mode. Will show extra information. Use bitwise (|) operators between different modes to enable different output
|
||||
*/
|
||||
debugMode: DEBUG_MODES = DEBUG_MODES.Logs;
|
||||
debugMode: DEBUG_MODES = DEBUG_MODES.None;
|
||||
/**
|
||||
* Debug mode. Will filter out any messages in here so they don't hit the log
|
||||
*/
|
||||
@ -230,6 +226,12 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy,
|
||||
if (reader !== null) {
|
||||
this.readerElemRef = new ElementRef(reader as HTMLDivElement);
|
||||
}
|
||||
|
||||
this.pageChangeSubject.pipe(
|
||||
debounceTime(300),
|
||||
takeUntilDestroyed(this.destroyRef),
|
||||
tap(page => this.pageNumberChange.emit(page)),
|
||||
).subscribe();
|
||||
}
|
||||
|
||||
ngOnChanges(changes: SimpleChanges): void {
|
||||
@ -366,9 +368,6 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy,
|
||||
}
|
||||
}
|
||||
|
||||
ngAfterViewInit() {
|
||||
this.bottomSpacerIntersectionObserver.observe(this.bottomSpacer().nativeElement);
|
||||
}
|
||||
|
||||
recalculateImageWidth() {
|
||||
const [_, innerWidth] = this.getInnerDimensions();
|
||||
@ -394,7 +393,7 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy,
|
||||
|
||||
/**
|
||||
* On scroll in document, calculate if the user/javascript has scrolled to the current image element (and it's visible), update that scrolling has ended completely,
|
||||
* and calculate the direction the scrolling is occuring. This is not used for prefetching.
|
||||
* and calculate the direction the scrolling is occurring. This is not used for prefetching.
|
||||
* @param event Scroll Event
|
||||
*/
|
||||
handleScrollEvent(event?: any) {
|
||||
@ -412,20 +411,6 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy,
|
||||
this.isScrolling = false;
|
||||
this.cdRef.markForCheck();
|
||||
}
|
||||
|
||||
// if (!this.isScrolling) {
|
||||
// // Use offset of the image against the scroll container to test if the most of the image is visible on the screen. We can use this
|
||||
// // to mark the current page and separate the prefetching code.
|
||||
// const midlineImages = Array.from(document.querySelectorAll('img[id^="page-"]'))
|
||||
// .filter(entry => this.shouldElementCountAsCurrentPage(entry));
|
||||
//
|
||||
// if (midlineImages.length > 0) {
|
||||
// this.setPageNum(parseInt(midlineImages[0].getAttribute('page') || this.pageNum + '', 10));
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// Check if we hit the last page
|
||||
this.checkIfShouldTriggerContinuousReader();
|
||||
}
|
||||
|
||||
handleScrollEndEvent(event?: any) {
|
||||
@ -460,55 +445,6 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy,
|
||||
return document.body.scrollTop;
|
||||
}
|
||||
|
||||
checkIfShouldTriggerContinuousReader() {
|
||||
if (this.isScrolling || this.isInitialLoad) return;
|
||||
|
||||
if (this.scrollingDirection === PAGING_DIRECTION.FORWARD) {
|
||||
const totalHeight = this.getTotalHeight();
|
||||
const totalScroll = this.getTotalScroll();
|
||||
|
||||
// If we were at top but have started scrolling down past page 0, remove top spacer
|
||||
if (this.atTop && this.pageNum > 0) {
|
||||
this.atTop = false;
|
||||
this.cdRef.markForCheck();
|
||||
}
|
||||
|
||||
if (totalHeight != 0 && totalScroll >= totalHeight && !this.atBottom) {
|
||||
this.atBottom = true;
|
||||
this.cdRef.markForCheck();
|
||||
this.setPageNum(this.totalPages);
|
||||
|
||||
// Scroll user back to original location
|
||||
this.previousScrollHeightMinusTop = this.getScrollTop();
|
||||
requestAnimationFrame(() => {
|
||||
document.body.scrollTop = this.previousScrollHeightMinusTop + (SPACER_SCROLL_INTO_PX / 2);
|
||||
this.cdRef.markForCheck();
|
||||
});
|
||||
this.checkIfShouldTriggerContinuousReader()
|
||||
} else if (totalScroll >= totalHeight + SPACER_SCROLL_INTO_PX && this.atBottom) {
|
||||
// This if statement will fire once we scroll into the spacer at all
|
||||
this.moveToNextChapter();
|
||||
}
|
||||
} else {
|
||||
// < 5 because debug mode and FF (mobile) can report non 0, despite being at 0
|
||||
if (this.getScrollTop() < 5 && this.pageNum === 0 && !this.atTop) {
|
||||
this.atBottom = false;
|
||||
this.atTop = true;
|
||||
this.cdRef.markForCheck();
|
||||
|
||||
// Scroll user back to original location
|
||||
this.previousScrollHeightMinusTop = document.body.scrollHeight - document.body.scrollTop;
|
||||
|
||||
const reader = this.isFullscreenMode ? this.readerElemRef.nativeElement : this.document.body;
|
||||
requestAnimationFrame(() => this.scrollService.scrollTo((SPACER_SCROLL_INTO_PX / 2), reader));
|
||||
} else if (this.getScrollTop() < 5 && this.pageNum === 0 && this.atTop) {
|
||||
// If already at top, then we are moving on
|
||||
this.loadPrevChapter.emit(undefined);
|
||||
this.cdRef.markForCheck();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @returns Height, Width
|
||||
@ -601,9 +537,8 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy,
|
||||
this.recalculateImageWidth();
|
||||
this.imagesLoaded = {};
|
||||
this.webtoonImages.next([]);
|
||||
this.retryImages = new Queue();
|
||||
this.retryImages = new Queue<{page: number, src: string, chapterId: number, retryCount: number}>();
|
||||
this.atBottom = false;
|
||||
this.checkIfShouldTriggerContinuousReader();
|
||||
this.cdRef.markForCheck();
|
||||
const [startingIndex, endingIndex] = this.calculatePrefetchIndecies();
|
||||
|
||||
@ -675,57 +610,61 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy,
|
||||
if (this.isProcessingRetries) return;
|
||||
this.isProcessingRetries = true;
|
||||
|
||||
while (!this.retryImages.isEmpty()) {
|
||||
const item = this.retryImages.dequeue();
|
||||
if (!item) continue;
|
||||
try {
|
||||
while (!this.retryImages.isEmpty()) {
|
||||
const item = this.retryImages.dequeue();
|
||||
if (!item) continue;
|
||||
|
||||
this.debugLog('Retrying failed load of page ' + item.page, ' retry count: ' + item.retryCount)
|
||||
// Skip stale (chapter id has changed)
|
||||
if (item?.chapterId !== this.chapterId) continue;
|
||||
this.debugLog('Retrying failed load of page ' + item.page, ' retry count: ' + item.retryCount)
|
||||
// Skip stale (chapter id has changed)
|
||||
if (item?.chapterId !== this.chapterId) continue;
|
||||
|
||||
// Skip descoped DOM
|
||||
const pageElem = this.document.querySelector('img#page-' + item.page) as HTMLImageElement;
|
||||
if (!pageElem) continue;
|
||||
// Skip descoped DOM
|
||||
const pageElem = this.document.querySelector('img#page-' + item.page) as HTMLImageElement;
|
||||
if (!pageElem) continue;
|
||||
|
||||
const urlWithoutRetry = item.src.split('&retry=')[0];
|
||||
pageElem.src = urlWithoutRetry + '&retry=' + item.retryCount;
|
||||
const urlWithoutRetry = item.src.split('&retry=')[0];
|
||||
pageElem.src = urlWithoutRetry + '&retry=' + item.retryCount;
|
||||
|
||||
const success = await this.waitForLoadOrError(pageElem);
|
||||
const success = await this.waitForLoadOrError(pageElem);
|
||||
|
||||
if (success) {
|
||||
this.debugLog('Resolved a failed load for page: ', item.page);
|
||||
// Remove the error styling
|
||||
this.renderer.setStyle(pageElem, 'border', 'initial');
|
||||
this.onImageLoad({ target: pageElem });
|
||||
} else if (item.retryCount < MAX_FAILED_IMG_RETRIES) {
|
||||
item.retryCount++;
|
||||
this.retryImages.enqueue(item);
|
||||
await this.delay(1000 * item.retryCount); // Backoff pressure
|
||||
} else {
|
||||
console.error('Failed to load page ' + this.pageNum + ' after 3 retries');
|
||||
if (success) {
|
||||
this.debugLog('Resolved a failed load for page: ', item.page);
|
||||
// Remove the error styling
|
||||
this.renderer.removeStyle(pageElem, 'border');
|
||||
this.renderer.removeStyle(pageElem, 'height');
|
||||
this.onImageLoad({ target: pageElem });
|
||||
} else if (item.retryCount < MAX_FAILED_IMG_RETRIES) {
|
||||
item.retryCount++;
|
||||
this.retryImages.enqueue(item);
|
||||
await this.delay(1000 * item.retryCount); // Backoff pressure
|
||||
} else {
|
||||
console.error('Failed to load page ' + item.page + ' for chapter ' + item.chapterId + ' after ' + MAX_FAILED_IMG_RETRIES + ' retries');
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
this.isProcessingRetries = false;
|
||||
}
|
||||
|
||||
this.isProcessingRetries = false;
|
||||
}
|
||||
|
||||
private delay(ms: number): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
private waitForLoadOrError(img: HTMLImageElement): Promise<boolean> {
|
||||
private waitForLoadOrError(img: HTMLImageElement): Promise<boolean> {
|
||||
return new Promise(resolve => {
|
||||
img.onload = () => resolve(true);
|
||||
img.onerror = () => resolve(false);
|
||||
const cleanup = () => {
|
||||
img.onload = null;
|
||||
img.onerror = null;
|
||||
clearTimeout(timer);
|
||||
};
|
||||
// Allow the image to load or timeout after ~10 seconds
|
||||
const timer = setTimeout(() => { cleanup(); resolve(false); }, IMAGE_RETRY_TIMEOUT_MS);
|
||||
img.onload = () => { cleanup(); resolve(true); };
|
||||
img.onerror = () => { cleanup(); resolve(false); };
|
||||
});
|
||||
}
|
||||
|
||||
handleBottomIntersection(entries: IntersectionObserverEntry[]) {
|
||||
if (entries.length > 0 && this.pageNum > this.totalPages - 5 && this.initFinished) {
|
||||
this.debugLog('[Intersection] The whole bottom spacer is visible', entries[0].isIntersecting);
|
||||
this.moveToNextChapter();
|
||||
}
|
||||
}
|
||||
|
||||
handleIntersection(entries: IntersectionObserverEntry[]) {
|
||||
if (!this.allImagesLoaded || this.isScrolling) {
|
||||
@ -764,8 +703,10 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy,
|
||||
} else if (pageNum < 0) {
|
||||
pageNum = 0;
|
||||
}
|
||||
|
||||
this.pageNum = pageNum;
|
||||
this.pageNumberChange.emit(this.pageNum);
|
||||
this.pageChangeSubject.next(this.pageNum);
|
||||
|
||||
this.cdRef.markForCheck();
|
||||
|
||||
this.prefetchWebtoonImages();
|
||||
|
||||
@ -1641,7 +1641,10 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
}
|
||||
|
||||
setPageNum(pageNum: number) {
|
||||
this.pageNum = Math.max(Math.min(pageNum, this.maxPages - 1), 0);
|
||||
const clampedPageNum = Math.max(Math.min(pageNum, this.maxPages - 1), 0);
|
||||
const isSamePage = clampedPageNum === pageNum;
|
||||
|
||||
this.pageNum = clampedPageNum;
|
||||
this.pageNumSubject.next({pageNum: this.pageNum, maxPages: this.maxPages});
|
||||
this.cdRef.markForCheck();
|
||||
|
||||
@ -1672,7 +1675,11 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
|
||||
// We need to avoid calling this on first load (except if the chapter only has one page)
|
||||
if (!this.incognitoMode && !this.bookmarkMode() && (!this.inSetup || this.maxPages === 1)) {
|
||||
this.readerService.saveProgress(this.libraryId, this.seriesId, this.volumeId, this.chapterId, tempPageNum).subscribe(() => {/* No operation */});
|
||||
if (isSamePage) {
|
||||
//console.log('Same page, dropping request: ', this.pageNum)
|
||||
return;
|
||||
}
|
||||
this.readerService.saveProgress(this.libraryId, this.seriesId, this.volumeId, this.chapterId, tempPageNum).subscribe();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -0,0 +1,24 @@
|
||||
<div
|
||||
#container
|
||||
class="container"
|
||||
[style.height]="containerHeight()"
|
||||
[class.armed]="isArmed()"
|
||||
[class.triggered]="isTriggered()"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div class="content" [class.stick-bottom]="direction() === 'down'">
|
||||
<span class="title">
|
||||
<i class="fa fa-angle-double-{{directionArrow()}} animate" aria-hidden="true"></i>
|
||||
{{ title() }}
|
||||
<i class="fa fa-angle-double-{{directionArrow()}} animate" aria-hidden="true"></i>
|
||||
</span>
|
||||
<div class="track">
|
||||
<div
|
||||
class="bar"
|
||||
[style.width.%]="progressPercent()"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div #triggerSentinel class="trigger-sentinel" [class.at-top]="direction() === 'down'"></div>
|
||||
</div>
|
||||
@ -0,0 +1,89 @@
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.container {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
user-select: none;
|
||||
|
||||
&.triggered .bar {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
&.armed .stick-bottom {
|
||||
top: calc(100% - 1.25rem);
|
||||
}
|
||||
}
|
||||
|
||||
.content {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
padding-inline: 0.75rem;
|
||||
height: 1.25rem;
|
||||
overflow: visible;
|
||||
pointer-events: none;
|
||||
|
||||
&.stick-bottom {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 0.75rem;
|
||||
white-space: nowrap;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.animate {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
animation: move-up-down 1s linear infinite;
|
||||
}
|
||||
|
||||
.track {
|
||||
flex: 1;
|
||||
max-width: 7.5rem;
|
||||
height: 0.1875rem;
|
||||
border-radius: 0.125rem;
|
||||
background: white;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.bar {
|
||||
height: 100%;
|
||||
border-radius: 0.125rem;
|
||||
background: var(--primary-color);
|
||||
transition: width 60ms linear;
|
||||
}
|
||||
|
||||
.trigger-sentinel {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 0.0625rem;
|
||||
pointer-events: none;
|
||||
|
||||
&.at-top {
|
||||
bottom: auto;
|
||||
top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@keyframes move-up-down {
|
||||
0%, 100% {
|
||||
top: 0;
|
||||
}
|
||||
50% {
|
||||
top: -0.375rem;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,366 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
DestroyRef,
|
||||
effect,
|
||||
ElementRef,
|
||||
inject,
|
||||
input,
|
||||
output,
|
||||
signal,
|
||||
untracked,
|
||||
viewChild
|
||||
} from '@angular/core';
|
||||
import {DOCUMENT} from '@angular/common';
|
||||
import {BreakpointService} from '../../../_services/breakpoint.service';
|
||||
|
||||
/** How long (ms) the user must be idle at the scroll boundary before scroll-driven progress arms. */
|
||||
const SCROLL_ARM_DELAY_MS = 100;
|
||||
|
||||
/** Resting height of the strip before arming. */
|
||||
const RESTING_HEIGHT_REM = 1.25;
|
||||
|
||||
/** Expanded height when armed on desktop, giving the user a scroll-through region. */
|
||||
const ARMED_HEIGHT_DESKTOP_REM = 18.75;
|
||||
|
||||
/** Expanded height when armed on mobile — shorter drag distance for touch. */
|
||||
const ARMED_HEIGHT_MOBILE_REM = 9.375;
|
||||
|
||||
const enum PullState {
|
||||
Idle,
|
||||
Armed,
|
||||
Triggered,
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-pull-to-load',
|
||||
imports: [],
|
||||
templateUrl: './pull-to-load.component.html',
|
||||
styleUrl: './pull-to-load.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class PullToLoadComponent {
|
||||
/** Which direction the user drags to trigger – "up" places the strip at the bottom of content. */
|
||||
readonly direction = input.required<'up' | 'down'>();
|
||||
|
||||
/** Label shown inside the strip (e.g. "Read Next Chapter"). */
|
||||
readonly title = input.required<string>();
|
||||
|
||||
/** Scroll container to monitor. Falls back to the document body when omitted. */
|
||||
readonly scrollContainer = input<ElementRef | HTMLElement | undefined>(undefined);
|
||||
|
||||
/** Suppresses all tracking when true. */
|
||||
readonly disabled = input<boolean>(false);
|
||||
|
||||
/** Fires once when the user completes the scroll-through (trigger sentinel fully visible). */
|
||||
readonly triggered = output<void>();
|
||||
|
||||
private readonly document = inject(DOCUMENT);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private readonly breakpointService = inject(BreakpointService);
|
||||
private readonly container = viewChild.required<ElementRef<HTMLElement>>('container');
|
||||
private readonly triggerSentinel = viewChild.required<ElementRef<HTMLElement>>('triggerSentinel');
|
||||
|
||||
private readonly state = signal(PullState.Idle);
|
||||
readonly progress = signal(0);
|
||||
|
||||
readonly isArmed = computed(() => this.state() === PullState.Armed);
|
||||
readonly isTriggered = computed(() => this.state() === PullState.Triggered);
|
||||
|
||||
readonly progressPercent = computed(() => Math.min(this.progress() * 100, 100));
|
||||
readonly restingHeightRem = RESTING_HEIGHT_REM;
|
||||
|
||||
readonly armedHeightRem = computed(() =>
|
||||
this.breakpointService.isMobile() ? ARMED_HEIGHT_MOBILE_REM : ARMED_HEIGHT_DESKTOP_REM
|
||||
);
|
||||
|
||||
readonly containerHeight = computed(() =>
|
||||
this.isArmed() || this.isTriggered() ? `${this.armedHeightRem()}rem` : `${RESTING_HEIGHT_REM}rem`
|
||||
);
|
||||
readonly directionArrow = computed(() => {
|
||||
switch (this.direction()) {
|
||||
case 'down': return 'up';
|
||||
case 'up': return 'down';
|
||||
}
|
||||
});
|
||||
|
||||
private armTimeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||
private fireTimeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||
private scrollListener: (() => void) | null = null;
|
||||
private isCompensatingScroll = false;
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
// Track only scrollContainer — untracked prevents disarm/setup from
|
||||
// registering state()/direction() as effect dependencies.
|
||||
this.scrollContainer();
|
||||
untracked(() => {
|
||||
this.disarm();
|
||||
this.setupScrollListener();
|
||||
});
|
||||
});
|
||||
|
||||
this.destroyRef.onDestroy(() => {
|
||||
this.teardown();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Attaches a single scroll listener that drives the entire state machine:
|
||||
* Idle: checks if the resting-height container is fully visible -> arms after delay
|
||||
* Armed: tracks scroll-through progress -> fires when sentinel is visible
|
||||
*/
|
||||
private setupScrollListener() {
|
||||
this.teardownScrollListener();
|
||||
|
||||
const scrollEl = this.resolveScrollElement();
|
||||
const scrollTarget = scrollEl instanceof Window ? this.document.body : scrollEl;
|
||||
|
||||
const onScroll = () => this.onScroll();
|
||||
scrollTarget.addEventListener('scroll', onScroll, {passive: true});
|
||||
this.scrollListener = () => scrollTarget.removeEventListener('scroll', onScroll);
|
||||
}
|
||||
|
||||
private onScroll() {
|
||||
if (this.disabled() || this.isCompensatingScroll) return;
|
||||
|
||||
const currentState = this.state();
|
||||
|
||||
if (currentState === PullState.Triggered) return;
|
||||
|
||||
if (currentState === PullState.Idle) {
|
||||
this.checkVisibilityForArming();
|
||||
} else if (currentState === PullState.Armed) {
|
||||
this.updateProgress();
|
||||
this.checkTrigger();
|
||||
this.checkDisarm();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* In Idle state: check if the container (at resting height) is fully visible
|
||||
* in the scroll container using getBoundingClientRect. If so, start the arm countdown.
|
||||
* Uses rect checks instead of IntersectionObserver to avoid issues with
|
||||
* ancestor CSS transforms (e.g. translate3d for hardware acceleration).
|
||||
*/
|
||||
private checkVisibilityForArming() {
|
||||
if (this.isFullyVisible(this.container().nativeElement)) {
|
||||
if (this.armTimeoutId === null) {
|
||||
this.startArmCountdown();
|
||||
}
|
||||
} else {
|
||||
this.clearArmTimeout();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* After SCROLL_ARM_DELAY_MS of being fully visible, arm the component:
|
||||
* expand to full height and start tracking scroll progress.
|
||||
*/
|
||||
private startArmCountdown() {
|
||||
this.clearArmTimeout();
|
||||
|
||||
this.armTimeoutId = setTimeout(() => {
|
||||
if (this.disabled() || this.state() === PullState.Triggered) return;
|
||||
|
||||
// Re-check visibility in case user scrolled away during the delay
|
||||
if (!this.isFullyVisible(this.container().nativeElement)) return;
|
||||
|
||||
this.state.set(PullState.Armed);
|
||||
this.progress.set(0);
|
||||
|
||||
// When direction is 'down' (top spacer), the expansion pushes content downward.
|
||||
// Wait for Angular to update the DOM height, then compensate scroll position
|
||||
// so the user's view stays on the content with just the text bar visible.
|
||||
if (this.direction() === 'down') {
|
||||
requestAnimationFrame(() => this.adjustScrollTop(this.getExpansionDeltaPx()));
|
||||
}
|
||||
}, SCROLL_ARM_DELAY_MS);
|
||||
}
|
||||
|
||||
/**
|
||||
* In Armed state: compute progress as the fraction of the expanded container
|
||||
* that is visible within the scroll container.
|
||||
*/
|
||||
private updateProgress() {
|
||||
const el = this.container().nativeElement;
|
||||
const rect = el.getBoundingClientRect();
|
||||
const [boundsTop, boundsBottom] = this.getScrollBounds();
|
||||
|
||||
let visibleHeight;
|
||||
if (this.direction() === 'up') {
|
||||
visibleHeight = Math.max(0, Math.min(boundsBottom - rect.top, rect.height));
|
||||
} else {
|
||||
visibleHeight = Math.max(0, Math.min(rect.bottom - boundsTop, rect.height));
|
||||
}
|
||||
|
||||
const p = rect.height > 0 ? Math.min(visibleHeight / rect.height, 1) : 0;
|
||||
this.progress.set(p);
|
||||
}
|
||||
|
||||
/**
|
||||
* In Armed state: check if the trigger sentinel at the far end of the
|
||||
* expanded container is fully visible. If so, the user has scrolled through.
|
||||
*/
|
||||
private checkTrigger() {
|
||||
if (this.isFullyVisible(this.triggerSentinel().nativeElement)) {
|
||||
this.fire();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* In Armed state: if the container is no longer even partially visible
|
||||
* within the scroll container, the user scrolled away — disarm and shrink back.
|
||||
*/
|
||||
private checkDisarm() {
|
||||
const rect = this.container().nativeElement.getBoundingClientRect();
|
||||
const [boundsTop, boundsBottom] = this.getScrollBounds();
|
||||
|
||||
if (rect.bottom < boundsTop || rect.top > boundsBottom) {
|
||||
this.disarm();
|
||||
}
|
||||
}
|
||||
|
||||
private fire() {
|
||||
if (this.state() === PullState.Triggered) return;
|
||||
|
||||
this.state.set(PullState.Triggered);
|
||||
this.progress.set(1);
|
||||
this.triggered.emit();
|
||||
|
||||
// Reset after a brief flash so the component is ready for next use
|
||||
this.fireTimeoutId = setTimeout(() => {
|
||||
this.progress.set(0);
|
||||
this.state.set(PullState.Idle);
|
||||
}, 200);
|
||||
}
|
||||
|
||||
private disarm() {
|
||||
this.clearArmTimeout();
|
||||
|
||||
if (this.state() !== PullState.Triggered) {
|
||||
const wasArmed = this.state() === PullState.Armed;
|
||||
this.state.set(PullState.Idle);
|
||||
this.progress.set(0);
|
||||
|
||||
if (wasArmed && this.direction() === 'down') {
|
||||
this.adjustScrollTop(-this.getExpansionDeltaPx());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private getExpansionDeltaPx() {
|
||||
const rootFontSize = parseFloat(getComputedStyle(this.document.documentElement).fontSize) || 16;
|
||||
return (this.armedHeightRem() - RESTING_HEIGHT_REM) * rootFontSize;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adjusts scrollTop by a fixed amount. Sets a guard flag so the resulting
|
||||
* scroll events are ignored and don't re-trigger state changes.
|
||||
* The guard lasts two animation frames to cover the scroll event dispatch.
|
||||
*/
|
||||
private adjustScrollTop(deltaPx: number) {
|
||||
this.isCompensatingScroll = true;
|
||||
|
||||
const scrollEl = this.resolveScrollElement();
|
||||
if (scrollEl instanceof Window) {
|
||||
const current = this.document.documentElement.scrollTop || this.document.body.scrollTop;
|
||||
const target = Math.max(0, current + deltaPx);
|
||||
this.document.documentElement.scrollTop = target;
|
||||
this.document.body.scrollTop = target;
|
||||
} else {
|
||||
scrollEl.scrollTop = Math.max(0, scrollEl.scrollTop + deltaPx);
|
||||
}
|
||||
|
||||
// Two rAF hops: first for the browser to process the scroll, second to
|
||||
// ensure any resulting scroll event has been dispatched and ignored.
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
this.isCompensatingScroll = false;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether an element is fully visible within the scroll container
|
||||
* using getBoundingClientRect. For non-window scroll containers, coordinates
|
||||
* are compared against the container's bounds rather than the browser viewport.
|
||||
* Immune to ancestor CSS transforms that break IntersectionObserver.
|
||||
*/
|
||||
private isFullyVisible(el: HTMLElement): boolean {
|
||||
const rect = el.getBoundingClientRect();
|
||||
const [boundsTop, boundsLeft, boundsBottom, boundsRight] = this.getVisibleBounds();
|
||||
|
||||
// 2px tolerance accounts for sub-pixel rounding from translate3d hardware
|
||||
// acceleration and fractional rem sizing of the trigger sentinel.
|
||||
const tolerance = 2;
|
||||
|
||||
return rect.top >= boundsTop - tolerance
|
||||
&& rect.left >= boundsLeft - tolerance
|
||||
&& rect.bottom <= boundsBottom + tolerance
|
||||
&& rect.right <= boundsRight + tolerance
|
||||
&& rect.height > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns [top, left, bottom, right] bounds of the scroll container in
|
||||
* viewport coordinates. For window this is the viewport rect; for a custom
|
||||
* element it's that element's bounding client rect.
|
||||
*/
|
||||
private getVisibleBounds(): [number, number, number, number] {
|
||||
const scrollEl = this.resolveScrollElement();
|
||||
if (scrollEl instanceof Window) {
|
||||
return [0, 0, this.document.documentElement.clientHeight, this.document.documentElement.clientWidth];
|
||||
}
|
||||
const r = scrollEl.getBoundingClientRect();
|
||||
return [r.top, r.left, r.bottom, r.right];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns [top, bottom] of the scroll container's visible area in viewport coordinates.
|
||||
*/
|
||||
private getScrollBounds(): [number, number] {
|
||||
const b = this.getVisibleBounds();
|
||||
return [b[0], b[2]];
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the actual scroll element. Normalizes `undefined` and `document.body`
|
||||
* to `window` so that scroll listeners attach to `document` (where page-level
|
||||
* scroll events actually fire) and viewport math uses consistent coordinates.
|
||||
*/
|
||||
private resolveScrollElement(): HTMLElement | Window {
|
||||
const ref = this.scrollContainer();
|
||||
if (!ref) return window;
|
||||
const el = ref instanceof ElementRef ? ref.nativeElement : ref;
|
||||
return el === this.document.body ? window : el;
|
||||
}
|
||||
|
||||
private clearArmTimeout() {
|
||||
if (this.armTimeoutId !== null) {
|
||||
clearTimeout(this.armTimeoutId);
|
||||
this.armTimeoutId = null;
|
||||
}
|
||||
}
|
||||
|
||||
private clearFireTimeout() {
|
||||
if (this.fireTimeoutId !== null) {
|
||||
clearTimeout(this.fireTimeoutId);
|
||||
this.fireTimeoutId = null;
|
||||
}
|
||||
}
|
||||
|
||||
private teardownScrollListener() {
|
||||
if (this.scrollListener) {
|
||||
this.scrollListener();
|
||||
this.scrollListener = null;
|
||||
}
|
||||
}
|
||||
|
||||
private teardown() {
|
||||
this.teardownScrollListener();
|
||||
this.clearArmTimeout();
|
||||
this.clearFireTimeout();
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user