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

Added: Rename change type conversion.

Szymon Cofalik 9 лет назад
Родитель
Сommit
2c42a3e4f1

+ 33 - 1
packages/ckeditor5-engine/src/conversion/model-to-view-converters.js

@@ -4,6 +4,7 @@
  */
 
 import ModelRange from '../model/range.js';
+import ModelElement from '../model/element.js';
 
 import ViewElement from '../view/element.js';
 import ViewText from '../view/text.js';
@@ -295,7 +296,7 @@ export function move() {
 }
 
 /**
- * Function factory, creates a default model-to-view converter for nodes remove changes.
+ * Function factory, creates a default model-to-view converter for node remove changes.
  *
  *		modelDispatcher.on( 'remove', remove() );
  *
@@ -316,6 +317,37 @@ export function remove() {
 	};
 }
 
+/**
+ * Function factory, creates default model-to-view converter for elements which name has changed.
+ *
+ *		modelDispatcher.on( 'rename', rename() );
+ *
+ * This converter enables default behavior of rename conversion implemented in {@link engine.conversion.ModelConversionDispatcher}.
+ * It exposes fake model element `fakeElement` and provides proper model-view bindings between original and fake model elements
+ * and their view counterparts.
+ *
+ * @external engine.conversion.modelToView
+ * @function engine.conversion.modelToView.rename
+ * @returns {Function}
+ */
+export function rename() {
+	return ( evt, data, consumable, conversionApi ) => {
+		if ( consumable.test( data.element, 'rename' ) ) {
+			// Create fake element in model, needed for conversion purposes.
+			const fakeElement = new ModelElement( data.oldName, data.element.getAttributes() );
+			// Append the fake element to artificial document fragment to enable making range on it.
+			data.element.parent.insertChildren( data.element.index, fakeElement );
+
+			// Rebind view element to the fake element.
+			// Check what was bound to renamed element.
+			const oldViewElement = conversionApi.mapper.toViewElement( data.element );
+			// Unbind renamed element.
+			conversionApi.mapper.unbindModelElement( data.element );
+			// Bind view element to the fake element.
+			conversionApi.mapper.bindElements( fakeElement, oldViewElement );
+
+			data.fakeElement = fakeElement;
+		}
 	};
 }
 

+ 70 - 0
packages/ckeditor5-engine/src/conversion/modelconversiondispatcher.js

@@ -126,6 +126,7 @@ export default class ModelConversionDispatcher {
 	 * @fires engine.conversion.ModelConversionDispatcher#insert
 	 * @fires engine.conversion.ModelConversionDispatcher#move
 	 * @fires engine.conversion.ModelConversionDispatcher#remove
+	 * @fires engine.conversion.ModelConversionDispatcher#rename
 	 * @fires engine.conversion.ModelConversionDispatcher#addAttribute
 	 * @fires engine.conversion.ModelConversionDispatcher#removeAttribute
 	 * @fires engine.conversion.ModelConversionDispatcher#changeAttribute
@@ -147,6 +148,8 @@ export default class ModelConversionDispatcher {
 			this.convertRemove( data.sourcePosition, data.range );
 		} else if ( type == 'addAttribute' || type == 'removeAttribute' || type == 'changeAttribute' ) {
 			this.convertAttribute( type, data.range, data.key, data.oldValue, data.newValue );
+		} else if ( type == 'rename' ) {
+			this.convertRename( data.element, data.oldName );
 		}
 	}
 
@@ -267,6 +270,50 @@ export default class ModelConversionDispatcher {
 		}
 	}
 
+	/**
+	 * Fires events needed to handle rename conversion.
+	 *
+	 * Renaming is handled by:
+	 * * firing insertion conversion - for a new element (renamed clone of old element),
+	 * * firing move conversion - moving children of old element to new element,
+	 * * firing remove conversion - removing old element.
+	 *
+	 * This way two goals are achieved:
+	 * * no need to provide additional converters for renaming,
+	 * * contents of renamed element do not have to be re-rendered.
+	 *
+	 * @fires engine.conversion.ModelConversionDispatcher#event:rename
+	 * @fires engine.conversion.ModelConversionDispatcher#event:insert
+	 * @fires engine.conversion.ModelConversionDispatcher#event:move
+	 * @fires engine.conversion.ModelConversionDispatcher#event:remove
+	 * @param {engine.view.Element} element Renamed element.
+	 * @param {String} oldName Name of the renamed element before it was renamed.
+	 */
+	convertRename( element, oldName ) {
+		const consumable = new Consumable();
+		consumable.add( element, 'rename' );
+
+		// Callbacks added to `rename` event should either provide totally custom handling of rename conversion
+		// or create "fake element" in model that is needed to fire default rename conversion.
+		const data = { element, oldName };
+		this.fire( 'rename:' + element.name + ':' + oldName, data, consumable, this.conversionApi );
+
+		// Default callback should provide `data.fakeElement` model element that is needed for firing conversion.
+		if ( data.fakeElement ) {
+			const insertRange = Range.createFromParentsAndOffsets( element.parent, element.startOffset, element, 0 );
+			const moveSourcePosition = Position.createAt( data.fakeElement, 0 );
+			const moveRange = Range.createIn( data.element );
+			const removeRange = Range.createOn( data.fakeElement );
+
+			this.convertInsertion( insertRange );
+			this.convertMove( moveSourcePosition, moveRange );
+			this.convertRemove( removeRange.start, removeRange );
+
+			// Cleanup.
+			data.fakeElement.remove();
+		}
+	}
+
 	/**
 	 * Fires events for given {@link engine.model.Selection selection} to start selection conversion.
 	 *
@@ -427,6 +474,29 @@ export default class ModelConversionDispatcher {
 	 * @param {Object} conversionApi Conversion interface to be used by callback, passed in `ModelConversionDispatcher` constructor.
 	 */
 
+	/**
+	 * Fired for renamed element.
+	 *
+	 * **Note:** rename event is a specific event because it requires to have a specific callback. The callback should either
+	 * consume value from `consumable` (and provide all renaming functionality) or add `output` object to `data` object
+	 * with following values:
+	 * * `insertRange` - range in model containing renamed element only (without children),
+	 * * `moveRange` - range in model containing renamed element children only (without renamed element),
+	 * * `moveSourcePosition` - range in model in empty, fake element,
+	 * * `removeRange` - range in model containing the fake element.
+	 *
+	 * For model to view conversion, {@link engine.conversion.modelToView.rename default rename converter} handles
+	 * creating fake element, filling `data` with values and providing correct binding between elements. Use that
+	 * callback as the default callback for renaming.
+	 *
+	 * @event engine.conversion.ModelConversionDispatcher.rename
+	 * @param {Object} data Additional information about the change.
+	 * @param {engine.model.Element} data.element Renamed element.
+	 * @param {String} data.oldName Old name of the renamed element.
+	 * @param {engine.conversion.ModelConsumable} consumable Values to consume.
+	 * @param {Object} conversionApi Conversion interface to be used by callback, passed in `ModelConversionDispatcher` constructor.
+	 */
+
 	/**
 	 * Fired when attribute has been added on a node.
 	 *

+ 2 - 1
packages/ckeditor5-engine/src/editingcontroller.js

@@ -6,7 +6,7 @@
 import ViewDocument from './view/document.js';
 import Mapper from './conversion/mapper.js';
 import ModelConversionDispatcher from './conversion/modelconversiondispatcher.js';
-import { insertText, remove, move } from './conversion/model-to-view-converters.js';
+import { insertText, remove, move, rename } from './conversion/model-to-view-converters.js';
 import { convertSelectionChange } from './conversion/view-selection-to-model-converters.js';
 import {
 	convertRangeSelection,
@@ -101,6 +101,7 @@ export default class EditingController {
 		this.modelToView.on( 'insert:$text', insertText(), 'lowest' );
 		this.modelToView.on( 'remove', remove(), 'low' );
 		this.modelToView.on( 'move', move(), 'low' );
+		this.modelToView.on( 'rename', rename(), 'low' );
 
 		// Attach default selection converters.
 		this.modelToView.on( 'selection', clearAttributes(), 'low' );

+ 7 - 5
packages/ckeditor5-engine/src/model/liverange.js

@@ -88,11 +88,15 @@ function bindWithDocument() {
 		this.root.document,
 		'change',
 		( event, type, changes ) => {
-			transform.call( this, type, changes.range, changes.sourcePosition );
+			if ( supportedTypes.has( type ) ) {
+				transform.call( this, type, changes.range, changes.sourcePosition );
+			}
 		}
 	);
 }
 
+const supportedTypes = new Set( [ 'insert', 'move', 'remove', 'reinsert' ] );
+
 /**
  * Updates this range accordingly to the updates applied to the model. Bases on change events.
  *
@@ -146,10 +150,8 @@ function transform( type, range, position ) {
 			break;
 	}
 
-	if ( updated ) {
-		this.start = updated.start;
-		this.end = updated.end;
-	}
+	this.start = updated.start;
+	this.end = updated.end;
 }
 
 mix( LiveRange, EmitterMixin );

+ 81 - 1
packages/ckeditor5-engine/tests/conversion/model-to-view-converters.js

@@ -27,7 +27,8 @@ import {
 	wrap,
 	unwrap,
 	move,
-	remove
+	remove,
+	rename
 } from '/ckeditor5/engine/conversion/model-to-view-converters.js';
 
 import { createRangeOnElementOnly } from '/tests/engine/model/_utils/utils.js';
@@ -463,3 +464,82 @@ describe( 'remove', () => {
 		expect( viewToString( viewRoot ) ).to.equal( '<div><div>கு</div></div>' );
 	} );
 } );
+
+describe( 'rename', () => {
+	const oldName = 'oldName';
+	const newName = 'newName';
+
+	let element, converters;
+
+	beforeEach( () => {
+		converters = {
+			insertText: insertText(),
+			insert:	insertElement( ( data ) => new ViewContainerElement( data.item.name ) ),
+			move: move(),
+			remove: remove(),
+			rename: rename()
+		};
+
+		sinon.spy( converters, 'insert' );
+		sinon.spy( converters, 'move' );
+		sinon.spy( converters, 'remove' );
+
+		element = new ModelElement( oldName, null, new ModelText( 'foo' ) );
+		modelRoot.appendChildren( element );
+
+		dispatcher.on( 'insert:$text', converters.insertText );
+		dispatcher.on( 'insert', converters.insert );
+		dispatcher.on( 'move', converters.move );
+		dispatcher.on( 'remove', converters.remove );
+		dispatcher.on( 'rename', converters.rename );
+
+		dispatcher.convertInsertion( ModelRange.createOn( element ) );
+
+		element.name = newName;
+	} );
+
+	afterEach( () => {
+		converters.insert.restore();
+		converters.move.restore();
+		converters.remove.restore();
+	} );
+
+	it( 'should unbind renamed element from view, add fake element to model and bind it with proper view element', () => {
+		const viewElement = viewRoot.getChild( 0 );
+
+		dispatcher.on( 'rename', ( evt, data, consumable, conversionApi ) => {
+			expect( data.fakeElement ).to.be.instanceof( ModelElement );
+			expect( data.fakeElement.parent ).to.equal( data.element.parent );
+
+			expect( conversionApi.mapper.toViewElement( data.element ) ).to.be.undefined;
+			expect( conversionApi.mapper.toViewElement( data.fakeElement ) ).to.equal( viewElement );
+		} );
+
+		dispatcher.convertRename( element, oldName );
+	} );
+
+	it( 'should enable default rename conversion, that uses already registered callbacks', () => {
+		expect( viewRoot.getChild( 0 ).name ).to.equal( 'oldName' );
+		dispatcher.convertRename( element, oldName );
+
+		// Called twice, first time when renamed element was originally inserted to model and converted to view.
+		expect( converters.insert.calledTwice ).to.be.true;
+		expect( converters.move.calledOnce ).to.be.true;
+		expect( converters.remove.calledOnce ).to.be.true;
+
+		expect( viewRoot.getChild( 0 ).name ).to.equal( 'newName' );
+		expect( viewRoot.getChild( 0 ).getChild( 0 ).data ).to.equal( 'foo' );
+	} );
+
+	it( 'should not execute if converted value was already consumed', () => {
+		dispatcher.on( 'rename', ( evt, data, consumable ) => {
+			consumable.consume( data.element, 'rename' );
+		}, 'high' );
+
+		dispatcher.on( 'rename', ( evt, data ) => {
+			expect( data.fakeElement ).to.be.undefined;
+		} );
+
+		dispatcher.convertRename( element, oldName );
+	} );
+} );

+ 54 - 0
packages/ckeditor5-engine/tests/conversion/modelconversiondispatcher.js

@@ -7,6 +7,7 @@
 
 import ModelConversionDispatcher from '/ckeditor5/engine/conversion/modelconversiondispatcher.js';
 import ModelDocument from '/ckeditor5/engine/model/document.js';
+import ModelDocumentFragment from '/ckeditor5/engine/model/documentfragment.js';
 import ModelText from '/ckeditor5/engine/model/text.js';
 import ModelElement from '/ckeditor5/engine/model/element.js';
 import ModelRange from '/ckeditor5/engine/model/range.js';
@@ -111,6 +112,16 @@ describe( 'ModelConversionDispatcher', () => {
 			expect( cbRemove.called );
 		} );
 
+		it( 'should fire rename callback for rename changes', () => {
+			const cbRename = sinon.spy();
+
+			dispatcher.on( 'rename', cbRename );
+
+			doc.batch().rename( 'figure', image );
+
+			expect( cbRename.called );
+		} );
+
 		it( 'should fire addAttribute callbacks for add attribute change', () => {
 			const cbAddText = sinon.spy();
 			const cbAddImage = sinon.spy();
@@ -313,6 +324,49 @@ describe( 'ModelConversionDispatcher', () => {
 		} );
 	} );
 
+	describe( 'convertRename', () => {
+		it( 'should fire rename event with correct name, consumable, and renamed element and it\'s old name in data', ( done ) => {
+			const oldName = 'oldName';
+			const element = new ModelElement( oldName );
+			element.name = 'newName';
+
+			dispatcher.on( 'rename', ( evt, data, consumable ) => {
+				expect( evt.name ).to.equal( 'rename:newName:oldName' );
+				expect( data.element ).to.equal( element );
+				expect( data.oldName ).to.equal( oldName );
+				expect( consumable.test( data.element, 'rename' ) ).to.be.true;
+
+				done();
+			} );
+
+			dispatcher.convertRename( element, oldName );
+		} );
+
+		it( 'should fire insert, move and remove conversion if fake model element was provided in data object', () => {
+			sinon.spy( dispatcher, 'convertInsertion' );
+			sinon.spy( dispatcher, 'convertMove' );
+			sinon.spy( dispatcher, 'convertRemove' );
+
+			dispatcher.on( 'rename', ( evt, data ) => {
+				const fakeElement = new ModelElement( 'oldName' );
+				const fakeParent = new ModelDocumentFragment();
+				fakeParent.appendChildren( fakeElement );
+
+				data.fakeElement = fakeElement;
+			} );
+
+			const element = new ModelElement( 'newName' );
+			const parent = new ModelDocumentFragment();
+			parent.appendChildren( element );
+
+			dispatcher.convertRename( element, 'oldName' );
+
+			expect( dispatcher.convertInsertion.calledOnce ).to.be.true;
+			expect( dispatcher.convertMove.calledOnce ).to.be.true;
+			expect( dispatcher.convertRemove.calledOnce ).to.be.true;
+		} );
+	} );
+
 	describe( 'convertAttribute', () => {
 		it( 'should fire event for every item in passed range', () => {
 			root.appendChildren( [

+ 12 - 1
packages/ckeditor5-engine/tests/editingcontroller.js

@@ -115,12 +115,13 @@ describe( 'EditingController', () => {
 			viewRoot = editing.createRoot( domRoot );
 
 			model.schema.registerItem( 'paragraph', '$block' );
+			model.schema.registerItem( 'div', '$block' );
 			buildModelConverter().for( editing.modelToView ).fromElement( 'paragraph' ).toElement( 'p' );
+			buildModelConverter().for( editing.modelToView ).fromElement( 'div' ).toElement( 'div' );
 		} );
 
 		beforeEach( () => {
 			// Note: The below code is highly overcomplicated due to #455.
-
 			model.selection.removeAllRanges();
 			modelRoot.removeChildren( 0, modelRoot.childCount );
 
@@ -157,6 +158,16 @@ describe( 'EditingController', () => {
 			expect( getViewData( editing.view ) ).to.equal( '<p>f</p><p>{}oo</p><p></p><p>bar</p>' );
 		} );
 
+		it( 'should convert rename', () => {
+			expect( getViewData( editing.view ) ).to.equal( '<p>f{}oo</p><p></p><p>bar</p>' );
+
+			model.enqueueChanges( () => {
+				model.batch().rename( 'div', modelRoot.getChild( 0 ) );
+			} );
+
+			expect( getViewData( editing.view ) ).to.equal( '<div>f{}oo</div><p></p><p>bar</p>' );
+		} );
+
 		it( 'should convert delete', () => {
 			model.enqueueChanges( () => {
 				model.batch().remove(