Browse Source

Merge branch 'master' into i/6170

Marek Lewandowski 5 năm trước cách đây
mục cha
commit
b4ebc9b4b2
64 tập tin đã thay đổi với 1967 bổ sung1042 xóa
  1. 2 6
      docs/_snippets/framework/tutorials/using-react-in-widget.js
  2. 2 1
      docs/framework/guides/architecture/editing-engine.md
  3. 4 12
      docs/framework/guides/tutorials/using-react-in-a-widget.md
  4. 5 5
      package.json
  5. BIN
      packages/ckeditor5-basic-styles/docs/assets/img/typing-after-code.gif
  6. BIN
      packages/ckeditor5-basic-styles/docs/assets/img/typing-inside-code.gif
  7. 12 0
      packages/ckeditor5-basic-styles/docs/features/basic-styles.md
  8. 1 0
      packages/ckeditor5-basic-styles/package.json
  9. 16 0
      packages/ckeditor5-basic-styles/src/code/codeediting.js
  10. 35 0
      packages/ckeditor5-basic-styles/tests/code/codeediting.js
  11. 4 0
      packages/ckeditor5-basic-styles/theme/code.css
  12. 3 0
      packages/ckeditor5-core/src/command.js
  13. 2 1
      packages/ckeditor5-core/src/commandcollection.js
  14. 2 1
      packages/ckeditor5-core/src/editor/editor.js
  15. 3 1
      packages/ckeditor5-core/src/multicommand.js
  16. 14 0
      packages/ckeditor5-core/tests/commandcollection.js
  17. 19 0
      packages/ckeditor5-core/tests/editor/editor.js
  18. 15 0
      packages/ckeditor5-core/tests/multicommand.js
  19. 11 0
      packages/ckeditor5-engine/src/controller/datacontroller.js
  20. 25 7
      packages/ckeditor5-engine/src/dev-utils/view.js
  21. 24 42
      packages/ckeditor5-engine/src/model/documentselection.js
  22. 42 28
      packages/ckeditor5-engine/src/view/domconverter.js
  23. 51 4
      packages/ckeditor5-engine/src/view/downcastwriter.js
  24. 4 4
      packages/ckeditor5-engine/src/view/observer/mutationobserver.js
  25. 145 0
      packages/ckeditor5-engine/src/view/rawelement.js
  26. 4 4
      packages/ckeditor5-engine/src/view/renderer.js
  27. 10 0
      packages/ckeditor5-engine/tests/controller/datacontroller.js
  28. 51 3
      packages/ckeditor5-engine/tests/dev-utils/view.js
  29. 28 6
      packages/ckeditor5-engine/tests/model/documentselection.js
  30. 150 0
      packages/ckeditor5-engine/tests/view/domconverter/rawelement.js
  31. 4 4
      packages/ckeditor5-engine/tests/view/domconverter/uielement.js
  32. 22 0
      packages/ckeditor5-engine/tests/view/downcastwriter/breakattributes.js
  33. 11 0
      packages/ckeditor5-engine/tests/view/downcastwriter/clear.js
  34. 12 0
      packages/ckeditor5-engine/tests/view/downcastwriter/insert.js
  35. 7 0
      packages/ckeditor5-engine/tests/view/downcastwriter/mergeattributes.js
  36. 24 0
      packages/ckeditor5-engine/tests/view/downcastwriter/move.js
  37. 29 0
      packages/ckeditor5-engine/tests/view/downcastwriter/remove.js
  38. 20 0
      packages/ckeditor5-engine/tests/view/downcastwriter/unwrap.js
  39. 19 0
      packages/ckeditor5-engine/tests/view/downcastwriter/wrap.js
  40. 32 0
      packages/ckeditor5-engine/tests/view/downcastwriter/writer.js
  41. 57 0
      packages/ckeditor5-engine/tests/view/observer/mutationobserver.js
  42. 119 0
      packages/ckeditor5-engine/tests/view/rawelement.js
  43. 69 2
      packages/ckeditor5-engine/tests/view/renderer.js
  44. 1 1
      packages/ckeditor5-markdown-gfm/docs/features/markdown.md
  45. 1 5
      packages/ckeditor5-media-embed/src/mediaregistry.js
  46. 0 10
      packages/ckeditor5-media-embed/src/utils.js
  47. 65 0
      packages/ckeditor5-media-embed/tests/integration.js
  48. 16 16
      packages/ckeditor5-media-embed/tests/mediaembedediting.js
  49. 34 0
      packages/ckeditor5-table/tests/tableselection-integration.js
  50. 159 316
      packages/ckeditor5-typing/src/twostepcaretmovement.js
  51. 1 1
      packages/ckeditor5-typing/tests/inputcommand.js
  52. 3 0
      packages/ckeditor5-typing/tests/manual/two-step-caret.html
  53. 260 196
      packages/ckeditor5-typing/tests/twostepcaretmovement.js
  54. 2 2
      packages/ckeditor5-typing/tests/utils/inlinehighlight.js
  55. 26 22
      packages/ckeditor5-ui/src/toolbar/toolbarview.js
  56. 11 0
      packages/ckeditor5-ui/tests/toolbar/toolbarview.js
  57. 2 0
      packages/ckeditor5-undo/src/basecommand.js
  58. 8 0
      packages/ckeditor5-undo/tests/redocommand.js
  59. 8 0
      packages/ckeditor5-undo/tests/undocommand.js
  60. 16 0
      packages/ckeditor5-widget/src/utils.js
  61. 56 31
      packages/ckeditor5-widget/src/widgetresize/resizer.js
  62. 18 0
      packages/ckeditor5-widget/tests/utils.js
  63. 30 0
      packages/ckeditor5-widget/tests/widgetresize/resizer.js
  64. 141 311
      yarn.lock

+ 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 );

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

@@ -226,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:
 
@@ -234,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:

+ 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 );

+ 5 - 5
package.json

@@ -77,11 +77,11 @@
   },
   "devDependencies": {
     "@ckeditor/ckeditor5-comments": "^20.0.0",
-    "@ckeditor/ckeditor5-dev-docs": "^22.0.0",
-    "@ckeditor/ckeditor5-dev-env": "^22.0.0",
-    "@ckeditor/ckeditor5-dev-tests": "^22.0.0",
-    "@ckeditor/ckeditor5-dev-utils": "^22.0.0",
-    "@ckeditor/ckeditor5-dev-webpack-plugin": "^22.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",

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);
+}

+ 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 );
 	}
 
 	/**

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

@@ -284,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 );
 	}
 
 	/**

+ 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 );

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

@@ -133,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.
@@ -317,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.
 	 */
@@ -452,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 );

+ 25 - 7
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 = {} ) {
@@ -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 ) );
@@ -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 ) );

+ 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 ) );
-}

+ 42 - 28
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 {
 	/**
@@ -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 );
 				}
@@ -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;
 		}
 

+ 51 - 4
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.
 	 *
@@ -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 );
@@ -1108,6 +1145,7 @@ export default class DowncastWriter {
 			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();
 
@@ -1572,6 +1609,16 @@ 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 ) ) {
 			return position.clone();
@@ -1872,7 +1919,7 @@ function validateNodesToInsert( nodes, 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.
 //

+ 4 - 4
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;
 			}
 

+ 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;
+}

+ 4 - 4
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 ] );
 					}
 

+ 10 - 0
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' );

+ 51 - 3
packages/ckeditor5-engine/tests/dev-utils/view.js

@@ -14,6 +14,7 @@ import AttributeElement from '../../src/view/attributeelement';
 import ContainerElement from '../../src/view/containerelement';
 import EmptyElement from '../../src/view/emptyelement';
 import UIElement from '../../src/view/uielement';
+import RawElement from '../../src/view/rawelement';
 import Text from '../../src/view/text';
 import DocumentSelection from '../../src/view/documentselection';
 import Range from '../../src/view/range';
@@ -404,6 +405,41 @@ describe( 'view test utils', () => {
 				.to.equal( '<container:p><ui:span><b>foo</b></ui:span></container:p>' );
 		} );
 
+		it( 'should stringify a RawElement', () => {
+			const span = new RawElement( viewDocument, 'span' );
+			const p = new ContainerElement( viewDocument, 'p', null, span );
+			expect( stringify( p, null, { showType: true } ) )
+				.to.equal( '<container:p><raw:span></raw:span></container:p>' );
+		} );
+
+		it( 'should not stringify the inner RawElement content (renderRawElements=false)', () => {
+			const span = new RawElement( viewDocument, 'span' );
+
+			span.render = function( domDocument ) {
+				const domElement = this.toDomElement( domDocument );
+
+				domElement.innerHTML = '<b>foo</b>';
+
+				return domElement;
+			};
+
+			const p = new ContainerElement( viewDocument, 'p', null, span );
+			expect( stringify( p, null, { showType: true } ) )
+				.to.equal( '<container:p><raw:span></raw:span></container:p>' );
+		} );
+
+		it( 'should stringify a RawElement, (renderRawElements=true)', () => {
+			const span = new RawElement( viewDocument, 'span' );
+
+			span.render = function( domElement ) {
+				domElement.innerHTML = '<b>foo</b>';
+			};
+
+			const p = new ContainerElement( viewDocument, 'p', null, span );
+			expect( stringify( p, null, { showType: true, renderRawElements: true } ) )
+				.to.equal( '<container:p><raw:span><b>foo</b></raw:span></container:p>' );
+		} );
+
 		it( 'should sort classes in specified element', () => {
 			const text = new Text( viewDocument, 'foobar' );
 			const b = new Element( viewDocument, 'b', {
@@ -714,29 +750,41 @@ describe( 'view test utils', () => {
 			expect( stringify( data ) ).to.equal( '<p><span>text</span><b>test</b></p>' );
 		} );
 
-		it( 'should parse EmptyElement', () => {
+		it( 'should parse an EmptyElement', () => {
 			const parsed = parse( '<empty:img></empty:img>' );
 
 			expect( parsed ).to.be.instanceof( EmptyElement );
 		} );
 
-		it( 'should parse UIElement', () => {
+		it( 'should parse a UIElement', () => {
 			const parsed = parse( '<ui:span></ui:span>' );
 
 			expect( parsed ).to.be.instanceof( UIElement );
 		} );
 
+		it( 'should parse a RawElement', () => {
+			const parsed = parse( '<raw:span></raw:span>' );
+
+			expect( parsed ).to.be.instanceof( RawElement );
+		} );
+
 		it( 'should throw an error if EmptyElement is not empty', () => {
 			expect( () => {
 				parse( '<empty:img>foo bar</empty:img>' );
 			} ).to.throw( Error, 'Parse error - cannot parse inside EmptyElement.' );
 		} );
 
-		it( 'should throw an error if UIElement is not empty', () => {
+		it( 'should throw an error if a UIElement is not empty', () => {
 			expect( () => {
 				parse( '<ui:span>foo bar</ui:span>' );
 			} ).to.throw( Error, 'Parse error - cannot parse inside UIElement.' );
 		} );
+
+		it( 'should throw an error if a RawElement is not empty', () => {
+			expect( () => {
+				parse( '<raw:span>foo bar</raw:span>' );
+			} ).to.throw( Error, 'Parse error - cannot parse inside RawElement.' );
+		} );
 	} );
 } );
 

+ 28 - 6
packages/ckeditor5-engine/tests/model/documentselection.js

@@ -1779,7 +1779,7 @@ describe( 'DocumentSelection', () => {
 				expect( selection.getFirstPosition().path ).to.deep.equal( [ 0, 0 ] );
 			} );
 
-			it( 'handles multi-range selection in a text node by merging it into one range (resulting in collapsed ranges)', () => {
+			it( 'handles multi-range selection in a text node by merging it into one range (resulting in a collapsed range)', () => {
 				const ranges = [
 					new Range( new Position( root, [ 1, 1 ] ), new Position( root, [ 1, 2 ] ) ),
 					new Range( new Position( root, [ 1, 3 ] ), new Position( root, [ 1, 4 ] ) )
@@ -1789,19 +1789,19 @@ describe( 'DocumentSelection', () => {
 
 				model.applyOperation(
 					new MoveOperation(
-						new Position( root, [ 1, 1 ] ),
-						4,
+						new Position( root, [ 1, 0 ] ),
+						5,
 						new Position( doc.graveyard, [ 0 ] ),
 						doc.version
 					)
 				);
 
 				expect( selection.rangeCount ).to.equal( 1 );
-				expect( selection.getFirstPosition().path ).to.deep.equal( [ 1, 1 ] );
-				expect( selection.getLastPosition().path ).to.deep.equal( [ 1, 1 ] );
+				expect( selection.getFirstPosition().path ).to.deep.equal( [ 1, 0 ] );
+				expect( selection.getLastPosition().path ).to.deep.equal( [ 1, 0 ] );
 			} );
 
-			it( 'handles multi-range selection on object nodes by merging it into one range (resulting in non-collapsed ranges)', () => {
+			it( 'handles multi-range selection on object nodes by merging it into one range (resulting in a non-collapsed range)', () => {
 				model.schema.register( 'outer', {
 					isObject: true
 				} );
@@ -1835,6 +1835,28 @@ describe( 'DocumentSelection', () => {
 				expect( selection.getFirstPosition().path ).to.deep.equal( [ 0, 0 ] );
 				expect( selection.getLastPosition().path ).to.deep.equal( [ 0, 1 ] );
 			} );
+
+			it( 'should not fix the selection if not all ranges were removed', () => {
+				const ranges = [
+					new Range( new Position( root, [ 1, 1 ] ), new Position( root, [ 1, 2 ] ) ),
+					new Range( new Position( root, [ 1, 3 ] ), new Position( root, [ 1, 4 ] ) )
+				];
+
+				selection._setTo( ranges );
+
+				model.applyOperation(
+					new MoveOperation(
+						new Position( root, [ 1, 1 ] ),
+						1,
+						new Position( doc.graveyard, [ 0 ] ),
+						doc.version
+					)
+				);
+
+				expect( selection.rangeCount ).to.equal( 1 );
+				expect( selection.getFirstPosition().path ).to.deep.equal( [ 1, 2 ] );
+				expect( selection.getLastPosition().path ).to.deep.equal( [ 1, 3 ] );
+			} );
 		} );
 
 		it( '`DocumentSelection#change:range` event should be fire once even if selection contains multi-ranges', () => {

+ 150 - 0
packages/ckeditor5-engine/tests/view/domconverter/rawelement.js

@@ -0,0 +1,150 @@
+/**
+ * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+/* globals document, HTMLElement */
+
+import ViewRawElement from '../../../src/view/rawelement';
+import ViewContainer from '../../../src/view/containerelement';
+import DomConverter from '../../../src/view/domconverter';
+import ViewDocument from '../../../src/view/document';
+import { StylesProcessor } from '../../../src/view/stylesmap';
+
+describe( 'DOMConverter RawElement integration', () => {
+	let converter, viewDocument;
+
+	function createRawElement( name ) {
+		const element = new ViewRawElement( viewDocument, name );
+
+		element.render = function( domElement ) {
+			domElement.innerHTML = '<p><span>foo</span> bar</p>';
+		};
+
+		return element;
+	}
+
+	beforeEach( () => {
+		viewDocument = new ViewDocument( new StylesProcessor() );
+		converter = new DomConverter( viewDocument );
+	} );
+
+	describe( 'viewToDom()', () => {
+		it( 'should create a DOM element from a RawElement', () => {
+			const rawElement = new ViewRawElement( viewDocument, 'div' );
+			rawElement.render = () => {};
+			const domElement = converter.viewToDom( rawElement, document );
+
+			expect( domElement ).to.be.instanceOf( HTMLElement );
+		} );
+
+		it( 'should create a DOM structure from a RawElement', () => {
+			const myElement = createRawElement( 'div' );
+			const domElement = converter.viewToDom( myElement, document );
+
+			expect( domElement ).to.be.instanceOf( HTMLElement );
+			expect( domElement.innerHTML ).to.equal( '<p><span>foo</span> bar</p>' );
+		} );
+
+		it( 'should create a DOM structure entirely mapped to a single RawElement', () => {
+			const myElement = createRawElement( 'div' );
+			const domElement = converter.viewToDom( myElement, document, { bind: true } );
+			const domParagraph = domElement.childNodes[ 0 ];
+
+			expect( converter.mapDomToView( domElement ) ).to.equal( myElement );
+			expect( converter.mapDomToView( domParagraph ) ).to.equal( myElement );
+			expect( converter.mapDomToView( domParagraph.childNodes[ 0 ] ) ).to.equal( myElement );
+		} );
+	} );
+
+	describe( 'domToView()', () => {
+		it( 'should return a RawElement', () => {
+			const rawElement = createRawElement( 'div' );
+			const domElement = converter.viewToDom( rawElement, document, { bind: true } );
+
+			expect( converter.domToView( domElement ) ).to.equal( rawElement );
+		} );
+
+		it( 'should return a RawElement for all nodes inside of it', () => {
+			const rawElement = createRawElement( 'div' );
+			const domElement = converter.viewToDom( rawElement, document, { bind: true } );
+
+			const domParagraph = domElement.childNodes[ 0 ];
+			const domSpan = domParagraph.childNodes[ 0 ];
+
+			expect( converter.domToView( domParagraph ) ).to.equal( rawElement );
+			expect( converter.domToView( domSpan ) ).to.equal( rawElement );
+			expect( converter.domToView( domParagraph.childNodes[ 0 ] ) ).equal( rawElement );
+			expect( converter.domToView( domSpan.childNodes[ 0 ] ) ).equal( rawElement );
+		} );
+	} );
+
+	describe( 'domPositionToView()', () => {
+		it( 'should convert a position inside a RawElement to a position before it', () => {
+			const rawElement = createRawElement( 'h1' );
+			const container = new ViewContainer( viewDocument, 'div', null, [ new ViewContainer( viewDocument, 'div' ), rawElement ] );
+			const domContainer = converter.viewToDom( container, document, { bind: true } );
+
+			const viewPosition = converter.domPositionToView( domContainer.childNodes[ 1 ], 0 );
+
+			expect( viewPosition.parent ).to.equal( container );
+			expect( viewPosition.offset ).to.equal( 1 );
+		} );
+
+		it( 'should convert a position inside RawElement children to a position before it', () => {
+			const rawElement = createRawElement( 'h1' );
+			const container = new ViewContainer( viewDocument, 'div', null, [ new ViewContainer( viewDocument, 'div' ), rawElement ] );
+			const domContainer = converter.viewToDom( container, document, { bind: true } );
+
+			const viewPosition = converter.domPositionToView( domContainer.childNodes[ 1 ].childNodes[ 0 ], 1 );
+
+			expect( viewPosition.parent ).to.equal( container );
+			expect( viewPosition.offset ).to.equal( 1 );
+		} );
+	} );
+
+	describe( 'mapDomToView()', () => {
+		it( 'should return a RawElement for all DOM elements inside of it', () => {
+			const myElement = createRawElement( 'div' );
+			const domElement = converter.viewToDom( myElement, document, { bind: true } );
+
+			expect( converter.mapDomToView( domElement ) ).to.equal( myElement );
+
+			const domParagraph = domElement.childNodes[ 0 ];
+			expect( converter.mapDomToView( domParagraph ) ).to.equal( myElement );
+
+			const domSpan = domParagraph.childNodes[ 0 ];
+			expect( converter.mapDomToView( domSpan ) ).to.equal( myElement );
+		} );
+	} );
+
+	describe( 'findCorrespondingViewText()', () => {
+		it( 'should return a RawElement for all DOM text nodes inside of it', () => {
+			const myElement = createRawElement( 'div' );
+			const domElement = converter.viewToDom( myElement, document, { bind: true } );
+
+			const domText = domElement.querySelector( 'span' ).childNodes[ 0 ];
+			expect( converter.findCorrespondingViewText( domText ) ).to.equal( myElement );
+		} );
+	} );
+
+	describe( 'getHostViewElement()', () => {
+		it( 'should return a RawElement for all DOM children', () => {
+			const rawElement = createRawElement( 'div' );
+			const domElement = converter.viewToDom( rawElement, document, { bind: true } );
+
+			const domParagraph = domElement.childNodes[ 0 ];
+			const domSpan = domParagraph.childNodes[ 0 ];
+
+			expect( converter.getHostViewElement( domParagraph ) ).to.equal( rawElement );
+			expect( converter.getHostViewElement( domSpan ) ).to.equal( rawElement );
+		} );
+
+		it( 'should return "null" for the parent itself', () => {
+			const rawElement = createRawElement( 'div' );
+			const domElement = converter.viewToDom( rawElement, document, { bind: true } );
+
+			expect( converter.getHostViewElement( domElement ) ).to.be.null;
+		} );
+	} );
+} );

+ 4 - 4
packages/ckeditor5-engine/tests/view/domconverter/uielement.js

@@ -130,7 +130,7 @@ describe( 'DOMConverter UIElement integration', () => {
 		} );
 	} );
 
-	describe( 'getParentUIElement()', () => {
+	describe( 'getHostViewElement()', () => {
 		it( 'should return UIElement for DOM children', () => {
 			const uiElement = createUIElement( 'div' );
 			const domElement = converter.viewToDom( uiElement, document, { bind: true } );
@@ -138,15 +138,15 @@ describe( 'DOMConverter UIElement integration', () => {
 			const domParagraph = domElement.childNodes[ 0 ];
 			const domSpan = domParagraph.childNodes[ 0 ];
 
-			expect( converter.getParentUIElement( domParagraph ) ).to.equal( uiElement );
-			expect( converter.getParentUIElement( domSpan ) ).to.equal( uiElement );
+			expect( converter.getHostViewElement( domParagraph ) ).to.equal( uiElement );
+			expect( converter.getHostViewElement( domSpan ) ).to.equal( uiElement );
 		} );
 
 		it( 'should return null for element itself', () => {
 			const uiElement = createUIElement( 'div' );
 			const domElement = converter.viewToDom( uiElement, document, { bind: true } );
 
-			expect( converter.getParentUIElement( domElement ) ).to.be.null;
+			expect( converter.getHostViewElement( domElement ) ).to.be.null;
 		} );
 	} );
 } );

+ 22 - 0
packages/ckeditor5-engine/tests/view/downcastwriter/breakattributes.js

@@ -10,6 +10,7 @@ import ContainerElement from '../../../src/view/containerelement';
 import AttributeElement from '../../../src/view/attributeelement';
 import EmptyElement from '../../../src/view/emptyelement';
 import UIElement from '../../../src/view/uielement';
+import RawElement from '../../../src/view/rawelement';
 import Range from '../../../src/view/range';
 import Position from '../../../src/view/position';
 
@@ -282,6 +283,27 @@ describe( 'DowncastWriter', () => {
 					writer.breakAttributes( range );
 				}, 'view-writer-cannot-break-ui-element', writer );
 			} );
+
+			it( 'should throw if breaking inside a RawElement #1', () => {
+				const span = new RawElement( document, 'span' );
+				new ContainerElement( document, 'p', null, span ); // eslint-disable-line no-new
+				const position = new Position( span, 0 );
+
+				expectToThrowCKEditorError( () => {
+					writer.breakAttributes( position );
+				}, 'view-writer-cannot-break-raw-element', writer );
+			} );
+
+			it( 'should throw if breaking inside a RawElement #2', () => {
+				const span = new RawElement( document, 'span' );
+				const b = new AttributeElement( document, 'b' );
+				new ContainerElement( document, 'p', null, [ span, b ] ); // eslint-disable-line no-new
+				const range = Range._createFromParentsAndOffsets( span, 0, b, 0 );
+
+				expectToThrowCKEditorError( () => {
+					writer.breakAttributes( range );
+				}, 'view-writer-cannot-break-raw-element', writer );
+			} );
 		} );
 	} );
 } );

+ 11 - 0
packages/ckeditor5-engine/tests/view/downcastwriter/clear.js

@@ -10,6 +10,7 @@ import ContainerElement from '../../../src/view/containerelement';
 import AttributeElement from '../../../src/view/attributeelement';
 import EmptyElement from '../../../src/view/emptyelement';
 import UIElement from '../../../src/view/uielement';
+import RawElement from '../../../src/view/rawelement';
 
 import Document from '../../../src/view/document';
 import { expectToThrowCKEditorError } from '@ckeditor/ckeditor5-utils/tests/_utils/utils';
@@ -166,6 +167,16 @@ describe( 'DowncastWriter', () => {
 			);
 		} );
 
+		it( 'should remove a RawElement', () => {
+			const elementToRemove = new RawElement( document, 'span' );
+
+			testDowncast(
+				elementToRemove,
+				'<container:p>f{oo<raw:span></raw:span>ba}r</container:p>',
+				'<container:p>foobar</container:p>'
+			);
+		} );
+
 		it( 'should remove ContainerElement', () => {
 			const elementToRemove = new ContainerElement( document, 'p' );
 

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

@@ -8,6 +8,7 @@ import ContainerElement from '../../../src/view/containerelement';
 import Element from '../../../src/view/element';
 import EmptyElement from '../../../src/view/emptyelement';
 import UIElement from '../../../src/view/uielement';
+import RawElement from '../../../src/view/rawelement';
 import Position from '../../../src/view/position';
 
 import { stringify, parse } from '../../../src/dev-utils/view';
@@ -215,5 +216,16 @@ describe( 'DowncastWriter', () => {
 				writer.insert( position, attributeElement );
 			}, 'view-writer-cannot-break-ui-element', document );
 		} );
+
+		it( 'should throw if trying to insert inside a RawElement', () => {
+			const rawElement = new RawElement( document, 'span' );
+			new ContainerElement( document, 'p', null, rawElement ); // eslint-disable-line no-new
+			const position = new Position( rawElement, 0 );
+			const attributeElement = new AttributeElement( document, 'i' );
+
+			expectToThrowCKEditorError( () => {
+				writer.insert( position, attributeElement );
+			}, 'view-writer-cannot-break-raw-element', document );
+		} );
 	} );
 } );

+ 7 - 0
packages/ckeditor5-engine/tests/view/downcastwriter/mergeattributes.js

@@ -150,5 +150,12 @@ describe( 'DowncastWriter', () => {
 				'<container:p><ui:span></ui:span>[]<ui:span></ui:span></container:p>'
 			);
 		} );
+
+		it( 'should not merge when placed between RawElements', () => {
+			testMerge(
+				'<container:p><raw:span></raw:span>[]<raw:span></raw:span></container:p>',
+				'<container:p><raw:span></raw:span>[]<raw:span></raw:span></container:p>'
+			);
+		} );
 	} );
 } );

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

@@ -10,6 +10,7 @@ import AttributeElement from '../../../src/view/attributeelement';
 import RootEditableElement from '../../../src/view/rooteditableelement';
 import EmptyElement from '../../../src/view/emptyelement';
 import UIElement from '../../../src/view/uielement';
+import RawElement from '../../../src/view/rawelement';
 import Range from '../../../src/view/range';
 import Position from '../../../src/view/position';
 
@@ -186,6 +187,29 @@ describe( 'DowncastWriter', () => {
 			}, 'view-writer-cannot-break-ui-element', writer );
 		} );
 
+		it( 'should move a RawElement', () => {
+			testMove(
+				'<container:p>foo[<raw:span></raw:span>]bar</container:p>',
+				'<container:div>baz{}quix</container:div>',
+				'<container:p>foobar</container:p>',
+				'<container:div>baz[<raw:span></raw:span>]quix</container:div>'
+			);
+		} );
+
+		it( 'should throw if trying to move to a RawElement', () => {
+			const srcAttribute = new AttributeElement( document, 'b' );
+			const srcContainer = new ContainerElement( document, 'p', null, srcAttribute );
+			const srcRange = Range._createFromParentsAndOffsets( srcContainer, 0, srcContainer, 1 );
+
+			const dstRawElement = new RawElement( document, 'span' );
+			new ContainerElement( document, 'p', null, dstRawElement ); // eslint-disable-line no-new
+			const dstPosition = new Position( dstRawElement, 0 );
+
+			expectToThrowCKEditorError( () => {
+				writer.move( srcRange, dstPosition );
+			}, 'view-writer-cannot-break-raw-element', writer );
+		} );
+
 		it( 'should not break marker mappings if marker element was split and the original element was removed', () => {
 			const mapper = new Mapper();
 

+ 29 - 0
packages/ckeditor5-engine/tests/view/downcastwriter/remove.js

@@ -11,6 +11,7 @@ import { stringify, parse } from '../../../src/dev-utils/view';
 import AttributeElement from '../../../src/view/attributeelement';
 import EmptyElement from '../../../src/view/emptyelement';
 import UIElement from '../../../src/view/uielement';
+import RawElement from '../../../src/view/rawelement';
 
 import Document from '../../../src/view/document';
 import { expectToThrowCKEditorError } from '@ckeditor/ckeditor5-utils/tests/_utils/utils';
@@ -160,6 +161,25 @@ describe( 'DowncastWriter', () => {
 			}, 'view-writer-cannot-break-ui-element', document );
 		} );
 
+		it( 'should remove a RawElement', () => {
+			testRemove(
+				'<container:p>foo[<raw:span></raw:span>]bar</container:p>',
+				'<container:p>foo{}bar</container:p>',
+				'<raw:span></raw:span>'
+			);
+		} );
+
+		it( 'should throw if a range is placed inside a RawElement', () => {
+			const rawElement = new RawElement( document, 'span' );
+			const attributeElement = new AttributeElement( document, 'b' );
+			new ContainerElement( document, 'p', null, [ rawElement, attributeElement ] ); // eslint-disable-line no-new
+			const range = Range._createFromParentsAndOffsets( rawElement, 0, attributeElement, 0 );
+
+			expectToThrowCKEditorError( () => {
+				writer.remove( range );
+			}, 'view-writer-cannot-break-raw-element', document );
+		} );
+
 		it( 'should remove single text node (as item)', () => {
 			testRemove( '<container:p>[foobar]</container:p>', '<container:p></container:p>', 'foobar', true );
 		} );
@@ -201,6 +221,15 @@ describe( 'DowncastWriter', () => {
 			);
 		} );
 
+		it( 'should remove a RawElement (as an item)', () => {
+			testRemove(
+				'<container:p>foo[<raw:span></raw:span>]bar</container:p>',
+				'<container:p>foobar</container:p>',
+				'<raw:span></raw:span>',
+				true
+			);
+		} );
+
 		it( 'should throw when item has no parent container', () => {
 			const el = new AttributeElement( document, 'b' );
 

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

@@ -9,6 +9,7 @@ import ContainerElement from '../../../src/view/containerelement';
 import AttributeElement from '../../../src/view/attributeelement';
 import EmptyElement from '../../../src/view/emptyelement';
 import UIElement from '../../../src/view/uielement';
+import RawElement from '../../../src/view/rawelement';
 import Position from '../../../src/view/position';
 import Range from '../../../src/view/range';
 import Text from '../../../src/view/text';
@@ -426,6 +427,25 @@ describe( 'DowncastWriter', () => {
 			}, 'view-writer-cannot-break-ui-element', document );
 		} );
 
+		it( 'should unwrap a RawElement', () => {
+			testUnwrap(
+				'<container:p>[<attribute:b><raw:span></raw:span></attribute:b>]</container:p>',
+				'<attribute:b></attribute:b>',
+				'<container:p>[<raw:span></raw:span>]</container:p>'
+			);
+		} );
+
+		it( 'should throw if a range is placed inside a RawElement', () => {
+			const rawElement = new RawElement( document, 'span' );
+			const attribute = new AttributeElement( document, 'b' );
+			const container = new ContainerElement( document, 'p', null, [ rawElement, attribute ] );
+			const range = Range._createFromParentsAndOffsets( rawElement, 0, container, 2 );
+
+			expectToThrowCKEditorError( () => {
+				writer.unwrap( range, attribute );
+			}, 'view-writer-cannot-break-raw-element', document );
+		} );
+
 		it( 'should unwrap if both elements have same id', () => {
 			const unwrapper = writer.createAttributeElement( 'span', null, { id: 'foo' } );
 			const attribute = writer.createAttributeElement( 'span', null, { id: 'foo' } );

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

@@ -10,6 +10,7 @@ import Element from '../../../src/view/element';
 import ContainerElement from '../../../src/view/containerelement';
 import AttributeElement from '../../../src/view/attributeelement';
 import EmptyElement from '../../../src/view/emptyelement';
+import RawElement from '../../../src/view/rawelement';
 import UIElement from '../../../src/view/uielement';
 import Position from '../../../src/view/position';
 import Range from '../../../src/view/range';
@@ -422,6 +423,24 @@ describe( 'DowncastWriter', () => {
 				}, 'view-writer-cannot-break-ui-element', document );
 			} );
 
+			it( 'should wrap a RawElement', () => {
+				testWrap(
+					'<container:p>[<raw:span></raw:span>]</container:p>',
+					'<attribute:b></attribute:b>',
+					'<container:p>[<attribute:b view-priority="10"><raw:span></raw:span></attribute:b>]</container:p>'
+				);
+			} );
+
+			it( 'should throw if a range is inside a RawElement', () => {
+				const rawElement = new RawElement( document, 'span' );
+				const container = new ContainerElement( document, 'p', null, rawElement );
+				const range = Range._createFromParentsAndOffsets( rawElement, 0, container, 1 );
+
+				expectToThrowCKEditorError( () => {
+					writer.wrap( range, new AttributeElement( document, 'b' ) );
+				}, 'view-writer-cannot-break-raw-element', document );
+			} );
+
 			it( 'should keep stable hierarchy when wrapping with attribute with same priority', () => {
 				testWrap(
 					'<container:p>[<attribute:span>foo</attribute:span>]</container:p>',

+ 32 - 0
packages/ckeditor5-engine/tests/view/downcastwriter/writer.js

@@ -136,6 +136,38 @@ describe( 'DowncastWriter', () => {
 		} );
 	} );
 
+	describe( 'createRawElement()', () => {
+		it( 'should create a RawElement', () => {
+			const element = writer.createRawElement( 'foo', attributes );
+
+			expect( element.is( 'rawElement' ) ).to.be.true;
+			expect( element.name ).to.equal( 'foo' );
+			assertElementAttributes( element, attributes );
+
+			expect( element.render ).to.be.a( 'function' );
+		} );
+
+		it( 'should provide a default empty render() method', () => {
+			const element = writer.createRawElement( 'foo' );
+
+			expect( element.render ).to.be.a( 'function' );
+
+			expect( () => {
+				element.render();
+			} ).to.not.throw();
+		} );
+
+		it( 'should allow to pass custom rendering method', () => {
+			const renderFn = function() {};
+			const element = writer.createRawElement( 'foo', attributes, renderFn );
+
+			expect( element.is( 'rawElement' ) ).to.be.true;
+			expect( element.name ).to.equal( 'foo' );
+			expect( element.render ).to.equal( renderFn );
+			assertElementAttributes( element, attributes );
+		} );
+	} );
+
 	describe( 'setAttribute()', () => {
 		it( 'should set attribute on given element', () => {
 			const element = writer.createAttributeElement( 'span' );

+ 57 - 0
packages/ckeditor5-engine/tests/view/observer/mutationobserver.js

@@ -8,6 +8,7 @@
 import View from '../../../src/view/view';
 import MutationObserver from '../../../src/view/observer/mutationobserver';
 import UIElement from '../../../src/view/uielement';
+import RawElement from '../../../src/view/rawelement';
 import createViewRoot from '../_utils/createroot';
 import { parse } from '../../../src/dev-utils/view';
 import { StylesProcessor } from '../../../src/view/stylesmap';
@@ -591,6 +592,62 @@ describe( 'MutationObserver', () => {
 		} );
 	} );
 
+	describe( 'RawElement integration', () => {
+		const renderStub = sinon.stub();
+		function createRawElement( name ) {
+			const element = new RawElement( name );
+
+			element.render = function( domElement ) {
+				domElement.innerHTML = 'foo bar';
+			};
+
+			return element;
+		}
+
+		beforeEach( () => {
+			const rawElement = createRawElement( 'div' );
+			viewRoot._appendChild( rawElement );
+
+			view.forceRender();
+			renderStub.reset();
+			view.on( 'render', renderStub );
+		} );
+
+		it( 'should not collect text mutations from RawElement', () => {
+			domEditor.childNodes[ 2 ].childNodes[ 0 ].data = 'foom';
+
+			mutationObserver.flush();
+
+			expect( lastMutations ).to.be.null;
+		} );
+
+		it( 'should not cause a render from RawElement', () => {
+			domEditor.childNodes[ 2 ].childNodes[ 0 ].data = 'foom';
+
+			mutationObserver.flush();
+
+			expect( renderStub.callCount ).to.equal( 0 );
+		} );
+
+		it( 'should not collect child mutations from RawElement', () => {
+			const span = document.createElement( 'span' );
+			domEditor.childNodes[ 2 ].appendChild( span );
+
+			mutationObserver.flush();
+
+			expect( lastMutations ).to.be.null;
+		} );
+
+		it( 'should not cause a render when RawElement gets a child', () => {
+			const span = document.createElement( 'span' );
+			domEditor.childNodes[ 2 ].appendChild( span );
+
+			mutationObserver.flush();
+
+			expect( renderStub.callCount ).to.equal( 0 );
+		} );
+	} );
+
 	function expectDomEditorNotToChange() {
 		expect( domEditor.childNodes.length ).to.equal( 2 );
 		expect( domEditor.childNodes[ 0 ].tagName ).to.equal( 'P' );

+ 119 - 0
packages/ckeditor5-engine/tests/view/rawelement.js

@@ -0,0 +1,119 @@
+/**
+ * @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 RawElement from '../../src/view/rawelement';
+import Element from '../../src/view/element';
+import Document from '../../src/view/document';
+import { expectToThrowCKEditorError } from '@ckeditor/ckeditor5-utils/tests/_utils/utils';
+import { StylesProcessor } from '../../src/view/stylesmap';
+
+describe( 'RawElement', () => {
+	let rawElement, doc;
+
+	beforeEach( () => {
+		doc = new Document( new StylesProcessor() );
+
+		rawElement = new RawElement( doc, 'span', {
+			foo: 'bar',
+			style: 'margin-top: 2em;color: white;',
+			class: 'foo bar'
+		} );
+	} );
+
+	describe( 'constructor()', () => {
+		it( 'should create instance', () => {
+			expect( rawElement.name ).to.equal( 'span' );
+			expect( rawElement.getAttribute( 'foo' ) ).to.equal( 'bar' );
+			expect( rawElement.getStyle( 'margin-top' ) ).to.equal( '2em' );
+			expect( rawElement.getStyle( 'color' ) ).to.equal( 'white' );
+			expect( rawElement.hasClass( 'foo' ) ).to.true;
+			expect( rawElement.hasClass( 'bar' ) ).to.true;
+		} );
+
+		it( 'should throw if child elements are passed to constructor', () => {
+			expectToThrowCKEditorError( () => {
+				new RawElement( doc, 'img', null, [ new Element( doc, 'i' ) ] ); // eslint-disable-line no-new
+			}, 'view-rawelement-cannot-add: Cannot add child nodes to a RawElement instance.' );
+		} );
+	} );
+
+	describe( 'is()', () => {
+		let el;
+
+		before( () => {
+			el = new RawElement( doc, 'span' );
+		} );
+
+		it( 'should return true for rawElement/element, also with correct name and element name', () => {
+			expect( el.is( 'rawElement' ) ).to.be.true;
+			expect( el.is( 'view:rawElement' ) ).to.be.true;
+			expect( el.is( 'rawElement', 'span' ) ).to.be.true;
+			expect( el.is( 'view:rawElement', 'span' ) ).to.be.true;
+			expect( el.is( 'element' ) ).to.be.true;
+			expect( el.is( 'view:element' ) ).to.be.true;
+			expect( el.is( 'node' ) ).to.be.true;
+			expect( el.is( 'view:node' ) ).to.be.true;
+			expect( el.is( 'element', 'span' ) ).to.be.true;
+			expect( el.is( 'view:element', 'span' ) ).to.be.true;
+			expect( el.is( 'span' ) ).to.be.true;
+			expect( el.is( 'view:span' ) ).to.be.true;
+		} );
+
+		it( 'should return false for other accept values', () => {
+			expect( el.is( 'rawElement', 'p' ) ).to.be.false;
+			expect( el.is( 'view:rawElement', 'p' ) ).to.be.false;
+			expect( el.is( 'element', 'p' ) ).to.be.false;
+			expect( el.is( 'view:element', 'p' ) ).to.be.false;
+			expect( el.is( 'p' ) ).to.be.false;
+			expect( el.is( 'view:p' ) ).to.be.false;
+			expect( el.is( 'text' ) ).to.be.false;
+			expect( el.is( 'textProxy' ) ).to.be.false;
+			expect( el.is( 'containerElement' ) ).to.be.false;
+			expect( el.is( 'attributeElement' ) ).to.be.false;
+			expect( el.is( 'emptyElement' ) ).to.be.false;
+			expect( el.is( 'rootElement' ) ).to.be.false;
+			expect( el.is( 'documentFragment' ) ).to.be.false;
+			expect( el.is( 'model:element' ) ).to.be.false;
+			expect( el.is( 'model:span' ) ).to.be.false;
+			expect( el.is( 'model:node' ) ).to.be.false;
+		} );
+	} );
+
+	describe( '_appendChild()', () => {
+		it( 'should throw when try to append new child element', () => {
+			expectToThrowCKEditorError( () => {
+				rawElement._appendChild( new Element( doc, 'i' ) );
+			}, 'view-rawelement-cannot-add: Cannot add child nodes to a RawElement instance.' );
+		} );
+	} );
+
+	describe( '_insertChild()', () => {
+		it( 'should throw when try to insert new child element', () => {
+			expectToThrowCKEditorError( () => {
+				rawElement._insertChild( 0, new Element( doc, 'i' ) );
+			}, 'view-rawelement-cannot-add: Cannot add child nodes to a RawElement instance.' );
+		} );
+	} );
+
+	describe( '_clone()', () => {
+		it( 'should be properly cloned', () => {
+			const newRawElement = rawElement._clone();
+
+			expect( newRawElement.name ).to.equal( 'span' );
+			expect( newRawElement.getAttribute( 'foo' ) ).to.equal( 'bar' );
+			expect( newRawElement.getStyle( 'margin-top' ) ).to.equal( '2em' );
+			expect( newRawElement.getStyle( 'color' ) ).to.equal( 'white' );
+			expect( newRawElement.hasClass( 'foo' ) ).to.true;
+			expect( newRawElement.hasClass( 'bar' ) ).to.true;
+			expect( newRawElement.isSimilar( rawElement ) ).to.true;
+		} );
+	} );
+
+	describe( 'getFillerOffset()', () => {
+		it( 'should return null', () => {
+			expect( rawElement.getFillerOffset() ).to.null;
+		} );
+	} );
+} );

+ 69 - 2
packages/ckeditor5-engine/tests/view/renderer.js

@@ -10,10 +10,11 @@ import ViewElement from '../../src/view/element';
 import ViewEditableElement from '../../src/view/editableelement';
 import ViewContainerElement from '../../src/view/containerelement';
 import ViewAttributeElement from '../../src/view/attributeelement';
+import ViewRawElement from '../../src/view/rawelement';
+import ViewUIElement from '../../src/view/uielement';
 import ViewText from '../../src/view/text';
 import ViewRange from '../../src/view/range';
 import ViewPosition from '../../src/view/position';
-import UIElement from '../../src/view/uielement';
 import DocumentSelection from '../../src/view/documentselection';
 import DomConverter from '../../src/view/domconverter';
 import Renderer from '../../src/view/renderer';
@@ -3264,7 +3265,7 @@ describe( 'Renderer', () => {
 
 			it( 'should handle uiElement rendering', () => {
 				function createUIElement( id, text ) {
-					const element = new UIElement( viewDocument, 'span' );
+					const element = new ViewUIElement( viewDocument, 'span' );
 					element.render = function( domDocument ) {
 						const domElement = this.toDomElement( domDocument );
 						domElement.innerText = `<span id="${ id }"><b>${ text }</b></span>`;
@@ -3300,6 +3301,72 @@ describe( 'Renderer', () => {
 					'<p>Foo<span><span id="id2"><b>UI2</b></span></span> Bar</p>' ) );
 			} );
 
+			it( 'should handle RawElement rendering', () => {
+				function createRawElement( id, text ) {
+					const element = new ViewRawElement( viewDocument, 'span' );
+					element.render = function( domElement ) {
+						domElement.innerText = `<span id="${ id }"><b>${ text }</b></span>`;
+					};
+
+					return element;
+				}
+
+				const raw1 = createRawElement( 'id1', 'RAW1' );
+				const raw2 = createRawElement( 'id2', 'RAW2' );
+				const viewP = new ViewContainerElement( viewDocument, 'p', null, [
+					new ViewText( viewDocument, 'Foo ' ),
+					raw1,
+					new ViewText( viewDocument, 'Bar' )
+				] );
+				viewRoot._appendChild( viewP );
+
+				renderer.markToSync( 'children', viewRoot );
+				renderer.render();
+
+				expect( normalizeHtml( domRoot.innerHTML ) ).to.equal( normalizeHtml(
+					'<p>Foo <span><span id="id1"><b>RAW1</b></span></span>Bar</p>' ) );
+
+				viewP._removeChildren( 0, viewP.childCount );
+				viewP._insertChild( 0, [ new ViewText( viewDocument, 'Foo' ), raw2, new ViewText( viewDocument, ' Bar' ) ] );
+
+				renderer.markToSync( 'children', viewRoot );
+				renderer.markToSync( 'children', viewP );
+				renderer.render();
+
+				expect( normalizeHtml( domRoot.innerHTML ) ).to.equal( normalizeHtml(
+					'<p>Foo<span><span id="id2"><b>RAW2</b></span></span> Bar</p>' ) );
+			} );
+
+			it( 'should manage RawElement attributes', () => {
+				const rawElement = new ViewRawElement( viewDocument, 'span', {
+					foo: 'foo1',
+					baz: 'baz1'
+				} );
+
+				rawElement.render = function( domElement ) {
+					domElement.innerHTML = '<b>foo</b>';
+				};
+
+				const viewP = new ViewContainerElement( viewDocument, 'p', null, [ rawElement ] );
+				viewRoot._appendChild( viewP );
+
+				renderer.markToSync( 'children', viewRoot );
+				renderer.render();
+
+				expect( normalizeHtml( domRoot.innerHTML ) ).to.equal( normalizeHtml(
+					'<p><span baz="baz1" foo="foo1"><b>foo</b></span></p>' ) );
+
+				rawElement._setAttribute( 'foo', 'foo2' );
+				rawElement._setAttribute( 'new', 'new-value' );
+				rawElement._removeAttribute( 'baz' );
+
+				renderer.markToSync( 'attributes', rawElement );
+				renderer.render();
+
+				expect( normalizeHtml( domRoot.innerHTML ) ).to.equal( normalizeHtml(
+					'<p><span foo="foo2" new="new-value"><b>foo</b></span></p>' ) );
+			} );
+
 			it( 'should handle linking entire content', () => {
 				viewRoot._appendChild( parse( '<container:p>Foo<attribute:i>Bar</attribute:i></container:p>' ) );
 

+ 1 - 1
packages/ckeditor5-markdown-gfm/docs/features/markdown.md

@@ -71,4 +71,4 @@ ClassicEditor
 
 ## Contribute
 
-The source code of this feature is available on GitHub in https://github.com/ckeditor/ckeditor5-markdown-gfm.
+The source code of this feature is available on GitHub in https://github.com/ckeditor/ckeditor5/tree/master/packages/ckeditor5-markdown-gfm.

+ 1 - 5
packages/ckeditor5-media-embed/src/mediaregistry.js

@@ -234,12 +234,8 @@ class Media {
 
 			const mediaHtml = this._getPreviewHtml( options );
 
-			viewElement = writer.createUIElement( 'div', attributes, function( domDocument ) {
-				const domElement = this.toDomElement( domDocument );
-
+			viewElement = writer.createRawElement( 'div', attributes, function( domElement ) {
 				domElement.innerHTML = mediaHtml;
-
-				return domElement;
 			} );
 		} else {
 			if ( this.url ) {

+ 0 - 10
packages/ckeditor5-media-embed/src/utils.js

@@ -75,12 +75,6 @@ export function isMediaWidget( viewElement ) {
 export function createMediaFigureElement( writer, registry, url, options ) {
 	const figure = writer.createContainerElement( 'figure', { class: 'media' } );
 
-	// TODO: This is a hack. Without it, the figure in the data pipeline will contain &nbsp; because
-	// its only child is the UIElement (wrapper).
-	//
-	// Note: The hack is a copy&paste from widget utils; it makes the figure act like it's a widget.
-	figure.getFillerOffset = getFillerOffset;
-
 	writer.insert( writer.createPositionAt( figure, 0 ), registry.getMediaViewElement( writer, url, options ) );
 
 	return figure;
@@ -123,7 +117,3 @@ export function insertMedia( model, url, insertPosition ) {
 		writer.setSelection( mediaElement, 'on' );
 	} );
 }
-
-function getFillerOffset() {
-	return null;
-}

+ 65 - 0
packages/ckeditor5-media-embed/tests/integration.js

@@ -0,0 +1,65 @@
+/**
+ * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+/* global document */
+
+import MediaEmbed from '../src/mediaembed';
+import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
+
+import ClassicTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/classictesteditor';
+import { getData as getViewData } from '@ckeditor/ckeditor5-engine/src/dev-utils/view';
+import { enablePlaceholder } from '@ckeditor/ckeditor5-engine/src/view/placeholder';
+
+describe( 'MediaEmbed integration', () => {
+	let element, clock;
+
+	beforeEach( () => {
+		clock = sinon.useFakeTimers();
+		element = document.createElement( 'div' );
+		document.body.appendChild( element );
+	} );
+
+	afterEach( () => {
+		element.remove();
+		clock.restore();
+	} );
+
+	describe( 'with the placeholder feature', () => {
+		// https://github.com/ckeditor/ckeditor5/issues/1684
+		it( 'should make the placeholder CSS class disappear when pasting a new media into an empty editing root', async () => {
+			const editor = await ClassicTestEditor.create( element, {
+				plugins: [ MediaEmbed, Paragraph ]
+			} );
+
+			enablePlaceholder( {
+				view: editor.editing.view,
+				element: editor.editing.view.document.getRoot(),
+				text: 'foo',
+				isDirectHost: false
+			} );
+
+			editor.editing.view.document.fire( 'paste', {
+				dataTransfer: {
+					getData() {
+						return 'https://www.youtube.com/watch?v=H08tGjXNHO4';
+					}
+				},
+				stopPropagation() {},
+				preventDefault() {}
+			} );
+
+			clock.tick( 100 );
+
+			expect( getViewData( editor.editing.view ) ).to.equal(
+				'[<figure class="ck-widget ck-widget_selected media" contenteditable="false" data-placeholder="foo">' +
+					'<div class="ck-media__wrapper" data-oembed-url="https://www.youtube.com/watch?v=H08tGjXNHO4"></div>' +
+					'<div class="ck ck-reset_all ck-widget__type-around"></div>' +
+				'</figure>]'
+			);
+
+			await editor.destroy();
+		} );
+	} );
+} );

+ 16 - 16
packages/ckeditor5-media-embed/tests/mediaembedediting.js

@@ -85,7 +85,7 @@ describe( 'MediaEmbedEditing', () => {
 					} ).then( editor => {
 						editor.setData( '<figure class="media"><div data-oembed-url="foo.com"></div></figure>' );
 
-						expect( getViewData( editor.editing.view, { withoutSelection: true, renderUIElements: true } ) ).to.equal( '' );
+						expect( getViewData( editor.editing.view, { withoutSelection: true, renderRawElements: true } ) ).to.equal( '' );
 					} );
 				} );
 
@@ -99,7 +99,7 @@ describe( 'MediaEmbedEditing', () => {
 					} ).then( editor => {
 						editor.setData( '<figure class="media"><div data-oembed-url="foo.com/123"></div></figure>' );
 
-						expect( getViewData( editor.editing.view, { withoutSelection: true, renderUIElements: true } ) ).to.equal(
+						expect( getViewData( editor.editing.view, { withoutSelection: true, renderRawElements: true } ) ).to.equal(
 							'<figure class="ck-widget media" contenteditable="false">' +
 								'<div class="ck-media__wrapper" data-oembed-url="https://foo.com/123">' +
 									'A, id=123' +
@@ -109,7 +109,7 @@ describe( 'MediaEmbedEditing', () => {
 
 						editor.setData( '<figure class="media"><div data-oembed-url="bar.com/123"></div></figure>' );
 
-						expect( getViewData( editor.editing.view, { withoutSelection: true, renderUIElements: true } ) ).to.equal(
+						expect( getViewData( editor.editing.view, { withoutSelection: true, renderRawElements: true } ) ).to.equal(
 							'<figure class="ck-widget media" contenteditable="false">' +
 								'<div class="ck-media__wrapper" data-oembed-url="https://bar.com/123">' +
 									'B, id=123' +
@@ -119,7 +119,7 @@ describe( 'MediaEmbedEditing', () => {
 
 						editor.setData( '<figure class="media"><div data-oembed-url="anything.com/123"></div></figure>' );
 
-						expect( getViewData( editor.editing.view, { withoutSelection: true, renderUIElements: true } ) ).to.equal(
+						expect( getViewData( editor.editing.view, { withoutSelection: true, renderRawElements: true } ) ).to.equal(
 							'<figure class="ck-widget media" contenteditable="false">' +
 								'<div class="ck-media__wrapper" data-oembed-url="https://anything.com/123">' +
 									'C, id=123' +
@@ -350,7 +350,7 @@ describe( 'MediaEmbedEditing', () => {
 					} ).then( editor => {
 						editor.setData( '<figure class="media"><div data-oembed-url="foo.com/123"></div></figure>' );
 
-						expect( getViewData( editor.editing.view, { withoutSelection: true, renderUIElements: true } ) ).to.equal(
+						expect( getViewData( editor.editing.view, { withoutSelection: true, renderRawElements: true } ) ).to.equal(
 							'<figure class="ck-widget media" contenteditable="false">' +
 								'<div class="ck-media__wrapper" data-oembed-url="https://foo.com/123">' +
 									'A, id=123' +
@@ -360,7 +360,7 @@ describe( 'MediaEmbedEditing', () => {
 
 						editor.setData( '<figure class="media"><div data-oembed-url="anything.com/123"></div></figure>' );
 
-						expect( getViewData( editor.editing.view, { withoutSelection: true, renderUIElements: true } ) ).to.equal(
+						expect( getViewData( editor.editing.view, { withoutSelection: true, renderRawElements: true } ) ).to.equal(
 							'<figure class="ck-widget media" contenteditable="false">' +
 								'<div class="ck-media__wrapper" data-oembed-url="https://anything.com/123">' +
 									'extraB, id=123' +
@@ -384,7 +384,7 @@ describe( 'MediaEmbedEditing', () => {
 							'<figure class="media"><div data-oembed-url="foo.com/123"></div></figure>' +
 							'<figure class="media"><div data-oembed-url="bar.com/123"></div></figure>' );
 
-						expect( getViewData( editor.editing.view, { withoutSelection: true, renderUIElements: true } ) ).to.equal(
+						expect( getViewData( editor.editing.view, { withoutSelection: true, renderRawElements: true } ) ).to.equal(
 							'<figure class="ck-widget media" contenteditable="false">' +
 								'<div class="ck-media__wrapper" data-oembed-url="https://bar.com/123">' +
 									'B, id=123' +
@@ -407,7 +407,7 @@ describe( 'MediaEmbedEditing', () => {
 							'<figure class="media"><div data-oembed-url="foo.com/123"></div></figure>' +
 							'<figure class="media"><div data-oembed-url="bar.com/123"></div></figure>' );
 
-						expect( getViewData( editor.editing.view, { withoutSelection: true, renderUIElements: true } ) ).to.equal(
+						expect( getViewData( editor.editing.view, { withoutSelection: true, renderRawElements: true } ) ).to.equal(
 							'<figure class="ck-widget media" contenteditable="false">' +
 								'<div class="ck-media__wrapper" data-oembed-url="https://bar.com/123">' +
 									'B, id=123' +
@@ -434,7 +434,7 @@ describe( 'MediaEmbedEditing', () => {
 							'<figure class="media"><div data-oembed-url="foo.com/123"></div></figure>' +
 							'<figure class="media"><div data-oembed-url="bar.com/123"></div></figure>' );
 
-						expect( getViewData( editor.editing.view, { withoutSelection: true, renderUIElements: true } ) ).to.equal(
+						expect( getViewData( editor.editing.view, { withoutSelection: true, renderRawElements: true } ) ).to.equal(
 							'<figure class="ck-widget media" contenteditable="false">' +
 								'<div class="ck-media__wrapper" data-oembed-url="https://bar.com/123">' +
 									'B, id=123' +
@@ -833,7 +833,7 @@ describe( 'MediaEmbedEditing', () => {
 					it( 'should convert', () => {
 						setModelData( model, '<media url="https://ckeditor.com"></media>' );
 
-						expect( getViewData( view, { withoutSelection: true, renderUIElements: true } ) ).to.equal(
+						expect( getViewData( view, { withoutSelection: true, renderRawElements: true } ) ).to.equal(
 							'<figure class="ck-widget media" contenteditable="false">' +
 								'<div class="ck-media__wrapper" data-oembed-url="https://ckeditor.com">' +
 									'allow-everything, id=https://ckeditor.com' +
@@ -850,7 +850,7 @@ describe( 'MediaEmbedEditing', () => {
 							writer.setAttribute( 'url', 'https://cksource.com', media );
 						} );
 
-						expect( getViewData( view, { withoutSelection: true, renderUIElements: true } ) ).to.equal(
+						expect( getViewData( view, { withoutSelection: true, renderRawElements: true } ) ).to.equal(
 							'<figure class="ck-widget media" contenteditable="false">' +
 								'<div class="ck-media__wrapper" data-oembed-url="https://cksource.com">' +
 									'allow-everything, id=https://cksource.com' +
@@ -867,7 +867,7 @@ describe( 'MediaEmbedEditing', () => {
 							writer.removeAttribute( 'url', media );
 						} );
 
-						expect( getViewData( view, { withoutSelection: true, renderUIElements: true } ) )
+						expect( getViewData( view, { withoutSelection: true, renderRawElements: true } ) )
 							.to.equal(
 								'<figure class="ck-widget media" contenteditable="false">' +
 									'<div class="ck-media__wrapper">' +
@@ -888,7 +888,7 @@ describe( 'MediaEmbedEditing', () => {
 							writer.removeAttribute( 'url', media );
 						} );
 
-						expect( getViewData( view, { withoutSelection: true, renderUIElements: true } ) ).to.equal(
+						expect( getViewData( view, { withoutSelection: true, renderRawElements: true } ) ).to.equal(
 							'<figure class="ck-widget media" contenteditable="false">' +
 								'<div class="ck-media__wrapper" data-oembed-url="https://ckeditor.com">' +
 									'allow-everything, id=https://ckeditor.com' +
@@ -916,7 +916,7 @@ describe( 'MediaEmbedEditing', () => {
 							writer.insert( writer.createPositionAt( widgetViewElement, 'end' ), externalUIElement );
 						} );
 
-						expect( getViewData( view, { withoutSelection: true, renderUIElements: true } ) ).to.equal(
+						expect( getViewData( view, { withoutSelection: true, renderUIElements: true, renderRawElements: true } ) ).to.equal(
 							'<figure class="ck-widget media" contenteditable="false">' +
 								'<div class="ck-media__wrapper" data-oembed-url="https://ckeditor.com">' +
 									'allow-everything, id=https://ckeditor.com' +
@@ -929,7 +929,7 @@ describe( 'MediaEmbedEditing', () => {
 							writer.setAttribute( 'url', 'https://cksource.com', media );
 						} );
 
-						expect( getViewData( view, { withoutSelection: true, renderUIElements: true } ) ).to.equal(
+						expect( getViewData( view, { withoutSelection: true, renderUIElements: true, renderRawElements: true } ) ).to.equal(
 							'<figure class="ck-widget media" contenteditable="false">' +
 								'<div class="ck-media__wrapper" data-oembed-url="https://cksource.com">' +
 									'allow-everything, id=https://cksource.com' +
@@ -947,7 +947,7 @@ describe( 'MediaEmbedEditing', () => {
 		for ( const url of urls ) {
 			editor.setData( `<figure class="media"><div data-oembed-url="${ url }"></div></figure>` );
 
-			const viewData = getViewData( view, { withoutSelection: true, renderUIElements: true } );
+			const viewData = getViewData( view, { withoutSelection: true, renderRawElements: true } );
 			let expectedRegExp;
 
 			const expectedUrl = url.match( /^https?:\/\// ) ? url : 'https://' + url;

+ 34 - 0
packages/ckeditor5-table/tests/tableselection-integration.js

@@ -220,6 +220,40 @@ describe( 'TableSelection - integration', () => {
 				'</table>'
 			);
 		} );
+
+		// https://github.com/ckeditor/ckeditor5/issues/7659.
+		// The fix is in the `DocumentSelection` class but this test is here to make sure that the fix works
+		// and that the behavior won't change in the future.
+		it( 'should not fix selection if not all ranges were removed', () => {
+			// [ ][ ][ ]
+			// [x][x][ ]
+			// [x][x][ ]
+			tableSelection.setCellSelection(
+				modelRoot.getNodeByPath( [ 0, 1, 0 ] ),
+				modelRoot.getNodeByPath( [ 0, 2, 1 ] )
+			);
+
+			editor.model.change( writer => {
+				// Remove second row.
+				writer.remove( modelRoot.getNodeByPath( [ 0, 1 ] ) );
+			} );
+
+			assertEqualMarkup(
+				getModelData( model ),
+				'<table>' +
+					'<tableRow>' +
+						'<tableCell><paragraph>11</paragraph></tableCell>' +
+						'<tableCell><paragraph>12</paragraph></tableCell>' +
+						'<tableCell><paragraph>13</paragraph></tableCell>' +
+					'</tableRow>' +
+					'<tableRow>' +
+						'[<tableCell><paragraph>31</paragraph></tableCell>]' +
+						'[<tableCell><paragraph>32</paragraph></tableCell>]' +
+						'<tableCell><paragraph>33</paragraph></tableCell>' +
+					'</tableRow>' +
+				'</table>'
+			);
+		} );
 	} );
 
 	describe( 'with undo', () => {

+ 159 - 316
packages/ckeditor5-typing/src/twostepcaretmovement.js

@@ -83,6 +83,25 @@ import priorities from '@ckeditor/ckeditor5-utils/src/priorities';
  *   <kbd>←</kbd>
  *
  *   		<$text a="true">ba{}r</$text>b{}az
+ *
+ * # Multiple attributes
+ *
+ * * When enabled and many attributes starts or ends at the same position:
+ *
+ *   		<$text a="true" b="true">bar</$text>{}baz
+ *
+ *    <kbd>←</kbd>
+ *
+ *   		<$text a="true" b="true">bar{}</$text>baz
+ *
+ * * When enabled and one procedes another:
+ *
+ *   		<$text a="true">bar</$text><$text b="true">{}bar</$text>
+ *
+ *    <kbd>←</kbd>
+ *
+ *   		<$text a="true">bar{}</$text><$text b="true">bar</$text>
+ *
  */
 export default class TwoStepCaretMovement extends Plugin {
 	/**
@@ -99,12 +118,21 @@ export default class TwoStepCaretMovement extends Plugin {
 		super( editor );
 
 		/**
-		 * A map of handlers for each attribute.
+		 * A set of attributes to handle.
 		 *
 		 * @protected
 		 * @property {module:typing/twostepcaretmovement~TwoStepCaretMovement}
 		 */
-		this._handlers = new Map();
+		this.attributes = new Set();
+
+		/**
+		 * The current UID of the overridden gravity, as returned by
+		 * {@link module:engine/model/writer~Writer#overrideSelectionGravity}.
+		 *
+		 * @private
+		 * @member {String}
+		 */
+		this._overrideUid = null;
 	}
 
 	/**
@@ -149,94 +177,25 @@ export default class TwoStepCaretMovement extends Plugin {
 				return;
 			}
 
-			const position = modelSelection.getFirstPosition();
 			const contentDirection = locale.contentLanguageDirection;
 			let isMovementHandled = false;
 
 			if ( ( contentDirection === 'ltr' && arrowRightPressed ) || ( contentDirection === 'rtl' && arrowLeftPressed ) ) {
-				for ( const [ , handler ] of this._handlers ) {
-					isMovementHandled = isMovementHandled || handler.handleForwardMovement( position, data );
-				}
+				isMovementHandled = this._handleForwardMovement( data );
 			} else {
-				for ( const [ , handler ] of this._handlers ) {
-					isMovementHandled = isMovementHandled || handler.handleBackwardMovement( position, data );
-				}
+				isMovementHandled = this._handleBackwardMovement( data );
 			}
 
 			// Stop the keydown event if the two-step caret movement handled it. Avoid collisions
 			// with other features which may also take over the caret movement (e.g. Widget).
-			if ( isMovementHandled ) {
+			if ( isMovementHandled === true ) {
 				evt.stop();
 			}
 		}, { priority: priorities.get( 'high' ) + 1 } );
-	}
-
-	/**
-	 * Registers a given attribute for the two-step caret movement.
-	 *
-	 * @param {String} attribute Name of the attribute to handle.
-	 */
-	registerAttribute( attribute ) {
-		this._handlers.set(
-			attribute,
-			new TwoStepCaretHandler( this.editor.model, this, attribute )
-		);
-	}
-}
-
-/**
- * This is a protected helper–class for {@link module:typing/twostepcaretmovement}.
- * It handles the state of the 2-step caret movement for a single {@link module:engine/model/model~Model}
- * attribute upon the `keypress` in the {@link module:engine/view/view~View}.
- *
- * @protected
- */
-export class TwoStepCaretHandler {
-	/*
-	 * Creates two step handler instance.
-	 *
-	 * @param {module:engine/model/model~Model} model Data model instance.
-	 * @param {module:utils/dom/emittermixin~Emitter} emitter The emitter to which this behavior should be added
-	 * (e.g. a plugin instance).
-	 * @param {String} attribute Attribute for which the behavior will be added.
-	 */
-	constructor( model, emitter, attribute ) {
-		/**
-		 * The model instance this class instance operates on.
-		 *
-		 * @readonly
-		 * @member {module:engine/model/model~Model#schema}
-		 */
-		this.model = model;
-
-		/**
-		 * The Attribute this class instance operates on.
-		 *
-		 * @readonly
-		 * @member {String}
-		 */
-		this.attribute = attribute;
 
 		/**
-		 * A reference to the document selection.
-		 *
-		 * @private
-		 * @member {module:engine/model/selection~Selection}
-		 */
-		this._modelSelection = model.document.selection;
-
-		/**
-		 * The current UID of the overridden gravity, as returned by
-		 * {@link module:engine/model/writer~Writer#overrideSelectionGravity}.
-		 *
-		 * @private
-		 * @member {String}
-		 */
-		this._overrideUid = null;
-
-		/**
-		 * A flag indicating that the automatic gravity restoration for this attribute
-		 * should not happen upon the next
+		 * A flag indicating that the automatic gravity restoration should not happen upon the next
+		 * gravity restoration.
 		 * {@link module:engine/model/selection~Selection#event:change:range} event.
 		 *
 		 * @private
@@ -245,7 +204,7 @@ export class TwoStepCaretHandler {
 		this._isNextGravityRestorationSkipped = false;
 
 		// The automatic gravity restoration logic.
-		emitter.listenTo( this._modelSelection, 'change:range', ( evt, data ) => {
+		this.listenTo( modelSelection, 'change:range', ( evt, data ) => {
 			// Skipping the automatic restoration is needed if the selection should change
 			// but the gravity must remain overridden afterwards. See the #handleBackwardMovement
 			// to learn more.
@@ -264,7 +223,7 @@ export class TwoStepCaretHandler {
 			// Skip automatic restore when the change is indirect AND the selection is at the attribute boundary.
 			// It means that e.g. if the change was external (collaboration) and the user had their
 			// selection around the link, its gravity should remain intact in this change:range event.
-			if ( !data.directChange && isAtBoundary( this._modelSelection.getFirstPosition(), attribute ) ) {
+			if ( !data.directChange && isBetweenDifferentAttributes( modelSelection.getFirstPosition(), this.attributes ) ) {
 				return;
 			}
 
@@ -272,17 +231,28 @@ export class TwoStepCaretHandler {
 		} );
 	}
 
+	/**
+	 * Registers a given attribute for the two-step caret movement.
+	 *
+	 * @param {String} attribute Name of the attribute to handle.
+	 */
+	registerAttribute( attribute ) {
+		this.attributes.add( attribute );
+	}
+
 	/**
 	 * Updates the document selection and the view according to the two–step caret movement state
 	 * when moving **forwards**. Executed upon `keypress` in the {@link module:engine/view/view~View}.
 	 *
-	 * @param {module:engine/model/position~Position} position The model position at the moment of the key press.
+	 * @private
 	 * @param {module:engine/view/observer/domeventdata~DomEventData} data Data of the key press.
 	 * @returns {Boolean} `true` when the handler prevented caret movement
 	 */
-	handleForwardMovement( position, data ) {
-		const attribute = this.attribute;
-
+	_handleForwardMovement( data ) {
+		const attributes = this.attributes;
+		const model = this.editor.model;
+		const selection = model.document.selection;
+		const position = selection.getFirstPosition();
 		// DON'T ENGAGE 2-SCM if gravity is already overridden. It means that we just entered
 		//
 		// 		<paragraph>foo<$text attribute>{}bar</$text>baz</paragraph>
@@ -293,7 +263,7 @@ export class TwoStepCaretHandler {
 		//
 		// and the gravity will be restored automatically.
 		if ( this._isGravityOverridden ) {
-			return;
+			return false;
 		}
 
 		// DON'T ENGAGE 2-SCM when the selection is at the beginning of the block AND already has the
@@ -303,44 +273,20 @@ export class TwoStepCaretHandler {
 		//
 		//		<paragraph><$text attribute>{}bar</$text>baz</paragraph>
 		//
-		if ( position.isAtStart && this._hasSelectionAttribute ) {
-			return;
+		if ( position.isAtStart && hasAnyAttribute( selection, attributes ) ) {
+			return false;
 		}
 
-		// ENGAGE 2-SCM when about to leave one attribute value and enter another:
-		//
-		// 		<paragraph><$text attribute="1">foo{}</$text><$text attribute="2">bar</$text></paragraph>
-		//
-		// but DON'T when already in between of them (no attribute selection):
-		//
-		// 		<paragraph><$text attribute="1">foo</$text>{}<$text attribute="2">bar</$text></paragraph>
-		//
-		if ( isBetweenDifferentValues( position, attribute ) && this._hasSelectionAttribute ) {
-			this._preventCaretMovement( data );
-			this._removeSelectionAttribute();
-
-			return true;
-		}
-
-		// ENGAGE 2-SCM when entering an attribute:
-		//
-		// 		<paragraph>foo{}<$text attribute>bar</$text>baz</paragraph>
-		//
-		if ( isAtStartBoundary( position, attribute ) ) {
-			this._preventCaretMovement( data );
-			this._overrideGravity();
-
-			return true;
-		}
-
-		// ENGAGE 2-SCM when leaving an attribute:
+		// ENGAGE 2-SCM When at least one of the observed attributes changes its value (incl. starts, ends).
 		//
 		//		<paragraph>foo<$text attribute>bar{}</$text>baz</paragraph>
+		//		<paragraph>foo<$text attribute>bar{}</$text><$text otherAttribute>baz</$text></paragraph>
+		//		<paragraph>foo<$text attribute=1>bar{}</$text><$text attribute=2>baz</$text></paragraph>
+		//		<paragraph>foo{}<$text attribute>bar</$text>baz</paragraph>
 		//
-		if ( isAtEndBoundary( position, attribute ) && this._hasSelectionAttribute ) {
-			this._preventCaretMovement( data );
+		if ( isBetweenDifferentAttributes( position, attributes ) ) {
+			preventCaretMovement( data );
 			this._overrideGravity();
-
 			return true;
 		}
 	}
@@ -349,151 +295,88 @@ export class TwoStepCaretHandler {
 	 * Updates the document selection and the view according to the two–step caret movement state
 	 * when moving **backwards**. Executed upon `keypress` in the {@link module:engine/view/view~View}.
 	 *
-	 * @param {module:engine/model/position~Position} position The model position at the moment of the key press.
+	 * @private
 	 * @param {module:engine/view/observer/domeventdata~DomEventData} data Data of the key press.
 	 * @returns {Boolean} `true` when the handler prevented caret movement
 	 */
-	handleBackwardMovement( position, data ) {
-		const attribute = this.attribute;
-
-		// When the gravity is already overridden...
+	_handleBackwardMovement( data ) {
+		const attributes = this.attributes;
+		const model = this.editor.model;
+		const selection = model.document.selection;
+		const position = selection.getFirstPosition();
+
+		// When the gravity is already overridden (by this plugin), it means we are on the two-step position.
+		// Prevent the movement, restore the gravity and update selection attributes.
+		//
+		//		<paragraph>foo<$text attribute=1>bar</$text><$text attribute=2>{}baz</$text></paragraph>
+		//		<paragraph>foo<$text attribute>bar</$text><$text otherAttribute>{}baz</$text></paragraph>
+		//		<paragraph>foo<$text attribute>{}bar</$text>baz</paragraph>
+		//		<paragraph>foo<$text attribute>bar</$text>{}baz</paragraph>
+		//
 		if ( this._isGravityOverridden ) {
-			// ENGAGE 2-SCM & REMOVE SELECTION ATTRIBUTE
-			// when about to leave one attribute value and enter another:
-			//
-			// 		<paragraph><$text attribute="1">foo</$text><$text attribute="2">{}bar</$text></paragraph>
-			//
-			// but DON'T when already in between of them (no attribute selection):
-			//
-			// 		<paragraph><$text attribute="1">foo</$text>{}<$text attribute="2">bar</$text></paragraph>
-			//
-			if ( isBetweenDifferentValues( position, attribute ) && this._hasSelectionAttribute ) {
-				this._preventCaretMovement( data );
-				this._restoreGravity();
-				this._removeSelectionAttribute();
-
-				return true;
-			}
+			preventCaretMovement( data );
+			this._restoreGravity();
+			setSelectionAttributesFromTheNodeBefore( model, attributes, position );
 
-			// ENGAGE 2-SCM when at any boundary of the attribute:
+			return true;
+		} else {
+			// REMOVE SELECTION ATTRIBUTE when restoring gravity towards a non-existent content at the
+			// beginning of the block.
 			//
-			// 		<paragraph>foo<$text attribute>bar</$text>{}baz</paragraph>
-			// 		<paragraph>foo<$text attribute>{}bar</$text>baz</paragraph>
+			// 		<paragraph>{}<$text attribute>bar</$text></paragraph>
 			//
-			else {
-				this._preventCaretMovement( data );
-				this._restoreGravity();
+			if ( position.isAtStart ) {
+				if ( hasAnyAttribute( selection, attributes ) ) {
+					preventCaretMovement( data );
+					setSelectionAttributesFromTheNodeBefore( model, attributes, position );
 
-				// REMOVE SELECTION ATRIBUTE at the beginning of the block.
-				// It's like restoring gravity but towards a non-existent content when
-				// the gravity is overridden:
-				//
-				// 		<paragraph><$text attribute>{}bar</$text></paragraph>
-				//
-				// becomes:
-				//
-				// 		<paragraph>{}<$text attribute>bar</$text></paragraph>
-				//
-				if ( position.isAtStart ) {
-					this._removeSelectionAttribute();
+					return true;
 				}
 
-				return true;
-			}
-		} else {
-			// ENGAGE 2-SCM when between two different attribute values but selection has no attribute:
-			//
-			// 		<paragraph><$text attribute="1">foo</$text>{}<$text attribute="2">bar</$text></paragraph>
-			//
-			if ( isBetweenDifferentValues( position, attribute ) && !this._hasSelectionAttribute ) {
-				this._preventCaretMovement( data );
-				this._setSelectionAttributeFromTheNodeBefore( position );
-
-				return true;
+				return false;
 			}
 
-			// End of block boundary cases:
+			// When we are moving from natural gravity, to the position of the 2SCM, we need to override the gravity,
+			// and make sure it won't be restored. Unless it's at the end of the block and an observed attribute.
+			// We need to check if the caret is a one position before the attribute boundary:
 			//
-			// 		<paragraph><$text attribute>bar{}</$text></paragraph>
-			// 		<paragraph><$text attribute>bar</$text>{}</paragraph>
+			//		<paragraph>foo<$text attribute=1>bar</$text><$text attribute=2>b{}az</$text></paragraph>
+			//		<paragraph>foo<$text attribute>bar</$text><$text otherAttribute>b{}az</$text></paragraph>
+			//		<paragraph>foo<$text attribute>b{}ar</$text>baz</paragraph>
+			//		<paragraph>foo<$text attribute>bar</$text>b{}az</paragraph>
 			//
-			if ( position.isAtEnd && isAtEndBoundary( position, attribute ) ) {
-				// DON'T ENGAGE 2-SCM if the selection has the attribute already.
-				// This is a common selection if set using the mouse.
-				//
-				// 		<paragraph><$text attribute>bar{}</$text></paragraph>
-				//
-				if ( this._hasSelectionAttribute ) {
-					// DON'T ENGAGE 2-SCM if the attribute at the end of the block which has length == 1.
-					// Make sure the selection will not the attribute after it moves backwards.
-					//
-					// 		<paragraph>foo<$text attribute>b{}</$text></paragraph>
-					//
-					if ( isStepAfterTheAttributeBoundary( position, attribute ) ) {
-						// Skip the automatic gravity restore upon the next selection#change:range event.
-						// If not skipped, it would automatically restore the gravity, which should remain
-						// overridden.
-						this._skipNextAutomaticGravityRestoration();
-						this._overrideGravity();
-
-						// Don't return "true" here because we didn't call _preventCaretMovement.
-						// Returning here will destabilize the filler logic, which also listens to
-						// keydown (and the event would be stopped).
-					}
-
-					return;
-				}
+			if ( isStepAfterAnyAttributeBoundary( position, attributes ) ) {
 				// ENGAGE 2-SCM if the selection has no attribute. This may happen when the user
 				// left the attribute using a FORWARD 2-SCM.
 				//
 				// 		<paragraph><$text attribute>bar</$text>{}</paragraph>
 				//
-				else {
-					this._preventCaretMovement( data );
-					this._setSelectionAttributeFromTheNodeBefore( position );
+				if (
+					position.isAtEnd &&
+					!hasAnyAttribute( selection, attributes ) &&
+					isBetweenDifferentAttributes( position, attributes )
+				) {
+					preventCaretMovement( data );
+					setSelectionAttributesFromTheNodeBefore( model, attributes, position );
 
 					return true;
 				}
-			}
-
-			// REMOVE SELECTION ATRIBUTE when restoring gravity towards a non-existent content at the
-			// beginning of the block.
-			//
-			// 		<paragraph>{}<$text attribute>bar</$text></paragraph>
-			//
-			if ( position.isAtStart ) {
-				if ( this._hasSelectionAttribute ) {
-					this._removeSelectionAttribute();
-					this._preventCaretMovement( data );
-
-					return true;
-				}
-
-				return;
-			}
-
-			// DON'T ENGAGE 2-SCM when about to enter of leave an attribute.
-			// We need to check if the caret is a one position before the attribute boundary:
-			//
-			// 		<paragraph>foo<$text attribute>b{}ar</$text>baz</paragraph>
-			// 		<paragraph>foo<$text attribute>bar</$text>b{}az</paragraph>
-			//
-			if ( isStepAfterTheAttributeBoundary( position, attribute ) ) {
 				// Skip the automatic gravity restore upon the next selection#change:range event.
 				// If not skipped, it would automatically restore the gravity, which should remain
 				// overridden.
-				this._skipNextAutomaticGravityRestoration();
+				this._isNextGravityRestorationSkipped = true;
 				this._overrideGravity();
 
 				// Don't return "true" here because we didn't call _preventCaretMovement.
 				// Returning here will destabilize the filler logic, which also listens to
 				// keydown (and the event would be stopped).
+				return false;
 			}
 		}
 	}
 
 	/**
-	 * `true` when the gravity is overridden for the {@link #attribute}.
+	 * `true` when the gravity is overridden for the plugin.
 	 *
 	 * @readonly
 	 * @private
@@ -503,17 +386,6 @@ export class TwoStepCaretHandler {
 		return !!this._overrideUid;
 	}
 
-	/**
-	 * `true` when the {@link module:engine/model/selection~Selection} has the {@link #attribute}.
-	 *
-	 * @readonly
-	 * @private
-	 * @type {Boolean}
-	 */
-	get _hasSelectionAttribute() {
-		return this._modelSelection.hasAttribute( this.attribute );
-	}
-
 	/**
 	 * Overrides the gravity using the {@link module:engine/model/writer~Writer model writer}
 	 * and stores the information about this fact in the {@link #_overrideUid}.
@@ -523,7 +395,9 @@ export class TwoStepCaretHandler {
 	 * @private
 	 */
 	_overrideGravity() {
-		this._overrideUid = this.model.change( writer => writer.overrideSelectionGravity() );
+		this._overrideUid = this.editor.model.change( writer => {
+			return writer.overrideSelectionGravity();
+		} );
 	}
 
 	/**
@@ -534,105 +408,74 @@ export class TwoStepCaretHandler {
 	 * @private
 	 */
 	_restoreGravity() {
-		this.model.change( writer => {
+		this.editor.model.change( writer => {
 			writer.restoreSelectionGravity( this._overrideUid );
 			this._overrideUid = null;
 		} );
 	}
+}
 
-	/**
-	 * Prevents the caret movement in the view by calling `preventDefault` on the event data.
-	 *
-	 * @private
-	 */
-	_preventCaretMovement( data ) {
-		data.preventDefault();
-	}
-
-	/**
-	 * Removes the {@link #attribute} from the selection using using the
-	 * {@link module:engine/model/writer~Writer model writer}.
-	 *
-	 * @private
-	 */
-	_removeSelectionAttribute() {
-		this.model.change( writer => {
-			writer.removeSelectionAttribute( this.attribute );
-		} );
-	}
-
-	/**
-	 * Applies the {@link #attribute} to the current selection using using the
-	 * value from the node before the current position. Uses
-	 * the {@link module:engine/model/writer~Writer model writer}.
-	 *
-	 * @private
-	 * @param {module:engine/model/position~Position} position
-	 */
-	_setSelectionAttributeFromTheNodeBefore( position ) {
-		const attribute = this.attribute;
-
-		this.model.change( writer => {
-			writer.setSelectionAttribute( this.attribute, position.nodeBefore.getAttribute( attribute ) );
-		} );
+// Checks whether the selection has any of given attributes.
+//
+// @param {module:engine/model/documentselection~DocumentSelection} selection
+// @param {Iterable.<String>} attributes
+function hasAnyAttribute( selection, attributes ) {
+	for ( const observedAttribute of attributes ) {
+		if ( selection.hasAttribute( observedAttribute ) ) {
+			return true;
+		}
 	}
 
-	/**
-	 * Skips the next automatic selection gravity restoration upon the
-	 * {@link module:engine/model/selection~Selection#event:change:range} event.
-	 *
-	 * See {@link #_isNextGravityRestorationSkipped}.
-	 *
-	 * @private
-	 */
-	_skipNextAutomaticGravityRestoration() {
-		this._isNextGravityRestorationSkipped = true;
-	}
+	return false;
 }
 
+// Applies the given attributes to the current selection using using the
+// values from the node before the current position. Uses
+// the {@link module:engine/model/writer~Writer model writer}.
+//
+// @param {module:engine/model/model~Model}
+// @param {Iterable.<String>} attributess
 // @param {module:engine/model/position~Position} position
-// @param {String} attribute
-// @returns {Boolean} `true` when position between the nodes sticks to the bound of text with given attribute.
-function isAtBoundary( position, attribute ) {
-	return isAtStartBoundary( position, attribute ) || isAtEndBoundary( position, attribute );
+function setSelectionAttributesFromTheNodeBefore( model, attributes, position ) {
+	const nodeBefore = position.nodeBefore;
+	model.change( writer => {
+		if ( nodeBefore ) {
+			writer.setSelectionAttribute( nodeBefore.getAttributes() );
+		} else {
+			writer.removeSelectionAttribute( attributes );
+		}
+	} );
 }
 
-// @param {module:engine/model/position~Position} position
-// @param {String} attribute
-function isAtStartBoundary( position, attribute ) {
-	const { nodeBefore, nodeAfter } = position;
-	const isAttrBefore = nodeBefore ? nodeBefore.hasAttribute( attribute ) : false;
-	const isAttrAfter = nodeAfter ? nodeAfter.hasAttribute( attribute ) : false;
-
-	return isAttrAfter && ( !isAttrBefore || nodeBefore.getAttribute( attribute ) !== nodeAfter.getAttribute( attribute ) );
+// Prevents the caret movement in the view by calling `preventDefault` on the event data.
+//
+// @alias data.preventDefault
+function preventCaretMovement( data ) {
+	data.preventDefault();
 }
 
+// Checks whether the step before `isBetweenDifferentAttributes()`.
+//
 // @param {module:engine/model/position~Position} position
 // @param {String} attribute
-function isAtEndBoundary( position, attribute ) {
-	const { nodeBefore, nodeAfter } = position;
-	const isAttrBefore = nodeBefore ? nodeBefore.hasAttribute( attribute ) : false;
-	const isAttrAfter = nodeAfter ? nodeAfter.hasAttribute( attribute ) : false;
-
-	return isAttrBefore && ( !isAttrAfter || nodeBefore.getAttribute( attribute ) !== nodeAfter.getAttribute( attribute ) );
+function isStepAfterAnyAttributeBoundary( position, attributes ) {
+	const positionBefore = position.getShiftedBy( -1 );
+	return isBetweenDifferentAttributes( positionBefore, attributes );
 }
 
+// Checks whether the given position is between different values of given attributes.
+//
 // @param {module:engine/model/position~Position} position
-// @param {String} attribute
-function isBetweenDifferentValues( position, attribute ) {
+// @param {Iterable.<String>} attributes
+function isBetweenDifferentAttributes( position, attributes ) {
 	const { nodeBefore, nodeAfter } = position;
-	const isAttrBefore = nodeBefore ? nodeBefore.hasAttribute( attribute ) : false;
-	const isAttrAfter = nodeAfter ? nodeAfter.hasAttribute( attribute ) : false;
+	for ( const observedAttribute of attributes ) {
+		const attrBefore = nodeBefore ? nodeBefore.getAttribute( observedAttribute ) : undefined;
+		const attrAfter = nodeAfter ? nodeAfter.getAttribute( observedAttribute ) : undefined;
 
-	if ( !isAttrAfter || !isAttrBefore ) {
-		return;
+		if ( attrAfter !== attrBefore ) {
+			return true;
+		}
 	}
-
-	return nodeAfter.getAttribute( attribute ) !== nodeBefore.getAttribute( attribute );
-}
-
-// @param {module:engine/model/position~Position} position
-// @param {String} attribute
-function isStepAfterTheAttributeBoundary( position, attribute ) {
-	return isAtBoundary( position.getShiftedBy( -1 ), attribute );
+	return false;
 }

+ 1 - 1
packages/ckeditor5-typing/tests/inputcommand.js

@@ -259,7 +259,7 @@ describe( 'InputCommand', () => {
 				model.change( writer => {
 					let rangeSelection;
 
-					for ( const range of selection.getRanges() ) {
+					for ( const range of Array.from( selection.getRanges() ) ) {
 						rangeSelection = writer.createSelection( range );
 
 						model.deleteContent( rangeSelection );

+ 3 - 0
packages/ckeditor5-typing/tests/manual/two-step-caret.html

@@ -3,7 +3,9 @@
 <div id="editor-ltr">
 	<p>Foo <u>bar</u> biz</p>
 	<p>Foo <u>bar</u><i>biz</i> buz?</p>
+	<p>Foo <i><u>barbiz</u></i> buz?</p>
 	<p>Foo <b>bar</b> biz</p>
+	<p>Foo <u>bar</u><i>biz</i></p>
 </div>
 
 <h2>Right–to–left content</h2>
@@ -11,6 +13,7 @@
 <div id="editor-rtl">
 	<p>&nbsp;היא <u>תכונה</u> של</p>
 	<p>&nbsp;זהה <i>לזה</i><u>שיוצג</u> בתוצאה</p>
+	<p>&nbsp;זהה <i><u>לזהשיוצג</u></i> בתוצאה</p>
 	<p>וכדומה. <strong>תכונה</strong> זו מאפיינת</p>
 </div>
 

+ 260 - 196
packages/ckeditor5-typing/tests/twostepcaretmovement.js

@@ -9,12 +9,14 @@ import VirtualTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/virtualtest
 import DomEmitterMixin from '@ckeditor/ckeditor5-utils/src/dom/emittermixin';
 import DomEventData from '@ckeditor/ckeditor5-engine/src/view/observer/domeventdata';
 import EventInfo from '@ckeditor/ckeditor5-utils/src/eventinfo';
-import TwoStepCaretMovement, { TwoStepCaretHandler } from '../src/twostepcaretmovement';
+import TwoStepCaretMovement from '../src/twostepcaretmovement';
 import Position from '@ckeditor/ckeditor5-engine/src/model/position';
 import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils';
 import { keyCodes } from '@ckeditor/ckeditor5-utils/src/keyboard';
 import { setData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
 
+import '@ckeditor/ckeditor5-core/tests/_utils/assertions/attribute';
+
 describe( 'TwoStepCaretMovement()', () => {
 	let editor, model, emitter, selection, view, plugin;
 	let preventDefaultSpy, evtStopSpy;
@@ -31,8 +33,8 @@ describe( 'TwoStepCaretMovement()', () => {
 			view = editor.editing.view;
 			plugin = editor.plugins.get( TwoStepCaretMovement );
 
-			preventDefaultSpy = sinon.spy().named( 'preventDefaultSpy' );
-			evtStopSpy = sinon.spy().named( 'evtStopSpy' );
+			preventDefaultSpy = sinon.spy().named( 'preventDefault' );
+			evtStopSpy = sinon.spy().named( 'evt.stop' );
 
 			editor.model.schema.extend( '$text', {
 				allowAttributes: [ 'a', 'b', 'c' ],
@@ -58,9 +60,9 @@ describe( 'TwoStepCaretMovement()', () => {
 			setData( model, '[]<$text c="true">foo</$text>' );
 
 			testTwoStepCaretMovement( [
-				{ selectionAttributes: [ 'c' ], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 },
+				{ selectionAttributes: [ 'c' ], isGravityOverridden: false, preventDefault: 0, evtStop: 0, caretPosition: 0 },
 				'→',
-				{ selectionAttributes: [ 'c' ], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 }
+				{ selectionAttributes: [ 'c' ], isGravityOverridden: false, preventDefault: 0, evtStop: 0, caretPosition: 1 }
 			] );
 		} );
 
@@ -68,9 +70,9 @@ describe( 'TwoStepCaretMovement()', () => {
 			setData( model, '<$text c="true">foo[]</$text>' );
 
 			testTwoStepCaretMovement( [
-				{ selectionAttributes: [ 'c' ], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 },
+				{ selectionAttributes: [ 'c' ], isGravityOverridden: false, preventDefault: 0, evtStop: 0 },
 				'→',
-				{ selectionAttributes: [ 'c' ], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 }
+				{ selectionAttributes: [ 'c' ], isGravityOverridden: false, preventDefault: 0, evtStop: 0 }
 			] );
 		} );
 
@@ -79,13 +81,13 @@ describe( 'TwoStepCaretMovement()', () => {
 
 			testTwoStepCaretMovement( [
 				// Gravity is not overridden, caret is at the beginning of the text but is "outside" of the text.
-				{ selectionAttributes: [ 'c' ], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 },
+				{ selectionAttributes: [ 'c' ], isGravityOverridden: false, preventDefault: 0, evtStop: 0 },
 				'→',
 				// Gravity is overridden, caret movement is blocked, selection at the beginning but "inside" the text.
-				{ selectionAttributes: [ 'a', 'b' ], isGravityOverridden: true, preventDefault: 1, evtStopCalled: 1 },
+				{ selectionAttributes: [ 'a', 'b' ], isGravityOverridden: true, preventDefault: 1, evtStop: 1 },
 				'→',
 				// Caret movement was not blocked this time (still once) so everything works normally.
-				{ selectionAttributes: [ 'a', 'b' ], isGravityOverridden: false, preventDefault: 1, evtStopCalled: 1 }
+				{ selectionAttributes: [ 'a', 'b' ], isGravityOverridden: false, preventDefault: 1, evtStop: 1 }
 			] );
 		} );
 
@@ -94,12 +96,12 @@ describe( 'TwoStepCaretMovement()', () => {
 
 			testTwoStepCaretMovement( [
 				// Gravity is not overridden, caret is at the end of the text but is "inside" of the text.
-				{ selectionAttributes: [ 'a', 'b' ], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 },
+				{ selectionAttributes: [ 'a', 'b' ], isGravityOverridden: false, preventDefault: 0, evtStop: 0 },
 				'→',
 				// Gravity is overridden, caret movement is blocked, selection at the end but "outside" the text.
-				{ selectionAttributes: [ 'c' ], isGravityOverridden: true, preventDefault: 1, evtStopCalled: 1 },
+				{ selectionAttributes: [ 'c' ], isGravityOverridden: true, preventDefault: 1, evtStop: 1 },
 				'→',
-				{ selectionAttributes: [ 'c' ], isGravityOverridden: false, preventDefault: 1, evtStopCalled: 1 }
+				{ selectionAttributes: [ 'c' ], isGravityOverridden: false, preventDefault: 1, evtStop: 1 }
 			] );
 		} );
 
@@ -107,16 +109,13 @@ describe( 'TwoStepCaretMovement()', () => {
 			setData( model, '<$text a="1">bar[]</$text><$text a="2">foo</$text>' );
 
 			testTwoStepCaretMovement( [
-				{ selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 },
-				'→',
-				// <$text a="1">bar</$text>[]<$text a="2">foo</$text>
-				{ selectionAttributes: [], isGravityOverridden: false, preventDefault: 1, evtStopCalled: 1 },
+				{ selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 0, evtStop: 0, caretPosition: 3 },
 				'→',
 				// <$text a="1">bar</$text><$text a="2">[]foo</$text>
-				{ selectionAttributes: [ 'a' ], isGravityOverridden: true, preventDefault: 2, evtStopCalled: 2 },
+				{ selectionAttributes: [ 'a' ], isGravityOverridden: true, preventDefault: 1, evtStop: 1, caretPosition: 3 },
 				'→',
 				// <$text a="1">bar</$text><$text a="2">f[]oo</$text>
-				{ selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 2, evtStopCalled: 2 }
+				{ selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 1, evtStop: 1, caretPosition: 4 }
 			] );
 		} );
 
@@ -125,32 +124,36 @@ describe( 'TwoStepCaretMovement()', () => {
 			setData( model, '<$text a="1">fo[]o</$text><$text a="1" b="2">bar</$text><$text a="1">baz</$text>' );
 
 			testTwoStepCaretMovement( [
-				{ selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 },
+				{ selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 0, evtStop: 0 },
 				'→',
-				{ selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 },
+				{ selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 0, evtStop: 0 },
 				'→',
-				{ selectionAttributes: [ 'a', 'b' ], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 },
+				{ selectionAttributes: [ 'a', 'b' ], isGravityOverridden: false, preventDefault: 0, evtStop: 0 },
 				'→',
-				{ selectionAttributes: [ 'a', 'b' ], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 },
+				{ selectionAttributes: [ 'a', 'b' ], isGravityOverridden: false, preventDefault: 0, evtStop: 0 },
 				'→',
-				{ selectionAttributes: [ 'a', 'b' ], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 },
+				{ selectionAttributes: [ 'a', 'b' ], isGravityOverridden: false, preventDefault: 0, evtStop: 0 },
 				'→',
-				{ selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 }
+				{ selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 0, evtStop: 0 }
 			] );
 		} );
 
 		// https://github.com/ckeditor/ckeditor5-engine/issues/1301
 		it( 'should handle passing through the only character in the block', () => {
-			setData( model, '[]<$text a="1">x</$text>' );
+			setData( model, '<$text a="1">[]x</$text>' );
 
 			testTwoStepCaretMovement( [
-				{ selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 },
+				// <$text a="1">[]x</$text>
+				{ selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 0, evtStop: 0, caretPosition: 0 },
 				'→',
-				{ selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 },
+				// <$text a="1">x[]</$text>
+				{ selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 0, evtStop: 0, caretPosition: 1 },
 				'→',
-				{ selectionAttributes: [], isGravityOverridden: true, preventDefault: 1, evtStopCalled: 1 },
+				// <$text a="1">x</$text>[]
+				{ selectionAttributes: [], isGravityOverridden: true, preventDefault: 1, evtStop: 1, caretPosition: 1 },
 				'→',
-				{ selectionAttributes: [], isGravityOverridden: true, preventDefault: 1, evtStopCalled: 1 }
+				// Stays at <$text a="1">x</$text>[]
+				{ selectionAttributes: [], isGravityOverridden: true, preventDefault: 1, evtStop: 1, caretPosition: 1 }
 			] );
 		} );
 
@@ -161,15 +164,15 @@ describe( 'TwoStepCaretMovement()', () => {
 			model.change( writer => writer.removeSelectionAttribute( 'a' ) );
 
 			testTwoStepCaretMovement( [
-				{ selectionAttributes: [], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 },
+				{ selectionAttributes: [], isGravityOverridden: false, preventDefault: 0, evtStop: 0 },
 				'→',
-				{ selectionAttributes: [ 'a' ], isGravityOverridden: true, preventDefault: 1, evtStopCalled: 1 },
+				{ selectionAttributes: [ 'a' ], isGravityOverridden: true, preventDefault: 1, evtStop: 1 },
 				'→',
-				{ selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 1, evtStopCalled: 1 },
+				{ selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 1, evtStop: 1 },
 				'→',
-				{ selectionAttributes: [], isGravityOverridden: true, preventDefault: 2, evtStopCalled: 2 },
+				{ selectionAttributes: [], isGravityOverridden: true, preventDefault: 2, evtStop: 2 },
 				'→',
-				{ selectionAttributes: [], isGravityOverridden: true, preventDefault: 2, evtStopCalled: 2 }
+				{ selectionAttributes: [], isGravityOverridden: true, preventDefault: 2, evtStop: 2 }
 			] );
 		} );
 
@@ -178,22 +181,22 @@ describe( 'TwoStepCaretMovement()', () => {
 			setData( model, '[]<$text a="1">xyz</$text>' );
 
 			testTwoStepCaretMovement( [
-				{ selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 },
+				{ selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 0, evtStop: 0 },
 				'→',
 				// <$text a="1">x{}yz</$text>
-				{ selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 },
+				{ selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 0, evtStop: 0 },
 				'→',
 				// <$text a="1">xy{}z</$text>
-				{ selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 },
+				{ selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 0, evtStop: 0 },
 				'→',
 				// <$text a="1">xyz{}</$text>
-				{ selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 },
+				{ selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 0, evtStop: 0 },
 				'→',
 				// <$text a="1">xyz</$text>{}
-				{ selectionAttributes: [], isGravityOverridden: true, preventDefault: 1, evtStopCalled: 1 },
+				{ selectionAttributes: [], isGravityOverridden: true, preventDefault: 1, evtStop: 1 },
 				'→',
 				// <$text a="1">xyz</$text>{}
-				{ selectionAttributes: [], isGravityOverridden: true, preventDefault: 1, evtStopCalled: 1 }
+				{ selectionAttributes: [], isGravityOverridden: true, preventDefault: 1, evtStop: 1 }
 			] );
 		} );
 
@@ -201,16 +204,16 @@ describe( 'TwoStepCaretMovement()', () => {
 			setData( model, '<paragraph><$text a="1">foo[]</$text></paragraph><paragraph><$text b="1">bar</$text></paragraph>' );
 
 			testTwoStepCaretMovement( [
-				{ selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 },
+				{ selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 0, evtStop: 0 },
 				'→',
 				// <paragraph><$text a="1">bar</$text>[]</paragraph><paragraph>foo</paragraph>
-				{ selectionAttributes: [], isGravityOverridden: true, preventDefault: 1, evtStopCalled: 1 },
+				{ selectionAttributes: [], isGravityOverridden: true, preventDefault: 1, evtStop: 1 },
 				'→',
 				// <paragraph><$text a="1">bar</$text></paragraph><paragraph>f[]oo</paragraph>
-				{ selectionAttributes: [ 'b' ], isGravityOverridden: false, preventDefault: 1, evtStopCalled: 1 },
+				{ selectionAttributes: [ 'b' ], isGravityOverridden: false, preventDefault: 1, evtStop: 1 },
 				'→',
 				// <paragraph><$text a="1">bar</$text></paragraph><paragraph>fo[]o</paragraph>
-				{ selectionAttributes: [ 'b' ], isGravityOverridden: false, preventDefault: 1, evtStopCalled: 1 }
+				{ selectionAttributes: [ 'b' ], isGravityOverridden: false, preventDefault: 1, evtStop: 1 }
 			] );
 		} );
 	} );
@@ -221,14 +224,14 @@ describe( 'TwoStepCaretMovement()', () => {
 
 			testTwoStepCaretMovement( [
 				// Gravity is not overridden, caret is a one character after the and of the text.
-				{ selectionAttributes: [ 'c' ], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 },
+				{ selectionAttributes: [ 'c' ], isGravityOverridden: false, preventDefault: 0, evtStop: 0 },
 				'←',
 				// Caret movement was not blocked but the gravity is overridden.
-				{ selectionAttributes: [ 'c' ], isGravityOverridden: true, preventDefault: 0, evtStopCalled: 0 },
+				{ selectionAttributes: [ 'c' ], isGravityOverridden: true, preventDefault: 0, evtStop: 0 },
 				'←',
-				{ selectionAttributes: [ 'a', 'b' ], isGravityOverridden: false, preventDefault: 1, evtStopCalled: 1 },
+				{ selectionAttributes: [ 'a', 'b' ], isGravityOverridden: false, preventDefault: 1, evtStop: 1 },
 				'←',
-				{ selectionAttributes: [ 'a', 'b' ], isGravityOverridden: false, preventDefault: 1, evtStopCalled: 1 }
+				{ selectionAttributes: [ 'a', 'b' ], isGravityOverridden: false, preventDefault: 1, evtStop: 1 }
 			] );
 		} );
 
@@ -237,14 +240,14 @@ describe( 'TwoStepCaretMovement()', () => {
 
 			testTwoStepCaretMovement( [
 				// Gravity is not overridden, caret is a one character after the beginning of the text.
-				{ selectionAttributes: [ 'a', 'b' ], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 },
+				{ selectionAttributes: [ 'a', 'b' ], isGravityOverridden: false, preventDefault: 0, evtStop: 0 },
 				'←',
 				// Caret movement was not blocked.
-				{ selectionAttributes: [ 'a', 'b' ], isGravityOverridden: true, preventDefault: 0, evtStopCalled: 0 },
+				{ selectionAttributes: [ 'a', 'b' ], isGravityOverridden: true, preventDefault: 0, evtStop: 0 },
 				'←',
-				{ selectionAttributes: [ 'c' ], isGravityOverridden: false, preventDefault: 1, evtStopCalled: 1 },
+				{ selectionAttributes: [ 'c' ], isGravityOverridden: false, preventDefault: 1, evtStop: 1 },
 				'←',
-				{ selectionAttributes: [ 'c' ], isGravityOverridden: false, preventDefault: 1, evtStopCalled: 1 }
+				{ selectionAttributes: [ 'c' ], isGravityOverridden: false, preventDefault: 1, evtStop: 1 }
 			] );
 		} );
 
@@ -252,11 +255,11 @@ describe( 'TwoStepCaretMovement()', () => {
 			setData( model, '<$text c="true">[]foo</$text>' );
 
 			testTwoStepCaretMovement( [
-				{ selectionAttributes: [ 'c' ], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 },
+				{ selectionAttributes: [ 'c' ], isGravityOverridden: false, preventDefault: 0, evtStop: 0 },
 				'←',
-				{ selectionAttributes: [ 'c' ], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 },
+				{ selectionAttributes: [ 'c' ], isGravityOverridden: false, preventDefault: 0, evtStop: 0 },
 				'←',
-				{ selectionAttributes: [ 'c' ], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 }
+				{ selectionAttributes: [ 'c' ], isGravityOverridden: false, preventDefault: 0, evtStop: 0 }
 			] );
 		} );
 
@@ -264,11 +267,11 @@ describe( 'TwoStepCaretMovement()', () => {
 			setData( model, '<$text c="true">foo</$text>[]' );
 
 			testTwoStepCaretMovement( [
-				{ selectionAttributes: [ 'c' ], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 },
+				{ selectionAttributes: [ 'c' ], isGravityOverridden: false, preventDefault: 0, evtStop: 0 },
 				'←',
-				{ selectionAttributes: [ 'c' ], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 },
+				{ selectionAttributes: [ 'c' ], isGravityOverridden: false, preventDefault: 0, evtStop: 0 },
 				'←',
-				{ selectionAttributes: [ 'c' ], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 }
+				{ selectionAttributes: [ 'c' ], isGravityOverridden: false, preventDefault: 0, evtStop: 0 }
 			] );
 		} );
 
@@ -276,9 +279,9 @@ describe( 'TwoStepCaretMovement()', () => {
 			setData( model, '[]foo', { lastRangeBackward: true } );
 
 			testTwoStepCaretMovement( [
-				{ selectionAttributes: [], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 },
+				{ selectionAttributes: [], isGravityOverridden: false, preventDefault: 0, evtStop: 0 },
 				'←',
-				{ selectionAttributes: [], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 }
+				{ selectionAttributes: [], isGravityOverridden: false, preventDefault: 0, evtStop: 0 }
 			] );
 		} );
 
@@ -286,19 +289,19 @@ describe( 'TwoStepCaretMovement()', () => {
 			setData( model, '<$text a="2">foo</$text><$text a="1">b[]ar</$text>' );
 
 			testTwoStepCaretMovement( [
-				{ selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 },
+				{ selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 0, evtStop: 0, caretPosition: 4 },
 				'←',
 				// <$text a="2">foo</$text><$text a="1">[]bar</$text>
-				{ selectionAttributes: [ 'a' ], isGravityOverridden: true, preventDefault: 0, evtStopCalled: 0 },
-				'←',
-				// <$text a="2">foo</$text>[]<$text a="1">bar</$text>
-				{ selectionAttributes: [], isGravityOverridden: false, preventDefault: 1, evtStopCalled: 1 },
+				{ selectionAttributes: [ 'a' ], isGravityOverridden: true, preventDefault: 0, evtStop: 0, caretPosition: 3 },
 				'←',
 				// <$text a="2">foo[]</$text><$text a="1">bar</$text>
-				{ selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 2, evtStopCalled: 2 },
+				{ selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 1, evtStop: 1, caretPosition: 3 }
+			] );
+			expect( selection ).to.have.attribute( 'a', 2 );
+			testTwoStepCaretMovement( [
 				'←',
 				// <$text a="2">fo[]o</$text><$text a="1">bar</$text>
-				{ selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 2, evtStopCalled: 2 }
+				{ selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 1, evtStop: 1, caretPosition: 2 }
 			] );
 		} );
 
@@ -307,15 +310,15 @@ describe( 'TwoStepCaretMovement()', () => {
 			setData( model, '<$text a="1">foo</$text><$text a="1" b="2">bar</$text><$text a="1">b[]az</$text>' );
 
 			testTwoStepCaretMovement( [
-				{ selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 },
+				{ selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 0, evtStop: 0 },
 				'←',
-				{ selectionAttributes: [ 'a', 'b' ], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 },
+				{ selectionAttributes: [ 'a', 'b' ], isGravityOverridden: false, preventDefault: 0, evtStop: 0 },
 				'←',
-				{ selectionAttributes: [ 'a', 'b' ], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 },
+				{ selectionAttributes: [ 'a', 'b' ], isGravityOverridden: false, preventDefault: 0, evtStop: 0 },
 				'←',
-				{ selectionAttributes: [ 'a', 'b' ], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 },
+				{ selectionAttributes: [ 'a', 'b' ], isGravityOverridden: false, preventDefault: 0, evtStop: 0 },
 				'←',
-				{ selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 }
+				{ selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 0, evtStop: 0 }
 			] );
 		} );
 
@@ -324,16 +327,16 @@ describe( 'TwoStepCaretMovement()', () => {
 			setData( model, '<$text a="1">x</$text>[]' );
 
 			testTwoStepCaretMovement( [
-				{ selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 },
+				{ selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 0, evtStop: 0, caretPosition: 1 },
 				'←',
 				// <$text a="1">{}x</$text>
-				{ selectionAttributes: [ 'a' ], isGravityOverridden: true, preventDefault: 0, evtStopCalled: 0 },
+				{ selectionAttributes: [ 'a' ], isGravityOverridden: true, preventDefault: 0, evtStop: 0, caretPosition: 0 },
 				'←',
 				// {}<$text a="1">x</$text> (because it's a first-child)
-				{ selectionAttributes: [], isGravityOverridden: false, preventDefault: 1, evtStopCalled: 1 },
+				{ selectionAttributes: [], isGravityOverridden: false, preventDefault: 1, evtStop: 1, caretPosition: 0 },
 				'←',
 				// {}<$text a="1">x</$text>
-				{ selectionAttributes: [], isGravityOverridden: false, preventDefault: 1, evtStopCalled: 1 }
+				{ selectionAttributes: [], isGravityOverridden: false, preventDefault: 1, evtStop: 1, caretPosition: 0 }
 			] );
 		} );
 
@@ -344,15 +347,15 @@ describe( 'TwoStepCaretMovement()', () => {
 			model.change( writer => writer.removeSelectionAttribute( 'a' ) );
 
 			testTwoStepCaretMovement( [
-				{ selectionAttributes: [], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 },
+				{ selectionAttributes: [], isGravityOverridden: false, preventDefault: 0, evtStop: 0 },
 				'←',
-				{ selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 1, evtStopCalled: 1 },
+				{ selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 1, evtStop: 1 },
 				'←',
-				{ selectionAttributes: [ 'a' ], isGravityOverridden: true, preventDefault: 1, evtStopCalled: 1 },
+				{ selectionAttributes: [ 'a' ], isGravityOverridden: true, preventDefault: 1, evtStop: 1 },
 				'←',
-				{ selectionAttributes: [], isGravityOverridden: false, preventDefault: 2, evtStopCalled: 2 },
+				{ selectionAttributes: [], isGravityOverridden: false, preventDefault: 2, evtStop: 2 },
 				'←',
-				{ selectionAttributes: [], isGravityOverridden: false, preventDefault: 2, evtStopCalled: 2 }
+				{ selectionAttributes: [], isGravityOverridden: false, preventDefault: 2, evtStop: 2 }
 			] );
 		} );
 
@@ -361,16 +364,16 @@ describe( 'TwoStepCaretMovement()', () => {
 			setData( model, 'abc<$text a="1">x</$text>[]' );
 
 			testTwoStepCaretMovement( [
-				{ selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 },
+				{ selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 0, evtStop: 0 },
 				'←',
 				// abc<$text a="1">{}x</$text>
-				{ selectionAttributes: [ 'a' ], isGravityOverridden: true, preventDefault: 0, evtStopCalled: 0 },
+				{ selectionAttributes: [ 'a' ], isGravityOverridden: true, preventDefault: 0, evtStop: 0 },
 				'←',
 				// abc{}<$text a="1">x</$text> (because it's a first-child)
-				{ selectionAttributes: [], isGravityOverridden: false, preventDefault: 1, evtStopCalled: 1 },
+				{ selectionAttributes: [], isGravityOverridden: false, preventDefault: 1, evtStop: 1 },
 				'←',
 				// abc{}<$text a="1">x</$text>
-				{ selectionAttributes: [], isGravityOverridden: false, preventDefault: 1, evtStopCalled: 1 }
+				{ selectionAttributes: [], isGravityOverridden: false, preventDefault: 1, evtStop: 1 }
 			] );
 		} );
 
@@ -379,22 +382,22 @@ describe( 'TwoStepCaretMovement()', () => {
 			setData( model, '<$text a="1">xyz</$text>[]' );
 
 			testTwoStepCaretMovement( [
-				{ selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 },
+				{ selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 0, evtStop: 0 },
 				'←',
 				// <$text a="1">xy{}z</$text>
-				{ selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 },
+				{ selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 0, evtStop: 0 },
 				'←',
 				// <$text a="1">x{}yz</$text>
-				{ selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 },
+				{ selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 0, evtStop: 0 },
 				'←',
 				// <$text a="1">{}xyz</$text>
-				{ selectionAttributes: [ 'a' ], isGravityOverridden: true, preventDefault: 0, evtStopCalled: 0 },
+				{ selectionAttributes: [ 'a' ], isGravityOverridden: true, preventDefault: 0, evtStop: 0 },
 				'←',
 				// {}<$text a="1">xyz</$text>
-				{ selectionAttributes: [], isGravityOverridden: false, preventDefault: 1, evtStopCalled: 1 },
+				{ selectionAttributes: [], isGravityOverridden: false, preventDefault: 1, evtStop: 1 },
 				'←',
 				// {}<$text a="1">xyz</$text>
-				{ selectionAttributes: [], isGravityOverridden: false, preventDefault: 1, evtStopCalled: 1 }
+				{ selectionAttributes: [], isGravityOverridden: false, preventDefault: 1, evtStop: 1 }
 			] );
 		} );
 
@@ -402,11 +405,11 @@ describe( 'TwoStepCaretMovement()', () => {
 			setData( model, '<paragraph><$text b="1">foo</$text></paragraph><paragraph><$text a="1">[]bar</$text></paragraph>' );
 
 			testTwoStepCaretMovement( [
-				{ selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 },
+				{ selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 0, evtStop: 0, caretPosition: [ 1, 0 ] },
 				'←',
-				{ selectionAttributes: [], isGravityOverridden: false, preventDefault: 1, evtStopCalled: 1 },
+				{ selectionAttributes: [], isGravityOverridden: false, preventDefault: 1, evtStop: 1, caretPosition: [ 1, 0 ] },
 				'←',
-				{ selectionAttributes: [ 'b' ], isGravityOverridden: false, preventDefault: 1, evtStopCalled: 1 }
+				{ selectionAttributes: [ 'b' ], isGravityOverridden: false, preventDefault: 1, evtStop: 1, caretPosition: [ 0, 3 ] }
 			] );
 		} );
 	} );
@@ -416,25 +419,25 @@ describe( 'TwoStepCaretMovement()', () => {
 			setData( model, '<$text a="1">x[]</$text>' );
 
 			testTwoStepCaretMovement( [
-				{ selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 },
+				{ selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 0, evtStop: 0, caretPosition: 1 },
 				'y',
 				// <$text a="1">xy[]</$text>
-				{ selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 },
+				{ selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 0, evtStop: 0, caretPosition: 2 },
 				'→',
 				// <$text a="1">xy</$text>[]
-				{ selectionAttributes: [], isGravityOverridden: true, preventDefault: 1, evtStopCalled: 1 },
+				{ selectionAttributes: [], isGravityOverridden: true, preventDefault: 1, evtStop: 1, caretPosition: 2 },
 				'z',
 				// <$text a="1">xy</$text>z[]
-				{ selectionAttributes: [], isGravityOverridden: false, preventDefault: 1, evtStopCalled: 1 },
+				{ selectionAttributes: [], isGravityOverridden: false, preventDefault: 1, evtStop: 1, caretPosition: 3 },
 				'←',
 				// <$text a="1">xy</$text>[]z
-				{ selectionAttributes: [], isGravityOverridden: true, preventDefault: 1, evtStopCalled: 1 },
+				{ selectionAttributes: [], isGravityOverridden: true, preventDefault: 1, evtStop: 1, caretPosition: 2 },
 				'←',
 				// <$text a="1">xy[]</$text>z
-				{ selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 2, evtStopCalled: 2 },
+				{ selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 2, evtStop: 2, caretPosition: 2 },
 				'w',
 				// <$text a="1">xyw[]</$text>
-				{ selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 2, evtStopCalled: 2 }
+				{ selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 2, evtStop: 2, caretPosition: 3 }
 			] );
 		} );
 
@@ -442,22 +445,22 @@ describe( 'TwoStepCaretMovement()', () => {
 			setData( model, '<$text a="1">[]x</$text>' );
 
 			testTwoStepCaretMovement( [
-				{ selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 },
+				{ selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 0, evtStop: 0 },
 				'←',
 				// []<$text a="1">x</$text>
-				{ selectionAttributes: [], isGravityOverridden: false, preventDefault: 1, evtStopCalled: 1 },
+				{ selectionAttributes: [], isGravityOverridden: false, preventDefault: 1, evtStop: 1 },
 				'z',
 				// z[]<$text a="1">x</$text>
-				{ selectionAttributes: [], isGravityOverridden: false, preventDefault: 1, evtStopCalled: 1 },
+				{ selectionAttributes: [], isGravityOverridden: false, preventDefault: 1, evtStop: 1 },
 				'x',
 				// zx[]<$text a="1">x</$text>
-				{ selectionAttributes: [], isGravityOverridden: false, preventDefault: 1, evtStopCalled: 1 },
+				{ selectionAttributes: [], isGravityOverridden: false, preventDefault: 1, evtStop: 1 },
 				'→',
 				// zx<$text a="1">[]x</$text>
-				{ selectionAttributes: [ 'a' ], isGravityOverridden: true, preventDefault: 2, evtStopCalled: 2 },
+				{ selectionAttributes: [ 'a' ], isGravityOverridden: true, preventDefault: 2, evtStop: 2 },
 				'a',
 				// zx<$text a="1">a[]x</$text>
-				{ selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 2, evtStopCalled: 2 }
+				{ selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 2, evtStop: 2 }
 			] );
 		} );
 
@@ -467,31 +470,31 @@ describe( 'TwoStepCaretMovement()', () => {
 			setData( model, 'fo[]o <$text a="1">bar</$text>' );
 
 			testTwoStepCaretMovement( [
-				{ selectionAttributes: [], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 },
+				{ selectionAttributes: [], isGravityOverridden: false, preventDefault: 0, evtStop: 0 },
 				'→',
 				// foo[] <$text a="1">bar</$text>
-				{ selectionAttributes: [], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 },
+				{ selectionAttributes: [], isGravityOverridden: false, preventDefault: 0, evtStop: 0 },
 				'→',
 				// foo []<$text a="1">bar</$text>
-				{ selectionAttributes: [], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 },
+				{ selectionAttributes: [], isGravityOverridden: false, preventDefault: 0, evtStop: 0 },
 				'→',
 				// foo <$text a="1">[]bar</$text>
-				{ selectionAttributes: [ 'a' ], isGravityOverridden: true, preventDefault: 1, evtStopCalled: 1 },
+				{ selectionAttributes: [ 'a' ], isGravityOverridden: true, preventDefault: 1, evtStop: 1 },
 				'→',
 				// foo <$text a="1">b[]ar</$text>
-				{ selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 1, evtStopCalled: 1 },
+				{ selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 1, evtStop: 1 },
 				'←',
 				// foo <$text a="1">[]bar</$text>
-				{ selectionAttributes: [ 'a' ], isGravityOverridden: true, preventDefault: 1, evtStopCalled: 1 },
+				{ selectionAttributes: [ 'a' ], isGravityOverridden: true, preventDefault: 1, evtStop: 1 },
 				'←',
 				// foo []<$text a="1">bar</$text>
-				{ selectionAttributes: [], isGravityOverridden: false, preventDefault: 2, evtStopCalled: 2 },
+				{ selectionAttributes: [], isGravityOverridden: false, preventDefault: 2, evtStop: 2 },
 				'←',
 				// foo[] <$text a="1">bar</$text>
-				{ selectionAttributes: [], isGravityOverridden: false, preventDefault: 2, evtStopCalled: 2 },
+				{ selectionAttributes: [], isGravityOverridden: false, preventDefault: 2, evtStop: 2 },
 				'←',
 				// fo[]o <$text a="1">bar</$text>
-				{ selectionAttributes: [], isGravityOverridden: false, preventDefault: 2, evtStopCalled: 2 }
+				{ selectionAttributes: [], isGravityOverridden: false, preventDefault: 2, evtStop: 2 }
 			] );
 		} );
 
@@ -500,10 +503,10 @@ describe( 'TwoStepCaretMovement()', () => {
 			setData( model, '<$text a="1">x[]</$text>' );
 
 			testTwoStepCaretMovement( [
-				{ selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 },
+				{ selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 0, evtStop: 0 },
 				'→',
 				// <$text a="1">x</$text>[]
-				{ selectionAttributes: [], isGravityOverridden: true, preventDefault: 1, evtStopCalled: 1 }
+				{ selectionAttributes: [], isGravityOverridden: true, preventDefault: 1, evtStop: 1 }
 			] );
 
 			model.change( writer => {
@@ -528,10 +531,10 @@ describe( 'TwoStepCaretMovement()', () => {
 			setData( model, '<$text a="1">[]x</$text>' );
 
 			testTwoStepCaretMovement( [
-				{ selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 },
+				{ selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 0, evtStop: 0 },
 				'←',
 				// []<$text a="1">x</$text>
-				{ selectionAttributes: [], isGravityOverridden: false, preventDefault: 1, evtStopCalled: 1 }
+				{ selectionAttributes: [], isGravityOverridden: false, preventDefault: 1, evtStop: 1 }
 			] );
 
 			model.change( writer => {
@@ -562,40 +565,40 @@ describe( 'TwoStepCaretMovement()', () => {
 
 			testTwoStepCaretMovement( [
 				// fo[]o<$text a="true">foo</$text><$text a="true" c="true">bar</$text><$text c="true">baz</$text>qux
-				{ selectionAttributes: [], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 },
+				{ selectionAttributes: [], isGravityOverridden: false, preventDefault: 0, evtStop: 0 },
 				'→',
 				// foo[]<$text a="true">foo</$text><$text a="true" c="true">bar</$text><$text c="true">baz</$text>qux
-				{ selectionAttributes: [], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 },
+				{ selectionAttributes: [], isGravityOverridden: false, preventDefault: 0, evtStop: 0 },
 				'→',
 				// foo<$text a="true">[]foo</$text><$text a="true" c="true">bar</$text><$text c="true">baz</$text>qux
-				{ selectionAttributes: [ 'a' ], isGravityOverridden: true, preventDefault: 1, evtStopCalled: 1 },
+				{ selectionAttributes: [ 'a' ], isGravityOverridden: true, preventDefault: 1, evtStop: 1 },
 				'→',
 				'→',
 				'→',
 				// foo<$text a="true">foo[]</$text><$text a="true" c="true">bar</$text><$text c="true">baz</$text>qux
-				{ selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 1, evtStopCalled: 1 },
+				{ selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 1, evtStop: 1 },
 				'→',
 				// foo<$text a="true">foo</$text><$text a="true" c="true">[]bar</$text><$text c="true">baz</$text>qux
-				{ selectionAttributes: [ 'a', 'c' ], isGravityOverridden: true, preventDefault: 2, evtStopCalled: 2 },
+				{ selectionAttributes: [ 'a', 'c' ], isGravityOverridden: true, preventDefault: 2, evtStop: 2 },
 				'→',
 				'→',
 				'→',
 				// foo<$text a="true">foo</$text><$text a="true" c="true">bar[]</$text><$text c="true">baz</$text>qux
-				{ selectionAttributes: [ 'a', 'c' ], isGravityOverridden: false, preventDefault: 2, evtStopCalled: 2 },
+				{ selectionAttributes: [ 'a', 'c' ], isGravityOverridden: false, preventDefault: 2, evtStop: 2 },
 				'→',
 				// foo<$text a="true">foo</$text><$text a="true" c="true">bar</$text><$text c="true">[]baz</$text>qux
-				{ selectionAttributes: [ 'c' ], isGravityOverridden: true, preventDefault: 3, evtStopCalled: 3 },
+				{ selectionAttributes: [ 'c' ], isGravityOverridden: true, preventDefault: 3, evtStop: 3 },
 				'→',
 				'→',
 				'→',
 				// foo<$text a="true">foo</$text><$text a="true" c="true">bar</$text><$text c="true">baz[]</$text>qux
-				{ selectionAttributes: [ 'c' ], isGravityOverridden: false, preventDefault: 3, evtStopCalled: 3 },
+				{ selectionAttributes: [ 'c' ], isGravityOverridden: false, preventDefault: 3, evtStop: 3 },
 				'→',
 				// foo<$text a="true">foo</$text><$text a="true" c="true">bar</$text><$text c="true">baz</$text>[]qux
-				{ selectionAttributes: [], isGravityOverridden: true, preventDefault: 4, evtStopCalled: 4 },
+				{ selectionAttributes: [], isGravityOverridden: true, preventDefault: 4, evtStop: 4 },
 				'→',
 				// foo<$text a="true">foo</$text><$text a="true" c="true">bar</$text><$text c="true">baz</$text>q[]ux
-				{ selectionAttributes: [], isGravityOverridden: false, preventDefault: 4, evtStopCalled: 4 }
+				{ selectionAttributes: [], isGravityOverridden: false, preventDefault: 4, evtStop: 4 }
 			] );
 		} );
 
@@ -604,42 +607,118 @@ describe( 'TwoStepCaretMovement()', () => {
 
 			testTwoStepCaretMovement( [
 				// foo<$text a="true">foo</$text><$text a="true" c="true">bar</$text><$text c="true">baz</$text>q[]ux
-				{ selectionAttributes: [], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 },
+				{ selectionAttributes: [], isGravityOverridden: false, preventDefault: 0, evtStop: 0 },
 				'←',
 				// foo<$text a="true">foo</$text><$text a="true" c="true">bar</$text><$text c="true">baz</$text>[]qux
-				{ selectionAttributes: [], isGravityOverridden: true, preventDefault: 0, evtStopCalled: 0 },
+				{ selectionAttributes: [], isGravityOverridden: true, preventDefault: 0, evtStop: 0 },
 				'←',
 				// foo<$text a="true">foo</$text><$text a="true" c="true">bar</$text><$text c="true">baz[]</$text>qux
-				{ selectionAttributes: [ 'c' ], isGravityOverridden: false, preventDefault: 1, evtStopCalled: 1 },
+				{ selectionAttributes: [ 'c' ], isGravityOverridden: false, preventDefault: 1, evtStop: 1 },
 				'←',
 				'←',
 				'←',
 				// foo<$text a="true">foo</$text><$text a="true" c="true">bar</$text><$text c="true">[]baz</$text>qux
-				{ selectionAttributes: [ 'c' ], isGravityOverridden: true, preventDefault: 1, evtStopCalled: 1 },
+				{ selectionAttributes: [ 'c' ], isGravityOverridden: true, preventDefault: 1, evtStop: 1 },
 				'←',
 				// foo<$text a="true">foo</$text><$text a="true" c="true">bar[]</$text><$text c="true">baz</$text>qux
-				{ selectionAttributes: [ 'a', 'c' ], isGravityOverridden: false, preventDefault: 2, evtStopCalled: 2 },
+				{ selectionAttributes: [ 'a', 'c' ], isGravityOverridden: false, preventDefault: 2, evtStop: 2 },
 				'←',
 				'←',
 				'←',
 				// foo<$text a="true">foo</$text><$text a="true" c="true">[]bar</$text><$text c="true">baz</$text>qux
-				{ selectionAttributes: [ 'a', 'c' ], isGravityOverridden: true, preventDefault: 2, evtStopCalled: 2 },
+				{ selectionAttributes: [ 'a', 'c' ], isGravityOverridden: true, preventDefault: 2, evtStop: 2 },
 				'←',
 				// foo<$text a="true">foo[]</$text><$text a="true" c="true">bar</$text><$text c="true">baz</$text>qux
-				{ selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 3, evtStopCalled: 3 },
+				{ selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 3, evtStop: 3 },
 				'←',
 				'←',
 				'←',
 				// foo<$text a="true">[]foo</$text><$text a="true" c="true">bar</$text><$text c="true">baz</$text>qux
-				{ selectionAttributes: [ 'a' ], isGravityOverridden: true, preventDefault: 3, evtStopCalled: 3 },
+				{ selectionAttributes: [ 'a' ], isGravityOverridden: true, preventDefault: 3, evtStop: 3 },
 				'←',
 				// foo[]<$text a="true">foo</$text><$text a="true" c="true">bar</$text><$text c="true">baz</$text>qux
-				{ selectionAttributes: [], isGravityOverridden: false, preventDefault: 4, evtStopCalled: 4 },
+				{ selectionAttributes: [], isGravityOverridden: false, preventDefault: 4, evtStop: 4 },
 				'←',
 				// fo[]o<$text a="true">foo</$text><$text a="true" c="true">bar</$text><$text c="true">baz</$text>qux
-				{ selectionAttributes: [], isGravityOverridden: false, preventDefault: 4, evtStopCalled: 4 }
+				{ selectionAttributes: [], isGravityOverridden: false, preventDefault: 4, evtStop: 4 }
 			] );
 		} );
+
+		describe( 'when two elements ends at the same position', () => {
+			it( 'moving the caret in should take 2 steps', () => {
+				setData( model, 'foo<$text a="true">foo</$text><$text a="true" c="true">bar</$text>q[]ux' );
+
+				testTwoStepCaretMovement( [
+					// foo<$text a="true">foo</$text><$text a="true" c="true">bar</$text>q[]ux
+					{ selectionAttributes: [], isGravityOverridden: false, preventDefault: 0, evtStop: 0, caretPosition: 10 },
+					'←',
+					// foo<$text a="true">foo</$text><$text a="true" c="true">bar</$text>[]qux
+					{ selectionAttributes: [], isGravityOverridden: true, preventDefault: 0, evtStop: 0, caretPosition: 9 },
+					'←',
+					// foo<$text a="true">foo</$text><$text a="true" c="true">bar[]</$text>qux
+					{ selectionAttributes: [ 'a', 'c' ], isGravityOverridden: false, preventDefault: 1, evtStop: 1, caretPosition: 9 },
+					'←',
+					// foo<$text a="true">foo</$text><$text a="true" c="true">ba[]r</$text>qux
+					{ selectionAttributes: [ 'a', 'c' ], isGravityOverridden: false, preventDefault: 1, evtStop: 1, caretPosition: 8 }
+				] );
+			} );
+
+			it( 'moving the caret out should take 2 steps', () => {
+				setData( model, 'foo<$text a="true">foo</$text><$text a="true" c="true">ba[]r</$text>qux' );
+
+				testTwoStepCaretMovement( [
+					// foo<$text a="true">foo</$text><$text a="true" c="true">ba[]r</$text>qux
+					{ selectionAttributes: [ 'a', 'c' ], isGravityOverridden: false, preventDefault: 0, evtStop: 0, caretPosition: 8 },
+					'→',
+					// foo<$text a="true">foo</$text><$text a="true" c="true">bar[]</$text>qux
+					{ selectionAttributes: [ 'a', 'c' ], isGravityOverridden: false, preventDefault: 0, evtStop: 0, caretPosition: 9 },
+					'→',
+					// foo<$text a="true">foo</$text><$text a="true" c="true">bar</$text>[]qux
+					{ selectionAttributes: [], isGravityOverridden: true, preventDefault: 1, evtStop: 1, caretPosition: 9 },
+					'→',
+					// foo<$text a="true">foo</$text><$text a="true" c="true">bar</$text>q[]ux
+					{ selectionAttributes: [], isGravityOverridden: false, preventDefault: 1, evtStop: 1, caretPosition: 10 }
+				] );
+			} );
+		} );
+
+		describe( 'when two elements starts at the same position', () => {
+			it( 'moving the caret in should take 2 steps', () => {
+				setData( model, 'fo[]o<$text a="true" c="true">bar</$text><$text a="true">baz</$text>qux' );
+
+				testTwoStepCaretMovement( [
+					// fo[]o<$text a="true" c="true">bar</$text><$text a="true">baz</$text>qux
+					{ selectionAttributes: [], isGravityOverridden: false, preventDefault: 0, evtStop: 0, caretPosition: 2 },
+					'→',
+					// foo[]<$text a="true" c="true">bar</$text><$text a="true">baz</$text>qux
+					{ selectionAttributes: [], isGravityOverridden: false, preventDefault: 0, evtStop: 0, caretPosition: 3 },
+					'→',
+					// foo<$text a="true" c="true">[]bar</$text><$text a="true">baz</$text>qux
+					{ selectionAttributes: [ 'a', 'c' ], isGravityOverridden: true, preventDefault: 1, evtStop: 1, caretPosition: 3 },
+					'→',
+					// foo<$text a="true" c="true">b[]ar</$text><$text a="true">baz</$text>qux
+					{ selectionAttributes: [ 'a', 'c' ], isGravityOverridden: false, preventDefault: 1, evtStop: 1, caretPosition: 4 }
+				] );
+			} );
+
+			it( 'moving the caret out should take 2 steps', () => {
+				setData( model, 'foo<$text a="true" c="true">b[]ar</$text><$text a="true">baz</$text>qux' );
+
+				testTwoStepCaretMovement( [
+					// foo<$text a="true" c="true">b[]ar</$text><$text a="true">baz</$text>qux
+					{ selectionAttributes: [ 'a', 'c' ], isGravityOverridden: false, preventDefault: 0, evtStop: 0, caretPosition: 4 },
+					'←',
+					// foo<$text a="true" c="true">[]bar</$text><$text a="true">baz</$text>qux
+					{ selectionAttributes: [ 'a', 'c' ], isGravityOverridden: true, preventDefault: 0, evtStop: 0, caretPosition: 3 },
+					'←',
+					// foo[]<$text a="true" c="true">bar</$text><$text a="true">baz</$text>qux
+					{ selectionAttributes: [], isGravityOverridden: false, preventDefault: 1, evtStop: 1, caretPosition: 3 },
+					'←',
+					// fo[]o<$text a="true" c="true">bar</$text><$text a="true">baz</$text>qux
+					{ selectionAttributes: [], isGravityOverridden: false, preventDefault: 1, evtStop: 1, caretPosition: 2 }
+				] );
+			} );
+		} );
 	} );
 
 	describe( 'mouse', () => {
@@ -732,9 +811,9 @@ describe( 'TwoStepCaretMovement()', () => {
 		setData( model, '<$text a="true">foo[]</$text>bar' );
 
 		testTwoStepCaretMovement( [
-			{ selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 },
+			{ selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 0, evtStop: 0 },
 			'→',
-			{ selectionAttributes: [], isGravityOverridden: true, preventDefault: 1, evtStopCalled: 1 }
+			{ selectionAttributes: [], isGravityOverridden: true, preventDefault: 1, evtStop: 1 }
 		] );
 
 		// Simulate an external text insertion BEFORE the user selection to trigger #change:range.
@@ -746,41 +825,15 @@ describe( 'TwoStepCaretMovement()', () => {
 		expect( getSelectionAttributesArray( selection ) ).to.have.members( [] );
 	} );
 
-	describe( 'left–to–right and right–to–left content', () => {
-		it( 'should call methods associated with the keys (LTR content direction)', () => {
-			const forwardStub = testUtils.sinon.stub( TwoStepCaretHandler.prototype, 'handleForwardMovement' );
-			const backwardStub = testUtils.sinon.stub( TwoStepCaretHandler.prototype, 'handleBackwardMovement' );
-
-			setData( model, '<$text>foo[]</$text><$text a="true">bar</$text>' );
-
-			fireKeyDownEvent( {
-				keyCode: keyCodes.arrowright
-			} );
-
-			expect( forwardStub ).to.be.calledOnce;
-			expect( backwardStub ).not.to.be.called;
-
-			setData( model, '<$text>foo</$text><$text a="true">[]bar</$text>' );
-
-			fireKeyDownEvent( {
-				keyCode: keyCodes.arrowleft
-			} );
-
-			expect( forwardStub ).to.be.calledOnce;
-			expect( backwardStub ).to.be.calledOnce;
-		} );
-
+	describe( 'right–to–left content', () => {
 		it( 'should use the opposite helper methods (RTL content direction)', () => {
-			const forwardStub = testUtils.sinon.stub( TwoStepCaretHandler.prototype, 'handleForwardMovement' );
-			const backwardStub = testUtils.sinon.stub( TwoStepCaretHandler.prototype, 'handleBackwardMovement' );
-
-			let model;
+			// let model;
 
 			return VirtualTestEditor
 				.create( {
 					plugins: [ TwoStepCaretMovement ],
 					language: {
-						content: 'ar'
+						content: 'he'
 					}
 				} )
 				.then( newEditor => {
@@ -804,23 +857,28 @@ describe( 'TwoStepCaretMovement()', () => {
 					return newEditor;
 				} )
 				.then( newEditor => {
-					setData( model, '<$text>foo[]</$text><$text a="true">bar</$text>' );
+					setData( model, '<$text>לזה[]</$text><$text a="true">שיוצג</$text>' );
 
-					fireKeyDownEvent( {
-						keyCode: keyCodes.arrowleft
-					} );
+					testTwoStepCaretMovement( [
+						{ selectionAttributes: [], isGravityOverridden: false, preventDefault: 0, evtStop: 0, caretPosition: 3 },
+						'←',
+						{ selectionAttributes: [ 'a' ], isGravityOverridden: true, preventDefault: 1, evtStop: 1, caretPosition: 3 }
+					], 'rtl' );
 
-					expect( forwardStub ).to.be.calledOnce;
-					expect( backwardStub ).not.to.be.called;
+					preventDefaultSpy.resetHistory();
+					evtStopSpy.resetHistory();
 
-					setData( model, '<$text>foo</$text><$text a="true">[]bar</$text>' );
+					setData( model, '<$text>לזה</$text><$text a="true">ש[]יוצג</$text>' );
 
-					fireKeyDownEvent( {
-						keyCode: keyCodes.arrowright
-					} );
-
-					expect( forwardStub ).to.be.calledOnce;
-					expect( backwardStub ).to.be.calledOnce;
+					testTwoStepCaretMovement( [
+						{ selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 0, evtStop: 0, caretPosition: 4 },
+						'→',
+						{ selectionAttributes: [ 'a' ], isGravityOverridden: true, preventDefault: 0, evtStop: 0, caretPosition: 3 },
+						'→',
+						{ selectionAttributes: [], isGravityOverridden: false, preventDefault: 1, evtStop: 1, caretPosition: 3 },
+						'→',
+						{ selectionAttributes: [], isGravityOverridden: false, preventDefault: 1, evtStop: 1, caretPosition: 2 }
+					], 'rtl' );
 
 					return newEditor.destroy();
 				} );
@@ -847,13 +905,12 @@ describe( 'TwoStepCaretMovement()', () => {
 		return Array.from( selection.getAttributeKeys() );
 	}
 
-	function testTwoStepCaretMovement( scenario ) {
+	function testTwoStepCaretMovement( scenario, rtl ) {
 		for ( const step of scenario ) {
 			if ( typeof step == 'string' ) {
 				// An arrow key pressed. Fire the view event and update the model selection.
 				if ( keyMap[ step ] ) {
 					let preventDefaultCalled;
-
 					fireKeyDownEvent( {
 						keyCode: keyCodes[ keyMap[ step ] ],
 						preventDefault: () => {
@@ -866,7 +923,7 @@ describe( 'TwoStepCaretMovement()', () => {
 					const position = selection.getFirstPosition();
 
 					if ( !preventDefaultCalled ) {
-						if ( step == '→' ) {
+						if ( step == '→' && !rtl || step == '←' && rtl ) {
 							model.change( writer => {
 								if ( position.isAtEnd ) {
 									const nextBlock = position.parent.nextSibling;
@@ -878,7 +935,7 @@ describe( 'TwoStepCaretMovement()', () => {
 									writer.setSelection( selection.getFirstPosition().getShiftedBy( 1 ) );
 								}
 							} );
-						} else if ( step == '←' ) {
+						} else if ( step == '←' && !rtl || step == '→' && rtl ) {
 							model.change( writer => {
 								if ( position.isAtStart ) {
 									const previousBlock = position.parent.previousSibling;
@@ -905,14 +962,21 @@ describe( 'TwoStepCaretMovement()', () => {
 			// If not a key, then it's an assertion.
 			else {
 				const stepIndex = scenario.indexOf( step );
-				const stepString = `in step #${ stepIndex } ${ JSON.stringify( step ) }`;
-
-				expect( getSelectionAttributesArray( selection ) ).to.have.members(
-					step.selectionAttributes, `#attributes ${ stepString }` );
-				expect( selection, `#isGravityOverridden ${ stepString }` )
+				const stepString = `in step #${ stepIndex }`;
+				let caretPosition = step.caretPosition;
+
+				if ( caretPosition !== undefined ) {
+					// Normalize position
+					caretPosition = Array.isArray( step.caretPosition ) ? caretPosition : [ caretPosition ];
+					expect( selection.getFirstPosition(), `in step #${ stepIndex }, selection's first position` )
+						.to.have.deep.property( 'path', caretPosition );
+				}
+				expect( getSelectionAttributesArray( selection ), `${ stepString }, selection's attributes` )
+					.to.have.members( step.selectionAttributes );
+				expect( selection, `${ stepString }, selection's gravity` )
 					.to.have.property( 'isGravityOverridden', step.isGravityOverridden );
-				expect( preventDefaultSpy.callCount ).to.equal( step.preventDefault, `#preventDefault ${ stepString }` );
-				expect( evtStopSpy.callCount ).to.equal( step.evtStopCalled, `#evtStopCalled ${ stepString }` );
+				expect( preventDefaultSpy, stepString ).to.have.callCount( step.preventDefault );
+				expect( evtStopSpy, stepString ).to.have.callCount( step.evtStop );
 			}
 		}
 	}

+ 2 - 2
packages/ckeditor5-typing/tests/utils/inlinehighlight.js

@@ -12,7 +12,7 @@ import '@ckeditor/ckeditor5-core/tests/_utils/assertions/attribute';
 
 /* global document */
 
-describe( 'LinkEditing', () => {
+describe( 'inlineHighlight', () => {
 	let element, editor, model, view;
 
 	beforeEach( async () => {
@@ -44,7 +44,7 @@ describe( 'LinkEditing', () => {
 		await editor.destroy();
 	} );
 
-	describe( 'link highlighting', () => {
+	describe( 'attribute highlighting', () => {
 		it( 'should convert the highlight to a proper view classes', () => {
 			setModelData( model,
 				'<paragraph>foo <$text linkHref="url">b{}ar</$text> baz</paragraph>'

+ 26 - 22
packages/ckeditor5-ui/src/toolbar/toolbarview.js

@@ -276,11 +276,11 @@ export default class ToolbarView extends View {
 	 * @param {module:ui/componentfactory~ComponentFactory} factory A factory producing toolbar items.
 	 */
 	fillFromConfig( config, factory ) {
-		config.map( name => {
+		this.items.addMany( config.map( name => {
 			if ( name == '|' ) {
-				this.items.add( new ToolbarSeparatorView() );
+				return new ToolbarSeparatorView();
 			} else if ( factory.has( name ) ) {
-				this.items.add( factory.create( name ) );
+				return factory.create( name );
 			} else {
 				/**
 				 * There was a problem processing the configuration of the toolbar. The item with the given
@@ -302,7 +302,7 @@ export default class ToolbarView extends View {
 				console.warn( attachLinkToDocumentation(
 					'toolbarview-item-unavailable: The requested toolbar item is unavailable.' ), { name } );
 			}
-		} );
+		} ).filter( item => item !== undefined ) );
 	}
 }
 
@@ -545,32 +545,36 @@ class DynamicGrouping {
 		view.children.on( 'add', this._updateFocusCycleableItems.bind( this ) );
 		view.children.on( 'remove', this._updateFocusCycleableItems.bind( this ) );
 
-		// ToolbarView#items is dynamic. When an item is added, it should be automatically
+		// ToolbarView#items is dynamic. When an item is added or removed, it should be automatically
 		// represented in either grouped or ungrouped items at the right index.
 		// In other words #items == concat( #ungroupedItems, #groupedItems )
 		// (in length and order).
-		view.items.on( 'add', ( evt, item, index ) => {
-			if ( index > this.ungroupedItems.length ) {
-				this.groupedItems.add( item, index - this.ungroupedItems.length );
-			} else {
-				this.ungroupedItems.add( item, index );
+		view.items.on( 'change', ( evt, changeData ) => {
+			const index = changeData.index;
+
+			// Removing.
+			for ( const removedItem of changeData.removed ) {
+				if ( index >= this.ungroupedItems.length ) {
+					this.groupedItems.remove( removedItem );
+				} else {
+					this.ungroupedItems.remove( removedItem );
+				}
 			}
 
-			// When a new ungrouped item joins in and lands in #ungroupedItems, there's a chance it causes
-			// the toolbar to overflow.
-			this._updateGrouping();
-		} );
+			// Adding.
+			for ( let currentIndex = index; currentIndex < index + changeData.added.length; currentIndex++ ) {
+				const addedItem = changeData.added[ currentIndex - index ];
 
-		// When an item is removed from ToolbarView#items, it should be automatically
-		// removed from either grouped or ungrouped items.
-		view.items.on( 'remove', ( evt, item, index ) => {
-			if ( index > this.ungroupedItems.length ) {
-				this.groupedItems.remove( item );
-			} else {
-				this.ungroupedItems.remove( item );
+				if ( currentIndex > this.ungroupedItems.length ) {
+					this.groupedItems.add( addedItem, currentIndex - this.ungroupedItems.length );
+				} else {
+					this.ungroupedItems.add( addedItem, currentIndex );
+				}
 			}
 
-			// Whether removed from grouped or ungrouped items, there is a chance
+			// When new ungrouped items join in and land in #ungroupedItems, there's a chance it causes
+			// the toolbar to overflow.
+			// Consequently if removed from grouped or ungrouped items, there is a chance
 			// some new space is available and we could do some ungrouping.
 			this._updateGrouping();
 		} );

+ 11 - 0
packages/ckeditor5-ui/tests/toolbar/toolbarview.js

@@ -575,6 +575,17 @@ describe( 'ToolbarView', () => {
 
 				expect( ungroupedItems.map( i => i ) ).to.have.ordered.members( [ itemB, itemD ] );
 			} );
+
+			it( 'doesn\'t throw when removing the first of grouped items', () => { // (#7655)
+				const items = [ focusable(), focusable(), focusable(), focusable() ];
+				view.element.style.width = '200px';
+				view.items.addMany( items );
+
+				view.items.remove( 1 );
+
+				expect( ungroupedItems.map( i => i ) ).to.have.ordered.members( [ items[ 0 ] ] );
+				expect( groupedItems.map( i => i ) ).to.have.ordered.members( [ items[ 2 ], items[ 3 ] ] );
+			} );
 		} );
 
 		it( 'groups items that overflow into the dropdown', () => {

+ 2 - 0
packages/ckeditor5-undo/src/basecommand.js

@@ -41,6 +41,8 @@ export default class BaseCommand extends Command {
 
 		// Refresh state, so the command is inactive right after initialization.
 		this.refresh();
+
+		this.listenTo( editor.data, 'set', () => this.clearStack() );
 	}
 
 	/**

+ 8 - 0
packages/ckeditor5-undo/tests/redocommand.js

@@ -288,5 +288,13 @@ describe( 'RedoCommand', () => {
 				expect( undoSpy.firstCall.args[ 1 ] ).to.equal( redoingBatch );
 			} );
 		} );
+
+		it( 'should clear stack on DataController set()', () => {
+			const spy = sinon.stub( redo, 'clearStack' );
+
+			editor.setData( 'foo' );
+
+			sinon.assert.called( spy );
+		} );
 	} );
 } );

+ 8 - 0
packages/ckeditor5-undo/tests/undocommand.js

@@ -347,5 +347,13 @@ describe( 'UndoCommand', () => {
 			expect( getCaseText( root ) ).to.equal( 'adbcef' );
 			expect( editor.model.document.selection.getFirstRange().isEqual( r( 1, 4 ) ) ).to.be.true;
 		} );
+
+		it( 'should clear stack on DataController set()', () => {
+			const spy = sinon.stub( undo, 'clearStack' );
+
+			editor.setData( 'foo' );
+
+			sinon.assert.called( spy );
+		} );
 	} );
 } );

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

@@ -12,6 +12,7 @@ import IconView from '@ckeditor/ckeditor5-ui/src/icon/iconview';
 import Rect from '@ckeditor/ckeditor5-utils/src/dom/rect';
 import BalloonPanelView from '@ckeditor/ckeditor5-ui/src/panel/balloon/balloonpanelview';
 import global from '@ckeditor/ckeditor5-utils/src/dom/global';
+import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
 
 import dragHandleIcon from '../theme/icons/drag-handle.svg';
 import { getTypeAroundFakeCaretPosition } from './widgettypearound/utils';
@@ -91,6 +92,21 @@ export function isWidget( node ) {
  * @returns {module:engine/view/element~Element} Returns the same element.
  */
 export function toWidget( element, writer, options = {} ) {
+	if ( !element.is( 'containerElement' ) ) {
+		/**
+		 * The element passed to `toWidget()` must be a {@link module:engine/view/containerelement~ContainerElement}
+		 * instance.
+		 *
+		 * @error widget-to-widget-wrong-element-type
+		 * @param {String} element The view element passed to `toWidget()`.
+		 */
+		throw new CKEditorError(
+			'widget-to-widget-wrong-element-type: The element passed to toWidget() must be a container element instance.',
+			null,
+			{ element }
+		);
+	}
+
 	writer.setAttribute( 'contenteditable', 'false', element );
 
 	writer.addClass( WIDGET_CLASS_NAME, element );

+ 56 - 31
packages/ckeditor5-widget/src/widgetresize/resizer.js

@@ -10,6 +10,7 @@
 import View from '@ckeditor/ckeditor5-ui/src/view';
 import Template from '@ckeditor/ckeditor5-ui/src/template';
 import Rect from '@ckeditor/ckeditor5-utils/src/dom/rect';
+import compareArrays from '@ckeditor/ckeditor5-utils/src/comparearrays';
 
 import ObservableMixin from '@ckeditor/ckeditor5-utils/src/observablemixin';
 import mix from '@ckeditor/ckeditor5-utils/src/mix';
@@ -228,39 +229,59 @@ export default class Resizer {
 	redraw( handleHostRect ) {
 		const domWrapper = this._domResizerWrapper;
 
-		if ( existsInDom( domWrapper ) ) {
-			this._options.editor.editing.view.change( writer => {
-				// Refresh only if resizer exists in the DOM.
-				const widgetWrapper = domWrapper.parentElement;
-				const handleHost = this._getHandleHost();
-				const clientRect = handleHostRect || new Rect( handleHost );
-
-				writer.setStyle( 'width', clientRect.width + 'px', this._viewResizerWrapper );
-				writer.setStyle( 'height', clientRect.height + 'px', this._viewResizerWrapper );
-
-				const offsets = {
-					left: handleHost.offsetLeft,
-					top: handleHost.offsetTop,
-					height: handleHost.offsetHeight,
-					width: handleHost.offsetWidth
-				};
-
-				// In case a resizing host is not a widget wrapper, we need to compensate
-				// for any additional offsets the resize host might have. E.g. wrapper padding
-				// or simply another editable. By doing that the border and resizers are shown
-				// only around the resize host.
-				if ( !widgetWrapper.isSameNode( handleHost ) ) {
-					writer.setStyle( 'left', offsets.left + 'px', this._viewResizerWrapper );
-					writer.setStyle( 'top', offsets.top + 'px', this._viewResizerWrapper );
-
-					writer.setStyle( 'height', offsets.height + 'px', this._viewResizerWrapper );
-					writer.setStyle( 'width', offsets.width + 'px', this._viewResizerWrapper );
-				}
-			} );
+		// Refresh only if resizer exists in the DOM.
+		if ( !existsInDom( domWrapper ) ) {
+			return;
+		}
+
+		const widgetWrapper = domWrapper.parentElement;
+		const handleHost = this._getHandleHost();
+		const resizerWrapper = this._viewResizerWrapper;
+		const currentDimensions = [
+			resizerWrapper.getStyle( 'width' ),
+			resizerWrapper.getStyle( 'height' ),
+			resizerWrapper.getStyle( 'left' ),
+			resizerWrapper.getStyle( 'top' )
+		];
+		let newDimensions;
+
+		if ( widgetWrapper.isSameNode( handleHost ) ) {
+			const clientRect = handleHostRect || new Rect( handleHost );
+
+			newDimensions = [
+				clientRect.width + 'px',
+				clientRect.height + 'px',
+				undefined,
+				undefined
+			];
+		}
+		// In case a resizing host is not a widget wrapper, we need to compensate
+		// for any additional offsets the resize host might have. E.g. wrapper padding
+		// or simply another editable. By doing that the border and resizers are shown
+		// only around the resize host.
+		else {
+			newDimensions = [
+				handleHost.offsetWidth + 'px',
+				handleHost.offsetHeight + 'px',
+				handleHost.offsetLeft + 'px',
+				handleHost.offsetTop + 'px'
+			];
 		}
 
-		function existsInDom( element ) {
-			return element && element.ownerDocument && element.ownerDocument.contains( element );
+		// Make changes to the view only if the resizer should actually get new dimensions.
+		// Otherwise, if View#change() was always called, this would cause EditorUI#update
+		// loops because the WidgetResize plugin listens to EditorUI#update and updates
+		// the resizer.
+		// https://github.com/ckeditor/ckeditor5/issues/7633
+		if ( compareArrays( currentDimensions, newDimensions ) !== 'same' ) {
+			this._options.editor.editing.view.change( writer => {
+				writer.setStyle( {
+					width: newDimensions[ 0 ],
+					height: newDimensions[ 1 ],
+					left: newDimensions[ 2 ],
+					top: newDimensions[ 3 ]
+				}, resizerWrapper );
+			} );
 		}
 	}
 
@@ -514,3 +535,7 @@ function extractCoordinates( event ) {
 		y: event.pageY
 	};
 }
+
+function existsInDom( element ) {
+	return element && element.ownerDocument && element.ownerDocument.contains( element );
+}

+ 18 - 0
packages/ckeditor5-widget/tests/utils.js

@@ -126,6 +126,24 @@ describe( 'widget utils', () => {
 			expect( icon.classList.contains( 'ck' ) ).to.be.true;
 			expect( icon.classList.contains( 'ck-icon' ) ).to.be.true;
 		} );
+
+		it( 'should throw when attempting to create a widget out of anything but ContainerElement', () => {
+			expect( () => {
+				toWidget( writer.createRawElement( 'div' ), writer );
+			}, 'raw element' ).to.throw( /^widget-to-widget-wrong-element-type/ );
+
+			expect( () => {
+				toWidget( writer.createEmptyElement( 'img' ), writer );
+			}, 'empty element' ).to.throw( /^widget-to-widget-wrong-element-type/ );
+
+			expect( () => {
+				toWidget( writer.createAttributeElement( 'a' ), writer );
+			}, 'attribute element' ).to.throw( /^widget-to-widget-wrong-element-type/ );
+
+			expect( () => {
+				toWidget( writer.createUIElement( 'span' ), writer );
+			}, 'UI element' ).to.throw( /^widget-to-widget-wrong-element-type/ );
+		} );
 	} );
 
 	describe( 'isWidget()', () => {

+ 30 - 0
packages/ckeditor5-widget/tests/widgetresize/resizer.js

@@ -131,6 +131,36 @@ describe( 'Resizer', () => {
 			// Cleanup.
 			renderedElement.remove();
 		} );
+
+		// https://github.com/ckeditor/ckeditor5/issues/7633
+		it( 'should not cause changes in the view unless the host size actually changed', () => {
+			const resizerInstance = createResizer( {
+				getHandleHost: widgetWrapper => widgetWrapper
+			} );
+
+			resizerInstance.attach();
+			const renderedElement = resizerInstance._viewResizerWrapper.render( document );
+
+			document.body.appendChild( renderedElement );
+
+			const viewChangeSpy = sinon.spy( editor.editing.view, 'change' );
+
+			resizerInstance.redraw();
+			sinon.assert.calledOnce( viewChangeSpy );
+
+			resizerInstance.redraw();
+			sinon.assert.calledOnce( viewChangeSpy );
+
+			const host = resizerInstance._getHandleHost();
+
+			host.style.width = '123px';
+
+			resizerInstance.redraw();
+			sinon.assert.calledTwice( viewChangeSpy );
+
+			// Cleanup.
+			renderedElement.remove();
+		} );
 	} );
 
 	describe( '_proposeNewSize()', () => {

+ 141 - 311
yarn.lock

@@ -251,7 +251,7 @@
     chalk "^2.0.0"
     js-tokens "^4.0.0"
 
-"@babel/parser@^7.10.4", "@babel/parser@^7.10.5", "@babel/parser@^7.6.0", "@babel/parser@^7.7.7", "@babel/parser@^7.9.6":
+"@babel/parser@^7.10.3", "@babel/parser@^7.10.4", "@babel/parser@^7.10.5", "@babel/parser@^7.6.0", "@babel/parser@^7.7.7", "@babel/parser@^7.9.6":
   version "7.10.5"
   resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.10.5.tgz#e7c6bf5a7deff957cec9f04b551e2762909d826b"
   integrity sha512-wfryxy4bE1UivvQKSQDU4/X6dr+i8bctjUjj8Zyt3DQy7NtPizJXT8M52nqpNKL+nq2PW8lxk4ZqLj0fD4B4hQ==
@@ -835,24 +835,23 @@
     date-fns "^1.29.0"
     lodash-es "^4.17.11"
 
-"@ckeditor/ckeditor5-dev-docs@^22.0.0":
-  version "22.0.0"
-  resolved "https://registry.yarnpkg.com/@ckeditor/ckeditor5-dev-docs/-/ckeditor5-dev-docs-22.0.0.tgz#546d81d165fab8284350fe79d0ab528640a94e3b"
-  integrity sha512-gozPtscnjdV2i2kJqWUtIjcWMyHokKSCBlhQO5KNIluEd+IN/cXA8Tgzq3euYjpuIpZByDfkfeMmmdEAoT6t2g==
+"@ckeditor/ckeditor5-dev-docs@^23.0.0":
+  version "23.0.0"
+  resolved "https://registry.yarnpkg.com/@ckeditor/ckeditor5-dev-docs/-/ckeditor5-dev-docs-23.0.0.tgz#e515e2c42e045a4ad2973c7166598da4d69b6142"
+  integrity sha512-yqyh3H9DtfLxqnx+LxvIZquLfrHhl5BqnVdNydHt8AMXsHCHj0dKydjC3jGqTmxmcnOLvjBCJXrakH4ntcdEkA==
   dependencies:
-    "@ckeditor/ckeditor5-dev-utils" "^22.0.0"
-    "@ckeditor/jsdoc-plugins" "^22.0.0"
-    jsdoc ckeditor/jsdoc#node12
-    map-stream "^0.0.7"
+    "@ckeditor/ckeditor5-dev-utils" "^23.0.0"
+    "@ckeditor/jsdoc-plugins" "^23.0.0"
+    fast-glob "^3.2.4"
+    jsdoc ckeditor/jsdoc#fixed-trailing-comment-doclets
     tmp "^0.2.1"
-    vinyl-fs "^3.0.3"
 
-"@ckeditor/ckeditor5-dev-env@^22.0.0":
-  version "22.0.0"
-  resolved "https://registry.yarnpkg.com/@ckeditor/ckeditor5-dev-env/-/ckeditor5-dev-env-22.0.0.tgz#7271fb93d5e3bda24d5572ad460f557d3910c4b9"
-  integrity sha512-HQDP88x0c2QcI4JV1F4unj83KYEw2U+rkZX0qwXbW8uLmAEkMmqNVrxtv2wusPcNIYA5eVrT88m4xBDLLMvHVA==
+"@ckeditor/ckeditor5-dev-env@^23.0.0":
+  version "23.0.0"
+  resolved "https://registry.yarnpkg.com/@ckeditor/ckeditor5-dev-env/-/ckeditor5-dev-env-23.0.0.tgz#39ded05c433c58adb4e74b4e7945077e2c815756"
+  integrity sha512-n//vHe8nEDMK6C/Bn7/APodks02cslkJKl+4kOkIDrdbDYIzU+X7AZ92J9ioKEA+cJPEO+SjPzClRrvtjVlINg==
   dependencies:
-    "@ckeditor/ckeditor5-dev-utils" "^22.0.0"
+    "@ckeditor/ckeditor5-dev-utils" "^23.0.0"
     "@octokit/rest" "^17.9.2"
     chalk "^4.0.0"
     compare-func "^2.0.0"
@@ -870,14 +869,14 @@
     request "^2.88.2"
     semver "^7.3.2"
 
-"@ckeditor/ckeditor5-dev-tests@^22.0.0":
-  version "22.0.0"
-  resolved "https://registry.yarnpkg.com/@ckeditor/ckeditor5-dev-tests/-/ckeditor5-dev-tests-22.0.0.tgz#aaed44193c2761ecd02ce7db060c93d1f7c226f3"
-  integrity sha512-xPPf7q0W/Hjoc25dPbUmmleC8FOP49MUeFWQWTdHldGkl1wuhTsnZ6Tf4DRuGWA7dVgG/zwABCvWd5YVvY7POg==
+"@ckeditor/ckeditor5-dev-tests@^23.0.0":
+  version "23.0.0"
+  resolved "https://registry.yarnpkg.com/@ckeditor/ckeditor5-dev-tests/-/ckeditor5-dev-tests-23.0.0.tgz#a039d8b86ec6265ad87e420e00590c95350fc37d"
+  integrity sha512-pRqlHQLDz8aTKwRvaUF5wKfpT+fHbFzribO3kV7a4LRDQP3QqBThHaKUBtONfQvJSNQ7/dkXTmekatQZRm3bNw==
   dependencies:
     "@babel/core" "^7.9.6"
-    "@ckeditor/ckeditor5-dev-utils" "^22.0.0"
-    "@ckeditor/ckeditor5-dev-webpack-plugin" "^22.0.0"
+    "@ckeditor/ckeditor5-dev-utils" "^23.0.0"
+    "@ckeditor/ckeditor5-dev-webpack-plugin" "^23.0.0"
     "@ckeditor/ckeditor5-inspector" "^2.1.0"
     babel-core "^6.26.3"
     babel-loader "^8.1.0"
@@ -959,10 +958,10 @@
     shelljs "^0.8.1"
     through2 "^3.0.1"
 
-"@ckeditor/ckeditor5-dev-utils@^22.0.0":
-  version "22.0.0"
-  resolved "https://registry.yarnpkg.com/@ckeditor/ckeditor5-dev-utils/-/ckeditor5-dev-utils-22.0.0.tgz#808873a1b54629a72698ea7b7532126530fa73a7"
-  integrity sha512-qxYs917hKZe3/kY5k8jsvGOJtCMKbhwC16oTOYdxgyTGk182p3eVNN8fAo1RFuDeEQzplN8CWGckOv9lcuj6aw==
+"@ckeditor/ckeditor5-dev-utils@^23.0.0":
+  version "23.0.0"
+  resolved "https://registry.yarnpkg.com/@ckeditor/ckeditor5-dev-utils/-/ckeditor5-dev-utils-23.0.0.tgz#1b0fd5fdb414472261645f25a1be5f1c58d213b2"
+  integrity sha512-JqExkyVQfxiwt3DLIRktOp2VomYY4h1EDx9SCyoNsEKC94ngkg1ucQBsBS8DAVQVYxAasIebLrysVL/S9C5Qew==
   dependencies:
     acorn "^6.2.1"
     acorn-walk "^6.2.0"
@@ -990,12 +989,12 @@
     rimraf "^2.6.2"
     webpack-sources "^1.1.0"
 
-"@ckeditor/ckeditor5-dev-webpack-plugin@^22.0.0":
-  version "22.0.0"
-  resolved "https://registry.yarnpkg.com/@ckeditor/ckeditor5-dev-webpack-plugin/-/ckeditor5-dev-webpack-plugin-22.0.0.tgz#b12564695fbe836f8a395e1538db0fb9ad276a48"
-  integrity sha512-A/5yq4Kvz1C52wyW4NspGrrhJgU0aCNKa9iBjbbElSdHK9YYjuV4sYXW0esNeAWg+TBt1uiV6RtdW4RnKQQ4VA==
+"@ckeditor/ckeditor5-dev-webpack-plugin@^23.0.0":
+  version "23.0.0"
+  resolved "https://registry.yarnpkg.com/@ckeditor/ckeditor5-dev-webpack-plugin/-/ckeditor5-dev-webpack-plugin-23.0.0.tgz#c05dfbc7821d53c0032679f648a5a488c58250c7"
+  integrity sha512-ICQXdMuCbQMPQ825z4jfZ4dTq58tdRach52UrR43gW/Y88QtNPXUghc3QNSjX0ZF6lqi5vTbIpb8ReI0oMWXvA==
   dependencies:
-    "@ckeditor/ckeditor5-dev-utils" "^22.0.0"
+    "@ckeditor/ckeditor5-dev-utils" "^23.0.0"
     chalk "^4.0.0"
     rimraf "^3.0.2"
     webpack-sources "^1.4.3"
@@ -1066,13 +1065,13 @@
     jsdoc ckeditor/jsdoc#node12
     lodash "^4.17.15"
 
-"@ckeditor/jsdoc-plugins@^22.0.0":
-  version "22.0.0"
-  resolved "https://registry.yarnpkg.com/@ckeditor/jsdoc-plugins/-/jsdoc-plugins-22.0.0.tgz#c88785260deb991601a85cb834a980b4a26b8131"
-  integrity sha512-HTbZyz1lqPN+wFF/yr5K5lZqOjqcANAP7jVWM4o5CkqrYGi63AfpCEwEbyzoTDkvV7G/qBB3Kmb7iQvrNw8Cbg==
+"@ckeditor/jsdoc-plugins@^23.0.0":
+  version "23.0.0"
+  resolved "https://registry.yarnpkg.com/@ckeditor/jsdoc-plugins/-/jsdoc-plugins-23.0.0.tgz#4435a5b2c78f5be9304fd23fcd89904e184479f8"
+  integrity sha512-tHhrvDAhsCdpZYLx7Df+MWcssUnzdnyV/F5w8yhFqw11qIGKfI5P5Vjtsax+N1JaxQG1/M70RL+y3XWUwRfi2g==
   dependencies:
     fs-extra "^8.1.0"
-    jsdoc ckeditor/jsdoc#node12
+    jsdoc ckeditor/jsdoc#fixed-trailing-comment-doclets
     lodash "^4.17.15"
 
 "@gradecam/tsenum@1.2.0":
@@ -1173,12 +1172,12 @@
     universal-user-agent "^5.0.0"
 
 "@octokit/endpoint@^6.0.1":
-  version "6.0.4"
-  resolved "https://registry.yarnpkg.com/@octokit/endpoint/-/endpoint-6.0.4.tgz#da3eafdee1fabd6e5b6ca311efcba26f0dd99848"
-  integrity sha512-ZJHIsvsClEE+6LaZXskDvWIqD3Ao7+2gc66pRG5Ov4MQtMvCU9wGu1TItw9aGNmRuU9x3Fei1yb+uqGaQnm0nw==
+  version "6.0.5"
+  resolved "https://registry.yarnpkg.com/@octokit/endpoint/-/endpoint-6.0.5.tgz#43a6adee813c5ffd2f719e20cfd14a1fee7c193a"
+  integrity sha512-70K5u6zd45ItOny6aHQAsea8HHQjlQq85yqOMe+Aj8dkhN2qSJ9T+Q3YjUjEYfPRBcuUWNgMn62DQnP/4LAIiQ==
   dependencies:
     "@octokit/types" "^5.0.0"
-    is-plain-object "^3.0.0"
+    is-plain-object "^4.0.0"
     universal-user-agent "^6.0.0"
 
 "@octokit/graphql@^4.3.1":
@@ -1220,15 +1219,15 @@
     once "^1.4.0"
 
 "@octokit/request@^5.3.0", "@octokit/request@^5.4.0":
-  version "5.4.6"
-  resolved "https://registry.yarnpkg.com/@octokit/request/-/request-5.4.6.tgz#e8cc8d4cfc654d30428ea92aaa62168fd5ead7eb"
-  integrity sha512-9r8Sn4CvqFI9LDLHl9P17EZHwj3ehwQnTpTE+LEneb0VBBqSiI/VS4rWIBfBhDrDs/aIGEGZRSB0QWAck8u+2g==
+  version "5.4.7"
+  resolved "https://registry.yarnpkg.com/@octokit/request/-/request-5.4.7.tgz#fd703ee092e0463ceba49ff7a3e61cb4cf8a0fde"
+  integrity sha512-FN22xUDP0i0uF38YMbOfx6TotpcENP5W8yJM1e/LieGXn6IoRxDMnBf7tx5RKSW4xuUZ/1P04NFZy5iY3Rax1A==
   dependencies:
     "@octokit/endpoint" "^6.0.1"
     "@octokit/request-error" "^2.0.0"
     "@octokit/types" "^5.0.0"
     deprecation "^2.0.0"
-    is-plain-object "^3.0.0"
+    is-plain-object "^4.0.0"
     node-fetch "^2.3.0"
     once "^1.4.0"
     universal-user-agent "^6.0.0"
@@ -1414,9 +1413,9 @@
   integrity sha1-aaI6OtKcrwCX8G7aWbNh7i8GOfY=
 
 "@types/node@*", "@types/node@>= 8", "@types/node@^14.0.18":
-  version "14.0.23"
-  resolved "https://registry.yarnpkg.com/@types/node/-/node-14.0.23.tgz#676fa0883450ed9da0bb24156213636290892806"
-  integrity sha512-Z4U8yDAl5TFkmYsZdFPdjeMa57NOvnaf1tljHzhouaPEp7LCj2JKkejpI1ODviIAQuW4CcQmxkQ77rnLsOOoKw==
+  version "14.0.24"
+  resolved "https://registry.yarnpkg.com/@types/node/-/node-14.0.24.tgz#b0f86f58564fa02a28b68f8b55d4cdec42e3b9d6"
+  integrity sha512-btt/oNOiDWcSuI721MdL8VQGnjsKjlTMdrKyTcLCKeQp/n4AAMFJ961wMbp+09y8WuGPClDEv07RIItdXKIXAA==
 
 "@types/node@^13.7.0":
   version "13.13.14"
@@ -1875,13 +1874,6 @@ anymatch@~3.1.1:
     normalize-path "^3.0.0"
     picomatch "^2.0.4"
 
-append-buffer@^1.0.2:
-  version "1.0.2"
-  resolved "https://registry.yarnpkg.com/append-buffer/-/append-buffer-1.0.2.tgz#d8220cf466081525efea50614f3de6514dfa58f1"
-  integrity sha1-2CIM9GYIFSXv6lBhTz3mUU36WPE=
-  dependencies:
-    buffer-equal "^1.0.0"
-
 append-transform@^2.0.0:
   version "2.0.0"
   resolved "https://registry.yarnpkg.com/append-transform/-/append-transform-2.0.0.tgz#99d9d29c7b38391e6f428d28ce136551f0b77e12"
@@ -2396,7 +2388,7 @@ block-stream@*:
   dependencies:
     inherits "~2.0.0"
 
-bluebird@^3.2.2, bluebird@^3.5.1, bluebird@^3.5.2, bluebird@^3.5.5:
+bluebird@^3.2.2, bluebird@^3.5.1, bluebird@^3.5.2, bluebird@^3.5.5, bluebird@^3.7.2:
   version "3.7.2"
   resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.7.2.tgz#9f229c15be272454ffa973ace0dbee79a1b0c36f"
   integrity sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==
@@ -2558,11 +2550,6 @@ browserslist@^4.0.0, browserslist@^4.12.0, browserslist@^4.8.5:
     escalade "^3.0.1"
     node-releases "^1.1.58"
 
-buffer-equal@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/buffer-equal/-/buffer-equal-1.0.0.tgz#59616b498304d556abd466966b22eeda3eca5fbe"
-  integrity sha1-WWFrSYME1Var1GaWayLu2j7KX74=
-
 buffer-from@^1.0.0:
   version "1.1.1"
   resolved "https://registry.yarnpkg.com/buffer-from/-/buffer-from-1.1.1.tgz#32713bc028f75c02fdb710d7c7bcec1f2c6070ef"
@@ -2775,16 +2762,16 @@ caniuse-api@^3.0.0:
     lodash.uniq "^4.5.0"
 
 caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001093, caniuse-lite@^1.0.30001097:
-  version "1.0.30001104"
-  resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001104.tgz#4e3d5b3b1dd3c3529f10cb7f519c62ba3e579f5d"
-  integrity sha512-pkpCg7dmI/a7WcqM2yfdOiT4Xx5tzyoHAXWsX5/HxZ3TemwDZs0QXdqbE0UPLPVy/7BeK7693YfzfRYfu1YVpg==
+  version "1.0.30001105"
+  resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001105.tgz#d2cb0b31e5cf2f3ce845033b61c5c01566549abf"
+  integrity sha512-JupOe6+dGMr7E20siZHIZQwYqrllxotAhiaej96y6x00b/48rPt42o+SzOSCPbrpsDWvRja40Hwrj0g0q6LZJg==
 
 caseless@~0.12.0:
   version "0.12.0"
   resolved "https://registry.yarnpkg.com/caseless/-/caseless-0.12.0.tgz#1b681c21ff84033c826543090689420d187151dc"
   integrity sha1-G2gcIf+EAzyCZUMJBolCDRhxUdw=
 
-catharsis@~0.8.8:
+catharsis@^0.8.11, catharsis@~0.8.8:
   version "0.8.11"
   resolved "https://registry.yarnpkg.com/catharsis/-/catharsis-0.8.11.tgz#d0eb3d2b82b7da7a3ce2efb1a7b00becc6643468"
   integrity sha512-a+xUyMV7hD1BrDQA/3iPV7oc+6W26BgVJO05PGEoatMyIuPScQKsde6i3YorWX1qs+AZjnJ18NqdKoCtKiNh1g==
@@ -3139,11 +3126,6 @@ cliui@^6.0.0:
     strip-ansi "^6.0.0"
     wrap-ansi "^6.2.0"
 
-clone-buffer@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/clone-buffer/-/clone-buffer-1.0.0.tgz#e3e25b207ac4e701af721e2cb5a16792cac3dc58"
-  integrity sha1-4+JbIHrE5wGvch4staFnksrD3Fg=
-
 clone-deep@^4.0.1:
   version "4.0.1"
   resolved "https://registry.yarnpkg.com/clone-deep/-/clone-deep-4.0.1.tgz#c19fd9bdbbf85942b4fd979c84dcf7d5f07c2387"
@@ -3160,25 +3142,11 @@ clone-regexp@^2.1.0:
   dependencies:
     is-regexp "^2.0.0"
 
-clone-stats@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/clone-stats/-/clone-stats-1.0.0.tgz#b3782dff8bb5474e18b9b6bf0fdfe782f8777680"
-  integrity sha1-s3gt/4u1R04Yuba/D9/ngvh3doA=
-
 clone@^2.1.0, clone@^2.1.1:
   version "2.1.2"
   resolved "https://registry.yarnpkg.com/clone/-/clone-2.1.2.tgz#1b7f4b9f591f1e8f83670401600345a02887435f"
   integrity sha1-G39Ln1kfHo+DZwQBYANFoCiHQ18=
 
-cloneable-readable@^1.0.0:
-  version "1.1.3"
-  resolved "https://registry.yarnpkg.com/cloneable-readable/-/cloneable-readable-1.1.3.tgz#120a00cb053bfb63a222e709f9683ea2e11d8cec"
-  integrity sha512-2EF8zTQOxYq70Y4XKtorQupqF0m49MBz2/yf5Bj+MHjvpG3Hy7sImifnqD6UA+TKYxeSV+u6qqQPawN5UvnpKQ==
-  dependencies:
-    inherits "^2.0.1"
-    process-nextick-args "^2.0.0"
-    readable-stream "^2.3.5"
-
 co@^4.6.0:
   version "4.6.0"
   resolved "https://registry.yarnpkg.com/co/-/co-4.6.0.tgz#6ea6bdf3d853ae54ccb8e47bfa0bf3f9031fb184"
@@ -4295,9 +4263,9 @@ ee-first@1.1.1:
   integrity sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0=
 
 electron-to-chromium@^1.3.488:
-  version "1.3.501"
-  resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.3.501.tgz#faa17a2cb0105ee30d5e1ca87eae7d8e85dd3175"
-  integrity sha512-tyzuKaV2POw2mtqBBzQGNBojMZzH0MRu8bT8T/50x+hWeucyG/9pkgAATy+PcM2ySNM9+8eG2VllY9c6j4i+bg==
+  version "1.3.503"
+  resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.3.503.tgz#3b2fc07b7e3ea0262c395984579ddabee1c9e0cd"
+  integrity sha512-Rh4+JP+5a+HeCg+77/BOLKzA6pLf8WZMd+DqiduEWCPyvSdY6KWzfkZXa2JRxrLO/aaB1YjTXenQdamZYzWfUw==
 
 elegant-spinner@^1.0.1:
   version "1.0.1"
@@ -4410,7 +4378,7 @@ entities@^1.1.1, entities@~1.1.1:
   resolved "https://registry.yarnpkg.com/entities/-/entities-1.1.2.tgz#bdfa735299664dfafd34529ed4f8522a275fea56"
   integrity sha512-f2LZMYl1Fzu7YSBKg+RoROelpOaNrcGmE9AZubeDfrCEia483oW4MI4VyFd5VNHIgQ/7qm1I0wUHK1eJnn2y2w==
 
-entities@^2.0.0:
+entities@^2.0.0, entities@~2.0.0:
   version "2.0.3"
   resolved "https://registry.yarnpkg.com/entities/-/entities-2.0.3.tgz#5c487e5742ab93c15abb5da22759b8590ec03b7f"
   integrity sha512-MyoZ0jgnLvB2X3Lg5HqpFmn1kybDiIfEQmKzTb5apr51Rb+T3KdmMiqa70T+bhGnyv7bQ6WMj2QMHpGMmlrUYQ==
@@ -4925,7 +4893,7 @@ fast-glob@^2.0.2, fast-glob@^2.2.6:
     merge2 "^1.2.3"
     micromatch "^3.1.10"
 
-fast-glob@^3.0.3, fast-glob@^3.1.1:
+fast-glob@^3.0.3, fast-glob@^3.1.1, fast-glob@^3.2.4:
   version "3.2.4"
   resolved "https://registry.yarnpkg.com/fast-glob/-/fast-glob-3.2.4.tgz#d20aefbf99579383e7f3cc66529158c9b98554d3"
   integrity sha512-kr/Oo6PX51265qeuCYsyGypiO5uJFgBS0jksyG7FUeCyQzNwYnzrNIMR1NXfkZXsMYXYLRAHgISHBz8gQcxKHQ==
@@ -5140,7 +5108,7 @@ flatted@^2.0.0, flatted@^2.0.1, flatted@^2.0.2:
   resolved "https://registry.yarnpkg.com/flatted/-/flatted-2.0.2.tgz#4575b21e2bcee7434aa9be662f4b7b5f9c2b5138"
   integrity sha512-r5wGx7YeOwNWNlCA0wQ86zKyDLMQr+/RB8xy74M4hTphfmjlijTSSXGuH8rnvKZnfT9i+75zmd8jcKdMR4O6jA==
 
-flush-write-stream@^1.0.0, flush-write-stream@^1.0.2:
+flush-write-stream@^1.0.0:
   version "1.1.1"
   resolved "https://registry.yarnpkg.com/flush-write-stream/-/flush-write-stream-1.1.1.tgz#8dd7d873a1babc207d94ead0c2e0e44276ebf2e8"
   integrity sha512-3Z4XhFZ3992uIq0XOqb9AreonueSYphE6oYbpt5+3u06JWklbsPkNv3ZKkP9Bz/r+1MWCaMoSQ28P85+1Yc77w==
@@ -5233,14 +5201,6 @@ fs-minipass@^2.0.0:
   dependencies:
     minipass "^3.0.0"
 
-fs-mkdirp-stream@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/fs-mkdirp-stream/-/fs-mkdirp-stream-1.0.0.tgz#0b7815fc3201c6a69e14db98ce098c16935259eb"
-  integrity sha1-C3gV/DIBxqaeFNuYzgmMFpNSWes=
-  dependencies:
-    graceful-fs "^4.1.11"
-    through2 "^2.0.3"
-
 fs-write-stream-atomic@^1.0.8:
   version "1.0.10"
   resolved "https://registry.yarnpkg.com/fs-write-stream-atomic/-/fs-write-stream-atomic-1.0.10.tgz#b47df53493ef911df75731e70a9ded0189db40c9"
@@ -5432,22 +5392,6 @@ glob-parent@^5.0.0, glob-parent@^5.1.0, glob-parent@~5.1.0:
   dependencies:
     is-glob "^4.0.1"
 
-glob-stream@^6.1.0:
-  version "6.1.0"
-  resolved "https://registry.yarnpkg.com/glob-stream/-/glob-stream-6.1.0.tgz#7045c99413b3eb94888d83ab46d0b404cc7bdde4"
-  integrity sha1-cEXJlBOz65SIjYOrRtC0BMx73eQ=
-  dependencies:
-    extend "^3.0.0"
-    glob "^7.1.1"
-    glob-parent "^3.1.0"
-    is-negated-glob "^1.0.0"
-    ordered-read-streams "^1.0.0"
-    pumpify "^1.3.5"
-    readable-stream "^2.1.5"
-    remove-trailing-separator "^1.0.1"
-    to-absolute-glob "^2.0.0"
-    unique-stream "^2.0.2"
-
 glob-to-regexp@^0.3.0:
   version "0.3.0"
   resolved "https://registry.yarnpkg.com/glob-to-regexp/-/glob-to-regexp-0.3.0.tgz#8c5a1494d2066c570cc3bfe4496175acc4d502ab"
@@ -5483,7 +5427,7 @@ glob@^6.0.1:
     once "^1.3.0"
     path-is-absolute "^1.0.0"
 
-glob@^7.0.0, glob@^7.0.3, glob@^7.0.5, glob@^7.1.1, glob@^7.1.2, glob@^7.1.3, glob@^7.1.4, glob@^7.1.6, glob@~7.1.1:
+glob@^7.0.0, glob@^7.0.3, glob@^7.0.5, glob@^7.1.2, glob@^7.1.3, glob@^7.1.4, glob@^7.1.6, glob@~7.1.1:
   version "7.1.6"
   resolved "https://registry.yarnpkg.com/glob/-/glob-7.1.6.tgz#141f33b81a7c2492e125594307480c46679278a6"
   integrity sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==
@@ -5634,7 +5578,7 @@ google-libphonenumber@^3.2.8:
   resolved "https://registry.yarnpkg.com/google-libphonenumber/-/google-libphonenumber-3.2.10.tgz#021a314652747d736a39e2e60dc670f0431425ad"
   integrity sha512-TsckE9O8QgqaIeaOXPjcJa4/kX3BzFdO1oCbMfmUpRZckml4xJhjJVxaT9Mdt/VrZZkT9lX44eHAEWfJK1tHtw==
 
-graceful-fs@^4.0.0, graceful-fs@^4.1.11, graceful-fs@^4.1.15, graceful-fs@^4.1.2, graceful-fs@^4.1.3, graceful-fs@^4.1.6, graceful-fs@^4.1.9, graceful-fs@^4.2.0, graceful-fs@^4.2.2, graceful-fs@^4.2.4:
+graceful-fs@^4.1.11, graceful-fs@^4.1.15, graceful-fs@^4.1.2, graceful-fs@^4.1.3, graceful-fs@^4.1.6, graceful-fs@^4.1.9, graceful-fs@^4.2.0, graceful-fs@^4.2.2, graceful-fs@^4.2.4:
   version "4.2.4"
   resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.4.tgz#2256bde14d3632958c465ebc96dc467ca07a29fb"
   integrity sha512-WjKPNJF79dtJAVniUlGGWHYGz2jWxT6VhN/4m1NdkbZ2nOsEF+cI1Edgql5zCRhs/VsQYRvrXctxktVXZUkixw==
@@ -6337,14 +6281,6 @@ is-absolute-url@^2.0.0:
   resolved "https://registry.yarnpkg.com/is-absolute-url/-/is-absolute-url-2.1.0.tgz#50530dfb84fcc9aa7dbe7852e83a37b93b9f2aa6"
   integrity sha1-UFMN+4T8yap9vnhS6Do3uTufKqY=
 
-is-absolute@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/is-absolute/-/is-absolute-1.0.0.tgz#395e1ae84b11f26ad1795e73c17378e48a301576"
-  integrity sha512-dOWoqflvcydARa360Gvv18DZ/gRuHKi2NU/wU5X1ZFzdYfH29nkiNZsF3mp4OJ3H4yo9Mx8A/uAGNzpzPN3yBA==
-  dependencies:
-    is-relative "^1.0.0"
-    is-windows "^1.0.1"
-
 is-accessor-descriptor@^0.1.6:
   version "0.1.6"
   resolved "https://registry.yarnpkg.com/is-accessor-descriptor/-/is-accessor-descriptor-0.1.6.tgz#a9e12cb3ae8d876727eeef3843f8a0897b5c98d6"
@@ -6585,11 +6521,6 @@ is-hexadecimal@^1.0.0:
   resolved "https://registry.yarnpkg.com/is-hexadecimal/-/is-hexadecimal-1.0.4.tgz#cc35c97588da4bd49a8eedd6bc4082d44dcb23a7"
   integrity sha512-gyPJuv83bHMpocVYoqof5VDiZveEoGoFL8m3BXNb2VW8Xs+rz9kqO8LOQ5DH6EsuvilT1ApazU0pyl+ytbPtlw==
 
-is-negated-glob@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/is-negated-glob/-/is-negated-glob-1.0.0.tgz#6910bca5da8c95e784b5751b976cf5a10fee36d2"
-  integrity sha1-aRC8pdqMleeEtXUbl2z1oQ/uNtI=
-
 is-number@^2.1.0:
   version "2.1.0"
   resolved "https://registry.yarnpkg.com/is-number/-/is-number-2.1.0.tgz#01fcbbb393463a548f2f466cce16dece49db908f"
@@ -6663,6 +6594,11 @@ is-plain-object@^3.0.0:
   resolved "https://registry.yarnpkg.com/is-plain-object/-/is-plain-object-3.0.1.tgz#662d92d24c0aa4302407b0d45d21f2251c85f85b"
   integrity sha512-Xnpx182SBMrr/aBik8y+GuR4U1L9FqMSojwDQwPMmxyC6bvEqly9UBCxhauBF5vNh2gwWJNX6oDV7O+OM4z34g==
 
+is-plain-object@^4.0.0:
+  version "4.1.0"
+  resolved "https://registry.yarnpkg.com/is-plain-object/-/is-plain-object-4.1.0.tgz#00196ad308ebb7de9d1fb57ae92ef1c38d5a740e"
+  integrity sha512-1N1OpoS8S4Ua+FsH6Mhvgaj0di3uRXgulcv2dnFu2J/WcEsDNbBoiUX6mYmhQ2cAzZ+B/lTJtX1qUSL5RwsGug==
+
 is-posix-bracket@^0.1.0:
   version "0.1.1"
   resolved "https://registry.yarnpkg.com/is-posix-bracket/-/is-posix-bracket-0.1.1.tgz#3334dc79774368e92f016e6fbc0a88f5cd6e6bc4"
@@ -6695,13 +6631,6 @@ is-regexp@^2.0.0:
   resolved "https://registry.yarnpkg.com/is-regexp/-/is-regexp-2.1.0.tgz#cd734a56864e23b956bf4e7c66c396a4c0b22c2d"
   integrity sha512-OZ4IlER3zmRIoB9AqNhEggVxqIH4ofDns5nRrPS6yQxXE1TPCUpFznBfRQmQa8uC+pXqjMnukiJBxCisIxiLGA==
 
-is-relative@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/is-relative/-/is-relative-1.0.0.tgz#a1bb6935ce8c5dba1e8b9754b9b2dcc020e2260d"
-  integrity sha512-Kw/ReK0iqwKeu0MITLFuj0jbPAmEiOsIwyIXvvbfa6QfmN9pkD1M+8pdk7Rl/dTKbH34/XBFMbgD4iMJhLQbGA==
-  dependencies:
-    is-unc-path "^1.0.0"
-
 is-resolvable@^1.0.0:
   version "1.1.0"
   resolved "https://registry.yarnpkg.com/is-resolvable/-/is-resolvable-1.1.0.tgz#fb18f87ce1feb925169c9a407c19318a3206ed88"
@@ -6743,23 +6672,11 @@ is-typedarray@^1.0.0, is-typedarray@~1.0.0:
   resolved "https://registry.yarnpkg.com/is-typedarray/-/is-typedarray-1.0.0.tgz#e479c80858df0c1b11ddda6940f96011fcda4a9a"
   integrity sha1-5HnICFjfDBsR3dppQPlgEfzaSpo=
 
-is-unc-path@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/is-unc-path/-/is-unc-path-1.0.0.tgz#d731e8898ed090a12c352ad2eaed5095ad322c9d"
-  integrity sha512-mrGpVd0fs7WWLfVsStvgF6iEJnbjDFZh9/emhRDcGWTduTfNHd9CHeUwH3gYIjdbwo4On6hunkztwOaAw0yllQ==
-  dependencies:
-    unc-path-regex "^0.1.2"
-
-is-utf8@^0.2.0, is-utf8@^0.2.1:
+is-utf8@^0.2.0:
   version "0.2.1"
   resolved "https://registry.yarnpkg.com/is-utf8/-/is-utf8-0.2.1.tgz#4b0da1442104d1b336340e80797e865cf39f7d72"
   integrity sha1-Sw2hRCEE0bM2NA6AeX6GXPOffXI=
 
-is-valid-glob@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/is-valid-glob/-/is-valid-glob-1.0.0.tgz#29bf3eff701be2d4d315dbacc39bc39fe8f601aa"
-  integrity sha1-Kb8+/3Ab4tTTFdusw5vDn+j2Aao=
-
 is-whitespace-character@^1.0.0:
   version "1.0.4"
   resolved "https://registry.yarnpkg.com/is-whitespace-character/-/is-whitespace-character-1.0.4.tgz#0858edd94a95594c7c9dd0b5c174ec6e45ee4aa7"
@@ -7035,6 +6952,13 @@ js-yaml@^3.12.0, js-yaml@^3.13.0, js-yaml@^3.13.1, js-yaml@^3.4.2:
     argparse "^1.0.7"
     esprima "^4.0.0"
 
+js2xmlparser@^4.0.1:
+  version "4.0.1"
+  resolved "https://registry.yarnpkg.com/js2xmlparser/-/js2xmlparser-4.0.1.tgz#670ef71bc5661f089cc90481b99a05a1227ae3bd"
+  integrity sha512-KrPTolcw6RocpYjdC7pL7v62e55q7qOMHvLX1UCLc5AAS8qeJ6nukarEJAF2KL2PZxlbGueEbINqZR2bDe/gUw==
+  dependencies:
+    xmlcreate "^2.0.3"
+
 js2xmlparser@~1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/js2xmlparser/-/js2xmlparser-1.0.0.tgz#5a170f2e8d6476ce45405e04823242513782fe30"
@@ -7045,6 +6969,25 @@ jsbn@~0.1.0:
   resolved "https://registry.yarnpkg.com/jsbn/-/jsbn-0.1.1.tgz#a5e654c2e5a2deb5f201d96cefbca80c0ef2f513"
   integrity sha1-peZUwuWi3rXyAdls77yoDA7y9RM=
 
+"jsdoc@github:ckeditor/jsdoc#fixed-trailing-comment-doclets":
+  version "3.6.4"
+  resolved "https://codeload.github.com/ckeditor/jsdoc/tar.gz/cb34eb2c1b4051103bbb2e802f8319633d820f56"
+  dependencies:
+    "@babel/parser" "^7.10.3"
+    bluebird "^3.7.2"
+    catharsis "^0.8.11"
+    escape-string-regexp "^2.0.0"
+    js2xmlparser "^4.0.1"
+    klaw "^3.0.0"
+    markdown-it "^10.0.0"
+    markdown-it-anchor "^5.2.7"
+    marked "^0.8.2"
+    mkdirp "^1.0.4"
+    requizzle "^0.2.3"
+    strip-json-comments "^3.1.0"
+    taffydb "2.6.2"
+    underscore "~1.10.2"
+
 "jsdoc@github:ckeditor/jsdoc#node12":
   version "3.4.3"
   resolved "https://codeload.github.com/ckeditor/jsdoc/tar.gz/e6c213ab53c20457d2778953a6d3fc54311f9a67"
@@ -7316,6 +7259,13 @@ kind-of@^6.0.0, kind-of@^6.0.2, kind-of@^6.0.3:
   resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-6.0.3.tgz#07c05034a6c349fa06e24fa35aa76db4580ce4dd"
   integrity sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==
 
+klaw@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/klaw/-/klaw-3.0.0.tgz#b11bec9cf2492f06756d6e809ab73a2910259146"
+  integrity sha512-0Fo5oir+O9jnXu5EefYbVK+mHMBeEVEy2cmctR1O1NECcCkPRreJKrS6Qt/j3KC2C148Dfo9i3pCmCMsdqGr0g==
+  dependencies:
+    graceful-fs "^4.1.9"
+
 klaw@~1.3.0:
   version "1.3.1"
   resolved "https://registry.yarnpkg.com/klaw/-/klaw-1.3.1.tgz#4088433b46b3b1ba259d78785d8e96f73ba02439"
@@ -7343,25 +7293,11 @@ lazy-cache@^1.0.3:
   resolved "https://registry.yarnpkg.com/lazy-cache/-/lazy-cache-1.0.4.tgz#a1d78fc3a50474cb80845d3b3b6e1da49a446e8e"
   integrity sha1-odePw6UEdMuAhF07O24dpJpEbo4=
 
-lazystream@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/lazystream/-/lazystream-1.0.0.tgz#f6995fe0f820392f61396be89462407bb77168e4"
-  integrity sha1-9plf4PggOS9hOWvolGJAe7dxaOQ=
-  dependencies:
-    readable-stream "^2.0.5"
-
 lcov-parse@^1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/lcov-parse/-/lcov-parse-1.0.0.tgz#eb0d46b54111ebc561acb4c408ef9363bdc8f7e0"
   integrity sha1-6w1GtUER68VhrLTECO+TY73I9+A=
 
-lead@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/lead/-/lead-1.0.0.tgz#6f14f99a37be3a9dd784f5495690e5903466ee42"
-  integrity sha1-bxT5mje+Op3XhPVJVpDlkDRm7kI=
-  dependencies:
-    flush-write-stream "^1.0.2"
-
 leven@^2.1.0:
   version "2.1.0"
   resolved "https://registry.yarnpkg.com/leven/-/leven-2.1.0.tgz#c2e7a9f772094dee9d34202ae8acce4687875580"
@@ -7863,11 +7799,6 @@ map-obj@^4.0.0:
   resolved "https://registry.yarnpkg.com/map-obj/-/map-obj-4.1.0.tgz#b91221b542734b9f14256c0132c897c5d7256fd5"
   integrity sha512-glc9y00wgtwcDmp7GaE/0b0OnxpNJsVf3ael/An6Fe2Q51LLwN1er6sdomLRzz5h0+yMpiYLhWYF5R7HeqVd4g==
 
-map-stream@^0.0.7:
-  version "0.0.7"
-  resolved "https://registry.yarnpkg.com/map-stream/-/map-stream-0.0.7.tgz#8a1f07896d82b10926bd3744a2420009f88974a8"
-  integrity sha1-ih8HiW2CsQkmvTdEokIACfiJdKg=
-
 map-visit@^1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/map-visit/-/map-visit-1.0.0.tgz#ecdca8f13144e660f1b5bd41f12f3479d98dfb8f"
@@ -7885,6 +7816,11 @@ markdown-it-abbr@^1.0.4:
   resolved "https://registry.yarnpkg.com/markdown-it-abbr/-/markdown-it-abbr-1.0.4.tgz#d66b5364521cbb3dd8aa59dadfba2fb6865c8fd8"
   integrity sha1-1mtTZFIcuz3Yqlna37ovtoZcj9g=
 
+markdown-it-anchor@^5.2.7:
+  version "5.3.0"
+  resolved "https://registry.yarnpkg.com/markdown-it-anchor/-/markdown-it-anchor-5.3.0.tgz#d549acd64856a8ecd1bea58365ef385effbac744"
+  integrity sha512-/V1MnLL/rgJ3jkMWo84UR+K+jF1cxNG1a+KwqeXqTIJ+jtA8aWSHuigx8lTzauiIjBDbwF3NcWQMotd0Dm39jA==
+
 markdown-it-deflist@^2.0.3:
   version "2.0.3"
   resolved "https://registry.yarnpkg.com/markdown-it-deflist/-/markdown-it-deflist-2.0.3.tgz#5727db04184d3cb2bc6ee4a9641e3a1091d5fd6f"
@@ -7940,6 +7876,17 @@ markdown-it-toc@^1.1.0:
   resolved "https://registry.yarnpkg.com/markdown-it-toc/-/markdown-it-toc-1.1.0.tgz#6d189858e32b3deb4ff3ad2713fa927439c957e0"
   integrity sha1-bRiYWOMrPetP860nE/qSdDnJV+A=
 
+markdown-it@^10.0.0:
+  version "10.0.0"
+  resolved "https://registry.yarnpkg.com/markdown-it/-/markdown-it-10.0.0.tgz#abfc64f141b1722d663402044e43927f1f50a8dc"
+  integrity sha512-YWOP1j7UbDNz+TumYP1kpwnP0aEa711cJjrAQrzd0UXlbJfc5aAq0F/PZHjiioqDC1NKgvIMX+o+9Bk7yuM2dg==
+  dependencies:
+    argparse "^1.0.7"
+    entities "~2.0.0"
+    linkify-it "^2.0.0"
+    mdurl "^1.0.1"
+    uc.micro "^1.0.5"
+
 markdown-it@^8.3.1:
   version "8.4.2"
   resolved "https://registry.yarnpkg.com/markdown-it/-/markdown-it-8.4.2.tgz#386f98998dc15a37722aa7722084f4020bdd9b54"
@@ -7970,6 +7917,11 @@ markdown@~0.5.0:
   dependencies:
     nopt "~2.1.1"
 
+marked@^0.8.2:
+  version "0.8.2"
+  resolved "https://registry.yarnpkg.com/marked/-/marked-0.8.2.tgz#4faad28d26ede351a7a1aaa5fec67915c869e355"
+  integrity sha512-EGwzEeCcLniFX51DhTpmTom+dSA/MG/OBUDjnWtHbEnjAH180VzUeAw+oE4+Zv+CoYBWyRlYOTR0N8SO9R1PVw==
+
 marked@~0.3.6:
   version "0.3.19"
   resolved "https://registry.yarnpkg.com/marked/-/marked-0.3.19.tgz#5d47f709c4c9fc3c216b6d46127280f40b39d790"
@@ -8681,13 +8633,6 @@ normalize-url@^3.0.0:
   resolved "https://registry.yarnpkg.com/normalize-url/-/normalize-url-3.3.0.tgz#b2e1c4dc4f7c6d57743df733a4f5978d18650559"
   integrity sha512-U+JJi7duF1o+u2pynbp2zXDW2/PADgC30f0GsHZtRh+HOcXHnw137TrNlyxxRvWW5fjKd3bcLHPxofWuCjaeZg==
 
-now-and-later@^2.0.0:
-  version "2.0.1"
-  resolved "https://registry.yarnpkg.com/now-and-later/-/now-and-later-2.0.1.tgz#8e579c8685764a7cc02cb680380e94f43ccb1f7c"
-  integrity sha512-KGvQ0cB70AQfg107Xvs/Fbu+dGmZoTRJp2TaPwcwQm3/7PteUyN2BCgk8KBMPGBUXZdVwyWS8fDCGFygBm19UQ==
-  dependencies:
-    once "^1.3.2"
-
 npm-path@^2.0.2:
   version "2.0.4"
   resolved "https://registry.yarnpkg.com/npm-path/-/npm-path-2.0.4.tgz#c641347a5ff9d6a09e4d9bce5580c4f505278e64"
@@ -8830,7 +8775,7 @@ object-visit@^1.0.0:
   dependencies:
     isobject "^3.0.0"
 
-object.assign@4.1.0, object.assign@^4.0.4, object.assign@^4.1.0:
+object.assign@4.1.0, object.assign@^4.1.0:
   version "4.1.0"
   resolved "https://registry.yarnpkg.com/object.assign/-/object.assign-4.1.0.tgz#968bf1100d7956bb3ca086f006f846b3bc4008da"
   integrity sha512-exHJeq6kBKj58mqGyTQ9DFvrZC/eR6OwxzoM9YRoGBqrXYonaFyGiFMuc9VZrXf7DarreEwMpurG3dd+CNyW5w==
@@ -8880,7 +8825,7 @@ on-finished@~2.3.0:
   dependencies:
     ee-first "1.1.1"
 
-once@^1.3.0, once@^1.3.1, once@^1.3.2, once@^1.4.0:
+once@^1.3.0, once@^1.3.1, once@^1.4.0:
   version "1.4.0"
   resolved "https://registry.yarnpkg.com/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1"
   integrity sha1-WDsap3WWHUsROsF9nFC6753Xa9E=
@@ -8943,13 +8888,6 @@ optionator@^0.9.1:
     type-check "^0.4.0"
     word-wrap "^1.2.3"
 
-ordered-read-streams@^1.0.0:
-  version "1.0.1"
-  resolved "https://registry.yarnpkg.com/ordered-read-streams/-/ordered-read-streams-1.0.1.tgz#77c0cb37c41525d64166d990ffad7ec6a0e1363e"
-  integrity sha1-d8DLN8QVJdZBZtmQ/61+xqDhNj4=
-  dependencies:
-    readable-stream "^2.0.1"
-
 os-browserify@^0.3.0:
   version "0.3.0"
   resolved "https://registry.yarnpkg.com/os-browserify/-/os-browserify-0.3.0.tgz#854373c7f5c2315914fc9bfc6bd8238fdda1ec27"
@@ -9891,7 +9829,7 @@ private@^0.1.8:
   resolved "https://registry.yarnpkg.com/private/-/private-0.1.8.tgz#2381edb3689f7a53d653190060fcf822d2f368ff"
   integrity sha512-VvivMrbvd2nKkiG38qjULzlc+4Vx4wm/whI9pQD35YrARNnhxeiRktSOhSukRLFNlzg6Br/cJPet5J/u19r/mg==
 
-process-nextick-args@^2.0.0, process-nextick-args@~2.0.0:
+process-nextick-args@~2.0.0:
   version "2.0.1"
   resolved "https://registry.yarnpkg.com/process-nextick-args/-/process-nextick-args-2.0.1.tgz#7820d9b16120cc55ca9ae7792680ae7dba6d7fe2"
   integrity sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==
@@ -10217,7 +10155,7 @@ pump@^3.0.0:
     end-of-stream "^1.1.0"
     once "^1.3.1"
 
-pumpify@^1.3.3, pumpify@^1.3.5:
+pumpify@^1.3.3:
   version "1.5.1"
   resolved "https://registry.yarnpkg.com/pumpify/-/pumpify-1.5.1.tgz#36513be246ab27570b1a374a5ce278bfd74370ce"
   integrity sha512-oClZI37HvuUJJxSKKrC17bZ9Cu0ZYhEAGPsPUy9KlMUmv9dKX2o77RUmq7f3XjIxbwyGwYzbzQ1L2Ks8sIradQ==
@@ -10449,7 +10387,7 @@ read-pkg@^5.2.0:
     parse-json "^5.0.0"
     type-fest "^0.6.0"
 
-"readable-stream@1 || 2", readable-stream@^2.0.0, readable-stream@^2.0.1, readable-stream@^2.0.2, readable-stream@^2.0.5, readable-stream@^2.0.6, readable-stream@^2.1.5, readable-stream@^2.2.2, readable-stream@^2.3.3, readable-stream@^2.3.5, readable-stream@^2.3.6, readable-stream@~2.3.6:
+"readable-stream@1 || 2", readable-stream@^2.0.0, readable-stream@^2.0.1, readable-stream@^2.0.2, readable-stream@^2.0.6, readable-stream@^2.1.5, readable-stream@^2.2.2, readable-stream@^2.3.3, readable-stream@^2.3.6, readable-stream@~2.3.6:
   version "2.3.7"
   resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.3.7.tgz#1eca1cf711aef814c04f62252a36a62f6cb23b57"
   integrity sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==
@@ -10548,9 +10486,9 @@ regenerator-runtime@^0.11.0:
   integrity sha512-MguG95oij0fC3QV3URf4V2SDYGJhJnJGqvIIgdECeODCT98wSWDAJ94SSuVpYQUoTcGUIL6L4yNB7j1DFFHSBg==
 
 regenerator-runtime@^0.13.4:
-  version "0.13.5"
-  resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.13.5.tgz#d878a1d094b4306d10b9096484b33ebd55e26697"
-  integrity sha512-ZS5w8CpKFinUzOwW3c83oPeVXoNsrLsaCoLtJvAClH135j/R77RuymhiSErhm2lKcwSCIpmvIWSbDkIfAqKQlA==
+  version "0.13.6"
+  resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.13.6.tgz#d236043c46ffab2968c1ef651803d8acdea8ed65"
+  integrity sha512-GmwlGiazQEbOwQWDdbbaP10i15pGtScYWLbMZuu+RKRz0cZ+g8IUONazBnaZqe7j1670IV1HgE4/8iy7CQPf4Q==
 
 regenerator-transform@^0.14.2:
   version "0.14.5"
@@ -10716,23 +10654,6 @@ remark@^12.0.0:
     remark-stringify "^8.0.0"
     unified "^9.0.0"
 
-remove-bom-buffer@^3.0.0:
-  version "3.0.0"
-  resolved "https://registry.yarnpkg.com/remove-bom-buffer/-/remove-bom-buffer-3.0.0.tgz#c2bf1e377520d324f623892e33c10cac2c252b53"
-  integrity sha512-8v2rWhaakv18qcvNeli2mZ/TMTL2nEyAKRvzo1WtnZBl15SHyEhrCu2/xKlJyUFKHiHgfXIyuY6g2dObJJycXQ==
-  dependencies:
-    is-buffer "^1.1.5"
-    is-utf8 "^0.2.1"
-
-remove-bom-stream@^1.2.0:
-  version "1.2.0"
-  resolved "https://registry.yarnpkg.com/remove-bom-stream/-/remove-bom-stream-1.2.0.tgz#05f1a593f16e42e1fb90ebf59de8e569525f9523"
-  integrity sha1-BfGlk/FuQuH7kOv1nejlaVJflSM=
-  dependencies:
-    remove-bom-buffer "^3.0.0"
-    safe-buffer "^5.1.0"
-    through2 "^2.0.3"
-
 remove-trailing-separator@^1.0.1:
   version "1.1.0"
   resolved "https://registry.yarnpkg.com/remove-trailing-separator/-/remove-trailing-separator-1.1.0.tgz#c24bce2a283adad5bc3f58e0d48249b92379d8ef"
@@ -10760,11 +10681,6 @@ replace-ext@1.0.0:
   resolved "https://registry.yarnpkg.com/replace-ext/-/replace-ext-1.0.0.tgz#de63128373fcbf7c3ccfa4de5a480c45a67958eb"
   integrity sha1-3mMSg3P8v3w8z6TeWkgMRaZ5WOs=
 
-replace-ext@^1.0.0:
-  version "1.0.1"
-  resolved "https://registry.yarnpkg.com/replace-ext/-/replace-ext-1.0.1.tgz#2d6d996d04a15855d967443631dd5f77825b016a"
-  integrity sha512-yD5BHCe7quCgBph4rMQ+0KkIRKwWCrHDOX1p1Gp6HwjPM5kVoCdKGNhN7ydqqsX6lJEnQDKZ/tFMiEdQ1dvPEw==
-
 request@^2.51.0, request@^2.87.0, request@^2.88.0, request@^2.88.2:
   version "2.88.2"
   resolved "https://registry.yarnpkg.com/request/-/request-2.88.2.tgz#d73c918731cb5a87da047e207234146f664d12b3"
@@ -10811,7 +10727,7 @@ requires-port@^1.0.0:
   resolved "https://registry.yarnpkg.com/requires-port/-/requires-port-1.0.0.tgz#925d2601d39ac485e091cf0da5c6e694dc3dcaff"
   integrity sha1-kl0mAdOaxIXgkc8NpcbmlNw9yv8=
 
-requizzle@~0.2.1:
+requizzle@^0.2.3, requizzle@~0.2.1:
   version "0.2.3"
   resolved "https://registry.yarnpkg.com/requizzle/-/requizzle-0.2.3.tgz#4675c90aacafb2c036bd39ba2daa4a1cb777fded"
   integrity sha512-YanoyJjykPxGHii0fZP0uUPEXpvqfBDxWV7s6GKAiiOsiqhX6vHNyW3Qzdmqp/iq/ExbhaGbVrjB4ruEVSM4GQ==
@@ -10848,13 +10764,6 @@ resolve-from@^5.0.0:
   resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-5.0.0.tgz#c35225843df8f776df21c57557bc087e9dfdfc69"
   integrity sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==
 
-resolve-options@^1.1.0:
-  version "1.1.0"
-  resolved "https://registry.yarnpkg.com/resolve-options/-/resolve-options-1.1.0.tgz#32bb9e39c06d67338dc9378c0d6d6074566ad131"
-  integrity sha1-MrueOcBtZzONyTeMDW1gdFZq0TE=
-  dependencies:
-    value-or-function "^3.0.0"
-
 resolve-url@^0.2.1:
   version "0.2.1"
   resolved "https://registry.yarnpkg.com/resolve-url/-/resolve-url-0.2.1.tgz#2c637fe77c893afd2a663fe21aa9080068e2052a"
@@ -12177,15 +12086,7 @@ text-table@^0.2.0:
   resolved "https://registry.yarnpkg.com/text-table/-/text-table-0.2.0.tgz#7f5ee823ae805207c00af2df4a84ec3fcfa570b4"
   integrity sha1-f17oI66AUgfACvLfSoTsP8+lcLQ=
 
-through2-filter@^3.0.0:
-  version "3.0.0"
-  resolved "https://registry.yarnpkg.com/through2-filter/-/through2-filter-3.0.0.tgz#700e786df2367c2c88cd8aa5be4cf9c1e7831254"
-  integrity sha512-jaRjI2WxN3W1V8/FMZ9HKIBXixtiqs3SQSX4/YGIiP3gL6djW48VoZq9tDqeCWs3MT8YY5wb/zli8VW8snY1CA==
-  dependencies:
-    through2 "~2.0.0"
-    xtend "~4.0.0"
-
-through2@^2.0.0, through2@^2.0.2, through2@^2.0.3, through2@~2.0.0:
+through2@^2.0.0, through2@^2.0.2:
   version "2.0.5"
   resolved "https://registry.yarnpkg.com/through2/-/through2-2.0.5.tgz#01c1e39eb31d07cb7d03a96a70823260b23132cd"
   integrity sha512-/mrRod8xqpA+IHSLyGCQ2s8SPHiCDEeQJSep1jqLYeEUClOFG2Qsh+4FU6G9VeqpZnGW/Su8LQGc4YKni5rYSQ==
@@ -12247,14 +12148,6 @@ tmp@^0.0.33:
   dependencies:
     os-tmpdir "~1.0.2"
 
-to-absolute-glob@^2.0.0:
-  version "2.0.2"
-  resolved "https://registry.yarnpkg.com/to-absolute-glob/-/to-absolute-glob-2.0.2.tgz#1865f43d9e74b0822db9f145b78cff7d0f7c849b"
-  integrity sha1-GGX0PZ50sIItufFFt4z/fQ98hJs=
-  dependencies:
-    is-absolute "^1.0.0"
-    is-negated-glob "^1.0.0"
-
 to-array@0.1.4:
   version "0.1.4"
   resolved "https://registry.yarnpkg.com/to-array/-/to-array-0.1.4.tgz#17e6c11f73dd4f3d74cda7a4ff3238e9ad9bf890"
@@ -12307,13 +12200,6 @@ to-regex@^3.0.1, to-regex@^3.0.2:
     regex-not "^1.0.2"
     safe-regex "^1.1.0"
 
-to-through@^2.0.0:
-  version "2.0.0"
-  resolved "https://registry.yarnpkg.com/to-through/-/to-through-2.0.0.tgz#fc92adaba072647bc0b67d6b03664aa195093af6"
-  integrity sha1-/JKtq6ByZHvAtn1rA2ZKoZUJOvY=
-  dependencies:
-    through2 "^2.0.3"
-
 toidentifier@1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/toidentifier/-/toidentifier-1.0.0.tgz#7e1be3470f1e77948bc43d94a3c8f4d7752ba553"
@@ -12519,9 +12405,9 @@ uglify-to-browserify@~1.0.0:
   integrity sha1-bgkk1r2mta/jSeOabWMoUKD4grc=
 
 umberto@^1.4.1:
-  version "1.5.1"
-  resolved "https://registry.yarnpkg.com/umberto/-/umberto-1.5.1.tgz#3b9c60d11191cd76d25299245f5648036e5adebd"
-  integrity sha512-9cn19O4q8+mwYQabFnQlVLf4jzhB/YdCJa9lvXGSspBjMye/7nTnxt7FbdUQCaFR7aGim587xkf9JLuN7p5jmA==
+  version "1.5.2"
+  resolved "https://registry.yarnpkg.com/umberto/-/umberto-1.5.2.tgz#d41851d5f4ff9400737ab68753b33a13eb5d9a71"
+  integrity sha512-+U2Fw0AJqfgErJTzfBPaOoAMy1eGj/M1VDi1bzIhc26qk4EN+Foex0jY3YcPRBh/QJBjVrQ2Byt8fE++UVsykw==
   dependencies:
     "@babel/core" "^7.10.3"
     "@babel/polyfill" "^7.10.1"
@@ -12556,10 +12442,10 @@ umberto@^1.4.1:
     vnu-jar "^20.5.29"
     webpack "^4.43.0"
 
-unc-path-regex@^0.1.2:
-  version "0.1.2"
-  resolved "https://registry.yarnpkg.com/unc-path-regex/-/unc-path-regex-0.1.2.tgz#e73dd3d7b0d7c5ed86fbac6b0ae7d8c6a69d50fa"
-  integrity sha1-5z3T17DXxe2G+6xrCufYxqadUPo=
+underscore@~1.10.2:
+  version "1.10.2"
+  resolved "https://registry.yarnpkg.com/underscore/-/underscore-1.10.2.tgz#73d6aa3668f3188e4adb0f1943bd12cfd7efaaaf"
+  integrity sha512-N4P+Q/BuyuEKFJ43B9gYuOj4TQUHXX+j2FqguVOpjkssLUUrnJofCcBccJSCoeturDoZU6GorDTHSvUDlSQbTg==
 
 underscore@~1.8.3:
   version "1.8.3"
@@ -12664,14 +12550,6 @@ unique-slug@^2.0.0:
   dependencies:
     imurmurhash "^0.1.4"
 
-unique-stream@^2.0.2:
-  version "2.3.1"
-  resolved "https://registry.yarnpkg.com/unique-stream/-/unique-stream-2.3.1.tgz#c65d110e9a4adf9a6c5948b28053d9a8d04cbeac"
-  integrity sha512-2nY4TnBE70yoxHkDli7DMazpWiP7xMdCYqU2nBRO0UB+ZpEkGsSija7MvmvnZFUeC+mrgiUfcHSr3LmRFIg4+A==
-  dependencies:
-    json-stable-stringify-without-jsonify "^1.0.1"
-    through2-filter "^3.0.0"
-
 unist-util-find-all-after@^1.0.2:
   version "1.0.5"
   resolved "https://registry.yarnpkg.com/unist-util-find-all-after/-/unist-util-find-all-after-1.0.5.tgz#5751a8608834f41d117ad9c577770c5f2f1b2899"
@@ -12905,11 +12783,6 @@ validator@13.0.0:
   resolved "https://registry.yarnpkg.com/validator/-/validator-13.0.0.tgz#0fb6c6bb5218ea23d368a8347e6d0f5a70e3bcab"
   integrity sha512-anYx5fURbgF04lQV18nEQWZ/3wHGnxiKdG4aL8J+jEDsm98n/sU/bey+tYk6tnGJzm7ioh5FoqrAiQ6m03IgaA==
 
-value-or-function@^3.0.0:
-  version "3.0.0"
-  resolved "https://registry.yarnpkg.com/value-or-function/-/value-or-function-3.0.0.tgz#1c243a50b595c1be54a754bfece8563b9ff8d813"
-  integrity sha1-HCQ6ULWVwb5Up1S/7OhWO5/42BM=
-
 vendors@^1.0.0:
   version "1.0.4"
   resolved "https://registry.yarnpkg.com/vendors/-/vendors-1.0.4.tgz#e2b800a53e7a29b93506c3cf41100d16c4c4ad8e"
@@ -12970,54 +12843,6 @@ vfile@^4.0.0:
     unist-util-stringify-position "^2.0.0"
     vfile-message "^2.0.0"
 
-vinyl-fs@^3.0.3:
-  version "3.0.3"
-  resolved "https://registry.yarnpkg.com/vinyl-fs/-/vinyl-fs-3.0.3.tgz#c85849405f67428feabbbd5c5dbdd64f47d31bc7"
-  integrity sha512-vIu34EkyNyJxmP0jscNzWBSygh7VWhqun6RmqVfXePrOwi9lhvRs//dOaGOTRUQr4tx7/zd26Tk5WeSVZitgng==
-  dependencies:
-    fs-mkdirp-stream "^1.0.0"
-    glob-stream "^6.1.0"
-    graceful-fs "^4.0.0"
-    is-valid-glob "^1.0.0"
-    lazystream "^1.0.0"
-    lead "^1.0.0"
-    object.assign "^4.0.4"
-    pumpify "^1.3.5"
-    readable-stream "^2.3.3"
-    remove-bom-buffer "^3.0.0"
-    remove-bom-stream "^1.2.0"
-    resolve-options "^1.1.0"
-    through2 "^2.0.0"
-    to-through "^2.0.0"
-    value-or-function "^3.0.0"
-    vinyl "^2.0.0"
-    vinyl-sourcemap "^1.1.0"
-
-vinyl-sourcemap@^1.1.0:
-  version "1.1.0"
-  resolved "https://registry.yarnpkg.com/vinyl-sourcemap/-/vinyl-sourcemap-1.1.0.tgz#92a800593a38703a8cdb11d8b300ad4be63b3e16"
-  integrity sha1-kqgAWTo4cDqM2xHYswCtS+Y7PhY=
-  dependencies:
-    append-buffer "^1.0.2"
-    convert-source-map "^1.5.0"
-    graceful-fs "^4.1.6"
-    normalize-path "^2.1.1"
-    now-and-later "^2.0.0"
-    remove-bom-buffer "^3.0.0"
-    vinyl "^2.0.0"
-
-vinyl@^2.0.0:
-  version "2.2.0"
-  resolved "https://registry.yarnpkg.com/vinyl/-/vinyl-2.2.0.tgz#d85b07da96e458d25b2ffe19fece9f2caa13ed86"
-  integrity sha512-MBH+yP0kC/GQ5GwBqrTPTzEfiiLjta7hTtvQtbxBgTeSXsmKQRQecjibMbxIXzVT3Y9KJK+drOz1/k+vsu8Nkg==
-  dependencies:
-    clone "^2.1.1"
-    clone-buffer "^1.0.0"
-    clone-stats "^1.0.0"
-    cloneable-readable "^1.0.0"
-    remove-trailing-separator "^1.0.1"
-    replace-ext "^1.0.0"
-
 vm-browserify@^1.0.1:
   version "1.1.2"
   resolved "https://registry.yarnpkg.com/vm-browserify/-/vm-browserify-1.1.2.tgz#78641c488b8e6ca91a75f511e7a3b32a86e5dda0"
@@ -13307,12 +13132,17 @@ x-is-string@^0.1.0:
   resolved "https://registry.yarnpkg.com/x-is-string/-/x-is-string-0.1.0.tgz#474b50865af3a49a9c4657f05acd145458f77d82"
   integrity sha1-R0tQhlrzpJqcRlfwWs0UVFj3fYI=
 
+xmlcreate@^2.0.3:
+  version "2.0.3"
+  resolved "https://registry.yarnpkg.com/xmlcreate/-/xmlcreate-2.0.3.tgz#df9ecd518fd3890ab3548e1b811d040614993497"
+  integrity sha512-HgS+X6zAztGa9zIK3Y3LXuJes33Lz9x+YyTxgrkIdabu2vqcGOWwdfCpf1hWLRrd553wd4QCDf6BBO6FfdsRiQ==
+
 xmlhttprequest-ssl@~1.5.4:
   version "1.5.5"
   resolved "https://registry.yarnpkg.com/xmlhttprequest-ssl/-/xmlhttprequest-ssl-1.5.5.tgz#c2876b06168aadc40e57d97e81191ac8f4398b3e"
   integrity sha1-wodrBhaKrcQOV9l+gRkayPQ5iz4=
 
-xtend@^4.0.0, xtend@^4.0.1, xtend@~4.0.0, xtend@~4.0.1:
+xtend@^4.0.0, xtend@^4.0.1, xtend@~4.0.1:
   version "4.0.2"
   resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.2.tgz#bb72779f5fa465186b1f438f674fa347fdb5db54"
   integrity sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==