소스 검색

Merge branch 'master' into i/6693

Aleksander Nowodzinski 5 년 전
부모
커밋
c1db77bfc4

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

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

+ 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.
@@ -343,6 +343,13 @@ function renameViewTableCell( tableCell, desiredCellElementName, conversionApi,
 		const editable = viewWriter.createEditableElement( desiredCellElementName, viewCell.getAttributes() );
 		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 ) );
@@ -354,6 +361,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
@@ -388,6 +399,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 );

+ 266 - 1
packages/ckeditor5-table/tests/converters/downcast.js

@@ -329,6 +329,7 @@ describe( 'downcast converters', () => {
 
 						defaultSchema( model.schema );
 						defaultConversion( editor.conversion, true );
+						markerConversion( editor.conversion );
 					} );
 			} );
 
@@ -350,6 +351,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>'
+				);
+			} );
 		} );
 	} );
 
@@ -569,6 +619,7 @@ describe( 'downcast converters', () => {
 
 						defaultSchema( model.schema );
 						defaultConversion( editor.conversion, true );
+						markerConversion( editor.conversion );
 					} );
 			} );
 
@@ -602,6 +653,69 @@ 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"></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"></td>' +
+								'</tr>' +
+							'</tbody>' +
+						'</table>' +
+					'</figure>'
+				);
+			} );
 		} );
 	} );
 
@@ -731,6 +845,7 @@ describe( 'downcast converters', () => {
 
 						defaultSchema( model.schema );
 						defaultConversion( editor.conversion, true );
+						markerConversion( editor.conversion );
 					} );
 			} );
 
@@ -747,7 +862,64 @@ 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>' +
+									'<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"></td>' +
+								'</tr>' +
+							'</tbody>' +
+						'</table>' +
+					'</figure>'
+				);
+			} );
+
+			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"></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>' +
@@ -925,6 +1097,7 @@ describe( 'downcast converters', () => {
 
 						defaultSchema( model.schema );
 						defaultConversion( editor.conversion, true );
+						markerConversion( editor.conversion );
 					} );
 			} );
 
@@ -952,6 +1125,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;
+			} );
 		} );
 	} );
 
@@ -965,6 +1172,8 @@ describe( 'downcast converters', () => {
 					doc = model.document;
 					root = doc.getRoot( 'main' );
 					view = editor.editing.view;
+
+					markerConversion( editor.conversion, 'marker' );
 				} );
 		} );
 
@@ -1174,6 +1383,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;
+			} );
 		} );
 	} );
 
@@ -1485,4 +1729,25 @@ describe( 'downcast converters', () => {
 			], { asWidget: true } ) );
 		} );
 	} );
+
+	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' );
+	}
 } );

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