Browse Source

Merge pull request #7562 from ckeditor/i/5866

Fix (list): Editor should not crash on the enter keypress inside a to-do list item containing soft-breaks. Closes #5866. Closes #6585.

Fix (list): Links inside a to-do list item should be properly converted to HTML. Closes #5779.

Feature (engine): Changed visibility scope of `Mapper#findPositionIn()` from `private` to `public`.
Maciej 5 years ago
parent
commit
3d260151f8

+ 6 - 7
packages/ckeditor5-engine/src/conversion/mapper.js

@@ -104,7 +104,7 @@ export default class Mapper {
 
 			const viewContainer = this._modelToViewMapping.get( data.modelPosition.parent );
 
-			data.viewPosition = this._findPositionIn( viewContainer, data.modelPosition.offset );
+			data.viewPosition = this.findPositionIn( viewContainer, data.modelPosition.offset );
 		}, { priority: 'low' } );
 
 		// Default mapper algorithm for mapping view position to model position.
@@ -510,25 +510,24 @@ export default class Mapper {
 	 *
 	 *		<p>fo<b>bar</b>bom</p> -> expected offset: 4
 	 *
-	 *		_findPositionIn( p, 4 ):
+	 *		findPositionIn( p, 4 ):
 	 *		<p>|fo<b>bar</b>bom</p> -> expected offset: 4, actual offset: 0
 	 *		<p>fo|<b>bar</b>bom</p> -> expected offset: 4, actual offset: 2
 	 *		<p>fo<b>bar</b>|bom</p> -> expected offset: 4, actual offset: 5 -> we are too far
 	 *
-	 *		_findPositionIn( b, 4 - ( 5 - 3 ) ):
+	 *		findPositionIn( b, 4 - ( 5 - 3 ) ):
 	 *		<p>fo<b>|bar</b>bom</p> -> expected offset: 2, actual offset: 0
 	 *		<p>fo<b>bar|</b>bom</p> -> expected offset: 2, actual offset: 3 -> we are too far
 	 *
-	 *		_findPositionIn( bar, 2 - ( 3 - 3 ) ):
+	 *		findPositionIn( bar, 2 - ( 3 - 3 ) ):
 	 *		We are in the text node so we can simple find the offset.
 	 *		<p>fo<b>ba|r</b>bom</p> -> expected offset: 2, actual offset: 2 -> position found
 	 *
-	 * @private
 	 * @param {module:engine/view/element~Element} viewParent Tree view element in which we are looking for the position.
 	 * @param {Number} expectedOffset Expected offset.
 	 * @returns {module:engine/view/position~Position} Found position.
 	 */
-	_findPositionIn( viewParent, expectedOffset ) {
+	findPositionIn( viewParent, expectedOffset ) {
 		// Last scanned view node.
 		let viewNode;
 		// Length of the last scanned view node.
@@ -560,7 +559,7 @@ export default class Mapper {
 		else {
 			// ( modelOffset - lastLength ) is the offset to the child we enter,
 			// so we subtract it from the expected offset to fine the offset in the child.
-			return this._findPositionIn( viewNode, expectedOffset - ( modelOffset - lastLength ) );
+			return this.findPositionIn( viewNode, expectedOffset - ( modelOffset - lastLength ) );
 		}
 	}
 

+ 6 - 2
packages/ckeditor5-engine/tests/conversion/mapper.js

@@ -536,7 +536,7 @@ describe( 'Mapper', () => {
 			it( 'should transform viewTextFOO 3', () => createToModelTest( viewTextFOO, 3, modelCaption, 3 ) );
 		} );
 
-		describe( 'toViewPosition', () => {
+		describe( 'toViewPosition and findPositionIn', () => {
 			it( 'should transform modelDiv 0', () => createToViewTest( modelDiv, 0, viewTextX, 0 ) );
 			it( 'should transform modelDiv 1', () => createToViewTest( modelDiv, 1, viewTextX, 1 ) );
 			it( 'should transform modelDiv 2', () => createToViewTest( modelDiv, 2, viewTextZZ, 0 ) );
@@ -553,7 +553,11 @@ describe( 'Mapper', () => {
 
 		function createToViewTest( modelElement, modelOffset, viewElement, viewOffset ) {
 			const modelPosition = ModelPosition._createAt( modelElement, modelOffset );
-			const viewPosition = mapper.toViewPosition( modelPosition );
+			let viewPosition = mapper.toViewPosition( modelPosition );
+			expect( viewPosition.parent ).to.equal( viewElement );
+			expect( viewPosition.offset ).to.equal( viewOffset );
+
+			viewPosition = mapper.findPositionIn( mapper.toViewElement( modelElement ), modelOffset );
 			expect( viewPosition.parent ).to.equal( viewElement );
 			expect( viewPosition.offset ).to.equal( viewOffset );
 		}

+ 59 - 62
packages/ckeditor5-list/src/todolistconverters.js

@@ -9,7 +9,7 @@
 
 /* global document */
 
-import { generateLiInUl, injectViewList } from './utils';
+import { generateLiInUl, injectViewList, positionAfterUiElements, findNestedList } from './utils';
 import createElement from '@ckeditor/ckeditor5-utils/src/dom/createelement';
 
 /**
@@ -22,6 +22,7 @@ import createElement from '@ckeditor/ckeditor5-utils/src/dom/createelement';
  *
  * @see module:engine/conversion/downcastdispatcher~DowncastDispatcher#event:insert
  * @param {module:engine/model/model~Model} model Model instance.
+ * @param {Function} onCheckboxChecked Callback function.
  * @returns {Function} Returns a conversion callback.
  */
 export function modelViewInsertion( model, onCheckboxChecked ) {
@@ -52,8 +53,13 @@ export function modelViewInsertion( model, onCheckboxChecked ) {
 		const isChecked = !!modelItem.getAttribute( 'todoListChecked' );
 		const checkmarkElement = createCheckmarkElement( modelItem, viewWriter, isChecked, onCheckboxChecked );
 
+		const span = viewWriter.createContainerElement( 'span', {
+			class: 'todo-list__label__description'
+		} );
+
 		viewWriter.addClass( 'todo-list', viewItem.parent );
 		viewWriter.insert( viewWriter.createPositionAt( viewItem, 0 ), checkmarkElement );
+		viewWriter.insert( viewWriter.createPositionAfter( checkmarkElement ), span );
 
 		injectViewList( modelItem, viewItem, conversionApi, model );
 	};
@@ -83,17 +89,19 @@ export function dataModelViewInsertion( model ) {
 			return;
 		}
 
-		consumable.consume( data.item, 'insert' );
-		consumable.consume( data.item, 'attribute:listType' );
-		consumable.consume( data.item, 'attribute:listIndent' );
+		const modelItem = data.item;
+
+		consumable.consume( modelItem, 'insert' );
+		consumable.consume( modelItem, 'attribute:listType' );
+		consumable.consume( modelItem, 'attribute:listIndent' );
+		consumable.consume( modelItem, 'attribute:todoListChecked' );
 
 		const viewWriter = conversionApi.writer;
-		const modelItem = data.item;
 		const viewItem = generateLiInUl( modelItem, conversionApi );
 
 		viewWriter.addClass( 'todo-list', viewItem.parent );
 
-		const label = viewWriter.createAttributeElement( 'label', {
+		const label = viewWriter.createContainerElement( 'label', {
 			class: 'todo-list__label'
 		} );
 
@@ -102,52 +110,23 @@ export function dataModelViewInsertion( model ) {
 			disabled: 'disabled'
 		} );
 
-		if ( data.item.getAttribute( 'todoListChecked' ) ) {
+		const span = viewWriter.createContainerElement( 'span', {
+			class: 'todo-list__label__description'
+		} );
+
+		if ( modelItem.getAttribute( 'todoListChecked' ) ) {
 			viewWriter.setAttribute( 'checked', 'checked', checkbox );
-			viewWriter.addClass( 'todo-list__label', label );
 		}
 
-		viewWriter.insert( viewWriter.createPositionAt( viewItem, 0 ), checkbox );
-		viewWriter.wrap( viewWriter.createRangeOn( checkbox ), label );
+		viewWriter.insert( viewWriter.createPositionAt( viewItem, 0 ), label );
+		viewWriter.insert( viewWriter.createPositionAt( label, 0 ), checkbox );
+		viewWriter.insert( viewWriter.createPositionAfter( checkbox ), span );
 
 		injectViewList( modelItem, viewItem, conversionApi, model );
 	};
 }
 
 /**
- * A model-to-view converter for the model `$text` element inside a to-do list item.
- *
- * It is used by {@link module:engine/controller/datacontroller~DataController}.
- *
- * @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 dataModelViewTextInsertion( 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 );
-
-	const span = viewWriter.createAttributeElement( 'span', { class: 'todo-list__label__description' } );
-	const label = viewPosition.parent.getChild( 0 );
-
-	viewWriter.insert( viewWriter.createPositionAt( viewPosition.parent, 'end' ), viewText );
-	viewWriter.wrap( viewWriter.createRangeOn( viewText ), span );
-	viewWriter.wrap( viewWriter.createRangeOn( viewText.parent ), label );
-}
-
-/**
  * A view-to-model converter for the checkbox element inside a view list item.
  *
  * It changes the `listType` of the model `listItem` to a `todo` value.
@@ -198,6 +177,7 @@ export function dataViewModelCheckmarkInsertion( evt, data, conversionApi ) {
  *
  * @see module:engine/conversion/downcastdispatcher~DowncastDispatcher#event:attribute
  * @param {Function} onCheckedChange Callback fired after clicking the checkbox UI element.
+ * @param {module:engine/view/view~View} view Editing view controller.
  * @returns {Function} Returns a conversion callback.
  */
 export function modelViewChangeType( onCheckedChange, view ) {
@@ -205,15 +185,34 @@ export function modelViewChangeType( onCheckedChange, view ) {
 		const viewItem = conversionApi.mapper.toViewElement( data.item );
 		const viewWriter = conversionApi.writer;
 
+		const labelElement = findLabel( viewItem, view );
+
 		if ( data.attributeNewValue == 'todo' ) {
 			const isChecked = !!data.item.getAttribute( 'todoListChecked' );
 			const checkmarkElement = createCheckmarkElement( data.item, viewWriter, isChecked, onCheckedChange );
 
+			const span = viewWriter.createContainerElement( 'span', {
+				class: 'todo-list__label__description'
+			} );
+
+			const itemRange = viewWriter.createRangeIn( viewItem );
+			const nestedList = findNestedList( viewItem );
+
+			const descriptionStart = positionAfterUiElements( itemRange.start );
+			const descriptionEnd = nestedList ? viewWriter.createPositionBefore( nestedList ) : itemRange.end;
+			const descriptionRange = viewWriter.createRange( descriptionStart, descriptionEnd );
+
 			viewWriter.addClass( 'todo-list', viewItem.parent );
+			viewWriter.move( descriptionRange, viewWriter.createPositionAt( span, 0 ) );
 			viewWriter.insert( viewWriter.createPositionAt( viewItem, 0 ), checkmarkElement );
+			viewWriter.insert( viewWriter.createPositionAfter( checkmarkElement ), span );
 		} else if ( data.attributeOldValue == 'todo' ) {
+			const descriptionSpan = findDescription( viewItem, view );
+
 			viewWriter.removeClass( 'todo-list', viewItem.parent );
-			viewWriter.remove( findLabel( viewItem, view ) );
+			viewWriter.remove( labelElement );
+			viewWriter.move( viewWriter.createRangeIn( descriptionSpan ), viewWriter.createPositionBefore( descriptionSpan ) );
+			viewWriter.remove( descriptionSpan );
 		}
 	};
 }
@@ -261,34 +260,22 @@ export function modelViewChangeChecked( onCheckedChange ) {
  * 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 ) {
+export function mapModelToViewPosition( view ) {
 	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 ) {
+		if ( !parent.is( 'listItem' ) || parent.getAttribute( 'listType' ) != 'todo' ) {
 			return;
 		}
 
-		const viewLi = mapper.toViewElement( parent );
-		const label = findLabel( viewLi, view );
+		const viewLi = data.mapper.toViewElement( parent );
+		const descSpan = findDescription( 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 );
+		if ( descSpan ) {
+			data.viewPosition = data.mapper.findPositionIn( descSpan, modelPosition.offset );
 		}
 	};
 }
@@ -338,3 +325,13 @@ function findLabel( viewItem, view ) {
 		}
 	}
 }
+
+function findDescription( viewItem, view ) {
+	const range = view.createRangeIn( viewItem );
+
+	for ( const value of range ) {
+		if ( value.item.is( 'containerElement', 'span' ) && value.item.hasClass( 'todo-list__label__description' ) ) {
+			return value.item;
+		}
+	}
+}

+ 33 - 25
packages/ckeditor5-list/src/todolistediting.js

@@ -15,13 +15,13 @@ import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
 
 import {
 	dataModelViewInsertion,
-	dataModelViewTextInsertion,
 	dataViewModelCheckmarkInsertion,
-	mapModelToViewZeroOffsetPosition,
+	mapModelToViewPosition,
 	modelViewChangeChecked,
 	modelViewChangeType,
 	modelViewInsertion
 } from './todolistconverters';
+import { getLocalizedArrowKeyCodeDirection } from '@ckeditor/ckeditor5-utils/src/keyboard';
 
 /**
  * The engine of the to-do list feature. It handles creating, editing and removing to-do lists and their items.
@@ -76,7 +76,7 @@ export default class TodoListEditing extends Plugin {
 
 		// Define converters.
 		data.downcastDispatcher.on( 'insert:listItem', dataModelViewInsertion( model ), { priority: 'high' } );
-		data.downcastDispatcher.on( 'insert:$text', dataModelViewTextInsertion, { priority: 'high' } );
+		data.upcastDispatcher.on( 'element:input', dataViewModelCheckmarkInsertion, { priority: 'high' } );
 
 		editing.downcastDispatcher.on(
 			'insert:listItem',
@@ -92,9 +92,8 @@ export default class TodoListEditing extends Plugin {
 			modelViewChangeChecked( listItem => this._handleCheckmarkChange( listItem ) )
 		);
 
-		editing.mapper.on( 'modelToViewPosition', mapModelToViewZeroOffsetPosition( editing.view, editing.mapper ) );
-
-		data.upcastDispatcher.on( 'element:input', dataViewModelCheckmarkInsertion, { priority: 'high' } );
+		editing.mapper.on( 'modelToViewPosition', mapModelToViewPosition( editing.view ) );
+		data.mapper.on( 'modelToViewPosition', mapModelToViewPosition( editing.view ) );
 
 		// Jump at the end of the previous node on left arrow key press, when selection is after the checkbox.
 		//
@@ -106,10 +105,7 @@ export default class TodoListEditing extends Plugin {
 		// <blockquote><p>Foo{}</p></blockquote>
 		// <ul><li><checkbox/>Bar</li></ul>
 		//
-		// Note: When content language direction is RTL, the behaviour is mirrored.
-		const localizedJumpOverCheckmarkKey = editor.locale.contentLanguageDirection === 'ltr' ? 'arrowleft' : 'arrowright';
-
-		editor.keystrokes.set( localizedJumpOverCheckmarkKey, ( evt, stop ) => jumpOverCheckmarkOnSideArrowKeyPress( stop, model ) );
+		this.listenTo( editing.view.document, 'keydown', jumpOverCheckmarkOnSideArrowKeyPress( model, editor.locale ) );
 
 		// Toggle check state of selected to-do list items on keystroke.
 		editor.keystrokes.set( 'Ctrl+space', () => editor.execute( 'todoListCheck' ) );
@@ -178,25 +174,37 @@ export default class TodoListEditing extends Plugin {
 // moving the selection to the left/right (LTR/RTL).
 //
 // @private
-// @param {Function} stopKeyEvent
 // @param {module:engine/model/model~Model} model
-function jumpOverCheckmarkOnSideArrowKeyPress( stopKeyEvent, model ) {
-	const schema = model.schema;
-	const selection = model.document.selection;
+// @param {module:utils/locale~Locale} locale
+// @returns {Function} Callback for 'keydown' events.
+function jumpOverCheckmarkOnSideArrowKeyPress( model, locale ) {
+	return ( eventInfo, domEventData ) => {
+		const direction = getLocalizedArrowKeyCodeDirection( domEventData.keyCode, locale.contentLanguageDirection );
+
+		if ( direction != 'left' ) {
+			return;
+		}
 
-	if ( !selection.isCollapsed ) {
-		return;
-	}
+		const schema = model.schema;
+		const selection = model.document.selection;
 
-	const position = selection.getFirstPosition();
-	const parent = position.parent;
+		if ( !selection.isCollapsed ) {
+			return;
+		}
+
+		const position = selection.getFirstPosition();
+		const parent = position.parent;
 
-	if ( parent.name === 'listItem' && parent.getAttribute( 'listType' ) == 'todo' && position.isAtStart ) {
-		const newRange = schema.getNearestSelectionRange( model.createPositionBefore( parent ), 'backward' );
+		if ( parent.name === 'listItem' && parent.getAttribute( 'listType' ) == 'todo' && position.isAtStart ) {
+			const newRange = schema.getNearestSelectionRange( model.createPositionBefore( parent ), 'backward' );
 
-		if ( newRange ) {
-			stopKeyEvent();
-			model.change( writer => writer.setSelection( newRange ) );
+			if ( newRange ) {
+				model.change( writer => writer.setSelection( newRange ) );
+			}
+
+			domEventData.preventDefault();
+			domEventData.stopPropagation();
+			eventInfo.stop();
 		}
-	}
+	};
 }

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

@@ -88,6 +88,19 @@ export function injectViewList( modelItem, injectedItem, conversionApi, model )
 			// If it is a list item, it has to have a lower indent.
 			// It means that the inserted item should be added to it as its nested item.
 			insertPosition = mapper.toViewPosition( model.createPositionAt( prevItem, 'end' ) );
+
+			// There could be some not mapped elements (eg. span in to-do list) but we need to insert
+			// a nested list directly inside the li element.
+			const mappedViewAncestor = mapper.findMappedViewAncestor( insertPosition );
+			const nestedList = findNestedList( mappedViewAncestor );
+
+			// If there already is some nested list, then use it's position.
+			if ( nestedList ) {
+				insertPosition = viewWriter.createPositionBefore( nestedList );
+			} else {
+				// Else just put new list on the end of list item content.
+				insertPosition = viewWriter.createPositionAt( mappedViewAncestor, 'end' );
+			}
 		} else {
 			// The previous item is not a list item (or does not exist at all).
 			// Just map the position and insert the view item at the mapped position.
@@ -250,6 +263,22 @@ export function createUIComponent( editor, commandName, label, icon ) {
 	} );
 }
 
+/**
+ * Returns a first list view element that is direct child of the given view element.
+ *
+ * @param {module:engine/view/element~Element} viewElement
+ * @return {module:engine/view/element~Element|null}
+ */
+export function findNestedList( viewElement ) {
+	for ( const node of viewElement.getChildren() ) {
+		if ( node.name == 'ul' || node.name == 'ol' ) {
+			return node;
+		}
+	}
+
+	return null;
+}
+
 // Implementation of getFillerOffset for view list item element.
 //
 // @returns {Number|null} Block filler offset or `null` if block filler is not needed.

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

@@ -16,13 +16,15 @@ import Table from '@ckeditor/ckeditor5-table/src/table';
 import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
 import Undo from '@ckeditor/ckeditor5-undo/src/undo';
 import Clipboard from '@ckeditor/ckeditor5-clipboard/src/clipboard';
-import List from '../../src/list';
+import ShiftEnter from '@ckeditor/ckeditor5-enter/src/shiftenter';
 import FontSize from '@ckeditor/ckeditor5-font/src/fontsize';
+
+import List from '../../src/list';
 import TodoList from '../../src/todolist';
 
 ClassicEditor
 	.create( document.querySelector( '#editor' ), {
-		plugins: [ Enter, Typing, Heading, Highlight, Table, Bold, Paragraph, Undo, List, TodoList, Clipboard, Link, FontSize ],
+		plugins: [ Enter, Typing, Heading, Highlight, Table, Bold, Paragraph, Undo, List, TodoList, Clipboard, Link, FontSize, ShiftEnter ],
 		toolbar: [
 			'heading',
 			'|',

+ 189 - 38
packages/ckeditor5-list/tests/todolistediting.js

@@ -13,6 +13,8 @@ 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 Enter from '@ckeditor/ckeditor5-enter/src/enter';
+import ShiftEnter from '@ckeditor/ckeditor5-enter/src/shiftenter';
 
 import VirtualTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/virtualtesteditor';
 import ClassicTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/classictesteditor';
@@ -29,7 +31,7 @@ describe( 'TodoListEditing', () => {
 	beforeEach( () => {
 		return VirtualTestEditor
 			.create( {
-				plugins: [ TodoListEditing, Typing, BoldEditing, BlockQuoteEditing, LinkEditing ]
+				plugins: [ TodoListEditing, Typing, BoldEditing, BlockQuoteEditing, LinkEditing, Enter, ShiftEnter ]
 			} )
 			.then( newEditor => {
 				editor = newEditor;
@@ -87,7 +89,9 @@ describe( 'TodoListEditing', () => {
 			assertEqualMarkup( getModelData( model ), '<listItem listIndent="0" listType="todo">[]</listItem>' );
 			assertEqualMarkup( getViewData( view ),
 				'<ul class="todo-list">' +
-					'<li><label class="todo-list__label" contenteditable="false"></label>[]</li>' +
+					'<li><label class="todo-list__label" contenteditable="false"></label>' +
+						'<span class="todo-list__label__description">[]</span>' +
+					'</li>' +
 				'</ul>'
 			);
 
@@ -96,7 +100,9 @@ describe( 'TodoListEditing', () => {
 			assertEqualMarkup( getModelData( model ), '<listItem listIndent="0" listType="todo">a[]</listItem>' );
 			assertEqualMarkup( getViewData( view ),
 				'<ul class="todo-list">' +
-					'<li><label class="todo-list__label" contenteditable="false"></label>a{}</li>' +
+					'<li><label class="todo-list__label" contenteditable="false"></label>' +
+						'<span class="todo-list__label__description">a{}</span>' +
+					'</li>' +
 				'</ul>'
 			);
 
@@ -105,7 +111,9 @@ describe( 'TodoListEditing', () => {
 			assertEqualMarkup( getModelData( model ), '<listItem listIndent="0" listType="todo">ab[]</listItem>' );
 			assertEqualMarkup( getViewData( view ),
 				'<ul class="todo-list">' +
-					'<li><label class="todo-list__label" contenteditable="false"></label>ab{}</li>' +
+					'<li><label class="todo-list__label" contenteditable="false"></label>' +
+						'<span class="todo-list__label__description">ab{}</span>' +
+					'</li>' +
 				'</ul>'
 			);
 
@@ -129,8 +137,12 @@ describe( 'TodoListEditing', () => {
 
 			assertEqualMarkup( getViewData( view ),
 				'<ul class="todo-list">' +
-					'<li><label class="todo-list__label" contenteditable="false"></label>{}1</li>' +
-					'<li><label class="todo-list__label" contenteditable="false"></label>2</li>' +
+					'<li><label class="todo-list__label" contenteditable="false"></label>' +
+						'<span class="todo-list__label__description">{}1</span>' +
+					'</li>' +
+					'<li><label class="todo-list__label" contenteditable="false"></label>' +
+						'<span class="todo-list__label__description">2</span>' +
+					'</li>' +
 				'</ul>'
 			);
 		} );
@@ -148,17 +160,31 @@ describe( 'TodoListEditing', () => {
 			assertEqualMarkup( getViewData( view ),
 				'<ul class="todo-list">' +
 					'<li>' +
-						'<label class="todo-list__label" contenteditable="false"></label>{}1.0' +
+						'<label class="todo-list__label" contenteditable="false"></label>' +
+						'<span class="todo-list__label__description">{}1.0</span>' +
 						'<ul class="todo-list">' +
-							'<li><label class="todo-list__label" contenteditable="false"></label>2.1</li>' +
 							'<li>' +
-								'<label class="todo-list__label" contenteditable="false"></label>3.1' +
+								'<label class="todo-list__label" contenteditable="false"></label>' +
+								'<span class="todo-list__label__description">2.1</span>' +
+							'</li>' +
+							'<li>' +
+								'<label class="todo-list__label" contenteditable="false"></label>' +
+								'<span class="todo-list__label__description">3.1</span>' +
 								'<ul class="todo-list">' +
-									'<li><label class="todo-list__label" contenteditable="false"></label>4.2</li>' +
-									'<li><label class="todo-list__label" contenteditable="false"></label>5.2</li>' +
+									'<li>' +
+										'<label class="todo-list__label" contenteditable="false"></label>' +
+										'<span class="todo-list__label__description">4.2</span>' +
+									'</li>' +
+									'<li>' +
+										'<label class="todo-list__label" contenteditable="false"></label>' +
+										'<span class="todo-list__label__description">5.2</span>' +
+									'</li>' +
 								'</ul>' +
 							'</li>' +
-							'<li><label class="todo-list__label" contenteditable="false"></label>6.1</li>' +
+							'<li>' +
+								'<label class="todo-list__label" contenteditable="false"></label>' +
+								'<span class="todo-list__label__description">6.1</span>' +
+							'</li>' +
 						'</ul>' +
 					'</li>' +
 				'</ul>'
@@ -176,18 +202,25 @@ describe( 'TodoListEditing', () => {
 
 			assertEqualMarkup( getViewData( view ),
 				'<ul class="todo-list">' +
-					'<li><label class="todo-list__label" contenteditable="false"></label>{}1.0</li>' +
+					'<li>' +
+						'<label class="todo-list__label" contenteditable="false"></label>' +
+						'<span class="todo-list__label__description">{}1.0</span>' +
+					'</li>' +
 				'</ul>' +
 				'<ul>' +
 					'<li>2.0' +
 						'<ul class="todo-list">' +
 							'<li>' +
-								'<label class="todo-list__label" contenteditable="false"></label>3.1' +
+								'<label class="todo-list__label" contenteditable="false"></label>' +
+								'<span class="todo-list__label__description">3.1</span>' +
 								'<ul>' +
 									'<li>4.2</li>' +
 								'</ul>' +
 							'</li>' +
-							'<li><label class="todo-list__label" contenteditable="false"></label>5.1</li>' +
+							'<li>' +
+								'<label class="todo-list__label" contenteditable="false"></label>' +
+								'<span class="todo-list__label__description">5.1</span>' +
+							'</li>' +
 						'</ul>' +
 					'</li>' +
 				'</ul>'
@@ -205,18 +238,25 @@ describe( 'TodoListEditing', () => {
 
 			assertEqualMarkup( getViewData( view ),
 				'<ul class="todo-list">' +
-					'<li><label class="todo-list__label" contenteditable="false"></label>{}1.0</li>' +
+					'<li>' +
+						'<label class="todo-list__label" contenteditable="false"></label>' +
+						'<span class="todo-list__label__description">{}1.0</span>' +
+					'</li>' +
 				'</ul>' +
 				'<ol>' +
 					'<li>2.0' +
 						'<ul class="todo-list">' +
 							'<li>' +
-								'<label class="todo-list__label" contenteditable="false"></label>3.1' +
+								'<label class="todo-list__label" contenteditable="false"></label>' +
+								'<span class="todo-list__label__description">3.1</span>' +
 								'<ol>' +
 									'<li>4.2</li>' +
 								'</ol>' +
 							'</li>' +
-							'<li><label class="todo-list__label" contenteditable="false"></label>5.1</li>' +
+							'<li>' +
+								'<label class="todo-list__label" contenteditable="false"></label>' +
+								'<span class="todo-list__label__description">5.1</span>' +
+							'</li>' +
 						'</ul>' +
 					'</li>' +
 				'</ol>'
@@ -237,7 +277,10 @@ describe( 'TodoListEditing', () => {
 					'<li>1.0</li>' +
 				'</ol>' +
 				'<ul class="todo-list">' +
-					'<li><label class="todo-list__label" contenteditable="false"></label>{}2.0</li>' +
+					'<li>' +
+						'<label class="todo-list__label" contenteditable="false"></label>' +
+						'<span class="todo-list__label__description">{}2.0</span>' +
+					'</li>' +
 				'</ul>' +
 				'<ol>' +
 					'<li>3.0</li>' +
@@ -256,13 +299,19 @@ describe( 'TodoListEditing', () => {
 
 			assertEqualMarkup( getViewData( view ),
 				'<ul class="todo-list">' +
-					'<li><label class="todo-list__label" contenteditable="false"></label>1.0</li>' +
+					'<li>' +
+						'<label class="todo-list__label" contenteditable="false"></label>' +
+						'<span class="todo-list__label__description">1.0</span>' +
+					'</li>' +
 				'</ul>' +
 				'<ol>' +
 					'<li>{}2.0</li>' +
 				'</ol>' +
 				'<ul class="todo-list">' +
-					'<li><label class="todo-list__label" contenteditable="false"></label>3.0</li>' +
+					'<li>' +
+						'<label class="todo-list__label" contenteditable="false"></label>' +
+						'<span class="todo-list__label__description">3.0</span>' +
+					'</li>' +
 				'</ul>'
 			);
 		} );
@@ -278,17 +327,71 @@ describe( 'TodoListEditing', () => {
 
 			assertEqualMarkup( getViewData( view ),
 				'<ul class="todo-list">' +
-					'<li><label class="todo-list__label" contenteditable="false"></label>1.0</li>' +
+					'<li>' +
+						'<label class="todo-list__label" contenteditable="false"></label>' +
+						'<span class="todo-list__label__description">1.0</span>' +
+					'</li>' +
 				'</ul>' +
 				'<ul>' +
 					'<li>{}2.0</li>' +
 				'</ul>' +
 				'<ul class="todo-list">' +
-					'<li><label class="todo-list__label" contenteditable="false"></label>3.0</li>' +
+					'<li>' +
+						'<label class="todo-list__label" contenteditable="false"></label>' +
+						'<span class="todo-list__label__description">3.0</span>' +
+					'</li>' +
 				'</ul>'
 			);
 		} );
 
+		it( 'should properly convert list type change (when next list item is nested)', () => {
+			setModelData( model,
+				'<listItem listType="todo" listIndent="0">1.0</listItem>' +
+				'<listItem listType="numbered" listIndent="0">[]2.0</listItem>' +
+				'<listItem listType="todo" listIndent="1">3.0</listItem>'
+			);
+
+			assertEqualMarkup( getViewData( view ),
+				'<ul class="todo-list">' +
+					'<li>' +
+						'<label class="todo-list__label" contenteditable="false"></label>' +
+						'<span class="todo-list__label__description">1.0</span>' +
+					'</li>' +
+				'</ul>' +
+				'<ol>' +
+					'<li>' +
+						'{}2.0' +
+						'<ul class="todo-list">' +
+							'<li>' +
+								'<label class="todo-list__label" contenteditable="false"></label>' +
+								'<span class="todo-list__label__description">3.0</span>' +
+							'</li>' +
+						'</ul>' +
+					'</li>' +
+				'</ol>'
+			);
+
+			editor.execute( 'todoList' );
+
+			assertEqualMarkup( getViewData( view ),
+				'<ul class="todo-list">' +
+					'<li>' +
+						'<label class="todo-list__label" contenteditable="false"></label>' +
+						'<span class="todo-list__label__description">1.0</span>' +
+					'</li>' +
+					'<li>' +
+						'<label class="todo-list__label" contenteditable="false"></label>' +
+						'<span class="todo-list__label__description">{}2.0</span>' +
+						'<ul class="todo-list">' +
+							'<li>' +
+								'<label class="todo-list__label" contenteditable="false"></label>' +
+								'<span class="todo-list__label__description">3.0</span>' +
+							'</li>' +
+						'</ul>' +
+					'</li>' +
+				'</ul>'
+			);
+		} );
 		it( 'should properly convert list type change - inner text with attribute', () => {
 			setModelData( model,
 				'<listItem listType="todo" listIndent="0">1[.0</listItem>' +
@@ -330,7 +433,10 @@ describe( 'TodoListEditing', () => {
 
 			assertEqualMarkup( getViewData( view ),
 				'<ul class="todo-list">' +
-					'<li><label class="todo-list__label" contenteditable="false"></label>{}1.0</li>' +
+					'<li>' +
+						'<label class="todo-list__label" contenteditable="false"></label>' +
+						'<span class="todo-list__label__description">{}1.0</span>' +
+					'</li>' +
 				'</ul>'
 			);
 
@@ -340,7 +446,10 @@ describe( 'TodoListEditing', () => {
 
 			assertEqualMarkup( getViewData( view ),
 				'<ul class="todo-list">' +
-					'<li><label class="todo-list__label" contenteditable="false"></label>{}1.0</li>' +
+					'<li>' +
+						'<label class="todo-list__label" contenteditable="false"></label>' +
+						'<span class="todo-list__label__description">{}1.0</span>' +
+					'</li>' +
 				'</ul>'
 			);
 
@@ -350,7 +459,10 @@ describe( 'TodoListEditing', () => {
 
 			assertEqualMarkup( getViewData( view ),
 				'<ul class="todo-list">' +
-					'<li><label class="todo-list__label" contenteditable="false"></label>{}1.0</li>' +
+					'<li>' +
+						'<label class="todo-list__label" contenteditable="false"></label>' +
+						'<span class="todo-list__label__description">{}1.0</span>' +
+					'</li>' +
 				'</ul>'
 			);
 		} );
@@ -418,7 +530,10 @@ describe( 'TodoListEditing', () => {
 
 			assertEqualMarkup( getViewData( view ),
 				'<ul class="todo-list">' +
-					'<li><label class="todo-list__label" contenteditable="false"></label>{}Foo</li>' +
+					'<li>' +
+						'<label class="todo-list__label" contenteditable="false"></label>' +
+						'<span class="todo-list__label__description">{}Foo</span>' +
+					'</li>' +
 				'</ul>'
 			);
 		} );
@@ -428,7 +543,10 @@ describe( 'TodoListEditing', () => {
 
 			assertEqualMarkup( getViewData( view ),
 				'<ul class="todo-list">' +
-					'<li><label class="todo-list__label" contenteditable="false"></label>[]</li>' +
+					'<li>' +
+						'<label class="todo-list__label" contenteditable="false"></label>' +
+						'<span class="todo-list__label__description">[]</span>' +
+					'</li>' +
 				'</ul>'
 			);
 		} );
@@ -474,14 +592,17 @@ describe( 'TodoListEditing', () => {
 				'<ul class="todo-list">' +
 					'<li>' +
 						'<label class="todo-list__label" contenteditable="false"></label>' +
-						'[]<span class="highlight">' +
-							'<element2></element2>' +
-							'<element1></element1>' +
-							'foo' +
+						'<span class="todo-list__label__description">' +
+							'[]<span class="highlight">' +
+								'<element2></element2>' +
+								'<element1></element1>' +
+								'foo' +
+							'</span>' +
 						'</span>' +
 					'</li>' +
 					'<li>' +
-						'<label class="todo-list__label" contenteditable="false"></label>bar' +
+						'<label class="todo-list__label" contenteditable="false"></label>' +
+						'<span class="todo-list__label__description">bar</span>' +
 					'</li>' +
 				'</ul>'
 			);
@@ -503,7 +624,9 @@ describe( 'TodoListEditing', () => {
 				'<ul class="todo-list">' +
 					'<li>' +
 						'<label class="todo-list__label" contenteditable="false"></label>' +
-						'<strong>b{}foo</strong>' +
+						'<span class="todo-list__label__description">' +
+							'<strong>b{}foo</strong>' +
+						'</span>' +
 					'</li>' +
 				'</ul>'
 			);
@@ -524,11 +647,24 @@ describe( 'TodoListEditing', () => {
 				'<ul class="todo-list">' +
 					'<li>' +
 						'<label class="todo-list__label" contenteditable="false"></label>' +
-						'<a href="foo"><strong>b{}foo</strong></a>' +
+						'<span class="todo-list__label__description">' +
+							'<a class="ck-link_selected" href="foo"><strong>b{}foo</strong></a>' +
+						'</span>' +
 					'</li>' +
 				'</ul>'
 			);
 		} );
+
+		it( 'should properly handle enter key in list item containing soft-breaks', () => {
+			setModelData( model, '<listItem listType="todo" listIndent="0">[]Foo<softBreak></softBreak>bar</listItem>' );
+
+			editor.execute( 'enter' );
+
+			assertEqualMarkup( getModelData( model ),
+				'<listItem listIndent="0" listType="todo"></listItem>' +
+				'<listItem listIndent="0" listType="todo">[]Foo<softBreak></softBreak>bar</listItem>'
+			);
+		} );
 	} );
 
 	describe( 'data pipeline m -> v', () => {
@@ -697,6 +833,21 @@ describe( 'TodoListEditing', () => {
 
 			expect( editor.getData() ).to.equal( '<test>Foo</test>' );
 		} );
+
+		it( 'should handle links inside to-do list item', () => {
+			setModelData( model, '<listItem listType="todo" listIndent="0"><$text linkHref="foo">Foo</$text> Bar</listItem>' );
+
+			expect( editor.getData() ).to.equal(
+				'<ul class="todo-list">' +
+					'<li>' +
+						'<label class="todo-list__label">' +
+							'<input type="checkbox" disabled="disabled">' +
+							'<span class="todo-list__label__description"><a href="foo">Foo</a> Bar</span>' +
+						'</label>' +
+					'</li>' +
+				'</ul>'
+			);
+		} );
 	} );
 
 	describe( 'data pipeline v -> m', () => {
@@ -970,13 +1121,13 @@ describe( 'TodoListEditing', () => {
 				sinon.assert.calledOnce( domEvtDataStub.stopPropagation );
 			} );
 
-			it( 'should do nothing when list item is a first block element in the root', () => {
+			it( 'should prevent default handler when list item is a first block element in the root', () => {
 				setModelData( model, '<listItem listIndent="0" listType="todo">[]bar</listItem>' );
 
 				viewDoc.fire( 'keydown', domEvtDataStub );
 
-				sinon.assert.notCalled( domEvtDataStub.preventDefault );
-				sinon.assert.notCalled( domEvtDataStub.stopPropagation );
+				sinon.assert.calledOnce( domEvtDataStub.preventDefault );
+				sinon.assert.calledOnce( domEvtDataStub.stopPropagation );
 
 				assertEqualMarkup( getModelData( model ), '<listItem listIndent="0" listType="todo">[]bar</listItem>' );
 			} );