ソースを参照

Feature: Improve Android typing by handling `beforeinpnut` event on Android devices.

Szymon Cofalik 6 年 前
コミット
a2d4bc2386

+ 54 - 4
packages/ckeditor5-typing/src/delete.js

@@ -10,8 +10,7 @@
 import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
 import DeleteCommand from './deletecommand';
 import DeleteObserver from './deleteobserver';
-
-import injectAndroidBackspaceMutationsHandling from './utils/injectandroidbackspacemutationshandling';
+import env from '@ckeditor/ckeditor5-utils/src/env';
 
 /**
  * The delete and backspace feature. Handles the <kbd>Delete</kbd> and <kbd>Backspace</kbd> keys in the editor.
@@ -37,11 +36,62 @@ export default class Delete extends Plugin {
 		editor.commands.add( 'delete', new DeleteCommand( editor, 'backward' ) );
 
 		this.listenTo( viewDocument, 'delete', ( evt, data ) => {
-			editor.execute( data.direction == 'forward' ? 'forwardDelete' : 'delete', { unit: data.unit, sequence: data.sequence } );
+			const deleteCommandParams = { unit: data.unit, sequence: data.sequence };
+
+			// If a specific (view) selection to remove was set, convert it to a model selection and set as a parameter for `DeleteCommand`.
+			if ( data.selectionToRemove ) {
+				const modelSelection = editor.model.createSelection();
+				const ranges = [];
+
+				for ( const viewRange of data.selectionToRemove.getRanges() ) {
+					ranges.push( editor.editing.mapper.toModelRange( viewRange ) );
+				}
+
+				modelSelection.setTo( ranges );
+
+				deleteCommandParams.selection = modelSelection;
+			}
+
+			editor.execute( data.direction == 'forward' ? 'forwardDelete' : 'delete', deleteCommandParams );
+
 			data.preventDefault();
+
 			view.scrollToTheSelection();
 		} );
 
-		injectAndroidBackspaceMutationsHandling( editor );
+		// Android IMEs have a quirk - they change DOM selection after the input changes were performed by the browser.
+		// This happens on `keyup` event. Android doesn't know anything about our deletion and selection handling. Even if the selection
+		// was changed during input events, IME remembers the position where the selection "should" be placed and moves it there.
+		//
+		// To prevent incorrect selection, we save the selection after deleting here and then re-set it on `keyup`. This has to be done
+		// on DOM selection level, because on `keyup` the model selection is still the same as it was just after deletion, so it
+		// wouldn't be changed and the fix would do nothing.
+		//
+		/* istanbul ignore if */
+		if ( env.isAndroid ) {
+			let domSelectionAfterDeletion = null;
+
+			this.listenTo( viewDocument, 'delete', ( evt, data ) => {
+				const domSelection = data.domTarget.ownerDocument.defaultView.getSelection();
+
+				domSelectionAfterDeletion = {
+					anchorNode: domSelection.anchorNode,
+					anchorOffset: domSelection.anchorOffset,
+					focusNode: domSelection.focusNode,
+					focusOffset: domSelection.focusOffset
+				};
+			}, { priority: 'lowest' } );
+
+			this.listenTo( viewDocument, 'keyup', ( evt, data ) => {
+				if ( domSelectionAfterDeletion ) {
+					const domSelection = data.domTarget.ownerDocument.defaultView.getSelection();
+
+					domSelection.collapse( domSelectionAfterDeletion.anchorNode, domSelectionAfterDeletion.anchorOffset );
+					domSelection.extend( domSelectionAfterDeletion.focusNode, domSelectionAfterDeletion.focusOffset );
+
+					domSelectionAfterDeletion = null;
+				}
+			} );
+		}
 	}
 }

+ 38 - 5
packages/ckeditor5-typing/src/deleteobserver.js

@@ -47,19 +47,52 @@ export default class DeleteObserver extends Observer {
 			deleteData.unit = hasWordModifier ? 'word' : deleteData.unit;
 			deleteData.sequence = ++sequence;
 
+			fireViewDeleteEvent( evt, data.domEvent, deleteData );
+		} );
+
+		// `beforeinput` is handled only for Android devices. Desktop Chrome and iOS are skipped because they are working fine now.
+		/* istanbul ignore if */
+		if ( env.isAndroid ) {
+			document.on( 'beforeinput', ( evt, data ) => {
+				// If event type is other than `deleteContentBackward` then this is not deleting.
+				if ( data.domEvent.inputType != 'deleteContentBackward' ) {
+					return;
+				}
+
+				const deleteData = {
+					unit: 'codepoint',
+					direction: 'backward',
+					sequence: 1
+				};
+
+				// Android IMEs may change the DOM selection on `beforeinput` event so that the selection contains all the text
+				// that the IME wants to remove. We will pass this information to `delete` event so proper part of the content is removed.
+				//
+				// Sometimes it is only expanding by a one character (in case of collapsed selection). In this case we don't need to
+				// set a different selection to remove, it will work just fine.
+				const domSelection = data.domTarget.ownerDocument.defaultView.getSelection();
+
+				if ( domSelection.anchorNode == domSelection.focusNode && domSelection.anchorOffset + 1 != domSelection.focusOffset ) {
+					deleteData.selectionToRemove = view.domConverter.domSelectionToView( domSelection );
+				}
+
+				fireViewDeleteEvent( evt, data.domEvent, deleteData );
+			} );
+		}
+
+		function fireViewDeleteEvent( originalEvent, domEvent, deleteData ) {
 			// Save the event object to check later if it was stopped or not.
 			let event;
 			document.once( 'delete', evt => ( event = evt ), { priority: Number.POSITIVE_INFINITY } );
 
-			const domEvtData = new DomEventData( document, data.domEvent, deleteData );
-			document.fire( 'delete', domEvtData );
+			document.fire( 'delete', new DomEventData( document, domEvent, deleteData ) );
 
-			// Stop `keydown` event if `delete` event was stopped.
+			// Stop the original event if `delete` event was stopped.
 			// https://github.com/ckeditor/ckeditor5/issues/753
 			if ( event && event.stop.called ) {
-				evt.stop();
+				originalEvent.stop();
 			}
-		} );
+		}
 	}
 
 	/**

+ 0 - 166
packages/ckeditor5-typing/src/utils/injectandroidbackspacemutationshandling.js

@@ -1,166 +0,0 @@
-/**
- * @license Copyright (c) 2003-2019, CKSource - Frederico Knabben. All rights reserved.
- * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
- */
-
-/**
- * @module typing/utils/injectandroidbackspacenutationshandling
- */
-
-import diff from '@ckeditor/ckeditor5-utils/src/diff';
-
-import { containerChildrenMutated } from './utils';
-
-/**
- * Handles mutations triggered by <kbd>Backspace</kbd> on Android.
- * Due to the fact that on Android `keydown` events don't have the `keyCode` set, we are not able
- * to handle backspacing directly. We need to guess that from mutations which the IME
- * on Android caused.
- *
- * @param {module:core/editor/editor~Editor} editor The editor instance.
- */
-export default function injectAndroidBackspaceMutationsHandling( editor ) {
-	const model = editor.model;
-	const view = editor.editing.view;
-	const selectionChangeToleranceMs = 200;
-
-	let previousSelection = null;
-	let currentSelection = model.createSelection( model.document.selection );
-	let latestSelectionChangeMs = Date.now();
-
-	model.document.selection.on( 'change', handleSelectionChange );
-
-	view.document.on( 'mutations', handleMutations, { priority: 'highest' } );
-
-	// Saves current and previous selection when it changes. Saved selections are used
-	// to remove correct piece of content when `Backspace` mutations are detected.
-	//
-	// @param {Object} evt
-	function handleSelectionChange( evt ) {
-		const newSelection = model.createSelection( evt.source );
-		if ( !currentSelection.isEqual( newSelection ) ) {
-			previousSelection = currentSelection;
-			currentSelection = newSelection;
-			latestSelectionChangeMs = Date.now();
-		}
-	}
-
-	// Handles DOM mutations and checks if they should be processed as block elements removal mutations.
-	//
-	// @param {Object} evt
-	// @param {Array.<module:engine/view/observer/mutationobserver~MutatedText|
-	// module:engine/view/observer/mutationobserver~MutatedChildren>} mutations
-	function handleMutations( evt, mutations ) {
-		if ( containerChildrenMutated( mutations ) && containsContainersRemoval( mutations ) ) {
-			handleContainerRemovalMutations();
-
-			// Stop mutations event propagation so other mutation handlers are not triggered.
-			evt.stop();
-		}
-	}
-
-	// Handles situations when mutations were generated by container removal.
-	// It happens on Android devices where every typing input has `229` key code
-	// and delete observer will not be triggered. In such cases we need to handle
-	// container removal mutations manually.
-	function handleContainerRemovalMutations() {
-		if ( shouldUsePreviousSelection() ) {
-			// If previous selection is used, update model selection in order
-			// to use `delete` command and to make `undo` work correctly.
-			model.enqueueChange( writer => {
-				writer.setSelection( previousSelection );
-			} );
-		}
-
-		editor.execute( 'delete' );
-	}
-
-	// Whether previously saved selection should be used instead of the current one to remove content.
-	//
-	// On Android devices when pressing backspace on non-collapsed selection, selection like:
-	//
-	//		`<h1>[Foo</h1><p>Bar]</p>`
-	//
-	// is changed to:
-	//
-	//		`<h1>Foo</h1><p>Bar[]</p>`
-	//
-	// even before `keypress` event, so in such cases we have to rely on previous selection to correctly process selected content.
-	//
-	// Previous selection will be used if:
-	//
-	//		* current selection is collapsed (see example above),
-	//		* previous selection exists, is non-collapsed and has same ending (last position) as the current one,
-	//		* change of the selection happened not earlier than X milliseconds ago (see `selectionChangeToleranceMs`).
-	//
-	// The last check is needed, because user can manually collapse the selection on its current end and then press `Backspace`.
-	// In such situations timing determines if the selection change was caused by the user or browser native behaviour.
-	// However, this happens only if selection was collapsed by the user on the beginning of the paragraph (so mutations
-	// still will show container removal).
-	//
-	// @returns {Boolean}
-	function shouldUsePreviousSelection() {
-		return Date.now() - latestSelectionChangeMs < selectionChangeToleranceMs &&
-			previousSelection && !previousSelection.isCollapsed && currentSelection.isCollapsed &&
-			currentSelection.getLastPosition().isEqual( previousSelection.getLastPosition() );
-	}
-}
-
-// Checks whether mutations array contains mutation generated by container/containers removal.
-// For example mutations generated on Android when pressing `backspace` on the beginning of the line:
-//
-//		<h1>Header1</h1>
-//		<p>{}Paragraph</p>
-//
-// are:
-//
-//		[
-//			{ newChildren: [], oldChildren: [ 'Paragraph' ], node: P, type: 'children' },
-//			{ newChildren: [ ContainerElement ], oldChildren: [ ContainerElement, ContainerElement ], node: Root, type: 'children' },
-//			{ newChildren: [ 'Heading 1Paragraph' ], oldChildren: [ 'Heading 1' ], node: H1, type: 'children' }
-//		]
-//
-// The 1st and 3rd mutations are just changes in a text (1st - text in `p` element was removed, 3rd - text in `h2` was changed)
-// and the 2nd one shows that one `ContainerElement` was removed. We have to recognize if mutations like 2nd one are present.
-// Based on that heuristic mutations are treated as the one removing container element.
-//
-// @private
-// @param {Array.<module:engine/view/observer/mutationobserver~MutatedText|
-// module:engine/view/observer/mutationobserver~MutatedChildren>} mutations
-// @returns {Boolean}
-function containsContainersRemoval( mutations ) {
-	for ( const mutation of mutations ) {
-		if ( mutation.type !== 'children' ) {
-			continue;
-		}
-
-		const childrenBefore = mutation.oldChildren;
-		const childrenAfter = mutation.newChildren;
-
-		// Check if only containers were present before the mutation.
-		if ( !hasOnlyContainers( childrenBefore ) ) {
-			continue;
-		}
-
-		const diffResult = diff( childrenBefore, childrenAfter );
-
-		// Check if there was only removing in that mutation without any insertions.
-		const hasDelete = diffResult.some( item => item === 'delete' );
-		const hasInsert = diffResult.some( item => item === 'insert' );
-
-		if ( hasDelete && !hasInsert ) {
-			return true;
-		}
-	}
-
-	return false;
-}
-
-// Whether provided array contains only nodes of `containerElement` type.
-//
-// @private
-// @param {Array.<module:engine/view/node~Node>} children
-// @returns {Boolean}
-function hasOnlyContainers( children ) {
-	return children.every( child => child.is( 'containerElement' ) );
-}

+ 9 - 2
packages/ckeditor5-typing/src/utils/injectunsafekeystrokeshandling.js

@@ -8,6 +8,7 @@
  */
 
 import { getCode } from '@ckeditor/ckeditor5-utils/src/keyboard';
+import env from '@ckeditor/ckeditor5-utils/src/env';
 
 /**
  * Handles keystrokes which are unsafe for typing. This handler's logic is explained
@@ -22,7 +23,13 @@ export default function injectUnsafeKeystrokesHandling( editor ) {
 	const view = editor.editing.view;
 	const inputCommand = editor.commands.get( 'input' );
 
-	view.document.on( 'keydown', ( evt, evtData ) => handleKeydown( evtData ), { priority: 'lowest' } );
+	// For Android, we want to handle keystrokes on `beforeinput` to be sure that code in `DeleteObserver` already had a chance to be fired.
+	/* istanbul ignore if */
+	if ( env.isAndroid ) {
+		view.document.on( 'beforeinput', ( evt, evtData ) => handleUnsafeKeystroke( evtData ), { priority: 'lowest' } );
+	} else {
+		view.document.on( 'keydown', ( evt, evtData ) => handleUnsafeKeystroke( evtData ), { priority: 'lowest' } );
+	}
 
 	view.document.on( 'compositionstart', handleCompositionStart, { priority: 'lowest' } );
 
@@ -42,7 +49,7 @@ export default function injectUnsafeKeystrokesHandling( editor ) {
 	// to handle the event.
 	//
 	// @param {module:engine/view/observer/keyobserver~KeyEventData} evtData
-	function handleKeydown( evtData ) {
+	function handleUnsafeKeystroke( evtData ) {
 		const doc = model.document;
 		const isComposing = view.document.isComposing;
 		const isSelectionUnchanged = latestCompositionSelection && latestCompositionSelection.isEqual( doc.selection );

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

@@ -60,6 +60,33 @@ describe( 'Delete feature', () => {
 		expect( spy.calledWithMatch( 'delete', { unit: 'character', sequence: 5 } ) ).to.be.true;
 	} );
 
+	it( 'passes options.selection parameter to delete command if selection to remove was specified', () => {
+		editor.setData( '<p>Foobar</p>' );
+
+		const spy = editor.execute = sinon.spy();
+		const view = editor.editing.view;
+		const viewDocument = view.document;
+		const domEvt = getDomEvent();
+
+		const viewSelection = view.createSelection( view.createRangeIn( viewDocument.getRoot() ) );
+
+		viewDocument.fire( 'delete', new DomEventData( viewDocument, domEvt, {
+			direction: 'backward',
+			unit: 'character',
+			sequence: 1,
+			selectionToRemove: viewSelection
+		} ) );
+
+		expect( spy.calledOnce ).to.be.true;
+
+		const commandName = spy.args[ 0 ][ 0 ];
+		const options = spy.args[ 0 ][ 1 ];
+		const expectedSelection = editor.model.createSelection( editor.model.createRangeIn( editor.model.document.getRoot() ) );
+
+		expect( commandName ).to.equal( 'delete' );
+		expect( options.selection.isEqual( expectedSelection ) ).to.be.true;
+	} );
+
 	it( 'scrolls the editing document to the selection after executing the command', () => {
 		const scrollSpy = sinon.stub( editor.editing.view, 'scrollToTheSelection' );
 		const executeSpy = editor.execute = sinon.spy();

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

@@ -117,6 +117,8 @@ describe( 'DeleteObserver', () => {
 				viewDocument.fire( 'keydown', new DomEventData( viewDocument, getDomEvent(), {
 					keyCode: getCode( 'delete' )
 				} ) );
+
+				viewDocument.fire( 'input', getDomEvent() );
 			}
 
 			expect( spy.callCount ).to.equal( 5 );
@@ -138,6 +140,8 @@ describe( 'DeleteObserver', () => {
 				viewDocument.fire( 'keydown', new DomEventData( viewDocument, getDomEvent(), {
 					keyCode: getCode( 'delete' )
 				} ) );
+
+				viewDocument.fire( 'input', getDomEvent() );
 			}
 
 			// Then the user has released the key.
@@ -150,6 +154,8 @@ describe( 'DeleteObserver', () => {
 				keyCode: getCode( 'delete' )
 			} ) );
 
+			viewDocument.fire( 'input', getDomEvent() );
+
 			expect( spy.callCount ).to.equal( 4 );
 
 			expect( spy.args[ 0 ][ 1 ] ).to.have.property( 'sequence', 1 );
@@ -167,6 +173,8 @@ describe( 'DeleteObserver', () => {
 				keyCode: getCode( 'backspace' )
 			} ) );
 
+			viewDocument.fire( 'input', getDomEvent() );
+
 			viewDocument.fire( 'keyup', new DomEventData( viewDocument, getDomEvent(), {
 				keyCode: getCode( 'backspace' )
 			} ) );
@@ -175,6 +183,8 @@ describe( 'DeleteObserver', () => {
 				keyCode: getCode( 'backspace' )
 			} ) );
 
+			viewDocument.fire( 'input', getDomEvent() );
+
 			expect( spy.callCount ).to.equal( 2 );
 
 			expect( spy.args[ 0 ][ 1 ] ).to.have.property( 'sequence', 1 );
@@ -190,6 +200,8 @@ describe( 'DeleteObserver', () => {
 				keyCode: getCode( 'delete' )
 			} ) );
 
+			viewDocument.fire( 'input', getDomEvent() );
+
 			viewDocument.fire( 'keyup', new DomEventData( viewDocument, getDomEvent(), {
 				keyCode: getCode( 'A' )
 			} ) );
@@ -198,6 +210,8 @@ describe( 'DeleteObserver', () => {
 				keyCode: getCode( 'delete' )
 			} ) );
 
+			viewDocument.fire( 'input', getDomEvent() );
+
 			expect( spy.args[ 0 ][ 1 ] ).to.have.property( 'sequence', 1 );
 			expect( spy.args[ 1 ][ 1 ] ).to.have.property( 'sequence', 2 );
 		} );

+ 0 - 373
packages/ckeditor5-typing/tests/utils/injectandroidbackspacemutationshandling.js

@@ -1,373 +0,0 @@
-/**
- * @license Copyright (c) 2003-2019, CKSource - Frederico Knabben. All rights reserved.
- * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
- */
-
-import ClassicTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/classictesteditor';
-import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils';
-import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
-import Heading from '@ckeditor/ckeditor5-heading/src/heading';
-import Undo from '@ckeditor/ckeditor5-undo/src/undo';
-import Italic from '@ckeditor/ckeditor5-basic-styles/src/italic';
-import Input from '../../src/input';
-import Delete from '../../src/delete';
-
-import ViewText from '@ckeditor/ckeditor5-engine/src/view/text';
-import ViewElement from '@ckeditor/ckeditor5-engine/src/view/element';
-
-import { getData as getModelData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
-import { getData as getViewData } from '@ckeditor/ckeditor5-engine/src/dev-utils/view';
-
-/* global document */
-
-describe( 'injectAndroidBackspaceMutationsHandling', () => {
-	let editor, model, modelRoot, view, viewDocument, viewRoot, mutationsSpy, dateNowStub;
-
-	testUtils.createSinonSandbox();
-
-	before( () => {
-		mutationsSpy = sinon.spy();
-	} );
-
-	beforeEach( () => {
-		const domElement = document.createElement( 'div' );
-		document.body.appendChild( domElement );
-
-		return ClassicTestEditor.create( domElement, { plugins: [ Input, Delete, Paragraph, Heading, Italic, Undo ] } )
-			.then( newEditor => {
-				editor = newEditor;
-				model = editor.model;
-				modelRoot = model.document.getRoot();
-				view = editor.editing.view;
-				viewDocument = view.document;
-				viewRoot = viewDocument.getRoot();
-
-				editor.setData( '<h2>Heading 1</h2><p>Paragraph</p><h3>Heading 2</h3>' );
-			} );
-	} );
-
-	afterEach( () => {
-		if ( dateNowStub ) {
-			dateNowStub.restore();
-			dateNowStub = null;
-		}
-
-		mutationsSpy.resetHistory();
-
-		return editor.destroy();
-	} );
-
-	it( 'should handle block merging', () => {
-		// 1. Set selection to '<h2>Heading 1</h2><p>{}Paragraph</p><h3>Heading 2</h3>'.
-		model.change( writer => {
-			writer.setSelection( modelRoot.getChild( 1 ), 0 );
-		} );
-
-		const modelContent = '<heading1>Heading 1</heading1><paragraph>[]Paragraph</paragraph><heading2>Heading 2</heading2>';
-		const viewContent = '<h2>Heading 1</h2><p>{}Paragraph</p><h3>Heading 2</h3>';
-
-		expect( getModelData( model ) ).to.equal( modelContent );
-		expect( getViewData( view ) ).to.equal( viewContent );
-
-		// 2. Create mutations which are result of changing HTML to '<h2>Heading 1{}Paragraph</h2><h3>Heading 2</h3>'.
-		const mutations = [ {
-			// `heading1` new text mutation
-			type: 'children',
-			newChildren: [ viewRoot.getChild( 0 ).getChild( 0 ) ],
-			oldChildren: [ viewRoot.getChild( 0 ).getChild( 0 ) ],
-			node: viewRoot.getChild( 0 )
-		}, {
-			// `paragraph` text removal mutation
-			type: 'children',
-			newChildren: [],
-			oldChildren: [ viewRoot.getChild( 1 ).getChild( 0 ) ],
-			node: viewRoot.getChild( 1 )
-		}, {
-			// `paragraph` removal mutation
-			type: 'children',
-			newChildren: [ viewRoot.getChild( 0 ) ],
-			oldChildren: [ viewRoot.getChild( 0 ), viewRoot.getChild( 1 ) ],
-			node: viewRoot
-		} ];
-
-		// 3. Simulate 'Backspace' flow on Android.
-		simulateBackspace( mutations );
-
-		expect( mutationsSpy.callCount ).to.equal( 0 );
-		expect( getModelData( model ) ).to.equal( '<heading1>Heading 1[]Paragraph</heading1><heading2>Heading 2</heading2>' );
-		expect( getViewData( view ) ).to.equal( '<h2>Heading 1{}Paragraph</h2><h3>Heading 2</h3>' );
-
-		// Due ot `Undo` issue the selection is after paragraph after undoing changes (ckeditor5-undo/issues/64).
-		expectContentAfterUndo(
-			'<heading1>Heading 1[]</heading1><paragraph>Paragraph</paragraph><heading2>Heading 2</heading2>',
-			'<h2>Heading 1{}</h2><p>Paragraph</p><h3>Heading 2</h3>' );
-	} );
-
-	it( 'should handle two entire blocks removal', () => {
-		// 1. Set selection to '<h2>{Heading 1</h2><p>Paragraph}</p><h3>Heading 2</h3>'.
-		model.change( writer => {
-			writer.setSelection( writer.createRange(
-				writer.createPositionAt( modelRoot.getChild( 0 ), 0 ), writer.createPositionAt( modelRoot.getChild( 1 ), 9 )
-			) );
-		} );
-
-		const modelContent = '<heading1>[Heading 1</heading1><paragraph>Paragraph]</paragraph><heading2>Heading 2</heading2>';
-		const viewContent = '<h2>{Heading 1</h2><p>Paragraph}</p><h3>Heading 2</h3>';
-
-		expect( getModelData( model ) ).to.equal( modelContent );
-		expect( getViewData( view ) ).to.equal( viewContent );
-
-		// 2. Create mutations which are result of changing HTML to '<h2>[]</h2><h3>Heading 2</h3>'.
-		const mutations = [ {
-			// `heading1` text removal mutation
-			type: 'children',
-			newChildren: [ new ViewElement( 'br' ) ],
-			oldChildren: [ viewRoot.getChild( 0 ).getChild( 0 ) ],
-			node: viewRoot.getChild( 0 )
-		}, {
-			// `paragraph` removal mutation
-			type: 'children',
-			newChildren: [ viewRoot.getChild( 0 ), viewRoot.getChild( 2 ) ],
-			oldChildren: [ viewRoot.getChild( 0 ), viewRoot.getChild( 1 ), viewRoot.getChild( 2 ) ],
-			node: viewRoot
-		} ];
-
-		// 3. Create selection which simulate Android behaviour where upon pressing `Backspace`
-		// selection is changed to `<h2>Heading 1</h2><p>Paragraph{}</p><h3>Heading 2</h3>`.
-		const newSelection = model.createRange( model.createPositionAt( modelRoot.getChild( 1 ), 9 ) );
-
-		// 4. Simulate 'Backspace' flow on Android.
-		simulateBackspace( mutations, newSelection );
-
-		expect( mutationsSpy.callCount ).to.equal( 0 );
-		expect( getModelData( model ) ).to.equal( '<heading1>[]</heading1><heading2>Heading 2</heading2>' );
-		expect( getViewData( view ) ).to.equal( '<h2>[]</h2><h3>Heading 2</h3>' );
-
-		expectContentAfterUndo(
-			'<heading1>[Heading 1</heading1><paragraph>Paragraph]</paragraph><heading2>Heading 2</heading2>',
-			'<h2>{Heading 1</h2><p>Paragraph}</p><h3>Heading 2</h3>' );
-	} );
-
-	it( 'should handle two partially selected blocks removal', () => {
-		// 1. Set selection to '<h2>Hea{ding 1</h2><p>Paragraph}</p><h3>Heading 2</h3>'.
-		model.change( writer => {
-			writer.setSelection( writer.createRange(
-				writer.createPositionAt( modelRoot.getChild( 0 ), 3 ), writer.createPositionAt( modelRoot.getChild( 1 ), 9 )
-			) );
-		} );
-
-		const modelContent = '<heading1>Hea[ding 1</heading1><paragraph>Paragraph]</paragraph><heading2>Heading 2</heading2>';
-		const viewContent = '<h2>Hea{ding 1</h2><p>Paragraph}</p><h3>Heading 2</h3>';
-
-		expect( getModelData( model ) ).to.equal( modelContent );
-		expect( getViewData( view ) ).to.equal( viewContent );
-
-		// 2. Create mutations which are result of changing HTML to '<h2>Hea{}</h2><h3>Heading 2</h3>'.
-		const mutations = [ {
-			// `heading1` text partial removal mutation
-			type: 'text',
-			newText: 'Hea',
-			oldText: 'Heading 1',
-			node: viewRoot.getChild( 0 ).getChild( 0 )
-		}, {
-			// `paragraph` removal mutation
-			type: 'children',
-			newChildren: [ viewRoot.getChild( 0 ), viewRoot.getChild( 2 ) ],
-			oldChildren: [ viewRoot.getChild( 0 ), viewRoot.getChild( 1 ), viewRoot.getChild( 2 ) ],
-			node: viewRoot
-		} ];
-
-		// 3. Create selection which simulate Android behaviour where upon pressing `Backspace`
-		// selection is changed to `<h2>Heading 1</h2><p>Paragraph{}</p><h3>Heading 2</h3>`.
-		const newSelection = model.createRange( model.createPositionAt( modelRoot.getChild( 1 ), 9 ) );
-
-		// 4. Simulate 'Backspace' flow on Android.
-		simulateBackspace( mutations, newSelection );
-
-		expect( mutationsSpy.callCount ).to.equal( 0 );
-		expect( getModelData( model ) ).to.equal( '<heading1>Hea[]</heading1><heading2>Heading 2</heading2>' );
-		expect( getViewData( view ) ).to.equal( '<h2>Hea{}</h2><h3>Heading 2</h3>' );
-
-		expectContentAfterUndo( modelContent, viewContent );
-	} );
-
-	it( 'should handle blocks removal if selection ends on the boundary of inline element', () => {
-		editor.setData( '<h2>Heading 1</h2><p>Paragraph</p><h3><em>Heading</em> 2</h3>' );
-
-		// 1. Set selection to '<h2>{Heading 1</h2><p>Paragraph</p><h3>]<i>Heading</i> 2</h3>'.
-		model.change( writer => {
-			writer.setSelection( writer.createRange(
-				writer.createPositionAt( modelRoot.getChild( 0 ), 0 ), writer.createPositionAt( modelRoot.getChild( 2 ), 0 )
-			) );
-		} );
-
-		const modelContent = '<heading1>[Heading 1</heading1><paragraph>Paragraph</paragraph>' +
-			'<heading2>]<$text italic="true">Heading</$text> 2</heading2>';
-		const viewContent = '<h2>{Heading 1</h2><p>Paragraph</p><h3>]<i>Heading</i> 2</h3>';
-
-		expect( getModelData( model ) ).to.equal( modelContent );
-		expect( getViewData( view ) ).to.equal( viewContent );
-
-		// 2. Create mutations which are result of changing HTML to '<h2><i>{}Heading</i> 2</h2>'.
-		const mutations = [ {
-			// `heading1` text to children mutation
-			type: 'children',
-			newChildren: Array.from( viewRoot.getChild( 2 ).getChildren() ),
-			oldChildren: [ viewRoot.getChild( 0 ).getChild( 0 ) ],
-			node: viewRoot.getChild( 0 )
-		}, {
-			// `heading2` children removal mutation
-			type: 'children',
-			newChildren: [],
-			oldChildren: Array.from( viewRoot.getChild( 2 ).getChildren() ),
-			node: viewRoot.getChild( 2 )
-		}, { // `paragraph` and `heading2` removal mutation
-			type: 'children',
-			newChildren: [ viewRoot.getChild( 0 ) ],
-			oldChildren: Array.from( viewRoot.getChildren() ),
-			node: viewRoot
-		} ];
-
-		// 3. Create selection which simulate Android behaviour where upon pressing `Backspace`
-		// selection is changed to `<h2>Heading 1</h2><p>Paragraph</p><h3><em>{}Heading</em> 2</h3>`.
-		const newSelection = model.createRange( model.createPositionAt( modelRoot.getChild( 2 ), 0 ) );
-
-		// 4. Simulate 'Backspace' flow on Android.
-		simulateBackspace( mutations, newSelection );
-
-		expect( mutationsSpy.callCount ).to.equal( 0 );
-		expect( getModelData( model ) ).to.equal( '<heading1><$text italic="true">[]Heading</$text> 2</heading1>' );
-		expect( getViewData( view ) ).to.equal( '<h2><i>{}Heading</i> 2</h2>' );
-
-		// https://github.com/ckeditor/ckeditor5-undo/issues/89
-		expectContentAfterUndo(
-			'<heading1>[Heading 1</heading1><paragraph>Paragraph]</paragraph><heading2><$text italic="true">Heading</$text> 2</heading2>',
-			'<h2>{Heading 1</h2><p>Paragraph}</p><h3><i>Heading</i> 2</h3>'
-		);
-	} );
-
-	it( 'should handle selection changed by the user before `backspace` on block merging', () => {
-		// 1. Stub `Date.now` so we can simulate user selection change timing.
-		let dateNowValue = 0;
-		dateNowStub = sinon.stub( Date, 'now' ).callsFake( () => {
-			dateNowValue += 500;
-			return dateNowValue;
-		} );
-
-		editor.setData( '<h2>Heading 1</h2><p>Paragraph</p><h3><em>Heading</em> 2</h3>' );
-
-		// 2. Set selection to '<h2>{Heading 1</h2><p>Paragraph</p><h3>]<i>Heading</i> 2</h3>'.
-		model.change( writer => {
-			writer.setSelection( writer.createRange(
-				writer.createPositionAt( modelRoot.getChild( 0 ), 0 ), writer.createPositionAt( modelRoot.getChild( 2 ), 0 )
-			) );
-		} );
-
-		const modelContent = '<heading1>[Heading 1</heading1><paragraph>Paragraph</paragraph>' +
-			'<heading2>]<$text italic="true">Heading</$text> 2</heading2>';
-		const viewContent = '<h2>{Heading 1</h2><p>Paragraph</p><h3>]<i>Heading</i> 2</h3>';
-
-		expect( getModelData( model ) ).to.equal( modelContent );
-		expect( getViewData( view ) ).to.equal( viewContent );
-
-		// 3. Create mutations which are result of changing HTML to '<h2>Heading 1</h2><p>Paragraph{}<i>Heading</i> 2</p>'.
-		// This is still a block container removal so 'injectAndroidBackspaceMutationsHandling' will get triggered.
-		const mutations = [ {
-			// `paragraph` children added mutation
-			type: 'children',
-			newChildren: [ viewRoot.getChild( 1 ).getChild( 0 ) ].concat( Array.from( viewRoot.getChild( 2 ).getChildren() ) ),
-			oldChildren: Array.from( viewRoot.getChild( 1 ).getChildren() ),
-			node: viewRoot.getChild( 1 )
-		}, {
-			// `heading2` children removal mutation
-			type: 'children',
-			newChildren: [],
-			oldChildren: Array.from( viewRoot.getChild( 2 ).getChildren() ),
-			node: viewRoot.getChild( 2 )
-		}, { // `heading2` removal mutation
-			type: 'children',
-			newChildren: [ viewRoot.getChild( 0 ), viewRoot.getChild( 1 ) ],
-			oldChildren: Array.from( viewRoot.getChildren() ),
-			node: viewRoot
-		} ];
-
-		// 4. Simulate user selection change which is identical as Android native change on 'Backspace'.
-		model.change( writer => {
-			writer.setSelection( modelRoot.getChild( 2 ), 0 );
-		} );
-
-		// 5. Simulate 'Backspace' flow on Android.
-		simulateBackspace( mutations );
-
-		expect( mutationsSpy.callCount ).to.equal( 0 );
-		expect( getModelData( model ) ).to.equal( '<heading1>Heading 1</heading1><paragraph>Paragraph[]' +
-			'<$text italic="true">Heading</$text> 2</paragraph>' );
-		expect( getViewData( view ) ).to.equal( '<h2>Heading 1</h2><p>Paragraph{}<i>Heading</i> 2</p>' );
-
-		// Due ot `Undo` issue the selection is after paragraph after undoing changes (ckeditor5-undo/issues/64).
-		expectContentAfterUndo( '<heading1>Heading 1</heading1><paragraph>Paragraph[]</paragraph>' +
-			'<heading2><$text italic="true">Heading</$text> 2</heading2>',
-		'<h2>Heading 1</h2><p>Paragraph{}</p><h3><i>Heading</i> 2</h3>' );
-	} );
-
-	it( 'should not be triggered for container insertion mutations', () => {
-		// 1. Set selection to '<h2>Heading 1</h2><p>Paragraph{}</p><h3>Heading 2</h3>'.
-		model.change( writer => {
-			writer.setSelection( modelRoot.getChild( 1 ), 9 );
-		} );
-
-		const modelContent = '<heading1>Heading 1</heading1><paragraph>Paragraph[]</paragraph><heading2>Heading 2</heading2>';
-		const viewContent = '<h2>Heading 1</h2><p>Paragraph{}</p><h3>Heading 2</h3>';
-
-		expect( getModelData( model ) ).to.equal( modelContent );
-		expect( getViewData( view ) ).to.equal( viewContent );
-
-		const viewFoo = new ViewText( 'foo' );
-		const viewP = new ViewElement( 'p', null, viewFoo );
-
-		// 2. Create mutations which are result of changing HTML to '<h2>Heading 1</h2><p>Paragraph{}</p><p>Foo</p><h3>Heading 2</h3>'.
-		const mutations = [ {
-			// new paragraph insertion mutation
-			type: 'children',
-			newChildren: [ viewRoot.getChild( 0 ), viewRoot.getChild( 1 ), viewP, viewRoot.getChild( 2 ) ],
-			oldChildren: Array.from( viewRoot.getChildren() ),
-			node: viewRoot.getChild( 0 )
-		} ];
-
-		// 3. Spy mutations listener calls. It should be called ones
-		// as it was not stopped by 'injectAndroidBackspaceMutationsHandling' handler.
-		viewDocument.on( 'mutations', mutationsSpy, { priority: 'lowest' } );
-
-		// 4. Fire mutations event.
-		viewDocument.fire( 'mutations', mutations );
-
-		expect( mutationsSpy.callCount ).to.equal( 1 );
-		expect( getModelData( model ) ).to.equal( modelContent );
-		expect( getViewData( view ) ).to.equal( viewContent );
-	} );
-
-	function simulateBackspace( mutations, newSelection ) {
-		// Spy mutations listener calls. If Android handler was triggered it should prevent further calls.
-		viewDocument.on( 'mutations', mutationsSpy, { priority: 'lowest' } );
-
-		// Simulate selection change on Android devices before `keydown` event.
-		if ( newSelection ) {
-			model.change( writer => {
-				writer.setSelection( newSelection );
-			} );
-		}
-
-		// Fire `keydown` event with `229` key code so it is consistent with what happens on Android devices.
-		viewDocument.fire( 'keydown', { keyCode: 229 } );
-
-		// Fire mutations event.
-		viewDocument.fire( 'mutations', mutations );
-	}
-
-	function expectContentAfterUndo( modelContent, viewContent ) {
-		editor.execute( 'undo' );
-
-		expect( getModelData( model ) ).to.equal( modelContent );
-		expect( getViewData( view ) ).to.equal( viewContent );
-	}
-} );