Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow root on static admin to have multiple keys #157

Merged
merged 12 commits into from
Aug 21, 2018
8 changes: 4 additions & 4 deletions addon/mixins/in-viewport.js
Original file line number Diff line number Diff line change
@@ -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);
},
@@ -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
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
*
@@ -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' } }}}));
});
});