Created
August 16, 2020 01:21
-
-
Save kylekyle/f61656677306286e1062810c53d1d4d6 to your computer and use it in GitHub Desktop.
Uses MutationsObservers and IntersectingObservers to let you know when elements arrive or appear.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
"use strict"; | |
var arriveUniqueId = 0; | |
var utils = (function() { | |
var matches = HTMLElement.prototype.matches | |
|| HTMLElement.prototype.webkitMatchesSelector | |
|| HTMLElement.prototype.mozMatchesSelector | |
|| HTMLElement.prototype.msMatchesSelector; | |
return { | |
matchesSelector: function(elem, selector) { | |
// you can't use instanceof HTMLElement because it returns false | |
// for objects from *another* DOM (like another window or iframe) | |
// instead, we'll just check that it has is ELEMENT nodeType, | |
// but this may break for things like SVG elements | |
return elem.nodeType == 1 && matches.call(elem, selector); | |
}, | |
callCallbacks: function(callbacksToBeCalled, registrationData) { | |
if (registrationData && registrationData.options.onceOnly && registrationData.firedElems.length == 1) { | |
// as onlyOnce param is true, make sure we fire the event for only one item | |
callbacksToBeCalled = [callbacksToBeCalled[0]]; | |
} | |
callbacksToBeCalled.forEach(cb => cb.callback(cb.elem)); | |
if (registrationData && registrationData.options.onceOnly && registrationData.firedElems.length == 1) { | |
// unbind event after first callback as onceOnly is true. | |
registrationData.me.remove(registrationData); | |
} | |
}, | |
// traverse through all descendants of a node to check if event should be fired for any descendant | |
checkChildNodesRecursively: function(nodes, registrationData, matchFunc, callbacksToBeCalled) { | |
// check each new node if it matches the selector | |
nodes.forEach(node => { | |
if (matchFunc(node, registrationData, callbacksToBeCalled)) { | |
callbacksToBeCalled.push({ callback: registrationData.callback, elem: node }); | |
} | |
if (node.childNodes.length > 0) { | |
utils.checkChildNodesRecursively(node.childNodes, registrationData, matchFunc, callbacksToBeCalled); | |
} | |
}); | |
}, | |
mergeArrays: function(firstArr, secondArr){ | |
// Overwrites default options with user-defined options. | |
var options = {}, | |
attrName; | |
for (attrName in firstArr) { | |
if (firstArr.hasOwnProperty(attrName)) { | |
options[attrName] = firstArr[attrName]; | |
} | |
} | |
for (attrName in secondArr) { | |
if (secondArr.hasOwnProperty(attrName)) { | |
options[attrName] = secondArr[attrName]; | |
} | |
} | |
return options; | |
}, | |
toElementsArray: function (elements) { | |
// check if object is an array (or array like object) | |
// Note: window object has .length property but it's not array of elements so don't consider it an array | |
if (elements !== undefined && (typeof elements.length !== "number" || elements === window)) { | |
elements = [elements]; | |
} | |
return elements; | |
} | |
}; | |
})(); | |
// Class to maintain state of all registered events of a single type | |
var EventsBucket = (function() { | |
var EventsBucket = function() { | |
// holds all the events | |
this._eventsBucket = []; | |
// function to be called while adding an event, the function should do the event initialization/registration | |
this._beforeAdding = null; | |
// function to be called while removing an event, the function should do the event destruction | |
this._beforeRemoving = null; | |
}; | |
EventsBucket.prototype.addEvent = function(target, selector, options, callback) { | |
var newEvent = { | |
target: target, | |
selector: selector, | |
options: options, | |
callback: callback, | |
firedElems: [] | |
}; | |
if (this._beforeAdding) { | |
this._beforeAdding(newEvent); | |
} | |
this._eventsBucket.push(newEvent); | |
return newEvent; | |
}; | |
EventsBucket.prototype.remove = function(event) { | |
const index = this._eventsBucket.indexOf(event); | |
this._eventsBucket.splice(index, 1); | |
if (this._beforeRemoving) { | |
this._beforeRemoving(event); | |
} | |
// mark callback as null to avoid callback in case an event mutation was already triggered | |
event.callback = null; | |
}; | |
EventsBucket.prototype.removeAll = function() { | |
_eventsBucket.forEach(event => this.remove(event)); | |
} | |
EventsBucket.prototype.beforeAdding = function(beforeAdding) { | |
this._beforeAdding = beforeAdding; | |
}; | |
EventsBucket.prototype.beforeRemoving = function(beforeRemoving) { | |
this._beforeRemoving = beforeRemoving; | |
}; | |
return EventsBucket; | |
})(); | |
/** | |
* @constructor | |
* General class for binding/unbinding arrive and leave events | |
*/ | |
var MutationEvents = function(getObserverConfig, onMutation) { | |
var eventsBucket = new EventsBucket(), | |
me = this; | |
var defaultOptions = { | |
fireOnAttributesModification: false | |
}; | |
// actual event registration before adding it to bucket | |
eventsBucket.beforeAdding(function(registrationData) { | |
var observer; | |
var target = registrationData.target; | |
// Create an observer instance | |
observer = new MutationObserver(e => onMutation(e, registrationData)); | |
var config = getObserverConfig(registrationData.options); | |
observer.observe(target, config); | |
registrationData.observer = observer; | |
registrationData.me = me; | |
}); | |
// cleanup/unregister before removing an event | |
eventsBucket.beforeRemoving((eventData) => { | |
eventData.observer.disconnect(); | |
}); | |
// returns an array of events created from this call | |
this.add = function(target, selector, options, callback) { | |
var events = []; | |
var elements = utils.toElementsArray(target); | |
options = utils.mergeArrays(defaultOptions, options); | |
elements.forEach(element => { | |
events.push(eventsBucket.addEvent(element, selector, options, callback)); | |
}); | |
return events; | |
}; | |
this.remove = (event) => eventsBucket.remove(event); | |
this.removeAllEvents = () => eventsBucket.removeAll(); | |
return this; | |
}; | |
/** | |
* @constructor | |
* Processes 'arrive' events | |
*/ | |
var ArriveEvents = function() { | |
// Default options for 'arrive' event | |
var arriveDefaultOptions = { | |
fireOnAttributesModification: false, | |
onceOnly: false, | |
existing: false | |
}; | |
function getArriveObserverConfig(options) { | |
var config = { | |
attributes: false, | |
childList: true, | |
subtree: true | |
}; | |
if (options.fireOnAttributesModification) { | |
config.attributes = true; | |
} | |
return config; | |
} | |
function onArriveMutation(mutations, registrationData) { | |
mutations.forEach(function( mutation ) { | |
var newNodes = mutation.addedNodes, | |
targetNode = mutation.target, | |
callbacksToBeCalled = [], | |
node; | |
// If new nodes are added | |
if( newNodes !== null && newNodes.length > 0 ) { | |
utils.checkChildNodesRecursively(newNodes, registrationData, nodeMatchFunc, callbacksToBeCalled); | |
} | |
else if (mutation.type === "attributes") { | |
if (nodeMatchFunc(targetNode, registrationData)) { | |
callbacksToBeCalled.push({ callback: registrationData.callback, elem: targetNode }); | |
} | |
} | |
utils.callCallbacks(callbacksToBeCalled, registrationData); | |
}); | |
} | |
function nodeMatchFunc(node, registrationData) { | |
// check a single node to see if it matches the selector | |
if (utils.matchesSelector(node, registrationData.selector)) { | |
if(node._id === undefined) { | |
node._id = arriveUniqueId++; | |
} | |
// make sure the arrive event is not already fired for the element | |
if (registrationData.firedElems.indexOf(node._id) == -1) { | |
registrationData.firedElems.push(node._id); | |
return true; | |
} | |
} | |
return false; | |
} | |
arriveEvents = new MutationEvents(getArriveObserverConfig, onArriveMutation); | |
var mutationAdd = arriveEvents.add; | |
// override bindEvent function | |
arriveEvents.add = function(target, selector, options, callback) { | |
if (callback === undefined) { | |
callback = options; | |
options = arriveDefaultOptions; | |
} else { | |
options = utils.mergeArrays(arriveDefaultOptions, options); | |
} | |
var elements = utils.toElementsArray(target); | |
if (options.existing) { | |
var existing = []; | |
elements.forEach(element => { | |
var nodes = element.querySelectorAll(selector); | |
nodes.forEach(node => { | |
existing.push({ callback: callback, elem: node }); | |
}); | |
}); | |
// no need to bind event if the callback has to be fired only once and we have already found the element | |
if (options.onceOnly && existing.length) { | |
return callback.call(existing[0].elem, existing[0].elem); | |
} | |
setTimeout(utils.callCallbacks, 1, existing); | |
} | |
return mutationAdd(target, selector, options, callback); | |
}; | |
return arriveEvents; | |
}; | |
/** | |
* @constructor | |
* Processes 'leave' events | |
*/ | |
var LeaveEvents = function() { | |
// Default options for 'leave' event | |
var leaveDefaultOptions = {}; | |
function getLeaveObserverConfig() { | |
var config = { | |
childList: true, | |
subtree: true | |
}; | |
return config; | |
} | |
function onLeaveMutation(mutations, registrationData) { | |
mutations.forEach(function( mutation ) { | |
var callbacksToBeCalled = []; | |
var removedNodes = mutation.removedNodes; | |
if( removedNodes !== null && removedNodes.length > 0 ) { | |
utils.checkChildNodesRecursively(removedNodes, registrationData, nodeMatchFunc, callbacksToBeCalled); | |
} | |
utils.callCallbacks(callbacksToBeCalled, registrationData); | |
}); | |
} | |
function nodeMatchFunc(node, registrationData) { | |
return utils.matchesSelector(node, registrationData.selector); | |
} | |
leaveEvents = new MutationEvents(getLeaveObserverConfig, onLeaveMutation); | |
var mutationAdd = leaveEvents.add; | |
// override bindEvent function | |
leaveEvents.add = function(target, selector, options, callback) { | |
if (callback === undefined) { | |
callback = options; | |
options = leaveDefaultOptions; | |
} else { | |
options = utils.mergeArrays(leaveDefaultOptions, options); | |
} | |
return mutationAdd(target, selector, options, callback); | |
}; | |
return leaveEvents; | |
}; | |
// https://bit.ly/iframe-loaded | |
function getIFrameDocument(iframe, cb) { | |
var canAccess = (iframe) => { | |
try { | |
return Boolean(iframe.contentDocument); | |
} | |
catch(e){ | |
return false; | |
} | |
} | |
if (canAccess(iframe)) { | |
var src = iframe.src || "about:blank"; | |
var location = iframe.contentWindow.location.href; | |
var ready = iframe.contentDocument.readyState === "complete"; | |
if (ready && src == location) { | |
cb(iframe.contentDocument); | |
} else { | |
var load = () => { | |
if (canAccess(iframe)) { | |
cb(iframe.contentDocument); | |
} | |
iframe.removeEventListener('load', load); | |
} | |
iframe.addEventListener('load', load) | |
} | |
} | |
} | |
var hookMutationEventsType = (mutationEvents) => { | |
return (target, selector, options, callback) => { | |
var events = mutationEvents.add(target, selector, options, callback); | |
return () => { | |
events.forEach(event => mutationEvents.remove(event)); | |
} | |
} | |
} | |
var enableIFrameTraversal = mutationFunc => { | |
var unbindCallbacks = []; | |
return (target, selector, options, callback) => { | |
callback = callback || options; | |
options = callback == options ? {} : options; | |
if (!('iframes' in options)) { | |
options.iframes = true; | |
} | |
if (options.iframes) { | |
var unbind = arrive(target, 'iframe', { existing: true }, iframe => { | |
getIFrameDocument(iframe, doc => { | |
unbindCallbacks.push(mutationFunc(doc, selector, options, callback)); | |
}); | |
}) | |
unbindCallbacks.push(unbind); | |
} | |
unbindCallbacks.push(mutationFunc(target, selector, options, callback)); | |
return () => { | |
unbindCallbacks.forEach(cb => cb()) | |
} | |
} | |
} | |
var hookIntersectionEvents = (mutationFunc, entryTest) => { | |
return (target, selector, callback) => { | |
var observer = new IntersectionObserver(entries => { | |
entries.forEach(entry => { | |
if (entryTest(entry)) { | |
callback(entry.target); | |
} | |
}); | |
}); | |
var unbind = mutationFunc(target, selector, {existing: true}, e => { | |
observer.observe(e); | |
}); | |
return () => { | |
unbind(); | |
observer.disconnect(); | |
} | |
} | |
} | |
var leaveEvents = new LeaveEvents(); | |
var arriveEvents = new ArriveEvents(); | |
var leave = hookMutationEventsType(leaveEvents); | |
var arrive = hookMutationEventsType(arriveEvents); | |
var ileave = enableIFrameTraversal(leave); | |
var iarrive = enableIFrameTraversal(arrive); | |
// intersecting observers cannot reliably visibility, but they can detect if | |
// the element is tacking up pixels on the page - visible or otherwise | |
// https://developers.google.com/web/updates/2019/02/intersectionobserver-v2 | |
var appear = hookIntersectionEvents(arrive, entry => entry.isIntersecting); | |
var disappear = hookIntersectionEvents(leave, entry => !entry.isIntersecting); | |
var iappear = hookIntersectionEvents(iarrive, entry => entry.isIntersecting); | |
var idisappear = hookIntersectionEvents(ileave, entry => !entry.isIntersecting); | |
module.exports = { | |
arrive: arrive, | |
leave: leave, | |
iarrive: iarrive, | |
ileave: ileave, | |
appear: appear, | |
disappear: disappear, | |
iappear: iappear, | |
idisappear: idisappear | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment