ソースを参照

Merge branch 'master' into release

Kamil Piechaczek 5 年 前
コミット
ce8db45e66
100 ファイル変更1889 行追加367 行削除
  1. 1 1
      CHANGELOG.md
  2. 0 6
      docs/_snippets/examples/document-editor.html
  3. 0 4
      docs/_snippets/framework/tutorials/using-react-in-widget.html
  4. 2 6
      docs/_snippets/framework/tutorials/using-react-in-widget.js
  5. 6 0
      docs/assets/snippet-styles.css
  6. 10 2
      docs/features/keyboard-support.md
  7. 6 3
      docs/framework/guides/architecture/editing-engine.md
  8. 3 3
      docs/framework/guides/architecture/ui-library.md
  9. 3 3
      docs/framework/guides/contributing/git-commit-message-convention.md
  10. 1 1
      docs/framework/guides/deep-dive/localization.md
  11. 1 1
      docs/framework/guides/development-tools.md
  12. 4 12
      docs/framework/guides/tutorials/using-react-in-a-widget.md
  13. 6 5
      package.json
  14. 2 1
      packages/ckeditor5-autoformat/package.json
  15. 5 4
      packages/ckeditor5-autoformat/src/autoformat.js
  16. 27 4
      packages/ckeditor5-autoformat/src/blockautoformatediting.js
  17. 1 1
      packages/ckeditor5-autoformat/src/inlineautoformatediting.js
  18. 71 8
      packages/ckeditor5-autoformat/tests/autoformat.js
  19. 30 4
      packages/ckeditor5-autoformat/tests/blockautoformatediting.js
  20. 5 7
      packages/ckeditor5-autoformat/tests/manual/autoformat.md
  21. 1 1
      packages/ckeditor5-autosave/src/autosave.js
  22. BIN
      packages/ckeditor5-basic-styles/docs/assets/img/typing-after-code.gif
  23. BIN
      packages/ckeditor5-basic-styles/docs/assets/img/typing-inside-code.gif
  24. 12 0
      packages/ckeditor5-basic-styles/docs/features/basic-styles.md
  25. 1 0
      packages/ckeditor5-basic-styles/package.json
  26. 16 0
      packages/ckeditor5-basic-styles/src/code/codeediting.js
  27. 35 0
      packages/ckeditor5-basic-styles/tests/code/codeediting.js
  28. 4 0
      packages/ckeditor5-basic-styles/theme/code.css
  29. 7 4
      packages/ckeditor5-block-quote/src/blockquoteediting.js
  30. 2 2
      packages/ckeditor5-clipboard/src/utils/viewtoplaintext.js
  31. 3 3
      packages/ckeditor5-code-block/src/codeblockcommand.js
  32. 8 8
      packages/ckeditor5-code-block/src/codeblockediting.js
  33. 1 1
      packages/ckeditor5-code-block/src/converters.js
  34. 2 2
      packages/ckeditor5-code-block/src/outdentcodeblockcommand.js
  35. 2 2
      packages/ckeditor5-code-block/src/utils.js
  36. 3 0
      packages/ckeditor5-core/src/command.js
  37. 2 1
      packages/ckeditor5-core/src/commandcollection.js
  38. 4 1
      packages/ckeditor5-core/src/editor/editor.js
  39. 3 1
      packages/ckeditor5-core/src/multicommand.js
  40. 1 1
      packages/ckeditor5-core/tests/_utils-tests/assertions/attribute.js
  41. 2 2
      packages/ckeditor5-core/tests/_utils/assertions/attribute.js
  42. 14 0
      packages/ckeditor5-core/tests/commandcollection.js
  43. 19 0
      packages/ckeditor5-core/tests/editor/editor.js
  44. 15 0
      packages/ckeditor5-core/tests/multicommand.js
  45. 1 0
      packages/ckeditor5-core/theme/icons/object-size-full.svg
  46. 1 0
      packages/ckeditor5-core/theme/icons/object-size-large.svg
  47. 1 0
      packages/ckeditor5-core/theme/icons/object-size-medium.svg
  48. 1 0
      packages/ckeditor5-core/theme/icons/object-size-small.svg
  49. 1 1
      packages/ckeditor5-engine/docs/_snippets/framework/extending-content-custom-figure-attributes.js
  50. 1 1
      packages/ckeditor5-engine/docs/framework/guides/deep-dive/conversion-preserving-custom-content.md
  51. 13 1
      packages/ckeditor5-engine/src/controller/datacontroller.js
  52. 2 1
      packages/ckeditor5-engine/src/controller/editingcontroller.js
  53. 3 3
      packages/ckeditor5-engine/src/conversion/conversion.js
  54. 6 0
      packages/ckeditor5-engine/src/conversion/downcastdispatcher.js
  55. 284 7
      packages/ckeditor5-engine/src/conversion/downcasthelpers.js
  56. 10 11
      packages/ckeditor5-engine/src/conversion/mapper.js
  57. 1 1
      packages/ckeditor5-engine/src/conversion/upcastdispatcher.js
  58. 194 3
      packages/ckeditor5-engine/src/conversion/upcasthelpers.js
  59. 5 5
      packages/ckeditor5-engine/src/conversion/viewconsumable.js
  60. 1 1
      packages/ckeditor5-engine/src/dev-utils/model.js
  61. 27 9
      packages/ckeditor5-engine/src/dev-utils/view.js
  62. 1 1
      packages/ckeditor5-engine/src/model/differ.js
  63. 1 1
      packages/ckeditor5-engine/src/model/documentfragment.js
  64. 24 42
      packages/ckeditor5-engine/src/model/documentselection.js
  65. 26 6
      packages/ckeditor5-engine/src/model/element.js
  66. 10 0
      packages/ckeditor5-engine/src/model/markercollection.js
  67. 1 1
      packages/ckeditor5-engine/src/model/model.js
  68. 2 2
      packages/ckeditor5-engine/src/model/node.js
  69. 2 2
      packages/ckeditor5-engine/src/model/operation/utils.js
  70. 18 5
      packages/ckeditor5-engine/src/model/position.js
  71. 57 0
      packages/ckeditor5-engine/src/model/range.js
  72. 1 5
      packages/ckeditor5-engine/src/model/rootelement.js
  73. 2 2
      packages/ckeditor5-engine/src/model/schema.js
  74. 10 6
      packages/ckeditor5-engine/src/model/text.js
  75. 10 5
      packages/ckeditor5-engine/src/model/textproxy.js
  76. 1 1
      packages/ckeditor5-engine/src/model/treewalker.js
  77. 1 1
      packages/ckeditor5-engine/src/model/utils/getselectedcontent.js
  78. 1 1
      packages/ckeditor5-engine/src/model/utils/modifyselection.js
  79. 3 5
      packages/ckeditor5-engine/src/view/attributeelement.js
  80. 3 5
      packages/ckeditor5-engine/src/view/containerelement.js
  81. 1 1
      packages/ckeditor5-engine/src/view/documentfragment.js
  82. 48 34
      packages/ckeditor5-engine/src/view/domconverter.js
  83. 61 14
      packages/ckeditor5-engine/src/view/downcastwriter.js
  84. 3 5
      packages/ckeditor5-engine/src/view/editableelement.js
  85. 5 7
      packages/ckeditor5-engine/src/view/element.js
  86. 3 5
      packages/ckeditor5-engine/src/view/emptyelement.js
  87. 2 0
      packages/ckeditor5-engine/src/view/filler.js
  88. 2 3
      packages/ckeditor5-engine/src/view/node.js
  89. 5 5
      packages/ckeditor5-engine/src/view/observer/mutationobserver.js
  90. 6 6
      packages/ckeditor5-engine/src/view/position.js
  91. 7 7
      packages/ckeditor5-engine/src/view/range.js
  92. 145 0
      packages/ckeditor5-engine/src/view/rawelement.js
  93. 6 6
      packages/ckeditor5-engine/src/view/renderer.js
  94. 3 5
      packages/ckeditor5-engine/src/view/rooteditableelement.js
  95. 15 13
      packages/ckeditor5-engine/src/view/text.js
  96. 10 5
      packages/ckeditor5-engine/src/view/textproxy.js
  97. 3 5
      packages/ckeditor5-engine/src/view/uielement.js
  98. 12 2
      packages/ckeditor5-engine/tests/controller/datacontroller.js
  99. 3 3
      packages/ckeditor5-engine/tests/conversion/conversion.js
  100. 482 1
      packages/ckeditor5-engine/tests/conversion/downcasthelpers.js

+ 1 - 1
CHANGELOG.md

@@ -19,7 +19,7 @@ New features were also accompanied by a set of bug fixes, to name a few:
 * [Autoformatting will no longer change formatting when typing in an inline code](https://github.com/ckeditor/ckeditor5/issues/1239).
 * Editor will no longer [crash if there's a HTML comment in the source data](https://github.com/ckeditor/ckeditor5/issues/5734).
 
-TODO: Add a link to the blog post.
+Read more in the blog post: https://ckeditor.com/blog/CKEditor-5-v20.0.0-with-linking-images-and-multi-cell-comments-released/
 
 ### Collaboration features
 

+ 0 - 6
docs/_snippets/examples/document-editor.html

@@ -195,12 +195,6 @@
 		margin-right: calc( 2 * var(--ck-spacing-large) );
 	}
 
-	/* Some table cells have a lot content and some not. Align them vertically
-	to make reading easier. */
-	.document-editor .ck-content table td {
-		vertical-align: middle;
-	}
-
 	@media only screen and (max-width: 960px) {
 		/* Because on mobile 2cm paddings are to big. */
 		.document-editor__editable-container .document-editor__editable.ck-editor__editable {

+ 0 - 4
docs/_snippets/framework/tutorials/using-react-in-widget.html

@@ -141,10 +141,6 @@
 	animation: slideUp 0.3s ease;
 }
 
-.app .ck-content .table td {
-	vertical-align: middle;
-}
-
 @keyframes slideUp {
 	0% {
 		opacity: 0;

+ 2 - 6
docs/_snippets/framework/tutorials/using-react-in-widget.js

@@ -114,17 +114,13 @@ class ProductPreviewEditing extends Plugin {
 
 				// The inner <div class="product__react-wrapper"></div> element.
 				// This element will host a React <ProductPreview /> component.
-				const reactWrapper = viewWriter.createUIElement( 'div', {
+				const reactWrapper = viewWriter.createRawElement( 'div', {
 					class: 'product__react-wrapper'
-				}, function( domDocument ) {
-					const domElement = this.toDomElement( domDocument );
-
+				}, function( domElement ) {
 					// This the place where React renders the actual product preview hosted
 					// by a UIElement in the view. you are using a function (renderer) passed as
 					// editor.config.products#productRenderer.
 					renderProduct( id, domElement );
-
-					return domElement;
 				} );
 
 				viewWriter.insert( viewWriter.createPositionAt( section, 0 ), reactWrapper );

+ 6 - 0
docs/assets/snippet-styles.css

@@ -232,6 +232,12 @@ It breaks CKEditor 5 (see how <p><code>[]</code></p> looks). */
 	display: table;
 }
 
+/* https://github.com/ckeditor/ckeditor5/issues/7310 */
+.live-snippet .ck.ck-content .table td,
+.live-snippet .ck.ck-content .table th {
+	vertical-align: middle;
+}
+
 /* https://github.com/ckeditor/ckeditor5/issues/1282 */
 .live-snippet .ck.ck-content .table p:first-child {
 	padding-top: 0;

+ 10 - 2
docs/features/keyboard-support.md

@@ -67,13 +67,21 @@ Below is a list of the most important keystrokes supported by CKEditor 5 and its
 			<th colspan="3">When a widget is selected (for example: image, table, horizontal line, etc.)</th>
 		</tr>
 		<tr>
-            <td>Insert a new paragraph directly after a widget</td>
+			<td>Insert a new paragraph directly after a widget</td>
 			<td colspan="2"><kbd>Enter</kbd></td>
-		</tr> 
+		</tr>
 		<tr>
 			<td>Insert a new paragraph directly before a widget</td>
 			<td colspan="2"><kbd>Shift</kbd> + <kbd>Enter</kbd></td>
 		</tr>
+		<tr>
+			<td>Display the caret to allow typing directly before a widget</td>
+			<td colspan="2"><kbd>↑</kbd> / <kbd>←</kbd></td>
+		</tr>
+		<tr>
+			<td>Display the caret to allow typing directly after a widget</td>
+			<td colspan="2"><kbd>↓</kbd> / <kbd>→</kbd></td>
+		</tr>
 		<tr>
 			<th colspan="3">In a table cell</th>
 		</tr>

+ 6 - 3
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 be synchronized over the network with other collaborating clients.
 * 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 &mdash; such as comments, selections of other users, etc.
+Markers are ideal for storing and maintaining additional data related to portions of the document &mdash; such as comments or selections of other users.
 
 ### Schema
 
@@ -224,7 +226,7 @@ editor.data;                    // The data pipeline (DataController).
 
 ### Element types and custom data
 
-The structure of the view resembles the structure in the DOM very closely. The semantics of HTML is defined in its specification. The view structure comes "DTD-free", so in order to provide additional information and better express the semantics of the content, the view structure implements 5 element types ({@link module:engine/view/containerelement~ContainerElement}, {@link module:engine/view/attributeelement~AttributeElement}, {@link module:engine/view/emptyelement~EmptyElement}, {@link module:engine/view/uielement~UIElement}, and {@link module:engine/view/editableelement~EditableElement}) and so called {@link module:engine/view/element~Element#getCustomProperty "custom properties"} (i.e. custom element properties which are not rendered). This additional information provided by editor features is then used by the {@link module:engine/view/renderer~Renderer} and [converters](#conversion).
+The structure of the view resembles the structure in the DOM very closely. The semantics of HTML is defined in its specification. The view structure comes "DTD-free", so in order to provide additional information and better express the semantics of the content, the view structure implements 6 element types ({@link module:engine/view/containerelement~ContainerElement}, {@link module:engine/view/attributeelement~AttributeElement}, {@link module:engine/view/emptyelement~EmptyElement}, {@link module:engine/view/rawelement~RawElement}, {@link module:engine/view/uielement~UIElement}, and {@link module:engine/view/editableelement~EditableElement}) and so called {@link module:engine/view/element~Element#getCustomProperty "custom properties"} (i.e. custom element properties which are not rendered). This additional information provided by editor features is then used by the {@link module:engine/view/renderer~Renderer} and [converters](#conversion).
 
 The element types can be defined as follows:
 
@@ -232,6 +234,7 @@ The element types can be defined as follows:
 * **Attribute element** &ndash; The elements that cannot contain container elements inside them. Most model text attributes are converted to view attribute elements. They are used mostly for inline styling elements such as `<strong>`, `<i>`, `<a>`, `<code>`. Similar attribute elements are flattened by the view writer, so e.g. `<a href="..."><a class="bar">x</a></a>` would automatically be optimized to `<a href="..." class="bar">x</a>`.
 * **Empty element** &ndash; The elements that must not have any child nodes, for example `<img>`.
 * **UI elements** &ndash; The elements that are not a part of the "data" but need to be "inlined" in the content. They are ignored by the selection (it jumps over them) and the view writer in general. The contents of these elements and events coming from them are filtered out, too.
+* **Raw element** &ndash; The elements that work as data containers ("wrappers", "sandboxes") but their children are transparent to the editor. Useful when non-standard data must be rendered but the editor should not be concerned what it is and how it works. Users cannot put the selection inside a raw element, split it into smaller chunks or directly modify its content.
 * **Editable element** &ndash; The elements used as "nested editables" of non-editable fragments of the content, for example a caption in the image widget, where the `<figure>` wrapping the image is not editable (it is a widget) and the `<figcaption>` inside it is an editable element.
 
 Additionally, you can define {@link module:engine/view/element~Element#getCustomProperty custom properties} which can be used to store information like:

+ 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 {@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
 import { createDropdown } from '@ckeditor/ckeditor5-ui/src/dropdown/utils';
@@ -376,7 +376,7 @@ dropdownView.buttonView.set( {
 
 #### 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
 import Model from '@ckeditor/ckeditor5-ui/src/model';
@@ -411,7 +411,7 @@ addListToDropdown( dropdownView, items );
 
 #### 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
 import ButtonView from '@ckeditor/ckeditor5-ui/src/button/buttonview';

+ 3 - 3
docs/framework/guides/contributing/git-commit-message-convention.md

@@ -24,8 +24,8 @@ Type (another-package-name): If the change affects more than one package, it's p
 
 Optional description.
 
-BREAKING CHANGE (scope): If any breaking changes were done, they need to be listed here.
-BREAKING CHANGE (scope): Another breaking change if needed. Closes #ZZZ.
+BREAKING CHANGE (package-name): If any breaking changes were done, they need to be listed here.
+BREAKING CHANGE (package-name): Another breaking change if needed. Closes #ZZZ.
 ```
 
 ### Commit types
@@ -50,7 +50,7 @@ If any change contains the `MAJOR BREAKING CHANGE` note, the next release will b
 
 For reference on how to identify minor or major breaking change see the {@link framework/guides/support/versioning-policy versioning policy guide}.
 
-Each `BREAKING CHANGE` note must be followed by the scope of changes.
+Each `BREAKING CHANGE` note must be followed by the package name.
 
 ### Package name
 

+ 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
 
-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.
 

+ 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.
 </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
 import { getData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';

+ 4 - 12
docs/framework/guides/tutorials/using-react-in-a-widget.md

@@ -365,17 +365,13 @@ export default class ProductPreviewEditing extends Plugin {
 
 				// The inner <div class="product__react-wrapper"></div> element.
 				// This element will host a React <ProductPreview /> component.
-				const reactWrapper = viewWriter.createUIElement( 'div', {
+				const reactWrapper = viewWriter.createRawElement( 'div', {
 					class: 'product__react-wrapper'
-				}, function( domDocument ) {
-					const domElement = this.toDomElement( domDocument );
-
+				}, function( domElement ) {
 					// This the place where React renders the actual product preview hosted
 					// by a UIElement in the view. You are using a function (renderer) passed as
 					// editor.config.products#productRenderer.
 					renderProduct( id, domElement );
-
-					return domElement;
 				} );
 
 				viewWriter.insert( viewWriter.createPositionAt( section, 0 ), reactWrapper );
@@ -1184,17 +1180,13 @@ export default class ProductPreviewEditing extends Plugin {
 
 				// The inner <div class="product__react-wrapper"></div> element.
 				// This element will host a React <ProductPreview /> component.
-				const reactWrapper = viewWriter.createUIElement( 'div', {
+				const reactWrapper = viewWriter.createRawElement( 'div', {
 					class: 'product__react-wrapper'
-				}, function( domDocument ) {
-					const domElement = this.toDomElement( domDocument );
-
+				}, function( domElement ) {
 					// This the place where React renders the actual product preview hosted
 					// by a UIElement in the view. You are using a function (renderer) passed as
 					// editor.config.products#productRenderer.
 					renderProduct( id, domElement );
-
-					return domElement;
 				} );
 
 				viewWriter.insert( viewWriter.createPositionAt( section, 0 ), reactWrapper );

+ 6 - 5
package.json

@@ -77,11 +77,11 @@
   },
   "devDependencies": {
     "@ckeditor/ckeditor5-comments": "^20.0.0",
-    "@ckeditor/ckeditor5-dev-docs": "^21.0.0",
-    "@ckeditor/ckeditor5-dev-env": "^21.0.0",
-    "@ckeditor/ckeditor5-dev-tests": "^21.0.0",
-    "@ckeditor/ckeditor5-dev-utils": "^21.0.0",
-    "@ckeditor/ckeditor5-dev-webpack-plugin": "^21.0.0",
+    "@ckeditor/ckeditor5-dev-docs": "^23.0.0",
+    "@ckeditor/ckeditor5-dev-env": "^23.0.0",
+    "@ckeditor/ckeditor5-dev-tests": "^23.0.0",
+    "@ckeditor/ckeditor5-dev-utils": "^23.0.0",
+    "@ckeditor/ckeditor5-dev-webpack-plugin": "^23.0.0",
     "@ckeditor/ckeditor5-inspector": "^2.1.0",
     "@ckeditor/ckeditor5-react": "^2.1.0",
     "@ckeditor/ckeditor5-real-time-collaboration": "^20.0.0",
@@ -98,6 +98,7 @@
     "lint-staged": "^10.2.6",
     "mini-css-extract-plugin": "^0.9.0",
     "minimatch": "^3.0.4",
+    "mkdirp": "^1.0.4",
     "nyc": "^15.0.1",
     "postcss-loader": "^3.0.0",
     "progress-bar-webpack-plugin": "^2.1.0",

+ 2 - 1
packages/ckeditor5-autoformat/package.json

@@ -23,7 +23,8 @@
     "@ckeditor/ckeditor5-heading": "^20.0.0",
     "@ckeditor/ckeditor5-list": "^20.0.0",
     "@ckeditor/ckeditor5-paragraph": "^20.0.0",
-    "@ckeditor/ckeditor5-undo": "^20.0.0"
+    "@ckeditor/ckeditor5-undo": "^20.0.0",
+    "@ckeditor/ckeditor5-utils": "^20.0.0"
   },
   "engines": {
     "node": ">=12.0.0",

+ 5 - 4
packages/ckeditor5-autoformat/src/autoformat.js

@@ -123,16 +123,17 @@ export default class Autoformat extends Plugin {
 		if ( command ) {
 			command.modelElements
 				.filter( name => name.match( /^heading[1-6]$/ ) )
-				.forEach( commandValue => {
-					const level = commandValue[ 7 ];
+				.forEach( modelName => {
+					const level = modelName[ 7 ];
 					const pattern = new RegExp( `^(#{${ level }})\\s$` );
 
 					blockAutoformatEditing( this.editor, this, pattern, () => {
-						if ( !command.isEnabled ) {
+						// Should only be active if command is enabled and heading style associated with pattern is inactive.
+						if ( !command.isEnabled || command.value === modelName ) {
 							return false;
 						}
 
-						this.editor.execute( 'heading', { value: commandValue } );
+						this.editor.execute( 'heading', { value: modelName } );
 					} );
 				} );
 		}

+ 27 - 4
packages/ckeditor5-autoformat/src/blockautoformatediting.js

@@ -2,7 +2,9 @@
  * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
  * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
  */
+
 import LiveRange from '@ckeditor/ckeditor5-engine/src/model/liverange';
+import first from '@ckeditor/ckeditor5-utils/src/first';
 
 /**
  * The block autoformatting engine. It allows to format various block patterns. For example,
@@ -43,7 +45,8 @@ import LiveRange from '@ckeditor/ckeditor5-engine/src/model/liverange';
  *
  * @param {module:core/editor/editor~Editor} editor The editor instance.
  * @param {module:autoformat/autoformat~Autoformat} plugin The autoformat plugin instance.
- * @param {RegExp} pattern The regular expression to execute on just inserted text.
+ * @param {RegExp} pattern The regular expression to execute on just inserted text. The regular expression is tested against the text
+ * from the beginning until the caret position.
  * @param {Function|String} callbackOrCommand The callback to execute or the command to run when the text is matched.
  * In case of providing the callback, it receives the following parameter:
  * * {Object} match RegExp.exec() result of matching the pattern to inserted text.
@@ -68,6 +71,12 @@ export default function blockAutoformatEditing( editor, plugin, pattern, callbac
 			return;
 		}
 
+		const range = first( editor.model.document.selection.getRanges() );
+
+		if ( !range.isCollapsed ) {
+			return;
+		}
+
 		if ( batch.type == 'transparent' ) {
 			return;
 		}
@@ -82,12 +91,26 @@ export default function blockAutoformatEditing( editor, plugin, pattern, callbac
 
 		const blockToFormat = entry.position.parent;
 
-		// Block formatting should trigger only if the entire content of a paragraph is a single text node... (see ckeditor5#5671).
-		if ( !blockToFormat.is( 'paragraph' ) || blockToFormat.childCount !== 1 ) {
+		// Block formatting should be disabled in codeBlocks (#5800).
+		if ( blockToFormat.is( 'element', 'codeBlock' ) ) {
+			return;
+		}
+
+		// In case a command is bound, do not re-execute it over an existing block style which would result with a style removal.
+		// Instead just drop processing so that autoformat trigger text is not lost. E.g. writing "# " in a level 1 heading.
+		if ( command && command.value === true ) {
+			return;
+		}
+
+		const firstNode = blockToFormat.getChild( 0 );
+		const firstNodeRange = editor.model.createRangeOn( firstNode );
+
+		// Range is only expected to be within or at the very end of the first text node.
+		if ( !firstNodeRange.containsRange( range ) && !range.end.isEqual( firstNodeRange.end ) ) {
 			return;
 		}
 
-		const match = pattern.exec( blockToFormat.getChild( 0 ).data );
+		const match = pattern.exec( firstNode.data.substr( 0, range.end.offset ) );
 
 		// ...and this text node's data match the pattern.
 		if ( !match ) {

+ 1 - 1
packages/ckeditor5-autoformat/src/inlineautoformatediting.js

@@ -201,7 +201,7 @@ function getTextAfterCode( range, model ) {
 
 	const text = Array.from( range.getItems() ).reduce( ( rangeText, node ) => {
 		// Trim text to a last occurrence of an inline element and update range start.
-		if ( !( node.is( 'text' ) || node.is( 'textProxy' ) ) || node.getAttribute( 'code' ) ) {
+		if ( !( node.is( '$text' ) || node.is( '$textProxy' ) ) || node.getAttribute( 'code' ) ) {
 			start = model.createPositionAfter( node );
 
 			return '';

+ 71 - 8
packages/ckeditor5-autoformat/tests/autoformat.js

@@ -77,6 +77,15 @@ describe( 'Autoformat', () => {
 			expect( getData( model ) ).to.equal( '<listItem listIndent="0" listType="bulleted">[]</listItem>' );
 		} );
 
+		it( 'should replace a non-empty paragraph using the asterisk', () => {
+			setData( model, '<paragraph>*[]sample text</paragraph>' );
+			model.change( writer => {
+				writer.insertText( ' ', doc.selection.getFirstPosition() );
+			} );
+
+			expect( getData( model ) ).to.equal( '<listItem listIndent="0" listType="bulleted">[]sample text</listItem>' );
+		} );
+
 		it( 'should not replace minus character when inside bulleted list item', () => {
 			setData( model, '<listItem listIndent="0" listType="bulleted">-[]</listItem>' );
 			model.change( writer => {
@@ -115,6 +124,15 @@ describe( 'Autoformat', () => {
 			expect( getData( model ) ).to.equal( '<listItem listIndent="0" listType="numbered">[]</listItem>' );
 		} );
 
+		it( 'should replace a non-empty paragraph using the parenthesis format', () => {
+			setData( model, '<paragraph>1)[]sample text</paragraph>' );
+			model.change( writer => {
+				writer.insertText( ' ', doc.selection.getFirstPosition() );
+			} );
+
+			expect( getData( model ) ).to.equal( '<listItem listIndent="0" listType="numbered">[]sample text</listItem>' );
+		} );
+
 		it( 'should not replace digit character when there is no . or ) in the format', () => {
 			setData( model, '<paragraph>1[]</paragraph>' );
 			model.change( writer => {
@@ -150,6 +168,24 @@ describe( 'Autoformat', () => {
 
 			expect( getData( model ) ).to.equal( '<paragraph>Foo<softBreak></softBreak>1. []</paragraph>' );
 		} );
+
+		it( 'should be converted from a header', () => {
+			setData( model, '<heading1>1.[]</heading1>' );
+			model.change( writer => {
+				writer.insertText( ' ', doc.selection.getFirstPosition() );
+			} );
+
+			expect( getData( model ) ).to.equal( '<listItem listIndent="0" listType="numbered">[]</listItem>' );
+		} );
+
+		it( 'should be converted from a bulleted list', () => {
+			setData( model, '<listItem listIndent="0" listType="bulleted">1.[]</listItem>' );
+			model.change( writer => {
+				writer.insertText( ' ', doc.selection.getFirstPosition() );
+			} );
+
+			expect( getData( model ) ).to.equal( '<listItem listIndent="0" listType="numbered">[]</listItem>' );
+		} );
 	} );
 
 	describe( 'Heading', () => {
@@ -243,6 +279,15 @@ describe( 'Autoformat', () => {
 
 			expect( getData( model ) ).to.equal( '<paragraph>Foo<softBreak></softBreak># []</paragraph>' );
 		} );
+
+		it( 'should convert a header that already contains a text', () => {
+			setData( model, '<heading1>###[]foo</heading1>' );
+			model.change( writer => {
+				writer.insertText( ' ', doc.selection.getFirstPosition() );
+			} );
+
+			expect( getData( model ) ).to.equal( '<heading3>[]foo</heading3>' );
+		} );
 	} );
 
 	describe( 'Block quote', () => {
@@ -255,13 +300,22 @@ describe( 'Autoformat', () => {
 			expect( getData( model ) ).to.equal( '<blockQuote><paragraph>[]</paragraph></blockQuote>' );
 		} );
 
-		it( 'should not replace greater-than character when inside heading', () => {
+		it( 'should replace greater-than character in a non-empty paragraph', () => {
+			setData( model, '<paragraph>>[]foo</paragraph>' );
+			model.change( writer => {
+				writer.insertText( ' ', doc.selection.getFirstPosition() );
+			} );
+
+			expect( getData( model ) ).to.equal( '<blockQuote><paragraph>[]foo</paragraph></blockQuote>' );
+		} );
+
+		it( 'should wrap the heading if greater-than character was used', () => {
 			setData( model, '<heading1>>[]</heading1>' );
 			model.change( writer => {
 				writer.insertText( ' ', doc.selection.getFirstPosition() );
 			} );
 
-			expect( getData( model ) ).to.equal( '<heading1>> []</heading1>' );
+			expect( getData( model ) ).to.equal( '<blockQuote><heading1>[]</heading1></blockQuote>' );
 		} );
 
 		it( 'should not replace greater-than character when inside numbered list', () => {
@@ -302,22 +356,31 @@ describe( 'Autoformat', () => {
 			expect( getData( model ) ).to.equal( '<codeBlock language="plaintext">[]</codeBlock>' );
 		} );
 
-		it( 'should not replace triple grave accents when already in a code block', () => {
-			setData( model, '<codeBlock language="plaintext">``[]</codeBlock>' );
+		it( 'should replace triple grave accents in a heading', () => {
+			setData( model, '<heading1>``[]</heading1>' );
 			model.change( writer => {
 				writer.insertText( '`', doc.selection.getFirstPosition() );
 			} );
 
-			expect( getData( model ) ).to.equal( '<codeBlock language="plaintext">```[]</codeBlock>' );
+			expect( getData( model ) ).to.equal( '<codeBlock language="plaintext">[]</codeBlock>' );
 		} );
 
-		it( 'should not replace triple grave accents when inside heading', () => {
-			setData( model, '<heading1>``[]</heading1>' );
+		it( 'should replace triple grave accents in a non-empty paragraph', () => {
+			setData( model, '<paragraph>``[]let foo = 1;</paragraph>' );
 			model.change( writer => {
 				writer.insertText( '`', doc.selection.getFirstPosition() );
 			} );
 
-			expect( getData( model ) ).to.equal( '<heading1>```[]</heading1>' );
+			expect( getData( model ) ).to.equal( '<codeBlock language="plaintext">[]let foo = 1;</codeBlock>' );
+		} );
+
+		it( 'should not replace triple grave accents when already in a code block', () => {
+			setData( model, '<codeBlock language="plaintext">``[]</codeBlock>' );
+			model.change( writer => {
+				writer.insertText( '`', doc.selection.getFirstPosition() );
+			} );
+
+			expect( getData( model ) ).to.equal( '<codeBlock language="plaintext">```[]</codeBlock>' );
 		} );
 
 		it( 'should not replace triple grave accents when inside numbered list', () => {

+ 30 - 4
packages/ckeditor5-autoformat/tests/blockautoformatediting.js

@@ -117,7 +117,7 @@ describe( 'blockAutoformatEditing', () => {
 
 		it( 'should ignore other delta operations', () => {
 			const spy = testUtils.sinon.spy();
-			blockAutoformatEditing( editor, plugin, /^[*]\s/, spy );
+			blockAutoformatEditing( editor, plugin, /^[*]\s$/, spy );
 
 			setData( model, '<paragraph>*[]</paragraph>' );
 			model.change( writer => {
@@ -127,9 +127,23 @@ describe( 'blockAutoformatEditing', () => {
 			sinon.assert.notCalled( spy );
 		} );
 
+		it( 'should ignore a ranged selection', () => {
+			model.schema.extend( '$text', { allowAttributes: 'foo' } );
+
+			const spy = testUtils.sinon.spy();
+			blockAutoformatEditing( editor, plugin, /^[*]\s$/, spy );
+
+			setData( model, '<paragraph>[* ]foo</paragraph>' );
+			model.change( writer => {
+				writer.setAttribute( 'foo', true, model.document.selection.getFirstRange() );
+			} );
+
+			sinon.assert.notCalled( spy );
+		} );
+
 		it( 'should stop if there is no text to run matching on', () => {
 			const spy = testUtils.sinon.spy();
-			blockAutoformatEditing( editor, plugin, /^[*]\s/, spy );
+			blockAutoformatEditing( editor, plugin, /^[*]\s$/, spy );
 
 			setData( model, '<paragraph>[]</paragraph>' );
 			model.change( writer => {
@@ -157,7 +171,7 @@ describe( 'blockAutoformatEditing', () => {
 				} );
 
 			const spy = testUtils.sinon.spy();
-			blockAutoformatEditing( editor, plugin, /^[*]\s/, spy );
+			blockAutoformatEditing( editor, plugin, /^[*]\s$/, spy );
 
 			setData( model, '<paragraph>*<softBreak></softBreak>[]</paragraph>' );
 			model.change( writer => {
@@ -167,6 +181,18 @@ describe( 'blockAutoformatEditing', () => {
 			sinon.assert.notCalled( spy );
 		} );
 
+		it( 'should not call callback when typing in the middle of block text', () => {
+			const spy = testUtils.sinon.spy();
+			blockAutoformatEditing( editor, plugin, /^[*]\s$/, spy );
+
+			setData( model, '<paragraph>* foo[]bar</paragraph>' );
+			model.change( writer => {
+				writer.insertText( ' ', doc.selection.getFirstPosition() );
+			} );
+
+			sinon.assert.notCalled( spy );
+		} );
+
 		it( 'should not call callback when after inline element (typing after softBreak in a "matching" paragraph)', () => {
 			// Configure the schema.
 			model.schema.register( 'softBreak', {
@@ -185,7 +211,7 @@ describe( 'blockAutoformatEditing', () => {
 				} );
 
 			const spy = testUtils.sinon.spy();
-			blockAutoformatEditing( editor, plugin, /^[*]\s/, spy );
+			blockAutoformatEditing( editor, plugin, /^[*]\s$/, spy );
 
 			setData( model, '<paragraph>* <softBreak></softBreak>[]</paragraph>' );
 

+ 5 - 7
packages/ckeditor5-autoformat/tests/manual/autoformat.md

@@ -1,14 +1,14 @@
 ## Autoformat
 
-1. Type `#` and press the space in an empty paragraph to replace it with a heading.
+Note: autoformat should not work in a code blocks.
 
-1. Type `*` or `-` and the press space in an empty paragraph to replace it with a list item.
+1. Type `#` and press the space at the beginning of a block to replace it with a heading.
 
-1. Type `>` and press the space in an empty paragraph to replace it with a block quote.
+1. Type `*` or `-` and the press space at the beginning of a block to replace it with a list item.
 
-1. Type a number from the range **1-3** followed by a `.` and press space to replace an empty paragraph with a numbered list item.
+1. Type `>` and press the space at the beginning of a block to replace it with a block quote.
 
-1. Type a number from the range **1-3** followed by a `)` and press space to replace an empty paragraph with a numbered list item.
+1. Type `1` followed by a `.` or `)` and press space (at the beginning of a block) to replace it with a numbered list item.
 
 1. Type `*foobar*`/`_foobar_` to italicize `foobar`. `*`/`_` should be removed.
 
@@ -22,6 +22,4 @@
 
 1. For every autoformat pattern: Undo until you'll see just the pattern (e.g. `- `). Typing should be then possible without triggering the autoformatting again.
 
-1. Typing a different pattern in an already converted block **must not** trigger the autoformatting. For example, typing `- ` in a heading should not convert a heading to a list.
-
 1. Type inline formatting (bold, italic, code, strikethrough) after a soft break (<kbd>Shift</kbd>+<kbd>Enter</kbd>).

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

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

BIN
packages/ckeditor5-basic-styles/docs/assets/img/typing-after-code.gif


BIN
packages/ckeditor5-basic-styles/docs/assets/img/typing-inside-code.gif


+ 12 - 0
packages/ckeditor5-basic-styles/docs/features/basic-styles.md

@@ -47,6 +47,18 @@ By default, each feature can upcast more than one type of the content. Here's th
 | {@link module:basic-styles/subscript~Subscript} | `<sub>`, `<* style="vertical-align: sub">` |
 | {@link module:basic-styles/superscript~Superscript} | `<sup>`, `<* style="vertical-align: super">` |
 
+## Typing around inline code
+
+CKEditor 5 allows for typing both at inner and outer boundaries of code to make the editing easier for the users.
+
+**To type inside a code element**, move the caret to its (start or end) boundary. As long as the code remains highlighted (by default: less transparent gray), typing and applying formatting will be done within its boundaries:
+
+{@img assets/img/typing-inside-code.gif 770 The animation showing typing inside the code element in CKEditor 5 rich text editor.}
+
+**To type before or after a code element**, move the caret to its boundary, then press the Arrow key (<kbd>→</kbd> or <kbd>←</kbd>) once. The code is no longer highlighted and whatever text you type or formatting you apply will not be enclosed by the code element:
+
+{@img assets/img/typing-after-code.gif 770 The animation showing typing after the code element in CKEditor 5 rich text editor.}
+
 ## Installation
 
 To add the basic styles features to your editor install the [`@ckeditor/ckeditor5-basic-styles`](https://www.npmjs.com/package/@ckeditor/ckeditor5-basic-styles) package:

+ 1 - 0
packages/ckeditor5-basic-styles/package.json

@@ -11,6 +11,7 @@
   ],
   "dependencies": {
     "@ckeditor/ckeditor5-core": "^20.0.0",
+    "@ckeditor/ckeditor5-typing": "^20.0.0",
     "@ckeditor/ckeditor5-ui": "^20.0.0"
   },
   "devDependencies": {

+ 16 - 0
packages/ckeditor5-basic-styles/src/code/codeediting.js

@@ -9,8 +9,11 @@
 
 import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
 import AttributeCommand from '../attributecommand';
+import TwoStepCaretMovement from '@ckeditor/ckeditor5-typing/src/twostepcaretmovement';
+import setupHighlight from '@ckeditor/ckeditor5-typing/src/utils/inlinehighlight';
 
 const CODE = 'code';
+const HIGHLIGHT_CLASS = 'ck-code_selected';
 
 /**
  * The code editing feature.
@@ -28,6 +31,13 @@ export default class CodeEditing extends Plugin {
 		return 'CodeEditing';
 	}
 
+	/**
+	 * @inheritDoc
+	 */
+	static get requires() {
+		return [ TwoStepCaretMovement ];
+	}
+
 	/**
 	 * @inheritDoc
 	 */
@@ -53,5 +63,11 @@ export default class CodeEditing extends Plugin {
 
 		// Create code command.
 		editor.commands.add( CODE, new AttributeCommand( editor, CODE ) );
+
+		// Enable two-step caret movement for `code` attribute.
+		editor.plugins.get( TwoStepCaretMovement ).registerAttribute( CODE );
+
+		// Setup highlight over selected element.
+		setupHighlight( editor, CODE, 'code', HIGHLIGHT_CLASS );
 	}
 }

+ 35 - 0
packages/ckeditor5-basic-styles/tests/code/codeediting.js

@@ -11,6 +11,9 @@ import AttributeCommand from '../../src/attributecommand';
 
 import { getData as getModelData, setData as setModelData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
 import { getData as getViewData } from '@ckeditor/ckeditor5-engine/src/dev-utils/view';
+import { keyCodes } from '@ckeditor/ckeditor5-utils/src/keyboard';
+
+/* global document */
 
 describe( 'CodeEditing', () => {
 	let editor, model;
@@ -100,4 +103,36 @@ describe( 'CodeEditing', () => {
 			expect( getViewData( editor.editing.view, { withoutSelection: true } ) ).to.equal( '<p><code>foo</code>bar</p>' );
 		} );
 	} );
+
+	it( 'should add `ck-code_selected` class when caret enters the element', () => {
+		// Put selection before the link element.
+		setModelData( editor.model, '<paragraph>foo[]<$text code="true">ba</$text>r</paragraph>' );
+
+		// So let's simulate the `keydown` event.
+		editor.editing.view.document.fire( 'keydown', {
+			keyCode: keyCodes.arrowright,
+			preventDefault: () => {},
+			domTarget: document.body
+		} );
+
+		expect( getViewData( editor.editing.view ) ).to.equal(
+			'<p>foo<code class="ck-code_selected">{}ba</code>r</p>'
+		);
+	} );
+
+	it( 'should remove `ck-code_selected` class when caret leaves the element', () => {
+		// Put selection before the link element.
+		setModelData( editor.model, '<paragraph>foo<$text code="true">ba[]</$text>r</paragraph>' );
+
+		// So let's simulate the `keydown` event.
+		editor.editing.view.document.fire( 'keydown', {
+			keyCode: keyCodes.arrowright,
+			preventDefault: () => {},
+			domTarget: document.body
+		} );
+
+		expect( getViewData( editor.editing.view ) ).to.equal(
+			'<p>foo<code>ba</code>{}r</p>'
+		);
+	} );
 } );

+ 4 - 0
packages/ckeditor5-basic-styles/theme/code.css

@@ -8,3 +8,7 @@
 	padding: .15em;
 	border-radius: 2px;
 }
+
+.ck .ck-code_selected {
+	background-color: hsla(0, 0%, 78%, 0.5);
+}

+ 7 - 4
packages/ckeditor5-block-quote/src/blockquoteediting.js

@@ -62,12 +62,12 @@ export default class BlockQuoteEditing extends Plugin {
 						continue;
 					}
 
-					if ( element.is( 'blockQuote' ) && element.isEmpty ) {
+					if ( element.is( 'element', 'blockQuote' ) && element.isEmpty ) {
 						// Added an empty blockQuote - remove it.
 						writer.remove( element );
 
 						return true;
-					} else if ( element.is( 'blockQuote' ) && !schema.checkChild( entry.position, element ) ) {
+					} else if ( element.is( 'element', 'blockQuote' ) && !schema.checkChild( entry.position, element ) ) {
 						// Added a blockQuote in incorrect place - most likely inside another blockQuote. Unwrap it
 						// so the content inside is not lost.
 						writer.unwrap( element );
@@ -78,7 +78,10 @@ export default class BlockQuoteEditing extends Plugin {
 						const range = writer.createRangeIn( element );
 
 						for ( const child of range.getItems() ) {
-							if ( child.is( 'blockQuote' ) && !schema.checkChild( writer.createPositionBefore( child ), child ) ) {
+							if (
+								child.is( 'element', 'blockQuote' ) &&
+								!schema.checkChild( writer.createPositionBefore( child ), child )
+							) {
 								writer.unwrap( child );
 
 								return true;
@@ -88,7 +91,7 @@ export default class BlockQuoteEditing extends Plugin {
 				} else if ( entry.type == 'remove' ) {
 					const parent = entry.position.parent;
 
-					if ( parent.is( 'blockQuote' ) && parent.isEmpty ) {
+					if ( parent.is( 'element', 'blockQuote' ) && parent.isEmpty ) {
 						// Something got removed and now blockQuote is empty. Remove the blockQuote as well.
 						writer.remove( parent );
 

+ 2 - 2
packages/ckeditor5-clipboard/src/utils/viewtoplaintext.js

@@ -21,10 +21,10 @@ const smallPaddingElements = [ 'figcaption', 'li' ];
 export default function viewToPlainText( viewItem ) {
 	let text = '';
 
-	if ( viewItem.is( 'text' ) || viewItem.is( 'textProxy' ) ) {
+	if ( viewItem.is( '$text' ) || viewItem.is( '$textProxy' ) ) {
 		// If item is `Text` or `TextProxy` simple take its text data.
 		text = viewItem.data;
-	} else if ( viewItem.is( 'img' ) && viewItem.hasAttribute( 'alt' ) ) {
+	} else if ( viewItem.is( 'element', 'img' ) && viewItem.hasAttribute( 'alt' ) ) {
 		// Special case for images - use alt attribute if it is provided.
 		text = viewItem.getAttribute( 'alt' );
 	} else {

+ 3 - 3
packages/ckeditor5-code-block/src/codeblockcommand.js

@@ -72,7 +72,7 @@ export default class CodeBlockCommand extends Command {
 	_getValue() {
 		const selection = this.editor.model.document.selection;
 		const firstBlock = first( selection.getSelectedBlocks() );
-		const isCodeBlock = !!( firstBlock && firstBlock.is( 'codeBlock' ) );
+		const isCodeBlock = !!( firstBlock && firstBlock.is( 'element', 'codeBlock' ) );
 
 		return isCodeBlock ? firstBlock.getAttribute( 'language' ) : false;
 	}
@@ -132,13 +132,13 @@ export default class CodeBlockCommand extends Command {
 	 * @param {Array.<module:engine/model/element~Element>} blocks
 	 */
 	_removeCodeBlock( writer, blocks ) {
-		const codeBlocks = blocks.filter( block => block.is( 'codeBlock' ) );
+		const codeBlocks = blocks.filter( block => block.is( 'element', 'codeBlock' ) );
 
 		for ( const block of codeBlocks ) {
 			const range = writer.createRangeOn( block );
 
 			for ( const item of Array.from( range.getItems() ).reverse() ) {
-				if ( item.is( 'softBreak' ) && item.parent.is( 'codeBlock' ) ) {
+				if ( item.is( 'element', 'softBreak' ) && item.parent.is( 'element', 'codeBlock' ) ) {
 					const { position } = writer.split( writer.createPositionBefore( item ) );
 
 					writer.rename( position.nodeAfter, 'paragraph' );

+ 8 - 8
packages/ckeditor5-code-block/src/codeblockediting.js

@@ -136,7 +136,7 @@ export default class CodeBlockEditing extends Plugin {
 		this.listenTo( editor.editing.view.document, 'clipboardInput', ( evt, data ) => {
 			const modelSelection = model.document.selection;
 
-			if ( !modelSelection.anchor.parent.is( 'codeBlock' ) ) {
+			if ( !modelSelection.anchor.parent.is( 'element', 'codeBlock' ) ) {
 				return;
 			}
 
@@ -156,7 +156,7 @@ export default class CodeBlockEditing extends Plugin {
 		this.listenTo( model, 'getSelectedContent', ( evt, [ selection ] ) => {
 			const anchor = selection.anchor;
 
-			if ( selection.isCollapsed || !anchor.parent.is( 'codeBlock' ) || !anchor.hasSameParentAs( selection.focus ) ) {
+			if ( selection.isCollapsed || !anchor.parent.is( 'element', 'codeBlock' ) || !anchor.hasSameParentAs( selection.focus ) ) {
 				return;
 			}
 
@@ -210,7 +210,7 @@ export default class CodeBlockEditing extends Plugin {
 		this.listenTo( editor.editing.view.document, 'enter', ( evt, data ) => {
 			const positionParent = editor.model.document.selection.getLastPosition().parent;
 
-			if ( !positionParent.is( 'codeBlock' ) ) {
+			if ( !positionParent.is( 'element', 'codeBlock' ) ) {
 				return;
 			}
 
@@ -248,7 +248,7 @@ function breakLineOnEnter( editor ) {
 	let leadingWhiteSpaces;
 
 	// Figure out the indentation (white space chars) at the beginning of the line.
-	if ( node && node.is( 'text' ) ) {
+	if ( node && node.is( '$text' ) ) {
 		leadingWhiteSpaces = getLeadingWhiteSpaces( node );
 	}
 
@@ -287,7 +287,7 @@ function leaveBlockStartOnEnter( editor, isSoftEnter ) {
 		return false;
 	}
 
-	if ( !nodeAfter || !nodeAfter.is( 'softBreak' ) ) {
+	if ( !nodeAfter || !nodeAfter.is( 'element', 'softBreak' ) ) {
 		return false;
 	}
 
@@ -350,7 +350,7 @@ function leaveBlockEndOnEnter( editor, isSoftEnter ) {
 	//
 	//		<codeBlock>foo[<softBreak></softBreak>]</codeBlock>
 	//
-	if ( nodeBefore.is( 'softBreak' ) ) {
+	if ( nodeBefore.is( 'element', 'softBreak' ) ) {
 		emptyLineRangeToRemoveOnEnter = model.createRangeOn( nodeBefore );
 	}
 
@@ -367,10 +367,10 @@ function leaveBlockEndOnEnter( editor, isSoftEnter ) {
 	//		<codeBlock>foo[<softBreak></softBreak>    ]</codeBlock>
 	//
 	else if (
-		nodeBefore.is( 'text' ) &&
+		nodeBefore.is( '$text' ) &&
 		!nodeBefore.data.match( /\S/ ) &&
 		nodeBefore.previousSibling &&
-		nodeBefore.previousSibling.is( 'softBreak' )
+		nodeBefore.previousSibling.is( 'element', 'softBreak' )
 	) {
 		emptyLineRangeToRemoveOnEnter = model.createRange(
 			model.createPositionBefore( nodeBefore.previousSibling ), model.createPositionAfter( nodeBefore )

+ 1 - 1
packages/ckeditor5-code-block/src/converters.js

@@ -147,7 +147,7 @@ export function dataViewToModelCodeBlockInsertion( editingView, languageDefs ) {
 		const viewItem = data.viewItem;
 		const viewChild = viewItem.getChild( 0 );
 
-		if ( !viewChild || !viewChild.is( 'code' ) ) {
+		if ( !viewChild || !viewChild.is( 'element', 'code' ) ) {
 			return;
 		}
 

+ 2 - 2
packages/ckeditor5-code-block/src/outdentcodeblockcommand.js

@@ -173,13 +173,13 @@ function getCodeLineTextNodeAtPosition( position ) {
 
 	// <codeBlock>foo^</codeBlock>
 	// <codeBlock>foo^<softBreak></softBreak>bar</codeBlock>
-	if ( !nodeAtPosition || nodeAtPosition.is( 'softBreak' ) ) {
+	if ( !nodeAtPosition || nodeAtPosition.is( 'element', 'softBreak' ) ) {
 		nodeAtPosition = position.nodeBefore;
 	}
 
 	// <codeBlock>^</codeBlock>
 	// <codeBlock>foo^<softBreak></softBreak>bar</codeBlock>
-	if ( !nodeAtPosition || nodeAtPosition.is( 'softBreak' ) ) {
+	if ( !nodeAtPosition || nodeAtPosition.is( 'element', 'softBreak' ) ) {
 		return null;
 	}
 

+ 2 - 2
packages/ckeditor5-code-block/src/utils.js

@@ -189,7 +189,7 @@ export function getIndentOutdentPositions( model ) {
 		} );
 
 		for ( const { item } of walker ) {
-			if ( item.is( 'textProxy' ) && item.parent.is( 'codeBlock' ) ) {
+			if ( item.is( '$textProxy' ) && item.parent.is( 'element', 'codeBlock' ) ) {
 				const leadingWhiteSpaces = getLeadingWhiteSpaces( item.textNode );
 				const { parent, startOffset } = item.textNode;
 
@@ -213,5 +213,5 @@ export function getIndentOutdentPositions( model ) {
 export function isModelSelectionInCodeBlock( selection ) {
 	const firstBlock = first( selection.getSelectedBlocks() );
 
-	return firstBlock && firstBlock.is( 'codeBlock' );
+	return firstBlock && firstBlock.is( 'element', 'codeBlock' );
 }

+ 3 - 0
packages/ckeditor5-core/src/command.js

@@ -208,6 +208,9 @@ export default class Command {
 	 *
 	 * In order to see how to disable a command from "outside" see the {@link #isEnabled} documentation.
 	 *
+	 * This method may return a value, which would be forwarded all the way down to the
+	 * {@link module:core/editor/editor~Editor#execute `editor.execute()`}.
+	 *
 	 * @fires execute
 	 */
 	execute() {}

+ 2 - 1
packages/ckeditor5-core/src/commandcollection.js

@@ -51,6 +51,7 @@ export default class CommandCollection {
 	 *
 	 * @param {String} commandName The name of the command.
 	 * @param {*} [...commandParams] Command parameters.
+	 * @returns {*} The value returned by the {@link module:core/command~Command#execute `command.execute()`}.
 	 */
 	execute( commandName, ...args ) {
 		const command = this.get( commandName );
@@ -65,7 +66,7 @@ export default class CommandCollection {
 			throw new CKEditorError( 'commandcollection-command-not-found: Command does not exist.', this, { commandName } );
 		}
 
-		command.execute( ...args );
+		return command.execute( ...args );
 	}
 
 	/**

+ 4 - 1
packages/ckeditor5-core/src/editor/editor.js

@@ -90,6 +90,8 @@ export default class Editor {
 		this.plugins = new PluginCollection( this, availablePlugins, this._context.plugins );
 
 		/**
+		 * The locale instance.
+		 *
 		 * @readonly
 		 * @type {module:utils/locale~Locale}
 		 */
@@ -282,10 +284,11 @@ export default class Editor {
 	 *
 	 * @param {String} commandName The name of the command to execute.
 	 * @param {*} [...commandParams] Command parameters.
+	 * @returns {*} The value returned by the {@link module:core/commandcollection~CommandCollection#execute `commands.execute()`}.
 	 */
 	execute( ...args ) {
 		try {
-			this.commands.execute( ...args );
+			return this.commands.execute( ...args );
 		} catch ( err ) {
 			// @if CK_DEBUG // throw err;
 			/* istanbul ignore next */

+ 3 - 1
packages/ckeditor5-core/src/multicommand.js

@@ -57,11 +57,13 @@ export default class MultiCommand extends Command {
 
 	/**
 	 * Executes the first of it registered child commands.
+	 *
+	 * @returns {*} The value returned by the {@link module:core/command~Command#execute `command.execute()`}.
 	 */
 	execute( ...args ) {
 		const command = this._getFirstEnabledCommand();
 
-		command.execute( args );
+		return command.execute( args );
 	}
 
 	/**

+ 1 - 1
packages/ckeditor5-core/tests/_utils-tests/assertions/attribute.js

@@ -54,7 +54,7 @@ describe( 'attribute chai assertion', () => {
 				hasAttribute: () => true,
 				getAttribute: () => 'bar'
 			} ).to.have.attribute( 'foo', 'baz' );
-		} ).to.throw( 'expected { Object (hasAttribute, getAttribute) } to have attribute \'foo\' of \'bar\', but got \'baz\'' );
+		} ).to.throw( 'expected { Object (hasAttribute, getAttribute) } to have attribute \'foo\' of \'baz\', but got \'bar\'' );
 	} );
 
 	it( 'negated, should assert for the given type the \'target.getAttribute\' returns a value different than the given one', () => {

+ 2 - 2
packages/ckeditor5-core/tests/_utils/assertions/attribute.js

@@ -54,8 +54,8 @@ chai.Assertion.addMethod( 'attribute', function attributeAssertion( key, value,
 			attributeValue === value,
 			`expected #{this} to have attribute '${ key }' of #{exp}, but got #{act}`,
 			`expected #{this} to not have attribute '${ key }' of #{exp}`,
-			attributeValue,
-			value
+			value,
+			attributeValue
 		);
 	}
 } );

+ 14 - 0
packages/ckeditor5-core/tests/commandcollection.js

@@ -55,6 +55,20 @@ describe( 'CommandCollection', () => {
 			expect( command.execute.args[ 0 ] ).to.deep.equal( [ 1, 2 ] );
 		} );
 
+		it( 'returns the result of command\'s execute()', () => {
+			const command = new SomeCommand( editor );
+
+			const commandResult = { foo: 'bar' };
+			sinon.stub( command, 'execute' ).returns( commandResult );
+
+			collection.add( 'foo', command );
+
+			const collectionResult = collection.execute( 'foo' );
+
+			expect( collectionResult, 'collection.execute()' ).to.equal( commandResult );
+			expect( collectionResult, 'collection.execute()' ).to.deep.equal( { foo: 'bar' } );
+		} );
+
 		it( 'throws an error if command does not exist', () => {
 			const command = new SomeCommand( editor );
 			collection.add( 'bar', command );

+ 19 - 0
packages/ckeditor5-core/tests/editor/editor.js

@@ -486,6 +486,25 @@ describe( 'Editor', () => {
 			expect( command.execute.calledOnce ).to.be.true;
 		} );
 
+		it( 'should return the result of command\'s execute()', () => {
+			class SomeCommand extends Command {
+				execute() {}
+			}
+
+			const editor = new TestEditor();
+			const command = new SomeCommand( editor );
+
+			const commandResult = { foo: 'bar' };
+			sinon.stub( command, 'execute' ).returns( commandResult );
+
+			editor.commands.add( 'someCommand', command );
+
+			const editorResult = editor.execute( 'someCommand' );
+
+			expect( editorResult, 'editor.execute()' ).to.equal( commandResult );
+			expect( editorResult, 'editor.execute()' ).to.deep.equal( { foo: 'bar' } );
+		} );
+
 		it( 'should throw an error if specified command has not been added', () => {
 			const editor = new TestEditor();
 

+ 15 - 0
packages/ckeditor5-core/tests/multicommand.js

@@ -98,6 +98,21 @@ describe( 'MultiCommand', () => {
 			sinon.assert.calledOnce( spyC );
 		} );
 
+		it( 'returns the result of command\'s execute()', () => {
+			const command = new Command( editor );
+			const commandResult = { foo: 'bar' };
+			sinon.stub( command, 'execute' ).returns( commandResult );
+
+			multiCommand.registerChildCommand( command );
+
+			command.isEnabled = true;
+
+			const multiCommandResult = multiCommand.execute();
+
+			expect( multiCommandResult, 'multiCommand.execute()' ).to.equal( commandResult );
+			expect( multiCommandResult, 'multiCommand.execute()' ).to.deep.equal( { foo: 'bar' } );
+		} );
+
 		it( 'executes first registered command if many are enabled', () => {
 			const commandA = new Command( editor );
 			const commandB = new Command( editor );

+ 1 - 0
packages/ckeditor5-core/theme/icons/object-size-full.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"><path d="M2.5 17v1h-1v-1h1zm2 0v1h-1v-1h1zm2 0v1h-1v-1h1zm2 0v1h-1v-1h1zm2 0v1h-1v-1h1zm2 0v1h-1v-1h1zm2 0v1h-1v-1h1zm2 0v1h-1v-1h1zm2 0v1h-1v-1h1zM1 15.5v1H0v-1h1zm19 0v1h-1v-1h1zm-19-2v1H0v-1h1zm19 0v1h-1v-1h1zm-19-2v1H0v-1h1zm19 0v1h-1v-1h1zm-19-2v1H0v-1h1zm19 0v1h-1v-1h1zm-19-2v1H0v-1h1zm19 0v1h-1v-1h1zm-19-2v1H0v-1h1zm19 0v1h-1v-1h1zm0-2v1h-1v-1h1zm-19 0v1H0v-1h1zM14.5 2v1h-1V2h1zm2 0v1h-1V2h1zm2 0v1h-1V2h1zm-8 0v1h-1V2h1zm-2 0v1h-1V2h1zm-2 0v1h-1V2h1zm-2 0v1h-1V2h1zm8 0v1h-1V2h1zm-10 0v1h-1V2h1z"/><path d="M18.095 2H1.905C.853 2 0 2.895 0 4v12c0 1.105.853 2 1.905 2h16.19C19.147 18 20 17.105 20 16V4c0-1.105-.853-2-1.905-2zm0 1.5c.263 0 .476.224.476.5v12c0 .276-.213.5-.476.5H1.905a.489.489 0 0 1-.476-.5V4c0-.276.213-.5.476-.5h16.19z"/></svg>

+ 1 - 0
packages/ckeditor5-core/theme/icons/object-size-large.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"><path d="M2.5 17v1h-1v-1h1zm2 0v1h-1v-1h1zm2 0v1h-1v-1h1zm2 0v1h-1v-1h1zm2 0v1h-1v-1h1zm2 0v1h-1v-1h1zm2 0v1h-1v-1h1zm2 0v1h-1v-1h1zm2 0v1h-1v-1h1zM1 15.5v1H0v-1h1zm19 0v1h-1v-1h1zm-19-2v1H0v-1h1zm19 0v1h-1v-1h1zm-19-2v1H0v-1h1zm19 0v1h-1v-1h1zm-19-2v1H0v-1h1zm19 0v1h-1v-1h1zm-19-2v1H0v-1h1zm19 0v1h-1v-1h1zm-19-2v1H0v-1h1zm19 0v1h-1v-1h1zm0-2v1h-1v-1h1zm-19 0v1H0v-1h1zM14.5 2v1h-1V2h1zm2 0v1h-1V2h1zm2 0v1h-1V2h1zm-8 0v1h-1V2h1zm-2 0v1h-1V2h1zm-2 0v1h-1V2h1zm-2 0v1h-1V2h1zm8 0v1h-1V2h1zm-10 0v1h-1V2h1z"/><path d="M13 6H2a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h11a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2zm0 1.5a.5.5 0 0 1 .5.5v8a.5.5 0 0 1-.5.5H2a.5.5 0 0 1-.5-.5V8a.5.5 0 0 1 .5-.5h11z"/></svg>

+ 1 - 0
packages/ckeditor5-core/theme/icons/object-size-medium.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"><path d="M2.5 17v1h-1v-1h1zm2 0v1h-1v-1h1zm2 0v1h-1v-1h1zm2 0v1h-1v-1h1zm2 0v1h-1v-1h1zm2 0v1h-1v-1h1zm2 0v1h-1v-1h1zm2 0v1h-1v-1h1zm2 0v1h-1v-1h1zM1 15.5v1H0v-1h1zm19 0v1h-1v-1h1zm-19-2v1H0v-1h1zm19 0v1h-1v-1h1zm-19-2v1H0v-1h1zm19 0v1h-1v-1h1zm-19-2v1H0v-1h1zm19 0v1h-1v-1h1zm-19-2v1H0v-1h1zm19 0v1h-1v-1h1zm-19-2v1H0v-1h1zm19 0v1h-1v-1h1zm0-2v1h-1v-1h1zm-19 0v1H0v-1h1zM14.5 2v1h-1V2h1zm2 0v1h-1V2h1zm2 0v1h-1V2h1zm-8 0v1h-1V2h1zm-2 0v1h-1V2h1zm-2 0v1h-1V2h1zm-2 0v1h-1V2h1zm8 0v1h-1V2h1zm-10 0v1h-1V2h1z"/><path d="M10 8H2a2 2 0 0 0-2 2v6a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2v-6a2 2 0 0 0-2-2zm0 1.5a.5.5 0 0 1 .5.5v6a.5.5 0 0 1-.5.5H2a.5.5 0 0 1-.5-.5v-6a.5.5 0 0 1 .5-.5h8z"/></svg>

+ 1 - 0
packages/ckeditor5-core/theme/icons/object-size-small.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"><path d="M2.5 17v1h-1v-1h1zm2 0v1h-1v-1h1zm2 0v1h-1v-1h1zm2 0v1h-1v-1h1zm2 0v1h-1v-1h1zm2 0v1h-1v-1h1zm2 0v1h-1v-1h1zm2 0v1h-1v-1h1zm2 0v1h-1v-1h1zM1 15.5v1H0v-1h1zm19 0v1h-1v-1h1zm-19-2v1H0v-1h1zm19 0v1h-1v-1h1zm-19-2v1H0v-1h1zm19 0v1h-1v-1h1zm-19-2v1H0v-1h1zm19 0v1h-1v-1h1zm-19-2v1H0v-1h1zm19 0v1h-1v-1h1zm-19-2v1H0v-1h1zm19 0v1h-1v-1h1zm0-2v1h-1v-1h1zm-19 0v1H0v-1h1zM14.5 2v1h-1V2h1zm2 0v1h-1V2h1zm2 0v1h-1V2h1zm-8 0v1h-1V2h1zm-2 0v1h-1V2h1zm-2 0v1h-1V2h1zm-2 0v1h-1V2h1zm8 0v1h-1V2h1zm-10 0v1h-1V2h1z"/><path d="M7 10H2a2 2 0 0 0-2 2v4a2 2 0 0 0 2 2h5a2 2 0 0 0 2-2v-4a2 2 0 0 0-2-2zm0 1.5a.5.5 0 0 1 .5.5v4a.5.5 0 0 1-.5.5H2a.5.5 0 0 1-.5-.5v-4a.5.5 0 0 1 .5-.5h5z"/></svg>

+ 1 - 1
packages/ckeditor5-engine/docs/_snippets/framework/extending-content-custom-figure-attributes.js

@@ -131,7 +131,7 @@ function downcastCustomClasses( modelElementName ) {
 function findViewChild( viewElement, viewElementName, conversionApi ) {
 	const viewChildren = Array.from( conversionApi.writer.createRangeIn( viewElement ).getItems() );
 
-	return viewChildren.find( item => item.is( viewElementName ) );
+	return viewChildren.find( item => item.is( 'element', viewElementName ) );
 }
 
 /**

+ 1 - 1
packages/ckeditor5-engine/docs/framework/guides/deep-dive/conversion-preserving-custom-content.md

@@ -429,7 +429,7 @@ function downcastCustomClassesToChild( viewElementName, modelElementName ) {
 function findViewChild( viewElement, viewElementName, conversionApi ) {
 	const viewChildren = Array.from( conversionApi.writer.createRangeIn( viewElement ).getItems() );
 
-	return viewChildren.find( item => item.is( viewElementName ) );
+	return viewChildren.find( item => item.is( 'element', viewElementName ) );
 }
 
 /**

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

@@ -90,7 +90,8 @@ export default class DataController {
 		 * @member {module:engine/conversion/downcastdispatcher~DowncastDispatcher}
 		 */
 		this.downcastDispatcher = new DowncastDispatcher( {
-			mapper: this.mapper
+			mapper: this.mapper,
+			schema: model.schema
 		} );
 		this.downcastDispatcher.on( 'insert:$text', insertText(), { priority: 'lowest' } );
 
@@ -132,6 +133,7 @@ export default class DataController {
 		this.upcastDispatcher.on( 'documentFragment', convertToModelFragment(), { priority: 'lowest' } );
 
 		this.decorate( 'init' );
+		this.decorate( 'set' );
 
 		// Fire `ready` event when initialisation has completed. Such low level listener gives possibility
 		// to plug into initialisation pipeline without interrupting the initialisation flow.
@@ -316,6 +318,7 @@ export default class DataController {
 	 *
 	 *		dataController.set( { main: '<p>Foo</p>', title: '<h1>Bar</h1>' } ); // Sets data on the `main` and `title` roots.
 	 *
+	 * @fires set
 	 * @param {String|Object.<String,String>} data Input data as a string or an object containing `rootName` - `data`
 	 * pairs to set data on multiple roots at once.
 	 */
@@ -451,6 +454,15 @@ export default class DataController {
 	 *
 	 * @event init
 	 */
+
+	/**
+	 * Event fired after {@link #set set() method} has been run.
+	 *
+	 * The `set` event is fired by decorated {@link #set} method.
+	 * See {@link module:utils/observablemixin~ObservableMixin#decorate} for more information and samples.
+	 *
+	 * @event set
+	 */
 }
 
 mix( DataController, ObservableMixin );

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

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

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

@@ -337,7 +337,7 @@ export default class Conversion {
 	 *				viewElement => {
 	 *					const fontWeight = viewElement.getStyle( 'font-weight' );
 	 *
-	 *					if ( viewElement.is( 'span' ) && fontWeight && /\d+/.test() && Number( fontWeight ) > 500 ) {
+	 *					if ( viewElement.is( 'element', 'span' ) && fontWeight && /\d+/.test() && Number( fontWeight ) > 500 ) {
 	 *						// Returned value can be an object with the matched properties.
 	 *						// These properties will be "consumed" during the conversion.
 	 *						// See `engine.view.Matcher~MatcherPattern` and `engine.view.Matcher#match` for more details.
@@ -388,7 +388,7 @@ export default class Conversion {
 	 *
 	 *					const size = Number( match[ 1 ] );
 	 *
-	 *					if ( viewElement.is( 'span' ) && size > 10 ) {
+	 *					if ( viewElement.is( 'element', 'span' ) && size > 10 ) {
 	 *						// Returned value can be an object with the matched properties.
 	 *						// These properties will be "consumed" during the conversion.
 	 *						// See `engine.view.Matcher~MatcherPattern` and `engine.view.Matcher#match` for more details.
@@ -413,7 +413,7 @@ export default class Conversion {
 	 *
 	 *					const size = Number( match[ 1 ] );
 	 *
-	 *					if ( viewElement.is( 'span' ) && size < 10 ) {
+	 *					if ( viewElement.is( 'element', 'span' ) && size < 10 ) {
 	 *						// Returned value can be an object with the matched properties.
 	 *						// These properties will be "consumed" during the conversion.
 	 *						// See `engine.view.Matcher~MatcherPattern` and `engine.view.Matcher#match` for more details.

+ 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
  */
 
+/**
+ * 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.
  *

+ 284 - 7
packages/ckeditor5-engine/src/conversion/downcasthelpers.js

@@ -239,22 +239,25 @@ export default class DowncastHelpers extends ConversionHelpers {
 	/**
 	 * 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
 	 * 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.
 	 *
-	 *		editor.conversion.for( 'downcast' ).markerToElement( {
+	 *		editor.conversion.for( 'editingDowncast' ).markerToElement( {
 	 *			model: 'search',
 	 *			view: 'marker-search'
 	 *		} );
 	 *
-	 *		editor.conversion.for( 'downcast' ).markerToElement( {
+	 *		editor.conversion.for( 'editingDowncast' ).markerToElement( {
 	 *			model: 'search',
 	 *			view: 'search-result',
 	 *			converterPriority: 'high'
 	 *		} );
 	 *
-	 *		editor.conversion.for( 'downcast' ).markerToElement( {
+	 *		editor.conversion.for( 'editingDowncast' ).markerToElement( {
 	 *			model: 'search',
 	 *			view: {
 	 *				name: 'span',
@@ -264,7 +267,7 @@ export default class DowncastHelpers extends ConversionHelpers {
 	 *			}
 	 *		} );
 	 *
-	 *		editor.conversion.for( 'downcast' ).markerToElement( {
+	 *		editor.conversion.for( 'editingDowncast' ).markerToElement( {
 	 *			model: 'search',
 	 *			view: ( markerData, viewWriter ) => {
 	 *				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 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
 	 * to the conversion process.
 	 *
@@ -357,6 +358,119 @@ export default class DowncastHelpers extends ConversionHelpers {
 	markerToHighlight( 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.
 //
 // Attributes from the model are converted to the view element attributes in the view. You may provide a custom function to generate
@@ -906,7 +1155,7 @@ function highlightText( highlightDescriptor ) {
 			return;
 		}
 
-		if ( !( data.item instanceof ModelSelection || data.item instanceof DocumentSelection ) && !data.item.is( 'textProxy' ) ) {
+		if ( !( data.item instanceof ModelSelection || data.item instanceof DocumentSelection ) && !data.item.is( '$textProxy' ) ) {
 			return;
 		}
 
@@ -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.
 //
 // See {@link ~DowncastHelpers#markerToElement `.markerToElement()` downcast helper} for examples.

+ 10 - 11
packages/ckeditor5-engine/src/conversion/mapper.js

@@ -104,7 +104,7 @@ export default class Mapper {
 
 			const viewContainer = this._modelToViewMapping.get( data.modelPosition.parent );
 
-			data.viewPosition = this._findPositionIn( viewContainer, data.modelPosition.offset );
+			data.viewPosition = this.findPositionIn( viewContainer, data.modelPosition.offset );
 		}, { priority: 'low' } );
 
 		// Default mapper algorithm for mapping view position to model position.
@@ -445,7 +445,7 @@ export default class Mapper {
 		// viewBlock == viewParent, so we need to calculate the offset in the parent element.
 
 		// If the position is a text it is simple ("ba|r" -> 2).
-		if ( viewParent.is( 'text' ) ) {
+		if ( viewParent.is( '$text' ) ) {
 			return viewOffset;
 		}
 
@@ -488,7 +488,7 @@ export default class Mapper {
 			return callback( viewNode );
 		} else if ( this._viewToModelMapping.has( viewNode ) ) {
 			return 1;
-		} else if ( viewNode.is( 'text' ) ) {
+		} else if ( viewNode.is( '$text' ) ) {
 			return viewNode.data.length;
 		} else if ( viewNode.is( 'uiElement' ) ) {
 			return 0;
@@ -510,25 +510,24 @@ export default class Mapper {
 	 *
 	 *		<p>fo<b>bar</b>bom</p> -> expected offset: 4
 	 *
-	 *		_findPositionIn( p, 4 ):
+	 *		findPositionIn( p, 4 ):
 	 *		<p>|fo<b>bar</b>bom</p> -> expected offset: 4, actual offset: 0
 	 *		<p>fo|<b>bar</b>bom</p> -> expected offset: 4, actual offset: 2
 	 *		<p>fo<b>bar</b>|bom</p> -> expected offset: 4, actual offset: 5 -> we are too far
 	 *
-	 *		_findPositionIn( b, 4 - ( 5 - 3 ) ):
+	 *		findPositionIn( b, 4 - ( 5 - 3 ) ):
 	 *		<p>fo<b>|bar</b>bom</p> -> expected offset: 2, actual offset: 0
 	 *		<p>fo<b>bar|</b>bom</p> -> expected offset: 2, actual offset: 3 -> we are too far
 	 *
-	 *		_findPositionIn( bar, 2 - ( 3 - 3 ) ):
+	 *		findPositionIn( bar, 2 - ( 3 - 3 ) ):
 	 *		We are in the text node so we can simple find the offset.
 	 *		<p>fo<b>ba|r</b>bom</p> -> expected offset: 2, actual offset: 2 -> position found
 	 *
-	 * @private
 	 * @param {module:engine/view/element~Element} viewParent Tree view element in which we are looking for the position.
 	 * @param {Number} expectedOffset Expected offset.
 	 * @returns {module:engine/view/position~Position} Found position.
 	 */
-	_findPositionIn( viewParent, expectedOffset ) {
+	findPositionIn( viewParent, expectedOffset ) {
 		// Last scanned view node.
 		let viewNode;
 		// Length of the last scanned view node.
@@ -538,7 +537,7 @@ export default class Mapper {
 		let viewOffset = 0;
 
 		// In the text node it is simple: offset in the model equals offset in the text.
-		if ( viewParent.is( 'text' ) ) {
+		if ( viewParent.is( '$text' ) ) {
 			return new ViewPosition( viewParent, expectedOffset );
 		}
 
@@ -560,7 +559,7 @@ export default class Mapper {
 		else {
 			// ( modelOffset - lastLength ) is the offset to the child we enter,
 			// so we subtract it from the expected offset to fine the offset in the child.
-			return this._findPositionIn( viewNode, expectedOffset - ( modelOffset - lastLength ) );
+			return this.findPositionIn( viewNode, expectedOffset - ( modelOffset - lastLength ) );
 		}
 	}
 
@@ -632,7 +631,7 @@ export default class Mapper {
 	 *			const sibling = data.modelPosition.nodeBefore;
 	 *
 	 *			// Check if this is the element we are interested in.
-	 *			if ( !sibling.is( 'customElement' ) ) {
+	 *			if ( !sibling.is( 'element', 'customElement' ) ) {
 	 *				return;
 	 *			}
 	 *

+ 1 - 1
packages/ckeditor5-engine/src/conversion/upcastdispatcher.js

@@ -237,7 +237,7 @@ export default class UpcastDispatcher {
 
 		if ( viewItem.is( 'element' ) ) {
 			this.fire( 'element:' + viewItem.name, data, this.conversionApi );
-		} else if ( viewItem.is( 'text' ) ) {
+		} else if ( viewItem.is( '$text' ) ) {
 			this.fire( 'text', data, this.conversionApi );
 		} else {
 			this.fire( 'documentFragment', data, this.conversionApi );

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

@@ -9,6 +9,9 @@ import ConversionHelpers from './conversionhelpers';
 
 import { cloneDeep } from 'lodash-es';
 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
@@ -287,6 +290,8 @@ export default class UpcastHelpers extends ConversionHelpers {
 	/**
 	 * 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:
 	 * `<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
@@ -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
 	 * to the conversion process.
 	 *
+	 * @deprecated
 	 * @method #elementToMarker
 	 * @param {Object} config Conversion configuration.
 	 * @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}
 	 */
 	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 ) );
 	}
+
+	/**
+	 * 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 ) {
 	config = cloneDeep( config );
 
-	normalizeToMarkerConfig( config );
+	normalizeElementToMarkerConfig( 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
 // 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()`
-// 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.
-function normalizeToMarkerConfig( config ) {
+function normalizeElementToMarkerConfig( config ) {
 	const oldModel = config.model;
 
 	config.model = ( viewElement, modelWriter ) => {
@@ -791,3 +962,23 @@ function normalizeToMarkerConfig( config ) {
 		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;
+}

+ 5 - 5
packages/ckeditor5-engine/src/conversion/viewconsumable.js

@@ -80,7 +80,7 @@ export default class ViewConsumable {
 		let elementConsumables;
 
 		// For text nodes and document fragments just mark them as consumable.
-		if ( element.is( 'text' ) || element.is( 'documentFragment' ) ) {
+		if ( element.is( '$text' ) || element.is( 'documentFragment' ) ) {
 			this._consumables.set( element, true );
 
 			return;
@@ -134,7 +134,7 @@ export default class ViewConsumable {
 		}
 
 		// For text nodes and document fragments return stored boolean value.
-		if ( element.is( 'text' ) || element.is( 'documentFragment' ) ) {
+		if ( element.is( '$text' ) || element.is( 'documentFragment' ) ) {
 			return elementConsumables;
 		}
 
@@ -172,7 +172,7 @@ export default class ViewConsumable {
 	 */
 	consume( element, consumables ) {
 		if ( this.test( element, consumables ) ) {
-			if ( element.is( 'text' ) || element.is( 'documentFragment' ) ) {
+			if ( element.is( '$text' ) || element.is( 'documentFragment' ) ) {
 				// For text nodes and document fragments set value to false.
 				this._consumables.set( element, false );
 			} else {
@@ -218,7 +218,7 @@ export default class ViewConsumable {
 		const elementConsumables = this._consumables.get( element );
 
 		if ( elementConsumables !== undefined ) {
-			if ( element.is( 'text' ) || element.is( 'documentFragment' ) ) {
+			if ( element.is( '$text' ) || element.is( 'documentFragment' ) ) {
 				// For text nodes and document fragments - set consumable to true.
 				this._consumables.set( element, true );
 			} else {
@@ -287,7 +287,7 @@ export default class ViewConsumable {
 			instance = new ViewConsumable( from );
 		}
 
-		if ( from.is( 'text' ) ) {
+		if ( from.is( '$text' ) ) {
 			instance.add( from );
 
 			return instance;

+ 1 - 1
packages/ckeditor5-engine/src/dev-utils/model.js

@@ -228,7 +228,7 @@ export function stringify( node, selectionOrPositionOrRange = null, markers = nu
 
 	downcastDispatcher.on( 'insert:$text', insertText() );
 	downcastDispatcher.on( 'attribute', ( evt, data, conversionApi ) => {
-		if ( data.item instanceof ModelSelection || data.item instanceof DocumentSelection || data.item.is( 'textProxy' ) ) {
+		if ( data.item instanceof ModelSelection || data.item instanceof DocumentSelection || data.item.is( '$textProxy' ) ) {
 			const converter = wrap( ( modelAttributeValue, viewWriter ) => {
 				return viewWriter.createAttributeElement(
 					'model-text-with-attributes',

+ 27 - 9
packages/ckeditor5-engine/src/dev-utils/view.js

@@ -25,6 +25,7 @@ import AttributeElement from '../view/attributeelement';
 import ContainerElement from '../view/containerelement';
 import EmptyElement from '../view/emptyelement';
 import UIElement from '../view/uielement';
+import RawElement from '../view/rawelement';
 import { StylesProcessor } from '../view/stylesmap';
 
 const ELEMENT_RANGE_START_TOKEN = '[';
@@ -35,7 +36,8 @@ const allowedTypes = {
 	'container': ContainerElement,
 	'attribute': AttributeElement,
 	'empty': EmptyElement,
-	'ui': UIElement
+	'ui': UIElement,
+	'raw': RawElement
 };
 
 /**
@@ -55,6 +57,8 @@ const allowedTypes = {
  * (`<span id="marker:foo">`).
  * @param {Boolean} [options.renderUIElements=false] When set to `true`, the inner content of each
  * {@link module:engine/view/uielement~UIElement} will be printed.
+ * @param {Boolean} [options.renderRawElements=false] When set to `true`, the inner content of each
+ * {@link module:engine/view/rawelement~RawElement} will be printed.
  * @returns {String} The stringified data.
  */
 export function getData( view, options = {} ) {
@@ -70,6 +74,7 @@ export function getData( view, options = {} ) {
 		showType: options.showType,
 		showPriority: options.showPriority,
 		renderUIElements: options.renderUIElements,
+		renderRawElements: options.renderRawElements,
 		ignoreRoot: true
 	};
 
@@ -234,6 +239,8 @@ setData._parse = parse;
  *  `{` and `}` and the selection outside the text as `[` and `]`. When set to `false`, both will be marked as `[` and `]` only.
  * @param {Boolean} [options.renderUIElements=false] When set to `true`, the inner content of each
  * {@link module:engine/view/uielement~UIElement} will be printed.
+ * @param {Boolean} [options.renderRawElements=false] When set to `true`, the inner content of each
+ * {@link module:engine/view/rawelement~RawElement} will be printed.
  * @returns {String} An HTML-like string representing the view.
  */
 export function stringify( node, selectionOrPositionOrRange = null, options = {} ) {
@@ -452,7 +459,7 @@ class RangeParser {
 			}
 		}
 
-		if ( node.is( 'text' ) ) {
+		if ( node.is( '$text' ) ) {
 			const regexp = new RegExp(
 				`[${ TEXT_RANGE_START_TOKEN }${ TEXT_RANGE_END_TOKEN }\\${ ELEMENT_RANGE_END_TOKEN }\\${ ELEMENT_RANGE_START_TOKEN }]`,
 				'g'
@@ -622,6 +629,8 @@ class ViewStringify {
 	 * `{` and `}` and the selection outside the text as `[` and `]`. When set to `false`, both are marked as `[` and `]`.
 	 * @param {Boolean} [options.renderUIElements=false] When set to `true`, the inner content of each
 	 * {@link module:engine/view/uielement~UIElement} will be printed.
+	 * @param {Boolean} [options.renderRawElements=false] When set to `true`, the inner content of each
+	 * {@link module:engine/view/rawelement~RawElement} will be printed.
 	 */
 	constructor( root, selection, options ) {
 		this.root = root;
@@ -638,6 +647,7 @@ class ViewStringify {
 		this.ignoreRoot = !!options.ignoreRoot;
 		this.sameSelectionCharacters = !!options.sameSelectionCharacters;
 		this.renderUIElements = !!options.renderUIElements;
+		this.renderRawElements = !!options.renderRawElements;
 	}
 
 	/**
@@ -670,8 +680,15 @@ class ViewStringify {
 				callback( this._stringifyElementOpen( root ) );
 			}
 
-			if ( this.renderUIElements && root.is( 'uiElement' ) ) {
+			if ( ( this.renderUIElements && root.is( 'uiElement' ) ) ) {
 				callback( root.render( document ).innerHTML );
+			} else if ( this.renderRawElements && root.is( 'rawElement' ) ) {
+				// There's no DOM element for "root" to pass to render(). Creating
+				// a surrogate container to render the children instead.
+				const rawContentContainer = document.createElement( 'div' );
+				root.render( rawContentContainer );
+
+				callback( rawContentContainer.innerHTML );
 			} else {
 				let offset = 0;
 				callback( this._stringifyElementRanges( root, offset ) );
@@ -688,7 +705,7 @@ class ViewStringify {
 			}
 		}
 
-		if ( root.is( 'text' ) ) {
+		if ( root.is( '$text' ) ) {
 			callback( this._stringifyTextRanges( root ) );
 		}
 	}
@@ -824,8 +841,9 @@ class ViewStringify {
 	 * Returns:
 	 * * 'attribute' for {@link module:engine/view/attributeelement~AttributeElement attribute elements},
 	 * * 'container' for {@link module:engine/view/containerelement~ContainerElement container elements},
-	 * * 'empty' for {@link module:engine/view/emptyelement~EmptyElement empty elements}.
-	 * * 'ui' for {@link module:engine/view/uielement~UIElement UI elements}.
+	 * * 'empty' for {@link module:engine/view/emptyelement~EmptyElement empty elements},
+	 * * 'ui' for {@link module:engine/view/uielement~UIElement UI elements},
+	 * * 'raw' for {@link module:engine/view/rawelement~RawElement raw elements},
 	 * * an empty string when the current configuration is preventing showing elements' types.
 	 *
 	 * @private
@@ -943,10 +961,10 @@ function _convertViewElements( rootNode ) {
 		for ( const child of [ ...rootNode.getChildren() ] ) {
 			if ( convertedElement.is( 'emptyElement' ) ) {
 				throw new Error( 'Parse error - cannot parse inside EmptyElement.' );
-			}
-
-			if ( convertedElement.is( 'uiElement' ) ) {
+			} else if ( convertedElement.is( 'uiElement' ) ) {
 				throw new Error( 'Parse error - cannot parse inside UIElement.' );
+			} else if ( convertedElement.is( 'rawElement' ) ) {
+				throw new Error( 'Parse error - cannot parse inside RawElement.' );
 			}
 
 			convertedElement._appendChild( _convertViewElements( child ) );

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

@@ -1039,7 +1039,7 @@ function _getChildrenSnapshot( children ) {
 	const snapshot = [];
 
 	for ( const child of children ) {
-		if ( child.is( 'text' ) ) {
+		if ( child.is( '$text' ) ) {
 			for ( let i = 0; i < child.data.length; i++ ) {
 				snapshot.push( {
 					name: '$text',

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

@@ -335,7 +335,7 @@ export default class DocumentFragment {
 	// @if CK_DEBUG_ENGINE //	for ( const child of this.getChildren() ) {
 	// @if CK_DEBUG_ENGINE //		string += '\n';
 
-	// @if CK_DEBUG_ENGINE //		if ( child.is( 'text' ) ) {
+	// @if CK_DEBUG_ENGINE //		if ( child.is( '$text' ) ) {
 	// @if CK_DEBUG_ENGINE //			const textAttrs = stringifyMap( child._attrs );
 
 	// @if CK_DEBUG_ENGINE //			string += '\t'.repeat( 1 );

+ 24 - 42
packages/ckeditor5-engine/src/model/documentselection.js

@@ -567,7 +567,6 @@ mix( DocumentSelection, EmitterMixin );
 //
 // @extends module:engine/model/selection~Selection
 //
-
 class LiveSelection extends Selection {
 	// Creates an empty live selection for given {@link module:engine/model/document~Document}.
 	// @param {module:engine/model/document~Document} doc Document which owns this selection.
@@ -602,10 +601,10 @@ class LiveSelection extends Selection {
 		// @member {Map} module:engine/model/liveselection~LiveSelection#_attributePriority
 		this._attributePriority = new Map();
 
-		// Contains data required to fix ranges which have been moved to the graveyard.
+		// Position to which the selection should be set if the last selection range was moved to the graveyard.
 		// @private
-		// @member {Array} module:engine/model/liveselection~LiveSelection#_fixGraveyardRangesData
-		this._fixGraveyardRangesData = [];
+		// @member {module:engine/model/position~Position} module:engine/model/liveselection~LiveSelection#_selectionRestorePosition
+		this._selectionRestorePosition = null;
 
 		// Flag that informs whether the selection ranges have changed. It is changed on true when `LiveRange#change:range` event is fired.
 		// @private
@@ -628,12 +627,14 @@ class LiveSelection extends Selection {
 				return;
 			}
 
-			while ( this._fixGraveyardRangesData.length ) {
-				const { liveRange, sourcePosition } = this._fixGraveyardRangesData.shift();
-
-				this._fixGraveyardSelection( liveRange, sourcePosition );
+			// Fix selection if the last range was removed from it and we have a position to which we can restore the selection.
+			if ( this._ranges.length == 0 && this._selectionRestorePosition ) {
+				this._fixGraveyardSelection( this._selectionRestorePosition );
 			}
 
+			// "Forget" the restore position even if it was not "used".
+			this._selectionRestorePosition = null;
+
 			if ( this._hasChangedRange ) {
 				this._hasChangedRange = false;
 				this.fire( 'change:range', { directChange: false } );
@@ -827,15 +828,17 @@ class LiveSelection extends Selection {
 
 		const liveRange = LiveRange.fromRange( range );
 
+		// If selection range is moved to the graveyard remove it from the selection object.
+		// Also, save some data that can be used to restore selection later, on `Model#applyOperation` event.
 		liveRange.on( 'change:range', ( evt, oldRange, data ) => {
 			this._hasChangedRange = true;
 
-			// If `LiveRange` is in whole moved to the graveyard, save necessary data. It will be fixed on `Model#applyOperation` event.
 			if ( liveRange.root == this._document.graveyard ) {
-				this._fixGraveyardRangesData.push( {
-					liveRange,
-					sourcePosition: data.deletionPosition
-				} );
+				this._selectionRestorePosition = data.deletionPosition;
+
+				const index = this._ranges.indexOf( liveRange );
+				this._ranges.splice( index, 1 );
+				liveRange.detach();
 			}
 		} );
 
@@ -1117,36 +1120,20 @@ class LiveSelection extends Selection {
 		return attrs;
 	}
 
-	// Fixes a selection range after it ends up in graveyard root.
+	// Fixes the selection after all its ranges got removed.
 	//
 	// @private
-	// @param {module:engine/model/liverange~LiveRange} liveRange The range from selection, that ended up in the graveyard root.
-	// @param {module:engine/model/position~Position} removedRangeStart Start position of a range which was removed.
-	_fixGraveyardSelection( liveRange, removedRangeStart ) {
-		// The start of the removed range is the closest position to the `liveRange` - the original selection range.
-		// This is a good candidate for a fixed selection range.
-		const positionCandidate = removedRangeStart.clone();
-
-		// Find a range that is a correct selection range and is closest to the start of removed range.
-		const selectionRange = this._model.schema.getNearestSelectionRange( positionCandidate );
-
-		// Remove the old selection range before preparing and adding new selection range. This order is important,
-		// because new range, in some cases, may intersect with old range (it depends on `getNearestSelectionRange()` result).
-		const index = this._ranges.indexOf( liveRange );
-		this._ranges.splice( index, 1 );
-		liveRange.detach();
+	// @param {module:engine/model/position~Position} deletionPosition Position where the deletion happened.
+	_fixGraveyardSelection( deletionPosition ) {
+		// Find a range that is a correct selection range and is closest to the position where the deletion happened.
+		const selectionRange = this._model.schema.getNearestSelectionRange( deletionPosition );
 
 		// If nearest valid selection range has been found - add it in the place of old range.
-		// If range is equal to any other selection ranges then it is probably due to contents
-		// of a multi-range selection being removed. See ckeditor/ckeditor5#6501.
-		if ( selectionRange && !isRangeCollidingWithSelection( selectionRange, this ) ) {
+		if ( selectionRange ) {
 			// Check the range, convert it to live range, bind events, etc.
-			const newRange = this._prepareRange( selectionRange );
-
-			// Add new range in the place of old range.
-			this._ranges.splice( index, 0, newRange );
+			this._pushRange( selectionRange );
 		}
-		// If nearest valid selection range cannot be found or is intersecting with other selection ranges removing the old range is fine.
+		// If nearest valid selection range cannot be found don't add any range. Selection will be set to the default range.
 	}
 }
 
@@ -1191,8 +1178,3 @@ function clearAttributesStoredInElement( model, batch ) {
 		}
 	}
 }
-
-// Checks if range collides with any of selection ranges.
-function isRangeCollidingWithSelection( range, selection ) {
-	return !selection._ranges.every( selectionRange => !range.isEqual( selectionRange ) );
-}

+ 26 - 6
packages/ckeditor5-engine/src/model/element.js

@@ -104,21 +104,19 @@ export default class Element extends Node {
 	 * Assuming that the object being checked is an element, you can also check its
 	 * {@link module:engine/model/element~Element#name name}:
 	 *
-	 *		element.is( 'image' ); // -> true if this is an <image> element
+	 *		element.is( 'element', 'image' ); // -> true if this is an <image> element
 	 *		element.is( 'element', 'image' ); // -> same as above
-	 *		text.is( 'image' ); -> false
+	 *		text.is( 'element', 'image' ); -> false
 	 *
 	 * {@link module:engine/model/node~Node#is Check the entire list of model objects} which implement the `is()` method.
 	 *
-	 * @param {String} type Type to check when `name` parameter is present.
-	 * Otherwise, it acts like the `name` parameter.
+	 * @param {String} type Type to check.
 	 * @param {String} [name] Element name.
 	 * @returns {Boolean}
 	 */
 	is( type, name = null ) {
 		if ( !name ) {
 			return type === 'element' || type === 'model:element' ||
-				type === this.name || type === 'model:' + this.name ||
 				// From super.is(). This is highly utilised method and cannot call super. See ckeditor/ckeditor5#6529.
 				type === 'node' || type === 'model:node';
 		}
@@ -209,6 +207,28 @@ export default class Element extends Node {
 		return node;
 	}
 
+	/**
+	 * Returns the parent element of the given name. Returns null if the element is not inside the desired parent.
+	 *
+	 * @param {String} parentName The name of the parent element to find.
+	 * @param {Object} [options] Options object.
+	 * @param {Boolean} [options.includeSelf=false] When set to `true` this node will be also included while searching.
+	 * @returns {module:engine/model/element~Element|null}
+	 */
+	findAncestor( parentName, options = { includeSelf: false } ) {
+		let parent = options.includeSelf ? this : this.parent;
+
+		while ( parent ) {
+			if ( parent.name === parentName ) {
+				return parent;
+			}
+
+			parent = parent.parent;
+		}
+
+		return null;
+	}
+
 	/**
 	 * Converts `Element` instance to plain object and returns it. Takes care of converting all of this element's children.
 	 *
@@ -359,7 +379,7 @@ export default class Element extends Node {
 	// @if CK_DEBUG_ENGINE // 	for ( const child of this.getChildren() ) {
 	// @if CK_DEBUG_ENGINE // 		string += '\n';
 
-	// @if CK_DEBUG_ENGINE // 		if ( child.is( 'text' ) ) {
+	// @if CK_DEBUG_ENGINE // 		if ( child.is( '$text' ) ) {
 	// @if CK_DEBUG_ENGINE // 			const textAttrs = convertMapToTags( child._attrs );
 
 	// @if CK_DEBUG_ENGINE // 			string += '\t'.repeat( level + 1 );

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

@@ -92,6 +92,16 @@ export default class MarkerCollection {
 	 */
 	_set( markerOrName, range, managedUsingOperations = false, affectsData = false ) {
 		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 );
 
 		if ( oldMarker ) {

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

@@ -573,7 +573,7 @@ export default class Model {
 		}
 
 		for ( const item of range.getItems() ) {
-			if ( item.is( 'textProxy' ) ) {
+			if ( item.is( '$textProxy' ) ) {
 				if ( !ignoreWhitespaces ) {
 					return true;
 				} else if ( item.data.search( /\S/ ) !== -1 ) {

+ 2 - 2
packages/ckeditor5-engine/src/model/node.js

@@ -406,7 +406,7 @@ export default class Node {
 	 *
 	 * By using this method it is also possible to check a name of an element:
 	 *
-	 *		imageElement.is( 'image' ); // -> true
+	 *		imageElement.is( 'element', 'image' ); // -> true
 	 *		imageElement.is( 'element', 'image' ); // -> same as above
 	 *		imageElement.is( 'model:element', 'image' ); // -> same as above, but more precise
 	 *
@@ -427,7 +427,7 @@ export default class Node {
 	 * * {@link module:engine/model/textproxy~TextProxy#is `TextProxy#is()`}
 	 *
 	 * @method #is
-	 * @param {String} type
+	 * @param {String} type Type to check.
 	 * @returns {Boolean}
 	 */
 	is( type ) {

+ 2 - 2
packages/ckeditor5-engine/src/model/operation/utils.js

@@ -143,7 +143,7 @@ export function _setAttribute( range, key, value ) {
 		// Iterator will return `TextProxy` instances but we know that those text proxies will
 		// always represent full text nodes (this is guaranteed thanks to splitting we did before).
 		// So, we can operate on those text proxies' text nodes.
-		const node = item.is( 'textProxy' ) ? item.textNode : item;
+		const node = item.is( '$textProxy' ) ? item.textNode : item;
 
 		if ( value !== null ) {
 			node._setAttribute( key, value );
@@ -219,7 +219,7 @@ function _mergeNodesAtIndex( element, index ) {
 	const nodeAfter = element.getChild( index );
 
 	// Check if both of those nodes are text objects with same attributes.
-	if ( nodeBefore && nodeAfter && nodeBefore.is( 'text' ) && nodeAfter.is( 'text' ) && _haveSameAttributes( nodeBefore, nodeAfter ) ) {
+	if ( nodeBefore && nodeAfter && nodeBefore.is( '$text' ) && nodeAfter.is( '$text' ) && _haveSameAttributes( nodeBefore, nodeAfter ) ) {
 		// Append text of text node after index to the before one.
 		const mergedNode = new Text( nodeBefore.data + nodeAfter.data, nodeBefore.getAttributes() );
 

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

@@ -146,9 +146,6 @@ export default class Position {
 		return this.path[ this.path.length - 1 ];
 	}
 
-	/**
-	 * @param {Number} newOffset
-	 */
 	set offset( newOffset ) {
 		this.path[ this.path.length - 1 ] = newOffset;
 	}
@@ -176,7 +173,7 @@ export default class Position {
 			}
 		}
 
-		if ( parent.is( 'text' ) ) {
+		if ( parent.is( '$text' ) ) {
 			/**
 			 * The position's path is incorrect. This means that a position does not point to
 			 * a correct place in the tree and hence, some of its methods and getters cannot work correctly.
@@ -354,6 +351,22 @@ export default class Position {
 		}
 	}
 
+	/**
+	 * Returns the parent element of the given name. Returns null if the position is not inside the desired parent.
+	 *
+	 * @param {String} parentName The name of the parent element to find.
+	 * @returns {module:engine/model/element~Element|null}
+	 */
+	findAncestor( parentName ) {
+		const parent = this.parent;
+
+		if ( parent.is( 'element' ) ) {
+			return parent.findAncestor( parentName, { includeSelf: true } );
+		}
+
+		return null;
+	}
+
 	/**
 	 * Returns the slice of two position {@link #path paths} which is identical. The {@link #root roots}
 	 * of these two paths must be identical.
@@ -1090,7 +1103,7 @@ export default class Position {
 export function getTextNodeAtPosition( position, positionParent ) {
 	const node = positionParent.getChild( positionParent.offsetToIndex( position.offset ) );
 
-	if ( node && node.is( 'text' ) && node.startOffset < position.offset ) {
+	if ( node && node.is( '$text' ) && node.startOffset < position.offset ) {
 		return node;
 	}
 

+ 57 - 0
packages/ckeditor5-engine/src/model/range.js

@@ -276,6 +276,63 @@ export default class Range {
 		return null;
 	}
 
+	/**
+	 * Returns a range created by joining this {@link ~Range range} with the given {@link ~Range range}.
+	 * If ranges have no common part, returns `null`.
+	 *
+	 * Examples:
+	 *
+	 *		let range = model.createRange(
+	 *			model.createPositionFromPath( root, [ 2, 7 ] ),
+	 *			model.createPositionFromPath( root, [ 4, 0, 1 ] )
+	 *		);
+	 *		let otherRange = model.createRange(
+	 *			model.createPositionFromPath( root, [ 1 ] ),
+	 *			model.createPositionFromPath( root, [ 2 ] )
+ 	 *		);
+	 *		let transformed = range.getJoined( otherRange ); // null - ranges have no common part
+	 *
+	 *		otherRange = model.createRange(
+	 *			model.createPositionFromPath( root, [ 3 ] ),
+	 *			model.createPositionFromPath( root, [ 5 ] )
+	 *		);
+	 *		transformed = range.getJoined( otherRange ); // range from [ 2, 7 ] to [ 5 ]
+	 *
+	 * @param {module:engine/model/range~Range} otherRange Range to be joined.
+	 * @param {Boolean} [loose=false] Whether the intersection check is loose or strict. If the check is strict (`false`),
+	 * ranges are tested for intersection or whether start/end positions are equal. If the check is loose (`true`),
+	 * compared range is also checked if it's {@link module:engine/model/position~Position#isTouching touching} current range.
+	 * @returns {module:engine/model/range~Range|null} A sum of given ranges or `null` if ranges have no common part.
+	 */
+	getJoined( otherRange, loose = false ) {
+		let shouldJoin = this.isIntersecting( otherRange );
+
+		if ( !shouldJoin ) {
+			if ( this.start.isBefore( otherRange.start ) ) {
+				shouldJoin = loose ? this.end.isTouching( otherRange.start ) : this.end.isEqual( otherRange.start );
+			} else {
+				shouldJoin = loose ? otherRange.end.isTouching( this.start ) : otherRange.end.isEqual( this.start );
+			}
+		}
+
+		if ( !shouldJoin ) {
+			return null;
+		}
+
+		let startPosition = this.start;
+		let endPosition = this.end;
+
+		if ( otherRange.start.isBefore( startPosition ) ) {
+			startPosition = otherRange.start;
+		}
+
+		if ( otherRange.end.isAfter( endPosition ) ) {
+			endPosition = otherRange.end;
+		}
+
+		return new Range( startPosition, endPosition );
+	}
+
 	/**
 	 * Computes and returns the smallest set of {@link #isFlat flat} ranges, that covers this range in whole.
 	 *

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

@@ -68,14 +68,11 @@ export default class RootElement extends Element {
 	 * Assuming that the object being checked is an element, you can also check its
 	 * {@link module:engine/model/element~Element#name name}:
 	 *
-	 *		rootElement.is( '$root' ); // -> true if this is a $root element
 	 *		rootElement.is( 'rootElement', '$root' ); // -> same as above
-	 *		text.is( '$root' ); -> false
 	 *
 	 * {@link module:engine/model/node~Node#is Check the entire list of model objects} which implement the `is()` method.
 	 *
-	 * @param {String} type Type to check when `name` parameter is present.
-	 * Otherwise, it acts like the `name` parameter.
+	 * @param {String} type Type to check.
 	 * @param {String} [name] Element name.
 	 * @returns {Boolean}
 	 */
@@ -84,7 +81,6 @@ export default class RootElement extends Element {
 			return type === 'rootElement' || type === 'model:rootElement' ||
 				// From super.is(). This is highly utilised method and cannot call super. See ckeditor/ckeditor5#6529.
 				type === 'element' || type === 'model:element' ||
-				type === this.name || type === 'model:' + this.name ||
 				type === 'node' || type === 'model:node';
 		}
 

+ 2 - 2
packages/ckeditor5-engine/src/model/schema.js

@@ -186,7 +186,7 @@ export default class Schema {
 
 		if ( typeof item == 'string' ) {
 			itemName = item;
-		} else if ( item.is && ( item.is( 'text' ) || item.is( 'textProxy' ) ) ) {
+		} else if ( item.is && ( item.is( '$text' ) || item.is( '$textProxy' ) ) ) {
 			itemName = '$text';
 		}
 		// Element or module:engine/model/schema~SchemaContextItem.
@@ -774,7 +774,7 @@ export default class Schema {
 	removeDisallowedAttributes( nodes, writer ) {
 		for ( const node of nodes ) {
 			// When node is a `Text` it has no children, so just filter it out.
-			if ( node.is( 'text' ) ) {
+			if ( node.is( '$text' ) ) {
 				removeDisallowedAttributeFromNode( this, node, writer );
 			}
 			// In a case of `Element` iterates through positions between nodes inside this element

+ 10 - 6
packages/ckeditor5-engine/src/model/text.js

@@ -67,22 +67,26 @@ export default class Text extends Node {
 	/**
 	 * Checks whether this object is of the given.
 	 *
-	 *		text.is( 'text' ); // -> true
+	 *		text.is( '$text' ); // -> true
 	 *		text.is( 'node' ); // -> true
-	 *		text.is( 'model:text' ); // -> true
+	 *		text.is( 'model:$text' ); // -> true
 	 *		text.is( 'model:node' ); // -> true
 	 *
-	 *		text.is( 'view:text' ); // -> false
+	 *		text.is( 'view:$text' ); // -> false
 	 *		text.is( 'documentSelection' ); // -> false
 	 *
 	 * {@link module:engine/model/node~Node#is Check the entire list of model objects} which implement the `is()` method.
 	 *
-	 * @param {String} type Type to check when `name` parameter is present.
-	 * Otherwise, it acts like the `name` parameter.
+	 * **Note:** Until version 20.0.0 this method wasn't accepting `'$text'` type. The legacy `'text'` type is still
+	 * accepted for backward compatibility.
+	 *
+	 * @param {String} type Type to check.
 	 * @returns {Boolean}
 	 */
 	is( type ) {
-		return type === 'text' || type === 'model:text' ||
+		return type === '$text' || type === 'model:$text' ||
+			// This are legacy values kept for backward compatibility.
+			type === 'text' || type === 'model:text' ||
 			// From super.is(). This is highly utilised method and cannot call super. See ckeditor/ckeditor5#6529.
 			type === 'node' || type === 'model:node';
 	}

+ 10 - 5
packages/ckeditor5-engine/src/model/textproxy.js

@@ -166,19 +166,24 @@ export default class TextProxy {
 	/**
 	 * Checks whether this object is of the given.
 	 *
-	 *		textProxy.is( 'textProxy' ); // -> true
-	 *		textProxy.is( 'model:textProxy' ); // -> true
+	 *		textProxy.is( '$textProxy' ); // -> true
+	 *		textProxy.is( 'model:$textProxy' ); // -> true
 	 *
-	 *		textProxy.is( 'view:textProxy' ); // -> false
+	 *		textProxy.is( 'view:$textProxy' ); // -> false
 	 *		textProxy.is( 'range' ); // -> false
 	 *
 	 * {@link module:engine/model/node~Node#is Check the entire list of model objects} which implement the `is()` method.
 	 *
-	 * @param {String} type
+	 * **Note:** Until version 20.0.0 this method wasn't accepting `'$textProxy'` type. The legacy `'textProxt'` type is still
+	 * accepted for backward compatibility.
+	 *
+	 * @param {String} type Type to check.
 	 * @returns {Boolean}
 	 */
 	is( type ) {
-		return type === 'textProxy' || type === 'model:textProxy';
+		return type === '$textProxy' || type === 'model:$textProxy' ||
+			// This are legacy values kept for backward compatibility.
+			type === 'textProxy' || type === 'model:textProxy';
 	}
 
 	/**

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

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

+ 1 - 1
packages/ckeditor5-engine/src/model/utils/getselectedcontent.js

@@ -70,7 +70,7 @@ export default function getSelectedContent( model, selection ) {
 
 		// Clone the whole contents.
 		for ( const item of flatSubtreeRange.getItems( { shallow: true } ) ) {
-			if ( item.is( 'textProxy' ) ) {
+			if ( item.is( '$textProxy' ) ) {
 				writer.appendText( item.data, item.getAttributes(), frag );
 			} else {
 				writer.append( writer.cloneElement( item, true ), frag );

+ 1 - 1
packages/ckeditor5-engine/src/model/utils/modifyselection.js

@@ -173,7 +173,7 @@ function getCorrectWordBreakPosition( walker, isForward ) {
 			const nextNode = isForward ? walker.position.nodeAfter : walker.position.nodeBefore;
 
 			// Scan only text nodes. Ignore inline elements (like `<softBreak>`).
-			if ( nextNode && nextNode.is( 'text' ) ) {
+			if ( nextNode && nextNode.is( '$text' ) ) {
 				// Check boundary char of an adjacent text node.
 				const boundaryChar = nextNode.data.charAt( isForward ? 0 : nextNode.data.length - 1 );
 

+ 3 - 5
packages/ckeditor5-engine/src/view/attributeelement.js

@@ -145,14 +145,13 @@ export default class AttributeElement extends Element {
 	 * Assuming that the object being checked is an attribute element, you can also check its
 	 * {@link module:engine/view/attributeelement~AttributeElement#name name}:
 	 *
-	 *		attributeElement.is( 'b' ); // -> true if this is a bold element
+	 *		attributeElement.is( 'element', 'b' ); // -> true if this is a bold element
 	 *		attributeElement.is( 'attributeElement', 'b' ); // -> same as above
-	 *		text.is( 'b' ); -> false
+	 *		text.is( 'element', 'b' ); -> false
 	 *
 	 * {@link module:engine/view/node~Node#is Check the entire list of view objects} which implement the `is()` method.
 	 *
-	 * @param {String} type Type to check when `name` parameter is present.
-	 * Otherwise, it acts like the `name` parameter.
+	 * @param {String} type Type to check.
 	 * @param {String} [name] Element name.
 	 * @returns {Boolean}
 	 */
@@ -160,7 +159,6 @@ export default class AttributeElement extends Element {
 		if ( !name ) {
 			return type === 'attributeElement' || type === 'view:attributeElement' ||
 				// From super.is(). This is highly utilised method and cannot call super. See ckeditor/ckeditor5#6529.
-				type === this.name || type === 'view:' + this.name ||
 				type === 'element' || type === 'view:element' ||
 				type === 'node' || type === 'view:node';
 		} else {

+ 3 - 5
packages/ckeditor5-engine/src/view/containerelement.js

@@ -71,14 +71,13 @@ export default class ContainerElement extends Element {
 	 * Assuming that the object being checked is a container element, you can also check its
 	 * {@link module:engine/view/containerelement~ContainerElement#name name}:
 	 *
-	 *		containerElement.is( 'div' ); // -> true if this is a div container element
+	 *		containerElement.is( 'element', 'div' ); // -> true if this is a div container element
 	 *		containerElement.is( 'contaienrElement', 'div' ); // -> same as above
-	 *		text.is( 'div' ); -> false
+	 *		text.is( 'element', 'div' ); -> false
 	 *
 	 * {@link module:engine/view/node~Node#is Check the entire list of view objects} which implement the `is()` method.
 	 *
-	 * @param {String} type Type to check when `name` parameter is present.
-	 * Otherwise, it acts like the `name` parameter.
+	 * @param {String} type Type to check.
 	 * @param {String} [name] Element name.
 	 * @returns {Boolean}
 	 */
@@ -86,7 +85,6 @@ export default class ContainerElement extends Element {
 		if ( !name ) {
 			return type === 'containerElement' || type === 'view:containerElement' ||
 				// From super.is(). This is highly utilised method and cannot call super. See ckeditor/ckeditor5#6529.
-				type === this.name || type === 'view:' + this.name ||
 				type === 'element' || type === 'view:element' ||
 				type === 'node' || type === 'view:node';
 		} else {

+ 1 - 1
packages/ckeditor5-engine/src/view/documentfragment.js

@@ -224,7 +224,7 @@ export default class DocumentFragment {
 	// @if CK_DEBUG_ENGINE //	let string = 'ViewDocumentFragment: [';
 
 	// @if CK_DEBUG_ENGINE //	for ( const child of this.getChildren() ) {
-	// @if CK_DEBUG_ENGINE //		if ( child.is( 'text' ) ) {
+	// @if CK_DEBUG_ENGINE //		if ( child.is( '$text' ) ) {
 	// @if CK_DEBUG_ENGINE //			string += '\n' + '\t'.repeat( 1 ) + child.data;
 	// @if CK_DEBUG_ENGINE //		} else {
 	// @if CK_DEBUG_ENGINE //			string += '\n' + child.printTree( 1 );

+ 48 - 34
packages/ckeditor5-engine/src/view/domconverter.js

@@ -29,16 +29,16 @@ import { isElement } from 'lodash-es';
 const BR_FILLER_REF = BR_FILLER( document );
 
 /**
- * DomConverter is a set of tools to do transformations between DOM nodes and view nodes. It also handles
- * {@link module:engine/view/domconverter~DomConverter#bindElements binding} these nodes.
+ * `DomConverter` is a set of tools to do transformations between DOM nodes and view nodes. It also handles
+ * {@link module:engine/view/domconverter~DomConverter#bindElements bindings} between these nodes.
  *
- * The instance of DOMConverter is available in {@link module:engine/view/view~View#domConverter `editor.editing.view.domConverter`}.
+ * The instance of `DOMConverter` is available under {@link module:engine/view/view~View#domConverter `editor.editing.view.domConverter`}.
  *
- * DomConverter does not check which nodes should be rendered (use {@link module:engine/view/renderer~Renderer}), does not keep a
+ * `DomConverter` does not check which nodes should be rendered (use {@link module:engine/view/renderer~Renderer}), does not keep a
  * state of a tree nor keeps synchronization between tree view and DOM tree (use {@link module:engine/view/document~Document}).
  *
- * DomConverter keeps DOM elements to View element bindings, so when the converter will be destroyed, the binding will
- * be lost. Two converters will keep separate binding maps, so one tree view can be bound with two DOM trees.
+ * `DomConverter` keeps DOM elements to View element bindings, so when the converter gets destroyed, the bindings are lost.
+ * Two converters will keep separate binding maps, so one tree view can be bound with two DOM trees.
  */
 export default class DomConverter {
 	/**
@@ -82,7 +82,7 @@ export default class DomConverter {
 		 * @readonly
 		 * @member {Array.<String>} module:engine/view/domconverter~DomConverter#blockElements
 		 */
-		this.blockElements = [ 'p', 'div', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'li', 'dd', 'dt', 'figcaption' ];
+		this.blockElements = [ 'p', 'div', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'li', 'dd', 'dt', 'figcaption', 'td', 'th' ];
 
 		/**
 		 * Block {@link module:engine/view/filler filler} creator, which is used to create all block fillers during the
@@ -201,7 +201,7 @@ export default class DomConverter {
 	 * @returns {Node|DocumentFragment} Converted node or DocumentFragment.
 	 */
 	viewToDom( viewNode, domDocument, options = {} ) {
-		if ( viewNode.is( 'text' ) ) {
+		if ( viewNode.is( '$text' ) ) {
 			const textData = this._processDataFromViewText( viewNode );
 
 			return domDocument.createTextNode( textData );
@@ -236,6 +236,12 @@ export default class DomConverter {
 					domElement = domDocument.createElement( viewNode.name );
 				}
 
+				// RawElement take care of their children in RawElement#render() method which can be customized
+				// (see https://github.com/ckeditor/ckeditor5/issues/4469).
+				if ( viewNode.is( 'rawElement' ) ) {
+					viewNode.render( domElement );
+				}
+
 				if ( options.bind ) {
 					this.bindElements( domElement, viewNode );
 				}
@@ -317,7 +323,7 @@ export default class DomConverter {
 	viewPositionToDom( viewPosition ) {
 		const viewParent = viewPosition.parent;
 
-		if ( viewParent.is( 'text' ) ) {
+		if ( viewParent.is( '$text' ) ) {
 			const domParent = this.findCorrespondingDomText( viewParent );
 
 			if ( !domParent ) {
@@ -348,7 +354,7 @@ export default class DomConverter {
 			} else {
 				const nodeBefore = viewPosition.nodeBefore;
 
-				domBefore = nodeBefore.is( 'text' ) ?
+				domBefore = nodeBefore.is( '$text' ) ?
 					this.findCorrespondingDomText( nodeBefore ) :
 					this.mapViewToDom( viewPosition.nodeBefore );
 
@@ -392,11 +398,11 @@ export default class DomConverter {
 			return null;
 		}
 
-		// When node is inside UIElement return that UIElement as it's view representation.
-		const uiElement = this.getParentUIElement( domNode, this._domToViewMapping );
+		// When node is inside a UIElement or a RawElement return that parent as it's view representation.
+		const hostElement = this.getHostViewElement( domNode, this._domToViewMapping );
 
-		if ( uiElement ) {
-			return uiElement;
+		if ( hostElement ) {
+			return hostElement;
 		}
 
 		if ( isText( domNode ) ) {
@@ -550,10 +556,10 @@ export default class DomConverter {
 			return this.domPositionToView( domParent.parentNode, indexOf( domParent ) );
 		}
 
-		// If position is somewhere inside UIElement - return position before that element.
+		// If position is somewhere inside UIElement or a RawElement - return position before that element.
 		const viewElement = this.mapDomToView( domParent );
 
-		if ( viewElement && viewElement.is( 'uiElement' ) ) {
+		if ( viewElement && ( viewElement.is( 'uiElement' ) || viewElement.is( 'rawElement' ) ) ) {
 			return ViewPosition._createBefore( viewElement );
 		}
 
@@ -605,14 +611,18 @@ export default class DomConverter {
 	 * {@link module:engine/view/documentfragment~DocumentFragment} for provided DOM element or
 	 * document fragment. If there is no view item {@link module:engine/view/domconverter~DomConverter#bindElements bound}
 	 * to the given DOM - `undefined` is returned.
-	 * For all DOM elements rendered by {@link module:engine/view/uielement~UIElement} that UIElement will be returned.
+	 *
+	 * For all DOM elements rendered by a {@link module:engine/view/uielement~UIElement} or
+	 * a {@link module:engine/view/rawelement~RawElement}, the parent `UIElement` or `RawElement` will be returned.
 	 *
 	 * @param {DocumentFragment|Element} domElementOrDocumentFragment DOM element or document fragment.
 	 * @returns {module:engine/view/element~Element|module:engine/view/documentfragment~DocumentFragment|undefined}
 	 * Corresponding view element, document fragment or `undefined` if no element was bound.
 	 */
 	mapDomToView( domElementOrDocumentFragment ) {
-		return this.getParentUIElement( domElementOrDocumentFragment ) || this._domToViewMapping.get( domElementOrDocumentFragment );
+		const hostElement = this.getHostViewElement( domElementOrDocumentFragment );
+
+		return hostElement || this._domToViewMapping.get( domElementOrDocumentFragment );
 	}
 
 	/**
@@ -625,7 +635,8 @@ export default class DomConverter {
 	 * If this is a first child in the parent and the parent is a {@link module:engine/view/domconverter~DomConverter#bindElements bound}
 	 * element, it is used to find the corresponding text node.
 	 *
-	 * For all text nodes rendered by {@link module:engine/view/uielement~UIElement} that UIElement will be returned.
+	 * For all text nodes rendered by a {@link module:engine/view/uielement~UIElement} or
+	 * a {@link module:engine/view/rawelement~RawElement}, the parent `UIElement` or `RawElement` will be returned.
 	 *
 	 * Otherwise `null` is returned.
 	 *
@@ -640,11 +651,11 @@ export default class DomConverter {
 			return null;
 		}
 
-		// If DOM text was rendered by UIElement - return that element.
-		const uiElement = this.getParentUIElement( domText );
+		// If DOM text was rendered by a UIElement or a RawElement - return this parent element.
+		const hostElement = this.getHostViewElement( domText );
 
-		if ( uiElement ) {
-			return uiElement;
+		if ( hostElement ) {
+			return hostElement;
 		}
 
 		const previousSibling = domText.previousSibling;
@@ -858,13 +869,13 @@ export default class DomConverter {
 	}
 
 	/**
-	 * Returns parent {@link module:engine/view/uielement~UIElement} for provided DOM node. Returns `null` if there is no
-	 * parent UIElement.
+	 * Returns a parent {@link module:engine/view/uielement~UIElement} or {@link module:engine/view/rawelement~RawElement}
+	 * that hosts the provided DOM node. Returns `null` if there is no such parent.
 	 *
 	 * @param {Node} domNode
-	 * @returns {module:engine/view/uielement~UIElement|null}
+	 * @returns {module:engine/view/uielement~UIElement|module:engine/view/rawelement~RawElement|null}
 	 */
-	getParentUIElement( domNode ) {
+	getHostViewElement( domNode ) {
 		const ancestors = getAncestors( domNode );
 
 		// Remove domNode from the list.
@@ -874,7 +885,7 @@ export default class DomConverter {
 			const domNode = ancestors.pop();
 			const viewNode = this._domToViewMapping.get( domNode );
 
-			if ( viewNode && viewNode.is( 'uiElement' ) ) {
+			if ( viewNode && ( viewNode.is( 'uiElement' ) || viewNode.is( 'rawElement' ) ) ) {
 				return viewNode;
 			}
 		}
@@ -886,8 +897,10 @@ export default class DomConverter {
 	 * Checks if given selection's boundaries are at correct places.
 	 *
 	 * The following places are considered as incorrect for selection boundaries:
+	 *
 	 * * before or in the middle of the inline filler sequence,
-	 * * inside the DOM element which represents {@link module:engine/view/uielement~UIElement a view ui element}.
+	 * * inside a DOM element which represents {@link module:engine/view/uielement~UIElement a view UI element},
+	 * * inside a DOM element which represents {@link module:engine/view/rawelement~RawElement a view raw element}.
 	 *
 	 * @param {Selection} domSelection DOM Selection object to be checked.
 	 * @returns {Boolean} `true` if the given selection is at a correct place, `false` otherwise.
@@ -919,9 +932,10 @@ export default class DomConverter {
 
 		const viewParent = this.mapDomToView( domParent );
 
-		// If selection is in `view.UIElement`, it is incorrect. Note that `mapDomToView()` returns `view.UIElement`
-		// also for any dom element that is inside the view ui element (so we don't need to perform any additional checks).
-		if ( viewParent && viewParent.is( 'uiElement' ) ) {
+		// The position is incorrect when anchored inside a UIElement or a RawElement.
+		// Note: In case of UIElement and RawElement, mapDomToView() returns a parent element for any DOM child
+		// so there's no need to perform any additional checks.
+		if ( viewParent && ( viewParent.is( 'uiElement' ) || viewParent.is( 'rawElement' ) ) ) {
 			return false;
 		}
 
@@ -1135,11 +1149,11 @@ export default class DomConverter {
 				return null;
 			}
 			// <br> found – it works like a block boundary, so do not scan further.
-			else if ( value.item.is( 'br' ) ) {
+			else if ( value.item.is( 'element', 'br' ) ) {
 				return null;
 			}
 			// Found a text node in the same container element.
-			else if ( value.item.is( 'textProxy' ) ) {
+			else if ( value.item.is( '$textProxy' ) ) {
 				return value.item;
 			}
 		}

+ 61 - 14
packages/ckeditor5-engine/src/view/downcastwriter.js

@@ -14,6 +14,7 @@ import ContainerElement from './containerelement';
 import AttributeElement from './attributeelement';
 import EmptyElement from './emptyelement';
 import UIElement from './uielement';
+import RawElement from './rawelement';
 import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
 import DocumentFragment from './documentfragment';
 import isIterable from '@ckeditor/ckeditor5-utils/src/isiterable';
@@ -257,6 +258,11 @@ export default class DowncastWriter {
 	 *			return domElement;
 	 *		} );
 	 *
+	 * Unlike {@link #createRawElement raw elements}, UI elements are by no means editor content, for instance,
+	 * they are ignored by the editor selection system.
+	 *
+	 * You should not use UI elements as data containers. Check out {@link #createRawElement} instead.
+	 *
 	 * @param {String} name Name of the element.
 	 * @param {Object} [attributes] Elements attributes.
 	 * @param {Function} [renderFunction] Custom render function.
@@ -272,6 +278,37 @@ export default class DowncastWriter {
 		return uiElement;
 	}
 
+	/**
+	 * Creates a new {@link module:engine/view/rawelement~RawElement}.
+	 *
+	 *		writer.createRawElement( 'span', { id: 'foo-1234' }, function( domElement ) {
+	 *			domElement.innerHTML = '<b>This is the raw content of the raw element.</b>';
+	 *		} );
+	 *
+	 * Raw elements work as data containers ("wrappers", "sandboxes") but their children are not managed or
+	 * even recognized by the editor. This encapsulation allows integrations to maintain custom DOM structures
+	 * in the editor content without, for instance, worrying about compatibility with other editor features.
+	 * Raw elements make a perfect tool for integration with external frameworks and data sources.
+	 *
+	 * Unlike {@link #createUIElement ui elements}, raw elements act like a "real" editor content (similar to
+	 * {@link module:engine/view/containerelement~ContainerElement} or {@link module:engine/view/emptyelement~EmptyElement}),
+	 * and they are considered by the editor selection.
+	 *
+	 * You should not use raw elements to render UI in the editor content. Check out {@link #createUIElement `#createUIElement()`} instead.
+	 *
+	 * @param {String} name Name of the element.
+	 * @param {Object} [attributes] Elements attributes.
+	 * @param {Function} [renderFunction] Custom render function.
+	 * @returns {module:engine/view/rawelement~RawElement} Created element.
+	 */
+	createRawElement( name, attributes, renderFunction ) {
+		const rawElement = new RawElement( this.document, name, attributes );
+
+		rawElement.render = renderFunction || ( () => {} );
+
+		return rawElement;
+	}
+
 	/**
 	 * Adds or overwrite element's attribute with a specified key and value.
 	 *
@@ -529,7 +566,7 @@ export default class DowncastWriter {
 		const positionParent = position.parent;
 
 		// When inside text node - nothing to merge.
-		if ( positionParent.is( 'text' ) ) {
+		if ( positionParent.is( '$text' ) ) {
 			return position;
 		}
 
@@ -553,7 +590,7 @@ export default class DowncastWriter {
 		}
 
 		// When position is between two text nodes.
-		if ( nodeBefore.is( 'text' ) && nodeAfter.is( 'text' ) ) {
+		if ( nodeBefore.is( '$text' ) && nodeAfter.is( '$text' ) ) {
 			return mergeTextNodes( nodeBefore, nodeAfter );
 		}
 		// When position is between two same attribute elements.
@@ -749,7 +786,7 @@ export default class DowncastWriter {
 				// Create range on this element.
 				rangeToRemove = Range._createOn( item );
 				// When range starts inside Text or TextProxy element.
-			} else if ( !current.nextPosition.isAfter( range.start ) && item.is( 'textProxy' ) ) {
+			} else if ( !current.nextPosition.isAfter( range.start ) && item.is( '$textProxy' ) ) {
 				// We need to check if parent of this text matches to given element.
 				const parentElement = item.getAncestors().find( ancestor => {
 					return ancestor.is( 'element' ) && element.isSimilar( ancestor );
@@ -831,7 +868,7 @@ export default class DowncastWriter {
 	 * @param {module:engine/view/range~Range} range Range to wrap.
 	 * @param {module:engine/view/attributeelement~AttributeElement} attribute Attribute element to use as wrapper.
 	 * @returns {module:engine/view/range~Range} range Range after wrapping, spanning over wrapping attribute element.
-	*/
+	 */
 	wrap( range, attribute ) {
 		if ( !( attribute instanceof AttributeElement ) ) {
 			throw new CKEditorError( 'view-writer-wrap-invalid-attribute', this.document );
@@ -1104,10 +1141,11 @@ export default class DowncastWriter {
 
 		while ( i < endOffset ) {
 			const child = parent.getChild( i );
-			const isText = child.is( 'text' );
+			const isText = child.is( '$text' );
 			const isAttribute = child.is( 'attributeElement' );
 			const isEmpty = child.is( 'emptyElement' );
 			const isUI = child.is( 'uiElement' );
+			const isRaw = child.is( 'rawElement' );
 
 			//
 			// (In all examples, assume that `wrapElement` is `<span class="foo">` element.)
@@ -1126,8 +1164,7 @@ export default class DowncastWriter {
 			//
 			// <p>abc</p>                   -->  <p><span class="foo">abc</span></p>
 			// <p><strong>abc</strong></p>  -->  <p><span class="foo"><strong>abc</strong></span></p>
-			//
-			else if ( isText || isEmpty || isUI || ( isAttribute && shouldABeOutsideB( wrapElement, child ) ) ) {
+			else if ( isText || isEmpty || isUI || isRaw || ( isAttribute && shouldABeOutsideB( wrapElement, child ) ) ) {
 				// Clone attribute.
 				const newAttribute = wrapElement._clone();
 
@@ -1334,7 +1371,7 @@ export default class DowncastWriter {
 		}
 
 		// When position is inside text node - break it and place new position between two text nodes.
-		if ( position.parent.is( 'text' ) ) {
+		if ( position.parent.is( '$text' ) ) {
 			position = breakTextNode( position );
 		}
 
@@ -1572,8 +1609,18 @@ export default class DowncastWriter {
 			throw new CKEditorError( 'view-writer-cannot-break-ui-element', this.document );
 		}
 
+		// If position is placed inside RawElement - throw an exception as we cannot break inside.
+		if ( position.parent.is( 'rawElement' ) ) {
+			/**
+			 * Cannot break inside RawElement instance.
+			 *
+			 * @error view-writer-cannot-break-raw-element
+			 */
+			throw new CKEditorError( 'view-writer-cannot-break-raw-element: Cannot break inside a RawElement instance.', this.document );
+		}
+
 		// There are no attributes to break and text nodes breaking is not forced.
-		if ( !forceSplitText && positionParent.is( 'text' ) && isContainerOrFragment( positionParent.parent ) ) {
+		if ( !forceSplitText && positionParent.is( '$text' ) && isContainerOrFragment( positionParent.parent ) ) {
 			return position.clone();
 		}
 
@@ -1583,7 +1630,7 @@ export default class DowncastWriter {
 		}
 
 		// Break text and start again in new position.
-		if ( positionParent.is( 'text' ) ) {
+		if ( positionParent.is( '$text' ) ) {
 			return this._breakAttributes( breakTextNode( position ), forceSplitText );
 		}
 
@@ -1778,13 +1825,13 @@ function shouldABeOutsideB( a, b ) {
 function movePositionToTextNode( position ) {
 	const nodeBefore = position.nodeBefore;
 
-	if ( nodeBefore && nodeBefore.is( 'text' ) ) {
+	if ( nodeBefore && nodeBefore.is( '$text' ) ) {
 		return new Position( nodeBefore, nodeBefore.data.length );
 	}
 
 	const nodeAfter = position.nodeAfter;
 
-	if ( nodeAfter && nodeAfter.is( 'text' ) ) {
+	if ( nodeAfter && nodeAfter.is( '$text' ) ) {
 		return new Position( nodeAfter, 0 );
 	}
 
@@ -1866,13 +1913,13 @@ function validateNodesToInsert( nodes, errorContext ) {
 			throw new CKEditorError( 'view-writer-insert-invalid-node', errorContext );
 		}
 
-		if ( !node.is( 'text' ) ) {
+		if ( !node.is( '$text' ) ) {
 			validateNodesToInsert( node.getChildren(), errorContext );
 		}
 	}
 }
 
-const validNodesToInsert = [ Text, AttributeElement, ContainerElement, EmptyElement, UIElement ];
+const validNodesToInsert = [ Text, AttributeElement, ContainerElement, EmptyElement, RawElement, UIElement ];
 
 // Checks if node is ContainerElement or DocumentFragment, because in most cases they should be treated the same way.
 //

+ 3 - 5
packages/ckeditor5-engine/src/view/editableelement.js

@@ -83,14 +83,13 @@ export default class EditableElement extends ContainerElement {
 	 * Assuming that the object being checked is an editbale element, you can also check its
 	 * {@link module:engine/view/editableelement~EditableElement#name name}:
 	 *
-	 *		editableElement.is( 'div' ); // -> true if this is a div element
+	 *		editableElement.is( 'element', 'div' ); // -> true if this is a div element
 	 *		editableElement.is( 'editableElement', 'div' ); // -> same as above
-	 *		text.is( 'div' ); -> false
+	 *		text.is( 'element', 'div' ); -> false
 	 *
 	 * {@link module:engine/view/node~Node#is Check the entire list of view objects} which implement the `is()` method.
 	 *
-	 * @param {String} type Type to check when `name` parameter is present.
-	 * Otherwise, it acts like the `name` parameter.
+	 * @param {String} type Type to check.
 	 * @param {String} [name] Element name.
 	 * @returns {Boolean}
 	 */
@@ -99,7 +98,6 @@ export default class EditableElement extends ContainerElement {
 			return type === 'editableElement' || type === 'view:editableElement' ||
 				// From super.is(). This is highly utilised method and cannot call super. See ckeditor/ckeditor5#6529.
 				type === 'containerElement' || type === 'view:containerElement' ||
-				type === this.name || type === 'view:' + this.name ||
 				type === 'element' || type === 'view:element' ||
 				type === 'node' || type === 'view:node';
 		} else {

+ 5 - 7
packages/ckeditor5-engine/src/view/element.js

@@ -164,21 +164,19 @@ export default class Element extends Node {
 	 * Assuming that the object being checked is an element, you can also check its
 	 * {@link module:engine/view/element~Element#name name}:
 	 *
-	 *		element.is( 'img' ); // -> true if this is an <img> element
+	 *		element.is( 'element', 'img' ); // -> true if this is an <img> element
 	 *		element.is( 'element', 'img' ); // -> same as above
-	 *		text.is( 'img' ); -> false
+	 *		text.is( 'element', 'img' ); -> false
 	 *
 	 * {@link module:engine/view/node~Node#is Check the entire list of view objects} which implement the `is()` method.
 	 *
-	 * @param {String} type Type to check when `name` parameter is present.
-	 * Otherwise, it acts like the `name` parameter.
+	 * @param {String} type Type to check.
 	 * @param {String} [name] Element name.
 	 * @returns {Boolean}
 	 */
 	is( type, name = null ) {
 		if ( !name ) {
-			return type === this.name || type === 'view:' + this.name ||
-				type === 'element' || type === 'view:element' ||
+			return type === 'element' || type === 'view:element' ||
 				// From super.is(). This is highly utilised method and cannot call super. See ckeditor/ckeditor5#6529.
 				type === 'node' || type === 'view:node';
 		} else {
@@ -834,7 +832,7 @@ export default class Element extends Node {
 	// @if CK_DEBUG_ENGINE //	string += '\t'.repeat( level ) + `<${ this.name }${ convertMapToTags( this.getAttributes() ) }>`;
 
 	// @if CK_DEBUG_ENGINE //	for ( const child of this.getChildren() ) {
-	// @if CK_DEBUG_ENGINE //		if ( child.is( 'text' ) ) {
+	// @if CK_DEBUG_ENGINE //		if ( child.is( '$text' ) ) {
 	// @if CK_DEBUG_ENGINE //			string += '\n' + '\t'.repeat( level + 1 ) + child.data;
 	// @if CK_DEBUG_ENGINE //		} else {
 	// @if CK_DEBUG_ENGINE //			string += '\n' + child.printTree( level + 1 );

+ 3 - 5
packages/ckeditor5-engine/src/view/emptyelement.js

@@ -62,14 +62,13 @@ export default class EmptyElement extends Element {
 	 * Assuming that the object being checked is an empty element, you can also check its
 	 * {@link module:engine/view/emptyelement~EmptyElement#name name}:
 	 *
-	 *		emptyElement.is( 'img' ); // -> true if this is a img element
+	 *		emptyElement.is( 'element', 'img' ); // -> true if this is a img element
 	 *		emptyElement.is( 'emptyElement', 'img' ); // -> same as above
-	 *		text.is( 'img' ); -> false
+	 *		text.is( 'element', 'img' ); -> false
 	 *
 	 * {@link module:engine/view/node~Node#is Check the entire list of view objects} which implement the `is()` method.
 	 *
-	 * @param {String} type Type to check when `name` parameter is present.
-	 * Otherwise, it acts like the `name` parameter.
+	 * @param {String} type Type to check.
 	 * @param {String} [name] Element name.
 	 * @returns {Boolean}
 	 */
@@ -77,7 +76,6 @@ export default class EmptyElement extends Element {
 		if ( !name ) {
 			return type === 'emptyElement' || type === 'view:emptyElement' ||
 				// From super.is(). This is highly utilised method and cannot call super. See ckeditor/ckeditor5#6529.
-				type === this.name || type === 'view:' + this.name ||
 				type === 'element' || type === 'view:element' ||
 				type === 'node' || type === 'view:node';
 		} else {

+ 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.
+ *
+ * @type {String}
  */
 export const INLINE_FILLER = ( () => {
 	let inlineFiller = '';

+ 2 - 3
packages/ckeditor5-engine/src/view/node.js

@@ -312,8 +312,7 @@ export default class Node {
 	 *
 	 * By using this method it is also possible to check a name of an element:
 	 *
-	 *		imgElement.is( 'img' ); // -> true
-	 *		imgElement.is( 'element', 'img' ); // -> same as above
+	 *		imgElement.is( 'element', 'img' ); // -> true
 	 *		imgElement.is( 'view:element', 'img' ); // -> same as above, but more precise
 	 *
 	 * The list of view objects which implement the `is()` method:
@@ -335,7 +334,7 @@ export default class Node {
 	 * * {@link module:engine/view/uielement~UIElement#is `UIElement#is()`}
 	 *
 	 * @method #is
-	 * @param {String} type
+	 * @param {String} type Type to check.
 	 * @returns {Boolean}
 	 */
 	is( type ) {

+ 5 - 5
packages/ckeditor5-engine/src/view/observer/mutationobserver.js

@@ -150,8 +150,8 @@ export default class MutationObserver extends Observer {
 			if ( mutation.type === 'childList' ) {
 				const element = domConverter.mapDomToView( mutation.target );
 
-				// Do not collect mutations from UIElements.
-				if ( element && element.is( 'uiElement' ) ) {
+				// Do not collect mutations from UIElements and RawElements.
+				if ( element && ( element.is( 'uiElement' ) || element.is( 'rawElement' ) ) ) {
 					continue;
 				}
 
@@ -165,8 +165,8 @@ export default class MutationObserver extends Observer {
 		for ( const mutation of domMutations ) {
 			const element = domConverter.mapDomToView( mutation.target );
 
-			// Do not collect mutations from UIElements.
-			if ( element && element.is( 'uiElement' ) ) {
+			// Do not collect mutations from UIElements and RawElements.
+			if ( element && ( element.is( 'uiElement' ) || element.is( 'rawElement' ) ) ) {
 				continue;
 			}
 
@@ -264,7 +264,7 @@ export default class MutationObserver extends Observer {
 				return true;
 			}
 			// Texts.
-			else if ( child1.is( 'text' ) && child2.is( 'text' ) ) {
+			else if ( child1.is( '$text' ) && child2.is( '$text' ) ) {
 				return child1.data === child2.data;
 			}
 

+ 6 - 6
packages/ckeditor5-engine/src/view/position.js

@@ -59,7 +59,7 @@ export default class Position {
 	 * @type {module:engine/view/node~Node|null}
 	 */
 	get nodeAfter() {
-		if ( this.parent.is( 'text' ) ) {
+		if ( this.parent.is( '$text' ) ) {
 			return null;
 		}
 
@@ -74,7 +74,7 @@ export default class Position {
 	 * @type {module:engine/view/node~Node|null}
 	 */
 	get nodeBefore() {
-		if ( this.parent.is( 'text' ) ) {
+		if ( this.parent.is( '$text' ) ) {
 			return null;
 		}
 
@@ -98,7 +98,7 @@ export default class Position {
 	 * @type {Boolean}
 	 */
 	get isAtEnd() {
-		const endOffset = this.parent.is( 'text' ) ? this.parent.data.length : this.parent.childCount;
+		const endOffset = this.parent.is( '$text' ) ? this.parent.data.length : this.parent.childCount;
 
 		return this.offset === endOffset;
 	}
@@ -346,7 +346,7 @@ export default class Position {
 			const node = itemOrPosition;
 
 			if ( offset == 'end' ) {
-				offset = node.is( 'text' ) ? node.data.length : node.childCount;
+				offset = node.is( '$text' ) ? node.data.length : node.childCount;
 			} else if ( offset == 'before' ) {
 				return this._createBefore( node );
 			} else if ( offset == 'after' ) {
@@ -378,7 +378,7 @@ export default class Position {
 	 */
 	static _createAfter( item ) {
 		// TextProxy is not a instance of Node so we need do handle it in specific way.
-		if ( item.is( 'textProxy' ) ) {
+		if ( item.is( '$textProxy' ) ) {
 			return new Position( item.textNode, item.offsetInText + item.data.length );
 		}
 
@@ -404,7 +404,7 @@ export default class Position {
 	 */
 	static _createBefore( item ) {
 		// TextProxy is not a instance of Node so we need do handle it in specific way.
-		if ( item.is( 'textProxy' ) ) {
+		if ( item.is( '$textProxy' ) ) {
 			return new Position( item.textNode, item.offsetInText );
 		}
 

+ 7 - 7
packages/ckeditor5-engine/src/view/range.js

@@ -113,11 +113,11 @@ export default class Range {
 		let end = this.end.getLastMatchingPosition( enlargeTrimSkip );
 
 		// Fix positions, in case if they are in Text node.
-		if ( start.parent.is( 'text' ) && start.isAtStart ) {
+		if ( start.parent.is( '$text' ) && start.isAtStart ) {
 			start = Position._createBefore( start.parent );
 		}
 
-		if ( end.parent.is( 'text' ) && end.isAtEnd ) {
+		if ( end.parent.is( '$text' ) && end.isAtEnd ) {
 			end = Position._createAfter( end.parent );
 		}
 
@@ -153,11 +153,11 @@ export default class Range {
 		const nodeBeforeEnd = end.nodeBefore;
 
 		// Because TreeWalker prefers positions next to text node, we need to move them manually into these text nodes.
-		if ( nodeAfterStart && nodeAfterStart.is( 'text' ) ) {
+		if ( nodeAfterStart && nodeAfterStart.is( '$text' ) ) {
 			start = new Position( nodeAfterStart, 0 );
 		}
 
-		if ( nodeBeforeEnd && nodeBeforeEnd.is( 'text' ) ) {
+		if ( nodeBeforeEnd && nodeBeforeEnd.is( '$text' ) ) {
 			end = new Position( nodeBeforeEnd, nodeBeforeEnd.data.length );
 		}
 
@@ -359,11 +359,11 @@ export default class Range {
 		// These are basically the same range, only the difference is if the range position is at
 		// at the end/at the beginning of a text node or just before/just after the text node.
 		//
-		if ( this.start.parent.is( 'text' ) && this.start.isAtEnd && this.start.parent.nextSibling ) {
+		if ( this.start.parent.is( '$text' ) && this.start.isAtEnd && this.start.parent.nextSibling ) {
 			nodeAfterStart = this.start.parent.nextSibling;
 		}
 
-		if ( this.end.parent.is( 'text' ) && this.end.isAtStart && this.end.parent.previousSibling ) {
+		if ( this.end.parent.is( '$text' ) && this.end.isAtStart && this.end.parent.previousSibling ) {
 			nodeBeforeEnd = this.end.parent.previousSibling;
 		}
 
@@ -517,7 +517,7 @@ export default class Range {
 	 * @returns {module:engine/view/range~Range}
 	 */
 	static _createOn( item ) {
-		const size = item.is( 'textProxy' ) ? item.offsetSize : 1;
+		const size = item.is( '$textProxy' ) ? item.offsetSize : 1;
 
 		return this._createFromPositionAndShift( Position._createBefore( item ), size );
 	}

+ 145 - 0
packages/ckeditor5-engine/src/view/rawelement.js

@@ -0,0 +1,145 @@
+/**
+ * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+/**
+ * @module engine/view/rawelement
+ */
+
+import Element from './element';
+import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
+import Node from './node';
+
+/**
+ * The raw element class.
+ *
+ * Raw elements work as data containers ("wrappers", "sandboxes") but their children are not managed or
+ * even recognized by the editor. This encapsulation allows integrations to maintain custom DOM structures
+ * in the editor content without, for instance, worrying about compatibility with other editor features.
+ * Raw elements make a perfect tool for integration with external frameworks and data sources.
+ *
+ * Unlike {@link module:engine/view/uielement~UIElement ui elements}, raw elements act like a real editor
+ * content (similar to {@link module:engine/view/containerelement~ContainerElement} or
+ * {@link module:engine/view/emptyelement~EmptyElement}), they are considered by the editor selection and
+ * {@link module:widget/utils~toWidget they can work as widgets}.
+ *
+ * To create a new raw element use the
+ * {@link module:engine/view/downcastwriter~DowncastWriter#createRawElement `downcastWriter#createRawElement()`} method.
+ *
+ * @extends module:engine/view/element~Element
+ */
+export default class RawElement extends Element {
+	/**
+	 * Creates new instance of RawElement.
+	 *
+	 * Throws {@link module:utils/ckeditorerror~CKEditorError CKEditorError} `view-rawelement-cannot-add` when `children` parameter
+	 * is passed, to inform that usage of `RawElement` is incorrect (adding child nodes to `RawElement` is forbidden).
+	 *
+	 * @see module:engine/view/downcastwriter~DowncastWriter#createRawElement
+	 * @protected
+	 * @param {module:engine/view/document~Document} document The document instance to which this element belongs.
+	 * @param {String} name Node name.
+	 * @param {Object|Iterable} [attrs] Collection of attributes.
+	 * @param {module:engine/view/node~Node|Iterable.<module:engine/view/node~Node>} [children]
+	 * A list of nodes to be inserted into created element.
+	 */
+	constructor( document, name, attrs, children ) {
+		super( document, name, attrs, children );
+
+		/**
+		 * Returns `null` because filler is not needed for RawElements.
+		 *
+		 * @method #getFillerOffset
+		 * @returns {null} Always returns null.
+		 */
+		this.getFillerOffset = getFillerOffset;
+	}
+
+	/**
+	 * Checks whether this object is of the given type or name.
+	 *
+	 *		rawElement.is( 'rawElement' ); // -> true
+	 *		rawElement.is( 'element' ); // -> true
+	 *		rawElement.is( 'node' ); // -> true
+	 *		rawElement.is( 'view:rawElement' ); // -> true
+	 *		rawElement.is( 'view:element' ); // -> true
+	 *		rawElement.is( 'view:node' ); // -> true
+	 *
+	 *		rawElement.is( 'model:element' ); // -> false
+	 *		rawElement.is( 'documentFragment' ); // -> false
+	 *
+	 * Assuming that the object being checked is a raw element, you can also check its
+	 * {@link module:engine/view/rawelement~RawElement#name name}:
+	 *
+	 *		rawElement.is( 'img' ); // -> true if this is a img element
+	 *		rawElement.is( 'rawElement', 'img' ); // -> same as above
+	 *		text.is( 'img' ); -> false
+	 *
+	 * {@link module:engine/view/node~Node#is Check the entire list of view objects} which implement the `is()` method.
+	 *
+	 * @param {String} type Type to check when `name` parameter is present.
+	 * Otherwise, it acts like the `name` parameter.
+	 * @param {String} [name] Element name.
+	 * @returns {Boolean}
+	 */
+	is( type, name = null ) {
+		if ( !name ) {
+			return type === 'rawElement' || type === 'view:rawElement' ||
+				// From super.is(). This is highly utilised method and cannot call super. See ckeditor/ckeditor5#6529.
+				type === this.name || type === 'view:' + this.name ||
+				type === 'element' || type === 'view:element' ||
+				type === 'node' || type === 'view:node';
+		} else {
+			return name === this.name && (
+				type === 'rawElement' || type === 'view:rawElement' ||
+				type === 'element' || type === 'view:element'
+			);
+		}
+	}
+
+	/**
+	 * Overrides {@link module:engine/view/element~Element#_insertChild} method.
+	 * Throws {@link module:utils/ckeditorerror~CKEditorError CKEditorError} `view-rawelement-cannot-add` to prevent
+	 * adding any child nodes to a `RawElement`.
+	 *
+	 * @protected
+	 */
+	_insertChild( index, nodes ) {
+		if ( nodes && ( nodes instanceof Node || Array.from( nodes ).length > 0 ) ) {
+			/**
+			 * Cannot add children to a {@link module:engine/view/rawelement~RawElement} instance.
+			 *
+			 * @error view-rawelement-cannot-add
+			 */
+			throw new CKEditorError(
+				'view-rawelement-cannot-add: Cannot add child nodes to a RawElement instance.',
+				[ this, nodes ]
+			);
+		}
+	}
+
+	/**
+	 * Allows rendering the children of a {@link module:engine/view/rawelement~RawElement} on the DOM level.
+	 * This method is called by the {@link module:engine/view/domconverter~DomConverter} with the raw DOM element
+	 * passed as an argument leaving the number and shape of the children up to the integrator.
+	 *
+	 * This method **must be defined** for the `RawElement` to work:
+	 *
+	 *		const myRawElement = downcastWriter.createRawElement( 'div' );
+	 *
+	 *		myRawElement.render = function( domElement ) {
+	 *			domElement.innerHTML = '<b>This is the raw content of myRawElement.</b>';
+	 *		};
+	 *
+	 * @method #render
+	 * @param {HTMLElement} domElement The native DOM element representing the raw view element.
+	 */
+}
+
+// Returns `null` because block filler is not needed for RawElements.
+//
+// @returns {null}
+function getFillerOffset() {
+	return null;
+}

+ 6 - 6
packages/ckeditor5-engine/src/view/renderer.js

@@ -273,10 +273,10 @@ export default class Renderer {
 					const deleteIndex = counter.equal + counter.delete;
 					const viewChild = viewElement.getChild( insertIndex );
 
-					// The 'uiElement' is a special one and its children are not stored in a view (#799),
-					// so we cannot use it with replacing flow (since it uses view children during rendering
-					// which will always result in rendering empty element).
-					if ( viewChild && !viewChild.is( 'uiElement' ) ) {
+					// UIElement and RawElement are special cases. Their children are not stored in a view (#799)
+					// so we cannot use them with replacing flow (since they use view children during rendering
+					// which will always result in rendering empty elements).
+					if ( viewChild && !( viewChild.is( 'uiElement' ) || viewChild.is( 'rawElement' ) ) ) {
 						this._updateElementMappings( viewChild, actualDomChildren[ deleteIndex ] );
 					}
 
@@ -332,7 +332,7 @@ export default class Renderer {
 	_getInlineFillerPosition() {
 		const firstPos = this.selection.getFirstPosition();
 
-		if ( firstPos.parent.is( 'text' ) ) {
+		if ( firstPos.parent.is( '$text' ) ) {
 			return ViewPosition._createBefore( this.selection.getFirstPosition().parent );
 		} else {
 			return firstPos;
@@ -659,7 +659,7 @@ export default class Renderer {
 			return;
 		}
 
-		if ( viewNode.is( 'text' ) ) {
+		if ( viewNode.is( '$text' ) ) {
 			this.markedTexts.add( viewNode );
 		} else if ( viewNode.is( 'element' ) ) {
 			for ( const child of viewNode.getChildren() ) {

+ 3 - 5
packages/ckeditor5-engine/src/view/rooteditableelement.js

@@ -55,14 +55,13 @@ export default class RootEditableElement extends EditableElement {
 	 * Assuming that the object being checked is a root editable element, you can also check its
 	 * {@link module:engine/view/rooteditableelement~RootEditableElement#name name}:
 	 *
-	 *		rootEditableElement.is( 'div' ); // -> true if this is a div root editable element
+	 *		rootEditableElement.is( 'element', 'div' ); // -> true if this is a div root editable element
 	 *		rootEditableElement.is( 'rootElement', 'div' ); // -> same as above
-	 *		text.is( 'div' ); -> false
+	 *		text.is( 'element', 'div' ); -> false
 	 *
 	 * {@link module:engine/view/node~Node#is Check the entire list of view objects} which implement the `is()` method.
 	 *
-	 * @param {String} type Type to check when `name` parameter is present.
-	 * Otherwise, it acts like the `name` parameter.
+	 * @param {String} type Type to check.
 	 * @param {String} [name] Element name.
 	 * @returns {Boolean}
 	 */
@@ -72,7 +71,6 @@ export default class RootEditableElement extends EditableElement {
 				// From super.is(). This is highly utilised method and cannot call super. See ckeditor/ckeditor5#6529.
 				type === 'editableElement' || type === 'view:editableElement' ||
 				type === 'containerElement' || type === 'view:containerElement' ||
-				type === this.name || type === 'view:' + this.name ||
 				type === 'element' || type === 'view:element' ||
 				type === 'node' || type === 'view:node';
 		} else {

+ 15 - 13
packages/ckeditor5-engine/src/view/text.js

@@ -45,22 +45,27 @@ export default class Text extends Node {
 	/**
 	 * Checks whether this object is of the given type.
 	 *
-	 *		text.is( 'text' ); // -> true
+	 *		text.is( '$text' ); // -> true
 	 *		text.is( 'node' ); // -> true
-	 *		text.is( 'view:text' ); // -> true
+	 *		text.is( 'view:$text' ); // -> true
 	 *		text.is( 'view:node' ); // -> true
 	 *
-	 *		text.is( 'model:text' ); // -> false
+	 *		text.is( 'model:$text' ); // -> false
 	 *		text.is( 'element' ); // -> false
 	 *		text.is( 'range' ); // -> false
 	 *
 	 * {@link module:engine/view/node~Node#is Check the entire list of view objects} which implement the `is()` method.
 	 *
-	 * @param {String} type
+	 * **Note:** Until version 20.0.0 this method wasn't accepting `'$text'` type. The legacy `'text'` type is still
+	 * accepted for backward compatibility.
+	 *
+	 * @param {String} type Type to check.
 	 * @returns {Boolean}
 	 */
 	is( type ) {
-		return type === 'text' || type === 'view:text' ||
+		return type === '$text' || type === 'view:$text' ||
+			// This are legacy values kept for backward compatibility.
+			type === 'text' || type === 'view:text' ||
 			// From super.is(). This is highly utilised method and cannot call super. See ckeditor/ckeditor5#6529.
 			type === 'node' || type === 'view:node';
 	}
@@ -76,7 +81,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 bar = downcastWriter.createText( 'bar' );
@@ -86,6 +93,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.
 	 *
+	 * The setter sets data and fires the {@link module:engine/view/node~Node#event:change:text change event}.
+	 *
 	 * @protected
 	 * @type {String}
 	 */
@@ -93,13 +102,6 @@ export default class Text extends Node {
 		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 ) {
 		this._fireChange( 'text', this );
 

+ 10 - 5
packages/ckeditor5-engine/src/view/textproxy.js

@@ -143,20 +143,25 @@ export default class TextProxy {
 	/**
 	 * Checks whether this object is of the given type.
 	 *
-	 *		textProxy.is( 'textProxy' ); // -> true
-	 *		textProxy.is( 'view:textProxy' ); // -> true
+	 *		textProxy.is( '$textProxy' ); // -> true
+	 *		textProxy.is( 'view:$textProxy' ); // -> true
 	 *
-	 *		textProxy.is( 'model:textProxy' ); // -> false
+	 *		textProxy.is( 'model:$textProxy' ); // -> false
 	 *		textProxy.is( 'element' ); // -> false
 	 *		textProxy.is( 'range' ); // -> false
 	 *
 	 * {@link module:engine/view/node~Node#is Check the entire list of view objects} which implement the `is()` method.
 	 *
-	 * @param {String} type
+	 * **Note:** Until version 20.0.0 this method wasn't accepting `'$textProxy'` type. The legacy `'textProxy'` type is still
+	 * accepted for backward compatibility.
+	 *
+	 * @param {String} type Type to check.
 	 * @returns {Boolean}
 	 */
 	is( type ) {
-		return type === 'textProxy' || type === 'view:textProxy';
+		return type === '$textProxy' || type === 'view:$textProxy' ||
+			// This are legacy values kept for backward compatibility.
+			type === 'textProxy' || type === 'view:textProxy';
 	}
 
 	/**

+ 3 - 5
packages/ckeditor5-engine/src/view/uielement.js

@@ -75,14 +75,13 @@ export default class UIElement extends Element {
 	 * Assuming that the object being checked is an ui element, you can also check its
 	 * {@link module:engine/view/uielement~UIElement#name name}:
 	 *
-	 *		uiElement.is( 'span' ); // -> true if this is a span ui element
+	 *		uiElement.is( 'element', 'span' ); // -> true if this is a span ui element
 	 *		uiElement.is( 'uiElement', 'span' ); // -> same as above
-	 *		text.is( 'span' ); -> false
+	 *		text.is( 'element', 'span' ); -> false
 	 *
 	 * {@link module:engine/view/node~Node#is Check the entire list of view objects} which implement the `is()` method.
 	 *
-	 * @param {String} type Type to check when `name` parameter is present.
-	 * Otherwise, it acts like the `name` parameter.
+	 * @param {String} type Type to check.
 	 * @param {String} [name] Element name.
 	 * @returns {Boolean}
 	 */
@@ -90,7 +89,6 @@ export default class UIElement extends Element {
 		if ( !name ) {
 			return type === 'uiElement' || type === 'view:uiElement' ||
 				// From super.is(). This is highly utilised method and cannot call super. See ckeditor/ckeditor5#6529.
-				type === this.name || type === 'view:' + this.name ||
 				type === 'element' || type === 'view:element' ||
 				type === 'node' || type === 'view:node';
 		} else {

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

@@ -258,6 +258,16 @@ describe( 'DataController', () => {
 	} );
 
 	describe( 'set()', () => {
+		it( 'should be decorated', () => {
+			const spy = sinon.spy();
+
+			data.on( 'set', spy );
+
+			data.set( 'foo bar' );
+
+			sinon.assert.calledWithExactly( spy, sinon.match.any, [ 'foo bar' ] );
+		} );
+
 		it( 'should set data to default main root', () => {
 			schema.extend( '$text', { allowIn: '$root' } );
 			data.set( 'foo' );
@@ -342,8 +352,8 @@ describe( 'DataController', () => {
 
 			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 => {
 				writer.addMarker( 'marker', { range: writer.createRangeIn( modelDocument.getRoot() ), usingOperation: true } );

+ 3 - 3
packages/ckeditor5-engine/tests/conversion/conversion.js

@@ -334,7 +334,7 @@ describe( 'Conversion', () => {
 						},
 						// Duplicates the `x-bold` from above to test if only one attribute would be converted.
 						// It should not convert to both bold & x-bold.
-						viewElement => viewElement.is( 'x-bold' ) ? { name: 'x-bold' } : null
+						viewElement => viewElement.is( 'element', 'x-bold' ) ? { name: 'x-bold' } : null
 					]
 				} );
 
@@ -426,7 +426,7 @@ describe( 'Conversion', () => {
 
 							const size = Number( match[ 1 ] );
 
-							if ( viewElement.is( 'span' ) && size > 10 ) {
+							if ( viewElement.is( 'element', 'span' ) && size > 10 ) {
 								return { name: true, style: [ 'font-size' ] };
 							}
 
@@ -447,7 +447,7 @@ describe( 'Conversion', () => {
 
 							const size = Number( match[ 1 ] );
 
-							if ( viewElement.is( 'span' ) && size < 10 ) {
+							if ( viewElement.is( 'element', 'span' ) && size < 10 ) {
 								return { name: true, style: [ 'font-size' ] };
 							}
 

+ 482 - 1
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()', () => {
 		it( 'should be chainable', () => {
 			expect( downcastHelpers.markerToHighlight( { model: 'comment', view: { classes: 'comment' } } ) ).to.equal( downcastHelpers );
@@ -2277,7 +2758,7 @@ describe( 'downcast selection converters', () => {
 				for ( const range of selection.getRanges() ) {
 					const node = range.start.parent;
 
-					if ( !!node && node.is( 'td' ) ) {
+					if ( !!node && node.is( 'element', 'td' ) ) {
 						conversionApi.consumable.consume( selection, 'selection' );
 
 						const viewNode = conversionApi.mapper.toViewElement( node );

この差分においてかなりの量のファイルが変更されているため、一部のファイルを表示していません