Skip to content

Commit

Permalink
Allow root on static admin to have multiple keys (#157)
Browse files Browse the repository at this point in the history
  • Loading branch information
snewcomer authored Aug 21, 2018
1 parent cd34d17 commit 5fef666
Show file tree
Hide file tree
Showing 3 changed files with 178 additions and 47 deletions.
8 changes: 4 additions & 4 deletions addon/mixins/in-viewport.js
Original file line number Diff line number Diff line change
Expand Up @@ -149,11 +149,11 @@ export default Mixin.create({
// https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API
// IntersectionObserver takes either a Document Element or null for `root`
const { top = 0, left = 0, bottom = 0, right = 0 } = this.viewportTolerance;
this._observerOptions = {
set(this, '_observerOptions', {
root: scrollableArea,
rootMargin: `${top}px ${right}px ${bottom}px ${left}px`,
threshold: get(this, 'intersectionThreshold')
};
});

get(this, '_observerAdmin').add(element, bind(this, this._onEnterIntersection), bind(this, this._onExitIntersection), this._observerOptions);
},
Expand Down Expand Up @@ -354,8 +354,8 @@ export default Mixin.create({
set(this, '_stopListening', true);

// if IntersectionObserver
if (get(this, 'viewportUseIntersectionObserver')) {
get(this, '_observerAdmin').unobserve(this.element, get(this, '_observerOptions.root'));
if (get(this, 'viewportUseIntersectionObserver') && get(this, 'viewportEnabled')) {
get(this, '_observerAdmin').unobserve(this.element, get(this, '_observerOptions'));
}

// if rAF
Expand Down
186 changes: 143 additions & 43 deletions addon/services/-observer-admin.js
Original file line number Diff line number Diff line change
@@ -1,20 +1,23 @@
import Service from '@ember/service';
import { bind } from '@ember/runloop';

// WeakMap { root: { elements: [{ element, enterCallback, exitCallback }], IntersectionObserver } }
let DOMRef = new WeakMap();

/**
* Static administrator to ensure use one IntersectionObserver per viewport
* Static administrator to ensure use one IntersectionObserver per combination of root + observerOptions
* Use `root` (viewport) as lookup property
* `root` will have one IntersectionObserver with many entries (elements) to watch
* provided callback will ensure consumer of this service is able to react to enter or exit
* of intersection observer
* `root` will have many options with each option containing one IntersectionObserver instance and various callbacks
* Provided callback will ensure consumer of this service is able to react to enter or exit of intersection observer
*
* @module Ember.Service
* @class ObserverAdmin
*/
export default class ObserverAdmin extends Service {
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();
}

/**
* adds element to observe entries of IntersectionObserver
*
Expand All @@ -24,84 +27,181 @@ export default class ObserverAdmin extends Service {
* @param {Function} exitCallback
* @param {Object} options
*/
add(element, enterCallback, exitCallback, options) {
let { root = window } = options;
let { elements, intersectionObserver } = this._findRoot(root);
add(element, enterCallback, exitCallback, observerOptions) {
let { root = window } = observerOptions;

if (elements && elements.length > 0) {
// 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, options);
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)), observerOptions);
newIO.observe(element);
let observerEntry = {
elements: [{ element, enterCallback, exitCallback }],
observerOptions,
intersectionObserver: newIO
};

if (potentialRootMatch) {
// if share same root and need to add new entry to root match
potentialRootMatch[JSON.stringify(observerOptions)] = observerEntry;
} else {
let newIO = new IntersectionObserver(bind(this, this._setupOnIntersection(root)), options);
newIO.observe(element);
DOMRef.set(root, { elements: [{ element, enterCallback, exitCallback }], intersectionObserver: newIO });
// no root exists, so add to WeakMap
this._DOMRef.set(root, { [JSON.stringify(observerOptions)]: observerEntry });
}
}

/**
* @method unobserve
* @param {Node} element
* @param {Node} target
* @param {Node|window} root
*/
unobserve(element, root) {
let { intersectionObserver } = this._findRoot(root);
if (intersectionObserver) {
intersectionObserver.unobserve(element);
unobserve(target, observerOptions) {
let { elements = [], intersectionObserver } = this._findMatchingRootEntry(observerOptions);

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

/**
* to unobserver multiple elements
* use function composition to curry observerOptions
*
* @method disconnect
* @param {Node|window} root
* @method _setupOnIntersection
* @param {Object} observerOptions
*/
disconnect(root) {
let { intersectionObserver } = this._findRoot(root);
if (intersectionObserver) {
intersectionObserver.disconnect();
}
}

_setupOnIntersection(root) {
_setupOnIntersection(observerOptions) {
return function(entries) {
return this._onAdminIntersection(root, entries);
return this._onIntersection(observerOptions, entries);
}
}

_onAdminIntersection(root, ioEntries) {
/**
* IntersectionObserver callback when element is intersecting viewport
*
* @method _onIntersection
* @param {Object} observerOptions
* @param {Array} ioEntries
*/
_onIntersection(observerOptions, 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._findRoot(root);
let { elements = [] } = this._findMatchingRootEntry(observerOptions);

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

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

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

/**
* 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
* @return {Object} entry with elements and other options
*/
_findMatchingRootEntry(observerOptions) {
let stringifiedOptions = JSON.stringify(observerOptions);
let { root = window } = observerOptions;
let matchingRoot = this._DOMRef.get(root) || {};
return matchingRoot[stringifiedOptions];
}

/**
* 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: [], ... }}
* @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
* @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;
}
}
31 changes: 31 additions & 0 deletions tests/unit/services/-observer-admin-test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { module, test } from 'qunit';
import { setupTest } from 'ember-qunit';

module('Unit | Mixin | -observer-admin', function(hooks) {
setupTest(hooks);

test('_areOptionsSame works', function(assert) {
let service = this.owner.lookup('service:-observer-admin');

// primitive
assert.ok(service._areOptionsSame('a', 'a'));
assert.ok(service._areOptionsSame(1, 1));
assert.notOk(service._areOptionsSame('a', 'ab'));
assert.notOk(service._areOptionsSame(1, 2));

// complex
assert.ok(service._areOptionsSame({}, {}));
assert.notOk(service._areOptionsSame({ a: 'b' }, {}));
assert.ok(service._areOptionsSame({ a: 'b' }, { a: 'b' }));
assert.notOk(service._areOptionsSame({ a: { b: 'c' }}, { a: 'b' }));
assert.ok(service._areOptionsSame({ a: { b: 'c' }}, { a: { b: 'c' } }));
assert.notOk(service._areOptionsSame({ a: { b: { c: 'd' } }}, { a: { b: 'c' } }));
assert.ok(service._areOptionsSame({ a: { b: { c: 'd' } }}, { a: { b: { c: 'd' } } }));
});

test('_determineMatchingElements works', function(assert) {
let service = this.owner.lookup('service:-observer-admin');
assert.ok(service._determineMatchingElements({ a: { b: 'c' }}, { key: { observerOptions: { a: { b: 'c' } }}}));
assert.notOk(service._determineMatchingElements({ a: { b: 'd' }}, { key: { observerOptions: { a: { b: 'c' } }}}));
});
});

0 comments on commit 5fef666

Please sign in to comment.