This commit is contained in:
Nikolay Evtikhovich 2021-09-24 15:17:28 +00:00 committed by GitHub
commit 9c81b12026
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 300 additions and 95 deletions

View file

@ -195,6 +195,7 @@
"tsConfig": "docs/tsconfig.app.json", "tsConfig": "docs/tsconfig.app.json",
"polyfills": "docs/polyfills.ts", "polyfills": "docs/polyfills.ts",
"assets": [ "assets": [
"docs/articles",
"docs/assets", "docs/assets",
"docs/404.html", "docs/404.html",
"docs/favicon.png", "docs/favicon.png",

View file

@ -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);
} }
} }

View file

@ -13,7 +13,7 @@ import {
} from '@angular/core'; } from '@angular/core';
import { takeWhile, map } from 'rxjs/operators'; import { takeWhile, map } from 'rxjs/operators';
import { ActivatedRoute } from '@angular/router'; import { ActivatedRoute } from '@angular/router';
import { of as observableOf, combineLatest } from 'rxjs'; import { combineLatest, Observable } from 'rxjs';
@Component({ @Component({
selector: 'ngx-page-toc', selector: 'ngx-page-toc',
@ -35,11 +35,11 @@ export class NgxPageTocComponent implements OnDestroy {
items: any[]; items: any[];
@Input() @Input()
set toc(value) { set toc(value: Observable<any[]>) {
combineLatest( combineLatest([
observableOf(value || []), value,
this.activatedRoute.fragment, this.activatedRoute.fragment,
) ])
.pipe( .pipe(
takeWhile(() => this.alive), takeWhile(() => this.alive),
map(([toc, fragment]) => { map(([toc, fragment]) => {

View file

@ -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<string> {
return this.http.get(`articles/${source}`, { responseType: 'text' });
}
}

View file

@ -17,6 +17,8 @@ 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';
import { NgxArticleService } from './article.service';
export const ngxLandingServices = [ export const ngxLandingServices = [
@ -33,4 +35,6 @@ export const ngxLandingServices = [
NgxCodeLoaderService, NgxCodeLoaderService,
NgxStylesService, NgxStylesService,
NgxIframeCommunicatorService, NgxIframeCommunicatorService,
NgxVisibilityService,
NgxArticleService,
]; ];

View file

@ -5,10 +5,13 @@
*/ */
import { Inject, Injectable } from '@angular/core'; import { Inject, Injectable } from '@angular/core';
import { map } from 'rxjs/operators';
import { combineLatest, Observable, of } from 'rxjs';
import { NgxTabbedService } from './tabbed.service'; import { NgxTabbedService } from './tabbed.service';
import { NgxTextService } from './text.service'; import { NgxTextService } from './text.service';
import { DOCS, STRUCTURE } from '../../app.options'; import { DOCS, STRUCTURE } from '../../app.options';
import { NgxArticleService } from './article.service';
@Injectable() @Injectable()
export class NgxStructureService { export class NgxStructureService {
@ -17,6 +20,7 @@ export class NgxStructureService {
constructor(private textService: NgxTextService, constructor(private textService: NgxTextService,
private tabbedService: NgxTabbedService, private tabbedService: NgxTabbedService,
private articleService: NgxArticleService,
@Inject(STRUCTURE) structure, @Inject(STRUCTURE) structure,
@Inject(DOCS) docs) { @Inject(DOCS) docs) {
this.prepared = this.prepareStructure(structure, docs); this.prepared = this.prepareStructure(structure, docs);
@ -56,8 +60,9 @@ export class NgxStructureService {
} }
if (item.block === 'markdown') { if (item.block === 'markdown') {
item.children = this.textService.mdToSectionsHTML( item.sections = this.articleService.getArticle(item.source).pipe(
require(`raw-loader!../../../articles/${item.source}`).default); map((article) => this.textService.mdToSectionsHTML(article)),
);
} }
if (item.children) { if (item.children) {
@ -113,39 +118,43 @@ export class NgxStructureService {
}; };
} }
protected prepareToc(item: any) { protected prepareToc(item: any): Observable<any[]> {
return item.children.reduce((acc: any[], child: any) => { const tocList = item.children.reduce((acc: Observable<any>[], child: any) => {
if (child.block === 'markdown') { if (child.block === 'markdown') {
return acc.concat(this.getTocForMd(child)); return [...acc, this.getTocForMd(child)];
} else if (child.block === 'tabbed') { }
return acc.concat(this.getTocForTabbed(child)); if (child.block === 'tabbed') {
} else if (child.block === 'component') { return [...acc, this.getTocForTabbed(child)];
acc.push(this.getTocForComponent(child)); }
if (child.block === 'component') {
return [...acc, this.getTocForComponent(child)];
} }
return acc; return acc;
}, []); }, []);
return combineLatest([...tocList]).pipe(map((toc) => [].concat(...toc)));
} }
protected getTocForMd(block: any) { protected getTocForMd(block: any): Observable<any[]> {
return block.children.map((section: any) => ({ return (block.sections as Observable<any[]>).pipe(
title: section.title, map((item) => item.map((val) => ({
fragment: section.fragment, title: val.title,
} fragment: val.fragment,
)); }))),
);
} }
protected getTocForComponent(block: any) { protected getTocForComponent(block: any): Observable<any[]> {
return { return of([{
title: block.source.name, title: block.source.name,
fragment: block.source.slag, fragment: block.source.slag,
}; }]);
} }
protected getTocForTabbed(block: any) { protected getTocForTabbed(block: any): Observable<any[]> {
return block.children.map((component: any) => ({ return of(block.children.map((component: any) => ({
title: component.name, title: component.name,
fragment: this.textService.createSlag(component.name), fragment: this.textService.createSlag(component.name),
} })));
));
} }
} }

View 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;
}
}

View file

@ -5,19 +5,34 @@
*/ */
import { ChangeDetectionStrategy, Component, Input } from '@angular/core'; import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
@Component({ @Component({
selector: 'ngx-md-block', selector: 'ngx-md-block',
template: ` template: `
<nb-card *ngFor="let section of source;" [ngxFragment]="section.fragment"> <nb-card *ngFor="let section of content;" [ngxFragment]="section.fragment">
<nb-card-body> <nb-card-body>
<div [innerHtml]="section.html"></div> <div [innerHtml]="getTemplate(section.html)"></div>
</nb-card-body> </nb-card-body>
</nb-card> </nb-card>
`, `,
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
}) })
export class NgxMdBLockComponent { 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;
} }

View file

@ -11,7 +11,7 @@
<ng-container *ngFor="let block of currentItem?.children"> <ng-container *ngFor="let block of currentItem?.children">
<ng-container [ngSwitch]="block.block"> <ng-container [ngSwitch]="block.block">
<ngx-md-block *ngSwitchCase="'markdown'" [source]="block.children"></ngx-md-block> <ngx-md-block *ngSwitchCase="'markdown'" [content]="block.sections | async"></ngx-md-block>
<ngx-component-block *ngSwitchCase="'component'" [source]="block.source"></ngx-component-block> <ngx-component-block *ngSwitchCase="'component'" [source]="block.source"></ngx-component-block>
<ngx-tabbed-block *ngSwitchCase="'tabbed'" [source]="block.children" [tabs]="currentItem.tabs"></ngx-tabbed-block> <ngx-tabbed-block *ngSwitchCase="'tabbed'" [source]="block.children" [tabs]="currentItem.tabs"></ngx-tabbed-block>
<ngx-theme-block *ngSwitchCase="'theme'" [block]="block"></ngx-theme-block> <ngx-theme-block *ngSwitchCase="'theme'" [block]="block"></ngx-theme-block>

View file

@ -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;
} }