
When more than one node matches a url, the last node defined in the navigation.json file won. This meant that, for instance, items in both the TopBarNarrow and the Footer views would not indicate that they were active. Now, each url is associated with a map of current nodes keyed off their view. Closes #17022
308 lines
10 KiB
TypeScript
308 lines
10 KiB
TypeScript
import { Component, ElementRef, HostBinding, HostListener, OnInit,
|
|
QueryList, ViewChild, ViewChildren } from '@angular/core';
|
|
import { MdSidenav } from '@angular/material';
|
|
|
|
import { CurrentNodes, NavigationService, NavigationViews, NavigationNode, VersionInfo } from 'app/navigation/navigation.service';
|
|
import { DocumentService, DocumentContents } from 'app/documents/document.service';
|
|
import { DocViewerComponent } from 'app/layout/doc-viewer/doc-viewer.component';
|
|
import { LocationService } from 'app/shared/location.service';
|
|
import { NavMenuComponent } from 'app/layout/nav-menu/nav-menu.component';
|
|
import { ScrollService } from 'app/shared/scroll.service';
|
|
import { SearchResultsComponent } from 'app/search/search-results/search-results.component';
|
|
import { SearchBoxComponent } from 'app/search/search-box/search-box.component';
|
|
import { SearchService } from 'app/search/search.service';
|
|
import { SwUpdateNotificationsService } from 'app/sw-updates/sw-update-notifications.service';
|
|
|
|
import { combineLatest } from 'rxjs/observable/combineLatest';
|
|
|
|
const sideNavView = 'SideNav';
|
|
|
|
@Component({
|
|
selector: 'aio-shell',
|
|
templateUrl: './app.component.html',
|
|
})
|
|
export class AppComponent implements OnInit {
|
|
|
|
currentDocument: DocumentContents;
|
|
currentDocVersion: NavigationNode;
|
|
currentNodes: CurrentNodes;
|
|
currentPath: string;
|
|
docVersions: NavigationNode[];
|
|
dtOn = false;
|
|
footerNodes: NavigationNode[];
|
|
|
|
/**
|
|
* An HTML friendly identifier for the currently displayed page.
|
|
* This is computed from the `currentDocument.id` by replacing `/` with `-`
|
|
*/
|
|
pageId: string;
|
|
/**
|
|
* An HTML friendly identifer for the "folder" of the currently displayed page.
|
|
* This is computed by taking everything up to the first `/` in the `currentDocument.id`
|
|
*/
|
|
folderId: string;
|
|
/**
|
|
* These CSS classes are computed from the current state of the application
|
|
* (e.g. what document is being viewed) to allow for fine grain control over
|
|
* the styling of individual pages.
|
|
* You will get three classes:
|
|
*
|
|
* * `page-...`: computed from the current document id (e.g. news, guide-security, tutorial-toh-pt2)
|
|
* * `folder-...`: computed from the top level folder for an id (e.g. guide, tutorial, etc)
|
|
* * `view-...`: computef from the navigation view (e.g. SideNav, TopBar, etc)
|
|
*/
|
|
@HostBinding('class')
|
|
hostClasses = '';
|
|
|
|
isFetching = false;
|
|
isStarting = true;
|
|
isSideBySide = false;
|
|
private isFetchingTimeout: any;
|
|
private isSideNavDoc = false;
|
|
|
|
private sideBySideWidth = 992;
|
|
sideNavNodes: NavigationNode[];
|
|
topMenuNodes: NavigationNode[];
|
|
topMenuNarrowNodes: NavigationNode[];
|
|
|
|
showFloatingToc = false;
|
|
showFloatingTocWidth = 800;
|
|
tocMaxHeight: string;
|
|
private tocMaxHeightOffset = 0;
|
|
|
|
versionInfo: VersionInfo;
|
|
|
|
get homeImageUrl() {
|
|
return this.isSideBySide ?
|
|
'assets/images/logos/standard/logo-nav@2x.png' :
|
|
'assets/images/logos/standard/shield-large.svg';
|
|
}
|
|
get isOpened() { return this.isSideBySide && this.isSideNavDoc; }
|
|
get mode() { return this.isSideBySide ? 'side' : 'over'; }
|
|
|
|
// Need the doc-viewer element for scrolling the contents
|
|
@ViewChild(DocViewerComponent, { read: ElementRef })
|
|
docViewer: ElementRef;
|
|
|
|
// Search related properties
|
|
showSearchResults = false;
|
|
@ViewChildren('searchBox, searchResults', { read: ElementRef })
|
|
searchElements: QueryList<ElementRef>;
|
|
@ViewChild(SearchResultsComponent)
|
|
searchResults: SearchResultsComponent;
|
|
@ViewChild(SearchBoxComponent)
|
|
searchBox: SearchBoxComponent;
|
|
|
|
@ViewChild(MdSidenav)
|
|
sidenav: MdSidenav;
|
|
|
|
constructor(
|
|
private documentService: DocumentService,
|
|
private hostElement: ElementRef,
|
|
private locationService: LocationService,
|
|
private navigationService: NavigationService,
|
|
private scrollService: ScrollService,
|
|
private searchService: SearchService,
|
|
private swUpdateNotifications: SwUpdateNotificationsService
|
|
) { }
|
|
|
|
ngOnInit() {
|
|
// Do not initialize the search on browsers that lack web worker support
|
|
if ('Worker' in window) {
|
|
this.searchService.initWorker('app/search/search-worker.js');
|
|
this.searchService.loadIndex();
|
|
}
|
|
|
|
this.onResize(window.innerWidth);
|
|
|
|
/* No need to unsubscribe because this root component never dies */
|
|
|
|
this.documentService.currentDocument.subscribe(doc => {
|
|
this.currentDocument = doc;
|
|
this.setPageId(doc.id);
|
|
this.setFolderId(doc.id);
|
|
this.updateHostClasses();
|
|
});
|
|
|
|
this.locationService.currentPath.subscribe(path => {
|
|
if (path === this.currentPath) {
|
|
// scroll only if on same page (most likely a change to the hash)
|
|
this.autoScroll();
|
|
} else {
|
|
// don't scroll; leave that to `onDocRendered`
|
|
this.currentPath = path;
|
|
|
|
// Start progress bar if doc not rendered within brief time
|
|
clearTimeout(this.isFetchingTimeout);
|
|
this.isFetchingTimeout = setTimeout(() => this.isFetching = true, 200);
|
|
}
|
|
});
|
|
|
|
this.navigationService.currentNodes.subscribe(currentNodes => {
|
|
this.currentNodes = currentNodes;
|
|
|
|
// Preserve current sidenav open state by default
|
|
let openSideNav = this.sidenav.opened;
|
|
const isSideNavDoc = !!currentNodes[sideNavView];
|
|
|
|
if (this.isSideNavDoc !== isSideNavDoc) {
|
|
// View type changed. Is it now a sidenav view (e.g, guide or tutorial)?
|
|
// Open if changed to a sidenav doc; close if changed to a marketing doc.
|
|
openSideNav = this.isSideNavDoc = isSideNavDoc;
|
|
}
|
|
// May be open or closed when wide; always closed when narrow
|
|
this.sideNavToggle(this.isSideBySide ? openSideNav : false);
|
|
});
|
|
|
|
// Compute the version picker list from the current version and the versions in the navigation map
|
|
combineLatest(
|
|
this.navigationService.versionInfo.map(versionInfo => ({ title: versionInfo.raw, url: null })),
|
|
this.navigationService.navigationViews.map(views => views['docVersions']),
|
|
(currentVersion, otherVersions) => [currentVersion, ...otherVersions])
|
|
.subscribe(versions => {
|
|
this.docVersions = versions;
|
|
this.currentDocVersion = this.docVersions[0];
|
|
});
|
|
|
|
this.navigationService.navigationViews.subscribe(views => {
|
|
this.footerNodes = views['Footer'] || [];
|
|
this.sideNavNodes = views['SideNav'] || [];
|
|
this.topMenuNodes = views['TopBar'] || [];
|
|
this.topMenuNarrowNodes = views['TopBarNarrow'] || this.topMenuNodes;
|
|
});
|
|
|
|
this.navigationService.versionInfo.subscribe( vi => this.versionInfo = vi );
|
|
|
|
this.swUpdateNotifications.enable();
|
|
}
|
|
|
|
// Scroll to the anchor in the hash fragment or top of doc.
|
|
autoScroll() {
|
|
this.scrollService.scroll();
|
|
}
|
|
|
|
onDocRendered() {
|
|
// Stop fetching timeout (which, when render is fast, means progress bar never shown)
|
|
clearTimeout(this.isFetchingTimeout);
|
|
|
|
// Scroll 500ms after the doc-viewer has finished rendering the new doc
|
|
// The delay is to allow time for async layout to complete
|
|
setTimeout(() => {
|
|
this.autoScroll();
|
|
this.isStarting = false;
|
|
this.isFetching = false;
|
|
}, 500);
|
|
}
|
|
|
|
onDocVersionChange(versionIndex: number) {
|
|
const version = this.docVersions[versionIndex];
|
|
if (version.url) {
|
|
this.locationService.go(version.url);
|
|
}
|
|
}
|
|
|
|
@HostListener('window:resize', ['$event.target.innerWidth'])
|
|
onResize(width) {
|
|
this.isSideBySide = width > this.sideBySideWidth;
|
|
this.showFloatingToc = width > this.showFloatingTocWidth;
|
|
}
|
|
|
|
@HostListener('click', ['$event.target', '$event.button', '$event.ctrlKey', '$event.metaKey', '$event.altKey'])
|
|
onClick(eventTarget: HTMLElement, button: number, ctrlKey: boolean, metaKey: boolean, altKey: boolean): boolean {
|
|
|
|
// Hide the search results if we clicked outside both the "search box" and the "search results"
|
|
if (!this.searchElements.some(element => element.nativeElement.contains(eventTarget))) {
|
|
this.hideSearchResults();
|
|
}
|
|
|
|
// Show developer source view if the footer is clicked while holding the meta and alt keys
|
|
if (eventTarget.tagName === 'FOOTER' && metaKey && altKey) {
|
|
this.dtOn = !this.dtOn;
|
|
return false;
|
|
}
|
|
|
|
// Deal with anchor clicks; climb DOM tree until anchor found (or null)
|
|
let target = eventTarget;
|
|
while (target && !(target instanceof HTMLAnchorElement)) {
|
|
target = target.parentElement;
|
|
}
|
|
if (target instanceof HTMLAnchorElement) {
|
|
return this.locationService.handleAnchorClick(target, button, ctrlKey, metaKey);
|
|
}
|
|
|
|
// Allow the click to pass through
|
|
return true;
|
|
}
|
|
|
|
sideNavToggle(value?: boolean) {
|
|
this.sidenav.toggle(value);
|
|
}
|
|
|
|
setPageId(id: string) {
|
|
// Special case the home page
|
|
this.pageId = (id === 'index') ? 'home' : id.replace('/', '-');
|
|
}
|
|
|
|
setFolderId(id: string) {
|
|
// Special case the home page
|
|
this.folderId = (id === 'index') ? 'home' : id.split('/', 1)[0];
|
|
}
|
|
|
|
updateHostClasses() {
|
|
const sideNavOpen = `sidenav-${this.sidenav.opened ? 'open' : 'closed'}`;
|
|
const pageClass = `page-${this.pageId}`;
|
|
const folderClass = `folder-${this.folderId}`;
|
|
const viewClasses = Object.keys(this.currentNodes || {}).map(view => `view-${view}`).join(' ');
|
|
|
|
this.hostClasses = `${sideNavOpen} ${pageClass} ${folderClass} ${viewClasses}`;
|
|
}
|
|
|
|
// Dynamically change height of table of contents container
|
|
@HostListener('window:scroll')
|
|
onScroll() {
|
|
if (!this.tocMaxHeightOffset) {
|
|
// Must wait until now for md-toolbar to be measurable.
|
|
const el = this.hostElement.nativeElement as Element;
|
|
this.tocMaxHeightOffset =
|
|
el.querySelector('footer').clientHeight +
|
|
el.querySelector('md-toolbar.app-toolbar').clientHeight +
|
|
44; // margin
|
|
}
|
|
|
|
this.tocMaxHeight = (document.body.scrollHeight - window.pageYOffset - this.tocMaxHeightOffset).toFixed(2);
|
|
}
|
|
|
|
|
|
// Search related methods and handlers
|
|
|
|
hideSearchResults() {
|
|
this.showSearchResults = false;
|
|
}
|
|
|
|
focusSearchBox() {
|
|
if (this.searchBox) {
|
|
this.searchBox.focus();
|
|
}
|
|
}
|
|
|
|
doSearch(query) {
|
|
this.searchService.search(query);
|
|
this.showSearchResults = !!query;
|
|
}
|
|
|
|
@HostListener('document:keyup', ['$event.key', '$event.which'])
|
|
onKeyUp(key: string, keyCode: number) {
|
|
// forward slash "/"
|
|
if (key === '/' || keyCode === 191) {
|
|
this.focusSearchBox();
|
|
}
|
|
if (key === 'Escape' || keyCode === 27 ) {
|
|
// escape key
|
|
if (this.showSearchResults) {
|
|
this.hideSearchResults();
|
|
this.focusSearchBox();
|
|
}
|
|
}
|
|
}
|
|
}
|