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 { 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
|
||||
|
@ -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>
|
||||
|
@ -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
|
||||
|
@ -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();
|
||||
}
|
||||
|
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 _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;
|
||||
}
|
||||
}
|
||||
|
@ -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'),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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>) {
|
||||
|
@ -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;
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user