diff --git a/src/utils.js b/src/utils.js index ee3dc66fe..ddfa70151 100644 --- a/src/utils.js +++ b/src/utils.js @@ -9,10 +9,13 @@ import { curveNatural, curveStep, curveStepAfter, - curveStepBefore + curveStepBefore, + select } from 'd3'; import { logger } from './logger'; import { sanitizeUrl } from '@braintree/sanitize-url'; +import common from './diagrams/common/common'; +import cryptoRandomString from 'crypto-random-string'; // Effectively an enum of the supported curve types, accessible by name const d3CurveTypes = { @@ -60,17 +63,29 @@ const anyComment = /\s*%%.*\n/gm; * ``` * * @param {string} text The text defining the graph - * @returns {object} the json object representing the init to pass to mermaid.initialize() + * @returns {object} the json object representing the init passed to mermaid.initialize() */ export const detectInit = function(text) { let inits = detectDirective(text, /(?:init\b)|(?:initialize\b)/); let results = {}; if (Array.isArray(inits)) { let args = inits.map(init => init.args); - results = Object.assign(results, ...args); + results = assignWithDepth(results, [...args]); } else { results = inits.args; } + if (results) { + let type = detectType(text); + ['config'].forEach(prop => { + if (typeof results[prop] !== 'undefined') { + if (type === 'flowchart-v2') { + type = 'flowchart'; + } + results[type] = results[prop]; + delete results[prop]; + } + }); + } return results; }; @@ -91,8 +106,8 @@ export const detectInit = function(text) { * ``` * * @param {string} text The text defining the graph - * @param {string|RegExp} type The directive to return (default: null - * @returns {object | Array} An object or Array representing the directive(s): { type: string, args: object|null } matchd by the input type + * @param {string|RegExp} type The directive to return (default: null) + * @returns {object | Array} An object or Array representing the directive(s): { type: string, args: object|null } matched by the input type * if a single directive was found, that directive object will be returned. */ export const detectDirective = function(text, type = null) { @@ -206,6 +221,20 @@ export const detectType = function(text) { return 'flowchart'; }; +const memoize = (fn, resolver) => { + let cache = {}; + return (...args) => { + let n = resolver ? resolver.apply(this, args) : args[0]; + if (n in cache) { + return cache[n]; + } else { + let result = fn(...args); + cache[n] = result; + return result; + } + }; +}; + /** * @function isSubstringInArray * Detects whether a substring in present in a given array @@ -241,13 +270,13 @@ export const formatUrl = (linkStr, config) => { }; export const runFunc = (functionName, ...params) => { - var arrPaths = functionName.split('.'); + const arrPaths = functionName.split('.'); - var len = arrPaths.length - 1; - var fnName = arrPaths[len]; + const len = arrPaths.length - 1; + const fnName = arrPaths[len]; - var obj = window; - for (var i = 0; i < len; i++) { + let obj = window; + for (let i = 0; i < len; i++) { obj = obj[arrPaths[i]]; if (!obj) return; } @@ -268,10 +297,8 @@ const traverseEdge = points => { }); // Traverse half of total distance along points - const distanceToLabel = totalDistance / 2; - - let remainingDistance = distanceToLabel; - let center; + let remainingDistance = totalDistance / 2; + let center = undefined; prevPoint = undefined; points.forEach(point => { if (prevPoint && !center) { @@ -298,8 +325,7 @@ const traverseEdge = points => { }; const calcLabelPosition = points => { - const p = traverseEdge(points); - return p; + return traverseEdge(points); }; const calcCardinalityPosition = (isRelationTypePresent, points, initialPosition) => { @@ -317,7 +343,7 @@ const calcCardinalityPosition = (isRelationTypePresent, points, initialPosition) const distanceToCardinalityPoint = 25; let remainingDistance = distanceToCardinalityPoint; - let center; + let center = { x: 0, y: 0 }; prevPoint = undefined; points.forEach(point => { if (prevPoint && !center) { @@ -382,7 +408,279 @@ export const generateId = () => { ); }; +export const random = options => { + return cryptoRandomString(options); +}; + +/** + * @function assignWithDepth + * Extends the functionality of {@link ObjectConstructor.assign} with the ability to merge arbitrary-depth objects + * For each key in src with path `k` (recursively) performs an Object.assign(dst[`k`], src[`k`]) with + * a slight change from the typical handling of undefined for dst[`k`]: instead of raising an error, + * dst[`k`] is auto-initialized to {} and effectively merged with src[`k`] + *
+ * Additionally, dissimilar types will not clobber unless the config.clobber parameter === true. Example: + * ``` + * let config_0 = { foo: { bar: 'bar' }, bar: 'foo' }; + * let config_1 = { foo: 'foo', bar: 'bar' }; + * let result = assignWithDepth(config_0, config_1); + * console.log(result); + * //-> result: { foo: { bar: 'bar' }, bar: 'bar' } + * ``` + *
+ * Traditional Object.assign would have clobbered foo in config_0 with foo in config_1. + *
+ * If src is a destructured array of objects and dst is not an array, assignWithDepth will apply each element of src to dst
+ * in order.
+ * @param dst:any - the destination of the merge
+ * @param src:any - the source object(s) to merge into destination
+ * @param config:{ depth: number, clobber: boolean } - depth: depth to traverse within src and dst for merging -
+ * clobber: should dissimilar types clobber (default: { depth: 2, clobber: false })
+ * @returns {*}
+ */
+export const assignWithDepth = function(dst, src, config) {
+ const { depth, clobber } = Object.assign({ depth: 2, clobber: false }, config);
+ if (Array.isArray(src) && !Array.isArray(dst)) {
+ src.forEach(s => assignWithDepth(dst, s, config));
+ return dst;
+ }
+ if (typeof dst === 'undefined' || depth <= 0) {
+ if (dst !== undefined && dst !== null && typeof dst === 'object' && typeof src === 'object') {
+ return Object.assign(dst, src);
+ } else {
+ return src;
+ }
+ }
+ if (typeof src !== 'undefined' && typeof dst === 'object' && typeof src === 'object') {
+ Object.keys(src).forEach(key => {
+ if (
+ typeof src[key] === 'object' &&
+ (dst[key] === undefined || typeof dst[key] === 'object')
+ ) {
+ if (dst[key] === undefined) {
+ dst[key] = Array.isArray(src[key]) ? [] : {};
+ }
+ dst[key] = assignWithDepth(dst[key], src[key], { depth: depth - 1, clobber });
+ } else if (clobber || (typeof dst[key] !== 'object' && typeof src[key] !== 'object')) {
+ dst[key] = src[key];
+ }
+ });
+ }
+ return dst;
+};
+
+export const getTextObj = function() {
+ return {
+ x: 0,
+ y: 0,
+ fill: undefined,
+ anchor: 'start',
+ style: '#666',
+ width: 100,
+ height: 100,
+ textMargin: 0,
+ rx: 0,
+ ry: 0,
+ valign: undefined
+ };
+};
+
+export const drawSimpleText = function(elem, textData) {
+ // Remove and ignore br:s
+ const nText = textData.text.replace(common.lineBreakRegex, ' ');
+
+ const textElem = elem.append('text');
+ textElem.attr('x', textData.x);
+ textElem.attr('y', textData.y);
+ textElem.style('text-anchor', textData.anchor);
+ textElem.style('font-family', textData.fontFamily);
+ textElem.style('font-size', textData.fontSize);
+ textElem.style('font-weight', textData.fontWeight);
+ textElem.attr('fill', textData.fill);
+ if (typeof textData.class !== 'undefined') {
+ textElem.attr('class', textData.class);
+ }
+
+ const span = textElem.append('tspan');
+ span.attr('x', textData.x + textData.textMargin * 2);
+ span.attr('fill', textData.fill);
+ span.text(nText);
+
+ return textElem;
+};
+
+export const wrapLabel = memoize(
+ (label, maxWidth, config) => {
+ if (!label) {
+ return label;
+ }
+ config = Object.assign(
+ { fontSize: 12, fontWeight: 400, fontFamily: 'Arial', joinWith: '
' },
+ config
+ );
+ if (common.lineBreakRegex.test(label)) {
+ return label;
+ }
+ const words = label.split(' ');
+ const completedLines = [];
+ let nextLine = '';
+ words.forEach((word, index) => {
+ const wordLength = calculateTextWidth(`${word} `, config);
+ const nextLineLength = calculateTextWidth(nextLine, config);
+ if (wordLength > maxWidth) {
+ const { hyphenatedStrings, remainingWord } = breakString(word, maxWidth, '-', config);
+ completedLines.push(nextLine, ...hyphenatedStrings);
+ nextLine = remainingWord;
+ } else if (nextLineLength + wordLength >= maxWidth) {
+ completedLines.push(nextLine);
+ nextLine = word;
+ } else {
+ nextLine = [nextLine, word].filter(Boolean).join(' ');
+ }
+ const currentWord = index + 1;
+ const isLastWord = currentWord === words.length;
+ if (isLastWord) {
+ completedLines.push(nextLine);
+ }
+ });
+ return completedLines.filter(line => line !== '').join(config.joinWith);
+ },
+ (label, maxWidth, config) =>
+ `${label}-${maxWidth}-${config.fontSize}-${config.fontWeight}-${config.fontFamily}-${config.joinWith}`
+);
+
+const breakString = memoize(
+ (word, maxWidth, hyphenCharacter = '-', config) => {
+ config = Object.assign(
+ { fontSize: 12, fontWeight: 400, fontFamily: 'Arial', margin: 0 },
+ config
+ );
+ const characters = word.split('');
+ const lines = [];
+ let currentLine = '';
+ characters.forEach((character, index) => {
+ const nextLine = `${currentLine}${character}`;
+ const lineWidth = calculateTextWidth(nextLine, config);
+ if (lineWidth >= maxWidth) {
+ const currentCharacter = index + 1;
+ const isLastLine = characters.length === currentCharacter;
+ const hyphenatedNextLine = `${nextLine}${hyphenCharacter}`;
+ lines.push(isLastLine ? nextLine : hyphenatedNextLine);
+ currentLine = '';
+ } else {
+ currentLine = nextLine;
+ }
+ });
+ return { hyphenatedStrings: lines, remainingWord: currentLine };
+ },
+ (word, maxWidth, hyphenCharacter = '-', config) =>
+ `${word}-${maxWidth}-${hyphenCharacter}-${config.fontSize}-${config.fontWeight}-${config.fontFamily}`
+);
+
+/**
+ * This calculates the text's height, taking into account the wrap breaks and
+ * both the statically configured height, width, and the length of the text (in pixels).
+ *
+ * If the wrapped text text has greater height, we extend the height, so it's
+ * value won't overflow.
+ *
+ * @return - The height for the given text
+ * @param text the text to measure
+ * @param config - the config for fontSize, fontFamily, and fontWeight all impacting the resulting size
+ */
+export const calculateTextHeight = function(text, config) {
+ config = Object.assign(
+ { fontSize: 12, fontWeight: 400, fontFamily: 'Arial', margin: 15 },
+ config
+ );
+ return calculateTextDimensions(text, config).height;
+};
+
+/**
+ * This calculates the width of the given text, font size and family.
+ *
+ * @return - The width for the given text
+ * @param text - The text to calculate the width of
+ * @param config - the config for fontSize, fontFamily, and fontWeight all impacting the resulting size
+ */
+export const calculateTextWidth = function(text, config) {
+ config = Object.assign({ fontSize: 12, fontWeight: 400, fontFamily: 'Arial' }, config);
+ return calculateTextDimensions(text, config).width;
+};
+
+/**
+ * This calculates the dimensions of the given text, font size, font family, font weight, and margins.
+ *
+ * @return - The width for the given text
+ * @param text - The text to calculate the width of
+ * @param config - the config for fontSize, fontFamily, fontWeight, and margin all impacting the resulting size
+ */
+export const calculateTextDimensions = memoize(
+ function(text, config) {
+ config = Object.assign({ fontSize: 12, fontWeight: 400, fontFamily: 'Arial' }, config);
+ const { fontSize, fontFamily, fontWeight } = config;
+ if (!text) {
+ return { width: 0, height: 0 };
+ }
+
+ // We can't really know if the user supplied font family will render on the user agent;
+ // thus, we'll take the max width between the user supplied font family, and a default
+ // of sans-serif.
+ const fontFamilies = ['sans-serif', fontFamily];
+ const lines = text.split(common.lineBreakRegex);
+ let dims = [];
+
+ const body = select('body');
+ // We don't want to leak DOM elements - if a removal operation isn't available
+ // for any reason, do not continue.
+ if (!body.remove) {
+ return { width: 0, height: 0, lineHeight: 0 };
+ }
+
+ const g = body.append('svg');
+
+ for (let fontFamily of fontFamilies) {
+ let cheight = 0;
+ let dim = { width: 0, height: 0, lineHeight: 0 };
+ for (let line of lines) {
+ const textObj = getTextObj();
+ textObj.text = line;
+ const textElem = drawSimpleText(g, textObj)
+ .style('font-size', fontSize)
+ .style('font-weight', fontWeight)
+ .style('font-family', fontFamily);
+
+ let bBox = (textElem._groups || textElem)[0][0].getBBox();
+ dim.width = Math.round(Math.max(dim.width, bBox.width));
+ cheight = Math.round(bBox.height);
+ dim.height += cheight;
+ dim.lineHeight = Math.round(Math.max(dim.lineHeight, cheight));
+ }
+ dims.push(dim);
+ }
+
+ g.remove();
+
+ let index =
+ isNaN(dims[1].height) ||
+ isNaN(dims[1].width) ||
+ isNaN(dims[1].lineHeight) ||
+ (dims[0].height > dims[1].height &&
+ dims[0].width > dims[1].width &&
+ dims[0].lineHeight > dims[1].lineHeight)
+ ? 0
+ : 1;
+ return dims[index];
+ },
+ (text, config) => `${text}-${config.fontSize}-${config.fontWeight}-${config.fontFamily}`
+);
+
export default {
+ assignWithDepth,
+ wrapLabel,
+ calculateTextHeight,
+ calculateTextWidth,
+ calculateTextDimensions,
detectInit,
detectDirective,
detectType,
@@ -393,5 +691,7 @@ export default {
formatUrl,
getStylesFromArray,
generateId,
+ random,
+ memoize,
runFunc
};
diff --git a/src/utils.spec.js b/src/utils.spec.js
index b50b4df6f..85e9b1120 100644
--- a/src/utils.spec.js
+++ b/src/utils.spec.js
@@ -1,6 +1,93 @@
/* eslint-env jasmine */
import utils from './utils';
+describe('when assignWithDepth: should merge objects within objects', function() {
+ it('should handle simple, depth:1 types (identity)', function() {
+ let config_0 = { foo: 'bar', bar: 0 };
+ let config_1 = { foo: 'bar', bar: 0 };
+ let result = utils.assignWithDepth(config_0, config_1);
+ expect(result).toEqual(config_1);
+ });
+ it('should handle simple, depth:1 types (dst: undefined)', function() {
+ let config_0 = undefined;
+ let config_1 = { foo: 'bar', bar: 0 };
+ let result = utils.assignWithDepth(config_0, config_1);
+ expect(result).toEqual(config_1);
+ });
+ it('should handle simple, depth:1 types (src: undefined)', function() {
+ let config_0 = { foo: 'bar', bar: 0 };
+ let config_1 = undefined;
+ let result = utils.assignWithDepth(config_0, config_1);
+ expect(result).toEqual(config_0);
+ });
+ it('should handle simple, depth:1 types (merge)', function() {
+ let config_0 = { foo: 'bar', bar: 0 };
+ let config_1 = { foo: 'foo' };
+ let result = utils.assignWithDepth(config_0, config_1);
+ expect(result).toEqual({ foo: 'foo', bar: 0});
+ });
+ it('should handle depth:2 types (dst: orphan)', function() {
+ let config_0 = { foo: 'bar', bar: { foo: 'bar' } };
+ let config_1 = { foo: 'bar' };
+ let result = utils.assignWithDepth(config_0, config_1);
+ expect(result).toEqual(config_0);
+ });
+ it('should handle depth:2 types (dst: object, src: simple type)', function() {
+ let config_0 = { foo: 'bar', bar: { foo: 'bar' } };
+ let config_1 = { foo: 'foo', bar: 'should NOT clobber'};
+ let result = utils.assignWithDepth(config_0, config_1);
+ expect(result).toEqual({ foo: 'foo', bar: { foo: 'bar' } } );
+ });
+ it('should handle depth:2 types (src: orphan)', function() {
+ let config_0 = { foo: 'bar' };
+ let config_1 = { foo: 'bar', bar: { foo: 'bar' } };
+ let result = utils.assignWithDepth(config_0, config_1);
+ expect(result).toEqual(config_1);
+ });
+ it('should handle depth:2 types (merge)', function() {
+ let config_0 = { foo: 'bar', bar: { foo: 'bar' }, boofar: 1 };
+ let config_1 = { foo: 'foo', bar: { bar: 0 }, foobar: 'foobar' };
+ let result = utils.assignWithDepth(config_0, config_1);
+ expect(result).toEqual({ foo: "foo", bar: { foo: "bar", bar: 0 }, foobar: "foobar", boofar: 1 });
+ });
+ it('should handle depth:3 types (merge with clobber because assignWithDepth::depth == 2)', function() {
+ let config_0 = { foo: 'bar', bar: { foo: 'bar', bar: { foo: { message: 'this', willbe: 'clobbered' } } }, boofar: 1 };
+ let config_1 = { foo: 'foo', bar: { foo: 'foo', bar: { foo: { message: 'clobbered other foo' } } }, foobar: 'foobar' };
+ let result = utils.assignWithDepth(config_0, config_1);
+ expect(result).toEqual({ foo: "foo", bar: { foo: 'foo', bar: { foo: { message: 'clobbered other foo' } } }, foobar: "foobar", boofar: 1 });
+ });
+ it('should handle depth:3 types (merge with clobber because assignWithDepth::depth == 1)', function() {
+ let config_0 = { foo: 'bar', bar: { foo: 'bar', bar: { foo: { message: '', willNotbe: 'present' }, bar: 'shouldNotBePresent' } }, boofar: 1 };
+ let config_1 = { foo: 'foo', bar: { foo: 'foo', bar: { foo: { message: 'this' } } }, foobar: 'foobar' };
+ let result = utils.assignWithDepth(config_0, config_1, { depth: 1 });
+ expect(result).toEqual({ foo: "foo", bar: { foo: 'foo', bar: { foo: { message: 'this' } } }, foobar: "foobar", boofar: 1 });
+ });
+ it('should handle depth:3 types (merge with no clobber because assignWithDepth::depth == 3)', function() {
+ let config_0 = { foo: 'bar', bar: { foo: 'bar', bar: { foo: { message: '', willbe: 'present' } } }, boofar: 1 };
+ let config_1 = { foo: 'foo', bar: { foo: 'foo', bar: { foo: { message: 'this' } } }, foobar: 'foobar' };
+ let result = utils.assignWithDepth(config_0, config_1, { depth: 3 });
+ expect(result).toEqual({ foo: "foo", bar: { foo: 'foo', bar: { foo: { message: 'this', willbe: 'present' } } }, foobar: "foobar", boofar: 1 });
+ });
+});
+describe('when memoizing', function() {
+ it('should return the same value', function() {
+ const fib = utils.memoize(function(n, canary) {
+ canary.flag = true;
+ if (n < 2){
+ return 1;
+ }else{
+ //We'll console.log a loader every time we have to recurse
+ return fib(n-2, canary) + fib(n-1, canary);
+ }
+ });
+ let canary = {flag: false};
+ fib(10, canary);
+ expect(canary.flag).toBe(true);
+ canary = {flag: false};
+ fib(10, canary);
+ expect(canary.flag).toBe(false);
+ });
+})
describe('when detecting chart type ', function() {
it('should handle a graph definition', function() {
const str = 'graph TB\nbfs1:queue';
@@ -27,6 +114,16 @@ Alice->Bob: hi`;
expect(type).toBe('sequence');
expect(init).toEqual({logLevel:0,theme:"dark"});
});
+ it('should handle an init definition with config converted to the proper diagram configuration', function() {
+ const str = `
+%%{init: { 'logLevel': 0, 'theme': 'dark', 'config': {'wrapEnabled': true} } }%%
+sequenceDiagram
+Alice->Bob: hi`;
+ const type = utils.detectType(str);
+ const init = utils.detectInit(str);
+ expect(type).toBe('sequence');
+ expect(init).toEqual({logLevel:0, theme:"dark", sequence: { wrapEnabled: true }});
+ });
it('should handle a multiline init definition', function() {
const str = `
%%{