Ver Fonte

Merge branch 'master' into t/1088

Oskar Wróbel há 8 anos atrás
pai
commit
fb9c90f2b3

+ 11 - 29
packages/ckeditor5-engine/src/conversion/buildmodelconverter.js

@@ -59,7 +59,7 @@ import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
  *		buildModelConverter().for( dispatcher ).fromAttribute( 'bold' ).toElement( 'strong' );
  *
  * 4. Model marker to view highlight converter. This is a converter that converts model markers to view highlight
- * described by {@link module:engine/conversion/buildmodelconverter~HighlightDescriptor} object passed to
+ * described by {@link module:engine/conversion/model-to-view-converters~HighlightDescriptor} object passed to
  * {@link module:engine/conversion/buildmodelconverter~ModelConverterBuilder#toHighlight} method.
  *
  *		buildModelConverter().for( dispatcher ).fromMarker( 'search' ).toHighlight( {
@@ -209,9 +209,9 @@ class ModelConverterBuilder {
 	 * from element, {@link module:engine/view/attributeelement~AttributeElement ViewAttributeElement} if you convert
 	 * from attribute and {@link module:engine/view/uielement~UIElement ViewUIElement} if you convert from marker.
 	 *
-	 * NOTE: When converting from model's marker, separate elements will be created at the beginning and at the end of the
+	 * **Note:** When converting from model's marker, separate elements will be created at the beginning and at the end of the
 	 * marker's range. If range is collapsed then only one element will be created. See how markers
-	 * {module:engine/model/buildviewconverter~ViewConverterBuilder#toMarker view -> model serialization}
+	 * {module:engine/model/buildviewconverter~ViewConverterBuilder#toMarker serialization from view to model}
 	 * works to find out what view element format is the best for you.
 	 *
 	 *		buildModelConverter().for( dispatcher ).fromElement( 'paragraph' ).toElement( 'p' );
@@ -286,10 +286,11 @@ class ModelConverterBuilder {
 	 *		viewElement.setCustomProperty( 'addHighlight', ( element, descriptor ) => {} );
 	 *		viewElement.setCustomProperty( 'removeHighlight', ( element, descriptor ) => {} );
 	 *
-	 * {@link module:engine/conversion/buildmodelconverter~HighlightDescriptor} will be used to create
+	 * {@link module:engine/conversion/model-to-view-converters~HighlightDescriptor} will be used to create
 	 * spans over text nodes and also will be provided to `addHighlight` and `removeHighlight` methods
 	 * each time highlight should be set or removed from view elements.
-	 * NOTE: When `addHighlight` and `removeHighlight` custom properties are present, converter assumes
+	 *
+	 * **Note:** When `addHighlight` and `removeHighlight` custom properties are present, converter assumes
 	 * that element itself is taking care of presenting highlight on its child nodes, so it won't convert them.
 	 *
 	 * Highlight descriptor can be provided as plain object:
@@ -307,7 +308,7 @@ class ModelConverterBuilder {
 	 * Throws {@link module:utils/ckeditorerror~CKEditorError CKEditorError}
 	 * `build-model-converter-non-marker-to-highlight` when trying to convert not from marker.
 	 *
-	 * @param {function|module:engine/conversion/buildmodelconverter~HighlightDescriptor} highlightDescriptor
+	 * @param {function|module:engine/conversion/model-to-view-converters~HighlightDescriptor} highlightDescriptor
 	 */
 	toHighlight( highlightDescriptor ) {
 		const priority = this._from.priority === null ? 'normal' : this._from.priority;
@@ -423,28 +424,9 @@ export default function buildModelConverter() {
 }
 
 /**
- * @typedef MarkerViewElementCreatorData
- * @param {Object} data Additional information about the change.
- * @param {String} data.markerName Marker name.
- * @param {module:engine/model/range~Range} data.markerRange Marker range.
- * @param {Boolean} data.isOpening Defines if currently converted element is a beginning or end of the marker range.
- * @param {module:engine/conversion/modelconsumable~ModelConsumable} consumable Values to consume.
- * @param {Object} conversionApi Conversion interface to be used by callback, passed in `ModelConversionDispatcher` constructor.
- */
-
-/**
- * @typedef HighlightDescriptor
- * Object describing how content highlight should be created in the view. Each text node contained in highlight
- * will be wrapped with `span` element with CSS class, attributes and priority described by this object. Each element
- * can handle displaying highlight separately by providing `addHighlight` and `removeHighlight` custom
- * properties.
+ * @typedef {Object} module:engine/conversion/buildmodelconverter~MarkerViewElementCreatorData
  *
- * @property {String|Array.<String>} class CSS class or array of classes that will be added to `span`
- * {@link module:engine/view/attributeelement~AttributeElement} wrapping each text node in the highlighted content.
- * @property {String} [id] Descriptor identifier. If not provided, marker's name from which given highlight is created
- * will be used.
- * @property {Number} [priority] {@link module:engine/view/attributeelement~AttributeElement#priority} of the `span`
- * wrapping each text node in the highlighted content. If not provided, default 10 priority will be used.
- * @property {Object} [attributes] Attributes that will be added to `span`
- * {@link module:engine/view/attributeelement~AttributeElement} wrapping each text node it the highlighted content.
+ * @param {String} markerName Marker name.
+ * @param {module:engine/model/range~Range} markerRange Marker range.
+ * @param {Boolean} isOpening Defines if currently converted element is a beginning or end of the marker range.
  */

+ 7 - 3
packages/ckeditor5-engine/src/conversion/model-selection-to-view-converters.js

@@ -6,7 +6,7 @@
 import ViewElement from '../view/element';
 import ViewRange from '../view/range';
 import viewWriter from '../view/writer';
-import { highlightDescriptorToAttributeElement } from './model-to-view-converters';
+import { createViewElementFromHighlightDescriptor } from './model-to-view-converters';
 
 /**
  * Contains {@link module:engine/model/selection~Selection model selection} to
@@ -162,7 +162,7 @@ export function convertSelectionAttribute( elementCreator ) {
  *		modelDispatcher.on( 'selectionMarker:searchResult', convertSelectionMarker( { class: 'search' } ) );
  *
  * @see module:engine/conversion/model-selection-to-view-converters~convertSelectionAttribute
- * @param {module:engine/conversion/buildmodelconverter~HighlightDescriptor|Function} highlightDescriptor Highlight
+ * @param {module:engine/conversion/model-to-view-converters~HighlightDescriptor|Function} highlightDescriptor Highlight
  * descriptor object or function returning a descriptor object.
  * @returns {Function} Selection converter.
  */
@@ -176,7 +176,11 @@ export function convertSelectionMarker( highlightDescriptor ) {
 			return;
 		}
 
-		const viewElement = highlightDescriptorToAttributeElement( descriptor );
+		if ( !descriptor.id ) {
+			descriptor.id = data.markerName;
+		}
+
+		const viewElement = createViewElementFromHighlightDescriptor( descriptor );
 		const consumableName = 'selectionMarker:' + data.markerName;
 
 		wrapCollapsedSelectionPosition( data.selection, conversionApi.viewSelection, viewElement, consumable, consumableName );

+ 87 - 29
packages/ckeditor5-engine/src/conversion/model-to-view-converters.js

@@ -409,7 +409,7 @@ export function remove() {
  * {@link module:engine/view/attributeelement~AttributeElement} created from provided descriptor.
  * See {link module:engine/conversion/model-to-view-converters~highlightDescriptorToAttributeElement}.
  *
- * @param {module:engine/conversion/buildmodelconverter~HighlightDescriptor|Function} highlightDescriptor
+ * @param {module:engine/conversion/model-to-view-converters~HighlightDescriptor|Function} highlightDescriptor
  * @return {Function}
  */
 export function highlightText( highlightDescriptor ) {
@@ -428,7 +428,11 @@ export function highlightText( highlightDescriptor ) {
 			return;
 		}
 
-		const viewElement = highlightDescriptorToAttributeElement( descriptor );
+		if ( !descriptor.id ) {
+			descriptor.id = data.markerName;
+		}
+
+		const viewElement = createViewElementFromHighlightDescriptor( descriptor );
 		const viewRange = conversionApi.mapper.toViewRange( data.range );
 
 		if ( evt.name.split( ':' )[ 0 ] == 'addMarker' ) {
@@ -442,16 +446,17 @@ export function highlightText( highlightDescriptor ) {
 /**
  * Function factory, creates converter that converts all elements inside marker's range. Converter checks if element has
  * functions stored under `addHighlight` and `removeHighlight` custom properties and calls them passing
- * {@link module:engine/conversion/buildmodelconverter~HighlightDescriptor}. In such case converter will consume
+ * {@link module:engine/conversion/model-to-view-converters~HighlightDescriptor}. In such case converter will consume
  * all element's children, assuming that they were handled by element itself. If highlight descriptor will not provide
- * priority, priority 10 will be used as default, to be compliant with
+ * priority, priority `10` will be used as default, to be compliant with
  * {@link module:engine/conversion/model-to-view-converters~highlightText} method which uses default priority of
  * {@link module:engine/view/attributeelement~AttributeElement}.
- * If highlight descriptor will not provide id property, name of the marker will be used.
+ *
+ * If highlight descriptor will not provide `id` property, name of the marker will be used.
  * When `addHighlight` and `removeHighlight` custom properties are not present, element is not converted
  * in any special way. This means that converters will proceed to convert element's child nodes.
  *
- * @param {module:engine/conversion/buildmodelconverter~HighlightDescriptor|Function} highlightDescriptor
+ * @param {module:engine/conversion/model-to-view-converters~HighlightDescriptor|Function} highlightDescriptor
  * @return {Function}
  */
 export function highlightElement( highlightDescriptor ) {
@@ -562,29 +567,6 @@ export function eventNameToConsumableType( evtName ) {
 	return parts[ 0 ] + ':' + parts[ 1 ];
 }
 
-/**
- * Creates `span` {@link module:engine/view/attributeelement~AttributeElement view attribute element} from information
- * provided by {@link module:engine/conversion/buildmodelconverter~HighlightDescriptor} object. If priority
- * is not provided in descriptor - default priority will be used.
- *
- * @param {module:engine/conversion/buildmodelconverter~HighlightDescriptor } descriptor
- * @return {module:engine/view/attributeelement~AttributeElement}
- */
-export function highlightDescriptorToAttributeElement( descriptor ) {
-	const attributeElement = new ViewAttributeElement( 'span', descriptor.attributes );
-
-	if ( descriptor.class ) {
-		const cssClasses = Array.isArray( descriptor.class ) ? descriptor.class : [ descriptor.class ];
-		attributeElement.addClass( ...cssClasses );
-	}
-
-	if ( descriptor.priority ) {
-		attributeElement.priority = descriptor.priority;
-	}
-
-	return attributeElement;
-}
-
 // Helper function that shifts given view `position` in a way that returned position is after `howMany` characters compared
 // to the original `position`.
 // Because in view there might be view ui elements splitting text nodes, we cannot simply use `ViewPosition#getShiftedBy()`.
@@ -604,3 +586,79 @@ function _shiftViewPositionByCharacters( position, howMany ) {
 		}
 	}
 }
+
+/**
+ * Creates `span` {@link module:engine/view/attributeelement~AttributeElement view attribute element} from information
+ * provided by {@link module:engine/conversion/model-to-view-converters~HighlightDescriptor} object. If priority
+ * is not provided in descriptor - default priority will be used.
+ *
+ * @param {module:engine/conversion/model-to-view-converters~HighlightDescriptor} descriptor
+ * @return {module:engine/conversion/model-to-view-converters~HighlightAttributeElement}
+ */
+export function createViewElementFromHighlightDescriptor( descriptor ) {
+	const viewElement = new HighlightAttributeElement( 'span', descriptor.attributes );
+
+	if ( descriptor.class ) {
+		const cssClasses = Array.isArray( descriptor.class ) ? descriptor.class : [ descriptor.class ];
+		viewElement.addClass( ...cssClasses );
+	}
+
+	if ( descriptor.priority ) {
+		viewElement.priority = descriptor.priority;
+	}
+
+	viewElement.setCustomProperty( 'highlightDescriptorId', descriptor.id );
+
+	return viewElement;
+}
+
+/**
+ * Special kind of {@link module:engine/view/attributeelement~AttributeElement} that is created and used in
+ * marker-to-highlight conversion.
+ *
+ * The difference between `HighlightAttributeElement` and {@link module:engine/view/attributeelement~AttributeElement}
+ * is {@link module:engine/view/attributeelement~AttributeElement#isSimilar} method.
+ *
+ * For `HighlightAttributeElement` it checks just `highlightDescriptorId` custom property, that is set during marker-to-highlight
+ * conversion basing on {@link module:engine/conversion/model-to-view-converters~HighlightDescriptor} object.
+ * `HighlightAttributeElement`s with same `highlightDescriptorId` property are considered similar.
+ */
+class HighlightAttributeElement extends ViewAttributeElement {
+	isSimilar( otherElement ) {
+		if ( otherElement.is( 'attributeElement' ) ) {
+			return this.getCustomProperty( 'highlightDescriptorId' ) === otherElement.getCustomProperty( 'highlightDescriptorId' );
+		}
+
+		return false;
+	}
+}
+
+/**
+ * Object describing how content highlight should be created in the view.
+ *
+ * Each text node contained in highlight will be wrapped with `span` element with CSS class(es), attributes and priority
+ * described by this object.
+ *
+ * Each element can handle displaying highlight separately by providing `addHighlight` and `removeHighlight` custom
+ * properties. Those properties are passed `HighlightDescriptor` object upon conversion and should use it to
+ * change the element.
+ *
+ * @typedef {Object} module:engine/conversion/model-to-view-converters~HighlightDescriptor
+ *
+ * @property {String|Array.<String>} class CSS class or array of classes to set. If descriptor is used to
+ * create {@link module:engine/view/attributeelement~AttributeElement} over text nodes, those classes will be set
+ * on that {@link module:engine/view/attributeelement~AttributeElement}. If descriptor is applied to an element,
+ * usually those class will be set on that element, however this depends on how the element converts the descriptor.
+ *
+ * @property {String} [id] Descriptor identifier. If not provided, defaults to converted marker's name.
+ *
+ * @property {Number} [priority] Descriptor priority. If not provided, defaults to `10`. If descriptor is used to create
+ * {@link module:engine/view/attributeelement~AttributeElement}, it will be that element's
+ * {@link module:engine/view/attributeelement~AttributeElement#priority}. If descriptor is applied to an element,
+ * the priority will be used to determine which descriptor is more important.
+ *
+ * @property {Object} [attributes] Attributes to set. If descriptor is used to create
+ * {@link module:engine/view/attributeelement~AttributeElement} over text nodes, those attributes will be set on that
+ * {@link module:engine/view/attributeelement~AttributeElement}. If descriptor is applied to an element, usually those
+ * attributes will be set on that element, however this depends on how the element converts the descriptor.
+ */

+ 34 - 8
packages/ckeditor5-engine/src/conversion/modelconversiondispatcher.js

@@ -217,15 +217,11 @@ export default class ModelConversionDispatcher {
 
 		for ( const marker of this._modelDocument.markers ) {
 			const markerRange = marker.getRange();
+			const intersection = markerRange.getIntersection( range );
 
 			// Check if inserted content is inserted into a marker.
-			if ( markerRange.containsPosition( range.start ) ) {
-				this.convertMarker( 'addMarker', marker.name, markerRange.getIntersection( range ) );
-			}
-
-			// Check if inserted content contains a marker.
-			if ( range.containsRange( markerRange, true ) ) {
-				this.convertMarker( 'addMarker', marker.name, markerRange );
+			if ( intersection && shouldMarkerChangeBeConverted( range.start, marker, this.conversionApi.mapper ) ) {
+				this.convertMarker( 'addMarker', marker.name, intersection );
 			}
 		}
 	}
@@ -367,10 +363,16 @@ export default class ModelConversionDispatcher {
 		this.fire( 'selection', { selection }, consumable, this.conversionApi );
 
 		for ( const marker of markers ) {
+			const markerRange = marker.getRange();
+
+			if ( !shouldMarkerChangeBeConverted( selection.getFirstPosition(), marker, this.conversionApi.mapper ) ) {
+				continue;
+			}
+
 			const data = {
 				selection,
 				markerName: marker.name,
-				markerRange: marker.getRange()
+				markerRange
 			};
 
 			if ( consumable.test( selection, 'selectionMarker:' + marker.name ) ) {
@@ -717,3 +719,27 @@ export default class ModelConversionDispatcher {
 }
 
 mix( ModelConversionDispatcher, EmitterMixin );
+
+// Helper function, checks whether change of `marker` at `modelPosition` should be converted. Marker changes are not
+// converted if they happen inside an element with custom conversion method.
+//
+// @param {module:engine/model/position~Position} modelPosition
+// @param {module:engine/model/markercollection~Marker} marker
+// @param {module:engine/conversion/mapper~Mapper} mapper
+// @returns {Boolean}
+function shouldMarkerChangeBeConverted( modelPosition, marker, mapper ) {
+	const range = marker.getRange();
+	const ancestors = Array.from( modelPosition.getAncestors() );
+	ancestors.shift(); // Remove root element. It cannot be passed to `model.Range#containsItem`.
+	ancestors.reverse();
+
+	const hasCustomHandling = ancestors.some( element => {
+		if ( range.containsItem( element ) ) {
+			const viewElement = mapper.toViewElement( element );
+
+			return !!viewElement.getCustomProperty( 'addHighlight' );
+		}
+	} );
+
+	return !hasCustomHandling;
+}

+ 12 - 1
packages/ckeditor5-engine/src/model/range.js

@@ -117,6 +117,17 @@ export default class Range {
 		return containsStart && containsEnd;
 	}
 
+	/**
+	 * Checks whether given {@link module:engine/model/item~Item} is inside this range.
+	 *
+	 * @param {module:engine/model/item~Item} item Model item to check.
+	 */
+	containsItem( item ) {
+		const pos = Position.createBefore( item );
+
+		return this.containsPosition( pos ) || this.start.isEqual( pos );
+	}
+
 	/**
 	 * Two ranges are equal if their {@link #start} and {@link #end} positions are equal.
 	 *
@@ -494,7 +505,7 @@ export default class Range {
 			// ^<p>xx</p><w>{<p>a[b</p>}</w><p>c]d</p>   -->   <p>a[b</p><p>xx</p><w></w><p>c]d</p>  // Note <p>xx</p> inclusion.
 			// <w>{<p>a[b</p>}</w>^<p>c]d</p>            -->   <w></w><p>a[b</p><p>c]d</p>
 			if (
-				sourceRange.containsPosition( this.start ) &&
+				( sourceRange.containsPosition( this.start ) || sourceRange.start.isEqual( this.start ) ) &&
 				this.containsPosition( sourceRange.end ) &&
 				this.end.isAfter( targetPosition )
 			) {

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

@@ -332,7 +332,11 @@ export default class Schema {
 					// If returned item does not have name property, it is a TextFragment.
 					const name = value.item.name || '$text';
 
-					if ( this.check( { name, inside: value.previousPosition, attributes: attribute } ) ) {
+					// Attribute should be checked together with existing attributes.
+					// See https://github.com/ckeditor/ckeditor5-engine/issues/1110.
+					const attributes = Array.from( value.item.getAttributeKeys() ).concat( attribute );
+
+					if ( this.check( { name, inside: value.previousPosition, attributes } ) ) {
 						// If we found a node that is allowed to have the attribute, return true.
 						return true;
 					}

+ 27 - 3
packages/ckeditor5-engine/tests/conversion/model-selection-to-view-converters.js

@@ -36,10 +36,8 @@ import {
 import { stringify as stringifyView } from '../../src/dev-utils/view';
 import { setData as setModelData } from '../../src/dev-utils/model';
 
-const highlightDescriptor = { class: 'marker', priority: 1 };
-
 describe( 'model-selection-to-view-converters', () => {
-	let dispatcher, mapper, modelDoc, modelRoot, modelSelection, viewDoc, viewRoot, viewSelection;
+	let dispatcher, mapper, modelDoc, modelRoot, modelSelection, viewDoc, viewRoot, viewSelection, highlightDescriptor;
 
 	beforeEach( () => {
 		modelDoc = new ModelDocument();
@@ -55,6 +53,8 @@ describe( 'model-selection-to-view-converters', () => {
 		mapper = new Mapper();
 		mapper.bindElements( modelRoot, viewRoot );
 
+		highlightDescriptor = { class: 'marker', priority: 1 };
+
 		dispatcher = new ModelConversionDispatcher( modelDoc, { mapper, viewSelection } );
 
 		dispatcher.on( 'insert:$text', insertText() );
@@ -293,6 +293,30 @@ describe( 'model-selection-to-view-converters', () => {
 					.to.equal( '<div>foo<span class="marker2">[]</span>bar</div>' );
 			} );
 
+			it( 'in marker - should merge with the rest of attribute elements', () => {
+				dispatcher.on( 'addMarker:marker2', highlightText( data => ( { 'class': data.markerName } ) ) );
+				dispatcher.on( 'selectionMarker:marker2', convertSelectionMarker( data => ( { 'class': data.markerName } ) ) );
+
+				setModelData( modelDoc, 'foobar' );
+				const marker = modelDoc.markers.set( 'marker2', ModelRange.createFromParentsAndOffsets( modelRoot, 1, modelRoot, 5 ) );
+
+				modelSelection.setRanges( [ new ModelRange( ModelPosition.createAt( modelRoot, 3 ) ) ] );
+
+				// Remove view children manually (without firing additional conversion).
+				viewRoot.removeChildren( 0, viewRoot.childCount );
+
+				// Convert model to view.
+				dispatcher.convertInsertion( ModelRange.createIn( modelRoot ) );
+				dispatcher.convertMarker( 'addMarker', marker.name, marker.getRange() );
+
+				const markers = Array.from( modelDoc.markers.getMarkersAtPosition( modelSelection.getFirstPosition() ) );
+				dispatcher.convertSelection( modelSelection, markers );
+
+				// Stringify view and check if it is same as expected.
+				expect( stringifyView( viewRoot, viewSelection, { showType: false } ) )
+					.to.equal( '<div>f<span class="marker2">oo{}ba</span>r</div>' );
+			} );
+
 			it( 'should do nothing if creator return null', () => {
 				dispatcher.on( 'selectionMarker:marker3', convertSelectionMarker( () => {
 

+ 39 - 7
packages/ckeditor5-engine/tests/conversion/model-to-view-converters.js

@@ -30,7 +30,7 @@ import {
 	removeUIElement,
 	highlightText,
 	highlightElement,
-	highlightDescriptorToAttributeElement
+	createViewElementFromHighlightDescriptor
 } from '../../src/conversion/model-to-view-converters';
 
 import { createRangeOnElementOnly } from '../../tests/model/_utils/utils';
@@ -1224,14 +1224,14 @@ describe( 'model-to-view-converters', () => {
 		} );
 	} );
 
-	describe( 'highlightDescriptorToAttributeElement()', () => {
+	describe( 'createViewElementFromHighlightDescriptor()', () => {
 		it( 'should return attribute element from descriptor object', () => {
 			const descriptor = {
 				class: 'foo-class',
 				attributes: { one: 1, two: 2 },
 				priority: 7,
 			};
-			const element = highlightDescriptorToAttributeElement( descriptor );
+			const element = createViewElementFromHighlightDescriptor( descriptor );
 
 			expect( element.is( 'attributeElement' ) ).to.be.true;
 			expect( element.name ).to.equal( 'span' );
@@ -1249,7 +1249,7 @@ describe( 'model-to-view-converters', () => {
 				attributes: { one: 1, two: 2 },
 				priority: 7,
 			};
-			const element = highlightDescriptorToAttributeElement( descriptor );
+			const element = createViewElementFromHighlightDescriptor( descriptor );
 
 			expect( element.is( 'attributeElement' ) ).to.be.true;
 			expect( element.name ).to.equal( 'span' );
@@ -1267,7 +1267,7 @@ describe( 'model-to-view-converters', () => {
 				attributes: { one: 1, two: 2 },
 				priority: 7,
 			};
-			const element = highlightDescriptorToAttributeElement( descriptor );
+			const element = createViewElementFromHighlightDescriptor( descriptor );
 
 			expect( element.is( 'attributeElement' ) ).to.be.true;
 			expect( element.name ).to.equal( 'span' );
@@ -1283,7 +1283,7 @@ describe( 'model-to-view-converters', () => {
 				class: 'foo-class',
 				attributes: { one: 1, two: 2 },
 			};
-			const element = highlightDescriptorToAttributeElement( descriptor );
+			const element = createViewElementFromHighlightDescriptor( descriptor );
 
 			expect( element.is( 'attributeElement' ) ).to.be.true;
 			expect( element.name ).to.equal( 'span' );
@@ -1300,12 +1300,44 @@ describe( 'model-to-view-converters', () => {
 				class: 'foo-class',
 				priority: 7
 			};
-			const element = highlightDescriptorToAttributeElement( descriptor );
+			const element = createViewElementFromHighlightDescriptor( descriptor );
 
 			expect( element.is( 'attributeElement' ) ).to.be.true;
 			expect( element.name ).to.equal( 'span' );
 			expect( element.priority ).to.equal( 7 );
 			expect( element.hasClass( 'foo-class' ) ).to.be.true;
 		} );
+
+		it( 'should create similar elements if they are created using same descriptor id', () => {
+			const a = createViewElementFromHighlightDescriptor( {
+				id: 'id',
+				class: 'classA',
+				priority: 1
+			} );
+
+			const b = createViewElementFromHighlightDescriptor( {
+				id: 'id',
+				class: 'classB',
+				priority: 2
+			} );
+
+			expect( a.isSimilar( b ) ).to.be.true;
+		} );
+
+		it( 'should create non-similar elements if they have different descriptor id', () => {
+			const a = createViewElementFromHighlightDescriptor( {
+				id: 'a',
+				class: 'foo',
+				priority: 1
+			} );
+
+			const b = createViewElementFromHighlightDescriptor( {
+				id: 'b',
+				class: 'foo',
+				priority: 1
+			} );
+
+			expect( a.isSimilar( b ) ).to.be.false;
+		} );
 	} );
 } );

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

@@ -15,6 +15,8 @@ import RenameOperation from '../../src/model/operation/renameoperation';
 import AttributeOperation from '../../src/model/operation/attributeoperation';
 import { wrapInDelta } from '../../tests/model/_utils/utils';
 
+import ViewContainerElement from '../../src/view/containerelement';
+
 describe( 'ModelConversionDispatcher', () => {
 	let dispatcher, doc, root, gyPos;
 
@@ -360,6 +362,78 @@ describe( 'ModelConversionDispatcher', () => {
 			expect( callArgs[ 1 ] ).to.equal( 'marker' );
 			expect( callArgs[ 2 ].isEqual( markerRange ) ).to.be.true;
 		} );
+
+		it( 'should not fire marker conversion if content is inserted into element with custom highlight handling', () => {
+			sinon.spy( dispatcher, 'convertMarker' );
+
+			const text = new ModelText( 'abc' );
+			const caption = new ModelElement( 'caption', null, text );
+			const image = new ModelElement( 'image', null, caption );
+			root.appendChildren( [ image ] );
+
+			// Create view elements that will be "mapped" to model elements.
+			const viewCaption = new ViewContainerElement( 'caption' );
+			const viewFigure = new ViewContainerElement( 'figure', null, viewCaption );
+
+			// Create custom highlight handler mock.
+			viewFigure.setCustomProperty( 'addHighlight', () => {} );
+			viewFigure.setCustomProperty( 'removeHighlight', () => {} );
+
+			// Create mapper mock.
+			dispatcher.conversionApi.mapper = {
+				toViewElement( modelElement ) {
+					if ( modelElement == image ) {
+						return viewFigure;
+					} else if ( modelElement == caption ) {
+						return viewCaption;
+					}
+				}
+			};
+
+			const markerRange = ModelRange.createFromParentsAndOffsets( root, 0, root, 1 );
+			doc.markers.set( 'marker', markerRange );
+
+			const insertionRange = ModelRange.createFromParentsAndOffsets( caption, 1, caption, 2 );
+			dispatcher.convertInsertion( insertionRange );
+
+			expect( dispatcher.convertMarker.called ).to.be.false;
+		} );
+
+		it( 'should fire marker conversion if inserted into element with highlight handling but element is not in marker range', () => {
+			sinon.spy( dispatcher, 'convertMarker' );
+
+			const text = new ModelText( 'abc' );
+			const caption = new ModelElement( 'caption', null, text );
+			const image = new ModelElement( 'image', null, caption );
+			root.appendChildren( [ image ] );
+
+			// Create view elements that will be "mapped" to model elements.
+			const viewCaption = new ViewContainerElement( 'caption' );
+			const viewFigure = new ViewContainerElement( 'figure', null, viewCaption );
+
+			// Create custom highlight handler mock.
+			viewFigure.setCustomProperty( 'addHighlight', () => {} );
+			viewFigure.setCustomProperty( 'removeHighlight', () => {} );
+
+			// Create mapper mock.
+			dispatcher.conversionApi.mapper = {
+				toViewElement( modelElement ) {
+					if ( modelElement == image ) {
+						return viewFigure;
+					} else if ( modelElement == caption ) {
+						return viewCaption;
+					}
+				}
+			};
+
+			const markerRange = ModelRange.createFromParentsAndOffsets( caption, 0, caption, 3 );
+			doc.markers.set( 'marker', markerRange );
+
+			const insertionRange = ModelRange.createFromParentsAndOffsets( caption, 2, caption, 3 );
+			dispatcher.convertInsertion( insertionRange );
+
+			expect( dispatcher.convertMarker.called ).to.be.true;
+		} );
 	} );
 
 	describe( 'convertMove', () => {
@@ -600,6 +674,46 @@ describe( 'ModelConversionDispatcher', () => {
 			expect( dispatcher.fire.calledWith( 'selectionMarker:name' ) ).to.be.true;
 		} );
 
+		it( 'should not fire event for marker if selection is in a element with custom highlight handling', () => {
+			// Clear after `beforeEach`.
+			root.removeChildren( 0, root.childCount );
+
+			const text = new ModelText( 'abc' );
+			const caption = new ModelElement( 'caption', null, text );
+			const image = new ModelElement( 'image', null, caption );
+			root.appendChildren( [ image ] );
+
+			// Create view elements that will be "mapped" to model elements.
+			const viewCaption = new ViewContainerElement( 'caption' );
+			const viewFigure = new ViewContainerElement( 'figure', null, viewCaption );
+
+			// Create custom highlight handler mock.
+			viewFigure.setCustomProperty( 'addHighlight', () => {} );
+			viewFigure.setCustomProperty( 'removeHighlight', () => {} );
+
+			// Create mapper mock.
+			dispatcher.conversionApi.mapper = {
+				toViewElement( modelElement ) {
+					if ( modelElement == image ) {
+						return viewFigure;
+					} else if ( modelElement == caption ) {
+						return viewCaption;
+					}
+				}
+			};
+
+			doc.markers.set( 'name', ModelRange.createFromParentsAndOffsets( root, 0, root, 1 ) );
+			doc.selection.setRanges( [ ModelRange.createFromParentsAndOffsets( caption, 1, caption, 1 ) ] );
+
+			sinon.spy( dispatcher, 'fire' );
+
+			const markers = Array.from( doc.markers.getMarkersAtPosition( doc.selection.getFirstPosition() ) );
+
+			dispatcher.convertSelection( doc.selection, markers );
+
+			expect( dispatcher.fire.calledWith( 'selectionMarker:name' ) ).to.be.false;
+		} );
+
 		it( 'should not fire events if information about marker has been consumed', () => {
 			doc.markers.set( 'foo', ModelRange.createFromParentsAndOffsets( root, 0, root, 2 ) );
 			doc.markers.set( 'bar', ModelRange.createFromParentsAndOffsets( root, 0, root, 2 ) );

+ 45 - 0
packages/ckeditor5-engine/tests/model/range.js

@@ -412,6 +412,12 @@ describe( 'Range', () => {
 
 			expect( range.containsPosition( position ) ).to.be.true;
 		} );
+	} );
+
+	describe( 'containsRange()', () => {
+		beforeEach( () => {
+			range = new Range( new Position( root, [ 1 ] ), new Position( root, [ 3 ] ) );
+		} );
 
 		it( 'should return true if ranges are equal and check is not strict', () => {
 			const otherRange = Range.createFromRange( range );
@@ -441,6 +447,32 @@ describe( 'Range', () => {
 		} );
 	} );
 
+	describe( 'containsItem()', () => {
+		let a, b, c, d, xxx;
+
+		beforeEach( () => {
+			a = new Element( 'a' );
+			b = new Element( 'b' );
+			c = new Element( 'c' );
+			d = new Element( 'd' );
+
+			xxx = new Text( 'xxx' );
+			b.appendChildren( xxx );
+
+			root.appendChildren( [ a, b, c, d ] );
+		} );
+
+		it( 'should return true if element is inside range and false when it is not inside range', () => {
+			const range = Range.createFromParentsAndOffsets( root, 1, root, 3 ); // Range over `b` and `c`.
+
+			expect( range.containsItem( a ) ).to.be.false;
+			expect( range.containsItem( b ) ).to.be.true;
+			expect( range.containsItem( xxx ) ).to.be.true;
+			expect( range.containsItem( c ) ).to.be.true;
+			expect( range.containsItem( d ) ).to.be.false;
+		} );
+	} );
+
 	describe( '_getTransformedByInsertion()', () => {
 		it( 'should return an array of Range objects', () => {
 			const range = new Range( new Position( root, [ 0 ] ), new Position( root, [ 1 ] ) );
@@ -954,6 +986,19 @@ describe( 'Range', () => {
 				expect( transformed[ 0 ].end.path ).to.deep.equal( [ 1, 1 ] );
 			} );
 
+			it( 'split at the beginning of multi-element range', () => {
+				range.start = new Position( root, [ 0, 4 ] );
+				range.end = new Position( root, [ 1, 2 ] );
+
+				const delta = getSplitDelta( new Position( root, [ 0, 4 ] ), new Element( 'p' ), 3, 1 );
+
+				const transformed = range.getTransformedByDelta( delta );
+
+				expect( transformed.length ).to.equal( 1 );
+				expect( transformed[ 0 ].start.path ).to.deep.equal( [ 1, 0 ] );
+				expect( transformed[ 0 ].end.path ).to.deep.equal( [ 2, 2 ] );
+			} );
+
 			it( 'split inside range which starts at the beginning of split element', () => {
 				range.start = new Position( root, [ 0, 0 ] );
 				range.end = new Position( root, [ 0, 4 ] );

+ 15 - 0
packages/ckeditor5-engine/tests/model/schema/schema.js

@@ -492,6 +492,7 @@ describe( 'Schema', () => {
 			schema.registerItem( 'p', '$block' );
 			schema.registerItem( 'h1', '$block' );
 			schema.registerItem( 'img', '$inline' );
+			schema.registerItem( 'figure' );
 
 			// Bold text is allowed only in P.
 			schema.allow( { name: '$text', attributes: 'bold', inside: 'p' } );
@@ -499,6 +500,10 @@ describe( 'Schema', () => {
 
 			// Disallow bold on image.
 			schema.disallow( { name: 'img', attributes: 'bold', inside: '$root' } );
+
+			// Figure must have name attribute and optional title attribute.
+			schema.requireAttributes( 'figure', [ 'name' ] );
+			schema.allow( { name: 'figure', attributes: [ 'title', 'name' ], inside: '$root' } );
 		} );
 
 		describe( 'when selection is collapsed', () => {
@@ -544,6 +549,16 @@ describe( 'Schema', () => {
 				setData( doc, '<p>foo[<img /><img />]bar</p>' );
 				expect( schema.checkAttributeInSelection( doc.selection, attribute ) ).to.be.false;
 			} );
+
+			it( 'should return true when checking element with required attribute', () => {
+				setData( doc, '[<figure name="figure"></figure>]' );
+				expect( schema.checkAttributeInSelection( doc.selection, 'title' ) ).to.be.true;
+			} );
+
+			it( 'should return true when checking element when attribute is already present', () => {
+				setData( doc, '[<figure name="figure" title="title"></figure>]' );
+				expect( schema.checkAttributeInSelection( doc.selection, 'title' ) ).to.be.true;
+			} );
 		} );
 	} );