UNPKG

fabric

Version:

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

1,114 lines (1,031 loc) 33.3 kB
import type { ObjectEvents, TPointerEvent } from '../../EventTypeDefs'; import { Point } from '../../Point'; import type { FabricObject } from '../Object/FabricObject'; import { FabricText } from '../Text/Text'; import { animate } from '../../util/animation/animate'; import type { TOnAnimationChangeCallback } from '../../util/animation/types'; import type { ValueAnimation } from '../../util/animation/ValueAnimation'; import type { TextStyleDeclaration } from '../Text/StyledText'; import type { SerializedTextProps, TextProps } from '../Text/Text'; import type { TOptions } from '../../typedefs'; import { getDocumentFromElement } from '../../util/dom_misc'; import { LEFT, MODIFIED, RIGHT, reNewline } from '../../constants'; import type { IText } from './IText'; /** * extend this regex to support non english languages * * - ` ` Matches a SPACE character (char code 32). * - `\n` Matches a LINE FEED character (char code 10). * - `\.` Matches a "." character (char code 46). * - `,` Matches a "," character (char code 44). * - `;` Matches a ";" character (char code 59). * - `!` Matches a "!" character (char code 33). * - `\?` Matches a "?" character (char code 63). * - `\-` Matches a "-" character (char code 45). */ // eslint-disable-next-line no-useless-escape const reNonWord = /[ \n\.,;!\?\-]/; export type ITextEvents = ObjectEvents & { 'selection:changed': never; changed: never | { index: number; action: string }; 'editing:entered': never | { e: TPointerEvent }; 'editing:exited': never; }; export abstract class ITextBehavior< Props extends TOptions<TextProps> = Partial<TextProps>, SProps extends SerializedTextProps = SerializedTextProps, EventSpec extends ITextEvents = ITextEvents, > extends FabricText<Props, SProps, EventSpec> { declare abstract isEditing: boolean; declare abstract cursorDelay: number; declare abstract selectionStart: number; declare abstract selectionEnd: number; declare abstract cursorDuration: number; declare abstract editable: boolean; declare abstract editingBorderColor: string; declare abstract compositionStart: number; declare abstract compositionEnd: number; declare abstract hiddenTextarea: HTMLTextAreaElement | null; /** * Helps determining when the text is in composition, so that the cursor * rendering is altered. */ protected declare inCompositionMode: boolean; protected declare _reSpace: RegExp; private declare _currentTickState?: ValueAnimation; private declare _currentTickCompleteState?: ValueAnimation; protected _currentCursorOpacity = 1; private declare _textBeforeEdit: string; protected declare __selectionStartOnMouseDown: number; /** * Keeps track if the IText object was selected before the actual click. * This because we want to delay enter editing by a click. */ protected declare selected: boolean; protected declare cursorOffsetCache: { left?: number; top?: number }; protected declare _savedProps?: { hasControls: boolean; borderColor: string; lockMovementX: boolean; lockMovementY: boolean; selectable: boolean; hoverCursor: CSSStyleDeclaration['cursor'] | null; defaultCursor?: CSSStyleDeclaration['cursor']; moveCursor?: CSSStyleDeclaration['cursor']; }; protected declare _selectionDirection: 'left' | 'right' | null; abstract initHiddenTextarea(): void; abstract _fireSelectionChanged(): void; abstract renderCursorOrSelection(): void; abstract getSelectionStartFromPointer(e: TPointerEvent): number; abstract _getCursorBoundaries( index: number, skipCaching?: boolean, ): { left: number; top: number; leftOffset: number; topOffset: number; }; /** * Initializes all the interactive behavior of IText */ initBehavior() { this._tick = this._tick.bind(this); this._onTickComplete = this._onTickComplete.bind(this); this.updateSelectionOnMouseMove = this.updateSelectionOnMouseMove.bind(this); } onDeselect(options?: { e?: TPointerEvent; object?: FabricObject }) { this.isEditing && this.exitEditing(); this.selected = false; return super.onDeselect(options); } /** * @private */ _animateCursor({ toValue, duration, delay, onComplete, }: { toValue: number; duration: number; delay?: number; onComplete?: TOnAnimationChangeCallback<number, void>; }) { return animate({ startValue: this._currentCursorOpacity, endValue: toValue, duration, delay, onComplete, abort: () => !this.canvas || // we do not want to animate a selection, only cursor this.selectionStart !== this.selectionEnd, onChange: (value) => { this._currentCursorOpacity = value; this.renderCursorOrSelection(); }, }); } /** * changes the cursor from visible to invisible */ private _tick(delay?: number) { this._currentTickState = this._animateCursor({ toValue: 0, duration: this.cursorDuration / 2, delay: Math.max(delay || 0, 100), onComplete: this._onTickComplete, }); } /** * Changes the cursor from invisible to visible */ private _onTickComplete() { this._currentTickCompleteState?.abort(); this._currentTickCompleteState = this._animateCursor({ toValue: 1, duration: this.cursorDuration, onComplete: this._tick, }); } /** * Initializes delayed cursor */ initDelayedCursor(restart?: boolean) { this.abortCursorAnimation(); this._tick(restart ? 0 : this.cursorDelay); } /** * Aborts cursor animation, clears all timeouts and clear textarea context if necessary */ abortCursorAnimation() { let shouldClear = false; [this._currentTickState, this._currentTickCompleteState].forEach( (cursorAnimation) => { if (cursorAnimation && !cursorAnimation.isDone()) { shouldClear = true; cursorAnimation.abort(); } }, ); this._currentCursorOpacity = 1; // make sure we clear context even if instance is not editing if (shouldClear) { this.clearContextTop(); } } /** * Restart tue cursor animation if either is in complete state ( between animations ) * or if it never started before */ restartCursorIfNeeded() { if ( [this._currentTickState, this._currentTickCompleteState].some( (cursorAnimation) => !cursorAnimation || cursorAnimation.isDone(), ) ) { this.initDelayedCursor(); } } /** * Selects entire text */ selectAll() { this.selectionStart = 0; this.selectionEnd = this._text.length; this._fireSelectionChanged(); this._updateTextarea(); return this; } /** * Selects entire text and updates the visual state */ cmdAll() { this.selectAll(); this.renderCursorOrSelection(); } /** * Returns selected text * @return {String} */ getSelectedText(): string { return this._text.slice(this.selectionStart, this.selectionEnd).join(''); } /** * Find new selection index representing start of current word according to current selection index * @param {Number} startFrom Current selection index * @return {Number} New selection index */ findWordBoundaryLeft(startFrom: number): number { let offset = 0, index = startFrom - 1; // remove space before cursor first if (this._reSpace.test(this._text[index])) { while (this._reSpace.test(this._text[index])) { offset++; index--; } } while (/\S/.test(this._text[index]) && index > -1) { offset++; index--; } return startFrom - offset; } /** * Find new selection index representing end of current word according to current selection index * @param {Number} startFrom Current selection index * @return {Number} New selection index */ findWordBoundaryRight(startFrom: number): number { let offset = 0, index = startFrom; // remove space after cursor first if (this._reSpace.test(this._text[index])) { while (this._reSpace.test(this._text[index])) { offset++; index++; } } while (/\S/.test(this._text[index]) && index < this._text.length) { offset++; index++; } return startFrom + offset; } /** * Find new selection index representing start of current line according to current selection index * @param {Number} startFrom Current selection index * @return {Number} New selection index */ findLineBoundaryLeft(startFrom: number): number { let offset = 0, index = startFrom - 1; while (!/\n/.test(this._text[index]) && index > -1) { offset++; index--; } return startFrom - offset; } /** * Find new selection index representing end of current line according to current selection index * @param {Number} startFrom Current selection index * @return {Number} New selection index */ findLineBoundaryRight(startFrom: number): number { let offset = 0, index = startFrom; while (!/\n/.test(this._text[index]) && index < this._text.length) { offset++; index++; } return startFrom + offset; } /** * Finds index corresponding to beginning or end of a word * @param {Number} selectionStart Index of a character * @param {Number} direction 1 or -1 * @return {Number} Index of the beginning or end of a word */ searchWordBoundary(selectionStart: number, direction: 1 | -1): number { const text = this._text; // if we land on a space we move the cursor backwards // if we are searching boundary end we move the cursor backwards ONLY if we don't land on a line break let index = selectionStart > 0 && this._reSpace.test(text[selectionStart]) && (direction === -1 || !reNewline.test(text[selectionStart - 1])) ? selectionStart - 1 : selectionStart, _char = text[index]; while (index > 0 && index < text.length && !reNonWord.test(_char)) { index += direction; _char = text[index]; } if (direction === -1 && reNonWord.test(_char)) { index++; } return index; } /** * Selects the word that contains the char at index selectionStart * @param {Number} selectionStart Index of a character */ selectWord(selectionStart?: number) { selectionStart = selectionStart ?? this.selectionStart; // search backwards const newSelectionStart = this.searchWordBoundary(selectionStart, -1), // search forward newSelectionEnd = Math.max( newSelectionStart, this.searchWordBoundary(selectionStart, 1), ); this.selectionStart = newSelectionStart; this.selectionEnd = newSelectionEnd; this._fireSelectionChanged(); this._updateTextarea(); // remove next major, for now it renders twice :( this.renderCursorOrSelection(); } /** * Selects the line that contains selectionStart * @param {Number} selectionStart Index of a character */ selectLine(selectionStart?: number) { selectionStart = selectionStart ?? this.selectionStart; const newSelectionStart = this.findLineBoundaryLeft(selectionStart), newSelectionEnd = this.findLineBoundaryRight(selectionStart); this.selectionStart = newSelectionStart; this.selectionEnd = newSelectionEnd; this._fireSelectionChanged(); this._updateTextarea(); } /** * Enters editing state */ enterEditing(e?: TPointerEvent) { if (this.isEditing || !this.editable) { return; } this.enterEditingImpl(); this.fire('editing:entered', e ? { e } : undefined); this._fireSelectionChanged(); if (this.canvas) { this.canvas.fire('text:editing:entered', { target: this as unknown as IText, e, }); this.canvas.requestRenderAll(); } } /** * runs the actual logic that enter from editing state, see {@link enterEditing} */ enterEditingImpl() { if (this.canvas) { this.canvas.calcOffset(); this.canvas.textEditingManager.exitTextEditing(); } this.isEditing = true; this.initHiddenTextarea(); this.hiddenTextarea!.focus(); this.hiddenTextarea!.value = this.text; this._updateTextarea(); this._saveEditingProps(); this._setEditingProps(); this._textBeforeEdit = this.text; this._tick(); } /** * called by {@link Canvas#textEditingManager} */ updateSelectionOnMouseMove(e: TPointerEvent) { if (this.getActiveControl()) { return; } const el = this.hiddenTextarea!; // regain focus getDocumentFromElement(el).activeElement !== el && el.focus(); const newSelectionStart = this.getSelectionStartFromPointer(e), currentStart = this.selectionStart, currentEnd = this.selectionEnd; if ( (newSelectionStart !== this.__selectionStartOnMouseDown || currentStart === currentEnd) && (currentStart === newSelectionStart || currentEnd === newSelectionStart) ) { return; } if (newSelectionStart > this.__selectionStartOnMouseDown) { this.selectionStart = this.__selectionStartOnMouseDown; this.selectionEnd = newSelectionStart; } else { this.selectionStart = newSelectionStart; this.selectionEnd = this.__selectionStartOnMouseDown; } if ( this.selectionStart !== currentStart || this.selectionEnd !== currentEnd ) { this._fireSelectionChanged(); this._updateTextarea(); this.renderCursorOrSelection(); } } /** * @private */ _setEditingProps() { this.hoverCursor = 'text'; if (this.canvas) { this.canvas.defaultCursor = this.canvas.moveCursor = 'text'; } this.borderColor = this.editingBorderColor; this.hasControls = this.selectable = false; this.lockMovementX = this.lockMovementY = true; } /** * convert from textarea to grapheme indexes */ fromStringToGraphemeSelection(start: number, end: number, text: string) { const smallerTextStart = text.slice(0, start), graphemeStart = this.graphemeSplit(smallerTextStart).length; if (start === end) { return { selectionStart: graphemeStart, selectionEnd: graphemeStart }; } const smallerTextEnd = text.slice(start, end), graphemeEnd = this.graphemeSplit(smallerTextEnd).length; return { selectionStart: graphemeStart, selectionEnd: graphemeStart + graphemeEnd, }; } /** * convert from fabric to textarea values */ fromGraphemeToStringSelection( start: number, end: number, graphemes: string[], ) { const smallerTextStart = graphemes.slice(0, start), graphemeStart = smallerTextStart.join('').length; if (start === end) { return { selectionStart: graphemeStart, selectionEnd: graphemeStart }; } const smallerTextEnd = graphemes.slice(start, end), graphemeEnd = smallerTextEnd.join('').length; return { selectionStart: graphemeStart, selectionEnd: graphemeStart + graphemeEnd, }; } /** * @private */ _updateTextarea() { this.cursorOffsetCache = {}; if (!this.hiddenTextarea) { return; } if (!this.inCompositionMode) { const newSelection = this.fromGraphemeToStringSelection( this.selectionStart, this.selectionEnd, this._text, ); this.hiddenTextarea.selectionStart = newSelection.selectionStart; this.hiddenTextarea.selectionEnd = newSelection.selectionEnd; } this.updateTextareaPosition(); } /** * @private */ updateFromTextArea() { if (!this.hiddenTextarea) { return; } this.cursorOffsetCache = {}; const textarea = this.hiddenTextarea; this.text = textarea.value; this.set('dirty', true); this.initDimensions(); this.setCoords(); const newSelection = this.fromStringToGraphemeSelection( textarea.selectionStart, textarea.selectionEnd, textarea.value, ); this.selectionEnd = this.selectionStart = newSelection.selectionEnd; if (!this.inCompositionMode) { this.selectionStart = newSelection.selectionStart; } this.updateTextareaPosition(); } /** * @private */ updateTextareaPosition() { if (this.selectionStart === this.selectionEnd) { const style = this._calcTextareaPosition(); this.hiddenTextarea!.style.left = style.left; this.hiddenTextarea!.style.top = style.top; } } /** * @private * @return {Object} style contains style for hiddenTextarea */ _calcTextareaPosition() { if (!this.canvas) { return { left: '1px', top: '1px' }; } const desiredPosition = this.inCompositionMode ? this.compositionStart : this.selectionStart, boundaries = this._getCursorBoundaries(desiredPosition), cursorLocation = this.get2DCursorLocation(desiredPosition), lineIndex = cursorLocation.lineIndex, charIndex = cursorLocation.charIndex, charHeight = this.getValueOfPropertyAt(lineIndex, charIndex, 'fontSize') * this.lineHeight, leftOffset = boundaries.leftOffset, retinaScaling = this.getCanvasRetinaScaling(), upperCanvas = this.canvas.upperCanvasEl, upperCanvasWidth = upperCanvas.width / retinaScaling, upperCanvasHeight = upperCanvas.height / retinaScaling, maxWidth = upperCanvasWidth - charHeight, maxHeight = upperCanvasHeight - charHeight; const p = new Point( boundaries.left + leftOffset, boundaries.top + boundaries.topOffset + charHeight, ) .transform(this.calcTransformMatrix()) .transform(this.canvas.viewportTransform) .multiply( new Point( upperCanvas.clientWidth / upperCanvasWidth, upperCanvas.clientHeight / upperCanvasHeight, ), ); if (p.x < 0) { p.x = 0; } if (p.x > maxWidth) { p.x = maxWidth; } if (p.y < 0) { p.y = 0; } if (p.y > maxHeight) { p.y = maxHeight; } // add canvas offset on document p.x += this.canvas._offset.left; p.y += this.canvas._offset.top; return { left: `${p.x}px`, top: `${p.y}px`, fontSize: `${charHeight}px`, charHeight: charHeight, }; } /** * @private */ _saveEditingProps() { this._savedProps = { hasControls: this.hasControls, borderColor: this.borderColor, lockMovementX: this.lockMovementX, lockMovementY: this.lockMovementY, hoverCursor: this.hoverCursor, selectable: this.selectable, defaultCursor: this.canvas && this.canvas.defaultCursor, moveCursor: this.canvas && this.canvas.moveCursor, }; } /** * @private */ _restoreEditingProps() { if (!this._savedProps) { return; } this.hoverCursor = this._savedProps.hoverCursor; this.hasControls = this._savedProps.hasControls; this.borderColor = this._savedProps.borderColor; this.selectable = this._savedProps.selectable; this.lockMovementX = this._savedProps.lockMovementX; this.lockMovementY = this._savedProps.lockMovementY; if (this.canvas) { this.canvas.defaultCursor = this._savedProps.defaultCursor || this.canvas.defaultCursor; this.canvas.moveCursor = this._savedProps.moveCursor || this.canvas.moveCursor; } delete this._savedProps; } /** * runs the actual logic that exits from editing state, see {@link exitEditing} * Please use exitEditingImpl, this function was kept to avoid breaking changes. * Will be removed in fabric 7.0 * @deprecated use "exitEditingImpl" */ protected _exitEditing() { const hiddenTextarea = this.hiddenTextarea; this.selected = false; this.isEditing = false; if (hiddenTextarea) { hiddenTextarea.blur && hiddenTextarea.blur(); hiddenTextarea.parentNode && hiddenTextarea.parentNode.removeChild(hiddenTextarea); } this.hiddenTextarea = null; this.abortCursorAnimation(); this.selectionStart !== this.selectionEnd && this.clearContextTop(); } /** * runs the actual logic that exits from editing state, see {@link exitEditing} * But it does not fire events */ exitEditingImpl() { this._exitEditing(); this.selectionEnd = this.selectionStart; this._restoreEditingProps(); if (this._forceClearCache) { this.initDimensions(); this.setCoords(); } } /** * Exits from editing state and fires relevant events */ exitEditing() { const isTextChanged = this._textBeforeEdit !== this.text; this.exitEditingImpl(); this.fire('editing:exited'); isTextChanged && this.fire(MODIFIED); if (this.canvas) { this.canvas.fire('text:editing:exited', { target: this as unknown as IText, }); // todo: evaluate add an action to this event isTextChanged && this.canvas.fire('object:modified', { target: this }); } return this; } /** * @private */ _removeExtraneousStyles() { for (const prop in this.styles) { if (!this._textLines[prop as unknown as number]) { delete this.styles[prop]; } } } /** * remove and reflow a style block from start to end. * @param {Number} start linear start position for removal (included in removal) * @param {Number} end linear end position for removal ( excluded from removal ) */ removeStyleFromTo(start: number, end: number) { const { lineIndex: lineStart, charIndex: charStart } = this.get2DCursorLocation(start, true), { lineIndex: lineEnd, charIndex: charEnd } = this.get2DCursorLocation( end, true, ); if (lineStart !== lineEnd) { // step1 remove the trailing of lineStart if (this.styles[lineStart]) { for ( let i = charStart; i < this._unwrappedTextLines[lineStart].length; i++ ) { delete this.styles[lineStart][i]; } } // step2 move the trailing of lineEnd to lineStart if needed if (this.styles[lineEnd]) { for ( let i = charEnd; i < this._unwrappedTextLines[lineEnd].length; i++ ) { const styleObj = this.styles[lineEnd][i]; if (styleObj) { this.styles[lineStart] || (this.styles[lineStart] = {}); this.styles[lineStart][charStart + i - charEnd] = styleObj; } } } // step3 detects lines will be completely removed. for (let i = lineStart + 1; i <= lineEnd; i++) { delete this.styles[i]; } // step4 shift remaining lines. this.shiftLineStyles(lineEnd, lineStart - lineEnd); } else { // remove and shift left on the same line if (this.styles[lineStart]) { const styleObj = this.styles[lineStart]; const diff = charEnd - charStart; for (let i = charStart; i < charEnd; i++) { delete styleObj[i]; } for (const char in this.styles[lineStart]) { const numericChar = parseInt(char, 10); if (numericChar >= charEnd) { styleObj[numericChar - diff] = styleObj[char]; delete styleObj[char]; } } } } } /** * Shifts line styles up or down * @param {Number} lineIndex Index of a line * @param {Number} offset Can any number? */ shiftLineStyles(lineIndex: number, offset: number) { const clonedStyles = Object.assign({}, this.styles); for (const line in this.styles) { const numericLine = parseInt(line, 10); if (numericLine > lineIndex) { this.styles[numericLine + offset] = clonedStyles[numericLine]; if (!clonedStyles[numericLine - offset]) { delete this.styles[numericLine]; } } } } /** * Handle insertion of more consecutive style lines for when one or more * newlines gets added to the text. Since current style needs to be shifted * first we shift the current style of the number lines needed, then we add * new lines from the last to the first. * @param {Number} lineIndex Index of a line * @param {Number} charIndex Index of a char * @param {Number} qty number of lines to add * @param {Array} copiedStyle Array of objects styles */ insertNewlineStyleObject( lineIndex: number, charIndex: number, qty: number, copiedStyle?: { [index: number]: TextStyleDeclaration }, ) { const newLineStyles: { [index: number]: TextStyleDeclaration } = {}; const originalLineLength = this._unwrappedTextLines[lineIndex].length; const isEndOfLine = originalLineLength === charIndex; let someStyleIsCarryingOver = false; qty || (qty = 1); this.shiftLineStyles(lineIndex, qty); const currentCharStyle = this.styles[lineIndex] ? this.styles[lineIndex][charIndex === 0 ? charIndex : charIndex - 1] : undefined; // we clone styles of all chars // after cursor onto the current line for (const index in this.styles[lineIndex]) { const numIndex = parseInt(index, 10); if (numIndex >= charIndex) { someStyleIsCarryingOver = true; newLineStyles[numIndex - charIndex] = this.styles[lineIndex][index]; // remove lines from the previous line since they're on a new line now if (!(isEndOfLine && charIndex === 0)) { delete this.styles[lineIndex][index]; } } } let styleCarriedOver = false; if (someStyleIsCarryingOver && !isEndOfLine) { // if is end of line, the extra style we copied // is probably not something we want this.styles[lineIndex + qty] = newLineStyles; styleCarriedOver = true; } if (styleCarriedOver || originalLineLength > charIndex) { // skip the last line of since we already prepared it. // or contains text without style that we don't want to style // just because it changed lines qty--; } // for the all the lines or all the other lines // we clone current char style onto the next (otherwise empty) line while (qty > 0) { if (copiedStyle && copiedStyle[qty - 1]) { this.styles[lineIndex + qty] = { 0: { ...copiedStyle[qty - 1] }, }; } else if (currentCharStyle) { this.styles[lineIndex + qty] = { 0: { ...currentCharStyle }, }; } else { delete this.styles[lineIndex + qty]; } qty--; } this._forceClearCache = true; } /** * Inserts style object for a given line/char index * @param {Number} lineIndex Index of a line * @param {Number} charIndex Index of a char * @param {Number} quantity number Style object to insert, if given * @param {Array} copiedStyle array of style objects */ insertCharStyleObject( lineIndex: number, charIndex: number, quantity: number, copiedStyle?: TextStyleDeclaration[], ) { if (!this.styles) { this.styles = {}; } const currentLineStyles = this.styles[lineIndex], currentLineStylesCloned = currentLineStyles ? { ...currentLineStyles } : {}; quantity || (quantity = 1); // shift all char styles by quantity forward // 0,1,2,3 -> (charIndex=2) -> 0,1,3,4 -> (insert 2) -> 0,1,2,3,4 for (const index in currentLineStylesCloned) { const numericIndex = parseInt(index, 10); if (numericIndex >= charIndex) { currentLineStyles[numericIndex + quantity] = currentLineStylesCloned[numericIndex]; // only delete the style if there was nothing moved there if (!currentLineStylesCloned[numericIndex - quantity]) { delete currentLineStyles[numericIndex]; } } } this._forceClearCache = true; if (copiedStyle) { while (quantity--) { if (!Object.keys(copiedStyle[quantity]).length) { continue; } if (!this.styles[lineIndex]) { this.styles[lineIndex] = {}; } this.styles[lineIndex][charIndex + quantity] = { ...copiedStyle[quantity], }; } return; } if (!currentLineStyles) { return; } const newStyle = currentLineStyles[charIndex ? charIndex - 1 : 1]; while (newStyle && quantity--) { this.styles[lineIndex][charIndex + quantity] = { ...newStyle }; } } /** * Inserts style object(s) * @param {Array} insertedText Characters at the location where style is inserted * @param {Number} start cursor index for inserting style * @param {Array} [copiedStyle] array of style objects to insert. */ insertNewStyleBlock( insertedText: string[], start: number, copiedStyle?: TextStyleDeclaration[], ) { const cursorLoc = this.get2DCursorLocation(start, true), addedLines = [0]; let linesLength = 0; // get an array of how many char per lines are being added. for (let i = 0; i < insertedText.length; i++) { if (insertedText[i] === '\n') { linesLength++; addedLines[linesLength] = 0; } else { addedLines[linesLength]++; } } // for the first line copy the style from the current char position. if (addedLines[0] > 0) { this.insertCharStyleObject( cursorLoc.lineIndex, cursorLoc.charIndex, addedLines[0], copiedStyle, ); copiedStyle = copiedStyle && copiedStyle.slice(addedLines[0] + 1); } linesLength && this.insertNewlineStyleObject( cursorLoc.lineIndex, cursorLoc.charIndex + addedLines[0], linesLength, ); let i; for (i = 1; i < linesLength; i++) { if (addedLines[i] > 0) { this.insertCharStyleObject( cursorLoc.lineIndex + i, 0, addedLines[i], copiedStyle, ); } else if (copiedStyle) { // this test is required in order to close #6841 // when a pasted buffer begins with a newline then // this.styles[cursorLoc.lineIndex + i] and copiedStyle[0] // may be undefined for some reason if (this.styles[cursorLoc.lineIndex + i] && copiedStyle[0]) { this.styles[cursorLoc.lineIndex + i][0] = copiedStyle[0]; } } copiedStyle = copiedStyle && copiedStyle.slice(addedLines[i] + 1); } if (addedLines[i] > 0) { this.insertCharStyleObject( cursorLoc.lineIndex + i, 0, addedLines[i], copiedStyle, ); } } /** * Removes characters from start/end * start/end ar per grapheme position in _text array. * * @param {Number} start * @param {Number} end default to start + 1 */ removeChars(start: number, end: number = start + 1) { this.removeStyleFromTo(start, end); this._text.splice(start, end - start); this.text = this._text.join(''); this.set('dirty', true); this.initDimensions(); this.setCoords(); this._removeExtraneousStyles(); } /** * insert characters at start position, before start position. * start equal 1 it means the text get inserted between actual grapheme 0 and 1 * if style array is provided, it must be as the same length of text in graphemes * if end is provided and is bigger than start, old text is replaced. * start/end ar per grapheme position in _text array. * * @param {String} text text to insert * @param {Array} style array of style objects * @param {Number} start * @param {Number} end default to start + 1 */ insertChars( text: string, style: TextStyleDeclaration[] | undefined, start: number, end: number = start, ) { if (end > start) { this.removeStyleFromTo(start, end); } const graphemes = this.graphemeSplit(text); this.insertNewStyleBlock(graphemes, start, style); this._text = [ ...this._text.slice(0, start), ...graphemes, ...this._text.slice(end), ]; this.text = this._text.join(''); this.set('dirty', true); this.initDimensions(); this.setCoords(); this._removeExtraneousStyles(); } /** * Set the selectionStart and selectionEnd according to the new position of cursor * mimic the key - mouse navigation when shift is pressed. */ setSelectionStartEndWithShift( start: number, end: number, newSelection: number, ) { if (newSelection <= start) { if (end === start) { this._selectionDirection = LEFT; } else if (this._selectionDirection === RIGHT) { this._selectionDirection = LEFT; this.selectionEnd = start; } this.selectionStart = newSelection; } else if (newSelection > start && newSelection < end) { if (this._selectionDirection === RIGHT) { this.selectionEnd = newSelection; } else { this.selectionStart = newSelection; } } else { // newSelection is > selection start and end if (end === start) { this._selectionDirection = RIGHT; } else if (this._selectionDirection === LEFT) { this._selectionDirection = RIGHT; this.selectionStart = end; } this.selectionEnd = newSelection; } } }