8
0
فهرست منبع

Merge branch 'master' into i/4600

Marek Lewandowski 5 سال پیش
والد
کامیت
bd97c2649b
66فایلهای تغییر یافته به همراه2451 افزوده شده و 300 حذف شده
  1. 1 1
      CHANGELOG.md
  2. 1 1
      docs/builds/guides/integration/advanced-setup.md
  3. 2 0
      docs/features/image-upload.md
  4. 2 2
      docs/features/index.md
  5. 9 3
      docs/framework/guides/contributing/development-environment.md
  6. 1 1
      docs/index.md
  7. 0 1
      package.json
  8. 9 0
      packages/ckeditor5-alignment/docs/features/text-alignment.md
  9. 10 1
      packages/ckeditor5-basic-styles/docs/features/basic-styles.md
  10. 3 2
      packages/ckeditor5-block-quote/docs/features/block-quote.md
  11. 1 1
      packages/ckeditor5-ckfinder/docs/features/ckfinder.md
  12. 7 0
      packages/ckeditor5-code-block/docs/features/code-blocks.md
  13. 3 5
      packages/ckeditor5-core/src/plugincollection.js
  14. 1 0
      packages/ckeditor5-engine/package.json
  15. 252 27
      packages/ckeditor5-engine/src/conversion/downcastdispatcher.js
  16. 37 5
      packages/ckeditor5-engine/src/conversion/downcasthelpers.js
  17. 2 4
      packages/ckeditor5-engine/src/conversion/upcasthelpers.js
  18. 2 15
      packages/ckeditor5-engine/src/view/observer/fakeselectionobserver.js
  19. 2 0
      packages/ckeditor5-engine/tests/conversion/downcastdispatcher.js
  20. 1141 0
      packages/ckeditor5-engine/tests/conversion/downcasthelpers.js
  21. 65 0
      packages/ckeditor5-engine/tests/manual/slotconversion.html
  22. 292 0
      packages/ckeditor5-engine/tests/manual/slotconversion.js
  23. 12 0
      packages/ckeditor5-engine/tests/manual/slotconversion.md
  24. 2 2
      packages/ckeditor5-engine/tests/model/differ.js
  25. 10 1
      packages/ckeditor5-font/docs/features/font.md
  26. 12 0
      packages/ckeditor5-heading/docs/_snippets/features/default-headings.html
  27. 30 0
      packages/ckeditor5-heading/docs/_snippets/features/default-headings.js
  28. 16 1
      packages/ckeditor5-heading/docs/features/headings.md
  29. 7 0
      packages/ckeditor5-heading/docs/features/title.md
  30. 8 0
      packages/ckeditor5-highlight/docs/features/highlight.md
  31. 4 1
      packages/ckeditor5-image/src/imageinsert/imageinsertui.js
  32. 2 2
      packages/ckeditor5-image/src/imageinsert/ui/imageinsertpanelview.js
  33. 3 8
      packages/ckeditor5-image/src/imagestyle/utils.js
  34. 14 0
      packages/ckeditor5-image/tests/imageinsert/imageinsertui.js
  35. 27 0
      packages/ckeditor5-image/tests/imageinsert/ui/imageinsertpanelview.js
  36. 2 1
      packages/ckeditor5-image/tests/imagestyle/utils.js
  37. 10 5
      packages/ckeditor5-image/tests/imageupload/imageuploadprogress.js
  38. 8 0
      packages/ckeditor5-indent/docs/features/indent.md
  39. 27 15
      packages/ckeditor5-list/docs/_snippets/features/lists-style.html
  40. 8 6
      packages/ckeditor5-list/docs/features/lists.md
  41. 0 1
      packages/ckeditor5-media-embed/src/mediaembedui.js
  42. 2 4
      packages/ckeditor5-media-embed/src/mediaregistry.js
  43. 10 0
      packages/ckeditor5-media-embed/src/ui/mediaformview.js
  44. 18 4
      packages/ckeditor5-media-embed/tests/mediaembedui.js
  45. 5 8
      packages/ckeditor5-mention/src/mentionui.js
  46. 9 4
      packages/ckeditor5-mention/tests/mentionui.js
  47. 7 0
      packages/ckeditor5-remove-format/docs/features/remove-format.md
  48. 54 18
      packages/ckeditor5-table/src/converters/downcast.js
  49. 31 59
      packages/ckeditor5-table/src/converters/table-cell-refresh-post-fixer.js
  50. 1 0
      packages/ckeditor5-table/src/converters/table-heading-rows-refresh-post-fixer.js
  51. 9 1
      packages/ckeditor5-table/src/tableediting.js
  52. 3 3
      packages/ckeditor5-table/tests/converters/downcast.js
  53. 1 1
      packages/ckeditor5-table/tests/converters/table-cell-paragraph-post-fixer.js
  54. 79 26
      packages/ckeditor5-table/tests/converters/table-cell-refresh-post-fixer.js
  55. 1 1
      packages/ckeditor5-ui/src/editableui/inline/inlineeditableuiview.js
  56. 2 4
      packages/ckeditor5-ui/src/toolbar/toolbarview.js
  57. 2 1
      packages/ckeditor5-ui/tests/toolbar/toolbarview.js
  58. 3 3
      packages/ckeditor5-upload/src/adapters/simpleuploadadapter.js
  59. 2 4
      packages/ckeditor5-upload/src/filerepository.js
  60. 2 1
      packages/ckeditor5-upload/tests/filerepository.js
  61. 62 23
      packages/ckeditor5-utils/src/ckeditorerror.js
  62. 71 9
      packages/ckeditor5-utils/tests/ckeditorerror.js
  63. 5 5
      packages/ckeditor5-utils/tests/locale.js
  64. 2 6
      packages/ckeditor5-widget/src/widgettoolbarrepository.js
  65. 2 2
      packages/ckeditor5-word-count/src/wordcount.js
  66. 23 1
      scripts/docs/snippetadapter.js

+ 1 - 1
CHANGELOG.md

@@ -18,7 +18,7 @@ Other than that, we focused on bug fixes and stability improvements. Some highli
 
 
 Please note that there are some **major breaking changes**. Be sure to review them before upgrading.
 Please note that there are some **major breaking changes**. Be sure to review them before upgrading.
 
 
-<!-- TODO: Add a link to the blog post. -->
+Read more in the blog post: https://ckeditor.com/blog/CKEditor-5-v23.0.0-with-pagination-feature-list-styles-and-improved-image-upload/
 
 
 ### Collaboration features
 ### Collaboration features
 
 

+ 1 - 1
docs/builds/guides/integration/advanced-setup.md

@@ -602,7 +602,7 @@ index c57e371..04fc9fe 100644
 Once you changed the `src/ckeditor.js` and `webpack.config.js` files it is time to rebuild the build:
 Once you changed the `src/ckeditor.js` and `webpack.config.js` files it is time to rebuild the build:
 
 
 ```bash
 ```bash
-yarn run build
+npm run build
 ```
 ```
 
 
 Finally, when webpack finishes compiling your super build, you can change the `samples/index.html` file to test both editors:
 Finally, when webpack finishes compiling your super build, you can change the `samples/index.html` file to test both editors:

+ 2 - 0
docs/features/image-upload.md

@@ -22,6 +22,8 @@ The software that makes the image upload possible is called an **upload adapter*
 * [**Official upload adapters**](#official-upload-adapters) &ndash; There are several features providing upload adapters developed and maintained by the CKEditor team. Pick the best one for your integration and let it handle the image upload in your project.
 * [**Official upload adapters**](#official-upload-adapters) &ndash; There are several features providing upload adapters developed and maintained by the CKEditor team. Pick the best one for your integration and let it handle the image upload in your project.
 * [**Custom upload adapters**](#implementing-your-own-upload-adapter) &ndash; Create your own upload adapter from scratch using the open API architecture of CKEditor 5.
 * [**Custom upload adapters**](#implementing-your-own-upload-adapter) &ndash; Create your own upload adapter from scratch using the open API architecture of CKEditor 5.
 
 
+Read our comprehensive blog post about [Managing images with CKEditor 5](https://ckeditor.com/blog/managing-images-with-ckeditor-5/) to find out more details about image upload and management and to compare the available options.
+
 <info-box>
 <info-box>
 	If you want to get a better look under the hood and learn more about the upload process, you can check out the {@link framework/guides/deep-dive/upload-adapter "Custom image upload adapter" deep dive guide} covering that topic.
 	If you want to get a better look under the hood and learn more about the upload process, you can check out the {@link framework/guides/deep-dive/upload-adapter "Custom image upload adapter" deep dive guide} covering that topic.
 </info-box>
 </info-box>

+ 2 - 2
docs/features/index.md

@@ -20,8 +20,8 @@ The number of features available for CKEditor 5 is constantly growing. Plenty of
 Each rich-text editor feature is presented on a separate page, with one or more working demos showcasing a feature along with some customization ideas that you can use in your implementation.
 Each rich-text editor feature is presented on a separate page, with one or more working demos showcasing a feature along with some customization ideas that you can use in your implementation.
 
 
 <info-box>
 <info-box>
-	**In most demos the number of features enabled is limited** to make the currently highlighted piece of functionality stand out more. However, in your CKEditor 5 WYSIWYG editor implementation you are free to choose and combine any features you like from those available.
-</info-box> 
+	**In most demos the number of features enabled is limited** to make the currently highlighted piece of functionality stand out more. However, in your CKEditor 5 WYSIWYG editor implementation you are free to choose and combine any features you like from those available. This can be easily and conveniently done in the [CKEditor 5 online builder](https://ckeditor.com/ckeditor-5/online-builder/).
+</info-box>
 
 
 ## Looking for more?
 ## Looking for more?
 
 

+ 9 - 3
docs/framework/guides/contributing/development-environment.md

@@ -95,9 +95,15 @@ This task accepts the following arguments:
 
 
 * `--skip-api` &ndash; Skips building the API documentation (which takes the majority of the total time).
 * `--skip-api` &ndash; Skips building the API documentation (which takes the majority of the total time).
 * `--skip-snippets` &ndash; Skips building live snippets.
 * `--skip-snippets` &ndash; Skips building live snippets.
-* `--snippets=snippet-name` &ndash; Snippets to build (accepts glob patterns). If a snippet that you want to build uses another snippet as a source that provides an editor instance, you need to specify both snippets. See examples:
-    - `--snippets=features/*` - all snippets that starts with `features/` in their names will be built,
-    - `--snippets=classic-editor,build-classic-source` - all snippets that contains the specified strings in their names will be built.
+* `--snippets=snippet-name` &ndash; Snippets to build. Accepts glob patterns that are matched against snippet names used in `{@snippet ...}` tags. Examples:
+
+	```
+	--snippets=image         // matches roughly {@snippet *image*}
+	--snippets="features/*"  // matches roughly {@snippet *features/*}
+	--snippets=classic-editor,build-classic-source
+	```
+
+	Note: If a snippet that you want to build uses another snippet as a source that provides an editor instance, you need to specify both snippets (e.g. `--files=features/default-headings,build-classic-source`).
 * `--skip-validation` &ndash; Skips the final link validation.
 * `--skip-validation` &ndash; Skips the final link validation.
 * `--watch` &ndash; Runs the documentation generator in a watch mode. It covers guides but it does not cover API docs.
 * `--watch` &ndash; Runs the documentation generator in a watch mode. It covers guides but it does not cover API docs.
 * `--production` &ndash; Minifies the assets and performs other actions which are unnecessary during CKEditor 5 development.
 * `--production` &ndash; Minifies the assets and performs other actions which are unnecessary during CKEditor 5 development.

+ 1 - 1
docs/index.md

@@ -11,7 +11,7 @@ meta-description: Learn how to install, integrate, configure and develop CKEdito
 <dl><dt>{@link builds/guides/overview CKEditor 5 Builds documentation}</dt><dd>Learn how to install, integrate and configure CKEditor 5 Builds. More complex aspects, like creating custom builds, are explained here, too.</dd>
 <dl><dt>{@link builds/guides/overview CKEditor 5 Builds documentation}</dt><dd>Learn how to install, integrate and configure CKEditor 5 Builds. More complex aspects, like creating custom builds, are explained here, too.</dd>
 <dt>{@link framework/guides/overview CKEditor 5 Framework documentation}</dt><dd>Learn how to work with CKEditor 5 Framework, customize it, create your own plugins and custom editors, change the UI or even bring your own UI to the editor.</dd>
 <dt>{@link framework/guides/overview CKEditor 5 Framework documentation}</dt><dd>Learn how to work with CKEditor 5 Framework, customize it, create your own plugins and custom editors, change the UI or even bring your own UI to the editor.</dd>
 <dt>{@link examples/index CKEditor 5 Examples}</dt><dd>Try out all CKEditor 5 Builds. See some of the possible customizations of CKEditor.</dd>
 <dt>{@link examples/index CKEditor 5 Examples}</dt><dd>Try out all CKEditor 5 Builds. See some of the possible customizations of CKEditor.</dd>
-<dt>{@link features/index CKEditor 5 Features}</dt><dd>Learn about selected features included in CKEditor 5 Builds.</dd>
+<dt>{@link features/index CKEditor 5 Features}</dt><dd>Learn about the features available for CKEditor 5 &mdash; both the ones included in Builds and a plethora of others.</dd>
 <dt>{@link api/index CKEditor 5 API Reference}</dt><dd>A complete API documentation.</dd></dl>
 <dt>{@link api/index CKEditor 5 API Reference}</dt><dd>A complete API documentation.</dd></dl>
 
 
 ## Contribute
 ## Contribute

+ 0 - 1
package.json

@@ -100,7 +100,6 @@
     "http-server": "^0.12.3",
     "http-server": "^0.12.3",
     "husky": "^4.2.5",
     "husky": "^4.2.5",
     "lint-staged": "^10.2.6",
     "lint-staged": "^10.2.6",
-    "marked": "^1.1.1",
     "mini-css-extract-plugin": "^0.9.0",
     "mini-css-extract-plugin": "^0.9.0",
     "minimatch": "^3.0.4",
     "minimatch": "^3.0.4",
     "mkdirp": "^1.0.4",
     "mkdirp": "^1.0.4",

+ 9 - 0
packages/ckeditor5-alignment/docs/features/text-alignment.md

@@ -11,6 +11,15 @@ The {@link module:alignment/alignment~Alignment} feature enables support for tex
 
 
 {@snippet features/text-alignment}
 {@snippet features/text-alignment}
 
 
+## Related features
+
+There are more CKEditor 5 features that can help you organize your content:
+* {@link features/title Document title} &ndash; Clearly divide your content into a title and body.
+* {@link features/headings Headings} &ndash; Split your content into logical sections.
+* {@link features/indent Block indentation} &ndash; Organize your content into visually separated blocks, indent crucial paragraphs, etc.
+* {@link features/block-quote Block quote} &ndash; Include block quotations or pull quotes in the rich-text content.
+* {@link features/remove-format Remove format} &ndash; Easily clean basic text formatting.
+
 ## Configuring alignment options
 ## Configuring alignment options
 
 
 It is possible to configure which alignment options are available in the editor by setting the {@link module:alignment/alignment~AlignmentConfig#options `alignment.options`} configuration option. You can choose from `'left'`, `'right'`, `'center'` and `'justify'`.
 It is possible to configure which alignment options are available in the editor by setting the {@link module:alignment/alignment~AlignmentConfig#options `alignment.options`} configuration option. You can choose from `'left'`, `'right'`, `'center'` and `'justify'`.

+ 10 - 1
packages/ckeditor5-basic-styles/docs/features/basic-styles.md

@@ -5,7 +5,7 @@ category: features
 
 
 {@snippet features/build-basic-styles-source}
 {@snippet features/build-basic-styles-source}
 
 
-The {@link api/basic-styles basic styles} package provides text formatting features such as bold, italic, underline, strikethrough, subscript, superscript, and code.
+The {@link api/basic-styles basic styles} feature allows you to apply the most frequently used formatting indispensable for content creation. This package provides essential text styling features such as bold, italic, underline, strikethrough, subscript, superscript, and code. Coupled with more [formatting features](#related-features), these serve as a base for any WYSIWYG editor toolset.
 
 
 <info-box info>
 <info-box info>
 	All basic text styles can be removed with the {@link features/remove-format remove format} feature.
 	All basic text styles can be removed with the {@link features/remove-format remove format} feature.
@@ -15,6 +15,15 @@ The {@link api/basic-styles basic styles} package provides text formatting featu
 
 
 {@snippet features/basic-styles}
 {@snippet features/basic-styles}
 
 
+## Related features
+
+Check out also these CKEditor 5 features to gain better control over your content style and format:
+* {@link features/font Font styles} &ndash; Easily and efficiently control the font {@link features/font#configuring-the-font-family-feature family}, {@link features/font#configuring-the-font-size-feature size}, {@link features/font#configuring-the-font-color-and-font-background-color-features text or background color}.
+* {@link features/text-alignment Text alignment} &ndash; Because it does matter whether the content is left, right, centered or justified.
+* {@link features/code-blocks Code blocks}  &ndash; Insert longer, multiline code listings, expanding the inline code style greatly.
+* {@link features/highlight Highlight} &ndash; Mark important words and passages, aiding a review or drawing attention to specific parts of content.
+* {@link features/remove-format Remove format} &ndash; Easily clean basic text formatting.
+
 ## Available text styles
 ## Available text styles
 
 
 | Style feature | {@link framework/guides/architecture/core-editor-architecture#commands Command} name | {@link builds/guides/integration/configuration#toolbar-setup Toolbar} component name | Output element |
 | Style feature | {@link framework/guides/architecture/core-editor-architecture#commands Command} name | {@link builds/guides/integration/configuration#toolbar-setup Toolbar} component name | Output element |

+ 3 - 2
packages/ckeditor5-block-quote/docs/features/block-quote.md

@@ -17,8 +17,9 @@ Use the editor below to see the block quote plugin in action.
 
 
 Here are some other CKEditor 5 features that you can use similarly to the block quote plugin to structure your text better:
 Here are some other CKEditor 5 features that you can use similarly to the block quote plugin to structure your text better:
 
 
-* The {@link features/indent block indentation feature} allows you to set indentation for text blocks such as paragraphs or lists.
-* The {@link features/code-blocks code block feature} allows for insertion of various code listings.
+* {@link features/indent Block indentation}  &ndash; Set indentation for text blocks such as paragraphs or lists.
+* {@link features/code-blocks Code block}  &ndash; Insert longer, multiline code listings.
+* {@link features/text-alignment Text alignment} &ndash; Align your content left, right, center it or justify.
 
 
 ## Installation
 ## Installation
 
 

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

@@ -37,7 +37,7 @@ This feature can be used in the rich-text editor in two different ways:
 
 
 ### Image upload only
 ### Image upload only
 
 
-This demo shows the integration where the file manager's server connector handles [the image upload](#configuring-the-full-integration) only:
+This demo shows the integration where the file manager's server connector handles [the image upload](#configuring-the-image-upload-only) only:
 
 
 * Paste the image directly into the rich-text editor content and it will be automatically uploaded using the server-side connector.
 * Paste the image directly into the rich-text editor content and it will be automatically uploaded using the server-side connector.
 * Use the "Insert image" button in the toolbar to insert an image.
 * Use the "Insert image" button in the toolbar to insert an image.

+ 7 - 0
packages/ckeditor5-code-block/docs/features/code-blocks.md

@@ -16,6 +16,13 @@ The {@link module:code-block/codeblock~CodeBlock} feature allows inserting and e
 
 
 {@snippet features/code-block}
 {@snippet features/code-block}
 
 
+## Related features
+
+Here are some CKEditor 5 features that you may find helpfully similar:
+* {@link features/basic-styles Basic text styles} &ndash; Use the `code` formatting for short inline code chunks.
+* {@link features/block-quote Block quote} &ndash; Include block quotations or pull quotes in your rich-text content.
+* {@link features/indent Block indentation} &ndash; Set indentation for text blocks such as paragraphs or lists.
+
 ## Configuring code block languages
 ## Configuring code block languages
 
 
 Each code block can be assigned a programming language. The language of the code block is represented as a CSS class of the `<code>` element, both when editing and in the editor data:
 Each code block can be assigned a programming language. The language of the code block is represented as a CSS class of the `<code>` element, both when editing and in the editor data:

+ 3 - 5
packages/ckeditor5-core/src/plugincollection.js

@@ -7,9 +7,7 @@
  * @module core/plugincollection
  * @module core/plugincollection
  */
  */
 
 
-/* globals console */
-
-import CKEditorError, { attachLinkToDocumentation } from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
+import CKEditorError, { logError } from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
 
 
 import EmitterMixin from '@ckeditor/ckeditor5-utils/src/emittermixin';
 import EmitterMixin from '@ckeditor/ckeditor5-utils/src/emittermixin';
 import mix from '@ckeditor/ckeditor5-utils/src/mix';
 import mix from '@ckeditor/ckeditor5-utils/src/mix';
@@ -207,7 +205,7 @@ export default class PluginCollection {
 			const errorId = 'plugincollection-plugin-not-found';
 			const errorId = 'plugincollection-plugin-not-found';
 
 
 			// Log the error, so it's more visible on the console. Hopefully, for better DX.
 			// Log the error, so it's more visible on the console. Hopefully, for better DX.
-			console.error( attachLinkToDocumentation( errorId ), { plugins: missingPlugins } );
+			logError( errorId, { plugins: missingPlugins } );
 
 
 			return Promise.reject( new CKEditorError( errorId, context, { plugins: missingPlugins } ) );
 			return Promise.reject( new CKEditorError( errorId, context, { plugins: missingPlugins } ) );
 		}
 		}
@@ -249,7 +247,7 @@ export default class PluginCollection {
 					 * @error plugincollection-load
 					 * @error plugincollection-load
 					 * @param {String} plugin The name of the plugin that could not be loaded.
 					 * @param {String} plugin The name of the plugin that could not be loaded.
 					 */
 					 */
-					console.error( attachLinkToDocumentation( 'plugincollection-load' ), { plugin: PluginConstructor } );
+					logError( 'plugincollection-load', { plugin: PluginConstructor } );
 
 
 					throw err;
 					throw err;
 				} );
 				} );

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

@@ -38,6 +38,7 @@
     "@ckeditor/ckeditor5-table": "^23.0.0",
     "@ckeditor/ckeditor5-table": "^23.0.0",
     "@ckeditor/ckeditor5-theme-lark": "^23.0.0",
     "@ckeditor/ckeditor5-theme-lark": "^23.0.0",
     "@ckeditor/ckeditor5-typing": "^23.0.0",
     "@ckeditor/ckeditor5-typing": "^23.0.0",
+    "@ckeditor/ckeditor5-ui": "^23.0.0",
     "@ckeditor/ckeditor5-undo": "^23.0.0",
     "@ckeditor/ckeditor5-undo": "^23.0.0",
     "@ckeditor/ckeditor5-widget": "^23.0.0"
     "@ckeditor/ckeditor5-widget": "^23.0.0"
   },
   },

+ 252 - 27
packages/ckeditor5-engine/src/conversion/downcastdispatcher.js

@@ -9,6 +9,8 @@
 
 
 import Consumable from './modelconsumable';
 import Consumable from './modelconsumable';
 import Range from '../model/range';
 import Range from '../model/range';
+import Position, { getNodeAfterPosition, getTextNodeAtPosition } from '../model/position';
+
 import EmitterMixin from '@ckeditor/ckeditor5-utils/src/emittermixin';
 import EmitterMixin from '@ckeditor/ckeditor5-utils/src/emittermixin';
 import mix from '@ckeditor/ckeditor5-utils/src/mix';
 import mix from '@ckeditor/ckeditor5-utils/src/mix';
 
 
@@ -121,6 +123,14 @@ export default class DowncastDispatcher {
 		 * @member {module:engine/conversion/downcastdispatcher~DowncastConversionApi}
 		 * @member {module:engine/conversion/downcastdispatcher~DowncastConversionApi}
 		 */
 		 */
 		this.conversionApi = Object.assign( { dispatcher: this }, conversionApi );
 		this.conversionApi = Object.assign( { dispatcher: this }, conversionApi );
+
+		/**
+		 * Maps conversion event names that will trigger element reconversion for given element name.
+		 *
+		 * @type {Map<String, String>}
+		 * @private
+		 */
+		this._reconversionEventsMapping = new Map();
 	}
 	}
 
 
 	/**
 	/**
@@ -136,14 +146,18 @@ export default class DowncastDispatcher {
 			this.convertMarkerRemove( change.name, change.range, writer );
 			this.convertMarkerRemove( change.name, change.range, writer );
 		}
 		}
 
 
+		const changes = this._mapChangesWithAutomaticReconversion( differ );
+
 		// Convert changes that happened on model tree.
 		// Convert changes that happened on model tree.
-		for ( const entry of differ.getChanges() ) {
-			if ( entry.type == 'insert' ) {
+		for ( const entry of changes ) {
+			if ( entry.type === 'insert' ) {
 				this.convertInsert( Range._createFromPositionAndShift( entry.position, entry.length ), writer );
 				this.convertInsert( Range._createFromPositionAndShift( entry.position, entry.length ), writer );
-			} else if ( entry.type == 'remove' ) {
+			} else if ( entry.type === 'remove' ) {
 				this.convertRemove( entry.position, entry.length, entry.name, writer );
 				this.convertRemove( entry.position, entry.length, entry.name, writer );
+			} else if ( entry.type === 'reconvert' ) {
+				this.reconvertElement( entry.element, writer );
 			} else {
 			} else {
-				// entry.type == 'attribute'.
+				// Defaults to 'attribute' change.
 				this.convertAttribute( entry.range, entry.attributeKey, entry.attributeOldValue, entry.attributeNewValue, writer );
 				this.convertAttribute( entry.range, entry.attributeKey, entry.attributeOldValue, entry.attributeNewValue, writer );
 			}
 			}
 		}
 		}
@@ -179,26 +193,8 @@ export default class DowncastDispatcher {
 		this.conversionApi.consumable = this._createInsertConsumable( range );
 		this.conversionApi.consumable = this._createInsertConsumable( range );
 
 
 		// Fire a separate insert event for each node and text fragment contained in the range.
 		// Fire a separate insert event for each node and text fragment contained in the range.
-		for ( const value of range ) {
-			const item = value.item;
-			const itemRange = Range._createFromPositionAndShift( value.previousPosition, value.length );
-			const data = {
-				item,
-				range: itemRange
-			};
-
-			this._testAndFire( 'insert', data );
-
-			// Fire a separate addAttribute event for each attribute that was set on inserted items.
-			// This is important because most attributes converters will listen only to add/change/removeAttribute events.
-			// If we would not add this part, attributes on inserted nodes would not be converted.
-			for ( const key of item.getAttributeKeys() ) {
-				data.attributeKey = key;
-				data.attributeOldValue = null;
-				data.attributeNewValue = item.getAttribute( key );
-
-				this._testAndFire( `attribute:${ key }`, data );
-			}
+		for ( const data of Array.from( range ).map( walkerValueToEventData ) ) {
+			this._convertInsertWithAttributes( data );
 		}
 		}
 
 
 		this._clearConversionApi();
 		this._clearConversionApi();
@@ -256,6 +252,71 @@ export default class DowncastDispatcher {
 		this._clearConversionApi();
 		this._clearConversionApi();
 	}
 	}
 
 
+	/**
+	 * Starts a reconversion of an element. It will:
+	 *
+	 * * Fire a {@link #event:insert `insert` event} for the element to reconvert.
+	 * * Fire an {@link #event:attribute `attribute` event} for element attributes.
+	 *
+	 * This will not reconvert children of the element if they have existing (already converted) views. For newly inserted child elements
+	 * it will behave the same as {@link #convertInsert}.
+	 *
+	 * Element reconversion is defined by a `triggerBy` configuration for
+	 * {@link module:engine/conversion/downcasthelpers~DowncastHelpers#elementToElement `elementToElement()`} conversion helper.
+	 *
+	 * @fires insert
+	 * @fires attribute
+	 * @param {module:engine/model/element~Element} element The element to be reconverted.
+	 * @param {module:engine/view/downcastwriter~DowncastWriter} writer The view writer that should be used to modify the view document.
+	 */
+	reconvertElement( element, writer ) {
+		const elementRange = Range._createOn( element );
+
+		this.conversionApi.writer = writer;
+
+		// Create a list of things that can be consumed, consisting of nodes and their attributes.
+		this.conversionApi.consumable = this._createInsertConsumable( elementRange );
+
+		const mapper = this.conversionApi.mapper;
+		const currentView = mapper.toViewElement( element );
+
+		// Remove the old view but do not remove mapper mappings - those will be used to revive existing elements.
+		writer.remove( currentView );
+
+		// Convert the element - without converting children.
+		this._convertInsertWithAttributes( {
+			item: element,
+			range: elementRange
+		} );
+
+		const convertedViewElement = mapper.toViewElement( element );
+
+		// Iterate over children of reconverted element in order to...
+		for ( const value of Range._createIn( element ) ) {
+			const { item } = value;
+
+			const view = elementOrTextProxyToView( item, mapper );
+
+			// ...either bring back previously converted view...
+			if ( view ) {
+				// Do not move views that are already in converted element - those might be created by the main element converter in case
+				// when main element converts also its direct children.
+				if ( view.root !== convertedViewElement.root ) {
+					writer.move(
+						writer.createRangeOn( view ),
+						mapper.toViewPosition( Position._createBefore( item ) )
+					);
+				}
+			}
+			// ... or by converting newly inserted elements.
+			else {
+				this._convertInsertWithAttributes( walkerValueToEventData( value ) );
+			}
+		}
+
+		this._clearConversionApi();
+	}
+
 	/**
 	/**
 	 * Starts model selection conversion.
 	 * Starts model selection conversion.
 	 *
 	 *
@@ -393,6 +454,25 @@ export default class DowncastDispatcher {
 		this._clearConversionApi();
 		this._clearConversionApi();
 	}
 	}
 
 
+	/**
+	 * Maps model element "insert" reconversion for given event names. The event names must be fully specified:
+	 *
+	 * * For "attribute" change event it should include main element name, ie: `'attribute:attributeName:elementName'`.
+	 * * For child nodes change events, those should use child event name as well, ie:
+	 *     * For adding a node: `'insert:childElementName'`.
+	 *     * For removing a node: `'remove:childElementName'`.
+	 *
+	 * **Note**: This method should not be used directly. A reconversion is defined by `triggerBy` configuration of the `elementToElement()`
+	 * conversion helper.
+	 *
+	 * @protected
+	 * @param {String} modelName Main model element name for which events will trigger reconversion.
+	 * @param {String} eventName Name of an event that would trigger conversion for given model element.
+	 */
+	_mapReconversionTriggerEvent( modelName, eventName ) {
+		this._reconversionEventsMapping.set( eventName, modelName );
+	}
+
 	/**
 	/**
 	 * Creates {@link module:engine/conversion/modelconsumable~ModelConsumable} with values to consume from given range,
 	 * Creates {@link module:engine/conversion/modelconsumable~ModelConsumable} with values to consume from given range,
 	 * assuming that the range has just been inserted to the model.
 	 * assuming that the range has just been inserted to the model.
@@ -474,9 +554,7 @@ export default class DowncastDispatcher {
 			return;
 			return;
 		}
 		}
 
 
-		const name = data.item.name || '$text';
-
-		this.fire( type + ':' + name, data, this.conversionApi );
+		this.fire( getEventName( type, data ), data, this.conversionApi );
 	}
 	}
 
 
 	/**
 	/**
@@ -489,6 +567,126 @@ export default class DowncastDispatcher {
 		delete this.conversionApi.consumable;
 		delete this.conversionApi.consumable;
 	}
 	}
 
 
+	/**
+	 * Internal method for converting element insert. It will fire events for the inserted element and events for its attributes.
+	 *
+	 * @private
+	 * @fires insert
+	 * @fires attribute
+	 * @param {Object} data Event data.
+	 */
+	_convertInsertWithAttributes( data ) {
+		this._testAndFire( 'insert', data );
+
+		// Fire a separate addAttribute event for each attribute that was set on inserted items.
+		// This is important because most attributes converters will listen only to add/change/removeAttribute events.
+		// If we would not add this part, attributes on inserted nodes would not be converted.
+		for ( const key of data.item.getAttributeKeys() ) {
+			data.attributeKey = key;
+			data.attributeOldValue = null;
+			data.attributeNewValue = data.item.getAttribute( key );
+
+			this._testAndFire( `attribute:${ key }`, data );
+		}
+	}
+
+	/**
+	 * Returns differ changes together with added "reconvert" type changes for {@link #reconvertElement}. Those are defined by
+	 * a `triggerBy` configuration for
+	 * {@link module:engine/conversion/downcasthelpers~DowncastHelpers#elementToElement `elementToElement()`} conversion helper.
+	 *
+	 * This method will remove every mapped insert or remove change with a single "reconvert" changes.
+	 *
+	 * For instance: Having a `triggerBy` configuration defined for `<complex>` element that issues this element reconversion on
+	 * `foo` and `bar` attributes change, and a set of changes for this element:
+	 *
+	 *		const differChanges = [
+	 *			{ type: 'attribute', attributeKey: 'foo', ... },
+	 *			{ type: 'attribute', attributeKey: 'bar', ... },
+	 *			{ type: 'attribute', attributeKey: 'baz', ... }
+	 *		];
+	 *
+	 * This method will return:
+	 *
+	 *		const updatedChanges = [
+	 *			{ type: 'reconvert', element: complexElementInstance },
+	 *			{ type: 'attribute', attributeKey: 'baz', ... }
+	 *		];
+	 *
+	 * In the example above the `'baz'` attribute change will fire an {@link #event:attribute attribute event}
+	 *
+	 * @param {module:engine/model/differ~Differ} differ The differ object with buffered changes.
+	 * @returns {Array.<Object>} Updated set of changes.
+	 * @private
+	 */
+	_mapChangesWithAutomaticReconversion( differ ) {
+		const itemsToReconvert = new Set();
+		const updated = [];
+
+		for ( const entry of differ.getChanges() ) {
+			const position = entry.position || entry.range.start;
+			// Cached parent - just in case. See https://github.com/ckeditor/ckeditor5/issues/6579.
+			const positionParent = position.parent;
+			const textNode = getTextNodeAtPosition( position, positionParent );
+
+			// Reconversion is done only on elements so skip text changes.
+			if ( textNode ) {
+				updated.push( entry );
+
+				continue;
+			}
+
+			const element = entry.type === 'attribute' ? getNodeAfterPosition( position, positionParent, null ) : positionParent;
+
+			// Case of text node set directly in root. For now used only in tests but can be possible when enabled in paragraph-like roots.
+			// See: https://github.com/ckeditor/ckeditor5/issues/762.
+			if ( element.is( '$text' ) ) {
+				updated.push( entry );
+
+				continue;
+			}
+
+			let eventName;
+
+			if ( entry.type === 'attribute' ) {
+				eventName = `attribute:${ entry.attributeKey }:${ element.name }`;
+			} else {
+				eventName = `${ entry.type }:${ entry.name }`;
+			}
+
+			if ( this._isReconvertTriggerEvent( eventName, element.name ) ) {
+				if ( itemsToReconvert.has( element ) ) {
+					// Element is already reconverted, so skip this change.
+					continue;
+				}
+
+				itemsToReconvert.add( element );
+
+				// Add special "reconvert" change.
+				updated.push( { type: 'reconvert', element } );
+			} else {
+				updated.push( entry );
+			}
+		}
+
+		return updated;
+	}
+
+	/**
+	 * Checks if resulting change should trigger element reconversion.
+	 *
+	 * Those are defined by a `triggerBy` configuration for
+	 * {@link module:engine/conversion/downcasthelpers~DowncastHelpers#elementToElement `elementToElement()`} conversion helper.
+	 *
+	 * @private
+	 * @param {String} eventName Event name to check.
+	 * @param {String} elementName Element name to check.
+	 * @returns {Boolean}
+	 */
+	_isReconvertTriggerEvent( eventName, elementName ) {
+		return this._reconversionEventsMapping.get( eventName ) === elementName;
+	}
+
 	/**
 	/**
 	 * Fired for inserted nodes.
 	 * Fired for inserted nodes.
 	 *
 	 *
@@ -636,6 +834,33 @@ function shouldMarkerChangeBeConverted( modelPosition, marker, mapper ) {
 	return !hasCustomHandling;
 	return !hasCustomHandling;
 }
 }
 
 
+function getEventName( type, data ) {
+	const name = data.item.name || '$text';
+
+	return `${ type }:${ name }`;
+}
+
+function walkerValueToEventData( value ) {
+	const item = value.item;
+	const itemRange = Range._createFromPositionAndShift( value.previousPosition, value.length );
+
+	return {
+		item,
+		range: itemRange
+	};
+}
+
+function elementOrTextProxyToView( item, mapper ) {
+	if ( item.is( 'textProxy' ) ) {
+		const mappedPosition = mapper.toViewPosition( Position._createBefore( item ) );
+		const positionParent = mappedPosition.parent;
+
+		return positionParent.is( '$text' ) ? positionParent : null;
+	}
+
+	return mapper.toViewElement( item );
+}
+
 /**
 /**
  * Conversion interface that is registered for given {@link module:engine/conversion/downcastdispatcher~DowncastDispatcher}
  * Conversion interface that is registered for given {@link module:engine/conversion/downcastdispatcher~DowncastDispatcher}
  * and is passed as one of parameters when {@link module:engine/conversion/downcastdispatcher~DowncastDispatcher dispatcher}
  * and is passed as one of parameters when {@link module:engine/conversion/downcastdispatcher~DowncastDispatcher dispatcher}

+ 37 - 5
packages/ckeditor5-engine/src/conversion/downcasthelpers.js

@@ -59,6 +59,19 @@ export default class DowncastHelpers extends ConversionHelpers {
 	 *			}
 	 *			}
 	 *		} );
 	 *		} );
 	 *
 	 *
+	 * The element-to-element conversion supports a reconversion mechanism. This is helpful in conversion to complex view structures where
+	 * multiple atomic element-to-element and attribute-to-attribute or attribute-to-element could be used. By specifying `triggerBy`
+	 * events you can trigger reconverting model to a full view tree structures at once.
+	 *
+	 *		editor.conversion.for( 'downcast' ).elementToElement( {
+	 *			model: 'complex',
+	 *			view: ( modelElement, conversionApi ) => createComplexViewFromModel( modelElement, conversionApi ),
+	 *			triggerBy: {
+	 *				attributes: [ 'foo', 'bar' ],
+	 *				children: [ 'slot' ]
+	 *			}
+	 *		} );
+	 *
 	 * See {@link module:engine/conversion/conversion~Conversion#for `conversion.for()`} to learn how to add a converter
 	 * See {@link module:engine/conversion/conversion~Conversion#for `conversion.for()`} to learn how to add a converter
 	 * to the conversion process.
 	 * to the conversion process.
 	 *
 	 *
@@ -71,6 +84,9 @@ export default class DowncastHelpers extends ConversionHelpers {
 	 * @param {module:engine/view/elementdefinition~ElementDefinition|Function} config.view A view element definition or a function
 	 * @param {module:engine/view/elementdefinition~ElementDefinition|Function} config.view A view element definition or a function
 	 * that takes the model element and {@link module:engine/conversion/downcastdispatcher~DowncastConversionApi downcast conversion API}
 	 * that takes the model element and {@link module:engine/conversion/downcastdispatcher~DowncastConversionApi downcast conversion API}
 	 * as parameters and returns a view container element.
 	 * as parameters and returns a view container element.
+	 * @param {Object} [config.triggerBy] Re-conversion triggers. At least one trigger must be defined.
+	 * @param {Array.<String>} config.triggerBy.attributes Name of element's attributes which change will trigger element reconversion.
+	 * @param {Array.<String>} config.triggerBy.children Name of direct children that adding or removing will trigger element reconversion.
 	 * @returns {module:engine/conversion/downcasthelpers~DowncastHelpers}
 	 * @returns {module:engine/conversion/downcasthelpers~DowncastHelpers}
 	 */
 	 */
 	elementToElement( config ) {
 	elementToElement( config ) {
@@ -1334,13 +1350,14 @@ function removeHighlight( highlightDescriptor ) {
 
 
 // Model element to view element conversion helper.
 // Model element to view element conversion helper.
 //
 //
-// See {@link ~DowncastHelpers#elementToElement `.elementToElement()` downcast helper} for examples.
+// See {@link ~DowncastHelpers#elementToElement `.elementToElement()` downcast helper} for examples and config params description.
 //
 //
 // @param {Object} config Conversion configuration.
 // @param {Object} config Conversion configuration.
-// @param {String} config.model The name of the model element to convert.
-// @param {module:engine/view/elementdefinition~ElementDefinition|Function} config.view A view element definition or a function
-// that takes the model element and {@link module:engine/view/downcastwriter~DowncastWriter view downcast writer}
-// as parameters and returns a view container element.
+// @param {String} config.model
+// @param {module:engine/view/elementdefinition~ElementDefinition|Function} config.view
+// @param {Object} [config.triggerBy]
+// @param {Array.<String>} [config.triggerBy.attributes]
+// @param {Array.<String>} [config.triggerBy.children]
 // @returns {Function} Conversion helper.
 // @returns {Function} Conversion helper.
 function downcastElementToElement( config ) {
 function downcastElementToElement( config ) {
 	config = cloneDeep( config );
 	config = cloneDeep( config );
@@ -1349,6 +1366,21 @@ function downcastElementToElement( config ) {
 
 
 	return dispatcher => {
 	return dispatcher => {
 		dispatcher.on( 'insert:' + config.model, insertElement( config.view ), { priority: config.converterPriority || 'normal' } );
 		dispatcher.on( 'insert:' + config.model, insertElement( config.view ), { priority: config.converterPriority || 'normal' } );
+
+		if ( config.triggerBy ) {
+			if ( config.triggerBy.attributes ) {
+				for ( const attributeKey of config.triggerBy.attributes ) {
+					dispatcher._mapReconversionTriggerEvent( config.model, `attribute:${ attributeKey }:${ config.model }` );
+				}
+			}
+
+			if ( config.triggerBy.children ) {
+				for ( const childName of config.triggerBy.children ) {
+					dispatcher._mapReconversionTriggerEvent( config.model, `insert:${ childName }` );
+					dispatcher._mapReconversionTriggerEvent( config.model, `remove:${ childName }` );
+				}
+			}
+		}
 	};
 	};
 }
 }
 
 

+ 2 - 4
packages/ckeditor5-engine/src/conversion/upcasthelpers.js

@@ -7,13 +7,11 @@ import Matcher from '../view/matcher';
 import ConversionHelpers from './conversionhelpers';
 import ConversionHelpers from './conversionhelpers';
 
 
 import { cloneDeep } from 'lodash-es';
 import { cloneDeep } from 'lodash-es';
-import { attachLinkToDocumentation } from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
+import { logWarning } from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
 
 
 import priorities from '@ckeditor/ckeditor5-utils/src/priorities';
 import priorities from '@ckeditor/ckeditor5-utils/src/priorities';
 import { isParagraphable, wrapInParagraph } from '../model/utils/autoparagraphing';
 import { isParagraphable, wrapInParagraph } from '../model/utils/autoparagraphing';
 
 
-/* global console */
-
 /**
 /**
  * Contains {@link module:engine/view/view view} to {@link module:engine/model/model model} converters for
  * Contains {@link module:engine/view/view view} to {@link module:engine/model/model model} converters for
  * {@link module:engine/conversion/upcastdispatcher~UpcastDispatcher}.
  * {@link module:engine/conversion/upcastdispatcher~UpcastDispatcher}.
@@ -349,7 +347,7 @@ export default class UpcastHelpers extends ConversionHelpers {
 		 *
 		 *
 		 * @error upcast-helpers-element-to-marker-deprecated
 		 * @error upcast-helpers-element-to-marker-deprecated
 		 */
 		 */
-		console.warn( attachLinkToDocumentation( 'upcast-helpers-element-to-marker-deprecated' ) );
+		logWarning( 'upcast-helpers-element-to-marker-deprecated' );
 
 
 		return this.add( upcastElementToMarker( config ) );
 		return this.add( upcastElementToMarker( config ) );
 	}
 	}

+ 2 - 15
packages/ckeditor5-engine/src/view/observer/fakeselectionobserver.js

@@ -9,7 +9,7 @@
 
 
 import Observer from './observer';
 import Observer from './observer';
 import ViewSelection from '../selection';
 import ViewSelection from '../selection';
-import { keyCodes } from '@ckeditor/ckeditor5-utils/src/keyboard';
+import { keyCodes, isArrowKeyCode } from '@ckeditor/ckeditor5-utils/src/keyboard';
 import { debounce } from 'lodash-es';
 import { debounce } from 'lodash-es';
 
 
 /**
 /**
@@ -49,7 +49,7 @@ export default class FakeSelectionObserver extends Observer {
 		document.on( 'keydown', ( eventInfo, data ) => {
 		document.on( 'keydown', ( eventInfo, data ) => {
 			const selection = document.selection;
 			const selection = document.selection;
 
 
-			if ( selection.isFake && _isArrowKeyCode( data.keyCode ) && this.isEnabled ) {
+			if ( selection.isFake && isArrowKeyCode( data.keyCode ) && this.isEnabled ) {
 				// Prevents default key down handling - no selection change will occur.
 				// Prevents default key down handling - no selection change will occur.
 				data.preventDefault();
 				data.preventDefault();
 
 
@@ -110,16 +110,3 @@ export default class FakeSelectionObserver extends Observer {
 		this._fireSelectionChangeDoneDebounced( data );
 		this._fireSelectionChangeDoneDebounced( data );
 	}
 	}
 }
 }
-
-// Checks if one of the arrow keys is pressed.
-//
-// @private
-// @param {Number} keyCode
-// @returns {Boolean}
-function _isArrowKeyCode( keyCode ) {
-	return keyCode == keyCodes.arrowright ||
-		keyCode == keyCodes.arrowleft ||
-		keyCode == keyCodes.arrowup ||
-		keyCode == keyCodes.arrowdown;
-}
-

+ 2 - 0
packages/ckeditor5-engine/tests/conversion/downcastdispatcher.js

@@ -75,6 +75,7 @@ describe( 'DowncastDispatcher', () => {
 
 
 		it( 'should call convertAttribute for attribute change', () => {
 		it( 'should call convertAttribute for attribute change', () => {
 			sinon.stub( dispatcher, 'convertAttribute' );
 			sinon.stub( dispatcher, 'convertAttribute' );
+			sinon.stub( dispatcher, '_mapChangesWithAutomaticReconversion' ).callsFake( differ => differ.getChanges() );
 
 
 			const position = model.createPositionFromPath( root, [ 0 ] );
 			const position = model.createPositionFromPath( root, [ 0 ] );
 			const range = ModelRange._createFromPositionAndShift( position, 1 );
 			const range = ModelRange._createFromPositionAndShift( position, 1 );
@@ -94,6 +95,7 @@ describe( 'DowncastDispatcher', () => {
 			sinon.stub( dispatcher, 'convertInsert' );
 			sinon.stub( dispatcher, 'convertInsert' );
 			sinon.stub( dispatcher, 'convertRemove' );
 			sinon.stub( dispatcher, 'convertRemove' );
 			sinon.stub( dispatcher, 'convertAttribute' );
 			sinon.stub( dispatcher, 'convertAttribute' );
+			sinon.stub( dispatcher, '_mapChangesWithAutomaticReconversion' ).callsFake( differ => differ.getChanges() );
 
 
 			const position = model.createPositionFromPath( root, [ 0 ] );
 			const position = model.createPositionFromPath( root, [ 0 ] );
 			const range = ModelRange._createFromPositionAndShift( position, 1 );
 			const range = ModelRange._createFromPositionAndShift( position, 1 );

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 1141 - 0
packages/ckeditor5-engine/tests/conversion/downcasthelpers.js


+ 65 - 0
packages/ckeditor5-engine/tests/manual/slotconversion.html

@@ -0,0 +1,65 @@
+<head>
+	<style>
+		body {
+			max-width: 800px;
+			margin: 20px auto;
+		}
+
+		.box {
+			border: 1px solid hsl(0, 0%, 20%);
+			padding: 2px;
+			background: hsl(0, 0%, 40%);
+		}
+
+		.box-meta {
+			border: 1px solid hsl(0, 0%, 80%);
+			background: hsl(0, 0%, 60%);
+		}
+
+		.box-content-field {
+			padding: .5em;
+			background: hsl(0, 0%, 100%);
+			border: 1px solid hsl(0, 0%, 80%)
+		}
+
+		.ck.ck-content [data-insert-count] {
+			position: relative;
+		}
+		.ck.ck-content [data-insert-count]:after {
+			content: attr(data-insert-count);
+			position: absolute;
+			font-size: 10px;
+			top: 2px;
+			right: 2px;
+			border: 1px solid hsl(219, 86%, 31%);
+			background: hsl(219, 100%, 91%);
+			padding: 0 2px;
+			opacity: 0.8;
+		}
+	</style>
+</head>
+
+<div id="editor">
+	<div class="box">
+		<div class="box__wrapper">
+			<div class="box-meta box-meta-header">
+				<div class="box-meta-header-icon"></div>
+				<div class="box-meta-header-title">
+					<h2>I'm a title</h2>
+				</div>
+			</div>
+			<div class="box-content">
+				<div class="box-content-field"><p>Foo</p></div>
+				<div class="box-content-field">
+					<ul>
+						<li>Bar</li>
+					</ul>
+				</div>
+				<div class="box-content-field"><p>Baz</p></div>
+			</div>
+			<div class="box-meta box-meta-author">
+				<a href="www.example.com">@john</a>
+			</div>
+		</div>
+	</div>
+</div>

+ 292 - 0
packages/ckeditor5-engine/tests/manual/slotconversion.js

@@ -0,0 +1,292 @@
+/**
+ * @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 ButtonView from '@ckeditor/ckeditor5-ui/src/button/buttonview';
+
+const byClassName = className => element => element.hasClass( className );
+
+const getRandom = () => parseInt( Math.random() * 1000 );
+
+function mapMeta( editor ) {
+	return metaElement => {
+		if ( metaElement.hasClass( 'box-meta-header' ) ) {
+			const title = getChildren( editor, metaElement )
+				.filter( byClassName( 'box-meta-header-title' ) )
+				.pop().getChild( 0 ).getChild( 0 ).data;
+
+			return {
+				header: {
+					title
+				}
+			};
+		}
+
+		if ( metaElement.hasClass( 'box-meta-author' ) ) {
+			const link = metaElement.getChild( 0 );
+
+			return {
+				author: {
+					name: link.getChild( 0 ).data,
+					website: link.getAttribute( 'href' )
+				}
+			};
+		}
+	};
+}
+
+function getChildren( editor, viewElement ) {
+	return [ ...( editor.editing.view.createRangeIn( viewElement ) ) ]
+		.filter( ( { type } ) => type === 'elementStart' )
+		.map( ( { item } ) => item );
+}
+
+function getBoxUpcastConverter( editor ) {
+	return dispatcher => dispatcher.on( 'element:div', ( event, data, conversionApi ) => {
+		const viewElement = data.viewItem;
+		const writer = conversionApi.writer;
+
+		if ( !viewElement.hasClass( 'box' ) ) {
+			return;
+		}
+
+		const box = writer.createElement( 'box' );
+
+		if ( !conversionApi.safeInsert( box, data.modelCursor ) ) {
+			return;
+		}
+
+		const elements = getChildren( editor, viewElement );
+
+		const fields = elements.filter( byClassName( 'box-content-field' ) );
+		const metaElements = elements.filter( byClassName( 'box-meta' ) );
+
+		const meta = metaElements.map( mapMeta( editor ) ).reduce( ( prev, current ) => Object.assign( prev, current ), {} );
+
+		writer.setAttribute( 'meta', meta, box );
+
+		for ( const field of fields ) {
+			const boxField = writer.createElement( 'boxField' );
+
+			conversionApi.safeInsert( boxField, writer.createPositionAt( box, field.index ) );
+			conversionApi.convertChildren( field, boxField );
+		}
+
+		conversionApi.consumable.consume( viewElement, { name: true } );
+		elements.map( element => {
+			conversionApi.consumable.consume( element, { name: true } );
+		} );
+
+		conversionApi.updateConversionResult( box, data );
+	} );
+}
+
+function downcastBox( modelElement, conversionApi ) {
+	const { writer } = conversionApi;
+
+	const viewBox = writer.createContainerElement( 'div', { class: 'box' } );
+	conversionApi.mapper.bindElements( modelElement, viewBox );
+
+	const contentWrap = writer.createContainerElement( 'div', { class: 'box-content' } );
+	writer.insert( writer.createPositionAt( viewBox, 0 ), contentWrap );
+
+	for ( const [ meta, metaValue ] of Object.entries( modelElement.getAttribute( 'meta' ) ) ) {
+		if ( meta === 'header' ) {
+			const header = writer.createRawElement( 'div', {
+				class: 'box-meta box-meta-header'
+			}, domElement => {
+				domElement.innerHTML = `<div class="box-meta-header-title"><h2>${ metaValue.title }</h2></div>`;
+			} );
+
+			writer.insert( writer.createPositionBefore( contentWrap ), header );
+		}
+
+		if ( meta === 'author' ) {
+			const author = writer.createRawElement( 'div', {
+				class: 'box-meta box-meta-author'
+			}, domElement => {
+				domElement.innerHTML = `<a href="${ metaValue.website }">${ metaValue.name }</a>`;
+			} );
+
+			writer.insert( writer.createPositionAfter( contentWrap ), author );
+		}
+	}
+
+	for ( const field of modelElement.getChildren() ) {
+		const viewField = writer.createContainerElement( 'div', { class: 'box-content-field' } );
+
+		writer.insert( writer.createPositionAt( contentWrap, field.index ), viewField );
+		conversionApi.mapper.bindElements( field, viewField );
+		conversionApi.consumable.consume( field, 'insert' );
+
+		// Might be simplified to:
+		//
+		// writer.defineSlot( field, viewField, field.index );
+		//
+		// but would require a converter:
+		//
+		// editor.conversion.for( 'downcast' ).elementToElement( {	// .slotToElement()?
+		// 		model: 'viewField',
+		// 		view: { name: 'div', class: 'box-content-field' }
+		// 	} );
+	}
+
+	// At this point we're inserting whole "component". Equivalent to (JSX-like notation):
+	//
+	//	"rendered" view																					Mapping/source
+	//
+	//	<div:container class="box">												<-- top-level			box
+	//		<div:raw class="box-meta box-meta-header">...</div:raw>										box[meta.header]
+	//		<div:container class="box-content">
+	//			<div:container class="box-content-field">...</div:container>	<-- this is "slot"		boxField
+	//			... many
+	//			<div:container class="box-content-field">...</div:container>	<-- this is "slot"		boxField
+	//		</div:container>
+	//		<div:raw class="box-meta box-meta-author">...</div:raw>										box[meta.author]
+	//	</div:container>
+
+	return viewBox;
+}
+
+function addButton( editor, uiName, label, callback ) {
+	editor.ui.componentFactory.add( uiName, locale => {
+		const view = new ButtonView( locale );
+
+		view.set( { label, withText: true } );
+
+		view.listenTo( view, 'execute', () => {
+			const parent = editor.model.document.selection.getFirstPosition().parent;
+			const boxField = parent.findAncestor( 'boxField' );
+
+			if ( !boxField ) {
+				return;
+			}
+
+			editor.model.change( writer => callback( writer, boxField.findAncestor( 'box' ), boxField ) );
+		} );
+
+		return view;
+	} );
+}
+
+function addBoxMetaButton( editor, uiName, label, updateWith ) {
+	addButton( editor, uiName, label, ( writer, box ) => {
+		writer.setAttribute( 'meta', {
+			...box.getAttribute( 'meta' ),
+			...updateWith()
+		}, box );
+	} );
+}
+
+function Box( editor ) {
+	editor.model.schema.register( 'box', {
+		allowIn: '$root',
+		isObject: true,
+		isSelectable: true,
+		allowAttributes: [ 'infoBoxMeta' ]
+	} );
+
+	editor.model.schema.register( 'boxField', {
+		allowContentOf: '$root',
+		allowIn: 'box',
+		isLimit: true
+	} );
+
+	editor.conversion.for( 'upcast' ).add( getBoxUpcastConverter( editor ) );
+
+	editor.conversion.for( 'downcast' ).elementToElement( {
+		model: 'box',
+		view: downcastBox,
+		triggerBy: {
+			attributes: [ 'meta' ],
+			children: [ 'boxField' ]
+		}
+	} );
+
+	addBoxMetaButton( editor, 'boxTitle', 'Box title', () => ( {
+		header: { title: `Random title no. ${ getRandom() }.` }
+	} ) );
+
+	addBoxMetaButton( editor, 'boxAuthor', 'Box author', () => ( {
+		author: {
+			website: `www.example.com/${ getRandom() }`,
+			name: `Random author no. ${ getRandom() }`
+		}
+	} ) );
+
+	addButton( editor, 'addBoxField', '+', ( writer, box, boxField ) => {
+		const newBoxField = writer.createElement( 'boxField' );
+		writer.insert( newBoxField, box, boxField.index );
+		writer.insert( writer.createElement( 'paragraph' ), newBoxField, 0 );
+	} );
+
+	addButton( editor, 'removeBoxField', '-', ( writer, box, boxField ) => {
+		writer.remove( boxField );
+	} );
+}
+
+function AddRenderCount( editor ) {
+	let insertCount = 0;
+
+	const nextInsert = () => insertCount++;
+
+	editor.conversion.for( 'downcast' ).add( dispatcher => dispatcher.on( 'insert', ( event, data, conversionApi ) => {
+		const view = conversionApi.mapper.toViewElement( data.item );
+
+		if ( view ) {
+			const insertCount = nextInsert();
+
+			conversionApi.writer.setAttribute( 'data-insert-count', `${ insertCount }`, view );
+			conversionApi.writer.setAttribute( 'title', `Insertion counter: ${ insertCount }`, view );
+		}
+	}, { priority: 'lowest' } ) );
+}
+
+ClassicEditor
+	.create( document.querySelector( '#editor' ), {
+		plugins: [ ArticlePluginSet, Box, AddRenderCount ],
+		toolbar: [
+			'heading',
+			'|',
+			'boxTitle',
+			'boxAuthor',
+			'addBoxField',
+			'removeBoxField',
+			'|',
+			'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 );
+	} );

+ 12 - 0
packages/ckeditor5-engine/tests/manual/slotconversion.md

@@ -0,0 +1,12 @@
+# Slot conversion
+
+The editor should be loaded with a "box" element that contains multiple "slots" in which user can edit content.
+
+An additional converter adds `"data-insert-count"` attribute to view elements to show when it was rendered. It is displayed with a CSS at the top-right corner of rendered element. If a view element was not re-rendered this attribute should not change. *Note*: it only acts on "insert" changes so it can omit attribute-to-element changes or insertions not passed through dispatcher.
+
+Observe which view elements are re-rendered when using UI-buttons:
+
+* `Box title` - updates title attribute which triggers re-rendering of a "box".
+* `Box author` - updates author attribute which triggers re-rendering of a "box".
+* `+` - adds "slot" to box"  which triggers re-rendering of a "box".
+* `-` - removes "slot" from box" which triggers re-rendering of a "box".

+ 2 - 2
packages/ckeditor5-engine/tests/model/differ.js

@@ -1677,7 +1677,7 @@ describe( 'Differ', () => {
 	} );
 	} );
 
 
 	describe( 'other cases', () => {
 	describe( 'other cases', () => {
-		// #1309.
+		// See https://github.com/ckeditor/ckeditor5/issues/4284.
 		it( 'multiple inserts and removes in one element', () => {
 		it( 'multiple inserts and removes in one element', () => {
 			model.change( () => {
 			model.change( () => {
 				insert( new Text( 'x' ), new Position( root, [ 0, 2 ] ) );
 				insert( new Text( 'x' ), new Position( root, [ 0, 2 ] ) );
@@ -1691,7 +1691,7 @@ describe( 'Differ', () => {
 			} );
 			} );
 		} );
 		} );
 
 
-		// ckeditor5#733.
+		// See https://github.com/ckeditor/ckeditor5/issues/733.
 		it( 'proper filtering of changes in removed elements', () => {
 		it( 'proper filtering of changes in removed elements', () => {
 			// Before fix there was a buggy scenario described in ckeditor5#733.
 			// Before fix there was a buggy scenario described in ckeditor5#733.
 			// There was this structure: `<paragraph>foo[</paragraph><image /><blockQuote><p>te]xt</p></blockQuote>`
 			// There was this structure: `<paragraph>foo[</paragraph><image /><blockQuote><p>te]xt</p></blockQuote>`

+ 10 - 1
packages/ckeditor5-font/docs/features/font.md

@@ -5,7 +5,7 @@ category: features
 
 
 {@snippet features/build-font-source}
 {@snippet features/build-font-source}
 
 
-The {@link module:font/font~Font} plugin enables the following features in the rich-text editor:
+The {@link module:font/font~Font} plugin provides extended text formatting options for the document content. It enables the following features in the rich-text editor:
 * {@link module:font/fontfamily~FontFamily} &ndash; Allows to change the font family by applying inline `<span>` elements with a `font-family` in the `style` attribute.
 * {@link module:font/fontfamily~FontFamily} &ndash; Allows to change the font family by applying inline `<span>` elements with a `font-family` in the `style` attribute.
 * {@link module:font/fontsize~FontSize} &ndash; Allows to control the font size by applying inline `<span>` elements that either have a CSS class or a `font-size` in the `style` attribute.
 * {@link module:font/fontsize~FontSize} &ndash; Allows to control the font size by applying inline `<span>` elements that either have a CSS class or a `font-size` in the `style` attribute.
 * {@link module:font/fontcolor~FontColor} &ndash; Allows to control the font color by applying inline `<span>` elements with a `color` in the `style` attribute.
 * {@link module:font/fontcolor~FontColor} &ndash; Allows to control the font color by applying inline `<span>` elements with a `color` in the `style` attribute.
@@ -19,6 +19,15 @@ The {@link module:font/font~Font} plugin enables the following features in the r
 
 
 {@snippet features/font}
 {@snippet features/font}
 
 
+## Related features
+
+Here are some more CKEditor 5 features that can help you format your content:
+* {@link features/basic-styles Basic font styles} &ndash; The essentials, like **bold**, *italic* and others.
+* {@link features/text-alignment Text alignment} &ndash; Because it does matter whether the content is left, right, centered or justified.
+* {@link features/headings Headings} &ndash; Divide your content into sections.
+* {@link features/highlight Highlight} &ndash; Mark important words and passages, aiding a review or drawing attention to specific parts of content.
+* {@link features/remove-format Remove format} &ndash; Easily clean basic text formatting.
+
 ## Configuring the font family feature
 ## Configuring the font family feature
 
 
 It is possible to configure which font family options are supported by the WYSIWYG editor. Use the {@link module:font/fontfamily~FontFamilyConfig#options `config.fontFamily.options`} configuration option to do so.
 It is possible to configure which font family options are supported by the WYSIWYG editor. Use the {@link module:font/fontfamily~FontFamilyConfig#options `config.fontFamily.options`} configuration option to do so.

+ 12 - 0
packages/ckeditor5-heading/docs/_snippets/features/default-headings.html

@@ -0,0 +1,12 @@
+
+<div id="snippet-default-headings">
+	<h1>On the importance of headings</h1>
+	<h2>What is a heading?</h2>
+	<p>A heading should be viewed as a title or a subtitle displayed in the document or a webpage. These are created with the &lt;h1&gt; to &lt;h6&gt; HTML tags where  1 is the largest (and most important) while 6 is the lowest level heading.</p>
+	<h2>The reason to use headings</h2>
+	<p>Headings serve dual purpose in the documents: they help the creators and readers interact with the content and they also affect the way search engines index the webpage.</p>
+	<h3>Human interaction</h3>
+	<p>Having a clearly defined structure with sections and subsections is beneficial for both the creator, who can convey their message better as well as for the readers, who can access the content with ease. Having a few good headings aids scanning and searching for specific information in the document as well as structuring the data contained.</p>
+	<h3>Search engine indexing algorithms</h3>
+	<p>Search engines use the headings to index the structure of the document and point to important keywords.</p>
+</div>

+ 30 - 0
packages/ckeditor5-heading/docs/_snippets/features/default-headings.js

@@ -0,0 +1,30 @@
+/**
+ * @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 ClassicEditor, console, window, document */
+
+import { CS_CONFIG } from '@ckeditor/ckeditor5-cloud-services/tests/_utils/cloud-services-config';
+
+ClassicEditor
+	.create( document.querySelector( '#snippet-default-headings' ), {
+		cloudServices: CS_CONFIG,
+		heading: {
+			options: [
+				{ model: 'paragraph', title: 'Paragraph', class: 'ck-heading_paragraph' },
+				{ model: 'heading1', view: 'h1', title: 'Heading 1', class: 'ck-heading_heading1' },
+				{ model: 'heading2', view: 'h2', title: 'Heading 2', class: 'ck-heading_heading2' },
+				{ model: 'heading3', view: 'h3', title: 'Heading 3', class: 'ck-heading_heading3' }
+			]
+		},
+		toolbar: {
+			viewportTopOffset: window.getViewportTopOffsetConfig()
+		}
+	} )
+	.then( editor => {
+		window.editor = editor;
+	} )
+	.catch( err => {
+		console.error( err.stack );
+	} );

+ 16 - 1
packages/ckeditor5-heading/docs/features/headings.md

@@ -6,7 +6,7 @@ category: features
 
 
 {@snippet build-classic-source}
 {@snippet build-classic-source}
 
 
-The {@link module:heading/heading~Heading} feature enables support for headings.
+The {@link module:heading/heading~Heading} feature enables support for headings. These are used by the creators to structure their documents. They also aid both the readers, making the content more organized and easier to read, and the search indexers scanning for crucial information.
 
 
 <info-box info>
 <info-box info>
 	This feature is enabled by default in all builds.
 	This feature is enabled by default in all builds.
@@ -26,6 +26,21 @@ By default, when your editor build does not include the title plugin, a `<h1>` e
 	You can read more about why the editor should not create `<h1>` elements for content headings in the [Headings section of Editor Recommendations](http://ckeditor.github.io/editor-recommendations/features/headings.html).
 	You can read more about why the editor should not create `<h1>` elements for content headings in the [Headings section of Editor Recommendations](http://ckeditor.github.io/editor-recommendations/features/headings.html).
 </info-box>
 </info-box>
 
 
+## Demo
+
+{@snippet features/default-headings}
+
+## Related features
+
+There are more CKEditor 5 features that can help you format your content:
+* {@link features/basic-styles Basic font styles} &ndash; The essentials, like **bold**, *italic* and others.
+* {@link features/title Document title} &ndash; Clearly divide your content into a title and body.
+* {@link features/indent Block indentation}  &ndash; Set indentation for text blocks such as paragraphs or lists.
+* {@link features/lists Lists}  &ndash; Organize your content better with ordered and unordered list you can style.
+* {@link features/remove-format Remove format} &ndash; Easily clean basic text formatting.
+
+## Configuration
+
 ### Configuring heading levels
 ### Configuring heading levels
 
 
 It is, of course, possible to configure which heading levels the editor should support and how they should be named in the Headings dropdown. Use the {@link module:heading/heading~HeadingConfig#options `heading.options`} configuration option to do so.
 It is, of course, possible to configure which heading levels the editor should support and how they should be named in the Headings dropdown. Use the {@link module:heading/heading~HeadingConfig#options `heading.options`} configuration option to do so.

+ 7 - 0
packages/ckeditor5-heading/docs/features/title.md

@@ -15,6 +15,13 @@ Use the editor below to create a document with clearly separated title and body
 
 
 {@snippet features/title}
 {@snippet features/title}
 
 
+## Related features
+
+There are more CKEditor 5 features that can help you structure your document better:
+* {@link features/headings Headings} &ndash; Divide your content into sections.
+* {@link features/indent Block indentation} &ndash; Organize your content into visually separated blocks, indent crucial paragraphs, etc.
+* {@link features/editor-placeholder Editor placeholder} &ndash; Set placeholder text to display when the content is empty. It helps users locate the editor in the application and prompts to input the content.
+
 ## Keyboard navigation
 ## Keyboard navigation
 
 
 The title plugin lets you move from the title to the body element using the <kbd>Tab</kbd> key, providing form-like experience. When the selection is at the beginning of the first body element, you can go back to the title element using <kbd>Shift</kbd>+<kbd>Tab</kbd>. You can also use <kbd>Enter</kbd> and <kbd>Backspace</kbd> keys to move the caret between the title and the body.
 The title plugin lets you move from the title to the body element using the <kbd>Tab</kbd> key, providing form-like experience. When the selection is at the beginning of the first body element, you can go back to the title element using <kbd>Shift</kbd>+<kbd>Tab</kbd>. You can also use <kbd>Enter</kbd> and <kbd>Backspace</kbd> keys to move the caret between the title and the body.

+ 8 - 0
packages/ckeditor5-highlight/docs/features/highlight.md

@@ -14,6 +14,14 @@ The highlight plugin always comes with a predefined and limited number of availa
 
 
 {@snippet features/highlight}
 {@snippet features/highlight}
 
 
+## Related features
+
+There are more CKEditor 5 features that can help you style your content:
+* {@link features/basic-styles Basic font styles} &ndash; The essentials, like **bold**, *italic* and others.
+* {@link features/font Font styles} &ndash; Easily and efficiently control the font {@link features/font#configuring-the-font-family-feature family}, {@link features/font#configuring-the-font-size-feature size}, {@link features/font#configuring-the-font-color-and-font-background-color-features text or background color}.
+* {@link features/block-quote Block quote} &ndash; Include block quotations or pull quotes in your rich-text content.
+* {@link features/remove-format Remove format} &ndash; Easily clean basic text formatting.
+
 ## Configuring the highlight options
 ## Configuring the highlight options
 
 
 ### Dropdown
 ### Dropdown

+ 4 - 1
packages/ckeditor5-image/src/imageinsert/imageinsertui.js

@@ -113,7 +113,10 @@ export default class ImageInsertUI extends Plugin {
 					insertImageViaUrlForm.label = t( 'Insert image via URL' );
 					insertImageViaUrlForm.label = t( 'Insert image via URL' );
 				}
 				}
 			}
 			}
-		} );
+		// Note: Use the low priority to make sure the following listener starts working after the
+		// default action of the drop-down is executed (i.e. the panel showed up). Otherwise, the
+		// invisible form/input cannot be focused/selected.
+		}, { priority: 'low' } );
 
 
 		imageInsertView.delegate( 'submit', 'cancel' ).to( dropdownView );
 		imageInsertView.delegate( 'submit', 'cancel' ).to( dropdownView );
 		this.delegate( 'cancel' ).to( dropdownView );
 		this.delegate( 'cancel' ).to( dropdownView );

+ 2 - 2
packages/ckeditor5-image/src/imageinsert/ui/imageinsertpanelview.js

@@ -135,7 +135,7 @@ export default class ImageInsertPanelView extends View {
 					integrationView.fieldView.bind( 'value' ).to( this, 'imageURLInputValue', value => value || '' );
 					integrationView.fieldView.bind( 'value' ).to( this, 'imageURLInputValue', value => value || '' );
 
 
 					integrationView.fieldView.on( 'input', () => {
 					integrationView.fieldView.on( 'input', () => {
-						this.imageURLInputValue = integrationView.fieldView.element.value;
+						this.imageURLInputValue = integrationView.fieldView.element.value.trim();
 					} );
 					} );
 				}
 				}
 
 
@@ -286,7 +286,7 @@ export default class ImageInsertPanelView extends View {
 			withText: true
 			withText: true
 		} );
 		} );
 
 
-		insertButtonView.bind( 'isEnabled' ).to( this, 'imageURLInputValue' );
+		insertButtonView.bind( 'isEnabled' ).to( this, 'imageURLInputValue', value => !!value );
 		insertButtonView.delegate( 'execute' ).to( this, 'submit' );
 		insertButtonView.delegate( 'execute' ).to( this, 'submit' );
 		cancelButtonView.delegate( 'execute' ).to( this, 'cancel' );
 		cancelButtonView.delegate( 'execute' ).to( this, 'cancel' );
 
 

+ 3 - 8
packages/ckeditor5-image/src/imagestyle/utils.js

@@ -7,13 +7,11 @@
  * @module image/imagestyle/utils
  * @module image/imagestyle/utils
  */
  */
 
 
-/* globals console */
-
 import fullWidthIcon from '@ckeditor/ckeditor5-core/theme/icons/object-full-width.svg';
 import fullWidthIcon from '@ckeditor/ckeditor5-core/theme/icons/object-full-width.svg';
 import leftIcon from '@ckeditor/ckeditor5-core/theme/icons/object-left.svg';
 import leftIcon from '@ckeditor/ckeditor5-core/theme/icons/object-left.svg';
 import centerIcon from '@ckeditor/ckeditor5-core/theme/icons/object-center.svg';
 import centerIcon from '@ckeditor/ckeditor5-core/theme/icons/object-center.svg';
 import rightIcon from '@ckeditor/ckeditor5-core/theme/icons/object-right.svg';
 import rightIcon from '@ckeditor/ckeditor5-core/theme/icons/object-right.svg';
-import { attachLinkToDocumentation } from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
+import { logWarning } from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
 
 
 /**
 /**
  * Default image styles provided by the plugin that can be referred in the
  * Default image styles provided by the plugin that can be referred in the
@@ -120,12 +118,9 @@ function _normalizeStyle( style ) {
 			 * There is no such image style of given name.
 			 * There is no such image style of given name.
 			 *
 			 *
 			 * @error image-style-not-found
 			 * @error image-style-not-found
-		 	 * @param {String} name Name of a missing style name.
+			 * @param {String} name Name of a missing style name.
 			 */
 			 */
-			console.warn(
-				attachLinkToDocumentation( 'image-style-not-found' ),
-				{ name: styleName }
-			);
+			logWarning( 'image-style-not-found', { name: styleName } );
 
 
 			// Normalize the style anyway to prevent errors.
 			// Normalize the style anyway to prevent errors.
 			style = {
 			style = {

+ 14 - 0
packages/ckeditor5-image/tests/imageinsert/imageinsertui.js

@@ -278,6 +278,20 @@ describe( 'ImageInsertUI', () => {
 				sinon.assert.notCalled( commandSpy );
 				sinon.assert.notCalled( commandSpy );
 				sinon.assert.calledOnce( cancelSpy );
 				sinon.assert.calledOnce( cancelSpy );
 			} );
 			} );
+
+			it( 'should focus on "insert image via URL" input after opening', () => {
+				let spy;
+
+				// The ImageInsertPanelView is added on first open.
+				// See https://github.com/ckeditor/ckeditor5/pull/8019#discussion_r484069652
+				dropdown.on( 'change:isOpen', () => {
+					const imageInsertPanelView = dropdown.panelView.children.first;
+					spy = sinon.spy( imageInsertPanelView, 'focus' );
+				}, { priority: 'highest' } );
+
+				dropdown.buttonView.fire( 'open' );
+				sinon.assert.calledOnce( spy );
+			} );
 		} );
 		} );
 
 
 		it( 'should inject integrations to the dropdown panel view from the config', async () => {
 		it( 'should inject integrations to the dropdown panel view from the config', async () => {

+ 27 - 0
packages/ckeditor5-image/tests/imageinsert/ui/imageinsertpanelview.js

@@ -294,5 +294,32 @@ describe( 'ImageUploadPanelView', () => {
 
 
 			expect( view.imageURLInputValue ).to.equal( 'xyz' );
 			expect( view.imageURLInputValue ).to.equal( 'xyz' );
 		} );
 		} );
+
+		it( 'should trim input value', () => {
+			const form = view.getIntegration( 'insertImageViaUrl' );
+
+			form.fieldView.element.value = '   ';
+			form.fieldView.fire( 'input' );
+
+			expect( view.imageURLInputValue ).to.equal( '' );
+
+			form.fieldView.element.value = '   test   ';
+			form.fieldView.fire( 'input' );
+
+			expect( view.imageURLInputValue ).to.equal( 'test' );
+		} );
+
+		it( 'binds saveButtonView#isEnabled to URL input value', () => {
+			const form = view.getIntegration( 'insertImageViaUrl' );
+			const saveButtonView = view.template.children[ 1 ].children.first;
+
+			expect( saveButtonView.isEnabled ).to.be.false;
+
+			form.fieldView.element.value = 'test';
+			form.fieldView.fire( 'input' );
+
+			expect( view.imageURLInputValue ).to.equal( 'test' );
+			expect( !!saveButtonView.isEnabled ).to.be.true;
+		} );
 	} );
 	} );
 } );
 } );

+ 2 - 1
packages/ckeditor5-image/tests/imagestyle/utils.js

@@ -109,7 +109,8 @@ describe( 'ImageStyle utils', () => {
 				sinon.assert.calledOnce( console.warn );
 				sinon.assert.calledOnce( console.warn );
 				sinon.assert.calledWithExactly( console.warn,
 				sinon.assert.calledWithExactly( console.warn,
 					sinon.match( /^image-style-not-found/ ),
 					sinon.match( /^image-style-not-found/ ),
-					{ name: 'foo' }
+					{ name: 'foo' },
+					sinon.match.string // Link to the documentation
 				);
 				);
 			} );
 			} );
 		} );
 		} );

+ 10 - 5
packages/ckeditor5-image/tests/imageupload/imageuploadprogress.js

@@ -104,6 +104,7 @@ describe( 'ImageUploadProgress', () => {
 	} );
 	} );
 
 
 	// See https://github.com/ckeditor/ckeditor5/issues/1985.
 	// See https://github.com/ckeditor/ckeditor5/issues/1985.
+	// Might be obsolete after changes in table refreshing (now it refreshes siblings of an image and not its parent).
 	it( 'should work if image parent is refreshed by the differ', function( done ) {
 	it( 'should work if image parent is refreshed by the differ', function( done ) {
 		model.schema.register( 'outerBlock', {
 		model.schema.register( 'outerBlock', {
 			allowWhere: '$block',
 			allowWhere: '$block',
@@ -125,7 +126,7 @@ describe( 'ImageUploadProgress', () => {
 				if ( change.type == 'insert' && change.name == 'image' ) {
 				if ( change.type == 'insert' && change.name == 'image' ) {
 					doc.differ.refreshItem( change.position.parent );
 					doc.differ.refreshItem( change.position.parent );
 
 
-					return true;
+					return false; // Refreshing item should not trigger calling post-fixer again.
 				}
 				}
 			}
 			}
 		} );
 		} );
@@ -137,10 +138,14 @@ describe( 'ImageUploadProgress', () => {
 		model.document.once( 'change', () => {
 		model.document.once( 'change', () => {
 			try {
 			try {
 				expect( getViewData( view ) ).to.equal(
 				expect( getViewData( view ) ).to.equal(
-					'<outerBlock><innerBlock>[<figure class="ck-appear ck-widget image" contenteditable="false">' +
-					`<img src="${ base64Sample }"></img>` +
-					'<div class="ck-progress-bar"></div>' +
-					'</figure>]</innerBlock></outerBlock>'
+					'<outerBlock>' +
+						'<innerBlock>' +
+							'[<figure class="ck-appear ck-widget image" contenteditable="false">' +
+								`<img src="${ base64Sample }"></img>` +
+								'<div class="ck-progress-bar"></div>' +
+							'</figure>]' +
+						'</innerBlock>' +
+					'</outerBlock>'
 				);
 				);
 
 
 				done();
 				done();

+ 8 - 0
packages/ckeditor5-indent/docs/features/indent.md

@@ -15,6 +15,14 @@ The block indentation feature allows you to set indentation for text blocks such
 
 
 {@snippet features/indent}
 {@snippet features/indent}
 
 
+## Related features
+
+Here are some CKEditor 5 features that may aid structuring your content better:
+* {@link features/block-quote Block quote} &ndash; Include block quotations or pull quotes in your rich-text content.
+* {@link features/headings Headings} &ndash; Divide your content into sections.
+* {@link features/code-blocks Code block} &ndash; Insert longer, multiline code listings.
+* {@link features/text-alignment Text alignment} &ndash; Because it does matter whether the content is left, right, centered or justified.
+
 ## Configuring the block indentation feature
 ## Configuring the block indentation feature
 
 
 This feature offers a few configuration options that can be used to adjust the text block indentation behavior.
 This feature offers a few configuration options that can be used to adjust the text block indentation behavior.

+ 27 - 15
packages/ckeditor5-list/docs/_snippets/features/lists-style.html

@@ -1,18 +1,30 @@
 <div id="snippet-lists-styles">
 <div id="snippet-lists-styles">
-    <h2>Ingredients</h2>
-    <p>Ingredients required for making a coffee:</p>
-    <ul style="list-style-type:circle;">
-        <li>15g ground coffee</li>
-        <li>water</li>
-        <li>milk <i>(optional)</i></li>
-    </ul>
-    <h2>Instructions</h2>
-    <p>How to prepare a cup of coffee:</p>
-    <ol style="list-style-type:decimal-leading-zero;">
-        <li>Gather the ingredients.</li>
-        <li>Put 15g of ground coffee in the cup.</li>
-        <li>Boil 200ml of water.</li>
-        <li>Pour the water into the cup.</li>
-        <li>Optional: Add milk.</li>
+    <h2>Modern manned spacecraft</h2>
+    <ol style="list-style-type:upper-roman;">
+        <li>Soyuz (Soviet/Russian)</li>
+            <ol style="list-style-type:decimal;">
+                <li>Early stage (all retired)</li>
+                    <ol style="list-style-type:lower-latin;">
+                        <li>7K-OK</li>
+                        <li>7KT-OK</li>
+                        <li>7K-T</li>
+                        <li>7K-TM</li>
+                    </ol>
+                <li>Soyuz T (retired)</li>
+                <li>Soyuz TM (retired)</li>
+                <li>Soyuz TMA (retired)</li>
+                <li>Soyuz TMA-M (retired)</li>
+                <li>Soyuz MS</li>
+            </ol>
+        <li>STS orbiter (American; all retired)</li>
+            <ol style="list-style-type:decimal;">
+                <li>Columbia</li>
+                <li>Challenger</li>
+                <li>Discovery</li>
+                <li>Atlantis</li>
+                <li>Endeavour</li>
+            </ol>
+        <li>SpaceX Crew Dragon (American)</li>
+        <li>Shenzhou (Chinese)</li>
     </ol>
     </ol>
 </div>
 </div>

+ 8 - 6
packages/ckeditor5-list/docs/features/lists.md

@@ -4,7 +4,7 @@ category: features
 
 
 # Lists
 # Lists
 
 
-The {@link module:list/list~List list} feature allows creating ordered (numbered) and unordered (bulleted) lists in the editor.
+The {@link module:list/list~List list} feature allows creating ordered (numbered) and unordered (bulleted) lists. This allows for better structuring and presenting specific content such as enumerating elements, creating tables of content or {@link features/todo-lists to-do lists}.
 
 
 <info-box info>
 <info-box info>
 	The feature is enabled by default in all CKEditor 5 WYSIWYG editor builds.
 	The feature is enabled by default in all CKEditor 5 WYSIWYG editor builds.
@@ -34,7 +34,13 @@ Use the editor below to see the list style plugin in action.
 
 
 {@snippet features/lists-style}
 {@snippet features/lists-style}
 
 
-### Installation
+## Related features
+
+These features also provide similar functionality:
+* {@link features/todo-lists To-do lists} &ndash; Create a list of interactive checkboxes with labels.
+* {@link features/indent Block indentation} &ndash; Set indentation for text blocks such as paragraphs or headings and lists.
+
+## Installation
 
 
 To add this feature to your editor, install the [`@ckeditor/ckeditor5-list`](https://www.npmjs.com/package/@ckeditor/ckeditor5-list) package:
 To add this feature to your editor, install the [`@ckeditor/ckeditor5-list`](https://www.npmjs.com/package/@ckeditor/ckeditor5-list) package:
 
 
@@ -68,10 +74,6 @@ ClassicEditor
 
 
 Refer to the {@link features/indent Indenting lists} section of the Block indentation feature guide.
 Refer to the {@link features/indent Indenting lists} section of the Block indentation feature guide.
 
 
-## To-do list
-
-You can read more about the feature in the {@link features/todo-lists To-do lists} feature guide.
-
 ## Common API
 ## Common API
 
 
 The {@link module:list/list~List} plugin registers:
 The {@link module:list/list~List} plugin registers:

+ 0 - 1
packages/ckeditor5-media-embed/src/mediaembedui.js

@@ -104,7 +104,6 @@ export default class MediaEmbedUI extends Plugin {
 
 
 		// Form elements should be read-only when corresponding commands are disabled.
 		// Form elements should be read-only when corresponding commands are disabled.
 		form.urlInputView.bind( 'isReadOnly' ).to( command, 'isEnabled', value => !value );
 		form.urlInputView.bind( 'isReadOnly' ).to( command, 'isEnabled', value => !value );
-		form.saveButtonView.bind( 'isEnabled' ).to( command );
 	}
 	}
 }
 }
 
 

+ 2 - 4
packages/ckeditor5-media-embed/src/mediaregistry.js

@@ -7,13 +7,11 @@
  * @module media-embed/mediaregistry
  * @module media-embed/mediaregistry
  */
  */
 
 
-/* globals console */
-
 import mediaPlaceholderIcon from '../theme/icons/media-placeholder.svg';
 import mediaPlaceholderIcon from '../theme/icons/media-placeholder.svg';
 import TooltipView from '@ckeditor/ckeditor5-ui/src/tooltip/tooltipview';
 import TooltipView from '@ckeditor/ckeditor5-ui/src/tooltip/tooltipview';
 import IconView from '@ckeditor/ckeditor5-ui/src/icon/iconview';
 import IconView from '@ckeditor/ckeditor5-ui/src/icon/iconview';
 import Template from '@ckeditor/ckeditor5-ui/src/template';
 import Template from '@ckeditor/ckeditor5-ui/src/template';
-import { attachLinkToDocumentation } from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
+import { logWarning } from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
 
 
 const mediaPlaceholderIconViewBox = '0 0 64 42';
 const mediaPlaceholderIconViewBox = '0 0 64 42';
 
 
@@ -48,7 +46,7 @@ export default class MediaRegistry {
 					 *
 					 *
 					 * @error media-embed-no-provider-name
 					 * @error media-embed-no-provider-name
 					 */
 					 */
-					console.warn( attachLinkToDocumentation( 'media-embed-no-provider-name' ), { provider } );
+					logWarning( 'media-embed-no-provider-name', { provider } );
 
 
 					return false;
 					return false;
 				}
 				}

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

@@ -57,6 +57,14 @@ export default class MediaFormView extends View {
 		 */
 		 */
 		this.keystrokes = new KeystrokeHandler();
 		this.keystrokes = new KeystrokeHandler();
 
 
+		/**
+		 * The value of the URL input.
+		 *
+		 * @member {String} #mediaURLInputValue
+		 * @observable
+		 */
+		this.set( 'mediaURLInputValue', '' );
+
 		/**
 		/**
 		 * The URL input view.
 		 * The URL input view.
 		 *
 		 *
@@ -71,6 +79,7 @@ export default class MediaFormView extends View {
 		 */
 		 */
 		this.saveButtonView = this._createButton( t( 'Save' ), checkIcon, 'ck-button-save' );
 		this.saveButtonView = this._createButton( t( 'Save' ), checkIcon, 'ck-button-save' );
 		this.saveButtonView.type = 'submit';
 		this.saveButtonView.type = 'submit';
+		this.saveButtonView.bind( 'isEnabled' ).to( this, 'mediaURLInputValue', value => !!value );
 
 
 		/**
 		/**
 		 * The Cancel button view.
 		 * The Cancel button view.
@@ -276,6 +285,7 @@ export default class MediaFormView extends View {
 		inputField.on( 'input', () => {
 		inputField.on( 'input', () => {
 			// Display the tip text only when there's some value. Otherwise fall back to the default info text.
 			// Display the tip text only when there's some value. Otherwise fall back to the default info text.
 			labeledInput.infoText = inputField.element.value ? this._urlInputViewInfoTip : this._urlInputViewInfoDefault;
 			labeledInput.infoText = inputField.element.value ? this._urlInputViewInfoTip : this._urlInputViewInfoDefault;
+			this.mediaURLInputValue = inputField.element.value.trim();
 		} );
 		} );
 
 
 		return labeledInput;
 		return labeledInput;

+ 18 - 4
packages/ckeditor5-media-embed/tests/mediaembedui.js

@@ -209,13 +209,27 @@ describe( 'MediaEmbedUI', () => {
 			expect( form.urlInputView.isReadOnly ).to.be.true;
 			expect( form.urlInputView.isReadOnly ).to.be.true;
 		} );
 		} );
 
 
-		it( 'binds saveButtonView#isEnabled to command#isEnabled', () => {
-			const command = editor.commands.get( 'mediaEmbed' );
+		it( 'should trim URL input value', () => {
+			form.urlInputView.fieldView.element.value = '   ';
+			form.urlInputView.fieldView.fire( 'input' );
 
 
-			expect( form.saveButtonView.isEnabled ).to.be.true;
+			expect( form.mediaURLInputValue ).to.equal( '' );
+
+			form.urlInputView.fieldView.element.value = '   test   ';
+			form.urlInputView.fieldView.fire( 'input' );
+
+			expect( form.mediaURLInputValue ).to.equal( 'test' );
+		} );
+
+		it( 'binds saveButtonView#isEnabled to trimmed URL input value', () => {
+			form.urlInputView.fieldView.fire( 'input' );
 
 
-			command.isEnabled = false;
 			expect( form.saveButtonView.isEnabled ).to.be.false;
 			expect( form.saveButtonView.isEnabled ).to.be.false;
+
+			form.urlInputView.fieldView.element.value = 'test';
+			form.urlInputView.fieldView.fire( 'input' );
+
+			expect( form.saveButtonView.isEnabled ).to.be.true;
 		} );
 		} );
 
 
 		describe( 'validators', () => {
 		describe( 'validators', () => {

+ 5 - 8
packages/ckeditor5-mention/src/mentionui.js

@@ -7,8 +7,6 @@
  * @module mention/mentionui
  * @module mention/mentionui
  */
  */
 
 
-/* global console */
-
 import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
 import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
 import ButtonView from '@ckeditor/ckeditor5-ui/src/button/buttonview';
 import ButtonView from '@ckeditor/ckeditor5-ui/src/button/buttonview';
 import Collection from '@ckeditor/ckeditor5-utils/src/collection';
 import Collection from '@ckeditor/ckeditor5-utils/src/collection';
@@ -16,7 +14,7 @@ import clickOutsideHandler from '@ckeditor/ckeditor5-ui/src/bindings/clickoutsid
 import { keyCodes } from '@ckeditor/ckeditor5-utils/src/keyboard';
 import { keyCodes } from '@ckeditor/ckeditor5-utils/src/keyboard';
 import env from '@ckeditor/ckeditor5-utils/src/env';
 import env from '@ckeditor/ckeditor5-utils/src/env';
 import Rect from '@ckeditor/ckeditor5-utils/src/dom/rect';
 import Rect from '@ckeditor/ckeditor5-utils/src/dom/rect';
-import CKEditorError, { attachLinkToDocumentation } from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
+import CKEditorError, { logWarning } from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
 import ContextualBalloon from '@ckeditor/ckeditor5-ui/src/panel/balloon/contextualballoon';
 import ContextualBalloon from '@ckeditor/ckeditor5-ui/src/panel/balloon/contextualballoon';
 import { debounce } from 'lodash-es';
 import { debounce } from 'lodash-es';
 
 
@@ -156,11 +154,9 @@ export default class MentionUI extends Plugin {
 				 * See {@link module:mention/mention~MentionConfig}.
 				 * See {@link module:mention/mention~MentionConfig}.
 				 *
 				 *
 				 * @error mentionconfig-incorrect-marker
 				 * @error mentionconfig-incorrect-marker
+				 * @param {String} marker Configured marker
 				 */
 				 */
-				throw new CKEditorError(
-					'mentionconfig-incorrect-marker',
-					null
-				);
+				throw new CKEditorError( 'mentionconfig-incorrect-marker', null, { marker } );
 			}
 			}
 
 
 			const minimumCharacters = mentionDescription.minimumCharacters || 0;
 			const minimumCharacters = mentionDescription.minimumCharacters || 0;
@@ -348,8 +344,9 @@ export default class MentionUI extends Plugin {
 				 * not displayed at all.
 				 * not displayed at all.
 				 *
 				 *
 				 * @error mention-feed-callback-error
 				 * @error mention-feed-callback-error
+				 * @param {String} marker Configured marker for which error occurred.
 				 */
 				 */
-				console.warn( attachLinkToDocumentation( 'mention-feed-callback-error' ) );
+				logWarning( 'mention-feed-callback-error', { marker } );
 			} );
 			} );
 	}
 	}
 
 

+ 9 - 4
packages/ckeditor5-mention/tests/mentionui.js

@@ -66,19 +66,19 @@ describe( 'MentionUI', () => {
 	describe( 'init()', () => {
 	describe( 'init()', () => {
 		it( 'should throw if marker was not provided for feed', () => {
 		it( 'should throw if marker was not provided for feed', () => {
 			return createClassicTestEditor( { feeds: [ { feed: [ 'a' ] } ] } ).catch( error => {
 			return createClassicTestEditor( { feeds: [ { feed: [ 'a' ] } ] } ).catch( error => {
-				assertCKEditorError( error, /mentionconfig-incorrect-marker/, null );
+				assertCKEditorError( error, /mentionconfig-incorrect-marker/, null, { marker: undefined } );
 			} );
 			} );
 		} );
 		} );
 
 
 		it( 'should throw if marker is empty string', () => {
 		it( 'should throw if marker is empty string', () => {
 			return createClassicTestEditor( { feeds: [ { marker: '', feed: [ 'a' ] } ] } ).catch( error => {
 			return createClassicTestEditor( { feeds: [ { marker: '', feed: [ 'a' ] } ] } ).catch( error => {
-				assertCKEditorError( error, /mentionconfig-incorrect-marker/, null );
+				assertCKEditorError( error, /mentionconfig-incorrect-marker/, null, { marker: '' } );
 			} );
 			} );
 		} );
 		} );
 
 
 		it( 'should throw if marker is longer then 1 character', () => {
 		it( 'should throw if marker is longer then 1 character', () => {
 			return createClassicTestEditor( { feeds: [ { marker: '$$', feed: [ 'a' ] } ] } ).catch( error => {
 			return createClassicTestEditor( { feeds: [ { marker: '$$', feed: [ 'a' ] } ] } ).catch( error => {
-				assertCKEditorError( error, /mentionconfig-incorrect-marker/, null );
+				assertCKEditorError( error, /mentionconfig-incorrect-marker/, null, { marker: '$$' } );
 			} );
 			} );
 		} );
 		} );
 	} );
 	} );
@@ -1249,7 +1249,12 @@ describe( 'MentionUI', () => {
 						expect( panelView.isVisible, 'panel is hidden' ).to.be.false;
 						expect( panelView.isVisible, 'panel is hidden' ).to.be.false;
 						expect( editor.model.markers.has( 'mention' ), 'there is no marker' ).to.be.false;
 						expect( editor.model.markers.has( 'mention' ), 'there is no marker' ).to.be.false;
 
 
-						sinon.assert.calledWithExactly( warnSpy, sinon.match( /^mention-feed-callback-error/ ) );
+						sinon.assert.calledWithExactly(
+							warnSpy,
+							sinon.match( /^mention-feed-callback-error/ ),
+							sinon.match( { marker: '#' } ),
+							sinon.match.string // Link to the documentation
+						);
 						sinon.assert.calledOnce( eventSpy );
 						sinon.assert.calledOnce( eventSpy );
 					} );
 					} );
 			} );
 			} );

+ 7 - 0
packages/ckeditor5-remove-format/docs/features/remove-format.md

@@ -16,6 +16,13 @@ Select the content you want to clean up and press the "Remove Format" button in
 
 
 {@snippet features/remove-format}
 {@snippet features/remove-format}
 
 
+## Related features
+
+There are more CKEditor 5 features that can help you format your content:
+* {@link features/basic-styles Basic font styles} &ndash; The essentials, like **bold**, *italic* and others.
+* {@link features/font Font styles} &ndash; Easily and efficiently control the font {@link features/font#configuring-the-font-family-feature family}, {@link features/font#configuring-the-font-size-feature size}, {@link features/font#configuring-the-font-color-and-font-background-color-features text or background color}.
+* {@link features/text-alignment Text alignment} &ndash; Align your content left, align it right, center it or justify.
+
 ## Configuring the remove format feature
 ## Configuring the remove format feature
 
 
 This feature has no integration–level configuration. Once enabled, it works out–of–the–box with all {@link features/index core editor features}.
 This feature has no integration–level configuration. Once enabled, it works out–of–the–box with all {@link features/index core editor features}.

+ 54 - 18
packages/ckeditor5-table/src/converters/downcast.js

@@ -8,7 +8,7 @@
  */
  */
 
 
 import TableWalker from './../tablewalker';
 import TableWalker from './../tablewalker';
-import { toWidget, toWidgetEditable, setHighlightHandling } from '@ckeditor/ckeditor5-widget/src/utils';
+import { setHighlightHandling, toWidget, toWidgetEditable } from '@ckeditor/ckeditor5-widget/src/utils';
 
 
 /**
 /**
  * Model table element to view table element conversion helper.
  * Model table element to view table element conversion helper.
@@ -235,6 +235,54 @@ export function downcastRemoveRow() {
 	}, { priority: 'higher' } );
 	}, { priority: 'higher' } );
 }
 }
 
 
+/**
+ * Overrides paragraph inside table cell conversion.
+ *
+ * This converter:
+ * * should be used to override default paragraph conversion in the editing view.
+ * * It will only convert <paragraph> placed directly inside <tableCell>.
+ * * For a single paragraph without attributes it returns `<span>` to simulate data table.
+ * * For all other cases it returns `<p>` element.
+ *
+ * @param {module:engine/model/element~Element} modelElement
+ * @param {module:engine/conversion/downcastdispatcher~DowncastConversionApi} conversionApi
+ * @returns {module:engine/view/containerelement~ContainerElement|undefined}
+ */
+export function convertParagraphInTableCell( modelElement, conversionApi ) {
+	const { writer } = conversionApi;
+
+	if ( !modelElement.parent.is( 'element', 'tableCell' ) ) {
+		return;
+	}
+
+	if ( isSingleParagraphWithoutAttributes( modelElement ) ) {
+		// Use display:inline-block to force Chrome/Safari to limit text mutations to this element.
+		// See #6062.
+		return writer.createContainerElement( 'span', { style: 'display:inline-block' } );
+	} else {
+		return writer.createContainerElement( 'p' );
+	}
+}
+
+/**
+ * Checks if given model `<paragraph>` is an only child of a parent (`<tableCell>`) and if it has any attribute set.
+ *
+ * The paragraph should be converted in the editing view to:
+ *
+ * * If returned `true` - to a `<span style="display:inline-block">`
+ * * If returned `false` - to a `<p>`
+ *
+ * @param {module:engine/model/element~Element} modelElement
+ * @returns {Boolean}
+ */
+export function isSingleParagraphWithoutAttributes( modelElement ) {
+	const tableCell = modelElement.parent;
+
+	const isSingleParagraph = tableCell.childCount === 1;
+
+	return isSingleParagraph && !hasAnyAttribute( modelElement );
+}
+
 // Converts a given {@link module:engine/view/element~Element} to a table widget:
 // Converts a given {@link module:engine/view/element~Element} to a table widget:
 // * Adds a {@link module:engine/view/element~Element#_setCustomProperty custom property} allowing to recognize the table widget element.
 // * Adds a {@link module:engine/view/element~Element#_setCustomProperty custom property} allowing to recognize the table widget element.
 // * Calls the {@link module:widget/utils~toWidget} function with the proper element's label creator.
 // * Calls the {@link module:widget/utils~toWidget} function with the proper element's label creator.
@@ -327,27 +375,15 @@ function createViewTableCellElement( tableSlot, tableAttributes, insertPosition,
 
 
 	conversionApi.writer.insert( insertPosition, cellElement );
 	conversionApi.writer.insert( insertPosition, cellElement );
 
 
-	if ( isSingleParagraph && !hasAnyAttribute( firstChild ) ) {
+	conversionApi.mapper.bindElements( tableCell, cellElement );
+
+	// Additional requirement for data pipeline to have backward compatible data tables.
+	if ( !asWidget && !hasAnyAttribute( firstChild ) && isSingleParagraph ) {
 		const innerParagraph = tableCell.getChild( 0 );
 		const innerParagraph = tableCell.getChild( 0 );
-		const paragraphInsertPosition = conversionApi.writer.createPositionAt( cellElement, 'end' );
 
 
 		conversionApi.consumable.consume( innerParagraph, 'insert' );
 		conversionApi.consumable.consume( innerParagraph, 'insert' );
 
 
-		if ( asWidget ) {
-			// Use display:inline-block to force Chrome/Safari to limit text mutations to this element.
-			// See #6062.
-			const fakeParagraph = conversionApi.writer.createContainerElement( 'span', { style: 'display:inline-block' } );
-
-			conversionApi.mapper.bindElements( innerParagraph, fakeParagraph );
-			conversionApi.writer.insert( paragraphInsertPosition, fakeParagraph );
-
-			conversionApi.mapper.bindElements( tableCell, cellElement );
-		} else {
-			conversionApi.mapper.bindElements( tableCell, cellElement );
-			conversionApi.mapper.bindElements( innerParagraph, cellElement );
-		}
-	} else {
-		conversionApi.mapper.bindElements( tableCell, cellElement );
+		conversionApi.mapper.bindElements( innerParagraph, cellElement );
 	}
 	}
 }
 }
 
 

+ 31 - 59
packages/ckeditor5-table/src/converters/table-cell-refresh-post-fixer.js

@@ -7,6 +7,8 @@
  * @module table/converters/table-cell-refresh-post-fixer
  * @module table/converters/table-cell-refresh-post-fixer
  */
  */
 
 
+import { isSingleParagraphWithoutAttributes } from './downcast';
+
 /**
 /**
  * Injects a table cell post-fixer into the model which marks the table cell in the differ to have it re-rendered.
  * Injects a table cell post-fixer into the model which marks the table cell in the differ to have it re-rendered.
  *
  *
@@ -17,87 +19,57 @@
  * re-rendered so it changes from `<span>` to `<p>`. The easiest way to do it is to re-render the entire table cell.
  * re-rendered so it changes from `<span>` to `<p>`. The easiest way to do it is to re-render the entire table cell.
  *
  *
  * @param {module:engine/model/model~Model} model
  * @param {module:engine/model/model~Model} model
+ * @param {module:engine/conversion/mapper~Mapper} mapper
  */
  */
-export default function injectTableCellRefreshPostFixer( model ) {
-	model.document.registerPostFixer( () => tableCellRefreshPostFixer( model ) );
+export default function injectTableCellRefreshPostFixer( model, mapper ) {
+	model.document.registerPostFixer( () => tableCellRefreshPostFixer( model.document.differ, mapper ) );
 }
 }
 
 
-function tableCellRefreshPostFixer( model ) {
-	const differ = model.document.differ;
-
-	// Stores cells to be refreshed so the table cell will be refreshed once for multiple changes.
-	const cellsToRefresh = new Set();
+function tableCellRefreshPostFixer( differ, mapper ) {
+	// Stores cells to be refreshed, so the table cell will be refreshed once for multiple changes.
 
 
-	// Counting the paragraph inserts to verify if it increased to more than one paragraph in the current differ.
-	let insertCount = 0;
+	// 1. Gather all changes inside table cell.
+	const cellsToCheck = new Set();
 
 
 	for ( const change of differ.getChanges() ) {
 	for ( const change of differ.getChanges() ) {
-		const parent = change.type == 'insert' || change.type == 'remove' ? change.position.parent : change.range.start.parent;
-
-		if ( !parent.is( 'element', 'tableCell' ) ) {
-			continue;
-		}
-
-		if ( change.type == 'insert' ) {
-			insertCount++;
-		}
+		const parent = change.type == 'attribute' ? change.range.start.parent : change.position.parent;
 
 
-		if ( checkRefresh( parent, change.type, insertCount ) ) {
-			cellsToRefresh.add( parent );
+		if ( parent.is( 'element', 'tableCell' ) ) {
+			cellsToCheck.add( parent );
 		}
 		}
 	}
 	}
 
 
-	if ( cellsToRefresh.size ) {
-		// @if CK_DEBUG_TABLE // console.log( `Post-fixing table: refreshing cells (${ cellsToRefresh.size }).` );
+	// @if CK_DEBUG_TABLE // console.log( `Post-fixing table: Checking table cell to refresh (${ cellsToCheck.size }).` );
+	// @if CK_DEBUG_TABLE // let paragraphsRefreshed = 0;
 
 
-		for ( const tableCell of cellsToRefresh.values() ) {
-			differ.refreshItem( tableCell );
+	for ( const tableCell of cellsToCheck.values() ) {
+		for ( const paragraph of [ ...tableCell.getChildren() ].filter( child => shouldRefresh( child, mapper ) ) ) {
+			// @if CK_DEBUG_TABLE // console.log( `Post-fixing table: refreshing paragraph in table cell (${++paragraphsRefreshed}).` );
+			differ.refreshItem( paragraph );
 		}
 		}
-
-		return true;
 	}
 	}
 
 
+	// Always return false to prevent the refresh post-fixer from re-running on the same set of changes and going into an infinite loop.
+	// This "post-fixer" does not change the model structure so there shouldn't be need to run other post-fixers again.
+	// See https://github.com/ckeditor/ckeditor5/issues/1936 & https://github.com/ckeditor/ckeditor5/issues/8200.
 	return false;
 	return false;
 }
 }
 
 
-// Checks if the model table cell requires refreshing to be re-rendered to a proper state in the view.
-//
-// This method detects changes that will require renaming `<span>` to `<p>` (or vice versa) in the view.
-//
-// This method is a simple heuristic that checks only a single change and will sometimes give a false positive result when multiple changes
-// will result in a state that does not require renaming in the view (but will be seen as requiring a refresh).
+// Check if given model element needs refreshing.
 //
 //
-// For instance: A `<span>` should be renamed to `<p>` when adding an attribute to a `<paragraph>`.
-// But adding one attribute and removing another one will result in a false positive: the check for an added attribute will see one
-// attribute on a paragraph and will falsely qualify such change as adding an attribute to a paragraph without any attribute.
-//
-// @param {module:engine/model/element~Element} tableCell The table cell to check.
-// @param {String} type Type of change.
-// @param {Number} insertCount The number of inserts in differ.
-function checkRefresh( tableCell, type, insertCount ) {
-	const hasInnerParagraph = Array.from( tableCell.getChildren() ).some( child => child.is( 'element', 'paragraph' ) );
-
-	// If there is no paragraph in table cell then the view doesn't require refreshing.
-	//
-	// Why? What we really want to achieve is to make all the old paragraphs (which weren't added in this batch) to be
-	// converted once again, so that the paragraph-in-table-cell converter can correctly create a `<p>` or a `<span>` element.
-	// If there are no paragraphs in the table cell, we don't care.
-	if ( !hasInnerParagraph ) {
+// @param {module:engine/model/element~Element} modelElement
+// @param {module:engine/conversion/mapper~Mapper} mapper
+// @returns {Boolean}
+function shouldRefresh( child, mapper ) {
+	if ( !child.is( 'element', 'paragraph' ) ) {
 		return false;
 		return false;
 	}
 	}
 
 
-	// For attribute change we only refresh if there is a single paragraph as in this case we may want to change existing `<span>` to `<p>`.
-	if ( type == 'attribute' ) {
-		const attributesCount = Array.from( tableCell.getChild( 0 ).getAttributeKeys() ).length;
+	const viewElement = mapper.toViewElement( child );
 
 
-		return tableCell.childCount === 1 && attributesCount < 2;
+	if ( !viewElement ) {
+		return false;
 	}
 	}
 
 
-	// For other changes (insert, remove) the `<span>` to `<p>` change is needed when:
-	//
-	// - another element is added to a single paragraph (childCount becomes >= 2)
-	// - another element is removed and a single paragraph is left (childCount == 1)
-	//
-	// Change is not needed if there were multiple blocks before change.
-	return tableCell.childCount <= ( type == 'insert' ? insertCount + 1 : 1 );
+	return isSingleParagraphWithoutAttributes( child ) !== viewElement.is( 'element', 'span' );
 }
 }

+ 1 - 0
packages/ckeditor5-table/src/converters/table-heading-rows-refresh-post-fixer.js

@@ -44,6 +44,7 @@ function tableHeadingRowsRefreshPostFixer( model ) {
 		// @if CK_DEBUG_TABLE // console.log( `Post-fixing table: refreshing heading rows (${ tablesToRefresh.size }).` );
 		// @if CK_DEBUG_TABLE // console.log( `Post-fixing table: refreshing heading rows (${ tablesToRefresh.size }).` );
 
 
 		for ( const table of tablesToRefresh.values() ) {
 		for ( const table of tablesToRefresh.values() ) {
+			// Should be handled by a `triggerBy` configuration. See: https://github.com/ckeditor/ckeditor5/issues/8138.
 			differ.refreshItem( table );
 			differ.refreshItem( table );
 		}
 		}
 
 

+ 9 - 1
packages/ckeditor5-table/src/tableediting.js

@@ -11,6 +11,7 @@ import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
 
 
 import upcastTable, { skipEmptyTableRow } from './converters/upcasttable';
 import upcastTable, { skipEmptyTableRow } from './converters/upcasttable';
 import {
 import {
+	convertParagraphInTableCell,
 	downcastInsertCell,
 	downcastInsertCell,
 	downcastInsertRow,
 	downcastInsertRow,
 	downcastInsertTable,
 	downcastInsertTable,
@@ -109,6 +110,13 @@ export default class TableEditing extends Plugin {
 
 
 		conversion.for( 'editingDowncast' ).add( downcastInsertCell() );
 		conversion.for( 'editingDowncast' ).add( downcastInsertCell() );
 
 
+		// Duplicates code - needed to properly refresh paragraph inside table cell.
+		editor.conversion.for( 'editingDowncast' ).elementToElement( {
+			model: 'paragraph',
+			view: convertParagraphInTableCell,
+			converterPriority: 'high'
+		} );
+
 		// Table attributes conversion.
 		// Table attributes conversion.
 		conversion.attributeToAttribute( { model: 'colspan', view: 'colspan' } );
 		conversion.attributeToAttribute( { model: 'colspan', view: 'colspan' } );
 		conversion.attributeToAttribute( { model: 'rowspan', view: 'rowspan' } );
 		conversion.attributeToAttribute( { model: 'rowspan', view: 'rowspan' } );
@@ -144,7 +152,7 @@ export default class TableEditing extends Plugin {
 
 
 		injectTableHeadingRowsRefreshPostFixer( model );
 		injectTableHeadingRowsRefreshPostFixer( model );
 		injectTableLayoutPostFixer( model );
 		injectTableLayoutPostFixer( model );
-		injectTableCellRefreshPostFixer( model );
+		injectTableCellRefreshPostFixer( model, editor.editing.mapper );
 		injectTableCellParagraphPostFixer( model );
 		injectTableCellParagraphPostFixer( model );
 	}
 	}
 
 

+ 3 - 3
packages/ckeditor5-table/tests/converters/downcast.js

@@ -1085,7 +1085,7 @@ describe( 'downcast converters', () => {
 				);
 				);
 			} );
 			} );
 
 
-			it( 'should react to removed row form the end of a body rows (no heading rows)', () => {
+			it( 'should react to removed row from the end of a body rows (no heading rows)', () => {
 				setModelData( model, modelTable( [
 				setModelData( model, modelTable( [
 					[ '00[]', '01' ],
 					[ '00[]', '01' ],
 					[ '10', '11' ]
 					[ '10', '11' ]
@@ -1149,7 +1149,7 @@ describe( 'downcast converters', () => {
 				);
 				);
 			} );
 			} );
 
 
-			it( 'should react to removed row form the end of a heading rows (no body rows)', () => {
+			it( 'should react to removed row from the end of a heading rows (no body rows)', () => {
 				setModelData( model, modelTable( [
 				setModelData( model, modelTable( [
 					[ '00[]', '01' ],
 					[ '00[]', '01' ],
 					[ '10', '11' ]
 					[ '10', '11' ]
@@ -1182,7 +1182,7 @@ describe( 'downcast converters', () => {
 				);
 				);
 			} );
 			} );
 
 
-			it( 'should react to removed row form the end of a heading rows (first cell in body has colspan)', () => {
+			it( 'should react to removed row from the end of a heading rows (first cell in body has colspan)', () => {
 				setModelData( model, modelTable( [
 				setModelData( model, modelTable( [
 					[ '00[]', '01', '02', '03' ],
 					[ '00[]', '01', '02', '03' ],
 					[ { rowspan: 2, colspan: 2, contents: '10' }, '12', '13' ],
 					[ { rowspan: 2, colspan: 2, contents: '10' }, '12', '13' ],

+ 1 - 1
packages/ckeditor5-table/tests/converters/table-cell-paragraph-post-fixer.js

@@ -122,7 +122,7 @@ describe( 'Table cell paragraph post-fixer', () => {
 		);
 		);
 	} );
 	} );
 
 
-	it( 'should wrap in paragraph $text nodes placed directly in tableCell (on table cell modification) ', () => {
+	it( 'should wrap in a paragraph $text nodes placed directly in tableCell (on table cell modification) ', () => {
 		setModelData( model,
 		setModelData( model,
 			'<table>' +
 			'<table>' +
 				'<tableRow>' +
 				'<tableRow>' +

+ 79 - 26
packages/ckeditor5-table/tests/converters/table-cell-refresh-post-fixer.js

@@ -16,7 +16,7 @@ import TableEditing from '../../src/tableediting';
 import { viewTable } from '../_utils/utils';
 import { viewTable } from '../_utils/utils';
 
 
 describe( 'Table cell refresh post-fixer', () => {
 describe( 'Table cell refresh post-fixer', () => {
-	let editor, model, doc, root, view, refreshItemSpy, element;
+	let editor, model, doc, root, view, element;
 
 
 	testUtils.createSinonSandbox();
 	testUtils.createSinonSandbox();
 
 
@@ -40,8 +40,6 @@ describe( 'Table cell refresh post-fixer', () => {
 				model.schema.extend( '$block', { allowAttributes: [ 'foo', 'bar' ] } );
 				model.schema.extend( '$block', { allowAttributes: [ 'foo', 'bar' ] } );
 				editor.conversion.attributeToAttribute( { model: 'foo', view: 'foo' } );
 				editor.conversion.attributeToAttribute( { model: 'foo', view: 'foo' } );
 				editor.conversion.attributeToAttribute( { model: 'bar', view: 'bar' } );
 				editor.conversion.attributeToAttribute( { model: 'bar', view: 'bar' } );
-
-				refreshItemSpy = sinon.spy( model.document.differ, 'refreshItem' );
 			} );
 			} );
 	} );
 	} );
 
 
@@ -50,10 +48,15 @@ describe( 'Table cell refresh post-fixer', () => {
 		return editor.destroy();
 		return editor.destroy();
 	} );
 	} );
 
 
-	it( 'should rename <span> to <p> when adding <paragraph> element to the same table cell', () => {
+	function getViewForParagraph( table ) {
+		return editor.editing.mapper.toViewElement( table.getNodeByPath( [ 0, 0, 0 ] ) );
+	}
+
+	it( 'should rename <span> to <p> when adding <paragraph> element to the same table cell (append)', () => {
 		editor.setData( viewTable( [ [ '<p>00</p>' ] ] ) );
 		editor.setData( viewTable( [ [ '<p>00</p>' ] ] ) );
 
 
 		const table = root.getChild( 0 );
 		const table = root.getChild( 0 );
+		const previousView = getViewForParagraph( table );
 
 
 		model.change( writer => {
 		model.change( writer => {
 			const nodeByPath = table.getNodeByPath( [ 0, 0, 0 ] );
 			const nodeByPath = table.getNodeByPath( [ 0, 0, 0 ] );
@@ -66,13 +69,33 @@ describe( 'Table cell refresh post-fixer', () => {
 		assertEqualMarkup( getViewData( view, { withoutSelection: true } ), viewTable( [
 		assertEqualMarkup( getViewData( view, { withoutSelection: true } ), viewTable( [
 			[ '<p>00</p><p></p>' ]
 			[ '<p>00</p><p></p>' ]
 		], { asWidget: true } ) );
 		], { asWidget: true } ) );
-		sinon.assert.calledOnce( refreshItemSpy );
+		expect( getViewForParagraph( table ) ).to.not.equal( previousView );
+	} );
+
+	it( 'should rename <span> to <p> when adding <paragraph> element to the same table cell (prepend)', () => {
+		editor.setData( viewTable( [ [ '<p>00</p>' ] ] ) );
+
+		const table = root.getChild( 0 );
+		const previousView = getViewForParagraph( table );
+
+		model.change( writer => {
+			const nodeByPath = table.getNodeByPath( [ 0, 0, 0 ] );
+			const paragraph = writer.createElement( 'paragraph' );
+
+			writer.insert( paragraph, nodeByPath, 'before' );
+		} );
+
+		assertEqualMarkup( getViewData( view, { withoutSelection: true } ), viewTable( [
+			[ '<p></p><p>00</p>' ]
+		], { asWidget: true } ) );
+		expect( getViewForParagraph( table ) ).to.not.equal( previousView );
 	} );
 	} );
 
 
 	it( 'should rename <span> to <p> when adding more <paragraph> elements to the same table cell', () => {
 	it( 'should rename <span> to <p> when adding more <paragraph> elements to the same table cell', () => {
 		editor.setData( viewTable( [ [ '<p>00</p>' ] ] ) );
 		editor.setData( viewTable( [ [ '<p>00</p>' ] ] ) );
 
 
 		const table = root.getChild( 0 );
 		const table = root.getChild( 0 );
+		const previousView = getViewForParagraph( table );
 
 
 		model.change( writer => {
 		model.change( writer => {
 			const nodeByPath = table.getNodeByPath( [ 0, 0, 0 ] );
 			const nodeByPath = table.getNodeByPath( [ 0, 0, 0 ] );
@@ -87,13 +110,14 @@ describe( 'Table cell refresh post-fixer', () => {
 		assertEqualMarkup( getViewData( view, { withoutSelection: true } ), viewTable( [
 		assertEqualMarkup( getViewData( view, { withoutSelection: true } ), viewTable( [
 			[ '<p>00</p><p></p><p></p>' ]
 			[ '<p>00</p><p></p><p></p>' ]
 		], { asWidget: true } ) );
 		], { asWidget: true } ) );
-		sinon.assert.calledOnce( refreshItemSpy );
+		expect( getViewForParagraph( table ) ).to.not.equal( previousView );
 	} );
 	} );
 
 
 	it( 'should rename <span> to <p> on adding other block element to the same table cell', () => {
 	it( 'should rename <span> to <p> on adding other block element to the same table cell', () => {
 		editor.setData( viewTable( [ [ '<p>00</p>' ] ] ) );
 		editor.setData( viewTable( [ [ '<p>00</p>' ] ] ) );
 
 
 		const table = root.getChild( 0 );
 		const table = root.getChild( 0 );
+		const previousView = getViewForParagraph( table );
 
 
 		model.change( writer => {
 		model.change( writer => {
 			const nodeByPath = table.getNodeByPath( [ 0, 0, 0 ] );
 			const nodeByPath = table.getNodeByPath( [ 0, 0, 0 ] );
@@ -106,13 +130,14 @@ describe( 'Table cell refresh post-fixer', () => {
 		assertEqualMarkup( getViewData( view, { withoutSelection: true } ), viewTable( [
 		assertEqualMarkup( getViewData( view, { withoutSelection: true } ), viewTable( [
 			[ '<p>00</p><div></div>' ]
 			[ '<p>00</p><div></div>' ]
 		], { asWidget: true } ) );
 		], { asWidget: true } ) );
-		sinon.assert.calledOnce( refreshItemSpy );
+		expect( getViewForParagraph( table ) ).to.not.equal( previousView );
 	} );
 	} );
 
 
 	it( 'should rename <span> to <p> on adding multiple other block elements to the same table cell', () => {
 	it( 'should rename <span> to <p> on adding multiple other block elements to the same table cell', () => {
 		editor.setData( viewTable( [ [ '<p>00</p>' ] ] ) );
 		editor.setData( viewTable( [ [ '<p>00</p>' ] ] ) );
 
 
 		const table = root.getChild( 0 );
 		const table = root.getChild( 0 );
+		const previousView = getViewForParagraph( table );
 
 
 		model.change( writer => {
 		model.change( writer => {
 			const nodeByPath = table.getNodeByPath( [ 0, 0, 0 ] );
 			const nodeByPath = table.getNodeByPath( [ 0, 0, 0 ] );
@@ -127,13 +152,34 @@ describe( 'Table cell refresh post-fixer', () => {
 		assertEqualMarkup( getViewData( view, { withoutSelection: true } ), viewTable( [
 		assertEqualMarkup( getViewData( view, { withoutSelection: true } ), viewTable( [
 			[ '<p>00</p><div></div><div></div>' ]
 			[ '<p>00</p><div></div><div></div>' ]
 		], { asWidget: true } ) );
 		], { asWidget: true } ) );
-		sinon.assert.calledOnce( refreshItemSpy );
+		expect( getViewForParagraph( table ) ).to.not.equal( previousView );
+	} );
+
+	it( 'should not rename <span> to <p> when adding and removing <paragraph>', () => {
+		editor.setData( '<table><tr><td><p>00</p></td></tr></table>' );
+
+		const table = root.getChild( 0 );
+		const previousView = getViewForParagraph( table );
+
+		model.change( writer => {
+			const nodeByPath = table.getNodeByPath( [ 0, 0, 0 ] );
+			const paragraph = writer.createElement( 'paragraph' );
+
+			writer.insert( paragraph, nodeByPath, 'after' );
+			writer.remove( table.getNodeByPath( [ 0, 0, 1 ] ) );
+		} );
+
+		assertEqualMarkup( getViewData( view, { withoutSelection: true } ), viewTable( [
+			[ '00' ]
+		], { asWidget: true } ) );
+		expect( getViewForParagraph( table ) ).to.equal( previousView );
 	} );
 	} );
 
 
 	it( 'should properly rename the same element on consecutive changes', () => {
 	it( 'should properly rename the same element on consecutive changes', () => {
 		editor.setData( viewTable( [ [ '<p>00</p>' ] ] ) );
 		editor.setData( viewTable( [ [ '<p>00</p>' ] ] ) );
 
 
 		const table = root.getChild( 0 );
 		const table = root.getChild( 0 );
+		const previousView = getViewForParagraph( table );
 
 
 		model.change( writer => {
 		model.change( writer => {
 			const nodeByPath = table.getNodeByPath( [ 0, 0, 0 ] );
 			const nodeByPath = table.getNodeByPath( [ 0, 0, 0 ] );
@@ -146,7 +192,6 @@ describe( 'Table cell refresh post-fixer', () => {
 		assertEqualMarkup( getViewData( view, { withoutSelection: true } ), viewTable( [
 		assertEqualMarkup( getViewData( view, { withoutSelection: true } ), viewTable( [
 			[ '<p>00</p><p></p>' ]
 			[ '<p>00</p><p></p>' ]
 		], { asWidget: true } ) );
 		], { asWidget: true } ) );
-		sinon.assert.calledOnce( refreshItemSpy );
 
 
 		model.change( writer => {
 		model.change( writer => {
 			writer.remove( table.getNodeByPath( [ 0, 0, 1 ] ) );
 			writer.remove( table.getNodeByPath( [ 0, 0, 1 ] ) );
@@ -155,13 +200,14 @@ describe( 'Table cell refresh post-fixer', () => {
 		assertEqualMarkup( getViewData( view, { withoutSelection: true } ), viewTable( [
 		assertEqualMarkup( getViewData( view, { withoutSelection: true } ), viewTable( [
 			[ '00' ]
 			[ '00' ]
 		], { asWidget: true } ) );
 		], { asWidget: true } ) );
-		sinon.assert.calledTwice( refreshItemSpy );
+		expect( getViewForParagraph( table ) ).to.not.equal( previousView );
 	} );
 	} );
 
 
 	it( 'should rename <span> to <p> when setting attribute on <paragraph>', () => {
 	it( 'should rename <span> to <p> when setting attribute on <paragraph>', () => {
 		editor.setData( '<table><tr><td><p>00</p></td></tr></table>' );
 		editor.setData( '<table><tr><td><p>00</p></td></tr></table>' );
 
 
 		const table = root.getChild( 0 );
 		const table = root.getChild( 0 );
+		const previousView = getViewForParagraph( table );
 
 
 		model.change( writer => {
 		model.change( writer => {
 			writer.setAttribute( 'foo', 'bar', table.getNodeByPath( [ 0, 0, 0 ] ) );
 			writer.setAttribute( 'foo', 'bar', table.getNodeByPath( [ 0, 0, 0 ] ) );
@@ -170,13 +216,14 @@ describe( 'Table cell refresh post-fixer', () => {
 		assertEqualMarkup( getViewData( view, { withoutSelection: true } ), viewTable( [
 		assertEqualMarkup( getViewData( view, { withoutSelection: true } ), viewTable( [
 			[ '<p foo="bar">00</p>' ]
 			[ '<p foo="bar">00</p>' ]
 		], { asWidget: true } ) );
 		], { asWidget: true } ) );
-		sinon.assert.calledOnce( refreshItemSpy );
+		expect( getViewForParagraph( table ) ).to.not.equal( previousView );
 	} );
 	} );
 
 
 	it( 'should rename <p> to <span> when removing one of two paragraphs inside table cell', () => {
 	it( 'should rename <p> to <span> when removing one of two paragraphs inside table cell', () => {
 		editor.setData( viewTable( [ [ '<p>00</p><p>foo</p>' ] ] ) );
 		editor.setData( viewTable( [ [ '<p>00</p><p>foo</p>' ] ] ) );
 
 
 		const table = root.getChild( 0 );
 		const table = root.getChild( 0 );
+		const previousView = getViewForParagraph( table );
 
 
 		model.change( writer => {
 		model.change( writer => {
 			writer.remove( table.getNodeByPath( [ 0, 0, 1 ] ) );
 			writer.remove( table.getNodeByPath( [ 0, 0, 1 ] ) );
@@ -185,13 +232,14 @@ describe( 'Table cell refresh post-fixer', () => {
 		assertEqualMarkup( getViewData( view, { withoutSelection: true } ), viewTable( [
 		assertEqualMarkup( getViewData( view, { withoutSelection: true } ), viewTable( [
 			[ '00' ]
 			[ '00' ]
 		], { asWidget: true } ) );
 		], { asWidget: true } ) );
-		sinon.assert.calledOnce( refreshItemSpy );
+		expect( getViewForParagraph( table ) ).to.not.equal( previousView );
 	} );
 	} );
 
 
 	it( 'should rename <p> to <span> when removing all but one paragraph inside table cell', () => {
 	it( 'should rename <p> to <span> when removing all but one paragraph inside table cell', () => {
 		editor.setData( viewTable( [ [ '<p>00</p><p>foo</p><p>bar</p>' ] ] ) );
 		editor.setData( viewTable( [ [ '<p>00</p><p>foo</p><p>bar</p>' ] ] ) );
 
 
 		const table = root.getChild( 0 );
 		const table = root.getChild( 0 );
+		const previousView = getViewForParagraph( table );
 
 
 		model.change( writer => {
 		model.change( writer => {
 			writer.remove( table.getNodeByPath( [ 0, 0, 1 ] ) );
 			writer.remove( table.getNodeByPath( [ 0, 0, 1 ] ) );
@@ -201,13 +249,14 @@ describe( 'Table cell refresh post-fixer', () => {
 		assertEqualMarkup( getViewData( view, { withoutSelection: true } ), viewTable( [
 		assertEqualMarkup( getViewData( view, { withoutSelection: true } ), viewTable( [
 			[ '00' ]
 			[ '00' ]
 		], { asWidget: true } ) );
 		], { asWidget: true } ) );
-		sinon.assert.calledOnce( refreshItemSpy );
+		expect( getViewForParagraph( table ) ).to.not.equal( previousView );
 	} );
 	} );
 
 
 	it( 'should rename <p> to <span> when removing attribute from <paragraph>', () => {
 	it( 'should rename <p> to <span> when removing attribute from <paragraph>', () => {
 		editor.setData( '<table><tr><td><p foo="bar">00</p></td></tr></table>' );
 		editor.setData( '<table><tr><td><p foo="bar">00</p></td></tr></table>' );
 
 
 		const table = root.getChild( 0 );
 		const table = root.getChild( 0 );
+		const previousView = getViewForParagraph( table );
 
 
 		model.change( writer => {
 		model.change( writer => {
 			writer.removeAttribute( 'foo', table.getNodeByPath( [ 0, 0, 0 ] ) );
 			writer.removeAttribute( 'foo', table.getNodeByPath( [ 0, 0, 0 ] ) );
@@ -216,13 +265,14 @@ describe( 'Table cell refresh post-fixer', () => {
 		assertEqualMarkup( getViewData( view, { withoutSelection: true } ), viewTable( [
 		assertEqualMarkup( getViewData( view, { withoutSelection: true } ), viewTable( [
 			[ '<span style="display:inline-block">00</span>' ]
 			[ '<span style="display:inline-block">00</span>' ]
 		], { asWidget: true } ) );
 		], { asWidget: true } ) );
-		sinon.assert.calledOnce( refreshItemSpy );
+		expect( getViewForParagraph( table ) ).to.not.equal( previousView );
 	} );
 	} );
 
 
 	it( 'should keep <p> in the view when <paragraph> attribute value is changed', () => {
 	it( 'should keep <p> in the view when <paragraph> attribute value is changed', () => {
 		editor.setData( viewTable( [ [ '<p foo="bar">00</p>' ] ] ) );
 		editor.setData( viewTable( [ [ '<p foo="bar">00</p>' ] ] ) );
 
 
 		const table = root.getChild( 0 );
 		const table = root.getChild( 0 );
+		const previousView = getViewForParagraph( table );
 
 
 		model.change( writer => {
 		model.change( writer => {
 			writer.setAttribute( 'foo', 'baz', table.getNodeByPath( [ 0, 0, 0 ] ) );
 			writer.setAttribute( 'foo', 'baz', table.getNodeByPath( [ 0, 0, 0 ] ) );
@@ -231,14 +281,14 @@ describe( 'Table cell refresh post-fixer', () => {
 		assertEqualMarkup( getViewData( view, { withoutSelection: true } ), viewTable( [
 		assertEqualMarkup( getViewData( view, { withoutSelection: true } ), viewTable( [
 			[ '<p foo="baz">00</p>' ]
 			[ '<p foo="baz">00</p>' ]
 		], { asWidget: true } ) );
 		], { asWidget: true } ) );
-		// False positive: should not be called.
-		sinon.assert.calledOnce( refreshItemSpy );
+		expect( getViewForParagraph( table ) ).to.equal( previousView );
 	} );
 	} );
 
 
 	it( 'should keep <p> in the view when adding another attribute to a <paragraph> with other attributes', () => {
 	it( 'should keep <p> in the view when adding another attribute to a <paragraph> with other attributes', () => {
 		editor.setData( viewTable( [ [ '<p foo="bar">00</p>' ] ] ) );
 		editor.setData( viewTable( [ [ '<p foo="bar">00</p>' ] ] ) );
 
 
 		const table = root.getChild( 0 );
 		const table = root.getChild( 0 );
+		const previousView = getViewForParagraph( table );
 
 
 		model.change( writer => {
 		model.change( writer => {
 			writer.setAttribute( 'bar', 'bar', table.getNodeByPath( [ 0, 0, 0 ] ) );
 			writer.setAttribute( 'bar', 'bar', table.getNodeByPath( [ 0, 0, 0 ] ) );
@@ -247,15 +297,14 @@ describe( 'Table cell refresh post-fixer', () => {
 		assertEqualMarkup( getViewData( view, { withoutSelection: true } ), viewTable( [
 		assertEqualMarkup( getViewData( view, { withoutSelection: true } ), viewTable( [
 			[ '<p bar="bar" foo="bar">00</p>' ]
 			[ '<p bar="bar" foo="bar">00</p>' ]
 		], { asWidget: true } ) );
 		], { asWidget: true } ) );
-
-		// False positive
-		sinon.assert.notCalled( refreshItemSpy );
+		expect( getViewForParagraph( table ) ).to.equal( previousView );
 	} );
 	} );
 
 
 	it( 'should keep <p> in the view when adding another attribute to a <paragraph> and removing attribute that is already set', () => {
 	it( 'should keep <p> in the view when adding another attribute to a <paragraph> and removing attribute that is already set', () => {
 		editor.setData( viewTable( [ [ '<p foo="bar">00</p>' ] ] ) );
 		editor.setData( viewTable( [ [ '<p foo="bar">00</p>' ] ] ) );
 
 
 		const table = root.getChild( 0 );
 		const table = root.getChild( 0 );
+		const previousView = getViewForParagraph( table );
 
 
 		model.change( writer => {
 		model.change( writer => {
 			writer.setAttribute( 'bar', 'bar', table.getNodeByPath( [ 0, 0, 0 ] ) );
 			writer.setAttribute( 'bar', 'bar', table.getNodeByPath( [ 0, 0, 0 ] ) );
@@ -265,14 +314,14 @@ describe( 'Table cell refresh post-fixer', () => {
 		assertEqualMarkup( getViewData( view, { withoutSelection: true } ), viewTable( [
 		assertEqualMarkup( getViewData( view, { withoutSelection: true } ), viewTable( [
 			[ '<p bar="bar">00</p>' ]
 			[ '<p bar="bar">00</p>' ]
 		], { asWidget: true } ) );
 		], { asWidget: true } ) );
-		// False positive: should not be called.
-		sinon.assert.calledOnce( refreshItemSpy );
+		expect( getViewForParagraph( table ) ).to.equal( previousView );
 	} );
 	} );
 
 
 	it( 'should keep <p> in the view when <paragraph> attribute value is changed (table cell with multiple blocks)', () => {
 	it( 'should keep <p> in the view when <paragraph> attribute value is changed (table cell with multiple blocks)', () => {
 		editor.setData( viewTable( [ [ '<p foo="bar">00</p><p>00</p>' ] ] ) );
 		editor.setData( viewTable( [ [ '<p foo="bar">00</p><p>00</p>' ] ] ) );
 
 
 		const table = root.getChild( 0 );
 		const table = root.getChild( 0 );
+		const previousView = getViewForParagraph( table );
 
 
 		model.change( writer => {
 		model.change( writer => {
 			writer.setAttribute( 'foo', 'baz', table.getNodeByPath( [ 0, 0, 0 ] ) );
 			writer.setAttribute( 'foo', 'baz', table.getNodeByPath( [ 0, 0, 0 ] ) );
@@ -281,13 +330,14 @@ describe( 'Table cell refresh post-fixer', () => {
 		assertEqualMarkup( getViewData( view, { withoutSelection: true } ), viewTable( [
 		assertEqualMarkup( getViewData( view, { withoutSelection: true } ), viewTable( [
 			[ '<p foo="baz">00</p><p>00</p>' ]
 			[ '<p foo="baz">00</p><p>00</p>' ]
 		], { asWidget: true } ) );
 		], { asWidget: true } ) );
-		sinon.assert.notCalled( refreshItemSpy );
+		expect( getViewForParagraph( table ) ).to.equal( previousView );
 	} );
 	} );
 
 
 	it( 'should do nothing on rename <paragraph> to other block', () => {
 	it( 'should do nothing on rename <paragraph> to other block', () => {
 		editor.setData( viewTable( [ [ '<p>00</p>' ] ] ) );
 		editor.setData( viewTable( [ [ '<p>00</p>' ] ] ) );
 
 
 		const table = root.getChild( 0 );
 		const table = root.getChild( 0 );
+		const previousView = getViewForParagraph( table );
 
 
 		model.change( writer => {
 		model.change( writer => {
 			writer.rename( table.getNodeByPath( [ 0, 0, 0 ] ), 'block' );
 			writer.rename( table.getNodeByPath( [ 0, 0, 0 ] ), 'block' );
@@ -296,13 +346,14 @@ describe( 'Table cell refresh post-fixer', () => {
 		assertEqualMarkup( getViewData( view, { withoutSelection: true } ), viewTable( [
 		assertEqualMarkup( getViewData( view, { withoutSelection: true } ), viewTable( [
 			[ '<div>00</div>' ]
 			[ '<div>00</div>' ]
 		], { asWidget: true } ) );
 		], { asWidget: true } ) );
-		sinon.assert.notCalled( refreshItemSpy );
+		expect( getViewForParagraph( table ) ).to.not.equal( previousView );
 	} );
 	} );
 
 
 	it( 'should do nothing on adding <paragraph> to existing paragraphs', () => {
 	it( 'should do nothing on adding <paragraph> to existing paragraphs', () => {
 		editor.setData( viewTable( [ [ '<p>a</p><p>b</p>' ] ] ) );
 		editor.setData( viewTable( [ [ '<p>a</p><p>b</p>' ] ] ) );
 
 
 		const table = root.getChild( 0 );
 		const table = root.getChild( 0 );
+		const previousView = getViewForParagraph( table );
 
 
 		model.change( writer => {
 		model.change( writer => {
 			writer.insertElement( 'paragraph', table.getNodeByPath( [ 0, 0, 1 ] ), 'after' );
 			writer.insertElement( 'paragraph', table.getNodeByPath( [ 0, 0, 1 ] ), 'after' );
@@ -311,13 +362,14 @@ describe( 'Table cell refresh post-fixer', () => {
 		assertEqualMarkup( getViewData( view, { withoutSelection: true } ), viewTable( [
 		assertEqualMarkup( getViewData( view, { withoutSelection: true } ), viewTable( [
 			[ '<p>a</p><p>b</p><p></p>' ]
 			[ '<p>a</p><p>b</p><p></p>' ]
 		], { asWidget: true } ) );
 		], { asWidget: true } ) );
-		sinon.assert.notCalled( refreshItemSpy );
+		expect( getViewForParagraph( table ) ).to.equal( previousView );
 	} );
 	} );
 
 
 	it( 'should do nothing when setting attribute on block item other then <paragraph>', () => {
 	it( 'should do nothing when setting attribute on block item other then <paragraph>', () => {
 		editor.setData( viewTable( [ [ '<div>foo</div>' ] ] ) );
 		editor.setData( viewTable( [ [ '<div>foo</div>' ] ] ) );
 
 
 		const table = root.getChild( 0 );
 		const table = root.getChild( 0 );
+		const previousView = getViewForParagraph( table );
 
 
 		model.change( writer => {
 		model.change( writer => {
 			writer.setAttribute( 'foo', 'bar', table.getNodeByPath( [ 0, 0, 0 ] ) );
 			writer.setAttribute( 'foo', 'bar', table.getNodeByPath( [ 0, 0, 0 ] ) );
@@ -326,13 +378,14 @@ describe( 'Table cell refresh post-fixer', () => {
 		assertEqualMarkup( getViewData( view, { withoutSelection: true } ), viewTable( [
 		assertEqualMarkup( getViewData( view, { withoutSelection: true } ), viewTable( [
 			[ '<div foo="bar">foo</div>' ]
 			[ '<div foo="bar">foo</div>' ]
 		], { asWidget: true } ) );
 		], { asWidget: true } ) );
-		sinon.assert.notCalled( refreshItemSpy );
+		expect( getViewForParagraph( table ) ).to.equal( previousView );
 	} );
 	} );
 
 
 	it( 'should rename <p> in to <span> when removing <paragraph> (table cell with 2 paragraphs)', () => {
 	it( 'should rename <p> in to <span> when removing <paragraph> (table cell with 2 paragraphs)', () => {
 		editor.setData( viewTable( [ [ '<p>00</p><p>00</p>' ] ] ) );
 		editor.setData( viewTable( [ [ '<p>00</p><p>00</p>' ] ] ) );
 
 
 		const table = root.getChild( 0 );
 		const table = root.getChild( 0 );
+		const previousView = getViewForParagraph( table );
 
 
 		model.change( writer => {
 		model.change( writer => {
 			writer.remove( writer.createRangeOn( table.getNodeByPath( [ 0, 0, 1 ] ) ) );
 			writer.remove( writer.createRangeOn( table.getNodeByPath( [ 0, 0, 1 ] ) ) );
@@ -341,7 +394,7 @@ describe( 'Table cell refresh post-fixer', () => {
 		assertEqualMarkup( getViewData( view, { withoutSelection: true } ), viewTable( [
 		assertEqualMarkup( getViewData( view, { withoutSelection: true } ), viewTable( [
 			[ '<span style="display:inline-block">00</span>' ]
 			[ '<span style="display:inline-block">00</span>' ]
 		], { asWidget: true } ) );
 		], { asWidget: true } ) );
-		sinon.assert.calledOnce( refreshItemSpy );
+		expect( getViewForParagraph( table ) ).to.not.equal( previousView );
 	} );
 	} );
 
 
 	it( 'should update view selection after deleting content', () => {
 	it( 'should update view selection after deleting content', () => {

+ 1 - 1
packages/ckeditor5-ui/src/editableui/inline/inlineeditableuiview.js

@@ -47,7 +47,7 @@ export default class InlineEditableUIView extends EditableUIView {
 		editingView.change( writer => {
 		editingView.change( writer => {
 			const viewRoot = editingView.document.getRoot( this.name );
 			const viewRoot = editingView.document.getRoot( this.name );
 
 
-			writer.setAttribute( 'aria-label', t( 'Rich Text Editor, %0', [ this.name ] ), viewRoot );
+			writer.setAttribute( 'aria-label', t( 'Rich Text Editor, %0', this.name ), viewRoot );
 		} );
 		} );
 	}
 	}
 }
 }

+ 2 - 4
packages/ckeditor5-ui/src/toolbar/toolbarview.js

@@ -7,8 +7,6 @@
  * @module ui/toolbar/toolbarview
  * @module ui/toolbar/toolbarview
  */
  */
 
 
-/* globals console */
-
 import View from '../view';
 import View from '../view';
 import FocusTracker from '@ckeditor/ckeditor5-utils/src/focustracker';
 import FocusTracker from '@ckeditor/ckeditor5-utils/src/focustracker';
 import FocusCycler from '../focuscycler';
 import FocusCycler from '../focuscycler';
@@ -19,7 +17,7 @@ import preventDefault from '../bindings/preventdefault.js';
 import Rect from '@ckeditor/ckeditor5-utils/src/dom/rect';
 import Rect from '@ckeditor/ckeditor5-utils/src/dom/rect';
 import global from '@ckeditor/ckeditor5-utils/src/dom/global';
 import global from '@ckeditor/ckeditor5-utils/src/dom/global';
 import { createDropdown, addToolbarToDropdown } from '../dropdown/utils';
 import { createDropdown, addToolbarToDropdown } from '../dropdown/utils';
-import { attachLinkToDocumentation } from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
+import { logWarning } from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
 import verticalDotsIcon from '@ckeditor/ckeditor5-core/theme/icons/three-vertical-dots.svg';
 import verticalDotsIcon from '@ckeditor/ckeditor5-core/theme/icons/three-vertical-dots.svg';
 
 
 import '../../theme/components/toolbar/toolbar.css';
 import '../../theme/components/toolbar/toolbar.css';
@@ -299,7 +297,7 @@ export default class ToolbarView extends View {
 				 * @error toolbarview-item-unavailable
 				 * @error toolbarview-item-unavailable
 				 * @param {String} name The name of the component.
 				 * @param {String} name The name of the component.
 				 */
 				 */
-				console.warn( attachLinkToDocumentation( 'toolbarview-item-unavailable' ), { name } );
+				logWarning( 'toolbarview-item-unavailable', { name } );
 			}
 			}
 		} ).filter( item => item !== undefined ) );
 		} ).filter( item => item !== undefined ) );
 	}
 	}

+ 2 - 1
packages/ckeditor5-ui/tests/toolbar/toolbarview.js

@@ -421,7 +421,8 @@ describe( 'ToolbarView', () => {
 			sinon.assert.calledOnce( consoleWarnStub );
 			sinon.assert.calledOnce( consoleWarnStub );
 			sinon.assert.calledWithExactly( consoleWarnStub,
 			sinon.assert.calledWithExactly( consoleWarnStub,
 				sinon.match( /^toolbarview-item-unavailable/ ),
 				sinon.match( /^toolbarview-item-unavailable/ ),
-				{ name: 'baz' }
+				sinon.match( { name: 'baz' } ),
+				sinon.match.string // Link to the documentation.
 			);
 			);
 		} );
 		} );
 	} );
 	} );

+ 3 - 3
packages/ckeditor5-upload/src/adapters/simpleuploadadapter.js

@@ -7,11 +7,11 @@
  * @module upload/adapters/simpleuploadadapter
  * @module upload/adapters/simpleuploadadapter
  */
  */
 
 
-/* globals XMLHttpRequest, FormData, console */
+/* globals XMLHttpRequest, FormData */
 
 
 import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
 import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
 import FileRepository from '../filerepository';
 import FileRepository from '../filerepository';
-import { attachLinkToDocumentation } from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
+import { logWarning } from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
 
 
 /**
 /**
  * The Simple upload adapter allows uploading images to an application running on your server using
  * The Simple upload adapter allows uploading images to an application running on your server using
@@ -71,7 +71,7 @@ export default class SimpleUploadAdapter extends Plugin {
 			 *
 			 *
 			 * @error simple-upload-adapter-missing-uploadurl
 			 * @error simple-upload-adapter-missing-uploadurl
 			 */
 			 */
-			console.warn( attachLinkToDocumentation( 'simple-upload-adapter-missing-uploadurl' ) );
+			logWarning( 'simple-upload-adapter-missing-uploadurl' );
 
 
 			return;
 			return;
 		}
 		}

+ 2 - 4
packages/ckeditor5-upload/src/filerepository.js

@@ -7,12 +7,10 @@
  * @module upload/filerepository
  * @module upload/filerepository
  */
  */
 
 
-/* globals console */
-
 import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
 import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
 
 
 import PendingActions from '@ckeditor/ckeditor5-core/src/pendingactions';
 import PendingActions from '@ckeditor/ckeditor5-core/src/pendingactions';
-import CKEditorError, { attachLinkToDocumentation } from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
+import CKEditorError, { logWarning } from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
 import ObservableMixin from '@ckeditor/ckeditor5-utils/src/observablemixin';
 import ObservableMixin from '@ckeditor/ckeditor5-utils/src/observablemixin';
 import Collection from '@ckeditor/ckeditor5-utils/src/collection';
 import Collection from '@ckeditor/ckeditor5-utils/src/collection';
 import mix from '@ckeditor/ckeditor5-utils/src/mix';
 import mix from '@ckeditor/ckeditor5-utils/src/mix';
@@ -172,7 +170,7 @@ export default class FileRepository extends Plugin {
 			 *
 			 *
 			 * @error filerepository-no-upload-adapter
 			 * @error filerepository-no-upload-adapter
 			 */
 			 */
-			console.warn( attachLinkToDocumentation( 'filerepository-no-upload-adapter' ) );
+			logWarning( 'filerepository-no-upload-adapter' );
 
 
 			return null;
 			return null;
 		}
 		}

+ 2 - 1
packages/ckeditor5-upload/tests/filerepository.js

@@ -178,7 +178,8 @@ describe( 'FileRepository', () => {
 			sinon.assert.calledOnce( consoleWarnStub );
 			sinon.assert.calledOnce( consoleWarnStub );
 			sinon.assert.calledWithExactly(
 			sinon.assert.calledWithExactly(
 				consoleWarnStub,
 				consoleWarnStub,
-				sinon.match( /^filerepository-no-upload-adapter/ )
+				sinon.match( /^filerepository-no-upload-adapter/ ),
+				sinon.match.string // Link to the documentation
 			);
 			);
 		} );
 		} );
 
 

+ 62 - 23
packages/ckeditor5-utils/src/ckeditorerror.js

@@ -7,6 +7,8 @@
  * @module utils/ckeditorerror
  * @module utils/ckeditorerror
  */
  */
 
 
+/* globals console */
+
 /**
 /**
  * URL to the documentation with error codes.
  * URL to the documentation with error codes.
  */
  */
@@ -22,9 +24,10 @@ export const DOCUMENTATION_URL =
  * by the {@link module:watchdog/watchdog~Watchdog watchdog} (if it is integrated),
  * by the {@link module:watchdog/watchdog~Watchdog watchdog} (if it is integrated),
  * * If the editor is incorrectly integrated or the editor API is used in the wrong way. This way you will give
  * * If the editor is incorrectly integrated or the editor API is used in the wrong way. This way you will give
  * feedback to the developer as soon as possible. Keep in mind that for common integration issues which should not
  * feedback to the developer as soon as possible. Keep in mind that for common integration issues which should not
- * stop editor initialization (like missing upload adapter, wrong name of a toolbar component) we use `console.warn()` with
- * {@link module:utils/ckeditorerror~attachLinkToDocumentation `attachLinkToDocumentation()`}
- * to improve developers experience and let them see the working editor as soon as possible.
+ * stop editor initialization (like missing upload adapter, wrong name of a toolbar component) we use
+ * {@link module:utils/ckeditorerror~logWarning `logWarning()`} and
+ * {@link module:utils/ckeditorerror~logError `logError()`}
+ * to improve developers experience and let them see the a working editor as soon as possible.
  *
  *
  *		/**
  *		/**
  *		 * Error thrown when a plugin cannot be loaded due to JavaScript errors, lack of plugins with a given name, etc.
  *		 * Error thrown when a plugin cannot be loaded due to JavaScript errors, lack of plugins with a given name, etc.
@@ -56,11 +59,7 @@ export default class CKEditorError extends Error {
 	 * data object will also be later available under the {@link #data} property.
 	 * data object will also be later available under the {@link #data} property.
 	 */
 	 */
 	constructor( errorName, context, data ) {
 	constructor( errorName, context, data ) {
-		let message = attachLinkToDocumentation( errorName );
-
-		if ( data ) {
-			message += ' ' + JSON.stringify( data );
-		}
+		const message = `${ errorName }${ ( data ? ` ${ JSON.stringify( data ) }` : '' ) }${ getLinkToDocumentationMessage( errorName ) }`;
 
 
 		super( message );
 		super( message );
 
 
@@ -92,11 +91,12 @@ export default class CKEditorError extends Error {
 	}
 	}
 
 
 	/**
 	/**
-	 * A utility that ensures the the thrown error is a {@link module:utils/ckeditorerror~CKEditorError} one.
+	 * A utility that ensures that the thrown error is a {@link module:utils/ckeditorerror~CKEditorError} one.
 	 * It is useful when combined with the {@link module:watchdog/watchdog~Watchdog} feature, which can restart the editor in case
 	 * It is useful when combined with the {@link module:watchdog/watchdog~Watchdog} feature, which can restart the editor in case
 	 * of a {@link module:utils/ckeditorerror~CKEditorError} error.
 	 * of a {@link module:utils/ckeditorerror~CKEditorError} error.
 	 *
 	 *
-	 * @param {Error} err An error.
+	 * @static
+	 * @param {Error} err The error to rethrow.
 	 * @param {Object} context An object connected through properties with the editor instance. This context will be used
 	 * @param {Object} context An object connected through properties with the editor instance. This context will be used
 	 * by the watchdog to verify which editor should be restarted.
 	 * by the watchdog to verify which editor should be restarted.
 	 */
 	 */
@@ -125,21 +125,60 @@ export default class CKEditorError extends Error {
 }
 }
 
 
 /**
 /**
- * Attaches the link to the documentation at the end of the error message. Use whenever you log a warning or error on the
- * console. It is also used by {@link module:utils/ckeditorerror~CKEditorError}.
+ * Logs a warning to the console with a properly formatted message and adds a link to the documentation.
+ * Use whenever you want to log a warning to the console.
+ *
+ *		/**
+ *		 * There was a problem processing the configuration of the toolbar. The item with the given
+ *		 * name does not exist, so it was omitted when rendering the toolbar.
+ *		 *
+ *		 * @error toolbarview-item-unavailable
+ *		 * @param {String} name The name of the component.
+ *		 * /
+ *		logWarning( 'toolbarview-item-unavailable', { name } );
+ *
+ * See also {@link module:utils/ckeditorerror~CKEditorError} for an explanation when to throw an error and when to log
+ * a warning or an error to the console.
+ *
+ * @param {String} errorName Error name to be logged.
+ * @param {Object} [data] Additional data to be logged.
+ * @returns {String}
+ */
+export function logWarning( errorName, data ) {
+	console.warn( ...formatConsoleArguments( errorName, data ) );
+}
+
+/**
+ * Logs an error to the console with properly formatted message and adds a link to the documentation.
+ * Use whenever you want to log a error to the console.
+ *
+ *		/**
+ *		 * There was a problem processing the configuration of the toolbar. The item with the given
+ *		 * name does not exist, so it was omitted when rendering the toolbar.
+ *		 *
+ *		 * @error toolbarview-item-unavailable
+ *		 * @param {String} name The name of the component.
+ *		 * /
+ *		 logError( 'toolbarview-item-unavailable', { name } );
  *
  *
- *		 /**
- *		  * There was a problem processing the configuration of the toolbar. The item with the given
- *		  * name does not exist so it was omitted when rendering the toolbar.
- *		  *
- *		  * @error toolbarview-item-unavailable
- *		  * @param {String} name The name of the component.
- *		  * /
- *		 console.warn( attachLinkToDocumentation( 'toolbarview-item-unavailable' ), { name } );
+ * **Note**: In most cases logging a warning using {@link module:utils/ckeditorerror~logWarning} is enough.
  *
  *
- * @param {String} errorName Error name to be linked.
+ * See also {@link module:utils/ckeditorerror~CKEditorError} for an explanation when to use each method.
+ *
+ * @param {String} errorName Error name to be logged.
+ * @param {Object} [data] Additional data to be logged.
  * @returns {String}
  * @returns {String}
  */
  */
-export function attachLinkToDocumentation( errorName ) {
-	return errorName + ` Read more: ${ DOCUMENTATION_URL }#error-${ errorName }\n`;
+export function logError( errorName, data ) {
+	console.error( ...formatConsoleArguments( errorName, data ) );
+}
+
+function getLinkToDocumentationMessage( errorName ) {
+	return `\nRead more: ${ DOCUMENTATION_URL }#error-${ errorName }`;
+}
+
+function formatConsoleArguments( errorName, data ) {
+	const documentationMessage = getLinkToDocumentationMessage( errorName );
+
+	return data ? [ errorName, data, documentationMessage ] : [ errorName, documentationMessage ];
 }
 }

+ 71 - 9
packages/ckeditor5-utils/tests/ckeditorerror.js

@@ -5,8 +5,11 @@
 
 
 /* eslint-disable ckeditor5-rules/ckeditor-error-message */
 /* eslint-disable ckeditor5-rules/ckeditor-error-message */
 
 
-import { default as CKEditorError, DOCUMENTATION_URL } from '../src/ckeditorerror';
+/* global console */
+
+import { default as CKEditorError, DOCUMENTATION_URL, logError, logWarning } from '../src/ckeditorerror';
 import { expectToThrowCKEditorError } from './_utils/utils';
 import { expectToThrowCKEditorError } from './_utils/utils';
+import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils';
 
 
 describe( 'CKEditorError', () => {
 describe( 'CKEditorError', () => {
 	it( 'inherits from Error', () => {
 	it( 'inherits from Error', () => {
@@ -35,7 +38,7 @@ describe( 'CKEditorError', () => {
 
 
 		expect( error ).to.have.property(
 		expect( error ).to.have.property(
 			'message',
 			'message',
-			`foo Read more: ${ DOCUMENTATION_URL }#error-foo\n {"bar":1}`
+			`foo {"bar":1}\nRead more: ${ DOCUMENTATION_URL }#error-foo`
 		);
 		);
 		expect( error ).to.have.property( 'data', data );
 		expect( error ).to.have.property( 'data', data );
 	} );
 	} );
@@ -64,7 +67,7 @@ describe( 'CKEditorError', () => {
 
 
 		expect( error ).to.have.property(
 		expect( error ).to.have.property(
 			'message',
 			'message',
-			`foo Read more: ${ DOCUMENTATION_URL }#error-foo\n {"bar":"a","bom":{"x":1},"bim":10}`
+			`foo {"bar":"a","bom":{"x":1},"bim":10}\nRead more: ${ DOCUMENTATION_URL }#error-foo`
 		);
 		);
 		expect( error ).to.have.property( 'data', data );
 		expect( error ).to.have.property( 'data', data );
 	} );
 	} );
@@ -72,18 +75,15 @@ describe( 'CKEditorError', () => {
 	it( 'contains a link which leads to the documentation', () => {
 	it( 'contains a link which leads to the documentation', () => {
 		const error = new CKEditorError( 'model-schema-no-item', null );
 		const error = new CKEditorError( 'model-schema-no-item', null );
 
 
-		const errorMessage = 'model-schema-no-item' +
-			` Read more: ${ DOCUMENTATION_URL }#error-model-schema-no-item\n`;
+		const errorMessage = `model-schema-no-item\nRead more: ${ DOCUMENTATION_URL }#error-model-schema-no-item`;
 
 
 		expect( error ).to.have.property( 'message', errorMessage );
 		expect( error ).to.have.property( 'message', errorMessage );
 	} );
 	} );
 
 
-	it( 'link to documentation is added before the additional data message', () => {
+	it( 'link to documentation is added after the additional data message', () => {
 		const error = new CKEditorError( 'model-schema-no-item', null, { foo: 1, bar: 2 } );
 		const error = new CKEditorError( 'model-schema-no-item', null, { foo: 1, bar: 2 } );
 
 
-		const errorMessage = 'model-schema-no-item ' +
-			`Read more: ${ DOCUMENTATION_URL }#error-model-schema-no-item\n ` +
-			'{"foo":1,"bar":2}';
+		const errorMessage = `model-schema-no-item {"foo":1,"bar":2}\nRead more: ${ DOCUMENTATION_URL }#error-model-schema-no-item`;
 
 
 		expect( error ).to.have.property( 'message', errorMessage );
 		expect( error ).to.have.property( 'message', errorMessage );
 	} );
 	} );
@@ -117,4 +117,66 @@ describe( 'CKEditorError', () => {
 			}, /foo/, context );
 			}, /foo/, context );
 		} );
 		} );
 	} );
 	} );
+
+	describe( 'logWarning()', () => {
+		beforeEach( () => {
+			testUtils.sinon.stub( console, 'warn' );
+		} );
+
+		afterEach( () => {
+			console.warn.restore();
+		} );
+
+		it( 'should log warning with data and link to the documentation', () => {
+			logWarning( 'foo', { name: 'foo' } );
+
+			sinon.assert.calledOnce( console.warn );
+			sinon.assert.calledWithExactly( console.warn,
+				sinon.match( 'foo' ),
+				{ name: 'foo' },
+				`\nRead more: ${ DOCUMENTATION_URL }#error-foo`
+			);
+		} );
+
+		it( 'should log warning without data and with a link to the documentation', () => {
+			logWarning( 'foo' );
+
+			sinon.assert.calledOnce( console.warn );
+			sinon.assert.calledWithExactly( console.warn,
+				sinon.match( 'foo' ),
+				`\nRead more: ${ DOCUMENTATION_URL }#error-foo`
+			);
+		} );
+	} );
+
+	describe( 'logError()', () => {
+		beforeEach( () => {
+			testUtils.sinon.stub( console, 'error' );
+		} );
+
+		afterEach( () => {
+			console.error.restore();
+		} );
+
+		it( 'should log error with data and link to the documentation', () => {
+			logError( 'foo', { name: 'foo' } );
+
+			sinon.assert.calledOnce( console.error );
+			sinon.assert.calledWithExactly( console.error,
+				sinon.match( 'foo' ),
+				{ name: 'foo' },
+				`\nRead more: ${ DOCUMENTATION_URL }#error-foo`
+			);
+		} );
+
+		it( 'should log error without data and with a link to the documentation', () => {
+			logError( 'foo' );
+
+			sinon.assert.calledOnce( console.error );
+			sinon.assert.calledWithExactly( console.error,
+				sinon.match( 'foo' ),
+				`\nRead more: ${ DOCUMENTATION_URL }#error-foo`
+			);
+		} );
+	} );
 } );
 } );

+ 5 - 5
packages/ckeditor5-utils/tests/locale.js

@@ -157,9 +157,9 @@ describe( 'Locale', () => {
 		it( 'should translate a message supporting plural forms', () => {
 		it( 'should translate a message supporting plural forms', () => {
 			const t = locale.t;
 			const t = locale.t;
 
 
-			expect( t( { string: 'bar', plural: '%0 bars' }, [ 1 ] ), 1 ).to.equal( 'bar_pl_0' );
-			expect( t( { string: 'bar', plural: '%0 bars' }, [ 2 ] ), 2 ).to.equal( '2 bar_pl_1' );
-			expect( t( { string: 'bar', plural: '%0 bars' }, [ 5 ] ), 3 ).to.equal( '5 bar_pl_2' );
+			expect( t( { string: 'bar', plural: '%0 bars' }, 1 ), 1 ).to.equal( 'bar_pl_0' );
+			expect( t( { string: 'bar', plural: '%0 bars' }, 2 ), 2 ).to.equal( '2 bar_pl_1' );
+			expect( t( { string: 'bar', plural: '%0 bars' }, 5 ), 3 ).to.equal( '5 bar_pl_2' );
 		} );
 		} );
 
 
 		it( 'should translate a message supporting plural forms with a message id if it was passed', () => {
 		it( 'should translate a message supporting plural forms with a message id if it was passed', () => {
@@ -181,13 +181,13 @@ describe( 'Locale', () => {
 		it( 'should interpolate a message with provided values', () => {
 		it( 'should interpolate a message with provided values', () => {
 			const t = locale.t;
 			const t = locale.t;
 
 
-			expect( t( '%0 - %0', [ 'foo' ] ) ).to.equal( 'foo - foo' );
+			expect( t( '%0 - %0', 'foo' ) ).to.equal( 'foo - foo' );
 			expect( t( '%1 - %0 - %2', [ 'a', 'b', 'c' ] ) ).to.equal( 'b - a - c' );
 			expect( t( '%1 - %0 - %2', [ 'a', 'b', 'c' ] ) ).to.equal( 'b - a - c' );
 
 
 			// Those test make sure that if %0 is really to be used, then it's going to work.
 			// Those test make sure that if %0 is really to be used, then it's going to work.
 			// It'd be a super rare case if one would need to use %0 and at the same time interpolate something.
 			// It'd be a super rare case if one would need to use %0 and at the same time interpolate something.
 			expect( t( '%1 - %0 - %2' ) ).to.equal( '%1 - %0 - %2' );
 			expect( t( '%1 - %0 - %2' ) ).to.equal( '%1 - %0 - %2' );
-			expect( t( '%1 - %0 - %2', [ 'a' ] ) ).to.equal( '%1 - a - %2' );
+			expect( t( '%1 - %0 - %2', 'a' ) ).to.equal( '%1 - a - %2' );
 		} );
 		} );
 
 
 		it( 'should interpolate a message with a provided value (shorthand version)', () => {
 		it( 'should interpolate a message with a provided value (shorthand version)', () => {

+ 2 - 6
packages/ckeditor5-widget/src/widgettoolbarrepository.js

@@ -7,8 +7,6 @@
  * @module widget/widgettoolbarrepository
  * @module widget/widgettoolbarrepository
  */
  */
 
 
-/* global console */
-
 import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
 import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
 import ContextualBalloon from '@ckeditor/ckeditor5-ui/src/panel/balloon/contextualballoon';
 import ContextualBalloon from '@ckeditor/ckeditor5-ui/src/panel/balloon/contextualballoon';
 import ToolbarView from '@ckeditor/ckeditor5-ui/src/toolbar/toolbarview';
 import ToolbarView from '@ckeditor/ckeditor5-ui/src/toolbar/toolbarview';
@@ -17,7 +15,7 @@ import {
 	isWidget,
 	isWidget,
 	centeredBalloonPositionForLongWidgets
 	centeredBalloonPositionForLongWidgets
 } from './utils';
 } from './utils';
-import CKEditorError, { attachLinkToDocumentation } from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
+import CKEditorError, { logWarning } from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
 
 
 /**
 /**
  * Widget toolbar repository plugin. A central point for registering widget toolbars. This plugin handles the whole
  * Widget toolbar repository plugin. A central point for registering widget toolbars. This plugin handles the whole
@@ -134,9 +132,7 @@ export default class WidgetToolbarRepository extends Plugin {
 			 *
 			 *
 			 * @error widget-toolbar-no-items
 			 * @error widget-toolbar-no-items
 			 */
 			 */
-			console.warn(
-				attachLinkToDocumentation( 'widget-toolbar-no-items' ), { toolbarId }
-			);
+			logWarning( 'widget-toolbar-no-items', { toolbarId } );
 
 
 			return;
 			return;
 		}
 		}

+ 2 - 2
packages/ckeditor5-word-count/src/wordcount.js

@@ -203,7 +203,7 @@ export default class WordCount extends Plugin {
 
 
 			if ( displayWords || displayWords === undefined ) {
 			if ( displayWords || displayWords === undefined ) {
 				this.bind( '_wordsLabel' ).to( this, 'words', words => {
 				this.bind( '_wordsLabel' ).to( this, 'words', words => {
-					return t( 'Words: %0', [ words ] );
+					return t( 'Words: %0', words );
 				} );
 				} );
 
 
 				children.push( {
 				children.push( {
@@ -221,7 +221,7 @@ export default class WordCount extends Plugin {
 
 
 			if ( displayCharacters || displayCharacters === undefined ) {
 			if ( displayCharacters || displayCharacters === undefined ) {
 				this.bind( '_charactersLabel' ).to( this, 'characters', words => {
 				this.bind( '_charactersLabel' ).to( this, 'characters', words => {
-					return t( 'Characters: %0', [ words ] );
+					return t( 'Characters: %0', words );
 				} );
 				} );
 
 
 				children.push( {
 				children.push( {

+ 23 - 1
scripts/docs/snippetadapter.js

@@ -14,6 +14,7 @@ const CKEditorWebpackPlugin = require( '@ckeditor/ckeditor5-dev-webpack-plugin'
 const MiniCssExtractPlugin = require( 'mini-css-extract-plugin' );
 const MiniCssExtractPlugin = require( 'mini-css-extract-plugin' );
 const TerserPlugin = require( 'terser-webpack-plugin' );
 const TerserPlugin = require( 'terser-webpack-plugin' );
 const ProgressBarPlugin = require( 'progress-bar-webpack-plugin' );
 const ProgressBarPlugin = require( 'progress-bar-webpack-plugin' );
+const glob = require( 'glob' );
 
 
 const DEFAULT_LANGUAGE = 'en';
 const DEFAULT_LANGUAGE = 'en';
 const MULTI_LANGUAGE = 'multi-language';
 const MULTI_LANGUAGE = 'multi-language';
@@ -393,7 +394,10 @@ function getWebpackConfig( snippets, config ) {
 		// Configure the paths so building CKEditor 5 snippets work even if the script
 		// Configure the paths so building CKEditor 5 snippets work even if the script
 		// is triggered from a directory outside ckeditor5 (e.g. multi-project case).
 		// is triggered from a directory outside ckeditor5 (e.g. multi-project case).
 		resolve: {
 		resolve: {
-			modules: getModuleResolvePaths()
+			modules: [
+				...getPackageDependenciesPaths(),
+				...getModuleResolvePaths()
+			]
 		},
 		},
 
 
 		resolveLoader: {
 		resolveLoader: {
@@ -471,6 +475,24 @@ function getModuleResolvePaths() {
 	];
 	];
 }
 }
 
 
+/**
+ * Returns an array that contains paths to packages' dependencies.
+ * The snippet adapter should use packages' dependencies instead of the documentation builder dependencies.
+ *
+ * See #7916.
+ *
+ * @returns {Array.<String>}
+ */
+function getPackageDependenciesPaths() {
+	const globOptions = {
+		cwd: path.resolve( __dirname, '..', '..' ),
+		absolute: true
+	};
+
+	return glob.sync( 'packages/*/node_modules', globOptions )
+		.concat( glob.sync( 'external/*/packages/*/node_modules', globOptions ) );
+}
+
 /**
 /**
  * Reads the snippet's configuration.
  * Reads the snippet's configuration.
  *
  *

برخی فایل ها در این مقایسه diff نمایش داده نمی شوند زیرا تعداد فایل ها بسیار زیاد است