From bc2ab1da29bbf3f325c119d65410434e84e3681d Mon Sep 17 00:00:00 2001 From: Sergey Andrievskiy Date: Thu, 31 Jan 2019 11:51:46 +0300 Subject: [PATCH] fix(docs): prevent scrolling when user scroll (#982) Fixes auto scrolling to page top when trying to scroll to the bottom. Closes #810. --- .../fragment-target.directive.ts | 91 ++++++---- .../components/page-toc/page-toc.component.ts | 4 +- docs/app/@theme/services/index.ts | 2 +- docs/app/@theme/services/toc-state.service.ts | 29 --- .../app/@theme/services/visibility.service.ts | 166 ++++++++++++++++++ docs/app/@theme/theme.module.ts | 4 +- docs/app/documentation/page/page.component.ts | 53 +----- 7 files changed, 236 insertions(+), 113 deletions(-) delete mode 100644 docs/app/@theme/services/toc-state.service.ts 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 05176b8e31..d32b4e2992 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,94 @@ -import { Directive, ElementRef, Inject, Input, OnDestroy, OnInit, Renderer2 } from '@angular/core'; -import { ActivatedRoute } from '@angular/router'; -import { NB_WINDOW } from '@nebular/theme'; -import { takeWhile, filter, publish, refCount } from 'rxjs/operators'; -import { NgdTocElement, NgdTocStateService } from '../../services'; -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 { timer } from 'rxjs'; +import { takeWhile, publish, refCount, filter, tap, debounce } from 'rxjs/operators'; +import { NB_WINDOW, NbLayoutScrollService } from '@nebular/theme'; +import { NgdVisibilityService } from '../../../@theme/services'; + +const OBSERVER_OPTIONS = { rootMargin: '-100px 0px 0px' }; @Directive({ - // tslint:disable-next-line selector: '[ngdFragment]', }) -export class NgdFragmentTargetDirective implements OnInit, OnDestroy, NgdTocElement { - @Input() ngdFragment: string; - @Input() ngdFragmentClass: string; - @Input() ngdFragmentSync: boolean = true; +export class NgdFragmentTargetDirective implements OnInit, OnDestroy { - private inView = false; - private alive = true; private readonly marginFromTop = 120; + private isCurrentlyViewed: boolean = false; + private isScrolling: boolean = false; + private alive = true; - get fragment(): string { - return this.ngdFragment; - } - - get element(): any { - return this.el.nativeElement; - } - - get y(): number { - return this.element.getBoundingClientRect().y; - } + @Input() ngdFragment: string; + @Input() ngdFragmentClass: string; + @Input() ngdFragmentSync: boolean = true; constructor( private activatedRoute: ActivatedRoute, @Inject(NB_WINDOW) private window, - private tocState: NgdTocStateService, - private el: ElementRef, + private el: ElementRef, private renderer: Renderer2, + private router: Router, + @Inject(PLATFORM_ID) private platformId, + private visibilityService: NgdVisibilityService, + private scrollService: NbLayoutScrollService, ) {} ngOnInit() { - this.ngdFragmentSync && this.tocState.add(this); - this.activatedRoute.fragment - .pipe(publish(null), refCount(), takeWhile(() => this.alive), delay(10)) + .pipe( + publish(null), + refCount(), + takeWhile(() => this.alive), + filter(() => this.ngdFragmentSync), + ) .subscribe((fragment: string) => { - if (fragment && this.fragment === fragment && !this.inView) { + if (fragment && this.ngdFragment === fragment) { this.selectFragment(); } else { this.deselectFragment(); } }); + + this.visibilityService.isTopmostVisible(this.el.nativeElement, OBSERVER_OPTIONS) + .pipe(takeWhile(() => this.alive)) + .subscribe((isTopmost: boolean) => { + this.isCurrentlyViewed = isTopmost; + if (isTopmost) { + this.updateUrl(); + } + }); + + this.scrollService.onScroll() + .pipe( + takeWhile(() => this.alive), + tap(() => this.isScrolling = true), + debounce(() => timer(100)), + ) + .subscribe(() => this.isScrolling = false); } selectFragment() { this.ngdFragmentClass && this.renderer.addClass(this.el.nativeElement, this.ngdFragmentClass); - 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.ngdFragmentClass); } - setInView(val: boolean) { - this.inView = val; + updateUrl() { + const urlFragment = this.activatedRoute.snapshot.fragment; + const alreadyThere = urlFragment && urlFragment.includes(this.ngdFragment); + if (!alreadyThere) { + this.router.navigate([], { fragment: this.ngdFragment, replaceUrl: true }); + } } ngOnDestroy() { this.alive = false; - this.ngdFragmentSync && this.tocState.remove(this); + this.visibilityService.unobserve(this.el.nativeElement, OBSERVER_OPTIONS); } } diff --git a/docs/app/@theme/components/page-toc/page-toc.component.ts b/docs/app/@theme/components/page-toc/page-toc.component.ts index d41b0db876..f94121a6f6 100644 --- a/docs/app/@theme/components/page-toc/page-toc.component.ts +++ b/docs/app/@theme/components/page-toc/page-toc.component.ts @@ -5,7 +5,7 @@ */ import { ChangeDetectionStrategy, ChangeDetectorRef, Component, Input, OnDestroy } from '@angular/core'; -import { takeWhile, map, filter } from 'rxjs/operators'; +import { takeWhile, map } from 'rxjs/operators'; import { ActivatedRoute } from '@angular/router'; import { of as observableOf, combineLatest } from 'rxjs'; @@ -17,7 +17,7 @@ import { of as observableOf, combineLatest } from 'rxjs';

Overview

diff --git a/docs/app/@theme/services/index.ts b/docs/app/@theme/services/index.ts index 4183ac54ce..0a079d8f60 100644 --- a/docs/app/@theme/services/index.ts +++ b/docs/app/@theme/services/index.ts @@ -6,7 +6,7 @@ export * from './code-loader.service'; export * from './iframe-communicator.service'; export * from './styles.service'; export * from './version.service'; -export * from './toc-state.service'; +export * from './visibility.service'; export * from './pagination.service'; export * from './analytics.service'; export * from './menu.service'; diff --git a/docs/app/@theme/services/toc-state.service.ts b/docs/app/@theme/services/toc-state.service.ts deleted file mode 100644 index 533971c36e..0000000000 --- a/docs/app/@theme/services/toc-state.service.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { Injectable } from '@angular/core'; - -export interface NgdTocElement { - fragment: string; - element: any; - y: number; - setInView(val: boolean); -} - -@Injectable() -export class NgdTocStateService { - state: NgdTocElement[] = []; - - add(el: NgdTocElement) { - this.state.push(el); - } - - remove(el: NgdTocElement) { - this.state = this.state.filter(e => e !== el); - } - - list(): NgdTocElement[] { - return this.state; - } - - clear() { - this.state = [] - } -} diff --git a/docs/app/@theme/services/visibility.service.ts b/docs/app/@theme/services/visibility.service.ts new file mode 100644 index 0000000000..dfae847886 --- /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 NgdVisibilityService { + + 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/@theme/theme.module.ts b/docs/app/@theme/theme.module.ts index a935163266..054b5ec1a1 100644 --- a/docs/app/@theme/theme.module.ts +++ b/docs/app/@theme/theme.module.ts @@ -41,7 +41,7 @@ import { NgdIframeCommunicatorService, NgdStylesService, NgdVersionService, - NgdTocStateService, + NgdVisibilityService, NgdPaginationService, NgdAnalytics, NgdMenuService, @@ -107,10 +107,10 @@ export class NgdThemeModule { NgdIframeCommunicatorService, NgdStylesService, NgdVersionService, - NgdTocStateService, NgdPaginationService, NgdAnalytics, NgdMenuService, + NgdVisibilityService, ], }; } diff --git a/docs/app/documentation/page/page.component.ts b/docs/app/documentation/page/page.component.ts index 9ded8d08a0..75961a5945 100644 --- a/docs/app/documentation/page/page.component.ts +++ b/docs/app/documentation/page/page.component.ts @@ -4,28 +4,19 @@ * Licensed under the MIT License. See License.txt in the project root for license information. */ -import { Component, Inject, NgZone, OnDestroy, OnInit } from '@angular/core'; +import { Component, Inject, NgZone, OnDestroy, OnInit, PLATFORM_ID } from '@angular/core'; +import { Title } from '@angular/platform-browser'; import { ActivatedRoute, Router } from '@angular/router'; -import {Title} from '@angular/platform-browser'; -import { - filter, - map, - publishBehavior, - publishReplay, - refCount, - tap, - takeWhile, -} from 'rxjs/operators'; +import { filter, map, publishReplay, refCount, tap, takeWhile } from 'rxjs/operators'; import { NB_WINDOW } from '@nebular/theme'; -import { NgdStructureService, NgdTocStateService } from '../../@theme/services'; -import { fromEvent } from 'rxjs'; +import { NgdStructureService } from '../../@theme/services'; @Component({ selector: 'ngd-page', templateUrl: './page.component.html', styleUrls: ['./page.component.scss'], }) -export class NgdPageComponent implements OnDestroy, OnInit { +export class NgdPageComponent implements OnInit, OnDestroy { currentItem; private alive = true; @@ -35,7 +26,6 @@ export class NgdPageComponent implements OnDestroy, OnInit { private router: Router, private activatedRoute: ActivatedRoute, private structureService: NgdStructureService, - private tocState: NgdTocStateService, private titleService: Title) { } @@ -46,10 +36,13 @@ export class NgdPageComponent implements OnDestroy, OnInit { ngOnInit() { this.handlePageNavigation(); - this.handleTocScroll(); this.window.history.scrollRestoration = 'manual'; } + ngOnDestroy() { + this.alive = false; + } + handlePageNavigation() { this.activatedRoute.params .pipe( @@ -70,32 +63,4 @@ export class NgdPageComponent implements OnDestroy, OnInit { this.currentItem = item; }); } - - 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; - } }