diff --git a/CHANGES.md b/CHANGES.md index 7ab0bd3ede4..2d0fc4c0dfb 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -22,6 +22,7 @@ Fixed Issues: * [#12097](http://dev.ckeditor.com/ticket/12097): Fixed: JAWS not reading number of list options correctly in colors list box. * [#12411](http://dev.ckeditor.com/ticket/12411): Fixed: [Page Break](http://ckeditor.com/addon/pagebreak) used directly in the editable breaks the editor. * [#12162](http://dev.ckeditor.com/ticket/12162): Fixed: Auto paragraphing and enter key in nested editables. +* [#12354](http://dev.ckeditor.com/ticket/12354): Fixed: Various issues in undo manager when holding keys. ## CKEditor 4.4.4 diff --git a/plugins/undo/plugin.js b/plugins/undo/plugin.js index 95a714e1204..b49f8ceffb0 100644 --- a/plugins/undo/plugin.js +++ b/plugins/undo/plugin.js @@ -191,125 +191,105 @@ * @param {CKEDITOR.editor} editor */ var UndoManager = CKEDITOR.plugins.undo.UndoManager = function( editor ) { - this.editor = editor; - - // Reset the undo stack. - this.reset(); - }; - - UndoManager.prototype = { /** - * Key groups identifier mapping. Used for accessing members in - * {@link #strokesRecorded}. - * - * * `FUNCTIONAL` – identifier for the Backspace / Delete key. - * * `PRINTABLE` – identifier for printable keys. - * - * Example usage: + * An array storing the number of key presses, count in a row. Use {@link #keyGroups} members as index. * - * undoManager.strokesRecorded[ undoManager.keyGroupsEnum.FUNCTIONAL ]; + * **Note:** The keystroke count will be reset after reaching the limit of characters per snapshot. * * @since 4.4.4 - * @readonly */ - keyGroupsEnum: { - PRINTABLE: 0, - FUNCTIONAL: 1 - }, + this.strokesRecorded = [ 0, 0 ]; /** - * An array storing the number of key presses, count in a row. Use {@link #keyGroupsEnum} members as index. + * When the `locked` property is not `null`, the undo manager is locked, so + * operations like `save` or `update` are forbidden. * - * **Note:** The keystroke count will be reset after reaching the limit of characters per snapshot. + * The manager can be locked and unlocked by the {@link #lock} and {@link #unlock} + * methods, respectively. * - * @since 4.4.4 * @readonly + * @property {Object} [locked=null] */ - strokesRecorded: [ 0, 0 ], + this.locked = null; /** - * Codes for navigation keys like Arrows, Page Up/Down, etc. - * Used by the {@link #isNavigationKey} method. + * Contains the previously processed key group, based on {@link #keyGroups}. + * `-1` means an unknown group. * * @since 4.4.4 * @readonly + * @property {Number} [previousKeyGroup=-1] */ - navigationKeyCodes: { - 37: 1, 38: 1, 39: 1, 40: 1, // Arrows. - 36: 1, 35: 1, // Home, end. - 33: 1, 34: 1 // Pgup, pgdn. - }, + this.previousKeyGroup = -1; /** - * When the `locked` property is not `null`, the undo manager is locked, so - * operations like `save` or `update` are forbidden. - * - * The manager can be locked and unlocked by the {@link #lock} and {@link #unlock} - * methods, respectively. + * Maximum number of snapshots in the stack. Configurable via {@link CKEDITOR.config#undoStackSize}. * * @readonly - * @property {Object} [locked=null] + * @property {Number} [limit] */ + this.limit = editor.config.undoStackSize || 20; /** - * Contains the previously processed key group, based on {@link #keyGroupsEnum}. - * `-1` means an unknown group. + * Maximum number of characters typed/deleted in one undo step. * - * @since 4.4.4 + * @since 4.4.5 * @readonly - * @property {Number} [previousKeyGroup=-1] */ - previousKeyGroup: -1, + this.strokesLimit = 25; + + this.editor = editor; + + // Reset the undo stack. + this.reset(); + }; + UndoManager.prototype = { /** * Handles keystroke support for the undo manager. It is called on `keyup` event for * keystrokes that can change the editor content. * * @param {Number} keyCode The key code. + * @param {Boolean} [strokesPerSnapshotExceeded] When set to `true` the method will + * behave as strokes limit was exceeded regardless of {@link #strokesRecorded} value. */ - type: function( keyCode ) { - var keyGroupsEnum = this.keyGroupsEnum, - keyGroup = backspaceOrDelete[ keyCode ] ? keyGroupsEnum.FUNCTIONAL : keyGroupsEnum.PRINTABLE, + type: function( keyCode, strokesPerSnapshotExceeded ) { + var keyGroup = UndoManager.getKeyGroup( keyCode ), // Count of keystrokes in current a row. // Note if strokesPerSnapshotExceeded will be exceeded, it'll be restarted. - strokesRecorded = this.strokesRecorded[ keyGroup ] + 1, - keyGroupChanged = keyGroup !== this.previousKeyGroup, - strokesPerSnapshotExceeded = strokesRecorded >= 25, - // Identifier of opposite group, used later on to reset its counter. - oppositeGroup = keyGroup == keyGroupsEnum.FUNCTIONAL ? keyGroupsEnum.PRINTABLE : keyGroupsEnum.FUNCTIONAL; + strokesRecorded = this.strokesRecorded[ keyGroup ] + 1; + + strokesPerSnapshotExceeded = + ( strokesPerSnapshotExceeded || strokesRecorded >= this.strokesLimit ); if ( !this.typing ) onTypingStart( this ); - if ( ( keyGroupChanged && this.previousKeyGroup !== -1 ) || strokesPerSnapshotExceeded ) { - if ( keyGroupChanged ) { - // Key group changed: - // Reset the other key group recorded count. - this.strokesRecorded[ oppositeGroup ] = 0; - // In case of group changed we need to save snapshot before DOM modification, - // consider:

ab^

when user was typing "ab", and is pressing backspace. - // Since we're in keyup event, DOM is modified, and we have

a^

- thus - // snapshot made in keydown, before modification. - if ( !this.save( false, this.editingHandler.lastKeydownImage, false ) ) - // Drop further snapshots. - this.snapshots.splice( this.index + 1, this.snapshots.length - this.index - 1 ); - } else { - // Limit of chars in snapshot exceeded: - // Reset the count of strokes, so it'll be later assigned to this.strokesRecorded. - strokesRecorded = 0; + if ( strokesPerSnapshotExceeded ) { + // Reset the count of strokes, so it'll be later assigned to this.strokesRecorded. + strokesRecorded = 0; - this.editor.fire( 'saveSnapshot' ); - // Force typing state to be enabled. It was reset because saveSnapshot is calling this.reset(). - this.typing = true; - } + this.editor.fire( 'saveSnapshot' ); + } else { + // Fire change event. + this.editor.fire( 'change' ); } // Store recorded strokes count. this.strokesRecorded[ keyGroup ] = strokesRecorded; // This prop will tell in next itaration what kind of group was processed previously. this.previousKeyGroup = keyGroup; - // Fire change event. - this.editor.fire( 'change' ); + }, + + /** + * Whether the new `keyCode` belongs to different group than the previous one ({@link #previousKeyGroup}). + * + * @since 4.4.5 + * @param {Number} keyCode + * @returns {Boolean} + */ + keyGroupChanged: function( keyCode ) { + return UndoManager.getKeyGroup( keyCode ) != this.previousKeyGroup; }, /** @@ -323,9 +303,6 @@ // Current snapshot history index. this.index = -1; - this.limit = this.editor.config.undoStackSize || 20; - - this.currentImage = null; this.hasUndo = false; @@ -686,21 +663,94 @@ } } } - }, - - /** - * Checks whether a key is one of navigation keys (Arrows, Page Up/Down, etc.). - * See also the {@link #navigationKeyCodes} property. - * - * @since 4.4.4 - * @param {Number} keyCode - * @returns {Boolean} - */ - isNavigationKey: function( keyCode ) { - return !!this.navigationKeyCodes[ keyCode ]; } }; + /** + * Codes for navigation keys like Arrows, Page Up/Down, etc. + * Used by the {@link #isNavigationKey} method. + * + * @since 4.4.5 + * @readonly + * @static + */ + UndoManager.navigationKeyCodes = { + 37: 1, 38: 1, 39: 1, 40: 1, // Arrows. + 36: 1, 35: 1, // Home, end. + 33: 1, 34: 1 // Pgup, pgdn. + }; + + /** + * Key groups identifier mapping. Used for accessing members in + * {@link #strokesRecorded}. + * + * * `FUNCTIONAL` – identifier for the Backspace / Delete key. + * * `PRINTABLE` – identifier for printable keys. + * + * Example usage: + * + * undoManager.strokesRecorded[ undoManager.keyGroups.FUNCTIONAL ]; + * + * @since 4.4.5 + * @readonly + * @static + */ + UndoManager.keyGroups = { + PRINTABLE: 0, + FUNCTIONAL: 1 + }; + + /** + * Checks whether a key is one of navigation keys (Arrows, Page Up/Down, etc.). + * See also the {@link #navigationKeyCodes} property. + * + * @since 4.4.5 + * @static + * @param {Number} keyCode + * @returns {Boolean} + */ + UndoManager.isNavigationKey = function( keyCode ) { + return !!UndoManager.navigationKeyCodes[ keyCode ]; + }; + + /** + * Returns the group to which passed `keyCode` belongs. + * + * @since 4.4.5 + * @static + * @param {Number} keyCode + * @returns {Number} + */ + UndoManager.getKeyGroup = function( keyCode ) { + var keyGroups = UndoManager.keyGroups; + + return backspaceOrDelete[ keyCode ] ? keyGroups.FUNCTIONAL : keyGroups.PRINTABLE; + }; + + /** + * @since 4.4.5 + * @static + * @param {Number} keyGroup + * @returns {Number} + */ + UndoManager.getOppositeKeyGroup = function( keyGroup ) { + var keyGroups = UndoManager.keyGroups; + return ( keyGroup == keyGroups.FUNCTIONAL ? keyGroups.PRINTABLE : keyGroups.FUNCTIONAL ); + }; + + /** + * Whether in this environment and for specified `keyCode` we need to use workaround + * for functional (backspace, delete) keys not firing `keypress` event on Internet Explorer. + * + * @since 4.4.5 + * @static + * @param {Number} keyCode + * @returns {Boolean} + */ + UndoManager.ieFunctionalKeysBug = function( keyCode ) { + return CKEDITOR.env.ie && UndoManager.getKeyGroup( keyCode ) == UndoManager.keyGroups.FUNCTIONAL; + }; + // Helper method called when undoManager.typing val was changed to true. function onTypingStart( undoManager ) { // It's safe to now indicate typing state. @@ -806,30 +856,24 @@ */ }; - var inputFired = 0, - ignoreInputEvent = false, - ignoreInputEventListener = function() { - ignoreInputEvent = true; - }; - /** * A class encapsulating all native event listeners which have to be used in * order to handle undo manager integration for native editing actions (excluding drag and drop and paste support * handled by the Clipboard plugin). * + * @since 4.4.4 * @private * @class CKEDITOR.plugins.undo.NativeEditingHandler * @member CKEDITOR.plugins.undo Undo manager owning the handler. * @constructor * @param {CKEDITOR.plugins.undo.UndoManager} undoManager - * @since 4.4.4 */ var NativeEditingHandler = CKEDITOR.plugins.undo.NativeEditingHandler = function( undoManager ) { // We'll use keyboard + input events to determine if snapshot should be created. // Since `input` event is fired before `keyup`. We can tell in `keyup` event if input occured. // That will tell us if any printable data was inserted. - // On `input` event we'll increase `inputFired` counter. Eventually it might be - // canceled by paste/drop using `ignoreInputEvent` flag. + // On `input` event we'll increase input fired counter for proper key code. + // Eventually it might be canceled by paste/drop using `ignoreInputEvent` flag. // Order of events can be found in http://www.w3.org/TR/DOM-Level-3-Events/ /** @@ -839,11 +883,28 @@ */ this.undoManager = undoManager; + /** + * See {@link #ignoreInputEventListener}. + * + * @since 4.4.5 + * @private + */ + this.ignoreInputEvent = false; + + /** + * A stack of pressed keys. + * + * @since 4.4.5 + * @property {CKEDITOR.plugins.undo.KeyEventsStack} keyEventsStack + */ + this.keyEventsStack = new KeyEventsStack(); + /** * An image of the editor during the `keydown` event (therefore without DOM modification). * * @property {CKEDITOR.plugins.undo.Image} lastKeydownImage */ + this.lastKeydownImage = null; }; NativeEditingHandler.prototype = { @@ -859,13 +920,23 @@ return; } + // Cleaning tab functional keys. + this.keyEventsStack.cleanUp( evt ); + var keyCode = evt.data.getKey(), undoManager = this.undoManager; + // Gets last record for provided keyCode. If not found will create one. + var last = this.keyEventsStack.getLast( keyCode ); + if ( !last ) { + this.keyEventsStack.push( keyCode ); + } + // We need to store an image which will be used in case of key group // change. this.lastKeydownImage = new Image( undoManager.editor ); - if ( undoManager.isNavigationKey( keyCode ) ) { + + if ( UndoManager.isNavigationKey( keyCode ) || this.undoManager.keyGroupChanged( keyCode ) ) { if ( undoManager.strokesRecorded[ 0 ] || undoManager.strokesRecorded[ 1 ] ) { // We already have image, so we'd like to reuse it. undoManager.save( false, this.lastKeydownImage ); @@ -878,11 +949,27 @@ * The `input` event listener. */ onInput: function() { - inputFired += 1; - // inputFired counter shouldn't be increased if paste/drop event were fired before. - if ( ignoreInputEvent ) { - inputFired -= 1; - ignoreInputEvent = false; + // Input event is ignored if paste/drop event were fired before. + if ( this.ignoreInputEvent ) { + // Reset flag - ignore only once. + this.ignoreInputEvent = false; + return; + } + + var lastInput = this.keyEventsStack.getLast(); + // Nothing in key events stack, but input event called. Interesting... + // That's because on Android order of events is buggy and also keyCode is set to 0. + if ( !lastInput ) { + lastInput = this.keyEventsStack.push( 0 ); + } + + // Increment inputs counter for provided key code. + this.keyEventsStack.increment( lastInput.keyCode ); + + // Exceeded limit. + if ( this.keyEventsStack.getTotalInputs() >= this.undoManager.strokesLimit ) { + this.undoManager.type( lastInput.keyCode, true ); + this.keyEventsStack.resetInputs(); } }, @@ -894,27 +981,22 @@ onKeyup: function( evt ) { var undoManager = this.undoManager, keyCode = evt.data.getKey(), - editor = undoManager.editor, - ieFunctionKeysWorkaround = CKEDITOR.env.ie && keyCode in backspaceOrDelete; - - // IE: doesn't call keypress for backspace/del keys so we need to handle it manually - // with a workaround. Also we need to be aware that lastKeydownImage might not be available (#12327). - if ( ieFunctionKeysWorkaround && this.lastKeydownImage ) { - if ( this.lastKeydownImage.equalsContent( new Image( editor, true ) ) ) { - // Content was not changed, we don't need to do anything. - return; - } else { - // Content was changed. And since no keypress event was fired, we have - // inputFired = 0, so undoManager.type method will not be called. - inputFired += 1; - } + totalInputs = this.keyEventsStack.getTotalInputs(); + + // Remove record from stack for provided key code. + this.keyEventsStack.remove( keyCode ); + + // Second part of the workaround for IEs functional keys bug. We need to check whether something has really + // changed because we blindly mocked the keypress event. + // Also we need to be aware that lastKeydownImage might not be available (#12327). + if ( UndoManager.ieFunctionalKeysBug( keyCode ) && this.lastKeydownImage && + this.lastKeydownImage.equalsContent( new Image( undoManager.editor, true ) ) ) { + return; } - if ( inputFired > 0 ) { - // Reset flag indicating input event. - inputFired -= 1; + if ( totalInputs > 0 ) { undoManager.type( keyCode ); - } else if ( undoManager.isNavigationKey( keyCode ) ) { + } else if ( UndoManager.isNavigationKey( keyCode ) ) { // Note content snapshot has been checked in keydown. this.onNavigationKey( true ); } @@ -942,10 +1024,10 @@ }, /** - * Resets the input counter. This method is for internal use only. + * Makes the next `input` event to be ignored. */ - resetCounter: function() { - inputFired = 0; + ignoreInputEventListener: function() { + this.ignoreInputEvent = true; }, /** @@ -957,7 +1039,15 @@ // We'll create a snapshot here (before DOM modification), because we'll // need unmodified content when we got keygroup toggled in keyup. - editable.attachListener( editable, 'keydown', that.onKeydown, that ); + editable.attachListener( editable, 'keydown', function( evt ) { + that.onKeydown( evt ); + + // On IE keypress isn't fired for functional (backspace/delete) keys. + // Let's pretend that something's changed. + if ( UndoManager.ieFunctionalKeysBug( evt.data.getKey() ) ) { + that.onInput(); + } + } ); // Only IE can't use input event, because it's not fired in contenteditable. editable.attachListener( editable, CKEDITOR.env.ie ? 'keypress' : 'input', that.onInput, that ); @@ -965,10 +1055,10 @@ // Keyup executes main snapshot logic. editable.attachListener( editable, 'keyup', that.onKeyup, that ); - // On paste and drop we need to cancel inputFired variable. + // On paste and drop we need to ignore input event. // It would result with calling undoManager.type() on any following key. - editable.attachListener( editable, 'paste', ignoreInputEventListener ); - editable.attachListener( editable, 'drop', ignoreInputEventListener ); + editable.attachListener( editable, 'paste', that.ignoreInputEventListener, that ); + editable.attachListener( editable, 'drop', that.ignoreInputEventListener, that ); // Click should create a snapshot if needed, but shouldn't cause change event. // Don't pass onNavigationKey directly as a listener because it accepts one argument which @@ -976,6 +1066,166 @@ editable.attachListener( editable, 'click', function() { that.onNavigationKey(); } ); + + // When pressing `Tab` key while editable is focused, `keyup` event is not fired. + // Which means that record for `tab` key stays in key events stack. + // We assume that when editor is blurred `tab` key is already up. + editable.attachListener( this.undoManager.editor, 'blur', function () { + that.keyEventsStack.remove( 9 /*Tab*/ ); + } ); + } + }; + + /** + * This class represents a stack of pressed keys and stores information + * about how many `input` events each caused. + * + * @since 4.4.5 + * @private + * @class CKEDITOR.plugins.undo.KeyEventsStack + * @constructor + */ + var KeyEventsStack = CKEDITOR.plugins.undo.KeyEventsStack = function() { + /** + * @readonly + */ + this.stack = []; + }; + + KeyEventsStack.prototype = { + /** + * Pushes to stack literal object with two keys: `keyCode` and `inputs` which initial value is set to `0`. + * It is intended to be called on the `keydown` event. + * + * @param {Number} keyCode + */ + push: function( keyCode ) { + var length = this.stack.push( { keyCode: keyCode, inputs: 0 } ); + return this.stack[ length - 1 ]; + }, + + /** + * Returns index of last registered `keyCode` in the stack. + * If no `keyCode` is provided, then function will return index of last item. + * If item is not found it will return `-1`. + * + * @param {Number} [keyCode] + * @returns {Number} + */ + getLastIndex: function( keyCode ) { + if ( typeof keyCode != 'number' ) { + return this.stack.length - 1; // Last index or -1. + } else { + var i = this.stack.length; + while ( i-- ) { + if ( this.stack[ i ].keyCode == keyCode ) { + return i; + } + } + return -1; + } + }, + + /** + * Returns last key recorded in the stack. If `keyCode` provided, then it will return last record for + * this `keyCode`. + * + * @param {Number} [keyCode] + * @returns {Object} Last matching record or `null`. + */ + getLast: function( keyCode ) { + var index = this.getLastIndex( keyCode ); + if ( index != -1 ) { + return this.stack[ index ]; + } else { + return null; + } + }, + + /** + * Increments registered input events for stack record for given `keyCode`. + * + * @param {Number} keyCode + */ + increment: function( keyCode ) { + var found = this.getLast( keyCode ); + if ( !found ) { // %REMOVE_LINE% + throw new Error( 'Trying to increment, but could not found by keyCode: ' + keyCode + '.' ); // %REMOVE_LINE% + } // %REMOVE_LINE% + + found.inputs++; + }, + + /** + * Removes last record from the stack for provided `keyCode`. + * + * @param {Number} keyCode + */ + remove: function( keyCode ) { + var index = this.getLastIndex( keyCode ); + + if ( index != -1 ) { + this.stack.splice( index, 1 ); + } + }, + + /** + * Resets inputs value to `0` for the given `keyCode` or in entire stack if `keyCode` is not specified. + * + * @param {Number} [keyCode] + */ + resetInputs: function( keyCode ) { + if ( typeof keyCode == 'number' ) { + var last = this.getLast( keyCode ); + + if ( !last ) { // %REMOVE_LINE% + throw new Error( 'Trying to reset inputs count, but could not found by keyCode: ' + keyCode + '.' ); // %REMOVE_LINE% + } // %REMOVE_LINE% + + last.inputs = 0; + } else { + var i = this.stack.length; + while ( i-- ) { + this.stack[ i ].inputs = 0; + } + } + }, + + /** + * Sums up inputs amount for each key code and returns it. + * + * @returns {Number} + */ + getTotalInputs: function() { + var i = this.stack.length, + total = 0; + + while ( i-- ) { + total += this.stack[ i ].inputs; + } + return total; + }, + + /** + * Cleans the stack based on provided `keydown` event object. The rationale behind this method + * is that some keystrokes causes `keydown` to being fired in editor, but not `keyup`. For instance, + * `ALT+TAB` will fire `keydown`, but since editor is blurred by it, then there is no `keyup` so + * the keystroke is not removed from the stack. + * + * @param {CKEDITOR.dom.event} event + */ + cleanUp: function( event ) { + var nativeEvent = event.data.$; + + if ( !( nativeEvent.ctrlKey || nativeEvent.metaKey ) ) { + this.remove( 17 ); + } + if ( !nativeEvent.shiftKey ) { + this.remove( 16 ); + } + if ( !nativeEvent.altKey ) { + this.remove( 18 ); + } } }; } )(); diff --git a/tests/plugins/undo/_helpers/tools.js b/tests/plugins/undo/_helpers/tools.js index 30ca17df523..2297c406bbe 100644 --- a/tests/plugins/undo/_helpers/tools.js +++ b/tests/plugins/undo/_helpers/tools.js @@ -74,9 +74,9 @@ var undoEventDispatchTestsTools = function( testSuite ) { /** * Calls keyEvent() with given ammount of times. */ - keyEventMultiple: function( repeatTimes, keyCode, eventInfo, skipInputEvent ) { + keyEventMultiple: function( repeatTimes, keyCode, eventInfo, skipInputEvent, domModificationFn ) { for ( var i = 0; i < repeatTimes; i++ ) - this.keyEvent( keyCode, eventInfo, skipInputEvent ); + this.keyEvent( keyCode, eventInfo, skipInputEvent, domModificationFn ); }, /** diff --git a/tests/plugins/undo/getnextimage.js b/tests/plugins/undo/getnextimage.js new file mode 100644 index 00000000000..ffd17012452 --- /dev/null +++ b/tests/plugins/undo/getnextimage.js @@ -0,0 +1,90 @@ +/* bender-ckeditor-plugins: undo */ + +( function() { + 'use strict'; + + var editor = { + config: { + undoStackSize: 20 + } + }, + um, + UNDO = true, + REDO = false; + + bender.test( { + setUp: function() { + um = new CKEDITOR.plugins.undo.UndoManager( editor ); + }, + + 'test getNextImage: no snapshots': function() { + um.snapshots = []; + um.index = -1; + um.currentImage = { + equalsContent: function() { return true; } + }; + + assert.isNull( um.getNextImage( REDO ) ); + assert.isNull( um.getNextImage( UNDO ) ); + }, + + 'test getNextImage: one snapshot ahead for -1 index': function() { + um.snapshots = [ {} ]; + um.index = -1; + + um.currentImage = { + equalsContent: function() { return false; } + }; + assert.isNull( um.getNextImage( UNDO ) ); + assert.areSame( um.snapshots[ 0 ], um.getNextImage( REDO ) ); + + um.currentImage = { + equalsContent: function() { return true; } + }; + assert.isNull( um.getNextImage( UNDO ) ); + assert.isNull( um.getNextImage( REDO ) ); + }, + + 'test getNextImage: no snapshot ahead for index 0': function() { + um.snapshots = [ {} ]; + um.index = 0; + + um.currentImage = { + equalsContent: function() { return false; } + }; + // Assert below is a bit confusing, but there shouldn't be such situation. + // assert.areSame( um.snapshots[ 0 ], um.getNextImage( UNDO ) ); + assert.isNull( um.getNextImage( REDO ) ); + + um.currentImage = { + equalsContent: function() { return true; } + }; + assert.isNull( um.getNextImage( UNDO ) ); + assert.isNull( um.getNextImage( REDO ) ); + }, + + 'test getNextImage: omit snapshots with same content': function() { + um.snapshots = [ { equals: false }, { equals: true }, { equals: false }, { equals: true } ]; + um.index = 0; + + um.currentImage = { + equalsContent: function ( img ) { return img.equals; } + }; + assert.isNull( um.getNextImage( UNDO ) ); + assert.areSame( um.snapshots[ 2 ], um.getNextImage( REDO ) ); + }, + + 'test getNextImage: omit snapshots with same content backward': function() { + um.snapshots = [ { equals: false }, { equals: true }, { equals: false }, { equals: true } ]; + um.index = 2; + + um.currentImage = { + equalsContent: function ( img ) { return img.equals; } + }; + + // Assert below is a bit confusing, but there shouldn't be such situation. + // assert.areSame( um.snapshots[ 2 ], um.getNextImage( UNDO ) ); + assert.isNull( um.getNextImage( REDO ) ); + } + } ); +} )(); \ No newline at end of file diff --git a/tests/plugins/undo/integrations.js b/tests/plugins/undo/integrations.js index 698e2e8ba36..417f96727cc 100644 --- a/tests/plugins/undo/integrations.js +++ b/tests/plugins/undo/integrations.js @@ -21,9 +21,7 @@ this.undoManager = this.editor.undoManager; // For each TC we want to reset undoManager. - this.undoManager.reset(); - // Force to reset inputFired counter, as some TCs may produce leftovers. - this.undoManager.editingHandler.resetCounter(); + this.editor.resetUndo(); }, // (#12327) diff --git a/tests/plugins/undo/keyboard.js b/tests/plugins/undo/keyboard.js index afbebbe86f7..6ca105a10c6 100644 --- a/tests/plugins/undo/keyboard.js +++ b/tests/plugins/undo/keyboard.js @@ -18,6 +18,40 @@ } }; + function removeLastCharFromTextNode( textNode ) { + var text = textNode.getText(); + text = text.substring( 0, text.length - 1 ); + textNode.setText( text ); + } + + function addCharactersToTextNode( textNode, characters ) { + textNode.setText( textNode.getText() + characters ); + } + + function curryAddCharactersToTextNode( textNode, characters ) { + return function () { + addCharactersToTextNode( textNode, characters ); + } + } + + function simulateHoldKey( times, keyTools, keyCode, domModification, beforeKeyUp ) { + var UndoManager = CKEDITOR.plugins.undo.UndoManager; + + while( times-- ) { + keyTools.singleKeyEvent( keyCode, { type: 'keydown' } ); + domModification(); + + // On IE there is no any event fired when functional key is down. + if ( !UndoManager.ieFunctionalKeysBug( keyCode ) ) { + keyTools.singleKeyEvent( 0, { type: ( CKEDITOR.env.ie ? 'keypress' : 'input' ) } ); + } + } + + beforeKeyUp && beforeKeyUp(); + + keyTools.singleKeyEvent( keyCode, { type: 'keyup' } ); + } + var keyCodesEnum, // keyCodesEnum will be inited in first setUp call. keyGroups, // tcs = { @@ -49,17 +83,15 @@ this.undoManager = this.editor.undoManager; if ( !keyGroups ) { - keyGroups = this.undoManager.keyGroupsEnum; + keyGroups = CKEDITOR.plugins.undo.UndoManager.keyGroups; } bender.tools.selection.setWithHtml( this.editor, '

_{}_

' ); // For each TC we want to reset undoManager. - this.undoManager.reset(); + this.editor.resetUndo(); // This will reset command objects states. this.undoManager.onChange(); - // Force to reset inputFired counter, as some TCs may produce leftovers. - this.undoManager.editingHandler.resetCounter(); }, _should: { @@ -130,11 +162,15 @@ }, 'test undoManager change event firing': tcWithExpectedChanges( 30, function() { - // Change event should be fired on each key. - var keyPressesCount = 30; + var textNode = this.editor.editable().getFirst().getFirst(), + // Change event should be fired on each key. + keyPressesCount = 30; - for ( var i = 0; i < keyPressesCount; i++ ) - this.keyTools.keyEvent( keyCodesEnum.KEY_D ); + for ( var i = 0; i < keyPressesCount; i++ ) { + this.keyTools.keyEvent( keyCodesEnum.KEY_D, null, false, function() { + addCharactersToTextNode( textNode, 'd' ); + } ); + } } ), 'test undoManager change event for functional keys': tcWithExpectedChanges( 2, function() { @@ -213,34 +249,45 @@ 'test strokesRecorded reset after exceeding limit': tcWithExpectedChanges( keystrokesPerSnapshotLimit, function() { // This count should be equal to limit of keys inbetween snapshot. - var keyStrokesCount = keystrokesPerSnapshotLimit, - snapshotEventsCount = 0, + var snapshotEventsCount = 0, + textNode = this.editor.editable().getFirst().getFirst(), undoManager = this.undoManager; this.editor.on( 'saveSnapshot', function() { snapshotEventsCount++; }, null, null, -1000 ); - this.keyTools.keyEventMultiple( keyStrokesCount, keyCodesEnum.KEY_D ); + this.keyTools.keyEventMultiple( keystrokesPerSnapshotLimit, keyCodesEnum.KEY_D, null, false, function() { + addCharactersToTextNode( textNode, 'd' ); + } ); assert.areSame( 0, undoManager.strokesRecorded[ keyGroups.PRINTABLE ], 'undoManager.strokesRecorded[ keyGroups.PRINTABLE ] is not zeroed' ); assert.areSame( 1, snapshotEventsCount, 'Wrong editor#saveSnapshot events count' ); } ), 'test undoManager.typing property': function() { + bender.tools.selection.setWithHtml( this.editor, '

foo {}bar

' ); + var undoManager = this.undoManager, + textNode = this.editor.editable().getFirst().getFirst(), iterationsCount = 15, i; + this.editor.resetUndo(); + assert.isFalse( undoManager.typing, 'Invalid undoManager.typing val' ); for ( i = 0; i < iterationsCount; i++ ) { - this.keyTools.keyEvent( keyCodesEnum.KEY_D ); + this.keyTools.keyEvent( keyCodesEnum.KEY_D, null, false, function() { + addCharactersToTextNode( textNode, 'd' ); + } ); assert.isTrue( undoManager.typing, 'Invalid undoManager.typing at character key ' + i + '. iteration' ); } // Now lets use functional keys and ensure that typing is still true. for ( i = 0; i < iterationsCount; i++ ) { - this.keyTools.keyEvent( keyCodesEnum.BACKSPACE ); + this.keyTools.keyEvent( keyCodesEnum.BACKSPACE, null, false, function() { + removeLastCharFromTextNode( textNode ); + } ); assert.isTrue( undoManager.typing, 'Invalid undoManager.typing at functional keys ' + i + '. iteration' ); } }, @@ -258,14 +305,14 @@ // We need to force at least one snapshot, which will be overwritten. this.editor.fire('saveSnapshot'); - assert.areEqual( 1, undoManager.snapshots.length, 'Invalid snapshots count' ); + assert.areEqual( 2, undoManager.snapshots.length, 'Invalid snapshots count' ); this.keyTools.keyEvent( keyCodesEnum.RIGHT, null, true, function() { // Pressing right arrow should move caret to 1 offset. that._moveTextNodeRange( 1 ); } ); - var bookmark = undoManager.snapshots[ 0 ].bookmarks[ 0 ]; + var bookmark = undoManager.snapshots[ 1 ].bookmarks[ 0 ]; assert.areEqual( 1, bookmark.startOffset, 'Invalid bookmark start offset' ); // IE8 sets bookmark end to 0 for some weird reason. @@ -278,7 +325,7 @@ } ); // Snapshot object should be replaced, so we need to refetch it. - bookmark = undoManager.snapshots[ 0 ].bookmarks[ 0 ]; + bookmark = undoManager.snapshots[ 1 ].bookmarks[ 0 ]; assert.areEqual( 0, bookmark.startOffset, 'Invalid bookmark start offset' ); assert.areEqual( 0, bookmark.endOffset, 'Invalid bookmark start offset' ); @@ -333,7 +380,7 @@ extraBr = ( CKEDITOR.env.gecko || CKEDITOR.env.ie && CKEDITOR.env.version >= 11 ) ? '
' : '', that = this; - assert.areEqual( 1, undoManager.snapshots.length, 'Invalid initial snapshots count' ); + assert.areEqual( 2, undoManager.snapshots.length, 'Invalid initial snapshots count' ); // Initis with: foo ^bar this._moveTextNodeRange( 4 ); @@ -361,18 +408,18 @@ that._moveTextNodeRange( 3 ); } ); - assert.areEqual( 2, undoManager.snapshots.length, 'Invalid snapshots count' ); + assert.areEqual( 3, undoManager.snapshots.length, 'Invalid snapshots count' ); assert.areEqual( '

foo bar' + extraBr + '

', - undoManager.snapshots[ 0 ].contents.toLowerCase(), - 'Invalid content for undoManager.snapshot[0]' + undoManager.snapshots[ 1 ].contents.toLowerCase(), + 'Invalid content for undoManager.snapshot[1]' ); assert.areEqual( '

foo dbar' + extraBr + '

', - undoManager.snapshots[ 1 ].contents.toLowerCase(), - 'Invalid content for undoManager.snapshot[1]' + undoManager.snapshots[ 2 ].contents.toLowerCase(), + 'Invalid content for undoManager.snapshot[2]' ); }, @@ -393,6 +440,159 @@ assert.areEqual( CKEDITOR.TRISTATE_DISABLED, redoCommand.state, 'Invalid redo command state after typing character' ); }, + 'test snapshot created on more than 25 backspaces': function() { + this.editorBot.setData( '

12345678901234567890123451234567890123456789012345

', function() { + var undoManager = this.editor.undoManager, + textNode = this.editor.editable().getFirst().getFirst(), + // On IE we will have the snapshot created earlier, because + // we must mock the keypress when key is hold, so DOM isn't changed yet. + expected = CKEDITOR.env.ie ? + '

12345678901234567890123451@

' : + '

1234567890123456789012345@

'; + + this.editor.resetUndo(); + + assert.areEqual( 1, undoManager.snapshots.length, 'At the beginning' ); + + this._moveTextNodeRange( 50 ); + assert.areEqual( 1, undoManager.snapshots.length, 'After setting selection' ); + + simulateHoldKey( undoManager.strokesLimit, this.keyTools, 8 /*BACKSPACE*/, function() { + removeLastCharFromTextNode( textNode ); + } ); + + assert.areEqual( 2, undoManager.snapshots.length, 'After backspace' ); + bender.assert.isInnerHtmlMatching( expected, undoManager.snapshots[ 1 ].contents ); + } ); + }, + + 'test snapshot created on more than 25 changes by holding different keys - one after another': function() { + // #12425 + assert.ignore(); + + this.editorBot.setData( '

Hi

', function() { + var undoManager = this.editor.undoManager, + textNode = this.editor.editable().getFirst().getFirst(); + + this._moveTextNodeRange( 2 ); + + assert.areEqual( 2, undoManager.snapshots.length, 'Invalid snapshots count' ); + + simulateHoldKey( undoManager.strokesLimit, this.keyTools, 70 /*f*/, function() { + addCharactersToTextNode( textNode, 'f' ); + } ); + + simulateHoldKey( undoManager.strokesLimit, this.keyTools, 80 /*p*/, function() { + addCharactersToTextNode( textNode, 'p' ); + } ); + + assert.areEqual( 3, undoManager.snapshots.length, 'Invalid snapshots count' ); + bender.assert.isInnerHtmlMatching( '

HIffffffffffffffffffffppppp@

', undoManager.snapshots[ 2 ].contents ); + } ); + }, + + 'test snapshot created when holding backspace after typing': function() { + this.editorBot.setData( '

foo

', function() { + var undoManager = this.editor.undoManager, + textNode = this.editor.editable().getFirst().getFirst(); + + this.editor.resetUndo(); + + this._moveTextNodeRange( 3 ); + + for ( var i = 0; i < 5; i++ ) { + this.keyTools.keyEvent( keyCodesEnum.KEY_D, null, false, function() { + addCharactersToTextNode( textNode, 'd' ); + } ); + } + + simulateHoldKey( 3, this.keyTools, 8 /*BACKSPACE*/, function() { + removeLastCharFromTextNode( textNode ); + } ); + + assert.areSame( '

foodd

', this.editor.getData(), 'After deleting' ); + + this.editor.execCommand( 'undo' ); + + assert.areSame( '

fooddddd

', this.editor.getData(), 'After undo' ); + } ); + }, + + 'test one change event fired once per 25 hold keys': function() { + this.editorBot.setData( '

foo

', function() { + var undoManager = this.editor.undoManager, + textNode = this.editor.editable().getFirst().getFirst(), + changeFired = 0, + data; + + this.editor.resetUndo(); + + this._moveTextNodeRange( 3 ); + + var listener = this.editor.on( 'change', function() { + changeFired += 1; + data = this.getData(); + } ); + + simulateHoldKey( undoManager.strokesLimit, this.keyTools, keyCodesEnum.KEY_D, function() { + addCharactersToTextNode( textNode, 'd' ); + } ); + + listener.removeListener(); + + assert.areSame( 1, changeFired ); + assert.areSame( '

fooddddddddddddddddddddddddd

', data ); + } ); + }, + + 'test snapshot created on more than 25 changes by holding different keys - both at the same time': function() { + this.editorBot.setData( '

Hi

', function() { + var undoManager = this.editor.undoManager, + textNode = this.editor.editable().getFirst().getFirst(), + addCharacterF = curryAddCharactersToTextNode( textNode, 'f' ), + addCharacterD = curryAddCharactersToTextNode( textNode, 'd' ), + keyTools = this.keyTools; + + this._moveTextNodeRange( 2 ); + + assert.areEqual( 2, undoManager.snapshots.length, 'Invalid snapshots count' ); + + simulateHoldKey( undoManager.strokesLimit - 5, keyTools, 70 /*f*/, addCharacterF, function() { + simulateHoldKey( 10, keyTools, 68 /*d*/, addCharacterD ); + } ); + + assert.areEqual( 3, undoManager.snapshots.length, 'Invalid snapshots count' ); + bender.assert.isInnerHtmlMatching( '

Hiffffffffffffffffffffddddd@

', undoManager.snapshots[ 2 ].contents ); + } ); + }, + + 'test undo command disable undo and enable redo ui button': function() { + var initData = '

aaaaaaaaaaaaaaaaaaaaaaaaa

'; + + this.editorBot.setData( initData, function() { + var textNode = this.editor.editable().getFirst().getFirst(); + + this._moveTextNodeRange( 25 ); + simulateHoldKey( 13, this.keyTools, 8 /*BACKSPACE*/, function() { + removeLastCharFromTextNode( textNode ); + } ); + + simulateCtrlZ( this.keyTools ); + assert.areEqual( initData, this.editor.getData(), 'Data should be same as initial one, because we just undo.' ); + + simulateCtrlZ( this.keyTools ); + assert.areEqual( CKEDITOR.TRISTATE_DISABLED, this.editor.getCommand( 'undo' ).state, 'Undo should be disabled because we undo only change.' ); + assert.areEqual( CKEDITOR.TRISTATE_OFF, this.editor.getCommand( 'redo' ).state, 'Redo be enabled because we just uno.' ); + + function simulateCtrlZ( keyTools ) { + keyTools.singleKeyEvent( 17 /*CTRL*/, { type: 'keydown', ctrlKey: true } ); + keyTools.singleKeyEvent( 90 /*Z*/, { type: 'keydown', ctrlKey: true } ); + keyTools.singleKeyEvent( 17 /*CTRL*/, { type: 'keyup' } ); + keyTools.singleKeyEvent( 90 /*Z*/, { type: 'keyup' } ); + } + } ); + }, + 'test no snapshot on dummy backspace': function() { // Backspace which does not remove anything, shouln'd create snapshot. var undoCommand = this.editor.getCommand( 'undo' ), @@ -406,23 +606,23 @@ // after exceeding the chars in snapshot limit. this.keyTools.keyEventMultiple( 30, keyCodesEnum.BACKSPACE, null, skipInputEvent ); - assert.areEqual( 0, undoManager.snapshots.length, 'Invalid snapshots count' ); + assert.areEqual( 1, undoManager.snapshots.length, 'Invalid snapshots count' ); assert.areEqual( CKEDITOR.TRISTATE_DISABLED, undoCommand.state, 'Invalid undo command state after typing character' ); }, 'test undoManager.isNavigationKey': function() { var naviKeys = [ 'HOME', 'END', 'RIGHT', 'LEFT', 'DOWN', 'UP', 'PAGEDOWN', 'PAGEUP' ], - undoManager = this.editor.undoManager, + isNavigationKey = CKEDITOR.plugins.undo.UndoManager.isNavigationKey, curKey; for ( var i=0; i < naviKeys.length; i++ ) { curKey = naviKeys[ i ]; - assert.isTrue( undoManager.isNavigationKey( keyCodesEnum[ curKey ] ), 'Invalid result for ' + curKey ); + assert.isTrue( isNavigationKey( keyCodesEnum[ curKey ] ), 'Invalid result for ' + curKey ); } - assert.isFalse( undoManager.isNavigationKey( keyCodesEnum.BACKSPACE ), 'Invalid result for Backspace' ); - assert.isFalse( undoManager.isNavigationKey( keyCodesEnum.KEY_D ), 'Invalid result for D key' ); + assert.isFalse( isNavigationKey( keyCodesEnum.BACKSPACE ), 'Invalid result for Backspace' ); + assert.isFalse( isNavigationKey( keyCodesEnum.KEY_D ), 'Invalid result for D key' ); } }; diff --git a/tests/plugins/undo/keyeventsstack.js b/tests/plugins/undo/keyeventsstack.js new file mode 100644 index 00000000000..daebbd0f158 --- /dev/null +++ b/tests/plugins/undo/keyeventsstack.js @@ -0,0 +1,124 @@ +/* bender-tags: editor,unit */ +/* bender-ckeditor-plugins: undo */ + +( function() { + 'use strict'; + + var KeyEventsStack; + + bender.editor = true; + + bender.test( { + setUp: function() { + KeyEventsStack = CKEDITOR.plugins.undo.KeyEventsStack; + }, + + 'test properly push record': function() { + var kes = new KeyEventsStack(); + + kes.push( 13 ); + assert.areEqual( 13, kes.stack[ 0 ].keyCode, 'Proper object should be added.' ); + assert.areEqual( 1, kes.stack.length, 'Only one should be in stack' ); + }, + + 'test properly find last index': function() { + var kes = new KeyEventsStack(); + + kes.push( 13 ); + kes.push( 23 ); + + assert.areEqual( 1, kes.getLastIndex() ); + assert.areEqual( -1, kes.getLastIndex( 100 ) ); + assert.areEqual( 0, kes.getLastIndex( 13 ) ); + assert.areEqual( 1, kes.getLastIndex( 23 ) ); + }, + + 'test properly find last record': function() { + var kes = new KeyEventsStack(); + + kes.push( 13 ); + kes.push( 23 ); + + assert.areEqual( 23, kes.getLast().keyCode ); + assert.areEqual( null, kes.getLast( 100 ) ); + assert.areEqual( 13, kes.getLast( 13 ).keyCode ); + assert.areEqual( 23, kes.getLast( 23 ).keyCode ); + }, + + 'test properly increment record': function() { + var kes = new KeyEventsStack(); + + kes.push( 13 ); + kes.push( 23 ); + + kes.increment( 13 ); + assert.areEqual( 1, kes.stack[ 0 ].inputs ); + }, + + 'test properly remove record': function() { + var kes = new KeyEventsStack(); + + kes.push( 13 ); + kes.push( 23 ); + + kes.remove(); + assert.areEqual( 1, kes.stack.length ); + assert.areEqual( 13, kes.stack[ 0 ].keyCode ); + + kes.remove( 13 ); + assert.areEqual( 0, kes.stack.length ); + }, + + 'test properly return total inputs': function() { + var kes = new KeyEventsStack(); + + kes.push( 13 ); + kes.push( 23 ); + + kes.increment( 13 ); + kes.increment( 13 ); + assert.areEqual( 2, kes.getTotalInputs() ); + + kes.increment( 23 ); + assert.areEqual( 3, kes.getTotalInputs() ); + }, + + 'test propetly reset inputs': function() { + var kes = new KeyEventsStack(); + + kes.push( 13 ); + kes.increment( 13 ); + kes.increment( 13 ); + kes.increment( 13 ); + kes.push( 23 ); + kes.increment( 23 ); + kes.increment( 23 ); + kes.push( 33 ); + kes.increment( 33 ); + + kes.resetInputs( 23 ); + assert.areEqual( 4, kes.getTotalInputs() ); + + kes.resetInputs(); + assert.areEqual( 0, kes.getTotalInputs() ); + }, + + 'test properly clean up': function() { + var kes = new KeyEventsStack(); + + kes.push( 17 ); /* Ctrl */ + kes.push( 16 ); /* Shift */ + + kes.cleanUp( { + data: { + $: { + ctrlKey: true + } + } + } ); + + assert.areEqual( 1, kes.stack.length ); + assert.areEqual( 17, kes.stack[ 0 ].keyCode ); + } + } ); +} )(); \ No newline at end of file diff --git a/tests/plugins/undo/pointer.js b/tests/plugins/undo/pointer.js index 161167c3a72..250c1d7a50e 100644 --- a/tests/plugins/undo/pointer.js +++ b/tests/plugins/undo/pointer.js @@ -29,7 +29,7 @@ this.undoManager = this.editor.undoManager; // For each TC we want to reset undoManager. - this.undoManager.reset(); + this.editor.resetUndo(); }, _should: { @@ -82,8 +82,8 @@ noSnapshot: true, callback: function() { resume( function() { - // There should be no snapshots at the begining, because of noSnapshot. - assert.areEqual( 0, that.editor.undoManager.snapshots.length, 'Invalid snapshots count' ); + // There should be one snapshot at the begining, because of noSnapshot. + assert.areEqual( 1, that.editor.undoManager.snapshots.length, 'Invalid snapshots count' ); // This will make initial snapshot. that.tools.mouse.click( null, function() { @@ -98,9 +98,9 @@ var snapshots = that.editor.undoManager.snapshots, bookmark; - assert.areEqual( 1, snapshots.length, 'Invalid snapshots count' ); + assert.areEqual( 2, snapshots.length, 'Invalid snapshots count' ); - bookmark = snapshots[ 0 ].bookmarks[ 0 ]; + bookmark = snapshots[ 1 ].bookmarks[ 0 ]; // Selection should be moved. assert.areEqual( 2, bookmark.startOffset, 'Invalid start offset' );