mirror of
https://github.com/akveo/ngx-admin.git
synced 2025-12-16 23:40:14 +01:00
fix(docs): add visibility service and ref fragment-target directive
This commit is contained in:
parent
6fff8623bc
commit
e77829a465
4 changed files with 227 additions and 62 deletions
|
|
@ -1,73 +1,97 @@
|
||||||
import { Directive, ElementRef, Inject, Input, OnDestroy, OnInit, Renderer2 } from '@angular/core';
|
import { Directive, ElementRef, Inject, Input, OnDestroy, OnInit, PLATFORM_ID, Renderer2 } from '@angular/core';
|
||||||
import { ActivatedRoute } from '@angular/router';
|
import { ActivatedRoute, Router } from '@angular/router';
|
||||||
import { NB_WINDOW } from '@nebular/theme';
|
import { NB_WINDOW, NbLayoutScrollService } from '@nebular/theme';
|
||||||
import { takeWhile, publish, refCount } from 'rxjs/operators';
|
import { debounce, filter, publish, refCount, takeUntil, tap } from 'rxjs/operators';
|
||||||
import { NgxTocElement, NgxTocStateService } from '../../services/toc-state.service';
|
import { Subject, timer } from 'rxjs';
|
||||||
import { delay } from 'rxjs/internal/operators';
|
|
||||||
|
import { NgxVisibilityService } from '../../services/visibility.service';
|
||||||
|
|
||||||
|
const OBSERVER_OPTIONS = { rootMargin: '-100px 0px 0px' };
|
||||||
|
|
||||||
@Directive({
|
@Directive({
|
||||||
// tslint:disable-next-line
|
// tslint:disable-next-line
|
||||||
selector: '[ngxFragment]',
|
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<void>();
|
||||||
|
|
||||||
@Input() ngxFragment: string;
|
@Input() ngxFragment: string;
|
||||||
@Input() ngxFragmentClass: string;
|
@Input() ngxFragmentClass: string;
|
||||||
@Input() ngxFragmentSync: boolean = true;
|
@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(
|
constructor(
|
||||||
private activatedRoute: ActivatedRoute,
|
private activatedRoute: ActivatedRoute,
|
||||||
@Inject(NB_WINDOW) private window,
|
@Inject(NB_WINDOW) private window,
|
||||||
private tocState: NgxTocStateService,
|
private el: ElementRef<HTMLElement>,
|
||||||
private el: ElementRef,
|
|
||||||
private renderer: Renderer2,
|
private renderer: Renderer2,
|
||||||
|
private router: Router,
|
||||||
|
@Inject(PLATFORM_ID) private platformId,
|
||||||
|
private visibilityService: NgxVisibilityService,
|
||||||
|
private scrollService: NbLayoutScrollService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
ngOnInit() {
|
ngOnInit() {
|
||||||
this.ngxFragmentSync && this.tocState.add(this);
|
|
||||||
|
|
||||||
this.activatedRoute.fragment
|
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) => {
|
.subscribe((fragment: string) => {
|
||||||
if (fragment && this.fragment === fragment && !this.inView) {
|
if (fragment && this.ngxFragment === fragment) {
|
||||||
this.selectFragment();
|
this.selectFragment();
|
||||||
} else {
|
} else {
|
||||||
this.deselectFragment();
|
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() {
|
selectFragment() {
|
||||||
this.ngxFragmentClass && this.renderer.addClass(this.el.nativeElement, this.ngxFragmentClass);
|
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() {
|
deselectFragment() {
|
||||||
this.renderer.removeClass(this.el.nativeElement, this.ngxFragmentClass);
|
this.renderer.removeClass(this.el.nativeElement, this.ngxFragmentClass);
|
||||||
}
|
}
|
||||||
|
|
||||||
setInView(val: boolean) {
|
updateUrl() {
|
||||||
this.inView = val;
|
const urlFragment = this.activatedRoute.snapshot.fragment;
|
||||||
|
const alreadyThere = urlFragment && urlFragment.includes(this.ngxFragment);
|
||||||
|
if (!alreadyThere) {
|
||||||
|
this.router.navigate([], { fragment: this.ngxFragment, replaceUrl: true });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnDestroy() {
|
ngOnDestroy() {
|
||||||
this.alive = false;
|
this.destroy$.next();
|
||||||
this.ngxFragmentSync && this.tocState.remove(this);
|
this.destroy$.complete();
|
||||||
|
this.visibilityService.unobserve(this.el.nativeElement, OBSERVER_OPTIONS);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ import { NgxTocStateService } from './toc-state.service';
|
||||||
import { NgxCodeLoaderService } from './code-loader.service';
|
import { NgxCodeLoaderService } from './code-loader.service';
|
||||||
import { NgxStylesService } from './styles.service';
|
import { NgxStylesService } from './styles.service';
|
||||||
import { NgxIframeCommunicatorService } from './iframe-communicator.service';
|
import { NgxIframeCommunicatorService } from './iframe-communicator.service';
|
||||||
|
import { NgxVisibilityService } from './visibility.service';
|
||||||
|
|
||||||
|
|
||||||
export const ngxLandingServices = [
|
export const ngxLandingServices = [
|
||||||
|
|
@ -33,4 +34,5 @@ export const ngxLandingServices = [
|
||||||
NgxCodeLoaderService,
|
NgxCodeLoaderService,
|
||||||
NgxStylesService,
|
NgxStylesService,
|
||||||
NgxIframeCommunicatorService,
|
NgxIframeCommunicatorService,
|
||||||
|
NgxVisibilityService,
|
||||||
];
|
];
|
||||||
|
|
|
||||||
166
docs/app/@theme/services/visibility.service.ts
Normal file
166
docs/app/@theme/services/visibility.service.ts
Normal file
|
|
@ -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 NgxVisibilityService {
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -9,14 +9,12 @@ import { ActivatedRoute, Router } from '@angular/router';
|
||||||
import {
|
import {
|
||||||
filter,
|
filter,
|
||||||
map,
|
map,
|
||||||
publishBehavior,
|
|
||||||
publishReplay,
|
publishReplay,
|
||||||
refCount,
|
refCount,
|
||||||
tap,
|
tap,
|
||||||
takeWhile,
|
takeWhile,
|
||||||
} from 'rxjs/operators';
|
} from 'rxjs/operators';
|
||||||
import { NB_WINDOW } from '@nebular/theme';
|
import { NB_WINDOW } from '@nebular/theme';
|
||||||
import { fromEvent } from 'rxjs';
|
|
||||||
|
|
||||||
import { NgxStructureService } from '../../../@theme/services/structure.service';
|
import { NgxStructureService } from '../../../@theme/services/structure.service';
|
||||||
import { NgxTocStateService } from '../../../@theme/services/toc-state.service';
|
import { NgxTocStateService } from '../../../@theme/services/toc-state.service';
|
||||||
|
|
@ -48,7 +46,6 @@ export class NgxAdminLandingPageComponent implements OnDestroy, OnInit {
|
||||||
|
|
||||||
ngOnInit() {
|
ngOnInit() {
|
||||||
this.handlePageNavigation();
|
this.handlePageNavigation();
|
||||||
this.handleTocScroll();
|
|
||||||
this.window.history.scrollRestoration = 'manual';
|
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() {
|
ngOnDestroy() {
|
||||||
this.alive = false;
|
this.alive = false;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue