/**
* Permission is hereby granted, free of charge, to any person
* obtaining a copy of this software and associated documentation
* files (the "Software"), to deal in the Software without
* restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the
* Software is furnished to do so, subject to the following
* conditions:
* The above copyright notice and this permission notice shall be
* included in all copies or substantial portions of the Software.
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
* OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
* HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
* WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
* OTHER DEALINGS IN THE SOFTWARE.
*/
/**
* @namespace
* @alias Sive
* @description Une simple librairie JS qui permet de détecter l'arrivée ou la disparition d'un élément de la vue du navigateur, permettant d'effectuer des animations de dévoilement par exemple.
* <br><br><em><b>Note</b>: This library uses document.querySelectorAll() to select the targeted elements</em>
* @author TIMMatane
* @version 1.0.0 (Janvier 2018)
*/
class Sive {
constructor() {
this._elemEvents = {};
this._elemEventsIndex = 0;
this._isListening = false;
this._shouldAutoTriggerAtFirstScroll = false;
this._autoTriggerPreviousElements = true;
this._scrollBuffer = 0;
this._bypassedScroll = 0;
this._height = null;
this._scrollHeight = null;
this._firstTimeScroll = false;
// Binding this to methods
this._onScroll = this._onScroll.bind(this);
}
/**
* @alias Sive.do
* @type {Function}
* @description This is the principal way to use this library. Adds a class to all nodes returned by the document.querySelectorAll() method when they become partially visibles in relation to the browser's viewport.<br>
* <em><b>Note</b>: Will be fired once, then automatically unregistered</em>
* @param {String} elemQS The document.querySelectorAll() selector string of the targeted element
* @param {String} className The class name that should be added to the targeted elements when they come into view
* @returns {undefined} Returns nothing
* @example <caption>How to easily add a class to a newly visible HTMLElement</caption>
* Sive.do("#myElementId", "myClassName");
* Sive.do("div", "myClassName");
*/
do(elemQS, className) {
this.doMore(elemQS, null, false, false, undefined, className);
}
/**
* @alias Sive.doMore
* @type {Function}
* @description A more complete way to react to in view/out of view events. Adds a Sive for all nodes returned by the document.querySelectorAll() method. It can also trigger a callback ({@link Sive~ScrollEventCallback}) function <b>each time</b> one of the targeted elements becomes visible or invisible in relation to the browser's viewport.
* <br><em><b>Note</b>: Will be fired each time the element's visibility changes as it is not unregistered automatically.</em>
* @param {String} elemQS The querySlectorAll string of the target elements
* @param {ScrollEventCallback} [eventCallback = null] The callback function to call when an element is in or out of the browser's viewport.
* <br><em><b>Note</b>: If not set, <strong>className</strong> will need to be defined for anything to happen.</em>
* @see ScrollEventCallback
* @param {Boolean} [fullyVisibleX = false] Should the element be fully visible on X, or partially?
* @param {Boolean} [fullyVisibleY = false] Should the element be fully visible on Y, or partially?
* @param {Object} [offset = {}] Object setting the offset in pixels to use as the viewport
* @param {Number} [offset.top = 0] Offset in pixels from the top of the browser's viewport
* @param {Number} [offset.bottom = 0] Offset in pixels from the bottom of the browser's viewport
* @param {Number} [offset.left = 0] Offset in pixels from the left of the browser's viewport
* @param {Number} [offset.right = 0] Offset in pixels from the right of the browser's viewport
* @param {String} [className = ""] The class name that should be added to the elements when they comes into view the first time only.
* <br><em><b>Note</b>: Will be used only if <strong>eventCallback</strong> is not defined.</em>
* @returns {undefined} Returns nothing
* @example <caption>This example uses a callback function to add or remove a border on the element depending of its visibility. It also forces element to be fully visible on both axis to be considered "in view"</caption>
* Sive.doMore(".myElementsClass", viewCallback, true, true);
* function viewCallback(event, auto){
* if (event.inview){
* event.elem.style.border = "2px solid #ff0000";
* }else{
* event.elem.style.border = "none";
* }
* }
*
* @example <caption>This example uses a class name to be added when elements come into view. It also use an object to virtually redefine the browser's viewport dimensions.
* <br><em><b>Note</b>: This event will be fired only once as a class name is used instead of a callback function</em></caption>
* Sive.doMore(".myElementsClass", null, false, false, {top:100, left:200, right:200, bottom:20 }, "myClassName");
*/
doMore(elemQS, eventCallback, fullyVisibleX = false, fullyVisibleY = false, offset = { top: 0, right: 0, bottom: 0, left: 0 }, className = "") {
const nodes = document.querySelectorAll(elemQS);
nodes.forEach((item, index) => {
const internalID = `${elemQS}${this._elemEventsIndex}`;
this._elemEvents[internalID] = {
elem: item,
callback: eventCallback,
inview: false,
fullyVisibleX,
fullyVisibleY,
offset,
internalID,
index: this._elemEventsIndex,
localIndex: index,
className,
querySelector: elemQS,
scrollEvent: {
elem: item,
inview: false,
internalID,
index: this._elemEventsIndex,
localIndex: index,
autoTriggered: false,
},
};
this._elemEventsIndex++;
});
}
/**
* @alias Sive.removeEventForElement
* @type {Function}
* @see Sive~ScrollEventCallback
* @description Unregisters a Sive for a previously registered HTMLElement.
* <br><em><b>Note: </b> As this library creates an internal ID for each of the targeted elements, this method is only usefull inside a ScrollEventCallback call. The later will give you access to the internal ID created for it.</em>
* @param {String} elemInternalID The ScrollEventCallback event.internalID parameter
* @returns {Boolean} Returns true if the Sive was found and unregistered successfully. Otherwise returns false.
* @throws Will throw a warning in the console if the element is not found while trying to remove the Sive for wich it is associated.
* @example <caption>This example shows how to use Sive.removeEventForElement inside a ScrollEventCallback call to unregister an element.</caption>
* Sive.doMore(".myElementsClass", viewCallback);
* function viewCallback(event, auto){
* if (event.inview){
* event.elem.style.border = "2px solid #ff0000";
* }else{
* event.elem.style.border = "none";
* Sive.removeEventForElement(event.internalID);
* }
* }
*/
removeEventForElement(elemInternalID) {
if (this._elemEvents[elemInternalID]) {
delete this._elemEvents[elemInternalID];
this._elemEventsIndex--;
return true;
} else {
console.warn(`Sive.js : The element target with internal ID ${elemInternalID} was not found!`);
return false;
}
}
/**
* @alias Sive.removeEventForSelector
* @type {Function}
* @description Unregisters a Sive using the same selector used for the previously registered elements
* @param {String} elemQS The quesrySelectorAll string of the elements to unregister
* @returns {Boolean} Returns true if the ScrollEvents were found and removed successfully. Otherwise returns false and throws a warning in the console.
* @example <caption>This example shows how to use Sive.removeEventForSelector to unregister all elements previously targeted by a <b>div</b> selector. </caption>
* Sive.removeEventForSelector("div");
*/
removeEventForSelector(elemQS) {
let success = false;
Object.keys(this._elemEvents).forEach((key) => {
if (this._elemEvents[key].querySelector === elemQS) {
this.removeEventForElement(this._elemEvents[key].internalID);
success = true;
}
});
if (!success) {
console.warn(`Sive.js : The element target with selector ${elemQS} was not found!`);
}
return success;
}
/**
* @alias Sive.startListening
* @type {Function}
* @description Starts listening to scroll event on the window, add a scroll event listener to window object<br>
* <em><b>Note: </b>If the page was scrolled before a reload of the page, it will trigger window.scroll event when refreshing is done, invaliding need of autoCheck (except when the page is at top).</em>
* @param {Boolean} [autoCheckAtStart = true] Should the elements trigger visible or hidden event at start (before scrolling)?
* @param {Boolean} [autoTriggerPreviousElements = true] Should the ScrollEvent for non visible elements higher in Y axis (than the current scroll position) be triggered as if they were visibles?
* @param {Number} [scrollBuffer = 5] How many window.scroll event to bypass before it is actually taken into account to trigger a Sive? This should be 0 if you want to have the best precision.
* @returns {undefined} Returns nothing
*/
startListening(autoCheckAtStart = true, autoTriggerPreviousElements = true, scrollBuffer = 5) {
this._shouldAutoTriggerAtFirstScroll = autoCheckAtStart;
this._autoTriggerPreviousElements = autoTriggerPreviousElements;
this._scrollBuffer = scrollBuffer;
this._height = window.innerHeight || document.documentElement.clientHeight || document.body.clientHeight;
this._scrollHeight = Math.max(document.body.scrollHeight, document.documentElement.scrollHeight, document.body.offsetHeight, document.documentElement.offsetHeight, document.body.clientHeight, document.documentElement.clientHeight);
this._isListening = true;
window.addEventListener("scroll", this._onScroll, true);
if (this._shouldAutoTriggerAtFirstScroll && (window.scrollY || document.documentElement.scrollTop || window.pageYOffset) === 0) {
setTimeout(() => this._loopAllElemEvents(true), 100);
}
}
/**
* @alias Sive.stopListening
* @type {Function}
* @description Stops listening to scroll event on the window, removes the scroll event from the window object
* @param {Boolean} [forceRemoveAll = false] If true, will also delete all events registered on the page
* @returns {undefined} Returns nothing
*/
stopListening(forceRemoveAll = false) {
if (forceRemoveAll) {
this._removeAllEvents();
}
this._isListening = false;
window.removeEventListener("scroll", this._onScroll);
}
/**
* @alias Sive.isListening
* @type {Function}
* @description Checks if listening to scroll event on the window is active
* @see Sive.startListening
* @returns {Boolean} Returns true if Sive lib is currently listening on the page, else false
*/
isListening() {
return this._isListening;
}
/**
* Called when scroll happens
* @private
* @type {Function}
* @param {UIEvent} e
* @returns {undefined}
*/
_onScroll() {
const top = window.scrollY || document.documentElement.scrollTop || window.pageYOffset;
if (!this._firstTimeScroll) {
this._loopAllElemEvents(false);
this._firstTimeScroll = true;
} else {
this._bypassedScroll++;
if (this._bypassedScroll >= this._scrollBuffer || top === 0 || top + this._height > this._scrollHeight) {
this._loopAllElemEvents(false);
this._bypassedScroll = 0;
}
}
}
/**
* _loopAllElemEvents();
* Loops through all registered HTMLElement to check wich are in view and which are not
* @private
* @type {Function}
* @param {Boolean} isAuto Was the trigger automatically called when reloading the page (as the page was already scrolled)
* @returns {undefined}
*/
_loopAllElemEvents(isAuto) {
Object.values(this._elemEvents).forEach((elemEvent) => {
const isBeforeInView = this._isBeforeInView(elemEvent.elem);
const inview = this._isInView(elemEvent.elem, elemEvent.fullyVisibleX, elemEvent.fullyVisibleY, elemEvent.offset);
console.log(elemEvent.inview, this._autoTriggerPreviousElements, isBeforeInView, elemEvent.className);
if (elemEvent.inview !== inview) {
elemEvent.inview = inview;
if (typeof elemEvent.callback === "function") {
elemEvent.scrollEvent.inview = inview;
elemEvent.scrollEvent.autoTriggered = false;
elemEvent.callback(elemEvent.scrollEvent, isAuto);
} else if (inview && elemEvent.className !== "") {
elemEvent.elem.className += elemEvent.elem.className === "" ? elemEvent.className : ` ${elemEvent.className}`;
this.removeEventForElement(elemEvent.internalID);
}
} else if (!elemEvent.inview && this._autoTriggerPreviousElements && isBeforeInView && elemEvent.className !== "") {
elemEvent.elem.className += elemEvent.elem.className === "" ? elemEvent.className : ` ${elemEvent.className}`;
this.removeEventForElement(elemEvent.internalID);
}
});
}
/**
* _isInView();
* Will check and return true if an HTMLElement is is view
* @private
* @type {Function}
* @param {HTMLElement} element The element to check
* @param {Boolean} fullyVisibleX Should the element be fully visible on X
* @param {Boolean} fullVisibleY Should the element be fully visible on Y
* @param {Object} offset An object with four properties (top, right, bottom, left) used as virtual viewport
* @returns {Boolean}
*/
_isInView(element, fullyVisibleX, fullyVisibleY, offset) {
const rect = element.getBoundingClientRect();
const html = document.documentElement;
const isPartiallyVisibleX = rect.right >= offset.left && rect.left <= (window.innerWidth || html.clientWidth) - offset.right;
const isPartiallyVisibleY = rect.bottom >= offset.top && rect.top <= (window.innerHeight || html.clientHeight) - offset.bottom;
const isFullyVisibleX = rect.left >= offset.left && rect.right <= (window.innerWidth || html.clientWidth) - offset.right;
const isFullyVisibleY = rect.top >= offset.top && rect.bottom <= (window.innerHeight || html.clientHeight) - offset.bottom;
return (fullyVisibleX ? isFullyVisibleX : isPartiallyVisibleX) && (fullyVisibleY ? isFullyVisibleY : isPartiallyVisibleY);
}
/**
* Will check if an element is higher in Y axis in relation to the highest visible element
* @private
* @type {Function}
* @param {HTMLElement} element
* @returns {Boolean} Returns true if the element is higher in Y axis in relation to the highest visible element, else returns false
*/
_isBeforeInView(element) {
const rect = element.getBoundingClientRect();
return rect.y <= -rect.height;
}
/**
* Will loop through all registered event on th page and remove them.
* @private
* @type {Function}
* @returns {undefined} Returns nothing
*/
_removeAllEvents() {
Object.keys(this._elemEvents).forEach((key) => this.removeEventForElement(key));
this._elemEvents = {};
}
/**
* The callback function will receive thoses params when an HTMLElement becomes visible/invisible.
* @callback ScrollEventCallback
* @see Sive.doMore
* @param {Object} inViewEvent Event object containing information about the current event.
* @param {HTMLElement} inViewEvent.elem The reference to the currently processed HTMLElement.
* @param {Boolean} inViewEvent.inview Is the HTMLElement visible or invisible when the event is processed.
* @param {String} inViewEvent.internalID The string used as an internal ID for this element. Only usefull with {@link Sive.removeEventForElement} method.
* @param {Number} inViewEvent.index The numerical index of the current HTMLElement in the global array of all targeted elements in the current page.
* @param {Number} inViewEvent.localIndex The numerical index of the current HTMLElement in the local array of all targeted elements by the document.querySelectorAll method
* @param {Boolean} inViewEvent.autoTriggered If the Sive was triggered automatically because the registered element was higher in Y axis than the highest currently visible element.
* @param {Boolean} isAuto Was the Sive automatically called when reloading the page (as the page was already scrolled, or forced).
*/
}
// Make the class available globally
export default new Sive();