diff --git a/src/mixins/itext.svg_export.js b/src/mixins/itext.svg_export.js index 5d8f7a63c25..e4f32efe7e2 100644 --- a/src/mixins/itext.svg_export.js +++ b/src/mixins/itext.svg_export.js @@ -142,7 +142,7 @@ // if we have charSpacing, we render char by char actualStyle = actualStyle || this.getCompleteStyleDeclaration(lineIndex, i); nextStyle = this.getCompleteStyleDeclaration(lineIndex, i + 1); - timeToRender = this._hasStyleChangedForSvg(actualStyle, nextStyle); + timeToRender = fabric.util.hasStyleChanged(actualStyle, nextStyle, true); } if (timeToRender) { style = this._getStyleDeclaration(lineIndex, i) || { }; diff --git a/src/shapes/itext.class.js b/src/shapes/itext.class.js index e7623637e80..49331d3600d 100644 --- a/src/shapes/itext.class.js +++ b/src/shapes/itext.class.js @@ -510,6 +510,7 @@ * @param {function} [callback] invoked with new instance as argument */ fabric.IText.fromObject = function(object, callback) { + object.styles = fabric.util.stylesFromArray(object.styles, object.text); parseDecoration(object); if (object.styles) { for (var i in object.styles) { diff --git a/src/shapes/text.class.js b/src/shapes/text.class.js index 71fc11573e6..2a29ba174b5 100644 --- a/src/shapes/text.class.js +++ b/src/shapes/text.class.js @@ -1079,7 +1079,7 @@ // if we have charSpacing, we render char by char actualStyle = actualStyle || this.getCompleteStyleDeclaration(lineIndex, i); nextStyle = this.getCompleteStyleDeclaration(lineIndex, i + 1); - timeToRender = this._hasStyleChanged(actualStyle, nextStyle); + timeToRender = fabric.util.hasStyleChanged(actualStyle, nextStyle, false); } if (timeToRender) { if (path) { @@ -1249,34 +1249,6 @@ return this; }, - /** - * @private - * @param {Object} prevStyle - * @param {Object} thisStyle - */ - _hasStyleChanged: function(prevStyle, thisStyle) { - return prevStyle.fill !== thisStyle.fill || - prevStyle.stroke !== thisStyle.stroke || - prevStyle.strokeWidth !== thisStyle.strokeWidth || - prevStyle.fontSize !== thisStyle.fontSize || - prevStyle.fontFamily !== thisStyle.fontFamily || - prevStyle.fontWeight !== thisStyle.fontWeight || - prevStyle.fontStyle !== thisStyle.fontStyle || - prevStyle.deltaY !== thisStyle.deltaY; - }, - - /** - * @private - * @param {Object} prevStyle - * @param {Object} thisStyle - */ - _hasStyleChangedForSvg: function(prevStyle, thisStyle) { - return this._hasStyleChanged(prevStyle, thisStyle) || - prevStyle.overline !== thisStyle.overline || - prevStyle.underline !== thisStyle.underline || - prevStyle.linethrough !== thisStyle.linethrough; - }, - /** * @private * @param {Number} lineIndex index text line @@ -1538,8 +1510,7 @@ toObject: function(propertiesToInclude) { var allProperties = additionalProps.concat(propertiesToInclude); var obj = this.callSuper('toObject', allProperties); - // styles will be overridden with a properly cloned structure - obj.styles = clone(this.styles, true); + obj.styles = fabric.util.stylesToArray(this.styles, this.text); if (obj.path) { obj.path = this.path.toObject(); } @@ -1705,6 +1676,7 @@ var objectCopy = clone(object), path = object.path; delete objectCopy.path; return fabric.Object._fromObject('Text', objectCopy, function(textInstance) { + textInstance.styles = fabric.util.stylesFromArray(object.styles, object.text); if (path) { fabric.Object._fromObject('Path', path, function(pathInstance) { textInstance.set('path', pathInstance); diff --git a/src/shapes/textbox.class.js b/src/shapes/textbox.class.js index da7355632e6..887c1c05fb1 100644 --- a/src/shapes/textbox.class.js +++ b/src/shapes/textbox.class.js @@ -453,6 +453,7 @@ * @param {Function} [callback] Callback to invoke when an fabric.Textbox instance is created */ fabric.Textbox.fromObject = function(object, callback) { + object.styles = fabric.util.stylesFromArray(object.styles, object.text); return fabric.Object._fromObject('Textbox', object, callback, 'text'); }; })(typeof exports !== 'undefined' ? exports : this); diff --git a/src/util/misc.js b/src/util/misc.js index 72ea8e3f4ad..22c472fd07f 100644 --- a/src/util/misc.js +++ b/src/util/misc.js @@ -1215,5 +1215,112 @@ } return new fabric.Group([a], { clipPath: b, inverted: inverted }); }, + + /** + * @memberOf fabric.util + * @param {Object} prevStyle first style to compare + * @param {Object} thisStyle second style to compare + * @param {boolean} forTextSpans whether to check overline, underline, and line-through properties + * @return {boolean} true if the style changed + */ + hasStyleChanged: function(prevStyle, thisStyle, forTextSpans) { + forTextSpans = forTextSpans || false; + return (prevStyle.fill !== thisStyle.fill || + prevStyle.stroke !== thisStyle.stroke || + prevStyle.strokeWidth !== thisStyle.strokeWidth || + prevStyle.fontSize !== thisStyle.fontSize || + prevStyle.fontFamily !== thisStyle.fontFamily || + prevStyle.fontWeight !== thisStyle.fontWeight || + prevStyle.fontStyle !== thisStyle.fontStyle || + prevStyle.deltaY !== thisStyle.deltaY) || + (forTextSpans && + (prevStyle.overline !== thisStyle.overline || + prevStyle.underline !== thisStyle.underline || + prevStyle.linethrough !== thisStyle.linethrough)); + }, + + /** + * Returns the array form of a text object's inline styles property with styles grouped in ranges + * rather than per character. This format is less verbose, and is better suited for storage + * so it is used in serialization (not during runtime). + * @memberOf fabric.util + * @param {object} styles per character styles for a text object + * @param {String} text the text string that the styles are applied to + * @return {{start: number, end: number, style: object}[]} + */ + stylesToArray: function(styles, text) { + // clone style structure to prevent mutation + var styles = fabric.util.object.clone(styles, true), + textLines = text.split('\n'), + charIndex = -1, prevStyle = {}, stylesArray = []; + //loop through each textLine + for (var i = 0; i < textLines.length; i++) { + if (!styles[i]) { + //no styles exist for this line, so add the line's length to the charIndex total + charIndex += textLines[i].length; + continue; + } + //loop through each character of the current line + for (var c = 0; c < textLines[i].length; c++) { + charIndex++; + var thisStyle = styles[i][c]; + //check if style exists for this character + if (thisStyle) { + var styleChanged = fabric.util.hasStyleChanged(prevStyle, thisStyle, true); + if (styleChanged) { + stylesArray.push({ + start: charIndex, + end: charIndex + 1, + style: thisStyle + }); + } + else { + //if style is the same as previous character, increase end index + stylesArray[stylesArray.length - 1].end++; + } + } + prevStyle = thisStyle || {}; + } + } + return stylesArray; + }, + + /** + * Returns the object form of the styles property with styles that are assigned per + * character rather than grouped by range. This format is more verbose, and is + * only used during runtime (not for serialization/storage) + * @memberOf fabric.util + * @param {Array} styles the serialized form of a text object's styles + * @param {String} text the text string that the styles are applied to + * @return {Object} + */ + stylesFromArray: function(styles, text) { + if (!Array.isArray(styles)) { + return styles; + } + var textLines = text.split('\n'), + charIndex = -1, styleIndex = 0, stylesObject = {}; + //loop through each textLine + for (var i = 0; i < textLines.length; i++) { + //loop through each character of the current line + for (var c = 0; c < textLines[i].length; c++) { + charIndex++; + //check if there's a style collection that includes the current character + if (styles[styleIndex] + && styles[styleIndex].start <= charIndex + && charIndex < styles[styleIndex].end) { + //create object for line index if it doesn't exist + stylesObject[i] = stylesObject[i] || {}; + //assign a style at this character's index + stylesObject[i][c] = Object.assign({}, styles[styleIndex].style); + //if character is at the end of the current style collection, move to the next + if (charIndex === styles[styleIndex].end - 1) { + styleIndex++; + } + } + } + } + return stylesObject; + } }; })(typeof exports !== 'undefined' ? exports : this); diff --git a/test/unit/itext.js b/test/unit/itext.js index 72d65cfda41..411dee5f1dd 100644 --- a/test/unit/itext.js +++ b/test/unit/itext.js @@ -44,7 +44,7 @@ skewX: 0, skewY: 0, charSpacing: 0, - styles: { }, + styles: [], strokeUniform: false, path: null, direction: 'ltr', @@ -105,7 +105,10 @@ assert.ok(typeof fabric.IText.fromObject === 'function'); fabric.IText.fromObject(ITEXT_OBJECT, function(iText) { assert.ok(iText instanceof fabric.IText); - assert.deepEqual(ITEXT_OBJECT, iText.toObject()); + // change styles from array to object for comparison + var object = iText.toObject(); + object.styles = {}; + assert.deepEqual(ITEXT_OBJECT, object); done(); }); }); @@ -131,22 +134,38 @@ }); QUnit.test('toObject', function(assert) { - var styles = { + var stylesObject = { 0: { 0: { fill: 'red' }, 1: { textDecoration: 'underline' } } }; + var stylesArray = [ + { + start: 0, + end: 1, + style: { fill: 'red' } + }, + { + start: 1, + end: 2, + style: { textDecoration: 'underline' } + } + ]; var iText = new fabric.IText('test', { - styles: styles + styles: stylesObject }); assert.equal(typeof iText.toObject, 'function'); var obj = iText.toObject(); - assert.deepEqual(obj.styles, styles); - assert.notEqual(obj.styles[0], styles[0]); - assert.notEqual(obj.styles[0][1], styles[0][1]); - assert.deepEqual(obj.styles[0], styles[0]); - assert.deepEqual(obj.styles[0][1], styles[0][1]); + assert.deepEqual(obj.styles, stylesArray); + assert.notEqual(obj.styles[0], stylesArray[0]); + assert.notEqual(obj.styles[1], stylesArray[1]); + assert.notEqual(obj.styles[0].style, stylesArray[0].style); + assert.notEqual(obj.styles[1].style, stylesArray[1].style); + assert.deepEqual(obj.styles[0], stylesArray[0]); + assert.deepEqual(obj.styles[1], stylesArray[1]); + assert.deepEqual(obj.styles[0].style, stylesArray[0].style); + assert.deepEqual(obj.styles[1].style, stylesArray[1].style); }); QUnit.test('setSelectionStart', function(assert) { diff --git a/test/unit/text.js b/test/unit/text.js index cbd956e5cd6..643fef3032b 100644 --- a/test/unit/text.js +++ b/test/unit/text.js @@ -51,7 +51,7 @@ skewX: 0, skewY: 0, charSpacing: 0, - styles: {}, + styles: [], path: null, strokeUniform: false, direction: 'ltr', diff --git a/test/unit/textbox.js b/test/unit/textbox.js index e47522b19b7..4e50790998a 100644 --- a/test/unit/textbox.js +++ b/test/unit/textbox.js @@ -13,8 +13,8 @@ originY: 'top', left: 0, top: 0, - width: 20, - height: 45.2, + width: 120, + height: 202.5, fill: 'rgb(0,0,0)', stroke: null, strokeWidth: 1, @@ -31,7 +31,7 @@ opacity: 1, shadow: null, visible: true, - text: 'x', + text: 'The quick \nbrown \nfox', fontSize: 40, fontWeight: 'normal', fontFamily: 'Times New Roman', @@ -49,7 +49,18 @@ skewX: 0, skewY: 0, charSpacing: 0, - styles: { }, + styles: [ + { + start: 5, + end: 9, + style: { fill: "red" } + }, + { + start: 13, + end: 18, + style: { underline: true } + } + ], minWidth: 20, splitByGrapheme: false, strokeUniform: false, @@ -86,20 +97,85 @@ }); QUnit.test('toObject', function(assert) { - var textbox = new fabric.Textbox('x'); + var textbox = new fabric.Textbox('The quick \nbrown \nfox', { + width: 120, + styles: { + "0":{ + "5":{fill:"red"}, + "6":{fill:"red"}, + "7":{fill:"red"}, + "8":{fill:"red"} + }, + "1":{ + "3":{underline:true}, + "4":{underline:true}, + "5":{underline:true} + }, + "2":{ + "0":{underline:true}, + "1":{underline:true} + } + } + }); var obj = textbox.toObject(); assert.deepEqual(obj, TEXTBOX_OBJECT, 'JSON OUTPUT MATCH'); + assert.deepEqual(obj.styles, TEXTBOX_OBJECT.styles, 'stylesToArray output matches'); + assert.deepEqual(obj.styles[0], TEXTBOX_OBJECT.styles[0], 'styles array matches at first index'); + assert.deepEqual(obj.styles[0].style, TEXTBOX_OBJECT.styles[0].style, 'style properties match at first index'); + assert.deepEqual(obj.styles[1], TEXTBOX_OBJECT.styles[1], 'styles array matches at second index'); + assert.deepEqual(obj.styles[1].style, TEXTBOX_OBJECT.styles[1].style, 'style properties match at second index'); }); QUnit.test('fromObject', function(assert) { var done = assert.async(); fabric.Textbox.fromObject(TEXTBOX_OBJECT, function(textbox) { - assert.equal(textbox.text, 'x', 'properties are respected'); + assert.equal(textbox.text, 'The quick \nbrown \nfox', 'properties are respected'); assert.ok(textbox instanceof fabric.Textbox, 'the generated object is a textbox'); done(); }); }); + QUnit.test('fromObjectWithStyles', function(assert) { + var done = assert.async(); + var textbox = new fabric.Textbox('The quick \nbrown \nfox', { + width: 120, + styles: { + "0":{ + "5":{fill:"red"}, + "6":{fill:"red"}, + "7":{fill:"red"}, + "8":{fill:"red"} + }, + "1":{ + "3":{underline:true}, + "4":{underline:true}, + "5":{underline:true} + }, + "2":{ + "0":{underline:true}, + "1":{underline:true} + } + } + }); + fabric.Textbox.fromObject(TEXTBOX_OBJECT, function(obj) { + assert.deepEqual(obj.styles, textbox.styles, 'stylesFromArray output matches'); + assert.deepEqual(obj.styles[0], textbox.styles[0], 'styles match at line 0'); + assert.notEqual(obj.styles[0][5], obj.styles[0][6], 'styles are separate objects'); + assert.deepEqual(obj.styles[0][5], textbox.styles[0][5], 'styles match at index 5'); + assert.deepEqual(obj.styles[0][6], textbox.styles[0][6], 'styles match at index 6'); + assert.deepEqual(obj.styles[0][7], textbox.styles[0][7], 'styles match at index 7'); + assert.deepEqual(obj.styles[0][8], textbox.styles[0][8], 'styles match at index 8'); + assert.deepEqual(obj.styles[1], textbox.styles[1], 'styles match at line 1'); + assert.deepEqual(obj.styles[1][3], textbox.styles[1][3], 'styles match at index 3'); + assert.deepEqual(obj.styles[1][4], textbox.styles[1][4], 'styles match at index 4'); + assert.deepEqual(obj.styles[1][5], textbox.styles[1][5], 'styles match at index 5'); + assert.deepEqual(obj.styles[2], textbox.styles[2], 'styles match at line 2'); + assert.deepEqual(obj.styles[2][0], textbox.styles[2][0], 'styles match at index 0'); + assert.deepEqual(obj.styles[2][1], textbox.styles[2][1], 'styles match at index 1'); + done(); + }); + }); + QUnit.test('isEndOfWrapping', function(assert) { var textbox = new fabric.Textbox('a q o m s g\np q r s t w', { width: 70,