Browse Source

Merge branch 'master' into t/675

Piotrek Koszuliński 9 years ago
parent
commit
b373a6ef86
28 changed files with 620 additions and 350 deletions
  1. 1 1
      packages/ckeditor5-engine/.travis.yml
  2. 68 11
      packages/ckeditor5-engine/src/controller/datacontroller.js
  3. 8 8
      packages/ckeditor5-engine/src/model/composer/deletecontents.js
  4. 19 20
      packages/ckeditor5-engine/src/controller/insert.js
  5. 6 6
      packages/ckeditor5-engine/src/model/composer/modifyselection.js
  6. 50 44
      packages/ckeditor5-engine/src/dev-utils/view.js
  7. 0 87
      packages/ckeditor5-engine/src/model/composer/composer.js
  8. 0 10
      packages/ckeditor5-engine/src/model/document.js
  9. 3 3
      packages/ckeditor5-engine/src/view/element.js
  10. 82 0
      packages/ckeditor5-engine/src/view/emptyelement.js
  11. 36 12
      packages/ckeditor5-engine/src/view/writer.js
  12. 87 11
      packages/ckeditor5-engine/tests/controller/datacontroller.js
  13. 16 18
      packages/ckeditor5-engine/tests/model/composer/deletecontents.js
  14. 0 1
      packages/ckeditor5-engine/tests/controller/editingcontroller.js
  15. 7 17
      packages/ckeditor5-engine/tests/controller/insert.js
  16. 1 3
      packages/ckeditor5-engine/tests/model/composer/modifyselection.js
  17. 20 0
      packages/ckeditor5-engine/tests/dev-utils/view.js
  18. 0 96
      packages/ckeditor5-engine/tests/model/composer/composer.js
  19. 0 2
      packages/ckeditor5-engine/tests/model/document/document.js
  20. 65 0
      packages/ckeditor5-engine/tests/view/emptyelement.js
  21. 23 0
      packages/ckeditor5-engine/tests/view/writer/breakAttributes.js
  22. 20 0
      packages/ckeditor5-engine/tests/view/writer/insert.js
  23. 7 0
      packages/ckeditor5-engine/tests/view/writer/mergeAttributes.js
  24. 29 0
      packages/ckeditor5-engine/tests/view/writer/move.js
  25. 20 0
      packages/ckeditor5-engine/tests/view/writer/remove.js
  26. 20 0
      packages/ckeditor5-engine/tests/view/writer/unwrap.js
  27. 19 0
      packages/ckeditor5-engine/tests/view/writer/wrap.js
  28. 13 0
      packages/ckeditor5-engine/tests/view/writer/wrapposition.js

+ 1 - 1
packages/ckeditor5-engine/.travis.yml

@@ -20,7 +20,7 @@ install:
   - npm install @ckeditor/ckeditor5-dev-tests
   - npm install codeclimate-test-reporter
 script:
-  - node_modules/.bin/ckeditor5-dev-tests --coverage
+  - node_modules/.bin/ckeditor5-dev-tests --coverage --ignore-duplicates
 after_success:
   - sed -i.backup 's/\.build\/ckeditor5\/[^\/]*/src/g' .build/coverage/lcov.info
   - node_modules/.bin/codeclimate-test-reporter < .build/coverage/lcov.info

+ 68 - 11
packages/ckeditor5-engine/src/controller/datacontroller.js

@@ -19,7 +19,9 @@ import ViewDocumentFragment from '../view/documentfragment.js';
 import ModelRange from '../model/range.js';
 import ModelPosition from '../model/position.js';
 
-import insert from './insert.js';
+import insertContent from './insertcontent.js';
+import deleteContent from './deletecontent.js';
+import modifySelection from './modifyselection.js';
 
 /**
  * Controller for the data pipeline. The data pipeline controls how data is retrieved from the document
@@ -111,7 +113,9 @@ export default class DataController {
 		this.viewToModel.on( 'element', convertToModelFragment(), { priority: 'lowest' } );
 		this.viewToModel.on( 'documentFragment', convertToModelFragment(), { priority: 'lowest' } );
 
-		this.on( 'insert', ( evt, data ) => insert( this, data.content, data.selection, data.batch ) );
+		this.on( 'insertContent', ( evt, data ) => insertContent( this, data.content, data.selection, data.batch ) );
+		this.on( 'deleteContent', ( evt, data ) => deleteContent( data.selection, data.batch, data.options ) );
+		this.on( 'modifySelection', ( evt, data ) => modifySelection( data.selection, data.options ) );
 	}
 
 	/**
@@ -214,30 +218,83 @@ export default class DataController {
 	destroy() {}
 
 	/**
-	 * See {@link engine.controller.insert}.
+	 * See {@link engine.controller.insertContent}.
 	 *
-	 * @fires engine.controller.DataController#insert
-	 * @param {engine.view.DocumentFragment} content The content to insert.
+	 * @fires engine.controller.DataController#insertContent
+	 * @param {engine.model.DocumentFragment} content The content to insert.
 	 * @param {engine.model.Selection} selection Selection into which the content should be inserted.
 	 * @param {engine.model.Batch} [batch] Batch to which deltas will be added. If not specified, then
 	 * changes will be added to a new batch.
 	 */
-	insert( content, selection, batch ) {
-		this.fire( 'insert', { content, selection, batch } );
+	insertContent( content, selection, batch ) {
+		this.fire( 'insertContent', { content, selection, batch } );
+	}
+
+	/**
+	 * See {@link engine.controller.deleteContent}.
+	 *
+	 * Note: For the sake of predictability, the resulting selection should always be collapsed.
+	 * In cases where a feature wants to modify deleting behavior so selection isn't collapsed
+	 * (e.g. a table feature may want to keep row selection after pressing <kbd>Backspace</kbd>),
+	 * then that behavior should be implemented in the view's listener. At the same time, the table feature
+	 * will need to modify this method's behavior too, e.g. to "delete contents and then collapse
+	 * the selection inside the last selected cell" or "delete the row and collapse selection somewhere near".
+	 * That needs to be done in order to ensure that other features which use `deleteContent()` will work well with tables.
+	 *
+	 * @fires engine.controller.DataController#deleteContent
+	 * @param {engine.model.Selection} selection Selection of which the content should be deleted.
+	 * @param {engine.model.Batch} batch Batch to which deltas will be added.
+	 * @param {Object} options See {@link engine.controller.deleteContent}'s options.
+	 */
+	deleteContent( selection, batch, options ) {
+		this.fire( 'deleteContent', { batch, selection, options } );
+	}
+
+	/**
+	 * See {@link engine.controller.modifySelection}.
+	 *
+	 * @fires engine.controller.DataController#modifySelection
+	 * @param {engine.model.Selection} The selection to modify.
+	 * @param {Object} options See {@link engine.controller.modifySelection}'s options.
+	 */
+	modifySelection( selection, options ) {
+		this.fire( 'modifySelection', { selection, options } );
 	}
 }
 
 mix( DataController, EmitterMixin );
 
 /**
- * Event fired when {@link engine.controller.DataController#insert} method is called.
- * The {@link engine.controller.dataController.insert default action of the composer} is implemented as a
- * listener to that event so it can be fully customized by the features.
+ * Event fired when {@link engine.controller.DataController#insertContent} method is called.
+ * The {@link engine.controller.dataController.insertContent default action of that method} is implemented as a
+ * listener to this event so it can be fully customized by the features.
  *
- * @event engine.controller.DataController#insert
+ * @event engine.controller.DataController#insertContent
  * @param {Object} data
  * @param {engine.view.DocumentFragment} data.content The content to insert.
  * @param {engine.model.Selection} data.selection Selection into which the content should be inserted.
  * @param {engine.model.Batch} [data.batch] Batch to which deltas will be added.
  */
 
+/**
+ * Event fired when {@link engine.controller.DataController#deleteContent} method is called.
+ * The {@link engine.controller.deleteContent default action of that method} is implemented as a
+ * listener to this event so it can be fully customized by the features.
+ *
+ * @event engine.controller.DataController#deleteContent
+ * @param {Object} data
+ * @param {engine.model.Batch} data.batch
+ * @param {engine.model.Selection} data.selection
+ * @param {Object} data.options See {@link engine.controller.deleteContent}'s options.
+ */
+
+/**
+ * Event fired when {@link engine.controller.DataController#modifySelection} method is called.
+ * The {@link engine.controller.modifySelection default action of that method} is implemented as a
+ * listener to this event so it can be fully customized by the features.
+ *
+ * @event engine.controller.DataController#modifySelection
+ * @param {Object} data
+ * @param {engine.model.Selection} data.selection
+ * @param {Object} data.options See {@link engine.controller.modifySelection}'s options.
+ */

+ 8 - 8
packages/ckeditor5-engine/src/model/composer/deletecontents.js

@@ -3,23 +3,23 @@
  * For licensing, see LICENSE.md.
  */
 
-import LivePosition from '../liveposition.js';
-import Position from '../position.js';
-import Element from '../element.js';
-import compareArrays from '../../../utils/comparearrays.js';
+import LivePosition from '../model/liveposition.js';
+import Position from '../model/position.js';
+import Element from '../model/element.js';
+import compareArrays from '../../utils/comparearrays.js';
 
 /**
- * Delete contents of the selection and merge siblings. The resulting selection is always collapsed.
+ * Deletes content of the selection and merge siblings. The resulting selection is always collapsed.
  *
- * @method engine.model.composer.deleteContents
- * @param {engine.model.Batch} batch Batch to which the deltas will be added.
+ * @method engine.controller.deleteContent
  * @param {engine.model.Selection} selection Selection of which the content should be deleted.
+ * @param {engine.model.Batch} batch Batch to which the deltas will be added.
  * @param {Object} [options]
  * @param {Boolean} [options.merge=false] Merge elements after removing the contents of the selection.
  * For example, `<h>x[x</h><p>y]y</p>` will become: `<h>x^y</h>` with the option enabled
  * and: `<h>x^</h><p>y</p>` without it.
  */
-export default function deleteContents( batch, selection, options = {} ) {
+export default function deleteContent( selection, batch, options = {} ) {
 	if ( selection.isCollapsed ) {
 		return;
 	}

+ 19 - 20
packages/ckeditor5-engine/src/controller/insert.js

@@ -10,48 +10,36 @@ import Element from '../model/element.js';
 import Range from '../model/range.js';
 import log from '../../utils/log.js';
 
-// import { stringify as stringifyModel } from '../dev-utils/model.js';
-
 /**
  * Inserts content into the editor (specified selection) as one would expect the paste
  * functionality to work.
  *
- * **Note:** Use {@link engine.controller.DataController#insert} instead of this function.
- * This function is only exposed to be reusable in algorithms which change the {@link engine.controller.DataController#insert}
+ * **Note:** Use {@link engine.controller.DataController#insertContent} instead of this function.
+ * This function is only exposed to be reusable in algorithms which change the {@link engine.controller.DataController#insertContent}
  * method's behavior.
  *
- * @method engine.controller.insert
+ * @method engine.controller.insertContent
  * @param {engine.controller.DataController} dataController The data controller in context of which the insertion
  * should be performed.
- * @param {engine.view.DocumentFragment} content The content to insert.
+ * @param {engine.model.DocumentFragment} content The content to insert.
  * @param {engine.model.Selection} selection Selection into which the content should be inserted.
  * @param {engine.model.Batch} [batch] Batch to which deltas will be added. If not specified, then
  * changes will be added to a new batch.
  */
-export default function insert( dataController, content, selection, batch ) {
+export default function insertContent( dataController, content, selection, batch ) {
 	if ( !batch ) {
 		batch = dataController.model.batch();
 	}
 
 	if ( !selection.isCollapsed ) {
-		dataController.model.composer.deleteContents( batch, selection, {
+		dataController.deleteContent( selection, batch, {
 			merge: true
 		} );
 	}
 
-	// Convert the pasted content to a model document fragment.
-	// Convertion is contextual, but in this case we need an "all allowed" context and for that
-	// we use the $clipboardHolder item.
-	const modelFragment = dataController.viewToModel.convert( content, {
-		context: [ '$clipboardHolder' ]
-	} );
-
-	// We'll be debugging this dozens of times still.
-	// console.log( stringifyModel( modelFragment ) );
-
 	const insertion = new Insertion( dataController, batch, selection.anchor );
 
-	insertion.handleNodes( modelFragment.getChildren(), {
+	insertion.handleNodes( content.getChildren(), {
 		// The set of children being inserted is the only set in this context
 		// so it's the first and last (it's a hack ;)).
 		isFirst: true,
@@ -71,16 +59,22 @@ class Insertion {
 	constructor( dataController, batch, position ) {
 		/**
 		 * The data controller in context of which the insertion should be performed.
+		 *
+		 * @member {engine.controller.DataController} #dataController
 		 */
 		this.dataController = dataController;
 
 		/**
 		 * Batch to which deltas will be added.
+		 *
+		 * @member {engine.model.Batch} #batch
 		 */
 		this.batch = batch;
 
 		/**
 		 * The position at which (or near which) the next node will be inserted.
+		 *
+		 * @member {engine.model.Position} #position
 		 */
 		this.position = position;
 
@@ -91,11 +85,16 @@ class Insertion {
 		 *		<p>x</p><p>^y</p> + <p>z</p> (can merge to <p>y</p>)
 		 *		<p>x^y</p> + <p>z</p> (can merge to <p>xy</p> which will be split during the action,
 		 *								so both its pieces will be added to this set)
+		 *
+		 *
+		 * @member {Set} #canMergeWith
 		 */
 		this.canMergeWith = new Set( [ this.position.parent ] );
 
 		/**
 		 * Schema of the model.
+		 *
+		 * @member {engine.model.Schema} #schema
 		 */
 		this.schema = dataController.model.schema;
 	}
@@ -152,7 +151,7 @@ class Insertion {
 	 * @param {Boolean} context.isFirst Whether the given node is the first one in the content to be inserted.
 	 * @param {Boolean} context.isLast Whether the given node is the last one in the content to be inserted.
 	 */
-	_handleNode( node, context = {} ) {
+	_handleNode( node, context ) {
 		// Let's handle object in a special way.
 		// * They should never be merged with other elements.
 		// * If they are not allowed in any of the selection ancestors, they could be either autoparagraphed or totally removed.

+ 6 - 6
packages/ckeditor5-engine/src/model/composer/modifyselection.js

@@ -3,13 +3,13 @@
  * For licensing, see LICENSE.md.
  */
 
-import Position from '../position.js';
-import TreeWalker from '../treewalker.js';
-import Range from '../range.js';
-import { isInsideSurrogatePair, isInsideCombinedSymbol } from '../../../utils/unicode.js';
+import Position from '../model/position.js';
+import TreeWalker from '../model/treewalker.js';
+import Range from '../model/range.js';
+import { isInsideSurrogatePair, isInsideCombinedSymbol } from '../../utils/unicode.js';
 
 /**
- * Modifies the selection. Currently the supported modifications are:
+ * Modifies the selection. Currently, the supported modifications are:
  *
  * * Extending. The selection focus is moved in the specified `options.direction` with a step specified in `options.unit`.
  * Possible values for `unit` are:
@@ -29,7 +29,7 @@ import { isInsideSurrogatePair, isInsideCombinedSymbol } from '../../../utils/un
  *
  * **Note:** if you extend a forward selection in a backward direction you will in fact shrink it.
  *
- * @method engine.model.composer.modifySelection
+ * @method engine.controller.modifySelection
  * @param {engine.model.Selection} selection The selection to modify.
  * @param {Object} [options]
  * @param {'forward'|'backward'} [options.direction='forward'] The direction in which the selection should be modified.

+ 50 - 44
packages/ckeditor5-engine/src/dev-utils/view.js

@@ -20,12 +20,18 @@ import Range from '../view/range.js';
 import Position from '../view/position.js';
 import AttributeElement from '../view/attributeelement.js';
 import ContainerElement from '../view/containerelement.js';
+import EmptyElement from '../view/emptyelement.js';
 import ViewText from '../view/text.js';
 
 const ELEMENT_RANGE_START_TOKEN = '[';
 const ELEMENT_RANGE_END_TOKEN = ']';
 const TEXT_RANGE_START_TOKEN = '{';
 const TEXT_RANGE_END_TOKEN = '}';
+const allowedTypes = {
+	'container': ContainerElement,
+	'attribute': AttributeElement,
+	'empty': EmptyElement,
+};
 
 /**
  * Writes the contents of the {@link engine.view.Document Document} to an HTML-like string.
@@ -38,7 +44,7 @@ const TEXT_RANGE_END_TOKEN = '}';
  * @param {Boolean} [options.rootName='main'] Name of the root from which data should be stringified. If not provided
  * default `main` name will be used.
  * @param {Boolean} [options.showType=false] When set to `true` type of elements will be printed (`<container:p>`
- * instead of `<p>` and `<attribute:b>` instead of `<b>`).
+ * instead of `<p>`, `<attribute:b>` instead of `<b>` and `<empty:img>` instead of `<img>`).
  * @param {Boolean} [options.showPriority=false] When set to `true` AttributeElement's priority will be printed
  * (`<span view-priority="12">`, `<b view-priority="10">`).
  * @returns {String} The stringified data.
@@ -162,13 +168,15 @@ setData._parse = parse;
  *
  * Additional options object can be provided.
  * If `options.showType` is set to `true`, element's types will be
- * presented for {@link engine.view.AttributeElement AttributeElements} and {@link engine.view.ContainerElement
- * ContainerElements}:
+ * presented for {@link engine.view.AttributeElement AttributeElements}, {@link engine.view.ContainerElement
+ * ContainerElements} and {@link engine.view.EmptyElement EmptyElements}:
  *
  *		const attribute = new AttributeElement( 'b' );
  *		const container = new ContainerElement( 'p' );
+ *		const empty = new EmptyElement( 'img' );
  *		getData( attribute, null, { showType: true } ); // '<attribute:b></attribute:b>'
  *		getData( container, null, { showType: true } ); // '<container:p></container:p>'
+ *		getData( empty, null, { showType: true } ); // '<empty:img></empty:img>'
  *
  * If `options.showPriority` is set to `true`, priority will be displayed for all
  * {@link engine.view.AttributeElement AttributeElements}.
@@ -185,7 +193,7 @@ setData._parse = parse;
  * containing one range collapsed at this position.
  * @param {Object} [options] Object with additional options.
  * @param {Boolean} [options.showType=false] When set to `true` type of elements will be printed (`<container:p>`
- * instead of `<p>` and `<attribute:b>` instead of `<b>`).
+ * instead of `<p>`, `<attribute:b>` instead of `<b>` and `<empty:img>` instead of `<img>`).
  * @param {Boolean} [options.showPriority=false] When set to `true` AttributeElement's priority will be printed
  * (`<span view-priority="12">`, `<b view-priority="10">`).
  * @param {Boolean} [options.ignoreRoot=false] When set to `true` root's element opening and closing will not be printed.
@@ -288,7 +296,7 @@ export function parse( data, options = {} ) {
 		sameSelectionCharacters: options.sameSelectionCharacters
 	} );
 	const processor = new XmlDataProcessor( {
-		namespaces: [ 'attribute', 'container' ]
+		namespaces: Object.keys( allowedTypes )
 	} );
 
 	// Convert data to view.
@@ -560,8 +568,8 @@ class ViewStringify {
 	 * @param root
 	 * @param {engine.view.Selection} [selection=null] Selection which ranges should be also converted to string.
 	 * @param {Object} [options] Options object.
-	 * @param {Boolean} [options.showType=false] When set to `true` type of elements will be printed ( `<container:p>`
-	 * instead of `<p>` and `<attribute:b>` instead of `<b>`.
+	 * @param {Boolean} [options.showType=false] When set to `true` type of elements will be printed (`<container:p>`
+	 * instead of `<p>`, `<attribute:b>` instead of `<b>` and `<empty:img>` instead of `<img>`).
 	 * @param {Boolean} [options.showPriority=false] When set to `true` AttributeElement's priority will be printed.
 	 * @param {Boolean} [options.ignoreRoot=false] When set to `true` root's element opening and closing tag will not
 	 * be outputted.
@@ -719,9 +727,9 @@ class ViewStringify {
 
 	/**
 	 * Converts passed {@link engine.view.Element Element} to opening tag.
-	 * Depending on current configuration opening tag can be simple (`<a>`), contain type prefix (`<container:p>` or
-	 * `<attribute:a>`), contain priority information ( `<attribute:a view-priority="20">` ). Element's attributes also
-	 * will be included (`<a href="http://ckeditor.com" name="foobar">`).
+	 * Depending on current configuration opening tag can be simple (`<a>`), contain type prefix (`<container:p>`,
+	 * `<attribute:a>` or `<empty:img>`), contain priority information ( `<attribute:a view-priority="20">` ).
+	 * Element's attributes also will be included (`<a href="http://ckeditor.com" name="foobar">`).
 	 *
 	 * @private
 	 * @param {engine.view.Element} element
@@ -740,8 +748,8 @@ class ViewStringify {
 
 	/**
 	 * Converts passed {@link engine.view.Element Element} to closing tag.
-	 * Depending on current configuration closing tag can be simple (`</a>`) or contain type prefix (`</container:p>` or
-	 * `</attribute:a>`).
+	 * Depending on current configuration closing tag can be simple (`</a>`) or contain type prefix (`</container:p>`,
+	 * `</attribute:a>` or `</empty:img>`).
 	 *
 	 * @private
 	 * @param {engine.view.Element} element
@@ -756,9 +764,10 @@ class ViewStringify {
 
 	/**
 	 * Converts passed {@link engine.view.Element Element's} type to its string representation
-	 * Returns 'attribute' for {@link engine.view.AttributeElement AttributeElements} and
-	 * 'container' for {@link engine.view.ContainerElement ContainerElements}. Returns empty string when current
-	 * configuration is preventing showing elements' types.
+	 * Returns 'attribute' for {@link engine.view.AttributeElement AttributeElements},
+	 * 'container' for {@link engine.view.ContainerElement ContainerElements} and 'empty' for
+	 * {@link engine.view.EmptyElement EmptyElements}. Returns empty string when current configuration is preventing
+	 * showing elements' types.
 	 *
 	 * @private
 	 * @param {engine.view.Element} element
@@ -766,12 +775,10 @@ class ViewStringify {
 	 */
 	_stringifyElementType( element ) {
 		if ( this.showType ) {
-			if ( element instanceof AttributeElement ) {
-				return 'attribute';
-			}
-
-			if ( element instanceof ContainerElement ) {
-				return 'container';
+			for ( let type in allowedTypes ) {
+				if ( element instanceof allowedTypes[ type ] ) {
+					return type;
+				}
 			}
 		}
 
@@ -816,13 +823,14 @@ class ViewStringify {
 	}
 }
 
-// Converts {@link engine.view.Element Elements} to {@link engine.view.AttributeElement AttributeElements} and
-// {@link engine.view.ContainerElement ContainerElements}. It converts whole tree starting from the `rootNode`.
-// Conversion is based on element names. See `_convertElement` method for more details.
+// Converts {@link engine.view.Element Elements} to {@link engine.view.AttributeElement AttributeElements},
+// {@link engine.view.ContainerElement ContainerElements} or {@link engine.view.EmptyElement EmptyElements}.
+// It converts whole tree starting from the `rootNode`. Conversion is based on element names.
+// See `_convertElement` method for more details.
 //
 // @param {engine.view.Element|engine.view.DocumentFragment|engine.view.Text} rootNode Root node to convert.
 // @returns {engine.view.Element|engine.view.DocumentFragment|engine.view.Text|engine.view.AttributeElement|
-// engine.view.ContainerElement} Root node of converted elements.
+// engine.view.ContainerElement|engine.view.EmptyElement} Root node of converted elements.
 function _convertViewElements( rootNode ) {
 	const isFragment = rootNode instanceof ViewDocumentFragment;
 
@@ -832,6 +840,10 @@ function _convertViewElements( rootNode ) {
 
 		// Convert all child nodes.
 		for ( let child of rootNode.getChildren() ) {
+			if ( convertedElement instanceof EmptyElement ) {
+				throw new Error( `Parse error - cannot parse inside EmptyElement.` );
+			}
+
 			convertedElement.appendChildren( _convertViewElements( child ) );
 		}
 
@@ -841,12 +853,14 @@ function _convertViewElements( rootNode ) {
 	return rootNode;
 }
 
-// Converts {@link engine.view.Element Element} to {@link engine.view.AttributeElement AttributeElement} or
-// {@link engine.view.ContainerElement ContainerElement}.
+// Converts {@link engine.view.Element Element} to {@link engine.view.AttributeElement AttributeElement},
+// {@link engine.view.ContainerElement ContainerElement} or {@link engine.view.EmptyElement EmptyElement}.
 // If element's name is in format `attribute:b` with `view-priority="11"` attribute it will be converted to
 // {@link engine.view.AttributeElement AttributeElement} with priority 11.
 // If element's name is in format `container:p` - it will be converted to
 // {@link engine.view.ContainerElement ContainerElement}.
+// If element's name is in format `empty:img` - it will be converted to
+// {@link engine.view.EmptyElement EmptyElement}.
 // If element's name will not contain any additional information - {@link engine.view.Element view Element} will be
 // returned.
 //
@@ -854,19 +868,14 @@ function _convertViewElements( rootNode ) {
 // @returns {engine.view.Element|engine.view.AttributeElement|engine.view.ContainerElement} Tree view
 // element converted according to it's name.
 function _convertElement( viewElement ) {
-	let newElement;
 	const info = _convertElementNameAndPriority( viewElement );
+	const ElementConstructor = allowedTypes[ info.type ];
+	const newElement = ElementConstructor ? new ElementConstructor( info.name ) : new ViewElement( info.name );
 
-	if ( info.type == 'attribute' ) {
-		newElement = new AttributeElement( info.name );
-
+	if ( newElement instanceof AttributeElement ) {
 		if ( info.priority !== null ) {
 			newElement.priority = info.priority;
 		}
-	} else if ( info.type == 'container' ) {
-		newElement = new ContainerElement( info.name );
-	} else {
-		newElement = new ViewElement( info.name );
 	}
 
 	// Move attributes.
@@ -878,14 +887,15 @@ function _convertElement( viewElement ) {
 }
 
 // Converts `view-priority` attribute and {@link engine.view.Element#name Element's name} information needed for creating
-// {@link engine.view.AttributeElement AttributeElement} or {@link engine.view.ContainerElement ContainerElement} instance.
+// {@link engine.view.AttributeElement AttributeElement}, {@link engine.view.ContainerElement ContainerElement} or
+// {@link engine.view.EmptyElement EmptyElement} instance.
 // Name can be provided in two formats: as a simple element's name (`div`), or as a type and name (`container:div`,
-// `attribute:span`);
+// `attribute:span`, `empty:img`);
 //
 // @param {engine.view.Element} element Element which name should be converted.
 // @returns {Object} info Object with parsed information.
 // @returns {String} info.name Parsed name of the element.
-// @returns {String|null} info.type Parsed type of the element, can be `attribute` or `container`.
+// @returns {String|null} info.type Parsed type of the element, can be `attribute`, `container` or `empty`.
 // returns {Number|null} info.priority Parsed priority of the element.
 function _convertElementNameAndPriority( viewElement ) {
 	const parts = viewElement.name.split( ':' );
@@ -914,16 +924,12 @@ function _convertElementNameAndPriority( viewElement ) {
 	throw new Error( `Parse error - cannot parse element's name: ${ viewElement.name }.` );
 }
 
-// Checks if element's type is allowed. Returns `attribute`, `container` or `null`.
+// Checks if element's type is allowed. Returns `attribute`, `container`, `empty` or `null`.
 //
 // @param {String} type
 // @returns {String|null}
 function _convertType( type ) {
-	if ( type == 'container' || type == 'attribute' ) {
-		return type;
-	}
-
-	return null;
+	return allowedTypes[ type ] ? type : null;
 }
 
 // Checks if given priority is allowed. Returns null if priority cannot be converted.

+ 0 - 87
packages/ckeditor5-engine/src/model/composer/composer.js

@@ -1,87 +0,0 @@
-/**
- * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
- * For licensing, see LICENSE.md.
- */
-
-import mix from '../../../utils/mix.js';
-import EmitterMixin from '../../../utils/emittermixin.js';
-import deleteContents from './deletecontents.js';
-import modifySelection from './modifyselection.js';
-
-/**
- * Set of frequently used tools to work with a document.
- * The instance of composer is available in {@link engine.model.Document#composer}.
- *
- * By default this class implements only a very basic version of those algorithms. However, all its methods can be extended
- * by features by listening to related events. The default action of those events are implemented
- * by functions available in the {@link engine.model.composer} namespace, so they can be reused
- * in the algorithms implemented by features.
- *
- * @member engine.model.composer
- * @mixes utils.EmitterMixin
- */
-export default class Composer {
-	/**
-	 * Creates an instance of the composer.
-	 */
-	constructor() {
-		this.on( 'deleteContents', ( evt, data ) => deleteContents( data.batch, data.selection, data.options ) );
-		this.on( 'modifySelection', ( evt, data ) => modifySelection( data.selection, data.options ) );
-	}
-
-	/**
-	 * See {@link engine.model.composer.deleteContents}.
-	 *
-	 * Note: For the sake of predictability, the resulting selection should always be collapsed.
-	 * In cases where a feature wants to modify deleting behavior so selection isn't collapsed
-	 * (e.g. a table feature may want to keep row selection after pressing <kbd>Backspace</kbd>),
-	 * then that behavior should be implemented in the view's listener. At the same time, the table feature
-	 * will need to modify this method's behavior too, e.g. to "delete contents and then collapse
-	 * the selection inside the last selected cell" or "delete the row and collapse selection somewhere near".
-	 * That needs to be done in order to ensure that other features which use `deleteContents()` will work well with tables.
-	 *
-	 * @fires engine.model.composer.Composer#deleteContents
-	 * @param {engine.model.Batch} batch Batch to which deltas will be added.
-	 * @param {engine.model.Selection} selection Selection of which the content should be deleted.
-	 * @param {Object} options See {@link engine.model.composer.deleteContents}'s options.
-	 */
-	deleteContents( batch, selection, options ) {
-		this.fire( 'deleteContents', { batch, selection, options } );
-	}
-
-	/**
-	 * See {@link engine.model.composer.modifySelection}.
-	 *
-	 * @fires engine.model.composer.Composer#modifySelection
-	 * @param {engine.model.Selection} The selection to modify.
-	 * @param {Object} options See {@link engine.model.composer.modifySelection}'s options.
-	 */
-	modifySelection( selection, options ) {
-		this.fire( 'modifySelection', { selection, options } );
-	}
-}
-
-mix( Composer, EmitterMixin );
-
-/**
- * Event fired when {@link engine.model.composer.Composer#deleteContents} method is called.
- * The {@link engine.model.composer.deleteContents default action of the composer} is implemented as a
- * listener to that event so it can be fully customized by the features.
- *
- * @event engine.model.composer.Composer#deleteContents
- * @param {Object} data
- * @param {engine.model.Batch} data.batch
- * @param {engine.model.Selection} data.selection
- * @param {Object} data.options See {@link engine.model.composer.deleteContents}'s options.
- */
-
-/**
- * Event fired when {@link engine.model.composer.Composer#modifySelection} method is called.
- * The {@link engine.model.composer.modifySelection default action of the composer} is implemented as a
- * listener to that event so it can be fully customized by the features.
- *
- * @event engine.model.composer.Composer#modifySelection
- * @param {Object} data
- * @param {engine.model.Selection} data.selection
- * @param {Object} data.options See {@link engine.model.composer.modifySelection}'s options.
- */

+ 0 - 10
packages/ckeditor5-engine/src/model/document.js

@@ -14,7 +14,6 @@ import Batch from './batch.js';
 import History from './history.js';
 import LiveSelection from './liveselection.js';
 import Schema from './schema.js';
-import Composer from './composer/composer.js';
 import clone from '../../utils/lib/lodash/clone.js';
 import EmitterMixin from '../../utils/emittermixin.js';
 import CKEditorError from '../../utils/ckeditorerror.js';
@@ -79,15 +78,6 @@ export default class Document {
 		this.history = new History( this );
 
 		/**
-		 * Composer for this document. Set of tools to work with the document.
-		 *
-		 * The features can tune up these tools to better work on their specific cases.
-		 *
-		 * @member {engine.model.composer.Composer} engine.model.Document#composer
-		 */
-		this.composer = new Composer();
-
-		/**
 		 * Array of pending changes. See: {@link engine.model.Document#enqueueChanges}.
 		 *
 		 * @private

+ 3 - 3
packages/ckeditor5-engine/src/view/element.js

@@ -10,11 +10,11 @@ import isIterable from '../../utils/isiterable.js';
 import isPlainObject from '../../utils/lib/lodash/isPlainObject.js';
 
 /**
- * Tree view element.
+ * View element.
  *
  * Editing engine does not define fixed HTML DTD. This is why the type of the {@link engine.view.Element} need to
  * be defined by the feature developer. Creating an element you should use {@link engine.view.ContainerElement}
- * class or {@link engine.view.AttributeElement}.
+ * class, {@link engine.view.AttributeElement} class or {@link engine.view.EmptyElement} class.
  *
  * Note that for view elements which are not created from model, like elements from mutations, paste or
  * {@link engine.controller.DataController#set data.set} it is not possible to define the type of the element, so
@@ -25,7 +25,7 @@ import isPlainObject from '../../utils/lib/lodash/isPlainObject.js';
  */
 export default class Element extends Node {
 	/**
-	 * Creates a tree view element.
+	 * Creates a view element.
 	 *
 	 * Attributes can be passed in various formats:
 	 *

+ 82 - 0
packages/ckeditor5-engine/src/view/emptyelement.js

@@ -0,0 +1,82 @@
+/**
+ * @license Copyright (c) 2003-2015, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+import Element from './element.js';
+import CKEditorError from '../../utils/ckeditorerror.js';
+
+/**
+ * EmptyElement class. It is used to represent elements that cannot contain any child nodes.
+ */
+export default class EmptyElement extends Element {
+	/**
+	 * Creates new instance of EmptyElement.
+	 *
+	 * Throws {@link utils.CKEditorError CKEditorError} `view-emptyelement-cannot-add` when third parameter is passed,
+	 * to inform that usage of EmptyElement is incorrect (adding child nodes to EmptyElement is forbidden).
+	 *
+	 * @param {String} name Node name.
+	 * @param {Object|Iterable} [attributes] Collection of attributes.
+	 */
+	constructor( name, attributes ) {
+		super( name, attributes );
+
+		if ( arguments.length > 2 ) {
+			throwCannotAdd();
+		}
+	}
+
+	/**
+	 * Clones provided element. Overrides {@link engine.view.Element#clone} method, as it's forbidden to pass child
+	 * nodes to EmptyElement's constructor.
+	 *
+	 * @returns {envine.view.EmptyElement} Clone of this element.
+	 */
+	clone() {
+		const cloned = new this.constructor( this.name, this._attrs );
+
+		// Classes and styles are cloned separately - this solution is faster than adding them back to attributes and
+		// parse once again in constructor.
+		cloned._classes = new Set( this._classes );
+		cloned._styles = new Map( this._styles );
+
+		return cloned;
+	}
+
+	/**
+	 * Overrides {@link engine.view.Element#appendChildren} method.
+	 * Throws {@link utils.CKEditorError CKEditorError} `view-emptyelement-cannot-add` to prevent adding any child nodes
+	 * to EmptyElement.
+	 */
+	appendChildren() {
+		throwCannotAdd();
+	}
+
+	/**
+	 * Overrides {@link engine.view.Element#insertChildren} method.
+	 * Throws {@link utils.CKEditorError CKEditorError} `view-emptyelement-cannot-add` to prevent adding any child nodes
+	 * to EmptyElement.
+	 */
+	insertChildren() {
+		throwCannotAdd();
+	}
+
+	/**
+	 * Returns `null` because block filler is not needed.
+	 *
+	 * @returns {null}
+	 */
+	getFillerOffset() {
+		return null;
+	}
+}
+
+function throwCannotAdd() {
+	/**
+	 * Cannot add children to {@link engine.view.EmptyElement}.
+	 *
+	 * @error view-emptyelement-cannot-add
+	 */
+	throw new CKEditorError( 'view-emptyelement-cannot-add: Cannot add child nodes to EmptyElement instance.' );
+}

+ 36 - 12
packages/ckeditor5-engine/src/view/writer.js

@@ -6,6 +6,7 @@
 import Position from './position.js';
 import ContainerElement from './containerelement.js';
 import AttributeElement from './attributeelement.js';
+import EmptyElement from './emptyelement.js';
 import Text from './text.js';
 import Range from './range.js';
 import CKEditorError from '../../utils/ckeditorerror.js';
@@ -54,6 +55,9 @@ export default {
  * Throws {@link utils.CKEditorError CKEditorError} `view-writer-invalid-range-container` when {@link engine.view.Range#start start}
  * and {@link engine.view.Range#end end} positions of a passed range are not placed inside same parent container.
  *
+ * Throws {@link utils.CKEditorError CKEditorError} `view-writer-cannot-break-empty-element` when trying to break attributes
+ * inside {@link engine.view.EmptyElement EmptyElement}.
+ *
  * @see engine.view.AttributeElement
  * @see engine.view.ContainerElement
  * @see engine.view.writer.breakContainer
@@ -137,8 +141,8 @@ export function breakContainer( position ) {
  * In following examples `<p>` is a container and `<b>` is an attribute element:
  *
  *		<p>foo[]bar</p> -> <p>foo{}bar</p>
- *		<p><b>foo</b>[]<b>bar</b> -> <p><b>foo{}bar</b></b>
- *		<p><b foo="bar">a</b>[]<b foo="baz">b</b> -> <p><b foo="bar">a</b>[]<b foo="baz">b</b>
+ *		<p><b>foo</b>[]<b>bar</b></p> -> <p><b>foo{}bar</b></p>
+ *		<p><b foo="bar">a</b>[]<b foo="baz">b</b></p> -> <p><b foo="bar">a</b>[]<b foo="baz">b</b></p>
  *
  * It will also take care about empty attributes when merging:
  *
@@ -188,6 +192,11 @@ export function mergeAttributes( position ) {
 		return position;
 	}
 
+	// When one or both nodes are EmptyElements - no attributes to merge.
+	if ( ( nodeBefore instanceof EmptyElement ) || ( nodeAfter instanceof EmptyElement ) ) {
+		return position;
+	}
+
 	// When position is between two text nodes.
 	if ( nodeBefore instanceof Text && nodeAfter instanceof Text ) {
 		return mergeTextNodes( nodeBefore, nodeAfter );
@@ -256,20 +265,20 @@ export function mergeContainers( position ) {
  *
  * Throws {@link utils.CKEditorError CKEditorError} `view-writer-insert-invalid-node` when nodes to insert
  * contains instances that are not {@link engine.view.Text Texts},
- * {@link engine.view.AttributeElement AttributeElements} or
- * {@link engine.view.ContainerElement ContainerElements}.
+ * {@link engine.view.AttributeElement AttributeElements},
+ * {@link engine.view.ContainerElement ContainerElements} or {@link engine.view.EmptyElement EmptyElements}.
  *
  * @function engine.view.writer.insert
  * @param {engine.view.Position} position Insertion position.
- * @param {engine.view.Text|engine.view.AttributeElement|engine.view.ContainerElement
- * |Iterable.<engine.view.Text|engine.view.AttributeElement|engine.view.ContainerElement>} nodes Node or
+ * @param {engine.view.Text|engine.view.AttributeElement|engine.view.ContainerElement|engine.view.EmptyElement
+ * |Iterable.<engine.view.Text|engine.view.AttributeElement|engine.view.ContainerElement|engine.view.EmptyElement>} nodes Node or
  * nodes to insert.
  * @returns {engine.view.Range} Range around inserted nodes.
  */
 export function insert( position, nodes ) {
 	nodes = isIterable( nodes ) ? [ ...nodes ] : [ nodes ];
 
-	// Check if nodes to insert are instances of AttributeElements, ContainerElements or Text.
+	// Check if nodes to insert are instances of AttributeElements, ContainerElements, EmptyElements or Text.
 	validateNodesToInsert( nodes );
 
 	const container = getParentContainer( position );
@@ -630,6 +639,9 @@ function _breakAttributesRange( range, forceSplitText = false ) {
 // Function used by public breakAttributes (without splitting text nodes) and by other methods (with
 // splitting text nodes).
 //
+// Throws {@link utils.CKEditorError CKEditorError} `view-writer-cannot-break-empty-element` break position is placed
+// inside {@link engine.view.EmptyElement EmptyElement}.
+//
 // @param {engine.view.Position} position Position where to break attributes.
 // @param {Boolean} [forceSplitText = false] If set to `true`, will break text nodes even if they are directly in
 // container element. This behavior will result in incorrect view state, but is needed by other view writing methods
@@ -639,6 +651,16 @@ function _breakAttributes( position, forceSplitText = false ) {
 	const positionOffset = position.offset;
 	const positionParent = position.parent;
 
+	// If position is placed inside EmptyElement - throw an exception as we cannot break inside.
+	if ( position.parent instanceof EmptyElement ) {
+		/**
+		 * Cannot break inside EmptyElement instance.
+		 *
+		 * @error view-writer-cannot-break-empty-element
+		 */
+		throw new CKEditorError( 'view-writer-cannot-break-empty-element' );
+	}
+
 	// There are no attributes to break and text nodes breaking is not forced.
 	if ( !forceSplitText && positionParent instanceof Text && isContainerOrFragment( positionParent.parent ) ) {
 		return Position.createFromPosition( position );
@@ -781,9 +803,10 @@ function wrapChildren( parent, startOffset, endOffset, attribute ) {
 		const child = parent.getChild( i );
 		const isText = child instanceof Text;
 		const isAttribute = child instanceof AttributeElement;
+		const isEmpty = child instanceof EmptyElement;
 
-		// Wrap text or attributes with higher or equal priority.
-		if ( isText || ( isAttribute && attribute.priority <= child.priority ) ) {
+		// Wrap text, empty elements or attributes with higher or equal priority.
+		if ( isText || isEmpty || ( isAttribute && attribute.priority <= child.priority ) ) {
 			// Clone attribute.
 			const newAttribute = attribute.clone();
 
@@ -1025,18 +1048,19 @@ function rangeSpansOnAllChildren( range ) {
 }
 
 // Checks if provided nodes are valid to insert. Checks if each node is an instance of
-// {@link engine.view.Text Text} or {@link engine.view.AttributeElement AttributeElement} or
-// {@link engine.view.ContainerElement ContainerElement}.
+// {@link engine.view.Text Text} or {@link engine.view.AttributeElement AttributeElement},
+// {@link engine.view.ContainerElement ContainerElement} or {@link engine.view.EmptyElement EmptyElement}.
 //
 // Throws {@link utils.CKEditorError CKEditorError} `view-writer-insert-invalid-node` when nodes to insert
 // contains instances that are not {@link engine.view.Text Texts},
+// {@link engine.view.EmptyElement EmptyElements},
 // {@link engine.view.AttributeElement AttributeElements} or
 // {@link engine.view.ContainerElement ContainerElements}.
 //
 // @param Iterable.<engine.view.Text|engine.view.AttributeElement|engine.view.ContainerElement> nodes
 function validateNodesToInsert( nodes ) {
 	for ( let node of nodes ) {
-		if ( !( node instanceof Text || node instanceof AttributeElement || node instanceof ContainerElement ) ) {
+		if ( !( node instanceof Text || node instanceof AttributeElement || node instanceof ContainerElement || node instanceof EmptyElement ) ) {
 			/**
 			 * Inserted nodes should be instance of {@link engine.view.AttributeElement AttributeElement},
 			 * {@link engine.view.ContainerElement ContainerElement} or {@link engine.view.Text Text}.

+ 87 - 11
packages/ckeditor5-engine/tests/controller/datacontroller.js

@@ -3,8 +3,6 @@
  * For licensing, see LICENSE.md.
  */
 
-/* bender-tags: view */
-
 import ModelDocument from 'ckeditor5/engine/model/document.js';
 import DataController from 'ckeditor5/engine/controller/datacontroller.js';
 import HtmlDataProcessor from 'ckeditor5/engine/dataprocessor/htmldataprocessor.js';
@@ -12,8 +10,8 @@ import HtmlDataProcessor from 'ckeditor5/engine/dataprocessor/htmldataprocessor.
 import buildViewConverter  from 'ckeditor5/engine/conversion/buildviewconverter.js';
 import buildModelConverter  from 'ckeditor5/engine/conversion/buildmodelconverter.js';
 
-import ViewDocumentFragment from 'ckeditor5/engine/view/documentfragment.js';
-import ViewText from 'ckeditor5/engine/view/text.js';
+import ModelDocumentFragment from 'ckeditor5/engine/model/documentfragment.js';
+import ModelText from 'ckeditor5/engine/model/text.js';
 
 import { getData, setData, stringify, parse } from 'ckeditor5/engine/dev-utils/model.js';
 
@@ -43,17 +41,63 @@ describe( 'DataController', () => {
 
 		it( 'should add insertContent listener', () => {
 			const batch = modelDocument.batch();
-			const content = new ViewDocumentFragment( [ new ViewText( 'x' ) ] );
+			const content = new ModelDocumentFragment( [ new ModelText( 'x' ) ] );
 
 			schema.registerItem( 'paragraph', '$block' );
 
 			setData( modelDocument, '<paragraph>a[]b</paragraph>' );
 
-			data.fire( 'insert', { content, selection: modelDocument.selection, batch } );
+			data.fire( 'insertContent', { content, selection: modelDocument.selection, batch } );
 
 			expect( getData( modelDocument ) ).to.equal( '<paragraph>ax[]b</paragraph>' );
 			expect( batch.deltas.length ).to.be.above( 0 );
 		} );
+
+		it( 'should add deleteContent listener', () => {
+			schema.registerItem( 'paragraph', '$block' );
+
+			setData( modelDocument, '<paragraph>f[oo</paragraph><paragraph>ba]r</paragraph>' );
+
+			const batch = modelDocument.batch();
+
+			data.fire( 'deleteContent', { batch, selection: modelDocument.selection } );
+
+			expect( getData( modelDocument ) ).to.equal( '<paragraph>f[]</paragraph><paragraph>r</paragraph>' );
+			expect( batch.deltas ).to.not.be.empty;
+		} );
+
+		it( 'should add deleteContent listener which passes ', () => {
+			schema.registerItem( 'paragraph', '$block' );
+
+			setData( modelDocument, '<paragraph>f[oo</paragraph><paragraph>ba]r</paragraph>' );
+
+			const batch = modelDocument.batch();
+
+			data.fire( 'deleteContent', {
+				batch,
+				selection: modelDocument.selection,
+				options: { merge: true }
+			} );
+
+			expect( getData( modelDocument ) ).to.equal( '<paragraph>f[]r</paragraph>' );
+		} );
+
+		it( 'should add modifySelection listener', () => {
+			schema.registerItem( 'paragraph', '$block' );
+
+			setData( modelDocument, '<paragraph>foo[]bar</paragraph>' );
+
+			data.fire( 'modifySelection', {
+				selection: modelDocument.selection,
+				options: {
+					direction: 'backward'
+				}
+			} );
+
+			expect( getData( modelDocument ) )
+				.to.equal( '<paragraph>fo[o]bar</paragraph>' );
+			expect( modelDocument.selection.isBackward ).to.true;
+		} );
 	} );
 
 	describe( 'parse', () => {
@@ -270,15 +314,15 @@ describe( 'DataController', () => {
 		} );
 	} );
 
-	describe( 'insert', () => {
-		it( 'should fire the insert event', () => {
+	describe( 'insertContent', () => {
+		it( 'should fire the insertContent event', () => {
 			const spy = sinon.spy();
-			const content = new ViewDocumentFragment( [ new ViewText( 'x' ) ] );
+			const content = new ModelDocumentFragment( [ new ModelText( 'x' ) ] );
 			const batch = modelDocument.batch();
 
-			data.on( 'insert', spy );
+			data.on( 'insertContent', spy );
 
-			data.insert( content, modelDocument.selection, batch );
+			data.insertContent( content, modelDocument.selection, batch );
 
 			expect( spy.args[ 0 ][ 1 ] ).to.deep.equal( {
 				batch: batch,
@@ -287,4 +331,36 @@ describe( 'DataController', () => {
 			} );
 		} );
 	} );
+
+	describe( 'deleteContent', () => {
+		it( 'should fire the deleteContent event', () => {
+			const spy = sinon.spy();
+			const batch = modelDocument.batch();
+
+			data.on( 'deleteContent', spy );
+
+			data.deleteContent( modelDocument.selection, batch );
+
+			const evtData = spy.args[ 0 ][ 1 ];
+
+			expect( evtData.batch ).to.equal( batch );
+			expect( evtData.selection ).to.equal( modelDocument.selection );
+		} );
+	} );
+
+	describe( 'modifySelection', () => {
+		it( 'should fire the deleteContent event', () => {
+			const spy = sinon.spy();
+			const opts = { direction: 'backward' };
+
+			data.on( 'modifySelection', spy );
+
+			data.modifySelection( modelDocument.selection, opts );
+
+			const evtData = spy.args[ 0 ][ 1 ];
+
+			expect( evtData.selection ).to.equal( modelDocument.selection );
+			expect( evtData.options ).to.equal( opts );
+		} );
+	} );
 } );

+ 16 - 18
packages/ckeditor5-engine/tests/model/composer/deletecontents.js

@@ -3,16 +3,14 @@
  * For licensing, see LICENSE.md.
  */
 
-/* bender-tags: model, composer */
-
 import Document from 'ckeditor5/engine/model/document.js';
-import deleteContents from 'ckeditor5/engine/model/composer/deletecontents.js';
+import deleteContent from 'ckeditor5/engine/controller/deletecontent.js';
 import { setData, getData } from 'ckeditor5/engine/dev-utils/model.js';
 
 describe( 'Delete utils', () => {
 	let doc;
 
-	describe( 'deleteContents', () => {
+	describe( 'deleteContent', () => {
 		describe( 'in simple scenarios', () => {
 			beforeEach( () => {
 				doc = new Document();
@@ -41,7 +39,7 @@ describe( 'Delete utils', () => {
 			it( 'deletes single character (backward selection)' , () => {
 				setData( doc, 'f[o]o', { lastRangeBackward: true } );
 
-				deleteContents( doc.batch(), doc.selection );
+				deleteContent( doc.selection, doc.batch() );
 
 				expect( getData( doc ) ).to.equal( 'f[]o' );
 			} );
@@ -95,7 +93,7 @@ describe( 'Delete utils', () => {
 			it( 'deletes characters (first half has attrs)', () => {
 				setData( doc, '<$text bold="true">fo[o</$text>b]ar' );
 
-				deleteContents( doc.batch(), doc.selection );
+				deleteContent( doc.selection, doc.batch() );
 
 				expect( getData( doc ) ).to.equal( '<$text bold="true">fo[]</$text>ar' );
 				expect( doc.selection.getAttribute( 'bold' ) ).to.equal( true );
@@ -104,7 +102,7 @@ describe( 'Delete utils', () => {
 			it( 'deletes characters (2nd half has attrs)', () => {
 				setData( doc, 'fo[o<$text bold="true">b]ar</$text>' );
 
-				deleteContents( doc.batch(), doc.selection );
+				deleteContent( doc.selection, doc.batch() );
 
 				expect( getData( doc ) ).to.equal( 'fo[]<$text bold="true">ar</$text>' );
 				expect( doc.selection.getAttribute( 'bold' ) ).to.undefined;
@@ -113,7 +111,7 @@ describe( 'Delete utils', () => {
 			it( 'clears selection attrs when emptied content', () => {
 				setData( doc, '<paragraph>x</paragraph><paragraph>[<$text bold="true">foo</$text>]</paragraph><paragraph>y</paragraph>' );
 
-				deleteContents( doc.batch(), doc.selection );
+				deleteContent( doc.selection, doc.batch() );
 
 				expect( getData( doc ) ).to.equal( '<paragraph>x</paragraph><paragraph>[]</paragraph><paragraph>y</paragraph>' );
 				expect( doc.selection.getAttribute( 'bold' ) ).to.undefined;
@@ -130,7 +128,7 @@ describe( 'Delete utils', () => {
 					}
 				);
 
-				deleteContents( doc.batch(), doc.selection );
+				deleteContent( doc.selection, doc.batch() );
 
 				expect( getData( doc ) ).to.equal( '<paragraph>x<$text bold="true">a[]b</$text>y</paragraph>' );
 				expect( doc.selection.getAttribute( 'bold' ) ).to.equal( true );
@@ -203,7 +201,7 @@ describe( 'Delete utils', () => {
 					{ lastRangeBackward: true }
 				);
 
-				deleteContents( doc.batch(), doc.selection, { merge: true } );
+				deleteContent( doc.selection, doc.batch(), { merge: true } );
 
 				expect( getData( doc ) ).to.equal( '<paragraph>x</paragraph><heading1>fo[]ar</heading1><paragraph>y</paragraph>' );
 			} );
@@ -316,7 +314,7 @@ describe( 'Delete utils', () => {
 					{ rootName: 'paragraphRoot' }
 				);
 
-				deleteContents( doc.batch(), doc.selection );
+				deleteContent( doc.selection, doc.batch() );
 
 				expect( getData( doc, { rootName: 'paragraphRoot' } ) )
 					.to.equal( '<paragraph>x[]z</paragraph>' );
@@ -329,7 +327,7 @@ describe( 'Delete utils', () => {
 					{ rootName: 'bodyRoot' }
 				);
 
-				deleteContents( doc.batch(), doc.selection );
+				deleteContent( doc.selection, doc.batch() );
 
 				expect( getData( doc, { rootName: 'bodyRoot' } ) )
 					.to.equal( '<paragraph>x</paragraph><paragraph>[]</paragraph><paragraph>z</paragraph>' );
@@ -342,7 +340,7 @@ describe( 'Delete utils', () => {
 					{ rootName: 'bodyRoot' }
 				);
 
-				deleteContents( doc.batch(), doc.selection );
+				deleteContent( doc.selection, doc.batch() );
 
 				expect( getData( doc, { rootName: 'bodyRoot' } ) )
 					.to.equal( '<paragraph>x</paragraph><paragraph>[]</paragraph><paragraph>z</paragraph>' );
@@ -355,7 +353,7 @@ describe( 'Delete utils', () => {
 					{ rootName: 'bodyRoot' }
 				);
 
-				deleteContents( doc.batch(), doc.selection );
+				deleteContent( doc.selection, doc.batch() );
 
 				expect( getData( doc, { rootName: 'bodyRoot' } ) )
 					.to.equal( '<paragraph>x</paragraph><paragraph>[]</paragraph><paragraph>z</paragraph>' );
@@ -368,7 +366,7 @@ describe( 'Delete utils', () => {
 					{ rootName: 'bodyRoot' }
 				);
 
-				deleteContents( doc.batch(), doc.selection );
+				deleteContent( doc.selection, doc.batch() );
 
 				expect( getData( doc, { rootName: 'bodyRoot' } ) )
 					.to.equal( '<paragraph>x</paragraph><paragraph>[]</paragraph><paragraph>z</paragraph>' );
@@ -381,7 +379,7 @@ describe( 'Delete utils', () => {
 					{ rootName: 'bodyRoot' }
 				);
 
-				deleteContents( doc.batch(), doc.selection );
+				deleteContent( doc.selection, doc.batch() );
 
 				expect( getData( doc, { rootName: 'bodyRoot' } ) )
 					.to.equal( '<paragraph>[]</paragraph>' );
@@ -394,7 +392,7 @@ describe( 'Delete utils', () => {
 					{ rootName: 'restrictedRoot' }
 				);
 
-				deleteContents( doc.batch(), doc.selection );
+				deleteContent( doc.selection, doc.batch() );
 
 				expect( getData( doc, { rootName: 'restrictedRoot' } ) )
 					.to.equal( '<blockWidget></blockWidget>[]<blockWidget></blockWidget>' );
@@ -405,7 +403,7 @@ describe( 'Delete utils', () => {
 			it( title, () => {
 				setData( doc, input );
 
-				deleteContents( doc.batch(), doc.selection, options );
+				deleteContent( doc.selection, doc.batch(), options );
 
 				expect( getData( doc ) ).to.equal( output );
 			} );

+ 0 - 1
packages/ckeditor5-engine/tests/controller/editingcontroller.js

@@ -4,7 +4,6 @@
  */
 
 /* globals setTimeout, Range, document */
-/* bender-tags: view */
 
 import EmitterMixin from 'ckeditor5/utils/emittermixin.js';
 

+ 7 - 17
packages/ckeditor5-engine/tests/controller/insert.js

@@ -3,15 +3,11 @@
  * For licensing, see LICENSE.md.
  */
 
-/* bender-tags: model */
-
 import Document from 'ckeditor5/engine/model/document.js';
 import DataController from 'ckeditor5/engine/controller/datacontroller.js';
-import insert from 'ckeditor5/engine/controller/insert.js';
+import insertContent from 'ckeditor5/engine/controller/insertcontent.js';
 
-import ViewDocumentFragment from 'ckeditor5/engine/view/documentfragment.js';
-import ViewText from 'ckeditor5/engine/view/text.js';
-import ModelDocumentFragment from 'ckeditor5/engine/model/documentfragment.js';
+import DocumentFragment from 'ckeditor5/engine/model/documentfragment.js';
 import Text from 'ckeditor5/engine/model/text.js';
 
 import { setData, getData, parse } from 'ckeditor5/engine/dev-utils/model.js';
@@ -19,7 +15,7 @@ import { setData, getData, parse } from 'ckeditor5/engine/dev-utils/model.js';
 describe( 'DataController', () => {
 	let doc, dataController;
 
-	describe( 'insert', () => {
+	describe( 'insertContent', () => {
 		it( 'uses the passed batch', () => {
 			doc = new Document();
 			doc.createRoot();
@@ -31,7 +27,7 @@ describe( 'DataController', () => {
 
 			setData( doc, 'x[]x' );
 
-			insert( dataController, new ViewDocumentFragment( [ new ViewText( 'a' ) ] ), doc.selection, batch );
+			insertContent( dataController, new DocumentFragment( [ new Text( 'a' ) ] ), doc.selection, batch );
 
 			expect( batch.deltas.length ).to.be.above( 0 );
 		} );
@@ -540,16 +536,10 @@ describe( 'DataController', () => {
 			} );
 		}
 
-		if ( !( content instanceof ModelDocumentFragment ) ) {
-			content = new ModelDocumentFragment( [ content ] );
+		if ( !( content instanceof DocumentFragment ) ) {
+			content = new DocumentFragment( [ content ] );
 		}
 
-		// Override the convertion so we get exactly the model that we defined in the content param.
-		// This way we avoid the need to write converters for everything we want to test.
-		dataController.viewToModel.convert = () => {
-			return content;
-		};
-
-		insert( dataController, new ViewDocumentFragment(), doc.selection );
+		insertContent( dataController, content, doc.selection );
 	}
 } );

+ 1 - 3
packages/ckeditor5-engine/tests/model/composer/modifyselection.js

@@ -3,11 +3,9 @@
  * For licensing, see LICENSE.md.
  */
 
-/* bender-tags: model, composer */
-
 import Document from 'ckeditor5/engine/model/document.js';
 import Selection from 'ckeditor5/engine/model/selection.js';
-import modifySelection from 'ckeditor5/engine/model/composer/modifyselection.js';
+import modifySelection from 'ckeditor5/engine/controller/modifyselection.js';
 import { setData, stringify } from 'ckeditor5/engine/dev-utils/model.js';
 
 describe( 'Delete utils', () => {

+ 20 - 0
packages/ckeditor5-engine/tests/dev-utils/view.js

@@ -12,6 +12,7 @@ import Position from 'ckeditor5/engine/view/position.js';
 import Element from 'ckeditor5/engine/view/element.js';
 import AttributeElement from 'ckeditor5/engine/view/attributeelement.js';
 import ContainerElement from 'ckeditor5/engine/view/containerelement.js';
+import EmptyElement from 'ckeditor5/engine/view/emptyelement.js';
 import Text from 'ckeditor5/engine/view/text.js';
 import Selection from 'ckeditor5/engine/view/selection.js';
 import Range from 'ckeditor5/engine/view/range.js';
@@ -332,6 +333,13 @@ describe( 'view test utils', () => {
 			const string = stringify( text, range );
 			expect( string ).to.equal( 'foo{b}ar' );
 		} );
+
+		it( 'should stringify EmptyElement', () => {
+			const img = new EmptyElement( 'img' );
+			const p = new ContainerElement( 'p', null, img );
+			expect( stringify( p, null, { showType: true } ) )
+				.to.equal( '<container:p><empty:img></empty:img></container:p>' );
+		} );
 	} );
 
 	describe( 'parse', () => {
@@ -612,5 +620,17 @@ describe( 'view test utils', () => {
 
 			expect( stringify( data ) ).to.equal( '<p><span>text</span><b>test</b></p>' );
 		} );
+
+		it( 'should parse EmptyElement', () => {
+			const parsed = parse( '<empty:img></empty:img>' );
+
+			expect( parsed ).to.be.instanceof( EmptyElement );
+		} );
+
+		it( 'should throw an error if EmptyElement is not empty', () => {
+			expect( () => {
+				parse( '<empty:img>foo bar</empty:img>' );
+			} ).to.throw( Error, 'Parse error - cannot parse inside EmptyElement.' );
+		} );
 	} );
 } );

+ 0 - 96
packages/ckeditor5-engine/tests/model/composer/composer.js

@@ -1,96 +0,0 @@
-/**
- * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
- * For licensing, see LICENSE.md.
- */
-
-/* bender-tags: model, composer */
-
-import Document from 'ckeditor5/engine/model/document.js';
-import Composer from 'ckeditor5/engine/model/composer/composer.js';
-import { setData, getData } from 'ckeditor5/engine/dev-utils/model.js';
-
-describe( 'Composer', () => {
-	let document, composer;
-
-	beforeEach( () => {
-		document = new Document();
-		document.schema.registerItem( 'p', '$block' );
-		document.createRoot();
-
-		composer = new Composer();
-	} );
-
-	describe( 'constructor()', () => {
-		it( 'attaches deleteContents default listener', () => {
-			setData( document, '<p>f[oo</p><p>ba]r</p>' );
-
-			const batch = document.batch();
-
-			composer.fire( 'deleteContents', { batch, selection: document.selection } );
-
-			expect( getData( document ) ).to.equal( '<p>f[]</p><p>r</p>' );
-			expect( batch.deltas ).to.not.be.empty;
-		} );
-
-		it( 'attaches deleteContents default listener which passes options', () => {
-			setData( document, '<p>f[oo</p><p>ba]r</p>' );
-
-			const batch = document.batch();
-
-			composer.fire( 'deleteContents', {
-				batch,
-				selection: document.selection,
-				options: { merge: true }
-			} );
-
-			expect( getData( document ) ).to.equal( '<p>f[]r</p>' );
-		} );
-
-		it( 'attaches modifySelection default listener', () => {
-			setData( document, '<p>foo[]bar</p>' );
-
-			composer.fire( 'modifySelection', {
-				selection: document.selection,
-				options: {
-					direction: 'backward'
-				}
-			} );
-
-			expect( getData( document ) )
-				.to.equal( '<p>fo[o]bar</p>' );
-			expect( document.selection.isBackward ).to.true;
-		} );
-	} );
-
-	describe( 'deleteContents', () => {
-		it( 'fires deleteContents event', () => {
-			const spy = sinon.spy();
-			const batch = document.batch();
-
-			composer.on( 'deleteContents', spy );
-
-			composer.deleteContents( batch, document.selection );
-
-			const data = spy.args[ 0 ][ 1 ];
-
-			expect( data.batch ).to.equal( batch );
-			expect( data.selection ).to.equal( document.selection );
-		} );
-	} );
-
-	describe( 'modifySelection', () => {
-		it( 'fires deleteContents event', () => {
-			const spy = sinon.spy();
-			const opts = { direction: 'backward' };
-
-			composer.on( 'modifySelection', spy );
-
-			composer.modifySelection( document.selection, opts );
-
-			const data = spy.args[ 0 ][ 1 ];
-
-			expect( data.selection ).to.equal( document.selection );
-			expect( data.options ).to.equal( opts );
-		} );
-	} );
-} );

+ 0 - 2
packages/ckeditor5-engine/tests/model/document/document.js

@@ -7,7 +7,6 @@
 
 import Document from 'ckeditor5/engine/model/document.js';
 import Schema from 'ckeditor5/engine/model/schema.js';
-import Composer from 'ckeditor5/engine/model/composer/composer.js';
 import RootElement from 'ckeditor5/engine/model/rootelement.js';
 import Batch from 'ckeditor5/engine/model/batch.js';
 import Delta from 'ckeditor5/engine/model/delta/delta.js';
@@ -31,7 +30,6 @@ describe( 'Document', () => {
 			expect( doc.graveyard.maxOffset ).to.equal( 0 );
 			expect( count( doc.selection.getRanges() ) ).to.equal( 1 );
 
-			expect( doc.composer ).to.be.instanceof( Composer );
 			expect( doc.schema ).to.be.instanceof( Schema );
 		} );
 	} );

+ 65 - 0
packages/ckeditor5-engine/tests/view/emptyelement.js

@@ -0,0 +1,65 @@
+/**
+ * @license Copyright (c) 2003-2015, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* bender-tags: view */
+
+import EmptyElement from 'ckeditor5/engine/view/emptyelement.js';
+import Element from 'ckeditor5/engine/view/element.js';
+import CKEditorError from 'ckeditor5/utils/ckeditorerror.js';
+
+describe( 'EmptyElement', () => {
+	let element, emptyElement;
+
+	beforeEach( () => {
+		element = new Element( 'b' );
+		emptyElement = new EmptyElement( 'img', {
+			alt: 'alternative text',
+			style: 'border: 1px solid red;color: white;',
+			class: 'image big'
+		} );
+	} );
+
+	it( 'should throw if child elements are passed to constructor', () => {
+		expect( () => {
+			new EmptyElement( 'img', null, [ new Element( 'i' ) ] );
+		} ).to.throw( CKEditorError, 'view-emptyelement-cannot-add: Cannot add child nodes to EmptyElement instance.' );
+	} );
+
+	describe( 'appendChildren', () => {
+		it( 'should throw when try to append new child element', () => {
+			expect( () => {
+				emptyElement.appendChildren( element );
+			} ).to.throw( CKEditorError, 'view-emptyelement-cannot-add: Cannot add child nodes to EmptyElement instance.' );
+		} );
+	} );
+
+	describe( 'insertChildren', () => {
+		it( 'should throw when try to insert new child element', () => {
+			expect( () => {
+				emptyElement.insertChildren( 0, element );
+			} ).to.throw( CKEditorError, 'view-emptyelement-cannot-add: Cannot add child nodes to EmptyElement instance.' );
+		} );
+	} );
+
+	describe( 'clone', () => {
+		it( 'should be cloned properly', () => {
+			const newEmptyElement = emptyElement.clone();
+
+			expect( newEmptyElement.name ).to.equal( 'img' );
+			expect( newEmptyElement.getAttribute( 'alt' ) ).to.equal( 'alternative text' );
+			expect( newEmptyElement.getStyle( 'border' ) ).to.equal( '1px solid red' );
+			expect( newEmptyElement.getStyle( 'color' ) ).to.equal( 'white' );
+			expect( newEmptyElement.hasClass( 'image' ) ).to.be.true;
+			expect( newEmptyElement.hasClass( 'big' ) ).to.be.true;
+			expect( newEmptyElement.isSimilar( emptyElement ) ).to.be.true;
+		} );
+	} );
+
+	describe( 'getFillerOffset', () => {
+		it( 'should return null', () => {
+			expect( emptyElement.getFillerOffset() ).to.be.null;
+		} );
+	} );
+} );

+ 23 - 0
packages/ckeditor5-engine/tests/view/writer/breakAttributes.js

@@ -9,7 +9,9 @@ import { breakAttributes } from 'ckeditor5/engine/view/writer.js';
 import { stringify, parse } from 'ckeditor5/engine/dev-utils/view.js';
 import ContainerElement from 'ckeditor5/engine/view/containerelement.js';
 import AttributeElement from 'ckeditor5/engine/view/attributeelement.js';
+import EmptyElement from 'ckeditor5/engine/view/emptyelement.js';
 import Range from 'ckeditor5/engine/view/range.js';
+import Position from 'ckeditor5/engine/view/position.js';
 import CKEditorError from 'ckeditor5/utils/ckeditorerror.js';
 
 describe( 'writer', () => {
@@ -227,6 +229,27 @@ describe( 'writer', () => {
 					'foo{}bar'
 				);
 			} );
+
+			it( 'should throw if breaking inside EmptyElement #1', () => {
+				const img = new EmptyElement( 'img' );
+				new ContainerElement( 'p', null, img );
+				const position = new Position( img, 0 );
+
+				expect( () => {
+					breakAttributes( position );
+				} ).to.throw( CKEditorError, 'view-writer-cannot-break-empty-element' );
+			} );
+
+			it( 'should throw if breaking inside EmptyElement #2', () => {
+				const img = new EmptyElement( 'img' );
+				const b = new AttributeElement( 'b' );
+				new ContainerElement( 'p', null, [ img, b ] );
+				const range = Range.createFromParentsAndOffsets( img, 0, b, 0 );
+
+				expect( () => {
+					breakAttributes( range );
+				} ).to.throw( CKEditorError, 'view-writer-cannot-break-empty-element' );
+			} );
 		} );
 	} );
 } );

+ 20 - 0
packages/ckeditor5-engine/tests/view/writer/insert.js

@@ -8,6 +8,7 @@
 import { insert } from 'ckeditor5/engine/view/writer.js';
 import ContainerElement from 'ckeditor5/engine/view/containerelement.js';
 import Element from 'ckeditor5/engine/view/element.js';
+import EmptyElement from 'ckeditor5/engine/view/emptyelement.js';
 import Position from 'ckeditor5/engine/view/position.js';
 import CKEditorError from 'ckeditor5/utils/ckeditorerror.js';
 import { stringify, parse } from 'ckeditor5/engine/dev-utils/view.js';
@@ -175,5 +176,24 @@ describe( 'writer', () => {
 				insert( position, attributeElement );
 			} ).to.throw( CKEditorError, 'view-writer-invalid-position-container' );
 		} );
+
+		it( 'should allow to insert EmptyElement into container', () => {
+			test(
+				'<container:p>[]</container:p>',
+				[ '<empty:img></empty:img>' ],
+				'<container:p>[<empty:img></empty:img>]</container:p>'
+			);
+		} );
+
+		it( 'should throw if trying to insert inside EmptyElement', () => {
+			const emptyElement = new EmptyElement( 'img' );
+			new ContainerElement( 'p', null, emptyElement );
+			const position = new Position( emptyElement, 0 );
+			const attributeElement = new AttributeElement( 'i' );
+
+			expect( () => {
+				insert( position, attributeElement );
+			} ).to.throw( CKEditorError, 'view-writer-cannot-break-empty-element' );
+		} );
 	} );
 } );

+ 7 - 0
packages/ckeditor5-engine/tests/view/writer/mergeAttributes.js

@@ -129,5 +129,12 @@ describe( 'writer', () => {
 				'<container:p>[]</container:p>'
 			);
 		} );
+
+		it( 'should not merge when placed between EmptyElements', () => {
+			test(
+				'<container:p><empty:img></empty:img>[]<empty:img></empty:img></container:p>',
+				'<container:p><empty:img></empty:img>[]<empty:img></empty:img></container:p>'
+			);
+		} );
 	} );
 } );

+ 29 - 0
packages/ckeditor5-engine/tests/view/writer/move.js

@@ -8,6 +8,12 @@
 import { move } from 'ckeditor5/engine/view/writer.js';
 import ViewPosition from 'ckeditor5/engine/view/position.js';
 import { stringify, parse } from 'ckeditor5/engine/dev-utils/view.js';
+import ContainerElement from 'ckeditor5/engine/view/containerelement.js';
+import AttributeElement from 'ckeditor5/engine/view/attributeelement.js';
+import EmptyElement from 'ckeditor5/engine/view/emptyelement.js';
+import Range from 'ckeditor5/engine/view/range.js';
+import Position from 'ckeditor5/engine/view/position.js';
+import CKEditorError from 'ckeditor5/utils/ckeditorerror.js';
 
 describe( 'writer', () => {
 	/**
@@ -112,5 +118,28 @@ describe( 'writer', () => {
 			const expectedView = '<container:p>b[<attribute:b>a}c</attribute:b></container:p>';
 			expect( stringify( view, newRange, { showType: true } ) ).to.equal( expectedView );
 		} );
+
+		it( 'should move EmptyElement', () => {
+			test(
+				'<container:p>foo[<empty:img></empty:img>]bar</container:p>',
+				'<container:div>baz{}quix</container:div>',
+				'<container:p>foobar</container:p>',
+				'<container:div>baz[<empty:img></empty:img>]quix</container:div>'
+			);
+		} );
+
+		it( 'should throw if trying to move to EmptyElement', () => {
+			const srcAttribute = new AttributeElement( 'b' );
+			const srcContainer = new ContainerElement( 'p', null, srcAttribute );
+			const srcRange = Range.createFromParentsAndOffsets( srcContainer, 0, srcContainer, 1 );
+
+			const dstEmpty = new EmptyElement( 'img' );
+			new ContainerElement( 'p', null, dstEmpty );
+			const dstPosition = new Position( dstEmpty, 0 );
+
+			expect( () => {
+				move( srcRange, dstPosition );
+			} ).to.throw( CKEditorError, 'view-writer-cannot-break-empty-element' );
+		} );
 	} );
 } );

+ 20 - 0
packages/ckeditor5-engine/tests/view/writer/remove.js

@@ -11,6 +11,7 @@ import Range from 'ckeditor5/engine/view/range.js';
 import DocumentFragment from 'ckeditor5/engine/view/documentfragment.js';
 import { stringify, parse } from 'ckeditor5/engine/dev-utils/view.js';
 import AttributeElement from 'ckeditor5/engine/view/attributeelement.js';
+import EmptyElement from 'ckeditor5/engine/view/emptyelement.js';
 import CKEditorError from 'ckeditor5/utils/ckeditorerror.js';
 
 describe( 'writer', () => {
@@ -110,5 +111,24 @@ describe( 'writer', () => {
 		it( 'should remove part of the text node in document fragment', () => {
 			test( 'fo{ob}ar', 'fo{}ar', 'ob' );
 		} );
+
+		it( 'should remove EmptyElement', () => {
+			test(
+				'<container:p>foo[<empty:img></empty:img>]bar</container:p>',
+				'<container:p>foo{}bar</container:p>',
+				'<empty:img></empty:img>'
+			);
+		} );
+
+		it( 'should throw if range is placed inside EmptyElement', () => {
+			const emptyElement = new EmptyElement( 'img' );
+			const attributeElement = new AttributeElement( 'b' );
+			new ContainerElement( 'p', null, [ emptyElement, attributeElement ] );
+			const range = Range.createFromParentsAndOffsets( emptyElement, 0, attributeElement, 0 );
+
+			expect( () => {
+				remove( range );
+			} ).to.throw( CKEditorError, 'view-writer-cannot-break-empty-element' );
+		} );
 	} );
 } );

+ 20 - 0
packages/ckeditor5-engine/tests/view/writer/unwrap.js

@@ -9,6 +9,7 @@ import { unwrap } from 'ckeditor5/engine/view/writer.js';
 import Element from 'ckeditor5/engine/view/element.js';
 import ContainerElement from 'ckeditor5/engine/view/containerelement.js';
 import AttributeElement from 'ckeditor5/engine/view/attributeelement.js';
+import EmptyElement from 'ckeditor5/engine/view/emptyelement.js';
 import Position from 'ckeditor5/engine/view/position.js';
 import Range from 'ckeditor5/engine/view/range.js';
 import Text from 'ckeditor5/engine/view/text.js';
@@ -298,5 +299,24 @@ describe( 'writer', () => {
 				'<container:p>[foobar]</container:p>'
 			);
 		} );
+
+		it( 'should unwrap EmptyElement', () => {
+			test(
+				'<container:p>[<attribute:b><empty:img></empty:img></attribute:b>]</container:p>',
+				'<attribute:b></attribute:b>',
+				'<container:p>[<empty:img></empty:img>]</container:p>'
+			);
+		} );
+
+		it( 'should throw if range is inside EmptyElement', () => {
+			const empty = new EmptyElement( 'img' );
+			const attribute = new AttributeElement( 'b' );
+			const container = new ContainerElement( 'p', null, [ empty, attribute ] );
+			const range = Range.createFromParentsAndOffsets( empty, 0, container, 2 );
+
+			expect( () => {
+				unwrap( range, attribute );
+			} ).to.throw( CKEditorError, 'view-writer-cannot-break-empty-element' );
+		} );
 	} );
 } );

+ 19 - 0
packages/ckeditor5-engine/tests/view/writer/wrap.js

@@ -9,6 +9,7 @@ import { wrap } from 'ckeditor5/engine/view/writer.js';
 import Element from 'ckeditor5/engine/view/element.js';
 import ContainerElement from 'ckeditor5/engine/view/containerelement.js';
 import AttributeElement from 'ckeditor5/engine/view/attributeelement.js';
+import EmptyElement from 'ckeditor5/engine/view/emptyelement.js';
 import Position from 'ckeditor5/engine/view/position.js';
 import Range from 'ckeditor5/engine/view/range.js';
 import Text from 'ckeditor5/engine/view/text.js';
@@ -270,5 +271,23 @@ describe( 'writer', () => {
 				'</container:p>'
 			);
 		} );
+
+		it( 'should wrap EmptyElement', () => {
+			test(
+				'<container:p>[<empty:img></empty:img>]</container:p>',
+				'<attribute:b></attribute:b>',
+				'<container:p>[<attribute:b view-priority="10"><empty:img></empty:img></attribute:b>]</container:p>'
+			);
+		} );
+
+		it( 'should throw if range is inside EmptyElement', () => {
+			const emptyElement = new EmptyElement( 'img' );
+			const container = new ContainerElement( 'p', null, emptyElement );
+			const range = Range.createFromParentsAndOffsets( emptyElement, 0, container, 1 );
+
+			expect( () => {
+				wrap( range, new AttributeElement( 'b' ) );
+			} ).to.throw( CKEditorError, 'view-writer-cannot-break-empty-element' );
+		} );
 	} );
 } );

+ 13 - 0
packages/ckeditor5-engine/tests/view/writer/wrapposition.js

@@ -9,6 +9,8 @@ import { wrapPosition } from 'ckeditor5/engine/view/writer.js';
 import Text from 'ckeditor5/engine/view/text.js';
 import Element from 'ckeditor5/engine/view/element.js';
 import ContainerElement from 'ckeditor5/engine/view/containerelement.js';
+import AttributeElement from 'ckeditor5/engine/view/attributeelement.js';
+import EmptyElement from 'ckeditor5/engine/view/emptyelement.js';
 import Position from 'ckeditor5/engine/view/position.js';
 import CKEditorError from 'ckeditor5/utils/ckeditorerror.js';
 import { stringify, parse } from 'ckeditor5/engine/dev-utils/view.js';
@@ -122,4 +124,15 @@ describe( 'wrapPosition', () => {
 			'<container:p><attribute:b view-priority="1">foobar{}</attribute:b></container:p>'
 		);
 	} );
+
+	it( 'should throw if position is set inside EmptyElement', () => {
+		const emptyElement = new EmptyElement( 'img' );
+		new ContainerElement( 'p', null, emptyElement );
+		const attributeElement = new AttributeElement( 'b' );
+		const position = new Position( emptyElement, 0 );
+
+		expect( () => {
+			wrapPosition( position, attributeElement );
+		} ).to.throw( CKEditorError, 'view-emptyelement-cannot-add' );
+	} );
 } );