Skip to content

Commit

Permalink
Extract static admin && update deps (#169)
Browse files Browse the repository at this point in the history
  • Loading branch information
snewcomer authored Nov 21, 2018
1 parent a68c046 commit c9b500f
Show file tree
Hide file tree
Showing 8 changed files with 12,556 additions and 10,003 deletions.
2 changes: 2 additions & 0 deletions .eslintignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,11 @@

# dependencies
/bower_components/
/node_modules/

# misc
/coverage/
!.*

# ember-try
/.node_modules.ember-try/
Expand Down
1 change: 1 addition & 0 deletions .eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ module.exports = {
// node files
{
files: [
'.eslintrc.js',
'.template-lintrc.js',
'ember-cli-build.js',
'index.js',
Expand Down
239 changes: 9 additions & 230 deletions addon/services/-observer-admin.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import Service from '@ember/service';
import { bind } from '@ember/runloop';
import IntersectionObserverAdmin from 'intersection-observer-admin';

/**
* Static administrator to ensure use one IntersectionObserver per combination of root + observerOptions
Expand All @@ -17,240 +17,19 @@ export default class ObserverAdmin extends Service {
/** @private **/
init() {
this._super(...arguments);
// WeakMap { root: { stringifiedOptions: { elements: [{ element, enterCallback, exitCallback }], observerOptions, IntersectionObserver }, stringifiedOptions: [].... } }
// A root may have multiple keys with different observer options
this._DOMRef = new WeakMap();
this._observerAdmin = new IntersectionObserverAdmin();
}

/**
* Adds element to observe via IntersectionObserver and stores element + relevant callbacks and observer options in static
* administrator for lookup in the future
*
* @method add
* @param {Node} element
* @param {Function} enterCallback
* @param {Function} exitCallback
* @param {Object} observerOptions
* @param {String} [scrollableArea]
* @public
*/
add(element, enterCallback, exitCallback, observerOptions, scrollableArea) {
if (!element || !observerOptions) {
return;
}
let { root = window } = observerOptions;

// first find shared root element (window or scrollable area)
let potentialRootMatch = this._findRoot(root);
// second if there is a matching root, find an entry with the same observerOptions
let matchingEntryForRoot = this._determineMatchingElements(observerOptions, potentialRootMatch);

if (matchingEntryForRoot) {
let { elements, intersectionObserver } = matchingEntryForRoot;
elements.push({ element, enterCallback, exitCallback });
intersectionObserver.observe(element);
return;
}

// No matching entry for root in static admin, thus create new IntersectionObserver instance
let newIO = new IntersectionObserver(bind(this, this._setupOnIntersection(observerOptions, scrollableArea)), observerOptions);
newIO.observe(element);
let observerEntry = {
elements: [{ element, enterCallback, exitCallback }],
observerOptions,
intersectionObserver: newIO
};

let stringifiedOptions = this._stringifyObserverOptions(observerOptions, scrollableArea);
if (potentialRootMatch) {
// if share same root and need to add new entry to root match
potentialRootMatch[stringifiedOptions] = observerEntry;
} else {
// no root exists, so add to WeakMap
this._DOMRef.set(root, { [stringifiedOptions]: observerEntry });
}
}

/**
* Unobserve target element and remove element from static admin
*
* @method unobserve
* @param {Node|window} target
* @param {Object} observerOptions
* @param {String} scrollableArea
* @public
*/
unobserve(target, observerOptions, scrollableArea) {
let { elements = [], intersectionObserver } = this._findMatchingRootEntry(observerOptions, scrollableArea);

intersectionObserver.unobserve(target);

// important to do this in reverse order
for (let i = elements.length - 1; i >= 0; i--) {
if (elements[i] && elements[i].element === target) {
elements.splice(i, 1);
break;
}
}
}

/**
* @method willDestroy
* @public
*/
willDestroy() {
this._super(...arguments);
this._DOMRef = null;
}

/**
* use function composition to curry observerOptions
*
* @method _setupOnIntersection
* @param {Object} observerOptions
* @param {String} scrollableArea
*/
_setupOnIntersection(observerOptions, scrollableArea) {
return function(entries) {
return this._onIntersection(observerOptions, scrollableArea, entries);
}
}

/**
* IntersectionObserver callback when element is intersecting viewport
*
* @method _onIntersection
* @param {Object} observerOptions
* @param {String} scrollableArea
* @param {Array} ioEntries
* @private
*/
_onIntersection(observerOptions, scrollableArea, ioEntries) {
ioEntries.forEach((entry) => {

let { isIntersecting, intersectionRatio } = entry;

// first determine if entry intersecting
if (isIntersecting) {
// then find entry's callback in static administration
let { elements = [] } = this._findMatchingRootEntry(observerOptions, scrollableArea);

elements.some((obj) => {
if (obj.element === entry.target) {
// call entry's enter callback
obj.enterCallback();
return true;
}
});
} else if (intersectionRatio <= 0) { // exiting viewport
// then find entry's callback in static administration
let { elements = [] } = this._findMatchingRootEntry(observerOptions, scrollableArea);

elements.some((obj) => {
if (obj.element === entry.target) {
// call entry's enter callback
obj.exitCallback();
return true;
}
});
}
});
}

/**
* @method _findRoot
* @param {Node|window} root
* @private
* @return {Object} of elements that share same root
*/
_findRoot(root) {
return this._DOMRef.get(root);
add(...args) {
return this._observerAdmin.observe(...args);
}

/**
* Used for onIntersection callbacks and unobserving the IntersectionObserver
* We don't care about observerOptions key order because we already added
* to the static administrator or found an existing IntersectionObserver with the same
* root && observerOptions to reuse
*
* @method _findMatchingRootEntry
* @param {Object} observerOptions
* @param {String} scrollableArea
* @return {Object} entry with elements and other options
*/
_findMatchingRootEntry(observerOptions, scrollableArea) {
let { root = window } = observerOptions;
let matchingRoot = this._findRoot(root) || {};
let stringifiedOptions = this._stringifyObserverOptions(observerOptions, scrollableArea);
return matchingRoot[stringifiedOptions];
unobserve(...args) {
return this._observerAdmin.unobserve(...args);
}

/**
* Determine if existing elements for a given root based on passed in observerOptions
* regardless of sort order of keys
*
* @method _determineMatchingElements
* @param {Object} observerOptions
* @param {Object} potentialRootMatch e.g. { stringifiedOptions: { elements: [], ... }, stringifiedOptions: { elements: [], ... }}
* @private
* @return {Object} containing array of elements and other meta
*/
_determineMatchingElements(observerOptions, potentialRootMatch = {}) {
let matchingKey = Object.keys(potentialRootMatch).filter((key) => {
let { observerOptions: comparableOptions } = potentialRootMatch[key];
return this._areOptionsSame(observerOptions, comparableOptions);
})[0];

return potentialRootMatch[matchingKey];
}

/**
* recursive method to test primitive string, number, null, etc and complex
* object equality.
*
* @method _areOptionsSame
* @param {Object} observerOptions
* @param {Object} comparableOptions
* @private
* @return {Boolean}
*/
_areOptionsSame(observerOptions, comparableOptions) {
// simple comparison of string, number or even null/undefined
let type1 = Object.prototype.toString.call(observerOptions);
let type2 = Object.prototype.toString.call(comparableOptions);
if (type1 !== type2) {
return false;
} else if (type1 !== '[object Object]' && type2 !== '[object Object]') {
return observerOptions === comparableOptions;
}

// complex comparison for only type of [object Object]
for (let key in observerOptions) {
if (observerOptions.hasOwnProperty(key)) {
// recursion to check nested
if (this._areOptionsSame(observerOptions[key], comparableOptions[key]) === false) {
return false;
}
}
}
return true;
}

/**
* Stringify observerOptions for use as a key.
* Excludes observerOptions.root so that the resulting key is stable
*
* @param {Object} observerOptions
* @param {String} scrollableArea
* @private
* @return {String}
*/
_stringifyObserverOptions(observerOptions, scrollableArea) {
let replacer = (key, value) => {
if (key === 'root') return scrollableArea;
return value;
};

return JSON.stringify(observerOptions, replacer);
destroy(...args) {
this._observerAdmin.destroy(...args);
this._observerAdmin = null;
}
}
4 changes: 2 additions & 2 deletions config/ember-try.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ module.exports = function() {
{
name: 'ember-lts-2.16',
env: {
EMBER_OPTIONAL_FEATURES: JSON.stringify({ 'jquery-integration': true }),
EMBER_OPTIONAL_FEATURES: JSON.stringify({ 'jquery-integration': true })
},
npm: {
devDependencies: {
Expand All @@ -25,7 +25,7 @@ module.exports = function() {
{
name: 'ember-lts-2.18',
env: {
EMBER_OPTIONAL_FEATURES: JSON.stringify({ 'jquery-integration': true }),
EMBER_OPTIONAL_FEATURES: JSON.stringify({ 'jquery-integration': true })
},
npm: {
devDependencies: {
Expand Down
Loading

0 comments on commit c9b500f

Please sign in to comment.