From c362f7fd83026058b3e28caf545df8e67f636b09 Mon Sep 17 00:00:00 2001 From: snewcomer Date: Mon, 20 Aug 2018 17:24:20 -0700 Subject: [PATCH] Allow `root` on static admin to have multiple keys 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 --- addon/mixins/in-viewport.js | 8 +- addon/services/-observer-admin.js | 148 ++++++++++++++++++++++++++---- 2 files changed, 133 insertions(+), 23 deletions(-) diff --git a/addon/mixins/in-viewport.js b/addon/mixins/in-viewport.js index 8393b1bf..fbb8f8fb 100644 --- a/addon/mixins/in-viewport.js +++ b/addon/mixins/in-viewport.js @@ -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 diff --git a/addon/services/-observer-admin.js b/addon/services/-observer-admin.js index 23945bd2..6512ad4b 100644 --- a/addon/services/-observer-admin.js +++ b/addon/services/-observer-admin.js @@ -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(); /** @@ -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 }); } } @@ -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); } } @@ -63,13 +80,26 @@ 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; @@ -77,7 +107,7 @@ export default class ObserverAdmin extends Service { // 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) { @@ -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) { @@ -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; } }