';
+ },
+
+ /**
+ * @private
+ * @param {CanvasRenderingContext2D} ctx Context to render on
+ */
+ _render: function(ctx) {
+ this.clipTo && fabric.util.clipContext(this, ctx);
+ this._setOpacity(ctx);
+ this._setShadow(ctx);
+ this._setupCompositeOperation(ctx);
+ this._renderTextBackground(ctx);
+ this._setStrokeStyles(ctx);
+ this._setFillStyles(ctx);
+ this._renderText(ctx);
+ this._renderTextDecoration(ctx);
+ this.clipTo && ctx.restore();
+ },
+
+ /**
+ * @private
+ * @param {CanvasRenderingContext2D} ctx Context to render on
+ */
+ _renderText: function(ctx) {
+ this._renderTextFill(ctx);
+ this._renderTextStroke(ctx);
+ },
+
+ /**
+ * @private
+ * @param {CanvasRenderingContext2D} ctx Context to render on
+ */
+ _setTextStyles: function(ctx) {
+ ctx.textBaseline = 'alphabetic';
+ ctx.font = this._getFontDeclaration();
+ },
+
+ /**
+ * @private
+ * @return {Number} Height of fabric.Text object
+ */
+ _getTextHeight: function() {
+ return this._getHeightOfSingleLine() + (this._textLines.length - 1) * this._getHeightOfLine();
+ },
+
+ /**
+ * @private
+ * @param {CanvasRenderingContext2D} ctx Context to render on
+ * @return {Number} Maximum width of fabric.Text object
+ */
+ _getTextWidth: function(ctx) {
+ var maxWidth = this._getLineWidth(ctx, 0);
+
+ for (var i = 1, len = this._textLines.length; i < len; i++) {
+ var currentLineWidth = this._getLineWidth(ctx, i);
+ if (currentLineWidth > maxWidth) {
+ maxWidth = currentLineWidth;
+ }
+ }
+ return maxWidth;
+ },
+
+ /*
+ * Calculate object dimensions from its properties
+ * @override
+ * @private
+ */
+ _getNonTransformedDimensions: function() {
+ return { x: this.width, y: this.height };
+ },
+
+ /**
+ * @private
+ * @param {String} method Method name ("fillText" or "strokeText")
+ * @param {CanvasRenderingContext2D} ctx Context to render on
+ * @param {String} chars Chars to render
+ * @param {Number} left Left position of text
+ * @param {Number} top Top position of text
+ */
+ _renderChars: function(method, ctx, chars, left, top) {
+ // remove Text word from method var
+ var shortM = method.slice(0, -4), char, width;
+ if (this[shortM].toLive) {
+ var offsetX = -this.width / 2 + this[shortM].offsetX || 0,
+ offsetY = -this.height / 2 + this[shortM].offsetY || 0;
+ ctx.save();
+ ctx.translate(offsetX, offsetY);
+ left -= offsetX;
+ top -= offsetY;
+ }
+ if (this.charSpacing !== 0) {
+ var additionalSpace = this._getWidthOfCharSpacing();
+ chars = chars.split('');
+ for (var i = 0, len = chars.length; i < len; i++) {
+ char = chars[i];
+ width = ctx.measureText(char).width + additionalSpace;
+ ctx[method](char, left, top);
+ left += width > 0 ? width : 0;
+ }
+ }
+ else {
+ ctx[method](chars, left, top);
+ }
+ this[shortM].toLive && ctx.restore();
+ },
+
+ /**
+ * @private
+ * @param {String} method Method name ("fillText" or "strokeText")
+ * @param {CanvasRenderingContext2D} ctx Context to render on
+ * @param {String} line Text to render
+ * @param {Number} left Left position of text
+ * @param {Number} top Top position of text
+ * @param {Number} lineIndex Index of a line in a text
+ */
+ _renderTextLine: function(method, ctx, line, left, top, lineIndex) {
+ // lift the line by quarter of fontSize
+ top -= this.fontSize * this._fontSizeFraction;
+
+ // short-circuit
+ var lineWidth = this._getLineWidth(ctx, lineIndex);
+ if (this.textAlign !== 'justify' || this.width < lineWidth) {
+ this._renderChars(method, ctx, line, left, top, lineIndex);
+ return;
+ }
+
+ // stretch the line
+ var words = line.split(/\s+/),
+ charOffset = 0,
+ wordsWidth = this._getWidthOfWords(ctx, words.join(' '), lineIndex, 0),
+ widthDiff = this.width - wordsWidth,
+ numSpaces = words.length - 1,
+ spaceWidth = numSpaces > 0 ? widthDiff / numSpaces : 0,
+ leftOffset = 0, word;
+
+ for (var i = 0, len = words.length; i < len; i++) {
+ while (line[charOffset] === ' ' && charOffset < line.length) {
+ charOffset++;
+ }
+ word = words[i];
+ this._renderChars(method, ctx, word, left + leftOffset, top, lineIndex, charOffset);
+ leftOffset += this._getWidthOfWords(ctx, word, lineIndex, charOffset) + spaceWidth;
+ charOffset += word.length;
+ }
+ },
+
+ /**
+ * @private
+ * @param {CanvasRenderingContext2D} ctx Context to render on
+ * @param {String} word
+ */
+ _getWidthOfWords: function (ctx, word) {
+ var width = ctx.measureText(word).width, charCount, additionalSpace;
+ if (this.charSpacing !== 0) {
+ charCount = word.split('').length;
+ additionalSpace = charCount * this._getWidthOfCharSpacing();
+ width += additionalSpace;
+ }
+ return width > 0 ? width : 0;
+ },
+
+ /**
+ * @private
+ * @return {Number} Left offset
+ */
+ _getLeftOffset: function() {
+ return -this.width / 2;
+ },
+
+ /**
+ * @private
+ * @return {Number} Top offset
+ */
+ _getTopOffset: function() {
+ return -this.height / 2;
+ },
+
+ /**
+ * Returns true because text has no style
+ */
+ isEmptyStyles: function() {
+ return true;
+ },
+
+ /**
+ * @private
+ * @param {CanvasRenderingContext2D} ctx Context to render on
+ * @param {String} method Method name ("fillText" or "strokeText")
+ */
+ _renderTextCommon: function(ctx, method) {
+
+ var lineHeights = 0, left = this._getLeftOffset(), top = this._getTopOffset();
+
+ for (var i = 0, len = this._textLines.length; i < len; i++) {
+ var heightOfLine = this._getHeightOfLine(ctx, i),
+ maxHeight = heightOfLine / this.lineHeight,
+ lineWidth = this._getLineWidth(ctx, i),
+ leftOffset = this._getLineLeftOffset(lineWidth);
+ this._renderTextLine(
+ method,
+ ctx,
+ this._textLines[i],
+ left + leftOffset,
+ top + lineHeights + maxHeight,
+ i
+ );
+ lineHeights += heightOfLine;
+ }
+ },
+
+ /**
+ * @private
+ * @param {CanvasRenderingContext2D} ctx Context to render on
+ */
+ _renderTextFill: function(ctx) {
+ if (!this.fill && this.isEmptyStyles()) {
+ return;
+ }
+
+ this._renderTextCommon(ctx, 'fillText');
+ },
+
+ /**
+ * @private
+ * @param {CanvasRenderingContext2D} ctx Context to render on
+ */
+ _renderTextStroke: function(ctx) {
+ if ((!this.stroke || this.strokeWidth === 0) && this.isEmptyStyles()) {
+ return;
+ }
+
+ if (this.shadow && !this.shadow.affectStroke) {
+ this._removeShadow(ctx);
+ }
+
+ ctx.save();
+ this._setLineDash(ctx, this.strokedashArray);
+ ctx.beginPath();
+ this._renderTextCommon(ctx, 'strokeText');
+ ctx.closePath();
+ ctx.restore();
+ },
+
+ /**
+ * @private
+ * @return {Number} height of line
+ */
+ _getHeightOfLine: function() {
+ return this._getHeightOfSingleLine() * this.lineHeight;
+ },
+
+ /**
+ * @private
+ * @return {Number} height of line without lineHeight
+ */
+ _getHeightOfSingleLine: function() {
+ return this.fontSize * this._fontSizeMult;
+ },
+
+ /**
+ * @private
+ * @param {CanvasRenderingContext2D} ctx Context to render on
+ */
+ _renderTextBackground: function(ctx) {
+ this._renderBackground(ctx);
+ this._renderTextLinesBackground(ctx);
+ },
+
+ /**
+ * @private
+ * @param {CanvasRenderingContext2D} ctx Context to render on
+ */
+ _renderTextLinesBackground: function(ctx) {
+ if (!this.textBackgroundColor) {
+ return;
+ }
+ var lineTopOffset = 0, heightOfLine,
+ lineWidth, lineLeftOffset;
+
+ ctx.fillStyle = this.textBackgroundColor;
+ for (var i = 0, len = this._textLines.length; i < len; i++) {
+ heightOfLine = this._getHeightOfLine(ctx, i);
+ lineWidth = this._getLineWidth(ctx, i);
+ if (lineWidth > 0) {
+ lineLeftOffset = this._getLineLeftOffset(lineWidth);
+ ctx.fillRect(
+ this._getLeftOffset() + lineLeftOffset,
+ this._getTopOffset() + lineTopOffset,
+ lineWidth,
+ heightOfLine / this.lineHeight
+ );
+ }
+ lineTopOffset += heightOfLine;
+ }
+ // if there is text background color no
+ // other shadows should be casted
+ this._removeShadow(ctx);
+ },
+
+ /**
+ * @private
+ * @param {Number} lineWidth Width of text line
+ * @return {Number} Line left offset
+ */
+ _getLineLeftOffset: function(lineWidth) {
+ if (this.textAlign === 'center') {
+ return (this.width - lineWidth) / 2;
+ }
+ if (this.textAlign === 'right') {
+ return this.width - lineWidth;
+ }
+ return 0;
+ },
+
+ /**
+ * @private
+ */
+ _clearCache: function() {
+ this.__lineWidths = [];
+ this.__lineHeights = [];
+ },
+
+ /**
+ * @private
+ */
+ _shouldClearCache: function() {
+ var shouldClear = false;
+ if (this._forceClearCache) {
+ this._forceClearCache = false;
+ return true;
+ }
+ for (var prop in this._dimensionAffectingProps) {
+ if (this['__' + prop] !== this[prop]) {
+ this['__' + prop] = this[prop];
+ shouldClear = true;
+ }
+ }
+ return shouldClear;
+ },
+
+ /**
+ * @private
+ * @param {CanvasRenderingContext2D} ctx Context to render on
+ * @param {Number} lineIndex line number
+ * @return {Number} Line width
+ */
+ _getLineWidth: function(ctx, lineIndex) {
+ if (this.__lineWidths[lineIndex]) {
+ return this.__lineWidths[lineIndex] === -1 ? this.width : this.__lineWidths[lineIndex];
+ }
+
+ var width, wordCount, line = this._textLines[lineIndex];
+
+ if (line === '') {
+ width = 0;
+ }
+ else {
+ width = this._measureLine(ctx, lineIndex);
+ }
+ this.__lineWidths[lineIndex] = width;
+
+ if (width && this.textAlign === 'justify') {
+ wordCount = line.split(/\s+/);
+ if (wordCount.length > 1) {
+ this.__lineWidths[lineIndex] = -1;
+ }
+ }
+ return width;
+ },
+
+ _getWidthOfCharSpacing: function() {
+ if (this.charSpacing !== 0) {
+ return this.fontSize * this.charSpacing / 1000;
+ }
+ return 0;
+ },
+
+ /**
+ * @private
+ * @param {CanvasRenderingContext2D} ctx Context to render on
+ * @param {Number} lineIndex line number
+ * @return {Number} Line width
+ */
+ _measureLine: function(ctx, lineIndex) {
+ var line = this._textLines[lineIndex],
+ width = ctx.measureText(line).width,
+ additionalSpace = 0, charCount, finalWidth;
+ if (this.charSpacing !== 0) {
+ charCount = line.split('').length;
+ additionalSpace = (charCount - 1) * this._getWidthOfCharSpacing();
+ }
+ finalWidth = width + additionalSpace;
+ return finalWidth > 0 ? finalWidth : 0;
+ },
+
+ /**
+ * @private
+ * @param {CanvasRenderingContext2D} ctx Context to render on
+ */
+ _renderTextDecoration: function(ctx) {
+ if (!this.textDecoration) {
+ return;
+ }
+ var halfOfVerticalBox = this.height / 2,
+ _this = this, offsets = [];
+
+ /** @ignore */
+ function renderLinesAtOffset(offsets) {
+ var i, lineHeight = 0, len, j, oLen, lineWidth,
+ lineLeftOffset, heightOfLine;
+
+ for (i = 0, len = _this._textLines.length; i < len; i++) {
+
+ lineWidth = _this._getLineWidth(ctx, i);
+ lineLeftOffset = _this._getLineLeftOffset(lineWidth);
+ heightOfLine = _this._getHeightOfLine(ctx, i);
+
+ for (j = 0, oLen = offsets.length; j < oLen; j++) {
+ ctx.fillRect(
+ _this._getLeftOffset() + lineLeftOffset,
+ lineHeight + (_this._fontSizeMult - 1 + offsets[j] ) * _this.fontSize - halfOfVerticalBox,
+ lineWidth,
+ _this.fontSize / 15);
+ }
+ lineHeight += heightOfLine;
+ }
+ }
+
+ if (this.textDecoration.indexOf('underline') > -1) {
+ offsets.push(0.85); // 1 - 3/16
+ }
+ if (this.textDecoration.indexOf('line-through') > -1) {
+ offsets.push(0.43);
+ }
+ if (this.textDecoration.indexOf('overline') > -1) {
+ offsets.push(-0.12);
+ }
+ if (offsets.length > 0) {
+ renderLinesAtOffset(offsets);
+ }
+ },
+
+ /**
+ * return font declaration string for canvas context
+ * @returns {String} font declaration formatted for canvas context.
+ */
+ _getFontDeclaration: function() {
+ return [
+ // node-canvas needs "weight style", while browsers need "style weight"
+ (fabric.isLikelyNode ? this.fontWeight : this.fontStyle),
+ (fabric.isLikelyNode ? this.fontStyle : this.fontWeight),
+ this.fontSize + 'px',
+ (fabric.isLikelyNode ? ('"' + this.fontFamily + '"') : this.fontFamily)
+ ].join(' ');
+ },
+
+ /**
+ * Renders text instance on a specified context
+ * @param {CanvasRenderingContext2D} ctx Context to render on
+ * @param {Boolean} noTransform
+ */
+ render: function(ctx, noTransform) {
+ // do not render if object is not visible
+ if (!this.visible) {
+ return;
+ }
+
+ ctx.save();
+ this._setTextStyles(ctx);
+
+ if (this._shouldClearCache()) {
+ this._initDimensions(ctx);
+ }
+ this.drawSelectionBackground(ctx);
+ if (!noTransform) {
+ this.transform(ctx);
+ }
+ if (this.transformMatrix) {
+ ctx.transform.apply(ctx, this.transformMatrix);
+ }
+ if (this.group && this.group.type === 'path-group') {
+ ctx.translate(this.left, this.top);
+ }
+ this._render(ctx);
+ ctx.restore();
+ },
+
+ /**
+ * Returns the text as an array of lines.
+ * @returns {Array} Lines in the text
+ */
+ _splitTextIntoLines: function() {
+ return this.text.split(this._reNewline);
+ },
+
+ /**
+ * Returns object representation of an instance
+ * @param {Array} [propertiesToInclude] Any properties that you might want to additionally include in the output
+ * @return {Object} Object representation of an instance
+ */
+ toObject: function(propertiesToInclude) {
+ var additionalProperties = [
+ 'text',
+ 'fontSize',
+ 'fontWeight',
+ 'fontFamily',
+ 'fontStyle',
+ 'lineHeight',
+ 'textDecoration',
+ 'textAlign',
+ 'textBackgroundColor',
+ 'charSpacing'
+ ].concat(propertiesToInclude);
+ return this.callSuper('toObject', additionalProperties);
+ },
+
+ /* _TO_SVG_START_ */
+ /**
+ * Returns SVG representation of an instance
+ * @param {Function} [reviver] Method for further parsing of svg representation.
+ * @return {String} svg representation of an instance
+ */
+ toSVG: function(reviver) {
+ if (!this.ctx) {
+ this.ctx = fabric.util.createCanvasElement().getContext('2d');
+ }
+ var markup = this._createBaseSVGMarkup(),
+ offsets = this._getSVGLeftTopOffsets(this.ctx),
+ textAndBg = this._getSVGTextAndBg(offsets.textTop, offsets.textLeft);
+ this._wrapSVGTextAndBg(markup, textAndBg);
+
+ return reviver ? reviver(markup.join('')) : markup.join('');
+ },
+
+ /**
+ * @private
+ */
+ _getSVGLeftTopOffsets: function(ctx) {
+ var lineTop = this._getHeightOfLine(ctx, 0),
+ textLeft = -this.width / 2,
+ textTop = 0;
+
+ return {
+ textLeft: textLeft + (this.group && this.group.type === 'path-group' ? this.left : 0),
+ textTop: textTop + (this.group && this.group.type === 'path-group' ? -this.top : 0),
+ lineTop: lineTop
+ };
+ },
+
+ /**
+ * @private
+ */
+ _wrapSVGTextAndBg: function(markup, textAndBg) {
+ var noShadow = true, filter = this.getSvgFilter(),
+ style = filter === '' ? '' : ' style="' + filter + '"';
+
+ markup.push(
+ '\t\n',
+ textAndBg.textBgRects.join(''),
+ '\t\t\n',
+ textAndBg.textSpans.join(''),
+ '\t\t\n',
+ '\t\n'
+ );
+ },
+
+ /**
+ * @private
+ * @param {Number} textTopOffset Text top offset
+ * @param {Number} textLeftOffset Text left offset
+ * @return {Object}
+ */
+ _getSVGTextAndBg: function(textTopOffset, textLeftOffset) {
+ var textSpans = [],
+ textBgRects = [],
+ height = 0;
+ // bounding-box background
+ this._setSVGBg(textBgRects);
+
+ // text and text-background
+ for (var i = 0, len = this._textLines.length; i < len; i++) {
+ if (this.textBackgroundColor) {
+ this._setSVGTextLineBg(textBgRects, i, textLeftOffset, textTopOffset, height);
+ }
+ this._setSVGTextLineText(i, textSpans, height, textLeftOffset, textTopOffset, textBgRects);
+ height += this._getHeightOfLine(this.ctx, i);
+ }
+
+ return {
+ textSpans: textSpans,
+ textBgRects: textBgRects
+ };
+ },
+
+ _setSVGTextLineText: function(i, textSpans, height, textLeftOffset, textTopOffset) {
+ var yPos = this.fontSize * (this._fontSizeMult - this._fontSizeFraction)
+ - textTopOffset + height - this.height / 2;
+ if (this.textAlign === 'justify') {
+ // i call from here to do not intefere with IText
+ this._setSVGTextLineJustifed(i, textSpans, yPos, textLeftOffset);
+ return;
+ }
+ textSpans.push(
+ '\t\t\t elements since setting opacity
+ // on containing one doesn't work in Illustrator
+ this._getFillAttributes(this.fill), '>',
+ fabric.util.string.escapeXml(this._textLines[i]),
+ '\n'
+ );
+ },
+
+ _setSVGTextLineJustifed: function(i, textSpans, yPos, textLeftOffset) {
+ var ctx = fabric.util.createCanvasElement().getContext('2d');
+
+ this._setTextStyles(ctx);
+
+ var line = this._textLines[i],
+ words = line.split(/\s+/),
+ wordsWidth = this._getWidthOfWords(ctx, words.join('')),
+ widthDiff = this.width - wordsWidth,
+ numSpaces = words.length - 1,
+ spaceWidth = numSpaces > 0 ? widthDiff / numSpaces : 0,
+ word, attributes = this._getFillAttributes(this.fill),
+ len;
+
+ textLeftOffset += this._getLineLeftOffset(this._getLineWidth(ctx, i));
+
+ for (i = 0, len = words.length; i < len; i++) {
+ word = words[i];
+ textSpans.push(
+ '\t\t\t elements since setting opacity
+ // on containing one doesn't work in Illustrator
+ attributes, '>',
+ fabric.util.string.escapeXml(word),
+ '\n'
+ );
+ textLeftOffset += this._getWidthOfWords(ctx, word) + spaceWidth;
+ }
+ },
+
+ _setSVGTextLineBg: function(textBgRects, i, textLeftOffset, textTopOffset, height) {
+ textBgRects.push(
+ '\t\t\n');
+ },
+
+ _setSVGBg: function(textBgRects) {
+ if (this.backgroundColor) {
+ textBgRects.push(
+ '\t\t\n');
+ }
+ },
+
+ /**
+ * Adobe Illustrator (at least CS5) is unable to render rgba()-based fill values
+ * we work around it by "moving" alpha channel into opacity attribute and setting fill's alpha to 1
+ *
+ * @private
+ * @param {*} value
+ * @return {String}
+ */
+ _getFillAttributes: function(value) {
+ var fillColor = (value && typeof value === 'string') ? new fabric.Color(value) : '';
+ if (!fillColor || !fillColor.getSource() || fillColor.getAlpha() === 1) {
+ return 'fill="' + value + '"';
+ }
+ return 'opacity="' + fillColor.getAlpha() + '" fill="' + fillColor.setAlpha(1).toRgb() + '"';
+ },
+ /* _TO_SVG_END_ */
+
+ /**
+ * Sets specified property to a specified value
+ * @param {String} key
+ * @param {*} value
+ * @return {fabric.Text} thisArg
+ * @chainable
+ */
+ _set: function(key, value) {
+ this.callSuper('_set', key, value);
+
+ if (key in this._dimensionAffectingProps) {
+ this._initDimensions();
+ this.setCoords();
+ }
+ },
+
+ /**
+ * Returns complexity of an instance
+ * @return {Number} complexity
+ */
+ complexity: function() {
+ return 1;
+ }
+ });
+
+ /* _FROM_SVG_START_ */
+ /**
+ * List of attribute names to account for when parsing SVG element (used by {@link fabric.Text.fromElement})
+ * @static
+ * @memberOf fabric.Text
+ * @see: http://www.w3.org/TR/SVG/text.html#TextElement
+ */
+ fabric.Text.ATTRIBUTE_NAMES = fabric.SHARED_ATTRIBUTES.concat(
+ 'x y dx dy font-family font-style font-weight font-size text-decoration text-anchor'.split(' '));
+
+ /**
+ * Default SVG font size
+ * @static
+ * @memberOf fabric.Text
+ */
+ fabric.Text.DEFAULT_SVG_FONT_SIZE = 16;
+
+ /**
+ * Returns fabric.Text instance from an SVG element (not yet implemented)
+ * @static
+ * @memberOf fabric.Text
+ * @param {SVGElement} element Element to parse
+ * @param {Object} [options] Options object
+ * @return {fabric.Text} Instance of fabric.Text
+ */
+ fabric.Text.fromElement = function(element, options) {
+ if (!element) {
+ return null;
+ }
+
+ var parsedAttributes = fabric.parseAttributes(element, fabric.Text.ATTRIBUTE_NAMES);
+ options = fabric.util.object.extend((options ? fabric.util.object.clone(options) : { }), parsedAttributes);
+
+ options.top = options.top || 0;
+ options.left = options.left || 0;
+ if ('dx' in parsedAttributes) {
+ options.left += parsedAttributes.dx;
+ }
+ if ('dy' in parsedAttributes) {
+ options.top += parsedAttributes.dy;
+ }
+ if (!('fontSize' in options)) {
+ options.fontSize = fabric.Text.DEFAULT_SVG_FONT_SIZE;
+ }
+
+ if (!options.originX) {
+ options.originX = 'left';
+ }
+
+ var textContent = '';
+
+ // The XML is not properly parsed in IE9 so a workaround to get
+ // textContent is through firstChild.data. Another workaround would be
+ // to convert XML loaded from a file to be converted using DOMParser (same way loadSVGFromString() does)
+ if (!('textContent' in element)) {
+ if ('firstChild' in element && element.firstChild !== null) {
+ if ('data' in element.firstChild && element.firstChild.data !== null) {
+ textContent = element.firstChild.data;
+ }
+ }
+ }
+ else {
+ textContent = element.textContent;
+ }
+
+ textContent = textContent.replace(/^\s+|\s+$|\n+/g, '').replace(/\s+/g, ' ');
+
+ var text = new fabric.Text(textContent, options),
+ textHeightScaleFactor = text.getHeight() / text.height,
+ lineHeightDiff = (text.height + text.strokeWidth) * text.lineHeight - text.height,
+ scaledDiff = lineHeightDiff * textHeightScaleFactor,
+ textHeight = text.getHeight() + scaledDiff,
+ offX = 0;
+ /*
+ Adjust positioning:
+ x/y attributes in SVG correspond to the bottom-left corner of text bounding box
+ top/left properties in Fabric correspond to center point of text bounding box
+ */
+ if (text.originX === 'left') {
+ offX = text.getWidth() / 2;
+ }
+ if (text.originX === 'right') {
+ offX = -text.getWidth() / 2;
+ }
+ text.set({
+ left: text.getLeft() + offX,
+ top: text.getTop() - textHeight / 2 + text.fontSize * (0.18 + text._fontSizeFraction) / text.lineHeight /* 0.3 is the old lineHeight */
+ });
+
+ return text;
+ };
+ /* _FROM_SVG_END_ */
+
+ /**
+ * Returns fabric.Text instance from an object representation
+ * @static
+ * @memberOf fabric.Text
+ * @param {Object} object Object to create an instance from
+ * @param {Function} [callback] Callback to invoke when an fabric.Text instance is created
+ * @return {fabric.Text} Instance of fabric.Text
+ */
+ fabric.Text.fromObject = function(object, callback) {
+ var text = new fabric.Text(object.text, clone(object));
+ callback && callback(text);
+ return text;
+ };
+
+ fabric.util.createAccessors(fabric.Text);
+
+})(typeof exports !== 'undefined' ? exports : this);
+
+
+(function() {
+
+ var clone = fabric.util.object.clone;
+
+ /**
+ * IText class (introduced in v1.4) Events are also fired with "text:"
+ * prefix when observing canvas.
+ * @class fabric.IText
+ * @extends fabric.Text
+ * @mixes fabric.Observable
+ *
+ * @fires changed
+ * @fires selection:changed
+ * @fires editing:entered
+ * @fires editing:exited
+ *
+ * @return {fabric.IText} thisArg
+ * @see {@link fabric.IText#initialize} for constructor definition
+ *
+ * Supported key combinations:
+ *
+ * Move cursor: left, right, up, down
+ * Select character: shift + left, shift + right
+ * Select text vertically: shift + up, shift + down
+ * Move cursor by word: alt + left, alt + right
+ * Select words: shift + alt + left, shift + alt + right
+ * Move cursor to line start/end: cmd + left, cmd + right or home, end
+ * Select till start/end of line: cmd + shift + left, cmd + shift + right or shift + home, shift + end
+ * Jump to start/end of text: cmd + up, cmd + down
+ * Select till start/end of text: cmd + shift + up, cmd + shift + down or shift + pgUp, shift + pgDown
+ * Delete character: backspace
+ * Delete word: alt + backspace
+ * Delete line: cmd + backspace
+ * Forward delete: delete
+ * Copy text: ctrl/cmd + c
+ * Paste text: ctrl/cmd + v
+ * Cut text: ctrl/cmd + x
+ * Select entire text: ctrl/cmd + a
+ * Quit editing tab or esc
+ *
+ *
+ * Supported mouse/touch combination
+ *
+ * Position cursor: click/touch
+ * Create selection: click/touch & drag
+ * Create selection: click & shift + click
+ * Select word: double click
+ * Select line: triple click
+ *
+ */
+ fabric.IText = fabric.util.createClass(fabric.Text, fabric.Observable, /** @lends fabric.IText.prototype */ {
+
+ /**
+ * Type of an object
+ * @type String
+ * @default
+ */
+ type: 'i-text',
+
+ /**
+ * Index where text selection starts (or where cursor is when there is no selection)
+ * @type Number
+ * @default
+ */
+ selectionStart: 0,
+
+ /**
+ * Index where text selection ends
+ * @type Number
+ * @default
+ */
+ selectionEnd: 0,
+
+ /**
+ * Color of text selection
+ * @type String
+ * @default
+ */
+ selectionColor: 'rgba(17,119,255,0.3)',
+
+ /**
+ * Indicates whether text is in editing mode
+ * @type Boolean
+ * @default
+ */
+ isEditing: false,
+
+ /**
+ * Indicates whether a text can be edited
+ * @type Boolean
+ * @default
+ */
+ editable: true,
+
+ /**
+ * Border color of text object while it's in editing mode
+ * @type String
+ * @default
+ */
+ editingBorderColor: 'rgba(102,153,255,0.25)',
+
+ /**
+ * Width of cursor (in px)
+ * @type Number
+ * @default
+ */
+ cursorWidth: 2,
+
+ /**
+ * Color of default cursor (when not overwritten by character style)
+ * @type String
+ * @default
+ */
+ cursorColor: '#333',
+
+ /**
+ * Delay between cursor blink (in ms)
+ * @type Number
+ * @default
+ */
+ cursorDelay: 1000,
+
+ /**
+ * Duration of cursor fadein (in ms)
+ * @type Number
+ * @default
+ */
+ cursorDuration: 600,
+
+ /**
+ * Object containing character styles
+ * (where top-level properties corresponds to line number and 2nd-level properties -- to char number in a line)
+ * @type Object
+ * @default
+ */
+ styles: null,
+
+ /**
+ * Indicates whether internal text char widths can be cached
+ * @type Boolean
+ * @default
+ */
+ caching: true,
+
+ /**
+ * @private
+ */
+ _reSpace: /\s|\n/,
+
+ /**
+ * @private
+ */
+ _currentCursorOpacity: 0,
+
+ /**
+ * @private
+ */
+ _selectionDirection: null,
+
+ /**
+ * @private
+ */
+ _abortCursorAnimation: false,
+
+ /**
+ * @private
+ */
+ __widthOfSpace: [],
+
+ /**
+ * Constructor
+ * @param {String} text Text string
+ * @param {Object} [options] Options object
+ * @return {fabric.IText} thisArg
+ */
+ initialize: function(text, options) {
+ this.styles = options ? (options.styles || { }) : { };
+ this.callSuper('initialize', text, options);
+ this.initBehavior();
+ },
+
+ /**
+ * @private
+ */
+ _clearCache: function() {
+ this.callSuper('_clearCache');
+ this.__widthOfSpace = [];
+ },
+
+ /**
+ * Returns true if object has no styling
+ */
+ isEmptyStyles: function() {
+ if (!this.styles) {
+ return true;
+ }
+ var obj = this.styles;
+
+ for (var p1 in obj) {
+ for (var p2 in obj[p1]) {
+ // eslint-disable-next-line no-unused-vars
+ for (var p3 in obj[p1][p2]) {
+ return false;
+ }
+ }
+ }
+ return true;
+ },
+
+ /**
+ * Sets selection start (left boundary of a selection)
+ * @param {Number} index Index to set selection start to
+ */
+ setSelectionStart: function(index) {
+ index = Math.max(index, 0);
+ this._updateAndFire('selectionStart', index);
+ },
+
+ /**
+ * Sets selection end (right boundary of a selection)
+ * @param {Number} index Index to set selection end to
+ */
+ setSelectionEnd: function(index) {
+ index = Math.min(index, this.text.length);
+ this._updateAndFire('selectionEnd', index);
+ },
+
+ /**
+ * @private
+ * @param {String} property 'selectionStart' or 'selectionEnd'
+ * @param {Number} index new position of property
+ */
+ _updateAndFire: function(property, index) {
+ if (this[property] !== index) {
+ this._fireSelectionChanged();
+ this[property] = index;
+ }
+ this._updateTextarea();
+ },
+
+ /**
+ * Fires the even of selection changed
+ * @private
+ */
+ _fireSelectionChanged: function() {
+ this.fire('selection:changed');
+ this.canvas && this.canvas.fire('text:selection:changed', { target: this });
+ },
+
+ /**
+ * Gets style of a current selection/cursor (at the start position)
+ * @param {Number} [startIndex] Start index to get styles at
+ * @param {Number} [endIndex] End index to get styles at
+ * @return {Object} styles Style object at a specified (or current) index
+ */
+ getSelectionStyles: function(startIndex, endIndex) {
+
+ if (arguments.length === 2) {
+ var styles = [];
+ for (var i = startIndex; i < endIndex; i++) {
+ styles.push(this.getSelectionStyles(i));
+ }
+ return styles;
+ }
+
+ var loc = this.get2DCursorLocation(startIndex),
+ style = this._getStyleDeclaration(loc.lineIndex, loc.charIndex);
+
+ return style || {};
+ },
+
+ /**
+ * Sets style of a current selection
+ * @param {Object} [styles] Styles object
+ * @return {fabric.IText} thisArg
+ * @chainable
+ */
+ setSelectionStyles: function(styles) {
+ if (this.selectionStart === this.selectionEnd) {
+ this._extendStyles(this.selectionStart, styles);
+ }
+ else {
+ for (var i = this.selectionStart; i < this.selectionEnd; i++) {
+ this._extendStyles(i, styles);
+ }
+ }
+ /* not included in _extendStyles to avoid clearing cache more than once */
+ this._forceClearCache = true;
+ return this;
+ },
+
+ /**
+ * @private
+ */
+ _extendStyles: function(index, styles) {
+ var loc = this.get2DCursorLocation(index);
+
+ if (!this._getLineStyle(loc.lineIndex)) {
+ this._setLineStyle(loc.lineIndex, {});
+ }
+
+ if (!this._getStyleDeclaration(loc.lineIndex, loc.charIndex)) {
+ this._setStyleDeclaration(loc.lineIndex, loc.charIndex, {});
+ }
+
+ fabric.util.object.extend(this._getStyleDeclaration(loc.lineIndex, loc.charIndex), styles);
+ },
+
+ /**
+ * @private
+ * @param {CanvasRenderingContext2D} ctx Context to render on
+ * @param {Boolean} noTransform
+ */
+ render: function(ctx, noTransform) {
+ this.clearContextTop();
+ this.callSuper('render', ctx, noTransform);
+ },
+
+ /**
+ * @private
+ * @param {CanvasRenderingContext2D} ctx Context to render on
+ */
+ _render: function(ctx) {
+ this.callSuper('_render', ctx);
+ this.ctx = ctx;
+ // clear the cursorOffsetCache, so we ensure to calculate once per renderCursor
+ // the correct position but not at every cursor animation.
+ this.cursorOffsetCache = { };
+ this.renderCursorOrSelection();
+ },
+
+ /**
+ * Prepare and clean the contextTop
+ */
+ clearContextTop: function() {
+ if (!this.active || !this.isEditing) {
+ return;
+ }
+ if (this.canvas && this.canvas.contextTop) {
+ var ctx = this.canvas.contextTop;
+ ctx.save();
+ ctx.transform.apply(ctx, this.canvas.viewportTransform);
+ this.transform(ctx);
+ this.transformMatrix && ctx.transform.apply(ctx, this.transformMatrix);
+ this._clearTextArea(ctx);
+ ctx.restore();
+ }
+ },
+
+ /**
+ * Renders cursor or selection (depending on what exists)
+ */
+ renderCursorOrSelection: function() {
+ if (!this.active || !this.isEditing) {
+ return;
+ }
+ var chars = this.text.split(''),
+ boundaries, ctx;
+ if (this.canvas && this.canvas.contextTop) {
+ ctx = this.canvas.contextTop;
+ ctx.save();
+ ctx.transform.apply(ctx, this.canvas.viewportTransform);
+ this.transform(ctx);
+ this.transformMatrix && ctx.transform.apply(ctx, this.transformMatrix);
+ this._clearTextArea(ctx);
+ }
+ else {
+ ctx = this.ctx;
+ ctx.save();
+ }
+ if (this.selectionStart === this.selectionEnd) {
+ boundaries = this._getCursorBoundaries(chars, 'cursor');
+ this.renderCursor(boundaries, ctx);
+ }
+ else {
+ boundaries = this._getCursorBoundaries(chars, 'selection');
+ this.renderSelection(chars, boundaries, ctx);
+ }
+ ctx.restore();
+ },
+
+ _clearTextArea: function(ctx) {
+ // we add 4 pixel, to be sure to do not leave any pixel out
+ var width = this.width + 4, height = this.height + 4;
+ ctx.clearRect(-width / 2, -height / 2, width, height);
+ },
+ /**
+ * Returns 2d representation (lineIndex and charIndex) of cursor (or selection start)
+ * @param {Number} [selectionStart] Optional index. When not given, current selectionStart is used.
+ */
+ get2DCursorLocation: function(selectionStart) {
+ if (typeof selectionStart === 'undefined') {
+ selectionStart = this.selectionStart;
+ }
+ var len = this._textLines.length;
+ for (var i = 0; i < len; i++) {
+ if (selectionStart <= this._textLines[i].length) {
+ return {
+ lineIndex: i,
+ charIndex: selectionStart
+ };
+ }
+ selectionStart -= this._textLines[i].length + 1;
+ }
+ return {
+ lineIndex: i - 1,
+ charIndex: this._textLines[i - 1].length < selectionStart ? this._textLines[i - 1].length : selectionStart
+ };
+ },
+
+ /**
+ * Returns complete style of char at the current cursor
+ * @param {Number} lineIndex Line index
+ * @param {Number} charIndex Char index
+ * @return {Object} Character style
+ */
+ getCurrentCharStyle: function(lineIndex, charIndex) {
+ var style = this._getStyleDeclaration(lineIndex, charIndex === 0 ? 0 : charIndex - 1);
+
+ return {
+ fontSize: style && style.fontSize || this.fontSize,
+ fill: style && style.fill || this.fill,
+ textBackgroundColor: style && style.textBackgroundColor || this.textBackgroundColor,
+ textDecoration: style && style.textDecoration || this.textDecoration,
+ fontFamily: style && style.fontFamily || this.fontFamily,
+ fontWeight: style && style.fontWeight || this.fontWeight,
+ fontStyle: style && style.fontStyle || this.fontStyle,
+ stroke: style && style.stroke || this.stroke,
+ strokeWidth: style && style.strokeWidth || this.strokeWidth
+ };
+ },
+
+ /**
+ * Returns fontSize of char at the current cursor
+ * @param {Number} lineIndex Line index
+ * @param {Number} charIndex Char index
+ * @return {Number} Character font size
+ */
+ getCurrentCharFontSize: function(lineIndex, charIndex) {
+ var style = this._getStyleDeclaration(lineIndex, charIndex === 0 ? 0 : charIndex - 1);
+ return style && style.fontSize ? style.fontSize : this.fontSize;
+ },
+
+ /**
+ * Returns color (fill) of char at the current cursor
+ * @param {Number} lineIndex Line index
+ * @param {Number} charIndex Char index
+ * @return {String} Character color (fill)
+ */
+ getCurrentCharColor: function(lineIndex, charIndex) {
+ var style = this._getStyleDeclaration(lineIndex, charIndex === 0 ? 0 : charIndex - 1);
+ return style && style.fill ? style.fill : this.cursorColor;
+ },
+
+ /**
+ * Returns cursor boundaries (left, top, leftOffset, topOffset)
+ * @private
+ * @param {Array} chars Array of characters
+ * @param {String} typeOfBoundaries
+ */
+ _getCursorBoundaries: function(chars, typeOfBoundaries) {
+
+ // left/top are left/top of entire text box
+ // leftOffset/topOffset are offset from that left/top point of a text box
+
+ var left = Math.round(this._getLeftOffset()),
+ top = this._getTopOffset(),
+
+ offsets = this._getCursorBoundariesOffsets(
+ chars, typeOfBoundaries);
+
+ return {
+ left: left,
+ top: top,
+ leftOffset: offsets.left + offsets.lineLeft,
+ topOffset: offsets.top
+ };
+ },
+
+ /**
+ * @private
+ */
+ _getCursorBoundariesOffsets: function(chars, typeOfBoundaries) {
+ if (this.cursorOffsetCache && 'top' in this.cursorOffsetCache) {
+ return this.cursorOffsetCache;
+ }
+ var lineLeftOffset = 0,
+ lineIndex = 0,
+ charIndex = 0,
+ topOffset = 0,
+ leftOffset = 0,
+ boundaries;
+
+ for (var i = 0; i < this.selectionStart; i++) {
+ if (chars[i] === '\n') {
+ leftOffset = 0;
+ topOffset += this._getHeightOfLine(this.ctx, lineIndex);
+
+ lineIndex++;
+ charIndex = 0;
+ }
+ else {
+ leftOffset += this._getWidthOfChar(this.ctx, chars[i], lineIndex, charIndex);
+ charIndex++;
+ }
+
+ lineLeftOffset = this._getLineLeftOffset(this._getLineWidth(this.ctx, lineIndex));
+ }
+ if (typeOfBoundaries === 'cursor') {
+ topOffset += (1 - this._fontSizeFraction) * this._getHeightOfLine(this.ctx, lineIndex) / this.lineHeight
+ - this.getCurrentCharFontSize(lineIndex, charIndex) * (1 - this._fontSizeFraction);
+ }
+ if (this.charSpacing !== 0 && charIndex === this._textLines[lineIndex].length) {
+ leftOffset -= this._getWidthOfCharSpacing();
+ }
+ boundaries = {
+ top: topOffset,
+ left: leftOffset > 0 ? leftOffset : 0,
+ lineLeft: lineLeftOffset
+ };
+ this.cursorOffsetCache = boundaries;
+ return this.cursorOffsetCache;
+ },
+
+ /**
+ * Renders cursor
+ * @param {Object} boundaries
+ * @param {CanvasRenderingContext2D} ctx transformed context to draw on
+ */
+ renderCursor: function(boundaries, ctx) {
+
+ var cursorLocation = this.get2DCursorLocation(),
+ lineIndex = cursorLocation.lineIndex,
+ charIndex = cursorLocation.charIndex,
+ charHeight = this.getCurrentCharFontSize(lineIndex, charIndex),
+ leftOffset = (lineIndex === 0 && charIndex === 0)
+ ? this._getLineLeftOffset(this._getLineWidth(ctx, lineIndex))
+ : boundaries.leftOffset,
+ multiplier = this.scaleX * this.canvas.getZoom(),
+ cursorWidth = this.cursorWidth / multiplier;
+
+ ctx.fillStyle = this.getCurrentCharColor(lineIndex, charIndex);
+ ctx.globalAlpha = this.__isMousedown ? 1 : this._currentCursorOpacity;
+
+ ctx.fillRect(
+ boundaries.left + leftOffset - cursorWidth / 2,
+ boundaries.top + boundaries.topOffset,
+ cursorWidth,
+ charHeight);
+ },
+
+ /**
+ * Renders text selection
+ * @param {Array} chars Array of characters
+ * @param {Object} boundaries Object with left/top/leftOffset/topOffset
+ * @param {CanvasRenderingContext2D} ctx transformed context to draw on
+ */
+ renderSelection: function(chars, boundaries, ctx) {
+
+ ctx.fillStyle = this.selectionColor;
+
+ var start = this.get2DCursorLocation(this.selectionStart),
+ end = this.get2DCursorLocation(this.selectionEnd),
+ startLine = start.lineIndex,
+ endLine = end.lineIndex;
+ for (var i = startLine; i <= endLine; i++) {
+ var lineOffset = this._getLineLeftOffset(this._getLineWidth(ctx, i)) || 0,
+ lineHeight = this._getHeightOfLine(this.ctx, i),
+ realLineHeight = 0, boxWidth = 0, line = this._textLines[i];
+
+ if (i === startLine) {
+ for (var j = 0, len = line.length; j < len; j++) {
+ if (j >= start.charIndex && (i !== endLine || j < end.charIndex)) {
+ boxWidth += this._getWidthOfChar(ctx, line[j], i, j);
+ }
+ if (j < start.charIndex) {
+ lineOffset += this._getWidthOfChar(ctx, line[j], i, j);
+ }
+ }
+ if (j === line.length) {
+ boxWidth -= this._getWidthOfCharSpacing();
+ }
+ }
+ else if (i > startLine && i < endLine) {
+ boxWidth += this._getLineWidth(ctx, i) || 5;
+ }
+ else if (i === endLine) {
+ for (var j2 = 0, j2len = end.charIndex; j2 < j2len; j2++) {
+ boxWidth += this._getWidthOfChar(ctx, line[j2], i, j2);
+ }
+ if (end.charIndex === line.length) {
+ boxWidth -= this._getWidthOfCharSpacing();
+ }
+ }
+ realLineHeight = lineHeight;
+ if (this.lineHeight < 1 || (i === endLine && this.lineHeight > 1)) {
+ lineHeight /= this.lineHeight;
+ }
+ ctx.fillRect(
+ boundaries.left + lineOffset,
+ boundaries.top + boundaries.topOffset,
+ boxWidth > 0 ? boxWidth : 0,
+ lineHeight);
+
+ boundaries.topOffset += realLineHeight;
+ }
+ },
+
+ /**
+ * @private
+ * @param {String} method
+ * @param {CanvasRenderingContext2D} ctx Context to render on
+ * @param {String} line Content of the line
+ * @param {Number} left
+ * @param {Number} top
+ * @param {Number} lineIndex
+ * @param {Number} charOffset
+ */
+ _renderChars: function(method, ctx, line, left, top, lineIndex, charOffset) {
+
+ if (this.isEmptyStyles()) {
+ return this._renderCharsFast(method, ctx, line, left, top);
+ }
+
+ charOffset = charOffset || 0;
+
+ // set proper line offset
+ var lineHeight = this._getHeightOfLine(ctx, lineIndex),
+ prevStyle,
+ thisStyle,
+ charsToRender = '';
+
+ ctx.save();
+ top -= lineHeight / this.lineHeight * this._fontSizeFraction;
+ for (var i = charOffset, len = line.length + charOffset; i <= len; i++) {
+ prevStyle = prevStyle || this.getCurrentCharStyle(lineIndex, i);
+ thisStyle = this.getCurrentCharStyle(lineIndex, i + 1);
+
+ if (this._hasStyleChanged(prevStyle, thisStyle) || i === len) {
+ this._renderChar(method, ctx, lineIndex, i - 1, charsToRender, left, top, lineHeight);
+ charsToRender = '';
+ prevStyle = thisStyle;
+ }
+ charsToRender += line[i - charOffset];
+ }
+ ctx.restore();
+ },
+
+ /**
+ * @private
+ * @param {String} method
+ * @param {CanvasRenderingContext2D} ctx Context to render on
+ * @param {String} line Content of the line
+ * @param {Number} left Left coordinate
+ * @param {Number} top Top coordinate
+ */
+ _renderCharsFast: function(method, ctx, line, left, top) {
+
+ if (method === 'fillText' && this.fill) {
+ this.callSuper('_renderChars', method, ctx, line, left, top);
+ }
+ if (method === 'strokeText' && ((this.stroke && this.strokeWidth > 0) || this.skipFillStrokeCheck)) {
+ this.callSuper('_renderChars', method, ctx, line, left, top);
+ }
+ },
+
+ /**
+ * @private
+ * @param {String} method
+ * @param {CanvasRenderingContext2D} ctx Context to render on
+ * @param {Number} lineIndex
+ * @param {Number} i
+ * @param {String} _char
+ * @param {Number} left Left coordinate
+ * @param {Number} top Top coordinate
+ * @param {Number} lineHeight Height of the line
+ */
+ _renderChar: function(method, ctx, lineIndex, i, _char, left, top, lineHeight) {
+ var charWidth, charHeight, shouldFill, shouldStroke,
+ decl = this._getStyleDeclaration(lineIndex, i),
+ offset, textDecoration, chars, additionalSpace, _charWidth;
+
+ if (decl) {
+ charHeight = this._getHeightOfChar(ctx, _char, lineIndex, i);
+ shouldStroke = decl.stroke;
+ shouldFill = decl.fill;
+ textDecoration = decl.textDecoration;
+ }
+ else {
+ charHeight = this.fontSize;
+ }
+
+ shouldStroke = (shouldStroke || this.stroke) && method === 'strokeText';
+ shouldFill = (shouldFill || this.fill) && method === 'fillText';
+
+ decl && ctx.save();
+
+ charWidth = this._applyCharStylesGetWidth(ctx, _char, lineIndex, i, decl || null);
+ textDecoration = textDecoration || this.textDecoration;
+
+ if (decl && decl.textBackgroundColor) {
+ this._removeShadow(ctx);
+ }
+ if (this.charSpacing !== 0) {
+ additionalSpace = this._getWidthOfCharSpacing();
+ chars = _char.split('');
+ charWidth = 0;
+ for (var j = 0, len = chars.length, char; j < len; j++) {
+ char = chars[j];
+ shouldFill && ctx.fillText(char, left + charWidth, top);
+ shouldStroke && ctx.strokeText(char, left + charWidth, top);
+ _charWidth = ctx.measureText(char).width + additionalSpace;
+ charWidth += _charWidth > 0 ? _charWidth : 0;
+ }
+ }
+ else {
+ shouldFill && ctx.fillText(_char, left, top);
+ shouldStroke && ctx.strokeText(_char, left, top);
+ }
+
+ if (textDecoration || textDecoration !== '') {
+ offset = this._fontSizeFraction * lineHeight / this.lineHeight;
+ this._renderCharDecoration(ctx, textDecoration, left, top, offset, charWidth, charHeight);
+ }
+
+ decl && ctx.restore();
+ ctx.translate(charWidth, 0);
+ },
+
+ /**
+ * @private
+ * @param {Object} prevStyle
+ * @param {Object} thisStyle
+ */
+ _hasStyleChanged: function(prevStyle, thisStyle) {
+ return (prevStyle.fill !== thisStyle.fill ||
+ prevStyle.fontSize !== thisStyle.fontSize ||
+ prevStyle.textBackgroundColor !== thisStyle.textBackgroundColor ||
+ prevStyle.textDecoration !== thisStyle.textDecoration ||
+ prevStyle.fontFamily !== thisStyle.fontFamily ||
+ prevStyle.fontWeight !== thisStyle.fontWeight ||
+ prevStyle.fontStyle !== thisStyle.fontStyle ||
+ prevStyle.stroke !== thisStyle.stroke ||
+ prevStyle.strokeWidth !== thisStyle.strokeWidth
+ );
+ },
+
+ /**
+ * @private
+ * @param {CanvasRenderingContext2D} ctx Context to render on
+ */
+ _renderCharDecoration: function(ctx, textDecoration, left, top, offset, charWidth, charHeight) {
+
+ if (!textDecoration) {
+ return;
+ }
+
+ var decorationWeight = charHeight / 15,
+ positions = {
+ underline: top + charHeight / 10,
+ 'line-through': top - charHeight * (this._fontSizeFraction + this._fontSizeMult - 1) + decorationWeight,
+ overline: top - (this._fontSizeMult - this._fontSizeFraction) * charHeight
+ },
+ decorations = ['underline', 'line-through', 'overline'], i, decoration;
+
+ for (i = 0; i < decorations.length; i++) {
+ decoration = decorations[i];
+ if (textDecoration.indexOf(decoration) > -1) {
+ ctx.fillRect(left, positions[decoration], charWidth , decorationWeight);
+ }
+ }
+ },
+
+ /**
+ * @private
+ * @param {String} method
+ * @param {CanvasRenderingContext2D} ctx Context to render on
+ * @param {String} line
+ * @param {Number} left
+ * @param {Number} top
+ * @param {Number} lineIndex
+ */
+ _renderTextLine: function(method, ctx, line, left, top, lineIndex) {
+ // to "cancel" this.fontSize subtraction in fabric.Text#_renderTextLine
+ // the adding 0.03 is just to align text with itext by overlap test
+ if (!this.isEmptyStyles()) {
+ top += this.fontSize * (this._fontSizeFraction + 0.03);
+ }
+ this.callSuper('_renderTextLine', method, ctx, line, left, top, lineIndex);
+ },
+
+ /**
+ * @private
+ * @param {CanvasRenderingContext2D} ctx Context to render on
+ */
+ _renderTextDecoration: function(ctx) {
+ if (this.isEmptyStyles()) {
+ return this.callSuper('_renderTextDecoration', ctx);
+ }
+ },
+
+ /**
+ * @private
+ * @param {CanvasRenderingContext2D} ctx Context to render on
+ */
+ _renderTextLinesBackground: function(ctx) {
+ this.callSuper('_renderTextLinesBackground', ctx);
+
+ var lineTopOffset = 0, heightOfLine,
+ lineWidth, lineLeftOffset,
+ leftOffset = this._getLeftOffset(),
+ topOffset = this._getTopOffset(),
+ line, _char, style;
+
+ for (var i = 0, len = this._textLines.length; i < len; i++) {
+ heightOfLine = this._getHeightOfLine(ctx, i);
+ line = this._textLines[i];
+
+ if (line === '' || !this.styles || !this._getLineStyle(i)) {
+ lineTopOffset += heightOfLine;
+ continue;
+ }
+
+ lineWidth = this._getLineWidth(ctx, i);
+ lineLeftOffset = this._getLineLeftOffset(lineWidth);
+
+ for (var j = 0, jlen = line.length; j < jlen; j++) {
+ style = this._getStyleDeclaration(i, j);
+ if (!style || !style.textBackgroundColor) {
+ continue;
+ }
+ _char = line[j];
+
+ ctx.fillStyle = style.textBackgroundColor;
+
+ ctx.fillRect(
+ leftOffset + lineLeftOffset + this._getWidthOfCharsAt(ctx, i, j),
+ topOffset + lineTopOffset,
+ this._getWidthOfChar(ctx, _char, i, j),
+ heightOfLine / this.lineHeight
+ );
+ }
+ lineTopOffset += heightOfLine;
+ }
+ },
+
+ /**
+ * @private
+ */
+ _getCacheProp: function(_char, styleDeclaration) {
+ return _char +
+ styleDeclaration.fontSize +
+ styleDeclaration.fontWeight +
+ styleDeclaration.fontStyle;
+ },
+
+ /**
+ * @private
+ * @param {String} fontFamily name
+ * @return {Object} reference to cache
+ */
+ _getFontCache: function(fontFamily) {
+ if (!fabric.charWidthsCache[fontFamily]) {
+ fabric.charWidthsCache[fontFamily] = { };
+ }
+ return fabric.charWidthsCache[fontFamily];
+ },
+
+ /**
+ * @private
+ * @param {CanvasRenderingContext2D} ctx Context to render on
+ * @param {String} _char
+ * @param {Number} lineIndex
+ * @param {Number} charIndex
+ * @param {Object} [decl]
+ */
+ _applyCharStylesGetWidth: function(ctx, _char, lineIndex, charIndex, decl) {
+ var charDecl = decl || this._getStyleDeclaration(lineIndex, charIndex),
+ styleDeclaration = clone(charDecl),
+ width, cacheProp, charWidthsCache;
+
+ this._applyFontStyles(styleDeclaration);
+ charWidthsCache = this._getFontCache(styleDeclaration.fontFamily);
+ cacheProp = this._getCacheProp(_char, styleDeclaration);
+
+ // short-circuit if no styles for this char
+ // global style from object is always applyed and handled by save and restore
+ if (!charDecl && charWidthsCache[cacheProp] && this.caching) {
+ return charWidthsCache[cacheProp];
+ }
+
+ if (typeof styleDeclaration.shadow === 'string') {
+ styleDeclaration.shadow = new fabric.Shadow(styleDeclaration.shadow);
+ }
+
+ var fill = styleDeclaration.fill || this.fill;
+ ctx.fillStyle = fill.toLive
+ ? fill.toLive(ctx, this)
+ : fill;
+
+ if (styleDeclaration.stroke) {
+ ctx.strokeStyle = (styleDeclaration.stroke && styleDeclaration.stroke.toLive)
+ ? styleDeclaration.stroke.toLive(ctx, this)
+ : styleDeclaration.stroke;
+ }
+
+ ctx.lineWidth = styleDeclaration.strokeWidth || this.strokeWidth;
+ ctx.font = this._getFontDeclaration.call(styleDeclaration);
+
+ //if we want this._setShadow.call to work with styleDeclarion
+ //we have to add those references
+ if (styleDeclaration.shadow) {
+ styleDeclaration.scaleX = this.scaleX;
+ styleDeclaration.scaleY = this.scaleY;
+ styleDeclaration.canvas = this.canvas;
+ styleDeclaration.getObjectScaling = this.getObjectScaling;
+ this._setShadow.call(styleDeclaration, ctx);
+ }
+
+ if (!this.caching || !charWidthsCache[cacheProp]) {
+ width = ctx.measureText(_char).width;
+ this.caching && (charWidthsCache[cacheProp] = width);
+ return width;
+ }
+
+ return charWidthsCache[cacheProp];
+ },
+
+ /**
+ * @private
+ * @param {Object} styleDeclaration
+ */
+ _applyFontStyles: function(styleDeclaration) {
+ if (!styleDeclaration.fontFamily) {
+ styleDeclaration.fontFamily = this.fontFamily;
+ }
+ if (!styleDeclaration.fontSize) {
+ styleDeclaration.fontSize = this.fontSize;
+ }
+ if (!styleDeclaration.fontWeight) {
+ styleDeclaration.fontWeight = this.fontWeight;
+ }
+ if (!styleDeclaration.fontStyle) {
+ styleDeclaration.fontStyle = this.fontStyle;
+ }
+ },
+
+ /**
+ * @param {Number} lineIndex
+ * @param {Number} charIndex
+ * @param {Boolean} [returnCloneOrEmpty=false]
+ * @private
+ */
+ _getStyleDeclaration: function(lineIndex, charIndex, returnCloneOrEmpty) {
+ if (returnCloneOrEmpty) {
+ return (this.styles[lineIndex] && this.styles[lineIndex][charIndex])
+ ? clone(this.styles[lineIndex][charIndex])
+ : { };
+ }
+
+ return this.styles[lineIndex] && this.styles[lineIndex][charIndex] ? this.styles[lineIndex][charIndex] : null;
+ },
+
+ /**
+ * @param {Number} lineIndex
+ * @param {Number} charIndex
+ * @param {Object} style
+ * @private
+ */
+ _setStyleDeclaration: function(lineIndex, charIndex, style) {
+ this.styles[lineIndex][charIndex] = style;
+ },
+
+ /**
+ *
+ * @param {Number} lineIndex
+ * @param {Number} charIndex
+ * @private
+ */
+ _deleteStyleDeclaration: function(lineIndex, charIndex) {
+ delete this.styles[lineIndex][charIndex];
+ },
+
+ /**
+ * @param {Number} lineIndex
+ * @private
+ */
+ _getLineStyle: function(lineIndex) {
+ return this.styles[lineIndex];
+ },
+
+ /**
+ * @param {Number} lineIndex
+ * @param {Object} style
+ * @private
+ */
+ _setLineStyle: function(lineIndex, style) {
+ this.styles[lineIndex] = style;
+ },
+
+ /**
+ * @param {Number} lineIndex
+ * @private
+ */
+ _deleteLineStyle: function(lineIndex) {
+ delete this.styles[lineIndex];
+ },
+
+ /**
+ * @private
+ * @param {CanvasRenderingContext2D} ctx Context to render on
+ */
+ _getWidthOfChar: function(ctx, _char, lineIndex, charIndex) {
+ if (!this._isMeasuring && this.textAlign === 'justify' && this._reSpacesAndTabs.test(_char)) {
+ return this._getWidthOfSpace(ctx, lineIndex);
+ }
+ ctx.save();
+ var width = this._applyCharStylesGetWidth(ctx, _char, lineIndex, charIndex);
+ if (this.charSpacing !== 0) {
+ width += this._getWidthOfCharSpacing();
+ }
+ ctx.restore();
+ return width > 0 ? width : 0
+ },
+
+ /**
+ * @private
+ * @param {CanvasRenderingContext2D} ctx Context to render on
+ * @param {Number} lineIndex
+ * @param {Number} charIndex
+ */
+ _getHeightOfChar: function(ctx, lineIndex, charIndex) {
+ var style = this._getStyleDeclaration(lineIndex, charIndex);
+ return style && style.fontSize ? style.fontSize : this.fontSize;
+ },
+
+ /**
+ * @private
+ * @param {CanvasRenderingContext2D} ctx Context to render on
+ * @param {Number} lineIndex
+ * @param {Number} charIndex
+ */
+ _getWidthOfCharsAt: function(ctx, lineIndex, charIndex) {
+ var width = 0, i, _char;
+ for (i = 0; i < charIndex; i++) {
+ _char = this._textLines[lineIndex][i];
+ width += this._getWidthOfChar(ctx, _char, lineIndex, i);
+ }
+ return width;
+ },
+
+ /**
+ * @private
+ * @param {CanvasRenderingContext2D} ctx Context to render on
+ * @param {Number} lineIndex line number
+ * @return {Number} Line width
+ */
+ _measureLine: function(ctx, lineIndex) {
+ this._isMeasuring = true;
+ var width = this._getWidthOfCharsAt(ctx, lineIndex, this._textLines[lineIndex].length);
+ if (this.charSpacing !== 0) {
+ width -= this._getWidthOfCharSpacing();
+ }
+ this._isMeasuring = false;
+ return width > 0 ? width : 0;
+ },
+
+ /**
+ * @private
+ * @param {CanvasRenderingContext2D} ctx Context to render on
+ * @param {Number} lineIndex
+ */
+ _getWidthOfSpace: function (ctx, lineIndex) {
+ if (this.__widthOfSpace[lineIndex]) {
+ return this.__widthOfSpace[lineIndex];
+ }
+ var line = this._textLines[lineIndex],
+ wordsWidth = this._getWidthOfWords(ctx, line, lineIndex, 0),
+ widthDiff = this.width - wordsWidth,
+ numSpaces = line.length - line.replace(this._reSpacesAndTabs, '').length,
+ width = Math.max(widthDiff / numSpaces, ctx.measureText(' ').width);
+ this.__widthOfSpace[lineIndex] = width;
+ return width;
+ },
+
+ /**
+ * @private
+ * @param {CanvasRenderingContext2D} ctx Context to render on
+ * @param {String} line
+ * @param {Number} lineIndex
+ * @param {Number} charOffset
+ */
+ _getWidthOfWords: function (ctx, line, lineIndex, charOffset) {
+ var width = 0;
+
+ for (var charIndex = 0; charIndex < line.length; charIndex++) {
+ var _char = line[charIndex];
+
+ if (!_char.match(/\s/)) {
+ width += this._getWidthOfChar(ctx, _char, lineIndex, charIndex + charOffset);
+ }
+ }
+
+ return width;
+ },
+
+ /**
+ * @private
+ * @param {CanvasRenderingContext2D} ctx Context to render on
+ */
+ _getHeightOfLine: function(ctx, lineIndex) {
+ if (this.__lineHeights[lineIndex]) {
+ return this.__lineHeights[lineIndex];
+ }
+
+ var line = this._textLines[lineIndex],
+ maxHeight = this._getHeightOfChar(ctx, lineIndex, 0);
+
+ for (var i = 1, len = line.length; i < len; i++) {
+ var currentCharHeight = this._getHeightOfChar(ctx, lineIndex, i);
+ if (currentCharHeight > maxHeight) {
+ maxHeight = currentCharHeight;
+ }
+ }
+ this.__lineHeights[lineIndex] = maxHeight * this.lineHeight * this._fontSizeMult;
+ return this.__lineHeights[lineIndex];
+ },
+
+ /**
+ * @private
+ * @param {CanvasRenderingContext2D} ctx Context to render on
+ */
+ _getTextHeight: function(ctx) {
+ var lineHeight, height = 0;
+ for (var i = 0, len = this._textLines.length; i < len; i++) {
+ lineHeight = this._getHeightOfLine(ctx, i);
+ height += (i === len - 1 ? lineHeight / this.lineHeight : lineHeight);
+ }
+ return height;
+ },
+
+ /**
+ * Returns object representation of an instance
+ * @method toObject
+ * @param {Array} [propertiesToInclude] Any properties that you might want to additionally include in the output
+ * @return {Object} object representation of an instance
+ */
+ toObject: function(propertiesToInclude) {
+ return fabric.util.object.extend(this.callSuper('toObject', propertiesToInclude), {
+ styles: clone(this.styles, true)
+ });
+ }
+ });
+
+ /**
+ * Returns fabric.IText instance from an object representation
+ * @static
+ * @memberOf fabric.IText
+ * @param {Object} object Object to create an instance from
+ * @param {function} [callback] invoked with new instance as argument
+ * @return {fabric.IText} instance of fabric.IText
+ */
+ fabric.IText.fromObject = function(object, callback) {
+ var iText = new fabric.IText(object.text, clone(object));
+ callback && callback(iText);
+ return iText;
+ };
+})();
+
+
+(function() {
+
+ var clone = fabric.util.object.clone;
+
+ fabric.util.object.extend(fabric.IText.prototype, /** @lends fabric.IText.prototype */ {
+
+ /**
+ * Initializes all the interactive behavior of IText
+ */
+ initBehavior: function() {
+ this.initAddedHandler();
+ this.initRemovedHandler();
+ this.initCursorSelectionHandlers();
+ this.initDoubleClickSimulation();
+ this.mouseMoveHandler = this.mouseMoveHandler.bind(this);
+ },
+
+ /**
+ * Initializes "selected" event handler
+ */
+ initSelectedHandler: function() {
+ this.on('selected', function() {
+
+ var _this = this;
+ setTimeout(function() {
+ _this.selected = true;
+ }, 100);
+ });
+ },
+
+ /**
+ * Initializes "added" event handler
+ */
+ initAddedHandler: function() {
+ var _this = this;
+ this.on('added', function() {
+ var canvas = _this.canvas;
+ if (canvas) {
+ if (!canvas._hasITextHandlers) {
+ canvas._hasITextHandlers = true;
+ _this._initCanvasHandlers(canvas);
+ }
+ canvas._iTextInstances = canvas._iTextInstances || [];
+ canvas._iTextInstances.push(_this);
+ }
+ });
+ },
+
+ initRemovedHandler: function() {
+ var _this = this;
+ this.on('removed', function() {
+ var canvas = _this.canvas;
+ if (canvas) {
+ canvas._iTextInstances = canvas._iTextInstances || [];
+ fabric.util.removeFromArray(canvas._iTextInstances, _this);
+ if (canvas._iTextInstances.length === 0) {
+ canvas._hasITextHandlers = false;
+ _this._removeCanvasHandlers(canvas);
+ }
+ }
+ });
+ },
+
+ /**
+ * register canvas event to manage exiting on other instances
+ * @private
+ */
+ _initCanvasHandlers: function(canvas) {
+ canvas._canvasITextSelectionClearedHanlder = (function() {
+ fabric.IText.prototype.exitEditingOnOthers(canvas);
+ }).bind(this);
+ canvas._mouseUpITextHandler = (function() {
+ if (canvas._iTextInstances) {
+ canvas._iTextInstances.forEach(function(obj) {
+ obj.__isMousedown = false;
+ });
+ }
+ }).bind(this);
+ canvas.on('selection:cleared', canvas._canvasITextSelectionClearedHanlder);
+ canvas.on('object:selected', canvas._canvasITextSelectionClearedHanlder);
+ canvas.on('mouse:up', canvas._mouseUpITextHandler);
+ },
+
+ /**
+ * remove canvas event to manage exiting on other instances
+ * @private
+ */
+ _removeCanvasHandlers: function(canvas) {
+ canvas.off('selection:cleared', canvas._canvasITextSelectionClearedHanlder);
+ canvas.off('object:selected', canvas._canvasITextSelectionClearedHanlder);
+ canvas.off('mouse:up', canvas._mouseUpITextHandler);
+ },
+
+ /**
+ * @private
+ */
+ _tick: function() {
+ this._currentTickState = this._animateCursor(this, 1, this.cursorDuration, '_onTickComplete');
+ },
+
+ /**
+ * @private
+ */
+ _animateCursor: function(obj, targetOpacity, duration, completeMethod) {
+
+ var tickState;
+
+ tickState = {
+ isAborted: false,
+ abort: function() {
+ this.isAborted = true;
+ },
+ };
+
+ obj.animate('_currentCursorOpacity', targetOpacity, {
+ duration: duration,
+ onComplete: function() {
+ if (!tickState.isAborted) {
+ obj[completeMethod]();
+ }
+ },
+ onChange: function() {
+ // we do not want to animate a selection, only cursor
+ if (obj.canvas && obj.selectionStart === obj.selectionEnd) {
+ obj.renderCursorOrSelection();
+ }
+ },
+ abort: function() {
+ return tickState.isAborted;
+ }
+ });
+ return tickState;
+ },
+
+ /**
+ * @private
+ */
+ _onTickComplete: function() {
+
+ var _this = this;
+
+ if (this._cursorTimeout1) {
+ clearTimeout(this._cursorTimeout1);
+ }
+ this._cursorTimeout1 = setTimeout(function() {
+ _this._currentTickCompleteState = _this._animateCursor(_this, 0, this.cursorDuration / 2, '_tick');
+ }, 100);
+ },
+
+ /**
+ * Initializes delayed cursor
+ */
+ initDelayedCursor: function(restart) {
+ var _this = this,
+ delay = restart ? 0 : this.cursorDelay;
+
+ this.abortCursorAnimation();
+ this._currentCursorOpacity = 1;
+ this._cursorTimeout2 = setTimeout(function() {
+ _this._tick();
+ }, delay);
+ },
+
+ /**
+ * Aborts cursor animation and clears all timeouts
+ */
+ abortCursorAnimation: function() {
+ var shouldClear = this._currentTickState || this._currentTickCompleteState;
+ this._currentTickState && this._currentTickState.abort();
+ this._currentTickCompleteState && this._currentTickCompleteState.abort();
+
+ clearTimeout(this._cursorTimeout1);
+ clearTimeout(this._cursorTimeout2);
+
+ this._currentCursorOpacity = 0;
+ // to clear just itext area we need to transform the context
+ // it may not be worth it
+ if (shouldClear) {
+ this.canvas && this.canvas.clearContext(this.canvas.contextTop || this.ctx);
+ }
+
+ },
+
+ /**
+ * Selects entire text
+ */
+ selectAll: function() {
+ this.selectionStart = 0;
+ this.selectionEnd = this.text.length;
+ this._fireSelectionChanged();
+ this._updateTextarea();
+ },
+
+ /**
+ * Returns selected text
+ * @return {String}
+ */
+ getSelectedText: function() {
+ return this.text.slice(this.selectionStart, this.selectionEnd);
+ },
+
+ /**
+ * Find new selection index representing start of current word according to current selection index
+ * @param {Number} startFrom Surrent selection index
+ * @return {Number} New selection index
+ */
+ findWordBoundaryLeft: function(startFrom) {
+ var offset = 0, index = startFrom - 1;
+
+ // remove space before cursor first
+ if (this._reSpace.test(this.text.charAt(index))) {
+ while (this._reSpace.test(this.text.charAt(index))) {
+ offset++;
+ index--;
+ }
+ }
+ while (/\S/.test(this.text.charAt(index)) && index > -1) {
+ offset++;
+ index--;
+ }
+
+ return startFrom - offset;
+ },
+
+ /**
+ * Find new selection index representing end of current word according to current selection index
+ * @param {Number} startFrom Current selection index
+ * @return {Number} New selection index
+ */
+ findWordBoundaryRight: function(startFrom) {
+ var offset = 0, index = startFrom;
+
+ // remove space after cursor first
+ if (this._reSpace.test(this.text.charAt(index))) {
+ while (this._reSpace.test(this.text.charAt(index))) {
+ offset++;
+ index++;
+ }
+ }
+ while (/\S/.test(this.text.charAt(index)) && index < this.text.length) {
+ offset++;
+ index++;
+ }
+
+ return startFrom + offset;
+ },
+
+ /**
+ * Find new selection index representing start of current line according to current selection index
+ * @param {Number} startFrom Current selection index
+ * @return {Number} New selection index
+ */
+ findLineBoundaryLeft: function(startFrom) {
+ var offset = 0, index = startFrom - 1;
+
+ while (!/\n/.test(this.text.charAt(index)) && index > -1) {
+ offset++;
+ index--;
+ }
+
+ return startFrom - offset;
+ },
+
+ /**
+ * Find new selection index representing end of current line according to current selection index
+ * @param {Number} startFrom Current selection index
+ * @return {Number} New selection index
+ */
+ findLineBoundaryRight: function(startFrom) {
+ var offset = 0, index = startFrom;
+
+ while (!/\n/.test(this.text.charAt(index)) && index < this.text.length) {
+ offset++;
+ index++;
+ }
+
+ return startFrom + offset;
+ },
+
+ /**
+ * Returns number of newlines in selected text
+ * @return {Number} Number of newlines in selected text
+ */
+ getNumNewLinesInSelectedText: function() {
+ var selectedText = this.getSelectedText(),
+ numNewLines = 0;
+
+ for (var i = 0, len = selectedText.length; i < len; i++) {
+ if (selectedText[i] === '\n') {
+ numNewLines++;
+ }
+ }
+ return numNewLines;
+ },
+
+ /**
+ * Finds index corresponding to beginning or end of a word
+ * @param {Number} selectionStart Index of a character
+ * @param {Number} direction 1 or -1
+ * @return {Number} Index of the beginning or end of a word
+ */
+ searchWordBoundary: function(selectionStart, direction) {
+ var index = this._reSpace.test(this.text.charAt(selectionStart)) ? selectionStart - 1 : selectionStart,
+ _char = this.text.charAt(index),
+ reNonWord = /[ \n\.,;!\?\-]/;
+
+ while (!reNonWord.test(_char) && index > 0 && index < this.text.length) {
+ index += direction;
+ _char = this.text.charAt(index);
+ }
+ if (reNonWord.test(_char) && _char !== '\n') {
+ index += direction === 1 ? 0 : 1;
+ }
+ return index;
+ },
+
+ /**
+ * Selects a word based on the index
+ * @param {Number} selectionStart Index of a character
+ */
+ selectWord: function(selectionStart) {
+ selectionStart = selectionStart || this.selectionStart;
+ var newSelectionStart = this.searchWordBoundary(selectionStart, -1), /* search backwards */
+ newSelectionEnd = this.searchWordBoundary(selectionStart, 1); /* search forward */
+
+ this.selectionStart = newSelectionStart;
+ this.selectionEnd = newSelectionEnd;
+ this._fireSelectionChanged();
+ this._updateTextarea();
+ this.renderCursorOrSelection();
+ },
+
+ /**
+ * Selects a line based on the index
+ * @param {Number} selectionStart Index of a character
+ */
+ selectLine: function(selectionStart) {
+ selectionStart = selectionStart || this.selectionStart;
+ var newSelectionStart = this.findLineBoundaryLeft(selectionStart),
+ newSelectionEnd = this.findLineBoundaryRight(selectionStart);
+
+ this.selectionStart = newSelectionStart;
+ this.selectionEnd = newSelectionEnd;
+ this._fireSelectionChanged();
+ this._updateTextarea();
+ },
+
+ /**
+ * Enters editing state
+ * @return {fabric.IText} thisArg
+ * @chainable
+ */
+ enterEditing: function(e) {
+ if (this.isEditing || !this.editable) {
+ return;
+ }
+
+ if (this.canvas) {
+ this.exitEditingOnOthers(this.canvas);
+ }
+
+ this.isEditing = true;
+
+ this.initHiddenTextarea(e);
+ this.hiddenTextarea.focus();
+ this._updateTextarea();
+ this._saveEditingProps();
+ this._setEditingProps();
+ this._textBeforeEdit = this.text;
+
+ this._tick();
+ this.fire('editing:entered');
+
+ if (!this.canvas) {
+ return this;
+ }
+ this.canvas.fire('text:editing:entered', { target: this });
+ this.initMouseMoveHandler();
+ this.canvas.renderAll();
+ return this;
+ },
+
+ exitEditingOnOthers: function(canvas) {
+ if (canvas._iTextInstances) {
+ canvas._iTextInstances.forEach(function(obj) {
+ obj.selected = false;
+ if (obj.isEditing) {
+ obj.exitEditing();
+ }
+ });
+ }
+ },
+
+ /**
+ * Initializes "mousemove" event handler
+ */
+ initMouseMoveHandler: function() {
+ this.canvas.on('mouse:move', this.mouseMoveHandler);
+ },
+
+ /**
+ * @private
+ */
+ mouseMoveHandler: function(options) {
+ if (!this.__isMousedown || !this.isEditing) {
+ return;
+ }
+
+ var newSelectionStart = this.getSelectionStartFromPointer(options.e),
+ currentStart = this.selectionStart,
+ currentEnd = this.selectionEnd;
+ if (newSelectionStart === this.__selectionStartOnMouseDown) {
+ return;
+ }
+ if (newSelectionStart > this.__selectionStartOnMouseDown) {
+ this.selectionStart = this.__selectionStartOnMouseDown;
+ this.selectionEnd = newSelectionStart;
+ }
+ else {
+ this.selectionStart = newSelectionStart;
+ this.selectionEnd = this.__selectionStartOnMouseDown;
+ }
+ if (this.selectionStart !== currentStart || this.selectionEnd !== currentEnd) {
+ this._fireSelectionChanged();
+ this._updateTextarea();
+ this.renderCursorOrSelection();
+ }
+ },
+
+ /**
+ * @private
+ */
+ _setEditingProps: function() {
+ this.hoverCursor = 'text';
+
+ if (this.canvas) {
+ this.canvas.defaultCursor = this.canvas.moveCursor = 'text';
+ }
+
+ this.borderColor = this.editingBorderColor;
+
+ this.hasControls = this.selectable = false;
+ this.lockMovementX = this.lockMovementY = true;
+ },
+
+ /**
+ * @private
+ */
+ _updateTextarea: function() {
+ if (!this.hiddenTextarea || this.inCompositionMode) {
+ return;
+ }
+ this.cursorOffsetCache = { };
+ this.hiddenTextarea.value = this.text;
+ this.hiddenTextarea.selectionStart = this.selectionStart;
+ this.hiddenTextarea.selectionEnd = this.selectionEnd;
+ if (this.selectionStart === this.selectionEnd) {
+ var style = this._calcTextareaPosition();
+ this.hiddenTextarea.style.left = style.left;
+ this.hiddenTextarea.style.top = style.top;
+ this.hiddenTextarea.style.fontSize = style.fontSize;
+ }
+ },
+
+ /**
+ * @private
+ * @return {Object} style contains style for hiddenTextarea
+ */
+ _calcTextareaPosition: function() {
+ if (!this.canvas) {
+ return { x: 1, y: 1 };
+ }
+ var chars = this.text.split(''),
+ boundaries = this._getCursorBoundaries(chars, 'cursor'),
+ cursorLocation = this.get2DCursorLocation(),
+ lineIndex = cursorLocation.lineIndex,
+ charIndex = cursorLocation.charIndex,
+ charHeight = this.getCurrentCharFontSize(lineIndex, charIndex),
+ leftOffset = (lineIndex === 0 && charIndex === 0)
+ ? this._getLineLeftOffset(this._getLineWidth(this.ctx, lineIndex))
+ : boundaries.leftOffset,
+ m = this.calcTransformMatrix(),
+ p = {
+ x: boundaries.left + leftOffset,
+ y: boundaries.top + boundaries.topOffset + charHeight
+ },
+ upperCanvas = this.canvas.upperCanvasEl,
+ maxWidth = upperCanvas.width - charHeight,
+ maxHeight = upperCanvas.height - charHeight;
+
+ p = fabric.util.transformPoint(p, m);
+ p = fabric.util.transformPoint(p, this.canvas.viewportTransform);
+
+ if (p.x < 0) {
+ p.x = 0;
+ }
+ if (p.x > maxWidth) {
+ p.x = maxWidth;
+ }
+ if (p.y < 0) {
+ p.y = 0;
+ }
+ if (p.y > maxHeight) {
+ p.y = maxHeight;
+ }
+
+ // add canvas offset on document
+ p.x += this.canvas._offset.left;
+ p.y += this.canvas._offset.top;
+
+ return { left: p.x + 'px', top: p.y + 'px', fontSize: charHeight };
+ },
+
+ /**
+ * @private
+ */
+ _saveEditingProps: function() {
+ this._savedProps = {
+ hasControls: this.hasControls,
+ borderColor: this.borderColor,
+ lockMovementX: this.lockMovementX,
+ lockMovementY: this.lockMovementY,
+ hoverCursor: this.hoverCursor,
+ defaultCursor: this.canvas && this.canvas.defaultCursor,
+ moveCursor: this.canvas && this.canvas.moveCursor
+ };
+ },
+
+ /**
+ * @private
+ */
+ _restoreEditingProps: function() {
+ if (!this._savedProps) {
+ return;
+ }
+
+ this.hoverCursor = this._savedProps.overCursor;
+ this.hasControls = this._savedProps.hasControls;
+ this.borderColor = this._savedProps.borderColor;
+ this.lockMovementX = this._savedProps.lockMovementX;
+ this.lockMovementY = this._savedProps.lockMovementY;
+
+ if (this.canvas) {
+ this.canvas.defaultCursor = this._savedProps.defaultCursor;
+ this.canvas.moveCursor = this._savedProps.moveCursor;
+ }
+ },
+
+ /**
+ * Exits from editing state
+ * @return {fabric.IText} thisArg
+ * @chainable
+ */
+ exitEditing: function() {
+ var isTextChanged = (this._textBeforeEdit !== this.text);
+ this.selected = false;
+ this.isEditing = false;
+ this.selectable = true;
+
+ this.selectionEnd = this.selectionStart;
+ this.hiddenTextarea && this.canvas && this.hiddenTextarea.parentNode.removeChild(this.hiddenTextarea);
+ this.hiddenTextarea = null;
+
+ this.abortCursorAnimation();
+ this._restoreEditingProps();
+ this._currentCursorOpacity = 0;
+
+ this.fire('editing:exited');
+ isTextChanged && this.fire('modified');
+ if (this.canvas) {
+ this.canvas.off('mouse:move', this.mouseMoveHandler);
+ this.canvas.fire('text:editing:exited', { target: this });
+ isTextChanged && this.canvas.fire('object:modified', { target: this });
+ }
+
+ return this;
+ },
+
+ /**
+ * @private
+ */
+ _removeExtraneousStyles: function() {
+ for (var prop in this.styles) {
+ if (!this._textLines[prop]) {
+ delete this.styles[prop];
+ }
+ }
+ },
+
+ /**
+ * @private
+ */
+ _removeCharsFromTo: function(start, end) {
+ while (end !== start) {
+ this._removeSingleCharAndStyle(start + 1);
+ end--;
+ }
+ this.selectionStart = start;
+ this.selectionEnd = start;
+ },
+
+ _removeSingleCharAndStyle: function(index) {
+ var isBeginningOfLine = this.text[index - 1] === '\n',
+ indexStyle = isBeginningOfLine ? index : index - 1;
+ this.removeStyleObject(isBeginningOfLine, indexStyle);
+ this.text = this.text.slice(0, index - 1) +
+ this.text.slice(index);
+
+ this._textLines = this._splitTextIntoLines();
+ },
+
+ /**
+ * Inserts characters where cursor is (replacing selection if one exists)
+ * @param {String} _chars Characters to insert
+ * @param {Boolean} useCopiedStyle use fabric.copiedTextStyle
+ */
+ insertChars: function(_chars, useCopiedStyle) {
+ var style;
+
+ if (this.selectionEnd - this.selectionStart > 1) {
+ this._removeCharsFromTo(this.selectionStart, this.selectionEnd);
+ }
+ //short circuit for block paste
+ if (!useCopiedStyle && this.isEmptyStyles()) {
+ this.insertChar(_chars, false);
+ return;
+ }
+ for (var i = 0, len = _chars.length; i < len; i++) {
+ if (useCopiedStyle) {
+ style = fabric.copiedTextStyle[i];
+ }
+ this.insertChar(_chars[i], i < len - 1, style);
+ }
+ },
+
+ /**
+ * Inserts a character where cursor is
+ * @param {String} _char Characters to insert
+ * @param {Boolean} skipUpdate trigger rendering and updates at the end of text insert
+ * @param {Object} styleObject Style to be inserted for the new char
+ */
+ insertChar: function(_char, skipUpdate, styleObject) {
+ var isEndOfLine = this.text[this.selectionStart] === '\n';
+ this.text = this.text.slice(0, this.selectionStart) +
+ _char + this.text.slice(this.selectionEnd);
+ this._textLines = this._splitTextIntoLines();
+ this.insertStyleObjects(_char, isEndOfLine, styleObject);
+ this.selectionStart += _char.length;
+ this.selectionEnd = this.selectionStart;
+ if (skipUpdate) {
+ return;
+ }
+ this._updateTextarea();
+ this.setCoords();
+ this._fireSelectionChanged();
+ this.fire('changed');
+ this.canvas && this.canvas.fire('text:changed', { target: this });
+ this.canvas && this.canvas.renderAll();
+ },
+
+ /**
+ * Inserts new style object
+ * @param {Number} lineIndex Index of a line
+ * @param {Number} charIndex Index of a char
+ * @param {Boolean} isEndOfLine True if it's end of line
+ */
+ insertNewlineStyleObject: function(lineIndex, charIndex, isEndOfLine) {
+
+ this.shiftLineStyles(lineIndex, +1);
+
+ if (!this.styles[lineIndex + 1]) {
+ this.styles[lineIndex + 1] = {};
+ }
+
+ var currentCharStyle = {},
+ newLineStyles = {};
+
+ if (this.styles[lineIndex] && this.styles[lineIndex][charIndex - 1]) {
+ currentCharStyle = this.styles[lineIndex][charIndex - 1];
+ }
+
+ // if there's nothing after cursor,
+ // we clone current char style onto the next (otherwise empty) line
+ if (isEndOfLine) {
+ newLineStyles[0] = clone(currentCharStyle);
+ this.styles[lineIndex + 1] = newLineStyles;
+ }
+ // otherwise we clone styles of all chars
+ // after cursor onto the next line, from the beginning
+ else {
+ for (var index in this.styles[lineIndex]) {
+ if (parseInt(index, 10) >= charIndex) {
+ newLineStyles[parseInt(index, 10) - charIndex] = this.styles[lineIndex][index];
+ // remove lines from the previous line since they're on a new line now
+ delete this.styles[lineIndex][index];
+ }
+ }
+ this.styles[lineIndex + 1] = newLineStyles;
+ }
+ this._forceClearCache = true;
+ },
+
+ /**
+ * Inserts style object for a given line/char index
+ * @param {Number} lineIndex Index of a line
+ * @param {Number} charIndex Index of a char
+ * @param {Object} [style] Style object to insert, if given
+ */
+ insertCharStyleObject: function(lineIndex, charIndex, style) {
+
+ var currentLineStyles = this.styles[lineIndex],
+ currentLineStylesCloned = clone(currentLineStyles);
+
+ if (charIndex === 0 && !style) {
+ charIndex = 1;
+ }
+
+ // shift all char styles by 1 forward
+ // 0,1,2,3 -> (charIndex=2) -> 0,1,3,4 -> (insert 2) -> 0,1,2,3,4
+ for (var index in currentLineStylesCloned) {
+ var numericIndex = parseInt(index, 10);
+
+ if (numericIndex >= charIndex) {
+ currentLineStyles[numericIndex + 1] = currentLineStylesCloned[numericIndex];
+
+ // only delete the style if there was nothing moved there
+ if (!currentLineStylesCloned[numericIndex - 1]) {
+ delete currentLineStyles[numericIndex];
+ }
+ }
+ }
+
+ this.styles[lineIndex][charIndex] =
+ style || clone(currentLineStyles[charIndex - 1]);
+ this._forceClearCache = true;
+ },
+
+ /**
+ * Inserts style object(s)
+ * @param {String} _chars Characters at the location where style is inserted
+ * @param {Boolean} isEndOfLine True if it's end of line
+ * @param {Object} [styleObject] Style to insert
+ */
+ insertStyleObjects: function(_chars, isEndOfLine, styleObject) {
+ // removed shortcircuit over isEmptyStyles
+
+ var cursorLocation = this.get2DCursorLocation(),
+ lineIndex = cursorLocation.lineIndex,
+ charIndex = cursorLocation.charIndex;
+
+ if (!this._getLineStyle(lineIndex)) {
+ this._setLineStyle(lineIndex, {});
+ }
+
+ if (_chars === '\n') {
+ this.insertNewlineStyleObject(lineIndex, charIndex, isEndOfLine);
+ }
+ else {
+ this.insertCharStyleObject(lineIndex, charIndex, styleObject);
+ }
+ },
+
+ /**
+ * Shifts line styles up or down
+ * @param {Number} lineIndex Index of a line
+ * @param {Number} offset Can be -1 or +1
+ */
+ shiftLineStyles: function(lineIndex, offset) {
+ // shift all line styles by 1 upward
+ var clonedStyles = clone(this.styles);
+ for (var line in this.styles) {
+ var numericLine = parseInt(line, 10);
+ if (numericLine > lineIndex) {
+ this.styles[numericLine + offset] = clonedStyles[numericLine];
+ if (!clonedStyles[numericLine - offset]) {
+ delete this.styles[numericLine];
+ }
+ }
+ }
+ //TODO: evaluate if delete old style lines with offset -1
+ },
+
+ /**
+ * Removes style object
+ * @param {Boolean} isBeginningOfLine True if cursor is at the beginning of line
+ * @param {Number} [index] Optional index. When not given, current selectionStart is used.
+ */
+ removeStyleObject: function(isBeginningOfLine, index) {
+
+ var cursorLocation = this.get2DCursorLocation(index),
+ lineIndex = cursorLocation.lineIndex,
+ charIndex = cursorLocation.charIndex;
+
+ this._removeStyleObject(isBeginningOfLine, cursorLocation, lineIndex, charIndex);
+ },
+
+ _getTextOnPreviousLine: function(lIndex) {
+ return this._textLines[lIndex - 1];
+ },
+
+ _removeStyleObject: function(isBeginningOfLine, cursorLocation, lineIndex, charIndex) {
+
+ if (isBeginningOfLine) {
+ var textOnPreviousLine = this._getTextOnPreviousLine(cursorLocation.lineIndex),
+ newCharIndexOnPrevLine = textOnPreviousLine ? textOnPreviousLine.length : 0;
+
+ if (!this.styles[lineIndex - 1]) {
+ this.styles[lineIndex - 1] = {};
+ }
+ for (charIndex in this.styles[lineIndex]) {
+ this.styles[lineIndex - 1][parseInt(charIndex, 10) + newCharIndexOnPrevLine]
+ = this.styles[lineIndex][charIndex];
+ }
+ this.shiftLineStyles(cursorLocation.lineIndex, -1);
+ }
+ else {
+ var currentLineStyles = this.styles[lineIndex];
+
+ if (currentLineStyles) {
+ delete currentLineStyles[charIndex];
+ }
+ var currentLineStylesCloned = clone(currentLineStyles);
+ // shift all styles by 1 backwards
+ for (var i in currentLineStylesCloned) {
+ var numericIndex = parseInt(i, 10);
+ if (numericIndex >= charIndex && numericIndex !== 0) {
+ currentLineStyles[numericIndex - 1] = currentLineStylesCloned[numericIndex];
+ delete currentLineStyles[numericIndex];
+ }
+ }
+ }
+ },
+
+ /**
+ * Inserts new line
+ */
+ insertNewline: function() {
+ this.insertChars('\n');
+ },
+
+ /**
+ * Set the selectionStart and selectionEnd according to the ne postion of cursor
+ * mimic the key - mouse navigation when shift is pressed.
+ */
+ setSelectionStartEndWithShift: function(start, end, newSelection) {
+ if (newSelection <= start) {
+ if (end === start) {
+ this._selectionDirection = 'left';
+ }
+ else if (this._selectionDirection === 'right') {
+ this._selectionDirection = 'left';
+ this.selectionEnd = start;
+ }
+ this.selectionStart = newSelection;
+ }
+ else if (newSelection > start && newSelection < end) {
+ if (this._selectionDirection === 'right') {
+ this.selectionEnd = newSelection;
+ }
+ else {
+ this.selectionStart = newSelection;
+ }
+ }
+ else {
+ // newSelection is > selection start and end
+ if (end === start) {
+ this._selectionDirection = 'right';
+ }
+ else if (this._selectionDirection === 'left') {
+ this._selectionDirection = 'right';
+ this.selectionStart = end;
+ }
+ this.selectionEnd = newSelection;
+ }
+ },
+
+ setSelectionInBoundaries: function() {
+ var length = this.text.length;
+ if (this.selectionStart > length) {
+ this.selectionStart = length;
+ }
+ else if (this.selectionStart < 0) {
+ this.selectionStart = 0;
+ }
+ if (this.selectionEnd > length) {
+ this.selectionEnd = length;
+ }
+ else if (this.selectionEnd < 0) {
+ this.selectionEnd = 0;
+ }
+ }
+ });
+})();
+
+
+fabric.util.object.extend(fabric.IText.prototype, /** @lends fabric.IText.prototype */ {
+ /**
+ * Initializes "dbclick" event handler
+ */
+ initDoubleClickSimulation: function() {
+
+ // for double click
+ this.__lastClickTime = +new Date();
+
+ // for triple click
+ this.__lastLastClickTime = +new Date();
+
+ this.__lastPointer = { };
+
+ this.on('mousedown', this.onMouseDown.bind(this));
+ },
+
+ onMouseDown: function(options) {
+
+ this.__newClickTime = +new Date();
+ var newPointer = this.canvas.getPointer(options.e);
+
+ if (this.isTripleClick(newPointer)) {
+ this.fire('tripleclick', options);
+ this._stopEvent(options.e);
+ }
+ else if (this.isDoubleClick(newPointer)) {
+ this.fire('dblclick', options);
+ this._stopEvent(options.e);
+ }
+
+ this.__lastLastClickTime = this.__lastClickTime;
+ this.__lastClickTime = this.__newClickTime;
+ this.__lastPointer = newPointer;
+ this.__lastIsEditing = this.isEditing;
+ this.__lastSelected = this.selected;
+ },
+
+ isDoubleClick: function(newPointer) {
+ return this.__newClickTime - this.__lastClickTime < 500 &&
+ this.__lastPointer.x === newPointer.x &&
+ this.__lastPointer.y === newPointer.y && this.__lastIsEditing;
+ },
+
+ isTripleClick: function(newPointer) {
+ return this.__newClickTime - this.__lastClickTime < 500 &&
+ this.__lastClickTime - this.__lastLastClickTime < 500 &&
+ this.__lastPointer.x === newPointer.x &&
+ this.__lastPointer.y === newPointer.y;
+ },
+
+ /**
+ * @private
+ */
+ _stopEvent: function(e) {
+ e.preventDefault && e.preventDefault();
+ e.stopPropagation && e.stopPropagation();
+ },
+
+ /**
+ * Initializes event handlers related to cursor or selection
+ */
+ initCursorSelectionHandlers: function() {
+ this.initSelectedHandler();
+ this.initMousedownHandler();
+ this.initMouseupHandler();
+ this.initClicks();
+ },
+
+ /**
+ * Initializes double and triple click event handlers
+ */
+ initClicks: function() {
+ this.on('dblclick', function(options) {
+ this.selectWord(this.getSelectionStartFromPointer(options.e));
+ });
+ this.on('tripleclick', function(options) {
+ this.selectLine(this.getSelectionStartFromPointer(options.e));
+ });
+ },
+
+ /**
+ * Initializes "mousedown" event handler
+ */
+ initMousedownHandler: function() {
+ this.on('mousedown', function(options) {
+ if (!this.editable) {
+ return;
+ }
+ var pointer = this.canvas.getPointer(options.e);
+
+ this.__mousedownX = pointer.x;
+ this.__mousedownY = pointer.y;
+ this.__isMousedown = true;
+
+ if (this.selected) {
+ this.setCursorByClick(options.e);
+ }
+
+ if (this.isEditing) {
+ this.__selectionStartOnMouseDown = this.selectionStart;
+ if (this.selectionStart === this.selectionEnd) {
+ this.abortCursorAnimation();
+ }
+ this.renderCursorOrSelection();
+ }
+ });
+ },
+
+ /**
+ * @private
+ */
+ _isObjectMoved: function(e) {
+ var pointer = this.canvas.getPointer(e);
+
+ return this.__mousedownX !== pointer.x ||
+ this.__mousedownY !== pointer.y;
+ },
+
+ /**
+ * Initializes "mouseup" event handler
+ */
+ initMouseupHandler: function() {
+ this.on('mouseup', function(options) {
+ this.__isMousedown = false;
+ if (!this.editable || this._isObjectMoved(options.e)) {
+ return;
+ }
+
+ if (this.__lastSelected && !this.__corner) {
+ this.enterEditing(options.e);
+ if (this.selectionStart === this.selectionEnd) {
+ this.initDelayedCursor(true);
+ }
+ else {
+ this.renderCursorOrSelection();
+ }
+ }
+ this.selected = true;
+ });
+ },
+
+ /**
+ * Changes cursor location in a text depending on passed pointer (x/y) object
+ * @param {Event} e Event object
+ */
+ setCursorByClick: function(e) {
+ var newSelection = this.getSelectionStartFromPointer(e),
+ start = this.selectionStart, end = this.selectionEnd;
+ if (e.shiftKey) {
+ this.setSelectionStartEndWithShift(start, end, newSelection);
+ }
+ else {
+ this.selectionStart = newSelection;
+ this.selectionEnd = newSelection;
+ }
+ this._fireSelectionChanged();
+ this._updateTextarea();
+ },
+
+ /**
+ * Returns index of a character corresponding to where an object was clicked
+ * @param {Event} e Event object
+ * @return {Number} Index of a character
+ */
+ getSelectionStartFromPointer: function(e) {
+ var mouseOffset = this.getLocalPointer(e),
+ prevWidth = 0,
+ width = 0,
+ height = 0,
+ charIndex = 0,
+ newSelectionStart,
+ line;
+
+ for (var i = 0, len = this._textLines.length; i < len; i++) {
+ line = this._textLines[i];
+ height += this._getHeightOfLine(this.ctx, i) * this.scaleY;
+
+ var widthOfLine = this._getLineWidth(this.ctx, i),
+ lineLeftOffset = this._getLineLeftOffset(widthOfLine);
+
+ width = lineLeftOffset * this.scaleX;
+
+ for (var j = 0, jlen = line.length; j < jlen; j++) {
+
+ prevWidth = width;
+
+ width += this._getWidthOfChar(this.ctx, line[j], i, this.flipX ? jlen - j : j) *
+ this.scaleX;
+
+ if (height <= mouseOffset.y || width <= mouseOffset.x) {
+ charIndex++;
+ continue;
+ }
+
+ return this._getNewSelectionStartFromOffset(
+ mouseOffset, prevWidth, width, charIndex + i, jlen);
+ }
+
+ if (mouseOffset.y < height) {
+ //this happens just on end of lines.
+ return this._getNewSelectionStartFromOffset(
+ mouseOffset, prevWidth, width, charIndex + i - 1, jlen);
+ }
+ }
+
+ // clicked somewhere after all chars, so set at the end
+ if (typeof newSelectionStart === 'undefined') {
+ return this.text.length;
+ }
+ },
+
+ /**
+ * @private
+ */
+ _getNewSelectionStartFromOffset: function(mouseOffset, prevWidth, width, index, jlen) {
+
+ var distanceBtwLastCharAndCursor = mouseOffset.x - prevWidth,
+ distanceBtwNextCharAndCursor = width - mouseOffset.x,
+ offset = distanceBtwNextCharAndCursor > distanceBtwLastCharAndCursor ? 0 : 1,
+ newSelectionStart = index + offset;
+
+ // if object is horizontally flipped, mirror cursor location from the end
+ if (this.flipX) {
+ newSelectionStart = jlen - newSelectionStart;
+ }
+
+ if (newSelectionStart > this.text.length) {
+ newSelectionStart = this.text.length;
+ }
+
+ return newSelectionStart;
+ }
+});
+
+
+fabric.util.object.extend(fabric.IText.prototype, /** @lends fabric.IText.prototype */ {
+
+ /**
+ * Initializes hidden textarea (needed to bring up keyboard in iOS)
+ */
+ initHiddenTextarea: function() {
+ this.hiddenTextarea = fabric.document.createElement('textarea');
+ this.hiddenTextarea.setAttribute('autocapitalize', 'off');
+ var style = this._calcTextareaPosition();
+ this.hiddenTextarea.style.cssText = 'position: absolute; top: ' + style.top + '; left: ' + style.left + ';'
+ + ' opacity: 0; width: 0px; height: 0px; z-index: -999;';
+ fabric.document.body.appendChild(this.hiddenTextarea);
+
+ fabric.util.addListener(this.hiddenTextarea, 'keydown', this.onKeyDown.bind(this));
+ fabric.util.addListener(this.hiddenTextarea, 'keyup', this.onKeyUp.bind(this));
+ fabric.util.addListener(this.hiddenTextarea, 'input', this.onInput.bind(this));
+ fabric.util.addListener(this.hiddenTextarea, 'copy', this.copy.bind(this));
+ fabric.util.addListener(this.hiddenTextarea, 'cut', this.cut.bind(this));
+ fabric.util.addListener(this.hiddenTextarea, 'paste', this.paste.bind(this));
+ fabric.util.addListener(this.hiddenTextarea, 'compositionstart', this.onCompositionStart.bind(this));
+ fabric.util.addListener(this.hiddenTextarea, 'compositionupdate', this.onCompositionUpdate.bind(this));
+ fabric.util.addListener(this.hiddenTextarea, 'compositionend', this.onCompositionEnd.bind(this));
+
+ if (!this._clickHandlerInitialized && this.canvas) {
+ fabric.util.addListener(this.canvas.upperCanvasEl, 'click', this.onClick.bind(this));
+ this._clickHandlerInitialized = true;
+ }
+ },
+
+ /**
+ * @private
+ */
+ _keysMap: {
+ 8: 'removeChars',
+ 9: 'exitEditing',
+ 27: 'exitEditing',
+ 13: 'insertNewline',
+ 33: 'moveCursorUp',
+ 34: 'moveCursorDown',
+ 35: 'moveCursorRight',
+ 36: 'moveCursorLeft',
+ 37: 'moveCursorLeft',
+ 38: 'moveCursorUp',
+ 39: 'moveCursorRight',
+ 40: 'moveCursorDown',
+ 46: 'forwardDelete'
+ },
+
+ /**
+ * @private
+ */
+ _ctrlKeysMapUp: {
+ 67: 'copy',
+ 88: 'cut'
+ },
+
+ /**
+ * @private
+ */
+ _ctrlKeysMapDown: {
+ 65: 'selectAll'
+ },
+
+ onClick: function() {
+ // No need to trigger click event here, focus is enough to have the keyboard appear on Android
+ this.hiddenTextarea && this.hiddenTextarea.focus();
+ },
+
+ /**
+ * Handles keyup event
+ * @param {Event} e Event object
+ */
+ onKeyDown: function(e) {
+ if (!this.isEditing) {
+ return;
+ }
+ if (e.keyCode in this._keysMap) {
+ this[this._keysMap[e.keyCode]](e);
+ }
+ else if ((e.keyCode in this._ctrlKeysMapDown) && (e.ctrlKey || e.metaKey)) {
+ this[this._ctrlKeysMapDown[e.keyCode]](e);
+ }
+ else {
+ return;
+ }
+ e.stopImmediatePropagation();
+ e.preventDefault();
+ this.canvas && this.canvas.renderAll();
+ },
+
+ /**
+ * Handles keyup event
+ * We handle KeyUp because ie11 and edge have difficulties copy/pasting
+ * if a copy/cut event fired, keyup is dismissed
+ * @param {Event} e Event object
+ */
+ onKeyUp: function(e) {
+ if (!this.isEditing || this._copyDone) {
+ this._copyDone = false;
+ return;
+ }
+ if ((e.keyCode in this._ctrlKeysMapUp) && (e.ctrlKey || e.metaKey)) {
+ this[this._ctrlKeysMapUp[e.keyCode]](e);
+ }
+ else {
+ return;
+ }
+ e.stopImmediatePropagation();
+ e.preventDefault();
+ this.canvas && this.canvas.renderAll();
+ },
+
+ /**
+ * Handles onInput event
+ * @param {Event} e Event object
+ */
+ onInput: function(e) {
+ if (!this.isEditing || this.inCompositionMode) {
+ return;
+ }
+ var offset = this.selectionStart || 0,
+ offsetEnd = this.selectionEnd || 0,
+ textLength = this.text.length,
+ newTextLength = this.hiddenTextarea.value.length,
+ diff, charsToInsert, start;
+ if (newTextLength > textLength) {
+ //we added some character
+ start = this._selectionDirection === 'left' ? offsetEnd : offset;
+ diff = newTextLength - textLength;
+ charsToInsert = this.hiddenTextarea.value.slice(start, start + diff);
+ }
+ else {
+ //we selected a portion of text and then input something else.
+ //Internet explorer does not trigger this else
+ diff = newTextLength - textLength + offsetEnd - offset;
+ charsToInsert = this.hiddenTextarea.value.slice(offset, offset + diff);
+ }
+ this.insertChars(charsToInsert);
+ e.stopPropagation();
+ },
+
+ /**
+ * Composition start
+ */
+ onCompositionStart: function() {
+ this.inCompositionMode = true;
+ this.prevCompositionLength = 0;
+ this.compositionStart = this.selectionStart;
+ },
+
+ /**
+ * Composition end
+ */
+ onCompositionEnd: function() {
+ this.inCompositionMode = false;
+ },
+
+ /**
+ * Composition update
+ */
+ onCompositionUpdate: function(e) {
+ var data = e.data;
+ this.selectionStart = this.compositionStart;
+ this.selectionEnd = this.selectionEnd === this.selectionStart ?
+ this.compositionStart + this.prevCompositionLength : this.selectionEnd;
+ this.insertChars(data, false);
+ this.prevCompositionLength = data.length;
+ },
+
+ /**
+ * Forward delete
+ */
+ forwardDelete: function(e) {
+ if (this.selectionStart === this.selectionEnd) {
+ if (this.selectionStart === this.text.length) {
+ return;
+ }
+ this.moveCursorRight(e);
+ }
+ this.removeChars(e);
+ },
+
+ /**
+ * Copies selected text
+ * @param {Event} e Event object
+ */
+ copy: function(e) {
+ if (this.selectionStart === this.selectionEnd) {
+ //do not cut-copy if no selection
+ return;
+ }
+ var selectedText = this.getSelectedText(),
+ clipboardData = this._getClipboardData(e);
+
+ // Check for backward compatibility with old browsers
+ if (clipboardData) {
+ clipboardData.setData('text', selectedText);
+ }
+
+ fabric.copiedText = selectedText;
+ fabric.copiedTextStyle = this.getSelectionStyles(
+ this.selectionStart,
+ this.selectionEnd);
+ e.stopImmediatePropagation();
+ e.preventDefault();
+ this._copyDone = true;
+ },
+
+ /**
+ * Pastes text
+ * @param {Event} e Event object
+ */
+ paste: function(e) {
+ var copiedText = null,
+ clipboardData = this._getClipboardData(e),
+ useCopiedStyle = true;
+
+ // Check for backward compatibility with old browsers
+ if (clipboardData) {
+ copiedText = clipboardData.getData('text').replace(/\r/g, '');
+ if (!fabric.copiedTextStyle || fabric.copiedText !== copiedText) {
+ useCopiedStyle = false;
+ }
+ }
+ else {
+ copiedText = fabric.copiedText;
+ }
+
+ if (copiedText) {
+ this.insertChars(copiedText, useCopiedStyle);
+ }
+ e.stopImmediatePropagation();
+ e.preventDefault();
+ },
+
+ /**
+ * Cuts text
+ * @param {Event} e Event object
+ */
+ cut: function(e) {
+ if (this.selectionStart === this.selectionEnd) {
+ return;
+ }
+
+ this.copy(e);
+ this.removeChars(e);
+ },
+
+ /**
+ * @private
+ * @param {Event} e Event object
+ * @return {Object} Clipboard data object
+ */
+ _getClipboardData: function(e) {
+ return (e && e.clipboardData) || fabric.window.clipboardData;
+ },
+
+ /**
+ * Finds the width in pixels before the cursor on the same line
+ * @private
+ * @param {Number} lineIndex
+ * @param {Number} charIndex
+ * @return {Number} widthBeforeCursor width before cursor
+ */
+ _getWidthBeforeCursor: function(lineIndex, charIndex) {
+ var textBeforeCursor = this._textLines[lineIndex].slice(0, charIndex),
+ widthOfLine = this._getLineWidth(this.ctx, lineIndex),
+ widthBeforeCursor = this._getLineLeftOffset(widthOfLine), _char;
+
+ for (var i = 0, len = textBeforeCursor.length; i < len; i++) {
+ _char = textBeforeCursor[i];
+ widthBeforeCursor += this._getWidthOfChar(this.ctx, _char, lineIndex, i);
+ }
+ return widthBeforeCursor;
+ },
+
+ /**
+ * Gets start offset of a selection
+ * @param {Event} e Event object
+ * @param {Boolean} isRight
+ * @return {Number}
+ */
+ getDownCursorOffset: function(e, isRight) {
+ var selectionProp = this._getSelectionForOffset(e, isRight),
+ cursorLocation = this.get2DCursorLocation(selectionProp),
+ lineIndex = cursorLocation.lineIndex;
+ // if on last line, down cursor goes to end of line
+ if (lineIndex === this._textLines.length - 1 || e.metaKey || e.keyCode === 34) {
+ // move to the end of a text
+ return this.text.length - selectionProp;
+ }
+ var charIndex = cursorLocation.charIndex,
+ widthBeforeCursor = this._getWidthBeforeCursor(lineIndex, charIndex),
+ indexOnOtherLine = this._getIndexOnLine(lineIndex + 1, widthBeforeCursor),
+ textAfterCursor = this._textLines[lineIndex].slice(charIndex);
+
+ return textAfterCursor.length + indexOnOtherLine + 2;
+ },
+
+ /**
+ * private
+ * Helps finding if the offset should be counted from Start or End
+ * @param {Event} e Event object
+ * @param {Boolean} isRight
+ * @return {Number}
+ */
+ _getSelectionForOffset: function(e, isRight) {
+ if (e.shiftKey && this.selectionStart !== this.selectionEnd && isRight) {
+ return this.selectionEnd;
+ }
+ else {
+ return this.selectionStart;
+ }
+ },
+
+ /**
+ * @param {Event} e Event object
+ * @param {Boolean} isRight
+ * @return {Number}
+ */
+ getUpCursorOffset: function(e, isRight) {
+ var selectionProp = this._getSelectionForOffset(e, isRight),
+ cursorLocation = this.get2DCursorLocation(selectionProp),
+ lineIndex = cursorLocation.lineIndex;
+ if (lineIndex === 0 || e.metaKey || e.keyCode === 33) {
+ // if on first line, up cursor goes to start of line
+ return -selectionProp;
+ }
+ var charIndex = cursorLocation.charIndex,
+ widthBeforeCursor = this._getWidthBeforeCursor(lineIndex, charIndex),
+ indexOnOtherLine = this._getIndexOnLine(lineIndex - 1, widthBeforeCursor),
+ textBeforeCursor = this._textLines[lineIndex].slice(0, charIndex);
+ // return a negative offset
+ return -this._textLines[lineIndex - 1].length + indexOnOtherLine - textBeforeCursor.length;
+ },
+
+ /**
+ * find for a given width it founds the matching character.
+ * @private
+ */
+ _getIndexOnLine: function(lineIndex, width) {
+
+ var widthOfLine = this._getLineWidth(this.ctx, lineIndex),
+ textOnLine = this._textLines[lineIndex],
+ lineLeftOffset = this._getLineLeftOffset(widthOfLine),
+ widthOfCharsOnLine = lineLeftOffset,
+ indexOnLine = 0,
+ foundMatch;
+
+ for (var j = 0, jlen = textOnLine.length; j < jlen; j++) {
+
+ var _char = textOnLine[j],
+ widthOfChar = this._getWidthOfChar(this.ctx, _char, lineIndex, j);
+
+ widthOfCharsOnLine += widthOfChar;
+
+ if (widthOfCharsOnLine > width) {
+
+ foundMatch = true;
+
+ var leftEdge = widthOfCharsOnLine - widthOfChar,
+ rightEdge = widthOfCharsOnLine,
+ offsetFromLeftEdge = Math.abs(leftEdge - width),
+ offsetFromRightEdge = Math.abs(rightEdge - width);
+
+ indexOnLine = offsetFromRightEdge < offsetFromLeftEdge ? j : (j - 1);
+
+ break;
+ }
+ }
+
+ // reached end
+ if (!foundMatch) {
+ indexOnLine = textOnLine.length - 1;
+ }
+
+ return indexOnLine;
+ },
+
+
+ /**
+ * Moves cursor down
+ * @param {Event} e Event object
+ */
+ moveCursorDown: function(e) {
+ if (this.selectionStart >= this.text.length && this.selectionEnd >= this.text.length) {
+ return;
+ }
+ this._moveCursorUpOrDown('Down', e);
+ },
+
+ /**
+ * Moves cursor up
+ * @param {Event} e Event object
+ */
+ moveCursorUp: function(e) {
+ if (this.selectionStart === 0 && this.selectionEnd === 0) {
+ return;
+ }
+ this._moveCursorUpOrDown('Up', e);
+ },
+
+ /**
+ * Moves cursor up or down, fires the events
+ * @param {String} direction 'Up' or 'Down'
+ * @param {Event} e Event object
+ */
+ _moveCursorUpOrDown: function(direction, e) {
+ // getUpCursorOffset
+ // getDownCursorOffset
+ var action = 'get' + direction + 'CursorOffset',
+ offset = this[action](e, this._selectionDirection === 'right');
+ if (e.shiftKey) {
+ this.moveCursorWithShift(offset);
+ }
+ else {
+ this.moveCursorWithoutShift(offset);
+ }
+ if (offset !== 0) {
+ this.setSelectionInBoundaries();
+ this.abortCursorAnimation();
+ this._currentCursorOpacity = 1;
+ this.initDelayedCursor();
+ this._fireSelectionChanged();
+ this._updateTextarea();
+ }
+ },
+
+ /**
+ * Moves cursor with shift
+ * @param {Number} offset
+ */
+ moveCursorWithShift: function(offset) {
+ var newSelection = this._selectionDirection === 'left'
+ ? this.selectionStart + offset
+ : this.selectionEnd + offset;
+ this.setSelectionStartEndWithShift(this.selectionStart, this.selectionEnd, newSelection);
+ return offset !== 0;
+ },
+
+ /**
+ * Moves cursor up without shift
+ * @param {Number} offset
+ */
+ moveCursorWithoutShift: function(offset) {
+ if (offset < 0) {
+ this.selectionStart += offset;
+ this.selectionEnd = this.selectionStart;
+ }
+ else {
+ this.selectionEnd += offset;
+ this.selectionStart = this.selectionEnd;
+ }
+ return offset !== 0;
+ },
+
+ /**
+ * Moves cursor left
+ * @param {Event} e Event object
+ */
+ moveCursorLeft: function(e) {
+ if (this.selectionStart === 0 && this.selectionEnd === 0) {
+ return;
+ }
+ this._moveCursorLeftOrRight('Left', e);
+ },
+
+ /**
+ * @private
+ * @return {Boolean} true if a change happened
+ */
+ _move: function(e, prop, direction) {
+ var newValue;
+ if (e.altKey) {
+ newValue = this['findWordBoundary' + direction](this[prop]);
+ }
+ else if (e.metaKey || e.keyCode === 35 || e.keyCode === 36 ) {
+ newValue = this['findLineBoundary' + direction](this[prop]);
+ }
+ else {
+ this[prop] += direction === 'Left' ? -1 : 1;
+ return true;
+ }
+ if (typeof newValue !== undefined && this[prop] !== newValue) {
+ this[prop] = newValue;
+ return true;
+ }
+ },
+
+ /**
+ * @private
+ */
+ _moveLeft: function(e, prop) {
+ return this._move(e, prop, 'Left');
+ },
+
+ /**
+ * @private
+ */
+ _moveRight: function(e, prop) {
+ return this._move(e, prop, 'Right');
+ },
+
+ /**
+ * Moves cursor left without keeping selection
+ * @param {Event} e
+ */
+ moveCursorLeftWithoutShift: function(e) {
+ var change = true;
+ this._selectionDirection = 'left';
+
+ // only move cursor when there is no selection,
+ // otherwise we discard it, and leave cursor on same place
+ if (this.selectionEnd === this.selectionStart && this.selectionStart !== 0) {
+ change = this._moveLeft(e, 'selectionStart');
+
+ }
+ this.selectionEnd = this.selectionStart;
+ return change;
+ },
+
+ /**
+ * Moves cursor left while keeping selection
+ * @param {Event} e
+ */
+ moveCursorLeftWithShift: function(e) {
+ if (this._selectionDirection === 'right' && this.selectionStart !== this.selectionEnd) {
+ return this._moveLeft(e, 'selectionEnd');
+ }
+ else if (this.selectionStart !== 0){
+ this._selectionDirection = 'left';
+ return this._moveLeft(e, 'selectionStart');
+ }
+ },
+
+ /**
+ * Moves cursor right
+ * @param {Event} e Event object
+ */
+ moveCursorRight: function(e) {
+ if (this.selectionStart >= this.text.length && this.selectionEnd >= this.text.length) {
+ return;
+ }
+ this._moveCursorLeftOrRight('Right', e);
+ },
+
+ /**
+ * Moves cursor right or Left, fires event
+ * @param {String} direction 'Left', 'Right'
+ * @param {Event} e Event object
+ */
+ _moveCursorLeftOrRight: function(direction, e) {
+ var actionName = 'moveCursor' + direction + 'With';
+ this._currentCursorOpacity = 1;
+
+ if (e.shiftKey) {
+ actionName += 'Shift';
+ }
+ else {
+ actionName += 'outShift';
+ }
+ if (this[actionName](e)) {
+ this.abortCursorAnimation();
+ this.initDelayedCursor();
+ this._fireSelectionChanged();
+ this._updateTextarea();
+ }
+ },
+
+ /**
+ * Moves cursor right while keeping selection
+ * @param {Event} e
+ */
+ moveCursorRightWithShift: function(e) {
+ if (this._selectionDirection === 'left' && this.selectionStart !== this.selectionEnd) {
+ return this._moveRight(e, 'selectionStart');
+ }
+ else if (this.selectionEnd !== this.text.length) {
+ this._selectionDirection = 'right';
+ return this._moveRight(e, 'selectionEnd');
+ }
+ },
+
+ /**
+ * Moves cursor right without keeping selection
+ * @param {Event} e Event object
+ */
+ moveCursorRightWithoutShift: function(e) {
+ var changed = true;
+ this._selectionDirection = 'right';
+
+ if (this.selectionStart === this.selectionEnd) {
+ changed = this._moveRight(e, 'selectionStart');
+ this.selectionEnd = this.selectionStart;
+ }
+ else {
+ this.selectionStart = this.selectionEnd;
+ }
+ return changed;
+ },
+
+ /**
+ * Removes characters selected by selection
+ * @param {Event} e Event object
+ */
+ removeChars: function(e) {
+ if (this.selectionStart === this.selectionEnd) {
+ this._removeCharsNearCursor(e);
+ }
+ else {
+ this._removeCharsFromTo(this.selectionStart, this.selectionEnd);
+ }
+
+ this.setSelectionEnd(this.selectionStart);
+
+ this._removeExtraneousStyles();
+
+ this.canvas && this.canvas.renderAll();
+
+ this.setCoords();
+ this.fire('changed');
+ this.canvas && this.canvas.fire('text:changed', { target: this });
+ },
+
+ /**
+ * @private
+ * @param {Event} e Event object
+ */
+ _removeCharsNearCursor: function(e) {
+ if (this.selectionStart === 0) {
+ return;
+ }
+ if (e.metaKey) {
+ // remove all till the start of current line
+ var leftLineBoundary = this.findLineBoundaryLeft(this.selectionStart);
+
+ this._removeCharsFromTo(leftLineBoundary, this.selectionStart);
+ this.setSelectionStart(leftLineBoundary);
+ }
+ else if (e.altKey) {
+ // remove all till the start of current word
+ var leftWordBoundary = this.findWordBoundaryLeft(this.selectionStart);
+
+ this._removeCharsFromTo(leftWordBoundary, this.selectionStart);
+ this.setSelectionStart(leftWordBoundary);
+ }
+ else {
+ this._removeSingleCharAndStyle(this.selectionStart);
+ this.setSelectionStart(this.selectionStart - 1);
+ }
+ }
+});
+
+
+/* _TO_SVG_START_ */
+(function() {
+ var toFixed = fabric.util.toFixed,
+ NUM_FRACTION_DIGITS = fabric.Object.NUM_FRACTION_DIGITS;
+
+ fabric.util.object.extend(fabric.IText.prototype, /** @lends fabric.IText.prototype */ {
+
+ /**
+ * @private
+ */
+ _setSVGTextLineText: function(lineIndex, textSpans, height, textLeftOffset, textTopOffset, textBgRects) {
+ if (!this._getLineStyle(lineIndex)) {
+ fabric.Text.prototype._setSVGTextLineText.call(this,
+ lineIndex, textSpans, height, textLeftOffset, textTopOffset);
+ }
+ else {
+ this._setSVGTextLineChars(
+ lineIndex, textSpans, height, textLeftOffset, textBgRects);
+ }
+ },
+
+ /**
+ * @private
+ */
+ _setSVGTextLineChars: function(lineIndex, textSpans, height, textLeftOffset, textBgRects) {
+
+ var chars = this._textLines[lineIndex],
+ charOffset = 0,
+ lineLeftOffset = this._getLineLeftOffset(this._getLineWidth(this.ctx, lineIndex)) - this.width / 2,
+ lineOffset = this._getSVGLineTopOffset(lineIndex),
+ heightOfLine = this._getHeightOfLine(this.ctx, lineIndex);
+
+ for (var i = 0, len = chars.length; i < len; i++) {
+ var styleDecl = this._getStyleDeclaration(lineIndex, i) || { };
+
+ textSpans.push(
+ this._createTextCharSpan(
+ chars[i], styleDecl, lineLeftOffset, lineOffset.lineTop + lineOffset.offset, charOffset));
+
+ var charWidth = this._getWidthOfChar(this.ctx, chars[i], lineIndex, i);
+
+ if (styleDecl.textBackgroundColor) {
+ textBgRects.push(
+ this._createTextCharBg(
+ styleDecl, lineLeftOffset, lineOffset.lineTop, heightOfLine, charWidth, charOffset));
+ }
+
+ charOffset += charWidth;
+ }
+ },
+
+ /**
+ * @private
+ */
+ _getSVGLineTopOffset: function(lineIndex) {
+ var lineTopOffset = 0, lastHeight = 0;
+ for (var j = 0; j < lineIndex; j++) {
+ lineTopOffset += this._getHeightOfLine(this.ctx, j);
+ }
+ lastHeight = this._getHeightOfLine(this.ctx, j);
+ return {
+ lineTop: lineTopOffset,
+ offset: (this._fontSizeMult - this._fontSizeFraction) * lastHeight / (this.lineHeight * this._fontSizeMult)
+ };
+ },
+
+ /**
+ * @private
+ */
+ _createTextCharBg: function(styleDecl, lineLeftOffset, lineTopOffset, heightOfLine, charWidth, charOffset) {
+ return [
+ '\t\t\n'
+ ].join('');
+ },
+
+ /**
+ * @private
+ */
+ _createTextCharSpan: function(_char, styleDecl, lineLeftOffset, lineTopOffset, charOffset) {
+
+ var fillStyles = this.getSvgStyles.call(fabric.util.object.extend({
+ visible: true,
+ fill: this.fill,
+ stroke: this.stroke,
+ type: 'text',
+ getSvgFilter: fabric.Object.prototype.getSvgFilter
+ }, styleDecl));
+
+ return [
+ '\t\t\t',
+ fabric.util.string.escapeXml(_char),
+ '\n'
+ ].join('');
+ }
+ });
+})();
+/* _TO_SVG_END_ */
+
+
+(function(global) {
+
+ 'use strict';
+
+ var fabric = global.fabric || (global.fabric = {}),
+ clone = fabric.util.object.clone;
+
+ /**
+ * Textbox class, based on IText, allows the user to resize the text rectangle
+ * and wraps lines automatically. Textboxes have their Y scaling locked, the
+ * user can only change width. Height is adjusted automatically based on the
+ * wrapping of lines.
+ * @class fabric.Textbox
+ * @extends fabric.IText
+ * @mixes fabric.Observable
+ * @return {fabric.Textbox} thisArg
+ * @see {@link fabric.Textbox#initialize} for constructor definition
+ */
+ fabric.Textbox = fabric.util.createClass(fabric.IText, fabric.Observable, {
+
+ /**
+ * Type of an object
+ * @type String
+ * @default
+ */
+ type: 'textbox',
+
+ /**
+ * Minimum width of textbox, in pixels.
+ * @type Number
+ * @default
+ */
+ minWidth: 20,
+
+ /**
+ * Minimum calculated width of a textbox, in pixels.
+ * fixed to 2 so that an empty textbox cannot go to 0
+ * and is still selectable without text.
+ * @type Number
+ * @default
+ */
+ dynamicMinWidth: 2,
+
+ /**
+ * Cached array of text wrapping.
+ * @type Array
+ */
+ __cachedLines: null,
+
+ /**
+ * Override standard Object class values
+ */
+ lockScalingY: true,
+
+ /**
+ * Override standard Object class values
+ */
+ lockScalingFlip: true,
+
+ /**
+ * Constructor. Some scaling related property values are forced. Visibility
+ * of controls is also fixed; only the rotation and width controls are
+ * made available.
+ * @param {String} text Text string
+ * @param {Object} [options] Options object
+ * @return {fabric.Textbox} thisArg
+ */
+ initialize: function(text, options) {
+ this.ctx = fabric.util.createCanvasElement().getContext('2d');
+ this.callSuper('initialize', text, options);
+ this.setControlsVisibility(fabric.Textbox.getTextboxControlVisibility());
+
+ // add width to this list of props that effect line wrapping.
+ this._dimensionAffectingProps.width = true;
+ },
+
+ /**
+ * Unlike superclass's version of this function, Textbox does not update
+ * its width.
+ * @param {CanvasRenderingContext2D} ctx Context to use for measurements
+ * @private
+ * @override
+ */
+ _initDimensions: function(ctx) {
+ if (this.__skipDimension) {
+ return;
+ }
+
+ if (!ctx) {
+ ctx = fabric.util.createCanvasElement().getContext('2d');
+ this._setTextStyles(ctx);
+ }
+
+ // clear dynamicMinWidth as it will be different after we re-wrap line
+ this.dynamicMinWidth = 0;
+
+ // wrap lines
+ this._textLines = this._splitTextIntoLines();
+ // if after wrapping, the width is smaller than dynamicMinWidth, change the width and re-wrap
+ if (this.dynamicMinWidth > this.width) {
+ this._set('width', this.dynamicMinWidth);
+ }
+
+ // clear cache and re-calculate height
+ this._clearCache();
+ this.height = this._getTextHeight(ctx);
+ },
+
+ /**
+ * Generate an object that translates the style object so that it is
+ * broken up by visual lines (new lines and automatic wrapping).
+ * The original text styles object is broken up by actual lines (new lines only),
+ * which is only sufficient for Text / IText
+ * @private
+ */
+ _generateStyleMap: function() {
+ var realLineCount = 0,
+ realLineCharCount = 0,
+ charCount = 0,
+ map = {};
+
+ for (var i = 0; i < this._textLines.length; i++) {
+ if (this.text[charCount] === '\n' && i > 0) {
+ realLineCharCount = 0;
+ charCount++;
+ realLineCount++;
+ }
+ else if (this.text[charCount] === ' ' && i > 0) {
+ // this case deals with space's that are removed from end of lines when wrapping
+ realLineCharCount++;
+ charCount++;
+ }
+
+ map[i] = { line: realLineCount, offset: realLineCharCount };
+
+ charCount += this._textLines[i].length;
+ realLineCharCount += this._textLines[i].length;
+ }
+
+ return map;
+ },
+
+ /**
+ * @param {Number} lineIndex
+ * @param {Number} charIndex
+ * @param {Boolean} [returnCloneOrEmpty=false]
+ * @private
+ */
+ _getStyleDeclaration: function(lineIndex, charIndex, returnCloneOrEmpty) {
+ if (this._styleMap) {
+ var map = this._styleMap[lineIndex];
+ if (!map) {
+ return returnCloneOrEmpty ? { } : null;
+ }
+ lineIndex = map.line;
+ charIndex = map.offset + charIndex;
+ }
+ return this.callSuper('_getStyleDeclaration', lineIndex, charIndex, returnCloneOrEmpty);
+ },
+
+ /**
+ * @param {Number} lineIndex
+ * @param {Number} charIndex
+ * @param {Object} style
+ * @private
+ */
+ _setStyleDeclaration: function(lineIndex, charIndex, style) {
+ var map = this._styleMap[lineIndex];
+ lineIndex = map.line;
+ charIndex = map.offset + charIndex;
+
+ this.styles[lineIndex][charIndex] = style;
+ },
+
+ /**
+ * @param {Number} lineIndex
+ * @param {Number} charIndex
+ * @private
+ */
+ _deleteStyleDeclaration: function(lineIndex, charIndex) {
+ var map = this._styleMap[lineIndex];
+ lineIndex = map.line;
+ charIndex = map.offset + charIndex;
+
+ delete this.styles[lineIndex][charIndex];
+ },
+
+ /**
+ * @param {Number} lineIndex
+ * @private
+ */
+ _getLineStyle: function(lineIndex) {
+ var map = this._styleMap[lineIndex];
+ return this.styles[map.line];
+ },
+
+ /**
+ * @param {Number} lineIndex
+ * @param {Object} style
+ * @private
+ */
+ _setLineStyle: function(lineIndex, style) {
+ var map = this._styleMap[lineIndex];
+ this.styles[map.line] = style;
+ },
+
+ /**
+ * @param {Number} lineIndex
+ * @private
+ */
+ _deleteLineStyle: function(lineIndex) {
+ var map = this._styleMap[lineIndex];
+ delete this.styles[map.line];
+ },
+
+ /**
+ * Wraps text using the 'width' property of Textbox. First this function
+ * splits text on newlines, so we preserve newlines entered by the user.
+ * Then it wraps each line using the width of the Textbox by calling
+ * _wrapLine().
+ * @param {CanvasRenderingContext2D} ctx Context to use for measurements
+ * @param {String} text The string of text that is split into lines
+ * @returns {Array} Array of lines
+ */
+ _wrapText: function(ctx, text) {
+ var lines = text.split(this._reNewline), wrapped = [], i;
+
+ for (i = 0; i < lines.length; i++) {
+ wrapped = wrapped.concat(this._wrapLine(ctx, lines[i], i));
+ }
+
+ return wrapped;
+ },
+
+ /**
+ * Helper function to measure a string of text, given its lineIndex and charIndex offset
+ *
+ * @param {CanvasRenderingContext2D} ctx
+ * @param {String} text
+ * @param {number} lineIndex
+ * @param {number} charOffset
+ * @returns {number}
+ * @private
+ */
+ _measureText: function(ctx, text, lineIndex, charOffset) {
+ var width = 0;
+ charOffset = charOffset || 0;
+ for (var i = 0, len = text.length; i < len; i++) {
+ width += this._getWidthOfChar(ctx, text[i], lineIndex, i + charOffset);
+ }
+ return width;
+ },
+
+ /**
+ * Wraps a line of text using the width of the Textbox and a context.
+ * @param {CanvasRenderingContext2D} ctx Context to use for measurements
+ * @param {String} text The string of text to split into lines
+ * @param {Number} lineIndex
+ * @returns {Array} Array of line(s) into which the given text is wrapped
+ * to.
+ */
+ _wrapLine: function(ctx, text, lineIndex) {
+ var lineWidth = 0,
+ lines = [],
+ line = '',
+ words = text.split(' '),
+ word = '',
+ offset = 0,
+ infix = ' ',
+ wordWidth = 0,
+ infixWidth = 0,
+ largestWordWidth = 0,
+ lineJustStarted = true,
+ additionalSpace = this._getWidthOfCharSpacing();
+
+ for (var i = 0; i < words.length; i++) {
+ word = words[i];
+ wordWidth = this._measureText(ctx, word, lineIndex, offset);
+
+ offset += word.length;
+
+ lineWidth += infixWidth + wordWidth - additionalSpace;
+
+ if (lineWidth >= this.width && !lineJustStarted) {
+ lines.push(line);
+ line = '';
+ lineWidth = wordWidth;
+ lineJustStarted = true;
+ }
+ else {
+ lineWidth += additionalSpace;
+ }
+
+ if (!lineJustStarted) {
+ line += infix;
+ }
+ line += word;
+
+ infixWidth = this._measureText(ctx, infix, lineIndex, offset);
+ offset++;
+ lineJustStarted = false;
+ // keep track of largest word
+ if (wordWidth > largestWordWidth) {
+ largestWordWidth = wordWidth;
+ }
+ }
+
+ i && lines.push(line);
+
+ if (largestWordWidth > this.dynamicMinWidth) {
+ this.dynamicMinWidth = largestWordWidth - additionalSpace;
+ }
+
+ return lines;
+ },
+ /**
+ * Gets lines of text to render in the Textbox. This function calculates
+ * text wrapping on the fly everytime it is called.
+ * @returns {Array} Array of lines in the Textbox.
+ * @override
+ */
+ _splitTextIntoLines: function() {
+ var originalAlign = this.textAlign;
+ this.ctx.save();
+ this._setTextStyles(this.ctx);
+ this.textAlign = 'left';
+ var lines = this._wrapText(this.ctx, this.text);
+ this.textAlign = originalAlign;
+ this.ctx.restore();
+ this._textLines = lines;
+ this._styleMap = this._generateStyleMap();
+ return lines;
+ },
+
+ /**
+ * When part of a group, we don't want the Textbox's scale to increase if
+ * the group's increases. That's why we reduce the scale of the Textbox by
+ * the amount that the group's increases. This is to maintain the effective
+ * scale of the Textbox at 1, so that font-size values make sense. Otherwise
+ * the same font-size value would result in different actual size depending
+ * on the value of the scale.
+ * @param {String} key
+ * @param {*} value
+ */
+ setOnGroup: function(key, value) {
+ if (key === 'scaleX') {
+ this.set('scaleX', Math.abs(1 / value));
+ this.set('width', (this.get('width') * value) /
+ (typeof this.__oldScaleX === 'undefined' ? 1 : this.__oldScaleX));
+ this.__oldScaleX = value;
+ }
+ },
+
+ /**
+ * Returns 2d representation (lineIndex and charIndex) of cursor (or selection start).
+ * Overrides the superclass function to take into account text wrapping.
+ *
+ * @param {Number} [selectionStart] Optional index. When not given, current selectionStart is used.
+ */
+ get2DCursorLocation: function(selectionStart) {
+ if (typeof selectionStart === 'undefined') {
+ selectionStart = this.selectionStart;
+ }
+
+ var numLines = this._textLines.length,
+ removed = 0;
+
+ for (var i = 0; i < numLines; i++) {
+ var line = this._textLines[i],
+ lineLen = line.length;
+
+ if (selectionStart <= removed + lineLen) {
+ return {
+ lineIndex: i,
+ charIndex: selectionStart - removed
+ };
+ }
+
+ removed += lineLen;
+
+ if (this.text[removed] === '\n' || this.text[removed] === ' ') {
+ removed++;
+ }
+ }
+
+ return {
+ lineIndex: numLines - 1,
+ charIndex: this._textLines[numLines - 1].length
+ };
+ },
+
+ /**
+ * Overrides superclass function and uses text wrapping data to get cursor
+ * boundary offsets instead of the array of chars.
+ * @param {Array} chars Unused
+ * @param {String} typeOfBoundaries Can be 'cursor' or 'selection'
+ * @returns {Object} Object with 'top', 'left', and 'lineLeft' properties set.
+ */
+ _getCursorBoundariesOffsets: function(chars, typeOfBoundaries) {
+ var topOffset = 0,
+ leftOffset = 0,
+ cursorLocation = this.get2DCursorLocation(),
+ lineChars = this._textLines[cursorLocation.lineIndex].split(''),
+ lineLeftOffset = this._getLineLeftOffset(this._getLineWidth(this.ctx, cursorLocation.lineIndex));
+
+ for (var i = 0; i < cursorLocation.charIndex; i++) {
+ leftOffset += this._getWidthOfChar(this.ctx, lineChars[i], cursorLocation.lineIndex, i);
+ }
+
+ for (i = 0; i < cursorLocation.lineIndex; i++) {
+ topOffset += this._getHeightOfLine(this.ctx, i);
+ }
+
+ if (typeOfBoundaries === 'cursor') {
+ topOffset += (1 - this._fontSizeFraction) * this._getHeightOfLine(this.ctx, cursorLocation.lineIndex)
+ / this.lineHeight - this.getCurrentCharFontSize(cursorLocation.lineIndex, cursorLocation.charIndex)
+ * (1 - this._fontSizeFraction);
+ }
+
+ return {
+ top: topOffset,
+ left: leftOffset,
+ lineLeft: lineLeftOffset
+ };
+ },
+
+ getMinWidth: function() {
+ return Math.max(this.minWidth, this.dynamicMinWidth);
+ },
+
+ /**
+ * Returns object representation of an instance
+ * @method toObject
+ * @param {Array} [propertiesToInclude] Any properties that you might want to additionally include in the output
+ * @return {Object} object representation of an instance
+ */
+ toObject: function(propertiesToInclude) {
+ return this.callSuper('toObject', ['minWidth'].concat(propertiesToInclude));
+ }
+ });
+ /**
+ * Returns fabric.Textbox instance from an object representation
+ * @static
+ * @memberOf fabric.Textbox
+ * @param {Object} object Object to create an instance from
+ * @param {Function} [callback] Callback to invoke when an fabric.Textbox instance is created
+ * @return {fabric.Textbox} instance of fabric.Textbox
+ */
+ fabric.Textbox.fromObject = function(object, callback) {
+ var textbox = new fabric.Textbox(object.text, clone(object));
+ callback && callback(textbox);
+ return textbox;
+ };
+ /**
+ * Returns the default controls visibility required for Textboxes.
+ * @returns {Object}
+ */
+ fabric.Textbox.getTextboxControlVisibility = function() {
+ return {
+ tl: false,
+ tr: false,
+ br: false,
+ bl: false,
+ ml: true,
+ mt: false,
+ mr: true,
+ mb: false,
+ mtr: true
+ };
+ };
+
+})(typeof exports !== 'undefined' ? exports : this);
+
+
+(function() {
+
+ /**
+ * Override _setObjectScale and add Textbox specific resizing behavior. Resizing
+ * a Textbox doesn't scale text, it only changes width and makes text wrap automatically.
+ */
+ var setObjectScaleOverridden = fabric.Canvas.prototype._setObjectScale;
+
+ fabric.Canvas.prototype._setObjectScale = function(localMouse, transform,
+ lockScalingX, lockScalingY, by, lockScalingFlip, _dim) {
+
+ var t = transform.target;
+ if (t instanceof fabric.Textbox) {
+ var w = t.width * ((localMouse.x / transform.scaleX) / (t.width + t.strokeWidth));
+ if (w >= t.getMinWidth()) {
+ t.set('width', w);
+ return true;
+ }
+ }
+ else {
+ return setObjectScaleOverridden.call(fabric.Canvas.prototype, localMouse, transform,
+ lockScalingX, lockScalingY, by, lockScalingFlip, _dim);
+ }
+ };
+
+ /**
+ * Sets controls of this group to the Textbox's special configuration if
+ * one is present in the group. Deletes _controlsVisibility otherwise, so that
+ * it gets initialized to default value at runtime.
+ */
+ fabric.Group.prototype._refreshControlsVisibility = function() {
+ if (typeof fabric.Textbox === 'undefined') {
+ return;
+ }
+ for (var i = this._objects.length; i--;) {
+ if (this._objects[i] instanceof fabric.Textbox) {
+ this.setControlsVisibility(fabric.Textbox.getTextboxControlVisibility());
+ return;
+ }
+ }
+ };
+
+ var clone = fabric.util.object.clone;
+
+ fabric.util.object.extend(fabric.Textbox.prototype, /** @lends fabric.IText.prototype */ {
+ /**
+ * @private
+ */
+ _removeExtraneousStyles: function() {
+ for (var prop in this._styleMap) {
+ if (!this._textLines[prop]) {
+ delete this.styles[this._styleMap[prop].line];
+ }
+ }
+ },
+
+ /**
+ * Inserts style object for a given line/char index
+ * @param {Number} lineIndex Index of a line
+ * @param {Number} charIndex Index of a char
+ * @param {Object} [style] Style object to insert, if given
+ */
+ insertCharStyleObject: function(lineIndex, charIndex, style) {
+ // adjust lineIndex and charIndex
+ var map = this._styleMap[lineIndex];
+ lineIndex = map.line;
+ charIndex = map.offset + charIndex;
+
+ fabric.IText.prototype.insertCharStyleObject.apply(this, [lineIndex, charIndex, style]);
+ },
+
+ /**
+ * Inserts new style object
+ * @param {Number} lineIndex Index of a line
+ * @param {Number} charIndex Index of a char
+ * @param {Boolean} isEndOfLine True if it's end of line
+ */
+ insertNewlineStyleObject: function(lineIndex, charIndex, isEndOfLine) {
+ // adjust lineIndex and charIndex
+ var map = this._styleMap[lineIndex];
+ lineIndex = map.line;
+ charIndex = map.offset + charIndex;
+
+ fabric.IText.prototype.insertNewlineStyleObject.apply(this, [lineIndex, charIndex, isEndOfLine]);
+ },
+
+ /**
+ * Shifts line styles up or down. This function is slightly different than the one in
+ * itext_behaviour as it takes into account the styleMap.
+ *
+ * @param {Number} lineIndex Index of a line
+ * @param {Number} offset Can be -1 or +1
+ */
+ shiftLineStyles: function(lineIndex, offset) {
+ // shift all line styles by 1 upward
+ var clonedStyles = clone(this.styles),
+ map = this._styleMap[lineIndex];
+
+ // adjust line index
+ lineIndex = map.line;
+
+ for (var line in this.styles) {
+ var numericLine = parseInt(line, 10);
+
+ if (numericLine > lineIndex) {
+ this.styles[numericLine + offset] = clonedStyles[numericLine];
+
+ if (!clonedStyles[numericLine - offset]) {
+ delete this.styles[numericLine];
+ }
+ }
+ }
+ //TODO: evaluate if delete old style lines with offset -1
+ },
+
+ /**
+ * Figure out programatically the text on previous actual line (actual = separated by \n);
+ *
+ * @param {Number} lIndex
+ * @returns {String}
+ * @private
+ */
+ _getTextOnPreviousLine: function(lIndex) {
+ var textOnPreviousLine = this._textLines[lIndex - 1];
+
+ while (this._styleMap[lIndex - 2] && this._styleMap[lIndex - 2].line === this._styleMap[lIndex - 1].line) {
+ textOnPreviousLine = this._textLines[lIndex - 2] + textOnPreviousLine;
+
+ lIndex--;
+ }
+
+ return textOnPreviousLine;
+ },
+
+ /**
+ * Removes style object
+ * @param {Boolean} isBeginningOfLine True if cursor is at the beginning of line
+ * @param {Number} [index] Optional index. When not given, current selectionStart is used.
+ */
+ removeStyleObject: function(isBeginningOfLine, index) {
+
+ var cursorLocation = this.get2DCursorLocation(index),
+ map = this._styleMap[cursorLocation.lineIndex],
+ lineIndex = map.line,
+ charIndex = map.offset + cursorLocation.charIndex;
+ this._removeStyleObject(isBeginningOfLine, cursorLocation, lineIndex, charIndex);
+ }
+ });
+})();
+
+
+(function() {
+ var override = fabric.IText.prototype._getNewSelectionStartFromOffset;
+ /**
+ * Overrides the IText implementation and adjusts character index as there is not always a linebreak
+ *
+ * @param {Number} mouseOffset
+ * @param {Number} prevWidth
+ * @param {Number} width
+ * @param {Number} index
+ * @param {Number} jlen
+ * @returns {Number}
+ */
+ fabric.IText.prototype._getNewSelectionStartFromOffset = function(mouseOffset, prevWidth, width, index, jlen) {
+ index = override.call(this, mouseOffset, prevWidth, width, index, jlen);
+
+ // the index passed into the function is padded by the amount of lines from _textLines (to account for \n)
+ // we need to remove this padding, and pad it by actual lines, and / or spaces that are meant to be there
+ var tmp = 0,
+ removed = 0;
+
+ // account for removed characters
+ for (var i = 0; i < this._textLines.length; i++) {
+ tmp += this._textLines[i].length;
+
+ if (tmp + removed >= index) {
+ break;
+ }
+
+ if (this.text[tmp + removed] === '\n' || this.text[tmp + removed] === ' ') {
+ removed++;
+ }
+ }
+
+ return index - i + removed;
+ };
+})();
+
+
+(function() {
+
+ if (typeof document !== 'undefined' && typeof window !== 'undefined') {
+ return;
+ }
+
+ var DOMParser = require('xmldom').DOMParser,
+ URL = require('url'),
+ HTTP = require('http'),
+ HTTPS = require('https'),
+
+ Canvas = require('canvas'),
+ Image = require('canvas').Image;
+
+ /** @private */
+ function request(url, encoding, callback) {
+ var oURL = URL.parse(url);
+
+ // detect if http or https is used
+ if ( !oURL.port ) {
+ oURL.port = ( oURL.protocol.indexOf('https:') === 0 ) ? 443 : 80;
+ }
+
+ // assign request handler based on protocol
+ var reqHandler = (oURL.protocol.indexOf('https:') === 0 ) ? HTTPS : HTTP,
+ req = reqHandler.request({
+ hostname: oURL.hostname,
+ port: oURL.port,
+ path: oURL.path,
+ method: 'GET'
+ }, function(response) {
+ var body = '';
+ if (encoding) {
+ response.setEncoding(encoding);
+ }
+ response.on('end', function () {
+ callback(body);
+ });
+ response.on('data', function (chunk) {
+ if (response.statusCode === 200) {
+ body += chunk;
+ }
+ });
+ });
+
+ req.on('error', function(err) {
+ if (err.errno === process.ECONNREFUSED) {
+ fabric.log('ECONNREFUSED: connection refused to ' + oURL.hostname + ':' + oURL.port);
+ }
+ else {
+ fabric.log(err.message);
+ }
+ callback(null);
+ });
+
+ req.end();
+ }
+
+ /** @private */
+ function requestFs(path, callback) {
+ var fs = require('fs');
+ fs.readFile(path, function (err, data) {
+ if (err) {
+ fabric.log(err);
+ throw err;
+ }
+ else {
+ callback(data);
+ }
+ });
+ }
+
+ fabric.util.loadImage = function(url, callback, context) {
+ function createImageAndCallBack(data) {
+ if (data) {
+ img.src = new Buffer(data, 'binary');
+ // preserving original url, which seems to be lost in node-canvas
+ img._src = url;
+ callback && callback.call(context, img);
+ }
+ else {
+ img = null;
+ callback && callback.call(context, null, true);
+ }
+ }
+ var img = new Image();
+ if (url && (url instanceof Buffer || url.indexOf('data') === 0)) {
+ img.src = img._src = url;
+ callback && callback.call(context, img);
+ }
+ else if (url && url.indexOf('http') !== 0) {
+ requestFs(url, createImageAndCallBack);
+ }
+ else if (url) {
+ request(url, 'binary', createImageAndCallBack);
+ }
+ else {
+ callback && callback.call(context, url);
+ }
+ };
+
+ fabric.loadSVGFromURL = function(url, callback, reviver) {
+ url = url.replace(/^\n\s*/, '').replace(/\?.*$/, '').trim();
+ if (url.indexOf('http') !== 0) {
+ requestFs(url, function(body) {
+ fabric.loadSVGFromString(body.toString(), callback, reviver);
+ });
+ }
+ else {
+ request(url, '', function(body) {
+ fabric.loadSVGFromString(body, callback, reviver);
+ });
+ }
+ };
+
+ fabric.loadSVGFromString = function(string, callback, reviver) {
+ var doc = new DOMParser().parseFromString(string);
+ fabric.parseSVGDocument(doc.documentElement, function(results, options) {
+ callback && callback(results, options);
+ }, reviver);
+ };
+
+ fabric.util.getScript = function(url, callback) {
+ request(url, '', function(body) {
+ // eslint-disable-next-line no-eval
+ eval(body);
+ callback && callback();
+ });
+ };
+
+ // fabric.util.createCanvasElement = function(_, width, height) {
+ // return new Canvas(width, height);
+ // }
+
+ /**
+ * Only available when running fabric on node.js
+ * @param {Number} width Canvas width
+ * @param {Number} height Canvas height
+ * @param {Object} [options] Options to pass to FabricCanvas.
+ * @param {Object} [nodeCanvasOptions] Options to pass to NodeCanvas.
+ * @return {Object} wrapped canvas instance
+ */
+ fabric.createCanvasForNode = function(width, height, options, nodeCanvasOptions) {
+ nodeCanvasOptions = nodeCanvasOptions || options;
+
+ var canvasEl = fabric.document.createElement('canvas'),
+ nodeCanvas = new Canvas(width || 600, height || 600, nodeCanvasOptions),
+ nodeCacheCanvas = new Canvas(width || 600, height || 600, nodeCanvasOptions);
+
+ // jsdom doesn't create style on canvas element, so here be temp. workaround
+ canvasEl.style = { };
+
+ canvasEl.width = nodeCanvas.width;
+ canvasEl.height = nodeCanvas.height;
+ options = options || { };
+ options.nodeCanvas = nodeCanvas;
+ options.nodeCacheCanvas = nodeCacheCanvas;
+ var FabricCanvas = fabric.Canvas || fabric.StaticCanvas,
+ fabricCanvas = new FabricCanvas(canvasEl, options);
+ fabricCanvas.nodeCanvas = nodeCanvas;
+ fabricCanvas.nodeCacheCanvas = nodeCacheCanvas;
+ fabricCanvas.contextContainer = nodeCanvas.getContext('2d');
+ fabricCanvas.contextCache = nodeCacheCanvas.getContext('2d');
+ fabricCanvas.Font = Canvas.Font;
+ return fabricCanvas;
+ };
+
+ var originaInitStatic = fabric.StaticCanvas.prototype._initStatic;
+ fabric.StaticCanvas.prototype._initStatic = function(el, options) {
+ el = el || fabric.document.createElement('canvas');
+ this.nodeCanvas = new Canvas(el.width, el.height);
+ this.nodeCacheCanvas = new Canvas(el.width, el.height);
+ originaInitStatic.call(this, el, options);
+ this.contextContainer = this.nodeCanvas.getContext('2d');
+ this.contextCache = this.nodeCacheCanvas.getContext('2d');
+ this.Font = Canvas.Font;
+ }
+
+ /** @ignore */
+ fabric.StaticCanvas.prototype.createPNGStream = function() {
+ return this.nodeCanvas.createPNGStream();
+ };
+
+ fabric.StaticCanvas.prototype.createJPEGStream = function(opts) {
+ return this.nodeCanvas.createJPEGStream(opts);
+ };
+
+ fabric.StaticCanvas.prototype._initRetinaScaling = function() {
+ if (!this._isRetinaScaling()) {
+ return;
+ }
+
+ this.lowerCanvasEl.setAttribute('width', this.width * fabric.devicePixelRatio);
+ this.lowerCanvasEl.setAttribute('height', this.height * fabric.devicePixelRatio);
+ this.nodeCanvas.width = this.width * fabric.devicePixelRatio;
+ this.nodeCanvas.height = this.height * fabric.devicePixelRatio;
+ this.contextContainer.scale(fabric.devicePixelRatio, fabric.devicePixelRatio);
+ return this;
+ };
+ if (fabric.Canvas) {
+ fabric.Canvas.prototype._initRetinaScaling = fabric.StaticCanvas.prototype._initRetinaScaling;
+ }
+
+ var origSetBackstoreDimension = fabric.StaticCanvas.prototype._setBackstoreDimension;
+ fabric.StaticCanvas.prototype._setBackstoreDimension = function(prop, value) {
+ origSetBackstoreDimension.call(this, prop, value);
+ this.nodeCanvas[prop] = value;
+ return this;
+ };
+ if (fabric.Canvas) {
+ fabric.Canvas.prototype._setBackstoreDimension = fabric.StaticCanvas.prototype._setBackstoreDimension;
+ }
+
+})();
+
diff --git a/web_widget_darkroom/static/src/js/plugins/darkroom.crop.js b/web_widget_darkroom/static/src/js/plugins/darkroom.crop.js
new file mode 100755
index 000000000000..0ae738f045e3
--- /dev/null
+++ b/web_widget_darkroom/static/src/js/plugins/darkroom.crop.js
@@ -0,0 +1,693 @@
+/**
+* Copyright 2013 Matthieu Moquet
+* Copyright 2016-2017 LasLabs Inc.
+* License MIT (https://opensource.org/licenses/MIT)
+**/
+
+odoo.define('web_widget_darkroom.darkroom_crop', function(){
+ 'use strict';
+
+ var DarkroomPluginCrop = function() {
+ var Crop = Darkroom.Transformation.extend({
+ applyTransformation: function(canvas, image, next) {
+ // Snapshot the image delimited by the crop zone
+ var snapshot = new Image();
+ snapshot.onload = function() {
+ var width = this.width;
+ var height = this.height;
+
+ // Validate image
+ if (height < 1 || width < 1) {
+ return;
+ }
+
+ var imgInstance = new fabric.Image(this, {
+ // Options to make the image static
+ selectable: false,
+ evented: false,
+ lockMovementX: true,
+ lockMovementY: true,
+ lockRotation: true,
+ lockScalingX: true,
+ lockScalingY: true,
+ lockUniScaling: true,
+ hasControls: false,
+ hasBorders: false,
+ });
+
+ // Update canvas size
+ canvas.setWidth(width);
+ canvas.setHeight(height);
+
+ // Add image
+ image.remove();
+ canvas.add(imgInstance);
+
+ next(imgInstance);
+ };
+
+ var viewport = Darkroom.Utils.computeImageViewPort(image);
+ var imageWidth = viewport.width;
+ var imageHeight = viewport.height;
+
+ var left = this.options.left * imageWidth;
+ var top = this.options.top * imageHeight;
+ var width = Math.min(this.options.width * imageWidth, imageWidth - left);
+ var height = Math.min(this.options.height * imageHeight, imageHeight - top);
+
+ snapshot.src = canvas.toDataURL({
+ left: left,
+ top: top,
+ width: width,
+ height: height,
+ });
+ },
+ });
+
+ var CropZone = fabric.util.createClass(fabric.Rect, {
+ _render: function(ctx) {
+ this.callSuper('_render', ctx);
+ var dashWidth = 7;
+
+ // Set original scale
+ var flipX = this.flipX ? -1 : 1;
+ var flipY = this.flipY ? -1 : 1;
+ var scaleX = flipX / this.scaleX;
+ var scaleY = flipY / this.scaleY;
+ ctx.scale(scaleX, scaleY);
+
+ // Overlay rendering
+ ctx.fillStyle = 'rgba(0, 0, 0, 0.5)';
+ this._renderOverlay(ctx);
+
+ // Set dashed borders
+ if (typeof ctx.setLineDash !== 'undefined') {
+ ctx.setLineDash([dashWidth, dashWidth]);
+ } else if (typeof ctx.mozDash !== 'undefined') {
+ ctx.mozDash = [dashWidth, dashWidth];
+ }
+
+ // First lines rendering with black
+ ctx.strokeStyle = 'rgba(0, 0, 0, 0.2)';
+ this._renderBorders(ctx);
+ this._renderGrid(ctx);
+
+ // Re render lines in white
+ ctx.lineDashOffset = dashWidth;
+ ctx.strokeStyle = 'rgba(255, 255, 255, 0.4)';
+ this._renderBorders(ctx);
+ this._renderGrid(ctx);
+
+ // Reset scale
+ ctx.scale(1/scaleX, 1/scaleY);
+ },
+
+ _renderOverlay: function(ctx) {
+ var canvas = ctx.canvas;
+ var borderOffset = 0;
+
+ //
+ // x0 x1 x2 x3
+ // y0 +------------------------+
+ // |\\\\\\\\\\\\\\\\\\\\\\\\|
+ // |\\\\\\\\\\\\\\\\\\\\\\\\|
+ // y1 +------+---------+-------+
+ // |\\\\\\| |\\\\\\\|
+ // |\\\\\\| 0 |\\\\\\\|
+ // |\\\\\\| |\\\\\\\|
+ // y2 +------+---------+-------+
+ // |\\\\\\\\\\\\\\\\\\\\\\\\|
+ // |\\\\\\\\\\\\\\\\\\\\\\\\|
+ // y3 +------------------------+
+ //
+
+ var x0 = Math.ceil(-this.getWidth() / 2 - this.getLeft());
+ var x1 = Math.ceil(-this.getWidth() / 2);
+ var x2 = Math.ceil(this.getWidth() / 2);
+ var x3 = Math.ceil(this.getWidth() / 2 + (canvas.width - this.getWidth() - this.getLeft()));
+
+ var y0 = Math.ceil(-this.getHeight() / 2 - this.getTop());
+ var y1 = Math.ceil(-this.getHeight() / 2);
+ var y2 = Math.ceil(this.getHeight() / 2);
+ var y3 = Math.ceil(this.getHeight() / 2 + (canvas.height - this.getHeight() - this.getTop()));
+
+ // Upper rect
+ ctx.fillRect(x0, y0, x3 - x0, y1 - y0 + borderOffset);
+
+ // Left rect
+ ctx.fillRect(x0, y1, x1 - x0, y2 - y1 + borderOffset);
+
+ // Right rect
+ ctx.fillRect(x2, y1, x3 - x2, y2 - y1 + borderOffset);
+
+ // Down rect
+ ctx.fillRect(x0, y2, x3 - x0, y3 - y2);
+ },
+
+ _renderBorders: function(ctx) {
+ ctx.beginPath();
+ // upper left
+ ctx.moveTo(-this.getWidth()/2, -this.getHeight()/2);
+ // upper right
+ ctx.lineTo(this.getWidth()/2, -this.getHeight()/2);
+ // down right
+ ctx.lineTo(this.getWidth()/2, this.getHeight()/2);
+ // down left
+ ctx.lineTo(-this.getWidth()/2, this.getHeight()/2);
+ // upper left
+ ctx.lineTo(-this.getWidth()/2, -this.getHeight()/2);
+ ctx.stroke();
+ },
+
+ _renderGrid: function(ctx) {
+ // Vertical lines
+ ctx.beginPath();
+ ctx.moveTo(-this.getWidth()/2 + 1/3 * this.getWidth(), -this.getHeight()/2);
+ ctx.lineTo(-this.getWidth()/2 + 1/3 * this.getWidth(), this.getHeight()/2);
+ ctx.stroke();
+ ctx.beginPath();
+ ctx.moveTo(-this.getWidth()/2 + 2/3 * this.getWidth(), -this.getHeight()/2);
+ ctx.lineTo(-this.getWidth()/2 + 2/3 * this.getWidth(), this.getHeight()/2);
+ ctx.stroke();
+
+ // Horizontal lines
+ ctx.beginPath();
+ ctx.moveTo(-this.getWidth()/2, -this.getHeight()/2 + 1/3 * this.getHeight());
+ ctx.lineTo(this.getWidth()/2, -this.getHeight()/2 + 1/3 * this.getHeight());
+ ctx.stroke();
+ ctx.beginPath();
+ ctx.moveTo(-this.getWidth()/2, -this.getHeight()/2 + 2/3 * this.getHeight());
+ ctx.lineTo(this.getWidth()/2, -this.getHeight()/2 + 2/3 * this.getHeight());
+ ctx.stroke();
+ },
+ });
+
+ Darkroom.plugins.crop = Darkroom.Plugin.extend({
+ // Init point
+ startX: null,
+ startY: null,
+
+ // Keycrop
+ isKeyCroping: false,
+ isKeyLeft: false,
+ isKeyUp: false,
+
+ defaults: {
+ // Min crop dimensions
+ minHeight: 1,
+ minWidth: 1,
+ // Ensure crop ratio
+ ratio: null,
+ // Quick crop feature (set a key code to enable it)
+ quickCropKey: false,
+ },
+
+ initialize: function InitDarkroomCropPlugin() {
+ var buttonGroup = this.darkroom.toolbar.createButtonGroup();
+
+ this.cropButton = buttonGroup.createButton({
+ image: 'fa fa-crop',
+ editOnly: true,
+ });
+ this.okButton = buttonGroup.createButton({
+ image: 'fa fa-check',
+ editOnly: true,
+ type: 'success',
+ hide: true
+ });
+ this.cancelButton = buttonGroup.createButton({
+ image: 'fa fa-times',
+ editOnly: true,
+ type: 'danger',
+ hide: true
+ });
+
+ // Button click events
+ this.cropButton.addEventListener('click', this.toggleCrop.bind(this));
+ this.okButton.addEventListener('click', this.cropCurrentZone.bind(this));
+ this.cancelButton.addEventListener('click', this.releaseFocus.bind(this));
+
+ // Canvas events
+ this.darkroom.canvas.on('mouse:down', this.onMouseDown.bind(this));
+ this.darkroom.canvas.on('mouse:move', this.onMouseMove.bind(this));
+ this.darkroom.canvas.on('mouse:up', this.onMouseUp.bind(this));
+ this.darkroom.canvas.on('object:moving', this.onObjectMoving.bind(this));
+ this.darkroom.canvas.on('object:scaling', this.onObjectScaling.bind(this));
+
+ fabric.util.addListener(fabric.document, 'keydown', this.onKeyDown.bind(this));
+ fabric.util.addListener(fabric.document, 'keyup', this.onKeyUp.bind(this));
+
+ this.darkroom.addEventListener('core:transformation', this.releaseFocus.bind(this));
+ },
+
+ // Avoid crop zone to go beyond the canvas edges
+ onObjectMoving: function(event) {
+ if (!this.hasFocus()) {
+ return;
+ }
+
+ var currentObject = event.target;
+ if (currentObject !== this.cropZone) {
+ return;
+ }
+
+ var canvas = this.darkroom.canvas;
+ var x = currentObject.getLeft(), y = currentObject.getTop();
+ var w = currentObject.getWidth(), h = currentObject.getHeight();
+ var maxX = canvas.getWidth() - w;
+ var maxY = canvas.getHeight() - h;
+
+ if (x < 0) {
+ currentObject.set('left', 0);
+ }
+ if (y < 0) {
+ currentObject.set('top', 0);
+ }
+ if (x > maxX) {
+ currentObject.set('left', maxX);
+ }
+ if (y > maxY) {
+ currentObject.set('top', maxY);
+ }
+
+ this.darkroom.dispatchEvent('crop:update');
+ },
+
+ // Prevent crop zone from going beyond the canvas edges (like mouseMove)
+ onObjectScaling: function(event) {
+ if (!this.hasFocus()) {
+ return;
+ }
+
+ var preventScaling = false;
+ var currentObject = event.target;
+ if (currentObject !== this.cropZone) {
+ return;
+ }
+
+ var canvas = this.darkroom.canvas;
+
+ var minX = currentObject.getLeft();
+ var minY = currentObject.getTop();
+ var maxX = currentObject.getLeft() + currentObject.getWidth();
+ var maxY = currentObject.getTop() + currentObject.getHeight();
+
+ if (this.options.ratio !== null) {
+ if (minX < 0 || maxX > canvas.getWidth() || minY < 0 || maxY > canvas.getHeight()) {
+ preventScaling = true;
+ }
+ }
+
+ if (minX < 0 || maxX > canvas.getWidth() || preventScaling) {
+ var lastScaleX = this.lastScaleX || 1;
+ currentObject.setScaleX(lastScaleX);
+ }
+ if (minX < 0) {
+ currentObject.setLeft(0);
+ }
+
+ if (minY < 0 || maxY > canvas.getHeight() || preventScaling) {
+ var lastScaleY = this.lastScaleY || 1;
+ currentObject.setScaleY(lastScaleY);
+ }
+ if (minY < 0) {
+ currentObject.setTop(0);
+ }
+
+ if (currentObject.getWidth() < this.options.minWidth) {
+ currentObject.scaleToWidth(this.options.minWidth);
+ }
+ if (currentObject.getHeight() < this.options.minHeight) {
+ currentObject.scaleToHeight(this.options.minHeight);
+ }
+
+ this.lastScaleX = currentObject.getScaleX();
+ this.lastScaleY = currentObject.getScaleY();
+
+ this.darkroom.dispatchEvent('crop:update');
+ },
+
+ // Init crop zone
+ onMouseDown: function(event) {
+ if (!this.hasFocus()) {
+ return;
+ }
+
+ var canvas = this.darkroom.canvas;
+
+ // Recalculate offset, in case canvas was manipulated since last `calcOffset`
+ canvas.calcOffset();
+ var pointer = canvas.getPointer(event.e);
+ var x = pointer.x;
+ var y = pointer.y;
+ var point = new fabric.Point(x, y);
+
+ // Check if user want to scale or drag the crop zone.
+ var activeObject = canvas.getActiveObject();
+ if (activeObject === this.cropZone || this.cropZone.containsPoint(point)) {
+ return;
+ }
+
+ canvas.discardActiveObject();
+ this.cropZone.setWidth(0);
+ this.cropZone.setHeight(0);
+ this.cropZone.setScaleX(1);
+ this.cropZone.setScaleY(1);
+
+ this.startX = x;
+ this.startY = y;
+ },
+
+ // Extend crop zone
+ onMouseMove: function(event) {
+ // Quick crop feature
+ if (this.isKeyCroping) {
+ return this.onMouseMoveKeyCrop(event);
+ }
+ if (this.startX === null || this.startY === null) {
+ return;
+ }
+
+ var canvas = this.darkroom.canvas;
+ var pointer = canvas.getPointer(event.e);
+ var x = pointer.x;
+ var y = pointer.y;
+
+ this._renderCropZone(this.startX, this.startY, x, y);
+ },
+
+ onMouseMoveKeyCrop: function(event) {
+ var canvas = this.darkroom.canvas;
+ var zone = this.cropZone;
+
+ var pointer = canvas.getPointer(event.e);
+ var x = pointer.x;
+ var y = pointer.y;
+
+ if (!zone.left || !zone.top) {
+ zone.setTop(y);
+ zone.setLeft(x);
+ }
+
+ this.isKeyLeft = x < zone.left + zone.width / 2;
+ this.isKeyUp = y < zone.top + zone.height / 2;
+
+ this._renderCropZone(
+ Math.min(zone.left, x),
+ Math.min(zone.top, y),
+ Math.max(zone.left+zone.width, x),
+ Math.max(zone.top+zone.height, y)
+ );
+ },
+
+ // Finish crop zone
+ onMouseUp: function() {
+ if (this.startX === null || this.startY === null) {
+ return;
+ }
+
+ var canvas = this.darkroom.canvas;
+ this.cropZone.setCoords();
+ canvas.setActiveObject(this.cropZone);
+ canvas.calcOffset();
+
+ this.startX = null;
+ this.startY = null;
+ },
+
+ onKeyDown: function(event) {
+ if (this.options.quickCropKey === false || event.keyCode !== this.options.quickCropKey || this.isKeyCroping) {
+ return;
+ }
+
+ // Active quick crop flow
+ this.isKeyCroping = true ;
+ this.darkroom.canvas.discardActiveObject();
+ this.cropZone.setWidth(0);
+ this.cropZone.setHeight(0);
+ this.cropZone.setScaleX(1);
+ this.cropZone.setScaleY(1);
+ this.cropZone.setTop(0);
+ this.cropZone.setLeft(0);
+ },
+
+ onKeyUp: function(event) {
+ if (this.options.quickCropKey === false || event.keyCode !== this.options.quickCropKey || !this.isKeyCroping) {
+ return;
+ }
+
+ // Inactive quick crop flow
+ this.isKeyCroping = false;
+ this.startX = 1;
+ this.startY = 1;
+ this.onMouseUp();
+ },
+
+ selectZone: function(x, y, width, height, forceDimension) {
+ if (!this.hasFocus()) {
+ this.requireFocus();
+ }
+
+ if (forceDimension) {
+ this.cropZone.set({
+ 'left': x,
+ 'top': y,
+ 'width': width,
+ 'height': height,
+ });
+ } else {
+ this._renderCropZone(x, y, x+width, y+height);
+ }
+
+ var canvas = this.darkroom.canvas;
+ canvas.bringToFront(this.cropZone);
+ this.cropZone.setCoords();
+ canvas.setActiveObject(this.cropZone);
+ canvas.calcOffset();
+
+ this.darkroom.dispatchEvent('crop:update');
+ },
+
+ toggleCrop: function() {
+ if (this.hasFocus()) {
+ this.releaseFocus();
+ } else {
+ this.requireFocus();
+ }
+ },
+
+ cropCurrentZone: function() {
+ if (!this.hasFocus()) {
+ return;
+ }
+
+ // Avoid croping empty zone
+ if (this.cropZone.width < 1 && this.cropZone.height < 1) {
+ return;
+ }
+
+ var image = this.darkroom.image;
+
+ // Compute crop zone dimensions
+ var top = this.cropZone.getTop() - image.getTop();
+ var left = this.cropZone.getLeft() - image.getLeft();
+ var width = this.cropZone.getWidth();
+ var height = this.cropZone.getHeight();
+
+ // Adjust dimensions to image only
+ if (top < 0) {
+ height += top;
+ top = 0;
+ }
+
+ if (left < 0) {
+ width += left;
+ left = 0;
+ }
+
+ // Apply crop transformation. Make sure to use relative
+ // dimension since the crop will be applied on the source image.
+ this.darkroom.applyTransformation(new Crop({
+ top: top / image.getHeight(),
+ left: left / image.getWidth(),
+ width: width / image.getWidth(),
+ height: height / image.getHeight(),
+ }));
+ },
+
+ // Test whether crop zone is set
+ hasFocus: function() {
+ return typeof this.cropZone !== 'undefined';
+ },
+
+ // Create the crop zone
+ requireFocus: function() {
+ this.cropZone = new CropZone({
+ fill: 'transparent',
+ hasBorders: false,
+ originX: 'left',
+ originY: 'top',
+ cornerColor: '#444',
+ cornerSize: 8,
+ transparentCorners: false,
+ lockRotation: true,
+ hasRotatingPoint: false,
+ });
+
+ if (this.options.ratio !== null) {
+ this.cropZone.set('lockUniScaling', true);
+ }
+
+ this.darkroom.canvas.add(this.cropZone);
+ this.darkroom.canvas.defaultCursor = 'crosshair';
+
+ this.cropButton.active(true);
+ this.okButton.hide(false);
+ this.cancelButton.hide(false);
+ },
+
+ // Remove the crop zone
+ releaseFocus: function() {
+ if (typeof this.cropZone === 'undefined') {
+ return;
+ }
+
+ this.cropZone.remove();
+ this.cropZone = undefined;
+
+ this.cropButton.active(false);
+ this.okButton.hide(true);
+ this.cancelButton.hide(true);
+
+ this.darkroom.canvas.defaultCursor = 'default';
+ this.darkroom.dispatchEvent('crop:update');
+ },
+
+ _renderCropZone: function(fromX, fromY, toX, toY) {
+ var canvas = this.darkroom.canvas;
+
+ var isRight = toX > fromX;
+ var isLeft = !isRight;
+ var isDown = toY > fromY;
+ var isUp = !isDown;
+
+ var minWidth = Math.min(Number(this.options.minWidth), canvas.getWidth());
+ var minHeight = Math.min(Number(this.options.minHeight), canvas.getHeight());
+
+ // Define corner coordinates
+ var leftX = Math.min(fromX, toX);
+ var rightX = Math.max(fromX, toX);
+ var topY = Math.min(fromY, toY);
+ var bottomY = Math.max(fromY, toY);
+
+ // Replace current point into the canvas
+ leftX = Math.max(0, leftX);
+ rightX = Math.min(canvas.getWidth(), rightX);
+ topY = Math.max(0, topY);
+ bottomY = Math.min(canvas.getHeight(), bottomY);
+
+ // Recalibrate coordinates according to given options
+ if (rightX - leftX < minWidth) {
+ if (isRight) {
+ rightX = leftX + minWidth;
+ } else {
+ leftX = rightX - minWidth;
+ }
+ }
+ if (bottomY - topY < minHeight) {
+ if (isDown) {
+ bottomY = topY + minHeight;
+ } else {
+ topY = bottomY - minHeight;
+ }
+ }
+
+ // Truncate truncate according to canvas dimensions
+ if (leftX < 0) {
+ // Translate to the left
+ rightX += Math.abs(leftX);
+ leftX = 0;
+ }
+ if (rightX > canvas.getWidth()) {
+ // Translate to the right
+ leftX -= rightX - canvas.getWidth();
+ rightX = canvas.getWidth();
+ }
+ if (topY < 0) {
+ // Translate to the bottom
+ bottomY += Math.abs(topY);
+ topY = 0;
+ }
+ if (bottomY > canvas.getHeight()) {
+ // Translate to the right
+ topY -= bottomY - canvas.getHeight();
+ bottomY = canvas.getHeight();
+ }
+
+ var width = rightX - leftX;
+ var height = bottomY - topY;
+ var currentRatio = width / height;
+
+ if (this.options.ratio && Number(this.options.ratio) !== currentRatio) {
+ var ratio = Number(this.options.ratio);
+ var newWidth = 0, newHeight = 0;
+
+ if(this.isKeyCroping) {
+ isLeft = this.isKeyLeft;
+ isUp = this.isKeyUp;
+ }
+
+ if (currentRatio < ratio) {
+ newWidth = height * ratio;
+ if (isLeft) {
+ leftX -= newWidth - width;
+ }
+ width = newWidth;
+ } else if (currentRatio > ratio) {
+ newHeight = height / (ratio * height/width);
+ if (isUp) {
+ topY -= newHeight - height;
+ }
+ height = newHeight;
+ }
+
+ if (leftX < 0) {
+ leftX = 0;
+ //TODO
+ }
+ if (topY < 0) {
+ topY = 0;
+ //TODO
+ }
+ if (leftX + width > canvas.getWidth()) {
+ newWidth = canvas.getWidth() - leftX;
+ height = newWidth * height / width;
+ width = newWidth;
+ if (isUp) {
+ topY = fromY - height;
+ }
+ }
+ if (topY + height > canvas.getHeight()) {
+ newHeight = canvas.getHeight() - topY;
+ width = width * newHeight / height;
+ height = newHeight;
+ if (isLeft) {
+ leftX = fromX - width;
+ }
+ }
+ }
+
+ // Apply coordinates
+ this.cropZone.left = leftX;
+ this.cropZone.top = topY;
+ this.cropZone.width = width;
+ this.cropZone.height = height;
+
+ this.darkroom.canvas.bringToFront(this.cropZone);
+ this.darkroom.dispatchEvent('crop:update');
+ }
+ });
+ };
+
+ return {DarkroomPluginCrop: DarkroomPluginCrop};
+});
diff --git a/web_widget_darkroom/static/src/js/plugins/darkroom.history.js b/web_widget_darkroom/static/src/js/plugins/darkroom.history.js
new file mode 100755
index 000000000000..11b2569c9839
--- /dev/null
+++ b/web_widget_darkroom/static/src/js/plugins/darkroom.history.js
@@ -0,0 +1,76 @@
+/**
+* Copyright 2013 Matthieu Moquet
+* Copyright 2016-2017 LasLabs Inc.
+* License MIT (https://opensource.org/licenses/MIT)
+**/
+
+odoo.define('web_widget_darkroom.darkroom_history', function() {
+ 'use strict';
+
+ var DarkroomPluginHistory = function() {
+ Darkroom.plugins.history = Darkroom.Plugin.extend({
+ undoTransformations: [],
+
+ initialize: function InitDarkroomHistoryPlugin() {
+ this._initButtons();
+ this.darkroom.addEventListener('core:transformation', this._onTranformationApplied.bind(this));
+ },
+
+ undo: function() {
+ if (this.darkroom.transformations.length === 0) {
+ return;
+ }
+
+ var lastTransformation = this.darkroom.transformations.pop();
+ this.undoTransformations.unshift(lastTransformation);
+
+ this.darkroom.reinitializeImage();
+ this._updateButtons();
+ },
+
+ redo: function() {
+ if (this.undoTransformations.length === 0) {
+ return;
+ }
+
+ var cancelTransformation = this.undoTransformations.shift();
+ this.darkroom.transformations.push(cancelTransformation);
+
+ this.darkroom.reinitializeImage();
+ this._updateButtons();
+ },
+
+ _initButtons: function() {
+ var buttonGroup = this.darkroom.toolbar.createButtonGroup();
+
+ this.backButton = buttonGroup.createButton({
+ image: 'fa fa-step-backward',
+ disabled: true,
+ editOnly: true,
+ });
+ this.forwardButton = buttonGroup.createButton({
+ image: 'fa fa-step-forward',
+ disabled: true,
+ editOnly: true,
+ });
+
+ this.backButton.addEventListener('click', this.undo.bind(this));
+ this.forwardButton.addEventListener('click', this.redo.bind(this));
+
+ return this;
+ },
+
+ _updateButtons: function() {
+ this.backButton.disable(this.darkroom.transformations.length === 0);
+ this.forwardButton.disable(this.undoTransformations.length === 0);
+ },
+
+ _onTranformationApplied: function() {
+ this.undoTransformations = [];
+ this._updateButtons();
+ },
+ });
+ };
+
+ return {DarkroomPluginHistory: DarkroomPluginHistory};
+});
diff --git a/web_widget_darkroom/static/src/js/plugins/darkroom.rotate.js b/web_widget_darkroom/static/src/js/plugins/darkroom.rotate.js
new file mode 100755
index 000000000000..0a02629a49c8
--- /dev/null
+++ b/web_widget_darkroom/static/src/js/plugins/darkroom.rotate.js
@@ -0,0 +1,64 @@
+/**
+* Copyright 2013 Matthieu Moquet
+* Copyright 2016-2017 LasLabs Inc.
+* License MIT (https://opensource.org/licenses/MIT)
+**/
+
+odoo.define('web_widget_darkroom.darkroom_rotate', function() {
+ 'use strict';
+
+ var DarkroomPluginRotate = function() {
+ var Rotation = Darkroom.Transformation.extend({
+ applyTransformation: function(canvas, image, next) {
+ var angle = (image.getAngle() + this.options.angle) % 360;
+ image.rotate(angle);
+
+ var height = Math.abs(image.getWidth()*Math.sin(angle*Math.PI/180))+Math.abs(image.getHeight()*Math.cos(angle*Math.PI/180));
+ var width = Math.abs(image.getHeight()*Math.sin(angle*Math.PI/180))+Math.abs(image.getWidth()*Math.cos(angle*Math.PI/180));
+
+ canvas.setWidth(width);
+ canvas.setHeight(height);
+
+ canvas.centerObject(image);
+ image.setCoords();
+ canvas.renderAll();
+
+ next();
+ },
+ });
+
+ Darkroom.plugins.rotate = Darkroom.Plugin.extend({
+ initialize: function InitDarkroomRotatePlugin() {
+ var buttonGroup = this.darkroom.toolbar.createButtonGroup();
+
+ var leftButton = buttonGroup.createButton({
+ image: 'fa fa-undo oe_edit_only',
+ editOnly: true,
+ });
+ var rightButton = buttonGroup.createButton({
+ image: 'fa fa-repeat oe_edit_only',
+ editOnly: true,
+ });
+
+ leftButton.addEventListener('click', this.rotateLeft.bind(this));
+ rightButton.addEventListener('click', this.rotateRight.bind(this));
+ },
+
+ rotateLeft: function rotateLeft() {
+ this.rotate(-90);
+ },
+
+ rotateRight: function rotateRight() {
+ this.rotate(90);
+ },
+
+ rotate: function rotate(angle) {
+ this.darkroom.applyTransformation(
+ new Rotation({angle: angle})
+ );
+ }
+ });
+ };
+
+ return {DarkroomPluginRotate: DarkroomPluginRotate};
+});
diff --git a/web_widget_darkroom/static/src/js/plugins/darkroom.zoom.js b/web_widget_darkroom/static/src/js/plugins/darkroom.zoom.js
new file mode 100755
index 000000000000..c484919681f7
--- /dev/null
+++ b/web_widget_darkroom/static/src/js/plugins/darkroom.zoom.js
@@ -0,0 +1,148 @@
+/**
+* Copyright 2013 Matthieu Moquet
+* Copyright 2016-2017 LasLabs Inc.
+* License MIT (https://opensource.org/licenses/MIT)
+**/
+
+odoo.define('web_widget_darkroom.darkroom_zoom', function() {
+ 'use strict';
+
+ var DarkroomPluginZoom = function() {
+ Darkroom.plugins.zoom = Darkroom.Plugin.extend({
+ inZoom: false,
+ zoomLevel: 0,
+ zoomFactor: 0.1,
+
+ initialize: function() {
+ var self = this;
+ var buttonGroup = this.darkroom.toolbar.createButtonGroup();
+
+ this.zoomButton = buttonGroup.createButton({
+ image: 'fa fa-search',
+ });
+ this.zoomInButton = buttonGroup.createButton({
+ image: 'fa fa-plus',
+ });
+ this.zoomOutButton = buttonGroup.createButton({
+ image: 'fa fa-minus',
+ });
+ this.cancelButton = buttonGroup.createButton({
+ image: 'fa fa-times',
+ type: 'danger',
+ hide: true
+ });
+
+ // Button click events
+ this.zoomButton.addEventListener('click', this.toggleZoom.bind(this));
+ this.zoomInButton.addEventListener('click', this.zoomIn.bind(this));
+ this.zoomOutButton.addEventListener('click', this.zoomOut.bind(this));
+ this.cancelButton.addEventListener('click', this.releaseFocus.bind(this));
+
+ // Canvas events
+ this.darkroom.canvas.on('mouse:down', this.onMouseDown.bind(this));
+ this.darkroom.canvas.on('mouse:move', this.onMouseMove.bind(this));
+ this.darkroom.canvas.on('mouse:up', this.onMouseUp.bind(this));
+ $(this.darkroom.canvas.wrapperEl).on('mousewheel', function(event){
+ self.onMouseWheel(event);
+ });
+
+ this.toggleElements(false);
+ },
+
+ toggleZoom: function() {
+ if (this.hasFocus()) {
+ this.releaseFocus();
+ } else {
+ this.requireFocus();
+ }
+ },
+
+ hasFocus: function() {
+ return this.inZoom;
+ },
+
+ releaseFocus: function() {
+ this.toggleElements(false);
+ },
+
+ requireFocus: function() {
+ this.toggleElements(true);
+ },
+
+ toggleElements: function(bool) {
+ var toggle = bool;
+ if (typeof bool === 'undefined') {
+ toggle = !this.hasFocus();
+ }
+
+ this.zoomButton.active(toggle);
+ this.inZoom = toggle;
+ this.zoomInButton.hide(!toggle);
+ this.zoomOutButton.hide(!toggle);
+ this.cancelButton.hide(!toggle);
+ this.darkroom.canvas.default_cursor = toggle ? 'move' : 'default';
+ },
+
+ zoomIn: function() {
+ return this.setZoomLevel(this.zoomFactor, this.getCenterPoint());
+ },
+
+ zoomOut: function() {
+ return this.setZoomLevel(-this.zoomFactor, this.getCenterPoint());
+ },
+
+ // Return fabric.Point object for center of canvas
+ getCenterPoint: function() {
+ var center = this.darkroom.canvas.getCenter();
+ return new fabric.Point(center.left, center.top);
+ },
+
+ // Set internal zoom
+ setZoomLevel: function(factor, point) {
+ var zoomLevel = this.zoomLevel + factor;
+ if (zoomLevel < 0) {
+ zoomLevel = 0;
+ }
+ if (zoomLevel === this.zoomLevel) {
+ return false;
+ }
+ if (point) {
+ var canvas = this.darkroom.canvas;
+ // Add one for zero index
+ canvas.zoomToPoint(point, zoomLevel + 1);
+ this.zoomLevel = zoomLevel;
+ }
+ return true;
+ },
+
+ onMouseWheel: function(event) {
+ if (this.hasFocus() && event && event.originalEvent) {
+ var modifier = event.originalEvent.wheelDelta < 0 ? -1 : 1;
+ var pointer = this.darkroom.canvas.getPointer(event.originalEvent);
+ var mousePoint = new fabric.Point(pointer.x, pointer.y);
+ this.setZoomLevel(modifier * this.zoomFactor, mousePoint);
+ return event.preventDefault();
+ }
+ },
+
+ onMouseDown: function() {
+ if (this.hasFocus()) {
+ this.panning = true;
+ }
+ },
+
+ onMouseUp: function() {
+ this.panning = false;
+ },
+
+ onMouseMove: function(event) {
+ if (this.panning && event && event.e) {
+ var delta = new fabric.Point(event.e.movementX, event.e.movementY);
+ this.darkroom.canvas.relativePan(delta);
+ }
+ },
+ });
+ };
+
+ return {DarkroomPluginZoom: DarkroomPluginZoom};
+});
diff --git a/web_widget_darkroom/static/src/js/widget_darkroom.js b/web_widget_darkroom/static/src/js/widget_darkroom.js
new file mode 100644
index 000000000000..407a73988826
--- /dev/null
+++ b/web_widget_darkroom/static/src/js/widget_darkroom.js
@@ -0,0 +1,223 @@
+/**
+* Copyright 2013 Matthieu Moquet
+* Copyright 2016-2017 LasLabs Inc.
+* License MIT (https://opensource.org/licenses/MIT)
+**/
+
+odoo.define('web_widget_darkroom.darkroom_widget', function(require) {
+ 'use strict';
+
+ var core = require('web.core');
+ var common = require('web.form_common');
+ var session = require('web.session');
+ var utils = require('web.utils');
+
+ var QWeb = core.qweb;
+
+ var FieldDarkroomImage = common.AbstractField.extend(common.ReinitializeFieldMixin, {
+ className: 'darkroom-widget',
+ template: 'FieldDarkroomImage',
+ placeholder: "/web/static/src/img/placeholder.png",
+ darkroom: null,
+ no_rerender: false,
+
+ defaults: {
+ // Canvas initialization size
+ minWidth: 100,
+ minHeight: 100,
+ maxWidth: 700,
+ maxHeight: 500,
+
+ // Plugin options
+ plugins: {
+ crop: {
+ minHeight: 50,
+ minWidth: 50,
+ ratio: 1
+ },
+ },
+ },
+
+ init: function(field_manager, node) {
+ this._super(field_manager, node);
+ this.options = _.defaults(this.options, this.defaults);
+ },
+
+ _init_darkroom: function() {
+ if (!this.darkroom) {
+ this._init_darkroom_icons();
+ this._init_darkroom_ui();
+ this._init_darkroom_plugins();
+ }
+ },
+
+ _init_darkroom_icons: function() {
+ var element = document.createElement('div');
+ element.id = 'darkroom-icons';
+ element.style.height = 0;
+ element.style.width = 0;
+ element.style.position = 'absolute';
+ element.style.visibility = 'hidden';
+ element.innerHTML = '';
+ this.el.appendChild(element);
+ },
+
+ _init_darkroom_plugins: function() {
+ require('web_widget_darkroom.darkroom_crop').DarkroomPluginCrop();
+ require('web_widget_darkroom.darkroom_history').DarkroomPluginHistory();
+ require('web_widget_darkroom.darkroom_rotate').DarkroomPluginRotate();
+ require('web_widget_darkroom.darkroom_zoom').DarkroomPluginZoom();
+ },
+
+ _init_darkroom_ui: function() {
+ // Button object
+ function Button(element) {
+ this.element = element;
+ }
+
+ Button.prototype = {
+ addEventListener: function(eventName, listener) {
+ if (this.element.addEventListener) {
+ this.element.addEventListener(eventName, listener);
+ } else if (this.element.attachEvent) {
+ this.element.attachEvent('on' + eventName, listener);
+ }
+ },
+ removeEventListener: function(eventName, listener) {
+ if (this.element.removeEventListener) {
+ this.element.removeEventListener(eventName, listener);
+ } else if (this.element.detachEvent) {
+ this.element.detachEvent('on' + eventName, listener);
+ }
+ },
+ active: function(bool) {
+ if (bool) {
+ this.element.classList.add('darkroom-button-active');
+ } else {
+ this.element.classList.remove('darkroom-button-active');
+ }
+ },
+ hide: function(bool) {
+ if (bool) {
+ this.element.classList.add('hidden');
+ } else {
+ this.element.classList.remove('hidden');
+ }
+ },
+ disable: function(bool) {
+ this.element.disabled = bool;
+ },
+ };
+
+ // ButtonGroup object
+ function ButtonGroup(element) {
+ this.element = element;
+ }
+
+ ButtonGroup.prototype = {
+ createButton: function(options) {
+ var defaults = {
+ image: 'fa fa-question-circle',
+ type: 'default',
+ group: 'default',
+ hide: false,
+ disabled: false,
+ editOnly: false,
+ addClass: '',
+ };
+ var optionsMerged = Darkroom.Utils.extend(options, defaults);
+
+ var buttonElement = document.createElement('button');
+ buttonElement.type = 'button';
+ buttonElement.className = 'darkroom-button darkroom-button-' + optionsMerged.type;
+ buttonElement.innerHTML = '';
+ if (optionsMerged.editOnly) {
+ buttonElement.classList.add('oe_edit_only');
+ }
+ if (optionsMerged.addClass) {
+ buttonElement.classList.add(optionsMerged.addClass);
+ }
+ this.element.appendChild(buttonElement);
+
+ var button = new Button(buttonElement);
+ button.hide(optionsMerged.hide);
+ button.disable(optionsMerged.disabled);
+
+ return button;
+ }
+ };
+
+ // Toolbar object
+ function Toolbar(element) {
+ this.element = element;
+ }
+
+ Toolbar.prototype = {
+ createButtonGroup: function() {
+ var buttonGroupElement = document.createElement('div');
+ buttonGroupElement.className = 'darkroom-button-group';
+ this.element.appendChild(buttonGroupElement);
+
+ return new ButtonGroup(buttonGroupElement);
+ }
+ };
+
+ Darkroom.UI = {
+ Toolbar: Toolbar,
+ ButtonGroup: ButtonGroup,
+ Button: Button,
+ };
+ },
+
+ destroy_content: function() {
+ if (this.darkroom && this.darkroom.containerElement) {
+ this.darkroom.containerElement.remove();
+ this.darkroom = null;
+ }
+ },
+
+ set_value: function(value) {
+ return this._super(value);
+ },
+
+ render_value: function() {
+ this.destroy_content();
+ this._init_darkroom();
+
+ var url = null;
+ if (this.get('value') && !utils.is_bin_size(this.get('value'))) {
+ url = 'data:image/png;base64,' + this.get('value');
+ } else if (this.get('value')) {
+ var id = JSON.stringify(this.view.datarecord.id || null);
+ var field = this.name;
+ if (this.options.preview_image) {
+ field = this.options.preview_image;
+ }
+ url = session.url('/web/image', {
+ model: this.view.dataset.model,
+ id: id,
+ field: field,
+ unique: (this.view.datarecord.__last_update || '').replace(/[^0-9]/g, ''),
+ });
+ } else {
+ url = this.placeholder;
+ }
+
+ var $img = $(QWeb.render("FieldBinaryImage-img", {widget: this, url: url}));
+ this.$el.find('> img').remove();
+ this.$el.append($img);
+ this.darkroom = new Darkroom($img.get(0), this.options);
+ this.darkroom.widget = this;
+ },
+
+ commit_value: function() {
+ if (this.darkroom.sourceImage) {
+ this.set_value(this.darkroom.sourceImage.toDataURL().split(',')[1]);
+ }
+ },
+ });
+
+ core.form_widget_registry.add("darkroom", FieldDarkroomImage);
+
+ return {FieldDarkroomImage: FieldDarkroomImage};
+});
diff --git a/web_widget_darkroom/static/src/js/widget_darkroom_modal.js b/web_widget_darkroom/static/src/js/widget_darkroom_modal.js
new file mode 100644
index 000000000000..8189969ccaaf
--- /dev/null
+++ b/web_widget_darkroom/static/src/js/widget_darkroom_modal.js
@@ -0,0 +1,64 @@
+/**
+* Copyright 2017 LasLabs Inc.
+* License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html).
+**/
+
+odoo.define('web_widget_darkroom.darkroom_modal_button', function(require) {
+ 'use strict';
+
+ var core = require('web.core');
+ var DataModel = require('web.DataModel');
+
+ core.form_widget_registry.get('image').include({
+ // Used in template to prevent Darkroom buttons from being added to
+ // forms for new records, which are not supported
+ darkroom_supported: function() {
+ if (this.field_manager.dataset.index === null) {
+ return false;
+ }
+ return true;
+ },
+
+ render_value: function() {
+ this._super();
+
+ var imageWidget = this;
+ var activeModel = imageWidget.field_manager.dataset._model.name;
+ var activeRecordId = imageWidget.field_manager.datarecord.id;
+ var activeField = imageWidget.node.attrs.name;
+
+ var updateImage = function() {
+ var ActiveModel = new DataModel(activeModel);
+ ActiveModel.query([activeField]).
+ filter([['id', '=', activeRecordId]]).
+ all().
+ then(function(result) {
+ imageWidget.set_value(result[0].image);
+ });
+ };
+
+ var openModal = function() {
+ var context = {
+ active_model: activeModel,
+ active_record_id: activeRecordId,
+ active_field: activeField,
+ };
+ var modalAction = {
+ type: 'ir.actions.act_window',
+ res_model: 'darkroom.modal',
+ name: 'Darkroom',
+ views: [[false, 'form']],
+ target: 'new',
+ context: context,
+ };
+ var options = {on_close: updateImage};
+ imageWidget.do_action(modalAction, options);
+ };
+
+ var $button = this.$('.o_form_binary_image_darkroom_modal');
+ if ($button.length > 0) {
+ $button.click(openModal);
+ }
+ },
+ });
+});
diff --git a/web_widget_darkroom/static/src/less/darkroom.less b/web_widget_darkroom/static/src/less/darkroom.less
new file mode 100755
index 000000000000..1c8a1b855150
--- /dev/null
+++ b/web_widget_darkroom/static/src/less/darkroom.less
@@ -0,0 +1,11 @@
+.darkroom-button-group {
+ display: inline;
+}
+
+.darkroom-button-active {
+ color: @odoo-brand-primary;
+}
+
+.oe_form_field_image_controls i {
+ margin: 0 5%;
+}
diff --git a/web_widget_darkroom/static/src/xml/field_templates.xml b/web_widget_darkroom/static/src/xml/field_templates.xml
new file mode 100644
index 000000000000..947a1349badb
--- /dev/null
+++ b/web_widget_darkroom/static/src/xml/field_templates.xml
@@ -0,0 +1,30 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/web_widget_darkroom/tests/__init__.py b/web_widget_darkroom/tests/__init__.py
new file mode 100644
index 000000000000..3773edddee00
--- /dev/null
+++ b/web_widget_darkroom/tests/__init__.py
@@ -0,0 +1,5 @@
+# -*- coding: utf-8 -*-
+# Copyright 2017 LasLabs Inc.
+# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html).
+
+from . import test_darkroom_modal
diff --git a/web_widget_darkroom/tests/test_darkroom_modal.py b/web_widget_darkroom/tests/test_darkroom_modal.py
new file mode 100644
index 000000000000..2ce988775206
--- /dev/null
+++ b/web_widget_darkroom/tests/test_darkroom_modal.py
@@ -0,0 +1,203 @@
+# -*- coding: utf-8 -*-
+# Copyright 2017 LasLabs Inc.
+# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html).
+
+from odoo.tests.common import TransactionCase
+
+
+class TestDarkroomModal(TransactionCase):
+
+ def test_default_res_model_id_model_in_context(self):
+ """Should return correct ir.model record when context has model name"""
+ active_model = 'res.users'
+ test_model = self.env['darkroom.modal'].with_context({
+ 'active_model': active_model,
+ })
+ test_result = test_model._default_res_model_id()
+
+ expected = self.env['ir.model'].search([('model', '=', active_model)])
+ self.assertEqual(test_result, expected)
+
+ def test_default_res_model_id_no_valid_info_in_context(self):
+ """Should return empty ir.model recordset when missing/invalid info"""
+ test_model = self.env['darkroom.modal'].with_context({})
+ test_result = test_model._default_res_model_id()
+
+ self.assertEqual(test_result, self.env['ir.model'])
+
+ def test_default_res_record_id_id_in_context(self):
+ """Should return correct value when ID in context"""
+ active_record_id = 5
+ test_model = self.env['darkroom.modal'].with_context({
+ 'active_record_id': active_record_id,
+ })
+ test_result = test_model._default_res_record_id()
+
+ self.assertEqual(test_result, active_record_id)
+
+ def test_default_res_record_id_no_id_in_context(self):
+ """Should return 0 when no ID in context"""
+ test_model = self.env['darkroom.modal'].with_context({})
+ test_result = test_model._default_res_record_id()
+
+ self.assertEqual(test_result, 0)
+
+ def test_default_res_record_model_and_id_in_context(self):
+ """Should return correct record when context has model name and ID"""
+ active_model = 'res.users'
+ active_record_id = 1
+ test_model = self.env['darkroom.modal'].with_context({
+ 'active_model': active_model,
+ 'active_record_id': active_record_id,
+ })
+ test_result = test_model._default_res_record()
+
+ expected = self.env[active_model].browse(active_record_id)
+ self.assertEqual(test_result, expected)
+
+ def test_default_res_record_model_but_no_id_in_context(self):
+ """Should return right empty recordset if model but no ID in context"""
+ active_model = 'res.users'
+ test_model = self.env['darkroom.modal'].with_context({
+ 'active_model': active_model,
+ })
+ test_result = test_model._default_res_record()
+
+ self.assertEqual(test_result, self.env[active_model])
+
+ def test_default_res_record_no_valid_model_info_in_context(self):
+ """Should return None if context has missing/invalid model info"""
+ active_model = 'bad.model.name'
+ test_model = self.env['darkroom.modal'].with_context({
+ 'active_model': active_model,
+ })
+ test_result = test_model._default_res_record()
+
+ self.assertIsNone(test_result)
+
+ def test_default_res_field_id_model_and_field_in_context(self):
+ """Should return correct ir.model.fields record when info in context"""
+ active_model = 'res.users'
+ active_field = 'name'
+ test_model = self.env['darkroom.modal'].with_context({
+ 'active_model': active_model,
+ 'active_field': active_field,
+ })
+ test_result = test_model._default_res_field_id()
+
+ self.assertEqual(test_result.name, active_field)
+ self.assertEqual(test_result.model_id.model, active_model)
+
+ def test_default_res_field_id_no_valid_field_in_context(self):
+ """Should return empty recordset if field info missing/invalid"""
+ active_model = 'res.users'
+ active_field = 'totally.not.a.real.field.name'
+ test_model = self.env['darkroom.modal'].with_context({
+ 'active_model': active_model,
+ 'active_field': active_field,
+ })
+ test_result = test_model._default_res_field_id()
+
+ self.assertEqual(test_result, self.env['ir.model.fields'])
+
+ def test_default_res_field_id_no_valid_model_in_context(self):
+ """Should return empty recordset if model info missing/invalid"""
+ active_field = 'name'
+ test_model = self.env['darkroom.modal'].with_context({
+ 'active_field': active_field,
+ })
+ test_result = test_model._default_res_field_id()
+
+ self.assertEqual(test_result, self.env['ir.model.fields'])
+
+ def test_default_image_all_info_in_context(self):
+ """Should return value of correct field if all info in context"""
+ active_model = 'res.users'
+ active_record_id = 1
+ active_field = 'name'
+ test_model = self.env['darkroom.modal'].with_context({
+ 'active_model': active_model,
+ 'active_record_id': active_record_id,
+ 'active_field': active_field,
+ })
+ test_result = test_model._default_image()
+
+ expected = self.env[active_model].browse(active_record_id).name
+ self.assertEqual(test_result, expected)
+
+ def test_default_image_no_valid_field_in_context(self):
+ """Should return None if missing/invalid field info in context"""
+ active_model = 'res.users'
+ active_record_id = 1
+ test_model = self.env['darkroom.modal'].with_context({
+ 'active_model': active_model,
+ 'active_record_id': active_record_id,
+ })
+ test_result = test_model._default_image()
+
+ self.assertIsNone(test_result)
+
+ def test_default_image_no_valid_id_in_context(self):
+ """Should return False/None if missing/invalid record ID in context"""
+ active_model = 'res.users'
+ active_field = 'name'
+ test_model = self.env['darkroom.modal'].with_context({
+ 'active_model': active_model,
+ 'active_field': active_field,
+ })
+ test_result = test_model._default_image()
+
+ self.assertFalse(test_result)
+
+ def test_default_image_no_valid_model_in_context(self):
+ """Should return None if missing/invalid model info in context"""
+ active_record_id = 1
+ active_field = 'name'
+ test_model = self.env['darkroom.modal'].with_context({
+ 'active_record_id': active_record_id,
+ 'active_field': active_field,
+ })
+ test_result = test_model._default_image()
+
+ self.assertIsNone(test_result)
+
+ def test_action_save_record_count_in_self(self):
+ """Should raise correct error if not called on recordset of 1"""
+ test_wizard = self.env['darkroom.modal'].with_context({
+ 'active_model': 'res.users',
+ 'active_record_id': 1,
+ 'active_field': 'name',
+ }).create({})
+ test_wizard_set = test_wizard + test_wizard.copy()
+
+ with self.assertRaises(ValueError):
+ self.env['darkroom.modal'].action_save()
+ with self.assertRaises(ValueError):
+ test_wizard_set.action_save()
+
+ def test_action_save_update_source(self):
+ """Should update source record correctly"""
+ active_model = 'res.users'
+ active_record_id = 1
+ test_wizard = self.env['darkroom.modal'].with_context({
+ 'active_model': active_model,
+ 'active_record_id': active_record_id,
+ 'active_field': 'name',
+ }).create({})
+ test_name = 'Test Name'
+ test_wizard.image = test_name
+ test_wizard.action_save()
+
+ result = self.env[active_model].browse(active_record_id).name
+ self.assertEqual(result, test_name)
+
+ def test_action_save_return_action(self):
+ """Should return correct action"""
+ test_wizard = self.env['darkroom.modal'].with_context({
+ 'active_model': 'res.users',
+ 'active_record_id': 1,
+ 'active_field': 'name',
+ }).create({})
+ test_value = test_wizard.action_save()
+
+ self.assertEqual(test_value, {'type': 'ir.actions.act_window_close'})
diff --git a/web_widget_darkroom/views/assets.xml b/web_widget_darkroom/views/assets.xml
new file mode 100644
index 000000000000..4e43e8227507
--- /dev/null
+++ b/web_widget_darkroom/views/assets.xml
@@ -0,0 +1,28 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/web_widget_darkroom/wizards/__init__.py b/web_widget_darkroom/wizards/__init__.py
new file mode 100644
index 000000000000..202f83a026e6
--- /dev/null
+++ b/web_widget_darkroom/wizards/__init__.py
@@ -0,0 +1,5 @@
+# -*- coding: utf-8 -*-
+# Copyright 2017 LasLabs Inc.
+# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html).
+
+from . import darkroom_modal
diff --git a/web_widget_darkroom/wizards/darkroom_modal.py b/web_widget_darkroom/wizards/darkroom_modal.py
new file mode 100644
index 000000000000..4a68032e7e26
--- /dev/null
+++ b/web_widget_darkroom/wizards/darkroom_modal.py
@@ -0,0 +1,82 @@
+# -*- coding: utf-8 -*-
+# Copyright 2017 LasLabs Inc.
+# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html).
+
+from odoo import api, fields, models
+from odoo.exceptions import MissingError
+
+
+class DarkroomModal(models.TransientModel):
+ _name = 'darkroom.modal'
+ _description = 'Darkroom Modal - Wizard Model'
+
+ @api.model
+ def _default_res_model_id(self):
+ res_model_name = self.env.context.get('active_model')
+ return self.env['ir.model'].search([('model', '=', res_model_name)])
+
+ @api.model
+ def _default_res_record_id(self):
+ return self.env.context.get('active_record_id', 0)
+
+ @api.model
+ def _default_res_record(self):
+ res_model_name = self._default_res_model_id().model
+ try:
+ res_model_model = self.env[res_model_name]
+ except KeyError:
+ return None
+
+ return res_model_model.browse(self._default_res_record_id())
+
+ @api.model
+ def _default_res_field_id(self):
+ res_model_id = self._default_res_model_id()
+ res_field_name = self.env.context.get('active_field')
+ return self.env['ir.model.fields'].search([
+ ('model_id', '=', res_model_id.id),
+ ('name', '=', res_field_name),
+ ])
+
+ @api.model
+ def _default_image(self):
+ res_record = self._default_res_record()
+ res_field_name = self._default_res_field_id().name
+
+ try:
+ return getattr(res_record, res_field_name, None)
+ except (TypeError, MissingError):
+ return None
+
+ res_model_id = fields.Many2one(
+ comodel_name='ir.model',
+ string='Source Model',
+ required=True,
+ default=lambda s: s._default_res_model_id(),
+ )
+ res_record_id = fields.Integer(
+ string='Source Record ID',
+ required=True,
+ default=lambda s: s._default_res_record_id(),
+ )
+ res_field_id = fields.Many2one(
+ comodel_name='ir.model.fields',
+ string='Source Field',
+ required=True,
+ default=lambda s: s._default_res_field_id(),
+ )
+ image = fields.Binary(
+ string='Darkroom Image',
+ required=True,
+ default=lambda s: s._default_image(),
+ )
+
+ @api.multi
+ def action_save(self):
+ self.ensure_one()
+
+ res_record = self._default_res_record()
+ res_field_name = self._default_res_field_id().name
+ setattr(res_record, res_field_name, self.image)
+
+ return {'type': 'ir.actions.act_window_close'}
diff --git a/web_widget_darkroom/wizards/darkroom_modal.xml b/web_widget_darkroom/wizards/darkroom_modal.xml
new file mode 100644
index 000000000000..413e6f154d90
--- /dev/null
+++ b/web_widget_darkroom/wizards/darkroom_modal.xml
@@ -0,0 +1,27 @@
+
+
+
+
+
+
+ Darkroom Modal Wizard
+ darkroom.modal
+
+
+
+
+