diff --git a/CHANGELOG.md b/CHANGELOG.md index 01677c48d14..580a32de667 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## [next] +- chore(TS): migrate Text classes/mixins [#8408](https://github.com/fabricjs/fabric.js/pull/8408) - chore(TS): migrate Path [#8412](https://github.com/fabricjs/fabric.js/pull/8412) - ci(): remove unwanted build stats (from [#8395](https://github.com/fabricjs/fabric.js/pull/8395)) [#8416](https://github.com/fabricjs/fabric.js/pull/8416) - chore(TS): migrate Line [#8413](https://github.com/fabricjs/fabric.js/pull/8413) diff --git a/index.js b/index.js index ad04eeb283d..421d937822d 100644 --- a/index.js +++ b/index.js @@ -58,7 +58,6 @@ import './src/filters/gamma_filter.class'; // optional image_filters import './src/filters/composed_filter.class'; // optional image_filters import './src/filters/hue_rotation.class'; // optional image_filters import './src/shapes/text.class'; // optional text -import './src/mixins/text_style.mixin'; // optional text import './src/shapes/itext.class'; // optional itext import './src/mixins/itext_behavior.mixin'; // optional itext import './src/mixins/itext_click_behavior.mixin'; // optional itext diff --git a/src/mixins/text_style.mixin.ts b/src/mixins/text_style.mixin.ts index 7c2162acfdf..62f59800dc5 100644 --- a/src/mixins/text_style.mixin.ts +++ b/src/mixins/text_style.mixin.ts @@ -1,349 +1,316 @@ -//@ts-nocheck -(function (global) { - var fabric = global.fabric; - fabric.util.object.extend( - fabric.Text.prototype, - /** @lends fabric.Text.prototype */ { - /** - * Returns true if object has no styling or no styling in a line - * @param {Number} lineIndex , lineIndex is on wrapped lines. - * @return {Boolean} - */ - isEmptyStyles: function (lineIndex) { - if (!this.styles) { - return true; - } - if (typeof lineIndex !== 'undefined' && !this.styles[lineIndex]) { - return true; - } - var obj = - typeof lineIndex === 'undefined' - ? this.styles - : { line: this.styles[lineIndex] }; - 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; - }, +import { FabricObject } from '../shapes/fabricObject.class'; - /** - * Returns true if object has a style property or has it ina specified line - * This function is used to detect if a text will use a particular property or not. - * @param {String} property to check for - * @param {Number} lineIndex to check the style on - * @return {Boolean} - */ - styleHas: function (property, lineIndex) { - if (!this.styles || !property || property === '') { - return false; - } - if (typeof lineIndex !== 'undefined' && !this.styles[lineIndex]) { - return false; - } - var obj = - typeof lineIndex === 'undefined' - ? this.styles - : { 0: this.styles[lineIndex] }; - // eslint-disable-next-line - for (var p1 in obj) { - // eslint-disable-next-line - for (var p2 in obj[p1]) { - if (typeof obj[p1][p2][property] !== 'undefined') { - return true; - } - } - } - return false; - }, +type TextStyleDeclaration = Record; + +export type TextStyle = { + [line: number | string]: { [char: number | string]: TextStyleDeclaration }; +}; + +export abstract class TextStyleMixin extends FabricObject { + abstract styles: TextStyle; + protected abstract _textLines: string[][]; + protected abstract _forceClearCache: boolean; + protected abstract _styleProperties: string[]; + abstract get2DCursorLocation( + selectionStart: number, + skipWrapping?: boolean + ): { charIndex: number; lineIndex: number }; - /** - * Check if characters in a text have a value for a property - * whose value matches the textbox's value for that property. If so, - * the character-level property is deleted. If the character - * has no other properties, then it is also deleted. Finally, - * if the line containing that character has no other characters - * then it also is deleted. - * - * @param {string} property The property to compare between characters and text. - */ - cleanStyle: function (property) { - if (!this.styles || !property || property === '') { + /** + * Returns true if object has no styling or no styling in a line + * @param {Number} lineIndex , lineIndex is on wrapped lines. + * @return {Boolean} + */ + isEmptyStyles(lineIndex: number): boolean { + if (!this.styles) { + return true; + } + if (typeof lineIndex !== 'undefined' && !this.styles[lineIndex]) { + return true; + } + const obj = + typeof lineIndex === 'undefined' + ? this.styles + : { line: this.styles[lineIndex] }; + for (const p1 in obj) { + for (const p2 in obj[p1]) { + // eslint-disable-next-line no-unused-vars + for (const p3 in obj[p1][p2]) { return false; } - var obj = this.styles, - stylesCount = 0, - letterCount, - stylePropertyValue, - allStyleObjectPropertiesMatch = true, - graphemeCount = 0, - styleObject; - // eslint-disable-next-line - for (var p1 in obj) { - letterCount = 0; - // eslint-disable-next-line - for (var p2 in obj[p1]) { - var styleObject = obj[p1][p2], - stylePropertyHasBeenSet = styleObject.hasOwnProperty(property); + } + } + return true; + } - stylesCount++; + /** + * Returns true if object has a style property or has it ina specified line + * This function is used to detect if a text will use a particular property or not. + * @param {String} property to check for + * @param {Number} lineIndex to check the style on + * @return {Boolean} + */ + styleHas(property: string, lineIndex: number): boolean { + if (!this.styles || !property || property === '') { + return false; + } + if (typeof lineIndex !== 'undefined' && !this.styles[lineIndex]) { + return false; + } + const obj = + typeof lineIndex === 'undefined' + ? this.styles + : { 0: this.styles[lineIndex] }; + // eslint-disable-next-line + for (const p1 in obj) { + // eslint-disable-next-line + for (const p2 in obj[p1]) { + if (typeof obj[p1][p2][property] !== 'undefined') { + return true; + } + } + } + return false; + } - if (stylePropertyHasBeenSet) { - if (!stylePropertyValue) { - stylePropertyValue = styleObject[property]; - } else if (styleObject[property] !== stylePropertyValue) { - allStyleObjectPropertiesMatch = false; - } + /** + * Check if characters in a text have a value for a property + * whose value matches the textbox's value for that property. If so, + * the character-level property is deleted. If the character + * has no other properties, then it is also deleted. Finally, + * if the line containing that character has no other characters + * then it also is deleted. + * + * @param {string} property The property to compare between characters and text. + */ + cleanStyle(property: string) { + if (!this.styles || !property || property === '') { + return false; + } + const obj = this.styles; + let stylesCount = 0, + letterCount, + stylePropertyValue, + allStyleObjectPropertiesMatch = true, + graphemeCount = 0; + for (const p1 in obj) { + letterCount = 0; + for (const p2 in obj[p1]) { + const styleObject = obj[p1][p2], + // TODO: this shouldn't be necessary anymore with modern browsers + stylePropertyHasBeenSet = Object.prototype.hasOwnProperty.call( + styleObject, + property + ); - if (styleObject[property] === this[property]) { - delete styleObject[property]; - } - } else { - allStyleObjectPropertiesMatch = false; - } + stylesCount++; - if (Object.keys(styleObject).length !== 0) { - letterCount++; - } else { - delete obj[p1][p2]; - } + if (stylePropertyHasBeenSet) { + if (!stylePropertyValue) { + stylePropertyValue = styleObject[property]; + } else if (styleObject[property] !== stylePropertyValue) { + allStyleObjectPropertiesMatch = false; } - if (letterCount === 0) { - delete obj[p1]; + if (styleObject[property] === this[property as keyof this]) { + delete styleObject[property]; } + } else { + allStyleObjectPropertiesMatch = false; } - // if every grapheme has the same style set then - // delete those styles and set it on the parent - for (var i = 0; i < this._textLines.length; i++) { - graphemeCount += this._textLines[i].length; - } - if (allStyleObjectPropertiesMatch && stylesCount === graphemeCount) { - this[property] = stylePropertyValue; - this.removeStyle(property); - } - }, - /** - * Remove a style property or properties from all individual character styles - * in a text object. Deletes the character style object if it contains no other style - * props. Deletes a line style object if it contains no other character styles. - * - * @param {String} props The property to remove from character styles. - */ - removeStyle: function (property) { - if (!this.styles || !property || property === '') { - return; + if (Object.keys(styleObject).length !== 0) { + letterCount++; + } else { + delete obj[p1][p2]; } - var obj = this.styles, - line, - lineNum, - charNum; - for (lineNum in obj) { - line = obj[lineNum]; - for (charNum in line) { - delete line[charNum][property]; - if (Object.keys(line[charNum]).length === 0) { - delete line[charNum]; - } - } - if (Object.keys(line).length === 0) { - delete obj[lineNum]; - } - } - }, + } - /** - * @private - */ - _extendStyles: function (index, styles) { - var loc = this.get2DCursorLocation(index); + if (letterCount === 0) { + delete obj[p1]; + } + } + // if every grapheme has the same style set then + // delete those styles and set it on the parent + for (let i = 0; i < this._textLines.length; i++) { + graphemeCount += this._textLines[i].length; + } + if (allStyleObjectPropertiesMatch && stylesCount === graphemeCount) { + this[property as keyof this] = stylePropertyValue; + this.removeStyle(property); + } + } - if (!this._getLineStyle(loc.lineIndex)) { - this._setLineStyle(loc.lineIndex); + /** + * Remove a style property or properties from all individual character styles + * in a text object. Deletes the character style object if it contains no other style + * props. Deletes a line style object if it contains no other character styles. + * + * @param {String} props The property to remove from character styles. + */ + removeStyle(property: string) { + if (!this.styles || !property || property === '') { + return; + } + const obj = this.styles; + let line, lineNum, charNum; + for (lineNum in obj) { + line = obj[lineNum]; + for (charNum in line) { + delete line[charNum][property]; + if (Object.keys(line[charNum]).length === 0) { + delete line[charNum]; } + } + if (Object.keys(line).length === 0) { + delete obj[lineNum]; + } + } + } - if (!this._getStyleDeclaration(loc.lineIndex, loc.charIndex)) { - this._setStyleDeclaration(loc.lineIndex, loc.charIndex, {}); - } + private _extendStyles(index: number, styles: TextStyleDeclaration) { + const { lineIndex, charIndex } = this.get2DCursorLocation(index); - fabric.util.object.extend( - this._getStyleDeclaration(loc.lineIndex, loc.charIndex), - styles - ); - }, + if (!this._getLineStyle(lineIndex)) { + this._setLineStyle(lineIndex); + } - /** - * Returns 2d representation (lineIndex and charIndex) of cursor (or selection start) - * @param {Number} [selectionStart] Optional index. When not given, current selectionStart is used. - * @param {Boolean} [skipWrapping] consider the location for unwrapped lines. useful to manage styles. - */ - get2DCursorLocation: function (selectionStart, skipWrapping) { - if (typeof selectionStart === 'undefined') { - selectionStart = this.selectionStart; - } - var lines = skipWrapping ? this._unwrappedTextLines : this._textLines, - len = lines.length; - for (var i = 0; i < len; i++) { - if (selectionStart <= lines[i].length) { - return { - lineIndex: i, - charIndex: selectionStart, - }; - } - selectionStart -= lines[i].length + this.missingNewlineOffset(i); - } - return { - lineIndex: i - 1, - charIndex: - lines[i - 1].length < selectionStart - ? lines[i - 1].length - : selectionStart, - }; - }, + if (!this._getStyleDeclaration(lineIndex, charIndex)) { + this._setStyleDeclaration(lineIndex, charIndex, {}); + } - /** - * Gets style of a current selection/cursor (at the start position) - * if startIndex or endIndex are not provided, selectionStart or selectionEnd will be used. - * @param {Number} [startIndex] Start index to get styles at - * @param {Number} [endIndex] End index to get styles at, if not specified selectionEnd or startIndex + 1 - * @param {Boolean} [complete] get full style or not - * @return {Array} styles an array with one, zero or more Style objects - */ - getSelectionStyles: function (startIndex, endIndex, complete) { - if (typeof startIndex === 'undefined') { - startIndex = this.selectionStart || 0; - } - if (typeof endIndex === 'undefined') { - endIndex = this.selectionEnd || startIndex; - } - var styles = []; - for (var i = startIndex; i < endIndex; i++) { - styles.push(this.getStyleAtPosition(i, complete)); - } - return styles; - }, + return Object.assign( + this._getStyleDeclaration(lineIndex, charIndex) || {}, + styles + ); + } - /** - * Gets style of a current selection/cursor position - * @param {Number} position to get styles at - * @param {Boolean} [complete] full style if true - * @return {Object} style Style object at a specified index - * @private - */ - getStyleAtPosition: function (position, complete) { - var loc = this.get2DCursorLocation(position), - style = complete - ? this.getCompleteStyleDeclaration(loc.lineIndex, loc.charIndex) - : this._getStyleDeclaration(loc.lineIndex, loc.charIndex); - return style || {}; - }, + /** + * 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, if not specified startIndex + 1 + * @param {Boolean} [complete] get full style or not + * @return {Array} styles an array with one, zero or more Style objects + */ + getSelectionStyles( + startIndex: number, + endIndex?: number, + complete?: boolean + ) { + const styles: TextStyleDeclaration[] = []; + for (let i = startIndex; i < (endIndex || startIndex); i++) { + styles.push(this.getStyleAtPosition(i, complete)); + } + return styles; + } - /** - * Sets style of a current selection, if no selection exist, do not set anything. - * @param {Object} [styles] Styles object - * @param {Number} [startIndex] Start index to get styles at - * @param {Number} [endIndex] End index to get styles at, if not specified selectionEnd or startIndex + 1 - * @return {fabric.IText} thisArg - * @chainable - */ - setSelectionStyles: function (styles, startIndex, endIndex) { - if (typeof startIndex === 'undefined') { - startIndex = this.selectionStart || 0; - } - if (typeof endIndex === 'undefined') { - endIndex = this.selectionEnd || startIndex; - } - for (var i = startIndex; i < endIndex; i++) { - this._extendStyles(i, styles); - } - /* not included in _extendStyles to avoid clearing cache more than once */ - this._forceClearCache = true; - return this; - }, + /** + * Gets style of a current selection/cursor position + * @param {Number} position to get styles at + * @param {Boolean} [complete] full style if true + * @return {Object} style Style object at a specified index + * @private + */ + getStyleAtPosition(position: number, complete?: boolean) { + const { lineIndex, charIndex } = this.get2DCursorLocation(position); + return ( + (complete + ? this.getCompleteStyleDeclaration(lineIndex, charIndex) + : this._getStyleDeclaration(lineIndex, charIndex)) || {} + ); + } - /** - * get the reference, not a clone, of the style object for a given character - * @param {Number} lineIndex - * @param {Number} charIndex - * @return {Object} style object - */ - _getStyleDeclaration: function (lineIndex, charIndex) { - var lineStyle = this.styles && this.styles[lineIndex]; - if (!lineStyle) { - return null; - } - return lineStyle[charIndex]; - }, + /** + * Sets style of a current selection, if no selection exist, do not set anything. + * @param {Object} styles Styles object + * @param {Number} startIndex Start index to get styles at + * @param {Number} [endIndex] End index to get styles at, if not specified startIndex + 1 + */ + setSelectionStyles(styles: object, startIndex: number, endIndex?: number) { + for (let i = startIndex; i < (endIndex || startIndex); i++) { + this._extendStyles(i, styles); + } + /* not included in _extendStyles to avoid clearing cache more than once */ + this._forceClearCache = true; + } - /** - * return a new object that contains all the style property for a character - * the object returned is newly created - * @param {Number} lineIndex of the line where the character is - * @param {Number} charIndex position of the character on the line - * @return {Object} style object - */ - getCompleteStyleDeclaration: function (lineIndex, charIndex) { - var style = this._getStyleDeclaration(lineIndex, charIndex) || {}, - styleObject = {}, - prop; - for (var i = 0; i < this._styleProperties.length; i++) { - prop = this._styleProperties[i]; - styleObject[prop] = - typeof style[prop] === 'undefined' ? this[prop] : style[prop]; - } - return styleObject; - }, + /** + * get the reference, not a clone, of the style object for a given character + * @param {Number} lineIndex + * @param {Number} charIndex + * @return {Object} style object + */ + _getStyleDeclaration(lineIndex: number, charIndex: number) { + const lineStyle = this.styles && this.styles[lineIndex]; + if (!lineStyle) { + return null; + } + return lineStyle[charIndex]; + } - /** - * @param {Number} lineIndex - * @param {Number} charIndex - * @param {Object} style - * @private - */ - _setStyleDeclaration: function (lineIndex, charIndex, style) { - this.styles[lineIndex][charIndex] = style; - }, + /** + * return a new object that contains all the style property for a character + * the object returned is newly created + * @param {Number} lineIndex of the line where the character is + * @param {Number} charIndex position of the character on the line + * @return {Object} style object + */ + getCompleteStyleDeclaration(lineIndex: number, charIndex: number) { + const style = this._getStyleDeclaration(lineIndex, charIndex) || {}, + styleObject: TextStyleDeclaration = {}; + for (let i = 0; i < this._styleProperties.length; i++) { + const prop = this._styleProperties[i]; + styleObject[prop] = + typeof style[prop] === 'undefined' + ? this[prop as keyof this] + : style[prop]; + } + return styleObject; + } - /** - * - * @param {Number} lineIndex - * @param {Number} charIndex - * @private - */ - _deleteStyleDeclaration: function (lineIndex, charIndex) { - delete this.styles[lineIndex][charIndex]; - }, + /** + * @param {Number} lineIndex + * @param {Number} charIndex + * @param {Object} style + * @private + */ + protected _setStyleDeclaration( + lineIndex: number, + charIndex: number, + style: object + ) { + this.styles[lineIndex][charIndex] = style; + } - /** - * @param {Number} lineIndex - * @return {Boolean} if the line exists or not - * @private - */ - _getLineStyle: function (lineIndex) { - return !!this.styles[lineIndex]; - }, + /** + * + * @param {Number} lineIndex + * @param {Number} charIndex + * @private + */ + protected _deleteStyleDeclaration(lineIndex: number, charIndex: number) { + delete this.styles[lineIndex][charIndex]; + } - /** - * Set the line style to an empty object so that is initialized - * @param {Number} lineIndex - * @private - */ - _setLineStyle: function (lineIndex) { - this.styles[lineIndex] = {}; - }, + /** + * @param {Number} lineIndex + * @return {Boolean} if the line exists or not + * @private + */ + protected _getLineStyle(lineIndex: number): boolean { + return !!this.styles[lineIndex]; + } - /** - * @param {Number} lineIndex - * @private - */ - _deleteLineStyle: function (lineIndex) { - delete this.styles[lineIndex]; - }, - } - ); -})(typeof exports !== 'undefined' ? exports : window); + /** + * Set the line style to an empty object so that is initialized + * @param {Number} lineIndex + * @private + */ + protected _setLineStyle(lineIndex: number) { + this.styles[lineIndex] = {}; + } + + protected _deleteLineStyle(lineIndex: number) { + delete this.styles[lineIndex]; + } +} diff --git a/src/shapes/itext.class.ts b/src/shapes/itext.class.ts index b871a2132e3..dafe604ff90 100644 --- a/src/shapes/itext.class.ts +++ b/src/shapes/itext.class.ts @@ -1,635 +1,679 @@ -//@ts-nocheck +// @ts-nocheck +import { fabric } from '../../HEADER'; +import { TClassProperties, TFiller } from '../typedefs'; +import { stylesFromArray } from '../util/misc/textStyles'; import { FabricObject } from './fabricObject.class'; +import { Text } from './text.class'; + +/** + * IText class (introduced in v1.4) Events are also fired with "text:" + * prefix when observing canvas. + * @class IText + * + * @fires changed + * @fires selection:changed + * @fires editing:entered + * @fires editing:exited + * @fires dragstart + * @fires drag drag event firing on the drag source + * @fires dragend + * @fires copy + * @fires cut + * @fires paste + * + * @return {IText} thisArg + * @see {@link 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
+ * 
+ */ +export class IText extends Text { + /** + * Index where text selection starts (or where cursor is when there is no selection) + * @type Number + * @default + */ + selectionStart: number; -(function (global) { - var fabric = global.fabric; - /** - * IText class (introduced in v1.4) Events are also fired with "text:" - * prefix when observing canvas. - * @class fabric.IText - * @extends fabric.Text - * - * @fires changed - * @fires selection:changed - * @fires editing:entered - * @fires editing:exited - * @fires dragstart - * @fires drag drag event firing on the drag source - * @fires dragend - * @fires copy - * @fires cut - * @fires paste - * - * @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, - /** @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 text cursor color in editing mode. - * if not set (default) will take color from the text. - * if set to a color value that fabric can understand, it will - * be used instead of the color of the text at the current position. - * @type String - * @default - */ - cursorColor: '', - - /** - * Delay between cursor blink (in ms) - * @type Number - * @default - */ - cursorDelay: 1000, - - /** - * Duration of cursor fadein (in ms) - * @type Number - * @default - */ - cursorDuration: 600, - - /** - * Indicates whether internal text char widths can be cached - * @type Boolean - * @default - */ - caching: true, - - /** - * DOM container to append the hiddenTextarea. - * An alternative to attaching to the document.body. - * Useful to reduce laggish redraw of the full document.body tree and - * also with modals event capturing that won't let the textarea take focus. - * @type HTMLElement - * @default - */ - hiddenTextareaContainer: null, - - /** - * @private - */ - _reSpace: /\s|\n/, - - /** - * @private - */ - _currentCursorOpacity: 1, - - /** - * @private - */ - _selectionDirection: null, - - /** - * Helps determining when the text is in composition, so that the cursor - * rendering is altered. - */ - inCompositionMode: false, - - /** - * Constructor - * @param {String} text Text string - * @param {Object} [options] Options object - * @return {fabric.IText} thisArg - */ - initialize: function (text, options) { - this.callSuper('initialize', text, options); - this.initBehavior(); - }, - - /** - * While editing handle differently - * @private - * @param {string} key - * @param {*} value - */ - _set: function (key, value) { - if (this.isEditing && this._savedProps && key in this._savedProps) { - this._savedProps[key] = value; - } else { - this.callSuper('_set', key, value); - } - }, - - /** - * 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 }); - }, - - /** - * Initialize text dimensions. Render all text on given context - * or on a offscreen canvas to get the text width with measureText. - * Updates this.width and this.height with the proper values. - * Does not return dimensions. - * @private - */ - initDimensions: function () { - this.isEditing && this.initDelayedCursor(); - this.clearContextTop(); - this.callSuper('initDimensions'); - }, - - /** - * @private - * @param {CanvasRenderingContext2D} ctx Context to render on - */ - render: function (ctx) { - this.clearContextTop(); - this.callSuper('render', 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(); - }, - - /** - * @private - * @param {CanvasRenderingContext2D} ctx Context to render on - */ - _render: function (ctx) { - this.callSuper('_render', ctx); - }, - - /** - * Renders cursor or selection (depending on what exists) - * it does on the contextTop. If contextTop is not available, do nothing. - */ - renderCursorOrSelection: function () { - if (!this.isEditing) { - return; - } - var ctx = this.clearContextTop(true); - if (!ctx) { - return; - } - var boundaries = this._getCursorBoundaries(); - if (this.selectionStart === this.selectionEnd) { - this.renderCursor(ctx, boundaries); + /** + * Index where text selection ends + * @type Number + * @default + */ + selectionEnd: number; + + /** + * Color of text selection + * @type String + * @default + */ + selectionColor: string; + + /** + * Indicates whether text is in editing mode + * @type Boolean + * @default + */ + isEditing: boolean; + + /** + * Indicates whether a text can be edited + * @type Boolean + * @default + */ + editable: boolean; + + /** + * Border color of text object while it's in editing mode + * @type String + * @default + */ + editingBorderColor: string; + + /** + * Width of cursor (in px) + * @type Number + * @default + */ + cursorWidth: number; + + /** + * Color of text cursor color in editing mode. + * if not set (default) will take color from the text. + * if set to a color value that fabric can understand, it will + * be used instead of the color of the text at the current position. + * @type String + * @default + */ + cursorColor: string; + + /** + * Delay between cursor blink (in ms) + * @type Number + * @default + */ + cursorDelay: number; + + /** + * Duration of cursor fade in (in ms) + * @type Number + * @default + */ + cursorDuration: number; + + /** + * Indicates whether internal text char widths can be cached + * @type Boolean + * @default + */ + caching: boolean; + + /** + * DOM container to append the hiddenTextarea. + * An alternative to attaching to the document.body. + * Useful to reduce laggish redraw of the full document.body tree and + * also with modals event capturing that won't let the textarea take focus. + * @type HTMLElement + * @default + */ + hiddenTextareaContainer?: HTMLElement | null; + + /** + * @private + */ + _reSpace: RegExp; + + /** + * @private + */ + _currentCursorOpacity: number; + + /** + * @private + */ + _selectionDirection: CanvasDirection; + + /** + * Helps determining when the text is in composition, so that the cursor + * rendering is altered. + */ + inCompositionMode: boolean; + + /** + * Constructor + * @param {String} text Text string + * @param {Object} [options] Options object + * @return {IText} thisArg + */ + constructor(text: string, options: object) { + super(text, options); + this.initBehavior(); + } + + /** + * While editing handle differently + * @private + * @param {string} key + * @param {*} value + */ + _set(key: string, value: any) { + if (this.isEditing && this._savedProps && key in this._savedProps) { + this._savedProps[key] = value; + } else { + super._set(key, value); + } + } + + /** + * Sets selection start (left boundary of a selection) + * @param {Number} index Index to set selection start to + */ + setSelectionStart(index: number) { + 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(index: number) { + 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(property: string, index: number) { + if (this[property] !== index) { + this._fireSelectionChanged(); + this[property] = index; + } + this._updateTextarea(); + } + + /** + * Fires the even of selection changed + * @private + */ + _fireSelectionChanged() { + this.fire('selection:changed'); + this.canvas && this.canvas.fire('text:selection:changed', { target: this }); + } + + /** + * Initialize text dimensions. Render all text on given context + * or on a offscreen canvas to get the text width with measureText. + * Updates this.width and this.height with the proper values. + * Does not return dimensions. + * @private + */ + initDimensions() { + this.isEditing && this.initDelayedCursor(); + this.clearContextTop(); + super.initDimensions(); + } + + /** + * Gets style of a current selection/cursor (at the start position) + * if startIndex or endIndex are not provided, selectionStart or selectionEnd will be used. + * @param {Number} startIndex Start index to get styles at + * @param {Number} endIndex End index to get styles at, if not specified selectionEnd or startIndex + 1 + * @param {Boolean} [complete] get full style or not + * @return {Array} styles an array with one, zero or more Style objects + */ + getSelectionStyles( + startIndex: number = this.selectionStart || 0, + endIndex: number = this.selectionEnd, + complete?: boolean + ) { + return super.getSelectionStyles(startIndex, endIndex, complete); + } + + /** + * Sets style of a current selection, if no selection exist, do not set anything. + * @param {Object} [styles] Styles object + * @param {Number} [startIndex] Start index to get styles at + * @param {Number} [endIndex] End index to get styles at, if not specified selectionEnd or startIndex + 1 + */ + setSelectionStyles( + styles: object, + startIndex: number = this.selectionStart || 0, + endIndex: number = this.selectionEnd + ) { + return super.setSelectionStyles(styles, startIndex, endIndex); + } + + /** + * Returns 2d representation (lineIndex and charIndex) of cursor (or selection start) + * @param {Number} [selectionStart] Optional index. When not given, current selectionStart is used. + * @param {Boolean} [skipWrapping] consider the location for unwrapped lines. useful to manage styles. + */ + get2DCursorLocation( + selectionStart = this.selectionStart, + skipWrapping?: boolean + ) { + return super.get2DCursorLocation(selectionStart, skipWrapping); + } + + /** + * @private + * @param {CanvasRenderingContext2D} ctx Context to render on + */ + render(ctx: CanvasRenderingContext2D) { + this.clearContextTop(); + super.render(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(); + } + + /** + * @private + * @param {CanvasRenderingContext2D} ctx Context to render on + */ + _render(ctx: CanvasRenderingContext2D) { + super._render(ctx); + } + + /** + * Renders cursor or selection (depending on what exists) + * it does on the contextTop. If contextTop is not available, do nothing. + */ + renderCursorOrSelection() { + if (!this.isEditing) { + return; + } + const ctx = this.clearContextTop(true); + if (!ctx) { + return; + } + const boundaries = this._getCursorBoundaries(); + if (this.selectionStart === this.selectionEnd) { + this.renderCursor(ctx, boundaries); + } else { + this.renderSelection(ctx, boundaries); + } + ctx.restore(); + } + + /** + * Renders cursor on context Top, outside the animation cycle, on request + * Used for the drag/drop effect. + * If contextTop is not available, do nothing. + */ + renderCursorAt(selectionStart) { + const boundaries = this._getCursorBoundaries(selectionStart, true); + this._renderCursor(this.canvas.contextTop, boundaries, selectionStart); + } + + /** + * Returns cursor boundaries (left, top, leftOffset, topOffset) + * left/top are left/top of entire text box + * leftOffset/topOffset are offset from that left/top point of a text box + * @private + * @param {number} [index] index from start + * @param {boolean} [skipCaching] + */ + _getCursorBoundaries(index: number, skipCaching: boolean) { + if (typeof index === 'undefined') { + index = this.selectionStart; + } + const left = this._getLeftOffset(), + top = this._getTopOffset(), + offsets = this._getCursorBoundariesOffsets(index, skipCaching); + return { + left: left, + top: top, + leftOffset: offsets.left, + topOffset: offsets.top, + }; + } + + /** + * Caches and returns cursor left/top offset relative to instance's center point + * @private + * @param {number} index index from start + * @param {boolean} [skipCaching] + */ + _getCursorBoundariesOffsets(index: number, skipCaching: boolean) { + if (skipCaching) { + return this.__getCursorBoundariesOffsets(index); + } + if (this.cursorOffsetCache && 'top' in this.cursorOffsetCache) { + return this.cursorOffsetCache; + } + return (this.cursorOffsetCache = this.__getCursorBoundariesOffsets(index)); + } + + /** + * Calculates cursor left/top offset relative to instance's center point + * @private + * @param {number} index index from start + */ + __getCursorBoundariesOffsets(index: number) { + let topOffset = 0, + leftOffset = 0; + const { charIndex, lineIndex } = this.get2DCursorLocation(index); + + for (let i = 0; i < lineIndex; i++) { + topOffset += this.getHeightOfLine(i); + } + const lineLeftOffset = this._getLineLeftOffset(lineIndex); + const bound = this.__charBounds[lineIndex][charIndex]; + bound && (leftOffset = bound.left); + if ( + this.charSpacing !== 0 && + charIndex === this._textLines[lineIndex].length + ) { + leftOffset -= this._getWidthOfCharSpacing(); + } + const boundaries = { + top: topOffset, + left: lineLeftOffset + (leftOffset > 0 ? leftOffset : 0), + }; + if (this.direction === 'rtl') { + if ( + this.textAlign === 'right' || + this.textAlign === 'justify' || + this.textAlign === 'justify-right' + ) { + boundaries.left *= -1; + } else if ( + this.textAlign === 'left' || + this.textAlign === 'justify-left' + ) { + boundaries.left = lineLeftOffset - (leftOffset > 0 ? leftOffset : 0); + } else if ( + this.textAlign === 'center' || + this.textAlign === 'justify-center' + ) { + boundaries.left = lineLeftOffset - (leftOffset > 0 ? leftOffset : 0); + } + } + return boundaries; + } + + /** + * Renders cursor + * @param {Object} boundaries + * @param {CanvasRenderingContext2D} ctx transformed context to draw on + */ + renderCursor(ctx: CanvasRenderingContext2D, boundaries: object) { + this._renderCursor(ctx, boundaries, this.selectionStart); + } + + _renderCursor(ctx, boundaries, selectionStart) { + const cursorLocation = this.get2DCursorLocation(selectionStart), + lineIndex = cursorLocation.lineIndex, + charIndex = + cursorLocation.charIndex > 0 ? cursorLocation.charIndex - 1 : 0, + charHeight = this.getValueOfPropertyAt(lineIndex, charIndex, 'fontSize'), + multiplier = this.scaleX * this.canvas.getZoom(), + cursorWidth = this.cursorWidth / multiplier, + dy = this.getValueOfPropertyAt(lineIndex, charIndex, 'deltaY'), + topOffset = + boundaries.topOffset + + ((1 - this._fontSizeFraction) * this.getHeightOfLine(lineIndex)) / + this.lineHeight - + charHeight * (1 - this._fontSizeFraction); + + if (this.inCompositionMode) { + // TODO: investigate why there isn't a return inside the if, + // and why can't happe top of the function + this.renderSelection(ctx, boundaries); + } + ctx.fillStyle = + this.cursorColor || + this.getValueOfPropertyAt(lineIndex, charIndex, 'fill'); + ctx.globalAlpha = this.__isMousedown ? 1 : this._currentCursorOpacity; + ctx.fillRect( + boundaries.left + boundaries.leftOffset - cursorWidth / 2, + topOffset + boundaries.top + dy, + cursorWidth, + charHeight + ); + } + + /** + * Renders text selection + * @param {Object} boundaries Object with left/top/leftOffset/topOffset + * @param {CanvasRenderingContext2D} ctx transformed context to draw on + */ + renderSelection(ctx: CanvasRenderingContext2D, boundaries: object) { + const selection = { + selectionStart: this.inCompositionMode + ? this.hiddenTextarea.selectionStart + : this.selectionStart, + selectionEnd: this.inCompositionMode + ? this.hiddenTextarea.selectionEnd + : this.selectionEnd, + }; + this._renderSelection(ctx, selection, boundaries); + } + + /** + * Renders drag start text selection + */ + renderDragSourceEffect() { + if ( + this.__isDragging && + this.__dragStartSelection && + this.__dragStartSelection + ) { + this._renderSelection( + this.canvas.contextTop, + this.__dragStartSelection, + this._getCursorBoundaries( + this.__dragStartSelection.selectionStart, + true + ) + ); + } + } + + renderDropTargetEffect(e) { + const dragSelection = this.getSelectionStartFromPointer(e); + this.renderCursorAt(dragSelection); + } + + /** + * Renders text selection + * @private + * @param {{ selectionStart: number, selectionEnd: number }} selection + * @param {Object} boundaries Object with left/top/leftOffset/topOffset + * @param {CanvasRenderingContext2D} ctx transformed context to draw on + */ + _renderSelection( + ctx: CanvasRenderingContext2D, + selection: { selectionStart: number; selectionEnd: number }, + boundaries: object + ) { + const selectionStart = selection.selectionStart, + selectionEnd = selection.selectionEnd, + isJustify = this.textAlign.indexOf('justify') !== -1, + start = this.get2DCursorLocation(selectionStart), + end = this.get2DCursorLocation(selectionEnd), + startLine = start.lineIndex, + endLine = end.lineIndex, + startChar = start.charIndex < 0 ? 0 : start.charIndex, + endChar = end.charIndex < 0 ? 0 : end.charIndex; + + for (let i = startLine; i <= endLine; i++) { + const lineOffset = this._getLineLeftOffset(i) || 0; + let lineHeight = this.getHeightOfLine(i), + realLineHeight = 0, + boxStart = 0, + boxEnd = 0; + + if (i === startLine) { + boxStart = this.__charBounds[startLine][startChar].left; + } + if (i >= startLine && i < endLine) { + boxEnd = + isJustify && !this.isEndOfWrapping(i) + ? this.width + : this.getLineWidth(i) || 5; // WTF is this 5? + } else if (i === endLine) { + if (endChar === 0) { + boxEnd = this.__charBounds[endLine][endChar].left; } else { - this.renderSelection(ctx, boundaries); - } - ctx.restore(); - }, - - /** - * Renders cursor on context Top, outside the animation cycle, on request - * Used for the drag/drop effect. - * If contextTop is not available, do nothing. - */ - renderCursorAt: function (selectionStart) { - var boundaries = this._getCursorBoundaries(selectionStart, true); - this._renderCursor(this.canvas.contextTop, boundaries, selectionStart); - }, - - /** - * Returns cursor boundaries (left, top, leftOffset, topOffset) - * left/top are left/top of entire text box - * leftOffset/topOffset are offset from that left/top point of a text box - * @private - * @param {number} [index] index from start - * @param {boolean} [skipCaching] - */ - _getCursorBoundaries: function (index, skipCaching) { - if (typeof index === 'undefined') { - index = this.selectionStart; + const charSpacing = this._getWidthOfCharSpacing(); + boxEnd = + this.__charBounds[endLine][endChar - 1].left + + this.__charBounds[endLine][endChar - 1].width - + charSpacing; } - var left = this._getLeftOffset(), - top = this._getTopOffset(), - offsets = this._getCursorBoundariesOffsets(index, skipCaching); - return { - left: left, - top: top, - leftOffset: offsets.left, - topOffset: offsets.top, - }; - }, - - /** - * Caches and returns cursor left/top offset relative to instance's center point - * @private - * @param {number} index index from start - * @param {boolean} [skipCaching] - */ - _getCursorBoundariesOffsets: function (index, skipCaching) { - if (skipCaching) { - return this.__getCursorBoundariesOffsets(index); - } - if (this.cursorOffsetCache && 'top' in this.cursorOffsetCache) { - return this.cursorOffsetCache; - } - return (this.cursorOffsetCache = - this.__getCursorBoundariesOffsets(index)); - }, - - /** - * Calcualtes cursor left/top offset relative to instance's center point - * @private - * @param {number} index index from start - */ - __getCursorBoundariesOffsets: function (index) { - var lineLeftOffset, - lineIndex, - charIndex, - topOffset = 0, - leftOffset = 0, - boundaries, - cursorPosition = this.get2DCursorLocation(index); - charIndex = cursorPosition.charIndex; - lineIndex = cursorPosition.lineIndex; - for (var i = 0; i < lineIndex; i++) { - topOffset += this.getHeightOfLine(i); - } - lineLeftOffset = this._getLineLeftOffset(lineIndex); - var bound = this.__charBounds[lineIndex][charIndex]; - bound && (leftOffset = bound.left); + } + realLineHeight = lineHeight; + if (this.lineHeight < 1 || (i === endLine && this.lineHeight > 1)) { + lineHeight /= this.lineHeight; + } + let drawStart = boundaries.left + lineOffset + boxStart, + drawHeight = lineHeight, + extraTop = 0; + const drawWidth = boxEnd - boxStart; + if (this.inCompositionMode) { + ctx.fillStyle = this.compositionColor || 'black'; + drawHeight = 1; + extraTop = lineHeight; + } else { + ctx.fillStyle = this.selectionColor; + } + if (this.direction === 'rtl') { if ( - this.charSpacing !== 0 && - charIndex === this._textLines[lineIndex].length + this.textAlign === 'right' || + this.textAlign === 'justify' || + this.textAlign === 'justify-right' ) { - leftOffset -= this._getWidthOfCharSpacing(); - } - boundaries = { - top: topOffset, - left: lineLeftOffset + (leftOffset > 0 ? leftOffset : 0), - }; - if (this.direction === 'rtl') { - if ( - this.textAlign === 'right' || - this.textAlign === 'justify' || - this.textAlign === 'justify-right' - ) { - boundaries.left *= -1; - } else if ( - this.textAlign === 'left' || - this.textAlign === 'justify-left' - ) { - boundaries.left = - lineLeftOffset - (leftOffset > 0 ? leftOffset : 0); - } else if ( - this.textAlign === 'center' || - this.textAlign === 'justify-center' - ) { - boundaries.left = - lineLeftOffset - (leftOffset > 0 ? leftOffset : 0); - } - } - return boundaries; - }, - - /** - * Renders cursor - * @param {Object} boundaries - * @param {CanvasRenderingContext2D} ctx transformed context to draw on - */ - renderCursor: function (ctx, boundaries) { - this._renderCursor(ctx, boundaries, this.selectionStart); - }, - - _renderCursor: function (ctx, boundaries, selectionStart) { - var cursorLocation = this.get2DCursorLocation(selectionStart), - lineIndex = cursorLocation.lineIndex, - charIndex = - cursorLocation.charIndex > 0 ? cursorLocation.charIndex - 1 : 0, - charHeight = this.getValueOfPropertyAt( - lineIndex, - charIndex, - 'fontSize' - ), - multiplier = this.scaleX * this.canvas.getZoom(), - cursorWidth = this.cursorWidth / multiplier, - topOffset = boundaries.topOffset, - dy = this.getValueOfPropertyAt(lineIndex, charIndex, 'deltaY'); - topOffset += - ((1 - this._fontSizeFraction) * this.getHeightOfLine(lineIndex)) / - this.lineHeight - - charHeight * (1 - this._fontSizeFraction); - - if (this.inCompositionMode) { - // TODO: investigate why there isn't a return inside the if, - // and why can't happe top of the function - this.renderSelection(ctx, boundaries); - } - ctx.fillStyle = - this.cursorColor || - this.getValueOfPropertyAt(lineIndex, charIndex, 'fill'); - ctx.globalAlpha = this.__isMousedown ? 1 : this._currentCursorOpacity; - ctx.fillRect( - boundaries.left + boundaries.leftOffset - cursorWidth / 2, - topOffset + boundaries.top + dy, - cursorWidth, - charHeight - ); - }, - - /** - * Renders text selection - * @param {Object} boundaries Object with left/top/leftOffset/topOffset - * @param {CanvasRenderingContext2D} ctx transformed context to draw on - */ - renderSelection: function (ctx, boundaries) { - var selection = { - selectionStart: this.inCompositionMode - ? this.hiddenTextarea.selectionStart - : this.selectionStart, - selectionEnd: this.inCompositionMode - ? this.hiddenTextarea.selectionEnd - : this.selectionEnd, - }; - this._renderSelection(ctx, selection, boundaries); - }, - - /** - * Renders drag start text selection - */ - renderDragSourceEffect: function () { - if ( - this.__isDragging && - this.__dragStartSelection && - this.__dragStartSelection + drawStart = this.width - drawStart - drawWidth; + } else if ( + this.textAlign === 'left' || + this.textAlign === 'justify-left' ) { - this._renderSelection( - this.canvas.contextTop, - this.__dragStartSelection, - this._getCursorBoundaries( - this.__dragStartSelection.selectionStart, - true - ) - ); - } - }, - - renderDropTargetEffect: function (e) { - var dragSelection = this.getSelectionStartFromPointer(e); - this.renderCursorAt(dragSelection); - }, - - /** - * Renders text selection - * @private - * @param {{ selectionStart: number, selectionEnd: number }} selection - * @param {Object} boundaries Object with left/top/leftOffset/topOffset - * @param {CanvasRenderingContext2D} ctx transformed context to draw on - */ - _renderSelection: function (ctx, selection, boundaries) { - var selectionStart = selection.selectionStart, - selectionEnd = selection.selectionEnd, - isJustify = this.textAlign.indexOf('justify') !== -1, - start = this.get2DCursorLocation(selectionStart), - end = this.get2DCursorLocation(selectionEnd), - startLine = start.lineIndex, - endLine = end.lineIndex, - startChar = start.charIndex < 0 ? 0 : start.charIndex, - endChar = end.charIndex < 0 ? 0 : end.charIndex; - - for (var i = startLine; i <= endLine; i++) { - var lineOffset = this._getLineLeftOffset(i) || 0, - lineHeight = this.getHeightOfLine(i), - realLineHeight = 0, - boxStart = 0, - boxEnd = 0; - - if (i === startLine) { - boxStart = this.__charBounds[startLine][startChar].left; - } - if (i >= startLine && i < endLine) { - boxEnd = - isJustify && !this.isEndOfWrapping(i) - ? this.width - : this.getLineWidth(i) || 5; // WTF is this 5? - } else if (i === endLine) { - if (endChar === 0) { - boxEnd = this.__charBounds[endLine][endChar].left; - } else { - var charSpacing = this._getWidthOfCharSpacing(); - boxEnd = - this.__charBounds[endLine][endChar - 1].left + - this.__charBounds[endLine][endChar - 1].width - - charSpacing; - } - } - realLineHeight = lineHeight; - if (this.lineHeight < 1 || (i === endLine && this.lineHeight > 1)) { - lineHeight /= this.lineHeight; - } - var drawStart = boundaries.left + lineOffset + boxStart, - drawWidth = boxEnd - boxStart, - drawHeight = lineHeight, - extraTop = 0; - if (this.inCompositionMode) { - ctx.fillStyle = this.compositionColor || 'black'; - drawHeight = 1; - extraTop = lineHeight; - } else { - ctx.fillStyle = this.selectionColor; - } - if (this.direction === 'rtl') { - if ( - this.textAlign === 'right' || - this.textAlign === 'justify' || - this.textAlign === 'justify-right' - ) { - drawStart = this.width - drawStart - drawWidth; - } else if ( - this.textAlign === 'left' || - this.textAlign === 'justify-left' - ) { - drawStart = boundaries.left + lineOffset - boxEnd; - } else if ( - this.textAlign === 'center' || - this.textAlign === 'justify-center' - ) { - drawStart = boundaries.left + lineOffset - boxEnd; - } - } - ctx.fillRect( - drawStart, - boundaries.top + boundaries.topOffset + extraTop, - drawWidth, - drawHeight - ); - boundaries.topOffset += realLineHeight; + drawStart = boundaries.left + lineOffset - boxEnd; + } else if ( + this.textAlign === 'center' || + this.textAlign === 'justify-center' + ) { + drawStart = boundaries.left + lineOffset - boxEnd; } - }, - - /** - * High level function to know the height of the cursor. - * the currentChar is the one that precedes the cursor - * Returns fontSize of char at the current cursor - * Unused from the library, is for the end user - * @return {Number} Character font size - */ - getCurrentCharFontSize: function () { - var cp = this._getCurrentCharIndex(); - return this.getValueOfPropertyAt(cp.l, cp.c, 'fontSize'); - }, - - /** - * High level function to know the color of the cursor. - * the currentChar is the one that precedes the cursor - * Returns color (fill) of char at the current cursor - * if the text object has a pattern or gradient for filler, it will return that. - * Unused by the library, is for the end user - * @return {String | fabric.Gradient | fabric.Pattern} Character color (fill) - */ - getCurrentCharColor: function () { - var cp = this._getCurrentCharIndex(); - return this.getValueOfPropertyAt(cp.l, cp.c, 'fill'); - }, - - /** - * Returns the cursor position for the getCurrent.. functions - * @private - */ - _getCurrentCharIndex: function () { - var cursorPosition = this.get2DCursorLocation( - this.selectionStart, - true - ), - charIndex = - cursorPosition.charIndex > 0 ? cursorPosition.charIndex - 1 : 0; - return { l: cursorPosition.lineIndex, c: charIndex }; - }, + } + ctx.fillRect( + drawStart, + boundaries.top + boundaries.topOffset + extraTop, + drawWidth, + drawHeight + ); + boundaries.topOffset += realLineHeight; } - ); + } + + /** + * High level function to know the height of the cursor. + * the currentChar is the one that precedes the cursor + * Returns fontSize of char at the current cursor + * Unused from the library, is for the end user + * @return {Number} Character font size + */ + getCurrentCharFontSize(): number { + const cp = this._getCurrentCharIndex(); + return this.getValueOfPropertyAt(cp.l, cp.c, 'fontSize'); + } + + /** + * High level function to know the color of the cursor. + * the currentChar is the one that precedes the cursor + * Returns color (fill) of char at the current cursor + * if the text object has a pattern or gradient for filler, it will return that. + * Unused by the library, is for the end user + * @return {String | TFiller} Character color (fill) + */ + getCurrentCharColor(): string | TFiller { + const cp = this._getCurrentCharIndex(); + return this.getValueOfPropertyAt(cp.l, cp.c, 'fill'); + } + + /** + * Returns the cursor position for the getCurrent.. functions + * @private + */ + _getCurrentCharIndex() { + const cursorPosition = this.get2DCursorLocation(this.selectionStart, true), + charIndex = + cursorPosition.charIndex > 0 ? cursorPosition.charIndex - 1 : 0; + return { l: cursorPosition.lineIndex, c: charIndex }; + } /** - * Returns fabric.IText instance from an object representation + * Returns IText instance from an object representation * @static - * @memberOf fabric.IText + * @memberOf IText * @param {Object} object Object to create an instance from - * @returns {Promise} + * @returns {Promise} */ - fabric.IText.fromObject = function (object) { - var styles = fabric.util.stylesFromArray(object.styles, object.text); + static fromObject(object: object): Promise { + const styles = stylesFromArray(object.styles, object.text); //copy object to prevent mutation - var objCopy = Object.assign({}, object, { styles: styles }); - return FabricObject._fromObject(fabric.IText, objCopy, { + const objCopy = Object.assign({}, object, { styles: styles }); + return FabricObject._fromObject(IText, objCopy, { extraParam: 'text', }); - }; -})(typeof exports !== 'undefined' ? exports : window); + } +} + +export const iTextDefaultValues: Partial> = { + type: 'i-text', + selectionStart: 0, + selectionEnd: 0, + selectionColor: 'rgba(17,119,255,0.3)', + isEditing: false, + editable: true, + editingBorderColor: 'rgba(102,153,255,0.25)', + cursorWidth: 2, + cursorColor: '', + cursorDelay: 1000, + cursorDuration: 600, + caching: true, + hiddenTextareaContainer: null, + _reSpace: /\s|\n/, + _currentCursorOpacity: 1, + _selectionDirection: null, + inCompositionMode: false, +}; + +Object.assign(IText.prototype, iTextDefaultValues); + +fabric.IText = IText; diff --git a/src/shapes/text.class.ts b/src/shapes/text.class.ts index 3621abc7504..0489bb00b37 100644 --- a/src/shapes/text.class.ts +++ b/src/shapes/text.class.ts @@ -1,1813 +1,1770 @@ -//@ts-nocheck - +// @ts-nocheck +import { fabric } from '../../HEADER'; import { cache } from '../cache'; import { DEFAULT_SVG_FONT_SIZE } from '../constants'; +import { TextStyle, TextStyleMixin } from '../mixins/text_style.mixin'; +import { TClassProperties, TFiller } from '../typedefs'; +import { graphemeSplit } from '../util/lang_string'; +import { createCanvasElement } from '../util/misc/dom'; +import { + hasStyleChanged, + stylesFromArray, + stylesToArray, +} from '../util/misc/textStyles'; +import { getPathSegmentsInfo, getPointOnPath } from '../util/path'; +import { FabricObject } from './fabricObject.class'; +import { fabricObjectDefaultValues } from './object.class'; + +/** + * Measure and return the info of a single grapheme. + * needs the the info of previous graphemes already filled + * Override to customize measuring + */ +export type GraphemeBBox = { + width: number; + height: number; + kernedWidth: number; + left: number; + deltaY: number; +}; + +const additionalProps = [ + 'fontFamily', + 'fontWeight', + 'fontSize', + 'text', + 'underline', + 'overline', + 'linethrough', + 'textAlign', + 'fontStyle', + 'lineHeight', + 'textBackgroundColor', + 'charSpacing', + 'styles', + 'direction', + 'path', + 'pathStartOffset', + 'pathSide', + 'pathAlign', +]; + +/** + * Text class + * @class Text + * @extends FabricObject + * @return {Text} thisArg + * @tutorial {@link http://fabricjs.com/fabric-intro-part-2#text} + * @see {@link Text#initialize} for constructor definition + */ +export class Text extends TextStyleMixin { + /** + * Properties which when set cause object to change dimensions + * @type Array + * @private + */ + _dimensionAffectingProps: Partial>[]; -(function (global) { - var fabric = global.fabric || (global.fabric = {}); - - var additionalProps = ( - 'fontFamily fontWeight fontSize text underline overline linethrough' + - ' textAlign fontStyle lineHeight textBackgroundColor charSpacing styles' + - ' direction path pathStartOffset pathSide pathAlign' - ).split(' '); - - /** - * Text class - * @class fabric.Text - * @extends fabric.Object - * @return {fabric.Text} thisArg - * @tutorial {@link http://fabricjs.com/fabric-intro-part-2#text} - * @see {@link fabric.Text#initialize} for constructor definition - */ - fabric.Text = fabric.util.createClass( - fabric.Object, - /** @lends fabric.Text.prototype */ { - /** - * Properties which when set cause object to change dimensions - * @type Array - * @private - */ - _dimensionAffectingProps: [ - 'fontSize', - 'fontWeight', - 'fontFamily', - 'fontStyle', - 'lineHeight', - 'text', - 'charSpacing', - 'textAlign', - 'styles', - 'path', - 'pathStartOffset', - 'pathSide', - 'pathAlign', - ], - - /** - * @private - */ - _reNewline: /\r?\n/, - - /** - * Use this regular expression to filter for whitespaces that is not a new line. - * Mostly used when text is 'justify' aligned. - * @private - */ - _reSpacesAndTabs: /[ \t\r]/g, - - /** - * Use this regular expression to filter for whitespace that is not a new line. - * Mostly used when text is 'justify' aligned. - * @private - */ - _reSpaceAndTab: /[ \t\r]/, - - /** - * Use this regular expression to filter consecutive groups of non spaces. - * Mostly used when text is 'justify' aligned. - * @private - */ - _reWords: /\S+/g, - - /** - * Type of an object - * @type String - * @default - */ - type: 'text', - - /** - * Font size (in pixels) - * @type Number - * @default - */ - fontSize: 40, - - /** - * Font weight (e.g. bold, normal, 400, 600, 800) - * @type {(Number|String)} - * @default - */ - fontWeight: 'normal', - - /** - * Font family - * @type String - * @default - */ - fontFamily: 'Times New Roman', - - /** - * Text decoration underline. - * @type Boolean - * @default - */ - underline: false, - - /** - * Text decoration overline. - * @type Boolean - * @default - */ - overline: false, - - /** - * Text decoration linethrough. - * @type Boolean - * @default - */ - linethrough: false, - - /** - * Text alignment. Possible values: "left", "center", "right", "justify", - * "justify-left", "justify-center" or "justify-right". - * @type String - * @default - */ - textAlign: 'left', - - /** - * Font style . Possible values: "", "normal", "italic" or "oblique". - * @type String - * @default - */ - fontStyle: 'normal', - - /** - * Line height - * @type Number - * @default - */ - lineHeight: 1.16, - - /** - * Superscript schema object (minimum overlap) - * @type {Object} - * @default - */ - superscript: { - size: 0.6, // fontSize factor - baseline: -0.35, // baseline-shift factor (upwards) - }, - - /** - * Subscript schema object (minimum overlap) - * @type {Object} - * @default - */ - subscript: { - size: 0.6, // fontSize factor - baseline: 0.11, // baseline-shift factor (downwards) - }, - - /** - * Background color of text lines - * @type String - * @default - */ - textBackgroundColor: '', - - /** - * List of properties to consider when checking if - * state of an object is changed ({@link fabric.Object#hasStateChanged}) - * as well as for history (undo/redo) purposes - * @type Array - */ - stateProperties: - fabric.Object.prototype.stateProperties.concat(additionalProps), - - /** - * List of properties to consider when checking if cache needs refresh - * @type Array - */ - cacheProperties: - fabric.Object.prototype.cacheProperties.concat(additionalProps), - - /** - * When defined, an object is rendered via stroke and this property specifies its color. - * Backwards incompatibility note: This property was named "strokeStyle" until v1.1.6 - * @type String - * @default - */ - stroke: null, - - /** - * Shadow object representing shadow of this shape. - * Backwards incompatibility note: This property was named "textShadow" (String) until v1.2.11 - * @type fabric.Shadow - * @default - */ - shadow: null, - - /** - * fabric.Path that the text should follow. - * since 4.6.0 the path will be drawn automatically. - * if you want to make the path visible, give it a stroke and strokeWidth or fill value - * if you want it to be hidden, assign visible = false to the path. - * This feature is in BETA, and SVG import/export is not yet supported. - * @type fabric.Path - * @example - * var textPath = new fabric.Text('Text on a path', { - * top: 150, - * left: 150, - * textAlign: 'center', - * charSpacing: -50, - * path: new fabric.Path('M 0 0 C 50 -100 150 -100 200 0', { - * strokeWidth: 1, - * visible: false - * }), - * pathSide: 'left', - * pathStartOffset: 0 - * }); - * @default - */ - path: null, - - /** - * Offset amount for text path starting position - * Only used when text has a path - * @type Number - * @default - */ - pathStartOffset: 0, - - /** - * Which side of the path the text should be drawn on. - * Only used when text has a path - * @type {String} 'left|right' - * @default - */ - pathSide: 'left', - - /** - * How text is aligned to the path. This property determines - * the perpendicular position of each character relative to the path. - * (one of "baseline", "center", "ascender", "descender") - * This feature is in BETA, and its behavior may change - * @type String - * @default - */ - pathAlign: 'baseline', - - /** - * @private - */ - _fontSizeFraction: 0.222, - - /** - * @private - */ - offsets: { - underline: 0.1, - linethrough: -0.315, - overline: -0.88, - }, - - /** - * Text Line proportion to font Size (in pixels) - * @type Number - * @default - */ - _fontSizeMult: 1.13, - - /** - * additional space between characters - * expressed in thousands of em unit - * @type Number - * @default - */ - charSpacing: 0, - - /** - * Object containing character styles - top-level properties -> line numbers, - * 2nd-level properties - character numbers - * @type Object - * @default - */ - styles: null, - - /** - * Reference to a context to measure text char or couple of chars - * the cacheContext of the canvas will be used or a freshly created one if the object is not on canvas - * once created it will be referenced on fabric._measuringContext to avoid creating a canvas for every - * text object created. - * @type {CanvasRenderingContext2D} - * @default - */ - _measuringContext: null, - - /** - * Baseline shift, styles only, keep at 0 for the main text object - * @type {Number} - * @default - */ - deltaY: 0, - - /** - * WARNING: EXPERIMENTAL. NOT SUPPORTED YET - * determine the direction of the text. - * This has to be set manually together with textAlign and originX for proper - * experience. - * some interesting link for the future - * https://www.w3.org/International/questions/qa-bidi-unicode-controls - * @since 4.5.0 - * @type {String} 'ltr|rtl' - * @default - */ - direction: 'ltr', - - /** - * Array of properties that define a style unit (of 'styles'). - * @type {Array} - * @default - */ - _styleProperties: [ - 'stroke', - 'strokeWidth', - 'fill', - 'fontFamily', - 'fontSize', - 'fontWeight', - 'fontStyle', - 'underline', - 'overline', - 'linethrough', - 'deltaY', - 'textBackgroundColor', - ], - - /** - * contains characters bounding boxes - */ - __charBounds: [], - - /** - * use this size when measuring text. To avoid IE11 rounding errors - * @type {Number} - * @default - * @readonly - * @private - */ - CACHE_FONT_SIZE: 400, - - /** - * contains the min text width to avoid getting 0 - * @type {Number} - * @default - */ - MIN_TEXT_WIDTH: 2, - - /** - * Constructor - * @param {String} text Text string - * @param {Object} [options] Options object - * @return {fabric.Text} thisArg - */ - initialize: function (text, options) { - this.styles = options ? options.styles || {} : {}; - this.text = text; - this.__skipDimension = true; - this.callSuper('initialize', options); - if (this.path) { - this.setPathInfo(); - } - this.__skipDimension = false; - this.initDimensions(); - this.setCoords(); - this.setupState({ propertySet: '_dimensionAffectingProps' }); - }, - - /** - * If text has a path, it will add the extra information needed - * for path and text calculations - * @return {fabric.Text} thisArg - */ - setPathInfo: function () { - var path = this.path; - if (path) { - path.segmentsInfo = fabric.util.getPathSegmentsInfo(path.path); - } - }, - - /** - * Return a context for measurement of text string. - * if created it gets stored for reuse - * this is for internal use, please do not use it - * @private - * @param {String} text Text string - * @param {Object} [options] Options object - * @return {fabric.Text} thisArg - */ - getMeasuringContext: function () { - // if we did not return we have to measure something. - if (!fabric._measuringContext) { - fabric._measuringContext = - (this.canvas && this.canvas.contextCache) || - fabric.util.createCanvasElement().getContext('2d'); - } - return fabric._measuringContext; - }, - - /** - * @private - * Divides text into lines of text and lines of graphemes. - */ - _splitText: function () { - var newLines = this._splitTextIntoLines(this.text); - this.textLines = newLines.lines; - this._textLines = newLines.graphemeLines; - this._unwrappedTextLines = newLines._unwrappedLines; - this._text = newLines.graphemeText; - return newLines; - }, - - /** - * Initialize or update text dimensions. - * Updates this.width and this.height with the proper values. - * Does not return dimensions. - */ - initDimensions: function () { - if (this.__skipDimension) { - return; - } - this._splitText(); - this._clearCache(); - if (this.path) { - this.width = this.path.width; - this.height = this.path.height; - } else { - this.width = - this.calcTextWidth() || this.cursorWidth || this.MIN_TEXT_WIDTH; - this.height = this.calcTextHeight(); - } - if (this.textAlign.indexOf('justify') !== -1) { - // once text is measured we need to make space fatter to make justified text. - this.enlargeSpaces(); - } - this.saveState({ propertySet: '_dimensionAffectingProps' }); - }, - - /** - * Enlarge space boxes and shift the others - */ - enlargeSpaces: function () { - var diffSpace, - currentLineWidth, - numberOfSpaces, - accumulatedSpace, - line, - charBound, - spaces; - for (var i = 0, len = this._textLines.length; i < len; i++) { - if ( - this.textAlign !== 'justify' && - (i === len - 1 || this.isEndOfWrapping(i)) - ) { - continue; - } - accumulatedSpace = 0; - line = this._textLines[i]; - currentLineWidth = this.getLineWidth(i); - if ( - currentLineWidth < this.width && - (spaces = this.textLines[i].match(this._reSpacesAndTabs)) - ) { - numberOfSpaces = spaces.length; - diffSpace = (this.width - currentLineWidth) / numberOfSpaces; - for (var j = 0, jlen = line.length; j <= jlen; j++) { - charBound = this.__charBounds[i][j]; - if (this._reSpaceAndTab.test(line[j])) { - charBound.width += diffSpace; - charBound.kernedWidth += diffSpace; - charBound.left += accumulatedSpace; - accumulatedSpace += diffSpace; - } else { - charBound.left += accumulatedSpace; - } - } + /** + * @private + */ + _reNewline: RegExp; + + /** + * Use this regular expression to filter for whitespaces that is not a new line. + * Mostly used when text is 'justify' aligned. + * @private + */ + _reSpacesAndTabs: RegExp; + + /** + * Use this regular expression to filter for whitespace that is not a new line. + * Mostly used when text is 'justify' aligned. + * @private + */ + _reSpaceAndTab: RegExp; + + /** + * Use this regular expression to filter consecutive groups of non spaces. + * Mostly used when text is 'justify' aligned. + * @private + */ + _reWords: RegExp; + + text: string; + + /** + * Font size (in pixels) + * @type Number + * @default + */ + fontSize: number; + + /** + * Font weight (e.g. bold, normal, 400, 600, 800) + * @type {(Number|String)} + * @default + */ + fontWeight: string; + + /** + * Font family + * @type String + * @default + */ + fontFamily: string; + + /** + * Text decoration underline. + * @type Boolean + * @default + */ + underline: boolean; + + /** + * Text decoration overline. + * @type Boolean + * @default + */ + overline: boolean; + + /** + * Text decoration linethrough. + * @type Boolean + * @default + */ + linethrough: boolean; + + /** + * Text alignment. Possible values: "left", "center", "right", "justify", + * "justify-left", "justify-center" or "justify-right". + * @type String + * @default + */ + textAlign: string; + + /** + * Font style . Possible values: "", "normal", "italic" or "oblique". + * @type String + * @default + */ + fontStyle: string; + + /** + * Line height + * @type Number + * @default + */ + lineHeight: number; + + /** + * Superscript schema object (minimum overlap) + */ + superscript: { + /** + * fontSize factor + * @default 0.6 + */ + size: number; + /** + * baseline-shift factor (upwards) + * @default -0.35 + */ + baseline: number; + }; + + /** + * Subscript schema object (minimum overlap) + */ + subscript: { + /** + * fontSize factor + * @default 0.6 + */ + size: number; + /** + * baseline-shift factor (downwards) + * @default 0.11 + */ + baseline: number; + }; + + /** + * Background color of text lines + * @type String + * @default + */ + textBackgroundColor: string; + + styles: TextStyle; + + /** + * fabric.Path that the text should follow. + * since 4.6.0 the path will be drawn automatically. + * if you want to make the path visible, give it a stroke and strokeWidth or fill value + * if you want it to be hidden, assign visible = false to the path. + * This feature is in BETA, and SVG import/export is not yet supported. + * @type fabric.Path + * @example + * var textPath = new Text('Text on a path', { + * top: 150, + * left: 150, + * textAlign: 'center', + * charSpacing: -50, + * path: new fabric.Path('M 0 0 C 50 -100 150 -100 200 0', { + * strokeWidth: 1, + * visible: false + * }), + * pathSide: 'left', + * pathStartOffset: 0 + * }); + * @default + */ + path: FabricObject /* todo fabric.Path*/; + + /** + * Offset amount for text path starting position + * Only used when text has a path + * @type Number + * @default + */ + pathStartOffset: number; + + /** + * Which side of the path the text should be drawn on. + * Only used when text has a path + * @type {String} 'left|right' + * @default + */ + pathSide: string; + + /** + * How text is aligned to the path. This property determines + * the perpendicular position of each character relative to the path. + * (one of "baseline", "center", "ascender", "descender") + * This feature is in BETA, and its behavior may change + * @type String + * @default + */ + pathAlign: string; + + /** + * @private + */ + _fontSizeFraction: number; + + /** + * @private + */ + offsets: { underline: number; linethrough: number; overline: number }; + + /** + * Text Line proportion to font Size (in pixels) + * @type Number + * @default + */ + _fontSizeMult: number; + + /** + * additional space between characters + * expressed in thousands of em unit + * @type Number + * @default + */ + charSpacing: number; + + /** + * Reference to a context to measure text char or couple of chars + * the cacheContext of the canvas will be used or a freshly created one if the object is not on canvas + * once created it will be referenced on fabric._measuringContext to avoid creating a canvas for every + * text object created. + * @type {CanvasRenderingContext2D} + * @default + */ + _measuringContext: CanvasRenderingContext2D | null = null; + + /** + * Baseline shift, styles only, keep at 0 for the main text object + * @type {Number} + * @default + */ + deltaY: number; + + /** + * WARNING: EXPERIMENTAL. NOT SUPPORTED YET + * determine the direction of the text. + * This has to be set manually together with textAlign and originX for proper + * experience. + * some interesting link for the future + * https://www.w3.org/International/questions/qa-bidi-unicode-controls + * @since 4.5.0 + * @type {String} 'ltr|rtl' + * @default + */ + direction: string; + + /** + * Array of properties that define a style unit (of 'styles'). + * @type {Array} + * @default + */ + _styleProperties: (keyof this)[]; + + /** + * contains characters bounding boxes + */ + protected __charBounds: { + left: number; + width: number; + kernedWidth: number; + height: number; + }[] = []; + + /** + * use this size when measuring text. To avoid IE11 rounding errors + * @type {Number} + * @default + * @readonly + * @private + */ + CACHE_FONT_SIZE: number; + + /** + * contains the min text width to avoid getting 0 + * @type {Number} + * @default + */ + MIN_TEXT_WIDTH: number; + + /** + * contains the the text of the object, divided in lines as they are displayed + * on screen. Wrapping will divide the text independently of line breaks + * @type {string[]} + * @default + */ + textLines: string[]; + + /** + * same as textlines, but each line is an array of graphemes as split by splitByGrapheme + * @type {string[]} + * @default + */ + _textLines: string[][]; + + _unwrappedTextLines: string[][]; + _text: string[]; + cursorWidth: number; + __lineHeights: number[]; + __lineWidths: number[]; + _forceClearCache: boolean; + + initialized?: true; + + constructor(text: string, options: object): Text { + super({ ...options, text, styles: options?.styles || {} }); + this.initialized = true; + if (this.path) { + this.setPathInfo(); + } + this.initDimensions(); + this.setCoords(); + this.setupState({ propertySet: '_dimensionAffectingProps' }); + } + + /** + * If text has a path, it will add the extra information needed + * for path and text calculations + */ + setPathInfo() { + const path = this.path; + if (path) { + path.segmentsInfo = getPathSegmentsInfo(path.path); + } + } + + /** + * Return a context for measurement of text string. + * if created it gets stored for reuse + * this is for internal use, please do not use it + * @private + * @param {String} text Text string + * @param {Object} [options] Options object + */ + getMeasuringContext() { + if (!fabric._measuringContext) { + fabric._measuringContext = + (this.canvas && this.canvas.contextCache) || + createCanvasElement().getContext('2d'); + } + return fabric._measuringContext; + } + + /** + * @private + * Divides text into lines of text and lines of graphemes. + */ + _splitText() { + const newLines = this._splitTextIntoLines(this.text); + this.textLines = newLines.lines; + this._textLines = newLines.graphemeLines; + this._unwrappedTextLines = newLines._unwrappedLines; + this._text = newLines.graphemeText; + return newLines; + } + + /** + * Initialize or update text dimensions. + * Updates this.width and this.height with the proper values. + * Does not return dimensions. + */ + initDimensions() { + if (this.__skipDimension) { + return; + } + this._splitText(); + this._clearCache(); + if (this.path) { + this.width = this.path.width; + this.height = this.path.height; + } else { + this.width = + this.calcTextWidth() || this.cursorWidth || this.MIN_TEXT_WIDTH; + this.height = this.calcTextHeight(); + } + if (this.textAlign.indexOf('justify') !== -1) { + // once text is measured we need to make space fatter to make justified text. + this.enlargeSpaces(); + } + this.saveState({ propertySet: '_dimensionAffectingProps' }); + } + + /** + * Enlarge space boxes and shift the others + */ + enlargeSpaces() { + let diffSpace, + currentLineWidth, + numberOfSpaces, + accumulatedSpace, + line, + charBound, + spaces; + for (let i = 0, len = this._textLines.length; i < len; i++) { + if ( + this.textAlign !== 'justify' && + (i === len - 1 || this.isEndOfWrapping(i)) + ) { + continue; + } + accumulatedSpace = 0; + line = this._textLines[i]; + currentLineWidth = this.getLineWidth(i); + if ( + currentLineWidth < this.width && + (spaces = this.textLines[i].match(this._reSpacesAndTabs)) + ) { + numberOfSpaces = spaces.length; + diffSpace = (this.width - currentLineWidth) / numberOfSpaces; + for (let j = 0, jlen = line.length; j <= jlen; j++) { + charBound = this.__charBounds[i][j]; + if (this._reSpaceAndTab.test(line[j])) { + charBound.width += diffSpace; + charBound.kernedWidth += diffSpace; + charBound.left += accumulatedSpace; + accumulatedSpace += diffSpace; + } else { + charBound.left += accumulatedSpace; } } - }, - - /** - * Detect if the text line is ended with an hard break - * text and itext do not have wrapping, return false - * @return {Boolean} - */ - isEndOfWrapping: function (lineIndex) { - return lineIndex === this._textLines.length - 1; - }, - - /** - * Detect if a line has a linebreak and so we need to account for it when moving - * and counting style. - * It return always for text and Itext. - * @return Number - */ - missingNewlineOffset: function () { - return 1; - }, - - /** - * Returns string representation of an instance - * @return {String} String representation of text object - */ - toString: function () { - return ( - '#' - ); - }, - - /** - * Return the dimension and the zoom level needed to create a cache canvas - * big enough to host the object to be cached. - * @private - * @param {Object} dim.x width of object to be cached - * @param {Object} dim.y height of object to be cached - * @return {Object}.width width of canvas - * @return {Object}.height height of canvas - * @return {Object}.zoomX zoomX zoom value to unscale the canvas before drawing cache - * @return {Object}.zoomY zoomY zoom value to unscale the canvas before drawing cache - */ - _getCacheCanvasDimensions: function () { - var dims = this.callSuper('_getCacheCanvasDimensions'); - var fontSize = this.fontSize; - dims.width += fontSize * dims.zoomX; - dims.height += fontSize * dims.zoomY; - return dims; - }, - - /** - * @private - * @param {CanvasRenderingContext2D} ctx Context to render on - */ - _render: function (ctx) { - var path = this.path; - path && !path.isNotVisible() && path._render(ctx); - this._setTextStyles(ctx); - this._renderTextLinesBackground(ctx); - this._renderTextDecoration(ctx, 'underline'); - this._renderText(ctx); - this._renderTextDecoration(ctx, 'overline'); - this._renderTextDecoration(ctx, 'linethrough'); - }, - - /** - * @private - * @param {CanvasRenderingContext2D} ctx Context to render on - */ - _renderText: function (ctx) { - if (this.paintFirst === 'stroke') { - this._renderTextStroke(ctx); - this._renderTextFill(ctx); - } else { - this._renderTextFill(ctx); - this._renderTextStroke(ctx); - } - }, - - /** - * Set the font parameter of the context with the object properties or with charStyle - * @private - * @param {CanvasRenderingContext2D} ctx Context to render on - * @param {Object} [charStyle] object with font style properties - * @param {String} [charStyle.fontFamily] Font Family - * @param {Number} [charStyle.fontSize] Font size in pixels. ( without px suffix ) - * @param {String} [charStyle.fontWeight] Font weight - * @param {String} [charStyle.fontStyle] Font style (italic|normal) - */ - _setTextStyles: function (ctx, charStyle, forMeasuring) { - ctx.textBaseline = 'alphabetical'; + } + } + } + + /** + * Detect if the text line is ended with an hard break + * text and itext do not have wrapping, return false + * @return {Boolean} + */ + isEndOfWrapping(lineIndex: number): boolean { + return lineIndex === this._textLines.length - 1; + } + + /** + * Detect if a line has a linebreak and so we need to account for it when moving + * and counting style. + * It return always for text and Itext. + * @return Number + */ + missingNewlineOffset() { + return 1; + } + + /** + * Returns 2d representation (lineIndex and charIndex) of cursor + * @param {Number} selectionStart + * @param {Boolean} [skipWrapping] consider the location for unwrapped lines. useful to manage styles. + */ + get2DCursorLocation(selectionStart: number, skipWrapping?: boolean) { + const lines = skipWrapping ? this._unwrappedTextLines : this._textLines; + let i: number; + for (i = 0; i < lines.length; i++) { + if (selectionStart <= lines[i].length) { + return { + lineIndex: i, + charIndex: selectionStart, + }; + } + selectionStart -= lines[i].length + this.missingNewlineOffset(i); + } + return { + lineIndex: i - 1, + charIndex: + lines[i - 1].length < selectionStart + ? lines[i - 1].length + : selectionStart, + }; + } + + /** + * Returns string representation of an instance + * @return {String} String representation of text object + */ + toString(): string { + return ( + '#' + ); + } + + /** + * Return the dimension and the zoom level needed to create a cache canvas + * big enough to host the object to be cached. + * @private + * @param {Object} dim.x width of object to be cached + * @param {Object} dim.y height of object to be cached + * @return {Object}.width width of canvas + * @return {Object}.height height of canvas + * @return {Object}.zoomX zoomX zoom value to unscale the canvas before drawing cache + * @return {Object}.zoomY zoomY zoom value to unscale the canvas before drawing cache + */ + _getCacheCanvasDimensions(): object { + const dims = super._getCacheCanvasDimensions(); + const fontSize = this.fontSize; + dims.width += fontSize * dims.zoomX; + dims.height += fontSize * dims.zoomY; + return dims; + } + + /** + * @private + * @param {CanvasRenderingContext2D} ctx Context to render on + */ + _render(ctx: CanvasRenderingContext2D) { + const path = this.path; + path && !path.isNotVisible() && path._render(ctx); + this._setTextStyles(ctx); + this._renderTextLinesBackground(ctx); + this._renderTextDecoration(ctx, 'underline'); + this._renderText(ctx); + this._renderTextDecoration(ctx, 'overline'); + this._renderTextDecoration(ctx, 'linethrough'); + } + + /** + * @private + * @param {CanvasRenderingContext2D} ctx Context to render on + */ + _renderText(ctx: CanvasRenderingContext2D) { + if (this.paintFirst === 'stroke') { + this._renderTextStroke(ctx); + this._renderTextFill(ctx); + } else { + this._renderTextFill(ctx); + this._renderTextStroke(ctx); + } + } + + /** + * Set the font parameter of the context with the object properties or with charStyle + * @private + * @param {CanvasRenderingContext2D} ctx Context to render on + * @param {Object} [charStyle] object with font style properties + * @param {String} [charStyle.fontFamily] Font Family + * @param {Number} [charStyle.fontSize] Font size in pixels. ( without px suffix ) + * @param {String} [charStyle.fontWeight] Font weight + * @param {String} [charStyle.fontStyle] Font style (italic|normal) + */ + _setTextStyles( + ctx: CanvasRenderingContext2D, + charStyle: any, + forMeasuring?: boolean + ) { + ctx.textBaseline = 'alphabetical'; + if (this.path) { + switch (this.pathAlign) { + case 'center': + ctx.textBaseline = 'middle'; + break; + case 'ascender': + ctx.textBaseline = 'top'; + break; + case 'descender': + ctx.textBaseline = 'bottom'; + break; + } + } + ctx.font = this._getFontDeclaration(charStyle, forMeasuring); + } + + /** + * calculate and return the text Width measuring each line. + * @private + * @param {CanvasRenderingContext2D} ctx Context to render on + * @return {Number} Maximum width of Text object + */ + calcTextWidth(): number { + let maxWidth = this.getLineWidth(0); + + for (let i = 1, len = this._textLines.length; i < len; i++) { + const currentLineWidth = this.getLineWidth(i); + if (currentLineWidth > maxWidth) { + maxWidth = currentLineWidth; + } + } + return maxWidth; + } + + /** + * @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( + method: 'fillText' | 'strokeText', + ctx: CanvasRenderingContext2D, + line: string, + left: number, + top: number, + lineIndex: number + ) { + this._renderChars(method, ctx, line, left, top, lineIndex); + } + + /** + * Renders the text background for lines, taking care of style + * @private + * @param {CanvasRenderingContext2D} ctx Context to render on + */ + _renderTextLinesBackground(ctx: CanvasRenderingContext2D) { + if (!this.textBackgroundColor && !this.styleHas('textBackgroundColor')) { + return; + } + const originalFill = ctx.fillStyle, + leftOffset = this._getLeftOffset(); + let lineTopOffset = this._getTopOffset(); + + for (let i = 0, len = this._textLines.length; i < len; i++) { + const heightOfLine = this.getHeightOfLine(i); + if ( + !this.textBackgroundColor && + !this.styleHas('textBackgroundColor', i) + ) { + lineTopOffset += heightOfLine; + continue; + } + const jlen = this._textLines[i].length; + const lineLeftOffset = this._getLineLeftOffset(i); + let boxWidth = 0; + let boxStart = 0; + let drawStart; + let currentColor; + let lastColor = this.getValueOfPropertyAt(i, 0, 'textBackgroundColor'); + for (let j = 0; j < jlen; j++) { + const charBox = this.__charBounds[i][j]; + currentColor = this.getValueOfPropertyAt(i, j, 'textBackgroundColor'); if (this.path) { - switch (this.pathAlign) { - case 'center': - ctx.textBaseline = 'middle'; - break; - case 'ascender': - ctx.textBaseline = 'top'; - break; - case 'descender': - ctx.textBaseline = 'bottom'; - break; - } - } - ctx.font = this._getFontDeclaration(charStyle, forMeasuring); - }, - - /** - * calculate and return the text Width measuring each line. - * @private - * @param {CanvasRenderingContext2D} ctx Context to render on - * @return {Number} Maximum width of fabric.Text object - */ - calcTextWidth: function () { - var maxWidth = this.getLineWidth(0); - - for (var i = 1, len = this._textLines.length; i < len; i++) { - var currentLineWidth = this.getLineWidth(i); - if (currentLineWidth > maxWidth) { - maxWidth = currentLineWidth; - } - } - return maxWidth; - }, - - /** - * @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) { - this._renderChars(method, ctx, line, left, top, lineIndex); - }, - - /** - * Renders the text background for lines, taking care of style - * @private - * @param {CanvasRenderingContext2D} ctx Context to render on - */ - _renderTextLinesBackground: function (ctx) { - if ( - !this.textBackgroundColor && - !this.styleHas('textBackgroundColor') - ) { - return; - } - var heightOfLine, - lineLeftOffset, - originalFill = ctx.fillStyle, - line, - lastColor, - leftOffset = this._getLeftOffset(), - lineTopOffset = this._getTopOffset(), - boxStart = 0, - boxWidth = 0, - charBox, - currentColor, - path = this.path, - drawStart; - - for (var i = 0, len = this._textLines.length; i < len; i++) { - heightOfLine = this.getHeightOfLine(i); - if ( - !this.textBackgroundColor && - !this.styleHas('textBackgroundColor', i) - ) { - lineTopOffset += heightOfLine; - continue; - } - line = this._textLines[i]; - lineLeftOffset = this._getLineLeftOffset(i); - boxWidth = 0; - boxStart = 0; - lastColor = this.getValueOfPropertyAt(i, 0, 'textBackgroundColor'); - for (var j = 0, jlen = line.length; j < jlen; j++) { - charBox = this.__charBounds[i][j]; - currentColor = this.getValueOfPropertyAt( - i, - j, - 'textBackgroundColor' + ctx.save(); + ctx.translate(charBox.renderLeft, charBox.renderTop); + ctx.rotate(charBox.angle); + ctx.fillStyle = currentColor; + currentColor && + ctx.fillRect( + -charBox.width / 2, + (-heightOfLine / this.lineHeight) * (1 - this._fontSizeFraction), + charBox.width, + heightOfLine / this.lineHeight ); - if (path) { - ctx.save(); - ctx.translate(charBox.renderLeft, charBox.renderTop); - ctx.rotate(charBox.angle); - ctx.fillStyle = currentColor; - currentColor && - ctx.fillRect( - -charBox.width / 2, - (-heightOfLine / this.lineHeight) * - (1 - this._fontSizeFraction), - charBox.width, - heightOfLine / this.lineHeight - ); - ctx.restore(); - } else if (currentColor !== lastColor) { - drawStart = leftOffset + lineLeftOffset + boxStart; - if (this.direction === 'rtl') { - drawStart = this.width - drawStart - boxWidth; - } - ctx.fillStyle = lastColor; - lastColor && - ctx.fillRect( - drawStart, - lineTopOffset, - boxWidth, - heightOfLine / this.lineHeight - ); - boxStart = charBox.left; - boxWidth = charBox.width; - lastColor = currentColor; - } else { - boxWidth += charBox.kernedWidth; - } + ctx.restore(); + } else if (currentColor !== lastColor) { + drawStart = leftOffset + lineLeftOffset + boxStart; + if (this.direction === 'rtl') { + drawStart = this.width - drawStart - boxWidth; } - if (currentColor && !path) { - drawStart = leftOffset + lineLeftOffset + boxStart; - if (this.direction === 'rtl') { - drawStart = this.width - drawStart - boxWidth; - } - ctx.fillStyle = currentColor; + ctx.fillStyle = lastColor; + lastColor && ctx.fillRect( drawStart, lineTopOffset, boxWidth, heightOfLine / this.lineHeight ); - } - lineTopOffset += heightOfLine; - } - ctx.fillStyle = originalFill; - // if there is text background color no - // other shadows should be casted - this._removeShadow(ctx); - }, - - /** - * measure and return the width of a single character. - * possibly overridden to accommodate different measure logic or - * to hook some external lib for character measurement - * @private - * @param {String} _char, char to be measured - * @param {Object} charStyle style of char to be measured - * @param {String} [previousChar] previous char - * @param {Object} [prevCharStyle] style of previous char - */ - _measureChar: function (_char, charStyle, previousChar, prevCharStyle) { - // first i try to return from cache - var fontCache = cache.getFontCache(charStyle), - fontDeclaration = this._getFontDeclaration(charStyle), - previousFontDeclaration = this._getFontDeclaration(prevCharStyle), - couple = previousChar + _char, - stylesAreEqual = fontDeclaration === previousFontDeclaration, - width, - coupleWidth, - previousWidth, - fontMultiplier = charStyle.fontSize / this.CACHE_FONT_SIZE, - kernedWidth; - - if (previousChar && fontCache[previousChar] !== undefined) { - previousWidth = fontCache[previousChar]; - } - if (fontCache[_char] !== undefined) { - kernedWidth = width = fontCache[_char]; - } - if (stylesAreEqual && fontCache[couple] !== undefined) { - coupleWidth = fontCache[couple]; - kernedWidth = coupleWidth - previousWidth; - } - if ( - width === undefined || - previousWidth === undefined || - coupleWidth === undefined - ) { - var ctx = this.getMeasuringContext(); - // send a TRUE to specify measuring font size CACHE_FONT_SIZE - this._setTextStyles(ctx, charStyle, true); - } - if (width === undefined) { - kernedWidth = width = ctx.measureText(_char).width; - fontCache[_char] = width; - } - if (previousWidth === undefined && stylesAreEqual && previousChar) { - previousWidth = ctx.measureText(previousChar).width; - fontCache[previousChar] = previousWidth; - } - if (stylesAreEqual && coupleWidth === undefined) { - // we can measure the kerning couple and subtract the width of the previous character - coupleWidth = ctx.measureText(couple).width; - fontCache[couple] = coupleWidth; - kernedWidth = coupleWidth - previousWidth; - } - return { - width: width * fontMultiplier, - kernedWidth: kernedWidth * fontMultiplier, - }; - }, - - /** - * Computes height of character at given position - * @param {Number} line the line index number - * @param {Number} _char the character index number - * @return {Number} fontSize of the character - */ - getHeightOfChar: function (line, _char) { - return this.getValueOfPropertyAt(line, _char, 'fontSize'); - }, - - /** - * measure a text line measuring all characters. - * @param {Number} lineIndex line number - * @return {Number} Line width - */ - measureLine: function (lineIndex) { - var lineInfo = this._measureLine(lineIndex); - if (this.charSpacing !== 0) { - lineInfo.width -= this._getWidthOfCharSpacing(); - } - if (lineInfo.width < 0) { - lineInfo.width = 0; - } - return lineInfo; - }, - - /** - * measure every grapheme of a line, populating __charBounds - * @param {Number} lineIndex - * @return {Object} object.width total width of characters - * @return {Object} object.widthOfSpaces length of chars that match this._reSpacesAndTabs - */ - _measureLine: function (lineIndex) { - var width = 0, - i, - grapheme, - line = this._textLines[lineIndex], - prevGrapheme, - graphemeInfo, - numOfSpaces = 0, - lineBounds = new Array(line.length), - positionInPath = 0, - startingPoint, - totalPathLength, - path = this.path, - reverse = this.pathSide === 'right'; - - this.__charBounds[lineIndex] = lineBounds; - for (i = 0; i < line.length; i++) { - grapheme = line[i]; - graphemeInfo = this._getGraphemeBox( - grapheme, - lineIndex, - i, - prevGrapheme - ); - lineBounds[i] = graphemeInfo; - width += graphemeInfo.kernedWidth; - prevGrapheme = grapheme; + boxStart = charBox.left; + boxWidth = charBox.width; + lastColor = currentColor; + } else { + boxWidth += charBox.kernedWidth; } - // this latest bound box represent the last character of the line - // to simplify cursor handling in interactive mode. - lineBounds[i] = { - left: graphemeInfo ? graphemeInfo.left + graphemeInfo.width : 0, - width: 0, - kernedWidth: 0, - height: this.fontSize, - }; - if (path) { - totalPathLength = - path.segmentsInfo[path.segmentsInfo.length - 1].length; - startingPoint = fabric.util.getPointOnPath( - path.path, - 0, - path.segmentsInfo - ); - startingPoint.x += path.pathOffset.x; - startingPoint.y += path.pathOffset.y; - switch (this.textAlign) { - case 'left': - positionInPath = reverse ? totalPathLength - width : 0; - break; - case 'center': - positionInPath = (totalPathLength - width) / 2; - break; - case 'right': - positionInPath = reverse ? 0 : totalPathLength - width; - break; - //todo - add support for justify - } - positionInPath += this.pathStartOffset * (reverse ? -1 : 1); - for ( - i = reverse ? line.length - 1 : 0; - reverse ? i >= 0 : i < line.length; - reverse ? i-- : i++ - ) { - graphemeInfo = lineBounds[i]; - if (positionInPath > totalPathLength) { - positionInPath %= totalPathLength; - } else if (positionInPath < 0) { - positionInPath += totalPathLength; - } - // it would probably much faster to send all the grapheme position for a line - // and calculate path position/angle at once. - this._setGraphemeOnPath( - positionInPath, - graphemeInfo, - startingPoint - ); - positionInPath += graphemeInfo.kernedWidth; - } + } + if (currentColor && !this.path) { + drawStart = leftOffset + lineLeftOffset + boxStart; + if (this.direction === 'rtl') { + drawStart = this.width - drawStart - boxWidth; } - return { width: width, numOfSpaces: numOfSpaces }; - }, - - /** - * Calculate the angle and the left,top position of the char that follow a path. - * It appends it to graphemeInfo to be reused later at rendering - * @private - * @param {Number} positionInPath to be measured - * @param {Object} graphemeInfo current grapheme box information - * @param {Object} startingPoint position of the point - */ - _setGraphemeOnPath: function ( - positionInPath, - graphemeInfo, - startingPoint - ) { - var centerPosition = positionInPath + graphemeInfo.kernedWidth / 2, - path = this.path; - - // we are at currentPositionOnPath. we want to know what point on the path is. - var info = fabric.util.getPointOnPath( - path.path, - centerPosition, - path.segmentsInfo + ctx.fillStyle = currentColor; + ctx.fillRect( + drawStart, + lineTopOffset, + boxWidth, + heightOfLine / this.lineHeight ); - graphemeInfo.renderLeft = info.x - startingPoint.x; - graphemeInfo.renderTop = info.y - startingPoint.y; - graphemeInfo.angle = - info.angle + (this.pathSide === 'right' ? Math.PI : 0); - }, - - /** - * Measure and return the info of a single grapheme. - * needs the the info of previous graphemes already filled - * Override to customize measuring - * - * @typedef {object} GraphemeBBox - * @property {number} width - * @property {number} height - * @property {number} kernedWidth - * @property {number} left - * @property {number} deltaY - * - * @param {String} grapheme to be measured - * @param {Number} lineIndex index of the line where the char is - * @param {Number} charIndex position in the line - * @param {String} [prevGrapheme] character preceding the one to be measured - * @returns {GraphemeBBox} grapheme bbox - */ - _getGraphemeBox: function ( - grapheme, - lineIndex, - charIndex, - prevGrapheme, - skipLeft + } + lineTopOffset += heightOfLine; + } + ctx.fillStyle = originalFill; + // if there is text background color no + // other shadows should be casted + this._removeShadow(ctx); + } + + /** + * measure and return the width of a single character. + * possibly overridden to accommodate different measure logic or + * to hook some external lib for character measurement + * @private + * @param {String} _char, char to be measured + * @param {Object} charStyle style of char to be measured + * @param {String} [previousChar] previous char + * @param {Object} [prevCharStyle] style of previous char + */ + _measureChar( + _char: string, + charStyle: object, + previousChar: string, + prevCharStyle: object + ) { + const fontCache = cache.getFontCache(charStyle), + fontDeclaration = this._getFontDeclaration(charStyle), + previousFontDeclaration = this._getFontDeclaration(prevCharStyle), + couple = previousChar + _char, + stylesAreEqual = fontDeclaration === previousFontDeclaration, + fontMultiplier = charStyle.fontSize / this.CACHE_FONT_SIZE; + let width, coupleWidth, previousWidth, kernedWidth, ctx; + + if (previousChar && fontCache[previousChar] !== undefined) { + previousWidth = fontCache[previousChar]; + } + if (fontCache[_char] !== undefined) { + kernedWidth = width = fontCache[_char]; + } + if (stylesAreEqual && fontCache[couple] !== undefined) { + coupleWidth = fontCache[couple]; + kernedWidth = coupleWidth - previousWidth; + } + if ( + width === undefined || + previousWidth === undefined || + coupleWidth === undefined + ) { + ctx = this.getMeasuringContext(); + // send a TRUE to specify measuring font size CACHE_FONT_SIZE + this._setTextStyles(ctx, charStyle, true); + if (width === undefined) { + kernedWidth = width = ctx.measureText(_char).width; + fontCache[_char] = width; + } + if (previousWidth === undefined && stylesAreEqual && previousChar) { + previousWidth = ctx.measureText(previousChar).width; + fontCache[previousChar] = previousWidth; + } + if (stylesAreEqual && coupleWidth === undefined) { + // we can measure the kerning couple and subtract the width of the previous character + coupleWidth = ctx.measureText(couple).width; + fontCache[couple] = coupleWidth; + kernedWidth = coupleWidth - previousWidth; + } + } + return { + width: width * fontMultiplier, + kernedWidth: kernedWidth * fontMultiplier, + }; + } + + /** + * Computes height of character at given position + * @param {Number} line the line index number + * @param {Number} _char the character index number + * @return {Number} fontSize of the character + */ + getHeightOfChar(line: number, _char: number): number { + return this.getValueOfPropertyAt(line, _char, 'fontSize'); + } + + /** + * measure a text line measuring all characters. + * @param {Number} lineIndex line number + * @return {Number} Line width + */ + measureLine(lineIndex: number): number { + const lineInfo = this._measureLine(lineIndex); + if (this.charSpacing !== 0) { + lineInfo.width -= this._getWidthOfCharSpacing(); + } + if (lineInfo.width < 0) { + lineInfo.width = 0; + } + return lineInfo; + } + + /** + * measure every grapheme of a line, populating __charBounds + * @param {Number} lineIndex + * @return {Object} object.width total width of characters + * @return {Object} object.numOfSpaces length of chars that match this._reSpacesAndTabs + */ + _measureLine(lineIndex: number): object { + let width = 0, + prevGrapheme, + graphemeInfo; + + const reverse = this.pathSide === 'right', + path = this.path, + line = this._textLines[lineIndex], + llength = line.length, + lineBounds = new Array(llength); + + this.__charBounds[lineIndex] = lineBounds; + for (let i = 0; i < llength; i++) { + const grapheme = line[i]; + graphemeInfo = this._getGraphemeBox(grapheme, lineIndex, i, prevGrapheme); + lineBounds[i] = graphemeInfo; + width += graphemeInfo.kernedWidth; + prevGrapheme = grapheme; + } + // this latest bound box represent the last character of the line + // to simplify cursor handling in interactive mode. + lineBounds[llength] = { + left: graphemeInfo ? graphemeInfo.left + graphemeInfo.width : 0, + width: 0, + kernedWidth: 0, + height: this.fontSize, + }; + if (path) { + let positionInPath = 0; + const totalPathLength = + path.segmentsInfo[path.segmentsInfo.length - 1].length; + const startingPoint = getPointOnPath(path.path, 0, path.segmentsInfo); + startingPoint.x += path.pathOffset.x; + startingPoint.y += path.pathOffset.y; + switch (this.textAlign) { + case 'left': + positionInPath = reverse ? totalPathLength - width : 0; + break; + case 'center': + positionInPath = (totalPathLength - width) / 2; + break; + case 'right': + positionInPath = reverse ? 0 : totalPathLength - width; + break; + //todo - add support for justify + } + positionInPath += this.pathStartOffset * (reverse ? -1 : 1); + for ( + let i = reverse ? llength - 1 : 0; + reverse ? i >= 0 : i < llength; + reverse ? i-- : i++ ) { - var style = this.getCompleteStyleDeclaration(lineIndex, charIndex), - prevStyle = prevGrapheme - ? this.getCompleteStyleDeclaration(lineIndex, charIndex - 1) - : {}, - info = this._measureChar(grapheme, style, prevGrapheme, prevStyle), - kernedWidth = info.kernedWidth, - width = info.width, - charSpacing; - - if (this.charSpacing !== 0) { - charSpacing = this._getWidthOfCharSpacing(); - width += charSpacing; - kernedWidth += charSpacing; + graphemeInfo = lineBounds[i]; + if (positionInPath > totalPathLength) { + positionInPath %= totalPathLength; + } else if (positionInPath < 0) { + positionInPath += totalPathLength; } + // it would probably much faster to send all the grapheme position for a line + // and calculate path position/angle at once. + this._setGraphemeOnPath(positionInPath, graphemeInfo, startingPoint); + positionInPath += graphemeInfo.kernedWidth; + } + } + return { width: width, numOfSpaces: 0 }; + } - var box = { - width: width, - left: 0, - height: style.fontSize, - kernedWidth: kernedWidth, - deltaY: style.deltaY, - }; - if (charIndex > 0 && !skipLeft) { - var previousBox = this.__charBounds[lineIndex][charIndex - 1]; - box.left = - previousBox.left + - previousBox.width + - info.kernedWidth - - info.width; - } - return box; - }, - - /** - * Calculate height of line at 'lineIndex' - * @param {Number} lineIndex index of line to calculate - * @return {Number} - */ - getHeightOfLine: function (lineIndex) { - if (this.__lineHeights[lineIndex]) { - return this.__lineHeights[lineIndex]; - } + /** + * Calculate the angle and the left,top position of the char that follow a path. + * It appends it to graphemeInfo to be reused later at rendering + * @private + * @param {Number} positionInPath to be measured + * @param {GraphemeBBox} graphemeInfo current grapheme box information + * @param {Object} startingPoint position of the point + */ + _setGraphemeOnPath( + positionInPath: number, + graphemeInfo: GraphemeBBox, + startingPoint + ) { + const centerPosition = positionInPath + graphemeInfo.kernedWidth / 2, + path = this.path; + + // we are at currentPositionOnPath. we want to know what point on the path is. + const info = getPointOnPath(path.path, centerPosition, path.segmentsInfo); + graphemeInfo.renderLeft = info.x - startingPoint.x; + graphemeInfo.renderTop = info.y - startingPoint.y; + graphemeInfo.angle = info.angle + (this.pathSide === 'right' ? Math.PI : 0); + } - var line = this._textLines[lineIndex], - // char 0 is measured before the line cycle because it nneds to char - // emptylines - maxHeight = this.getHeightOfChar(lineIndex, 0); - for (var i = 1, len = line.length; i < len; i++) { - maxHeight = Math.max(this.getHeightOfChar(lineIndex, i), maxHeight); - } + /** + * + * @param {String} grapheme to be measured + * @param {Number} lineIndex index of the line where the char is + * @param {Number} charIndex position in the line + * @param {String} [prevGrapheme] character preceding the one to be measured + * @returns {GraphemeBBox} grapheme bbox + */ + _getGraphemeBox( + grapheme: string, + lineIndex: number, + charIndex: number, + prevGrapheme: string, + skipLeft + ): GraphemeBBox { + const style = this.getCompleteStyleDeclaration(lineIndex, charIndex), + prevStyle = prevGrapheme + ? this.getCompleteStyleDeclaration(lineIndex, charIndex - 1) + : {}, + info = this._measureChar(grapheme, style, prevGrapheme, prevStyle); + let kernedWidth = info.kernedWidth, + width = info.width, + charSpacing; + + if (this.charSpacing !== 0) { + charSpacing = this._getWidthOfCharSpacing(); + width += charSpacing; + kernedWidth += charSpacing; + } - return (this.__lineHeights[lineIndex] = - maxHeight * this.lineHeight * this._fontSizeMult); - }, - - /** - * Calculate text box height - */ - calcTextHeight: function () { - var lineHeight, - height = 0; - for (var i = 0, len = this._textLines.length; i < len; i++) { - lineHeight = this.getHeightOfLine(i); - height += i === len - 1 ? lineHeight / this.lineHeight : lineHeight; - } - return height; - }, - - /** - * @private - * @return {Number} Left offset - */ - _getLeftOffset: function () { - return this.direction === 'ltr' ? -this.width / 2 : this.width / 2; - }, - - /** - * @private - * @return {Number} Top offset - */ - _getTopOffset: function () { - return -this.height / 2; - }, - - /** - * @private - * @param {CanvasRenderingContext2D} ctx Context to render on - * @param {String} method Method name ("fillText" or "strokeText") - */ - _renderTextCommon: function (ctx, method) { - ctx.save(); - var lineHeights = 0, - left = this._getLeftOffset(), - top = this._getTopOffset(); - for (var i = 0, len = this._textLines.length; i < len; i++) { - var heightOfLine = this.getHeightOfLine(i), - maxHeight = heightOfLine / this.lineHeight, - leftOffset = this._getLineLeftOffset(i); - this._renderTextLine( - method, - ctx, - this._textLines[i], - left + leftOffset, - top + lineHeights + maxHeight, - i - ); - lineHeights += heightOfLine; - } - ctx.restore(); - }, - - /** - * @private - * @param {CanvasRenderingContext2D} ctx Context to render on - */ - _renderTextFill: function (ctx) { - if (!this.fill && !this.styleHas('fill')) { - return; - } + const box: GraphemeBBox = { + width: width, + left: 0, + height: style.fontSize, + kernedWidth: kernedWidth, + deltaY: style.deltaY, + }; + if (charIndex > 0 && !skipLeft) { + const previousBox = this.__charBounds[lineIndex][charIndex - 1]; + box.left = + previousBox.left + previousBox.width + info.kernedWidth - info.width; + } + return box; + } - this._renderTextCommon(ctx, 'fillText'); - }, + /** + * Calculate height of line at 'lineIndex' + * @param {Number} lineIndex index of line to calculate + * @return {Number} + */ + getHeightOfLine(lineIndex: number): number { + if (this.__lineHeights[lineIndex]) { + return this.__lineHeights[lineIndex]; + } - /** - * @private - * @param {CanvasRenderingContext2D} ctx Context to render on - */ - _renderTextStroke: function (ctx) { - if ((!this.stroke || this.strokeWidth === 0) && this.isEmptyStyles()) { - return; - } + // char 0 is measured before the line cycle because it nneds to char + // emptylines + let maxHeight = this.getHeightOfChar(lineIndex, 0); + for (let i = 1, len = this._textLines[lineIndex].length; i < len; i++) { + maxHeight = Math.max(this.getHeightOfChar(lineIndex, i), maxHeight); + } - if (this.shadow && !this.shadow.affectStroke) { - this._removeShadow(ctx); - } + return (this.__lineHeights[lineIndex] = + maxHeight * this.lineHeight * this._fontSizeMult); + } + + /** + * Calculate text box height + */ + calcTextHeight() { + let lineHeight, + height = 0; + for (let i = 0, len = this._textLines.length; i < len; i++) { + lineHeight = this.getHeightOfLine(i); + height += i === len - 1 ? lineHeight / this.lineHeight : lineHeight; + } + return height; + } + + /** + * @private + * @return {Number} Left offset + */ + _getLeftOffset(): number { + return this.direction === 'ltr' ? -this.width / 2 : this.width / 2; + } + + /** + * @private + * @return {Number} Top offset + */ + _getTopOffset(): number { + return -this.height / 2; + } + + /** + * @private + * @param {CanvasRenderingContext2D} ctx Context to render on + * @param {String} method Method name ("fillText" or "strokeText") + */ + _renderTextCommon( + ctx: CanvasRenderingContext2D, + method: 'fillText' | 'strokeText' + ) { + ctx.save(); + let lineHeights = 0; + const left = this._getLeftOffset(), + top = this._getTopOffset(); + for (let i = 0, len = this._textLines.length; i < len; i++) { + const heightOfLine = this.getHeightOfLine(i), + maxHeight = heightOfLine / this.lineHeight, + leftOffset = this._getLineLeftOffset(i); + this._renderTextLine( + method, + ctx, + this._textLines[i], + left + leftOffset, + top + lineHeights + maxHeight, + i + ); + lineHeights += heightOfLine; + } + ctx.restore(); + } + + /** + * @private + * @param {CanvasRenderingContext2D} ctx Context to render on + */ + _renderTextFill(ctx: CanvasRenderingContext2D) { + if (!this.fill && !this.styleHas('fill')) { + return; + } + + this._renderTextCommon(ctx, 'fillText'); + } + + /** + * @private + * @param {CanvasRenderingContext2D} ctx Context to render on + */ + _renderTextStroke(ctx: CanvasRenderingContext2D) { + 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 - * @param {String} method fillText or strokeText. - * @param {CanvasRenderingContext2D} ctx Context to render on - * @param {Array} line Content of the line, splitted in an array by grapheme - * @param {Number} left - * @param {Number} top - * @param {Number} lineIndex - */ - _renderChars: function (method, ctx, line, left, top, lineIndex) { - // set proper line offset - var lineHeight = this.getHeightOfLine(lineIndex), - isJustify = this.textAlign.indexOf('justify') !== -1, - actualStyle, - nextStyle, - charsToRender = '', - charBox, - boxWidth = 0, - timeToRender, - path = this.path, - shortCut = - !isJustify && - this.charSpacing === 0 && - this.isEmptyStyles(lineIndex) && - !path, - isLtr = this.direction === 'ltr', - sign = this.direction === 'ltr' ? 1 : -1, - // this was changed in the PR #7674 - // currentDirection = ctx.canvas.getAttribute('dir'); - drawingLeft, - currentDirection = ctx.direction; - ctx.save(); - if (currentDirection !== this.direction) { - ctx.canvas.setAttribute('dir', isLtr ? 'ltr' : 'rtl'); - ctx.direction = isLtr ? 'ltr' : 'rtl'; - ctx.textAlign = isLtr ? 'left' : 'right'; + ctx.save(); + this._setLineDash(ctx, this.strokeDashArray); + ctx.beginPath(); + this._renderTextCommon(ctx, 'strokeText'); + ctx.closePath(); + ctx.restore(); + } + + /** + * @private + * @param {String} method fillText or strokeText. + * @param {CanvasRenderingContext2D} ctx Context to render on + * @param {Array} line Content of the line, splitted in an array by grapheme + * @param {Number} left + * @param {Number} top + * @param {Number} lineIndex + */ + _renderChars( + method: 'fillText' | 'strokeText', + ctx: CanvasRenderingContext2D, + line: Array, + left: number, + top: number, + lineIndex: number + ) { + const lineHeight = this.getHeightOfLine(lineIndex), + isJustify = this.textAlign.indexOf('justify') !== -1, + path = this.path, + shortCut = + !isJustify && + this.charSpacing === 0 && + this.isEmptyStyles(lineIndex) && + !path, + isLtr = this.direction === 'ltr', + sign = this.direction === 'ltr' ? 1 : -1, + // this was changed in the PR #7674 + // currentDirection = ctx.canvas.getAttribute('dir'); + currentDirection = ctx.direction; + + let actualStyle, + nextStyle, + charsToRender = '', + charBox, + boxWidth = 0, + timeToRender, + drawingLeft; + + ctx.save(); + if (currentDirection !== this.direction) { + ctx.canvas.setAttribute('dir', isLtr ? 'ltr' : 'rtl'); + ctx.direction = isLtr ? 'ltr' : 'rtl'; + ctx.textAlign = isLtr ? 'left' : 'right'; + } + top -= (lineHeight * this._fontSizeFraction) / this.lineHeight; + if (shortCut) { + // render all the line in one pass without checking + // drawingLeft = isLtr ? left : left - this.getLineWidth(lineIndex); + this._renderChar( + method, + ctx, + lineIndex, + 0, + line.join(''), + left, + top, + lineHeight + ); + ctx.restore(); + return; + } + for (let i = 0, len = line.length - 1; i <= len; i++) { + timeToRender = i === len || this.charSpacing || path; + charsToRender += line[i]; + charBox = this.__charBounds[lineIndex][i]; + if (boxWidth === 0) { + left += sign * (charBox.kernedWidth - charBox.width); + boxWidth += charBox.width; + } else { + boxWidth += charBox.kernedWidth; + } + if (isJustify && !timeToRender) { + if (this._reSpaceAndTab.test(line[i])) { + timeToRender = true; } - top -= (lineHeight * this._fontSizeFraction) / this.lineHeight; - if (shortCut) { - // render all the line in one pass without checking - // drawingLeft = isLtr ? left : left - this.getLineWidth(lineIndex); + } + if (!timeToRender) { + // if we have charSpacing, we render char by char + actualStyle = + actualStyle || this.getCompleteStyleDeclaration(lineIndex, i); + nextStyle = this.getCompleteStyleDeclaration(lineIndex, i + 1); + timeToRender = hasStyleChanged(actualStyle, nextStyle, false); + } + if (timeToRender) { + if (path) { + ctx.save(); + ctx.translate(charBox.renderLeft, charBox.renderTop); + ctx.rotate(charBox.angle); this._renderChar( method, ctx, lineIndex, + i, + charsToRender, + -boxWidth / 2, 0, - line.join(''), - left, - top, lineHeight ); ctx.restore(); - return; - } - for (var i = 0, len = line.length - 1; i <= len; i++) { - timeToRender = i === len || this.charSpacing || path; - charsToRender += line[i]; - charBox = this.__charBounds[lineIndex][i]; - if (boxWidth === 0) { - left += sign * (charBox.kernedWidth - charBox.width); - boxWidth += charBox.width; - } else { - boxWidth += charBox.kernedWidth; - } - if (isJustify && !timeToRender) { - if (this._reSpaceAndTab.test(line[i])) { - timeToRender = true; - } - } - if (!timeToRender) { - // if we have charSpacing, we render char by char - actualStyle = - actualStyle || this.getCompleteStyleDeclaration(lineIndex, i); - nextStyle = this.getCompleteStyleDeclaration(lineIndex, i + 1); - timeToRender = fabric.util.hasStyleChanged( - actualStyle, - nextStyle, - false - ); - } - if (timeToRender) { - if (path) { - ctx.save(); - ctx.translate(charBox.renderLeft, charBox.renderTop); - ctx.rotate(charBox.angle); - this._renderChar( - method, - ctx, - lineIndex, - i, - charsToRender, - -boxWidth / 2, - 0, - lineHeight - ); - ctx.restore(); - } else { - drawingLeft = left; - this._renderChar( - method, - ctx, - lineIndex, - i, - charsToRender, - drawingLeft, - top, - lineHeight - ); - } - charsToRender = ''; - actualStyle = nextStyle; - left += sign * boxWidth; - boxWidth = 0; - } - } - ctx.restore(); - }, - - /** - * This function try to patch the missing gradientTransform on canvas gradients. - * transforming a context to transform the gradient, is going to transform the stroke too. - * we want to transform the gradient but not the stroke operation, so we create - * a transformed gradient on a pattern and then we use the pattern instead of the gradient. - * this method has drawbacks: is slow, is in low resolution, needs a patch for when the size - * is limited. - * @private - * @param {fabric.Gradient} filler a fabric gradient instance - * @return {CanvasPattern} a pattern to use as fill/stroke style - */ - _applyPatternGradientTransformText: function (filler) { - var pCanvas = fabric.util.createCanvasElement(), - pCtx, - // TODO: verify compatibility with strokeUniform - width = this.width + this.strokeWidth, - height = this.height + this.strokeWidth; - pCanvas.width = width; - pCanvas.height = height; - pCtx = pCanvas.getContext('2d'); - pCtx.beginPath(); - pCtx.moveTo(0, 0); - pCtx.lineTo(width, 0); - pCtx.lineTo(width, height); - pCtx.lineTo(0, height); - pCtx.closePath(); - pCtx.translate(width / 2, height / 2); - pCtx.fillStyle = filler.toLive(pCtx); - this._applyPatternGradientTransform(pCtx, filler); - pCtx.fill(); - return pCtx.createPattern(pCanvas, 'no-repeat'); - }, - - handleFiller: function (ctx, property, filler) { - var offsetX, offsetY; - if (filler.toLive) { - if ( - filler.gradientUnits === 'percentage' || - filler.gradientTransform || - filler.patternTransform - ) { - // need to transform gradient in a pattern. - // this is a slow process. If you are hitting this codepath, and the object - // is not using caching, you should consider switching it on. - // we need a canvas as big as the current object caching canvas. - offsetX = -this.width / 2; - offsetY = -this.height / 2; - ctx.translate(offsetX, offsetY); - ctx[property] = this._applyPatternGradientTransformText(filler); - return { offsetX: offsetX, offsetY: offsetY }; - } else { - // is a simple gradient or pattern - ctx[property] = filler.toLive(ctx, this); - return this._applyPatternGradientTransform(ctx, filler); - } } else { - // is a color - ctx[property] = filler; + drawingLeft = left; + this._renderChar( + method, + ctx, + lineIndex, + i, + charsToRender, + drawingLeft, + top, + lineHeight + ); } - return { offsetX: 0, offsetY: 0 }; - }, - - _setStrokeStyles: function (ctx, decl) { - ctx.lineWidth = decl.strokeWidth; - ctx.lineCap = this.strokeLineCap; - ctx.lineDashOffset = this.strokeDashOffset; - ctx.lineJoin = this.strokeLineJoin; - ctx.miterLimit = this.strokeMiterLimit; - return this.handleFiller(ctx, 'strokeStyle', decl.stroke); - }, - - _setFillStyles: function (ctx, decl) { - return this.handleFiller(ctx, 'fillStyle', decl.fill); - }, - - /** - * @private - * @param {String} method - * @param {CanvasRenderingContext2D} ctx Context to render on - * @param {Number} lineIndex - * @param {Number} charIndex - * @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, - charIndex, + charsToRender = ''; + actualStyle = nextStyle; + left += sign * boxWidth; + boxWidth = 0; + } + } + ctx.restore(); + } + + /** + * This function try to patch the missing gradientTransform on canvas gradients. + * transforming a context to transform the gradient, is going to transform the stroke too. + * we want to transform the gradient but not the stroke operation, so we create + * a transformed gradient on a pattern and then we use the pattern instead of the gradient. + * this method has drawbacks: is slow, is in low resolution, needs a patch for when the size + * is limited. + * @private + * @param {TFiller} filler a fabric gradient instance + * @return {CanvasPattern} a pattern to use as fill/stroke style + */ + _applyPatternGradientTransformText(filler: TFiller): CanvasPattern { + const pCanvas = createCanvasElement(), + // TODO: verify compatibility with strokeUniform + width = this.width + this.strokeWidth, + height = this.height + this.strokeWidth, + pCtx = pCanvas.getContext('2d'); + pCanvas.width = width; + pCanvas.height = height; + pCtx.beginPath(); + pCtx.moveTo(0, 0); + pCtx.lineTo(width, 0); + pCtx.lineTo(width, height); + pCtx.lineTo(0, height); + pCtx.closePath(); + pCtx.translate(width / 2, height / 2); + pCtx.fillStyle = filler.toLive(pCtx); + this._applyPatternGradientTransform(pCtx, filler); + pCtx.fill(); + return pCtx.createPattern(pCanvas, 'no-repeat'); + } + + handleFiller(ctx, property, filler) { + let offsetX, offsetY; + if (filler.toLive) { + if ( + filler.gradientUnits === 'percentage' || + filler.gradientTransform || + filler.patternTransform + ) { + // need to transform gradient in a pattern. + // this is a slow process. If you are hitting this codepath, and the object + // is not using caching, you should consider switching it on. + // we need a canvas as big as the current object caching canvas. + offsetX = -this.width / 2; + offsetY = -this.height / 2; + ctx.translate(offsetX, offsetY); + ctx[property] = this._applyPatternGradientTransformText(filler); + return { offsetX: offsetX, offsetY: offsetY }; + } else { + // is a simple gradient or pattern + ctx[property] = filler.toLive(ctx, this); + return this._applyPatternGradientTransform(ctx, filler); + } + } else { + // is a color + ctx[property] = filler; + } + return { offsetX: 0, offsetY: 0 }; + } + + _setStrokeStyles(ctx, decl) { + ctx.lineWidth = decl.strokeWidth; + ctx.lineCap = this.strokeLineCap; + ctx.lineDashOffset = this.strokeDashOffset; + ctx.lineJoin = this.strokeLineJoin; + ctx.miterLimit = this.strokeMiterLimit; + return this.handleFiller(ctx, 'strokeStyle', decl.stroke); + } + + _setFillStyles(ctx, decl) { + return this.handleFiller(ctx, 'fillStyle', decl.fill); + } + + /** + * @private + * @param {String} method + * @param {CanvasRenderingContext2D} ctx Context to render on + * @param {Number} lineIndex + * @param {Number} charIndex + * @param {String} _char + * @param {Number} left Left coordinate + * @param {Number} top Top coordinate + * @param {Number} lineHeight Height of the line + */ + _renderChar( + method: 'fillText' | 'strokeText', + ctx: CanvasRenderingContext2D, + lineIndex: number, + charIndex: number, + _char: string, + left: number, + top: number + ) { + const decl = this._getStyleDeclaration(lineIndex, charIndex), + fullDecl = this.getCompleteStyleDeclaration(lineIndex, charIndex), + shouldFill = method === 'fillText' && fullDecl.fill, + shouldStroke = + method === 'strokeText' && fullDecl.stroke && fullDecl.strokeWidth; + let fillOffsets, strokeOffsets; + + if (!shouldStroke && !shouldFill) { + return; + } + ctx.save(); + + shouldFill && (fillOffsets = this._setFillStyles(ctx, fullDecl)); + shouldStroke && (strokeOffsets = this._setStrokeStyles(ctx, fullDecl)); + + ctx.font = this._getFontDeclaration(fullDecl); + + if (decl && decl.textBackgroundColor) { + this._removeShadow(ctx); + } + if (decl && decl.deltaY) { + top += decl.deltaY; + } + shouldFill && + ctx.fillText( _char, - left, - top + left - fillOffsets.offsetX, + top - fillOffsets.offsetY + ); + shouldStroke && + ctx.strokeText( + _char, + left - strokeOffsets.offsetX, + top - strokeOffsets.offsetY + ); + ctx.restore(); + } + + /** + * Turns the character into a 'superior figure' (i.e. 'superscript') + * @param {Number} start selection start + * @param {Number} end selection end + */ + setSuperscript(start: number, end: number) { + this._setScript(start, end, this.superscript); + } + + /** + * Turns the character into an 'inferior figure' (i.e. 'subscript') + * @param {Number} start selection start + * @param {Number} end selection end + */ + setSubscript(start: number, end: number) { + this._setScript(start, end, this.subscript); + } + + /** + * Applies 'schema' at given position + * @private + * @param {Number} start selection start + * @param {Number} end selection end + * @param {Number} schema + */ + protected _setScript(start: number, end: number, schema: number) { + const loc = this.get2DCursorLocation(start, true), + fontSize = this.getValueOfPropertyAt( + loc.lineIndex, + loc.charIndex, + 'fontSize' + ), + dy = this.getValueOfPropertyAt(loc.lineIndex, loc.charIndex, 'deltaY'), + style = { + fontSize: fontSize * schema.size, + deltaY: dy + fontSize * schema.baseline, + }; + this.setSelectionStyles(style, start, end); + } + + /** + * @private + * @param {Number} lineIndex index text line + * @return {Number} Line left offset + */ + _getLineLeftOffset(lineIndex: number): number { + const lineWidth = this.getLineWidth(lineIndex), + lineDiff = this.width - lineWidth, + textAlign = this.textAlign, + direction = this.direction, + isEndOfWrapping = this.isEndOfWrapping(lineIndex); + let leftOffset = 0; + if ( + textAlign === 'justify' || + (textAlign === 'justify-center' && !isEndOfWrapping) || + (textAlign === 'justify-right' && !isEndOfWrapping) || + (textAlign === 'justify-left' && !isEndOfWrapping) + ) { + return 0; + } + if (textAlign === 'center') { + leftOffset = lineDiff / 2; + } + if (textAlign === 'right') { + leftOffset = lineDiff; + } + if (textAlign === 'justify-center') { + leftOffset = lineDiff / 2; + } + if (textAlign === 'justify-right') { + leftOffset = lineDiff; + } + if (direction === 'rtl') { + if ( + textAlign === 'right' || + textAlign === 'justify' || + textAlign === 'justify-right' ) { - var decl = this._getStyleDeclaration(lineIndex, charIndex), - fullDecl = this.getCompleteStyleDeclaration(lineIndex, charIndex), - shouldFill = method === 'fillText' && fullDecl.fill, - shouldStroke = - method === 'strokeText' && fullDecl.stroke && fullDecl.strokeWidth, - fillOffsets, - strokeOffsets; - - if (!shouldStroke && !shouldFill) { - return; - } - ctx.save(); + leftOffset = 0; + } else if (textAlign === 'left' || textAlign === 'justify-left') { + leftOffset = -lineDiff; + } else if (textAlign === 'center' || textAlign === 'justify-center') { + leftOffset = -lineDiff / 2; + } + } + return leftOffset; + } - shouldFill && (fillOffsets = this._setFillStyles(ctx, fullDecl)); - shouldStroke && (strokeOffsets = this._setStrokeStyles(ctx, fullDecl)); + /** + * @private + */ + _clearCache() { + this.__lineWidths = []; + this.__lineHeights = []; + this.__charBounds = []; + } - ctx.font = this._getFontDeclaration(fullDecl); + /** + * @private + */ + _shouldClearDimensionCache() { + let shouldClear = this._forceClearCache; + shouldClear || + (shouldClear = this.hasStateChanged('_dimensionAffectingProps')); + if (shouldClear) { + this.dirty = true; + this._forceClearCache = false; + } + return shouldClear; + } - if (decl && decl.textBackgroundColor) { - this._removeShadow(ctx); - } - if (decl && decl.deltaY) { - top += decl.deltaY; - } - shouldFill && - ctx.fillText( - _char, - left - fillOffsets.offsetX, - top - fillOffsets.offsetY - ); - shouldStroke && - ctx.strokeText( - _char, - left - strokeOffsets.offsetX, - top - strokeOffsets.offsetY - ); - ctx.restore(); - }, - - /** - * Turns the character into a 'superior figure' (i.e. 'superscript') - * @param {Number} start selection start - * @param {Number} end selection end - * @returns {fabric.Text} thisArg - * @chainable - */ - setSuperscript: function (start, end) { - return this._setScript(start, end, this.superscript); - }, - - /** - * Turns the character into an 'inferior figure' (i.e. 'subscript') - * @param {Number} start selection start - * @param {Number} end selection end - * @returns {fabric.Text} thisArg - * @chainable - */ - setSubscript: function (start, end) { - return this._setScript(start, end, this.subscript); - }, - - /** - * Applies 'schema' at given position - * @private - * @param {Number} start selection start - * @param {Number} end selection end - * @param {Number} schema - * @returns {fabric.Text} thisArg - * @chainable - */ - _setScript: function (start, end, schema) { - var loc = this.get2DCursorLocation(start, true), - fontSize = this.getValueOfPropertyAt( - loc.lineIndex, - loc.charIndex, - 'fontSize' - ), - dy = this.getValueOfPropertyAt( - loc.lineIndex, - loc.charIndex, - 'deltaY' - ), - style = { - fontSize: fontSize * schema.size, - deltaY: dy + fontSize * schema.baseline, - }; - this.setSelectionStyles(style, start, end); - return this; - }, - - /** - * @private - * @param {Number} lineIndex index text line - * @return {Number} Line left offset - */ - _getLineLeftOffset: function (lineIndex) { - var lineWidth = this.getLineWidth(lineIndex), - lineDiff = this.width - lineWidth, - textAlign = this.textAlign, - direction = this.direction, - isEndOfWrapping, - leftOffset = 0, - isEndOfWrapping = this.isEndOfWrapping(lineIndex); - if ( - textAlign === 'justify' || - (textAlign === 'justify-center' && !isEndOfWrapping) || - (textAlign === 'justify-right' && !isEndOfWrapping) || - (textAlign === 'justify-left' && !isEndOfWrapping) - ) { - return 0; - } - if (textAlign === 'center') { - leftOffset = lineDiff / 2; - } - if (textAlign === 'right') { - leftOffset = lineDiff; - } - if (textAlign === 'justify-center') { - leftOffset = lineDiff / 2; - } - if (textAlign === 'justify-right') { - leftOffset = lineDiff; - } - if (direction === 'rtl') { - if ( - textAlign === 'right' || - textAlign === 'justify' || - textAlign === 'justify-right' - ) { - leftOffset = 0; - } else if (textAlign === 'left' || textAlign === 'justify-left') { - leftOffset = -lineDiff; - } else if (textAlign === 'center' || textAlign === 'justify-center') { - leftOffset = -lineDiff / 2; - } - } - return leftOffset; - }, - - /** - * @private - */ - _clearCache: function () { - this.__lineWidths = []; - this.__lineHeights = []; - this.__charBounds = []; - }, - - /** - * @private - */ - _shouldClearDimensionCache: function () { - var shouldClear = this._forceClearCache; - shouldClear || - (shouldClear = this.hasStateChanged('_dimensionAffectingProps')); - if (shouldClear) { - this.dirty = true; - this._forceClearCache = false; - } - return shouldClear; - }, - - /** - * Measure a single line given its index. Used to calculate the initial - * text bounding box. The values are calculated and stored in __lineWidths cache. - * @private - * @param {Number} lineIndex line number - * @return {Number} Line width - */ - getLineWidth: function (lineIndex) { - if (this.__lineWidths[lineIndex] !== undefined) { - return this.__lineWidths[lineIndex]; - } + /** + * Measure a single line given its index. Used to calculate the initial + * text bounding box. The values are calculated and stored in __lineWidths cache. + * @private + * @param {Number} lineIndex line number + * @return {Number} Line width + */ + getLineWidth(lineIndex: number): number { + if (this.__lineWidths[lineIndex] !== undefined) { + return this.__lineWidths[lineIndex]; + } - var lineInfo = this.measureLine(lineIndex); - var width = lineInfo.width; - this.__lineWidths[lineIndex] = width; - return width; - }, + const lineInfo = this.measureLine(lineIndex); + const width = lineInfo.width; + this.__lineWidths[lineIndex] = width; + return width; + } - _getWidthOfCharSpacing: function () { - if (this.charSpacing !== 0) { - return (this.fontSize * this.charSpacing) / 1000; - } - return 0; - }, - - /** - * Retrieves the value of property at given character position - * @param {Number} lineIndex the line number - * @param {Number} charIndex the character number - * @param {String} property the property name - * @returns the value of 'property' - */ - getValueOfPropertyAt: function (lineIndex, charIndex, property) { - var charStyle = this._getStyleDeclaration(lineIndex, charIndex); - if (charStyle && typeof charStyle[property] !== 'undefined') { - return charStyle[property]; - } - return this[property]; - }, - - /** - * @private - * @param {CanvasRenderingContext2D} ctx Context to render on - */ - _renderTextDecoration: function (ctx, type) { - if (!this[type] && !this.styleHas(type)) { - return; - } - var heightOfLine, - size, - _size, - lineLeftOffset, - dy, - _dy, - line, - lastDecoration, - leftOffset = this._getLeftOffset(), - topOffset = this._getTopOffset(), - top, - boxStart, - boxWidth, - charBox, - currentDecoration, - maxHeight, - currentFill, - lastFill, - path = this.path, - charSpacing = this._getWidthOfCharSpacing(), - offsetY = this.offsets[type]; - - for (var i = 0, len = this._textLines.length; i < len; i++) { - heightOfLine = this.getHeightOfLine(i); - if (!this[type] && !this.styleHas(type, i)) { - topOffset += heightOfLine; - continue; - } - line = this._textLines[i]; - maxHeight = heightOfLine / this.lineHeight; - lineLeftOffset = this._getLineLeftOffset(i); - boxStart = 0; - boxWidth = 0; - lastDecoration = this.getValueOfPropertyAt(i, 0, type); - lastFill = this.getValueOfPropertyAt(i, 0, 'fill'); - top = topOffset + maxHeight * (1 - this._fontSizeFraction); - size = this.getHeightOfChar(i, 0); - dy = this.getValueOfPropertyAt(i, 0, 'deltaY'); - for (var j = 0, jlen = line.length; j < jlen; j++) { - charBox = this.__charBounds[i][j]; - currentDecoration = this.getValueOfPropertyAt(i, j, type); - currentFill = this.getValueOfPropertyAt(i, j, 'fill'); - _size = this.getHeightOfChar(i, j); - _dy = this.getValueOfPropertyAt(i, j, 'deltaY'); - if (path && currentDecoration && currentFill) { - ctx.save(); - ctx.fillStyle = lastFill; - ctx.translate(charBox.renderLeft, charBox.renderTop); - ctx.rotate(charBox.angle); - ctx.fillRect( - -charBox.kernedWidth / 2, - offsetY * _size + _dy, - charBox.kernedWidth, - this.fontSize / 15 - ); - ctx.restore(); - } else if ( - (currentDecoration !== lastDecoration || - currentFill !== lastFill || - _size !== size || - _dy !== dy) && - boxWidth > 0 - ) { - var drawStart = leftOffset + lineLeftOffset + boxStart; - if (this.direction === 'rtl') { - drawStart = this.width - drawStart - boxWidth; - } - if (lastDecoration && lastFill) { - ctx.fillStyle = lastFill; - ctx.fillRect( - drawStart, - top + offsetY * size + dy, - boxWidth, - this.fontSize / 15 - ); - } - boxStart = charBox.left; - boxWidth = charBox.width; - lastDecoration = currentDecoration; - lastFill = currentFill; - size = _size; - dy = _dy; - } else { - boxWidth += charBox.kernedWidth; - } - } - var drawStart = leftOffset + lineLeftOffset + boxStart; + _getWidthOfCharSpacing() { + if (this.charSpacing !== 0) { + return (this.fontSize * this.charSpacing) / 1000; + } + return 0; + } + + /** + * Retrieves the value of property at given character position + * @param {Number} lineIndex the line number + * @param {Number} charIndex the character number + * @param {String} property the property name + * @returns the value of 'property' + */ + getValueOfPropertyAt(lineIndex: number, charIndex: number, property: string) { + const charStyle = this._getStyleDeclaration(lineIndex, charIndex); + if (charStyle && typeof charStyle[property] !== 'undefined') { + return charStyle[property]; + } + return this[property]; + } + + /** + * @private + * @param {CanvasRenderingContext2D} ctx Context to render on + */ + _renderTextDecoration(ctx: CanvasRenderingContext2D, type) { + if (!this[type] && !this.styleHas(type)) { + return; + } + let topOffset = this._getTopOffset(); + const leftOffset = this._getLeftOffset(), + path = this.path, + charSpacing = this._getWidthOfCharSpacing(), + offsetY = this.offsets[type]; + + for (let i = 0, len = this._textLines.length; i < len; i++) { + const heightOfLine = this.getHeightOfLine(i); + if (!this[type] && !this.styleHas(type, i)) { + topOffset += heightOfLine; + continue; + } + const line = this._textLines[i]; + const maxHeight = heightOfLine / this.lineHeight; + const lineLeftOffset = this._getLineLeftOffset(i); + let boxStart = 0; + let boxWidth = 0; + let lastDecoration = this.getValueOfPropertyAt(i, 0, type); + let lastFill = this.getValueOfPropertyAt(i, 0, 'fill'); + let currentDecoration; + let currentFill; + const top = topOffset + maxHeight * (1 - this._fontSizeFraction); + let size = this.getHeightOfChar(i, 0); + let dy = this.getValueOfPropertyAt(i, 0, 'deltaY'); + for (let j = 0, jlen = line.length; j < jlen; j++) { + const charBox = this.__charBounds[i][j]; + currentDecoration = this.getValueOfPropertyAt(i, j, type); + currentFill = this.getValueOfPropertyAt(i, j, 'fill'); + const currentSize = this.getHeightOfChar(i, j); + const currentDy = this.getValueOfPropertyAt(i, j, 'deltaY'); + if (path && currentDecoration && currentFill) { + ctx.save(); + ctx.fillStyle = lastFill; + ctx.translate(charBox.renderLeft, charBox.renderTop); + ctx.rotate(charBox.angle); + ctx.fillRect( + -charBox.kernedWidth / 2, + offsetY * currentSize + currentDy, + charBox.kernedWidth, + this.fontSize / 15 + ); + ctx.restore(); + } else if ( + (currentDecoration !== lastDecoration || + currentFill !== lastFill || + currentSize !== size || + currentDy !== dy) && + boxWidth > 0 + ) { + let drawStart = leftOffset + lineLeftOffset + boxStart; if (this.direction === 'rtl') { drawStart = this.width - drawStart - boxWidth; } - ctx.fillStyle = currentFill; - currentDecoration && - currentFill && + if (lastDecoration && lastFill) { + ctx.fillStyle = lastFill; ctx.fillRect( drawStart, top + offsetY * size + dy, - boxWidth - charSpacing, + boxWidth, this.fontSize / 15 ); - topOffset += heightOfLine; - } - // if there is text background color no - // other shadows should be casted - this._removeShadow(ctx); - }, - - /** - * return font declaration string for canvas context - * @param {Object} [styleObject] object - * @returns {String} font declaration formatted for canvas context. - */ - _getFontDeclaration: function (styleObject, forMeasuring) { - var style = styleObject || this, - family = this.fontFamily, - fontIsGeneric = - fabric.Text.genericFonts.indexOf(family.toLowerCase()) > -1; - var fontFamily = - family === undefined || - family.indexOf("'") > -1 || - family.indexOf(',') > -1 || - family.indexOf('"') > -1 || - fontIsGeneric - ? style.fontFamily - : '"' + style.fontFamily + '"'; - return [ - // node-canvas needs "weight style", while browsers need "style weight" - // verify if this can be fixed in JSDOM - fabric.isLikelyNode ? style.fontWeight : style.fontStyle, - fabric.isLikelyNode ? style.fontStyle : style.fontWeight, - forMeasuring ? this.CACHE_FONT_SIZE + 'px' : style.fontSize + 'px', - fontFamily, - ].join(' '); - }, - - /** - * Renders text instance on a specified context - * @param {CanvasRenderingContext2D} ctx Context to render on - */ - render: function (ctx) { - // do not render if object is not visible - if (!this.visible) { - return; - } - if ( - this.canvas && - this.canvas.skipOffscreen && - !this.group && - !this.isOnScreen() - ) { - return; - } - if (this._shouldClearDimensionCache()) { - this.initDimensions(); - } - this.callSuper('render', ctx); - }, - - /** - * Override this method to customize grapheme splitting - * @param {string} value - * @returns {string[]} array of graphemes - */ - graphemeSplit: function (value) { - return fabric.util.string.graphemeSplit(value); - }, - - /** - * Returns the text as an array of lines. - * @param {String} text text to split - * @returns {Array} Lines in the text - */ - _splitTextIntoLines: function (text) { - var lines = text.split(this._reNewline), - newLines = new Array(lines.length), - newLine = ['\n'], - newText = []; - for (var i = 0; i < lines.length; i++) { - newLines[i] = this.graphemeSplit(lines[i]); - newText = newText.concat(newLines[i], newLine); - } - newText.pop(); - return { - _unwrappedLines: newLines, - lines: lines, - graphemeText: newText, - graphemeLines: newLines, - }; - }, - - /** - * 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 allProperties = additionalProps.concat(propertiesToInclude); - var obj = this.callSuper('toObject', allProperties); - obj.styles = fabric.util.stylesToArray(this.styles, this.text); - if (obj.path) { - obj.path = this.path.toObject(); - } - return obj; - }, - - /** - * Sets property to a given value. When changing position/dimension -related properties (left, top, scale, angle, etc.) `set` does not update position of object's borders/controls. If you need to update those, call `setCoords()`. - * @param {String|Object} key Property name or object (if object, iterate over the object properties) - * @param {Object|Function} value Property value (if function, the value is passed into it and its return value is used as a new one) - * @return {fabric.Object} thisArg - * @chainable - */ - set: function (key, value) { - this.callSuper('set', key, value); - var needsDims = false; - var isAddingPath = false; - if (typeof key === 'object') { - for (var _key in key) { - if (_key === 'path') { - this.setPathInfo(); - } - needsDims = - needsDims || this._dimensionAffectingProps.indexOf(_key) !== -1; - isAddingPath = isAddingPath || _key === 'path'; } + boxStart = charBox.left; + boxWidth = charBox.width; + lastDecoration = currentDecoration; + lastFill = currentFill; + size = currentSize; + dy = currentDy; } else { - needsDims = this._dimensionAffectingProps.indexOf(key) !== -1; - isAddingPath = key === 'path'; + boxWidth += charBox.kernedWidth; } - if (isAddingPath) { + } + let drawStart = leftOffset + lineLeftOffset + boxStart; + if (this.direction === 'rtl') { + drawStart = this.width - drawStart - boxWidth; + } + ctx.fillStyle = currentFill; + currentDecoration && + currentFill && + ctx.fillRect( + drawStart, + top + offsetY * size + dy, + boxWidth - charSpacing, + this.fontSize / 15 + ); + topOffset += heightOfLine; + } + // if there is text background color no + // other shadows should be casted + this._removeShadow(ctx); + } + + /** + * return font declaration string for canvas context + * @param {Object} [styleObject] object + * @returns {String} font declaration formatted for canvas context. + */ + _getFontDeclaration(styleObject: object, forMeasuring): string { + const style = styleObject || this, + family = this.fontFamily, + fontIsGeneric = Text.genericFonts.indexOf(family.toLowerCase()) > -1; + const fontFamily = + family === undefined || + family.indexOf("'") > -1 || + family.indexOf(',') > -1 || + family.indexOf('"') > -1 || + fontIsGeneric + ? style.fontFamily + : '"' + style.fontFamily + '"'; + return [ + // node-canvas needs "weight style", while browsers need "style weight" + // verify if this can be fixed in JSDOM + fabric.isLikelyNode ? style.fontWeight : style.fontStyle, + fabric.isLikelyNode ? style.fontStyle : style.fontWeight, + forMeasuring ? this.CACHE_FONT_SIZE + 'px' : style.fontSize + 'px', + fontFamily, + ].join(' '); + } + + /** + * Renders text instance on a specified context + * @param {CanvasRenderingContext2D} ctx Context to render on + */ + render(ctx: CanvasRenderingContext2D) { + if (!this.visible) { + return; + } + if ( + this.canvas && + this.canvas.skipOffscreen && + !this.group && + !this.isOnScreen() + ) { + return; + } + if (this._shouldClearDimensionCache()) { + this.initDimensions(); + } + super.render(ctx); + } + + /** + * Override this method to customize grapheme splitting + * @todo the util `graphemeSplit` needs to be injectable in some way. + * is more comfortable to inject the correct util rather than having to override text + * in the middle of the prototype chain + * @param {string} value + * @returns {string[]} array of graphemes + */ + graphemeSplit(value: string): string[] { + return graphemeSplit(value); + } + + /** + * Returns the text as an array of lines. + * @param {String} text text to split + * @returns Lines in the text + */ + _splitTextIntoLines(text: string) { + const lines = text.split(this._reNewline), + newLines = new Array(lines.length), + newLine = ['\n']; + let newText = []; + for (let i = 0; i < lines.length; i++) { + newLines[i] = this.graphemeSplit(lines[i]); + newText = newText.concat(newLines[i], newLine); + } + newText.pop(); + return { + _unwrappedLines: newLines, + lines: lines, + graphemeText: newText, + graphemeLines: newLines, + }; + } + + /** + * 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(propertiesToInclude: Array): object { + const allProperties = additionalProps.concat(propertiesToInclude); + const obj = super.toObject(allProperties); + obj.styles = stylesToArray(this.styles, this.text); + if (obj.path) { + obj.path = this.path.toObject(); + } + return obj; + } + + set(key: string | any, value?: any) { + super.set(key, value); + let needsDims = false; + let isAddingPath = false; + if (typeof key === 'object') { + for (const _key in key) { + if (_key === 'path') { this.setPathInfo(); } - if (needsDims) { - this.initDimensions(); - this.setCoords(); - } - return this; - }, - - /** - * Returns complexity of an instance - * @return {Number} complexity - */ - complexity: function () { - return 1; - }, + needsDims = + needsDims || this._dimensionAffectingProps.indexOf(_key) !== -1; + isAddingPath = isAddingPath || _key === 'path'; + } + } else { + needsDims = this._dimensionAffectingProps.indexOf(key) !== -1; + isAddingPath = key === 'path'; } - ); + if (isAddingPath) { + this.setPathInfo(); + } + if (needsDims && this.initialized) { + this.initDimensions(); + this.setCoords(); + } + return this; + } + + /** + * Returns complexity of an instance + * @return {Number} complexity + */ + complexity(): number { + return 1; + } + + static genericFonts = [ + 'sans-serif', + 'serif', + 'cursive', + 'fantasy', + 'monospace', + ]; /* _FROM_SVG_START_ */ + /** - * List of attribute names to account for when parsing SVG element (used by {@link fabric.Text.fromElement}) + * List of attribute names to account for when parsing SVG element (used by {@link Text.fromElement}) * @static - * @memberOf fabric.Text + * @memberOf Text * @see: http://www.w3.org/TR/SVG/text.html#TextElement */ - fabric.Text.ATTRIBUTE_NAMES = fabric.SHARED_ATTRIBUTES.concat( + static ATTRIBUTE_NAMES = fabric.SHARED_ATTRIBUTES.concat( 'x y dx dy font-family font-style font-weight font-size letter-spacing text-decoration text-anchor'.split( ' ' ) ); /** - * Returns fabric.Text instance from an SVG element (not yet implemented) + * Returns Text instance from an SVG element (not yet implemented) * @static - * @memberOf fabric.Text + * @memberOf Text * @param {SVGElement} element Element to parse * @param {Function} callback callback function invoked after parsing * @param {Object} [options] Options object */ - fabric.Text.fromElement = function (element, callback, options) { + static fromElement(element: SVGElement, callback: Function, options: object) { if (!element) { return callback(null); } - var parsedAttributes = fabric.parseAttributes( + const parsedAttributes = fabric.parseAttributes( element, - fabric.Text.ATTRIBUTE_NAMES + Text.ATTRIBUTE_NAMES ), parsedAnchor = parsedAttributes.textAnchor || 'left'; options = Object.assign({}, options, parsedAttributes); @@ -1815,7 +1772,7 @@ import { DEFAULT_SVG_FONT_SIZE } from '../constants'; options.top = options.top || 0; options.left = options.left || 0; if (parsedAttributes.textDecoration) { - var textDecoration = parsedAttributes.textDecoration; + const textDecoration = parsedAttributes.textDecoration; if (textDecoration.indexOf('underline') !== -1) { options.underline = true; } @@ -1837,7 +1794,7 @@ import { DEFAULT_SVG_FONT_SIZE } from '../constants'; options.fontSize = DEFAULT_SVG_FONT_SIZE; } - var textContent = ''; + let textContent = ''; // The XML is not properly parsed in IE9 so a workaround to get // textContent is through firstChild.data. Another workaround would be @@ -1855,16 +1812,17 @@ import { DEFAULT_SVG_FONT_SIZE } from '../constants'; textContent = textContent .replace(/^\s+|\s+$|\n+/g, '') .replace(/\s+/g, ' '); - var originalStrokeWidth = options.strokeWidth; + const originalStrokeWidth = options.strokeWidth; options.strokeWidth = 0; - var text = new fabric.Text(textContent, options), + const text = new Text(textContent, options), textHeightScaleFactor = text.getScaledHeight() / text.height, lineHeightDiff = (text.height + text.strokeWidth) * text.lineHeight - text.height, scaledDiff = lineHeightDiff * textHeightScaleFactor, - textHeight = text.getScaledHeight() + scaledDiff, - offX = 0; + textHeight = text.getScaledHeight() + scaledDiff; + + let offX = 0; /* Adjust positioning: x/y attributes in SVG correspond to the bottom-left corner of text bounding box @@ -1886,30 +1844,105 @@ import { DEFAULT_SVG_FONT_SIZE } from '../constants'; typeof originalStrokeWidth !== 'undefined' ? originalStrokeWidth : 1, }); callback(text); - }; + } + /* _FROM_SVG_END_ */ /** - * Returns fabric.Text instance from an object representation + * Returns Text instance from an object representation * @static - * @memberOf fabric.Text + * @memberOf Text * @param {Object} object plain js Object to create an instance from - * @returns {Promise} + * @returns {Promise} */ - fabric.Text.fromObject = function (object) { - var styles = fabric.util.stylesFromArray(object.styles, object.text); + static fromObject(object: object): Promise { + const styles = stylesFromArray(object.styles, object.text); //copy object to prevent mutation - var objCopy = Object.assign({}, object, { styles: styles }); - return fabric.Object._fromObject(fabric.Text, objCopy, { + const objCopy = Object.assign({}, object, { styles: styles }); + return FabricObject._fromObject(Text, objCopy, { extraParam: 'text', }); - }; - - fabric.Text.genericFonts = [ - 'sans-serif', - 'serif', - 'cursive', - 'fantasy', - 'monospace', - ]; -})(typeof exports !== 'undefined' ? exports : window); + } +} + +export const textDefaultValues: Partial> = { + _dimensionAffectingProps: [ + 'fontSize', + 'fontWeight', + 'fontFamily', + 'fontStyle', + 'lineHeight', + 'text', + 'charSpacing', + 'textAlign', + 'styles', + 'path', + 'pathStartOffset', + 'pathSide', + 'pathAlign', + ], + _styleProperties: [ + 'stroke', + 'strokeWidth', + 'fill', + 'fontFamily', + 'fontSize', + 'fontWeight', + 'fontStyle', + 'underline', + 'overline', + 'linethrough', + 'deltaY', + 'textBackgroundColor', + ], + _reNewline: /\r?\n/, + _reSpacesAndTabs: /[ \t\r]/g, + _reSpaceAndTab: /[ \t\r]/, + _reWords: /\S+/g, + type: 'text', + fontSize: 40, + fontWeight: 'normal', + fontFamily: 'Times New Roman', + underline: false, + overline: false, + linethrough: false, + textAlign: 'left', + fontStyle: 'normal', + lineHeight: 1.16, + superscript: { + size: 0.6, // fontSize factor + baseline: -0.35, // baseline-shift factor (upwards) + }, + subscript: { + size: 0.6, // fontSize factor + baseline: 0.11, // baseline-shift factor (downwards) + }, + textBackgroundColor: '', + stateProperties: + fabricObjectDefaultValues.stateProperties.concat(additionalProps), + cacheProperties: + fabricObjectDefaultValues.cacheProperties.concat(additionalProps), + stroke: null, + shadow: null, + path: null, + pathStartOffset: 0, + pathSide: 'left', + pathAlign: 'baseline', + _fontSizeFraction: 0.222, + offsets: { + underline: 0.1, + linethrough: -0.315, + overline: -0.88, + }, + _fontSizeMult: 1.13, + charSpacing: 0, + styles: null, + deltaY: 0, + direction: 'ltr', + CACHE_FONT_SIZE: 400, + MIN_TEXT_WIDTH: 2, +}; + +Object.assign(Text.prototype, textDefaultValues); + +fabric.Text = Text; diff --git a/src/shapes/textbox.class.ts b/src/shapes/textbox.class.ts index d529b363242..b3e12255278 100644 --- a/src/shapes/textbox.class.ts +++ b/src/shapes/textbox.class.ts @@ -1,514 +1,494 @@ -//@ts-nocheck -(function (global) { - var fabric = global.fabric || (global.fabric = {}); +// @ts-nocheck + +import { fabric } from '../../HEADER'; +import { TClassProperties } from '../typedefs'; +import { stylesFromArray } from '../util/misc/textStyles'; +import { IText } from './itext.class'; +import { FabricObject } from './object.class'; +import { textDefaultValues } from './text.class'; + +/** + * 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. + */ +export class Textbox extends IText { + /** + * Minimum width of textbox, in pixels. + * @type Number + * @default + */ + minWidth: number; /** - * 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 - * @return {fabric.Textbox} thisArg - * @see {@link fabric.Textbox#initialize} for constructor definition + * 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 */ - fabric.Textbox = fabric.util.createClass(fabric.IText, { - /** - * 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 - */ - lockScalingFlip: true, - - /** - * Override standard Object class values - * Textbox needs this on false - */ - noScaleCache: false, - - /** - * Properties which when set cause object to change dimensions - * @type Object - * @private - */ - _dimensionAffectingProps: - fabric.Text.prototype._dimensionAffectingProps.concat('width'), - - /** - * Use this regular expression to split strings in breakable lines - * @private - */ - _wordJoiners: /[ \t\r]/, - - /** - * Use this boolean property in order to split strings that have no white space concept. - * this is a cheap way to help with chinese/japanese - * @type Boolean - * @since 2.6.0 - */ - splitByGrapheme: false, - - /** - * Unlike superclass's version of this function, Textbox does not update - * its width. - * @private - * @override - */ - initDimensions: function () { - if (this.__skipDimension) { - return; - } - this.isEditing && this.initDelayedCursor(); - this.clearContextTop(); - this._clearCache(); - // clear dynamicMinWidth as it will be different after we re-wrap line - this.dynamicMinWidth = 0; - // wrap lines - this._styleMap = this._generateStyleMap(this._splitText()); - // 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); - } - if (this.textAlign.indexOf('justify') !== -1) { - // once text is measured we need to make space fatter to make justified text. - this.enlargeSpaces(); - } - // clear cache and re-calculate height - this.height = this.calcTextHeight(); - this.saveState({ propertySet: '_dimensionAffectingProps' }); - }, - - /** - * 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 (textInfo) { - var realLineCount = 0, - realLineCharCount = 0, - charCount = 0, - map = {}; - - for (var i = 0; i < textInfo.graphemeLines.length; i++) { - if (textInfo.graphemeText[charCount] === '\n' && i > 0) { - realLineCharCount = 0; - charCount++; - realLineCount++; - } else if ( - !this.splitByGrapheme && - this._reSpaceAndTab.test(textInfo.graphemeText[charCount]) && - i > 0 - ) { - // this case deals with space's that are removed from end of lines when wrapping - realLineCharCount++; - charCount++; - } + dynamicMinWidth: number; - map[i] = { line: realLineCount, offset: realLineCharCount }; + /** + * Cached array of text wrapping. + * @type Array + */ + __cachedLines: Array | null = null; - charCount += textInfo.graphemeLines[i].length; - realLineCharCount += textInfo.graphemeLines[i].length; - } + /** + * Use this boolean property in order to split strings that have no white space concept. + * this is a cheap way to help with chinese/japanese + * @type Boolean + * @since 2.6.0 + */ + splitByGrapheme: boolean; - return map; - }, - - /** - * Returns true if object has a style property or has it on a specified line - * @param {Number} lineIndex - * @return {Boolean} - */ - styleHas: function (property, lineIndex) { - if (this._styleMap && !this.isWrapping) { - var map = this._styleMap[lineIndex]; - if (map) { - lineIndex = map.line; - } - } - return fabric.Text.prototype.styleHas.call(this, property, lineIndex); - }, - - /** - * Returns true if object has no styling or no styling in a line - * @param {Number} lineIndex , lineIndex is on wrapped lines. - * @return {Boolean} - */ - isEmptyStyles: function (lineIndex) { - if (!this.styles) { - return true; + /** + * Unlike superclass's version of this function, Textbox does not update + * its width. + * @private + * @override + */ + initDimensions() { + if (!this.initialized) { + return; + } + this.isEditing && this.initDelayedCursor(); + this.clearContextTop(); + this._clearCache(); + // clear dynamicMinWidth as it will be different after we re-wrap line + this.dynamicMinWidth = 0; + // wrap lines + this._styleMap = this._generateStyleMap(this._splitText()); + // 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); + } + if (this.textAlign.indexOf('justify') !== -1) { + // once text is measured we need to make space fatter to make justified text. + this.enlargeSpaces(); + } + // clear cache and re-calculate height + this.height = this.calcTextHeight(); + this.saveState({ propertySet: '_dimensionAffectingProps' }); + } + + /** + * 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(textInfo) { + let realLineCount = 0, + realLineCharCount = 0, + charCount = 0; + const map = {}; + + for (let i = 0; i < textInfo.graphemeLines.length; i++) { + if (textInfo.graphemeText[charCount] === '\n' && i > 0) { + realLineCharCount = 0; + charCount++; + realLineCount++; + } else if ( + !this.splitByGrapheme && + this._reSpaceAndTab.test(textInfo.graphemeText[charCount]) && + i > 0 + ) { + // this case deals with space's that are removed from end of lines when wrapping + realLineCharCount++; + charCount++; } - var offset = 0, - nextLineIndex = lineIndex + 1, - nextOffset, - obj, - shouldLimit = false, - map = this._styleMap[lineIndex], - mapNextLine = this._styleMap[lineIndex + 1]; + + map[i] = { line: realLineCount, offset: realLineCharCount }; + + charCount += textInfo.graphemeLines[i].length; + realLineCharCount += textInfo.graphemeLines[i].length; + } + + return map; + } + + /** + * Returns true if object has a style property or has it on a specified line + * @param {Number} lineIndex + * @return {Boolean} + */ + styleHas(property, lineIndex: number): boolean { + if (this._styleMap && !this.isWrapping) { + const map = this._styleMap[lineIndex]; if (map) { lineIndex = map.line; - offset = map.offset; - } - if (mapNextLine) { - nextLineIndex = mapNextLine.line; - shouldLimit = nextLineIndex === lineIndex; - nextOffset = mapNextLine.offset; } - obj = - typeof lineIndex === 'undefined' - ? this.styles - : { line: this.styles[lineIndex] }; - for (var p1 in obj) { - for (var p2 in obj[p1]) { - if (p2 >= offset && (!shouldLimit || p2 < nextOffset)) { - // eslint-disable-next-line no-unused-vars - for (var p3 in obj[p1][p2]) { - return false; - } + } + return super.styleHas(property, lineIndex); + } + + /** + * Returns true if object has no styling or no styling in a line + * @param {Number} lineIndex , lineIndex is on wrapped lines. + * @return {Boolean} + */ + isEmptyStyles(lineIndex: number): boolean { + if (!this.styles) { + return true; + } + let offset = 0, + nextLineIndex = lineIndex + 1, + nextOffset, + shouldLimit = false; + const map = this._styleMap[lineIndex], + mapNextLine = this._styleMap[lineIndex + 1]; + if (map) { + lineIndex = map.line; + offset = map.offset; + } + if (mapNextLine) { + nextLineIndex = mapNextLine.line; + shouldLimit = nextLineIndex === lineIndex; + nextOffset = mapNextLine.offset; + } + const obj = + typeof lineIndex === 'undefined' + ? this.styles + : { line: this.styles[lineIndex] }; + for (const p1 in obj) { + for (const p2 in obj[p1]) { + if (p2 >= offset && (!shouldLimit || p2 < nextOffset)) { + // eslint-disable-next-line no-unused-vars + for (const p3 in obj[p1][p2]) { + return false; } } } - return true; - }, - - /** - * @param {Number} lineIndex - * @param {Number} charIndex - * @private - */ - _getStyleDeclaration: function (lineIndex, charIndex) { - if (this._styleMap && !this.isWrapping) { - var map = this._styleMap[lineIndex]; - if (!map) { - return null; - } - lineIndex = map.line; - charIndex = map.offset + charIndex; + } + return true; + } + + /** + * @param {Number} lineIndex + * @param {Number} charIndex + * @private + */ + _getStyleDeclaration(lineIndex: number, charIndex: number) { + if (this._styleMap && !this.isWrapping) { + const map = this._styleMap[lineIndex]; + if (!map) { + return null; } - return this.callSuper('_getStyleDeclaration', lineIndex, charIndex); - }, - - /** - * @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; + } + return super._getStyleDeclaration(lineIndex, charIndex); + } - this.styles[lineIndex][charIndex] = style; - }, + /** + * @param {Number} lineIndex + * @param {Number} charIndex + * @param {Object} style + * @private + */ + _setStyleDeclaration(lineIndex: number, charIndex: number, style: object) { + const map = this._styleMap[lineIndex]; + lineIndex = map.line; + charIndex = map.offset + charIndex; - /** - * @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]; - }, - - /** - * probably broken need a fix - * Returns the real style line that correspond to the wrapped lineIndex line - * Used just to verify if the line does exist or not. - * @param {Number} lineIndex - * @returns {Boolean} if the line exists or not - * @private - */ - _getLineStyle: function (lineIndex) { - var map = this._styleMap[lineIndex]; - return !!this.styles[map.line]; - }, - - /** - * Set the line style to an empty object so that is initialized - * @param {Number} lineIndex - * @param {Object} style - * @private - */ - _setLineStyle: function (lineIndex) { - var map = this._styleMap[lineIndex]; - 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 {Array} lines The string array of text that is split into lines - * @param {Number} desiredWidth width you want to wrap to - * @returns {Array} Array of lines - */ - _wrapText: function (lines, desiredWidth) { - var wrapped = [], - i; - this.isWrapping = true; - for (i = 0; i < lines.length; i++) { - wrapped.push.apply(wrapped, this._wrapLine(lines[i], i, desiredWidth)); - } - this.isWrapping = false; - return wrapped; - }, - - /** - * Helper function to measure a string of text, given its lineIndex and charIndex offset - * It gets called when charBounds are not available yet. - * Override if necessary - * Use with {@link fabric.Textbox#wordSplit} - * - * @param {CanvasRenderingContext2D} ctx - * @param {String} text - * @param {number} lineIndex - * @param {number} charOffset - * @returns {number} - */ - _measureWord: function (word, lineIndex, charOffset) { - var width = 0, + this.styles[lineIndex][charIndex] = style; + } + + /** + * @param {Number} lineIndex + * @param {Number} charIndex + * @private + */ + _deleteStyleDeclaration(lineIndex: number, charIndex: number) { + const map = this._styleMap[lineIndex]; + lineIndex = map.line; + charIndex = map.offset + charIndex; + delete this.styles[lineIndex][charIndex]; + } + + /** + * probably broken need a fix + * Returns the real style line that correspond to the wrapped lineIndex line + * Used just to verify if the line does exist or not. + * @param {Number} lineIndex + * @returns {Boolean} if the line exists or not + * @private + */ + _getLineStyle(lineIndex: number): boolean { + const map = this._styleMap[lineIndex]; + return !!this.styles[map.line]; + } + + /** + * Set the line style to an empty object so that is initialized + * @param {Number} lineIndex + * @param {Object} style + * @private + */ + _setLineStyle(lineIndex: number) { + const map = this._styleMap[lineIndex]; + 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 {Array} lines The string array of text that is split into lines + * @param {Number} desiredWidth width you want to wrap to + * @returns {Array} Array of lines + */ + _wrapText(lines: Array, desiredWidth: number): Array { + const wrapped = []; + this.isWrapping = true; + for (let i = 0; i < lines.length; i++) { + wrapped.push(...this._wrapLine(lines[i], i, desiredWidth)); + } + this.isWrapping = false; + return wrapped; + } + + /** + * Helper function to measure a string of text, given its lineIndex and charIndex offset + * It gets called when charBounds are not available yet. + * Override if necessary + * Use with {@link Textbox#wordSplit} + * + * @param {CanvasRenderingContext2D} ctx + * @param {String} text + * @param {number} lineIndex + * @param {number} charOffset + * @returns {number} + */ + _measureWord(word, lineIndex: number, charOffset = 0): number { + let width = 0, + prevGrapheme; + const skipLeft = true; + for (let i = 0, len = word.length; i < len; i++) { + const box = this._getGraphemeBox( + word[i], + lineIndex, + i + charOffset, prevGrapheme, - skipLeft = true; - charOffset = charOffset || 0; - for (var i = 0, len = word.length; i < len; i++) { - var box = this._getGraphemeBox( - word[i], - lineIndex, - i + charOffset, - prevGrapheme, - skipLeft - ); - width += box.kernedWidth; - prevGrapheme = word[i]; - } - return width; - }, - - /** - * Override this method to customize word splitting - * Use with {@link fabric.Textbox#_measureWord} - * @param {string} value - * @returns {string[]} array of words - */ - wordSplit: function (value) { - return value.split(this._wordJoiners); - }, - - /** - * Wraps a line of text using the width of the Textbox and a context. - * @param {Array} line The grapheme array that represent the line - * @param {Number} lineIndex - * @param {Number} desiredWidth width you want to wrap the line to - * @param {Number} reservedSpace space to remove from wrapping for custom functionalities - * @returns {Array} Array of line(s) into which the given text is wrapped - * to. - */ - _wrapLine: function (_line, lineIndex, desiredWidth, reservedSpace) { - var lineWidth = 0, - splitByGrapheme = this.splitByGrapheme, - graphemeLines = [], - line = [], - // spaces in different languages? - words = splitByGrapheme - ? this.graphemeSplit(_line) - : this.wordSplit(_line), - word = '', - offset = 0, - infix = splitByGrapheme ? '' : ' ', - wordWidth = 0, - infixWidth = 0, - largestWordWidth = 0, - lineJustStarted = true, - additionalSpace = this._getWidthOfCharSpacing(), - reservedSpace = reservedSpace || 0; - // fix a difference between split and graphemeSplit - if (words.length === 0) { - words.push([]); - } - desiredWidth -= reservedSpace; - // measure words - var data = words.map( - function (word) { - // if using splitByGrapheme words are already in graphemes. - word = splitByGrapheme ? word : this.graphemeSplit(word); - var width = this._measureWord(word, lineIndex, offset); - largestWordWidth = Math.max(width, largestWordWidth); - offset += word.length + 1; - return { word: word, width: width }; - }.bind(this) + skipLeft ); - var maxWidth = Math.max( - desiredWidth, - largestWordWidth, - this.dynamicMinWidth - ); - // layout words - offset = 0; - for (var i = 0; i < words.length; i++) { - word = data[i].word; - wordWidth = data[i].width; - offset += word.length; - - lineWidth += infixWidth + wordWidth - additionalSpace; - if (lineWidth > maxWidth && !lineJustStarted) { - graphemeLines.push(line); - line = []; - lineWidth = wordWidth; - lineJustStarted = true; - } else { - lineWidth += additionalSpace; - } - - if (!lineJustStarted && !splitByGrapheme) { - line.push(infix); - } - line = line.concat(word); + width += box.kernedWidth; + prevGrapheme = word[i]; + } + return width; + } - infixWidth = splitByGrapheme - ? 0 - : this._measureWord([infix], lineIndex, offset); - offset++; - lineJustStarted = false; - } + /** + * Override this method to customize word splitting + * Use with {@link Textbox#_measureWord} + * @param {string} value + * @returns {string[]} array of words + */ + wordSplit(value: string): string[] { + return value.split(this._wordJoiners); + } - i && graphemeLines.push(line); + /** + * Wraps a line of text using the width of the Textbox and a context. + * @param {Array} line The grapheme array that represent the line + * @param {Number} lineIndex + * @param {Number} desiredWidth width you want to wrap the line to + * @param {Number} reservedSpace space to remove from wrapping for custom functionalities + * @returns {Array} Array of line(s) into which the given text is wrapped + * to. + */ + _wrapLine( + _line, + lineIndex: number, + desiredWidth: number, + reservedSpace = 0 + ): Array { + const additionalSpace = this._getWidthOfCharSpacing(), + splitByGrapheme = this.splitByGrapheme, + graphemeLines = [], + words = splitByGrapheme + ? this.graphemeSplit(_line) + : this.wordSplit(_line), + infix = splitByGrapheme ? '' : ' '; + + let lineWidth = 0, + line = [], + // spaces in different languages? + offset = 0, + infixWidth = 0, + largestWordWidth = 0, + lineJustStarted = true; + // fix a difference between split and graphemeSplit + if (words.length === 0) { + words.push([]); + } + desiredWidth -= reservedSpace; + // measure words + const data = words.map((word) => { + // if using splitByGrapheme words are already in graphemes. + word = splitByGrapheme ? word : this.graphemeSplit(word); + const width = this._measureWord(word, lineIndex, offset); + largestWordWidth = Math.max(width, largestWordWidth); + offset += word.length + 1; + return { word: word, width: width }; + }); - if (largestWordWidth + reservedSpace > this.dynamicMinWidth) { - this.dynamicMinWidth = - largestWordWidth - additionalSpace + reservedSpace; - } - return graphemeLines; - }, - - /** - * Detect if the text line is ended with an hard break - * text and itext do not have wrapping, return false - * @param {Number} lineIndex text to split - * @return {Boolean} - */ - isEndOfWrapping: function (lineIndex) { - if (!this._styleMap[lineIndex + 1]) { - // is last line, return true; - return true; + const maxWidth = Math.max( + desiredWidth, + largestWordWidth, + this.dynamicMinWidth + ); + // layout words + offset = 0; + let i; + for (i = 0; i < words.length; i++) { + const word = data[i].word; + const wordWidth = data[i].width; + offset += word.length; + + lineWidth += infixWidth + wordWidth - additionalSpace; + if (lineWidth > maxWidth && !lineJustStarted) { + graphemeLines.push(line); + line = []; + lineWidth = wordWidth; + lineJustStarted = true; + } else { + lineWidth += additionalSpace; } - if ( - this._styleMap[lineIndex + 1].line !== this._styleMap[lineIndex].line - ) { - // this is last line before a line break, return true; - return true; - } - return false; - }, - - /** - * Detect if a line has a linebreak and so we need to account for it when moving - * and counting style. - * @return Number - */ - missingNewlineOffset: function (lineIndex) { - if (this.splitByGrapheme) { - return this.isEndOfWrapping(lineIndex) ? 1 : 0; - } - return 1; - }, - - /** - * Gets lines of text to render in the Textbox. This function calculates - * text wrapping on the fly every time it is called. - * @param {String} text text to split - * @returns {Array} Array of lines in the Textbox. - * @override - */ - _splitTextIntoLines: function (text) { - var newText = fabric.Text.prototype._splitTextIntoLines.call(this, text), - graphemeLines = this._wrapText(newText.lines, this.width), - lines = new Array(graphemeLines.length); - for (var i = 0; i < graphemeLines.length; i++) { - lines[i] = graphemeLines[i].join(''); + + if (!lineJustStarted && !splitByGrapheme) { + line.push(infix); } - newText.lines = lines; - newText.graphemeLines = graphemeLines; - return newText; - }, - - getMinWidth: function () { - return Math.max(this.minWidth, this.dynamicMinWidth); - }, - - _removeExtraneousStyles: function () { - var linesToKeep = {}; - for (var prop in this._styleMap) { - if (this._textLines[prop]) { - linesToKeep[this._styleMap[prop].line] = 1; - } + line = line.concat(word); + + infixWidth = splitByGrapheme + ? 0 + : this._measureWord([infix], lineIndex, offset); + offset++; + lineJustStarted = false; + } + + i && graphemeLines.push(line); + + if (largestWordWidth + reservedSpace > this.dynamicMinWidth) { + this.dynamicMinWidth = largestWordWidth - additionalSpace + reservedSpace; + } + return graphemeLines; + } + + /** + * Detect if the text line is ended with an hard break + * text and itext do not have wrapping, return false + * @param {Number} lineIndex text to split + * @return {Boolean} + */ + isEndOfWrapping(lineIndex: number): boolean { + if (!this._styleMap[lineIndex + 1]) { + // is last line, return true; + return true; + } + if (this._styleMap[lineIndex + 1].line !== this._styleMap[lineIndex].line) { + // this is last line before a line break, return true; + return true; + } + return false; + } + + /** + * Detect if a line has a linebreak and so we need to account for it when moving + * and counting style. + * @return Number + */ + missingNewlineOffset(lineIndex) { + if (this.splitByGrapheme) { + return this.isEndOfWrapping(lineIndex) ? 1 : 0; + } + return 1; + } + + /** + * Gets lines of text to render in the Textbox. This function calculates + * text wrapping on the fly every time it is called. + * @param {String} text text to split + * @returns {Array} Array of lines in the Textbox. + * @override + */ + _splitTextIntoLines(text: string) { + const newText = super._splitTextIntoLines(text), + graphemeLines = this._wrapText(newText.lines, this.width), + lines = new Array(graphemeLines.length); + for (let i = 0; i < graphemeLines.length; i++) { + lines[i] = graphemeLines[i].join(''); + } + newText.lines = lines; + newText.graphemeLines = graphemeLines; + return newText; + } + + getMinWidth() { + return Math.max(this.minWidth, this.dynamicMinWidth); + } + + _removeExtraneousStyles() { + const linesToKeep = {}; + for (const prop in this._styleMap) { + if (this._textLines[prop]) { + linesToKeep[this._styleMap[prop].line] = 1; } - for (var prop in this.styles) { - if (!linesToKeep[prop]) { - delete this.styles[prop]; - } + } + for (const prop in this.styles) { + if (!linesToKeep[prop]) { + delete this.styles[prop]; } - }, - - /** - * 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', 'splitByGrapheme'].concat(propertiesToInclude) - ); - }, - }); + } + } + + /** + * 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(propertiesToInclude: Array): object { + return super.toObject( + ['minWidth', 'splitByGrapheme'].concat(propertiesToInclude) + ); + } /** - * Returns fabric.Textbox instance from an object representation + * Returns Textbox instance from an object representation * @static - * @memberOf fabric.Textbox + * @memberOf Textbox * @param {Object} object Object to create an instance from - * @returns {Promise} + * @returns {Promise} */ - fabric.Textbox.fromObject = function (object) { - var styles = fabric.util.stylesFromArray(object.styles, object.text); + static fromObject(object: object): Promise { + const styles = stylesFromArray(object.styles, object.text); //copy object to prevent mutation - var objCopy = Object.assign({}, object, { styles: styles }); - return fabric.Object._fromObject(fabric.Textbox, objCopy, { + const objCopy = Object.assign({}, object, { styles: styles }); + return FabricObject._fromObject(Textbox, objCopy, { extraParam: 'text', }); - }; -})(typeof exports !== 'undefined' ? exports : window); + } +} + +export const textboxDefaultValues: Partial> = { + type: 'textbox', + minWidth: 20, + dynamicMinWidth: 2, + lockScalingFlip: true, + noScaleCache: false, + _dimensionAffectingProps: + textDefaultValues._dimensionAffectingProps!.concat('width'), + _wordJoiners: /[ \t\r]/, + splitByGrapheme: false, +}; + +Object.assign(Textbox.prototype, textboxDefaultValues); + +fabric.Textbox = Textbox; diff --git a/test/unit/text.js b/test/unit/text.js index 2ffdaa84b15..adf29a86945 100644 --- a/test/unit/text.js +++ b/test/unit/text.js @@ -1,6 +1,13 @@ (function() { - QUnit.module('fabric.Text'); + QUnit.module('fabric.Text', { + before() { + fabric.config.configure({ NUM_FRACTION_DIGITS: 2 }); + }, + after() { + fabric.config.restoreDefaults(); + } + }); function createTextObject(text) { return new fabric.Text(text || 'x'); @@ -75,7 +82,7 @@ QUnit.test('toString', function(assert) { var text = createTextObject(); assert.ok(typeof text.toString === 'function'); - assert.equal(text.toString(), '#'); + assert.equal(text.toString(), '#'); }); QUnit.test('_getFontDeclaration', function(assert) { @@ -715,7 +722,8 @@ var schema = text.superscript; var styleFontSize = text.styles[0][2].fontSize; var styleDeltaY = text.styles[0][2].deltaY; - text.setSuperscript(1, 2).setSuperscript(2, 3); + text.setSuperscript(1, 2); + text.setSuperscript(2, 3); assert.equal(text.styles[0][0].fontSize, undefined, 'character 0: fontSize is not set'); assert.equal(text.styles[0][0].deltaY, undefined, 'character 0: deltaY is not set'); @@ -737,7 +745,8 @@ var schema = text.subscript; var styleFontSize = text.styles[0][2].fontSize; var styleDeltaY = text.styles[0][2].deltaY; - text.setSubscript(1,2).setSubscript(2,3); + text.setSubscript(1, 2); + text.setSubscript(2, 3); assert.equal(text.styles[0][0].fontSize, undefined, 'character 0: fontSize is not set'); assert.equal(text.styles[0][0].deltaY, undefined, 'character 0: deltaY is not set'); diff --git a/test/unit/text_to_svg.js b/test/unit/text_to_svg.js index 50774c220ec..783b971f8cc 100644 --- a/test/unit/text_to_svg.js +++ b/test/unit/text_to_svg.js @@ -2,7 +2,14 @@ function removeTranslate(str) { return str.replace(/matrix\(.*?\)/, ''); } - QUnit.module('fabric.Text'); + QUnit.module('fabric.Text SVG Export', { + before() { + fabric.config.configure({ NUM_FRACTION_DIGITS: 2 }); + }, + after() { + fabric.config.restoreDefaults(); + } + }); QUnit.test('toSVG', function(assert) { var TEXT_SVG = '\n\t\tx\n\n'; var text = new fabric.Text('x'); diff --git a/test/unit/textbox.js b/test/unit/textbox.js index 913ff396ddc..5191cf67b4a 100644 --- a/test/unit/textbox.js +++ b/test/unit/textbox.js @@ -1,11 +1,16 @@ (function() { var canvas = this.canvas = new fabric.Canvas(); QUnit.module('fabric.Textbox', { - afterEach: function() { + before() { + fabric.config.configure({ NUM_FRACTION_DIGITS: 2 }); + }, + after() { + fabric.config.restoreDefaults(); + }, + afterEach() { canvas.clear(); } }); - var TEXTBOX_OBJECT = { version: fabric.version, type: 'textbox',