fabric.js/src/mixins/itext_behavior.mixin.js

722 lines
20 KiB
JavaScript

(function() {
var clone = fabric.util.object.clone;
fabric.util.object.extend(fabric.IText.prototype, /** @lends fabric.IText.prototype */ {
/**
* Initializes all the interactive behavior of IText
*/
initBehavior: function() {
this.initAddedHandler();
this.initCursorSelectionHandlers();
this.initDoubleClickSimulation();
},
/**
* Initializes "selected" event handler
*/
initSelectedHandler: function() {
this.on('selected', function() {
var _this = this;
setTimeout(function() {
_this.selected = true;
}, 100);
});
},
/**
* Initializes "added" event handler
*/
initAddedHandler: function() {
this.on('added', function() {
if (this.canvas && !this.canvas._hasITextHandlers) {
this.canvas._hasITextHandlers = true;
this._initCanvasHandlers();
}
});
},
/**
* @private
*/
_initCanvasHandlers: function() {
this.canvas.on('selection:cleared', function() {
fabric.IText.prototype.exitEditingOnOthers.call();
});
this.canvas.on('mouse:up', function() {
fabric.IText.instances.forEach(function(obj) {
obj.__isMousedown = false;
});
});
this.canvas.on('object:selected', function(options) {
fabric.IText.prototype.exitEditingOnOthers.call(options.target);
});
},
/**
* @private
*/
_tick: function() {
var tickState, _this = this;
tickState = {
isAborted: false,
abort: function() {
this.isAborted = true;
},
};
this.animate('_currentCursorOpacity', 1, {
duration: this.cursorDuration,
onComplete: function() {
if (!tickState.isAborted) {
_this._onTickComplete();
}
},
onChange: function() {
_this.canvas && _this.canvas.renderAll();
},
abort: function() {
return tickState.isAborted;
}
});
this._currentTickState = tickState;
},
/**
* @private
*/
_onTickComplete: function() {
var tickState, _this = this;
tickState = {
isAborted: false,
abort: function() {
this.isAborted = true;
},
};
if (this._cursorTimeout1) {
clearTimeout(this._cursorTimeout1);
}
this._cursorTimeout1 = setTimeout(function() {
_this.animate('_currentCursorOpacity', 0, {
duration: this.cursorDuration / 2,
onComplete: function() {
if (!tickState.isAborted) {
_this._tick();
}
},
onChange: function() {
_this.canvas && _this.canvas.renderAll();
},
abort: function() {
return tickState.isAborted;
}
});
}, 100);
this._currentTickCompleteState = tickState;
},
/**
* Initializes delayed cursor
*/
initDelayedCursor: function(restart) {
var _this = this,
delay = restart ? 0 : this.cursorDelay;
if (restart) {
this._currentTickState && this._currentTickState.abort();
this._currentTickCompleteState && this._currentTickCompleteState.abort();
clearTimeout(this._cursorTimeout1);
this._currentCursorOpacity = 1;
this.canvas && this.canvas.renderAll();
}
if (this._cursorTimeout2) {
clearTimeout(this._cursorTimeout2);
}
this._cursorTimeout2 = setTimeout(function() {
_this._tick();
}, delay);
},
/**
* Aborts cursor animation and clears all timeouts
*/
abortCursorAnimation: function() {
this._currentTickState && this._currentTickState.abort();
this._currentTickCompleteState && this._currentTickCompleteState.abort();
clearTimeout(this._cursorTimeout1);
clearTimeout(this._cursorTimeout2);
this._currentCursorOpacity = 0;
this.canvas && this.canvas.renderAll();
},
/**
* Selects entire text
*/
selectAll: function() {
this.setSelectionStart(0);
this.setSelectionEnd(this.text.length);
},
/**
* Returns selected text
* @return {String}
*/
getSelectedText: function() {
return this.text.slice(this.selectionStart, this.selectionEnd);
},
/**
* Find new selection index representing start of current word according to current selection index
* @param {Number} startFrom Surrent selection index
* @return {Number} New selection index
*/
findWordBoundaryLeft: function(startFrom) {
var offset = 0, index = startFrom - 1;
// remove space before cursor first
if (this._reSpace.test(this.text.charAt(index))) {
while (this._reSpace.test(this.text.charAt(index))) {
offset++;
index--;
}
}
while (/\S/.test(this.text.charAt(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: function(startFrom) {
var offset = 0, index = startFrom;
// remove space after cursor first
if (this._reSpace.test(this.text.charAt(index))) {
while (this._reSpace.test(this.text.charAt(index))) {
offset++;
index++;
}
}
while (/\S/.test(this.text.charAt(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: function(startFrom) {
var offset = 0, index = startFrom - 1;
while (!/\n/.test(this.text.charAt(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: function(startFrom) {
var offset = 0, index = startFrom;
while (!/\n/.test(this.text.charAt(index)) && index < this.text.length) {
offset++;
index++;
}
return startFrom + offset;
},
/**
* Returns number of newlines in selected text
* @return {Number} Number of newlines in selected text
*/
getNumNewLinesInSelectedText: function() {
var selectedText = this.getSelectedText(),
numNewLines = 0;
for (var i = 0, chars = selectedText.split(''), len = chars.length; i < len; i++) {
if (chars[i] === '\n') {
numNewLines++;
}
}
return numNewLines;
},
/**
* 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: function(selectionStart, direction) {
var index = this._reSpace.test(this.text.charAt(selectionStart)) ? selectionStart - 1 : selectionStart,
_char = this.text.charAt(index),
reNonWord = /[ \n\.,;!\?\-]/;
while (!reNonWord.test(_char) && index > 0 && index < this.text.length) {
index += direction;
_char = this.text.charAt(index);
}
if (reNonWord.test(_char) && _char !== '\n') {
index += direction === 1 ? 0 : 1;
}
return index;
},
/**
* Selects a word based on the index
* @param {Number} selectionStart Index of a character
*/
selectWord: function(selectionStart) {
var newSelectionStart = this.searchWordBoundary(selectionStart, -1), /* search backwards */
newSelectionEnd = this.searchWordBoundary(selectionStart, 1); /* search forward */
this.setSelectionStart(newSelectionStart);
this.setSelectionEnd(newSelectionEnd);
this.initDelayedCursor(true);
},
/**
* Selects a line based on the index
* @param {Number} selectionStart Index of a character
*/
selectLine: function(selectionStart) {
var newSelectionStart = this.findLineBoundaryLeft(selectionStart),
newSelectionEnd = this.findLineBoundaryRight(selectionStart);
this.setSelectionStart(newSelectionStart);
this.setSelectionEnd(newSelectionEnd);
this.initDelayedCursor(true);
},
/**
* Enters editing state
* @return {fabric.IText} thisArg
* @chainable
*/
enterEditing: function() {
if (this.isEditing || !this.editable) {
return;
}
this.exitEditingOnOthers();
this.isEditing = true;
this.initHiddenTextarea();
this.hiddenTextarea.focus();
this._updateTextarea();
this._saveEditingProps();
this._setEditingProps();
this._tick();
this.fire('editing:entered');
if (this.canvas) {
var _this = this;
this.canvas.renderAll();
this.canvas.fire('text:editing:entered', { target: this });
this.canvas.on('mouse:move', function(options) {
if (!_this.__isMousedown || !_this.isEditing) {
return;
}
var newSelectionStart = _this.getSelectionStartFromPointer(options.e);
if (newSelectionStart >= _this.__selectionStartOnMouseDown) {
_this.setSelectionStart(_this.__selectionStartOnMouseDown);
_this.setSelectionEnd(newSelectionStart);
}
else {
_this.setSelectionStart(newSelectionStart);
_this.setSelectionEnd(_this.__selectionStartOnMouseDown);
}
});
}
return this;
},
exitEditingOnOthers: function() {
fabric.IText.instances.forEach(function(obj) {
obj.selected = false;
if (obj.isEditing) {
obj.exitEditing();
}
}, this);
},
/**
* @private
*/
_setEditingProps: function() {
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;
},
/**
* @private
*/
_updateTextarea: function() {
if (!this.hiddenTextarea) {
return;
}
this.hiddenTextarea.value = this.text;
this.hiddenTextarea.selectionStart = this.selectionStart;
this.hiddenTextarea.selectionEnd = this.selectionEnd;
},
/**
* @private
*/
_saveEditingProps: function() {
this._savedProps = {
hasControls: this.hasControls,
borderColor: this.borderColor,
lockMovementX: this.lockMovementX,
lockMovementY: this.lockMovementY,
hoverCursor: this.hoverCursor,
defaultCursor: this.canvas && this.canvas.defaultCursor,
moveCursor: this.canvas && this.canvas.moveCursor
};
},
/**
* @private
*/
_restoreEditingProps: function() {
if (!this._savedProps) {
return;
}
this.hoverCursor = this._savedProps.overCursor;
this.hasControls = this._savedProps.hasControls;
this.borderColor = this._savedProps.borderColor;
this.lockMovementX = this._savedProps.lockMovementX;
this.lockMovementY = this._savedProps.lockMovementY;
if (this.canvas) {
this.canvas.defaultCursor = this._savedProps.defaultCursor;
this.canvas.moveCursor = this._savedProps.moveCursor;
}
},
/**
* Exits from editing state
* @return {fabric.IText} thisArg
* @chainable
*/
exitEditing: function() {
this.selected = false;
this.isEditing = false;
this.selectable = true;
this.selectionEnd = this.selectionStart;
this.hiddenTextarea && this.canvas && this.hiddenTextarea.parentNode.removeChild(this.hiddenTextarea);
this.hiddenTextarea = null;
this.abortCursorAnimation();
this._restoreEditingProps();
this._currentCursorOpacity = 0;
this.fire('editing:exited');
this.canvas && this.canvas.fire('text:editing:exited', { target: this });
return this;
},
/**
* @private
*/
_removeExtraneousStyles: function() {
for (var prop in this.styles) {
if (!this._textLines[prop]) {
delete this.styles[prop];
}
}
},
/**
* @private
*/
_removeCharsFromTo: function(start, end) {
var i = end;
while (i !== start) {
var prevIndex = this.get2DCursorLocation(i).charIndex;
i--;
var index = this.get2DCursorLocation(i).charIndex,
isNewline = index > prevIndex;
if (isNewline) {
this.removeStyleObject(isNewline, i + 1);
}
else {
this.removeStyleObject(this.get2DCursorLocation(i).charIndex === 0, i);
}
}
this.text = this.text.slice(0, start) +
this.text.slice(end);
this._clearCache();
},
/**
* Inserts a character where cursor is (replacing selection if one exists)
* @param {String} _chars Characters to insert
*/
insertChars: function(_chars, useCopiedStyle) {
var isEndOfLine = this.text.slice(this.selectionStart, this.selectionStart + 1) === '\n';
this.text = this.text.slice(0, this.selectionStart) +
_chars +
this.text.slice(this.selectionEnd);
if (this.selectionStart === this.selectionEnd) {
this.insertStyleObjects(_chars, isEndOfLine, useCopiedStyle);
}
// else if (this.selectionEnd - this.selectionStart > 1) {
// TODO: replace styles properly
// console.log('replacing MORE than 1 char');
// }
this.setSelectionStart(this.selectionStart + _chars.length);
this.setSelectionEnd(this.selectionStart);
this._clearCache();
this.canvas && this.canvas.renderAll();
this.setCoords();
this.fire('changed');
this.canvas && this.canvas.fire('text:changed', { target: this });
},
/**
* Inserts new style object
* @param {Number} lineIndex Index of a line
* @param {Number} charIndex Index of a char
* @param {Boolean} isEndOfLine True if it's end of line
*/
insertNewlineStyleObject: function(lineIndex, charIndex, isEndOfLine) {
this.shiftLineStyles(lineIndex, +1);
if (!this.styles[lineIndex + 1]) {
this.styles[lineIndex + 1] = { };
}
var currentCharStyle = this.styles[lineIndex][charIndex - 1],
newLineStyles = { };
// if there's nothing after cursor,
// we clone current char style onto the next (otherwise empty) line
if (isEndOfLine) {
newLineStyles[0] = clone(currentCharStyle);
this.styles[lineIndex + 1] = newLineStyles;
}
// otherwise we clone styles of all chars
// after cursor onto the next line, from the beginning
else {
for (var index in this.styles[lineIndex]) {
if (parseInt(index, 10) >= charIndex) {
newLineStyles[parseInt(index, 10) - charIndex] = this.styles[lineIndex][index];
// remove lines from the previous line since they're on a new line now
delete this.styles[lineIndex][index];
}
}
this.styles[lineIndex + 1] = newLineStyles;
}
this._clearCache();
},
/**
* Inserts style object for a given line/char index
* @param {Number} lineIndex Index of a line
* @param {Number} charIndex Index of a char
* @param {Object} [style] Style object to insert, if given
*/
insertCharStyleObject: function(lineIndex, charIndex, style) {
var currentLineStyles = this.styles[lineIndex],
currentLineStylesCloned = clone(currentLineStyles);
if (charIndex === 0 && !style) {
charIndex = 1;
}
// shift all char styles by 1 forward
// 0,1,2,3 -> (charIndex=2) -> 0,1,3,4 -> (insert 2) -> 0,1,2,3,4
for (var index in currentLineStylesCloned) {
var numericIndex = parseInt(index, 10);
if (numericIndex >= charIndex) {
currentLineStyles[numericIndex + 1] = currentLineStylesCloned[numericIndex];
//delete currentLineStyles[index];
}
}
this.styles[lineIndex][charIndex] =
style || clone(currentLineStyles[charIndex - 1]);
this._clearCache();
},
/**
* Inserts style object(s)
* @param {String} _chars Characters at the location where style is inserted
* @param {Boolean} isEndOfLine True if it's end of line
* @param {Boolean} [useCopiedStyle] Style to insert
*/
insertStyleObjects: function(_chars, isEndOfLine, useCopiedStyle) {
// removed shortcircuit over isEmptyStyles
var cursorLocation = this.get2DCursorLocation(),
lineIndex = cursorLocation.lineIndex,
charIndex = cursorLocation.charIndex;
if (!this.styles[lineIndex]) {
this.styles[lineIndex] = { };
}
if (_chars === '\n') {
this.insertNewlineStyleObject(lineIndex, charIndex, isEndOfLine);
}
else {
if (useCopiedStyle) {
this._insertStyles(this.copiedStyles);
}
else {
// TODO: support multiple style insertion if _chars.length > 1
this.insertCharStyleObject(lineIndex, charIndex);
}
}
},
/**
* @private
*/
_insertStyles: function(styles) {
for (var i = 0, len = styles.length; i < len; i++) {
var cursorLocation = this.get2DCursorLocation(this.selectionStart + i),
lineIndex = cursorLocation.lineIndex,
charIndex = cursorLocation.charIndex;
this.insertCharStyleObject(lineIndex, charIndex, styles[i]);
}
},
/**
* Shifts line styles up or down
* @param {Number} lineIndex Index of a line
* @param {Number} offset Can be -1 or +1
*/
shiftLineStyles: function(lineIndex, offset) {
// shift all line styles by 1 upward
var clonedStyles = clone(this.styles);
for (var line in this.styles) {
var numericLine = parseInt(line, 10);
if (numericLine > lineIndex) {
this.styles[numericLine + offset] = clonedStyles[numericLine];
}
}
},
/**
* Removes style object
* @param {Boolean} isBeginningOfLine True if cursor is at the beginning of line
* @param {Number} [index] Optional index. When not given, current selectionStart is used.
*/
removeStyleObject: function(isBeginningOfLine, index) {
var cursorLocation = this.get2DCursorLocation(index),
lineIndex = cursorLocation.lineIndex,
charIndex = cursorLocation.charIndex;
if (isBeginningOfLine) {
var textOnPreviousLine = this._textLines[lineIndex - 1],
newCharIndexOnPrevLine = textOnPreviousLine
? textOnPreviousLine.length
: 0;
if (!this.styles[lineIndex - 1]) {
this.styles[lineIndex - 1] = { };
}
for (charIndex in this.styles[lineIndex]) {
this.styles[lineIndex - 1][parseInt(charIndex, 10) + newCharIndexOnPrevLine]
= this.styles[lineIndex][charIndex];
}
this.shiftLineStyles(lineIndex, -1);
}
else {
var currentLineStyles = this.styles[lineIndex];
if (currentLineStyles) {
var offset = this.selectionStart === this.selectionEnd ? -1 : 0;
delete currentLineStyles[charIndex + offset];
// console.log('deleting', lineIndex, charIndex + offset);
}
var currentLineStylesCloned = clone(currentLineStyles);
// shift all styles by 1 backwards
for (var i in currentLineStylesCloned) {
var numericIndex = parseInt(i, 10);
if (numericIndex >= charIndex && numericIndex !== 0) {
currentLineStyles[numericIndex - 1] = currentLineStylesCloned[numericIndex];
delete currentLineStyles[numericIndex];
}
}
}
},
/**
* Inserts new line
*/
insertNewline: function() {
this.insertChars('\n');
}
});
})();