浏览代码

Merge pull request #42 from ckeditor/t/41-rc

Feature: Added support for nested lists.

These changes close a wide range of issues. Closes #8. Closes #9. Closes #30. Closes #36. Closes #37. Closes #38. Closes #39. Closes #40. Closes #41. Closes #44. Closes #45.
Piotrek Koszuliński 8 年之前
父节点
当前提交
d0ab0343f8

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

@@ -5,16 +5,17 @@
   "keywords": [],
   "dependencies": {
     "@ckeditor/ckeditor5-engine": "^0.8.0",
+    "@ckeditor/ckeditor5-paragraph": "^0.6.1",
     "@ckeditor/ckeditor5-core": "^0.7.0",
     "@ckeditor/ckeditor5-ui": "^0.7.1",
     "@ckeditor/ckeditor5-utils": "^0.8.0"
   },
   "devDependencies": {
+    "@ckeditor/ckeditor5-basic-styles": "^0.7.1",
     "@ckeditor/ckeditor5-dev-lint": "^2.0.2",
     "@ckeditor/ckeditor5-editor-classic": "^0.7.1",
     "@ckeditor/ckeditor5-enter": "^0.8.0",
     "@ckeditor/ckeditor5-heading": "^0.8.0",
-    "@ckeditor/ckeditor5-paragraph": "^0.6.1",
     "@ckeditor/ckeditor5-typing": "^0.8.0",
     "@ckeditor/ckeditor5-undo": "^0.7.1",
     "gulp": "^3.9.0",

+ 582 - 119
packages/ckeditor5-list/src/converters.js

@@ -11,10 +11,12 @@ import ViewListItemElement from './viewlistitemelement';
 
 import ModelElement from '@ckeditor/ckeditor5-engine/src/model/element';
 import ModelPosition from '@ckeditor/ckeditor5-engine/src/model/position';
+import modelWriter from '@ckeditor/ckeditor5-engine/src/model/writer';
 
 import ViewContainerElement from '@ckeditor/ckeditor5-engine/src/view/containerelement';
 import ViewPosition from '@ckeditor/ckeditor5-engine/src/view/position';
 import ViewRange from '@ckeditor/ckeditor5-engine/src/view/range';
+import ViewTreeWalker from '@ckeditor/ckeditor5-engine/src/view/treewalker';
 import viewWriter from '@ckeditor/ckeditor5-engine/src/view/writer';
 
 /**
@@ -96,16 +98,32 @@ export function modelViewRemove( evt, data, consumable, conversionApi ) {
 		return;
 	}
 
-	const viewItem = conversionApi.mapper.toViewElement( data.item );
+	const viewPosition = conversionApi.mapper.toViewPosition( data.sourcePosition );
+	const viewItem = viewPosition.nodeAfter.is( 'li' ) ? viewPosition.nodeAfter : viewPosition.nodeAfter.getChild( 0 );
 
 	// 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.
+	// 2. Remove the UL that contains just the removed <li>.
 	const viewList = viewItem.parent;
-	viewWriter.remove( ViewRange.createOn( viewList ) );
+	const viewListPrev = viewList.previousSibling;
+	const removeRange = ViewRange.createOn( viewList );
+	viewWriter.remove( removeRange );
+
+	if ( viewListPrev && viewListPrev.nextSibling ) {
+		mergeViewLists( viewListPrev, viewListPrev.nextSibling );
+	}
+
+	// 3. Bring back nested list that was in the removed <li>.
+	hoistNestedLists( data.item.getAttribute( 'indent' ) + 1, data.sourcePosition, removeRange.start, viewItem, conversionApi.mapper );
+
+	// Unbind this element only if it was moved to graveyard.
+	// See #847.
+	if ( data.item.root.rootName == '$graveyard' ) {
+		conversionApi.mapper.unbindModelElement( data.item );
+	}
 }
 
 /**
@@ -118,7 +136,6 @@ export function modelViewRemove( evt, data, consumable, conversionApi ) {
  * @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;
 	}
@@ -133,14 +150,25 @@ export function modelViewChangeIndent( evt, data, consumable, conversionApi ) {
 	// 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 removeRange = ViewRange.createOn( viewList );
+	viewWriter.remove( removeRange );
 
-	viewWriter.remove( ViewRange.createOn( viewList ) );
+	// TODO: get rid of `removePosition` when conversion is done on `changesDone`.
+	let removePosition;
 
-	// If there is no `viewListPrev` it means that the first item was indented which is an error.
-	mergeViewLists( viewListPrev, viewListPrev.nextSibling );
+	if ( viewListPrev && viewListPrev.nextSibling ) {
+		removePosition = mergeViewLists( viewListPrev, viewListPrev.nextSibling );
+	}
 
-	// 3. Inject view list like it is newly inserted.
-	injectViewList( data.item, viewItem, conversionApi.mapper );
+	if ( !removePosition ) {
+		removePosition = removeRange.start;
+	}
+
+	// 3. Bring back nested list that was in the removed <li>.
+	hoistNestedLists( data.attributeOldValue + 1, data.range.start, removeRange.start, viewItem, conversionApi.mapper );
+
+	// 4. Inject view list like it is newly inserted.
+	injectViewList( data.item, viewItem, conversionApi.mapper, removePosition );
 }
 
 /**
@@ -172,17 +200,96 @@ export function modelViewSplitOnInsert( evt, data, consumable, conversionApi ) {
 	if ( data.item.name != 'listItem' ) {
 		let viewPosition = conversionApi.mapper.toViewPosition( data.range.start );
 
+		const lists = [];
+
 		// Break multiple ULs/OLs if there are.
+		//
+		// Imagine following list:
+		//
+		// 1 --------
+		//   1.1 --------
+		//     1.1.1 --------
+		//     1.1.2 --------
+		//     1.1.3 --------
+		//       1.1.3.1 --------
+		//   1.2 --------
+		//     1.2.1 --------
+		// 2 --------
+		//
+		// Insert paragraph after item 1.1.1:
+		//
+		// 1 --------
+		//   1.1 --------
+		//     1.1.1 --------
+		//
+		// Lorem ipsum.
+		//
+		//     1.1.2 --------
+		//     1.1.3 --------
+		//       1.1.3.1 --------
+		//   1.2 --------
+		//     1.2.1 --------
+		// 2 --------
+		//
+		// In this case 1.1.2 has to become beginning of a new list.
+		// We need to break list before 1.1.2 (obvious), then we need to break list also before 1.2.
+		// Then we need to move those broken pieces one after another and merge:
+		//
+		// 1 --------
+		//   1.1 --------
+		//     1.1.1 --------
+		//
+		// Lorem ipsum.
+		//
+		// 1.1.2 --------
+		//   1.1.3 --------
+		//     1.1.3.1 --------
+		// 1.2 --------
+		//   1.2.1 --------
+		// 2 --------
+		//
 		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 ) {
+			if ( viewPosition.parent.name != 'li' ) {
 				break;
 			}
 
-			/* istanbul ignore next */ // Part of code connected with indenting that is not yet complete.
-			viewPosition = ViewPosition.createBefore( viewPosition.parent );
+			// Remove lists that are after inserted element.
+			// They will be brought back later, below the inserted element.
+			const removeStart = viewPosition;
+			const removeEnd = ViewPosition.createAt( viewPosition.parent, 'end' );
+
+			// Don't remove if there is nothing to remove.
+			if ( !removeStart.isEqual( removeEnd ) ) {
+				const removed = viewWriter.remove( new ViewRange( removeStart, removeEnd ) );
+				lists.push( removed );
+			}
+
+			viewPosition = ViewPosition.createAfter( viewPosition.parent );
+		}
+
+		// Bring back removed lists.
+		if ( lists.length > 0 ) {
+			for ( let i = 0; i < lists.length; i++ ) {
+				const previousList = viewPosition.nodeBefore;
+				const insertedRange = viewWriter.insert( viewPosition, lists[ i ] );
+				viewPosition = insertedRange.end;
+
+				// Don't merge first list! We want a split in that place (this is why this converter is introduced).
+				if ( i > 0 ) {
+					let mergePos = mergeViewLists( previousList, previousList.nextSibling );
+
+					// If `mergePos` is in `previousList` it means that the lists got merged.
+					// In this case, we need to fix insert position.
+					if ( mergePos && mergePos.parent == previousList ) {
+						viewPosition.offset--;
+					}
+				}
+			}
+
+			// Merge last inserted list with element after it.
+			mergeViewLists( viewPosition.nodeBefore, viewPosition.nodeAfter );
 		}
 	}
 }
@@ -212,14 +319,16 @@ export function modelViewSplitOnInsert( evt, data, consumable, conversionApi ) {
  * @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 );
+	if ( !data.item.is( 'listItem' ) ) {
+		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 );
+	}
 }
 
 /**
@@ -250,7 +359,7 @@ export function viewModelConverter( evt, data, consumable, conversionApi ) {
 		// 3. Handle `<li>` children.
 		data.context.push( listItem );
 
-		// `listItem`s created recursievly should have bigger indent.
+		// `listItem`s created recursively should have bigger indent.
 		data.indent++;
 
 		// `listItem`s will be kept in flat structure.
@@ -270,15 +379,14 @@ export function viewModelConverter( evt, data, consumable, conversionApi ) {
 			}
 			// If it was not a list it was a "regular" list item content. Just append it to `listItem`.
 			else {
-				listItem.appendChildren( converted );
+				modelWriter.insert( ModelPosition.createAt( listItem, 'end' ), 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;
+		data.output = items;
 	}
 }
 
@@ -298,7 +406,7 @@ export function cleanList( evt, data, consumable ) {
 		const children = Array.from( data.input.getChildren() );
 
 		for ( let child of children ) {
-			if ( !child.name || child.name != 'li' ) {
+			if ( !child.is( 'li' ) ) {
 				child.remove();
 			}
 		}
@@ -306,6 +414,50 @@ export function cleanList( evt, data, consumable ) {
 }
 
 /**
+ * View to model converter for `<li>, that cleans white space formatting from the input view.
+ *
+ * @see module:engine/conversion/viewconversiondispatcher~ViewConversionDispatcher#event:element
+ * @param {module:utils/eventinfo~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 {module:engine/conversion/viewconsumable~ViewConsumable} consumable Values to consume.
+ */
+export function cleanListItem( evt, data, consumable ) {
+	if ( consumable.test( data.input, { name: true } ) ) {
+		if ( data.input.childCount === 0 ) {
+			return;
+		}
+
+		const children = [ ...data.input.getChildren() ];
+
+		let foundList = false;
+		let firstNode = true;
+
+		for ( let child of children ) {
+			if ( foundList && !child.is( 'ul' ) && !child.is( 'ol' ) ) {
+				child.remove();
+			}
+
+			if ( child.is( 'text' ) ) {
+				// If this is the first node and it's a text node, left-trim it.
+				if ( firstNode ) {
+					child.data = child.data.replace( /^\s+/, '' );
+				}
+
+				// If this is the last text node before <ul> or <ol>, right-trim it.
+				if ( !child.nextSibling || ( child.nextSibling.is( 'ul' ) || child.nextSibling.is( 'ol' ) ) ) {
+					child.data = child.data.replace( /\s+$/, '' );
+				}
+			} else if ( child.is( 'ul' ) || child.is( 'ol' ) ) {
+				// If this is a <ul> or <ol>, do not process it, just mark that we already visited list element.
+				foundList = true;
+			}
+
+			firstNode = false;
+		}
+	}
+}
+
+/**
  * Callback for model position to view position mapping for {@link module:engine/conversion/mapper~Mapper}. The callback fixes positions
  * between `listItem` elements, that would be incorrectly mapped because of how list items are represented in model
  * and view.
@@ -315,10 +467,26 @@ export function cleanList( evt, data, consumable ) {
  * @param {Object} data Object containing additional data and placeholder for mapping result.
  */
 export function modelToViewPosition( evt, data ) {
-	if ( data.viewPosition.parent.name == 'li' && data.modelPosition.parent.name != 'listItem' ) {
-		data.viewPosition = ViewPosition.createBefore( data.viewPosition.parent );
+	const modelItem = data.modelPosition.nodeBefore;
 
-		evt.stop();
+	if ( modelItem && modelItem.is( 'listItem' ) ) {
+		const viewItem = data.mapper.toViewElement( modelItem );
+		const topmostViewList = viewItem.getAncestors().find( ( element ) => element.is( 'ul' ) || element.is( 'ol' ) );
+		const walker = new ViewTreeWalker( {
+			startPosition: ViewPosition.createAt( viewItem, 0 )
+		} );
+
+		for ( let value of walker ) {
+			if ( value.type == 'elementStart' && value.item.is( 'li' ) ) {
+				data.viewPosition = value.previousPosition;
+
+				break;
+			} else if ( value.type == 'elementEnd' && value.item == topmostViewList ) {
+				data.viewPosition = value.nextPosition;
+
+				break;
+			}
+		}
 	}
 }
 
@@ -332,63 +500,202 @@ export function modelToViewPosition( evt, data ) {
  * @param {Object} data Object containing additional data and placeholder for mapping result.
  */
 export function viewToModelPosition( evt, data ) {
-	const viewPosition = data.viewPosition;
+	const viewPos = data.viewPosition;
+	const viewParent = viewPos.parent;
 	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 ) {
+	if ( viewParent.name == 'ul' || viewParent.name == 'ol' ) {
+		// Position is directly in <ul> or <ol>.
+		if ( !viewPos.isAtEnd ) {
+			// If position is not at the end, it must be before <li>.
+			// Get that <li>, map it to `listItem` and set model position before that `listItem`.
+			const modelNode = mapper.toModelElement( viewPos.nodeAfter );
+
 			data.modelPosition = ModelPosition.createBefore( modelNode );
+		} else {
+			// Position is at the end of <ul> or <ol>, so there is no <li> after it to be mapped.
+			// There is <li> before the position, but we cannot just map it to `listItem` and set model position after it,
+			// because that <li> may contain nested items.
+			// We will check "model length" of that <li>, in other words - how many `listItem`s are in that <li>.
+			const modelNode = mapper.toModelElement( viewPos.nodeBefore );
+			const modelLength = mapper.getModelLength( viewPos.nodeBefore );
+
+			// Then we get model position before mapped `listItem` and shift it accordingly.
+			data.modelPosition = ModelPosition.createBefore( modelNode ).getShiftedBy( modelLength );
 		}
-	} 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;
+
+		evt.stop();
+	} else if ( viewParent.name == 'li' && viewPos.nodeBefore && ( viewPos.nodeBefore.name == 'ul' || viewPos.nodeBefore.name == 'ol' ) ) {
+		// In most cases when view position is in <li> it is in text and this is a correct position.
+		// However, if position is after <ul> or <ol> we have to fix it -- because in model <ul>/<ol> are not in the `listItem`.
+		const modelNode = mapper.toModelElement( viewParent );
+
+		// Check all <ul>s and <ol>s that are in the <li> but before mapped position.
+		// Get model length of those elements and then add it to the offset of `listItem` mapped to the original <li>.
+		let modelLength = 1; // Starts from 1 because the original <li> has to be counted in too.
+		let viewList = viewPos.nodeBefore;
+
+		while ( viewList && ( viewList.is( 'ul' ) || viewList.is( 'ol' ) ) ) {
+			modelLength += mapper.getModelLength( viewList );
+
+			viewList = viewList.previousSibling;
 		}
 
-		// 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( modelLength );
+
+		evt.stop();
+	}
+}
 
-			data.modelPosition = ModelPosition.createBefore( modelNode ).getShiftedBy( offset );
+/**
+ * Post fixer that reacts to changes on document and fixed incorrect model states.
+ *
+ * Example:
+ *
+ *		<listItem type="bulleted" indent=0>Item 1</listItem>
+ *		<listItem type="bulleted" indent=1>Item 2</listItem>   <--- this is removed.
+ *		<listItem type="bulleted" indent=2>Item 3</listItem>
+ *
+ * Should become:
+ *
+ *		<listItem type="bulleted" indent=0>Item 1</listItem>
+ *		<listItem type="bulleted" indent=1>Item 3</listItem>   <--- note that indent got postfixed.
+ *
+ * @param {module:engine/model/document~Document} document Document to observe.
+ * @returns {Function} Callback to be attached to {@link module:engine/model/document~Document#event:change document change event}.
+ */
+export function modelChangePostFixer( document ) {
+	return ( evt, type, changes, batch ) => {
+		if ( type == 'remove' ) {
+			// Fix list items after the cut-out range.
+			// This fix is needed if items in model after cut-out range have now wrong indents compared to their previous siblings.
+			_fixItemsIndent( changes.sourcePosition, document, batch );
+			// This fix is needed if two different nested lists got merged, change types of list items "below".
+			_fixItemsType( changes.sourcePosition, false, document, batch );
+		} else if ( type == 'move' ) {
+			// Fix list items after the cut-out range.
+			// This fix is needed if items in model after cut-out range have now wrong indents compared to their previous siblings.
+			_fixItemsIndent( changes.sourcePosition, document, batch );
+			// This fix is needed if two different nested lists got merged, change types of list items "below".
+			_fixItemsType( changes.sourcePosition, false, document, batch );
+
+			// Fix items in moved range.
+			// This fix is needed if inserted items are too deeply intended.
+			_fixItemsIndent( changes.range.start, document, batch );
+			// This fix is needed if one or more first inserted items have different type.
+			_fixItemsType( changes.range.start, false, document, batch );
+
+			// Fix list items after inserted range.
+			// This fix is needed if items in model after inserted range have wrong indents.
+			_fixItemsIndent( changes.range.end, document, batch );
+			// This fix is needed if one or more last inserted items have different type.
+			_fixItemsType( changes.range.end, true, document, batch );
+		} else if ( type == 'rename' && changes.oldName == 'listItem' && changes.newName != 'listItem' ) {
+			const element = changes.element;
+
+			// Element name is changed from list to something else. Remove useless attributes.
+			document.enqueueChanges( () => {
+				batch.removeAttribute( element, 'indent' ).removeAttribute( element, 'type' );
+			} );
+
+			const changePos = ModelPosition.createAfter( changes.element );
+
+			// Fix list items after the renamed element.
+			// This fix is needed if there are items after renamed element, those items should start from indent = 0.
+			_fixItemsIndent( changePos, document, batch );
+		} else if ( type == 'insert' ) {
+			// Fix list items in inserted range.
+			// This fix is needed if inserted items are too deeply intended.
+			_fixItemsIndent( changes.range.start, document, batch );
+			// This fix is needed if one or more first inserted items have different type.
+			_fixItemsType( changes.range.start, false, document, batch );
+
+			// Fix list items after inserted range.
+			// This fix is needed if items in model after inserted range have wrong indents.
+			_fixItemsIndent( changes.range.end, document, batch );
+			// This fix is needed if one or more last inserted items have different type.
+			_fixItemsType( changes.range.end, true, document, batch );
+		}
+	};
+}
+
+// Helper function for post fixer callback. Performs fixing of model `listElement` items indent attribute. Checks the model at the
+// `changePosition`. Looks at the node before position where change occurred and uses that node as a reference for following list items.
+function _fixItemsIndent( changePosition, document, batch ) {
+	const prevItem = changePosition.nodeBefore;
+	let nextItem = changePosition.nodeAfter;
+
+	if ( nextItem && nextItem.name == 'listItem' ) {
+		// This is the maximum indent that following model list item may have.
+		const maxIndent = prevItem && prevItem.is( 'listItem' ) ? prevItem.getAttribute( 'indent' ) + 1 : 0;
+
+		// Check how much the next item needs to be outdented.
+		let outdentBy = nextItem.getAttribute( 'indent' ) - maxIndent;
+		const items = [];
+
+		while ( nextItem && nextItem.name == 'listItem' && nextItem.getAttribute( 'indent' ) > maxIndent ) {
+			if ( outdentBy > nextItem.getAttribute( 'indent' ) ) {
+				outdentBy = nextItem.getAttribute( 'indent' );
+			}
+
+			const newIndent = nextItem.getAttribute( 'indent' ) - outdentBy;
+
+			items.push( { item: nextItem, indent: newIndent } );
+
+			nextItem = nextItem.nextSibling;
+		}
+
+		if ( items.length > 0 ) {
+			document.enqueueChanges( () => {
+				// Since we are outdenting list items, it is safer to start from the last one (it will maintain correct model state).
+				for ( let item of items.reverse() ) {
+					batch.setAttribute( item.item, 'indent', item.indent );
+				}
+			} );
 		}
 	}
+}
 
-	// If we found a model position, stop the event.
-	if ( data.modelPosition !== null ) {
-		evt.stop();
+// Helper function for post fixer callback. Performs fixing of model nested `listElement` items type attribute.
+// Checks the model at the `changePosition`. Looks at nodes after/before that position and changes those items type
+// to the same as node before/after `changePosition`.
+function _fixItemsType( changePosition, fixPrevious, document, batch ) {
+	let item = changePosition[ fixPrevious ? 'nodeBefore' : 'nodeAfter' ];
+
+	if ( !item ) {
+		// May happen if last item got removed.
+		return;
+	}
+
+	const refItem = getSiblingListItem( item, { checkAllSiblings: true, getNext: fixPrevious, sameIndent: true } );
+
+	if ( !refItem ) {
+		// May happen if first list item is inserted.
+		return;
+	}
+
+	const refIndent = refItem.getAttribute( 'indent' );
+	const refType = refItem.getAttribute( 'type' );
+
+	if ( refIndent === 0 ) {
+		// Happens if changes are done on top level lists.
+		return;
 	}
+
+	document.enqueueChanges( () => {
+		while ( item && item.is( 'listItem' ) && item.getAttribute( 'indent' ) >= refIndent ) {
+			if ( item.getAttribute( 'type' ) != refType && item.getAttribute( 'indent' ) == refIndent ) {
+				batch.setAttribute( item, 'type', refType );
+			}
+
+			item = item[ fixPrevious ? 'previousSibling' : 'nextSibling' ];
+		}
+	} );
 }
 
-// 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).
+// Helper function that creates a `<ul><li></li></ul>` or (`<ol>`) structure out of given `modelItem` model `listItem` element.
+// Then, it binds created view list item (<li>) with model `listItem` element.
+// The function then returns created view list item (<li>).
 function generateLiInUl( modelItem, mapper ) {
 	const listType = modelItem.getAttribute( 'type' ) == 'numbered' ? 'ol' : 'ul';
 	const viewItem = new ViewListItemElement();
@@ -401,29 +708,47 @@ function generateLiInUl( modelItem, mapper ) {
 	return viewItem;
 }
 
-// Helper function that seeks for a sibling of given `modelItem` that is a `listItem` element and meets given criteria.
+// Helper function that seeks for a list item sibling of given model item (or position) which 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).
+// `options.smallerIndent` - whether sought sibling should have smaller indent (default = no).
+// `options.isMapped` - whether sought sibling must be mapped to view (default = no).
+// `options.mapper` - used to map model elements when `isMapped` option is set to true.
+// `options.indent` - used as reference item when first parameter is a position
 // Either `options.sameIndent` or `options.biggerIndent` should be set to `true`.
-function getSiblingListItem( modelItem, options ) {
+function getSiblingListItem( modelItemOrPosition, options ) {
 	const direction = options.getNext ? 'nextSibling' : 'previousSibling';
+	const posDirection = options.getNext ? 'nodeAfter' : 'nodeBefore';
 	const checkAllSiblings = !!options.checkAllSiblings;
 	const sameIndent = !!options.sameIndent;
 	const biggerIndent = !!options.biggerIndent;
+	const smallerIndent = !!options.smallerIndent;
+	const isMapped = !!options.isMapped;
 
-	const indent = modelItem.getAttribute( 'indent' );
-
-	let item = modelItem[ direction ];
+	const indent = modelItemOrPosition instanceof ModelElement ? modelItemOrPosition.getAttribute( 'indent' ) : options.indent;
+	let item = modelItemOrPosition instanceof ModelElement ? modelItemOrPosition[ direction ] : modelItemOrPosition[ posDirection ];
 
 	while ( item && item.name == 'listItem' ) {
 		let itemIndent = item.getAttribute( 'indent' );
 
-		if ( sameIndent && indent == itemIndent || biggerIndent && indent < itemIndent ) {
-			return item;
-		} else if ( !checkAllSiblings || indent > itemIndent ) {
+		if (
+			( sameIndent && indent == itemIndent ) ||
+			( biggerIndent && indent < itemIndent ) ||
+			( smallerIndent && indent > itemIndent )
+		) {
+			if ( !isMapped || options.mapper.toViewElement( item ) ) {
+				return item;
+			} else {
+				item = item[ direction ];
+
+				continue;
+			}
+		}
+
+		if ( !checkAllSiblings ) {
 			return null;
 		}
 
@@ -437,65 +762,203 @@ function getSiblingListItem( modelItem, options ) {
 // 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 ) );
+		return viewWriter.mergeContainers( ViewPosition.createAfter( firstList ) );
 	}
+
+	return null;
 }
 
 // Helper function that takes model list item element `modelItem`, corresponding view list item element `injectedItem`
 // that is not added to the view and is inside a view list element (`ul` or `ol`) and is that's list only child.
 // The list is inserted at correct position (element breaking may be needed) and then merged with it's siblings.
 // See comments below to better understand the algorithm.
-function injectViewList( modelItem, injectedItem, mapper ) {
+function injectViewList( modelItem, injectedItem, mapper, removePosition ) {
 	const injectedList = injectedItem.parent;
 
-	// 1. Break after previous `listItem` if it has same or bigger indent.
-	const prevModelItem = getSiblingListItem( modelItem, { sameIndent: true, biggerIndent: true } );
+	// Position where view list will be inserted.
+	let insertPosition;
 
-	if ( prevModelItem ) {
-		let viewItem = mapper.toViewElement( prevModelItem );
-		let viewPosition = ViewPosition.createAfter( viewItem );
-		viewWriter.breakContainer( viewPosition );
-	}
+	// 1. Find previous list item that has same or smaller indent. Basically we are looking for a first model item
+	// that is "parent" or "sibling" if injected model item.
+	// If there is no such list item, it means that injected list item is the first item in "its list".
+	let prevItem = getSiblingListItem( modelItem, { sameIndent: true, smallerIndent: true, checkAllSiblings: true } );
 
-	// 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 );
+	if ( prevItem && prevItem.getAttribute( 'indent' ) == modelItem.getAttribute( 'indent' ) ) {
+		// There is a list item with same indent - we found same-level sibling.
+		// Break the list after it. Inserted view item will be inserted in the broken space.
+		let viewItem = mapper.toViewElement( prevItem );
+		insertPosition = viewWriter.breakContainer( ViewPosition.createAfter( viewItem ) );
 	} 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' );
+		// There is no list item with same indent. Check previous model item.
+		prevItem = modelItem.previousSibling;
+
+		if ( prevItem && prevItem.name == 'listItem' ) {
+			// // If it is a list item, it has to have lower indent.
+			// // It means that inserted item should be added to it as its nested item.
+			// insertPosition = mapper.toViewPosition( ModelPosition.createAt( prevItem, 'end' ) );
+			// ^ ACTUALLY NOT BECAUSE FIXING DOES NOT WORK PROPERLY.
+			// TODO: fix this part of code when conversion from model to view is done on `changesDone` event or post/prefixing is better.
+			if ( prevItem.getAttribute( 'indent' ) < modelItem.getAttribute( 'indent' ) ) {
+				// Lower indent, correct model, previous item is a parent and this model item is its nested item.
+				insertPosition = mapper.toViewPosition( ModelPosition.createAt( prevItem, 'end' ) );
+			} else {
+				// Higher indent, incorrect model that is probably being fixed. Inject the view list where it was.
+				// TODO: get rid of `removePosition` when conversion is done on `changesDone`.
+				if ( removePosition.parent.is( 'ul' ) || removePosition.parent.is( 'ol' ) ) {
+					insertPosition = viewWriter.breakContainer( removePosition );
+				} else {
+					insertPosition = removePosition;
+				}
+			}
 		} else {
-			// This is the very first list item, use position mapping to get correct insertion position.
-			insertionPosition = mapper.toViewPosition( ModelPosition.createBefore( modelItem ) );
+			// Previous item is not a list item (or does not exist at all).
+			// Just map the position and insert the view item at mapped position.
+			insertPosition = mapper.toViewPosition( ModelPosition.createBefore( modelItem ) );
 		}
 	}
 
-	// 3. Append new UL/OL in position after breaking in step 2.
-	viewWriter.insert( insertionPosition, injectedList );
+	// Insert the view item.
+	viewWriter.insert( insertPosition, injectedList );
+
+	// 2. Handle possible children of injected model item.
+	// We have to check if next list item in model has bigger indent. If it has, it means that it and possibly
+	// some following list items should be nested in the injected view item.
+	// Look only after model elements that are already mapped to view. Some following model items might not be mapped
+	// if multiple items in model were inserted/moved at once.
+	const nextItem = getSiblingListItem(
+		modelItem,
+		{ biggerIndent: true, getNext: true, isMapped: true, mapper: mapper }
+	);
+
+	if ( nextItem ) {
+		let viewItem = mapper.toViewElement( nextItem );
+
+		// Break the list between found view item and its preceding `<li>`s.
+		viewWriter.breakContainer( ViewPosition.createBefore( viewItem ) );
 
-	// 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 );
+		// The broken ("lower") part will be moved as nested children of the inserted view item.
+		const sourceStart = ViewPosition.createBefore( viewItem.parent );
 
-	/* 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' );
+		const lastModelItem = _getModelLastItem( nextItem );
+		const lastViewItem = mapper.toViewElement( lastModelItem );
+		const sourceEnd = viewWriter.breakContainer( ViewPosition.createAfter( lastViewItem ) );
+		const sourceRange = new ViewRange( sourceStart, sourceEnd );
+
+		const targetPosition = ViewPosition.createAt( injectedItem, 'end' );
 		viewWriter.move( sourceRange, targetPosition );
 	}
 
-	// 5. Merge new UL/OL with above and below items (ULs/OLs or LIs).
+	// Merge inserted view list with its possible neighbour lists.
 	mergeViewLists( injectedList, injectedList.nextSibling );
 	mergeViewLists( injectedList.previousSibling, injectedList );
 }
+
+// Helper function that takes all children of given `viewRemovedItem` and moves them in a correct place, according
+// to other given parameters.
+function hoistNestedLists( nextIndent, modelRemoveStartPosition, viewRemoveStartPosition, viewRemovedItem, mapper ) {
+	// Find correct previous model list item element.
+	// The element has to have either same or smaller indent than given reference indent.
+	// This will be the model element which will get nested items (if it has smaller indent) or sibling items (if it has same indent).
+	// Keep in mind that such element might not be found, if removed item was the first item.
+	const prevModelItem = getSiblingListItem( modelRemoveStartPosition, {
+		sameIndent: true,
+		smallerIndent: true,
+		checkAllSiblings: true,
+		indent: nextIndent
+	} );
+
+	// Indent of found element or `null` if the element has not been found.
+	const prevIndent = prevModelItem ? prevModelItem.getAttribute( 'indent' ) : null;
+
+	let insertPosition;
+
+	if ( !prevModelItem ) {
+		// If element has not been found, simply insert lists at the position where the removed item was:
+		//
+		// Lorem ipsum.
+		// 1 --------           <--- this is removed, no previous list item, put nested items in place of removed item.
+		//   1.1 --------       <--- this is reference indent.
+		//     1.1.1 --------
+		//     1.1.2 --------
+		//   1.2 --------
+		//
+		// Becomes:
+		//
+		// Lorem ipsum.
+		// 1.1 --------
+		//   1.1.1 --------
+		//   1.1.2 --------
+		// 1.2 --------
+		insertPosition = viewRemoveStartPosition;
+	} else if ( prevIndent == nextIndent ) {
+		// If element has been found and has same indent as reference indent it means that nested items should
+		// become siblings of found element:
+		//
+		// 1 --------
+		//   1.1 --------
+		//   1.2 --------       <--- this is `prevModelItem`.
+		// 2 --------           <--- this is removed, previous list item has indent same as reference indent.
+		//   2.1 --------       <--- this is reference indent, this and 2.2 should become siblings of 1.2.
+		//   2.2 --------
+		//
+		// Becomes:
+		//
+		// 1 --------
+		//   1.1 --------
+		//   1.2 --------
+		//   2.1 --------
+		//   2.2 --------
+		const prevViewList = mapper.toViewElement( prevModelItem ).parent;
+		insertPosition = ViewPosition.createAfter( prevViewList );
+	} else {
+		// If element has been found and has smaller indent as reference indent it means that nested items
+		// should become nested items of found item:
+		//
+		// 1 --------           <--- this is `prevModelItem`.
+		//   1.1 --------       <--- this is removed, previous list item has indent smaller than reference indent.
+		//     1.1.1 --------   <--- this is reference indent, this and 1.1.1 should become nested items of 1.
+		//     1.1.2 --------
+		//   1.2 --------
+		//
+		// Becomes:
+		//
+		// 1 --------
+		//   1.1.1 --------
+		//   1.1.2 --------
+		//   1.2 --------
+		//
+		// Note: in this case 1.1.1 have indent 2 while 1 have indent 0. In model that should not be possible,
+		// because following item may have indent bigger only by one. But this is fixed by postfixer.
+		const modelPosition = ModelPosition.createAt( prevModelItem, 'end' );
+		insertPosition = mapper.toViewPosition( modelPosition );
+	}
+
+	// Handle multiple lists. This happens if list item has nested numbered and bulleted lists. Following lists
+	// are inserted after the first list (no need to recalculate insertion position for them).
+	for ( let child of [ ...viewRemovedItem.getChildren() ] ) {
+		if ( child.is( 'ul' ) || child.is( 'ol' ) ) {
+			insertPosition = viewWriter.move( ViewRange.createOn( child ), insertPosition ).end;
+
+			mergeViewLists( child, child.nextSibling );
+			mergeViewLists( child.previousSibling, child );
+		}
+	}
+}
+
+// Helper function to obtain the last model list item that is a forward sibling of given model list item that has
+// same or bigger indent. In other words, it looks for the last model item that is a nested item of the same item
+// that given item.
+function _getModelLastItem( item ) {
+	const indent = item.getAttribute( 'indent' );
+	let result = item;
+
+	while ( item.nextSibling && item.nextSibling.is( 'listItem' ) && item.nextSibling.getAttribute( 'indent' ) >= indent ) {
+		item = item.nextSibling;
+
+		if ( item.getAttribute( 'indent' ) == indent ) {
+			result = item;
+		}
+	}
+
+	return result;
+}

+ 28 - 13
packages/ckeditor5-list/src/indentcommand.js

@@ -8,7 +8,7 @@
  */
 
 import Command from '@ckeditor/ckeditor5-core/src/command/command';
-import { getClosestListItem } from './utils';
+import first from '@ckeditor/ckeditor5-utils/src/first';
 
 /**
  * The list indent command. It is used by the {@link module:list/list~List list feature}.
@@ -51,18 +51,16 @@ export default class IndentCommand extends Command {
 	_doExecute() {
 		const doc = this.editor.document;
 		const batch = doc.batch();
-		const element = getClosestListItem( doc.selection.getFirstPosition() );
+		let itemsToChange = Array.from( doc.selection.getSelectedBlocks() );
 
 		doc.enqueueChanges( () => {
-			const oldIndent = element.getAttribute( 'indent' );
-
-			let itemsToChange = [ element ];
+			const lastItem = itemsToChange[ itemsToChange.length - 1 ];
 
 			// Indenting a list item should also indent all the items that are already sub-items of indented item.
-			let next = element.nextSibling;
+			let next = lastItem.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 ) {
+			// Check all items after last indented item, as long as their indent is bigger than indent of that item.
+			while ( next && next.name == 'listItem' && next.getAttribute( 'indent' ) > lastItem.getAttribute( 'indent' ) ) {
 				itemsToChange.push( next );
 
 				next = next.nextSibling;
@@ -84,9 +82,26 @@ export default class IndentCommand extends Command {
 				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.
+					// No need to remove attributes, will be removed by post fixer.
+					batch.rename( item, 'paragraph' );
+				}
+				// If indent is >= 0, change the attribute value.
+				else {
+					// If indent is > 0 and the item was outdented, check whether list item's type should not be fixed.
+					if ( indent > 0 && this._indentBy < 0 ) {
+						// First, find previous sibling with same indent.
+						let prev = item.previousSibling;
+
+						while ( prev.getAttribute( 'indent' ) > indent ) {
+							prev = prev.previousSibling;
+						}
+
+						// Then check if that sibling has same type. If not, change type of this item.
+						if ( prev.getAttribute( 'type' ) != item.getAttribute( 'type' ) ) {
+							batch.setAttribute( item, 'type', prev.getAttribute( 'type' ) );
+						}
+					}
+
 					batch.setAttribute( item, 'indent', indent );
 				}
 			}
@@ -98,10 +113,10 @@ export default class IndentCommand extends Command {
 	 */
 	_checkEnabled() {
 		// Check whether any of position's ancestor is a list item.
-		const listItem = getClosestListItem( this.editor.document.selection.getFirstPosition() );
+		const listItem = first( this.editor.document.selection.getSelectedBlocks() );
 
 		// If selection is not in a list item, the command is disabled.
-		if ( !listItem ) {
+		if ( !listItem || !listItem.is( 'listItem' ) ) {
 			return false;
 		}
 

+ 8 - 7
packages/ckeditor5-list/src/list.js

@@ -7,14 +7,15 @@
  * @module list/list
  */
 
-import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
 import ListEngine from './listengine';
-import ButtonView from '@ckeditor/ckeditor5-ui/src/button/buttonview';
-import { parseKeystroke } from '@ckeditor/ckeditor5-utils/src/keyboard';
 
 import numberedListIcon from '../theme/icons/numberedlist.svg';
 import bulletedListIcon from '../theme/icons/bulletedlist.svg';
 
+import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
+import { parseKeystroke } from '@ckeditor/ckeditor5-utils/src/keyboard';
+import ButtonView from '@ckeditor/ckeditor5-ui/src/button/buttonview';
+
 /**
  * The lists feature. It introduces the `numberedList` and `bulletedList` buttons which
  * allows to convert paragraphs to/from list items and indent/outdent them.
@@ -56,13 +57,13 @@ export default class List extends Plugin {
 
 		// Add tab key support.
 		// When in list item, pressing tab should indent list item, if possible.
-		// Pressing shift + tab shout outdent list item.
+		// Pressing Shift+Tab shout outdent list item.
 		this.listenTo( this.editor.editing.view, 'keydown', ( evt, data ) => {
-			let commandName = null;
+			let commandName;
 
-			if ( data.keystroke == parseKeystroke( 'tab' ) ) {
+			if ( data.keystroke == parseKeystroke( 'Tab' ) ) {
 				commandName = 'indentList';
-			} else if ( data.keystroke == parseKeystroke( 'Shift+tab' ) ) {
+			} else if ( data.keystroke == parseKeystroke( 'Shift+Tab' ) ) {
 				commandName = 'outdentList';
 			}
 

+ 107 - 21
packages/ckeditor5-list/src/listcommand.js

@@ -9,7 +9,7 @@
 
 import Command from '@ckeditor/ckeditor5-core/src/command/command';
 import Position from '@ckeditor/ckeditor5-engine/src/model/position';
-import { getClosestListItem } from './utils';
+import first from '@ckeditor/ckeditor5-utils/src/first';
 
 /**
  * The list command. It is used by the {@link module:list/list~List list feature}.
@@ -38,7 +38,7 @@ export default class ListCommand extends Command {
 		 * Flag indicating whether the command is active, which means that selection starts in a list of the same type.
 		 *
 		 * @observable
-		 * @member {Boolean}
+		 * @member {Boolean} #value
 		 */
 		this.set( 'value', false );
 
@@ -56,10 +56,9 @@ export default class ListCommand extends Command {
 	 * 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 );
+		const listItem = first( this.editor.document.selection.getSelectedBlocks() );
+
 		this.value = listItem !== null && listItem.getAttribute( 'type' ) == this.type;
 	}
 
@@ -101,16 +100,16 @@ export default class ListCommand extends Command {
 				// 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.
+				// 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 has to become indent = 0, because it should not be 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       * --------
 				//
@@ -169,20 +168,60 @@ export default class ListCommand extends Command {
 				}
 			}
 
+			// If we are turning on, we might change some items that are already `listItem`s but with different type.
+			// Changing one nested list item to other type should also trigger changing all its siblings so the
+			// whole nested list is of the same type.
+			// Example (assume changing to numbered list):
+			// * ------				<-- do not fix, top level item
+			//   * ------			<-- fix, because latter list item of this item's list is changed
+			//      * ------		<-- do not fix, item is not affected (different list)
+			//   * ------			<-- fix, because latter list item of this item's list is changed
+			//      * ------		<-- fix, because latter list item of this item's list is changed
+			//      * ---[--		<-- already in selection
+			//   * ------			<-- already in selection
+			//   * ------			<-- already in selection
+			// * ------				<-- already in selection, but does not cause other list items to change because is top-level
+			//   * ---]--			<-- already in selection
+			//   * ------			<-- fix, because preceding list item of this item's list is changed
+			//      * ------		<-- do not fix, item is not affected (different list)
+			// * ------				<-- do not fix, top level item
+			if ( !turnOff ) {
+				// Find lowest indent among selected items. This will be indicator what is the indent of
+				// top-most list affected by the command.
+				let lowestIndent = Number.POSITIVE_INFINITY;
+
+				for ( let item of blocks ) {
+					if ( item.is( 'listItem' ) && item.getAttribute( 'indent' ) < lowestIndent ) {
+						lowestIndent = item.getAttribute( 'indent' );
+					}
+				}
+
+				// Do not execute the fix for top-level lists.
+				lowestIndent = lowestIndent === 0 ? 1 : lowestIndent;
+
+				// Fix types of list items that are "before" the selected blocks.
+				_fixType( blocks, true, lowestIndent );
+
+				// Fix types of list items that are "after" the selected blocks.
+				_fixType( blocks, false, lowestIndent );
+			}
+
 			// 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 ) {
+			// Do it in reverse as there might be multiple blocks (same as with changing indents).
+			for ( let element of blocks.reverse() ) {
 				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' );
+					// List item specific attributes are removed by post fixer.
+					batch.rename( element, 'paragraph' );
 				} 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.
+					// The order of operations 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.
+					// We are turning on and the element is a `listItem` but has different type - change it's type and
+					// type of it's all siblings that have same indent.
 					batch.setAttribute( element, 'type', this.type );
 				}
 			}
@@ -201,7 +240,7 @@ export default class ListCommand extends Command {
 		const selection = this.editor.document.selection;
 		const schema = this.editor.document.schema;
 
-		const firstBlock = selection.getSelectedBlocks().next().value;
+		const firstBlock = first( selection.getSelectedBlocks() );
 
 		if ( !firstBlock ) {
 			return false;
@@ -215,3 +254,50 @@ export default class ListCommand extends Command {
 		} );
 	}
 }
+
+// Helper function used when one or more list item have their type changed. Fixes type of other list items
+// that are affected by the change (are in same lists) but are not directly in selection. The function got extracted
+// not to duplicated code, as same fix has to be performed before and after selection.
+//
+// @param {Array.<module:engine/model/node~Node>} blocks Blocks that are in selection.
+// @param {Boolean} isBackward Specified whether fix will be applied for blocks before first selected block (`true`)
+// or blocks after last selected block (`false`).
+// @param {Number} lowestIndent Lowest indent among selected blocks.
+function _fixType( blocks, isBackward, lowestIndent ) {
+	// We need to check previous sibling of first changed item and next siblings of last changed item.
+	const startingItem = isBackward ? blocks[ 0 ] : blocks[ blocks.length - 1 ];
+
+	if ( startingItem.is( 'listItem' ) ) {
+		let item = startingItem[ isBackward ? 'previousSibling' : 'nextSibling' ];
+		// During processing items, keeps the lowest indent of already processed items.
+		// This saves us from changing too many items.
+		// Following example is for going forward as it is easier to read, however same applies to going backward.
+		// * ------
+		//   * ------
+		//     * --[---
+		//   * ------		<-- `lowestIndent` should be 1
+		//     * --]---		<-- `startingItem`, `currentIndent` = 2, `lowestIndent` == 1
+		//     * ------		<-- should be fixed, `indent` == 2 == `currentIndent`
+		//   * ------		<-- should be fixed, set `currentIndent` to 1, `indent` == 1 == `currentIndent`
+		//     * ------		<-- should not be fixed, item is in different list, `indent` = 2, `indent` != `currentIndent`
+		//   * ------		<-- should be fixed, `indent` == 1 == `currentIndent`
+		// * ------			<-- break loop (`indent` < `lowestIndent`)
+		let currentIndent = startingItem.getAttribute( 'indent' );
+
+		// Look back until a list item with indent lower than reference `lowestIndent`.
+		// That would be the parent of nested sublist which contains item having `lowestIndent`.
+		while ( item && item.is( 'listItem' ) && item.getAttribute( 'indent' ) >= lowestIndent ) {
+			if ( currentIndent > item.getAttribute( 'indent' ) ) {
+				currentIndent = item.getAttribute( 'indent' );
+			}
+
+			// Found an item that is in the same nested sublist.
+			if ( item.getAttribute( 'indent' ) == currentIndent ) {
+				// Just add the item to selected blocks like it was selected by the user.
+				blocks[ isBackward ? 'unshift' : 'push' ]( item );
+			}
+
+			item = item[ isBackward ? 'previousSibling' : 'nextSibling' ];
+		}
+	}
+}

+ 48 - 2
packages/ckeditor5-list/src/listengine.js

@@ -7,18 +7,22 @@
  * @module list/listengine
  */
 
-import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
 import ListCommand from './listcommand';
 import IndentCommand from './indentcommand';
 
+import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
+import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
+
 import {
 	cleanList,
+	cleanListItem,
 	modelViewInsertion,
 	modelViewChangeType,
 	modelViewMergeAfter,
 	modelViewRemove,
 	modelViewSplitOnInsert,
 	modelViewChangeIndent,
+	modelChangePostFixer,
 	viewModelConverter,
 	modelToViewPosition,
 	viewToModelPosition
@@ -34,10 +38,20 @@ export default class ListEngine extends Plugin {
 	/**
 	 * @inheritDoc
 	 */
+	static get requires() {
+		return [ Paragraph ];
+	}
+
+	/**
+	 * @inheritDoc
+	 */
 	init() {
 		const editor = this.editor;
 
 		// Schema.
+		// Note: in case `$block` will be ever allowed in `listItem`, keep in mind that this feature
+		// uses `Selection#getSelectedBlocks()` without any additional processing to obtain all selected list items.
+		// If there are blocks allowed inside list item, algorithms using `getSelectedBlocks()` will have to be modified.
 		const schema = editor.document.schema;
 		schema.registerItem( 'listItem', '$block' );
 		schema.allow( {
@@ -51,6 +65,23 @@ export default class ListEngine extends Plugin {
 		const data = editor.data;
 		const editing = editor.editing;
 
+		this.editor.document.on( 'change', modelChangePostFixer( this.editor.document ), { priority: 'high' } );
+
+		// Unbind all moved model elements before conversion happens. This is important for converters.
+		// TODO: fix this when changes are converted on `changesDone`.
+		this.editor.document.on( 'change', ( evt, type, changes ) => {
+			if ( type == 'move' ) {
+				for ( let item of changes.range.getItems() ) {
+					if ( item.is( 'listItem' ) ) {
+						editing.mapper.unbindModelElement( item );
+					}
+				}
+			}
+		}, { priority: 'high' } );
+
+		editing.mapper.registerViewToModelLength( 'li', getViewListItemLength );
+		data.mapper.registerViewToModelLength( 'li', getViewListItemLength );
+
 		editing.mapper.on( 'modelToViewPosition', modelToViewPosition );
 		editing.mapper.on( 'viewToModelPosition', viewToModelPosition );
 		data.mapper.on( 'modelToViewPosition', modelToViewPosition );
@@ -73,9 +104,10 @@ export default class ListEngine extends Plugin {
 		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' } );
+		data.viewToModel.on( 'element:li', cleanListItem, { priority: 'high' } );
+		data.viewToModel.on( 'element:li', viewModelConverter );
 
 		// Register commands for numbered and bulleted list.
 		editor.commands.set( 'numberedList', new ListCommand( editor, 'numbered' ) );
@@ -86,3 +118,17 @@ export default class ListEngine extends Plugin {
 		editor.commands.set( 'outdentList', new IndentCommand( editor, 'backward' ) );
 	}
 }
+
+function getViewListItemLength( element ) {
+	let length = 1;
+
+	for ( let child of element.getChildren() ) {
+		if ( child.name == 'ul' || child.name == 'ol' ) {
+			for ( let item of child.getChildren() ) {
+				length += getViewListItemLength( item );
+			}
+		}
+	}
+
+	return length;
+}

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

@@ -1,22 +0,0 @@
-/**
- * @license Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved.
- * For licensing, see LICENSE.md.
- */
-
-/**
- * Utilities used in modules from {@link module:list/list list} package.
- *
- * @module list/utils
- */
-
-/**
- * For given {@link module:engine/model/position~Position position}, returns the closest ancestor of that position which is a
- * `listItem` element.
- *
- * @param {module:engine/model/position~Position} position Position which ancestor should be check looking for `listItem` element.
- * @returns {module:engine/model/element~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;
-}

+ 85 - 15
packages/ckeditor5-list/tests/indentcommand.js

@@ -21,9 +21,11 @@ describe( 'IndentCommand', () => {
 		root = doc.createRoot();
 
 		doc.schema.registerItem( 'listItem', '$block' );
+		doc.schema.registerItem( 'paragraph', '$block' );
 
 		doc.schema.allow( { name: '$block', inside: '$root' } );
 		doc.schema.allow( { name: 'listItem', attributes: [ 'type', 'indent' ], inside: '$root' } );
+		doc.schema.allow( { name: 'paragraph', inside: '$root' } );
 
 		setData(
 			doc,
@@ -66,6 +68,15 @@ describe( 'IndentCommand', () => {
 
 				expect( command.isEnabled ).to.be.false;
 			} );
+
+			// Edge case but may happen that some other blocks will also use the indent attribute
+			// and before we fixed it the command was enabled in such a case.
+			it( 'should be false if selection starts in a paragraph with indent attribute', () => {
+				doc.schema.allow( { name: 'paragraph', attributes: [ 'indent' ], inside: '$root' } );
+				setData( doc, '<listItem indent="0">a</listItem><paragraph indent="0">b[]</paragraph>' );
+
+				expect( command.isEnabled ).to.be.false;
+			} );
 		} );
 
 		describe( '_doExecute', () => {
@@ -85,27 +96,27 @@ describe( 'IndentCommand', () => {
 				);
 			} );
 
-			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 ] )
-				) ] );
+			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="0" type="bulleted">b</listItem>' +
-					'<listItem indent="1" type="bulleted">c</listItem>' +
-					'<listItem indent="2" type="bulleted">d</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="1" type="bulleted">f</listItem>' +
+					'<listItem indent="2" 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 ) );
+			it( 'should increment indent of all selected item when multiple items are selected', () => {
+				doc.selection.setRanges( [ new Range(
+					new Position( root.getChild( 1 ), [ 0 ] ),
+					new Position( root.getChild( 3 ), [ 0 ] )
+				) ] );
 
 				command._doExecute();
 
@@ -114,8 +125,8 @@ describe( 'IndentCommand', () => {
 					'<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="2" type="bulleted">e</listItem>' +
+					'<listItem indent="1" type="bulleted">f</listItem>' +
 					'<listItem indent="0" type="bulleted">g</listItem>'
 				);
 			} );
@@ -178,7 +189,7 @@ describe( 'IndentCommand', () => {
 				command._doExecute();
 
 				expect( getData( doc, { withoutSelection: true } ) ).to.equal(
-					'<paragraph>a</paragraph>' +
+					'<paragraph indent="0" type="bulleted">a</paragraph>' +
 					'<listItem indent="0" type="bulleted">b</listItem>' +
 					'<listItem indent="1" type="bulleted">c</listItem>' +
 					'<listItem indent="2" type="bulleted">d</listItem>' +
@@ -195,7 +206,7 @@ describe( 'IndentCommand', () => {
 
 				expect( getData( doc, { withoutSelection: true } ) ).to.equal(
 					'<listItem indent="0" type="bulleted">a</listItem>' +
-					'<paragraph>b</paragraph>' +
+					'<paragraph indent="0" type="bulleted">b</paragraph>' +
 					'<listItem indent="0" type="bulleted">c</listItem>' +
 					'<listItem indent="1" type="bulleted">d</listItem>' +
 					'<listItem indent="1" type="bulleted">e</listItem>' +
@@ -203,6 +214,65 @@ describe( 'IndentCommand', () => {
 					'<listItem indent="0" type="bulleted">g</listItem>'
 				);
 			} );
+
+			it( 'should outdent all selected item when multiple items are selected', () => {
+				doc.selection.setRanges( [ new Range(
+					new Position( root.getChild( 1 ), [ 0 ] ),
+					new Position( root.getChild( 3 ), [ 0 ] )
+				) ] );
+
+				command._doExecute();
+
+				expect( getData( doc, { withoutSelection: true } ) ).to.equal(
+					'<listItem indent="0" type="bulleted">a</listItem>' +
+					'<paragraph indent="0" type="bulleted">b</paragraph>' +
+					'<listItem indent="0" type="bulleted">c</listItem>' +
+					'<listItem indent="1" 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 fix list type if item is outdented', () => {
+				setData(
+					doc,
+					'<listItem indent="0" type="bulleted">a</listItem>' +
+					'<listItem indent="1" type="bulleted">b</listItem>' +
+					'<listItem indent="2" type="numbered">c</listItem>'
+				);
+
+				doc.selection.setRanges( [ new Range(
+					new Position( root.getChild( 2 ), [ 0 ] )
+				) ] );
+
+				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="1" type="bulleted">c</listItem>'
+				);
+			} );
+
+			it( 'should not fix list type if item is outdented to top level', () => {
+				setData(
+					doc,
+					'<listItem indent="0" type="bulleted">a</listItem>' +
+					'<listItem indent="1" type="numbered">b</listItem>'
+				);
+
+				doc.selection.setRanges( [ new Range(
+					new Position( root.getChild( 1 ), [ 0 ] )
+				) ] );
+
+				command._doExecute();
+
+				expect( getData( doc, { withoutSelection: true } ) ).to.equal(
+					'<listItem indent="0" type="bulleted">a</listItem>' +
+					'<listItem indent="0" type="numbered">b</listItem>'
+				);
+			} );
 		} );
 	} );
 } );

+ 3 - 15
packages/ckeditor5-list/tests/list.js

@@ -129,7 +129,7 @@ describe( 'List', () => {
 
 		beforeEach( () => {
 			domEvtDataStub = {
-				keystroke: getCode( 'tab' ),
+				keystroke: getCode( 'Tab' ),
 				preventDefault() {}
 			};
 
@@ -151,20 +151,8 @@ describe( 'List', () => {
 			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' );
+		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.

+ 63 - 9
packages/ckeditor5-list/tests/listcommand.js

@@ -140,12 +140,13 @@ describe( 'ListCommand', () => {
 					expect( getData( doc ) ).to.equal( '<listItem indent="0" type="bulleted">fo[]o</listItem>' );
 				} );
 
-				it( 'should rename closest listItem to paragraph and remove attributes', () => {
+				it( 'should rename closest listItem to paragraph', () => {
 					setData( doc, '<listItem indent="0" type="bulleted">fo[]o</listItem>' );
 
 					command._doExecute();
 
-					expect( getData( doc ) ).to.equal( '<paragraph>fo[]o</paragraph>' );
+					// Attributes will be removed by post fixer.
+					expect( getData( doc ) ).to.equal( '<paragraph indent="0" type="bulleted">fo[]o</paragraph>' );
 				} );
 
 				it( 'should change closest listItem\' type', () => {
@@ -216,7 +217,7 @@ describe( 'ListCommand', () => {
 					const expectedData =
 						'<listItem indent="0" type="bulleted">---</listItem>' +
 						'<listItem indent="1" type="bulleted">---</listItem>' +
-						'<paragraph>[]---</paragraph>' +
+						'<paragraph indent="2" type="bulleted">[]---</paragraph>' + // Attributes will be removed by post fixer.
 						'<listItem indent="0" type="bulleted">---</listItem>' +
 						'<listItem indent="1" type="bulleted">---</listItem>' +
 						'<listItem indent="0" type="bulleted">---</listItem>' +
@@ -271,7 +272,7 @@ describe( 'ListCommand', () => {
 					expect( getData( doc ) ).to.equal( expectedData );
 				} );
 
-				it( 'should rename closest listItem to paragraph and remove attributes', () => {
+				it( 'should rename closest listItem to paragraph', () => {
 					// From second bullet list item to first numbered list item.
 					// Command value=true, we are turning off list items.
 					doc.selection.setRanges( [ new Range(
@@ -284,10 +285,10 @@ describe( 'ListCommand', () => {
 
 					const expectedData =
 						'<listItem indent="0" type="bulleted">---</listItem>' +
-						'<paragraph>[---</paragraph>' +
+						'<paragraph indent="0" type="bulleted">[---</paragraph>' + // Attributes will be removed by post fixer.
 						'<paragraph>---</paragraph>' +
 						'<paragraph>---</paragraph>' +
-						'<paragraph>]---</paragraph>' +
+						'<paragraph indent="0" type="numbered">]---</paragraph>' + // Attributes will be removed by post fixer.
 						'<listItem indent="0" type="numbered">---</listItem>' +
 						'<listItem indent="1" type="bulleted">---</listItem>' +
 						'<listItem indent="2" type="bulleted">---</listItem>';
@@ -330,16 +331,69 @@ describe( 'ListCommand', () => {
 
 					const expectedData =
 						'<listItem indent="0" type="bulleted">---</listItem>' +
-						'<paragraph>[---</paragraph>' +
+						'<paragraph indent="0" type="bulleted">[---</paragraph>' + // Attributes will be removed by post fixer.
 						'<paragraph>---</paragraph>' +
 						'<paragraph>---</paragraph>' +
-						'<paragraph>---</paragraph>' +
-						'<paragraph>]---</paragraph>' +
+						'<paragraph indent="0" type="numbered">---</paragraph>' + // Attributes will be removed by post fixer.
+						'<paragraph indent="0" type="numbered">]---</paragraph>' + // Attributes will be removed by post fixer.
 						'<listItem indent="0" type="bulleted">---</listItem>' +
 						'<listItem indent="1" type="bulleted">---</listItem>';
 
 					expect( getData( doc ) ).to.equal( expectedData );
 				} );
+
+				// Example from docs.
+				it( 'should change type of all items in nested list if one of items changed', () => {
+					setData(
+						doc,
+						'<listItem indent="0" type="numbered">---</listItem>' +
+						'<listItem indent="1" type="numbered">---</listItem>' +
+						'<listItem indent="2" type="numbered">---</listItem>' +
+						'<listItem indent="1" type="numbered">---</listItem>' +
+						'<listItem indent="2" type="numbered">---</listItem>' +
+						'<listItem indent="2" type="numbered">-[-</listItem>' +
+						'<listItem indent="1" type="numbered">---</listItem>' +
+						'<listItem indent="1" type="numbered">---</listItem>' +
+						'<listItem indent="0" type="numbered">---</listItem>' +
+						'<listItem indent="1" type="numbered">-]-</listItem>' +
+						'<listItem indent="1" type="numbered">---</listItem>' +
+						'<listItem indent="2" type="numbered">---</listItem>' +
+						'<listItem indent="0" type="numbered">---</listItem>'
+					);
+
+					// * ------				<-- do not fix, top level item
+					//   * ------			<-- fix, because latter list item of this item's list is changed
+					//      * ------		<-- do not fix, item is not affected (different list)
+					//   * ------			<-- fix, because latter list item of this item's list is changed
+					//      * ------		<-- fix, because latter list item of this item's list is changed
+					//      * ---[--		<-- already in selection
+					//   * ------			<-- already in selection
+					//   * ------			<-- already in selection
+					// * ------				<-- already in selection, but does not cause other list items to change because is top-level
+					//   * ---]--			<-- already in selection
+					//   * ------			<-- fix, because preceding list item of this item's list is changed
+					//      * ------		<-- do not fix, item is not affected (different list)
+					// * ------				<-- do not fix, top level item
+
+					command._doExecute();
+
+					const expectedData =
+						'<listItem indent="0" type="numbered">---</listItem>' +
+						'<listItem indent="1" type="bulleted">---</listItem>' +
+						'<listItem indent="2" type="numbered">---</listItem>' +
+						'<listItem indent="1" type="bulleted">---</listItem>' +
+						'<listItem indent="2" type="bulleted">---</listItem>' +
+						'<listItem indent="2" type="bulleted">-[-</listItem>' +
+						'<listItem indent="1" type="bulleted">---</listItem>' +
+						'<listItem indent="1" type="bulleted">---</listItem>' +
+						'<listItem indent="0" type="bulleted">---</listItem>' +
+						'<listItem indent="1" type="bulleted">-]-</listItem>' +
+						'<listItem indent="1" type="bulleted">---</listItem>' +
+						'<listItem indent="2" type="numbered">---</listItem>' +
+						'<listItem indent="0" type="numbered">---</listItem>';
+
+					expect( getData( doc ) ).to.equal( expectedData );
+				} );
 			} );
 		} );
 	} );

文件差异内容过多而无法显示
+ 2962 - 381
packages/ckeditor5-list/tests/listengine.js


+ 2 - 1
packages/ckeditor5-list/tests/manual/list.js

@@ -11,10 +11,11 @@ import Typing from '@ckeditor/ckeditor5-typing/src/typing';
 import Heading from '@ckeditor/ckeditor5-heading/src/heading';
 import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
 import Undo from '@ckeditor/ckeditor5-undo/src/undo';
+import Clipboard from '@ckeditor/ckeditor5-clipboard/src/clipboard';
 import List from '../../src/list';
 
 ClassicEditor.create( document.querySelector( '#editor' ), {
-	plugins: [ Enter, Typing, Heading, Paragraph, Undo, List ],
+	plugins: [ Enter, Typing, Heading, Paragraph, Undo, List, Clipboard ],
 	toolbar: [ 'headings', 'bulletedList', 'numberedList', 'undo', 'redo' ]
 } )
 .then( editor => {

+ 42 - 37
packages/ckeditor5-list/tests/manual/list.md

@@ -2,47 +2,52 @@
 
 1. The data should be loaded with:
   * two paragraphs,
-  * bulleted list with four items,
+  * bulleted list with eight items,
   * two paragraphs,
-  * numbered list with an item,
-  * bullet list with an item.
+  * numbered list with one item,
+  * bullet list with one 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
+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
+
+Removing:
+
+1. Delete all contents from list item and then the list item
+2. Press enter in empty list item
+3. Click on highlighted button ("turn off" list feature)
+4. Do it for first, second and last list item
+
+Changing type:
+
+1. Change type from bulleted to numbered
+2. Do it for first, second and last item
+3. Do it for multiple items at once
+
+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
+
+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

+ 34 - 0
packages/ckeditor5-list/tests/manual/nestedlists.html

@@ -0,0 +1,34 @@
+<div id="editor">
+	<ul>
+		<li>
+			Bullet list item 1
+			<ul>
+				<li>
+					Bullet list item 1.1
+					<ul>
+						<li>Bullet list item 1.1.1</li>
+					</ul>
+				</li>
+				<li>
+					Bullet list item 1.2
+					<ul>
+						<li>Bullet list item 1.2.1</li>
+						<li>Bullet list item 1.2.2</li>
+						<li>Bullet list item 1.2.3</li>
+						<li>Bullet list item 1.2.4</li>
+					</ul>
+				</li>
+			</ul>
+		</li>
+		<li>
+			<ul>
+				<li>
+					<ol>
+						<li>Numbered list item 2.1.1</li>
+						<li>Numbered list item 2.1.2</li>
+					</ol>
+				</li>
+			</ul>
+		</li>
+	</ul>
+</div>

+ 29 - 0
packages/ckeditor5-list/tests/manual/nestedlists.js

@@ -0,0 +1,29 @@
+/**
+ * @license Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* globals console, window, document */
+
+import ClassicEditor from '@ckeditor/ckeditor5-editor-classic/src/classic';
+import Enter from '@ckeditor/ckeditor5-enter/src/enter';
+import Typing from '@ckeditor/ckeditor5-typing/src/typing';
+import Heading from '@ckeditor/ckeditor5-heading/src/heading';
+import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
+import Undo from '@ckeditor/ckeditor5-undo/src/undo';
+import Clipboard from '@ckeditor/ckeditor5-clipboard/src/clipboard';
+import Link from '@ckeditor/ckeditor5-link/src/link';
+import List from '../../src/list';
+
+ClassicEditor.create( document.querySelector( '#editor' ), {
+	plugins: [ Enter, Typing, Heading, Paragraph, Undo, List, Clipboard, Link ],
+	toolbar: [ 'headings', 'bulletedList', 'numberedList', 'undo', 'redo' ]
+} )
+.then( editor => {
+	window.editor = editor;
+	window.modelRoot = editor.document.getRoot();
+	window.viewRoot = editor.editing.view.getRoot();
+} )
+.catch( err => {
+	console.error( err.stack );
+} );

+ 59 - 0
packages/ckeditor5-list/tests/manual/nestedlists.md

@@ -0,0 +1,59 @@
+### Loading
+
+The loaded data should look like this:
+
+<ul>
+	<li>
+		Bullet list item 1
+		<ul>
+			<li>
+				Bullet list item 1.1
+				<ul>
+					<li>Bullet list item 1.1.1</li>
+				</ul>
+			</li>
+			<li>
+				Bullet list item 1.2
+				<ul>
+					<li>Bullet list item 1.2.1</li>
+					<li>Bullet list item 1.2.2</li>
+					<li>Bullet list item 1.2.3</li>
+					<li>Bullet list item 1.2.4</li>
+				</ul>
+			</li>
+		</ul>
+	</li>
+	<li>
+		&nbsp;
+		<ul>
+			<li>
+				&nbsp;
+				<ol>
+					<li>Numbered list item 2.1.1</li>
+					<li>Numbered list item 2.1.2</li>
+				</ol>
+			</li>
+		</ul>
+	</li>
+</ul>
+
+### Testing
+
+Check if:
+
+1. You can write and delete in indented list items and remove indented list items.
+2. Tab key indents list item (other than first item on the list).
+3. Shift+tab key outdents list item.
+4. Enter key creates list item with same indent.
+5. Enter key in empty list item outdents it.
+6. Indenting and outdenting list item also indents/outdents following items.
+7. Outdenting not-indented item converts it to paragraph.
+8. Enter in not-indented item converts it to paragraph.
+9. Lists are correctly merged after outdenting.
+10. Lists are correctly merged when removing list item from between lists (for example 1.2, 1.1, 2.1 (empty)).
+11. Lists are correctly merged when deleting multi-level selection.
+12. You can put selection in empty list item, write something there, remove it.
+13. Changing list type works correctly with nested lists.
+14. Turning off list item works correctly.
+15. Changing list item to heading works correctly (undo may not work correctly for this one).
+16. Undo works correctly.

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

@@ -1,22 +0,0 @@
-/**
- * @license Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved.
- * For licensing, see LICENSE.md.
- */
-
-import { getClosestListItem } from '../src/utils';
-
-import Element from '@ckeditor/ckeditor5-engine/src/model/element';
-import Position from '@ckeditor/ckeditor5-engine/src/model/position';
-
-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;
-	} );
-} );