Skip to content

Commit

Permalink
feat(docs): angular anchors (#223)
Browse files Browse the repository at this point in the history
  • Loading branch information
Margar1ta authored and pimenovoleg committed Oct 25, 2019
1 parent f36c1e7 commit fc9caf5
Show file tree
Hide file tree
Showing 11 changed files with 76 additions and 88 deletions.
Original file line number Diff line number Diff line change
@@ -1,10 +1,8 @@
<div class="anchors-menu">
<div class="anchors-menu__container">
<div class="anchors-menu__list" >
<div *ngFor="let anchor of anchors; let i = index"
[class.anchors-menu__list-element]="true"
[class.anchors-menu__list-element_active]="anchors[i].active">
<a [href]="this.pathName + anchor.href" (click)="setActiveAnchor(i)">{{anchor.name}}</a>
<div class="anchors-menu__list">
<div *ngFor="let anchor of anchors; let i = index" class="anchors-menu__list-element {{anchors[i].active? activeClass: null}}">
<a [routerLink]="[this.pathName]" fragment="{{anchor.href}}" (click)="setFragment(i)">{{anchor.name}}</a>
</div>
</div>
</div>
Expand Down
120 changes: 52 additions & 68 deletions packages/docs/src/app/components/anchors/anchors.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,104 +27,77 @@ export class AnchorsComponent {
container: string;
headerHeight: number = 64;
// коэффициент для вычисления расстояния якоря над заголовком при скроле (== headerHeight * anchorHeaderCoef)
anchorHeaderCoef = 3;
debounceTime: number = 5;
readonly isSmoothScrollSupported;
anchorHeaderCoef = 2;
debounceTime: number = 10;
private destroyed = new Subject();
private urlFragment = '';
private fragment = '';
private activeClass = 'anchors-menu__list-element_active';
private scrollContainer: any;
private currentUrl: any;
private pathName: string;
private scrollTimeout: number = 1000;
private scrollOptions: ScrollIntoViewOptions = { behavior: 'smooth' };

constructor(private router: Router,
private route: ActivatedRoute,
private element: ElementRef,
private ref: ChangeDetectorRef,
@Inject(DOCUMENT) private document: Document) {
this.currentUrl = router.url.split('#')[0];
this.container = '.anchors-menu';
this.isSmoothScrollSupported = 'scrollBehavior' in this.document.documentElement.style;
this.pathName = location.pathname;
this.router.events.pipe(takeUntil(this.destroyed)).subscribe((event) => {
if (event instanceof NavigationEnd) {
const rootUrl = router.url.split('#')[0];

if (rootUrl !== this.currentUrl) {
this.anchors = this.createAnchors();
this.currentUrl = rootUrl;
this.pathName = location.pathname;
}
}
});
}

ngOnInit() {
// Срабатывает при изменении якоря в адресной строке руками или кликом по якорю
this.route.fragment.pipe(takeUntil(this.destroyed)).subscribe((fragment) => {
this.urlFragment = fragment;
const anchorActive = this.document.querySelector('.anchors-menu__list-element_active');

const target = this.document.getElementById(this.urlFragment);
const index = this.getAnchorIndex(this.urlFragment);

if (index) { this.setActiveAnchor(index); }

if (target) {
this.click = true;
this.fragment = fragment;
const index = this.getAnchorIndex(fragment);

if (anchorActive && this.urlFragment === anchorActive.textContent.trim().toLowerCase()) {
return;
}
this.scrollTo(target);
}
if (index) { this.setFragment(index); }
});
}

ngOnDestroy() {
this.destroyed.next();
}

scrollTo(target) {
if (this.isSmoothScrollSupported) {
target.scrollIntoView(this.scrollOptions);
} else {
target.scrollIntoView();
}
}

getAnchorIndex(urlFragment): number {
let index = 0;
this.anchors.forEach((anchor, i) => {
if (anchor.href === `#${urlFragment}`) { index = i; }
if (anchor.href === `${urlFragment}`) { index = i; }
});

return index;
}

setScrollPosition() {
this.anchors = this.createAnchors();
const target = this.document.getElementById(this.urlFragment);
this.scrollContainer = this.document || window;
const target = this.document.getElementById(this.fragment);

if (target) {
const index = this.getAnchorIndex(this.urlFragment);
const index = this.getAnchorIndex(this.fragment);

if (index) { this.setActiveAnchor(index); }
if (index) { this.setFragment(index); }
target.scrollTop += this.headerHeight;
this.scrollTo(target);
target.scrollIntoView();
}

const scrollPromise = new Promise((resolve, reject) => {
setTimeout(() => { resolve(); }, this.scrollTimeout);
});

scrollPromise.then(() => {
this.scrollContainer = this.document || window;

if (this.scrollContainer) {
fromEvent(this.scrollContainer, 'scroll').pipe(
takeUntil(this.destroyed),
debounceTime(this.debounceTime))
.subscribe(() => this.onScroll());
}
});
if (this.scrollContainer) {
fromEvent(this.scrollContainer, 'scroll').pipe(
takeUntil(this.destroyed),
debounceTime(this.debounceTime))
.subscribe(() => this.onScroll());
}
}

/* TODO Техдолг: при изменении ширины экрана должен переопределяться параметр top
Expand All @@ -144,17 +117,26 @@ export class AnchorsComponent {
return window.pageYOffset + this.headerHeight * this.anchorHeaderCoef;
}

private createAnchors(): IAnchor[] {
private isScrolledToEnd(): boolean {
const documentHeight = this.document.documentElement.scrollHeight;
const scrollTop = window.pageYOffset || this.document.documentElement.scrollTop || this.document.body.scrollTop;
const clientHeight = this.document.documentElement.clientHeight;
const scrollHeight = scrollTop + clientHeight;

// scrollHeight должен быть строго равен documentHeight, но в Edge он чуть больше
return scrollHeight >= documentHeight;
}

private createAnchors(): IAnchor[] {
const anchors = [];
const headers = Array.from(this.document.querySelectorAll(this.headerSelectors)) as HTMLElement[];

if (headers.length) {
const bodyTop = this.document.body.getBoundingClientRect().top;
for (let i = 0; i < headers.length; i++) {
// remove the 'link' icon name from the inner text
const name = headers[i].innerText.trim();
const href = `#${headers[i].id}`;
const {top} = headers[i].getBoundingClientRect();
const href = `${headers[i].querySelector('span').id}`;
const top = headers[i].getBoundingClientRect().top - bodyTop + this.headerHeight;

anchors.push({
href,
Expand All @@ -171,15 +153,17 @@ export class AnchorsComponent {
}

private onScroll() {
// Если переход на якорь был инициирован не скролом выходим из функции
if (this.click) {
this.disableClickState();
if (this.isScrolledToEnd()) {
this.setActiveAnchor(this.anchors.length - 1);
this.ref.detectChanges();

return;
}

for (let i = 0; i < this.anchors.length; i++) {
this.anchors[i].active = this.isLinkActive(this.anchors[i], this.anchors[i + 1]);
if (this.isLinkActive(this.anchors[i], this.anchors[i + 1])) {
this.setActiveAnchor(i);
}
}
this.ref.detectChanges();
}
Expand All @@ -192,20 +176,20 @@ export class AnchorsComponent {
return scrollOffset >= currentLink.top && !(nextLink && nextLink.top < scrollOffset);
}

private setActiveAnchor(index) {
private setFragment(index) {
if (this.isScrolledToEnd()) {
this.setActiveAnchor(this.anchors.length - 1);

return;
}
this.click = true;
this.setActiveAnchor(index);
}

private setActiveAnchor(index) {
for (const anchor of this.anchors) {
anchor.active = false;
}
this.anchors[index].active = true;
this.ref.detectChanges();
}

private disableClickState() {
// При клике по якорю браузер вызывает scroll несколько раз - setTimeout нужен, чтобы код отработал единожды
// Возможная альтернатива: debounceTime: number = 50; - но это ухудшает отзывчивость якорей при скролле
setTimeout(() => {
this.click = false;
}, 500);
}
}
3 changes: 2 additions & 1 deletion packages/docs/src/app/components/anchors/anchors.module.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import { RouterModule } from '@angular/router';

import { AnchorsComponent } from './anchors.component';


@NgModule({
imports: [CommonModule],
imports: [CommonModule, RouterModule],
exports: [AnchorsComponent],
declarations: [AnchorsComponent]
})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import { DocumentationItems } from '../../shared/documentation-items/documentati
import { TableOfContentsModule } from '../../shared/table-of-contents/table-of-contents.module';
import { AnchorsModule } from '../anchors/anchors.module';


import {
ComponentOverviewComponent,
ComponentViewerComponent
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { AnchorsModule } from '../anchors/anchors.module';
import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import { RouterModule } from '@angular/router';
Expand All @@ -7,11 +6,12 @@ import { McDropdownModule } from '@ptsecurity/mosaic/dropdown';
import { McIconModule } from '@ptsecurity/mosaic/icon';
import { McTreeModule } from '@ptsecurity/mosaic/tree';

import { AnchorsModule } from '../anchors/anchors.module';
import { FooterModule } from '../footer/footer.module';
import { NavbarModule } from '../navbar';
import { SidenavModule } from '../sidenav/sidenav.module';

import { MainLayoutComponent } from './main-layout.component';
import { FooterModule } from '../footer/footer.module';


@NgModule({
Expand Down
4 changes: 2 additions & 2 deletions packages/docs/src/app/docs.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,8 @@ import { DocumentationItems } from './shared/documentation-items/documentation-i

RouterModule.forRoot(APP_ROUTES, {
scrollPositionRestoration: 'enabled',
anchorScrolling: 'enabled',
scrollOffset: [0, 64] // [x, y]
onSameUrlNavigation: 'reload',
anchorScrolling: 'enabled'
}),

HomepageModule,
Expand Down
4 changes: 0 additions & 4 deletions packages/docs/src/app/docs.scss
Original file line number Diff line number Diff line change
@@ -1,7 +1,3 @@
html {
scroll-behavior: smooth;
}

docs-app {
display: flex;
flex-direction: column;
Expand Down
1 change: 0 additions & 1 deletion packages/docs/src/styles/_common.scss
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@ html {
-webkit-font-smoothing: antialiased;
-webkit-tap-highlight-color: rgba(0, 0, 0, 0); // 4
-webkit-touch-callout: none;
scroll-behavior: smooth;
}

// Body
Expand Down
11 changes: 11 additions & 0 deletions packages/docs/src/styles/_markdown.scss
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,17 @@
}
}

.docs-header-link {
position: relative;

& span {
position: absolute;
display: block;
left: 0;
top: -60px;
}
}

@mixin docs-markdown-theme($theme) {
$primary: map-get($theme, primary);
$second: map-get($theme, second);
Expand Down
4 changes: 2 additions & 2 deletions packages/mosaic/button/button.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@ Mosaic buttons are available using native `<button>` or `<a>` elements.

### Variants
There are two button variants, each applied as an attribute:
+ basic buttons
+ icon buttons
+ basic buttons;
+ icon buttons.

#### Basic buttons

Expand Down
4 changes: 2 additions & 2 deletions tools/gulp/tasks/docs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,8 +99,8 @@ task('markdown-docs-mosaic', () => {
const escapedText = text.toLowerCase().replace(/[^\w]+/g, '-');

return `
<div id="${escapedText}" class="docs-header-link docs-header-link_${level}">
<span header-link="${escapedText}"></span>
<div class="docs-header-link docs-header-link_${level}">
<span header-link="${escapedText}" id="${escapedText}"></span>
${text}
</div>
`;
Expand Down

0 comments on commit fc9caf5

Please sign in to comment.