UNPKG

fabric

Version:

Object model for HTML5 canvas, and SVG-to-canvas parser. Backed by jsdom and node-canvas.

584 lines (527 loc) 17.6 kB
import type { TClassProperties, TOptions } from '../typedefs'; import { IText } from './IText/IText'; import { classRegistry } from '../ClassRegistry'; import { createTextboxDefaultControls } from '../controls/commonControls'; import { JUSTIFY } from './Text/constants'; import type { TextStyleDeclaration } from './Text/StyledText'; import type { SerializedITextProps, ITextProps } from './IText/IText'; import type { ITextEvents } from './IText/ITextBehavior'; import type { TextLinesInfo } from './Text/Text'; import type { Control } from '../controls/Control'; // @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 export const textboxDefaultValues: Partial<TClassProperties<Textbox>> = { minWidth: 20, dynamicMinWidth: 2, lockScalingFlip: true, noScaleCache: false, _wordJoiners: /[ \t\r]/, splitByGrapheme: false, }; export type GraphemeData = { wordsData: { word: string[]; width: number; }[][]; largestWordWidth: number; }; export type StyleMap = Record<string, { line: number; offset: number }>; // @TODO this is not complete interface UniqueTextboxProps { minWidth: number; splitByGrapheme: boolean; dynamicMinWidth: number; _wordJoiners: RegExp; } export interface SerializedTextboxProps extends SerializedITextProps, Pick<UniqueTextboxProps, 'minWidth' | 'splitByGrapheme'> {} export interface TextboxProps extends ITextProps, UniqueTextboxProps {} /** * 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< Props extends TOptions<TextboxProps> = Partial<TextboxProps>, SProps extends SerializedTextboxProps = SerializedTextboxProps, EventSpec extends ITextEvents = ITextEvents, > extends IText<Props, SProps, EventSpec> implements UniqueTextboxProps { /** * Minimum width of textbox, in pixels. * @type Number * @default */ declare minWidth: number; /** * 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 */ declare dynamicMinWidth: number; /** * 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 */ declare splitByGrapheme: boolean; declare _wordJoiners: RegExp; declare _styleMap: StyleMap; declare isWrapping: boolean; static type = 'Textbox'; static textLayoutProperties = [...IText.textLayoutProperties, 'width']; static ownDefaults = textboxDefaultValues; static getDefaults(): Record<string, any> { return { ...super.getDefaults(), ...Textbox.ownDefaults, }; } /** * Constructor * @param {String} text Text string * @param {Object} [options] Options object */ constructor(text: string, options?: Props) { super(text, { ...Textbox.ownDefaults, ...options } as Props); } /** * 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(): { controls: Record<string, Control> } { 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: TextLinesInfo): StyleMap { let realLineCount = 0, realLineCharCount = 0, charCount = 0; const map: StyleMap = {}; 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: keyof TextStyleDeclaration, lineIndex: number): boolean { 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: number): boolean { if (!this.styles) { return true; } let offset = 0, nextLineIndex = lineIndex + 1, nextOffset: number, 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: number, charIndex: number, ): TextStyleDeclaration { 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 */ protected _setStyleDeclaration( lineIndex: number, charIndex: number, style: object, ) { const map = this._styleMap[lineIndex]; super._setStyleDeclaration(map.line, map.offset + charIndex, style); } /** * @param {Number} lineIndex * @param {Number} charIndex * @private */ protected _deleteStyleDeclaration(lineIndex: number, charIndex: number) { 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 */ protected _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 */ protected _setLineStyle(lineIndex: number) { 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: string[], desiredWidth: number): string[][] { this.isWrapping = true; // extract all thewords and the widths to optimally wrap lines. const data = this.getGraphemeDataForRender(lines); const wrapped: string[][] = []; 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: string[]): GraphemeData { 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: string) => { // 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: string[], 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, ); 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: string): string[] { 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: number, desiredWidth: number, { largestWordWidth, wordsData }: GraphemeData, reservedSpace = 0, ): string[][] { const additionalSpace = this._getWidthOfCharSpacing(), splitByGrapheme = this.splitByGrapheme, graphemeLines = [], infix = splitByGrapheme ? '' : ' '; let lineWidth = 0, line: string[] = [], // 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: 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. * 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: number, skipWrapping?: boolean): 0 | 1 { 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: 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 = 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(`${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< T extends Omit<Props & TClassProperties<this>, keyof SProps>, K extends keyof T = never, >(propertiesToInclude: K[] = []): Pick<T, K> & SProps { return super.toObject<T, K>([ 'minWidth', 'splitByGrapheme', ...propertiesToInclude, ] as K[]) as Pick<T, K> & SProps; } } classRegistry.setClass(Textbox);