feat(aio): implement ScrollSpy service (to highlight the active section in ToC)

This commit is contained in:
Georgios Kalpakas 2017-05-08 14:33:56 +03:00 committed by Pete Bacon Darwin
parent 3d382dc750
commit c8b08f3a59
10 changed files with 1126 additions and 56 deletions

View File

@ -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

View File

@ -14,7 +14,8 @@
</button>
<ul class="toc-list">
<li *ngFor="let toc of tocList" title="{{toc.title}}" class="{{toc.level}}" [class.secondary]="toc.isSecondary">
<li #tocItem *ngFor="let toc of tocList; let i = index" title="{{toc.title}}"
class="{{toc.level}}" [class.secondary]="toc.isSecondary" [class.active]="i === activeIndex">
<a [href]="toc.href" [innerHTML]="toc.content"></a>
</li>
</ul>

View File

@ -227,7 +227,7 @@ describe('TocComponent', () => {
});
});
describe('when in side panel (not embedded))', () => {
describe('when in side panel (not embedded)', () => {
let fixture: ComponentFixture<HostNotEmbeddedTocComponent>;
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<TocItem[]>(getTestTocList());
activeItemIndex = new BehaviorSubject<number | null>(null);
}
// tslint:disable:quotemark

View File

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

View File

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

View File

@ -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<ScrollItem | null>;
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<ScrollItem | null>} 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<ScrollItem | null> = 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();
}
}
}

View File

@ -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;
}
}

View File

@ -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<ScrollItem | null>,
unspy: jasmine.Spy
};
spyOn(headings: HTMLHeadingElement[]): ScrollSpyInfo {
return this.$lastInfo = {
active: new Subject<ScrollItem | null>(),
unspy: jasmine.createSpy('unspy'),
};
}
}

View File

@ -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<TocItem[]>(1);
activeItemIndex = new ReplaySubject<number | null>(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<string, number>();
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<string, number>();
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<string, number>) {

View File

@ -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;
}