From 4842587e1076f5b34fb11eadea7aebe82a29352c Mon Sep 17 00:00:00 2001 From: apapachristou Date: Tue, 15 Oct 2019 17:25:43 +0300 Subject: [PATCH] Table of Contents (First Commit) (Issue #180) --- .../form-composite-field.component.html | 10 +- .../form-composite-field.component.scss | 2 +- .../form-composite-field.component.ts | 2 +- .../form-section/form-section.component.html | 4 +- .../form-section/form-section.component.scss | 34 +-- .../form-section/form-section.component.ts | 25 +- .../dataset-description-form.component.html | 42 ++-- .../dataset-description-form.component.scss | 22 ++ .../dataset-description-form.component.ts | 12 + .../dataset-description-form.module.ts | 5 +- .../table-of-contents.html | 12 + .../table-of-contents.module.ts | 12 + .../table-of-contents.scss | 58 +++++ .../table-of-contents.ts | 221 ++++++++++++++++++ 14 files changed, 416 insertions(+), 45 deletions(-) create mode 100644 dmp-frontend/src/app/ui/misc/dataset-description-form/tableOfContentsMaterial/table-of-contents.html create mode 100644 dmp-frontend/src/app/ui/misc/dataset-description-form/tableOfContentsMaterial/table-of-contents.module.ts create mode 100644 dmp-frontend/src/app/ui/misc/dataset-description-form/tableOfContentsMaterial/table-of-contents.scss create mode 100644 dmp-frontend/src/app/ui/misc/dataset-description-form/tableOfContentsMaterial/table-of-contents.ts diff --git a/dmp-frontend/src/app/ui/misc/dataset-description-form/components/form-composite-field/form-composite-field.component.html b/dmp-frontend/src/app/ui/misc/dataset-description-form/components/form-composite-field/form-composite-field.component.html index 2f675c6dd..50d76924d 100644 --- a/dmp-frontend/src/app/ui/misc/dataset-description-form/components/form-composite-field/form-composite-field.component.html +++ b/dmp-frontend/src/app/ui/misc/dataset-description-form/components/form-composite-field/form-composite-field.component.html @@ -1,10 +1,8 @@ -
+
-
+
-
+
{{form.get('numbering').value}} {{form.get('title').value}} -
- -
- +
+ +
+
+
+ +
+
+ + {{pageFormGroup.get('title').value}} + +
+ +
+
+
-
- + +
+ +
+
+
+ diff --git a/dmp-frontend/src/app/ui/misc/dataset-description-form/dataset-description-form.component.scss b/dmp-frontend/src/app/ui/misc/dataset-description-form/dataset-description-form.component.scss index 0c2851cd0..372eb669e 100644 --- a/dmp-frontend/src/app/ui/misc/dataset-description-form/dataset-description-form.component.scss +++ b/dmp-frontend/src/app/ui/misc/dataset-description-form/dataset-description-form.component.scss @@ -12,6 +12,28 @@ } } +.toc-and-content { + display: flex; + align-items: flex-start; + text-align: left; + max-width: 940px; + margin: 0 auto; +} + +// table-of-contents { +// top: 0px; +// position: sticky; + +// Reposition on top of content on small screens and remove +// sticky positioning +// @media (max-width: 720px) { +// order: -1; +// position: inherit; +// width: auto; +// padding-left: 0; +// } +// } + // .ng-sidebar { // width: 40%; // } diff --git a/dmp-frontend/src/app/ui/misc/dataset-description-form/dataset-description-form.component.ts b/dmp-frontend/src/app/ui/misc/dataset-description-form/dataset-description-form.component.ts index 1dd3ab9a2..af61f5406 100644 --- a/dmp-frontend/src/app/ui/misc/dataset-description-form/dataset-description-form.component.ts +++ b/dmp-frontend/src/app/ui/misc/dataset-description-form/dataset-description-form.component.ts @@ -5,6 +5,7 @@ import { ActivatedRoute, Router } from '@angular/router'; import { BaseComponent } from '../../../core/common/base/base.component'; import { Rule } from '../../../core/model/dataset-profile-definition/rule'; import { FormFocusService } from './form-focus/form-focus.service'; +import { LinkToScroll } from './tableOfContentsMaterial/table-of-contents'; import { VisibilityRulesService } from './visibility-rules/visibility-rules.service'; @Component({ @@ -31,6 +32,9 @@ export class DatasetDescriptionFormComponent extends BaseComponent implements On // pageTrackByFn = (index, item) => item['id']; @Input() datasetProfileId: String; + linkToScroll: LinkToScroll; + + // uniqueId = Math.random().toString(36).substr(2, 9); constructor( private router: Router, @@ -79,6 +83,14 @@ export class DatasetDescriptionFormComponent extends BaseComponent implements On } } + onStepFound(linkToScroll: LinkToScroll) { + + if (linkToScroll.page >= 0) { + this.stepper.selectedIndex = linkToScroll.page; + } + this.linkToScroll = linkToScroll; + } + ngAfterViewInit() { //this.visibilityRulesService.triggerVisibilityEvaluation(); // this.route.queryParams diff --git a/dmp-frontend/src/app/ui/misc/dataset-description-form/dataset-description-form.module.ts b/dmp-frontend/src/app/ui/misc/dataset-description-form/dataset-description-form.module.ts index fa3f68655..5ce506604 100644 --- a/dmp-frontend/src/app/ui/misc/dataset-description-form/dataset-description-form.module.ts +++ b/dmp-frontend/src/app/ui/misc/dataset-description-form/dataset-description-form.module.ts @@ -9,12 +9,15 @@ import { FormSectionComponent } from './components/form-section/form-section.com import { DatasetDescriptionFormComponent } from './dataset-description-form.component'; import { FormFocusService } from './form-focus/form-focus.service'; import { VisibilityRulesService } from './visibility-rules/visibility-rules.service'; +import { TableOfContentsModule } from './tableOfContentsMaterial/table-of-contents.module'; +import { TableOfContents } from './tableOfContentsMaterial/table-of-contents'; @NgModule({ imports: [ CommonUiModule, CommonFormsModule, - AutoCompleteModule + AutoCompleteModule, + TableOfContentsModule ], declarations: [ DatasetDescriptionFormComponent, diff --git a/dmp-frontend/src/app/ui/misc/dataset-description-form/tableOfContentsMaterial/table-of-contents.html b/dmp-frontend/src/app/ui/misc/dataset-description-form/tableOfContentsMaterial/table-of-contents.html new file mode 100644 index 000000000..a2bb11d99 --- /dev/null +++ b/dmp-frontend/src/app/ui/misc/dataset-description-form/tableOfContentsMaterial/table-of-contents.html @@ -0,0 +1,12 @@ +
+
Contents
+ + + + {{link.name}} + + +
diff --git a/dmp-frontend/src/app/ui/misc/dataset-description-form/tableOfContentsMaterial/table-of-contents.module.ts b/dmp-frontend/src/app/ui/misc/dataset-description-form/tableOfContentsMaterial/table-of-contents.module.ts new file mode 100644 index 000000000..b28ec7804 --- /dev/null +++ b/dmp-frontend/src/app/ui/misc/dataset-description-form/tableOfContentsMaterial/table-of-contents.module.ts @@ -0,0 +1,12 @@ +import {CommonModule} from '@angular/common'; +import {NgModule} from '@angular/core'; +import {TableOfContents} from './table-of-contents'; +import {RouterModule} from '@angular/router'; + +@NgModule({ + imports: [CommonModule, RouterModule], + declarations: [TableOfContents], + exports: [TableOfContents], + entryComponents: [TableOfContents], +}) +export class TableOfContentsModule { } diff --git a/dmp-frontend/src/app/ui/misc/dataset-description-form/tableOfContentsMaterial/table-of-contents.scss b/dmp-frontend/src/app/ui/misc/dataset-description-form/tableOfContentsMaterial/table-of-contents.scss new file mode 100644 index 000000000..be241f387 --- /dev/null +++ b/dmp-frontend/src/app/ui/misc/dataset-description-form/tableOfContentsMaterial/table-of-contents.scss @@ -0,0 +1,58 @@ +:host { + font-size: 13px; + // Width is container width minus content width + width: 19%; + height: fit-content; + position: -webkit-sticky; + position: sticky; + // display: inline-flex; + align-self: flex-start; + top: 0; + padding-left: 25px; + box-sizing: border-box; +} + +.docs-toc-container { + width: 100%; + padding: 5px 0 10px 10px; + cursor: pointer; + border-left: solid 4px #0c7489; + + .docs-link { + color: rgba(0, 0, 0, 0.54); + // color: mat-color($app-blue-theme-foreground, secondary-text); + transition: color 100ms; + + &:hover, + &.docs-active { + color: #0c7489; + // color: mat-color($primary, if($is-dark-theme, 200, default)); + } + } +} + +.docs-toc-heading { + margin: 0; + padding: 0; + font-size: 13px; + font-weight: bold; +} + +span { + line-height: 16px; + margin: 8px 0 0; + position: relative; + text-decoration: none; + display: block; + text-overflow: ellipsis !important; + overflow: hidden; + color: rgba(0, 0, 0, 0.54); +} + +.docs-level-mat-expansion-panel { + margin-left: 12px; +} + +.docs-level-h5 { + margin-left: 24px; +} diff --git a/dmp-frontend/src/app/ui/misc/dataset-description-form/tableOfContentsMaterial/table-of-contents.ts b/dmp-frontend/src/app/ui/misc/dataset-description-form/tableOfContentsMaterial/table-of-contents.ts new file mode 100644 index 000000000..682fffcb1 --- /dev/null +++ b/dmp-frontend/src/app/ui/misc/dataset-description-form/tableOfContentsMaterial/table-of-contents.ts @@ -0,0 +1,221 @@ +import { DOCUMENT } from '@angular/common'; +import { AfterViewInit, Component, ElementRef, EventEmitter, Inject, Input, OnDestroy, OnInit, Output } from '@angular/core'; +import { ActivatedRoute, NavigationEnd, Router } from '@angular/router'; +import { fromEvent, Subject } from 'rxjs'; +import { debounceTime, takeUntil } from 'rxjs/operators'; +import { link } from 'fs'; + + +interface Link { + /* id of the section*/ + id: string; + + /* header type h3/h4 */ + type: string; + + /* If the anchor is in view of the page */ + active: boolean; + + /* name of the anchor */ + name: string; + + /* top offset px of the anchor */ + top: number; + + page: number; + + section: number; +} + +@Component({ + selector: 'table-of-contents', + styleUrls: ['./table-of-contents.scss'], + templateUrl: './table-of-contents.html' +}) +export class TableOfContents implements OnInit, AfterViewInit, OnDestroy { + + @Input() links: Link[] = []; + @Input() container: string; + @Input() headerSelectors = '.toc-page-header, .toc-section-header, .toc-copositeField-header'; + + @Output() stepFound = new EventEmitter(); + + _rootUrl = this._router.url.split('#')[0]; + private _scrollContainer: any; + private _destroyed = new Subject(); + private _urlFragment = ''; + private selectedLinkId: string; + + constructor(private _router: Router, + private _route: ActivatedRoute, + private _element: ElementRef, + @Inject(DOCUMENT) private _document: Document) { + + this._router.events.pipe(takeUntil(this._destroyed)).subscribe((event) => { + if (event instanceof NavigationEnd) { + const rootUrl = _router.url.split('#')[0]; + if (rootUrl !== this._rootUrl) { + this.links = this.createLinks(); + this._rootUrl = rootUrl; + } + } + }); + + this._route.fragment.pipe(takeUntil(this._destroyed)).subscribe(fragment => { + this._urlFragment = fragment; + + const target = document.getElementById(this._urlFragment); + if (target) { + target.scrollIntoView(); + } + }); + } + + ngOnInit(): void { + // On init, the sidenav content element doesn't yet exist, so it's not possible + // to subscribe to its scroll event until next tick (when it does exist). + Promise.resolve().then(() => { + this._scrollContainer = this.container ? + this._document.querySelectorAll(this.container)[0] : window; + + if (this._scrollContainer) { + fromEvent(this._scrollContainer, 'scroll').pipe( + takeUntil(this._destroyed), + debounceTime(10)) + .subscribe(() => this.onScroll()); + } + }); + } + + ngAfterViewInit() { + this.updateScrollPosition(); + } + + ngOnDestroy(): void { + this._destroyed.next(); + } + + updateScrollPosition(): void { + this.links = this.createLinks(); + + const target = document.getElementById(this._urlFragment); + if (target) { + target.scrollIntoView(); + } + } + + /** Gets the scroll offset of the scroll container */ + private getScrollOffset(): number | void { + const { top } = this._element.nativeElement.getBoundingClientRect(); + if (typeof this._scrollContainer.scrollTop !== 'undefined') { + return this._scrollContainer.scrollTop + top; + } else if (typeof this._scrollContainer.pageYOffset !== 'undefined') { + return this._scrollContainer.pageYOffset + top; + } + } + + private createLinks(): Link[] { + const links: Array = []; + const headers = + Array.from(this._document.querySelectorAll(this.headerSelectors)) as HTMLElement[]; + + if (headers.length) { + for (const header of headers) { + // const step = 0; + // remove the 'link' icon name from the inner text + const name = header.innerText.trim().replace(/^link/, ''); + const { top } = header.getBoundingClientRect(); + // links.push({ + // name, + // // step, + // type: header.tagName.toLowerCase(), + // top: top, + // id: header.id, + // active: false, + // section: section + // }); + } + } + + return links; + } + + private onScroll(): void { + // for (let i = 0; i < this.links.length; i++) { + // this.links[i].active = this.isLinkActive(this.links[i], this.links[i + 1]); + // } + } + + // private isLinkActive(currentLink: any, nextLink: any): boolean { + // A link is considered active if the page is scrolled passed the anchor without also + // being scrolled passed the next link + // const scrollOffset = this.getScrollOffset(); + // return scrollOffset >= currentLink.top && !(nextLink && nextLink.top < scrollOffset); + // } + + getLinks() { + const links: Array = []; + const headers = + Array.from(this._document.querySelectorAll(this.headerSelectors)) as HTMLElement[]; + + if (headers.length) { + let page; + let section; + for (const header of headers) { + let name; + let id; + if (header.classList.contains('toc-page-header')) { + name = header.innerText.trim().replace(/^link/, ''); + id = header.id; + page = header.id.split('_')[1]; + section = undefined; + } else if (header.classList.contains('toc-section-header')) { + name = header.childNodes[0].childNodes[0].childNodes[0].childNodes[0].childNodes[0].nodeValue.trim().replace(/^link/, ''); + id = header.id; + page = header.id.split('.')[1]; + section = header.id; + } else if (header.classList.contains('toc-copositeField-header')) { + name = (header.childNodes[0]).nodeValue.trim().replace(/^link/, ''); + id = header.id; + // id = header.parentElement.parentElement.parentElement.id; + } + const { top } = header.getBoundingClientRect(); + links.push({ + name, + id, + type: header.tagName.toLowerCase(), + top: top, + active: this.selectedLinkId == id ? true : false, + page: page, + section: section + }); + } + } + this.links = links; + return links; + } + + goToStep(link: Link) { + this.selectedLinkId = link.id; + this.stepFound.emit({ + page: link.page, + section: link.section + }); + + setTimeout(() => { + const target = document.getElementById(link.id); + target.scrollIntoView(true); + + var scrolledY = window.scrollY; + if (scrolledY) { + window.scroll(0, scrolledY - 70); + } + }, 200); + } + +} + +export interface LinkToScroll { + page: number; + section: number; +}