UNPKG

js-utl

Version:

A collection of JS utility functions to be used across several applications or libraries.

903 lines (832 loc) 27.9 kB
/* * Copyright (c) 2022 Anton Bagdatyev (Tonix) * * 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. */ /** * Web application (browser) utility functions. */ import { config, uniqueId, isEmpty, isJSONString, isPlainObject } from "./core"; /** * Builds a form data instance or object recursively. * * @see https://stackoverflow.com/questions/22783108/convert-js-object-to-form-data#answer-42483509 * * @param {FormData|Object} formData Form data instance or JS POJO object. * @param {Array|Object} data JS POJO object or array with the form data to use to build the forma data. * @param {boolean} [shouldEncodeURIComponent] Optionally instructs the function if the parameters should be encoded * (keys and values) using "encodeURIComponent". * @param {string} [parentKey] Parent key for nested parameters (used internally for recursion). * @return {undefined} */ function buildFormData(formData, data, shouldEncodeURIComponent, parentKey) { if ( data && typeof data === "object" && !(data instanceof Date) && !(data instanceof File) ) { Object.keys(data).forEach(key => { buildFormData( formData, data[key], shouldEncodeURIComponent, parentKey ? `${parentKey}[${key}]` : shouldEncodeURIComponent ? encodeURIComponent(key) : key ); }); } else { // Leaf value. const value = data == null ? "" : shouldEncodeURIComponent ? encodeURIComponent(data) : data; formData instanceof FormData ? formData.append(parentKey, value) : (formData[parentKey] = value); } } /** * Constructs a query string from the given data. * This method works with nested objects/arrays. * * @param {Array|Object} data The object. * @return {string} The query string. */ export function buildQueryString(data) { const formData = {}; buildFormData(formData, data, true); // "true" instructs the function to encode URI parts (keys and values). const parts = []; for (const prop in formData) { parts.push(prop + "=" + formData[prop]); } return parts.join("&"); } /** * Converts an object (array or JS POJO object) to a form data instance. * * @param {Array|Object} data The data (array or POJO object). * @return {FormData} A form data instance. */ export function formData(data) { const formData = new FormData(); buildFormData(formData, data); return formData; } /** * Returns a new XMLHttpRequest or ActiveXObject object. * * @return {XMLHttpRequest|ActiveXObject} */ export const xhr = () => (window.ActiveXObject && new window.ActiveXObject("Microsoft.XMLHTTP")) || new XMLHttpRequest(); /** * Checks whether the network is reachable or not. * * @param {string} URI An eventual URI to use for the request. * NOTE: If omitted, then "config.checkNetworkURI" will be used, * or, if "config.checkNetworkURI" is not set, * the website's hostname will be used. * Also, in every case, A GET query string with a random parameter * is always appended to the URI. * @return {Promise} A promise which resolves to "true" if the network is reachable and to "false" otherwise. * Note that the returned promise always resolves to "true" when this code runs on localhost. */ export function checkNetwork(URI = null) { // Handle IE and more capable browsers. const xhrObj = xhr(); return new Promise(resolve => { // Issue request and handle response. try { xhrObj.onreadystatechange = () => { if (xhrObj.readyState == 4) { resolve( xhrObj.status >= 200 && (xhrObj.status < 300 || xhrObj.status === 304) ); } }; // Open new request as a HEAD to the root hostname with a random param to bust the cache. xhrObj.open( "HEAD", (URI || config.checkNetworkURI || "//" + window.location.hostname + (window.location.port != 80 ? ":" + window.location.port : "")) + "?rand=" + Math.floor((1 + Math.random()) * 0x10000), // 0x10000 = 2^16 true ); xhrObj.send(); } catch (error) { resolve(false); } }); } /** * Returns a promise which resolves when the network is available. * * NOTE: If the network is available, the promise will resolve almost immediately. * * @param {number} intervalMillisecs An interval in milliseconds to wait before the next network check. * @return {Promise} A promise. */ export function waitNetwork(intervalMillisecs = 3000) { return new Promise(resolve => { checkNetwork().then(isNetworkReachable => { if (isNetworkReachable) { resolve(); } else { const interval = setInterval(() => { checkNetwork().then(isNetworkReachable => { if (isNetworkReachable) { clearInterval(interval); resolve(); } }); }, intervalMillisecs); } }); }); } /** * Sets a cookie value. * * @see https://www.w3schools.com/js/js_cookies.asp * * @param {string} cname The cookie name. * @param {string} cvalue The cookie value. * @param {number|undefined} exdays Number of days after which the cookie expires, * or "undefined" to make the cookie expire at the end of the session. * @return {undefined} */ export function setCookie(cname, cvalue, exdays) { let expires = ""; if (exdays) { const d = new Date(); d.setTime(d.getTime() + exdays * 24 * 60 * 60 * 1000); expires = "expires=" + d.toUTCString() + ";"; } document.cookie = cname + "=" + cvalue + ";" + expires + "path=/"; } /** * Gets a cookie value. * * @see https://www.w3schools.com/js/js_cookies.asp * * @param {string} cname The cookie name. * @return {string|undefined} The cookie value or "undefined", if not set. */ export function getCookie(cname) { const name = cname + "="; const ca = document.cookie.split(";"); for (let i = 0; i < ca.length; i++) { let c = ca[i]; while (c.charAt(0) == " ") { c = c.substring(1); } if (c.indexOf(name) == 0) { return c.substring(name.length, c.length); } } return void 0; } /** * Unsets a cookie. * * @param {string} cname The cookie name. * @return {undefined} */ export function unsetCookie(cname) { setCookie(cname, "", -365); } /** * Tests whether a DOM element is in viewport or not. * * @param {Element} elem The DOM element. * @return {boolean} True if it is in viewport, false otherwise. */ export function isInViewport(elem) { const bounding = elem.getBoundingClientRect(); return ( bounding.top >= 0 && bounding.left >= 0 && bounding.bottom <= (window.innerHeight || document.documentElement.clientHeight) && bounding.right <= (window.innerWidth || document.documentElement.clientWidth) ); } /** * Tests if a DOM element is scrolled into the scrollable view of its parent scrollable container. * * @param {Element} elem The DOM element to test. * @param {Element} holder The DOM element of the scrollable container of the DOM element to test. * @param {number|Function} minPx Minimum number of pixels of the element's height which must be scrolled into the view * in order to consider the element to be scrolled into view. * If a function is given, it will receive the element's bounding client rect as the first argument * as well as the holder element's bounding client rect as the second argument and must return * the minimum number of pixels. * @return {boolean} True if the given element is scrolled into view, false otherwise. */ export function isScrolledIntoView(el, holder, minPx = 1) { const elRect = el.getBoundingClientRect(); const holderRect = holder.getBoundingClientRect(); const { top, bottom, height } = elRect; const minPxNumberFn = () => typeof minPx === "function" ? minPx(elRect, holderRect) : minPx; let bottomDiff; return top <= holderRect.top ? holderRect.top - top + minPxNumberFn() <= height : (bottomDiff = bottom - holderRect.bottom) < 0 ? true : holderRect.bottom >= top && minPx ? holderRect.bottom - top >= minPxNumberFn() : bottomDiff <= height; } /** * Tests if a DOM element has a vertical scrollbar. * * @param {Element} elem The DOM element. * @return {boolean} True it has a vertical scrollbar, false otherwise. */ export function hasVerticalScrollbar(elem) { const hasVerticalScrollbar = elem.scrollHeight > elem.clientHeight; return hasVerticalScrollbar; } /** * Tests if a DOM element has a horizontal scrollbar. * * @param {Element} elem The DOM element. * @return {boolean} True it has a horizontal scrollbar, false otherwise. */ export function hasHorizontalScrollbar(elem) { const hasHorizontalScrollbar = elem.scrollWidth > elem.clientWidth; return hasHorizontalScrollbar; } /** * Generates a unique ID which can be used for an element. * * @param {string|undefined} [elementUniqueIdPrefix] Local unique ID prefix which overrides the prefix * set on the "config" configuration object. * @return {string} The element unique ID. */ export function elementUniqueId(elementUniqueIdPrefix = void 0) { const uniqueElementIdSuffix = uniqueId(); return ( (elementUniqueIdPrefix || config.elementUniqueIdPrefix) + uniqueElementIdSuffix ); } /** * Gets the computed style of an element. * * @param {Element} element DOM element. * @return {CSSStyleDeclaration} The computed style. */ export function getElementComputedStyle(element) { return window.getComputedStyle(element); } /** * Gets element's inner dimensions (height and width without padding). * * @param {Element} element An element. * @return {Object} An object with "width" and "height" properties. */ export function elementInnerDimensions(element) { const computedStyle = getElementComputedStyle(element); let elementHeight = element.clientHeight; // Height with padding. let elementWidth = element.clientWidth; // Width with padding elementHeight -= parseFloat(computedStyle.paddingTop) + parseFloat(computedStyle.paddingBottom); elementWidth -= parseFloat(computedStyle.paddingLeft) + parseFloat(computedStyle.paddingRight); return { width: elementWidth, height: elementHeight, }; } /** * @type {HTMLTextAreaElement} */ let countTextareaLinesBuffer; /** * Returns the number of lines in a textarea, including wrapped lines. * * @see https://stackoverflow.com/questions/28905965/textarea-how-to-count-wrapped-lines-rows#answer-45252226 * * @param {HTMLTextAreaElement} textarea A textarea element. Note that the textarea should have an integer line height * to avoid rounding errors. */ export function countTextareaLines(textarea) { if (countTextareaLinesBuffer == null) { countTextareaLinesBuffer = document.createElement("textarea"); countTextareaLinesBuffer.style.border = "none"; countTextareaLinesBuffer.style.height = "0"; countTextareaLinesBuffer.style.overflow = "hidden"; countTextareaLinesBuffer.style.padding = "0"; countTextareaLinesBuffer.style.position = "absolute"; countTextareaLinesBuffer.style.left = "0"; countTextareaLinesBuffer.style.top = "0"; countTextareaLinesBuffer.style.zIndex = "-1"; document.body.appendChild(countTextareaLinesBuffer); } const cs = window.getComputedStyle(textarea); const pl = parseInt(cs.paddingLeft); const pr = parseInt(cs.paddingRight); let lh = parseInt(cs.lineHeight); // `cs.lineHeight` may return 'normal', which means line height = font size. if (isNaN(lh)) lh = parseInt(cs.fontSize); // Copy content width. countTextareaLinesBuffer.style.width = textarea.clientWidth - pl - pr + "px"; // Copy text properties. countTextareaLinesBuffer.style.font = cs.font; countTextareaLinesBuffer.style.letterSpacing = cs.letterSpacing; countTextareaLinesBuffer.style.whiteSpace = cs.whiteSpace; countTextareaLinesBuffer.style.wordBreak = cs.wordBreak; countTextareaLinesBuffer.style.wordSpacing = cs.wordSpacing; countTextareaLinesBuffer.style.wordWrap = cs.wordWrap; // Copy value. countTextareaLinesBuffer.value = textarea.value; let result = Math.floor(countTextareaLinesBuffer.scrollHeight / lh); if (result == 0) { result = 1; } return result; } /** * Checks if the scroll of an element is on the bottom. * * @param {Element} DOMNode Element. * @return {boolean} True if the scroll is on the bottom, false otherwise. */ export function isScrollOnBottom(DOMNode) { const ret = DOMNode.scrollTop + DOMNode.offsetHeight >= DOMNode.scrollHeight; return ret; } /** * Returns the default browser's vertical scrollbar width. * * @return {Number} The scrollbar width. */ export function getVerticalScrollBarWidth() { const scrollDiv = document.createElement("div"); scrollDiv.className = "vertical-scrollbar-measure"; const sheet = document.createElement("style"); sheet.innerHTML = "div.vertical-scrollbar-measure { width: 100px; height: 100px; overflow: scroll; position: absolute; top: -9999px; }"; document.body.appendChild(sheet); document.body.appendChild(scrollDiv); const scrollbarWidth = scrollDiv.offsetWidth - scrollDiv.clientWidth; document.body.removeChild(scrollDiv); const sheetParent = sheet.parentNode; sheetParent.removeChild(sheet); return scrollbarWidth; } /** * Tests if an element with "text-overflow: ellipsis;" has the ellipsis active * and therefore its text is truncated. * * @param {Element} e The DOM element. * @return {boolean} True if ellipsis are present, false otherwise. */ export function isEllipsisActive(e) { return e.offsetWidth < e.scrollWidth; } /** * A fallback function to copy a text to clipboard. * * @param {string} text The text to copy. * @param {Function} [onSuccess] An optional callback to execute on success. * @param {Function} [onFailure] An optional callback to execute on failure. * @return {undefined} */ function fallbackCopyTextToClipboard(text, onSuccess, onFailure) { const bodyScrollTop = document.documentElement.scrollTop || document.body.scrollTop; const textArea = document.createElement("textarea"); textArea.value = text; document.body.appendChild(textArea); textArea.focus(); textArea.select(); try { const successful = document.execCommand("copy"); if (successful) { onSuccess && onSuccess(); } else { onFailure && onFailure(); } } catch (err) { onFailure && onFailure(err); } (document.body.removeChild(textArea) && document.documentElement.scrollTop && (document.documentElement.scrollTop = bodyScrollTop)) || (document.body.scrollTop = bodyScrollTop); } /** * Copies a text to clipboard. * * @param {string} text The text to copy. * @param {Function} [onSuccess] An optional callback to execute on success. * @param {Function} [onFailure] An optional callback to execute on failure. * @return {undefined} */ export function copyTextToClipboard(text, onSuccess, onFailure) { if (!navigator.clipboard) { fallbackCopyTextToClipboard(text, onSuccess, onFailure); return; } navigator.clipboard.writeText(text).then( function () { onSuccess && onSuccess(); }, function (err) { onFailure && onFailure(err); } ); } /** * Asynchronously invokes a callback multiple times, each in its own animation frame. * * @param {number} n The number of times the callback should be invoked. * @param {Function} callback The callback to invoke. * @return {*} The identifier of the first animation frame or "n" if it is falsy during the outermost call. */ export const rAFLooper = (n, callback) => n && window.requestAnimationFrame(() => (callback(), rAFLooper(--n, callback))); /** * Requests a predefined number of animation frames and executes a callback after. * * @param {number} count The number of animation frames to request before executing the callback. * @param {Function} callback The callback to execute after "count" animation frames have been requested. * @return {number} The identifier of the first animation frame. */ export const nestedRAF = (count, callback) => { let c = count || 1; const innerCallback = () => { c--; if (!c) { callback(); return; } window.requestAnimationFrame(innerCallback); }; return window.requestAnimationFrame(innerCallback); }; /** * Returns the raw contents of the URI fragment (i.e. everything after the hash ("#") character). * * @param {string} URIFragment The URI fragment. * @return {string} The raw contents of the URI fragment. */ export function getRawURIFragment(URIFragment) { const fragment = (URIFragment || window.location.hash).replace(/^#/, ""); return fragment; } /** * Returns the decoded contents of a URI fragment (i.e. everything after the hash ("#") character). * * @param {string} URIFragment The URI fragment. * @return {string} The contents of the URI fragment, decoded. */ export function getDecodedURIFragment(URIFragment) { const fragment = decodeURIComponent(getRawURIFragment(URIFragment)); return fragment; } /** * Appends an encoded JSON fragment to a URI. * * @param {string} URI The URI. * @param {*} data Data to encode in JSON format. */ export function appendEncodedJSONFragmentToURI(URI, data) { return URI + "#" + encodeURIComponent(JSON.stringify(data)); } /** * Returnes the decoded JSON data eventually stored in the URI fragment. * * @param {*} defaultData Default data to return if either the URI fragment is missing or * the content of the URI fragment is not a valid JSON-encoded string. * @return {*} The decoded JSON data or "defaultData". */ export function getDecodedJSONFromFragmentURI(defaultData = null) { const fragment = window.location.hash; if (!isEmpty(fragment)) { const decodedFragment = getDecodedURIFragment(fragment); if (isJSONString(decodedFragment)) { return JSON.parse(decodedFragment); } } return defaultData; } /** * Parses a multidimensional query string and returns an object with the parsed args. * * @see https://stackoverflow.com/questions/8648892/convert-url-parameters-to-a-javascript-object#answer-44713056 * * @param {string} str The query string. * @param {Object} [array] The base object to use (or a new object if omitted or falsy). * @return {Object} The object with the parsed data. */ export function parseQueryStringArgsMultiDim(str, array) { if (!str) { str = window.location.search.substr(1); } let i, j, ct, p, lastObj, obj, undef, chr, tmp, key, value, postLeftBracketPos, keys, keysLen; const strArr = String(str).replace(/^&/, "").replace(/&$/, "").split("&"), sal = strArr.length, fixStr = function (str) { return decodeURIComponent(str.replace(/\+/g, "%20")); }; if (!array) { array = {}; } for (i = 0; i < sal; i++) { tmp = strArr[i].split("="); key = fixStr(tmp[0]); value = tmp.length < 2 ? "" : fixStr(tmp[1]); while (key.charAt(0) === " ") { key = key.slice(1); } if (key.indexOf("\x00") > -1) { key = key.slice(0, key.indexOf("\x00")); } if (key && key.charAt(0) !== "[") { keys = []; postLeftBracketPos = 0; for (j = 0; j < key.length; j++) { if (key.charAt(j) === "[" && !postLeftBracketPos) { postLeftBracketPos = j + 1; } else if (key.charAt(j) === "]") { if (postLeftBracketPos) { if (!keys.length) { keys.push(key.slice(0, postLeftBracketPos - 1)); } keys.push(key.substr(postLeftBracketPos, j - postLeftBracketPos)); postLeftBracketPos = 0; if (key.charAt(j + 1) !== "[") { break; } } } } if (!keys.length) { keys = [key]; } for (j = 0; j < keys[0].length; j++) { chr = keys[0].charAt(j); if (chr === " " || chr === "." || chr === "[") { keys[0] = keys[0].substr(0, j) + "_" + keys[0].substr(j + 1); } if (chr === "[") { break; } } obj = array; for (j = 0, keysLen = keys.length; j < keysLen; j++) { key = keys[j].replace(/^['"]/, "").replace(/['"]$/, ""); lastObj = obj; if ((key !== "" && key !== " ") || j === 0) { if (obj[key] === undef) { obj[key] = {}; } obj = obj[key]; } else { // To insert new dimension ct = -1; for (p in obj) { if (Object.prototype.hasOwnProperty.call(obj, p)) { if (+p > ct && p.match(/^\d+$/g)) { ct = +p; } } } key = ct + 1; } } lastObj[key] = value; } } /* ===== */ /* * This code converts the object properties to arrays if they are objects with consecutive integer keys * from 0 to n, where n is the number of properties of that object minus one * (i.e. it converts meaningful objects which are to be interpreted as arrays to arrays). */ const fnNormalizeObjToArrayIfPropsAreConsecutiveIntsFrom0 = function (obj) { const keys = Object.keys(obj); const truthMap = {}; for (let i = 0; i < keys.length; i++) { truthMap[i] = true; } const array = []; for (let i = 0; i < keys.length; i++) { const prop = keys[i] + ""; if (!prop.match(/^[0-9]+$/)) { return obj; } const intProp = Number(prop); if (truthMap[intProp]) { array[intProp] = obj[prop]; delete truthMap[intProp]; } else { return obj; } } if (Object.keys(truthMap).length === 0) { return array; } else { return obj; } }; const fnNormalizeToArrayIfNeeded = function fnNormalizeToArrayIfNeeded(obj) { for (const prop in obj) { if (isPlainObject(obj[prop])) { obj[prop] = fnNormalizeObjToArrayIfPropsAreConsecutiveIntsFrom0( obj[prop] ); fnNormalizeToArrayIfNeeded(obj[prop]); } } }; /* /===== */ fnNormalizeToArrayIfNeeded(array); return array; } /** * Gets the query string arguments of the current location in a multidimensional fashion * (multidimension-aware). * * @return {Object} An object representing the query string arguments. */ export function getQueryStringArgsMultiDim() { const obj = {}; parseQueryStringArgsMultiDim(location.search.substring(1), obj); return obj; } /** * Focuses an input without scrolling. * * @see https://stackoverflow.com/questions/4963053/focus-to-input-without-scrolling * * @param {Element} elem The DOM element. */ export const cursorFocus = function (elem) { let x, y; // More sources for scroll x, y offset. if (typeof window.pageXOffset !== "undefined") { x = window.pageXOffset; y = window.pageYOffset; } else if (typeof window.scrollX !== "undefined") { x = window.scrollX; y = window.scrollY; } else if ( document.documentElement && typeof document.documentElement.scrollLeft !== "undefined" ) { x = document.documentElement.scrollLeft; y = document.documentElement.scrollTop; } else { x = document.body.scrollLeft; y = document.body.scrollTop; } elem.focus(); if (typeof x !== "undefined") { window.scrollTo(x, y); } }; /** * Detects wrapped elements. * * @param {string|Element[]} classNameOrElements A class name (with or without the leading dot) or the DOM elements to check. * @return {Element[]} The wrapped DOM elements. */ export function detectWrapped(classNameOrElements) { let elements; if (typeof classNameOrElements === "string") { classNameOrElements = classNameOrElements.replace(/^\./, ""); elements = document.getElementsByClassName(classNameOrElements); } else { elements = classNameOrElements; } const wrapped = []; let prev = {}; let curr = {}; for (let i = 0; i < elements.length; i++) { const element = elements[i]; curr = element.getBoundingClientRect(); if (prev && prev.top < curr.top) { wrapped.push(element); } prev = i === 0 ? curr : prev; } return wrapped; } /** * Gets the maximum nesting level of an element (or of the whole DOM if "document.body" is given as parameter).abs * * @param {Element} elem The DOM element from which to start identifying the maximum nesting level. * @return {number} The maximum nesting level, starting from 0 if the given element has no children. */ export function maxNestingLevel(el) { if (!el.children) { return 0; } let max = -1; for (let i = 0; i < el.children.length; i++) { const nestingLevel = maxNestingLevel(el.children[i]); if (nestingLevel > max) { max = nestingLevel; } } return max + 1; } /** * @type {RegExp} */ const REGEXP_SCROLL_PARENT = /^(visible|hidden)/; /** * Get the first scrollable ancestor of an element. * * @param {Element} el The element to use as the base from which to determine its first scrollable ancestor. * @return {Element} The first scrollable ancestor element scroll, or the document body. */ export const getScrollableAncestor = el => !(el instanceof HTMLElement) || typeof window.getComputedStyle !== "function" ? null : el.scrollHeight >= el.clientHeight && !REGEXP_SCROLL_PARENT.test( window.getComputedStyle(el).overflowY || "visible" ) ? el : getScrollableAncestor(el.parentElement) || document.scrollingElement || document.body; /** * Smoothly scrolls to the top of a scrollable element or the browser's window. * * @param {Element} [el] The element. Defaults to "window". * @return {undefined} */ export const smoothScrollToTop = (el = window) => el.scroll({ top: 0, behavior: "smooth", }); /** * Downloads a file without opening a new browser's tab. * * @see https://stackoverflow.com/questions/1066452/easiest-way-to-open-a-download-window-without-navigating-away-from-the-page#answer-43523297 * * @param {string} fileURI The URI of the file to download. * @return {undefined} */ export function downloadFile(fileURI) { var link = document.createElement("a"); link.href = fileURI; link.download = fileURI.substr(fileURI.lastIndexOf("/") + 1); link.click(); }