浏览代码

Merge pull request #133 from ckeditor/t/1434

Feature: Introduced to-do lists. Closes ckeditor/ckeditor5#1434.
Piotr Jasiun 6 年之前
父节点
当前提交
b60349e13e

+ 2 - 0
packages/ckeditor5-list/package.json

@@ -23,8 +23,10 @@
     "@ckeditor/ckeditor5-editor-classic": "^12.1.3",
     "@ckeditor/ckeditor5-enter": "^11.0.4",
     "@ckeditor/ckeditor5-heading": "^11.0.4",
+    "@ckeditor/ckeditor5-highlight": "^11.0.4",
     "@ckeditor/ckeditor5-indent": "^10.0.1",
     "@ckeditor/ckeditor5-link": "^11.1.1",
+    "@ckeditor/ckeditor5-table": "^13.0.2",
     "@ckeditor/ckeditor5-typing": "^12.1.1",
     "@ckeditor/ckeditor5-undo": "^11.0.4",
     "eslint": "^5.5.0",

+ 33 - 162
packages/ckeditor5-list/src/converters.js

@@ -7,7 +7,13 @@
  * @module list/converters
  */
 
-import { createViewListItemElement } from './utils';
+import {
+	generateLiInUl,
+	injectViewList,
+	mergeViewLists,
+	getSiblingListItem,
+	positionAfterUiElements
+} from './utils';
 import TreeWalker from '@ckeditor/ckeditor5-engine/src/model/treewalker';
 
 /**
@@ -89,7 +95,11 @@ export function modelViewRemove( model ) {
  * A model-to-view converter for the `type` attribute change on the `listItem` model element.
  *
  * This change means that the `<li>` element parent changes from `<ul>` to `<ol>` (or vice versa). This is accomplished
- * by breaking view elements, changing their name and merging them.
+ * by breaking view elements and changing their name. Next {@link module:list/converters~modelViewMergeAfterChangeType}
+ * converter will try to merge split nodes.
+ *
+ * Splitting this conversion into 2 steps makes it possible to add an additional conversion in the middle.
+ * Check {@link module:list/todolistconverters~modelViewChangeType} to see an example of it.
  *
  * @see module:engine/conversion/downcastdispatcher~DowncastDispatcher#event:attribute
  * @param {module:utils/eventinfo~EventInfo} evt An object containing information about the fired event.
@@ -104,22 +114,37 @@ export function modelViewChangeType( evt, data, conversionApi ) {
 	const viewItem = conversionApi.mapper.toViewElement( data.item );
 	const viewWriter = conversionApi.writer;
 
-	// 1. Break the container after and before the list item.
+	// Break the container after and before the list item.
 	// This will create a view list with one view list item -- the one that changed type.
 	viewWriter.breakContainer( viewWriter.createPositionBefore( viewItem ) );
 	viewWriter.breakContainer( viewWriter.createPositionAfter( viewItem ) );
 
-	// 2. Change name of the view list that holds the changed view item.
+	// Change name of the view list that holds the changed view item.
 	// We cannot just change name property, because that would not render properly.
-	let viewList = viewItem.parent;
+	const viewList = viewItem.parent;
 	const listName = data.attributeNewValue == 'numbered' ? 'ol' : 'ul';
-	viewList = viewWriter.rename( listName, viewList );
 
-	// 3. Merge the changed view list with other lists, if possible.
+	viewWriter.rename( listName, viewList );
+}
+
+/**
+ * A model-to-view converter that try to merge nodes split by {@link module:list/converters~modelViewChangeType}.
+ *
+ * @see module:engine/conversion/downcastdispatcher~DowncastDispatcher#event:attribute
+ * @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 modelViewMergeAfterChangeType( evt, data, conversionApi ) {
+	const viewItem = conversionApi.mapper.toViewElement( data.item );
+	const viewList = viewItem.parent;
+	const viewWriter = conversionApi.writer;
+
+	// Merge the changed view list with other lists, if possible.
 	mergeViewLists( viewWriter, viewList, viewList.nextSibling );
 	mergeViewLists( viewWriter, viewList.previousSibling, viewList );
 
-	// 4. Consumable insertion of children inside the item. They are already handled by re-building the item in view.
+	// Consumable insertion of children inside the item. They are already handled by re-building the item in view.
 	for ( const child of data.item.getChildren() ) {
 		conversionApi.consumable.consume( child, 'insert' );
 	}
@@ -784,23 +809,6 @@ export function modelIndentPasteFixer( evt, [ content, selectable ] ) {
 	}
 }
 
-// Helper function that creates a `<ul><li></li></ul>` or (`<ol>`) structure out of given `modelItem` model `listItem` element.
-// Then, it binds created view list item (<li>) with model `listItem` element.
-// The function then returns created view list item (<li>).
-function generateLiInUl( modelItem, conversionApi ) {
-	const mapper = conversionApi.mapper;
-	const viewWriter = conversionApi.writer;
-	const listType = modelItem.getAttribute( 'listType' ) == 'numbered' ? 'ol' : 'ul';
-	const viewItem = createViewListItemElement( viewWriter );
-
-	const viewList = viewWriter.createContainerElement( listType, null );
-	viewWriter.insert( viewWriter.createPositionAt( viewList, 0 ), viewItem );
-
-	mapper.bindElements( modelItem, viewItem );
-
-	return viewItem;
-}
-
 // Helper function that converts children of a given `<li>` view element into corresponding model elements.
 // The function maintains proper order of elements if model `listItem` is split during the conversion
 // due to block children conversion.
@@ -888,134 +896,6 @@ function findNextListItem( startPosition ) {
 	return value.value.item;
 }
 
-// Helper function that seeks for a previous list item sibling of given model item which meets given criteria.
-// `options` object may contain one or more of given values (by default they are `false`):
-// `options.sameIndent` - whether sought sibling should have same indent (default = no),
-// `options.smallerIndent` - whether sought sibling should have smaller indent (default = no).
-// `options.listIndent` - the reference indent.
-// Either `options.sameIndent` or `options.smallerIndent` should be set to `true`.
-function getSiblingListItem( modelItem, options ) {
-	const sameIndent = !!options.sameIndent;
-	const smallerIndent = !!options.smallerIndent;
-	const indent = options.listIndent;
-
-	let item = modelItem;
-
-	while ( item && item.name == 'listItem' ) {
-		const itemIndent = item.getAttribute( 'listIndent' );
-
-		if ( ( sameIndent && indent == itemIndent ) || ( smallerIndent && indent > itemIndent ) ) {
-			return item;
-		}
-
-		item = item.previousSibling;
-	}
-
-	return null;
-}
-
-// Helper function that takes two parameters, that are expected to be view list elements, and merges them.
-// The merge happen only if both parameters are UL or OL elements.
-function mergeViewLists( viewWriter, firstList, secondList ) {
-	if ( firstList && secondList && ( firstList.name == 'ul' || firstList.name == 'ol' ) && firstList.name == secondList.name ) {
-		return viewWriter.mergeContainers( viewWriter.createPositionAfter( firstList ) );
-	}
-
-	return null;
-}
-
-// Helper function that takes model list item element `modelItem`, corresponding view list item element `injectedItem`
-// that is not added to the view and is inside a view list element (`ul` or `ol`) and is that's list only child.
-// The list is inserted at correct position (element breaking may be needed) and then merged with it's siblings.
-// See comments below to better understand the algorithm.
-function injectViewList( modelItem, injectedItem, conversionApi, model ) {
-	const injectedList = injectedItem.parent;
-	const mapper = conversionApi.mapper;
-	const viewWriter = conversionApi.writer;
-
-	// Position where view list will be inserted.
-	let insertPosition = mapper.toViewPosition( model.createPositionBefore( modelItem ) );
-
-	// 1. Find previous list item that has same or smaller indent. Basically we are looking for a first model item
-	// that is "parent" or "sibling" of injected model item.
-	// If there is no such list item, it means that injected list item is the first item in "its list".
-	const refItem = getSiblingListItem( modelItem.previousSibling, {
-		sameIndent: true,
-		smallerIndent: true,
-		listIndent: modelItem.getAttribute( 'listIndent' )
-	} );
-	const prevItem = modelItem.previousSibling;
-
-	if ( refItem && refItem.getAttribute( 'listIndent' ) == modelItem.getAttribute( 'listIndent' ) ) {
-		// There is a list item with same indent - we found same-level sibling.
-		// Break the list after it. Inserted view item will be inserted in the broken space.
-		const viewItem = mapper.toViewElement( refItem );
-		insertPosition = viewWriter.breakContainer( viewWriter.createPositionAfter( viewItem ) );
-	} else {
-		// There is no list item with same indent. Check previous model item.
-		if ( prevItem && prevItem.name == 'listItem' ) {
-			// If it is a list item, it has to have lower indent.
-			// It means that inserted item should be added to it as its nested item.
-			insertPosition = mapper.toViewPosition( model.createPositionAt( prevItem, 'end' ) );
-		} else {
-			// Previous item is not a list item (or does not exist at all).
-			// Just map the position and insert the view item at mapped position.
-			insertPosition = mapper.toViewPosition( model.createPositionBefore( modelItem ) );
-		}
-	}
-
-	insertPosition = positionAfterUiElements( insertPosition );
-
-	// Insert the view item.
-	viewWriter.insert( insertPosition, injectedList );
-
-	// 2. Handle possible children of injected model item.
-	if ( prevItem && prevItem.name == 'listItem' ) {
-		const prevView = mapper.toViewElement( prevItem );
-
-		const walkerBoundaries = viewWriter.createRange( viewWriter.createPositionAt( prevView, 0 ), insertPosition );
-		const walker = walkerBoundaries.getWalker( { ignoreElementEnd: true } );
-
-		for ( const value of walker ) {
-			if ( value.item.is( 'li' ) ) {
-				const breakPosition = viewWriter.breakContainer( viewWriter.createPositionBefore( value.item ) );
-				const viewList = value.item.parent;
-
-				const targetPosition = viewWriter.createPositionAt( injectedItem, 'end' );
-				mergeViewLists( viewWriter, targetPosition.nodeBefore, targetPosition.nodeAfter );
-				viewWriter.move( viewWriter.createRangeOn( viewList ), targetPosition );
-
-				walker.position = breakPosition;
-			}
-		}
-	} else {
-		const nextViewList = injectedList.nextSibling;
-
-		if ( nextViewList && ( nextViewList.is( 'ul' ) || nextViewList.is( 'ol' ) ) ) {
-			let lastSubChild = null;
-
-			for ( const child of nextViewList.getChildren() ) {
-				const modelChild = mapper.toModelElement( child );
-
-				if ( modelChild && modelChild.getAttribute( 'listIndent' ) > modelItem.getAttribute( 'listIndent' ) ) {
-					lastSubChild = child;
-				} else {
-					break;
-				}
-			}
-
-			if ( lastSubChild ) {
-				viewWriter.breakContainer( viewWriter.createPositionAfter( lastSubChild ) );
-				viewWriter.move( viewWriter.createRangeOn( lastSubChild.parent ), viewWriter.createPositionAt( injectedItem, 'end' ) );
-			}
-		}
-	}
-
-	// Merge inserted view list with its possible neighbour lists.
-	mergeViewLists( viewWriter, injectedList, injectedList.nextSibling );
-	mergeViewLists( viewWriter, injectedList.previousSibling, injectedList );
-}
-
 // Helper function that takes all children of given `viewRemovedItem` and moves them in a correct place, according
 // to other given parameters.
 function hoistNestedLists( nextIndent, modelRemoveStartPosition, viewRemoveStartPosition, viewRemovedItem, conversionApi, model ) {
@@ -1112,12 +992,3 @@ function hoistNestedLists( nextIndent, modelRemoveStartPosition, viewRemoveStart
 		}
 	}
 }
-
-// Helper function that for given `view.Position`, returns a `view.Position` that is after all `view.UIElement`s that
-// are after given position.
-// For example:
-// <container:p>foo^<ui:span></ui:span><ui:span></ui:span>bar</contain:p>
-// For position ^, a position before "bar" will be returned.
-function positionAfterUiElements( viewPosition ) {
-	return viewPosition.getLastMatchingPosition( value => value.item.is( 'uiElement' ) );
-}

+ 2 - 2
packages/ckeditor5-list/src/listcommand.js

@@ -29,9 +29,9 @@ export default class ListCommand extends Command {
 		 * The type of the list created by the command.
 		 *
 		 * @readonly
-		 * @member {'numbered'|'bulleted'}
+		 * @member {'numbered'|'bulleted'|'todo'}
 		 */
-		this.type = type == 'bulleted' ? 'bulleted' : 'numbered';
+		this.type = type;
 
 		/**
 		 * A flag indicating whether the command is active, which means that the selection starts in a list of the same type.

+ 7 - 6
packages/ckeditor5-list/src/listediting.js

@@ -18,6 +18,7 @@ import {
 	cleanListItem,
 	modelViewInsertion,
 	modelViewChangeType,
+	modelViewMergeAfterChangeType,
 	modelViewMergeAfter,
 	modelViewRemove,
 	modelViewSplitOnInsert,
@@ -77,15 +78,12 @@ export default class ListEditing extends Plugin {
 		data.downcastDispatcher.on( 'insert', modelViewSplitOnInsert, { priority: 'high' } );
 		data.downcastDispatcher.on( 'insert:listItem', modelViewInsertion( editor.model ) );
 
-		editing.downcastDispatcher.on( 'attribute:listType:listItem', modelViewChangeType );
-		data.downcastDispatcher.on( 'attribute:listType:listItem', modelViewChangeType );
+		editing.downcastDispatcher.on( 'attribute:listType:listItem', modelViewChangeType, { priority: 'high' } );
+		editing.downcastDispatcher.on( 'attribute:listType:listItem', modelViewMergeAfterChangeType, { priority: 'low' } );
 		editing.downcastDispatcher.on( 'attribute:listIndent:listItem', modelViewChangeIndent( editor.model ) );
-		data.downcastDispatcher.on( 'attribute:listIndent:listItem', modelViewChangeIndent( editor.model ) );
 
 		editing.downcastDispatcher.on( 'remove:listItem', modelViewRemove( editor.model ) );
 		editing.downcastDispatcher.on( 'remove', modelViewMergeAfter, { priority: 'low' } );
-		data.downcastDispatcher.on( 'remove:listItem', modelViewRemove( editor.model ) );
-		data.downcastDispatcher.on( 'remove', modelViewMergeAfter, { priority: 'low' } );
 
 		data.upcastDispatcher.on( 'element:ul', cleanList, { priority: 'high' } );
 		data.upcastDispatcher.on( 'element:ol', cleanList, { priority: 'high' } );
@@ -103,7 +101,7 @@ export default class ListEditing extends Plugin {
 		editor.commands.add( 'indentList', new IndentCommand( editor, 'forward' ) );
 		editor.commands.add( 'outdentList', new IndentCommand( editor, 'backward' ) );
 
-		const viewDocument = this.editor.editing.view.document;
+		const viewDocument = editing.view.document;
 
 		// Overwrite default Enter key behavior.
 		// If Enter key is pressed with selection collapsed in empty list item, outdent it instead of breaking it.
@@ -172,6 +170,9 @@ export default class ListEditing extends Plugin {
 		editor.keystrokes.set( 'Shift+Tab', getCommandExecuter( 'outdentList' ) );
 	}
 
+	/**
+	 * @inheritDoc
+	 */
 	afterInit() {
 		const commands = this.editor.commands;
 

+ 5 - 36
packages/ckeditor5-list/src/listui.js

@@ -7,11 +7,12 @@
  * @module list/listui
  */
 
+import { createUIComponent } from './utils';
+
 import numberedListIcon from '../theme/icons/numberedlist.svg';
 import bulletedListIcon from '../theme/icons/bulletedlist.svg';
 
 import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
-import ButtonView from '@ckeditor/ckeditor5-ui/src/button/buttonview';
 
 /**
  * The list UI feature. It introduces the `'numberedList'` and `'bulletedList'` buttons that
@@ -24,42 +25,10 @@ export default class ListUI extends Plugin {
 	 * @inheritDoc
 	 */
 	init() {
-		// Create two buttons and link them with numberedList and bulletedList commands.
 		const t = this.editor.t;
-		this._addButton( 'numberedList', t( 'Numbered List' ), numberedListIcon );
-		this._addButton( 'bulletedList', t( 'Bulleted List' ), bulletedListIcon );
-	}
-
-	/**
-	 * Helper method for initializing a button and linking it with an appropriate command.
-	 *
-	 * @private
-	 * @param {String} commandName The name of the command.
-	 * @param {Object} label The button label.
-	 * @param {String} icon The source of the icon.
-	 */
-	_addButton( commandName, label, icon ) {
-		const editor = this.editor;
 
-		editor.ui.componentFactory.add( commandName, locale => {
-			const command = editor.commands.get( commandName );
-
-			const buttonView = new ButtonView( locale );
-
-			buttonView.set( {
-				label,
-				icon,
-				tooltip: true,
-				isToggleable: true
-			} );
-
-			// Bind button model to command.
-			buttonView.bind( 'isOn', 'isEnabled' ).to( command, 'value', 'isEnabled' );
-
-			// Execute command.
-			this.listenTo( buttonView, 'execute', () => editor.execute( commandName ) );
-
-			return buttonView;
-		} );
+		// Create two buttons and link them with numberedList and bulletedList commands.
+		createUIComponent( this.editor, 'numberedList', t( 'Numbered List' ), numberedListIcon );
+		createUIComponent( this.editor, 'bulletedList', t( 'Bulleted List' ), bulletedListIcon );
 	}
 }

+ 37 - 0
packages/ckeditor5-list/src/todolist.js

@@ -0,0 +1,37 @@
+/**
+ * @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 list/todolist
+ */
+
+import TodoListEditing from './todolistediting';
+import TodoListUI from './todolistui';
+
+import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
+
+/**
+ * The todo list feature.
+ *
+ * This is a "glue" plugin which loads the {@link module:list/todolistediting~TodoListEditing todo list editing feature}
+ * and {@link module:list/todolistui~TodoListUI list UI feature}.
+ *
+ * @extends module:core/plugin~Plugin
+ */
+export default class List extends Plugin {
+	/**
+	 * @inheritDoc
+	 */
+	static get requires() {
+		return [ TodoListEditing, TodoListUI ];
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	static get pluginName() {
+		return 'TodoList';
+	}
+}

+ 106 - 0
packages/ckeditor5-list/src/todolistcheckcommand.js

@@ -0,0 +1,106 @@
+/**
+ * @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 list/todolistcheckedcommand
+ */
+
+import Command from '@ckeditor/ckeditor5-core/src/command';
+
+const attributeKey = 'todoListChecked';
+
+/**
+ * @extends module:core/command~Command
+ */
+export default class TodoListCheckCommand extends Command {
+	/**
+	 * @inheritDoc
+	 */
+	constructor( editor ) {
+		super( editor );
+
+		/**
+		 * Flag indicating whether the command is active. The command is active when at least one of
+		 * {@link module:engine/model/selection~Selection selected} elements is a todo list item.
+		 *
+		 * @observable
+		 * @readonly
+		 * @member {Boolean} #isEnabled
+		 */
+
+		/**
+		 * A List of todo list item selected by the {@link module:engine/model/selection~Selection}.
+		 *
+		 * @observable
+		 * @readonly
+		 * @member {Array.<module:engine/model/element~Element>} #value
+		 */
+
+		/**
+		 * List of todo list items selected by the {@link module:engine/model/selection~Selection}.
+		 *
+		 * @type {Array.<module:engine/model/element~Element>}
+		 * @private
+		 */
+		this._selectedElements = [];
+
+		// Refresh command before executing to be sure all values are up to date.
+		// It is needed when selection has changed before command execution, in the same change block.
+		this.on( 'execute', () => {
+			this.refresh();
+		}, { priority: 'highest' } );
+	}
+
+	/**
+	 * Updates the command's {@link #value} and {@link #isEnabled} based on the current selection.
+	 */
+	refresh() {
+		this._selectedElements = this._getSelectedItems();
+		this.value = this._selectedElements.every( element => !!element.getAttribute( 'todoListChecked' ) );
+		this.isEnabled = !!this._selectedElements.length;
+	}
+
+	/**
+	 * Gets all todo list items selected by the {@link module:engine/model/selection~Selection}.
+	 *
+	 * @private
+	 * @returns {Array.<module:engine/model/element~Element>}
+	 */
+	_getSelectedItems() {
+		const model = this.editor.model;
+		const schema = model.schema;
+
+		const selectionRange = model.document.selection.getFirstRange();
+		const startElement = selectionRange.start.parent;
+		const elements = [];
+
+		if ( schema.checkAttribute( startElement, attributeKey ) ) {
+			elements.push( startElement );
+		}
+
+		for ( const item of selectionRange.getItems() ) {
+			if ( schema.checkAttribute( item, attributeKey ) && !elements.includes( item ) ) {
+				elements.push( item );
+			}
+		}
+
+		return elements;
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	execute() {
+		this.editor.model.change( writer => {
+			for ( const element of this._selectedElements ) {
+				if ( !this.value ) {
+					writer.setAttribute( attributeKey, true, element );
+				} else {
+					writer.removeAttribute( attributeKey, element );
+				}
+			}
+		} );
+	}
+}

+ 321 - 0
packages/ckeditor5-list/src/todolistconverters.js

@@ -0,0 +1,321 @@
+/**
+ * @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 list/todolistconverters
+ */
+
+/* global document */
+
+import { generateLiInUl, injectViewList, findInRange } from './utils';
+import createElement from '@ckeditor/ckeditor5-utils/src/dom/createelement';
+
+/**
+ * A model-to-view converter for `listItem` model element insertion.
+ *
+ * It converts `listItem` model element to an unordered list with {@link module:engine/view/uielement~UIElement checkbox element}
+ * at the beginning of each list item. It also merges the list with surrounding lists (if available).
+ *
+ * It is used by {@link module:engine/controller/editingcontroller~EditingController}.
+ *
+ * @see module:engine/conversion/downcastdispatcher~DowncastDispatcher#event:insert
+ * @param {module:engine/model/model~Model} model Model instance.
+ * @returns {Function} Returns a conversion callback.
+ */
+export function modelViewInsertion( model, onCheckboxChecked ) {
+	return ( evt, data, conversionApi ) => {
+		const consumable = conversionApi.consumable;
+
+		if ( !consumable.test( data.item, 'insert' ) ||
+			!consumable.test( data.item, 'attribute:listType' ) ||
+			!consumable.test( data.item, 'attribute:listIndent' )
+		) {
+			return;
+		}
+
+		if ( data.item.getAttribute( 'listType' ) != 'todo' ) {
+			return;
+		}
+
+		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 viewItem = generateLiInUl( modelItem, conversionApi );
+
+		const isChecked = !!modelItem.getAttribute( 'todoListChecked' );
+		const checkmarkElement = createCheckmarkElement( modelItem, viewWriter, isChecked, onCheckboxChecked );
+
+		viewWriter.addClass( 'todo-list', viewItem.parent );
+		viewWriter.insert( viewWriter.createPositionAt( viewItem, 0 ), checkmarkElement );
+
+		injectViewList( modelItem, viewItem, conversionApi, model );
+	};
+}
+
+/**
+ * A model-to-view converter for model `$text` element inside a todo 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 `listItem` model element insertion.
+ *
+ * It is used by {@link module:engine/controller/datacontroller~DataController}.
+ *
+ * @see module:engine/conversion/downcastdispatcher~DowncastDispatcher#event:insert
+ * @param {module:engine/model/model~Model} model Model instance.
+ * @returns {Function} Returns a conversion callback.
+ */
+export function dataModelViewInsertion( model ) {
+	return ( evt, data, conversionApi ) => {
+		const consumable = conversionApi.consumable;
+
+		if ( !consumable.test( data.item, 'insert' ) ||
+			!consumable.test( data.item, 'attribute:listType' ) ||
+			!consumable.test( data.item, 'attribute:listIndent' )
+		) {
+			return;
+		}
+
+		if ( data.item.getAttribute( 'listType' ) != 'todo' ) {
+			return;
+		}
+
+		consumable.consume( data.item, 'insert' );
+		consumable.consume( data.item, 'attribute:listType' );
+		consumable.consume( data.item, 'attribute:listIndent' );
+
+		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 checkbox = viewWriter.createEmptyElement( 'input', {
+			type: 'checkbox',
+			disabled: 'disabled',
+			class: 'todo-list__checkmark'
+		} );
+
+		if ( data.item.getAttribute( 'todoListChecked' ) ) {
+			viewWriter.setAttribute( 'checked', 'checked', checkbox );
+		}
+
+		viewWriter.insert( viewWriter.createPositionAt( viewItem, 0 ), checkbox );
+		viewWriter.wrap( viewWriter.createRangeOn( checkbox ), label );
+
+		injectViewList( modelItem, viewItem, conversionApi, model );
+	};
+}
+
+/**
+ * A model-to-view converter for model `$text` element inside a todo 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' } );
+	const label = viewWriter.createAttributeElement( 'label' );
+
+	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 checkbox element inside a view list item.
+ *
+ * It changes `listType` of model `listItem` to a `todo` value.
+ * When view checkbox is marked as checked the additional `todoListChecked="true"` attribute is added to model item.
+ *
+ * It is used by {@link module:engine/controller/datacontroller~DataController}.
+ *
+ * @see module:engine/conversion/upcastdispatcher~UpcastDispatcher#event:element
+ * @param {module:utils/eventinfo~EventInfo} evt An object containing information about the fired event.
+ * @param {Object} data An object containing conversion input and a placeholder for conversion output and possibly other values.
+ * @param {module:engine/conversion/upcastdispatcher~UpcastConversionApi} conversionApi Conversion interface to be used by the callback.
+ */
+export function dataViewModelCheckmarkInsertion( evt, data, conversionApi ) {
+	const modelCursor = data.modelCursor;
+	const modelItem = modelCursor.parent;
+	const viewItem = data.viewItem;
+
+	if ( viewItem.getAttribute( 'type' ) != 'checkbox' || modelItem.name != 'listItem' || !modelCursor.isAtStart ) {
+		return;
+	}
+
+	if ( !conversionApi.consumable.consume( viewItem, { name: true } ) ) {
+		return;
+	}
+
+	const writer = conversionApi.writer;
+
+	writer.setAttribute( 'listType', 'todo', modelItem );
+
+	if ( data.viewItem.getAttribute( 'checked' ) == 'checked' ) {
+		writer.setAttribute( 'todoListChecked', true, modelItem );
+	}
+
+	data.modelRange = writer.createRange( modelCursor );
+}
+
+/**
+ * A model-to-view converter for `listType` attribute change on `listItem` model element.
+ *
+ * This change means that `<li>` elements parent changes to `<ul class="todo-list">` and
+ * {@link module:engine/view/uielement~UIElement checkbox UI element} is added at the beginning
+ * of the list item element (or vice versa).
+ *
+ * This converter is preceded by {@link module:list/converters~modelViewChangeType} and followed by
+ * {@link module:list/converters~modelViewMergeAfterChangeType} to handle splitting and merging surrounding lists of the same type.
+ *
+ * It is used by {@link module:engine/controller/editingcontroller~EditingController}.
+ *
+ * @see module:engine/conversion/downcastdispatcher~DowncastDispatcher#event:attribute
+ * @param {Function} onCheckedChange Callback fired after clicking on checkmark element.
+ * @returns {Function} Returns a conversion callback.
+ */
+export function modelViewChangeType( onCheckedChange ) {
+	return ( evt, data, conversionApi ) => {
+		const viewItem = conversionApi.mapper.toViewElement( data.item );
+		const viewWriter = conversionApi.writer;
+
+		if ( data.attributeNewValue == 'todo' ) {
+			const isChecked = !!data.item.getAttribute( 'todoListChecked' );
+			const checkmarkElement = createCheckmarkElement( data.item, viewWriter, isChecked, onCheckedChange );
+
+			viewWriter.addClass( 'todo-list', viewItem.parent );
+			viewWriter.insert( viewWriter.createPositionAt( viewItem, 0 ), checkmarkElement );
+		} else if ( data.attributeOldValue == 'todo' ) {
+			viewWriter.removeClass( 'todo-list', viewItem.parent );
+			viewWriter.remove( viewItem.getChild( 0 ) );
+		}
+	};
+}
+
+/**
+ * A model-to-view converter for `todoListChecked` attribute change on `listItem` model element.
+ *
+ * It marks {@link module:engine/view/uielement~UIElement checkbox UI element} as checked.
+ *
+ * It is used by {@link module:engine/controller/editingcontroller~EditingController}.
+ *
+ * @see module:engine/conversion/downcastdispatcher~DowncastDispatcher#event:attribute
+ * @param {Function} onCheckedChange Callback fired after clicking on checkmark element.
+ * @returns {Function} Returns a conversion callback.
+ */
+export function modelViewChangeChecked( onCheckedChange ) {
+	return ( evt, data, conversionApi ) => {
+		// Do not convert `todoListChecked` attribute when todo list item has changed to other list item.
+		// This attribute will be removed by the model post fixer.
+		if ( data.item.getAttribute( 'listType' ) != 'todo' ) {
+			return;
+		}
+
+		if ( !conversionApi.consumable.consume( data.item, 'attribute:todoListChecked' ) ) {
+			return;
+		}
+
+		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 );
+		const newCheckmarkElement = createCheckmarkElement( data.item, viewWriter, isChecked, onCheckedChange );
+
+		viewWriter.insert( viewWriter.createPositionAfter( oldCheckmarkElement ), newCheckmarkElement );
+		viewWriter.remove( oldCheckmarkElement );
+	};
+}
+
+// Creates checkbox UI element.
+//
+// @private
+// @param {module:engine/model/item~Item} modelItem
+// @param {module:engine/view/downcastwriter~DowncastWriter} viewWriter
+// @param {Boolean} isChecked
+// @param {Function} onChange
+// @returns {module:view/uielement~UIElement}
+function createCheckmarkElement( modelItem, viewWriter, isChecked, onChange ) {
+	const uiElement = viewWriter.createUIElement(
+		'label',
+		{
+			class: 'todo-list__checkmark',
+			contenteditable: false
+		},
+		function( domDocument ) {
+			const checkbox = createElement( document, 'input', { type: 'checkbox', } );
+
+			checkbox.checked = isChecked;
+			checkbox.addEventListener( 'change', () => onChange( modelItem ) );
+
+			const domElement = this.toDomElement( domDocument );
+
+			domElement.appendChild( checkbox );
+
+			return domElement;
+		}
+	);
+
+	if ( isChecked ) {
+		viewWriter.addClass( 'todo-list__checkmark_checked', uiElement );
+	}
+
+	return uiElement;
+}

+ 310 - 0
packages/ckeditor5-list/src/todolistediting.js

@@ -0,0 +1,310 @@
+/**
+ * @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 list/todolistediting
+ */
+
+import ListCommand from './listcommand';
+import ListEditing from './listediting';
+import TodoListCheckCommand from './todolistcheckcommand';
+
+import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
+
+import {
+	modelViewInsertion,
+	modelViewTextInsertion,
+	dataModelViewInsertion,
+	dataModelViewTextInsertion,
+	dataViewModelCheckmarkInsertion,
+	modelViewChangeChecked,
+	modelViewChangeType
+} from './todolistconverters';
+
+import { findInRange } from './utils';
+
+/**
+ * The engine of the todo list feature. It handles creating, editing and removing todo lists and its items.
+ *
+ * It registers all functionalities of {@link module:list/listediting~ListEditing list editing plugin} and extends
+ * it by `'todoList'` command.
+ *
+ * @extends module:core/plugin~Plugin
+ */
+export default class TodoListEditing extends Plugin {
+	/**
+	 * @inheritDoc
+	 */
+	static get requires() {
+		return [ ListEditing ];
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	init() {
+		const editor = this.editor;
+		const { editing, data, model } = editor;
+		const viewDocument = editing.view.document;
+
+		// Extend schema.
+		model.schema.extend( 'listItem', {
+			allowAttributes: [ 'todoListChecked' ]
+		} );
+
+		// Disallow todoListChecked attribute on other nodes than listItem with todo listType.
+		model.schema.addAttributeCheck( ( context, attributeName ) => {
+			const item = context.last;
+
+			if ( attributeName == 'todoListChecked' && item.name == 'listItem' && item.getAttribute( 'listType' ) != 'todo' ) {
+				return false;
+			}
+		} );
+
+		// Register commands.
+		editor.commands.add( 'todoList', new ListCommand( editor, 'todo' ) );
+		editor.commands.add( 'todoListCheck', new TodoListCheckCommand( editor ) );
+
+		// Define converters.
+		editing.downcastDispatcher.on(
+			'insert:listItem',
+			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 ) )
+		);
+		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 checkmark 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 checkmark element.
+		viewDocument.registerPostFixer( writer => {
+			const changedCheckmarkElements = getChangedCheckmarkElements( writer, changedViewNodes );
+
+			changedViewNodes.clear();
+
+			return moveUIElementsAfterCheckmark( writer, changedCheckmarkElements );
+		} );
+
+		// Move selection after a checkmark element.
+		viewDocument.registerPostFixer( writer => moveSelectionAfterCheckmark( writer, viewDocument.selection ) );
+
+		// Jump at the end of the previous node on left arrow key press, when selection is after the checkbox.
+		//
+		// <blockquote><p>Foo</p></blockquote>
+		// <ul><li><checkbox/>{}Bar</li></ul>
+		//
+		// press: `<-`
+		//
+		// <blockquote><p>Foo{}</p></blockquote>
+		// <ul><li><checkbox/>Bar</li></ul>
+		editor.keystrokes.set( 'arrowleft', ( evt, stop ) => jumpOverCheckmarkOnLeftArrowKeyPress( stop, model ) );
+
+		// Toggle check state of selected todo list items on keystroke.
+		editor.keystrokes.set( 'Ctrl+space', () => editor.execute( 'todoListCheck' ) );
+
+		// Remove `todoListChecked` attribute when a host element is no longer a todo list item.
+		const listItemsToFix = new Set();
+
+		this.listenTo( model, 'applyOperation', ( evt, args ) => {
+			const operation = args[ 0 ];
+
+			if ( operation.type == 'rename' && operation.oldName == 'listItem' ) {
+				const item = operation.position.nodeAfter;
+
+				if ( item.hasAttribute( 'todoListChecked' ) ) {
+					listItemsToFix.add( item );
+				}
+			} else if ( operation.type == 'changeAttribute' && operation.key == 'listType' && operation.oldValue === 'todo' ) {
+				for ( const item of operation.range.getItems() ) {
+					if ( item.hasAttribute( 'todoListChecked' ) && item.getAttribute( 'listType' ) !== 'todo' ) {
+						listItemsToFix.add( item );
+					}
+				}
+			}
+		} );
+
+		model.document.registerPostFixer( writer => {
+			let hasChanged = false;
+
+			for ( const listItem of listItemsToFix ) {
+				writer.removeAttribute( 'todoListChecked', listItem );
+				hasChanged = true;
+			}
+
+			listItemsToFix.clear();
+
+			return hasChanged;
+		} );
+	}
+
+	/**
+	 * Handles checkmark element change, moves selection to the corresponding model item to makes it possible
+	 * to toggle `todoListChecked` attribute using command and restore the selection position.
+	 *
+	 * Some say it's a hack :) Moving selection only for executing the command on a certain node and restoring it after,
+	 * it's not a clear solution. We need to design an API for using commands beyond the selection range.
+	 * See https://github.com/ckeditor/ckeditor5/issues/1954.
+	 *
+	 * @private
+	 * @param {module:engine/model/element~Element} listItem
+	 */
+	_handleCheckmarkChange( listItem ) {
+		const editor = this.editor;
+		const model = editor.model;
+		const previousSelectionRanges = Array.from( model.document.selection.getRanges() );
+
+		model.change( writer => {
+			writer.setSelection( listItem, 'end' );
+			editor.execute( 'todoListCheck' );
+			writer.setSelection( previousSelectionRanges );
+		} );
+	}
+}
+
+// Moves all uiElements in the todo list item after the checkmark 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 selection in the todo list item after the checkmark 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 left arrow key and move selection at the end of the previous block element if the selection is just after
+// the checkmark element. In other words, it jumps over the checkmark element when moving the selection to the left.
+//
+// @private
+// @param {Function} stopKeyEvent
+// @param {module:engine/model/model~Model} model
+function jumpOverCheckmarkOnLeftArrowKeyPress( stopKeyEvent, model ) {
+	const schema = model.schema;
+	const selection = model.document.selection;
+
+	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 ( newRange ) {
+			stopKeyEvent();
+			model.change( writer => writer.setSelection( newRange ) );
+		}
+	}
+}
+
+// Gets list of all checkmark 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 list item ancestor of 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;
+		}
+	}
+}

+ 32 - 0
packages/ckeditor5-list/src/todolistui.js

@@ -0,0 +1,32 @@
+/**
+ * @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 list/todolistui
+ */
+
+import { createUIComponent } from './utils';
+
+import todoListIcon from '../theme/icons/todolist.svg';
+import '../theme/list.css';
+
+import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
+
+/**
+ * The todo list UI feature. It introduces the `'todoList'` button that
+ * allow to convert elements to and from list items and indent or outdent them.
+ *
+ * @extends module:core/plugin~Plugin
+ */
+export default class TodoListUI extends Plugin {
+	/**
+	 * @inheritDoc
+	 */
+	init() {
+		const t = this.editor.t;
+
+		createUIComponent( this.editor, 'todoList', t( 'Todo List' ), todoListIcon );
+	}
+}

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

@@ -8,6 +8,7 @@
  */
 
 import { getFillerOffset } from '@ckeditor/ckeditor5-engine/src/view/containerelement';
+import ButtonView from '@ckeditor/ckeditor5-ui/src/button/buttonview';
 
 /**
  * Creates a list item {@link module:engine/view/containerelement~ContainerElement}.
@@ -17,11 +18,245 @@ import { getFillerOffset } from '@ckeditor/ckeditor5-engine/src/view/containerel
  */
 export function createViewListItemElement( writer ) {
 	const viewItem = writer.createContainerElement( 'li' );
+
 	viewItem.getFillerOffset = getListItemFillerOffset;
 
 	return viewItem;
 }
 
+/**
+ * Helper function that creates a `<ul><li></li></ul>` or (`<ol>`) structure out of given `modelItem` model `listItem` element.
+ * Then, it binds created view list item (<li>) with model `listItem` element.
+ * The function then returns created view list item (<li>).
+ *
+ * @param {module:engine/model/item~Item} modelItem Model list item.
+ * @param {module:engine/conversion/upcastdispatcher~UpcastConversionApi} conversionApi Conversion interface.
+ * @returns {module:engine/view/containerelement~ContainerElement} View list element.
+ */
+export function generateLiInUl( modelItem, conversionApi ) {
+	const mapper = conversionApi.mapper;
+	const viewWriter = conversionApi.writer;
+	const listType = modelItem.getAttribute( 'listType' ) == 'numbered' ? 'ol' : 'ul';
+	const viewItem = createViewListItemElement( viewWriter );
+
+	const viewList = viewWriter.createContainerElement( listType, null );
+
+	viewWriter.insert( viewWriter.createPositionAt( viewList, 0 ), viewItem );
+
+	mapper.bindElements( modelItem, viewItem );
+
+	return viewItem;
+}
+
+/**
+ * Helper function that takes model list item element `modelItem`, corresponding view list item element `injectedItem`
+ * that is not added to the view and is inside a view list element (`ul` or `ol`) and is that's list only child.
+ * The list is inserted at correct position (element breaking may be needed) and then merged with it's siblings.
+ * See comments below to better understand the algorithm.
+ *
+ * @param {module:engine/view/item~Item} modelItem Model list item.
+ * @param {module:engine/view/containerelement~ContainerElement} injectedItem
+ * @param {module:engine/conversion/upcastdispatcher~UpcastConversionApi} conversionApi Conversion interface.
+ * @param {module:engine/model/model~Model} model The model instance.
+ */
+export function injectViewList( modelItem, injectedItem, conversionApi, model ) {
+	const injectedList = injectedItem.parent;
+	const mapper = conversionApi.mapper;
+	const viewWriter = conversionApi.writer;
+
+	// Position where view list will be inserted.
+	let insertPosition = mapper.toViewPosition( model.createPositionBefore( modelItem ) );
+
+	// 1. Find previous list item that has same or smaller indent. Basically we are looking for a first model item
+	// that is "parent" or "sibling" of injected model item.
+	// If there is no such list item, it means that injected list item is the first item in "its list".
+	const refItem = getSiblingListItem( modelItem.previousSibling, {
+		sameIndent: true,
+		smallerIndent: true,
+		listIndent: modelItem.getAttribute( 'listIndent' )
+	} );
+	const prevItem = modelItem.previousSibling;
+
+	if ( refItem && refItem.getAttribute( 'listIndent' ) == modelItem.getAttribute( 'listIndent' ) ) {
+		// There is a list item with same indent - we found same-level sibling.
+		// Break the list after it. Inserted view item will be inserted in the broken space.
+		const viewItem = mapper.toViewElement( refItem );
+		insertPosition = viewWriter.breakContainer( viewWriter.createPositionAfter( viewItem ) );
+	} else {
+		// There is no list item with same indent. Check previous model item.
+		if ( prevItem && prevItem.name == 'listItem' ) {
+			// If it is a list item, it has to have lower indent.
+			// It means that inserted item should be added to it as its nested item.
+			insertPosition = mapper.toViewPosition( model.createPositionAt( prevItem, 'end' ) );
+		} else {
+			// Previous item is not a list item (or does not exist at all).
+			// Just map the position and insert the view item at mapped position.
+			insertPosition = mapper.toViewPosition( model.createPositionBefore( modelItem ) );
+		}
+	}
+
+	insertPosition = positionAfterUiElements( insertPosition );
+
+	// Insert the view item.
+	viewWriter.insert( insertPosition, injectedList );
+
+	// 2. Handle possible children of injected model item.
+	if ( prevItem && prevItem.name == 'listItem' ) {
+		const prevView = mapper.toViewElement( prevItem );
+
+		const walkerBoundaries = viewWriter.createRange( viewWriter.createPositionAt( prevView, 0 ), insertPosition );
+		const walker = walkerBoundaries.getWalker( { ignoreElementEnd: true } );
+
+		for ( const value of walker ) {
+			if ( value.item.is( 'li' ) ) {
+				const breakPosition = viewWriter.breakContainer( viewWriter.createPositionBefore( value.item ) );
+				const viewList = value.item.parent;
+
+				const targetPosition = viewWriter.createPositionAt( injectedItem, 'end' );
+				mergeViewLists( viewWriter, targetPosition.nodeBefore, targetPosition.nodeAfter );
+				viewWriter.move( viewWriter.createRangeOn( viewList ), targetPosition );
+
+				walker.position = breakPosition;
+			}
+		}
+	} else {
+		const nextViewList = injectedList.nextSibling;
+
+		if ( nextViewList && ( nextViewList.is( 'ul' ) || nextViewList.is( 'ol' ) ) ) {
+			let lastSubChild = null;
+
+			for ( const child of nextViewList.getChildren() ) {
+				const modelChild = mapper.toModelElement( child );
+
+				if ( modelChild && modelChild.getAttribute( 'listIndent' ) > modelItem.getAttribute( 'listIndent' ) ) {
+					lastSubChild = child;
+				} else {
+					break;
+				}
+			}
+
+			if ( lastSubChild ) {
+				viewWriter.breakContainer( viewWriter.createPositionAfter( lastSubChild ) );
+				viewWriter.move( viewWriter.createRangeOn( lastSubChild.parent ), viewWriter.createPositionAt( injectedItem, 'end' ) );
+			}
+		}
+	}
+
+	// Merge inserted view list with its possible neighbour lists.
+	mergeViewLists( viewWriter, injectedList, injectedList.nextSibling );
+	mergeViewLists( viewWriter, injectedList.previousSibling, injectedList );
+}
+
+/**
+ * Helper function that takes two parameters, that are expected to be view list elements, and merges them.
+ * The merge happen only if both parameters are list elements of the same types (the same element name and the same class attributes).
+ *
+ * @param {module:engine/view/downcastwriter~DowncastWriter} viewWriter The writer instance.
+ * @param {module:engine/view/item~Item} firstList First element o compare.
+ * @param {module:engine/view/item~Item} secondList Second parameter to compare.
+ * @returns {module:engine/view/position~Position|null} Position after merge or `null` when there was no merge.
+ */
+export function mergeViewLists( viewWriter, firstList, secondList ) {
+	// Check if two lists are going to be merged.
+	if ( !firstList || !secondList || ( firstList.name != 'ul' && firstList.name != 'ol' ) ) {
+		return null;
+	}
+
+	// Both parameters are list elements, so compare types now.
+	if ( firstList.name != secondList.name || firstList.getAttribute( 'class' ) !== secondList.getAttribute( 'class' ) ) {
+		return null;
+	}
+
+	return viewWriter.mergeContainers( viewWriter.createPositionAfter( firstList ) );
+}
+
+/**
+ * Helper function that for given `view.Position`, returns a `view.Position` that is after all `view.UIElement`s that
+ * are after given position.
+ *
+ * For example:
+ * `<container:p>foo^<ui:span></ui:span><ui:span></ui:span>bar</container:p>`
+ * For position ^, a position before "bar" will be returned.
+ *
+ * @param {module:engine/view/position~Position} viewPosition
+ * @returns {module:engine/view/position~Position}
+ */
+export function positionAfterUiElements( viewPosition ) {
+	return viewPosition.getLastMatchingPosition( value => value.item.is( 'uiElement' ) );
+}
+
+/**
+ * Helper function that seeks for a previous list item sibling of given model item which meets given criteria
+ * passed by an options object.
+ *
+ * @param {module:engine/model/item~Item} modelItem
+ * @param {Object} options Search criteria.
+ * @param {Boolean} [options.sameIndent=false] Whether sought sibling should have same indent.
+ * @param {Boolean} [options.smallerIndent=false] Whether sought sibling should have smaller indent.
+ * @param {Number} [options.listIndent] The reference indent.
+ * @returns {module:engine/model/item~Item|null}
+ */
+export function getSiblingListItem( modelItem, options ) {
+	const sameIndent = !!options.sameIndent;
+	const smallerIndent = !!options.smallerIndent;
+	const indent = options.listIndent;
+
+	let item = modelItem;
+
+	while ( item && item.name == 'listItem' ) {
+		const itemIndent = item.getAttribute( 'listIndent' );
+
+		if ( ( sameIndent && indent == itemIndent ) || ( smallerIndent && indent > itemIndent ) ) {
+			return item;
+		}
+
+		item = item.previousSibling;
+	}
+
+	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 an UI button and linking it with an appropriate command.
+ *
+ * @private
+ * @param {module:core/editor/editor~Editor} editor The editor instance to which UI component will be added.
+ * @param {String} commandName The name of the command.
+ * @param {Object} label The button label.
+ * @param {String} icon The source of the icon.
+ */
+export function createUIComponent( editor, commandName, label, icon ) {
+	editor.ui.componentFactory.add( commandName, locale => {
+		const command = editor.commands.get( commandName );
+		const buttonView = new ButtonView( locale );
+
+		buttonView.set( {
+			label,
+			icon,
+			tooltip: true,
+			isToggleable: true
+		} );
+
+		// Bind button model to command.
+		buttonView.bind( 'isOn', 'isEnabled' ).to( command, 'value', 'isEnabled' );
+
+		// Execute command.
+		buttonView.on( 'execute', () => editor.execute( commandName ) );
+
+		return buttonView;
+	} );
+}
+
 // Implementation of getFillerOffset for view list item element.
 //
 // @returns {Number|null} Block filler offset or `null` if block filler is not needed.

+ 4 - 0
packages/ckeditor5-list/tests/listediting.js

@@ -50,6 +50,10 @@ describe( 'ListEditing', () => {
 			} );
 	} );
 
+	afterEach( () => {
+		return editor.destroy();
+	} );
+
 	it( 'should be loaded', () => {
 		expect( editor.plugins.get( ListEditing ) ).to.be.instanceOf( ListEditing );
 	} );

+ 4 - 2
packages/ckeditor5-list/tests/listui.js

@@ -15,10 +15,10 @@ import ButtonView from '@ckeditor/ckeditor5-ui/src/button/buttonview';
 import { setData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
 
 describe( 'ListUI', () => {
-	let editor, model, bulletedListButton, numberedListButton;
+	let editorElement, editor, model, bulletedListButton, numberedListButton;
 
 	beforeEach( () => {
-		const editorElement = document.createElement( 'div' );
+		editorElement = document.createElement( 'div' );
 		document.body.appendChild( editorElement );
 
 		return ClassicTestEditor.create( editorElement, { plugins: [ Paragraph, BlockQuote, ListEditing, ListUI ] } )
@@ -32,6 +32,8 @@ describe( 'ListUI', () => {
 	} );
 
 	afterEach( () => {
+		editorElement.remove();
+
 		return editor.destroy();
 	} );
 

+ 25 - 0
packages/ckeditor5-list/tests/manual/todo-list.html

@@ -0,0 +1,25 @@
+<div id="editor">
+	<p>This is a test for list feature.</p>
+	<p>Some more text for testing.</p>
+	<ul>
+		<li><input type="checkbox">Todo list item 1</li>
+		<li><input type="checkbox" checked="checked">Todo list item 2</li>
+		<li><input type="checkbox">Todo list item 3</li>
+		<li><input type="checkbox" checked="checked">Todo list item 4</li>
+		<li><input type="checkbox">Todo list item 5</li>
+		<li><input type="checkbox">Todo list item 6</li>
+		<li><input type="checkbox" checked="checked">Todo list item 7</li>
+		<li><input type="checkbox">Todo list item 8</li>
+	</ul>
+	<p>Paragraph.</p>
+	<p>Another testing paragraph.</p>
+	<ol>
+		<li>Numbered list item</li>
+	</ol>
+	<ul>
+		<li><input type="checkbox">Todo list item</li>
+	</ul>
+	<ul>
+		<li>Bullet list</li>
+	</ul>
+</div>

+ 40 - 0
packages/ckeditor5-list/tests/manual/todo-list.js

@@ -0,0 +1,40 @@
+/**
+ * @license Copyright (c) 2003-2019, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+/* globals console, window, document */
+
+import ClassicEditor from '@ckeditor/ckeditor5-editor-classic/src/classiceditor';
+import Enter from '@ckeditor/ckeditor5-enter/src/enter';
+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 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 TodoList from '../../src/todolist';
+
+ClassicEditor
+	.create( document.querySelector( '#editor' ), {
+		plugins: [ Enter, Typing, Heading, Highlight, Table, Bold, Paragraph, Undo, List, TodoList, Clipboard ],
+		toolbar: [
+			'heading', '|', 'bulletedList', 'numberedList', 'todoList', '|', 'bold', 'highlight', 'insertTable', '|', 'undo', 'redo'
+		],
+		table: {
+			contentToolbar: [
+				'tableColumn',
+				'tableRow',
+				'mergeTableCells'
+			]
+		}
+	} )
+	.then( editor => {
+		window.editor = editor;
+	} )
+	.catch( err => {
+		console.error( err.stack );
+	} );

+ 86 - 0
packages/ckeditor5-list/tests/manual/todo-list.md

@@ -0,0 +1,86 @@
+## Loading
+
+1. The data should be loaded with:
+  * two paragraphs,
+  * todo list with eight items, where 2,4 and 7 are checked,
+  * two paragraphs,
+  * numbered list with one item,
+  * todo list with one unchecked item,
+  * bullet list with one item.
+2. Toolbar should have three buttons: for bullet, numbered and todo list.
+
+## Testing
+
+### Creating:
+
+1. Convert first paragraph to todo list item
+2. Create empty paragraph and convert to todo list item
+3. Press `Enter` in the middle of item
+4. Press `Enter` at the start of item
+5. Press `Enter` at the end of item
+
+### Removing:
+
+1. Delete all contents from list item and then the list item
+2. Press enter in empty list item
+3. Click on highlighted button ("turn off" list feature)
+4. Do it for first, second and last list item
+
+### Changing type:
+
+1. Change type from todo to numbered for checked and unchecked list item
+3. Do it for multiple items at once
+
+### Merging:
+
+1. Convert paragraph before todo list to same type of list
+2. Convert paragraph after todo list to same type of list
+3. Convert paragraph before todo list to different type of list
+4. Convert paragraph after todo list to different type of list
+5. Convert first paragraph to todo list, then convert second paragraph to todo list
+6. Convert multiple items and paragraphs at once
+
+### Toggling check state:
+
+1. Put selection in the middle of unchecked the todo list item
+2. Check list item (selection should not move)
+
+---
+
+1. Select multiple todo list items
+2. Check or uncheck todo list item (selection should not move)
+
+---
+
+1. Check todo list item
+2. Convert checked list item to other list item
+3. Convert this list item once again to todo list item ()should be unchecked)
+
+---
+
+1. Put collapsed selection to todo list item
+2. Press `Ctrl+Space` (check state should toggle)
+
+### Toggling check state for multiple items:
+
+1. Select two unchecked list items
+2. Press `Ctrl+Space` (both should be checked)
+3. Press `Ctrl+Space` once again (both should be unchecked)
+
+---
+
+1. Select checked and unchecked list item
+2. Press `Ctrl+Space` (both should be checked)
+
+---
+
+1. Select the entire content
+2. Press `Ctrl+Space` (all todo list items should be checked)
+3. Press `Ctrl+Space` once again (all todo list items should be unchecked)
+
+### Integration with attribute elements:
+
+1. Select multiple todo list items
+2. Highlight selected text
+3. Check or uncheck highlighted todo list item
+4. Type inside highlighted todo list item

+ 18 - 0
packages/ckeditor5-list/tests/todolist.js

@@ -0,0 +1,18 @@
+/**
+ * @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 TodoList from '../src/todolist';
+import TodoListEditing from '../src/todolistediting';
+import TodoListUI from '../src/todolistui';
+
+describe( 'TodoList', () => {
+	it( 'should be named', () => {
+		expect( TodoList.pluginName ).to.equal( 'TodoList' );
+	} );
+
+	it( 'should require TodoListEditing and TodoListUI', () => {
+		expect( TodoList.requires ).to.deep.equal( [ TodoListEditing, TodoListUI ] );
+	} );
+} );

+ 220 - 0
packages/ckeditor5-list/tests/todolistcheckcommand.js

@@ -0,0 +1,220 @@
+/**
+ * @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 TodoListEditing from '../src/todolistediting';
+import TodoListCheckCommand from '../src/todolistcheckcommand';
+
+import ModelTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/modeltesteditor';
+import { getData as getModelData, setData as setModelData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
+
+describe( 'TodoListCheckCommand', () => {
+	let editor, model, command;
+
+	beforeEach( () => {
+		return ModelTestEditor
+			.create( {
+				plugins: [ TodoListEditing ]
+			} )
+			.then( newEditor => {
+				editor = newEditor;
+				model = editor.model;
+				command = new TodoListCheckCommand( editor );
+			} );
+	} );
+
+	describe( 'value', () => {
+		it( 'should be false when selection is in not checked element', () => {
+			setModelData( model, '<listItem listIndent="0" listType="todo">ab[]c</listItem>' );
+
+			expect( command.value ).to.equal( false );
+		} );
+
+		it( 'should be true when selection is in checked element', () => {
+			setModelData( model, '<listItem listIndent="0" listType="todo" todoListChecked="true">ab[]c</listItem>' );
+
+			expect( command.value ).to.equal( true );
+		} );
+
+		it( 'should be false when at least one selected element is not checked', () => {
+			setModelData( model,
+				'<listItem listIndent="0" listType="todo" todoListChecked="true">ab[c</listItem>' +
+				'<paragraph>abc</paragraph>' +
+				'<listItem listIndent="0" listType="todo">abc</listItem>' +
+				'<listItem listIndent="0" listType="todo" todoListChecked="true">ab]c</listItem>'
+			);
+
+			expect( command.value ).to.equal( false );
+		} );
+
+		it( 'should be true when all selected elements are checked', () => {
+			setModelData( model,
+				'<listItem listIndent="0" listType="todo" todoListChecked="true">ab[c</listItem>' +
+				'<paragraph>abc</paragraph>' +
+				'<listItem listIndent="0" listType="todo" todoListChecked="true">abc</listItem>' +
+				'<listItem listIndent="0" listType="todo" todoListChecked="true">ab]c</listItem>'
+			);
+
+			expect( command.value ).to.equal( true );
+		} );
+	} );
+
+	describe( 'isEnabled', () => {
+		it( 'should be enabled when selection is inside todo list item', () => {
+			setModelData( model, '<listItem listIndent="0" listType="todo">a[b]c</listItem>' );
+
+			expect( command.isEnabled ).to.equal( true );
+		} );
+
+		it( 'should be disabled when selection is not inside todo list item', () => {
+			setModelData( model, '<paragraph>a[b]c</paragraph>' );
+
+			expect( command.isEnabled ).to.equal( false );
+		} );
+
+		it( 'should be enabled when at least one todo list item is selected', () => {
+			setModelData( model,
+				'<paragraph>a[bc</paragraph>' +
+				'<listItem listIndent="0" listType="todo">abc</listItem>' +
+				'<paragraph>ab]c</paragraph>'
+			);
+
+			expect( command.isEnabled ).to.equal( true );
+		} );
+
+		it( 'should be enabled when none todo list item is selected', () => {
+			setModelData( model,
+				'<paragraph>a[bc</paragraph>' +
+				'<paragraph>abc</paragraph>' +
+				'<paragraph>a]bc</paragraph>'
+			);
+
+			expect( command.isEnabled ).to.equal( false );
+		} );
+	} );
+
+	describe( 'execute()', () => {
+		it( 'should toggle checked state on todo list item when collapsed selection is inside this item', () => {
+			setModelData( model, '<listItem listIndent="0" listType="todo">b[]ar</listItem>' );
+
+			command.execute();
+
+			expect( getModelData( model ) ).to.equal(
+				'<listItem listIndent="0" listType="todo" todoListChecked="true">b[]ar</listItem>'
+			);
+
+			command.execute();
+
+			expect( getModelData( model ) ).to.equal(
+				'<listItem listIndent="0" listType="todo">b[]ar</listItem>'
+			);
+		} );
+
+		it( 'should toggle checked state on todo list item when non-collapsed selection is inside this item', () => {
+			setModelData( model, '<listItem listIndent="0" listType="todo">b[a]r</listItem>' );
+
+			command.execute();
+
+			expect( getModelData( model ) ).to.equal(
+				'<listItem listIndent="0" listType="todo" todoListChecked="true">b[a]r</listItem>'
+			);
+
+			command.execute();
+
+			expect( getModelData( model ) ).to.equal(
+				'<listItem listIndent="0" listType="todo">b[a]r</listItem>'
+			);
+		} );
+
+		it( 'should toggle state on multiple items', () => {
+			setModelData( model,
+				'<listItem listIndent="0" listType="todo">abc[</listItem>' +
+				'<listItem listIndent="0" listType="todo">def</listItem>' +
+				'<listItem listIndent="0" listType="todo">]ghi</listItem>'
+			);
+
+			command.execute();
+
+			expect( getModelData( model ) ).to.equal(
+				'<listItem listIndent="0" listType="todo" todoListChecked="true">abc[</listItem>' +
+				'<listItem listIndent="0" listType="todo" todoListChecked="true">def</listItem>' +
+				'<listItem listIndent="0" listType="todo" todoListChecked="true">]ghi</listItem>'
+			);
+
+			command.execute();
+
+			expect( getModelData( model ) ).to.equal(
+				'<listItem listIndent="0" listType="todo">abc[</listItem>' +
+				'<listItem listIndent="0" listType="todo">def</listItem>' +
+				'<listItem listIndent="0" listType="todo">]ghi</listItem>'
+			);
+		} );
+
+		it( 'should toggle state on multiple items mixed with none todo list items', () => {
+			setModelData( model,
+				'<paragraph>a[bc</paragraph>' +
+				'<listItem listIndent="0" listType="todo">def</listItem>' +
+				'<listItem listIndent="0" listType="numbered">ghi</listItem>' +
+				'<listItem listIndent="0" listType="todo">jkl</listItem>' +
+				'<paragraph>mn]o</paragraph>'
+			);
+
+			command.execute();
+
+			expect( getModelData( model ) ).to.equal(
+				'<paragraph>a[bc</paragraph>' +
+				'<listItem listIndent="0" listType="todo" todoListChecked="true">def</listItem>' +
+				'<listItem listIndent="0" listType="numbered">ghi</listItem>' +
+				'<listItem listIndent="0" listType="todo" todoListChecked="true">jkl</listItem>' +
+				'<paragraph>mn]o</paragraph>'
+			);
+
+			command.execute();
+
+			expect( getModelData( model ) ).to.equal(
+				'<paragraph>a[bc</paragraph>' +
+				'<listItem listIndent="0" listType="todo">def</listItem>' +
+				'<listItem listIndent="0" listType="numbered">ghi</listItem>' +
+				'<listItem listIndent="0" listType="todo">jkl</listItem>' +
+				'<paragraph>mn]o</paragraph>'
+			);
+		} );
+
+		it( 'should mark all selected items as checked when at least one selected item is not checked', () => {
+			setModelData( model,
+				'<listItem listIndent="0" listType="todo">abc[</listItem>' +
+				'<listItem listIndent="0" listType="todo" todoListChecked="true">def</listItem>' +
+				'<listItem listIndent="0" listType="todo">]ghi</listItem>'
+			);
+
+			command.execute();
+
+			expect( getModelData( model ) ).to.equal(
+				'<listItem listIndent="0" listType="todo" todoListChecked="true">abc[</listItem>' +
+				'<listItem listIndent="0" listType="todo" todoListChecked="true">def</listItem>' +
+				'<listItem listIndent="0" listType="todo" todoListChecked="true">]ghi</listItem>'
+			);
+		} );
+
+		it( 'should do nothing when there is no elements to toggle attribute', () => {
+			setModelData( model, '<paragraph>b[]ar</paragraph>' );
+
+			command.execute();
+
+			expect( getModelData( model ) ).to.equal( '<paragraph>b[]ar</paragraph>' );
+		} );
+
+		it( 'should be up to date just before execution', () => {
+			setModelData( model,
+				'<listItem listIndent="0" listType="0">f[]oo</listItem>' +
+				'<listItem listIndent="0" listType="0">bar</listItem>'
+			);
+
+			model.change( writer => {
+				writer.setSelection( model.document.getRoot().getChild( 1 ), 'end' );
+				command.execute();
+			} );
+		} );
+	} );
+} );

文件差异内容过多而无法显示
+ 1035 - 0
packages/ckeditor5-list/tests/todolistediting.js


+ 63 - 0
packages/ckeditor5-list/tests/todolistui.js

@@ -0,0 +1,63 @@
+/**
+ * @license Copyright (c) 2003-2019, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+/* globals document */
+
+import TodoListEditing from '../src/todolistediting';
+import TodoListUI from '../src/todolistui';
+
+import ClassicTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/classictesteditor';
+import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
+import ButtonView from '@ckeditor/ckeditor5-ui/src/button/buttonview';
+import { setData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
+
+describe( 'TodoListUI', () => {
+	let editorElement, editor, model, button;
+
+	beforeEach( () => {
+		editorElement = document.createElement( 'div' );
+		document.body.appendChild( editorElement );
+
+		return ClassicTestEditor.create( editorElement, { plugins: [ Paragraph, TodoListEditing, TodoListUI ] } )
+			.then( newEditor => {
+				editor = newEditor;
+				model = editor.model;
+
+				button = editor.ui.componentFactory.create( 'todoList' );
+			} );
+	} );
+
+	afterEach( () => {
+		editorElement.remove();
+
+		return editor.destroy();
+	} );
+
+	it( 'should set up buttons for bulleted list and numbered list', () => {
+		expect( button ).to.be.instanceOf( ButtonView );
+	} );
+
+	it( 'should execute proper commands when buttons are used', () => {
+		sinon.spy( editor, 'execute' );
+
+		button.fire( 'execute' );
+		sinon.assert.calledWithExactly( editor.execute, 'todoList' );
+	} );
+
+	it( 'should bind button to command', () => {
+		setData( model, '<listItem listType="todo" listIndent="0">[]foo</listItem>' );
+
+		const command = editor.commands.get( 'todoList' );
+
+		expect( button.isOn ).to.be.true;
+		expect( button.isEnabled ).to.be.true;
+
+		command.value = false;
+		expect( button.isOn ).to.be.false;
+
+		command.isEnabled = false;
+		expect( button.isEnabled ).to.be.false;
+	} );
+} );

文件差异内容过多而无法显示
+ 1 - 0
packages/ckeditor5-list/theme/icons/todolist.svg


+ 78 - 0
packages/ckeditor5-list/theme/list.css

@@ -0,0 +1,78 @@
+:root {
+	--ck-todo-list-checkmark-size: 16px;
+	--ck-color-todo-list-checkmark-background: hsl(120, 100%, 42%);
+	--ck-color-todo-list-checkmark-border: hsl( 120, 100%, 27%);
+}
+
+.ck .todo-list {
+	list-style: none;
+	position: relative;
+}
+
+.ck .todo-list > li {
+	margin-bottom: 5px;
+}
+
+.ck .todo-list > li > .todo-list {
+	margin-top: 5px;
+}
+
+/*
+ * Let's hide native checkbox and make a fancy one.
+ *
+ * Note: Checkbox element cannot be hidden using `display: block` or `visibility: hidden`.
+ * It has to be clickable to not steal the focus after clicking on it.
+ */
+.ck .todo-list__checkmark input[type='checkbox'] {
+	display: block;
+	width: 100%;
+	height: 100%;
+	opacity: 0;
+	margin: 0;
+}
+
+.ck .todo-list__checkmark {
+	cursor: pointer;
+	width: var(--ck-todo-list-checkmark-size);
+	height: var(--ck-todo-list-checkmark-size);
+	position: relative;
+	display: inline-block;
+	left: -23px;
+	margin-right: -16px;
+	top: 2px;
+}
+
+.ck .todo-list__checkmark::before {
+	content: '';
+	position: absolute;
+	display: block;
+	width: 100%;
+	height: 100%;
+	border: 1px solid var(--ck-color-base-text);
+	border-radius: var(--ck-border-radius);
+	transition: 250ms ease-in-out box-shadow, 250ms ease-in-out background, 250ms ease-in-out border;
+	box-sizing: border-box;
+}
+
+.ck .todo-list__checkmark::after {
+	pointer-events: none;
+	content: '';
+	position: absolute;
+	left: 5px;
+	top: 3px;
+	display: block;
+	width: 3px;
+	height: 6px;
+	border: solid #fff;
+	border-width: 0 2px 2px 0;
+	transform: rotate(45deg);
+}
+
+.ck .todo-list__checkmark:hover::before {
+	box-shadow: 0 0 0 5px hsla(0, 0%, 0%, 0.1)
+}
+
+.ck .todo-list__checkmark_checked::before {
+	background: var(--ck-color-todo-list-checkmark-background);
+	border-color: var(--ck-color-todo-list-checkmark-border);
+}