fabric
Version:
Object model for HTML5 canvas, and SVG-to-canvas parser. Backed by jsdom and node-canvas.
797 lines (731 loc) • 24.9 kB
text/typescript
import { Canvas } from '../../canvas/Canvas';
import type { ITextEvents } from './ITextBehavior';
import { ITextClickBehavior } from './ITextClickBehavior';
import {
ctrlKeysMapDown,
ctrlKeysMapUp,
keysMap,
keysMapRtl,
} from './constants';
import type { TClassProperties, TFiller, TOptions } from '../../typedefs';
import { classRegistry } from '../../ClassRegistry';
import type { SerializedTextProps, TextProps } from '../Text/Text';
import {
JUSTIFY,
JUSTIFY_CENTER,
JUSTIFY_LEFT,
JUSTIFY_RIGHT,
} from '../Text/constants';
import { CENTER, FILL, LEFT, RIGHT } from '../../constants';
import type { ObjectToCanvasElementOptions } from '../Object/Object';
import type { FabricObject } from '../Object/FabricObject';
import { createCanvasElementFor } from '../../util/misc/dom';
import { applyCanvasTransform } from '../../util/internals/applyCanvasTransform';
export type CursorBoundaries = {
left: number;
top: number;
leftOffset: number;
topOffset: number;
};
export type CursorRenderingData = {
color: string;
opacity: number;
left: number;
top: number;
width: number;
height: number;
};
// Declare IText protected properties to workaround TS
const protectedDefaultValues = {
_selectionDirection: null,
_reSpace: /\s|\r?\n/,
inCompositionMode: false,
};
export const iTextDefaultValues: Partial<TClassProperties<IText>> = {
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,
keysMap,
keysMapRtl,
ctrlKeysMapDown,
ctrlKeysMapUp,
...protectedDefaultValues,
};
// @TODO this is not complete
interface UniqueITextProps {
selectionStart: number;
selectionEnd: number;
}
export interface SerializedITextProps
extends SerializedTextProps,
UniqueITextProps {}
export interface ITextProps extends TextProps, UniqueITextProps {}
/**
* @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
*
* #### 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<
Props extends TOptions<ITextProps> = Partial<ITextProps>,
SProps extends SerializedITextProps = SerializedITextProps,
EventSpec extends ITextEvents = ITextEvents,
>
extends ITextClickBehavior<Props, SProps, EventSpec>
implements UniqueITextProps
{
/**
* Index where text selection starts (or where cursor is when there is no selection)
* @type Number
* @default
*/
declare selectionStart: number;
/**
* Index where text selection ends
* @type Number
* @default
*/
declare selectionEnd: number;
declare compositionStart: number;
declare compositionEnd: number;
/**
* Color of text selection
* @type String
* @default
*/
declare selectionColor: string;
/**
* Indicates whether text is in editing mode
* @type Boolean
* @default
*/
declare isEditing: boolean;
/**
* Indicates whether a text can be edited
* @type Boolean
* @default
*/
declare editable: boolean;
/**
* Border color of text object while it's in editing mode
* @type String
* @default
*/
declare editingBorderColor: string;
/**
* Width of cursor (in px)
* @type Number
* @default
*/
declare 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
*/
declare cursorColor: string;
/**
* Delay between cursor blink (in ms)
* @type Number
* @default
*/
declare cursorDelay: number;
/**
* Duration of cursor fade in (in ms)
* @type Number
* @default
*/
declare cursorDuration: number;
declare compositionColor: string;
/**
* Indicates whether internal text char widths can be cached
* @type Boolean
* @default
*/
declare caching: boolean;
static ownDefaults = iTextDefaultValues;
static getDefaults(): Record<string, any> {
return { ...super.getDefaults(), ...IText.ownDefaults };
}
static type = 'IText';
get type() {
const type = super.type;
// backward compatibility
return type === 'itext' ? 'i-text' : type;
}
/**
* Constructor
* @param {String} text Text string
* @param {Object} [options] Options object
*/
constructor(text: string, options?: Props) {
super(text, { ...IText.ownDefaults, ...options } as Props);
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) {
// @ts-expect-error irritating TS
this._savedProps[key] = value;
return this;
}
if (key === 'canvas') {
this.canvas instanceof Canvas &&
this.canvas.textEditingManager.remove(this);
value instanceof Canvas && value.textEditingManager.add(this);
}
return 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
*/
protected _updateAndFire(
property: 'selectionStart' | 'selectionEnd',
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();
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) {
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();
}
/**
* @override block cursor/selection logic while rendering the exported canvas
* @todo this workaround should be replaced with a more robust solution
*/
toCanvasElement(options?: ObjectToCanvasElementOptions): HTMLCanvasElement {
const isEditing = this.isEditing;
this.isEditing = false;
const canvas = super.toCanvasElement(options);
this.isEditing = isEditing;
return canvas;
}
/**
* Renders cursor or selection (depending on what exists)
* it does on the contextTop. If contextTop is not available, do nothing.
*/
renderCursorOrSelection() {
if (!this.isEditing || !this.canvas) {
return;
}
const ctx = this.clearContextTop(true);
if (!ctx) {
return;
}
const boundaries = this._getCursorBoundaries();
const ancestors = this.findAncestorsWithClipPath();
const hasAncestorsWithClipping = ancestors.length > 0;
let drawingCtx: CanvasRenderingContext2D = ctx;
let drawingCanvas: HTMLCanvasElement | undefined = undefined;
if (hasAncestorsWithClipping) {
// we have some clipPath, we need to draw the selection on an intermediate layer.
drawingCanvas = createCanvasElementFor(ctx.canvas);
drawingCtx = drawingCanvas.getContext('2d')!;
applyCanvasTransform(drawingCtx, this.canvas);
const m = this.calcTransformMatrix();
drawingCtx.transform(m[0], m[1], m[2], m[3], m[4], m[5]);
}
if (this.selectionStart === this.selectionEnd && !this.inCompositionMode) {
this.renderCursor(drawingCtx, boundaries);
} else {
this.renderSelection(drawingCtx, boundaries);
}
if (hasAncestorsWithClipping) {
// we need a neutral context.
// this won't work for nested clippaths in which a clippath
// has its own clippath
for (const ancestor of ancestors) {
const clipPath = ancestor.clipPath!;
const clippingCanvas = createCanvasElementFor(ctx.canvas);
const clippingCtx = clippingCanvas.getContext('2d')!;
applyCanvasTransform(clippingCtx, this.canvas);
// position the ctx in the center of the outer ancestor
if (!clipPath.absolutePositioned) {
const m = ancestor.calcTransformMatrix();
clippingCtx.transform(m[0], m[1], m[2], m[3], m[4], m[5]);
}
clipPath.transform(clippingCtx);
// we assign an empty drawing context, we don't plan to have this working for nested clippaths for now
clipPath.drawObject(clippingCtx, true, {});
this.drawClipPathOnCache(drawingCtx, clipPath, clippingCanvas);
}
}
if (hasAncestorsWithClipping) {
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.drawImage(drawingCanvas!, 0, 0);
}
this.canvas.contextTopDirty = true;
ctx.restore();
}
/**
* Finds and returns an array of clip paths that are applied to the parent
* group(s) of the current FabricObject instance. The object's hierarchy is
* traversed upwards (from the current object towards the root of the canvas),
* checking each parent object for the presence of a `clipPath` that is not
* absolutely positioned.
*/
findAncestorsWithClipPath(): FabricObject[] {
const clipPathAncestors: FabricObject[] = [];
// eslint-disable-next-line @typescript-eslint/no-this-alias
let obj: FabricObject | undefined = this;
while (obj) {
if (obj.clipPath) {
clipPathAncestors.push(obj);
}
obj = obj.parent;
}
return clipPathAncestors;
}
/**
* 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 = this.selectionStart,
skipCaching?: boolean,
): CursorBoundaries {
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,
): { left: number; top: number } {
if (skipCaching) {
return this.__getCursorBoundariesOffsets(index);
}
if (this.cursorOffsetCache && 'top' in this.cursorOffsetCache) {
return this.cursorOffsetCache as { left: number; top: number };
}
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 on context Top, outside the animation cycle, on request
* Used for the drag/drop effect.
* If contextTop is not available, do nothing.
*/
renderCursorAt(selectionStart: number) {
this._renderCursor(
this.canvas!.contextTop,
this._getCursorBoundaries(selectionStart, true),
selectionStart,
);
}
/**
* Renders cursor
* @param {Object} boundaries
* @param {CanvasRenderingContext2D} ctx transformed context to draw on
*/
renderCursor(ctx: CanvasRenderingContext2D, boundaries: CursorBoundaries) {
this._renderCursor(ctx, boundaries, this.selectionStart);
}
/**
* Return the data needed to render the cursor for given selection start
* The left,top are relative to the object, while width and height are prescaled
* to look think with canvas zoom and object scaling,
* so they depend on canvas and object scaling
*/
getCursorRenderingData(
selectionStart: number = this.selectionStart,
boundaries: CursorBoundaries = this._getCursorBoundaries(selectionStart),
): CursorRenderingData {
const cursorLocation = this.get2DCursorLocation(selectionStart),
lineIndex = cursorLocation.lineIndex,
charIndex =
cursorLocation.charIndex > 0 ? cursorLocation.charIndex - 1 : 0,
charHeight = this.getValueOfPropertyAt(lineIndex, charIndex, 'fontSize'),
multiplier = this.getObjectScaling().x * 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);
return {
color:
this.cursorColor ||
(this.getValueOfPropertyAt(lineIndex, charIndex, 'fill') as string),
opacity: this._currentCursorOpacity,
left: boundaries.left + boundaries.leftOffset - cursorWidth / 2,
top: topOffset + boundaries.top + dy,
width: cursorWidth,
height: charHeight,
};
}
/**
* Render the cursor at the given selectionStart.
* @param {CanvasRenderingContext2D} ctx transformed context to draw on
*/
_renderCursor(
ctx: CanvasRenderingContext2D,
boundaries: CursorBoundaries,
selectionStart: number,
) {
const { color, opacity, left, top, width, height } =
this.getCursorRenderingData(selectionStart, boundaries);
ctx.fillStyle = color;
ctx.globalAlpha = opacity;
ctx.fillRect(left, top, width, height);
}
/**
* Renders text selection
* @param {Object} boundaries Object with left/top/leftOffset/topOffset
* @param {CanvasRenderingContext2D} ctx transformed context to draw on
*/
renderSelection(ctx: CanvasRenderingContext2D, boundaries: CursorBoundaries) {
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() {
const dragStartSelection =
this.draggableTextDelegate.getDragStartSelection()!;
this._renderSelection(
this.canvas!.contextTop,
dragStartSelection,
this._getCursorBoundaries(dragStartSelection.selectionStart, true),
);
}
renderDropTargetEffect(e: DragEvent) {
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: CursorBoundaries,
) {
const selectionStart = selection.selectionStart,
selectionEnd = selection.selectionEnd,
isJustify = this.textAlign.includes(JUSTIFY),
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 {
const 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;
}
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.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;
}
}
/**
* 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 | null {
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 };
}
dispose() {
this.exitEditingImpl();
this.draggableTextDelegate.dispose();
super.dispose();
}
}
classRegistry.setClass(IText);
// legacy
classRegistry.setClass(IText, 'i-text');