8
0
Просмотр исходного кода

Merge pull request #7902 from ckeditor/i/7879

Fix (list): When removing the content between two lists items, the lists will be merged into a single list. The second list should adjust its value for the `listStyle` attribute based on the most outer `listItem` in the first list. Closes #7879.
Maciej 5 лет назад
Родитель
Сommit
7aa952823a

+ 1 - 81
packages/ckeditor5-list/src/liststylecommand.js

@@ -8,7 +8,7 @@
  */
  */
 
 
 import Command from '@ckeditor/ckeditor5-core/src/command';
 import Command from '@ckeditor/ckeditor5-core/src/command';
-import TreeWalker from '@ckeditor/ckeditor5-engine/src/model/treewalker';
+import { getSiblingNodes } from './utils';
 
 
 /**
 /**
  * The list style command. It is used by the {@link module:list/liststyle~ListStyle list styles feature}.
  * The list style command. It is used by the {@link module:list/liststyle~ListStyle list styles feature}.
@@ -115,83 +115,3 @@ export default class ListStyleCommand extends Command {
 		return numberedList.isEnabled || bulletedList.isEnabled;
 		return numberedList.isEnabled || bulletedList.isEnabled;
 	}
 	}
 }
 }
-
-// Returns an array with all `listItem` elements that represents the same list.
-//
-// It means that values for `listIndent`, `listType`, and `listStyle` for all items
-// are equal.
-//
-// @param {module:engine/model/position~Position} position Starting position.
-// @param {'forward'|'backward'} direction Walking direction.
-// @returns {Array.<module:engine/model/element~Element>
-function getSiblingNodes( position, direction ) {
-	const items = [];
-	const listItem = position.parent;
-	const walkerOptions = {
-		ignoreElementEnd: true,
-		startPosition: position,
-		shallow: true,
-		direction
-	};
-	const limitIndent = listItem.getAttribute( 'listIndent' );
-	const nodes = [ ...new TreeWalker( walkerOptions ) ]
-		.filter( value => value.item.is( 'element' ) )
-		.map( value => value.item );
-
-	for ( const element of nodes ) {
-		// If found something else than `listItem`, we're out of the list scope.
-		if ( !element.is( 'element', 'listItem' ) ) {
-			break;
-		}
-
-		// If current parsed item has lower indent that element that the element that was a starting point,
-		// it means we left a nested list. Abort searching items.
-		//
-		// ■ List item 1.       [listIndent=0]
-		//     ○ List item 2.[] [listIndent=1], limitIndent = 1,
-		//     ○ List item 3.   [listIndent=1]
-		// ■ List item 4.       [listIndent=0]
-		//
-		// Abort searching when leave nested list.
-		if ( element.getAttribute( 'listIndent' ) < limitIndent ) {
-			break;
-		}
-
-		// ■ List item 1.[]     [listIndent=0] limitIndent = 0,
-		//     ○ List item 2.   [listIndent=1]
-		//     ○ List item 3.   [listIndent=1]
-		// ■ List item 4.       [listIndent=0]
-		//
-		// Ignore nested lists.
-		if ( element.getAttribute( 'listIndent' ) > limitIndent ) {
-			continue;
-		}
-
-		// ■ List item 1.[]  [listType=bulleted]
-		// 1. List item 2.   [listType=numbered]
-		// 2.List item 3.    [listType=numbered]
-		//
-		// Abort searching when found a different kind of a list.
-		if ( element.getAttribute( 'listType' ) !== listItem.getAttribute( 'listType' ) ) {
-			break;
-		}
-
-		// ■ List item 1.[]  [listType=bulleted]
-		// ■ List item 2.    [listType=bulleted]
-		// ○ List item 3.    [listType=bulleted]
-		// ○ List item 4.    [listType=bulleted]
-		//
-		// Abort searching when found a different list style.
-		if ( element.getAttribute( 'listStyle' ) !== listItem.getAttribute( 'listStyle' ) ) {
-			break;
-		}
-
-		if ( direction === 'backward' ) {
-			items.unshift( element );
-		} else {
-			items.push( element );
-		}
-	}
-
-	return items;
-}

+ 105 - 1
packages/ckeditor5-list/src/liststyleediting.js

@@ -10,7 +10,7 @@
 import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
 import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
 import ListEditing from './listediting';
 import ListEditing from './listediting';
 import ListStyleCommand from './liststylecommand';
 import ListStyleCommand from './liststylecommand';
-import { getSiblingListItem } from './utils';
+import { getSiblingListItem, getSiblingNodes } from './utils';
 
 
 const DEFAULT_LIST_TYPE = 'default';
 const DEFAULT_LIST_TYPE = 'default';
 
 
@@ -66,6 +66,9 @@ export default class ListStyleEditing extends Plugin {
 		// Set up conversion.
 		// Set up conversion.
 		editor.conversion.for( 'upcast' ).add( upcastListItemStyle() );
 		editor.conversion.for( 'upcast' ).add( upcastListItemStyle() );
 		editor.conversion.for( 'downcast' ).add( downcastListStyleAttribute() );
 		editor.conversion.for( 'downcast' ).add( downcastListStyleAttribute() );
+
+		// Handle merging two separated lists into the single one.
+		this._mergeListStyleAttributeWhileMergingLists();
 	}
 	}
 
 
 	/**
 	/**
@@ -80,6 +83,107 @@ export default class ListStyleEditing extends Plugin {
 			editor.model.document.registerPostFixer( removeListStyleAttributeFromTodoList( editor ) );
 			editor.model.document.registerPostFixer( removeListStyleAttributeFromTodoList( editor ) );
 		}
 		}
 	}
 	}
+
+	/**
+	 * Starts listening to {@link module:engine/model/model~Model#deleteContent} checks whether two lists will be merged into a single one
+	 * after deleting the content.
+	 *
+	 * The purpose of this action is to adjust the `listStyle` value for the list that was merged.
+	 *
+	 * Consider the following model's content:
+	 *
+	 *     <listItem listIndent="0" listType="bulleted" listStyle="square">UL List item 1</listItem>
+	 *     <listItem listIndent="0" listType="bulleted" listStyle="square">UL List item 2</listItem>
+	 *     <paragraph>[A paragraph.]</paragraph>
+	 *     <listItem listIndent="0" listType="bulleted" listStyle="circle">UL List item 1</listItem>
+	 *     <listItem listIndent="0" listType="bulleted" listStyle="circle">UL List item 2</listItem>
+	 *
+	 * After removing the paragraph element, the second list will be merged into the first one.
+	 * We want to inherit the `listStyle` attribute for the second list from the first one.
+	 *
+	 *     <listItem listIndent="0" listType="bulleted" listStyle="square">UL List item 1</listItem>
+	 *     <listItem listIndent="0" listType="bulleted" listStyle="square">UL List item 2</listItem>
+	 *     <listItem listIndent="0" listType="bulleted" listStyle="square">UL List item 1</listItem>
+	 *     <listItem listIndent="0" listType="bulleted" listStyle="square">UL List item 2</listItem>
+	 *
+	 * See https://github.com/ckeditor/ckeditor5/issues/7879.
+	 *
+	 * @private
+	 */
+	_mergeListStyleAttributeWhileMergingLists() {
+		const editor = this.editor;
+		const model = editor.model;
+
+		// First the most-outer `listItem` in the first list reference.
+		// If found, lists should be merged and this `listItem` provides the `listStyle` attribute
+		// and it' also a starting point when searching for items in the second list.
+		let firstMostOuterItem;
+
+		// Check whether the removed content is between two lists.
+		this.listenTo( model, 'deleteContent', ( evt, [ selection ] ) => {
+			const firstPosition = selection.getFirstPosition();
+			const lastPosition = selection.getLastPosition();
+
+			// Typing or removing content in a single item. Aborting.
+			if ( firstPosition.parent === lastPosition.parent ) {
+				return;
+			}
+
+			// An element before the content that will be removed is not a list.
+			if ( !firstPosition.parent.is( 'element', 'listItem' ) ) {
+				return;
+			}
+
+			const nextSibling = lastPosition.parent.nextSibling;
+
+			// An element after the content that will be removed is not a list.
+			if ( !nextSibling || !nextSibling.is( 'element', 'listItem' ) ) {
+				return;
+			}
+
+			const mostOuterItemList = getSiblingListItem( firstPosition.parent, {
+				sameIndent: true,
+				listIndent: 0
+			} );
+
+			if ( mostOuterItemList.getAttribute( 'listType' ) === nextSibling.getAttribute( 'listType' ) ) {
+				firstMostOuterItem = mostOuterItemList;
+			}
+		}, { priority: 'high' } );
+
+		// If so, update the `listStyle` attribute for the second list.
+		this.listenTo( model, 'deleteContent', () => {
+			if ( !firstMostOuterItem ) {
+				return;
+			}
+
+			model.change( writer => {
+				// Find the first most-outer item list in the merged list.
+				// A case when the first list item in the second list was merged into the last item in the first list.
+				//
+				// <listItem listIndent="0" listType="bulleted" listStyle="square">UL List item 1</listItem>
+				// <listItem listIndent="0" listType="bulleted" listStyle="square">UL List item 2</listItem>
+				// <listItem listIndent="0" listType="bulleted" listStyle="circle">[]UL List item 1</listItem>
+				// <listItem listIndent="0" listType="bulleted" listStyle="circle">UL List item 2</listItem>
+				const secondListMostOuterItem = getSiblingListItem( firstMostOuterItem.nextSibling, {
+					sameIndent: true,
+					listIndent: 0,
+					direction: 'forward'
+				} );
+
+				const items = [
+					secondListMostOuterItem,
+					...getSiblingNodes( writer.createPositionAt( secondListMostOuterItem, 0 ), 'forward' )
+				];
+
+				for ( const listItem of items ) {
+					writer.setAttribute( 'listStyle', firstMostOuterItem.getAttribute( 'listStyle' ), listItem );
+				}
+			} );
+
+			firstMostOuterItem = null;
+		}, { priority: 'low' } );
+	}
 }
 }
 
 
 // Returns a converter that consumes the `style` attribute and search for `list-style-type` definition.
 // Returns a converter that consumes the `style` attribute and search for `list-style-type` definition.

+ 88 - 1
packages/ckeditor5-list/src/utils.js

@@ -9,6 +9,7 @@
 
 
 import { getFillerOffset } from '@ckeditor/ckeditor5-engine/src/view/containerelement';
 import { getFillerOffset } from '@ckeditor/ckeditor5-engine/src/view/containerelement';
 import ButtonView from '@ckeditor/ckeditor5-ui/src/button/buttonview';
 import ButtonView from '@ckeditor/ckeditor5-ui/src/button/buttonview';
+import TreeWalker from '@ckeditor/ckeditor5-engine/src/model/treewalker';
 
 
 /**
 /**
  * Creates a list item {@link module:engine/view/containerelement~ContainerElement}.
  * Creates a list item {@link module:engine/view/containerelement~ContainerElement}.
@@ -207,6 +208,7 @@ export function positionAfterUiElements( viewPosition ) {
  * @param {Boolean} [options.sameIndent=false] Whether the sought sibling should have the same indentation.
  * @param {Boolean} [options.sameIndent=false] Whether the sought sibling should have the same indentation.
  * @param {Boolean} [options.smallerIndent=false] Whether the sought sibling should have a smaller indentation.
  * @param {Boolean} [options.smallerIndent=false] Whether the sought sibling should have a smaller indentation.
  * @param {Number} [options.listIndent] The reference indentation.
  * @param {Number} [options.listIndent] The reference indentation.
+ * @param {'forward'|'backward'} [options.direction='backward'] Walking direction.
  * @returns {module:engine/model/item~Item|null}
  * @returns {module:engine/model/item~Item|null}
  */
  */
 export function getSiblingListItem( modelItem, options ) {
 export function getSiblingListItem( modelItem, options ) {
@@ -223,7 +225,11 @@ export function getSiblingListItem( modelItem, options ) {
 			return item;
 			return item;
 		}
 		}
 
 
-		item = item.previousSibling;
+		if ( options.direction === 'forward' ) {
+			item = item.nextSibling;
+		} else {
+			item = item.previousSibling;
+		}
 	}
 	}
 
 
 	return null;
 	return null;
@@ -279,6 +285,87 @@ export function findNestedList( viewElement ) {
 	return null;
 	return null;
 }
 }
 
 
+/**
+ * Returns an array with all `listItem` elements that represents the same list.
+ *
+ * It means that values for `listIndent`, `listType`, and `listStyle` for all items are equal.
+ *
+ * @param {module:engine/model/position~Position} position Starting position.
+ * @param {'forward'|'backward'} direction Walking direction.
+ * @returns {Array.<module:engine/model/element~Element>}
+ */
+export function getSiblingNodes( position, direction ) {
+	const items = [];
+	const listItem = position.parent;
+	const walkerOptions = {
+		ignoreElementEnd: true,
+		startPosition: position,
+		shallow: true,
+		direction
+	};
+	const limitIndent = listItem.getAttribute( 'listIndent' );
+	const nodes = [ ...new TreeWalker( walkerOptions ) ]
+		.filter( value => value.item.is( 'element' ) )
+		.map( value => value.item );
+
+	for ( const element of nodes ) {
+		// If found something else than `listItem`, we're out of the list scope.
+		if ( !element.is( 'element', 'listItem' ) ) {
+			break;
+		}
+
+		// If current parsed item has lower indent that element that the element that was a starting point,
+		// it means we left a nested list. Abort searching items.
+		//
+		// ■ List item 1.       [listIndent=0]
+		//     ○ List item 2.[] [listIndent=1], limitIndent = 1,
+		//     ○ List item 3.   [listIndent=1]
+		// ■ List item 4.       [listIndent=0]
+		//
+		// Abort searching when leave nested list.
+		if ( element.getAttribute( 'listIndent' ) < limitIndent ) {
+			break;
+		}
+
+		// ■ List item 1.[]     [listIndent=0] limitIndent = 0,
+		//     ○ List item 2.   [listIndent=1]
+		//     ○ List item 3.   [listIndent=1]
+		// ■ List item 4.       [listIndent=0]
+		//
+		// Ignore nested lists.
+		if ( element.getAttribute( 'listIndent' ) > limitIndent ) {
+			continue;
+		}
+
+		// ■ List item 1.[]  [listType=bulleted]
+		// 1. List item 2.   [listType=numbered]
+		// 2.List item 3.    [listType=numbered]
+		//
+		// Abort searching when found a different kind of a list.
+		if ( element.getAttribute( 'listType' ) !== listItem.getAttribute( 'listType' ) ) {
+			break;
+		}
+
+		// ■ List item 1.[]  [listType=bulleted]
+		// ■ List item 2.    [listType=bulleted]
+		// ○ List item 3.    [listType=bulleted]
+		// ○ List item 4.    [listType=bulleted]
+		//
+		// Abort searching when found a different list style.
+		if ( element.getAttribute( 'listStyle' ) !== listItem.getAttribute( 'listStyle' ) ) {
+			break;
+		}
+
+		if ( direction === 'backward' ) {
+			items.unshift( element );
+		} else {
+			items.push( element );
+		}
+	}
+
+	return items;
+}
+
 // Implementation of getFillerOffset for view list item element.
 // Implementation of getFillerOffset for view list item element.
 //
 //
 // @returns {Number|null} Block filler offset or `null` if block filler is not needed.
 // @returns {Number|null} Block filler offset or `null` if block filler is not needed.

+ 263 - 1
packages/ckeditor5-list/tests/liststyleediting.js

@@ -5,9 +5,11 @@
 
 
 import VirtualTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/virtualtesteditor';
 import VirtualTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/virtualtesteditor';
 import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
 import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
+import Typing from '@ckeditor/ckeditor5-typing/src/typing';
+import UndoEditing from '@ckeditor/ckeditor5-undo/src/undoediting';
 import { getData as getModelData, setData as setModelData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
 import { getData as getModelData, setData as setModelData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
 import { getData as getViewData } from '@ckeditor/ckeditor5-engine/src/dev-utils/view';
 import { getData as getViewData } from '@ckeditor/ckeditor5-engine/src/dev-utils/view';
-import UndoEditing from '@ckeditor/ckeditor5-undo/src/undoediting';
+
 import ListStyleEditing from '../src/liststyleediting';
 import ListStyleEditing from '../src/liststyleediting';
 import TodoListEditing from '../src/todolistediting';
 import TodoListEditing from '../src/todolistediting';
 import ListStyleCommand from '../src/liststylecommand';
 import ListStyleCommand from '../src/liststylecommand';
@@ -892,5 +894,265 @@ describe( 'ListStyleEditing', () => {
 				);
 				);
 			} );
 			} );
 		} );
 		} );
+
+		describe( 'removing content between two lists', () => {
+			let editor, model;
+
+			beforeEach( () => {
+				return VirtualTestEditor
+					.create( {
+						plugins: [ Paragraph, ListStyleEditing, Typing ]
+					} )
+					.then( newEditor => {
+						editor = newEditor;
+						model = editor.model;
+					} );
+			} );
+
+			afterEach( () => {
+				return editor.destroy();
+			} );
+
+			it( 'should not do anything while removing a letter inside a listItem', () => {
+				setModelData( model,
+					'<listItem listIndent="0" listStyle="square" listType="bulleted">1.</listItem>' +
+					'<listItem listIndent="0" listStyle="square" listType="bulleted">2.[]</listItem>' +
+					'<paragraph></paragraph>' +
+					'<listItem listIndent="0" listStyle="circle" listType="bulleted">1.</listItem>' +
+					'<listItem listIndent="0" listStyle="circle" listType="bulleted">2.</listItem>'
+				);
+
+				editor.execute( 'delete' );
+
+				expect( getModelData( model ) ).to.equal(
+					'<listItem listIndent="0" listStyle="square" listType="bulleted">1.</listItem>' +
+					'<listItem listIndent="0" listStyle="square" listType="bulleted">2[]</listItem>' +
+					'<paragraph></paragraph>' +
+					'<listItem listIndent="0" listStyle="circle" listType="bulleted">1.</listItem>' +
+					'<listItem listIndent="0" listStyle="circle" listType="bulleted">2.</listItem>'
+				);
+			} );
+
+			it( 'should not do anything if there is a non-listItem before the removed content', () => {
+				setModelData( model,
+					'<paragraph>Foo</paragraph>' +
+					'<paragraph>[]</paragraph>' +
+					'<listItem listIndent="0" listStyle="circle" listType="bulleted">1.</listItem>' +
+					'<listItem listIndent="0" listStyle="circle" listType="bulleted">2.</listItem>'
+				);
+
+				editor.execute( 'delete' );
+
+				expect( getModelData( model ) ).to.equal(
+					'<paragraph>Foo[]</paragraph>' +
+					'<listItem listIndent="0" listStyle="circle" listType="bulleted">1.</listItem>' +
+					'<listItem listIndent="0" listStyle="circle" listType="bulleted">2.</listItem>'
+				);
+			} );
+
+			it( 'should not do anything if there is a non-listItem after the removed content', () => {
+				setModelData( model,
+					'<listItem listIndent="0" listStyle="circle" listType="bulleted">1.</listItem>' +
+					'<listItem listIndent="0" listStyle="circle" listType="bulleted">2.</listItem>' +
+					'<paragraph>[]</paragraph>' +
+					'<paragraph>Foo</paragraph>'
+				);
+
+				editor.execute( 'delete' );
+
+				expect( getModelData( model ) ).to.equal(
+					'<listItem listIndent="0" listStyle="circle" listType="bulleted">1.</listItem>' +
+					'<listItem listIndent="0" listStyle="circle" listType="bulleted">2.[]</listItem>' +
+					'<paragraph>Foo</paragraph>'
+				);
+			} );
+
+			it( 'should not do anything if there is no element after the removed content', () => {
+				setModelData( model,
+					'<listItem listIndent="0" listStyle="circle" listType="bulleted">1.</listItem>' +
+					'<listItem listIndent="0" listStyle="circle" listType="bulleted">2.</listItem>' +
+					'<paragraph>[]</paragraph>'
+				);
+
+				editor.execute( 'delete' );
+
+				expect( getModelData( model ) ).to.equal(
+					'<listItem listIndent="0" listStyle="circle" listType="bulleted">1.</listItem>' +
+					'<listItem listIndent="0" listStyle="circle" listType="bulleted">2.[]</listItem>'
+				);
+			} );
+
+			it(
+				'should modify the the `listStyle` attribute for the merged (second) list when removing content between those lists',
+				() => {
+					setModelData( model,
+						'<listItem listIndent="0" listStyle="square" listType="bulleted">1.</listItem>' +
+						'<listItem listIndent="0" listStyle="square" listType="bulleted">2.</listItem>' +
+						'<paragraph>[]</paragraph>' +
+						'<listItem listIndent="0" listStyle="circle" listType="bulleted">1.</listItem>' +
+						'<listItem listIndent="0" listStyle="circle" listType="bulleted">2.</listItem>'
+					);
+
+					editor.execute( 'delete' );
+
+					expect( getModelData( model ) ).to.equal(
+						'<listItem listIndent="0" listStyle="square" listType="bulleted">1.</listItem>' +
+						'<listItem listIndent="0" listStyle="square" listType="bulleted">2.[]</listItem>' +
+						'<listItem listIndent="0" listStyle="square" listType="bulleted">1.</listItem>' +
+						'<listItem listIndent="0" listStyle="square" listType="bulleted">2.</listItem>'
+					);
+				}
+			);
+
+			it( 'should read the `listStyle` attribute from the most outer list', () => {
+				setModelData( model,
+					'<listItem listIndent="0" listStyle="square" listType="bulleted">1.</listItem>' +
+					'<listItem listIndent="0" listStyle="square" listType="bulleted">2.</listItem>' +
+					'<listItem listIndent="1" listStyle="numbered" listType="decimal">2.1.</listItem>' +
+					'<listItem listIndent="2" listStyle="default" listType="default">2.1.1</listItem>' +
+					'<paragraph>[]</paragraph>' +
+					'<listItem listIndent="0" listStyle="circle" listType="bulleted">1.</listItem>' +
+					'<listItem listIndent="0" listStyle="circle" listType="bulleted">2.</listItem>'
+				);
+
+				editor.execute( 'delete' );
+
+				expect( getModelData( model ) ).to.equal(
+					'<listItem listIndent="0" listStyle="square" listType="bulleted">1.</listItem>' +
+					'<listItem listIndent="0" listStyle="square" listType="bulleted">2.</listItem>' +
+					'<listItem listIndent="1" listStyle="numbered" listType="decimal">2.1.</listItem>' +
+					'<listItem listIndent="2" listStyle="default" listType="default">2.1.1[]</listItem>' +
+					'<listItem listIndent="0" listStyle="square" listType="bulleted">1.</listItem>' +
+					'<listItem listIndent="0" listStyle="square" listType="bulleted">2.</listItem>'
+				);
+			} );
+
+			it(
+				'should not modify the the `listStyle` attribute for the merged (second) list if merging different `listType` attribute',
+				() => {
+					setModelData( model,
+						'<listItem listIndent="0" listStyle="square" listType="bulleted">1.</listItem>' +
+						'<listItem listIndent="0" listStyle="square" listType="bulleted">2.</listItem>' +
+						'<paragraph>[]</paragraph>' +
+						'<listItem listIndent="0" listStyle="decimal" listType="numbered">1.</listItem>' +
+						'<listItem listIndent="0" listStyle="decimal" listType="numbered">2.</listItem>'
+					);
+
+					editor.execute( 'delete' );
+
+					expect( getModelData( model ) ).to.equal(
+						'<listItem listIndent="0" listStyle="square" listType="bulleted">1.</listItem>' +
+						'<listItem listIndent="0" listStyle="square" listType="bulleted">2.[]</listItem>' +
+						'<listItem listIndent="0" listStyle="decimal" listType="numbered">1.</listItem>' +
+						'<listItem listIndent="0" listStyle="decimal" listType="numbered">2.</listItem>'
+					);
+				}
+			);
+
+			it(
+				'should modify the the `listStyle` attribute for the merged (second) list when removing content from both lists',
+				() => {
+					setModelData( model,
+						'<listItem listIndent="0" listStyle="square" listType="bulleted">1.</listItem>' +
+						'<listItem listIndent="0" listStyle="square" listType="bulleted">2.</listItem>' +
+						'<listItem listIndent="0" listStyle="square" listType="bulleted">[3.</listItem>' +
+						'<paragraph>Foo</paragraph>' +
+						'<listItem listIndent="0" listStyle="circle" listType="bulleted">1.]</listItem>' +
+						'<listItem listIndent="0" listStyle="circle" listType="bulleted">2.</listItem>'
+					);
+
+					editor.execute( 'delete' );
+
+					expect( getModelData( model ) ).to.equal(
+						'<listItem listIndent="0" listStyle="square" listType="bulleted">1.</listItem>' +
+						'<listItem listIndent="0" listStyle="square" listType="bulleted">2.</listItem>' +
+						'<listItem listIndent="0" listStyle="square" listType="bulleted">[]</listItem>' +
+						'<listItem listIndent="0" listStyle="square" listType="bulleted">2.</listItem>'
+					);
+				}
+			);
+
+			it(
+				'should modify the the `listStyle` attribute for the merged (second) list when typing over content from both lists',
+				() => {
+					setModelData( model,
+						'<listItem listIndent="0" listStyle="square" listType="bulleted">1.</listItem>' +
+						'<listItem listIndent="0" listStyle="square" listType="bulleted">2.</listItem>' +
+						'<listItem listIndent="0" listStyle="square" listType="bulleted">[3.</listItem>' +
+						'<paragraph>Foo</paragraph>' +
+						'<listItem listIndent="0" listStyle="circle" listType="bulleted">1.]</listItem>' +
+						'<listItem listIndent="0" listStyle="circle" listType="bulleted">2.</listItem>'
+					);
+
+					editor.execute( 'input', { text: 'Foo' } );
+
+					expect( getModelData( model ) ).to.equal(
+						'<listItem listIndent="0" listStyle="square" listType="bulleted">1.</listItem>' +
+						'<listItem listIndent="0" listStyle="square" listType="bulleted">2.</listItem>' +
+						'<listItem listIndent="0" listStyle="square" listType="bulleted">Foo[]</listItem>' +
+						'<listItem listIndent="0" listStyle="square" listType="bulleted">2.</listItem>'
+					);
+				}
+			);
+
+			it(
+				'should not modify the the `listStyle` if lists were not merged but the content was partially removed',
+				() => {
+					setModelData( model,
+						'<listItem listIndent="0" listStyle="square" listType="bulleted">111.</listItem>' +
+						'<listItem listIndent="0" listStyle="square" listType="bulleted">222.</listItem>' +
+						'<listItem listIndent="0" listStyle="square" listType="bulleted">[333.</listItem>' +
+						'<paragraph>Foo</paragraph>' +
+						'<listItem listIndent="0" listStyle="circle" listType="bulleted">1]11.</listItem>' +
+						'<listItem listIndent="0" listStyle="circle" listType="bulleted">2.</listItem>'
+					);
+
+					editor.execute( 'delete' );
+
+					expect( getModelData( model ) ).to.equal(
+						'<listItem listIndent="0" listStyle="square" listType="bulleted">111.</listItem>' +
+						'<listItem listIndent="0" listStyle="square" listType="bulleted">222.</listItem>' +
+						'<listItem listIndent="0" listStyle="circle" listType="bulleted">[]11.</listItem>' +
+						'<listItem listIndent="0" listStyle="circle" listType="bulleted">2.</listItem>'
+					);
+				}
+			);
+
+			it( 'should not do anything while typing in a list item', () => {
+				setModelData( model,
+					'<listItem listIndent="0" listStyle="square" listType="bulleted">1.</listItem>' +
+					'<listItem listIndent="0" listStyle="square" listType="bulleted">2.[]</listItem>' +
+					'<listItem listIndent="0" listStyle="square" listType="bulleted">3.</listItem>' +
+					'<paragraph></paragraph>' +
+					'<listItem listIndent="0" listStyle="circle" listType="bulleted">1.</listItem>' +
+					'<listItem listIndent="0" listStyle="circle" listType="bulleted">2.</listItem>'
+				);
+
+				const modelChangeStub = sinon.stub( model, 'change' ).callThrough();
+
+				simulateTyping( ' Foo' );
+
+				// Each character calls `editor.model.change()`.
+				expect( modelChangeStub.callCount ).to.equal( 4 );
+
+				expect( getModelData( model ) ).to.equal(
+					'<listItem listIndent="0" listStyle="square" listType="bulleted">1.</listItem>' +
+					'<listItem listIndent="0" listStyle="square" listType="bulleted">2. Foo[]</listItem>' +
+					'<listItem listIndent="0" listStyle="square" listType="bulleted">3.</listItem>' +
+					'<paragraph></paragraph>' +
+					'<listItem listIndent="0" listStyle="circle" listType="bulleted">1.</listItem>' +
+					'<listItem listIndent="0" listStyle="circle" listType="bulleted">2.</listItem>'
+				);
+			} );
+
+			function simulateTyping( text ) {
+				// While typing, every character is an atomic change.
+				text.split( '' ).forEach( character => {
+					editor.execute( 'input', {
+						text: character
+					} );
+				} );
+			}
+		} );
 	} );
 	} );
 } );
 } );

+ 316 - 1
packages/ckeditor5-list/tests/utils.js

@@ -5,7 +5,13 @@
 
 
 import ViewContainerElement from '@ckeditor/ckeditor5-engine/src/view/containerelement';
 import ViewContainerElement from '@ckeditor/ckeditor5-engine/src/view/containerelement';
 import ViewDowncastWriter from '@ckeditor/ckeditor5-engine/src/view/downcastwriter';
 import ViewDowncastWriter from '@ckeditor/ckeditor5-engine/src/view/downcastwriter';
-import { createViewListItemElement } from '../src/utils';
+import { setData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
+import VirtualTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/virtualtesteditor';
+
+import ListEditing from '../src/listediting';
+import ListStyleEditing from '../src/liststyleediting';
+
+import { createViewListItemElement, getSiblingListItem, getSiblingNodes } from '../src/utils';
 
 
 describe( 'utils', () => {
 describe( 'utils', () => {
 	let writer;
 	let writer;
@@ -133,4 +139,313 @@ describe( 'utils', () => {
 			} );
 			} );
 		} );
 		} );
 	} );
 	} );
+
+	describe( 'getSiblingListItem()', () => {
+		let editor, model, document;
+
+		beforeEach( () => {
+			return VirtualTestEditor.create( { plugins: [ ListEditing ] } )
+				.then( newEditor => {
+					editor = newEditor;
+					model = editor.model;
+					document = model.document;
+				} );
+		} );
+
+		afterEach( () => {
+			return editor.destroy();
+		} );
+
+		it( 'should return the passed element if it matches the criteria (sameIndent, listIndent=0)', () => {
+			setData( model,
+				'<listItem listType="bulleted" listIndent="0">0.</listItem>' +
+				'<listItem listType="bulleted" listIndent="0">1.</listItem>' + // Starting item, wanted item.
+				'<listItem listType="bulleted" listIndent="0">2.</listItem>'
+			);
+
+			const listItem = document.getRoot().getChild( 1 );
+			const foundElement = getSiblingListItem( listItem, {
+				sameIndent: true,
+				listIndent: 0
+			} );
+
+			expect( foundElement ).to.equal( document.getRoot().getChild( 1 ) );
+		} );
+
+		it( 'should return the passed element if it matches the criteria (sameIndent, listIndent=0, direction="forward")', () => {
+			setData( model,
+				'<listItem listType="bulleted" listIndent="0">0.</listItem>' +
+				'<listItem listType="bulleted" listIndent="0">1.</listItem>' + // Starting item, wanted item.
+				'<listItem listType="bulleted" listIndent="0">2.</listItem>'
+			);
+
+			const listItem = document.getRoot().getChild( 1 );
+			const foundElement = getSiblingListItem( listItem, {
+				sameIndent: true,
+				listIndent: 0,
+				direction: 'forward'
+			} );
+
+			expect( foundElement ).to.equal( document.getRoot().getChild( 1 ) );
+		} );
+
+		it( 'should return the first listItem that matches criteria (sameIndent, listIndent=1)', () => {
+			setData( model,
+				'<listItem listType="bulleted" listIndent="0">0.</listItem>' +
+				'<listItem listType="bulleted" listIndent="0">1.</listItem>' +
+				'<listItem listType="bulleted" listIndent="1">1.1</listItem>' +
+				'<listItem listType="bulleted" listIndent="1">1.2</listItem>' + // Wanted item.
+				'<listItem listType="bulleted" listIndent="0">2.</listItem>' + // Starting item.
+				'<listItem listType="bulleted" listIndent="1">2.1.</listItem>' +
+				'<listItem listType="bulleted" listIndent="1">2.2.</listItem>'
+			);
+
+			const listItem = document.getRoot().getChild( 5 );
+			const foundElement = getSiblingListItem( listItem.previousSibling, {
+				sameIndent: true,
+				listIndent: 1
+			} );
+
+			expect( foundElement ).to.equal( document.getRoot().getChild( 3 ) );
+		} );
+
+		it( 'should return the first listItem that matches criteria (sameIndent, listIndent=1, direction="forward")', () => {
+			setData( model,
+				'<listItem listType="bulleted" listIndent="0">0.</listItem>' +
+				'<listItem listType="bulleted" listIndent="0">1.</listItem>' + // Starting item.
+				'<listItem listType="bulleted" listIndent="0">2.</listItem>' +
+				'<listItem listType="bulleted" listIndent="1">2.1.</listItem>' + // Wanted item.
+				'<listItem listType="bulleted" listIndent="1">2.2.</listItem>'
+			);
+
+			const listItem = document.getRoot().getChild( 1 );
+			const foundElement = getSiblingListItem( listItem.nextSibling, {
+				sameIndent: true,
+				listIndent: 1,
+				direction: 'forward'
+			} );
+
+			expect( foundElement ).to.equal( document.getRoot().getChild( 3 ) );
+		} );
+
+		it( 'should return the first listItem that matches criteria (smallerIndent, listIndent=1)', () => {
+			setData( model,
+				'<listItem listType="bulleted" listIndent="0">0.</listItem>' +
+				'<listItem listType="bulleted" listIndent="0">1.</listItem>' +
+				'<listItem listType="bulleted" listIndent="0">2.</listItem>' + // Wanted item.
+				'<listItem listType="bulleted" listIndent="1">2.1.</listItem>' + // Starting item.
+				'<listItem listType="bulleted" listIndent="1">2.2.</listItem>'
+			);
+
+			const listItem = document.getRoot().getChild( 4 );
+			const foundElement = getSiblingListItem( listItem, {
+				smallerIndent: true,
+				listIndent: 1
+			} );
+
+			expect( foundElement ).to.equal( document.getRoot().getChild( 2 ) );
+		} );
+
+		it( 'should return the first listItem that matches criteria (smallerIndent, listIndent=1, direction="forward")', () => {
+			setData( model,
+				'<listItem listType="bulleted" listIndent="0">0.</listItem>' +
+				'<listItem listType="bulleted" listIndent="1">0.1.</listItem>' + // Starting item.
+				'<listItem listType="bulleted" listIndent="1">0.2.</listItem>' +
+				'<listItem listType="bulleted" listIndent="1">0.3.</listItem>' +
+				'<listItem listType="bulleted" listIndent="0">1.</listItem>' // Wanted item.
+			);
+
+			const listItem = document.getRoot().getChild( 1 );
+			const foundElement = getSiblingListItem( listItem, {
+				smallerIndent: true,
+				listIndent: 1,
+				direction: 'forward'
+			} );
+
+			expect( foundElement ).to.equal( document.getRoot().getChild( 4 ) );
+		} );
+	} );
+
+	describe( 'getSiblingNodes()', () => {
+		let editor, model, document;
+
+		beforeEach( () => {
+			return VirtualTestEditor.create( { plugins: [ ListStyleEditing ] } )
+				.then( newEditor => {
+					editor = newEditor;
+					model = editor.model;
+					document = model.document;
+				} );
+		} );
+
+		afterEach( () => {
+			return editor.destroy();
+		} );
+
+		it( 'should return all listItems above the current selection position (direction="backward")', () => {
+			setData( model,
+				'<listItem listType="bulleted" listIndent="0">0.</listItem>' +
+				'<listItem listType="bulleted" listIndent="0">1.</listItem>' +
+				'<listItem listType="bulleted" listIndent="0">[]2.</listItem>' +
+				'<listItem listType="bulleted" listIndent="0">3.</listItem>' +
+				'<listItem listType="bulleted" listIndent="0">4.</listItem>'
+			);
+
+			expect( getSiblingNodes( document.selection.getFirstPosition(), 'backward' ) ).to.deep.equal( [
+				document.getRoot().getChild( 0 ),
+				document.getRoot().getChild( 1 ),
+				document.getRoot().getChild( 2 )
+			] );
+		} );
+
+		it( 'should return all listItems below the current selection position (direction="forward")', () => {
+			setData( model,
+				'<listItem listType="bulleted" listIndent="0">0.</listItem>' +
+				'<listItem listType="bulleted" listIndent="0">1.</listItem>' +
+				'<listItem listType="bulleted" listIndent="0">[]2.</listItem>' +
+				'<listItem listType="bulleted" listIndent="0">3.</listItem>' +
+				'<listItem listType="bulleted" listIndent="0">4.</listItem>'
+			);
+
+			expect( getSiblingNodes( document.selection.getFirstPosition(), 'forward' ) ).to.deep.equal( [
+				document.getRoot().getChild( 3 ),
+				document.getRoot().getChild( 4 )
+			] );
+		} );
+
+		it( 'should break searching when spotted a non-listItem element (direction="backward")', () => {
+			setData( model,
+				'<listItem listType="bulleted" listIndent="0">0.</listItem>' +
+				'<listItem listType="bulleted" listIndent="0">1.</listItem>' +
+				'<paragraph>Foo</paragraph>' +
+				'<listItem listType="bulleted" listIndent="0">2.</listItem>' +
+				'<listItem listType="bulleted" listIndent="0">3.</listItem>' +
+				'<listItem listType="bulleted" listIndent="0">4.[].</listItem>'
+			);
+
+			expect( getSiblingNodes( document.selection.getFirstPosition(), 'backward' ) ).to.deep.equal( [
+				document.getRoot().getChild( 3 ),
+				document.getRoot().getChild( 4 ),
+				document.getRoot().getChild( 5 )
+			] );
+		} );
+
+		it( 'should break searching when spotted a non-listItem element (direction="forward")', () => {
+			setData( model,
+				'<listItem listType="bulleted" listIndent="0">[]0.</listItem>' +
+				'<listItem listType="bulleted" listIndent="0">1.</listItem>' +
+				'<listItem listType="bulleted" listIndent="0">2.</listItem>' +
+				'<paragraph>Foo</paragraph>' +
+				'<listItem listType="bulleted" listIndent="0">3.</listItem>' +
+				'<listItem listType="bulleted" listIndent="0">4.</listItem>'
+			);
+
+			expect( getSiblingNodes( document.selection.getFirstPosition(), 'forward' ) ).to.deep.equal( [
+				document.getRoot().getChild( 1 ),
+				document.getRoot().getChild( 2 )
+			] );
+		} );
+
+		it( 'should break searching when spotted a different value for the `listType` attribute (direction="backward")', () => {
+			setData( model,
+				'<listItem listType="bulleted" listIndent="0">0.</listItem>' +
+				'<listItem listType="bulleted" listIndent="0">1.</listItem>' +
+				'<listItem listType="bulleted" listIndent="0">2.</listItem>' +
+				'<listItem listType="numbered" listIndent="0">Numbered item.</listItem>' +
+				'<listItem listType="bulleted" listIndent="0">3.</listItem>' +
+				'<listItem listType="bulleted" listIndent="0">[]4.</listItem>'
+			);
+
+			expect( getSiblingNodes( document.selection.getFirstPosition(), 'backward' ) ).to.deep.equal( [
+				document.getRoot().getChild( 4 ),
+				document.getRoot().getChild( 5 )
+			] );
+		} );
+
+		it( 'should break searching when spotted a different value for the `listType` attribute (direction="forward")', () => {
+			setData( model,
+				'<listItem listType="bulleted" listIndent="0">[]0.</listItem>' +
+				'<listItem listType="bulleted" listIndent="0">1.</listItem>' +
+				'<listItem listType="bulleted" listIndent="0">2.</listItem>' +
+				'<listItem listType="numbered" listIndent="0">Numbered item.</listItem>' +
+				'<listItem listType="bulleted" listIndent="0">3.</listItem>' +
+				'<listItem listType="bulleted" listIndent="0">4.</listItem>'
+			);
+
+			expect( getSiblingNodes( document.selection.getFirstPosition(), 'forward' ) ).to.deep.equal( [
+				document.getRoot().getChild( 1 ),
+				document.getRoot().getChild( 2 )
+			] );
+		} );
+
+		it( 'should break searching when spotted a different value for the `listStyle` attribute (direction="backward")', () => {
+			setData( model,
+				'<listItem listType="bulleted" listStyle="disc" listIndent="0">0.</listItem>' +
+				'<listItem listType="bulleted" listStyle="disc" listIndent="0">1.</listItem>' +
+				'<listItem listType="bulleted" listStyle="disc" listIndent="0">2.</listItem>' +
+				'<listItem listType="bulleted" listStyle="square" listIndent="0">Broken item.</listItem>' +
+				'<listItem listType="bulleted" listStyle="disc" listIndent="0">3.</listItem>' +
+				'<listItem listType="bulleted" listStyle="disc" listIndent="0">[]4.</listItem>'
+			);
+
+			expect( getSiblingNodes( document.selection.getFirstPosition(), 'backward' ) ).to.deep.equal( [
+				document.getRoot().getChild( 4 ),
+				document.getRoot().getChild( 5 )
+			] );
+		} );
+
+		it( 'should break searching when spotted a different value for the `listStyle` attribute (direction="forward")', () => {
+			setData( model,
+				'<listItem listType="bulleted" listStyle="disc" listIndent="0">[]0.</listItem>' +
+				'<listItem listType="bulleted" listStyle="disc" listIndent="0">1.</listItem>' +
+				'<listItem listType="bulleted" listStyle="disc" listIndent="0">2.</listItem>' +
+				'<listItem listType="bulleted" listStyle="square" listIndent="0">Broken item.</listItem>' +
+				'<listItem listType="bulleted" listStyle="disc" listIndent="0">3.</listItem>' +
+				'<listItem listType="bulleted" listStyle="disc" listIndent="0">4.</listItem>'
+			);
+
+			expect( getSiblingNodes( document.selection.getFirstPosition(), 'forward' ) ).to.deep.equal( [
+				document.getRoot().getChild( 1 ),
+				document.getRoot().getChild( 2 )
+			] );
+		} );
+
+		it( 'should ignore nested items (looking for listIndent=0)', () => {
+			setData( model,
+				'<listItem listType="bulleted" listIndent="0">[]0.</listItem>' +
+				'<listItem listType="bulleted" listIndent="0">1.</listItem>' +
+				'<listItem listType="bulleted" listIndent="0">2.</listItem>' +
+				'<listItem listType="bulleted" listIndent="1">2.1.</listItem>' +
+				'<listItem listType="bulleted" listIndent="1">2.2.</listItem>' +
+				'<listItem listType="bulleted" listIndent="0">3.</listItem>' +
+				'<listItem listType="bulleted" listIndent="1">3.1.</listItem>' +
+				'<listItem listType="bulleted" listIndent="2">3.1.1.</listItem>' +
+				'<listItem listType="bulleted" listIndent="0">4.</listItem>'
+			);
+
+			expect( getSiblingNodes( document.selection.getFirstPosition(), 'forward' ) ).to.deep.equal( [
+				document.getRoot().getChild( 1 ),
+				document.getRoot().getChild( 2 ),
+				document.getRoot().getChild( 5 ),
+				document.getRoot().getChild( 8 )
+			] );
+		} );
+
+		it( 'should break when spotted an outer list (looking for listIndent=1)', () => {
+			setData( model,
+				'<listItem listType="bulleted" listIndent="0">0.</listItem>' +
+				'<listItem listType="bulleted" listIndent="0">1.</listItem>' +
+				'<listItem listType="bulleted" listIndent="1">[]1.1.</listItem>' +
+				'<listItem listType="bulleted" listIndent="1">1.2.</listItem>' +
+				'<listItem listType="bulleted" listIndent="1">1.3.</listItem>' +
+				'<listItem listType="bulleted" listIndent="0">2.</listItem>' +
+				'<listItem listType="bulleted" listIndent="0">3.</listItem>'
+			);
+
+			expect( getSiblingNodes( document.selection.getFirstPosition(), 'forward' ) ).to.deep.equal( [
+				document.getRoot().getChild( 3 ),
+				document.getRoot().getChild( 4 )
+			] );
+		} );
+	} );
 } );
 } );