Takumon/mean-blog

View on GitHub
src/app/articles/shared/scroll-spy.service.ts

Summary

Maintainability
A
0 mins
Test Coverage
import { Inject, Injectable } from '@angular/core';
import { DOCUMENT } from '@angular/platform-browser';
import { Observable, fromEvent, Subject, ReplaySubject } from 'rxjs';
import { auditTime, distinctUntilChanged, takeUntil } from 'rxjs/operators';

import { ScrollService } from './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 = fromEvent(window, 'resize').pipe(auditTime(300), takeUntil(this.onStopListening));
  // 同期性を重視してauditTimeは設定しない
  private scrollEvents = fromEvent(window, 'scroll').pipe(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().pipe(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 + 12;
  }

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