UNPKG

fabric-pure-browser

Version:

Fabric.js package with no node-specific dependencies (node-canvas, jsdom). The project is published once a day (in case if a new version appears) from 'master' branch of https://github.com/fabricjs/fabric.js repository. You can keep original imports in

920 lines (837 loc) 29.3 kB
(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.initRemovedHandler(); this.initCursorSelectionHandlers(); this.initDoubleClickSimulation(); this.mouseMoveHandler = this.mouseMoveHandler.bind(this); }, onDeselect: function() { this.isEditing && this.exitEditing(); this.selected = false; }, /** * Initializes "added" event handler */ initAddedHandler: function() { var _this = this; this.on('added', function() { var canvas = _this.canvas; if (canvas) { if (!canvas._hasITextHandlers) { canvas._hasITextHandlers = true; _this._initCanvasHandlers(canvas); } canvas._iTextInstances = canvas._iTextInstances || []; canvas._iTextInstances.push(_this); } }); }, initRemovedHandler: function() { var _this = this; this.on('removed', function() { var canvas = _this.canvas; if (canvas) { canvas._iTextInstances = canvas._iTextInstances || []; fabric.util.removeFromArray(canvas._iTextInstances, _this); if (canvas._iTextInstances.length === 0) { canvas._hasITextHandlers = false; _this._removeCanvasHandlers(canvas); } } }); }, /** * register canvas event to manage exiting on other instances * @private */ _initCanvasHandlers: function(canvas) { canvas._mouseUpITextHandler = function() { if (canvas._iTextInstances) { canvas._iTextInstances.forEach(function(obj) { obj.__isMousedown = false; }); } }; canvas.on('mouse:up', canvas._mouseUpITextHandler); }, /** * remove canvas event to manage exiting on other instances * @private */ _removeCanvasHandlers: function(canvas) { canvas.off('mouse:up', canvas._mouseUpITextHandler); }, /** * @private */ _tick: function() { this._currentTickState = this._animateCursor(this, 1, this.cursorDuration, '_onTickComplete'); }, /** * @private */ _animateCursor: function(obj, targetOpacity, duration, completeMethod) { var tickState; tickState = { isAborted: false, abort: function() { this.isAborted = true; }, }; obj.animate('_currentCursorOpacity', targetOpacity, { duration: duration, onComplete: function() { if (!tickState.isAborted) { obj[completeMethod](); } }, onChange: function() { // we do not want to animate a selection, only cursor if (obj.canvas && obj.selectionStart === obj.selectionEnd) { obj.renderCursorOrSelection(); } }, abort: function() { return tickState.isAborted; } }); return tickState; }, /** * @private */ _onTickComplete: function() { var _this = this; if (this._cursorTimeout1) { clearTimeout(this._cursorTimeout1); } this._cursorTimeout1 = setTimeout(function() { _this._currentTickCompleteState = _this._animateCursor(_this, 0, this.cursorDuration / 2, '_tick'); }, 100); }, /** * Initializes delayed cursor */ initDelayedCursor: function(restart) { var _this = this, delay = restart ? 0 : this.cursorDelay; this.abortCursorAnimation(); this._currentCursorOpacity = 1; this._cursorTimeout2 = setTimeout(function() { _this._tick(); }, delay); }, /** * Aborts cursor animation and clears all timeouts */ abortCursorAnimation: function() { var shouldClear = this._currentTickState || this._currentTickCompleteState, canvas = this.canvas; this._currentTickState && this._currentTickState.abort(); this._currentTickCompleteState && this._currentTickCompleteState.abort(); clearTimeout(this._cursorTimeout1); clearTimeout(this._cursorTimeout2); this._currentCursorOpacity = 0; // to clear just itext area we need to transform the context // it may not be worth it if (shouldClear && canvas) { canvas.clearContext(canvas.contextTop || canvas.contextContainer); } }, /** * Selects entire text * @return {fabric.IText} thisArg * @chainable */ selectAll: function() { this.selectionStart = 0; this.selectionEnd = this._text.length; this._fireSelectionChanged(); this._updateTextarea(); return this; }, /** * Returns selected text * @return {String} */ getSelectedText: function() { return this._text.slice(this.selectionStart, this.selectionEnd).join(''); }, /** * Find new selection index representing start of current word according to current selection index * @param {Number} startFrom Current 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[index])) { while (this._reSpace.test(this._text[index])) { offset++; index--; } } while (/\S/.test(this._text[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[index])) { while (this._reSpace.test(this._text[index])) { offset++; index++; } } while (/\S/.test(this._text[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[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[index]) && index < this._text.length) { offset++; index++; } return startFrom + offset; }, /** * 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 text = this._text, index = this._reSpace.test(text[selectionStart]) ? selectionStart - 1 : selectionStart, _char = text[index], // wrong reNonWord = fabric.reNonWord; while (!reNonWord.test(_char) && index > 0 && index < text.length) { index += direction; _char = text[index]; } if (reNonWord.test(_char)) { index += direction === 1 ? 0 : 1; } return index; }, /** * Selects a word based on the index * @param {Number} selectionStart Index of a character */ selectWord: function(selectionStart) { selectionStart = selectionStart || this.selectionStart; var newSelectionStart = this.searchWordBoundary(selectionStart, -1), /* search backwards */ newSelectionEnd = this.searchWordBoundary(selectionStart, 1); /* search forward */ this.selectionStart = newSelectionStart; this.selectionEnd = newSelectionEnd; this._fireSelectionChanged(); this._updateTextarea(); this.renderCursorOrSelection(); }, /** * Selects a line based on the index * @param {Number} selectionStart Index of a character * @return {fabric.IText} thisArg * @chainable */ selectLine: function(selectionStart) { selectionStart = selectionStart || this.selectionStart; var newSelectionStart = this.findLineBoundaryLeft(selectionStart), newSelectionEnd = this.findLineBoundaryRight(selectionStart); this.selectionStart = newSelectionStart; this.selectionEnd = newSelectionEnd; this._fireSelectionChanged(); this._updateTextarea(); return this; }, /** * Enters editing state * @return {fabric.IText} thisArg * @chainable */ enterEditing: function(e) { if (this.isEditing || !this.editable) { return; } if (this.canvas) { this.canvas.calcOffset(); this.exitEditingOnOthers(this.canvas); } this.isEditing = true; this.initHiddenTextarea(e); this.hiddenTextarea.focus(); this.hiddenTextarea.value = this.text; this._updateTextarea(); this._saveEditingProps(); this._setEditingProps(); this._textBeforeEdit = this.text; this._tick(); this.fire('editing:entered'); this._fireSelectionChanged(); if (!this.canvas) { return this; } this.canvas.fire('text:editing:entered', { target: this }); this.initMouseMoveHandler(); this.canvas.requestRenderAll(); return this; }, exitEditingOnOthers: function(canvas) { if (canvas._iTextInstances) { canvas._iTextInstances.forEach(function(obj) { obj.selected = false; if (obj.isEditing) { obj.exitEditing(); } }); } }, /** * Initializes "mousemove" event handler */ initMouseMoveHandler: function() { this.canvas.on('mouse:move', this.mouseMoveHandler); }, /** * @private */ mouseMoveHandler: function(options) { if (!this.__isMousedown || !this.isEditing) { return; } var newSelectionStart = this.getSelectionStartFromPointer(options.e), currentStart = this.selectionStart, currentEnd = this.selectionEnd; if ( (newSelectionStart !== this.__selectionStartOnMouseDown || currentStart === currentEnd) && (currentStart === newSelectionStart || currentEnd === newSelectionStart) ) { return; } if (newSelectionStart > this.__selectionStartOnMouseDown) { this.selectionStart = this.__selectionStartOnMouseDown; this.selectionEnd = newSelectionStart; } else { this.selectionStart = newSelectionStart; this.selectionEnd = this.__selectionStartOnMouseDown; } if (this.selectionStart !== currentStart || this.selectionEnd !== currentEnd) { this.restartCursorIfNeeded(); this._fireSelectionChanged(); this._updateTextarea(); this.renderCursorOrSelection(); } }, /** * @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; }, /** * convert from textarea to grapheme indexes */ fromStringToGraphemeSelection: function(start, end, text) { var smallerTextStart = text.slice(0, start), graphemeStart = fabric.util.string.graphemeSplit(smallerTextStart).length; if (start === end) { return { selectionStart: graphemeStart, selectionEnd: graphemeStart }; } var smallerTextEnd = text.slice(start, end), graphemeEnd = fabric.util.string.graphemeSplit(smallerTextEnd).length; return { selectionStart: graphemeStart, selectionEnd: graphemeStart + graphemeEnd }; }, /** * convert from fabric to textarea values */ fromGraphemeToStringSelection: function(start, end, _text) { var smallerTextStart = _text.slice(0, start), graphemeStart = smallerTextStart.join('').length; if (start === end) { return { selectionStart: graphemeStart, selectionEnd: graphemeStart }; } var smallerTextEnd = _text.slice(start, end), graphemeEnd = smallerTextEnd.join('').length; return { selectionStart: graphemeStart, selectionEnd: graphemeStart + graphemeEnd }; }, /** * @private */ _updateTextarea: function() { this.cursorOffsetCache = { }; if (!this.hiddenTextarea) { return; } if (!this.inCompositionMode) { var newSelection = this.fromGraphemeToStringSelection(this.selectionStart, this.selectionEnd, this._text); this.hiddenTextarea.selectionStart = newSelection.selectionStart; this.hiddenTextarea.selectionEnd = newSelection.selectionEnd; } this.updateTextareaPosition(); }, /** * @private */ updateFromTextArea: function() { if (!this.hiddenTextarea) { return; } this.cursorOffsetCache = { }; this.text = this.hiddenTextarea.value; if (this._shouldClearDimensionCache()) { this.initDimensions(); this.setCoords(); } var newSelection = this.fromStringToGraphemeSelection( this.hiddenTextarea.selectionStart, this.hiddenTextarea.selectionEnd, this.hiddenTextarea.value); this.selectionEnd = this.selectionStart = newSelection.selectionEnd; if (!this.inCompositionMode) { this.selectionStart = newSelection.selectionStart; } this.updateTextareaPosition(); }, /** * @private */ updateTextareaPosition: function() { if (this.selectionStart === this.selectionEnd) { var style = this._calcTextareaPosition(); this.hiddenTextarea.style.left = style.left; this.hiddenTextarea.style.top = style.top; } }, /** * @private * @return {Object} style contains style for hiddenTextarea */ _calcTextareaPosition: function() { if (!this.canvas) { return { x: 1, y: 1 }; } var desiredPosition = this.inCompositionMode ? this.compositionStart : this.selectionStart, boundaries = this._getCursorBoundaries(desiredPosition), cursorLocation = this.get2DCursorLocation(desiredPosition), lineIndex = cursorLocation.lineIndex, charIndex = cursorLocation.charIndex, charHeight = this.getValueOfPropertyAt(lineIndex, charIndex, 'fontSize') * this.lineHeight, leftOffset = boundaries.leftOffset, m = this.calcTransformMatrix(), p = { x: boundaries.left + leftOffset, y: boundaries.top + boundaries.topOffset + charHeight }, retinaScaling = this.canvas.getRetinaScaling(), upperCanvas = this.canvas.upperCanvasEl, upperCanvasWidth = upperCanvas.width / retinaScaling, upperCanvasHeight = upperCanvas.height / retinaScaling, maxWidth = upperCanvasWidth - charHeight, maxHeight = upperCanvasHeight - charHeight, scaleX = upperCanvas.clientWidth / upperCanvasWidth, scaleY = upperCanvas.clientHeight / upperCanvasHeight; p = fabric.util.transformPoint(p, m); p = fabric.util.transformPoint(p, this.canvas.viewportTransform); p.x *= scaleX; p.y *= scaleY; if (p.x < 0) { p.x = 0; } if (p.x > maxWidth) { p.x = maxWidth; } if (p.y < 0) { p.y = 0; } if (p.y > maxHeight) { p.y = maxHeight; } // add canvas offset on document p.x += this.canvas._offset.left; p.y += this.canvas._offset.top; return { left: p.x + 'px', top: p.y + 'px', fontSize: charHeight + 'px', charHeight: charHeight }; }, /** * @private */ _saveEditingProps: function() { this._savedProps = { hasControls: this.hasControls, borderColor: this.borderColor, lockMovementX: this.lockMovementX, lockMovementY: this.lockMovementY, hoverCursor: this.hoverCursor, selectable: this.selectable, defaultCursor: this.canvas && this.canvas.defaultCursor, moveCursor: this.canvas && this.canvas.moveCursor }; }, /** * @private */ _restoreEditingProps: function() { if (!this._savedProps) { return; } this.hoverCursor = this._savedProps.hoverCursor; this.hasControls = this._savedProps.hasControls; this.borderColor = this._savedProps.borderColor; this.selectable = this._savedProps.selectable; 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() { var isTextChanged = (this._textBeforeEdit !== this.text); var hiddenTextarea = this.hiddenTextarea; this.selected = false; this.isEditing = false; this.selectionEnd = this.selectionStart; if (hiddenTextarea) { hiddenTextarea.blur && hiddenTextarea.blur(); hiddenTextarea.parentNode && hiddenTextarea.parentNode.removeChild(hiddenTextarea); } this.hiddenTextarea = null; this.abortCursorAnimation(); this._restoreEditingProps(); this._currentCursorOpacity = 0; if (this._shouldClearDimensionCache()) { this.initDimensions(); this.setCoords(); } this.fire('editing:exited'); isTextChanged && this.fire('modified'); if (this.canvas) { this.canvas.off('mouse:move', this.mouseMoveHandler); this.canvas.fire('text:editing:exited', { target: this }); isTextChanged && this.canvas.fire('object:modified', { target: this }); } return this; }, /** * @private */ _removeExtraneousStyles: function() { for (var prop in this.styles) { if (!this._textLines[prop]) { delete this.styles[prop]; } } }, /** * remove and reflow a style block from start to end. * @param {Number} start linear start position for removal (included in removal) * @param {Number} end linear end position for removal ( excluded from removal ) */ removeStyleFromTo: function(start, end) { var cursorStart = this.get2DCursorLocation(start, true), cursorEnd = this.get2DCursorLocation(end, true), lineStart = cursorStart.lineIndex, charStart = cursorStart.charIndex, lineEnd = cursorEnd.lineIndex, charEnd = cursorEnd.charIndex, i, styleObj; if (lineStart !== lineEnd) { // step1 remove the trailing of lineStart if (this.styles[lineStart]) { for (i = charStart; i < this._unwrappedTextLines[lineStart].length; i++) { delete this.styles[lineStart][i]; } } // step2 move the trailing of lineEnd to lineStart if needed if (this.styles[lineEnd]) { for (i = charEnd; i < this._unwrappedTextLines[lineEnd].length; i++) { styleObj = this.styles[lineEnd][i]; if (styleObj) { this.styles[lineStart] || (this.styles[lineStart] = { }); this.styles[lineStart][charStart + i - charEnd] = styleObj; } } } // step3 detects lines will be completely removed. for (i = lineStart + 1; i <= lineEnd; i++) { delete this.styles[i]; } // step4 shift remaining lines. this.shiftLineStyles(lineEnd, lineStart - lineEnd); } else { // remove and shift left on the same line if (this.styles[lineStart]) { styleObj = this.styles[lineStart]; var diff = charEnd - charStart, numericChar, _char; for (i = charStart; i < charEnd; i++) { delete styleObj[i]; } for (_char in this.styles[lineStart]) { numericChar = parseInt(_char, 10); if (numericChar >= charEnd) { styleObj[numericChar - diff] = styleObj[_char]; delete styleObj[_char]; } } } } }, /** * Shifts line styles up or down * @param {Number} lineIndex Index of a line * @param {Number} offset Can any number? */ shiftLineStyles: function(lineIndex, offset) { // shift all line styles by offset upward or downward // do not clone deep. we need new array, not new style objects 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]; if (!clonedStyles[numericLine - offset]) { delete this.styles[numericLine]; } } } }, restartCursorIfNeeded: function() { if (!this._currentTickState || this._currentTickState.isAborted || !this._currentTickCompleteState || this._currentTickCompleteState.isAborted ) { this.initDelayedCursor(); } }, /** * Inserts new style object * @param {Number} lineIndex Index of a line * @param {Number} charIndex Index of a char * @param {Number} qty number of lines to add * @param {Array} copiedStyle Array of objects styles */ insertNewlineStyleObject: function(lineIndex, charIndex, qty, copiedStyle) { var currentCharStyle, newLineStyles = {}, somethingAdded = false; qty || (qty = 1); this.shiftLineStyles(lineIndex, qty); if (this.styles[lineIndex]) { currentCharStyle = this.styles[lineIndex][charIndex === 0 ? charIndex : charIndex - 1]; } // we clone styles of all chars // after cursor onto the current line for (var index in this.styles[lineIndex]) { var numIndex = parseInt(index, 10); if (numIndex >= charIndex) { somethingAdded = true; newLineStyles[numIndex - charIndex] = this.styles[lineIndex][index]; // remove lines from the previous line since they're on a new line now delete this.styles[lineIndex][index]; } } if (somethingAdded) { this.styles[lineIndex + qty] = newLineStyles; } else { delete this.styles[lineIndex + qty]; } // for the other lines // we clone current char style onto the next (otherwise empty) line while (qty > 1) { qty--; if (copiedStyle && copiedStyle[qty]) { this.styles[lineIndex + qty] = { 0: clone(copiedStyle[qty]) }; } else if (currentCharStyle) { this.styles[lineIndex + qty] = { 0: clone(currentCharStyle) }; } else { delete this.styles[lineIndex + qty]; } } this._forceClearCache = true; }, /** * Inserts style object for a given line/char index * @param {Number} lineIndex Index of a line * @param {Number} charIndex Index of a char * @param {Number} quantity number Style object to insert, if given * @param {Array} copiedStyle array of style objects */ insertCharStyleObject: function(lineIndex, charIndex, quantity, copiedStyle) { if (!this.styles) { this.styles = {}; } var currentLineStyles = this.styles[lineIndex], currentLineStylesCloned = currentLineStyles ? clone(currentLineStyles) : {}; quantity || (quantity = 1); // shift all char styles by quantity 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 + quantity] = currentLineStylesCloned[numericIndex]; // only delete the style if there was nothing moved there if (!currentLineStylesCloned[numericIndex - quantity]) { delete currentLineStyles[numericIndex]; } } } this._forceClearCache = true; if (copiedStyle) { while (quantity--) { if (!Object.keys(copiedStyle[quantity]).length) { continue; } if (!this.styles[lineIndex]) { this.styles[lineIndex] = {}; } this.styles[lineIndex][charIndex + quantity] = clone(copiedStyle[quantity]); } return; } if (!currentLineStyles) { return; } var newStyle = currentLineStyles[charIndex ? charIndex - 1 : 1]; while (newStyle && quantity--) { this.styles[lineIndex][charIndex + quantity] = clone(newStyle); } }, /** * Inserts style object(s) * @param {Array} insertedText Characters at the location where style is inserted * @param {Number} start cursor index for inserting style * @param {Array} [copiedStyle] array of style objects to insert. */ insertNewStyleBlock: function(insertedText, start, copiedStyle) { var cursorLoc = this.get2DCursorLocation(start, true), addedLines = [0], linesLength = 0; for (var i = 0; i < insertedText.length; i++) { if (insertedText[i] === '\n') { linesLength++; addedLines[linesLength] = 0; } else { addedLines[linesLength]++; } } if (addedLines[0] > 0) { this.insertCharStyleObject(cursorLoc.lineIndex, cursorLoc.charIndex, addedLines[0], copiedStyle); copiedStyle = copiedStyle && copiedStyle.slice(addedLines[0] + 1); } linesLength && this.insertNewlineStyleObject( cursorLoc.lineIndex, cursorLoc.charIndex + addedLines[0], linesLength); for (var i = 1; i < linesLength; i++) { if (addedLines[i] > 0) { this.insertCharStyleObject(cursorLoc.lineIndex + i, 0, addedLines[i], copiedStyle); } else if (copiedStyle) { this.styles[cursorLoc.lineIndex + i][0] = copiedStyle[0]; } copiedStyle = copiedStyle && copiedStyle.slice(addedLines[i] + 1); } // we use i outside the loop to get it like linesLength if (addedLines[i] > 0) { this.insertCharStyleObject(cursorLoc.lineIndex + i, 0, addedLines[i], copiedStyle); } }, /** * Set the selectionStart and selectionEnd according to the new position of cursor * mimic the key - mouse navigation when shift is pressed. */ setSelectionStartEndWithShift: function(start, end, newSelection) { if (newSelection <= start) { if (end === start) { this._selectionDirection = 'left'; } else if (this._selectionDirection === 'right') { this._selectionDirection = 'left'; this.selectionEnd = start; } this.selectionStart = newSelection; } else if (newSelection > start && newSelection < end) { if (this._selectionDirection === 'right') { this.selectionEnd = newSelection; } else { this.selectionStart = newSelection; } } else { // newSelection is > selection start and end if (end === start) { this._selectionDirection = 'right'; } else if (this._selectionDirection === 'left') { this._selectionDirection = 'right'; this.selectionStart = end; } this.selectionEnd = newSelection; } }, setSelectionInBoundaries: function() { var length = this.text.length; if (this.selectionStart > length) { this.selectionStart = length; } else if (this.selectionStart < 0) { this.selectionStart = 0; } if (this.selectionEnd > length) { this.selectionEnd = length; } else if (this.selectionEnd < 0) { this.selectionEnd = 0; } } }); })();