From 0e56b30dc328759862f4eef108bfa04a765bfbb1 Mon Sep 17 00:00:00 2001 From: Joe Milazzo Date: Sat, 11 Apr 2026 08:53:54 -0500 Subject: [PATCH] Webtoon Reader Continuous Reader Changes (#4598) --- README.md | 2 +- .../infinite-scroller.component.html | 38 +- .../infinite-scroller.component.scss | 20 - .../infinite-scroller.component.ts | 191 ++++----- .../manga-reader/manga-reader.component.ts | 11 +- .../pull-to-load/pull-to-load.component.html | 24 ++ .../pull-to-load/pull-to-load.component.scss | 89 +++++ .../pull-to-load/pull-to-load.component.ts | 366 ++++++++++++++++++ 8 files changed, 565 insertions(+), 176 deletions(-) create mode 100644 UI/Web/src/app/shared/_components/pull-to-load/pull-to-load.component.html create mode 100644 UI/Web/src/app/shared/_components/pull-to-load/pull-to-load.component.scss create mode 100644 UI/Web/src/app/shared/_components/pull-to-load/pull-to-load.component.ts diff --git a/README.md b/README.md index 2e87fb0d4..93c1ade04 100644 --- a/README.md +++ b/README.md @@ -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. Translation status diff --git a/UI/Web/src/app/manga-reader/_components/infinite-scroller/infinite-scroller.component.html b/UI/Web/src/app/manga-reader/_components/infinite-scroller/infinite-scroller.component.html index 63f83cbe1..5886de491 100644 --- a/UI/Web/src/app/manga-reader/_components/infinite-scroller/infinite-scroller.component.html +++ b/UI/Web/src/app/manga-reader/_components/infinite-scroller/infinite-scroller.component.html @@ -15,21 +15,11 @@ } - @if (atTop) { - - } +
@for(item of webtoonImages | async; let index = $index; track item.src) { @@ -48,18 +38,10 @@ }
- + diff --git a/UI/Web/src/app/manga-reader/_components/infinite-scroller/infinite-scroller.component.scss b/UI/Web/src/app/manga-reader/_components/infinite-scroller/infinite-scroller.component.scss index f710eb964..b639c4de6 100644 --- a/UI/Web/src/app/manga-reader/_components/infinite-scroller/infinite-scroller.component.scss +++ b/UI/Web/src/app/manga-reader/_components/infinite-scroller/infinite-scroller.component.scss @@ -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; diff --git a/UI/Web/src/app/manga-reader/_components/infinite-scroller/infinite-scroller.component.ts b/UI/Web/src/app/manga-reader/_components/infinite-scroller/infinite-scroller.component.ts index 0130c270c..f42f0f0de 100644 --- a/UI/Web/src/app/manga-reader/_components/infinite-scroller/infinite-scroller.component.ts +++ b/UI/Web/src/app/manga-reader/_components/infinite-scroller/infinite-scroller.component.ts @@ -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); 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; @Input({required: true}) readingProfile!: ReadingProfile; @Input({required: true}) chapterId!: number; + readonly pageNumberChange = output(); readonly loadNextChapter = output(); readonly loadPrevChapter = output(); @@ -124,13 +126,11 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy, @Input() bookmarkPage: ReplaySubject = new ReplaySubject(); @Input() fullscreenToggled: ReplaySubject = new ReplaySubject(); - readonly bottomSpacer = viewChild.required('bottomSpacer'); - bottomSpacerIntersectionObserver: IntersectionObserver = new IntersectionObserver((entries) => this.handleBottomIntersection(entries), - { threshold: 1.0 }); - darkness$: Observable = of('brightness(100%)'); readerElemRef!: ElementRef; + /** This will update the output to allow for throttling, since we hit the page change on scroll event **/ + private pageChangeSubject = new Subject(); /** * 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 { return new Promise(resolve => setTimeout(resolve, ms)); } - private waitForLoadOrError(img: HTMLImageElement): Promise { + private waitForLoadOrError(img: HTMLImageElement): Promise { 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(); diff --git a/UI/Web/src/app/manga-reader/_components/manga-reader/manga-reader.component.ts b/UI/Web/src/app/manga-reader/_components/manga-reader/manga-reader.component.ts index 1edace7bf..830ac92fd 100644 --- a/UI/Web/src/app/manga-reader/_components/manga-reader/manga-reader.component.ts +++ b/UI/Web/src/app/manga-reader/_components/manga-reader/manga-reader.component.ts @@ -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(); } } diff --git a/UI/Web/src/app/shared/_components/pull-to-load/pull-to-load.component.html b/UI/Web/src/app/shared/_components/pull-to-load/pull-to-load.component.html new file mode 100644 index 000000000..3f5f13240 --- /dev/null +++ b/UI/Web/src/app/shared/_components/pull-to-load/pull-to-load.component.html @@ -0,0 +1,24 @@ + diff --git a/UI/Web/src/app/shared/_components/pull-to-load/pull-to-load.component.scss b/UI/Web/src/app/shared/_components/pull-to-load/pull-to-load.component.scss new file mode 100644 index 000000000..3f45f5bee --- /dev/null +++ b/UI/Web/src/app/shared/_components/pull-to-load/pull-to-load.component.scss @@ -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; + } +} diff --git a/UI/Web/src/app/shared/_components/pull-to-load/pull-to-load.component.ts b/UI/Web/src/app/shared/_components/pull-to-load/pull-to-load.component.ts new file mode 100644 index 000000000..9a83cb7fd --- /dev/null +++ b/UI/Web/src/app/shared/_components/pull-to-load/pull-to-load.component.ts @@ -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(); + + /** Scroll container to monitor. Falls back to the document body when omitted. */ + readonly scrollContainer = input(undefined); + + /** Suppresses all tracking when true. */ + readonly disabled = input(false); + + /** Fires once when the user completes the scroll-through (trigger sentinel fully visible). */ + readonly triggered = output(); + + private readonly document = inject(DOCUMENT); + private readonly destroyRef = inject(DestroyRef); + private readonly breakpointService = inject(BreakpointService); + private readonly container = viewChild.required>('container'); + private readonly triggerSentinel = viewChild.required>('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 | null = null; + private fireTimeoutId: ReturnType | 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(); + } +}