diff --git a/aio/src/app/app.module.ts b/aio/src/app/app.module.ts
index 275c15def1..564ac3cf1d 100644
--- a/aio/src/app/app.module.ts
+++ b/aio/src/app/app.module.ts
@@ -31,6 +31,7 @@ import { FooterComponent } from 'app/layout/footer/footer.component';
import { NavMenuComponent } from 'app/layout/nav-menu/nav-menu.component';
import { NavItemComponent } from 'app/layout/nav-item/nav-item.component';
import { ScrollService } from 'app/shared/scroll.service';
+import { ScrollSpyService } from 'app/shared/scroll-spy.service';
import { SearchResultsComponent } from './search/search-results/search-results.component';
import { SearchBoxComponent } from './search/search-box/search-box.component';
import { TocService } from 'app/shared/toc.service';
@@ -94,6 +95,7 @@ export const svgIconProviders = [
NavigationService,
Platform,
ScrollService,
+ ScrollSpyService,
SearchService,
svgIconProviders,
TocService
diff --git a/aio/src/app/embedded/toc/toc.component.html b/aio/src/app/embedded/toc/toc.component.html
index d30070d473..9bc80a615e 100644
--- a/aio/src/app/embedded/toc/toc.component.html
+++ b/aio/src/app/embedded/toc/toc.component.html
@@ -14,7 +14,8 @@
diff --git a/aio/src/app/embedded/toc/toc.component.spec.ts b/aio/src/app/embedded/toc/toc.component.spec.ts
index 370394aabd..9192dd8c97 100644
--- a/aio/src/app/embedded/toc/toc.component.spec.ts
+++ b/aio/src/app/embedded/toc/toc.component.spec.ts
@@ -227,7 +227,7 @@ describe('TocComponent', () => {
});
});
- describe('when in side panel (not embedded))', () => {
+ describe('when in side panel (not embedded)', () => {
let fixture: ComponentFixture;
let scrollToTopSpy: jasmine.Spy;
@@ -274,6 +274,161 @@ describe('TocComponent', () => {
fixture.detectChanges();
expect(scrollToTopSpy).toHaveBeenCalled();
});
+
+ describe('#activeIndex', () => {
+ it('should keep track of `TocService`\'s `activeItemIndex`', () => {
+ expect(tocComponent.activeIndex).toBeNull();
+
+ tocService.activeItemIndex.next(42);
+ expect(tocComponent.activeIndex).toBe(42);
+
+ tocService.activeItemIndex.next(null);
+ expect(tocComponent.activeIndex).toBeNull();
+ });
+
+ it('should stop tracking `activeItemIndex` once destroyed', () => {
+ tocService.activeItemIndex.next(42);
+ expect(tocComponent.activeIndex).toBe(42);
+
+ tocComponent.ngOnDestroy();
+
+ tocService.activeItemIndex.next(43);
+ expect(tocComponent.activeIndex).toBe(42);
+
+ tocService.activeItemIndex.next(null);
+ expect(tocComponent.activeIndex).toBe(42);
+ });
+
+ it('should set the `active` class to the active anchor (and only that)', () => {
+ expect(page.listItems.findIndex(By.css('.active'))).toBe(-1);
+
+ tocComponent.activeIndex = 1;
+ fixture.detectChanges();
+ expect(page.listItems.filter(By.css('.active')).length).toBe(1);
+ expect(page.listItems.findIndex(By.css('.active'))).toBe(1);
+
+ tocComponent.activeIndex = null;
+ fixture.detectChanges();
+ expect(page.listItems.filter(By.css('.active')).length).toBe(0);
+ expect(page.listItems.findIndex(By.css('.active'))).toBe(-1);
+
+ tocComponent.activeIndex = 0;
+ fixture.detectChanges();
+ expect(page.listItems.filter(By.css('.active')).length).toBe(1);
+ expect(page.listItems.findIndex(By.css('.active'))).toBe(0);
+
+ tocComponent.activeIndex = 1337;
+ fixture.detectChanges();
+ expect(page.listItems.filter(By.css('.active')).length).toBe(0);
+ expect(page.listItems.findIndex(By.css('.active'))).toBe(-1);
+
+ tocComponent.activeIndex = page.listItems.length - 1;
+ fixture.detectChanges();
+ expect(page.listItems.filter(By.css('.active')).length).toBe(1);
+ expect(page.listItems.findIndex(By.css('.active'))).toBe(page.listItems.length - 1);
+ });
+
+ it('should re-apply the `active` class when the list elements change', () => {
+ const getActiveTextContent = () =>
+ page.listItems.find(By.css('.active')).nativeElement.textContent.trim();
+
+ tocComponent.activeIndex = 1;
+ fixture.detectChanges();
+ expect(getActiveTextContent()).toBe('H2 Two');
+
+ tocComponent.tocList = [{content: 'New 1'}, {content: 'New 2'}] as any as TocItem[];
+ fixture.detectChanges();
+ page = setPage();
+ expect(getActiveTextContent()).toBe('New 2');
+
+ tocComponent.tocList.unshift({content: 'New 0'} as any as TocItem);
+ fixture.detectChanges();
+ page = setPage();
+ expect(getActiveTextContent()).toBe('New 1');
+
+ tocComponent.tocList = [{content: 'Very New 1'}] as any as TocItem[];
+ fixture.detectChanges();
+ page = setPage();
+ expect(page.listItems.findIndex(By.css('.active'))).toBe(-1);
+
+ tocComponent.activeIndex = 0;
+ fixture.detectChanges();
+ expect(getActiveTextContent()).toBe('Very New 1');
+ });
+
+ describe('should scroll the active ToC item into viewport (if not already visible)', () => {
+ let parentScrollTop: number;
+
+ beforeEach(() => {
+ const firstItem = page.listItems[0].nativeElement;
+ const offsetParent = firstItem.offsetParent;
+
+ offsetParent.style.maxHeight = `${offsetParent.clientHeight - firstItem.clientHeight}px`;
+ Object.defineProperty(offsetParent, 'scrollTop', {
+ get: () => parentScrollTop,
+ set: v => parentScrollTop = v
+ });
+
+ parentScrollTop = 0;
+ });
+
+ it('when the `activeIndex` changes', () => {
+ tocService.activeItemIndex.next(0);
+ fixture.detectChanges();
+
+ expect(parentScrollTop).toBe(0);
+
+ tocService.activeItemIndex.next(1);
+ fixture.detectChanges();
+
+ expect(parentScrollTop).toBe(0);
+
+ tocService.activeItemIndex.next(page.listItems.length - 1);
+ fixture.detectChanges();
+
+ expect(parentScrollTop).toBeGreaterThan(0);
+ });
+
+ it('when the `tocList` changes', () => {
+ const tocList = tocComponent.tocList;
+
+ tocComponent.tocList = [];
+ fixture.detectChanges();
+
+ expect(parentScrollTop).toBe(0);
+
+ tocService.activeItemIndex.next(tocList.length - 1);
+ fixture.detectChanges();
+
+ expect(parentScrollTop).toBe(0);
+
+ tocComponent.tocList = tocList;
+ fixture.detectChanges();
+
+ expect(parentScrollTop).toBeGreaterThan(0);
+ });
+
+ it('not after it has been destroyed', () => {
+ const tocList = tocComponent.tocList;
+ tocComponent.ngOnDestroy();
+
+ tocService.activeItemIndex.next(page.listItems.length - 1);
+ fixture.detectChanges();
+
+ expect(parentScrollTop).toBe(0);
+
+ tocComponent.tocList = [];
+ fixture.detectChanges();
+
+ expect(parentScrollTop).toBe(0);
+
+ tocComponent.tocList = tocList;
+ fixture.detectChanges();
+
+ expect(parentScrollTop).toBe(0);
+ });
+ });
+ });
});
});
@@ -297,6 +452,7 @@ class TestScrollService {
class TestTocService {
tocList = new BehaviorSubject(getTestTocList());
+ activeItemIndex = new BehaviorSubject(null);
}
// tslint:disable:quotemark
diff --git a/aio/src/app/embedded/toc/toc.component.ts b/aio/src/app/embedded/toc/toc.component.ts
index 6e261e9aa4..3133ec22b8 100644
--- a/aio/src/app/embedded/toc/toc.component.ts
+++ b/aio/src/app/embedded/toc/toc.component.ts
@@ -1,5 +1,7 @@
-import { Component, ElementRef, OnDestroy, OnInit } from '@angular/core';
+import { AfterViewInit, Component, ElementRef, OnDestroy, OnInit, QueryList, ViewChildren } from '@angular/core';
+import { Observable } from 'rxjs/Observable';
import { Subject } from 'rxjs/Subject';
+import 'rxjs/add/observable/combineLatest';
import 'rxjs/add/operator/takeUntil';
import { ScrollService } from 'app/shared/scroll.service';
@@ -10,13 +12,15 @@ import { TocItem, TocService } from 'app/shared/toc.service';
templateUrl: 'toc.component.html',
styles: []
})
-export class TocComponent implements OnInit, OnDestroy {
+export class TocComponent implements OnInit, AfterViewInit, OnDestroy {
+ activeIndex: number | null = null;
hasSecondary = false;
hasToc = false;
hostElement: HTMLElement;
isCollapsed = true;
isEmbedded = false;
+ @ViewChildren('tocItem') private items: QueryList;
private onDestroy = new Subject();
private primaryMax = 4;
tocList: TocItem[];
@@ -32,7 +36,7 @@ export class TocComponent implements OnInit, OnDestroy {
ngOnInit() {
this.tocService.tocList
.takeUntil(this.onDestroy)
- .subscribe((tocList: TocItem[]) => {
+ .subscribe(tocList => {
const count = tocList.length;
this.hasToc = count > 0;
@@ -47,6 +51,34 @@ export class TocComponent implements OnInit, OnDestroy {
});
}
+ ngAfterViewInit() {
+ if (!this.isEmbedded) {
+ this.tocService.activeItemIndex
+ .takeUntil(this.onDestroy)
+ .subscribe(index => this.activeIndex = index);
+
+ Observable.combineLatest(this.tocService.activeItemIndex, this.items.changes.startWith(this.items))
+ .takeUntil(this.onDestroy)
+ .subscribe(([index, items]) => {
+ if (index === null || index >= items.length) {
+ return;
+ }
+
+ const e = items.toArray()[index].nativeElement;
+ const p = e.offsetParent;
+
+ const eRect = e.getBoundingClientRect();
+ const pRect = p.getBoundingClientRect();
+
+ const isInViewport = (eRect.top >= pRect.top) && (eRect.bottom <= pRect.bottom);
+
+ if (!isInViewport) {
+ p.scrollTop += (eRect.top - pRect.top) - (p.clientHeight / 2);
+ }
+ });
+ }
+ }
+
ngOnDestroy() {
this.onDestroy.next();
}
diff --git a/aio/src/app/shared/scroll-spy.service.spec.ts b/aio/src/app/shared/scroll-spy.service.spec.ts
new file mode 100644
index 0000000000..062b6e08d4
--- /dev/null
+++ b/aio/src/app/shared/scroll-spy.service.spec.ts
@@ -0,0 +1,517 @@
+import { Injector, ReflectiveInjector } from '@angular/core';
+import { fakeAsync, tick } from '@angular/core/testing';
+import { DOCUMENT } from '@angular/platform-browser';
+
+import { ScrollService } from 'app/shared/scroll.service';
+import { ScrollItem, ScrollSpiedElement, ScrollSpiedElementGroup, ScrollSpyInfo, ScrollSpyService } from 'app/shared/scroll-spy.service';
+
+
+describe('ScrollSpiedElement', () => {
+ it('should expose the spied element and index', () => {
+ const elem = {} as Element;
+ const spiedElem = new ScrollSpiedElement(elem, 42);
+
+ expect(spiedElem.element).toBe(elem);
+ expect(spiedElem.index).toBe(42);
+ });
+
+ describe('#calculateTop()', () => {
+ it('should calculate the `top` value', () => {
+ const elem = {getBoundingClientRect: () => ({top: 100})} as Element;
+ const spiedElem = new ScrollSpiedElement(elem, 42);
+
+ spiedElem.calculateTop(0, 0);
+ expect(spiedElem.top).toBe(100);
+
+ spiedElem.calculateTop(20, 0);
+ expect(spiedElem.top).toBe(120);
+
+ spiedElem.calculateTop(0, 10);
+ expect(spiedElem.top).toBe(90);
+
+ spiedElem.calculateTop(20, 10);
+ expect(spiedElem.top).toBe(110);
+ });
+ });
+});
+
+
+describe('ScrollSpiedElementGroup', () => {
+ describe('#calibrate()', () => {
+ it('should calculate `top` for all spied elements', () => {
+ const spy = spyOn(ScrollSpiedElement.prototype, 'calculateTop').and.returnValue(0);
+ const elems = [{}, {}, {}] as Element[];
+ const group = new ScrollSpiedElementGroup(elems);
+
+ expect(spy).not.toHaveBeenCalled();
+
+ group.calibrate(20, 10);
+ const callInfo = spy.calls.all();
+
+ expect(spy).toHaveBeenCalledTimes(3);
+ expect(callInfo[0].object.index).toBe(0);
+ expect(callInfo[1].object.index).toBe(1);
+ expect(callInfo[2].object.index).toBe(2);
+ expect(callInfo[0].args).toEqual([20, 10]);
+ expect(callInfo[1].args).toEqual([20, 10]);
+ expect(callInfo[2].args).toEqual([20, 10]);
+ });
+ });
+
+ describe('#onScroll()', () => {
+ let group: ScrollSpiedElementGroup;
+ let activeItems: ScrollItem[];
+
+ const activeIndices = () => activeItems.map(x => x && x.index);
+
+ beforeEach(() => {
+ const tops = [50, 150, 100];
+
+ spyOn(ScrollSpiedElement.prototype, 'calculateTop').and.callFake(function(scrollTop, topOffset) {
+ this.top = tops[this.index];
+ });
+
+ activeItems = [];
+ group = new ScrollSpiedElementGroup([{}, {}, {}] as Element[]);
+ group.activeScrollItem.subscribe(item => activeItems.push(item));
+ group.calibrate(20, 10);
+ });
+
+
+ it('should emit a `ScrollItem` on `activeScrollItem`', () => {
+ expect(activeItems.length).toBe(0);
+
+ group.onScroll(20, 140);
+ expect(activeItems.length).toBe(1);
+
+ group.onScroll(20, 140);
+ expect(activeItems.length).toBe(2);
+ });
+
+ it('should emit the lower-most element that is above `scrollTop`', () => {
+ group.onScroll(45, 200);
+ group.onScroll(55, 200);
+ expect(activeIndices()).toEqual([null, 0]);
+
+ activeItems.length = 0;
+ group.onScroll(95, 200);
+ group.onScroll(105, 200);
+ expect(activeIndices()).toEqual([0, 2]);
+
+ activeItems.length = 0;
+ group.onScroll(145, 200);
+ group.onScroll(155, 200);
+ expect(activeIndices()).toEqual([2, 1]);
+
+ activeItems.length = 0;
+ group.onScroll(75, 200);
+ group.onScroll(175, 200);
+ group.onScroll(125, 200);
+ group.onScroll(25, 200);
+ expect(activeIndices()).toEqual([0, 1, 2, null]);
+ });
+
+ it('should always emit the lower-most element if scrolled to the bottom', () => {
+ group.onScroll(140, 140);
+ group.onScroll(145, 140);
+ group.onScroll(138.5, 140);
+ group.onScroll(139.5, 140);
+
+ expect(activeIndices()).toEqual([1, 1, 2, 1]);
+ });
+
+ it('should emit null if all elements are below `scrollTop`', () => {
+ group.onScroll(0, 140);
+ expect(activeItems).toEqual([null]);
+
+ group.onScroll(49, 140);
+ expect(activeItems).toEqual([null, null]);
+ });
+
+ it('should emit null if there are no spied elements (even if scrolled to the bottom)', () => {
+ group = new ScrollSpiedElementGroup([]);
+ group.activeScrollItem.subscribe(item => activeItems.push(item));
+
+ group.onScroll(20, 140);
+ expect(activeItems).toEqual([null]);
+
+ group.onScroll(140, 140);
+ expect(activeItems).toEqual([null, null]);
+
+ group.onScroll(145, 140);
+ expect(activeItems).toEqual([null, null, null]);
+ });
+ });
+});
+
+
+describe('ScrollSpyService', () => {
+ let injector: Injector;
+ let scrollSpyService: ScrollSpyService;
+
+ beforeEach(() => {
+ injector = ReflectiveInjector.resolveAndCreate([
+ { provide: DOCUMENT, useValue: { body: {} } },
+ { provide: ScrollService, useValue: { topOffset: 50 } },
+ ScrollSpyService
+ ]);
+
+ scrollSpyService = injector.get(ScrollSpyService);
+ });
+
+
+ it('should be creatable', () => {
+ expect(scrollSpyService).toBeTruthy();
+ });
+
+ describe('#spyOn()', () => {
+ let getSpiedElemGroups: () => ScrollSpiedElementGroup[];
+
+ beforeEach(() => {
+ getSpiedElemGroups = () => (scrollSpyService as any).spiedElementGroups;
+ });
+
+
+ it('should create a `ScrollSpiedElementGroup` when called', () => {
+ expect(getSpiedElemGroups().length).toBe(0);
+
+ scrollSpyService.spyOn([]);
+ expect(getSpiedElemGroups().length).toBe(1);
+ });
+
+ it('should initialize the newly created `ScrollSpiedElementGroup`', () => {
+ const calibrateSpy = spyOn(ScrollSpiedElementGroup.prototype, 'calibrate');
+ const onScrollSpy = spyOn(ScrollSpiedElementGroup.prototype, 'onScroll');
+
+ scrollSpyService.spyOn([]);
+ expect(calibrateSpy).toHaveBeenCalledTimes(1);
+ expect(onScrollSpy).toHaveBeenCalledTimes(1);
+
+ scrollSpyService.spyOn([]);
+ expect(calibrateSpy).toHaveBeenCalledTimes(2);
+ expect(onScrollSpy).toHaveBeenCalledTimes(2);
+ });
+
+ it('should call `onResize()` if it is the first `ScrollSpiedElementGroup`', () => {
+ const actions: string[] = [];
+
+ const onResizeSpy = spyOn(ScrollSpyService.prototype as any, 'onResize')
+ .and.callFake(() => actions.push('onResize'));
+ const calibrateSpy = spyOn(ScrollSpiedElementGroup.prototype, 'calibrate')
+ .and.callFake(() => actions.push('calibrate'));
+
+ expect(onResizeSpy).not.toHaveBeenCalled();
+
+ scrollSpyService.spyOn([]);
+ expect(actions).toEqual(['onResize', 'calibrate']);
+
+ scrollSpyService.spyOn([]);
+ expect(actions).toEqual(['onResize', 'calibrate', 'calibrate']);
+ });
+
+ it('should forward `ScrollSpiedElementGroup#activeScrollItem` as `active`', () => {
+ const activeIndices1: (number | null)[] = [];
+ const activeIndices2: (number | null)[] = [];
+
+ const info1 = scrollSpyService.spyOn([]);
+ const info2 = scrollSpyService.spyOn([]);
+ const spiedElemGroups = getSpiedElemGroups();
+
+ info1.active.subscribe(item => activeIndices1.push(item && item.index));
+ info2.active.subscribe(item => activeIndices2.push(item && item.index));
+ activeIndices1.length = 0;
+ activeIndices2.length = 0;
+
+ spiedElemGroups[0].activeScrollItem.next({index: 1} as ScrollItem);
+ spiedElemGroups[0].activeScrollItem.next({index: 2} as ScrollItem);
+ spiedElemGroups[1].activeScrollItem.next({index: 3} as ScrollItem);
+ spiedElemGroups[0].activeScrollItem.next(null);
+ spiedElemGroups[1].activeScrollItem.next({index: 4} as ScrollItem);
+ spiedElemGroups[1].activeScrollItem.next(null);
+ spiedElemGroups[0].activeScrollItem.next({index: 5} as ScrollItem);
+ spiedElemGroups[1].activeScrollItem.next({index: 6} as ScrollItem);
+
+ expect(activeIndices1).toEqual([1, 2, null, 5]);
+ expect(activeIndices2).toEqual([3, 4, null, 6]);
+ });
+
+ it('should remember and emit the last active item to new subscribers', () => {
+ const items = [{index: 1}, {index: 2}, {index: 3}] as ScrollItem[];
+ let lastActiveItem: ScrollItem | null;
+
+ const info = scrollSpyService.spyOn([]);
+ const spiedElemGroup = getSpiedElemGroups()[0];
+
+ spiedElemGroup.activeScrollItem.next(items[0]);
+ spiedElemGroup.activeScrollItem.next(items[1]);
+ spiedElemGroup.activeScrollItem.next(items[2]);
+ spiedElemGroup.activeScrollItem.next(null);
+ spiedElemGroup.activeScrollItem.next(items[1]);
+ info.active.subscribe(item => lastActiveItem = item);
+
+ expect(lastActiveItem).toBe(items[1]);
+
+ spiedElemGroup.activeScrollItem.next(null);
+ info.active.subscribe(item => lastActiveItem = item);
+
+ expect(lastActiveItem).toBeNull();
+ });
+
+ it('should only emit distinct values on `active`', () => {
+ const items = [{index: 1}, {index: 2}] as ScrollItem[];
+ const activeIndices: (number | null)[] = [];
+
+ const info = scrollSpyService.spyOn([]);
+ const spiedElemGroup = getSpiedElemGroups()[0];
+
+ info.active.subscribe(item => activeIndices.push(item && item.index));
+ activeIndices.length = 0;
+
+ spiedElemGroup.activeScrollItem.next(items[0]);
+ spiedElemGroup.activeScrollItem.next(items[0]);
+ spiedElemGroup.activeScrollItem.next(items[1]);
+ spiedElemGroup.activeScrollItem.next(items[1]);
+ spiedElemGroup.activeScrollItem.next(null);
+ spiedElemGroup.activeScrollItem.next(null);
+ spiedElemGroup.activeScrollItem.next(items[0]);
+ spiedElemGroup.activeScrollItem.next(items[1]);
+ spiedElemGroup.activeScrollItem.next(null);
+
+ expect(activeIndices).toEqual([1, 2, null, 1, 2, null]);
+ });
+
+ it('should remove the corresponding `ScrollSpiedElementGroup` when calling `unspy()`', () => {
+ const info1 = scrollSpyService.spyOn([]);
+ const info2 = scrollSpyService.spyOn([]);
+ const info3 = scrollSpyService.spyOn([]);
+ const groups = getSpiedElemGroups().slice();
+
+ expect(getSpiedElemGroups()).toEqual(groups);
+
+ info2.unspy();
+ expect(getSpiedElemGroups()).toEqual([groups[0], groups[2]]);
+
+ info1.unspy();
+ expect(getSpiedElemGroups()).toEqual([groups[2]]);
+
+ info3.unspy();
+ expect(getSpiedElemGroups()).toEqual([]);
+ });
+ });
+
+ describe('window resize events', () => {
+ let onResizeSpy: jasmine.Spy;
+
+ beforeEach(() => {
+ onResizeSpy = spyOn(ScrollSpyService.prototype as any, 'onResize');
+ });
+
+
+ it('should be subscribed to when the first group of elements is spied on', fakeAsync(() => {
+ window.dispatchEvent(new Event('resize'));
+ expect(onResizeSpy).not.toHaveBeenCalled();
+
+ scrollSpyService.spyOn([]);
+ onResizeSpy.calls.reset();
+
+ window.dispatchEvent(new Event('resize'));
+ expect(onResizeSpy).not.toHaveBeenCalled();
+
+ tick(300);
+ expect(onResizeSpy).toHaveBeenCalled();
+ }));
+
+ it('should be unsubscribed from when the last group of elements is removed', fakeAsync(() => {
+ const info1 = scrollSpyService.spyOn([]);
+ const info2 = scrollSpyService.spyOn([]);
+ onResizeSpy.calls.reset();
+
+ window.dispatchEvent(new Event('resize'));
+ tick(300);
+ expect(onResizeSpy).toHaveBeenCalled();
+
+ info1.unspy();
+ onResizeSpy.calls.reset();
+
+ window.dispatchEvent(new Event('resize'));
+ tick(300);
+ expect(onResizeSpy).toHaveBeenCalled();
+
+ info2.unspy();
+ onResizeSpy.calls.reset();
+
+ window.dispatchEvent(new Event('resize'));
+ tick(300);
+ expect(onResizeSpy).not.toHaveBeenCalled();
+ }));
+
+ it('should only fire every 300ms', fakeAsync(() => {
+ scrollSpyService.spyOn([]);
+ onResizeSpy.calls.reset();
+
+ window.dispatchEvent(new Event('resize'));
+ tick(100);
+ expect(onResizeSpy).not.toHaveBeenCalled();
+
+ window.dispatchEvent(new Event('resize'));
+ tick(100);
+ expect(onResizeSpy).not.toHaveBeenCalled();
+
+ window.dispatchEvent(new Event('resize'));
+ tick(100);
+ expect(onResizeSpy).toHaveBeenCalledTimes(1);
+
+ onResizeSpy.calls.reset();
+ tick(150);
+
+ window.dispatchEvent(new Event('resize'));
+ tick(100);
+ expect(onResizeSpy).not.toHaveBeenCalled();
+
+ window.dispatchEvent(new Event('resize'));
+ tick(100);
+ expect(onResizeSpy).not.toHaveBeenCalled();
+
+ window.dispatchEvent(new Event('resize'));
+ tick(100);
+ expect(onResizeSpy).toHaveBeenCalledTimes(1);
+ }));
+ });
+
+ describe('window scroll events', () => {
+ let onScrollSpy: jasmine.Spy;
+
+ beforeEach(() => {
+ onScrollSpy = spyOn(ScrollSpyService.prototype as any, 'onScroll');
+ });
+
+
+ it('should be subscribed to when the first group of elements is spied on', fakeAsync(() => {
+ window.dispatchEvent(new Event('scroll'));
+ expect(onScrollSpy).not.toHaveBeenCalled();
+
+ scrollSpyService.spyOn([]);
+
+ window.dispatchEvent(new Event('scroll'));
+ expect(onScrollSpy).not.toHaveBeenCalled();
+
+ tick(300);
+ expect(onScrollSpy).toHaveBeenCalled();
+ }));
+
+ it('should be unsubscribed from when the last group of elements is removed', fakeAsync(() => {
+ const info1 = scrollSpyService.spyOn([]);
+ const info2 = scrollSpyService.spyOn([]);
+
+ window.dispatchEvent(new Event('scroll'));
+ tick(300);
+ expect(onScrollSpy).toHaveBeenCalled();
+
+ info1.unspy();
+ onScrollSpy.calls.reset();
+
+ window.dispatchEvent(new Event('scroll'));
+ tick(300);
+ expect(onScrollSpy).toHaveBeenCalled();
+
+ info2.unspy();
+ onScrollSpy.calls.reset();
+
+ window.dispatchEvent(new Event('scroll'));
+ tick(300);
+ expect(onScrollSpy).not.toHaveBeenCalled();
+ }));
+
+ it('should only fire every 300ms', fakeAsync(() => {
+ scrollSpyService.spyOn([]);
+
+ window.dispatchEvent(new Event('scroll'));
+ tick(100);
+ expect(onScrollSpy).not.toHaveBeenCalled();
+
+ window.dispatchEvent(new Event('scroll'));
+ tick(100);
+ expect(onScrollSpy).not.toHaveBeenCalled();
+
+ window.dispatchEvent(new Event('scroll'));
+ tick(100);
+ expect(onScrollSpy).toHaveBeenCalledTimes(1);
+
+ onScrollSpy.calls.reset();
+ tick(150);
+
+ window.dispatchEvent(new Event('scroll'));
+ tick(100);
+ expect(onScrollSpy).not.toHaveBeenCalled();
+
+ window.dispatchEvent(new Event('scroll'));
+ tick(100);
+ expect(onScrollSpy).not.toHaveBeenCalled();
+
+ window.dispatchEvent(new Event('scroll'));
+ tick(100);
+ expect(onScrollSpy).toHaveBeenCalledTimes(1);
+ }));
+ });
+
+ describe('#onResize()', () => {
+ it('should re-calibrate each `ScrollSpiedElementGroup`', () => {
+ scrollSpyService.spyOn([]);
+ scrollSpyService.spyOn([]);
+ scrollSpyService.spyOn([]);
+
+ const spiedElemGroups: ScrollSpiedElementGroup[] = (scrollSpyService as any).spiedElementGroups;
+ const calibrateSpies = spiedElemGroups.map(group => spyOn(group, 'calibrate'));
+
+ calibrateSpies.forEach(spy => expect(spy).not.toHaveBeenCalled());
+
+ (scrollSpyService as any).onResize();
+ calibrateSpies.forEach(spy => expect(spy).toHaveBeenCalled());
+ });
+ });
+
+ describe('#onScroll()', () => {
+ it('should propagate to each `ScrollSpiedElementGroup`', () => {
+ scrollSpyService.spyOn([]);
+ scrollSpyService.spyOn([]);
+ scrollSpyService.spyOn([]);
+
+ const spiedElemGroups: ScrollSpiedElementGroup[] = (scrollSpyService as any).spiedElementGroups;
+ const onScrollSpies = spiedElemGroups.map(group => spyOn(group, 'onScroll'));
+
+ onScrollSpies.forEach(spy => expect(spy).not.toHaveBeenCalled());
+
+ (scrollSpyService as any).onScroll();
+ onScrollSpies.forEach(spy => expect(spy).toHaveBeenCalled());
+ });
+
+ it('should first re-calibrate if the content height has changed', () => {
+ const body = injector.get(DOCUMENT).body as any;
+
+ scrollSpyService.spyOn([]);
+ scrollSpyService.spyOn([]);
+ scrollSpyService.spyOn([]);
+
+ const spiedElemGroups: ScrollSpiedElementGroup[] = (scrollSpyService as any).spiedElementGroups;
+ const onScrollSpies = spiedElemGroups.map(group => spyOn(group, 'onScroll'));
+ const calibrateSpies = spiedElemGroups.map((group, i) => spyOn(group, 'calibrate')
+ .and.callFake(() => expect(onScrollSpies[i]).not.toHaveBeenCalled()));
+
+ calibrateSpies.forEach(spy => expect(spy).not.toHaveBeenCalled());
+ onScrollSpies.forEach(spy => expect(spy).not.toHaveBeenCalled());
+
+ // No content height change...
+ (scrollSpyService as any).onScroll();
+ calibrateSpies.forEach(spy => expect(spy).not.toHaveBeenCalled());
+ onScrollSpies.forEach(spy => expect(spy).toHaveBeenCalled());
+
+ onScrollSpies.forEach(spy => spy.calls.reset());
+ body.scrollHeight = 100;
+
+ // Viewport changed...
+ (scrollSpyService as any).onScroll();
+ calibrateSpies.forEach(spy => expect(spy).toHaveBeenCalled());
+ onScrollSpies.forEach(spy => expect(spy).toHaveBeenCalled());
+ });
+ });
+});
diff --git a/aio/src/app/shared/scroll-spy.service.ts b/aio/src/app/shared/scroll-spy.service.ts
new file mode 100644
index 0000000000..ef6cc30bcf
--- /dev/null
+++ b/aio/src/app/shared/scroll-spy.service.ts
@@ -0,0 +1,233 @@
+import { Inject, Injectable } from '@angular/core';
+import { DOCUMENT } from '@angular/platform-browser';
+import { Observable } from 'rxjs/Observable';
+import { ReplaySubject } from 'rxjs/ReplaySubject';
+import { Subject } from 'rxjs/Subject';
+import 'rxjs/add/observable/fromEvent';
+import 'rxjs/add/operator/auditTime';
+import 'rxjs/add/operator/distinctUntilChanged';
+import 'rxjs/add/operator/takeUntil';
+
+import { ScrollService } from 'app/shared/scroll.service';
+
+
+export interface ScrollItem {
+ element: Element;
+ index: number;
+}
+
+export interface ScrollSpyInfo {
+ active: Observable;
+ unspy: () => void;
+}
+
+/*
+ * Represents a "scroll-spied" element. Contains info and methods for determining whether this
+ * element is the active one (i.e. whether it has been scrolled passed), based on the window's
+ * scroll position.
+ *
+ * @prop {Element} element - The element whose position relative to the viewport is tracked.
+ * @prop {number} index - The index of the element in the original list of element (group).
+ * @prop {number} top - The `scrollTop` value at which this element becomes active.
+ */
+export class ScrollSpiedElement implements ScrollItem {
+ top = 0;
+
+ /*
+ * @constructor
+ * @param {Element} element - The element whose position relative to the viewport is tracked.
+ * @param {number} index - The index of the element in the original list of element (group).
+ */
+ constructor(public readonly element: Element, public readonly index: number) {}
+
+ /*
+ * @method
+ * Caclulate the `top` value, i.e. the value of the `scrollTop` property at which this element
+ * becomes active. The current implementation assumes that window is the scroll-container.
+ *
+ * @param {number} scrollTop - How much is window currently scrolled (vertically).
+ * @param {number} topOffset - The distance from the top at which the element becomes active.
+ */
+ calculateTop(scrollTop: number, topOffset: number) {
+ this.top = scrollTop + this.element.getBoundingClientRect().top - topOffset;
+ }
+}
+
+/*
+ * Represents a group of "scroll-spied" elements. Contains info and methods for efficiently
+ * determining which element should be considered "active", i.e. which element has been scrolled
+ * passed the top of the viewport.
+ *
+ * @prop {Observable} activeScrollItem - An observable that emits ScrollItem
+ * elements (containing the HTML element and its original index) identifying the latest "active"
+ * element from a list of elements.
+ */
+export class ScrollSpiedElementGroup {
+ activeScrollItem: ReplaySubject = new ReplaySubject(1);
+ private spiedElements: ScrollSpiedElement[];
+
+ /*
+ * @constructor
+ * @param {Element[]} elements - A list of elements whose position relative to the viewport will
+ * be tracked, in order to determine which one is "active" at any given moment.
+ */
+ constructor(elements: Element[]) {
+ this.spiedElements = elements.map((elem, i) => new ScrollSpiedElement(elem, i));
+ }
+
+ /*
+ * @method
+ * Caclulate the `top` value of each ScrollSpiedElement of this group (based on te current
+ * `scrollTop` and `topOffset` values), so that the active element can be later determined just by
+ * comparing its `top` property with the then current `scrollTop`.
+ *
+ * @param {number} scrollTop - How much is window currently scrolled (vertically).
+ * @param {number} topOffset - The distance from the top at which the element becomes active.
+ */
+ calibrate(scrollTop: number, topOffset: number) {
+ this.spiedElements.forEach(spiedElem => spiedElem.calculateTop(scrollTop, topOffset));
+ this.spiedElements.sort((a, b) => b.top - a.top); // Sort in descending `top` order.
+ }
+
+ /*
+ * @method
+ * Determine which element is the currently active one, i.e. the lower-most element that is
+ * scrolled passed the top of the viewport (taking offsets into account) and emit it on
+ * `activeScrollItem`.
+ * If no element can be considered active, `null` is emitted instead.
+ * If window is scrolled all the way to the bottom, then the lower-most element is considered
+ * active even if it not scrolled passed the top of the viewport.
+ *
+ * @param {number} scrollTop - How much is window currently scrolled (vertically).
+ * @param {number} maxScrollTop - The maximum possible `scrollTop` (based on the viewport size).
+ */
+ onScroll(scrollTop: number, maxScrollTop: number) {
+ let activeItem: ScrollItem;
+
+ if (scrollTop + 1 >= maxScrollTop) {
+ activeItem = this.spiedElements[0];
+ } else {
+ this.spiedElements.some(spiedElem => {
+ if (spiedElem.top <= scrollTop) {
+ activeItem = spiedElem;
+ return true;
+ }
+ });
+ }
+
+ this.activeScrollItem.next(activeItem || null);
+ }
+}
+
+@Injectable()
+export class ScrollSpyService {
+ private spiedElementGroups: ScrollSpiedElementGroup[] = [];
+ private onStopListening = new Subject();
+ private resizeEvents = Observable.fromEvent(window, 'resize').auditTime(300).takeUntil(this.onStopListening);
+ private scrollEvents = Observable.fromEvent(window, 'scroll').auditTime(300).takeUntil(this.onStopListening);
+ private lastContentHeight: number;
+ private lastMaxScrollTop: number;
+
+ constructor(@Inject(DOCUMENT) private doc: any, private scrollService: ScrollService) {}
+
+ /*
+ * @method
+ * Start tracking a group of elements and emitting active elements; i.e. elements that are
+ * currently visible in the viewport. If there was no other group being spied, start listening for
+ * `resize` and `scroll` events.
+ *
+ * @param {Element[]} elements - A list of elements to track.
+ *
+ * @return {ScrollSpyInfo} - An object containing the following properties:
+ * - `active`: An observable of distinct ScrollItems.
+ * - `unspy`: A method to stop tracking this group of elements.
+ */
+ spyOn(elements: Element[]): ScrollSpyInfo {
+ if (!this.spiedElementGroups.length) {
+ this.resizeEvents.subscribe(() => this.onResize());
+ this.scrollEvents.subscribe(() => this.onScroll());
+ this.onResize();
+ }
+
+ const scrollTop = this.getScrollTop();
+ const topOffset = this.getTopOffset();
+ const maxScrollTop = this.lastMaxScrollTop;
+
+ const spiedGroup = new ScrollSpiedElementGroup(elements);
+ spiedGroup.calibrate(scrollTop, topOffset);
+ spiedGroup.onScroll(scrollTop, maxScrollTop);
+
+ this.spiedElementGroups.push(spiedGroup);
+
+ return {
+ active: spiedGroup.activeScrollItem.asObservable().distinctUntilChanged(),
+ unspy: () => this.unspy(spiedGroup)
+ };
+ }
+
+ private getContentHeight() {
+ return this.doc.body.scrollHeight || Number.MAX_SAFE_INTEGER;
+ }
+
+ private getScrollTop() {
+ return window && window.pageYOffset || 0;
+ }
+
+ private getTopOffset() {
+ return this.scrollService.topOffset + 50;
+ }
+
+ private getViewportHeight() {
+ return this.doc.body.clientHeight || 0;
+ }
+
+ /*
+ * @method
+ * The size of the window has changed. Re-calculate all affected values,
+ * so that active elements can be determined efficiently on scroll.
+ */
+ private onResize() {
+ const contentHeight = this.getContentHeight();
+ const viewportHeight = this.getViewportHeight();
+ const scrollTop = this.getScrollTop();
+ const topOffset = this.getTopOffset();
+
+ this.lastContentHeight = contentHeight;
+ this.lastMaxScrollTop = contentHeight - viewportHeight;
+
+ this.spiedElementGroups.forEach(group => group.calibrate(scrollTop, topOffset));
+ }
+
+ /*
+ * @method
+ * Determine which element for each ScrollSpiedElementGroup is active. If the content height has
+ * changed since last check, re-calculate all affected values first.
+ */
+ private onScroll() {
+ if (this.lastContentHeight !== this.getContentHeight()) {
+ // Something has caused the scroll height to change.
+ // (E.g. image downloaded, accordion expanded/collapsed etc.)
+ this.onResize();
+ }
+
+ const scrollTop = this.getScrollTop();
+ const maxScrollTop = this.lastMaxScrollTop;
+ this.spiedElementGroups.forEach(group => group.onScroll(scrollTop, maxScrollTop));
+ }
+
+ /*
+ * @method
+ * Stop tracking this group of elements and emitting active elements. If there is no other group
+ * being spied, stop listening for `resize` or `scroll` events.
+ *
+ * @param {ScrollSpiedElementGroup} spiedGroup - The group to stop tracking.
+ */
+ private unspy(spiedGroup: ScrollSpiedElementGroup) {
+ spiedGroup.activeScrollItem.complete();
+ this.spiedElementGroups = this.spiedElementGroups.filter(group => group !== spiedGroup);
+
+ if (!this.spiedElementGroups.length) {
+ this.onStopListening.next();
+ }
+ }
+}
diff --git a/aio/src/app/shared/scroll.service.ts b/aio/src/app/shared/scroll.service.ts
index 05b216b643..0b37feaeaf 100644
--- a/aio/src/app/shared/scroll.service.ts
+++ b/aio/src/app/shared/scroll.service.ts
@@ -12,6 +12,22 @@ export class ScrollService {
private _topOffset: number;
private _topOfPageElement: Element;
+ /** Offset from the top of the document to bottom of toolbar + some margin */
+ get topOffset() {
+ if (!this._topOffset) {
+ const toolbar = document.querySelector('md-toolbar.app-toolbar');
+ this._topOffset = (toolbar ? toolbar.clientHeight : 0) + topMargin;
+ }
+ return this._topOffset;
+ }
+
+ private get topOfPageElement() {
+ if (!this._topOfPageElement) {
+ this._topOfPageElement = this.document.getElementById('top-of-page') || this.document.body;
+ }
+ return this._topOfPageElement;
+ }
+
constructor(
@Inject(DOCUMENT) private document: any,
private location: PlatformLocation) { }
@@ -51,20 +67,4 @@ export class ScrollService {
private getCurrentHash() {
return this.location.hash.replace(/^#/, '');
}
-
- /** Offset from the top of the document to bottom of toolbar + some margin */
- private get topOffset() {
- if (!this._topOffset) {
- const toolbar = document.querySelector('md-toolbar.app-toolbar');
- this._topOffset = (toolbar ? toolbar.clientHeight : 0) + topMargin;
- }
- return this._topOffset;
- }
-
- private get topOfPageElement() {
- if (!this._topOfPageElement) {
- this._topOfPageElement = this.document.getElementById('top-of-page') || this.document.body;
- }
- return this._topOfPageElement;
- }
}
diff --git a/aio/src/app/shared/toc.service.spec.ts b/aio/src/app/shared/toc.service.spec.ts
index 15b3217319..e9a1d7586e 100644
--- a/aio/src/app/shared/toc.service.spec.ts
+++ b/aio/src/app/shared/toc.service.spec.ts
@@ -1,10 +1,13 @@
import { ReflectiveInjector, SecurityContext } from '@angular/core';
import { DOCUMENT, DomSanitizer, SafeHtml } from '@angular/platform-browser';
+import { Subject } from 'rxjs/Subject';
+import { ScrollItem, ScrollSpyInfo, ScrollSpyService } from 'app/shared/scroll-spy.service';
import { TocItem, TocService } from './toc.service';
describe('TocService', () => {
let injector: ReflectiveInjector;
+ let scrollSpyService: MockScrollSpyService;
let tocService: TocService;
let lastTocList: TocItem[];
@@ -20,8 +23,10 @@ describe('TocService', () => {
injector = ReflectiveInjector.resolveAndCreate([
{ provide: DomSanitizer, useClass: TestDomSanitizer },
{ provide: DOCUMENT, useValue: document },
+ { provide: ScrollSpyService, useClass: MockScrollSpyService },
TocService,
]);
+ scrollSpyService = injector.get(ScrollSpyService);
tocService = injector.get(TocService);
tocService.tocList.subscribe(tocList => lastTocList = tocList);
});
@@ -62,6 +67,89 @@ describe('TocService', () => {
});
});
+ describe('activeItemIndex', () => {
+ it('should emit the active heading index (or null)', () => {
+ const indices: (number | null)[] = [];
+
+ tocService.activeItemIndex.subscribe(i => indices.push(i));
+ callGenToc();
+
+ scrollSpyService.$lastInfo.active.next({index: 42} as ScrollItem);
+ scrollSpyService.$lastInfo.active.next({index: 0} as ScrollItem);
+ scrollSpyService.$lastInfo.active.next(null);
+ scrollSpyService.$lastInfo.active.next({index: 7} as ScrollItem);
+
+ expect(indices).toEqual([null, 42, 0, null, 7]);
+ });
+
+ it('should reset athe active index (and unspy) when calling `reset()`', () => {
+ const indices: (number | null)[] = [];
+
+ tocService.activeItemIndex.subscribe(i => indices.push(i));
+
+ callGenToc();
+ const unspy = scrollSpyService.$lastInfo.unspy;
+ scrollSpyService.$lastInfo.active.next({index: 42} as ScrollItem);
+
+ expect(unspy).not.toHaveBeenCalled();
+ expect(indices).toEqual([null, 42]);
+
+ tocService.reset();
+
+ expect(unspy).toHaveBeenCalled();
+ expect(indices).toEqual([null, 42, null]);
+ });
+
+ it('should reset the active index (and unspy) when a new `tocList` is requested', () => {
+ const indices: (number | null)[] = [];
+
+ tocService.activeItemIndex.subscribe(i => indices.push(i));
+
+ callGenToc();
+ const unspy1 = scrollSpyService.$lastInfo.unspy;
+ scrollSpyService.$lastInfo.active.next({index: 1} as ScrollItem);
+
+ expect(unspy1).not.toHaveBeenCalled();
+ expect(indices).toEqual([null, 1]);
+
+ tocService.genToc();
+
+ expect(unspy1).toHaveBeenCalled();
+ expect(indices).toEqual([null, 1, null]);
+
+ callGenToc();
+ const unspy2 = scrollSpyService.$lastInfo.unspy;
+ scrollSpyService.$lastInfo.active.next({index: 3} as ScrollItem);
+
+ expect(unspy2).not.toHaveBeenCalled();
+ expect(indices).toEqual([null, 1, null, null, 3]);
+
+ callGenToc();
+ scrollSpyService.$lastInfo.active.next({index: 4} as ScrollItem);
+
+ expect(unspy2).toHaveBeenCalled();
+ expect(indices).toEqual([null, 1, null, null, 3, null, 4]);
+ });
+
+ it('should emit the active index for the latest `tocList`', () => {
+ const indices: (number | null)[] = [];
+
+ tocService.activeItemIndex.subscribe(i => indices.push(i));
+
+ callGenToc();
+ const activeSubject1 = scrollSpyService.$lastInfo.active;
+ activeSubject1.next({index: 1} as ScrollItem);
+ activeSubject1.next({index: 2} as ScrollItem);
+
+ callGenToc();
+ const activeSubject2 = scrollSpyService.$lastInfo.active;
+ activeSubject2.next({index: 3} as ScrollItem);
+ activeSubject2.next({index: 4} as ScrollItem);
+
+ expect(indices).toEqual([null, 1, 2, null, 3, 4]);
+ });
+ });
+
describe('should clear tocList', () => {
beforeEach(() => {
// Start w/ dummy data from previous usage
@@ -260,3 +348,18 @@ class TestDomSanitizer {
} as TestSafeHtml;
});
}
+
+class MockScrollSpyService {
+ $lastInfo: {
+ active: Subject,
+ unspy: jasmine.Spy
+ };
+
+ spyOn(headings: HTMLHeadingElement[]): ScrollSpyInfo {
+ return this.$lastInfo = {
+ active: new Subject(),
+ unspy: jasmine.createSpy('unspy'),
+ };
+ }
+}
+
diff --git a/aio/src/app/shared/toc.service.ts b/aio/src/app/shared/toc.service.ts
index c44907ecc3..3b923d94b3 100644
--- a/aio/src/app/shared/toc.service.ts
+++ b/aio/src/app/shared/toc.service.ts
@@ -2,6 +2,9 @@ import { Inject, Injectable } from '@angular/core';
import { DOCUMENT, DomSanitizer, SafeHtml } from '@angular/platform-browser';
import { ReplaySubject } from 'rxjs/ReplaySubject';
+import { ScrollSpyInfo, ScrollSpyService } from 'app/shared/scroll-spy.service';
+
+
export interface TocItem {
content: SafeHtml;
href: string;
@@ -13,38 +16,39 @@ export interface TocItem {
@Injectable()
export class TocService {
tocList = new ReplaySubject(1);
+ activeItemIndex = new ReplaySubject(1);
+ private scrollSpyInfo: ScrollSpyInfo | null;
- constructor(@Inject(DOCUMENT) private document: any, private domSanitizer: DomSanitizer) { }
+ constructor(
+ @Inject(DOCUMENT) private document: any,
+ private domSanitizer: DomSanitizer,
+ private scrollSpyService: ScrollSpyService) { }
- genToc(docElement: Element, docId = '') {
- const tocList = [];
+ genToc(docElement?: Element, docId = '') {
+ this.resetScrollSpyInfo();
- if (docElement) {
- const headings = docElement.querySelectorAll('h2,h3');
- const idMap = new Map();
-
- for (let i = 0; i < headings.length; i++) {
- const heading = headings[i] as HTMLHeadingElement;
-
- // skip if heading class is 'no-toc'
- if (/(no-toc|notoc)/i.test(heading.className)) { continue; }
-
- const id = this.getId(heading, idMap);
- const toc: TocItem = {
- content: this.extractHeadingSafeHtml(heading),
- href: `${docId}#${id}`,
- level: heading.tagName.toLowerCase(),
- title: heading.innerText.trim(),
- };
-
- tocList.push(toc);
- }
+ if (!docElement) {
+ this.tocList.next([]);
+ return;
}
+ const headings = this.findTocHeadings(docElement);
+ const idMap = new Map();
+ const tocList = headings.map(heading => ({
+ content: this.extractHeadingSafeHtml(heading),
+ href: `${docId}#${this.getId(heading, idMap)}`,
+ level: heading.tagName.toLowerCase(),
+ title: heading.innerText.trim(),
+ }));
+
this.tocList.next(tocList);
+
+ this.scrollSpyInfo = this.scrollSpyService.spyOn(headings);
+ this.scrollSpyInfo.active.subscribe(item => this.activeItemIndex.next(item && item.index));
}
reset() {
+ this.resetScrollSpyInfo();
this.tocList.next([]);
}
@@ -61,6 +65,22 @@ export class TocService {
return this.domSanitizer.bypassSecurityTrustHtml(a.innerHTML.trim());
}
+ private findTocHeadings(docElement: Element): HTMLHeadingElement[] {
+ const headings = docElement.querySelectorAll('h2,h3');
+ const skipNoTocHeadings = (heading: HTMLHeadingElement) => !/(?:no-toc|notoc)/i.test(heading.className);
+
+ return Array.prototype.filter.call(headings, skipNoTocHeadings);
+ }
+
+ private resetScrollSpyInfo() {
+ if (this.scrollSpyInfo) {
+ this.scrollSpyInfo.unspy();
+ this.scrollSpyInfo = null;
+ }
+
+ this.activeItemIndex.next(null);
+ }
+
// Extract the id from the heading; create one if necessary
// Is it possible for a heading to lack an id?
private getId(h: HTMLHeadingElement, idMap: Map) {
diff --git a/aio/src/styles/2-modules/_toc.scss b/aio/src/styles/2-modules/_toc.scss
index ecbafd8d13..da96a76372 100644
--- a/aio/src/styles/2-modules/_toc.scss
+++ b/aio/src/styles/2-modules/_toc.scss
@@ -1,16 +1,16 @@
.toc-container {
- width: 18%;
- position: fixed;
- top: 96px;
- right: 0;
- bottom: 32px;
- overflow-y: auto;
- overflow-x: hidden;
+ width: 18%;
+ position: fixed;
+ top: 96px;
+ right: 0;
+ bottom: 32px;
+ overflow-y: auto;
+ overflow-x: hidden;
- @media (max-width: 800px) {
- display: none;
- width: 0;
- }
+ @media (max-width: 800px) {
+ display: none;
+ width: 0;
+ }
}
aio-toc {
@@ -143,6 +143,12 @@ aio-toc > div {
color: $accentblue;
}
}
+
+ &.active {
+ a {
+ color: $accentblue;
+ }
+ }
}
ul.toc-list li.h3 {
@@ -151,5 +157,5 @@ aio-toc > div {
}
aio-toc.embedded > div.collapsed li.secondary {
- display: none;
+ display: none;
}