8
0
Просмотр исходного кода

Merge branch 'master' into i/6693

Aleksander Nowodzinski 5 лет назад
Родитель
Сommit
893a577caf
54 измененных файлов с 1995 добавлено и 1013 удалено
  1. 6 1
      packages/ckeditor5-core/tests/_utils-tests/articlepluginset.js
  2. 10 1
      packages/ckeditor5-font/tests/integration.js
  3. 1 5
      packages/ckeditor5-image/src/image/converters.js
  4. 13 1
      packages/ckeditor5-image/src/image/utils.js
  5. 15 2
      packages/ckeditor5-image/tests/image/converters.js
  6. 4 3
      packages/ckeditor5-image/tests/image/imageediting.js
  7. 55 2
      packages/ckeditor5-image/tests/image/utils.js
  8. 4 1
      packages/ckeditor5-image/tests/imagetoolbar.js
  9. 5 1
      packages/ckeditor5-image/tests/integration.js
  10. 24 0
      packages/ckeditor5-link/docs/features/link.md
  11. 2 0
      packages/ckeditor5-link/package.json
  12. 22 0
      packages/ckeditor5-link/src/link.js
  13. 40 1
      packages/ckeditor5-link/src/linkcommand.js
  14. 36 0
      packages/ckeditor5-link/src/linkimage.js
  15. 141 0
      packages/ckeditor5-link/src/linkimageediting.js
  16. 39 0
      packages/ckeditor5-link/src/linkimageui.js
  17. 15 2
      packages/ckeditor5-link/src/linkui.js
  18. 6 5
      packages/ckeditor5-link/src/ui/linkformview.js
  19. 62 3
      packages/ckeditor5-link/tests/linkcommand.js
  20. 18 0
      packages/ckeditor5-link/tests/linkimage.js
  21. 334 0
      packages/ckeditor5-link/tests/linkimageediting.js
  22. 41 0
      packages/ckeditor5-link/tests/linkimageui.js
  23. 145 1
      packages/ckeditor5-link/tests/linkui.js
  24. 11 0
      packages/ckeditor5-link/tests/manual/linkimage.html
  25. 49 0
      packages/ckeditor5-link/tests/manual/linkimage.js
  26. 3 0
      packages/ckeditor5-link/tests/manual/linkimage.md
  27. 59 0
      packages/ckeditor5-link/tests/manual/protocol.html
  28. 37 0
      packages/ckeditor5-link/tests/manual/protocol.js
  29. 6 0
      packages/ckeditor5-link/tests/manual/protocol.md
  30. BIN
      packages/ckeditor5-link/tests/manual/sample.jpg
  31. 20 2
      packages/ckeditor5-media-embed/tests/mediaembedtoolbar.js
  32. 28 40
      packages/ckeditor5-table/src/converters/downcast.js
  33. 5 9
      packages/ckeditor5-table/src/tableediting.js
  34. 12 94
      packages/ckeditor5-table/tests/_utils/utils.js
  35. 51 20
      packages/ckeditor5-table/tests/commands/insertcolumncommand.js
  36. 71 22
      packages/ckeditor5-table/tests/commands/insertrowcommand.js
  37. 6 8
      packages/ckeditor5-table/tests/commands/inserttablecommand.js
  38. 18 11
      packages/ckeditor5-table/tests/commands/mergecellcommand.js
  39. 7 8
      packages/ckeditor5-table/tests/commands/removecolumncommand.js
  40. 6 6
      packages/ckeditor5-table/tests/commands/selectcolumncommand.js
  41. 6 6
      packages/ckeditor5-table/tests/commands/selectrowcommand.js
  42. 7 8
      packages/ckeditor5-table/tests/commands/setheadercolumncommand.js
  43. 8 8
      packages/ckeditor5-table/tests/commands/setheaderrowcommand.js
  44. 7 8
      packages/ckeditor5-table/tests/commands/splitcellcommand.js
  45. 353 533
      packages/ckeditor5-table/tests/converters/downcast.js
  46. 7 12
      packages/ckeditor5-table/tests/converters/table-cell-refresh-post-fixer.js
  47. 123 171
      packages/ckeditor5-table/tests/tableutils.js
  48. 5 6
      packages/ckeditor5-table/tests/tablewalker.js
  49. 7 7
      packages/ckeditor5-table/tests/utils/common.js
  50. 18 1
      packages/ckeditor5-widget/src/widgettoolbarrepository.js
  51. 4 1
      packages/ckeditor5-widget/tests/widgetresize.js
  52. 4 1
      packages/ckeditor5-widget/tests/widgetresize/resizer.js
  53. 15 1
      packages/ckeditor5-widget/tests/widgettoolbarrepository.js
  54. 4 1
      packages/ckeditor5-widget/tests/widgettypearound/widgettypearound.js

+ 6 - 1
packages/ckeditor5-core/tests/_utils-tests/articlepluginset.js

@@ -33,7 +33,12 @@ describe( 'ArticlePluginSet', () => {
 		editorElement = document.createElement( 'div' );
 		document.body.appendChild( editorElement );
 
-		editor = await ClassicTestEditor.create( editorElement, { plugins: [ ArticlePluginSet ] } );
+		editor = await ClassicTestEditor.create( editorElement, {
+			plugins: [ ArticlePluginSet ],
+			image: {
+				toolbar: [ 'imageStyle:full', 'imageStyle:side' ]
+			}
+		} );
 	} );
 
 	afterEach( async () => {

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

@@ -19,7 +19,10 @@ describe( 'Integration test Font', () => {
 
 		return ClassicTestEditor
 			.create( element, {
-				plugins: [ Font, ArticlePluginSet ]
+				plugins: [ Font, ArticlePluginSet ],
+				image: {
+					toolbar: [ 'imageStyle:full', 'imageStyle:side' ]
+				}
 			} )
 			.then( newEditor => {
 				editor = newEditor;
@@ -66,6 +69,9 @@ describe( 'Integration test Font', () => {
 					fontSize: {
 						options: [ 10, 12, 14 ],
 						supportAllValues: true
+					},
+					image: {
+						toolbar: [ 'imageStyle:full', 'imageStyle:side' ]
 					}
 				} )
 				.then( editor => {
@@ -129,6 +135,9 @@ describe( 'Integration test Font', () => {
 					fontSize: {
 						options: [ 10, 12, 14 ],
 						supportAllValues: true
+					},
+					image: {
+						toolbar: [ 'imageStyle:full', 'imageStyle:side' ]
 					}
 				} )
 				.then( editor => {

+ 1 - 5
packages/ckeditor5-image/src/image/converters.js

@@ -125,10 +125,6 @@ export function modelToViewAttributeConverter( attributeKey ) {
 		const figure = conversionApi.mapper.toViewElement( data.item );
 		const img = getViewImgFromWidget( figure );
 
-		if ( data.attributeNewValue !== null ) {
-			viewWriter.setAttribute( data.attributeKey, data.attributeNewValue, img );
-		} else {
-			viewWriter.removeAttribute( data.attributeKey, img );
-		}
+		viewWriter.setAttribute( data.attributeKey, data.attributeNewValue || '', img );
 	}
 }

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

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

+ 15 - 2
packages/ckeditor5-image/tests/image/converters.js

@@ -218,7 +218,20 @@ describe( 'Image converters', () => {
 			);
 		} );
 
-		it( 'should convert removing attribute from image', () => {
+		it( 'should convert an empty "src" attribute from image even if removed', () => {
+			setModelData( model, '<image src="" alt="foo bar"></image>' );
+			const image = document.getRoot().getChild( 0 );
+
+			model.change( writer => {
+				writer.removeAttribute( 'src', image );
+			} );
+
+			expect( getViewData( viewDocument, { withoutSelection: true } ) ).to.equal(
+				'<figure class="ck-widget image" contenteditable="false"><img alt="foo bar" src=""></img></figure>'
+			);
+		} );
+
+		it( 'should convert an empty "alt" attribute from image even if removed', () => {
 			setModelData( model, '<image src="" alt="foo bar"></image>' );
 			const image = document.getRoot().getChild( 0 );
 
@@ -227,7 +240,7 @@ describe( 'Image converters', () => {
 			} );
 
 			expect( getViewData( viewDocument, { withoutSelection: true } ) ).to.equal(
-				'<figure class="ck-widget image" contenteditable="false"><img src=""></img></figure>'
+				'<figure class="ck-widget image" contenteditable="false"><img alt="" src=""></img></figure>'
 			);
 		} );
 

+ 4 - 3
packages/ckeditor5-image/tests/image/imageediting.js

@@ -458,7 +458,7 @@ describe( 'ImageEditing', () => {
 				);
 			} );
 
-			it( 'should convert attribute removal', () => {
+			it( 'should convert attribute removal (but keeps an empty "alt" to the data)', () => {
 				setModelData( model, '<image src="/assets/sample.png" alt="alt text"></image>' );
 				const image = doc.getRoot().getChild( 0 );
 
@@ -466,8 +466,9 @@ describe( 'ImageEditing', () => {
 					writer.removeAttribute( 'alt', image );
 				} );
 
-				expect( getViewData( view, { withoutSelection: true } ) )
-					.to.equal( '<figure class="ck-widget image" contenteditable="false"><img src="/assets/sample.png"></img></figure>' );
+				expect( getViewData( view, { withoutSelection: true } ) ).to.equal(
+					'<figure class="ck-widget image" contenteditable="false"><img alt="" src="/assets/sample.png"></img></figure>'
+				);
 			} );
 
 			it( 'should not convert change if is already consumed', () => {

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

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

+ 4 - 1
packages/ckeditor5-image/tests/imagetoolbar.js

@@ -3,7 +3,7 @@
  * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
  */
 
-/* global document */
+/* global document, console */
 
 import ClassicEditor from '@ckeditor/ckeditor5-editor-classic/src/classiceditor';
 import ImageToolbar from '../src/imagetoolbar';
@@ -53,6 +53,7 @@ describe( 'ImageToolbar', () => {
 	} );
 
 	it( 'should not initialize if there is no configuration', () => {
+		const consoleWarnStub = sinon.stub( console, 'warn' );
 		const editorElement = global.document.createElement( 'div' );
 		global.document.body.appendChild( editorElement );
 
@@ -61,6 +62,8 @@ describe( 'ImageToolbar', () => {
 		} )
 			.then( editor => {
 				expect( editor.plugins.get( ImageToolbar )._toolbar ).to.be.undefined;
+				expect( consoleWarnStub.calledOnce ).to.equal( true );
+				expect( consoleWarnStub.firstCall.args[ 0 ] ).to.match( /^widget-toolbar-no-items:/ );
 
 				editorElement.remove();
 				return editor.destroy();

+ 5 - 1
packages/ckeditor5-image/tests/integration.js

@@ -12,6 +12,7 @@ import Image from '../src/image';
 import ImageToolbar from '../src/imagetoolbar';
 import View from '@ckeditor/ckeditor5-ui/src/view';
 import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils';
+import ImageTextAlternative from '../src/imagetextalternative';
 
 describe( 'ImageToolbar integration', () => {
 	describe( 'with the BalloonToolbar', () => {
@@ -25,7 +26,10 @@ describe( 'ImageToolbar integration', () => {
 
 			return ClassicTestEditor
 				.create( editorElement, {
-					plugins: [ Image, ImageToolbar, BalloonToolbar, Paragraph ]
+					plugins: [ Image, ImageTextAlternative, ImageToolbar, BalloonToolbar, Paragraph ],
+					image: {
+						toolbar: [ 'imageTextAlternative' ]
+					}
 				} )
 				.then( editor => {
 					newEditor = editor;

+ 24 - 0
packages/ckeditor5-link/docs/features/link.md

@@ -147,6 +147,30 @@ ClassicEditor
 	.catch( ... );
 ```
 
+#### Adding default link protocol for the external links
+
+Default link protocol can be usefull when user forget to type a full URL address to an external source, site etc. Sometimes copying the text, like for example `ckeditor.com` and converting it to a link may cause some issues. When you do this, the created link will direct you to `yourdomain.com/ckeditor.com`, because you forgot to pass the right protocol which makes the link relative to the site where it appears.
+
+Enabling the `{@link module:link/link~LinkConfig#defaultProtocol config.link.defaultProtocol}`, the {@link module:link/link~Link} feature will handle this issue for you. By default it doesn't fix the passed link value, but when you set `{@link module:link/link~LinkConfig#defaultProtocol config.link.defaultProtocol}` to — for example — `http://`, the plugin will add the given protocol to the every link that may need it (like `ckeditor.com`, `example.com` etc. where `[protocol://]example.com` is missing). Here's the basic configuration example:
+
+```js
+ClassicEditor
+	.create( document.querySelector( '#editor' ), {
+		// ...
+		link: {
+			defaultProtocol: 'http://'
+		}
+	} )
+	.then( ... )
+	.catch( ... );
+```
+
+<info-box>
+	Having `config.link.defaultProtocol` enabled you are still able to link things locally using `#` or `/`. Protocol won't be added to those links.
+
+	Enabled feature also gives you an **email addresses auto-detection** feature. When you submit `hello@example.com`, the plugin will change it automatically to `mailto:hello@example.com`.
+</info-box>
+
 #### Adding attributes to links based on pre–defined rules (automatic decorators)
 
 Automatic link decorators match all links in the editor content against a {@link module:link/link~LinkDecoratorAutomaticDefinition function} which decides whether the link should receive some set of attributes, considering the URL (`href`) of the link. These decorators work silently and are being applied during the {@link framework/guides/architecture/editing-engine#conversion data downcast} only.

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

@@ -16,10 +16,12 @@
     "lodash-es": "^4.17.15"
   },
   "devDependencies": {
+    "@ckeditor/ckeditor5-basic-styles": "^19.0.1",
     "@ckeditor/ckeditor5-block-quote": "^19.0.1",
     "@ckeditor/ckeditor5-clipboard": "^19.0.1",
     "@ckeditor/ckeditor5-editor-classic": "^19.0.1",
     "@ckeditor/ckeditor5-enter": "^19.0.1",
+    "@ckeditor/ckeditor5-image": "^19.0.1",
     "@ckeditor/ckeditor5-paragraph": "^19.1.0",
     "@ckeditor/ckeditor5-theme-lark": "^19.1.0",
     "@ckeditor/ckeditor5-typing": "^19.0.1",

+ 22 - 0
packages/ckeditor5-link/src/link.js

@@ -57,6 +57,28 @@ export default class Link extends Plugin {
  * @interface LinkConfig
  */
 
+/**
+ * When set, the editor will add the given protocol to the link when the user creates a link without one.
+ * For example, when the user is creating a link and types `ckeditor.com` in the link form input — during link submission —
+ * the editor will automatically add the `http://` protocol, so the link will be as follows: `http://ckeditor.com`.
+ *
+ * The feature also comes with an email auto-detection. When you submit `hello@example.com`
+ * the plugin will automatically change it to `mailto:hello@example.com`.
+ *
+ * 		ClassicEditor
+ *			.create( editorElement, {
+ * 				link: {
+ * 					defaultProtocol: 'http://'
+ * 				}
+ *			} )
+ *			.then( ... )
+ *			.catch( ... );
+ *
+ * **NOTE:** In case no configuration is provided, the editor won't auto-fix the links.
+ *
+ * @member {String} module:link/link~LinkConfig#defaultProtocol
+ */
+
 /**
  * When set to `true`, the `target="blank"` and `rel="noopener noreferrer"` attributes are automatically added to all external links
  * in the editor. "External links" are all links in the editor content starting with `http`, `https`, or `//`.

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

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

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

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

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

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

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

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

+ 15 - 2
packages/ckeditor5-link/src/linkui.js

@@ -21,6 +21,8 @@ import LinkActionsView from './ui/linkactionsview';
 import linkIcon from '../theme/icons/link.svg';
 
 const linkKeystroke = 'Ctrl+K';
+const protocolRegExp = /^((\w+:(\/{2,})?)|(\W))/i;
+const emailRegExp = /[\w-]+@[\w-]+\.+[\w-]+/i;
 
 /**
  * The link UI plugin. It introduces the `'link'` and `'unlink'` buttons and support for the <kbd>Ctrl+K</kbd> keystroke.
@@ -143,8 +145,9 @@ export default class LinkUI extends Plugin {
 	_createFormView() {
 		const editor = this.editor;
 		const linkCommand = editor.commands.get( 'link' );
+		const defaultProtocol = editor.config.get( 'link.defaultProtocol' );
 
-		const formView = new LinkFormView( editor.locale, linkCommand );
+		const formView = new LinkFormView( editor.locale, linkCommand, defaultProtocol );
 
 		formView.urlInputView.fieldView.bind( 'value' ).to( linkCommand, 'value' );
 
@@ -154,7 +157,17 @@ export default class LinkUI extends Plugin {
 
 		// Execute link command after clicking the "Save" button.
 		this.listenTo( formView, 'submit', () => {
-			editor.execute( 'link', formView.urlInputView.fieldView.element.value, formView.getDecoratorSwitchesState() );
+			const { value } = formView.urlInputView.fieldView.element;
+
+			// The regex checks for the protocol syntax ('xxxx://' or 'xxxx:')
+			// or non-word charecters at the begining of the link ('/', '#' etc.).
+			const isProtocolNeeded = !!defaultProtocol && !protocolRegExp.test( value );
+			const isEmail = emailRegExp.test( value );
+
+			const protocol = isEmail ? 'mailto:' : defaultProtocol;
+			const parsedValue = value && isProtocolNeeded ? protocol + value : value;
+
+			editor.execute( 'link', parsedValue, formView.getDecoratorSwitchesState() );
 			this._closeFormView();
 		} );
 

+ 6 - 5
packages/ckeditor5-link/src/ui/linkformview.js

@@ -40,8 +40,9 @@ export default class LinkFormView extends View {
 	 *
 	 * @param {module:utils/locale~Locale} [locale] The localization services instance.
 	 * @param {module:link/linkcommand~LinkCommand} linkCommand Reference to {@link module:link/linkcommand~LinkCommand}.
+	 * @param {String} [protocol] A value of a protocol to be displayed in the input's placeholder.
 	 */
-	constructor( locale, linkCommand ) {
+	constructor( locale, linkCommand, protocol ) {
 		super( locale );
 
 		const t = locale.t;
@@ -67,7 +68,7 @@ export default class LinkFormView extends View {
 		 *
 		 * @member {module:ui/labeledfield/labeledfieldview~LabeledFieldView}
 		 */
-		this.urlInputView = this._createUrlInput();
+		this.urlInputView = this._createUrlInput( protocol );
 
 		/**
 		 * The Save button view.
@@ -207,15 +208,15 @@ export default class LinkFormView extends View {
 	 * Creates a labeled input view.
 	 *
 	 * @private
+	 * @param {String} [protocol=http://] A value of a protocol to be displayed in the input's placeholder.
 	 * @returns {module:ui/labeledfield/labeledfieldview~LabeledFieldView} Labeled field view instance.
 	 */
-	_createUrlInput() {
+	_createUrlInput( protocol = 'https://' ) {
 		const t = this.locale.t;
-
 		const labeledInput = new LabeledFieldView( this.locale, createLabeledInputText );
 
 		labeledInput.label = t( 'Link URL' );
-		labeledInput.fieldView.placeholder = 'https://example.com';
+		labeledInput.fieldView.placeholder = protocol + 'example.com';
 
 		return labeledInput;
 	}

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

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

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

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

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

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

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

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

+ 145 - 1
packages/ckeditor5-link/tests/linkui.js

@@ -8,7 +8,7 @@
 import ClassicTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/classictesteditor';
 import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils';
 import { keyCodes } from '@ckeditor/ckeditor5-utils/src/keyboard';
-import { setData as setModelData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
+import { setData as setModelData, getData as getModelData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
 import { getData as getViewData } from '@ckeditor/ckeditor5-engine/src/dev-utils/view';
 
 import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
@@ -891,6 +891,27 @@ describe( 'LinkUI', () => {
 	describe( 'link form view', () => {
 		let focusEditableSpy;
 
+		const createEditorWithDefaultProtocol = defaultProtocol => {
+			return ClassicTestEditor
+				.create( editorElement, {
+					plugins: [ LinkEditing, LinkUI, Paragraph, BlockQuote ],
+					link: { defaultProtocol }
+				} )
+				.then( editor => {
+					const linkUIFeature = editor.plugins.get( LinkUI );
+					const formView = linkUIFeature.formView;
+
+					formView.render();
+
+					editor.model.schema.extend( '$text', {
+						allowIn: '$root',
+						allowAttributes: 'linkHref'
+					} );
+
+					return { editor, formView };
+				} );
+		};
+
 		beforeEach( () => {
 			focusEditableSpy = testUtils.sinon.spy( editor.editing.view, 'focus' );
 		} );
@@ -905,6 +926,129 @@ describe( 'LinkUI', () => {
 			expect( editor.ui.focusTracker.isFocused ).to.be.true;
 		} );
 
+		describe( 'link protocol', () => {
+			it( 'should use a default link protocol from the `config.link.defaultProtocol` when provided', () => {
+				return ClassicTestEditor
+					.create( editorElement, {
+						link: {
+							defaultProtocol: 'https://'
+						}
+					} )
+					.then( editor => {
+						const defaultProtocol = editor.config.get( 'link.defaultProtocol' );
+
+						expect( defaultProtocol ).to.equal( 'https://' );
+
+						editor.destroy();
+					} );
+			} );
+
+			it( 'should not add a protocol without the configuration', () => {
+				formView.urlInputView.fieldView.value = 'ckeditor.com';
+				formView.fire( 'submit' );
+
+				expect( formView.urlInputView.fieldView.value ).to.equal( 'ckeditor.com' );
+			} );
+
+			it( 'should not add a protocol to the local links even when `config.link.defaultProtocol` configured', () => {
+				return createEditorWithDefaultProtocol( 'http://' ).then( ( { editor, formView } ) => {
+					formView.urlInputView.fieldView.value = '#test';
+					formView.fire( 'submit' );
+
+					expect( formView.urlInputView.fieldView.value ).to.equal( '#test' );
+
+					editor.destroy();
+				} );
+			} );
+
+			it( 'should not add a protocol to the relative links even when `config.link.defaultProtocol` configured', () => {
+				return createEditorWithDefaultProtocol( 'http://' ).then( ( { editor, formView } ) => {
+					formView.urlInputView.fieldView.value = '/test.html';
+					formView.fire( 'submit' );
+
+					expect( formView.urlInputView.fieldView.value ).to.equal( '/test.html' );
+
+					editor.destroy();
+				} );
+			} );
+
+			it( 'should not add a protocol when given provided within the value even when `config.link.defaultProtocol` configured', () => {
+				return createEditorWithDefaultProtocol( 'http://' ).then( ( { editor, formView } ) => {
+					formView.urlInputView.fieldView.value = 'http://example.com';
+					formView.fire( 'submit' );
+
+					expect( formView.urlInputView.fieldView.value ).to.equal( 'http://example.com' );
+
+					editor.destroy();
+				} );
+			} );
+
+			it( 'should use the "http://" protocol when it\'s configured', () => {
+				return createEditorWithDefaultProtocol( 'http://' ).then( ( { editor, formView } ) => {
+					formView.urlInputView.fieldView.value = 'ckeditor.com';
+					formView.fire( 'submit' );
+
+					expect( formView.urlInputView.fieldView.value ).to.equal( 'http://ckeditor.com' );
+
+					editor.destroy();
+				} );
+			} );
+
+			it( 'should use the "http://" protocol when it\'s configured and form input value contains "www."', () => {
+				return createEditorWithDefaultProtocol( 'http://' ).then( ( { editor, formView } ) => {
+					formView.urlInputView.fieldView.value = 'www.ckeditor.com';
+					formView.fire( 'submit' );
+
+					expect( formView.urlInputView.fieldView.value ).to.equal( 'http://www.ckeditor.com' );
+
+					editor.destroy();
+				} );
+			} );
+
+			it( 'should propagate the protocol to the link\'s `linkHref` attribute in model', () => {
+				return createEditorWithDefaultProtocol( 'http://' ).then( ( { editor, formView } ) => {
+					setModelData( editor.model, '[ckeditor.com]' );
+
+					formView.urlInputView.fieldView.value = 'ckeditor.com';
+					formView.fire( 'submit' );
+
+					expect( getModelData( editor.model ) ).to.equal(
+						'[<$text linkHref="http://ckeditor.com">ckeditor.com</$text>]'
+					);
+
+					editor.destroy();
+				} );
+			} );
+
+			it( 'should detect an email on submitting the form and add "mailto:" protocol automatically to the provided value', () => {
+				return createEditorWithDefaultProtocol( 'http://' ).then( ( { editor, formView } ) => {
+					setModelData( editor.model, '[email@example.com]' );
+
+					formView.urlInputView.fieldView.value = 'email@example.com';
+					formView.fire( 'submit' );
+
+					expect( formView.urlInputView.fieldView.value ).to.equal( 'mailto:email@example.com' );
+					expect( getModelData( editor.model ) ).to.equal(
+						'[<$text linkHref="mailto:email@example.com">email@example.com</$text>]'
+					);
+
+					editor.destroy();
+				} );
+			} );
+
+			it( 'should not add an email protocol when given provided within the value' +
+				'even when `config.link.defaultProtocol` configured', () => {
+				return createEditorWithDefaultProtocol( 'mailto:' ).then( ( { editor, formView } ) => {
+					formView.urlInputView.fieldView.value = 'mailto:test@example.com';
+					formView.fire( 'submit' );
+
+					expect( formView.urlInputView.fieldView.value ).to.equal( 'mailto:test@example.com' );
+
+					editor.destroy();
+				} );
+			} );
+		} );
+
 		describe( 'binding', () => {
 			beforeEach( () => {
 				setModelData( editor.model, '<paragraph>f[o]o</paragraph>' );

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

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

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

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

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

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

+ 59 - 0
packages/ckeditor5-link/tests/manual/protocol.html

@@ -0,0 +1,59 @@
+<style>
+	code {
+		background: hsl(0, 0%, 90%);
+	}
+
+	sup {
+		position: relative;
+	}
+
+	.ck-editor__editable sup:after {
+		content: '';
+		display: inline-block;
+		position: absolute;
+		width: 14px;
+		height: 14px;
+		left: 1px;
+		top: 1px;
+		background: hsla(207, 87%, 55%, 0.5);
+		border-radius: 50px;
+		animation: pulse 2s linear infinite;
+	}
+
+	@keyframes pulse {
+		0% {
+			transform: scale(1);
+		}
+
+		50% {
+			transform: scale(2);
+		}
+
+		100% {
+			transform: scale(1);
+		}
+	}
+</style>
+
+
+<h2><code>Feature is disabled</code></h2>
+<div id="editor0">
+	<p>This is <a href="http://ckeditor.com">CKEditor5</a> from <a href="http://cksource.com">CKSource</a>. If you need more information please contact us at support@example.com.</p>
+</div>
+
+<h2><code>http://</code></h2>
+<div id="editor1">
+	<p>This is <a href="http://ckeditor.com">CKEditor5</a> from <a href="http://cksource.com">CKSource</a>. If you need more information please contact us at support@example.com <sup class="indicator">[1]</sup>.</p>
+</div>
+
+<p><sup>[1]</sup><strong>When feature enabled:</strong> copy the email address and create a link with it (<code>mailto:</code> protocol will be added automatically).</p>
+
+<h2><code>https://</code></h2>
+<div id="editor2">
+	<p>This is <a href="http://ckeditor.com">CKEditor5</a> from <a href="http://cksource.com">CKSource</a>. If you need more information please contact us at support@example.com.</p>
+</div>
+
+<h2><code>mailto:</code></h2>
+<div id="editor3">
+	<p>This is <a href="http://ckeditor.com">CKEditor5</a> from <a href="http://cksource.com">CKSource</a>. If you need more information please contact us at support@example.com.</p>
+</div>

+ 37 - 0
packages/ckeditor5-link/tests/manual/protocol.js

@@ -0,0 +1,37 @@
+/**
+ * @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:false, window, document */
+
+import ClassicEditor from '@ckeditor/ckeditor5-editor-classic/src/classiceditor';
+import Enter from '@ckeditor/ckeditor5-enter/src/enter';
+import Typing from '@ckeditor/ckeditor5-typing/src/typing';
+import Link from '../../src/link';
+import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
+import Undo from '@ckeditor/ckeditor5-undo/src/undo';
+import Superscript from '@ckeditor/ckeditor5-basic-styles/src/superscript';
+
+createEditorWithDefaultProtocol( '#editor0' );
+createEditorWithDefaultProtocol( '#editor1', 'http://' );
+createEditorWithDefaultProtocol( '#editor2', 'https://' );
+createEditorWithDefaultProtocol( '#editor3', 'mailto:' );
+
+function createEditorWithDefaultProtocol( editor, defaultProtocol ) {
+	return ClassicEditor
+		.create( document.querySelector( editor ), {
+			plugins: [ Link, Typing, Paragraph, Undo, Enter, Superscript ],
+			toolbar: [ 'link', 'undo', 'redo' ],
+			link: {
+				addTargetToExternalLinks: true,
+				...defaultProtocol && { defaultProtocol }
+			}
+		} )
+		.then( editor => {
+			window.editor = editor;
+		} )
+		.catch( err => {
+			console.error( err.stack );
+		} );
+}

+ 6 - 0
packages/ckeditor5-link/tests/manual/protocol.md

@@ -0,0 +1,6 @@
+## Link protocol
+
+This test checks whether:
+- `config.link.defaultProtocol` applies.
+- when input value starts with a protocol-like syntax (like `http://` etc.) or any non-word (like `#` or `/`) then `defaultProtocol` won't be applied.
+- the plugin dynamically change link protocol to `mailto:` when email address was detected.

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


+ 20 - 2
packages/ckeditor5-media-embed/tests/mediaembedtoolbar.js

@@ -3,7 +3,7 @@
  * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
  */
 
-/* global document */
+/* global document, console */
 
 import ClassicTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/classictesteditor';
 import BalloonEditor from '@ckeditor/ckeditor5-editor-balloon/src/ballooneditor';
@@ -186,7 +186,7 @@ describe( 'MediaEmbedToolbar - integration with BalloonEditor', () => {
 		return BalloonEditor.create( element, {
 			plugins: [ Paragraph, MediaEmbed, MediaEmbedToolbar, FakeButton, Bold ],
 			balloonToolbar: [ 'bold' ],
-			media: {
+			mediaEmbed: {
 				toolbar: [ 'fake_button' ]
 			}
 		} ).then( _editor => {
@@ -239,6 +239,24 @@ describe( 'MediaEmbedToolbar - integration with BalloonEditor', () => {
 
 		expect( balloon.visibleView ).to.equal( balloonToolbar.toolbarView );
 	} );
+
+	it( 'does not create the toolbar if its items are not specified', () => {
+		const consoleWarnStub = sinon.stub( console, 'warn' );
+		const element = document.createElement( 'div' );
+
+		return BalloonEditor.create( element, {
+			plugins: [ Paragraph, MediaEmbed, MediaEmbedToolbar, Bold ]
+		} ).then( editor => {
+			widgetToolbarRepository = editor.plugins.get( 'WidgetToolbarRepository' );
+
+			expect( widgetToolbarRepository._toolbarDefinitions.get( 'mediaEmbed' ) ).to.be.undefined;
+			expect( consoleWarnStub.calledOnce ).to.equal( true );
+			expect( consoleWarnStub.firstCall.args[ 0 ] ).to.match( /^widget-toolbar-no-items:/ );
+
+			element.remove();
+			return editor.destroy();
+		} );
+	} );
 } );
 
 // Plugin that adds fake_button to editor's component factory.

+ 28 - 40
packages/ckeditor5-table/src/converters/downcast.js

@@ -84,7 +84,7 @@ export function downcastInsertTable( options = {} ) {
  *
  * @returns {Function} Conversion helper.
  */
-export function downcastInsertRow( options = {} ) {
+export function downcastInsertRow() {
 	return dispatcher => dispatcher.on( 'insert:tableRow', ( evt, data, conversionApi ) => {
 		const tableRow = data.item;
 
@@ -120,7 +120,7 @@ export function downcastInsertRow( options = {} ) {
 
 			const insertPosition = conversionApi.writer.createPositionAt( trElement, 'end' );
 
-			createViewTableCellElement( tableSlot, tableAttributes, insertPosition, conversionApi, options );
+			createViewTableCellElement( tableSlot, tableAttributes, insertPosition, conversionApi, { asWidget: true } );
 		}
 	} );
 }
@@ -133,7 +133,7 @@ export function downcastInsertRow( options = {} ) {
  *
  * @returns {Function} Conversion helper.
  */
-export function downcastInsertCell( options = {} ) {
+export function downcastInsertCell() {
 	return dispatcher => dispatcher.on( 'insert:tableCell', ( evt, data, conversionApi ) => {
 		const tableCell = data.item;
 
@@ -158,7 +158,7 @@ export function downcastInsertCell( options = {} ) {
 				const trElement = conversionApi.mapper.toViewElement( tableRow );
 				const insertPosition = conversionApi.writer.createPositionAt( trElement, tableRow.getChildIndex( tableCell ) );
 
-				createViewTableCellElement( tableSlot, tableAttributes, insertPosition, conversionApi, options );
+				createViewTableCellElement( tableSlot, tableAttributes, insertPosition, conversionApi, { asWidget: true } );
 
 				// No need to iterate further.
 				return;
@@ -178,9 +178,7 @@ export function downcastInsertCell( options = {} ) {
  *
  * @returns {Function} Conversion helper.
  */
-export function downcastTableHeadingRowsChange( options = {} ) {
-	const asWidget = !!options.asWidget;
-
+export function downcastTableHeadingRowsChange() {
 	return dispatcher => dispatcher.on( 'attribute:headingRows:table', ( evt, data, conversionApi ) => {
 		const table = data.item;
 
@@ -205,7 +203,7 @@ export function downcastTableHeadingRowsChange( options = {} ) {
 			// Rename all table cells from moved rows to 'th' as they lands in <thead>.
 			for ( const tableRow of rowsToMove ) {
 				for ( const tableCell of tableRow.getChildren() ) {
-					renameViewTableCell( tableCell, 'th', conversionApi, asWidget );
+					renameViewTableCell( tableCell, 'th', conversionApi );
 				}
 			}
 		}
@@ -228,7 +226,7 @@ export function downcastTableHeadingRowsChange( options = {} ) {
 			};
 
 			for ( const tableSlot of tableWalker ) {
-				renameViewTableCellIfRequired( tableSlot, tableAttributes, conversionApi, asWidget );
+				renameViewTableCellIfRequired( tableSlot, tableAttributes, conversionApi );
 			}
 		}
 
@@ -249,9 +247,7 @@ export function downcastTableHeadingRowsChange( options = {} ) {
  *
  * @returns {Function} Conversion helper.
  */
-export function downcastTableHeadingColumnsChange( options = {} ) {
-	const asWidget = !!options.asWidget;
-
+export function downcastTableHeadingColumnsChange() {
 	return dispatcher => dispatcher.on( 'attribute:headingColumns:table', ( evt, data, conversionApi ) => {
 		const table = data.item;
 
@@ -270,7 +266,7 @@ export function downcastTableHeadingColumnsChange( options = {} ) {
 		const lastColumnToCheck = ( oldColumns > newColumns ? oldColumns : newColumns ) - 1;
 
 		for ( const tableSlot of new TableWalker( table, { endColumn: lastColumnToCheck } ) ) {
-			renameViewTableCellIfRequired( tableSlot, tableAttributes, conversionApi, asWidget );
+			renameViewTableCellIfRequired( tableSlot, tableAttributes, conversionApi );
 		}
 	} );
 }
@@ -327,8 +323,7 @@ function toTableWidget( viewElement, writer ) {
 // @param {module:engine/model/element~Element} tableCell
 // @param {String} desiredCellElementName
 // @param {module:engine/conversion/downcastdispatcher~DowncastConversionApi} conversionApi
-// @param {Boolean} asWidget
-function renameViewTableCell( tableCell, desiredCellElementName, conversionApi, asWidget ) {
+function renameViewTableCell( tableCell, desiredCellElementName, conversionApi ) {
 	const viewWriter = conversionApi.writer;
 	const viewCell = conversionApi.mapper.toViewElement( tableCell );
 
@@ -337,41 +332,30 @@ function renameViewTableCell( tableCell, desiredCellElementName, conversionApi,
 		return;
 	}
 
-	let renamedCell;
-
-	if ( asWidget ) {
-		const editable = viewWriter.createEditableElement( desiredCellElementName, viewCell.getAttributes() );
-		renamedCell = toWidgetEditable( editable, viewWriter );
+	const editable = viewWriter.createEditableElement( desiredCellElementName, viewCell.getAttributes() );
+	const renamedCell = toWidgetEditable( editable, viewWriter );
 
-		setHighlightHandling(
-			renamedCell,
-			viewWriter,
-			( element, descriptor, writer ) => writer.addClass( normalizeToArray( descriptor.classes ), element ),
-			( element, descriptor, writer ) => writer.removeClass( normalizeToArray( descriptor.classes ), element )
-		);
+	setHighlightHandling(
+		renamedCell,
+		viewWriter,
+		( element, descriptor, writer ) => writer.addClass( normalizeToArray( descriptor.classes ), element ),
+		( element, descriptor, writer ) => writer.removeClass( normalizeToArray( descriptor.classes ), element )
+	);
 
-		viewWriter.insert( viewWriter.createPositionAfter( viewCell ), renamedCell );
-		viewWriter.move( viewWriter.createRangeIn( viewCell ), viewWriter.createPositionAt( renamedCell, 0 ) );
-		viewWriter.remove( viewWriter.createRangeOn( viewCell ) );
-	} else {
-		renamedCell = viewWriter.rename( desiredCellElementName, viewCell );
-	}
+	viewWriter.insert( viewWriter.createPositionAfter( viewCell ), renamedCell );
+	viewWriter.move( viewWriter.createRangeIn( viewCell ), viewWriter.createPositionAt( renamedCell, 0 ) );
+	viewWriter.remove( viewWriter.createRangeOn( viewCell ) );
 
 	conversionApi.mapper.unbindViewElement( viewCell );
 	conversionApi.mapper.bindElements( tableCell, renamedCell );
 }
 
-function normalizeToArray( classes ) {
-	return Array.isArray( classes ) ? classes : [ classes ];
-}
-
 // Renames a table cell element in the view according to its location in the table.
 //
 // @param {module:table/tablewalker~TableSlot} tableSlot
 // @param {{headingColumns, headingRows}} tableAttributes
 // @param {module:engine/conversion/downcastdispatcher~DowncastConversionApi} conversionApi
-// @param {Boolean} asWidget
-function renameViewTableCellIfRequired( tableSlot, tableAttributes, conversionApi, asWidget ) {
+function renameViewTableCellIfRequired( tableSlot, tableAttributes, conversionApi ) {
 	const { cell } = tableSlot;
 
 	// Check whether current columnIndex is overlapped by table cells from previous rows.
@@ -382,7 +366,7 @@ function renameViewTableCellIfRequired( tableSlot, tableAttributes, conversionAp
 	// If in single change we're converting attribute changes and inserting cell the table cell might not be inserted into view
 	// because of child conversion is done after parent.
 	if ( viewCell && viewCell.name !== desiredCellElementName ) {
-		renameViewTableCell( cell, desiredCellElementName, conversionApi, asWidget );
+		renameViewTableCell( cell, desiredCellElementName, conversionApi );
 	}
 }
 
@@ -421,7 +405,7 @@ function createViewTableCellElement( tableSlot, tableAttributes, insertPosition,
 
 		conversionApi.consumable.consume( innerParagraph, 'insert' );
 
-		if ( options.asWidget ) {
+		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' } );
@@ -589,3 +573,7 @@ function getViewTable( viewFigure ) {
 function hasAnyAttribute( element ) {
 	return !![ ...element.getAttributeKeys() ].length;
 }
+
+function normalizeToArray( classes ) {
+	return Array.isArray( classes ) ? classes : [ classes ];
+}

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

@@ -100,26 +100,22 @@ export default class TableEditing extends Plugin {
 		conversion.for( 'upcast' ).elementToElement( { model: 'tableRow', view: 'tr' } );
 		conversion.for( 'upcast' ).add( skipEmptyTableRow() );
 
-		conversion.for( 'editingDowncast' ).add( downcastInsertRow( { asWidget: true } ) );
-		conversion.for( 'dataDowncast' ).add( downcastInsertRow() );
-		conversion.for( 'downcast' ).add( downcastRemoveRow() );
+		conversion.for( 'editingDowncast' ).add( downcastInsertRow() );
+		conversion.for( 'editingDowncast' ).add( downcastRemoveRow() );
 
 		// Table cell conversion.
 		conversion.for( 'upcast' ).add( upcastTableCell( 'td' ) );
 		conversion.for( 'upcast' ).add( upcastTableCell( 'th' ) );
 
-		conversion.for( 'editingDowncast' ).add( downcastInsertCell( { asWidget: true } ) );
-		conversion.for( 'dataDowncast' ).add( downcastInsertCell() );
+		conversion.for( 'editingDowncast' ).add( downcastInsertCell() );
 
 		// Table attributes conversion.
 		conversion.attributeToAttribute( { model: 'colspan', view: 'colspan' } );
 		conversion.attributeToAttribute( { model: 'rowspan', view: 'rowspan' } );
 
 		// Table heading rows and columns conversion.
-		conversion.for( 'editingDowncast' ).add( downcastTableHeadingColumnsChange( { asWidget: true } ) );
-		conversion.for( 'dataDowncast' ).add( downcastTableHeadingColumnsChange() );
-		conversion.for( 'editingDowncast' ).add( downcastTableHeadingRowsChange( { asWidget: true } ) );
-		conversion.for( 'dataDowncast' ).add( downcastTableHeadingRowsChange() );
+		conversion.for( 'editingDowncast' ).add( downcastTableHeadingColumnsChange() );
+		conversion.for( 'editingDowncast' ).add( downcastTableHeadingRowsChange() );
 
 		// Define all the commands.
 		editor.commands.add( 'insertTable', new InsertTableCommand( editor ) );

+ 12 - 94
packages/ckeditor5-table/tests/_utils/utils.js

@@ -3,21 +3,11 @@
  * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
  */
 
-import {
-	downcastInsertCell,
-	downcastInsertRow,
-	downcastInsertTable,
-	downcastRemoveRow,
-	downcastTableHeadingColumnsChange,
-	downcastTableHeadingRowsChange
-} from '../../src/converters/downcast';
-import upcastTable, { upcastTableCell } from '../../src/converters/upcasttable';
 import { assertEqualMarkup } from '@ckeditor/ckeditor5-utils/tests/_utils/utils';
 import { setData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
 import TableWalker from '../../src/tablewalker';
 
 const WIDGET_TABLE_CELL_CLASS = 'ck-editor__editable ck-editor__nested-editable';
-const BORDER_REG_EXP = /[\s\S]+/;
 
 /**
  * Returns a model representation of a table shorthand notation:
@@ -180,85 +170,6 @@ export function viewTable( tableData, attributes = {} ) {
 	return `<figure ${ figureAttributes }>${ asWidget ? widgetHandler : '' }<table>${ thead }${ tbody }</table></figure>`;
 }
 
-export function defaultSchema( schema, registerParagraph = true ) {
-	schema.register( 'table', {
-		allowWhere: '$block',
-		allowAttributes: [ 'headingRows', 'headingColumns' ],
-		isLimit: true,
-		isObject: true,
-		isBlock: true
-	} );
-
-	schema.register( 'tableRow', {
-		allowIn: 'table',
-		isLimit: true
-	} );
-
-	schema.register( 'tableCell', {
-		allowIn: 'tableRow',
-		allowAttributes: [ 'colspan', 'rowspan' ],
-		isObject: true
-	} );
-
-	// Allow all $block content inside table cell.
-	schema.extend( '$block', { allowIn: 'tableCell' } );
-
-	// Disallow table in table.
-	schema.addChildCheck( ( context, childDefinition ) => {
-		if ( childDefinition.name == 'table' && Array.from( context.getNames() ).includes( 'table' ) ) {
-			return false;
-		}
-	} );
-
-	if ( registerParagraph ) {
-		schema.register( 'paragraph', { inheritAllFrom: '$block' } );
-	}
-
-	// Styles
-	schema.extend( 'tableCell', {
-		allowAttributes: [ 'border' ]
-	} );
-}
-
-export function defaultConversion( conversion, asWidget = false ) {
-	conversion.elementToElement( { model: 'paragraph', view: 'p' } );
-
-	// Table conversion.
-	conversion.for( 'upcast' ).add( upcastTable() );
-	conversion.for( 'downcast' ).add( downcastInsertTable( { asWidget } ) );
-
-	// Table row conversion.
-	conversion.for( 'upcast' ).elementToElement( { model: 'tableRow', view: 'tr' } );
-	conversion.for( 'downcast' ).add( downcastInsertRow( { asWidget } ) );
-	conversion.for( 'downcast' ).add( downcastRemoveRow( { asWidget } ) );
-
-	// Table cell conversion.
-	conversion.for( 'upcast' ).add( upcastTableCell( 'td' ) );
-	conversion.for( 'upcast' ).add( upcastTableCell( 'th' ) );
-	conversion.for( 'downcast' ).add( downcastInsertCell( { asWidget } ) );
-
-	// Table attributes conversion.
-	conversion.attributeToAttribute( { model: 'colspan', view: 'colspan' } );
-	conversion.attributeToAttribute( { model: 'rowspan', view: 'rowspan' } );
-
-	conversion.for( 'downcast' ).add( downcastTableHeadingColumnsChange( { asWidget } ) );
-	conversion.for( 'downcast' ).add( downcastTableHeadingRowsChange( { asWidget } ) );
-
-	// Styles
-	conversion.for( 'upcast' ).attributeToAttribute( {
-		view: {
-			name: 'td',
-			styles: {
-				border: BORDER_REG_EXP
-			}
-		},
-		model: {
-			key: 'border',
-			value: viewElement => viewElement.getStyle( 'border' )
-		}
-	} );
-}
-
 /**
  * An assertion helper for top-right-bottom-left attribute object.
  *
@@ -420,10 +331,10 @@ function formatAttributes( attributes ) {
 	let attributesString = '';
 
 	if ( attributes ) {
-		const entries = Object.entries( attributes );
+		const sortedKeys = Object.keys( attributes ).sort();
 
-		if ( entries.length ) {
-			attributesString = ' ' + entries.map( entry => `${ entry[ 0 ] }="${ entry[ 1 ] }"` ).join( ' ' );
+		if ( sortedKeys.length ) {
+			attributesString = ' ' + sortedKeys.map( key => `${ key }="${ attributes[ key ] }"` ).join( ' ' );
 		}
 	}
 
@@ -456,17 +367,24 @@ function makeRows( tableData, options ) {
 					delete tableCellData.isSelected;
 				}
 
-				const attributes = isObject ? tableCellData : {};
+				let attributes = {};
 
 				if ( asWidget ) {
 					attributes.class = getClassToSet( attributes );
 					attributes.contenteditable = 'true';
 				}
 
+				if ( isObject ) {
+					attributes = {
+						...attributes,
+						...tableCellData
+					};
+				}
+
 				if ( !( contents.replace( '[', '' ).replace( ']', '' ).startsWith( '<' ) ) && enforceWrapping ) {
 					contents =
 						`<${ wrappingElement == 'span' ? 'span style="display:inline-block"' : wrappingElement }>` +
-							contents +
+						contents +
 						`</${ wrappingElement }>`;
 				}
 

+ 51 - 20
packages/ckeditor5-table/tests/commands/insertcolumncommand.js

@@ -5,13 +5,15 @@
 
 import ModelTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/modeltesteditor';
 import HorizontalLineEditing from '@ckeditor/ckeditor5-horizontal-line/src/horizontallineediting';
+import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
 import { getData, setData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
+import { assertEqualMarkup } from '@ckeditor/ckeditor5-utils/tests/_utils/utils';
 
-import InsertColumnCommand from '../../src/commands/insertcolumncommand';
 import TableSelection from '../../src/tableselection';
-import { assertSelectedCells, defaultConversion, defaultSchema, modelTable } from '../_utils/utils';
-import TableUtils from '../../src/tableutils';
-import { assertEqualMarkup } from '@ckeditor/ckeditor5-utils/tests/_utils/utils';
+import TableEditing from '../../src/tableediting';
+import { assertSelectedCells, modelTable } from '../_utils/utils';
+
+import InsertColumnCommand from '../../src/commands/insertcolumncommand';
 
 describe( 'InsertColumnCommand', () => {
 	let editor, model, command;
@@ -19,14 +21,11 @@ describe( 'InsertColumnCommand', () => {
 	beforeEach( () => {
 		return ModelTestEditor
 			.create( {
-				plugins: [ TableUtils, TableSelection, HorizontalLineEditing ]
+				plugins: [ Paragraph, TableEditing, TableSelection, HorizontalLineEditing ]
 			} )
 			.then( newEditor => {
 				editor = newEditor;
 				model = editor.model;
-
-				defaultSchema( model.schema );
-				defaultConversion( editor.conversion );
 			} );
 	} );
 
@@ -168,18 +167,34 @@ describe( 'InsertColumnCommand', () => {
 			} );
 
 			it( 'should skip wide spanned columns', () => {
+				// +----+----+----+----+----+----+
+				// | 00 | 01 | 02 | 03 | 04 | 05 |
+				// +----+----+----+----+----+----+
+				// | 10 | 11 | 12      | 14 | 15 |
+				// +----+----+----+----+----+----+
+				// | 20                | 24      |
+				// +----+----+----+----+----+----+
+				//                     ^-- heading columns
 				setData( model, modelTable( [
-					[ '11', '12[]', '13', '14', '15' ],
-					[ '21', '22', { colspan: 2, contents: '23' }, '25' ],
-					[ { colspan: 4, contents: '31' }, { colspan: 2, contents: '34' } ]
+					[ '00', '01[]', '02', '03', '04', '05' ],
+					[ '10', '11', { contents: '12', colspan: 2 }, '14', '15' ],
+					[ { contents: '20', colspan: 4 }, { contents: '24', colspan: 2 } ]
 				], { headingColumns: 4 } ) );
 
 				command.execute();
 
+				// +----+----+----+----+----+----+----+
+				// | 00 | 01 |    | 02 | 03 | 04 | 05 |
+				// +----+----+----+----+----+----+----+
+				// | 10 | 11 |    | 12      | 14 | 15 |
+				// +----+----+----+----+----+----+----+
+				// | 20                     | 24      |
+				// +----+----+----+----+----+----+----+
+				//                          ^-- heading columns
 				assertEqualMarkup( getData( model ), modelTable( [
-					[ '11', '12[]', '', '13', '14', '15' ],
-					[ '21', '22', '', { colspan: 2, contents: '23' }, '25' ],
-					[ { colspan: 5, contents: '31' }, { colspan: 2, contents: '34' } ]
+					[ '00', '01[]', '', '02', '03', '04', '05' ],
+					[ '10', '11', '', { contents: '12', colspan: 2 }, '14', '15' ],
+					[ { contents: '20', colspan: 5 }, { contents: '24', colspan: 2 } ]
 				], { headingColumns: 5 } ) );
 			} );
 
@@ -335,18 +350,34 @@ describe( 'InsertColumnCommand', () => {
 			} );
 
 			it( 'should skip wide spanned columns', () => {
+				// +----+----+----+----+----+----+
+				// | 00 | 01 | 02 | 03 | 04 | 05 |
+				// +----+----+----+----+----+----+
+				// | 10 | 11 | 12      | 14 | 15 |
+				// +----+----+----+----+----+----+
+				// | 20                | 24      |
+				// +----+----+----+----+----+----+
+				//                     ^-- heading columns
 				setData( model, modelTable( [
-					[ '11', '12', '13[]', '14', '15' ],
-					[ '21', '22', { colspan: 2, contents: '23' }, '25' ],
-					[ { colspan: 4, contents: '31' }, { colspan: 2, contents: '34' } ]
+					[ '00', '01', '[]02', '03', '04', '05' ],
+					[ '10', '11', { contents: '12', colspan: 2 }, '14', '15' ],
+					[ { contents: '20', colspan: 4 }, { contents: '24', colspan: 2 } ]
 				], { headingColumns: 4 } ) );
 
 				command.execute();
 
+				// +----+----+----+----+----+----+----+
+				// | 00 | 01 |    | 02 | 03 | 04 | 05 |
+				// +----+----+----+----+----+----+----+
+				// | 10 | 11 |    | 12      | 14 | 15 |
+				// +----+----+----+----+----+----+----+
+				// | 20                     | 24      |
+				// +----+----+----+----+----+----+----+
+				//                          ^-- heading columns
 				assertEqualMarkup( getData( model ), modelTable( [
-					[ '11', '12', '', '13[]', '14', '15' ],
-					[ '21', '22', '', { colspan: 2, contents: '23' }, '25' ],
-					[ { colspan: 5, contents: '31' }, { colspan: 2, contents: '34' } ]
+					[ '00', '01', '', '[]02', '03', '04', '05' ],
+					[ '10', '11', '', { contents: '12', colspan: 2 }, '14', '15' ],
+					[ { contents: '20', colspan: 5 }, { contents: '24', colspan: 2 } ]
 				], { headingColumns: 5 } ) );
 			} );
 		} );

+ 71 - 22
packages/ckeditor5-table/tests/commands/insertrowcommand.js

@@ -5,13 +5,15 @@
 
 import ModelTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/modeltesteditor';
 import HorizontalLineEditing from '@ckeditor/ckeditor5-horizontal-line/src/horizontallineediting';
+import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
 import { getData, setData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
+import { assertEqualMarkup } from '@ckeditor/ckeditor5-utils/tests/_utils/utils';
 
-import InsertRowCommand from '../../src/commands/insertrowcommand';
+import TableEditing from '../../src/tableediting';
 import TableSelection from '../../src/tableselection';
-import { assertSelectedCells, defaultConversion, defaultSchema, modelTable } from '../_utils/utils';
-import TableUtils from '../../src/tableutils';
-import { assertEqualMarkup } from '@ckeditor/ckeditor5-utils/tests/_utils/utils';
+import { assertSelectedCells, modelTable } from '../_utils/utils';
+
+import InsertRowCommand from '../../src/commands/insertrowcommand';
 
 describe( 'InsertRowCommand', () => {
 	let editor, model, command;
@@ -19,14 +21,11 @@ describe( 'InsertRowCommand', () => {
 	beforeEach( () => {
 		return ModelTestEditor
 			.create( {
-				plugins: [ TableUtils, TableSelection, HorizontalLineEditing ]
+				plugins: [ Paragraph, TableEditing, TableSelection, HorizontalLineEditing ]
 			} )
 			.then( newEditor => {
 				editor = newEditor;
 				model = editor.model;
-
-				defaultSchema( model.schema );
-				defaultConversion( editor.conversion );
 			} );
 	} );
 
@@ -119,54 +118,104 @@ describe( 'InsertRowCommand', () => {
 			} );
 
 			it( 'should expand rowspan of a cell that overlaps inserted rows', () => {
+				// +----+----+----+----+
+				// | 00      | 02 | 03 |
+				// +----+----+----+----+ <-- heading rows
+				// | 10      | 12 | 13 |
+				// +         +----+----+
+				// |         | 22 | 23 |
+				// +----+----+----+----+
+				//                     ^-- heading columns
 				setData( model, modelTable( [
-					[ { colspan: 2, contents: '00' }, '02', '03' ],
-					[ { colspan: 2, rowspan: 4, contents: '10[]' }, '12', '13' ],
+					[ { contents: '00', colspan: 2 }, '02', '03' ],
+					[ { contents: '10[]', colspan: 2, rowspan: 2 }, '12', '13' ],
 					[ '22', '23' ]
 				], { headingColumns: 3, headingRows: 1 } ) );
 
 				command.execute();
 
+				// +----+----+----+----+
+				// | 00      | 02 | 03 |
+				// +----+----+----+----+ <-- heading rows
+				// | 10      | 12 | 13 |
+				// +         +----+----+
+				// |         |    |    |
+				// +         +----+----+
+				// |         | 22 | 23 |
+				// +----+----+----+----+
+				//                     ^-- heading columns
 				assertEqualMarkup( getData( model ), modelTable( [
-					[ { colspan: 2, contents: '00' }, '02', '03' ],
-					[ { colspan: 2, rowspan: 5, contents: '10[]' }, '12', '13' ],
+					[ { contents: '00', colspan: 2 }, '02', '03' ],
+					[ { contents: '10[]', colspan: 2, rowspan: 3 }, '12', '13' ],
 					[ '', '' ],
 					[ '22', '23' ]
 				], { headingColumns: 3, headingRows: 1 } ) );
 			} );
 
 			it( 'should not expand rowspan of a cell that does not overlaps inserted rows', () => {
+				// +----+----+----+
+				// | 00 | 01 | 02 |
+				// +    +----+----+
+				// |    | 11 | 12 |
+				// +----+----+----+ <-- heading rows
+				// | 20 | 21 | 22 |
+				// +----+----+----+
 				setData( model, modelTable( [
-					[ { rowspan: 2, contents: '00' }, '01', '02' ],
+					[ { contents: '00', rowspan: 2 }, '01', '02' ],
 					[ '11[]', '12' ],
 					[ '20', '21', '22' ]
-				], { headingColumns: 3, headingRows: 1 } ) );
+				], { headingRows: 2 } ) );
 
 				command.execute();
 
+				// +----+----+----+
+				// | 00 | 01 | 02 |
+				// +    +----+----+
+				// |    | 11 | 12 |
+				// +----+----+----+ <-- heading rows
+				// |    |    |    |
+				// +----+----+----+
+				// | 20 | 21 | 22 |
+				// +----+----+----+
 				assertEqualMarkup( getData( model ), modelTable( [
-					[ { rowspan: 2, contents: '00' }, '01', '02' ],
+					[ { contents: '00', rowspan: 2 }, '01', '02' ],
 					[ '11[]', '12' ],
 					[ '', '', '' ],
 					[ '20', '21', '22' ]
-				], { headingColumns: 3, headingRows: 1 } ) );
+				], { headingRows: 2 } ) );
 			} );
 
 			it( 'should properly calculate columns if next row has colspans', () => {
+				// +----+----+----+
+				// | 00 | 01 | 02 |
+				// +    +----+----+
+				// |    | 11 | 12 |
+				// +----+----+----+ <-- heading rows
+				// | 20           |
+				// +----+----+----+
 				setData( model, modelTable( [
-					[ { rowspan: 2, contents: '00' }, '01', '02' ],
+					[ { contents: '00', rowspan: 2 }, '01', '02' ],
 					[ '11[]', '12' ],
-					[ { colspan: 3, contents: '20' } ]
-				], { headingColumns: 3, headingRows: 1 } ) );
+					[ { contents: '20', colspan: 3 } ]
+				], { headingRows: 2 } ) );
 
 				command.execute();
 
+				// +----+----+----+
+				// | 00 | 01 | 02 |
+				// +    +----+----+
+				// |    | 11 | 12 |
+				// +----+----+----+ <-- heading rows
+				// |    |    |    |
+				// +----+----+----+
+				// | 20           |
+				// +----+----+----+
 				assertEqualMarkup( getData( model ), modelTable( [
-					[ { rowspan: 2, contents: '00' }, '01', '02' ],
+					[ { contents: '00', rowspan: 2 }, '01', '02' ],
 					[ '11[]', '12' ],
 					[ '', '', '' ],
-					[ { colspan: 3, contents: '20' } ]
-				], { headingColumns: 3, headingRows: 1 } ) );
+					[ { contents: '20', colspan: 3 } ]
+				], { headingRows: 2 } ) );
 			} );
 
 			it( 'should insert rows at the end of a table', () => {

+ 6 - 8
packages/ckeditor5-table/tests/commands/inserttablecommand.js

@@ -4,13 +4,14 @@
  */
 
 import ModelTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/modeltesteditor';
+import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
 import { getData, setData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
+import { assertEqualMarkup } from '@ckeditor/ckeditor5-utils/tests/_utils/utils';
 
-import InsertTableCommand from '../../src/commands/inserttablecommand';
-import TableUtils from '../../src/tableutils';
+import TableEditing from '../../src/tableediting';
+import { modelTable } from '../_utils/utils';
 
-import { defaultConversion, defaultSchema, modelTable } from '../_utils/utils';
-import { assertEqualMarkup } from '@ckeditor/ckeditor5-utils/tests/_utils/utils';
+import InsertTableCommand from '../../src/commands/inserttablecommand';
 
 describe( 'InsertTableCommand', () => {
 	let editor, model, command;
@@ -18,15 +19,12 @@ describe( 'InsertTableCommand', () => {
 	beforeEach( () => {
 		return ModelTestEditor
 			.create( {
-				plugins: [ TableUtils ]
+				plugins: [ Paragraph, TableEditing ]
 			} )
 			.then( newEditor => {
 				editor = newEditor;
 				model = editor.model;
 				command = new InsertTableCommand( editor );
-
-				defaultSchema( model.schema );
-				defaultConversion( editor.conversion );
 			} );
 	} );
 

+ 18 - 11
packages/ckeditor5-table/tests/commands/mergecellcommand.js

@@ -4,12 +4,14 @@
  */
 
 import ModelTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/modeltesteditor';
+import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
 import { getData, setData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
+import { assertEqualMarkup } from '@ckeditor/ckeditor5-utils/tests/_utils/utils';
+
+import TableEditing from '../../src/tableediting';
+import { modelTable } from '../_utils/utils';
 
 import MergeCellCommand from '../../src/commands/mergecellcommand';
-import { defaultConversion, defaultSchema, modelTable } from '../_utils/utils';
-import TableUtils from '../../src/tableutils';
-import { assertEqualMarkup } from '@ckeditor/ckeditor5-utils/tests/_utils/utils';
 
 describe( 'MergeCellCommand', () => {
 	let editor, model, command, root;
@@ -17,15 +19,12 @@ describe( 'MergeCellCommand', () => {
 	beforeEach( () => {
 		return ModelTestEditor
 			.create( {
-				plugins: [ TableUtils ]
+				plugins: [ Paragraph, TableEditing ]
 			} )
 			.then( newEditor => {
 				editor = newEditor;
 				model = editor.model;
 				root = model.document.getRoot( 'main' );
-
-				defaultSchema( model.schema );
-				defaultConversion( editor.conversion );
 			} );
 	} );
 
@@ -65,7 +64,9 @@ describe( 'MergeCellCommand', () => {
 
 			it( 'should be false if in a cell that has sibling but with different rowspan', () => {
 				setData( model, modelTable( [
-					[ { rowspan: 2, contents: '00[]' }, { rowspan: 3, contents: '01' } ]
+					[ { rowspan: 2, contents: '00[]' }, { rowspan: 3, contents: '01' }, '02' ],
+					[ '12' ],
+					[ '20', '22' ]
 				] ) );
 
 				expect( command.isEnabled ).to.be.false;
@@ -173,7 +174,9 @@ describe( 'MergeCellCommand', () => {
 
 			it( 'should be undefined if in a cell that has sibling but with different rowspan', () => {
 				setData( model, modelTable( [
-					[ { rowspan: 2, contents: '00[]' }, { rowspan: 3, contents: '01' } ]
+					[ { rowspan: 2, contents: '00[]' }, { rowspan: 3, contents: '01' }, '02' ],
+					[ '12' ],
+					[ '20', '22' ]
 				] ) );
 
 				expect( command.value ).to.be.undefined;
@@ -309,7 +312,9 @@ describe( 'MergeCellCommand', () => {
 
 			it( 'should be false if in a cell that has sibling but with different rowspan', () => {
 				setData( model, modelTable( [
-					[ { rowspan: 2, contents: '00' }, { rowspan: 3, contents: '01[]' } ]
+					[ { rowspan: 2, contents: '00' }, { rowspan: 3, contents: '01[]' } ],
+					[ '12' ],
+					[ '20', '22' ]
 				] ) );
 
 				expect( command.isEnabled ).to.be.false;
@@ -409,7 +414,9 @@ describe( 'MergeCellCommand', () => {
 
 			it( 'should be undefined if in a cell that has sibling but with different rowspan', () => {
 				setData( model, modelTable( [
-					[ { rowspan: 2, contents: '00' }, { rowspan: 3, contents: '01[]' } ]
+					[ { rowspan: 2, contents: '00' }, { rowspan: 3, contents: '01[]' } ],
+					[ '12' ],
+					[ '20', '22' ]
 				] ) );
 
 				expect( command.value ).to.be.undefined;

+ 7 - 8
packages/ckeditor5-table/tests/commands/removecolumncommand.js

@@ -4,13 +4,15 @@
  */
 
 import ModelTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/modeltesteditor';
+import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
 import { getData, setData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
+import { assertEqualMarkup } from '@ckeditor/ckeditor5-utils/tests/_utils/utils';
 
-import RemoveColumnCommand from '../../src/commands/removecolumncommand';
+import TableEditing from '../../src/tableediting';
 import TableSelection from '../../src/tableselection';
-import { defaultConversion, defaultSchema, modelTable } from '../_utils/utils';
-import TableUtils from '../../src/tableutils';
-import { assertEqualMarkup } from '@ckeditor/ckeditor5-utils/tests/_utils/utils';
+import { modelTable } from '../_utils/utils';
+
+import RemoveColumnCommand from '../../src/commands/removecolumncommand';
 
 describe( 'RemoveColumnCommand', () => {
 	let editor, model, command;
@@ -18,15 +20,12 @@ describe( 'RemoveColumnCommand', () => {
 	beforeEach( () => {
 		return ModelTestEditor
 			.create( {
-				plugins: [ TableUtils, TableSelection ]
+				plugins: [ Paragraph, TableEditing, TableSelection ]
 			} )
 			.then( newEditor => {
 				editor = newEditor;
 				model = editor.model;
 				command = new RemoveColumnCommand( editor );
-
-				defaultSchema( model.schema );
-				defaultConversion( editor.conversion );
 			} );
 	} );
 

+ 6 - 6
packages/ckeditor5-table/tests/commands/selectcolumncommand.js

@@ -4,26 +4,26 @@
  */
 
 import VirtualTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/virtualtesteditor';
+import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
 import { setData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
 
-import SelectColumnCommand from '../../src/commands/selectcolumncommand';
+import TableEditing from '../../src/tableediting';
 import TableSelection from '../../src/tableselection';
-import { assertSelectedCells, defaultConversion, defaultSchema, modelTable } from '../_utils/utils';
+import { assertSelectedCells, modelTable } from '../_utils/utils';
+
+import SelectColumnCommand from '../../src/commands/selectcolumncommand';
 
 describe( 'SelectColumnCommand', () => {
 	let editor, model, modelRoot, command, tableSelection;
 
 	beforeEach( () => {
-		return VirtualTestEditor.create( { plugins: [ TableSelection ] } )
+		return VirtualTestEditor.create( { plugins: [ Paragraph, TableEditing, TableSelection ] } )
 			.then( newEditor => {
 				editor = newEditor;
 				model = editor.model;
 				modelRoot = model.document.getRoot();
 				command = new SelectColumnCommand( editor );
 				tableSelection = editor.plugins.get( TableSelection );
-
-				defaultSchema( model.schema );
-				defaultConversion( editor.conversion );
 			} );
 	} );
 

+ 6 - 6
packages/ckeditor5-table/tests/commands/selectrowcommand.js

@@ -4,26 +4,26 @@
  */
 
 import VirtualTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/virtualtesteditor';
+import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
 import { setData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
 
-import SelectRowCommand from '../../src/commands/selectrowcommand';
+import TableEditing from '../../src/tableediting';
 import TableSelection from '../../src/tableselection';
-import { assertSelectedCells, defaultConversion, defaultSchema, modelTable } from '../_utils/utils';
+import { assertSelectedCells, modelTable } from '../_utils/utils';
+
+import SelectRowCommand from '../../src/commands/selectrowcommand';
 
 describe( 'SelectRowCommand', () => {
 	let editor, model, modelRoot, command, tableSelection;
 
 	beforeEach( () => {
-		return VirtualTestEditor.create( { plugins: [ TableSelection ] } )
+		return VirtualTestEditor.create( { plugins: [ Paragraph, TableEditing, TableSelection ] } )
 			.then( newEditor => {
 				editor = newEditor;
 				model = editor.model;
 				modelRoot = model.document.getRoot();
 				command = new SelectRowCommand( editor );
 				tableSelection = editor.plugins.get( TableSelection );
-
-				defaultSchema( model.schema );
-				defaultConversion( editor.conversion );
 			} );
 	} );
 

+ 7 - 8
packages/ckeditor5-table/tests/commands/setheadercolumncommand.js

@@ -4,13 +4,15 @@
  */
 
 import ModelTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/modeltesteditor';
+import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
 import { getData, setData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
+import { assertEqualMarkup } from '@ckeditor/ckeditor5-utils/tests/_utils/utils';
 
-import SetHeaderColumnCommand from '../../src/commands/setheadercolumncommand';
-import { assertSelectedCells, defaultConversion, defaultSchema, modelTable } from '../_utils/utils';
 import TableSelection from '../../src/tableselection';
-import TableUtils from '../../src/tableutils';
-import { assertEqualMarkup } from '@ckeditor/ckeditor5-utils/tests/_utils/utils';
+import TableEditing from '../../src/tableediting';
+import { assertSelectedCells, modelTable } from '../_utils/utils';
+
+import SetHeaderColumnCommand from '../../src/commands/setheadercolumncommand';
 
 describe( 'SetHeaderColumnCommand', () => {
 	let editor, model, command;
@@ -18,15 +20,12 @@ describe( 'SetHeaderColumnCommand', () => {
 	beforeEach( () => {
 		return ModelTestEditor
 			.create( {
-				plugins: [ TableUtils, TableSelection ]
+				plugins: [ Paragraph, TableEditing, TableSelection ]
 			} )
 			.then( newEditor => {
 				editor = newEditor;
 				model = editor.model;
 				command = new SetHeaderColumnCommand( editor );
-
-				defaultSchema( model.schema );
-				defaultConversion( editor.conversion );
 			} );
 	} );
 

+ 8 - 8
packages/ckeditor5-table/tests/commands/setheaderrowcommand.js

@@ -4,28 +4,28 @@
  */
 
 import ModelTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/modeltesteditor';
+import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
 import { getData, setData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
-import SetHeaderRowCommand from '../../src/commands/setheaderrowcommand';
-import { assertSelectedCells, defaultConversion, defaultSchema, modelTable } from '../_utils/utils';
-import TableSelection from '../../src/tableselection';
-import TableUtils from '../../src/tableutils';
 import { assertEqualMarkup } from '@ckeditor/ckeditor5-utils/tests/_utils/utils';
 
+import TableEditing from '../../src/tableediting';
+import TableSelection from '../../src/tableselection';
+import { assertSelectedCells, modelTable } from '../_utils/utils';
+
+import SetHeaderRowCommand from '../../src/commands/setheaderrowcommand';
+
 describe( 'SetHeaderRowCommand', () => {
 	let editor, model, command;
 
 	beforeEach( () => {
 		return ModelTestEditor
 			.create( {
-				plugins: [ TableUtils, TableSelection ]
+				plugins: [ Paragraph, TableEditing, TableSelection ]
 			} )
 			.then( newEditor => {
 				editor = newEditor;
 				model = editor.model;
 				command = new SetHeaderRowCommand( editor );
-
-				defaultSchema( model.schema );
-				defaultConversion( editor.conversion );
 			} );
 	} );
 

+ 7 - 8
packages/ckeditor5-table/tests/commands/splitcellcommand.js

@@ -4,13 +4,15 @@
  */
 
 import ModelTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/modeltesteditor';
+import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
 import { getData, setData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
+import { assertEqualMarkup } from '@ckeditor/ckeditor5-utils/tests/_utils/utils';
 
-import SplitCellCommand from '../../src/commands/splitcellcommand';
-import { defaultConversion, defaultSchema, modelTable } from '../_utils/utils';
+import TableEditing from '../../src/tableediting';
 import TableSelection from '../../src/tableselection';
-import TableUtils from '../../src/tableutils';
-import { assertEqualMarkup } from '@ckeditor/ckeditor5-utils/tests/_utils/utils';
+import { modelTable } from '../_utils/utils';
+
+import SplitCellCommand from '../../src/commands/splitcellcommand';
 
 describe( 'SplitCellCommand', () => {
 	let editor, model, command;
@@ -18,15 +20,12 @@ describe( 'SplitCellCommand', () => {
 	beforeEach( () => {
 		return ModelTestEditor
 			.create( {
-				plugins: [ TableUtils, TableSelection ]
+				plugins: [ Paragraph, TableEditing, TableSelection ]
 			} )
 			.then( newEditor => {
 				editor = newEditor;
 				model = editor.model;
 				command = new SplitCellCommand( editor );
-
-				defaultSchema( model.schema );
-				defaultConversion( editor.conversion );
 			} );
 	} );
 

Разница между файлами не показана из-за своего большого размера
+ 353 - 533
packages/ckeditor5-table/tests/converters/downcast.js


+ 7 - 12
packages/ckeditor5-table/tests/converters/table-cell-refresh-post-fixer.js

@@ -5,16 +5,15 @@
 
 /* globals document */
 
-import { getData as getViewData } from '@ckeditor/ckeditor5-engine/src/dev-utils/view';
-
-import { defaultConversion, defaultSchema, viewTable } from '../_utils/utils';
-import injectTableCellRefreshPostFixer from '../../src/converters/table-cell-refresh-post-fixer';
-
-import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils';
 import ClassicTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/classictesteditor';
-
 import Delete from '@ckeditor/ckeditor5-typing/src/delete';
+import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
+import { getData as getViewData } from '@ckeditor/ckeditor5-engine/src/dev-utils/view';
 import { assertEqualMarkup } from '@ckeditor/ckeditor5-utils/tests/_utils/utils';
+import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils';
+
+import TableEditing from '../../src/tableediting';
+import { viewTable } from '../_utils/utils';
 
 describe( 'Table cell refresh post-fixer', () => {
 	let editor, model, doc, root, view, refreshItemSpy, element;
@@ -25,7 +24,7 @@ describe( 'Table cell refresh post-fixer', () => {
 		element = document.createElement( 'div' );
 		document.body.appendChild( element );
 
-		return ClassicTestEditor.create( element, { extraPlugins: [ Delete ] } )
+		return ClassicTestEditor.create( element, { plugins: [ Paragraph, TableEditing, Delete ] } )
 			.then( newEditor => {
 				editor = newEditor;
 				model = editor.model;
@@ -33,9 +32,6 @@ describe( 'Table cell refresh post-fixer', () => {
 				root = doc.getRoot( 'main' );
 				view = editor.editing.view;
 
-				defaultSchema( model.schema );
-				defaultConversion( editor.conversion, true );
-
 				editor.model.schema.register( 'block', {
 					inheritAllFrom: '$block'
 				} );
@@ -45,7 +41,6 @@ describe( 'Table cell refresh post-fixer', () => {
 				editor.conversion.attributeToAttribute( { model: 'foo', view: 'foo' } );
 				editor.conversion.attributeToAttribute( { model: 'bar', view: 'bar' } );
 
-				injectTableCellRefreshPostFixer( model );
 				refreshItemSpy = sinon.spy( model.document.differ, 'refreshItem' );
 			} );
 	} );

+ 123 - 171
packages/ckeditor5-table/tests/tableutils.js

@@ -4,57 +4,40 @@
  */
 
 import ModelTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/modeltesteditor';
+import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
 import { getData, setData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
-
-import { defaultConversion, defaultSchema, modelTable } from './_utils/utils';
+import { assertEqualMarkup } from '@ckeditor/ckeditor5-utils/tests/_utils/utils';
 
 import TableEditing from '../src/tableediting';
+import { modelTable } from './_utils/utils';
+
 import TableUtils from '../src/tableutils';
-import { assertEqualMarkup } from '@ckeditor/ckeditor5-utils/tests/_utils/utils';
-import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
 
 describe( 'TableUtils', () => {
 	let editor, model, root, tableUtils;
 
+	beforeEach( () => {
+		return ModelTestEditor.create( {
+			plugins: [ Paragraph, TableEditing, TableUtils ]
+		} ).then( newEditor => {
+			editor = newEditor;
+			model = editor.model;
+			root = model.document.getRoot( 'main' );
+			tableUtils = editor.plugins.get( TableUtils );
+		} );
+	} );
+
 	afterEach( () => {
 		return editor.destroy();
 	} );
 
 	describe( '#pluginName', () => {
-		beforeEach( () => {
-			return ModelTestEditor.create( {
-				plugins: [ TableUtils ]
-			} ).then( newEditor => {
-				editor = newEditor;
-				model = editor.model;
-				root = model.document.getRoot( 'main' );
-				tableUtils = editor.plugins.get( TableUtils );
-
-				defaultSchema( model.schema );
-				defaultConversion( editor.conversion );
-			} );
-		} );
-
 		it( 'should provide plugin name', () => {
 			expect( TableUtils.pluginName ).to.equal( 'TableUtils' );
 		} );
 	} );
 
 	describe( 'getCellLocation()', () => {
-		beforeEach( () => {
-			return ModelTestEditor.create( {
-				plugins: [ TableUtils ]
-			} ).then( newEditor => {
-				editor = newEditor;
-				model = editor.model;
-				root = model.document.getRoot( 'main' );
-				tableUtils = editor.plugins.get( TableUtils );
-
-				defaultSchema( model.schema );
-				defaultConversion( editor.conversion );
-			} );
-		} );
-
 		it( 'should return proper table cell location', () => {
 			setData( model, modelTable( [
 				[ { rowspan: 2, colspan: 2, contents: '00[]' }, '02' ],
@@ -68,20 +51,6 @@ describe( 'TableUtils', () => {
 	} );
 
 	describe( 'insertRows()', () => {
-		beforeEach( () => {
-			return ModelTestEditor.create( {
-				plugins: [ TableUtils ]
-			} ).then( newEditor => {
-				editor = newEditor;
-				model = editor.model;
-				root = model.document.getRoot( 'main' );
-				tableUtils = editor.plugins.get( TableUtils );
-
-				defaultSchema( model.schema );
-				defaultConversion( editor.conversion );
-			} );
-		} );
-
 		it( 'should insert row in given table at given index', () => {
 			setData( model, modelTable( [
 				[ '11[]', '12' ],
@@ -147,60 +116,122 @@ describe( 'TableUtils', () => {
 		} );
 
 		it( 'should expand rowspan of a cell that overlaps inserted rows', () => {
+			// +----+----+----+----+
+			// | 00      | 02 | 03 |
+			// +----+----+----+----+ <-- heading rows
+			// | 10      | 12 | 13 |
+			// +         +----+----+
+			// |         | 22 | 23 |
+			// +----+----+----+----+
+			//                     ^-- heading columns
 			setData( model, modelTable( [
-				[ { colspan: 2, contents: '11[]' }, '13', '14' ],
-				[ { colspan: 2, rowspan: 4, contents: '21' }, '23', '24' ],
-				[ '33', '34' ]
+				[ { contents: '00', colspan: 2 }, '02', '03' ],
+				[ { contents: '10[]', colspan: 2, rowspan: 2 }, '12', '13' ],
+				[ '22', '23' ]
 			], { headingColumns: 3, headingRows: 1 } ) );
 
 			tableUtils.insertRows( root.getNodeByPath( [ 0 ] ), { at: 2, rows: 3 } );
 
+			// +----+----+----+----+
+			// | 00      | 02 | 03 |
+			// +----+----+----+----+ <-- heading rows
+			// | 10      | 12 | 13 |
+			// +         +----+----+
+			// |         |    |    |
+			// +         +----+----+
+			// |         |    |    |
+			// +         +----+----+
+			// |         |    |    |
+			// +         +----+----+
+			// |         | 22 | 23 |
+			// +----+----+----+----+
+			//                     ^-- heading columns
 			assertEqualMarkup( getData( model ), modelTable( [
-				[ { colspan: 2, contents: '11[]' }, '13', '14' ],
-				[ { colspan: 2, rowspan: 7, contents: '21' }, '23', '24' ],
+				[ { contents: '00', colspan: 2 }, '02', '03' ],
+				[ { contents: '10[]', colspan: 2, rowspan: 5 }, '12', '13' ],
 				[ '', '' ],
 				[ '', '' ],
 				[ '', '' ],
-				[ '33', '34' ]
+				[ '22', '23' ]
 			], { headingColumns: 3, headingRows: 1 } ) );
 		} );
 
 		it( 'should not expand rowspan of a cell that does not overlaps inserted rows', () => {
+			// +----+----+----+
+			// | 00 | 01 | 02 |
+			// +    +----+----+
+			// |    | 11 | 12 |
+			// +----+----+----+ <-- heading rows
+			// | 20 | 21 | 22 |
+			// +----+----+----+
 			setData( model, modelTable( [
-				[ { rowspan: 2, contents: '11[]' }, '12', '13' ],
-				[ '22', '23' ],
-				[ '31', '32', '33' ]
-			], { headingColumns: 3, headingRows: 1 } ) );
+				[ { contents: '00', rowspan: 2 }, '01', '02' ],
+				[ '11[]', '12' ],
+				[ '20', '21', '22' ]
+			], { headingRows: 2 } ) );
 
 			tableUtils.insertRows( root.getNodeByPath( [ 0 ] ), { at: 2, rows: 3 } );
 
+			// +----+----+----+
+			// | 00 | 01 | 02 |
+			// +    +----+----+
+			// |    | 11 | 12 |
+			// +----+----+----+ <-- heading rows
+			// |    |    |    |
+			// +----+----+----+
+			// |    |    |    |
+			// +----+----+----+
+			// |    |    |    |
+			// +----+----+----+
+			// | 20 | 21 | 22 |
+			// +----+----+----+
 			assertEqualMarkup( getData( model ), modelTable( [
-				[ { rowspan: 2, contents: '11[]' }, '12', '13' ],
-				[ '22', '23' ],
+				[ { contents: '00', rowspan: 2 }, '01', '02' ],
+				[ '11[]', '12' ],
 				[ '', '', '' ],
 				[ '', '', '' ],
 				[ '', '', '' ],
-				[ '31', '32', '33' ]
-			], { headingColumns: 3, headingRows: 1 } ) );
+				[ '20', '21', '22' ]
+			], { headingRows: 2 } ) );
 		} );
 
 		it( 'should properly calculate columns if next row has colspans', () => {
+			// +----+----+----+
+			// | 00 | 01 | 02 |
+			// +    +----+----+
+			// |    | 11 | 12 |
+			// +----+----+----+ <-- heading rows
+			// | 20           |
+			// +----+----+----+
 			setData( model, modelTable( [
-				[ { rowspan: 2, contents: '11[]' }, '12', '13' ],
-				[ '22', '23' ],
-				[ { colspan: 3, contents: '31' } ]
-			], { headingColumns: 3, headingRows: 1 } ) );
+				[ { contents: '00', rowspan: 2 }, '01', '02' ],
+				[ '11[]', '12' ],
+				[ { contents: '20', colspan: 3 } ]
+			], { headingRows: 2 } ) );
 
 			tableUtils.insertRows( root.getNodeByPath( [ 0 ] ), { at: 2, rows: 3 } );
 
+			// +----+----+----+
+			// | 00 | 01 | 02 |
+			// +    +----+----+
+			// |    | 11 | 12 |
+			// +----+----+----+ <-- heading rows
+			// |    |    |    |
+			// +----+----+----+
+			// |    |    |    |
+			// +----+----+----+
+			// |    |    |    |
+			// +----+----+----+
+			// | 20           |
+			// +----+----+----+
 			assertEqualMarkup( getData( model ), modelTable( [
-				[ { rowspan: 2, contents: '11[]' }, '12', '13' ],
-				[ '22', '23' ],
+				[ { contents: '00', rowspan: 2 }, '01', '02' ],
+				[ '11[]', '12' ],
 				[ '', '', '' ],
 				[ '', '', '' ],
 				[ '', '', '' ],
-				[ { colspan: 3, contents: '31' } ]
-			], { headingColumns: 3, headingRows: 1 } ) );
+				[ { contents: '20', colspan: 3 } ]
+			], { headingRows: 2 } ) );
 		} );
 
 		it( 'should insert rows at the end of a table', () => {
@@ -304,20 +335,6 @@ describe( 'TableUtils', () => {
 	} );
 
 	describe( 'insertColumns()', () => {
-		beforeEach( () => {
-			return ModelTestEditor.create( {
-				plugins: [ TableUtils ]
-			} ).then( newEditor => {
-				editor = newEditor;
-				model = editor.model;
-				root = model.document.getRoot( 'main' );
-				tableUtils = editor.plugins.get( TableUtils );
-
-				defaultSchema( model.schema );
-				defaultConversion( editor.conversion );
-			} );
-		} );
-
 		it( 'should insert column in given table at given index', () => {
 			setData( model, modelTable( [
 				[ '11[]', '12' ],
@@ -465,18 +482,34 @@ describe( 'TableUtils', () => {
 		} );
 
 		it( 'should skip wide spanned columns', () => {
+			// +----+----+----+----+----+----+
+			// | 00 | 01 | 02 | 03 | 04 | 05 |
+			// +----+----+----+----+----+----+
+			// | 10 | 11 | 12      | 14 | 15 |
+			// +----+----+----+----+----+----+
+			// | 20                | 24      |
+			// +----+----+----+----+----+----+
+			//                     ^-- heading columns
 			setData( model, modelTable( [
-				[ '11[]', '12', '13', '14', '15' ],
-				[ '21', '22', { colspan: 2, contents: '23' }, '25' ],
-				[ { colspan: 4, contents: '31' }, { colspan: 2, contents: '34' } ]
+				[ '00', '01[]', '02', '03', '04', '05' ],
+				[ '10', '11', { contents: '12', colspan: 2 }, '14', '15' ],
+				[ { contents: '20', colspan: 4 }, { contents: '24', colspan: 2 } ]
 			], { headingColumns: 4 } ) );
 
 			tableUtils.insertColumns( root.getNodeByPath( [ 0 ] ), { at: 2, columns: 2 } );
 
+			// +----+----+----+----+----+----+----+----+
+			// | 00 | 01 |    |    | 02 | 03 | 04 | 05 |
+			// +----+----+----+----+----+----+----+----+
+			// | 10 | 11 |    |    | 12      | 14 | 15 |
+			// +----+----+----+----+----+----+----+----+
+			// | 20                          | 24      |
+			// +----+----+----+----+----+----+----+----+
+			//                               ^-- heading columns
 			assertEqualMarkup( getData( model ), modelTable( [
-				[ '11[]', '12', '', '', '13', '14', '15' ],
-				[ '21', '22', '', '', { colspan: 2, contents: '23' }, '25' ],
-				[ { colspan: 6, contents: '31' }, { colspan: 2, contents: '34' } ]
+				[ '00', '01[]', '', '', '02', '03', '04', '05' ],
+				[ '10', '11', '', '', { contents: '12', colspan: 2 }, '14', '15' ],
+				[ { contents: '20', colspan: 6 }, { contents: '24', colspan: 2 } ]
 			], { headingColumns: 6 } ) );
 		} );
 
@@ -523,20 +556,6 @@ describe( 'TableUtils', () => {
 	} );
 
 	describe( 'splitCellVertically()', () => {
-		beforeEach( () => {
-			return ModelTestEditor.create( {
-				plugins: [ TableUtils ]
-			} ).then( newEditor => {
-				editor = newEditor;
-				model = editor.model;
-				root = model.document.getRoot( 'main' );
-				tableUtils = editor.plugins.get( TableUtils );
-
-				defaultSchema( model.schema );
-				defaultConversion( editor.conversion );
-			} );
-		} );
-
 		it( 'should split table cell to given table cells number', () => {
 			setData( model, modelTable( [
 				[ '00', '01', '02' ],
@@ -681,20 +700,6 @@ describe( 'TableUtils', () => {
 	} );
 
 	describe( 'splitCellHorizontally()', () => {
-		beforeEach( () => {
-			return ModelTestEditor.create( {
-				plugins: [ TableUtils ]
-			} ).then( newEditor => {
-				editor = newEditor;
-				model = editor.model;
-				root = model.document.getRoot( 'main' );
-				tableUtils = editor.plugins.get( TableUtils );
-
-				defaultSchema( model.schema );
-				defaultConversion( editor.conversion );
-			} );
-		} );
-
 		it( 'should split table cell to default table cells number', () => {
 			setData( model, modelTable( [
 				[ '00', '01', '02' ],
@@ -835,7 +840,7 @@ describe( 'TableUtils', () => {
 		it( 'should split row-spanned & col-spanned cell', () => {
 			setData( model, modelTable( [
 				[ '00', { colspan: 2, contents: '01[]' } ],
-				[ '10', '11' ]
+				[ '10', '11', '12' ]
 			] ) );
 
 			const tableCell = root.getNodeByPath( [ 0, 0, 1 ] );
@@ -846,7 +851,7 @@ describe( 'TableUtils', () => {
 				[ { rowspan: 3, contents: '00' }, { colspan: 2, contents: '01[]' } ],
 				[ { colspan: 2, contents: '' } ],
 				[ { colspan: 2, contents: '' } ],
-				[ '10', '11' ]
+				[ '10', '11', '12' ]
 			] ) );
 		} );
 
@@ -870,20 +875,6 @@ describe( 'TableUtils', () => {
 	} );
 
 	describe( 'getColumns()', () => {
-		beforeEach( () => {
-			return ModelTestEditor.create( {
-				plugins: [ TableUtils ]
-			} ).then( newEditor => {
-				editor = newEditor;
-				model = editor.model;
-				root = model.document.getRoot( 'main' );
-				tableUtils = editor.plugins.get( TableUtils );
-
-				defaultSchema( model.schema );
-				defaultConversion( editor.conversion );
-			} );
-		} );
-
 		it( 'should return proper number of columns', () => {
 			setData( model, modelTable( [
 				[ '00', { colspan: 3, contents: '01' }, '04' ]
@@ -894,20 +885,6 @@ describe( 'TableUtils', () => {
 	} );
 
 	describe( 'getRows()', () => {
-		beforeEach( () => {
-			return ModelTestEditor.create( {
-				plugins: [ TableUtils ]
-			} ).then( newEditor => {
-				editor = newEditor;
-				model = editor.model;
-				root = model.document.getRoot( 'main' );
-				tableUtils = editor.plugins.get( TableUtils );
-
-				defaultSchema( model.schema );
-				defaultConversion( editor.conversion );
-			} );
-		} );
-
 		it( 'should return proper number of columns for simple table', () => {
 			setData( model, modelTable( [
 				[ '00', '01' ],
@@ -938,17 +915,6 @@ describe( 'TableUtils', () => {
 	} );
 
 	describe( 'removeRows()', () => {
-		beforeEach( () => {
-			return ModelTestEditor.create( {
-				plugins: [ Paragraph, TableEditing, TableUtils ]
-			} ).then( newEditor => {
-				editor = newEditor;
-				model = editor.model;
-				root = model.document.getRoot( 'main' );
-				tableUtils = editor.plugins.get( TableUtils );
-			} );
-		} );
-
 		describe( 'single row', () => {
 			it( 'should remove a given row from a table start', () => {
 				setData( model, modelTable( [
@@ -1289,20 +1255,6 @@ describe( 'TableUtils', () => {
 	} );
 
 	describe( 'removeColumns()', () => {
-		beforeEach( () => {
-			return ModelTestEditor.create( {
-				plugins: [ TableUtils ]
-			} ).then( newEditor => {
-				editor = newEditor;
-				model = editor.model;
-				root = model.document.getRoot( 'main' );
-				tableUtils = editor.plugins.get( TableUtils );
-
-				defaultSchema( model.schema );
-				defaultConversion( editor.conversion );
-			} );
-		} );
-
 		describe( 'single row', () => {
 			it( 'should remove a given column', () => {
 				setData( model, modelTable( [

+ 5 - 6
packages/ckeditor5-table/tests/tablewalker.js

@@ -3,11 +3,13 @@
  * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
  */
 
-import { setData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
 import ModelTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/modeltesteditor';
+import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
 import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
+import { setData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
 
-import { defaultConversion, defaultSchema, modelTable } from './_utils/utils';
+import TableEditing from '../src/tableediting';
+import { modelTable } from './_utils/utils';
 
 import TableWalker from '../src/tablewalker';
 
@@ -15,15 +17,12 @@ describe( 'TableWalker', () => {
 	let editor, model, doc, root;
 
 	beforeEach( () => {
-		return ModelTestEditor.create()
+		return ModelTestEditor.create( { plugins: [ Paragraph, TableEditing ] } )
 			.then( newEditor => {
 				editor = newEditor;
 				model = editor.model;
 				doc = model.document;
 				root = doc.getRoot( 'main' );
-
-				defaultSchema( model.schema );
-				defaultConversion( editor.conversion );
 			} );
 	} );
 

+ 7 - 7
packages/ckeditor5-table/tests/utils/common.js

@@ -4,9 +4,12 @@
  */
 
 import ModelTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/modeltesteditor';
-import TableUtils from '../../src/tableutils';
+import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
 import { setData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
-import { defaultConversion, defaultSchema, modelTable } from '../_utils/utils';
+
+import TableEditing from '../../src/tableediting';
+import { modelTable } from '../_utils/utils';
+
 import { findAncestor, isHeadingColumnCell } from '../../src/utils/common';
 
 describe( 'table utils', () => {
@@ -14,16 +17,13 @@ describe( 'table utils', () => {
 	beforeEach( () => {
 		return ModelTestEditor
 			.create( {
-				plugins: [ TableUtils ]
+				plugins: [ Paragraph, TableEditing ]
 			} )
 			.then( newEditor => {
 				editor = newEditor;
 				model = editor.model;
 				modelRoot = model.document.getRoot();
-				tableUtils = editor.plugins.get( TableUtils );
-
-				defaultSchema( model.schema );
-				defaultConversion( editor.conversion );
+				tableUtils = editor.plugins.get( 'TableUtils' );
 			} );
 	} );
 

+ 18 - 1
packages/ckeditor5-widget/src/widgettoolbarrepository.js

@@ -7,6 +7,8 @@
  * @module widget/widgettoolbarrepository
  */
 
+/* global console */
+
 import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
 import ContextualBalloon from '@ckeditor/ckeditor5-ui/src/panel/balloon/contextualballoon';
 import ToolbarView from '@ckeditor/ckeditor5-ui/src/toolbar/toolbarview';
@@ -15,7 +17,7 @@ import {
 	isWidget,
 	centeredBalloonPositionForLongWidgets
 } from './utils';
-import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
+import CKEditorError, { attachLinkToDocumentation } from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
 
 /**
  * Widget toolbar repository plugin. A central point for registering widget toolbars. This plugin handles the whole
@@ -124,6 +126,21 @@ export default class WidgetToolbarRepository extends Plugin {
 	 * @param {String} [options.balloonClassName='ck-toolbar-container'] CSS class for the widget balloon.
 	 */
 	register( toolbarId, { ariaLabel, items, getRelatedElement, balloonClassName = 'ck-toolbar-container' } ) {
+		// Trying to register a toolbar without any item.
+		if ( !items.length ) {
+			/**
+			 * When {@link #register} a new toolbar, you need to provide a non-empty array with
+			 * the items that will be inserted into the toolbar.
+			 *
+			 * @error widget-toolbar-no-items
+			 */
+			console.warn(
+				attachLinkToDocumentation( 'widget-toolbar-no-items: Trying to register a toolbar without items.' ), { toolbarId }
+			);
+
+			return;
+		}
+
 		const editor = this.editor;
 		const t = editor.t;
 		const toolbarView = new ToolbarView( editor.locale );

+ 4 - 1
packages/ckeditor5-widget/tests/widgetresize.js

@@ -561,7 +561,10 @@ describe( 'WidgetResize', () => {
 			.create( element, {
 				plugins: [
 					ArticlePluginSet, WidgetResize, simpleWidgetPlugin
-				]
+				],
+				image: {
+					toolbar: [ 'imageStyle:full', 'imageStyle:side' ]
+				}
 			} );
 	}
 

+ 4 - 1
packages/ckeditor5-widget/tests/widgetresize/resizer.js

@@ -24,7 +24,10 @@ describe( 'Resizer', () => {
 			.create( editorElement, {
 				plugins: [
 					ArticlePluginSet
-				]
+				],
+				image: {
+					toolbar: [ 'imageStyle:full', 'imageStyle:side' ]
+				}
 			} )
 			.then( newEditor => {
 				editor = newEditor;

+ 15 - 1
packages/ckeditor5-widget/tests/widgettoolbarrepository.js

@@ -3,7 +3,7 @@
  * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
  */
 
-/* global document */
+/* global document, console */
 
 import ClassicTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/classictesteditor';
 import BalloonEditor from '@ckeditor/ckeditor5-editor-balloon/src/ballooneditor';
@@ -126,6 +126,20 @@ describe( 'WidgetToolbarRepository', () => {
 
 			toolbarView.destroy();
 		} );
+
+		it( 'should not register a toolbar when passed an empty collection of the items', () => {
+			const consoleWarnStub = sinon.stub( console, 'warn' );
+
+			widgetToolbarRepository.register( 'fake', {
+				items: [],
+				getRelatedElement: () => null
+			} );
+
+			expect( widgetToolbarRepository._toolbarDefinitions.get( 'fake' ) ).to.be.undefined;
+
+			expect( consoleWarnStub.calledOnce ).to.equal( true );
+			expect( consoleWarnStub.firstCall.args[ 0 ] ).to.match( /^widget-toolbar-no-items:/ );
+		} );
 	} );
 
 	describe( 'integration tests', () => {

+ 4 - 1
packages/ckeditor5-widget/tests/widgettypearound/widgettypearound.js

@@ -29,7 +29,10 @@ describe( 'WidgetTypeAround', () => {
 				ArticlePluginSet, Widget,
 
 				blockWidgetPlugin, inlineWidgetPlugin
-			]
+			],
+			image: {
+				toolbar: [ 'imageStyle:full', 'imageStyle:side' ]
+			}
 		} );
 
 		editingView = editor.editing.view;

Некоторые файлы не были показаны из-за большого количества измененных файлов