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
text/typescript
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);