diff --git a/package.json b/package.json index 504b3cd0b..3cf1eded6 100644 --- a/package.json +++ b/package.json @@ -58,7 +58,7 @@ "serializable": "^1", "space-pen": "3.8.0", "temp": "0.7.0", - "text-buffer": "^3.5.1", + "text-buffer": "^3.6.0", "theorist": "^1.0.2", "underscore-plus": "^1.6.1", "vm-compatibility-layer": "0.1.0" diff --git a/spec/text-editor-component-spec.coffee b/spec/text-editor-component-spec.coffee index 811681648..030bba37d 100644 --- a/spec/text-editor-component-spec.coffee +++ b/spec/text-editor-component-spec.coffee @@ -1924,6 +1924,31 @@ describe "TextEditorComponent", -> expect(nextAnimationFrame).toBe noAnimationFrame expect(editor.lineTextForBufferRow(0)).toBe 'var quicksort = function () {' + it "groups events that occur close together in time into single undo entries", -> + currentTime = 0 + spyOn(Date, 'now').andCallFake -> currentTime + + atom.config.set('editor.undoGroupingInterval', 100) + + editor.setText("") + componentNode.dispatchEvent(buildTextInputEvent(data: 'x', target: inputNode)) + + currentTime += 99 + componentNode.dispatchEvent(buildTextInputEvent(data: 'y', target: inputNode)) + + currentTime += 99 + componentNode.dispatchEvent(new CustomEvent('editor:duplicate-lines', bubbles: true, cancelable: true)) + + currentTime += 100 + componentNode.dispatchEvent(new CustomEvent('editor:duplicate-lines', bubbles: true, cancelable: true)) + expect(editor.getText()).toBe "xy\nxy\nxy" + + componentNode.dispatchEvent(new CustomEvent('core:undo', bubbles: true, cancelable: true)) + expect(editor.getText()).toBe "xy\nxy" + + componentNode.dispatchEvent(new CustomEvent('core:undo', bubbles: true, cancelable: true)) + expect(editor.getText()).toBe "" + describe "when IME composition is used to insert international characters", -> inputNode = null diff --git a/src/config-schema.coffee b/src/config-schema.coffee index afb1af4ce..bf41e5a6f 100644 --- a/src/config-schema.coffee +++ b/src/config-schema.coffee @@ -105,6 +105,11 @@ module.exports = scrollPastEnd: type: 'boolean' default: false + undoGroupingInterval: + type: 'integer' + default: 500 + minimum: 0 + description: 'Time interval in milliseconds within which operations will be grouped together in the undo history' useHardwareAcceleration: type: 'boolean' default: true diff --git a/src/text-editor-component.coffee b/src/text-editor-component.coffee index a57f2762d..c6e1af08b 100644 --- a/src/text-editor-component.coffee +++ b/src/text-editor-component.coffee @@ -457,7 +457,9 @@ TextEditorComponent = React.createClass selectedLength = inputNode.selectionEnd - inputNode.selectionStart editor.selectLeft() if selectedLength is 1 - inputNode.value = event.data if editor.insertText(event.data) + insertedRange = editor.transact atom.config.get('editor.undoGroupingInterval'), -> + editor.insertText(event.data) + inputNode.value = event.data if insertedRange onVerticalScroll: (scrollTop) -> {editor} = @props diff --git a/src/text-editor-element.coffee b/src/text-editor-element.coffee index 4a428178e..377aa2a55 100644 --- a/src/text-editor-element.coffee +++ b/src/text-editor-element.coffee @@ -134,115 +134,129 @@ class TextEditorElement extends HTMLElement hasFocus: -> this is document.activeElement or @contains(document.activeElement) -stopCommandEventPropagation = (commandListeners) -> +stopEventPropagation = (commandListeners) -> newCommandListeners = {} for commandName, commandListener of commandListeners do (commandListener) -> newCommandListeners[commandName] = (event) -> event.stopPropagation() - commandListener.call(this, event) + commandListener.call(@getModel(), event) newCommandListeners -atom.commands.add 'atom-text-editor', stopCommandEventPropagation( - 'core:move-left': -> @getModel().moveLeft() - 'core:move-right': -> @getModel().moveRight() - 'core:select-left': -> @getModel().selectLeft() - 'core:select-right': -> @getModel().selectRight() - 'core:select-all': -> @getModel().selectAll() - 'core:backspace': -> @getModel().backspace() - 'core:delete': -> @getModel().delete() - 'core:undo': -> @getModel().undo() - 'core:redo': -> @getModel().redo() - 'core:cut': -> @getModel().cutSelectedText() - 'core:copy': -> @getModel().copySelectedText() - 'core:paste': -> @getModel().pasteText() - 'editor:move-to-previous-word': -> @getModel().moveToPreviousWord() - 'editor:select-word': -> @getModel().selectWordsContainingCursors() - 'editor:consolidate-selections': (event) -> event.abortKeyBinding() unless @getModel().consolidateSelections() - 'editor:delete-to-beginning-of-word': -> @getModel().deleteToBeginningOfWord() - 'editor:delete-to-beginning-of-line': -> @getModel().deleteToBeginningOfLine() - 'editor:delete-to-end-of-line': -> @getModel().deleteToEndOfLine() - 'editor:delete-to-end-of-word': -> @getModel().deleteToEndOfWord() - 'editor:delete-line': -> @getModel().deleteLine() - 'editor:cut-to-end-of-line': -> @getModel().cutToEndOfLine() - 'editor:move-to-beginning-of-next-paragraph': -> @getModel().moveToBeginningOfNextParagraph() - 'editor:move-to-beginning-of-previous-paragraph': -> @getModel().moveToBeginningOfPreviousParagraph() - 'editor:move-to-beginning-of-screen-line': -> @getModel().moveToBeginningOfScreenLine() - 'editor:move-to-beginning-of-line': -> @getModel().moveToBeginningOfLine() - 'editor:move-to-end-of-screen-line': -> @getModel().moveToEndOfScreenLine() - 'editor:move-to-end-of-line': -> @getModel().moveToEndOfLine() - 'editor:move-to-first-character-of-line': -> @getModel().moveToFirstCharacterOfLine() - 'editor:move-to-beginning-of-word': -> @getModel().moveToBeginningOfWord() - 'editor:move-to-end-of-word': -> @getModel().moveToEndOfWord() - 'editor:move-to-beginning-of-next-word': -> @getModel().moveToBeginningOfNextWord() - 'editor:move-to-previous-word-boundary': -> @getModel().moveToPreviousWordBoundary() - 'editor:move-to-next-word-boundary': -> @getModel().moveToNextWordBoundary() - 'editor:select-to-beginning-of-next-paragraph': -> @getModel().selectToBeginningOfNextParagraph() - 'editor:select-to-beginning-of-previous-paragraph': -> @getModel().selectToBeginningOfPreviousParagraph() - 'editor:select-to-end-of-line': -> @getModel().selectToEndOfLine() - 'editor:select-to-beginning-of-line': -> @getModel().selectToBeginningOfLine() - 'editor:select-to-end-of-word': -> @getModel().selectToEndOfWord() - 'editor:select-to-beginning-of-word': -> @getModel().selectToBeginningOfWord() - 'editor:select-to-beginning-of-next-word': -> @getModel().selectToBeginningOfNextWord() - 'editor:select-to-next-word-boundary': -> @getModel().selectToNextWordBoundary() - 'editor:select-to-previous-word-boundary': -> @getModel().selectToPreviousWordBoundary() - 'editor:select-to-first-character-of-line': -> @getModel().selectToFirstCharacterOfLine() - 'editor:select-line': -> @getModel().selectLinesContainingCursors() - 'editor:transpose': -> @getModel().transpose() - 'editor:upper-case': -> @getModel().upperCase() - 'editor:lower-case': -> @getModel().lowerCase() +stopEventPropagationAndGroupUndo = (commandListeners) -> + newCommandListeners = {} + for commandName, commandListener of commandListeners + do (commandListener) -> + newCommandListeners[commandName] = (event) -> + event.stopPropagation() + model = @getModel() + model.transact atom.config.get('editor.undoGroupingInterval'), -> + commandListener.call(model, event) + newCommandListeners + +atom.commands.add 'atom-text-editor', stopEventPropagation( + 'core:undo': -> @undo() + 'core:redo': -> @redo() ) -atom.commands.add 'atom-text-editor:not(.mini)', stopCommandEventPropagation( - 'core:move-up': -> @getModel().moveUp() - 'core:move-down': -> @getModel().moveDown() - 'core:move-to-top': -> @getModel().moveToTop() - 'core:move-to-bottom': -> @getModel().moveToBottom() - 'core:page-up': -> @getModel().pageUp() - 'core:page-down': -> @getModel().pageDown() - 'core:select-up': -> @getModel().selectUp() - 'core:select-down': -> @getModel().selectDown() - 'core:select-to-top': -> @getModel().selectToTop() - 'core:select-to-bottom': -> @getModel().selectToBottom() - 'core:select-page-up': -> @getModel().selectPageUp() - 'core:select-page-down': -> @getModel().selectPageDown() - 'editor:indent': -> @getModel().indent() - 'editor:auto-indent': -> @getModel().autoIndentSelectedRows() - 'editor:indent-selected-rows': -> @getModel().indentSelectedRows() - 'editor:outdent-selected-rows': -> @getModel().outdentSelectedRows() - 'editor:newline': -> @getModel().insertNewline() - 'editor:newline-below': -> @getModel().insertNewlineBelow() - 'editor:newline-above': -> @getModel().insertNewlineAbove() - 'editor:add-selection-below': -> @getModel().addSelectionBelow() - 'editor:add-selection-above': -> @getModel().addSelectionAbove() - 'editor:split-selections-into-lines': -> @getModel().splitSelectionsIntoLines() - 'editor:toggle-soft-tabs': -> @getModel().toggleSoftTabs() - 'editor:toggle-soft-wrap': -> @getModel().toggleSoftWrapped() - 'editor:fold-all': -> @getModel().foldAll() - 'editor:unfold-all': -> @getModel().unfoldAll() - 'editor:fold-current-row': -> @getModel().foldCurrentRow() - 'editor:unfold-current-row': -> @getModel().unfoldCurrentRow() - 'editor:fold-selection': -> @getModel().foldSelectedLines() - 'editor:fold-at-indent-level-1': -> @getModel().foldAllAtIndentLevel(0) - 'editor:fold-at-indent-level-2': -> @getModel().foldAllAtIndentLevel(1) - 'editor:fold-at-indent-level-3': -> @getModel().foldAllAtIndentLevel(2) - 'editor:fold-at-indent-level-4': -> @getModel().foldAllAtIndentLevel(3) - 'editor:fold-at-indent-level-5': -> @getModel().foldAllAtIndentLevel(4) - 'editor:fold-at-indent-level-6': -> @getModel().foldAllAtIndentLevel(5) - 'editor:fold-at-indent-level-7': -> @getModel().foldAllAtIndentLevel(6) - 'editor:fold-at-indent-level-8': -> @getModel().foldAllAtIndentLevel(7) - 'editor:fold-at-indent-level-9': -> @getModel().foldAllAtIndentLevel(8) - 'editor:toggle-line-comments': -> @getModel().toggleLineCommentsInSelection() - 'editor:log-cursor-scope': -> @getModel().logCursorScope() - 'editor:checkout-head-revision': -> atom.project.getRepositories()[0]?.checkoutHeadForEditor(@getModel()) - 'editor:copy-path': -> @getModel().copyPathToClipboard() - 'editor:move-line-up': -> @getModel().moveLineUp() - 'editor:move-line-down': -> @getModel().moveLineDown() - 'editor:duplicate-lines': -> @getModel().duplicateLines() - 'editor:join-lines': -> @getModel().joinLines() +atom.commands.add 'atom-text-editor', stopEventPropagationAndGroupUndo( + 'core:move-left': -> @moveLeft() + 'core:move-right': -> @moveRight() + 'core:select-left': -> @selectLeft() + 'core:select-right': -> @selectRight() + 'core:select-all': -> @selectAll() + 'core:backspace': -> @backspace() + 'core:delete': -> @delete() + 'core:cut': -> @cutSelectedText() + 'core:copy': -> @copySelectedText() + 'core:paste': -> @pasteText() + 'editor:move-to-previous-word': -> @moveToPreviousWord() + 'editor:select-word': -> @selectWordsContainingCursors() + 'editor:consolidate-selections': (event) -> event.abortKeyBinding() unless @consolidateSelections() + 'editor:delete-to-beginning-of-word': -> @deleteToBeginningOfWord() + 'editor:delete-to-beginning-of-line': -> @deleteToBeginningOfLine() + 'editor:delete-to-end-of-line': -> @deleteToEndOfLine() + 'editor:delete-to-end-of-word': -> @deleteToEndOfWord() + 'editor:delete-line': -> @deleteLine() + 'editor:cut-to-end-of-line': -> @cutToEndOfLine() + 'editor:move-to-beginning-of-next-paragraph': -> @moveToBeginningOfNextParagraph() + 'editor:move-to-beginning-of-previous-paragraph': -> @moveToBeginningOfPreviousParagraph() + 'editor:move-to-beginning-of-screen-line': -> @moveToBeginningOfScreenLine() + 'editor:move-to-beginning-of-line': -> @moveToBeginningOfLine() + 'editor:move-to-end-of-screen-line': -> @moveToEndOfScreenLine() + 'editor:move-to-end-of-line': -> @moveToEndOfLine() + 'editor:move-to-first-character-of-line': -> @moveToFirstCharacterOfLine() + 'editor:move-to-beginning-of-word': -> @moveToBeginningOfWord() + 'editor:move-to-end-of-word': -> @moveToEndOfWord() + 'editor:move-to-beginning-of-next-word': -> @moveToBeginningOfNextWord() + 'editor:move-to-previous-word-boundary': -> @moveToPreviousWordBoundary() + 'editor:move-to-next-word-boundary': -> @moveToNextWordBoundary() + 'editor:select-to-beginning-of-next-paragraph': -> @selectToBeginningOfNextParagraph() + 'editor:select-to-beginning-of-previous-paragraph': -> @selectToBeginningOfPreviousParagraph() + 'editor:select-to-end-of-line': -> @selectToEndOfLine() + 'editor:select-to-beginning-of-line': -> @selectToBeginningOfLine() + 'editor:select-to-end-of-word': -> @selectToEndOfWord() + 'editor:select-to-beginning-of-word': -> @selectToBeginningOfWord() + 'editor:select-to-beginning-of-next-word': -> @selectToBeginningOfNextWord() + 'editor:select-to-next-word-boundary': -> @selectToNextWordBoundary() + 'editor:select-to-previous-word-boundary': -> @selectToPreviousWordBoundary() + 'editor:select-to-first-character-of-line': -> @selectToFirstCharacterOfLine() + 'editor:select-line': -> @selectLinesContainingCursors() + 'editor:transpose': -> @transpose() + 'editor:upper-case': -> @upperCase() + 'editor:lower-case': -> @lowerCase() +) + +atom.commands.add 'atom-text-editor:not(.mini)', stopEventPropagationAndGroupUndo( + 'core:move-up': -> @moveUp() + 'core:move-down': -> @moveDown() + 'core:move-to-top': -> @moveToTop() + 'core:move-to-bottom': -> @moveToBottom() + 'core:page-up': -> @pageUp() + 'core:page-down': -> @pageDown() + 'core:select-up': -> @selectUp() + 'core:select-down': -> @selectDown() + 'core:select-to-top': -> @selectToTop() + 'core:select-to-bottom': -> @selectToBottom() + 'core:select-page-up': -> @selectPageUp() + 'core:select-page-down': -> @selectPageDown() + 'editor:indent': -> @indent() + 'editor:auto-indent': -> @autoIndentSelectedRows() + 'editor:indent-selected-rows': -> @indentSelectedRows() + 'editor:outdent-selected-rows': -> @outdentSelectedRows() + 'editor:newline': -> @insertNewline() + 'editor:newline-below': -> @insertNewlineBelow() + 'editor:newline-above': -> @insertNewlineAbove() + 'editor:add-selection-below': -> @addSelectionBelow() + 'editor:add-selection-above': -> @addSelectionAbove() + 'editor:split-selections-into-lines': -> @splitSelectionsIntoLines() + 'editor:toggle-soft-tabs': -> @toggleSoftTabs() + 'editor:toggle-soft-wrap': -> @toggleSoftWrapped() + 'editor:fold-all': -> @foldAll() + 'editor:unfold-all': -> @unfoldAll() + 'editor:fold-current-row': -> @foldCurrentRow() + 'editor:unfold-current-row': -> @unfoldCurrentRow() + 'editor:fold-selection': -> @foldSelectedLines() + 'editor:fold-at-indent-level-1': -> @foldAllAtIndentLevel(0) + 'editor:fold-at-indent-level-2': -> @foldAllAtIndentLevel(1) + 'editor:fold-at-indent-level-3': -> @foldAllAtIndentLevel(2) + 'editor:fold-at-indent-level-4': -> @foldAllAtIndentLevel(3) + 'editor:fold-at-indent-level-5': -> @foldAllAtIndentLevel(4) + 'editor:fold-at-indent-level-6': -> @foldAllAtIndentLevel(5) + 'editor:fold-at-indent-level-7': -> @foldAllAtIndentLevel(6) + 'editor:fold-at-indent-level-8': -> @foldAllAtIndentLevel(7) + 'editor:fold-at-indent-level-9': -> @foldAllAtIndentLevel(8) + 'editor:toggle-line-comments': -> @toggleLineCommentsInSelection() + 'editor:log-cursor-scope': -> @logCursorScope() + 'editor:checkout-head-revision': -> atom.project.getRepositories()[0]?.checkoutHeadForEditor(this) + 'editor:copy-path': -> @copyPathToClipboard() + 'editor:move-line-up': -> @moveLineUp() + 'editor:move-line-down': -> @moveLineDown() + 'editor:duplicate-lines': -> @duplicateLines() + 'editor:join-lines': -> @joinLines() 'editor:toggle-indent-guide': -> atom.config.set('editor.showIndentGuide', not atom.config.get('editor.showIndentGuide')) 'editor:toggle-line-numbers': -> atom.config.set('editor.showLineNumbers', not atom.config.get('editor.showLineNumbers')) - 'editor:scroll-to-cursor': -> @getModel().scrollToCursorPosition() + 'editor:scroll-to-cursor': -> @scrollToCursorPosition() ) module.exports = TextEditorElement = document.registerElement 'atom-text-editor', prototype: TextEditorElement.prototype diff --git a/src/text-editor.coffee b/src/text-editor.coffee index 812524482..6e8ab4922 100644 --- a/src/text-editor.coffee +++ b/src/text-editor.coffee @@ -1096,8 +1096,10 @@ class TextEditor extends Model # abort the transaction, call {::abortTransaction} to terminate the function's # execution and revert any changes performed up to the abortion. # + # * `groupingInterval` (optional) This is the sames as the `groupingInterval` + # parameter in {::beginTransaction} # * `fn` A {Function} to call inside the transaction. - transact: (fn) -> @buffer.transact(fn) + transact: (groupingInterval, fn) -> @buffer.transact(groupingInterval, fn) # Extended: Start an open-ended transaction. # @@ -1105,7 +1107,12 @@ class TextEditor extends Model # transaction. If you nest calls to transactions, only the outermost # transaction is considered. You must match every begin with a matching # commit, but a single call to abort will cancel all nested transactions. - beginTransaction: -> @buffer.beginTransaction() + # + # * `groupingInterval` (optional) The {Number} of milliseconds for which this + # transaction should be considered 'groupable' after it begins. If a transaction + # with a positive `groupingInterval` is committed while the previous transaction is + # still 'groupable', the two transactions are merged with respect to undo and redo. + beginTransaction: (groupingInterval) -> @buffer.beginTransaction(groupingInterval) # Extended: Commit an open-ended transaction started with {::beginTransaction} # and push it to the undo stack.