浏览代码

Merge pull request #24 from ckeditor/t/17

Merge ckeditor5-delete into this package
Szymon Cofalik 9 年之前
父节点
当前提交
36eb11eee9

+ 31 - 0
packages/ckeditor5-typing/src/delete.js

@@ -0,0 +1,31 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+import Feature from '../feature.js';
+import DeleteCommand from './deletecommand.js';
+import DeleteObserver from './deleteobserver.js';
+
+/**
+ * The delete and backspace feature. Handles <kbd>Delete</kbd> and <kbd>Backspace</kbd> keys in the editor.
+ *
+ * @memberOf delete
+ * @extends ckeditor5.Feature
+ */
+export default class Delete extends Feature {
+	init() {
+		const editor = this.editor;
+		const editingView = editor.editing.view;
+
+		editingView.addObserver( DeleteObserver );
+
+		editor.commands.set( 'forwardDelete', new DeleteCommand( editor, 'forward' ) );
+		editor.commands.set( 'delete', new DeleteCommand( editor, 'backward' ) );
+
+		this.listenTo( editingView, 'delete', ( evt, data ) => {
+			editor.execute( data.direction == 'forward' ? 'forwardDelete' : 'delete' );
+			data.preventDefault();
+		} );
+	}
+}

+ 85 - 0
packages/ckeditor5-typing/src/deletecommand.js

@@ -0,0 +1,85 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+import Command from '../command/command.js';
+import Selection from '../engine/model/selection.js';
+import ChangeBuffer from './changebuffer.js';
+import count from '../utils/count.js';
+
+/**
+ * Delete command. Used by the {@link typing.Delete delete feature} to handle <kbd>Delete</kbd> and
+ * <kbd>Backspace</kbd> keys.
+ *
+ * @member delete
+ * @extends ckeditor5.command.Command
+ */
+export default class DeleteCommand extends Command {
+	/**
+	 * Creates instance of the command;
+	 *
+	 * @param {ckeditor5.Editor} editor
+	 * @param {'forward'|'backward'} direction The directionality of the delete (in what direction it should
+	 * consume the content when selection is collapsed).
+	 */
+	constructor( editor, direction ) {
+		super( editor );
+
+		/**
+		 * The directionality of the delete (in what direction it should
+		 * consume the content when selection is collapsed).
+		 *
+		 * @readonly
+		 * @member {'forward'|'backward'} typing.DeleteCommand#direction
+		 */
+		this.direction = direction;
+
+		/**
+		 * Delete's change buffer used to group subsequent changes into batches.
+		 *
+		 * @readonly
+		 * @private
+		 * @member {typing.ChangeBuffer} typing.DeleteCommand#buffer
+		 */
+		this._buffer = new ChangeBuffer( editor.document, editor.config.get( 'undo.step' ) );
+	}
+
+	/**
+	 * Executes the command: depending on whether the selection is collapsed or not, deletes its contents
+	 * or piece of content in the {@link typing.DeleteCommand#direction defined direction}.
+	 *
+	 * @param {Object} [options] The command options.
+	 * @param {'character'} [options.unit='character'] See {@link engine.model.composer.modifySelection}'s options.
+	 */
+	_doExecute( options = {} ) {
+		const doc = this.editor.document;
+
+		doc.enqueueChanges( () => {
+			const selection = Selection.createFromSelection( doc.selection );
+
+			// Try to extend the selection in the specified direction.
+			if ( selection.isCollapsed ) {
+				doc.composer.modifySelection( selection, { direction: this.direction, unit: options.unit } );
+			}
+
+			// If selection is still collapsed, then there's nothing to delete.
+			if ( selection.isCollapsed ) {
+				return;
+			}
+
+			let changeCount = 0;
+
+			selection.getFirstRange().getMinimalFlatRanges().forEach( ( range ) => {
+				changeCount += count(
+					range.getWalker( { singleCharacters: true, ignoreElementEnd: true, shallow: true } )
+				);
+			} );
+
+			doc.composer.deleteContents( this._buffer.batch, selection, { merge: true } );
+			this._buffer.input( changeCount );
+
+			doc.selection.setRanges( selection.getRanges(), selection.isBackward );
+		} );
+	}
+}

+ 53 - 0
packages/ckeditor5-typing/src/deleteobserver.js

@@ -0,0 +1,53 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+import Observer from '../engine/view/observer/observer.js';
+import DomEventData from '../engine/view/observer/domeventdata.js';
+import { keyCodes } from '../utils/keyboard.js';
+
+/**
+ * Delete observer introduces the {@link engine.view.Document#delete} event.
+ *
+ * @memberOf delete
+ * @extends engine.view.observer.Observer
+ */
+export default class DeleteObserver extends Observer {
+	constructor( document ) {
+		super( document );
+
+		document.on( 'keydown', ( evt, data ) => {
+			const deleteData = {};
+
+			if ( data.keyCode == keyCodes.delete ) {
+				deleteData.direction = 'forward';
+			} else if ( data.keyCode == keyCodes.backspace ) {
+				deleteData.direction = 'backward';
+			} else {
+				return;
+			}
+
+			deleteData.unit = data.altKey ? 'word' : 'character';
+
+			document.fire( 'delete', new DomEventData( document, data.domEvent, deleteData ) );
+		} );
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	observe() {}
+}
+
+/**
+ * Event fired when the user tries to delete contents (e.g. presses <kbd>Delete</kbd> or <kbd>Backspace</kbd>).
+ *
+ * Note: This event is fired by the {@link typing.DeleteObserver observer}
+ * (usually registered by the {@link typing.Delete delete feature}).
+ *
+ * @event engine.view.Document#delete
+ * @param {engine.view.observer.DomEventData} data
+ * @param {'forward'|'delete'} data.direction The direction in which the deletion should happen.
+ * @param {'character'|'word'} data.unit The "amount" of content that should be deleted.
+ */

+ 281 - 0
packages/ckeditor5-typing/src/input.js

@@ -0,0 +1,281 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+import Feature from '../feature.js';
+import ChangeBuffer from './changebuffer.js';
+import ModelPosition from '../engine/model/position.js';
+import ModelRange from '../engine/model/range.js';
+import ViewPosition from '../engine/view/position.js';
+import ViewText from '../engine/view/text.js';
+import diff from '../utils/diff.js';
+import diffToChanges from '../utils/difftochanges.js';
+import { getCode } from '../utils/keyboard.js';
+
+/**
+ * The typing feature. Handles... typing.
+ *
+ * @memberOf typing
+ * @extends ckeditor5.Feature
+ */
+export default class Input extends Feature {
+	/**
+	 * @inheritDoc
+	 */
+	init() {
+		const editor = this.editor;
+		const editingView = editor.editing.view;
+
+		/**
+		 * Typing's change buffer used to group subsequent changes into batches.
+		 *
+		 * @protected
+		 * @member {typing.ChangeBuffer} typing.Input#_buffer
+		 */
+		this._buffer = new ChangeBuffer( editor.document, editor.config.get( 'typing.undoStep' ) || 20 );
+
+		// TODO The above default config value should be defines using editor.config.define() once it's fixed.
+
+		this.listenTo( editingView, 'keydown', ( evt, data ) => {
+			this._handleKeydown( data );
+		}, null, 9999 ); // LOWEST
+
+		this.listenTo( editingView, 'mutations', ( evt, mutations ) => {
+			this._handleMutations( mutations );
+		} );
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	destroy() {
+		super.destroy();
+
+		this._buffer.destroy();
+		this._buffer = null;
+	}
+
+	/**
+	 * Handles keydown event. We need to guess whether such a keystroke is going to result
+	 * in typing. If so, then before character insertion happens, we need to delete
+	 * any selected content. Otherwise, a default browser deletion mechanism would be
+	 * triggered, resulting in:
+	 *
+	 * * hundreds of mutations which couldn't be handled,
+	 * * but most importantly, loss of a control over how content is being deleted.
+	 *
+	 * The method is used in a low-prior listener, hence allowing other listeners (e.g. delete or enter features)
+	 * to handle the event.
+	 *
+	 * @private
+	 * @param {engine.view.observer.keyObserver.KeyEventData} evtData
+	 */
+	_handleKeydown( evtData ) {
+		const doc = this.editor.document;
+
+		if ( isSafeKeystroke( evtData ) || doc.selection.isCollapsed ) {
+			return;
+		}
+
+		doc.enqueueChanges( () => {
+			doc.composer.deleteContents( this._buffer.batch, doc.selection );
+		} );
+	}
+
+	/**
+	 * Handles DOM mutations.
+	 *
+	 * @param {Array.<engine.view.Document~MutatatedText|engine.view.Document~MutatatedChildren>} mutations
+	 */
+	_handleMutations( mutations ) {
+		const doc = this.editor.document;
+		const handler = new MutationHandler( this.editor.editing, this._buffer );
+
+		doc.enqueueChanges( () => handler.handle( mutations ) );
+	}
+}
+
+/**
+ * Helper class for translating DOM mutations into model changes.
+ *
+ * @private
+ * @member typing.Input
+ */
+class MutationHandler {
+	/**
+	 * Creates instance of the mutation handler.
+	 *
+	 * @param {engine.EditingController} editing
+	 * @param {typing.ChangeBuffer} buffer
+	 */
+	constructor( editing, buffer ) {
+		/**
+		 * The editing controller.
+		 *
+		 * @member {engine.EditingController} typing.Input.MutationHandler#editing
+		 */
+		this.editing = editing;
+
+		/**
+		 * The change buffer.
+		 *
+		 * @member {engine.EditingController} typing.Input.MutationHandler#buffer
+		 */
+		this.buffer = buffer;
+
+		/**
+		 * Number of inserted characters which need to be feed to the {@link #buffer change buffer}
+		 * on {@link #commit}.
+		 *
+		 * @member {Number} typing.Input.MutationHandler#insertedCharacterCount
+		 */
+		this.insertedCharacterCount = 0;
+
+		/**
+		 * Position to which the selection should be moved on {@link #commit}.
+		 *
+		 * Note: Currently, the mutation handler will move selection to the position set by the
+		 * last consumer. Placing the selection right after the last change will work for many cases, but not
+		 * for ones like autocorrection or spellchecking. The caret should be placed after the whole piece
+		 * which was corrected (e.g. a word), not after the letter that was replaced.
+		 *
+		 * @member {engine.model.Position} typing.Input.MutationHandler#selectionPosition
+		 */
+	}
+
+	/**
+	 * Handle given mutations.
+	 *
+	 * @param {Array.<engine.view.Document~MutatatedText|engine.view.Document~MutatatedChildren>} mutations
+	 */
+	handle( mutations ) {
+		for ( let mutation of mutations ) {
+			// Fortunately it will never be both.
+			this._handleTextMutation( mutation );
+			this._handleTextNodeInsertion( mutation );
+		}
+
+		this.buffer.input( Math.max( this.insertedCharacterCount, 0 ) );
+
+		if ( this.selectionPosition ) {
+			this.editing.model.selection.collapse( this.selectionPosition );
+		}
+	}
+
+	_handleTextMutation( mutation ) {
+		if ( mutation.type != 'text' ) {
+			return;
+		}
+
+		const diffResult = diff( mutation.oldText, mutation.newText );
+		const changes = diffToChanges( diffResult, mutation.newText );
+
+		for ( let change of changes ) {
+			const viewPos = new ViewPosition( mutation.node, change.index );
+			const modelPos = this.editing.mapper.toModelPosition( viewPos );
+
+			if ( change.type == 'insert' ) {
+				const insertedText = change.values.join( '' );
+
+				this._insert( modelPos, insertedText );
+
+				this.selectionPosition = ModelPosition.createAt( modelPos.parent, modelPos.offset + insertedText.length );
+			} else /* if ( change.type == 'delete' ) */ {
+				this._remove( new ModelRange( modelPos, modelPos.getShiftedBy( change.howMany ) ), change.howMany );
+
+				this.selectionPosition = modelPos;
+			}
+		}
+	}
+
+	_handleTextNodeInsertion( mutation ) {
+		if ( mutation.type != 'children' ) {
+			return;
+		}
+
+		// One new node.
+		if ( mutation.newChildren.length - mutation.oldChildren.length != 1 ) {
+			return;
+		}
+
+		// Which is text.
+		const diffResult = diff( mutation.oldChildren, mutation.newChildren, compare );
+		const changes = diffToChanges( diffResult, mutation.newChildren );
+
+		// In case of [ delete, insert, insert ] the previous check will not exit.
+		if ( changes.length > 1 ) {
+			return;
+		}
+
+		const change = changes[ 0 ];
+
+		if ( !( change.values[ 0 ] instanceof ViewText ) ) {
+			return;
+		}
+
+		const viewPos = new ViewPosition( mutation.node, change.index );
+		const modelPos = this.editing.mapper.toModelPosition( viewPos );
+		const insertedText = change.values[ 0 ].data;
+
+		this._insert( modelPos, insertedText );
+
+		this.selectionPosition = ModelPosition.createAt( modelPos.parent, 'end' );
+
+		function compare( oldChild, newChild ) {
+			if ( oldChild instanceof ViewText && newChild instanceof ViewText ) {
+				return oldChild.data === newChild.data;
+			} else {
+				return oldChild === newChild;
+			}
+		}
+	}
+
+	_insert( position, text ) {
+		this.buffer.batch.weakInsert( position, text );
+
+		this.insertedCharacterCount += text.length;
+	}
+
+	_remove( range, length ) {
+		this.buffer.batch.remove( range );
+
+		this.insertedCharacterCount -= length;
+	}
+}
+
+const safeKeycodes = [
+	getCode( 'arrowUp' ),
+	getCode( 'arrowRight' ),
+	getCode( 'arrowDown' ),
+	getCode( 'arrowLeft' ),
+	16, // Shift
+	17, // Ctrl
+	18, // Alt
+	20, // CapsLock
+	27, // Escape
+	33, // PageUp
+	34, // PageDown
+	35, // Home
+	36, // End
+];
+
+// Function keys.
+for ( let code = 112; code <= 135; code++ ) {
+	safeKeycodes.push( code );
+}
+
+// Returns true if a keystroke should not cause any content change caused by "typing".
+//
+// Note: this implementation is very simple and will need to be refined with time.
+//
+// @param {engine.view.observer.keyObserver.KeyEventData} keyData
+// @returns {Boolean}
+function isSafeKeystroke( keyData ) {
+	// Keystrokes which contain Ctrl don't represent typing.
+	if ( keyData.ctrlKey ) {
+		return true;
+	}
+
+	return safeKeycodes.includes( keyData.keyCode );
+}

+ 5 - 260
packages/ckeditor5-typing/src/typing.js

@@ -4,14 +4,8 @@
  */
 
 import Feature from '../feature.js';
-import ChangeBuffer from './changebuffer.js';
-import ModelPosition from '../engine/model/position.js';
-import ModelRange from '../engine/model/range.js';
-import ViewPosition from '../engine/view/position.js';
-import ViewText from '../engine/view/text.js';
-import diff from '../utils/diff.js';
-import diffToChanges from '../utils/difftochanges.js';
-import { getCode } from '../utils/keyboard.js';
+import Input from './input';
+import Delete from './delete';
 
 /**
  * The typing feature. Handles... typing.
@@ -20,262 +14,13 @@ import { getCode } from '../utils/keyboard.js';
  * @extends ckeditor5.Feature
  */
 export default class Typing extends Feature {
-	/**
-	 * @inheritDoc
-	 */
-	init() {
-		const editor = this.editor;
-		const editingView = editor.editing.view;
-
-		/**
-		 * Typing's change buffer used to group subsequent changes into batches.
-		 *
-		 * @protected
-		 * @member {typing.ChangeBuffer} typing.Typing#_buffer
-		 */
-		this._buffer = new ChangeBuffer( editor.document, editor.config.get( 'typing.undoStep' ) || 20 );
-
-		// TODO The above default config value should be defines using editor.config.define() once it's fixed.
-
-		this.listenTo( editingView, 'keydown', ( evt, data ) => {
-			this._handleKeydown( data );
-		}, null, 9999 ); // LOWEST
-
-		this.listenTo( editingView, 'mutations', ( evt, mutations ) => {
-			this._handleMutations( mutations );
-		} );
+	static get requires() {
+		return [ Input, Delete ];
 	}
 
 	/**
 	 * @inheritDoc
 	 */
-	destroy() {
-		super.destroy();
-
-		this._buffer.destroy();
-		this._buffer = null;
-	}
-
-	/**
-	 * Handles keydown event. We need to guess whether such a keystroke is going to result
-	 * in typing. If so, then before character insertion happens, we need to delete
-	 * any selected content. Otherwise, a default browser deletion mechanism would be
-	 * triggered, resulting in:
-	 *
-	 * * hundreds of mutations which couldn't be handled,
-	 * * but most importantly, loss of a control over how content is being deleted.
-	 *
-	 * The method is used in a low-prior listener, hence allowing other listeners (e.g. delete or enter features)
-	 * to handle the event.
-	 *
-	 * @private
-	 * @param {engine.view.observer.keyObserver.KeyEventData} evtData
-	 */
-	_handleKeydown( evtData ) {
-		const doc = this.editor.document;
-
-		if ( isSafeKeystroke( evtData ) || doc.selection.isCollapsed ) {
-			return;
-		}
-
-		doc.enqueueChanges( () => {
-			doc.composer.deleteContents( this._buffer.batch, doc.selection );
-		} );
-	}
-
-	/**
-	 * Handles DOM mutations.
-	 *
-	 * @param {Array.<engine.view.Document~MutatatedText|engine.view.Document~MutatatedChildren>} mutations
-	 */
-	_handleMutations( mutations ) {
-		const doc = this.editor.document;
-		const handler = new MutationHandler( this.editor.editing, this._buffer );
-
-		doc.enqueueChanges( () => handler.handle( mutations ) );
-	}
-}
-
-/**
- * Helper class for translating DOM mutations into model changes.
- *
- * @private
- * @member typing.typing
- */
-class MutationHandler {
-	/**
-	 * Creates instance of the mutation handler.
-	 *
-	 * @param {engine.EditingController} editing
-	 * @param {typing.ChangeBuffer} buffer
-	 */
-	constructor( editing, buffer ) {
-		/**
-		 * The editing controller.
-		 *
-		 * @member {engine.EditingController} typing.typing.MutationHandler#editing
-		 */
-		this.editing = editing;
-
-		/**
-		 * The change buffer.
-		 *
-		 * @member {engine.EditingController} typing.typing.MutationHandler#buffer
-		 */
-		this.buffer = buffer;
-
-		/**
-		 * Number of inserted characters which need to be feed to the {@link #buffer change buffer}
-		 * on {@link #commit}.
-		 *
-		 * @member {Number} typing.typing.MutationHandler#insertedCharacterCount
-		 */
-		this.insertedCharacterCount = 0;
-
-		/**
-		 * Position to which the selection should be moved on {@link #commit}.
-		 *
-		 * Note: Currently, the mutation handler will move selection to the position set by the
-		 * last consumer. Placing the selection right after the last change will work for many cases, but not
-		 * for ones like autocorrection or spellchecking. The caret should be placed after the whole piece
-		 * which was corrected (e.g. a word), not after the letter that was replaced.
-		 *
-		 * @member {engine.model.Position} typing.typing.MutationHandler#selectionPosition
-		 */
-	}
-
-	/**
-	 * Handle given mutations.
-	 *
-	 * @param {Array.<engine.view.Document~MutatatedText|engine.view.Document~MutatatedChildren>} mutations
-	 */
-	handle( mutations ) {
-		for ( let mutation of mutations ) {
-			// Fortunately it will never be both.
-			this._handleTextMutation( mutation );
-			this._handleTextNodeInsertion( mutation );
-		}
-
-		this.buffer.input( Math.max( this.insertedCharacterCount, 0 ) );
-
-		if ( this.selectionPosition ) {
-			this.editing.model.selection.collapse( this.selectionPosition );
-		}
-	}
-
-	_handleTextMutation( mutation ) {
-		if ( mutation.type != 'text' ) {
-			return;
-		}
-
-		const diffResult = diff( mutation.oldText, mutation.newText );
-		const changes = diffToChanges( diffResult, mutation.newText );
-
-		for ( let change of changes ) {
-			const viewPos = new ViewPosition( mutation.node, change.index );
-			const modelPos = this.editing.mapper.toModelPosition( viewPos );
-
-			if ( change.type == 'insert' ) {
-				const insertedText = change.values.join( '' );
-
-				this._insert( modelPos, insertedText );
-
-				this.selectionPosition = ModelPosition.createAt( modelPos.parent, modelPos.offset + insertedText.length );
-			} else /* if ( change.type == 'delete' ) */ {
-				this._remove( new ModelRange( modelPos, modelPos.getShiftedBy( change.howMany ) ), change.howMany );
-
-				this.selectionPosition = modelPos;
-			}
-		}
-	}
-
-	_handleTextNodeInsertion( mutation ) {
-		if ( mutation.type != 'children' ) {
-			return;
-		}
-
-		// One new node.
-		if ( mutation.newChildren.length - mutation.oldChildren.length != 1 ) {
-			return;
-		}
-
-		// Which is text.
-		const diffResult = diff( mutation.oldChildren, mutation.newChildren, compare );
-		const changes = diffToChanges( diffResult, mutation.newChildren );
-
-		// In case of [ delete, insert, insert ] the previous check will not exit.
-		if ( changes.length > 1 ) {
-			return;
-		}
-
-		const change = changes[ 0 ];
-
-		if ( !( change.values[ 0 ] instanceof ViewText ) ) {
-			return;
-		}
-
-		const viewPos = new ViewPosition( mutation.node, change.index );
-		const modelPos = this.editing.mapper.toModelPosition( viewPos );
-		const insertedText = change.values[ 0 ].data;
-
-		this._insert( modelPos, insertedText );
-
-		this.selectionPosition = ModelPosition.createAt( modelPos.parent, 'end' );
-
-		function compare( oldChild, newChild ) {
-			if ( oldChild instanceof ViewText && newChild instanceof ViewText ) {
-				return oldChild.data === newChild.data;
-			} else {
-				return oldChild === newChild;
-			}
-		}
-	}
-
-	_insert( position, text ) {
-		this.buffer.batch.weakInsert( position, text );
-
-		this.insertedCharacterCount += text.length;
-	}
-
-	_remove( range, length ) {
-		this.buffer.batch.remove( range );
-
-		this.insertedCharacterCount -= length;
-	}
-}
-
-const safeKeycodes = [
-	getCode( 'arrowUp' ),
-	getCode( 'arrowRight' ),
-	getCode( 'arrowDown' ),
-	getCode( 'arrowLeft' ),
-	16, // Shift
-	17, // Ctrl
-	18, // Alt
-	20, // CapsLock
-	27, // Escape
-	33, // PageUp
-	34, // PageDown
-	35, // Home
-	36, // End
-];
-
-// Function keys.
-for ( let code = 112; code <= 135; code++ ) {
-	safeKeycodes.push( code );
-}
-
-// Returns true if a keystroke should not cause any content change caused by "typing".
-//
-// Note: this implementation is very simple and will need to be refined with time.
-//
-// @param {engine.view.observer.keyObserver.KeyEventData} keyData
-// @returns {Boolean}
-function isSafeKeystroke( keyData ) {
-	// Keystrokes which contain Ctrl don't represent typing.
-	if ( keyData.ctrlKey ) {
-		return true;
+	init() {
 	}
-
-	return safeKeycodes.includes( keyData.keyCode );
 }

+ 57 - 0
packages/ckeditor5-typing/tests/delete.js

@@ -0,0 +1,57 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+import VirtualTestEditor from '/tests/ckeditor5/_utils/virtualtesteditor.js';
+import Delete from '/ckeditor5/typing/delete.js';
+import DomEventData from '/ckeditor5/engine/view/observer/domeventdata.js';
+
+describe( 'Delete feature', () => {
+	let editor, editingView;
+
+	beforeEach( () => {
+		return VirtualTestEditor.create( {
+				features: [ Delete ]
+			} )
+			.then( newEditor => {
+				editor = newEditor;
+				editingView = editor.editing.view;
+			} );
+	} );
+
+	it( 'creates two commands', () => {
+		expect( editor.commands.get( 'delete' ) ).to.have.property( 'direction', 'backward' );
+		expect( editor.commands.get( 'forwardDelete' ) ).to.have.property( 'direction', 'forward' );
+	} );
+
+	it( 'listens to the editing view delete event', () => {
+		const spy = editor.execute = sinon.spy();
+		const view = editor.editing.view;
+		const domEvt = getDomEvent();
+
+		view.fire( 'delete', new DomEventData( editingView, domEvt, {
+			direction: 'forward',
+			unit: 'character'
+		} ) );
+
+		expect( spy.calledOnce ).to.be.true;
+		expect( spy.calledWithExactly( 'forwardDelete' ) ).to.be.true;
+
+		expect( domEvt.preventDefault.calledOnce ).to.be.true;
+
+		view.fire( 'delete', new DomEventData( editingView, getDomEvent(), {
+			direction: 'backward',
+			unit: 'character'
+		} ) );
+
+		expect( spy.calledTwice ).to.be.true;
+		expect( spy.calledWithExactly( 'delete' ) ).to.be.true;
+	} );
+
+	function getDomEvent() {
+		return {
+			preventDefault: sinon.spy()
+		};
+	}
+} );

+ 113 - 0
packages/ckeditor5-typing/tests/deletecommand-integration.js

@@ -0,0 +1,113 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+import ModelTestEditor from '/tests/ckeditor5/_utils/modeltesteditor.js';
+import DeleteCommand from '/ckeditor5/typing/deletecommand.js';
+import UndoEngine from '/ckeditor5/undo/undoengine.js';
+import { getData, setData } from '/tests/engine/_utils/model.js';
+
+let editor, doc;
+
+beforeEach( () => {
+	return ModelTestEditor.create( {
+			features: [
+				UndoEngine
+			],
+			undo: {
+				step: 3
+			}
+		} )
+		.then( newEditor => {
+			editor = newEditor;
+			doc = editor.document;
+
+			const command = new DeleteCommand( editor, 'backward' );
+			editor.commands.set( 'delete', command );
+
+			doc.schema.registerItem( 'p', '$block' );
+			doc.schema.registerItem( 'img', '$inline' );
+			doc.schema.allow( { name: '$text', inside: 'img' } );
+		} );
+} );
+
+function assertOutput( output ) {
+	expect( getData( doc ) ).to.equal( output );
+}
+
+describe( 'DeleteCommand integration', () => {
+	it( 'deletes characters (and group changes in batches) and rollbacks', () => {
+		setData( doc, '<p>123456789<selection /></p>' );
+
+		for ( let i = 0; i < 3; ++i ) {
+			editor.execute( 'delete' );
+		}
+
+		editor.execute( 'undo' );
+
+		assertOutput( '<p>123456789<selection /></p>' );
+	} );
+
+	it( 'deletes characters (and group changes in batches) and rollbacks - test step', () => {
+		setData( doc, '<p>123456789<selection /></p>' );
+
+		for ( let i = 0; i < 6; ++i ) {
+			editor.execute( 'delete' );
+		}
+
+		editor.execute( 'undo' );
+
+		assertOutput( '<p>123456<selection /></p>' );
+
+		editor.execute( 'undo' );
+
+		assertOutput( '<p>123456789<selection /></p>' );
+	} );
+
+	it( 'deletes elements (and group changes in batches) and rollbacks', () => {
+		setData( doc, '<p><img>1</img><img>2</img><img>3</img><img>4</img><img>5</img><img>6</img><selection /></p>' );
+
+		for ( let i = 0; i < 3; ++i ) {
+			editor.execute( 'delete' );
+		}
+
+		editor.execute( 'undo' );
+
+		assertOutput( '<p><img>1</img><img>2</img><img>3</img><img>4</img><img>5</img><img>6</img><selection /></p>' );
+	} );
+
+	it( 'merges elements (and group changes in batches) and rollbacks', () => {
+		setData( doc, '<p>123456</p><p><selection />78</p>' );
+
+		for ( let i = 0; i < 6; ++i ) {
+			editor.execute( 'delete' );
+		}
+
+		editor.execute( 'undo' );
+
+		// Deleted 6,5,4, <P> does not count.
+		// It's not the most elegant solution, but is the best if we don't want to make complicated algorithm.
+		assertOutput( '<p>123<selection />78</p>' );
+
+		editor.execute( 'undo' );
+
+		assertOutput( '<p>123456</p><p><selection />78</p>' );
+	} );
+
+	it( 'merges elements (and group changes in batches) and rollbacks - non-collapsed selection', () => {
+		setData( doc, '<p>12345<selection>6</p><p>7</selection>8</p>' );
+
+		editor.execute( 'delete' );
+		editor.execute( 'delete' );
+		editor.execute( 'delete' );
+
+		editor.execute( 'undo' );
+
+		assertOutput( '<p>1234<selection />8</p>' );
+
+		editor.execute( 'undo' );
+
+		assertOutput( '<p>12345<selection>6</p><p>7</selection>8</p>' );
+	} );
+} );

+ 96 - 0
packages/ckeditor5-typing/tests/deletecommand.js

@@ -0,0 +1,96 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+import ModelTestEditor from '/tests/ckeditor5/_utils/modeltesteditor.js';
+import DeleteCommand from '/ckeditor5/typing/deletecommand.js';
+import { getData, setData } from '/tests/engine/_utils/model.js';
+
+describe( 'DeleteCommand', () => {
+	let editor, doc;
+
+	beforeEach( () => {
+		return ModelTestEditor.create( )
+			.then( newEditor => {
+				editor = newEditor;
+				doc = editor.document;
+
+				const command = new DeleteCommand( editor, 'backward' );
+				editor.commands.set( 'delete', command );
+
+				doc.schema.registerItem( 'p', '$block' );
+			} );
+	} );
+
+	it( 'has direction', () => {
+		const command = new DeleteCommand( editor, 'forward' );
+
+		expect( command ).to.have.property( 'direction', 'forward' );
+	} );
+
+	describe( 'execute', () => {
+		it( 'uses enqueueChanges', () => {
+			const spy = sinon.spy( doc, 'enqueueChanges' );
+
+			setData( doc, '<p>foo<selection />bar</p>' );
+
+			editor.execute( 'delete' );
+
+			expect( spy.calledOnce ).to.be.true;
+		} );
+
+		it( 'deletes previous character when selection is collapsed', () => {
+			setData( doc, '<p>foo<selection />bar</p>' );
+
+			editor.execute( 'delete' );
+
+			expect( getData( doc, { selection: true } ) ).to.equal( '<p>fo<selection />bar</p>' );
+		} );
+
+		it( 'deletes selection contents', () => {
+			setData( doc, '<p>fo<selection>ob</selection>ar</p>' );
+
+			editor.execute( 'delete' );
+
+			expect( getData( doc, { selection: true } ) ).to.equal( '<p>fo<selection />ar</p>' );
+		} );
+
+		it( 'merges elements', () => {
+			setData( doc, '<p>foo</p><p><selection />bar</p>' );
+
+			editor.execute( 'delete' );
+
+			expect( getData( doc, { selection: true } ) ).to.equal( '<p>foo<selection />bar</p>' );
+		} );
+
+		it( 'does not try to delete when selection is at the boundary', () => {
+			const spy = sinon.spy();
+
+			doc.composer.on( 'deleteContents', spy );
+			setData( doc, '<p><selection />foo</p>' );
+
+			editor.execute( 'delete' );
+
+			expect( getData( doc, { selection: true } ) ).to.equal( '<p><selection />foo</p>' );
+			expect( spy.callCount ).to.equal( 0 );
+		} );
+
+		it( 'passes options to modifySelection', () => {
+			const spy = sinon.spy();
+
+			doc.composer.on( 'modifySelection', spy );
+			setData( doc, '<p>foo<selection />bar</p>' );
+
+			editor.commands.get( 'delete' ).direction = 'forward';
+
+			editor.execute( 'delete', { unit: 'word' } );
+
+			expect( spy.callCount ).to.equal( 1 );
+
+			const modifyOpts = spy.args[ 0 ][ 1 ].options;
+			expect( modifyOpts ).to.have.property( 'direction', 'forward' );
+			expect( modifyOpts ).to.have.property( 'unit', 'word' );
+		} );
+	} );
+} );

+ 78 - 0
packages/ckeditor5-typing/tests/deleteobserver.js

@@ -0,0 +1,78 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+import DeleteObserver from '/ckeditor5/typing/deleteobserver.js';
+import ViewDocument from '/ckeditor5/engine/view/document.js';
+import DomEventData from '/ckeditor5/engine/view/observer/domeventdata.js';
+import { getCode } from '/ckeditor5/utils/keyboard.js';
+
+describe( 'DeleteObserver', () => {
+	let viewDocument, observer;
+
+	beforeEach( () => {
+		viewDocument = new ViewDocument();
+		observer = viewDocument.addObserver( DeleteObserver );
+	} );
+
+	// See ckeditor/ckeditor5-enter#10.
+	it( 'can be initialized', () => {
+		expect( () => {
+			viewDocument.createRoot( document.createElement( 'div' ) );
+		} ).to.not.throw();
+	} );
+
+	describe( 'delete event', () => {
+		it( 'is fired on keydown', () => {
+			const spy = sinon.spy();
+
+			viewDocument.on( 'delete', spy );
+
+			viewDocument.fire( 'keydown', new DomEventData( viewDocument, getDomEvent(), {
+				keyCode: getCode( 'delete' )
+			} ) );
+
+			expect( spy.calledOnce ).to.be.true;
+
+			const data = spy.args[ 0 ][ 1 ];
+			expect( data ).to.have.property( 'direction', 'forward' );
+			expect( data ).to.have.property( 'unit', 'character' );
+		} );
+
+		it( 'is fired with a proper direction and unit', () => {
+			const spy = sinon.spy();
+
+			viewDocument.on( 'delete', spy );
+
+			viewDocument.fire( 'keydown', new DomEventData( viewDocument, getDomEvent(), {
+				keyCode: getCode( 'backspace' ),
+				altKey: true
+			} ) );
+
+			expect( spy.calledOnce ).to.be.true;
+
+			const data = spy.args[ 0 ][ 1 ];
+			expect( data ).to.have.property( 'direction', 'backward' );
+			expect( data ).to.have.property( 'unit', 'word' );
+		} );
+
+		it( 'is not fired on keydown when keyCode does not match backspace or delete', () => {
+			const spy = sinon.spy();
+
+			viewDocument.on( 'delete', spy );
+
+			viewDocument.fire( 'keydown', new DomEventData( viewDocument, getDomEvent(), {
+				keyCode: 1
+			} ) );
+
+			expect( spy.calledOnce ).to.be.false;
+		} );
+	} );
+
+	function getDomEvent() {
+		return {
+			preventDefault: sinon.spy()
+		};
+	}
+} );

+ 285 - 0
packages/ckeditor5-typing/tests/input.js

@@ -0,0 +1,285 @@
+/*
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+import VirtualTestEditor from '/tests/ckeditor5/_utils/virtualtesteditor.js';
+import Input from '/ckeditor5/typing/input.js';
+import Paragraph from '/ckeditor5/paragraph/paragraph.js';
+
+import ModelRange from '/ckeditor5/engine/model/range.js';
+import BuildModelConverterFor from '/ckeditor5/engine/conversion/model-converter-builder.js';
+import BuildViewConverterFor from '/ckeditor5/engine/conversion/view-converter-builder.js';
+
+import ViewText from '/ckeditor5/engine/view/text.js';
+import ViewElement from '/ckeditor5/engine/view/element.js';
+
+import EmitterMixin from '/ckeditor5/utils/emittermixin.js';
+import { getCode } from '/ckeditor5/utils/keyboard.js';
+
+import { getData as getModelData } from '/tests/engine/_utils/model.js';
+import { getData as getViewData } from '/tests/engine/_utils/view.js';
+
+describe( 'Input feature', () => {
+	let editor, model, modelRoot, view, viewRoot, listenter;
+
+	before( () => {
+		listenter = Object.create( EmitterMixin );
+
+		return VirtualTestEditor.create( {
+				features: [ Input, Paragraph ]
+			} )
+			.then( newEditor => {
+				// Mock image feature.
+				newEditor.document.schema.registerItem( 'image', '$inline' );
+
+				BuildModelConverterFor( newEditor.data.modelToView, newEditor.editing.modelToView )
+					.fromElement( 'image' )
+					.toElement( 'img' );
+
+				BuildViewConverterFor( newEditor.data.viewToModel )
+					.fromElement( 'img' )
+					.toElement( 'image' );
+
+				editor = newEditor;
+				model = editor.editing.model;
+				modelRoot = model.getRoot();
+				view = editor.editing.view;
+				viewRoot = view.getRoot();
+			} );
+	} );
+
+	beforeEach( () => {
+		editor.setData( '<p>foobar</p>' );
+
+		model.enqueueChanges( () => {
+			model.selection.setRanges( [
+				ModelRange.createFromParentsAndOffsets( modelRoot.getChild( 0 ), 3, modelRoot.getChild( 0 ), 3 ) ] );
+		} );
+	} );
+
+	afterEach( () => {
+		listenter.stopListening();
+	} );
+
+	it( 'has a buffer configured to default value of config.typing.undoStep', () => {
+		expect( editor.plugins.get( Input )._buffer ).to.have.property( 'limit', 20 );
+	} );
+
+	it( 'has a buffer configured to config.typing.undoStep', () => {
+		return VirtualTestEditor.create( {
+				features: [ Input ],
+				typing: {
+					undoStep: 5
+				}
+			} )
+			.then( editor => {
+				expect( editor.plugins.get( Input )._buffer ).to.have.property( 'limit', 5 );
+			} );
+	} );
+
+	describe( 'mutations handling', () => {
+		it( 'should handle text mutation', () => {
+			view.fire( 'mutations', [
+				{
+					type: 'text',
+					oldText: 'foobar',
+					newText: 'fooxbar',
+					node: viewRoot.getChild( 0 ).getChild( 0 )
+				}
+			] );
+
+			expect( getModelData( model ) ).to.equal( '<paragraph>foox<selection />bar</paragraph>' );
+			expect( getViewData( view ) ).to.equal( '<p>foox{}bar</p>' );
+		} );
+
+		it( 'should handle text mutation change', () => {
+			view.fire( 'mutations', [
+				{
+					type: 'text',
+					oldText: 'foobar',
+					newText: 'foodar',
+					node: viewRoot.getChild( 0 ).getChild( 0 )
+				}
+			] );
+
+			expect( getModelData( model ) ).to.equal( '<paragraph>food<selection />ar</paragraph>' );
+			expect( getViewData( view ) ).to.equal( '<p>food{}ar</p>' );
+		} );
+
+		it( 'should handle text node insertion', () => {
+			editor.setData( '<p></p>' );
+
+			view.fire( 'mutations', [
+				{
+					type: 'children',
+					oldChildren: [],
+					newChildren: [ new ViewText( 'x' ) ],
+					node: viewRoot.getChild( 0 )
+				}
+			] );
+
+			expect( getModelData( model ) ).to.equal( '<paragraph>x<selection /></paragraph>' );
+			expect( getViewData( view ) ).to.equal( '<p>x{}</p>' );
+		} );
+
+		it( 'should do nothing when two nodes were inserted', () => {
+			editor.setData( '<p></p>' );
+
+			view.fire( 'mutations', [
+				{
+					type: 'children',
+					oldChildren: [],
+					newChildren: [ new ViewText( 'x' ), new ViewElement( 'img' ) ],
+					node: viewRoot.getChild( 0 )
+				}
+			] );
+
+			expect( getModelData( model ) ).to.equal( '<paragraph></paragraph>' );
+			expect( getViewData( view ) ).to.equal( '<p></p>' );
+		} );
+
+		it( 'should do nothing when two nodes were inserted and one removed', () => {
+			view.fire( 'mutations', [
+				{
+					type: 'children',
+					oldChildren: [ new ViewText( 'foobar' ) ],
+					newChildren: [ new ViewText( 'x' ), new ViewElement( 'img' ) ],
+					node: viewRoot.getChild( 0 )
+				}
+			] );
+
+			expect( getModelData( model ) ).to.equal( '<paragraph>foo<selection />bar</paragraph>' );
+			expect( getViewData( view ) ).to.equal( '<p>foo{}bar</p>' );
+		} );
+
+		it( 'should handle multiple children in the node', () => {
+			editor.setData( '<p>foo<img></img></p>' );
+
+			view.fire( 'mutations', [
+				{
+					type: 'children',
+					oldChildren: [ new ViewText( 'foo' ), viewRoot.getChild( 0 ).getChild( 1 ) ],
+					newChildren: [ new ViewText( 'foo' ), viewRoot.getChild( 0 ).getChild( 1 ), new ViewText( 'x' ) ],
+					node: viewRoot.getChild( 0 )
+				}
+			] );
+
+			expect( getModelData( model ) ).to.equal( '<paragraph>foo<image></image>x<selection /></paragraph>' );
+			expect( getViewData( view ) ).to.equal( '<p>foo<img></img>x{}</p>' );
+		} );
+
+		it( 'should do nothing when node was removed', () => {
+			view.fire( 'mutations', [
+				{
+					type: 'children',
+					oldChildren: [ new ViewText( 'foobar' ) ],
+					newChildren: [],
+					node: viewRoot.getChild( 0 )
+				}
+			] );
+
+			expect( getModelData( model ) ).to.equal( '<paragraph>foo<selection />bar</paragraph>' );
+			expect( getViewData( view ) ).to.equal( '<p>foo{}bar</p>' );
+		} );
+
+		it( 'should do nothing when element was inserted', () => {
+			editor.setData( '<p></p>' );
+
+			view.fire( 'mutations', [
+				{
+					type: 'children',
+					oldChildren: [],
+					newChildren: [ new ViewElement( 'img' ) ],
+					node: viewRoot.getChild( 0 )
+				}
+			] );
+
+			expect( getModelData( model ) ).to.equal( '<paragraph></paragraph>' );
+			expect( getViewData( view ) ).to.equal( '<p></p>' );
+		} );
+	} );
+
+	describe( 'keystroke handling', () => {
+		it( 'should remove contents', () => {
+			model.enqueueChanges( () => {
+				model.selection.setRanges( [
+					ModelRange.createFromParentsAndOffsets( modelRoot.getChild( 0 ), 2, modelRoot.getChild( 0 ), 4 ) ] );
+			} );
+
+			listenter.listenTo( view, 'keydown', () => {
+				expect( getModelData( model ) ).to.equal( '<paragraph>fo<selection />ar</paragraph>' );
+
+				view.fire( 'mutations', [
+					{
+						type: 'text',
+						oldText: 'foar',
+						newText: 'foyar',
+						node: viewRoot.getChild( 0 ).getChild( 0 )
+					}
+				] );
+			}, null, 1000000 );
+
+			view.fire( 'keydown', { keyCode: getCode( 'y' ) } );
+
+			expect( getModelData( model ) ).to.equal( '<paragraph>foy<selection />ar</paragraph>' );
+			expect( getViewData( view ) ).to.equal( '<p>foy{}ar</p>' );
+		} );
+
+		it( 'should do nothing on arrow key', () => {
+			model.enqueueChanges( () => {
+				model.selection.setRanges( [
+					ModelRange.createFromParentsAndOffsets( modelRoot.getChild( 0 ), 2, modelRoot.getChild( 0 ), 4 ) ] );
+			} );
+
+			view.fire( 'keydown', { keyCode: getCode( 'arrowright' ) } );
+
+			expect( getModelData( model ) ).to.equal( '<paragraph>fo<selection>ob</selection>ar</paragraph>' );
+		} );
+
+		it( 'should do nothing on ctrl combinations', () => {
+			model.enqueueChanges( () => {
+				model.selection.setRanges( [
+					ModelRange.createFromParentsAndOffsets( modelRoot.getChild( 0 ), 2, modelRoot.getChild( 0 ), 4 ) ] );
+			} );
+
+			view.fire( 'keydown', { ctrlKey: true, keyCode: getCode( 'c' ) } );
+
+			expect( getModelData( model ) ).to.equal( '<paragraph>fo<selection>ob</selection>ar</paragraph>' );
+		} );
+
+		it( 'should do nothing on non printable keys', () => {
+			model.enqueueChanges( () => {
+				model.selection.setRanges( [
+					ModelRange.createFromParentsAndOffsets( modelRoot.getChild( 0 ), 2, modelRoot.getChild( 0 ), 4 ) ] );
+			} );
+
+			view.fire( 'keydown', { keyCode: 16 } ); // Shift
+			view.fire( 'keydown', { keyCode: 35 } ); // Home
+			view.fire( 'keydown', { keyCode: 112 } ); // F1
+
+			expect( getModelData( model ) ).to.equal( '<paragraph>fo<selection>ob</selection>ar</paragraph>' );
+		} );
+
+		it( 'should do nothing if selection is collapsed', () => {
+			view.fire( 'keydown', { ctrlKey: true, keyCode: getCode( 'c' ) } );
+
+			expect( getModelData( model ) ).to.equal( '<paragraph>foo<selection />bar</paragraph>' );
+		} );
+	} );
+
+	describe( 'destroy', () => {
+		it( 'should destroy change buffer', () => {
+			const typing = new Input( new VirtualTestEditor() );
+			typing.init();
+
+			const destroy = typing._buffer.destroy = sinon.spy();
+
+			typing.destroy();
+
+			expect( destroy.calledOnce ).to.be.true;
+			expect( typing._buffer ).to.be.null;
+		} );
+	} );
+} );
+

+ 7 - 275
packages/ckeditor5-typing/tests/typing.js

@@ -1,285 +1,17 @@
-/*
+/**
  * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
  * For licensing, see LICENSE.md.
  */
 
-import VirtualTestEditor from '/tests/ckeditor5/_utils/virtualtesteditor.js';
 import Typing from '/ckeditor5/typing/typing.js';
-import Paragraph from '/ckeditor5/paragraph/paragraph.js';
-
-import ModelRange from '/ckeditor5/engine/model/range.js';
-import BuildModelConverterFor from '/ckeditor5/engine/conversion/model-converter-builder.js';
-import BuildViewConverterFor from '/ckeditor5/engine/conversion/view-converter-builder.js';
-
-import ViewText from '/ckeditor5/engine/view/text.js';
-import ViewElement from '/ckeditor5/engine/view/element.js';
-
-import EmitterMixin from '/ckeditor5/utils/emittermixin.js';
-import { getCode } from '/ckeditor5/utils/keyboard.js';
-
-import { getData as getModelData } from '/tests/engine/_utils/model.js';
-import { getData as getViewData } from '/tests/engine/_utils/view.js';
+import Input from '/ckeditor5/typing/input.js';
+import Delete from '/ckeditor5/typing/delete.js';
 
 describe( 'Typing feature', () => {
-	let editor, model, modelRoot, view, viewRoot, listenter;
-
-	before( () => {
-		listenter = Object.create( EmitterMixin );
-
-		return VirtualTestEditor.create( {
-				features: [ Typing, Paragraph ]
-			} )
-			.then( newEditor => {
-				// Mock image feature.
-				newEditor.document.schema.registerItem( 'image', '$inline' );
-
-				BuildModelConverterFor( newEditor.data.modelToView, newEditor.editing.modelToView )
-					.fromElement( 'image' )
-					.toElement( 'img' );
-
-				BuildViewConverterFor( newEditor.data.viewToModel )
-					.fromElement( 'img' )
-					.toElement( 'image' );
+	it( 'requires Input and Delete features', () => {
+		const typingRequirements = Typing.requires;
 
-				editor = newEditor;
-				model = editor.editing.model;
-				modelRoot = model.getRoot();
-				view = editor.editing.view;
-				viewRoot = view.getRoot();
-			} );
-	} );
-
-	beforeEach( () => {
-		editor.setData( '<p>foobar</p>' );
-
-		model.enqueueChanges( () => {
-			model.selection.setRanges( [
-				ModelRange.createFromParentsAndOffsets( modelRoot.getChild( 0 ), 3, modelRoot.getChild( 0 ), 3 ) ] );
-		} );
-	} );
-
-	afterEach( () => {
-		listenter.stopListening();
-	} );
-
-	it( 'has a buffer configured to default value of config.typing.undoStep', () => {
-		expect( editor.plugins.get( Typing )._buffer ).to.have.property( 'limit', 20 );
-	} );
-
-	it( 'has a buffer configured to config.typing.undoStep', () => {
-		return VirtualTestEditor.create( {
-				features: [ Typing ],
-				typing: {
-					undoStep: 5
-				}
-			} )
-			.then( editor => {
-				expect( editor.plugins.get( Typing )._buffer ).to.have.property( 'limit', 5 );
-			} );
-	} );
-
-	describe( 'mutations handling', () => {
-		it( 'should handle text mutation', () => {
-			view.fire( 'mutations', [
-				{
-					type: 'text',
-					oldText: 'foobar',
-					newText: 'fooxbar',
-					node: viewRoot.getChild( 0 ).getChild( 0 )
-				}
-			] );
-
-			expect( getModelData( model ) ).to.equal( '<paragraph>foox<selection />bar</paragraph>' );
-			expect( getViewData( view ) ).to.equal( '<p>foox{}bar</p>' );
-		} );
-
-		it( 'should handle text mutation change', () => {
-			view.fire( 'mutations', [
-				{
-					type: 'text',
-					oldText: 'foobar',
-					newText: 'foodar',
-					node: viewRoot.getChild( 0 ).getChild( 0 )
-				}
-			] );
-
-			expect( getModelData( model ) ).to.equal( '<paragraph>food<selection />ar</paragraph>' );
-			expect( getViewData( view ) ).to.equal( '<p>food{}ar</p>' );
-		} );
-
-		it( 'should handle text node insertion', () => {
-			editor.setData( '<p></p>' );
-
-			view.fire( 'mutations', [
-				{
-					type: 'children',
-					oldChildren: [],
-					newChildren: [ new ViewText( 'x' ) ],
-					node: viewRoot.getChild( 0 )
-				}
-			] );
-
-			expect( getModelData( model ) ).to.equal( '<paragraph>x<selection /></paragraph>' );
-			expect( getViewData( view ) ).to.equal( '<p>x{}</p>' );
-		} );
-
-		it( 'should do nothing when two nodes were inserted', () => {
-			editor.setData( '<p></p>' );
-
-			view.fire( 'mutations', [
-				{
-					type: 'children',
-					oldChildren: [],
-					newChildren: [ new ViewText( 'x' ), new ViewElement( 'img' ) ],
-					node: viewRoot.getChild( 0 )
-				}
-			] );
-
-			expect( getModelData( model ) ).to.equal( '<paragraph></paragraph>' );
-			expect( getViewData( view ) ).to.equal( '<p></p>' );
-		} );
-
-		it( 'should do nothing when two nodes were inserted and one removed', () => {
-			view.fire( 'mutations', [
-				{
-					type: 'children',
-					oldChildren: [ new ViewText( 'foobar' ) ],
-					newChildren: [ new ViewText( 'x' ), new ViewElement( 'img' ) ],
-					node: viewRoot.getChild( 0 )
-				}
-			] );
-
-			expect( getModelData( model ) ).to.equal( '<paragraph>foo<selection />bar</paragraph>' );
-			expect( getViewData( view ) ).to.equal( '<p>foo{}bar</p>' );
-		} );
-
-		it( 'should handle multiple children in the node', () => {
-			editor.setData( '<p>foo<img></img></p>' );
-
-			view.fire( 'mutations', [
-				{
-					type: 'children',
-					oldChildren: [ new ViewText( 'foo' ), viewRoot.getChild( 0 ).getChild( 1 ) ],
-					newChildren: [ new ViewText( 'foo' ), viewRoot.getChild( 0 ).getChild( 1 ), new ViewText( 'x' ) ],
-					node: viewRoot.getChild( 0 )
-				}
-			] );
-
-			expect( getModelData( model ) ).to.equal( '<paragraph>foo<image></image>x<selection /></paragraph>' );
-			expect( getViewData( view ) ).to.equal( '<p>foo<img></img>x{}</p>' );
-		} );
-
-		it( 'should do nothing when node was removed', () => {
-			view.fire( 'mutations', [
-				{
-					type: 'children',
-					oldChildren: [ new ViewText( 'foobar' ) ],
-					newChildren: [],
-					node: viewRoot.getChild( 0 )
-				}
-			] );
-
-			expect( getModelData( model ) ).to.equal( '<paragraph>foo<selection />bar</paragraph>' );
-			expect( getViewData( view ) ).to.equal( '<p>foo{}bar</p>' );
-		} );
-
-		it( 'should do nothing when element was inserted', () => {
-			editor.setData( '<p></p>' );
-
-			view.fire( 'mutations', [
-				{
-					type: 'children',
-					oldChildren: [],
-					newChildren: [ new ViewElement( 'img' ) ],
-					node: viewRoot.getChild( 0 )
-				}
-			] );
-
-			expect( getModelData( model ) ).to.equal( '<paragraph></paragraph>' );
-			expect( getViewData( view ) ).to.equal( '<p></p>' );
-		} );
-	} );
-
-	describe( 'keystroke handling', () => {
-		it( 'should remove contents', () => {
-			model.enqueueChanges( () => {
-				model.selection.setRanges( [
-					ModelRange.createFromParentsAndOffsets( modelRoot.getChild( 0 ), 2, modelRoot.getChild( 0 ), 4 ) ] );
-			} );
-
-			listenter.listenTo( view, 'keydown', () => {
-				expect( getModelData( model ) ).to.equal( '<paragraph>fo<selection />ar</paragraph>' );
-
-				view.fire( 'mutations', [
-					{
-						type: 'text',
-						oldText: 'foar',
-						newText: 'foyar',
-						node: viewRoot.getChild( 0 ).getChild( 0 )
-					}
-				] );
-			}, null, 1000000 );
-
-			view.fire( 'keydown', { keyCode: getCode( 'y' ) } );
-
-			expect( getModelData( model ) ).to.equal( '<paragraph>foy<selection />ar</paragraph>' );
-			expect( getViewData( view ) ).to.equal( '<p>foy{}ar</p>' );
-		} );
-
-		it( 'should do nothing on arrow key', () => {
-			model.enqueueChanges( () => {
-				model.selection.setRanges( [
-					ModelRange.createFromParentsAndOffsets( modelRoot.getChild( 0 ), 2, modelRoot.getChild( 0 ), 4 ) ] );
-			} );
-
-			view.fire( 'keydown', { keyCode: getCode( 'arrowright' ) } );
-
-			expect( getModelData( model ) ).to.equal( '<paragraph>fo<selection>ob</selection>ar</paragraph>' );
-		} );
-
-		it( 'should do nothing on ctrl combinations', () => {
-			model.enqueueChanges( () => {
-				model.selection.setRanges( [
-					ModelRange.createFromParentsAndOffsets( modelRoot.getChild( 0 ), 2, modelRoot.getChild( 0 ), 4 ) ] );
-			} );
-
-			view.fire( 'keydown', { ctrlKey: true, keyCode: getCode( 'c' ) } );
-
-			expect( getModelData( model ) ).to.equal( '<paragraph>fo<selection>ob</selection>ar</paragraph>' );
-		} );
-
-		it( 'should do nothing on non printable keys', () => {
-			model.enqueueChanges( () => {
-				model.selection.setRanges( [
-					ModelRange.createFromParentsAndOffsets( modelRoot.getChild( 0 ), 2, modelRoot.getChild( 0 ), 4 ) ] );
-			} );
-
-			view.fire( 'keydown', { keyCode: 16 } ); // Shift
-			view.fire( 'keydown', { keyCode: 35 } ); // Home
-			view.fire( 'keydown', { keyCode: 112 } ); // F1
-
-			expect( getModelData( model ) ).to.equal( '<paragraph>fo<selection>ob</selection>ar</paragraph>' );
-		} );
-
-		it( 'should do nothing if selection is collapsed', () => {
-			view.fire( 'keydown', { ctrlKey: true, keyCode: getCode( 'c' ) } );
-
-			expect( getModelData( model ) ).to.equal( '<paragraph>foo<selection />bar</paragraph>' );
-		} );
-	} );
-
-	describe( 'destroy', () => {
-		it( 'should destroy change buffer', () => {
-			const typing = new Typing( new VirtualTestEditor() );
-			typing.init();
-
-			const destroy = typing._buffer.destroy = sinon.spy();
-
-			typing.destroy();
-
-			expect( destroy.calledOnce ).to.be.true;
-			expect( typing._buffer ).to.be.null;
-		} );
+		expect( typingRequirements ).to.contain( Input );
+		expect( typingRequirements ).to.contain( Delete );
 	} );
 } );
-