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