UNPKG

@joint/core

Version:

JavaScript diagramming library

1,538 lines (1,215 loc) 57.5 kB
import $ from '../mvc/Dom/index.mjs'; import V from '../V/index.mjs'; import { config } from '../config/index.mjs'; import { isBoolean, isObject, isNumber, isString, mixin, deepMixin, supplement, defaults, defaultsDeep, deepSupplement, assign, invoke, invokeProperty, sortedIndex, uniq, clone, cloneDeep, isEmpty, isEqual, isFunction, isPlainObject, toArray, debounce, groupBy, sortBy, flattenDeep, without, difference, intersection, union, has, result, omit, pick, bindAll, forIn, camelCase, uniqueId, merge } from './utilHelpers.mjs'; export const addClassNamePrefix = function(className) { if (!className) return className; return className.toString().split(' ').map(function(_className) { if (_className.substr(0, config.classNamePrefix.length) !== config.classNamePrefix) { _className = config.classNamePrefix + _className; } return _className; }).join(' '); }; export const removeClassNamePrefix = function(className) { if (!className) return className; return className.toString().split(' ').map(function(_className) { if (_className.substr(0, config.classNamePrefix.length) === config.classNamePrefix) { _className = _className.substr(config.classNamePrefix.length); } return _className; }).join(' '); }; export const parseDOMJSON = function(json, namespace) { const selectors = {}; const groupSelectors = {}; const svgNamespace = V.namespace.svg; const initialNS = namespace || svgNamespace; const fragment = document.createDocumentFragment(); const parseNode = function(siblingsDef, parentNode, parentNS) { for (let i = 0; i < siblingsDef.length; i++) { const nodeDef = siblingsDef[i]; // Text node if (typeof nodeDef === 'string') { const textNode = document.createTextNode(nodeDef); parentNode.appendChild(textNode); continue; } // TagName if (!nodeDef.hasOwnProperty('tagName')) throw new Error('json-dom-parser: missing tagName'); const tagName = nodeDef.tagName; let node; // Namespace URI const ns = (nodeDef.hasOwnProperty('namespaceURI')) ? nodeDef.namespaceURI : parentNS; node = document.createElementNS(ns, tagName); const svg = (ns === svgNamespace); const wrapperNode = (svg) ? V(node) : $(node); // Attributes const attributes = nodeDef.attributes; if (attributes) wrapperNode.attr(attributes); // Style const style = nodeDef.style; if (style) $(node).css(style); // ClassName if (nodeDef.hasOwnProperty('className')) { const className = nodeDef.className; if (svg) { node.className.baseVal = className; } else { node.className = className; } } // TextContent if (nodeDef.hasOwnProperty('textContent')) { node.textContent = nodeDef.textContent; } // Selector if (nodeDef.hasOwnProperty('selector')) { const nodeSelector = nodeDef.selector; if (selectors[nodeSelector]) throw new Error('json-dom-parser: selector must be unique'); selectors[nodeSelector] = node; wrapperNode.attr('joint-selector', nodeSelector); } // Groups if (nodeDef.hasOwnProperty('groupSelector')) { let nodeGroups = nodeDef.groupSelector; if (!Array.isArray(nodeGroups)) nodeGroups = [nodeGroups]; for (let j = 0; j < nodeGroups.length; j++) { const nodeGroup = nodeGroups[j]; let group = groupSelectors[nodeGroup]; if (!group) group = groupSelectors[nodeGroup] = []; group.push(node); } } parentNode.appendChild(node); // Children const childrenDef = nodeDef.children; if (Array.isArray(childrenDef)) { parseNode(childrenDef, node, ns); } } }; parseNode(json, fragment, initialNS); return { fragment: fragment, selectors: selectors, groupSelectors: groupSelectors }; }; // Return a simple hash code from a string. See http://werxltd.com/wp/2010/05/13/javascript-implementation-of-javas-string-hashcode-method/. export const hashCode = function(str) { let hash = 0; if (str.length === 0) return hash; for (let i = 0; i < str.length; i++) { const c = str.charCodeAt(i); hash = ((hash << 5) - hash) + c; hash = hash & hash; // Convert to 32bit integer } return hash; }; export const getByPath = function(obj, path, delimiter) { var keys = Array.isArray(path) ? path : path.split(delimiter || '/'); var key; var i = 0; var length = keys.length; while (i < length) { key = keys[i++]; if (Object(obj) === obj && key in obj) { obj = obj[key]; } else { return undefined; } } return obj; }; const isGetSafe = function(obj, key) { // Prevent prototype pollution // https://snyk.io/vuln/SNYK-JS-JSON8MERGEPATCH-1038399 if (typeof key !== 'string' && typeof key !== 'number') { key = String(key); } if (key === 'constructor' && typeof obj[key] === 'function') { return false; } if (key === '__proto__') { return false; } return true; }; export const setByPath = function(obj, path, value, delimiter) { const keys = Array.isArray(path) ? path : path.split(delimiter || '/'); const last = keys.length - 1; let diver = obj; let i = 0; for (; i < last; i++) { const key = keys[i]; if (!isGetSafe(diver, key)) return obj; const value = diver[key]; // diver creates an empty object if there is no nested object under such a key. // This means that one can populate an empty nested object with setByPath(). diver = value || (diver[key] = {}); } diver[keys[last]] = value; return obj; }; export const unsetByPath = function(obj, path, delimiter) { const keys = Array.isArray(path) ? path : path.split(delimiter || '/'); const last = keys.length - 1; let diver = obj; let i = 0; for (; i < last; i++) { const key = keys[i]; if (!isGetSafe(diver, key)) return obj; const value = diver[key]; if (!value) return obj; diver = value; } delete diver[keys[last]]; return obj; }; export const flattenObject = function(obj, delim, stop) { delim = delim || '/'; var ret = {}; for (var key in obj) { if (!obj.hasOwnProperty(key)) continue; var shouldGoDeeper = typeof obj[key] === 'object'; if (shouldGoDeeper && stop && stop(obj[key])) { shouldGoDeeper = false; } if (shouldGoDeeper) { var flatObject = flattenObject(obj[key], delim, stop); for (var flatKey in flatObject) { if (!flatObject.hasOwnProperty(flatKey)) continue; ret[key + delim + flatKey] = flatObject[flatKey]; } } else { ret[key] = obj[key]; } } return ret; }; export const uuid = function() { // credit: http://stackoverflow.com/posts/2117523/revisions return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) { var r = (Math.random() * 16) | 0; var v = (c === 'x') ? r : (r & 0x3 | 0x8); return v.toString(16); }); }; // Generates global unique id and stores it as a property of the object, if provided. export const guid = function(obj) { guid.id = guid.id || 1; if (obj === undefined) { return 'j_' + guid.id++; } obj.id = (obj.id === undefined ? 'j_' + guid.id++ : obj.id); return obj.id; }; export const toKebabCase = function(string) { return string.replace(/[A-Z]/g, '-$&').toLowerCase(); }; export const normalizeEvent = function(evt) { if (evt.normalized) return evt; const { originalEvent, target } = evt; // If the event is a touch event, normalize it to a mouse event. const touch = originalEvent && originalEvent.changedTouches && originalEvent.changedTouches[0]; if (touch) { for (let property in touch) { // copy all the properties from the first touch that are not // defined on TouchEvent (clientX, clientY, pageX, pageY, screenX, screenY, identifier, ...) if (evt[property] === undefined) { evt[property] = touch[property]; } } } // IE: evt.target could be set to SVGElementInstance for SVGUseElement if (target) { const useElement = target.correspondingUseElement; if (useElement) evt.target = useElement; } evt.normalized = true; return evt; }; export const normalizeWheel = function(evt) { // Sane values derived empirically const PIXEL_STEP = 10; const LINE_HEIGHT = 40; const PAGE_HEIGHT = 800; let sX = 0, sY = 0, pX = 0, pY = 0; // Legacy if ('detail' in evt) { sY = evt.detail; } if ('wheelDelta' in evt) { sY = -evt.wheelDelta / 120; } if ('wheelDeltaY' in evt) { sY = -evt.wheelDeltaY / 120; } if ('wheelDeltaX' in evt) { sX = -evt.wheelDeltaX / 120; } // side scrolling on FF with DOMMouseScroll if ( 'axis' in evt && evt.axis === evt.HORIZONTAL_AXIS ) { sX = sY; sY = 0; } pX = 'deltaX' in evt ? evt.deltaX : sX * PIXEL_STEP; pY = 'deltaY' in evt ? evt.deltaY : sY * PIXEL_STEP; if ((pX || pY) && evt.deltaMode) { if (evt.deltaMode == 1) { pX *= LINE_HEIGHT; pY *= LINE_HEIGHT; } else { pX *= PAGE_HEIGHT; pY *= PAGE_HEIGHT; } } // macOS switches deltaX and deltaY automatically when scrolling with shift key, so this is needed in other cases if (evt.deltaX === 0 && evt.deltaY !== 0 && evt.shiftKey) { pX = pY; pY = 0; sX = sY; sY = 0; } // Fall-back if spin cannot be determined if (pX && !sX) { sX = (pX < 1) ? -1 : 1; } if (pY && !sY) { sY = (pY < 1) ? -1 : 1; } return { spinX : sX, spinY : sY, deltaX : pX, deltaY : pY, }; }; export const cap = function(val, max) { return val > max ? max : val < -max ? -max : val; }; export const nextFrame = (function() { var raf; if (typeof window !== 'undefined') { raf = window.requestAnimationFrame || window.webkitRequestAnimationFrame || window.mozRequestAnimationFrame || window.oRequestAnimationFrame || window.msRequestAnimationFrame; } if (!raf) { var lastTime = 0; raf = function(callback) { var currTime = new Date().getTime(); var timeToCall = Math.max(0, 16 - (currTime - lastTime)); var id = setTimeout(function() { callback(currTime + timeToCall); }, timeToCall); lastTime = currTime + timeToCall; return id; }; } return function(callback, context, ...rest) { return (context !== undefined) ? raf(callback.bind(context, ...rest)) : raf(callback); }; })(); export const cancelFrame = (function() { var caf; var client = typeof window != 'undefined'; if (client) { caf = window.cancelAnimationFrame || window.webkitCancelAnimationFrame || window.webkitCancelRequestAnimationFrame || window.msCancelAnimationFrame || window.msCancelRequestAnimationFrame || window.oCancelAnimationFrame || window.oCancelRequestAnimationFrame || window.mozCancelAnimationFrame || window.mozCancelRequestAnimationFrame; } caf = caf || clearTimeout; return client ? caf.bind(window) : caf; })(); export const isPercentage = function(val) { return isString(val) && val.slice(-1) === '%'; }; export const parseCssNumeric = function(val, restrictUnits) { function getUnit(validUnitExp) { // one or more numbers, followed by // any number of ( // `.`, followed by // one or more numbers // ), followed by // `validUnitExp`, followed by // end of string var matches = new RegExp('(?:\\d+(?:\\.\\d+)*)(' + validUnitExp + ')$').exec(val); if (!matches) return null; return matches[1]; } var number = parseFloat(val); // if `val` cannot be parsed as a number, return `null` if (Number.isNaN(number)) return null; // else: we know `output.value` var output = {}; output.value = number; // determine the unit var validUnitExp; if (restrictUnits == null) { // no restriction // accept any unit, as well as no unit validUnitExp = '[A-Za-z]*'; } else if (Array.isArray(restrictUnits)) { // if this is an empty array, top restriction - return `null` if (restrictUnits.length === 0) return null; // else: restriction - an array of valid unit strings validUnitExp = restrictUnits.join('|'); } else if (isString(restrictUnits)) { // restriction - a single valid unit string validUnitExp = restrictUnits; } var unit = getUnit(validUnitExp); // if we found no matches for `restrictUnits`, return `null` if (unit === null) return null; // else: we know the unit output.unit = unit; return output; }; const NO_SPACE = 0; function splitWordWithEOL(word, eol) { const eolWords = word.split(eol); let n = 1; for (let j = 0, jl = eolWords.length - 1; j < jl; j++) { const replacement = []; if (j > 0 || eolWords[0] !== '') replacement.push(NO_SPACE); replacement.push(eol); if (j < jl - 1 || eolWords[jl] !== '') replacement.push(NO_SPACE); eolWords.splice(n, 0, ...replacement); n += replacement.length + 1; } return eolWords.filter(word => word !== ''); } function getLineHeight(heightValue, textElement) { if (heightValue === null) { // Default 1em lineHeight return textElement.getBBox().height; } switch (heightValue.unit) { case 'em': return textElement.getBBox().height * heightValue.value; case 'px': case '': return heightValue.value; } } export const breakText = function(text, size, styles = {}, opt = {}) { var width = size.width; var height = size.height; var svgDocument = opt.svgDocument || V('svg').node; var textSpan = V('tspan').node; var textElement = V('text').attr(styles).append(textSpan).node; var textNode = document.createTextNode(''); // Prevent flickering textElement.style.opacity = 0; // Prevent FF from throwing an uncaught exception when `getBBox()` // called on element that is not in the render tree (is not measurable). // <tspan>.getComputedTextLength() returns always 0 in this case. // Note that the `textElement` resp. `textSpan` can become hidden // when it's appended to the DOM and a `display: none` CSS stylesheet // rule gets applied. textElement.style.display = 'block'; textSpan.style.display = 'block'; textSpan.appendChild(textNode); svgDocument.appendChild(textElement); // lgtm [js/xss-through-dom] if (!opt.svgDocument) { document.body.appendChild(svgDocument); } const preserveSpaces = opt.preserveSpaces; const space = ' '; const separator = (opt.separator || opt.separator === '') ? opt.separator : space; // If separator is a RegExp, we use the space character to join words together again (not ideal) const separatorChar = (typeof separator === 'string') ? separator : space; var eol = opt.eol || '\n'; var hyphen = opt.hyphen ? new RegExp(opt.hyphen) : /[^\w\d\u00C0-\u1FFF\u2800-\uFFFD]/; var maxLineCount = opt.maxLineCount; if (!isNumber(maxLineCount)) maxLineCount = Infinity; var words = text.split(separator); var full = []; var lines = []; var p, h; var lineHeight; if (preserveSpaces) { V(textSpan).attr('xml:space', 'preserve'); } for (var i = 0, l = 0, len = words.length; i < len; i++) { var word = words[i]; if (!word && !preserveSpaces) continue; if (typeof word !== 'string') continue; var isEol = false; if (eol && word.indexOf(eol) >= 0) { // word contains end-of-line character if (word.length > 1) { // separate word and continue cycle const eolWords = splitWordWithEOL(words[i], eol); words.splice(i, 1, ...eolWords); i--; len = words.length; continue; } else { // creates a new line if (preserveSpaces && typeof words[i - 1] === 'string' ) { words.splice(i, NO_SPACE, '', NO_SPACE); len += 2; i--; continue; } lines[++l] = (!preserveSpaces || typeof words[i + 1] === 'string') ? '' : undefined; isEol = true; } } if (!isEol) { let data; if (preserveSpaces) { data = lines[l] !== undefined ? lines[l] + separatorChar + word : word; } else { data = lines[l] ? lines[l] + separatorChar + word : word; } textNode.data = data; if (textSpan.getComputedTextLength() <= width) { // the current line fits lines[l] = data; if (p || h) { // We were partitioning. Put rest of the word onto next line full[l++] = true; // cancel partitioning and splitting by hyphens p = 0; h = 0; } } else { if (!lines[l] || p) { var partition = !!p; p = word.length - 1; if (partition || !p) { // word has only one character. if (!p) { if (!lines[l]) { // we won't fit this text within our rect lines = []; break; } // partitioning didn't help on the non-empty line // try again, but this time start with a new line // cancel partitions created words.splice(i, 2, word + words[i + 1]); // adjust word length len--; full[l++] = true; i--; continue; } // move last letter to the beginning of the next word words[i] = word.substring(0, p); const nextWord = words[i + 1]; words[i + 1] = word.substring(p) + (nextWord === undefined || nextWord === NO_SPACE ? '' : nextWord); } else { if (h) { // cancel splitting and put the words together again words.splice(i, 2, words[i] + words[i + 1]); h = 0; } else { var hyphenIndex = word.search(hyphen); if (hyphenIndex > -1 && hyphenIndex !== word.length - 1 && hyphenIndex !== 0) { h = hyphenIndex + 1; p = 0; } // We initiate partitioning or splitting // split the long word into two words words.splice(i, 1, word.substring(0, h || p), word.substring(h|| p)); // adjust words length len++; } if (l && !full[l - 1]) { // if the previous line is not full, try to fit max part of // the current word there l--; } } if (!preserveSpaces || lines[l] !== '') { i--; } continue; } l++; i--; } } var lastL = null; if (lines.length > maxLineCount) { lastL = maxLineCount - 1; } else if (height !== undefined) { // if size.height is defined we have to check whether the height of the entire // text exceeds the rect height if (lineHeight === undefined && textNode.data !== '') { // use the same defaults as in V.prototype.text if (styles.lineHeight === 'auto') { lineHeight = getLineHeight({ value: 1.5, unit: 'em' }, textElement); } else { const parsed = parseCssNumeric(styles.lineHeight, ['em', 'px', '']); lineHeight = getLineHeight(parsed, textElement); } } if (lineHeight * lines.length > height) { // remove overflowing lines lastL = Math.floor(height / lineHeight) - 1; } } if (lastL !== null) { lines.splice(lastL + 1); // add ellipsis var ellipsis = opt.ellipsis; if (!ellipsis || lastL < 0) break; if (typeof ellipsis !== 'string') ellipsis = '\u2026'; var lastLine = lines[lastL]; if (!lastLine && !isEol) break; var k = lastLine.length; var lastLineWithOmission, lastChar; do { lastChar = lastLine[k]; lastLineWithOmission = lastLine.substring(0, k); if (!lastChar) { lastLineWithOmission += separatorChar; } else if (lastChar.match(separator)) { lastLineWithOmission += lastChar; } lastLineWithOmission += ellipsis; textNode.data = lastLineWithOmission; if (textSpan.getComputedTextLength() <= width) { lines[lastL] = lastLineWithOmission; break; } k--; } while (k >= 0); break; } } if (opt.svgDocument) { // svg document was provided, remove the text element only svgDocument.removeChild(textElement); } else { // clean svg document document.body.removeChild(svgDocument); } return lines.join(eol); }; // Sanitize HTML // Based on https://gist.github.com/ufologist/5a0da51b2b9ef1b861c30254172ac3c9 // Parses a string into an array of DOM nodes. // Then outputs it back as a string. export const sanitizeHTML = function(html) { // Ignores tags that are invalid inside a <div> tag (e.g. <body>, <head>) const [outputEl] = $.parseHTML('<div>' + html + '</div>'); Array.from(outputEl.getElementsByTagName('*')).forEach(function(node) { // for all nodes const names = node.getAttributeNames(); names.forEach(function(name) { const value = node.getAttribute(name); // Remove attribute names that start with "on" (e.g. onload, onerror...). // Remove attribute values that start with "javascript:" pseudo protocol (e.g. `href="javascript:alert(1)"`). if (name.startsWith('on') || value.startsWith('javascript:' || value.startsWith('data:') || value.startsWith('vbscript:'))) { node.removeAttribute(name); } }); }); return outputEl.innerHTML; }; // Download `blob` as file with `fileName`. // Does not work in IE9. export const downloadBlob = function(blob, fileName) { if (window.navigator.msSaveBlob) { // requires IE 10+ // pulls up a save dialog window.navigator.msSaveBlob(blob, fileName); } else { // other browsers // downloads directly in Chrome and Safari // presents a save/open dialog in Firefox // Firefox bug: `from` field in save dialog always shows `from:blob:` // https://bugzilla.mozilla.org/show_bug.cgi?id=1053327 var url = window.URL.createObjectURL(blob); var link = document.createElement('a'); link.href = url; link.download = fileName; document.body.appendChild(link); link.click(); document.body.removeChild(link); window.URL.revokeObjectURL(url); // mark the url for garbage collection } }; // Download `dataUri` as file with `fileName`. // Does not work in IE9. export const downloadDataUri = function(dataUri, fileName) { const blob = dataUriToBlob(dataUri); downloadBlob(blob, fileName); }; // Convert an uri-encoded data component (possibly also base64-encoded) to a blob. export const dataUriToBlob = function(dataUri) { // first, make sure there are no newlines in the data uri dataUri = dataUri.replace(/\s/g, ''); dataUri = decodeURIComponent(dataUri); var firstCommaIndex = dataUri.indexOf(','); // split dataUri as `dataTypeString`,`data` var dataTypeString = dataUri.slice(0, firstCommaIndex); // e.g. 'data:image/jpeg;base64' var mimeString = dataTypeString.split(':')[1].split(';')[0]; // e.g. 'image/jpeg' var data = dataUri.slice(firstCommaIndex + 1); var decodedString; if (dataTypeString.indexOf('base64') >= 0) { // data may be encoded in base64 decodedString = atob(data); // decode data } else { // convert the decoded string to UTF-8 decodedString = unescape(encodeURIComponent(data)); } // write the bytes of the string to a typed array var ia = new Uint8Array(decodedString.length); for (var i = 0; i < decodedString.length; i++) { ia[i] = decodedString.charCodeAt(i); } return new Blob([ia], { type: mimeString }); // return the typed array as Blob }; // Read an image at `url` and return it as base64-encoded data uri. // The mime type of the image is inferred from the `url` file extension. // If data uri is provided as `url`, it is returned back unchanged. // `callback` is a method with `err` as first argument and `dataUri` as second argument. // Works with IE9. export const imageToDataUri = function(url, callback) { if (!url || url.substr(0, 'data:'.length) === 'data:') { // No need to convert to data uri if it is already in data uri. // This not only convenient but desired. For example, // IE throws a security error if data:image/svg+xml is used to render // an image to the canvas and an attempt is made to read out data uri. // Now if our image is already in data uri, there is no need to render it to the canvas // and so we can bypass this error. // Keep the async nature of the function. return setTimeout(function() { callback(null, url); }, 0); } // chrome, IE10+ var modernHandler = function(xhr, callback) { if (xhr.status === 200) { var reader = new FileReader(); reader.onload = function(evt) { var dataUri = evt.target.result; callback(null, dataUri); }; reader.onerror = function() { callback(new Error('Failed to load image ' + url)); }; reader.readAsDataURL(xhr.response); } else { callback(new Error('Failed to load image ' + url)); } }; var legacyHandler = function(xhr, callback) { var Uint8ToString = function(u8a) { var CHUNK_SZ = 0x8000; var c = []; for (var i = 0; i < u8a.length; i += CHUNK_SZ) { c.push(String.fromCharCode.apply(null, u8a.subarray(i, i + CHUNK_SZ))); } return c.join(''); }; if (xhr.status === 200) { var bytes = new Uint8Array(xhr.response); var suffix = (url.split('.').pop()) || 'png'; var map = { 'svg': 'svg+xml' }; var meta = 'data:image/' + (map[suffix] || suffix) + ';base64,'; var b64encoded = meta + btoa(Uint8ToString(bytes)); callback(null, b64encoded); } else { callback(new Error('Failed to load image ' + url)); } }; var xhr = new XMLHttpRequest(); xhr.open('GET', url, true); xhr.addEventListener('error', function() { callback(new Error('Failed to load image ' + url)); }); xhr.responseType = window.FileReader ? 'blob' : 'arraybuffer'; xhr.addEventListener('load', function() { if (window.FileReader) { modernHandler(xhr, callback); } else { legacyHandler(xhr, callback); } }); xhr.send(); }; export const getElementBBox = function(el) { var $el = $(el); if ($el.length === 0) { throw new Error('Element not found'); } var element = $el[0]; var doc = element.ownerDocument; var clientBBox = element.getBoundingClientRect(); var strokeWidthX = 0; var strokeWidthY = 0; // Firefox correction if (element.ownerSVGElement) { var vel = V(element); var bbox = vel.getBBox({ target: vel.svg() }); // if FF getBoundingClientRect includes stroke-width, getBBox doesn't. // To unify this across all browsers we need to adjust the final bBox with `stroke-width` value. strokeWidthX = (clientBBox.width - bbox.width); strokeWidthY = (clientBBox.height - bbox.height); } return { x: clientBBox.left + window.pageXOffset - doc.documentElement.offsetLeft + strokeWidthX / 2, y: clientBBox.top + window.pageYOffset - doc.documentElement.offsetTop + strokeWidthY / 2, width: clientBBox.width - strokeWidthX, height: clientBBox.height - strokeWidthY }; }; // Highly inspired by the jquery.sortElements plugin by Padolsey. // See http://james.padolsey.com/javascript/sorting-elements-with-jquery/. export const sortElements = function(elements, comparator) { elements = $(elements).toArray(); var placements = elements.map(function(sortElement) { var parentNode = sortElement.parentNode; // Since the element itself will change position, we have // to have some way of storing it's original position in // the DOM. The easiest way is to have a 'flag' node: var nextSibling = parentNode.insertBefore(document.createTextNode(''), sortElement.nextSibling); return function() { if (parentNode === this) { throw new Error('You can\'t sort elements if any one is a descendant of another.'); } // Insert before flag: parentNode.insertBefore(this, nextSibling); // Remove flag: parentNode.removeChild(nextSibling); }; }); elements.sort(comparator); for (var i = 0; i < placements.length; i++) { placements[i].call(elements[i]); } return elements; }; // Sets attributes on the given element and its descendants based on the selector. // `attrs` object: { [SELECTOR1]: { attrs1 }, [SELECTOR2]: { attrs2}, ... } e.g. { 'input': { color : 'red' }} export const setAttributesBySelector = function(element, attrs) { var $element = $(element); forIn(attrs, function(attrs, selector) { var $elements = $element.find(selector).addBack().filter(selector); // Make a special case for setting classes. // We do not want to overwrite any existing class. if (has(attrs, 'class')) { $elements.addClass(attrs['class']); attrs = omit(attrs, 'class'); } $elements.attr(attrs); }); }; // Return a new object with all four sides (top, right, bottom, left) in it. // Value of each side is taken from the given argument (either number or object). // Default value for a side is 0. // Examples: // normalizeSides(5) --> { top: 5, right: 5, bottom: 5, left: 5 } // normalizeSides({ horizontal: 5 }) --> { top: 0, right: 5, bottom: 0, left: 5 } // normalizeSides({ left: 5 }) --> { top: 0, right: 0, bottom: 0, left: 5 } // normalizeSides({ horizontal: 10, left: 5 }) --> { top: 0, right: 10, bottom: 0, left: 5 } // normalizeSides({ horizontal: 0, left: 5 }) --> { top: 0, right: 0, bottom: 0, left: 5 } export const normalizeSides = function(box) { if (Object(box) !== box) { // `box` is not an object var val = 0; // `val` left as 0 if `box` cannot be understood as finite number if (isFinite(box)) val = +box; // actually also accepts string numbers (e.g. '100') return { top: val, right: val, bottom: val, left: val }; } // `box` is an object var top, right, bottom, left; top = right = bottom = left = 0; if (isFinite(box.vertical)) top = bottom = +box.vertical; if (isFinite(box.horizontal)) right = left = +box.horizontal; if (isFinite(box.top)) top = +box.top; // overwrite vertical if (isFinite(box.right)) right = +box.right; // overwrite horizontal if (isFinite(box.bottom)) bottom = +box.bottom; // overwrite vertical if (isFinite(box.left)) left = +box.left; // overwrite horizontal return { top: top, right: right, bottom: bottom, left: left }; }; export const timing = { linear: function(t) { return t; }, quad: function(t) { return t * t; }, cubic: function(t) { return t * t * t; }, inout: function(t) { if (t <= 0) return 0; if (t >= 1) return 1; var t2 = t * t; var t3 = t2 * t; return 4 * (t < .5 ? t3 : 3 * (t - t2) + t3 - .75); }, exponential: function(t) { return Math.pow(2, 10 * (t - 1)); }, bounce: function(t) { for (var a = 0, b = 1; 1; a += b, b /= 2) { if (t >= (7 - 4 * a) / 11) { var q = (11 - 6 * a - 11 * t) / 4; return -q * q + b * b; } } }, reverse: function(f) { return function(t) { return 1 - f(1 - t); }; }, reflect: function(f) { return function(t) { return .5 * (t < .5 ? f(2 * t) : (2 - f(2 - 2 * t))); }; }, clamp: function(f, n, x) { n = n || 0; x = x || 1; return function(t) { var r = f(t); return r < n ? n : r > x ? x : r; }; }, back: function(s) { if (!s) s = 1.70158; return function(t) { return t * t * ((s + 1) * t - s); }; }, elastic: function(x) { if (!x) x = 1.5; return function(t) { return Math.pow(2, 10 * (t - 1)) * Math.cos(20 * Math.PI * x / 3 * t); }; } }; export const interpolate = { number: function(a, b) { var d = b - a; return function(t) { return a + d * t; }; }, object: function(a, b) { var s = Object.keys(a); return function(t) { var i, p; var r = {}; for (i = s.length - 1; i != -1; i--) { p = s[i]; r[p] = a[p] + (b[p] - a[p]) * t; } return r; }; }, hexColor: function(a, b) { var ca = parseInt(a.slice(1), 16); var cb = parseInt(b.slice(1), 16); var ra = ca & 0x0000ff; var rd = (cb & 0x0000ff) - ra; var ga = ca & 0x00ff00; var gd = (cb & 0x00ff00) - ga; var ba = ca & 0xff0000; var bd = (cb & 0xff0000) - ba; return function(t) { var r = (ra + rd * t) & 0x000000ff; var g = (ga + gd * t) & 0x0000ff00; var b = (ba + bd * t) & 0x00ff0000; return '#' + (1 << 24 | r | g | b).toString(16).slice(1); }; }, unit: function(a, b) { var r = /(-?[0-9]*.[0-9]*)(px|em|cm|mm|in|pt|pc|%)/; var ma = r.exec(a); var mb = r.exec(b); var p = mb[1].indexOf('.'); var f = p > 0 ? mb[1].length - p - 1 : 0; a = +ma[1]; var d = +mb[1] - a; var u = ma[2]; return function(t) { return (a + d * t).toFixed(f) + u; }; } }; // SVG filters. // (values in parentheses are default values) export const filter = { // `color` ... outline color ('blue') // `width`... outline width (1) // `opacity` ... outline opacity (1) // `margin` ... gap between outline and the element (2) outline: function(args) { var tpl = '<filter><feFlood flood-color="${color}" flood-opacity="${opacity}" result="colored"/><feMorphology in="SourceAlpha" result="morphedOuter" operator="dilate" radius="${outerRadius}" /><feMorphology in="SourceAlpha" result="morphedInner" operator="dilate" radius="${innerRadius}" /><feComposite result="morphedOuterColored" in="colored" in2="morphedOuter" operator="in"/><feComposite operator="xor" in="morphedOuterColored" in2="morphedInner" result="outline"/><feMerge><feMergeNode in="outline"/><feMergeNode in="SourceGraphic"/></feMerge></filter>'; var margin = Number.isFinite(args.margin) ? args.margin : 2; var width = Number.isFinite(args.width) ? args.width : 1; return template(tpl)({ color: args.color || 'blue', opacity: Number.isFinite(args.opacity) ? args.opacity : 1, outerRadius: margin + width, innerRadius: margin }); }, // `color` ... color ('red') // `width`... width (1) // `blur` ... blur (0) // `opacity` ... opacity (1) highlight: function(args) { var tpl = '<filter><feFlood flood-color="${color}" flood-opacity="${opacity}" result="colored"/><feMorphology result="morphed" in="SourceGraphic" operator="dilate" radius="${width}"/><feComposite result="composed" in="colored" in2="morphed" operator="in"/><feGaussianBlur result="blured" in="composed" stdDeviation="${blur}"/><feBlend in="SourceGraphic" in2="blured" mode="normal"/></filter>'; return template(tpl)({ color: args.color || 'red', width: Number.isFinite(args.width) ? args.width : 1, blur: Number.isFinite(args.blur) ? args.blur : 0, opacity: Number.isFinite(args.opacity) ? args.opacity : 1 }); }, // `x` ... horizontal blur (2) // `y` ... vertical blur (optional) blur: function(args) { var x = Number.isFinite(args.x) ? args.x : 2; return template('<filter><feGaussianBlur stdDeviation="${stdDeviation}"/></filter>')({ stdDeviation: Number.isFinite(args.y) ? [x, args.y] : x }); }, // `dx` ... horizontal shift (0) // `dy` ... vertical shift (0) // `blur` ... blur (4) // `color` ... color ('black') // `opacity` ... opacity (1) dropShadow: function(args) { var tpl = 'SVGFEDropShadowElement' in window ? '<filter><feDropShadow stdDeviation="${blur}" dx="${dx}" dy="${dy}" flood-color="${color}" flood-opacity="${opacity}"/></filter>' : '<filter><feGaussianBlur in="SourceAlpha" stdDeviation="${blur}"/><feOffset dx="${dx}" dy="${dy}" result="offsetblur"/><feFlood flood-color="${color}"/><feComposite in2="offsetblur" operator="in"/><feComponentTransfer><feFuncA type="linear" slope="${opacity}"/></feComponentTransfer><feMerge><feMergeNode/><feMergeNode in="SourceGraphic"/></feMerge></filter>'; return template(tpl)({ dx: args.dx || 0, dy: args.dy || 0, opacity: Number.isFinite(args.opacity) ? args.opacity : 1, color: args.color || 'black', blur: Number.isFinite(args.blur) ? args.blur : 4 }); }, // `amount` ... the proportion of the conversion (1). A value of 1 (default) is completely grayscale. A value of 0 leaves the input unchanged. grayscale: function(args) { var amount = Number.isFinite(args.amount) ? args.amount : 1; return template('<filter><feColorMatrix type="matrix" values="${a} ${b} ${c} 0 0 ${d} ${e} ${f} 0 0 ${g} ${b} ${h} 0 0 0 0 0 1 0"/></filter>')({ a: 0.2126 + 0.7874 * (1 - amount), b: 0.7152 - 0.7152 * (1 - amount), c: 0.0722 - 0.0722 * (1 - amount), d: 0.2126 - 0.2126 * (1 - amount), e: 0.7152 + 0.2848 * (1 - amount), f: 0.0722 - 0.0722 * (1 - amount), g: 0.2126 - 0.2126 * (1 - amount), h: 0.0722 + 0.9278 * (1 - amount) }); }, // `amount` ... the proportion of the conversion (1). A value of 1 (default) is completely sepia. A value of 0 leaves the input unchanged. sepia: function(args) { var amount = Number.isFinite(args.amount) ? args.amount : 1; return template('<filter><feColorMatrix type="matrix" values="${a} ${b} ${c} 0 0 ${d} ${e} ${f} 0 0 ${g} ${h} ${i} 0 0 0 0 0 1 0"/></filter>')({ a: 0.393 + 0.607 * (1 - amount), b: 0.769 - 0.769 * (1 - amount), c: 0.189 - 0.189 * (1 - amount), d: 0.349 - 0.349 * (1 - amount), e: 0.686 + 0.314 * (1 - amount), f: 0.168 - 0.168 * (1 - amount), g: 0.272 - 0.272 * (1 - amount), h: 0.534 - 0.534 * (1 - amount), i: 0.131 + 0.869 * (1 - amount) }); }, // `amount` ... the proportion of the conversion (1). A value of 0 is completely un-saturated. A value of 1 (default) leaves the input unchanged. saturate: function(args) { var amount = Number.isFinite(args.amount) ? args.amount : 1; return template('<filter><feColorMatrix type="saturate" values="${amount}"/></filter>')({ amount: 1 - amount }); }, // `angle` ... the number of degrees around the color circle the input samples will be adjusted (0). hueRotate: function(args) { return template('<filter><feColorMatrix type="hueRotate" values="${angle}"/></filter>')({ angle: args.angle || 0 }); }, // `amount` ... the proportion of the conversion (1). A value of 1 (default) is completely inverted. A value of 0 leaves the input unchanged. invert: function(args) { var amount = Number.isFinite(args.amount) ? args.amount : 1; return template('<filter><feComponentTransfer><feFuncR type="table" tableValues="${amount} ${amount2}"/><feFuncG type="table" tableValues="${amount} ${amount2}"/><feFuncB type="table" tableValues="${amount} ${amount2}"/></feComponentTransfer></filter>')({ amount: amount, amount2: 1 - amount }); }, // `amount` ... proportion of the conversion (1). A value of 0 will create an image that is completely black. A value of 1 (default) leaves the input unchanged. brightness: function(args) { return template('<filter><feComponentTransfer><feFuncR type="linear" slope="${amount}"/><feFuncG type="linear" slope="${amount}"/><feFuncB type="linear" slope="${amount}"/></feComponentTransfer></filter>')({ amount: Number.isFinite(args.amount) ? args.amount : 1 }); }, // `amount` ... proportion of the conversion (1). A value of 0 will create an image that is completely black. A value of 1 (default) leaves the input unchanged. contrast: function(args) { var amount = Number.isFinite(args.amount) ? args.amount : 1; return template('<filter><feComponentTransfer><feFuncR type="linear" slope="${amount}" intercept="${amount2}"/><feFuncG type="linear" slope="${amount}" intercept="${amount2}"/><feFuncB type="linear" slope="${amount}" intercept="${amount2}"/></feComponentTransfer></filter>')({ amount: amount, amount2: .5 - amount / 2 }); } }; export const format = { // Formatting numbers via the Python Format Specification Mini-language. // See http://docs.python.org/release/3.1.3/library/string.html#format-specification-mini-language. // Heavilly inspired by the D3.js library implementation. number: function(specifier, value, locale) { locale = locale || { currency: ['$', ''], decimal: '.', thousands: ',', grouping: [3] }; // See Python format specification mini-language: http://docs.python.org/release/3.1.3/library/string.html#format-specification-mini-language. // [[fill]align][sign][symbol][0][width][,][.precision][type] var re = /(?:([^{])?([<>=^]))?([+\- ])?([$#])?(0)?(\d+)?(,)?(\.-?\d+)?([a-z%])?/i; var match = re.exec(specifier); var fill = match[1] || ' '; var align = match[2] || '>'; var sign = match[3] || ''; var symbol = match[4] || ''; var zfill = match[5]; var width = +match[6]; var comma = match[7]; var precision = match[8]; var type = match[9]; var scale = 1; var prefix = ''; var suffix = ''; var integer = false; if (precision) precision = +precision.substring(1); if (zfill || fill === '0' && align === '=') { zfill = fill = '0'; align = '='; if (comma) width -= Math.floor((width - 1) / 4); } switch (type) { case 'n': comma = true; type = 'g'; break; case '%': scale = 100; suffix = '%'; type = 'f'; break; case 'p': scale = 100; suffix = '%'; type = 'r'; break; case 'b': case 'o': case 'x': case 'X': if (symbol === '#') prefix = '0' + type.toLowerCase(); break; case 'c': case 'd': integer = true; precision = 0; break; case 's': scale = -1; type = 'r'; break; } if (symbol === '$') { prefix = locale.currency[0]; suffix = locale.currency[1]; } // If no precision is specified for `'r'`, fallback to general notation. if (type == 'r' && !precision) type = 'g'; // Ensure that the requested precision is in the supported range. if (precision != null) { if (type == 'g') precision = Math.max(1, Math.min(21, precision)); else if (type == 'e' || type == 'f') precision = Math.max(0, Math.min(20, precision)); } var zcomma = zfill && comma; // Return the empty string for floats formatted as ints. if (integer && (value % 1)) return ''; // Convert negative to positive, and record the sign prefix. var negative = value < 0 || value === 0 && 1 / value < 0 ? (value = -value, '-') : sign; var fullSuffix = suffix; // Apply the scale, computing it from the value's exponent for si format. // Preserve the existing suffix, if any, such as the currency symbol. if (scale < 0) { var unit = this.prefix(value, precision); value = unit.scale(value); fullSuffix = unit.symbol + suffix; } else { value *= scale; } // Convert to the desired precision. value = this.convert(type, value, precision); // Break the value into the integer part (before) and decimal part (after). var i = value.lastIndexOf('.'); var before = i < 0 ? value : value.substring(0, i); var after = i < 0 ? '' : locale.decimal + value.substring(i + 1); function formatGroup(value) { var i = value.length; var t = []; var j = 0; var g = locale.grouping[0]; while (i > 0 && g > 0) { t.push(value.substring(i -= g, i + g)); g = locale.grouping[j = (j + 1) % locale.grouping.length]; } return t.reverse().join(locale.thousands); } // If the fill character is not `'0'`, grouping is applied before padding. if (!zfill && comma && locale.grouping) { before = formatGroup(before); } var length = prefix.length + before.length + after.length + (zcomma ? 0 : negative.length); var padding = length < width ? new Array(length = width - length + 1).join(fill) : ''; // If the fill character is `'0'`, grouping is applied after padding. if (zcomma) before = formatGroup(padding + before); // Apply prefix. negative += prefix; // Rejoin integer and deci