feat(aio): implement ScrollSpy service (to highlight the active section in ToC)
This commit is contained in:
parent
3d382dc750
commit
c8b08f3a59
@ -31,6 +31,7 @@ import { FooterComponent } from 'app/layout/footer/footer.component';
|
|||||||
import { NavMenuComponent } from 'app/layout/nav-menu/nav-menu.component';
|
import { NavMenuComponent } from 'app/layout/nav-menu/nav-menu.component';
|
||||||
import { NavItemComponent } from 'app/layout/nav-item/nav-item.component';
|
import { NavItemComponent } from 'app/layout/nav-item/nav-item.component';
|
||||||
import { ScrollService } from 'app/shared/scroll.service';
|
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 { SearchResultsComponent } from './search/search-results/search-results.component';
|
||||||
import { SearchBoxComponent } from './search/search-box/search-box.component';
|
import { SearchBoxComponent } from './search/search-box/search-box.component';
|
||||||
import { TocService } from 'app/shared/toc.service';
|
import { TocService } from 'app/shared/toc.service';
|
||||||
@ -94,6 +95,7 @@ export const svgIconProviders = [
|
|||||||
NavigationService,
|
NavigationService,
|
||||||
Platform,
|
Platform,
|
||||||
ScrollService,
|
ScrollService,
|
||||||
|
ScrollSpyService,
|
||||||
SearchService,
|
SearchService,
|
||||||
svgIconProviders,
|
svgIconProviders,
|
||||||
TocService
|
TocService
|
||||||
|
@ -14,7 +14,8 @@
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
<ul class="toc-list">
|
<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>
|
<a [href]="toc.href" [innerHTML]="toc.content"></a>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
@ -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 fixture: ComponentFixture<HostNotEmbeddedTocComponent>;
|
||||||
let scrollToTopSpy: jasmine.Spy;
|
let scrollToTopSpy: jasmine.Spy;
|
||||||
|
|
||||||
@ -274,6 +274,161 @@ describe('TocComponent', () => {
|
|||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
expect(scrollToTopSpy).toHaveBeenCalled();
|
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 {
|
class TestTocService {
|
||||||
tocList = new BehaviorSubject<TocItem[]>(getTestTocList());
|
tocList = new BehaviorSubject<TocItem[]>(getTestTocList());
|
||||||
|
activeItemIndex = new BehaviorSubject<number | null>(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
// tslint:disable:quotemark
|
// tslint:disable:quotemark
|
||||||
|
@ -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 { Subject } from 'rxjs/Subject';
|
||||||
|
import 'rxjs/add/observable/combineLatest';
|
||||||
import 'rxjs/add/operator/takeUntil';
|
import 'rxjs/add/operator/takeUntil';
|
||||||
|
|
||||||
import { ScrollService } from 'app/shared/scroll.service';
|
import { ScrollService } from 'app/shared/scroll.service';
|
||||||
@ -10,13 +12,15 @@ import { TocItem, TocService } from 'app/shared/toc.service';
|
|||||||
templateUrl: 'toc.component.html',
|
templateUrl: 'toc.component.html',
|
||||||
styles: []
|
styles: []
|
||||||
})
|
})
|
||||||
export class TocComponent implements OnInit, OnDestroy {
|
export class TocComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||||
|
|
||||||
|
activeIndex: number | null = null;
|
||||||
hasSecondary = false;
|
hasSecondary = false;
|
||||||
hasToc = false;
|
hasToc = false;
|
||||||
hostElement: HTMLElement;
|
hostElement: HTMLElement;
|
||||||
isCollapsed = true;
|
isCollapsed = true;
|
||||||
isEmbedded = false;
|
isEmbedded = false;
|
||||||
|
@ViewChildren('tocItem') private items: QueryList<ElementRef>;
|
||||||
private onDestroy = new Subject();
|
private onDestroy = new Subject();
|
||||||
private primaryMax = 4;
|
private primaryMax = 4;
|
||||||
tocList: TocItem[];
|
tocList: TocItem[];
|
||||||
@ -32,7 +36,7 @@ export class TocComponent implements OnInit, OnDestroy {
|
|||||||
ngOnInit() {
|
ngOnInit() {
|
||||||
this.tocService.tocList
|
this.tocService.tocList
|
||||||
.takeUntil(this.onDestroy)
|
.takeUntil(this.onDestroy)
|
||||||
.subscribe((tocList: TocItem[]) => {
|
.subscribe(tocList => {
|
||||||
const count = tocList.length;
|
const count = tocList.length;
|
||||||
|
|
||||||
this.hasToc = count > 0;
|
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() {
|
ngOnDestroy() {
|
||||||
this.onDestroy.next();
|
this.onDestroy.next();
|
||||||
}
|
}
|
||||||
|
517
aio/src/app/shared/scroll-spy.service.spec.ts
Normal file
517
aio/src/app/shared/scroll-spy.service.spec.ts
Normal 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());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
233
aio/src/app/shared/scroll-spy.service.ts
Normal file
233
aio/src/app/shared/scroll-spy.service.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -12,6 +12,22 @@ export class ScrollService {
|
|||||||
private _topOffset: number;
|
private _topOffset: number;
|
||||||
private _topOfPageElement: Element;
|
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(
|
constructor(
|
||||||
@Inject(DOCUMENT) private document: any,
|
@Inject(DOCUMENT) private document: any,
|
||||||
private location: PlatformLocation) { }
|
private location: PlatformLocation) { }
|
||||||
@ -51,20 +67,4 @@ export class ScrollService {
|
|||||||
private getCurrentHash() {
|
private getCurrentHash() {
|
||||||
return this.location.hash.replace(/^#/, '');
|
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
@ -1,10 +1,13 @@
|
|||||||
import { ReflectiveInjector, SecurityContext } from '@angular/core';
|
import { ReflectiveInjector, SecurityContext } from '@angular/core';
|
||||||
import { DOCUMENT, DomSanitizer, SafeHtml } from '@angular/platform-browser';
|
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';
|
import { TocItem, TocService } from './toc.service';
|
||||||
|
|
||||||
describe('TocService', () => {
|
describe('TocService', () => {
|
||||||
let injector: ReflectiveInjector;
|
let injector: ReflectiveInjector;
|
||||||
|
let scrollSpyService: MockScrollSpyService;
|
||||||
let tocService: TocService;
|
let tocService: TocService;
|
||||||
let lastTocList: TocItem[];
|
let lastTocList: TocItem[];
|
||||||
|
|
||||||
@ -20,8 +23,10 @@ describe('TocService', () => {
|
|||||||
injector = ReflectiveInjector.resolveAndCreate([
|
injector = ReflectiveInjector.resolveAndCreate([
|
||||||
{ provide: DomSanitizer, useClass: TestDomSanitizer },
|
{ provide: DomSanitizer, useClass: TestDomSanitizer },
|
||||||
{ provide: DOCUMENT, useValue: document },
|
{ provide: DOCUMENT, useValue: document },
|
||||||
|
{ provide: ScrollSpyService, useClass: MockScrollSpyService },
|
||||||
TocService,
|
TocService,
|
||||||
]);
|
]);
|
||||||
|
scrollSpyService = injector.get(ScrollSpyService);
|
||||||
tocService = injector.get(TocService);
|
tocService = injector.get(TocService);
|
||||||
tocService.tocList.subscribe(tocList => lastTocList = tocList);
|
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', () => {
|
describe('should clear tocList', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
// Start w/ dummy data from previous usage
|
// Start w/ dummy data from previous usage
|
||||||
@ -260,3 +348,18 @@ class TestDomSanitizer {
|
|||||||
} as TestSafeHtml;
|
} 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'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ -2,6 +2,9 @@ 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 { ScrollSpyInfo, ScrollSpyService } from 'app/shared/scroll-spy.service';
|
||||||
|
|
||||||
|
|
||||||
export interface TocItem {
|
export interface TocItem {
|
||||||
content: SafeHtml;
|
content: SafeHtml;
|
||||||
href: string;
|
href: string;
|
||||||
@ -13,38 +16,39 @@ export interface TocItem {
|
|||||||
@Injectable()
|
@Injectable()
|
||||||
export class TocService {
|
export class TocService {
|
||||||
tocList = new ReplaySubject<TocItem[]>(1);
|
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 = '') {
|
genToc(docElement?: Element, docId = '') {
|
||||||
const tocList = [];
|
this.resetScrollSpyInfo();
|
||||||
|
|
||||||
if (docElement) {
|
if (!docElement) {
|
||||||
const headings = docElement.querySelectorAll('h2,h3');
|
this.tocList.next([]);
|
||||||
const idMap = new Map<string, number>();
|
return;
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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.tocList.next(tocList);
|
||||||
|
|
||||||
|
this.scrollSpyInfo = this.scrollSpyService.spyOn(headings);
|
||||||
|
this.scrollSpyInfo.active.subscribe(item => this.activeItemIndex.next(item && item.index));
|
||||||
}
|
}
|
||||||
|
|
||||||
reset() {
|
reset() {
|
||||||
|
this.resetScrollSpyInfo();
|
||||||
this.tocList.next([]);
|
this.tocList.next([]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -61,6 +65,22 @@ export class TocService {
|
|||||||
return this.domSanitizer.bypassSecurityTrustHtml(a.innerHTML.trim());
|
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
|
// Extract the id from the heading; create one if necessary
|
||||||
// Is it possible for a heading to lack an id?
|
// Is it possible for a heading to lack an id?
|
||||||
private getId(h: HTMLHeadingElement, idMap: Map<string, number>) {
|
private getId(h: HTMLHeadingElement, idMap: Map<string, number>) {
|
||||||
|
@ -1,16 +1,16 @@
|
|||||||
.toc-container {
|
.toc-container {
|
||||||
width: 18%;
|
width: 18%;
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 96px;
|
top: 96px;
|
||||||
right: 0;
|
right: 0;
|
||||||
bottom: 32px;
|
bottom: 32px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
|
|
||||||
@media (max-width: 800px) {
|
@media (max-width: 800px) {
|
||||||
display: none;
|
display: none;
|
||||||
width: 0;
|
width: 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
aio-toc {
|
aio-toc {
|
||||||
@ -143,6 +143,12 @@ aio-toc > div {
|
|||||||
color: $accentblue;
|
color: $accentblue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
a {
|
||||||
|
color: $accentblue;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ul.toc-list li.h3 {
|
ul.toc-list li.h3 {
|
||||||
@ -151,5 +157,5 @@ aio-toc > div {
|
|||||||
}
|
}
|
||||||
|
|
||||||
aio-toc.embedded > div.collapsed li.secondary {
|
aio-toc.embedded > div.collapsed li.secondary {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user