Browse Source

Feature: Introduced `UpcastConversionApi#getSplitParts`.

Szymon Cofalik 6 years ago
parent
commit
c01eba8d1f

+ 117 - 30
packages/ckeditor5-engine/src/conversion/upcastdispatcher.js

@@ -108,15 +108,14 @@ export default class UpcastDispatcher {
 	 */
 	constructor( conversionApi = {} ) {
 		/**
-		 * List of elements that will be checked after conversion process and if element in the list will be empty it
-		 * will be removed from conversion result.
+		 * List of the elements that were created during splitting.
 		 *
-		 * After conversion process list is cleared.
+		 * After conversion process the list is cleared.
 		 *
-		 * @protected
-		 * @type {Set<module:engine/model/element~Element>}
+		 * @private
+		 * @type {Map.<module:engine/model/element~Element,Array.<module:engine/model/element~Element>>}
 		 */
-		this._removeIfEmpty = new Set();
+		this._splitParts = new Map();
 
 		/**
 		 * Position in the temporary structure where the converted content is inserted. The structure reflect the context of
@@ -140,6 +139,7 @@ export default class UpcastDispatcher {
 		this.conversionApi.convertItem = this._convertItem.bind( this );
 		this.conversionApi.convertChildren = this._convertChildren.bind( this );
 		this.conversionApi.splitToAllowedParent = this._splitToAllowedParent.bind( this );
+		this.conversionApi.getSplitParts = this._getSplitParts.bind( this );
 	}
 
 	/**
@@ -176,15 +176,15 @@ export default class UpcastDispatcher {
 		// Do the conversion.
 		const { modelRange } = this._convertItem( viewItem, this._modelCursor );
 
-		// Conversion result is always a document fragment so let's create this fragment.
+		// Conversion result is always a document fragment so let's create it.
 		const documentFragment = writer.createDocumentFragment();
 
 		// When there is a conversion result.
 		if ( modelRange ) {
-			// Remove all empty elements that was added to #_removeIfEmpty list.
+			// Remove all empty elements that were create while splitting.
 			this._removeEmptyElements();
 
-			// Move all items that was converted to context tree to document fragment.
+			// Move all items that were converted in context tree to the document fragment.
 			for ( const item of Array.from( this._modelCursor.parent.getChildren() ) ) {
 				writer.append( item, documentFragment );
 			}
@@ -196,8 +196,8 @@ export default class UpcastDispatcher {
 		// Clear context position.
 		this._modelCursor = null;
 
-		// Clear split elements.
-		this._removeIfEmpty.clear();
+		// Clear split elements lists.
+		this._splitParts.clear();
 
 		// Clear conversion API.
 		this.conversionApi.writer = null;
@@ -283,14 +283,17 @@ export default class UpcastDispatcher {
 		// Split element to allowed parent.
 		const splitResult = this.conversionApi.writer.split( modelCursor, allowedParent );
 
-		// Remember all elements that are created as a result of split.
-		// This is important because at the end of conversion we want to remove all empty split elements.
-		//
-		// Loop through positions between elements in range (except split result position) and collect parents.
-		// <notSplit><split1><split2>[pos]</split2>[pos]</split1>[omit]<split1>[pos]<split2>[pos]</split2></split1></notSplit>
-		for ( const position of splitResult.range.getPositions() ) {
-			if ( !position.isEqual( splitResult.position ) ) {
-				this._removeIfEmpty.add( position.parent );
+		const stack = [];
+
+		for ( const treeWalkerValue of splitResult.range.getWalker() ) {
+			if ( treeWalkerValue.type == 'elementEnd' ) {
+				stack.push( treeWalkerValue.item );
+			} else {
+				// There should not be any text nodes after the element is split, so the only other value is `elementStart`.
+				const originalElement = stack.pop();
+				const splitElement = treeWalkerValue.item;
+
+				this._registerSplitPair( originalElement, splitElement );
 			}
 		}
 
@@ -301,25 +304,64 @@ export default class UpcastDispatcher {
 	}
 
 	/**
-	 * Checks if {@link #_removeIfEmpty} contains empty elements and remove them.
-	 * We need to do it smart because there could be elements that are not empty because contains
-	 * other empty elements and after removing its children they become available to remove.
-	 * We need to continue iterating over split elements as long as any element will be removed.
+	 * Registers that `split` element is a split part of the `original` element.
+	 *
+	 * Data set by this method is then used by {@link #_getSplitParts} and {@link #_removeEmptyElements}
+	 *
+	 * @private
+	 * @param {module:engine/model/element~Element} original The original element.
+	 * @param {module:engine/model/element~Element} split The split part element.
+	 */
+	_registerSplitPair( original, split ) {
+		if ( !this._splitParts.has( original ) ) {
+			this._splitParts.set( original, [ original ] );
+		}
+
+		const list = this._splitParts.get( original );
+
+		this._splitParts.set( split, list );
+		list.push( split );
+	}
+
+	/**
+	 * @private
+	 * @see module:engine/conversion/upcastdispatcher~UpcastConversionApi#getSplitParts
+	 */
+	_getSplitParts( element ) {
+		let parts;
+
+		if ( !this._splitParts.has( element ) ) {
+			parts = [ element ];
+		} else {
+			parts = this._splitParts.get( element );
+		}
+
+		parts.last = parts[ parts.length - 1 ];
+
+		return parts;
+	}
+
+	/**
+	 * Checks if there are any empty elements created while splitting and removes them.
+	 *
+	 * This method works recursively to re-check empty elements again after at least one element was removed in the initial call,
+	 * as some elements might have become empty after other empty elements were removed from them.
 	 *
 	 * @private
 	 */
 	_removeEmptyElements() {
-		let removed = false;
+		let anyRemoved = false;
 
-		for ( const element of this._removeIfEmpty ) {
+		for ( const element of this._splitParts.keys() ) {
 			if ( element.isEmpty ) {
 				this.conversionApi.writer.remove( element );
-				this._removeIfEmpty.delete( element );
-				removed = true;
+				this._splitParts.delete( element );
+
+				anyRemoved = true;
 			}
 		}
 
-		if ( removed ) {
+		if ( anyRemoved ) {
 			this._removeEmptyElements();
 		}
 	}
@@ -408,7 +450,7 @@ function extractMarkersFromModelFragment( modelItem, writer ) {
 	return markers;
 }
 
-// Creates model fragment according to given context and returns position in top element.
+// Creates model fragment according to given context and returns position in the bottom (the deepest) element.
 function createContextTree( contextDefinition, writer ) {
 	let position;
 
@@ -465,7 +507,7 @@ function createContextTree( contextDefinition, writer ) {
  * @fires module:engine/conversion/upcastdispatcher~UpcastDispatcher#event:element
  * @fires module:engine/conversion/upcastdispatcher~UpcastDispatcher#event:text
  * @fires module:engine/conversion/upcastdispatcher~UpcastDispatcher#event:documentFragment
- * @param {module:engine/view/item~Item} viewItem Item to convert.
+ * @param {module:engine/view/item~Item} viewItem Element which children should be converted.
  * @param {module:engine/model/position~Position} modelCursor Position of conversion.
  * @returns {Object} result Conversion result.
  * @returns {module:engine/model/range~Range} result.modelRange Model range containing results of conversion of all children of given item.
@@ -504,6 +546,51 @@ function createContextTree( contextDefinition, writer ) {
  * continue conversion. When element is not defined it means that there was no split.
  */
 
+/**
+ * Returns all the split parts of given `element` that were created during upcasting through using {@link #splitToAllowedParent}.
+ * It enables you to easily track those elements and continue processing them after they are split during their children conversion.
+ *
+ *		<paragraph>Foo<image />bar<image />baz</paragraph> ->
+ *		<paragraph>Foo</paragraph><image /><paragraph>bar</paragraph><image /><paragraph>baz</paragraph>
+ *
+ * For a reference to any of above paragraphs, the function will return all three paragraphs (the original element included),
+ * sorted in the order of their creation (the original element is the first one).
+ *
+ * The function returns `Array` with elements. Additionally, `.last` property is set on the array for an easy access to the last split part.
+ *
+ * If given `element` was not split, an array with single element is returned.
+ *
+ * Example of a usage in a converter code:
+ *
+ *		const myElement = conversionApi.writer.createElement( 'myElement' );
+ *		// ...
+ *		conversionApi.convertChildren( myElement, modelCursor );
+ *		// ...
+ *		const splitParts = conversionApi.getSplitParts( myElement );
+ *
+ *		// Additional processing on all the parts after the children are converted and the original element might be split.
+ *		for ( const element of splitParts ) {
+ *			// ...
+ *		}
+ *
+ *		// Setting `data.modelRange` basing on split parts:
+ *		data.modelRange = conversionApi.writer.createRange(
+ *			conversionApi.writer.createPositionBefore( myElement ),
+ *			conversionApi.writer.createPositionAfter( splitParts.last )
+ *		);
+ *
+ *		// Setting `data.modelCursor` to continue after the last split element:
+ *		data.modelCursor = conversionApi.writer.createPositionAfter( splitParts.last );
+ *
+ * **Tip:** if you are unable to get a reference to the original element (for example because the code is split into multiple converters
+ * or even classes) but it was already converted, you might want to check first element in `data.modelRange`. This is a common situation
+ * if an attribute converter is separated from an element converter.
+ *
+ * @method #getSplitParts
+ * @param {module:engine/model/element~Element} element
+ * @returns {Array.<module:engine/model/element~Element>}
+ */
+
 /**
  * Stores information about what parts of processed view item are still waiting to be handled. After a piece of view item
  * was converted, appropriate consumable value should be {@link module:engine/conversion/viewconsumable~ViewConsumable#consume consumed}.

+ 134 - 52
packages/ckeditor5-engine/tests/conversion/upcastdispatcher.js

@@ -5,6 +5,7 @@
 
 import UpcastDispatcher from '../../src/conversion/upcastdispatcher';
 import ViewContainerElement from '../../src/view/containerelement';
+import ViewElement from '../../src/view/element';
 import ViewDocumentFragment from '../../src/view/documentfragment';
 import ViewText from '../../src/view/text';
 
@@ -38,10 +39,12 @@ describe( 'UpcastDispatcher', () => {
 			expect( dispatcher.conversionApi ).to.have.property( 'splitToAllowedParent' ).that.is.instanceof( Function );
 		} );
 
-		it( 'should have properties', () => {
+		it( 'should not crash if no additional api is passed', () => {
 			const dispatcher = new UpcastDispatcher();
 
-			expect( dispatcher._removeIfEmpty ).to.instanceof( Set );
+			expect( dispatcher.conversionApi ).to.have.property( 'convertItem' ).that.is.instanceof( Function );
+			expect( dispatcher.conversionApi ).to.have.property( 'convertChildren' ).that.is.instanceof( Function );
+			expect( dispatcher.conversionApi ).to.have.property( 'splitToAllowedParent' ).that.is.instanceof( Function );
 		} );
 	} );
 
@@ -49,10 +52,10 @@ describe( 'UpcastDispatcher', () => {
 		let dispatcher;
 
 		beforeEach( () => {
-			dispatcher = new UpcastDispatcher();
+			dispatcher = new UpcastDispatcher( { schema: model.schema } );
 		} );
 
-		it( 'should create api for current conversion process', () => {
+		it( 'should create api for a conversion process', () => {
 			const viewElement = new ViewContainerElement( 'p', null, new ViewText( 'foobar' ) );
 
 			// To be sure that both converters was called.
@@ -64,13 +67,11 @@ describe( 'UpcastDispatcher', () => {
 			// Conversion process properties should be undefined/empty before conversion.
 			expect( dispatcher.conversionApi.writer ).to.not.ok;
 			expect( dispatcher.conversionApi.store ).to.not.ok;
-			expect( dispatcher._removeIfEmpty.size ).to.equal( 0 );
 
 			dispatcher.on( 'element', ( evt, data, conversionApi ) => {
 				// Check conversion api params.
 				expect( conversionApi.writer ).to.instanceof( ModelWriter );
 				expect( conversionApi.store ).to.deep.equal( {} );
-				expect( dispatcher._removeIfEmpty.size ).to.equal( 0 );
 
 				// Remember writer to check in next converter that is exactly the same instance (the same undo step).
 				writer = conversionApi.writer;
@@ -78,9 +79,6 @@ describe( 'UpcastDispatcher', () => {
 				// Add some data to conversion storage to verify them in next converter.
 				conversionApi.store.foo = 'bar';
 
-				// Add empty element and mark as a split result to check in next converter.
-				dispatcher._removeIfEmpty.add( conversionApi.writer.createElement( 'paragraph' ) );
-
 				// Convert children - this will call second converter.
 				conversionApi.convertChildren( data.viewItem, data.modelCursor );
 
@@ -94,9 +92,6 @@ describe( 'UpcastDispatcher', () => {
 				// Data set by previous converter are remembered.
 				expect( conversionApi.store ).to.deep.equal( { foo: 'bar' } );
 
-				// Split element is remembered as well.
-				expect( dispatcher._removeIfEmpty.size ).to.equal( 1 );
-
 				spy();
 			} );
 
@@ -108,7 +103,6 @@ describe( 'UpcastDispatcher', () => {
 			// Conversion process properties should be cleared after conversion.
 			expect( dispatcher.conversionApi.writer ).to.not.ok;
 			expect( dispatcher.conversionApi.store ).to.not.ok;
-			expect( dispatcher._removeIfEmpty.size ).to.equal( 0 );
 		} );
 
 		it( 'should fire viewCleanup event on converted view part', () => {
@@ -248,58 +242,50 @@ describe( 'UpcastDispatcher', () => {
 		} );
 
 		it( 'should remove empty elements that was created as a result of split', () => {
-			const viewElement = new ViewContainerElement( 'p' );
+			const viewElement = new ViewElement( 'div', null, [
+				new ViewElement( 'p', null, [
+					new ViewElement( 'img' )
+				] )
+			] );
 
-			// To be sure that converter was called.
-			const spy = sinon.spy();
+			model.schema.register( 'div', { allowIn: '$root' } );
+			model.schema.register( 'p', { allowIn: 'div' } );
+			model.schema.register( 'image', { allowIn: '$root' } );
 
-			dispatcher.on( 'element', ( evt, data, conversionApi ) => {
-				// First let's convert target element.
-				const paragraph = conversionApi.writer.createElement( 'paragraph' );
-				conversionApi.writer.insert( paragraph, data.modelCursor );
+			dispatcher.on( 'element:img', ( evt, data, conversionApi ) => {
+				const writer = conversionApi.writer;
 
-				// Then add some elements and mark as split.
+				const modelElement = writer.createElement( 'image' );
+				const splitResult = conversionApi.splitToAllowedParent( modelElement, data.modelCursor );
+				writer.insert( modelElement, splitResult.position );
 
-				// Create and insert empty split element before target element.
-				const emptySplit = conversionApi.writer.createElement( 'paragraph' );
-				conversionApi.writer.insert( emptySplit, ModelPosition._createAfter( paragraph ) );
+				data.modelRange = writer.createRangeOn( modelElement );
+				data.modelCursor = writer.createPositionAt( splitResult.cursorParent, 0 );
 
-				// Create and insert not empty split after target element.
-				const notEmptySplit = conversionApi.writer.createElement( 'paragraph' );
-				conversionApi.writer.appendText( 'foo', notEmptySplit );
-				conversionApi.writer.insert( notEmptySplit, ModelPosition._createAfter( emptySplit ) );
+				// Prevent below converter to fire.
+				evt.stop();
+			}, { priority: 'high' } );
 
-				// Create and insert split with other split inside (both should be removed)
-				const outerSplit = conversionApi.writer.createElement( 'paragraph' );
-				const innerSplit = conversionApi.writer.createElement( 'paragraph' );
-				conversionApi.writer.append( innerSplit, outerSplit );
-				conversionApi.writer.insert( outerSplit, ModelPosition._createBefore( paragraph ) );
+			dispatcher.on( 'element', ( evt, data, conversionApi ) => {
+				const writer = conversionApi.writer;
 
-				dispatcher._removeIfEmpty.add( emptySplit );
-				dispatcher._removeIfEmpty.add( notEmptySplit );
-				dispatcher._removeIfEmpty.add( outerSplit );
-				dispatcher._removeIfEmpty.add( innerSplit );
+				const modelElement = writer.createElement( data.viewItem.name );
+				writer.insert( modelElement, data.modelCursor );
 
-				data.modelRange = ModelRange._createOn( paragraph );
-				data.modelCursor = data.modelRange.end;
+				const result = conversionApi.convertChildren( data.viewItem, writer.createPositionAt( modelElement, 0 ) );
 
-				// We have the following result:
-				// <p><p></p></p>[<p></p>]<p></p><p>foo</p>
-				// Everything out of selected range is a result of the split.
-
-				spy();
+				data.modelRange = writer.createRange(
+					writer.createPositionBefore( modelElement ),
+					conversionApi.writer.createPositionAfter( result.modelCursor.parent )
+				);
+				data.modelCursor = data.modelRange.end;
 			} );
 
 			const result = model.change( writer => dispatcher.convert( viewElement, writer ) );
 
-			// Empty split elements should be removed and we should have the following result:
-			// [<p></p>]<p>foo</p>
-			expect( result.childCount ).to.equal( 2 );
-			expect( result.getChild( 0 ).name ).to.equal( 'paragraph' );
-			expect( result.getChild( 0 ).childCount ).to.equal( 0 );
-			expect( result.getChild( 1 ).name ).to.equal( 'paragraph' );
-			expect( result.getChild( 1 ).childCount ).to.equal( 1 );
-			expect( result.getChild( 1 ).getChild( 0 ).data ).to.equal( 'foo' );
+			// After splits `div` and `p` are empty so they should be removed.
+			expect( result.childCount ).to.equal( 1 );
+			expect( result.getChild( 0 ).name ).to.equal( 'image' );
 		} );
 
 		it( 'should extract temporary markers elements from converter element and create static markers list', () => {
@@ -628,5 +614,101 @@ describe( 'UpcastDispatcher', () => {
 				sinon.assert.calledOnce( spy );
 			} );
 		} );
+
+		describe( 'getSplitParts()', () => {
+			it( 'should return an array containing only passed element if the element has not been split', () => {
+				model.schema.register( 'paragraph', { allowIn: '$root' } );
+
+				const spy = sinon.spy();
+
+				dispatcher.on( 'element', ( evt, data, conversionApi ) => {
+					const modelElement = conversionApi.writer.createElement( 'paragraph' );
+					const parts = conversionApi.getSplitParts( modelElement );
+
+					expect( parts ).to.deep.equal( [ modelElement ] );
+					expect( parts.last ).to.equal( modelElement );
+
+					spy();
+
+					// Overwrite converters specified in `beforeEach`.
+					evt.stop();
+				}, { priority: 'high' } );
+
+				const viewElement = new ViewElement( 'p' );
+
+				model.change( writer => dispatcher.convert( viewElement, writer ) );
+
+				expect( spy.called ).to.be.true;
+			} );
+
+			it( 'should return all parts of the split element', () => {
+				model.schema.register( 'paragraph', { allowIn: '$root' } );
+				model.schema.register( 'text', { allowIn: 'paragraph' } );
+				model.schema.register( 'image', { allowIn: '$root' } );
+
+				dispatcher.on( 'text', ( evt, data, conversionApi ) => {
+					const modelText = conversionApi.writer.createText( data.viewItem.data );
+
+					conversionApi.writer.insert( modelText, data.modelCursor );
+
+					data.modelRange = conversionApi.writer.createRangeOn( modelText );
+					data.modelCursor = data.modelRange.end;
+
+					// Overwrite converters specified in `beforeEach`.
+					evt.stop();
+				}, { priority: 'high' } );
+
+				dispatcher.on( 'element:image', ( evt, data, conversionApi ) => {
+					const modelElement = conversionApi.writer.createElement( 'image' );
+
+					const splitResult = conversionApi.splitToAllowedParent( modelElement, data.modelCursor );
+
+					conversionApi.writer.insert( modelElement, splitResult.position );
+
+					data.modelRange = conversionApi.writer.createRangeOn( modelElement );
+					data.modelCursor = conversionApi.writer.createPositionAt( splitResult.cursorParent, 0 );
+
+					// Overwrite converters specified in `beforeEach`.
+					evt.stop();
+				}, { priority: 'high' } );
+
+				const spy = sinon.spy();
+
+				dispatcher.on( 'element:p', ( evt, data, conversionApi ) => {
+					const modelElement = conversionApi.writer.createElement( 'paragraph' );
+
+					conversionApi.writer.insert( modelElement, data.modelCursor );
+					conversionApi.convertChildren( data.viewItem, conversionApi.writer.createPositionAt( modelElement, 0 ) );
+
+					const parts = conversionApi.getSplitParts( modelElement );
+
+					expect( parts.length ).to.equal( 3 );
+
+					expect( parts[ 0 ].getChild( 0 ).data ).to.equal( 'foo' );
+					expect( parts[ 1 ].getChild( 0 ).data ).to.equal( 'bar' );
+					expect( parts[ 2 ].getChild( 0 ).data ).to.equal( 'xyz' );
+
+					expect( parts[ 0 ] ).to.equal( modelElement );
+					expect( parts.last ).to.equal( parts[ 2 ] );
+
+					spy();
+
+					// Overwrite converters specified in `beforeEach`.
+					evt.stop();
+				}, { priority: 'high' } );
+
+				const viewElement = new ViewElement( 'p', null, [
+					new ViewText( 'foo' ),
+					new ViewElement( 'image' ),
+					new ViewText( 'bar' ),
+					new ViewElement( 'image' ),
+					new ViewText( 'xyz' )
+				] );
+
+				model.change( writer => dispatcher.convert( viewElement, writer, [ '$root' ] ) );
+
+				expect( spy.called ).to.be.true;
+			} );
+		} );
 	} );
 } );