Merge pull request #4071 from atom/mb-editor-grouped-undo

Add grouped undo for all text editor commands
This commit is contained in:
Max Brunsfeld
2014-11-06 10:13:04 -08:00
6 changed files with 155 additions and 102 deletions

View File

@@ -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"

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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.