Source: src/utils/utils.js

/**
 * Common utility methods
 * @module utils
 */


//import objectAssignDeep from './objectAssignDeep.js';
//export {objectAssignDeep}


/**
 * Given a font weight string description, will return the weight as a number compatible with the PIXI text system.
 * <br>- 100 - Thin
 * <br>- 200 - Extra Light (Ultra Light)
 * <br>- 300 - Light
 * <br>- 400 - Normal
 * <br>- 500 - Medium
 * <br>- 600 - Semi Bold (Demi Bold)
 * <br>- 700 - Bold
 * <br>- 800 - Extra Bold (Ultra Bold)
 * <br>- 900 - Black (Heavy)
 * @param {string} fontWeightStrToNum - The font weight description. 
 * @returns {number} fontWeight - The numeric font weight. If weight is undetermined will return 400 as default.
 * @example
 * utils.fontWeightStrToNum('extra bold'); // Returns 800
 */
export function fontWeightStrToNum(fontWeightStr){

  let str = fontWeightStr.trim().toLowerCase();
  
  // If a font weight number is supplied then return it as is
  if (!isNaN(Number(str)) && str.length == 3 && str.charAt(1) == '0' && str.charAt(2) == '0'){
    return Number(str);
  }

  if (str.split('thin').length > 1){
    return 100;
  }
  
  if (str.split('light').length > 1){
    if (str.split('ultra').length > 1 || str.split('extra').length > 1){
        return 200;
    }
    return 300;
  }
  
  if (str.split('normal').length > 1 || str.split('regular').length > 1){
    return 400;
  }

  if (str.split('medium').length > 1){
    return 500;
  }
  
  if (str.split('bold').length > 1){
    
    if (str.split('semi').length > 1 || str.split('demi').length > 1){
      return 600;
    } else if (str.split('ultra').length > 1 || str.split('extra').length > 1){
        return 800;
    }
    return 700;
  }
  
  if (str.split('black').length > 1){
      return 900;
  }

  return 400;

/*  
100 - Thin
200 - Extra Light (Ultra Light)
300 - Light
400 - Normal
500 - Medium
600 - Semi Bold (Demi Bold)
700 - Bold
800 - Extra Bold (Ultra Bold)
900 - Black (Heavy)
*/

  // Pixi supports:
  //  ('normal', 'bold', 'bolder', 'lighter' and '100', '200', '300', '400', '500', '600', '700', '800' or '900')
  
  
}
  
/**
 * Gets the computed style of the given HTML DOM element as a number value. 
 * @param {DOMElement} element - The DOM element to target.
 * @param {string} property - The property to retrieve. Eg. `width`.
 * @returns {integer} value - The property value.
 */
export function getProp(ele, prop){
	var style = window.getComputedStyle(ele, null);
	var val = parseInt(style[prop]);
	return(val);
}

/**
 * Returns the HTML DOM element with the given id.
 * @param {string} id - The id of the DOM element to retrieve.
 * @returns {DOMElement} [element=null] - The DOM element, if found.
 */
export function e(id){
  return id ? document.getElementById(id) : null;
}

/**
 * Returns wheather the given image is loaded.
 * @param {string|DOMElement} image - The id of the DOM element or the DOM element itself.
 * @returns {boolean} loaded - The load state.
 */
export function isImgLoaded(image){
    image = typeof image === 'string' ? e(image) : image;
    if (!image){
      return false;
    }
    return image.complete && image.naturalHeight !== 0;
}


/**
 * Performs a shallow clone of a given object. 
 * @param {Object} source - The object to clone.
 * @returns {Object} clone - The duplicate object.
 */
export function cloneObj(obj){
  return Object.assign({}, obj);
}

/**
 * Given a 1-2 character description of a horizontal and/or vertical alignment, will return the alignment as a vector representation.
 * @param {string} alignmentDescription - The alignment description as initials. Eg. `CT` for center / top. Case independent.
 * @param {boolean} [defineSingleAxisMode=false] - If true: `C` means centered on x axis,`M` means centered on y axis and any unset axes will return `null`. If false: Both x and y will resolve, `C` applies to both x and y, `M` means centered on y axis, will default to 0 (centered).
 * @returns {vector} alignment - The alignment as a vector. A value of -1 means left/top, a value of 0 means centered and a value of 1 means right/bottom.
 */
export function alignmentStringToXY(alignmentStr, defineSingleAxisMode = false){
  
  // let alignment = defaultAlignment ? defaultAlignment : {x:0,y:0};
  
  alignmentStr = alignmentStr.trim().toUpperCase();
  
  if (!defineSingleAxisMode && alignmentStr.length == 1 && alignmentStr == 'C'){
    return {x:0,y:0};
  } 
  
  if (alignmentStr == 'CC'){
    return {x:0,y:0};
  } 
  
  let alignment = {x:null,y:null}
  
  if (alignmentStr.split('L').length == 2){
    alignment.x = -1;
  } else if (alignmentStr.split('R').length == 2){
    alignment.x = 1;
  } else if (defineSingleAxisMode && alignmentStr.split('C').length == 2){
    alignment.x = 0;
  }
  
  if (alignmentStr.split('T').length == 2){
    alignment.y = -1;
  } else if (alignmentStr.split('B').length == 2){
    alignment.y = 1; 
  } else if (alignmentStr.split('M').length == 2){
    alignment.y = 0;
  } 
  
  // Interpret ambiguous `C`, eg `CT` will resolve `C` to x axis, `CR` will resolve `C` to y axis
  if (!defineSingleAxisMode){
    
    if (alignmentStr.split('C').length == 2){
      if (alignment.x === null && alignment.y !== null){
        alignment.x = 0;
      } else if (alignment.y === null && alignment.x !== null){
        alignment.y = 0;
      }
    }
    
    // Fallback to center if not set
    alignment.x = alignment.x === null ? 0 : alignment.x;
    alignment.y = alignment.y === null ? 0 : alignment.y;
    
  }
    
  return alignment;
  
}


/**
 * Set the path of an object with supplied value.
 * <br>If the path doesn't exist then it will be created.
 * <br>If the existing value is numeric and the value is a string prefixed with `-=` or `+=`, then the value will be updated relative to its existing value.
 * @param {Object} object - The target object.
 * @param {string} path - The path to set, as a single string with dot syntax.
 * @param {*} value - The value to apply.
 * @example
 * let obj = {foo:{bar:123}}
 * setObjPathVal(obj, 'foo.bar', 321);
 */
export function setObjPathVal(obj, path, val){

	var ref = obj;
	var pathParts = path.split('.');
  for (let i = 0; i < pathParts.length; i++){
    pathParts[i] = pathParts[i].split('(dot)').join ('.');
  }

	for (var i = 0; i < pathParts.length; i++){
		if (i == pathParts.length - 1){
      // Apply relative value mapping eg. `+22`
      if (typeof val === 'string' && val.length > 0 && (val.charAt(0) === '+' ||val.charAt(0) === '-') && !isNaN(Number(ref[pathParts[i]]))) {
        let mod = val.charAt(0) === '-' ? -1.0 : 1.0
        val = val.substr(1);
        if (val.charAt(0) === '='){
          val = val.substr(1);
        }
        let relVal = Number(val)
        if (!isNaN(relVal)){
          val = ref[pathParts[i]] + mod*relVal
        }
      }
      ref[pathParts[i]] = val;
		}	else if (ref[pathParts[i]] == undefined) {
			ref[pathParts[i]] =  {};
		}	
		ref = ref[pathParts[i]];
	}
  
}


/**
 * Returns the value of an object at a given path.
 * <br>Supports array indexes in the path. Eg. `path.to.arr[7]`.
 * @param {Object} object - The target object.
 * @param {string} path - The path to retrieve, as a single string with dot syntax.
 * @returns {Object} value - The value retrieved.
 * @example
 * let obj = {foo:{bar:['a','b','c']}}
 * getObjPath(obj, 'foo.bar[1]'); // Returns `b`
 */
export function getObjPath(obj, path){

	var ref = obj;
	var pathParts = path.split('.');

	for (var i = 0; i < pathParts.length; i++){

		var path = pathParts[i]

		if (ref[path] == undefined) {

			// Return object length
			if (path == 'length' && typeof obj == 'object' && !Array.isArray(ref)) {
				var k = 0;
				for (var p in ref){
					k++;
				}
				return k;
			}

			// Return array by [index]
			if (path.charAt(path.length-1) == ']' && path.split('[').length == 2){
				var parts = path.split('[');
				var index = parts[1].substr(0, parts[1].length-1);
				if (index >= 0 && ref[parts[0]] != undefined && Array.isArray(ref[parts[0]]) && ref[parts[0]].length > index) {
					return ref[parts[0]][index];
				}
			}

			return undefined;

		}
		ref = ref[path];

		if (i == pathParts.length - 1){

			return ref; // Made it to end
		}
	}

	return undefined;

}

/**
 * Pads string to a given length with supplied character.
 * <br>Supports array indexes in the path. Eg. `path.to.arr[7]`.
 * @param {string|number} subject - The target to pad.
 * @param {integer} targetLength - The desired string length.
 * @param {string} [padChar='0'] - The pad character to use.
 * @param {boolean} [padBefore=true] - If true: pad chars will be added to the start of the subject, otherwise will be added to the end.
 * @returns {string} padded - The padded string.
 * @example
 * pad(777, 5); // Returns `00777`
 */
export function pad(subject, targetLength, padChar = '0', padBefore = true){
  
  subject = String(subject);
  
  const padPart = targetLength>subject.length ? padChar.repeat(targetLength-subject.length) : '';
  
  return padBefore ? padPart + subject : subject + padPart;
}


let _globMatchCache = {}; // Caches regex
/**
 * Returns if a string matches a glob pattern.
 * <br>Supports patterns such as '!pattern','pattern*','!dingo*','*dingo'.
 * @param {string} subject - The subject string.
 * @param {string} glob - The glob pattern. 
 * @returns {boolean} match - Whether the subject matched the glob pattern.
 * @example
 * utils.globMatch('kitten', '*ten'); // Returns true.
 */
export function globMatch(subject, glob){
  
  let inverseResults;
  let pattern;
  
  if (!_globMatchCache[glob]){
    
    let _glob = glob;
    inverseResults = false;
    if (_glob.startsWith('!')){
      inverseResults = true;
      _glob = _glob.substr(1)
    }
    
    let asteriskParts = _glob.split('*');
    if (asteriskParts.length == 1){ 
      // Simple case insenstive string match
      pattern = _glob.toLowerCase();
    } else {
      _glob = asteriskParts.join('__asterisk__');
      _glob = escapeRegExp( _glob);
      _glob = _glob.split('__asterisk__').join('.*');
      _glob = '^' + _glob + '$'; // Start and end of subject
      pattern = new RegExp(_glob, 'i');
    }
    
    _globMatchCache[glob] = {inverseResults:inverseResults, pattern:pattern}
    
  } else {
    
    inverseResults = _globMatchCache[glob].inverseResults
    pattern = _globMatchCache[glob].pattern
    
  }
  const result = typeof pattern === 'string' ? subject.toLowerCase() == pattern : subject.match(pattern);
  return inverseResults ? !result : result;
  
}

/**
 * Escapes any regex reserved tokens from a string.
 * @param {string} subject - The subject string.
 * @returns {string} escapedSubject - The escaped string, ready to be used in a regex expression.
 */
export function escapeRegExp(string) {
  return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); 
}


/**
 * Requires all files with given context and returns an array of document content.
 * <br>>Note: Webpack may need to be configured to process the required file type.
 * @param {RequireContext} requireContext - A directory to search, a flag indicating whether subdirectories should be searched too, and a regular expression to match files against.
 * @returns {Array.<*>} documentContent - Each entry in the array represents the content of a matched document.
 * @example
 * const _psdInfo = requireAll(require.context('./ui', false, /.json$/));
 */
export function requireAll( requireContext ) {
  return requireContext.keys().map( requireContext );
}

/**
 * Requires all files with given context and returns an object with the original filename as the key.
 * <br>Note: Webpack may need to be configured to process the required file type. See: https://webpack.js.org/guides/asset-modules/
 * @param {RequireContext} requireContext - A directory to search, a flag indicating whether subdirectories should be searched too, and a regular expression to match files against.
 * @returns {Object.<string>} documentContent - An object with document filenames set at the key, populated with its content.
 */
export function requireAllLookup(requireContext){
  let lookup = {}
  requireContext.keys().forEach((key) => {
    let safeKey = key.replace(/^[\.\\//]*/g, "");
    lookup[safeKey] = requireContext(key)
  });
  return lookup;
} 


/**
 * Enforces a minimum wait time between multiple calls to a given function.
 * @param {Function} callback - Function to call.
 * @param {number} wait - Minimum time in milliseconds between calls.
 * @example
 * onResize(){
 *    utils.debounce(this.onResizeDebounced.bind(this), 1000);
 * }
 */
export function debounce(func, wait, _immediate = false) {
  let timeout;
  return function() {
    let args = arguments;
    let later = () => {
        timeout = null;
        if (!immediate) {
          func.apply(this, args);
        }
    };
    let callNow = immediate && !timeout;
    clearTimeout(timeout);
    timeout = setTimeout(later, wait);
    if (callNow) {
      func.apply(this, args);
    }
  }
}


/**
 * Enforces a minimum wait time between multiple calls to a function.
 * @param {Function} callback - Function associated with `utils.wait` call.
 * @example
 * utils.wait(this, 2.0, this.delayCall); 
 * utils.killWaitsFor(this.delayCall); // Cancels delay call
 */
export function killWaitsFor(callback) {
  gsap.killTweensOf(callback);
}

/**
 * Cancels a queued wait call.
 * @param {gsap.delayedCall} delayedCall - The result of `utils.wait`.
 * @example
 * let delayedCall = utils.wait(this, 2.0, this.delayCall); 
 * utils.killWait(delayedCall); // Cancels delay call
 */
export function killWait(delayedCall){
  if (delayedCall){
    delayedCall.kill();
  }
}

/**
 * Calls function after set delay.
 * @param {Object} [thisScope=null] - Optionally supply a scope in which to call the function.
 * @param {number} delay - The delay in seconds.
 * @param {Function} callback - The function to call.
 * @param {Array} [callpackParams=null] - An array of parameters to supply to the callback function.
 * @returns {gsap.delayedCall} delayedCall - The queued GSAP delayed call instance.
 * @example
 * utils.wait(this, 2.0, this.delayCall); 
 */
export function wait(){
  
  let args = Array.from(arguments);
  
  let thisArg = null;
  if (typeof args[0] !== 'number'){
    thisArg = args.shift();
  }
  
  if (args.length == 0){
    args[0] = 0.0; // Delay
  }
  
  if (args.length == 1){
    args[1] = null; // Callback
  }
  
  if (args.length == 2){
    args[2] = null; // params
  }
  
  if (thisArg !== null){
    args[3] = thisArg;
  }
  
  return gsap.delayedCall.apply(thisArg, args);
  
}

/**
 * Shuffles the element order of an array.
 * @param {Array} array - Array to shuffle.
 * @example
 * let tmp = ['one','two','three'];
 * utils.shuffle(tmp);
 */
export function shuffle(array) {
  let counter = array.length;
  while (counter > 0) {
    let index = Math.floor(Math.random() * counter);
    counter--;
    let temp = array[counter];
    array[counter] = array[index];
    array[index] = temp;
  }
  return array;
}

/**
 * Darkens color by set percentage.
 * @param {int} color - Color value, eg. 0xff3300.
 * @param {number} brightness - Amount to darken in the range of 0.0 to 1.0.
 * @returns {int} colorResult - Darkened color value.
 * @example
 * utils.darkenCol(0xff3300, 0.5); // 50% darken
 */  
export function darkenCol(col, amt) {
  return lightenCol(col, -Math.abs(amt))
}
  
/**
 * Lightens color by set percentage.
 * @param {int} color - Color value, eg. 0xff3300.
 * @param {number} brightness - Amount to brighten in the range of 0.0 to 1.0.
 * @returns {int} colorResult - Lightened color value.
 * @example
 * utils.lightenCol(0xff3300, 0.5); // 50% lighter
 */  
export function lightenCol(rgb, brite)
{
  var r;
  var g;
  var b;
  
  brite*= 100;
  
  if (brite == 0)
    return rgb;
  
  if (brite < 0)
  {
    brite = (100 + brite) / 100;
    r = ((rgb >> 16) & 0xFF) * brite;
    g = ((rgb >> 8) & 0xFF) * brite;
    b = (rgb & 0xFF) * brite;
  }
  else // bright > 0
  {
    brite /= 100;
    r = ((rgb >> 16) & 0xFF);
    g = ((rgb >> 8) & 0xFF);
    b = (rgb & 0xFF);
    
    r += ((0xFF - r) * brite);
    g += ((0xFF - g) * brite);
    b += ((0xFF - b) * brite);
    
    r = Math.min(r, 255);
    g = Math.min(g, 255);
    b = Math.min(b, 255);
  }

  return (r << 16) | (g << 8) | b;
}


/**
 * Determines if currently running on a touch device.
 * @returns {boolean} isTouch
 */  
export function isTouchDevice() {
  return (('ontouchstart' in window) ||
     (navigator.maxTouchPoints > 0) ||
     (navigator.msMaxTouchPoints > 0));
}

// Class utils 

/**
 * Determines if the given HTML DOM Element has the class applied.
 * @param {DOMElement} htmlEle - The target element.
 * @param {string} className - The CSS class name.
 * @returns {boolean} classExists - Whether the class is applied. 
 */  
export function hasClass(el, className) {  
  if (el.classList) {
    return el.classList.contains(className);
  }
  return !!el.className.match(new RegExp('(\\s|^)' + className + '(\\s|$)'));    
}

/**
 * Adds class to HTML DOM Element if not already applied.
 * @param {DOMElement} htmlEle - The target element.
 * @param {string} className - The CSS class name.
 */  
export function addClass(el, className) {  
  if (el.classList) {
      el.classList.add(className)
  } else if (!hasClass(el, className)) {
      el.className += " " + className;
  }
}

/**
 * Removes class from HTML DOM Element.
 * @param {DOMElement} htmlEle - The target element.
 * @param {string} className - The CSS class name.
 */  
export function removeClass(el, className){
  if (el.classList) {
    el.classList.remove(className)
  } else if (hasClass(el, className)) {
    var reg = new RegExp('(\\s|^)' + className + '(\\s|$)');
    el.className = el.className.replace(reg, ' ');
  }
}

/**
 * Loads a js document at runtime.
 * @param {string} jsFilePath - The path to js file.
 * @param {Function} loadCallback - The function to call after script is loaded.
 * @example 
 * loadScript('js/my-script.js', this.onScriptLoaded.bind(this))
 */
export function loadScript(url, callback) {
  
  // adding the script element to the head as suggested before
  var head = document.getElementsByTagName('head')[0];
  var script = document.createElement('script');
  script.type = 'text/javascript';
  script.src = url;
  // then bind the event to the callback function 
  // there are several events for cross browser compatibility
  script.onreadystatechange = callback;
  script.onload = callback;
  // fire the loading
  head.appendChild(script);
}


/**
 * Extend an object with any properties defined in a second object.
 * @param {Object} baseObject - The base object.
 * @param {Object} extendObject - The object with properties to be applied to the base object.
 * @example 
 * constructor(options) {
 *   let defaults = {
 *     color: 0xff3300,
 *     isLabel: true
 *   };
 *   options = utils.extend(defaults, options);
 * }  
 */
export function extend(obj, deep) {
  
  var argsStart,
      args,
      deepClone;

  if (typeof deep === 'boolean') {
      argsStart = 2;
      deepClone = deep;
  } else {
      argsStart = 1;
      deepClone = true;
  }

  for (var i = argsStart; i < arguments.length; i++) {
    
      var source = arguments[i];

      if (source) {
          for (var prop in source) {
              if (deepClone && source[prop] && source[prop].constructor === Object) {
                  if (!obj[prop] || obj[prop].constructor === Object) {
                      obj[prop] = obj[prop] || {};
                      extend(obj[prop], deepClone, source[prop]);
                  } else {
                      obj[prop] = source[prop];
                  }
              } else {
                  obj[prop] = source[prop];
              }
          }
      }
  }
  
  return obj;
  
};


/**
 * Apply `Object.freeze` to an object and its child objects recursively.
 * @param {Object} targetObject - The object to freeze.
 */
export function deepFreeze(obj){
  // Retrieve the property names defined on object
  const propNames = Object.getOwnPropertyNames(obj);
  // Freeze properties before freezing self
  for (const name of propNames) {
    const value = obj[name];
    if (value && typeof value === "object") {
      deepFreeze(value);
    }
  }
  return Object.freeze(obj);
}

/**
 * Retrieves a query variable from the current url.
 * @returns {string} value
 */
export function getQueryVar(varname){
  let params = (new URL(document.location)).searchParams;
  return params.get(varname);
}

// Clipboard 

function _fallbackCopyTextToClipboard(text) {
  var textArea = document.createElement('textarea');
  textArea.value = text;
  
  // Avoid scrolling to bottom
  textArea.style.top = '0';
  textArea.style.left ='0';
  textArea.style.position = 'fixed';

  document.body.appendChild(textArea);
  textArea.focus();
  textArea.select();

  try {
    var successful = document.execCommand('copy');
    if (!successful){
      throw new Error('Unable to copyTextToClipboard()');
    }
  } catch (err) {
    throw err;
  }

  document.body.removeChild(textArea);
}

/**
 * Copies text to clipboard with fallback for legacy browsers.
 * @param {string} text - The text to copy. 
 */
export function copyTextToClipboard(text) {
  if (!navigator.clipboard) {
    _fallbackCopyTextToClipboard(text);
    return;
  }
  navigator.clipboard.writeText(text).then(function() {
    //console.log('Async: Copying to clipboard was successful!');
  }, function(err) {
    throw err;
  });
}

/**
 * Detects if the page can scroll vertically.
 * @returns {boolean} scrollsVertically
 */
export function canPageScrollVertically(){
  let scrollHeight = Number(document.body.scrollHeight);
  let containingHeight = Number(document.body.clientHeight);
  if ((isNaN(containingHeight) || scrollHeight === containingHeight) && document.documentElement){
    let _containingHeight = Number(document.documentElement.clientHeight);
    if (!isNaN(_containingHeight) && _containingHeight > 0.0){
      containingHeight = _containingHeight;
    }
  }
  return scrollHeight > containingHeight;
}