elwayman02/ember-user-activity

View on GitHub
addon/services/scroll-activity.js

Summary

Maintainability
A
1 hr
Test Coverage
import FastBootAwareEventManagerService from 'ember-user-activity/services/-private/fastboot-aware-event-manager';
import { begin as beginRunloop, end as endRunloop } from '@ember/runloop';
import getScroll from 'ember-user-activity/utils/get-scroll';

/*
 * Polling uses rAF and/or a setTimeout at 16ms, however rAF will run in the
 * microtask queue and might fire just after Ember's render step occurred.
 * By enforcing that the interval between a poll and the previous must be
 * below a reasonable number, we can be reasonably sure the main UI
 * thread didn't just do a lot of work.
 *
 * This number should be above the minimum polling period (16ms)
 */
const MAX_POLL_PERIOD = 32;
const SCROLL_EVENT_TYPE_VERTICAL = 'vertical';
const SCROLL_EVENT_TYPE_HORIZONTAL = 'horizontal';
const SCROLL_EVENT_TYPE_DIAGONAL = 'diagonal';

export default class ScrollActivityService extends FastBootAwareEventManagerService {
  _animationFrame = null;
  _subscribers = [];
  _lastCheckAt = new Date();

  constructor() {
    super(...arguments);

    if (this._isFastBoot) {
      return;
    }

    this.subscribe(document, document, () => {}, false);

    this._pollScroll();
  }

  subscribe(target, element, callback = () => {}, highPriority = true) {
    this._subscribers.push({
      target,
      element,
      callback,
      highPriority,
      scrollTop: null,
      scrollLeft: null,
    });
  }

  unsubscribe(target) {
    let { _subscribers: subscribers } = this;
    for (let i = 0; i < subscribers.length; i++) {
      let subscriber = subscribers[i];
      if (subscriber.target === target) {
        subscribers.splice(i, 1);
        break;
      }
    }
  }

  _pollScroll() {
    if (this._isFastBoot) {
      return;
    }
    if (window.requestAnimationFrame) {
      this._animationFrame = requestAnimationFrame(() => this._checkScroll());
    } else {
      this._animationFrame = setTimeout(() => this._checkScroll(), 16);
    }
  }

  _checkScroll() {
    let { _subscribers: subscribers } = this;
    let now = new Date();
    if (subscribers.length) {
      if (this._hasScrolled(now)) {
        this.trigger('scroll');
        endRunloop();
      }
    }
    this._lastCheckAt = now;
    this._pollScroll();
  }

  _updateScroll(subscriber) {
    subscriber.scrollTop = getScroll(subscriber.element);
    subscriber.scrollLeft = getScroll(subscriber.element, 'left');
  }

  _hasScrolled(now) {
    let { _subscribers: subscribers, _lastCheckAt: lastCheckAt } = this;
    let lowPriorityFrame = now - lastCheckAt < MAX_POLL_PERIOD;
    let hasScrolled = false;
    for (let i = 0; i < subscribers.length; i++) {
      let subscriber = subscribers[i];
      if (subscriber.highPriority || lowPriorityFrame) {
        let scrollTop = getScroll(subscriber.element);
        let scrollLeft = getScroll(subscriber.element, 'left');
        if (
          scrollTop !== subscriber.scrollTop &&
          scrollLeft !== subscriber.scrollLeft
        ) {
          hasScrolled = this._handleAllScrollChanged(subscriber, hasScrolled);
        } else if (scrollTop !== subscriber.scrollTop) {
          hasScrolled = this._handleScrollTopChanged(subscriber, hasScrolled);
        } else if (scrollLeft !== subscriber.scrollLeft) {
          hasScrolled = this._handleScrollLeftChanged(subscriber, hasScrolled);
        }
      }
    }
    return hasScrolled;
  }

  _handleAllScrollChanged(subscriber, hasScrolled) {
    // If the values are changing from an initial null state to first-time values, do not treat it like a change.
    if (subscriber.scrollTop !== null && subscriber.scrollLeft !== null) {
      if (!hasScrolled) {
        beginRunloop();
        hasScrolled = true;
      }

      let scrollTop = getScroll(subscriber.element);
      let scrollLeft = getScroll(subscriber.element, 'left');
      subscriber.callback(
        scrollTop,
        subscriber.scrollTop,
        SCROLL_EVENT_TYPE_DIAGONAL,
        scrollLeft,
        subscriber.scrollLeft,
      );
    }
    this._updateScroll(subscriber);
    return hasScrolled;
  }

  _handleScrollLeftChanged(subscriber, hasScrolled) {
    // If the value is changing from an initial null state to a first
    // time value, do not treat it like a change.
    if (subscriber.scrollLeft !== null) {
      if (!hasScrolled) {
        beginRunloop();
        hasScrolled = true;
      }
      subscriber.callback(
        getScroll(subscriber.element, 'left'),
        subscriber.scrollLeft,
        SCROLL_EVENT_TYPE_HORIZONTAL,
      );
    }
    this._updateScroll(subscriber);
    return hasScrolled;
  }

  _handleScrollTopChanged(subscriber, hasScrolled) {
    // If the value is changing from an initial null state to a first
    // time value, do not treat it like a change.
    if (subscriber.scrollTop !== null) {
      if (!hasScrolled) {
        beginRunloop();
        hasScrolled = true;
      }
      subscriber.callback(
        getScroll(subscriber.element),
        subscriber.scrollTop,
        SCROLL_EVENT_TYPE_VERTICAL,
      );
    }
    this._updateScroll(subscriber);
    return hasScrolled;
  }

  willDestroy() {
    if (this._isFastBoot) {
      return;
    }
    if (window.requestAnimationFrame) {
      cancelAnimationFrame(this._animationFrame);
    } else {
      clearTimeout(this._animationFrame);
    }
    this._subscribers.length = 0;

    super.willDestroy(...arguments);
  }
}