浏览代码

Merge pull request #59 from ckeditor/t/56

Fix: Pasted list items' indentation now will be correctly adjusted if they are pasted into a nested list. Closes #56.
Piotrek Koszuliński 8 年之前
父节点
当前提交
a2c613d2b4

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

@@ -12,6 +12,7 @@
   },
   "devDependencies": {
     "@ckeditor/ckeditor5-basic-styles": "^0.8.0",
+    "@ckeditor/ckeditor5-clipboard": "^0.5.0",
     "@ckeditor/ckeditor5-dev-lint": "^2.0.2",
     "@ckeditor/ckeditor5-editor-classic": "^0.7.2",
     "@ckeditor/ckeditor5-enter": "^0.9.0",

+ 93 - 30
packages/ckeditor5-list/src/converters.js

@@ -46,7 +46,10 @@ export function modelViewInsertion( evt, data, consumable, conversionApi ) {
 	const modelItem = data.item;
 	const viewItem = generateLiInUl( modelItem, conversionApi.mapper );
 
-	injectViewList( modelItem, viewItem, conversionApi.mapper );
+	// Providing kind of "default" insert position in case of converting incorrect model.
+	const insertPosition = conversionApi.mapper.toViewPosition( ModelPosition.createBefore( modelItem ) );
+
+	injectViewList( modelItem, viewItem, conversionApi.mapper, insertPosition );
 }
 
 /**
@@ -622,37 +625,37 @@ export function modelChangePostFixer( document ) {
 // 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' );
-			}
+		document.enqueueChanges( () => {
+			const prevItem = nextItem.previousSibling;
+			// 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;
+				const newIndent = nextItem.getAttribute( 'indent' ) - outdentBy;
 
-			items.push( { item: nextItem, indent: newIndent } );
+				items.push( { item: nextItem, indent: newIndent } );
 
-			nextItem = nextItem.nextSibling;
-		}
+				nextItem = nextItem.nextSibling;
+			}
 
-		if ( items.length > 0 ) {
-			document.enqueueChanges( () => {
+			if ( items.length > 0 ) {
 				// 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 );
 				}
-			} );
-		}
+			}
+		} );
 	}
 }
 
@@ -669,18 +672,18 @@ function _fixItemsType( changePosition, fixPrevious, document, batch ) {
 		return;
 	}
 
-	const refItem = _getBoundaryItemOfSameList( item, !fixPrevious );
+	document.enqueueChanges( () => {
+		const refItem = _getBoundaryItemOfSameList( item, !fixPrevious );
 
-	if ( !refItem || refItem == item ) {
-		// !refItem - happens if first list item is inserted.
-		// refItem == item - happens if last item is inserted.
-		return;
-	}
+		if ( !refItem || refItem == item ) {
+			// !refItem - happens if first list item is inserted.
+			// refItem == item - happens if last item is inserted.
+			return;
+		}
 
-	const refIndent = refItem.getAttribute( 'indent' );
-	const refType = refItem.getAttribute( 'type' );
+		const refIndent = refItem.getAttribute( 'indent' );
+		const refType = refItem.getAttribute( 'type' );
 
-	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 );
@@ -691,6 +694,66 @@ function _fixItemsType( changePosition, fixPrevious, document, batch ) {
 	} );
 }
 
+/**
+ * Fixer for pasted content that includes list items.
+ *
+ * Fixes indent of pasted list items so the pasted items match correctly to the context they are pasted into.
+ *
+ * Example:
+ *
+ *		<listItem type="bulleted" indent=0>A</listItem>
+ *		<listItem type="bulleted" indent=1>B^</listItem>
+ *		// At ^ paste:  <listItem type="bulleted" indent=4>X</listItem>
+ *		//              <listItem type="bulleted" indent=5>Y</listItem>
+ *		<listItem type="bulleted" indent=2>C</listItem>
+ *
+ * Should become:
+ *
+ *		<listItem type="bulleted" indent=0>A</listItem>
+ *		<listItem type="bulleted" indent=1>BX</listItem>
+ *		<listItem type="bulleted" indent=2>Y/listItem>
+ *		<listItem type="bulleted" indent=2>C</listItem>
+ *
+ * @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 modelIndentPasteFixer( evt, data ) {
+	// Check whether inserted content starts from a `listItem`. If it does not, it means that there are some other
+	// elements before it and there is no need to fix indents, because even if we insert that content into a list,
+	// that list will be broken.
+	let item = data.content.getChild( 0 );
+
+	if ( item.is( 'listItem' ) ) {
+		// Get a reference list item. Inserted list items will be fixed according to that item.
+		const pos = data.selection.getFirstPosition();
+		let refItem = null;
+
+		if ( pos.parent.is( 'listItem' ) ) {
+			refItem = pos.parent;
+		} else if ( pos.nodeBefore && pos.nodeBefore.is( 'listItem' ) ) {
+			refItem = pos.nodeBefore;
+		}
+
+		// If there is `refItem` it means that we do insert list items into an existing list.
+		if ( refItem ) {
+			// First list item in `data` has indent equal to 0 (it is a first list item). It should have indent equal
+			// to the indent of reference item. We have to fix the first item and all of it's children and following siblings.
+			// Indent of all those items has to be adjusted to reference item.
+			const indentChange = refItem.getAttribute( 'indent' );
+
+			// Fix only if there is anything to fix.
+			if ( indentChange > 0 ) {
+				// Adjust indent of all "first" list items in inserted data.
+				while ( item && item.is( 'listItem' ) ) {
+					item.setAttribute( 'indent', item.getAttribute( 'indent' ) + indentChange );
+
+					item = item.nextSibling;
+				}
+			}
+		}
+	}
+}
+
 // 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>).

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

@@ -23,6 +23,7 @@ import {
 	modelViewSplitOnInsert,
 	modelViewChangeIndent,
 	modelChangePostFixer,
+	modelIndentPasteFixer,
 	viewModelConverter,
 	modelToViewPosition,
 	viewToModelPosition
@@ -109,6 +110,9 @@ export default class ListEngine extends Plugin {
 		data.viewToModel.on( 'element:li', cleanListItem, { priority: 'high' } );
 		data.viewToModel.on( 'element:li', viewModelConverter );
 
+		// Fix indentation of pasted items.
+		data.on( 'insertContent', modelIndentPasteFixer, { priority: 'high' } );
+
 		// Register commands for numbered and bulleted list.
 		editor.commands.set( 'numberedList', new ListCommand( editor, 'numbered' ) );
 		editor.commands.set( 'bulletedList', new ListCommand( editor, 'bulleted' ) );

+ 130 - 2
packages/ckeditor5-list/tests/listengine.js

@@ -12,17 +12,18 @@ import ViewPosition from '@ckeditor/ckeditor5-engine/src/view/position';
 import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
 import BoldEngine from '@ckeditor/ckeditor5-basic-styles/src/boldengine';
 import UndoEngine from '@ckeditor/ckeditor5-undo/src/undoengine';
+import Clipboard from '@ckeditor/ckeditor5-clipboard/src/clipboard';
 
 import VirtualTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/virtualtesteditor';
 import { getData as getModelData, setData as setModelData, parse as parseModel } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
-import { getData as getViewData } from '@ckeditor/ckeditor5-engine/src/dev-utils/view';
+import { getData as getViewData, parse as parseView } from '@ckeditor/ckeditor5-engine/src/dev-utils/view';
 
 describe( 'ListEngine', () => {
 	let editor, modelDoc, modelRoot, viewDoc, viewRoot;
 
 	beforeEach( () => {
 		return VirtualTestEditor.create( {
-			plugins: [ BoldEngine, Paragraph, ListEngine, UndoEngine ]
+			plugins: [ Clipboard, BoldEngine, Paragraph, ListEngine, UndoEngine ]
 		} )
 		.then( newEditor => {
 			editor = newEditor;
@@ -3010,6 +3011,133 @@ describe( 'ListEngine', () => {
 		} );
 	} );
 
+	describe( 'paste integration', () => {
+		it( 'should fix indents of pasted list items', () => {
+			setModelData( modelDoc,
+				'<listItem type="bulleted" indent="0">A</listItem>' +
+				'<listItem type="bulleted" indent="1">B[]</listItem>' +
+				'<listItem type="bulleted" indent="2">C</listItem>'
+			);
+
+			const clipboard = editor.plugins.get( 'clipboard/clipboard' );
+
+			clipboard.fire( 'inputTransformation', {
+				content: parseView( '<ul><li>X<ul><li>Y</li></ul></li></ul>' )
+			} );
+
+			expect( getModelData( modelDoc ) ).to.equal(
+				'<listItem indent="0" type="bulleted">A</listItem>' +
+				'<listItem indent="1" type="bulleted">BX</listItem>' +
+				'<listItem indent="2" type="bulleted">Y[]</listItem>' +
+				'<listItem indent="2" type="bulleted">C</listItem>'
+			);
+		} );
+
+		it( 'should not fix indents of list items that are separated by non-list element', () => {
+			setModelData( modelDoc,
+				'<listItem type="bulleted" indent="0">A</listItem>' +
+				'<listItem type="bulleted" indent="1">B[]</listItem>' +
+				'<listItem type="bulleted" indent="2">C</listItem>'
+			);
+
+			const clipboard = editor.plugins.get( 'clipboard/clipboard' );
+
+			clipboard.fire( 'inputTransformation', {
+				content: parseView( '<ul><li>W<ul><li>X</li></ul></li></ul><p>Y</p><ul><li>Z</li></ul>' )
+			} );
+
+			expect( getModelData( modelDoc ) ).to.equal(
+				'<listItem indent="0" type="bulleted">A</listItem>' +
+				'<listItem indent="1" type="bulleted">BW</listItem>' +
+				'<listItem indent="2" type="bulleted">X</listItem>' +
+				'<paragraph>Y</paragraph>' +
+				'<listItem indent="0" type="bulleted">Z[]</listItem>' +
+				'<listItem indent="1" type="bulleted">C</listItem>'
+			);
+		} );
+
+		it( 'should co-work correctly with post fixer', () => {
+			setModelData( modelDoc,
+				'<listItem type="bulleted" indent="0">A</listItem>' +
+				'<listItem type="bulleted" indent="1">B[]</listItem>' +
+				'<listItem type="bulleted" indent="2">C</listItem>'
+			);
+
+			const clipboard = editor.plugins.get( 'clipboard/clipboard' );
+
+			clipboard.fire( 'inputTransformation', {
+				content: parseView( '<p>X</p><ul><li>Y</li></ul>' )
+			} );
+
+			expect( getModelData( modelDoc ) ).to.equal(
+				'<listItem indent="0" type="bulleted">A</listItem>' +
+				'<listItem indent="1" type="bulleted">BX</listItem>' +
+				'<listItem indent="0" type="bulleted">Y[]</listItem>' +
+				'<listItem indent="1" type="bulleted">C</listItem>'
+			);
+		} );
+
+		it( 'should work if items are pasted between listItem elements', () => {
+			setModelData( modelDoc,
+				'<listItem type="bulleted" indent="0">A</listItem>' +
+				'<listItem type="bulleted" indent="1">B</listItem>[]' +
+				'<listItem type="bulleted" indent="2">C</listItem>'
+			);
+
+			const clipboard = editor.plugins.get( 'clipboard/clipboard' );
+
+			clipboard.fire( 'inputTransformation', {
+				content: parseView( '<ul><li>X<ul><li>Y</li></ul></li></ul>' )
+			} );
+
+			expect( getModelData( modelDoc ) ).to.equal(
+				'<listItem indent="0" type="bulleted">A</listItem>' +
+				'<listItem indent="1" type="bulleted">B</listItem>' +
+				'<listItem indent="1" type="bulleted">X</listItem>' +
+				'<listItem indent="2" type="bulleted">Y[]</listItem>' +
+				'<listItem indent="2" type="bulleted">C</listItem>'
+			);
+		} );
+
+		it( 'should create correct model when list items are pasted in top-level list', () => {
+			setModelData( modelDoc,
+				'<listItem type="bulleted" indent="0">A[]</listItem>' +
+				'<listItem type="bulleted" indent="1">B</listItem>'
+			);
+
+			const clipboard = editor.plugins.get( 'clipboard/clipboard' );
+
+			clipboard.fire( 'inputTransformation', {
+				content: parseView( '<ul><li>X<ul><li>Y</li></ul></li></ul>' )
+			} );
+
+			expect( getModelData( modelDoc ) ).to.equal(
+				'<listItem indent="0" type="bulleted">AX</listItem>' +
+				'<listItem indent="1" type="bulleted">Y[]</listItem>' +
+				'<listItem indent="1" type="bulleted">B</listItem>'
+			);
+		} );
+
+		it( 'should create correct model when list items are pasted in non-list context', () => {
+			setModelData( modelDoc,
+				'<paragraph>A[]</paragraph>' +
+				'<paragraph>B</paragraph>'
+			);
+
+			const clipboard = editor.plugins.get( 'clipboard/clipboard' );
+
+			clipboard.fire( 'inputTransformation', {
+				content: parseView( '<ul><li>X<ul><li>Y</li></ul></li></ul>' )
+			} );
+
+			expect( getModelData( modelDoc ) ).to.equal(
+				'<paragraph>AX</paragraph>' +
+				'<listItem indent="0" type="bulleted">Y[]</listItem>' +
+				'<paragraph>B</paragraph>'
+			);
+		} );
+	} );
+
 	describe( 'other', () => {
 		it( 'model insert converter should not fire if change was already consumed', () => {
 			editor.editing.modelToView.on( 'insert:listItem', ( evt, data, consumable ) => {