fabric
Version:
Object model for HTML5 canvas, and SVG-to-canvas parser. Backed by jsdom and node-canvas.
497 lines (471 loc) • 15.8 kB
JavaScript
import { defineProperty as _defineProperty, objectSpread2 as _objectSpread2 } from '../../_virtual/_rollupPluginBabelHelpers.mjs';
import { IText } from './IText/IText.mjs';
import { classRegistry } from '../ClassRegistry.mjs';
import { createTextboxDefaultControls } from '../controls/commonControls.mjs';
import { JUSTIFY } from './Text/constants.mjs';
// @TODO: Many things here are configuration related and shouldn't be on the class nor prototype
// regexes, list of properties that are not suppose to change by instances, magic consts.
// this will be a separated effort
const textboxDefaultValues = {
minWidth: 20,
dynamicMinWidth: 2,
lockScalingFlip: true,
noScaleCache: false,
_wordJoiners: /[ \t\r]/,
splitByGrapheme: false
};
// @TODO this is not complete
/**
* 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 Textbox extends IText {
static getDefaults() {
return _objectSpread2(_objectSpread2({}, super.getDefaults()), Textbox.ownDefaults);
}
/**
* Constructor
* @param {String} text Text string
* @param {Object} [options] Options object
*/
constructor(text, options) {
super(text, _objectSpread2(_objectSpread2({}, Textbox.ownDefaults), options));
}
/**
* Creates the default control object.
* If you prefer to have on instance of controls shared among all objects
* make this function return an empty object and add controls to the ownDefaults object
*/
static createControls() {
return {
controls: createTextboxDefaultControls()
};
}
/**
* 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._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.includes(JUSTIFY)) {
// 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();
}
/**
* 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++;
}
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) {
if (this._styleMap && !this.isWrapping) {
const map = this._styleMap[lineIndex];
if (map) {
lineIndex = map.line;
}
}
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) {
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]) {
const p2Number = parseInt(p2, 10);
if (p2Number >= offset && (!shouldLimit || p2Number < nextOffset)) {
// eslint-disable-next-line no-unused-vars
for (const p3 in obj[p1][p2]) {
return false;
}
}
}
}
return true;
}
/**
* @protected
* @param {Number} lineIndex
* @param {Number} charIndex
* @return {TextStyleDeclaration} a style object reference to the existing one or a new empty object when undefined
*/
_getStyleDeclaration(lineIndex, charIndex) {
if (this._styleMap && !this.isWrapping) {
const map = this._styleMap[lineIndex];
if (!map) {
return {};
}
lineIndex = map.line;
charIndex = map.offset + charIndex;
}
return super._getStyleDeclaration(lineIndex, charIndex);
}
/**
* @param {Number} lineIndex
* @param {Number} charIndex
* @param {Object} style
* @private
*/
_setStyleDeclaration(lineIndex, charIndex, style) {
const map = this._styleMap[lineIndex];
super._setStyleDeclaration(map.line, map.offset + charIndex, style);
}
/**
* @param {Number} lineIndex
* @param {Number} charIndex
* @private
*/
_deleteStyleDeclaration(lineIndex, charIndex) {
const map = this._styleMap[lineIndex];
super._deleteStyleDeclaration(map.line, map.offset + 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) {
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) {
const map = this._styleMap[lineIndex];
super._setLineStyle(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, desiredWidth) {
this.isWrapping = true;
// extract all thewords and the widths to optimally wrap lines.
const data = this.getGraphemeDataForRender(lines);
const wrapped = [];
for (let i = 0; i < data.wordsData.length; i++) {
wrapped.push(...this._wrapLine(i, desiredWidth, data));
}
this.isWrapping = false;
return wrapped;
}
/**
* For each line of text terminated by an hard line stop,
* measure each word width and extract the largest word from all.
* The returned words here are the one that at the end will be rendered.
* @param {string[]} lines the lines we need to measure
*
*/
getGraphemeDataForRender(lines) {
const splitByGrapheme = this.splitByGrapheme,
infix = splitByGrapheme ? '' : ' ';
let largestWordWidth = 0;
const data = lines.map((line, lineIndex) => {
let offset = 0;
const wordsOrGraphemes = splitByGrapheme ? this.graphemeSplit(line) : this.wordSplit(line);
if (wordsOrGraphemes.length === 0) {
return [{
word: [],
width: 0
}];
}
return wordsOrGraphemes.map(word => {
// if using splitByGrapheme words are already in graphemes.
const graphemeArray = splitByGrapheme ? [word] : this.graphemeSplit(word);
const width = this._measureWord(graphemeArray, lineIndex, offset);
largestWordWidth = Math.max(width, largestWordWidth);
offset += graphemeArray.length + infix.length;
return {
word: graphemeArray,
width
};
});
});
return {
wordsData: data,
largestWordWidth
};
}
/**
* 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) {
let charOffset = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : 0;
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);
width += box.kernedWidth;
prevGrapheme = word[i];
}
return width;
}
/**
* Override this method to customize word splitting
* Use with {@link Textbox#_measureWord}
* @param {string} value
* @returns {string[]} array of words
*/
wordSplit(value) {
return value.split(this._wordJoiners);
}
/**
* Wraps a line of text using the width of the Textbox as desiredWidth
* and leveraging the known width o words from GraphemeData
* @private
* @param {Number} lineIndex
* @param {Number} desiredWidth width you want to wrap the line to
* @param {GraphemeData} graphemeData an object containing all the lines' words width.
* @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(lineIndex, desiredWidth, _ref) {
let {
largestWordWidth,
wordsData
} = _ref;
let reservedSpace = arguments.length > 3 && arguments[3] !== undefined ? arguments[3] : 0;
const additionalSpace = this._getWidthOfCharSpacing(),
splitByGrapheme = this.splitByGrapheme,
graphemeLines = [],
infix = splitByGrapheme ? '' : ' ';
let lineWidth = 0,
line = [],
// spaces in different languages?
offset = 0,
infixWidth = 0,
lineJustStarted = true;
desiredWidth -= reservedSpace;
const maxWidth = Math.max(desiredWidth, largestWordWidth, this.dynamicMinWidth);
// layout words
const data = wordsData[lineIndex];
offset = 0;
let i;
for (i = 0; i < data.length; i++) {
const {
word,
width: wordWidth
} = data[i];
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);
infixWidth = splitByGrapheme ? 0 : this._measureWord([infix], lineIndex, offset);
offset++;
lineJustStarted = false;
}
i && graphemeLines.push(line);
// TODO: this code is probably not necessary anymore.
// it can be moved out of this function since largestWordWidth is now
// known in advance
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) {
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.
* This is important only for splitByGrapheme at the end of wrapping.
* If we are not wrapping the offset is always 1
* @return Number
*/
missingNewlineOffset(lineIndex, skipWrapping) {
if (this.splitByGrapheme && !skipWrapping) {
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) {
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 = new Map();
for (const prop in this._styleMap) {
const propNumber = parseInt(prop, 10);
if (this._textLines[propNumber]) {
const lineIndex = this._styleMap[prop].line;
linesToKeep.set("".concat(lineIndex), true);
}
}
for (const prop in this.styles) {
if (!linesToKeep.has(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() {
let propertiesToInclude = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : [];
return super.toObject(['minWidth', 'splitByGrapheme', ...propertiesToInclude]);
}
}
/**
* Minimum width of textbox, in pixels.
* @type Number
* @default
*/
/**
* 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
*/
/**
* 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
*/
_defineProperty(Textbox, "type", 'Textbox');
_defineProperty(Textbox, "textLayoutProperties", [...IText.textLayoutProperties, 'width']);
_defineProperty(Textbox, "ownDefaults", textboxDefaultValues);
classRegistry.setClass(Textbox);
export { Textbox, textboxDefaultValues };
//# sourceMappingURL=Textbox.mjs.map