Webtoon Reader Continuous Reader Changes (#4598)

This commit is contained in:
Joe Milazzo 2026-04-11 08:53:54 -05:00 committed by GitHub
parent cb1d12046f
commit 0e56b30dc3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 565 additions and 176 deletions

View File

@ -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" />

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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