From e77829a4653b4c792330bb99a5cb10e4f9087e53 Mon Sep 17 00:00:00 2001 From: evtkhvch Date: Fri, 24 Sep 2021 15:22:13 +0300 Subject: [PATCH] fix(docs): add visibility service and ref fragment-target directive --- .../fragment-target.directive.ts | 94 ++++++---- docs/app/@theme/services/index.ts | 2 + .../app/@theme/services/visibility.service.ts | 166 ++++++++++++++++++ .../page/ngx-admin-landing-page.component.ts | 27 --- 4 files changed, 227 insertions(+), 62 deletions(-) create mode 100644 docs/app/@theme/services/visibility.service.ts diff --git a/docs/app/@theme/components/fragment-target/fragment-target.directive.ts b/docs/app/@theme/components/fragment-target/fragment-target.directive.ts index 10b36d0e..37389038 100644 --- a/docs/app/@theme/components/fragment-target/fragment-target.directive.ts +++ b/docs/app/@theme/components/fragment-target/fragment-target.directive.ts @@ -1,73 +1,97 @@ -import { Directive, ElementRef, Inject, Input, OnDestroy, OnInit, Renderer2 } from '@angular/core'; -import { ActivatedRoute } from '@angular/router'; -import { NB_WINDOW } from '@nebular/theme'; -import { takeWhile, publish, refCount } from 'rxjs/operators'; -import { NgxTocElement, NgxTocStateService } from '../../services/toc-state.service'; -import { delay } from 'rxjs/internal/operators'; +import { Directive, ElementRef, Inject, Input, OnDestroy, OnInit, PLATFORM_ID, Renderer2 } from '@angular/core'; +import { ActivatedRoute, Router } from '@angular/router'; +import { NB_WINDOW, NbLayoutScrollService } from '@nebular/theme'; +import { debounce, filter, publish, refCount, takeUntil, tap } from 'rxjs/operators'; +import { Subject, timer } from 'rxjs'; + +import { NgxVisibilityService } from '../../services/visibility.service'; + +const OBSERVER_OPTIONS = { rootMargin: '-100px 0px 0px' }; @Directive({ // tslint:disable-next-line selector: '[ngxFragment]', }) -export class NgxFragmentTargetDirective implements OnInit, OnDestroy, NgxTocElement { +export class NgxFragmentTargetDirective implements OnInit, OnDestroy { + + private readonly marginFromTop = 120; + private isCurrentlyViewed: boolean = false; + private isScrolling: boolean = false; + private destroy$ = new Subject(); + @Input() ngxFragment: string; @Input() ngxFragmentClass: string; @Input() ngxFragmentSync: boolean = true; - private inView = false; - private alive = true; - private readonly marginFromTop = 120; - - get fragment(): string { - return this.ngxFragment; - } - - get element(): any { - return this.el.nativeElement; - } - - get y(): number { - return this.element.getBoundingClientRect().y; - } - constructor( private activatedRoute: ActivatedRoute, @Inject(NB_WINDOW) private window, - private tocState: NgxTocStateService, - private el: ElementRef, + private el: ElementRef, private renderer: Renderer2, + private router: Router, + @Inject(PLATFORM_ID) private platformId, + private visibilityService: NgxVisibilityService, + private scrollService: NbLayoutScrollService, ) {} ngOnInit() { - this.ngxFragmentSync && this.tocState.add(this); - this.activatedRoute.fragment - .pipe(publish(null), refCount(), takeWhile(() => this.alive), delay(10)) + .pipe( + publish(null), + refCount(), + takeUntil(this.destroy$), + filter(() => this.ngxFragmentSync), + ) .subscribe((fragment: string) => { - if (fragment && this.fragment === fragment && !this.inView) { + if (fragment && this.ngxFragment === fragment) { this.selectFragment(); } else { this.deselectFragment(); } }); + + this.visibilityService.isTopmostVisible(this.el.nativeElement, OBSERVER_OPTIONS) + .pipe(takeUntil(this.destroy$)) + .subscribe((isTopmost: boolean) => { + this.isCurrentlyViewed = isTopmost; + if (isTopmost) { + this.updateUrl(); + } + }); + + this.scrollService.onScroll() + .pipe( + tap(() => this.isScrolling = true), + debounce(() => timer(100)), + takeUntil(this.destroy$), + ) + .subscribe(() => this.isScrolling = false); } selectFragment() { this.ngxFragmentClass && this.renderer.addClass(this.el.nativeElement, this.ngxFragmentClass); - this.setInView(true); - this.window.scrollTo(0, this.el.nativeElement.offsetTop - this.marginFromTop); + + const shouldScroll = !this.isCurrentlyViewed || !this.isScrolling; + if (shouldScroll) { + this.window.scrollTo(0, this.el.nativeElement.offsetTop - this.marginFromTop); + } } deselectFragment() { this.renderer.removeClass(this.el.nativeElement, this.ngxFragmentClass); } - setInView(val: boolean) { - this.inView = val; + updateUrl() { + const urlFragment = this.activatedRoute.snapshot.fragment; + const alreadyThere = urlFragment && urlFragment.includes(this.ngxFragment); + if (!alreadyThere) { + this.router.navigate([], { fragment: this.ngxFragment, replaceUrl: true }); + } } ngOnDestroy() { - this.alive = false; - this.ngxFragmentSync && this.tocState.remove(this); + this.destroy$.next(); + this.destroy$.complete(); + this.visibilityService.unobserve(this.el.nativeElement, OBSERVER_OPTIONS); } } diff --git a/docs/app/@theme/services/index.ts b/docs/app/@theme/services/index.ts index 93f027bc..98ad2799 100644 --- a/docs/app/@theme/services/index.ts +++ b/docs/app/@theme/services/index.ts @@ -17,6 +17,7 @@ import { NgxTocStateService } from './toc-state.service'; import { NgxCodeLoaderService } from './code-loader.service'; import { NgxStylesService } from './styles.service'; import { NgxIframeCommunicatorService } from './iframe-communicator.service'; +import { NgxVisibilityService } from './visibility.service'; export const ngxLandingServices = [ @@ -33,4 +34,5 @@ export const ngxLandingServices = [ NgxCodeLoaderService, NgxStylesService, NgxIframeCommunicatorService, + NgxVisibilityService, ]; diff --git a/docs/app/@theme/services/visibility.service.ts b/docs/app/@theme/services/visibility.service.ts new file mode 100644 index 00000000..4c787c14 --- /dev/null +++ b/docs/app/@theme/services/visibility.service.ts @@ -0,0 +1,166 @@ +import { isPlatformBrowser } from '@angular/common'; +import { Inject, Injectable, PLATFORM_ID } from '@angular/core'; +import { NB_WINDOW } from '@nebular/theme'; +import { EMPTY, Observable, Subject } from 'rxjs'; +import { distinctUntilChanged, filter, finalize, map, publish, refCount, takeUntil, tap } from 'rxjs/operators'; + +interface ObserverWithStream { + intersectionObserver: IntersectionObserver; + visibilityChange$: Observable; +} + +@Injectable() +export class NgxVisibilityService { + + private readonly isBrowser: boolean; + private readonly supportsIntersectionObserver: boolean; + + private readonly visibilityObservers = new Map(); + private readonly topmostObservers = new Map>(); + private readonly visibleElements = new Map(); + private readonly unobserve$ = new Subject<{ target: Element, options: IntersectionObserverInit }>(); + + constructor( + @Inject(PLATFORM_ID) platformId: Object, + @Inject(NB_WINDOW) private window, + ) { + this.isBrowser = isPlatformBrowser(platformId); + this.supportsIntersectionObserver = !!this.window.IntersectionObserver; + } + + visibilityChange(target: Element, options: IntersectionObserverInit): Observable { + if (!this.isBrowser || !this.supportsIntersectionObserver) { + return EMPTY; + } + + let visibilityObserver = this.visibilityObservers.get(options); + if (!visibilityObserver) { + visibilityObserver = this.addVisibilityChangeObserver(options); + } + const { intersectionObserver, visibilityChange$ } = visibilityObserver; + intersectionObserver.observe(target); + + const targetUnobserved$ = this.unobserve$.pipe(filter(e => e.target === target && e.options === options)); + + return visibilityChange$.pipe( + map((entries: IntersectionObserverEntry[]) => entries.find(entry => entry.target === target)), + filter((entry: IntersectionObserverEntry | undefined) => !!entry), + finalize(() => { + intersectionObserver.unobserve(target); + this.removeFromVisible(options, target); + }), + takeUntil(targetUnobserved$), + ); + } + + isTopmostVisible(target: Element, options: IntersectionObserverInit): Observable { + if (!this.isBrowser || !this.supportsIntersectionObserver) { + return EMPTY; + } + + const targetUnobserve$ = this.unobserve$.pipe(filter(e => e.target === target && e.options === options)); + const topmostChange$ = this.topmostObservers.get(options) || this.addTopmostChangeObserver(options); + + const { intersectionObserver } = this.visibilityObservers.get(options); + intersectionObserver.observe(target); + + return topmostChange$.pipe( + finalize(() => { + intersectionObserver.unobserve(target); + this.removeFromVisible(options, target); + }), + map((element: Element) => element === target), + distinctUntilChanged(), + takeUntil(targetUnobserve$), + ); + } + + unobserve(target: Element, options: IntersectionObserverInit): void { + this.unobserve$.next({ target, options }); + } + + private addVisibilityChangeObserver(options: IntersectionObserverInit): ObserverWithStream { + const visibilityChange$ = new Subject(); + const intersectionObserver = new IntersectionObserver( + (entries: IntersectionObserverEntry[]) => visibilityChange$.next(entries), + options, + ); + const refCountedObserver = visibilityChange$.pipe( + finalize(() => { + this.visibilityObservers.delete(options); + this.visibleElements.delete(options); + intersectionObserver.disconnect(); + }), + tap((entries: IntersectionObserverEntry[]) => this.updateVisibleItems(options, entries)), + publish(), + refCount(), + ); + + const observerWithStream = { intersectionObserver, visibilityChange$: refCountedObserver }; + this.visibilityObservers.set(options, observerWithStream); + return observerWithStream; + } + + private addTopmostChangeObserver(options: IntersectionObserverInit): Observable { + const { visibilityChange$ } = this.visibilityObservers.get(options) || this.addVisibilityChangeObserver(options); + + const topmostChange$ = visibilityChange$.pipe( + finalize(() => this.topmostObservers.delete(options)), + map(() => this.findTopmostElement(options)), + distinctUntilChanged(), + publish(), + refCount(), + ); + + this.topmostObservers.set(options, topmostChange$); + return topmostChange$; + } + + private updateVisibleItems(options, entries: IntersectionObserverEntry[]) { + for (const entry of entries) { + if (entry.isIntersecting) { + this.addToVisible(options, entry.target); + } else { + this.removeFromVisible(options, entry.target); + } + } + } + + private addToVisible(options: IntersectionObserverInit, element: Element): void { + if (!this.visibleElements.has(options)) { + this.visibleElements.set(options, []); + } + + const existing = this.visibleElements.get(options); + if (existing.indexOf(element) === -1) { + existing.push(element); + } + } + + private removeFromVisible(options: IntersectionObserverInit, element: Element): void { + const visibleEntries = this.visibleElements.get(options); + if (!visibleEntries) { + return; + } + + const index = visibleEntries.indexOf(element); + if (index !== -1) { + visibleEntries.splice(index, 1); + } + } + + private findTopmostElement(options: IntersectionObserverInit): Element | undefined { + const visibleElements = this.visibleElements.get(options); + if (!visibleElements) { + return; + } + + let topmost: Element; + for (const element of visibleElements) { + if (!topmost || element.getBoundingClientRect().top < topmost.getBoundingClientRect().top) { + topmost = element; + } + } + return topmost; + } +} diff --git a/docs/app/pages/docs/page/ngx-admin-landing-page.component.ts b/docs/app/pages/docs/page/ngx-admin-landing-page.component.ts index ca07cd61..bd27ec2d 100644 --- a/docs/app/pages/docs/page/ngx-admin-landing-page.component.ts +++ b/docs/app/pages/docs/page/ngx-admin-landing-page.component.ts @@ -9,14 +9,12 @@ import { ActivatedRoute, Router } from '@angular/router'; import { filter, map, - publishBehavior, publishReplay, refCount, tap, takeWhile, } from 'rxjs/operators'; import { NB_WINDOW } from '@nebular/theme'; -import { fromEvent } from 'rxjs'; import { NgxStructureService } from '../../../@theme/services/structure.service'; import { NgxTocStateService } from '../../../@theme/services/toc-state.service'; @@ -48,7 +46,6 @@ export class NgxAdminLandingPageComponent implements OnDestroy, OnInit { ngOnInit() { this.handlePageNavigation(); - this.handleTocScroll(); this.window.history.scrollRestoration = 'manual'; } @@ -75,30 +72,6 @@ export class NgxAdminLandingPageComponent implements OnDestroy, OnInit { }); } - handleTocScroll() { - this.ngZone.runOutsideAngular(() => { - fromEvent(this.window, 'scroll') - .pipe( - publishBehavior(null), - refCount(), - takeWhile(() => this.alive), - filter(() => this.tocState.list().length > 0), - ) - .subscribe(() => { - this.tocState.list().map(item => item.setInView(false)); - - const current: any = this.tocState.list().reduce((acc, item) => { - return item.y > 0 && item.y < acc.y ? item : acc; - }, { y: Number.POSITIVE_INFINITY, fake: true }); - - if (current && !current.fake) { - current.setInView(true); - this.router.navigate([], { fragment: current.fragment, replaceUrl: true }); - } - }); - }); - } - ngOnDestroy() { this.alive = false; }