Skip to content

Commit

Permalink
fix(docs): prevent scrolling when user scroll (#982)
Browse files Browse the repository at this point in the history
Fixes auto scrolling to page top when trying to scroll to the bottom.
Closes #810.
  • Loading branch information
yggg authored and nnixaa committed Jan 31, 2019
1 parent 9ce7019 commit bc2ab1d
Show file tree
Hide file tree
Showing 7 changed files with 236 additions and 113 deletions.
Original file line number Diff line number Diff line change
@@ -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<HTMLElement>,
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);
}
}
4 changes: 2 additions & 2 deletions docs/app/@theme/components/page-toc/page-toc.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -17,7 +17,7 @@ import { of as observableOf, combineLatest } from 'rxjs';
<h4>Overview</h4>
<ul>
<li *ngFor="let item of items" [class.selected]="item.selected">
<a [routerLink]="item.link" [fragment]="item.fragment">{{ item.title }}</a>
<a routerLink="." [fragment]="item.fragment">{{ item.title }}</a>
</li>
</ul>
</ng-container>
Expand Down
2 changes: 1 addition & 1 deletion docs/app/@theme/services/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
29 changes: 0 additions & 29 deletions docs/app/@theme/services/toc-state.service.ts

This file was deleted.

166 changes: 166 additions & 0 deletions docs/app/@theme/services/visibility.service.ts
Original file line number Diff line number Diff line change
@@ -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<IntersectionObserverEntry[]>;
}

@Injectable()
export class NgdVisibilityService {

private readonly isBrowser: boolean;
private readonly supportsIntersectionObserver: boolean;

private readonly visibilityObservers = new Map<IntersectionObserverInit, ObserverWithStream>();
private readonly topmostObservers = new Map<IntersectionObserverInit, Observable<Element>>();
private readonly visibleElements = new Map<IntersectionObserverInit, Element[]>();
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<IntersectionObserverEntry> {
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<boolean> {
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<IntersectionObserverEntry[]>();
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<Element> {
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;
}
}
4 changes: 2 additions & 2 deletions docs/app/@theme/theme.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ import {
NgdIframeCommunicatorService,
NgdStylesService,
NgdVersionService,
NgdTocStateService,
NgdVisibilityService,
NgdPaginationService,
NgdAnalytics,
NgdMenuService,
Expand Down Expand Up @@ -107,10 +107,10 @@ export class NgdThemeModule {
NgdIframeCommunicatorService,
NgdStylesService,
NgdVersionService,
NgdTocStateService,
NgdPaginationService,
NgdAnalytics,
NgdMenuService,
NgdVisibilityService,
],
};
}
Expand Down
Loading

0 comments on commit bc2ab1d

Please sign in to comment.