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
JavaScript
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