Browse Source

Merge pull request #154 from ckeditor/t/ckeditor5/2009

Fix: Use model-to-view position mapping in todo lists. Closes ckeditor/ckeditor5#2009. Closed ckeditor/ckeditor5#1980.
Piotrek Koszuliński 6 years ago
parent
commit
c660ce9a5e

+ 56 - 40
packages/ckeditor5-list/src/todolistconverters.js

@@ -9,7 +9,7 @@
 
 /* global document */
 
-import { generateLiInUl, injectViewList, findInRange } from './utils';
+import { generateLiInUl, injectViewList } from './utils';
 import createElement from '@ckeditor/ckeditor5-utils/src/dom/createelement';
 
 /**
@@ -60,41 +60,6 @@ export function modelViewInsertion( model, onCheckboxChecked ) {
 }
 
 /**
- * A model-to-view converter for the model `$text` element inside a to-do list item.
- *
- * It takes care of creating text after the {@link module:engine/view/uielement~UIElement checkbox UI element}.
- *
- * It is used by {@link module:engine/controller/editingcontroller~EditingController}.
- *
- * @see module:engine/conversion/downcastdispatcher~DowncastDispatcher#event:insert
- * @param {module:utils/eventinfo~EventInfo} evt An object containing information about the fired event.
- * @param {Object} data Additional information about the change.
- * @param {module:engine/conversion/downcastdispatcher~DowncastConversionApi} conversionApi Conversion interface.
- */
-export function modelViewTextInsertion( evt, data, conversionApi ) {
-	const parent = data.range.start.parent;
-
-	if ( parent.name != 'listItem' || parent.getAttribute( 'listType' ) != 'todo' ) {
-		return;
-	}
-
-	if ( !conversionApi.consumable.consume( data.item, 'insert' ) ) {
-		return;
-	}
-
-	const viewWriter = conversionApi.writer;
-	const viewPosition = conversionApi.mapper.toViewPosition( data.range.start );
-	const viewText = viewWriter.createText( data.item.data );
-
-	// Be sure text is created after the UIElement, so if it is a first text node inside a `listItem` element
-	// it has to be moved after the first node in the view list item.
-	//
-	// model: <listItem listtype="todo">[foo]</listItem>
-	// view: <li>^<checkbox/></li> -> <li><checkbox/>foo</li>
-	viewWriter.insert( viewPosition.offset ? viewPosition : viewPosition.getShiftedBy( 1 ), viewText );
-}
-
-/**
  * A model-to-view converter for the `listItem` model element insertion.
  *
  * It is used by {@link module:engine/controller/datacontroller~DataController}.
@@ -233,7 +198,7 @@ export function dataViewModelCheckmarkInsertion( evt, data, conversionApi ) {
  * @param {Function} onCheckedChange Callback fired after clicking the checkbox UI element.
  * @returns {Function} Returns a conversion callback.
  */
-export function modelViewChangeType( onCheckedChange ) {
+export function modelViewChangeType( onCheckedChange, view ) {
 	return ( evt, data, conversionApi ) => {
 		const viewItem = conversionApi.mapper.toViewElement( data.item );
 		const viewWriter = conversionApi.writer;
@@ -246,7 +211,7 @@ export function modelViewChangeType( onCheckedChange ) {
 			viewWriter.insert( viewWriter.createPositionAt( viewItem, 0 ), checkmarkElement );
 		} else if ( data.attributeOldValue == 'todo' ) {
 			viewWriter.removeClass( 'todo-list', viewItem.parent );
-			viewWriter.remove( viewItem.getChild( 0 ) );
+			viewWriter.remove( findLabel( viewItem, view ) );
 		}
 	};
 }
@@ -277,8 +242,8 @@ export function modelViewChangeChecked( onCheckedChange ) {
 		const { mapper, writer: viewWriter } = conversionApi;
 		const isChecked = !!data.item.getAttribute( 'todoListChecked' );
 		const viewItem = mapper.toViewElement( data.item );
-		const itemRange = viewWriter.createRangeIn( viewItem );
-		const oldCheckmarkElement = findInRange( itemRange, item => item.is( 'uiElement' ) ? item : false );
+		// Because of m -> v position mapper we can be sure checkbox is always at the beginning.
+		const oldCheckmarkElement = viewItem.getChild( 0 );
 		const newCheckmarkElement = createCheckmarkElement( data.item, viewWriter, isChecked, onCheckedChange );
 
 		viewWriter.insert( viewWriter.createPositionAfter( oldCheckmarkElement ), newCheckmarkElement );
@@ -286,6 +251,46 @@ export function modelViewChangeChecked( onCheckedChange ) {
 	};
 }
 
+/**
+ * A model-to-view position at zero offset mapper.
+ *
+ * This helper ensures that position inside todo-list in the view is mapped after the checkbox.
+ *
+ * It only handles the position at the beginning of a list item as other positions are properly mapped be the default mapper.
+ *
+ * @param {module:engine/view/view~View} view
+ * @param {module:engine/conversion/mapper~Mapper} mapper
+ * @return {Function}
+ */
+export function mapModelToViewZeroOffsetPosition( view, mapper ) {
+	return ( evt, data ) => {
+		const modelPosition = data.modelPosition;
+		const parent = modelPosition.parent;
+
+		// Handle only position at the beginning of a todo list item.
+		if ( !parent.is( 'listItem' ) || parent.getAttribute( 'listType' ) != 'todo' || modelPosition.offset !== 0 ) {
+			return;
+		}
+
+		const viewLi = mapper.toViewElement( parent );
+		const label = findLabel( viewLi, view );
+
+		// If there is no label then most probably the default converter was overridden.
+		if ( !label ) {
+			return;
+		}
+
+		// Map the position to the next sibling (if it is not a marker) - most likely it will be a text node...
+		if ( label.nextSibling && !label.nextSibling.is( 'uiElement' ) ) {
+			data.viewPosition = view.createPositionAt( label.nextSibling, 0 );
+		}
+		// ... otherwise return position after the label.
+		else {
+			data.viewPosition = view.createPositionAfter( label );
+		}
+	};
+}
+
 // Creates a checkbox UI element.
 //
 // @private
@@ -321,3 +326,14 @@ function createCheckmarkElement( modelItem, viewWriter, isChecked, onChange ) {
 
 	return uiElement;
 }
+
+// Helper method to find label element inside li.
+function findLabel( viewItem, view ) {
+	const range = view.createRangeIn( viewItem );
+
+	for ( const value of range ) {
+		if ( value.item.is( 'uiElement', 'label' ) ) {
+			return value.item;
+		}
+	}
+}

+ 6 - 129
packages/ckeditor5-list/src/todolistediting.js

@@ -14,17 +14,15 @@ import TodoListCheckCommand from './todolistcheckcommand';
 import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
 
 import {
-	modelViewInsertion,
-	modelViewTextInsertion,
 	dataModelViewInsertion,
 	dataModelViewTextInsertion,
 	dataViewModelCheckmarkInsertion,
+	mapModelToViewZeroOffsetPosition,
 	modelViewChangeChecked,
-	modelViewChangeType
+	modelViewChangeType,
+	modelViewInsertion
 } from './todolistconverters';
 
-import { findInRange } from './utils';
-
 /**
  * The engine of the to-do list feature. It handles creating, editing and removing to-do lists and their items.
  *
@@ -47,7 +45,6 @@ export default class TodoListEditing extends Plugin {
 	init() {
 		const editor = this.editor;
 		const { editing, data, model } = editor;
-		const viewDocument = editing.view.document;
 
 		// Extend schema.
 		model.schema.extend( 'listItem', {
@@ -73,43 +70,21 @@ export default class TodoListEditing extends Plugin {
 			modelViewInsertion( model, listItem => this._handleCheckmarkChange( listItem ) ),
 			{ priority: 'high' }
 		);
-		editing.downcastDispatcher.on( 'insert:$text', modelViewTextInsertion, { priority: 'high' } );
 		data.downcastDispatcher.on( 'insert:listItem', dataModelViewInsertion( model ), { priority: 'high' } );
 		data.downcastDispatcher.on( 'insert:$text', dataModelViewTextInsertion, { priority: 'high' } );
 
 		editing.downcastDispatcher.on(
 			'attribute:listType:listItem',
-			modelViewChangeType( listItem => this._handleCheckmarkChange( listItem ) )
+			modelViewChangeType( listItem => this._handleCheckmarkChange( listItem ), editing.view )
 		);
 		editing.downcastDispatcher.on(
 			'attribute:todoListChecked:listItem',
 			modelViewChangeChecked( listItem => this._handleCheckmarkChange( listItem ) )
 		);
 
-		data.upcastDispatcher.on( 'element:input', dataViewModelCheckmarkInsertion, { priority: 'high' } );
-
-		// Collect all view nodes that have changed and use it to check if the checkbox UI element is going to
-		// be re-rendered. If yes than view post-fixer should verify view structure.
-		const changedViewNodes = new Set();
-
-		Array.from( viewDocument.roots ).forEach( watchRootForViewChildChanges );
-		this.listenTo( viewDocument.roots, 'add', ( evt, root ) => watchRootForViewChildChanges( root ) );
-
-		function watchRootForViewChildChanges( viewRoot ) {
-			viewRoot.on( 'change:children', ( evt, node ) => changedViewNodes.add( node ) );
-		}
-
-		// Move all uiElements after a checkbox element.
-		viewDocument.registerPostFixer( writer => {
-			const changedCheckmarkElements = getChangedCheckmarkElements( writer, changedViewNodes );
+		editing.mapper.on( 'modelToViewPosition', mapModelToViewZeroOffsetPosition( editing.view, editing.mapper ) );
 
-			changedViewNodes.clear();
-
-			return moveUIElementsAfterCheckmark( writer, changedCheckmarkElements );
-		} );
-
-		// Move selection after a checkbox element.
-		viewDocument.registerPostFixer( writer => moveSelectionAfterCheckmark( writer, viewDocument.selection ) );
+		data.upcastDispatcher.on( 'element:input', dataViewModelCheckmarkInsertion, { priority: 'high' } );
 
 		// Jump at the end of the previous node on left arrow key press, when selection is after the checkbox.
 		//
@@ -188,71 +163,6 @@ export default class TodoListEditing extends Plugin {
 	}
 }
 
-// Moves all UI elements in the to-do list item after the checkbox element.
-//
-// @private
-// @param {module:engine/view/downcastwriter~DowncastWriter} writer
-// @param {Array.<module:engine/view/uielement~UIElement>} uiElements
-// @returns {Boolean}
-function moveUIElementsAfterCheckmark( writer, uiElements ) {
-	let hasChanged = false;
-
-	for ( const uiElement of uiElements ) {
-		const listItem = findViewListItemAncestor( uiElement );
-		const positionAtListItem = writer.createPositionAt( listItem, 0 );
-		const positionBeforeUiElement = writer.createPositionBefore( uiElement );
-
-		if ( positionAtListItem.isEqual( positionBeforeUiElement ) ) {
-			continue;
-		}
-
-		const range = writer.createRange( positionAtListItem, positionBeforeUiElement );
-
-		for ( const item of Array.from( range.getItems() ) ) {
-			if ( item.is( 'uiElement' ) ) {
-				writer.move( writer.createRangeOn( item ), writer.createPositionAfter( uiElement ) );
-				hasChanged = true;
-			}
-		}
-	}
-
-	return hasChanged;
-}
-
-// Moves the selection in the to-do list item after the checkbox element.
-//
-// @private
-// @param {module:engine/view/downcastwriter~DowncastWriter} writer
-// @param {module:engine/view/documentselection~DocumentSelection} selection
-function moveSelectionAfterCheckmark( writer, selection ) {
-	if ( !selection.isCollapsed ) {
-		return false;
-	}
-
-	const positionToChange = selection.getFirstPosition();
-
-	if ( positionToChange.parent.name != 'li' || !positionToChange.parent.parent.hasClass( 'todo-list' ) ) {
-		return false;
-	}
-
-	const parentEndPosition = writer.createPositionAt( positionToChange.parent, 'end' );
-	const uiElement = findInRange( writer.createRange( positionToChange, parentEndPosition ), item => {
-		return ( item.is( 'uiElement' ) && item.hasClass( 'todo-list__checkmark' ) ) ? item : false;
-	} );
-
-	if ( uiElement && !positionToChange.isAfter( writer.createPositionBefore( uiElement ) ) ) {
-		const boundaries = writer.createRange( writer.createPositionAfter( uiElement ), parentEndPosition );
-		const text = findInRange( boundaries, item => item.is( 'textProxy' ) ? item.textNode : false );
-		const nextPosition = text ? writer.createPositionAt( text, 0 ) : parentEndPosition;
-
-		writer.setSelection( writer.createRange( nextPosition ), { isBackward: selection.isBackward } );
-
-		return true;
-	}
-
-	return false;
-}
-
 // Handles the left/right (LTR/RTL content) arrow key and moves the selection at the end of the previous block element
 // if the selection is just after the checkbox element. In other words, it jumps over the checkbox element when
 // moving the selection to the left/right (LTR/RTL).
@@ -280,36 +190,3 @@ function jumpOverCheckmarkOnSideArrowKeyPress( stopKeyEvent, model ) {
 		}
 	}
 }
-
-// Gets the list of all checkbox elements that are going to be rendered.
-//
-// @private
-// @param {module:engine/view/view~View>} editingView
-// @param {Set.<module:engine/view/element~Element>} changedViewNodes
-// @returns {Array.<module:engine/view/uielement~UIElement>}
-function getChangedCheckmarkElements( editingView, changedViewNodes ) {
-	const elements = [];
-
-	for ( const element of changedViewNodes ) {
-		for ( const item of editingView.createRangeIn( element ).getItems() ) {
-			if ( item.is( 'uiElement' ) && item.hasClass( 'todo-list__checkmark' ) && !elements.includes( item ) && element.document ) {
-				elements.push( item );
-			}
-		}
-	}
-
-	return elements;
-}
-
-// Returns the list item ancestor of a given element.
-//
-// @private
-// @param {module:engine/view/item~Item} item
-// @returns {module:engine/view/element~Element}
-function findViewListItemAncestor( item ) {
-	for ( const parent of item.getAncestors( { parentFirst: true } ) ) {
-		if ( parent.name == 'li' ) {
-			return parent;
-		}
-	}
-}

+ 0 - 10
packages/ckeditor5-list/src/utils.js

@@ -216,16 +216,6 @@ export function getSiblingListItem( modelItem, options ) {
 	return null;
 }
 
-export function findInRange( range, comparator ) {
-	for ( const item of range.getItems() ) {
-		const result = comparator( item );
-
-		if ( result ) {
-			return result;
-		}
-	}
-}
-
 /**
  * Helper method for creating a UI button and linking it with an appropriate command.
  *

+ 3 - 2
packages/ckeditor5-list/tests/manual/todo-list.js

@@ -11,6 +11,7 @@ import Typing from '@ckeditor/ckeditor5-typing/src/typing';
 import Heading from '@ckeditor/ckeditor5-heading/src/heading';
 import Bold from '@ckeditor/ckeditor5-basic-styles/src/bold';
 import Highlight from '@ckeditor/ckeditor5-highlight/src/highlight';
+import Link from '@ckeditor/ckeditor5-link/src/link';
 import Table from '@ckeditor/ckeditor5-table/src/table';
 import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
 import Undo from '@ckeditor/ckeditor5-undo/src/undo';
@@ -21,13 +22,13 @@ import TodoList from '../../src/todolist';
 
 ClassicEditor
 	.create( document.querySelector( '#editor' ), {
-		plugins: [ Enter, Typing, Heading, Highlight, Table, Bold, Paragraph, Undo, List, TodoList, Clipboard, FontSize ],
+		plugins: [ Enter, Typing, Heading, Highlight, Table, Bold, Paragraph, Undo, List, TodoList, Clipboard, Link, FontSize ],
 		toolbar: [
 			'heading',
 			'|',
 			'bulletedList', 'numberedList', 'todoList',
 			'|',
-			'bold', 'highlight', 'insertTable', 'fontSize',
+			'bold', 'link', 'highlight', 'insertTable', 'fontSize',
 			'|',
 			'undo', 'redo'
 		],

+ 194 - 121
packages/ckeditor5-list/tests/todolistediting.js

@@ -12,12 +12,14 @@ import ListCommand from '../src/listcommand';
 import TodoListCheckCommand from '../src/todolistcheckcommand';
 import ModelElement from '@ckeditor/ckeditor5-engine/src/model/element';
 import InlineEditableUIView from '@ckeditor/ckeditor5-ui/src/editableui/inline/inlineeditableuiview';
+import LinkEditing from '@ckeditor/ckeditor5-link/src/linkediting';
 
 import VirtualTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/virtualtesteditor';
 import ClassicTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/classictesteditor';
 import { getData as getModelData, setData as setModelData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
 import { getData as getViewData } from '@ckeditor/ckeditor5-engine/src/dev-utils/view';
 import { getCode } from '@ckeditor/ckeditor5-utils/src/keyboard';
+import { assertEqualMarkup } from '@ckeditor/ckeditor5-utils/tests/_utils/utils';
 
 /* global Event, document */
 
@@ -27,7 +29,7 @@ describe( 'TodoListEditing', () => {
 	beforeEach( () => {
 		return VirtualTestEditor
 			.create( {
-				plugins: [ TodoListEditing, Typing, BoldEditing, BlockQuoteEditing ]
+				plugins: [ TodoListEditing, Typing, BoldEditing, BlockQuoteEditing, LinkEditing ]
 			} )
 			.then( newEditor => {
 				editor = newEditor;
@@ -77,13 +79,13 @@ describe( 'TodoListEditing', () => {
 		} );
 
 		it( 'should create to-do list item and change to paragraph in normal usage flow', () => {
-			expect( getViewData( view ) ).to.equal( '<p>[]</p>' );
-			expect( getModelData( model ) ).to.equal( '<paragraph>[]</paragraph>' );
+			assertEqualMarkup( getViewData( view ), '<p>[]</p>' );
+			assertEqualMarkup( getModelData( model ), '<paragraph>[]</paragraph>' );
 
 			editor.execute( 'todoList' );
 
-			expect( getModelData( model ) ).to.equal( '<listItem listIndent="0" listType="todo">[]</listItem>' );
-			expect( getViewData( view ) ).to.equal(
+			assertEqualMarkup( getModelData( model ), '<listItem listIndent="0" listType="todo">[]</listItem>' );
+			assertEqualMarkup( getViewData( view ),
 				'<ul class="todo-list">' +
 					'<li><label class="todo-list__checkmark" contenteditable="false"></label>[]</li>' +
 				'</ul>'
@@ -91,8 +93,8 @@ describe( 'TodoListEditing', () => {
 
 			editor.execute( 'input', { text: 'a' } );
 
-			expect( getModelData( model ) ).to.equal( '<listItem listIndent="0" listType="todo">a[]</listItem>' );
-			expect( getViewData( view ) ).to.equal(
+			assertEqualMarkup( getModelData( model ), '<listItem listIndent="0" listType="todo">a[]</listItem>' );
+			assertEqualMarkup( getViewData( view ),
 				'<ul class="todo-list">' +
 					'<li><label class="todo-list__checkmark" contenteditable="false"></label>a{}</li>' +
 				'</ul>'
@@ -100,8 +102,8 @@ describe( 'TodoListEditing', () => {
 
 			editor.execute( 'input', { text: 'b' } );
 
-			expect( getModelData( model ) ).to.equal( '<listItem listIndent="0" listType="todo">ab[]</listItem>' );
-			expect( getViewData( view ) ).to.equal(
+			assertEqualMarkup( getModelData( model ), '<listItem listIndent="0" listType="todo">ab[]</listItem>' );
+			assertEqualMarkup( getViewData( view ),
 				'<ul class="todo-list">' +
 					'<li><label class="todo-list__checkmark" contenteditable="false"></label>ab{}</li>' +
 				'</ul>'
@@ -109,8 +111,8 @@ describe( 'TodoListEditing', () => {
 
 			editor.execute( 'todoList' );
 
-			expect( getModelData( model ) ).to.equal( '<paragraph>ab[]</paragraph>' );
-			expect( getViewData( view ) ).to.equal( '<p>ab{}</p>' );
+			assertEqualMarkup( getModelData( model ), '<paragraph>ab[]</paragraph>' );
+			assertEqualMarkup( getViewData( view ), '<p>ab{}</p>' );
 		} );
 
 		it( 'should register todoListCheck command', () => {
@@ -125,7 +127,7 @@ describe( 'TodoListEditing', () => {
 				'<listItem listType="todo" listIndent="0" todoListChecked="true">2</listItem>'
 			);
 
-			expect( getViewData( view ) ).to.equal(
+			assertEqualMarkup( getViewData( view ),
 				'<ul class="todo-list">' +
 					'<li><label class="todo-list__checkmark" contenteditable="false"></label>{}1</li>' +
 					'<li><label class="todo-list__checkmark todo-list__checkmark_checked" contenteditable="false"></label>2</li>' +
@@ -143,7 +145,7 @@ describe( 'TodoListEditing', () => {
 				'<listItem listType="todo" listIndent="1">6.1</listItem>'
 			);
 
-			expect( getViewData( view ) ).to.equal(
+			assertEqualMarkup( getViewData( view ),
 				'<ul class="todo-list">' +
 					'<li>' +
 						'<label class="todo-list__checkmark" contenteditable="false"></label>{}1.0' +
@@ -172,7 +174,7 @@ describe( 'TodoListEditing', () => {
 				'<listItem listType="todo" listIndent="1">5.1</listItem>'
 			);
 
-			expect( getViewData( view ) ).to.equal(
+			assertEqualMarkup( getViewData( view ),
 				'<ul class="todo-list">' +
 					'<li><label class="todo-list__checkmark" contenteditable="false"></label>{}1.0</li>' +
 				'</ul>' +
@@ -201,7 +203,7 @@ describe( 'TodoListEditing', () => {
 				'<listItem listType="todo" listIndent="1">5.1</listItem>'
 			);
 
-			expect( getViewData( view ) ).to.equal(
+			assertEqualMarkup( getViewData( view ),
 				'<ul class="todo-list">' +
 					'<li><label class="todo-list__checkmark" contenteditable="false"></label>{}1.0</li>' +
 				'</ul>' +
@@ -230,7 +232,7 @@ describe( 'TodoListEditing', () => {
 
 			editor.execute( 'todoList' );
 
-			expect( getViewData( view ) ).to.equal(
+			assertEqualMarkup( getViewData( view ),
 				'<ol>' +
 					'<li>1.0</li>' +
 				'</ol>' +
@@ -252,7 +254,7 @@ describe( 'TodoListEditing', () => {
 
 			editor.execute( 'numberedList' );
 
-			expect( getViewData( view ) ).to.equal(
+			assertEqualMarkup( getViewData( view ),
 				'<ul class="todo-list">' +
 					'<li><label class="todo-list__checkmark" contenteditable="false"></label>1.0</li>' +
 				'</ul>' +
@@ -274,7 +276,7 @@ describe( 'TodoListEditing', () => {
 
 			editor.execute( 'bulletedList' );
 
-			expect( getViewData( view ) ).to.equal(
+			assertEqualMarkup( getViewData( view ),
 				'<ul class="todo-list">' +
 					'<li><label class="todo-list__checkmark" contenteditable="false"></label>1.0</li>' +
 				'</ul>' +
@@ -287,10 +289,46 @@ describe( 'TodoListEditing', () => {
 			);
 		} );
 
+		it( 'should properly convert list type change - inner text with attribute', () => {
+			setModelData( model,
+				'<listItem listType="todo" listIndent="0">1[.0</listItem>' +
+				'<listItem listType="todo" listIndent="0"><$text bold="true">2.0</$text></listItem>' +
+				'<listItem listType="todo" listIndent="0">3.]0</listItem>'
+			);
+
+			editor.execute( 'bulletedList' );
+
+			assertEqualMarkup( getViewData( view ),
+				'<ul>' +
+				'<li>1{.0</li>' +
+				'<li><strong>2.0</strong></li>' +
+				'<li>3.}0</li>' +
+				'</ul>'
+			);
+		} );
+
+		it( 'should properly convert list type change - inner text with many attributes', () => {
+			setModelData( model,
+				'<listItem listType="todo" listIndent="0">1[.0</listItem>' +
+				'<listItem listType="todo" listIndent="0"><$text bold="true" linkHref="foo">2.0</$text></listItem>' +
+				'<listItem listType="todo" listIndent="0">3.]0</listItem>'
+			);
+
+			editor.execute( 'bulletedList' );
+
+			assertEqualMarkup( getViewData( view ),
+				'<ul>' +
+				'<li>1{.0</li>' +
+				'<li><a href="foo"><strong>2.0</strong></a></li>' +
+				'<li>3.}0</li>' +
+				'</ul>'
+			);
+		} );
+
 		it( 'should convert todoListChecked attribute change', () => {
 			setModelData( model, '<listItem listType="todo" listIndent="0">1.0</listItem>' );
 
-			expect( getViewData( view ) ).to.equal(
+			assertEqualMarkup( getViewData( view ),
 				'<ul class="todo-list">' +
 					'<li><label class="todo-list__checkmark" contenteditable="false"></label>{}1.0</li>' +
 				'</ul>'
@@ -300,7 +338,7 @@ describe( 'TodoListEditing', () => {
 				writer.setAttribute( 'todoListChecked', true, modelRoot.getChild( 0 ) );
 			} );
 
-			expect( getViewData( view ) ).to.equal(
+			assertEqualMarkup( getViewData( view ),
 				'<ul class="todo-list">' +
 					'<li><label class="todo-list__checkmark todo-list__checkmark_checked" contenteditable="false"></label>{}1.0</li>' +
 				'</ul>'
@@ -310,7 +348,7 @@ describe( 'TodoListEditing', () => {
 				writer.setAttribute( 'todoListChecked', false, modelRoot.getChild( 0 ) );
 			} );
 
-			expect( getViewData( view ) ).to.equal(
+			assertEqualMarkup( getViewData( view ),
 				'<ul class="todo-list">' +
 					'<li><label class="todo-list__checkmark" contenteditable="false"></label>{}1.0</li>' +
 				'</ul>'
@@ -325,7 +363,7 @@ describe( 'TodoListEditing', () => {
 
 			editor.execute( 'bulletedList' );
 
-			expect( getModelData( model ) ).to.equal(
+			assertEqualMarkup( getModelData( model ),
 				'<listItem listIndent="0" listType="bulleted">f[oo</listItem>' +
 				'<listItem listIndent="0" listType="bulleted">fo]o</listItem>'
 			);
@@ -369,10 +407,127 @@ describe( 'TodoListEditing', () => {
 			}, { priority: 'highest' } );
 
 			setModelData( model, '<listItem listType="todo" listIndent="0">Foo</listItem>' );
-			expect( getViewData( view ) ).to.equal( '<test>{}Foo</test>' );
+			assertEqualMarkup( getViewData( view ), '<test>{}Foo</test>' );
 
 			model.change( writer => writer.setAttribute( 'todoListChecked', true, modelRoot.getChild( 0 ) ) );
-			expect( getViewData( view ) ).to.equal( '<test class="checked">{}Foo</test>' );
+			assertEqualMarkup( getViewData( view ), '<test class="checked">{}Foo</test>' );
+		} );
+
+		it( 'should render selection after checkmark element in the first text node', () => {
+			setModelData( model, '<listItem listType="todo" listIndent="0">Foo</listItem>' );
+
+			assertEqualMarkup( getViewData( view ),
+				'<ul class="todo-list">' +
+					'<li><label class="todo-list__checkmark" contenteditable="false"></label>{}Foo</li>' +
+				'</ul>'
+			);
+		} );
+
+		it( 'should render selection after checkmark element when list item does not contain any text nodes', () => {
+			setModelData( model, '<listItem listType="todo" listIndent="0">[]</listItem>' );
+
+			assertEqualMarkup( getViewData( view ),
+				'<ul class="todo-list">' +
+					'<li><label class="todo-list__checkmark" contenteditable="false"></label>[]</li>' +
+				'</ul>'
+			);
+		} );
+
+		it( 'should render marker UIElements after the checkmark element', () => {
+			setModelData( model,
+				'<listItem listType="todo" listIndent="0">[]foo</listItem>' +
+				'<listItem listType="todo" listIndent="0">bar</listItem>'
+			);
+
+			editor.conversion.for( 'downcast' ).markerToElement( {
+				model: 'element1',
+				view: ( data, writer ) => writer.createUIElement( 'element1' )
+			} );
+
+			editor.conversion.for( 'downcast' ).markerToElement( {
+				model: 'element2',
+				view: ( data, writer ) => writer.createUIElement( 'element2' )
+			} );
+
+			editor.conversion.for( 'downcast' ).markerToHighlight( {
+				model: 'highlight',
+				view: { classes: 'highlight' }
+			} );
+			model.change( writer => {
+				writer.addMarker( 'element1', {
+					range: writer.createRange( writer.createPositionAt( modelRoot.getChild( 0 ), 0 ) ),
+					usingOperation: false
+				} );
+
+				writer.addMarker( 'element2', {
+					range: writer.createRange( writer.createPositionAt( modelRoot.getChild( 0 ), 0 ) ),
+					usingOperation: false
+				} );
+
+				writer.addMarker( 'highlight', {
+					range: writer.createRangeIn( modelRoot.getChild( 0 ) ),
+					usingOperation: false
+				} );
+			} );
+
+			assertEqualMarkup( getViewData( view ),
+				'<ul class="todo-list">' +
+					'<li>' +
+						'<label class="todo-list__checkmark" contenteditable="false"></label>' +
+						'[]<span class="highlight">' +
+							'<element2></element2>' +
+							'<element1></element1>' +
+							'foo' +
+						'</span>' +
+					'</li>' +
+					'<li>' +
+						'<label class="todo-list__checkmark" contenteditable="false"></label>bar' +
+					'</li>' +
+				'</ul>'
+			);
+
+			// CC.
+			editor.execute( 'todoListCheck' );
+		} );
+
+		it( 'should properly handle typing inside text node with attribute', () => {
+			setModelData( model, '<listItem listType="todo" listIndent="0"><$text bold="true">[]foo</$text></listItem>' );
+
+			editor.execute( 'input', { text: 'b' } );
+
+			assertEqualMarkup( getModelData( model ),
+				'<listItem listIndent="0" listType="todo"><$text bold="true">b[]foo</$text></listItem>'
+			);
+
+			assertEqualMarkup( getViewData( view ),
+				'<ul class="todo-list">' +
+					'<li>' +
+						'<label class="todo-list__checkmark" contenteditable="false"></label>' +
+						'<strong>b{}foo</strong>' +
+					'</li>' +
+				'</ul>'
+			);
+		} );
+
+		it( 'should properly handle typing inside text node with many attributes', () => {
+			setModelData( model,
+				'<listItem listType="todo" listIndent="0"><$text bold="true" linkHref="foo">[]foo</$text></listItem>'
+			);
+
+			editor.execute( 'input', { text: 'b' } );
+
+			assertEqualMarkup( getModelData( model ),
+				'<listItem listIndent="0" listType="todo"><$text bold="true" linkHref="foo">b[]foo</$text></listItem>'
+			);
+
+			assertEqualMarkup( getViewData( view ),
+				'<ul class="todo-list">' +
+					'<li>' +
+						'<label class="todo-list__checkmark" contenteditable="false"></label>' +
+						'<a href="foo"><strong>b{}foo</strong></a>' +
+					'</li>' +
+				'</ul>'
+			);
 		} );
 	} );
 
@@ -548,7 +703,7 @@ describe( 'TodoListEditing', () => {
 		it( 'should convert li with checkbox before the first text node as to-do list item', () => {
 			editor.setData( '<ul><li><input type="checkbox">foo</li></ul>' );
 
-			expect( getModelData( model ) ).to.equal( '<listItem listIndent="0" listType="todo">[]foo</listItem>' );
+			assertEqualMarkup( getModelData( model ), '<listItem listIndent="0" listType="todo">[]foo</listItem>' );
 		} );
 
 		it( 'should convert li with checked checkbox as checked to-do list item', () => {
@@ -560,7 +715,7 @@ describe( 'TodoListEditing', () => {
 				'</ul>'
 			);
 
-			expect( getModelData( model ) ).to.equal(
+			assertEqualMarkup( getModelData( model ),
 				'<listItem listIndent="0" listType="todo" todoListChecked="true">[]a</listItem>' +
 				'<listItem listIndent="0" listType="todo" todoListChecked="true">b</listItem>' +
 				'<listItem listIndent="0" listType="todo" todoListChecked="true">c</listItem>'
@@ -570,13 +725,13 @@ describe( 'TodoListEditing', () => {
 		it( 'should not convert li with checkbox in the middle of the text', () => {
 			editor.setData( '<ul><li>Foo<input type="checkbox">Bar</li></ul>' );
 
-			expect( getModelData( model ) ).to.equal( '<listItem listIndent="0" listType="bulleted">[]FooBar</listItem>' );
+			assertEqualMarkup( getModelData( model ), '<listItem listIndent="0" listType="bulleted">[]FooBar</listItem>' );
 		} );
 
 		it( 'should convert li with checkbox wrapped by inline elements when checkbox is before the first text node', () => {
 			editor.setData( '<ul><li><label><input type="checkbox">Foo</label></li></ul>' );
 
-			expect( getModelData( model ) ).to.equal( '<listItem listIndent="0" listType="todo">[]Foo</listItem>' );
+			assertEqualMarkup( getModelData( model ), '<listItem listIndent="0" listType="todo">[]Foo</listItem>' );
 		} );
 
 		it( 'should split items with checkboxes - bulleted list', () => {
@@ -588,7 +743,7 @@ describe( 'TodoListEditing', () => {
 				'</ul>'
 			);
 
-			expect( getModelData( model ) ).to.equal(
+			assertEqualMarkup( getModelData( model ),
 				'<listItem listIndent="0" listType="bulleted">[]foo</listItem>' +
 				'<listItem listIndent="0" listType="todo">bar</listItem>' +
 				'<listItem listIndent="0" listType="bulleted">biz</listItem>'
@@ -604,7 +759,7 @@ describe( 'TodoListEditing', () => {
 				'</ol>'
 			);
 
-			expect( getModelData( model ) ).to.equal(
+			assertEqualMarkup( getModelData( model ),
 				'<listItem listIndent="0" listType="numbered">[]foo</listItem>' +
 				'<listItem listIndent="0" listType="todo">bar</listItem>' +
 				'<listItem listIndent="0" listType="numbered">biz</listItem>'
@@ -630,7 +785,7 @@ describe( 'TodoListEditing', () => {
 				'</ul>'
 			);
 
-			expect( getModelData( model ) ).to.equal(
+			assertEqualMarkup( getModelData( model ),
 				'<listItem listIndent="0" listType="bulleted">[]1.1</listItem>' +
 				'<listItem listIndent="1" listType="todo">2.2</listItem>' +
 				'<listItem listIndent="1" listType="todo">3.2</listItem>' +
@@ -673,7 +828,7 @@ describe( 'TodoListEditing', () => {
 				'</ul>'
 			);
 
-			expect( getModelData( model ) ).to.equal(
+			assertEqualMarkup( getModelData( model ),
 				'<listItem listIndent="0" listType="todo" todoListChecked="true">[]1.1</listItem>' +
 				'<listItem listIndent="1" listType="todo">2.2</listItem>' +
 				'<listItem listIndent="1" listType="todo" todoListChecked="true">3.2</listItem>' +
@@ -694,89 +849,7 @@ describe( 'TodoListEditing', () => {
 				'</ul>'
 			);
 
-			expect( getModelData( model ) ).to.equal( '<listItem listIndent="0" listType="numbered">[]foo</listItem>' );
-		} );
-	} );
-
-	describe( 'selection view post-fixer', () => {
-		it( 'should move selection after checkmark element to the first text node', () => {
-			setModelData( model, '<listItem listType="todo" listIndent="0">Foo</listItem>' );
-
-			expect( getViewData( view ) ).to.equal(
-				'<ul class="todo-list">' +
-					'<li><label class="todo-list__checkmark" contenteditable="false"></label>{}Foo</li>' +
-				'</ul>'
-			);
-		} );
-
-		it( 'should move selection after checkmark element when list item does not contain any text node', () => {
-			setModelData( model, '<listItem listType="todo" listIndent="0">[]</listItem>' );
-
-			expect( getViewData( view ) ).to.equal(
-				'<ul class="todo-list">' +
-					'<li><label class="todo-list__checkmark" contenteditable="false"></label>[]</li>' +
-				'</ul>'
-			);
-		} );
-	} );
-
-	describe( 'uiElements view post-fixer', () => {
-		it( 'should move all UIElements from before a checkmark after the checkmark element', () => {
-			setModelData( model,
-				'<listItem listType="todo" listIndent="0">[]foo</listItem>' +
-				'<listItem listType="todo" listIndent="0">bar</listItem>'
-			);
-
-			editor.conversion.for( 'downcast' ).markerToElement( {
-				model: 'element1',
-				view: ( data, writer ) => writer.createUIElement( 'element1' )
-			} );
-
-			editor.conversion.for( 'downcast' ).markerToElement( {
-				model: 'element2',
-				view: ( data, writer ) => writer.createUIElement( 'element2' )
-			} );
-
-			editor.conversion.for( 'downcast' ).markerToHighlight( {
-				model: 'highlight',
-				view: { classes: 'highlight' }
-			} );
-
-			model.change( writer => {
-				writer.addMarker( 'element1', {
-					range: writer.createRangeIn( writer.createPositionAt( modelRoot.getChild( 0 ), 0 ) ),
-					usingOperation: false
-				} );
-
-				writer.addMarker( 'element2', {
-					range: writer.createRangeIn( writer.createPositionAt( modelRoot.getChild( 0 ), 0 ) ),
-					usingOperation: false
-				} );
-
-				writer.addMarker( 'highlight', {
-					range: writer.createRangeIn( modelRoot.getChild( 0 ) ),
-					usingOperation: false
-				} );
-			} );
-
-			expect( getViewData( view ) ).to.equal(
-				'<ul class="todo-list">' +
-					'<li>' +
-						'<span class="highlight">' +
-							'<label class="todo-list__checkmark" contenteditable="false"></label>' +
-							'<element1></element1>' +
-							'<element2></element2>' +
-							'{}foo' +
-						'</span>' +
-					'</li>' +
-					'<li>' +
-						'<label class="todo-list__checkmark" contenteditable="false"></label>bar' +
-					'</li>' +
-				'</ul>'
-			);
-
-			// CC.
-			editor.execute( 'todoListCheck' );
+			assertEqualMarkup( getModelData( model ), '<listItem listIndent="0" listType="numbered">[]foo</listItem>' );
 		} );
 	} );
 
@@ -786,7 +859,7 @@ describe( 'TodoListEditing', () => {
 
 			editor.execute( 'todoList' );
 
-			expect( getModelData( model ) ).to.equal( '<paragraph>fo[]o</paragraph>' );
+			assertEqualMarkup( getModelData( model ), '<paragraph>fo[]o</paragraph>' );
 		} );
 	} );
 
@@ -888,7 +961,7 @@ describe( 'TodoListEditing', () => {
 
 				viewDoc.fire( 'keydown', domEvtDataStub );
 
-				expect( getModelData( model ) ).to.equal(
+				assertEqualMarkup( getModelData( model ),
 					'<blockQuote><paragraph>foo[]</paragraph></blockQuote>' +
 					'<listItem listIndent="0" listType="todo">bar</listItem>'
 				);
@@ -905,7 +978,7 @@ describe( 'TodoListEditing', () => {
 				sinon.assert.notCalled( domEvtDataStub.preventDefault );
 				sinon.assert.notCalled( domEvtDataStub.stopPropagation );
 
-				expect( getModelData( model ) ).to.equal( '<listItem listIndent="0" listType="todo">[]bar</listItem>' );
+				assertEqualMarkup( getModelData( model ), '<listItem listIndent="0" listType="todo">[]bar</listItem>' );
 			} );
 
 			it( 'should do nothing when selection is not collapsed', () => {
@@ -1033,7 +1106,7 @@ describe( 'TodoListEditing', () => {
 
 		sinon.assert.calledOnce( command.execute );
 		expect( checkboxElement.checked ).to.equal( true );
-		expect( getModelData( model ) ).to.equal(
+		assertEqualMarkup( getModelData( model ),
 			'<listItem listIndent="0" listType="todo" todoListChecked="true">foo</listItem>' +
 			'<paragraph>b[a]r</paragraph>'
 		);
@@ -1046,7 +1119,7 @@ describe( 'TodoListEditing', () => {
 
 		sinon.assert.calledTwice( command.execute );
 		expect( checkboxElement.checked ).to.equal( false );
-		expect( getModelData( model ) ).to.equal(
+		assertEqualMarkup( getModelData( model ),
 			'<listItem listIndent="0" listType="todo">foo</listItem>' +
 			'<paragraph>b[a]r</paragraph>'
 		);
@@ -1074,7 +1147,7 @@ describe( 'TodoListEditing', () => {
 
 		sinon.assert.calledOnce( command.execute );
 		expect( checkboxElement.checked ).to.equal( true );
-		expect( getModelData( model ) ).to.equal(
+		assertEqualMarkup( getModelData( model ),
 			'<listItem listIndent="0" listType="todo" todoListChecked="true">f[]oo</listItem>'
 		);
 	} );