ソースを参照

Merge pull request #798 from ckeditor/t/788

Feature: Introduced `view.UIElement` and `view.writer.clear()` method. Closes #788.

BREAKING CHANGES: Removed `view.DocumentFragment#getAncestors()`. Closes #803. Closes #805.
BREAKING CHANGES: `Position.getAncestors()` should return elements in the same order as `Node.getAncestors()`.
Piotrek Koszuliński 9 年 前
コミット
d5b09493eb
25 ファイル変更711 行追加59 行削除
  1. 32 15
      packages/ckeditor5-engine/src/dev-utils/view.js
  2. 5 1
      packages/ckeditor5-engine/src/model/position.js
  3. 0 9
      packages/ckeditor5-engine/src/view/documentfragment.js
  4. 13 7
      packages/ckeditor5-engine/src/view/position.js
  5. 62 0
      packages/ckeditor5-engine/src/view/uielement.js
  6. 110 16
      packages/ckeditor5-engine/src/view/writer.js
  7. 6 0
      packages/ckeditor5-engine/tests/dev-utils/model.js
  8. 20 0
      packages/ckeditor5-engine/tests/dev-utils/view.js
  9. 7 1
      packages/ckeditor5-engine/tests/model/position.js
  10. 25 0
      packages/ckeditor5-engine/tests/model/treewalker.js
  11. 0 8
      packages/ckeditor5-engine/tests/view/documentfragment.js
  12. 34 1
      packages/ckeditor5-engine/tests/view/position.js
  13. 18 0
      packages/ckeditor5-engine/tests/view/treewalker.js
  14. 73 0
      packages/ckeditor5-engine/tests/view/uielement.js
  15. 22 0
      packages/ckeditor5-engine/tests/view/writer/breakattributes.js
  16. 0 0
      packages/ckeditor5-engine/tests/view/writer/breakcontainer.js
  17. 169 0
      packages/ckeditor5-engine/tests/view/writer/clear.js
  18. 12 0
      packages/ckeditor5-engine/tests/view/writer/insert.js
  19. 7 0
      packages/ckeditor5-engine/tests/view/writer/mergeattributes.js
  20. 0 0
      packages/ckeditor5-engine/tests/view/writer/mergecontainers.js
  21. 24 0
      packages/ckeditor5-engine/tests/view/writer/move.js
  22. 21 1
      packages/ckeditor5-engine/tests/view/writer/remove.js
  23. 20 0
      packages/ckeditor5-engine/tests/view/writer/unwrap.js
  24. 19 0
      packages/ckeditor5-engine/tests/view/writer/wrap.js
  25. 12 0
      packages/ckeditor5-engine/tests/view/writer/wrapposition.js

+ 32 - 15
packages/ckeditor5-engine/src/dev-utils/view.js

@@ -21,6 +21,7 @@ import Position from '../view/position';
 import AttributeElement from '../view/attributeelement';
 import ContainerElement from '../view/containerelement';
 import EmptyElement from '../view/emptyelement';
+import UIElement from '../view/uielement';
 import ViewText from '../view/text';
 
 const ELEMENT_RANGE_START_TOKEN = '[';
@@ -31,6 +32,7 @@ const allowedTypes = {
 	'container': ContainerElement,
 	'attribute': AttributeElement,
 	'empty': EmptyElement,
+	'ui': UIElement
 };
 
 /**
@@ -168,14 +170,17 @@ setData._parse = parse;
  * If `options.showType` is set to `true`, element's types will be
  * presented for {@link module:engine/view/attributeelement~AttributeElement AttributeElements},
  * {@link module:engine/view/containerelement~ContainerElement ContainerElements}
- * and {@link module:engine/view/emptyelement~EmptyElement EmptyElements}:
+ * {@link module:engine/view/emptyelement~EmptyElement EmptyElements}
+ * and {@link module:engine/view/uielement~UIElement UIElements}:
  *
  *		const attribute = new AttributeElement( 'b' );
  *		const container = new ContainerElement( 'p' );
  *		const empty = new EmptyElement( 'img' );
+ *		const ui = new UIElement( 'span' );
  *		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>'
+ *		getData( ui, null, { showType: true } ); // '<ui:span></ui:span>'
  *
  * If `options.showPriority` is set to `true`, priority will be displayed for all
  * {@link module:engine/view/attributeelement~AttributeElement AttributeElements}.
@@ -432,8 +437,7 @@ class RangeParser {
 
 				brackets.push( {
 					bracket: bracket,
-					textOffset: index - offset,
-					outer: index === 0 || index == node._data.length - 1
+					textOffset: index - offset
 				} );
 
 				offset++;
@@ -453,7 +457,7 @@ class RangeParser {
 				// Non-empty text node.
 				if ( text ) {
 					if (
-						( this.sameSelectionCharacters && !item.outer ) ||
+						this.sameSelectionCharacters ||
 						( !this.sameSelectionCharacters && ( item.bracket == TEXT_RANGE_START_TOKEN || item.bracket == TEXT_RANGE_END_TOKEN ) )
 					) {
 						// Store information about text range delimiter.
@@ -765,10 +769,13 @@ class ViewStringify {
 
 	/**
 	 * Converts passed {@link module:engine/view/element~Element Element's} type to its string representation
-	 * Returns 'attribute' for {@link module:engine/view/attributeelement~AttributeElement AttributeElements},
-	 * 'container' for {@link module:engine/view/containerelement~ContainerElement ContainerElements} and 'empty' for
-	 * {@link module:engine/view/emptyelement~EmptyElement EmptyElements}. Returns empty string when current configuration is preventing
-	 * showing elements' types.
+	 *
+	 * Returns:
+	 * * 'attribute' for {@link module:engine/view/attributeelement~AttributeElement AttributeElements},
+	 * * 'container' for {@link module:engine/view/containerelement~ContainerElement ContainerElements},
+	 * * 'empty' for {@link module:engine/view/emptyelement~EmptyElement EmptyElements}.
+	 * * 'ui' for {@link module:engine/view/uielement~UIElement UIElements}.
+	 * * empty string when current configuration is preventing showing elements' types.
 	 *
 	 * @private
 	 * @param {module:engine/view/element~Element} element
@@ -826,8 +833,9 @@ class ViewStringify {
 
 // Converts {@link module:engine/view/element~Element Elements} to
 // {@link module:engine/view/attributeelement~AttributeElement AttributeElements},
-// {@link module:engine/view/containerelement~ContainerElement ContainerElements} or
-// {@link module:engine/view/emptyelement~EmptyElement EmptyElements}.
+// {@link module:engine/view/containerelement~ContainerElement ContainerElements},
+// {@link module:engine/view/emptyelement~EmptyElement EmptyElements} or
+// {@link module:engine/view/uielement~UIElement UIElements}.
 // It converts whole tree starting from the `rootNode`. Conversion is based on element names.
 // See `_convertElement` method for more details.
 //
@@ -848,6 +856,10 @@ function _convertViewElements( rootNode ) {
 				throw new Error( `Parse error - cannot parse inside EmptyElement.` );
 			}
 
+			if ( convertedElement instanceof UIElement ) {
+				throw new Error( `Parse error - cannot parse inside UIElement.` );
+			}
+
 			convertedElement.appendChildren( _convertViewElements( child ) );
 		}
 
@@ -859,19 +871,23 @@ function _convertViewElements( rootNode ) {
 
 // Converts {@link module:engine/view/element~Element Element} to
 // {@link module:engine/view/attributeelement~AttributeElement AttributeElement},
-// {@link module:engine/view/containerelement~ContainerElement ContainerElement} or
-// {@link module:engine/view/emptyelement~EmptyElement EmptyElement}.
+// {@link module:engine/view/containerelement~ContainerElement ContainerElement},
+// {@link module:engine/view/emptyelement~EmptyElement EmptyElement} or
+// {@link module:engine/view/uielement~UIElement UIElement}.
 // If element's name is in format `attribute:b` with `view-priority="11"` attribute it will be converted to
 // {@link module:engine/view/attributeelement~AttributeElement AttributeElement} with priority 11.
 // If element's name is in format `container:p` - it will be converted to
 // {@link module:engine/view/containerelement~ContainerElement ContainerElement}.
 // If element's name is in format `empty:img` - it will be converted to
 // {@link module:engine/view/emptyelement~EmptyElement EmptyElement}.
+// If element's name is in format `ui:span` - it will be converted to
+// {@link module:engine/view/uielement~UIElement UIElement}.
 // If element's name will not contain any additional information - {@link module:engine/view/element~Element view Element} will be
 // returned.
 //
 // @param {module:engine/view/element~Element} viewElement View element to convert.
 // @returns {module:engine/view/element~Element|module:engine/view/attributeelement~AttributeElement|
+// module:engine/view/emptyelement~EmptyElement|module:engine/view/uielement~UIElement|
 // module:engine/view/containerelement~ContainerElement} Tree view
 // element converted according to it's name.
 function _convertElement( viewElement ) {
@@ -895,10 +911,11 @@ function _convertElement( viewElement ) {
 
 // Converts `view-priority` attribute and {@link module:engine/view/element~Element#name Element's name} information needed for creating
 // {@link module:engine/view/attributeelement~AttributeElement AttributeElement},
-// {@link module:engine/view/containerelement~ContainerElement ContainerElement} or
-// {@link module:engine/view/emptyelement~EmptyElement EmptyElement} instance.
+// {@link module:engine/view/containerelement~ContainerElement ContainerElement},
+// {@link module:engine/view/emptyelement~EmptyElement EmptyElement} or,
+// {@link module:engine/view/uielement~UIElement UIElement}.
 // Name can be provided in two formats: as a simple element's name (`div`), or as a type and name (`container:div`,
-// `attribute:span`, `empty:img`);
+// `attribute:span`, `empty:img`, `ui:span`);
 //
 // @param {module:engine/view/element~Element} element Element which name should be converted.
 // @returns {Object} info Object with parsed information.

+ 5 - 1
packages/ckeditor5-engine/src/model/position.js

@@ -262,7 +262,11 @@ export default class Position {
 	 * @returns {Array.<module:engine/model/item~Item>} Array with ancestors.
 	 */
 	getAncestors() {
-		return this.parent.getAncestors( { includeNode: true, parentFirst: true } );
+		if ( this.parent instanceof DocumentFragment ) {
+			return [ this.parent ];
+		} else {
+			return this.parent.getAncestors( { includeNode: true } );
+		}
 	}
 
 	/**

+ 0 - 9
packages/ckeditor5-engine/src/view/documentfragment.js

@@ -83,15 +83,6 @@ export default class DocumentFragment {
 		return null;
 	}
 
-	/**
-	 * Returns ancestor elements of `DocumentFragment`, which is an empty array. Added for compatibility reasons.
-	 *
-	 * @returns {Array}
-	 */
-	getAncestors() {
-		return [];
-	}
-
 	/**
 	 * {@link module:engine/view/documentfragment~DocumentFragment#insertChildren Insert} a child node or a list of child nodes at the end
 	 * and sets the parent of these nodes to this fragment.

+ 13 - 7
packages/ckeditor5-engine/src/view/position.js

@@ -9,6 +9,7 @@
 
 import Text from './text';
 import TextProxy from './textproxy';
+import DocumentFragment from './documentfragment';
 
 import compareArrays from '@ckeditor/ckeditor5-utils/src/comparearrays';
 import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
@@ -21,14 +22,15 @@ export default class Position {
 	/**
 	 * Creates a position.
 	 *
-	 * @param {module:engine/view/node~Node} parent Position parent node.
+	 * @param {module:engine/view/node~Node|module:engine/view/documentfragment~DocumentFragment} parent Position parent.
 	 * @param {Number} offset Position offset.
 	 */
 	constructor( parent, offset ) {
 		/**
-		 * Position parent node.
+		 * Position parent.
 		 *
-		 * @member {module:engine/view/node~Node} module:engine/view/position~Position#parent
+		 * @member {module:engine/view/node~Node|module:engine/view/documentfragment~DocumentFragment}
+		 * module:engine/view/position~Position#parent
 		 */
 		this.parent = parent;
 
@@ -143,7 +145,11 @@ export default class Position {
 	 * @returns {Array} Array with ancestors.
 	 */
 	getAncestors() {
-		return this.parent.getAncestors( { includeNode: true, parentFirst: true } );
+		if ( this.parent instanceof DocumentFragment ) {
+			return [ this.parent ];
+		} else {
+			return this.parent.getAncestors( { includeNode: true } );
+		}
 	}
 
 	/**
@@ -153,7 +159,7 @@ export default class Position {
 	 * @returns {Boolean} True if positions are same.
 	 */
 	isEqual( otherPosition ) {
-		return this == otherPosition || ( this.parent == otherPosition.parent && this.offset == otherPosition.offset );
+		return ( this.parent == otherPosition.parent && this.offset == otherPosition.offset );
 	}
 
 	/**
@@ -202,8 +208,8 @@ export default class Position {
 		}
 
 		// Get path from root to position's parent element.
-		const path = this.parent.getAncestors( { includeNode: true } );
-		const otherPath = otherPosition.parent.getAncestors( { includeNode: true } );
+		const path = this.getAncestors();
+		const otherPath = otherPosition.getAncestors();
 
 		// Compare both path arrays to find common ancestor.
 		const result = compareArrays( path, otherPath );

+ 62 - 0
packages/ckeditor5-engine/src/view/uielement.js

@@ -0,0 +1,62 @@
+/**
+ * @license Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/**
+ * @module engine/view/uielement
+ */
+
+import Element from './element';
+import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
+import Node from './node';
+
+/**
+ * UIElement class. It is used to represent UI not a content of the document.
+ * This element can't be split and selection can't be placed inside this element.
+ */
+export default class UIElement extends Element {
+	/**
+	 * Creates new instance of UIElement.
+	 *
+	 * Throws {@link module:utils/ckeditorerror~CKEditorError CKEditorError} `view-uielement-cannot-add` when third parameter is passed,
+	 * to inform that usage of UIElement is incorrect (adding child nodes to UIElement is forbidden).
+	 *
+	 * @param {String} name Node name.
+	 * @param {Object|Iterable} [attributes] Collection of attributes.
+	 */
+	constructor( name, attributes, children ) {
+		super( name, attributes, children );
+
+		/**
+		 * Returns `null` because filler is not needed for UIElements.
+		 *
+		 * @method #getFillerOffset
+		 * @returns {null} Always returns null.
+		 */
+		this.getFillerOffset = getFillerOffset;
+	}
+
+	/**
+	 * Overrides {@link module:engine/view/element~Element#insertChildren} method.
+	 * Throws {@link module:utils/ckeditorerror~CKEditorError CKEditorError} `view-uielement-cannot-add` to prevent adding any child nodes
+	 * to UIElement.
+	 */
+	insertChildren( index, nodes ) {
+		if ( nodes && ( nodes instanceof Node || Array.from( nodes ).length > 0 ) ) {
+			/**
+			 * Cannot add children to {@link module:engine/view/uielement~UIElement}.
+			 *
+			 * @error view-uielement-cannot-add
+			 */
+			throw new CKEditorError( 'view-uielement-cannot-add: Cannot add child nodes to UIElement instance.' );
+		}
+	}
+}
+
+// Returns `null` because block filler is not needed for UIElements.
+//
+// @returns {null}
+function getFillerOffset() {
+	return null;
+}

+ 110 - 16
packages/ckeditor5-engine/src/view/writer.js

@@ -11,7 +11,10 @@ import Position from './position';
 import ContainerElement from './containerelement';
 import AttributeElement from './attributeelement';
 import EmptyElement from './emptyelement';
+import UIElement from './uielement';
+import Element from './element';
 import Text from './text';
+import TextProxy from './textproxy';
 import Range from './range';
 import TreeWalker from './treewalker';
 import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
@@ -31,6 +34,7 @@ const writer = {
 	mergeContainers,
 	insert,
 	remove,
+	clear,
 	move,
 	wrap,
 	wrapPosition,
@@ -69,6 +73,10 @@ export default writer;
  * when trying to break attributes
  * inside {@link module:engine/view/emptyelement~EmptyElement EmptyElement}.
  *
+ * Throws {@link module:utils/ckeditorerror~CKEditorError CKEditorError} `view-writer-cannot-break-ui-element`
+ * when trying to break attributes
+ * inside {@link module:engine/view/uielement~UIElement UIElement}.
+ *
  * @see module:engine/view/attributeelement~AttributeElement
  * @see module:engine/view/containerelement~ContainerElement
  * @see module:engine/view/writer~writer.breakContainer
@@ -210,6 +218,11 @@ export function mergeAttributes( position ) {
 		return position;
 	}
 
+	// When one or both nodes are UIElements - no attributes to merge.
+	if ( ( nodeBefore instanceof UIElement ) || ( nodeAfter instanceof UIElement ) ) {
+		return position;
+	}
+
 	// When position is between two text nodes.
 	if ( nodeBefore instanceof Text && nodeAfter instanceof Text ) {
 		return mergeTextNodes( nodeBefore, nodeAfter );
@@ -310,22 +323,23 @@ export function breakViewRangePerContainer( range ) {
  * Throws {@link module:utils/ckeditorerror~CKEditorError CKEditorError} `view-writer-insert-invalid-node` when nodes to insert
  * contains instances that are not {@link module:engine/view/text~Text Texts},
  * {@link module:engine/view/attributeelement~AttributeElement AttributeElements},
- * {@link module:engine/view/containerelement~ContainerElement ContainerElements} or
- * {@link module:engine/view/emptyelement~EmptyElement EmptyElements}.
+ * {@link module:engine/view/containerelement~ContainerElement ContainerElements},
+ * {@link module:engine/view/emptyelement~EmptyElement EmptyElements} or
+ * {@link module:engine/view/uielement~UIElement UIElements}.
  *
  * @function insert
  * @param {module:engine/view/position~Position} position Insertion position.
  * @param {module:engine/view/text~Text|module:engine/view/attributeelement~AttributeElement|
- * module:engine/view/containerelement~ContainerElement|module:engine/view/emptyelement~EmptyElement
- * |Iterable.<module:engine/view/text~Text|module:engine/view/attributeelement~AttributeElement|
- * module:engine/view/containerelement~ContainerElement|module:engine/view/emptyelement~EmptyElement>} nodes Node or
- * nodes to insert.
+ * module:engine/view/containerelement~ContainerElement|module:engine/view/emptyelement~EmptyElement|
+ * module:engine/view/uielement~UIElement|Iterable.<module:engine/view/text~Text|
+ * module:engine/view/attributeelement~AttributeElement|module:engine/view/containerelement~ContainerElement|
+ * module:engine/view/emptyelement~EmptyElement|module:engine/view/uielement~UIElement>} nodes Node or nodes to insert.
  * @returns {module:engine/view/range~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, EmptyElements or Text.
+	// Check if nodes to insert are instances of AttributeElements, ContainerElements, EmptyElements, UIElements or Text.
 	validateNodesToInsert( nodes );
 
 	const container = getParentContainer( position );
@@ -398,6 +412,66 @@ export function remove( range ) {
 	return new DocumentFragment( removed );
 }
 
+/**
+ * Removes matching elements from given range.
+ *
+ * Throws {@link module:utils/ckeditorerror~CKEditorError CKEditorError} `view-writer-invalid-range-container` when
+ * {@link module:engine/view/range~Range#start start} and {@link module:engine/view/range~Range#end end} positions are not placed inside
+ * same parent container.
+ *
+ * @function module:engine/view/writer~writer.clear
+ * @param {module:engine/view/range~Range} range Range to clear.
+ * @param {module:engine/view/element~Element} element Element to remove.
+ */
+export function clear( range, element ) {
+	validateRangeContainer( range );
+
+	// Create walker on given range.
+	// We walk backward because when we remove element during walk it modifies range end position.
+	const walker = range.getWalker( {
+		direction: 'backward',
+		ignoreElementEnd: true
+	} );
+
+	// Let's walk.
+	for ( const current of walker ) {
+		const item = current.item;
+		let rangeToRemove;
+
+		// When current item matches to the given element.
+		if ( item instanceof Element && element.isSimilar( item ) ) {
+			// Create range on this element.
+			rangeToRemove = Range.createOn( item );
+		// When range starts inside Text or TextProxy element.
+		} else if ( !current.nextPosition.isAfter( range.start ) && ( item instanceof Text || item instanceof TextProxy ) ) {
+			// We need to check if parent of this text matches to given element.
+			const parentElement = item.getAncestors().find( ( ancestor ) => {
+				return ancestor instanceof Element && element.isSimilar( ancestor );
+			} );
+
+			// If it is then create range inside this element.
+			if ( parentElement ) {
+				rangeToRemove = Range.createIn( parentElement );
+			}
+		}
+
+		// If we have found element to remove.
+		if ( rangeToRemove ) {
+			// We need to check if element range stick out of the given range and truncate if it is.
+			if ( rangeToRemove.end.isAfter( range.end ) ) {
+				rangeToRemove.end = range.end;
+			}
+
+			if ( rangeToRemove.start.isBefore( range.start ) ) {
+				rangeToRemove.start = range.start;
+			}
+
+			// At the end we remove range with found element.
+			remove( rangeToRemove );
+		}
+	}
+}
+
 /**
  * Moves nodes from provided range to target position.
  *
@@ -696,8 +770,11 @@ function _breakAttributesRange( range, forceSplitText = false ) {
 // Function used by public breakAttributes (without splitting text nodes) and by other methods (with
 // splitting text nodes).
 //
-// Throws {@link module:utils/ckeditorerror~CKEditorError CKEditorError} `view-writer-cannot-break-empty-element` break position is placed
-// inside {@link module:engine/view/emptyelement~EmptyElement EmptyElement}.
+// Throws {@link module:utils/ckeditorerror~CKEditorError CKEditorError} `view-writer-cannot-break-empty-element` when break position
+// is placed inside {@link module:engine/view/emptyelement~EmptyElement EmptyElement}.
+//
+// Throws {@link module:utils/ckeditorerror~CKEditorError CKEditorError} `view-writer-cannot-break-ui-element` when break position
+// is placed inside {@link module:engine/view/uielement~UIElement UIElement}.
 //
 // @param {module:engine/view/position~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
@@ -718,6 +795,16 @@ function _breakAttributes( position, forceSplitText = false ) {
 		throw new CKEditorError( 'view-writer-cannot-break-empty-element' );
 	}
 
+	// If position is placed inside UIElement - throw an exception as we cannot break inside.
+	if ( position.parent instanceof UIElement ) {
+		/**
+		 * Cannot break inside UIElement instance.
+		 *
+		 * @error view-writer-cannot-break-ui-element
+		 */
+		throw new CKEditorError( 'view-writer-cannot-break-ui-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 );
@@ -861,9 +948,10 @@ function wrapChildren( parent, startOffset, endOffset, attribute ) {
 		const isText = child instanceof Text;
 		const isAttribute = child instanceof AttributeElement;
 		const isEmpty = child instanceof EmptyElement;
+		const isUI = child instanceof UIElement;
 
-		// Wrap text, empty elements or attributes with higher or equal priority.
-		if ( isText || isEmpty || ( isAttribute && attribute.priority <= child.priority ) ) {
+		// Wrap text, empty elements, ui elements or attributes with higher or equal priority.
+		if ( isText || isEmpty || isUI || ( isAttribute && attribute.priority <= child.priority ) ) {
 			// Clone attribute.
 			const newAttribute = attribute.clone();
 
@@ -1108,12 +1196,14 @@ function rangeSpansOnAllChildren( range ) {
 
 // Checks if provided nodes are valid to insert. Checks if each node is an instance of
 // {@link module:engine/view/text~Text Text} or {@link module:engine/view/attributeelement~AttributeElement AttributeElement},
-// {@link module:engine/view/containerelement~ContainerElement ContainerElement} or
-// {@link module:engine/view/emptyelement~EmptyElement EmptyElement}.
+// {@link module:engine/view/containerelement~ContainerElement ContainerElement},
+// {@link module:engine/view/emptyelement~EmptyElement EmptyElement} or
+// {@link module:engine/view/uielement~UIElement UIElement}.
 //
 // Throws {@link module:utils/ckeditorerror~CKEditorError CKEditorError} `view-writer-insert-invalid-node` when nodes to insert
 // contains instances that are not {@link module:engine/view/text~Text Texts},
 // {@link module:engine/view/emptyelement~EmptyElement EmptyElements},
+// {@link module:engine/view/uielement~UIElement UIElements},
 // {@link module:engine/view/attributeelement~AttributeElement AttributeElements} or
 // {@link module:engine/view/containerelement~ContainerElement ContainerElements}.
 //
@@ -1121,10 +1211,12 @@ function rangeSpansOnAllChildren( range ) {
 // |module:engine/view/containerelement~ContainerElement> nodes
 function validateNodesToInsert( nodes ) {
 	for ( let node of nodes ) {
-		if ( !( node instanceof Text || node instanceof AttributeElement || node instanceof ContainerElement || node instanceof EmptyElement ) ) {
+		if ( !validNodesToInsert.some( ( validNode => node instanceof validNode ) ) ) {
 			/**
-			 * Inserted nodes should be instance of {@link module:engine/view/attributeelement~AttributeElement AttributeElement},
-			 * {@link module:engine/view/containerelement~ContainerElement ContainerElement} or {@link module:engine/view/text~Text Text}.
+			 * Inserted nodes should be valid to insert. of {@link module:engine/view/attributeelement~AttributeElement AttributeElement},
+			 * {@link module:engine/view/containerelement~ContainerElement ContainerElement},
+			 * {@link module:engine/view/emptyelement~EmptyElement EmptyElement},
+			 * {@link module:engine/view/uielement~UIElement UIElement}, {@link module:engine/view/text~Text Text}.
 			 *
 			 * @error view-writer-insert-invalid-node
 			 */
@@ -1137,6 +1229,8 @@ function validateNodesToInsert( nodes ) {
 	}
 }
 
+const validNodesToInsert = [ Text, AttributeElement, ContainerElement, EmptyElement, UIElement ];
+
 // Checks if node is ContainerElement or DocumentFragment, because in most cases they should be treated the same way.
 //
 // @param {module:engine/view/node~Node} node

+ 6 - 0
packages/ckeditor5-engine/tests/dev-utils/model.js

@@ -151,6 +151,12 @@ describe( 'model test utils', () => {
 			} ).to.throw( TypeError, 'Document needs to be an instance of module:engine/model/document~Document.' );
 		} );
 
+		it( 'should set attributes to the selection', () => {
+			setData( document, '<b>[foo bar]</b>', { selectionAttributes: { foo: 'bar' } } );
+
+			expect( document.selection.getAttribute( 'foo' ) ).to.equal( 'bar' );
+		} );
+
 		function test( data, expected ) {
 			expected = expected || data;
 

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

@@ -12,6 +12,7 @@ import Element from '../../src/view/element';
 import AttributeElement from '../../src/view/attributeelement';
 import ContainerElement from '../../src/view/containerelement';
 import EmptyElement from '../../src/view/emptyelement';
+import UIElement from '../../src/view/uielement';
 import Text from '../../src/view/text';
 import Selection from '../../src/view/selection';
 import Range from '../../src/view/range';
@@ -347,6 +348,13 @@ describe( 'view test utils', () => {
 			expect( stringify( p, null, { showType: true } ) )
 				.to.equal( '<container:p><empty:img></empty:img></container:p>' );
 		} );
+
+		it( 'should stringify UIElement', () => {
+			const span = new UIElement( 'span' );
+			const p = new ContainerElement( 'p', null, span );
+			expect( stringify( p, null, { showType: true } ) )
+				.to.equal( '<container:p><ui:span></ui:span></container:p>' );
+		} );
 	} );
 
 	describe( 'parse', () => {
@@ -634,10 +642,22 @@ describe( 'view test utils', () => {
 			expect( parsed ).to.be.instanceof( EmptyElement );
 		} );
 
+		it( 'should parse UIElement', () => {
+			const parsed = parse( '<ui:span></ui:span>' );
+
+			expect( parsed ).to.be.instanceof( UIElement );
+		} );
+
 		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.' );
 		} );
+
+		it( 'should throw an error if UIElement is not empty', () => {
+			expect( () => {
+				parse( '<ui:span>foo bar</ui:span>' );
+			} ).to.throw( Error, 'Parse error - cannot parse inside UIElement.' );
+		} );
 	} );
 } );

+ 7 - 1
packages/ckeditor5-engine/tests/model/position.js

@@ -503,7 +503,13 @@ describe( 'position', () => {
 
 	describe( 'getAncestors', () => {
 		it( 'should return position parent element and it\'s ancestors', () => {
-			expect( new Position( root, [ 1, 1, 1 ] ).getAncestors() ).to.deep.equal( [ li2, ul, root ] );
+			expect( new Position( root, [ 1, 1, 1 ] ).getAncestors() ).to.deep.equal( [ root, ul, li2 ] );
+		} );
+
+		it( 'should return DocumentFragment if position is directly in document fragment', () => {
+			const docFrag = new DocumentFragment();
+
+			expect( new Position( docFrag, [ 0 ] ).getAncestors() ).to.deep.equal( [ docFrag ] );
 		} );
 	} );
 

+ 25 - 0
packages/ckeditor5-engine/tests/model/treewalker.js

@@ -4,6 +4,7 @@
  */
 
 import Document from '../../src/model/document';
+import DocumentFragment from '../../src/model/documentfragment';
 import Element from '../../src/model/element';
 import Text from '../../src/model/text';
 import TreeWalker from '../../src/model/treewalker';
@@ -537,6 +538,30 @@ describe( 'TreeWalker', () => {
 			} );
 		} );
 	} );
+
+	it( 'should iterate over document fragment', () => {
+		const foo = new Text( 'foo' );
+		const bar = new Text( 'bar' );
+		const p = new Element( 'p', null, [ foo, bar ] );
+		const docFrag = new DocumentFragment( [ p ] );
+
+		const iterator = new TreeWalker( {
+			startPosition: new Position( docFrag, [ 0 ] ),
+			ignoreElementEnd: true
+		} );
+
+		const expected = [
+			{ type: 'elementStart', item: p },
+			{ type: 'text', data: 'foo', attrs: [] },
+			{ type: 'text', data: 'bar', attrs: [] }
+		];
+
+		let i = 0;
+
+		for ( let value of iterator ) {
+			expectValue( value, expected[ i++ ], { ignoreElementEnd: true } );
+		}
+	} );
 } );
 
 function expectValue( value, expected, options ) {

+ 0 - 8
packages/ckeditor5-engine/tests/view/documentfragment.js

@@ -55,14 +55,6 @@ describe( 'DocumentFragment', () => {
 		} );
 	} );
 
-	describe( 'getAncestors', () => {
-		it( 'should return empty array', () => {
-			const fragment = new DocumentFragment();
-
-			expect( fragment.getAncestors() ).to.deep.equal( [] );
-		} );
-	} );
-
 	describe( 'isEmpty', () => {
 		it( 'should return true if there are no children in document fragment', () => {
 			const fragment = new DocumentFragment();

+ 34 - 1
packages/ckeditor5-engine/tests/view/position.js

@@ -121,7 +121,13 @@ describe( 'Position', () => {
 			const div = new Element( 'div', null, p );
 			const docFrag = new DocumentFragment( div );
 
-			expect( new Position( foo, 1 ).getAncestors() ).to.deep.equal( [ foo, p, div, docFrag ] );
+			expect( new Position( foo, 1 ).getAncestors() ).to.deep.equal( [ docFrag, div, p, foo ] );
+		} );
+
+		it( 'should return DocumentFragment if position is directly in document fragment', () => {
+			const docFrag = new DocumentFragment();
+
+			expect( new Position( docFrag, 0 ).getAncestors() ).to.deep.equal( [ docFrag ] );
 		} );
 	} );
 
@@ -174,6 +180,19 @@ describe( 'Position', () => {
 			expect( pEnd.parent ).to.equal( p );
 			expect( pEnd.offset ).to.equal( 1 );
 		} );
+
+		it( 'should create positions in document fragment', () => {
+			const foo = new Text( 'foo' );
+			const docFrag = new DocumentFragment( [ foo ] );
+
+			const pStart = Position.createAt( docFrag, 0 );
+			const pEnd = Position.createAt( docFrag, 'end' );
+
+			expect( pStart.parent ).to.equal( docFrag );
+			expect( pStart.offset ).to.equal( 0 );
+			expect( pEnd.parent ).to.equal( docFrag );
+			expect( pEnd.offset ).to.equal( 1 );
+		} );
 	} );
 
 	describe( 'createFromPosition', () => {
@@ -395,6 +414,20 @@ describe( 'Position', () => {
 
 			expect( position.compareWith( compared ) ).to.equal( 'different' );
 		} );
+
+		it( 'should return correct results if position is in document fragment', () => {
+			const node = new Node( 'name' );
+			const docFrag = new DocumentFragment( [ node ] );
+			const position = new Position( docFrag, 0 );
+			const compared = new Position( docFrag, 1 );
+			const posInNode = new Position( node, 0 );
+
+			expect( position.compareWith( compared ) ).to.equal( 'before' );
+			expect( compared.compareWith( position ) ).to.equal( 'after' );
+			expect( compared.compareWith( compared ) ).to.equal( 'same' );
+			expect( position.compareWith( posInNode ) ).to.equal( 'before' );
+			expect( compared.compareWith( posInNode ) ).to.equal( 'after' );
+		} );
 	} );
 
 	describe( 'createBefore', () => {

+ 18 - 0
packages/ckeditor5-engine/tests/view/treewalker.js

@@ -6,6 +6,7 @@
 /* globals document */
 
 import Document from '../../src/view/document';
+import DocumentFragment from '../../src/view/documentfragment';
 import AttributeElement from '../../src/view/attributeelement';
 import ContainerElement from '../../src/view/containerelement';
 import Text from '../../src/view/text';
@@ -977,6 +978,23 @@ describe( 'TreeWalker', () => {
 			} );
 		} );
 	} );
+
+	it( 'should iterate over document fragment', () => {
+		const foo = new Text( 'foo' );
+		const bar = new Text( 'bar' );
+		const p = new ContainerElement( 'p', null, foo );
+		const b = new AttributeElement( 'b', null, bar );
+		const docFrag = new DocumentFragment( [ p, b ] );
+
+		const iterator = new TreeWalker( {
+			startPosition: new Position( docFrag, 0 ),
+			ignoreElementEnd: true
+		} );
+
+		const nodes = Array.from( iterator ).map( ( step ) => step.item );
+
+		expect( nodes ).to.deep.equal( [ p, foo, b, bar ] );
+	} );
 } );
 
 function expectValue( value, expected, options = {} ) {

+ 73 - 0
packages/ckeditor5-engine/tests/view/uielement.js

@@ -0,0 +1,73 @@
+/**
+ * @license Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+import UIElement from '../../src/view/uielement';
+import Element from '../../src/view/element';
+import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
+
+describe( 'UIElement', () => {
+	let uiElement;
+
+	beforeEach( () => {
+		uiElement = new UIElement( 'span', {
+			foo: 'bar',
+			style: 'border: 1px solid red;color: white;',
+			class: 'foo bar'
+		} );
+	} );
+
+	describe( 'constructor()', () => {
+		it( 'should create instance', () => {
+			expect( uiElement.name ).to.equal( 'span' );
+			expect( uiElement.getAttribute( 'foo' ) ).to.equal( 'bar' );
+			expect( uiElement.getStyle( 'border' ) ).to.equal( '1px solid red' );
+			expect( uiElement.getStyle( 'color' ) ).to.equal( 'white' );
+			expect( uiElement.hasClass( 'foo' ) ).to.true;
+			expect( uiElement.hasClass( 'bar' ) ).to.true;
+		} );
+
+		it( 'should throw if child elements are passed to constructor', () => {
+			expect( () => {
+				new UIElement( 'img', null, [ new Element( 'i' ) ] );
+			} ).to.throw( CKEditorError, 'view-uielement-cannot-add: Cannot add child nodes to UIElement instance.' );
+		} );
+	} );
+
+	describe( 'appendChildren()', () => {
+		it( 'should throw when try to append new child element', () => {
+			expect( () => {
+				uiElement.appendChildren( new Element( 'i' ) );
+			} ).to.throw( CKEditorError, 'view-uielement-cannot-add: Cannot add child nodes to UIElement instance.' );
+		} );
+	} );
+
+	describe( 'insertChildren()', () => {
+		it( 'should throw when try to insert new child element', () => {
+			expect( () => {
+				uiElement.insertChildren( 0, new Element( 'i' ) );
+			} ).to.throw( CKEditorError, 'view-uielement-cannot-add: Cannot add child nodes to UIElement instance.' );
+		} );
+	} );
+
+	describe( 'clone()', () => {
+		it( 'should be properly cloned', () => {
+			const newUIElement = uiElement.clone();
+
+			expect( newUIElement.name ).to.equal( 'span' );
+			expect( newUIElement.getAttribute( 'foo' ) ).to.equal( 'bar' );
+			expect( newUIElement.getStyle( 'border' ) ).to.equal( '1px solid red' );
+			expect( newUIElement.getStyle( 'color' ) ).to.equal( 'white' );
+			expect( newUIElement.hasClass( 'foo' ) ).to.true;
+			expect( newUIElement.hasClass( 'bar' ) ).to.true;
+			expect( newUIElement.isSimilar( uiElement ) ).to.true;
+		} );
+	} );
+
+	describe( 'getFillerOffset()', () => {
+		it( 'should return null', () => {
+			expect( uiElement.getFillerOffset() ).to.null;
+		} );
+	} );
+} );

+ 22 - 0
packages/ckeditor5-engine/tests/view/writer/breakAttributes.js → packages/ckeditor5-engine/tests/view/writer/breakattributes.js

@@ -8,6 +8,7 @@ import { stringify, parse } from '../../../src/dev-utils/view';
 import ContainerElement from '../../../src/view/containerelement';
 import AttributeElement from '../../../src/view/attributeelement';
 import EmptyElement from '../../../src/view/emptyelement';
+import UIElement from '../../../src/view/uielement';
 import Range from '../../../src/view/range';
 import Position from '../../../src/view/position';
 import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
@@ -248,6 +249,27 @@ describe( 'writer', () => {
 					breakAttributes( range );
 				} ).to.throw( CKEditorError, 'view-writer-cannot-break-empty-element' );
 			} );
+
+			it( 'should throw if breaking inside UIElement #1', () => {
+				const span = new UIElement( 'span' );
+				new ContainerElement( 'p', null, span );
+				const position = new Position( span, 0 );
+
+				expect( () => {
+					breakAttributes( position );
+				} ).to.throw( CKEditorError, 'view-writer-cannot-break-ui-element' );
+			} );
+
+			it( 'should throw if breaking inside UIElement #2', () => {
+				const span = new UIElement( 'span' );
+				const b = new AttributeElement( 'b' );
+				new ContainerElement( 'p', null, [ span, b ] );
+				const range = Range.createFromParentsAndOffsets( span, 0, b, 0 );
+
+				expect( () => {
+					breakAttributes( range );
+				} ).to.throw( CKEditorError, 'view-writer-cannot-break-ui-element' );
+			} );
 		} );
 	} );
 } );

+ 0 - 0
packages/ckeditor5-engine/tests/view/writer/breakContainer.js → packages/ckeditor5-engine/tests/view/writer/breakcontainer.js


+ 169 - 0
packages/ckeditor5-engine/tests/view/writer/clear.js

@@ -0,0 +1,169 @@
+/**
+ * @license Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+import { clear } from '../../../src/view/writer';
+import Range from '../../../src/view/range';
+import { stringify, parse } from '../../../src/dev-utils/view';
+import ContainerElement from '../../../src/view/containerelement';
+import AttributeElement from '../../../src/view/attributeelement';
+import EmptyElement from '../../../src/view/emptyelement';
+import UIElement from '../../../src/view/uielement';
+import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
+
+describe( 'writer', () => {
+	// Executes test using `parse` and `stringify` utils functions. Uses range delimiters `[]{}` to create ranges.
+	//
+	// @param {Object} elementToRemove
+	// @param {String} input
+	// @param {String} expectedResult
+	function test( elementToRemove, input, expectedResult ) {
+		let { view, selection } = parse( input );
+
+		clear( selection.getFirstRange(), elementToRemove );
+
+		expect( stringify( view, null, { showType: true } ) ).to.equal( expectedResult );
+	}
+
+	describe( 'clear', () => {
+		it( 'should throw when range placed in two containers', () => {
+			const p1 = new ContainerElement( 'p' );
+			const p2 = new ContainerElement( 'p' );
+
+			expect( () => {
+				clear( Range.createFromParentsAndOffsets( p1, 0, p2, 0 ) );
+			} ).to.throw( CKEditorError, 'view-writer-invalid-range-container' );
+		} );
+
+		it( 'should throw when range has no parent container', () => {
+			const el = new AttributeElement( 'b' );
+
+			expect( () => {
+				clear( Range.createFromParentsAndOffsets( el, 0, el, 0 ) );
+			} ).to.throw( CKEditorError, 'view-writer-invalid-range-container' );
+		} );
+
+		it( 'should remove matched element from range', () => {
+			const elementToRemove = new AttributeElement( 'b' );
+
+			test(
+				elementToRemove,
+				'<container:p>[b<attribute:b>a</attribute:b>r]</container:p>',
+				'<container:p>br</container:p>'
+			);
+		} );
+
+		it( 'should remove matched element from range when range is inside text node', () => {
+			const elementToRemove = new AttributeElement( 'b' );
+
+			test(
+				elementToRemove,
+				'<container:p>F{o<attribute:b>o</attribute:b> ba}r</container:p>',
+				'<container:p>Fo bar</container:p>'
+			);
+		} );
+
+		it( 'should remove multiple matched elements from range', () => {
+			const elementToRemove = new AttributeElement( 'b' );
+
+			test(
+				elementToRemove,
+				'<container:p>[Fo<attribute:b>o</attribute:b> b<attribute:b>a</attribute:b>r]</container:p>',
+				'<container:p>Fo br</container:p>'
+			);
+		} );
+
+		it( 'should remove multiple matched elements from range when range is inside text node', () => {
+			const elementToRemove = new AttributeElement( 'b' );
+
+			test(
+				elementToRemove,
+				'<container:p>F{o<attribute:b>o</attribute:b> <attribute:b>b</attribute:b>a}r</container:p>',
+				'<container:p>Fo ar</container:p>'
+			);
+		} );
+
+		it( 'should remove only matched element', () => {
+			const elementToRemove = new AttributeElement( 'b' );
+
+			test(
+				elementToRemove,
+				'<container:p>F{o<attribute:i>o</attribute:i> <attribute:b>b</attribute:b>a}r</container:p>',
+				'<container:p>Fo<attribute:i>o</attribute:i> ar</container:p>'
+			);
+		} );
+
+		it( 'should remove part of node when range ends inside this node', () => {
+			const elementToRemove = new AttributeElement( 'b' );
+
+			test(
+				elementToRemove,
+				'<container:p>f{oo<attribute:b>ba}r</attribute:b></container:p>',
+				'<container:p>foo<attribute:b>r</attribute:b></container:p>'
+			);
+		} );
+
+		it( 'should remove part of node when range starts inside this node', () => {
+			const elementToRemove = new AttributeElement( 'b' );
+
+			test(
+				elementToRemove,
+				'<container:p>foo<attribute:b>b{ar</attribute:b>bi}z</container:p>',
+				'<container:p>foo<attribute:b>b</attribute:b>biz</container:p>'
+			);
+		} );
+
+		it( 'should remove part of node when range starts and ends inside this node', () => {
+			const elementToRemove = new AttributeElement( 'b' );
+
+			test(
+				elementToRemove,
+				'<container:p>foo<attribute:b>b{a}r</attribute:b>biz</container:p>',
+				'<container:p>foo<attribute:b>br</attribute:b>biz</container:p>'
+			);
+		} );
+
+		it( 'should merge after removing', () => {
+			const elementToRemove = new AttributeElement( 'b' );
+
+			test(
+				elementToRemove,
+				'<container:p>' +
+					'<attribute:a>fo{o</attribute:a><attribute:b>a</attribute:b><attribute:a>b}iz</attribute:a>' +
+				'</container:p>',
+				'<container:p><attribute:a>foobiz</attribute:a></container:p>'
+			);
+		} );
+
+		it( 'should remove EmptyElement', () => {
+			const elementToRemove = new EmptyElement( 'img' );
+
+			test(
+				elementToRemove,
+				'<container:p>f{oo<empty:img></empty:img>ba}r</container:p>',
+				'<container:p>foobar</container:p>'
+			);
+		} );
+
+		it( 'should remove UIElement', () => {
+			const elementToRemove = new UIElement( 'span' );
+
+			test(
+				elementToRemove,
+				'<container:p>f{oo<ui:span></ui:span>ba}r</container:p>',
+				'<container:p>foobar</container:p>'
+			);
+		} );
+
+		it( 'should remove ContainerElement', () => {
+			const elementToRemove = new ContainerElement( 'p' );
+
+			test(
+				elementToRemove,
+				'[<container:div>foo</container:div><container:p>bar</container:p><container:div>biz</container:div>]',
+				'<container:div>foo</container:div><container:div>biz</container:div>'
+			);
+		} );
+	} );
+} );

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

@@ -7,6 +7,7 @@ import { insert } from '../../../src/view/writer';
 import ContainerElement from '../../../src/view/containerelement';
 import Element from '../../../src/view/element';
 import EmptyElement from '../../../src/view/emptyelement';
+import UIElement from '../../../src/view/uielement';
 import Position from '../../../src/view/position';
 import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
 import { stringify, parse } from '../../../src/dev-utils/view';
@@ -193,5 +194,16 @@ describe( 'writer', () => {
 				insert( position, attributeElement );
 			} ).to.throw( CKEditorError, 'view-writer-cannot-break-empty-element' );
 		} );
+
+		it( 'should throw if trying to insert inside UIElement', () => {
+			const uiElement = new UIElement( 'span' );
+			new ContainerElement( 'p', null, uiElement );
+			const position = new Position( uiElement, 0 );
+			const attributeElement = new AttributeElement( 'i' );
+
+			expect( () => {
+				insert( position, attributeElement );
+			} ).to.throw( CKEditorError, 'view-writer-cannot-break-ui-element' );
+		} );
 	} );
 } );

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

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

+ 0 - 0
packages/ckeditor5-engine/tests/view/writer/mergeContainers.js → packages/ckeditor5-engine/tests/view/writer/mergecontainers.js


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

@@ -9,6 +9,7 @@ import { stringify, parse } from '../../../src/dev-utils/view';
 import ContainerElement from '../../../src/view/containerelement';
 import AttributeElement from '../../../src/view/attributeelement';
 import EmptyElement from '../../../src/view/emptyelement';
+import UIElement from '../../../src/view/uielement';
 import Range from '../../../src/view/range';
 import Position from '../../../src/view/position';
 import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
@@ -150,5 +151,28 @@ describe( 'writer', () => {
 				move( srcRange, dstPosition );
 			} ).to.throw( CKEditorError, 'view-writer-cannot-break-empty-element' );
 		} );
+
+		it( 'should move UIElement', () => {
+			test(
+				'<container:p>foo[<ui:span></ui:span>]bar</container:p>',
+				'<container:div>baz{}quix</container:div>',
+				'<container:p>foobar</container:p>',
+				'<container:div>baz[<ui:span></ui:span>]quix</container:div>'
+			);
+		} );
+
+		it( 'should throw if trying to move to UIElement', () => {
+			const srcAttribute = new AttributeElement( 'b' );
+			const srcContainer = new ContainerElement( 'p', null, srcAttribute );
+			const srcRange = Range.createFromParentsAndOffsets( srcContainer, 0, srcContainer, 1 );
+
+			const dstUI = new UIElement( 'span' );
+			new ContainerElement( 'p', null, dstUI );
+			const dstPosition = new Position( dstUI, 0 );
+
+			expect( () => {
+				move( srcRange, dstPosition );
+			} ).to.throw( CKEditorError, 'view-writer-cannot-break-ui-element' );
+		} );
 	} );
 } );

+ 21 - 1
packages/ckeditor5-engine/tests/view/writer/remove.js

@@ -10,6 +10,7 @@ import DocumentFragment from '../../../src/view/documentfragment';
 import { stringify, parse } from '../../../src/dev-utils/view';
 import AttributeElement from '../../../src/view/attributeelement';
 import EmptyElement from '../../../src/view/emptyelement';
+import UIElement from '../../../src/view/uielement';
 import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
 
 describe( 'writer', () => {
@@ -70,7 +71,7 @@ describe( 'writer', () => {
 			test( '<container:p>f{oob}ar</container:p>', '<container:p>f{}ar</container:p>', 'oob' );
 		} );
 
-		it( 'should remove parts of nodes', () => {
+		it( 'should remove parts of nodes #1', () => {
 			test(
 				'<container:p>f{oo<attribute:b view-priority="10">ba}r</attribute:b></container:p>',
 				'<container:p>f[]<attribute:b view-priority="10">r</attribute:b></container:p>',
@@ -128,5 +129,24 @@ describe( 'writer', () => {
 				remove( range );
 			} ).to.throw( CKEditorError, 'view-writer-cannot-break-empty-element' );
 		} );
+
+		it( 'should remove UIElement', () => {
+			test(
+				'<container:p>foo[<ui:span></ui:span>]bar</container:p>',
+				'<container:p>foo{}bar</container:p>',
+				'<ui:span></ui:span>'
+			);
+		} );
+
+		it( 'should throw if range is placed inside UIElement', () => {
+			const uiElement = new UIElement( 'span' );
+			const attributeElement = new AttributeElement( 'b' );
+			new ContainerElement( 'p', null, [ uiElement, attributeElement ] );
+			const range = Range.createFromParentsAndOffsets( uiElement, 0, attributeElement, 0 );
+
+			expect( () => {
+				remove( range );
+			} ).to.throw( CKEditorError, 'view-writer-cannot-break-ui-element' );
+		} );
 	} );
 } );

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

@@ -8,6 +8,7 @@ import Element from '../../../src/view/element';
 import ContainerElement from '../../../src/view/containerelement';
 import AttributeElement from '../../../src/view/attributeelement';
 import EmptyElement from '../../../src/view/emptyelement';
+import UIElement from '../../../src/view/uielement';
 import Position from '../../../src/view/position';
 import Range from '../../../src/view/range';
 import Text from '../../../src/view/text';
@@ -316,5 +317,24 @@ describe( 'writer', () => {
 				unwrap( range, attribute );
 			} ).to.throw( CKEditorError, 'view-writer-cannot-break-empty-element' );
 		} );
+
+		it( 'should unwrap UIElement', () => {
+			test(
+				'<container:p>[<attribute:b><ui:span></ui:span></attribute:b>]</container:p>',
+				'<attribute:b></attribute:b>',
+				'<container:p>[<ui:span></ui:span>]</container:p>'
+			);
+		} );
+
+		it( 'should throw if range is placed inside UIElement', () => {
+			const uiElement = new UIElement( 'span' );
+			const attribute = new AttributeElement( 'b' );
+			const container = new ContainerElement( 'p', null, [ uiElement, attribute ] );
+			const range = Range.createFromParentsAndOffsets( uiElement, 0, container, 2 );
+
+			expect( () => {
+				unwrap( range, attribute );
+			} ).to.throw( CKEditorError, 'view-writer-cannot-break-ui-element' );
+		} );
 	} );
 } );

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

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

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

@@ -9,6 +9,7 @@ import Element from '../../../src/view/element';
 import ContainerElement from '../../../src/view/containerelement';
 import AttributeElement from '../../../src/view/attributeelement';
 import EmptyElement from '../../../src/view/emptyelement';
+import UIElement from '../../../src/view/uielement';
 import Position from '../../../src/view/position';
 import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
 import { stringify, parse } from '../../../src/dev-utils/view';
@@ -133,4 +134,15 @@ describe( 'wrapPosition', () => {
 			wrapPosition( position, attributeElement );
 		} ).to.throw( CKEditorError, 'view-emptyelement-cannot-add' );
 	} );
+
+	it( 'should throw if position is set inside UIElement', () => {
+		const uiElement = new UIElement( 'span' );
+		new ContainerElement( 'p', null, uiElement );
+		const attributeElement = new AttributeElement( 'b' );
+		const position = new Position( uiElement, 0 );
+
+		expect( () => {
+			wrapPosition( position, attributeElement );
+		} ).to.throw( CKEditorError, 'view-uielement-cannot-add' );
+	} );
 } );