diff --git a/src/LiveDevelopment/Agents/HighlightAgent.js b/src/LiveDevelopment/Agents/HighlightAgent.js index 8b77a2eff59..08f978b6365 100644 --- a/src/LiveDevelopment/Agents/HighlightAgent.js +++ b/src/LiveDevelopment/Agents/HighlightAgent.js @@ -37,7 +37,8 @@ define(function HighlightAgent(require, exports, module) { var DOMAgent = require("LiveDevelopment/Agents/DOMAgent"), Inspector = require("LiveDevelopment/Inspector/Inspector"), LiveDevelopment = require("LiveDevelopment/LiveDevelopment"), - RemoteAgent = require("LiveDevelopment/Agents/RemoteAgent"); + RemoteAgent = require("LiveDevelopment/Agents/RemoteAgent"), + _ = require("thirdparty/lodash"); var _highlight = {}; // active highlight @@ -108,11 +109,22 @@ define(function HighlightAgent(require, exports, module) { } /** Highlight all nodes with 'data-brackets-id' value - * that matches id. - * @param {string} value of the 'data-brackets-id' to match + * that matches id, or if id is an array, matches any of the given ids. + * @param {string|Array} value of the 'data-brackets-id' to match, + * or an array of such. */ - function domElement(id) { - rule("[data-brackets-id='" + id + "']"); + function domElement(ids) { + var selector = ""; + if (!Array.isArray(ids)) { + ids = [ids]; + } + _.each(ids, function (id) { + if (selector !== "") { + selector += ","; + } + selector += "[data-brackets-id='" + id + "']"; + }); + rule(selector); } /** diff --git a/src/LiveDevelopment/Documents/CSSDocument.js b/src/LiveDevelopment/Documents/CSSDocument.js index 032e6e631c3..c9a1e9e90d0 100644 --- a/src/LiveDevelopment/Documents/CSSDocument.js +++ b/src/LiveDevelopment/Documents/CSSDocument.js @@ -159,10 +159,17 @@ define(function CSSDocumentModule(require, exports, module) { CSSDocument.prototype.updateHighlight = function () { if (Inspector.config.highlight && this.editor) { - var codeMirror = this.editor._codeMirror; - var selector = CSSUtils.findSelectorAtDocumentPos(this.editor, codeMirror.getCursor()); - if (selector) { - HighlightAgent.rule(selector); + var editor = this.editor, + codeMirror = editor._codeMirror, + selectors = []; + _.each(this.editor.getSelections(), function (sel) { + var selector = CSSUtils.findSelectorAtDocumentPos(editor, (sel.reversed ? sel.end : sel.start)); + if (selector) { + selectors.push(selector); + } + }); + if (selectors.length) { + HighlightAgent.rule(selectors.join(",")); } else { HighlightAgent.hide(); } diff --git a/src/LiveDevelopment/Documents/HTMLDocument.js b/src/LiveDevelopment/Documents/HTMLDocument.js index a8c398ef477..d4d8a1ee350 100644 --- a/src/LiveDevelopment/Documents/HTMLDocument.js +++ b/src/LiveDevelopment/Documents/HTMLDocument.js @@ -52,7 +52,8 @@ define(function HTMLDocumentModule(require, exports, module) { LiveDevelopment = require("LiveDevelopment/LiveDevelopment"), PerfUtils = require("utils/PerfUtils"), RemoteAgent = require("LiveDevelopment/Agents/RemoteAgent"), - StringUtils = require("utils/StringUtils"); + StringUtils = require("utils/StringUtils"), + _ = require("thirdparty/lodash"); /** * Constructor @@ -140,17 +141,24 @@ define(function HTMLDocumentModule(require, exports, module) { /** Update the highlight */ HTMLDocument.prototype.updateHighlight = function () { - var codeMirror = this.editor._codeMirror; + var editor = this.editor, + codeMirror = editor._codeMirror, + ids = []; if (Inspector.config.highlight) { - var tagID = HTMLInstrumentation._getTagIDAtDocumentPos( - this.editor, - codeMirror.getCursor() - ); + _.each(this.editor.getSelections(), function (sel) { + var tagID = HTMLInstrumentation._getTagIDAtDocumentPos( + editor, + sel.reversed ? sel.end : sel.start + ); + if (tagID !== -1) { + ids.push(tagID); + } + }); - if (tagID === -1) { + if (!ids.length) { HighlightAgent.hide(); } else { - HighlightAgent.domElement(tagID); + HighlightAgent.domElement(ids); } } }; diff --git a/src/base-config/keyboard.json b/src/base-config/keyboard.json index 7978fce2c73..4ce1c1a9ec4 100644 --- a/src/base-config/keyboard.json +++ b/src/base-config/keyboard.json @@ -65,6 +65,21 @@ "platform": "mac" } ], + "edit.splitSelIntoLines": [ + "Ctrl-Alt-L" + ], + "edit.addPrevLineToSel": [ + { + "key": "Shift-Alt-Up", + "displayKey": "Shift-Alt-↑" + } + ], + "edit.addNextLineToSel": [ + { + "key": "Shift-Alt-Down", + "displayKey": "Shift-Alt-↓" + } + ], "edit.find": [ "Ctrl-F" ], @@ -89,6 +104,25 @@ "platform": "mac" } ], + "edit.findAllAndSelect": [ + { + "key": "Alt-F3" + }, + { + "key": "Cmd-Ctrl-G", + "platform": "mac" + } + ], + "edit.addNextMatch": [ + { + "key": "Ctrl-B" + } + ], + "edit.skipCurrentMatch": [ + { + "key": "Ctrl-Shift-B" + } + ], "edit.replace": [ { "key": "Ctrl-H" diff --git a/src/brackets.js b/src/brackets.js index 566ae02ef20..c644d820b79 100644 --- a/src/brackets.js +++ b/src/brackets.js @@ -44,6 +44,18 @@ define(function (require, exports, module) { require("widgets/bootstrap-twipsy-mod"); require("thirdparty/path-utils/path-utils.min"); require("thirdparty/smart-auto-complete-local/jquery.smart_autocomplete"); + + // Load CodeMirror add-ons--these attach themselves to the CodeMirror module + require("thirdparty/CodeMirror2/addon/fold/xml-fold"); + require("thirdparty/CodeMirror2/addon/edit/matchtags"); + require("thirdparty/CodeMirror2/addon/edit/matchbrackets"); + require("thirdparty/CodeMirror2/addon/edit/closebrackets"); + require("thirdparty/CodeMirror2/addon/edit/closetag"); + require("thirdparty/CodeMirror2/addon/selection/active-line"); + require("thirdparty/CodeMirror2/addon/mode/multiplex"); + require("thirdparty/CodeMirror2/addon/mode/overlay"); + require("thirdparty/CodeMirror2/addon/search/searchcursor"); + require("thirdparty/CodeMirror2/keymap/sublime"); // Load dependent modules var Global = require("utils/Global"), diff --git a/src/command/Commands.js b/src/command/Commands.js index c20776f3bf2..3b90e698f98 100644 --- a/src/command/Commands.js +++ b/src/command/Commands.js @@ -69,14 +69,20 @@ define(function (require, exports, module) { exports.EDIT_SELECT_ALL = "edit.selectAll"; // EditorCommandHandlers.js _handleSelectAll() exports.EDIT_SELECT_LINE = "edit.selectLine"; // EditorCommandHandlers.js selectLine() + exports.EDIT_SPLIT_SEL_INTO_LINES = "edit.splitSelIntoLines"; // EditorCommandHandlers.js splitSelIntoLines() + exports.EDIT_ADD_NEXT_LINE_TO_SEL = "edit.addNextLineToSel"; // EditorCommandHandlers.js addNextLineToSel() + exports.EDIT_ADD_PREV_LINE_TO_SEL = "edit.addPrevLineToSel"; // EditorCommandHandlers.js addPrevLineToSel() exports.EDIT_FIND = "edit.find"; // FindReplace.js _launchFind() exports.EDIT_FIND_IN_FILES = "edit.findInFiles"; // FindInFiles.js _doFindInFiles() exports.EDIT_FIND_IN_SUBTREE = "edit.findInSubtree"; // FindInFiles.js _doFindInSubtree() exports.EDIT_FIND_NEXT = "edit.findNext"; // FindReplace.js _findNext() exports.EDIT_FIND_PREVIOUS = "edit.findPrevious"; // FindReplace.js _findPrevious() + exports.EDIT_FIND_ALL_AND_SELECT = "edit.findAllAndSelect"; // FindReplace.js _findAllAndSelect() + exports.EDIT_ADD_NEXT_MATCH = "edit.addNextMatch"; // FindReplace.js _expandAndAddNextToSelection() + exports.EDIT_SKIP_CURRENT_MATCH = "edit.skipCurrentMatch"; // FindReplace.js _skipCurrentMatch() exports.EDIT_REPLACE = "edit.replace"; // FindReplace.js _replace() exports.EDIT_INDENT = "edit.indent"; // EditorCommandHandlers.js indentText() - exports.EDIT_UNINDENT = "edit.unindent"; // EditorCommandHandlers.js unidentText() + exports.EDIT_UNINDENT = "edit.unindent"; // EditorCommandHandlers.js unindentText() exports.EDIT_DUPLICATE = "edit.duplicate"; // EditorCommandHandlers.js duplicateText() exports.EDIT_DELETE_LINES = "edit.deletelines"; // EditorCommandHandlers.js deleteCurrentLines() exports.EDIT_LINE_COMMENT = "edit.lineComment"; // EditorCommandHandlers.js lineComment() diff --git a/src/command/DefaultMenus.js b/src/command/DefaultMenus.js index 5fd455d62e4..f41b2079a1c 100644 --- a/src/command/DefaultMenus.js +++ b/src/command/DefaultMenus.js @@ -78,12 +78,17 @@ define(function (require, exports, module) { menu.addMenuDivider(); menu.addMenuItem(Commands.EDIT_SELECT_ALL); menu.addMenuItem(Commands.EDIT_SELECT_LINE); + menu.addMenuItem(Commands.EDIT_SPLIT_SEL_INTO_LINES); + menu.addMenuItem(Commands.EDIT_ADD_PREV_LINE_TO_SEL); + menu.addMenuItem(Commands.EDIT_ADD_NEXT_LINE_TO_SEL); menu.addMenuDivider(); menu.addMenuItem(Commands.EDIT_FIND); menu.addMenuItem(Commands.EDIT_FIND_IN_FILES); menu.addMenuItem(Commands.EDIT_FIND_NEXT); - menu.addMenuItem(Commands.EDIT_FIND_PREVIOUS); + menu.addMenuItem(Commands.EDIT_FIND_ALL_AND_SELECT); + menu.addMenuItem(Commands.EDIT_ADD_NEXT_MATCH); + menu.addMenuItem(Commands.EDIT_SKIP_CURRENT_MATCH); menu.addMenuDivider(); menu.addMenuItem(Commands.EDIT_REPLACE); diff --git a/src/dependencies.js b/src/dependencies.js index d9e2c3e0c37..43ef9b82cd0 100644 --- a/src/dependencies.js +++ b/src/dependencies.js @@ -26,7 +26,7 @@ window.setTimeout(function () { "use strict"; - var deps = { "Mustache": window.Mustache, "jQuery": window.$, "CodeMirror": window.CodeMirror, "RequireJS": window.require }; + var deps = { "Mustache": window.Mustache, "jQuery": window.$, "RequireJS": window.require }; var key, missingDeps = []; for (key in deps) { if (deps.hasOwnProperty(key) && !deps[key]) { diff --git a/src/document/Document.js b/src/document/Document.js index 05090bfeec6..3b09da8514f 100644 --- a/src/document/Document.js +++ b/src/document/Document.js @@ -32,7 +32,9 @@ define(function (require, exports, module) { FileUtils = require("file/FileUtils"), InMemoryFile = require("document/InMemoryFile"), PerfUtils = require("utils/PerfUtils"), - LanguageManager = require("language/LanguageManager"); + LanguageManager = require("language/LanguageManager"), + CodeMirror = require("thirdparty/CodeMirror2/lib/codemirror"), + _ = require("thirdparty/lodash"); /** * @constructor @@ -43,13 +45,12 @@ define(function (require, exports, module) { * * change -- When the text of the editor changes (including due to undo/redo). * - * Passes ({Document}, {ChangeList}), where ChangeList is a linked list (NOT an array) + * Passes ({Document}, {ChangeList}), where ChangeList is an array * of change record objects. Each change record looks like: * * { from: start of change, expressed as {line: , ch: }, * to: end of change, expressed as {line: , ch: }, - * text: array of lines of text to replace existing text, - * next: next change record in the linked list, or undefined if this is the last record } + * text: array of lines of text to replace existing text } * * The line and ch offsets are both 0-based. * @@ -62,9 +63,6 @@ define(function (require, exports, module) { * IMPORTANT: If you listen for the "change" event, you MUST also addRef() the document * (and releaseRef() it whenever you stop listening). You should also listen to the "deleted" * event. - * - * (FUTURE: this is a modified version of the raw CodeMirror change event format; may want to make - * it an ordinary array) * * deleted -- When the file for this document has been deleted. All views onto the document should * be closed. The document will no longer be editable or dispatch "change" events. @@ -295,7 +293,7 @@ define(function (require, exports, module) { // TODO: Dumb to split it here just to join it again in the change handler, but this is // the CodeMirror change format. Should we document our change format to allow this to // either be an array of lines or a single string? - $(this).triggerHandler("change", [this, {text: text.split(/\r?\n/)}]); + $(this).triggerHandler("change", [this, [{text: text.split(/\r?\n/)}]]); } this._updateTimestamp(newTimestamp); @@ -449,6 +447,192 @@ define(function (require, exports, module) { }); }; + /** + * Adjusts a given position taking a given replaceRange-type edit into account. + * If the position is within the original edit range (start and end inclusive), + * it gets pushed to the end of the content that replaced the range. Otherwise, + * if it's after the edit, it gets adjusted so it refers to the same character + * it did before the edit. + * @param {!{line:number, ch: number}} pos The position to adjust. + * @param {!Array.} textLines The text of the change, split into an array of lines. + * @param {!{line: number, ch: number}} start The start of the edit. + * @param {!{line: number, ch: number}} end The end of the edit. + * @return {{line: number, ch: number}} The adjusted position. + */ + Document.prototype.adjustPosForChange = function (pos, textLines, start, end) { + // Same as CodeMirror.adjustForChange(), but that's a private function + // and Marijn would rather not expose it publicly. + var change = { text: textLines, from: start, to: end }; + + if (CodeMirror.cmpPos(pos, start) < 0) { + return pos; + } + if (CodeMirror.cmpPos(pos, end) <= 0) { + return CodeMirror.changeEnd(change); + } + + var line = pos.line + change.text.length - (change.to.line - change.from.line) - 1, + ch = pos.ch; + if (pos.line === change.to.line) { + ch += CodeMirror.changeEnd(change).ch - change.to.ch; + } + return {line: line, ch: ch}; + }; + + /** + * Like _.each(), but if given a single item not in an array, acts as + * if it were an array containing just that item. + */ + function oneOrEach(itemOrArr, cb) { + if (Array.isArray(itemOrArr)) { + _.each(itemOrArr, cb); + } else { + cb(itemOrArr, 0); + } + } + + /** + * Helper function for edit operations that operate on multiple selections. Takes an "edit list" + * that specifies a list of replaceRanges that should occur, but where all the positions are with + * respect to the document state before all the edits (i.e., you don't have to figure out how to fix + * up the selections after each sub-edit). Edits must be non-overlapping (in original-document terms). + * All the edits are done in a single batch. + * + * If your edits are structured in such a way that each individual edit would cause its associated + * selection to be properly updated, then all you need to specify are the edits themselves, and the + * selections will automatically be updated as the edits are performed. However, for some + * kinds of edits, you need to fix up the selection afterwards. In that case, you can specify one + * or more selections to be associated with each edit. Those selections are assumed to be in terms + * of the document state after the edit, *as if* that edit were the only one being performed (i.e., + * you don't have to worry about adjusting for the effect of other edits). If you supply these selections, + * then this function will adjust them as necessary for the effects of other edits, and then return a + * flat list of all the selections, suitable for passing to `setSelections()`. + * + * @param {!Array.<{edit: {text: string, start:{line: number, ch: number}, end:?{line: number, ch: number}} + * | Array.<{text: string, start:{line: number, ch: number}, end:?{line: number, ch: number}}>, + * selection: ?{start:{line:number, ch:number}, end:{line:number, ch:number}, + * primary:boolean, reversed: boolean, isBeforeEdit: boolean}>} + * | ?Array.<{start:{line:number, ch:number}, end:{line:number, ch:number}, + * primary:boolean, reversed: boolean, isBeforeEdit: boolean}>}>} edits + * Specifies the list of edits to perform in a manner similar to CodeMirror's `replaceRange`. This array + * will be mutated. + * + * `edit` is the edit to perform: + * `text` will replace the current contents of the range between `start` and `end`. + * If `end` is unspecified, the text is inserted at `start`. + * `start` and `end` should be positions relative to the document *ignoring* all other edit descriptions + * (i.e., as if you were only performing this one edit on the document). + * If any of the edits overlap, an error will be thrown. + * + * If `selection` is specified, it should be a selection associated with this edit. + * If `isBeforeEdit` is set on the selection, the selection will be fixed up for this edit. + * If not, it won't be fixed up for this edit, meaning it should be expressed in terms of + * the document state after this individual edit is performed (ignoring any other edits). + * Note that if you were planning on just specifying `isBeforeEdit` for every selection, you can + * accomplish the same thing by simply not passing any selections and letting the editor update + * the existing selections automatically. + * + * Note that `edit` and `selection` can each be either an individual edit/selection, or a group of + * edits/selections to apply in order. This can be useful if you need to perform multiple edits in a row + * and then specify a resulting selection that shouldn't be fixed up for any of those edits (but should be + * fixed up for edits related to other selections). It can also be useful if you have several selections + * that should ignore the effects of a given edit because you've fixed them up already (this commonly happens + * with line-oriented edits where multiple cursors on the same line should be ignored, but still tracked). + * Within an edit group, edit positions must be specified relative to previous edits within that group. Also, + * the total bounds of edit groups must not overlap (e.g. edits in one group can't surround an edit from another group). + * + * @param {?string} origin An optional edit origin that's passed through to each replaceRange(). + * @return {Array<{start:{line:number, ch:number}, end:{line:number, ch:number}, primary:boolean, reversed: boolean}>} + * The list of passed selections adjusted for the performed edits, if any. + */ + Document.prototype.doMultipleEdits = function (edits, origin) { + var self = this; + + // Sort the edits backwards, so we don't have to adjust the edit positions as we go along + // (though we do have to adjust the selection positions). + edits.sort(function (editDesc1, editDesc2) { + var edit1 = (Array.isArray(editDesc1.edit) ? editDesc1.edit[0] : editDesc1.edit), + edit2 = (Array.isArray(editDesc2.edit) ? editDesc2.edit[0] : editDesc2.edit); + // Treat all no-op edits as if they should happen before all other edits (the order + // doesn't really matter, as long as they sort out of the way of the real edits). + if (!edit1) { + return -1; + } else if (!edit2) { + return 1; + } else { + return CodeMirror.cmpPos(edit2.start, edit1.start); + } + }); + + // Pull out the selections, in the same order as the edits. + var result = _.cloneDeep(_.pluck(edits, "selection")); + + // Preflight the edits to specify "end" if unspecified and make sure they don't overlap. + // (We don't want to do it during the actual edits, since we don't want to apply some of + // the edits before we find out.) + _.each(edits, function (editDesc, index) { + oneOrEach(editDesc.edit, function (edit) { + if (edit) { + if (!edit.end) { + edit.end = edit.start; + } + if (index > 0) { + var prevEditGroup = edits[index - 1].edit; + // The edits are in reverse order, so we want to make sure this edit ends + // before any of the previous ones start. + oneOrEach(prevEditGroup, function (prevEdit) { + if (CodeMirror.cmpPos(edit.end, prevEdit.start) > 0) { + throw new Error("Document.doMultipleEdits(): Overlapping edits specified"); + } + }); + } + } + }); + }); + + // Perform the edits. + this.batchOperation(function () { + _.each(edits, function (editDesc, index) { + // Perform this group of edits. The edit positions are guaranteed to be okay + // since all the previous edits we've done have been later in the document. However, + // we have to fix up any selections that overlap or come after the edit. + oneOrEach(editDesc.edit, function (edit) { + if (edit) { + self.replaceRange(edit.text, edit.start, edit.end, origin); + + // Fix up all the selections *except* the one(s) related to this edit list that + // are not "before-edit" selections. + var textLines = edit.text.split("\n"); + _.each(result, function (selections, selIndex) { + if (selections) { + oneOrEach(selections, function (sel) { + if (sel.isBeforeEdit || selIndex !== index) { + sel.start = self.adjustPosForChange(sel.start, textLines, edit.start, edit.end); + sel.end = self.adjustPosForChange(sel.end, textLines, edit.start, edit.end); + } + }); + } + }); + } + }); + }); + }); + + result = _.chain(result) + .filter(function (item) { + return item !== undefined; + }) + .flatten() + .sort(function (sel1, sel2) { + return CodeMirror.cmpPos(sel1.start, sel2.start); + }) + .value(); + _.each(result, function (item) { + delete item.isBeforeEdit; + }); + return result; + }; + /* (pretty toString(), to aid debugging) */ Document.prototype.toString = function () { var dirtyInfo = (this.isDirty ? " (dirty!)" : " (clean)"); diff --git a/src/document/DocumentCommandHandlers.js b/src/document/DocumentCommandHandlers.js index 62c01f2ac12..553cf9f8b8f 100644 --- a/src/document/DocumentCommandHandlers.js +++ b/src/document/DocumentCommandHandlers.js @@ -714,8 +714,7 @@ define(function (require, exports, module) { var editor = EditorManager.getActiveEditor(); if (editor) { if (settings) { - editor.setCursorPos(settings.cursorPos); - editor.setSelection(settings.selection.start, settings.selection.end); + editor.setSelections(settings.selections); editor.setScrollPos(settings.scrollPos.x, settings.scrollPos.y); } } @@ -828,8 +827,7 @@ define(function (require, exports, module) { if (doc.isUntitled()) { if (doc === activeDoc) { settings = { - selection: activeEditor.getSelection(), - cursorPos: activeEditor.getCursorPos(), + selections: activeEditor.getSelections(), scrollPos: activeEditor.getScrollPos() }; } @@ -918,8 +916,7 @@ define(function (require, exports, module) { if (activeEditor) { doc = activeEditor.document; settings = {}; - settings.selection = activeEditor.getSelection(); - settings.cursorPos = activeEditor.getCursorPos(); + settings.selections = activeEditor.getSelections(); settings.scrollPos = activeEditor.getScrollPos(); } } diff --git a/src/document/TextRange.js b/src/document/TextRange.js index 3acfdb8f849..c465ac5f358 100644 --- a/src/document/TextRange.js +++ b/src/document/TextRange.js @@ -23,7 +23,7 @@ /*jslint vars: true, plusplus: true, devel: true, nomen: true, indent: 4, maxerr: 50 */ -/*global define, $, CodeMirror */ +/*global define, $ */ /** */ @@ -161,10 +161,10 @@ define(function (require, exports, module) { */ TextRange.prototype._applyChangesToRange = function (changeList) { var hasChanged = false, hasContentChanged = false; - var change; - for (change = changeList; change; change = change.next) { + var i; + for (i = 0; i < changeList.length; i++) { // Apply this step of the change list - var result = this._applySingleChangeToRange(change); + var result = this._applySingleChangeToRange(changeList[i]); hasChanged = hasChanged || result.hasChanged; hasContentChanged = hasContentChanged || result.hasContentChanged; diff --git a/src/editor/CSSInlineEditor.js b/src/editor/CSSInlineEditor.js index 6e4fe5b4f59..22bfe1af8f5 100644 --- a/src/editor/CSSInlineEditor.js +++ b/src/editor/CSSInlineEditor.js @@ -23,7 +23,7 @@ /*jslint vars: true, plusplus: true, devel: true, nomen: true, indent: 4, maxerr: 50 */ -/*global define, $, CodeMirror, window, Mustache */ +/*global define, $, window, Mustache */ define(function (require, exports, module) { "use strict"; diff --git a/src/editor/CodeHintManager.js b/src/editor/CodeHintManager.js index efa08c2d17c..a0442a9b00f 100644 --- a/src/editor/CodeHintManager.js +++ b/src/editor/CodeHintManager.js @@ -517,24 +517,29 @@ define(function (require, exports, module) { * @param {Editor} editor * @param {KeyboardEvent} event */ - function _handleKeyEvent(jqEvent, editor, event) { + function _handleKeydownEvent(jqEvent, editor, event) { keyDownEditor = editor; - if (event.type === "keydown") { - if (!(event.ctrlKey || event.altKey || event.metaKey) && - (event.keyCode === KeyEvent.DOM_VK_ENTER || - event.keyCode === KeyEvent.DOM_VK_RETURN || - event.keyCode === KeyEvent.DOM_VK_TAB)) { - lastChar = String.fromCharCode(event.keyCode); - } - } else if (event.type === "keypress") { - // Last inserted character, used later by handleChange - lastChar = String.fromCharCode(event.charCode); - - // Pending Text is used in hintList._keydownHook() - if (hintList) { - hintList.addPendingText(lastChar); - } - } else if (event.type === "keyup" && _inSession(editor)) { + if (!(event.ctrlKey || event.altKey || event.metaKey) && + (event.keyCode === KeyEvent.DOM_VK_ENTER || + event.keyCode === KeyEvent.DOM_VK_RETURN || + event.keyCode === KeyEvent.DOM_VK_TAB)) { + lastChar = String.fromCharCode(event.keyCode); + } + } + function _handleKeypressEvent(jqEvent, editor, event) { + keyDownEditor = editor; + + // Last inserted character, used later by handleChange + lastChar = String.fromCharCode(event.charCode); + + // Pending Text is used in hintList._keydownHook() + if (hintList) { + hintList.addPendingText(lastChar); + } + } + function _handleKeyupEvent(jqEvent, editor, event) { + keyDownEditor = editor; + if (_inSession(editor)) { if (event.keyCode === KeyEvent.DOM_VK_HOME || event.keyCode === KeyEvent.DOM_VK_END) { _endSession(); } else if (event.keyCode === KeyEvent.DOM_VK_LEFT || @@ -577,9 +582,9 @@ define(function (require, exports, module) { } // Pending Text is used in hintList._keydownHook() - if (hintList && changeList.text.length && changeList.text[0].length) { - var expectedLength = editor.getCursorPos().ch - changeList.from.ch, - newText = changeList.text[0]; + if (hintList && changeList[0] && changeList[0].text.length && changeList[0].text[0].length) { + var expectedLength = editor.getCursorPos().ch - changeList[0].from.ch, + newText = changeList[0].text[0]; // We may get extra text in newText since some features like auto // close braces can append some text automatically. // See https://github.com/adobe/brackets/issues/6345#issuecomment-32548064 @@ -624,13 +629,17 @@ define(function (require, exports, module) { function activeEditorChangeHandler(event, current, previous) { if (current) { $(current).on("editorChange", _handleChange); - $(current).on("keyEvent", _handleKeyEvent); + $(current).on("keydown", _handleKeydownEvent); + $(current).on("keypress", _handleKeypressEvent); + $(current).on("keyup", _handleKeyupEvent); } if (previous) { //Removing all old Handlers $(previous).off("editorChange", _handleChange); - $(previous).off("keyEvent", _handleKeyEvent); + $(previous).off("keydown", _handleKeydownEvent); + $(previous).off("keypress", _handleKeypressEvent); + $(previous).off("keyup", _handleKeyupEvent); } } diff --git a/src/editor/Editor.js b/src/editor/Editor.js index bd8f8ba6d0d..c1fdd9f1694 100644 --- a/src/editor/Editor.js +++ b/src/editor/Editor.js @@ -23,7 +23,7 @@ /*jslint vars: true, plusplus: true, devel: true, nomen: true, indent: 4, maxerr: 50 */ -/*global define, $, CodeMirror, window */ +/*global define, $, window */ /** * Editor is a 1-to-1 wrapper for a CodeMirror editor instance. It layers on Brackets-specific @@ -64,7 +64,8 @@ define(function (require, exports, module) { "use strict"; - var Menus = require("command/Menus"), + var CodeMirror = require("thirdparty/CodeMirror2/lib/codemirror"), + Menus = require("command/Menus"), PerfUtils = require("utils/PerfUtils"), PreferencesManager = require("preferences/PreferencesManager"), Strings = require("strings"), @@ -122,163 +123,12 @@ define(function (require, exports, module) { /** * @private - * Handle Tab key press. - * @param {!CodeMirror} instance CodeMirror instance. - */ - function _handleTabKey(instance) { - // Tab key handling is done as follows: - // 1. If the selection is before any text and the indentation is to the left of - // the proper indentation then indent it to the proper place. Otherwise, - // add another tab. In either case, move the insertion point to the - // beginning of the text. - // 2. If the selection is multi-line, indent all the lines. - // 3. If the selection is after the first non-space character, and is an - // insertion point, insert a tab character or the appropriate number - // of spaces to pad to the nearest tab boundary. - var from = instance.getCursor(true), - to = instance.getCursor(false), - line = instance.getLine(from.line), - indentAuto = false, - insertTab = false; - - if (from.line === to.line) { - if (line.search(/\S/) > to.ch || to.ch === 0) { - indentAuto = true; - } - } - - if (indentAuto) { - var currentLength = line.length; - CodeMirror.commands.indentAuto(instance); - - // If the amount of whitespace and the cursor position didn't change, we must have - // already been at the correct indentation level as far as CM is concerned, so insert - // another tab. - if (instance.getLine(from.line).length === currentLength) { - var newFrom = instance.getCursor(true), - newTo = instance.getCursor(false); - if (newFrom.line === from.line && newFrom.ch === from.ch && - newTo.line === to.line && newTo.ch === to.ch) { - insertTab = true; - to.ch = 0; - } - } - } else if (instance.somethingSelected() && from.line !== to.line) { - CodeMirror.commands.indentMore(instance); - } else { - insertTab = true; - } - - if (insertTab) { - if (instance.getOption("indentWithTabs")) { - CodeMirror.commands.insertTab(instance); - } else { - var i, ins = "", numSpaces = instance.getOption("indentUnit"); - numSpaces -= from.ch % numSpaces; - for (i = 0; i < numSpaces; i++) { - ins += " "; - } - instance.replaceSelection(ins, "end"); - } - } - } - - /** - * @private - * Handle left arrow, right arrow, backspace and delete keys when soft tabs are used. - * @param {!CodeMirror} instance CodeMirror instance - * @param {number} direction Direction of movement: 1 for forward, -1 for backward - * @param {function} functionName name of the CodeMirror function to call - * @return {boolean} true if key was handled - */ - function _handleSoftTabNavigation(instance, direction, functionName) { - var handled = false; - if (!instance.getOption("indentWithTabs")) { - var indentUnit = instance.getOption("indentUnit"), - cursor = instance.getCursor(), - jump = cursor.ch % indentUnit, - line = instance.getLine(cursor.line); - - if (direction === 1) { - jump = indentUnit - jump; - - if (cursor.ch + jump > line.length) { // Jump would go beyond current line - return false; - } - - if (line.substr(cursor.ch, jump).search(/\S/) === -1) { - instance[functionName](jump, "char"); - handled = true; - } - } else { - // Quick exit if we are at the beginning of the line - if (cursor.ch === 0) { - return false; - } - - // If we are on the tab boundary, jump by the full amount, - // but not beyond the start of the line. - if (jump === 0) { - jump = indentUnit; - } - - // Search backwards to the first non-space character - var offset = line.substr(cursor.ch - jump, jump).search(/\s*$/g); - - if (offset !== -1) { // Adjust to jump to first non-space character - jump -= offset; - } - - if (jump > 0) { - instance[functionName](-jump, "char"); - handled = true; - } - } - } - - return handled; - } - - /** - * Checks if the user just typed a closing brace/bracket/paren, and considers automatically - * back-indenting it if so. - */ - function _checkElectricChars(jqEvent, editor, event) { - var instance = editor._codeMirror; - if (event.type === "keypress") { - var keyStr = String.fromCharCode(event.which || event.keyCode); - if (/[\]\{\}\)]/.test(keyStr)) { - // If all text before the cursor is whitespace, auto-indent it - var cursor = instance.getCursor(); - var lineStr = instance.getLine(cursor.line); - var nonWS = lineStr.search(/\S/); - - if (nonWS === -1 || nonWS >= cursor.ch) { - // Need to do the auto-indent on a timeout to ensure - // the keypress is handled before auto-indenting. - // This is the same timeout value used by the - // electricChars feature in CodeMirror. - window.setTimeout(function () { - instance.indentLine(cursor.line); - }, 75); - } - } - } - } - - /** - * @private - * Handle any cursor movement in editor, including selecting and unselecting text. - * @param {jQueryObject} jqEvent jQuery event object - * @param {Editor} editor Current, focused editor (main or inline) - * @param {!Event} event + * Create a copy of the given CodeMirror position + * @param {!CodeMirror.Pos} pos + * @return {CodeMirror.Pos} */ - function _handleCursorActivity(jqEvent, editor, event) { - editor._updateStyleActiveLine(); - } - - function _handleKeyEvents(jqEvent, editor, event) { - _checkElectricChars(jqEvent, editor, event); + function _copyPos(pos) { + return new CodeMirror.Pos(pos.line, pos.ch); } /** @@ -299,7 +149,6 @@ define(function (require, exports, module) { */ var _instances = []; - /** * @constructor * @@ -347,31 +196,27 @@ define(function (require, exports, module) { // Editor supplies some standard keyboard behavior extensions of its own var codeMirrorKeyMap = { - "Tab": _handleTabKey, + "Tab": function () { self._handleTabKey(); }, "Shift-Tab": "indentLess", "Left": function (instance) { - if (!_handleSoftTabNavigation(instance, -1, "moveH")) { - CodeMirror.commands.goCharLeft(instance); - } + self._handleSoftTabNavigation(-1, "moveH"); }, "Right": function (instance) { - if (!_handleSoftTabNavigation(instance, 1, "moveH")) { - CodeMirror.commands.goCharRight(instance); - } + self._handleSoftTabNavigation(1, "moveH"); }, "Backspace": function (instance) { - if (!_handleSoftTabNavigation(instance, -1, "deleteH")) { - CodeMirror.commands.delCharBefore(instance); - } + self._handleSoftTabNavigation(-1, "deleteH"); }, "Delete": function (instance) { - if (!_handleSoftTabNavigation(instance, 1, "deleteH")) { - CodeMirror.commands.delCharAfter(instance); - } + self._handleSoftTabNavigation(1, "deleteH"); }, "Esc": function (instance) { - self.removeAllInlineWidgets(); + if (self.getSelections().length > 1) { + CodeMirror.commands.singleSelection(instance); + } else { + self.removeAllInlineWidgets(); + } }, "Cmd-Left": "goLineStartSmart" }; @@ -411,10 +256,15 @@ define(function (require, exports, module) { this._installEditorListeners(); - $(this) - .on("cursorActivity", _handleCursorActivity) - .on("keyEvent", _handleKeyEvents) - .on("change", this._handleEditorChange.bind(this)); + $(this).on("cursorActivity", function (jqEvent, editor) { + self._handleCursorActivity(jqEvent); + }); + $(this).on("keypress", function (jqEvent, editor, cmEvent) { + self._handleKeypressEvents(cmEvent); + }); + $(this).on("change", function (jqEvent, editor, changeList) { + self._handleEditorChange(changeList); + }); // Set code-coloring mode BEFORE populating with text, to avoid a flash of uncolored text this._codeMirror.setOption("mode", mode); @@ -478,6 +328,253 @@ define(function (require, exports, module) { }); }; + /** + * @private + * Checks if the user just typed a closing brace/bracket/paren, and considers automatically + * back-indenting it if so. + */ + Editor.prototype._checkElectricChars = function (event) { + var instance = this._codeMirror, + keyStr = String.fromCharCode(event.which || event.keyCode); + + if (/[\]\{\}\)]/.test(keyStr)) { + // If all text before the cursor is whitespace, auto-indent it + var cursor = this.getCursorPos(); + var lineStr = instance.getLine(cursor.line); + var nonWS = lineStr.search(/\S/); + + if (nonWS === -1 || nonWS >= cursor.ch) { + // Need to do the auto-indent on a timeout to ensure + // the keypress is handled before auto-indenting. + // This is the same timeout value used by the + // electricChars feature in CodeMirror. + window.setTimeout(function () { + instance.indentLine(cursor.line); + }, 75); + } + } + }; + + /** + * @private + * Handle any cursor movement in editor, including selecting and unselecting text. + * @param {!Event} event + */ + Editor.prototype._handleCursorActivity = function (event) { + this._updateStyleActiveLine(); + }; + + /** + * @private + * Handle CodeMirror key events. + * @param {!Event} event + */ + Editor.prototype._handleKeypressEvents = function (event) { + this._checkElectricChars(event); + }; + + /** + * @private + * Helper function for `_handleTabKey()` (case 2) - see comment in that function. + * @param {Array.<{start:{line:number, ch:number}, end:{line:number, ch:number}, reversed:boolean, primary:boolean}>} selections + * The selections to indent. + */ + Editor.prototype._addIndentAtEachSelection = function (selections) { + var instance = this._codeMirror, + usingTabs = instance.getOption("indentWithTabs"), + indentUnit = instance.getOption("indentUnit"), + edits = []; + + _.each(selections, function (sel) { + var indentStr = "", i, numSpaces; + if (usingTabs) { + indentStr = "\t"; + } else { + numSpaces = indentUnit - (sel.start.ch % indentUnit); + for (i = 0; i < numSpaces; i++) { + indentStr += " "; + } + } + edits.push({edit: {text: indentStr, start: sel.start}}); + }); + + this.document.doMultipleEdits(edits); + }; + + /** + * @private + * Helper function for `_handleTabKey()` (case 3) - see comment in that function. + * @param {Array.<{start:{line:number, ch:number}, end:{line:number, ch:number}, reversed:boolean, primary:boolean}>} selections + * The selections to indent. + */ + Editor.prototype._autoIndentEachSelection = function (selections) { + // Capture all the line lengths, so we can tell if anything changed. + // Note that this function should only be called if all selections are within a single line. + var instance = this._codeMirror, + lineLengths = {}; + _.each(selections, function (sel) { + lineLengths[sel.start.line] = instance.getLine(sel.start.line).length; + }); + + // First, try to do a smart indent on all selections. + CodeMirror.commands.indentAuto(instance); + + // If there were no code or selection changes, then indent each selection one more indent. + var changed = false, + newSelections = this.getSelections(); + if (newSelections.length === selections.length) { + _.each(selections, function (sel, index) { + var newSel = newSelections[index]; + if (CodeMirror.cmpPos(sel.start, newSel.start) !== 0 || + CodeMirror.cmpPos(sel.end, newSel.end) !== 0 || + instance.getLine(sel.start.line).length !== lineLengths[sel.start.line]) { + changed = true; + // Bail - we don't need to look any further once we've found a change. + return false; + } + }); + } else { + changed = true; + } + + if (!changed) { + CodeMirror.commands.indentMore(instance); + } + }; + + /** + * @private + * Handle Tab key press. + * @param {!CodeMirror} instance CodeMirror instance. + */ + Editor.prototype._handleTabKey = function () { + // Tab key handling is done as follows: + // 1. If any of the selections are multiline, just add one indent level to the + // beginning of all lines that intersect any selection. + // 2. Otherwise, if any of the selections is a cursor or single-line range that + // ends at or after the first non-whitespace character in a line: + // - if indentation is set to tabs, just insert a hard tab before each selection. + // - if indentation is set to spaces, insert the appropriate number of spaces before + // each selection to get to its next soft tab stop. + // 3. Otherwise (all selections are cursors or single-line, and are in the whitespace + // before their respective lines), try to autoindent each line based on the mode. + // If none of the cursors moved and no space was added, then add one indent level + // to the beginning of all lines. + + // Note that in case 2, we do the "dumb" insertion even if the cursor is immediately + // before the first non-whitespace character in a line. It might seem more convenient + // to do autoindent in that case. However, the problem is if that line is already + // indented past its "proper" location. In that case, we don't want Tab to + // *outdent* the line. If we had more control over the autoindent algorithm or + // implemented it ourselves, we could handle that case separately. + + var instance = this._codeMirror, + selectionType = "indentAuto", + selections = this.getSelections(); + + _.each(selections, function (sel) { + if (sel.start.line !== sel.end.line) { + // Case 1 - we found a multiline selection. We can bail as soon as we find one of these. + selectionType = "indentAtBeginning"; + return false; + } else if (sel.end.ch > 0 && sel.end.ch >= instance.getLine(sel.end.line).search(/\S/)) { + // Case 2 - we found a selection that ends at or after the first non-whitespace + // character on the line. We need to keep looking in case we find a later multiline + // selection though. + selectionType = "indentAtSelection"; + } + }); + + switch (selectionType) { + case "indentAtBeginning": + // Case 1 + CodeMirror.commands.indentMore(instance); + break; + + case "indentAtSelection": + // Case 2 + this._addIndentAtEachSelection(selections); + break; + + case "indentAuto": + // Case 3 + this._autoIndentEachSelection(selections); + break; + } + }; + + /** + * @private + * Handle left arrow, right arrow, backspace and delete keys when soft tabs are used. + * @param {number} direction Direction of movement: 1 for forward, -1 for backward + * @param {string} functionName name of the CodeMirror function to call if we handle the key + */ + Editor.prototype._handleSoftTabNavigation = function (direction, functionName) { + var instance = this._codeMirror, + overallJump = null; + + if (!instance.getOption("indentWithTabs")) { + var indentUnit = instance.getOption("indentUnit"); + + _.each(this.getSelections(), function (sel) { + if (CodeMirror.cmpPos(sel.start, sel.end) !== 0) { + // This is a range - it will just collapse/be deleted regardless of the jump we set, so + // we can just ignore it and continue. (We don't want to return false in this case since + // we want to keep looking at other ranges.) + return; + } + + var cursor = sel.start, + jump = cursor.ch % indentUnit, + line = instance.getLine(cursor.line); + + // Don't do any soft tab handling if there are non-whitespace characters before the cursor in + // any of the selections. + if (line.substr(0, cursor.ch).search(/\S/) !== -1) { + jump = null; + } else if (direction === 1) { // right + jump = indentUnit - jump; + + // Don't jump if it would take us past the end of the line, or if there are + // non-whitespace characters within the jump distance. + if (cursor.ch + jump > line.length || line.substr(cursor.ch, jump).search(/\S/) !== -1) { + jump = null; + } + } else { // left + // If we are on the tab boundary, jump by the full amount, + // but not beyond the start of the line. + if (jump === 0) { + jump = indentUnit; + } + if (cursor.ch - jump < 0) { + jump = null; + } else { + // We're moving left, so negate the jump. + jump = -jump; + } + } + + // Did we calculate a jump, and is this jump value either the first one or + // consistent with all the other jumps? If so, we're good. Otherwise, bail + // out of the foreach, since as soon as we hit an inconsistent jump we don't + // have to look any further. + if (jump !== null && + (overallJump === null || overallJump === jump)) { + overallJump = jump; + } else { + overallJump = null; + return false; + } + }); + } + + if (overallJump === null) { + // Just do the default move, which is one char in the given direction. + overallJump = direction; + } + instance[functionName](overallJump, "char"); + }; + /** * Determine the mode to use from the document's language * Uses "text/plain" if the language does not define a mode @@ -545,8 +642,9 @@ define(function (require, exports, module) { // Apply text changes to CodeMirror editor var cm = this._codeMirror; cm.operation(function () { - var change, newText; - for (change = changeList; change; change = change.next) { + var change, newText, i; + for (i = 0; i < changeList.length; i++) { + change = changeList[i]; newText = change.text.join('\n'); if (!change.from || !change.to) { if (change.from || change.to) { @@ -572,7 +670,7 @@ define(function (require, exports, module) { * - if we're a secondary editor, editor changes should be ignored if they were caused by us reacting * to a Document change */ - Editor.prototype._handleEditorChange = function (event, editor, changeList) { + Editor.prototype._handleEditorChange = function (changeList) { // we're currently syncing from the Document, so don't echo back TO the Document if (this._duringSync) { return; @@ -664,20 +762,22 @@ define(function (require, exports, module) { Editor.prototype._installEditorListeners = function () { var self = this; - // onKeyEvent is an option in CodeMirror rather than an event--it's a - // low-level hook for all keyboard events rather than a specific event. For - // our purposes, though, it's convenient to treat it as an event internally, - // so we bridge it to jQuery events the same way we do ordinary CodeMirror - // events. - this._codeMirror.setOption("onKeyEvent", function (instance, event) { - $(self).triggerHandler("keyEvent", [self, event]); + // Redispatch these CodeMirror key events as jQuery events + function _onKeyEvent(instance, event) { + $(self).triggerHandler("keyEvent", [self, event]); // deprecated + $(self).triggerHandler(event.type, [self, event]); return event.defaultPrevented; // false tells CodeMirror we didn't eat the event - }); + } + this._codeMirror.on("keydown", _onKeyEvent); + this._codeMirror.on("keypress", _onKeyEvent); + this._codeMirror.on("keyup", _onKeyEvent); // FUTURE: if this list grows longer, consider making this a more generic mapping // NOTE: change is a "private" event--others shouldn't listen to it on Editor, only on // Document - this._codeMirror.on("change", function (instance, changeList) { + // Also, note that we use the new "changes" event in v4, which provides an array of + // change objects. Our own event is still called just "change". + this._codeMirror.on("changes", function (instance, changeList) { $(self).triggerHandler("change", [self, changeList]); }); this._codeMirror.on("beforeChange", function (instance, changeObj) { @@ -742,16 +842,27 @@ define(function (require, exports, module) { PerfUtils.addMeasurement(perfTimerName); }; - /** - * Gets the current cursor position within the editor. If there is a selection, returns whichever - * end of the range the cursor lies at. + * Gets the current cursor position within the editor. * @param {boolean} expandTabs If true, return the actual visual column number instead of the character offset in * the "ch" property. + * @param {?string} which Optional string indicating which end of the + * selection to return. It may be "start", "end", "head" (the side of the + * selection that moves when you press shift+arrow), or "anchor" (the + * fixed side of the selection). Omitting the argument is the same as + * passing "head". A {line, ch} object will be returned.) * @return !{line:number, ch:number} */ - Editor.prototype.getCursorPos = function (expandTabs) { - var cursor = this._codeMirror.getCursor(); + Editor.prototype.getCursorPos = function (expandTabs, which) { + // Translate "start" and "end" to the official CM names (it actually + // supports them as-is, but that isn't documented and we don't want to + // rely on it). + if (which === "start") { + which = "from"; + } else if (which === "end") { + which = "to"; + } + var cursor = _copyPos(this._codeMirror.getCursor(which)); if (expandTabs) { cursor.ch = this.getColOffset(cursor); @@ -817,7 +928,7 @@ define(function (require, exports, module) { * * This does not alter the horizontal scroll position. * - * @param {number} centerOptions Option value, or 0 for no options. + * @param {number} centerOptions Option value, or 0 for no options; one of the BOUNDARY_* constants above. */ Editor.prototype.centerOnCursor = function (centerOptions) { var $scrollerElement = $(this.getScrollerElement()); @@ -888,37 +999,163 @@ define(function (require, exports, module) { }; /** - * Gets the current selection. Start is inclusive, end is exclusive. If there is no selection, + * @private + * Takes an anchor/head pair and returns a start/end pair where the start is guaranteed to be <= end, and a "reversed" flag indicating + * if the head is before the anchor. + * @param {!{line: number, ch: number}} anchorPos + * @param {!{line: number, ch: number}} headPos + * @return {!{start:{line:number, ch:number}, end:{line:number, ch:number}}, reversed:boolean} the normalized range with start <= end + */ + function _normalizeRange(anchorPos, headPos) { + if (headPos.line < anchorPos.line || (headPos.line === anchorPos.line && headPos.ch < anchorPos.ch)) { + return {start: _copyPos(headPos), end: _copyPos(anchorPos), reversed: true}; + } else { + return {start: _copyPos(anchorPos), end: _copyPos(headPos), reversed: false}; + } + } + + /** + * Gets the current selection; if there is more than one selection, returns the primary selection + * (generally the last one made). Start is inclusive, end is exclusive. If there is no selection, * returns the current cursor position as both the start and end of the range (i.e. a selection - * of length zero). - * @return {!{start:{line:number, ch:number}, end:{line:number, ch:number}}} + * of length zero). If `reversed` is set, then the head of the selection (the end of the selection + * that would be changed if the user extended the selection) is before the anchor. + * @return {!{start:{line:number, ch:number}, end:{line:number, ch:number}}, reversed:boolean} */ Editor.prototype.getSelection = function () { - var selStart = this._codeMirror.getCursor(true), - selEnd = this._codeMirror.getCursor(false); - return { start: selStart, end: selEnd }; + return _normalizeRange(this.getCursorPos(false, "anchor"), this.getCursorPos(false, "head")); + }; + + /** + * Returns an array of current selections, nonoverlapping and sorted in document order. + * Each selection is a start/end pair, with the start guaranteed to come before the end. + * Cursors are represented as a range whose start is equal to the end. + * If `reversed` is set, then the head of the selection + * (the end of the selection that would be changed if the user extended the selection) + * is before the anchor. + * If `primary` is set, then that selection is the primary selection. + * @return {Array.<{start:{line:number, ch:number}, end:{line:number, ch:number}, reversed:boolean, primary:boolean}>} + */ + Editor.prototype.getSelections = function () { + var primarySel = this.getSelection(); + return _.map(this._codeMirror.listSelections(), function (sel) { + var result = _normalizeRange(sel.anchor, sel.head); + if (result.start.line === primarySel.start.line && result.start.ch === primarySel.start.ch && + result.end.line === primarySel.end.line && result.end.ch === primarySel.end.ch) { + result.primary = true; + } else { + result.primary = false; + } + return result; + }); + }; + + /** + * Takes the given selections, and expands each selection so it encompasses whole lines. Merges + * adjacent line selections together. Keeps track of each original selection associated with a given + * line selection (there might be multiple if individual selections were merged into a single line selection). + * Useful for doing multiple-selection-aware line edits. + * + * @param {Array.<{start:{line:number, ch:number}, end:{line:number, ch:number}, reversed:boolean, primary:boolean}>} selections + * The selections to expand. + * @param {{expandEndAtStartOfLine: boolean, mergeAdjacent: boolean}} options + * expandEndAtStartOfLine: true if a range selection that ends at the beginning of a line should be expanded + * to encompass the line. Default false. + * mergeAdjacent: true if adjacent line ranges should be merged. Default true. + * @return {Array.<{selectionForEdit: {start:{line:number, ch:number}, end:{line:number, ch:number}, reversed:boolean, primary:boolean}, + * selectionsToTrack: Array.<{start:{line:number, ch:number}, end:{line:number, ch:number}, reversed:boolean, primary:boolean}>}>} + * The combined line selections. For each selection, `selectionForEdit` is the line selection, and `selectionsToTrack` is + * the set of original selections that combined to make up the given line selection. Note that the selectionsToTrack will + * include the original objects passed in `selections`, so if it is later mutated the original passed-in selections will be + * mutated as well. + */ + Editor.prototype.convertToLineSelections = function (selections, options) { + var self = this; + options = options || {}; + _.defaults(options, { expandEndAtStartOfLine: false, mergeAdjacent: true }); + + // Combine adjacent lines with selections so they don't collide with each other, as they would + // if we did them individually. + var combinedSelections = [], prevSel; + _.each(selections, function (sel) { + var newSel = _.cloneDeep(sel); + + // Adjust selection to encompass whole lines. + newSel.start.ch = 0; + // The end of the selection becomes the start of the next line, if it isn't already + // or if expandEndAtStartOfLine is set. + var hasSelection = (newSel.start.line !== newSel.end.line) || (newSel.start.ch !== newSel.end.ch); + if (options.expandEndAtStartOfLine || !hasSelection || newSel.end.ch !== 0) { + newSel.end = {line: newSel.end.line + 1, ch: 0}; + } + + // If the start of the new selection is within the range of the previous (expanded) selection, merge + // the two selections together, but keep track of all the original selections that were related to this + // selection, so they can be properly adjusted. (We only have to check for the start being inside the previous + // range - it can't be before it because the selections started out sorted.) + if (prevSel && self.posWithinRange(newSel.start, prevSel.selectionForEdit.start, prevSel.selectionForEdit.end, options.mergeAdjacent)) { + prevSel.selectionForEdit.end.line = newSel.end.line; + prevSel.selectionsToTrack.push(sel); + } else { + prevSel = {selectionForEdit: newSel, selectionsToTrack: [sel]}; + combinedSelections.push(prevSel); + } + }); + return combinedSelections; }; /** - * @return {!string} The currently selected text, or "" if no selection. Includes \n if the - * selection spans multiple lines (does NOT reflect the Document's line-endings style). + * Returns the currently selected text, or "" if no selection. Includes \n if the + * selection spans multiple lines (does NOT reflect the Document's line-endings style). By + * default, returns only the contents of the primary selection, unless `allSelections` is true. + * @param {boolean=} allSelections Whether to return the contents of all selections (separated + * by newlines) instead of just the primary selection. Default false. + * @return {!string} The selected text. */ - Editor.prototype.getSelectedText = function () { - return this._codeMirror.getSelection(); + Editor.prototype.getSelectedText = function (allSelections) { + if (allSelections) { + return this._codeMirror.getSelection(); + } else { + var sel = this.getSelection(); + return this.document.getRange(sel.start, sel.end); + } }; /** * Sets the current selection. Start is inclusive, end is exclusive. Places the cursor at the - * end of the selection range. Optionally centers the around the cursor after + * end of the selection range. Optionally centers around the cursor after * making the selection * * @param {!{line:number, ch:number}} start - * @param {!{line:number, ch:number}} end + * @param {{line:number, ch:number}=} end If not specified, defaults to start. * @param {boolean} center true to center the viewport - * @param {number} centerOptions Option value, or 0 for no options. + * @param {number} centerOptions Option value, or 0 for no options; one of the BOUNDARY_* constants above. */ Editor.prototype.setSelection = function (start, end, center, centerOptions) { - this._codeMirror.setSelection(start, end); + this.setSelections([{start: start, end: end || start}], center, centerOptions); + }; + + /** + * Sets a multiple selection, with the "primary" selection (the one returned by + * getSelection() and getCursorPos()) defaulting to the last if not specified. + * Overlapping ranges will be automatically merged, and the selection will be sorted. + * Optionally centers around the primary selection after making the selection. + * @param {!Array<{start:{line:number, ch:number}, end:{line:number, ch:number}, primary:boolean, reversed: boolean}>} selections + * The selection ranges to set. If the start and end of a range are the same, treated as a cursor. + * If reversed is true, set the anchor of the range to the end instead of the start. + * If primary is true, this is the primary selection. Behavior is undefined if more than + * one selection has primary set to true. If none has primary set to true, the last one is primary. + * @param {boolean} center true to center the viewport around the primary selection. + * @param {number} centerOptions Option value, or 0 for no options; one of the BOUNDARY_* constants above. + */ + Editor.prototype.setSelections = function (selections, center, centerOptions) { + var primIndex; + this._codeMirror.setSelections(_.map(selections, function (sel, index) { + if (sel.primary) { + primIndex = index; + } + return { anchor: sel.reversed ? sel.end : sel.start, head: sel.reversed ? sel.start : sel.end }; + }), primIndex); if (center) { this.centerOnCursor(centerOptions); } @@ -1434,34 +1671,72 @@ define(function (require, exports, module) { }; /** - * Gets the syntax-highlighting mode for the current selection or cursor position. (The mode may - * vary within one file due to embedded languages, e.g. JS embedded in an HTML script block). - * + * Gets the syntax-highlighting mode for the given range. * Returns null if the mode at the start of the selection differs from the mode at the end - * an *approximation* of whether the mode is consistent across the whole range (a pattern like * A-B-A would return A as the mode, not null). * + * @param {!{line: number, ch: number}} start The start of the range to check. + * @param {!{line: number, ch: number}} end The end of the range to check. + * @return {?(Object|string)} Name of syntax-highlighting mode, or object containing a "name" property + * naming the mode along with configuration options required by the mode. + * See {@link LanguageManager#getLanguageForPath()} and {@link Language#getMode()}. + */ + Editor.prototype.getModeForRange = function (start, end) { + var startMode = TokenUtils.getModeAt(this._codeMirror, start), + endMode = TokenUtils.getModeAt(this._codeMirror, end); + if (!startMode || !endMode || startMode.name !== endMode.name) { + return null; + } else { + return startMode; + } + }; + + /** + * Gets the syntax-highlighting mode for the current selection or cursor position. (The mode may + * vary within one file due to embedded languages, e.g. JS embedded in an HTML script block). See + * `getModeForRange()` for how this is determined for a single selection. + * + * If there are multiple selections, this will return a mode only if all the selections are individually + * consistent and resolve to the same mode. + * * @return {?(Object|string)} Name of syntax-highlighting mode, or object containing a "name" property * naming the mode along with configuration options required by the mode. * See {@link LanguageManager#getLanguageForPath()} and {@link Language#getMode()}. */ Editor.prototype.getModeForSelection = function () { // Check for mixed mode info - var sel = this.getSelection(), + var self = this, + sels = this.getSelections(), + primarySel = this.getSelection(), outerMode = this._codeMirror.getMode(), - startMode = TokenUtils.getModeAt(this._codeMirror, sel.start), + startMode = TokenUtils.getModeAt(this._codeMirror, primarySel.start), isMixed = (outerMode.name !== startMode.name); if (isMixed) { - // If mixed mode, check that mode is the same at start & end of selection - if (sel.start.line !== sel.end.line || sel.start.ch !== sel.end.ch) { - var endMode = TokenUtils.getModeAt(this._codeMirror, sel.end); + // Shortcut the first check to avoid getModeAt(), which can be expensive + if (primarySel.start.line !== primarySel.end.line || primarySel.start.ch !== primarySel.end.ch) { + var endMode = TokenUtils.getModeAt(this._codeMirror, primarySel.end); if (startMode.name !== endMode.name) { return null; } } + // If mixed mode, check that mode is the same at start & end of each selection + var hasMixedSel = _.some(sels, function (sel) { + if (sels === primarySel) { + // We already checked this before, so we know it's not mixed. + return false; + } + + var rangeMode = self.getModeForRange(sel.start, sel.end); + return (!rangeMode || rangeMode.name !== startMode.name); + }); + if (hasMixedSel) { + return null; + } + return startMode.name; } else { // Mode does not vary: just use the editor-wide mode diff --git a/src/editor/EditorCommandHandlers.js b/src/editor/EditorCommandHandlers.js index 1be80143d7b..6c9c84aca52 100644 --- a/src/editor/EditorCommandHandlers.js +++ b/src/editor/EditorCommandHandlers.js @@ -21,7 +21,7 @@ * */ -/*jslint vars: true, plusplus: true, devel: true, browser: true, nomen: true, indent: 4, maxerr: 50 */ +/*jslint vars: true, plusplus: true, devel: true, nomen: true, indent: 4, maxerr: 50 */ /*global define, $ */ @@ -37,15 +37,16 @@ define(function (require, exports, module) { CommandManager = require("command/CommandManager"), EditorManager = require("editor/EditorManager"), StringUtils = require("utils/StringUtils"), - TokenUtils = require("utils/TokenUtils"); + TokenUtils = require("utils/TokenUtils"), + CodeMirror = require("thirdparty/CodeMirror2/lib/codemirror"), + _ = require("thirdparty/lodash"); /** * List of constants */ var DIRECTION_UP = -1; var DIRECTION_DOWN = +1; - - + /** * @private * Creates regular expressions for multiple line comment prefixes @@ -119,8 +120,10 @@ define(function (require, exports, module) { } /** - * Add or remove line-comment tokens to all the lines in the selected range, preserving selection - * and cursor position. Applies to currently focused Editor. + * @private + * Generates an edit that adds or removes line-comment tokens to all the lines in the selected range, + * preserving selection and cursor position. Applies to currently focused Editor. The given selection + * must already be a line selection in the form returned by `Editor.convertToLineSelections()`. * * If all non-whitespace lines are already commented out, then we uncomment; otherwise we comment * out. Commenting out adds the prefix at column 0 of every line. Uncommenting removes the first prefix @@ -128,22 +131,30 @@ define(function (require, exports, module) { * * @param {!Editor} editor * @param {!Array.} prefixes, e.g. ["//"] + * @param {!Editor} editor The editor to edit within. + * @param {!{selectionForEdit: {start:{line:number, ch:number}, end:{line:number, ch:number}, reversed:boolean, primary:boolean}, + * selectionsToTrack: Array.<{start:{line:number, ch:number}, end:{line:number, ch:number}, reversed:boolean, primary:boolean}>}} + * lineSel A line selection as returned from `Editor.convertToLineSelections()`. `selectionForEdit` is the selection to perform + * the line comment operation on, and `selectionsToTrack` are a set of selections associated with this line that need to be + * tracked through the edit. + * @return {{edit: {text: string, start:{line: number, ch: number}, end:?{line: number, ch: number}}|Array.<{text: string, start:{line: number, ch: number}, end:?{line: number, ch: number}}>, + * selection: {start:{line:number, ch:number}, end:{line:number, ch:number}, primary:boolean, reversed: boolean, isBeforeEdit: boolean}>}|Array.<{start:{line:number, ch:number}, end:{line:number, ch:number}, primary:boolean, reversed: boolean, isBeforeEdit: boolean}>}} + * An edit description suitable for including in the edits array passed to `Document.doMultipleEdits()`. */ - function lineCommentPrefix(editor, prefixes) { - var doc = editor.document, - sel = editor.getSelection(), - startLine = sel.start.line, - endLine = sel.end.line, - lineExp = _createLineExpressions(prefixes); - - // Is a range of text selected? (vs just an insertion pt) - var hasSelection = (startLine !== endLine) || (sel.start.ch !== sel.end.ch); - + function _getLineCommentPrefixEdit(editor, prefixes, lineSel) { + var doc = editor.document, + sel = lineSel.selectionForEdit, + trackedSels = lineSel.selectionsToTrack, + lineExp = _createLineExpressions(prefixes), + startLine = sel.start.line, + endLine = sel.end.line, + editGroup = []; + // In full-line selection, cursor pos is start of next line - but don't want to modify that line - if (sel.end.ch === 0 && hasSelection) { + if (sel.end.ch === 0) { endLine--; } - + // Decide if we're commenting vs. un-commenting // Are there any non-blank lines that aren't commented out? (We ignore blank lines because // some editors like Sublime don't comment them out) @@ -153,44 +164,40 @@ define(function (require, exports, module) { var prefix; var commentI; var updateSelection = false; - - // Make the edit - doc.batchOperation(function () { - - if (containsUncommented) { - // Comment out - prepend the first prefix to each line - for (i = startLine; i <= endLine; i++) { - doc.replaceRange(prefixes[0], {line: i, ch: 0}); - } - - // Make sure selection includes the prefix that was added at start of range - if (sel.start.ch === 0 && hasSelection) { - updateSelection = true; + + if (containsUncommented) { + // Comment out - prepend the first prefix to each line + for (i = startLine; i <= endLine; i++) { + editGroup.push({text: prefixes[0], start: {line: i, ch: 0}}); + } + + // Make sure tracked selections include the prefix that was added at start of range + _.each(trackedSels, function (trackedSel) { + if (trackedSel.start.ch === 0 && CodeMirror.cmpPos(trackedSel.start, trackedSel.end) !== 0) { + trackedSel.start = {line: trackedSel.start.line, ch: 0}; + trackedSel.end = {line: trackedSel.end.line, ch: (trackedSel.end.line === endLine ? trackedSel.end.ch + prefixes[0].length : 0)}; + } else { + trackedSel.isBeforeEdit = true; } - - } else { - // Uncomment - remove the prefix on each line (if any) - for (i = startLine; i <= endLine; i++) { - line = doc.getLine(i); - prefix = _getLinePrefix(line, lineExp, prefixes); - - if (prefix) { - commentI = line.indexOf(prefix); - doc.replaceRange("", {line: i, ch: commentI}, {line: i, ch: commentI + prefix.length}); - } + }); + } else { + // Uncomment - remove the prefix on each line (if any) + for (i = startLine; i <= endLine; i++) { + line = doc.getLine(i); + prefix = _getLinePrefix(line, lineExp, prefixes); + + if (prefix) { + commentI = line.indexOf(prefix); + editGroup.push({text: "", start: {line: i, ch: commentI}, end: {line: i, ch: commentI + prefix.length}}); } } - }); - - // Update the selection after the document batch so it's not blown away on resynchronization - // if this editor is not the master editor. - if (updateSelection) { - // use *current* selection end, which has been updated for our text insertions - editor.setSelection({line: startLine, ch: 0}, editor.getSelection().end); + _.each(trackedSels, function (trackedSel) { + trackedSel.isBeforeEdit = true; + }); } + return {edit: editGroup, selection: trackedSels}; } - /** * @private * Moves the token context to the token that starts the block-comment. Ctx starts in a block-comment. @@ -247,7 +254,7 @@ define(function (require, exports, module) { } /** - * Add or remove block-comment tokens to the selection, preserving selection + * Generates an edit that adds or removes block-comment tokens to the selection, preserving selection * and cursor position. Applies to the currently focused Editor. * * If the selection is inside a block-comment or one block-comment is inside or partially @@ -255,19 +262,25 @@ define(function (require, exports, module) { * Commenting out adds the prefix before the selection and the suffix after. * Uncommenting removes them. * - * If slashComment is true and the start or end of the selection is inside a line-comment it - * will try to do a line uncomment if is not actually inside a bigger block comment and all - * the lines in the selection are line-commented. + * As a special case, if slashComment is true and the start or end of the selection is inside a + * line-comment it needs to do a line uncomment if is not actually inside a bigger block comment and all + * the lines in the selection are line-commented. In this case, we return null to indicate to the caller + * that it needs to handle this selection as a line comment. * * @param {!Editor} editor * @param {!string} prefix, e.g. "" * @param {!Array.} linePrefixes, e.g. ["//"] + * @param {!{start:{line:number, ch:number}, end:{line:number, ch:number}, reversed:boolean, primary:boolean}} sel + * The selection to block comment/uncomment. + * @param {?Array.<{!{start:{line:number, ch:number}, end:{line:number, ch:number}, reversed:boolean, primary:boolean}}>} selectionsToTrack + * An array of selections that should be tracked through this edit. + * @return {{edit: {text: string, start:{line: number, ch: number}, end:?{line: number, ch: number}}|Array.<{text: string, start:{line: number, ch: number}, end:?{line: number, ch: number}}>, + * selection: {start:{line:number, ch:number}, end:{line:number, ch:number}, primary:boolean, reversed: boolean, isBeforeEdit: boolean}>}|Array.<{start:{line:number, ch:number}, end:{line:number, ch:number}, primary:boolean, reversed: boolean, isBeforeEdit: boolean}>}} + * An edit description suitable for including in the edits array passed to `Document.doMultipleEdits()`. */ - function blockCommentPrefixSuffix(editor, prefix, suffix, linePrefixes) { - + function _getBlockCommentPrefixSuffixEdit(editor, prefix, suffix, linePrefixes, sel, selectionsToTrack) { var doc = editor.document, - sel = editor.getSelection(), ctx = TokenUtils.getInitialContext(editor._codeMirror, {line: sel.start.line, ch: sel.start.ch}), startCtx = TokenUtils.getInitialContext(editor._codeMirror, {line: sel.start.line, ch: sel.start.ch}), endCtx = TokenUtils.getInitialContext(editor._codeMirror, {line: sel.end.line, ch: sel.end.ch}), @@ -279,41 +292,47 @@ define(function (require, exports, module) { canComment = false, invalidComment = false, lineUncomment = false, - newSelection; + editGroup = [], + edit; + if (!selectionsToTrack) { + // Track the original selection. + selectionsToTrack = [_.cloneDeep(sel)]; + } + var result, text, line; - + // Move the context to the first non-empty token. if (!ctx.token.type && ctx.token.string.trim().length === 0) { result = TokenUtils.moveSkippingWhitespace(TokenUtils.moveNextToken, ctx); } - + // Check if we should just do a line uncomment (if all lines in the selection are commented). if (lineExp.length && (_matchExpressions(ctx.token.string, lineExp) || _matchExpressions(endCtx.token.string, lineExp))) { var startCtxIndex = editor.indexFromPos({line: ctx.pos.line, ch: ctx.token.start}); var endCtxIndex = editor.indexFromPos({line: endCtx.pos.line, ch: endCtx.token.start + endCtx.token.string.length}); - + // Find if we aren't actually inside a block-comment result = true; while (result && _matchExpressions(ctx.token.string, lineExp)) { result = TokenUtils.moveSkippingWhitespace(TokenUtils.movePrevToken, ctx); } - + // If we aren't in a block-comment. if (!result || ctx.token.type !== "comment" || ctx.token.string.match(suffixExp)) { // Is a range of text selected? (vs just an insertion pt) var hasSelection = (sel.start.line !== sel.end.line) || (sel.start.ch !== sel.end.ch); - + // In full-line selection, cursor pos is start of next line - but don't want to modify that line var endLine = sel.end.line; if (sel.end.ch === 0 && hasSelection) { endLine--; } - + // Find if all the lines are line-commented. if (!_containsUncommented(editor, sel.start.line, endLine, lineExp)) { lineUncomment = true; - + // Block-comment in all the other cases } else { canComment = true; @@ -322,34 +341,34 @@ define(function (require, exports, module) { prefixPos = _findCommentStart(startCtx, prefixExp); suffixPos = _findCommentEnd(startCtx, suffixExp, suffix.length); } - + // If we are in a selection starting and ending in invalid tokens and with no content (not considering spaces), // find if we are inside a block-comment. } else if (startCtx.token.type === null && endCtx.token.type === null && !editor.posWithinRange(ctx.pos, startCtx.pos, endCtx.pos, true)) { result = TokenUtils.moveSkippingWhitespace(TokenUtils.moveNextToken, startCtx); - + // We found a comment, find the start and end and check if the selection is inside the block-comment. if (startCtx.token.type === "comment") { prefixPos = _findCommentStart(startCtx, prefixExp); suffixPos = _findCommentEnd(startCtx, suffixExp, suffix.length); - + if (prefixPos !== null && suffix !== null && !editor.posWithinRange(sel.start, prefixPos, suffixPos, true)) { canComment = true; } } else { canComment = true; } - + // If the start is inside a comment, find the prefix and suffix positions. } else if (ctx.token.type === "comment") { prefixPos = _findCommentStart(ctx, prefixExp); suffixPos = _findCommentEnd(ctx, suffixExp, suffix.length); - + // If not try to find the first comment inside the selection. } else { result = _findNextBlockComment(ctx, sel.end, prefixExp); - + // If nothing was found is ok to comment. if (!result) { canComment = true; @@ -362,7 +381,7 @@ define(function (require, exports, module) { suffixPos = _findCommentEnd(ctx, suffixExp, suffix.length); } } - + // Search if there is another comment in the selection. Do nothing if there is one. if (!canComment && !invalidComment && !lineUncomment && suffixPos) { var start = {line: suffixPos.line, ch: suffixPos.ch + suffix.length + 1}; @@ -370,90 +389,116 @@ define(function (require, exports, module) { // Start searching at the next token, if there is one. result = TokenUtils.moveSkippingWhitespace(TokenUtils.moveNextToken, ctx) && _findNextBlockComment(ctx, sel.end, prefixExp); - + if (result) { invalidComment = true; } } } - - + + // Make the edit if (invalidComment) { - return; - + // We don't want to do an edit, but we still want to track selections associated with it. + edit = {edit: [], selection: selectionsToTrack}; + } else if (lineUncomment) { - lineCommentPrefix(editor, linePrefixes); - + // Return a null edit. This is a signal to the caller that we should delegate to the + // line commenting code. We don't want to just generate the edit here, because the edit + // might need to be coalesced with other line-uncomment edits generated by cursors on the + // same line. + edit = null; + } else { - doc.batchOperation(function () { - - if (canComment) { - // Comment out - add the suffix to the start and the prefix to the end. - var completeLineSel = sel.start.ch === 0 && sel.end.ch === 0 && sel.start.line < sel.end.line; - if (completeLineSel) { - doc.replaceRange(suffix + "\n", sel.end); - doc.replaceRange(prefix + "\n", sel.start); - } else { - doc.replaceRange(suffix, sel.end); - doc.replaceRange(prefix, sel.start); - } - - // Correct the selection. - if (completeLineSel) { - newSelection = {start: {line: sel.start.line + 1, ch: 0}, end: {line: sel.end.line + 1, ch: 0}}; - } else { - var newSelStart = {line: sel.start.line, ch: sel.start.ch + prefix.length}; - if (sel.start.line === sel.end.line) { - newSelection = {start: newSelStart, end: {line: sel.end.line, ch: sel.end.ch + prefix.length}}; - } else { - newSelection = {start: newSelStart, end: {line: sel.end.line, ch: sel.end.ch}}; - } - } - - // Uncomment - remove prefix and suffix. + if (canComment) { + // Comment out - add the suffix to the start and the prefix to the end. + var completeLineSel = sel.start.ch === 0 && sel.end.ch === 0 && sel.start.line < sel.end.line; + if (completeLineSel) { + editGroup.push({text: suffix + "\n", start: sel.end}); + editGroup.push({text: prefix + "\n", start: sel.start}); } else { - // Find if the prefix and suffix are at the ch 0 and if they are the only thing in the line. - // If both are found we assume that a complete line selection comment added new lines, so we remove them. - var prefixAtStart = false, suffixAtStart = false; - - line = doc.getLine(prefixPos.line).trim(); - prefixAtStart = prefixPos.ch === 0 && prefix.length === line.length; - if (suffixPos) { - line = doc.getLine(suffixPos.line).trim(); - suffixAtStart = suffixPos.ch === 0 && suffix.length === line.length; - } - - // Remove the suffix if there is one - if (suffixPos) { - if (prefixAtStart && suffixAtStart) { - doc.replaceRange("", suffixPos, {line: suffixPos.line + 1, ch: 0}); - } else { - doc.replaceRange("", suffixPos, {line: suffixPos.line, ch: suffixPos.ch + suffix.length}); + editGroup.push({text: suffix, start: sel.end}); + editGroup.push({text: prefix, start: sel.start}); + } + + // Correct the tracked selections. We can't just use the default selection fixup, + // because it will push the end of the selection past the inserted content. Also, + // it's possible that we have to deal with tracked selections that might be outside + // the bounds of the edit. + _.each(selectionsToTrack, function (trackedSel) { + function updatePosForEdit(pos) { + // First adjust for the suffix insertion. Don't adjust + // positions that are exactly at the suffix insertion point. + if (CodeMirror.cmpPos(pos, sel.end) > 0) { + if (completeLineSel) { + pos.line++; + } else if (pos.line === sel.end.line) { + pos.ch += suffix.length; + } + } + // Now adjust for the prefix insertion. In this case, we do + // want to adjust positions that are exactly at the insertion + // point. + if (CodeMirror.cmpPos(pos, sel.start) >= 0) { + if (completeLineSel) { + // Just move the line down. + pos.line++; + } else if (pos.line === sel.start.line) { + pos.ch += prefix.length; + } } } - // Remove the prefix + updatePosForEdit(trackedSel.start); + updatePosForEdit(trackedSel.end); + }); + + // Uncomment - remove prefix and suffix. + } else { + // Find if the prefix and suffix are at the ch 0 and if they are the only thing in the line. + // If both are found we assume that a complete line selection comment added new lines, so we remove them. + var prefixAtStart = false, suffixAtStart = false; + + line = doc.getLine(prefixPos.line).trim(); + prefixAtStart = prefixPos.ch === 0 && prefix.length === line.length; + if (suffixPos) { + line = doc.getLine(suffixPos.line).trim(); + suffixAtStart = suffixPos.ch === 0 && suffix.length === line.length; + } + + // Remove the suffix if there is one + if (suffixPos) { if (prefixAtStart && suffixAtStart) { - doc.replaceRange("", prefixPos, {line: prefixPos.line + 1, ch: 0}); + editGroup.push({text: "", start: suffixPos, end: {line: suffixPos.line + 1, ch: 0}}); } else { - doc.replaceRange("", prefixPos, {line: prefixPos.line, ch: prefixPos.ch + prefix.length}); + editGroup.push({text: "", start: suffixPos, end: {line: suffixPos.line, ch: suffixPos.ch + suffix.length}}); } } - }); - - // Update the selection after the document batch so it's not blown away on resynchronization - // if this editor is not the master editor. - if (newSelection) { - editor.setSelection(newSelection.start, newSelection.end); + + // Remove the prefix + if (prefixAtStart && suffixAtStart) { + editGroup.push({text: "", start: prefixPos, end: {line: prefixPos.line + 1, ch: 0}}); + } else { + editGroup.push({text: "", start: prefixPos, end: {line: prefixPos.line, ch: prefixPos.ch + prefix.length}}); + } + + // Don't fix up the tracked selections here - let the edit fix them up. + _.each(selectionsToTrack, function (trackedSel) { + trackedSel.isBeforeEdit = true; + }); } + + edit = {edit: editGroup, selection: selectionsToTrack}; } + + return edit; } /** - * Add or remove block-comment tokens to the selection, preserving selection - * and cursor position. Applies to the currently focused Editor. + * Generates an edit that adds or removes block-comment tokens to the selection, preserving selection + * and cursor position. Applies to the currently focused Editor. The selection must already be a + * line selection in the form returned by `Editor.convertToLineSelections()`. * * The implementation uses blockCommentPrefixSuffix, with the exception of the case where * there is no selection on a uncommented and not empty line. In this case the whole lines gets @@ -462,94 +507,125 @@ define(function (require, exports, module) { * @param {!Editor} editor * @param {!String} prefix * @param {!String} suffix + * @param {!{selectionForEdit: {start:{line:number, ch:number}, end:{line:number, ch:number}, reversed:boolean, primary:boolean}, + * selectionsToTrack: Array.<{start:{line:number, ch:number}, end:{line:number, ch:number}, reversed:boolean, primary:boolean}>}} + * lineSel A line selection as returned from `Editor.convertToLineSelections()`. `selectionForEdit` is the selection to perform + * the line comment operation on, and `selectionsToTrack` are a set of selections associated with this line that need to be + * tracked through the edit. + * @return {{edit: {text: string, start:{line: number, ch: number}, end:?{line: number, ch: number}}|Array.<{text: string, start:{line: number, ch: number}, end:?{line: number, ch: number}}>, + * selection: {start:{line:number, ch:number}, end:{line:number, ch:number}, primary:boolean, reversed: boolean, isBeforeEdit: boolean}>}|Array.<{start:{line:number, ch:number}, end:{line:number, ch:number}, primary:boolean, reversed: boolean, isBeforeEdit: boolean}>}} + * An edit description suitable for including in the edits array passed to `Document.doMultipleEdits()`. */ - function lineCommentPrefixSuffix(editor, prefix, suffix) { - var sel = editor.getSelection(), + function _getLineCommentPrefixSuffixEdit(editor, prefix, suffix, lineSel) { + var sel = lineSel.selectionForEdit, selStart = sel.start, selEnd = sel.end, - prefixExp = new RegExp("^" + StringUtils.regexEscape(prefix), "g"), - isLineSelection = sel.start.ch === 0 && sel.end.ch === 0 && sel.start.line !== sel.end.line, - isMultipleLine = sel.start.line !== sel.end.line, - lineLength = editor.document.getLine(sel.start.line).length; + edit; - // Line selections already behave like we want to - if (!isLineSelection) { - // For a multiple line selection transform it to a multiple whole line selection - if (isMultipleLine) { - selStart = {line: sel.start.line, ch: 0}; - selEnd = {line: sel.end.line + 1, ch: 0}; - - // For one line selections, just start at column 0 and end at the end of the line - } else { - selStart = {line: sel.start.line, ch: 0}; - selEnd = {line: sel.end.line, ch: lineLength}; - } + // For one-line selections, we shrink the selection to exclude the trailing newline. + if (sel.end.line === sel.start.line + 1 && sel.end.ch === 0) { + sel.end = {line: sel.start.line, ch: editor.document.getLine(sel.start.line).length}; } - // If the selection includes a comment or is already a line selection, delegate to Block-Comment - var ctx = TokenUtils.getInitialContext(editor._codeMirror, {line: selStart.line, ch: selStart.ch}); - var result = TokenUtils.moveSkippingWhitespace(TokenUtils.moveNextToken, ctx); - var className = ctx.token.type; - result = result && _findNextBlockComment(ctx, selEnd, prefixExp); - - if (className === "comment" || result || isLineSelection) { - blockCommentPrefixSuffix(editor, prefix, suffix, []); - } else { - // Set the new selection and comment it - editor.setSelection(selStart, selEnd); - blockCommentPrefixSuffix(editor, prefix, suffix, []); - - // Restore the old selection taking into account the prefix change - if (isMultipleLine) { - sel.start.line++; - sel.end.line++; - } else { - sel.start.ch += prefix.length; - sel.end.ch += prefix.length; - } - editor.setSelection(sel.start, sel.end); - } + // Now just run the standard block comment code, but make sure to track any associated selections + // that were subsumed into this line selection. + return _getBlockCommentPrefixSuffixEdit(editor, prefix, suffix, [], sel, lineSel.selectionsToTrack); } + /** + * @private + * Generates an array of edits for toggling line comments on the given selections. + * + * @param {!Editor} editor The editor to edit within. + * @param {Array.<{start:{line:number, ch:number}, end:{line:number, ch:number}, primary:boolean, reversed: boolean, isBeforeEdit: boolean}>} + * selections The selections we want to line-comment. + * @return {Array.<{edit: {text: string, start:{line: number, ch: number}, end:?{line: number, ch: number}}|Array.<{text: string, start:{line: number, ch: number}, end:?{line: number, ch: number}}>, + * selection: {start:{line:number, ch:number}, end:{line:number, ch:number}, primary:boolean, reversed: boolean, isBeforeEdit: boolean}>}|Array.<{start:{line:number, ch:number}, end:{line:number, ch:number}, primary:boolean, reversed: boolean, isBeforeEdit: boolean}>}>} + * An array of edit descriptions suitable for including in the edits array passed to `Document.doMultipleEdits()`. + */ + function _getLineCommentEdits(editor, selections) { + // We need to expand line selections in order to coalesce cursors on the same line, but we + // don't want to merge adjacent line selections. + var lineSelections = editor.convertToLineSelections(selections, { mergeAdjacent: false }), + edits = []; + _.each(lineSelections, function (lineSel) { + var sel = lineSel.selectionForEdit, + mode = editor.getModeForRange(sel.start, sel.end), + edit; + if (mode) { + var language = editor.document.getLanguage().getLanguageForMode(mode.name || mode); + + if (language.hasLineCommentSyntax()) { + edit = _getLineCommentPrefixEdit(editor, language.getLineCommentPrefixes(), lineSel); + } else if (language.hasBlockCommentSyntax()) { + edit = _getLineCommentPrefixSuffixEdit(editor, language.getBlockCommentPrefix(), language.getBlockCommentSuffix(), lineSel); + } + } + if (!edit) { + // Even if we didn't want to do an edit, we still need to track the selection. + edit = {selection: [sel]}; + } + edits.push(edit); + }); + return edits; + } /** - * Invokes a language-specific block-comment/uncomment handler + * Invokes a language-specific line-comment/uncomment handler * @param {?Editor} editor If unspecified, applies to the currently focused editor */ - function blockComment(editor) { + function lineComment(editor) { editor = editor || EditorManager.getFocusedEditor(); if (!editor) { return; } - var language = editor.getLanguageForSelection(); - - if (language.hasBlockCommentSyntax()) { - // getLineCommentPrefixes always return an array, and will be empty if no line comment syntax is defined - blockCommentPrefixSuffix(editor, language.getBlockCommentPrefix(), language.getBlockCommentSuffix(), language.getLineCommentPrefixes()); - } + editor.setSelections(editor.document.doMultipleEdits(_getLineCommentEdits(editor, editor.getSelections()))); } /** - * Invokes a language-specific line-comment/uncomment handler + * Invokes a language-specific block-comment/uncomment handler * @param {?Editor} editor If unspecified, applies to the currently focused editor */ - function lineComment(editor) { + function blockComment(editor) { editor = editor || EditorManager.getFocusedEditor(); if (!editor) { return; } - var language = editor.getLanguageForSelection(); + var edits = [], + lineCommentSels = []; + _.each(editor.getSelections(), function (sel) { + var mode = editor.getModeForRange(sel.start, sel.end), + edit = {edit: [], selection: [sel]}; // default edit in case we don't have a mode for this selection + if (mode) { + var language = editor.document.getLanguage().getLanguageForMode(mode.name || mode); + + if (language.hasBlockCommentSyntax()) { + // getLineCommentPrefixes always return an array, and will be empty if no line comment syntax is defined + edit = _getBlockCommentPrefixSuffixEdit(editor, language.getBlockCommentPrefix(), language.getBlockCommentSuffix(), + language.getLineCommentPrefixes(), sel); + if (!edit) { + // This is only null if the block comment code found that the selection is within a line-commented line. + // Add this to the list of line-comment selections we need to handle. Since edit is null, we'll skip + // pushing anything onto the edit list for this selection. + lineCommentSels.push(sel); + } + } + } + if (edit) { + edits.push(edit); + } + }); + + // Handle any line-comment edits. It's okay if these are out-of-order with the other edits, since + // they shouldn't overlap, and `doMultipleEdits()` will take care of sorting the edits so the + // selections can be tracked appropriately. + edits.push.apply(edits, _getLineCommentEdits(editor, lineCommentSels)); - if (language.hasLineCommentSyntax()) { - lineCommentPrefix(editor, language.getLineCommentPrefixes()); - } else if (language.hasBlockCommentSyntax()) { - lineCommentPrefixSuffix(editor, language.getBlockCommentPrefix(), language.getBlockCommentSuffix()); - } + editor.setSelections(editor.document.doMultipleEdits(edits)); } - - + /** * Duplicates the selected text, or current line if no selection. The cursor/selection is left * on the second copy. @@ -560,23 +636,38 @@ define(function (require, exports, module) { return; } - var sel = editor.getSelection(), - hasSelection = (sel.start.line !== sel.end.line) || (sel.start.ch !== sel.end.ch), - delimiter = ""; + var selections = editor.getSelections(), + delimiter = "", + edits = [], + rangeSels = [], + cursorSels = [], + doc = editor.document; - if (!hasSelection) { - sel.start.ch = 0; - sel.end = {line: sel.start.line + 1, ch: 0}; + // When there are multiple selections, we want to handle all the cursors first (duplicating + // their lines), then all the ranges (duplicating the ranges). + _.each(selections, function (sel) { + if (CodeMirror.cmpPos(sel.start, sel.end) === 0) { + cursorSels.push(sel); + } else { + rangeSels.push(sel); + } + }); + + var cursorLineSels = editor.convertToLineSelections(cursorSels); + _.each(cursorLineSels, function (lineSel, index) { + var sel = lineSel.selectionForEdit; if (sel.end.line === editor.lineCount()) { delimiter = "\n"; } - } - - // Make the edit - var doc = editor.document; + // Don't need to explicitly track selections since we are doing the edits in such a way that + // the existing selections will get appropriately updated. + edits.push({edit: {text: doc.getRange(sel.start, sel.end) + delimiter, start: sel.start }}); + }); + _.each(rangeSels, function (sel) { + edits.push({edit: {text: doc.getRange(sel.start, sel.end), start: sel.start }}); + }); - var selectedText = doc.getRange(sel.start, sel.end) + delimiter; - doc.replaceRange(selectedText, sel.start); + doc.doMultipleEdits(edits); } /** @@ -588,38 +679,39 @@ define(function (require, exports, module) { if (!editor) { return; } - - var from, - to, - sel = editor.getSelection(), - doc = editor.document, - endLine; - - from = {line: sel.start.line, ch: 0}; - // endLine is the line after the last one we want to delete. - endLine = sel.end.line + 1; - if (sel.start.line < sel.end.line && sel.end.ch === 0) { - // The selection is more than one line and ends right at the beginning - // of a line. In this case, we don't want to delete that last line - we - // only want to delete the one before it. - endLine--; - } + // Walk the selections, calculating the deletion edits we need to do as we go; + // document.doMultipleEdits() will take care of adjusting the edit locations when + // it actually performs the edits. + var doc = editor.document, + from, + to, + lineSelections = editor.convertToLineSelections(editor.getSelections()), + edits = []; - if (endLine === editor.getLastVisibleLine() + 1) { - // Instead of deleting the newline after the last line, delete the newline - // before the first line--unless this is the entire visible content of the editor, - // in which case just delete the line content. - if (from.line > editor.getFirstVisibleLine()) { - from.line -= 1; - from.ch = doc.getLine(from.line).length; + _.each(lineSelections, function (lineSel, index) { + var sel = lineSel.selectionForEdit, + selStartLine = sel.start.line; + + from = sel.start; + to = sel.end; // this is already at the beginning of the line after the last selected line + if (to.line === editor.getLastVisibleLine() + 1) { + // Instead of deleting the newline after the last line, delete the newline + // before the beginning of the line--unless this is the entire visible content + // of the editor, in which case just delete the line content. + if (from.line > editor.getFirstVisibleLine()) { + from.line -= 1; + from.ch = doc.getLine(from.line).length; + } + to.line -= 1; + to.ch = doc.getLine(to.line).length; } - to = {line: endLine - 1, ch: doc.getLine(endLine - 1).length}; - } else { - to = {line: endLine, ch: 0}; - } - - doc.replaceRange("", from, to); + + // We don't need to track the original selections, since they'll get collapsed as + // part of the various deletions that occur. + edits.push({edit: {text: "", start: from, end: to}}); + }); + doc.doMultipleEdits(edits); } /** @@ -634,80 +726,84 @@ define(function (require, exports, module) { return; } - var doc = editor.document, - sel = editor.getSelection(), - originalSel = editor.getSelection(), - hasSelection = (sel.start.line !== sel.end.line) || (sel.start.ch !== sel.end.ch), + var doc = editor.document, + lineSelections = editor.convertToLineSelections(editor.getSelections()), isInlineWidget = !!EditorManager.getFocusedInlineWidget(), firstLine = editor.getFirstVisibleLine(), lastLine = editor.getLastVisibleLine(), totalLines = editor.lineCount(), - lineLength = 0; + lineLength = 0, + edits = []; - sel.start.ch = 0; - // The end of the selection becomes the start of the next line, if it isn't already - if (!hasSelection || sel.end.ch !== 0) { - sel.end = {line: sel.end.line + 1, ch: 0}; - } - - // Make the move - switch (direction) { - case DIRECTION_UP: - if (sel.start.line !== firstLine) { - doc.batchOperation(function () { + _.each(lineSelections, function (lineSel) { + var sel = lineSel.selectionForEdit, + editGroup = []; + + // Make the move + switch (direction) { + case DIRECTION_UP: + if (sel.start.line !== firstLine) { var prevText = doc.getRange({ line: sel.start.line - 1, ch: 0 }, sel.start); - + if (sel.end.line === lastLine + 1) { if (isInlineWidget) { prevText = prevText.substring(0, prevText.length - 1); lineLength = doc.getLine(sel.end.line - 1).length; - doc.replaceRange("\n", { line: sel.end.line - 1, ch: lineLength }); + editGroup.push({text: "\n", start: { line: sel.end.line - 1, ch: lineLength }}); } else { prevText = "\n" + prevText.substring(0, prevText.length - 1); } } - - doc.replaceRange("", { line: sel.start.line - 1, ch: 0 }, sel.start); - doc.replaceRange(prevText, { line: sel.end.line - 1, ch: 0 }); - + + editGroup.push({text: "", start: { line: sel.start.line - 1, ch: 0 }, end: sel.start}); + editGroup.push({text: prevText, start: { line: sel.end.line - 1, ch: 0 }}); + // Make sure CodeMirror hasn't expanded the selection to include // the line we inserted below. - originalSel.start.line--; - originalSel.end.line--; - }); - - // Update the selection after the document batch so it's not blown away on resynchronization - // if this editor is not the master editor. - editor.setSelection(originalSel.start, originalSel.end); - } - break; - case DIRECTION_DOWN: - if (sel.end.line <= lastLine) { - doc.batchOperation(function () { + _.each(lineSel.selectionsToTrack, function (originalSel) { + originalSel.start.line--; + originalSel.end.line--; + }); + + edits.push({edit: editGroup, selection: lineSel.selectionsToTrack}); + } + break; + case DIRECTION_DOWN: + if (sel.end.line <= lastLine) { var nextText = doc.getRange(sel.end, { line: sel.end.line + 1, ch: 0 }), deletionStart = sel.end; - + if (sel.end.line === lastLine) { if (isInlineWidget) { if (sel.end.line === totalLines - 1) { nextText += "\n"; } lineLength = doc.getLine(sel.end.line - 1).length; - doc.replaceRange("\n", { line: sel.end.line, ch: doc.getLine(sel.end.line).length }); + editGroup.push({text: "\n", start: { line: sel.end.line, ch: doc.getLine(sel.end.line).length }}); } else { nextText += "\n"; deletionStart = { line: sel.end.line - 1, ch: doc.getLine(sel.end.line - 1).length }; } } - - doc.replaceRange("", deletionStart, { line: sel.end.line + 1, ch: 0 }); + + editGroup.push({text: "", start: deletionStart, end: { line: sel.end.line + 1, ch: 0 }}); if (lineLength) { - doc.replaceRange("", { line: sel.end.line - 1, ch: lineLength }, { line: sel.end.line, ch: 0 }); + editGroup.push({text: "", start: { line: sel.end.line - 1, ch: lineLength }, end: { line: sel.end.line, ch: 0 }}); } - doc.replaceRange(nextText, { line: sel.start.line, ch: 0 }); - }); + editGroup.push({text: nextText, start: { line: sel.start.line, ch: 0 }}); + + // In this case, we don't need to track selections, because the edits are done in such a way that + // the existing selections will automatically be updated properly by CodeMirror as it does the edits. + edits.push({edit: editGroup}); + } + break; + } + }); + if (edits.length) { + var newSels = doc.doMultipleEdits(edits); + if (direction === DIRECTION_UP) { + editor.setSelections(newSels); } - break; } } @@ -739,35 +835,73 @@ define(function (require, exports, module) { return; } - var sel = editor.getSelection(), - hasSelection = (sel.start.line !== sel.end.line) || (sel.start.ch !== sel.end.ch), + var selections = editor.getSelections(), isInlineWidget = !!EditorManager.getFocusedInlineWidget(), lastLine = editor.getLastVisibleLine(), - cm = editor._codeMirror, doc = editor.document, + edits = [], + newSelections, line; - // Insert the new line - switch (direction) { - case DIRECTION_UP: - line = sel.start.line; - break; - case DIRECTION_DOWN: - line = sel.end.line; - if (!(hasSelection && sel.end.ch === 0)) { - // If not linewise selection - line++; - } - break; - } + // First, insert all the newlines (skipping multiple selections on the same line), + // then indent them all. (We can't easily do them all at once, because doMultipleEdits() + // won't do the indentation for us, but we want its help tracking any selection changes + // as the result of the edits.) - if (line > lastLine && isInlineWidget) { - doc.replaceRange("\n", {line: line - 1, ch: doc.getLine(line - 1).length}, null, "+input"); - } else { - doc.replaceRange("\n", {line: line, ch: 0}, null, "+input"); - } - cm.indentLine(line, "smart", true); - editor.setSelection({line: line, ch: null}); + // Note that we don't just use `editor.getLineSelections()` here because we don't actually want + // to coalesce adjacent selections - we just want to ignore dupes. + + doc.batchOperation(function () { + _.each(selections, function (sel, index) { + if (index === 0 || + (direction === DIRECTION_UP && sel.start.line > selections[index - 1].start.line) || + (direction === DIRECTION_DOWN && sel.end.line > selections[index - 1].end.line)) { + // Insert the new line + switch (direction) { + case DIRECTION_UP: + line = sel.start.line; + break; + case DIRECTION_DOWN: + line = sel.end.line; + if (!(CodeMirror.cmpPos(sel.start, sel.end) !== 0 && sel.end.ch === 0)) { + // If not linewise selection + line++; + } + break; + } + + var insertPos; + if (line > lastLine && isInlineWidget) { + insertPos = {line: line - 1, ch: doc.getLine(line - 1).length}; + } else { + insertPos = {line: line, ch: 0}; + } + // We want the selection after this edit to be right before the \n we just inserted. + edits.push({edit: {text: "\n", start: insertPos}, selection: {start: insertPos, end: insertPos, primary: sel.primary}}); + } else { + // We just want to discard this selection, since we've already operated on the + // same line and it would just collapse to the same location. But if this was + // primary, make sure the last selection we did operate on ends up as primary. + if (sel.primary) { + edits[edits.length - 1].selections[0].primary = true; + } + } + }); + newSelections = doc.doMultipleEdits(edits, "+input"); + + // Now indent each added line (which doesn't mess up any line numbers, and + // we're going to set the character offset to the last position on each line anyway). + _.each(newSelections, function (sel) { + // This is a bit of a hack. The document is the one that batches operations, but we want + // to use CodeMirror's "smart indent" operation. So we need to use the document's own backing editor's + // CodeMirror to do the indentation. A better way to fix this would be to expose this + // operation on Document, but I'm not sure we want to sign up for that as a public API. + doc._masterEditor._codeMirror.indentLine(sel.start.line, "smart", true); + sel.start.ch = null; // last character on line + sel.end = sel.start; + }); + }); + editor.setSelections(newSelections); } /** @@ -803,7 +937,7 @@ define(function (require, exports, module) { /** * Unindent a line of text if no selection. Otherwise, unindent all lines in selection. */ - function unidentText() { + function unindentText() { var editor = EditorManager.getFocusedEditor(); if (!editor) { return; @@ -815,20 +949,72 @@ define(function (require, exports, module) { function selectLine(editor) { editor = editor || EditorManager.getFocusedEditor(); if (editor) { - var sel = editor.getSelection(); - var from = {line: sel.start.line, ch: 0}; - var to = {line: sel.end.line + 1, ch: 0}; - - if (to.line === editor.getLastVisibleLine() + 1) { - // Last line: select to end of line instead of start of (hidden/nonexistent) following line, - // which due to how CM clips coords would only work some of the time - to.line -= 1; - to.ch = editor.document.getLine(to.line).length; - } - - editor.setSelection(from, to); + // We can just use `convertToLineSelections`, but throw away the original tracked selections and just use the + // coalesced selections. + editor.setSelections(_.pluck(editor.convertToLineSelections(editor.getSelections(), { expandEndAtStartOfLine: true }), "selectionForEdit")); } } + + /** + * @private + * Takes the current selection and splits each range into separate selections, one per line. + * @param {!Editor} editor The editor to operate on. + */ + function splitSelIntoLines(editor) { + editor = editor || EditorManager.getFocusedEditor(); + if (editor) { + editor._codeMirror.execCommand("splitSelectionByLine"); + } + } + + /** + * @private + * Adds a cursor on the next/previous line after/before each selected range to the selection. + * @param {!Editor} editor The editor to operate on. + * @param {number} dir The direction to add - 1 is down, -1 is up. + */ + function addLineToSelection(editor, dir) { + editor = editor || EditorManager.getFocusedEditor(); + if (editor) { + var origSels = editor.getSelections(), + newSels = []; + _.each(origSels, function (sel) { + var pos; + if ((dir === -1 && sel.start.line > editor.getFirstVisibleLine()) || (dir === 1 && sel.end.line < editor.getLastVisibleLine())) { + // Add a new cursor on the next line up/down. It's okay if it overlaps another selection, because CM + // will take care of throwing it away in that case. It will also take care of clipping the char position + // to the end of the new line if the line is shorter. + pos = _.clone(dir === -1 ? sel.start : sel.end); + pos.line += dir; + + // If this is the primary selection, we want the new cursor we're adding to become the + // primary selection. + newSels.push({start: pos, end: pos, primary: sel.primary}); + sel.primary = false; + } + }); + // CM will take care of sorting the selections. + editor.setSelections(origSels.concat(newSels)); + } + } + + /** + * @private + * Adds a cursor on the previous line before each selected range to the selection. + * @param {!Editor} editor The editor to operate on. + */ + function addPrevLineToSelection(editor) { + addLineToSelection(editor, -1); + } + + /** + * @private + * Adds a cursor on the next line after each selected range to the selection. + * @param {!Editor} editor The editor to operate on. + */ + function addNextLineToSelection(editor) { + addLineToSelection(editor, 1); + } function handleUndoRedo(operation) { var editor = EditorManager.getFocusedEditor(); @@ -877,22 +1063,25 @@ define(function (require, exports, module) { } // Register commands - CommandManager.register(Strings.CMD_INDENT, Commands.EDIT_INDENT, indentText); - CommandManager.register(Strings.CMD_UNINDENT, Commands.EDIT_UNINDENT, unidentText); - CommandManager.register(Strings.CMD_COMMENT, Commands.EDIT_LINE_COMMENT, lineComment); - CommandManager.register(Strings.CMD_BLOCK_COMMENT, Commands.EDIT_BLOCK_COMMENT, blockComment); - CommandManager.register(Strings.CMD_DUPLICATE, Commands.EDIT_DUPLICATE, duplicateText); - CommandManager.register(Strings.CMD_DELETE_LINES, Commands.EDIT_DELETE_LINES, deleteCurrentLines); - CommandManager.register(Strings.CMD_LINE_UP, Commands.EDIT_LINE_UP, moveLineUp); - CommandManager.register(Strings.CMD_LINE_DOWN, Commands.EDIT_LINE_DOWN, moveLineDown); - CommandManager.register(Strings.CMD_OPEN_LINE_ABOVE, Commands.EDIT_OPEN_LINE_ABOVE, openLineAbove); - CommandManager.register(Strings.CMD_OPEN_LINE_BELOW, Commands.EDIT_OPEN_LINE_BELOW, openLineBelow); - CommandManager.register(Strings.CMD_SELECT_LINE, Commands.EDIT_SELECT_LINE, selectLine); - - CommandManager.register(Strings.CMD_UNDO, Commands.EDIT_UNDO, handleUndo); - CommandManager.register(Strings.CMD_REDO, Commands.EDIT_REDO, handleRedo); - CommandManager.register(Strings.CMD_CUT, Commands.EDIT_CUT, ignoreCommand); - CommandManager.register(Strings.CMD_COPY, Commands.EDIT_COPY, ignoreCommand); - CommandManager.register(Strings.CMD_PASTE, Commands.EDIT_PASTE, ignoreCommand); - CommandManager.register(Strings.CMD_SELECT_ALL, Commands.EDIT_SELECT_ALL, _handleSelectAll); + CommandManager.register(Strings.CMD_INDENT, Commands.EDIT_INDENT, indentText); + CommandManager.register(Strings.CMD_UNINDENT, Commands.EDIT_UNINDENT, unindentText); + CommandManager.register(Strings.CMD_COMMENT, Commands.EDIT_LINE_COMMENT, lineComment); + CommandManager.register(Strings.CMD_BLOCK_COMMENT, Commands.EDIT_BLOCK_COMMENT, blockComment); + CommandManager.register(Strings.CMD_DUPLICATE, Commands.EDIT_DUPLICATE, duplicateText); + CommandManager.register(Strings.CMD_DELETE_LINES, Commands.EDIT_DELETE_LINES, deleteCurrentLines); + CommandManager.register(Strings.CMD_LINE_UP, Commands.EDIT_LINE_UP, moveLineUp); + CommandManager.register(Strings.CMD_LINE_DOWN, Commands.EDIT_LINE_DOWN, moveLineDown); + CommandManager.register(Strings.CMD_OPEN_LINE_ABOVE, Commands.EDIT_OPEN_LINE_ABOVE, openLineAbove); + CommandManager.register(Strings.CMD_OPEN_LINE_BELOW, Commands.EDIT_OPEN_LINE_BELOW, openLineBelow); + CommandManager.register(Strings.CMD_SELECT_LINE, Commands.EDIT_SELECT_LINE, selectLine); + CommandManager.register(Strings.CMD_SPLIT_SEL_INTO_LINES, Commands.EDIT_SPLIT_SEL_INTO_LINES, splitSelIntoLines); + CommandManager.register(Strings.CMD_ADD_NEXT_LINE_TO_SEL, Commands.EDIT_ADD_NEXT_LINE_TO_SEL, addNextLineToSelection); + CommandManager.register(Strings.CMD_ADD_PREV_LINE_TO_SEL, Commands.EDIT_ADD_PREV_LINE_TO_SEL, addPrevLineToSelection); + + CommandManager.register(Strings.CMD_UNDO, Commands.EDIT_UNDO, handleUndo); + CommandManager.register(Strings.CMD_REDO, Commands.EDIT_REDO, handleRedo); + CommandManager.register(Strings.CMD_CUT, Commands.EDIT_CUT, ignoreCommand); + CommandManager.register(Strings.CMD_COPY, Commands.EDIT_COPY, ignoreCommand); + CommandManager.register(Strings.CMD_PASTE, Commands.EDIT_PASTE, ignoreCommand); + CommandManager.register(Strings.CMD_SELECT_ALL, Commands.EDIT_SELECT_ALL, _handleSelectAll); }); diff --git a/src/editor/EditorManager.js b/src/editor/EditorManager.js index 427b3fb4d85..a95c0ec1695 100644 --- a/src/editor/EditorManager.js +++ b/src/editor/EditorManager.js @@ -481,7 +481,7 @@ define(function (require, exports, module) { /** Updates _viewStateCache from the given editor's actual current state */ function _saveEditorViewState(editor) { _viewStateCache[editor.document.file.fullPath] = { - selection: editor.getSelection(), + selections: editor.getSelections(), scrollPos: editor.getScrollPos() }; } @@ -492,8 +492,13 @@ define(function (require, exports, module) { var viewState = _viewStateCache[editor.document.file.fullPath]; if (viewState) { if (viewState.selection) { + // We no longer write out single-selection, but there might be some view state + // from an older version. editor.setSelection(viewState.selection.start, viewState.selection.end); } + if (viewState.selections) { + editor.setSelections(viewState.selections); + } if (viewState.scrollPos) { editor.setScrollPos(viewState.scrollPos.x, viewState.scrollPos.y); } diff --git a/src/editor/EditorStatusBar.js b/src/editor/EditorStatusBar.js index 68e431d35fd..d4c9151e953 100644 --- a/src/editor/EditorStatusBar.js +++ b/src/editor/EditorStatusBar.js @@ -94,11 +94,14 @@ define(function (require, exports, module) { var cursor = editor.getCursorPos(true); var cursorStr = StringUtils.format(Strings.STATUSBAR_CURSOR_POSITION, cursor.line + 1, cursor.ch + 1); - if (editor.hasSelection()) { - // Show info about selection size when one exists - var sel = editor.getSelection(), - selStr; - + + var sels = editor.getSelections(), + selStr = ""; + + if (sels.length > 1) { + selStr = StringUtils.format(Strings.STATUSBAR_SELECTION_MULTIPLE, sels.length); + } else if (editor.hasSelection()) { + var sel = sels[0]; if (sel.start.line !== sel.end.line) { var lines = sel.end.line - sel.start.line + 1; if (sel.end.ch === 0) { @@ -109,10 +112,8 @@ define(function (require, exports, module) { var cols = editor.getColOffset(sel.end) - editor.getColOffset(sel.start); // end ch is exclusive always selStr = _formatCountable(cols, Strings.STATUSBAR_SELECTION_CH_SINGULAR, Strings.STATUSBAR_SELECTION_CH_PLURAL); } - $cursorInfo.text(cursorStr + selStr); - } else { - $cursorInfo.text(cursorStr); } + $cursorInfo.text(cursorStr + selStr); } function _changeIndentWidth(value) { diff --git a/src/editor/InlineTextEditor.js b/src/editor/InlineTextEditor.js index ed2c809aa15..36a15f83233 100644 --- a/src/editor/InlineTextEditor.js +++ b/src/editor/InlineTextEditor.js @@ -24,17 +24,19 @@ // FUTURE: Merge part (or all) of this class with MultiRangeInlineEditor /*jslint vars: true, plusplus: true, devel: true, nomen: true, indent: 4, maxerr: 50 */ -/*global define, $, CodeMirror, window */ +/*global define, $, window */ define(function (require, exports, module) { "use strict"; // Load dependent modules - var DocumentManager = require("document/DocumentManager"), + var CodeMirror = require("thirdparty/CodeMirror2/lib/codemirror"), + DocumentManager = require("document/DocumentManager"), EditorManager = require("editor/EditorManager"), CommandManager = require("command/CommandManager"), Commands = require("command/Commands"), - InlineWidget = require("editor/InlineWidget").InlineWidget; + InlineWidget = require("editor/InlineWidget").InlineWidget, + KeyEvent = require("utils/KeyEvent"); /** * Returns editor holder width (not CodeMirror's width). @@ -80,6 +82,10 @@ define(function (require, exports, module) { /* @type {Editor}*/ this.editor = null; + + // We need to set this as a capture handler so CodeMirror doesn't handle Esc before we see it. + this.handleKeyDown = this.handleKeyDown.bind(this); + this.htmlContent.addEventListener("keydown", this.handleKeyDown, true); } InlineTextEditor.prototype = Object.create(InlineWidget.prototype); InlineTextEditor.prototype.constructor = InlineTextEditor; @@ -137,6 +143,7 @@ define(function (require, exports, module) { // Destroy the inline editor. this.setInlineContent(null); + this.htmlContent.removeEventListener("keydown", this.handleKeyDown, true); }; /** @@ -193,6 +200,17 @@ define(function (require, exports, module) { return null; }; + /** + * @private + * Make sure that if we want to handle Esc to cancel a multiple selection, we don't let it bubble + * up to InlineWidget, which will close the edit. + */ + InlineTextEditor.prototype.handleKeyDown = function (e) { + if (e.keyCode === KeyEvent.DOM_VK_ESCAPE && this.editor && this.editor.getSelections().length > 1) { + CodeMirror.commands.singleSelection(this.editor._codeMirror); + e.stopImmediatePropagation(); + } + }; /** * Sets the document and range to show in the inline editor, or null to destroy the current editor and leave diff --git a/src/editor/InlineWidget.js b/src/editor/InlineWidget.js index ba1fa2bee17..ed122425845 100644 --- a/src/editor/InlineWidget.js +++ b/src/editor/InlineWidget.js @@ -23,7 +23,7 @@ /*jslint vars: true, plusplus: true, devel: true, nomen: true, indent: 4, maxerr: 50 */ -/*global define, $, CodeMirror, window */ +/*global define, $, window */ define(function (require, exports, module) { "use strict"; diff --git a/src/editor/MultiRangeInlineEditor.js b/src/editor/MultiRangeInlineEditor.js index ea22b2582be..f77334b8d0f 100644 --- a/src/editor/MultiRangeInlineEditor.js +++ b/src/editor/MultiRangeInlineEditor.js @@ -24,7 +24,7 @@ // FUTURE: Merge part (or all) of this class with InlineTextEditor /*jslint vars: true, plusplus: true, devel: true, nomen: true, indent: 4, maxerr: 50 */ -/*global define, $, CodeMirror, window */ +/*global define, $, window */ /** * An inline editor for displaying and editing multiple text ranges. Each range corresponds to a diff --git a/src/extensions/default/InlineColorEditor/InlineColorEditor.js b/src/extensions/default/InlineColorEditor/InlineColorEditor.js index 60fdb82ca51..0a65cda58ee 100644 --- a/src/extensions/default/InlineColorEditor/InlineColorEditor.js +++ b/src/extensions/default/InlineColorEditor/InlineColorEditor.js @@ -50,7 +50,7 @@ define(function (require, exports, module) { this._endBookmark = endBookmark; this._isOwnChange = false; this._isHostChange = false; - this._origin = "*InlineColorEditor_" + (lastOriginId++); + this._origin = "+InlineColorEditor_" + (lastOriginId++); this._handleColorChange = this._handleColorChange.bind(this); this._handleHostDocumentChange = this._handleHostDocumentChange.bind(this); @@ -141,6 +141,7 @@ define(function (require, exports, module) { * @param {!string} colorString */ InlineColorEditor.prototype._handleColorChange = function (colorString) { + var self = this; if (colorString !== this._color) { var range = this.getCurrentRange(); if (!range) { @@ -149,13 +150,15 @@ define(function (require, exports, module) { // Don't push the change back into the host editor if it came from the host editor. if (!this._isHostChange) { - // Replace old color in code with the picker's color, and select it - this._isOwnChange = true; - this.hostEditor.document.replaceRange(colorString, range.start, range.end, this._origin); - this._isOwnChange = false; - this.hostEditor.setSelection(range.start, { - line: range.start.line, - ch: range.start.ch + colorString.length + this.hostEditor.document.batchOperation(function () { + // Replace old color in code with the picker's color, and select it + self._isOwnChange = true; + self.hostEditor.document.replaceRange(colorString, range.start, range.end, self._origin); + self._isOwnChange = false; + self.hostEditor.setSelection(range.start, { + line: range.start.line, + ch: range.start.ch + colorString.length + }); }); } diff --git a/src/extensions/default/JavaScriptCodeHints/ParameterHintManager.js b/src/extensions/default/JavaScriptCodeHints/ParameterHintManager.js index ad59a900f90..a5b99048b2a 100644 --- a/src/extensions/default/JavaScriptCodeHints/ParameterHintManager.js +++ b/src/extensions/default/JavaScriptCodeHints/ParameterHintManager.js @@ -393,8 +393,8 @@ define(function (require, exports, module) { */ function installListeners(editor) { - $(editor).on("keyEvent", function (jqEvent, editor, event) { - if (event.type === "keydown" && event.keyCode === KeyEvent.DOM_VK_ESCAPE) { + $(editor).on("keydown", function (jqEvent, editor, event) { + if (event.keyCode === KeyEvent.DOM_VK_ESCAPE) { dismissHint(); } }).on("scroll", function () { diff --git a/src/extensions/default/JavaScriptCodeHints/ScopeManager.js b/src/extensions/default/JavaScriptCodeHints/ScopeManager.js index 29afe852a23..171b9dfa587 100644 --- a/src/extensions/default/JavaScriptCodeHints/ScopeManager.js +++ b/src/extensions/default/JavaScriptCodeHints/ScopeManager.js @@ -29,7 +29,7 @@ */ /*jslint vars: true, plusplus: true, devel: true, nomen: true, indent: 4, maxerr: 50 */ -/*global define, brackets, CodeMirror, $, Worker, setTimeout */ +/*global define, brackets, $, Worker, setTimeout */ define(function (require, exports, module) { "use strict"; @@ -42,6 +42,7 @@ define(function (require, exports, module) { ExtensionUtils = brackets.getModule("utils/ExtensionUtils"), FileSystem = brackets.getModule("filesystem/FileSystem"), FileUtils = brackets.getModule("file/FileUtils"), + CodeMirror = brackets.getModule("thirdparty/CodeMirror2/lib/codemirror"), HintUtils = require("HintUtils"), MessageIds = require("MessageIds"), Preferences = require("Preferences"); @@ -1320,29 +1321,32 @@ define(function (require, exports, module) { * Track the update area of the current document so we can tell if we can send * partial updates to tern or not. * - * @param {{from: {line:number, ch: number}, to: {line:number, ch: number}, - * text: Array}} changeList - the document changes (since last change or cumlative?) + * @param {Array.<{from: {line:number, ch: number}, to: {line:number, ch: number}, + * text: Array}>} changeList - the document changes from the current change event */ function trackChange(changeList) { - var changed = documentChanges; + var changed = documentChanges, i; if (changed === null) { - documentChanges = changed = {from: changeList.from.line, to: changeList.from.line}; + documentChanges = changed = {from: changeList[0].from.line, to: changeList[0].from.line}; if (config.debug) { console.debug("ScopeManager: document has changed"); } } - var end = changeList.from.line + (changeList.text.length - 1); - if (changeList.from.line < changed.to) { - changed.to = changed.to - (changeList.to.line - end); - } + for (i = 0; i < changeList.length; i++) { + var thisChange = changeList[i], + end = thisChange.from.line + (thisChange.text.length - 1); + if (thisChange.from.line < changed.to) { + changed.to = changed.to - (thisChange.to.line - end); + } - if (end >= changed.to) { - changed.to = end + 1; - } + if (end >= changed.to) { + changed.to = end + 1; + } - if (changed.from > changeList.from.line) { - changed.from = changeList.from.line; + if (changed.from > thisChange.from.line) { + changed.from = thisChange.from.line; + } } } diff --git a/src/extensions/default/JavaScriptCodeHints/main.js b/src/extensions/default/JavaScriptCodeHints/main.js index b36392bd0ee..89dd351ae60 100644 --- a/src/extensions/default/JavaScriptCodeHints/main.js +++ b/src/extensions/default/JavaScriptCodeHints/main.js @@ -453,7 +453,7 @@ define(function (require, exports, module) { // type has changed since the last hint computation if (this.needNewHints(session)) { if (key) { - ScopeManager.handleFileChange({from: cursor, to: cursor, text: [key]}); + ScopeManager.handleFileChange([{from: cursor, to: cursor, text: [key]}]); ignoreChange = true; } diff --git a/src/extensions/default/QuickView/main.js b/src/extensions/default/QuickView/main.js index da3dab9f97b..4a87e5b903d 100644 --- a/src/extensions/default/QuickView/main.js +++ b/src/extensions/default/QuickView/main.js @@ -22,7 +22,7 @@ */ /*jslint vars: true, plusplus: true, devel: true, nomen: true, regexp: true, indent: 4, maxerr: 50 */ -/*global define, brackets, $, window, PathUtils, CodeMirror */ +/*global define, brackets, $, window, PathUtils */ define(function (require, exports, module) { "use strict"; diff --git a/src/index.html b/src/index.html index 24701b7595d..010c65d0ade 100644 --- a/src/index.html +++ b/src/index.html @@ -49,22 +49,7 @@ - - - - - - - - - - - - - - - diff --git a/src/language/CSSUtils.js b/src/language/CSSUtils.js index 17166ffc70e..c82b669cc1b 100644 --- a/src/language/CSSUtils.js +++ b/src/language/CSSUtils.js @@ -23,7 +23,7 @@ /*jslint vars: true, plusplus: true, devel: true, nomen: true, indent: 4, maxerr: 50, regexp: true */ -/*global define, $, CodeMirror, _parseRuleList: true */ +/*global define, $, _parseRuleList: true */ // JSLint Note: _parseRuleList() is cyclical dependency, not a global function. // It was added to this list to prevent JSLint warning about being used before being defined. @@ -34,7 +34,8 @@ define(function (require, exports, module) { "use strict"; - var Async = require("utils/Async"), + var CodeMirror = require("thirdparty/CodeMirror2/lib/codemirror"), + Async = require("utils/Async"), DocumentManager = require("document/DocumentManager"), EditorManager = require("editor/EditorManager"), HTMLUtils = require("language/HTMLUtils"), @@ -382,7 +383,7 @@ define(function (require, exports, module) { testToken = editor._codeMirror.getTokenAt(testPos, true); // Currently only support url. May be null if starting to type - if (ctx.token.className && ctx.token.className !== "string") { + if (ctx.token.type && ctx.token.type !== "string") { return createInfo(); } @@ -392,11 +393,11 @@ define(function (require, exports, module) { propValues[0] = backwardCtx.token.string; while (TokenUtils.movePrevToken(backwardCtx)) { - if (backwardCtx.token.className === "def" && backwardCtx.token.string === "@import") { + if (backwardCtx.token.type === "def" && backwardCtx.token.string === "@import") { break; } - if (backwardCtx.token.className && backwardCtx.token.className !== "tag" && backwardCtx.token.string !== "url") { + if (backwardCtx.token.type && backwardCtx.token.type !== "tag" && backwardCtx.token.string !== "url") { // Previous token may be white-space // Otherwise, previous token may only be "url(" break; @@ -406,7 +407,7 @@ define(function (require, exports, module) { offset += backwardCtx.token.string.length; } - if (backwardCtx.token.className !== "def" || backwardCtx.token.string !== "@import") { + if (backwardCtx.token.type !== "def" || backwardCtx.token.string !== "@import") { // Not in url return createInfo(); } @@ -430,7 +431,7 @@ define(function (require, exports, module) { /** * Returns a context info object for the given cursor position * @param {!Editor} editor - * @param {{ch: number, line: number}} constPos A CM pos (likely from editor.getCursor()) + * @param {{ch: number, line: number}} constPos A CM pos (likely from editor.getCursorPos()) * @return {{context: string, * offset: number, * name: string, diff --git a/src/language/HTMLDOMDiff.js b/src/language/HTMLDOMDiff.js index e81c18ef20a..58de1fdca76 100644 --- a/src/language/HTMLDOMDiff.js +++ b/src/language/HTMLDOMDiff.js @@ -23,7 +23,7 @@ /*jslint vars: true, plusplus: true, devel: true, nomen: true, indent: 4, maxerr: 50 */ -/*global define, $, CodeMirror */ +/*global define, $ */ /*unittests: HTML Instrumentation*/ define(function (require, exports, module) { diff --git a/src/language/HTMLInstrumentation.js b/src/language/HTMLInstrumentation.js index e0d9e9e3557..5a84fc13bd2 100644 --- a/src/language/HTMLInstrumentation.js +++ b/src/language/HTMLInstrumentation.js @@ -23,7 +23,7 @@ /*jslint vars: true, plusplus: true, devel: true, nomen: true, indent: 4, maxerr: 50 */ -/*global define, $, CodeMirror */ +/*global define, $ */ /*unittests: HTML Instrumentation*/ /** @@ -190,6 +190,10 @@ define(function (require, exports, module) { * the API is likely to change in the future. * * @param {Editor} editor The editor to scan. + * @param {{line: number, ch: number}} pos The position to find the DOM marker for. + * @param {Object=} markCache An optional cache to look up positions of existing + * markers. (This avoids calling the find() operation on marks multiple times, + * which is expensive.) * @return {number} tagID at the specified position, or -1 if there is no tag */ function _getTagIDAtDocumentPos(editor, pos, markCache) { @@ -248,7 +252,7 @@ define(function (require, exports, module) { * * @param {Object} previousDOM The root of the HTMLSimpleDOM tree representing a previous state of the DOM. * @param {Editor} editor The editor containing the instrumented HTML. - * @param {Array=} changeList An optional list of CodeMirror change records representing the + * @param {Array=} changeList An optional array of CodeMirror change records representing the * edits the user made in the editor since previousDOM was built. If provided, and the * edits are not structural, DOMUpdater will do a fast incremental reparse. If not provided, * or if one of the edits changes the DOM structure, DOMUpdater will reparse the whole DOM. @@ -265,16 +269,17 @@ define(function (require, exports, module) { } // If there's more than one change, be conservative and assume we have to do a full reparse. - if (changeList && !changeList.next) { + if (changeList && changeList.length === 1) { // If the inserted or removed text doesn't have any characters that could change the // structure of the DOM (e.g. by adding or removing a tag boundary), then we can do // an incremental reparse of just the parent tag containing the edit. This should just // be the marked range that contains the beginning of the edit range, since that position // isn't changed by the edit. - if (!isDangerousEdit(changeList.text) && !isDangerousEdit(changeList.removed)) { + var change = changeList[0]; + if (!isDangerousEdit(change.text) && !isDangerousEdit(change.removed)) { // If the edit is right at the beginning or end of a tag, we want to be conservative // and use the parent as the edit range. - var startMark = _getMarkerAtDocumentPos(editor, changeList.from, true); + var startMark = _getMarkerAtDocumentPos(editor, change.from, true); if (startMark) { var range = startMark.find(); if (range) { diff --git a/src/language/HTMLSimpleDOM.js b/src/language/HTMLSimpleDOM.js index 17aecfa15c8..2cc429e9784 100644 --- a/src/language/HTMLSimpleDOM.js +++ b/src/language/HTMLSimpleDOM.js @@ -23,7 +23,7 @@ /*jslint vars: true, plusplus: true, devel: true, nomen: true, indent: 4, maxerr: 50 */ -/*global define, $, CodeMirror */ +/*global define, $ */ /*unittests: HTML Instrumentation*/ define(function (require, exports, module) { diff --git a/src/language/HTMLTokenizer.js b/src/language/HTMLTokenizer.js index 4069cb9195c..2244d1d626a 100644 --- a/src/language/HTMLTokenizer.js +++ b/src/language/HTMLTokenizer.js @@ -25,7 +25,7 @@ // (MIT-licensed), but with significant customizations for use in HTML live development. /*jslint vars: true, plusplus: true, devel: true, nomen: true, indent: 4, maxerr: 50, continue: true */ -/*global define, $, CodeMirror */ +/*global define, $ */ /*unittests: HTML Tokenizer*/ define(function (require, exports, module) { diff --git a/src/language/HTMLUtils.js b/src/language/HTMLUtils.js index fd10be9b849..fc2c03e883b 100644 --- a/src/language/HTMLUtils.js +++ b/src/language/HTMLUtils.js @@ -23,12 +23,13 @@ /*jslint vars: true, plusplus: true, devel: true, nomen: true, indent: 4, maxerr: 50 */ -/*global define, $, CodeMirror */ +/*global define, $ */ define(function (require, exports, module) { "use strict"; - var TokenUtils = require("utils/TokenUtils"); + var CodeMirror = require("thirdparty/CodeMirror2/lib/codemirror"), + TokenUtils = require("utils/TokenUtils"); //constants var TAG_NAME = "tagName", @@ -292,7 +293,7 @@ define(function (require, exports, module) { * className:string string:""open-files-disclosure-arrow"" * className:tag string:">" * @param {Editor} editor An instance of a Brackets editor - * @param {{ch: number, line: number}} constPos A CM pos (likely from editor.getCursor()) + * @param {{ch: number, line: number}} constPos A CM pos (likely from editor.getCursorPos()) * @return {{tagName:string, * attr:{name:string, value:string, valueAssigned:boolean, quoteChar:string, hasEndQuote:boolean}, * position:{tokenType:string, offset:number} diff --git a/src/language/JSUtils.js b/src/language/JSUtils.js index 8d76c4dc2e1..2f626ae27f7 100644 --- a/src/language/JSUtils.js +++ b/src/language/JSUtils.js @@ -22,7 +22,7 @@ */ /*jslint vars: true, plusplus: true, devel: true, nomen: true, regexp: true, indent: 4, maxerr: 50 */ -/*global define, $, brackets, CodeMirror */ +/*global define, $, brackets */ /** * Set of utilities for simple parsing of JS text. @@ -33,7 +33,8 @@ define(function (require, exports, module) { var _ = require("thirdparty/lodash"); // Load brackets modules - var Async = require("utils/Async"), + var CodeMirror = require("thirdparty/CodeMirror2/lib/codemirror"), + Async = require("utils/Async"), DocumentManager = require("document/DocumentManager"), ChangedDocumentTracker = require("document/ChangedDocumentTracker"), FileSystem = require("filesystem/FileSystem"), diff --git a/src/language/LanguageManager.js b/src/language/LanguageManager.js index d8123fc7d40..5b06397b57f 100644 --- a/src/language/LanguageManager.js +++ b/src/language/LanguageManager.js @@ -23,7 +23,7 @@ /*jslint vars: true, plusplus: true, devel: true, nomen: true, indent: 4, maxerr: 50 */ -/*global define, $, CodeMirror */ +/*global define, $ */ /** * LanguageManager provides access to the languages supported by Brackets @@ -114,7 +114,8 @@ define(function (require, exports, module) { // Dependencies - var Async = require("utils/Async"), + var CodeMirror = require("thirdparty/CodeMirror2/lib/codemirror"), + Async = require("utils/Async"), FileUtils = require("file/FileUtils"), _defaultLanguagesJSON = require("text!language/languages.json"); diff --git a/src/nls/root/strings.js b/src/nls/root/strings.js index 6d27096eb19..ef6d3213d29 100644 --- a/src/nls/root/strings.js +++ b/src/nls/root/strings.js @@ -202,6 +202,7 @@ define({ "STATUSBAR_SELECTION_CH_PLURAL" : " \u2014 Selected {0} columns", "STATUSBAR_SELECTION_LINE_SINGULAR" : " \u2014 Selected {0} line", "STATUSBAR_SELECTION_LINE_PLURAL" : " \u2014 Selected {0} lines", + "STATUSBAR_SELECTION_MULTIPLE" : " \u2014 {0} selections", "STATUSBAR_INDENT_TOOLTIP_SPACES" : "Click to switch indentation to spaces", "STATUSBAR_INDENT_TOOLTIP_TABS" : "Click to switch indentation to tabs", "STATUSBAR_INDENT_SIZE_TOOLTIP_SPACES" : "Click to change number of spaces used when indenting", @@ -267,12 +268,18 @@ define({ "CMD_PASTE" : "Paste", "CMD_SELECT_ALL" : "Select All", "CMD_SELECT_LINE" : "Select Line", + "CMD_SPLIT_SEL_INTO_LINES" : "Split Selection into Lines", + "CMD_ADD_NEXT_LINE_TO_SEL" : "Add Next Line to Selection", + "CMD_ADD_PREV_LINE_TO_SEL" : "Add Previous Line to Selection", "CMD_FIND" : "Find", "CMD_FIND_FIELD_PLACEHOLDER" : "Find\u2026", "CMD_FIND_IN_FILES" : "Find in Files", "CMD_FIND_IN_SUBTREE" : "Find in\u2026", "CMD_FIND_NEXT" : "Find Next", "CMD_FIND_PREVIOUS" : "Find Previous", + "CMD_FIND_ALL_AND_SELECT" : "Find All and Select", + "CMD_ADD_NEXT_MATCH" : "Add Next Match to Selection", + "CMD_SKIP_CURRENT_MATCH" : "Skip and Add Next Match", "CMD_REPLACE" : "Replace", "CMD_INDENT" : "Indent", "CMD_UNINDENT" : "Unindent", diff --git a/src/search/FindInFiles.js b/src/search/FindInFiles.js index 7021995d52d..a40c011f21f 100644 --- a/src/search/FindInFiles.js +++ b/src/search/FindInFiles.js @@ -612,89 +612,90 @@ define(function (require, exports, module) { * @private * Update the search results using the given list of changes fr the given document * @param {Document} doc The Document that changed, should be the current one - * @param {{from: {line:number,ch:number}, to: {line:number,ch:number}, text: string, next: change}} change - * A linked list as described in the Document constructor - * @param {boolean} resultsChanged True when the search results changed from a file change + * @param {Array.<{from: {line:number,ch:number}, to: {line:number,ch:number}, text: string, next: change}>} changeList + * An array of changes as described in the Document constructor + * @return {boolean} True when the search results changed from a file change */ - function _updateSearchResults(doc, change, resultsChanged) { + function _updateSearchResults(doc, changeList) { var i, diff, matches, + resultsChanged = false, fullPath = doc.file.fullPath, - lines = [], - start = 0, - howMany = 0; - - // There is no from or to positions, so the entire file changed, we must search all over again - if (!change.from || !change.to) { - _addSearchMatches(fullPath, doc.getText(), currentQueryExpr); - resultsChanged = true; + lines, start, howMany; - } else { - // Get only the lines that changed - for (i = 0; i < change.text.length; i++) { - lines.push(doc.getLine(change.from.line + i)); - } - - // We need to know how many lines changed to update the rest of the lines - if (change.from.line !== change.to.line) { - diff = change.from.line - change.to.line; + changeList.forEach(function (change) { + lines = []; + start = 0; + howMany = 0; + + // There is no from or to positions, so the entire file changed, we must search all over again + if (!change.from || !change.to) { + _addSearchMatches(fullPath, doc.getText(), currentQueryExpr); + resultsChanged = true; + } else { - diff = lines.length - 1; - } - - if (searchResults[fullPath]) { - // Search the last match before a replacement, the amount of matches deleted and update - // the lines values for all the matches after the change - searchResults[fullPath].matches.forEach(function (item) { - if (item.end.line < change.from.line) { - start++; - } else if (item.end.line <= change.to.line) { - howMany++; + // Get only the lines that changed + for (i = 0; i < change.text.length; i++) { + lines.push(doc.getLine(change.from.line + i)); + } + + // We need to know how many lines changed to update the rest of the lines + if (change.from.line !== change.to.line) { + diff = change.from.line - change.to.line; + } else { + diff = lines.length - 1; + } + + if (searchResults[fullPath]) { + // Search the last match before a replacement, the amount of matches deleted and update + // the lines values for all the matches after the change + searchResults[fullPath].matches.forEach(function (item) { + if (item.end.line < change.from.line) { + start++; + } else if (item.end.line <= change.to.line) { + howMany++; + } else { + item.start.line += diff; + item.end.line += diff; + } + }); + + // Delete the lines that where deleted or replaced + if (howMany > 0) { + searchResults[fullPath].matches.splice(start, howMany); + } + resultsChanged = true; + } + + // Searches only over the lines that changed + matches = _getSearchMatches(lines.join("\r\n"), currentQueryExpr); + if (matches && matches.length) { + // Updates the line numbers, since we only searched part of the file + matches.forEach(function (value, key) { + matches[key].start.line += change.from.line; + matches[key].end.line += change.from.line; + }); + + // If the file index exists, add the new matches to the file at the start index found before + if (searchResults[fullPath]) { + Array.prototype.splice.apply(searchResults[fullPath].matches, [start, 0].concat(matches)); + // If not, add the matches to a new file index } else { - item.start.line += diff; - item.end.line += diff; + searchResults[fullPath] = { + matches: matches, + collapsed: false + }; } - }); - - // Delete the lines that where deleted or replaced - if (howMany > 0) { - searchResults[fullPath].matches.splice(start, howMany); + resultsChanged = true; } - resultsChanged = true; - } - - // Searches only over the lines that changed - matches = _getSearchMatches(lines.join("\r\n"), currentQueryExpr); - if (matches && matches.length) { - // Updates the line numbers, since we only searched part of the file - matches.forEach(function (value, key) { - matches[key].start.line += change.from.line; - matches[key].end.line += change.from.line; - }); - - // If the file index exists, add the new matches to the file at the start index found before - if (searchResults[fullPath]) { - Array.prototype.splice.apply(searchResults[fullPath].matches, [start, 0].concat(matches)); - // If not, add the matches to a new file index - } else { - searchResults[fullPath] = { - matches: matches, - collapsed: false - }; + + // All the matches where deleted, remove the file from the results + if (searchResults[fullPath] && !searchResults[fullPath].matches.length) { + delete searchResults[fullPath]; + resultsChanged = true; } - resultsChanged = true; - } - - // All the matches where deleted, remove the file from the results - if (searchResults[fullPath] && !searchResults[fullPath].matches.length) { - delete searchResults[fullPath]; - resultsChanged = true; - } - - // This is link to the next change object, so we need to keep searching - if (change.next) { - return _updateSearchResults(doc, change.next, resultsChanged); } - } + }); + return resultsChanged; } diff --git a/src/search/FindReplace.js b/src/search/FindReplace.js index 4cfb8cca485..14d66dae749 100644 --- a/src/search/FindReplace.js +++ b/src/search/FindReplace.js @@ -50,7 +50,9 @@ define(function (require, exports, module) { Resizer = require("utils/Resizer"), StatusBar = require("widgets/StatusBar"), PreferencesManager = require("preferences/PreferencesManager"), - ViewUtils = require("utils/ViewUtils"); + ViewUtils = require("utils/ViewUtils"), + _ = require("thirdparty/lodash"), + CodeMirror = require("thirdparty/CodeMirror2/lib/CodeMirror"); var searchBarTemplate = require("text!htmlContent/findreplace-bar.html"), searchReplacePanelTemplate = require("text!htmlContent/search-replace-panel.html"), @@ -152,6 +154,235 @@ define(function (require, exports, module) { return replaceWith; } + /** + * @private + * Returns the next match for the current query (from the search state) before/after the given position. Wraps around + * the end of the document if no match is found before the end. + * + * @param {!Editor} editor The editor to search in + * @param {boolean} rev True to search backwards + * @param {{line: number, ch: number}=} pos The position to start from. Defaults to the current primary selection's + * head cursor position. + * @param {boolean=} wrap Whether to wrap the search around if we hit the end of the document. Default true. + * @return {?{start: {line: number, ch: number}, end: {line: number, ch: number}}} The range for the next match, or + * null if there is no match. + */ + function _getNextMatch(editor, rev, pos, wrap) { + var cm = editor._codeMirror; + var state = getSearchState(cm); + var cursor = getSearchCursor(cm, state.query, pos || editor.getCursorPos(false, rev ? "start" : "end")); + + state.lastMatch = cursor.find(rev); + if (!state.lastMatch && wrap !== false) { + // If no result found before hitting edge of file, try wrapping around + cursor = getSearchCursor(cm, state.query, rev ? {line: cm.lineCount() - 1} : {line: 0, ch: 0}); + state.lastMatch = cursor.find(rev); + } + if (!state.lastMatch) { + // No result found, period: clear selection & bail + cm.setCursor(editor.getCursorPos()); // collapses selection, keeping cursor in place to avoid scrolling + return null; + } + + return {start: cursor.from(), end: cursor.to()}; + } + + /** + * @private + * Sets the given selections in the editor and applies some heuristics to determine whether and how we should + * center the primary selection. + * + * @param {!Editor} editor The editor to search in + * @param {!Array<{start:{line:number, ch:number}, end:{line:number, ch:number}, primary:boolean, reversed: boolean}>} selections + * The selections to set. Must not be empty. + * @param {boolean} center Whether to try to center the primary selection vertically on the screen. If false, the selection will still be scrolled + * into view if it's offscreen, but will not be centered. + * @param {boolean=} preferNoScroll If center is true, whether to avoid scrolling if the hit is in the top half of the screen. Default false. + */ + function _selectAndScrollTo(editor, selections, center, preferNoScroll) { + var primarySelection = _.find(selections, function (sel) { return sel.primary; }) || _.last(selections), + resultVisible = editor.isLineVisible(primarySelection.start.line), + centerOptions = Editor.BOUNDARY_CHECK_NORMAL; + + if (preferNoScroll && resultVisible) { + // no need to scroll if the line with the match is in view + centerOptions = Editor.BOUNDARY_IGNORE_TOP; + } + + // Make sure the primary selection is fully visible on screen. + var primary = _.find(selections, function (sel) { + return sel.primary; + }); + if (!primary) { + primary = _.last(selections); + } + editor._codeMirror.scrollIntoView({from: primary.start, to: primary.end}); + editor.setSelections(selections, center, centerOptions); + } + + /** + * Returns the range of the word surrounding the given editor position. Similar to getWordAt() from CodeMirror. + * + * @param {!Editor} editor The editor to search in + * @param {!{line: number, ch: number}} pos The position to find a word at. + * @return {{start:{line: number, ch: number}, end:{line:number, ch:number}, text:string}} The range and content of the found word. If + * there is no word, start will equal end and the text will be the empty string. + */ + function _getWordAt(editor, pos) { + var cm = editor._codeMirror, + start = pos.ch, + end = start, + line = cm.getLine(pos.line); + while (start && CodeMirror.isWordChar(line.charAt(start - 1))) { + --start; + } + while (end < line.length && CodeMirror.isWordChar(line.charAt(end))) { + ++end; + } + return {start: {line: pos.line, ch: start}, end: {line: pos.line, ch: end}, text: line.slice(start, end)}; + } + + /** + * @private + * Helper function. Returns true if two selections are equal. + * @param {!{start: {line: number, ch: number}, end: {line: number, ch: number}}} sel1 The first selection to compare + * @param {!{start: {line: number, ch: number}, end: {line: number, ch: number}}} sel2 The second selection to compare + * @return {boolean} true if the selections are equal + */ + function _selEq(sel1, sel2) { + return (CodeMirror.cmpPos(sel1.start, sel2.start) === 0 && CodeMirror.cmpPos(sel1.end, sel2.end) === 0); + } + + /** + * Expands each empty range in the selection to the nearest word boundaries. Then, if the primary selection + * was already a range (even a non-word range), adds the next instance of the contents of that range as a selection. + * + * @param {!Editor} editor The editor to search in + * @param {boolean=} removePrimary Whether to remove the current primary selection in addition to adding the + * next one. If true, we add the next match even if the current primary selection is a cursor (we expand it + * first to determine what to match). + */ + function _expandWordAndAddNextToSelection(editor, removePrimary) { + editor = editor || EditorManager.getActiveEditor(); + if (!editor) { + return; + } + + var selections = editor.getSelections(), + primarySel, + primaryIndex, + searchText, + added = false; + + _.each(selections, function (sel, index) { + var isEmpty = (CodeMirror.cmpPos(sel.start, sel.end) === 0); + if (sel.primary) { + primarySel = sel; + primaryIndex = index; + if (!isEmpty) { + searchText = editor.document.getRange(primarySel.start, primarySel.end); + } + } + if (isEmpty) { + var wordInfo = _getWordAt(editor, sel.start); + sel.start = wordInfo.start; + sel.end = wordInfo.end; + if (sel.primary && removePrimary) { + // Get the expanded text, even though we're going to remove this selection, + // since in this case we still want to select the next match. + searchText = wordInfo.text; + } + } + }); + + if (searchText && searchText.length) { + // We store this as a query in the state so that if the user next does a "Find Next", + // it will use the same query (but throw away the existing selection). + var state = getSearchState(editor._codeMirror); + state.query = searchText; + + // Skip over matches that are already in the selection. + var searchStart = primarySel.end, + nextMatch, + isInSelection; + do { + nextMatch = _getNextMatch(editor, false, searchStart); + if (nextMatch) { + // This is a little silly, but if we just stick the equivalence test function in here + // JSLint complains about creating a function in a loop, even though it's safe in this case. + isInSelection = _.find(selections, _.partial(_selEq, nextMatch)); + searchStart = nextMatch.end; + + // If we've gone all the way around, then all instances must have been selected already. + if (CodeMirror.cmpPos(searchStart, primarySel.end) === 0) { + nextMatch = null; + break; + } + } + } while (nextMatch && isInSelection); + + if (nextMatch) { + nextMatch.primary = true; + selections.push(nextMatch); + added = true; + } + } + + if (removePrimary) { + selections.splice(primaryIndex, 1); + } + + if (added) { + // Center the new match, but avoid scrolling to matches that are already on screen. + _selectAndScrollTo(editor, selections, true, true); + } else { + // If all we did was expand some selections, don't center anything. + _selectAndScrollTo(editor, selections, false); + } + } + + function _skipCurrentMatch(editor) { + return _expandWordAndAddNextToSelection(editor, true); + } + + /** + * Takes the primary selection, expands it to a word range if necessary, then sets the selection to + * include all instances of that range. Removes all other selections. Does nothing if the selection + * is not a range after expansion. + */ + function _findAllAndSelect(editor) { + editor = editor || EditorManager.getActiveEditor(); + if (!editor) { + return; + } + + var sel = editor.getSelection(), + newSelections = []; + if (CodeMirror.cmpPos(sel.start, sel.end) === 0) { + sel = _getWordAt(editor, sel.start); + } + if (CodeMirror.cmpPos(sel.start, sel.end) !== 0) { + var searchStart = {line: 0, ch: 0}, + state = getSearchState(editor._codeMirror), + nextMatch; + state.query = editor.document.getRange(sel.start, sel.end); + + while ((nextMatch = _getNextMatch(editor, false, searchStart, false)) !== null) { + if (_selEq(sel, nextMatch)) { + nextMatch.primary = true; + } + newSelections.push(nextMatch); + searchStart = nextMatch.end; + } + + // This should find at least the original selection, but just in case... + if (newSelections.length) { + // Don't change the scroll position. + editor.setSelections(newSelections, false); + } + } + } + /** * Selects the next match (or prev match, if rev==true) starting from either the current position * (if pos unspecified) or the given position (if pos specified explicitly). The starting position @@ -166,31 +397,12 @@ define(function (require, exports, module) { function findNext(editor, rev, preferNoScroll, pos) { var cm = editor._codeMirror; cm.operation(function () { - var state = getSearchState(cm); - var cursor = getSearchCursor(cm, state.query, pos || cm.getCursor(Boolean(rev))); // null and false mean different things to getCursor() - - state.lastMatch = cursor.find(rev); - if (!state.lastMatch) { - // If no result found before hitting edge of file, try wrapping around - cursor = getSearchCursor(cm, state.query, rev ? {line: cm.lineCount() - 1} : {line: 0, ch: 0}); - state.lastMatch = cursor.find(rev); - - if (!state.lastMatch) { - // No result found, period: clear selection & bail - cm.setCursor(cm.getCursor()); // collapses selection, keeping cursor in place to avoid scrolling - return; - } - } - - var resultVisible = editor.isLineVisible(cursor.from().line), - centerOptions = Editor.BOUNDARY_CHECK_NORMAL; - - if (preferNoScroll && resultVisible) { - // no need to scroll if the line with the match is in view - centerOptions = Editor.BOUNDARY_IGNORE_TOP; + var nextMatch = _getNextMatch(editor, rev, pos); + if (nextMatch) { + _selectAndScrollTo(editor, [nextMatch], true, preferNoScroll); + } else { + cm.setCursor(editor.getCursorPos()); // collapses selection, keeping cursor in place to avoid scrolling } - cm.scrollIntoView({from: cursor.from(), to: cursor.to()}); - editor.setSelection(cursor.from(), cursor.to(), true, centerOptions); }); } @@ -345,8 +557,12 @@ define(function (require, exports, module) { * Called each time the search query field changes. Updates state.query (query will be falsy if the field * was blank OR contained a regexp with invalid syntax). Then calls updateResultSet(), and then jumps to * the first matching result, starting from the original cursor position. + * @param {!Editor} editor The editor we're searching in. + * @param {Object} state The current query state. + * @param {boolean} initial Whether this is the initial population of the query when the search bar opens. + * In that case, we don't want to change the selection unnecessarily. */ - function handleQueryChange(editor, state) { + function handleQueryChange(editor, state, initial) { state.query = parseQuery($("#find-what").val()); updateResultSet(editor); @@ -354,7 +570,7 @@ define(function (require, exports, module) { // 3rd arg: prefer to avoid scrolling if result is anywhere within view, since in this case user // is in the middle of typing, not navigating explicitly; viewport jumping would be distracting. findNext(editor, false, true, state.searchStartPos); - } else { + } else if (!initial) { // Blank or invalid query: just jump back to initial pos editor._codeMirror.setCursor(state.searchStartPos); } @@ -375,7 +591,7 @@ define(function (require, exports, module) { // start with a pre-populated search and enter an additional character, // it will extend the initial selection instead of jumping to the next // occurrence. - state.searchStartPos = cm.getCursor(true); + state.searchStartPos = editor.getCursorPos(false, "start"); // If a previous search/replace bar was open, capture its query text for use below var initialQuery; @@ -429,8 +645,9 @@ define(function (require, exports, module) { // Prepopulate the search field if (!initialQuery) { - // Prepopulate with the current selection, if any - initialQuery = cm.getSelection(); + // Prepopulate with the current primary selection, if any + var sel = editor.getSelection(); + initialQuery = cm.getRange(sel.start, sel.end); // Eliminate newlines since we don't generally support searching across line boundaries (#2960) var newline = initialQuery.indexOf("\n"); @@ -445,7 +662,7 @@ define(function (require, exports, module) { .get(0).select(); _updateSearchBarFromPrefs(); - handleQueryChange(editor, state); + handleQueryChange(editor, state, true); } /** @@ -695,14 +912,22 @@ define(function (require, exports, module) { $(DocumentManager).on("currentDocumentChange", _handleDocumentChange); - CommandManager.register(Strings.CMD_FIND, Commands.EDIT_FIND, _launchFind); - CommandManager.register(Strings.CMD_FIND_NEXT, Commands.EDIT_FIND_NEXT, _findNext); - CommandManager.register(Strings.CMD_REPLACE, Commands.EDIT_REPLACE, _replace); - CommandManager.register(Strings.CMD_FIND_PREVIOUS, Commands.EDIT_FIND_PREVIOUS, _findPrevious); + CommandManager.register(Strings.CMD_FIND, Commands.EDIT_FIND, _launchFind); + CommandManager.register(Strings.CMD_FIND_NEXT, Commands.EDIT_FIND_NEXT, _findNext); + CommandManager.register(Strings.CMD_REPLACE, Commands.EDIT_REPLACE, _replace); + CommandManager.register(Strings.CMD_FIND_PREVIOUS, Commands.EDIT_FIND_PREVIOUS, _findPrevious); + CommandManager.register(Strings.CMD_FIND_ALL_AND_SELECT, Commands.EDIT_FIND_ALL_AND_SELECT, _findAllAndSelect); + CommandManager.register(Strings.CMD_ADD_NEXT_MATCH, Commands.EDIT_ADD_NEXT_MATCH, _expandWordAndAddNextToSelection); + CommandManager.register(Strings.CMD_SKIP_CURRENT_MATCH, Commands.EDIT_SKIP_CURRENT_MATCH, _skipCurrentMatch); // APIs shared with FindInFiles - exports._updatePrefsFromSearchBar = _updatePrefsFromSearchBar; - exports._updateSearchBarFromPrefs = _updateSearchBarFromPrefs; - exports._closeFindBar = _closeFindBar; - exports._registerFindInFilesCloser = _registerFindInFilesCloser; + exports._updatePrefsFromSearchBar = _updatePrefsFromSearchBar; + exports._updateSearchBarFromPrefs = _updateSearchBarFromPrefs; + exports._closeFindBar = _closeFindBar; + exports._registerFindInFilesCloser = _registerFindInFilesCloser; + + // For unit testing + exports._getWordAt = _getWordAt; + exports._expandWordAndAddNextToSelection = _expandWordAndAddNextToSelection; + exports._findAllAndSelect = _findAllAndSelect; }); diff --git a/src/search/QuickOpen.js b/src/search/QuickOpen.js index 6a18b58acdc..be6de41abc0 100644 --- a/src/search/QuickOpen.js +++ b/src/search/QuickOpen.js @@ -222,12 +222,12 @@ define(function (require, exports, module) { /** * @private - * Remembers the selection in origDocPath that was present when showDialog() was called. Focusing on an + * Remembers the selection state in origDocPath that was present when showDialog() was called. Focusing on an * item can change the selection; we restore this original selection if the user presses Escape. Null if * no document was open when Quick Open was invoked. - * @type {?{start:{line:number, ch:number}, end:{line:number, ch:number}}} + * @type {?Array.<{{start:{line:number, ch:number}, end:{line:number, ch:number}, primary:boolean, reversed:boolean}}>} */ - QuickNavigateDialog.prototype._origSelection = null; + QuickNavigateDialog.prototype._origSelections = null; /** * @private @@ -406,7 +406,7 @@ define(function (require, exports, module) { setTimeout(function () { if (e.keyCode === KeyEvent.DOM_VK_ESCAPE) { // Restore original selection / scroll pos - self.close(self._origScrollPos, self._origSelection); + self.close(self._origScrollPos, self._origSelections); } else if (e.keyCode === KeyEvent.DOM_VK_RETURN) { self._handleItemSelect(null, $(".smart_autocomplete_highlight").get(0)); // calls close() too } @@ -462,11 +462,11 @@ define(function (require, exports, module) { * searching is done. * @param {{x: number, y: number}=} scrollPos If specified, scroll to the given * position when closing the ModalBar. - * @param {{start: {line: number, ch: number}, end: {line: number, ch: number}} selection If specified, - * restore the given selection when closing the ModalBar. + * @param Array.<{{start:{line:number, ch:number}, end:{line:number, ch:number}, primary:boolean, reversed:boolean}}> + * selections If specified, restore the given selections when closing the ModalBar. * @return {$.Promise} Resolved when the search bar is entirely closed. */ - QuickNavigateDialog.prototype.close = function (scrollPos, selection) { + QuickNavigateDialog.prototype.close = function (scrollPos, selections) { if (!this.isOpen) { return this._closeDeferred.promise(); } @@ -503,8 +503,8 @@ define(function (require, exports, module) { // `ModalBar.close()` (before the animation completes). // See description of `restoreScrollPos` in `ModalBar.close()`. var editor = EditorManager.getCurrentFullEditor(); - if (selection) { - editor.setSelection(selection.start, selection.end); + if (selections) { + editor.setSelections(selections); } if (scrollPos) { editor.setScrollPos(scrollPos.x, scrollPos.y); @@ -816,10 +816,10 @@ define(function (require, exports, module) { var curDoc = DocumentManager.getCurrentDocument(); this._origDocPath = curDoc ? curDoc.file.fullPath : null; if (curDoc) { - this._origSelection = EditorManager.getCurrentFullEditor().getSelection(); + this._origSelections = EditorManager.getCurrentFullEditor().getSelections(); this._origScrollPos = EditorManager.getCurrentFullEditor().getScrollPos(); } else { - this._origSelection = null; + this._origSelections = null; this._origScrollPos = null; } diff --git a/src/styles/brackets_codemirror_override.less b/src/styles/brackets_codemirror_override.less index d0322229b2b..9750b2f06d9 100644 --- a/src/styles/brackets_codemirror_override.less +++ b/src/styles/brackets_codemirror_override.less @@ -93,8 +93,10 @@ span.CodeMirror-matchingbracket {color: @accent-bracket !important; background-color: @matching-bracket;} span.CodeMirror-nonmatchingbracket {color: @accent-bracket !important;} - .CodeMirror-cursor { - .code-cursor(); + .CodeMirror-cursors { + .CodeMirror-cursor { + .code-cursor(); + } /* Ensure the cursor shows up in front of code spans with a background color * (e.g. matchingbracket). @@ -177,7 +179,7 @@ other than a vanilla .CodeMirror) */ .CodeMirror { - div.CodeMirror-cursor.CodeMirror-overwrite { + div.CodeMirror-overwrite div.CodeMirror-cursor { border-left: none !important; border-bottom: 1px solid black !important; width: 1.2ex; @@ -193,10 +195,10 @@ color: @accent-bracket !important; } - .CodeMirror .CodeMirror-cursor { + .CodeMirror .CodeMirror-cursors { visibility: hidden; } - .CodeMirror.CodeMirror-focused .CodeMirror-cursor { + .CodeMirror.CodeMirror-focused .CodeMirror-cursors { visibility: visible; } diff --git a/src/thirdparty/CodeMirror2 b/src/thirdparty/CodeMirror2 index 5581979d2c6..37d31d53b69 160000 --- a/src/thirdparty/CodeMirror2 +++ b/src/thirdparty/CodeMirror2 @@ -1 +1 @@ -Subproject commit 5581979d2c624d6c32342e50bb417749ad799ee7 +Subproject commit 37d31d53b699bdfe92ffa5142c2c311be4882c6c diff --git a/src/utils/ExtensionLoader.js b/src/utils/ExtensionLoader.js index 08fa379f7ea..0d87c93be1d 100644 --- a/src/utils/ExtensionLoader.js +++ b/src/utils/ExtensionLoader.js @@ -23,7 +23,7 @@ /*jslint vars: true, plusplus: true, devel: true, nomen: true, indent: 4, maxerr: 50 */ -/*global define, $, CodeMirror, brackets, window */ +/*global define, $, brackets, window */ /** * ExtensionLoader searches the filesystem for extensions, then creates a new context for each one and loads it. diff --git a/src/utils/TokenUtils.js b/src/utils/TokenUtils.js index 047e552dd5e..ec12ed726d3 100644 --- a/src/utils/TokenUtils.js +++ b/src/utils/TokenUtils.js @@ -23,7 +23,7 @@ /*jslint vars: true, plusplus: true, devel: true, nomen: true, indent: 4, maxerr: 50 */ -/*global define, $, CodeMirror */ +/*global define, $ */ /** * Functions for iterating through tokens in the current editor buffer. Useful for doing @@ -33,6 +33,8 @@ define(function (require, exports, module) { "use strict"; + var CodeMirror = require("thirdparty/CodeMirror2/lib/codemirror"); + /** * Creates a context object for the given editor and position, suitable for passing to the * move functions. diff --git a/test/SpecRunner.html b/test/SpecRunner.html index 568eae7d9ba..5f376c40012 100644 --- a/test/SpecRunner.html +++ b/test/SpecRunner.html @@ -36,11 +36,6 @@ - - - - - diff --git a/test/SpecRunner.js b/test/SpecRunner.js index af121a0511e..a979c0e20c4 100644 --- a/test/SpecRunner.js +++ b/test/SpecRunner.js @@ -79,6 +79,18 @@ define(function (require, exports, module) { // Load JUnitXMLReporter require("test/thirdparty/jasmine-reporters/jasmine.junit_reporter"); + // Load CodeMirror add-ons--these attach themselves to the CodeMirror module + require("thirdparty/CodeMirror2/addon/fold/xml-fold"); + require("thirdparty/CodeMirror2/addon/edit/matchtags"); + require("thirdparty/CodeMirror2/addon/edit/matchbrackets"); + require("thirdparty/CodeMirror2/addon/edit/closebrackets"); + require("thirdparty/CodeMirror2/addon/edit/closetag"); + require("thirdparty/CodeMirror2/addon/selection/active-line"); + require("thirdparty/CodeMirror2/addon/mode/multiplex"); + require("thirdparty/CodeMirror2/addon/mode/overlay"); + require("thirdparty/CodeMirror2/addon/search/searchcursor"); + require("thirdparty/CodeMirror2/keymap/sublime"); + var selectedSuites, params = new UrlParams(), reporter, diff --git a/test/perf/OpenFile-perf-files/InlineWidget.js b/test/perf/OpenFile-perf-files/InlineWidget.js index 3a9dcb7e7ec..2a2395c14fa 100644 --- a/test/perf/OpenFile-perf-files/InlineWidget.js +++ b/test/perf/OpenFile-perf-files/InlineWidget.js @@ -23,7 +23,7 @@ /*jslint vars: true, plusplus: true, devel: true, nomen: true, indent: 4, maxerr: 50 */ -/*global define, $, CodeMirror, window */ +/*global define, $, window */ define(function (require, exports, module) { "use strict"; diff --git a/test/spec/CSSUtils-test.js b/test/spec/CSSUtils-test.js index 06a81211c31..2f15383520b 100644 --- a/test/spec/CSSUtils-test.js +++ b/test/spec/CSSUtils-test.js @@ -22,7 +22,7 @@ */ /*jslint vars: true, plusplus: true, devel: true, browser: true, nomen: true, indent: 4, maxerr: 50 */ -/*global define, describe, xdescribe, it, xit, expect, beforeEach, afterEach, waitsFor, runs, $, CodeMirror, beforeFirst, afterLast */ +/*global define, describe, xdescribe, it, xit, expect, beforeEach, afterEach, waitsFor, runs, $, beforeFirst, afterLast */ define(function (require, exports, module) { "use strict"; diff --git a/test/spec/CodeHintUtils-test.js b/test/spec/CodeHintUtils-test.js index e03742f9bc0..8c1dfd0df9d 100644 --- a/test/spec/CodeHintUtils-test.js +++ b/test/spec/CodeHintUtils-test.js @@ -23,7 +23,7 @@ /*jslint vars: true, plusplus: true, devel: true, browser: true, nomen: true, indent: 4, maxerr: 50 */ -/*global define: false, describe: false, beforeEach: false, afterEach: false, it: false, runs: false, waitsFor: false, expect: false, $: false, CodeMirror: false */ +/*global define: false, describe: false, beforeEach: false, afterEach: false, it: false, runs: false, waitsFor: false, expect: false, $: false */ define(function (require, exports, module) { 'use strict'; diff --git a/test/spec/Document-test.js b/test/spec/Document-test.js index da1c42a3896..6fe3db6faa6 100644 --- a/test/spec/Document-test.js +++ b/test/spec/Document-test.js @@ -23,7 +23,7 @@ /*jslint vars: true, plusplus: true, devel: true, browser: true, nomen: true, indent: 4, maxerr: 50 */ -/*global define, $, jasmine, describe, beforeFirst, afterLast, afterEach, it, runs, waitsFor, expect, waitsForDone */ +/*global define, $, jasmine, describe, beforeFirst, afterLast, beforeEach, afterEach, it, runs, waitsFor, expect, waitsForDone */ define(function (require, exports, module) { 'use strict'; @@ -37,6 +37,180 @@ define(function (require, exports, module) { describe("Document", function () { + describe("doMultipleEdits", function () { + // Even though these are Document unit tests, we need to create an editor in order to + // be able to test actual edit ops. + var myEditor, myDocument, initialContentLines; + + function makeDummyLines(num) { + var content = [], i; + for (i = 0; i < num; i++) { + content.push("this is line " + i); + } + return content; + } + + beforeEach(function () { + // Each line from 0-9 is 14 chars long, each line from 10-19 is 15 chars long + initialContentLines = makeDummyLines(20); + var mocks = SpecRunnerUtils.createMockEditor(initialContentLines.join("\n"), "unknown"); + myDocument = mocks.doc; + myEditor = mocks.editor; + }); + + afterEach(function () { + if (myEditor) { + SpecRunnerUtils.destroyMockEditor(myDocument); + myEditor = null; + myDocument = null; + } + }); + + it("should do a single edit, tracking a beforeEdit selection and preserving reversed flag", function () { + var result = myDocument.doMultipleEdits([{edit: {text: "new content", start: {line: 2, ch: 0}, end: {line: 2, ch: 14}}, + selection: {start: {line: 2, ch: 4}, end: {line: 2, ch: 4}, reversed: true, isBeforeEdit: true}}]); + initialContentLines[2] = "new content"; + expect(myDocument.getText()).toEqual(initialContentLines.join("\n")); + expect(result.length).toBe(1); + expect(result[0].start).toEqual({line: 2, ch: 11}); // end of "new content" + expect(result[0].end).toEqual({line: 2, ch: 11}); + expect(result[0].reversed).toBe(true); + }); + + it("should do a single edit, leaving a non-beforeEdit selection untouched and preserving reversed flag", function () { + var result = myDocument.doMultipleEdits([{edit: {text: "new content", start: {line: 2, ch: 0}, end: {line: 2, ch: 14}}, + selection: {start: {line: 2, ch: 4}, end: {line: 2, ch: 4}, reversed: true}}]); + initialContentLines[2] = "new content"; + expect(myDocument.getText()).toEqual(initialContentLines.join("\n")); + expect(result.length).toBe(1); + expect(result[0].start).toEqual({line: 2, ch: 4}); + expect(result[0].end).toEqual({line: 2, ch: 4}); + expect(result[0].reversed).toBe(true); + }); + + it("should do multiple edits, fixing up isBeforeEdit selections with respect to both edits and preserving other selection attributes", function () { + var result = myDocument.doMultipleEdits([ + {edit: {text: "modified line 2\n", start: {line: 2, ch: 0}, end: {line: 2, ch: 14}}, + selection: {start: {line: 2, ch: 4}, end: {line: 2, ch: 4}, isBeforeEdit: true, primary: true}}, + {edit: {text: "modified line 4\n", start: {line: 4, ch: 0}, end: {line: 4, ch: 14}}, + selection: {start: {line: 4, ch: 4}, end: {line: 4, ch: 4}, isBeforeEdit: true, reversed: true}} + ]); + initialContentLines[2] = "modified line 2"; + initialContentLines[4] = "modified line 4"; + initialContentLines.splice(5, 0, ""); + initialContentLines.splice(3, 0, ""); + expect(myDocument.getText()).toEqual(initialContentLines.join("\n")); + expect(result.length).toBe(2); + expect(result[0].start).toEqual({line: 3, ch: 0}); // pushed to end of modified text + expect(result[0].end).toEqual({line: 3, ch: 0}); + expect(result[0].primary).toBe(true); + expect(result[1].start).toEqual({line: 6, ch: 0}); // pushed to end of modified text and updated for both edits + expect(result[1].end).toEqual({line: 6, ch: 0}); + expect(result[1].reversed).toBe(true); + }); + + it("should do multiple edits, fixing up non-isBeforeEdit selections only with respect to other edits", function () { + var result = myDocument.doMultipleEdits([ + {edit: {text: "modified line 2\n", start: {line: 2, ch: 0}, end: {line: 2, ch: 14}}, + selection: {start: {line: 2, ch: 4}, end: {line: 2, ch: 4}, primary: true}}, + {edit: {text: "modified line 4\n", start: {line: 4, ch: 0}, end: {line: 4, ch: 14}}, + selection: {start: {line: 4, ch: 4}, end: {line: 4, ch: 4}, reversed: true}} + ]); + initialContentLines[2] = "modified line 2"; + initialContentLines[4] = "modified line 4"; + initialContentLines.splice(5, 0, ""); + initialContentLines.splice(3, 0, ""); + expect(myDocument.getText()).toEqual(initialContentLines.join("\n")); + expect(result.length).toBe(2); + expect(result[0].start).toEqual({line: 2, ch: 4}); // not modified since it's above the other edit + expect(result[0].end).toEqual({line: 2, ch: 4}); + expect(result[0].primary).toBe(true); + expect(result[1].start).toEqual({line: 5, ch: 4}); // not pushed to end of modified text, but updated for previous edit + expect(result[1].end).toEqual({line: 5, ch: 4}); + expect(result[1].reversed).toBe(true); + }); + + it("should perform multiple changes/track multiple selections within a single edit, selections specified as isBeforeEdit", function () { + var result = myDocument.doMultipleEdits([ + {edit: [{text: "modified line 1", start: {line: 1, ch: 0}, end: {line: 1, ch: 14}}, + {text: "modified line 2\n", start: {line: 2, ch: 0}, end: {line: 2, ch: 14}}], + selection: [{start: {line: 1, ch: 4}, end: {line: 1, ch: 4}, isBeforeEdit: true}, + {start: {line: 2, ch: 4}, end: {line: 2, ch: 4}, isBeforeEdit: true, primary: true}]}, + {edit: {text: "modified line 4\n", start: {line: 4, ch: 0}, end: {line: 4, ch: 14}}, + selection: {start: {line: 4, ch: 4}, end: {line: 4, ch: 4}, isBeforeEdit: true, reversed: true}} + ]); + initialContentLines[1] = "modified line 1"; // no extra newline inserted here + initialContentLines[2] = "modified line 2"; + initialContentLines[4] = "modified line 4"; + initialContentLines.splice(5, 0, ""); + initialContentLines.splice(3, 0, ""); + expect(myDocument.getText()).toEqual(initialContentLines.join("\n")); + expect(result.length).toBe(3); + expect(result[0].start).toEqual({line: 1, ch: 15}); // pushed to end of first modified text + expect(result[0].end).toEqual({line: 1, ch: 15}); + expect(result[0].primary).toBeFalsy(); + expect(result[1].start).toEqual({line: 3, ch: 0}); // pushed to end of second modified text + expect(result[1].end).toEqual({line: 3, ch: 0}); + expect(result[1].primary).toBe(true); + expect(result[2].start).toEqual({line: 6, ch: 0}); // pushed to end of third modified text and updated for both edits + expect(result[2].end).toEqual({line: 6, ch: 0}); + expect(result[2].reversed).toBe(true); + }); + + it("should perform multiple changes/track multiple selections within a single edit, selections not specified as isBeforeEdit", function () { + var result = myDocument.doMultipleEdits([ + {edit: [{text: "modified line 1", start: {line: 1, ch: 0}, end: {line: 1, ch: 14}}, + {text: "modified line 2\n", start: {line: 2, ch: 0}, end: {line: 2, ch: 14}}], + selection: [{start: {line: 1, ch: 4}, end: {line: 1, ch: 4}}, + {start: {line: 2, ch: 4}, end: {line: 2, ch: 4}, primary: true}]}, + {edit: {text: "modified line 4\n", start: {line: 4, ch: 0}, end: {line: 4, ch: 14}}, + selection: {start: {line: 4, ch: 4}, end: {line: 4, ch: 4}, reversed: true}} + ]); + initialContentLines[1] = "modified line 1"; // no extra newline inserted here + initialContentLines[2] = "modified line 2"; + initialContentLines[4] = "modified line 4"; + initialContentLines.splice(5, 0, ""); + initialContentLines.splice(3, 0, ""); + expect(myDocument.getText()).toEqual(initialContentLines.join("\n")); + expect(result.length).toBe(3); + expect(result[0].start).toEqual({line: 1, ch: 4}); // not fixed up + expect(result[0].end).toEqual({line: 1, ch: 4}); + expect(result[0].primary).toBeFalsy(); + expect(result[1].start).toEqual({line: 2, ch: 4}); // not fixed up, no need to adjust for first edit + expect(result[1].end).toEqual({line: 2, ch: 4}); + expect(result[1].primary).toBe(true); + expect(result[2].start).toEqual({line: 5, ch: 4}); // not pushed to end of modified text, but updated for previous edit + expect(result[2].end).toEqual({line: 5, ch: 4}); + expect(result[2].reversed).toBe(true); + }); + + it("should throw an error if edits overlap", function () { + function shouldDie() { + myDocument.doMultipleEdits([ + {edit: {text: "modified line 3", start: {line: 3, ch: 0}, end: {line: 3, ch: 5}}}, + {edit: {text: "modified line 3 again", start: {line: 3, ch: 3}, end: {line: 3, ch: 8}}} + ]); + } + + expect(shouldDie).toThrow(); + }); + + it("should throw an error if multiple edits in one group surround an edit in another group, even if they don't directly overlap", function () { + function shouldDie() { + myDocument.doMultipleEdits([ + {edit: [{text: "modified line 2", start: {line: 2, ch: 0}, end: {line: 2, ch: 0}}, + {text: "modified line 4", start: {line: 4, ch: 0}, end: {line: 4, ch: 0}}]}, + {edit: {text: "modified line 3", start: {line: 3, ch: 0}, end: {line: 3, ch: 0}}} + ]); + } + + expect(shouldDie).toThrow(); + }); + + }); + }); + + describe("Document Integration", function () { this.category = "integration"; var testPath = SpecRunnerUtils.getTestPath("/spec/Document-test-files"), diff --git a/test/spec/DocumentCommandHandlers-test.js b/test/spec/DocumentCommandHandlers-test.js index b98128f101c..53e94adb90e 100644 --- a/test/spec/DocumentCommandHandlers-test.js +++ b/test/spec/DocumentCommandHandlers-test.js @@ -752,7 +752,10 @@ define(function (require, exports, module) { describe("Save As", function () { var filePath, newFilename, - newFilePath; + newFilePath, + selections = [{start: {line: 0, ch: 1}, end: {line: 0, ch: 3}, primary: false, reversed: false}, + {start: {line: 0, ch: 6}, end: {line: 0, ch: 6}, primary: true, reversed: false}, + {start: {line: 0, ch: 9}, end: {line: 0, ch: 12}, primary: false, reversed: true}]; beforeEach(function () { filePath = testPath + "/test.js"; @@ -768,8 +771,10 @@ define(function (require, exports, module) { }); runs(function () { - var currentDocument = DocumentManager.getCurrentDocument(); + var currentDocument = DocumentManager.getCurrentDocument(), + currentEditor = EditorManager.getActiveEditor(); expect(currentDocument.file.fullPath).toEqual(filePath); + currentEditor.setSelections(selections); }); runs(function () { @@ -782,8 +787,10 @@ define(function (require, exports, module) { }); runs(function () { - var currentDocument = DocumentManager.getCurrentDocument(); + var currentDocument = DocumentManager.getCurrentDocument(), + currentEditor = EditorManager.getActiveEditor(); expect(currentDocument.file.fullPath).toEqual(newFilePath); + expect(currentEditor.getSelections()).toEqual(selections); }); runs(function () { diff --git a/test/spec/Editor-test.js b/test/spec/Editor-test.js index b8eb11f677c..a05ecdcbd2d 100644 --- a/test/spec/Editor-test.js +++ b/test/spec/Editor-test.js @@ -23,7 +23,7 @@ /*jslint vars: true, plusplus: true, devel: true, browser: true, nomen: true, indent: 4, maxerr: 50 */ -/*global define: false, describe: false, it: false, expect: false, beforeEach: false, afterEach: false, waitsFor: false, runs: false, $: false, CodeMirror: false */ +/*global define: false, describe: false, it: false, expect: false, beforeEach: false, afterEach: false, waitsFor: false, runs: false, $: false, jasmine: false */ define(function (require, exports, module) { 'use strict'; @@ -31,7 +31,8 @@ define(function (require, exports, module) { var Editor = require("editor/Editor").Editor, EditorManager = require("editor/EditorManager"), SpecRunnerUtils = require("spec/SpecRunnerUtils"), - LanguageManager = require("language/LanguageManager"); + LanguageManager = require("language/LanguageManager"), + KeyEvent = require("utils/KeyEvent"); var langNames = { css: {mode: "css", langName: "CSS"}, @@ -101,16 +102,37 @@ define(function (require, exports, module) { $(myDocument).off("change", changeHandler); changeFired = true; expect(doc).toBe(myDocument); - expect(changeList.from).toEqual({line: 0, ch: 0}); - expect(changeList.to).toEqual({line: 1, ch: 0}); - expect(changeList.text).toEqual(["new content"]); - expect(changeList.next).toBe(undefined); + expect(changeList.length).toBe(1); + expect(changeList[0].from).toEqual({line: 0, ch: 0}); + expect(changeList[0].to).toEqual({line: 1, ch: 0}); + expect(changeList[0].text).toEqual(["new content"]); } $(myDocument).on("change", changeHandler); myEditor._codeMirror.setValue("new content"); expect(changeFired).toBe(true); }); + it("should send an array of multiple change records for an operation", function () { + var cm = myEditor._codeMirror, + changeHandler = jasmine.createSpy(); + $(myDocument).on("change", changeHandler); + cm.operation(function () { + cm.replaceRange("inserted", {line: 1, ch: 0}); + cm.replaceRange("", {line: 0, ch: 0}, {line: 0, ch: 4}); + }); + + expect(changeHandler.callCount).toBe(1); + + var args = changeHandler.mostRecentCall.args; + expect(args[1]).toBe(myDocument); + expect(args[2][0].text).toEqual(["inserted"]); + expect(args[2][0].from).toEqual({line: 1, ch: 0}); + expect(args[2][0].to).toEqual({line: 1, ch: 0}); + expect(args[2][1].text).toEqual([""]); + expect(args[2][1].from).toEqual({line: 0, ch: 0}); + expect(args[2][1].to).toEqual({line: 0, ch: 4}); + }); + it("should set mode based on Document language", function () { createTestEditor(defaultContent, "html"); @@ -145,7 +167,7 @@ define(function (require, exports, module) { "

Hello

\n" + ""; - it("should get mode in homogenous file", function () { + it("should get mode in homogeneous file", function () { createTestEditor(jsContent, langNames.javascript.mode); // Mode at point @@ -162,6 +184,14 @@ define(function (require, exports, module) { expectModeAndLang(myEditor, langNames.javascript); myEditor.setSelection({line: 0, ch: 0}, {line: 0, ch: 8}); // select all expectModeAndLang(myEditor, langNames.javascript); + + // Mode for multiple cursors/selections + myEditor.setSelections([{start: {line: 0, ch: 0}, end: {line: 0, ch: 0}}, + {start: {line: 0, ch: 5}, end: {line: 0, ch: 5}}]); + expectModeAndLang(myEditor, langNames.javascript); + myEditor.setSelections([{start: {line: 0, ch: 0}, end: {line: 0, ch: 3}}, + {start: {line: 0, ch: 5}, end: {line: 0, ch: 7}}]); + expectModeAndLang(myEditor, langNames.javascript); }); it("should get mode in HTML file", function () { @@ -178,7 +208,7 @@ define(function (require, exports, module) { myEditor.setCursorPos(2, 7); // middle of text - js expectModeAndLang(myEditor, langNames.javascript); - // Mode for range - homogenous mode + // Mode for range - homogeneous mode myEditor.setSelection({line: 5, ch: 2}, {line: 5, ch: 14}); expectModeAndLang(myEditor, langNames.html); myEditor.setSelection({line: 5, ch: 0}, {line: 6, ch: 0}); // whole line @@ -188,10 +218,44 @@ define(function (require, exports, module) { myEditor.setSelection({line: 2, ch: 0}, {line: 3, ch: 0}); // whole line expectModeAndLang(myEditor, langNames.javascript); + // Mode for multiple cursors/selections - homogeneous mode + myEditor.setSelections([{start: {line: 2, ch: 0}, end: {line: 2, ch: 0}}, + {start: {line: 2, ch: 4}, end: {line: 2, ch: 4}}]); + expectModeAndLang(myEditor, langNames.javascript); + myEditor.setSelections([{start: {line: 2, ch: 0}, end: {line: 2, ch: 2}}, + {start: {line: 2, ch: 4}, end: {line: 2, ch: 6}}]); + expectModeAndLang(myEditor, langNames.javascript); + myEditor.setSelections([{start: {line: 0, ch: 0}, end: {line: 0, ch: 0}}, + {start: {line: 5, ch: 7}, end: {line: 5, ch: 7}}, + {start: {line: 6, ch: 14}, end: {line: 6, ch: 14}}]); + expectModeAndLang(myEditor, langNames.html); + myEditor.setSelections([{start: {line: 0, ch: 0}, end: {line: 0, ch: 2}}, + {start: {line: 5, ch: 7}, end: {line: 5, ch: 9}}, + {start: {line: 6, ch: 12}, end: {line: 6, ch: 14}}]); + expectModeAndLang(myEditor, langNames.html); + // Mode for range - mix of modes myEditor.setSelection({line: 2, ch: 4}, {line: 3, ch: 7}); expectModeAndLang(myEditor, langNames.unknown); + // Mode for multiple cursors/selections - mix of modes + myEditor.setSelections([{start: {line: 0, ch: 0}, end: {line: 0, ch: 0}}, + {start: {line: 2, ch: 4}, end: {line: 2, ch: 4}}, + {start: {line: 6, ch: 14}, end: {line: 6, ch: 14}}]); + expectModeAndLang(myEditor, langNames.unknown); + myEditor.setSelections([{start: {line: 0, ch: 0}, end: {line: 0, ch: 2}}, + {start: {line: 2, ch: 4}, end: {line: 2, ch: 7}}, + {start: {line: 6, ch: 12}, end: {line: 6, ch: 14}}]); + expectModeAndLang(myEditor, langNames.unknown); + myEditor.setSelections([{start: {line: 0, ch: 0}, end: {line: 2, ch: 0}}, + {start: {line: 2, ch: 4}, end: {line: 2, ch: 7}}, + {start: {line: 6, ch: 12}, end: {line: 6, ch: 14}}]); + expectModeAndLang(myEditor, langNames.unknown); + myEditor.setSelections([{start: {line: 0, ch: 0}, end: {line: 0, ch: 2}}, + {start: {line: 2, ch: 4}, end: {line: 5, ch: 3}}, + {start: {line: 6, ch: 12}, end: {line: 6, ch: 14}}]); + expectModeAndLang(myEditor, langNames.unknown); + // Mode for range - mix of modes where start & endpoints are same mode // Known limitation of getModeForSelection() that it does not spot where the mode // differs in mid-selection @@ -251,5 +315,1597 @@ define(function (require, exports, module) { Editor.setTabSize(4); }); }); + + function makeDummyLines(num) { + var content = [], i; + for (i = 0; i < num; i++) { + content.push("this is line " + i); + } + return content; + } + + describe("Selections", function () { + + beforeEach(function () { + createTestEditor(makeDummyLines(10).join("\n"), "unknown"); + }); + + describe("hasSelection", function () { + it("should return false for a single cursor", function () { + myEditor._codeMirror.setCursor(0, 2); + expect(myEditor.hasSelection()).toBe(false); + }); + + it("should return true for a single selection", function () { + myEditor._codeMirror.setSelection({line: 0, ch: 1}, {line: 0, ch: 5}); + expect(myEditor.hasSelection()).toBe(true); + }); + + it("should return false for multiple cursors", function () { + myEditor._codeMirror.setSelections([{anchor: {line: 0, ch: 1}, head: {line: 0, ch: 1}}, + {anchor: {line: 1, ch: 1}, head: {line: 1, ch: 1}}, + {anchor: {line: 2, ch: 1}, head: {line: 2, ch: 1}} + ]); + expect(myEditor.hasSelection()).toBe(false); + }); + + it("should return true for multiple selections", function () { + myEditor._codeMirror.setSelections([{anchor: {line: 0, ch: 1}, head: {line: 0, ch: 4}}, + {anchor: {line: 1, ch: 1}, head: {line: 1, ch: 4}}, + {anchor: {line: 2, ch: 1}, head: {line: 2, ch: 4}} + ]); + expect(myEditor.hasSelection()).toBe(true); + }); + + it("should return true for mixed cursors and selections", function () { + myEditor._codeMirror.setSelections([{anchor: {line: 0, ch: 1}, head: {line: 0, ch: 1}}, + {anchor: {line: 1, ch: 1}, head: {line: 1, ch: 4}}, + {anchor: {line: 2, ch: 1}, head: {line: 2, ch: 1}} + ]); + expect(myEditor.hasSelection()).toBe(true); + }); + }); + + describe("getCursorPos", function () { + it("should return a single cursor", function () { + myEditor._codeMirror.setCursor(0, 2); + expect(myEditor.getCursorPos()).toEqual({line: 0, ch: 2}); + expect(myEditor.getCursorPos(false, "start")).toEqual({line: 0, ch: 2}); + expect(myEditor.getCursorPos(false, "anchor")).toEqual({line: 0, ch: 2}); + expect(myEditor.getCursorPos(false, "end")).toEqual({line: 0, ch: 2}); + expect(myEditor.getCursorPos(false, "head")).toEqual({line: 0, ch: 2}); + }); + + it("should return the correct ends of a single selection", function () { + myEditor._codeMirror.setSelection({line: 0, ch: 1}, {line: 0, ch: 5}); + expect(myEditor.getCursorPos()).toEqual({line: 0, ch: 5}); + expect(myEditor.getCursorPos(false, "start")).toEqual({line: 0, ch: 1}); + expect(myEditor.getCursorPos(false, "anchor")).toEqual({line: 0, ch: 1}); + expect(myEditor.getCursorPos(false, "end")).toEqual({line: 0, ch: 5}); + expect(myEditor.getCursorPos(false, "head")).toEqual({line: 0, ch: 5}); + }); + + it("should return the default primary cursor in a multiple cursor selection", function () { + myEditor._codeMirror.setSelections([{anchor: {line: 0, ch: 1}, head: {line: 0, ch: 1}}, + {anchor: {line: 1, ch: 1}, head: {line: 1, ch: 1}}, + {anchor: {line: 2, ch: 1}, head: {line: 2, ch: 1}} + ]); + expect(myEditor.getCursorPos()).toEqual({line: 2, ch: 1}); + expect(myEditor.getCursorPos(false, "start")).toEqual({line: 2, ch: 1}); + expect(myEditor.getCursorPos(false, "anchor")).toEqual({line: 2, ch: 1}); + expect(myEditor.getCursorPos(false, "end")).toEqual({line: 2, ch: 1}); + expect(myEditor.getCursorPos(false, "head")).toEqual({line: 2, ch: 1}); + }); + + it("should return the specific primary cursor in a multiple cursor selection", function () { + myEditor._codeMirror.setSelections([{anchor: {line: 0, ch: 1}, head: {line: 0, ch: 1}}, + {anchor: {line: 1, ch: 1}, head: {line: 1, ch: 1}}, + {anchor: {line: 2, ch: 1}, head: {line: 2, ch: 1}} + ], 1); + expect(myEditor.getCursorPos()).toEqual({line: 1, ch: 1}); + expect(myEditor.getCursorPos(false, "start")).toEqual({line: 1, ch: 1}); + expect(myEditor.getCursorPos(false, "anchor")).toEqual({line: 1, ch: 1}); + expect(myEditor.getCursorPos(false, "end")).toEqual({line: 1, ch: 1}); + expect(myEditor.getCursorPos(false, "head")).toEqual({line: 1, ch: 1}); + }); + + it("should return the correct ends of the default primary selection in a multiple selection", function () { + myEditor._codeMirror.setSelections([{anchor: {line: 0, ch: 1}, head: {line: 0, ch: 4}}, + {anchor: {line: 1, ch: 1}, head: {line: 1, ch: 4}}, + {anchor: {line: 2, ch: 1}, head: {line: 2, ch: 4}} + ]); + expect(myEditor.getCursorPos()).toEqual({line: 2, ch: 4}); + expect(myEditor.getCursorPos(false, "start")).toEqual({line: 2, ch: 1}); + expect(myEditor.getCursorPos(false, "anchor")).toEqual({line: 2, ch: 1}); + expect(myEditor.getCursorPos(false, "end")).toEqual({line: 2, ch: 4}); + expect(myEditor.getCursorPos(false, "head")).toEqual({line: 2, ch: 4}); + }); + + it("should return the correct ends of a specific primary selection in a multiple selection", function () { + myEditor._codeMirror.setSelections([{anchor: {line: 0, ch: 1}, head: {line: 0, ch: 4}}, + {anchor: {line: 1, ch: 1}, head: {line: 1, ch: 4}}, + {anchor: {line: 2, ch: 1}, head: {line: 2, ch: 4}} + ], 1); + expect(myEditor.getCursorPos()).toEqual({line: 1, ch: 4}); + expect(myEditor.getCursorPos(false, "start")).toEqual({line: 1, ch: 1}); + expect(myEditor.getCursorPos(false, "anchor")).toEqual({line: 1, ch: 1}); + expect(myEditor.getCursorPos(false, "end")).toEqual({line: 1, ch: 4}); + expect(myEditor.getCursorPos(false, "head")).toEqual({line: 1, ch: 4}); + }); + }); + + describe("setCursorPos", function () { + it("should replace an existing single cursor", function () { + myEditor._codeMirror.setCursor(0, 2); + myEditor.setCursorPos(1, 3); + expect(myEditor.getCursorPos()).toEqual({line: 1, ch: 3}); + }); + + it("should replace an existing single selection", function () { + myEditor._codeMirror.setSelection({line: 0, ch: 1}, {line: 0, ch: 5}); + myEditor.setCursorPos(1, 3); + expect(myEditor.getCursorPos()).toEqual({line: 1, ch: 3}); + }); + + it("should replace existing multiple cursors", function () { + myEditor._codeMirror.setSelections([{anchor: {line: 0, ch: 1}, head: {line: 0, ch: 1}}, + {anchor: {line: 1, ch: 1}, head: {line: 1, ch: 1}}, + {anchor: {line: 2, ch: 1}, head: {line: 2, ch: 1}} + ]); + myEditor.setCursorPos(1, 3); + expect(myEditor.getCursorPos()).toEqual({line: 1, ch: 3}); + }); + + it("should replace existing multiple selections", function () { + myEditor._codeMirror.setSelections([{anchor: {line: 0, ch: 1}, head: {line: 0, ch: 4}}, + {anchor: {line: 1, ch: 1}, head: {line: 1, ch: 4}}, + {anchor: {line: 2, ch: 1}, head: {line: 2, ch: 4}} + ]); + myEditor.setCursorPos(1, 3); + expect(myEditor.getCursorPos()).toEqual({line: 1, ch: 3}); + }); + }); + + describe("getSelection", function () { + it("should return a single cursor", function () { + myEditor._codeMirror.setCursor(0, 2); + expect(myEditor.getSelection()).toEqual({start: {line: 0, ch: 2}, end: {line: 0, ch: 2}, reversed: false}); + }); + + it("should return a single selection", function () { + myEditor._codeMirror.setSelection({line: 0, ch: 1}, {line: 0, ch: 5}); + expect(myEditor.getSelection()).toEqual({start: {line: 0, ch: 1}, end: {line: 0, ch: 5}, reversed: false}); + }); + + it("should return a multiline selection", function () { + myEditor._codeMirror.setSelection({line: 0, ch: 5}, {line: 1, ch: 3}); + expect(myEditor.getSelection()).toEqual({start: {line: 0, ch: 5}, end: {line: 1, ch: 3}, reversed: false}); + }); + + it("should return a single selection in the proper order when reversed", function () { + myEditor._codeMirror.setSelection({line: 0, ch: 5}, {line: 0, ch: 1}); + expect(myEditor.getSelection()).toEqual({start: {line: 0, ch: 1}, end: {line: 0, ch: 5}, reversed: true}); + }); + + it("should return a multiline selection in the proper order when reversed", function () { + myEditor._codeMirror.setSelection({line: 1, ch: 3}, {line: 0, ch: 5}); + expect(myEditor.getSelection()).toEqual({start: {line: 0, ch: 5}, end: {line: 1, ch: 3}, reversed: true}); + }); + + it("should return the default primary cursor in a multiple cursor selection", function () { + myEditor._codeMirror.setSelections([{anchor: {line: 0, ch: 1}, head: {line: 0, ch: 1}}, + {anchor: {line: 1, ch: 1}, head: {line: 1, ch: 1}}, + {anchor: {line: 2, ch: 1}, head: {line: 2, ch: 1}} + ]); + expect(myEditor.getSelection()).toEqual({start: {line: 2, ch: 1}, end: {line: 2, ch: 1}, reversed: false}); + }); + + it("should return the specific primary cursor in a multiple cursor selection", function () { + myEditor._codeMirror.setSelections([{anchor: {line: 0, ch: 1}, head: {line: 0, ch: 1}}, + {anchor: {line: 1, ch: 1}, head: {line: 1, ch: 1}}, + {anchor: {line: 2, ch: 1}, head: {line: 2, ch: 1}} + ], 1); + expect(myEditor.getSelection()).toEqual({start: {line: 1, ch: 1}, end: {line: 1, ch: 1}, reversed: false}); + }); + + it("should return the default primary selection in a multiple selection", function () { + myEditor._codeMirror.setSelections([{anchor: {line: 0, ch: 1}, head: {line: 0, ch: 4}}, + {anchor: {line: 1, ch: 1}, head: {line: 1, ch: 4}}, + {anchor: {line: 2, ch: 1}, head: {line: 2, ch: 4}} + ]); + expect(myEditor.getSelection()).toEqual({start: {line: 2, ch: 1}, end: {line: 2, ch: 4}, reversed: false}); + }); + + it("should return the default primary selection in the proper order when reversed", function () { + myEditor._codeMirror.setSelections([{anchor: {line: 0, ch: 1}, head: {line: 0, ch: 4}}, + {anchor: {line: 1, ch: 1}, head: {line: 1, ch: 4}}, + {anchor: {line: 2, ch: 4}, head: {line: 2, ch: 1}} + ]); + expect(myEditor.getSelection()).toEqual({start: {line: 2, ch: 1}, end: {line: 2, ch: 4}, reversed: true}); + }); + + it("should return the specific primary selection in a multiple selection", function () { + myEditor._codeMirror.setSelections([{anchor: {line: 0, ch: 1}, head: {line: 0, ch: 4}}, + {anchor: {line: 1, ch: 1}, head: {line: 1, ch: 4}}, + {anchor: {line: 2, ch: 1}, head: {line: 2, ch: 4}} + ], 1); + expect(myEditor.getSelection()).toEqual({start: {line: 1, ch: 1}, end: {line: 1, ch: 4}, reversed: false}); + }); + + it("should return the specific primary selection in the proper order when reversed", function () { + myEditor._codeMirror.setSelections([{anchor: {line: 0, ch: 1}, head: {line: 0, ch: 4}}, + {anchor: {line: 1, ch: 4}, head: {line: 1, ch: 1}}, + {anchor: {line: 2, ch: 1}, head: {line: 2, ch: 4}} + ], 1); + expect(myEditor.getSelection()).toEqual({start: {line: 1, ch: 1}, end: {line: 1, ch: 4}, reversed: true}); + }); + + }); + + describe("getSelections", function () { + it("should return a single cursor", function () { + myEditor._codeMirror.setCursor(0, 2); + expect(myEditor.getSelections()).toEqual([{start: {line: 0, ch: 2}, end: {line: 0, ch: 2}, reversed: false, primary: true}]); + }); + + it("should return a single selection", function () { + myEditor._codeMirror.setSelection({line: 0, ch: 1}, {line: 0, ch: 5}); + expect(myEditor.getSelections()).toEqual([{start: {line: 0, ch: 1}, end: {line: 0, ch: 5}, reversed: false, primary: true}]); + }); + + it("should properly reverse a single selection whose head is before its anchor", function () { + myEditor._codeMirror.setSelection({line: 0, ch: 5}, {line: 0, ch: 1}); + expect(myEditor.getSelections()).toEqual([{start: {line: 0, ch: 1}, end: {line: 0, ch: 5}, reversed: true, primary: true}]); + }); + + it("should return multiple cursors", function () { + myEditor._codeMirror.setSelections([{anchor: {line: 0, ch: 1}, head: {line: 0, ch: 1}}, + {anchor: {line: 1, ch: 1}, head: {line: 1, ch: 1}}, + {anchor: {line: 2, ch: 1}, head: {line: 2, ch: 1}} + ]); + expect(myEditor.getSelections()).toEqual([{start: {line: 0, ch: 1}, end: {line: 0, ch: 1}, reversed: false, primary: false}, + {start: {line: 1, ch: 1}, end: {line: 1, ch: 1}, reversed: false, primary: false}, + {start: {line: 2, ch: 1}, end: {line: 2, ch: 1}, reversed: false, primary: true} + ]); + }); + + it("should return a multiple selection", function () { + myEditor._codeMirror.setSelections([{anchor: {line: 0, ch: 1}, head: {line: 0, ch: 4}}, + {anchor: {line: 1, ch: 1}, head: {line: 1, ch: 4}}, + {anchor: {line: 2, ch: 1}, head: {line: 2, ch: 4}} + ]); + expect(myEditor.getSelections()).toEqual([{start: {line: 0, ch: 1}, end: {line: 0, ch: 4}, reversed: false, primary: false}, + {start: {line: 1, ch: 1}, end: {line: 1, ch: 4}, reversed: false, primary: false}, + {start: {line: 2, ch: 1}, end: {line: 2, ch: 4}, reversed: false, primary: true} + ]); + }); + + it("should properly reverse selections whose heads are before their anchors in a multiple selection", function () { + myEditor._codeMirror.setSelections([{anchor: {line: 0, ch: 4}, head: {line: 0, ch: 1}}, + {anchor: {line: 1, ch: 1}, head: {line: 1, ch: 4}}, + {anchor: {line: 2, ch: 4}, head: {line: 2, ch: 1}} + ]); + expect(myEditor.getSelections()).toEqual([{start: {line: 0, ch: 1}, end: {line: 0, ch: 4}, reversed: true, primary: false}, + {start: {line: 1, ch: 1}, end: {line: 1, ch: 4}, reversed: false, primary: false}, + {start: {line: 2, ch: 1}, end: {line: 2, ch: 4}, reversed: true, primary: true} + ]); + }); + + it("should properly reverse multiline selections whose heads are before their anchors in a multiple selection", function () { + myEditor._codeMirror.setSelections([{anchor: {line: 1, ch: 3}, head: {line: 0, ch: 5}}, + {anchor: {line: 4, ch: 4}, head: {line: 3, ch: 1}} + ]); + expect(myEditor.getSelections()).toEqual([{start: {line: 0, ch: 5}, end: {line: 1, ch: 3}, reversed: true, primary: false}, + {start: {line: 3, ch: 1}, end: {line: 4, ch: 4}, reversed: true, primary: true} + ]); + }); + }); + + describe("getSelectedText", function () { + it("should return empty string for a cursor", function () { + myEditor._codeMirror.setCursor(0, 2); + expect(myEditor.getSelectedText()).toEqual(""); + expect(myEditor.getSelectedText(true)).toEqual(""); + }); + + it("should return the contents of a single selection", function () { + myEditor._codeMirror.setSelection({line: 0, ch: 8}, {line: 0, ch: 14}); + expect(myEditor.getSelectedText()).toEqual("line 0"); + expect(myEditor.getSelectedText(true)).toEqual("line 0"); + }); + + it("should return the primary selection by default, but concatenate contents if allSelections is true", function () { + myEditor._codeMirror.setSelections([{anchor: {line: 0, ch: 8}, head: {line: 0, ch: 14}}, + {anchor: {line: 1, ch: 8}, head: {line: 1, ch: 14}}, + {anchor: {line: 2, ch: 8}, head: {line: 2, ch: 14}} + ]); + expect(myEditor.getSelectedText()).toEqual("line 2"); + expect(myEditor.getSelectedText(true)).toEqual("line 0\nline 1\nline 2"); + }); + + it("should return a primary selection other than the last", function () { + myEditor._codeMirror.setSelections([{anchor: {line: 0, ch: 8}, head: {line: 0, ch: 14}}, + {anchor: {line: 1, ch: 8}, head: {line: 1, ch: 14}}, + {anchor: {line: 2, ch: 8}, head: {line: 2, ch: 14}} + ], 1); + expect(myEditor.getSelectedText()).toEqual("line 1"); + expect(myEditor.getSelectedText(true)).toEqual("line 0\nline 1\nline 2"); + }); + + it("should return the contents of a multiple selection when some selections are reversed", function () { + myEditor._codeMirror.setSelections([{anchor: {line: 0, ch: 14}, head: {line: 0, ch: 8}}, + {anchor: {line: 1, ch: 8}, head: {line: 1, ch: 14}}, + {anchor: {line: 2, ch: 14}, head: {line: 2, ch: 8}} + ]); + expect(myEditor.getSelectedText()).toEqual("line 2"); + expect(myEditor.getSelectedText(true)).toEqual("line 0\nline 1\nline 2"); + }); + }); + + describe("setSelection", function () { + it("should replace an existing single cursor", function () { + myEditor._codeMirror.setCursor(0, 2); + myEditor.setSelection({line: 1, ch: 3}, {line: 2, ch: 5}); + expect(myEditor.getSelection()).toEqual({start: {line: 1, ch: 3}, end: {line: 2, ch: 5}, reversed: false}); + }); + + it("should replace an existing single selection", function () { + myEditor._codeMirror.setSelection({line: 0, ch: 1}, {line: 0, ch: 5}); + myEditor.setSelection({line: 1, ch: 3}, {line: 2, ch: 5}); + expect(myEditor.getSelection()).toEqual({start: {line: 1, ch: 3}, end: {line: 2, ch: 5}, reversed: false}); + }); + + it("should allow implicit end", function () { + myEditor.setSelection({line: 1, ch: 3}); + expect(myEditor.getSelection()).toEqual({start: {line: 1, ch: 3}, end: {line: 1, ch: 3}, reversed: false}); + }); + + it("should replace existing multiple cursors", function () { + myEditor._codeMirror.setSelections([{anchor: {line: 0, ch: 1}, head: {line: 0, ch: 1}}, + {anchor: {line: 1, ch: 1}, head: {line: 1, ch: 1}}, + {anchor: {line: 2, ch: 1}, head: {line: 2, ch: 1}} + ]); + myEditor.setSelection({line: 1, ch: 3}, {line: 2, ch: 5}); + expect(myEditor.getSelection()).toEqual({start: {line: 1, ch: 3}, end: {line: 2, ch: 5}, reversed: false}); + }); + + it("should replace existing multiple selections", function () { + myEditor._codeMirror.setSelections([{anchor: {line: 0, ch: 1}, head: {line: 0, ch: 4}}, + {anchor: {line: 1, ch: 1}, head: {line: 1, ch: 4}}, + {anchor: {line: 2, ch: 1}, head: {line: 2, ch: 4}} + ]); + myEditor.setSelection({line: 1, ch: 3}, {line: 2, ch: 5}); + expect(myEditor.getSelection()).toEqual({start: {line: 1, ch: 3}, end: {line: 2, ch: 5}, reversed: false}); + }); + }); + + describe("setSelections", function () { + it("should replace an existing single cursor", function () { + myEditor._codeMirror.setCursor(0, 2); + myEditor.setSelections([{start: {line: 0, ch: 1}, end: {line: 1, ch: 3}}, + {start: {line: 1, ch: 8}, end: {line: 2, ch: 5}}]); + expect(myEditor.getSelections()).toEqual([{start: {line: 0, ch: 1}, end: {line: 1, ch: 3}, reversed: false, primary: false}, + {start: {line: 1, ch: 8}, end: {line: 2, ch: 5}, reversed: false, primary: true}]); + expect(myEditor.getSelection()).toEqual({start: {line: 1, ch: 8}, end: {line: 2, ch: 5}, reversed: false}); + }); + + it("should replace an existing single selection", function () { + myEditor._codeMirror.setSelection({line: 0, ch: 1}, {line: 0, ch: 5}); + myEditor.setSelections([{start: {line: 0, ch: 1}, end: {line: 1, ch: 3}}, + {start: {line: 1, ch: 8}, end: {line: 2, ch: 5}}]); + expect(myEditor.getSelections()).toEqual([{start: {line: 0, ch: 1}, end: {line: 1, ch: 3}, reversed: false, primary: false}, + {start: {line: 1, ch: 8}, end: {line: 2, ch: 5}, reversed: false, primary: true}]); + expect(myEditor.getSelection()).toEqual({start: {line: 1, ch: 8}, end: {line: 2, ch: 5}, reversed: false}); + }); + + it("should replace existing multiple cursors", function () { + myEditor._codeMirror.setSelections([{anchor: {line: 0, ch: 1}, head: {line: 0, ch: 1}}, + {anchor: {line: 1, ch: 1}, head: {line: 1, ch: 1}}, + {anchor: {line: 2, ch: 1}, head: {line: 2, ch: 1}} + ]); + myEditor.setSelections([{start: {line: 0, ch: 1}, end: {line: 1, ch: 3}}, + {start: {line: 1, ch: 8}, end: {line: 2, ch: 5}}]); + expect(myEditor.getSelections()).toEqual([{start: {line: 0, ch: 1}, end: {line: 1, ch: 3}, reversed: false, primary: false}, + {start: {line: 1, ch: 8}, end: {line: 2, ch: 5}, reversed: false, primary: true}]); + expect(myEditor.getSelection()).toEqual({start: {line: 1, ch: 8}, end: {line: 2, ch: 5}, reversed: false}); + }); + + it("should replace existing multiple selections", function () { + myEditor._codeMirror.setSelections([{anchor: {line: 0, ch: 1}, head: {line: 0, ch: 4}}, + {anchor: {line: 1, ch: 1}, head: {line: 1, ch: 4}}, + {anchor: {line: 2, ch: 1}, head: {line: 2, ch: 4}} + ]); + myEditor.setSelections([{start: {line: 0, ch: 1}, end: {line: 1, ch: 3}}, + {start: {line: 1, ch: 8}, end: {line: 2, ch: 5}}]); + expect(myEditor.getSelections()).toEqual([{start: {line: 0, ch: 1}, end: {line: 1, ch: 3}, reversed: false, primary: false}, + {start: {line: 1, ch: 8}, end: {line: 2, ch: 5}, reversed: false, primary: true}]); + expect(myEditor.getSelection()).toEqual({start: {line: 1, ch: 8}, end: {line: 2, ch: 5}, reversed: false}); + }); + + it("should specify non-default primary selection", function () { + myEditor.setSelections([{start: {line: 0, ch: 1}, end: {line: 1, ch: 3}, primary: true}, + {start: {line: 1, ch: 8}, end: {line: 2, ch: 5}}]); + expect(myEditor.getSelections()).toEqual([{start: {line: 0, ch: 1}, end: {line: 1, ch: 3}, reversed: false, primary: true}, + {start: {line: 1, ch: 8}, end: {line: 2, ch: 5}, reversed: false, primary: false}]); + expect(myEditor.getSelection()).toEqual({start: {line: 0, ch: 1}, end: {line: 1, ch: 3}, reversed: false}); + }); + + it("should sort and merge overlapping selections", function () { + myEditor.setSelections([{start: {line: 2, ch: 4}, end: {line: 3, ch: 0}}, + {start: {line: 2, ch: 3}, end: {line: 2, ch: 6}}, + {start: {line: 1, ch: 1}, end: {line: 1, ch: 4}}]); + expect(myEditor.getSelections()).toEqual([{start: {line: 1, ch: 1}, end: {line: 1, ch: 4}, reversed: false, primary: true}, + {start: {line: 2, ch: 3}, end: {line: 3, ch: 0}, reversed: false, primary: false}]); + expect(myEditor.getSelection()).toEqual({start: {line: 1, ch: 1}, end: {line: 1, ch: 4}, reversed: false}); + }); + + it("should properly set reversed selections", function () { + myEditor.setSelections([{start: {line: 0, ch: 1}, end: {line: 1, ch: 3}, reversed: true}, + {start: {line: 1, ch: 8}, end: {line: 2, ch: 5}}]); + expect(myEditor.getSelections()).toEqual([{start: {line: 0, ch: 1}, end: {line: 1, ch: 3}, reversed: true, primary: false}, + {start: {line: 1, ch: 8}, end: {line: 2, ch: 5}, reversed: false, primary: true}]); + + }); + }); + + describe("convertToLineSelections", function () { + it("should expand a cursor to a line selection, keeping original selection for tracking", function () { + var origSelections = [{start: {line: 0, ch: 4}, end: {line: 0, ch: 4}}], + result = myEditor.convertToLineSelections(origSelections); + expect(result.length).toBe(1); + expect(result[0].selectionForEdit.start).toEqual({line: 0, ch: 0}); + expect(result[0].selectionForEdit.end).toEqual({line: 1, ch: 0}); + expect(result[0].selectionsToTrack.length).toBe(1); + expect(result[0].selectionsToTrack[0]).toEqual(origSelections[0]); + }); + + it("should expand a range within a line to a line selection, keeping original selection for tracking", function () { + var origSelections = [{start: {line: 0, ch: 4}, end: {line: 0, ch: 8}}], + result = myEditor.convertToLineSelections(origSelections); + expect(result.length).toBe(1); + expect(result[0].selectionForEdit.start).toEqual({line: 0, ch: 0}); + expect(result[0].selectionForEdit.end).toEqual({line: 1, ch: 0}); + expect(result[0].selectionsToTrack.length).toBe(1); + expect(result[0].selectionsToTrack[0]).toEqual(origSelections[0]); + }); + + it("should expand a range that spans multiple lines to a line selection", function () { + var origSelections = [{start: {line: 0, ch: 4}, end: {line: 1, ch: 8}}], + result = myEditor.convertToLineSelections(origSelections); + expect(result.length).toBe(1); + expect(result[0].selectionForEdit.start).toEqual({line: 0, ch: 0}); + expect(result[0].selectionForEdit.end).toEqual({line: 2, ch: 0}); + expect(result[0].selectionsToTrack.length).toBe(1); + expect(result[0].selectionsToTrack[0]).toEqual(origSelections[0]); + }); + + it("should preserve the reversed attribute on a tracked range", function () { + var origSelections = [{start: {line: 0, ch: 4}, end: {line: 0, ch: 8}, reversed: true}], + result = myEditor.convertToLineSelections(origSelections); + expect(result.length).toBe(1); + expect(result[0].selectionForEdit.start).toEqual({line: 0, ch: 0}); + expect(result[0].selectionForEdit.end).toEqual({line: 1, ch: 0}); + expect(result[0].selectionsToTrack.length).toBe(1); + expect(result[0].selectionsToTrack[0]).toEqual(origSelections[0]); + }); + + it("should keep a line selection the same if expandEndAtStartOfLine is not set", function () { + var origSelections = [{start: {line: 0, ch: 0}, end: {line: 1, ch: 0}}], + result = myEditor.convertToLineSelections(origSelections); + expect(result.length).toBe(1); + expect(result[0].selectionForEdit.start).toEqual({line: 0, ch: 0}); + expect(result[0].selectionForEdit.end).toEqual({line: 1, ch: 0}); + expect(result[0].selectionsToTrack.length).toBe(1); + expect(result[0].selectionsToTrack[0]).toEqual(origSelections[0]); + }); + + it("should expand a line selection if expandEndAtStartOfLine is set", function () { + var origSelections = [{start: {line: 0, ch: 0}, end: {line: 1, ch: 0}}], + result = myEditor.convertToLineSelections(origSelections, {expandEndAtStartOfLine: true}); + expect(result.length).toBe(1); + expect(result[0].selectionForEdit.start).toEqual({line: 0, ch: 0}); + expect(result[0].selectionForEdit.end).toEqual({line: 2, ch: 0}); + expect(result[0].selectionsToTrack.length).toBe(1); + expect(result[0].selectionsToTrack[0]).toEqual(origSelections[0]); + }); + + it("should process a discontiguous mix of cursor, range, and line selections separately, preserving the primary tracked selection", function () { + var origSelections = [{start: {line: 0, ch: 4}, end: {line: 0, ch: 4}}, + {start: {line: 2, ch: 4}, end: {line: 2, ch: 8}, primary: true}, + {start: {line: 4, ch: 4}, end: {line: 5, ch: 8}}, + {start: {line: 7, ch: 0}, end: {line: 8, ch: 0}}], + result = myEditor.convertToLineSelections(origSelections); + expect(result.length).toBe(4); + expect(result[0].selectionForEdit.start).toEqual({line: 0, ch: 0}); + expect(result[0].selectionForEdit.end).toEqual({line: 1, ch: 0}); + expect(result[0].selectionsToTrack.length).toBe(1); + expect(result[0].selectionsToTrack[0]).toEqual(origSelections[0]); + expect(result[1].selectionForEdit.start).toEqual({line: 2, ch: 0}); + expect(result[1].selectionForEdit.end).toEqual({line: 3, ch: 0}); + expect(result[1].selectionsToTrack.length).toBe(1); + expect(result[1].selectionsToTrack[0]).toEqual(origSelections[1]); + expect(result[2].selectionForEdit.start).toEqual({line: 4, ch: 0}); + expect(result[2].selectionForEdit.end).toEqual({line: 6, ch: 0}); + expect(result[2].selectionsToTrack.length).toBe(1); + expect(result[2].selectionsToTrack[0]).toEqual(origSelections[2]); + expect(result[3].selectionForEdit.start).toEqual({line: 7, ch: 0}); + expect(result[3].selectionForEdit.end).toEqual({line: 8, ch: 0}); // not expanded since expandEndAtStartOfLine is false + expect(result[3].selectionsToTrack.length).toBe(1); + expect(result[3].selectionsToTrack[0]).toEqual(origSelections[3]); + }); + + it("should merge selections on same line, preserving primary/reversed info on subsumed selections", function () { + var origSelections = [{start: {line: 0, ch: 4}, end: {line: 0, ch: 4}}, + {start: {line: 0, ch: 8}, end: {line: 1, ch: 8}, primary: true, reversed: true}, + {start: {line: 4, ch: 0}, end: {line: 5, ch: 0}}], + result = myEditor.convertToLineSelections(origSelections); + expect(result.length).toBe(2); + expect(result[0].selectionForEdit.start).toEqual({line: 0, ch: 0}); + expect(result[0].selectionForEdit.end).toEqual({line: 2, ch: 0}); + expect(result[0].selectionsToTrack.length).toBe(2); + expect(result[0].selectionsToTrack[0]).toEqual(origSelections[0]); + expect(result[0].selectionsToTrack[1]).toEqual(origSelections[1]); + expect(result[1].selectionForEdit.start).toEqual({line: 4, ch: 0}); + expect(result[1].selectionForEdit.end).toEqual({line: 5, ch: 0}); + expect(result[1].selectionsToTrack.length).toBe(1); + expect(result[1].selectionsToTrack[0]).toEqual(origSelections[2]); + }); + + it("should merge selections on adjacent lines by default", function () { + var origSelections = [{start: {line: 0, ch: 4}, end: {line: 0, ch: 4}}, + {start: {line: 1, ch: 4}, end: {line: 1, ch: 4}, primary: true}, + {start: {line: 4, ch: 0}, end: {line: 5, ch: 0}}], + result = myEditor.convertToLineSelections(origSelections); + expect(result.length).toBe(2); + expect(result[0].selectionForEdit.start).toEqual({line: 0, ch: 0}); + expect(result[0].selectionForEdit.end).toEqual({line: 2, ch: 0}); + expect(result[0].selectionsToTrack.length).toBe(2); + expect(result[0].selectionsToTrack[0]).toEqual(origSelections[0]); + expect(result[0].selectionsToTrack[1]).toEqual(origSelections[1]); + expect(result[1].selectionForEdit.start).toEqual({line: 4, ch: 0}); + expect(result[1].selectionForEdit.end).toEqual({line: 5, ch: 0}); + expect(result[1].selectionsToTrack.length).toBe(1); + expect(result[1].selectionsToTrack[0]).toEqual(origSelections[2]); + }); + + it("should merge adjacent multiline selections where the first selection ends on the same line where the second selection starts", function () { + var origSelections = [{start: {line: 0, ch: 4}, end: {line: 1, ch: 4}, primary: true}, + {start: {line: 1, ch: 8}, end: {line: 2, ch: 8}}], + result = myEditor.convertToLineSelections(origSelections); + expect(result.length).toBe(1); + expect(result[0].selectionForEdit.start).toEqual({line: 0, ch: 0}); + expect(result[0].selectionForEdit.end).toEqual({line: 3, ch: 0}); + expect(result[0].selectionsToTrack.length).toBe(2); + expect(result[0].selectionsToTrack[0]).toEqual(origSelections[0]); + expect(result[0].selectionsToTrack[1]).toEqual(origSelections[1]); + }); + + it("should not merge selections on adjacent lines if mergeAdjacent is false", function () { + var origSelections = [{start: {line: 0, ch: 4}, end: {line: 0, ch: 4}}, + {start: {line: 1, ch: 4}, end: {line: 1, ch: 4}, primary: true}, + {start: {line: 4, ch: 0}, end: {line: 5, ch: 0}}], + result = myEditor.convertToLineSelections(origSelections, {mergeAdjacent: false}); + expect(result.length).toBe(3); + expect(result[0].selectionForEdit.start).toEqual({line: 0, ch: 0}); + expect(result[0].selectionForEdit.end).toEqual({line: 1, ch: 0}); + expect(result[0].selectionsToTrack.length).toBe(1); + expect(result[0].selectionsToTrack[0]).toEqual(origSelections[0]); + expect(result[1].selectionForEdit.start).toEqual({line: 1, ch: 0}); + expect(result[1].selectionForEdit.end).toEqual({line: 2, ch: 0}); + expect(result[1].selectionsToTrack.length).toBe(1); + expect(result[1].selectionsToTrack[0]).toEqual(origSelections[1]); + expect(result[2].selectionForEdit.start).toEqual({line: 4, ch: 0}); + expect(result[2].selectionForEdit.end).toEqual({line: 5, ch: 0}); // not expanded since expandEndAtStartOfLine not set + expect(result[2].selectionsToTrack.length).toBe(1); + expect(result[2].selectionsToTrack[0]).toEqual(origSelections[2]); + }); + + it("should merge line selections separated by a one-line gap by default if expandEndAtStartOfLine is true", function () { + var origSelections = [{start: {line: 0, ch: 0}, end: {line: 1, ch: 0}, primary: true}, + {start: {line: 2, ch: 0}, end: {line: 3, ch: 0}}], + result = myEditor.convertToLineSelections(origSelections, {expandEndAtStartOfLine: true}); + expect(result.length).toBe(1); + expect(result[0].selectionForEdit.start).toEqual({line: 0, ch: 0}); + expect(result[0].selectionForEdit.end).toEqual({line: 4, ch: 0}); + expect(result[0].selectionsToTrack.length).toBe(2); + expect(result[0].selectionsToTrack[0]).toEqual(origSelections[0]); + expect(result[0].selectionsToTrack[1]).toEqual(origSelections[1]); + }); + + it("should not merge line selections separated by a one-line gap if expandEndAtStartOfLine is true but mergeAdjacent is false", function () { + // Note that in this case, if you were to actually set this as a multiple selection, CodeMirror would + // merge the adjacent selections at that point. But while processing an edit you might not want that. + var origSelections = [{start: {line: 0, ch: 0}, end: {line: 1, ch: 0}, primary: true}, + {start: {line: 2, ch: 0}, end: {line: 3, ch: 0}}], + result = myEditor.convertToLineSelections(origSelections, {expandEndAtStartOfLine: true, mergeAdjacent: false}); + expect(result.length).toBe(2); + expect(result[0].selectionForEdit.start).toEqual({line: 0, ch: 0}); + expect(result[0].selectionForEdit.end).toEqual({line: 2, ch: 0}); + expect(result[0].selectionsToTrack.length).toBe(1); + expect(result[0].selectionsToTrack[0]).toEqual(origSelections[0]); + expect(result[1].selectionForEdit.start).toEqual({line: 2, ch: 0}); + expect(result[1].selectionForEdit.end).toEqual({line: 4, ch: 0}); + expect(result[1].selectionsToTrack.length).toBe(1); + expect(result[1].selectionsToTrack[0]).toEqual(origSelections[1]); + }); + + it("should merge multiple adjacent/overlapping selections together", function () { + var origSelections = [{start: {line: 0, ch: 4}, end: {line: 0, ch: 4}}, + {start: {line: 1, ch: 4}, end: {line: 2, ch: 4}, primary: true, reversed: true}, + {start: {line: 2, ch: 8}, end: {line: 5, ch: 0}}], + result = myEditor.convertToLineSelections(origSelections); + expect(result.length).toBe(1); + expect(result[0].selectionForEdit.start).toEqual({line: 0, ch: 0}); + expect(result[0].selectionForEdit.end).toEqual({line: 5, ch: 0}); + expect(result[0].selectionsToTrack.length).toBe(3); + expect(result[0].selectionsToTrack[0]).toEqual(origSelections[0]); + expect(result[0].selectionsToTrack[1]).toEqual(origSelections[1]); + expect(result[0].selectionsToTrack[2]).toEqual(origSelections[2]); + }); + }); + }); + + describe("Soft tabs", function () { + beforeEach(function () { + // Configure the editor's CM instance for 4-space soft tabs, regardless of prefs. + createTestEditor("", "unknown"); + var instance = myEditor._codeMirror; + instance.setOption("indentWithTabs", false); + instance.setOption("indentUnit", 4); + }); + + function checkSoftTab(sel, dir, command, expectedSel, expectedText) { + var input, endPos; + expectedText = expectedText || myEditor.document.getText(); + + if (Array.isArray(sel)) { + myEditor.setSelections(sel); + } else { + myEditor.setCursorPos(sel); + } + + myEditor._handleSoftTabNavigation(dir, command); + + if (Array.isArray(expectedSel)) { + expect(myEditor.getSelections()).toEqual(expectedSel); + } else { + expect(myEditor.getCursorPos()).toEqual(expectedSel); + } + expect(myEditor.document.getText()).toEqual(expectedText); + } + + it("should move left by a soft tab if cursor is immediately after 1 indent level worth of spaces at beginning of line", function () { + myEditor.document.setText(" content"); + checkSoftTab({line: 0, ch: 4}, -1, "moveH", {line: 0, ch: 0}); + }); + it("should backspace by a soft tab if cursor is immediately after 1 indent level worth of spaces at beginning of line", function () { + myEditor.document.setText(" content"); + checkSoftTab({line: 0, ch: 4}, -1, "deleteH", {line: 0, ch: 0}, "content"); + }); + it("should move right by a soft tab if cursor is immediately before 1 indent level worth of spaces at beginning of line", function () { + myEditor.document.setText(" content"); + checkSoftTab({line: 0, ch: 0}, 1, "moveH", {line: 0, ch: 4}); + }); + it("should delete right by a soft tab if cursor is immediately before 1 indent level worth of spaces at beginning of line", function () { + myEditor.document.setText(" content"); + checkSoftTab({line: 0, ch: 0}, 1, "deleteH", {line: 0, ch: 0}, "content"); + }); + + it("should move left by a soft tab if cursor is immediately after 2 indent levels worth of spaces at beginning of line", function () { + myEditor.document.setText(" content"); + checkSoftTab({line: 0, ch: 8}, -1, "moveH", {line: 0, ch: 4}); + }); + it("should backspace by a soft tab if cursor is immediately after 2 indent levels worth of spaces at beginning of line", function () { + myEditor.document.setText(" content"); + checkSoftTab({line: 0, ch: 8}, -1, "deleteH", {line: 0, ch: 4}, " content"); + }); + it("should move right by a soft tab if cursor is immediately before 2 indent levels worth of spaces at beginning of line", function () { + myEditor.document.setText(" content"); + checkSoftTab({line: 0, ch: 0}, 1, "moveH", {line: 0, ch: 4}); + }); + it("should delete right by a soft tab if cursor is immediately before 2 indent levels worth of spaces at beginning of line", function () { + myEditor.document.setText(" content"); + checkSoftTab({line: 0, ch: 0}, 1, "deleteH", {line: 0, ch: 0}, " content"); + }); + it("should move left by a soft tab if cursor is exactly between 2 indent levels worth of spaces", function () { + myEditor.document.setText(" content"); + checkSoftTab({line: 0, ch: 4}, -1, "moveH", {line: 0, ch: 0}); + }); + it("should backspace by a soft tab if cursor is exactly between 2 indent levels worth of spaces", function () { + myEditor.document.setText(" content"); + checkSoftTab({line: 0, ch: 4}, -1, "deleteH", {line: 0, ch: 0}, " content"); + }); + it("should move right by a soft tab if cursor is exactly between 2 indent levels worth of spaces", function () { + myEditor.document.setText(" content"); + checkSoftTab({line: 0, ch: 4}, 1, "moveH", {line: 0, ch: 8}); + }); + it("should delete right by a soft tab if cursor is exactly between 2 indent levels worth of spaces", function () { + myEditor.document.setText(" content"); + checkSoftTab({line: 0, ch: 4}, 1, "deleteH", {line: 0, ch: 4}, " content"); + }); + + it("should move left to tab stop if cursor is 1 char after it", function () { + myEditor.document.setText(" "); + checkSoftTab({line: 0, ch: 5}, -1, "moveH", {line: 0, ch: 4}); + }); + it("should backspace to tab stop if cursor is 1 char after it", function () { + myEditor.document.setText(" "); + checkSoftTab({line: 0, ch: 5}, -1, "deleteH", {line: 0, ch: 4}, " "); + }); + it("should move right to tab stop if cursor is 1 char before it", function () { + myEditor.document.setText(" "); + checkSoftTab({line: 0, ch: 3}, 1, "moveH", {line: 0, ch: 4}); + }); + it("should delete right to tab stop if cursor is 1 char before it", function () { + myEditor.document.setText(" "); + checkSoftTab({line: 0, ch: 3}, 1, "deleteH", {line: 0, ch: 3}, " "); + }); + it("should move left to tab stop if cursor is 2 chars after it", function () { + myEditor.document.setText(" "); + checkSoftTab({line: 0, ch: 6}, -1, "moveH", {line: 0, ch: 4}); + }); + it("should backspace to tab stop if cursor is 2 chars after it", function () { + myEditor.document.setText(" "); + checkSoftTab({line: 0, ch: 6}, -1, "deleteH", {line: 0, ch: 4}, " "); + }); + it("should move right to tab stop if cursor is 2 chars before it", function () { + myEditor.document.setText(" "); + checkSoftTab({line: 0, ch: 2}, 1, "moveH", {line: 0, ch: 4}); + }); + it("should delete right to tab stop if cursor is 2 chars before it", function () { + myEditor.document.setText(" "); + checkSoftTab({line: 0, ch: 2}, 1, "deleteH", {line: 0, ch: 2}, " "); + }); + + it("should not handle soft tab if moving left after non-whitespace content", function () { + myEditor.document.setText("start content"); + checkSoftTab({line: 0, ch: 8}, -1, "moveH", {line: 0, ch: 7}); + }); + it("should not handle soft tab if moving right after non-whitespace content", function () { + myEditor.document.setText("start content"); + checkSoftTab({line: 0, ch: 5}, 1, "moveH", {line: 0, ch: 6}); + }); + it("should not handle soft tab if moving left at beginning of line", function () { + myEditor.document.setText("foo\n content"); + checkSoftTab({line: 1, ch: 0}, -1, "moveH", {line: 0, ch: 3}); + }); + it("should not handle soft tab if moving right at end of line", function () { + myEditor.document.setText(" content\nfoo"); + checkSoftTab({line: 0, ch: 11}, 1, "moveH", {line: 1, ch: 0}); + }); + it("should not handle soft tab if moving right at end of line would cause a jump past end of line", function () { + myEditor.document.setText(" four "); + checkSoftTab({line: 0, ch: 8}, 1, "moveH", {line: 0, ch: 9}); + }); + + describe("with multiple selections", function () { + it("should move left over a soft tab from multiple aligned cursors", function () { + myEditor.document.setText(" one\n two\n three\n"); + checkSoftTab([{start: {line: 0, ch: 4}, end: {line: 0, ch: 4}}, + {start: {line: 1, ch: 4}, end: {line: 1, ch: 4}}, + {start: {line: 2, ch: 4}, end: {line: 2, ch: 4}}], + -1, "moveH", + [{start: {line: 0, ch: 0}, end: {line: 0, ch: 0}, primary: false, reversed: false}, + {start: {line: 1, ch: 0}, end: {line: 1, ch: 0}, primary: false, reversed: false}, + {start: {line: 2, ch: 0}, end: {line: 2, ch: 0}, primary: true, reversed: false}]); + }); + + it("should move right over a soft tab from multiple aligned cursors", function () { + myEditor.document.setText(" one\n two\n three\n"); + checkSoftTab([{start: {line: 0, ch: 0}, end: {line: 0, ch: 0}}, + {start: {line: 1, ch: 0}, end: {line: 1, ch: 0}}, + {start: {line: 2, ch: 0}, end: {line: 2, ch: 0}}], + 1, "moveH", + [{start: {line: 0, ch: 4}, end: {line: 0, ch: 4}, primary: false, reversed: false}, + {start: {line: 1, ch: 4}, end: {line: 1, ch: 4}, primary: false, reversed: false}, + {start: {line: 2, ch: 4}, end: {line: 2, ch: 4}, primary: true, reversed: false}]); + }); + + it("should backspace over a soft tab from multiple aligned cursors", function () { + myEditor.document.setText(" one\n two\n three\n"); + checkSoftTab([{start: {line: 0, ch: 4}, end: {line: 0, ch: 4}}, + {start: {line: 1, ch: 4}, end: {line: 1, ch: 4}}, + {start: {line: 2, ch: 4}, end: {line: 2, ch: 4}}], + -1, "deleteH", + [{start: {line: 0, ch: 0}, end: {line: 0, ch: 0}, primary: false, reversed: false}, + {start: {line: 1, ch: 0}, end: {line: 1, ch: 0}, primary: false, reversed: false}, + {start: {line: 2, ch: 0}, end: {line: 2, ch: 0}, primary: true, reversed: false}], + "one\ntwo\nthree\n"); + }); + + it("should delete right over a soft tab from multiple aligned cursors", function () { + myEditor.document.setText(" one\n two\n three\n"); + checkSoftTab([{start: {line: 0, ch: 0}, end: {line: 0, ch: 0}}, + {start: {line: 1, ch: 0}, end: {line: 1, ch: 0}}, + {start: {line: 2, ch: 0}, end: {line: 2, ch: 0}}], + 1, "deleteH", + [{start: {line: 0, ch: 0}, end: {line: 0, ch: 0}, primary: false, reversed: false}, + {start: {line: 1, ch: 0}, end: {line: 1, ch: 0}, primary: false, reversed: false}, + {start: {line: 2, ch: 0}, end: {line: 2, ch: 0}, primary: true, reversed: false}], + "one\ntwo\nthree\n"); + }); + + it("should move left to next soft tab from multiple cursors at same distance from tab stops", function () { + myEditor.document.setText(" one\n two\n three\n"); + checkSoftTab([{start: {line: 0, ch: 6}, end: {line: 0, ch: 6}}, + {start: {line: 1, ch: 2}, end: {line: 1, ch: 2}}, + {start: {line: 2, ch: 10}, end: {line: 2, ch: 10}}], + -1, "moveH", + [{start: {line: 0, ch: 4}, end: {line: 0, ch: 4}, primary: false, reversed: false}, + {start: {line: 1, ch: 0}, end: {line: 1, ch: 0}, primary: false, reversed: false}, + {start: {line: 2, ch: 8}, end: {line: 2, ch: 8}, primary: true, reversed: false}]); + }); + + it("should move right to next soft tab from multiple cursors at same distance from tab stops", function () { + myEditor.document.setText(" one\n two\n three\n"); + checkSoftTab([{start: {line: 0, ch: 5}, end: {line: 0, ch: 5}}, + {start: {line: 1, ch: 1}, end: {line: 1, ch: 1}}, + {start: {line: 2, ch: 9}, end: {line: 2, ch: 9}}], + 1, "moveH", + [{start: {line: 0, ch: 8}, end: {line: 0, ch: 8}, primary: false, reversed: false}, + {start: {line: 1, ch: 4}, end: {line: 1, ch: 4}, primary: false, reversed: false}, + {start: {line: 2, ch: 12}, end: {line: 2, ch: 12}, primary: true, reversed: false}]); + }); + + it("should backspace to next soft tab from multiple cursors at same distance from tab stops", function () { + myEditor.document.setText(" one\n two\n three\n"); + checkSoftTab([{start: {line: 0, ch: 6}, end: {line: 0, ch: 6}}, + {start: {line: 1, ch: 2}, end: {line: 1, ch: 2}}, + {start: {line: 2, ch: 10}, end: {line: 2, ch: 10}}], + -1, "deleteH", + [{start: {line: 0, ch: 4}, end: {line: 0, ch: 4}, primary: false, reversed: false}, + {start: {line: 1, ch: 0}, end: {line: 1, ch: 0}, primary: false, reversed: false}, + {start: {line: 2, ch: 8}, end: {line: 2, ch: 8}, primary: true, reversed: false}], + " one\n two\n three\n"); + }); + + it("should delete right to next soft tab from multiple cursors at same distance from tab stops", function () { + myEditor.document.setText(" one\n two\n three\n"); + checkSoftTab([{start: {line: 0, ch: 5}, end: {line: 0, ch: 5}}, + {start: {line: 1, ch: 1}, end: {line: 1, ch: 1}}, + {start: {line: 2, ch: 9}, end: {line: 2, ch: 9}}], + 1, "deleteH", + [{start: {line: 0, ch: 5}, end: {line: 0, ch: 5}, primary: false, reversed: false}, + {start: {line: 1, ch: 1}, end: {line: 1, ch: 1}, primary: false, reversed: false}, + {start: {line: 2, ch: 9}, end: {line: 2, ch: 9}, primary: true, reversed: false}], + " one\n two\n three\n" + ); + }); + + it("should do default move left from multiple cursors at different distances from tab stops", function () { + myEditor.document.setText(" one\n two\n three\n"); + checkSoftTab([{start: {line: 0, ch: 6}, end: {line: 0, ch: 6}}, + {start: {line: 1, ch: 1}, end: {line: 1, ch: 1}}, + {start: {line: 2, ch: 10}, end: {line: 2, ch: 10}}], + -1, "moveH", + [{start: {line: 0, ch: 5}, end: {line: 0, ch: 5}, primary: false, reversed: false}, + {start: {line: 1, ch: 0}, end: {line: 1, ch: 0}, primary: false, reversed: false}, + {start: {line: 2, ch: 9}, end: {line: 2, ch: 9}, primary: true, reversed: false}]); + }); + + it("should do default move right from multiple cursors at different distances from tab stops", function () { + myEditor.document.setText(" one\n two\n three\n"); + checkSoftTab([{start: {line: 0, ch: 5}, end: {line: 0, ch: 5}}, + {start: {line: 1, ch: 1}, end: {line: 1, ch: 1}}, + {start: {line: 2, ch: 10}, end: {line: 2, ch: 10}}], + 1, "moveH", + [{start: {line: 0, ch: 6}, end: {line: 0, ch: 6}, primary: false, reversed: false}, + {start: {line: 1, ch: 2}, end: {line: 1, ch: 2}, primary: false, reversed: false}, + {start: {line: 2, ch: 11}, end: {line: 2, ch: 11}, primary: true, reversed: false}]); + }); + + it("should do default backspace from multiple cursors at different distances from tab stops", function () { + myEditor.document.setText(" one\n two\n three\n"); + checkSoftTab([{start: {line: 0, ch: 5}, end: {line: 0, ch: 5}}, + {start: {line: 1, ch: 2}, end: {line: 1, ch: 2}}, + {start: {line: 2, ch: 10}, end: {line: 2, ch: 10}}], + -1, "deleteH", + [{start: {line: 0, ch: 4}, end: {line: 0, ch: 4}, primary: false, reversed: false}, + {start: {line: 1, ch: 1}, end: {line: 1, ch: 1}, primary: false, reversed: false}, + {start: {line: 2, ch: 9}, end: {line: 2, ch: 9}, primary: true, reversed: false}], + " one\n two\n three\n"); + }); + + it("should do default delete right from multiple cursors at different distances from tab stops", function () { + myEditor.document.setText(" one\n two\n three\n"); + checkSoftTab([{start: {line: 0, ch: 5}, end: {line: 0, ch: 5}}, + {start: {line: 1, ch: 1}, end: {line: 1, ch: 1}}, + {start: {line: 2, ch: 8}, end: {line: 2, ch: 8}}], + 1, "deleteH", + [{start: {line: 0, ch: 5}, end: {line: 0, ch: 5}, primary: false, reversed: false}, + {start: {line: 1, ch: 1}, end: {line: 1, ch: 1}, primary: false, reversed: false}, + {start: {line: 2, ch: 8}, end: {line: 2, ch: 8}, primary: true, reversed: false}], + " one\n two\n three\n" + ); + }); + + it("should do default move left from multiple cursors if one is inside content", function () { + myEditor.document.setText(" one\n two\n three\n"); + checkSoftTab([{start: {line: 0, ch: 6}, end: {line: 0, ch: 6}}, + {start: {line: 1, ch: 9}, end: {line: 1, ch: 9}}, + {start: {line: 2, ch: 10}, end: {line: 2, ch: 10}}], + -1, "moveH", + [{start: {line: 0, ch: 5}, end: {line: 0, ch: 5}, primary: false, reversed: false}, + {start: {line: 1, ch: 8}, end: {line: 1, ch: 8}, primary: false, reversed: false}, + {start: {line: 2, ch: 9}, end: {line: 2, ch: 9}, primary: true, reversed: false}]); + }); + + it("should do default move right from multiple cursors if one is inside content", function () { + myEditor.document.setText(" one\n two\n three\n"); + checkSoftTab([{start: {line: 0, ch: 5}, end: {line: 0, ch: 5}}, + {start: {line: 1, ch: 1}, end: {line: 1, ch: 1}}, + {start: {line: 2, ch: 14}, end: {line: 2, ch: 14}}], + 1, "moveH", + [{start: {line: 0, ch: 6}, end: {line: 0, ch: 6}, primary: false, reversed: false}, + {start: {line: 1, ch: 2}, end: {line: 1, ch: 2}, primary: false, reversed: false}, + {start: {line: 2, ch: 15}, end: {line: 2, ch: 15}, primary: true, reversed: false}]); + }); + + it("should do default backspace from multiple cursors if one is inside content", function () { + myEditor.document.setText(" one\n two\n three\n"); + checkSoftTab([{start: {line: 0, ch: 10}, end: {line: 0, ch: 10}}, + {start: {line: 1, ch: 2}, end: {line: 1, ch: 2}}, + {start: {line: 2, ch: 10}, end: {line: 2, ch: 10}}], + -1, "deleteH", + [{start: {line: 0, ch: 9}, end: {line: 0, ch: 9}, primary: false, reversed: false}, + {start: {line: 1, ch: 1}, end: {line: 1, ch: 1}, primary: false, reversed: false}, + {start: {line: 2, ch: 9}, end: {line: 2, ch: 9}, primary: true, reversed: false}], + " oe\n two\n three\n"); + }); + + it("should do default delete right from multiple cursors if one is inside content", function () { + myEditor.document.setText(" one\n two\n three\n"); + checkSoftTab([{start: {line: 0, ch: 5}, end: {line: 0, ch: 5}}, + {start: {line: 1, ch: 1}, end: {line: 1, ch: 1}}, + {start: {line: 2, ch: 15}, end: {line: 2, ch: 15}}], + 1, "deleteH", + [{start: {line: 0, ch: 5}, end: {line: 0, ch: 5}, primary: false, reversed: false}, + {start: {line: 1, ch: 1}, end: {line: 1, ch: 1}, primary: false, reversed: false}, + {start: {line: 2, ch: 15}, end: {line: 2, ch: 15}, primary: true, reversed: false}], + " one\n two\n thre\n" + ); + }); + + it("should collapse ranges and handle other consistent soft tabs when moving left", function () { + myEditor.document.setText(" one\n two\n three\n"); + checkSoftTab([{start: {line: 0, ch: 6}, end: {line: 0, ch: 6}}, + {start: {line: 1, ch: 2}, end: {line: 1, ch: 6}}, + {start: {line: 2, ch: 10}, end: {line: 2, ch: 10}}], + -1, "moveH", + [{start: {line: 0, ch: 4}, end: {line: 0, ch: 4}, primary: false, reversed: false}, + {start: {line: 1, ch: 2}, end: {line: 1, ch: 2}, primary: false, reversed: false}, + {start: {line: 2, ch: 8}, end: {line: 2, ch: 8}, primary: true, reversed: false}]); + }); + + it("should collapse ranges and handle other consistent soft tabs when moving right", function () { + myEditor.document.setText(" one\n two\n three\n"); + checkSoftTab([{start: {line: 0, ch: 5}, end: {line: 0, ch: 9}}, + {start: {line: 1, ch: 1}, end: {line: 1, ch: 1}}, + {start: {line: 2, ch: 9}, end: {line: 2, ch: 9}}], + 1, "moveH", + [{start: {line: 0, ch: 9}, end: {line: 0, ch: 9}, primary: false, reversed: false}, + {start: {line: 1, ch: 4}, end: {line: 1, ch: 4}, primary: false, reversed: false}, + {start: {line: 2, ch: 12}, end: {line: 2, ch: 12}, primary: true, reversed: false}]); + }); + + it("should delete ranges and do nothing with cursors when backspacing", function () { + myEditor.document.setText(" one\n two\n three\n"); + checkSoftTab([{start: {line: 0, ch: 6}, end: {line: 0, ch: 6}}, + {start: {line: 1, ch: 8}, end: {line: 1, ch: 10}}, + {start: {line: 2, ch: 10}, end: {line: 2, ch: 10}}], + -1, "deleteH", + [{start: {line: 0, ch: 6}, end: {line: 0, ch: 6}, primary: false, reversed: false}, + {start: {line: 1, ch: 8}, end: {line: 1, ch: 8}, primary: false, reversed: false}, + {start: {line: 2, ch: 10}, end: {line: 2, ch: 10}, primary: true, reversed: false}], + " one\n o\n three\n"); + }); + + it("should delete ranges and do nothing with cursors when deleting right", function () { + myEditor.document.setText(" one\n two\n three\n"); + checkSoftTab([{start: {line: 0, ch: 5}, end: {line: 0, ch: 5}}, + {start: {line: 1, ch: 1}, end: {line: 1, ch: 1}}, + {start: {line: 2, ch: 14}, end: {line: 2, ch: 16}}], + 1, "deleteH", + [{start: {line: 0, ch: 5}, end: {line: 0, ch: 5}, primary: false, reversed: false}, + {start: {line: 1, ch: 1}, end: {line: 1, ch: 1}, primary: false, reversed: false}, + {start: {line: 2, ch: 14}, end: {line: 2, ch: 14}, primary: true, reversed: false}], + " one\n two\n the\n" + ); + }); + + }); + }); + + describe("Smart Tab handling", function () { + function makeEditor(content, useTabs) { + createTestEditor(content, "javascript"); + var instance = myEditor._codeMirror; + instance.setOption("indentWithTabs", useTabs); + instance.setOption("indentUnit", 4); + } + + it("should indent and move cursor to correct position if at beginning of an empty line - spaces", function () { + var content = "function foo() {\n" + + " if (bar) {\n" + + "\n" + + " }\n" + + "}"; + makeEditor(content); + myEditor.setCursorPos({line: 2, ch: 0}); + myEditor._handleTabKey(); + expect(myEditor.getSelection()).toEqual({start: {line: 2, ch: 8}, end: {line: 2, ch: 8}, reversed: false}); + + var lines = content.split("\n"); + lines[2] = " "; + expect(myEditor.document.getText()).toEqual(lines.join("\n")); + }); + + it("should indent and move cursor to correct position if at beginning of an empty line - tabs", function () { + var content = "function foo() {\n" + + "\tif (bar) {\n" + + "\n" + + "\t}\n" + + "}"; + makeEditor(content, true); + myEditor.setCursorPos({line: 2, ch: 0}); + myEditor._handleTabKey(); + expect(myEditor.getSelection()).toEqual({start: {line: 2, ch: 2}, end: {line: 2, ch: 2}, reversed: false}); + + var lines = content.split("\n"); + lines[2] = "\t\t"; + expect(myEditor.document.getText()).toEqual(lines.join("\n")); + }); + + it("should move cursor to end of whitespace (without adding more) if at beginning of a line with correct amount of whitespace - spaces", function () { + var content = "function foo() {\n" + + " if (bar) {\n" + + " \n" + + " }\n" + + "}"; + makeEditor(content); + myEditor.setCursorPos({line: 2, ch: 0}); + myEditor._handleTabKey(); + expect(myEditor.getSelection()).toEqual({start: {line: 2, ch: 8}, end: {line: 2, ch: 8}, reversed: false}); + + expect(myEditor.document.getText()).toEqual(content); + }); + + it("should move cursor to end of whitespace (without adding more) if at beginning of a line with correct amount of whitespace - tabs", function () { + var content = "function foo() {\n" + + "\tif (bar) {\n" + + "\t\t\n" + + "\t}\n" + + "}"; + makeEditor(content, true); + myEditor.setCursorPos({line: 2, ch: 0}); + myEditor._handleTabKey(); + expect(myEditor.getSelection()).toEqual({start: {line: 2, ch: 2}, end: {line: 2, ch: 2}, reversed: false}); + expect(myEditor.document.getText()).toEqual(content); + }); + + it("should add another indent whitespace if already past correct indent level on an all whitespace line - spaces", function () { + var content = "function foo() {\n" + + " if (bar) {\n" + + " \n" + + " }\n" + + "}"; + makeEditor(content); + myEditor.setCursorPos({line: 2, ch: 12}); + myEditor._handleTabKey(); + expect(myEditor.getSelection()).toEqual({start: {line: 2, ch: 16}, end: {line: 2, ch: 16}, reversed: false}); + + var lines = content.split("\n"); + lines[2] = " " + lines[2]; + expect(myEditor.document.getText()).toEqual(lines.join("\n")); + + }); + + it("should add another indent whitespace if already past correct indent level on an all whitespace line - tabs", function () { + var content = "function foo() {\n" + + "\tif (bar) {\n" + + "\t\t\t\n" + + "\t}\n" + + "}"; + makeEditor(content, true); + myEditor.setCursorPos({line: 2, ch: 3}); + myEditor._handleTabKey(); + expect(myEditor.getSelection()).toEqual({start: {line: 2, ch: 4}, end: {line: 2, ch: 4}, reversed: false}); + + var lines = content.split("\n"); + lines[2] = "\t" + lines[2]; + expect(myEditor.document.getText()).toEqual(lines.join("\n")); + + }); + + it("should indent improperly indented line to proper level and move cursor to beginning of content if cursor is in whitespace before content - spaces", function () { + var content = "function foo() {\n" + + " if (bar) {\n" + + " indentme();\n" + + " }\n" + + "}"; + makeEditor(content); + myEditor.setCursorPos({line: 2, ch: 2}); + myEditor._handleTabKey(); + expect(myEditor.getSelection()).toEqual({start: {line: 2, ch: 8}, end: {line: 2, ch: 8}, reversed: false}); + + var lines = content.split("\n"); + lines[2] = " indentme();"; + expect(myEditor.document.getText()).toEqual(lines.join("\n")); + }); + + it("should indent improperly indented line to proper level and move cursor to beginning of content if cursor is in whitespace before content - tabs", function () { + var content = "function foo() {\n" + + "\tif (bar) {\n" + + "\tindentme();\n" + + "\t}\n" + + "}"; + makeEditor(content, true); + myEditor.setCursorPos({line: 2, ch: 0}); + myEditor._handleTabKey(); + expect(myEditor.getSelection()).toEqual({start: {line: 2, ch: 2}, end: {line: 2, ch: 2}, reversed: false}); + + var lines = content.split("\n"); + lines[2] = "\t\tindentme();"; + expect(myEditor.document.getText()).toEqual(lines.join("\n")); + }); + + it("should add one indent level (not autoindent) if cursor is immediately before content - spaces", function () { + var content = "function foo() {\n" + + " if (bar) {\n" + + " indentme();\n" + + " }\n" + + "}"; + makeEditor(content); + myEditor.setCursorPos({line: 1, ch: 8}); + myEditor._handleTabKey(); + expect(myEditor.getSelection()).toEqual({start: {line: 1, ch: 12}, end: {line: 1, ch: 12}, reversed: false}); + + var lines = content.split("\n"); + lines[1] = " if (bar) {"; + expect(myEditor.document.getText()).toEqual(lines.join("\n")); + }); + + it("should add one indent level (not autoindent) if cursor is immediately before content - tabs", function () { + var content = "function foo() {\n" + + "\t\tif (bar) {\n" + + "\tindentme();\n" + + "\t}\n" + + "}"; + makeEditor(content, true); + myEditor.setCursorPos({line: 1, ch: 2}); + myEditor._handleTabKey(); + expect(myEditor.getSelection()).toEqual({start: {line: 1, ch: 3}, end: {line: 1, ch: 3}, reversed: false}); + + var lines = content.split("\n"); + lines[1] = "\t\t\tif (bar) {"; + expect(myEditor.document.getText()).toEqual(lines.join("\n")); + }); + + it("should move cursor and not indent further if cursor is in whitespace before properly indented line - spaces", function () { + var content = "function foo() {\n" + + " if (bar) {\n" + + " indentme();\n" + + " }\n" + + "}"; + makeEditor(content); + myEditor.setCursorPos({line: 2, ch: 4}); + myEditor._handleTabKey(); + expect(myEditor.getSelection()).toEqual({start: {line: 2, ch: 8}, end: {line: 2, ch: 8}, reversed: false}); + expect(myEditor.document.getText()).toEqual(content); + }); + + it("should move cursor and not indent further if cursor is in whitespace before properly indented line - tabs", function () { + var content = "function foo() {\n" + + "\tif (bar) {\n" + + "\t\tindentme();\n" + + "\t}\n" + + "}"; + makeEditor(content, true); + myEditor.setCursorPos({line: 2, ch: 1}); + myEditor._handleTabKey(); + expect(myEditor.getSelection()).toEqual({start: {line: 2, ch: 2}, end: {line: 2, ch: 2}, reversed: false}); + expect(myEditor.document.getText()).toEqual(content); + }); + + it("should add an indent level if cursor is immediately before content on properly indented line - spaces", function () { + var content = "function foo() {\n" + + " if (bar) {\n" + + " indentme();\n" + + " }\n" + + "}"; + makeEditor(content); + myEditor.setCursorPos({line: 2, ch: 8}); + myEditor._handleTabKey(); + expect(myEditor.getSelection()).toEqual({start: {line: 2, ch: 12}, end: {line: 2, ch: 12}, reversed: false}); + + var lines = content.split("\n"); + lines[2] = " indentme();"; + expect(myEditor.document.getText()).toEqual(lines.join("\n")); + }); + + it("should add an indent level if cursor is immediately before content on properly indented line - tabs", function () { + var content = "function foo() {\n" + + "\tif (bar) {\n" + + "\t\tindentme();\n" + + "\t}\n" + + "}"; + makeEditor(content, true); + myEditor.setCursorPos({line: 2, ch: 2}); + myEditor._handleTabKey(); + expect(myEditor.getSelection()).toEqual({start: {line: 2, ch: 3}, end: {line: 2, ch: 3}, reversed: false}); + + var lines = content.split("\n"); + lines[2] = "\t\t\tindentme();"; + expect(myEditor.document.getText()).toEqual(lines.join("\n")); + }); + + it("should add an indent level to each line (regardless of existing indentation) if selection spans multiple lines - spaces", function () { + var content = "function foo() {\n" + + " if (bar) {\n" + + " indentme();\n" + + " }\n" + + "}", + i; + makeEditor(content); + myEditor.setSelection({line: 1, ch: 6}, {line: 3, ch: 3}); + myEditor._handleTabKey(); + expect(myEditor.getSelection()).toEqual({start: {line: 1, ch: 10}, end: {line: 3, ch: 8}, reversed: false}); + + var lines = content.split("\n"); + for (i = 1; i <= 3; i++) { + lines[i] = " " + lines[i]; + } + expect(myEditor.document.getText()).toEqual(lines.join("\n")); + }); + + it("should add an indent level to each line (regardless of existing indentation) if selection spans multiple lines - tabs", function () { + var content = "function foo() {\n" + + "\tif (bar) {\n" + + "\tindentme();\n" + + "\t}\n" + + "}", + i; + makeEditor(content, true); + myEditor.setSelection({line: 1, ch: 0}, {line: 3, ch: 1}); + myEditor._handleTabKey(); + expect(myEditor.getSelection()).toEqual({start: {line: 1, ch: 2}, end: {line: 3, ch: 2}, reversed: false}); + + var lines = content.split("\n"); + for (i = 1; i <= 3; i++) { + lines[i] = "\t" + lines[i]; + } + expect(myEditor.document.getText()).toEqual(lines.join("\n")); + }); + + it("should add spaces to indent to the next soft tab stop if cursor is in the middle of a line after non-whitespace content - spaces", function () { + var content = "function foo() {\n" + + " if (bar) {\n" + + " indentme();\n" + + " }\n" + + "}", + i; + makeEditor(content); + myEditor.setSelection({line: 2, ch: 9}, {line: 2, ch: 9}); // should add three spaces to get to column 12 + myEditor._handleTabKey(); + expect(myEditor.getSelection()).toEqual({start: {line: 2, ch: 12}, end: {line: 2, ch: 12}, reversed: false}); + + var lines = content.split("\n"); + lines[2] = " inden tme();"; + expect(myEditor.document.getText()).toEqual(lines.join("\n")); + }); + + it("should insert a tab if cursor is in the middle of a line after non-whitespace content - tab", function () { + var content = "function foo() {\n" + + "\tif (bar) {\n" + + "\tindentme();\n" + + "\t}\n" + + "}", + i; + makeEditor(content, true); + myEditor.setSelection({line: 2, ch: 5}, {line: 2, ch: 5}); + myEditor._handleTabKey(); + expect(myEditor.getSelection()).toEqual({start: {line: 2, ch: 6}, end: {line: 2, ch: 6}, reversed: false}); + + var lines = content.split("\n"); + lines[2] = "\tinde\tntme();"; + expect(myEditor.document.getText()).toEqual(lines.join("\n")); + }); + + it("should add spaces to next soft tab before the beginning of the selection if it's a range in the middle of a line after non-whitespace content - spaces", function () { + var content = "function foo() {\n" + + " if (bar) {\n" + + " indentme();\n" + + " }\n" + + "}", + i; + makeEditor(content); + myEditor.setSelection({line: 2, ch: 9}, {line: 2, ch: 14}); // should add three spaces to get to column 12 + myEditor._handleTabKey(); + expect(myEditor.getSelection()).toEqual({start: {line: 2, ch: 12}, end: {line: 2, ch: 17}, reversed: false}); + + var lines = content.split("\n"); + lines[2] = " inden tme();"; + expect(myEditor.document.getText()).toEqual(lines.join("\n")); + }); + + it("should add a tab before the beginning of the selection if it's a range in the middle of a line after non-whitespace content - tabs", function () { + var content = "function foo() {\n" + + "\tif (bar) {\n" + + "\tindentme();\n" + + "\t}\n" + + "}", + i; + makeEditor(content, true); + myEditor.setSelection({line: 2, ch: 5}, {line: 2, ch: 8}); + myEditor._handleTabKey(); + expect(myEditor.getSelection()).toEqual({start: {line: 2, ch: 6}, end: {line: 2, ch: 9}, reversed: false}); + + var lines = content.split("\n"); + lines[2] = "\tinde\tntme();"; + expect(myEditor.document.getText()).toEqual(lines.join("\n")); + }); + + describe("with multiple selections", function () { + // In some of these tests we force a selection other than the last to be primary if the last selection is the + // one that triggers the behavior - so our code can't just cheat and rely on the primary selection. + + // Note that a side-effect of the way CM adds indent levels is that ends of ranges that are within the + // whitespace at the beginning of the line get pushed to the first non-whitespace character on the line, + // so in tests below that fall back to "add one indent level before each line", the selections might change + // more than you would expect by just adding a single indent level. + + it("should add one indent level before all selected lines if any of the selections is multiline - spaces", function () { + var content = "function foo() {\n" + + " if (bar) {\n" + + " indentme();\n" + + " }\n" + + "}", + i; + makeEditor(content); + myEditor.setSelections([{start: {line: 0, ch: 9}, end: {line: 0, ch: 9}, primary: true}, + {start: {line: 2, ch: 6}, end: {line: 3, ch: 3}}]); + myEditor._handleTabKey(); + expect(myEditor.getSelections()).toEqual([{start: {line: 0, ch: 13}, end: {line: 0, ch: 13}, primary: true, reversed: false}, + {start: {line: 2, ch: 10}, end: {line: 3, ch: 8}, primary: false, reversed: false}]); + + var lines = content.split("\n"); + lines[0] = " " + lines[0]; + lines[2] = " " + lines[2]; + lines[3] = " " + lines[3]; + expect(myEditor.document.getText()).toEqual(lines.join("\n")); + }); + + it("should add one indent level before all selected lines if any of the selections is multiline - tabs", function () { + var content = "function foo() {\n" + + "\tif (bar) {\n" + + "\tindentme();\n" + + "\t}\n" + + "}", + i; + makeEditor(content, true); + myEditor.setSelections([{start: {line: 0, ch: 6}, end: {line: 0, ch: 6}, primary: true}, + {start: {line: 2, ch: 3}, end: {line: 3, ch: 1}}]); + myEditor._handleTabKey(); + expect(myEditor.getSelections()).toEqual([{start: {line: 0, ch: 7}, end: {line: 0, ch: 7}, primary: true, reversed: false}, + {start: {line: 2, ch: 4}, end: {line: 3, ch: 2}, primary: false, reversed: false}]); + + var lines = content.split("\n"); + lines[0] = "\t" + lines[0]; + lines[2] = "\t" + lines[2]; + lines[3] = "\t" + lines[3]; + expect(myEditor.document.getText()).toEqual(lines.join("\n")); + }); + + it("should add spaces before each cursor to get to next tab stop if any selection is after first non-whitespace character in its line - spaces", function () { + var content = "function foo() {\n" + + " if (bar) {\n" + + " indentme();\n" + + " }\n" + + "}", + i; + makeEditor(content); + myEditor.setSelections([{start: {line: 0, ch: 3}, end: {line: 0, ch: 3}}, + {start: {line: 2, ch: 6}, end: {line: 2, ch: 6}}, + {start: {line: 3, ch: 2}, end: {line: 3, ch: 2}}]); + myEditor._handleTabKey(); + expect(myEditor.getSelections()).toEqual([{start: {line: 0, ch: 4}, end: {line: 0, ch: 4}, primary: false, reversed: false}, + {start: {line: 2, ch: 8}, end: {line: 2, ch: 8}, primary: false, reversed: false}, + {start: {line: 3, ch: 4}, end: {line: 3, ch: 4}, primary: true, reversed: false}]); + + var lines = content.split("\n"); + lines[0] = "fun ction foo() {"; + lines[2] = " in dentme();"; + lines[3] = " }"; + expect(myEditor.document.getText()).toEqual(lines.join("\n")); + }); + + it("should add a tab before each cursor if any selection is after first non-whitespace character in its line - tabs", function () { + var content = "function foo() {\n" + + "\tif (bar) {\n" + + "\tindentme();\n" + + "\t}\n" + + "}", + i; + makeEditor(content, true); + myEditor.setSelections([{start: {line: 0, ch: 3}, end: {line: 0, ch: 3}}, + {start: {line: 2, ch: 6}, end: {line: 2, ch: 6}}, + {start: {line: 3, ch: 1}, end: {line: 3, ch: 1}}]); + myEditor._handleTabKey(); + expect(myEditor.getSelections()).toEqual([{start: {line: 0, ch: 4}, end: {line: 0, ch: 4}, primary: false, reversed: false}, + {start: {line: 2, ch: 7}, end: {line: 2, ch: 7}, primary: false, reversed: false}, + {start: {line: 3, ch: 2}, end: {line: 3, ch: 2}, primary: true, reversed: false}]); + + var lines = content.split("\n"); + lines[0] = "fun\tction foo() {"; + lines[2] = "\tinden\ttme();"; + lines[3] = "\t\t}"; + expect(myEditor.document.getText()).toEqual(lines.join("\n")); + }); + + it("should add spaces before beginning of each range to get to next tab stop if any selection is after first non-whitespace character in its line - spaces", function () { + var content = "function foo() {\n" + + " if (bar) {\n" + + " indentme();\n" + + " }\n" + + "}", + i; + makeEditor(content); + myEditor.setSelections([{start: {line: 0, ch: 3}, end: {line: 0, ch: 6}}, + {start: {line: 2, ch: 6}, end: {line: 2, ch: 9}}, + {start: {line: 3, ch: 2}, end: {line: 3, ch: 4}}]); + myEditor._handleTabKey(); + expect(myEditor.getSelections()).toEqual([{start: {line: 0, ch: 4}, end: {line: 0, ch: 7}, primary: false, reversed: false}, + {start: {line: 2, ch: 8}, end: {line: 2, ch: 11}, primary: false, reversed: false}, + {start: {line: 3, ch: 4}, end: {line: 3, ch: 6}, primary: true, reversed: false}]); + + var lines = content.split("\n"); + lines[0] = "fun ction foo() {"; + lines[2] = " in dentme();"; + lines[3] = " }"; + expect(myEditor.document.getText()).toEqual(lines.join("\n")); + }); + + it("should add a tab before beginning of each range if any selection is after first non-whitespace character in its line - tabs", function () { + var content = "function foo() {\n" + + "\tif (bar) {\n" + + "\tindentme();\n" + + "\t}\n" + + "}", + i; + makeEditor(content, true); + myEditor.setSelections([{start: {line: 0, ch: 3}, end: {line: 0, ch: 6}}, + {start: {line: 2, ch: 6}, end: {line: 2, ch: 9}}, + {start: {line: 3, ch: 1}, end: {line: 3, ch: 2}}]); + myEditor._handleTabKey(); + expect(myEditor.getSelections()).toEqual([{start: {line: 0, ch: 4}, end: {line: 0, ch: 7}, primary: false, reversed: false}, + {start: {line: 2, ch: 7}, end: {line: 2, ch: 10}, primary: false, reversed: false}, + {start: {line: 3, ch: 2}, end: {line: 3, ch: 3}, primary: true, reversed: false}]); + + var lines = content.split("\n"); + lines[0] = "fun\tction foo() {"; + lines[2] = "\tinden\ttme();"; + lines[3] = "\t\t}"; + expect(myEditor.document.getText()).toEqual(lines.join("\n")); + }); + + it("should add spaces before each cursor to get to next tab stop (not autoindent) if any selection is exactly before the first non-whitespace character on the line - spaces", function () { + var content = "function foo() {\n" + + " if (bar) {\n" + + " indentme();\n" + + " }\n" + + "}", + i; + makeEditor(content); + myEditor.setSelections([{start: {line: 1, ch: 4}, end: {line: 1, ch: 4}, primary: true}, // should not move + {start: {line: 2, ch: 4}, end: {line: 2, ch: 4}}]); // should get indented and move + myEditor._handleTabKey(); + expect(myEditor.getSelections()).toEqual([{start: {line: 1, ch: 8}, end: {line: 1, ch: 8}, primary: true, reversed: false}, + {start: {line: 2, ch: 8}, end: {line: 2, ch: 8}, primary: false, reversed: false}]); + + var lines = content.split("\n"); + lines[1] = " if (bar) {"; + lines[2] = " indentme();"; + expect(myEditor.document.getText()).toEqual(lines.join("\n")); + }); + + it("should add a tab at each cursor (not autoindent) if any selection is exactly before the first non-whitespace character on the line - tabs", function () { + var content = "function foo() {\n" + + "\tif (bar) {\n" + + "\tindentme();\n" + + "\t}\n" + + "}", + i; + makeEditor(content, true); + myEditor.setSelections([{start: {line: 1, ch: 1}, end: {line: 1, ch: 1}, primary: true}, // should not move + {start: {line: 2, ch: 1}, end: {line: 2, ch: 1}}]); // should get indented and move + myEditor._handleTabKey(); + expect(myEditor.getSelections()).toEqual([{start: {line: 1, ch: 2}, end: {line: 1, ch: 2}, primary: true, reversed: false}, + {start: {line: 2, ch: 2}, end: {line: 2, ch: 2}, primary: false, reversed: false}]); + + var lines = content.split("\n"); + lines[1] = "\t\tif (bar) {"; + lines[2] = "\t\tindentme();"; + expect(myEditor.document.getText()).toEqual(lines.join("\n")); + }); + + it("should try to autoindent each line if all cursors are in start-of-line whitespace, and if at least one cursor changed position or indent was added, do nothing further - spaces", function () { + var content = "function foo() {\n" + + " if (bar) {\n" + + " indentme();\n" + + " }\n" + + "}", + i; + makeEditor(content); + myEditor.setSelections([{start: {line: 1, ch: 2}, end: {line: 1, ch: 2}, primary: true}, // should not move + {start: {line: 2, ch: 2}, end: {line: 2, ch: 2}}]); // should get indented and move + myEditor._handleTabKey(); + expect(myEditor.getSelections()).toEqual([{start: {line: 1, ch: 4}, end: {line: 1, ch: 4}, primary: true, reversed: false}, + {start: {line: 2, ch: 8}, end: {line: 2, ch: 8}, primary: false, reversed: false}]); + + var lines = content.split("\n"); + lines[2] = " indentme();"; + expect(myEditor.document.getText()).toEqual(lines.join("\n")); + }); + + it("should try to autoindent each line if all cursors are in start-of-line whitespace, and if at least one cursor changed position or indent was added, do nothing further - tabs", function () { + var content = "function foo() {\n" + + "\tif (bar) {\n" + + "\tindentme();\n" + + "\t}\n" + + "}", + i; + makeEditor(content, true); + myEditor.setSelections([{start: {line: 1, ch: 0}, end: {line: 1, ch: 0}, primary: true}, // should not move + {start: {line: 2, ch: 0}, end: {line: 2, ch: 0}}]); // should get indented and move + myEditor._handleTabKey(); + expect(myEditor.getSelections()).toEqual([{start: {line: 1, ch: 1}, end: {line: 1, ch: 1}, primary: true, reversed: false}, + {start: {line: 2, ch: 2}, end: {line: 2, ch: 2}, primary: false, reversed: false}]); + + var lines = content.split("\n"); + lines[2] = "\t\tindentme();"; + expect(myEditor.document.getText()).toEqual(lines.join("\n")); + }); + + it("should try to autoindent each line if all cursors are in start-of-line whitespace, but if no cursors changed position or added indent, add an indent to the beginning of each line - spaces", function () { + var content = "function foo() {\n" + + " if (bar) {\n" + + " indentme();\n" + // indent already correct + " }\n" + + "}", + i; + makeEditor(content); + myEditor.setSelections([{start: {line: 1, ch: 4}, end: {line: 1, ch: 4}}, + {start: {line: 2, ch: 8}, end: {line: 2, ch: 8}}]); + myEditor._handleTabKey(); + expect(myEditor.getSelections()).toEqual([{start: {line: 1, ch: 8}, end: {line: 1, ch: 8}, primary: false, reversed: false}, + {start: {line: 2, ch: 12}, end: {line: 2, ch: 12}, primary: true, reversed: false}]); + + var lines = content.split("\n"); + lines[1] = " if (bar) {"; + lines[2] = " indentme();"; + expect(myEditor.document.getText()).toEqual(lines.join("\n")); + }); + + it("should try to autoindent each line if all cursors are in start-of-line whitespace, but if no cursors changed position or added indent, add an indent to the beginning of each line - tabs", function () { + var content = "function foo() {\n" + + "\tif (bar) {\n" + + "\t\tindentme();\n" + // indent already correct + "\t}\n" + + "}", + i; + makeEditor(content, true); + myEditor.setSelections([{start: {line: 1, ch: 1}, end: {line: 1, ch: 1}}, + {start: {line: 2, ch: 2}, end: {line: 2, ch: 2}}]); + myEditor._handleTabKey(); + expect(myEditor.getSelections()).toEqual([{start: {line: 1, ch: 2}, end: {line: 1, ch: 2}, primary: false, reversed: false}, + {start: {line: 2, ch: 3}, end: {line: 2, ch: 3}, primary: true, reversed: false}]); + + var lines = content.split("\n"); + lines[1] = "\t\tif (bar) {"; + lines[2] = "\t\t\tindentme();"; + expect(myEditor.document.getText()).toEqual(lines.join("\n")); + }); + }); + }); }); }); diff --git a/test/spec/EditorCommandHandlers-test.js b/test/spec/EditorCommandHandlers-test.js index adad65ba093..3c9d8174556 100644 --- a/test/spec/EditorCommandHandlers-test.js +++ b/test/spec/EditorCommandHandlers-test.js @@ -33,7 +33,8 @@ define(function (require, exports, module) { Commands = require("command/Commands"), CommandManager = require("command/CommandManager"), LanguageManager = require("language/LanguageManager"), - SpecRunnerUtils = require("spec/SpecRunnerUtils"); + SpecRunnerUtils = require("spec/SpecRunnerUtils"), + _ = require("thirdparty/lodash"); describe("EditorCommandHandlers", function () { @@ -90,9 +91,21 @@ define(function (require, exports, module) { expect(selection.start).toEqual(pos); } function expectSelection(sel) { + if (!sel.reversed) { + sel.reversed = false; + } expect(myEditor.getSelection()).toEqual(sel); } - + function expectSelections(sels) { + expect(myEditor.getSelections()).toEqual(sels); + } + function contentWithDeletedLines(lineNums) { + var lines = defaultContent.split("\n"); + _.forEachRight(lineNums, function (num) { + lines.splice(num, 1); + }); + return lines.join("\n"); + } // Helper function for creating a test window function createTestWindow(spec) { @@ -152,6 +165,52 @@ define(function (require, exports, module) { SpecRunnerUtils.closeTestWindow(); } + /** + * Invokes Toggle Line or Block Comment, expects the given selection/cursor & document text, invokes + * it a 2nd time, and then expects the original selection/cursor & document text again. + * @param {!string} expectedCommentedText + * @param {!{ch:number,line:number}|{start:{ch:number,line:number},end:{ch:number,line:number}}} expectedCommentedSel + * @param {?string} expectedSelText If provided, the text that should be selected after the first comment operation. + * @param {!string} type Either "block" or "line". + */ + function testToggleComment(expectedCommentedText, expectedCommentedSel, expectedSelText, type) { + var command = (type === "block" ? Commands.EDIT_BLOCK_COMMENT : Commands.EDIT_LINE_COMMENT); + + function expectSel(sel) { + if (Array.isArray(sel)) { + expectSelections(sel); + } else if (sel.start) { + expectSelection(sel); + } else { + expectCursorAt(sel); + } + } + + var startingContent = myDocument.getText(); + var startingSel = myEditor.getSelections(); + + // Toggle comment on + CommandManager.execute(command, myEditor); + expect(myDocument.getText()).toEqual(expectedCommentedText); + expectSel(expectedCommentedSel); + if (expectedSelText) { + expect(myEditor.getSelectedText()).toEqual(expectedSelText); + } + + runs(function () { + CommandManager.execute(command, myEditor); + expect(myDocument.getText()).toEqual(startingContent); + expectSel(startingSel); + }); + } + + function testToggleLine(expectedCommentedText, expectedCommentedSel, expectedSelText) { + testToggleComment(expectedCommentedText, expectedCommentedSel, expectedSelText, "line"); + } + + function testToggleBlock(expectedCommentedText, expectedCommentedSel, expectedSelText) { + testToggleComment(expectedCommentedText, expectedCommentedSel, expectedSelText, "block"); + } describe("Line comment/uncomment", function () { beforeEach(setupFullEditor); @@ -159,120 +218,70 @@ define(function (require, exports, module) { it("should comment/uncomment a single line, cursor at start", function () { myEditor.setCursorPos(3, 0); - CommandManager.execute(Commands.EDIT_LINE_COMMENT, myEditor); - var lines = defaultContent.split("\n"); lines[3] = "// a();"; var expectedText = lines.join("\n"); - - expect(myDocument.getText()).toEqual(expectedText); - expectCursorAt({line: 3, ch: 2}); - - CommandManager.execute(Commands.EDIT_LINE_COMMENT, myEditor); - - expect(myDocument.getText()).toEqual(defaultContent); - expectCursorAt({line: 3, ch: 0}); + + testToggleLine(expectedText, {line: 3, ch: 2}); }); it("should comment/uncomment a single line, cursor at end", function () { myEditor.setCursorPos(3, 12); - CommandManager.execute(Commands.EDIT_LINE_COMMENT, myEditor); - var lines = defaultContent.split("\n"); lines[3] = "// a();"; var expectedText = lines.join("\n"); - expect(myDocument.getText()).toEqual(expectedText); - expectCursorAt({line: 3, ch: 14}); - - CommandManager.execute(Commands.EDIT_LINE_COMMENT, myEditor); - - expect(myDocument.getText()).toEqual(defaultContent); - expectCursorAt({line: 3, ch: 12}); + testToggleLine(expectedText, {line: 3, ch: 14}); }); it("should comment/uncomment first line in file", function () { myEditor.setCursorPos(0, 0); - CommandManager.execute(Commands.EDIT_LINE_COMMENT, myEditor); - var lines = defaultContent.split("\n"); lines[0] = "//function foo() {"; var expectedText = lines.join("\n"); - expect(myDocument.getText()).toEqual(expectedText); - expectCursorAt({line: 0, ch: 2}); - - CommandManager.execute(Commands.EDIT_LINE_COMMENT, myEditor); - - expect(myDocument.getText()).toEqual(defaultContent); - expectCursorAt({line: 0, ch: 0}); + testToggleLine(expectedText, {line: 0, ch: 2}); }); it("should comment/uncomment a single partly-selected line", function () { // select "function" on line 1 myEditor.setSelection({line: 1, ch: 4}, {line: 1, ch: 12}); - CommandManager.execute(Commands.EDIT_LINE_COMMENT, myEditor); - var lines = defaultContent.split("\n"); lines[1] = "// function bar() {"; var expectedText = lines.join("\n"); - expect(myDocument.getText()).toEqual(expectedText); - expectSelection({start: {line: 1, ch: 6}, end: {line: 1, ch: 14}}); - - CommandManager.execute(Commands.EDIT_LINE_COMMENT, myEditor); - - expect(myDocument.getText()).toEqual(defaultContent); - expectSelection({start: {line: 1, ch: 4}, end: {line: 1, ch: 12}}); + testToggleLine(expectedText, {start: {line: 1, ch: 6}, end: {line: 1, ch: 14}}); }); it("should comment/uncomment a single selected line", function () { // selection covers all of line's text, but not \n at end myEditor.setSelection({line: 1, ch: 0}, {line: 1, ch: 20}); - CommandManager.execute(Commands.EDIT_LINE_COMMENT, myEditor); - var lines = defaultContent.split("\n"); lines[1] = "// function bar() {"; var expectedText = lines.join("\n"); - expect(myDocument.getText()).toEqual(expectedText); - expectSelection({start: {line: 1, ch: 0}, end: {line: 1, ch: 22}}); - - CommandManager.execute(Commands.EDIT_LINE_COMMENT, myEditor); - - expect(myDocument.getText()).toEqual(defaultContent); - expectSelection({start: {line: 1, ch: 0}, end: {line: 1, ch: 20}}); + testToggleLine(expectedText, {start: {line: 1, ch: 0}, end: {line: 1, ch: 22}}); }); it("should comment/uncomment a single fully-selected line (including LF)", function () { // selection including \n at end of line myEditor.setSelection({line: 1, ch: 0}, {line: 2, ch: 0}); - CommandManager.execute(Commands.EDIT_LINE_COMMENT, myEditor); - var lines = defaultContent.split("\n"); lines[1] = "// function bar() {"; var expectedText = lines.join("\n"); - expect(myDocument.getText()).toEqual(expectedText); - expectSelection({start: {line: 1, ch: 0}, end: {line: 2, ch: 0}}); - - CommandManager.execute(Commands.EDIT_LINE_COMMENT, myEditor); - - expect(myDocument.getText()).toEqual(defaultContent); - expectSelection({start: {line: 1, ch: 0}, end: {line: 2, ch: 0}}); + testToggleLine(expectedText, {start: {line: 1, ch: 0}, end: {line: 2, ch: 0}}); }); it("should comment/uncomment multiple selected lines", function () { // selection including \n at end of line myEditor.setSelection({line: 1, ch: 0}, {line: 6, ch: 0}); - CommandManager.execute(Commands.EDIT_LINE_COMMENT, myEditor); - var lines = defaultContent.split("\n"); lines[1] = "// function bar() {"; lines[2] = "// "; @@ -281,55 +290,31 @@ define(function (require, exports, module) { lines[5] = "// }"; var expectedText = lines.join("\n"); - expect(myDocument.getText()).toEqual(expectedText); - expectSelection({start: {line: 1, ch: 0}, end: {line: 6, ch: 0}}); - - CommandManager.execute(Commands.EDIT_LINE_COMMENT, myEditor); - - expect(myDocument.getText()).toEqual(defaultContent); - expectSelection({start: {line: 1, ch: 0}, end: {line: 6, ch: 0}}); + testToggleLine(expectedText, {start: {line: 1, ch: 0}, end: {line: 6, ch: 0}}); }); it("should comment/uncomment ragged multi-line selection", function () { myEditor.setSelection({line: 1, ch: 6}, {line: 3, ch: 9}); - CommandManager.execute(Commands.EDIT_LINE_COMMENT, myEditor); - var lines = defaultContent.split("\n"); lines[1] = "// function bar() {"; lines[2] = "// "; lines[3] = "// a();"; var expectedText = lines.join("\n"); - expect(myDocument.getText()).toEqual(expectedText); - expectSelection({start: {line: 1, ch: 8}, end: {line: 3, ch: 11}}); - - expect(myEditor.getSelectedText()).toEqual("nction bar() {\n// \n// a"); - - CommandManager.execute(Commands.EDIT_LINE_COMMENT, myEditor); - - expect(myDocument.getText()).toEqual(defaultContent); - expectSelection({start: {line: 1, ch: 6}, end: {line: 3, ch: 9}}); + testToggleLine(expectedText, {start: {line: 1, ch: 8}, end: {line: 3, ch: 11}}, "nction bar() {\n// \n// a"); }); it("should comment/uncomment when selection starts & ends on whitespace lines", function () { myEditor.setSelection({line: 2, ch: 0}, {line: 4, ch: 8}); - CommandManager.execute(Commands.EDIT_LINE_COMMENT, myEditor); - var lines = defaultContent.split("\n"); lines[2] = "// "; lines[3] = "// a();"; lines[4] = "// "; var expectedText = lines.join("\n"); - expect(myDocument.getText()).toEqual(expectedText); - expectSelection({start: {line: 2, ch: 0}, end: {line: 4, ch: 10}}); - - CommandManager.execute(Commands.EDIT_LINE_COMMENT, myEditor); - - expect(myDocument.getText()).toEqual(defaultContent); - expectSelection({start: {line: 2, ch: 0}, end: {line: 4, ch: 8}}); + testToggleLine(expectedText, {start: {line: 2, ch: 0}, end: {line: 4, ch: 10}}); }); it("should do nothing on whitespace line", function () { @@ -359,8 +344,6 @@ define(function (require, exports, module) { it("should comment/uncomment after select all", function () { myEditor.setSelection({line: 0, ch: 0}, {line: 7, ch: 1}); - CommandManager.execute(Commands.EDIT_LINE_COMMENT, myEditor); - var expectedText = "//function foo() {\n" + "// function bar() {\n" + "// \n" + @@ -369,13 +352,8 @@ define(function (require, exports, module) { "// }\n" + "//\n" + "//}"; - expect(myDocument.getText()).toEqual(expectedText); - expectSelection({start: {line: 0, ch: 0}, end: {line: 7, ch: 3}}); - - CommandManager.execute(Commands.EDIT_LINE_COMMENT, myEditor); - - expect(myDocument.getText()).toEqual(defaultContent); - expectSelection({start: {line: 0, ch: 0}, end: {line: 7, ch: 1}}); + + testToggleLine(expectedText, {start: {line: 0, ch: 0}, end: {line: 7, ch: 3}}); }); it("should comment/uncomment lines that were partially commented out already, our style", function () { @@ -388,21 +366,13 @@ define(function (require, exports, module) { // select lines 1-3 myEditor.setSelection({line: 1, ch: 0}, {line: 4, ch: 0}); - CommandManager.execute(Commands.EDIT_LINE_COMMENT, myEditor); - lines = defaultContent.split("\n"); lines[1] = "// function bar() {"; lines[2] = "// "; lines[3] = "//// a();"; var expectedText = lines.join("\n"); - expect(myDocument.getText()).toEqual(expectedText); - expectSelection({start: {line: 1, ch: 0}, end: {line: 4, ch: 0}}); - - CommandManager.execute(Commands.EDIT_LINE_COMMENT, myEditor); - - expect(myDocument.getText()).toEqual(startingContent); - expectSelection({start: {line: 1, ch: 0}, end: {line: 4, ch: 0}}); + testToggleLine(expectedText, {start: {line: 1, ch: 0}, end: {line: 4, ch: 0}}); }); it("should comment/uncomment lines that were partially commented out already, comment closer to code", function () { @@ -415,21 +385,13 @@ define(function (require, exports, module) { // select lines 1-3 myEditor.setSelection({line: 1, ch: 0}, {line: 4, ch: 0}); - CommandManager.execute(Commands.EDIT_LINE_COMMENT, myEditor); - lines = defaultContent.split("\n"); lines[1] = "// function bar() {"; lines[2] = "// "; lines[3] = "// //a();"; var expectedText = lines.join("\n"); - expect(myDocument.getText()).toEqual(expectedText); - expectSelection({start: {line: 1, ch: 0}, end: {line: 4, ch: 0}}); - - CommandManager.execute(Commands.EDIT_LINE_COMMENT, myEditor); - - expect(myDocument.getText()).toEqual(startingContent); - expectSelection({start: {line: 1, ch: 0}, end: {line: 4, ch: 0}}); + testToggleLine(expectedText, {start: {line: 1, ch: 0}, end: {line: 4, ch: 0}}); }); it("should uncomment indented, aligned comments", function () { @@ -472,6 +434,123 @@ define(function (require, exports, module) { expectSelection({start: {line: 1, ch: 0}, end: {line: 6, ch: 0}}); }); + describe("with multiple selections", function () { + it("should toggle comments on separate lines with cursor selections", function () { + myEditor.setSelections([{start: {line: 1, ch: 4}, end: {line: 1, ch: 4}}, + {start: {line: 3, ch: 4}, end: {line: 3, ch: 4}}]); + + var lines = defaultContent.split("\n"); + lines[1] = "// function bar() {"; + lines[3] = "// a();"; + var expectedText = lines.join("\n"); + + testToggleLine(expectedText, [{start: {line: 1, ch: 6}, end: {line: 1, ch: 6}, primary: false, reversed: false}, + {start: {line: 3, ch: 6}, end: {line: 3, ch: 6}, primary: true, reversed: false}]); + }); + + it("should toggle comments on separate lines with range selections", function () { + myEditor.setSelections([{start: {line: 1, ch: 4}, end: {line: 1, ch: 6}}, + {start: {line: 3, ch: 4}, end: {line: 3, ch: 6}}]); + + var lines = defaultContent.split("\n"); + lines[1] = "// function bar() {"; + lines[3] = "// a();"; + var expectedText = lines.join("\n"); + + testToggleLine(expectedText, [{start: {line: 1, ch: 6}, end: {line: 1, ch: 8}, primary: false, reversed: false}, + {start: {line: 3, ch: 6}, end: {line: 3, ch: 8}, primary: true, reversed: false}]); + }); + + it("should toggle comments on separate lines with multiline selections", function () { + myEditor.setSelections([{start: {line: 1, ch: 4}, end: {line: 2, ch: 6}}, + {start: {line: 3, ch: 4}, end: {line: 4, ch: 6}}]); + + var lines = defaultContent.split("\n"), i; + for (i = 1; i <= 4; i++) { + lines[i] = "//" + lines[i]; + } + var expectedText = lines.join("\n"); + + testToggleLine(expectedText, [{start: {line: 1, ch: 6}, end: {line: 2, ch: 8}, primary: false, reversed: false}, + {start: {line: 3, ch: 6}, end: {line: 4, ch: 8}, primary: true, reversed: false}]); + }); + + it("should adjust selections appropriately at start of line", function () { + myEditor.setSelections([{start: {line: 1, ch: 0}, end: {line: 1, ch: 0}}, + {start: {line: 3, ch: 0}, end: {line: 3, ch: 6}}]); + + var lines = defaultContent.split("\n"), i; + lines[1] = "// function bar() {"; + lines[3] = "// a();"; + var expectedText = lines.join("\n"); + + testToggleLine(expectedText, [{start: {line: 1, ch: 2}, end: {line: 1, ch: 2}, primary: false, reversed: false}, + {start: {line: 3, ch: 0}, end: {line: 3, ch: 8}, primary: true, reversed: false}]); + }); + + it("should only handle each line once, but preserve primary/reversed flags on ignored selections", function () { + myEditor.setSelections([{start: {line: 1, ch: 4}, end: {line: 1, ch: 4}}, + {start: {line: 1, ch: 6}, end: {line: 2, ch: 4}, primary: true}, + {start: {line: 3, ch: 4}, end: {line: 3, ch: 4}}, + {start: {line: 3, ch: 6}, end: {line: 3, ch: 8}, reversed: true}]); + + var lines = defaultContent.split("\n"), i; + for (i = 1; i <= 3; i++) { + lines[i] = "//" + lines[i]; + } + var expectedText = lines.join("\n"); + + testToggleLine(expectedText, [{start: {line: 1, ch: 6}, end: {line: 1, ch: 6}, primary: false, reversed: false}, + {start: {line: 1, ch: 8}, end: {line: 2, ch: 6}, primary: true, reversed: false}, + {start: {line: 3, ch: 6}, end: {line: 3, ch: 6}, primary: false, reversed: false}, + {start: {line: 3, ch: 8}, end: {line: 3, ch: 10}, primary: false, reversed: true}]); + + }); + + it("should properly toggle when some selections are already commented but others aren't", function () { + var lines = defaultContent.split("\n"); + lines[1] = "//" + lines[1]; + lines[5] = "//" + lines[5]; + var startingContent = lines.join("\n"); + myDocument.setText(startingContent); + + myEditor.setSelections([{start: {line: 1, ch: 4}, end: {line: 1, ch: 4}}, + {start: {line: 3, ch: 4}, end: {line: 3, ch: 4}}, + {start: {line: 5, ch: 4}, end: {line: 5, ch: 4}}]); + + lines[1] = lines[1].slice(2); + lines[3] = "//" + lines[3]; + lines[5] = lines[5].slice(2); + var expectedText = lines.join("\n"); + + testToggleLine(expectedText, [{start: {line: 1, ch: 2}, end: {line: 1, ch: 2}, primary: false, reversed: false}, + {start: {line: 3, ch: 6}, end: {line: 3, ch: 6}, primary: false, reversed: false}, + {start: {line: 5, ch: 2}, end: {line: 5, ch: 2}, primary: true, reversed: false}]); + }); + + it("should properly toggle adjacent lines (not coalescing them) if there are cursors on each line", function () { + var lines = defaultContent.split("\n"); + lines[1] = "//" + lines[1]; + lines[2] = " foo();"; // make this line non-blank so it will get commented + lines[3] = "//" + lines[3]; + var startingContent = lines.join("\n"); + myDocument.setText(startingContent); + + myEditor.setSelections([{start: {line: 1, ch: 4}, end: {line: 1, ch: 4}}, + {start: {line: 2, ch: 4}, end: {line: 2, ch: 4}}, + {start: {line: 3, ch: 4}, end: {line: 3, ch: 4}}]); + + lines[1] = lines[1].slice(2); + lines[2] = "//" + lines[2]; + lines[3] = lines[3].slice(2); + var expectedText = lines.join("\n"); + + testToggleLine(expectedText, [{start: {line: 1, ch: 2}, end: {line: 1, ch: 2}, primary: false, reversed: false}, + {start: {line: 2, ch: 6}, end: {line: 2, ch: 6}, primary: false, reversed: false}, + {start: {line: 3, ch: 2}, end: {line: 3, ch: 2}, primary: true, reversed: false}]); + + }); + }); }); describe("Line comment in languages with mutiple line comment prefixes", function () { @@ -546,37 +625,6 @@ define(function (require, exports, module) { expectSelection({start: {line: 1, ch: 0}, end: {line: 4, ch: 0}}); }); }); - - - /** - * Invokes Toggle Block Comment, expects the given selection/cursor & document text, invokes - * it a 2nd time, and then expects the original selection/cursor & document text again. - * @param {!string} expectedCommentedText - * @param {!{ch:number,line:number}|{start:{ch:number,line:number},end:{ch:number,line:number}}} expectedCommentedSel - */ - function testToggleBlock(expectedCommentedText, expectedCommentedSel) { - function expectSel(sel) { - if (sel.start) { - expectSelection(sel); - } else { - expectCursorAt(sel); - } - } - - var startingContent = myDocument.getText(); - var startingSel = myEditor.getSelection(); - - // Toggle comment on - CommandManager.execute(Commands.EDIT_BLOCK_COMMENT, myEditor); - expect(myDocument.getText()).toEqual(expectedCommentedText); - expectSel(expectedCommentedSel); - - runs(function () { - CommandManager.execute(Commands.EDIT_BLOCK_COMMENT, myEditor); - expect(myDocument.getText()).toEqual(startingContent); - expectSel(startingSel); - }); - } describe("Block comment/uncomment", function () { beforeEach(setupFullEditor); @@ -948,6 +996,35 @@ define(function (require, exports, module) { expectSelection({start: {line: 1, ch: 0}, end: {line: 1, ch: 28}}); // no change }); + describe("with multiple selections", function () { + it("should comment out multiple selections/cursors, preserving primary/reversed selections", function () { + var lines = defaultContent.split("\n"); + lines[1] = lines[1].substr(0, 4) + "/**/" + lines[1].substr(4); + lines[3] = lines[3].substr(0, 4) + "/*" + lines[3].substr(4, 8) + "*/" + lines[3].substr(12); + + myEditor.setSelections([{start: {line: 1, ch: 4}, end: {line: 1, ch: 4}, primary: true}, + {start: {line: 3, ch: 4}, end: {line: 3, ch: 12}, reversed: true}]); + testToggleBlock(lines.join("\n"), [{start: {line: 1, ch: 6}, end: {line: 1, ch: 6}, primary: true, reversed: false}, + {start: {line: 3, ch: 6}, end: {line: 3, ch: 14}, primary: false, reversed: true}]); + }); + + it("should skip the case where a selection covers multiple block comments, but still track it and handle other selections", function () { + var lines = defaultContent.split("\n"); + lines[4] = " /*a*/ /*()*/ {"; + var startingContent = lines.join("\n"); + myDocument.setText(startingContent); + + myEditor.setSelections([{start: {line: 0, ch: 0}, end: {line: 1, ch: 0}}, + {start: {line: 4, ch: 0}, end: {line: 4, ch: 18}, reversed: true}]); + CommandManager.execute(Commands.EDIT_BLOCK_COMMENT, myEditor); + + lines.splice(1, 0, "*/"); + lines.splice(0, 0, "/*"); + expect(myDocument.getText()).toEqual(lines.join("\n")); + expect(myEditor.getSelections()).toEqual([{start: {line: 1, ch: 0}, end: {line: 2, ch: 0}, primary: false, reversed: false}, + {start: {line: 6, ch: 0}, end: {line: 6, ch: 18}, primary: true, reversed: true}]); + }); + }); }); // If the cursor's/selection's lines contain nothing but line comments and whitespace, we assume the user @@ -1392,6 +1469,47 @@ define(function (require, exports, module) { expect(myDocument.getText()).toEqual(expectedText); expectSelection({start: {line: 11, ch: 0}, end: {line: 14, ch: 0}}); }); + + describe("with multiple selections", function () { + it("should handle multiple selections where one of them is in a line comment", function () { + // Add a line comment to line 1 + var lines = defaultContent.split("\n"); + lines[1] = "//" + lines[1]; + myDocument.setText(lines.join("\n")); + + myEditor.setSelections([{start: {line: 1, ch: 4}, end: {line: 1, ch: 4}, primary: true}, + {start: {line: 3, ch: 4}, end: {line: 3, ch: 12}}]); + + CommandManager.execute(Commands.EDIT_BLOCK_COMMENT, myEditor); + + // Line 1 should no longer have a line comment, and line 3 should have a block comment. + lines[1] = lines[1].substr(2); + lines[3] = lines[3].substr(0, 4) + "/*" + lines[3].substr(4, 8) + "*/" + lines[3].substr(12); + + expect(myDocument.getText()).toEqual(lines.join("\n")); + expectSelections([{start: {line: 1, ch: 2}, end: {line: 1, ch: 2}, primary: true, reversed: false}, + {start: {line: 3, ch: 6}, end: {line: 3, ch: 14}, primary: false, reversed: false}]); + }); + + it("should handle multiple selections where several of them are in the same line comment, preserving the ignored selections", function () { + // Add a line comment to line 1 + var lines = defaultContent.split("\n"); + lines[1] = "//" + lines[1]; + myDocument.setText(lines.join("\n")); + + myEditor.setSelections([{start: {line: 1, ch: 4}, end: {line: 1, ch: 4}, primary: true}, + {start: {line: 1, ch: 6}, end: {line: 1, ch: 6}}]); + + CommandManager.execute(Commands.EDIT_BLOCK_COMMENT, myEditor); + + // Line 1 should no longer have a line comment + lines[1] = lines[1].substr(2); + + expect(myDocument.getText()).toEqual(lines.join("\n")); + expectSelections([{start: {line: 1, ch: 2}, end: {line: 1, ch: 2}, primary: true, reversed: false}, + {start: {line: 1, ch: 4}, end: {line: 1, ch: 4}, primary: false, reversed: false}]); + }); + }); }); // In cases where the language only supports block comments, the line comment/uncomment command may perform block comment/uncomment instead @@ -1662,6 +1780,56 @@ define(function (require, exports, module) { expect(myDocument.getText()).toEqual(htmlContent); expectSelection({start: {line: 3, ch: 0}, end: {line: 11, ch: 0}}); }); + + describe("with multiple selections", function () { + it("should handle multiple selections in different regions, toggling block selection in each", function () { + myEditor.setSelections([{start: {line: 1, ch: 4}, end: {line: 1, ch: 10}}, + {start: {line: 4, ch: 16}, end: {line: 4, ch: 32}}, + {start: {line: 8, ch: 0}, end: {line: 13, ch: 0}}]); + + var lines = htmlContent.split("\n"); + lines[1] = " "; + lines[4] = " /*font-size: 15px;*/"; + lines.splice(13, 0, "*/"); + lines.splice(8, 0, "/*"); + + testToggleBlock(lines.join("\n"), [{start: {line: 1, ch: 8}, end: {line: 1, ch: 14}, primary: false, reversed: false}, + {start: {line: 4, ch: 18}, end: {line: 4, ch: 34}, primary: false, reversed: false}, + {start: {line: 9, ch: 0}, end: {line: 14, ch: 0}, primary: true, reversed: false}]); + }); + + it("should handle multiple selections in different regions, toggling line selection (but falling back to block selection in HTML/CSS)", function () { + myEditor.setSelections([{start: {line: 1, ch: 4}, end: {line: 1, ch: 10}}, + {start: {line: 4, ch: 16}, end: {line: 4, ch: 32}}, + {start: {line: 10, ch: 0}, end: {line: 10, ch: 0}}]); + + var lines = htmlContent.split("\n"); + lines[1] = ""; + lines[4] = "/* font-size: 15px;*/"; + lines[10] = "// a();"; + + testToggleLine(lines.join("\n"), [{start: {line: 1, ch: 8}, end: {line: 1, ch: 14}, primary: false, reversed: false}, + {start: {line: 4, ch: 18}, end: {line: 4, ch: 34}, primary: false, reversed: false}, + {start: {line: 10, ch: 2}, end: {line: 10, ch: 2}, primary: true, reversed: false}]); + }); + + it("shouldn't comment anything in a mixed-mode selection, but should track it properly and comment the other selections", function () { + // Select the whole HTML tag so it will actually insert a line, causing other selections to get fixed up. + myEditor.setSelections([{start: {line: 1, ch: 0}, end: {line: 2, ch: 0}}, + {start: {line: 5, ch: 0}, end: {line: 7, ch: 0}, reversed: true, primary: true}, + {start: {line: 8, ch: 0}, end: {line: 13, ch: 0}}]); + + var lines = htmlContent.split("\n"); + lines.splice(13, 0, "*/"); + lines.splice(8, 0, "/*"); + lines.splice(2, 0, "-->"); + lines.splice(1, 0, "