Sfoglia il codice sorgente

Merge pull request #158 from ckeditor/t/83

Fix: Remove selection contents on `keydown` before the composition starts. Closes #83. Closes #150.

BREAKING CHANGE: `@ckeditor/ckeditor5-typing/src/changebuffer.js` was moved to `@ckeditor/ckeditor5-typing/src/utils/changebuffer.js';
Piotrek Koszuliński 7 anni fa
parent
commit
a95042492d

+ 1 - 1
packages/ckeditor5-typing/src/deletecommand.js

@@ -11,7 +11,7 @@ import Command from '@ckeditor/ckeditor5-core/src/command';
 import Selection from '@ckeditor/ckeditor5-engine/src/model/selection';
 import Element from '@ckeditor/ckeditor5-engine/src/model/element';
 import Range from '@ckeditor/ckeditor5-engine/src/model/range';
-import ChangeBuffer from './changebuffer';
+import ChangeBuffer from './utils/changebuffer';
 import count from '@ckeditor/ckeditor5-utils/src/count';
 
 /**

+ 6 - 471
packages/ckeditor5-typing/src/input.js

@@ -8,15 +8,11 @@
  */
 
 import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
-import ModelRange from '@ckeditor/ckeditor5-engine/src/model/range';
-import ViewPosition from '@ckeditor/ckeditor5-engine/src/view/position';
-import ViewText from '@ckeditor/ckeditor5-engine/src/view/text';
-import diff from '@ckeditor/ckeditor5-utils/src/diff';
-import diffToChanges from '@ckeditor/ckeditor5-utils/src/difftochanges';
-import { getCode } from '@ckeditor/ckeditor5-utils/src/keyboard';
-import DomConverter from '@ckeditor/ckeditor5-engine/src/view/domconverter';
 import InputCommand from './inputcommand';
 
+import injectUnsafeKeystrokesHandling from './utils/injectunsafekeystrokeshandling';
+import injectTypingMutationsHandling from './utils/injecttypingmutationshandling';
+
 /**
  * Handles text input coming from the keyboard or other input methods.
  *
@@ -35,474 +31,13 @@ export default class Input extends Plugin {
 	 */
 	init() {
 		const editor = this.editor;
-		const editingView = editor.editing.view;
-		const inputCommand = new InputCommand( editor, editor.config.get( 'typing.undoStep' ) || 20 );
 
 		// TODO The above default configuration value should be defined using editor.config.define() once it's fixed.
+		const inputCommand = new InputCommand( editor, editor.config.get( 'typing.undoStep' ) || 20 );
 
 		editor.commands.add( 'input', inputCommand );
 
-		this.listenTo( editingView.document, 'keydown', ( evt, data ) => {
-			this._handleKeydown( data, inputCommand );
-		}, { priority: 'lowest' } );
-
-		this.listenTo( editingView.document, 'mutations', ( evt, mutations, viewSelection ) => {
-			this._handleMutations( mutations, viewSelection );
-		} );
-	}
-
-	/**
-	 * Handles the keydown event. We need to guess whether such keystroke is going to result
-	 * in typing. If so, then before character insertion happens, any selected content needs
-	 * to be deleted. Otherwise the default browser deletion mechanism would be
-	 * triggered, resulting in:
-	 *
-	 * * Hundreds of mutations which could not be handled.
-	 * * But most importantly, loss of control over how the content is being deleted.
-	 *
-	 * The method is used in a low-priority listener, hence allowing other listeners (e.g. delete or enter features)
-	 * to handle the event.
-	 *
-	 * @private
-	 * @param {module:engine/view/observer/keyobserver~KeyEventData} evtData
-	 * @param {module:typing/inputcommand~InputCommand} inputCommand
-	 */
-	_handleKeydown( evtData, inputCommand ) {
-		const model = this.editor.model;
-		const doc = model.document;
-		const buffer = inputCommand.buffer;
-
-		// By relying on the state of the input command we allow disabling the entire input easily
-		// by just disabling the input command. We could’ve used here the delete command but that
-		// would mean requiring the delete feature which would block loading one without the other.
-		// We could also check the editor.isReadOnly property, but that wouldn't allow to block
-		// the input without blocking other features.
-		if ( !inputCommand.isEnabled ) {
-			return;
-		}
-
-		if ( isSafeKeystroke( evtData ) || doc.selection.isCollapsed ) {
-			return;
-		}
-
-		buffer.lock();
-
-		model.enqueueChange( buffer.batch, () => {
-			this.editor.model.deleteContent( doc.selection );
-		} );
-
-		buffer.unlock();
-	}
-
-	/**
-	 * Handles DOM mutations.
-	 *
-	 * @private
-	 * @param {Array.<module:engine/view/observer/mutationobserver~MutatedText|
-	 * module:engine/view/observer/mutationobserver~MutatedChildren>} mutations
-	 * @param {module:engine/view/selection~Selection|null} viewSelection
-	 */
-	_handleMutations( mutations, viewSelection ) {
-		new MutationHandler( this.editor ).handle( mutations, viewSelection );
-	}
-}
-
-/**
- * Helper class for translating DOM mutations into model changes.
- *
- * @private
- */
-class MutationHandler {
-	/**
-	 * Creates an instance of the mutation handler.
-	 *
-	 * @param {module:core/editor/editor~Editor} editor
-	 */
-	constructor( editor ) {
-		/**
-		 * Editor instance for which mutations are handled.
-		 *
-		 * @readonly
-		 * @member {module:core/editor/editor~Editor} #editor
-		 */
-		this.editor = editor;
-
-		/**
-		 * The editing controller.
-		 *
-		 * @readonly
-		 * @member {module:engine/controller/editingcontroller~EditingController} #editing
-		 */
-		this.editing = this.editor.editing;
-	}
-
-	/**
-	 * Handles given mutations.
-	 *
-	 * @param {Array.<module:engine/view/observer/mutationobserver~MutatedText|
-	 * module:engine/view/observer/mutationobserver~MutatedChildren>} mutations
-	 * @param {module:engine/view/selection~Selection|null} viewSelection
-	 */
-	handle( mutations, viewSelection ) {
-		if ( containerChildrenMutated( mutations ) ) {
-			this._handleContainerChildrenMutations( mutations, viewSelection );
-		} else {
-			for ( const mutation of mutations ) {
-				// Fortunately it will never be both.
-				this._handleTextMutation( mutation, viewSelection );
-				this._handleTextNodeInsertion( mutation );
-			}
-		}
-	}
-
-	/**
-	 * Handles situations when container's children mutated during input. This can happen when
-	 * the browser is trying to "fix" DOM in certain situations. For example, when the user starts to type
-	 * in `<p><a href=""><i>Link{}</i></a></p>`, the browser might change the order of elements
-	 * to `<p><i><a href="">Link</a>x{}</i></p>`. A similar situation happens when the spell checker
-	 * replaces a word wrapped with `<strong>` with a word wrapped with a `<b>` element.
-	 *
-	 * To handle such situations, the common DOM ancestor of all mutations is converted to the model representation
-	 * and then compared with the current model to calculate the proper text change.
-	 *
-	 * Note: Single text node insertion is handled in {@link #_handleTextNodeInsertion} and text node mutation is handled
-	 * in {@link #_handleTextMutation}).
-	 *
-	 * @private
-	 * @param {Array.<module:engine/view/observer/mutationobserver~MutatedText|
-	 * module:engine/view/observer/mutationobserver~MutatedChildren>} mutations
-	 * @param {module:engine/view/selection~Selection|null} viewSelection
-	 */
-	_handleContainerChildrenMutations( mutations, viewSelection ) {
-		// Get common ancestor of all mutations.
-		const mutationsCommonAncestor = getMutationsContainer( mutations );
-
-		// Quit if there is no common ancestor.
-		if ( !mutationsCommonAncestor ) {
-			return;
-		}
-
-		const domConverter = this.editor.editing.view.domConverter;
-
-		// Get common ancestor in DOM.
-		const domMutationCommonAncestor = domConverter.mapViewToDom( mutationsCommonAncestor );
-
-		// Create fresh DomConverter so it will not use existing mapping and convert current DOM to model.
-		// This wouldn't be needed if DomConverter would allow to create fresh view without checking any mappings.
-		const freshDomConverter = new DomConverter();
-		const modelFromCurrentDom = this.editor.data.toModel(
-			freshDomConverter.domToView( domMutationCommonAncestor )
-		).getChild( 0 );
-
-		// Current model.
-		const currentModel = this.editor.editing.mapper.toModelElement( mutationsCommonAncestor );
-
-		// If common ancestor is not mapped, do not do anything. It probably is a parent of another view element.
-		// That means that we would need to diff model elements (see `if` below). Better return early instead of
-		// trying to get a reasonable model ancestor. It will fell into the `if` below anyway.
-		// This situation happens for example for lists. If `<ul>` is a common ancestor, `currentModel` is `undefined`
-		// because `<ul>` is not mapped (`<li>`s are).
-		// See https://github.com/ckeditor/ckeditor5/issues/718.
-		if ( !currentModel ) {
-			return;
-		}
-
-		// Get children from both ancestors.
-		const modelFromDomChildren = Array.from( modelFromCurrentDom.getChildren() );
-		const currentModelChildren = Array.from( currentModel.getChildren() );
-
-		// Remove the last `<softBreak>` from the end of `modelFromDomChildren` if there is no `<softBreak>` in current model.
-		// If the described scenario happened, it means that this is a bogus `<br />` added by a browser.
-		const lastDomChild = modelFromDomChildren[ modelFromDomChildren.length - 1 ];
-		const lastCurrentChild = currentModelChildren[ currentModelChildren.length - 1 ];
-
-		if ( lastDomChild && lastDomChild.is( 'softBreak' ) && lastCurrentChild && !lastCurrentChild.is( 'softBreak' ) ) {
-			modelFromDomChildren.pop();
-		}
-
-		// Skip situations when common ancestor has any container elements.
-		if ( !isSafeForTextMutation( modelFromDomChildren ) || !isSafeForTextMutation( currentModelChildren ) ) {
-			return;
-		}
-
-		// Replace &nbsp; inserted by the browser with normal space. See comment in `_handleTextMutation`.
-		// Replace non-texts with any character. This is potentially dangerous but passes in manual tests. The thing is
-		// that we need to take care of proper indexes so we cannot simply remove non-text elements from the content.
-		// By inserting a character we keep all the real texts on their indexes.
-		const newText = modelFromDomChildren.map( item => item.is( 'text' ) ? item.data : '@' ).join( '' ).replace( /\u00A0/g, ' ' );
-		const oldText = currentModelChildren.map( item => item.is( 'text' ) ? item.data : '@' ).join( '' );
-
-		// Do nothing if mutations created same text.
-		if ( oldText === newText ) {
-			return;
-		}
-
-		const diffResult = diff( oldText, newText );
-
-		const { firstChangeAt, insertions, deletions } = calculateChanges( diffResult );
-
-		// Try setting new model selection according to passed view selection.
-		let modelSelectionRange = null;
-
-		if ( viewSelection ) {
-			modelSelectionRange = this.editing.mapper.toModelRange( viewSelection.getFirstRange() );
-		}
-
-		const insertText = newText.substr( firstChangeAt, insertions );
-		const removeRange = ModelRange.createFromParentsAndOffsets(
-			currentModel,
-			firstChangeAt,
-			currentModel,
-			firstChangeAt + deletions
-		);
-
-		this.editor.execute( 'input', {
-			text: insertText,
-			range: removeRange,
-			resultRange: modelSelectionRange
-		} );
-	}
-
-	/**
-	 * @private
-	 */
-	_handleTextMutation( mutation, viewSelection ) {
-		if ( mutation.type != 'text' ) {
-			return;
-		}
-
-		// Replace &nbsp; inserted by the browser with normal space.
-		// We want only normal spaces in the model and in the view. Renderer and DOM Converter will be then responsible
-		// for rendering consecutive spaces using &nbsp;, but the model and the view has to be clear.
-		// Other feature may introduce inserting non-breakable space on specific key stroke (for example shift + space).
-		// However then it will be handled outside of mutations, like enter key is.
-		// The replacing is here because it has to be done before `diff` and `diffToChanges` functions, as they
-		// take `newText` and compare it to (cleaned up) view.
-		// It could also be done in mutation observer too, however if any outside plugin would like to
-		// introduce additional events for mutations, they would get already cleaned up version (this may be good or not).
-		const newText = mutation.newText.replace( /\u00A0/g, ' ' );
-		// To have correct `diffResult`, we also compare view node text data with &nbsp; replaced by space.
-		const oldText = mutation.oldText.replace( /\u00A0/g, ' ' );
-
-		const diffResult = diff( oldText, newText );
-
-		const { firstChangeAt, insertions, deletions } = calculateChanges( diffResult );
-
-		// Try setting new model selection according to passed view selection.
-		let modelSelectionRange = null;
-
-		if ( viewSelection ) {
-			modelSelectionRange = this.editing.mapper.toModelRange( viewSelection.getFirstRange() );
-		}
-
-		// Get the position in view and model where the changes will happen.
-		const viewPos = new ViewPosition( mutation.node, firstChangeAt );
-		const modelPos = this.editing.mapper.toModelPosition( viewPos );
-		const removeRange = ModelRange.createFromPositionAndShift( modelPos, deletions );
-		const insertText = newText.substr( firstChangeAt, insertions );
-
-		this.editor.execute( 'input', {
-			text: insertText,
-			range: removeRange,
-			resultRange: modelSelectionRange
-		} );
-	}
-
-	/**
-	 * @private
-	 */
-	_handleTextNodeInsertion( mutation ) {
-		if ( mutation.type != 'children' ) {
-			return;
-		}
-
-		const change = getSingleTextNodeChange( mutation );
-		const viewPos = new ViewPosition( mutation.node, change.index );
-		const modelPos = this.editing.mapper.toModelPosition( viewPos );
-		const insertedText = change.values[ 0 ].data;
-
-		this.editor.execute( 'input', {
-			// Replace &nbsp; inserted by the browser with normal space.
-			// See comment in `_handleTextMutation`.
-			// In this case we don't need to do this before `diff` because we diff whole nodes.
-			// Just change &nbsp; in case there are some.
-			text: insertedText.replace( /\u00A0/g, ' ' ),
-			range: new ModelRange( modelPos )
-		} );
-	}
-}
-
-const safeKeycodes = [
-	getCode( 'arrowUp' ),
-	getCode( 'arrowRight' ),
-	getCode( 'arrowDown' ),
-	getCode( 'arrowLeft' ),
-	9, // Tab
-	16, // Shift
-	17, // Ctrl
-	18, // Alt
-	20, // CapsLock
-	27, // Escape
-	33, // PageUp
-	34, // PageDown
-	35, // Home
-	36, // End
-	229 // Composition start key
-];
-
-// 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.
-//
-// @private
-// @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 );
-}
-
-// Helper function that compares whether two given view nodes are same. It is used in `diff` when it's passed an array
-// with child nodes.
-function compareChildNodes( oldChild, newChild ) {
-	if ( oldChild instanceof ViewText && newChild instanceof ViewText ) {
-		return oldChild.data === newChild.data;
-	} else {
-		return oldChild === newChild;
-	}
-}
-
-// Returns change made to a single text node. Returns `undefined` if more than a single text node was changed.
-//
-// @private
-// @param mutation
-function getSingleTextNodeChange( mutation ) {
-	// One new node.
-	if ( mutation.newChildren.length - mutation.oldChildren.length != 1 ) {
-		return;
-	}
-
-	// Which is text.
-	const diffResult = diff( mutation.oldChildren, mutation.newChildren, compareChildNodes );
-	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 ];
-
-	// Which is text.
-	if ( !( change.values[ 0 ] instanceof ViewText ) ) {
-		return;
-	}
-
-	return change;
-}
-
-// Returns first common ancestor of all mutations that is either {@link module:engine/view/containerelement~ContainerElement}
-// or {@link module:engine/view/rootelement~RootElement}.
-//
-// @private
-// @param {Array.<module:engine/view/observer/mutationobserver~MutatedText|
-// module:engine/view/observer/mutationobserver~MutatedChildren>} mutations
-// @returns {module:engine/view/containerelement~ContainerElement|engine/view/rootelement~RootElement|undefined}
-function getMutationsContainer( mutations ) {
-	const lca = mutations
-		.map( mutation => mutation.node )
-		.reduce( ( commonAncestor, node ) => {
-			return commonAncestor.getCommonAncestor( node, { includeSelf: true } );
-		} );
-
-	if ( !lca ) {
-		return;
-	}
-
-	// We need to look for container and root elements only, so check all LCA's
-	// ancestors (starting from itself).
-	return lca.getAncestors( { includeSelf: true, parentFirst: true } )
-		.find( element => element.is( 'containerElement' ) || element.is( 'rootElement' ) );
-}
-
-// Returns true if container children have mutated or more than a single text node was changed.
-//
-// Single text node child insertion is handled in {@link module:typing/input~MutationHandler#_handleTextNodeInsertion}
-// while text mutation is handled in {@link module:typing/input~MutationHandler#_handleTextMutation}.
-//
-// @private
-// @param {Array.<module:engine/view/observer/mutationobserver~MutatedText|
-// module:engine/view/observer/mutationobserver~MutatedChildren>} mutations
-// @returns {Boolean}
-function containerChildrenMutated( mutations ) {
-	if ( mutations.length == 0 ) {
-		return false;
-	}
-
-	// Check if there is any mutation of `children` type or any mutation that changes more than one text node.
-	for ( const mutation of mutations ) {
-		if ( mutation.type === 'children' && !getSingleTextNodeChange( mutation ) ) {
-			return true;
-		}
-	}
-
-	return false;
-}
-
-// Returns true if provided array contains content that won't be problematic during diffing and text mutation handling.
-//
-// @param {Array.<module:engine/model/node~Node>} children
-// @returns {Boolean}
-function isSafeForTextMutation( children ) {
-	return children.every( child => child.is( 'text' ) || child.is( 'softBreak' ) );
-}
-
-// Calculates first change index and number of characters that should be inserted and deleted starting from that index.
-//
-// @private
-// @param diffResult
-// @returns {{insertions: number, deletions: number, firstChangeAt: *}}
-function calculateChanges( diffResult ) {
-	// Index where the first change happens. Used to set the position from which nodes will be removed and where will be inserted.
-	let firstChangeAt = null;
-	// Index where the last change happens. Used to properly count how many characters have to be removed and inserted.
-	let lastChangeAt = null;
-
-	// Get `firstChangeAt` and `lastChangeAt`.
-	for ( let i = 0; i < diffResult.length; i++ ) {
-		const change = diffResult[ i ];
-
-		if ( change != 'equal' ) {
-			firstChangeAt = firstChangeAt === null ? i : firstChangeAt;
-			lastChangeAt = i;
-		}
+		injectUnsafeKeystrokesHandling( editor );
+		injectTypingMutationsHandling( editor );
 	}
-
-	// How many characters, starting from `firstChangeAt`, should be removed.
-	let deletions = 0;
-	// How many characters, starting from `firstChangeAt`, should be inserted.
-	let insertions = 0;
-
-	for ( let i = firstChangeAt; i <= lastChangeAt; i++ ) {
-		// If there is no change (equal) or delete, the character is existing in `oldText`. We count it for removing.
-		if ( diffResult[ i ] != 'insert' ) {
-			deletions++;
-		}
-
-		// If there is no change (equal) or insert, the character is existing in `newText`. We count it for inserting.
-		if ( diffResult[ i ] != 'delete' ) {
-			insertions++;
-		}
-	}
-
-	return { insertions, deletions, firstChangeAt };
 }

+ 3 - 3
packages/ckeditor5-typing/src/inputcommand.js

@@ -8,7 +8,7 @@
  */
 
 import Command from '@ckeditor/ckeditor5-core/src/command';
-import ChangeBuffer from './changebuffer';
+import ChangeBuffer from './utils/changebuffer';
 
 /**
  * The input command. Used by the {@link module:typing/input~Input input feature} to handle typing.
@@ -31,7 +31,7 @@ export default class InputCommand extends Command {
 		 *
 		 * @readonly
 		 * @private
-		 * @member {module:typing/changebuffer~ChangeBuffer} #_buffer
+		 * @member {module:typing/utils/changebuffer~ChangeBuffer} #_buffer
 		 */
 		this._buffer = new ChangeBuffer( editor.model, undoStepSize );
 	}
@@ -39,7 +39,7 @@ export default class InputCommand extends Command {
 	/**
 	 * The current change buffer.
 	 *
-	 * @type {module:typing/changebuffer~ChangeBuffer}
+	 * @type {module:typing/utils/changebuffer~ChangeBuffer}
 	 */
 	get buffer() {
 		return this._buffer;

+ 1 - 1
packages/ckeditor5-typing/src/changebuffer.js

@@ -4,7 +4,7 @@
  */
 
 /**
- * @module typing/changebuffer
+ * @module typing/utils/changebuffer
  */
 
 import Batch from '@ckeditor/ckeditor5-engine/src/model/batch';

+ 387 - 0
packages/ckeditor5-typing/src/utils/injecttypingmutationshandling.js

@@ -0,0 +1,387 @@
+/**
+ * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/**
+ * @module typing/utils/injecttypingmutationshandling
+ */
+
+import ModelRange from '@ckeditor/ckeditor5-engine/src/model/range';
+import ViewPosition from '@ckeditor/ckeditor5-engine/src/view/position';
+import ViewText from '@ckeditor/ckeditor5-engine/src/view/text';
+import diff from '@ckeditor/ckeditor5-utils/src/diff';
+import diffToChanges from '@ckeditor/ckeditor5-utils/src/difftochanges';
+import DomConverter from '@ckeditor/ckeditor5-engine/src/view/domconverter';
+
+/**
+ * Handles mutations caused by normal typing.
+ *
+ * @param {module:core/editor/editor~Editor} editor The editor instance.
+ */
+export default function injectTypingMutationsHandling( editor ) {
+	editor.editing.view.document.on( 'mutations', ( evt, mutations, viewSelection ) => {
+		new MutationHandler( editor ).handle( mutations, viewSelection );
+	} );
+}
+
+/**
+ * Helper class for translating DOM mutations into model changes.
+ *
+ * @private
+ */
+class MutationHandler {
+	/**
+	 * Creates an instance of the mutation handler.
+	 *
+	 * @param {module:core/editor/editor~Editor} editor
+	 */
+	constructor( editor ) {
+		/**
+		 * Editor instance for which mutations are handled.
+		 *
+		 * @readonly
+		 * @member {module:core/editor/editor~Editor} #editor
+		 */
+		this.editor = editor;
+
+		/**
+		 * The editing controller.
+		 *
+		 * @readonly
+		 * @member {module:engine/controller/editingcontroller~EditingController} #editing
+		 */
+		this.editing = this.editor.editing;
+	}
+
+	/**
+	 * Handles given mutations.
+	 *
+	 * @param {Array.<module:engine/view/observer/mutationobserver~MutatedText|
+	 * module:engine/view/observer/mutationobserver~MutatedChildren>} mutations
+	 * @param {module:engine/view/selection~Selection|null} viewSelection
+	 */
+	handle( mutations, viewSelection ) {
+		if ( containerChildrenMutated( mutations ) ) {
+			this._handleContainerChildrenMutations( mutations, viewSelection );
+		} else {
+			for ( const mutation of mutations ) {
+				// Fortunately it will never be both.
+				this._handleTextMutation( mutation, viewSelection );
+				this._handleTextNodeInsertion( mutation );
+			}
+		}
+	}
+
+	/**
+	 * Handles situations when container's children mutated during input. This can happen when
+	 * the browser is trying to "fix" DOM in certain situations. For example, when the user starts to type
+	 * in `<p><a href=""><i>Link{}</i></a></p>`, the browser might change the order of elements
+	 * to `<p><i><a href="">Link</a>x{}</i></p>`. A similar situation happens when the spell checker
+	 * replaces a word wrapped with `<strong>` with a word wrapped with a `<b>` element.
+	 *
+	 * To handle such situations, the common DOM ancestor of all mutations is converted to the model representation
+	 * and then compared with the current model to calculate the proper text change.
+	 *
+	 * Note: Single text node insertion is handled in {@link #_handleTextNodeInsertion} and text node mutation is handled
+	 * in {@link #_handleTextMutation}).
+	 *
+	 * @private
+	 * @param {Array.<module:engine/view/observer/mutationobserver~MutatedText|
+	 * module:engine/view/observer/mutationobserver~MutatedChildren>} mutations
+	 * @param {module:engine/view/selection~Selection|null} viewSelection
+	 */
+	_handleContainerChildrenMutations( mutations, viewSelection ) {
+		// Get common ancestor of all mutations.
+		const mutationsCommonAncestor = getMutationsContainer( mutations );
+
+		// Quit if there is no common ancestor.
+		if ( !mutationsCommonAncestor ) {
+			return;
+		}
+
+		const domConverter = this.editor.editing.view.domConverter;
+
+		// Get common ancestor in DOM.
+		const domMutationCommonAncestor = domConverter.mapViewToDom( mutationsCommonAncestor );
+
+		// Create fresh DomConverter so it will not use existing mapping and convert current DOM to model.
+		// This wouldn't be needed if DomConverter would allow to create fresh view without checking any mappings.
+		const freshDomConverter = new DomConverter();
+		const modelFromCurrentDom = this.editor.data.toModel(
+			freshDomConverter.domToView( domMutationCommonAncestor )
+		).getChild( 0 );
+
+		// Current model.
+		const currentModel = this.editor.editing.mapper.toModelElement( mutationsCommonAncestor );
+
+		// If common ancestor is not mapped, do not do anything. It probably is a parent of another view element.
+		// That means that we would need to diff model elements (see `if` below). Better return early instead of
+		// trying to get a reasonable model ancestor. It will fell into the `if` below anyway.
+		// This situation happens for example for lists. If `<ul>` is a common ancestor, `currentModel` is `undefined`
+		// because `<ul>` is not mapped (`<li>`s are).
+		// See https://github.com/ckeditor/ckeditor5/issues/718.
+		if ( !currentModel ) {
+			return;
+		}
+
+		// Get children from both ancestors.
+		const modelFromDomChildren = Array.from( modelFromCurrentDom.getChildren() );
+		const currentModelChildren = Array.from( currentModel.getChildren() );
+
+		// Remove the last `<softBreak>` from the end of `modelFromDomChildren` if there is no `<softBreak>` in current model.
+		// If the described scenario happened, it means that this is a bogus `<br />` added by a browser.
+		const lastDomChild = modelFromDomChildren[ modelFromDomChildren.length - 1 ];
+		const lastCurrentChild = currentModelChildren[ currentModelChildren.length - 1 ];
+
+		if ( lastDomChild && lastDomChild.is( 'softBreak' ) && lastCurrentChild && !lastCurrentChild.is( 'softBreak' ) ) {
+			modelFromDomChildren.pop();
+		}
+
+		// Skip situations when common ancestor has any container elements.
+		if ( !isSafeForTextMutation( modelFromDomChildren ) || !isSafeForTextMutation( currentModelChildren ) ) {
+			return;
+		}
+
+		// Replace &nbsp; inserted by the browser with normal space. See comment in `_handleTextMutation`.
+		// Replace non-texts with any character. This is potentially dangerous but passes in manual tests. The thing is
+		// that we need to take care of proper indexes so we cannot simply remove non-text elements from the content.
+		// By inserting a character we keep all the real texts on their indexes.
+		const newText = modelFromDomChildren.map( item => item.is( 'text' ) ? item.data : '@' ).join( '' ).replace( /\u00A0/g, ' ' );
+		const oldText = currentModelChildren.map( item => item.is( 'text' ) ? item.data : '@' ).join( '' );
+
+		// Do nothing if mutations created same text.
+		if ( oldText === newText ) {
+			return;
+		}
+
+		const diffResult = diff( oldText, newText );
+
+		const { firstChangeAt, insertions, deletions } = calculateChanges( diffResult );
+
+		// Try setting new model selection according to passed view selection.
+		let modelSelectionRange = null;
+
+		if ( viewSelection ) {
+			modelSelectionRange = this.editing.mapper.toModelRange( viewSelection.getFirstRange() );
+		}
+
+		const insertText = newText.substr( firstChangeAt, insertions );
+		const removeRange = ModelRange.createFromParentsAndOffsets(
+			currentModel,
+			firstChangeAt,
+			currentModel,
+			firstChangeAt + deletions
+		);
+
+		this.editor.execute( 'input', {
+			text: insertText,
+			range: removeRange,
+			resultRange: modelSelectionRange
+		} );
+	}
+
+	/**
+	 * @private
+	 */
+	_handleTextMutation( mutation, viewSelection ) {
+		if ( mutation.type != 'text' ) {
+			return;
+		}
+
+		// Replace &nbsp; inserted by the browser with normal space.
+		// We want only normal spaces in the model and in the view. Renderer and DOM Converter will be then responsible
+		// for rendering consecutive spaces using &nbsp;, but the model and the view has to be clear.
+		// Other feature may introduce inserting non-breakable space on specific key stroke (for example shift + space).
+		// However then it will be handled outside of mutations, like enter key is.
+		// The replacing is here because it has to be done before `diff` and `diffToChanges` functions, as they
+		// take `newText` and compare it to (cleaned up) view.
+		// It could also be done in mutation observer too, however if any outside plugin would like to
+		// introduce additional events for mutations, they would get already cleaned up version (this may be good or not).
+		const newText = mutation.newText.replace( /\u00A0/g, ' ' );
+		// To have correct `diffResult`, we also compare view node text data with &nbsp; replaced by space.
+		const oldText = mutation.oldText.replace( /\u00A0/g, ' ' );
+
+		const diffResult = diff( oldText, newText );
+
+		const { firstChangeAt, insertions, deletions } = calculateChanges( diffResult );
+
+		// Try setting new model selection according to passed view selection.
+		let modelSelectionRange = null;
+
+		if ( viewSelection ) {
+			modelSelectionRange = this.editing.mapper.toModelRange( viewSelection.getFirstRange() );
+		}
+
+		// Get the position in view and model where the changes will happen.
+		const viewPos = new ViewPosition( mutation.node, firstChangeAt );
+		const modelPos = this.editing.mapper.toModelPosition( viewPos );
+		const removeRange = ModelRange.createFromPositionAndShift( modelPos, deletions );
+		const insertText = newText.substr( firstChangeAt, insertions );
+
+		this.editor.execute( 'input', {
+			text: insertText,
+			range: removeRange,
+			resultRange: modelSelectionRange
+		} );
+	}
+
+	/**
+	 * @private
+	 */
+	_handleTextNodeInsertion( mutation ) {
+		if ( mutation.type != 'children' ) {
+			return;
+		}
+
+		const change = getSingleTextNodeChange( mutation );
+		const viewPos = new ViewPosition( mutation.node, change.index );
+		const modelPos = this.editing.mapper.toModelPosition( viewPos );
+		const insertedText = change.values[ 0 ].data;
+
+		this.editor.execute( 'input', {
+			// Replace &nbsp; inserted by the browser with normal space.
+			// See comment in `_handleTextMutation`.
+			// In this case we don't need to do this before `diff` because we diff whole nodes.
+			// Just change &nbsp; in case there are some.
+			text: insertedText.replace( /\u00A0/g, ' ' ),
+			range: new ModelRange( modelPos )
+		} );
+	}
+}
+
+// Helper function that compares whether two given view nodes are same. It is used in `diff` when it's passed an array
+// with child nodes.
+function compareChildNodes( oldChild, newChild ) {
+	if ( oldChild instanceof ViewText && newChild instanceof ViewText ) {
+		return oldChild.data === newChild.data;
+	} else {
+		return oldChild === newChild;
+	}
+}
+
+// Returns change made to a single text node. Returns `undefined` if more than a single text node was changed.
+//
+// @private
+// @param mutation
+function getSingleTextNodeChange( mutation ) {
+	// One new node.
+	if ( mutation.newChildren.length - mutation.oldChildren.length != 1 ) {
+		return;
+	}
+
+	// Which is text.
+	const diffResult = diff( mutation.oldChildren, mutation.newChildren, compareChildNodes );
+	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 ];
+
+	// Which is text.
+	if ( !( change.values[ 0 ] instanceof ViewText ) ) {
+		return;
+	}
+
+	return change;
+}
+
+// Returns first common ancestor of all mutations that is either {@link module:engine/view/containerelement~ContainerElement}
+// or {@link module:engine/view/rootelement~RootElement}.
+//
+// @private
+// @param {Array.<module:engine/view/observer/mutationobserver~MutatedText|
+// module:engine/view/observer/mutationobserver~MutatedChildren>} mutations
+// @returns {module:engine/view/containerelement~ContainerElement|engine/view/rootelement~RootElement|undefined}
+function getMutationsContainer( mutations ) {
+	const lca = mutations
+		.map( mutation => mutation.node )
+		.reduce( ( commonAncestor, node ) => {
+			return commonAncestor.getCommonAncestor( node, { includeSelf: true } );
+		} );
+
+	if ( !lca ) {
+		return;
+	}
+
+	// We need to look for container and root elements only, so check all LCA's
+	// ancestors (starting from itself).
+	return lca.getAncestors( { includeSelf: true, parentFirst: true } )
+		.find( element => element.is( 'containerElement' ) || element.is( 'rootElement' ) );
+}
+
+// Returns true if container children have mutated or more than a single text node was changed.
+//
+// Single text node child insertion is handled in {@link module:typing/input~MutationHandler#_handleTextNodeInsertion}
+// while text mutation is handled in {@link module:typing/input~MutationHandler#_handleTextMutation}.
+//
+// @private
+// @param {Array.<module:engine/view/observer/mutationobserver~MutatedText|
+// module:engine/view/observer/mutationobserver~MutatedChildren>} mutations
+// @returns {Boolean}
+function containerChildrenMutated( mutations ) {
+	if ( mutations.length == 0 ) {
+		return false;
+	}
+
+	// Check if there is any mutation of `children` type or any mutation that changes more than one text node.
+	for ( const mutation of mutations ) {
+		if ( mutation.type === 'children' && !getSingleTextNodeChange( mutation ) ) {
+			return true;
+		}
+	}
+
+	return false;
+}
+
+// Returns true if provided array contains content that won't be problematic during diffing and text mutation handling.
+//
+// @param {Array.<module:engine/model/node~Node>} children
+// @returns {Boolean}
+function isSafeForTextMutation( children ) {
+	return children.every( child => child.is( 'text' ) || child.is( 'softBreak' ) );
+}
+
+// Calculates first change index and number of characters that should be inserted and deleted starting from that index.
+//
+// @private
+// @param diffResult
+// @returns {{insertions: number, deletions: number, firstChangeAt: *}}
+function calculateChanges( diffResult ) {
+	// Index where the first change happens. Used to set the position from which nodes will be removed and where will be inserted.
+	let firstChangeAt = null;
+	// Index where the last change happens. Used to properly count how many characters have to be removed and inserted.
+	let lastChangeAt = null;
+
+	// Get `firstChangeAt` and `lastChangeAt`.
+	for ( let i = 0; i < diffResult.length; i++ ) {
+		const change = diffResult[ i ];
+
+		if ( change != 'equal' ) {
+			firstChangeAt = firstChangeAt === null ? i : firstChangeAt;
+			lastChangeAt = i;
+		}
+	}
+
+	// How many characters, starting from `firstChangeAt`, should be removed.
+	let deletions = 0;
+	// How many characters, starting from `firstChangeAt`, should be inserted.
+	let insertions = 0;
+
+	for ( let i = firstChangeAt; i <= lastChangeAt; i++ ) {
+		// If there is no change (equal) or delete, the character is existing in `oldText`. We count it for removing.
+		if ( diffResult[ i ] != 'insert' ) {
+			deletions++;
+		}
+
+		// If there is no change (equal) or insert, the character is existing in `newText`. We count it for inserting.
+		if ( diffResult[ i ] != 'delete' ) {
+			insertions++;
+		}
+	}
+
+	return { insertions, deletions, firstChangeAt };
+}

+ 153 - 0
packages/ckeditor5-typing/src/utils/injectunsafekeystrokeshandling.js

@@ -0,0 +1,153 @@
+/**
+ * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/**
+ * @module typing/utils/injectunsafekeystrokeshandling
+ */
+
+import Selection from '@ckeditor/ckeditor5-engine/src/model/selection';
+
+import { getCode } from '@ckeditor/ckeditor5-utils/src/keyboard';
+
+/**
+ * Handles keystrokes which are unsafe for typing. This handler's logic is explained
+ * in https://github.com/ckeditor/ckeditor5-typing/issues/83#issuecomment-398690251.
+ *
+ * @param {module:core/editor/editor~Editor} editor The editor instance.
+ */
+export default function injectUnsafeKeystrokesHandling( editor ) {
+	let latestCompositionSelection = null;
+
+	const model = editor.model;
+	const view = editor.editing.view;
+	const inputCommand = editor.commands.get( 'input' );
+
+	view.document.on( 'keydown', ( evt, evtData ) => handleKeydown( evtData ), { priority: 'lowest' } );
+
+	view.document.on( 'compositionstart', handleCompositionStart, { priority: 'lowest' } );
+
+	view.document.on( 'compositionend', () => {
+		latestCompositionSelection = new Selection( model.document.selection );
+	}, { priority: 'lowest' } );
+
+	// Handles the keydown event. We need to guess whether such keystroke is going to result
+	// in typing. If so, then before character insertion happens, any selected content needs
+	// to be deleted. Otherwise the default browser deletion mechanism would be
+	// triggered, resulting in:
+	//
+	// * Hundreds of mutations which could not be handled.
+	// * But most importantly, loss of control over how the content is being deleted.
+	//
+	// The method is used in a low-priority listener, hence allowing other listeners (e.g. delete or enter features)
+	// to handle the event.
+	//
+	// @param {module:engine/view/observer/keyobserver~KeyEventData} evtData
+	function handleKeydown( evtData ) {
+		const doc = model.document;
+		const isComposing = view.document.isComposing;
+		const isSelectionUnchanged = latestCompositionSelection && latestCompositionSelection.isEqual( doc.selection );
+
+		// Reset stored composition selection.
+		latestCompositionSelection = null;
+
+		// By relying on the state of the input command we allow disabling the entire input easily
+		// by just disabling the input command. We could’ve used here the delete command but that
+		// would mean requiring the delete feature which would block loading one without the other.
+		// We could also check the editor.isReadOnly property, but that wouldn't allow to block
+		// the input without blocking other features.
+		if ( !inputCommand.isEnabled ) {
+			return;
+		}
+
+		if ( isSafeKeystroke( evtData ) || doc.selection.isCollapsed ) {
+			return;
+		}
+
+		// If during composition, deletion should be prevented as it may remove composed sequence (#83).
+		if ( isComposing && evtData.keyCode === 229 ) {
+			return;
+		}
+
+		// If there is a `keydown` event fired with '229' keycode it might be related
+		// to recent composition. Check if selection is the same as upon ending recent composition,
+		// if so do not remove selected content as it will remove composed sequence (#83).
+		if ( !isComposing && evtData.keyCode === 229 && isSelectionUnchanged ) {
+			return;
+		}
+
+		deleteSelectionContent();
+	}
+
+	// Handles the `compositionstart` event. It is used only in special cases to remove the contents
+	// of a non-collapsed selection so composition itself does not result in complex mutations.
+	//
+	// The special case mentioned above is a situation in which the `keydown` event is fired after
+	// `compositionstart` event. In such cases {@link #handleKeydown} cannot clear current selection
+	// contents (because it is too late and will break the composition) so the composition handler takes care of it.
+	function handleCompositionStart() {
+		const doc = model.document;
+		const isFlatSelection = doc.selection.rangeCount === 1 ? doc.selection.getFirstRange().isFlat : true;
+
+		// If on `compositionstart` there is a non-collapsed selection which start and end have different parents
+		// it means the `handleKeydown()` method did not remove its contents. It happens usually because
+		// of different order of events (`compositionstart` before `keydown` - in Safari). In such cases
+		// we need to remove selection contents on composition start (#83).
+		if ( doc.selection.isCollapsed || isFlatSelection ) {
+			return;
+		}
+
+		deleteSelectionContent();
+	}
+
+	function deleteSelectionContent() {
+		const buffer = inputCommand.buffer;
+
+		buffer.lock();
+
+		model.enqueueChange( buffer.batch, () => {
+			model.deleteContent( model.document.selection );
+		} );
+
+		buffer.unlock();
+	}
+}
+
+const safeKeycodes = [
+	getCode( 'arrowUp' ),
+	getCode( 'arrowRight' ),
+	getCode( 'arrowDown' ),
+	getCode( 'arrowLeft' ),
+	9, // Tab
+	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.
+//
+// @private
+// @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 );
+}

+ 109 - 13
packages/ckeditor5-typing/tests/input.js

@@ -8,11 +8,13 @@ import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils';
 import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
 import List from '@ckeditor/ckeditor5-list/src/list';
 import Bold from '@ckeditor/ckeditor5-basic-styles/src/bold';
+import Italic from '@ckeditor/ckeditor5-basic-styles/src/italic';
 import ShiftEnter from '@ckeditor/ckeditor5-enter/src/shiftenter';
 import Input from '../src/input';
 
 import Writer from '@ckeditor/ckeditor5-engine/src/model/writer';
 import ModelRange from '@ckeditor/ckeditor5-engine/src/model/range';
+import ModelSelection from '@ckeditor/ckeditor5-engine/src/model/selection';
 
 import ViewText from '@ckeditor/ckeditor5-engine/src/view/text';
 import ViewElement from '@ckeditor/ckeditor5-engine/src/view/element';
@@ -37,7 +39,7 @@ describe( 'Input feature', () => {
 		const domElement = document.createElement( 'div' );
 		document.body.appendChild( domElement );
 
-		return ClassicTestEditor.create( domElement, { plugins: [ Input, Paragraph, Bold, List, ShiftEnter ] } )
+		return ClassicTestEditor.create( domElement, { plugins: [ Input, Paragraph, Bold, Italic, List, ShiftEnter ] } )
 			.then( newEditor => {
 				// Mock image feature.
 				newEditor.model.schema.register( 'image', { allowWhere: '$text' } );
@@ -614,18 +616,6 @@ describe( 'Input feature', () => {
 			expect( getModelData( model ) ).to.equal( '<paragraph>fo[ob]ar</paragraph>' );
 		} );
 
-		// #82
-		it( 'should do nothing on composition start key', () => {
-			model.change( writer => {
-				writer.setSelection(
-					ModelRange.createFromParentsAndOffsets( modelRoot.getChild( 0 ), 2, modelRoot.getChild( 0 ), 4 ) );
-			} );
-
-			viewDocument.fire( 'keydown', { keyCode: 229 } );
-
-			expect( getModelData( model ) ).to.equal( '<paragraph>fo[ob]ar</paragraph>' );
-		} );
-
 		it( 'should do nothing if selection is collapsed', () => {
 			viewDocument.fire( 'keydown', { ctrlKey: true, keyCode: getCode( 'c' ) } );
 
@@ -693,6 +683,112 @@ describe( 'Input feature', () => {
 
 			expect( getModelData( model ) ).to.equal( '<paragraph>fo[ob]ar</paragraph>' );
 		} );
+
+		describe( '#83', () => {
+			it( 'should remove contents on composition start key if not during composition', () => {
+				model.change( writer => {
+					writer.setSelection(
+						ModelRange.createFromParentsAndOffsets( modelRoot.getChild( 0 ), 2, modelRoot.getChild( 0 ), 4 ) );
+				} );
+
+				viewDocument.fire( 'keydown', { keyCode: 229 } );
+
+				expect( getModelData( model ) ).to.equal( '<paragraph>fo[]ar</paragraph>' );
+			} );
+
+			it( 'should not remove contents on composition start key if during composition', () => {
+				model.change( writer => {
+					writer.setSelection(
+						ModelRange.createFromParentsAndOffsets( modelRoot.getChild( 0 ), 2, modelRoot.getChild( 0 ), 4 ) );
+				} );
+
+				viewDocument.fire( 'compositionstart' );
+				viewDocument.fire( 'keydown', { keyCode: 229 } );
+
+				expect( getModelData( model ) ).to.equal( '<paragraph>fo[ob]ar</paragraph>' );
+			} );
+
+			it( 'should not remove contents on compositionstart event if selection is flat', () => {
+				editor.setData( '<p><strong>foo</strong> <i>bar</i></p>' );
+
+				model.change( writer => {
+					writer.setSelection(
+						ModelRange.createFromParentsAndOffsets( modelRoot.getChild( 0 ), 2, modelRoot.getChild( 0 ), 5 ) );
+				} );
+
+				viewDocument.fire( 'compositionstart' );
+
+				expect( getModelData( model ) ).to.equal(
+					'<paragraph><$text bold="true">fo[o</$text> <$text italic="true">b]ar</$text></paragraph>' );
+			} );
+
+			it( 'should not remove contents on compositionstart event if no selection', () => {
+				editor.setData( '<p><strong>foo</strong> <i>bar</i></p>' );
+
+				const documentSelection = model.document.selection;
+
+				// Create empty selection.
+				model.document.selection = new ModelSelection();
+
+				viewDocument.fire( 'compositionstart' );
+
+				expect( getModelData( model ) ).to.equal(
+					'<paragraph><$text bold="true">foo</$text> <$text italic="true">bar</$text></paragraph>' );
+
+				// Restore document selection.
+				model.document.selection = documentSelection;
+			} );
+
+			it( 'should remove contents on compositionstart event if selection is not flat', () => {
+				editor.setData( '<p><strong>foo</strong></p><p><i>bar</i></p>' );
+
+				model.change( writer => {
+					writer.setSelection(
+						ModelRange.createFromParentsAndOffsets( modelRoot.getChild( 0 ), 2, modelRoot.getChild( 1 ), 2 ) );
+				} );
+
+				viewDocument.fire( 'compositionstart' );
+
+				expect( getModelData( model ) ).to.equal(
+					'<paragraph><$text bold="true">fo[]</$text><$text italic="true">r</$text></paragraph>' );
+			} );
+
+			it( 'should not remove contents on keydown event after compositionend event if selection did not change', () => {
+				editor.setData( '<p><strong>foo</strong></p><p><i>bar</i></p>' );
+
+				model.change( writer => {
+					writer.setSelection(
+						ModelRange.createFromParentsAndOffsets( modelRoot.getChild( 0 ), 2, modelRoot.getChild( 1 ), 2 ) );
+				} );
+
+				viewDocument.fire( 'compositionend' );
+				viewDocument.fire( 'keydown', { keyCode: 229 } );
+
+				expect( getModelData( model ) ).to.equal(
+					'<paragraph><$text bold="true">fo[o</$text></paragraph><paragraph><$text italic="true">ba]r</$text></paragraph>' );
+			} );
+
+			it( 'should remove contents on keydown event after compositionend event if selection have changed', () => {
+				editor.setData( '<p><strong>foo</strong></p><p><i>bar</i></p>' );
+
+				model.change( writer => {
+					writer.setSelection(
+						ModelRange.createFromParentsAndOffsets( modelRoot.getChild( 0 ), 2, modelRoot.getChild( 1 ), 2 ) );
+				} );
+
+				viewDocument.fire( 'compositionend' );
+
+				model.change( writer => {
+					writer.setSelection(
+						ModelRange.createFromParentsAndOffsets( modelRoot.getChild( 0 ), 2, modelRoot.getChild( 1 ), 1 ) );
+				} );
+
+				viewDocument.fire( 'keydown', { keyCode: 229 } );
+
+				expect( getModelData( model ) ).to.equal(
+					'<paragraph><$text bold="true">fo[]</$text><$text italic="true">ar</$text></paragraph>' );
+			} );
+		} );
 	} );
 } );
 

+ 1 - 1
packages/ckeditor5-typing/tests/inputcommand.js

@@ -10,7 +10,7 @@ import { getData, setData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model
 import Range from '@ckeditor/ckeditor5-engine/src/model/range';
 import Position from '@ckeditor/ckeditor5-engine/src/model/position';
 import InputCommand from '../src/inputcommand';
-import ChangeBuffer from '../src/changebuffer';
+import ChangeBuffer from '../src/utils/changebuffer';
 import Input from '../src/input';
 
 describe( 'InputCommand', () => {

+ 1 - 1
packages/ckeditor5-typing/tests/changebuffer.js

@@ -3,7 +3,7 @@
  * For licensing, see LICENSE.md.
  */
 
-import ChangeBuffer from '../src/changebuffer';
+import ChangeBuffer from '../../src/utils/changebuffer';
 import Model from '@ckeditor/ckeditor5-engine/src/model/model';
 import Batch from '@ckeditor/ckeditor5-engine/src/model/batch';