Last active
July 4, 2024 21:46
-
-
Save isocroft/e39c96f4eae67c431916636ad273e7fe to your computer and use it in GitHub Desktop.
A collection of very useful helper functions for frontend working with both React and Vanilla JS
This file contains 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
import { lazy } from "react"; | |
import type { JSX } from "react"; | |
/** | |
* lazyWithRetry: | |
* | |
* @param {() => (() => JSX.Element)} componentImport | |
* @returns {() => JSX.Element} | |
* | |
* @see https://gist.github.com/raphael-leger/4d703dea6c845788ff9eb36142374bdb#file-lazywithretry-js | |
* | |
*/ | |
export const lazyWithRetry = <Props extends Record<string, unknown>>( | |
componentImport: () => Promise<{ default: (props: Props) => JSX.Element | null }>, | |
retryStorageKey = "page-has-been-force-refreshed" | |
) => | |
lazy<React.ComponentType<Props | undefined>>(async () => { | |
const pageHasAlreadyBeenForceRefreshed = JSON.parse( | |
window.sessionStorage.getItem( | |
retryStorageKey | |
) || "false" | |
) as boolean; | |
try { | |
const component = await componentImport(); | |
window.sessionStorage.setItem( | |
retryStorageKey, | |
"false" | |
); | |
return component; | |
} catch (error) { | |
if (!pageHasAlreadyBeenForceRefreshed) { | |
/* @HINT: Assuming that the user is not on the latest version of the application. */ | |
/* @HINT: Let's refresh the page immediately. */ | |
window.sessionStorage.setItem( | |
retryStorageKey, | |
"true" | |
); | |
window.location.reload(); | |
} else { | |
/* @HINT: If we get here, it means the page has already been reloaded */ | |
/* @HINT: Assuming that user is already using the latest version of the application. */ | |
/* @HINT: Let's let the application crash and raise the error. */ | |
throw error; | |
} | |
return { default: () => null }; | |
} | |
}); | |
}; | |
/* @EXAMPLE: lazyWithRetry() */ | |
/** | |
* componentLoader: | |
* | |
* @param {() => Promise} lazyComponent | |
* @param {Number} attemptsLeft | |
* | |
* @returns | |
* | |
* @see https://medium.com/@botfather/react-loading-chunk-failed-error-88d0bb75b406 | |
* | |
*/ | |
export function componentLoader<M extends { default: () => JSX.Element | null }>( | |
lazyComponent: () => Promise<M>, | |
attemptsLeft = 3 | |
) { | |
return new Promise<M>((resolve, reject) => { | |
lazyComponent() | |
.then(resolve) | |
.catch((error) => { | |
/* @HINT: let us retry after 1500 milliseconds */ | |
setTimeout(() => { | |
if (attemptsLeft === 1) { | |
reject(error); | |
return; | |
} | |
componentLoader(lazyComponent, attemptsLeft - 1).then(resolve, reject); | |
}, 1500); | |
}); | |
}); | |
} | |
/** | |
* fileExtension: | |
* | |
* @param {String} urlOrFileType | |
* | |
* @returns {String} | |
*/ | |
export const fileExtension = (urlOrFileType?: string | null): string => { | |
let extension = "blob"; | |
if ( | |
urlOrFileType === "image/png" || | |
urlOrFileType === "image/jpeg" || | |
urlOrFileType === "image/jpg" || | |
urlOrFileType === "image/svg+xml" || | |
urlOrFileType === "application/pdf" || | |
urlOrFileType === "text/plain" || | |
urlOrFileType === "application/json" || | |
urlOrFileType === "text/javascript" || | |
urlOrFileType === "text/css" || | |
urlOrFileType === "text/csv" || | |
urlOrFileType === "text/x-csv" || | |
urlOrFileType === "application/vnd.ms-excel" || | |
urlOrFileType === "application/csv" || | |
urlOrFileType === "application/x-csv" || | |
urlOrFileType === "text/comma-separated-values" || | |
urlOrFileType === "text/x-comma-separated-values" || | |
urlOrFileType === "text/tab-separated-values" || | |
urlOrFileType === "application/octet-stream" | |
) { | |
[ extension ] = (urlOrFileType || "/").split("/").reverse(); | |
if (extension === "octet-stream") { | |
extension = "blob"; | |
} | |
if ([ | |
"x-csv", | |
"vnd.ms-excel", | |
"tab-separated-values", | |
"comma-separated-values", | |
"x-comma-separated-values"].includes(extension)) { | |
extension = "csv"; | |
} | |
} else if (typeof urlOrFileType === "string") { | |
const [ urlBaseName ] = urlOrFileType.split(/[#?]/); | |
[ extension ] = urlBaseName.split(".").reverse(); | |
} | |
return extension === "javascript" ? "js" : extension; | |
}; | |
/* @EXAMPLE: fileExtension("image/png") */ | |
/** | |
* composeClasses: | |
* | |
* @param {Array.<*>} styles | |
* | |
* @returns {String} | |
*/ | |
export const composeClasses = (...styles: unknown[]): string => { | |
return styles.filter((item) => item).join(' ') | |
} | |
/* @EXAMPLE: <AvatarWrapper className={composeClasses('text-align-center', 'position-relative')} /> */ | |
/** | |
* bloToataURL: | |
* | |
* @param {Blob} blob | |
* | |
* @returns {Promise<String>} | |
* | |
*/ | |
export const blobToDataURL = (blob: Blob): Promise<string> => { | |
return new Promise((fulfill: Function, reject: Function) => { | |
let reader: FileReader = new FileReader() | |
reader.onerror = (ev: ProgressEvent<FileReader>) => | |
reject(ev.target?.error) | |
reader.onload = () => fulfill(reader.result) | |
reader.readAsDataURL(blob) | |
}) | |
} | |
/** | |
* dataURLtoObjectURL: converts a data URI to an object URL | |
* | |
* | |
* @param {String} dataURL | |
* | |
* @returns {String} | |
* | |
* @see https://en.wikipedia.org/wiki/Data_URI_scheme/ | |
*/ | |
export const dataURLtoObjectURL = (dataURL?: string): string => { | |
const [ mimeType, base64String ] = (dataURL || ",").split(","); | |
const [, contentTypeDataPrefix ] = mimeType.split(":") || [, ";"]; | |
const [ contentType ] = contentTypeDataPrefix | |
? contentTypeDataPrefix.split(";") | |
: ["application/octet-stream"]; | |
return URL.createObjectURL( | |
base64StringToBlob(base64String, contentType) | |
); | |
}; | |
/** | |
* dataURLtoObjectBlob: converts a data URI to a blob | |
* | |
* | |
* @param {String} dataURL | |
* | |
* @returns {Blob} | |
* | |
* @see https://en.wikipedia.org/wiki/Data_URI_scheme/ | |
* @see https://en.wikipedia.org/wiki/Binary_large_object/ | |
*/ | |
export const dataURLtoObjectBlob = (dataURL?: string): Blob => { | |
const [ mimeType, base64String ] = (dataURL || ",").split(","); | |
const [, contentTypeDataPrefix ] = mimeType.split(":") || [, ";"]; | |
const [ contentType ] = contentTypeDataPrefix | |
? contentTypeDataPrefix.split(";") | |
: ["application/octet-stream"]; | |
return base64StringToBlob(base64String, contentType); | |
}; | |
/*! | |
* @EXAMPLE: | |
* | |
* const fileBlob = dataURItoObjectBlob( | |
* "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVQYV2NgYAAAAAMAAWgmWQ0AAAAASUVORK5CYII=" | |
* ); | |
* console.log(fileBlob); // Blob {size: 68, type: 'image/png'} | |
*/ | |
/** | |
* blobToFile: | |
* | |
* @param {Blob | Undefined} theBlob | |
* @param {String | Null | Undefined} fileName | |
* @param {Boolean} useCast | |
* | |
* @returns {File} | |
* | |
*/ | |
export const blobToFile = (theBlob?: Blob, fileName?: string | null, useCast = false): File => { | |
const todaysDate = new Date(); | |
const defaultFileName = `${todaysDate.getTime()}_${Math.random() * 1}`; | |
const defaultFileExtension = `.${fileExtension(theBlob?.type)}`; | |
const fullFileName = defaultFileName + defaultFileExtension; | |
if (!(theBlob instanceof window.Blob)) { | |
return new File([""], fullFileName); | |
} | |
const blob = <Blob & { lastModifiedDate: Date, name: string }>theBlob; | |
blob.lastModifiedDate = new Date(); | |
blob.name = fileName || fullFileName; | |
return useCast ? <File>theBlob : new File([theBlob], fileName || fullFileName); | |
}; | |
/* @EXAMPLE: blobToFile(new Blob(['hello!'], { type: 'text/plain' }), "text.txt") */ | |
/** | |
* base64StringToBlob: | |
* | |
* @param {String} base64Data | |
* @param {String} contentType | |
* @param {Number} sliceSize | |
* | |
* @returns {Blob} | |
* | |
*/ | |
export const base64StringToBlob = (base64Data: string, contentType?: string | null, sliceSize = 512) => { | |
const $contentType = contentType || ""; | |
const byteCharacters = atob(base64Data); | |
const byteArrays = []; | |
for (let offset = 0; offset < byteCharacters.length; offset += sliceSize) { | |
const slice = byteCharacters.slice(offset, offset + sliceSize); | |
const byteNumbers = new Array(slice.length); | |
for (let i = 0; i < slice.length; i++) { | |
byteNumbers[i] = slice.charCodeAt(i); | |
} | |
const byteArray = new Uint8Array(byteNumbers); | |
byteArrays.push(byteArray); | |
} | |
return new Blob(byteArrays, { type: $contentType }); | |
}; | |
/* @EXAMPLE: const urlString = blobToDataURL(new Blob(['hello world'], { type: 'text/plain' })) */ | |
/** | |
* getJpegBlob: | |
* | |
* @param {HTMLCanvasElement | null} canvas | |
* | |
* @return {Promise<Blob | null>} | |
* | |
*/ | |
export function getJpegBlob(canvas: HTMLCanvasElement | null): Promise<Blob | null> { | |
/* @CHECK: https://stackoverflow.com/a/46182044/5221762 */ | |
/* @NOTE: May require the `toBlob()` polyfill */ | |
if (!HTMLCanvasElement.prototype.toBlob) { | |
window.Object!.defineProperty(HTMLCanvasElement.prototype, 'toBlob', { | |
value: function (callback, type, quality) { | |
const canvas = this; | |
window.setTimeout(function() { | |
var binStr = atob( canvas.toDataURL(type, quality).split(',')[1] ), | |
len = binStr.length, | |
arr = new Uint8Array(len); | |
for (let index = 0; index < len; index++ ) { | |
arr[index] = binStr.charCodeAt(index); | |
} | |
callback( new Blob( [arr], {type: type || 'image/png'} ) ); | |
}); | |
} | |
}); | |
} | |
return new Promise((resolve, reject) => { | |
try { | |
if (canvas) { | |
canvas.toBlob(blob => resolve(blob), 'image/jpeg', 0.95); | |
} | |
} catch (e) { | |
reject(e); | |
} | |
}) | |
}; | |
/* @EXAMPLE: getJpegBlob(window.document.getElementsByTagName('canvas')[0]) */ | |
/** | |
* getJpegBytes: | |
* | |
* @param {HTMLCanvasElement | null} canvas | |
* | |
* @return {Promise<string | ArrayBuffer | null>} | |
* | |
*/ | |
export function getJpegBytes(canvas: HTMLCanvasElement | null): Promise<string | ArrayBuffer | null> { | |
return getJpegBlob(canvas).then((blob) => { | |
return new Promise((resolve, reject) => { | |
const fileReader = new FileReader() | |
fileReader.addEventListener('loadend', () => { | |
if (this.error) { | |
reject(this.error) | |
return | |
} | |
resolve(this.result) | |
}) | |
if (blob) { | |
fileReader.readAsArrayBuffer(blob); | |
} | |
}) | |
}) | |
}; | |
/* @EXAMPLE: getJpegBytes(window.document.getElementsByTagName('canvas')[0]) */ | |
/** | |
* isBase64String: | |
* | |
* @param {String} base64String | |
* | |
* @return {Boolean} | |
* | |
*/ | |
export const isBase64String = (base64String: string): boolean => { | |
let base64Regex = /^([0-9a-zA-Z+/]{4})*(([0-9a-zA-Z+/]{2}==)|([0-9a-zA-Z+/]{3}=))?$/; | |
return typeof base64String !== 'string' | |
? false | |
: (base64String.length % 4 === 0) && base64Regex.test(base64String) | |
}; | |
/* @EXAMPLE: isBase64String("") */ | |
/** | |
* sleepFor: | |
* | |
* @param {Number} durationInMilliSeconds | |
* | |
* @return {void} | |
* | |
*/ | |
export const sleepFor = (durationInMilliSeconds = 10) => { | |
return new Promise( | |
(resolve) => window.setTimeout(resolve, durationInMilliSeconds) | |
); | |
}; | |
/* @EXAMPLE: await sleepFor(2500) */ | |
/** | |
* waitFor: | |
* | |
* | |
* @param {Function} conditionCallback | |
* @param {Number} pollIntervalMilliSeconds | |
* @param {Number} timeoutAfterMilliSeconds | |
* | |
* @returns {Void} | |
* | |
* @see https://davidwalsh.name/waitfor/ | |
*/ | |
export const waitFor = async ( | |
conditionCallback: () => boolean, | |
pollIntervalMilliSeconds = 50, | |
timeoutAfterMilliSeconds = 3000 | |
) => { | |
const startTimeMilliSeconds = Date.now(); | |
while (true) { | |
if ( | |
typeof (timeoutAfterMilliSeconds) === "number" | |
&& Date.now() > startTimeMilliSeconds + timeoutAfterMilliSeconds) { | |
throw new Error("Condition not met bbefore timeout"); | |
} | |
const result = conditionCallback(); | |
if (result) { | |
return result; | |
} | |
await sleepFor(pollIntervalMilliSeconds); | |
} | |
}; | |
/* @EXAMPLE: waitFor(() => window.document.body.classList.has('loaded'), 100, 5000) */ | |
/** | |
* htmlEncode: | |
* | |
* | |
* @param {String} rawText | |
* | |
* @returns {String} | |
* | |
*/ | |
export const htmlEncode = (rawText: string): string => { | |
return (rawText || "").replace(/[\u00A0-\u9999<>&]/gim, function (mark: string) { | |
return '&#' + mark.charCodeAt(0) + ';' | |
}) | |
}; | |
/* @EXAMPLE: const encodedHTML = htmlEncode('<h1><img onerror="javascript:return null" /></h1>'); */ | |
/** | |
* htmlDecode: | |
* | |
* | |
* @param {String} encodedText | |
* | |
* @returns {String | Null} | |
* | |
*/ | |
export const htmlDecode = (encodedText: string): string | null => { | |
const doc = new window.DOMParser().parseFromString(encodedText || " ", 'text/html') | |
const docElem = doc.documentElement as Node | |
return docElem.textContent | |
}; | |
/* @EXAMPLE: const decodedHTML = htmlDecode("<h1>Hi there!</h1>"); */ | |
/** | |
* detectFullScreenTrigger: | |
* | |
* | |
* @param {Event} event | |
* | |
* @returns {"user-manual" | "programmatic" | "unknown"} | |
* | |
*/ | |
export const detectFullScreenTrigger = (event: Event): string => { | |
if ( | |
window.matchMedia && | |
window.matchMedia('(display-mode: fullscreen)').matches | |
) { | |
// page entered fullscreen mode through the Web Application Manifest | |
return 'user-manual' | |
} else if (document.fullscreenEnabled && document.fullscreenElement) { | |
// page entered fullscreen mode through the Fullscreen API | |
return 'programmatic' | |
} | |
return 'unknown' | |
}; | |
/* @EXAMPLE: document.onfullscreenchange = detectFullScreenTrigger; */ | |
/** | |
* detectAppleIOS: | |
* | |
* | |
* @returns {Boolean} | |
* | |
*/ | |
export const detectAppleIOS = (): boolean => { | |
const global: Window = window | |
const navigator: Navigator = global.navigator | |
const userAgent = navigator.userAgent.toLowerCase() | |
const vendor = navigator.vendor.toLowerCase() | |
return /iphone|ipad|ipod/.test(userAgent) && vendor.indexOf('apple') > -1 | |
} | |
/* @EXAMPLE: const isIOS = detectAppleIOS() */ | |
/** | |
* isInStandaloneMode: | |
* | |
* | |
* @returns {Boolean} | |
* | |
*/ | |
export const isInStandaloneMode = (): boolean => { | |
const global: Window = window | |
const navigator: Navigator = global.navigator | |
const location: Location = global.location | |
/** | |
* @CHECK: https://stackoverflow.com/questions/21125337/how-to-detect-if-web-app-running-standalone-on-chrome-mobile | |
*/ | |
if (detectAppleIOS() && navigator instanceof Navigator) { | |
return navigator.standalone === true | |
} | |
return ( | |
location.search.indexOf('standalone=true') !== -1 && | |
Boolean(global.matchMedia('(display-mode: standalone)').matches) && | |
(global.screen.height - document.documentElement.clientHeight < 40 || | |
global.screen.width - document.documentElement.clientHeight < 40) | |
) | |
}; | |
/* @EXAMPLE: const standalone = isInStandaloneMode(); */ | |
/** | |
* formatHTMLEntity: | |
* | |
* | |
* @param {String} textValue | |
* @param {String} entityHexValue | |
* @param {String} prefix | |
* | |
* @returns {String} | |
* | |
*/ | |
export const formatHTMLEntity = ( | |
textValue: string, | |
entityHexVal: string, | |
prefix: string = '' | |
): string => { | |
const isNumeric = /^\d{2,5}$/.test(entityHexValue) | |
const number = parseInt(isNumeric ? "8" : entityHexValue, 16) | |
return ( | |
(textValue ? textValue + ' ' : '') + | |
prefix + String.fromCharCode(number) | |
) | |
}; | |
/* @EXAMPLE: <p className="wrapper">{formatHTMLEntity('View Full Project', '279D')}</p> */ | |
/** | |
* isEmpty: | |
* | |
* @param {Object} objectValue | |
* | |
* @returns {Boolean} | |
*/ | |
export function isEmpty<T>(objectValue: T): boolean { | |
if(!objectValue || typeof objectValue !== "object") { | |
return true; | |
} | |
for(const prop in objectValue) { | |
if(Object.prototype.hasOwnProperty.call(objectValue, prop)) { | |
return false; | |
} | |
} | |
return JSON.stringify(objectValue) === JSON.stringify({}); | |
} | |
/* @EXAMPLE: isEmpty({}) */ | |
/** | |
* slugify: | |
* | |
* | |
* @param {String} plainText | |
* @param {String} delimeter | |
* | |
* @returns {String} | |
* | |
*/ | |
export const slugify = (plainText: string, delimeter = "_") => { | |
return (plainText || "") | |
.toString() | |
.normalize("NFD") | |
.replace(/[\u0300-\u036f]/g, "") | |
.toLowerCase() | |
.trim() | |
.replace(/[^a-z0-9 ]/g, "") | |
.replace(/\s+/g, delimeter); | |
}; | |
/* @EXAMPLE: slugify('Last Name') */ | |
/** | |
* unSlugify: | |
* | |
* @param {String} slugifiedText | |
* @param {String} delimeter | |
* @param {Boolean} shouldTrim | |
* | |
* @returns {String} | |
* | |
*/ | |
export const unSlugify = ( | |
slugifiedText: string, | |
delimeter = '_', | |
shouldTrim = false | |
): string => { | |
return (slugifiedText || '') | |
.split(delimeter) | |
.map( | |
(slugPart) => | |
`${slugPart.charAt(0).toUpperCase()}${slugPart.substring(1)}` | |
).join(shouldTrim ? '' : ' ') | |
}; | |
/* @EXAMPLE: unSlugify('first_name') */ | |
/** | |
* getOrdinalSuffix: | |
* | |
* | |
* @param {Number} ordinal | |
* @param {Boolean} asWord | |
* | |
* @returns {String} | |
* | |
*/ | |
export const getOrdinalSuffix = (ordinal: number, asWord = false): string => { | |
let ord = "th"; | |
if (ordinal % 10 == 1 && ordinal % 100 != 11) { | |
ord = "st"; | |
} | |
else if (ordinal % 10 == 2 && ordinal % 100 != 12) { | |
ord = asWord ? "ond" : "nd"; | |
} | |
else if (ordinal % 10 == 3 && ordinal % 100 != 13) { | |
ord = "rd"; | |
} | |
return ord; | |
}; | |
/* @EXAMPLE: getOrdinalSuffix(23) */ | |
/** | |
* getShortAmount: | |
* | |
* @param {Number} amount | |
* | |
* @returns {String} | |
* | |
*/ | |
export const getShortAmount = (amount: number): string => { | |
const strFigure = String(Number.isNaN(amount) ? false : Math.round(amount)) | |
const [firstPart, ...remainingParts] = strFigure.match( | |
/\d{1,3}(?=(\d{3})*$)/g | |
) || [''] | |
const shortenedMap: { [key: number]: string } = { | |
1: 'K', | |
2: 'M', | |
3: 'B', | |
4: 'T', | |
5: 'Z', | |
} | |
return firstPart !== '' && remainingParts.length | |
? firstPart + shortenedMap[remainingParts.length] | |
: firstPart | |
} | |
/* @EXAMPLE: getShortName(305000) */ | |
/** | |
* isUUIDOrGUID: | |
* | |
* @param {String} uidLineText | |
* | |
* @returns {Boolean} | |
* | |
*/ | |
export const isUUIDOrGUID = (uidLineText: string): boolean => { | |
const uuidRegExp = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i | |
const guidRegExp = /^(\{){0,1}[0-9a-fA-F]{8}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{12}(\}){0,1}$/gi | |
let uid = uidLineText; | |
if (uidLineText.startsWith('{')) { | |
uid = uidLineText.substring(1, uidLineText.length - 1); | |
} | |
return uuidRegExp.test(uid) || guidRegExp.test(uid) | |
} | |
/* @EXAMPLE: isUUIDOrGUID('a4caeacc-72cb-4824-80f8-b55961f148c6') */ | |
/** | |
* isWebPageURL: | |
* | |
* | |
* | |
* @returns {Boolean} | |
* | |
*/ | |
export const isWebPageURL = (urlString: string): boolean => { | |
let result = null; | |
try { | |
result = urlString.match( | |
/(http(s)?:\/\/.)?(www\.)?[-a-zA-Z0-9@:%._\+~#=]{2,256}\.[a-z0-9]{2,6}\b([-a-zA-Z0-9@:%_\+.~#?&//=]*)/g | |
) | |
} catch (e) { | |
result = null | |
} | |
return result !== null; | |
} | |
/* @EXAMPLE: isWebPageURL('https://www.example.com?id=98747904') */ |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment