mirror of
https://github.com/Hopiu/fabric.js.git
synced 2026-05-11 07:13:09 +00:00
2029 lines
58 KiB
JavaScript
2029 lines
58 KiB
JavaScript
(function() {
|
|
|
|
var clone = fabric.util.object.clone;
|
|
|
|
function isEmptyStyles(obj) {
|
|
for (var p1 in obj) {
|
|
for (var p2 in obj[p1]) {
|
|
/*jshint unused:false */
|
|
for (var p3 in obj[p1][p2]) {
|
|
return false;
|
|
}
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* IText class
|
|
* @class fabric.IText
|
|
* @extends fabric.Text
|
|
* @mixes fabric.Observable
|
|
* @fires #text:changed
|
|
* @return {fabric.IText} thisArg
|
|
* @see {@link fabric.IText#initialize} for constructor definition
|
|
*
|
|
* Supported key combinations:
|
|
*
|
|
* Move cursor: left, right, up, down
|
|
* Select character: shift + left, shift + right
|
|
* Select text vertically: shift + up, shift + down
|
|
* Move cursor by word: alt + left, alt + right
|
|
* Select words: shift + alt + left, shift + alt + right
|
|
* Move cursor to line start/end: cmd + left, cmd + right
|
|
* Select till start/end of line: cmd + shift + left, cmd + shift + right
|
|
* Jump to start/end of text: cmd + up, cmd + down
|
|
* Select till start/end of text: cmd + shift + up, cmd + shift + down
|
|
* Delete character: delete
|
|
* Delete word: alt + delete
|
|
* Delete line: cmd + delete
|
|
*/
|
|
fabric.IText = fabric.util.createClass(fabric.Text, fabric.Observable, {
|
|
|
|
/**
|
|
* Type of an object
|
|
* @type String
|
|
* @default
|
|
*/
|
|
type: 'i-text',
|
|
|
|
/**
|
|
* Index where text selection starts (or where cursor is when there is no selection)
|
|
* @type Nubmer
|
|
* @default
|
|
*/
|
|
selectionStart: 0,
|
|
|
|
/**
|
|
* Index where text selection ends
|
|
* @type Nubmer
|
|
* @default
|
|
*/
|
|
selectionEnd: 0,
|
|
|
|
/**
|
|
* Color of text selection
|
|
* @type String
|
|
* @default
|
|
*/
|
|
selectionColor: 'rgba(17,119,255,0.3)',
|
|
|
|
/**
|
|
* Indicates whether text is in editing mode
|
|
* @type Boolean
|
|
* @default
|
|
*/
|
|
isEditing: false,
|
|
|
|
/**
|
|
* Indicates whether a text can be edited
|
|
* @type Boolean
|
|
* @default
|
|
*/
|
|
editable: true,
|
|
|
|
/**
|
|
* Border color of text object while it's in editing mode
|
|
* @type String
|
|
* @default
|
|
*/
|
|
editingBorderColor: 'rgba(102,153,255,0.25)',
|
|
|
|
/**
|
|
* Width of cursor (in px)
|
|
* @type Number
|
|
* @default
|
|
*/
|
|
cursorWidth: 2,
|
|
|
|
/**
|
|
* Color of default cursor (when not overwritten by character style)
|
|
* @type String
|
|
* @default
|
|
*/
|
|
cursorColor: '#333',
|
|
|
|
/**
|
|
* Delay between cursor blink (in ms)
|
|
* @type Number
|
|
* @default
|
|
*/
|
|
cursorDelay: 1000,
|
|
|
|
/**
|
|
* Duration of cursor fadein (in ms)
|
|
* @type Number
|
|
* @default
|
|
*/
|
|
cursorDuration: 600,
|
|
|
|
/**
|
|
* Object containing character styles
|
|
* (where top-level properties corresponds to line number and 2nd-level properties -- to char number in a line)
|
|
* @type Object
|
|
* @default
|
|
*/
|
|
styles: null,
|
|
|
|
skipFillStrokeCheck: true,
|
|
|
|
/**
|
|
* @private
|
|
*/
|
|
_reNewline: /\r?\n/,
|
|
|
|
/**
|
|
* @private
|
|
*/
|
|
_fontSizeFraction: 4,
|
|
|
|
/**
|
|
* @private
|
|
*/
|
|
_currentCursorOpacity: 0,
|
|
|
|
/**
|
|
* @private
|
|
*/
|
|
_selectionDirection: null,
|
|
|
|
/**
|
|
* @private
|
|
*/
|
|
_abortCursorAnimation: false,
|
|
|
|
/**
|
|
* Constructor
|
|
* @param {String} text Text string
|
|
* @param {Object} [options] Options object
|
|
* @return {fabric.IText} thisArg
|
|
*/
|
|
initialize: function(text, options) {
|
|
this.styles = options.styles || { };
|
|
|
|
this.callSuper('initialize', text, options);
|
|
|
|
fabric.util.addListener(document, 'keydown', this.onKeyUp.bind(this));
|
|
fabric.util.addListener(document, 'keypress', this.onKeyPress.bind(this));
|
|
|
|
this.initCursorSelectionHandlers();
|
|
this.initDblClickSimulation();
|
|
this.initHiddenTextarea();
|
|
},
|
|
|
|
/**
|
|
* Initializes hidden textarea (needed to bring up keyboard in iOS)
|
|
*/
|
|
initHiddenTextarea: function() {
|
|
if (!/(iPad|iPhone|iPod)/g.test(navigator.userAgent)) return;
|
|
|
|
this.hiddenTextarea = fabric.document.createElement('textarea');
|
|
|
|
this.hiddenTextarea.setAttribute('autocapitalize', 'off');
|
|
this.hiddenTextarea.style.cssText = 'position: absolute; top: 0; left: -9999px';
|
|
|
|
fabric.document.body.appendChild(this.hiddenTextarea);
|
|
},
|
|
|
|
/**
|
|
* Initializes "dbclick" event handler
|
|
*/
|
|
initDblClickSimulation: function() {
|
|
var lastClickTime = +new Date();
|
|
var newClickTime;
|
|
this.on('mousedown', function(options) {
|
|
newClickTime = +new Date();
|
|
if (newClickTime - lastClickTime < 500) {
|
|
this.fire('dblclick', options);
|
|
|
|
var event = options.e;
|
|
|
|
event.preventDefault && event.preventDefault();
|
|
event.stopPropagation && event.stopPropagation();
|
|
}
|
|
lastClickTime = newClickTime;
|
|
});
|
|
},
|
|
|
|
/**
|
|
* Initializes event handlers related to cursor or selection
|
|
*/
|
|
initCursorSelectionHandlers: function() {
|
|
this.initSelectedHandler();
|
|
this.initMousedownHandler();
|
|
this.initMousemoveHandler();
|
|
this.initMouseupHandler();
|
|
|
|
this.on('dblclick', function(options) {
|
|
this.selectWord(this.getSelectionStartFromPointer(options.e));
|
|
});
|
|
},
|
|
|
|
/**
|
|
* Initializes "mousedown" event handler
|
|
*/
|
|
initMousedownHandler: function() {
|
|
this.on('mousedown', function(options) {
|
|
var pointer = this.canvas.getPointer(options.e);
|
|
|
|
this.__mousedownX = pointer.x;
|
|
this.__mousedownY = pointer.y;
|
|
this.__isMousedown = true;
|
|
|
|
if (this.hiddenTextarea && this.canvas) {
|
|
this.canvas.wrapperEl.appendChild(this.hiddenTextarea);
|
|
}
|
|
|
|
if (this.isEditing) {
|
|
this.setCursorByClick(options.e);
|
|
}
|
|
});
|
|
},
|
|
|
|
/**
|
|
* Initializes "mousemove" event handler
|
|
*/
|
|
initMousemoveHandler: function() {
|
|
this.on('mousemove', function() {
|
|
if (this.__isMousedown && this.isEditing) {
|
|
console.log('mousemove: need to select text');
|
|
}
|
|
});
|
|
},
|
|
|
|
/**
|
|
* Initializes "mouseup" event handler
|
|
*/
|
|
initMouseupHandler: function() {
|
|
this.on('mouseup', function(options) {
|
|
this.__isMousedown = false;
|
|
|
|
var pointer = this.canvas.getPointer(options.e);
|
|
|
|
var isObjectMoved = this.__mousedownX !== pointer.x ||
|
|
this.__mousedownY !== pointer.y;
|
|
|
|
if (isObjectMoved) return;
|
|
|
|
if (this.selected) {
|
|
this.enterEditing();
|
|
}
|
|
});
|
|
},
|
|
|
|
/**
|
|
* Initializes "selected" event handler
|
|
*/
|
|
initSelectedHandler: function() {
|
|
this.on('selected', function() {
|
|
|
|
var _this = this;
|
|
setTimeout(function() {
|
|
_this.selected = true;
|
|
}, 100);
|
|
|
|
if (!this._hasClearSelectionListener) {
|
|
this.canvas.on('selection:cleared', function(options) {
|
|
// do not exit editing if event fired when clicking on an object again (in editing mode)
|
|
if (options.e && _this.canvas.findTarget(options.e)) return;
|
|
_this.exitEditing();
|
|
});
|
|
|
|
this._hasClearSelectionListener = true;
|
|
}
|
|
});
|
|
},
|
|
|
|
/**
|
|
* Sets selection start (left boundary of a selection)
|
|
* @param {Number} index Index to set selection start to
|
|
*/
|
|
setSelectionStart: function(index) {
|
|
this.selectionStart = index;
|
|
this.hiddenTextarea && (this.hiddenTextarea.selectionStart = index);
|
|
},
|
|
|
|
/**
|
|
* Sets selection end (right boundary of a selection)
|
|
* @param {Number} index Index to set selection end to
|
|
*/
|
|
setSelectionEnd: function(index) {
|
|
this.selectionEnd = index;
|
|
this.hiddenTextarea && (this.hiddenTextarea.selectionEnd = index);
|
|
},
|
|
|
|
/**
|
|
* Selects a word based on the index
|
|
* @param {Number} selectionStart Index of a character
|
|
*/
|
|
selectWord: function(selectionStart) {
|
|
|
|
function searchWordBoundary(direction) {
|
|
var index = selectionStart;
|
|
var _char = this.text.charAt(index);
|
|
var 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;
|
|
}
|
|
|
|
var newSelectionStart = searchWordBoundary.call(this, -1) /* search backwards */;
|
|
var newSelectionEnd = searchWordBoundary.call(this, 1) /* search forward */;
|
|
|
|
this.setSelectionStart(newSelectionStart);
|
|
this.setSelectionEnd(newSelectionEnd);
|
|
},
|
|
|
|
/**
|
|
* Returns coordinates of a pointer relative to an object
|
|
* @return {Object} Coordinates of a pointer (x, y)
|
|
*/
|
|
getLocalPointer: function(e) {
|
|
var pointer = this.canvas.getPointer(e);
|
|
var objectLeftTop = this.translateToOriginPoint(this.getCenterPoint(), 'left', 'top');
|
|
return {
|
|
x: pointer.x - objectLeftTop.x,
|
|
y: pointer.y - objectLeftTop.y
|
|
};
|
|
},
|
|
|
|
/**
|
|
* Changes cursor location in a text depending on passed pointer (x/y) object
|
|
* @param {Object} pointer Pointer object with x and y numeric properties
|
|
*/
|
|
setCursorByClick: function(e) {
|
|
var newSelectionStart = this.getSelectionStartFromPointer(e);
|
|
|
|
this.setSelectionStart(newSelectionStart);
|
|
this.setSelectionEnd(newSelectionStart);
|
|
},
|
|
|
|
/**
|
|
* Returns index of a character corresponding to where an object was clicked
|
|
* @param {Event} e Event object
|
|
* @return {Number} Index of a character
|
|
*/
|
|
getSelectionStartFromPointer: function(e) {
|
|
|
|
var localPointer = this.getLocalPointer(e);
|
|
var mouseOffsetX = localPointer.x;
|
|
var mouseOffsetY = localPointer.y;
|
|
var textLines = this.text.split(this._reNewline);
|
|
var prevWidth = 0;
|
|
var width = 0;
|
|
var height = 0;
|
|
var charIndex = 0;
|
|
var newSelectionStart;
|
|
|
|
for (var i = 0, len = textLines.length; i < len; i++) {
|
|
height += this._getHeightOfLine(this.ctx, i) * this.scaleY;
|
|
|
|
var widthOfLine = this._getWidthOfLine(this.ctx, i, textLines);
|
|
var lineLeftOffset = this._getLineLeftOffset(widthOfLine);
|
|
|
|
width = lineLeftOffset;
|
|
|
|
for (var j = 0, jlen = textLines[i].length; j < jlen; j++) {
|
|
var _char = textLines[i][j];
|
|
prevWidth = width;
|
|
width += this._getWidthOfChar(this.ctx, _char, i, j) * this.scaleX;
|
|
|
|
// debugging
|
|
// var objectLeftTop = this.translateToOriginPoint(this.getCenterPoint(), 'left', 'top');
|
|
// var ctx = this.canvas.upperCanvasEl.getContext('2d');
|
|
|
|
if (height > mouseOffsetY && width > mouseOffsetX) {
|
|
|
|
// ctx.save();
|
|
// ctx.strokeRect(objectLeftTop.x, objectLeftTop.y, width, height);
|
|
// ctx.translate(objectLeftTop.x, objectLeftTop.y);
|
|
// ctx.fillRect(mouseOffsetX, mouseOffsetY, 10, 10);
|
|
// ctx.restore();
|
|
|
|
var distanceBtwLastCharAndCursor = mouseOffsetX - prevWidth;
|
|
var distanceBtwNextCharAndCursor = width - mouseOffsetX;
|
|
|
|
if (distanceBtwNextCharAndCursor > distanceBtwLastCharAndCursor) {
|
|
newSelectionStart = charIndex + i;
|
|
// console.log('leaning left');
|
|
}
|
|
else {
|
|
// console.log('leaning right');
|
|
newSelectionStart = charIndex + i + 1;
|
|
}
|
|
|
|
if (newSelectionStart > this.text.length) {
|
|
newSelectionStart = this.text.length;
|
|
}
|
|
|
|
//this.canvas.renderAll();
|
|
return newSelectionStart;
|
|
}
|
|
|
|
charIndex++;
|
|
}
|
|
}
|
|
|
|
// clicked somewhere after all chars, so set at the end
|
|
if (typeof newSelectionStart === 'undefined') {
|
|
return this.text.length;
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Enters editing state
|
|
* @return {fabric.IText} thisArg
|
|
* @chainable
|
|
*/
|
|
enterEditing: function() {
|
|
if (this.isEditing || !this.editable) return;
|
|
|
|
this.isEditing = true;
|
|
|
|
if (this.hiddenTextarea) {
|
|
this.hiddenTextarea.value = this.text;
|
|
this.hiddenTextarea.selectionStart = this.selectionStart;
|
|
this.hiddenTextarea.focus();
|
|
}
|
|
|
|
this._savedProps = {
|
|
|
|
hasControls: this.hasControls,
|
|
borderColor: this.borderColor,
|
|
lockMovementX: this.lockMovementX,
|
|
lockMovementY: this.lockMovementY,
|
|
|
|
hoverCursor: this.hoverCursor,
|
|
defaultCursor: this.canvas.defaultCursor,
|
|
moveCursor: this.canvas.moveCursor
|
|
};
|
|
|
|
this.hoverCursor = 'text';
|
|
this.canvas.defaultCursor = 'text';
|
|
this.canvas.moveCursor = 'text';
|
|
|
|
this.hasControls = false;
|
|
this.borderColor = this.editingBorderColor;
|
|
this.selectable = false;
|
|
this.lockMovementX = true;
|
|
this.lockMovementY = true;
|
|
|
|
this._tick();
|
|
this.canvas.renderAll();
|
|
|
|
return this;
|
|
},
|
|
|
|
/**
|
|
* Exits from editing state
|
|
* @return {fabric.IText} thisArg
|
|
* @chainable
|
|
*/
|
|
exitEditing: function() {
|
|
|
|
this.selected = false;
|
|
this.isEditing = false;
|
|
this.selectable = true;
|
|
|
|
this.hiddenTextarea && this.hiddenTextarea.blur();
|
|
|
|
this.abortCursorAnimation();
|
|
|
|
if (this._savedProps) {
|
|
this.hoverCursor = this._savedProps.overCursor;
|
|
this.canvas.defaultCursor = this._savedProps.defaultCursor;
|
|
this.canvas.moveCursor = this._savedProps.moveCursor;
|
|
|
|
this.hasControls = this._savedProps.hasControls;
|
|
this.borderColor = this._savedProps.borderColor;
|
|
this.lockMovementX = this._savedProps.lockMovementX;
|
|
this.lockMovementY = this._savedProps.lockMovementY;
|
|
}
|
|
|
|
this._currentCursorOpacity = 0;
|
|
|
|
return this;
|
|
},
|
|
|
|
/**
|
|
* Gets style of a current selection/cursor (at the start position)
|
|
* @return {Object} styles Style object at a cursor position
|
|
*/
|
|
getSelectionStyles: function() {
|
|
var loc = this.get2DCursorLocation();
|
|
if (this.styles[loc.lineIndex]) {
|
|
return this.styles[loc.lineIndex][loc.charIndex] || { };
|
|
}
|
|
return { };
|
|
},
|
|
|
|
/**
|
|
* Sets style of a current selection
|
|
* @param {Object} [styles] Styles object
|
|
* @return {fabric.IText} thisArg
|
|
* @chainable
|
|
*/
|
|
setSelectionStyles: function(styles) {
|
|
|
|
function setStyle(i) {
|
|
var loc = this.get2DCursorLocation(i);
|
|
|
|
if (!this.styles[loc.lineIndex]) {
|
|
this.styles[loc.lineIndex] = { };
|
|
}
|
|
if (!this.styles[loc.lineIndex][loc.charIndex]) {
|
|
this.styles[loc.lineIndex][loc.charIndex] = { };
|
|
}
|
|
|
|
fabric.util.object.extend(this.styles[loc.lineIndex][loc.charIndex], styles);
|
|
}
|
|
|
|
if (this.selectionStart === this.selectionEnd) {
|
|
setStyle.call(this, this.selectionStart);
|
|
}
|
|
else {
|
|
for (var i = this.selectionStart; i < this.selectionEnd; i++) {
|
|
setStyle.call(this, i);
|
|
}
|
|
}
|
|
|
|
return this;
|
|
},
|
|
|
|
/**
|
|
* @private
|
|
*/
|
|
_tick: function() {
|
|
|
|
var _this = this;
|
|
|
|
if (this._abortCursorAnimation) return;
|
|
|
|
this.animate('_currentCursorOpacity', 1, {
|
|
|
|
duration: this.cursorDuration,
|
|
|
|
onComplete: function() {
|
|
_this._onTickComplete();
|
|
},
|
|
|
|
onChange: function() {
|
|
_this.canvas && _this.canvas.renderAll();
|
|
},
|
|
|
|
abort: function() {
|
|
return _this._abortCursorAnimation;
|
|
}
|
|
});
|
|
},
|
|
|
|
/**
|
|
* @private
|
|
*/
|
|
_onTickComplete: function() {
|
|
if (this._abortCursorAnimation) return;
|
|
|
|
var _this = this;
|
|
if (this._cursorTimeout1) {
|
|
clearTimeout(this._cursorTimeout1);
|
|
}
|
|
this._cursorTimeout1 = setTimeout(function() {
|
|
_this.animate('_currentCursorOpacity', 0, {
|
|
duration: this.cursorDuration / 2,
|
|
onComplete: function() {
|
|
_this._tick();
|
|
},
|
|
onChange: function() {
|
|
_this.canvas && _this.canvas.renderAll();
|
|
},
|
|
abort: function() {
|
|
return _this._abortCursorAnimation;
|
|
}
|
|
});
|
|
}, 100);
|
|
},
|
|
|
|
/**
|
|
* Initializes delayed cursor
|
|
*/
|
|
initDelayedCursor: function() {
|
|
var _this = this;
|
|
if (this._cursorTimeout2) {
|
|
clearTimeout(this._cursorTimeout2);
|
|
}
|
|
this._cursorTimeout2 = setTimeout(function() {
|
|
_this._abortCursorAnimation = false;
|
|
_this._tick();
|
|
}, this.cursorDelay);
|
|
},
|
|
|
|
/**
|
|
* Handles keyup event
|
|
* @param {Event} e Event object
|
|
*/
|
|
onKeyUp: function(e) {
|
|
if (!this.isEditing || e.ctrlKey) return;
|
|
|
|
if (e.keyCode === 39) {
|
|
this.moveCursorRight(e);
|
|
}
|
|
else if (e.keyCode === 37) {
|
|
this.moveCursorLeft(e);
|
|
}
|
|
else if (e.keyCode === 38) {
|
|
this.moveCursorUp(e);
|
|
}
|
|
else if (e.keyCode === 40) {
|
|
this.moveCursorDown(e);
|
|
}
|
|
else if (e.keyCode === 8) {
|
|
this.removeChars(e);
|
|
}
|
|
else if (e.keyCode === 13) {
|
|
this.insertNewline();
|
|
}
|
|
else {
|
|
return;
|
|
}
|
|
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
|
|
this.canvas && this.canvas.renderAll();
|
|
},
|
|
|
|
/**
|
|
* Handles keypress event
|
|
* @param {Event} e Event object
|
|
*/
|
|
onKeyPress: function(e) {
|
|
if (!this.isEditing || e.metaKey || e.ctrlKey ||
|
|
e.keyCode === 8 ||
|
|
e.keyCode === 13 ||
|
|
e.keyCode === 37 ||
|
|
e.keyCode === 38 ||
|
|
e.keyCode === 39 ||
|
|
e.keyCode === 40) {
|
|
return;
|
|
}
|
|
|
|
this.insertChar(String.fromCharCode(e.which));
|
|
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
},
|
|
|
|
/**
|
|
* Gets start offset of a selection
|
|
* @return {Number}
|
|
*/
|
|
getSelectionStartOffset: function() {
|
|
var textBeforeCursor = this.text.slice(0, this.selectionStart);
|
|
var textAfterCursor = this.text.slice(this.selectionStart);
|
|
|
|
var textOnSameLineBeforeCursor = textBeforeCursor.slice(textBeforeCursor.lastIndexOf('\n') + 1);
|
|
var textOnSameLineAfterCursor = textAfterCursor.match(/(.*)\n?/)[1];
|
|
var textOnNextLine = (textAfterCursor.match(/.*\n(.*)\n?/) || { })[1] || '';
|
|
|
|
if (textOnSameLineBeforeCursor.length > textOnNextLine.length) {
|
|
return (textOnNextLine + textOnSameLineAfterCursor).length + 1;
|
|
}
|
|
else {
|
|
return (textOnSameLineBeforeCursor + textOnSameLineAfterCursor).length + 1;
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Moves cursor down
|
|
* @param {Event} e Event object
|
|
*/
|
|
moveCursorDown: function(e) {
|
|
|
|
this.abortCursorAnimation();
|
|
this._currentCursorOpacity = 1;
|
|
|
|
var offset = this.getSelectionStartOffset();
|
|
|
|
if (e.metaKey) {
|
|
// move to the end of a text
|
|
offset = this.text.length - this.selectionStart;
|
|
}
|
|
|
|
if (e.shiftKey) {
|
|
this.moveCursorDownWithShift(offset);
|
|
}
|
|
else {
|
|
this.moveCursorDownWithoutShift(offset);
|
|
}
|
|
|
|
this.initDelayedCursor();
|
|
},
|
|
|
|
/**
|
|
* Moves cursor down without keeping selection
|
|
* @param {Number} offset
|
|
*/
|
|
moveCursorDownWithoutShift: function(offset) {
|
|
|
|
this._selectionDirection = 'right';
|
|
this.selectionStart += offset;
|
|
|
|
if (this.selectionStart > this.text.length) {
|
|
this.selectionStart = this.text.length;
|
|
}
|
|
this.selectionEnd = this.selectionStart;
|
|
},
|
|
|
|
/**
|
|
* Moves cursor down while keeping selection
|
|
* @param {Number} offset
|
|
*/
|
|
moveCursorDownWithShift: function(offset) {
|
|
|
|
if (this._selectionDirection === 'left' && (this.selectionStart !== this.selectionEnd)) {
|
|
this.selectionStart = this.selectionEnd;
|
|
this._selectionDirection = 'right';
|
|
}
|
|
else {
|
|
this._selectionDirection = 'right';
|
|
this.selectionEnd += offset;
|
|
|
|
if (this.selectionEnd > this.text.length) {
|
|
this.selectionEnd = this.text.length;
|
|
}
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Moves cursor up
|
|
* @param {Event} e Event object
|
|
*/
|
|
moveCursorUp: function(e) {
|
|
|
|
var textBeforeCursor = this.text.slice(0, this.selectionStart);
|
|
|
|
this.abortCursorAnimation();
|
|
this._currentCursorOpacity = 1;
|
|
|
|
var textOnSameLineBeforeCursor = textBeforeCursor.slice(textBeforeCursor.lastIndexOf('\n') + 1);
|
|
var textOnPreviousLine = (textBeforeCursor.match(/\n?(.*)\n.*$/) || {})[1] || '';
|
|
var offset;
|
|
|
|
// only change cursor location if there's no selection at the moment
|
|
if (textOnSameLineBeforeCursor.length > textOnPreviousLine.length) {
|
|
offset = textOnSameLineBeforeCursor.length + 1;
|
|
}
|
|
else {
|
|
offset = textOnPreviousLine.length + 1;
|
|
}
|
|
|
|
if (e.metaKey) {
|
|
// move to start of text
|
|
offset = this.selectionStart;
|
|
}
|
|
|
|
if (e.shiftKey) {
|
|
this.moveCursorUpWithShift(offset);
|
|
}
|
|
else {
|
|
this.moveCursorUpWithoutShift(offset);
|
|
}
|
|
|
|
this.initDelayedCursor();
|
|
},
|
|
|
|
moveCursorUpWithShift: function(offset) {
|
|
if (this.selectionStart === this.selectionEnd) {
|
|
this.selectionStart -= offset;
|
|
}
|
|
else {
|
|
if (this._selectionDirection === 'right') {
|
|
this.selectionEnd = this.selectionStart;
|
|
}
|
|
else {
|
|
this.selectionStart -= offset;
|
|
}
|
|
}
|
|
if (this.selectionStart < 0) {
|
|
this.selectionStart = 0;
|
|
}
|
|
|
|
this._selectionDirection = 'left';
|
|
},
|
|
|
|
moveCursorUpWithoutShift: function(offset) {
|
|
if (this.selectionStart === this.selectionEnd) {
|
|
this.selectionStart -= offset;
|
|
}
|
|
if (this.selectionStart < 0) {
|
|
this.selectionStart = 0;
|
|
}
|
|
this.selectionEnd = this.selectionStart;
|
|
|
|
this._selectionDirection = 'left';
|
|
},
|
|
|
|
/**
|
|
* Moves cursor left
|
|
* @param {Event} e Event object
|
|
*/
|
|
moveCursorLeft: function(e) {
|
|
if (this.selectionStart === 0 && this.selectionEnd === 0) return;
|
|
|
|
this.abortCursorAnimation();
|
|
this._currentCursorOpacity = 1;
|
|
|
|
if (e.shiftKey) {
|
|
this.moveCursorLeftWithShift(e);
|
|
}
|
|
else {
|
|
this.moveCursorLeftWithoutShift(e);
|
|
}
|
|
|
|
this.initDelayedCursor();
|
|
},
|
|
|
|
/**
|
|
* Find new selection index representing start of current word according to current selection index
|
|
* @param {Number} current selection index
|
|
*/
|
|
findLeftWordBoundary: function(startFrom) {
|
|
var offset = 0, index = startFrom - 1;
|
|
|
|
// remove space before cursor first
|
|
if ((/\s|\n/).test(this.text.charAt(index))) {
|
|
while (/\s|\n/.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} current selection index
|
|
*/
|
|
findRightWordBoundary: function(startFrom) {
|
|
var offset = 0, index = startFrom;
|
|
|
|
// remove space after cursor first
|
|
if ((/\s|\n/).test(this.text.charAt(index))) {
|
|
while (/\s|\n/.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} current selection index
|
|
*/
|
|
findLeftLineBoundary: 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} current selection index
|
|
*/
|
|
findRightLineBoundary: function(startFrom) {
|
|
var offset = 0, index = startFrom;
|
|
|
|
while (!/\n/.test(this.text.charAt(index)) && index < this.text.length) {
|
|
offset++;
|
|
index++;
|
|
}
|
|
|
|
return startFrom + offset;
|
|
},
|
|
|
|
/**
|
|
* Moves cursor left without keeping selection
|
|
* @param {Event} e
|
|
*/
|
|
moveCursorLeftWithoutShift: function(e) {
|
|
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) {
|
|
if (e.altKey) {
|
|
this.selectionStart = this.findLeftWordBoundary(this.selectionStart);
|
|
}
|
|
else if (e.metaKey) {
|
|
this.selectionStart = this.findLeftLineBoundary(this.selectionStart);
|
|
}
|
|
else {
|
|
this.selectionStart--;
|
|
}
|
|
}
|
|
this.selectionEnd = this.selectionStart;
|
|
},
|
|
|
|
/**
|
|
* Moves cursor left while keeping selection
|
|
* @param {Event} e
|
|
*/
|
|
moveCursorLeftWithShift: function(e) {
|
|
if (this._selectionDirection === 'right' && this.selectionStart !== this.selectionEnd) {
|
|
if (e.altKey) {
|
|
this.selectionEnd = this.findLeftWordBoundary(this.selectionEnd);
|
|
}
|
|
else if (e.metaKey) {
|
|
this.selectionEnd = this.findLeftLineBoundary(this.selectionEnd);
|
|
}
|
|
else {
|
|
this.selectionEnd--;
|
|
}
|
|
}
|
|
else {
|
|
this._selectionDirection = 'left';
|
|
if (e.altKey) {
|
|
this.selectionStart = this.findLeftWordBoundary(this.selectionStart);
|
|
}
|
|
else if (e.metaKey) {
|
|
this.selectionStart = this.findLeftLineBoundary(this.selectionStart);
|
|
}
|
|
else {
|
|
this.selectionStart--;
|
|
}
|
|
|
|
// increase selection by one if it's a newline
|
|
if (this.text.charAt(this.selectionStart) === '\n') {
|
|
this.selectionStart--;
|
|
}
|
|
if (this.selectionStart < 0) {
|
|
this.selectionStart = 0;
|
|
}
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Moves cursor right
|
|
* @param {Event} e Event object
|
|
*/
|
|
moveCursorRight: function(e) {
|
|
if (this.selectionStart >= this.text.length && this.selectionEnd >= this.text.length) return;
|
|
|
|
this.abortCursorAnimation();
|
|
this._currentCursorOpacity = 1;
|
|
|
|
if (e.shiftKey) {
|
|
this.moveCursorRightWithShift(e);
|
|
}
|
|
else {
|
|
this.moveCursorRightWithoutShift(e);
|
|
}
|
|
|
|
this.initDelayedCursor();
|
|
},
|
|
|
|
/**
|
|
* Moves cursor right while keeping selection
|
|
* @param {Event} e
|
|
*/
|
|
moveCursorRightWithShift: function(e) {
|
|
if (this._selectionDirection === 'left' && this.selectionStart !== this.selectionEnd) {
|
|
if (e.altKey) {
|
|
this.selectionStart = this.findRightWordBoundary(this.selectionStart);
|
|
}
|
|
else if (e.metaKey) {
|
|
this.selectionStart = this.findRightLineBoundary(this.selectionStart);
|
|
}
|
|
else {
|
|
this.selectionStart++;
|
|
}
|
|
}
|
|
else {
|
|
this._selectionDirection = 'right';
|
|
if (e.altKey) {
|
|
this.selectionEnd = this.findRightWordBoundary(this.selectionEnd);
|
|
}
|
|
else if (e.metaKey) {
|
|
this.selectionEnd = this.findRightLineBoundary(this.selectionEnd);
|
|
}
|
|
else {
|
|
this.selectionEnd++;
|
|
}
|
|
|
|
// increase selection by one if it's a newline
|
|
if (this.text.charAt(this.selectionEnd - 1) === '\n') {
|
|
this.selectionEnd++;
|
|
}
|
|
if (this.selectionEnd > this.text.length) {
|
|
this.selectionEnd = this.text.length;
|
|
}
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Moves cursor right without keeping selection
|
|
* @param {Event} e
|
|
*/
|
|
moveCursorRightWithoutShift: function(e) {
|
|
this._selectionDirection = 'right';
|
|
|
|
if (this.selectionStart === this.selectionEnd) {
|
|
if (e.altKey) {
|
|
this.selectionStart = this.findRightWordBoundary(this.selectionStart);
|
|
}
|
|
else if (e.metaKey) {
|
|
this.selectionStart = this.findRightLineBoundary(this.selectionStart);
|
|
}
|
|
else {
|
|
this.selectionStart++;
|
|
}
|
|
this.selectionEnd = this.selectionStart;
|
|
}
|
|
else {
|
|
this.selectionEnd += this.getNumNewLinesInSelectedText();
|
|
if (this.selectionEnd > this.text.length) {
|
|
this.selectionEnd = this.text.length;
|
|
}
|
|
this.selectionStart = this.selectionEnd;
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Returns number of newlines in selected text
|
|
* @return {Number}
|
|
*/
|
|
getNumNewLinesInSelectedText: function() {
|
|
var selectedText = this.text.slice(this.selectionStart, this.selectionEnd);
|
|
var numNewLines = 0;
|
|
for (var i = 0, chars = selectedText.split(''), len = chars.length; i < len; i++) {
|
|
if (chars[i] === '\n') {
|
|
numNewLines++;
|
|
}
|
|
}
|
|
return numNewLines;
|
|
},
|
|
|
|
/**
|
|
* Aborts cursor animation and clears all timeouts
|
|
*/
|
|
abortCursorAnimation: function() {
|
|
this._abortCursorAnimation = true;
|
|
|
|
clearTimeout(this._cursorTimeout1);
|
|
clearTimeout(this._cursorTimeout2);
|
|
|
|
this._currentCursorOpacity = 0;
|
|
this.canvas.renderAll();
|
|
|
|
var _this = this;
|
|
setTimeout(function() {
|
|
_this._abortCursorAnimation = false;
|
|
}, 10);
|
|
},
|
|
|
|
_removeCharsFromTo: function(start, end) {
|
|
var i = end;
|
|
while (i !== start) {
|
|
i--;
|
|
//var isBeginningOfLine = this.text.slice(end - 1, end) === '\n';
|
|
this.removeStyleObject(false, i);
|
|
}
|
|
|
|
this.text = this.text.slice(0, start) +
|
|
this.text.slice(end);
|
|
},
|
|
|
|
/**
|
|
* Inserts a character where cursor is (replacing selection if one exists)
|
|
*/
|
|
removeChars: function(e) {
|
|
|
|
if (this.selectionStart === this.selectionEnd) {
|
|
if (this.selectionStart !== 0) {
|
|
|
|
if (e.metaKey) {
|
|
// remove all till the start of current line
|
|
var leftLineBoundary = this.findLeftLineBoundary(this.selectionStart);
|
|
|
|
this._removeCharsFromTo(leftLineBoundary, this.selectionStart);
|
|
this.selectionStart = leftLineBoundary;
|
|
}
|
|
else if (e.altKey) {
|
|
// remove all till the start of current word
|
|
var leftWordBoundary = this.findLeftWordBoundary(this.selectionStart);
|
|
|
|
this._removeCharsFromTo(leftWordBoundary, this.selectionStart);
|
|
this.selectionStart = leftWordBoundary;
|
|
}
|
|
else {
|
|
var isBeginningOfLine = this.text.slice(this.selectionStart-1, this.selectionStart) === '\n';
|
|
this.removeStyleObject(isBeginningOfLine);
|
|
|
|
this.selectionStart--;
|
|
this.text = this.text.slice(0, this.selectionStart) +
|
|
this.text.slice(this.selectionStart + 1);
|
|
}
|
|
}
|
|
}
|
|
else {
|
|
this._removeCharsFromTo(this.selectionStart, this.selectionEnd);
|
|
}
|
|
|
|
this.selectionEnd = this.selectionStart;
|
|
|
|
// remove any extraneous styles "at the end"
|
|
var textLines = this.text.split(this._reNewline);
|
|
for (var prop in this.styles) {
|
|
if (!textLines[prop]) {
|
|
delete this.styles[prop];
|
|
}
|
|
}
|
|
|
|
if (this.canvas) {
|
|
// TODO: double renderAll gets rid of text box shift happenning sometimes
|
|
// need to find out what exactly causes it and fix it
|
|
this.canvas.renderAll().renderAll();
|
|
}
|
|
|
|
this.setCoords();
|
|
this.fire('text:changed');
|
|
},
|
|
|
|
/**
|
|
* Inserts a character where cursor is (replacing selection if one exists)
|
|
* @param {String} _char Character to insert
|
|
*/
|
|
insertChar: function(_char) {
|
|
var isEndOfLine = this.text.slice(this.selectionStart, this.selectionStart + 1) === '\n';
|
|
|
|
this.text = this.text.slice(0, this.selectionStart) +
|
|
_char +
|
|
this.text.slice(this.selectionEnd);
|
|
|
|
if (this.selectionStart === this.selectionEnd) {
|
|
this.insertStyleObject(_char, isEndOfLine);
|
|
}
|
|
else if (this.selectionEnd - this.selectionStart > 1) {
|
|
// TODO: replace styles properly
|
|
// console.log('replacing MORE than 1 char');
|
|
}
|
|
|
|
this.selectionStart++;
|
|
this.selectionEnd = this.selectionStart;
|
|
|
|
if (this.canvas) {
|
|
// TODO: double renderAll gets rid of text box shift happenning sometimes
|
|
// need to find out what exactly causes it and fix it
|
|
this.canvas.renderAll().renderAll();
|
|
}
|
|
|
|
this.setCoords();
|
|
this.fire('text:changed');
|
|
},
|
|
|
|
/**
|
|
* 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) {
|
|
|
|
// shift all line styles by 1 forward
|
|
var clonedStyles = clone(this.styles);
|
|
for (var line in this.styles) {
|
|
var numericLine = parseInt(line, 10);
|
|
if (numericLine >= lineIndex) {
|
|
this.styles[numericLine + 1] = clonedStyles[numericLine];
|
|
}
|
|
}
|
|
|
|
if (!this.styles[lineIndex + 1]) {
|
|
this.styles[lineIndex + 1] = { };
|
|
}
|
|
|
|
var currentCharStyle = this.styles[lineIndex][charIndex - 1];
|
|
var 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;
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Inserts style object for a given line/char index
|
|
* @param {Number} lineIndex Index of a line
|
|
* @param {Number} charIndex Index of a char
|
|
*/
|
|
insertCharStyleObject: function(lineIndex, charIndex) {
|
|
|
|
var currentLineStyles = this.styles[lineIndex];
|
|
var currentLineStylesCloned = clone(currentLineStyles);
|
|
|
|
if (charIndex === 0) {
|
|
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] = clone(currentLineStyles[charIndex - 1]);
|
|
},
|
|
|
|
/**
|
|
* Inserts style object
|
|
* @param {String} _char Character at the location where style is inserted
|
|
* @param {Boolean} isEndOfLine True if it's end of line
|
|
*/
|
|
insertStyleObject: function(_char, isEndOfLine) {
|
|
|
|
// short-circuit
|
|
if (!this.styles || isEmptyStyles(this.styles)) return;
|
|
|
|
var cursorLocation = this.get2DCursorLocation();
|
|
var lineIndex = cursorLocation.lineIndex;
|
|
var charIndex = cursorLocation.charIndex;
|
|
|
|
if (!this.styles[lineIndex]) {
|
|
this.styles[lineIndex] = { };
|
|
}
|
|
|
|
if (_char === '\n') {
|
|
this.insertNewlineStyleObject(lineIndex, charIndex, isEndOfLine);
|
|
}
|
|
else {
|
|
this.insertCharStyleObject(lineIndex, charIndex);
|
|
}
|
|
},
|
|
|
|
/**
|
|
* 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);
|
|
var lineIndex = cursorLocation.lineIndex;
|
|
var charIndex = cursorLocation.charIndex;
|
|
|
|
if (isBeginningOfLine) {
|
|
|
|
var textLines = this.text.split(this._reNewline);
|
|
var textOnPreviousLine = textLines[lineIndex - 1];
|
|
var newCharIndexOnPrevLine = textOnPreviousLine.length;
|
|
|
|
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];
|
|
}
|
|
|
|
// 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 - 1] = clonedStyles[numericLine];
|
|
}
|
|
}
|
|
}
|
|
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.insertChar('\n');
|
|
},
|
|
|
|
/**
|
|
* @private
|
|
* @param {CanvasRenderingContext2D} ctx Context to render on
|
|
*/
|
|
_render: function(ctx) {
|
|
this.callSuper('_render', ctx);
|
|
this.isEditing && this.renderCursorOrSelection(ctx);
|
|
this.ctx = ctx;
|
|
},
|
|
|
|
/**
|
|
* Renders cursor or selection (depending on what exists)
|
|
* @param {CanvasRenderingContext2D} ctx Context to render on
|
|
*/
|
|
renderCursorOrSelection: function(ctx) {
|
|
if (!this.active) return;
|
|
|
|
var chars = this.text.split(''),
|
|
boundaries;
|
|
|
|
if (this.selectionStart === this.selectionEnd) {
|
|
boundaries = this.getCursorBoundaries(ctx, chars, 'cursor');
|
|
this.renderCursor(ctx, boundaries);
|
|
}
|
|
else {
|
|
boundaries = this.getCursorBoundaries(ctx, chars, 'selection');
|
|
this.renderSelection(ctx, chars, boundaries);
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Returns 2d representation (lineIndex and charIndex) of cursor (or selection start)
|
|
* @param {Number} [selectionStart] Optional index. When not given, current selectionStart is used.
|
|
*/
|
|
get2DCursorLocation: function(selectionStart) {
|
|
if (typeof selectionStart === 'undefined') {
|
|
selectionStart = this.selectionStart;
|
|
}
|
|
var textBeforeCursor = this.text.slice(0, selectionStart);
|
|
var linesBeforeCursor = textBeforeCursor.split(this._reNewline);
|
|
|
|
return {
|
|
lineIndex: linesBeforeCursor.length - 1,
|
|
charIndex: linesBeforeCursor[linesBeforeCursor.length - 1].length
|
|
};
|
|
},
|
|
|
|
/**
|
|
* Returns fontSize of char at the current cursor
|
|
* @param {Number} lineIndex Line index
|
|
* @param {Number} charIndex Char index
|
|
* @return {Number} Character font size
|
|
*/
|
|
getCurrentCharFontSize: function(lineIndex, charIndex) {
|
|
return (
|
|
this.styles[lineIndex] &&
|
|
this.styles[lineIndex][charIndex === 0 ? 0 : (charIndex - 1)] &&
|
|
this.styles[lineIndex][charIndex === 0 ? 0 : (charIndex - 1)].fontSize) || this.fontSize;
|
|
},
|
|
|
|
/**
|
|
* Returns color (fill) of char at the current cursor
|
|
* @param {Number} lineIndex Line index
|
|
* @param {Number} charIndex Char index
|
|
* @return {String} Character color (fill)
|
|
*/
|
|
getCurrentCharColor: function(lineIndex, charIndex) {
|
|
return (
|
|
this.styles[lineIndex] &&
|
|
this.styles[lineIndex][charIndex === 0 ? 0 : (charIndex - 1)] &&
|
|
this.styles[lineIndex][charIndex === 0 ? 0 : (charIndex - 1)].fill) || this.cursorColor;
|
|
},
|
|
|
|
/**
|
|
* Returns fontStyle of char at the current cursor
|
|
* @param {Number} lineIndex Line index
|
|
* @param {Number} charIndex Char index
|
|
* @return {String} Character font style
|
|
*/
|
|
getCurrentCharStyle: function(lineIndex, charIndex) {
|
|
return (
|
|
this.styles[lineIndex] &&
|
|
this.styles[lineIndex][charIndex === 0 ? 0 : (charIndex - 1)] &&
|
|
this.styles[lineIndex][charIndex === 0 ? 0 : (charIndex - 1)].fontStyle) || this.fontStyle;
|
|
},
|
|
|
|
/**
|
|
* Returns cursor boundaries (left, top, leftOffset, topOffset)
|
|
* @param {CanvasRenderingContext2D} ctx Context to render on
|
|
* @param {Array} chars Array of characters
|
|
*/
|
|
getCursorBoundaries: function(ctx, chars, typeOfBoundaries) {
|
|
|
|
var cursorLocation = this.get2DCursorLocation();
|
|
var lineIndex = cursorLocation.lineIndex;
|
|
var charIndex = cursorLocation.charIndex;
|
|
|
|
var textLines = this.text.split(this._reNewline);
|
|
|
|
var widthOfLine;
|
|
var lineLeftOffset;
|
|
|
|
// left/top are left/top of entire text box
|
|
// leftOffset/topOffset are offset from that left/top point of a text box
|
|
var left = Math.round(this._getLeftOffset());
|
|
var top = -this.height / 2;
|
|
|
|
var leftOffset = 0;
|
|
var topOffset = typeOfBoundaries === 'cursor'
|
|
// selection starts at the very top of the line,
|
|
// whereas cursor starts at the padding created by line height
|
|
? (this._getHeightOfLine(ctx, 0) - this.getCurrentCharFontSize(lineIndex, charIndex))
|
|
: 0;
|
|
|
|
lineIndex = 0;
|
|
charIndex = 0;
|
|
|
|
for (var i = 0; i < this.selectionStart; i++) {
|
|
if (chars[i] === '\n') {
|
|
leftOffset = 0;
|
|
topOffset += this._getHeightOfLine(ctx, lineIndex + (typeOfBoundaries === 'cursor' ? 1 : 0));
|
|
|
|
lineIndex++;
|
|
charIndex = 0;
|
|
}
|
|
else {
|
|
leftOffset += this._getWidthOfChar(ctx, chars[i], lineIndex, charIndex);
|
|
charIndex++;
|
|
}
|
|
|
|
widthOfLine = this._getWidthOfLine(ctx, lineIndex, textLines);
|
|
lineLeftOffset = this._getLineLeftOffset(widthOfLine);
|
|
}
|
|
|
|
return {
|
|
left: left,
|
|
top: top,
|
|
leftOffset: leftOffset + (lineLeftOffset || 0),
|
|
topOffset: topOffset
|
|
};
|
|
},
|
|
|
|
/**
|
|
* Renders cursor
|
|
* @param {CanvasRenderingContext2D} ctx Context to render on
|
|
*/
|
|
renderCursor: function(ctx, boundaries) {
|
|
ctx.save();
|
|
|
|
var cursorLocation = this.get2DCursorLocation();
|
|
var lineIndex = cursorLocation.lineIndex;
|
|
var charIndex = cursorLocation.charIndex;
|
|
|
|
ctx.fillStyle = this.getCurrentCharColor(lineIndex, charIndex);
|
|
ctx.globalAlpha = this._currentCursorOpacity;
|
|
|
|
var charHeight = this.getCurrentCharFontSize(lineIndex, charIndex);
|
|
|
|
ctx.fillRect(
|
|
boundaries.left + boundaries.leftOffset,
|
|
boundaries.top + boundaries.topOffset,
|
|
this.cursorWidth,
|
|
charHeight);
|
|
|
|
ctx.restore();
|
|
},
|
|
|
|
/**
|
|
* Renders text selection
|
|
* @param {CanvasRenderingContext2D} ctx Context to render on
|
|
* @param {Array} chars Array of characters
|
|
* @param {Object} boundaries Object with left/top/leftOffset/topOffset
|
|
*/
|
|
renderSelection: function(ctx, chars, boundaries) {
|
|
ctx.save();
|
|
|
|
ctx.fillStyle = this.selectionColor;
|
|
|
|
var cursorLocation = this.get2DCursorLocation();
|
|
var lineIndex = cursorLocation.lineIndex;
|
|
var charIndex = cursorLocation.charIndex;
|
|
var textLines = this.text.split(this._reNewline);
|
|
var origLineIndex = lineIndex;
|
|
|
|
for (var i = this.selectionStart; i < this.selectionEnd; i++) {
|
|
|
|
if (chars[i] === '\n') {
|
|
boundaries.leftOffset = 0;
|
|
boundaries.topOffset += this._getHeightOfLine(ctx, lineIndex);
|
|
lineIndex++;
|
|
charIndex = 0;
|
|
}
|
|
else if (i !== this.text.length) {
|
|
|
|
var charWidth = this._getWidthOfChar(ctx, chars[i], lineIndex, charIndex);
|
|
var lineOffset = this._getLineLeftOffset(this._getWidthOfLine(ctx, lineIndex, textLines)) || 0;
|
|
|
|
if (lineIndex === origLineIndex) {
|
|
// only offset the line if we're drawing selection of 2nd, 3rd, etc. line
|
|
lineOffset = 0;
|
|
}
|
|
|
|
ctx.fillRect(
|
|
boundaries.left + boundaries.leftOffset + lineOffset,
|
|
boundaries.top + boundaries.topOffset,
|
|
charWidth,
|
|
this._getHeightOfLine(ctx, lineIndex));
|
|
|
|
boundaries.leftOffset += charWidth;
|
|
charIndex++;
|
|
}
|
|
}
|
|
ctx.restore();
|
|
},
|
|
|
|
/**
|
|
* @private
|
|
* @param {CanvasRenderingContext2D} ctx Context to render on
|
|
* @param {String} _char
|
|
* @param {Number} lineIndex
|
|
* @param {Number} charIndex
|
|
* @param {Object} [decl]
|
|
*/
|
|
_applyCharStylesGetWidth: function(ctx, _char, lineIndex, charIndex, decl) {
|
|
var styleDeclaration = decl || (this.styles[lineIndex] && this.styles[lineIndex][charIndex]);
|
|
|
|
if (styleDeclaration) {
|
|
// cloning so that original style object is not polluted with following font declarations
|
|
styleDeclaration = clone(styleDeclaration);
|
|
}
|
|
else {
|
|
styleDeclaration = { };
|
|
}
|
|
|
|
var fill = styleDeclaration.fill || this.fill;
|
|
ctx.fillStyle = fill.toLive
|
|
? fill.toLive(ctx)
|
|
: fill;
|
|
|
|
if (styleDeclaration.stroke) {
|
|
ctx.strokeStyle = (styleDeclaration.stroke && styleDeclaration.stroke.toLive)
|
|
? styleDeclaration.stroke.toLive(ctx)
|
|
: styleDeclaration.stroke;
|
|
}
|
|
|
|
ctx.lineWidth = styleDeclaration.strokeWidth || this.strokeWidth;
|
|
|
|
this._applyFontStyles(styleDeclaration);
|
|
|
|
if (typeof styleDeclaration.shadow === 'string') {
|
|
styleDeclaration.shadow = new fabric.Shadow(styleDeclaration.shadow);
|
|
}
|
|
|
|
this._setShadow.call(styleDeclaration, ctx);
|
|
|
|
ctx.font = this._getFontDeclaration.call(styleDeclaration);
|
|
|
|
return ctx.measureText(_char).width;
|
|
},
|
|
|
|
_applyFontStyles: function(styleDeclaration) {
|
|
if (!styleDeclaration.fontFamily) {
|
|
styleDeclaration.fontFamily = this.fontFamily;
|
|
}
|
|
if (!styleDeclaration.fontSize) {
|
|
styleDeclaration.fontSize = this.fontSize;
|
|
}
|
|
if (!styleDeclaration.fontWeight) {
|
|
styleDeclaration.fontWeight = this.fontWeight;
|
|
}
|
|
if (!styleDeclaration.fontStyle) {
|
|
styleDeclaration.fontStyle = this.fontStyle;
|
|
}
|
|
},
|
|
|
|
/**
|
|
* @private
|
|
* @param {String} method
|
|
* @param {CanvasRenderingContext2D} ctx Context to render on
|
|
* @param {String} line
|
|
*/
|
|
_drawTextLine: function(method, ctx, line, left, top, lineIndex) {
|
|
// to "cancel" this.fontSize subtraction in fabric.Text#_drawTextLine
|
|
top += this.fontSize / 4;
|
|
this.callSuper('_drawTextLine', method, ctx, line, left, top, lineIndex);
|
|
},
|
|
|
|
_renderTextDecoration: function(ctx, textLines) {
|
|
if (!this.styles || isEmptyStyles(this.styles)) {
|
|
return this.callSuper('_renderTextDecoration', ctx, textLines);
|
|
}
|
|
},
|
|
|
|
/**
|
|
* @private
|
|
* @param {String} method
|
|
* @param {CanvasRenderingContext2D} ctx Context to render on
|
|
* @param {String} line
|
|
*/
|
|
_drawCharsFast: function(method, ctx, line, left, top) {
|
|
this.skipTextAlign = false;
|
|
|
|
if (method === 'fillText' && this.fill) {
|
|
this.callSuper('_drawChars', method, ctx, line, left, top);
|
|
}
|
|
if (method === 'strokeText' && this.stroke) {
|
|
this.callSuper('_drawChars', method, ctx, line, left, top);
|
|
}
|
|
},
|
|
|
|
/**
|
|
* @private
|
|
* @param {String} method
|
|
* @param {CanvasRenderingContext2D} ctx Context to render on
|
|
*/
|
|
_drawChars: function(method, ctx, line, left, top, lineIndex) {
|
|
|
|
if (this.styles && isEmptyStyles(this.styles)) {
|
|
return this._drawCharsFast(method, ctx, line, left, top);
|
|
}
|
|
|
|
this.skipTextAlign = true;
|
|
|
|
// set proper box offset
|
|
left -= this.textAlign === 'center'
|
|
? (this.width / 2)
|
|
: (this.textAlign === 'right')
|
|
? this.width
|
|
: 0;
|
|
|
|
// set proper line offset
|
|
var textLines = this.text.split(this._reNewline);
|
|
var lineWidth = this._getWidthOfLine(ctx, lineIndex, textLines);
|
|
var lineHeight = this._getHeightOfLine(ctx, lineIndex, textLines);
|
|
var lineLeftOffset = this._getLineLeftOffset(lineWidth);
|
|
var chars = line.split('');
|
|
|
|
left += lineLeftOffset || 0;
|
|
|
|
ctx.save();
|
|
for (var i = 0, len = chars.length; i < len; i++) {
|
|
this._drawChar(method, ctx, lineIndex, i, chars[i], left, top, lineHeight);
|
|
}
|
|
ctx.restore();
|
|
},
|
|
|
|
/**
|
|
* @private
|
|
* @param {CanvasRenderingContext2D} ctx Context to render on
|
|
*/
|
|
_drawChar: function(method, ctx, lineIndex, i, _char, left, top, lineHeight) {
|
|
var decl, charWidth;
|
|
|
|
if (this.styles && this.styles[lineIndex] && (decl = this.styles[lineIndex][i])) {
|
|
|
|
var shouldStroke = decl.stroke || this.stroke;
|
|
var shouldFill = decl.fill || this.fill;
|
|
|
|
ctx.save();
|
|
charWidth = this._applyCharStylesGetWidth(ctx, _char, lineIndex, i, decl);
|
|
|
|
if (shouldFill) {
|
|
ctx.fillText(_char, left, top);
|
|
}
|
|
if (shouldStroke) {
|
|
ctx.strokeText(_char, left, top);
|
|
}
|
|
|
|
this._renderCharDecoration(ctx, decl, left, top, charWidth, lineHeight);
|
|
ctx.restore();
|
|
|
|
ctx.translate(charWidth, 0);
|
|
}
|
|
else {
|
|
if (method === 'strokeText' && this.stroke) {
|
|
ctx[method](_char, left, top);
|
|
}
|
|
if (method === 'fillText' && this.fill) {
|
|
ctx[method](_char, left, top);
|
|
}
|
|
charWidth = this._applyCharStylesGetWidth(ctx, _char, lineIndex, i);
|
|
this._renderCharDecoration(ctx, null, left, top, charWidth, lineHeight);
|
|
|
|
ctx.translate(ctx.measureText(_char).width, 0);
|
|
}
|
|
},
|
|
|
|
/**
|
|
* @private
|
|
* @param {CanvasRenderingContext2D} ctx Context to render on
|
|
*/
|
|
_renderCharDecoration: function(ctx, styleDeclaration, left, top, charWidth, lineHeight) {
|
|
var textDecoration = styleDeclaration
|
|
? (styleDeclaration.textDecoration || this.textDecoration)
|
|
: this.textDecoration;
|
|
|
|
if (!textDecoration) return;
|
|
|
|
if (textDecoration.indexOf('underline') > -1) {
|
|
|
|
this._renderCharDecorationAtOffset(
|
|
ctx,
|
|
left,
|
|
top + (this.fontSize / this._fontSizeFraction),
|
|
charWidth,
|
|
0
|
|
);
|
|
}
|
|
if (textDecoration.indexOf('line-through') > -1) {
|
|
this._renderCharDecorationAtOffset(
|
|
ctx,
|
|
left,
|
|
top + (this.fontSize / this._fontSizeFraction),
|
|
charWidth,
|
|
(lineHeight / this._fontSizeFraction)
|
|
);
|
|
}
|
|
if (textDecoration.indexOf('overline') > -1) {
|
|
this._renderCharDecorationAtOffset(
|
|
ctx,
|
|
left,
|
|
top,
|
|
charWidth,
|
|
lineHeight - (this.fontSize / this._fontSizeFraction)
|
|
);
|
|
}
|
|
},
|
|
|
|
/**
|
|
* @private
|
|
* @param {CanvasRenderingContext2D} ctx Context to render on
|
|
*/
|
|
_renderCharDecorationAtOffset: function(ctx, left, top, charWidth, offset) {
|
|
ctx.fillRect(left, top - offset, charWidth, 1);
|
|
},
|
|
|
|
/**
|
|
* @private
|
|
* @param {CanvasRenderingContext2D} ctx Context to render on
|
|
* @param {Array} textLines Array of all text lines
|
|
*/
|
|
_renderTextLinesBackground: function(ctx, textLines) {
|
|
if (!this.textBackgroundColor && !this.styles) return;
|
|
|
|
ctx.save();
|
|
|
|
if (this.textBackgroundColor) {
|
|
ctx.fillStyle = this.textBackgroundColor;
|
|
}
|
|
|
|
var lineHeights = 0;
|
|
var fractionOfFontSize = this.fontSize / this._fontSizeFraction;
|
|
|
|
for (var i = 0, len = textLines.length; i < len; i++) {
|
|
|
|
var heightOfLine = this._getHeightOfLine(ctx, i, textLines);
|
|
if (textLines[i] === '') {
|
|
lineHeights += heightOfLine;
|
|
continue;
|
|
}
|
|
|
|
var lineWidth = this._getWidthOfLine(ctx, i, textLines);
|
|
var lineLeftOffset = this._getLineLeftOffset(lineWidth);
|
|
|
|
if (this.textBackgroundColor) {
|
|
ctx.fillStyle = this.textBackgroundColor;
|
|
|
|
ctx.fillRect(
|
|
this._getLeftOffset() + lineLeftOffset,
|
|
this._getTopOffset() + lineHeights + fractionOfFontSize,
|
|
lineWidth,
|
|
heightOfLine
|
|
);
|
|
}
|
|
if (this.styles[i]) {
|
|
for (var j = 0, jlen = textLines[i].length; j < jlen; j++) {
|
|
if (this.styles[i] && this.styles[i][j] && this.styles[i][j].textBackgroundColor) {
|
|
|
|
var _char = textLines[i][j];
|
|
|
|
ctx.fillStyle = this.styles[i][j].textBackgroundColor;
|
|
|
|
ctx.fillRect(
|
|
this._getLeftOffset() + lineLeftOffset + this._getWidthOfCharsAt(ctx, i, j, textLines),
|
|
this._getTopOffset() + lineHeights + fractionOfFontSize,
|
|
this._getWidthOfChar(ctx, _char, i, j, textLines) + 1,
|
|
heightOfLine
|
|
);
|
|
}
|
|
}
|
|
}
|
|
lineHeights += heightOfLine;
|
|
}
|
|
ctx.restore();
|
|
},
|
|
|
|
/**
|
|
* Returns object representation of an instance
|
|
* @methd toObject
|
|
* @param {Array} [propertiesToInclude] Any properties that you might want to additionally include in the output
|
|
* @return {Object} object representation of an instance
|
|
*/
|
|
toObject: function(propertiesToInclude) {
|
|
return fabric.util.object.extend(this.callSuper('toObject', propertiesToInclude), {
|
|
styles: clone(this.styles)
|
|
});
|
|
},
|
|
|
|
/* _TO_SVG_START_ */
|
|
/**
|
|
* Returns SVG representation of an instance
|
|
* @return {String} svg representation of an instance
|
|
*/
|
|
toSVG: function(reviver) {
|
|
if (!this.styles || isEmptyStyles(this.styles)) {
|
|
return this.callSuper('toSVG', reviver);
|
|
}
|
|
// TODO: add support for styled text SVG output
|
|
},
|
|
/* _TO_SVG_END_ */
|
|
|
|
/**
|
|
* @private
|
|
* @param {CanvasRenderingContext2D} ctx Context to render on
|
|
*/
|
|
_getWidthOfChar: function(ctx, _char, lineIndex, charIndex) {
|
|
ctx.save();
|
|
var width = this._applyCharStylesGetWidth(ctx, _char, lineIndex, charIndex);
|
|
ctx.restore();
|
|
return width;
|
|
},
|
|
|
|
/**
|
|
* @private
|
|
* @param {CanvasRenderingContext2D} ctx Context to render on
|
|
*/
|
|
_getHeightOfChar: function(ctx, _char, lineIndex, charIndex) {
|
|
if (this.styles[lineIndex] && this.styles[lineIndex][charIndex]) {
|
|
return this.styles[lineIndex][charIndex].fontSize || this.fontSize;
|
|
}
|
|
return this.fontSize;
|
|
},
|
|
|
|
/**
|
|
* @private
|
|
* @param {CanvasRenderingContext2D} ctx Context to render on
|
|
*/
|
|
_getWidthOfCharAt: function(ctx, lineIndex, charIndex, lines) {
|
|
lines = lines || this.text.split(this._reNewline);
|
|
var _char = lines[lineIndex].split('')[charIndex];
|
|
return this._getWidthOfChar(ctx, _char, lineIndex, charIndex);
|
|
},
|
|
|
|
/**
|
|
* @private
|
|
* @param {CanvasRenderingContext2D} ctx Context to render on
|
|
*/
|
|
_getHeightOfCharAt: function(ctx, lineIndex, charIndex, lines) {
|
|
lines = lines || this.text.split(this._reNewline);
|
|
var _char = lines[lineIndex].split('')[charIndex];
|
|
return this._getHeightOfChar(ctx, _char, lineIndex, charIndex);
|
|
},
|
|
|
|
/**
|
|
* @private
|
|
* @param {CanvasRenderingContext2D} ctx Context to render on
|
|
*/
|
|
_getWidthOfCharsAt: function(ctx, lineIndex, charIndex, lines) {
|
|
var width = 0;
|
|
for (var i = 0; i < charIndex; i++) {
|
|
width += this._getWidthOfCharAt(ctx, lineIndex, i, lines);
|
|
}
|
|
return width;
|
|
},
|
|
|
|
/**
|
|
* @private
|
|
* @param {CanvasRenderingContext2D} ctx Context to render on
|
|
*/
|
|
_getWidthOfLine: function(ctx, lineIndex, textLines) {
|
|
// if (!this.styles[lineIndex]) {
|
|
// return this.callSuper('_getLineWidth', ctx, textLines[lineIndex]);
|
|
// }
|
|
return this._getWidthOfCharsAt(ctx, lineIndex, textLines[lineIndex].length, textLines);
|
|
},
|
|
|
|
/**
|
|
* @private
|
|
* @param {CanvasRenderingContext2D} ctx Context to render on
|
|
*/
|
|
_getTextWidth: function(ctx, textLines) {
|
|
|
|
if (!this.styles || isEmptyStyles(this.styles)) {
|
|
return this.callSuper('_getTextWidth', ctx, textLines);
|
|
}
|
|
|
|
var maxWidth = this._getWidthOfLine(ctx, 0, textLines);
|
|
|
|
for (var i = 1, len = textLines.length; i < len; i++) {
|
|
var currentLineWidth = this._getWidthOfLine(ctx, i, textLines);
|
|
if (currentLineWidth > maxWidth) {
|
|
maxWidth = currentLineWidth;
|
|
}
|
|
}
|
|
return maxWidth;
|
|
},
|
|
|
|
/**
|
|
* @private
|
|
* @param {CanvasRenderingContext2D} ctx Context to render on
|
|
*/
|
|
_getHeightOfLine: function(ctx, lineIndex, textLines) {
|
|
|
|
textLines = textLines || this.text.split(this._reNewline);
|
|
|
|
var maxHeight = this._getHeightOfChar(ctx, textLines[lineIndex][0], lineIndex, 0);
|
|
|
|
var line = textLines[lineIndex];
|
|
var chars = line.split('');
|
|
|
|
for (var i = 1, len = chars.length; i < len; i++) {
|
|
var currentCharHeight = this._getHeightOfChar(ctx, chars[i], lineIndex, i);
|
|
if (currentCharHeight > maxHeight) {
|
|
maxHeight = currentCharHeight;
|
|
}
|
|
}
|
|
|
|
return maxHeight * this.lineHeight;
|
|
},
|
|
|
|
/**
|
|
* @private
|
|
* @param {CanvasRenderingContext2D} ctx Context to render on
|
|
*/
|
|
_getTextHeight: function(ctx, textLines) {
|
|
var height = 0;
|
|
for (var i = 0, len = textLines.length; i < len; i++) {
|
|
height += this._getHeightOfLine(ctx, i, textLines);
|
|
}
|
|
return height;
|
|
},
|
|
|
|
/**
|
|
* @private
|
|
* @param {CanvasRenderingContext2D} ctx Context to render on
|
|
*/
|
|
_getTopOffset: function() {
|
|
var topOffset = fabric.Text.prototype._getTopOffset.call(this);
|
|
return topOffset - (this.fontSize / this._fontSizeFraction);
|
|
}
|
|
});
|
|
|
|
/**
|
|
* Returns fabric.IText instance from an object representation
|
|
* @static
|
|
* @memberOf fabric.IText
|
|
* @param {Object} object Object to create an instance from
|
|
* @return {fabric.IText} instance of fabric.IText
|
|
*/
|
|
fabric.IText.fromObject = function(object) {
|
|
return new fabric.IText(object.text, clone(object));
|
|
};
|
|
|
|
})();
|