diff --git a/angular.json b/angular.json index f5e06acf..ca714158 100644 --- a/angular.json +++ b/angular.json @@ -195,6 +195,7 @@ "tsConfig": "docs/tsconfig.app.json", "polyfills": "docs/polyfills.ts", "assets": [ + "docs/articles", "docs/assets", "docs/404.html", "docs/favicon.png", 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/components/page-toc/page-toc.component.ts b/docs/app/@theme/components/page-toc/page-toc.component.ts index f269d8d0..c2d833bd 100644 --- a/docs/app/@theme/components/page-toc/page-toc.component.ts +++ b/docs/app/@theme/components/page-toc/page-toc.component.ts @@ -13,7 +13,7 @@ import { } from '@angular/core'; import { takeWhile, map } from 'rxjs/operators'; import { ActivatedRoute } from '@angular/router'; -import { of as observableOf, combineLatest } from 'rxjs'; +import { combineLatest, Observable } from 'rxjs'; @Component({ selector: 'ngx-page-toc', @@ -35,11 +35,11 @@ export class NgxPageTocComponent implements OnDestroy { items: any[]; @Input() - set toc(value) { - combineLatest( - observableOf(value || []), + set toc(value: Observable) { + combineLatest([ + value, this.activatedRoute.fragment, - ) + ]) .pipe( takeWhile(() => this.alive), map(([toc, fragment]) => { diff --git a/docs/app/@theme/services/article.service.ts b/docs/app/@theme/services/article.service.ts new file mode 100644 index 00000000..49b24fca --- /dev/null +++ b/docs/app/@theme/services/article.service.ts @@ -0,0 +1,13 @@ +import { Injectable } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { Observable } from 'rxjs'; + +@Injectable() +export class NgxArticleService { + + constructor(private http: HttpClient) { } + + getArticle(source: string): Observable { + return this.http.get(`articles/${source}`, { responseType: 'text' }); + } +} diff --git a/docs/app/@theme/services/index.ts b/docs/app/@theme/services/index.ts index 93f027bc..fb2685b5 100644 --- a/docs/app/@theme/services/index.ts +++ b/docs/app/@theme/services/index.ts @@ -17,6 +17,8 @@ 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'; +import { NgxArticleService } from './article.service'; export const ngxLandingServices = [ @@ -33,4 +35,6 @@ export const ngxLandingServices = [ NgxCodeLoaderService, NgxStylesService, NgxIframeCommunicatorService, + NgxVisibilityService, + NgxArticleService, ]; diff --git a/docs/app/@theme/services/structure.service.ts b/docs/app/@theme/services/structure.service.ts index d32f3fa2..0746d1c7 100644 --- a/docs/app/@theme/services/structure.service.ts +++ b/docs/app/@theme/services/structure.service.ts @@ -5,10 +5,13 @@ */ import { Inject, Injectable } from '@angular/core'; +import { map } from 'rxjs/operators'; +import { combineLatest, Observable, of } from 'rxjs'; import { NgxTabbedService } from './tabbed.service'; import { NgxTextService } from './text.service'; import { DOCS, STRUCTURE } from '../../app.options'; +import { NgxArticleService } from './article.service'; @Injectable() export class NgxStructureService { @@ -17,6 +20,7 @@ export class NgxStructureService { constructor(private textService: NgxTextService, private tabbedService: NgxTabbedService, + private articleService: NgxArticleService, @Inject(STRUCTURE) structure, @Inject(DOCS) docs) { this.prepared = this.prepareStructure(structure, docs); @@ -56,8 +60,9 @@ export class NgxStructureService { } if (item.block === 'markdown') { - item.children = this.textService.mdToSectionsHTML( - require(`raw-loader!../../../articles/${item.source}`).default); + item.sections = this.articleService.getArticle(item.source).pipe( + map((article) => this.textService.mdToSectionsHTML(article)), + ); } if (item.children) { @@ -113,39 +118,43 @@ export class NgxStructureService { }; } - protected prepareToc(item: any) { - return item.children.reduce((acc: any[], child: any) => { + protected prepareToc(item: any): Observable { + const tocList = item.children.reduce((acc: Observable[], child: any) => { if (child.block === 'markdown') { - return acc.concat(this.getTocForMd(child)); - } else if (child.block === 'tabbed') { - return acc.concat(this.getTocForTabbed(child)); - } else if (child.block === 'component') { - acc.push(this.getTocForComponent(child)); + return [...acc, this.getTocForMd(child)]; + } + if (child.block === 'tabbed') { + return [...acc, this.getTocForTabbed(child)]; + } + if (child.block === 'component') { + return [...acc, this.getTocForComponent(child)]; } return acc; }, []); + + return combineLatest([...tocList]).pipe(map((toc) => [].concat(...toc))); } - protected getTocForMd(block: any) { - return block.children.map((section: any) => ({ - title: section.title, - fragment: section.fragment, - } - )); + protected getTocForMd(block: any): Observable { + return (block.sections as Observable).pipe( + map((item) => item.map((val) => ({ + title: val.title, + fragment: val.fragment, + }))), + ); } - protected getTocForComponent(block: any) { - return { + protected getTocForComponent(block: any): Observable { + return of([{ title: block.source.name, fragment: block.source.slag, - }; + }]); } - protected getTocForTabbed(block: any) { - return block.children.map((component: any) => ({ - title: component.name, - fragment: this.textService.createSlag(component.name), - } - )); + protected getTocForTabbed(block: any): Observable { + return of(block.children.map((component: any) => ({ + title: component.name, + fragment: this.textService.createSlag(component.name), + }))); } } 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/blocks/components/md-block/md-block.component.ts b/docs/app/blocks/components/md-block/md-block.component.ts index c02d0686..f38e81e0 100644 --- a/docs/app/blocks/components/md-block/md-block.component.ts +++ b/docs/app/blocks/components/md-block/md-block.component.ts @@ -5,19 +5,34 @@ */ import { ChangeDetectionStrategy, Component, Input } from '@angular/core'; +import { DomSanitizer, SafeHtml } from '@angular/platform-browser'; @Component({ selector: 'ngx-md-block', template: ` - + -
+
`, changeDetection: ChangeDetectionStrategy.OnPush, }) export class NgxMdBLockComponent { + @Input() content: MdChildren[] = []; - @Input() source: string; + constructor(private readonly domSanitizer: DomSanitizer) { + } + + // TODO: create NbDOMPurifyPipe + getTemplate(content: string): SafeHtml { + return this.domSanitizer.bypassSecurityTrustHtml(content); + } +} + +interface MdChildren { + fragment: string; + html: string; + source: string; + title: string; } diff --git a/docs/app/pages/docs/page/ngx-admin-landing-page.component.html b/docs/app/pages/docs/page/ngx-admin-landing-page.component.html index dc5e37ba..b3c7d8a9 100644 --- a/docs/app/pages/docs/page/ngx-admin-landing-page.component.html +++ b/docs/app/pages/docs/page/ngx-admin-landing-page.component.html @@ -11,7 +11,7 @@ - + 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; }