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

Merge branch 'master' into i/6194-collection-getall

Aleksander Nowodzinski 5 лет назад
Родитель
Сommit
13c7fae3fe
43 измененных файлов с 1399 добавлено и 148 удалено
  1. 4 2
      docs/framework/guides/architecture/editing-engine.md
  2. 3 3
      docs/framework/guides/architecture/ui-library.md
  3. 1 1
      docs/framework/guides/deep-dive/localization.md
  4. 1 1
      docs/framework/guides/development-tools.md
  5. 1 1
      packages/ckeditor5-autosave/src/autosave.js
  6. 2 0
      packages/ckeditor5-core/src/editor/editor.js
  7. 2 1
      packages/ckeditor5-engine/src/controller/datacontroller.js
  8. 2 1
      packages/ckeditor5-engine/src/controller/editingcontroller.js
  9. 6 0
      packages/ckeditor5-engine/src/conversion/downcastdispatcher.js
  10. 283 6
      packages/ckeditor5-engine/src/conversion/downcasthelpers.js
  11. 194 3
      packages/ckeditor5-engine/src/conversion/upcasthelpers.js
  12. 10 0
      packages/ckeditor5-engine/src/model/markercollection.js
  13. 0 3
      packages/ckeditor5-engine/src/model/position.js
  14. 1 1
      packages/ckeditor5-engine/src/model/treewalker.js
  15. 2 0
      packages/ckeditor5-engine/src/view/filler.js
  16. 5 8
      packages/ckeditor5-engine/src/view/text.js
  17. 2 2
      packages/ckeditor5-engine/tests/controller/datacontroller.js
  18. 481 0
      packages/ckeditor5-engine/tests/conversion/downcasthelpers.js
  19. 196 11
      packages/ckeditor5-engine/tests/conversion/upcasthelpers.js
  20. 6 0
      packages/ckeditor5-engine/tests/model/markercollection.js
  21. 30 1
      packages/ckeditor5-image/docs/_snippets/features/image-resize-px.js
  22. 14 0
      packages/ckeditor5-image/docs/_snippets/features/image-resize.js
  23. 0 6
      packages/ckeditor5-image/docs/_snippets/features/image-resizeui.html
  24. 9 2
      packages/ckeditor5-image/docs/_snippets/features/image-resizeui.js
  25. 0 6
      packages/ckeditor5-image/docs/_snippets/features/image-resizeuidropdown.html
  26. 12 1
      packages/ckeditor5-image/docs/_snippets/features/image-resizeuidropdown.js
  27. 7 1
      packages/ckeditor5-image/docs/_snippets/features/image-style-presentational.html
  28. 8 8
      packages/ckeditor5-image/docs/_snippets/features/image-style-presentational.js
  29. 90 39
      packages/ckeditor5-image/docs/features/image.md
  30. 3 3
      packages/ckeditor5-image/tests/manual/imageresize.js
  31. 3 3
      packages/ckeditor5-image/tests/manual/imageresizepx.js
  32. 3 3
      packages/ckeditor5-image/tests/manual/imageresizeui.js
  33. 1 7
      packages/ckeditor5-image/theme/imagestyle.css
  34. 4 3
      packages/ckeditor5-link/theme/linkimage.css
  35. 1 9
      packages/ckeditor5-media-embed/src/ui/mediaformview.js
  36. 2 0
      packages/ckeditor5-mention/src/ui/domwrapperview.js
  37. 1 1
      packages/ckeditor5-typing/src/deletecommand.js
  38. 3 3
      packages/ckeditor5-ui/src/editableui/editableuiview.js
  39. 1 1
      packages/ckeditor5-utils/src/keyboard.js
  40. 1 1
      packages/ckeditor5-watchdog/src/watchdog.js
  41. 0 2
      packages/ckeditor5-widget/src/utils.js
  42. 1 1
      packages/ckeditor5-word-count/src/wordcount.js
  43. 3 3
      yarn.lock

+ 4 - 2
docs/framework/guides/architecture/editing-engine.md

@@ -159,9 +159,11 @@ Markers are a special type of ranges.
 * They can only be created and changed through the {@link module:engine/model/writer~Writer model writer}.
 * They can only be created and changed through the {@link module:engine/model/writer~Writer model writer}.
 * They can be synchronized over the network with other collaborating clients.
 * They can be synchronized over the network with other collaborating clients.
 * They are automatically updated when the document's structure is changed.
 * They are automatically updated when the document's structure is changed.
-* They can be converted to attributes or elements in the [view](#view).
+* They can be converted to the editing view, to show them in the editor (as {@link module:engine/conversion/downcasthelpers~DowncastHelpers#markerToHighlight highlights} or {@link module:engine/conversion/downcasthelpers~DowncastHelpers#markerToElement elements}).
+* They can be {@link module:engine/conversion/downcasthelpers~DowncastHelpers#markerToData converted to the data view}, to store them with the document data.
+* They can be {@link module:engine/conversion/upcasthelpers~UpcastHelpers#dataToMarker loaded with the document data}.
 
 
-This makes them ideal for storing and maintaining additional data in the model — such as comments, selections of other users, etc.
+Markers are ideal for storing and maintaining additional data related to portions of the document — such as comments or selections of other users.
 
 
 ### Schema
 ### Schema
 
 

+ 3 - 3
docs/framework/guides/architecture/ui-library.md

@@ -310,7 +310,7 @@ The dropdown panel exposes its {@link module:ui/dropdown/dropdownpanelview~Dropd
 
 
 The framework provides a set of helpers to make the dropdown creation process easier, although it is still possible to compose a custom dropdown from scratch using the base classes.
 The framework provides a set of helpers to make the dropdown creation process easier, although it is still possible to compose a custom dropdown from scratch using the base classes.
 
 
-The {@link module:ui/dropdown/utils#createDropdown} helper creates a {@link module:ui/dropdown/dropdownview~DropdownView} with either a {@link module:ui/button/buttonview~ButtonView} or a {@link module:ui/dropdown/button/splitbuttonview~SplitButtonView}.
+The {@link module:ui/dropdown/utils~createDropdown} helper creates a {@link module:ui/dropdown/dropdownview~DropdownView} with either a {@link module:ui/button/buttonview~ButtonView} or a {@link module:ui/dropdown/button/splitbuttonview~SplitButtonView}.
 
 
 ```js
 ```js
 import { createDropdown } from '@ckeditor/ckeditor5-ui/src/dropdown/utils';
 import { createDropdown } from '@ckeditor/ckeditor5-ui/src/dropdown/utils';
@@ -376,7 +376,7 @@ dropdownView.buttonView.set( {
 
 
 #### Adding a list to a dropdown
 #### Adding a list to a dropdown
 
 
-The {@link module:ui/list/listview~ListView} can be added to a dropdown using the {@link module:ui/dropdown/utils#addListToDropdown} helper.
+The {@link module:ui/list/listview~ListView} can be added to a dropdown using the {@link module:ui/dropdown/utils~addListToDropdown} helper.
 
 
 ```js
 ```js
 import Model from '@ckeditor/ckeditor5-ui/src/model';
 import Model from '@ckeditor/ckeditor5-ui/src/model';
@@ -411,7 +411,7 @@ addListToDropdown( dropdownView, items );
 
 
 #### Adding a toolbar to a dropdown
 #### Adding a toolbar to a dropdown
 
 
-A {@link module:ui/toolbar/toolbarview~ToolbarView} can be added to a dropdown using  the {@link module:ui/dropdown/utils#addToolbarToDropdown} helper.
+A {@link module:ui/toolbar/toolbarview~ToolbarView} can be added to a dropdown using  the {@link module:ui/dropdown/utils~addToolbarToDropdown} helper.
 
 
 ```js
 ```js
 import ButtonView from '@ckeditor/ckeditor5-ui/src/button/buttonview';
 import ButtonView from '@ckeditor/ckeditor5-ui/src/button/buttonview';

+ 1 - 1
docs/framework/guides/deep-dive/localization.md

@@ -149,7 +149,7 @@ Adding translations to the editor can be done in three ways to satisfy various n
 
 
 ### Using the `add()` function
 ### Using the `add()` function
 
 
-The first option for adding translations is via {@link module:utils/translation-service.add the translation-service's `add()` helper}. This utility adds translations to the global `window.CKEDITOR_TRANSLATIONS` object by extending it. Since it needs to be imported, it works only before building the editor.
+The first option for adding translations is via {@link module:utils/translation-service~add the translation-service's `add()` helper}. This utility adds translations to the global `window.CKEDITOR_TRANSLATIONS` object by extending it. Since it needs to be imported, it works only before building the editor.
 
 
 Starting with the CKEditor 5 v19.0.0 release, the `add()` method now accepts an optional `getPluralForm()` function as the third argument. This function is only needed for defining the plural form if no language file was loaded for a particular language. It also accepts an array of translations for a *message* if the *message* should support singular and plural forms.
 Starting with the CKEditor 5 v19.0.0 release, the `add()` method now accepts an optional `getPluralForm()` function as the third argument. This function is only needed for defining the plural form if no language file was loaded for a particular language. It also accepts an array of translations for a *message* if the *message* should support singular and plural forms.
 
 

+ 1 - 1
docs/framework/guides/development-tools.md

@@ -90,7 +90,7 @@ They allow for "stringifying" the {@link framework/guides/architecture/editing-e
 	Both tools are designed for prototyping, debugging, and testing purposes. Do not use them in production-grade code.
 	Both tools are designed for prototyping, debugging, and testing purposes. Do not use them in production-grade code.
 </info-box>
 </info-box>
 
 
-For instance, to take a peek at the editor model, you could use the {@link module:engine/dev-utils/model#static-function-getData `getData()`} helper:
+For instance, to take a peek at the editor model, you could use the {@link module:engine/dev-utils/model~getData `getData()`} helper:
 
 
 ```js
 ```js
 import { getData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
 import { getData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';

+ 1 - 1
packages/ckeditor5-autosave/src/autosave.js

@@ -139,7 +139,7 @@ export default class Autosave extends Plugin {
 		 * Editor's pending actions manager.
 		 * Editor's pending actions manager.
 		 *
 		 *
 		 * @private
 		 * @private
-		 * @member {@module:core/pendingactions~PendingActions} #_pendingActions
+		 * @member {module:core/pendingactions~PendingActions} #_pendingActions
 		 */
 		 */
 	}
 	}
 
 

+ 2 - 0
packages/ckeditor5-core/src/editor/editor.js

@@ -90,6 +90,8 @@ export default class Editor {
 		this.plugins = new PluginCollection( this, availablePlugins, this._context.plugins );
 		this.plugins = new PluginCollection( this, availablePlugins, this._context.plugins );
 
 
 		/**
 		/**
+		 * The locale instance.
+		 *
 		 * @readonly
 		 * @readonly
 		 * @type {module:utils/locale~Locale}
 		 * @type {module:utils/locale~Locale}
 		 */
 		 */

+ 2 - 1
packages/ckeditor5-engine/src/controller/datacontroller.js

@@ -90,7 +90,8 @@ export default class DataController {
 		 * @member {module:engine/conversion/downcastdispatcher~DowncastDispatcher}
 		 * @member {module:engine/conversion/downcastdispatcher~DowncastDispatcher}
 		 */
 		 */
 		this.downcastDispatcher = new DowncastDispatcher( {
 		this.downcastDispatcher = new DowncastDispatcher( {
-			mapper: this.mapper
+			mapper: this.mapper,
+			schema: model.schema
 		} );
 		} );
 		this.downcastDispatcher.on( 'insert:$text', insertText(), { priority: 'lowest' } );
 		this.downcastDispatcher.on( 'insert:$text', insertText(), { priority: 'lowest' } );
 
 

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

@@ -65,7 +65,8 @@ export default class EditingController {
 		 * @member {module:engine/conversion/downcastdispatcher~DowncastDispatcher} #downcastDispatcher
 		 * @member {module:engine/conversion/downcastdispatcher~DowncastDispatcher} #downcastDispatcher
 		 */
 		 */
 		this.downcastDispatcher = new DowncastDispatcher( {
 		this.downcastDispatcher = new DowncastDispatcher( {
-			mapper: this.mapper
+			mapper: this.mapper,
+			schema: model.schema
 		} );
 		} );
 
 
 		const doc = this.model.document;
 		const doc = this.model.document;

+ 6 - 0
packages/ckeditor5-engine/src/conversion/downcastdispatcher.js

@@ -658,6 +658,12 @@ function shouldMarkerChangeBeConverted( modelPosition, marker, mapper ) {
  * @member {module:engine/conversion/mapper~Mapper} #mapper
  * @member {module:engine/conversion/mapper~Mapper} #mapper
  */
  */
 
 
+/**
+ * The {@link module:engine/model/schema~Schema} instance set for the model that is downcast.
+ *
+ * @member {module:engine/model/schema~Schema} #schema
+ */
+
 /**
 /**
  * The {@link module:engine/view/downcastwriter~DowncastWriter} instance used to manipulate data during conversion.
  * The {@link module:engine/view/downcastwriter~DowncastWriter} instance used to manipulate data during conversion.
  *
  *

+ 283 - 6
packages/ckeditor5-engine/src/conversion/downcasthelpers.js

@@ -239,22 +239,25 @@ export default class DowncastHelpers extends ConversionHelpers {
 	/**
 	/**
 	 * Model marker to view element conversion helper.
 	 * Model marker to view element conversion helper.
 	 *
 	 *
+	 * **Note**: This method should be used only for editing downcast. For data downcast, use
+	 * {@link #markerToData `#markerToData()`} that produces valid HTML data.
+	 *
 	 * This conversion results in creating a view element on the boundaries of the converted marker. If the converted marker
 	 * This conversion results in creating a view element on the boundaries of the converted marker. If the converted marker
 	 * is collapsed, only one element is created. For example, model marker set like this: `<paragraph>F[oo b]ar</paragraph>`
 	 * is collapsed, only one element is created. For example, model marker set like this: `<paragraph>F[oo b]ar</paragraph>`
 	 * becomes `<p>F<span data-marker="search"></span>oo b<span data-marker="search"></span>ar</p>` in the view.
 	 * becomes `<p>F<span data-marker="search"></span>oo b<span data-marker="search"></span>ar</p>` in the view.
 	 *
 	 *
-	 *		editor.conversion.for( 'downcast' ).markerToElement( {
+	 *		editor.conversion.for( 'editingDowncast' ).markerToElement( {
 	 *			model: 'search',
 	 *			model: 'search',
 	 *			view: 'marker-search'
 	 *			view: 'marker-search'
 	 *		} );
 	 *		} );
 	 *
 	 *
-	 *		editor.conversion.for( 'downcast' ).markerToElement( {
+	 *		editor.conversion.for( 'editingDowncast' ).markerToElement( {
 	 *			model: 'search',
 	 *			model: 'search',
 	 *			view: 'search-result',
 	 *			view: 'search-result',
 	 *			converterPriority: 'high'
 	 *			converterPriority: 'high'
 	 *		} );
 	 *		} );
 	 *
 	 *
-	 *		editor.conversion.for( 'downcast' ).markerToElement( {
+	 *		editor.conversion.for( 'editingDowncast' ).markerToElement( {
 	 *			model: 'search',
 	 *			model: 'search',
 	 *			view: {
 	 *			view: {
 	 *				name: 'span',
 	 *				name: 'span',
@@ -264,7 +267,7 @@ export default class DowncastHelpers extends ConversionHelpers {
 	 *			}
 	 *			}
 	 *		} );
 	 *		} );
 	 *
 	 *
-	 *		editor.conversion.for( 'downcast' ).markerToElement( {
+	 *		editor.conversion.for( 'editingDowncast' ).markerToElement( {
 	 *			model: 'search',
 	 *			model: 'search',
 	 *			view: ( markerData, viewWriter ) => {
 	 *			view: ( markerData, viewWriter ) => {
 	 *				return viewWriter.createUIElement( 'span', {
 	 *				return viewWriter.createUIElement( 'span', {
@@ -282,8 +285,6 @@ export default class DowncastHelpers extends ConversionHelpers {
 	 * the `data.isOpening` parameter is passed, which is set to `true` for the marker start boundary element, and `false` to
 	 * the `data.isOpening` parameter is passed, which is set to `true` for the marker start boundary element, and `false` to
 	 * the marker end boundary element.
 	 * the marker end boundary element.
 	 *
 	 *
-	 * This kind of conversion is useful for saving data into the database, so it should be used in the data conversion pipeline.
-	 *
 	 * See {@link module:engine/conversion/conversion~Conversion#for `conversion.for()`} to learn how to add a converter
 	 * See {@link module:engine/conversion/conversion~Conversion#for `conversion.for()`} to learn how to add a converter
 	 * to the conversion process.
 	 * to the conversion process.
 	 *
 	 *
@@ -357,6 +358,119 @@ export default class DowncastHelpers extends ConversionHelpers {
 	markerToHighlight( config ) {
 	markerToHighlight( config ) {
 		return this.add( downcastMarkerToHighlight( config ) );
 		return this.add( downcastMarkerToHighlight( config ) );
 	}
 	}
+
+	/**
+	 * Model marker converter for data downcast.
+	 *
+	 * This conversion creates a representation for model marker boundaries in the view:
+	 *
+	 * * if the marker boundary is at a position where text nodes are allowed, then a view element with specified tag name
+	 * and `name` attribute is added at that position,
+	 * * in other cases, a specified attribute is set on a view element that is before/after marker boundary.
+	 *
+	 * Typically, the marker names use `group:uniqueId:otherData` convention. For example: `comment:e34zfk9k2n459df53sjl34:zx32c`.
+	 * The default configuration for this conversion is that the first part is `group` part and the rest of
+	 * the marker name becomes `name` part.
+	 *
+	 * Tag and attribute names and values are generated from the marker name:
+	 *
+	 * * templates for attributes are `data-[group]-start-before="[name]"`, `data-[group]-start-after="[name]"`,
+	 * `data-[group]-end-before="[name]"` and `data-[group]-end-after="[name]"`,
+	 * * templates for view elements are `<[group]-start name="[name]">` and `<[group]-end name="[name]">`.
+	 *
+	 * Attributes mark whether given marker start or end boundary is before or after given element.
+	 * Attributes `data-[group]-start-before` and `data-[group]-end-after` are favored.
+	 * The other two are used when the former two cannot be used.
+	 *
+	 * The conversion configuration can take a function that will generate different group and name parts.
+	 * If such function is set as the `config.view` parameter, it is passed a marker name and it is expected to return an object with two
+	 * properties: `group` and `name`. If the function returns falsy value, the conversion will not take place.
+	 *
+	 * Basic usage:
+	 *
+	 *		// Using the default conversion.
+	 *		// In this case, all markers which name starts with 'comment:' will be converted.
+	 *		// The `group` parameter will be set to `comment`.
+	 *		// The `name` parameter will be the rest of the marker name (without `:`).
+	 *		editor.conversion.for( 'dataDowncast' ).markerToData( {
+	 *			model: 'comment'
+	 *		} );
+	 *
+	 * An example of a view that may be generated by this conversion (assuming a marker with name `comment:commentId:uid` marked by `[]`):
+	 *
+	 *		// Model:
+	 *		<paragraph>Foo[bar</paragraph>
+	 *		<image src="abc.jpg"></image>]
+	 *
+	 *		// View:
+	 *		<p>Foo<comment-start name="commentId:uid"></comment-start>bar</p>
+	 *		<figure data-comment-end-after="commentId:uid" class="image"><img src="abc.jpg" /></figure>
+	 *
+	 * In the example above, the comment starts before "bar" and ends after the image.
+	 *
+	 * If `name` part is empty, following view may be generated:
+	 *
+	 *		<p>Foo <myMarker-start></myMarker-start>bar</p>
+	 *		<figure data-myMarker-end-after="" class="image"><img src="abc.jpg" /></figure>
+	 *
+	 * **Note:** situation when some markers have `name` part and some don't is incorrect and should be avoided.
+	 *
+	 * Examples where `data-group-start-after` and `data-group-end-before` are used:
+	 *
+	 *		// Model:
+	 *		<blockQuote>[]<paragraph>Foo</paragraph></blockQuote>
+	 *
+	 * 		// View:
+	 *		<blockquote><p data-group-end-before="name" data-group-start-before="name">Foo</p></blockquote>
+	 *
+	 * Similarly, when marker is collapsed after the last element:
+	 *
+	 *		// Model:
+	 *		<blockQuote><paragraph>Foo</paragraph>[]</blockQuote>
+	 *
+	 *		// View:
+	 *		<blockquote><p data-group-end-after="name" data-group-start-after="name">Foo</p></blockquote>
+	 *
+	 * When there are multiple markers from the same group stored in the same attribute of the same element, their
+	 * name parts are put together in the attribute value, for example: `data-group-start-before="name1,name2,name3"`.
+	 *
+	 * Other examples of usage:
+	 *
+	 *		// Using custom function which is the same as the default conversion:
+	 *		editor.conversion.for( 'dataDowncast' ).markerToData( {
+	 *			model: 'comment'
+	 *			view: markerName => ( {
+	 *				group: 'comment',
+	 *				name: markerName.substr( 8 ) // Removes 'comment:' part.
+	 *			} )
+	 *		} );
+	 *
+	 *		// Using converter priority:
+	 *		editor.conversion.for( 'dataDowncast' ).markerToData( {
+	 *			model: 'comment'
+	 *			view: markerName => ( {
+	 *				group: 'comment',
+	 *				name: markerName.substr( 8 ) // Removes 'comment:' part.
+	 *			} ),
+	 *			converterPriority: 'high'
+	 *		} );
+	 *
+	 * This kind of conversion is useful for saving data into the database, so it should be used in the data conversion pipeline.
+	 *
+	 * See {@link module:engine/conversion/conversion~Conversion#for `conversion.for()`} to learn how to add a converter
+	 * to the conversion process.
+	 *
+	 * @method #markerToData
+	 * @param {Object} config Conversion configuration.
+	 * @param {String} config.model The name of the model marker (or model marker group) to convert.
+	 * @param {Function} [config.view] Function that takes the model marker name as a parameter and returns an object with `group`
+	 * and `name` properties.
+	 * @param {module:utils/priorities~PriorityString} [config.converterPriority='normal'] Converter priority.
+	 * @returns {module:engine/conversion/downcasthelpers~DowncastHelpers}
+	 */
+	markerToData( config ) {
+		return this.add( downcastMarkerToData( config ) );
+	}
 }
 }
 
 
 /**
 /**
@@ -755,6 +869,141 @@ function removeUIElement() {
 	};
 	};
 }
 }
 
 
+// Function factory that creates a default converter for model markers.
+//
+// See {@link DowncastHelpers#markerToData} for more information what type of view is generated.
+//
+// This converter binds created UI elements and affected view elements with the marker name
+// using {@link module:engine/conversion/mapper~Mapper#bindElementToMarker}.
+//
+// @returns {Function} Add marker converter.
+function insertMarkerData( viewCreator ) {
+	return ( evt, data, conversionApi ) => {
+		const viewMarkerData = viewCreator( data.markerName );
+
+		if ( !viewMarkerData ) {
+			return;
+		}
+
+		const markerRange = data.markerRange;
+
+		if ( !conversionApi.consumable.consume( markerRange, evt.name ) ) {
+			return;
+		}
+
+		// Adding closing data first to keep the proper order in the view.
+		handleMarkerBoundary( markerRange, false, conversionApi, data, viewMarkerData );
+		handleMarkerBoundary( markerRange, true, conversionApi, data, viewMarkerData );
+
+		evt.stop();
+	};
+}
+
+// Helper function for `insertMarkerData()` that marks a marker boundary at the beginning or end of given `range`.
+function handleMarkerBoundary( range, isStart, conversionApi, data, viewMarkerData ) {
+	const modelPosition = isStart ? range.start : range.end;
+	const canInsertElement = conversionApi.schema.checkChild( modelPosition, '$text' );
+
+	if ( canInsertElement ) {
+		const viewPosition = conversionApi.mapper.toViewPosition( modelPosition );
+
+		insertMarkerAsElement( viewPosition, isStart, conversionApi, data, viewMarkerData );
+	} else {
+		let modelElement;
+		let isBefore;
+
+		// If possible, we want to add `data-group-start-before` and `data-group-end-after` attributes.
+		// Below `if` is constructed in a way that will favor adding these attributes.
+		//
+		// Also, I assume that there will be always an element either after or before the position.
+		// If not, then it is a case when we are not in a position where text is allowed and also there are no elements around...
+		if ( isStart && modelPosition.nodeAfter || !isStart && !modelPosition.nodeBefore ) {
+			modelElement = modelPosition.nodeAfter;
+			isBefore = true;
+		} else {
+			modelElement = modelPosition.nodeBefore;
+			isBefore = false;
+		}
+
+		const viewElement = conversionApi.mapper.toViewElement( modelElement );
+
+		insertMarkerAsAttribute( viewElement, isStart, isBefore, conversionApi, data, viewMarkerData );
+	}
+}
+
+// Helper function for `insertMarkerData()` that marks a marker boundary in the view as an attribute on a view element.
+function insertMarkerAsAttribute( viewElement, isStart, isBefore, conversionApi, data, viewMarkerData ) {
+	const attributeName = `data-${ viewMarkerData.group }-${ isStart ? 'start' : 'end' }-${ isBefore ? 'before' : 'after' }`;
+
+	const markerNames = viewElement.hasAttribute( attributeName ) ? viewElement.getAttribute( attributeName ).split( ',' ) : [];
+
+	// Adding marker name at the beginning to have the same order in the attribute as there is with marker elements.
+	markerNames.unshift( viewMarkerData.name );
+
+	conversionApi.writer.setAttribute( attributeName, markerNames.join( ',' ), viewElement );
+	conversionApi.mapper.bindElementToMarker( viewElement, data.markerName );
+}
+
+// Helper function for `insertMarkerData()` that marks a marker boundary in the view as a separate view ui element.
+function insertMarkerAsElement( position, isStart, conversionApi, data, viewMarkerData ) {
+	const viewElementName = `${ viewMarkerData.group }-${ isStart ? 'start' : 'end' }`;
+
+	const attrs = viewMarkerData.name ? { 'name': viewMarkerData.name } : null;
+	const viewElement = conversionApi.writer.createUIElement( viewElementName, attrs );
+
+	conversionApi.writer.insert( position, viewElement );
+	conversionApi.mapper.bindElementToMarker( viewElement, data.markerName );
+}
+
+// Function factory that creates a converter for removing a model marker data added by the {@link #insertMarkerData} converter.
+//
+// @returns {Function} Remove marker converter.
+function removeMarkerData( viewCreator ) {
+	return ( evt, data, conversionApi ) => {
+		const viewData = viewCreator( data.markerName );
+
+		if ( !viewData ) {
+			return;
+		}
+
+		const elements = conversionApi.mapper.markerNameToElements( data.markerName );
+
+		if ( !elements ) {
+			return;
+		}
+
+		for ( const element of elements ) {
+			conversionApi.mapper.unbindElementFromMarkerName( element, data.markerName );
+
+			if ( element.is( 'containerElement' ) ) {
+				removeMarkerFromAttribute( `data-${ viewData.group }-start-before`, element );
+				removeMarkerFromAttribute( `data-${ viewData.group }-start-after`, element );
+				removeMarkerFromAttribute( `data-${ viewData.group }-end-before`, element );
+				removeMarkerFromAttribute( `data-${ viewData.group }-end-after`, element );
+			} else {
+				conversionApi.writer.clear( conversionApi.writer.createRangeOn( element ), element );
+			}
+		}
+
+		conversionApi.writer.clearClonedElementsGroup( data.markerName );
+
+		evt.stop();
+
+		function removeMarkerFromAttribute( attributeName, element ) {
+			if ( element.hasAttribute( attributeName ) ) {
+				const markerNames = new Set( element.getAttribute( attributeName ).split( ',' ) );
+				markerNames.delete( viewData.name );
+
+				if ( markerNames.size == 0 ) {
+					conversionApi.writer.removeAttribute( attributeName, element );
+				} else {
+					conversionApi.writer.setAttribute( attributeName, Array.from( markerNames ).join( ',' ), element );
+				}
+			}
+		}
+	};
+}
+
 // Function factory that creates a converter which converts set/change/remove attribute changes from the model to the view.
 // Function factory that creates a converter which converts set/change/remove attribute changes from the model to the view.
 //
 //
 // Attributes from the model are converted to the view element attributes in the view. You may provide a custom function to generate
 // Attributes from the model are converted to the view element attributes in the view. You may provide a custom function to generate
@@ -1179,6 +1428,34 @@ function downcastMarkerToElement( config ) {
 	};
 	};
 }
 }
 
 
+// Model marker to view data conversion helper.
+//
+// See {@link ~DowncastHelpers#markerToData `markerToData()` downcast helper} to learn more.
+//
+// @param {Object} config
+// @param {String} config.model
+// @param {Function} [config.view]
+// @param {module:utils/priorities~PriorityString} [config.converterPriority='normal']
+// @returns {Function} Conversion helper.
+function downcastMarkerToData( config ) {
+	config = cloneDeep( config );
+
+	const group = config.model;
+
+	// Default conversion.
+	if ( !config.view ) {
+		config.view = markerName => ( {
+			group,
+			name: markerName.substr( config.model.length + 1 )
+		} );
+	}
+
+	return dispatcher => {
+		dispatcher.on( 'addMarker:' + group, insertMarkerData( config.view ), { priority: config.converterPriority || 'normal' } );
+		dispatcher.on( 'removeMarker:' + group, removeMarkerData( config.view ), { priority: config.converterPriority || 'normal' } );
+	};
+}
+
 // Model marker to highlight conversion helper.
 // Model marker to highlight conversion helper.
 //
 //
 // See {@link ~DowncastHelpers#markerToElement `.markerToElement()` downcast helper} for examples.
 // See {@link ~DowncastHelpers#markerToElement `.markerToElement()` downcast helper} for examples.

+ 194 - 3
packages/ckeditor5-engine/src/conversion/upcasthelpers.js

@@ -9,6 +9,9 @@ import ConversionHelpers from './conversionhelpers';
 
 
 import { cloneDeep } from 'lodash-es';
 import { cloneDeep } from 'lodash-es';
 import ModelSelection from '../model/selection';
 import ModelSelection from '../model/selection';
+import { attachLinkToDocumentation } from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
+
+/* global console */
 
 
 /**
 /**
  * Contains {@link module:engine/view/view view} to {@link module:engine/model/model model} converters for
  * Contains {@link module:engine/view/view view} to {@link module:engine/model/model model} converters for
@@ -287,6 +290,8 @@ export default class UpcastHelpers extends ConversionHelpers {
 	/**
 	/**
 	 * View element to model marker conversion helper.
 	 * View element to model marker conversion helper.
 	 *
 	 *
+	 * **Note**: This method was deprecated. Please use {@link #dataToMarker} instead.
+	 *
 	 * This conversion results in creating a model marker. For example, if the marker was stored in a view as an element:
 	 * This conversion results in creating a model marker. For example, if the marker was stored in a view as an element:
 	 * `<p>Fo<span data-marker="comment" data-comment-id="7"></span>o</p><p>B<span data-marker="comment" data-comment-id="7"></span>ar</p>`,
 	 * `<p>Fo<span data-marker="comment" data-comment-id="7"></span>o</p><p>B<span data-marker="comment" data-comment-id="7"></span>ar</p>`,
 	 * after the conversion is done, the marker will be available in
 	 * after the conversion is done, the marker will be available in
@@ -321,6 +326,7 @@ export default class UpcastHelpers extends ConversionHelpers {
 	 * See {@link module:engine/conversion/conversion~Conversion#for `conversion.for()`} to learn how to add a converter
 	 * See {@link module:engine/conversion/conversion~Conversion#for `conversion.for()`} to learn how to add a converter
 	 * to the conversion process.
 	 * to the conversion process.
 	 *
 	 *
+	 * @deprecated
 	 * @method #elementToMarker
 	 * @method #elementToMarker
 	 * @param {Object} config Conversion configuration.
 	 * @param {Object} config Conversion configuration.
 	 * @param {module:engine/view/matcher~MatcherPattern} config.view Pattern matching all view elements which should be converted.
 	 * @param {module:engine/view/matcher~MatcherPattern} config.view Pattern matching all view elements which should be converted.
@@ -330,8 +336,93 @@ export default class UpcastHelpers extends ConversionHelpers {
 	 * @returns {module:engine/conversion/upcasthelpers~UpcastHelpers}
 	 * @returns {module:engine/conversion/upcasthelpers~UpcastHelpers}
 	 */
 	 */
 	elementToMarker( config ) {
 	elementToMarker( config ) {
+		/**
+		 * The {@link module:engine/conversion/upcasthelpers~UpcastHelpers#elementToMarker `UpcastHelpers#elementToMarker()`}
+		 * method has been deprecated and will be removed in the near future.
+		 * Please use {@link module:engine/conversion/upcasthelpers~UpcastHelpers#dataToMarker `UpcastHelpers#dataToMarker()`} instead.
+		 *
+		 * @error upcast-helpers-element-to-marker-deprecated
+		 */
+		console.warn(
+			attachLinkToDocumentation(
+				'upcast-helpers-element-to-marker-deprecated: ' +
+				'The UpcastHelpers#elementToMarker() method has been deprecated and will be removed in the near future. ' +
+				'Please use UpcastHelpers#dataToMarker() instead.'
+			)
+		);
+
 		return this.add( upcastElementToMarker( config ) );
 		return this.add( upcastElementToMarker( config ) );
 	}
 	}
+
+	/**
+	 * View to model marker conversion helper.
+	 *
+	 * Converts view data created by {@link module:engine/conversion/downcasthelpers~DowncastHelpers#markerToData `#markerToData()`}
+	 * back to a model marker.
+	 *
+	 * This converter looks for specific view elements and view attributes that mark marker boundaries. See
+	 * {@link module:engine/conversion/downcasthelpers~DowncastHelpers#markerToData `#markerToData()`} to learn what view data
+	 * is expected by this converter.
+	 *
+	 * The `config.view` property is equal to the marker group name to convert.
+	 *
+	 * By default, this converter creates markers with `group:name` name convention (to match the default `markerToData` conversion).
+	 *
+	 * The conversion configuration can take a function that will generate a marker name.
+	 * If such function is set as the `config.model` parameter, it is passed the `name` part from the view element or attribute and it is
+	 * expected to return a string with the marker name.
+	 *
+	 * Basic usage:
+	 *
+	 *		// Using the default conversion.
+	 *		// In this case, all markers from `comment` group will be converted.
+	 *		// The conversion will look for `<comment-start>` and `<comment-end>` tags and
+	 *		// `data-comment-start-before`, `data-comment-start-after`,
+	 *		// `data-comment-end-before` and `data-comment-end-after` attributes.
+	 *		editor.conversion.for( 'upcast' ).dataToMarker( {
+	 *			view: 'comment'
+	 *		} );
+	 *
+	 * An example of a model that may be generated by this conversion:
+	 *
+	 *		// View:
+	 *		<p>Foo<comment-start name="commentId:uid"></comment-start>bar</p>
+	 *		<figure data-comment-end-after="commentId:uid" class="image"><img src="abc.jpg" /></figure>
+	 *
+	 *		// Model:
+	 *		<paragraph>Foo[bar</paragraph>
+	 *		<image src="abc.jpg"></image>]
+	 *
+	 * Where `[]` are boundaries of a marker that will receive `comment:commentId:uid` name.
+	 *
+	 * Other examples of usage:
+	 *
+	 *		// Using custom function which is the same as the default conversion:
+	 *		editor.conversion.for( 'upcast' ).dataToMarker( {
+	 *			view: 'comment',
+	 *			model: name => 'comment:' + name,
+	 *		} );
+	 *
+	 *		// Using converter priority:
+	 *		editor.conversion.for( 'upcast' ).dataToMarker( {
+	 *			view: 'comment',
+	 *			model: name => 'comment:' + name,
+	 *			converterPriority: 'high'
+	 *		} );
+	 *
+	 * See {@link module:engine/conversion/conversion~Conversion#for `conversion.for()`} to learn how to add a converter
+	 * to the conversion process.
+	 *
+	 * @method #dataToMarker
+	 * @param {Object} config Conversion configuration.
+	 * @param {String} config.view Marker group name to convert.
+	 * @param {Function} [config.model] Function that takes `name` part from the view element or attribute and returns the marker name.
+	 * @param {module:utils/priorities~PriorityString} [config.converterPriority='normal'] Converter priority.
+	 * @returns {module:engine/conversion/upcasthelpers~UpcastHelpers}
+	 */
+	dataToMarker( config ) {
+		return this.add( upcastDataToMarker( config ) );
+	}
 }
 }
 
 
 /**
 /**
@@ -512,11 +603,91 @@ function upcastAttributeToAttribute( config ) {
 function upcastElementToMarker( config ) {
 function upcastElementToMarker( config ) {
 	config = cloneDeep( config );
 	config = cloneDeep( config );
 
 
-	normalizeToMarkerConfig( config );
+	normalizeElementToMarkerConfig( config );
 
 
 	return upcastElementToElement( config );
 	return upcastElementToElement( config );
 }
 }
 
 
+// View data to model marker conversion helper.
+//
+// See {@link ~UpcastHelpers#dataToMarker} to learn more.
+//
+// @param {Object} config
+// @param {String} config.view
+// @param {Function} [config.model]
+// @param {module:utils/priorities~PriorityString} [config.converterPriority='normal']
+// @returns {Function} Conversion helper.
+function upcastDataToMarker( config ) {
+	config = cloneDeep( config );
+
+	// Default conversion.
+	if ( !config.model ) {
+		config.model = name => {
+			return name ? config.view + ':' + name : config.view;
+		};
+	}
+
+	const converterStart = prepareToElementConverter( normalizeDataToMarkerConfig( config, 'start' ) );
+	const converterEnd = prepareToElementConverter( normalizeDataToMarkerConfig( config, 'end' ) );
+
+	return dispatcher => {
+		dispatcher.on( 'element:' + config.view + '-start', converterStart, { priority: config.converterPriority || 'normal' } );
+		dispatcher.on( 'element:' + config.view + '-end', converterEnd, { priority: config.converterPriority || 'normal' } );
+
+		// This is attribute upcast so it has to be done after the element upcast.
+		dispatcher.on( 'element', upcastAttributeToMarker( config ), { priority: config.converterPriority || 'low' } );
+	};
+}
+
+// Function factory, returns a callback function which converts view attributes to a model marker.
+//
+// The converter looks for elements with `data-group-start-before`, `data-group-start-after`, `data-group-end-before`
+// and `data-group-end-after` attributes and inserts `$marker` model elements before/after those elements.
+// `group` part is specified in `config.view`.
+//
+// @param {Object} config
+// @param {String} config.view
+// @param {Function} [config.model]
+// @returns {Function} Marker converter.
+function upcastAttributeToMarker( config ) {
+	return ( evt, data, conversionApi ) => {
+		const attrName = `data-${ config.view }`;
+
+		if ( conversionApi.consumable.consume( data.viewItem, { attributes: attrName + '-end-after' } ) ) {
+			addMarkerElements( data.modelRange.end, data.viewItem.getAttribute( attrName + '-end-after' ).split( ',' ) );
+		}
+
+		if ( conversionApi.consumable.consume( data.viewItem, { attributes: attrName + '-start-after' } ) ) {
+			addMarkerElements( data.modelRange.end, data.viewItem.getAttribute( attrName + '-start-after' ).split( ',' ) );
+		}
+
+		if ( conversionApi.consumable.consume( data.viewItem, { attributes: attrName + '-end-before' } ) ) {
+			addMarkerElements( data.modelRange.start, data.viewItem.getAttribute( attrName + '-end-before' ).split( ',' ) );
+		}
+
+		if ( conversionApi.consumable.consume( data.viewItem, { attributes: attrName + '-start-before' } ) ) {
+			addMarkerElements( data.modelRange.start, data.viewItem.getAttribute( attrName + '-start-before' ).split( ',' ) );
+		}
+
+		function addMarkerElements( position, markerViewNames ) {
+			for ( const markerViewName of markerViewNames ) {
+				const markerName = config.model( markerViewName );
+				const element = conversionApi.writer.createElement( '$marker', { 'data-name': markerName } );
+
+				conversionApi.writer.insert( element, position );
+
+				if ( data.modelCursor.isEqual( position ) ) {
+					data.modelCursor = data.modelCursor.getShiftedBy( 1 );
+				} else {
+					data.modelCursor = data.modelCursor._getTransformedByInsertion( position, 1 );
+				}
+
+				data.modelRange = data.modelRange._getTransformedByInsertion( position, 1 )[ 0 ];
+			}
+		}
+	};
+}
+
 // Helper function for from-view-element conversion. Checks if `config.view` directly specifies converted view element's name
 // Helper function for from-view-element conversion. Checks if `config.view` directly specifies converted view element's name
 // and if so, returns it.
 // and if so, returns it.
 //
 //
@@ -779,10 +950,10 @@ function setAttributeOn( modelRange, modelAttribute, shallow, conversionApi ) {
 }
 }
 
 
 // Helper function for upcasting-to-marker conversion. Takes the config in a format requested by `upcastElementToMarker()`
 // Helper function for upcasting-to-marker conversion. Takes the config in a format requested by `upcastElementToMarker()`
-// function and converts it to a format that is supported by `_upcastElementToElement()` function.
+// function and converts it to a format that is supported by `upcastElementToElement()` function.
 //
 //
 // @param {Object} config Conversion configuration.
 // @param {Object} config Conversion configuration.
-function normalizeToMarkerConfig( config ) {
+function normalizeElementToMarkerConfig( config ) {
 	const oldModel = config.model;
 	const oldModel = config.model;
 
 
 	config.model = ( viewElement, modelWriter ) => {
 	config.model = ( viewElement, modelWriter ) => {
@@ -791,3 +962,23 @@ function normalizeToMarkerConfig( config ) {
 		return modelWriter.createElement( '$marker', { 'data-name': markerName } );
 		return modelWriter.createElement( '$marker', { 'data-name': markerName } );
 	};
 	};
 }
 }
+
+// Helper function for upcasting-to-marker conversion. Takes the config in a format requested by `upcastDataToMarker()`
+// function and converts it to a format that is supported by `upcastElementToElement()` function.
+//
+// @param {Object} config Conversion configuration.
+function normalizeDataToMarkerConfig( config, type ) {
+	const configForElements = {};
+
+	// Upcast <markerGroup-start> and <markerGroup-end> elements.
+	configForElements.view = config.view + '-' + type;
+
+	configForElements.model = ( viewElement, modelWriter ) => {
+		const viewName = viewElement.getAttribute( 'name' );
+		const markerName = config.model( viewName );
+
+		return modelWriter.createElement( '$marker', { 'data-name': markerName } );
+	};
+
+	return configForElements;
+}

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

@@ -92,6 +92,16 @@ export default class MarkerCollection {
 	 */
 	 */
 	_set( markerOrName, range, managedUsingOperations = false, affectsData = false ) {
 	_set( markerOrName, range, managedUsingOperations = false, affectsData = false ) {
 		const markerName = markerOrName instanceof Marker ? markerOrName.name : markerOrName;
 		const markerName = markerOrName instanceof Marker ? markerOrName.name : markerOrName;
+
+		if ( markerName.includes( ',' ) ) {
+			/**
+			 * Marker name cannot contain the "," character.
+			 *
+			 * @error markercollection-incorrect-marker-name
+			 */
+			throw new CKEditorError( 'markercollection-incorrect-marker-name: Marker name cannot contain "," character.', this );
+		}
+
 		const oldMarker = this._markers.get( markerName );
 		const oldMarker = this._markers.get( markerName );
 
 
 		if ( oldMarker ) {
 		if ( oldMarker ) {

+ 0 - 3
packages/ckeditor5-engine/src/model/position.js

@@ -146,9 +146,6 @@ export default class Position {
 		return this.path[ this.path.length - 1 ];
 		return this.path[ this.path.length - 1 ];
 	}
 	}
 
 
-	/**
-	 * @param {Number} newOffset
-	 */
 	set offset( newOffset ) {
 	set offset( newOffset ) {
 		this.path[ this.path.length - 1 ] = newOffset;
 		this.path[ this.path.length - 1 ] = newOffset;
 	}
 	}

+ 1 - 1
packages/ckeditor5-engine/src/model/treewalker.js

@@ -410,5 +410,5 @@ function formatReturnValue( type, item, previousPosition, nextPosition, length )
 /**
 /**
  * Tree walking directions.
  * Tree walking directions.
  *
  *
- * @typedef {'forward'|'backward'} module:engine/view/treewalker~TreeWalkerDirection
+ * @typedef {'forward'|'backward'} module:engine/model/treewalker~TreeWalkerDirection
  */
  */

+ 2 - 0
packages/ckeditor5-engine/src/view/filler.js

@@ -64,6 +64,8 @@ export const INLINE_FILLER_LENGTH = 7;
 
 
 /**
 /**
  * Inline filler which is a sequence of the zero width spaces.
  * Inline filler which is a sequence of the zero width spaces.
+ *
+ * @type {String}
  */
  */
 export const INLINE_FILLER = ( () => {
 export const INLINE_FILLER = ( () => {
 	let inlineFiller = '';
 	let inlineFiller = '';

+ 5 - 8
packages/ckeditor5-engine/src/view/text.js

@@ -76,7 +76,9 @@ export default class Text extends Node {
 	}
 	}
 
 
 	/**
 	/**
-	 * This getter is required when using the addition assignment operator on protected property:
+	 * The `_data` property is controlled by a getter and a setter.
+	 *
+	 * The getter is required when using the addition assignment operator on protected property:
 	 *
 	 *
 	 *		const foo = downcastWriter.createText( 'foo' );
 	 *		const foo = downcastWriter.createText( 'foo' );
 	 *		const bar = downcastWriter.createText( 'bar' );
 	 *		const bar = downcastWriter.createText( 'bar' );
@@ -86,6 +88,8 @@ export default class Text extends Node {
 	 *
 	 *
 	 * If the protected getter didn't exist, `foo._data` will return `undefined` and result of the merge will be invalid.
 	 * If the protected getter didn't exist, `foo._data` will return `undefined` and result of the merge will be invalid.
 	 *
 	 *
+	 * The setter sets data and fires the {@link module:engine/view/node~Node#event:change:text change event}.
+	 *
 	 * @protected
 	 * @protected
 	 * @type {String}
 	 * @type {String}
 	 */
 	 */
@@ -93,13 +97,6 @@ export default class Text extends Node {
 		return this.data;
 		return this.data;
 	}
 	}
 
 
-	/**
-	 * Sets data and fires the {@link module:engine/view/node~Node#event:change:text change event}.
-	 *
-	 * @protected
-	 * @fires change:text
-	 * @param {String} data New data for the text node.
-	 */
 	set _data( data ) {
 	set _data( data ) {
 		this._fireChange( 'text', this );
 		this._fireChange( 'text', this );
 
 

+ 2 - 2
packages/ckeditor5-engine/tests/controller/datacontroller.js

@@ -342,8 +342,8 @@ describe( 'DataController', () => {
 
 
 			data.set( 'foo' );
 			data.set( 'foo' );
 
 
-			downcastHelpers.markerToElement( { model: 'marker', view: 'marker' } );
-			upcastHelpers.elementToMarker( { view: 'marker', model: 'marker' } );
+			downcastHelpers.markerToData( { model: 'marker' } );
+			upcastHelpers.dataToMarker( { view: 'marker' } );
 
 
 			model.change( writer => {
 			model.change( writer => {
 				writer.addMarker( 'marker', { range: writer.createRangeIn( modelDocument.getRoot() ), usingOperation: true } );
 				writer.addMarker( 'marker', { range: writer.createRangeIn( modelDocument.getRoot() ), usingOperation: true } );

+ 481 - 0
packages/ckeditor5-engine/tests/conversion/downcasthelpers.js

@@ -996,6 +996,487 @@ describe( 'DowncastHelpers', () => {
 		} );
 		} );
 	} );
 	} );
 
 
+	describe( 'markerToData()', () => {
+		let root;
+
+		beforeEach( () => {
+			root = model.document.getRoot();
+
+			model.schema.register( 'paragraph', { inheritAllFrom: '$block' } );
+			downcastHelpers.elementToElement( { model: 'paragraph', view: 'p' } );
+		} );
+
+		it( 'should be chainable', () => {
+			expect( downcastHelpers.markerToData( { model: 'search' } ) ).to.equal( downcastHelpers );
+		} );
+
+		it( 'default conversion, inside text, non-collapsed, no name', () => {
+			downcastHelpers.markerToData( { model: 'search' } );
+
+			setModelData( model, '<paragraph>Fo[ob]ar</paragraph>' );
+
+			model.change( writer => {
+				writer.addMarker( 'search', { range: model.document.selection.getFirstRange(), usingOperation: false } );
+			} );
+
+			expectResult( '<p>Fo<search-start></search-start>ob<search-end></search-end>ar</p>' );
+
+			model.change( writer => {
+				writer.removeMarker( 'search' );
+			} );
+
+			expectResult( '<p>Foobar</p>' );
+		} );
+
+		it( 'default conversion, inside text, non-collapsed, name', () => {
+			downcastHelpers.markerToData( { model: 'group' } );
+
+			setModelData( model, '<paragraph>Fo[ob]ar</paragraph>' );
+
+			model.change( writer => {
+				writer.addMarker( 'group:foo:bar:baz', { range: model.document.selection.getFirstRange(), usingOperation: false } );
+			} );
+
+			expectResult( '<p>Fo<group-start name="foo:bar:baz"></group-start>ob<group-end name="foo:bar:baz"></group-end>ar</p>' );
+
+			model.change( writer => {
+				writer.removeMarker( 'group:foo:bar:baz' );
+			} );
+
+			expectResult( '<p>Foobar</p>' );
+		} );
+
+		it( 'default conversion, inside text, collapsed, no name', () => {
+			downcastHelpers.markerToData( { model: 'search' } );
+
+			setModelData( model, '<paragraph>Foo[]bar</paragraph>' );
+
+			model.change( writer => {
+				writer.addMarker( 'search', { range: model.document.selection.getFirstRange(), usingOperation: false } );
+			} );
+
+			expectResult( '<p>Foo<search-start></search-start><search-end></search-end>bar</p>' );
+
+			model.change( writer => {
+				writer.removeMarker( 'search' );
+			} );
+
+			expectResult( '<p>Foobar</p>' );
+		} );
+
+		it( 'default conversion, inside text, collapsed, multiple markers, no name', () => {
+			downcastHelpers.markerToData( { model: 'group' } );
+
+			setModelData( model, '<paragraph>Foo[]bar</paragraph>' );
+
+			model.change( writer => {
+				writer.addMarker( 'group:foo', { range: model.document.selection.getFirstRange(), usingOperation: false } );
+				writer.addMarker( 'group:abc', { range: model.document.selection.getFirstRange(), usingOperation: false } );
+			} );
+
+			expectResult(
+				'<p>' +
+					'Foo' +
+					'<group-start name="abc"></group-start><group-end name="abc"></group-end>' +
+					'<group-start name="foo"></group-start><group-end name="foo"></group-end>' +
+					'bar' +
+				'</p>'
+			);
+
+			model.change( writer => {
+				writer.removeMarker( 'group:foo' );
+				writer.removeMarker( 'group:abc' );
+			} );
+
+			expectResult( '<p>Foobar</p>' );
+		} );
+
+		it( 'default conversion, on two elements, no name', () => {
+			downcastHelpers.markerToData( { model: 'search' } );
+
+			setModelData( model, '<paragraph>Foo</paragraph><paragraph>Bar</paragraph>' );
+
+			model.change( writer => {
+				const range = writer.createRangeIn( root );
+				writer.addMarker( 'search', { range, usingOperation: false } );
+			} );
+
+			expectResult( '<p data-search-start-before="">Foo</p><p data-search-end-after="">Bar</p>' );
+
+			model.change( writer => {
+				writer.removeMarker( 'search' );
+			} );
+
+			expectResult( '<p>Foo</p><p>Bar</p>' );
+		} );
+
+		it( 'default conversion, on two elements, name', () => {
+			downcastHelpers.markerToData( { model: 'group' } );
+
+			setModelData( model, '<paragraph>Foo</paragraph><paragraph>Bar</paragraph>' );
+
+			model.change( writer => {
+				const range = writer.createRangeIn( root );
+				writer.addMarker( 'group:foo:bar:baz', { range, usingOperation: false } );
+			} );
+
+			expectResult( '<p data-group-start-before="foo:bar:baz">Foo</p><p data-group-end-after="foo:bar:baz">Bar</p>' );
+
+			model.change( writer => {
+				writer.removeMarker( 'group:foo:bar:baz' );
+			} );
+
+			expectResult( '<p>Foo</p><p>Bar</p>' );
+		} );
+
+		it( 'default conversion, on one element, name', () => {
+			downcastHelpers.markerToData( { model: 'group' } );
+
+			setModelData( model, '<paragraph>Foobar</paragraph>' );
+
+			model.change( writer => {
+				const range = writer.createRangeIn( root );
+				writer.addMarker( 'group:foo:bar:baz', { range, usingOperation: false } );
+			} );
+
+			expectResult( '<p data-group-end-after="foo:bar:baz" data-group-start-before="foo:bar:baz">Foobar</p>' );
+
+			model.change( writer => {
+				writer.removeMarker( 'group:foo:bar:baz' );
+			} );
+
+			expectResult( '<p>Foobar</p>' );
+		} );
+
+		it( 'default conversion, collapsed before element, name', () => {
+			downcastHelpers.markerToData( { model: 'group' } );
+
+			setModelData( model, '<paragraph>Foobar</paragraph>' );
+
+			model.change( writer => {
+				// Collapsed before <paragraph>.
+				const range = writer.createRange(
+					writer.createPositionFromPath( root, [ 0 ] )
+				);
+
+				writer.addMarker( 'group:foo:bar:baz', { range, usingOperation: false } );
+			} );
+
+			expectResult( '<p data-group-end-before="foo:bar:baz" data-group-start-before="foo:bar:baz">Foobar</p>' );
+
+			model.change( writer => {
+				writer.removeMarker( 'group:foo:bar:baz' );
+			} );
+
+			expectResult( '<p>Foobar</p>' );
+		} );
+
+		it( 'default conversion, collapsed after element, name', () => {
+			downcastHelpers.markerToData( { model: 'group' } );
+
+			setModelData( model, '<paragraph>Foobar</paragraph>' );
+
+			model.change( writer => {
+				// Collapsed before <paragraph>.
+				const range = writer.createRange(
+					writer.createPositionFromPath( root, [ 1 ] )
+				);
+
+				writer.addMarker( 'group:foo:bar:baz', { range, usingOperation: false } );
+			} );
+
+			expectResult( '<p data-group-end-after="foo:bar:baz" data-group-start-after="foo:bar:baz">Foobar</p>' );
+
+			model.change( writer => {
+				writer.removeMarker( 'group:foo:bar:baz' );
+			} );
+
+			expectResult( '<p>Foobar</p>' );
+		} );
+
+		it( 'default conversion, mixed, multiple markers, name', () => {
+			downcastHelpers.markerToData( { model: 'group' } );
+
+			setModelData( model, '<paragraph>Foo</paragraph><paragraph>Bar</paragraph>' );
+
+			model.change( writer => {
+				const range = writer.createRange(
+					writer.createPositionFromPath( root, [ 0 ] ),
+					writer.createPositionFromPath( root, [ 1, 2 ] )
+				);
+
+				writer.addMarker( 'group:foo:bar', { range, usingOperation: false } );
+				writer.addMarker( 'group:abc:xyz', { range, usingOperation: false } );
+			} );
+
+			expectResult(
+				'<p data-group-start-before="abc:xyz,foo:bar">Foo</p>' +
+				'<p>Ba<group-end name="abc:xyz"></group-end><group-end name="foo:bar"></group-end>r</p>'
+			);
+
+			model.change( writer => {
+				writer.removeMarker( 'group:foo:bar' );
+				writer.removeMarker( 'group:abc:xyz' );
+			} );
+
+			expectResult( '<p>Foo</p><p>Bar</p>' );
+		} );
+
+		it( 'default conversion, mixed #2, multiple markers, name', () => {
+			downcastHelpers.markerToData( { model: 'group' } );
+
+			setModelData( model, '<paragraph>Foo</paragraph><paragraph>Bar</paragraph>' );
+
+			model.change( writer => {
+				const range = writer.createRange(
+					writer.createPositionFromPath( root, [ 0, 1 ] ),
+					writer.createPositionFromPath( root, [ 2 ] )
+				);
+
+				writer.addMarker( 'group:foo:bar', { range, usingOperation: false } );
+				writer.addMarker( 'group:abc:xyz', { range, usingOperation: false } );
+			} );
+
+			expectResult(
+				'<p>F<group-start name="abc:xyz"></group-start><group-start name="foo:bar"></group-start>oo</p>' +
+				'<p data-group-end-after="abc:xyz,foo:bar">Bar</p>'
+			);
+
+			model.change( writer => {
+				writer.removeMarker( 'group:foo:bar' );
+				writer.removeMarker( 'group:abc:xyz' );
+			} );
+
+			expectResult( '<p>Foo</p><p>Bar</p>' );
+		} );
+
+		it( 'default conversion, mixed #3, multiple markers, name', () => {
+			downcastHelpers.markerToData( { model: 'group' } );
+
+			setModelData( model, '<paragraph>Foo</paragraph>' );
+
+			model.change( writer => {
+				const range = writer.createRange(
+					writer.createPositionFromPath( root, [ 0 ] ),
+					writer.createPositionFromPath( root, [ 0, 2 ] )
+				);
+
+				writer.addMarker( 'group:foo:bar', { range, usingOperation: false } );
+				writer.addMarker( 'group:abc:xyz', { range, usingOperation: false } );
+			} );
+
+			expectResult(
+				'<p data-group-start-before="abc:xyz,foo:bar">' +
+					'Fo<group-end name="abc:xyz"></group-end><group-end name="foo:bar"></group-end>o' +
+				'</p>'
+			);
+
+			model.change( writer => {
+				writer.removeMarker( 'group:foo:bar' );
+				writer.removeMarker( 'group:abc:xyz' );
+			} );
+
+			expectResult( '<p>Foo</p>' );
+		} );
+
+		it( 'default conversion, mixed #4, multiple markers, name', () => {
+			downcastHelpers.markerToData( { model: 'group' } );
+
+			setModelData( model, '<paragraph>Foo</paragraph>' );
+
+			model.change( writer => {
+				const range = writer.createRange(
+					writer.createPositionFromPath( root, [ 0, 2 ] ),
+					writer.createPositionFromPath( root, [ 1 ] )
+				);
+
+				writer.addMarker( 'group:foo:bar', { range, usingOperation: false } );
+				writer.addMarker( 'group:abc:xyz', { range, usingOperation: false } );
+			} );
+
+			expectResult(
+				'<p data-group-end-after="abc:xyz,foo:bar">' +
+					'Fo<group-start name="abc:xyz"></group-start><group-start name="foo:bar"></group-start>o' +
+				'</p>'
+			);
+
+			model.change( writer => {
+				writer.removeMarker( 'group:foo:bar' );
+				writer.removeMarker( 'group:abc:xyz' );
+			} );
+
+			expectResult( '<p>Foo</p>' );
+		} );
+
+		it( 'conversion callback, mixed, multiple markers, name', () => {
+			const customData = {
+				foo: 'bar',
+				abc: 'xyz'
+			};
+
+			downcastHelpers.markerToData( {
+				model: 'group',
+				view: markerName => {
+					const namePart = markerName.split( ':' )[ 1 ];
+
+					return {
+						group: 'g',
+						name: namePart + '_' + customData[ namePart ]
+					};
+				}
+			} );
+
+			setModelData( model, '<paragraph>Foo</paragraph><paragraph>Bar</paragraph>' );
+
+			model.change( writer => {
+				const range = writer.createRange(
+					writer.createPositionFromPath( root, [ 0 ] ),
+					writer.createPositionFromPath( root, [ 1, 2 ] )
+				);
+
+				writer.addMarker( 'group:foo', { range, usingOperation: false } );
+				writer.addMarker( 'group:abc', { range, usingOperation: false } );
+			} );
+
+			expectResult(
+				'<p data-g-start-before="abc_xyz,foo_bar">Foo</p>' +
+				'<p>Ba<g-end name="abc_xyz"></g-end><g-end name="foo_bar"></g-end>r</p>'
+			);
+
+			model.change( writer => {
+				writer.removeMarker( 'group:foo' );
+				writer.removeMarker( 'group:abc' );
+			} );
+
+			expectResult( '<p>Foo</p><p>Bar</p>' );
+		} );
+
+		it( 'conversion callback, mixed #2, multiple markers, name', () => {
+			const customData = {
+				foo: 'bar',
+				abc: 'xyz'
+			};
+
+			downcastHelpers.markerToData( {
+				model: 'group',
+				view: markerName => {
+					const namePart = markerName.split( ':' )[ 1 ];
+
+					return {
+						group: 'g',
+						name: namePart + '_' + customData[ namePart ]
+					};
+				}
+			} );
+
+			setModelData( model, '<paragraph>Foo</paragraph><paragraph>Bar</paragraph>' );
+
+			model.change( writer => {
+				const range = writer.createRange(
+					writer.createPositionFromPath( root, [ 0, 1 ] ),
+					writer.createPositionFromPath( root, [ 2 ] )
+				);
+
+				writer.addMarker( 'group:foo', { range, usingOperation: false } );
+				writer.addMarker( 'group:abc', { range, usingOperation: false } );
+			} );
+
+			expectResult(
+				'<p>F<g-start name="abc_xyz"></g-start><g-start name="foo_bar"></g-start>oo</p>' +
+				'<p data-g-end-after="abc_xyz,foo_bar">Bar</p>'
+			);
+
+			model.change( writer => {
+				writer.removeMarker( 'group:foo' );
+				writer.removeMarker( 'group:abc' );
+			} );
+
+			expectResult( '<p>Foo</p><p>Bar</p>' );
+		} );
+
+		it( 'can be overwritten using converterPriority', () => {
+			downcastHelpers.markerToData( {
+				model: 'group'
+			} );
+
+			downcastHelpers.markerToData( {
+				model: 'group',
+				view: markerName => {
+					const name = markerName.split( ':' )[ 1 ];
+
+					return {
+						group: 'g',
+						name
+					};
+				},
+				converterPriority: 'high'
+			} );
+
+			setModelData( model, '<paragraph>F[ooba]r</paragraph>' );
+
+			model.change( writer => {
+				writer.addMarker( 'group:foo', { range: model.document.selection.getFirstRange(), usingOperation: false } );
+			} );
+
+			expectResult(
+				'<p>F<g-start name="foo"></g-start>ooba<g-end name="foo"></g-end>r</p>'
+			);
+
+			model.change( writer => {
+				writer.removeMarker( 'group:foo' );
+			} );
+
+			expectResult( '<p>Foobar</p>' );
+		} );
+
+		it( 'can be overwritten by custom callback', () => {
+			downcastHelpers.markerToData( {
+				model: 'group'
+			} );
+
+			downcastHelpers.add( dispatcher => {
+				dispatcher.on( 'addMarker:group', ( evt, data, conversionApi ) => {
+					conversionApi.consumable.consume( data.markerRange, evt.name );
+				}, { priority: 'high' } );
+			} );
+
+			setModelData( model, '<paragraph>Foo[]bar</paragraph>' );
+
+			model.change( writer => {
+				writer.addMarker( 'group:foo', { range: model.document.selection.getFirstRange(), usingOperation: false } );
+			} );
+
+			expectResult( '<p>Foobar</p>' );
+
+			model.change( writer => {
+				writer.removeMarker( 'group:foo' );
+			} );
+
+			expectResult( '<p>Foobar</p>' );
+		} );
+
+		it( 'should not perform conversion if the callback returned falsy value', () => {
+			downcastHelpers.markerToData( {
+				model: 'group',
+				view: () => false
+			} );
+
+			setModelData( model, '<paragraph>F[ooba]r</paragraph>' );
+
+			model.change( writer => {
+				writer.addMarker( 'group:foo', { range: model.document.selection.getFirstRange(), usingOperation: false } );
+			} );
+
+			expectResult( '<p>Foobar</p>' );
+
+			model.change( writer => {
+				writer.removeMarker( 'group:foo' );
+			} );
+
+			expectResult( '<p>Foobar</p>' );
+		} );
+	} );
+
 	describe( 'markerToHighlight()', () => {
 	describe( 'markerToHighlight()', () => {
 		it( 'should be chainable', () => {
 		it( 'should be chainable', () => {
 			expect( downcastHelpers.markerToHighlight( { model: 'comment', view: { classes: 'comment' } } ) ).to.equal( downcastHelpers );
 			expect( downcastHelpers.markerToHighlight( { model: 'comment', view: { classes: 'comment' } } ) ).to.equal( downcastHelpers );

+ 196 - 11
packages/ckeditor5-engine/tests/conversion/upcasthelpers.js

@@ -24,12 +24,14 @@ import UpcastHelpers, { convertToModelFragment, convertText, convertSelectionCha
 import { getData as modelGetData, setData as modelSetData, stringify } from '../../src/dev-utils/model';
 import { getData as modelGetData, setData as modelSetData, stringify } from '../../src/dev-utils/model';
 import View from '../../src/view/view';
 import View from '../../src/view/view';
 import createViewRoot from '../view/_utils/createroot';
 import createViewRoot from '../view/_utils/createroot';
-import { setData as viewSetData } from '../../src/dev-utils/view';
+import { setData as viewSetData, parse as viewParse } from '../../src/dev-utils/view';
 import Mapper from '../../src/conversion/mapper';
 import Mapper from '../../src/conversion/mapper';
 import ViewSelection from '../../src/view/selection';
 import ViewSelection from '../../src/view/selection';
 import ViewRange from '../../src/view/range';
 import ViewRange from '../../src/view/range';
 import { StylesProcessor } from '../../src/view/stylesmap';
 import { StylesProcessor } from '../../src/view/stylesmap';
 
 
+/* globals console */
+
 describe( 'UpcastHelpers', () => {
 describe( 'UpcastHelpers', () => {
 	let upcastDispatcher, model, schema, upcastHelpers, viewDocument;
 	let upcastDispatcher, model, schema, upcastHelpers, viewDocument;
 
 
@@ -56,7 +58,7 @@ describe( 'UpcastHelpers', () => {
 		upcastHelpers = new UpcastHelpers( [ upcastDispatcher ] );
 		upcastHelpers = new UpcastHelpers( [ upcastDispatcher ] );
 	} );
 	} );
 
 
-	describe( '.elementToElement()', () => {
+	describe( 'elementToElement()', () => {
 		it( 'should be chainable', () => {
 		it( 'should be chainable', () => {
 			expect( upcastHelpers.elementToElement( { view: 'p', model: 'paragraph' } ) ).to.equal( upcastHelpers );
 			expect( upcastHelpers.elementToElement( { view: 'p', model: 'paragraph' } ) ).to.equal( upcastHelpers );
 		} );
 		} );
@@ -168,7 +170,7 @@ describe( 'UpcastHelpers', () => {
 		} );
 		} );
 	} );
 	} );
 
 
-	describe( '.elementToAttribute()', () => {
+	describe( 'elementToAttribute()', () => {
 		it( 'should be chainable', () => {
 		it( 'should be chainable', () => {
 			expect( upcastHelpers.elementToAttribute( { view: 'strong', model: 'bold' } ) ).to.equal( upcastHelpers );
 			expect( upcastHelpers.elementToAttribute( { view: 'strong', model: 'bold' } ) ).to.equal( upcastHelpers );
 		} );
 		} );
@@ -369,7 +371,7 @@ describe( 'UpcastHelpers', () => {
 		} );
 		} );
 	} );
 	} );
 
 
-	describe( '.attributeToAttribute()', () => {
+	describe( 'attributeToAttribute()', () => {
 		beforeEach( () => {
 		beforeEach( () => {
 			upcastHelpers.elementToElement( { view: 'img', model: 'image' } );
 			upcastHelpers.elementToElement( { view: 'img', model: 'image' } );
 
 
@@ -584,7 +586,17 @@ describe( 'UpcastHelpers', () => {
 		} );
 		} );
 	} );
 	} );
 
 
-	describe( '.elementToMarker()', () => {
+	describe( 'elementToMarker()', () => {
+		beforeEach( () => {
+			// Silence warning about deprecated method.
+			// This whole suite will be removed when the deprecated method is removed.
+			sinon.stub( console, 'warn' );
+		} );
+
+		afterEach( () => {
+			console.warn.restore();
+		} );
+
 		it( 'should be chainable', () => {
 		it( 'should be chainable', () => {
 			expect( upcastHelpers.elementToMarker( { view: 'marker-search', model: 'search' } ) ).to.equal( upcastHelpers );
 			expect( upcastHelpers.elementToMarker( { view: 'marker-search', model: 'search' } ) ).to.equal( upcastHelpers );
 		} );
 		} );
@@ -682,16 +694,189 @@ describe( 'UpcastHelpers', () => {
 		} );
 		} );
 	} );
 	} );
 
 
-	function expectResult( viewToConvert, modelString, marker ) {
+	describe( 'dataToMarker()', () => {
+		beforeEach( () => {
+			upcastHelpers.elementToElement( { view: 'p', model: 'paragraph' } );
+		} );
+
+		it( 'should be chainable', () => {
+			expect( upcastHelpers.dataToMarker( { view: 'search' } ) ).to.equal( upcastHelpers );
+		} );
+
+		it( 'default conversion, inside text, non-collapsed, no name', () => {
+			upcastHelpers.dataToMarker( { view: 'search' } );
+
+			expectResult(
+				viewParse( '<p>Fo<search-start></search-start>ob<search-end></search-end>ar</p>' ),
+				'<paragraph>Foobar</paragraph>',
+				{ name: 'search', start: [ 0, 2 ], end: [ 0, 4 ] }
+			);
+		} );
+
+		it( 'default conversion, inside text, non-collapsed, name', () => {
+			upcastHelpers.dataToMarker( { view: 'group' } );
+
+			expectResult(
+				viewParse( '<p>Fo<group-start name="foo:bar:baz"></group-start>ob<group-end name="foo:bar:baz"></group-end>ar</p>' ),
+				'<paragraph>Foobar</paragraph>',
+				{ name: 'group:foo:bar:baz', start: [ 0, 2 ], end: [ 0, 4 ] }
+			);
+		} );
+
+		it( 'default conversion, inside text, collapsed, no name', () => {
+			upcastHelpers.dataToMarker( { view: 'search' } );
+
+			expectResult(
+				viewParse( '<p>Foo<search-start></search-start><search-end></search-end>bar</p>' ),
+				'<paragraph>Foobar</paragraph>',
+				{ name: 'search', start: [ 0, 3 ], end: [ 0, 3 ] }
+			);
+		} );
+
+		it( 'default conversion, inside text, collapsed, multiple markers, no name', () => {
+			upcastHelpers.dataToMarker( { view: 'group' } );
+
+			expectResult(
+				viewParse(
+					'<p>' +
+						'Foo' +
+						'<group-start name="abc"></group-start><group-end name="abc"></group-end>' +
+						'<group-start name="foo"></group-start><group-end name="foo"></group-end>' +
+						'bar' +
+					'</p>'
+				),
+				'<paragraph>Foobar</paragraph>',
+				[
+					{ name: 'group:abc', start: [ 0, 3 ], end: [ 0, 3 ] },
+					{ name: 'group:foo', start: [ 0, 3 ], end: [ 0, 3 ] }
+				]
+			);
+		} );
+
+		it( 'default conversion, on two elements, no name', () => {
+			upcastHelpers.dataToMarker( { view: 'search' } );
+
+			expectResult(
+				viewParse( '<p data-search-start-before="">Foo</p><p data-search-end-after="">Bar</p>' ),
+				'<paragraph>Foo</paragraph><paragraph>Bar</paragraph>',
+				{ name: 'search', start: [ 0 ], end: [ 2 ] }
+			);
+		} );
+
+		it( 'default conversion, on two elements, name', () => {
+			upcastHelpers.dataToMarker( { view: 'group' } );
+
+			expectResult(
+				viewParse( '<p data-group-start-before="foo:bar:baz">Foo</p><p data-group-end-after="foo:bar:baz">Bar</p>' ),
+				'<paragraph>Foo</paragraph><paragraph>Bar</paragraph>',
+				{ name: 'group:foo:bar:baz', start: [ 0 ], end: [ 2 ] }
+			);
+		} );
+
+		it( 'default conversion, on one element, name', () => {
+			upcastHelpers.dataToMarker( { view: 'group' } );
+
+			expectResult(
+				viewParse( '<p data-group-end-after="foo:bar:baz" data-group-start-before="foo:bar:baz">Foobar</p>' ),
+				'<paragraph>Foobar</paragraph>',
+				{ name: 'group:foo:bar:baz', start: [ 0 ], end: [ 1 ] }
+			);
+		} );
+
+		it( 'default conversion, collapsed before element, name', () => {
+			upcastHelpers.dataToMarker( { view: 'group' } );
+
+			expectResult(
+				viewParse( '<p data-group-end-before="foo:bar:baz" data-group-start-before="foo:bar:baz">Foobar</p>' ),
+				'<paragraph>Foobar</paragraph>',
+				{ name: 'group:foo:bar:baz', start: [ 0 ], end: [ 0 ] }
+			);
+		} );
+
+		it( 'default conversion, collapsed after element, name', () => {
+			upcastHelpers.dataToMarker( { view: 'group' } );
+
+			expectResult(
+				viewParse( '<p data-group-end-after="foo:bar:baz" data-group-start-after="foo:bar:baz">Foobar</p>' ),
+				'<paragraph>Foobar</paragraph>',
+				{ name: 'group:foo:bar:baz', start: [ 1 ], end: [ 1 ] }
+			);
+		} );
+
+		it( 'default conversion, mixed, multiple markers, name', () => {
+			upcastHelpers.dataToMarker( { view: 'group' } );
+
+			expectResult(
+				viewParse(
+					'<p data-group-start-before="abc:xyz,foo:bar">Foo</p>' +
+					'<p>Ba<group-end name="abc:xyz"></group-end><group-end name="foo:bar"></group-end>r</p>'
+				),
+				'<paragraph>Foo</paragraph><paragraph>Bar</paragraph>',
+				[
+					{ name: 'group:foo:bar', start: [ 0 ], end: [ 1, 2 ] },
+					{ name: 'group:abc:xyz', start: [ 0 ], end: [ 1, 2 ] }
+				]
+			);
+		} );
+
+		it( 'default conversion, mixed #2, multiple markers, name', () => {
+			upcastHelpers.dataToMarker( { view: 'group' } );
+
+			expectResult(
+				viewParse(
+					'<p>F<group-start name="abc:xyz"></group-start><group-start name="foo:bar"></group-start>oo</p>' +
+					'<p data-group-end-after="abc:xyz,foo:bar">Bar</p>'
+				),
+				'<paragraph>Foo</paragraph><paragraph>Bar</paragraph>',
+				[
+					{ name: 'group:foo:bar', start: [ 0, 1 ], end: [ 2 ] },
+					{ name: 'group:abc:xyz', start: [ 0, 1 ], end: [ 2 ] }
+				]
+			);
+		} );
+
+		it( 'conversion callback, mixed, multiple markers, name', () => {
+			upcastHelpers.dataToMarker( { view: 'g', model: name => 'group:' + name.split( '_' )[ 0 ] } );
+
+			expectResult(
+				viewParse(
+					'<p data-g-start-before="abc_xyz,foo_bar">Foo</p>' +
+					'<p>Ba<g-end name="abc_xyz"></g-end><g-end name="foo_bar"></g-end>r</p>'
+				),
+				'<paragraph>Foo</paragraph><paragraph>Bar</paragraph>',
+				[
+					{ name: 'group:foo', start: [ 0 ], end: [ 1, 2 ] },
+					{ name: 'group:abc', start: [ 0 ], end: [ 1, 2 ] }
+				]
+			);
+		} );
+
+		it( 'can be overwritten using converterPriority', () => {
+			upcastHelpers.dataToMarker( { view: 'group' } );
+			upcastHelpers.dataToMarker( { view: 'group', model: name => 'g:' + name, converterPriority: 'high' } );
+
+			expectResult(
+				viewParse( '<p>Foo<group-start name="foo"></group-start><group-end name="foo"></group-end>bar</p>' ),
+				'<paragraph>Foobar</paragraph>',
+				{ name: 'g:foo', start: [ 0, 3 ], end: [ 0, 3 ] }
+			);
+		} );
+	} );
+
+	function expectResult( viewToConvert, modelString, markers ) {
 		const conversionResult = model.change( writer => upcastDispatcher.convert( viewToConvert, writer ) );
 		const conversionResult = model.change( writer => upcastDispatcher.convert( viewToConvert, writer ) );
 
 
-		if ( marker ) {
-			expect( conversionResult.markers.has( marker.name ) ).to.be.true;
+		if ( markers ) {
+			markers = Array.isArray( markers ) ? markers : [ markers ];
+
+			for ( const marker of markers ) {
+				expect( conversionResult.markers.has( marker.name ) ).to.be.true;
 
 
-			const convertedMarker = conversionResult.markers.get( marker.name );
+				const convertedMarker = conversionResult.markers.get( marker.name );
 
 
-			expect( convertedMarker.start.path ).to.deep.equal( marker.start );
-			expect( convertedMarker.end.path ).to.deep.equal( marker.end );
+				expect( convertedMarker.start.path ).to.deep.equal( marker.start );
+				expect( convertedMarker.end.path ).to.deep.equal( marker.end );
+			}
 		}
 		}
 
 
 		expect( stringify( conversionResult ) ).to.equal( modelString );
 		expect( stringify( conversionResult ) ).to.equal( modelString );

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

@@ -123,6 +123,12 @@ describe( 'MarkerCollection', () => {
 
 
 			expect( marker.getRange().isEqual( range2 ) ).to.be.true;
 			expect( marker.getRange().isEqual( range2 ) ).to.be.true;
 		} );
 		} );
+
+		it( 'should throw if marker name with "," is added', () => {
+			expectToThrowCKEditorError( () => {
+				markers._set( 'foo,bar', range );
+			}, /^markercollection-incorrect-marker-name:/, markers );
+		} );
 	} );
 	} );
 
 
 	describe( 'has', () => {
 	describe( 'has', () => {

+ 30 - 1
packages/ckeditor5-image/docs/_snippets/features/image-resize-px.js

@@ -11,7 +11,36 @@ ClassicEditor
 	.create( document.querySelector( '#snippet-image-resize-px' ), {
 	.create( document.querySelector( '#snippet-image-resize-px' ), {
 		removePlugins: [ 'LinkImage' ],
 		removePlugins: [ 'LinkImage' ],
 		image: {
 		image: {
-			resizeUnit: 'px'
+			resizeUnit: 'px',
+			resizeOptions: [
+				{
+					name: 'imageResize:original',
+					label: 'Original',
+					value: null
+				},
+				{
+					name: 'imageResize:250',
+					label: '250px',
+					value: '250'
+				},
+				{
+					name: 'imageResize:400',
+					label: '400px',
+					value: '400'
+				}
+			],
+			styles: [
+				'alignLeft',
+				'alignCenter',
+				'alignRight'
+			],
+			toolbar: [
+				'imageStyle:alignLeft',
+				'imageStyle:alignCenter',
+				'imageStyle:alignRight',
+				'|',
+				'imageResize'
+			]
 		},
 		},
 		toolbar: {
 		toolbar: {
 			viewportTopOffset: window.getViewportTopOffsetConfig()
 			viewportTopOffset: window.getViewportTopOffsetConfig()

+ 14 - 0
packages/ckeditor5-image/docs/_snippets/features/image-resize.js

@@ -13,6 +13,20 @@ ClassicEditor
 		toolbar: {
 		toolbar: {
 			viewportTopOffset: window.getViewportTopOffsetConfig()
 			viewportTopOffset: window.getViewportTopOffsetConfig()
 		},
 		},
+		image: {
+			styles: [
+				'alignLeft',
+				'alignCenter',
+				'alignRight'
+			],
+			toolbar: [
+				'imageStyle:alignLeft',
+				'imageStyle:alignCenter',
+				'imageStyle:alignRight',
+				'|',
+				'imageTextAlternative'
+			]
+		},
 		cloudServices: CS_CONFIG
 		cloudServices: CS_CONFIG
 	} )
 	} )
 	.then( editor => {
 	.then( editor => {

+ 0 - 6
packages/ckeditor5-image/docs/_snippets/features/image-resizeui.html

@@ -5,10 +5,4 @@
 		<img src="%BASE_PATH%/assets/img/fields.jpg" alt="Autumn fields">
 		<img src="%BASE_PATH%/assets/img/fields.jpg" alt="Autumn fields">
 		<figcaption>Autumn fields by Aleksander Nowodziński</figcaption>
 		<figcaption>Autumn fields by Aleksander Nowodziński</figcaption>
 	</figure>
 	</figure>
-
-	<h3>Resized image (width: 75%):</h3>
-
-	<figure class="image image_resized" style="width:75%;">
-		<img src="%BASE_PATH%/assets/img/fields.jpg" alt="Autumn fields">
-	</figure>
 </div>
 </div>

+ 9 - 2
packages/ckeditor5-image/docs/_snippets/features/image-resizeui.js

@@ -34,9 +34,16 @@ ClassicEditor
 					icon: 'large'
 					icon: 'large'
 				}
 				}
 			],
 			],
+			styles: [
+				'alignLeft',
+				'alignCenter',
+				'alignRight'
+			],
 			toolbar: [
 			toolbar: [
-				'imageStyle:full',
-				'imageStyle:side', '|',
+				'imageStyle:alignLeft',
+				'imageStyle:alignCenter',
+				'imageStyle:alignRight',
+				'|',
 				'imageResize:50',
 				'imageResize:50',
 				'imageResize:75',
 				'imageResize:75',
 				'imageResize:original'
 				'imageResize:original'

+ 0 - 6
packages/ckeditor5-image/docs/_snippets/features/image-resizeuidropdown.html

@@ -5,10 +5,4 @@
 		<img src="%BASE_PATH%/assets/img/fields.jpg" alt="Autumn fields">
 		<img src="%BASE_PATH%/assets/img/fields.jpg" alt="Autumn fields">
 		<figcaption>Autumn fields by Aleksander Nowodziński</figcaption>
 		<figcaption>Autumn fields by Aleksander Nowodziński</figcaption>
 	</figure>
 	</figure>
-
-	<h3>Resized image (width: 75%):</h3>
-
-	<figure class="image image_resized" style="width:75%;">
-		<img src="%BASE_PATH%/assets/img/fields.jpg" alt="Autumn fields">
-	</figure>
 </div>
 </div>

+ 12 - 1
packages/ckeditor5-image/docs/_snippets/features/image-resizeuidropdown.js

@@ -31,7 +31,18 @@ ClassicEditor
 					value: '75'
 					value: '75'
 				}
 				}
 			],
 			],
-			toolbar: [ 'imageStyle:full', 'imageStyle:side', '|', 'imageResize' ]
+			styles: [
+				'alignLeft',
+				'alignCenter',
+				'alignRight'
+			],
+			toolbar: [
+				'imageStyle:alignLeft',
+				'imageStyle:alignCenter',
+				'imageStyle:alignRight',
+				'|',
+				'imageResize'
+			]
 		},
 		},
 		cloudServices: CS_CONFIG
 		cloudServices: CS_CONFIG
 	} )
 	} )

+ 7 - 1
packages/ckeditor5-image/docs/_snippets/features/image-style-custom.html → packages/ckeditor5-image/docs/_snippets/features/image-style-presentational.html

@@ -1,9 +1,15 @@
 <div id="snippet-image-style-custom">
 <div id="snippet-image-style-custom">
-	<p>An image to play with:</p>
+	<p>This is a default image (no style):</p>
 
 
 	<figure class="image">
 	<figure class="image">
 		<img src="%BASE_PATH%/assets/img/fields.jpg" alt="Autumn fields">
 		<img src="%BASE_PATH%/assets/img/fields.jpg" alt="Autumn fields">
 	</figure>
 	</figure>
 
 
+	<p>This is a right-aligned image:</p>
+
+	<figure class="image image-style-align-right">
+		<img src="%BASE_PATH%/assets/img/fields.jpg" alt="Autumn fields">
+	</figure>
+
 	<p>Lots of text here. Lots of text here. Lots of text here. Lots of text here. Lots of text here. Lots of text here. Lots of text here. Lots of text here. Lots of text here. Lots of text here. Lots of text here. Lots of text here. Lots of text here. Lots of text here. Lots of text here. Lots of text here. Lots of text here. Lots of text here. Lots of text here. Lots of text here. Lots of text here. Lots of text here. Lots of text here. Lots of text here. Lots of text here. Lots of text here. Lots of text here.</p>
 	<p>Lots of text here. Lots of text here. Lots of text here. Lots of text here. Lots of text here. Lots of text here. Lots of text here. Lots of text here. Lots of text here. Lots of text here. Lots of text here. Lots of text here. Lots of text here. Lots of text here. Lots of text here. Lots of text here. Lots of text here. Lots of text here. Lots of text here. Lots of text here. Lots of text here. Lots of text here. Lots of text here. Lots of text here. Lots of text here. Lots of text here. Lots of text here.</p>
 </div>
 </div>

+ 8 - 8
packages/ckeditor5-image/docs/_snippets/features/image-style-custom.js → packages/ckeditor5-image/docs/_snippets/features/image-style-presentational.js

@@ -12,17 +12,17 @@ ClassicEditor
 		removePlugins: [ 'ImageResize', 'LinkImage' ],
 		removePlugins: [ 'ImageResize', 'LinkImage' ],
 		image: {
 		image: {
 			styles: [
 			styles: [
-				// This option is equal to a situation where no style is applied.
-				'full',
-
-				// This represents an image aligned to left.
 				'alignLeft',
 				'alignLeft',
-
-				// This represents an image aligned to right.
+				'alignCenter',
 				'alignRight'
 				'alignRight'
 			],
 			],
-
-			toolbar: [ 'imageTextAlternative', '|', 'imageStyle:alignLeft', 'imageStyle:full', 'imageStyle:alignRight' ]
+			toolbar: [
+				'imageStyle:alignLeft',
+				'imageStyle:alignCenter',
+				'imageStyle:alignRight',
+				'|',
+				'imageTextAlternative'
+			]
 		},
 		},
 		toolbar: {
 		toolbar: {
 			viewportTopOffset: window.getViewportTopOffsetConfig()
 			viewportTopOffset: window.getViewportTopOffsetConfig()

+ 90 - 39
packages/ckeditor5-image/docs/features/image.md

@@ -78,17 +78,31 @@ By default, if the image caption is empty, the `<figcaption>` element is not vis
 
 
 {@snippet features/image-caption}
 {@snippet features/image-caption}
 
 
+## Image upload
+
+See the {@link features/image-upload Image upload} guide.
+
+## Responsive images
+
+Support for responsive images in CKEditor 5 is brought by the {@link features/easy-image Easy Image} feature without any additional configuration. Learn more how to use the feature in your project in the {@link features/easy-image#responsive-images Easy Image integration} guide.
+
 ## Image styles
 ## Image styles
 
 
 In simple integrations it is enough to let the user insert images, set their text alternative and the editor's job is done. An example of such a simple solution are e.g. [GitHub](https://github.com) comments. The styling of the images (for example, their maximum width and margins) is controlled by GitHub through stylesheets.
 In simple integrations it is enough to let the user insert images, set their text alternative and the editor's job is done. An example of such a simple solution are e.g. [GitHub](https://github.com) comments. The styling of the images (for example, their maximum width and margins) is controlled by GitHub through stylesheets.
 
 
 In more advanced scenarios, the user may need to be able to decide whether the image should take the whole width (if it is the article's main photo) or it should take, for example, 50% of the width and be pulled out of the content (so called "pulled images"). Various integration scenarios require different types of images to be used.
 In more advanced scenarios, the user may need to be able to decide whether the image should take the whole width (if it is the article's main photo) or it should take, for example, 50% of the width and be pulled out of the content (so called "pulled images"). Various integration scenarios require different types of images to be used.
 
 
-This is what the {@link module:image/imagestyle~ImageStyle} feature is designed for.
+Finally, in certain situations, the user should be able to granularly control how an image is presented so they should be able to set the size and alignment separately.
 
 
-However, unlike in CKEditor 4, in CKEditor 5 the end user does not set the image border, alignment, margins, width, etc. separately. Instead, they can pick one of the styles defined by the developer who prepared the WYSIWYG editor integration. This gives the developer control over how the users style images and makes the user's life easier by setting multiple properties at once.
+The {@link module:image/imagestyle~ImageStyle} feature solves the last two scenarios. The former is handled by so-called ["semantical styles"](#semantical-styles) and the latter by ["presentational styles"](#presentational-styles) in combination with [image resize](#resizing-images).
 
 
-A style is applied to the image in form of a class. By default, CKEditor 5 is configured to support two styles: "full width" (which does not apply any class &mdash; it is the default style) and "side image" (which applies the `image-style-side` class).
+The available image styles can be configured using the {@link module:image/image~ImageConfig#styles `config.image.styles`} option. Respective buttons should also be added to the image toolbar via {@link module:image/image~ImageConfig#toolbar `config.image.toolbar`}.
+
+### Semantical styles
+
+A semantical style let the user choose from a predefined "types" of images. The user is not able to set the image border, alignment, margins, width, etc. separately. Instead, they can pick one of the styles defined by the developer who prepared the WYSIWYG editor integration. This gives the developer control over how the users style images and makes the user's life easier by setting multiple properties at once.
+
+A style is applied to the image in form of a class. By default, CKEditor 5 is configured to support two default semantical styles: **"full width"** (which does not apply any class &mdash; it is the default style) and **"side image"** (which applies the `image-style-side` class).
 
 
 A normal (full width) image:
 A normal (full width) image:
 
 
@@ -103,37 +117,72 @@ A side image:
 ```
 ```
 
 
 <info-box>
 <info-box>
-	The actual styling of the images is the developer's job. CKEditor 5 WYSIWYG editor comes with some default styles, but they will only be applied to images inside the editor. The developer needs to style them appropriately on the target pages.
+	The actual styling of the images is the integrator's job. CKEditor 5 WYSIWYG editor comes with some default styles, but they will only be applied to images inside the editor. The integrator needs to style them appropriately on the target pages.
+
+	You can find the source of the default styles applied by the editor here: [`ckeditor5-image/theme/imagestyle.css`](https://github.com/ckeditor/ckeditor5/blob/master/packages/ckeditor5-image/theme/imagestyle.css).
 
 
-	You can find the source of the default styles applied by the editor here: [`ckeditor5-image/theme/imagestyle.css`](https://github.com/ckeditor/ckeditor5-image/blob/master/theme/imagestyle.css).
+	Read more about {@link builds/guides/integration/content-styles styling the content of the editor}.
 </info-box>
 </info-box>
 
 
-Below you can see a demo of the WYSIWYG editor with the image styles feature enabled. The default configuration is used. You can change the styles of images through the image's contextual toolbar.
+Below you can see a demo of the WYSIWYG editor with the semantical image styles. The "full" and "side" styles are the default value of {@link module:image/image~ImageConfig#styles `config.image.styles`} so you do not need to set it.
+
+```js
+ClassicEditor
+	.create( document.querySelector( '#editor' ), {
+		plugins: [ Image, ImageToolbar, ImageCaption, ImageStyle ],
+		image: {
+			toolbar: [
+				'imageStyle:full',
+				'imageStyle:side',
+				'|',
+				'imageTextAlternative'
+			],
+
+			// The default value,
+			styles: [
+				'full',
+				'side'
+			]
+		}
+	} )
+	.then( ... )
+	.catch( ... );
+```
+
+See the result below. You can change the styles of images through the image's contextual toolbar.
 
 
 {@snippet features/image-style}
 {@snippet features/image-style}
 
 
-### Configuring image styles
+<info-box hint>
+Try to understand what use cases the system needs to support and define semantic options accordingly. Defining useful and clear styles is one of the steps towards a good user experience and clear, portable output. For example, the "side image" style can be displayed as a floated image on wide screens and as a normal image on low resolution screens (e.g. mobile browsers).
+</info-box>
+
+<info-box warning>
+	While semantical styles can be combined with manual [image resizing](#resizing-images), these features were not designed to be used together.
 
 
-The available image styles can be configured using the {@link module:image/image~ImageConfig#styles `image.styles`} option.
+	If you want to enable image resizing, use [presentational image styles](#presentational-styles).
+</info-box>
+
+### Presentational styles
 
 
-The following WYSIWYG editor supports the default full image style plus left- and right-aligned images:
+Presentational styles do not add any special meaning to the content. They directly control the visual aspect of an image.
+
+Currently, the available presentational styles are "align center", "align left" and "align right".
 
 
 ```js
 ```js
 ClassicEditor
 ClassicEditor
 	.create( document.querySelector( '#editor' ), {
 	.create( document.querySelector( '#editor' ), {
 		image: {
 		image: {
-			// You need to configure the image toolbar, too, so it uses the new style buttons.
-			toolbar: [ 'imageTextAlternative', '|', 'imageStyle:alignLeft', 'imageStyle:full', 'imageStyle:alignRight' ],
-
+			// Configure the available styles.
 			styles: [
 			styles: [
-				// This option is equal to a situation where no style is applied.
-				'full',
-
-				// This represents an image aligned to the left.
-				'alignLeft',
+				'alignLeft', 'alignCenter', 'alignRight'
+			],
 
 
-				// This represents an image aligned to the right.
-				'alignRight'
+			// You need to configure the image toolbar, too, so it shows the new style buttons.
+			toolbar: [
+				'imageStyle:alignLeft', 'imageStyle:alignCenter', 'imageStyle:alignRight',
+				'|',
+				'imageTextAlternative'
 			]
 			]
 		}
 		}
 	} )
 	} )
@@ -141,15 +190,11 @@ ClassicEditor
 	.catch( ... );
 	.catch( ... );
 ```
 ```
 
 
-The code sample above uses predefined image styles: `'full'`, `'alignLeft'` and `'alignRight'`. The latter two apply, respectively, the `.image-style-align-left` and  `.image-style-align-right` classes to the `<figure>` element.
+The code sample above uses predefined presentational image styles: `'alignLeft'`, `'alignCenter'` and `'alignRight'`. They apply, respectively, the `.image-style-align-left`, `.image-style-align-center` and  `.image-style-align-right` classes to the `<figure>` element.
 
 
 See the result below:
 See the result below:
 
 
-{@snippet features/image-style-custom}
-
-<info-box hint>
-	In the example above the options represent simple "align left" and "align right" styles. Most text editors support left, center and right alignments, however, it is better not to think about CKEditor 5's image styles in this way. Try to understand what use cases the system needs to support and define semantic options accordingly. Defining useful and clear styles is one of the steps towards a good user experience and clear, portable output. For example, the "side image" style can be displayed as a floated image on wide screens and as a normal image on low resolution screens.
-</info-box>
+{@snippet features/image-style-presentational}
 
 
 ### Defining custom styles
 ### Defining custom styles
 
 
@@ -167,18 +212,10 @@ you can also define your own styles or modify the existing ones.
 	Reusing (or modifying) predefined styles has the following advantage: CKEditor 5 will use its official translations for the defined button titles.
 	Reusing (or modifying) predefined styles has the following advantage: CKEditor 5 will use its official translations for the defined button titles.
 </info-box>
 </info-box>
 
 
-You can find advanced examples in the {@link module:image/image~ImageConfig#styles `image.styles`} configuration option documentation.
+You can find advanced examples in the {@link module:image/image~ImageConfig#styles `config.image.styles`} configuration option documentation.
 
 
 <!-- TODO (live example)... -->
 <!-- TODO (live example)... -->
 
 
-## Image upload
-
-See the {@link features/image-upload Image upload} guide.
-
-## Responsive images
-
-Support for responsive images in CKEditor 5 is brought by the {@link features/easy-image Easy Image} feature without any additional configuration. Learn more how to use the feature in your project in the {@link features/easy-image#responsive-images Easy Image integration} guide.
-
 ## Resizing images
 ## Resizing images
 
 
 The [image styles](#image-styles) feature is meant to give the user the choice between a set of styling options provided by the system (so by the developer or administrator who created it). There are also scenarios where the user should be able to freely set the width of an image. And that is where the image resize feature comes to play.
 The [image styles](#image-styles) feature is meant to give the user the choice between a set of styling options provided by the system (so by the developer or administrator who created it). There are also scenarios where the user should be able to freely set the width of an image. And that is where the image resize feature comes to play.
@@ -187,11 +224,21 @@ It is implemented by the {@link module:image/imageresize~ImageResize} plugin and
 
 
 The plugin also gives you an ability to change the size of the image through the image toolbar. You can set an optional static configuration with {@link module:image/image~ImageConfig#resizeOptions} and choose whether you want to use a dropdown or set of the standalone buttons.
 The plugin also gives you an ability to change the size of the image through the image toolbar. You can set an optional static configuration with {@link module:image/image~ImageConfig#resizeOptions} and choose whether you want to use a dropdown or set of the standalone buttons.
 
 
-### Resize image using handles
+### Methods to resize images
+
+The editor offers three ways to resize images. One of them (resize handles) is
+
+#### Using handles
+
+In this case, the user is able to resize images via dragging square handles displayed in each corner of the image. Once [image resizing was enabled](#enabling-image-resizing), this option does not require any additional configuration.
 
 
 {@snippet features/image-resize}
 {@snippet features/image-resize}
 
 
-### Resize image using the plugin dropdown
+#### Using the dropdown
+
+In this case, the user is able to choose from a set of predefined options. These options can be displayed in the image toolbar in form of a dropdown.
+
+To use this option, you need to [enable image resizing](#enabling-image-resizing) and configure the available {@link module:image/image~ImageConfig#resizeOptions resize options}.
 
 
 ```js
 ```js
 const imageConfiguration = {
 const imageConfiguration = {
@@ -212,13 +259,17 @@ const imageConfiguration = {
 			value: '75'
 			value: '75'
 		}
 		}
 	],
 	],
-	toolbar: [ ... , 'imageResize' ]
+	toolbar: [ ..., 'imageResize' ]
 }
 }
 ```
 ```
 
 
 {@snippet features/image-resizeuidropdown}
 {@snippet features/image-resizeuidropdown}
 
 
-### Resize image using the standalone buttons
+#### Using standalone buttons
+
+In this case, the resize options are displayed in form of separate buttons. The benefit of this solution is the smoothest UX as the user needs just one click to resize an image.
+
+To use this option, you need to [enabling image resizing](#enabling-image-resizing) and configure the available {@link module:image/image~ImageConfig#resizeOptions resize options}.
 
 
 ```js
 ```js
 const imageConfiguration = {
 const imageConfiguration = {
@@ -240,7 +291,7 @@ const imageConfiguration = {
 		}
 		}
 	],
 	],
 	toolbar: [
 	toolbar: [
-		// ...,
+		...,
 		'imageResize:original',
 		'imageResize:original',
 		'imageResize:50',
 		'imageResize:50',
 		'imageResize:75'
 		'imageResize:75'

+ 3 - 3
packages/ckeditor5-image/tests/manual/imageresize.js

@@ -25,11 +25,11 @@ const commonConfig = {
 	toolbar: [ 'heading', '|', 'bold', 'italic', 'link',
 	toolbar: [ 'heading', '|', 'bold', 'italic', 'link',
 		'bulletedList', 'numberedList', 'blockQuote', 'insertTable', 'undo', 'redo', 'outdent', 'indent' ],
 		'bulletedList', 'numberedList', 'blockQuote', 'insertTable', 'undo', 'redo', 'outdent', 'indent' ],
 	image: {
 	image: {
-		toolbar: [ 'imageStyle:alignLeft', 'imageStyle:full', 'imageStyle:side' ],
+		toolbar: [ 'imageStyle:alignLeft', 'imageStyle:alignCenter', 'imageStyle:alignRight', '|', 'imageResize' ],
 		styles: [
 		styles: [
-			'full',
 			'alignLeft',
 			'alignLeft',
-			'side' // Purposely using side image instead right aligned image to make sure it works well with both style types.
+			'alignCenter',
+			'alignRight'
 		]
 		]
 	},
 	},
 	table: {
 	table: {

+ 3 - 3
packages/ckeditor5-image/tests/manual/imageresizepx.js

@@ -26,11 +26,11 @@ const commonConfig = {
 		'bulletedList', 'numberedList', 'blockQuote', 'insertTable', 'undo', 'redo', 'outdent', 'indent' ],
 		'bulletedList', 'numberedList', 'blockQuote', 'insertTable', 'undo', 'redo', 'outdent', 'indent' ],
 	image: {
 	image: {
 		resizeUnit: 'px',
 		resizeUnit: 'px',
-		toolbar: [ 'imageStyle:alignLeft', 'imageStyle:full', 'imageStyle:side' ],
+		toolbar: [ 'imageStyle:alignLeft', 'imageStyle:alignCenter', 'imageStyle:alignRight', '|', 'imageResize' ],
 		styles: [
 		styles: [
-			'full',
 			'alignLeft',
 			'alignLeft',
-			'side' // Purposely using side image instead right aligned image to make sure it works well with both style types.
+			'alignCenter',
+			'alignRight'
 		]
 		]
 	},
 	},
 	cloudServices: CS_CONFIG
 	cloudServices: CS_CONFIG

+ 3 - 3
packages/ckeditor5-image/tests/manual/imageresizeui.js

@@ -47,11 +47,11 @@ const imageConfig1 = {
 			value: '75'
 			value: '75'
 		}
 		}
 	],
 	],
-	toolbar: [ 'imageStyle:alignLeft', 'imageStyle:full', 'imageStyle:side', '|', 'imageResize' ],
+	toolbar: [ 'imageStyle:alignLeft', 'imageStyle:alignCenter', 'imageStyle:alignRight', '|', 'imageResize' ],
 	styles: [
 	styles: [
-		'full',
 		'alignLeft',
 		'alignLeft',
-		'side' // Purposely using side image instead right aligned image to make sure it works well with both style types.
+		'alignCenter',
+		'alignRight'
 	]
 	]
 };
 };
 
 

+ 1 - 7
packages/ckeditor5-image/theme/imagestyle.css

@@ -8,16 +8,10 @@
 }
 }
 
 
 .ck-content {
 .ck-content {
-	& .image-style-side,
-	& .image-style-align-left,
-	& .image-style-align-center,
-	& .image-style-align-right {
-		max-width: 50%;
-	}
-
 	& .image-style-side {
 	& .image-style-side {
 		float: right;
 		float: right;
 		margin-left: var(--ck-image-style-spacing);
 		margin-left: var(--ck-image-style-spacing);
+		max-width: 50%;
 	}
 	}
 
 
 	& .image-style-align-left {
 	& .image-style-align-left {

+ 4 - 3
packages/ckeditor5-link/theme/linkimage.css

@@ -7,9 +7,10 @@
 	position: absolute;
 	position: absolute;
 	top: var(--ck-spacing-medium);
 	top: var(--ck-spacing-medium);
 	right: var(--ck-spacing-medium);
 	right: var(--ck-spacing-medium);
-	width: 18px;
-	height: 18px;
-	padding: 5px;
+	width: 28px;
+	height: 28px;
+	padding: 4px;
+	box-sizing: border-box;
 	border-radius: var(--ck-border-radius);
 	border-radius: var(--ck-border-radius);
 
 
 	& svg {
 	& svg {

+ 1 - 9
packages/ckeditor5-media-embed/src/ui/mediaformview.js

@@ -210,20 +210,12 @@ export default class MediaFormView extends View {
 	 * **Note**: Do not confuse it with the {@link module:ui/inputtext/inputtextview~InputTextView#value}
 	 * **Note**: Do not confuse it with the {@link module:ui/inputtext/inputtextview~InputTextView#value}
 	 * which works one way only and may not represent the actual state of the component in the DOM.
 	 * which works one way only and may not represent the actual state of the component in the DOM.
 	 *
 	 *
-	 * @type {Number}
+	 * @type {String}
 	 */
 	 */
 	get url() {
 	get url() {
 		return this.urlInputView.fieldView.element.value.trim();
 		return this.urlInputView.fieldView.element.value.trim();
 	}
 	}
 
 
-	/**
-	 * Sets the native DOM `value` of the {@link #urlInputView} element.
-	 *
-	 * **Note**: Do not confuse it with the {@link module:ui/inputtext/inputtextview~InputTextView#value}
-	 * which works one way only and may not represent the actual state of the component in the DOM.
-	 *
-	 * @param {String} url
-	 */
 	set url( url ) {
 	set url( url ) {
 		this.urlInputView.fieldView.element.value = url.trim();
 		this.urlInputView.fieldView.element.value = url.trim();
 	}
 	}

+ 2 - 0
packages/ckeditor5-mention/src/ui/domwrapperview.js

@@ -13,6 +13,8 @@ import View from '@ckeditor/ckeditor5-ui/src/view';
  * This class wraps DOM element as a CKEditor5 UI View.
  * This class wraps DOM element as a CKEditor5 UI View.
  *
  *
  * It allows to render any DOM element and use it in mentions list.
  * It allows to render any DOM element and use it in mentions list.
+ *
+ * @extends {module:ui/view~View}
  */
  */
 export default class DomWrapperView extends View {
 export default class DomWrapperView extends View {
 	/**
 	/**

+ 1 - 1
packages/ckeditor5-typing/src/deletecommand.js

@@ -43,7 +43,7 @@ export default class DeleteCommand extends Command {
 		 *
 		 *
 		 * @readonly
 		 * @readonly
 		 * @private
 		 * @private
-		 * @member {typing.ChangeBuffer} #buffer
+		 * @type {module:typing/utils/changebuffer~ChangeBuffer}
 		 */
 		 */
 		this._buffer = new ChangeBuffer( editor.model, editor.config.get( 'typing.undoStep' ) );
 		this._buffer = new ChangeBuffer( editor.model, editor.config.get( 'typing.undoStep' ) );
 	}
 	}

+ 3 - 3
packages/ckeditor5-ui/src/editableui/editableuiview.js

@@ -59,7 +59,7 @@ export default class EditableUIView extends View {
 		 * The element which is the main editable element (usually the one with `contentEditable="true"`).
 		 * The element which is the main editable element (usually the one with `contentEditable="true"`).
 		 *
 		 *
 		 * @private
 		 * @private
-		 * @member {HTMLElement} #_editableElement
+		 * @type {HTMLElement}
 		 */
 		 */
 		this._editableElement = editableElement;
 		this._editableElement = editableElement;
 
 
@@ -68,7 +68,7 @@ export default class EditableUIView extends View {
 		 * the view will not render its {@link #template}.
 		 * the view will not render its {@link #template}.
 		 *
 		 *
 		 * @private
 		 * @private
-		 * @member {Boolean} #_hasExternalElement
+		 * @type {Boolean}
 		 */
 		 */
 		this._hasExternalElement = !!this._editableElement;
 		this._hasExternalElement = !!this._editableElement;
 
 
@@ -81,7 +81,7 @@ export default class EditableUIView extends View {
 		 * the editable DOM element must remain under the full control of the engine to work properly.
 		 * the editable DOM element must remain under the full control of the engine to work properly.
 		 *
 		 *
 		 * @protected
 		 * @protected
-		 * @member {module:engine/view/view~View} #isFocused
+		 * @type {module:engine/view/view~View}
 		 */
 		 */
 		this._editingView = editingView;
 		this._editingView = editingView;
 	}
 	}

+ 1 - 1
packages/ckeditor5-utils/src/keyboard.js

@@ -77,7 +77,7 @@ export function getCode( key ) {
 
 
 /**
 /**
  * Parses keystroke and returns a keystroke code that will match the code returned by
  * Parses keystroke and returns a keystroke code that will match the code returned by
- * link {@link module:utils/keyboard.getCode} for a corresponding {@link module:utils/keyboard~KeystrokeInfo keystroke info}.
+ * link {@link module:utils/keyboard~getCode} for a corresponding {@link module:utils/keyboard~KeystrokeInfo keystroke info}.
  *
  *
  * The keystroke can be passed in two formats:
  * The keystroke can be passed in two formats:
  *
  *

+ 1 - 1
packages/ckeditor5-watchdog/src/watchdog.js

@@ -49,7 +49,7 @@ export default class Watchdog {
 		 * * `destroyed` &ndash; A state when the item is manually destroyed by the user after calling `watchdog.destroy()`.
 		 * * `destroyed` &ndash; A state when the item is manually destroyed by the user after calling `watchdog.destroy()`.
 		 *
 		 *
 		 * @public
 		 * @public
-		 * @member {'initializing'|'ready'|'crashed'|'crashedPermanently'|'destroyed'} #state
+		 * @type {'initializing'|'ready'|'crashed'|'crashedPermanently'|'destroyed'}
 		 */
 		 */
 		this.state = 'initializing';
 		this.state = 'initializing';
 
 

+ 0 - 2
packages/ckeditor5-widget/src/utils.js

@@ -44,7 +44,6 @@ export function isWidget( node ) {
 	return !!node.getCustomProperty( 'widget' );
 	return !!node.getCustomProperty( 'widget' );
 }
 }
 
 
-/* eslint-disable max-len */
 /**
 /**
  * Converts the given {@link module:engine/view/element~Element} to a widget in the following way:
  * Converts the given {@link module:engine/view/element~Element} to a widget in the following way:
  *
  *
@@ -91,7 +90,6 @@ export function isWidget( node ) {
  * @param {Boolean} [options.hasSelectionHandle=false] If `true`, the widget will have a selection handle added.
  * @param {Boolean} [options.hasSelectionHandle=false] If `true`, the widget will have a selection handle added.
  * @returns {module:engine/view/element~Element} Returns the same element.
  * @returns {module:engine/view/element~Element} Returns the same element.
  */
  */
-/* eslint-enable max-len */
 export function toWidget( element, writer, options = {} ) {
 export function toWidget( element, writer, options = {} ) {
 	writer.setAttribute( 'contenteditable', 'false', element );
 	writer.setAttribute( 'contenteditable', 'false', element );
 
 

+ 1 - 1
packages/ckeditor5-word-count/src/wordcount.js

@@ -20,7 +20,7 @@ import env from '@ckeditor/ckeditor5-utils/src/env';
  * This plugin calculates all words and characters in all {@link module:engine/model/text~Text text nodes} available in the model.
  * This plugin calculates all words and characters in all {@link module:engine/model/text~Text text nodes} available in the model.
  * It also provides an HTML element that updates its state whenever the editor content is changed.
  * It also provides an HTML element that updates its state whenever the editor content is changed.
  *
  *
- * The model's data is first converted to plain text using {@link module:word-count/utils.modelElementToPlainText}.
+ * The model's data is first converted to plain text using {@link module:word-count/utils~modelElementToPlainText}.
  * The number of words and characters in your text are determined based on the created plain text. Please keep in mind
  * The number of words and characters in your text are determined based on the created plain text. Please keep in mind
  * that every block in the editor is separated with a newline character, which is included in the calculation.
  * that every block in the editor is separated with a newline character, which is included in the calculation.
  *
  *

+ 3 - 3
yarn.lock

@@ -12519,9 +12519,9 @@ uglify-to-browserify@~1.0.0:
   integrity sha1-bgkk1r2mta/jSeOabWMoUKD4grc=
   integrity sha1-bgkk1r2mta/jSeOabWMoUKD4grc=
 
 
 umberto@^1.4.1:
 umberto@^1.4.1:
-  version "1.5.0"
-  resolved "https://registry.yarnpkg.com/umberto/-/umberto-1.5.0.tgz#abdc002f0ec6e1c604a122d64e9ed9bd56692e56"
-  integrity sha512-PfT3NB3culnvEcqYGsX8nxvRzXbEgKr+poU0Xd8q+R2dXKP93MRYoKfPxOdybnIEOxv9WJddtBj32njHIt9HHA==
+  version "1.5.1"
+  resolved "https://registry.yarnpkg.com/umberto/-/umberto-1.5.1.tgz#3b9c60d11191cd76d25299245f5648036e5adebd"
+  integrity sha512-9cn19O4q8+mwYQabFnQlVLf4jzhB/YdCJa9lvXGSspBjMye/7nTnxt7FbdUQCaFR7aGim587xkf9JLuN7p5jmA==
   dependencies:
   dependencies:
     "@babel/core" "^7.10.3"
     "@babel/core" "^7.10.3"
     "@babel/polyfill" "^7.10.1"
     "@babel/polyfill" "^7.10.1"