Przeglądaj źródła

Merge pull request #6 from ckeditor/t/1b

Introduce List feature.
Piotrek Koszuliński 9 lat temu
rodzic
commit
71d67f6f8c

+ 552 - 0
packages/ckeditor5-list/src/converters.js

@@ -0,0 +1,552 @@
+/**
+ * The list indent command. It is used by the {@link list.List list feature}.
+ *
+ * @memberOf list
+ * @namespace list.converters
+ */
+
+import ViewListItemElement from './viewlistitemelement.js';
+
+import ModelElement from '../engine/model/element.js';
+import ModelPosition from '../engine/model/position.js';
+
+import ViewContainerElement from '../engine/view/containerelement.js';
+import ViewPosition from '../engine/view/position.js';
+import ViewRange from '../engine/view/range.js';
+import viewWriter from '../engine/view/writer.js';
+
+// Helper function that creates a `<ul><li></li></ul>` 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, mapper ) {
+	const listType = modelItem.getAttribute( 'type' ) == 'numbered' ? 'ol' : 'ul';
+	const viewItem = new ViewListItemElement();
+
+	const viewList = new ViewContainerElement( listType, null );
+	viewList.appendChildren( viewItem );
+
+	mapper.bindElements( modelItem, viewItem );
+
+	return viewItem;
+}
+
+// Helper function that seeks for a sibling of given `modelItem` that is a `listItem` element and meets given criteria.
+// `options` object may contain one or more of given values (by default they are `false`):
+// `options.getNext` - whether next or previous siblings should be checked (default = previous)
+// `options.checkAllSiblings` - whether all siblings or just the first one should be checked (default = only one),
+// `options.sameIndent` - whether sought sibling should have same indent (default = no),
+// `options.biggerIndent` - whether sought sibling should have bigger indent (default = no).
+// Either `options.sameIndent` or `options.biggerIndent` should be set to `true`.
+function getSiblingListItem( modelItem, options ) {
+	const direction = options.getNext ? 'nextSibling' : 'previousSibling';
+	const checkAllSiblings = !!options.checkAllSiblings;
+	const sameIndent = !!options.sameIndent;
+	const biggerIndent = !!options.biggerIndent;
+
+	const indent = modelItem.getAttribute( 'indent' );
+
+	let item = modelItem[ direction ];
+
+	while ( item && item.name == 'listItem' ) {
+		let itemIndent = item.getAttribute( 'indent' );
+
+		if ( sameIndent && indent == itemIndent || biggerIndent && indent < itemIndent ) {
+			return item;
+		} else if ( !checkAllSiblings || indent > itemIndent ) {
+			return null;
+		}
+
+		item = item[ direction ];
+	}
+
+	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( firstList, secondList ) {
+	if ( firstList && secondList && ( firstList.name == 'ul' || firstList.name == 'ol' ) && firstList.name == secondList.name ) {
+		viewWriter.mergeContainers( ViewPosition.createAfter( firstList ) );
+	}
+}
+
+// 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, mapper ) {
+	const injectedList = injectedItem.parent;
+
+	// 1. Break after previous `listItem` if it has same or bigger indent.
+	const prevModelItem = getSiblingListItem( modelItem, { sameIndent: true, biggerIndent: true } );
+
+	if ( prevModelItem ) {
+		let viewItem = mapper.toViewElement( prevModelItem );
+		let viewPosition = ViewPosition.createAfter( viewItem );
+		viewWriter.breakContainer( viewPosition );
+	}
+
+	// 2. Break after closest previous `listItem` sibling with same indent.
+	const sameIndentModelItem = getSiblingListItem( modelItem, { sameIndent: true, checkAllSiblings: true } );
+	// Position between broken lists will be a place where new list is inserted.
+	// If there is nothing to break (`sameIndentModelItem` is falsy) it means that converted list item
+	// is (will be) the first list item.
+	let insertionPosition;
+
+	if ( sameIndentModelItem ) {
+		let viewItem = mapper.toViewElement( sameIndentModelItem );
+		let viewPosition = ViewPosition.createAfter( viewItem );
+		insertionPosition = viewWriter.breakContainer( viewPosition );
+	} else {
+		// If there is a list item before converted list item, it means that that list item has lower indent.
+		// In such case the created view list should be appended as a child of that item.
+		const prevSibling = modelItem.previousSibling;
+
+		if ( prevSibling && prevSibling.name == 'listItem' ) {
+			insertionPosition = ViewPosition.createAt( mapper.toViewElement( prevSibling ), 'end' );
+		} else {
+			// This is the very first list item, use position mapping to get correct insertion position.
+			insertionPosition = mapper.toViewPosition( ModelPosition.createBefore( modelItem ) );
+		}
+	}
+
+	// 3. Append new UL/OL in position after breaking in step 2.
+	viewWriter.insert( insertionPosition, injectedList );
+
+	// 4. If next sibling is list item with bigger indent, append it's UL/OL to new LI.
+	const nextModelItem = getSiblingListItem( modelItem, { getNext: true, biggerIndent: true } );
+	const nextViewItem = mapper.toViewElement( nextModelItem );
+
+	/* istanbul ignore if */ // Part of code connected with indenting that is not yet complete.
+	if ( nextViewItem ) {
+		let sourceRange = ViewRange.createOn( nextViewItem.parent );
+		let targetPosition = ViewPosition.createAt( injectedItem, 'end' );
+		viewWriter.move( sourceRange, targetPosition );
+	}
+
+	// 5. Merge new UL/OL with above and below items (ULs/OLs or LIs).
+	mergeViewLists( injectedList, injectedList.nextSibling );
+	mergeViewLists( injectedList.previousSibling, injectedList );
+}
+
+/**
+ * Model to view converter for `listItem` model element insertion.
+ *
+ * It creates `<ul><li></li><ul>` (or `<ol>`) view structure out of `listItem` model element, inserts it at correct
+ * position, and merges the list with surrounding lists (if able).
+ *
+ * @see engine.conversion.ModelConversionDispatcher#event:insert
+ * @param {utils.EventInfo} evt Object containing information about the fired event.
+ * @param {Object} data Additional information about the change.
+ * @param {engine.conversion.ModelConsumable} consumable Values to consume.
+ * @param {Object} conversionApi Conversion interface.
+ */
+export function modelViewInsertion( evt, data, consumable, conversionApi ) {
+	if ( !consumable.test( data.item, 'insert' ) ||
+		!consumable.test( data.item, 'addAttribute:type' ) ||
+		!consumable.test( data.item, 'addAttribute:indent' )
+	) {
+		return;
+	}
+
+	consumable.consume( data.item, 'insert' );
+	consumable.consume( data.item, 'addAttribute:type' );
+	consumable.consume( data.item, 'addAttribute:indent' );
+
+	const modelItem = data.item;
+	const viewItem = generateLiInUl( modelItem, conversionApi.mapper );
+
+	injectViewList( modelItem, viewItem, conversionApi.mapper );
+}
+
+/**
+ * Model to view converter for `type` attribute change on `listItem` model element.
+ *
+ * This change means that `<li>`s parent changes from `<ul>` to `<ol>` (or vice versa). This is accomplished by breaking
+ * view elements, changing their name and merging them.
+ *
+ * @see engine.conversion.ModelConversionDispatcher#event:changeAttribute
+ * @param {utils.EventInfo} evt Object containing information about the fired event.
+ * @param {Object} data Additional information about the change.
+ * @param {engine.conversion.ModelConsumable} consumable Values to consume.
+ * @param {Object} conversionApi Conversion interface.
+ */
+export function modelViewChangeType( evt, data, consumable, conversionApi ) {
+	if ( !consumable.consume( data.item, 'changeAttribute:type' ) ) {
+		return;
+	}
+
+	const viewItem = conversionApi.mapper.toViewElement( data.item );
+
+	// 1. 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( ViewPosition.createBefore( viewItem ) );
+	viewWriter.breakContainer( ViewPosition.createAfter( viewItem ) );
+
+	// 2. 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 listName = data.attributeNewValue == 'numbered' ? 'ol' : 'ul';
+	viewList = viewWriter.rename( viewList, listName );
+
+	// 3. Merge the changed view list with other lists, if possible.
+	mergeViewLists( viewList, viewList.nextSibling );
+	mergeViewLists( viewList.previousSibling, viewList );
+}
+
+/**
+ * Model to view converter for `listItem` model element remove.
+ *
+ * @see engine.conversion.ModelConversionDispatcher#event:remove
+ * @param {utils.EventInfo} evt Object containing information about the fired event.
+ * @param {Object} data Additional information about the change.
+ * @param {engine.conversion.ModelConsumable} consumable Values to consume.
+ * @param {Object} conversionApi Conversion interface.
+ */
+export function modelViewRemove( evt, data, consumable, conversionApi ) {
+	if ( !consumable.consume( data.item, 'remove' ) ) {
+		return;
+	}
+
+	const viewItem = conversionApi.mapper.toViewElement( data.item );
+
+	// 1. 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( ViewPosition.createBefore( viewItem ) );
+	viewWriter.breakContainer( ViewPosition.createAfter( viewItem ) );
+
+	// 2. Remove the UL that contains just the removed LI.
+	const viewList = viewItem.parent;
+	viewWriter.remove( ViewRange.createOn( viewList ) );
+}
+
+/**
+ * Model to view converter for `listItem` model element move.
+ *
+ * @see engine.conversion.ModelConversionDispatcher#event:move
+ * @param {utils.EventInfo} evt Object containing information about the fired event.
+ * @param {Object} data Additional information about the change.
+ * @param {engine.conversion.ModelConsumable} consumable Values to consume.
+ * @param {Object} conversionApi Conversion interface.
+ */
+export function modelViewMove( evt, data, consumable, conversionApi ) {
+	if ( !consumable.consume( data.item, 'move' ) ) {
+		return;
+	}
+
+	const viewItem = conversionApi.mapper.toViewElement( data.item );
+
+	// 1. 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( ViewPosition.createBefore( viewItem ) );
+	viewWriter.breakContainer( ViewPosition.createAfter( viewItem ) );
+
+	// 2. Extract view list with changed view list item and merge "hole" possibly created by breaking and removing elements.
+	const viewList = viewItem.parent;
+	const viewListPrev = viewList.previousSibling;
+	const viewListNext = viewList.nextSibling;
+
+	let insertionPosition = conversionApi.mapper.toViewPosition( data.targetPosition );
+
+	if ( insertionPosition.parent.name == 'ol' || insertionPosition.parent.name == 'ul' ) {
+		insertionPosition = viewWriter.breakContainer( insertionPosition );
+	}
+
+	viewWriter.move( ViewRange.createOn( viewList ), insertionPosition );
+
+	// No worries, merging will happen only if both elements exist and they are same type of lists.
+	mergeViewLists( viewListPrev, viewListNext );
+	mergeViewLists( viewList, viewList.nextSibling );
+	mergeViewLists( viewList.previousSibling, viewList );
+}
+
+/**
+ * Model to view converter for `indent` attribute change on `listItem` model element.
+ *
+ * @see engine.conversion.ModelConversionDispatcher#event:changeAttribute
+ * @param {utils.EventInfo} evt Object containing information about the fired event.
+ * @param {Object} data Additional information about the change.
+ * @param {engine.conversion.ModelConsumable} consumable Values to consume.
+ * @param {Object} conversionApi Conversion interface.
+ */
+export function modelViewChangeIndent( evt, data, consumable, conversionApi ) {
+	/* istanbul ignore if */ // Part of code connected with indenting that is not yet complete.
+	if ( !consumable.consume( data.item, 'changeAttribute:indent' ) ) {
+		return;
+	}
+
+	const viewItem = conversionApi.mapper.toViewElement( data.item );
+
+	// 1. 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( ViewPosition.createBefore( viewItem ) );
+	viewWriter.breakContainer( ViewPosition.createAfter( viewItem ) );
+
+	// 2. Extract view list with changed view list item and merge "hole" possibly created by breaking and removing elements.
+	const viewList = viewItem.parent;
+	const viewListPrev = viewList.previousSibling;
+
+	viewWriter.remove( ViewRange.createOn( viewList ) );
+
+	// If there is no `viewListPrev` it means that the first item was indented which is an error.
+	mergeViewLists( viewListPrev, viewListPrev.nextSibling );
+
+	// 3. Inject view list like it is newly inserted.
+	injectViewList( data.item, viewItem, conversionApi.mapper );
+}
+
+/**
+ * A special model to view converter introduced by {@link list.List List feature}. This converter is fired for
+ * insert change of every model item, and should be fired before actual converter. The converter checks whether inserted
+ * model item is a non-`listItem` element. If it is, and it is inserted inside a view list, the converter breaks the
+ * list so the model element is inserted to the view parent element corresponding to its model parent element.
+ *
+ * The converter prevents such situations:
+ *
+ *		// Model:                        // View:
+ *		<listItem>foo</listItem>         <ul>
+ *		<listItem>bar</listItem>             <li>foo</li>
+ *		                                     <li>bar</li>
+ *		                                 </ul>
+ *
+ *		// After change:                 // Correct view guaranteed by this converter:
+ *		<listItem>foo</listItem>         <ul><li>foo</li></ul><p>xxx</p><ul><li>bar</li></ul>
+ *		<paragraph>xxx</paragraph>       // Instead of this wrong view state:
+ *		<listItem>bar</listItem>         <ul><li>foo</li><p>xxx</p><li>bar</li></ul>
+ *
+ * @see engine.conversion.ModelConversionDispatcher#event:insert
+ * @param {utils.EventInfo} evt Object containing information about the fired event.
+ * @param {Object} data Additional information about the change.
+ * @param {engine.conversion.ModelConsumable} consumable Values to consume.
+ * @param {Object} conversionApi Conversion interface.
+ */
+export function modelViewSplitOnInsert( evt, data, consumable, conversionApi ) {
+	if ( data.item.name != 'listItem' ) {
+		let viewPosition = conversionApi.mapper.toViewPosition( data.range.start );
+
+		// Break multiple ULs/OLs if there are.
+		while ( viewPosition.parent.name == 'ul' || viewPosition.parent.name == 'ol' ) {
+			viewPosition = viewWriter.breakContainer( viewPosition );
+
+			/* istanbul ignore else */ // Part of code connected with indenting that is not yet complete.
+			if ( viewPosition.parent.parent === null ) {
+				break;
+			}
+
+			/* istanbul ignore next */ // Part of code connected with indenting that is not yet complete.
+			viewPosition = ViewPosition.createBefore( viewPosition.parent );
+		}
+	}
+}
+
+/**
+ * A special model to view converter introduced by {@link list.List List feature}. This converter takes care of
+ * merging view lists after something is removed or moved from near them.
+ *
+ * Example:
+ *
+ *		// Model:                        // View:
+ *		<listItem>foo</listItem>         <ul><li>foo</li></ul>
+ *		<paragraph>xxx</paragraph>       <p>xxx</p>
+ *		<listItem>bar</listItem>         <ul><li>bar</li></ul>
+ *
+ *		// After change:                 // Correct view guaranteed by this converter:
+ *		<listItem>foo</listItem>         <ul>
+ *		<listItem>bar</listItem>             <li>foo</li>
+ *		                                     <li>bar</li>
+ *		                                 </ul>
+ *
+ * @see engine.conversion.ModelConversionDispatcher#event:remove
+ * @see engine.conversion.ModelConversionDispatcher#event:move
+ * @param {utils.EventInfo} evt Object containing information about the fired event.
+ * @param {Object} data Additional information about the change.
+ * @param {engine.conversion.ModelConsumable} consumable Values to consume.
+ * @param {Object} conversionApi Conversion interface.
+ */
+export function modelViewMergeAfter( evt, data, consumable, conversionApi ) {
+	const viewPosition = conversionApi.mapper.toViewPosition( data.sourcePosition );
+	const viewItemPrev = viewPosition.nodeBefore;
+	const viewItemNext = viewPosition.nodeAfter;
+
+	// Merge lists if something (remove, move) was done from inside of list.
+	// Merging will be done only if both items are view lists of the same type.
+	// The check is done inside the helper function.
+	mergeViewLists( viewItemPrev, viewItemNext );
+}
+
+/**
+ * View to model converter that converts view `<li>` elements into `listItem` model elements.
+ *
+ * To set correct values of `type` and `indent` attribute the converter:
+ * * checks `<li>`'s parent,
+ * * passes `data.indent` value when `<li>`'s sub-items are converted.
+ *
+ * @see engine.conversion.ViewConversionDispatcher#event:element
+ * @param {utils.EventInfo} evt Object containing information about the fired event.
+ * @param {Object} data Object containing conversion input and a placeholder for conversion output and possibly other values.
+ * @param {engine.conversion.ViewConsumable} consumable Values to consume.
+ * @param {Object} conversionApi Conversion interface to be used by callback.
+ */
+export function viewModelConverter( evt, data, consumable, conversionApi ) {
+	if ( consumable.consume( data.input, { name: true } ) ) {
+		// 1. Create `listItem` model element.
+		const listItem = new ModelElement( 'listItem' );
+
+		// 2. Handle `listItem` model element attributes.
+		data.indent = data.indent ? data.indent : 0;
+
+		const type = data.input.parent.name == 'ul' ? 'bulleted' : 'numbered';
+		listItem.setAttribute( 'type', type );
+		listItem.setAttribute( 'indent', data.indent );
+
+		// 3. Handle `<li>` children.
+		data.context.push( listItem );
+
+		// `listItem`s created recursievly should have bigger indent.
+		data.indent++;
+
+		// `listItem`s will be kept in flat structure.
+		let items = [ listItem ];
+
+		// Check all children of the converted `<li>`.
+		// At this point we assume there are no "whitespace" view text nodes in view list, between view list items.
+		// This should be handled by `<ul>` and `<ol>` converters.
+		for ( let child of data.input.getChildren() ) {
+			// Let's convert the child.
+			const converted = conversionApi.convertItem( child, consumable, data );
+
+			// If this is a view list element, we will convert it and concat the result (`listItem` model elements)
+			// with already gathered results (in `items` array). `converted` should be a `ModelDocumentFragment`.
+			if ( child.name == 'ul' || child.name == 'ol' ) {
+				items = items.concat( Array.from( converted.getChildren() ) );
+			}
+			// If it was not a list it was a "regular" list item content. Just append it to `listItem`.
+			else {
+				listItem.appendChildren( converted );
+			}
+		}
+
+		data.indent--;
+		data.context.pop();
+
+		/* istanbul ignore next */ // Part of code connected with indenting that is not yet complete.
+		data.output = data.output ? data.output.concat( items ) : items;
+	}
+}
+
+/**
+ * View to model converter for `<ul>` and `<ol>` view elements, that cleans the input view out of garbage.
+ * This is mostly to clean white spaces from between `<li>` view elements inside the view list element, however also
+ * incorrect data can be cleared if the view was incorrect.
+ *
+ * @see engine.conversion.ViewConversionDispatcher#event:element
+ * @param {utils.EventInfo} evt Object containing information about the fired event.
+ * @param {Object} data Object containing conversion input and a placeholder for conversion output and possibly other values.
+ * @param {engine.conversion.ViewConsumable} consumable Values to consume.
+ */
+export function cleanList( evt, data, consumable ) {
+	if ( consumable.test( data.input, { name: true } ) ) {
+		// Caching children because when we start removing them iterating fails.
+		const children = Array.from( data.input.getChildren() );
+
+		for ( let child of children ) {
+			if ( !child.name || child.name != 'li' ) {
+				child.remove();
+			}
+		}
+	}
+}
+
+/**
+ * Callback for model position to view position mapping for {@link engine.conversion.Mapper}. The callback fixes positions
+ * between `listItem` elements, that would be incorrectly mapped because of how list items are represented in model
+ * and view.
+ *
+ * @see engine.conversion.Mapper#event:modelToViewPosition
+ * @param {utils.EventInfo} evt Object containing information about the fired event.
+ * @param {Object} data Object containing additional data and placeholder for mapping result.
+ */
+export function modelToViewPosition( evt, data ) {
+	const modelPosition = data.modelPosition;
+	const mapper = data.mapper;
+	const nodeAfter = modelPosition.nodeAfter;
+
+	// `listItem` elements are mapped with view, so positions inside them will be correctly mapped by default algorithm.
+	// Problem are positions between `listItem`s because they are incorrectly mapped to inside `<li>`. This is
+	// because of how view-to-model lengths work. What is important is that if a position is before a `listItem` and
+	// it is not a first `listItem`, the position has to be placed before corresponding `<li>`. If this is the first
+	// `listItem` position has to be before `<ul>` (this is default behavior).
+	if ( nodeAfter && nodeAfter.name == 'listItem' ) {
+		const viewNode = mapper.toViewElement( nodeAfter );
+
+		if ( viewNode && viewNode.index !== 0 ) {
+			data.viewPosition = ViewPosition.createBefore( viewNode );
+
+			evt.stop();
+		}
+	}
+}
+
+/**
+ * Callback for view position to model position mapping for {@link engine.conversion.Mapper}. The callback fixes positions
+ * between `<li>` elements, that would be incorrectly mapped because of how list items are represented in model
+ * and view.
+ *
+ * @see engine.conversion.Mapper#event:viewToModelPosition
+ * @param {utils.EventInfo} evt Object containing information about the fired event.
+ * @param {Object} data Object containing additional data and placeholder for mapping result.
+ */
+export function viewToModelPosition( evt, data ) {
+	const viewPosition = data.viewPosition;
+	const mapper = data.mapper;
+	const nodeAfter = viewPosition.nodeAfter;
+	const nodeBefore = viewPosition.nodeBefore;
+
+	let modelNode;
+
+	if ( nodeAfter ) {
+		if ( nodeAfter.name == 'ul' || nodeAfter.name == 'ol' ) {
+			// If the position is before view list, model position should be placed before `listItem`
+			// that is bound to the first `<li>` of that view list.
+			// Default algorithm would work like this but only for top-level list.
+			modelNode = mapper.toModelElement( nodeAfter.getChild( 0 ) );
+		} else if ( nodeAfter.name == 'li' ) {
+			// If the position is before view list item, just place model position before bound `listItem` element.
+			modelNode = mapper.toModelElement( nodeAfter );
+		}
+
+		if ( modelNode ) {
+			data.modelPosition = ModelPosition.createBefore( modelNode );
+		}
+	} else if ( nodeBefore ) {
+		let viewNode;
+
+		// Find `<li>` after which we want to place position.
+		// We want to find a `<li>` that will be mapped to model `listItem` element. That `listItem` will
+		// be used as a reference point to evaluate model position.
+		/* istanbul ignore if */ // Part of code connected with indenting that is not yet complete.
+		if ( nodeBefore.name == 'ul' || nodeBefore.name == 'ol' ) {
+			// If the position is before view list, take the last `<li>` of that view list.
+			viewNode = nodeBefore.getChild( nodeBefore.childCount - 1 );
+		} else if ( nodeBefore.name == 'li' ) {
+			// If the position is before view list item, take that `<li>`.
+			viewNode = nodeBefore;
+		}
+
+		// Evaluate correct model position.
+		// At this stage we have a `<li>`. This `<li>` may have nested `<li>`s inside. We will use `mapper`
+		// to obtain this `<li>`'s model length. Placing model position after that `<li>` will be done
+		// by placing it before the bound `listItem` and moving by offset equal to `<li>`s length.
+		if ( viewNode ) {
+			modelNode = mapper.toModelElement( viewNode );
+			const offset = mapper.getModelLength( viewNode );
+
+			data.modelPosition = ModelPosition.createBefore( modelNode ).getShiftedBy( offset );
+		}
+	}
+
+	// If we found a model position, stop the event.
+	if ( data.modelPosition !== null ) {
+		evt.stop();
+	}
+}

+ 125 - 0
packages/ckeditor5-list/src/indentcommand.js

@@ -0,0 +1,125 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+import Command from '../core/command/command.js';
+import { getClosestListItem } from './utils.js';
+
+/**
+ * The list indent command. It is used by the {@link list.List list feature}.
+ *
+ * @memberOf list
+ * @extends core.command.Command
+ */
+export default class IndentCommand extends Command {
+	/**
+	 * Creates an instance of the command.
+	 *
+	 * @param {core.editor.Editor} editor Editor instance.
+	 * @param {'forward'|'backward'} indentDirection Direction of indent. If it is equal to `backward`, the command
+	 * will outdent a list item.
+	 */
+	constructor( editor, indentDirection ) {
+		super( editor );
+
+		/**
+		 * By how much the command will change list item's indent attribute.
+		 *
+		 * @readonly
+		 * @private
+		 * @member {Number} list.IndentCommand#_indentBy
+		 */
+		this._indentBy = indentDirection == 'forward' ? 1 : -1;
+
+		// Refresh command state after selection is changed or changes has been done to the document.
+		this.listenTo( editor.document.selection, 'change:range', () => {
+			this.refreshState();
+		} );
+
+		this.listenTo( editor.document, 'changesDone', () => {
+			this.refreshState();
+		} );
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	_doExecute() {
+		const doc = this.editor.document;
+		const batch = doc.batch();
+		const element = getClosestListItem( doc.selection.getFirstPosition() );
+
+		doc.enqueueChanges( () => {
+			const oldIndent = element.getAttribute( 'indent' );
+
+			let itemsToChange = [ element ];
+
+			// Indenting a list item should also indent all the items that are already sub-items of indented item.
+			let next = element.nextSibling;
+
+			// Check all items as long as their indent is bigger than indent of changed list item.
+			while ( next && next.name == 'listItem' && next.getAttribute( 'indent' ) > oldIndent ) {
+				itemsToChange.push( next );
+
+				next = next.nextSibling;
+			}
+
+			// We need to be sure to keep model in correct state after each small change, because converters
+			// bases on that state and assumes that model is correct.
+			// Because of that, if the command outdented items, we will outdent them starting from the last item, as
+			// it is safer.
+			if ( this._indentBy < 0 ) {
+				itemsToChange = itemsToChange.reverse();
+			}
+
+			for ( let item of itemsToChange ) {
+				const indent = item.getAttribute( 'indent' ) + this._indentBy;
+
+				// If indent is lower than 0, it means that the item got outdented when it was not indented.
+				// This means that we need to convert that list item to paragraph.
+				if ( indent < 0 ) {
+					// To keep the model as correct as possible, first rename listItem, then remove attributes,
+					// as listItem without attributes is very incorrect and will cause problems in converters.
+					batch.rename( item, 'paragraph' ).removeAttribute( item, 'indent' ).removeAttribute( item, 'type' );
+				} else {
+					// If indent is >= 0, just change the attribute value.
+					batch.setAttribute( item, 'indent', indent );
+				}
+			}
+		} );
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	_checkEnabled() {
+		// Check whether any of position's ancestor is a list item.
+		const listItem = getClosestListItem( this.editor.document.selection.getFirstPosition() );
+
+		// If selection is not in a list item, the command is disabled.
+		if ( !listItem ) {
+			return false;
+		}
+
+		const prev = listItem.previousSibling;
+		const oldIndent = listItem.getAttribute( 'indent' );
+		const newIndent = oldIndent + this._indentBy;
+
+		if ( this._indentBy > 0 ) {
+			// If we are indenting, there are some conditions to meet.
+			// Cannot indent first list item.
+			if ( !prev || prev.name != 'listItem' ) {
+				return false;
+			}
+
+			// Indent can be at most greater by one than indent of previous item.
+			if ( prev.getAttribute( 'indent' ) + 1 < newIndent ) {
+				return false;
+			}
+		}
+
+		// If we are outdenting it is enough to be in list item. Every list item can always be outdented.
+		return true;
+	}
+}

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

@@ -0,0 +1,106 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+import Feature from '../core/feature.js';
+import ListEngine from './listengine.js';
+import ButtonController from '../ui/button/button.js';
+import ButtonView from '../ui/button/buttonview.js';
+import Model from '../ui/model.js';
+import { parseKeystroke } from '../utils/keyboard.js';
+
+/**
+ * The lists feature. It introduces the `numberedList` and `bulletedList` buttons which
+ * allows to convert paragraphs to/from list items and indent/outdent them.
+ *
+ * See also {@link list.ListEngine}.
+ *
+ * @memberOf list
+ * @extends core.Feature
+ */
+export default class List extends Feature {
+	/**
+	 * @inheritDoc
+	 */
+	static get requires() {
+		return [ ListEngine ];
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	init() {
+		// Create two buttons and link them with numberedList and bulletedList commands.
+		const t = this.editor.t;
+		this._addButton( 'numberedList', t( 'Numbered List' ) );
+		this._addButton( 'bulletedList', t( 'Bulleted List' ) );
+
+		// Overwrite default enter key behavior.
+		// If enter key is pressed with selection collapsed in empty list item, outdent it instead of breaking it.
+		this.listenTo( this.editor.editing.view, 'enter', ( evt, data ) => {
+			const doc = this.editor.document;
+			const positionParent = doc.selection.getLastPosition().parent;
+
+			if ( doc.selection.isCollapsed && positionParent.name == 'listItem' && positionParent.isEmpty ) {
+				this.editor.execute( 'outdentList' );
+
+				data.preventDefault();
+				evt.stop();
+			}
+		} );
+
+		// Add tab key support.
+		// When in list item, pressing tab should indent list item, if possible.
+		// Pressing shift + tab shout outdent list item.
+		this.listenTo( this.editor.editing.view, 'keydown', ( evt, data ) => {
+			let commandName = null;
+
+			if ( data.keystroke == parseKeystroke( 'tab' ) ) {
+				commandName = 'indentList';
+			} else if ( data.keystroke == parseKeystroke( 'Shift+tab' ) ) {
+				commandName = 'outdentList';
+			}
+
+			if ( commandName ) {
+				const command = this.editor.commands.get( commandName );
+
+				if ( command.isEnabled ) {
+					this.editor.execute( commandName );
+
+					data.preventDefault();
+					evt.stop();
+				}
+			}
+		} );
+	}
+
+	/**
+	 * Helper method for initializing a button and linking it with an appropriate command.
+	 *
+	 * @private
+	 * @param {String} commandName Name of the command.
+	 * @param {Object} label Button label.
+	 */
+	_addButton( commandName, label ) {
+		const editor = this.editor;
+		const command = editor.commands.get( commandName );
+
+		// Create button model.
+		const buttonModel = new Model( {
+			isEnabled: true,
+			isOn: false,
+			label: label,
+			icon: commandName.toLowerCase()
+		} );
+
+		// Bind button model to command.
+		buttonModel.bind( 'isOn', 'isEnabled' ).to( command, 'value', 'isEnabled' );
+
+		// Execute command.
+		this.listenTo( buttonModel, 'execute', () => editor.execute( commandName ) );
+
+		// Add button to feature components.
+		editor.ui.featureComponents.add( commandName, ButtonController, ButtonView, buttonModel );
+	}
+}

+ 199 - 0
packages/ckeditor5-list/src/listcommand.js

@@ -0,0 +1,199 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+import Command from '../core/command/command.js';
+import { getClosestListItem, getSelectedBlocks, getPositionBeforeBlock } from './utils.js';
+
+/**
+ * The list command. It is used by the {@link list.List list feature}.
+ *
+ * @memberOf list
+ * @extends core.command.Command
+ */
+export default class ListCommand extends Command {
+	/**
+	 * Creates an instance of the command.
+	 *
+	 * @param {core.editor.Editor} editor Editor instance.
+	 * @param {'numbered'|'bulleted'} type List type that will be handled by this command.
+	 */
+	constructor( editor, type ) {
+		super( editor );
+
+		/**
+		 * The type of list created by the command.
+		 *
+		 * @readonly
+		 * @member {'numbered'|'bulleted'} list.ListCommand#type
+		 */
+		this.type = type == 'bulleted' ? 'bulleted' : 'numbered';
+
+		/**
+		 * Flag indicating whether the command is active, which means that selection starts in a list of the same type.
+		 *
+		 * @observable
+		 * @member {Boolean} list.ListCommand#value
+		 */
+		this.set( 'value', false );
+
+		const changeCallback = () => {
+			this.refreshValue();
+			this.refreshState();
+		};
+
+		// Listen on selection and document changes and set the current command's value.
+		this.listenTo( editor.document.selection, 'change:range', changeCallback );
+		this.listenTo( editor.document, 'changesDone', changeCallback );
+	}
+
+	/**
+	 * Sets command's value based on the document selection.
+	 */
+	refreshValue() {
+		const position = this.editor.document.selection.getFirstPosition();
+
+		// Check whether closest `listItem` ancestor of the position has a correct type.
+		const listItem = getClosestListItem( position );
+		this.value = listItem !== null && listItem.getAttribute( 'type' ) == this.type;
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	_doExecute() {
+		const document = this.editor.document;
+		const blocks = getSelectedBlocks( document.selection, document.schema );
+
+		// Whether we are turning off some items.
+		const turnOff = this.value === true;
+		// If we are turning off items, we are going to rename them to paragraphs.
+
+		document.enqueueChanges( () => {
+			const batch = document.batch();
+
+			// If part of a list got turned off, we need to handle (outdent) all of sub-items of the last turned-off item.
+			// To be sure that model is all the time in a good state, we first fix items below turned-off item.
+			if ( turnOff ) {
+				// Start from the model item that is just after the last turned-off item.
+				let next = blocks[ blocks.length - 1 ].nextSibling;
+				let currentIndent = Number.POSITIVE_INFINITY;
+				let changes = [];
+
+				// Correct indent of all items after the last turned off item.
+				// Rules that should be followed:
+				// 1. All direct sub-items of turned-off item should become indent 0, because the first item after it
+				//    will be the first item of a new list. Other items are at the same level, so should have same 0 index.
+				// 2. All items with indent lower than indent of turned-off item should become indent 0, because they
+				//    should not end up as a child of any of list items that they were not children of before.
+				// 3. All other items should have their indent changed relatively to it's parent.
+				//
+				// For example:
+				// 1  * --------
+				// 2     * --------
+				// 3        * -------- <- this is turned off.
+				// 4           * -------- <- this has to become indent = 0, because it will be first item on a new list.
+				// 5              * -------- <- this should be still be a child of item above, so indent = 1.
+				// 6        * -------- <- this also has to become indent = 0, because it shouldn't end up as a child of any of items above.
+				// 7           * -------- <- this should be still be a child of item above, so indent = 1.
+				// 8     * -------- <- this has to become indent = 0.
+				// 9        * -------- <- this should still be a child of item above, so indent = 1.
+				// 10          * -------- <- this should still be a child of item above, so indent = 2.
+				// 11          * -------- <- this should still be at the same level as item above, so indent = 2.
+				// 12 * -------- <- this and all below are left unchanged.
+				// 13    * --------
+				// 14       * --------
+				//
+				// After turning off 3 the list becomes:
+				//
+				// 1  * --------
+				// 2     * --------
+				//
+				// 3  --------
+				//
+				// 4  * --------
+				// 5     * --------
+				// 6  * --------
+				// 7     * --------
+				// 8  * --------
+				// 9     * --------
+				// 10       * --------
+				// 11       * --------
+				// 12 * --------
+				// 13    * --------
+				// 14       * --------
+				//
+				// Thanks to this algorithm no lists are mismatched and no items get unexpected children/parent, while
+				// those parent-child connection which are possible to maintain are still maintained. It's worth noting
+				// that this is the same effect that we would be get by multiple use of outdent command. However doing
+				// it like this is much more efficient because it's less operation (less memory usage, easier OT) and
+				// less conversion (faster).
+				while ( next && next.name == 'listItem' && next.getAttribute( 'indent' ) !== 0 ) {
+					// Check each next list item, as long as its indent is bigger than 0.
+					// If the indent is 0 we are not going to change anything anyway.
+					const indent = next.getAttribute( 'indent' );
+
+					// We check if that's item indent is lower as current relative indent.
+					if ( indent < currentIndent ) {
+						// If it is, current relative indent becomes that indent.
+						currentIndent = indent;
+					}
+
+					// Fix indent relatively to current relative indent.
+					// Note, that if we just changed the current relative indent, the newIndent will be equal to 0.
+					const newIndent = indent - currentIndent;
+
+					// Save the entry in changes array. We do not apply it at the moment, because we will need to
+					// reverse the changes so the last item is changed first.
+					// This is to keep model in correct state all the time.
+					changes.push( { element: next, indent: newIndent } );
+
+					// Find next item.
+					next = next.nextSibling;
+				}
+
+				changes = changes.reverse();
+
+				for ( let item of changes ) {
+					batch.setAttribute( item.element, 'indent', item.indent );
+				}
+			}
+
+			// Phew! Now it will be easier :).
+			// For each block element that was in the selection, we will either: turn it to list item,
+			// turn it to paragraph, or change it's type. Or leave it as it is.
+			for ( let element of blocks ) {
+				if ( turnOff && element.name == 'listItem' ) {
+					// We are turning off and the element is a `listItem` - it should be converted to `paragraph`.
+					// The order is important to keep model in correct state.
+					batch.rename( element, 'paragraph' ).removeAttribute( element, 'type' ).removeAttribute( element, 'indent' );
+				} else if ( !turnOff && element.name != 'listItem' ) {
+					// We are turning on and the element is not a `listItem` - it should be converted to `listItem`.
+					// The order is important to keep model in correct state.
+					batch.setAttribute( element, 'type', this.type ).setAttribute( element, 'indent', 0 ).rename( element, 'listItem' );
+				} else if ( !turnOff && element.name == 'listItem' && element.getAttribute( 'type' ) != this.type ) {
+					// We are turning on and the element is a `listItem` but has different type - change type.
+					batch.setAttribute( element, 'type', this.type );
+				}
+			}
+		} );
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	_checkEnabled() {
+		// If command is enabled it means that we are in list item, so the command should be enabled.
+		if ( this.value ) {
+			return true;
+		}
+
+		const selection = this.editor.document.selection;
+		const schema = this.editor.document.schema;
+		const position = getPositionBeforeBlock( selection.getFirstPosition(), schema );
+
+		// Otherwise, check if list item can be inserted at the position start.
+		return schema.check( { name: 'listItem', inside: position, attributes: [ 'type', 'indent' ] } );
+	}
+}

+ 91 - 0
packages/ckeditor5-list/src/listengine.js

@@ -0,0 +1,91 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+import Feature from '../core/feature.js';
+import ListCommand from './listcommand.js';
+import IndentCommand from './indentcommand.js';
+
+import {
+	cleanList,
+	modelViewInsertion,
+	modelViewChangeType,
+	modelViewMergeAfter,
+	modelViewRemove,
+	modelViewMove,
+	modelViewSplitOnInsert,
+	modelViewChangeIndent,
+	viewModelConverter,
+	modelToViewPosition,
+	viewToModelPosition
+} from './converters.js';
+
+/**
+ * The engine of the lists feature. It handles creating, editing and removing lists and list items.
+ * It registers the `numberedList`, `bulletedList`, `indentList` and `outdentList` commands.
+ *
+ * @memberOf list
+ * @extends core.Feature
+ */
+export default class ListEngine extends Feature {
+	/**
+	 * @inheritDoc
+	 */
+	init() {
+		const editor = this.editor;
+
+		// Schema.
+		const schema = editor.document.schema;
+		schema.registerItem( 'listItem', '$block' );
+		schema.allow( {
+			name: 'listItem',
+			inside: '$root',
+			attributes: [ 'type', 'indent' ]
+		} );
+		schema.requireAttributes( 'listItem', [ 'type', 'indent' ] );
+
+		// Converters.
+		const data = editor.data;
+		const editing = editor.editing;
+
+		editing.mapper.on( 'modelToViewPosition', modelToViewPosition );
+		editing.mapper.on( 'viewToModelPosition', viewToModelPosition );
+		data.mapper.on( 'modelToViewPosition', modelToViewPosition );
+
+		editing.modelToView.on( 'insert', modelViewSplitOnInsert, { priority: 'high' } );
+		editing.modelToView.on( 'insert:listItem', modelViewInsertion );
+		data.modelToView.on( 'insert', modelViewSplitOnInsert, { priority: 'high' } );
+		data.modelToView.on( 'insert:listItem', modelViewInsertion );
+
+		// Only change converter is needed. List item's type attribute is required, so it's adding is handled when
+		// list item is added and you cannot remove it.
+		editing.modelToView.on( 'changeAttribute:type:listItem', modelViewChangeType );
+		data.modelToView.on( 'changeAttribute:type:listItem', modelViewChangeType );
+
+		editing.modelToView.on( 'remove:listItem', modelViewRemove );
+		editing.modelToView.on( 'remove', modelViewMergeAfter, { priority: 'low' } );
+		data.modelToView.on( 'remove:listItem', modelViewRemove );
+		data.modelToView.on( 'remove', modelViewMergeAfter, { priority: 'low' } );
+
+		editing.modelToView.on( 'move:listItem', modelViewMove );
+		editing.modelToView.on( 'move', modelViewMergeAfter, { priority: 'low' } );
+		data.modelToView.on( 'move:listItem', modelViewMove );
+		data.modelToView.on( 'move', modelViewMergeAfter, { priority: 'low' } );
+
+		editing.modelToView.on( 'changeAttribute:indent:listItem', modelViewChangeIndent );
+		data.modelToView.on( 'changeAttribute:indent:listItem', modelViewChangeIndent );
+
+		data.viewToModel.on( 'element:li', viewModelConverter );
+		data.viewToModel.on( 'element:ul', cleanList, { priority: 'high' } );
+		data.viewToModel.on( 'element:ol', cleanList, { priority: 'high' } );
+
+		// Register commands for numbered and bulleted list.
+		editor.commands.set( 'numberedList', new ListCommand( editor, 'numbered' ) );
+		editor.commands.set( 'bulletedList', new ListCommand( editor, 'bulleted' ) );
+
+		// Register commands for indenting.
+		editor.commands.set( 'indentList', new IndentCommand( editor, 'forward' ) );
+		editor.commands.set( 'outdentList', new IndentCommand( editor, 'backward' ) );
+	}
+}

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

@@ -0,0 +1,80 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/**
+ * Utilities used in modules from {@link list list} package.
+ *
+ * @memberOf list
+ * @namespace list.utils
+ */
+
+import Position from '../engine/model/position.js';
+
+/**
+ * For given {@link engine.model.Position position}, returns the closest ancestor of that position which is a
+ * `listItem` element.
+ *
+ * @function list.utils.getClosestListItem
+ * @param {engine.model.Position} position Position which ancestor should be check looking for `listItem` element.
+ * @returns {engine.model.Element|null} Element with `listItem` name that is a closest ancestor of given `position`, or
+ * `null` if neither of `position` ancestors is a `listItem`.
+ */
+export function getClosestListItem( position ) {
+	return Array.from( position.getAncestors() ).find( ( parent ) => parent.name == 'listItem' ) || null;
+}
+
+/**
+ * For given {@link engine.model.Selection selection} and {@link engine.model.Schema schema}, returns an array with
+ * all elements that are in the selection and are extending `$block` schema item.
+ *
+ * @function list.utils.getSelectedBlocks
+ * @param {engine.model.Selection} selection Selection from which blocks will be taken.
+ * @param {engine.model.Schema} schema Schema which will be used to check if a model element extends `$block`.
+ * @returns {Array.<engine.model.Element>} All blocks from the selection.
+ */
+export function getSelectedBlocks( selection, schema ) {
+	let position = getPositionBeforeBlock( selection.getFirstPosition(), schema );
+
+	const endPosition = selection.getLastPosition();
+	const blocks = [];
+
+	// Traverse model from the first position before a block to the end position of selection.
+	// Store all elements that were after the correct positions.
+	while ( position !== null && position.isBefore( endPosition ) ) {
+		blocks.push( position.nodeAfter );
+
+		position.offset++;
+		position = getPositionBeforeBlock( position, schema );
+	}
+
+	return blocks;
+}
+
+/**
+ * For given {@link engine.model.Position position}, finds a model element extending `$block` schema item which is
+ * closest element to that position. First node after the position is checked and then the position's ancestors. `null`
+ * is returned if such element has not been found or found element is a root element.
+ *
+ * @param position
+ * @param schema
+ * @returns {*}
+ */
+export function getPositionBeforeBlock( position, schema ) {
+	// Start from the element right after the position. Maybe it is already a `$block` element.
+	let element = position.nodeAfter;
+
+	// If the position is not before an element, check the parent.
+	if ( !element ) {
+		element = position.parent;
+	}
+
+	// If proper element is still not found, check the ancestors.
+	while ( element !== null && !schema.itemExtends( element.name || '$text', '$block' ) ) {
+		element = element.parent;
+	}
+
+	// If proper element has been found, return position before it, otherwise return null;
+	return element !== null && element.parent !== null ? Position.createBefore( element ) : null;
+}

+ 34 - 0
packages/ckeditor5-list/src/viewlistitemelement.js

@@ -0,0 +1,34 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+import ViewContainerElement from '../engine/view/containerelement.js';
+
+/**
+ * View element class representing list item (`<li>`). It extends {@link engine.view.ContainerElement} and overwrites
+ * {@link list.ViewListItemElement#getFillerOffset evaluating whether filler offset} is needed.
+ *
+ * @memberOf list
+ * @extends engine.view.ContainerElement
+ */
+export default class ViewListItemElement extends ViewContainerElement {
+	/**
+	 * Creates `<li>` view item.
+	 *
+	 * @param {Object|Iterable} [attrs] Collection of attributes.
+	 * @param {engine.view.Node|Iterable.<engine.view.Node>} [children] List of nodes to be inserted into created element.
+	 */
+	constructor( attrs, children ) {
+		super( 'li', attrs, children );
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	getFillerOffset() {
+		const hasOnlyLists = !this.isEmpty && ( this.getChild( 0 ).name == 'ul' || this.getChild( 0 ).name == 'ol' );
+
+		return this.isEmpty || hasOnlyLists ? 0 : null;
+	}
+}

+ 206 - 0
packages/ckeditor5-list/tests/indentcommand.js

@@ -0,0 +1,206 @@
+/**
+ * @license Copyright (c) 2003-2015, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+import Editor from '/ckeditor5/core/editor/editor.js';
+import Document from '/ckeditor5/engine/model/document.js';
+import IndentCommand from '/ckeditor5/list/indentcommand.js';
+import Range from '/ckeditor5/engine/model/range.js';
+import Position from '/ckeditor5/engine/model/position.js';
+import { setData, getData } from '/ckeditor5/engine/dev-utils/model.js';
+
+let editor, doc, root;
+
+beforeEach( () => {
+	editor = new Editor();
+	editor.document = new Document();
+
+	doc = editor.document;
+	root = doc.createRoot();
+
+	doc.schema.registerItem( 'listItem', '$block' );
+
+	doc.schema.allow( { name: '$block', inside: '$root' } );
+	doc.schema.allow( { name: 'listItem', attributes: [ 'type', 'indent' ], inside: '$root' } );
+
+	setData(
+		doc,
+		'<listItem indent="0" type="bulleted">a</listItem>' +
+		'<listItem indent="0" type="bulleted">b</listItem>' +
+		'<listItem indent="1" type="bulleted">c</listItem>' +
+		'<listItem indent="2" type="bulleted">d</listItem>' +
+		'<listItem indent="2" type="bulleted">e</listItem>' +
+		'<listItem indent="1" type="bulleted">f</listItem>' +
+		'<listItem indent="0" type="bulleted">g</listItem>'
+	);
+} );
+
+describe( 'IndentCommand', () => {
+	let command;
+
+	beforeEach( () => {
+		command = new IndentCommand( editor, 'forward' );
+	} );
+
+	afterEach( () => {
+		command.destroy();
+	} );
+
+	describe( 'isEnabled', () => {
+		it( 'should be true if selection starts in list item', () => {
+			doc.selection.collapse( root.getChild( 5 ) );
+
+			expect( command.isEnabled ).to.be.true;
+		} );
+
+		it( 'should be false if selection starts in first list item', () => {
+			doc.selection.collapse( root.getChild( 0 ) );
+
+			expect( command.isEnabled ).to.be.false;
+		} );
+
+		it( 'should be false if selection starts in a list item that has bigger indent than it\'s previous sibling', () => {
+			doc.selection.collapse( root.getChild( 2 ) );
+
+			expect( command.isEnabled ).to.be.false;
+		} );
+	} );
+
+	describe( '_doExecute', () => {
+		it( 'should increment indent attribute by 1', () => {
+			doc.selection.collapse( root.getChild( 5 ) );
+
+			command._doExecute();
+
+			expect( getData( doc, { withoutSelection: true } ) ).to.equal(
+				'<listItem indent="0" type="bulleted">a</listItem>' +
+				'<listItem indent="0" type="bulleted">b</listItem>' +
+				'<listItem indent="1" type="bulleted">c</listItem>' +
+				'<listItem indent="2" type="bulleted">d</listItem>' +
+				'<listItem indent="2" type="bulleted">e</listItem>' +
+				'<listItem indent="2" type="bulleted">f</listItem>' +
+				'<listItem indent="0" type="bulleted">g</listItem>'
+			);
+		} );
+
+		it( 'should increment indent of only first selected item when multiple items are selected', () => {
+			doc.selection.setRanges( [ new Range(
+				new Position( root.getChild( 4 ), [ 0 ] ),
+				new Position( root.getChild( 6 ), [ 0 ] )
+			) ] );
+
+			command._doExecute();
+
+			expect( getData( doc, { withoutSelection: true } ) ).to.equal(
+				'<listItem indent="0" type="bulleted">a</listItem>' +
+				'<listItem indent="0" type="bulleted">b</listItem>' +
+				'<listItem indent="1" type="bulleted">c</listItem>' +
+				'<listItem indent="2" type="bulleted">d</listItem>' +
+				'<listItem indent="3" type="bulleted">e</listItem>' +
+				'<listItem indent="1" type="bulleted">f</listItem>' +
+				'<listItem indent="0" type="bulleted">g</listItem>'
+			);
+		} );
+
+		it( 'should increment indent of all sub-items of indented item', () => {
+			doc.selection.collapse( root.getChild( 1 ) );
+
+			command._doExecute();
+
+			expect( getData( doc, { withoutSelection: true } ) ).to.equal(
+				'<listItem indent="0" type="bulleted">a</listItem>' +
+				'<listItem indent="1" type="bulleted">b</listItem>' +
+				'<listItem indent="2" type="bulleted">c</listItem>' +
+				'<listItem indent="3" type="bulleted">d</listItem>' +
+				'<listItem indent="3" type="bulleted">e</listItem>' +
+				'<listItem indent="2" type="bulleted">f</listItem>' +
+				'<listItem indent="0" type="bulleted">g</listItem>'
+			);
+		} );
+	} );
+} );
+
+describe( 'IndentCommand - backward (outdent)', () => {
+	let command;
+
+	beforeEach( () => {
+		command = new IndentCommand( editor, 'backward' );
+	} );
+
+	afterEach( () => {
+		command.destroy();
+	} );
+
+	describe( 'isEnabled', () => {
+		it( 'should be true if selection starts in list item', () => {
+			doc.selection.collapse( root.getChild( 5 ) );
+
+			expect( command.isEnabled ).to.be.true;
+		} );
+
+		it( 'should be true if selection starts in first list item', () => {
+			// This is in contrary to forward indent command.
+			doc.selection.collapse( root.getChild( 0 ) );
+
+			expect( command.isEnabled ).to.be.true;
+		} );
+
+		it( 'should be true if selection starts in a list item that has bigger indent than it\'s previous sibling', () => {
+			// This is in contrary to forward indent command.
+			doc.selection.collapse( root.getChild( 2 ) );
+
+			expect( command.isEnabled ).to.be.true;
+		} );
+	} );
+
+	describe( '_doExecute', () => {
+		it( 'should decrement indent attribute by 1 (if it is bigger than 0)', () => {
+			doc.selection.collapse( root.getChild( 5 ) );
+
+			command._doExecute();
+
+			expect( getData( doc, { withoutSelection: true } ) ).to.equal(
+				'<listItem indent="0" type="bulleted">a</listItem>' +
+				'<listItem indent="0" type="bulleted">b</listItem>' +
+				'<listItem indent="1" type="bulleted">c</listItem>' +
+				'<listItem indent="2" type="bulleted">d</listItem>' +
+				'<listItem indent="2" type="bulleted">e</listItem>' +
+				'<listItem indent="0" type="bulleted">f</listItem>' +
+				'<listItem indent="0" type="bulleted">g</listItem>'
+			);
+		} );
+
+		it( 'should rename listItem to paragraph (if indent is equal to 0)', () => {
+			doc.selection.collapse( root.getChild( 0 ) );
+
+			command._doExecute();
+
+			expect( getData( doc, { withoutSelection: true } ) ).to.equal(
+				'<paragraph>a</paragraph>' +
+				'<listItem indent="0" type="bulleted">b</listItem>' +
+				'<listItem indent="1" type="bulleted">c</listItem>' +
+				'<listItem indent="2" type="bulleted">d</listItem>' +
+				'<listItem indent="2" type="bulleted">e</listItem>' +
+				'<listItem indent="1" type="bulleted">f</listItem>' +
+				'<listItem indent="0" type="bulleted">g</listItem>'
+			);
+		} );
+
+		it( 'should decrement indent of all sub-items of outdented item', () => {
+			doc.selection.collapse( root.getChild( 1 ) );
+
+			command._doExecute();
+
+			expect( getData( doc, { withoutSelection: true } ) ).to.equal(
+				'<listItem indent="0" type="bulleted">a</listItem>' +
+				'<paragraph>b</paragraph>' +
+				'<listItem indent="0" type="bulleted">c</listItem>' +
+				'<listItem indent="1" type="bulleted">d</listItem>' +
+				'<listItem indent="1" type="bulleted">e</listItem>' +
+				'<listItem indent="0" type="bulleted">f</listItem>' +
+				'<listItem indent="0" type="bulleted">g</listItem>'
+			);
+		} );
+	} );
+} );

+ 203 - 0
packages/ckeditor5-list/tests/list.js

@@ -0,0 +1,203 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* globals document */
+
+import ClassicTestEditor from '/tests/core/_utils/classictesteditor.js';
+import List from '/ckeditor5/list/list.js';
+import Paragraph from '/ckeditor5/paragraph/paragraph.js';
+import ListEngine from '/ckeditor5/list/listengine.js';
+import ButtonController from '/ckeditor5/ui/button/button.js';
+import { getCode } from '/ckeditor5/utils/keyboard.js';
+
+describe( 'List', () => {
+	let editor, bulletedListButton, numberedListButton, schema;
+
+	beforeEach( () => {
+		const editorElement = document.createElement( 'div' );
+		document.body.appendChild( editorElement );
+
+		return ClassicTestEditor.create( editorElement, {
+			features: [ Paragraph, List ]
+		} )
+			.then( newEditor => {
+				editor = newEditor;
+				schema = editor.document.schema;
+
+				bulletedListButton = editor.ui.featureComponents.create( 'bulletedList' );
+				numberedListButton = editor.ui.featureComponents.create( 'numberedList' );
+			} );
+	} );
+
+	afterEach( () => {
+		return editor.destroy();
+	} );
+
+	it( 'should be loaded', () => {
+		expect( editor.plugins.get( List ) ).to.be.instanceOf( List );
+	} );
+
+	it( 'should load ListEngine', () => {
+		expect( editor.plugins.get( ListEngine ) ).to.be.instanceOf( ListEngine );
+	} );
+
+	it( 'should set up keys for bulleted list and numbered list', () => {
+		expect( bulletedListButton ).to.be.instanceOf( ButtonController );
+		expect( numberedListButton ).to.be.instanceOf( ButtonController );
+	} );
+
+	it( 'should execute proper commands when buttons are used', () => {
+		sinon.spy( editor, 'execute' );
+
+		bulletedListButton.model.fire( 'execute' );
+		expect( editor.execute.calledWithExactly( 'bulletedList' ) );
+
+		numberedListButton.model.fire( 'execute' );
+		expect( editor.execute.calledWithExactly( 'numberedList' ) );
+	} );
+
+	it( 'should bind bulleted list button model to bulledList command', () => {
+		editor.setData( '<ul><li>foo</li></ul>' );
+		// Collapsing selection in model, which has just flat listItems.
+		editor.document.selection.collapse( editor.document.getRoot().getChild( 0 ) );
+
+		const model = bulletedListButton.model;
+		const command = editor.commands.get( 'bulletedList' );
+
+		expect( model.isOn ).to.be.true;
+		expect( model.isEnabled ).to.be.true;
+
+		command.value = false;
+		expect( model.isOn ).to.be.false;
+
+		command.isEnabled = false;
+		expect( model.isEnabled ).to.be.false;
+	} );
+
+	it( 'should bind numbered list button model to numberedList command', () => {
+		editor.setData( '<ul><li>foo</li></ul>' );
+		// Collapsing selection in model, which has just flat listItems.
+		editor.document.selection.collapse( editor.document.getRoot().getChild( 0 ) );
+
+		const model = numberedListButton.model;
+		const command = editor.commands.get( 'numberedList' );
+
+		// We are in UL, so numbered list is off.
+		expect( model.isOn ).to.be.false;
+		expect( model.isEnabled ).to.be.true;
+
+		command.value = true;
+		expect( model.isOn ).to.be.true;
+
+		command.isEnabled = false;
+		expect( model.isEnabled ).to.be.false;
+	} );
+
+	describe( 'enter key handling callback', () => {
+		it( 'should execute outdentList command on enter key in empty list', () => {
+			const domEvtDataStub = { preventDefault() {} };
+
+			sinon.spy( editor, 'execute' );
+
+			editor.setData( '<ul><li></li></ul>' );
+			// Collapsing selection in model, which has just flat listItems.
+			editor.document.selection.collapse( editor.document.getRoot().getChild( 0 ) );
+
+			editor.editing.view.fire( 'enter', domEvtDataStub );
+
+			expect( editor.execute.calledOnce ).to.be.true;
+			expect( editor.execute.calledWithExactly( 'outdentList' ) );
+		} );
+
+		it( 'should not execute outdentList command on enter key in non-empty list', () => {
+			const domEvtDataStub = { preventDefault() {} };
+
+			sinon.spy( editor, 'execute' );
+
+			editor.setData( '<ul><li>foobar</li></ul>' );
+			// Collapsing selection in model, which has just flat listItems.
+			editor.document.selection.collapse( editor.document.getRoot().getChild( 0 ) );
+
+			editor.editing.view.fire( 'enter', domEvtDataStub );
+
+			expect( editor.execute.called ).to.be.false;
+		} );
+	} );
+
+	describe( 'tab key handling callback', () => {
+		let domEvtDataStub;
+
+		beforeEach( () => {
+			domEvtDataStub = {
+				keystroke: getCode( 'tab' ),
+				preventDefault() {}
+			};
+
+			sinon.spy( editor, 'execute' );
+		} );
+
+		afterEach( () => {
+			editor.execute.restore();
+		} );
+
+		it( 'should execute indentList command on tab key', () => {
+			editor.setData( '<ul><li>foo</li><li>bar</li></ul>' );
+			// Collapsing selection in model, which has just flat listItems.
+			editor.document.selection.collapse( editor.document.getRoot().getChild( 1 ) );
+
+			editor.editing.view.fire( 'keydown', domEvtDataStub );
+
+			expect( editor.execute.calledOnce ).to.be.true;
+			expect( editor.execute.calledWithExactly( 'indentList' ) ).to.be.true;
+		} );
+
+		it( 'should execute indentList command on tab key for non-collapsed selection and indent only first item', () => {
+			editor.setData( '<ul><li>foo</li><li>bar</li><li>xyz</li></ul>' );
+			editor.document.selection.collapse( editor.document.getRoot().getChild( 1 ) );
+			editor.document.selection.setFocus( editor.document.getRoot().getChild( 2 ) );
+
+			editor.editing.view.fire( 'keydown', domEvtDataStub );
+
+			expect( editor.execute.calledOnce ).to.be.true;
+			expect( editor.execute.calledWithExactly( 'indentList' ) ).to.be.true;
+			expect( editor.getData() ).to.equal( '<ul><li>foo<ul><li>bar</li></ul></li><li>xyz</li></ul>' );
+		} );
+
+		it( 'should execute outdentList command on shift+tab keystroke', () => {
+			domEvtDataStub.keystroke += getCode( 'shift' );
+
+			editor.setData( '<ul><li>foo<ul><li>bar</li></ul></li></ul>' );
+			// Collapsing selection in model, which has just flat listItems.
+			editor.document.selection.collapse( editor.document.getRoot().getChild( 1 ) );
+
+			editor.editing.view.fire( 'keydown', domEvtDataStub );
+
+			expect( editor.execute.calledOnce ).to.be.true;
+			expect( editor.execute.calledWithExactly( 'outdentList' ) ).to.be.true;
+		} );
+
+		it( 'should not indent if command is disabled', () => {
+			editor.setData( '<ul><li>foo</li></ul>' );
+			// Collapsing selection in model, which has just flat listItems.
+			editor.document.selection.collapse( editor.document.getRoot().getChild( 0 ) );
+
+			editor.editing.view.fire( 'keydown', domEvtDataStub );
+
+			expect( editor.execute.called ).to.be.false;
+		} );
+
+		it( 'should not indent or outdent if alt+tab is pressed', () => {
+			domEvtDataStub.keystroke += getCode( 'alt' );
+
+			editor.setData( '<ul><li>foo</li></ul>' );
+			// Collapsing selection in model, which has just flat listItems.
+			editor.document.selection.collapse( editor.document.getRoot().getChild( 0 ) );
+
+			editor.editing.view.fire( 'keydown', domEvtDataStub );
+
+			expect( editor.execute.called ).to.be.false;
+		} );
+	} );
+} );

+ 321 - 0
packages/ckeditor5-list/tests/listcommand.js

@@ -0,0 +1,321 @@
+/**
+ * @license Copyright (c) 2003-2015, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+import Editor from '/ckeditor5/core/editor/editor.js';
+import Document from '/ckeditor5/engine/model/document.js';
+import ListCommand from '/ckeditor5/list/listcommand.js';
+import Range from '/ckeditor5/engine/model/range.js';
+import Position from '/ckeditor5/engine/model/position.js';
+import { setData, getData } from '/ckeditor5/engine/dev-utils/model.js';
+
+let editor, command, doc, root;
+
+beforeEach( () => {
+	editor = new Editor();
+	editor.document = new Document();
+
+	doc = editor.document;
+	root = doc.createRoot();
+
+	command = new ListCommand( editor, 'bulleted' );
+
+	doc.schema.registerItem( 'listItem', '$block' );
+	doc.schema.registerItem( 'paragraph', '$block' );
+	doc.schema.registerItem( 'widget', '$block' );
+
+	doc.schema.allow( { name: '$block', inside: '$root' } );
+	doc.schema.allow( { name: 'listItem', attributes: [ 'type', 'indent' ], inside: '$root' } );
+	doc.schema.disallow( { name: 'listItem', attributes: [ 'type', 'indent' ], inside: 'widget' } );
+
+	setData(
+		doc,
+		'<paragraph>foo</paragraph>' +
+		'<listItem type="bulleted" indent="0">bulleted</listItem>' +
+		'<listItem type="numbered" indent="0">numbered</listItem>' +
+		'<paragraph>bar</paragraph>' +
+		'<widget>' +
+			'<paragraph>xyz</paragraph>' +
+		'</widget>'
+	);
+
+	doc.selection.collapse( doc.getRoot().getChild( 0 ) );
+} );
+
+afterEach( () => {
+	command.destroy();
+} );
+
+describe( 'ListCommand', () => {
+	describe( 'constructor', () => {
+		it( 'should create list command with given type and value set to false', () => {
+			expect( command.type ).to.equal( 'bulleted' );
+			expect( command.value ).to.be.false;
+
+			const numberedList = new ListCommand( editor, 'numbered' );
+			expect( numberedList.type ).to.equal( 'numbered' );
+		} );
+	} );
+
+	describe( 'value', () => {
+		it( 'should be false if first position in selection is not in a list item', () => {
+			doc.selection.collapse( doc.getRoot().getChild( 3 ) );
+			expect( command.value ).to.be.false;
+		} );
+
+		it( 'should be false if first position in selection is in a list item of different type', () => {
+			doc.selection.collapse( doc.getRoot().getChild( 2 ) );
+			expect( command.value ).to.be.false;
+		} );
+
+		it( 'should be true if first position in selection is in a list item of same type', () => {
+			doc.selection.collapse( doc.getRoot().getChild( 1 ) );
+			expect( command.value ).to.be.true;
+		} );
+	} );
+
+	describe( 'isEnabled', () => {
+		it( 'should be true if command value is true', () => {
+			command.value = true;
+			command.refreshState();
+
+			expect( command.isEnabled ).to.be.true;
+
+			command.value = false;
+			doc.selection.collapse( doc.getRoot().getChild( 1 ) );
+
+			expect( command.value ).to.be.true;
+			expect( command.isEnabled ).to.be.true;
+		} );
+
+		it( 'should be true if selection first position is in a place where listItem can be inserted', () => {
+			doc.selection.collapse( doc.getRoot(), 2 );
+			expect( command.isEnabled ).to.be.true;
+
+			doc.selection.collapse( doc.getRoot().getChild( 0 ) );
+			expect( command.isEnabled ).to.be.true;
+
+			doc.selection.collapse( doc.getRoot().getChild( 2 ) );
+			expect( command.isEnabled ).to.be.true;
+		} );
+
+		it( 'should be false if selection first position is in a place where listItem cannot be inserted', () => {
+			doc.selection.collapse( doc.getRoot().getChild( 4 ) );
+			expect( command.isEnabled ).to.be.false;
+		} );
+	} );
+
+	describe( '_doExecute', () => {
+		describe( 'collapsed selection', () => {
+			it( 'should rename closest block to listItem and set correct attributes', () => {
+				setData( doc, '<paragraph>fo[]o</paragraph>' );
+
+				command._doExecute();
+
+				expect( getData( doc ) ).to.equal( '<listItem indent="0" type="bulleted">fo[]o</listItem>' );
+			} );
+
+			it( 'should rename closest listItem to paragraph and remove attributes', () => {
+				setData( doc, '<listItem indent="0" type="bulleted">fo[]o</listItem>' );
+
+				command._doExecute();
+
+				expect( getData( doc ) ).to.equal( '<paragraph>fo[]o</paragraph>' );
+			} );
+
+			it( 'should change closest listItem\' type', () => {
+				setData( doc, '<listItem indent="0" type="numbered">fo[]o</listItem>' );
+
+				command._doExecute();
+
+				expect( getData( doc ) ).to.equal( '<listItem indent="0" type="bulleted">fo[]o</listItem>' );
+			} );
+
+			it( 'should handle outdenting sub-items when list item is turned off', () => {
+				// Taken from docs.
+				//
+				// 1  * --------
+				// 2     * --------
+				// 3        * -------- <- this is turned off.
+				// 4           * -------- <- this has to become indent = 0, because it will be first item on a new list.
+				// 5              * -------- <- this should be still be a child of item above, so indent = 1.
+				// 6        * -------- <- this also has to become indent = 0, because it shouldn't end up as a child of any of items above.
+				// 7           * -------- <- this should be still be a child of item above, so indent = 1.
+				// 8     * -------- <- this has to become indent = 0.
+				// 9        * -------- <- this should still be a child of item above, so indent = 1.
+				// 10          * -------- <- this should still be a child of item above, so indent = 2.
+				// 11          * -------- <- this should still be at the same level as item above, so indent = 2.
+				// 12 * -------- <- this and all below are left unchanged.
+				// 13    * --------
+				// 14       * --------
+				//
+				// After turning off "3", the list becomes:
+				//
+				// 1  * --------
+				// 2     * --------
+				//
+				// 3  --------
+				//
+				// 4  * --------
+				// 5     * --------
+				// 6  * --------
+				// 7     * --------
+				// 8  * --------
+				// 9     * --------
+				// 10       * --------
+				// 11       * --------
+				// 12 * --------
+				// 13    * --------
+				// 14       * --------
+
+				setData(
+					doc,
+					'<listItem indent="0" type="bulleted">---</listItem>' +
+					'<listItem indent="1" type="bulleted">---</listItem>' +
+					'<listItem indent="2" type="bulleted">[]---</listItem>' +
+					'<listItem indent="3" type="bulleted">---</listItem>' +
+					'<listItem indent="4" type="bulleted">---</listItem>' +
+					'<listItem indent="2" type="bulleted">---</listItem>' +
+					'<listItem indent="3" type="bulleted">---</listItem>' +
+					'<listItem indent="1" type="bulleted">---</listItem>' +
+					'<listItem indent="2" type="bulleted">---</listItem>' +
+					'<listItem indent="3" type="bulleted">---</listItem>' +
+					'<listItem indent="3" type="bulleted">---</listItem>' +
+					'<listItem indent="0" type="bulleted">---</listItem>' +
+					'<listItem indent="1" type="bulleted">---</listItem>' +
+					'<listItem indent="2" type="bulleted">---</listItem>'
+				);
+
+				command._doExecute();
+
+				const expectedData =
+					'<listItem indent="0" type="bulleted">---</listItem>' +
+					'<listItem indent="1" type="bulleted">---</listItem>' +
+					'<paragraph>[]---</paragraph>' +
+					'<listItem indent="0" type="bulleted">---</listItem>' +
+					'<listItem indent="1" type="bulleted">---</listItem>' +
+					'<listItem indent="0" type="bulleted">---</listItem>' +
+					'<listItem indent="1" type="bulleted">---</listItem>' +
+					'<listItem indent="0" type="bulleted">---</listItem>' +
+					'<listItem indent="1" type="bulleted">---</listItem>' +
+					'<listItem indent="2" type="bulleted">---</listItem>' +
+					'<listItem indent="2" type="bulleted">---</listItem>' +
+					'<listItem indent="0" type="bulleted">---</listItem>' +
+					'<listItem indent="1" type="bulleted">---</listItem>' +
+					'<listItem indent="2" type="bulleted">---</listItem>';
+
+				expect( getData( doc ) ).to.equal( expectedData );
+			} );
+		} );
+
+		describe( 'non-collapsed selection', () => {
+			beforeEach( () => {
+				setData(
+					doc,
+					'<listItem indent="0" type="bulleted">---</listItem>' +
+					'<listItem indent="0" type="bulleted">---</listItem>' +
+					'<paragraph>---</paragraph>' +
+					'<paragraph>---</paragraph>' +
+					'<listItem indent="0" type="numbered">---</listItem>' +
+					'<listItem indent="0" type="numbered">---</listItem>' +
+					'<listItem indent="1" type="bulleted">---</listItem>' +
+					'<listItem indent="2" type="bulleted">---</listItem>'
+				);
+			} );
+
+			it( 'should rename closest block to listItem and set correct attributes', () => {
+				// From first paragraph to second paragraph.
+				// Command value=false, we are turning on list items.
+				doc.selection.setRanges( [ new Range(
+					Position.createAt( root.getChild( 2 ) ),
+					Position.createAt( root.getChild( 3 ) )
+				) ] );
+
+				command._doExecute();
+
+				const expectedData =
+					'<listItem indent="0" type="bulleted">---</listItem>' +
+					'<listItem indent="0" type="bulleted">---</listItem>' +
+					'<listItem indent="0" type="bulleted">[---</listItem>' +
+					'<listItem indent="0" type="bulleted">]---</listItem>' +
+					'<listItem indent="0" type="numbered">---</listItem>' +
+					'<listItem indent="0" type="numbered">---</listItem>' +
+					'<listItem indent="1" type="bulleted">---</listItem>' +
+					'<listItem indent="2" type="bulleted">---</listItem>';
+
+				expect( getData( doc ) ).to.equal( expectedData );
+			} );
+
+			it( 'should rename closest listItem to paragraph and remove attributes', () => {
+				// From second bullet list item to first numbered list item.
+				// Command value=true, we are turning off list items.
+				doc.selection.setRanges( [ new Range(
+					Position.createAt( root.getChild( 1 ) ),
+					Position.createAt( root.getChild( 4 ) )
+				) ] );
+
+				// Convert paragraphs, leave numbered list items.
+				command._doExecute();
+
+				const expectedData =
+					'<listItem indent="0" type="bulleted">---</listItem>' +
+					'<paragraph>[---</paragraph>' +
+					'<paragraph>---</paragraph>' +
+					'<paragraph>---</paragraph>' +
+					'<paragraph>]---</paragraph>' +
+					'<listItem indent="0" type="numbered">---</listItem>' +
+					'<listItem indent="1" type="bulleted">---</listItem>' +
+					'<listItem indent="2" type="bulleted">---</listItem>';
+
+				expect( getData( doc ) ).to.equal( expectedData );
+			} );
+
+			it( 'should change closest listItem\'s type', () => {
+				// From first numbered lsit item to third bulleted list item.
+				doc.selection.setRanges( [ new Range(
+					Position.createAt( root.getChild( 4 ) ),
+					Position.createAt( root.getChild( 6 ) )
+				) ] );
+
+				// Convert paragraphs, leave numbered list items.
+				command._doExecute();
+
+				const expectedData =
+					'<listItem indent="0" type="bulleted">---</listItem>' +
+					'<listItem indent="0" type="bulleted">---</listItem>' +
+					'<paragraph>---</paragraph>' +
+					'<paragraph>---</paragraph>' +
+					'<listItem indent="0" type="bulleted">[---</listItem>' +
+					'<listItem indent="0" type="bulleted">---</listItem>' +
+					'<listItem indent="1" type="bulleted">]---</listItem>' +
+					'<listItem indent="2" type="bulleted">---</listItem>';
+
+				expect( getData( doc ) ).to.equal( expectedData );
+			} );
+
+			it( 'should handle outdenting sub-items when list item is turned off', () => {
+				// From first numbered lsit item to third bulleted list item.
+				doc.selection.setRanges( [ new Range(
+					Position.createAt( root.getChild( 1 ) ),
+					Position.createAt( root.getChild( 5 ) )
+				) ] );
+
+				// Convert paragraphs, leave numbered list items.
+				command._doExecute();
+
+				const expectedData =
+					'<listItem indent="0" type="bulleted">---</listItem>' +
+					'<paragraph>[---</paragraph>' +
+					'<paragraph>---</paragraph>' +
+					'<paragraph>---</paragraph>' +
+					'<paragraph>---</paragraph>' +
+					'<paragraph>]---</paragraph>' +
+					'<listItem indent="0" type="bulleted">---</listItem>' +
+					'<listItem indent="1" type="bulleted">---</listItem>';
+
+				expect( getData( doc ) ).to.equal( expectedData );
+			} );
+		} );
+	} );
+} );

+ 605 - 0
packages/ckeditor5-list/tests/listengine.js

@@ -0,0 +1,605 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+import ListEngine from '/ckeditor5/list/listengine.js';
+import ListCommand from '/ckeditor5/list/listcommand.js';
+
+import ModelElement from '/ckeditor5/engine/model/element.js';
+import ModelText from '/ckeditor5/engine/model/text.js';
+import ModelPosition from '/ckeditor5/engine/model/position.js';
+import ModelRange from '/ckeditor5/engine/model/range.js';
+
+import ViewPosition from '/ckeditor5/engine/view/position.js';
+
+import VirtualTestEditor from '/tests/core/_utils/virtualtesteditor.js';
+import { getData as getModelData, setData as setModelData } from '/ckeditor5/engine/dev-utils/model.js';
+import { getData as getViewData } from '/ckeditor5/engine/dev-utils/view.js';
+
+describe( 'ListEngine', () => {
+	let editor, doc, root;
+
+	beforeEach( () => {
+		return VirtualTestEditor.create( {
+			features: [ 'paragraph', ListEngine ]
+		} )
+			.then( newEditor => {
+				editor = newEditor;
+
+				doc = editor.document;
+				root = doc.getRoot();
+
+				doc.schema.allow( { name: '$text', inside: '$root' } );
+			} );
+	} );
+
+	it( 'should be loaded', () => {
+		expect( editor.plugins.get( ListEngine ) ).to.be.instanceOf( ListEngine );
+	} );
+
+	it( 'should set proper schema rules', () => {
+		expect( doc.schema.hasItem( 'listItem' ) );
+		expect( doc.schema.itemExtends( 'listItem', '$block' ) );
+
+		expect( doc.schema.check( { name: '$inline', inside: 'listItem' } ) ).to.be.true;
+		expect( doc.schema.check( { name: 'listItem', inside: 'listItem' } ) ).to.be.false;
+		expect( doc.schema.check( { name: '$block', inside: 'listItem' } ) ).to.be.false;
+
+		expect( doc.schema.check( { name: 'listItem', inside: '$root' } ) ).to.be.false;
+		expect( doc.schema.check( { name: 'listItem', inside: '$root', attributes: [ 'indent' ] } ) ).to.be.false;
+		expect( doc.schema.check( { name: 'listItem', inside: '$root', attributes: [ 'type' ] } ) ).to.be.false;
+		expect( doc.schema.check( { name: 'listItem', inside: '$root', attributes: [ 'indent', 'type' ] } ) ).to.be.true;
+	} );
+
+	describe( 'commands', () => {
+		it( 'should register bulleted list command', () => {
+			expect( editor.commands.has( 'bulletedList' ) ).to.be.true;
+
+			const command = editor.commands.get( 'bulletedList' );
+
+			expect( command ).to.be.instanceOf( ListCommand );
+			expect( command ).to.have.property( 'type', 'bulleted' );
+		} );
+
+		it( 'should register numbered list command', () => {
+			expect( editor.commands.has( 'numberedList' ) ).to.be.true;
+
+			const command = editor.commands.get( 'numberedList' );
+
+			expect( command ).to.be.instanceOf( ListCommand );
+			expect( command ).to.have.property( 'type', 'numbered' );
+		} );
+	} );
+
+	describe( 'converters', () => {
+		describe( 'model to view', () => {
+			let viewDoc;
+
+			function expectView( expectedView ) {
+				expect( getViewData( viewDoc, { withoutSelection: true } ) ).to.equal( expectedView );
+			}
+
+			beforeEach( () => {
+				doc = editor.document;
+				viewDoc = editor.editing.view;
+			} );
+
+			describe( 'insert', () => {
+				let item;
+
+				beforeEach( () => {
+					item = new ModelElement( 'listItem', { indent: 0, type: 'bulleted' }, new ModelText( '---' ) );
+
+					const data =
+						'<paragraph>foo</paragraph>' +
+						'<listItem indent="0" type="bulleted">xxx</listItem>' +
+						'<listItem indent="0" type="bulleted">yyy</listItem>' +
+						'<paragraph>bar</paragraph>';
+
+					setModelData( doc, data );
+				} );
+
+				it( 'list into empty editor (initialization)', () => {
+					expectView( '<p>foo</p><ul><li>xxx</li><li>yyy</li></ul><p>bar</p>' );
+				} );
+
+				it( 'item at the beginning of list', () => {
+					doc.batch().insert( ModelPosition.createAt( root, 1 ), item );
+
+					expectView( '<p>foo</p><ul><li>---</li><li>xxx</li><li>yyy</li></ul><p>bar</p>' );
+				} );
+
+				it( 'item in the middle of list', () => {
+					doc.batch().insert( ModelPosition.createAt( root, 2 ), item );
+
+					expectView( '<p>foo</p><ul><li>xxx</li><li>---</li><li>yyy</li></ul><p>bar</p>' );
+				} );
+
+				it( 'item at the end of the list', () => {
+					doc.batch().insert( ModelPosition.createAt( root, 3 ), item );
+
+					expectView( '<p>foo</p><ul><li>xxx</li><li>yyy</li><li>---</li></ul><p>bar</p>' );
+				} );
+
+				it( 'item with different type at the beginning of list', () => {
+					item.setAttribute( 'type', 'numbered' );
+					doc.batch().insert( ModelPosition.createAt( root, 1 ), item );
+
+					expectView( '<p>foo</p><ol><li>---</li></ol><ul><li>xxx</li><li>yyy</li></ul><p>bar</p>' );
+				} );
+
+				it( 'item with different type in the middle of list', () => {
+					item.setAttribute( 'type', 'numbered' );
+					doc.batch().insert( ModelPosition.createAt( root, 2 ), item );
+
+					expectView( '<p>foo</p><ul><li>xxx</li></ul><ol><li>---</li></ol><ul><li>yyy</li></ul><p>bar</p>' );
+				} );
+
+				it( 'item with different type at the end of the list', () => {
+					item.setAttribute( 'type', 'numbered' );
+					doc.batch().insert( ModelPosition.createAt( root, 3 ), item );
+
+					expectView( '<p>foo</p><ul><li>xxx</li><li>yyy</li></ul><ol><li>---</li></ol><p>bar</p>' );
+				} );
+			} );
+
+			describe( 'remove', () => {
+				beforeEach( () => {
+					const data =
+						'<paragraph>foo</paragraph>' +
+						'<listItem indent="0" type="bulleted">xxx</listItem>' +
+						'<listItem indent="0" type="bulleted">yyy</listItem>' +
+						'<listItem indent="0" type="bulleted">zzz</listItem>' +
+						'<paragraph>bar</paragraph>';
+
+					setModelData( doc, data );
+				} );
+
+				it( 'item from the beginning of the list', () => {
+					doc.batch().remove( ModelRange.createFromParentsAndOffsets( root, 1, root, 2 ) );
+
+					expectView( '<p>foo</p><ul><li>yyy</li><li>zzz</li></ul><p>bar</p>' );
+				} );
+
+				it( 'item from the middle of the list', () => {
+					doc.batch().remove( ModelRange.createFromParentsAndOffsets( root, 2, root, 3 ) );
+
+					expectView( '<p>foo</p><ul><li>xxx</li><li>zzz</li></ul><p>bar</p>' );
+				} );
+
+				it( 'item from the end of the list', () => {
+					doc.batch().remove( ModelRange.createFromParentsAndOffsets( root, 3, root, 4 ) );
+
+					expectView( '<p>foo</p><ul><li>xxx</li><li>yyy</li></ul><p>bar</p>' );
+				} );
+
+				it( 'multiple items #1', () => {
+					doc.batch().remove( ModelRange.createFromParentsAndOffsets( root, 1, root, 3 ) );
+
+					expectView( '<p>foo</p><ul><li>zzz</li></ul><p>bar</p>' );
+				} );
+
+				it( 'multiple items #2', () => {
+					doc.batch().remove( ModelRange.createFromParentsAndOffsets( root, 2, root, 4 ) );
+
+					expectView( '<p>foo</p><ul><li>xxx</li></ul><p>bar</p>' );
+				} );
+
+				it( 'all items', () => {
+					doc.batch().remove( ModelRange.createFromParentsAndOffsets( root, 1, root, 4 ) );
+
+					expectView( '<p>foo</p><p>bar</p>' );
+				} );
+			} );
+
+			describe( 'move', () => {
+				let targetPosition;
+
+				beforeEach( () => {
+					const data =
+						'<paragraph>foo</paragraph>' +
+						'<listItem indent="0" type="bulleted">xxx</listItem>' +
+						'<listItem indent="0" type="bulleted">yyy</listItem>' +
+						'<listItem indent="0" type="bulleted">zzz</listItem>' +
+						'<paragraph>bar</paragraph>';
+
+					setModelData( doc, data );
+
+					targetPosition = ModelPosition.createAt( root, 5 );
+				} );
+
+				it( 'item from the beginning of the list', () => {
+					doc.batch().move( ModelRange.createFromParentsAndOffsets( root, 1, root, 2 ), targetPosition );
+
+					expectView( '<p>foo</p><ul><li>yyy</li><li>zzz</li></ul><p>bar</p><ul><li>xxx</li></ul>' );
+				} );
+
+				it( 'item from the middle of the list', () => {
+					doc.batch().move( ModelRange.createFromParentsAndOffsets( root, 2, root, 3 ), targetPosition );
+
+					expectView( '<p>foo</p><ul><li>xxx</li><li>zzz</li></ul><p>bar</p><ul><li>yyy</li></ul>' );
+				} );
+
+				it( 'item from the end of the list', () => {
+					doc.batch().move( ModelRange.createFromParentsAndOffsets( root, 3, root, 4 ), targetPosition );
+
+					expectView( '<p>foo</p><ul><li>xxx</li><li>yyy</li></ul><p>bar</p><ul><li>zzz</li></ul>' );
+				} );
+
+				it( 'move item around the same list', () => {
+					doc.batch().move(
+						ModelRange.createFromParentsAndOffsets( root, 1, root, 2 ),
+						ModelPosition.createAt( root, 3 )
+					);
+
+					expectView( '<p>foo</p><ul><li>yyy</li><li>xxx</li><li>zzz</li></ul><p>bar</p>' );
+				} );
+
+				it( 'multiple items #1', () => {
+					doc.batch().move( ModelRange.createFromParentsAndOffsets( root, 1, root, 3 ), targetPosition );
+
+					expectView( '<p>foo</p><ul><li>zzz</li></ul><p>bar</p><ul><li>xxx</li><li>yyy</li></ul>' );
+				} );
+
+				it( 'multiple items #2', () => {
+					doc.batch().move( ModelRange.createFromParentsAndOffsets( root, 2, root, 4 ), targetPosition );
+
+					expectView( '<p>foo</p><ul><li>xxx</li></ul><p>bar</p><ul><li>yyy</li><li>zzz</li></ul>' );
+				} );
+
+				it( 'all items', () => {
+					doc.batch().move( ModelRange.createFromParentsAndOffsets( root, 1, root, 4 ), targetPosition );
+
+					expectView( '<p>foo</p><p>bar</p><ul><li>xxx</li><li>yyy</li><li>zzz</li></ul>' );
+				} );
+			} );
+
+			describe( 'change type', () => {
+				beforeEach( () => {
+					const data =
+						'<paragraph>foo</paragraph>' +
+						'<listItem indent="0" type="bulleted">xxx</listItem>' +
+						'<listItem indent="0" type="bulleted">yyy</listItem>' +
+						'<listItem indent="0" type="bulleted">zzz</listItem>' +
+						'<paragraph>bar</paragraph>';
+
+					setModelData( doc, data );
+				} );
+
+				it( 'item at the beginning of list', () => {
+					let item = root.getChild( 1 );
+					doc.batch().setAttribute( item, 'type', 'numbered' );
+
+					expectView( '<p>foo</p><ol><li>xxx</li></ol><ul><li>yyy</li><li>zzz</li></ul><p>bar</p>' );
+				} );
+
+				it( 'item in the middle of list', () => {
+					let item = root.getChild( 2 );
+					doc.batch().setAttribute( item, 'type', 'numbered' );
+
+					expectView( '<p>foo</p><ul><li>xxx</li></ul><ol><li>yyy</li></ol><ul><li>zzz</li></ul><p>bar</p>' );
+				} );
+
+				it( 'item at the end of the list', () => {
+					let item = root.getChild( 3 );
+					doc.batch().setAttribute( item, 'type', 'numbered' );
+
+					expectView( '<p>foo</p><ul><li>xxx</li><li>yyy</li></ul><ol><li>zzz</li></ol><p>bar</p>' );
+				} );
+			} );
+
+			describe( 'rename', () => {
+				beforeEach( () => {
+					const data =
+						'<paragraph>foo</paragraph>' +
+						'<listItem indent="0" type="bulleted">xxx</listItem>' +
+						'<listItem indent="0" type="bulleted">yyy</listItem>' +
+						'<listItem indent="0" type="bulleted">zzz</listItem>' +
+						'<paragraph>bar</paragraph>';
+
+					setModelData( doc, data );
+				} );
+
+				describe( 'item to paragraph', () => {
+					it( 'first item', () => {
+						doc.batch().rename( root.getChild( 1 ), 'paragraph' );
+
+						expectView( '<p>foo</p><p>xxx</p><ul><li>yyy</li><li>zzz</li></ul><p>bar</p>' );
+					} );
+
+					it( 'middle item', () => {
+						doc.batch().rename( root.getChild( 2 ), 'paragraph' );
+
+						expectView( '<p>foo</p><ul><li>xxx</li></ul><p>yyy</p><ul><li>zzz</li></ul><p>bar</p>' );
+					} );
+
+					it( 'last item', () => {
+						doc.batch().rename( root.getChild( 3 ), 'paragraph' );
+
+						expectView( '<p>foo</p><ul><li>xxx</li><li>yyy</li></ul><p>zzz</p><p>bar</p>' );
+					} );
+				} );
+
+				describe( 'paragraph to item', () => {
+					it( 'first paragraph', () => {
+						const item = root.getChild( 0 );
+
+						doc.batch()
+							.setAttribute( item, 'type', 'bulleted' )
+							.setAttribute( item, 'indent', 0 )
+							.rename( item, 'listItem' );
+
+						expectView( '<ul><li>foo</li><li>xxx</li><li>yyy</li><li>zzz</li></ul><p>bar</p>' );
+					} );
+
+					it( 'last paragraph', () => {
+						const item = root.getChild( 4 );
+
+						doc.batch()
+							.setAttribute( item, 'type', 'bulleted' )
+							.setAttribute( item, 'indent', 0 )
+							.rename( item, 'listItem' );
+
+						expectView( '<p>foo</p><ul><li>xxx</li><li>yyy</li><li>zzz</li><li>bar</li></ul>' );
+					} );
+				} );
+			} );
+
+			describe( 'merging', () => {
+				let paragraph;
+
+				beforeEach( () => {
+					const data =
+						'<paragraph>foo</paragraph>' +
+						'<listItem indent="0" type="bulleted">xxx</listItem>' +
+						'<listItem indent="0" type="bulleted">yyy</listItem>' +
+						'<paragraph>bar</paragraph>' +
+						'<listItem indent="0" type="bulleted">zzz</listItem>';
+
+					setModelData( doc, data );
+
+					paragraph = root.getChild( 3 );
+				} );
+
+				it( 'after removing element from between two lists', () => {
+					doc.batch().remove( paragraph );
+
+					expectView( '<p>foo</p><ul><li>xxx</li><li>yyy</li><li>zzz</li></ul>' );
+				} );
+
+				it( 'after removing multiple elements', () => {
+					doc.batch().remove( ModelRange.createFromParentsAndOffsets( root, 2, root, 4 ) );
+
+					expectView( '<p>foo</p><ul><li>xxx</li><li>zzz</li></ul>' );
+				} );
+
+				it( 'after moving element from between two lists', () => {
+					doc.batch().move( paragraph, ModelPosition.createAt( root, 5 ) );
+
+					expectView( '<p>foo</p><ul><li>xxx</li><li>yyy</li><li>zzz</li></ul><p>bar</p>' );
+				} );
+
+				it( 'after renaming element between two lists - paragraph', () => {
+					doc.batch()
+						.setAttribute( paragraph, 'type', 'bulleted' )
+						.setAttribute( paragraph, 'indent', 0 )
+						.rename( paragraph, 'listItem' );
+
+					expectView( '<p>foo</p><ul><li>xxx</li><li>yyy</li><li>bar</li><li>zzz</li></ul>' );
+				} );
+
+				it( 'after renaming element between two lists - different list type', () => {
+					doc.batch()
+						.setAttribute( paragraph, 'type', 'numbered' )
+						.setAttribute( paragraph, 'indent', 0 )
+						.rename( paragraph, 'listItem' );
+
+					// Different list type - do not merge.
+					expectView( '<p>foo</p><ul><li>xxx</li><li>yyy</li></ul><ol><li>bar</li></ol><ul><li>zzz</li></ul>' );
+
+					doc.batch().setAttribute( root.getChild( 3 ), 'type', 'bulleted' );
+
+					// Same list type - merge.
+					expectView( '<p>foo</p><ul><li>xxx</li><li>yyy</li><li>bar</li><li>zzz</li></ul>' );
+				} );
+			} );
+		} );
+
+		describe( 'view to model', () => {
+			it( 'converts structure with ul, li and ol', () => {
+				editor.setData( '<p>foo</p><ul><li>xxx</li><li>yyy</li></ul><ol><li>zzz</li></ol><p>bar</p>' );
+
+				const expectedModel =
+					'<paragraph>foo</paragraph>' +
+					'<listItem indent="0" type="bulleted">xxx</listItem>' +
+					'<listItem indent="0" type="bulleted">yyy</listItem>' +
+					'<listItem indent="0" type="numbered">zzz</listItem>' +
+					'<paragraph>bar</paragraph>';
+
+				expect( getModelData( doc, { withoutSelection: true } ) ).to.equal( expectedModel );
+			} );
+
+			it( 'cleans incorrect elements (for example whitespaces)', () => {
+				editor.setData(
+					'<p>foo</p>' +
+					'<ul>' +
+					'	<li>xxx</li>' +
+					'	<li>yyy</li>' +
+					'	<p>bar</p>' +
+					'</ul>'
+				);
+
+				const expectedModel =
+					'<paragraph>foo</paragraph>' +
+					'<listItem indent="0" type="bulleted">xxx</listItem>' +
+					'<listItem indent="0" type="bulleted">yyy</listItem>';
+
+				expect( getModelData( doc, { withoutSelection: true } ) ).to.equal( expectedModel );
+			} );
+		} );
+
+		it( 'model insert converter should not fire if change was already consumed', () => {
+			editor.editing.modelToView.on( 'insert', ( evt, data, consumable ) => {
+				consumable.consume( data.item, 'insert' );
+			}, { priority: 'highest' } );
+
+			setModelData( doc, '<listItem indent="0" type="bulleted"></listItem>' );
+
+			expect( getViewData( editor.editing.view, { withoutSelection: true } ) ).to.equal( '' );
+		} );
+
+		it( 'model remove converter should not fire if change was already consumed', () => {
+			editor.editing.modelToView.on( 'remove', ( evt, data, consumable ) => {
+				consumable.consume( data.item, 'remove' );
+			}, { priority: 'highest' } );
+
+			setModelData( doc, '<listItem indent="0" type="bulleted"></listItem>' );
+
+			doc.batch().remove( root.getChild( 0 ) );
+
+			expect( getViewData( editor.editing.view, { withoutSelection: true } ) ).to.equal( '<ul><li></li></ul>' );
+		} );
+
+		it( 'model move converter should not fire if change was already consumed', () => {
+			editor.editing.modelToView.on( 'move', ( evt, data, consumable ) => {
+				consumable.consume( data.item, 'move' );
+			}, { priority: 'highest' } );
+
+			setModelData( doc, '<listItem indent="0" type="bulleted"></listItem><paragraph>foo</paragraph>' );
+
+			doc.batch().move( root.getChild( 0 ), ModelPosition.createAt( root, 2 ) );
+
+			expect( getViewData( editor.editing.view, { withoutSelection: true } ) ).to.equal( '<ul><li></li></ul><p>foo</p>' );
+		} );
+
+		it( 'model change type converter should not fire if change was already consumed', () => {
+			editor.editing.modelToView.on( 'changeAttribute:type', ( evt, data, consumable ) => {
+				consumable.consume( data.item, 'changeAttribute:type' );
+			}, { priority: 'highest' } );
+
+			setModelData( doc, '<listItem indent="0" type="bulleted"></listItem>' );
+
+			doc.batch().setAttribute( root.getChild( 0 ), 'type', 'numbered' );
+
+			expect( getViewData( editor.editing.view, { withoutSelection: true } ) ).to.equal( '<ul><li></li></ul>' );
+		} );
+
+		it( 'view li converter should not fire if change was already consumed', () => {
+			editor.data.viewToModel.on( 'element:li', ( evt, data, consumable ) => {
+				consumable.consume( data.input, { name: true } );
+			}, { priority: 'highest' } );
+
+			editor.setData( '<ul><li></li></ul>' );
+
+			expect( getModelData( doc, { withoutSelection: true } ) ).to.equal( '' );
+		} );
+
+		it( 'view ul converter should not fire if change was already consumed', () => {
+			editor.data.viewToModel.on( 'element:ul', ( evt, data, consumable ) => {
+				consumable.consume( data.input, { name: true } );
+			}, { priority: 'highest' } );
+
+			editor.setData( '<ul><li></li></ul>' );
+
+			expect( getModelData( doc, { withoutSelection: true } ) ).to.equal( '' );
+		} );
+	} );
+
+	describe( 'position mapping', () => {
+		let viewRoot;
+
+		beforeEach( () => {
+			const data =
+				'<paragraph>foo</paragraph>' +
+				'<listItem indent="0" type="bulleted">xxx</listItem>' +
+				'<listItem indent="0" type="bulleted">yyy</listItem>' +
+				'<listItem indent="0" type="bulleted">zzz</listItem>' +
+				'<paragraph>bar</paragraph>';
+
+			setModelData( doc, data );
+
+			viewRoot = editor.editing.view.getRoot();
+		} );
+
+		describe( 'model to view', () => {
+			it( 'before listItem mapped to first LI => before UL', () => {
+				const position = editor.editing.mapper.toViewPosition( ModelPosition.createAt( root, 1 ) );
+
+				expect( position.parent ).to.equal( viewRoot );
+				expect( position.offset ).to.equal( 1 );
+				expect( position.nodeAfter.name ).to.equal( 'ul' );
+			} );
+
+			it( 'before listItem mapped to not-first LI => before LI', () => {
+				let position = editor.editing.mapper.toViewPosition( ModelPosition.createAt( root, 2 ) );
+
+				expect( position.parent.name ).to.equal( 'ul' );
+				expect( position.offset ).to.equal( 1 );
+
+				position = editor.editing.mapper.toViewPosition( ModelPosition.createAt( root, 3 ) );
+
+				expect( position.parent.name ).to.equal( 'ul' );
+				expect( position.offset ).to.equal( 2 );
+			} );
+
+			it( 'after listItem mapped to last LI => after UL', () => {
+				const position = editor.editing.mapper.toViewPosition( ModelPosition.createAt( root, 4 ) );
+
+				expect( position.parent ).to.equal( viewRoot );
+				expect( position.offset ).to.equal( 2 );
+				expect( position.nodeBefore.name ).to.equal( 'ul' );
+			} );
+		} );
+
+		describe( 'view to model', () => {
+			it( 'before UL => before listItem mapped to first LI of that UL', () => {
+				const position = editor.editing.mapper.toModelPosition( ViewPosition.createAt( viewRoot, 1 ) );
+
+				expect( position.path ).to.deep.equal( [ 1 ] );
+			} );
+
+			it( 'before LI => before listItem mapped to that LI', () => {
+				let position = editor.editing.mapper.toModelPosition( ViewPosition.createAt( viewRoot.getChild( 1 ), 0 ) );
+				expect( position.path ).to.deep.equal( [ 1 ] );
+
+				position = editor.editing.mapper.toModelPosition( ViewPosition.createAt( viewRoot.getChild( 1 ), 1 ) );
+				expect( position.path ).to.deep.equal( [ 2 ] );
+
+				position = editor.editing.mapper.toModelPosition( ViewPosition.createAt( viewRoot.getChild( 1 ), 2 ) );
+				expect( position.path ).to.deep.equal( [ 3 ] );
+			} );
+
+			it( 'after UL => after listItem mapped to last LI of that UL', () => {
+				const position = editor.editing.mapper.toModelPosition( ViewPosition.createAt( viewRoot, 2 ) );
+
+				expect( position.path ).to.deep.equal( [ 4 ] );
+			} );
+
+			it( 'after LI => after listItem mapped to that LI', () => {
+				const position = editor.editing.mapper.toModelPosition( ViewPosition.createAt( viewRoot.getChild( 1 ), 3 ) );
+
+				expect( position.path ).to.deep.equal( [ 4 ] );
+			} );
+
+			it( 'falls back to default algorithm for not described cases #1', () => {
+				// This is mostly for CC.
+				setModelData( doc, '<listItem indent="0" type="bulleted"></listItem>' );
+
+				const viewListItem = editor.editing.mapper.toViewElement( root.getChild( 0 ) );
+				const position = editor.editing.mapper.toModelPosition( ViewPosition.createAt( viewListItem, 0 ) );
+
+				expect( position.path ).to.deep.equal( [ 0, 0 ] );
+			} );
+
+			it( 'falls back to default algorithm for not described cases #2', () => {
+				// This is mostly for CC.
+				setModelData( doc, '<paragraph>foo</paragraph>' );
+
+				const position = editor.editing.mapper.toModelPosition( ViewPosition.createAt( viewRoot.getChild( 0 ), 'end' ) );
+
+				expect( position.path ).to.deep.equal( [ 0, 3 ] );
+			} );
+		} );
+	} );
+} );

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

@@ -0,0 +1,25 @@
+<head>
+	<link rel="stylesheet" href="%APPS_DIR%ckeditor/build/modules/amd/theme/ckeditor.css">
+</head>
+<div id="editor">
+	<p>This is a test for list feature.</p>
+	<p>Some more text for testing.</p>
+	<ul>
+		<li>Bullet list item 1</li>
+		<li>Bullet list item 2</li>
+		<li>Bullet list item 3</li>
+		<li>Bullet list item 4</li>
+		<li>Bullet list item 5</li>
+		<li>Bullet list item 6</li>
+		<li>Bullet list item 7</li>
+		<li>Bullet list item 8</li>
+	</ul>
+	<p>Paragraph.</p>
+	<p>Another testing paragraph.</p>
+	<ol>
+		<li>Numbered list item 1</li>
+	</ol>
+	<ul>
+		<li>Another bullet list</li>
+	</ul>
+</div>

+ 19 - 0
packages/ckeditor5-list/tests/manual/list.js

@@ -0,0 +1,19 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* globals console, window, document */
+
+import ClassicEditor from '/ckeditor5/editor-classic/classic.js';
+
+ClassicEditor.create( document.querySelector( '#editor' ), {
+	features: [ 'enter', 'typing', 'heading', 'paragraph', 'undo', 'list' ],
+	toolbar: [ 'headings', 'bulletedList', 'numberedList', 'undo', 'redo' ]
+} )
+.then( editor => {
+	window.editor = editor;
+} )
+.catch( err => {
+	console.error( err.stack );
+} );

+ 50 - 0
packages/ckeditor5-list/tests/manual/list.md

@@ -0,0 +1,50 @@
+@bender-ui: collapsed
+
+### Loading
+
+1. The data should be loaded with:
+  * two paragraphs,
+  * bulleted list with four items,
+  * two paragraphs,
+  * numbered list with an item,
+  * bullet list with an item.
+2. Toolbar should have two buttons: for bullet and for numbered list.
+
+### Testing
+
+After each step test undo (whole stack) -> redo (whole stack) -> undo (whole stack).
+
+1. Creating:
+  1. Convert first paragraph to list item
+  2. Create empty paragraph and convert to list item
+  3. Enter in the middle of item
+  4. Enter at the start of item
+  5. Enter at the end of item
+
+2. 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
+
+3. Changing type:
+  1. Change type from bulleted to numbered
+  2. Do it for first, second and last item
+  3. Do it for multiple items at once
+
+4. Merging:
+  1. Convert paragraph before list to same type of list
+  2. Convert paragraph after list to same type of list
+  3. Convert paragraph before list to different type of list
+  4. Convert paragraph after list to different type of list
+  5. Convert first paragraph to bulleted list, then convert second paragraph to bulleted list
+  6. Convert multiple items and paragraphs at once
+
+5. Selection deletion. Make selection between items and press delete button:
+  1. two items from the same list
+  2. all items in a list
+  3. paragraph before list and second item of list
+  4. paragraph after list and one-but-last item of list
+  5. two paragraphs that have list between them
+  6. two items from different lists of same type
+  7. two items from different lists of different type

+ 100 - 0
packages/ckeditor5-list/tests/utils.js

@@ -0,0 +1,100 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+import { getClosestListItem, getSelectedBlocks, getPositionBeforeBlock } from '/ckeditor5/list/utils.js';
+
+import Element from '/ckeditor5/engine/model/element.js';
+import Text from '/ckeditor5/engine/model/text.js';
+import Position from '/ckeditor5/engine/model/position.js';
+import Schema from '/ckeditor5/engine/model/schema.js';
+import Selection from '/ckeditor5/engine/model/selection.js';
+
+describe( 'getClosestListItem', () => {
+	const item = new Element( 'listItem', null, 'foobar' );
+	const root = new Element( '$root', null, [ item ] );
+
+	it( 'should return model listItem element if given position is in such element', () => {
+		expect( getClosestListItem( Position.createAt( item ) ) ).to.equal( item );
+	} );
+
+	it( 'should return null if position is not in listItem', () => {
+		expect( getClosestListItem( Position.createAt( root ) ) ).to.be.null;
+	} );
+} );
+
+describe( 'getSelectedBlocks', () => {
+	const paragraph1 = new Element( 'paragraph', null, '---' );
+	const item1 = new Element( 'listItem', null, '---' );
+	const item2 = new Element( 'listItem', null, '---' );
+	const item3 = new Element( 'listItem', null, '---' );
+	const paragraph2 = new Element( 'paragraph', null, '---' );
+
+	const root = new Element( '$root', null, [
+		paragraph1, item1, item2, item3, paragraph2
+	] );
+
+	const schema = new Schema();
+	schema.registerItem( 'paragraph', '$block' );
+	schema.registerItem( 'listItem', '$block' );
+
+	const selection = new Selection();
+
+	it( 'should return just one block if selection is over one block', () => {
+		selection.collapse( root, 2 );
+		selection.setFocus( root, 3 );
+
+		expect( getSelectedBlocks( selection, schema ) ).to.deep.equal( [ item2 ] );
+	} );
+
+	it( 'should return ancestor block if selection is collapsed and not before a block', () => {
+		selection.collapse( paragraph1, 2 );
+
+		expect( getSelectedBlocks( selection, schema ) ).to.deep.equal( [ paragraph1 ] );
+	} );
+
+	it( 'should return empty array for collapsed selection before a block, in a root', () => {
+		selection.collapse( root, 1 );
+
+		expect( getSelectedBlocks( selection, schema ) ).to.deep.equal( [] );
+	} );
+
+	it( 'should return all blocks "touched" by the selection if it spans over multiple blocks', () => {
+		selection.collapse( item1, 3 );
+		selection.setFocus( root, 4 );
+
+		expect( getSelectedBlocks( selection, schema ) ).to.deep.equal( [ item1, item2, item3 ] );
+	} );
+} );
+
+describe( 'getPositionBeforeBlock', () => {
+	const paragraph = new Element( 'paragraph', null, 'foo' );
+	const item = new Element( 'listItem', null, 'bar' );
+	const text = new Text( 'xyz' );
+
+	const root = new Element( '$root' );
+	root.appendChildren( [ paragraph, item, text ] );
+
+	const schema = new Schema();
+	schema.registerItem( 'paragraph', '$block' );
+	schema.registerItem( 'listItem', '$block' );
+
+	it( 'should return same position if position is already before a block', () => {
+		const position = Position.createBefore( paragraph );
+
+		expect( getPositionBeforeBlock( position, schema ).isEqual( position ) ).to.be.true;
+	} );
+
+	it( 'should return position before position parent if position is inside a block', () => {
+		const position = Position.createAt( item );
+
+		expect( getPositionBeforeBlock( position, schema ).isEqual( Position.createBefore( item ) ) ).to.be.true;
+	} );
+
+	it( 'should return null if position is not next to block and is not in a block other than root', () => {
+		const position = Position.createBefore( text );
+
+		expect( getPositionBeforeBlock( position, schema ) ).to.be.null;
+	} );
+} );

+ 47 - 0
packages/ckeditor5-list/tests/viewlistitemelement.js

@@ -0,0 +1,47 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+import ViewListItemElement from '/ckeditor5/list/viewlistitemelement.js';
+import ViewContainerElement from '/ckeditor5/engine/view/containerelement.js';
+import ViewText from '/ckeditor5/engine/view/text.js';
+
+describe( 'ViewListItemElement', () => {
+	it( 'should extend ViewContainerElement', () => {
+		let item = new ViewListItemElement();
+
+		expect( item ).to.be.instanceof( ViewContainerElement );
+	} );
+
+	it( 'should have li name', () => {
+		let item = new ViewListItemElement();
+
+		expect( item.name ).to.equal( 'li' );
+	} );
+
+	describe( 'getFillerOffset', () => {
+		it( 'should return 0 if item is empty', () => {
+			let item = new ViewListItemElement();
+
+			expect( item.getFillerOffset() ).to.equal( 0 );
+		} );
+
+		it( 'should return 0 if item has only lists as children', () => {
+			let item = new ViewListItemElement( null, [
+				new ViewContainerElement( 'ul', null, [
+					new ViewListItemElement( null, new ViewText( 'foo' ) ),
+					new ViewListItemElement( null, new ViewText( 'bar' ) )
+				] )
+			] );
+
+			expect( item.getFillerOffset() ).to.equal( 0 );
+		} );
+
+		it( 'should return null if item has non-list contents', () => {
+			let item = new ViewListItemElement( null, new ViewText( 'foo' ) );
+
+			expect( item.getFillerOffset() ).to.be.null;
+		} );
+	} );
+} );