UNPKG

fabric

Version:

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

693 lines (644 loc) 20.7 kB
import { config } from '../../config'; import { getFabricDocument, getEnv } from '../../env'; import { capValue } from '../../util/misc/capValue'; import type { ITextEvents } from './ITextBehavior'; import { ITextBehavior } from './ITextBehavior'; import type { TKeyMapIText } from './constants'; import type { TOptions } from '../../typedefs'; import type { TextProps, SerializedTextProps } from '../Text/Text'; import { getDocumentFromElement } from '../../util/dom_misc'; import { CHANGED, LEFT, RIGHT } from '../../constants'; import type { IText } from './IText'; import type { TextStyleDeclaration } from '../Text/StyledText'; export abstract class ITextKeyBehavior< Props extends TOptions<TextProps> = Partial<TextProps>, SProps extends SerializedTextProps = SerializedTextProps, EventSpec extends ITextEvents = ITextEvents, > extends ITextBehavior<Props, SProps, EventSpec> { /** * For functionalities on keyDown * Map a special key to a function of the instance/prototype * If you need different behavior for ESC or TAB or arrows, you have to change * this map setting the name of a function that you build on the IText or * your prototype. * the map change will affect all Instances unless you need for only some text Instances * in that case you have to clone this object and assign your Instance. * this.keysMap = Object.assign({}, this.keysMap); * The function must be in IText.prototype.myFunction And will receive event as args[0] */ declare keysMap: TKeyMapIText; declare keysMapRtl: TKeyMapIText; /** * For functionalities on keyUp + ctrl || cmd */ declare ctrlKeysMapUp: TKeyMapIText; /** * For functionalities on keyDown + ctrl || cmd */ declare ctrlKeysMapDown: TKeyMapIText; declare hiddenTextarea: HTMLTextAreaElement | null; /** * DOM container to append the hiddenTextarea. * An alternative to attaching to the document.body. * Useful to reduce laggish redraw of the full document.body tree and * also with modals event capturing that won't let the textarea take focus. * @type HTMLElement * @default */ declare hiddenTextareaContainer?: HTMLElement | null; private declare _clickHandlerInitialized: boolean; private declare _copyDone: boolean; private declare fromPaste: boolean; /** * Initializes hidden textarea (needed to bring up keyboard in iOS) */ initHiddenTextarea() { const doc = (this.canvas && getDocumentFromElement(this.canvas.getElement())) || getFabricDocument(); const textarea = doc.createElement('textarea'); Object.entries({ autocapitalize: 'off', autocorrect: 'off', autocomplete: 'off', spellcheck: 'false', 'data-fabric': 'textarea', wrap: 'off', }).map(([attribute, value]) => textarea.setAttribute(attribute, value)); const { top, left, fontSize } = this._calcTextareaPosition(); // line-height: 1px; was removed from the style to fix this: // https://bugs.chromium.org/p/chromium/issues/detail?id=870966 textarea.style.cssText = `position: absolute; top: ${top}; left: ${left}; z-index: -999; opacity: 0; width: 1px; height: 1px; font-size: 1px; padding-top: ${fontSize};`; (this.hiddenTextareaContainer || doc.body).appendChild(textarea); Object.entries({ blur: 'blur', keydown: 'onKeyDown', keyup: 'onKeyUp', input: 'onInput', copy: 'copy', cut: 'copy', paste: 'paste', compositionstart: 'onCompositionStart', compositionupdate: 'onCompositionUpdate', compositionend: 'onCompositionEnd', } as Record<string, keyof this>).map(([eventName, handler]) => textarea.addEventListener( eventName, (this[handler] as EventListener).bind(this), ), ); this.hiddenTextarea = textarea; } /** * Override this method to customize cursor behavior on textbox blur */ blur() { this.abortCursorAnimation(); } /** * Handles keydown event * only used for arrows and combination of modifier keys. * @param {KeyboardEvent} e Event object */ onKeyDown(e: KeyboardEvent) { if (!this.isEditing) { return; } const keyMap = this.direction === 'rtl' ? this.keysMapRtl : this.keysMap; if (e.keyCode in keyMap) { (this[keyMap[e.keyCode] as keyof this] as (arg: KeyboardEvent) => void)( e, ); } else if (e.keyCode in this.ctrlKeysMapDown && (e.ctrlKey || e.metaKey)) { ( this[this.ctrlKeysMapDown[e.keyCode] as keyof this] as ( arg: KeyboardEvent, ) => void )(e); } else { return; } e.stopImmediatePropagation(); e.preventDefault(); if (e.keyCode >= 33 && e.keyCode <= 40) { // if i press an arrow key just update selection this.inCompositionMode = false; this.clearContextTop(); this.renderCursorOrSelection(); } else { this.canvas && this.canvas.requestRenderAll(); } } /** * Handles keyup event * We handle KeyUp because ie11 and edge have difficulties copy/pasting * if a copy/cut event fired, keyup is dismissed * @param {KeyboardEvent} e Event object */ onKeyUp(e: KeyboardEvent) { if (!this.isEditing || this._copyDone || this.inCompositionMode) { this._copyDone = false; return; } if (e.keyCode in this.ctrlKeysMapUp && (e.ctrlKey || e.metaKey)) { ( this[this.ctrlKeysMapUp[e.keyCode] as keyof this] as ( arg: KeyboardEvent, ) => void )(e); } else { return; } e.stopImmediatePropagation(); e.preventDefault(); this.canvas && this.canvas.requestRenderAll(); } /** * Handles onInput event * @param {Event} e Event object */ onInput(this: this & { hiddenTextarea: HTMLTextAreaElement }, e: Event) { const fromPaste = this.fromPaste; const { value, selectionStart, selectionEnd } = this.hiddenTextarea; this.fromPaste = false; e && e.stopPropagation(); if (!this.isEditing) { return; } const updateAndFire = () => { this.updateFromTextArea(); this.fire(CHANGED); if (this.canvas) { this.canvas.fire('text:changed', { target: this as unknown as IText }); this.canvas.requestRenderAll(); } }; if (this.hiddenTextarea.value === '') { this.styles = {}; updateAndFire(); return; } // decisions about style changes. const nextText = this._splitTextIntoLines(value).graphemeText, charCount = this._text.length, nextCharCount = nextText.length, _selectionStart = this.selectionStart, _selectionEnd = this.selectionEnd, selection = _selectionStart !== _selectionEnd; let copiedStyle: TextStyleDeclaration[] | undefined, removedText, charDiff = nextCharCount - charCount, removeFrom, removeTo; const textareaSelection = this.fromStringToGraphemeSelection( selectionStart, selectionEnd, value, ); const backDelete = _selectionStart > textareaSelection.selectionStart; if (selection) { removedText = this._text.slice(_selectionStart, _selectionEnd); charDiff += _selectionEnd - _selectionStart; } else if (nextCharCount < charCount) { if (backDelete) { removedText = this._text.slice(_selectionEnd + charDiff, _selectionEnd); } else { removedText = this._text.slice( _selectionStart, _selectionStart - charDiff, ); } } const insertedText = nextText.slice( textareaSelection.selectionEnd - charDiff, textareaSelection.selectionEnd, ); if (removedText && removedText.length) { if (insertedText.length) { // let's copy some style before deleting. // we want to copy the style before the cursor OR the style at the cursor if selection // is bigger than 0. copiedStyle = this.getSelectionStyles( _selectionStart, _selectionStart + 1, false, ); // now duplicate the style one for each inserted text. copiedStyle = insertedText.map( () => // this return an array of references, but that is fine since we are // copying the style later. copiedStyle![0], ); } if (selection) { removeFrom = _selectionStart; removeTo = _selectionEnd; } else if (backDelete) { // detect differences between forwardDelete and backDelete removeFrom = _selectionEnd - removedText.length; removeTo = _selectionEnd; } else { removeFrom = _selectionEnd; removeTo = _selectionEnd + removedText.length; } this.removeStyleFromTo(removeFrom, removeTo); } if (insertedText.length) { const { copyPasteData } = getEnv(); if ( fromPaste && insertedText.join('') === copyPasteData.copiedText && !config.disableStyleCopyPaste ) { copiedStyle = copyPasteData.copiedTextStyle; } this.insertNewStyleBlock(insertedText, _selectionStart, copiedStyle); } updateAndFire(); } /** * Composition start */ onCompositionStart() { this.inCompositionMode = true; } /** * Composition end */ onCompositionEnd() { this.inCompositionMode = false; } onCompositionUpdate({ target }: CompositionEvent) { const { selectionStart, selectionEnd } = target as HTMLTextAreaElement; this.compositionStart = selectionStart; this.compositionEnd = selectionEnd; this.updateTextareaPosition(); } /** * Copies selected text */ copy() { if (this.selectionStart === this.selectionEnd) { //do not cut-copy if no selection return; } const { copyPasteData } = getEnv(); copyPasteData.copiedText = this.getSelectedText(); if (!config.disableStyleCopyPaste) { copyPasteData.copiedTextStyle = this.getSelectionStyles( this.selectionStart, this.selectionEnd, true, ); } else { copyPasteData.copiedTextStyle = undefined; } this._copyDone = true; } /** * Pastes text */ paste() { this.fromPaste = true; } /** * Finds the width in pixels before the cursor on the same line * @private * @param {Number} lineIndex * @param {Number} charIndex * @return {Number} widthBeforeCursor width before cursor */ _getWidthBeforeCursor(lineIndex: number, charIndex: number): number { let widthBeforeCursor = this._getLineLeftOffset(lineIndex), bound; if (charIndex > 0) { bound = this.__charBounds[lineIndex][charIndex - 1]; widthBeforeCursor += bound.left + bound.width; } return widthBeforeCursor; } /** * Gets start offset of a selection * @param {KeyboardEvent} e Event object * @param {Boolean} isRight * @return {Number} */ getDownCursorOffset(e: KeyboardEvent, isRight: boolean): number { const selectionProp = this._getSelectionForOffset(e, isRight), cursorLocation = this.get2DCursorLocation(selectionProp), lineIndex = cursorLocation.lineIndex; // if on last line, down cursor goes to end of line if ( lineIndex === this._textLines.length - 1 || e.metaKey || e.keyCode === 34 ) { // move to the end of a text return this._text.length - selectionProp; } const charIndex = cursorLocation.charIndex, widthBeforeCursor = this._getWidthBeforeCursor(lineIndex, charIndex), indexOnOtherLine = this._getIndexOnLine(lineIndex + 1, widthBeforeCursor), textAfterCursor = this._textLines[lineIndex].slice(charIndex); return ( textAfterCursor.length + indexOnOtherLine + 1 + this.missingNewlineOffset(lineIndex) ); } /** * private * Helps finding if the offset should be counted from Start or End * @param {KeyboardEvent} e Event object * @param {Boolean} isRight * @return {Number} */ _getSelectionForOffset(e: KeyboardEvent, isRight: boolean): number { if (e.shiftKey && this.selectionStart !== this.selectionEnd && isRight) { return this.selectionEnd; } else { return this.selectionStart; } } /** * @param {KeyboardEvent} e Event object * @param {Boolean} isRight * @return {Number} */ getUpCursorOffset(e: KeyboardEvent, isRight: boolean): number { const selectionProp = this._getSelectionForOffset(e, isRight), cursorLocation = this.get2DCursorLocation(selectionProp), lineIndex = cursorLocation.lineIndex; if (lineIndex === 0 || e.metaKey || e.keyCode === 33) { // if on first line, up cursor goes to start of line return -selectionProp; } const charIndex = cursorLocation.charIndex, widthBeforeCursor = this._getWidthBeforeCursor(lineIndex, charIndex), indexOnOtherLine = this._getIndexOnLine(lineIndex - 1, widthBeforeCursor), textBeforeCursor = this._textLines[lineIndex].slice(0, charIndex), missingNewlineOffset = this.missingNewlineOffset(lineIndex - 1); // return a negative offset return ( -this._textLines[lineIndex - 1].length + indexOnOtherLine - textBeforeCursor.length + (1 - missingNewlineOffset) ); } /** * for a given width it founds the matching character. * @private */ _getIndexOnLine(lineIndex: number, width: number) { const line = this._textLines[lineIndex], lineLeftOffset = this._getLineLeftOffset(lineIndex); let widthOfCharsOnLine = lineLeftOffset, indexOnLine = 0, charWidth, foundMatch; for (let j = 0, jlen = line.length; j < jlen; j++) { charWidth = this.__charBounds[lineIndex][j].width; widthOfCharsOnLine += charWidth; if (widthOfCharsOnLine > width) { foundMatch = true; const leftEdge = widthOfCharsOnLine - charWidth, rightEdge = widthOfCharsOnLine, offsetFromLeftEdge = Math.abs(leftEdge - width), offsetFromRightEdge = Math.abs(rightEdge - width); indexOnLine = offsetFromRightEdge < offsetFromLeftEdge ? j : j - 1; break; } } // reached end if (!foundMatch) { indexOnLine = line.length - 1; } return indexOnLine; } /** * Moves cursor down * @param {KeyboardEvent} e Event object */ moveCursorDown(e: KeyboardEvent) { if ( this.selectionStart >= this._text.length && this.selectionEnd >= this._text.length ) { return; } this._moveCursorUpOrDown('Down', e); } /** * Moves cursor up * @param {KeyboardEvent} e Event object */ moveCursorUp(e: KeyboardEvent) { if (this.selectionStart === 0 && this.selectionEnd === 0) { return; } this._moveCursorUpOrDown('Up', e); } /** * Moves cursor up or down, fires the events * @param {String} direction 'Up' or 'Down' * @param {KeyboardEvent} e Event object */ _moveCursorUpOrDown(direction: 'Up' | 'Down', e: KeyboardEvent) { const offset = this[`get${direction}CursorOffset`]( e, this._selectionDirection === RIGHT, ); if (e.shiftKey) { this.moveCursorWithShift(offset); } else { this.moveCursorWithoutShift(offset); } if (offset !== 0) { const max = this.text.length; this.selectionStart = capValue(0, this.selectionStart, max); this.selectionEnd = capValue(0, this.selectionEnd, max); // TODO fix: abort and init should be an alternative depending // on selectionStart/End being equal or different this.abortCursorAnimation(); this.initDelayedCursor(); this._fireSelectionChanged(); this._updateTextarea(); } } /** * Moves cursor with shift * @param {Number} offset */ moveCursorWithShift(offset: number) { const newSelection = this._selectionDirection === LEFT ? this.selectionStart + offset : this.selectionEnd + offset; this.setSelectionStartEndWithShift( this.selectionStart, this.selectionEnd, newSelection, ); return offset !== 0; } /** * Moves cursor up without shift * @param {Number} offset */ moveCursorWithoutShift(offset: number) { if (offset < 0) { this.selectionStart += offset; this.selectionEnd = this.selectionStart; } else { this.selectionEnd += offset; this.selectionStart = this.selectionEnd; } return offset !== 0; } /** * Moves cursor left * @param {KeyboardEvent} e Event object */ moveCursorLeft(e: KeyboardEvent) { if (this.selectionStart === 0 && this.selectionEnd === 0) { return; } this._moveCursorLeftOrRight('Left', e); } /** * @private * @return {Boolean} true if a change happened * * @todo refactor not to use method name composition */ _move( e: KeyboardEvent, prop: 'selectionStart' | 'selectionEnd', direction: 'Left' | 'Right', ): boolean { let newValue: number | undefined; if (e.altKey) { newValue = this[`findWordBoundary${direction}`](this[prop]); } else if (e.metaKey || e.keyCode === 35 || e.keyCode === 36) { newValue = this[`findLineBoundary${direction}`](this[prop]); } else { this[prop] += direction === 'Left' ? -1 : 1; return true; } if (typeof newValue !== 'undefined' && this[prop] !== newValue) { this[prop] = newValue; return true; } return false; } /** * @private */ _moveLeft(e: KeyboardEvent, prop: 'selectionStart' | 'selectionEnd') { return this._move(e, prop, 'Left'); } /** * @private */ _moveRight(e: KeyboardEvent, prop: 'selectionStart' | 'selectionEnd') { return this._move(e, prop, 'Right'); } /** * Moves cursor left without keeping selection * @param {KeyboardEvent} e */ moveCursorLeftWithoutShift(e: KeyboardEvent) { let change = true; this._selectionDirection = LEFT; // only move cursor when there is no selection, // otherwise we discard it, and leave cursor on same place if ( this.selectionEnd === this.selectionStart && this.selectionStart !== 0 ) { change = this._moveLeft(e, 'selectionStart'); } this.selectionEnd = this.selectionStart; return change; } /** * Moves cursor left while keeping selection * @param {KeyboardEvent} e */ moveCursorLeftWithShift(e: KeyboardEvent) { if ( this._selectionDirection === RIGHT && this.selectionStart !== this.selectionEnd ) { return this._moveLeft(e, 'selectionEnd'); } else if (this.selectionStart !== 0) { this._selectionDirection = LEFT; return this._moveLeft(e, 'selectionStart'); } } /** * Moves cursor right * @param {KeyboardEvent} e Event object */ moveCursorRight(e: KeyboardEvent) { if ( this.selectionStart >= this._text.length && this.selectionEnd >= this._text.length ) { return; } this._moveCursorLeftOrRight('Right', e); } /** * Moves cursor right or Left, fires event * @param {String} direction 'Left', 'Right' * @param {KeyboardEvent} e Event object */ _moveCursorLeftOrRight(direction: 'Left' | 'Right', e: KeyboardEvent) { const actionName = `moveCursor${direction}${ e.shiftKey ? 'WithShift' : 'WithoutShift' }` as const; this._currentCursorOpacity = 1; if (this[actionName](e)) { // TODO fix: abort and init should be an alternative depending // on selectionStart/End being equal or different this.abortCursorAnimation(); this.initDelayedCursor(); this._fireSelectionChanged(); this._updateTextarea(); } } /** * Moves cursor right while keeping selection * @param {KeyboardEvent} e */ moveCursorRightWithShift(e: KeyboardEvent) { if ( this._selectionDirection === LEFT && this.selectionStart !== this.selectionEnd ) { return this._moveRight(e, 'selectionStart'); } else if (this.selectionEnd !== this._text.length) { this._selectionDirection = RIGHT; return this._moveRight(e, 'selectionEnd'); } } /** * Moves cursor right without keeping selection * @param {KeyboardEvent} e Event object */ moveCursorRightWithoutShift(e: KeyboardEvent) { let changed = true; this._selectionDirection = RIGHT; if (this.selectionStart === this.selectionEnd) { changed = this._moveRight(e, 'selectionStart'); this.selectionEnd = this.selectionStart; } else { this.selectionStart = this.selectionEnd; } return changed; } }