Przeglądaj źródła

Fix table downcast tests after merge.

Maciej Gołaszewski 5 lat temu
rodzic
commit
56b386249a
45 zmienionych plików z 2131 dodań i 580 usunięć
  1. 1 1
      CHANGELOG.md
  2. 120 96
      docs/builds/guides/integration/content-styles.md
  3. 1 1
      packages/ckeditor5-autoformat/docs/features/autoformat.md
  4. 14 27
      packages/ckeditor5-autoformat/src/autoformat.js
  5. 82 91
      packages/ckeditor5-autoformat/src/blockautoformatediting.js
  6. 165 175
      packages/ckeditor5-autoformat/src/inlineautoformatediting.js
  7. 146 0
      packages/ckeditor5-autoformat/tests/autoformat.js
  8. 28 17
      packages/ckeditor5-autoformat/tests/blockautoformatediting.js
  9. 35 25
      packages/ckeditor5-autoformat/tests/inlineautoformatediting.js
  10. 1 1
      packages/ckeditor5-autoformat/tests/manual/autoformat.html
  11. 1 1
      packages/ckeditor5-build-decoupled-document/tests/ckeditor.js
  12. 8 8
      packages/ckeditor5-engine/docs/framework/guides/deep-dive/schema.md
  13. 39 39
      packages/ckeditor5-engine/src/model/schema.js
  14. 45 11
      packages/ckeditor5-engine/tests/manual/highlight.html
  15. 3 2
      packages/ckeditor5-engine/tests/manual/highlight.js
  16. 6 1
      packages/ckeditor5-engine/tests/manual/highlight.md
  17. 1 1
      packages/ckeditor5-font/src/fontfamily.js
  18. 1 1
      packages/ckeditor5-font/src/fontfamily/utils.js
  19. 6 4
      packages/ckeditor5-font/tests/fontfamily/fontfamilyediting.js
  20. 50 28
      packages/ckeditor5-font/tests/fontfamily/fontfamilyui.js
  21. 2 2
      packages/ckeditor5-font/tests/fontfamily/utils.js
  22. 2 1
      packages/ckeditor5-font/tests/integration.js
  23. 2 1
      packages/ckeditor5-font/tests/manual/font-family.md
  24. 13 1
      packages/ckeditor5-image/src/image/utils.js
  25. 55 2
      packages/ckeditor5-image/tests/image/utils.js
  26. 1 0
      packages/ckeditor5-link/package.json
  27. 40 1
      packages/ckeditor5-link/src/linkcommand.js
  28. 36 0
      packages/ckeditor5-link/src/linkimage.js
  29. 141 0
      packages/ckeditor5-link/src/linkimageediting.js
  30. 39 0
      packages/ckeditor5-link/src/linkimageui.js
  31. 62 3
      packages/ckeditor5-link/tests/linkcommand.js
  32. 18 0
      packages/ckeditor5-link/tests/linkimage.js
  33. 334 0
      packages/ckeditor5-link/tests/linkimageediting.js
  34. 41 0
      packages/ckeditor5-link/tests/linkimageui.js
  35. 11 0
      packages/ckeditor5-link/tests/manual/linkimage.html
  36. 49 0
      packages/ckeditor5-link/tests/manual/linkimage.js
  37. 3 0
      packages/ckeditor5-link/tests/manual/linkimage.md
  38. BIN
      packages/ckeditor5-link/tests/manual/sample.jpg
  39. 21 1
      packages/ckeditor5-table/src/converters/downcast.js
  40. 30 5
      packages/ckeditor5-table/src/tableclipboard.js
  41. 275 28
      packages/ckeditor5-table/tests/converters/downcast.js
  42. 162 3
      packages/ckeditor5-table/tests/tableclipboard-paste.js
  43. 1 0
      packages/ckeditor5-ui/package.json
  44. 29 1
      packages/ckeditor5-ui/src/toolbar/balloon/balloontoolbar.js
  45. 11 1
      packages/ckeditor5-ui/tests/toolbar/balloon/balloontoolbar.js

+ 1 - 1
CHANGELOG.md

@@ -44,7 +44,7 @@ But we did not stop there, as the release comes with several bug fixes, too:
 * [Potential editor crash when removing a column](https://github.com/ckeditor/ckeditor5/issues/6789).
 * [Editor crash when inserting a table row or column with another widget selected in the cell](https://github.com/ckeditor/ckeditor5/issues/6607).
 
-Blog post coming soon...
+Read more in the blog post: https://ckeditor.com/blog/CKEditor-5-v19.1.1-with-table-enhancements-typing-around-widgets-and-print-to-PDF-feature/
 
 ### Collaboration features
 

+ 120 - 96
docs/builds/guides/integration/content-styles.md

@@ -48,18 +48,18 @@ Below there is a full list of content styles used by the editor features. You ca
 
 ```css
 /*
- * CKEditor 5 (v17.0.0) content styles.
- * Generated on Fri, 13 Mar 2020 13:27:10 GMT.
+ * CKEditor 5 (v19.1.1) content styles.
+ * Generated on Tue, 09 Jun 2020 10:37:49 GMT.
  * For more information, check out https://ckeditor.com/docs/ckeditor5/latest/builds/guides/integration/content-styles.html
  */
 
 :root {
-	--ck-highlight-marker-blue: #72cdfd;
-	--ck-highlight-marker-green: #63f963;
-	--ck-highlight-marker-pink: #fc7999;
-	--ck-highlight-marker-yellow: #fdfd77;
-	--ck-highlight-pen-green: #118800;
-	--ck-highlight-pen-red: #e91313;
+	--ck-highlight-marker-blue: hsl(201, 97%, 72%);
+	--ck-highlight-marker-green: hsl(120, 93%, 68%);
+	--ck-highlight-marker-pink: hsl(345, 96%, 73%);
+	--ck-highlight-marker-yellow: hsl(60, 97%, 73%);
+	--ck-highlight-pen-green: hsl(112, 100%, 27%);
+	--ck-highlight-pen-red: hsl(0, 85%, 49%);
 	--ck-image-style-spacing: 1.5em;
 	--ck-todo-list-checkmark-size: 16px;
 }
@@ -78,26 +78,23 @@ Below there is a full list of content styles used by the editor features. You ca
 .ck-content .image.image_resized > figcaption {
 	display: block;
 }
+/* ckeditor5-image/theme/imagecaption.css */
+.ck-content .image > figcaption {
+	display: table-caption;
+	caption-side: bottom;
+	word-break: break-word;
+	color: hsl(0, 0%, 20%);
+	background-color: hsl(0, 0%, 97%);
+	padding: .6em;
+	font-size: .75em;
+	outline-offset: -1px;
+}
 /* ckeditor5-basic-styles/theme/code.css */
 .ck-content code {
 	background-color: hsla(0, 0%, 78%, 0.3);
 	padding: .15em;
 	border-radius: 2px;
 }
-/* ckeditor5-image/theme/image.css */
-.ck-content .image {
-	display: table;
-	clear: both;
-	text-align: center;
-	margin: 1em auto;
-}
-/* ckeditor5-image/theme/image.css */
-.ck-content .image > img {
-	display: block;
-	margin: 0 auto;
-	max-width: 100%;
-	min-width: 50px;
-}
 /* ckeditor5-image/theme/imagestyle.css */
 .ck-content .image-style-side,
 .ck-content .image-style-align-left,
@@ -125,42 +122,6 @@ Below there is a full list of content styles used by the editor features. You ca
 	float: right;
 	margin-left: var(--ck-image-style-spacing);
 }
-/* ckeditor5-page-break/theme/pagebreak.css */
-.ck-content .page-break {
-	position: relative;
-	clear: both;
-	padding: 5px 0;
-	display: flex;
-	align-items: center;
-	justify-content: center;
-}
-/* ckeditor5-page-break/theme/pagebreak.css */
-.ck-content .page-break::after {
-	content: '';
-	position: absolute;
-	border-bottom: 2px dashed hsl(0, 0%, 77%);
-	width: 100%;
-}
-/* ckeditor5-page-break/theme/pagebreak.css */
-.ck-content .page-break__label {
-	position: relative;
-	z-index: 1;
-	padding: .3em .6em;
-	display: block;
-	text-transform: uppercase;
-	border: 1px solid hsl(0, 0%, 77%);
-	border-radius: 2px;
-	font-family: Helvetica, Arial, Tahoma, Verdana, Sans-Serif;
-	font-size: 0.75em;
-	font-weight: bold;
-	color: hsl(0, 0%, 20%);
-	background: #fff;
-	box-shadow: 2px 2px 1px hsla(0, 0%, 0%, 0.15);
-	-webkit-user-select: none;
-	-moz-user-select: none;
-	-ms-user-select: none;
-	user-select: none;
-}
 /* ckeditor5-block-quote/theme/blockquote.css */
 .ck-content blockquote {
 	overflow: hidden;
@@ -176,12 +137,46 @@ Below there is a full list of content styles used by the editor features. You ca
 	border-left: 0;
 	border-right: solid 5px hsl(0, 0%, 80%);
 }
-/* ckeditor5-media-embed/theme/mediaembed.css */
-.ck-content .media {
+/* ckeditor5-code-block/theme/codeblock.css */
+.ck-content pre {
+	padding: 1em;
+	color: hsl(0, 0%, 20.8%);
+	background: hsla(0, 0%, 78%, 0.3);
+	border: 1px solid hsl(0, 0%, 77%);
+	border-radius: 2px;
+	text-align: left;
+	direction: ltr;
+	tab-size: 4;
+	white-space: pre-wrap;
+	font-style: normal;
+	min-width: 200px;
+}
+/* ckeditor5-code-block/theme/codeblock.css */
+.ck-content pre code {
+	background: unset;
+	padding: 0;
+	border-radius: 0;
+}
+/* ckeditor5-horizontal-line/theme/horizontalline.css */
+.ck-content hr {
+	border-width: 1px 0 0;
+	border-style: solid;
+	border-color: hsl(0, 0%, 37%);
+	margin: 0;
+}
+/* ckeditor5-image/theme/image.css */
+.ck-content .image {
+	display: table;
 	clear: both;
-	margin: 1em 0;
+	text-align: center;
+	margin: 1em auto;
+}
+/* ckeditor5-image/theme/image.css */
+.ck-content .image > img {
 	display: block;
-	min-width: 15em;
+	margin: 0 auto;
+	max-width: 100%;
+	min-width: 50px;
 }
 /* ckeditor5-table/theme/table.css */
 .ck-content .table {
@@ -201,13 +196,28 @@ Below there is a full list of content styles used by the editor features. You ca
 .ck-content .table table th {
 	min-width: 2em;
 	padding: .4em;
-	border-color: hsl(0, 0%, 75%);
+	border: 1px solid hsl(0, 0%, 75%);
 }
 /* ckeditor5-table/theme/table.css */
 .ck-content .table table th {
 	font-weight: bold;
 	background: hsla(0, 0%, 0%, 5%);
 }
+/* ckeditor5-table/theme/table.css */
+.ck-content[dir="rtl"] .table th {
+	text-align: right;
+}
+/* ckeditor5-table/theme/table.css */
+.ck-content[dir="ltr"] .table th {
+	text-align: left;
+}
+/* ckeditor5-media-embed/theme/mediaembed.css */
+.ck-content .media {
+	clear: both;
+	margin: 1em 0;
+	display: block;
+	min-width: 15em;
+}
 /* ckeditor5-list/theme/todolist.css */
 .ck-content .todo-list {
 	list-style: none;
@@ -275,17 +285,6 @@ Below there is a full list of content styles used by the editor features. You ca
 .ck-content .todo-list .todo-list__label .todo-list__label__description {
 	vertical-align: middle;
 }
-/* ckeditor5-image/theme/imagecaption.css */
-.ck-content .image > figcaption {
-	display: table-caption;
-	caption-side: bottom;
-	word-break: break-word;
-	color: hsl(0, 0%, 20%);
-	background-color: hsl(0, 0%, 97%);
-	padding: .6em;
-	font-size: .75em;
-	outline-offset: -1px;
-}
 /* ckeditor5-highlight/theme/highlight.css */
 .ck-content .marker-yellow {
 	background-color: var(--ck-highlight-marker-yellow);
@@ -312,32 +311,57 @@ Below there is a full list of content styles used by the editor features. You ca
 	color: var(--ck-highlight-pen-green);
 	background-color: transparent;
 }
-/* ckeditor5-horizontal-line/theme/horizontalline.css */
-.ck-content hr {
-	border-width: 1px 0 0;
-	border-style: solid;
-	border-color: hsl(0, 0%, 37%);
-	margin: 0;
+/* ckeditor5-page-break/theme/pagebreak.css */
+.ck-content .page-break {
+	position: relative;
+	clear: both;
+	padding: 5px 0;
+	display: flex;
+	align-items: center;
+	justify-content: center;
 }
-/* ckeditor5-code-block/theme/codeblock.css */
-.ck-content pre {
-	padding: 1em;
-	color: #353535;
-	background: hsla(0, 0%, 78%, 0.3);
+/* ckeditor5-page-break/theme/pagebreak.css */
+.ck-content .page-break::after {
+	content: '';
+	position: absolute;
+	border-bottom: 2px dashed hsl(0, 0%, 77%);
+	width: 100%;
+}
+/* ckeditor5-page-break/theme/pagebreak.css */
+.ck-content .page-break__label {
+	position: relative;
+	z-index: 1;
+	padding: .3em .6em;
+	display: block;
+	text-transform: uppercase;
 	border: 1px solid hsl(0, 0%, 77%);
 	border-radius: 2px;
-	text-align: left;
-	direction: ltr;
-	tab-size: 4;
-	white-space: pre-wrap;
-	font-style: normal;
-	min-width: 200px;
+	font-family: Helvetica, Arial, Tahoma, Verdana, Sans-Serif;
+	font-size: 0.75em;
+	font-weight: bold;
+	color: hsl(0, 0%, 20%);
+	background: hsl(0, 0%, 100%);
+	box-shadow: 2px 2px 1px hsla(0, 0%, 0%, 0.15);
+	-webkit-user-select: none;
+	-moz-user-select: none;
+	-ms-user-select: none;
+	user-select: none;
 }
-/* ckeditor5-code-block/theme/codeblock.css */
-.ck-content pre code {
-	background: unset;
-	padding: 0;
-	border-radius: 0;
+/* ckeditor5-font/theme/fontsize.css */
+.ck-content .text-tiny {
+	font-size: .7em;
+}
+/* ckeditor5-font/theme/fontsize.css */
+.ck-content .text-small {
+	font-size: .85em;
+}
+/* ckeditor5-font/theme/fontsize.css */
+.ck-content .text-big {
+	font-size: 1.4em;
+}
+/* ckeditor5-font/theme/fontsize.css */
+.ck-content .text-huge {
+	font-size: 1.8em;
 }
 @media print {
 	/* ckeditor5-page-break/theme/pagebreak.css */

+ 1 - 1
packages/ckeditor5-autoformat/docs/features/autoformat.md

@@ -81,7 +81,7 @@ ClassicEditor
 
 ## Creating custom autoformatters
 
-The {@link module:autoformat/autoformat~Autoformat} feature bases on {@link module:autoformat/blockautoformatediting~BlockAutoformatEditing} and {@link module:autoformat/inlineautoformatediting~InlineAutoformatEditing} tools to create the autoformatters mentioned above.
+The {@link module:autoformat/autoformat~Autoformat} feature bases on {@link module:autoformat/blockautoformatediting~blockAutoformatEditing} and {@link module:autoformat/inlineautoformatediting~inlineAutoformatEditing} tools to create the autoformatters mentioned above.
 
 You can use these tools to create your own autoformatters. Check the [`Autoformat` feature's code](https://github.com/ckeditor/ckeditor5/blob/master/packages/ckeditor5-autoformat/src/autoformat.js) as an example.
 

+ 14 - 27
packages/ckeditor5-autoformat/src/autoformat.js

@@ -7,8 +7,8 @@
  * @module autoformat/autoformat
  */
 
-import BlockAutoformatEditing from './blockautoformatediting';
-import InlineAutoformatEditing from './inlineautoformatediting';
+import blockAutoformatEditing from './blockautoformatediting';
+import inlineAutoformatEditing from './inlineautoformatediting';
 import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
 
 /**
@@ -51,13 +51,11 @@ export default class Autoformat extends Plugin {
 		const commands = this.editor.commands;
 
 		if ( commands.get( 'bulletedList' ) ) {
-			// eslint-disable-next-line no-new
-			new BlockAutoformatEditing( this.editor, /^[*-]\s$/, 'bulletedList' );
+			blockAutoformatEditing( this.editor, this, /^[*-]\s$/, 'bulletedList' );
 		}
 
 		if ( commands.get( 'numberedList' ) ) {
-			// eslint-disable-next-line no-new
-			new BlockAutoformatEditing( this.editor, /^1[.|)]\s$/, 'numberedList' );
+			blockAutoformatEditing( this.editor, this, /^1[.|)]\s$/, 'numberedList' );
 		}
 	}
 
@@ -80,39 +78,31 @@ export default class Autoformat extends Plugin {
 		const commands = this.editor.commands;
 
 		if ( commands.get( 'bold' ) ) {
-			/* eslint-disable no-new */
 			const boldCallback = getCallbackFunctionForInlineAutoformat( this.editor, 'bold' );
 
-			new InlineAutoformatEditing( this.editor, /(\*\*)([^*]+)(\*\*)$/g, boldCallback );
-			new InlineAutoformatEditing( this.editor, /(__)([^_]+)(__)$/g, boldCallback );
-			/* eslint-enable no-new */
+			inlineAutoformatEditing( this.editor, this, /(\*\*)([^*]+)(\*\*)$/g, boldCallback );
+			inlineAutoformatEditing( this.editor, this, /(__)([^_]+)(__)$/g, boldCallback );
 		}
 
 		if ( commands.get( 'italic' ) ) {
-			/* eslint-disable no-new */
 			const italicCallback = getCallbackFunctionForInlineAutoformat( this.editor, 'italic' );
 
 			// The italic autoformatter cannot be triggered by the bold markers, so we need to check the
 			// text before the pattern (e.g. `(?:^|[^\*])`).
-			new InlineAutoformatEditing( this.editor, /(?:^|[^*])(\*)([^*_]+)(\*)$/g, italicCallback );
-			new InlineAutoformatEditing( this.editor, /(?:^|[^_])(_)([^_]+)(_)$/g, italicCallback );
-			/* eslint-enable no-new */
+			inlineAutoformatEditing( this.editor, this, /(?:^|[^*])(\*)([^*_]+)(\*)$/g, italicCallback );
+			inlineAutoformatEditing( this.editor, this, /(?:^|[^_])(_)([^_]+)(_)$/g, italicCallback );
 		}
 
 		if ( commands.get( 'code' ) ) {
-			/* eslint-disable no-new */
 			const codeCallback = getCallbackFunctionForInlineAutoformat( this.editor, 'code' );
 
-			new InlineAutoformatEditing( this.editor, /(`)([^`]+)(`)$/g, codeCallback );
-			/* eslint-enable no-new */
+			inlineAutoformatEditing( this.editor, this, /(`)([^`]+)(`)$/g, codeCallback );
 		}
 
 		if ( commands.get( 'strikethrough' ) ) {
-			/* eslint-disable no-new */
 			const strikethroughCallback = getCallbackFunctionForInlineAutoformat( this.editor, 'strikethrough' );
 
-			new InlineAutoformatEditing( this.editor, /(~~)([^~]+)(~~)$/g, strikethroughCallback );
-			/* eslint-enable no-new */
+			inlineAutoformatEditing( this.editor, this, /(~~)([^~]+)(~~)$/g, strikethroughCallback );
 		}
 	}
 
@@ -137,8 +127,7 @@ export default class Autoformat extends Plugin {
 					const level = commandValue[ 7 ];
 					const pattern = new RegExp( `^(#{${ level }})\\s$` );
 
-					// eslint-disable-next-line no-new
-					new BlockAutoformatEditing( this.editor, pattern, () => {
+					blockAutoformatEditing( this.editor, this, pattern, () => {
 						if ( !command.isEnabled ) {
 							return false;
 						}
@@ -159,8 +148,7 @@ export default class Autoformat extends Plugin {
 	 */
 	_addBlockQuoteAutoformats() {
 		if ( this.editor.commands.get( 'blockQuote' ) ) {
-			// eslint-disable-next-line no-new
-			new BlockAutoformatEditing( this.editor, /^>\s$/, 'blockQuote' );
+			blockAutoformatEditing( this.editor, this, /^>\s$/, 'blockQuote' );
 		}
 	}
 
@@ -174,13 +162,12 @@ export default class Autoformat extends Plugin {
 	 */
 	_addCodeBlockAutoformats() {
 		if ( this.editor.commands.get( 'codeBlock' ) ) {
-			// eslint-disable-next-line no-new
-			new BlockAutoformatEditing( this.editor, /^```$/, 'codeBlock' );
+			blockAutoformatEditing( this.editor, this, /^```$/, 'codeBlock' );
 		}
 	}
 }
 
-// Helper function for getting `InlineAutoformatEditing` callbacks that checks if command is enabled.
+// Helper function for getting `inlineAutoformatEditing` callbacks that checks if command is enabled.
 //
 // @param {module:core/editor/editor~Editor} editor
 // @param {String} attributeKey

+ 82 - 91
packages/ckeditor5-autoformat/src/blockautoformatediting.js

@@ -2,11 +2,6 @@
  * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
  * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
  */
-
-/**
- * @module autoformat/blockautoformatediting
- */
-
 import LiveRange from '@ckeditor/ckeditor5-engine/src/model/liverange';
 
 /**
@@ -16,108 +11,104 @@ import LiveRange from '@ckeditor/ckeditor5-engine/src/model/liverange';
  * The autoformatting operation is integrated with the undo manager,
  * so the autoformatting step can be undone if the user's intention was not to format the text.
  *
- * See the constructors documentation to learn how to create custom inline autoformatters. You can also use
+ * See the {@link module:autoformat/blockautoformatediting~blockAutoformatEditing `blockAutoformatEditing`} documentation
+ * to learn how to create custom block autoformatters. You can also use
  * the {@link module:autoformat/autoformat~Autoformat} feature which enables a set of default autoformatters
  * (lists, headings, bold and italic).
+ *
+ * @module autoformat/blockautoformatediting
  */
-export default class BlockAutoformatEditing {
-	/**
-	 * @inheritDoc
-	 */
-	static get pluginName() {
-		return 'BlockAutoformatEditing';
+
+/**
+ * Creates a listener triggered on {@link module:engine/model/document~Document#event:change:data `change:data`} event in the document.
+ * Calls the callback when inserted text matches the regular expression or the command name
+ * if provided instead of the callback.
+ *
+ * Examples of usage:
+ *
+ * To convert a paragraph to heading 1 when `- ` is typed, using just the command name:
+ *
+ *		blockAutoformatEditing( editor, plugin, /^\- $/, 'heading1' );
+ *
+ * To convert a paragraph to heading 1 when `- ` is typed, using just the callback:
+ *
+ *		blockAutoformatEditing( editor, plugin, /^\- $/, ( context ) => {
+ *			const { match } = context;
+ *			const headingLevel = match[ 1 ].length;
+ *
+ *			editor.execute( 'heading', {
+ *				formatId: `heading${ headingLevel }`
+ *			} );
+ * 		} );
+ *
+ * @param {module:core/editor/editor~Editor} editor The editor instance.
+ * @param {module:autoformat/autoformat~Autoformat} plugin The autoformat plugin instance.
+ * @param {RegExp} pattern The regular expression to execute on just inserted text.
+ * @param {Function|String} callbackOrCommand The callback to execute or the command to run when the text is matched.
+ * In case of providing the callback, it receives the following parameter:
+ * * {Object} match RegExp.exec() result of matching the pattern to inserted text.
+ */
+export default function blockAutoformatEditing( editor, plugin, pattern, callbackOrCommand ) {
+	let callback;
+	let command = null;
+
+	if ( typeof callbackOrCommand == 'function' ) {
+		callback = callbackOrCommand;
+	} else {
+		// We assume that the actual command name was provided.
+		command = editor.commands.get( callbackOrCommand );
+
+		callback = () => {
+			editor.execute( callbackOrCommand );
+		};
 	}
 
-	/**
-	 * Creates a listener triggered on `change` event in the document.
-	 * Calls the callback when inserted text matches the regular expression or the command name
-	 * if provided instead of the callback.
-	 *
-	 * Examples of usage:
-	 *
-	 * To convert a paragraph to heading 1 when `- ` is typed, using just the command name:
-	 *
-	 *		new BlockAutoformatEditing( editor, /^\- $/, 'heading1' );
-	 *
-	 * To convert a paragraph to heading 1 when `- ` is typed, using just the callback:
-	 *
-	 *		new BlockAutoformatEditing( editor, /^\- $/, ( context ) => {
-	 *			const { match } = context;
-	 *			const headingLevel = match[ 1 ].length;
-	 *
-	 *			editor.execute( 'heading', {
-	 *				formatId: `heading${ headingLevel }`
-	 *			} );
-	 * 		} );
-	 *
-	 * @param {module:core/editor/editor~Editor} editor The editor instance.
-	 * @param {RegExp} pattern The regular expression to execute on just inserted text.
-	 * @param {Function|String} callbackOrCommand The callback to execute or the command to run when the text is matched.
-	 * In case of providing the callback, it receives the following parameter:
-	 * * {Object} match RegExp.exec() result of matching the pattern to inserted text.
-	 */
-	constructor( editor, pattern, callbackOrCommand ) {
-		let callback;
-		let command = null;
-
-		if ( typeof callbackOrCommand == 'function' ) {
-			callback = callbackOrCommand;
-		} else {
-			// We assume that the actual command name was provided.
-			command = editor.commands.get( callbackOrCommand );
-
-			callback = () => {
-				editor.execute( callbackOrCommand );
-			};
+	editor.model.document.on( 'change:data', ( evt, batch ) => {
+		if ( command && !command.isEnabled || !plugin.isEnabled ) {
+			return;
 		}
 
-		editor.model.document.on( 'change', ( evt, batch ) => {
-			if ( command && !command.isEnabled ) {
-				return;
-			}
-
-			if ( batch.type == 'transparent' ) {
-				return;
-			}
+		if ( batch.type == 'transparent' ) {
+			return;
+		}
 
-			const changes = Array.from( editor.model.document.differ.getChanges() );
-			const entry = changes[ 0 ];
+		const changes = Array.from( editor.model.document.differ.getChanges() );
+		const entry = changes[ 0 ];
 
-			// Typing is represented by only a single change.
-			if ( changes.length != 1 || entry.type !== 'insert' || entry.name != '$text' || entry.length != 1 ) {
-				return;
-			}
+		// Typing is represented by only a single change.
+		if ( changes.length != 1 || entry.type !== 'insert' || entry.name != '$text' || entry.length != 1 ) {
+			return;
+		}
 
-			const blockToFormat = entry.position.parent;
+		const blockToFormat = entry.position.parent;
 
-			// Block formatting should trigger only if the entire content of a paragraph is a single text node... (see ckeditor5#5671).
-			if ( !blockToFormat.is( 'paragraph' ) || blockToFormat.childCount !== 1 ) {
-				return;
-			}
+		// Block formatting should trigger only if the entire content of a paragraph is a single text node... (see ckeditor5#5671).
+		if ( !blockToFormat.is( 'paragraph' ) || blockToFormat.childCount !== 1 ) {
+			return;
+		}
 
-			const match = pattern.exec( blockToFormat.getChild( 0 ).data );
+		const match = pattern.exec( blockToFormat.getChild( 0 ).data );
 
-			// ...and this text node's data match the pattern.
-			if ( !match ) {
-				return;
-			}
+		// ...and this text node's data match the pattern.
+		if ( !match ) {
+			return;
+		}
 
-			// Use enqueueChange to create new batch to separate typing batch from the auto-format changes.
-			editor.model.enqueueChange( writer => {
-				// Matched range.
-				const start = writer.createPositionAt( blockToFormat, 0 );
-				const end = writer.createPositionAt( blockToFormat, match[ 0 ].length );
-				const range = new LiveRange( start, end );
+		// Use enqueueChange to create new batch to separate typing batch from the auto-format changes.
+		editor.model.enqueueChange( writer => {
+			// Matched range.
+			const start = writer.createPositionAt( blockToFormat, 0 );
+			const end = writer.createPositionAt( blockToFormat, match[ 0 ].length );
+			const range = new LiveRange( start, end );
 
-				const wasChanged = callback( { match } );
+			const wasChanged = callback( { match } );
 
-				// Remove matched text.
-				if ( wasChanged !== false ) {
-					writer.remove( range );
-				}
+			// Remove matched text.
+			if ( wasChanged !== false ) {
+				writer.remove( range );
+			}
 
-				range.detach();
-			} );
+			range.detach();
 		} );
-	}
+	} );
 }

+ 165 - 175
packages/ckeditor5-autoformat/src/inlineautoformatediting.js

@@ -3,12 +3,6 @@
  * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
  */
 
-/**
- * @module autoformat/inlineautoformatediting
- */
-
-import getLastTextLine from '@ckeditor/ckeditor5-typing/src/utils/getlasttextline';
-
 /**
  * The inline autoformatting engine. It allows to format various inline patterns. For example,
  * it can be configured to make "foo" bold when typed `**foo**` (the `**` markers will be removed).
@@ -16,199 +10,171 @@ import getLastTextLine from '@ckeditor/ckeditor5-typing/src/utils/getlasttextlin
  * The autoformatting operation is integrated with the undo manager,
  * so the autoformatting step can be undone if the user's intention was not to format the text.
  *
- * See the constructors documentation to learn how to create custom inline autoformatters. You can also use
+ * See the {@link module:autoformat/inlineautoformatediting~inlineAutoformatEditing `inlineAutoformatEditing`} documentation
+ * to learn how to create custom inline autoformatters. You can also use
  * the {@link module:autoformat/autoformat~Autoformat} feature which enables a set of default autoformatters
  * (lists, headings, bold and italic).
+ *
+ * @module autoformat/inlineautoformatediting
  */
-export default class InlineAutoformatEditing {
-	/**
-	 * @inheritDoc
-	 */
-	static get pluginName() {
-		return 'InlineAutoformatEditing';
-	}
 
-	/**
-	 * Enables autoformatting mechanism for a given {@link module:core/editor/editor~Editor}.
-	 *
-	 * It formats the matched text by applying the given model attribute or by running the provided formatting callback.
-	 * On every change applied to the model the autoformatting engine checks the text on the left of the selection
-	 * and executes the provided action if the text matches given criteria (regular expression or callback).
-	 *
-	 * @param {module:core/editor/editor~Editor} editor The editor instance.
-	 * @param {Function|RegExp} testRegexpOrCallback The regular expression or callback to execute on text.
-	 * Provided regular expression *must* have three capture groups. The first and the third capture group
-	 * should match opening and closing delimiters. The second capture group should match the text to format.
-	 *
-	 *		// Matches the `**bold text**` pattern.
-	 *		// There are three capturing groups:
-	 *		// - The first to match the starting `**` delimiter.
-	 *		// - The second to match the text to format.
-	 *		// - The third to match the ending `**` delimiter.
-	 *		new InlineAutoformatEditing( editor, /(\*\*)([^\*]+?)(\*\*)$/g, 'bold' );
-	 *
-	 * When a function is provided instead of the regular expression, it will be executed with the text to match as a parameter.
-	 * The function should return proper "ranges" to delete and format.
-	 *
-	 *		{
-	 *			remove: [
-	 *				[ 0, 1 ],	// Remove the first letter from the given text.
-	 *				[ 5, 6 ]	// Remove the 6th letter from the given text.
-	 *			],
-	 *			format: [
-	 *				[ 1, 5 ]	// Format all letters from 2nd to 5th.
-	 *			]
-	 *		}
-	 *
-	 * @param {Function|String} attributeOrCallback The name of attribute to apply on matching text or a callback for manual
-	 * formatting. If callback is passed it should return `false` if changes should not be applied (e.g. if a command is disabled).
-	 *
-	 *		// Use attribute name:
-	 *		new InlineAutoformatEditing( editor, /(\*\*)([^\*]+?)(\*\*)$/g, 'bold' );
-	 *
-	 *		// Use formatting callback:
-	 *		new InlineAutoformatEditing( editor, /(\*\*)([^\*]+?)(\*\*)$/g, ( writer, rangesToFormat ) => {
-	 *			const command = editor.commands.get( 'bold' );
-	 *
-	 *			if ( !command.isEnabled ) {
-	 *				return false;
-	 *			}
-	 *
-	 *			const validRanges = editor.model.schema.getValidRanges( rangesToFormat, 'bold' );
-	 *
-	 *			for ( let range of validRanges ) {
-	 *				writer.setAttribute( 'bold', true, range );
-	 *			}
-	 *		} );
-	 */
-	constructor( editor, testRegexpOrCallback, attributeOrCallback ) {
-		let regExp;
-		let attributeKey;
-		let testCallback;
-		let formatCallback;
-
-		if ( testRegexpOrCallback instanceof RegExp ) {
-			regExp = testRegexpOrCallback;
-		} else {
-			testCallback = testRegexpOrCallback;
-		}
+/**
+ * Enables autoformatting mechanism for a given {@link module:core/editor/editor~Editor}.
+ *
+ * It formats the matched text by applying the given model attribute or by running the provided formatting callback.
+ * On every {@link module:engine/model/document~Document#event:change:data data change} in the model document
+ * the autoformatting engine checks the text on the left of the selection
+ * and executes the provided action if the text matches given criteria (regular expression or callback).
+ *
+ * @param {module:core/editor/editor~Editor} editor The editor instance.
+ * @param {module:autoformat/autoformat~Autoformat} plugin The autoformat plugin instance.
+ * @param {Function|RegExp} testRegexpOrCallback The regular expression or callback to execute on text.
+ * Provided regular expression *must* have three capture groups. The first and the third capture group
+ * should match opening and closing delimiters. The second capture group should match the text to format.
+ *
+ *		// Matches the `**bold text**` pattern.
+ *		// There are three capturing groups:
+ *		// - The first to match the starting `**` delimiter.
+ *		// - The second to match the text to format.
+ *		// - The third to match the ending `**` delimiter.
+ *		inlineAutoformatEditing( editor, plugin, /(\*\*)([^\*]+?)(\*\*)$/g, formatCallback );
+ *
+ * When a function is provided instead of the regular expression, it will be executed with the text to match as a parameter.
+ * The function should return proper "ranges" to delete and format.
+ *
+ *		{
+ *			remove: [
+ *				[ 0, 1 ],	// Remove the first letter from the given text.
+ *				[ 5, 6 ]	// Remove the 6th letter from the given text.
+ *			],
+ *			format: [
+ *				[ 1, 5 ]	// Format all letters from 2nd to 5th.
+ *			]
+ *		}
+ *
+ * @param {Function} formatCallback A callback to apply actual formatting.
+ * It should return `false` if changes should not be applied (e.g. if a command is disabled).
+ *
+ *		inlineAutoformatEditing( editor, plugin, /(\*\*)([^\*]+?)(\*\*)$/g, ( writer, rangesToFormat ) => {
+ *			const command = editor.commands.get( 'bold' );
+ *
+ *			if ( !command.isEnabled ) {
+ *				return false;
+ *			}
+ *
+ *			const validRanges = editor.model.schema.getValidRanges( rangesToFormat, 'bold' );
+ *
+ *			for ( let range of validRanges ) {
+ *				writer.setAttribute( 'bold', true, range );
+ *			}
+ *		} );
+ */
+export default function inlineAutoformatEditing( editor, plugin, testRegexpOrCallback, formatCallback ) {
+	let regExp;
+	let testCallback;
+
+	if ( testRegexpOrCallback instanceof RegExp ) {
+		regExp = testRegexpOrCallback;
+	} else {
+		testCallback = testRegexpOrCallback;
+	}
 
-		if ( typeof attributeOrCallback == 'string' ) {
-			attributeKey = attributeOrCallback;
-		} else {
-			formatCallback = attributeOrCallback;
-		}
+	// A test callback run on changed text.
+	testCallback = testCallback || ( text => {
+		let result;
+		const remove = [];
+		const format = [];
 
-		// A test callback run on changed text.
-		testCallback = testCallback || ( text => {
-			let result;
-			const remove = [];
-			const format = [];
-
-			while ( ( result = regExp.exec( text ) ) !== null ) {
-				// There should be full match and 3 capture groups.
-				if ( result && result.length < 4 ) {
-					break;
-				}
-
-				let {
-					index,
-					'1': leftDel,
-					'2': content,
-					'3': rightDel
-				} = result;
-
-				// Real matched string - there might be some non-capturing groups so we need to recalculate starting index.
-				const found = leftDel + content + rightDel;
-				index += result[ 0 ].length - found.length;
-
-				// Start and End offsets of delimiters to remove.
-				const delStart = [
-					index,
-					index + leftDel.length
-				];
-				const delEnd = [
-					index + leftDel.length + content.length,
-					index + leftDel.length + content.length + rightDel.length
-				];
-
-				remove.push( delStart );
-				remove.push( delEnd );
-
-				format.push( [ index + leftDel.length, index + leftDel.length + content.length ] );
+		while ( ( result = regExp.exec( text ) ) !== null ) {
+			// There should be full match and 3 capture groups.
+			if ( result && result.length < 4 ) {
+				break;
 			}
 
-			return {
-				remove,
-				format
-			};
-		} );
+			let {
+				index,
+				'1': leftDel,
+				'2': content,
+				'3': rightDel
+			} = result;
+
+			// Real matched string - there might be some non-capturing groups so we need to recalculate starting index.
+			const found = leftDel + content + rightDel;
+			index += result[ 0 ].length - found.length;
+
+			// Start and End offsets of delimiters to remove.
+			const delStart = [
+				index,
+				index + leftDel.length
+			];
+			const delEnd = [
+				index + leftDel.length + content.length,
+				index + leftDel.length + content.length + rightDel.length
+			];
+
+			remove.push( delStart );
+			remove.push( delEnd );
+
+			format.push( [ index + leftDel.length, index + leftDel.length + content.length ] );
+		}
 
-		// A format callback run on matched text.
-		formatCallback = formatCallback || ( ( writer, rangesToFormat ) => {
-			const validRanges = editor.model.schema.getValidRanges( rangesToFormat, attributeKey );
+		return {
+			remove,
+			format
+		};
+	} );
 
-			for ( const range of validRanges ) {
-				writer.setAttribute( attributeKey, true, range );
-			}
+	editor.model.document.on( 'change:data', ( evt, batch ) => {
+		if ( batch.type == 'transparent' || !plugin.isEnabled ) {
+			return;
+		}
 
-			// After applying attribute to the text, remove given attribute from the selection.
-			// This way user is able to type a text without attribute used by auto formatter.
-			writer.removeSelectionAttribute( attributeKey );
-		} );
+		const model = editor.model;
+		const selection = model.document.selection;
 
-		editor.model.document.on( 'change', ( evt, batch ) => {
-			if ( batch.type == 'transparent' ) {
-				return;
-			}
+		// Do nothing if selection is not collapsed.
+		if ( !selection.isCollapsed ) {
+			return;
+		}
 
-			const model = editor.model;
-			const selection = model.document.selection;
+		const changes = Array.from( model.document.differ.getChanges() );
+		const entry = changes[ 0 ];
 
-			// Do nothing if selection is not collapsed.
-			if ( !selection.isCollapsed ) {
-				return;
-			}
+		// Typing is represented by only a single change.
+		if ( changes.length != 1 || entry.type !== 'insert' || entry.name != '$text' || entry.length != 1 ) {
+			return;
+		}
 
-			const changes = Array.from( model.document.differ.getChanges() );
-			const entry = changes[ 0 ];
+		const focus = selection.focus;
+		const block = focus.parent;
+		const { text, range } = getTextAfterCode( model.createRange( model.createPositionAt( block, 0 ), focus ), model );
+		const testOutput = testCallback( text );
+		const rangesToFormat = testOutputToRanges( range.start, testOutput.format, model );
+		const rangesToRemove = testOutputToRanges( range.start, testOutput.remove, model );
 
-			// Typing is represented by only a single change.
-			if ( changes.length != 1 || entry.type !== 'insert' || entry.name != '$text' || entry.length != 1 ) {
-				return;
-			}
+		if ( !( rangesToFormat.length && rangesToRemove.length ) ) {
+			return;
+		}
 
-			const focus = selection.focus;
-			const block = focus.parent;
-			const { text, range } = getLastTextLine( model.createRange( model.createPositionAt( block, 0 ), focus ), model );
-			const testOutput = testCallback( text );
-			const rangesToFormat = testOutputToRanges( range.start, testOutput.format, model );
-			const rangesToRemove = testOutputToRanges( range.start, testOutput.remove, model );
+		// Use enqueueChange to create new batch to separate typing batch from the auto-format changes.
+		model.enqueueChange( writer => {
+			// Apply format.
+			const hasChanged = formatCallback( writer, rangesToFormat );
 
-			if ( !( rangesToFormat.length && rangesToRemove.length ) ) {
+			// Strict check on `false` to have backward compatibility (when callbacks were returning `undefined`).
+			if ( hasChanged === false ) {
 				return;
 			}
 
-			// Use enqueueChange to create new batch to separate typing batch from the auto-format changes.
-			model.enqueueChange( writer => {
-				// Apply format.
-				const hasChanged = formatCallback( writer, rangesToFormat );
-
-				// Strict check on `false` to have backward compatibility (when callbacks were returning `undefined`).
-				if ( hasChanged === false ) {
-					return;
-				}
-
-				// Remove delimiters - use reversed order to not mix the offsets while removing.
-				for ( const range of rangesToRemove.reverse() ) {
-					writer.remove( range );
-				}
-			} );
+			// Remove delimiters - use reversed order to not mix the offsets while removing.
+			for ( const range of rangesToRemove.reverse() ) {
+				writer.remove( range );
+			}
 		} );
-	}
+	} );
 }
 
-// Converts output of the test function provided to the InlineAutoformatEditing and converts it to the model ranges
+// Converts output of the test function provided to the inlineAutoformatEditing and converts it to the model ranges
 // inside provided block.
 //
 // @private
@@ -222,3 +188,27 @@ function testOutputToRanges( start, arrays, model ) {
 			return model.createRange( start.getShiftedBy( array[ 0 ] ), start.getShiftedBy( array[ 1 ] ) );
 		} );
 }
+
+// Returns the last text line after the last code element from the given range.
+// It is similar to {@link module:typing/utils/getlasttextline.getLastTextLine `getLastTextLine()`},
+// but it ignores any text before the last `code`.
+//
+// @param {module:engine/model/range~Range} range
+// @param {module:engine/model/model~Model} model
+// @returns {module:typing/utils/getlasttextline~LastTextLineData}
+function getTextAfterCode( range, model ) {
+	let start = range.start;
+
+	const text = Array.from( range.getItems() ).reduce( ( rangeText, node ) => {
+		// Trim text to a last occurrence of an inline element and update range start.
+		if ( !( node.is( 'text' ) || node.is( 'textProxy' ) ) || node.getAttribute( 'code' ) ) {
+			start = model.createPositionAfter( node );
+
+			return '';
+		}
+
+		return rangeText + node.data;
+	}, '' );
+
+	return { text, range: model.createRange( start, range.end ) };
+}

+ 146 - 0
packages/ckeditor5-autoformat/tests/autoformat.js

@@ -410,6 +410,152 @@ describe( 'Autoformat', () => {
 			expect( getData( model ) ).to.equal( '<paragraph>**foobar**[]</paragraph>' );
 		} );
 
+		it( 'should not format if the plugin is disabled', () => {
+			editor.plugins.get( 'Autoformat' ).forceDisabled( 'Test' );
+
+			setData( model, '<paragraph>**foobar*[]</paragraph>' );
+
+			model.change( writer => {
+				writer.insertText( '*', doc.selection.getFirstPosition() );
+			} );
+
+			expect( getData( model ) ).to.equal( '<paragraph>**foobar**[]</paragraph>' );
+		} );
+
+		describe( 'with code element', () => {
+			describe( 'should not format', () => {
+				it( '* inside', () => {
+					setData( model, '<paragraph><$text code="true">fo*obar[]</$text></paragraph>' );
+
+					model.change( writer => {
+						writer.insertText( '*', { code: true }, doc.selection.getFirstPosition() );
+					} );
+
+					expect( getData( model ) ).to
+						.equal( '<paragraph><$text code="true">fo*obar*[]</$text></paragraph>' );
+				} );
+
+				it( '__ inside', () => {
+					setData( model, '<paragraph><$text code="true">fo__obar_[]</$text></paragraph>' );
+
+					model.change( writer => {
+						writer.insertText( '_', { code: true }, doc.selection.getFirstPosition() );
+					} );
+
+					expect( getData( model ) ).to
+						.equal( '<paragraph><$text code="true">fo__obar__[]</$text></paragraph>' );
+				} );
+
+				it( '~~ inside', () => {
+					setData( model, '<paragraph><$text code="true">fo~~obar~[]</$text></paragraph>' );
+
+					model.change( writer => {
+						writer.insertText( '~', { code: true }, doc.selection.getFirstPosition() );
+					} );
+
+					expect( getData( model ) ).to
+						.equal( '<paragraph><$text code="true">fo~~obar~~[]</$text></paragraph>' );
+				} );
+
+				it( '` inside', () => {
+					setData( model, '<paragraph><$text code="true">fo`obar[]</$text></paragraph>' );
+
+					model.change( writer => {
+						writer.insertText( '`', { code: true }, doc.selection.getFirstPosition() );
+					} );
+
+					expect( getData( model ) ).to
+						.equal( '<paragraph><$text code="true">fo`obar`[]</$text></paragraph>' );
+				} );
+			} );
+
+			describe( 'should not format', () => {
+				it( '* across', () => {
+					setData( model, '<paragraph><$text code="true">fo*o</$text>bar[]</paragraph>' );
+
+					model.change( writer => {
+						writer.insertText( '*', doc.selection.getFirstPosition() );
+					} );
+
+					expect( getData( model ) ).to
+						.equal( '<paragraph><$text code="true">fo*o</$text>bar*[]</paragraph>' );
+				} );
+				it( '__ across', () => {
+					setData( model, '<paragraph><$text code="true">fo__o</$text>bar_[]</paragraph>' );
+
+					model.change( writer => {
+						writer.insertText( '_', doc.selection.getFirstPosition() );
+					} );
+
+					expect( getData( model ) ).to
+						.equal( '<paragraph><$text code="true">fo__o</$text>bar__[]</paragraph>' );
+				} );
+				it( '~~ across', () => {
+					setData( model, '<paragraph><$text code="true">fo~~o</$text>bar~[]</paragraph>' );
+
+					model.change( writer => {
+						writer.insertText( '~', doc.selection.getFirstPosition() );
+					} );
+
+					expect( getData( model ) ).to
+						.equal( '<paragraph><$text code="true">fo~~o</$text>bar~~[]</paragraph>' );
+				} );
+				it( '` across', () => {
+					setData( model, '<paragraph><$text code="true">fo`o</$text>bar[]</paragraph>' );
+
+					model.change( writer => {
+						writer.insertText( '`', doc.selection.getFirstPosition() );
+					} );
+
+					expect( getData( model ) ).to
+						.equal( '<paragraph><$text code="true">fo`o</$text>bar`[]</paragraph>' );
+				} );
+			} );
+
+			describe( 'should format', () => {
+				it( '* after', () => {
+					setData( model, '<paragraph><$text code="true">fo*o</$text>b*ar[]</paragraph>' );
+
+					model.change( writer => {
+						writer.insertText( '*', doc.selection.getFirstPosition() );
+					} );
+
+					expect( getData( model ) ).to
+						.equal( '<paragraph><$text code="true">fo*o</$text>b<$text italic="true">ar</$text>[]</paragraph>' );
+				} );
+				it( '__ after', () => {
+					setData( model, '<paragraph><$text code="true">fo__o</$text>b__ar_[]</paragraph>' );
+
+					model.change( writer => {
+						writer.insertText( '_', doc.selection.getFirstPosition() );
+					} );
+
+					expect( getData( model ) ).to
+						.equal( '<paragraph><$text code="true">fo__o</$text>b<$text bold="true">ar</$text>[]</paragraph>' );
+				} );
+				it( '~~ after', () => {
+					setData( model, '<paragraph><$text code="true">fo~~o</$text>b~~ar~[]</paragraph>' );
+
+					model.change( writer => {
+						writer.insertText( '~', doc.selection.getFirstPosition() );
+					} );
+
+					expect( getData( model ) ).to
+						.equal( '<paragraph><$text code="true">fo~~o</$text>b<$text strikethrough="true">ar</$text>[]</paragraph>' );
+				} );
+				it( '` after', () => {
+					setData( model, '<paragraph><$text code="true">fo`o</$text>b`ar[]</paragraph>' );
+
+					model.change( writer => {
+						writer.insertText( '`', doc.selection.getFirstPosition() );
+					} );
+
+					expect( getData( model ) ).to
+						.equal( '<paragraph><$text code="true">fo`o</$text>b<$text code="true">ar</$text>[]</paragraph>' );
+				} );
+			} );
+		} );
+
 		it( 'should work with <softBreak>s in paragraph', () => {
 			setData( model, '<paragraph>foo<softBreak></softBreak>**barbaz*[]</paragraph>' );
 			model.change( writer => {

+ 28 - 17
packages/ckeditor5-autoformat/tests/blockautoformatediting.js

@@ -4,7 +4,7 @@
  */
 
 import Autoformat from '../src/autoformat';
-import BlockAutoformatEditing from '../src/blockautoformatediting';
+import blockAutoformatEditing from '../src/blockautoformatediting';
 import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
 import VirtualTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/virtualtesteditor';
 import Enter from '@ckeditor/ckeditor5-enter/src/enter';
@@ -12,8 +12,8 @@ import { setData, getData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model
 import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils';
 import Command from '@ckeditor/ckeditor5-core/src/command';
 
-describe( 'BlockAutoformatEditing', () => {
-	let editor, model, doc;
+describe( 'blockAutoformatEditing', () => {
+	let editor, model, doc, plugin;
 
 	testUtils.createSinonSandbox();
 
@@ -26,13 +26,10 @@ describe( 'BlockAutoformatEditing', () => {
 				editor = newEditor;
 				model = editor.model;
 				doc = model.document;
+				plugin = editor.plugins.get( 'Autoformat' );
 			} );
 	} );
 
-	it( 'should have pluginName', () => {
-		expect( BlockAutoformatEditing.pluginName ).to.equal( 'BlockAutoformatEditing' );
-	} );
-
 	describe( 'command name', () => {
 		it( 'should run a command when the pattern is matched', () => {
 			const spy = testUtils.sinon.spy();
@@ -40,7 +37,7 @@ describe( 'BlockAutoformatEditing', () => {
 
 			editor.commands.add( 'testCommand', testCommand );
 
-			new BlockAutoformatEditing( editor, /^[*]\s$/, 'testCommand' ); // eslint-disable-line no-new
+			blockAutoformatEditing( editor, plugin, /^[*]\s$/, 'testCommand' );
 
 			setData( model, '<paragraph>*[]</paragraph>' );
 
@@ -57,7 +54,7 @@ describe( 'BlockAutoformatEditing', () => {
 
 			editor.commands.add( 'testCommand', testCommand );
 
-			new BlockAutoformatEditing( editor, /^[*]\s$/, 'testCommand' ); // eslint-disable-line no-new
+			blockAutoformatEditing( editor, plugin, /^[*]\s$/, 'testCommand' );
 
 			setData( model, '<paragraph>*[]</paragraph>' );
 
@@ -79,7 +76,7 @@ describe( 'BlockAutoformatEditing', () => {
 
 			editor.commands.add( 'testCommand', testCommand );
 
-			new BlockAutoformatEditing( editor, /^[*]\s$/, 'testCommand' ); // eslint-disable-line no-new
+			blockAutoformatEditing( editor, plugin, /^[*]\s$/, 'testCommand' );
 
 			setData( model, '<paragraph>*[]</paragraph>' );
 
@@ -94,7 +91,7 @@ describe( 'BlockAutoformatEditing', () => {
 	describe( 'callback', () => {
 		it( 'should run callback when the pattern is matched', () => {
 			const spy = testUtils.sinon.spy();
-			new BlockAutoformatEditing( editor, /^[*]\s$/, spy ); // eslint-disable-line no-new
+			blockAutoformatEditing( editor, plugin, /^[*]\s$/, spy );
 
 			setData( model, '<paragraph>*[]</paragraph>' );
 			model.change( writer => {
@@ -104,9 +101,23 @@ describe( 'BlockAutoformatEditing', () => {
 			sinon.assert.calledOnce( spy );
 		} );
 
+		it( 'should not call the callback when the pattern is matched but the plugin is disabled', () => {
+			const callbackSpy = testUtils.sinon.spy().named( 'callback' );
+			blockAutoformatEditing( editor, plugin, /^[*]\s$/, callbackSpy );
+
+			plugin.isEnabled = false;
+
+			setData( model, '<paragraph>*[]</paragraph>' );
+			model.change( writer => {
+				writer.insertText( ' ', doc.selection.getFirstPosition() );
+			} );
+
+			sinon.assert.notCalled( callbackSpy );
+		} );
+
 		it( 'should ignore other delta operations', () => {
 			const spy = testUtils.sinon.spy();
-			new BlockAutoformatEditing( editor, /^[*]\s/, spy ); // eslint-disable-line no-new
+			blockAutoformatEditing( editor, plugin, /^[*]\s/, spy );
 
 			setData( model, '<paragraph>*[]</paragraph>' );
 			model.change( writer => {
@@ -118,7 +129,7 @@ describe( 'BlockAutoformatEditing', () => {
 
 		it( 'should stop if there is no text to run matching on', () => {
 			const spy = testUtils.sinon.spy();
-			new BlockAutoformatEditing( editor, /^[*]\s/, spy ); // eslint-disable-line no-new
+			blockAutoformatEditing( editor, plugin, /^[*]\s/, spy );
 
 			setData( model, '<paragraph>[]</paragraph>' );
 			model.change( writer => {
@@ -146,7 +157,7 @@ describe( 'BlockAutoformatEditing', () => {
 				} );
 
 			const spy = testUtils.sinon.spy();
-			new BlockAutoformatEditing( editor, /^[*]\s/, spy ); // eslint-disable-line no-new
+			blockAutoformatEditing( editor, plugin, /^[*]\s/, spy );
 
 			setData( model, '<paragraph>*<softBreak></softBreak>[]</paragraph>' );
 			model.change( writer => {
@@ -174,7 +185,7 @@ describe( 'BlockAutoformatEditing', () => {
 				} );
 
 			const spy = testUtils.sinon.spy();
-			new BlockAutoformatEditing( editor, /^[*]\s/, spy ); // eslint-disable-line no-new
+			blockAutoformatEditing( editor, plugin, /^[*]\s/, spy );
 
 			setData( model, '<paragraph>* <softBreak></softBreak>[]</paragraph>' );
 
@@ -186,7 +197,7 @@ describe( 'BlockAutoformatEditing', () => {
 		} );
 
 		it( 'should stop if callback returned false', () => {
-			new BlockAutoformatEditing( editor, /^[*]\s$/, () => false ); // eslint-disable-line no-new
+			blockAutoformatEditing( editor, plugin, /^[*]\s$/, () => false );
 
 			setData( model, '<paragraph>*[]</paragraph>' );
 			model.change( writer => {
@@ -199,7 +210,7 @@ describe( 'BlockAutoformatEditing', () => {
 
 	it( 'should ignore transparent batches', () => {
 		const spy = testUtils.sinon.spy();
-		new BlockAutoformatEditing( editor, /^[*]\s$/, spy ); // eslint-disable-line no-new
+		blockAutoformatEditing( editor, plugin, /^[*]\s$/, spy );
 
 		setData( model, '<paragraph>*[]</paragraph>' );
 		model.enqueueChange( 'transparent', writer => {

+ 35 - 25
packages/ckeditor5-autoformat/tests/inlineautoformatediting.js

@@ -4,19 +4,21 @@
  */
 
 import Autoformat from '../src/autoformat';
-import InlineAutoformatEditing from '../src/inlineautoformatediting';
+import inlineAutoformatEditing from '../src/inlineautoformatediting';
 import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
 import VirtualTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/virtualtesteditor';
 import Enter from '@ckeditor/ckeditor5-enter/src/enter';
 import { setData, getData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
 import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils';
 
-describe( 'InlineAutoformatEditing', () => {
-	let editor, model, doc;
+describe( 'inlineAutoformatEditing', () => {
+	let editor, model, doc, plugin, formatSpy;
 
 	testUtils.createSinonSandbox();
 
 	beforeEach( () => {
+		formatSpy = testUtils.sinon.spy().named( 'formatCallback' );
+
 		return VirtualTestEditor
 			.create( {
 				plugins: [ Enter, Paragraph, Autoformat ]
@@ -25,59 +27,55 @@ describe( 'InlineAutoformatEditing', () => {
 				editor = newEditor;
 				model = editor.model;
 				doc = model.document;
+				plugin = editor.plugins.get( 'Autoformat' );
 
 				model.schema.extend( '$text', { allowAttributes: 'testAttribute' } );
 			} );
 	} );
 
-	it( 'should have pluginName', () => {
-		expect( InlineAutoformatEditing.pluginName ).to.equal( 'InlineAutoformatEditing' );
-	} );
-
-	describe( 'attribute', () => {
-		it( 'should stop early if there are less than 3 capture groups', () => {
-			new InlineAutoformatEditing( editor, /(\*)(.+?)\*/g, 'testAttribute' ); // eslint-disable-line no-new
+	describe( 'regExp', () => {
+		it( 'should not call the formatCallback if there are less than 3 capture groups', () => {
+			inlineAutoformatEditing( editor, plugin, /(\*)(.+?)\*/g, formatSpy );
 
 			setData( model, '<paragraph>*foobar[]</paragraph>' );
 			model.change( writer => {
 				writer.insertText( '*', doc.selection.getFirstPosition() );
 			} );
 
-			expect( getData( model ) ).to.equal( '<paragraph>*foobar*[]</paragraph>' );
+			sinon.assert.notCalled( formatSpy );
 		} );
 
-		it( 'should apply an attribute when the pattern is matched', () => {
-			new InlineAutoformatEditing( editor, /(\*)(.+?)(\*)/g, 'testAttribute' ); // eslint-disable-line no-new
+		it( 'should call the formatCallback when the pattern is matched', () => {
+			inlineAutoformatEditing( editor, plugin, /(\*)(.+?)(\*)/g, formatSpy );
 
 			setData( model, '<paragraph>*foobar[]</paragraph>' );
 			model.change( writer => {
 				writer.insertText( '*', doc.selection.getFirstPosition() );
 			} );
 
-			expect( getData( model ) ).to.equal( '<paragraph><$text testAttribute="true">foobar</$text>[]</paragraph>' );
+			sinon.assert.calledOnce( formatSpy );
 		} );
 
-		it( 'should stop early if selection is not collapsed', () => {
-			new InlineAutoformatEditing( editor, /(\*)(.+?)\*/g, 'testAttribute' ); // eslint-disable-line no-new
+		it( 'should not call the formatCallback if the selection is not collapsed', () => {
+			inlineAutoformatEditing( editor, plugin, /(\*)(.+?)\*/g, formatSpy );
 
 			setData( model, '<paragraph>*foob[ar]</paragraph>' );
 			model.change( writer => {
 				writer.insertText( '*', doc.selection.getFirstPosition() );
 			} );
 
-			expect( getData( model ) ).to.equal( '<paragraph>*foob*[ar]</paragraph>' );
+			sinon.assert.notCalled( formatSpy );
 		} );
 	} );
 
 	describe( 'callback', () => {
 		it( 'should stop when there are no format ranges returned from testCallback', () => {
-			const formatSpy = testUtils.sinon.spy();
 			const testStub = testUtils.sinon.stub().returns( {
 				format: [ [] ],
 				remove: []
 			} );
 
-			new InlineAutoformatEditing( editor, testStub, formatSpy ); // eslint-disable-line no-new
+			inlineAutoformatEditing( editor, plugin, testStub, formatSpy );
 
 			setData( model, '<paragraph>*[]</paragraph>' );
 			model.change( writer => {
@@ -88,13 +86,12 @@ describe( 'InlineAutoformatEditing', () => {
 		} );
 
 		it( 'should stop when there are no remove ranges returned from testCallback', () => {
-			const formatSpy = testUtils.sinon.spy();
 			const testStub = testUtils.sinon.stub().returns( {
 				format: [],
 				remove: [ [] ]
 			} );
 
-			new InlineAutoformatEditing( editor, testStub, formatSpy ); // eslint-disable-line no-new
+			inlineAutoformatEditing( editor, plugin, testStub, formatSpy );
 
 			setData( model, '<paragraph>*[]</paragraph>' );
 			model.change( writer => {
@@ -105,13 +102,12 @@ describe( 'InlineAutoformatEditing', () => {
 		} );
 
 		it( 'should stop early when there is no text', () => {
-			const formatSpy = testUtils.sinon.spy();
 			const testStub = testUtils.sinon.stub().returns( {
 				format: [],
 				remove: [ [] ]
 			} );
 
-			new InlineAutoformatEditing( editor, testStub, formatSpy ); // eslint-disable-line no-new
+			inlineAutoformatEditing( editor, plugin, testStub, formatSpy );
 
 			setData( model, '<paragraph>[]</paragraph>' );
 			model.change( writer => {
@@ -121,6 +117,19 @@ describe( 'InlineAutoformatEditing', () => {
 			sinon.assert.notCalled( formatSpy );
 		} );
 
+		it( 'should not run the formatCallback when the pattern is matched but the plugin is disabled', () => {
+			inlineAutoformatEditing( editor, plugin, /(\*)(.+?)(\*)/g, formatSpy );
+
+			plugin.isEnabled = false;
+
+			setData( model, '<paragraph>*foobar[]</paragraph>' );
+			model.change( writer => {
+				writer.insertText( '*', doc.selection.getFirstPosition() );
+			} );
+
+			sinon.assert.notCalled( formatSpy );
+		} );
+
 		it( 'should not autoformat if callback returned false', () => {
 			setData( model, '<paragraph>Foobar[]</paragraph>' );
 
@@ -133,7 +142,7 @@ describe( 'InlineAutoformatEditing', () => {
 
 			const formatCallback = () => false;
 
-			new InlineAutoformatEditing( editor, testCallback, formatCallback ); // eslint-disable-line no-new
+			inlineAutoformatEditing( editor, plugin, testCallback, formatCallback );
 
 			model.change( writer => {
 				writer.insertText( ' ', doc.selection.getFirstPosition() );
@@ -144,13 +153,14 @@ describe( 'InlineAutoformatEditing', () => {
 	} );
 
 	it( 'should ignore transparent batches', () => {
-		new InlineAutoformatEditing( editor, /(\*)(.+?)(\*)/g, 'testAttribute' ); // eslint-disable-line no-new
+		inlineAutoformatEditing( editor, plugin, /(\*)(.+?)(\*)/g, formatSpy );
 
 		setData( model, '<paragraph>*foobar[]</paragraph>' );
 		model.enqueueChange( 'transparent', writer => {
 			writer.insertText( '*', doc.selection.getFirstPosition() );
 		} );
 
+		sinon.assert.notCalled( formatSpy );
 		expect( getData( model ) ).to.equal( '<paragraph>*foobar*[]</paragraph>' );
 	} );
 } );

+ 1 - 1
packages/ckeditor5-autoformat/tests/manual/autoformat.html

@@ -1,3 +1,3 @@
 <div id="editor">
-	<p>This is the editor instance.</p>
+	<p>This is the editor instance, with a <code>code_element*2;</code> inside.</p>
 </div>

+ 1 - 1
packages/ckeditor5-build-decoupled-document/tests/ckeditor.js

@@ -200,7 +200,7 @@ describe( 'DecoupledEditor build', () => {
 
 				editor.setData( data );
 				expect( editor.getData() ).to.equal( data );
-				expect( editor.model.document.selection.getAttribute( 'fontFamily' ) ).to.equal( 'Georgia' );
+				expect( editor.model.document.selection.getAttribute( 'fontFamily' ) ).to.equal( 'Georgia, serif' );
 			} );
 
 			it( 'font background color works', () => {

+ 8 - 8
packages/ckeditor5-engine/docs/framework/guides/deep-dive/schema.md

@@ -46,7 +46,7 @@ While this would be incorrect:
 
 ## Defining additional semantics
 
-In addition to setting allowed structures, the schema can also define additional traits of model elements. By using the `is*` properties a feature author may declare how a certain element should be treated by other features and the engine.
+In addition to setting allowed structures, the schema can also define additional traits of model elements. By using the `is*` properties, a feature author may declare how a certain element should be treated by other features and the engine.
 
 ### Limit elements
 
@@ -55,9 +55,9 @@ Consider a feature like an image caption. The caption text area should construct
 * A selection that starts inside should not end outside.
 * Pressing <kbd>Backspace</kbd> or <kbd>Delete</kbd> should not delete the area. Pressing <kbd>Enter</kbd> should not split the area.
 
-It should also act as a boundary for external actions. This is mostly enforced by a selection post-fixer that ensures that a selection that starts outside, should not end inside. That means that most actions will either apply to the "outside" of such an element or to a content inside it.
+It should also act as a boundary for external actions. This is mostly enforced by a selection post-fixer that ensures that a selection that starts outside, should not end inside. It means that most actions will either apply to the "outside" of such an element or to the content inside it.
 
-Taken these characteristics, the image caption should be defined as limit element by using the {@link module:engine/model/schema~SchemaItemDefinition#isLimit `isLimit`} property.
+Taken these characteristics, the image caption should be defined as a limit element by using the {@link module:engine/model/schema~SchemaItemDefinition#isLimit `isLimit`} property.
 
 ```js
 schema.register( 'myCaption', {
@@ -73,9 +73,9 @@ The engine and various features then check it via {@link module:engine/model/sch
 
 ### Object elements
 
-For the image caption as in the example above it does not make much sense to select the caption box, then copy or drag it somewhere else.
+For an image caption like in the example above it does not make much sense to select the caption box, then copy or drag it somewhere else.
 
-A caption without the image that it describes does not make much sense. However, the image is more self-sufficient. Most likely users should be able to select the entire image (with all its internals), then copy or move it around. {@link module:engine/model/schema~SchemaItemDefinition#isObject `isObject`} should be used to mark such behavior.
+A caption without the image that it describes makes little sense. However, the image is more self-sufficient. Most likely users should be able to select the entire image (with all its internals), then copy or move it around. The {@link module:engine/model/schema~SchemaItemDefinition#isObject `isObject`} property should be used to mark such behavior.
 
 ```js
 schema.register( 'myImage', {
@@ -95,7 +95,7 @@ The {@link module:engine/model/schema~Schema#isObject `Schema#isObject()`} can l
 
 Generally speaking, content is usually made out of blocks like paragraphs, list items, images, headings, etc. All these elements should be marked as blocks by using {@link module:engine/model/schema~SchemaItemDefinition#isBlock `isBlock`}.
 
-It is important to remember that a block should not allow another block inside. Container elements like `<blockQuote>` which can contain other block elements should not be marked as blocks.
+It is important to remember that a block should not allow another block inside. Container elements like `<blockQuote>`, which can contain other block elements, should not be marked as blocks.
 
 <info-box>
 	There is also the `$block` generic item which has `isBlock` set to `true`. Most block type items will inherit from `$block` (through `inheritAllFrom`).
@@ -103,11 +103,11 @@ It is important to remember that a block should not allow another block inside.
 
 ### Inline elements
 
-In the editor, all HTML formatting elements such as `<strong>` or `<code>` are represented by text attributes. Therefore, inline model elements are not to be used for this scenarios.
+In the editor, all HTML formatting elements such as `<strong>` or `<code>` are represented by text attributes. Therefore, inline model elements are not supposed to be used for these scenarios.
 
 Currently, the {@link module:engine/model/schema~SchemaItemDefinition#isInline `isInline`} property is used for the `$text` token (so, text nodes) and elements such as `<softBreak>` or placeholder elements such as in the {@link framework/guides/tutorials/implementing-an-inline-widget Implementing an inline widget} tutorial.
 
-The support for inline elements in CKEditor 5 is so far limited to self-contained elements. This is &mdash; all elements marked with `isInline` should also be marked with `isObject`.
+The support for inline elements in CKEditor 5 is so far limited to self-contained elements. Because of this, all elements marked with `isInline` should also be marked with `isObject`.
 
 ## Generic items
 

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

@@ -26,9 +26,9 @@ import TreeWalker from './treewalker';
  *
  * Read more about the schema in:
  *
- * * {@glink framework/guides/architecture/editing-engine#schema "Schema"} section of the
- * {@glink framework/guides/architecture/editing-engine Introduction to the "Editing engine architecture"}.
- * * {@glink framework/guides/deep-dive/schema "Schema" deep dive} guide.
+ * * {@glink framework/guides/architecture/editing-engine#schema Schema} section of the
+ * {@glink framework/guides/architecture/editing-engine Introduction to the Editing engine architecture}.
+ * * {@glink framework/guides/deep-dive/schema Schema deep dive} guide.
  *
  * @mixes module:utils/observablemixin~ObservableMixin
  */
@@ -171,7 +171,7 @@ export default class Schema {
 	}
 
 	/**
-	 * Returns a definition of the given item or `undefined` if item is not registered.
+	 * Returns a definition of the given item or `undefined` if an item is not registered.
 	 *
 	 * This method should normally be used for reflection purposes (e.g. defining a clone of a certain element,
 	 * checking a list of all block elements, etc).
@@ -212,7 +212,7 @@ export default class Schema {
 
 	/**
 	 * Returns `true` if the given item is defined to be
-	 * a block by {@link module:engine/model/schema~SchemaItemDefinition}'s `isBlock` property.
+	 * a block by the {@link module:engine/model/schema~SchemaItemDefinition}'s `isBlock` property.
 	 *
 	 *		schema.isBlock( 'paragraph' ); // -> true
 	 *		schema.isBlock( '$root' ); // -> false
@@ -220,7 +220,7 @@ export default class Schema {
 	 *		const paragraphElement = writer.createElement( 'paragraph' );
 	 *		schema.isBlock( paragraphElement ); // -> true
 	 *
-	 * See the {@glink framework/guides/deep-dive/schema#block-elements Block elements} section of the "Schema" deep dive}
+	 * See the {@glink framework/guides/deep-dive/schema#block-elements Block elements} section of the Schema deep dive}
 	 * guide for more details.
 	 *
 	 * @param {module:engine/model/item~Item|module:engine/model/schema~SchemaContextItem|String} item
@@ -234,18 +234,18 @@ export default class Schema {
 	/**
 	 * Returns `true` if the given item should be treated as a limit element.
 	 *
-	 * It considers the item to be a limit element if its
+	 * It considers an item to be a limit element if its
 	 * {@link module:engine/model/schema~SchemaItemDefinition}'s
 	 * {@link module:engine/model/schema~SchemaItemDefinition#isLimit `isLimit`} or
 	 * {@link module:engine/model/schema~SchemaItemDefinition#isObject `isObject`} property
-	 * were set to `true`.
+	 * was set to `true`.
 	 *
 	 *		schema.isLimit( 'paragraph' ); // -> false
 	 *		schema.isLimit( '$root' ); // -> true
 	 *		schema.isLimit( editor.model.document.getRoot() ); // -> true
 	 *		schema.isLimit( 'image' ); // -> true
 	 *
-	 * See the {@glink framework/guides/deep-dive/schema#limit-elements Limit elements} section of the "Schema" deep dive}
+	 * See the {@glink framework/guides/deep-dive/schema#limit-elements Limit elements} section of the Schema deep dive}
 	 * guide for more details.
 	 *
 	 * @param {module:engine/model/item~Item|module:engine/model/schema~SchemaContextItem|String} item
@@ -261,12 +261,12 @@ export default class Schema {
 	}
 
 	/**
-	 * Returns `true` if the given item is should be treated as an object element.
+	 * Returns `true` if the given item should be treated as an object element.
 	 *
-	 * It considers the item to be an object element if its
+	 * It considers an item to be an object element if its
 	 * {@link module:engine/model/schema~SchemaItemDefinition}'s
 	 * {@link module:engine/model/schema~SchemaItemDefinition#isObject `isObject`} property
-	 * were set to `true`.
+	 * was set to `true`.
 	 *
 	 *		schema.isObject( 'paragraph' ); // -> false
 	 *		schema.isObject( 'image' ); // -> true
@@ -274,7 +274,7 @@ export default class Schema {
 	 *		const imageElement = writer.createElement( 'image' );
 	 *		schema.isObject( imageElement ); // -> true
 	 *
-	 * See the {@glink framework/guides/deep-dive/schema#object-elements Object elements} section of the "Schema" deep dive}
+	 * See the {@glink framework/guides/deep-dive/schema#object-elements Object elements} section of the Schema deep dive}
 	 * guide for more details.
 	 *
 	 * @param {module:engine/model/item~Item|module:engine/model/schema~SchemaContextItem|String} item
@@ -287,7 +287,7 @@ export default class Schema {
 
 	/**
 	 * Returns `true` if the given item is defined to be
-	 * an inline element by {@link module:engine/model/schema~SchemaItemDefinition}'s `isInline` property.
+	 * an inline element by the {@link module:engine/model/schema~SchemaItemDefinition}'s `isInline` property.
 	 *
 	 *		schema.isInline( 'paragraph' ); // -> false
 	 *		schema.isInline( 'softBreak' ); // -> true
@@ -295,7 +295,7 @@ export default class Schema {
 	 *		const text = writer.createText('foo' );
 	 *		schema.isInline( text ); // -> true
 	 *
-	 * See the {@glink framework/guides/deep-dive/schema#inline-elements Inline elements} section of the "Schema" deep dive}
+	 * See the {@glink framework/guides/deep-dive/schema#inline-elements Inline elements} section of the Schema deep dive}
 	 * guide for more details.
 	 *
 	 * @param {module:engine/model/item~Item|module:engine/model/schema~SchemaContextItem|String} item
@@ -737,13 +737,13 @@ export default class Schema {
 	}
 
 	/**
-	 * Tries to find position ancestors that allows to insert given node.
+	 * Tries to find position ancestors that allow to insert a given node.
 	 * It starts searching from the given position and goes node by node to the top of the model tree
-	 * as long as {@link module:engine/model/schema~Schema#isLimit limit element},
-	 * {@link module:engine/model/schema~Schema#isObject object element} or top-most ancestor won't be reached.
+	 * as long as a {@link module:engine/model/schema~Schema#isLimit limit element}, an
+	 * {@link module:engine/model/schema~Schema#isObject object element} or a topmost ancestor is not reached.
 	 *
-	 * @param {module:engine/model/position~Position} position Position from searching will start.
-	 * @param {module:engine/model/node~Node|String} node Node for which allowed parent should be found or its name.
+	 * @param {module:engine/model/position~Position} position The position that the search will start from.
+	 * @param {module:engine/model/node~Node|String} node The node for which an allowed parent should be found or its name.
 	 * @returns {module:engine/model/element~Element|null} element Allowed parent or null if nothing was found.
 	 */
 	findAllowedParent( position, node ) {
@@ -873,7 +873,7 @@ export default class Schema {
 	 * This is a helper function for {@link ~Schema#getValidRanges}.
 	 *
 	 * @private
-	 * @param {module:engine/model/range~Range} range Range to process.
+	 * @param {module:engine/model/range~Range} range The range to process.
 	 * @param {String} attribute The name of the attribute to check.
 	 * @returns {Iterable.<module:engine/model/range~Range>} Ranges in which the attribute is allowed.
 	 */
@@ -907,7 +907,7 @@ mix( Schema, ObservableMixin );
 
 /**
  * Event fired when the {@link #checkChild} method is called. It allows plugging in
- * additional behavior – e.g. implementing rules which cannot be defined using the declarative
+ * additional behavior, for example implementing rules which cannot be defined using the declarative
  * {@link module:engine/model/schema~SchemaItemDefinition} interface.
  *
  * **Note:** The {@link #addChildCheck} method is a more handy way to register callbacks. Internally,
@@ -916,7 +916,7 @@ mix( Schema, ObservableMixin );
  *
  * The {@link #checkChild} method fires an event because it is
  * {@link module:utils/observablemixin~ObservableMixin#decorate decorated} with it. Thanks to that you can
- * use this event in a various way, but the most important use case is overriding standard behaviour of the
+ * use this event in various ways, but the most important use case is overriding standard behavior of the
  * `checkChild()` method. Let's see a typical listener template:
  *
  *		schema.on( 'checkChild', ( evt, args ) => {
@@ -927,12 +927,12 @@ mix( Schema, ObservableMixin );
  * The listener is added with a `high` priority to be executed before the default method is really called. The `args` callback
  * parameter contains arguments passed to `checkChild( context, child )`. However, the `context` parameter is already
  * normalized to a {@link module:engine/model/schema~SchemaContext} instance and `child` to a
- * {@link module:engine/model/schema~SchemaCompiledItemDefinition} instance, so you don't have to worry about
+ * {@link module:engine/model/schema~SchemaCompiledItemDefinition} instance, so you do not have to worry about
  * the various ways how `context` and `child` may be passed to `checkChild()`.
  *
  * **Note:** `childDefinition` may be `undefined` if `checkChild()` was called with a non-registered element.
  *
- * So, in order to implement a rule "disallow `heading1` in `blockQuote`" you can add such a listener:
+ * So, in order to implement a rule "disallow `heading1` in `blockQuote`", you can add such a listener:
  *
  *		schema.on( 'checkChild', ( evt, args ) => {
  *			const context = args[ 0 ];
@@ -946,8 +946,8 @@ mix( Schema, ObservableMixin );
  *			}
  *		}, { priority: 'high' } );
  *
- * Allowing elements in specific contexts will be a far less common use case, because it's normally handled by
- * `allowIn` rule from {@link module:engine/model/schema~SchemaItemDefinition} but if you have a complex scenario
+ * Allowing elements in specific contexts will be a far less common use case, because it is normally handled by the
+ * `allowIn` rule from {@link module:engine/model/schema~SchemaItemDefinition}. But if you have a complex scenario
  * where `listItem` should be allowed only in element `foo` which must be in element `bar`, then this would be the way:
  *
  *		schema.on( 'checkChild', ( evt, args ) => {
@@ -968,16 +968,16 @@ mix( Schema, ObservableMixin );
 
 /**
  * Event fired when the {@link #checkAttribute} method is called. It allows plugging in
- * additional behavior – e.g. implementing rules which cannot be defined using the declarative
+ * additional behavior, for example implementing rules which cannot be defined using the declarative
  * {@link module:engine/model/schema~SchemaItemDefinition} interface.
  *
  * **Note:** The {@link #addAttributeCheck} method is a more handy way to register callbacks. Internally,
  * it registers a listener to this event but comes with a simpler API and it is the recommended choice
  * in most of the cases.
  *
- * The {@link #checkAttribute} method fires an event because it's
+ * The {@link #checkAttribute} method fires an event because it is
  * {@link module:utils/observablemixin~ObservableMixin#decorate decorated} with it. Thanks to that you can
- * use this event in a various way, but the most important use case is overriding standard behaviour of the
+ * use this event in various ways, but the most important use case is overriding the standard behavior of the
  * `checkAttribute()` method. Let's see a typical listener template:
  *
  *		schema.on( 'checkAttribute', ( evt, args ) => {
@@ -987,10 +987,10 @@ mix( Schema, ObservableMixin );
  *
  * The listener is added with a `high` priority to be executed before the default method is really called. The `args` callback
  * parameter contains arguments passed to `checkAttribute( context, attributeName )`. However, the `context` parameter is already
- * normalized to a {@link module:engine/model/schema~SchemaContext} instance, so you don't have to worry about
+ * normalized to a {@link module:engine/model/schema~SchemaContext} instance, so you do not have to worry about
  * the various ways how `context` may be passed to `checkAttribute()`.
  *
- * So, in order to implement a rule "disallow `bold` in a text which is in a `heading1` you can add such a listener:
+ * So, in order to implement a rule "disallow `bold` in a text which is in a `heading1`, you can add such a listener:
  *
  *		schema.on( 'checkAttribute', ( evt, args ) => {
  *			const context = args[ 0 ];
@@ -1004,8 +1004,8 @@ mix( Schema, ObservableMixin );
  *			}
  *		}, { priority: 'high' } );
  *
- * Allowing attributes in specific contexts will be a far less common use case, because it's normally handled by
- * `allowAttributes` rule from {@link module:engine/model/schema~SchemaItemDefinition} but if you have a complex scenario
+ * Allowing attributes in specific contexts will be a far less common use case, because it is normally handled by the
+ * `allowAttributes` rule from {@link module:engine/model/schema~SchemaItemDefinition}. But if you have a complex scenario
  * where `bold` should be allowed only in element `foo` which must be in element `bar`, then this would be the way:
  *
  *		schema.on( 'checkAttribute', ( evt, args ) => {
@@ -1056,7 +1056,7 @@ mix( Schema, ObservableMixin );
  * {@link module:engine/model/schema~Schema#isLimit `isLimit()`} returns `true` for object elements automatically.
  *
  * Read more about the meaning of these types in the
- * {@glink framework/guides/deep-dive/schema#defining-additional-semantics Dedicated section of the "Schema" deep dive} guide.
+ * {@glink framework/guides/deep-dive/schema#defining-additional-semantics dedicated section of the Schema deep dive} guide.
  *
  * # Generic items
  *
@@ -1159,14 +1159,14 @@ mix( Schema, ObservableMixin );
  * Most block type items will inherit from `$block` (through `inheritAllFrom`).
  *
  * Read more about the block elements in the
- * {@glink framework/guides/deep-dive/schema#block-elements Block elements} section of the "Schema" deep dive} guide.
+ * {@glink framework/guides/deep-dive/schema#block-elements Block elements} section of the Schema deep dive} guide.
  *
  * @property {Boolean} isInline
  * Whether an item is "text-like" and should be treated as an inline node. Examples of inline elements:
  * `$text`, `softBreak` (`<br>`), etc.
  *
  * Read more about the inline elements in the
- * {@glink framework/guides/deep-dive/schema#inline-elements Inline elements} section of the "Schema" deep dive} guide.
+ * {@glink framework/guides/deep-dive/schema#inline-elements Inline elements} section of the Schema deep dive} guide.
  *
  * @property {Boolean} isLimit
  * It can be understood as whether this element should not be split by <kbd>Enter</kbd>.
@@ -1174,7 +1174,7 @@ mix( Schema, ObservableMixin );
  * a limit element are limited to its content.
  *
  * Read more about the limit elements in the
- * {@glink framework/guides/deep-dive/schema#limit-elements Limit elements} section of the "Schema" deep dive} guide.
+ * {@glink framework/guides/deep-dive/schema#limit-elements Limit elements} section of the Schema deep dive} guide.
  *
  * @property {Boolean} isObject
  * Whether an item is "self-contained" and should be treated as a whole. Examples of object elements:
@@ -1184,7 +1184,7 @@ mix( Schema, ObservableMixin );
  * {@link module:engine/model/schema~Schema#isLimit `isLimit()`} returns `true` for object elements automatically.
  *
  * Read more about the object elements in the
- * {@glink framework/guides/deep-dive/schema#object-elements Object elements} section of the "Schema" deep dive} guide.
+ * {@glink framework/guides/deep-dive/schema#object-elements Object elements} section of the Schema deep dive} guide.
  */
 
 /**

+ 45 - 11
packages/ckeditor5-engine/tests/manual/highlight.html

@@ -1,37 +1,61 @@
 <style>
+	:root {
+		--ck-manual-test-highlight-yellow: hsla(60, 100%, 50%, 0.5);
+		--ck-manual-test-highlight-red: hsla(0, 100%, 50%, 0.5);
+		--ck-manual-test-highlight-blue: hsla(240, 100%, 50%, 0.5);
+	}
+
 	span.highlight-yellow {
-		background-color: rgba( 255, 255, 0, 0.5 );
+		background-color: var(--ck-manual-test-highlight-yellow);
 	}
 
 	span.highlight-blue {
-		background-color: rgba( 0, 0, 255, 0.5 );
+		background-color: var(--ck-manual-test-highlight-blue);
 	}
 
 	span.highlight-red {
-		background-color: rgba( 255, 0, 0, 0.5 );
+		background-color: var(--ck-manual-test-highlight-red);
 	}
 
 	.ck-widget.fancy-widget {
-		background-color: black;
-		color: white;
+		background-color: hsl(0, 0%, 0%);
+		color: hsl(0, 0%, 100%);
 		text-align: center;
 		height: 40px;
 		line-height: 40px;
 	}
 
 	.ck-widget.fancy-widget.highlight-yellow {
-		background-color: yellow;
-		color: black;
+		background-color: var(--ck-manual-test-highlight-yellow);
+		color: hsl(0, 0%, 0%);
 	}
 
 	.ck-widget.fancy-widget.highlight-blue {
-		background-color: blue;
-		color: black;
+		background-color: var(--ck-manual-test-highlight-blue);
+		color: hsl(0, 0%, 0%);
 	}
 
 	.ck-widget.fancy-widget.highlight-red {
-		background-color: red;
-		color: black;
+		background-color: var(--ck-manual-test-highlight-red);
+		color: hsl(0, 0%, 0%);
+	}
+
+	.table td.highlight-yellow,
+	.table th.highlight-yellow {
+		/* !important to override selected editable styles. */
+		background-color: var(--ck-manual-test-highlight-yellow) !important;
+	}
+
+	.table td.highlight-blue,
+	.table th.highlight-blue {
+		/* !important to override selected editable styles. */
+		background-color: var(--ck-manual-test-highlight-blue) !important;
+	}
+
+	.table td.highlight-red,
+	.table th.highlight-red {
+		/* !important to override selected editable styles. */
+		background-color: var(--ck-manual-test-highlight-red) !important;
 	}
 
 	button {
@@ -43,6 +67,16 @@
 	<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit. Maecenas at diam quis justo imperdiet posuere non eu tortor. Mauris vel magna eu sem hendrerit varius. Ut imperdiet velit ut ante interdum convallis. Vestibulum vitae lacinia mi. </p>
 	<figure></figure>
 	<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit. Maecenas at diam quis justo imperdiet posuere non eu tortor. Mauris vel magna eu sem hendrerit varius. Ut imperdiet velit ut ante interdum convallis. Vestibulum vitae lacinia mi. </p>
+	<table>
+		<tr>
+			<td>foo</td>
+			<td>bar</td>
+		</tr>
+		<tr>
+			<td>baz</td>
+			<td>qux</td>
+		</tr>
+	</table>
 </div>
 <h2>Markers</h2>
 <button id="add-marker-yellow">Set yellow highlight</button><button id="remove-marker-yellow">Remove yellow highlight</button>

+ 3 - 2
packages/ckeditor5-engine/tests/manual/highlight.js

@@ -16,6 +16,7 @@ import Italic from '@ckeditor/ckeditor5-basic-styles/src/italic';
 import List from '@ckeditor/ckeditor5-list/src/list';
 import global from '@ckeditor/ckeditor5-utils/src/dom/global';
 import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
+import Table from '@ckeditor/ckeditor5-table/src/table';
 import Widget from '@ckeditor/ckeditor5-widget/src/widget';
 import { toWidget } from '@ckeditor/ckeditor5-widget/src/utils';
 
@@ -53,8 +54,8 @@ class FancyWidget extends Plugin {
 }
 
 ClassicEditor.create( global.document.querySelector( '#editor' ), {
-	plugins: [ Enter, Typing, Paragraph, Undo, Heading, Bold, Italic, List, FancyWidget ],
-	toolbar: [ 'heading', '|', 'undo', 'redo', 'bold', 'italic', 'numberedList', 'bulletedList' ]
+	plugins: [ Enter, Typing, Paragraph, Undo, Heading, Bold, Italic, List, Table, FancyWidget ],
+	toolbar: [ 'heading', '|', 'undo', 'redo', 'bold', 'italic', 'numberedList', 'bulletedList', 'insertTable' ]
 } )
 	.then( editor => {
 		window.editor = editor;

+ 6 - 1
packages/ckeditor5-engine/tests/manual/highlight.md

@@ -10,5 +10,10 @@ applied.
 1. Red highlight should be visible on the whole content, including the widget.
 1. Play with highlights, add and remove them, check if they are applied correctly.
 
-NOTE: Yellow highlight should be visible over red and blue one. Red highlight should be visible over blue one. 
+### Highlighting table cells
+
+1. You can select the entire table cell and apply a highlight to it.
+1. The highlight should change the background of an **entire cell** (not only its content).
+
+NOTE: Yellow highlight should be visible over red and blue one. Red highlight should be visible over blue one.
 

+ 1 - 1
packages/ckeditor5-font/src/fontfamily.js

@@ -120,7 +120,7 @@ export default class FontFamily extends Plugin {
  *
  * You can preserve pasted font family values by switching the `supportAllValues` option to `true`:
  *
- *		const fontSizeConfig = {
+ *		const fontFamilyConfig = {
  *			supportAllValues: true
  *		};
  *

+ 1 - 1
packages/ckeditor5-font/src/fontfamily/utils.js

@@ -66,7 +66,7 @@ function generateFontPreset( fontDefinition ) {
 
 	return {
 		title: firstFontName,
-		model: firstFontName,
+		model: cssFontNames,
 		view: {
 			name: 'span',
 			styles: {

+ 6 - 4
packages/ckeditor5-font/tests/fontfamily/fontfamilyediting.js

@@ -104,9 +104,9 @@ describe( 'FontFamilyEditing', () => {
 				} );
 
 				it( 'should convert defined fontFamily attribute values', () => {
-					setModelData( doc, '<paragraph>f<$text fontFamily="Arial">o</$text>o</paragraph>' );
+					setModelData( doc, '<paragraph>f<$text fontFamily="Arial, Helvetica, sans-serif">o</$text>o</paragraph>' );
 
-					expect( editor.getData() ).to.equal( '<p>f<span style="font-family:Arial;">o</span>o</p>' );
+					expect( editor.getData() ).to.equal( '<p>f<span style="font-family:Arial, Helvetica, sans-serif;">o</span>o</p>' );
 				} );
 			} );
 
@@ -174,10 +174,12 @@ describe( 'FontFamilyEditing', () => {
 		} );
 
 		it( 'should convert fontFamily attribute to configured complex preset', () => {
-			setModelData( doc, '<paragraph>f<$text fontFamily="Lucida Sans Unicode">o</$text>o</paragraph>' );
+			const fontFamily = '\'Lucida Sans Unicode\', \'Lucida Grande\', sans-serif';
+
+			setModelData( doc, `<paragraph>f<$text fontFamily="${ fontFamily }">o</$text>o</paragraph>` );
 
 			expect( editor.getData() )
-				.to.equal( '<p>f<span style="font-family:\'Lucida Sans Unicode\', \'Lucida Grande\', sans-serif;">o</span>o</p>' );
+				.to.equal( `<p>f<span style="font-family:${ fontFamily };">o</span>o</p>` );
 		} );
 
 		it( 'should convert fontFamily attribute from user defined settings', () => {

+ 50 - 28
packages/ckeditor5-font/tests/fontfamily/fontfamilyui.js

@@ -10,9 +10,11 @@ import FontFamilyUI from '../../src/fontfamily/fontfamilyui';
 
 import fontFamilyIcon from '../../theme/icons/font-family.svg';
 
+import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
 import ClassicTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/classictesteditor';
 import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils';
 import { add as addTranslations, _clear as clearTranslations } from '@ckeditor/ckeditor5-utils/src/translation-service';
+import { getData as getModelData, setData as setModelData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
 
 describe( 'FontFamilyUI', () => {
 	let editor, command, element;
@@ -97,47 +99,67 @@ describe( 'FontFamilyUI', () => {
 			expect( listView.items.map( item => item.children.first.isOn ) )
 				.to.deep.equal( [ true, false, false, false, false, false, false, false, false ] );
 
-			command.value = 'Arial';
+			command.value = 'Arial, Helvetica, sans-serif';
 
 			// The second item is 'Arial' font family.
 			expect( listView.items.map( item => item.children.first.isOn ) )
 				.to.deep.equal( [ false, true, false, false, false, false, false, false, false ] );
 		} );
 
-		it( 'should activate current option in dropdown for full font family definitions', () => {
-			const element = document.createElement( 'div' );
-			document.body.appendChild( element );
+		describe( 'with supportAllValues=true', () => {
+			let editor, element, command, dropdown;
 
-			return ClassicTestEditor
-				.create( element, {
-					plugins: [ FontFamilyEditing, FontFamilyUI ],
-					fontSize: {
-						supportAllValues: true
-					}
-				} )
-				.then( editor => {
-					const command = editor.commands.get( 'fontFamily' );
-					const dropdown = editor.ui.componentFactory.create( 'fontFamily' );
+			beforeEach( async () => {
+				element = document.createElement( 'div' );
+				document.body.appendChild( element );
+
+				editor = await ClassicTestEditor
+					.create( element, {
+						plugins: [ Paragraph, FontFamilyEditing, FontFamilyUI ],
+						fontSize: {
+							supportAllValues: true
+						}
+					} );
+
+				command = editor.commands.get( 'fontFamily' );
+				dropdown = editor.ui.componentFactory.create( 'fontFamily' );
+			} );
+
+			afterEach( async () => {
+				await editor.destroy();
+				element.remove();
+			} );
 
-					const listView = dropdown.listView;
+			it( 'should activate the current option in the dropdown for full font family definitions', () => {
+				const listView = dropdown.listView;
+
+				command.value = undefined;
+
+				// The first item is 'default' font family.
+				expect( listView.items.map( item => item.children.first.isOn ) )
+					.to.deep.equal( [ true, false, false, false, false, false, false, false, false ] );
+
+				command.value = '\'Courier New\', Courier, monospace';
 
-					command.value = undefined;
+				// The third item is 'Courier New' font family.
+				expect( listView.items.map( item => item.children.first.isOn ) )
+					.to.deep.equal( [ false, false, true, false, false, false, false, false, false ] );
+			} );
+
+			it( 'should apply the complete font-family value (list of font-families)', () => {
+				const listView = dropdown.listView;
+				const fontFamilyArialButton = listView.items.get( 1 ).children.first;
 
-					// The first item is 'default' font family.
-					expect( listView.items.map( item => item.children.first.isOn ) )
-						.to.deep.equal( [ true, false, false, false, false, false, false, false, false ] );
+				setModelData( editor.model, '<paragraph>f[oo]</paragraph>' );
 
-					command.value = '\'Courier New\', Courier, monospace';
+				fontFamilyArialButton.fire( 'execute' );
 
-					// The third item is 'Courier New' font family.
-					expect( listView.items.map( item => item.children.first.isOn ) )
-						.to.deep.equal( [ false, false, true, false, false, false, false, false, false ] );
+				expect( getModelData( editor.model ) ).to.equal(
+					'<paragraph>f[<$text fontFamily="Arial, Helvetica, sans-serif">oo</$text>]</paragraph>'
+				);
 
-					return editor.destroy();
-				} )
-				.then( () => {
-					element.remove();
-				} );
+				expect( editor.getData() ).to.equal( '<p>f<span style="font-family:Arial, Helvetica, sans-serif;">oo</span></p>' );
+			} );
 		} );
 
 		describe( 'model to command binding', () => {

+ 2 - 2
packages/ckeditor5-font/tests/fontfamily/utils.js

@@ -62,7 +62,7 @@ describe( 'FontFamily utils', () => {
 					},
 					{
 						title: 'Comic Sans MS',
-						model: 'Comic Sans MS',
+						model: '\'Comic Sans MS\', sans-serif',
 						view: {
 							name: 'span',
 							styles: {
@@ -73,7 +73,7 @@ describe( 'FontFamily utils', () => {
 					},
 					{
 						title: 'Lucida Console',
-						model: 'Lucida Console',
+						model: '\'Lucida Console\', \'Courier New\', Courier, monospace',
 						view: {
 							name: 'span',
 							styles: {

+ 2 - 1
packages/ckeditor5-font/tests/integration.js

@@ -37,7 +37,8 @@ describe( 'Integration test Font', () => {
 		it( 'should render one span element for all types of font features', () => {
 			setModelData( model,
 				'<paragraph>' +
-					'<$text fontColor="#123456" fontBackgroundColor="rgb(10,20,30)" fontSize="big" fontFamily="Arial">foo</$text>' +
+					'<$text fontColor="#123456" fontBackgroundColor="rgb(10,20,30)" fontSize="big" ' +
+						'fontFamily="Arial, Helvetica, sans-serif">foo</$text>' +
 				'</paragraph>'
 			);
 

+ 2 - 1
packages/ckeditor5-font/tests/manual/font-family.md

@@ -14,7 +14,8 @@ Try to:
 ### Converters mode
 
 The "Restricted value matching" option means that all font-family values that aren't defined in the plugin's configuration will be removed (e.g. when pasted from Google Docs).
-This behaviour can be disabled by selecting the "Disabled value matching" option.
+
+This behaviour can be disabled by selecting the "Disabled value matching" option, which sets ["`supportAllValues: true`"](https://ckeditor.com/docs/ckeditor5/latest/api/module_font_fontfamily-FontFamilyConfig.html#member-supportAllValues) in the font family configuration.
 
 The `Docs-Roboto, Arial` font-family is not specified in the plugin's configuration and should be restored to default font when the "Restricted value matching" option is selected.
 

+ 13 - 1
packages/ckeditor5-image/src/image/utils.js

@@ -113,11 +113,23 @@ export function isImageAllowed( model ) {
  * Assuming that image is always a first child of a widget (ie. `figureView.getChild( 0 )`) is unsafe as other features might
  * inject their own elements to the widget.
  *
+ * The `<img>` can be wrapped to other elements, e.g. `<a>`. Nested check required.
+ *
  * @param {module:engine/view/element~Element} figureView
  * @returns {module:engine/view/element~Element}
  */
 export function getViewImgFromWidget( figureView ) {
-	return Array.from( figureView.getChildren() ).find( viewChild => viewChild.is( 'img' ) );
+	const figureChildren = [];
+
+	for ( const figureChild of figureView.getChildren() ) {
+		figureChildren.push( figureChild );
+
+		if ( figureChild.is( 'element' ) ) {
+			figureChildren.push( ...figureChild.getChildren() );
+		}
+	}
+
+	return figureChildren.find( viewChild => viewChild.is( 'img' ) );
 }
 
 // Checks if image is allowed by schema in optimal insertion parent.

+ 55 - 2
packages/ckeditor5-image/tests/image/utils.js

@@ -7,18 +7,27 @@ import ViewDocumentFragment from '@ckeditor/ckeditor5-engine/src/view/documentfr
 import ViewDowncastWriter from '@ckeditor/ckeditor5-engine/src/view/downcastwriter';
 import ViewDocument from '@ckeditor/ckeditor5-engine/src/view/document';
 import ModelElement from '@ckeditor/ckeditor5-engine/src/model/element';
-import { toImageWidget, isImageWidget, getSelectedImageWidget, isImage, isImageAllowed, insertImage } from '../../src/image/utils';
+import {
+	toImageWidget,
+	isImageWidget,
+	getSelectedImageWidget,
+	isImage,
+	isImageAllowed,
+	insertImage,
+	getViewImgFromWidget
+} from '../../src/image/utils';
 import { isWidget, getLabel } from '@ckeditor/ckeditor5-widget/src/utils';
 import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
 import VirtualTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/virtualtesteditor';
 import { setData as setModelData, getData as getModelData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
 import Image from '../../src/image/imageediting';
+import { StylesProcessor } from '@ckeditor/ckeditor5-engine/src/view/stylesmap';
 
 describe( 'image widget utils', () => {
 	let element, image, writer, viewDocument;
 
 	beforeEach( () => {
-		viewDocument = new ViewDocument();
+		viewDocument = new ViewDocument( new StylesProcessor() );
 		writer = new ViewDowncastWriter( viewDocument );
 		image = writer.createContainerElement( 'img' );
 		element = writer.createContainerElement( 'figure' );
@@ -266,4 +275,48 @@ describe( 'image widget utils', () => {
 			expect( getModelData( model ) ).to.equal( '<other>[]</other>' );
 		} );
 	} );
+
+	describe( 'getViewImgFromWidget()', () => {
+		// figure
+		//   img
+		it( 'returns the the img element from widget if the img is the first children', () => {
+			expect( getViewImgFromWidget( element ) ).to.equal( image );
+		} );
+
+		// figure
+		//   div
+		//   img
+		it( 'returns the the img element from widget if the img is not the first children', () => {
+			writer.insert( writer.createPositionAt( element, 0 ), writer.createContainerElement( 'div' ) );
+			expect( getViewImgFromWidget( element ) ).to.equal( image );
+		} );
+
+		// figure
+		//   div
+		//     img
+		it( 'returns the the img element from widget if the img is a child of another element', () => {
+			const divElement = writer.createContainerElement( 'div' );
+
+			writer.insert( writer.createPositionAt( element, 0 ), divElement );
+			writer.move( writer.createRangeOn( image ), writer.createPositionAt( divElement, 0 ) );
+
+			expect( getViewImgFromWidget( element ) ).to.equal( image );
+		} );
+
+		// figure
+		//   div
+		//     "Bar"
+		//     img
+		//   "Foo"
+		it( 'does not throw an error if text node found', () => {
+			const divElement = writer.createContainerElement( 'div' );
+
+			writer.insert( writer.createPositionAt( element, 0 ), divElement );
+			writer.insert( writer.createPositionAt( element, 0 ), writer.createText( 'Foo' ) );
+			writer.insert( writer.createPositionAt( divElement, 0 ), writer.createText( 'Bar' ) );
+			writer.move( writer.createRangeOn( image ), writer.createPositionAt( divElement, 1 ) );
+
+			expect( getViewImgFromWidget( element ) ).to.equal( image );
+		} );
+	} );
 } );

+ 1 - 0
packages/ckeditor5-link/package.json

@@ -20,6 +20,7 @@
     "@ckeditor/ckeditor5-clipboard": "^19.0.1",
     "@ckeditor/ckeditor5-editor-classic": "^19.0.1",
     "@ckeditor/ckeditor5-enter": "^19.0.1",
+    "@ckeditor/ckeditor5-image": "^19.0.1",
     "@ckeditor/ckeditor5-paragraph": "^19.1.0",
     "@ckeditor/ckeditor5-theme-lark": "^19.1.0",
     "@ckeditor/ckeditor5-typing": "^19.0.1",

+ 40 - 1
packages/ckeditor5-link/src/linkcommand.js

@@ -187,10 +187,30 @@ export default class LinkCommand extends Command {
 				}
 			} else {
 				// If selection has non-collapsed ranges, we change attribute on nodes inside those ranges
-				// omitting nodes where `linkHref` attribute is disallowed.
+				// omitting nodes where the `linkHref` attribute is disallowed.
 				const ranges = model.schema.getValidRanges( selection.getRanges(), 'linkHref' );
 
+				// But for the first, check whether the `linkHref` attribute is allowed on selected blocks (e.g. the "image" element).
+				const allowedRanges = [];
+
+				for ( const element of selection.getSelectedBlocks() ) {
+					if ( model.schema.checkAttribute( element, 'linkHref' ) ) {
+						allowedRanges.push( writer.createRangeOn( element ) );
+					}
+				}
+
+				// Ranges that accept the `linkHref` attribute. Since we will iterate over `allowedRanges`, let's clone it.
+				const rangesToUpdate = allowedRanges.slice();
+
+				// For all selection ranges we want to check whether given range is inside an element that accepts the `linkHref` attribute.
+				// If so, we don't want to propagate applying the attribute to its children.
 				for ( const range of ranges ) {
+					if ( this._isRangeToUpdate( range, allowedRanges ) ) {
+						rangesToUpdate.push( range );
+					}
+				}
+
+				for ( const range of rangesToUpdate ) {
 					writer.setAttribute( 'linkHref', href, range );
 
 					truthyManualDecorators.forEach( item => {
@@ -216,4 +236,23 @@ export default class LinkCommand extends Command {
 		const doc = this.editor.model.document;
 		return doc.selection.getAttribute( decoratorName );
 	}
+
+	/**
+	 * Checks whether specified `range` is inside an element that accepts the `linkHref` attribute.
+	 *
+	 * @private
+	 * @param {module:engine/view/range~Range} range A range to check.
+	 * @param {Array.<module:engine/view/range~Range>} allowedRanges An array of ranges created on elements where the attribute is accepted.
+	 * @returns {Boolean}
+	 */
+	_isRangeToUpdate( range, allowedRanges ) {
+		for ( const allowedRange of allowedRanges ) {
+			// A range is inside an element that will have the `linkHref` attribute. Do not modify its nodes.
+			if ( allowedRange.containsRange( range ) ) {
+				return false;
+			}
+		}
+
+		return true;
+	}
 }

+ 36 - 0
packages/ckeditor5-link/src/linkimage.js

@@ -0,0 +1,36 @@
+/**
+ * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+/**
+ * @module link/linkimage
+ */
+
+import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
+import LinkImageEditing from './linkimageediting';
+import LinkImageUI from './linkimageui';
+
+/**
+ * The `LinkImage` plugin.
+ *
+ * This is a "glue" plugin that loads the {@link module:link/linkimageediting~LinkImageEditing link image editing feature}
+ * and {@link module:link/linkimageui~LinkImageUI linkimage UI feature}.
+ *
+ * @extends module:core/plugin~Plugin
+ */
+export default class LinkImage extends Plugin {
+	/**
+	 * @inheritDoc
+	 */
+	static get requires() {
+		return [ LinkImageEditing, LinkImageUI ];
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	static get pluginName() {
+		return 'LinkImage';
+	}
+}

+ 141 - 0
packages/ckeditor5-link/src/linkimageediting.js

@@ -0,0 +1,141 @@
+/**
+ * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+/**
+ * @module link/linkimageediting
+ */
+
+import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
+import ImageEditing from '@ckeditor/ckeditor5-image/src/image/imageediting';
+import LinkEditing from './linkediting';
+
+/**
+ * The link image engine feature.
+ *
+ * It accepts the `linkHref="url"` attribute in the model for the {@link module:image/image~Image `<image>`} element
+ * which allows linking images.
+ *
+ * @extends module:core/plugin~Plugin
+ */
+export default class LinkImageEditing extends Plugin {
+	/**
+	 * @inheritDoc
+	 */
+	static get requires() {
+		return [ ImageEditing, LinkEditing ];
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	static get pluginName() {
+		return 'LinkImageEditing';
+	}
+
+	init() {
+		const editor = this.editor;
+
+		editor.model.schema.extend( 'image', { allowAttributes: [ 'linkHref' ] } );
+
+		editor.conversion.for( 'upcast' ).add( upcastLink() );
+		editor.conversion.for( 'downcast' ).add( downcastImageLink() );
+	}
+}
+
+// Returns a converter that consumes the 'href' attribute if a link contains an image.
+//
+// @private
+// @returns {Function}
+//
+function upcastLink() {
+	return dispatcher => {
+		dispatcher.on( 'element:a', ( evt, data, conversionApi ) => {
+			const viewLink = data.viewItem;
+			const imageInLink = Array.from( viewLink.getChildren() ).find( child => child.name === 'img' );
+
+			if ( !imageInLink ) {
+				return;
+			}
+
+			// There's an image inside an <a> element - we consume it so it won't be picked up by the Link plugin.
+			const consumableAttributes = { attributes: [ 'href' ] };
+
+			// Consume the `href` attribute so the default one will not convert it to $text attribute.
+			if ( !conversionApi.consumable.consume( viewLink, consumableAttributes ) ) {
+				// Might be consumed by something else - i.e. other converter with priority=highest - a standard check.
+				return;
+			}
+
+			const linkHref = viewLink.getAttribute( 'href' );
+
+			// Missing the 'href' attribute.
+			if ( !linkHref ) {
+				return;
+			}
+
+			// A full definition of the image feature.
+			// figure > a > img: parent of the link element is an image element.
+			let modelElement = data.modelCursor.parent;
+
+			if ( !modelElement.is( 'image' ) ) {
+				// a > img: parent of the link is not the image element. We need to convert it manually.
+				const conversionResult = conversionApi.convertItem( imageInLink, data.modelCursor );
+
+				// Set image range as conversion result.
+				data.modelRange = conversionResult.modelRange;
+
+				// Continue conversion where image conversion ends.
+				data.modelCursor = conversionResult.modelCursor;
+
+				modelElement = data.modelCursor.nodeBefore;
+			}
+
+			if ( modelElement && modelElement.is( 'image' ) ) {
+				// Set the linkHref attribute from link element on model image element.
+				conversionApi.writer.setAttribute( 'linkHref', linkHref, modelElement );
+			}
+		}, { priority: 'high' } );
+	};
+}
+
+// Return a converter that adds the `<a>` element to data.
+//
+// @private
+// @returns {Function}
+//
+function downcastImageLink() {
+	return dispatcher => {
+		dispatcher.on( 'attribute:linkHref:image', ( evt, data, conversionApi ) => {
+			// The image will be already converted - so it will be present in the view.
+			const viewFigure = conversionApi.mapper.toViewElement( data.item );
+			const writer = conversionApi.writer;
+
+			// But we need to check whether the link element exists.
+			const linkInImage = Array.from( viewFigure.getChildren() ).find( child => child.name === 'a' );
+
+			// If so, update the attribute if it's defined or remove the entire link if the attribute is empty.
+			if ( linkInImage ) {
+				if ( data.attributeNewValue ) {
+					writer.setAttribute( 'href', data.attributeNewValue, linkInImage );
+				} else {
+					const viewImage = Array.from( linkInImage.getChildren() ).find( child => child.name === 'img' );
+
+					writer.move( writer.createRangeOn( viewImage ), writer.createPositionAt( viewFigure, 0 ) );
+					writer.remove( linkInImage );
+				}
+			} else {
+				// But if it does not exist. Let's wrap already converted image by newly created link element.
+				// 1. Create an empty link element.
+				const linkElement = writer.createContainerElement( 'a', { href: data.attributeNewValue } );
+
+				// 2. Insert link inside the associated image.
+				writer.insert( writer.createPositionAt( viewFigure, 0 ), linkElement );
+
+				// 3. Move the image to the link.
+				writer.move( writer.createRangeOn( viewFigure.getChild( 1 ) ), writer.createPositionAt( linkElement, 0 ) );
+			}
+		} );
+	};
+}

+ 39 - 0
packages/ckeditor5-link/src/linkimageui.js

@@ -0,0 +1,39 @@
+/**
+ * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+/**
+ * @module link/linkimageui
+ */
+
+import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
+import Image from '@ckeditor/ckeditor5-image/src/image';
+import LinkUI from './linkui';
+import LinkEditing from './linkediting';
+
+/**
+ * The link image UI plugin.
+ *
+ * TODO: Docs.
+ *
+ * @extends module:core/plugin~Plugin
+ */
+export default class LinkImageUI extends Plugin {
+	/**
+	 * @inheritDoc
+	 */
+	static get requires() {
+		return [ Image, LinkEditing, LinkUI ];
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	static get pluginName() {
+		return 'LinkImageUI';
+	}
+
+	init() {
+	}
+}

+ 62 - 3
packages/ckeditor5-link/tests/linkcommand.js

@@ -193,8 +193,8 @@ describe( 'LinkCommand', () => {
 				expect( command.value ).to.equal( 'url' );
 			} );
 
-			it( 'should set `linkHref` attribute only to allowed elements and omit disallowed', () => {
-				model.schema.register( 'img', { allowWhere: '$text' } );
+			it( 'should set `linkHref` attribute to allowed elements', () => {
+				model.schema.register( 'img', { isBlock: true, allowWhere: '$text', allowAttributes: [ 'linkHref' ] } );
 
 				setData( model, '<p>f[oo<img></img>ba]r</p>' );
 
@@ -203,9 +203,68 @@ describe( 'LinkCommand', () => {
 				command.execute( 'url' );
 
 				expect( getData( model ) )
-					.to.equal( '<p>f[<$text linkHref="url">oo</$text><img></img><$text linkHref="url">ba</$text>]r</p>' );
+					.to.equal( '<p>f[<$text linkHref="url">oo</$text><img linkHref="url"></img><$text linkHref="url">ba</$text>]r</p>' );
 				expect( command.value ).to.equal( 'url' );
 			} );
+
+			it( 'should set `linkHref` attribute to nested allowed elements', () => {
+				model.schema.register( 'img', { isBlock: true, allowWhere: '$text', allowAttributes: [ 'linkHref' ] } );
+				model.schema.register( 'blockQuote', { allowWhere: '$block', allowContentOf: '$root' } );
+
+				setData( model, '<p>foo</p>[<blockQuote><img></img></blockQuote>]<p>bar</p>' );
+
+				command.execute( 'url' );
+
+				expect( getData( model ) )
+					.to.equal( '<p>foo</p>[<blockQuote><img linkHref="url"></img></blockQuote>]<p>bar</p>' );
+			} );
+
+			it( 'should set `linkHref` attribute to allowed elements on multi-selection', () => {
+				model.schema.register( 'img', { isBlock: true, allowWhere: '$text', allowAttributes: [ 'linkHref' ] } );
+
+				setData( model, '<p>[<img></img>][<img></img>]</p>' );
+
+				command.execute( 'url' );
+
+				expect( getData( model ) )
+					.to.equal( '<p>[<img linkHref="url"></img>][<img linkHref="url"></img>]</p>' );
+			} );
+
+			it( 'should set `linkHref` attribute to allowed elements and omit disallowed', () => {
+				model.schema.register( 'img', { isBlock: true, allowWhere: '$text' } );
+				model.schema.register( 'caption', { allowIn: 'img' } );
+				model.schema.extend( '$text', { allowIn: 'caption' } );
+
+				setData( model, '<p>f[oo<img><caption>xxx</caption></img>ba]r</p>' );
+
+				command.execute( 'url' );
+
+				expect( getData( model ) ).to.equal(
+					'<p>' +
+						'f[<$text linkHref="url">oo</$text>' +
+						'<img><caption><$text linkHref="url">xxx</$text></caption></img>' +
+						'<$text linkHref="url">ba</$text>]r' +
+					'</p>'
+				);
+			} );
+
+			it( 'should set `linkHref` attribute to allowed elements and omit their children even if they accept the attribute', () => {
+				model.schema.register( 'img', { isBlock: true, allowWhere: '$text', allowAttributes: [ 'linkHref' ] } );
+				model.schema.register( 'caption', { allowIn: 'img' } );
+				model.schema.extend( '$text', { allowIn: 'caption' } );
+
+				setData( model, '<p>f[oo<img><caption>xxx</caption></img>ba]r</p>' );
+
+				command.execute( 'url' );
+
+				expect( getData( model ) ).to.equal(
+					'<p>' +
+						'f[<$text linkHref="url">oo</$text>' +
+						'<img linkHref="url"><caption>xxx</caption></img>' +
+						'<$text linkHref="url">ba</$text>]r' +
+					'</p>'
+				);
+			} );
 		} );
 
 		describe( 'collapsed selection', () => {

+ 18 - 0
packages/ckeditor5-link/tests/linkimage.js

@@ -0,0 +1,18 @@
+/**
+ * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+import LinkImage from '../src/linkimage';
+import LinkImageEditing from '../src/linkimageediting';
+import LinkImageUI from '../src/linkimageui';
+
+describe( 'LinkImage', () => {
+	it( 'should require LinkImageEditing and LinkImageUI', () => {
+		expect( LinkImage.requires ).to.deep.equal( [ LinkImageEditing, LinkImageUI ] );
+	} );
+
+	it( 'should be named', () => {
+		expect( LinkImage.pluginName ).to.equal( 'LinkImage' );
+	} );
+} );

+ 334 - 0
packages/ckeditor5-link/tests/linkimageediting.js

@@ -0,0 +1,334 @@
+/**
+ * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+import VirtualTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/virtualtesteditor';
+import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
+import LinkImageEditing from '../src/linkimageediting';
+import { getData as getModelData, setData as setModelData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
+import normalizeHtml from '@ckeditor/ckeditor5-utils/tests/_utils/normalizehtml';
+import { getData as getViewData } from '@ckeditor/ckeditor5-engine/src/dev-utils/view';
+import ImageCaptionEditing from '@ckeditor/ckeditor5-image/src/imagecaption/imagecaptionediting';
+
+describe( 'LinkImageEditing', () => {
+	let editor, model, view;
+
+	beforeEach( () => {
+		return VirtualTestEditor
+			.create( {
+				plugins: [ Paragraph, LinkImageEditing ]
+			} )
+			.then( newEditor => {
+				editor = newEditor;
+				model = editor.model;
+				view = editor.editing.view;
+			} );
+	} );
+
+	afterEach( () => {
+		return editor.destroy();
+	} );
+
+	it( 'should have pluginName', () => {
+		expect( LinkImageEditing.pluginName ).to.equal( 'LinkImageEditing' );
+	} );
+
+	it( 'should be loaded', () => {
+		expect( editor.plugins.get( LinkImageEditing ) ).to.be.instanceOf( LinkImageEditing );
+	} );
+
+	it( 'should set proper schema rules', () => {
+		expect( model.schema.checkAttribute( [ '$root', 'image' ], 'linkHref' ) ).to.be.true;
+	} );
+
+	describe( 'conversion in data pipeline', () => {
+		describe( 'model to view', () => {
+			it( 'should convert an image with a link', () => {
+				setModelData( model, '<image src="/assets/sample.png" alt="alt text" linkHref="http://ckeditor.com"></image>' );
+
+				expect( editor.getData() ).to.equal(
+					'<figure class="image"><a href="http://ckeditor.com"><img alt="alt text" src="/assets/sample.png"></a></figure>'
+				);
+			} );
+
+			it( 'should convert an image with a link and without alt attribute', () => {
+				setModelData( model, '<image src="/assets/sample.png" linkHref="http://ckeditor.com"></image>' );
+
+				expect( editor.getData() ).to.equal(
+					'<figure class="image"><a href="http://ckeditor.com"><img src="/assets/sample.png"></a></figure>'
+				);
+			} );
+
+			it( 'should convert srcset attribute to srcset and sizes attribute wrapped into a link', () => {
+				setModelData( model,
+					'<image src="/assets/sample.png" ' +
+						'linkHref="http://ckeditor.com" ' +
+						'srcset=\'{ "data": "small.png 148w, big.png 1024w" }\'>' +
+					'</image>'
+				);
+
+				expect( normalizeHtml( editor.getData() ) ).to.equal(
+					'<figure class="image">' +
+						'<a href="http://ckeditor.com">' +
+							'<img sizes="100vw" src="/assets/sample.png" srcset="small.png 148w, big.png 1024w"></img>' +
+						'</a>' +
+					'</figure>'
+				);
+			} );
+		} );
+
+		describe( 'view to model', () => {
+			describe( 'figure > a > img', () => {
+				it( 'should convert a link in an image figure', () => {
+					editor.setData(
+						'<figure class="image"><a href="http://ckeditor.com"><img src="/assets/sample.png" alt="alt text" /></a></figure>'
+					);
+
+					expect( getModelData( model, { withoutSelection: true } ) )
+						.to.equal( '<image alt="alt text" linkHref="http://ckeditor.com" src="/assets/sample.png"></image>' );
+				} );
+
+				it( 'should convert an image with a link and without alt attribute', () => {
+					editor.setData( '<figure class="image"><a href="http://ckeditor.com"><img src="/assets/sample.png" /></a></figure>' );
+
+					expect( getModelData( model, { withoutSelection: true } ) )
+						.to.equal( '<image linkHref="http://ckeditor.com" src="/assets/sample.png"></image>' );
+				} );
+
+				it( 'should not convert without src attribute', () => {
+					editor.setData( '<figure class="image"><a href="http://ckeditor.com"><img alt="alt text" /></a></figure>' );
+
+					expect( getModelData( model, { withoutSelection: true } ) )
+						.to.equal( '<paragraph></paragraph>' );
+				} );
+
+				it( 'should not convert in wrong context', () => {
+					model.schema.register( 'div', { inheritAllFrom: '$block' } );
+					model.schema.addChildCheck( ( ctx, childDef ) => {
+						if ( ctx.endsWith( '$root' ) && childDef.name == 'image' ) {
+							return false;
+						}
+					} );
+
+					editor.conversion.elementToElement( { model: 'div', view: 'div' } );
+
+					editor.setData(
+						'<div>' +
+							'<figure class="image">' +
+								'<a href="http://ckeditor.com">' +
+									'<img src="/assets/sample.png" alt="alt text" />' +
+								'</a>' +
+							'</figure>' +
+						'</div>' );
+
+					expect( getModelData( model, { withoutSelection: true } ) )
+						.to.equal( '<div></div>' );
+				} );
+
+				it( 'should not convert "a" element if is already consumed', () => {
+					editor.data.upcastDispatcher.on( 'element:a', ( evt, data, conversionApi ) => {
+						conversionApi.consumable.consume( data.viewItem, { attributes: [ 'href' ] } );
+					}, { priority: 'highest' } );
+
+					editor.setData(
+						'<figure class="image"><a href="http://ckeditor.com"><img src="/assets/sample.png" alt="alt text" /></a></figure>'
+					);
+
+					expect( editor.getData() ).to.equal( '<figure class="image"><img src="/assets/sample.png" alt="alt text"></figure>' );
+				} );
+
+				it( 'should not convert if a link misses "href" attribute', () => {
+					editor.setData(
+						'<figure class="image"><a href=""><img src="/assets/sample.png" alt="alt text" /></a></figure>'
+					);
+
+					expect( getModelData( model, { withoutSelection: true } ) )
+						.to.equal( '<image alt="alt text" src="/assets/sample.png"></image>' );
+				} );
+
+				it( 'should convert a link without an image to a paragraph with the link', () => {
+					editor.setData(
+						'<figure class="image"><a href="http://ckeditor.com">Foo</a></figure>'
+					);
+
+					expect( getModelData( model, { withoutSelection: true } ) )
+						.to.equal( '<paragraph><$text linkHref="http://ckeditor.com">Foo</$text></paragraph>' );
+				} );
+			} );
+
+			describe( 'a > img', () => {
+				it( 'should convert a link in an image figure', () => {
+					editor.setData(
+						'<a href="http://ckeditor.com"><img src="/assets/sample.png" alt="alt text" /></a>'
+					);
+
+					expect( getModelData( model, { withoutSelection: true } ) )
+						.to.equal( '<image alt="alt text" linkHref="http://ckeditor.com" src="/assets/sample.png"></image>' );
+				} );
+
+				it( 'should convert an image with a link and without alt attribute', () => {
+					editor.setData( '<a href="http://ckeditor.com"><img src="/assets/sample.png" /></a>' );
+
+					expect( getModelData( model, { withoutSelection: true } ) )
+						.to.equal( '<image linkHref="http://ckeditor.com" src="/assets/sample.png"></image>' );
+				} );
+
+				it( 'should not convert without src attribute', () => {
+					editor.setData( '<a href="http://ckeditor.com"><img alt="alt text" /></a>' );
+
+					expect( getModelData( model, { withoutSelection: true } ) )
+						.to.equal( '<paragraph></paragraph>' );
+				} );
+
+				it( 'should not convert in wrong context', () => {
+					model.schema.register( 'div', { inheritAllFrom: '$block' } );
+					model.schema.addChildCheck( ( ctx, childDef ) => {
+						if ( ctx.endsWith( '$root' ) && childDef.name == 'image' ) {
+							return false;
+						}
+					} );
+
+					editor.conversion.elementToElement( { model: 'div', view: 'div' } );
+
+					editor.setData(
+						'<div>' +
+							'<a href="http://ckeditor.com">' +
+								'<img src="/assets/sample.png" alt="alt text" />' +
+							'</a>' +
+						'</div>' );
+
+					expect( getModelData( model, { withoutSelection: true } ) )
+						.to.equal( '<div></div>' );
+				} );
+
+				it( 'should not convert "a" element if is already consumed', () => {
+					editor.data.upcastDispatcher.on( 'element:a', ( evt, data, conversionApi ) => {
+						conversionApi.consumable.consume( data.viewItem, { attributes: [ 'href' ] } );
+					}, { priority: 'highest' } );
+
+					editor.setData(
+						'<a href="http://ckeditor.com"><img src="/assets/sample.png" alt="alt text" /></a>'
+					);
+
+					expect( editor.getData() ).to.equal( '<figure class="image"><img src="/assets/sample.png" alt="alt text"></figure>' );
+				} );
+
+				it( 'should not convert if a link misses "href" attribute', () => {
+					editor.setData(
+						'<a href=""><img src="/assets/sample.png" alt="alt text" /></a>'
+					);
+
+					expect( getModelData( model, { withoutSelection: true } ) )
+						.to.equal( '<image alt="alt text" src="/assets/sample.png"></image>' );
+				} );
+			} );
+
+			describe( 'figure > a > img + figcaption', () => {
+				it( 'should convert a link and the caption element', () => {
+					return VirtualTestEditor
+						.create( {
+							plugins: [ Paragraph, LinkImageEditing, ImageCaptionEditing ]
+						} )
+						.then( editor => {
+							editor.setData(
+								'<figure class="image">' +
+									'<a href="http://ckeditor.com">' +
+										'<img src="/assets/sample.png" alt="alt text" />' +
+									'</a>' +
+									'<figcaption>' +
+										'Foo Bar.' +
+									'</figcaption>' +
+								'</figure>'
+							);
+
+							expect( getModelData( editor.model, { withoutSelection: true } ) ).to.equal(
+								'<image alt="alt text" linkHref="http://ckeditor.com" src="/assets/sample.png">' +
+									'<caption>Foo Bar.</caption>' +
+								'</image>'
+							);
+
+							return editor.destroy();
+						} );
+				} );
+			} );
+		} );
+	} );
+
+	describe( 'conversion in editing pipeline', () => {
+		describe( 'model to view', () => {
+			it( 'should convert the image element', () => {
+				setModelData( model, '<image linkHref="http://ckeditor.com" src="/assets/sample.png" alt="alt text"></image>' );
+
+				expect( getViewData( view, { withoutSelection: true } ) ).to.equal(
+					'<figure class="ck-widget image" contenteditable="false">' +
+						'<a href="http://ckeditor.com">' +
+							'<img alt="alt text" src="/assets/sample.png"></img>' +
+						'</a>' +
+					'</figure>'
+				);
+			} );
+
+			it( 'should convert attribute change', () => {
+				setModelData( model, '<image linkHref="http://ckeditor.com" src="/assets/sample.png" alt="alt text"></image>' );
+				const image = model.document.getRoot().getChild( 0 );
+
+				model.change( writer => {
+					writer.setAttribute( 'linkHref', 'https://ckeditor.com/why-ckeditor/', image );
+				} );
+
+				expect( getViewData( view, { withoutSelection: true } ) ).to.equal(
+					'<figure class="ck-widget image" contenteditable="false">' +
+						'<a href="https://ckeditor.com/why-ckeditor/">' +
+							'<img alt="alt text" src="/assets/sample.png"></img>' +
+						'</a>' +
+					'</figure>'
+				);
+			} );
+
+			it( 'should convert attribute removal', () => {
+				setModelData( model, '<image linkHref="http://ckeditor.com" src="/assets/sample.png" alt="alt text"></image>' );
+				const image = model.document.getRoot().getChild( 0 );
+
+				model.change( writer => {
+					writer.removeAttribute( 'linkHref', image );
+				} );
+
+				expect( getViewData( view, { withoutSelection: true } ) ).to.equal(
+					'<figure class="ck-widget image" contenteditable="false">' +
+						'<img alt="alt text" src="/assets/sample.png"></img>' +
+					'</figure>'
+				);
+			} );
+		} );
+
+		describe( 'figure > a > img + figcaption', () => {
+			it( 'should convert a link and the caption element', () => {
+				return VirtualTestEditor
+					.create( {
+						plugins: [ Paragraph, LinkImageEditing, ImageCaptionEditing ]
+					} )
+					.then( editor => {
+						setModelData( editor.model,
+							'<image linkHref="http://ckeditor.com" src="/assets/sample.png" alt="alt text">' +
+								'<caption>Foo Bar.</caption>' +
+							'</image>'
+						);
+
+						expect( getViewData( editor.editing.view, { withoutSelection: true } ) ).to.equal(
+							'<figure class="ck-widget image" contenteditable="false">' +
+								'<a href="http://ckeditor.com">' +
+									'<img alt="alt text" src="/assets/sample.png"></img>' +
+								'</a>' +
+								'<figcaption class="ck-editor__editable ck-editor__nested-editable" ' +
+									'contenteditable="true" data-placeholder="Enter image caption">' +
+										'Foo Bar.' +
+								'</figcaption>' +
+							'</figure>'
+						);
+						return editor.destroy();
+					} );
+			} );
+		} );
+	} );
+} );

+ 41 - 0
packages/ckeditor5-link/tests/linkimageui.js

@@ -0,0 +1,41 @@
+/**
+ * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+/* globals document */
+
+import ClassicTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/classictesteditor';
+import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils';
+import LinkImageUI from '../src/linkimageui';
+
+describe( 'LinkImageUI', () => {
+	let editor, editorElement;
+
+	testUtils.createSinonSandbox();
+
+	beforeEach( () => {
+		editorElement = document.createElement( 'div' );
+		document.body.appendChild( editorElement );
+
+		return ClassicTestEditor
+			.create( editorElement, {
+				plugins: [ LinkImageUI ]
+			} )
+			.then( newEditor => {
+				editor = newEditor;
+			} );
+	} );
+
+	afterEach( () => {
+		editorElement.remove();
+
+		return editor.destroy();
+	} );
+
+	describe( 'init()', () => {
+		it( 'does nothing', () => {
+			expect( editor.plugins.get( LinkImageUI ).init() ).to.equal( undefined );
+		} );
+	} );
+} );

+ 11 - 0
packages/ckeditor5-link/tests/manual/linkimage.html

@@ -0,0 +1,11 @@
+<div id="editor">
+	<figure class="image">
+		<a href="https://cksource.com">
+			<img alt="bar" src="sample.jpg">
+		</a>
+		<figcaption>CKEditor logo - caption</figcaption>
+	</figure>
+	<a href="https://cksource.com">
+		<img alt="bar" src="sample.jpg">
+	</a>
+</div>

+ 49 - 0
packages/ckeditor5-link/tests/manual/linkimage.js

@@ -0,0 +1,49 @@
+/**
+ * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+/* globals console, window, document */
+
+import ClassicEditor from '@ckeditor/ckeditor5-editor-classic/src/classiceditor';
+import ArticlePluginSet from '@ckeditor/ckeditor5-core/tests/_utils/articlepluginset';
+import LinkImage from '../../src/linkimage';
+
+ClassicEditor
+	.create( document.querySelector( '#editor' ), {
+		plugins: [ ArticlePluginSet, LinkImage ],
+		toolbar: [
+			'heading',
+			'|',
+			'bold',
+			'italic',
+			'link',
+			'bulletedList',
+			'numberedList',
+			'|',
+			'outdent',
+			'indent',
+			'|',
+			'blockQuote',
+			'insertTable',
+			'mediaEmbed',
+			'undo',
+			'redo'
+		],
+		image: {
+			toolbar: [ 'imageStyle:full', 'imageStyle:side', '|', 'imageTextAlternative' ]
+		},
+		table: {
+			contentToolbar: [
+				'tableColumn',
+				'tableRow',
+				'mergeTableCells'
+			]
+		}
+	} )
+	.then( editor => {
+		window.editor = editor;
+	} )
+	.catch( err => {
+		console.error( err.stack );
+	} );

+ 3 - 0
packages/ckeditor5-link/tests/manual/linkimage.md

@@ -0,0 +1,3 @@
+# Linking Image
+
+Both images should have defined the `linkHref` attribute.

BIN
packages/ckeditor5-link/tests/manual/sample.jpg


+ 21 - 1
packages/ckeditor5-table/src/converters/downcast.js

@@ -8,7 +8,7 @@
  */
 
 import TableWalker from './../tablewalker';
-import { toWidget, toWidgetEditable } from '@ckeditor/ckeditor5-widget/src/utils';
+import { toWidget, toWidgetEditable, setHighlightHandling } from '@ckeditor/ckeditor5-widget/src/utils';
 
 /**
  * Model table element to view table element conversion helper.
@@ -335,6 +335,13 @@ function renameViewTableCell( tableCell, desiredCellElementName, conversionApi )
 	const editable = viewWriter.createEditableElement( desiredCellElementName, viewCell.getAttributes() );
 	const renamedCell = toWidgetEditable( editable, viewWriter );
 
+	setHighlightHandling(
+		renamedCell,
+		viewWriter,
+		( element, descriptor, writer ) => writer.addClass( normalizeToArray( descriptor.classes ), element ),
+		( element, descriptor, writer ) => writer.removeClass( normalizeToArray( descriptor.classes ), element )
+	);
+
 	viewWriter.insert( viewWriter.createPositionAfter( viewCell ), renamedCell );
 	viewWriter.move( viewWriter.createRangeIn( viewCell ), viewWriter.createPositionAt( renamedCell, 0 ) );
 	viewWriter.remove( viewWriter.createRangeOn( viewCell ) );
@@ -343,6 +350,10 @@ function renameViewTableCell( tableCell, desiredCellElementName, conversionApi )
 	conversionApi.mapper.bindElements( tableCell, renamedCell );
 }
 
+function normalizeToArray( classes ) {
+	return Array.isArray( classes ) ? classes : [ classes ];
+}
+
 // Renames a table cell element in the view according to its location in the table.
 //
 // @param {module:table/tablewalker~TableSlot} tableSlot
@@ -376,6 +387,15 @@ function createViewTableCellElement( tableSlot, tableAttributes, insertPosition,
 		toWidgetEditable( conversionApi.writer.createEditableElement( cellElementName ), conversionApi.writer ) :
 		conversionApi.writer.createContainerElement( cellElementName );
 
+	if ( asWidget ) {
+		setHighlightHandling(
+			cellElement,
+			conversionApi.writer,
+			( element, descriptor, writer ) => writer.addClass( normalizeToArray( descriptor.classes ), element ),
+			( element, descriptor, writer ) => writer.removeClass( normalizeToArray( descriptor.classes ), element )
+		);
+	}
+
 	const tableCell = tableSlot.cell;
 
 	const firstChild = tableCell.getChild( 0 );

+ 30 - 5
packages/ckeditor5-table/src/tableclipboard.js

@@ -120,7 +120,7 @@ export default class TableClipboard extends Plugin {
 		}
 
 		// We might need to crop table before inserting so reference might change.
-		let pastedTable = getTableIfOnlyTableInContent( content );
+		let pastedTable = getTableIfOnlyTableInContent( content, model );
 
 		if ( !pastedTable ) {
 			return;
@@ -336,7 +336,7 @@ function expandTableSize( table, expectedHeight, expectedWidth, writer, tableUti
 	}
 }
 
-function getTableIfOnlyTableInContent( content ) {
+function getTableIfOnlyTableInContent( content, model ) {
 	// Table passed directly.
 	if ( content.is( 'table' ) ) {
 		return content;
@@ -344,11 +344,36 @@ function getTableIfOnlyTableInContent( content ) {
 
 	// We do not support mixed content when pasting table into table.
 	// See: https://github.com/ckeditor/ckeditor5/issues/6817.
-	if ( content.childCount != 1 || !content.getChild( 0 ).is( 'table' ) ) {
-		return null;
+	if ( content.childCount == 1 && content.getChild( 0 ).is( 'table' ) ) {
+		return content.getChild( 0 );
 	}
 
-	return content.getChild( 0 );
+	// If there are only whitespaces around a table then use that table for pasting.
+
+	const contentRange = model.createRangeIn( content );
+
+	for ( const element of contentRange.getItems() ) {
+		if ( element.is( 'table' ) ) {
+			// Stop checking if there is some content before table.
+			const rangeBefore = model.createRange( contentRange.start, model.createPositionBefore( element ) );
+
+			if ( model.hasContent( rangeBefore, { ignoreWhitespaces: true } ) ) {
+				return null;
+			}
+
+			// Stop checking if there is some content after table.
+			const rangeAfter = model.createRange( model.createPositionAfter( element ), contentRange.end );
+
+			if ( model.hasContent( rangeAfter, { ignoreWhitespaces: true } ) ) {
+				return null;
+			}
+
+			// There wasn't any content neither before nor after.
+			return element;
+		}
+	}
+
+	return null;
 }
 
 // Returns two-dimensional array that is addressed by [ row ][ column ] that stores cells anchored at given location.

+ 275 - 28
packages/ckeditor5-table/tests/converters/downcast.js

@@ -13,8 +13,8 @@ import { modelTable, viewTable } from '../_utils/utils';
 
 import TableEditing from '../../src/tableediting';
 
-describe( 'downcast converters', () => {
-	let editor, model, doc, root, view;
+describe.only( 'downcast converters', () => {
+	let editor, model, root, view;
 
 	testUtils.createSinonSandbox();
 
@@ -24,6 +24,8 @@ describe( 'downcast converters', () => {
 		model = editor.model;
 		root = model.document.getRoot( 'main' );
 		view = editor.editing.view;
+
+		markerConversion( editor.conversion );
 	} );
 
 	afterEach( () => {
@@ -31,15 +33,6 @@ describe( 'downcast converters', () => {
 	} );
 
 	describe( 'downcastInsertTable()', () => {
-		beforeEach( async () => {
-			editor = await VirtualTestEditor.create( { plugins: [ Paragraph, TableEditing ] } );
-
-			model = editor.model;
-			doc = model.document;
-			root = doc.getRoot( 'main' );
-			view = editor.editing.view;
-		} );
-
 		describe( 'editing pipeline', () => {
 			it( 'should create table as a widget', () => {
 				setModelData( model, modelTable( [ [ '' ] ] ) );
@@ -59,6 +52,55 @@ describe( 'downcast converters', () => {
 					'</figure>'
 				);
 			} );
+
+			it( 'should apply marker class on tableCell', () => {
+				setModelData( model, modelTable( [ [ '00' ] ] ) );
+
+				model.change( writer => {
+					const cell = root.getNodeByPath( [ 0, 0, 0 ] );
+
+					writer.addMarker( 'marker:yellow', {
+						range: writer.createRangeOn( cell ),
+						usingOperation: false
+					} );
+
+					checkCustomPropertyForHighlight( editor.editing.mapper.toViewElement( cell ) );
+				} );
+
+				assertEqualMarkup( getViewData( view, { withoutSelection: true } ),
+					'<figure class="ck-widget ck-widget_with-selection-handle table" contenteditable="false">' +
+						'<div class="ck ck-widget__selection-handle"></div>' +
+						'<table>' +
+							'<tbody>' +
+							'<tr>' +
+								'<td class="ck-editor__editable ck-editor__nested-editable highlight-yellow" contenteditable="true">' +
+									'<span style="display:inline-block">00</span>' +
+								'</td>' +
+							'</tr>' +
+							'</tbody>' +
+						'</table>' +
+					'</figure>'
+				);
+
+				model.change( writer => {
+					writer.removeMarker( 'marker:yellow' );
+				} );
+
+				assertEqualMarkup( getViewData( view, { withoutSelection: true } ),
+					'<figure class="ck-widget ck-widget_with-selection-handle table" contenteditable="false">' +
+						'<div class="ck ck-widget__selection-handle"></div>' +
+						'<table>' +
+							'<tbody>' +
+								'<tr>' +
+									'<td class="ck-editor__editable ck-editor__nested-editable" contenteditable="true">' +
+										'<span style="display:inline-block">00</span>' +
+									'</td>' +
+								'</tr>' +
+							'</tbody>' +
+						'</table>' +
+					'</figure>'
+				);
+			} );
 		} );
 
 		describe( 'data pipeline', () => {
@@ -310,11 +352,11 @@ describe( 'downcast converters', () => {
 
 				assertEqualMarkup( editor.getData(),
 					'<figure class="table">' +
-					'<table>' +
-					'<tbody>' +
-					'<tr><td>&nbsp;</td></tr>' +
-					'</tbody>' +
-					'</table>' +
+						'<table>' +
+							'<tbody>' +
+								'<tr><td>&nbsp;</td></tr>' +
+							'</tbody>' +
+						'</table>' +
 					'</figure>'
 				);
 			} );
@@ -545,6 +587,72 @@ describe( 'downcast converters', () => {
 					'</figure>'
 				);
 			} );
+
+			it( 'should apply marker class on tableCell', () => {
+				setModelData( model, modelTable( [ [ '00' ] ] ) );
+
+				const table = root.getChild( 0 );
+
+				model.change( writer => {
+					const firstRow = writer.createElement( 'tableRow' );
+					const cell = writer.createElement( 'tableCell' );
+
+					writer.insert( firstRow, table, 1 );
+					writer.insert( cell, firstRow, 'end' );
+
+					writer.addMarker( 'marker:yellow', {
+						range: writer.createRangeOn( cell ),
+						usingOperation: false
+					} );
+				} );
+
+				const cell = root.getNodeByPath( [ 0, 1, 0 ] );
+				checkCustomPropertyForHighlight( editor.editing.mapper.toViewElement( cell ) );
+
+				assertEqualMarkup( getViewData( view, { withoutSelection: true } ),
+					'<figure class="ck-widget ck-widget_with-selection-handle table" contenteditable="false">' +
+						'<div class="ck ck-widget__selection-handle"></div>' +
+						'<table>' +
+							'<tbody>' +
+								'<tr>' +
+									'<td class="ck-editor__editable ck-editor__nested-editable" contenteditable="true">' +
+										'<span style="display:inline-block">00</span>' +
+									'</td>' +
+								'</tr>' +
+								'<tr>' +
+									'<td class="ck-editor__editable ck-editor__nested-editable highlight-yellow" contenteditable="true">' +
+										'<span style="display:inline-block"></span>' +
+									'</td>' +
+								'</tr>' +
+							'</tbody>' +
+						'</table>' +
+					'</figure>'
+				);
+
+				model.change( writer => {
+					writer.removeMarker( 'marker:yellow' );
+				} );
+
+				assertEqualMarkup( getViewData( view, { withoutSelection: true } ),
+					'<figure class="ck-widget ck-widget_with-selection-handle table" contenteditable="false">' +
+						'<div class="ck ck-widget__selection-handle"></div>' +
+						'<table>' +
+							'<tbody>' +
+								'<tr>' +
+									'<td class="ck-editor__editable ck-editor__nested-editable" contenteditable="true">' +
+										'<span style="display:inline-block">00</span>' +
+									'</td>' +
+								'</tr>' +
+								'<tr>' +
+									'<td class="ck-editor__editable ck-editor__nested-editable" contenteditable="true">' +
+										'<span style="display:inline-block"></span>' +
+									'</td>' +
+								'</tr>' +
+							'</tbody>' +
+						'</table>' +
+					'</figure>'
+				);
+			} );
 		} );
 	} );
 
@@ -662,7 +770,7 @@ describe( 'downcast converters', () => {
 
 				assertEqualMarkup( getViewData( view, { withoutSelection: true } ),
 					'<figure class="ck-widget ck-widget_with-selection-handle table" contenteditable="false">' +
-					'<div class="ck ck-widget__selection-handle"></div>' +
+						'<div class="ck ck-widget__selection-handle"></div>' +
 						'<table>' +
 							'<tbody>' +
 								'<tr>' +
@@ -678,21 +786,70 @@ describe( 'downcast converters', () => {
 					'</figure>'
 				);
 			} );
-		} );
-	} );
 
-	describe( 'downcastTableHeadingColumnsChange()', () => {
-		beforeEach( () => {
-			return VirtualTestEditor.create( { plugins: [ Paragraph, TableEditing ] } )
-				.then( newEditor => {
-					editor = newEditor;
-					model = editor.model;
-					doc = model.document;
-					root = doc.getRoot( 'main' );
-					view = editor.editing.view;
+			it( 'should apply marker class on tableCell', () => {
+				setModelData( model, modelTable( [ [ '00' ] ] ) );
+
+				const table = root.getChild( 0 );
+
+				model.change( writer => {
+					const row = table.getChild( 0 );
+					const cell = writer.createElement( 'tableCell' );
+
+					writer.insert( cell, row, 'end' );
+					writer.addMarker( 'marker:yellow', {
+						range: writer.createRangeOn( cell ),
+						usingOperation: false
+					} );
 				} );
+
+				const cell = root.getNodeByPath( [ 0, 0, 1 ] );
+				checkCustomPropertyForHighlight( editor.editing.mapper.toViewElement( cell ) );
+
+				assertEqualMarkup( getViewData( view, { withoutSelection: true } ),
+					'<figure class="ck-widget ck-widget_with-selection-handle table" contenteditable="false">' +
+						'<div class="ck ck-widget__selection-handle"></div>' +
+						'<table>' +
+							'<tbody>' +
+								'<tr>' +
+									'<td class="ck-editor__editable ck-editor__nested-editable" contenteditable="true">' +
+										'<span style="display:inline-block">00</span>' +
+									'</td>' +
+									'<td class="ck-editor__editable ck-editor__nested-editable highlight-yellow" contenteditable="true">' +
+										'<span style="display:inline-block"></span>' +
+									'</td>' +
+								'</tr>' +
+							'</tbody>' +
+						'</table>' +
+					'</figure>'
+				);
+
+				model.change( writer => {
+					writer.removeMarker( 'marker:yellow' );
+				} );
+
+				assertEqualMarkup( getViewData( view, { withoutSelection: true } ),
+					'<figure class="ck-widget ck-widget_with-selection-handle table" contenteditable="false">' +
+						'<div class="ck ck-widget__selection-handle"></div>' +
+						'<table>' +
+							'<tbody>' +
+								'<tr>' +
+									'<td class="ck-editor__editable ck-editor__nested-editable" contenteditable="true">' +
+										'<span style="display:inline-block">00</span>' +
+									'</td>' +
+									'<td class="ck-editor__editable ck-editor__nested-editable" contenteditable="true">' +
+										'<span style="display:inline-block"></span>' +
+									'</td>' +
+								'</tr>' +
+							'</tbody>' +
+						'</table>' +
+					'</figure>'
+				);
+			} );
 		} );
+	} );
 
+	describe( 'downcastTableHeadingColumnsChange()', () => {
 		// The heading columns change downcast conversion is not executed in data pipeline.
 		describe( 'editing pipeline', () => {
 			it( 'should work for adding heading columns', () => {
@@ -857,6 +1014,40 @@ describe( 'downcast converters', () => {
 					'</figure>'
 				);
 			} );
+
+			it( 'should preserve marker class on tableCell', () => {
+				setModelData( model, modelTable( [
+					[ '00', '01', '02', '03' ],
+					[ '10', '11', '12', '13' ]
+				], { headingColumns: 1 } ) );
+
+				const table = root.getChild( 0 );
+
+				model.change( writer => {
+					const cell = root.getNodeByPath( [ 0, 0, 1 ] );
+
+					writer.addMarker( 'marker:yellow', {
+						range: writer.createRangeOn( cell ),
+						usingOperation: false
+					} );
+				} );
+
+				model.change( writer => {
+					writer.setAttribute( 'headingColumns', 3, table );
+				} );
+
+				const cell = root.getNodeByPath( [ 0, 0, 1 ] );
+				const viewElement = editor.editing.mapper.toViewElement( cell );
+
+				checkCustomPropertyForHighlight( viewElement );
+				expect( viewElement.hasClass( 'highlight-yellow' ) ).to.be.true;
+
+				model.change( writer => {
+					writer.removeMarker( 'marker:yellow' );
+				} );
+
+				expect( viewElement.hasClass( 'highlight-yellow' ) ).to.be.false;
+			} );
 		} );
 	} );
 
@@ -1067,6 +1258,41 @@ describe( 'downcast converters', () => {
 					'</figure>'
 				);
 			} );
+
+			it( 'should preserve marker class on tableCell', () => {
+				setModelData( model, modelTable( [
+					[ '00', '01' ],
+					[ '10', '11' ],
+					[ '20', '21' ]
+				], { headingRows: 1 } ) );
+
+				const table = root.getChild( 0 );
+
+				model.change( writer => {
+					const cell = root.getNodeByPath( [ 0, 1, 0 ] );
+
+					writer.addMarker( 'marker:yellow', {
+						range: writer.createRangeOn( cell ),
+						usingOperation: false
+					} );
+				} );
+
+				model.change( writer => {
+					writer.setAttribute( 'headingRows', 2, table );
+				} );
+
+				const cell = root.getNodeByPath( [ 0, 1, 0 ] );
+				const viewElement = editor.editing.mapper.toViewElement( cell );
+
+				checkCustomPropertyForHighlight( viewElement );
+				expect( viewElement.hasClass( 'highlight-yellow' ) ).to.be.true;
+
+				model.change( writer => {
+					writer.removeMarker( 'marker:yellow' );
+				} );
+
+				expect( viewElement.hasClass( 'highlight-yellow' ) ).to.be.false;
+			} );
 		} );
 	} );
 
@@ -1312,4 +1538,25 @@ describe( 'downcast converters', () => {
 			} );
 		} );
 	} );
+
+	function markerConversion( conversion, extraClasses = null ) {
+		conversion.for( 'editingDowncast' ).markerToHighlight( {
+			model: 'marker',
+			view: data => {
+				const className = 'highlight-' + data.markerName.split( ':' )[ 1 ];
+
+				return {
+					classes: extraClasses ? [ ...extraClasses, className ] : className
+				};
+			}
+		} );
+	}
+
+	function checkCustomPropertyForHighlight( viewElement ) {
+		const set = viewElement.getCustomProperty( 'addHighlight' );
+		const remove = viewElement.getCustomProperty( 'removeHighlight' );
+
+		expect( typeof set ).to.equal( 'function' );
+		expect( typeof remove ).to.equal( 'function' );
+	}
 } );

+ 162 - 3
packages/ckeditor5-table/tests/tableclipboard-paste.js

@@ -178,7 +178,8 @@ describe( 'table clipboard', () => {
 
 			const table = viewTable( [
 				[ 'aa', 'ab' ],
-				[ 'ba', 'bb' ] ] );
+				[ 'ba', 'bb' ]
+			] );
 
 			const data = {
 				dataTransfer: createDataTransfer(),
@@ -204,7 +205,8 @@ describe( 'table clipboard', () => {
 
 			const table = viewTable( [
 				[ 'aa', 'ab' ],
-				[ 'ba', 'bb' ] ] );
+				[ 'ba', 'bb' ]
+			] );
 
 			const data = {
 				dataTransfer: createDataTransfer(),
@@ -230,7 +232,8 @@ describe( 'table clipboard', () => {
 
 			const table = viewTable( [
 				[ 'aa', 'ab' ],
-				[ 'ba', 'bb' ] ] );
+				[ 'ba', 'bb' ]
+			] );
 
 			const data = {
 				dataTransfer: createDataTransfer(),
@@ -248,6 +251,162 @@ describe( 'table clipboard', () => {
 			] ) );
 		} );
 
+		it( 'should alter model.insertContent if mixed content is pasted (table + empty paragraph)', () => {
+			tableSelection.setCellSelection(
+				modelRoot.getNodeByPath( [ 0, 0, 0 ] ),
+				modelRoot.getNodeByPath( [ 0, 1, 1 ] )
+			);
+
+			const table = viewTable( [
+				[ 'aa', 'ab' ],
+				[ 'ba', 'bb' ]
+			] );
+
+			const data = {
+				dataTransfer: createDataTransfer(),
+				preventDefault: sinon.spy(),
+				stopPropagation: sinon.spy()
+			};
+			data.dataTransfer.setData( 'text/html', `${ table }<p>&nbsp;</p>` );
+			viewDocument.fire( 'paste', data );
+
+			assertEqualMarkup( getModelData( model, { withoutSelection: true } ), modelTable( [
+				[ 'aa', 'ab', '02', '03' ],
+				[ 'ba', 'bb', '12', '13' ],
+				[ '20', '21', '22', '23' ],
+				[ '30', '31', '32', '33' ]
+			] ) );
+		} );
+
+		it( 'should alter model.insertContent if mixed content is pasted (table + br)', () => {
+			tableSelection.setCellSelection(
+				modelRoot.getNodeByPath( [ 0, 0, 0 ] ),
+				modelRoot.getNodeByPath( [ 0, 1, 1 ] )
+			);
+
+			const table = viewTable( [
+				[ 'aa', 'ab' ],
+				[ 'ba', 'bb' ]
+			] );
+
+			const data = {
+				dataTransfer: createDataTransfer(),
+				preventDefault: sinon.spy(),
+				stopPropagation: sinon.spy()
+			};
+			data.dataTransfer.setData( 'text/html', `${ table }<br>` );
+			viewDocument.fire( 'paste', data );
+
+			assertEqualMarkup( getModelData( model, { withoutSelection: true } ), modelTable( [
+				[ 'aa', 'ab', '02', '03' ],
+				[ 'ba', 'bb', '12', '13' ],
+				[ '20', '21', '22', '23' ],
+				[ '30', '31', '32', '33' ]
+			] ) );
+		} );
+
+		it( 'should alter model.insertContent if mixed content is pasted (empty paragraph + table)', () => {
+			tableSelection.setCellSelection(
+				modelRoot.getNodeByPath( [ 0, 0, 0 ] ),
+				modelRoot.getNodeByPath( [ 0, 1, 1 ] )
+			);
+
+			const table = viewTable( [
+				[ 'aa', 'ab' ],
+				[ 'ba', 'bb' ]
+			] );
+
+			const data = {
+				dataTransfer: createDataTransfer(),
+				preventDefault: sinon.spy(),
+				stopPropagation: sinon.spy()
+			};
+			data.dataTransfer.setData( 'text/html', `<p>&nbsp;</p>${ table }` );
+			viewDocument.fire( 'paste', data );
+
+			assertEqualMarkup( getModelData( model, { withoutSelection: true } ), modelTable( [
+				[ 'aa', 'ab', '02', '03' ],
+				[ 'ba', 'bb', '12', '13' ],
+				[ '20', '21', '22', '23' ],
+				[ '30', '31', '32', '33' ]
+			] ) );
+		} );
+
+		it( 'should alter model.insertContent if mixed content is pasted (br + table)', () => {
+			tableSelection.setCellSelection(
+				modelRoot.getNodeByPath( [ 0, 0, 0 ] ),
+				modelRoot.getNodeByPath( [ 0, 1, 1 ] )
+			);
+
+			const table = viewTable( [
+				[ 'aa', 'ab' ],
+				[ 'ba', 'bb' ]
+			] );
+
+			const data = {
+				dataTransfer: createDataTransfer(),
+				preventDefault: sinon.spy(),
+				stopPropagation: sinon.spy()
+			};
+			data.dataTransfer.setData( 'text/html', `<br>${ table }` );
+			viewDocument.fire( 'paste', data );
+
+			assertEqualMarkup( getModelData( model, { withoutSelection: true } ), modelTable( [
+				[ 'aa', 'ab', '02', '03' ],
+				[ 'ba', 'bb', '12', '13' ],
+				[ '20', '21', '22', '23' ],
+				[ '30', '31', '32', '33' ]
+			] ) );
+		} );
+
+		it( 'should alter model.insertContent if mixed content is pasted (p + p + table + p + br)', () => {
+			tableSelection.setCellSelection(
+				modelRoot.getNodeByPath( [ 0, 0, 0 ] ),
+				modelRoot.getNodeByPath( [ 0, 1, 1 ] )
+			);
+
+			const table = viewTable( [
+				[ 'aa', 'ab' ],
+				[ 'ba', 'bb' ]
+			] );
+
+			const data = {
+				dataTransfer: createDataTransfer(),
+				preventDefault: sinon.spy(),
+				stopPropagation: sinon.spy()
+			};
+			data.dataTransfer.setData( 'text/html', `<p>&nbsp;</p><p>&nbsp;</p>${ table }<p>&nbsp;</p><br>` );
+			viewDocument.fire( 'paste', data );
+
+			assertEqualMarkup( getModelData( model, { withoutSelection: true } ), modelTable( [
+				[ 'aa', 'ab', '02', '03' ],
+				[ 'ba', 'bb', '12', '13' ],
+				[ '20', '21', '22', '23' ],
+				[ '30', '31', '32', '33' ]
+			] ) );
+		} );
+
+		it( 'should not alter model.insertContent if element other than a table is passed directly', () => {
+			tableSelection.setCellSelection(
+				modelRoot.getNodeByPath( [ 0, 0, 0 ] ),
+				modelRoot.getNodeByPath( [ 0, 1, 1 ] )
+			);
+
+			model.change( writer => {
+				const element = writer.createElement( 'paragraph' );
+
+				writer.insertText( 'foo', element, 0 );
+				model.insertContent( element );
+			} );
+
+			assertEqualMarkup( getModelData( model, { withoutSelection: true } ), modelTable( [
+				[ 'foo', '', '02', '03' ],
+				[ '', '', '12', '13' ],
+				[ '20', '21', '22', '23' ],
+				[ '30', '31', '32', '33' ]
+			] ) );
+		} );
+
 		it( 'should alter model.insertContent if selectable is a document selection', () => {
 			tableSelection.setCellSelection(
 				modelRoot.getNodeByPath( [ 0, 0, 0 ] ),

+ 1 - 0
packages/ckeditor5-ui/package.json

@@ -27,6 +27,7 @@
     "@ckeditor/ckeditor5-list": "^19.0.1",
     "@ckeditor/ckeditor5-mention": "^19.0.1",
     "@ckeditor/ckeditor5-paragraph": "^19.1.0",
+    "@ckeditor/ckeditor5-horizontal-line": "^19.0.1",
     "@ckeditor/ckeditor5-typing": "^19.0.1"
   },
   "engines": {

+ 29 - 1
packages/ckeditor5-ui/src/toolbar/balloon/balloontoolbar.js

@@ -209,6 +209,8 @@ export default class BalloonToolbar extends Plugin {
 	 */
 	show() {
 		const editor = this.editor;
+		const selection = editor.model.document.selection;
+		const schema = editor.model.schema;
 
 		// Do not add the toolbar to the balloon stack twice.
 		if ( this._balloon.hasView( this.toolbarView ) ) {
@@ -216,7 +218,13 @@ export default class BalloonToolbar extends Plugin {
 		}
 
 		// Do not show the toolbar when the selection is collapsed.
-		if ( editor.model.document.selection.isCollapsed ) {
+		if ( selection.isCollapsed ) {
+			return;
+		}
+
+		// Do not show the toolbar when there is more than one range in the selection and they fully contain object elements.
+		// See https://github.com/ckeditor/ckeditor5/issues/6443.
+		if ( selectionContainsOnlyMultipleObjects( selection, schema ) ) {
 			return;
 		}
 
@@ -356,6 +364,26 @@ function getBalloonPositions( isBackward ) {
 	];
 }
 
+// Returns "true" when the selection has multiple ranges and each range contains an object
+// and nothing else.
+//
+// @private
+// @param {module:engine/model/selection~Selection} selection
+// @param {module:engine/model/schema~Schema} schema
+// @returns {Boolean}
+function selectionContainsOnlyMultipleObjects( selection, schema ) {
+	// It doesn't contain multiple objects if there is only one range.
+	if ( selection.rangeCount === 1 ) {
+		return false;
+	}
+
+	return [ ...selection.getRanges() ].every( range => {
+		const element = range.getContainedElement();
+
+		return element && schema.isObject( element );
+	} );
+}
+
 /**
  * Contextual toolbar configuration. Used by the {@link module:ui/toolbar/balloon/balloontoolbar~BalloonToolbar}
  * feature.

+ 11 - 1
packages/ckeditor5-ui/tests/toolbar/balloon/balloontoolbar.js

@@ -14,6 +14,7 @@ import Bold from '@ckeditor/ckeditor5-basic-styles/src/bold';
 import Italic from '@ckeditor/ckeditor5-basic-styles/src/italic';
 import Underline from '@ckeditor/ckeditor5-basic-styles/src/underline';
 import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
+import HorizontalLine from '@ckeditor/ckeditor5-horizontal-line/src/horizontalline';
 import global from '@ckeditor/ckeditor5-utils/src/dom/global';
 import ResizeObserver from '@ckeditor/ckeditor5-utils/src/dom/resizeobserver';
 
@@ -55,7 +56,7 @@ describe( 'BalloonToolbar', () => {
 
 		return ClassicTestEditor
 			.create( editorElement, {
-				plugins: [ Paragraph, Bold, Italic, BalloonToolbar ],
+				plugins: [ Paragraph, Bold, Italic, BalloonToolbar, HorizontalLine ],
 				balloonToolbar: [ 'bold', 'italic' ]
 			} )
 			.then( newEditor => {
@@ -374,6 +375,15 @@ describe( 'BalloonToolbar', () => {
 			sinon.assert.notCalled( balloonAddSpy );
 		} );
 
+		// https://github.com/ckeditor/ckeditor5/issues/6443
+		it( 'should not add the #toolbarView to the #_balloon when the selection contains more than one fully contained object', () => {
+			// This is for multi cell selection in tables.
+			setData( model, '[<horizontalLine></horizontalLine>]<paragraph>foo</paragraph>[<horizontalLine></horizontalLine>]' );
+
+			balloonToolbar.show();
+			sinon.assert.notCalled( balloonAddSpy );
+		} );
+
 		it( 'should not add #toolbarView to the #_balloon when all components inside #toolbarView are disabled', () => {
 			Array.from( balloonToolbar.toolbarView.items ).forEach( item => {
 				item.isEnabled = false;