import { WINDOW, } from './constants'; /** * Check if the given value is not a number. */ export const isNaN = Number.isNaN || WINDOW.isNaN; /** * Check if the given value is a number. * @param {*} value - The value to check. * @returns {boolean} Returns `true` if the given value is a number, else `false`. */ export function isNumber(value) { return typeof value === 'number' && !isNaN(value); } /** * Check if the given value is undefined. * @param {*} value - The value to check. * @returns {boolean} Returns `true` if the given value is undefined, else `false`. */ export function isUndefined(value) { return typeof value === 'undefined'; } /** * Check if the given value is an object. * @param {*} value - The value to check. * @returns {boolean} Returns `true` if the given value is an object, else `false`. */ export function isObject(value) { return typeof value === 'object' && value !== null; } const { hasOwnProperty } = Object.prototype; /** * Check if the given value is a plain object. * @param {*} value - The value to check. * @returns {boolean} Returns `true` if the given value is a plain object, else `false`. */ export function isPlainObject(value) { if (!isObject(value)) { return false; } try { const { constructor } = value; const { prototype } = constructor; return constructor && prototype && hasOwnProperty.call(prototype, 'isPrototypeOf'); } catch (e) { return false; } } /** * Check if the given value is a function. * @param {*} value - The value to check. * @returns {boolean} Returns `true` if the given value is a function, else `false`. */ export function isFunction(value) { return typeof value === 'function'; } /** * Iterate the given data. * @param {*} data - The data to iterate. * @param {Function} callback - The process function for each element. * @returns {*} The original data. */ export function each(data, callback) { if (data && isFunction(callback)) { if (Array.isArray(data) || isNumber(data.length)/* array-like */) { const { length } = data; let i; for (i = 0; i < length; i += 1) { if (callback.call(data, data[i], i, data) === false) { break; } } } else if (isObject(data)) { Object.keys(data).forEach((key) => { callback.call(data, data[key], key, data); }); } } return data; } /** * Extend the given object. * @param {*} obj - The object to be extended. * @param {*} args - The rest objects which will be merged to the first object. * @returns {Object} The extended object. */ export function extend(obj, ...args) { if (isObject(obj) && args.length > 0) { if (Object.assign) { return Object.assign(obj, ...args); } args.forEach((arg) => { if (isObject(arg)) { Object.keys(arg).forEach((key) => { obj[key] = arg[key]; }); } }); } return obj; } /** * Takes a function and returns a new one that will always have a particular context. * @param {Function} fn - The target function. * @param {Object} context - The new context for the function. * @returns {boolean} The new function. */ export function proxy(fn, context, ...args) { return (...args2) => fn.apply(context, args.concat(args2)); } const REGEXP_DECIMALS = /\.\d*(?:0|9){12}\d*$/i; /** * Normalize decimal number. * Check out {@link http://0.30000000000000004.com/ } * @param {number} value - The value to normalize. * @param {number} [times=100000000000] - The times for normalizing. * @returns {number} Returns the normalized number. */ export function normalizeDecimalNumber(value, times = 100000000000) { return REGEXP_DECIMALS.test(value) ? (Math.round(value * times) / times) : value; } const REGEXP_SUFFIX = /^(?:width|height|left|top|marginLeft|marginTop)$/; /** * Apply styles to the given element. * @param {Element} element - The target element. * @param {Object} styles - The styles for applying. */ export function setStyle(element, styles) { const { style } = element; each(styles, (value, property) => { if (REGEXP_SUFFIX.test(property) && isNumber(value)) { value += 'px'; } style[property] = value; }); } /** * Check if the given element has a special class. * @param {Element} element - The element to check. * @param {string} value - The class to search. * @returns {boolean} Returns `true` if the special class was found. */ export function hasClass(element, value) { return element.classList ? element.classList.contains(value) : element.className.indexOf(value) > -1; } /** * Add classes to the given element. * @param {Element} element - The target element. * @param {string} value - The classes to be added. */ export function addClass(element, value) { if (!value) { return; } if (isNumber(element.length)) { each(element, (elem) => { addClass(elem, value); }); return; } if (element.classList) { element.classList.add(value); return; } const className = element.className.trim(); if (!className) { element.className = value; } else if (className.indexOf(value) < 0) { element.className = `${className} ${value}`; } } /** * Remove classes from the given element. * @param {Element} element - The target element. * @param {string} value - The classes to be removed. */ export function removeClass(element, value) { if (!value) { return; } if (isNumber(element.length)) { each(element, (elem) => { removeClass(elem, value); }); return; } if (element.classList) { element.classList.remove(value); return; } if (element.className.indexOf(value) >= 0) { element.className = element.className.replace(value, ''); } } /** * Add or remove classes from the given element. * @param {Element} element - The target element. * @param {string} value - The classes to be toggled. * @param {boolean} added - Add only. */ export function toggleClass(element, value, added) { if (!value) { return; } if (isNumber(element.length)) { each(element, (elem) => { toggleClass(elem, value, added); }); return; } // IE10-11 doesn't support the second parameter of `classList.toggle` if (added) { addClass(element, value); } else { removeClass(element, value); } } const REGEXP_HYPHENATE = /([a-z\d])([A-Z])/g; /** * Hyphenate the given value. * @param {string} value - The value to hyphenate. * @returns {string} The hyphenated value. */ export function hyphenate(value) { return value.replace(REGEXP_HYPHENATE, '$1-$2').toLowerCase(); } /** * Get data from the given element. * @param {Element} element - The target element. * @param {string} name - The data key to get. * @returns {string} The data value. */ export function getData(element, name) { if (isObject(element[name])) { return element[name]; } else if (element.dataset) { return element.dataset[name]; } return element.getAttribute(`data-${hyphenate(name)}`); } /** * Set data to the given element. * @param {Element} element - The target element. * @param {string} name - The data key to set. * @param {string} data - The data value. */ export function setData(element, name, data) { if (isObject(data)) { element[name] = data; } else if (element.dataset) { element.dataset[name] = data; } else { element.setAttribute(`data-${hyphenate(name)}`, data); } } /** * Remove data from the given element. * @param {Element} element - The target element. * @param {string} name - The data key to remove. */ export function removeData(element, name) { if (isObject(element[name])) { try { delete element[name]; } catch (e) { element[name] = null; } } else if (element.dataset) { // #128 Safari not allows to delete dataset property try { delete element.dataset[name]; } catch (e) { element.dataset[name] = null; } } else { element.removeAttribute(`data-${hyphenate(name)}`); } } const REGEXP_SPACES = /\s\s*/; /** * Remove event listener from the target element. * @param {Element} element - The event target. * @param {string} type - The event type(s). * @param {Function} listener - The event listener. * @param {Object} options - The event options. */ export function removeListener(element, type, listener, options = {}) { if (!isFunction(listener)) { return; } const types = type.trim().split(REGEXP_SPACES); if (types.length > 1) { each(types, (t) => { removeListener(element, t, listener, options); }); return; } if (element.removeEventListener) { element.removeEventListener(type, listener, options); } else if (element.detachEvent) { element.detachEvent(`on${type}`, listener); } } /** * Add event listener to the target element. * @param {Element} element - The event target. * @param {string} type - The event type(s). * @param {Function} listener - The event listener. * @param {Object} options - The event options. */ export function addListener(element, type, listener, options = {}) { if (!isFunction(listener)) { return; } const types = type.trim().split(REGEXP_SPACES); if (types.length > 1) { each(types, (t) => { addListener(element, t, listener, options); }); return; } if (options.once) { const originalListener = listener; listener = (...args) => { removeListener(element, type, listener, options); return originalListener.apply(element, args); }; } if (element.addEventListener) { element.addEventListener(type, listener, options); } else if (element.attachEvent) { element.attachEvent(`on${type}`, listener); } } /** * Dispatch event on the target element. * @param {Element} element - The event target. * @param {string} type - The event type(s). * @param {Object} data - The additional event data. * @returns {boolean} Indicate if the event is default prevented or not. */ export function dispatchEvent(element, type, data) { if (element.dispatchEvent) { let event; // Event and CustomEvent on IE9-11 are global objects, not constructors if (isFunction(Event) && isFunction(CustomEvent)) { if (isUndefined(data)) { event = new Event(type, { bubbles: true, cancelable: true, }); } else { event = new CustomEvent(type, { detail: data, bubbles: true, cancelable: true, }); } } else if (isUndefined(data)) { event = document.createEvent('Event'); event.initEvent(type, true, true); } else { event = document.createEvent('CustomEvent'); event.initCustomEvent(type, true, true, data); } // IE9+ return element.dispatchEvent(event); } else if (element.fireEvent) { // IE6-10 (native events only) return element.fireEvent(`on${type}`); } return true; } /** * Get the offset base on the document. * @param {Element} element - The target element. * @returns {Object} The offset data. */ export function getOffset(element) { const doc = document.documentElement; const box = element.getBoundingClientRect(); return { left: box.left + ( (window.scrollX || (doc && doc.scrollLeft) || 0) - ((doc && doc.clientLeft) || 0) ), top: box.top + ( (window.scrollY || (doc && doc.scrollTop) || 0) - ((doc && doc.clientTop) || 0) ), }; } /** * Empty an element. * @param {Element} element - The element to empty. */ export function empty(element) { while (element.firstChild) { element.removeChild(element.firstChild); } } const { location } = WINDOW; const REGEXP_ORIGINS = /^(https?:)\/\/([^:/?#]+):?(\d*)/i; /** * Check if the given URL is a cross origin URL. * @param {string} url - The target URL. * @returns {boolean} Returns `true` if the given URL is a cross origin URL, else `false`. */ export function isCrossOriginURL(url) { const parts = url.match(REGEXP_ORIGINS); return parts && ( parts[1] !== location.protocol || parts[2] !== location.hostname || parts[3] !== location.port ); } /** * Add timestamp to the given URL. * @param {string} url - The target URL. * @returns {string} The result URL. */ export function addTimestamp(url) { const timestamp = `timestamp=${(new Date()).getTime()}`; return (url + (url.indexOf('?') === -1 ? '?' : '&') + timestamp); } /** * Get transforms base on the given object. * @param {Object} obj - The target object. * @returns {string} A string contains transform values. */ export function getTransforms({ rotate, scaleX, scaleY, translateX, translateY, }) { const values = []; if (isNumber(translateX) && translateX !== 0) { values.push(`translateX(${translateX}px)`); } if (isNumber(translateY) && translateY !== 0) { values.push(`translateY(${translateY}px)`); } // Rotate should come first before scale to match orientation transform if (isNumber(rotate) && rotate !== 0) { values.push(`rotate(${rotate}deg)`); } if (isNumber(scaleX) && scaleX !== 1) { values.push(`scaleX(${scaleX})`); } if (isNumber(scaleY) && scaleY !== 1) { values.push(`scaleY(${scaleY})`); } const transform = values.length ? values.join(' ') : 'none'; return { WebkitTransform: transform, msTransform: transform, transform, }; } const { navigator } = WINDOW; const IS_SAFARI_OR_UIWEBVIEW = navigator && /(Macintosh|iPhone|iPod|iPad).*AppleWebKit/i.test(navigator.userAgent); /** * Get an image's natural sizes. * @param {string} image - The target image. * @param {Function} callback - The callback function. */ export function getImageNaturalSizes(image, callback) { // Modern browsers (except Safari) if (image.naturalWidth && !IS_SAFARI_OR_UIWEBVIEW) { callback(image.naturalWidth, image.naturalHeight); return; } const newImage = document.createElement('img'); const body = document.body || document.documentElement; newImage.onload = () => { callback(newImage.width, newImage.height); if (!IS_SAFARI_OR_UIWEBVIEW) { body.removeChild(newImage); } }; newImage.src = image.src; // iOS Safari will convert the image automatically // with its orientation once append it into DOM (#279) if (!IS_SAFARI_OR_UIWEBVIEW) { newImage.style.cssText = ( 'left:0;' + 'max-height:none!important;' + 'max-width:none!important;' + 'min-height:0!important;' + 'min-width:0!important;' + 'opacity:0;' + 'position:absolute;' + 'top:0;' + 'z-index:-1;' ); body.appendChild(newImage); } } /** * Get the max ratio of a group of pointers. * @param {string} pointers - The target pointers. * @returns {number} The result ratio. */ export function getMaxZoomRatio(pointers) { const pointers2 = extend({}, pointers); const ratios = []; each(pointers, (pointer, pointerId) => { delete pointers2[pointerId]; each(pointers2, (pointer2) => { const x1 = Math.abs(pointer.startX - pointer2.startX); const y1 = Math.abs(pointer.startY - pointer2.startY); const x2 = Math.abs(pointer.endX - pointer2.endX); const y2 = Math.abs(pointer.endY - pointer2.endY); const z1 = Math.sqrt((x1 * x1) + (y1 * y1)); const z2 = Math.sqrt((x2 * x2) + (y2 * y2)); const ratio = (z2 - z1) / z1; ratios.push(ratio); }); }); ratios.sort((a, b) => Math.abs(a) < Math.abs(b)); return ratios[0]; } /** * Get a pointer from an event object. * @param {Object} event - The target event object. * @param {boolean} endOnly - Indicates if only returns the end point coordinate or not. * @returns {Object} The result pointer contains start and/or end point coordinates. */ export function getPointer({ pageX, pageY }, endOnly) { const end = { endX: pageX, endY: pageY, }; if (endOnly) { return end; } return extend({ startX: pageX, startY: pageY, }, end); } /** * Get the center point coordinate of a group of pointers. * @param {Object} pointers - The target pointers. * @returns {Object} The center point coordinate. */ export function getPointersCenter(pointers) { let pageX = 0; let pageY = 0; let count = 0; each(pointers, ({ startX, startY }) => { pageX += startX; pageY += startY; count += 1; }); pageX /= count; pageY /= count; return { pageX, pageY, }; } /** * Check if the given value is a finite number. */ export const isFinite = Number.isFinite || WINDOW.isFinite; /** * Get the max sizes in a rectangle under the given aspect ratio. * @param {Object} data - The original sizes. * @param {string} [type='contain'] - The adjust type. * @returns {Object} The result sizes. */ export function getAdjustedSizes( { aspectRatio, height, width, }, type = 'contain', // or 'cover' ) { const isValidNumber = value => isFinite(value) && value > 0; if (isValidNumber(width) && isValidNumber(height)) { const adjustedWidth = height * aspectRatio; if ((type === 'contain' && adjustedWidth > width) || (type === 'cover' && adjustedWidth < width)) { height = width / aspectRatio; } else { width = height * aspectRatio; } } else if (isValidNumber(width)) { height = width / aspectRatio; } else if (isValidNumber(height)) { width = height * aspectRatio; } return { width, height, }; } /** * Get the new sizes of a rectangle after rotated. * @param {Object} data - The original sizes. * @returns {Object} The result sizes. */ export function getRotatedSizes({ width, height, degree }) { degree = Math.abs(degree) % 180; if (degree === 90) { return { width: height, height: width, }; } const arc = ((degree % 90) * Math.PI) / 180; const sinArc = Math.sin(arc); const cosArc = Math.cos(arc); const newWidth = (width * cosArc) + (height * sinArc); const newHeight = (width * sinArc) + (height * cosArc); return degree > 90 ? { width: newHeight, height: newWidth, } : { width: newWidth, height: newHeight, }; } /** * Get a canvas which drew the given image. * @param {HTMLImageElement} image - The image for drawing. * @param {Object} imageData - The image data. * @param {Object} canvasData - The canvas data. * @param {Object} options - The options. * @returns {HTMLCanvasElement} The result canvas. */ export function getSourceCanvas( image, { rotate = 0, scaleX = 1, scaleY = 1, }, { aspectRatio, naturalWidth, naturalHeight, }, { fillColor = 'transparent', imageSmoothingEnabled = true, imageSmoothingQuality = 'low', maxWidth = Infinity, maxHeight = Infinity, minWidth = 0, minHeight = 0, }, ) { const canvas = document.createElement('canvas'); const context = canvas.getContext('2d'); const maxSizes = getAdjustedSizes({ aspectRatio, width: maxWidth, height: maxHeight, }); const minSizes = getAdjustedSizes({ aspectRatio, width: minWidth, height: minHeight, }, 'cover'); const width = Math.min(maxSizes.width, Math.max(minSizes.width, naturalWidth)); const height = Math.min(maxSizes.height, Math.max(minSizes.height, naturalHeight)); const params = [ -width / 2, -height / 2, width, height, ]; canvas.width = normalizeDecimalNumber(width); canvas.height = normalizeDecimalNumber(height); context.fillStyle = fillColor; context.fillRect(0, 0, width, height); context.save(); context.translate(width / 2, height / 2); context.rotate((rotate * Math.PI) / 180); context.scale(scaleX, scaleY); context.imageSmoothingEnabled = imageSmoothingEnabled; context.imageSmoothingQuality = imageSmoothingQuality; context.drawImage(image, ...params.map(param => Math.floor(normalizeDecimalNumber(param)))); context.restore(); return canvas; } const { fromCharCode } = String; /** * Get string from char code in data view. * @param {DataView} dataView - The data view for read. * @param {number} start - The start index. * @param {number} length - The read length. * @returns {string} The read result. */ export function getStringFromCharCode(dataView, start, length) { let str = ''; let i; length += start; for (i = start; i < length; i += 1) { str += fromCharCode(dataView.getUint8(i)); } return str; } const REGEXP_DATA_URL_HEAD = /^data:.*,/; /** * Transform Data URL to array buffer. * @param {string} dataURL - The Data URL to transform. * @returns {ArrayBuffer} The result array buffer. */ export function dataURLToArrayBuffer(dataURL) { const base64 = dataURL.replace(REGEXP_DATA_URL_HEAD, ''); const binary = atob(base64); const arrayBuffer = new ArrayBuffer(binary.length); const uint8 = new Uint8Array(arrayBuffer); each(uint8, (value, i) => { uint8[i] = binary.charCodeAt(i); }); return arrayBuffer; } /** * Transform array buffer to Data URL. * @param {ArrayBuffer} arrayBuffer - The array buffer to transform. * @param {string} mimeType - The mime type of the Data URL. * @returns {string} The result Data URL. */ export function arrayBufferToDataURL(arrayBuffer, mimeType) { const uint8 = new Uint8Array(arrayBuffer); let data = ''; // TypedArray.prototype.forEach is not supported in some browsers. each(uint8, (value) => { data += fromCharCode(value); }); return `data:${mimeType};base64,${btoa(data)}`; } /** * Get orientation value from given array buffer. * @param {ArrayBuffer} arrayBuffer - The array buffer to read. * @returns {number} The read orientation value. */ export function getOrientation(arrayBuffer) { const dataView = new DataView(arrayBuffer); let orientation; let littleEndian; let app1Start; let ifdStart; // Only handle JPEG image (start by 0xFFD8) if (dataView.getUint8(0) === 0xFF && dataView.getUint8(1) === 0xD8) { const length = dataView.byteLength; let offset = 2; while (offset < length) { if (dataView.getUint8(offset) === 0xFF && dataView.getUint8(offset + 1) === 0xE1) { app1Start = offset; break; } offset += 1; } } if (app1Start) { const exifIDCode = app1Start + 4; const tiffOffset = app1Start + 10; if (getStringFromCharCode(dataView, exifIDCode, 4) === 'Exif') { const endianness = dataView.getUint16(tiffOffset); littleEndian = endianness === 0x4949; if (littleEndian || endianness === 0x4D4D /* bigEndian */) { if (dataView.getUint16(tiffOffset + 2, littleEndian) === 0x002A) { const firstIFDOffset = dataView.getUint32(tiffOffset + 4, littleEndian); if (firstIFDOffset >= 0x00000008) { ifdStart = tiffOffset + firstIFDOffset; } } } } } if (ifdStart) { const length = dataView.getUint16(ifdStart, littleEndian); let offset; let i; for (i = 0; i < length; i += 1) { offset = ifdStart + (i * 12) + 2; if (dataView.getUint16(offset, littleEndian) === 0x0112 /* Orientation */) { // 8 is the offset of the current tag's value offset += 8; // Get the original orientation value orientation = dataView.getUint16(offset, littleEndian); // Override the orientation with its default value dataView.setUint16(offset, 1, littleEndian); break; } } } return orientation; } /** * Parse Exif Orientation value. * @param {number} orientation - The orientation to parse. * @returns {Object} The parsed result. */ export function parseOrientation(orientation) { let rotate = 0; let scaleX = 1; let scaleY = 1; switch (orientation) { // Flip horizontal case 2: scaleX = -1; break; // Rotate left 180° case 3: rotate = -180; break; // Flip vertical case 4: scaleY = -1; break; // Flip vertical and rotate right 90° case 5: rotate = 90; scaleY = -1; break; // Rotate right 90° case 6: rotate = 90; break; // Flip horizontal and rotate right 90° case 7: rotate = 90; scaleX = -1; break; // Rotate left 90° case 8: rotate = -90; break; default: } return { rotate, scaleX, scaleY, }; }