Bläddra i källkod

Merge branch 'master' into t/ckeditor5-theme-lark/107

Aleksander Nowodzinski 8 år sedan
förälder
incheckning
edf9076a5e
34 ändrade filer med 1327 tillägg och 181 borttagningar
  1. 101 0
      packages/ckeditor5-engine/CHANGELOG.md
  2. 12 12
      packages/ckeditor5-engine/package.json
  3. 29 0
      packages/ckeditor5-engine/src/controller/datacontroller.js
  4. 15 10
      packages/ckeditor5-engine/src/controller/deletecontent.js
  5. 45 22
      packages/ckeditor5-engine/src/controller/insertcontent.js
  6. 11 27
      packages/ckeditor5-engine/src/conversion/buildmodelconverter.js
  7. 7 3
      packages/ckeditor5-engine/src/conversion/model-selection-to-view-converters.js
  8. 91 28
      packages/ckeditor5-engine/src/conversion/model-to-view-converters.js
  9. 44 8
      packages/ckeditor5-engine/src/conversion/modelconversiondispatcher.js
  10. 20 2
      packages/ckeditor5-engine/src/model/delta/basic-transformations.js
  11. 1 4
      packages/ckeditor5-engine/src/model/document.js
  12. 5 10
      packages/ckeditor5-engine/src/model/operation/attributeoperation.js
  13. 5 1
      packages/ckeditor5-engine/src/model/operation/nooperation.js
  14. 3 7
      packages/ckeditor5-engine/src/model/operation/renameoperation.js
  15. 12 1
      packages/ckeditor5-engine/src/model/range.js
  16. 43 2
      packages/ckeditor5-engine/src/model/schema.js
  17. 84 0
      packages/ckeditor5-engine/tests/controller/datacontroller.js
  18. 46 7
      packages/ckeditor5-engine/tests/controller/deletecontent.js
  19. 77 1
      packages/ckeditor5-engine/tests/controller/insertcontent.js
  20. 27 3
      packages/ckeditor5-engine/tests/conversion/model-selection-to-view-converters.js
  21. 72 8
      packages/ckeditor5-engine/tests/conversion/model-to-view-converters.js
  22. 148 0
      packages/ckeditor5-engine/tests/conversion/modelconversiondispatcher.js
  23. 28 0
      packages/ckeditor5-engine/tests/manual/tickets/1088/1.html
  24. 32 0
      packages/ckeditor5-engine/tests/manual/tickets/1088/1.js
  25. 31 0
      packages/ckeditor5-engine/tests/manual/tickets/1088/1.md
  26. BIN
      packages/ckeditor5-engine/tests/manual/tickets/1088/sample.jpg
  27. 25 0
      packages/ckeditor5-engine/tests/model/delta/transform/insertdelta.js
  28. 57 0
      packages/ckeditor5-engine/tests/model/delta/transform/mergedelta.js
  29. 27 0
      packages/ckeditor5-engine/tests/model/delta/transform/movedelta.js
  30. 0 16
      packages/ckeditor5-engine/tests/model/operation/attributeoperation.js
  31. 5 1
      packages/ckeditor5-engine/tests/model/operation/nooperation.js
  32. 0 7
      packages/ckeditor5-engine/tests/model/operation/renameoperation.js
  33. 45 0
      packages/ckeditor5-engine/tests/model/range.js
  34. 179 1
      packages/ckeditor5-engine/tests/model/schema/schema.js

+ 101 - 0
packages/ckeditor5-engine/CHANGELOG.md

@@ -1,6 +1,107 @@
 Changelog
 =========
 
+## [0.11.0](https://github.com/ckeditor/ckeditor5-engine/compare/v0.10.0...v0.11.0) (2017-09-03)
+
+### Bug fixes
+
+* [Firefox] Prevented setting incorrect initial selection when placeholder was clicked. See ckeditor/ckeditor5#469. ([34498a8](https://github.com/ckeditor/ckeditor5-engine/commit/34498a8))
+* `AttributeElement`s created by selection conversion were not merged with `AttributeElement`s created by markers conversion. Closes [#1117](https://github.com/ckeditor/ckeditor5-engine/issues/1117). ([e6c5bcf](https://github.com/ckeditor/ckeditor5-engine/commit/e6c5bcf))
+* `DataController#insertContent()` and `DataController#deleteContent()` should strip disallowed attributes from text nodes. Closes [#1088](https://github.com/ckeditor/ckeditor5-engine/issues/1088). ([df83343](https://github.com/ckeditor/ckeditor5-engine/commit/df83343))
+* `DomConverter` should actively prevent unwanted scrolling on focus. Closes [#951](https://github.com/ckeditor/ckeditor5-engine/issues/951). Closes [#707](https://github.com/ckeditor/ckeditor5-engine/issues/707). Closes [#706](https://github.com/ckeditor/ckeditor5-engine/issues/706). ([cb18a95](https://github.com/ckeditor/ckeditor5-engine/commit/cb18a95))
+* `LiveSelection` will correctly set its properties in case of a non-collapsed default range. This will fix loading data which starts with an object element. Closes [#699](https://github.com/ckeditor/ckeditor5-engine/issues/699). ([e6e92e9](https://github.com/ckeditor/ckeditor5-engine/commit/e6e92e9))
+* `LiveSelection` will not read attributes from object element's children. Closes [#986](https://github.com/ckeditor/ckeditor5-engine/issues/986). ([93639d0](https://github.com/ckeditor/ckeditor5-engine/commit/93639d0))
+* `MarkerDelta` transformation should no longer cause editor to crash, if a `MarkerOperation` had `null` as it's `oldRange` or `newRange`. Closes [#943](https://github.com/ckeditor/ckeditor5-engine/issues/943). ([d328811](https://github.com/ckeditor/ckeditor5-engine/commit/d328811))
+* `model.Element#getNodeByPath()` and `model.DocumentFragment#getNodeByPath()` should work with offsets not indexes (because path is an array of offsets). Closes [#1009](https://github.com/ckeditor/ckeditor5-engine/issues/1009). ([331d2f4](https://github.com/ckeditor/ckeditor5-engine/commit/331d2f4))
+* `Schema.checkAttributeInSelection` should use element's existing attributes when querying schema. Closes [#1110](https://github.com/ckeditor/ckeditor5-engine/issues/1110). ([25ef1a8](https://github.com/ckeditor/ckeditor5-engine/commit/25ef1a8))
+* `view.Range#getTrimmed()` was returning incorrect ranges in some cases. Fixes [#1058](https://github.com/ckeditor/ckeditor5-engine/issues/1058). ([d99c568](https://github.com/ckeditor/ckeditor5-engine/commit/d99c568))
+* `AttributeElement` with bogus `<br />` will now be placed after all UI elements which will fix how those elements are rendered. Closes [#1072](https://github.com/ckeditor/ckeditor5-engine/issues/1072). ([43b6ea9](https://github.com/ckeditor/ckeditor5-engine/commit/43b6ea9))
+* Editor will no longer crash when `ReinsertOperation` is transformed by a specific `RemoveOperation`. Closes [#946](https://github.com/ckeditor/ckeditor5-engine/issues/946). ([6875eff](https://github.com/ckeditor/ckeditor5-engine/commit/6875eff))
+* Fixed a bug when `SplitDelta` transformation might cause undo to throw an error in some cases. Closes [#1084](https://github.com/ckeditor/ckeditor5-engine/issues/1084). ([cb9d409](https://github.com/ckeditor/ckeditor5-engine/commit/cb9d409))
+* Fixed incorrect markers transformations and conversions. Closes [#1112](https://github.com/ckeditor/ckeditor5-engine/issues/1112). Closes [#1080](https://github.com/ckeditor/ckeditor5-engine/issues/1080). Closes [#1079](https://github.com/ckeditor/ckeditor5-engine/issues/1079). ([b71adfb](https://github.com/ckeditor/ckeditor5-engine/commit/b71adfb))
+* Multiple spaces in an empty paragraph are now allowed. Closes ckeditor/ckeditor5-typing[#101](https://github.com/ckeditor/ckeditor5-engine/issues/101). ([9ca61d5](https://github.com/ckeditor/ckeditor5-engine/commit/9ca61d5))
+* Mutation observer will ignore children mutations if as a result of several native mutations the element's children haven't changed. Closes [#1031](https://github.com/ckeditor/ckeditor5-engine/issues/1031). ([552198e](https://github.com/ckeditor/ckeditor5-engine/commit/552198e))
+* None of the editable's ancestors should scroll when the `DomConverter` focuses an editable. Closes [#957](https://github.com/ckeditor/ckeditor5-engine/issues/957). ([e3bc4d1](https://github.com/ckeditor/ckeditor5-engine/commit/e3bc4d1))
+* Placeholder text now will not be hidden if the element has only ui elements. Closes [#1018](https://github.com/ckeditor/ckeditor5-engine/issues/1018). ([299628b](https://github.com/ckeditor/ckeditor5-engine/commit/299628b))
+* Prevent unbinding elements that are reused during rendering. Closes [#922](https://github.com/ckeditor/ckeditor5-engine/issues/922). ([88fcdcb](https://github.com/ckeditor/ckeditor5-engine/commit/88fcdcb))
+* Prevented editor throwing during `SplitDelta` x `RemoveDelta` transformation when SplitDelta's first operation was neither InsertOperation nor ReinsertOperation. Closes [#1065](https://github.com/ckeditor/ckeditor5-engine/issues/1065). ([85e38e1](https://github.com/ckeditor/ckeditor5-engine/commit/85e38e1))
+
+* Fixed remove model-to-view converter for some edge cases. Closes [#1068](https://github.com/ckeditor/ckeditor5-engine/issues/1068).
+* Singular white spaces (new lines, tabs and carriage returns) will be ignored when loading data when used outside/between block elements. Closes [#822](https://github.com/ckeditor/ckeditor5-engine/issues/822). ([4c9a0af](https://github.com/ckeditor/ckeditor5-engine/commit/4c9a0af))
+
+  Also, the range of characters which are being normalized during DOM to view conversion was reduced to `[ \n\t\r]` to avoid losing space characters (which matches `/\s/`) that could be significant.
+* Splitting paragraph twice in the same position will now be undoable. Also fixed SplitDelta x SplitDelta transformation. Closes [#1096](https://github.com/ckeditor/ckeditor5-engine/issues/1096). Closes [#1097](https://github.com/ckeditor/ckeditor5-engine/issues/1097). ([b7cc243](https://github.com/ckeditor/ckeditor5-engine/commit/b7cc243))
+* Writer will create a consistent hierarchy for attribute elements with same priorities. Introduced viewElement.getIdentity() method. Closes [#1060](https://github.com/ckeditor/ckeditor5-engine/issues/1060). ([85c96ef](https://github.com/ckeditor/ckeditor5-engine/commit/85c96ef))
+* Selection attributes should be cleared in an `enqueueChanges()` block. Fixed also a bug concerning `AttributeDelta` x `SplitDelta` transformation. Closes [#1055](https://github.com/ckeditor/ckeditor5-engine/issues/1055). ([ed1b7e7](https://github.com/ckeditor/ckeditor5-engine/commit/ed1b7e7))
+* Fixed a bug when additional list item has been created when undoing applying block quote to a list followed by splitting list item in that list. Closes [#1053](https://github.com/ckeditor/ckeditor5-engine/issues/1053). ([a6c6167](https://github.com/ckeditor/ckeditor5-engine/commit/a6c6167))
+* Fixed a bug when renaming followed by merge or split resulted in multiple elements being incorrectly renamed during undo. Closes [#1051](https://github.com/ckeditor/ckeditor5-engine/issues/#1051). ([033e850](https://github.com/ckeditor/ckeditor5-engine/commit/033e850))
+* If a new position of `DocumentSelection` cannot be calculated after the content in which the selection was located was removed from the document, the position of the selection should use the "default selection" so it does not end up in disallowed places. Closes [#1046](https://github.com/ckeditor/ckeditor5-engine/issues/#1046). ([9f7e0a2](https://github.com/ckeditor/ckeditor5-engine/commit/9f7e0a2))
+* Block filler was rendered before UI elements, interfering with their positioning. Now it will be properly rendered at the end of an element. Closes [#1021](https://github.com/ckeditor/ckeditor5-engine/issues/#1021). ([7c014f7](https://github.com/ckeditor/ckeditor5-engine/commit/7c014f7))
+* Live ranges and markers, that are at the end of an element, are now correctly transformed when they are split. Closes [#1006](https://github.com/ckeditor/ckeditor5-engine/issues/#1006). ([690f32c](https://github.com/ckeditor/ckeditor5-engine/commit/690f32c))
+
+### Features
+
+* `DataController#deleteContent()` will leave a paragraph if the entire content was selected. Closes [#1012](https://github.com/ckeditor/ckeditor5-engine/issues/1012). ([17e70c3](https://github.com/ckeditor/ckeditor5-engine/commit/17e70c3))
+
+  On the occasion `$root` element has been marked as a limit element in `Schema` in order to simplify the checks.
+* `model.LiveRange#event:change` got renamed to `change:range`. Introduced `model.LiveRange#event:change:content`. Closes [#1089](https://github.com/ckeditor/ckeditor5-engine/issues/1089). ([ec22a29](https://github.com/ckeditor/ckeditor5-engine/commit/ec22a29))
+* `model.LiveRange#event:change` now contains `data.batch` instance which changed the range. Closes [#1076](https://github.com/ckeditor/ckeditor5-engine/issues/1076). ([c6f5e9f](https://github.com/ckeditor/ckeditor5-engine/commit/c6f5e9f))
+* Enhanced `Selection#setTo()`, introduced `Selection#setIn()`, `Selection#setOn()`, `Range.createCollapsedAt()` and renamed few existing `Selection` methods for both model and view. Closes [#1074](https://github.com/ckeditor/ckeditor5-engine/issues/1074). ([070c313](https://github.com/ckeditor/ckeditor5-engine/commit/070c313))
+* Hide the caret when the editor is read-only. `EditingControler` is observable from now. Observable property `isReadOnly` was added to the `ViewDocument` and `EditingController`. Closes [#1024](https://github.com/ckeditor/ckeditor5-engine/issues/1024). Closes ckeditor/ckeditor5[#503](https://github.com/ckeditor/ckeditor5-engine/issues/503). ([e8fd17d](https://github.com/ckeditor/ckeditor5-engine/commit/e8fd17d))
+* Highlights on text nodes will be now unwrapped basing on descriptor id (which by default is marker name). Closes [#1108](https://github.com/ckeditor/ckeditor5-engine/issues/1108). ([885901f](https://github.com/ckeditor/ckeditor5-engine/commit/885901f))
+* Implemented `view.Document#scrollToTheSelection()` method. Closes [#660](https://github.com/ckeditor/ckeditor5-engine/issues/660). ([4479c40](https://github.com/ckeditor/ckeditor5-engine/commit/4479c40))
+* Introduced the highlights feature. ([af34f31](https://github.com/ckeditor/ckeditor5-engine/commit/af34f31))
+* Introduced `DataController#hasContent()`. Closes [#1114](https://github.com/ckeditor/ckeditor5-engine/issues/1114). ([712ccfc](https://github.com/ckeditor/ckeditor5-engine/commit/712ccfc))
+* Introduced `model.Node#getCommonAncestor()` and `view.Node#getCommonAncestor()`. Closes [#1033](https://github.com/ckeditor/ckeditor5-engine/issues/1033). ([f913aee](https://github.com/ckeditor/ckeditor5-engine/commit/f913aee))
+* Introduced `Position#getCommonAncestor( position )` and `Range#getCommonAncestor()` methods for the view and model. Closes [#1002](https://github.com/ckeditor/ckeditor5-engine/issues/1002). ([0e29844](https://github.com/ckeditor/ckeditor5-engine/commit/0e29844))
+* Introduced `Schema#getLimitElement()`. Closes [#1042](https://github.com/ckeditor/ckeditor5-engine/issues/1042). ([691e53e](https://github.com/ckeditor/ckeditor5-engine/commit/691e53e))
+* Introduced `view.Document#keyup` event (fired by the `KeyObserver`). Closes [#1026](https://github.com/ckeditor/ckeditor5-engine/issues/1026). ([cc766ab](https://github.com/ckeditor/ckeditor5-engine/commit/cc766ab))
+* Introduced the `Selection#isEntireContentSelected( element )` method. Closes [#1063](https://github.com/ckeditor/ckeditor5-engine/issues/1063). ([1902d7a](https://github.com/ckeditor/ckeditor5-engine/commit/1902d7a))
+* OT will use context information to achieve better conflict resolution. ([481eb9b](https://github.com/ckeditor/ckeditor5-engine/commit/481eb9b))
+
+  This change includes refactoring of: `History`, `RemoveOperation`, operational transformation algorithms, delta transformation algorithms and more.
+
+  Context information will be used instead of removing deltas from history, which caused bugs in more complicated scenarios. This mostly affects undo algorithms.
+* `UIElement` has its own render method used by DomConverter and can create DOM children. Improved integration with observers and other view elements. Closes [#799](https://github.com/ckeditor/ckeditor5-engine/issues/799). ([7fc52ea](https://github.com/ckeditor/ckeditor5-engine/commit/7fc52ea))
+* When engine debugging is on, additional logs will be provided when delta transformation causes editor to throw an error. ([2ae80ca](https://github.com/ckeditor/ckeditor5-engine/commit/2ae80ca))
+* When engine debugging is on, deltas that are results of transformation will keep their history of changes in `#history` property. Closes [#940](https://github.com/ckeditor/ckeditor5-engine/issues/940). ([7d8db49](https://github.com/ckeditor/ckeditor5-engine/commit/7d8db49))
+* Introduced two `Schema` helpers – `#checkAttributeInSelection()` and `#getValidRanges()`. Closes [#969](https://github.com/ckeditor/ckeditor5-engine/issues/969). ([34a7a06](https://github.com/ckeditor/ckeditor5-engine/commit/34a7a06))
+
+### Other changes
+
+* Changed the `merge` option of `DataController.deleteContent()` to `leaveUnmerged`. The default value stayed `false`, so the default behavior of the function was changed to merge blocks. Closes [#982](https://github.com/ckeditor/ckeditor5-engine/issues/982). ([56347d1](https://github.com/ckeditor/ckeditor5-engine/commit/56347d1))
+* From now, every operation execution will fire `model.Document#event:change`, even if the operation does nothing (for example, if operation changes attribute to the same value). Closes [#1099](https://github.com/ckeditor/ckeditor5-engine/issues/1099). ([6502bbb](https://github.com/ckeditor/ckeditor5-engine/commit/6502bbb))
+* Introduced `options.includeSelf` to `getCommonAncestor()`. Closes [#1036](https://github.com/ckeditor/ckeditor5-engine/issues/1036). ([4a4a89a](https://github.com/ckeditor/ckeditor5-engine/commit/4a4a89a))
+* The `Selection#getSelectedBlocks()` method will not return a block in which selection ends if no content of that block is selected. Closes [#984](https://github.com/ckeditor/ckeditor5-engine/issues/984). ([d3e7afa](https://github.com/ckeditor/ckeditor5-engine/commit/d3e7afa))
+
+  For example, in the following case only the first two paragraphs will be returned:
+
+  ```html
+  <paragraph>[Foo</paragraph>
+  <paragraph>Bar</paragraph>
+  <paragraph>]Baz</paragraph>
+  ```
+
+  The reasoning behind this change is that the user doesn't consider the last block as selected in such a case (as its selection isn't even visible).
+
+### BREAKING CHANGES
+
+* `ModelConverterBuilder#toStamp()` functionality is renamed to `ModelConverterBuilder#toElement()`. Introduced `ModelConverterBuilder#toVirtualSelection()` which replaces current marker to element conversion.
+* The `DataController#deleteContent()` option was renamed from `merge` to `leaveUnmerged` and the default behavior of the function was changed to merge blocks.
+* Removed `wrapRange()` and `unwrapRange()` functions from `model-to-view-converters.js` as they're no longer used.
+* Renamed marker stamps to marker elements in code and docs.
+* Renamed `Selection#collapse()` to `Selection#setCollapsedAt()`.
+* Renamed `Selection#setFocus()` to `Selection#moveFocusTo()`.
+* The `includeNode` option of `Node#getAncestors()` methods (model and view) was renamed to `includeSelf`. See [#1036](https://github.com/ckeditor/ckeditor5-engine/issues/1036).
+* Parameter change for `convertSelectionMarker()` function from `model-selection-to-view-converters.js`.
+* `deltaTransform#transformDeltaSets()` is now an internal method. Use `Document#transformDeltas()` instead.
+* Removed `Renderer#getCorrespondingDom()` and `Renderer#getCorrespondingView()` methods.
+* Renamed `Renderer#getCorrespondingDomText()` method to  `Renderer#findCorrespondingDomText()` and `Renderer#getCorrespondingViewText()` to  `Renderer#findCorrespondingViewText()`.
+* Merged `Renderer#getCorrespondingDomElement()` and `Renderer#getCorrespondingDomDocumentFragment()` into one method `Renderer#mapViewToDom()`.
+* Merged `Renderer#getCorrespondingViewElement()` and `Renderer#getCorrespondingViewDocumentFragment()` into `Renderer#mapDomToView()`.
+* `History` API for deleting undone deltas has been removed.
+
+
 ## [0.10.0](https://github.com/ckeditor/ckeditor5-engine/compare/v0.9.0...v0.10.0) (2017-05-07)
 
 ### Bug fixes

+ 12 - 12
packages/ckeditor5-engine/package.json

@@ -1,25 +1,25 @@
 {
   "name": "@ckeditor/ckeditor5-engine",
-  "version": "0.10.0",
+  "version": "0.11.0",
   "description": "CKEditor 5 Editing Engine",
   "keywords": [
     "CKEditor"
   ],
   "dependencies": {
-    "@ckeditor/ckeditor5-utils": "^0.9.1"
+    "@ckeditor/ckeditor5-utils": "^0.10.0"
   },
   "devDependencies": {
     "@ckeditor/ckeditor5-dev-lint": "^3.1.0",
-    "@ckeditor/ckeditor5-basic-styles": "^0.8.1",
-    "@ckeditor/ckeditor5-core": "^0.8.1",
-    "@ckeditor/ckeditor5-editor-classic": "^0.7.3",
-    "@ckeditor/ckeditor5-enter": "^0.9.1",
-    "@ckeditor/ckeditor5-heading": "^0.9.1",
-    "@ckeditor/ckeditor5-list": "^0.6.1",
-    "@ckeditor/ckeditor5-paragraph": "^0.8.0",
-    "@ckeditor/ckeditor5-typing": "^0.9.1",
-    "@ckeditor/ckeditor5-undo": "^0.8.1",
-    "@ckeditor/ckeditor5-widget": "^0.1.1",
+    "@ckeditor/ckeditor5-basic-styles": "^0.9.0",
+    "@ckeditor/ckeditor5-core": "^0.9.0",
+    "@ckeditor/ckeditor5-editor-classic": "^0.8.0",
+    "@ckeditor/ckeditor5-enter": "^0.10.0",
+    "@ckeditor/ckeditor5-heading": "^0.10.0",
+    "@ckeditor/ckeditor5-list": "^0.7.0",
+    "@ckeditor/ckeditor5-paragraph": "^0.9.0",
+    "@ckeditor/ckeditor5-typing": "^0.10.0",
+    "@ckeditor/ckeditor5-undo": "^0.9.0",
+    "@ckeditor/ckeditor5-widget": "^0.2.0",
     "eslint-config-ckeditor5": "^1.0.5",
     "gulp": "^3.9.1",
     "guppy-pre-commit": "^0.4.0"

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

@@ -22,6 +22,7 @@ import ViewDocumentFragment from '../view/documentfragment';
 
 import ModelRange from '../model/range';
 import ModelPosition from '../model/position';
+import ModelElement from '../model/element';
 
 import insertContent from './insertcontent';
 import deleteContent from './deletecontent';
@@ -297,6 +298,34 @@ export default class DataController {
 	getSelectedContent( selection ) {
 		return getSelectedContent( selection );
 	}
+
+	/**
+	 * Checks whether given {@link module:engine/model/range~Range range} or {@link module:engine/model/element~Element element}
+	 * has any content.
+	 *
+	 * Content is any text node or element which is registered in {@link module:engine/model/schema~Schema schema}.
+	 *
+	 * @param {module:engine/model/range~Range|module:engine/model/element~Element} rangeOrElement Range or element to check.
+	 * @returns {Boolean}
+	 */
+	hasContent( rangeOrElement ) {
+		if ( rangeOrElement instanceof ModelElement ) {
+			rangeOrElement = ModelRange.createIn( rangeOrElement );
+		}
+
+		if ( rangeOrElement.isCollapsed ) {
+			return false;
+		}
+
+		for ( const item of rangeOrElement.getItems() ) {
+			// Remember, `TreeWalker` returns always `textProxy` nodes.
+			if ( item.is( 'textProxy' ) || this.model.schema.objects.has( item.name ) ) {
+				return true;
+			}
+		}
+
+		return false;
+	}
 }
 
 mix( DataController, ObservableMixin );

+ 15 - 10
packages/ckeditor5-engine/src/controller/deletecontent.js

@@ -41,9 +41,11 @@ export default function deleteContent( selection, batch, options = {} ) {
 		return;
 	}
 
+	const schema = batch.document.schema;
+
 	// 1. Replace the entire content with paragraph.
 	// See: https://github.com/ckeditor/ckeditor5-engine/issues/1012#issuecomment-315017594.
-	if ( !options.doNotResetEntireContent && shouldEntireContentBeReplacedWithParagraph( batch.document.schema, selection ) ) {
+	if ( !options.doNotResetEntireContent && shouldEntireContentBeReplacedWithParagraph( schema, selection ) ) {
 		replaceEntireContentWithParagraph( batch, selection );
 
 		return;
@@ -68,13 +70,20 @@ export default function deleteContent( selection, batch, options = {} ) {
 	// want to override that behavior anyway.
 	if ( !options.leaveUnmerged ) {
 		mergeBranches( batch, startPos, endPos );
+
+		// We need to check and strip disallowed attributes in all nested nodes because after merge
+		// some attributes could end up in a path where are disallowed.
+		//
+		// e.g. bold is disallowed for <H1>
+		// <h1>Fo{o</h1><p>b}a<b>r</b><p> -> <h1>Fo{}a<b>r</b><h1> -> <h1>Fo{}ar<h1>.
+		schema.removeDisallowedAttributes( startPos.parent.getChildren(), startPos, batch );
 	}
 
 	selection.setCollapsedAt( startPos );
 
 	// 4. Autoparagraphing.
 	// Check if a text is allowed in the new container. If not, try to create a new paragraph (if it's allowed here).
-	if ( shouldAutoparagraph( batch.document, startPos ) ) {
+	if ( shouldAutoparagraph( schema, startPos ) ) {
 		insertParagraph( batch, startPos, selection );
 	}
 
@@ -151,9 +160,9 @@ function mergeBranches( batch, startPos, endPos ) {
 	mergeBranches( batch, startPos, endPos );
 }
 
-function shouldAutoparagraph( doc, position ) {
-	const isTextAllowed = doc.schema.check( { name: '$text', inside: position } );
-	const isParagraphAllowed = doc.schema.check( { name: 'paragraph', inside: position } );
+function shouldAutoparagraph( schema, position ) {
+	const isTextAllowed = schema.check( { name: '$text', inside: position } );
+	const isParagraphAllowed = schema.check( { name: 'paragraph', inside: position } );
 
 	return !isTextAllowed && isParagraphAllowed;
 }
@@ -208,9 +217,5 @@ function shouldEntireContentBeReplacedWithParagraph( schema, selection ) {
 		return false;
 	}
 
-	if ( !schema.check( { name: 'paragraph', inside: limitElement.name } ) ) {
-		return false;
-	}
-
-	return true;
+	return schema.check( { name: 'paragraph', inside: limitElement.name } );
 }

+ 45 - 22
packages/ckeditor5-engine/src/controller/insertcontent.js

@@ -222,11 +222,17 @@ class Insertion {
 	 * @param {Object} context
 	 */
 	_handleDisallowedNode( node, context ) {
-		// Try inserting its children (strip the parent).
+		// If the node is an element, try inserting its children (strip the parent).
 		if ( node.is( 'element' ) ) {
 			this.handleNodes( node.getChildren(), context );
 		}
-		// Try autoparagraphing.
+		// If the node is a text and bare text is allowed in current position it means that the node
+		// contains disallowed attributes and we have to remove them.
+		else if ( this.schema.check( { name: '$text', inside: this.position } ) ) {
+			this.schema.removeDisallowedAttributes( [ node ], this.position );
+			this._handleNode( node, context );
+		}
+		// If text is not allowed, try autoparagraphing.
 		else {
 			this._tryAutoparagraphing( node, context );
 		}
@@ -237,7 +243,7 @@ class Insertion {
 	 */
 	_insert( node ) {
 		/* istanbul ignore if */
-		if ( !this._checkIsAllowed( node, [ this.position.parent ] ) ) {
+		if ( !this._checkIsAllowed( node, this.position ) ) {
 			// Algorithm's correctness check. We should never end up here but it's good to know that we did.
 			// Note that it would often be a silent issue if we insert node in a place where it's not allowed.
 			log.error(
@@ -256,7 +262,7 @@ class Insertion {
 		livePos.detach();
 
 		// The last inserted object should be selected because we can't put a collapsed selection after it.
-		if ( this._checkIsObject( node ) && !this.schema.check( { name: '$text', inside: [ this.position.parent ] } ) ) {
+		if ( this._checkIsObject( node ) && !this.schema.check( { name: '$text', inside: this.position } ) ) {
 			this.nodeToSelect = node;
 		} else {
 			this.nodeToSelect = null;
@@ -282,6 +288,11 @@ class Insertion {
 
 			this.batch.merge( mergePosLeft );
 
+			// We need to check and strip disallowed attributes in all nested nodes because after merge
+			// some attributes could end up in a path where are disallowed.
+			const parent = position.nodeBefore;
+			this.schema.removeDisallowedAttributes( parent.getChildren(), Position.createAt( parent ), this.batch );
+
 			this.position = Position.createFromPosition( position );
 			position.detach();
 		}
@@ -305,12 +316,22 @@ class Insertion {
 
 			this.batch.merge( mergePosRight );
 
+			// We need to check and strip disallowed attributes in all nested nodes because after merge
+			// some attributes could end up in a place where are disallowed.
+			this.schema.removeDisallowedAttributes( position.parent.getChildren(), position, this.batch );
+
 			this.position = Position.createFromPosition( position );
 			position.detach();
 		}
 
 		mergePosLeft.detach();
 		mergePosRight.detach();
+
+		// When there was no merge we need to check and strip disallowed attributes in all nested nodes of
+		// just inserted node because some attributes could end up in a place where are disallowed.
+		if ( !mergeLeft && !mergeRight ) {
+			this.schema.removeDisallowedAttributes( node.getChildren(), Position.createAt( node ), this.batch );
+		}
 	}
 
 	/**
@@ -325,10 +346,17 @@ class Insertion {
 		// Do not autoparagraph if the paragraph won't be allowed there,
 		// cause that would lead to an infinite loop. The paragraph would be rejected in
 		// the next _handleNode() call and we'd be here again.
-		if ( this._getAllowedIn( paragraph, this.position.parent ) && this._checkIsAllowed( node, [ paragraph ] ) ) {
-			paragraph.appendChildren( node );
+		if ( this._getAllowedIn( paragraph, this.position.parent ) ) {
+			// When node is a text and is disallowed by schema it means that contains disallowed attributes
+			// and we need to remove them.
+			if ( node.is( 'text' ) && !this._checkIsAllowed( node, [ paragraph ] ) ) {
+				this.schema.removeDisallowedAttributes( [ node ], [ paragraph ] );
+			}
 
-			this._handleNode( paragraph, context );
+			if ( this._checkIsAllowed( node, [ paragraph ] ) ) {
+				paragraph.appendChildren( node );
+				this._handleNode( paragraph, context );
+			}
 		}
 	}
 
@@ -402,31 +430,26 @@ class Insertion {
 	 */
 	_checkIsAllowed( node, path ) {
 		return this.schema.check( {
-			name: this._getNodeSchemaName( node ),
+			name: getNodeSchemaName( node ),
 			attributes: Array.from( node.getAttributeKeys() ),
 			inside: path
 		} );
 	}
 
 	/**
-	 * Checks wether according to the schema this is an object type element.
+	 * Checks whether according to the schema this is an object type element.
 	 *
 	 * @param {module:engine/model/node~Node} node The node to check.
 	 */
 	_checkIsObject( node ) {
-		return this.schema.objects.has( this._getNodeSchemaName( node ) );
+		return this.schema.objects.has( getNodeSchemaName( node ) );
 	}
+}
 
-	/**
-	 * Gets a name under which we should check this node in the schema.
-	 *
-	 * @param {module:engine/model/node~Node} node The node.
-	 */
-	_getNodeSchemaName( node ) {
-		if ( node.is( 'text' ) ) {
-			return '$text';
-		}
-
-		return node.name;
-	}
+// Gets a name under which we should check this node in the schema.
+//
+// @param {module:engine/model/node~Node} node The node.
+// @returns {String} Node name.
+function getNodeSchemaName( node ) {
+	return node.is( 'text' ) ? '$text' : node.name;
 }

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

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

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

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

+ 91 - 28
packages/ckeditor5-engine/src/conversion/model-to-view-converters.js

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

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

@@ -217,15 +217,11 @@ export default class ModelConversionDispatcher {
 
 		for ( const marker of this._modelDocument.markers ) {
 			const markerRange = marker.getRange();
+			const intersection = markerRange.getIntersection( range );
 
 			// Check if inserted content is inserted into a marker.
-			if ( markerRange.containsPosition( range.start ) ) {
-				this.convertMarker( 'addMarker', marker.name, markerRange.getIntersection( range ) );
-			}
-
-			// Check if inserted content contains a marker.
-			if ( range.containsRange( markerRange, true ) ) {
-				this.convertMarker( 'addMarker', marker.name, markerRange );
+			if ( intersection && shouldMarkerChangeBeConverted( range.start, marker, this.conversionApi.mapper ) ) {
+				this.convertMarker( 'addMarker', marker.name, intersection );
 			}
 		}
 	}
@@ -293,6 +289,11 @@ export default class ModelConversionDispatcher {
 	 * @param {*} newValue New attribute value or `null` if attribute has been removed.
 	 */
 	convertAttribute( type, range, key, oldValue, newValue ) {
+		if ( oldValue == newValue ) {
+			// Do not convert if the attribute did not change.
+			return;
+		}
+
 		// Create a list with attributes to consume.
 		const consumable = this._createConsumableForRange( range, type + ':' + key );
 
@@ -323,6 +324,11 @@ export default class ModelConversionDispatcher {
 	 * @param {String} oldName Name of the renamed element before it was renamed.
 	 */
 	convertRename( element, oldName ) {
+		if ( element.name == oldName ) {
+			// Do not convert if the name did not change.
+			return;
+		}
+
 		// Create fake element that will be used to fire remove event. The fake element will have the old element name.
 		const fakeElement = element.clone( true );
 		fakeElement.name = oldName;
@@ -357,10 +363,16 @@ export default class ModelConversionDispatcher {
 		this.fire( 'selection', { selection }, consumable, this.conversionApi );
 
 		for ( const marker of markers ) {
+			const markerRange = marker.getRange();
+
+			if ( !shouldMarkerChangeBeConverted( selection.getFirstPosition(), marker, this.conversionApi.mapper ) ) {
+				continue;
+			}
+
 			const data = {
 				selection,
 				markerName: marker.name,
-				markerRange: marker.getRange()
+				markerRange
 			};
 
 			if ( consumable.test( selection, 'selectionMarker:' + marker.name ) ) {
@@ -707,3 +719,27 @@ export default class ModelConversionDispatcher {
 }
 
 mix( ModelConversionDispatcher, EmitterMixin );
+
+// Helper function, checks whether change of `marker` at `modelPosition` should be converted. Marker changes are not
+// converted if they happen inside an element with custom conversion method.
+//
+// @param {module:engine/model/position~Position} modelPosition
+// @param {module:engine/model/markercollection~Marker} marker
+// @param {module:engine/conversion/mapper~Mapper} mapper
+// @returns {Boolean}
+function shouldMarkerChangeBeConverted( modelPosition, marker, mapper ) {
+	const range = marker.getRange();
+	const ancestors = Array.from( modelPosition.getAncestors() );
+	ancestors.shift(); // Remove root element. It cannot be passed to `model.Range#containsItem`.
+	ancestors.reverse();
+
+	const hasCustomHandling = ancestors.some( element => {
+		if ( range.containsItem( element ) ) {
+			const viewElement = mapper.toViewElement( element );
+
+			return !!viewElement.getCustomProperty( 'addHighlight' );
+		}
+	} );
+
+	return !hasCustomHandling;
+}

+ 20 - 2
packages/ckeditor5-engine/src/model/delta/basic-transformations.js

@@ -97,6 +97,11 @@ addTransformationCase( AttributeDelta, SplitDelta, ( a, b, context ) => {
 
 // Add special case for InsertDelta x MergeDelta transformation.
 addTransformationCase( InsertDelta, MergeDelta, ( a, b, context ) => {
+	// Do not apply special transformation case if `MergeDelta` has `NoOperation` as the second operation.
+	if ( !b.position ) {
+		return defaultTransform( a, b, context );
+	}
+
 	const undoMode = context.aWasUndone || context.bWasUndone;
 
 	// If insert is applied at the same position where merge happened, we reverse the merge (we treat it like it
@@ -136,10 +141,14 @@ addTransformationCase( MarkerDelta, RenameDelta, transformMarkerDelta );
 
 // Add special case for MoveDelta x MergeDelta transformation.
 addTransformationCase( MoveDelta, MergeDelta, ( a, b, context ) => {
+	// Do not apply special transformation case if `MergeDelta` has `NoOperation` as the second operation.
+	if ( !b.position ) {
+		return defaultTransform( a, b, context );
+	}
+
 	// If move delta is supposed to move a node that has been merged, we reverse the merge (we treat it like it
 	// didn't happen) and then apply the original move operation. This is "mirrored" in MergeDelta x MoveDelta
 	// transformation below, where we simply do not apply MergeDelta.
-
 	const operateInSameParent =
 		a.sourcePosition.root == b.position.root &&
 		compareArrays( a.sourcePosition.getParentPath(), b.position.getParentPath() ) === 'same';
@@ -158,6 +167,11 @@ addTransformationCase( MoveDelta, MergeDelta, ( a, b, context ) => {
 
 // Add special case for MergeDelta x InsertDelta transformation.
 addTransformationCase( MergeDelta, InsertDelta, ( a, b, context ) => {
+	// Do not apply special transformation case if `MergeDelta` has `NoOperation` as the second operation.
+	if ( !a.position ) {
+		return defaultTransform( a, b, context );
+	}
+
 	const undoMode = context.aWasUndone || context.bWasUndone;
 
 	// If merge is applied at the same position where we inserted a range of nodes we cancel the merge as it's results
@@ -171,9 +185,13 @@ addTransformationCase( MergeDelta, InsertDelta, ( a, b, context ) => {
 
 // Add special case for MergeDelta x MoveDelta transformation.
 addTransformationCase( MergeDelta, MoveDelta, ( a, b, context ) => {
+	// Do not apply special transformation case if `MergeDelta` has `NoOperation` as the second operation.
+	if ( !a.position ) {
+		return defaultTransform( a, b, context );
+	}
+
 	// If merge is applied at the position between moved nodes we cancel the merge as it's results may be unexpected and
 	// very weird. Even if we do some "magic" we don't know what really are users' expectations.
-
 	const operateInSameParent =
 		a.position.root == b.sourcePosition.root &&
 		compareArrays( a.position.getParentPath(), b.sourcePosition.getParentPath() ) === 'same';

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

@@ -168,10 +168,7 @@ export default class Document {
 
 		this.history.addDelta( operation.delta );
 
-		if ( changes ) {
-			// `NoOperation` returns no changes, do not fire event for it.
-			this.fire( 'change', operation.type, changes, operation.delta.batch, operation.delta.type );
-		}
+		this.fire( 'change', operation.type, changes, operation.delta.batch, operation.delta.type );
 	}
 
 	/**

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

@@ -141,18 +141,13 @@ export default class AttributeOperation extends Operation {
 					{ node: item, key: this.key }
 				);
 			}
-
-			// If value to set is same as old value, don't do anything.
-			// By returning `undefined`, this operation will be seen as `NoOperation` - that means
-			// that it won't generate any events, etc. `AttributeOperation` with such parameters may be
-			// a result of operational transformation.
-			if ( isEqual( this.oldValue, this.newValue ) ) {
-				return;
-			}
 		}
 
-		// Execution.
-		writer.setAttribute( this.range, this.key, this.newValue );
+		// If value to set is same as old value, don't do anything.
+		if ( !isEqual( this.oldValue, this.newValue ) ) {
+			// Execution.
+			writer.setAttribute( this.range, this.key, this.newValue );
+		}
 
 		return { range: this.range, key: this.key, oldValue: this.oldValue, newValue: this.newValue };
 	}

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

@@ -20,6 +20,10 @@ import Operation from './operation';
  * @extends module:engine/model/operation/operation~Operation
  */
 export default class NoOperation extends Operation {
+	get type() {
+		return 'noop';
+	}
+
 	/**
 	 * Creates and returns an operation that has the same parameters as this operation.
 	 *
@@ -42,7 +46,7 @@ export default class NoOperation extends Operation {
 	 * @inheritDoc
 	 */
 	_execute() {
-		// Do nothing.
+		return {};
 	}
 
 	/**

+ 3 - 7
packages/ckeditor5-engine/src/model/operation/renameoperation.js

@@ -106,15 +106,11 @@ export default class RenameOperation extends Operation {
 		}
 
 		// If value to set is same as old value, don't do anything.
-		// By not returning `undefined`, this operation will be seen as `NoOperation` - that means
-		// that it won't generate any events, etc. `RenameOperation` with such parameters may be
-		// a result of Operational Transformation.
-		if ( this.oldName == this.newName ) {
-			return;
+		if ( element.name != this.newName ) {
+			// Execution.
+			element.name = this.newName;
 		}
 
-		element.name = this.newName;
-
 		return { element, oldName: this.oldName };
 	}
 

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

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

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

@@ -9,11 +9,11 @@
 
 import Position from './position';
 import Element from './element';
+import Range from './range';
 import clone from '@ckeditor/ckeditor5-utils/src/lib/lodash/clone';
 import isArray from '@ckeditor/ckeditor5-utils/src/lib/lodash/isArray';
 import isString from '@ckeditor/ckeditor5-utils/src/lib/lodash/isString';
 import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
-import Range from './range';
 
 /**
  * Schema is a definition of the structure of the document. It allows to define which tree model items (element, text, etc.)
@@ -332,7 +332,11 @@ export default class Schema {
 					// If returned item does not have name property, it is a TextFragment.
 					const name = value.item.name || '$text';
 
-					if ( this.check( { name, inside: value.previousPosition, attributes: attribute } ) ) {
+					// Attribute should be checked together with existing attributes.
+					// See https://github.com/ckeditor/ckeditor5-engine/issues/1110.
+					const attributes = Array.from( value.item.getAttributeKeys() ).concat( attribute );
+
+					if ( this.check( { name, inside: value.previousPosition, attributes } ) ) {
 						// If we found a node that is allowed to have the attribute, return true.
 						return true;
 					}
@@ -411,6 +415,43 @@ export default class Schema {
 		return element;
 	}
 
+	/**
+	 * Removes disallowed by {@link module:engine/model/schema~Schema schema} attributes from given nodes.
+	 * When {@link module:engine/model/batch~Batch batch} parameter is provided then attributes will be removed
+	 * using that batch, by creating {@link module:engine/model/delta/attributedelta~AttributeDelta attribute deltas}.
+	 * Otherwise, attributes will be removed directly from provided nodes using {@link module:engine/model/node~Node node} API.
+	 *
+	 * @param {Iterable.<module:engine/model/node~Node>} nodes Nodes that will be filtered.
+	 * @param {module:engine/model/schema~SchemaPath} inside Path inside which schema will be checked.
+	 * @param {module:engine/model/batch~Batch} [batch] Batch to which the deltas will be added.
+	 */
+	removeDisallowedAttributes( nodes, inside, batch ) {
+		for ( const node of nodes ) {
+			const name = node.is( 'text' ) ? '$text' : node.name;
+			const attributes = Array.from( node.getAttributeKeys() );
+			const queryPath = Schema._normalizeQueryPath( inside );
+
+			// When node with attributes is not allowed in current position.
+			if ( !this.check( { name, attributes, inside: queryPath } ) ) {
+				// Let's remove attributes one by one.
+				// TODO: this should be improved to check all combination of attributes.
+				for ( const attribute of node.getAttributeKeys() ) {
+					if ( !this.check( { name, attributes: attribute, inside: queryPath } ) ) {
+						if ( batch ) {
+							batch.removeAttribute( node, attribute );
+						} else {
+							node.removeAttribute( attribute );
+						}
+					}
+				}
+			}
+
+			if ( node.is( 'element' ) ) {
+				this.removeDisallowedAttributes( node.getChildren(), queryPath.concat( node.name ), batch );
+			}
+		}
+	}
+
 	/**
 	 * Returns {@link module:engine/model/schema~SchemaItem schema item} that was registered in the schema under given name.
 	 * If item has not been found, throws error.

+ 84 - 0
packages/ckeditor5-engine/tests/controller/datacontroller.js

@@ -12,6 +12,7 @@ import buildModelConverter from '../../src/conversion/buildmodelconverter';
 
 import ModelDocumentFragment from '../../src/model/documentfragment';
 import ModelText from '../../src/model/text';
+import ModelRange from '../../src/model/range';
 import ModelSelection from '../../src/model/selection';
 
 import ViewDocumentFragment from '../../src/view/documentfragment';
@@ -439,4 +440,87 @@ describe( 'DataController', () => {
 			expect( stringify( content ) ).to.equal( 'ob' );
 		} );
 	} );
+
+	describe( 'hasContent', () => {
+		let root;
+
+		beforeEach( () => {
+			schema.registerItem( 'paragraph', '$block' );
+			schema.registerItem( 'div', '$block' );
+			schema.allow( { name: '$block', inside: 'div' } );
+			schema.registerItem( 'image' );
+			schema.allow( { name: 'image', inside: 'div' } );
+			schema.objects.add( 'image' );
+
+			setData(
+				modelDocument,
+
+				'<div>' +
+					'<paragraph></paragraph>' +
+				'</div>' +
+				'<paragraph>foo</paragraph>' +
+				'<div>' +
+					'<image></image>' +
+				'</div>'
+			);
+
+			root = modelDocument.getRoot();
+		} );
+
+		it( 'should return true if given element has text node', () => {
+			const pFoo = root.getChild( 1 );
+
+			expect( data.hasContent( pFoo ) ).to.be.true;
+		} );
+
+		it( 'should return true if given element has element that is an object', () => {
+			const divImg = root.getChild( 2 );
+
+			expect( data.hasContent( divImg ) ).to.be.true;
+		} );
+
+		it( 'should return false if given element has no elements', () => {
+			const pEmpty = root.getChild( 0 ).getChild( 0 );
+
+			expect( data.hasContent( pEmpty ) ).to.be.false;
+		} );
+
+		it( 'should return false if given element has only elements that are not objects', () => {
+			const divP = root.getChild( 0 );
+
+			expect( data.hasContent( divP ) ).to.be.false;
+		} );
+
+		it( 'should return true if there is a text node in given range', () => {
+			const range = ModelRange.createFromParentsAndOffsets( root, 1, root, 2 );
+
+			expect( data.hasContent( range ) ).to.be.true;
+		} );
+
+		it( 'should return true if there is a part of text node in given range', () => {
+			const pFoo = root.getChild( 1 );
+			const range = ModelRange.createFromParentsAndOffsets( pFoo, 1, pFoo, 2 );
+
+			expect( data.hasContent( range ) ).to.be.true;
+		} );
+
+		it( 'should return true if there is element that is an object in given range', () => {
+			const divImg = root.getChild( 2 );
+			const range = ModelRange.createFromParentsAndOffsets( divImg, 0, divImg, 1 );
+
+			expect( data.hasContent( range ) ).to.be.true;
+		} );
+
+		it( 'should return false if range is collapsed', () => {
+			const range = ModelRange.createFromParentsAndOffsets( root, 1, root, 1 );
+
+			expect( data.hasContent( range ) ).to.be.false;
+		} );
+
+		it( 'should return false if range has only elements that are not objects', () => {
+			const range = ModelRange.createFromParentsAndOffsets( root, 0, root, 1 );
+
+			expect( data.hasContent( range ) ).to.be.false;
+		} );
+	} );
 } );

+ 46 - 7
packages/ckeditor5-engine/tests/controller/deletecontent.js

@@ -155,9 +155,9 @@ describe( 'DataController', () => {
 
 				schema.registerItem( 'paragraph', '$block' );
 				schema.registerItem( 'heading1', '$block' );
+				schema.registerItem( 'image', '$inline' );
 				schema.registerItem( 'pchild' );
 				schema.registerItem( 'pparent' );
-				schema.registerItem( 'image', '$inline' );
 
 				schema.allow( { name: 'pchild', inside: 'paragraph' } );
 				schema.allow( { name: '$text', inside: 'pchild' } );
@@ -188,12 +188,6 @@ describe( 'DataController', () => {
 				{ leaveUnmerged: true }
 			);
 
-			test(
-				'merges second element into the first one (same name)',
-				'<paragraph>x</paragraph><paragraph>fo[o</paragraph><paragraph>b]ar</paragraph><paragraph>y</paragraph>',
-				'<paragraph>x</paragraph><paragraph>fo[]ar</paragraph><paragraph>y</paragraph>'
-			);
-
 			test(
 				'merges second element into the first one (different name)',
 				'<paragraph>x</paragraph><heading1>fo[o</heading1><paragraph>b]ar</paragraph><paragraph>y</paragraph>',
@@ -436,6 +430,51 @@ describe( 'DataController', () => {
 					'<paragraph>ba[]</paragraph><blockWidget><nestedEditable>oo</nestedEditable></blockWidget>'
 				);
 			} );
+
+			describe( 'filtering out', () => {
+				beforeEach( () => {
+					const schema = doc.schema;
+
+					schema.allow( { name: '$text', attributes: [ 'a', 'b' ], inside: 'paragraph' } );
+					schema.allow( { name: '$text', attributes: [ 'b', 'c' ], inside: 'pchild' } );
+					schema.allow( { name: 'pchild', inside: 'pchild' } );
+					schema.disallow( { name: '$text', attributes: [ 'c' ], inside: 'pchild pchild' } );
+				} );
+
+				test(
+					'filters out disallowed attributes after left merge',
+					'<paragraph>x<pchild>fo[o</pchild></paragraph><paragraph>y]<$text a="1" b="1">z</$text></paragraph>',
+					'<paragraph>x<pchild>fo[]<$text b="1">z</$text></pchild></paragraph>'
+				);
+
+				test(
+					'filters out disallowed attributes from nested nodes after left merge',
+					'<paragraph>' +
+						'x' +
+						'<pchild>fo[o</pchild>' +
+					'</paragraph>' +
+					'<paragraph>' +
+						'b]a<$text a="1" b="1">r</$text>' +
+						'<pchild>b<$text b="1" c="1">i</$text>z</pchild>' +
+						'y' +
+					'</paragraph>',
+
+					'<paragraph>' +
+						'x' +
+						'<pchild>' +
+							'fo[]a<$text b="1">r</$text>' +
+							'<pchild>b<$text b="1">i</$text>z</pchild>' +
+							'y' +
+						'</pchild>' +
+					'</paragraph>'
+				);
+
+				test(
+					'filters out disallowed attributes after right merge',
+					'<paragraph>fo[o</paragraph><paragraph><pchild>x<$text b="1" c="1">y]z</$text></pchild></paragraph>',
+					'<paragraph>fo[]<$text b="1">z</$text></paragraph>'
+				);
+			} );
 		} );
 
 		describe( 'in element selections scenarios', () => {

+ 77 - 1
packages/ckeditor5-engine/tests/controller/insertcontent.js

@@ -281,6 +281,17 @@ describe( 'DataController', () => {
 				expect( getData( doc ) ).to.equal( '<heading1>foxyz[]ar</heading1>' );
 			} );
 
+			it( 'not inserts autoparagraph when paragraph is disallowed at the current position', () => {
+				doc.schema.disallow( { name: 'paragraph', inside: '$root' } );
+				doc.schema.disallow( { name: 'heading2', inside: '$root' } );
+
+				const content = new Element( 'heading2', [], [ new Text( 'bar' ) ] );
+
+				setData( doc, '[<heading1>foo</heading1>]' );
+				insertContent( dataController, content, doc.selection );
+				expect( getData( doc ) ).to.equal( '[]' );
+			} );
+
 			describe( 'block to block handling', () => {
 				it( 'inserts one paragraph', () => {
 					setData( doc, '<paragraph>f[]oo</paragraph>' );
@@ -300,6 +311,12 @@ describe( 'DataController', () => {
 					expect( getData( doc ) ).to.equal( '<paragraph>xyz[]</paragraph>' );
 				} );
 
+				it( 'inserts one empty paragraph', () => {
+					setData( doc, '<paragraph>f[]oo</paragraph>' );
+					insertHelper( '<paragraph></paragraph>' );
+					expect( getData( doc ) ).to.equal( '<paragraph>f[]oo</paragraph>' );
+				} );
+
 				it( 'inserts one block into a fully selected content', () => {
 					setData( doc, '<heading1>[foo</heading1><paragraph>bar]</paragraph>' );
 					insertHelper( '<heading2>xyz</heading2>' );
@@ -576,8 +593,9 @@ describe( 'DataController', () => {
 				const schema = doc.schema;
 
 				schema.registerItem( 'paragraph', '$block' );
+				schema.registerItem( 'heading1', '$block' );
+				schema.registerItem( 'element', '$block' );
 
-				// Let's use table as an example of content which needs to be filtered out.
 				schema.registerItem( 'table' );
 				schema.registerItem( 'td' );
 				schema.registerItem( 'disallowedWidget' );
@@ -585,12 +603,22 @@ describe( 'DataController', () => {
 				schema.allow( { name: 'table', inside: '$clipboardHolder' } );
 				schema.allow( { name: 'td', inside: '$clipboardHolder' } );
 				schema.allow( { name: 'td', inside: 'table' } );
+				schema.allow( { name: 'element', inside: 'td' } );
 				schema.allow( { name: '$block', inside: 'td' } );
 				schema.allow( { name: '$text', inside: 'td' } );
+				schema.allow( { name: 'table', inside: 'element' } );
 
 				schema.allow( { name: 'disallowedWidget', inside: '$clipboardHolder' } );
 				schema.allow( { name: '$text', inside: 'disallowedWidget' } );
 				schema.objects.add( 'disallowedWidget' );
+
+				schema.allow( { name: 'element', inside: 'paragraph' } );
+				schema.allow( { name: 'element', inside: 'heading1' } );
+				schema.allow( { name: '$text', attributes: 'b', inside: 'paragraph' } );
+				schema.allow( { name: '$text', attributes: [ 'b' ], inside: 'paragraph element' } );
+				schema.allow( { name: '$text', attributes: [ 'a', 'b' ], inside: 'heading1 element' } );
+				schema.allow( { name: '$text', attributes: [ 'a', 'b' ], inside: 'td element' } );
+				schema.allow( { name: '$text', attributes: [ 'b' ], inside: 'element table td' } );
 			} );
 
 			it( 'filters out disallowed elements and leaves out the text', () => {
@@ -610,6 +638,54 @@ describe( 'DataController', () => {
 				insertHelper( '<disallowedWidget>xxx</disallowedWidget>' );
 				expect( getData( doc ) ).to.equal( '<paragraph>f[]oo</paragraph>' );
 			} );
+
+			it( 'filters out disallowed attributes when inserting text', () => {
+				setData( doc, '<paragraph>f[]oo</paragraph>' );
+				insertHelper( 'x<$text a="1" b="1">x</$text>xy<$text a="1">y</$text>y' );
+				expect( getData( doc ) ).to.equal( '<paragraph>fx<$text b="1">x</$text>xyyy[]oo</paragraph>' );
+			} );
+
+			it( 'filters out disallowed attributes when inserting nested elements', () => {
+				setData( doc, '<element>[]</element>' );
+				insertHelper( '<table><td>f<$text a="1" b="1" c="1">o</$text>o</td></table>' );
+				expect( getData( doc ) ).to.equal( '<element><table><td>f<$text b="1">o</$text>o</td></table>[]</element>' );
+			} );
+
+			it( 'filters out disallowed attributes when inserting text in disallowed elements', () => {
+				setData( doc, '<paragraph>f[]oo</paragraph>' );
+				insertHelper( '<table><td>x<$text a="1" b="1">x</$text>x</td><td>y<$text a="1">y</$text>y</td></table>' );
+				expect( getData( doc ) ).to.equal( '<paragraph>fx<$text b="1">x</$text>xyyy[]oo</paragraph>' );
+			} );
+
+			it( 'filters out disallowed attributes when merging #1', () => {
+				setData( doc, '<paragraph>[]foo</paragraph>' );
+				insertHelper( '<paragraph>x<$text a="1" b="1">x</$text>x</paragraph>' );
+				expect( getData( doc ) ).to.equal( '<paragraph>x<$text b="1">x</$text>x[]foo</paragraph>' );
+			} );
+
+			it( 'filters out disallowed attributes when merging #2', () => {
+				setData( doc, '<paragraph>f[]oo</paragraph>' );
+				insertHelper( '<paragraph>x<$text a="1" b="1">x</$text>x</paragraph>' );
+				expect( getData( doc ) ).to.equal( '<paragraph>fx<$text b="1">x</$text>x[]oo</paragraph>' );
+			} );
+
+			it( 'filters out disallowed attributes when merging #3', () => {
+				setData( doc, '<paragraph>foo[]</paragraph>' );
+				insertHelper( '<paragraph>x<$text a="1" b="1">x</$text>x</paragraph>' );
+				expect( getData( doc ) ).to.equal( '<paragraph>foox<$text b="1">x</$text>x[]</paragraph>' );
+			} );
+
+			it( 'filters out disallowed attributes from nested nodes when merging', () => {
+				setData( doc, '<paragraph>f[]oo</paragraph>' );
+				insertHelper( '<heading1>x<element>b<$text a="1" b="1">a</$text>r</element>x</heading1>' );
+				expect( getData( doc ) ).to.equal( '<paragraph>fx<element>b<$text b="1">a</$text>r</element>x[]oo</paragraph>' );
+			} );
+
+			it( 'filters out disallowed attributes when autoparagraphing', () => {
+				setData( doc, '<paragraph>f[]oo</paragraph>' );
+				insertHelper( '<paragraph>xxx</paragraph><$text a="1" b="1">yyy</$text>' );
+				expect( getData( doc ) ).to.equal( '<paragraph>fxxx</paragraph><paragraph><$text b="1">yyy[]</$text>oo</paragraph>' );
+			} );
 		} );
 	} );
 

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

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

+ 72 - 8
packages/ckeditor5-engine/tests/conversion/model-to-view-converters.js

@@ -30,7 +30,7 @@ import {
 	removeUIElement,
 	highlightText,
 	highlightElement,
-	highlightDescriptorToAttributeElement
+	createViewElementFromHighlightDescriptor
 } from '../../src/conversion/model-to-view-converters';
 
 import { createRangeOnElementOnly } from '../../tests/model/_utils/utils';
@@ -176,7 +176,8 @@ describe( 'model-to-view-converters', () => {
 		const highlightDescriptor = {
 			class: 'highlight-class',
 			priority: 7,
-			attributes: { title: 'title' }
+			attributes: { title: 'title' },
+			id: 'customId'
 		};
 
 		beforeEach( () => {
@@ -276,6 +277,37 @@ describe( 'model-to-view-converters', () => {
 			expect( viewToString( viewRoot ) ).to.equal( '<div><p>foo</p><p>bar</p></div>' );
 		} );
 
+		it( 'should use provide default priority and id if not provided', () => {
+			const highlightDescriptor = { class: 'highlight-class' };
+
+			dispatcher.on( 'addMarker:marker', highlightElement( highlightDescriptor ) );
+			dispatcher.on( 'removeMarker:marker', highlightElement( highlightDescriptor ) );
+			dispatcher.on( 'insert:paragraph', insertElement( data => {
+				// Use special converter only for first paragraph.
+				if ( data.item == modelElement2 ) {
+					return;
+				}
+
+				const viewContainer = new ViewContainerElement( 'p' );
+
+				viewContainer.setCustomProperty( 'addHighlight', ( element, descriptor ) => {
+					expect( descriptor.priority ).to.equal( 10 );
+					expect( descriptor.id ).to.equal( 'marker:foo-bar-baz' );
+				} );
+
+				viewContainer.setCustomProperty( 'removeHighlight', ( element, descriptor ) => {
+					expect( descriptor.priority ).to.equal( 10 );
+					expect( descriptor.id ).to.equal( 'marker:foo-bar-baz' );
+				} );
+
+				return viewContainer;
+			} ), { priority: 'high' } );
+
+			dispatcher.convertInsertion( markerRange );
+			modelDoc.markers.set( 'marker', markerRange );
+			dispatcher.convertMarker( 'addMarker', 'marker:foo-bar-baz', markerRange );
+		} );
+
 		it( 'should do nothing if descriptor is not provided', () => {
 			dispatcher.on( 'addMarker:marker', highlightElement( () => null ) );
 			dispatcher.on( 'removeMarker:marker', highlightElement( () => null ) );
@@ -1192,14 +1224,14 @@ describe( 'model-to-view-converters', () => {
 		} );
 	} );
 
-	describe( 'highlightDescriptorToAttributeElement()', () => {
+	describe( 'createViewElementFromHighlightDescriptor()', () => {
 		it( 'should return attribute element from descriptor object', () => {
 			const descriptor = {
 				class: 'foo-class',
 				attributes: { one: 1, two: 2 },
 				priority: 7,
 			};
-			const element = highlightDescriptorToAttributeElement( descriptor );
+			const element = createViewElementFromHighlightDescriptor( descriptor );
 
 			expect( element.is( 'attributeElement' ) ).to.be.true;
 			expect( element.name ).to.equal( 'span' );
@@ -1217,7 +1249,7 @@ describe( 'model-to-view-converters', () => {
 				attributes: { one: 1, two: 2 },
 				priority: 7,
 			};
-			const element = highlightDescriptorToAttributeElement( descriptor );
+			const element = createViewElementFromHighlightDescriptor( descriptor );
 
 			expect( element.is( 'attributeElement' ) ).to.be.true;
 			expect( element.name ).to.equal( 'span' );
@@ -1235,7 +1267,7 @@ describe( 'model-to-view-converters', () => {
 				attributes: { one: 1, two: 2 },
 				priority: 7,
 			};
-			const element = highlightDescriptorToAttributeElement( descriptor );
+			const element = createViewElementFromHighlightDescriptor( descriptor );
 
 			expect( element.is( 'attributeElement' ) ).to.be.true;
 			expect( element.name ).to.equal( 'span' );
@@ -1251,7 +1283,7 @@ describe( 'model-to-view-converters', () => {
 				class: 'foo-class',
 				attributes: { one: 1, two: 2 },
 			};
-			const element = highlightDescriptorToAttributeElement( descriptor );
+			const element = createViewElementFromHighlightDescriptor( descriptor );
 
 			expect( element.is( 'attributeElement' ) ).to.be.true;
 			expect( element.name ).to.equal( 'span' );
@@ -1268,12 +1300,44 @@ describe( 'model-to-view-converters', () => {
 				class: 'foo-class',
 				priority: 7
 			};
-			const element = highlightDescriptorToAttributeElement( descriptor );
+			const element = createViewElementFromHighlightDescriptor( descriptor );
 
 			expect( element.is( 'attributeElement' ) ).to.be.true;
 			expect( element.name ).to.equal( 'span' );
 			expect( element.priority ).to.equal( 7 );
 			expect( element.hasClass( 'foo-class' ) ).to.be.true;
 		} );
+
+		it( 'should create similar elements if they are created using same descriptor id', () => {
+			const a = createViewElementFromHighlightDescriptor( {
+				id: 'id',
+				class: 'classA',
+				priority: 1
+			} );
+
+			const b = createViewElementFromHighlightDescriptor( {
+				id: 'id',
+				class: 'classB',
+				priority: 2
+			} );
+
+			expect( a.isSimilar( b ) ).to.be.true;
+		} );
+
+		it( 'should create non-similar elements if they have different descriptor id', () => {
+			const a = createViewElementFromHighlightDescriptor( {
+				id: 'a',
+				class: 'foo',
+				priority: 1
+			} );
+
+			const b = createViewElementFromHighlightDescriptor( {
+				id: 'b',
+				class: 'foo',
+				priority: 1
+			} );
+
+			expect( a.isSimilar( b ) ).to.be.false;
+		} );
 	} );
 } );

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

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

+ 28 - 0
packages/ckeditor5-engine/tests/manual/tickets/1088/1.html

@@ -0,0 +1,28 @@
+<div style="padding: 20px;">
+	<div id="editor">
+		<h2>Heading 1 (disallowed: italic, link)</h2>
+		<p>This is a paragraph</p>
+		<h3>Heading 2 (disallowed: italic)</h3>
+		<p></p>
+		<blockquote>
+			<p>This is a paragraph in a blockQuote</p>
+			<p></p>
+		</blockquote>
+	</div>
+
+	<div>
+		<p><a href="https://ckeditor.com"><i>Paragraph with link and italic</i></a></p>
+
+		<ul>
+			<li><a href="https://ckeditor.com"><b>List item with link and bold</b></a></li>
+		</ul>
+
+		<h4><b><i>Heading 3 with bold and italic</i></b></h4>
+
+		<div><b>Just a text with bold</b></div>
+
+		<br>
+
+		<img src="sample.jpg" alt="Sample image" height="150">
+	</div>
+</div>

+ 32 - 0
packages/ckeditor5-engine/tests/manual/tickets/1088/1.js

@@ -0,0 +1,32 @@
+/**
+ * @license Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* globals console, window, document */
+
+import ClassicEditor from '@ckeditor/ckeditor5-editor-classic/src/classiceditor';
+import ArticlePresets from '@ckeditor/ckeditor5-presets/src/article';
+
+ClassicEditor
+	.create( document.querySelector( '#editor' ), {
+		plugins: [ ArticlePresets ],
+		toolbar: [ 'headings', 'undo', 'redo' ],
+		image: {
+			toolbar: [ 'imageTextAlternative' ]
+		}
+	} )
+	.then( editor => {
+		window.editor = editor;
+
+		const schema = editor.document.schema;
+
+		schema.disallow( { name: '$text', attributes: [ 'linkHref', 'italic' ], inside: 'heading1' } );
+		schema.disallow( { name: '$text', attributes: [ 'italic' ], inside: 'heading2' } );
+		schema.disallow( { name: '$text', attributes: [ 'linkHref' ], inside: 'blockQuote listItem' } );
+		schema.disallow( { name: '$text', attributes: [ 'bold' ], inside: 'paragraph' } );
+		schema.disallow( { name: 'heading3', inside: '$root' } );
+	} )
+	.catch( err => {
+		console.error( err.stack );
+	} );

+ 31 - 0
packages/ckeditor5-engine/tests/manual/tickets/1088/1.md

@@ -0,0 +1,31 @@
+## Stripping disallowed attributes by `(insert|delete)Content` [#1088](https://github.com/ckeditor/ckeditor5-engine/issues/1088)
+
+### Simple scenario.
+
+1. Copy a paragraph with italic and link.
+2. Paste it to the Heading 1. Inserted text should be stripped
+3. Paste it to the Heading 2. Inserted text should be a link only.
+4. Paste it to paragraph. Inserted text should not be stripped.
+
+### Simple scenario (element).
+
+1. Copy image.
+2. Paste it to the editor. Image should be inserted with an alternative text "Sample image".
+
+### Nested nodes.
+
+1. Copy a list item with bold and link.
+2. Paste it into the empty block (directly to the root) . Inserted list item should be a bold link.
+2. Paste it into the empty block in BlockQuote. Inserted list item should be a bold only.
+
+### Auto paragraphing.
+
+1. Copy a text with bold.
+2. Select all content in the editor.
+3. Paste copied text. Inserted content should be a paragraph and should be stripped from bold.
+
+### Auto paragraphing (disallowed block).
+
+1. Copy Heading 3 with bold and italic.
+2. Select all content in the editor.
+3. Paste copied text. Inserted content should be a paragraph and should be stripped from bold.

BIN
packages/ckeditor5-engine/tests/manual/tickets/1088/sample.jpg


+ 25 - 0
packages/ckeditor5-engine/tests/model/delta/transform/insertdelta.js

@@ -18,6 +18,7 @@ import SplitDelta from '../../../../src/model/delta/splitdelta';
 import InsertOperation from '../../../../src/model/operation/insertoperation';
 import MoveOperation from '../../../../src/model/operation/moveoperation';
 import ReinsertOperation from '../../../../src/model/operation/reinsertoperation';
+import NoOperation from '../../../../src/model/operation/nooperation';
 
 import { getNodesAndText } from '../../../../tests/model/_utils/utils';
 
@@ -156,6 +157,30 @@ describe( 'transform', () => {
 				} );
 			} );
 
+			it( 'merge in same position as insert - undo mode', () => {
+				// If MergeDelta second operation is NoOperation, default transformation algorithm should be used.
+				const mergeDelta = getMergeDelta( insertPosition, 4, 12, baseVersion );
+				mergeDelta.operations[ 1 ] = new NoOperation( 1 );
+
+				const transformed = transform( insertDelta, mergeDelta, context );
+
+				baseVersion = mergeDelta.operations.length;
+
+				expect( transformed.length ).to.equal( 1 );
+
+				expectDelta( transformed[ 0 ], {
+					type: InsertDelta,
+					operations: [
+						{
+							type: InsertOperation,
+							position: Position.createFromPosition( insertPosition ),
+							nodes: [ nodeA, nodeB ],
+							baseVersion
+						}
+					]
+				} );
+			} );
+
 			it( 'merge the node that is parent of insert position (sticky move test)', () => {
 				const mergeDelta = getMergeDelta( new Position( root, [ 3, 3 ] ), 1, 4, baseVersion );
 				const transformed = transform( insertDelta, mergeDelta, context );

+ 57 - 0
packages/ckeditor5-engine/tests/model/delta/transform/mergedelta.js

@@ -120,6 +120,34 @@ describe( 'transform', () => {
 				} );
 			} );
 
+			it( 'insert at same position as merge - merge second operation is NoOperation', () => {
+				// If MergeDelta second operation is NoOperation, default transformation algorithm should be used.
+				mergeDelta.operations[ 1 ] = new NoOperation( 1 );
+				const insertDelta = getInsertDelta( mergePosition, [ nodeA, nodeB ], baseVersion );
+				const transformed = transform( mergeDelta, insertDelta, context );
+
+				baseVersion = insertDelta.operations.length;
+
+				expect( transformed.length ).to.equal( 1 );
+
+				expectDelta( transformed[ 0 ], {
+					type: MergeDelta,
+					operations: [
+						{
+							type: MoveOperation,
+							sourcePosition: new Position( root, [ 3, 3, 5, 0 ] ),
+							howMany: mergeDelta.operations[ 0 ].howMany,
+							targetPosition: mergeDelta.operations[ 0 ].targetPosition,
+							baseVersion
+						},
+						{
+							type: NoOperation,
+							baseVersion: baseVersion + 1
+						}
+					]
+				} );
+			} );
+
 			it( 'insert inside merged node (sticky move test)', () => {
 				const insertDelta = getInsertDelta( new Position( root, [ 3, 3, 3, 12 ] ), [ nodeA, nodeB ], baseVersion );
 				const transformed = transform( mergeDelta, insertDelta, context );
@@ -227,6 +255,35 @@ describe( 'transform', () => {
 				// MoveDelta is applied. MergeDelta is discarded.
 				expect( nodesAndText ).to.equal( 'DIVXabcdabcfoobarxyzXXXXXDIV' );
 			} );
+
+			it( 'node on the left side of merge was moved - second merge operation is NoOperation', () => {
+				// If second MergeDelta operation is NoOperation default algorithm should be used.
+				mergeDelta.operations[ 1 ] = new NoOperation( 1 );
+
+				const moveDelta = getMoveDelta( new Position( root, [ 3, 3, 2 ] ), 1, new Position( root, [ 3, 3, 0 ] ), baseVersion );
+				const transformed = transform( mergeDelta, moveDelta, context );
+
+				expect( transformed.length ).to.equal( 1 );
+
+				baseVersion = moveDelta.operations.length;
+
+				expectDelta( transformed[ 0 ], {
+					type: MergeDelta,
+					operations: [
+						{
+							type: MoveOperation,
+							sourcePosition: new Position( root, [ 3, 3, 3, 0 ] ),
+							howMany: 12,
+							targetPosition: new Position( root, [ 3, 3, 0, 4 ] ),
+							baseVersion
+						},
+						{
+							type: NoOperation,
+							baseVersion: baseVersion + 1
+						}
+					]
+				} );
+			} );
 		} );
 
 		describe( 'MergeDelta', () => {

+ 27 - 0
packages/ckeditor5-engine/tests/model/delta/transform/movedelta.js

@@ -15,6 +15,7 @@ import MoveDelta from '../../../../src/model/delta/movedelta';
 import SplitDelta from '../../../../src/model/delta/splitdelta';
 
 import MoveOperation from '../../../../src/model/operation/moveoperation';
+import NoOperation from '../../../../src/model/operation/nooperation';
 
 import { getNodesAndText } from '../../../../tests/model/_utils/utils';
 
@@ -159,6 +160,32 @@ describe( 'transform', () => {
 					]
 				} );
 			} );
+
+			it( 'node on the right side of merge was moved - second merge operation is NoOperation', () => {
+				// If second MergeDelta operation is NoOperation default algorithm should be used.
+				const mergePosition = new Position( root, [ 3, 3, 3 ] );
+				const mergeDelta = getMergeDelta( mergePosition, 4, 12, baseVersion );
+				mergeDelta.operations[ 1 ] = new NoOperation( 1 );
+
+				const transformed = transform( moveDelta, mergeDelta, context );
+
+				expect( transformed.length ).to.equal( 1 );
+
+				baseVersion = mergeDelta.operations.length;
+
+				expectDelta( transformed[ 0 ], {
+					type: MoveDelta,
+					operations: [
+						{
+							type: MoveOperation,
+							sourcePosition: moveDelta._moveOperation.sourcePosition,
+							howMany: moveDelta._moveOperation.howMany,
+							targetPosition: moveDelta._moveOperation.targetPosition,
+							baseVersion
+						}
+					]
+				} );
+			} );
 		} );
 	} );
 } );

+ 0 - 16
packages/ckeditor5-engine/tests/model/operation/attributeoperation.js

@@ -369,22 +369,6 @@ describe( 'AttributeOperation', () => {
 		expect( root.getChild( 1 ).data ).to.equal( 'bcxyz' );
 	} );
 
-	it( 'should return undefined upon execution if new value is same as old value', () => {
-		root.insertChildren( 0, new Text( 'bar', { foo: true } ) );
-
-		const operation = new AttributeOperation(
-			new Range( new Position( root, [ 0 ] ), new Position( root, [ 3 ] ) ),
-			'foo',
-			true,
-			true,
-			doc.version
-		);
-
-		const result = operation._execute();
-
-		expect( result ).to.be.undefined;
-	} );
-
 	describe( 'toJSON', () => {
 		it( 'should create proper serialized object', () => {
 			const range = new Range( new Position( root, [ 0 ] ), new Position( root, [ 2 ] ) );

+ 5 - 1
packages/ckeditor5-engine/tests/model/operation/nooperation.js

@@ -19,6 +19,10 @@ describe( 'NoOperation', () => {
 		expect( () => doc.applyOperation( wrapInDelta( noop ) ) ).to.not.throw( Error );
 	} );
 
+	it( 'should return empty object when executed', () => {
+		expect( noop._execute() ).to.deep.equal( {} );
+	} );
+
 	it( 'should create a NoOperation as a reverse', () => {
 		const reverse = noop.getReversed();
 
@@ -26,7 +30,7 @@ describe( 'NoOperation', () => {
 		expect( reverse.baseVersion ).to.equal( 1 );
 	} );
 
-	it( 'should create a do-nothing operation having same parameters when cloned', () => {
+	it( 'should create NoOperation having same parameters when cloned', () => {
 		const clone = noop.clone();
 
 		expect( clone ).to.be.an.instanceof( NoOperation );

+ 0 - 7
packages/ckeditor5-engine/tests/model/operation/renameoperation.js

@@ -92,13 +92,6 @@ describe( 'RenameOperation', () => {
 		expect( clone.newName ).to.equal( newName );
 	} );
 
-	it( 'should return undefined on execution if old name and new name is same', () => {
-		const op = new RenameOperation( Position.createAt( root, 0 ), oldName, oldName, doc.version );
-		const result = op._execute();
-
-		expect( result ).to.be.undefined;
-	} );
-
 	describe( 'toJSON', () => {
 		it( 'should create proper serialized object', () => {
 			const op = new RenameOperation( Position.createAt( root, 'end' ), oldName, newName, doc.version );

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

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

+ 179 - 1
packages/ckeditor5-engine/tests/model/schema/schema.js

@@ -6,12 +6,15 @@
 import { default as Schema, SchemaItem } from '../../../src/model/schema';
 import Document from '../../../src/model/document';
 import Element from '../../../src/model/element';
+import Text from '../../../src/model/text';
+import DocumentFragment from '../../../src/model/documentfragment';
 import Position from '../../../src/model/position';
 import Range from '../../../src/model/range';
 import Selection from '../../../src/model/selection';
+import AttributeDelta from '../../../src/model/delta/attributedelta';
 import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
 import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils';
-import { setData, stringify } from '../../../src/dev-utils/model';
+import { setData, getData, stringify } from '../../../src/dev-utils/model';
 
 testUtils.createSinonSandbox();
 
@@ -492,6 +495,7 @@ describe( 'Schema', () => {
 			schema.registerItem( 'p', '$block' );
 			schema.registerItem( 'h1', '$block' );
 			schema.registerItem( 'img', '$inline' );
+			schema.registerItem( 'figure' );
 
 			// Bold text is allowed only in P.
 			schema.allow( { name: '$text', attributes: 'bold', inside: 'p' } );
@@ -499,6 +503,10 @@ describe( 'Schema', () => {
 
 			// Disallow bold on image.
 			schema.disallow( { name: 'img', attributes: 'bold', inside: '$root' } );
+
+			// Figure must have name attribute and optional title attribute.
+			schema.requireAttributes( 'figure', [ 'name' ] );
+			schema.allow( { name: 'figure', attributes: [ 'title', 'name' ], inside: '$root' } );
 		} );
 
 		describe( 'when selection is collapsed', () => {
@@ -544,6 +552,16 @@ describe( 'Schema', () => {
 				setData( doc, '<p>foo[<img /><img />]bar</p>' );
 				expect( schema.checkAttributeInSelection( doc.selection, attribute ) ).to.be.false;
 			} );
+
+			it( 'should return true when checking element with required attribute', () => {
+				setData( doc, '[<figure name="figure"></figure>]' );
+				expect( schema.checkAttributeInSelection( doc.selection, 'title' ) ).to.be.true;
+			} );
+
+			it( 'should return true when checking element when attribute is already present', () => {
+				setData( doc, '[<figure name="figure" title="title"></figure>]' );
+				expect( schema.checkAttributeInSelection( doc.selection, 'title' ) ).to.be.true;
+			} );
 		} );
 	} );
 
@@ -737,4 +755,164 @@ describe( 'Schema', () => {
 			expect( schema.getLimitElement( doc.selection ) ).to.equal( root );
 		} );
 	} );
+
+	describe( 'removeDisallowedAttributes()', () => {
+		let doc, root;
+
+		beforeEach( () => {
+			doc = new Document();
+			root = doc.createRoot();
+			schema = doc.schema;
+
+			schema.registerItem( 'paragraph', '$block' );
+			schema.registerItem( 'div', '$block' );
+			schema.registerItem( 'image' );
+			schema.objects.add( 'image' );
+			schema.allow( { name: '$block', inside: 'div' } );
+		} );
+
+		describe( 'filtering attributes from nodes', () => {
+			let text, image;
+
+			beforeEach( () => {
+				schema.allow( { name: '$text', attributes: [ 'a' ], inside: '$root' } );
+				schema.allow( { name: 'image', attributes: [ 'b' ], inside: '$root' } );
+
+				text = new Text( 'foo', { a: 1, b: 1 } );
+				image = new Element( 'image', { a: 1, b: 1 } );
+			} );
+
+			it( 'should filter out disallowed attributes from given nodes', () => {
+				schema.removeDisallowedAttributes( [ text, image ], '$root' );
+
+				expect( Array.from( text.getAttributeKeys() ) ).to.deep.equal( [ 'a' ] );
+				expect( Array.from( image.getAttributeKeys() ) ).to.deep.equal( [ 'b' ] );
+			} );
+
+			it( 'should filter out disallowed attributes from given nodes (batch)', () => {
+				const root = doc.getRoot();
+				const batch = doc.batch();
+
+				root.appendChildren( [ text, image ] );
+
+				schema.removeDisallowedAttributes( [ text, image ], '$root', batch );
+
+				expect( Array.from( text.getAttributeKeys() ) ).to.deep.equal( [ 'a' ] );
+				expect( Array.from( image.getAttributeKeys() ) ).to.deep.equal( [ 'b' ] );
+
+				expect( batch.deltas ).to.length( 2 );
+				expect( batch.deltas[ 0 ] ).to.instanceof( AttributeDelta );
+				expect( batch.deltas[ 1 ] ).to.instanceof( AttributeDelta );
+			} );
+		} );
+
+		describe( 'filtering attributes from child nodes', () => {
+			let div;
+
+			beforeEach( () => {
+				schema.allow( { name: '$text', attributes: [ 'a' ], inside: 'div' } );
+				schema.allow( { name: '$text', attributes: [ 'b' ], inside: 'div paragraph' } );
+				schema.allow( { name: 'image', attributes: [ 'a' ], inside: 'div' } );
+				schema.allow( { name: 'image', attributes: [ 'b' ], inside: 'div paragraph' } );
+
+				const foo = new Text( 'foo', { a: 1, b: 1 } );
+				const bar = new Text( 'bar', { a: 1, b: 1 } );
+				const imageInDiv = new Element( 'image', { a: 1, b: 1 } );
+				const imageInParagraph = new Element( 'image', { a: 1, b: 1 } );
+				const paragraph = new Element( 'paragraph', [], [ foo, imageInParagraph ] );
+
+				div = new Element( 'div', [], [ paragraph, bar, imageInDiv ] );
+			} );
+
+			it( 'should filter out disallowed attributes from child nodes', () => {
+				schema.removeDisallowedAttributes( [ div ], '$root' );
+
+				expect( stringify( div ) )
+					.to.equal(
+						'<div>' +
+							'<paragraph>' +
+								'<$text b="1">foo</$text>' +
+								'<image b="1"></image>' +
+							'</paragraph>' +
+							'<$text a="1">bar</$text>' +
+							'<image a="1"></image>' +
+						'</div>'
+					);
+			} );
+
+			it( 'should filter out disallowed attributes from child nodes (batch)', () => {
+				const root = doc.getRoot();
+				const batch = doc.batch();
+
+				root.appendChildren( [ div ] );
+
+				schema.removeDisallowedAttributes( [ div ], '$root', batch );
+
+				expect( batch.deltas ).to.length( 4 );
+				expect( batch.deltas[ 0 ] ).to.instanceof( AttributeDelta );
+				expect( batch.deltas[ 1 ] ).to.instanceof( AttributeDelta );
+				expect( batch.deltas[ 2 ] ).to.instanceof( AttributeDelta );
+				expect( batch.deltas[ 3 ] ).to.instanceof( AttributeDelta );
+
+				expect( getData( doc, { withoutSelection: true } ) )
+					.to.equal(
+						'<div>' +
+							'<paragraph>' +
+								'<$text b="1">foo</$text>' +
+								'<image b="1"></image>' +
+							'</paragraph>' +
+							'<$text a="1">bar</$text>' +
+							'<image a="1"></image>' +
+						'</div>'
+					);
+			} );
+		} );
+
+		describe( 'allowed parameters', () => {
+			let frag;
+
+			beforeEach( () => {
+				schema.allow( { name: '$text', attributes: [ 'a' ], inside: '$root' } );
+				schema.allow( { name: '$text', attributes: [ 'b' ], inside: 'paragraph' } );
+
+				frag = new DocumentFragment( [
+					new Text( 'foo', { a: 1 } ),
+					new Element( 'paragraph', [], [ new Text( 'bar', { a: 1, b: 1 } ) ] ),
+					new Text( 'biz', { b: 1 } )
+				] );
+			} );
+
+			it( 'should accept iterable as nodes', () => {
+				schema.removeDisallowedAttributes( frag.getChildren(), '$root' );
+
+				expect( stringify( frag ) )
+					.to.equal( '<$text a="1">foo</$text><paragraph><$text b="1">bar</$text></paragraph>biz' );
+			} );
+
+			it( 'should accept Position as inside', () => {
+				schema.removeDisallowedAttributes( frag.getChildren(), Position.createAt( root ) );
+
+				expect( stringify( frag ) )
+					.to.equal( '<$text a="1">foo</$text><paragraph><$text b="1">bar</$text></paragraph>biz' );
+			} );
+
+			it( 'should accept Node as inside', () => {
+				schema.removeDisallowedAttributes( frag.getChildren(), [ root ] );
+
+				expect( stringify( frag ) )
+					.to.equal( '<$text a="1">foo</$text><paragraph><$text b="1">bar</$text></paragraph>biz' );
+			} );
+		} );
+
+		it( 'should not filter out allowed combination of attributes', () => {
+			schema.allow( { name: 'image', attributes: [ 'a', 'b' ] } );
+			schema.requireAttributes( 'image', [ 'a', 'b' ] );
+
+			const image = new Element( 'image', { a: 1, b: 1 } );
+
+			schema.removeDisallowedAttributes( [ image ], '$root' );
+
+			expect( Array.from( image.getAttributeKeys() ) ).to.deep.equal( [ 'a', 'b' ] );
+		} );
+	} );
 } );