8
0
فهرست منبع

Added: introduced support for nested lists.

Szymon Cofalik 8 سال پیش
والد
کامیت
4cef91c2b2
3فایلهای تغییر یافته به همراه3159 افزوده شده و 427 حذف شده
  1. 518 113
      packages/ckeditor5-list/src/converters.js
  2. 23 1
      packages/ckeditor5-list/src/listengine.js
  3. 2618 313
      packages/ckeditor5-list/tests/listengine.js

+ 518 - 113
packages/ckeditor5-list/src/converters.js

@@ -105,7 +105,18 @@ export function modelViewRemove( evt, data, consumable, conversionApi ) {
 
 	// 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 );
+
+	conversionApi.mapper.unbindModelElement( data.item );
 }
 
 /**
@@ -118,7 +129,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 +143,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 );
+
+	// TODO: get rid of `removePosition` when conversion is done on `changesDone`.
+	let removePosition;
+
+	if ( viewListPrev && viewListPrev.nextSibling ) {
+		removePosition = mergeViewLists( viewListPrev, viewListPrev.nextSibling );
+	}
 
-	viewWriter.remove( ViewRange.createOn( viewList ) );
+	if ( !removePosition ) {
+		removePosition = removeRange.start;
+	}
 
-	// If there is no `viewListPrev` it means that the first item was indented which is an error.
-	mergeViewLists( viewListPrev, viewListPrev.nextSibling );
+	// 3. Bring back nested list that was in the removed LI.
+	hoistNestedLists( data.attributeOldValue + 1, data.range.start, removeRange.start, viewItem, conversionApi.mapper );
 
-	// 3. Inject view list like it is newly inserted.
-	injectViewList( data.item, viewItem, conversionApi.mapper );
+	// 4. Inject view list like it is newly inserted.
+	injectViewList( data.item, viewItem, conversionApi.mapper, removePosition );
 }
 
 /**
@@ -172,17 +193,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 +312,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 );
+	}
 }
 
 /**
@@ -277,8 +379,7 @@ export function viewModelConverter( evt, data, consumable, conversionApi ) {
 		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,9 +399,53 @@ 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();
+			}
+		}
+	}
+}
+
+/**
+ * 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;
 		}
 	}
 }
@@ -315,10 +460,37 @@ 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 );
+	let posParent = data.viewPosition.parent;
 
-		evt.stop();
+	// When view position is wrong it usually ends up in text node. We need to get more meaningful parent.
+	if ( posParent.is( 'text' ) ) {
+		posParent = posParent.parent;
+	}
+
+	// If the position ended up somewhere in LI, but in model it is not in `listItem`, this means it got incorrectly mapped.
+	// In model the position is probably between two `listItem` elements and the offset reflects between which items it is.
+	if ( posParent.name == 'li' && data.modelPosition.parent.name != 'listItem' ) {
+		const viewRange = ViewRange.createIn( posParent );
+
+		// Because of how default model to view mapping works, the view position offset is telling us which nested
+		// LI of this LI we have to find.
+		let offset = data.viewPosition.offset;
+
+		// Search this LI contents for all LIs. Treat each LI like it is next offset in model.
+		for ( let item of viewRange.getItems() ) {
+			if ( item.is( 'li' ) ) {
+				offset--;
+
+				// We found enough LIs, this is the correct one.
+				if ( offset === 0 ) {
+					// Create position before correct LI.
+					data.viewPosition = ViewPosition.createBefore( item );
+					evt.stop();
+
+					break;
+				}
+			}
+		}
 	}
 }
 
@@ -332,61 +504,151 @@ 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;
-		}
 
-		// 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 );
+		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 );
 
-			data.modelPosition = ModelPosition.createBefore( modelNode ).getShiftedBy( offset );
+			viewList = viewList.previousSibling;
 		}
-	}
 
-	// If we found a model position, stop the event.
-	if ( data.modelPosition !== null ) {
+		data.modelPosition = ModelPosition.createBefore( modelNode ).getShiftedBy( modelLength );
+
 		evt.stop();
 	}
 }
 
-// Helper function that creates a `<ul><li></li></ul>` structure out of given `modelItem` model `listItem` element.
+/**
+ * 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.
+			_fixItems( changes.sourcePosition, document, batch );
+		} else if ( type == 'move' ) {
+			// Fix items in moved range.
+			// This fix is needed if inserted items are too deeply intended.
+			_fixItems( changes.range.start, document, batch );
+
+			// Fix list items after inserted range.
+			// This fix is needed if items in model after inserted range have wrong indents.
+			_fixItems( changes.range.end, document, batch );
+
+			// 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.
+			_fixItems( changes.sourcePosition, 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.
+			_fixItems( changePos, document, batch );
+		} else if ( type == 'insert' ) {
+			// Fix list items in inserted range.
+			// This fix is needed if inserted items are too deeply intended.
+			_fixItems( changes.range.start, document, batch );
+
+			// Fix list items after inserted range.
+			// This fix is needed if items in model after inserted range have wrong indents.
+			_fixItems( changes.range.end, document, batch );
+		}
+	};
+}
+
+// Helper function for post fixer callback. Performs actual model fixing. 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 _fixItems( 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 );
+				}
+			} );
+		}
+	}
+}
+
+// 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 ) {
@@ -401,27 +663,34 @@ 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.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 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 ) {
+		if (
+			( sameIndent && indent == itemIndent ) ||
+			( biggerIndent && indent < itemIndent ) ||
+			( smallerIndent && indent > itemIndent )
+		) {
 			return item;
 		} else if ( !checkAllSiblings || indent > itemIndent ) {
 			return null;
@@ -437,65 +706,201 @@ 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 indent and break after.
+	// 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, 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 ) {
+		// There is a list item with same indent. 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 );
 
-	// 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 );
+	// 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.
+	const nextItem = getSiblingListItem( modelItem, { biggerIndent: true, getNext: true } );
 
-	/* 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 );
+	if ( nextItem ) {
+		let viewItem = mapper.toViewElement( nextItem );
+
+		// Check if `nextItem` is already mapped to view. It might not be mapped if multiple items in model were inserted
+		// and following list items were not converted yet.
+		if ( viewItem ) {
+			// Break the list between found view item and its preceding `<li>`s.
+			viewWriter.breakContainer( ViewPosition.createBefore( viewItem ) );
+
+			// The broken ("lower") part will be moved as nested children of the inserted view item.
+			const sourceStart = ViewPosition.createBefore( viewItem.parent );
+
+			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;
+}

+ 23 - 1
packages/ckeditor5-list/src/listengine.js

@@ -13,12 +13,14 @@ import IndentCommand from './indentcommand';
 
 import {
 	cleanList,
+	cleanListItem,
 	modelViewInsertion,
 	modelViewChangeType,
 	modelViewMergeAfter,
 	modelViewRemove,
 	modelViewSplitOnInsert,
 	modelViewChangeIndent,
+	modelChangePostFixer,
 	viewModelConverter,
 	modelToViewPosition,
 	viewToModelPosition
@@ -51,6 +53,11 @@ 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' } );
+
+		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 +80,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 +94,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;
+}

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 2618 - 313
packages/ckeditor5-list/tests/listengine.js


برخی فایل ها در این مقایسه diff نمایش داده نمی شوند زیرا تعداد فایل ها بسیار زیاد است