UNPKG

fabric

Version:

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

336 lines (308 loc) 10.7 kB
import type { ObjectEvents } from '../../EventTypeDefs'; import type { FabricObjectProps, SerializedObjectProps } from '../Object/types'; import type { TOptions } from '../../typedefs'; import { FabricObject } from '../Object/FabricObject'; import { styleProperties } from './constants'; import type { StylePropertiesType } from './constants'; import type { FabricText } from './Text'; import { pick } from '../../util'; import { pickBy } from '../../util/misc/pick'; export type CompleteTextStyleDeclaration = Pick< FabricText, StylePropertiesType >; export type TextStyleDeclaration = Partial<CompleteTextStyleDeclaration>; export type TextStyle = { [line: number | string]: { [char: number | string]: TextStyleDeclaration }; }; export abstract class StyledText< Props extends TOptions<FabricObjectProps> = Partial<FabricObjectProps>, SProps extends SerializedObjectProps = SerializedObjectProps, EventSpec extends ObjectEvents = ObjectEvents, > extends FabricObject<Props, SProps, EventSpec> { declare abstract styles: TextStyle; protected declare abstract _textLines: string[][]; protected declare _forceClearCache: boolean; static _styleProperties: Readonly<StylePropertiesType[]> = styleProperties; abstract get2DCursorLocation( selectionStart: number, skipWrapping?: boolean, ): { charIndex: number; lineIndex: number }; /** * 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; } } } return true; } /** * 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: keyof TextStyleDeclaration, lineIndex?: number): boolean { if (!this.styles) { 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; } /** * 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. */ cleanStyle(property: keyof TextStyleDeclaration) { if (!this.styles) { 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] || {}, stylePropertyHasBeenSet = styleObject[property] !== undefined; stylesCount++; if (stylePropertyHasBeenSet) { if (!stylePropertyValue) { stylePropertyValue = styleObject[property]; } else if (styleObject[property] !== stylePropertyValue) { allStyleObjectPropertiesMatch = false; } if (styleObject[property] === this[property as keyof this]) { delete styleObject[property]; } } else { allStyleObjectPropertiesMatch = false; } if (Object.keys(styleObject).length !== 0) { letterCount++; } else { delete obj[p1][p2]; } } 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 as any; 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(property: keyof TextStyleDeclaration) { if (!this.styles) { 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]; } } } private _extendStyles(index: number, style: TextStyleDeclaration): void { const { lineIndex, charIndex } = this.get2DCursorLocation(index); if (!this._getLineStyle(lineIndex)) { this._setLineStyle(lineIndex); } const newStyle = pickBy( { // first create a new object that is a merge of existing and new ...this._getStyleDeclaration(lineIndex, charIndex), ...style, // use the predicate to discard undefined values }, (value) => value !== undefined, ); // finally assign to the old position the new style this._setStyleDeclaration(lineIndex, charIndex, newStyle); } /** * 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, ): TextStyleDeclaration[] { const styles: TextStyleDeclaration[] = []; for (let i = startIndex; i < (endIndex || startIndex); i++) { styles.push(this.getStyleAtPosition(i, complete)); } return 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(position: number, complete?: boolean) { const { lineIndex, charIndex } = this.get2DCursorLocation(position); return complete ? this.getCompleteStyleDeclaration(lineIndex, charIndex) : this._getStyleDeclaration(lineIndex, 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; } /** * Get a reference, not a clone, to the style object for a given character, * if no style is set for a line or char, return a new empty object. * This is tricky and confusing because when you get an empty object you can't * determine if it is a reference or a new one. * @TODO this should always return a reference or always a clone or undefined when necessary. * @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 { const lineStyle = this.styles && this.styles[lineIndex]; return lineStyle ? lineStyle[charIndex] ?? {} : {}; } /** * 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, ): CompleteTextStyleDeclaration { return { ...pick( this, (this.constructor as typeof StyledText) ._styleProperties as (keyof this)[], ), ...this._getStyleDeclaration(lineIndex, charIndex), } as CompleteTextStyleDeclaration; } /** * @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 * @param {Number} charIndex * @private */ protected _deleteStyleDeclaration(lineIndex: number, charIndex: number) { delete this.styles[lineIndex][charIndex]; } /** * @param {Number} lineIndex * @return {Boolean} if the line exists or not * @private */ protected _getLineStyle(lineIndex: number): boolean { return !!this.styles[lineIndex]; } /** * 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]; } }