Skip to content

Instantly share code, notes, and snippets.

@kylekyle
Created August 16, 2020 01:21
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save kylekyle/f61656677306286e1062810c53d1d4d6 to your computer and use it in GitHub Desktop.
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.
"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