feat(aio): TOC float right + service refactor
TOC appears in right panel when wide and hides embedded TOC Right TOC panel height adjusts dynamically during scroll Refactored `TocService` and its tests for clarity.
This commit is contained in:
parent
566dab1140
commit
799be9c98a
|
@ -33,7 +33,9 @@
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<div *ngIf="isSideBySide" class="toc-container"></div>
|
<div class="toc-container" [style.max-height.px]="tocMaxHeight">
|
||||||
|
<aio-toc></aio-toc>
|
||||||
|
</div>
|
||||||
|
|
||||||
<footer>
|
<footer>
|
||||||
<aio-footer [nodes]="footerNodes" [versionInfo]="versionInfo" ></aio-footer>
|
<aio-footer [nodes]="footerNodes" [versionInfo]="versionInfo" ></aio-footer>
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { NO_ERRORS_SCHEMA } from '@angular/core';
|
import { NO_ERRORS_SCHEMA, DebugElement } from '@angular/core';
|
||||||
import { async, inject, ComponentFixture, TestBed } from '@angular/core/testing';
|
import { async, inject, ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
import { Title } from '@angular/platform-browser';
|
import { Title } from '@angular/platform-browser';
|
||||||
import { APP_BASE_HREF } from '@angular/common';
|
import { APP_BASE_HREF } from '@angular/common';
|
||||||
|
@ -10,20 +10,21 @@ import { of } from 'rxjs/observable/of';
|
||||||
|
|
||||||
import { AppComponent } from './app.component';
|
import { AppComponent } from './app.component';
|
||||||
import { AppModule } from './app.module';
|
import { AppModule } from './app.module';
|
||||||
import { AutoScrollService } from 'app/shared/auto-scroll.service';
|
import { TocComponent } from 'app/embedded/toc/toc.component';
|
||||||
import { DocViewerComponent } from 'app/layout/doc-viewer/doc-viewer.component';
|
import { DocViewerComponent } from 'app/layout/doc-viewer/doc-viewer.component';
|
||||||
|
import { NavigationNode } from 'app/navigation/navigation.service';
|
||||||
|
import { SearchService } from 'app/search/search.service';
|
||||||
|
import { SearchBoxComponent } from 'app/search/search-box/search-box.component';
|
||||||
|
import { SearchResultsComponent } from 'app/search/search-results/search-results.component';
|
||||||
|
import { AutoScrollService } from 'app/shared/auto-scroll.service';
|
||||||
import { GaService } from 'app/shared/ga.service';
|
import { GaService } from 'app/shared/ga.service';
|
||||||
import { LocationService } from 'app/shared/location.service';
|
import { LocationService } from 'app/shared/location.service';
|
||||||
import { Logger } from 'app/shared/logger.service';
|
import { Logger } from 'app/shared/logger.service';
|
||||||
|
import { SwUpdateNotificationsService } from 'app/sw-updates/sw-update-notifications.service';
|
||||||
import { MockLocationService } from 'testing/location.service';
|
import { MockLocationService } from 'testing/location.service';
|
||||||
import { MockLogger } from 'testing/logger.service';
|
import { MockLogger } from 'testing/logger.service';
|
||||||
import { MockSearchService } from 'testing/search.service';
|
import { MockSearchService } from 'testing/search.service';
|
||||||
import { MockSwUpdateNotificationsService } from 'testing/sw-update-notifications.service';
|
import { MockSwUpdateNotificationsService } from 'testing/sw-update-notifications.service';
|
||||||
import { NavigationNode } from 'app/navigation/navigation.service';
|
|
||||||
import { SearchBoxComponent } from 'app/search/search-box/search-box.component';
|
|
||||||
import { SearchResultsComponent } from 'app/search/search-results/search-results.component';
|
|
||||||
import { SearchService } from 'app/search/search.service';
|
|
||||||
import { SwUpdateNotificationsService } from 'app/sw-updates/sw-update-notifications.service';
|
|
||||||
|
|
||||||
describe('AppComponent', () => {
|
describe('AppComponent', () => {
|
||||||
let component: AppComponent;
|
let component: AppComponent;
|
||||||
|
@ -71,6 +72,15 @@ describe('AppComponent', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('onScroll', () => {
|
||||||
|
it('should update `tocMaxHeight` accordingly', () => {
|
||||||
|
expect(component.tocMaxHeight).toBeUndefined();
|
||||||
|
|
||||||
|
component.onScroll();
|
||||||
|
expect(component.tocMaxHeight).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('SideNav when side-by-side (wide)', () => {
|
describe('SideNav when side-by-side (wide)', () => {
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
@ -434,6 +444,31 @@ describe('AppComponent', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('aio-toc', () => {
|
||||||
|
let tocDebugElement: DebugElement;
|
||||||
|
let tocContainer: DebugElement;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
tocDebugElement = fixture.debugElement.query(By.directive(TocComponent));
|
||||||
|
tocContainer = tocDebugElement.parent;
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
it('should have a non-embedded `<aio-toc>` element', () => {
|
||||||
|
expect(tocDebugElement).toBeDefined();
|
||||||
|
expect(tocDebugElement.classes.embedded).toBeFalsy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should update the TOC container\'s `maxHeight` based on `tocMaxHeight`', () => {
|
||||||
|
expect(tocContainer.styles['max-height']).toBeNull();
|
||||||
|
|
||||||
|
component.tocMaxHeight = '100';
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
expect(tocContainer.styles['max-height']).toBe('100px');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('footer', () => {
|
describe('footer', () => {
|
||||||
it('should have version number', () => {
|
it('should have version number', () => {
|
||||||
const versionEl: HTMLElement = fixture.debugElement.query(By.css('aio-footer')).nativeElement;
|
const versionEl: HTMLElement = fixture.debugElement.query(By.css('aio-footer')).nativeElement;
|
||||||
|
|
|
@ -19,9 +19,13 @@ const sideNavView = 'SideNav';
|
||||||
})
|
})
|
||||||
export class AppComponent implements OnInit {
|
export class AppComponent implements OnInit {
|
||||||
|
|
||||||
|
currentDocument: DocumentContents;
|
||||||
|
currentDocVersion: NavigationNode;
|
||||||
currentNode: CurrentNode;
|
currentNode: CurrentNode;
|
||||||
currentPath: string;
|
currentPath: string;
|
||||||
|
docVersions: NavigationNode[];
|
||||||
dtOn = false;
|
dtOn = false;
|
||||||
|
footerNodes: NavigationNode[];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* An HTML friendly identifier for the currently displayed page.
|
* An HTML friendly identifier for the currently displayed page.
|
||||||
|
@ -46,9 +50,6 @@ export class AppComponent implements OnInit {
|
||||||
@HostBinding('class')
|
@HostBinding('class')
|
||||||
hostClasses = '';
|
hostClasses = '';
|
||||||
|
|
||||||
currentDocument: DocumentContents;
|
|
||||||
footerNodes: NavigationNode[];
|
|
||||||
|
|
||||||
isStarting = true;
|
isStarting = true;
|
||||||
isSideBySide = false;
|
isSideBySide = false;
|
||||||
private isSideNavDoc = false;
|
private isSideNavDoc = false;
|
||||||
|
@ -57,9 +58,8 @@ export class AppComponent implements OnInit {
|
||||||
private sideBySideWidth = 1032;
|
private sideBySideWidth = 1032;
|
||||||
sideNavNodes: NavigationNode[];
|
sideNavNodes: NavigationNode[];
|
||||||
topMenuNodes: NavigationNode[];
|
topMenuNodes: NavigationNode[];
|
||||||
|
tocMaxHeight: string;
|
||||||
currentDocVersion: NavigationNode;
|
private tocMaxHeightOffset = 0;
|
||||||
docVersions: NavigationNode[];
|
|
||||||
versionInfo: VersionInfo;
|
versionInfo: VersionInfo;
|
||||||
|
|
||||||
get homeImageUrl() {
|
get homeImageUrl() {
|
||||||
|
@ -86,6 +86,7 @@ export class AppComponent implements OnInit {
|
||||||
constructor(
|
constructor(
|
||||||
private autoScrollService: AutoScrollService,
|
private autoScrollService: AutoScrollService,
|
||||||
private documentService: DocumentService,
|
private documentService: DocumentService,
|
||||||
|
private hostElement: ElementRef,
|
||||||
private locationService: LocationService,
|
private locationService: LocationService,
|
||||||
private navigationService: NavigationService,
|
private navigationService: NavigationService,
|
||||||
private swUpdateNotifications: SwUpdateNotificationsService
|
private swUpdateNotifications: SwUpdateNotificationsService
|
||||||
|
@ -209,4 +210,19 @@ export class AppComponent implements OnInit {
|
||||||
|
|
||||||
this.hostClasses = `${pageClass} ${folderClass} ${viewClass}`;
|
this.hostClasses = `${pageClass} ${folderClass} ${viewClass}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -45,6 +45,9 @@ export class EmbeddedComponents {
|
||||||
ContributorComponent,
|
ContributorComponent,
|
||||||
EmbeddedPlunkerComponent
|
EmbeddedPlunkerComponent
|
||||||
],
|
],
|
||||||
|
exports: [
|
||||||
|
TocComponent
|
||||||
|
],
|
||||||
providers: [
|
providers: [
|
||||||
ContributorService,
|
ContributorService,
|
||||||
CopierService,
|
CopierService,
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
import { Component, DebugElement } from '@angular/core';
|
import { Component, DebugElement } from '@angular/core';
|
||||||
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
|
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
import { By, DOCUMENT } from '@angular/platform-browser';
|
import { By, DOCUMENT } from '@angular/platform-browser';
|
||||||
|
import { BehaviorSubject } from 'rxjs/BehaviorSubject';
|
||||||
|
|
||||||
import { TocComponent } from './toc.component';
|
import { TocComponent } from './toc.component';
|
||||||
import { TocItem, TocService } from 'app/shared/toc.service';
|
import { TocItem, TocService } from 'app/shared/toc.service';
|
||||||
|
@ -55,15 +56,36 @@ describe('TocComponent', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should not display anything when no TocItems', () => {
|
it('should not display anything when no TocItems', () => {
|
||||||
tocService.tocList = [];
|
tocService.tocList.next([]);
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
expect(tocComponentDe.children.length).toEqual(0);
|
expect(tocComponentDe.children.length).toEqual(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should update when the TocItems are updated', () => {
|
||||||
|
tocService.tocList.next([{}] as TocItem[]);
|
||||||
|
fixture.detectChanges();
|
||||||
|
expect(tocComponentDe.queryAllNodes(By.css('li')).length).toBe(1);
|
||||||
|
|
||||||
|
tocService.tocList.next([{}, {}, {}] as TocItem[]);
|
||||||
|
fixture.detectChanges();
|
||||||
|
expect(tocComponentDe.queryAllNodes(By.css('li')).length).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should stop listening for TocItems once destroyed', () => {
|
||||||
|
tocService.tocList.next([{}] as TocItem[]);
|
||||||
|
fixture.detectChanges();
|
||||||
|
expect(tocComponentDe.queryAllNodes(By.css('li')).length).toBe(1);
|
||||||
|
|
||||||
|
tocComponent.ngOnDestroy();
|
||||||
|
tocService.tocList.next([{}, {}, {}] as TocItem[]);
|
||||||
|
fixture.detectChanges();
|
||||||
|
expect(tocComponentDe.queryAllNodes(By.css('li')).length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
describe('when four TocItems', () => {
|
describe('when four TocItems', () => {
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
tocService.tocList.length = 4;
|
tocService.tocList.next([{}, {}, {}, {}] as TocItem[]);
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
page = setPage();
|
page = setPage();
|
||||||
});
|
});
|
||||||
|
@ -92,8 +114,11 @@ describe('TocComponent', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should have more than 4 displayed items', () => {
|
it('should have more than 4 displayed items', () => {
|
||||||
|
let tocList: TocItem[];
|
||||||
|
tocService.tocList.subscribe(v => tocList = v);
|
||||||
|
|
||||||
expect(page.listItems.length).toBeGreaterThan(4);
|
expect(page.listItems.length).toBeGreaterThan(4);
|
||||||
expect(page.listItems.length).toEqual(tocService.tocList.length);
|
expect(page.listItems.length).toEqual(tocList.length);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should be in "closed" (not expanded) state at the start', () => {
|
it('should be in "closed" (not expanded) state at the start', () => {
|
||||||
|
@ -154,7 +179,10 @@ describe('TocComponent', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should display all items', () => {
|
it('should display all items', () => {
|
||||||
expect(page.listItems.length).toEqual(tocService.tocList.length);
|
let tocList: TocItem[];
|
||||||
|
tocService.tocList.subscribe(v => tocList = v);
|
||||||
|
|
||||||
|
expect(page.listItems.length).toEqual(tocList.length);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should not have secondary items', () => {
|
it('should not have secondary items', () => {
|
||||||
|
@ -185,7 +213,7 @@ class HostEmbeddedTocComponent {}
|
||||||
class HostNotEmbeddedTocComponent {}
|
class HostNotEmbeddedTocComponent {}
|
||||||
|
|
||||||
class TestTocService {
|
class TestTocService {
|
||||||
tocList: TocItem[] = getTestTocList();
|
tocList = new BehaviorSubject<TocItem[]>(getTestTocList());
|
||||||
}
|
}
|
||||||
|
|
||||||
// tslint:disable:quotemark
|
// tslint:disable:quotemark
|
||||||
|
|
|
@ -1,4 +1,6 @@
|
||||||
import { Component, ElementRef, OnInit } from '@angular/core';
|
import { Component, ElementRef, OnDestroy, OnInit } from '@angular/core';
|
||||||
|
import { Subject } from 'rxjs/Subject';
|
||||||
|
import 'rxjs/add/operator/takeUntil';
|
||||||
|
|
||||||
import { TocItem, TocService } from 'app/shared/toc.service';
|
import { TocItem, TocService } from 'app/shared/toc.service';
|
||||||
|
|
||||||
|
@ -7,13 +9,14 @@ import { TocItem, TocService } from 'app/shared/toc.service';
|
||||||
templateUrl: 'toc.component.html',
|
templateUrl: 'toc.component.html',
|
||||||
styles: []
|
styles: []
|
||||||
})
|
})
|
||||||
export class TocComponent implements OnInit {
|
export class TocComponent implements OnInit, OnDestroy {
|
||||||
|
|
||||||
hasSecondary = false;
|
hasSecondary = false;
|
||||||
hasToc = true;
|
hasToc = true;
|
||||||
isClosed = true;
|
isClosed = true;
|
||||||
isEmbedded = false;
|
isEmbedded = false;
|
||||||
private primaryMax = 4;
|
private primaryMax = 4;
|
||||||
|
private onDestroy = new Subject();
|
||||||
tocList: TocItem[];
|
tocList: TocItem[];
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
|
@ -24,16 +27,25 @@ export class TocComponent implements OnInit {
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnInit() {
|
ngOnInit() {
|
||||||
const tocList = this.tocList = this.tocService.tocList;
|
this.tocService.tocList
|
||||||
|
.takeUntil(this.onDestroy)
|
||||||
|
.subscribe((tocList: TocItem[]) => {
|
||||||
const count = tocList.length;
|
const count = tocList.length;
|
||||||
|
|
||||||
this.hasToc = count > 0;
|
this.hasToc = count > 0;
|
||||||
if (this.isEmbedded && this.hasToc) {
|
this.hasSecondary = this.isEmbedded && this.hasToc && (count > this.primaryMax);
|
||||||
// If TOC is embedded in doc, mark secondary (sometimes hidden) items
|
this.tocList = tocList;
|
||||||
this.hasSecondary = tocList.length > this.primaryMax;
|
|
||||||
|
if (this.hasSecondary) {
|
||||||
for (let i = this.primaryMax; i < count; i++) {
|
for (let i = this.primaryMax; i < count; i++) {
|
||||||
tocList[i].isSecondary = true;
|
tocList[i].isSecondary = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy() {
|
||||||
|
this.onDestroy.next();
|
||||||
}
|
}
|
||||||
|
|
||||||
toggle() {
|
toggle() {
|
||||||
|
|
|
@ -6,6 +6,7 @@ import { TocItem, TocService } from './toc.service';
|
||||||
describe('TocService', () => {
|
describe('TocService', () => {
|
||||||
let injector: ReflectiveInjector;
|
let injector: ReflectiveInjector;
|
||||||
let tocService: TocService;
|
let tocService: TocService;
|
||||||
|
let lastTocList: TocItem[];
|
||||||
|
|
||||||
// call TocService.genToc
|
// call TocService.genToc
|
||||||
function callGenToc(html = '', docId = 'fizz/buzz'): HTMLDivElement {
|
function callGenToc(html = '', docId = 'fizz/buzz'): HTMLDivElement {
|
||||||
|
@ -22,34 +23,66 @@ describe('TocService', () => {
|
||||||
TocService,
|
TocService,
|
||||||
]);
|
]);
|
||||||
tocService = injector.get(TocService);
|
tocService = injector.get(TocService);
|
||||||
|
tocService.tocList.subscribe(tocList => lastTocList = tocList);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should be creatable', () => {
|
it('should be creatable', () => {
|
||||||
expect(tocService).toBeTruthy();
|
expect(tocService).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('tocList', () => {
|
||||||
|
it('should emit the latest value to new subscribers', () => {
|
||||||
|
let value1: TocItem[];
|
||||||
|
let value2: TocItem[];
|
||||||
|
|
||||||
|
tocService.tocList.next([] as TocItem[]);
|
||||||
|
tocService.tocList.subscribe(v => value1 = v);
|
||||||
|
expect(value1).toEqual([]);
|
||||||
|
|
||||||
|
tocService.tocList.next([{}, {}] as TocItem[]);
|
||||||
|
tocService.tocList.subscribe(v => value2 = v);
|
||||||
|
expect(value2).toEqual([{}, {}]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should emit the same values to all subscribers', () => {
|
||||||
|
const emittedValues: TocItem[][] = [];
|
||||||
|
|
||||||
|
tocService.tocList.subscribe(v => emittedValues.push(v));
|
||||||
|
tocService.tocList.subscribe(v => emittedValues.push(v));
|
||||||
|
tocService.tocList.next([{ title: 'A' }, { title: 'B' }] as TocItem[]);
|
||||||
|
|
||||||
|
expect(emittedValues).toEqual([
|
||||||
|
[{ title: 'A' }, { title: 'B' }],
|
||||||
|
[{ title: 'A' }, { title: 'B' }]
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('should clear tocList', () => {
|
describe('should clear tocList', () => {
|
||||||
|
beforeEach(() => {
|
||||||
// Start w/ dummy data from previous usage
|
// Start w/ dummy data from previous usage
|
||||||
beforeEach(() => tocService.tocList = [{}, {}] as TocItem[]);
|
tocService.tocList.next([{}, {}] as TocItem[]);
|
||||||
|
expect(lastTocList).not.toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
it('when reset()', () => {
|
it('when reset()', () => {
|
||||||
tocService.reset();
|
tocService.reset();
|
||||||
expect(tocService.tocList.length).toEqual(0);
|
expect(lastTocList).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('when given undefined doc element', () => {
|
it('when given undefined doc element', () => {
|
||||||
tocService.genToc(undefined);
|
tocService.genToc(undefined);
|
||||||
expect(tocService.tocList.length).toEqual(0);
|
expect(lastTocList).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('when given doc element w/ no headings', () => {
|
it('when given doc element w/ no headings', () => {
|
||||||
callGenToc('<p>This</p><p>and</p><p>that</p>');
|
callGenToc('<p>This</p><p>and</p><p>that</p>');
|
||||||
expect(tocService.tocList.length).toEqual(0);
|
expect(lastTocList).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('when given doc element w/ headings other than h2 & h3', () => {
|
it('when given doc element w/ headings other than h2 & h3', () => {
|
||||||
callGenToc('<h1>This</h1><h4>and</h4><h5>that</h5>');
|
callGenToc('<h1>This</h1><h4>and</h4><h5>that</h5>');
|
||||||
expect(tocService.tocList.length).toEqual(0);
|
expect(lastTocList).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('when given doc element w/ no-toc headings', () => {
|
it('when given doc element w/ no-toc headings', () => {
|
||||||
|
@ -60,14 +93,13 @@ describe('TocService', () => {
|
||||||
<h2 class="no-Toc">three</h2><p>some three</p>
|
<h2 class="no-Toc">three</h2><p>some three</p>
|
||||||
<h2 class="noToc">four</h2><p>some four</p>
|
<h2 class="noToc">four</h2><p>some four</p>
|
||||||
`);
|
`);
|
||||||
expect(tocService.tocList.length).toEqual(0);
|
expect(lastTocList).toEqual([]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('when given many headings', () => {
|
describe('when given many headings', () => {
|
||||||
let docId: string;
|
let docId: string;
|
||||||
let docEl: HTMLDivElement;
|
let docEl: HTMLDivElement;
|
||||||
let tocList: TocItem[];
|
|
||||||
let headings: NodeListOf<HTMLHeadingElement>;
|
let headings: NodeListOf<HTMLHeadingElement>;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
@ -104,39 +136,38 @@ describe('TocService', () => {
|
||||||
<h3 id="h3-6a">H3 6a</h3> <p>h3 toc 8</p>
|
<h3 id="h3-6a">H3 6a</h3> <p>h3 toc 8</p>
|
||||||
`, docId);
|
`, docId);
|
||||||
|
|
||||||
tocList = tocService.tocList;
|
|
||||||
headings = docEl.querySelectorAll('h1,h2,h3,h4') as NodeListOf<HTMLHeadingElement>;
|
headings = docEl.querySelectorAll('h1,h2,h3,h4') as NodeListOf<HTMLHeadingElement>;
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should have tocList with expect number of TocItems', () => {
|
it('should have tocList with expect number of TocItems', () => {
|
||||||
// should ignore h1, h4, and the no-toc h2
|
// should ignore h1, h4, and the no-toc h2
|
||||||
expect(tocList.length).toEqual(headings.length - 3);
|
expect(lastTocList.length).toEqual(headings.length - 3);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should have href with docId and heading\'s id', () => {
|
it('should have href with docId and heading\'s id', () => {
|
||||||
const tocItem = tocList[0];
|
const tocItem = lastTocList[0];
|
||||||
expect(tocItem.href).toEqual(`${docId}#heading-one-special-id`);
|
expect(tocItem.href).toEqual(`${docId}#heading-one-special-id`);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should have level "h2" for an <h2>', () => {
|
it('should have level "h2" for an <h2>', () => {
|
||||||
const tocItem = tocList[0];
|
const tocItem = lastTocList[0];
|
||||||
expect(tocItem.level).toEqual('h2');
|
expect(tocItem.level).toEqual('h2');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should have level "h3" for an <h3>', () => {
|
it('should have level "h3" for an <h3>', () => {
|
||||||
const tocItem = tocList[3];
|
const tocItem = lastTocList[3];
|
||||||
expect(tocItem.level).toEqual('h3');
|
expect(tocItem.level).toEqual('h3');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should have title which is heading\'s innerText ', () => {
|
it('should have title which is heading\'s innerText ', () => {
|
||||||
const heading = headings[3];
|
const heading = headings[3];
|
||||||
const tocItem = tocList[2];
|
const tocItem = lastTocList[2];
|
||||||
expect(heading.innerText).toEqual(tocItem.title);
|
expect(heading.innerText).toEqual(tocItem.title);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should have "SafeHtml" content which is heading\'s innerHTML ', () => {
|
it('should have "SafeHtml" content which is heading\'s innerHTML ', () => {
|
||||||
const heading = headings[3];
|
const heading = headings[3];
|
||||||
const content = tocList[2].content;
|
const content = lastTocList[2].content;
|
||||||
expect((<TestSafeHtml>content).changingThisBreaksApplicationSecurity)
|
expect((<TestSafeHtml>content).changingThisBreaksApplicationSecurity)
|
||||||
.toEqual(heading.innerHTML);
|
.toEqual(heading.innerHTML);
|
||||||
});
|
});
|
||||||
|
@ -147,20 +178,20 @@ describe('TocService', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should have href with docId and calculated heading id', () => {
|
it('should have href with docId and calculated heading id', () => {
|
||||||
const tocItem = tocList[1];
|
const tocItem = lastTocList[1];
|
||||||
expect(tocItem.href).toEqual(`${docId}#h2-two`);
|
expect(tocItem.href).toEqual(`${docId}#h2-two`);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should ignore HTML in heading when calculating id', () => {
|
it('should ignore HTML in heading when calculating id', () => {
|
||||||
const id = headings[3].getAttribute('id');
|
const id = headings[3].getAttribute('id');
|
||||||
const tocItem = tocList[2];
|
const tocItem = lastTocList[2];
|
||||||
expect(id).toEqual('h2-three', 'heading id');
|
expect(id).toEqual('h2-three', 'heading id');
|
||||||
expect(tocItem.href).toEqual(`${docId}#h2-three`, 'tocItem href');
|
expect(tocItem.href).toEqual(`${docId}#h2-three`, 'tocItem href');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should avoid repeating an id when calculating', () => {
|
it('should avoid repeating an id when calculating', () => {
|
||||||
const tocItem4a = tocList[5];
|
const tocItem4a = lastTocList[5];
|
||||||
const tocItem4b = tocList[6];
|
const tocItem4b = lastTocList[6];
|
||||||
expect(tocItem4a.href).toEqual(`${docId}#h2-4-repeat`, 'first');
|
expect(tocItem4a.href).toEqual(`${docId}#h2-4-repeat`, 'first');
|
||||||
expect(tocItem4b.href).toEqual(`${docId}#h2-4-repeat-2`, 'second');
|
expect(tocItem4b.href).toEqual(`${docId}#h2-4-repeat-2`, 'second');
|
||||||
});
|
});
|
||||||
|
@ -186,7 +217,7 @@ describe('TocService', () => {
|
||||||
</h2>
|
</h2>
|
||||||
`, docId);
|
`, docId);
|
||||||
|
|
||||||
tocItem = tocService.tocList[0];
|
tocItem = lastTocList[0];
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should have expected href', () => {
|
it('should have expected href', () => {
|
||||||
|
|
|
@ -1,10 +1,7 @@
|
||||||
import { Inject, Injectable } from '@angular/core';
|
import { Inject, Injectable } from '@angular/core';
|
||||||
import { DOCUMENT, DomSanitizer, SafeHtml } from '@angular/platform-browser';
|
import { DOCUMENT, DomSanitizer, SafeHtml } from '@angular/platform-browser';
|
||||||
|
|
||||||
import { ReplaySubject } from 'rxjs/ReplaySubject';
|
import { ReplaySubject } from 'rxjs/ReplaySubject';
|
||||||
|
|
||||||
import { DocumentContents } from 'app/documents/document.service';
|
|
||||||
|
|
||||||
export interface TocItem {
|
export interface TocItem {
|
||||||
content: SafeHtml;
|
content: SafeHtml;
|
||||||
href: string;
|
href: string;
|
||||||
|
@ -15,19 +12,20 @@ export interface TocItem {
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class TocService {
|
export class TocService {
|
||||||
tocList: TocItem[];
|
tocList = new ReplaySubject<TocItem[]>(1);
|
||||||
|
|
||||||
constructor(@Inject(DOCUMENT) private document: any, private domSanitizer: DomSanitizer) { }
|
constructor(@Inject(DOCUMENT) private document: any, private domSanitizer: DomSanitizer) { }
|
||||||
|
|
||||||
genToc(docElement: Element, docId = '') {
|
genToc(docElement: Element, docId = '') {
|
||||||
const tocList = this.tocList = [];
|
const tocList = [];
|
||||||
if (!docElement) { return; }
|
|
||||||
|
|
||||||
|
if (docElement) {
|
||||||
const headings = docElement.querySelectorAll('h2,h3');
|
const headings = docElement.querySelectorAll('h2,h3');
|
||||||
const idMap = new Map<string, number>();
|
const idMap = new Map<string, number>();
|
||||||
|
|
||||||
for (let i = 0; i < headings.length; i++) {
|
for (let i = 0; i < headings.length; i++) {
|
||||||
const heading = headings[i] as HTMLHeadingElement;
|
const heading = headings[i] as HTMLHeadingElement;
|
||||||
|
|
||||||
// skip if heading class is 'no-toc'
|
// skip if heading class is 'no-toc'
|
||||||
if (/(no-toc|notoc)/i.test(heading.className)) { continue; }
|
if (/(no-toc|notoc)/i.test(heading.className)) { continue; }
|
||||||
|
|
||||||
|
@ -38,12 +36,16 @@ export class TocService {
|
||||||
level: heading.tagName.toLowerCase(),
|
level: heading.tagName.toLowerCase(),
|
||||||
title: heading.innerText.trim(),
|
title: heading.innerText.trim(),
|
||||||
};
|
};
|
||||||
|
|
||||||
tocList.push(toc);
|
tocList.push(toc);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.tocList.next(tocList);
|
||||||
|
}
|
||||||
|
|
||||||
reset() {
|
reset() {
|
||||||
this.tocList = [];
|
this.tocList.next([]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// This bad boy exists only to strip off the anchor link attached to a heading
|
// This bad boy exists only to strip off the anchor link attached to a heading
|
||||||
|
|
|
@ -1,9 +1,9 @@
|
||||||
.toc-container {
|
.toc-container {
|
||||||
width: 18%;
|
width: 18%;
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 84px;
|
top: 96px;
|
||||||
right: 0;
|
right: 0;
|
||||||
bottom: 18px;
|
bottom: 32px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
|
|
||||||
|
@ -13,6 +13,20 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
aio-toc {
|
||||||
|
&.embedded {
|
||||||
|
@media (min-width: 801px) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&:not(.embedded) {
|
||||||
|
@media (max-width: 800px) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
aio-toc > div {
|
aio-toc > div {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
overflow-y: visible;
|
overflow-y: visible;
|
||||||
|
@ -141,6 +155,6 @@ aio-toc > div {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
aio-toc > div.closed li.secondary {
|
aio-toc.embedded > div.closed li.secondary {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue