stem-core
Version:
Frontend and core-library framework
520 lines (449 loc) • 15.8 kB
JavaScript
// TODO: should this be renamed to "toUnwrappedArray"?
export function unwrapArray(elements) {
if (!elements) {
return [];
}
if (!Array.isArray(elements)) {
// In case this is an iterable, convert to array
if (elements[Symbol.iterator]) {
return unwrapArray(Array.from(elements));
} else {
return [elements];
}
}
// Check if the passed in array is valid, and try to return it if possible to preserve references
let allProperElements = true;
for (let i = 0; i < elements.length; i++) {
if (Array.isArray(elements[i]) || elements[i] == null) {
allProperElements = false;
break;
}
}
if (allProperElements) {
// Return the exact same array as was passed in
return elements;
}
let result = [];
for (let i = 0; i < elements.length; i++) {
if (Array.isArray(elements[i])) {
let unwrappedElement = unwrapArray(elements[i]);
for (let j = 0; j < unwrappedElement.length; j += 1) {
result.push(unwrappedElement[j]);
}
} else {
if (elements[i] != null) {
result.push(elements[i]);
}
}
}
return result;
}
export function isLocalUrl(url, host=self.location.host, origin=self.location.origin) {
// Empty url is considered local
if (!url) {
return true;
}
// Protocol-relative url is local if the host matches
if (url.startsWith("//")) {
return url.startsWith("//" + host);
}
// Absolute url is local if the origin matches
let r = new RegExp("^(?:[a-z]+:)?//", "i");
if (r.test(url)) {
return url.startsWith(origin);
}
// Root-relative and document-relative urls are always local
return true;
}
// Trims a local url to root-relative or document-relative url.
// If the url is protocol-relative, removes the starting "//"+host, transforming it in a root-relative url.
// If the url is absolute, removes the origin, transforming it in a root-relative url.
// If the url is root-relative or document-relative, leaves it as is.
export function trimLocalUrl(url, host=self.location.host, origin=self.location.origin) {
if (!isLocalUrl(url, host, origin)) {
throw Error("Trying to trim non-local url!");
}
if (!url) {
return url;
}
if (url.startsWith("//" + host)) {
return url.slice(("//" + host).length);
}
if (url.startsWith(origin)) {
return url.slice(origin.length);
}
return url;
}
// Split the passed in array into arrays with at most maxChunkSize elements
export function splitInChunks(array, maxChunkSize) {
let chunks = [];
while (array.length > 0) {
chunks.push(array.splice(0, maxChunkSize));
}
return chunks;
}
export function isIterable(obj) {
if (obj == null) {
return false;
}
return obj[Symbol.iterator] !== undefined;
}
export function defaultComparator(a, b) {
if (a == null && b == null) {
return 0;
}
if (b == null) {
return 1;
}
if (a == null) {
return -1;
}
// TODO: might want to use valueof here
if (isNumber(a) && isNumber(b)) {
return a - b;
}
let aStr = a.toString();
let bStr = b.toString();
if (aStr === bStr) {
return 0;
}
return aStr < bStr ? -1 : 1;
}
export function slugify(string) {
string = string.trim();
string = string.replace((/[^a-zA-Z0-9-\s]/g), ""); // remove anything non-latin alphanumeric
string = string.replace((/\s+/g), "-"); // replace whitespace with dashes
string = string.replace((/-{2,}/g), "-"); // remove consecutive dashes
string = string.toLowerCase();
return string;
}
// If the first argument is a number, it's returned concatenated with the suffix, otherwise it's returned unchanged
export function suffixNumber(value, suffix) {
if (typeof value === "number" || value instanceof Number) {
return value + suffix;
}
return value;
}
export function setObjectPrototype(obj, Class) {
obj.__proto__ = Class.prototype;
return obj;
}
export function isNumber(obj) {
return (typeof obj === "number") || (obj instanceof Number);
}
export function isString(obj) {
return (typeof obj === "string") || (obj instanceof String);
}
export function isPlainObject(obj) {
if (!obj || typeof obj !== "object" || obj.nodeType) {
return false;
}
if (obj.constructor && obj.constructor != Object) {
return false;
}
return true;
}
export function deepCopy() {
let target = arguments[0] || {};
// Handle case when target is a string or something (possible in deep copy)
if (typeof target !== "object" && typeof target !== "function") {
target = {};
}
for (let i = 1; i < arguments.length; i += 1) {
let obj = arguments[i];
if (obj == null) {
continue;
}
// Extend the base object
for (let [key, value] of Object.entries(obj)) {
// Recurse if we're merging plain objects or arrays
if (value && isPlainObject(value) || Array.isArray(value)) {
let clone;
let src = target[key];
if (Array.isArray(value)) {
clone = (src && Array.isArray(src)) ? src : [];
} else {
clone = (src && isPlainObject(src)) ? src : {};
}
target[key] = deepCopy(clone, value);
} else {
// TODO: if value has .clone() method, use that?
target[key] = value;
}
}
}
return target;
}
export function objectFromKeyValue(key, value) {
return {
[key]: value,
}
}
export function dashCase(str) {
let rez = "";
for (let i = 0; i < str.length; i++) {
if ("A" <= str[i] && str[i] <= "Z") {
if (i > 0) {
rez += "-";
}
rez += str[i].toLowerCase();
} else {
rez += str[i];
}
}
return (rez == str) ? str : rez;
}
// TODO: have a Cookie helper file
export function getCookie(name) {
let cookies = (document.cookie || "").split(";");
for (let cookie of cookies) {
cookie = cookie.trim();
if (cookie.startsWith(name + "=")) {
return cookie.substring(name.length + 1);
}
}
return "";
}
export function setCookie(name, value, maxAge=60*60*4 /* 4 hours */) {
document.cookie = `${name}=${value};path=/;max-age=${maxAge}`;
}
export function serializeCookie(name, value, maxAge=60*60*4) {
setCookie(name, encodeURIComponent(JSON.stringify(value)), maxAge);
}
export function deserializeCookie(name) {
const value = getCookie(name);
if (!value) {
return value;
}
return JSON.parse(decodeURIComponent(value));
}
export function uniqueId(obj) {
if (!uniqueId.objectWeakMap) {
uniqueId.objectWeakMap = new WeakMap();
uniqueId.constructorWeakMap = new WeakMap();
uniqueId.totalObjectCount = 0;
}
let objectWeakMap = uniqueId.objectWeakMap;
let constructorWeakMap = uniqueId.constructorWeakMap;
if (!objectWeakMap.has(obj)) {
const objConstructor = obj.constructor || obj.__proto__ || Object;
// Increment the object count
const objIndex = (constructorWeakMap.get(objConstructor) || 0) + 1;
constructorWeakMap.set(objConstructor, objIndex);
const objUniqueId = objIndex + "-" + (++uniqueId.totalObjectCount);
objectWeakMap.set(obj, objUniqueId);
}
return objectWeakMap.get(obj);
}
// TODO: should be done with String.padLeft
export function padNumber(num, minLength) {
let strNum = String(num);
while (strNum.length < minLength) {
strNum = "0" + strNum;
}
return strNum;
}
// Returns the english ordinal suffix of a number
export function getOrdinalSuffix(num) {
let suffixes = ["th", "st", "nd", "rd"];
let lastDigit = num % 10;
let isTeen = Math.floor(num / 10) % 10 === 1;
return (!isTeen && suffixes[lastDigit]) || suffixes[0];
}
export function suffixWithOrdinal(num) {
return num + getOrdinalSuffix(num);
}
export function instantiateNative(BaseClass, NewClass, ...args) {
let obj = new BaseClass(...args);
obj.__proto__ = NewClass.prototype;
return obj;
}
// This function can be used as a decorator in case we're extending native classes (Map/Set/Date)
// and we want to fix the way babel breaks this scenario
// WARNING: it destroys the code in constructor
// If you want a custom constructor, you need to implement a static create method that generates new objects
// Check the default constructor this code, or an example where this is done.
export function extendsNative(targetClass) {
if (targetClass.toString().includes(" extends ")) {
// Native extended classes are cool, leave them as they are
return;
}
let BaseClass = targetClass.__proto__;
let allKeys = Object.getOwnPropertySymbols(targetClass).concat(Object.getOwnPropertyNames(targetClass));
// Fill in the default constructor
let newClass = targetClass.create || function create() {
return instantiateNative(BaseClass, newClass, ...arguments);
};
for (const key of allKeys) {
let property = Object.getOwnPropertyDescriptor(targetClass, key);
Object.defineProperty(newClass, key, property);
}
newClass.prototype = targetClass.prototype;
newClass.__proto__ = targetClass.__proto__;
newClass.prototype.constructor = newClass;
return newClass;
}
export const NOOP_FUNCTION = () => undefined;
// Helpers to wrap iterators, to wrap all values in a function or to filter them
export function* mapIterator(iter, func) {
for (let value of iter) {
yield func(value);
}
}
export function* filterIterator(iter, func) {
for (let value of iter) {
if (func(value)) {
yield value;
}
}
}
export class CallModifier {
wrap(func) {
throw Error("Implement wrap method");
}
call(func) {
return this.wrap(func)();
}
toFunction() {
return (func) => this.wrap(func);
}
}
export class UnorderedCallDropper extends CallModifier {
index = 1;
lastExecuted = 0;
wrap(callback) {
const currentIndex = this.index++;
return (...args) => {
if (currentIndex > this.lastExecuted) {
this.lastExecuted = currentIndex;
return callback(...args);
}
}
}
}
/*
CallThrottler acts both as a throttler and a debouncer, allowing you to combine both types of functionality.
Available options:
- debounce (ms): delays the function call by x ms, each call extending the delay
- throttle (ms): keeps calls from happening with at most x ms between them. If debounce is also set, will make sure to
fire a debounced even if over x ms have passed. If equal to CallTimer.ON_ANIMATION_FRAME, means that we want to use
requestAnimationFrame instead of setTimeout, to execute before next frame redraw()
- dropThrottled (boolean, default false): any throttled function call is not delayed, but dropped
*/
export class CallThrottler extends CallModifier {
static ON_ANIMATION_FRAME = Symbol();
static AUTOMATIC = Symbol();
lastCallTime = 0;
pendingCall = null;
pendingCallArgs = [];
pendingCallExpectedTime = 0;
numCalls = 0;
totalCallDuration = 0;
constructor(options={}) {
super();
Object.assign(this, options);
}
isThrottleOnAnimationFrame() {
return this.throttle === this.constructor.ON_ANIMATION_FRAME;
}
clearPendingCall() {
this.pendingCall = null;
this.pendingCallArgs = [];
this.pendingCallExpectedTime = 0;
}
cancel() {
this.pendingCall && this.pendingCall.cancel();
this.clearPendingCall();
}
flush() {
this.pendingCall && this.pendingCall.flush();
this.clearPendingCall();
}
// API compatibility with cleanup jobs
cleanup() {
this.cancel();
}
computeExecutionDelay(timeNow) {
let executionDelay = null;
if (this.throttle != null) {
executionDelay = Math.max(this.lastCallTime + this.throttle - timeNow, 0);
}
if (this.debounce != null) {
executionDelay = Math.min(executionDelay != null ? executionDelay : this.debounce, this.debounce);
}
return executionDelay;
}
replacePendingCall(wrappedFunc, funcCall, funcCallArgs) {
this.cancel();
if (this.isThrottleOnAnimationFrame()) {
const cancelHandler = requestAnimationFrame(funcCall);
wrappedFunc.cancel = () => cancelAnimationFrame(cancelHandler);
return;
}
const timeNow = Date.now();
let executionDelay = this.computeExecutionDelay(timeNow);
if (this.dropThrottled) {
return executionDelay == 0 && funcCall();
}
const cancelHandler = setTimeout(funcCall, executionDelay);
wrappedFunc.cancel = () => clearTimeout(cancelHandler);
this.pendingCall = wrappedFunc;
this.pendingCallArgs = funcCallArgs;
this.pendingCallExpectedTime = timeNow + executionDelay;
}
updatePendingCall(args) {
this.pendingCallArgs = args;
if (!this.isThrottleOnAnimationFrame()) {
const timeNow = Date.now();
this.pendingCallExpectedTime = timeNow + this.computeExecutionDelay(timeNow);
}
}
wrap(func) {
const funcCall = () => {
const timeNow = Date.now();
// The expected time when the function should be executed next might have been changed
// Check if that's the case, while allowing a 1ms error for time measurement
if (!this.isThrottleOnAnimationFrame() &&
timeNow + 1 < this.pendingCallExpectedTime) {
this.replacePendingCall(wrappedFunc, funcCall, this.pendingCallArgs);
} else {
this.lastCallTime = timeNow;
this.clearPendingCall();
func(...this.pendingCallArgs);
}
};
const wrappedFunc = (...args) => {
// Check if it's our function, and update the arguments and next execution time only
if (this.pendingCall && func === this.pendingCall.originalFunc) {
// We only need to update the arguments, and maybe mark that we want to executed later than scheduled
// It's an optimization to not invoke too many setTimeout/clearTimeout pairs
return this.updatePendingCall(args);
}
return this.replacePendingCall(wrappedFunc, funcCall, args);
};
wrappedFunc.originalFunc = func;
wrappedFunc.cancel = NOOP_FUNCTION;
wrappedFunc.flush = () => {
if (wrappedFunc === this.pendingCall) {
this.cancel();
wrappedFunc();
}
};
return wrappedFunc;
}
}
// export function benchmarkThrottle(options={}) {
// const startTime = performance.now();
// const calls = options.calls || 100000;
//
// const throttler = new CallThrottler({throttle: options.throttle || 300, debounce: options.debounce || 100});
//
// const func = options.func || NOOP_FUNCTION;
//
// const wrappedFunc = throttler.wrap(func);
//
// for (let i = 0; i < calls; i += 1) {
// wrappedFunc();
// }
// console.warn("Throttle benchmark:", performance.now() - startTime, "for", calls, "calls");
// }