/* ***** BEGIN LICENSE BLOCK ***** * Distributed under the BSD license: * * Copyright (c) 2010, Ajax.org B.V. * All rights reserved. * * Redistribution and use in source and binary forms, with or without * modification, are permitted provided that the following conditions are met: * * Redistributions of source code must retain the above copyright * notice, this list of conditions and the following disclaimer. * * Redistributions in binary form must reproduce the above copyright * notice, this list of conditions and the following disclaimer in the * documentation and/or other materials provided with the distribution. * * Neither the name of Ajax.org B.V. nor the * names of its contributors may be used to endorse or promote products * derived from this software without specific prior written permission. * * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE * DISCLAIMED. IN NO EVENT SHALL AJAX.ORG B.V. BE LIABLE FOR ANY * DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND * ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. * * ***** END LICENSE BLOCK ***** */ define(function(require, exports, module) { "use strict"; var oop = require("./lib/oop"); var applyDelta = require("./apply_delta").applyDelta; var EventEmitter = require("./lib/event_emitter").EventEmitter; var Range = require("./range").Range; var Anchor = require("./anchor").Anchor; /** * Contains the text of the document. Document can be attached to several [[EditSession `EditSession`]]s. * At its core, `Document`s are just an array of strings, with each row in the document matching up to the array index. * * @class Document **/ /** * * Creates a new `Document`. If `text` is included, the `Document` contains those strings; otherwise, it's empty. * @param {String | Array} text The starting text * @constructor **/ var Document = function(textOrLines) { this.$lines = [""]; // There has to be one line at least in the document. If you pass an empty // string to the insert function, nothing will happen. Workaround. if (textOrLines.length === 0) { this.$lines = [""]; } else if (Array.isArray(textOrLines)) { this.insertMergedLines({row: 0, column: 0}, textOrLines); } else { this.insert({row: 0, column:0}, textOrLines); } }; (function() { oop.implement(this, EventEmitter); /** * Replaces all the lines in the current `Document` with the value of `text`. * * @param {String} text The text to use **/ this.setValue = function(text) { var len = this.getLength() - 1; this.remove(new Range(0, 0, len, this.getLine(len).length)); this.insert({row: 0, column: 0}, text); }; /** * Returns all the lines in the document as a single string, joined by the new line character. **/ this.getValue = function() { return this.getAllLines().join(this.getNewLineCharacter()); }; /** * Creates a new `Anchor` to define a floating point in the document. * @param {Number} row The row number to use * @param {Number} column The column number to use * **/ this.createAnchor = function(row, column) { return new Anchor(this, row, column); }; /** * Splits a string of text on any newline (`\n`) or carriage-return (`\r`) characters. * * @method $split * @param {String} text The text to work with * @returns {String} A String array, with each index containing a piece of the original `text` string. * **/ // check for IE split bug if ("aaa".split(/a/).length === 0) { this.$split = function(text) { return text.replace(/\r\n|\r/g, "\n").split("\n"); }; } else { this.$split = function(text) { return text.split(/\r\n|\r|\n/); }; } this.$detectNewLine = function(text) { var match = text.match(/^.*?(\r\n|\r|\n)/m); this.$autoNewLine = match ? match[1] : "\n"; this._signal("changeNewLineMode"); }; /** * Returns the newline character that's being used, depending on the value of `newLineMode`. * @returns {String} If `newLineMode == windows`, `\r\n` is returned. * If `newLineMode == unix`, `\n` is returned. * If `newLineMode == auto`, the value of `autoNewLine` is returned. * **/ this.getNewLineCharacter = function() { switch (this.$newLineMode) { case "windows": return "\r\n"; case "unix": return "\n"; default: return this.$autoNewLine || "\n"; } }; this.$autoNewLine = ""; this.$newLineMode = "auto"; /** * [Sets the new line mode.]{: #Document.setNewLineMode.desc} * @param {String} newLineMode [The newline mode to use; can be either `windows`, `unix`, or `auto`]{: #Document.setNewLineMode.param} * **/ this.setNewLineMode = function(newLineMode) { if (this.$newLineMode === newLineMode) return; this.$newLineMode = newLineMode; this._signal("changeNewLineMode"); }; /** * [Returns the type of newlines being used; either `windows`, `unix`, or `auto`]{: #Document.getNewLineMode} * @returns {String} **/ this.getNewLineMode = function() { return this.$newLineMode; }; /** * Returns `true` if `text` is a newline character (either `\r\n`, `\r`, or `\n`). * @param {String} text The text to check * **/ this.isNewLine = function(text) { return (text == "\r\n" || text == "\r" || text == "\n"); }; /** * Returns a verbatim copy of the given line as it is in the document * @param {Number} row The row index to retrieve * **/ this.getLine = function(row) { return this.$lines[row] || ""; }; /** * Returns an array of strings of the rows between `firstRow` and `lastRow`. This function is inclusive of `lastRow`. * @param {Number} firstRow The first row index to retrieve * @param {Number} lastRow The final row index to retrieve * **/ this.getLines = function(firstRow, lastRow) { return this.$lines.slice(firstRow, lastRow + 1); }; /** * Returns all lines in the document as string array. **/ this.getAllLines = function() { return this.getLines(0, this.getLength()); }; /** * Returns the number of rows in the document. **/ this.getLength = function() { return this.$lines.length; }; /** * Returns all the text within `range` as a single string. * @param {Range} range The range to work with. * * @returns {String} **/ this.getTextRange = function(range) { return this.getLinesForRange(range).join(this.getNewLineCharacter()); }; /** * Returns all the text within `range` as an array of lines. * @param {Range} range The range to work with. * * @returns {Array} **/ this.getLinesForRange = function(range) { var lines; if (range.start.row === range.end.row) { // Handle a single-line range. lines = [this.getLine(range.start.row).substring(range.start.column, range.end.column)]; } else { // Handle a multi-line range. lines = this.getLines(range.start.row, range.end.row); lines[0] = (lines[0] || "").substring(range.start.column); var l = lines.length - 1; if (range.end.row - range.start.row == l) lines[l] = lines[l].substring(0, range.end.column); } return lines; }; // Deprecated methods retained for backwards compatibility. this.insertLines = function(row, lines) { console.warn("Use of document.insertLines is deprecated. Use the insertFullLines method instead."); return this.insertFullLines(row, lines); }; this.removeLines = function(firstRow, lastRow) { console.warn("Use of document.removeLines is deprecated. Use the removeFullLines method instead."); return this.removeFullLines(firstRow, lastRow); }; this.insertNewLine = function(position) { console.warn("Use of document.insertNewLine is deprecated. Use insertMergedLines(position, ['', '']) instead."); return this.insertMergedLines(position, ["", ""]); }; /** * Inserts a block of `text` at the indicated `position`. * @param {Object} position The position to start inserting at; it's an object that looks like `{ row: row, column: column}` * @param {String} text A chunk of text to insert * @returns {Object} The position ({row, column}) of the last line of `text`. If the length of `text` is 0, this function simply returns `position`. * **/ this.insert = function(position, text) { // Only detect new lines if the document has no line break yet. if (this.getLength() <= 1) this.$detectNewLine(text); return this.insertMergedLines(position, this.$split(text)); }; /** * Inserts `text` into the `position` at the current row. This method also triggers the `"change"` event. * * This differs from the `insert` method in two ways: * 1. This does NOT handle newline characters (single-line text only). * 2. This is faster than the `insert` method for single-line text insertions. * * @param {Object} position The position to insert at; it's an object that looks like `{ row: row, column: column}` * @param {String} text A chunk of text * @returns {Object} Returns an object containing the final row and column, like this: * ``` * {row: endRow, column: 0} * ``` **/ this.insertInLine = function(position, text) { var start = this.clippedPos(position.row, position.column); var end = this.pos(position.row, position.column + text.length); this.applyDelta({ start: start, end: end, action: "insert", lines: [text] }, true); return this.clonePos(end); }; this.clippedPos = function(row, column) { var length = this.getLength(); if (row === undefined) { row = length; } else if (row < 0) { row = 0; } else if (row >= length) { row = length - 1; column = undefined; } var line = this.getLine(row); if (column == undefined) column = line.length; column = Math.min(Math.max(column, 0), line.length); return {row: row, column: column}; }; this.clonePos = function(pos) { return {row: pos.row, column: pos.column}; }; this.pos = function(row, column) { return {row: row, column: column}; }; this.$clipPosition = function(position) { var length = this.getLength(); if (position.row >= length) { position.row = Math.max(0, length - 1); position.column = this.getLine(length - 1).length; } else { position.row = Math.max(0, position.row); position.column = Math.min(Math.max(position.column, 0), this.getLine(position.row).length); } return position; }; /** * Fires whenever the document changes. * * Several methods trigger different `"change"` events. Below is a list of each action type, followed by each property that's also available: * * * `"insert"` * * `range`: the [[Range]] of the change within the document * * `lines`: the lines being added * * `"remove"` * * `range`: the [[Range]] of the change within the document * * `lines`: the lines being removed * * @event change * @param {Object} e Contains at least one property called `"action"`. `"action"` indicates the action that triggered the change. Each action also has a set of additional properties. * **/ /** * Inserts the elements in `lines` into the document as full lines (does not merge with existing line), starting at the row index given by `row`. This method also triggers the `"change"` event. * @param {Number} row The index of the row to insert at * @param {Array} lines An array of strings * @returns {Object} Contains the final row and column, like this: * ``` * {row: endRow, column: 0} * ``` * If `lines` is empty, this function returns an object containing the current row, and column, like this: * ``` * {row: row, column: 0} * ``` * **/ this.insertFullLines = function(row, lines) { // Clip to document. // Allow one past the document end. row = Math.min(Math.max(row, 0), this.getLength()); // Calculate insertion point. var column = 0; if (row < this.getLength()) { // Insert before the specified row. lines = lines.concat([""]); column = 0; } else { // Insert after the last row in the document. lines = [""].concat(lines); row--; column = this.$lines[row].length; } // Insert. this.insertMergedLines({row: row, column: column}, lines); }; /** * Inserts the elements in `lines` into the document, starting at the position index given by `row`. This method also triggers the `"change"` event. * @param {Number} row The index of the row to insert at * @param {Array} lines An array of strings * @returns {Object} Contains the final row and column, like this: * ``` * {row: endRow, column: 0} * ``` * If `lines` is empty, this function returns an object containing the current row, and column, like this: * ``` * {row: row, column: 0} * ``` * **/ this.insertMergedLines = function(position, lines) { var start = this.clippedPos(position.row, position.column); var end = { row: start.row + lines.length - 1, column: (lines.length == 1 ? start.column : 0) + lines[lines.length - 1].length }; this.applyDelta({ start: start, end: end, action: "insert", lines: lines }); return this.clonePos(end); }; /** * Removes the `range` from the document. * @param {Range} range A specified Range to remove * @returns {Object} Returns the new `start` property of the range, which contains `startRow` and `startColumn`. If `range` is empty, this function returns the unmodified value of `range.start`. * **/ this.remove = function(range) { var start = this.clippedPos(range.start.row, range.start.column); var end = this.clippedPos(range.end.row, range.end.column); this.applyDelta({ start: start, end: end, action: "remove", lines: this.getLinesForRange({start: start, end: end}) }); return this.clonePos(start); }; /** * Removes the specified columns from the `row`. This method also triggers a `"change"` event. * @param {Number} row The row to remove from * @param {Number} startColumn The column to start removing at * @param {Number} endColumn The column to stop removing at * @returns {Object} Returns an object containing `startRow` and `startColumn`, indicating the new row and column values.
If `startColumn` is equal to `endColumn`, this function returns nothing. * **/ this.removeInLine = function(row, startColumn, endColumn) { var start = this.clippedPos(row, startColumn); var end = this.clippedPos(row, endColumn); this.applyDelta({ start: start, end: end, action: "remove", lines: this.getLinesForRange({start: start, end: end}) }, true); return this.clonePos(start); }; /** * Removes a range of full lines. This method also triggers the `"change"` event. * @param {Number} firstRow The first row to be removed * @param {Number} lastRow The last row to be removed * @returns {[String]} Returns all the removed lines. * **/ this.removeFullLines = function(firstRow, lastRow) { // Clip to document. firstRow = Math.min(Math.max(0, firstRow), this.getLength() - 1); lastRow = Math.min(Math.max(0, lastRow ), this.getLength() - 1); // Calculate deletion range. // Delete the ending new line unless we're at the end of the document. // If we're at the end of the document, delete the starting new line. var deleteFirstNewLine = lastRow == this.getLength() - 1 && firstRow > 0; var deleteLastNewLine = lastRow < this.getLength() - 1; var startRow = ( deleteFirstNewLine ? firstRow - 1 : firstRow ); var startCol = ( deleteFirstNewLine ? this.getLine(startRow).length : 0 ); var endRow = ( deleteLastNewLine ? lastRow + 1 : lastRow ); var endCol = ( deleteLastNewLine ? 0 : this.getLine(endRow).length ); var range = new Range(startRow, startCol, endRow, endCol); // Store delelted lines with bounding newlines ommitted (maintains previous behavior). var deletedLines = this.$lines.slice(firstRow, lastRow + 1); this.applyDelta({ start: range.start, end: range.end, action: "remove", lines: this.getLinesForRange(range) }); // Return the deleted lines. return deletedLines; }; /** * Removes the new line between `row` and the row immediately following it. This method also triggers the `"change"` event. * @param {Number} row The row to check * **/ this.removeNewLine = function(row) { if (row < this.getLength() - 1 && row >= 0) { this.applyDelta({ start: this.pos(row, this.getLine(row).length), end: this.pos(row + 1, 0), action: "remove", lines: ["", ""] }); } }; /** * Replaces a range in the document with the new `text`. * @param {Range} range A specified Range to replace * @param {String} text The new text to use as a replacement * @returns {Object} Returns an object containing the final row and column, like this: * {row: endRow, column: 0} * If the text and range are empty, this function returns an object containing the current `range.start` value. * If the text is the exact same as what currently exists, this function returns an object containing the current `range.end` value. * **/ this.replace = function(range, text) { if (!(range instanceof Range)) range = Range.fromPoints(range.start, range.end); if (text.length === 0 && range.isEmpty()) return range.start; // Shortcut: If the text we want to insert is the same as it is already // in the document, we don't have to replace anything. if (text == this.getTextRange(range)) return range.end; this.remove(range); var end; if (text) { end = this.insert(range.start, text); } else { end = range.start; } return end; }; /** * Applies all changes in `deltas` to the document. * @param {Array} deltas An array of delta objects (can include "insert" and "remove" actions) **/ this.applyDeltas = function(deltas) { for (var i=0; i=0; i--) { this.revertDelta(deltas[i]); } }; /** * Applies `delta` to the document. * @param {Object} delta A delta object (can include "insert" and "remove" actions) **/ this.applyDelta = function(delta, doNotValidate) { var isInsert = delta.action == "insert"; // An empty range is a NOOP. if (isInsert ? delta.lines.length <= 1 && !delta.lines[0] : !Range.comparePoints(delta.start, delta.end)) { return; } if (isInsert && delta.lines.length > 20000) { this.$splitAndapplyLargeDelta(delta, 20000); } else { applyDelta(this.$lines, delta, doNotValidate); this._signal("change", delta); } }; this.$splitAndapplyLargeDelta = function(delta, MAX) { // Split large insert deltas. This is necessary because: // 1. We need to support splicing delta lines into the document via $lines.splice.apply(...) // 2. fn.apply() doesn't work for a large number of params. The smallest threshold is on chrome 40 ~42000. // we use 20000 to leave some space for actual stack // // To Do: Ideally we'd be consistent and also split 'delete' deltas. We don't do this now, because delete // delta handling is too slow. If we make delete delta handling faster we can split all large deltas // as shown in https://gist.github.com/aldendaniels/8367109#file-document-snippet-js // If we do this, update validateDelta() to limit the number of lines in a delete delta. var lines = delta.lines; var l = lines.length - MAX + 1; var row = delta.start.row; var column = delta.start.column; for (var from = 0, to = 0; from < l; from = to) { to += MAX - 1; var chunk = lines.slice(from, to); chunk.push(""); this.applyDelta({ start: this.pos(row + from, column), end: this.pos(row + to, column = 0), action: delta.action, lines: chunk }, true); } // Update remaining delta. delta.lines = lines.slice(from); delta.start.row = row + from; delta.start.column = column; this.applyDelta(delta, true); }; /** * Reverts `delta` from the document. * @param {Object} delta A delta object (can include "insert" and "remove" actions) **/ this.revertDelta = function(delta) { this.applyDelta({ start: this.clonePos(delta.start), end: this.clonePos(delta.end), action: (delta.action == "insert" ? "remove" : "insert"), lines: delta.lines.slice() }); }; /** * Converts an index position in a document to a `{row, column}` object. * * Index refers to the "absolute position" of a character in the document. For example: * * ```javascript * var x = 0; // 10 characters, plus one for newline * var y = -1; * ``` * * Here, `y` is an index 15: 11 characters for the first row, and 5 characters until `y` in the second. * * @param {Number} index An index to convert * @param {Number} startRow=0 The row from which to start the conversion * @returns {Object} A `{row, column}` object of the `index` position */ this.indexToPosition = function(index, startRow) { var lines = this.$lines || this.getAllLines(); var newlineLength = this.getNewLineCharacter().length; for (var i = startRow || 0, l = lines.length; i < l; i++) { index -= lines[i].length + newlineLength; if (index < 0) return {row: i, column: index + lines[i].length + newlineLength}; } return {row: l-1, column: index + lines[l-1].length + newlineLength}; }; /** * Converts the `{row, column}` position in a document to the character's index. * * Index refers to the "absolute position" of a character in the document. For example: * * ```javascript * var x = 0; // 10 characters, plus one for newline * var y = -1; * ``` * * Here, `y` is an index 15: 11 characters for the first row, and 5 characters until `y` in the second. * * @param {Object} pos The `{row, column}` to convert * @param {Number} startRow=0 The row from which to start the conversion * @returns {Number} The index position in the document */ this.positionToIndex = function(pos, startRow) { var lines = this.$lines || this.getAllLines(); var newlineLength = this.getNewLineCharacter().length; var index = 0; var row = Math.min(pos.row, lines.length); for (var i = startRow || 0; i < row; ++i) index += lines[i].length + newlineLength; return index + pos.column; }; }).call(Document.prototype); exports.Document = Document; });