angular/aio/src/app/app.component.ts
Pete Bacon Darwin 784347f61f fix(aio): ensure all views can indicate the active node (#17194)
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
2017-06-05 23:36:22 -07:00

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