UNPKG

fabric

Version:

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

604 lines (567 loc) 18.6 kB
import { config } from '../../config.mjs'; import { getFabricDocument, getEnv } from '../../env/index.mjs'; import { capValue } from '../../util/misc/capValue.mjs'; import { ITextBehavior } from './ITextBehavior.mjs'; import { getDocumentFromElement } from '../../util/dom_misc.mjs'; import { RIGHT, LEFT, CHANGED } from '../../constants.mjs'; class ITextKeyBehavior extends ITextBehavior { /** * 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] */ /** * For functionalities on keyUp + ctrl || cmd */ /** * For functionalities on keyDown + ctrl || cmd */ /** * 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 */ /** * 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(_ref => { let [attribute, value] = _ref; return 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: ".concat(top, "; left: ").concat(left, "; z-index: -999; opacity: 0; width: 1px; height: 1px; font-size: 1px; padding-top: ").concat(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' }).map(_ref2 => { let [eventName, handler] = _ref2; return textarea.addEventListener(eventName, this[handler].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) { if (!this.isEditing) { return; } const keyMap = this.direction === 'rtl' ? this.keysMapRtl : this.keysMap; if (e.keyCode in keyMap) { this[keyMap[e.keyCode]](e); } else if (e.keyCode in this.ctrlKeysMapDown && (e.ctrlKey || e.metaKey)) { this[this.ctrlKeysMapDown[e.keyCode]](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) { 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]](e); } else { return; } e.stopImmediatePropagation(); e.preventDefault(); this.canvas && this.canvas.requestRenderAll(); } /** * Handles onInput event * @param {Event} e Event object */ onInput(e) { 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 }); 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, 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(_ref3) { let { target } = _ref3; const { selectionStart, selectionEnd } = target; 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, charIndex) { 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, isRight) { 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, isRight) { 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, isRight) { 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, width) { 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) { 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) { 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, e) { const offset = this["get".concat(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) { 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) { 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) { 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, prop, direction) { let newValue; if (e.altKey) { newValue = this["findWordBoundary".concat(direction)](this[prop]); } else if (e.metaKey || e.keyCode === 35 || e.keyCode === 36) { newValue = this["findLineBoundary".concat(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, prop) { return this._move(e, prop, 'Left'); } /** * @private */ _moveRight(e, prop) { return this._move(e, prop, 'Right'); } /** * Moves cursor left without keeping selection * @param {KeyboardEvent} e */ moveCursorLeftWithoutShift(e) { 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) { 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) { 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, e) { const actionName = "moveCursor".concat(direction).concat(e.shiftKey ? 'WithShift' : 'WithoutShift'); 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) { 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) { 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; } } export { ITextKeyBehavior }; //# sourceMappingURL=ITextKeyBehavior.mjs.map