Skip to content

Commit

Permalink
Allow root on static admin to have multiple keys
Browse files Browse the repository at this point in the history
The root scrollable area can have multiple elements with different observer options.  To prevent the first element"s options from overriding the rest of the page, we need to ensure that when we want to find a potential match for an element, that we compare the observer options andd only reuse the instance of the IntersectionObserver if the element shares the same root and same observer options
  • Loading branch information
snewcomer committed Aug 21, 2018
1 parent cd34d17 commit c362f7f
Show file tree
Hide file tree
Showing 2 changed files with 133 additions and 23 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
148 changes: 129 additions & 19 deletions addon/services/-observer-admin.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import Service from '@ember/service';
import { bind } from '@ember/runloop';

// WeakMap { root: { elements: [{ element, enterCallback, exitCallback }], IntersectionObserver } }
// WeakMap { root: { stringifiedOptions: [{ element, enterCallback, exitCallback, observerOptions, IntersectionObserver }], stringifiedOptions: [].... } }
// A root may have multiple keys with different observer options
let DOMRef = new WeakMap();

/**
Expand All @@ -24,17 +25,31 @@ 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) {
elements.push({ element, enterCallback, exitCallback });
intersectionObserver.observe(element, options);
// 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 matchingElements = this._determineMatchingElements(observerOptions, potentialRootMatch);

if (matchingElements.length > 0) {
let { intersectionObserver } = matchingElements[0];
matchingElements.push({ element, enterCallback, exitCallback, observerOptions, intersectionObserver });
intersectionObserver.observe(element);
return;
}

let newIO = new IntersectionObserver(bind(this, this._setupOnIntersection(observerOptions)), observerOptions);
newIO.observe(element);
let observerEntry = [{ 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
DOMRef.set(root, { [JSON.stringify(observerOptions)]: observerEntry });
}
}

Expand All @@ -43,9 +58,11 @@ export default class ObserverAdmin extends Service {
* @param {Node} element
* @param {Node|window} root
*/
unobserve(element, root) {
let { intersectionObserver } = this._findRoot(root);
if (intersectionObserver) {
unobserve(element, observerOptions) {
let elements = this._findMatchingRootEntry(observerOptions);

if (elements.length > 0) {
let { intersectionObserver } = elements[0];
intersectionObserver.unobserve(element);
}
}
Expand All @@ -63,21 +80,34 @@ export default class ObserverAdmin extends Service {
}
}

_setupOnIntersection(root) {
/**
* use function composition to curry observerOptions
*
* @method _setupOnIntersection
* @param {Object} observerOptions
*/
_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) {
Expand All @@ -88,7 +118,7 @@ export default class ObserverAdmin extends Service {
});
} 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) {
Expand All @@ -101,7 +131,87 @@ export default class ObserverAdmin extends Service {
});
}

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

/**
* Used for onIntersection callbacks and unobserving the IntersectionObserver
* We don't care about key order in the observerOptions because we already added
* to the static administrator or found an existing IntersectionObserver with the same
* root && observerOptions to reuse their IntersectionObserver
*
* @method _findMatchingRootEntry
* @param {Object} observerOptions
* @return {Array} of elements that share same root and observerOptions
*/
_findMatchingRootEntry(observerOptions) {
let { root = window } = observerOptions;
let stringifiedOptions = JSON.stringify(observerOptions);
let matchingRoot = DOMRef.get(root) || {};
return matchingRoot[stringifiedOptions] || [];
}

/**
* determine if existing elements for a given root based on passed in observerOptions
* irregardless of sort order of keys
*
* @method _determineMatchingElements
* @param {Object} observerOptions
* @param {Object} potentialRootMatch e.g. { stringifiedOptions: [], stringifiedOptions: [], ...}
* @return {Array}
*/
_determineMatchingElements(observerOptions, potentialRootMatch = {}) {
let matchingKey = Object.keys(potentialRootMatch).filter((key) => {
return this._hasSimilarElement(observerOptions, potentialRootMatch[key]);
});
return potentialRootMatch[matchingKey] || [];
}

/**
* determine if share same observerOptions as to be observed element's observerOptions
*
* @method _hasSimilarElement
* @param {Object} observerOptions
* @param {Array} elements
* @return {Array}
*/
_hasSimilarElement(observerOptions, elements) {
return elements.some((testElement) => {
return this._compareOptions(observerOptions, testElement.observerOptions);
});
}

/**
* @method _compareOptions
* @param {Object} observerOptions
* @param {Object} elementOptions
* @return {Boolean}
*/
_compareOptions(observerOptions, elementOptions) {
// simple comparison of string, number or even null/undefined
let type1 = Object.prototype.toString.call(observerOptions);
let type2 = Object.prototype.toString.call(elementOptions);
if (type1 !== type2) {
return false;
} else if (type1 !== '[object Object]' && type2 !== '[object Object]') {
return observerOptions === elementOptions;
}

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

0 comments on commit c362f7f

Please sign in to comment.