Browse Source

Merge branch 'master' into i/7608-is-text

# Conflicts:
#	packages/ckeditor5-link/src/linkcommand.js
#	packages/ckeditor5-link/src/unlinkcommand.js
Kuba Niegowski 5 years ago
parent
commit
2f2de9d4ad

+ 5 - 15
packages/ckeditor5-font/src/documentcolorcollection.js

@@ -29,6 +29,10 @@ export default class DocumentColorCollection extends Collection {
 		 * @member {Boolean} #isEmpty
 		 */
 		this.set( 'isEmpty', true );
+
+		this.on( 'change', () => {
+			this.set( 'isEmpty', this.length === 0 );
+		} );
 	}
 
 	/**
@@ -44,6 +48,7 @@ export default class DocumentColorCollection extends Collection {
 	 * @param {Number} [index] The position of the item in the collection. The item
 	 * is pushed to the collection when `index` is not specified.
 	 * @fires add
+	 * @fires change
 	 */
 	add( item, index ) {
 		if ( this.find( element => element.color === item.color ) ) {
@@ -52,21 +57,6 @@ export default class DocumentColorCollection extends Collection {
 		}
 
 		super.add( item, index );
-
-		this.set( 'isEmpty', false );
-	}
-
-	/**
-	 * @inheritdoc
-	 */
-	remove( subject ) {
-		const ret = super.remove( subject );
-
-		if ( this.length === 0 ) {
-			this.set( 'isEmpty', true );
-		}
-
-		return ret;
 	}
 
 	/**

+ 23 - 2
packages/ckeditor5-link/src/linkcommand.js

@@ -12,6 +12,8 @@ import findLinkRange from './findlinkrange';
 import toMap from '@ckeditor/ckeditor5-utils/src/tomap';
 import Collection from '@ckeditor/ckeditor5-utils/src/collection';
 import first from '@ckeditor/ckeditor5-utils/src/first';
+import AutomaticDecorators from './utils/automaticdecorators';
+import { isImageAllowed } from './utils';
 
 /**
  * The link command. It is used by the {@link module:link/link~Link link feature}.
@@ -40,6 +42,15 @@ export default class LinkCommand extends Command {
 		 * @type {module:utils/collection~Collection}
 		 */
 		this.manualDecorators = new Collection();
+
+		/**
+		 * An instance of the helper that ties together all {@link module:link/link~LinkDecoratorAutomaticDefinition}
+		 * that are used by the {@glink features/link link} and the {@glink features/image#linking-images linking images} features.
+		 *
+		 * @readonly
+		 * @type {module:link/utils~AutomaticDecorators}
+		 */
+		this.automaticDecorators = new AutomaticDecorators();
 	}
 
 	/**
@@ -62,7 +73,7 @@ export default class LinkCommand extends Command {
 
 		// A check for the `LinkImage` plugin. If the selection contains an element, get values from the element.
 		// Currently the selection reads attributes from text nodes only. See #7429 and #7465.
-		if ( selectedElement && selectedElement.is( 'element', 'image' ) && model.schema.checkAttribute( 'image', 'linkHref' ) ) {
+		if ( isImageAllowed( selectedElement, model.schema ) ) {
 			this.value = selectedElement.getAttribute( 'linkHref' );
 			this.isEnabled = model.schema.checkAttribute( selectedElement, 'linkHref' );
 		} else {
@@ -248,7 +259,17 @@ export default class LinkCommand extends Command {
 	 * @returns {Boolean} The information whether a given decorator is currently present in the selection.
 	 */
 	_getDecoratorStateFromModel( decoratorName ) {
-		const doc = this.editor.model.document;
+		const model = this.editor.model;
+		const doc = model.document;
+
+		const selectedElement = first( doc.selection.getSelectedBlocks() );
+
+		// A check for the `LinkImage` plugin. If the selection contains an element, get values from the element.
+		// Currently the selection reads attributes from text nodes only. See #7429 and #7465.
+		if ( isImageAllowed( selectedElement, model.schema ) ) {
+			return selectedElement.getAttribute( decoratorName );
+		}
+
 		return doc.selection.getAttribute( decoratorName );
 	}
 

+ 4 - 2
packages/ckeditor5-link/src/linkediting.js

@@ -14,7 +14,6 @@ import Input from '@ckeditor/ckeditor5-typing/src/input';
 import Clipboard from '@ckeditor/ckeditor5-clipboard/src/clipboard';
 import LinkCommand from './linkcommand';
 import UnlinkCommand from './unlinkcommand';
-import AutomaticDecorators from './utils/automaticdecorators';
 import ManualDecorator from './utils/manualdecorator';
 import findLinkRange from './findlinkrange';
 import { createLinkElement, ensureSafeUrl, getLocalizedDecorators, normalizeDecorators } from './utils';
@@ -132,7 +131,10 @@ export default class LinkEditing extends Plugin {
 	 */
 	_enableAutomaticDecorators( automaticDecoratorDefinitions ) {
 		const editor = this.editor;
-		const automaticDecorators = new AutomaticDecorators();
+		// Store automatic decorators in the command instance as we do the same with manual decorators.
+		// Thanks to that, `LinkImageEditing` plugin can re-use the same definitions.
+		const command = editor.commands.get( 'link' );
+		const automaticDecorators = command.automaticDecorators;
 
 		// Adds a default decorator for external links.
 		if ( editor.config.get( 'link.addTargetToExternalLinks' ) ) {

+ 115 - 12
packages/ckeditor5-link/src/linkimageediting.js

@@ -9,6 +9,8 @@
 
 import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
 import ImageEditing from '@ckeditor/ckeditor5-image/src/image/imageediting';
+import Matcher from '@ckeditor/ckeditor5-engine/src/view/matcher';
+import toMap from '@ckeditor/ckeditor5-utils/src/tomap';
 import LinkEditing from './linkediting';
 
 import linkIcon from '../theme/icons/link.svg';
@@ -42,7 +44,46 @@ export default class LinkImageEditing extends Plugin {
 		editor.model.schema.extend( 'image', { allowAttributes: [ 'linkHref' ] } );
 
 		editor.conversion.for( 'upcast' ).add( upcastLink() );
-		editor.conversion.for( 'downcast' ).add( downcastImageLink() );
+		editor.conversion.for( 'editingDowncast' ).add( downcastImageLink( { attachIconIndicator: true } ) );
+		editor.conversion.for( 'dataDowncast' ).add( downcastImageLink( { attachIconIndicator: false } ) );
+
+		// Definitions for decorators are provided by the `link` command and the `LinkEditing` plugin.
+		this._enableAutomaticDecorators();
+		this._enableManualDecorators();
+	}
+
+	/**
+	 * Processes {@link module:link/link~LinkDecoratorAutomaticDefinition automatic decorators} definitions and
+	 * attaches proper converters that will work when linking an image.`
+	 *
+	 * @private
+	 */
+	_enableAutomaticDecorators() {
+		const editor = this.editor;
+		const command = editor.commands.get( 'link' );
+		const automaticDecorators = command.automaticDecorators;
+
+		if ( automaticDecorators.length ) {
+			editor.conversion.for( 'downcast' ).add( automaticDecorators.getDispatcherForLinkedImage() );
+		}
+	}
+
+	/**
+	 * Processes transformed {@link module:link/utils~ManualDecorator} instances and attaches proper converters
+	 * that will work when linking an image.
+	 *
+	 * @private
+	 */
+	_enableManualDecorators() {
+		const editor = this.editor;
+		const command = editor.commands.get( 'link' );
+		const manualDecorators = command.manualDecorators;
+
+		for ( const decorator of command.manualDecorators ) {
+			editor.model.schema.extend( 'image', { allowAttributes: decorator.id } );
+			editor.conversion.for( 'downcast' ).add( downcastImageLinkManualDecorator( manualDecorators, decorator ) );
+			editor.conversion.for( 'upcast' ).add( upcastImageLinkManualDecorator( manualDecorators, decorator ) );
+		}
 	}
 }
 
@@ -50,7 +91,6 @@ export default class LinkImageEditing extends Plugin {
 //
 // @private
 // @returns {Function}
-//
 function upcastLink() {
 	return dispatcher => {
 		dispatcher.on( 'element:a', ( evt, data, conversionApi ) => {
@@ -99,15 +139,18 @@ function upcastLink() {
 				conversionApi.writer.setAttribute( 'linkHref', linkHref, modelElement );
 			}
 		}, { priority: 'high' } );
+		// Using the same priority that `upcastImageLinkManualDecorator()` converter guarantees
+		// that manual decorators will decorate the proper element.
 	};
 }
 
 // Return a converter that adds the `<a>` element to data.
 //
 // @private
+// @params {Object} options
+// @params {Boolean} options.attachIconIndicator=false If set to `true`, an icon that informs about the linked image will be added.
 // @returns {Function}
-//
-function downcastImageLink() {
+function downcastImageLink( options ) {
 	return dispatcher => {
 		dispatcher.on( 'attribute:linkHref:image', ( evt, data, conversionApi ) => {
 			// The image will be already converted - so it will be present in the view.
@@ -117,13 +160,17 @@ function downcastImageLink() {
 			// But we need to check whether the link element exists.
 			const linkInImage = Array.from( viewFigure.getChildren() ).find( child => child.name === 'a' );
 
-			// Create an icon indicator for a linked image.
-			const linkIconIndicator = writer.createUIElement( 'span', { class: 'ck ck-link-image_icon' }, function( domDocument ) {
-				const domElement = this.toDomElement( domDocument );
-				domElement.innerHTML = linkIcon;
+			let linkIconIndicator;
 
-				return domElement;
-			} );
+			if ( options.attachIconIndicator ) {
+				// Create an icon indicator for a linked image.
+				linkIconIndicator = writer.createUIElement( 'span', { class: 'ck ck-link-image_icon' }, function( domDocument ) {
+					const domElement = this.toDomElement( domDocument );
+					domElement.innerHTML = linkIcon;
+
+					return domElement;
+				} );
+			}
 
 			// If so, update the attribute if it's defined or remove the entire link if the attribute is empty.
 			if ( linkInImage ) {
@@ -146,9 +193,65 @@ function downcastImageLink() {
 				// 3. Move the image to the link.
 				writer.move( writer.createRangeOn( viewFigure.getChild( 1 ) ), writer.createPositionAt( linkElement, 0 ) );
 
-				// 4. Inset the linked image icon indicator.
-				writer.insert( writer.createPositionAt( linkElement, 'end' ), linkIconIndicator );
+				// 4. Inset the linked image icon indicator while downcast to editing.
+				if ( linkIconIndicator ) {
+					writer.insert( writer.createPositionAt( linkElement, 'end' ), linkIconIndicator );
+				}
 			}
 		} );
 	};
 }
+
+// Returns a converter that decorates the `<a>` element when the image is the link label.
+//
+// @private
+// @returns {Function}
+function downcastImageLinkManualDecorator( manualDecorators, decorator ) {
+	return dispatcher => {
+		dispatcher.on( `attribute:${ decorator.id }:image`, ( evt, data, conversionApi ) => {
+			const attributes = manualDecorators.get( decorator.id ).attributes;
+
+			const viewFigure = conversionApi.mapper.toViewElement( data.item );
+			const linkInImage = Array.from( viewFigure.getChildren() ).find( child => child.name === 'a' );
+
+			for ( const [ key, val ] of toMap( attributes ) ) {
+				conversionApi.writer.setAttribute( key, val, linkInImage );
+			}
+		} );
+	};
+}
+
+// Returns a converter that checks whether manual decorators should be applied to the link.
+//
+// @private
+// @returns {Function}
+function upcastImageLinkManualDecorator( manualDecorators, decorator ) {
+	return dispatcher => {
+		dispatcher.on( 'element:a', ( evt, data, conversionApi ) => {
+			const viewLink = data.viewItem;
+
+			const consumableAttributes = {
+				attributes: manualDecorators.get( decorator.id ).attributes
+			};
+
+			const matcher = new Matcher( consumableAttributes );
+			const result = matcher.match( viewLink );
+
+			// The link element does not have required attributes or/and proper values.
+			if ( !result ) {
+				return;
+			}
+
+			// Check whether we can consume those attributes.
+			if ( !conversionApi.consumable.consume( viewLink, result.match ) ) {
+				return;
+			}
+
+			// At this stage we can assume that we have the `<image>` element.
+			const modelElement = data.modelCursor.parent;
+
+			conversionApi.writer.setAttribute( decorator.id, true, modelElement );
+		}, { priority: 'high' } );
+		// Using the same priority that `upcastLink()` converter guarantees that the linked image was properly converted.
+	};
+}

+ 3 - 2
packages/ckeditor5-link/src/unlinkcommand.js

@@ -8,8 +8,9 @@
  */
 
 import Command from '@ckeditor/ckeditor5-core/src/command';
-import findLinkRange from './findlinkrange';
 import first from '@ckeditor/ckeditor5-utils/src/first';
+import findLinkRange from './findlinkrange';
+import { isImageAllowed } from './utils';
 
 /**
  * The unlink command. It is used by the {@link module:link/link~Link link plugin}.
@@ -28,7 +29,7 @@ export default class UnlinkCommand extends Command {
 
 		// A check for the `LinkImage` plugin. If the selection contains an image element, get values from the element.
 		// Currently the selection reads attributes from text nodes only. See #7429 and #7465.
-		if ( selectedElement && selectedElement.is( 'element', 'image' ) && model.schema.checkAttribute( 'image', 'linkHref' ) ) {
+		if ( isImageAllowed( selectedElement, model.schema ) ) {
 			this.isEnabled = model.schema.checkAttribute( selectedElement, 'linkHref' );
 		} else {
 			this.isEnabled = model.schema.checkAttributeInSelection( doc.selection, 'linkHref' );

+ 15 - 0
packages/ckeditor5-link/src/utils.js

@@ -119,3 +119,18 @@ export function normalizeDecorators( decorators ) {
 
 	return retArray;
 }
+
+/**
+ * Returns `true` if the specified `element` is an image and it can be linked (the element allows having the `linkHref` attribute).
+ *
+ * @params {module:engine/model/element~Element|null} element
+ * @params {module:engine/model/schema~Schema} schema
+ * @returns {Boolean}
+ */
+export function isImageAllowed( element, schema ) {
+	if ( !element ) {
+		return false;
+	}
+
+	return element.is( 'image' ) && schema.checkAttribute( 'image', 'linkHref' );
+}

+ 41 - 1
packages/ckeditor5-link/src/utils/automaticdecorators.js

@@ -7,9 +7,11 @@
  * @module link/utils
  */
 
+import toMap from '@ckeditor/ckeditor5-utils/src/tomap';
+
 /**
  * Helper class that ties together all {@link module:link/link~LinkDecoratorAutomaticDefinition} and provides
- * a {@link module:engine/conversion/downcasthelpers~DowncastHelpers#attributeToElement downcast dispatcher} for them.
+ * the {@link module:engine/conversion/downcasthelpers~DowncastHelpers#attributeToElement downcast dispatchers} for them.
  */
 export default class AutomaticDecorators {
 	constructor() {
@@ -85,4 +87,42 @@ export default class AutomaticDecorators {
 			}, { priority: 'high' } );
 		};
 	}
+
+	/**
+	 * Provides the conversion helper used in the {@link module:engine/conversion/downcasthelpers~DowncastHelpers#add} method
+	 * when linking images.
+	 *
+	 * @returns {Function} A dispatcher function used as conversion helper
+	 * in {@link module:engine/conversion/downcasthelpers~DowncastHelpers#add}.
+	 */
+	getDispatcherForLinkedImage() {
+		return dispatcher => {
+			dispatcher.on( 'attribute:linkHref:image', ( evt, data, conversionApi ) => {
+				const viewFigure = conversionApi.mapper.toViewElement( data.item );
+				const linkInImage = Array.from( viewFigure.getChildren() ).find( child => child.name === 'a' );
+
+				for ( const item of this._definitions ) {
+					const attributes = toMap( item.attributes );
+
+					if ( item.callback( data.attributeNewValue ) ) {
+						for ( const [ key, val ] of attributes ) {
+							if ( key === 'class' ) {
+								conversionApi.writer.addClass( val, linkInImage );
+							} else {
+								conversionApi.writer.setAttribute( key, val, linkInImage );
+							}
+						}
+					} else {
+						for ( const [ key, val ] of attributes ) {
+							if ( key === 'class' ) {
+								conversionApi.writer.removeClass( val, linkInImage );
+							} else {
+								conversionApi.writer.removeAttribute( key, linkInImage );
+							}
+						}
+					}
+				}
+			} );
+		};
+	}
 }

+ 21 - 0
packages/ckeditor5-link/tests/linkcommand.js

@@ -7,6 +7,7 @@ import ModelTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/modeltestedit
 import LinkCommand from '../src/linkcommand';
 import ManualDecorator from '../src/utils/manualdecorator';
 import { setData, getData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
+import AutomaticDecorators from '../src/utils/automaticdecorators';
 
 describe( 'LinkCommand', () => {
 	let editor, model, command;
@@ -440,6 +441,13 @@ describe( 'LinkCommand', () => {
 						allowAttributes: [ 'linkHref', 'linkIsFoo', 'linkIsBar', 'linkIsSth' ]
 					} );
 
+					model.schema.register( 'image', {
+						allowIn: '$root',
+						isObject: true,
+						isBlock: true,
+						allowAttributes: [ 'linkHref', 'linkIsFoo', 'linkIsBar', 'linkIsSth' ]
+					} );
+
 					model.schema.register( 'p', { inheritAllFrom: '$block' } );
 				} );
 		} );
@@ -565,6 +573,19 @@ describe( 'LinkCommand', () => {
 				expect( command._getDecoratorStateFromModel( 'linkIsFoo' ) ).to.be.undefined;
 				expect( command._getDecoratorStateFromModel( 'linkIsBar' ) ).to.be.true;
 			} );
+
+			it( 'obtain current values from the image element', () => {
+				setData( model, '[<image linkHref="url" linkIsBar="true"></image>]' );
+
+				expect( command._getDecoratorStateFromModel( 'linkIsFoo' ) ).to.be.undefined;
+				expect( command._getDecoratorStateFromModel( 'linkIsBar' ) ).to.be.true;
+			} );
+		} );
+	} );
+
+	describe( '#automaticDecorators', () => {
+		it( 'is defined', () => {
+			expect( command.automaticDecorators ).to.be.an.instanceOf( AutomaticDecorators );
 		} );
 	} );
 } );

+ 4 - 0
packages/ckeditor5-link/tests/linkediting.js

@@ -724,6 +724,10 @@ describe( 'LinkEditing', () => {
 						expect( editor.getData() ).to.equal( `<p><a ${ reducedAttr }href="${ link.url }">foo</a>bar</p>` );
 					} );
 				} );
+
+				it( 'stores decorators in LinkCommand#automaticDecorators collection', () => {
+					expect( editor.commands.get( 'link' ).automaticDecorators.length ).to.equal( 3 );
+				} );
 			} );
 		} );
 

+ 374 - 7
packages/ckeditor5-link/tests/linkimageediting.js

@@ -50,13 +50,26 @@ describe( 'LinkImageEditing', () => {
 
 	describe( 'conversion in data pipeline', () => {
 		describe( 'model to view', () => {
+			it( 'should attach a link indicator to the image element', () => {
+				setModelData( model, '<image src="/assets/sample.png" alt="alt text" linkHref="http://ckeditor.com"></image>' );
+
+				expect( getViewData( view, { withoutSelection: true, renderUIElements: true } ) ).to.equal(
+					'<figure class="ck-widget image" contenteditable="false">' +
+						'<a href="http://ckeditor.com">' +
+							'<img alt="alt text" src="/assets/sample.png"></img>' +
+							linkIconInditatorElement +
+						'</a>' +
+					'</figure>'
+				);
+			} );
+		} );
+
+		describe( 'model to data', () => {
 			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">' +
-					linkIconInditatorElement +
-					'</a></figure>'
+					'<figure class="image"><a href="http://ckeditor.com"><img alt="alt text" src="/assets/sample.png"></a></figure>'
 				);
 			} );
 
@@ -64,9 +77,7 @@ describe( 'LinkImageEditing', () => {
 				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">' +
-					linkIconInditatorElement +
-					'</a></figure>'
+					'<figure class="image"><a href="http://ckeditor.com"><img src="/assets/sample.png"></a></figure>'
 				);
 			} );
 
@@ -82,7 +93,6 @@ describe( 'LinkImageEditing', () => {
 					'<figure class="image">' +
 						'<a href="http://ckeditor.com">' +
 							'<img sizes="100vw" src="/assets/sample.png" srcset="small.png 148w, big.png 1024w"></img>' +
-							linkIconInditatorElement +
 						'</a>' +
 					'</figure>'
 				);
@@ -348,4 +358,361 @@ describe( 'LinkImageEditing', () => {
 			} );
 		} );
 	} );
+
+	describe( 'link attributes decorator', () => {
+		describe( 'default behavior', () => {
+			const testLinks = [
+				{
+					external: true,
+					url: 'http://example.com'
+				}, {
+					external: true,
+					url: 'https://cksource.com'
+				}, {
+					external: false,
+					url: 'ftp://server.io'
+				}, {
+					external: true,
+					url: '//schemaless.org'
+				}, {
+					external: false,
+					url: 'www.ckeditor.com'
+				}, {
+					external: false,
+					url: '/relative/url.html'
+				}, {
+					external: false,
+					url: 'another/relative/url.html'
+				}, {
+					external: false,
+					url: '#anchor'
+				}, {
+					external: false,
+					url: 'mailto:some@user.org'
+				}, {
+					external: false,
+					url: 'tel:123456789'
+				}
+			];
+
+			describe( 'for link.addTargetToExternalLinks=false', () => {
+				let editor, model;
+
+				beforeEach( async () => {
+					editor = await VirtualTestEditor.create( {
+						plugins: [ Paragraph, LinkImageEditing ],
+						link: {
+							addTargetToExternalLinks: false
+						}
+					} );
+
+					model = editor.model;
+					view = editor.editing.view;
+				} );
+
+				afterEach( async () => {
+					await editor.destroy();
+				} );
+
+				testLinks.forEach( link => {
+					it( `link: ${ link.url } should not get 'target' and 'rel' attributes`, () => {
+						// Upcast check.
+						editor.setData(
+							'<figure class="image">' +
+								`<a href="${ link.url }" target="_blank" rel="noopener noreferrer">` +
+									'<img src="/assets/sample.png">' +
+								'</a>' +
+							'</figure>'
+						);
+
+						expect( getModelData( model, { withoutSelection: true } ) )
+							.to.equal( `<image linkHref="${ link.url }" src="/assets/sample.png"></image>` );
+
+						// Downcast check.
+						expect( editor.getData() ).to.equal(
+							'<figure class="image">' +
+								`<a href="${ link.url }">` +
+									'<img src="/assets/sample.png">' +
+								'</a>' +
+							'</figure>'
+						);
+					} );
+				} );
+			} );
+
+			describe( 'for link.addTargetToExternalLinks=true', () => {
+				let editor, model;
+
+				beforeEach( async () => {
+					editor = await VirtualTestEditor.create( {
+						plugins: [ Paragraph, LinkImageEditing ],
+						link: {
+							addTargetToExternalLinks: true
+						}
+					} );
+
+					model = editor.model;
+					view = editor.editing.view;
+				} );
+
+				afterEach( async () => {
+					await editor.destroy();
+				} );
+
+				testLinks.forEach( link => {
+					it( `link: ${ link.url } should be treat as ${ link.external ? 'external' : 'non-external' } link`, () => {
+						// Upcast check.
+						editor.setData(
+							`<a href="${ link.url }" target="_blank" rel="noopener noreferrer"><img src="/assets/sample.png"></a>`
+						);
+
+						expect( getModelData( model, { withoutSelection: true } ) )
+							.to.equal( `<image linkHref="${ link.url }" src="/assets/sample.png"></image>` );
+
+						// Downcast check.
+						if ( link.external ) {
+							expect( editor.getData() ).to.equal(
+								'<figure class="image">' +
+									`<a href="${ link.url }" target="_blank" rel="noopener noreferrer">` +
+										'<img src="/assets/sample.png">' +
+									'</a>' +
+								'</figure>'
+							);
+						} else {
+							expect( editor.getData() ).to.equal(
+								'<figure class="image">' +
+									`<a href="${ link.url }">` +
+										'<img src="/assets/sample.png">' +
+									'</a>' +
+								'</figure>'
+							);
+						}
+					} );
+				} );
+			} );
+		} );
+
+		describe( 'custom config', () => {
+			describe( 'mode: automatic', () => {
+				let editor;
+
+				const testLinks = [
+					{
+						url: 'relative/url.html',
+						attributes: {}
+					}, {
+						url: 'http://exmaple.com',
+						attributes: {
+							target: '_blank'
+						}
+					}, {
+						url: 'https://example.com/download/link.pdf',
+						attributes: {
+							target: '_blank',
+							download: 'download'
+						}
+					}, {
+						url: 'mailto:some@person.io',
+						attributes: {
+							class: 'mail-url'
+						}
+					}
+				];
+
+				beforeEach( async () => {
+					editor = await VirtualTestEditor.create( {
+						plugins: [ Paragraph, LinkImageEditing ],
+						link: {
+							addTargetToExternalLinks: false,
+							decorators: {
+								isExternal: {
+									mode: 'automatic',
+									callback: url => url.startsWith( 'http' ),
+									attributes: {
+										target: '_blank'
+									}
+								},
+								isDownloadable: {
+									mode: 'automatic',
+									callback: url => url.includes( 'download' ),
+									attributes: {
+										download: 'download'
+									}
+								},
+								isMail: {
+									mode: 'automatic',
+									callback: url => url.startsWith( 'mailto:' ),
+									attributes: {
+										class: 'mail-url'
+									}
+								}
+							}
+						}
+					} );
+
+					model = editor.model;
+				} );
+
+				afterEach( () => {
+					return editor.destroy();
+				} );
+
+				testLinks.forEach( link => {
+					it( `Link: ${ link.url } should get attributes: ${ JSON.stringify( link.attributes ) }`, () => {
+						const ORDER = [ 'class', 'href', 'target', 'download' ];
+						const attributes = Object.assign( {}, link.attributes, {
+							href: link.url
+						} );
+						const attr = Object.entries( attributes ).sort( ( a, b ) => {
+							const aIndex = ORDER.indexOf( a[ 0 ] );
+							const bIndex = ORDER.indexOf( b[ 0 ] );
+							return aIndex - bIndex;
+						} );
+						const reducedAttr = attr.reduce( ( acc, cur ) => {
+							return acc + `${ cur[ 0 ] }="${ cur[ 1 ] }" `;
+						}, '' ).trim();
+
+						editor.setData( `<a href="${ link.url }"><img src="/assets/sample.png"></a>` );
+
+						expect( getModelData( model, { withoutSelection: true } ) )
+							.to.equal( `<image linkHref="${ link.url }" src="/assets/sample.png"></image>` );
+
+						// Order of attributes is important, that's why this is assert is construct in such way.
+						expect( editor.getData() ).to.equal(
+							'<figure class="image">' +
+								`<a ${ reducedAttr }>` +
+									'<img src="/assets/sample.png">' +
+								'</a>' +
+							'</figure>'
+						);
+					} );
+				} );
+			} );
+		} );
+
+		describe( 'upcast converter', () => {
+			let editor;
+
+			it( 'should upcast attributes from initial data', async () => {
+				editor = await VirtualTestEditor.create( {
+					initialData: '<figure class="image"><a href="url" target="_blank" rel="noopener noreferrer" download="file">' +
+						'<img src="/assets/sample.png"></a></figure>',
+					plugins: [ Paragraph, LinkImageEditing ],
+					link: {
+						decorators: {
+							isExternal: {
+								mode: 'manual',
+								label: 'Open in a new window',
+								attributes: {
+									target: '_blank',
+									rel: 'noopener noreferrer'
+								}
+							},
+							isDownloadable: {
+								mode: 'manual',
+								label: 'Downloadable',
+								attributes: {
+									download: 'file'
+								}
+							}
+						}
+					}
+				} );
+
+				model = editor.model;
+
+				expect( getModelData( model, { withoutSelection: true } ) ).to.equal(
+					'<image linkHref="url" linkIsDownloadable="true" linkIsExternal="true" src="/assets/sample.png"></image>'
+				);
+
+				await editor.destroy();
+			} );
+
+			it( 'should not upcast partial and incorrect attributes', async () => {
+				editor = await VirtualTestEditor.create( {
+					initialData: '<figure class="image"><a href="url" target="_blank" rel="noopener noreferrer" download="something">' +
+						'<img src="/assets/sample.png"></a></figure>',
+					plugins: [ Paragraph, LinkImageEditing ],
+					link: {
+						decorators: {
+							isExternal: {
+								mode: 'manual',
+								label: 'Open in a new window',
+								attributes: {
+									target: '_blank',
+									rel: 'noopener noreferrer'
+								}
+							},
+							isDownloadable: {
+								mode: 'manual',
+								label: 'Downloadable',
+								attributes: {
+									download: 'file'
+								}
+							}
+						}
+					}
+				} );
+
+				model = editor.model;
+
+				expect( getModelData( model, { withoutSelection: true } ) ).to.equal(
+					'<image linkHref="url" linkIsExternal="true" src="/assets/sample.png"></image>'
+				);
+
+				await editor.destroy();
+			} );
+
+			it( 'allows overwriting conversion process using highest priority', async () => {
+				editor = await VirtualTestEditor.create( {
+					initialData: '',
+					plugins: [ Paragraph, LinkImageEditing ],
+					link: {
+						decorators: {
+							isExternal: {
+								mode: 'manual',
+								label: 'Open in a new window',
+								attributes: {
+									target: '_blank',
+									rel: 'noopener noreferrer'
+								}
+							},
+							isDownloadable: {
+								mode: 'manual',
+								label: 'Downloadable',
+								attributes: {
+									download: 'file'
+								}
+							}
+						}
+					}
+				} );
+
+				model = editor.model;
+
+				// Block manual decorator converter. Consume all attributes and do nothing with them.
+				editor.conversion.for( 'upcast' ).add( dispatcher => {
+					dispatcher.on( 'element:a', ( evt, data, conversionApi ) => {
+						const consumableAttributes = {
+							attributes: [ 'target', 'rel', 'download' ]
+						};
+
+						conversionApi.consumable.consume( data.viewItem, consumableAttributes );
+					}, { priority: 'highest' } );
+				} );
+
+				editor.setData(
+					'<figure class="image">' +
+						'<a href="url" target="_blank" rel="noopener noreferrer" download="something">' +
+							'<img src="/assets/sample.png">' +
+						'</a>' +
+					'</figure>'
+				);
+
+				expect( editor.getData() ).to.equal( '<figure class="image"><a href="url"><img src="/assets/sample.png"></a></figure>' );
+
+				await editor.destroy();
+			} );
+		} );
+	} );
 } );

+ 10 - 8
packages/ckeditor5-link/tests/manual/linkdecorator.js

@@ -6,12 +6,8 @@
 /* globals console:false, window, document, CKEditorInspector */
 
 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 Clipboard from '@ckeditor/ckeditor5-clipboard/src/clipboard';
+import ArticlePluginSet from '@ckeditor/ckeditor5-core/tests/_utils/articlepluginset';
+import LinkImage from '../../src/linkimage';
 
 // Just to have nicely styles switchbutton;
 import '@ckeditor/ckeditor5-theme-lark/theme/ckeditor5-ui/components/list/list.css';
@@ -20,7 +16,7 @@ window.editors = {};
 
 ClassicEditor
 	.create( document.querySelector( '#editor' ), {
-		plugins: [ Link, Typing, Paragraph, Clipboard, Undo, Enter ],
+		plugins: [ ArticlePluginSet, LinkImage ],
 		toolbar: [ 'link', 'undo', 'redo' ],
 		link: {
 			decorators: {
@@ -47,6 +43,9 @@ ClassicEditor
 					}
 				}
 			}
+		},
+		image: {
+			toolbar: [ 'imageStyle:full', 'imageStyle:side', '|', 'imageTextAlternative', '|', 'linkImage' ]
 		}
 	} )
 	.then( editor => {
@@ -59,7 +58,7 @@ ClassicEditor
 
 ClassicEditor
 	.create( document.querySelector( '#editor2' ), {
-		plugins: [ Link, Typing, Paragraph, Clipboard, Undo, Enter ],
+		plugins: [ ArticlePluginSet, LinkImage ],
 		toolbar: [ 'link', 'undo', 'redo' ],
 		link: {
 			decorators: {
@@ -79,6 +78,9 @@ ClassicEditor
 				}
 			},
 			addTargetToExternalLinks: true
+		},
+		image: {
+			toolbar: [ 'imageStyle:full', 'imageStyle:side', '|', 'imageTextAlternative', '|', 'linkImage' ]
 		}
 	} )
 	.then( editor => {

+ 31 - 2
packages/ckeditor5-link/tests/utils.js

@@ -8,8 +8,9 @@ import ViewDowncastWriter from '@ckeditor/ckeditor5-engine/src/view/downcastwrit
 import AttributeElement from '@ckeditor/ckeditor5-engine/src/view/attributeelement';
 import ContainerElement from '@ckeditor/ckeditor5-engine/src/view/containerelement';
 import Text from '@ckeditor/ckeditor5-engine/src/view/text';
-
-import { createLinkElement, isLinkElement, ensureSafeUrl, normalizeDecorators } from '../src/utils';
+import Schema from '@ckeditor/ckeditor5-engine/src/model/schema';
+import ModelElement from '@ckeditor/ckeditor5-engine/src/model/element';
+import { createLinkElement, isLinkElement, ensureSafeUrl, normalizeDecorators, isImageAllowed } from '../src/utils';
 
 describe( 'utils', () => {
 	describe( 'isLinkElement()', () => {
@@ -215,4 +216,32 @@ describe( 'utils', () => {
 			] );
 		} );
 	} );
+
+	describe( 'isImageAllowed()', () => {
+		it( 'returns false when passed "null" as element', () => {
+			expect( isImageAllowed( null, new Schema() ) ).to.equal( false );
+		} );
+
+		it( 'returns false when passed an element that is not the image element', () => {
+			const element = new ModelElement( 'paragraph' );
+			expect( isImageAllowed( element, new Schema() ) ).to.equal( false );
+		} );
+
+		it( 'returns false when schema does not allow linking images', () => {
+			const element = new ModelElement( 'image' );
+			expect( isImageAllowed( element, new Schema() ) ).to.equal( false );
+		} );
+
+		it( 'returns true when passed an image element and it can be linked', () => {
+			const element = new ModelElement( 'image' );
+			const schema = new Schema();
+
+			schema.register( 'image', {
+				allowIn: '$root',
+				allowAttributes: [ 'linkHref' ]
+			} );
+
+			expect( isImageAllowed( element, schema ) ).to.equal( true );
+		} );
+	} );
 } );

+ 6 - 0
packages/ckeditor5-link/tests/utils/automaticdecorators.js

@@ -95,4 +95,10 @@ describe( 'Automatic Decorators', () => {
 			expect( automaticDecorators.getDispatcher() ).to.be.a( 'function' );
 		} );
 	} );
+
+	describe( 'getDispatcherForLinkedImage()', () => {
+		it( 'should return a dispatcher function', () => {
+			expect( automaticDecorators.getDispatcherForLinkedImage() ).to.be.a( 'function' );
+		} );
+	} );
 } );

+ 6 - 6
packages/ckeditor5-table/tests/tableclipboard-paste.js

@@ -861,7 +861,7 @@ describe( 'table clipboard', () => {
 					setModelData( model, modelTable( [
 						[ '00', '01', '02' ],
 						[ '10', '11', '12' ],
-						[ '20', '21', '[<image src="/assets/sample.jpg"><caption></caption></image>]' ]
+						[ '20', '21', '[<image src="/assets/sample.png"><caption></caption></image>]' ]
 					] ) );
 
 					pasteTable( [
@@ -884,7 +884,7 @@ describe( 'table clipboard', () => {
 					setModelData( model, modelTable( [
 						[ '00', '01', '02' ],
 						[ '10', '11', '12' ],
-						[ '20', '21', '<image src="/assets/sample.jpg"><caption>fo[]o</caption></image>' ]
+						[ '20', '21', '<image src="/assets/sample.png"><caption>fo[]o</caption></image>' ]
 					] ) );
 
 					pasteTable( [
@@ -3767,12 +3767,12 @@ describe( 'table clipboard', () => {
 			);
 
 			pasteTable( [
-				[ '<img src="/assets/sample.jpg">', 'ab' ],
+				[ '<img src="/assets/sample.png">', 'ab' ],
 				[ 'ba', 'bb' ]
 			] );
 
 			assertEqualMarkup( getModelData( model, { withoutSelection: true } ), modelTable( [
-				[ '<image src="/assets/sample.jpg"><caption></caption></image>', 'ab', '02' ],
+				[ '<image src="/assets/sample.png"><caption></caption></image>', 'ab', '02' ],
 				[ 'ba', 'bb', '12' ],
 				[ '02', '21', '22' ]
 			] ) );
@@ -3792,7 +3792,7 @@ describe( 'table clipboard', () => {
 				modelRoot.getNodeByPath( [ 0, 1, 1 ] )
 			);
 
-			const img = '<img src="/assets/sample.jpg">';
+			const img = '<img src="/assets/sample.png">';
 			const list = '<ul><li>foo</li><li>bar</li></ul>';
 			const blockquote = `<blockquote><p>baz</p>${ list }</blockquote>`;
 
@@ -3803,7 +3803,7 @@ describe( 'table clipboard', () => {
 
 			assertEqualMarkup( getModelData( model, { withoutSelection: true } ), modelTable( [
 				[
-					'<image src="/assets/sample.jpg"><caption></caption></image>' +
+					'<image src="/assets/sample.png"><caption></caption></image>' +
 					'<listItem listIndent="0" listType="bulleted">foo</listItem>' +
 					'<listItem listIndent="0" listType="bulleted">bar</listItem>' +
 					'<blockQuote>' +

+ 116 - 49
packages/ckeditor5-utils/src/collection.js

@@ -179,11 +179,24 @@ export default class Collection {
 	 * @param {Number} [index] The position of the item in the collection. The item
 	 * is pushed to the collection when `index` not specified.
 	 * @fires add
+	 * @fires change
 	 */
 	add( item, index ) {
-		const itemId = this._getItemIdBeforeAdding( item );
+		return this.addMany( [ item ], index );
+	}
 
-		// TODO: Use ES6 default function argument.
+	/**
+	 * Adds multiple items into the collection.
+	 *
+	 * Any item not containing an id will get an automatically generated one.
+	 *
+	 * @chainable
+	 * @param {Iterable.<Object>} item
+	 * @param {Number} [index] The position of the insertion. Items will be appended if no `index` is specified.
+	 * @fires add
+	 * @fires change
+	 */
+	addMany( items, index ) {
 		if ( index === undefined ) {
 			index = this._items.length;
 		} else if ( index > this._items.length || index < 0 ) {
@@ -195,11 +208,22 @@ export default class Collection {
 			throw new CKEditorError( 'collection-add-item-invalid-index', this );
 		}
 
-		this._items.splice( index, 0, item );
+		for ( let offset = 0; offset < items.length; offset++ ) {
+			const item = items[ offset ];
+			const itemId = this._getItemIdBeforeAdding( item );
+			const currentItemIndex = index + offset;
 
-		this._itemMap.set( itemId, item );
+			this._items.splice( currentItemIndex, 0, item );
+			this._itemMap.set( itemId, item );
 
-		this.fire( 'add', item, index );
+			this.fire( 'add', item, currentItemIndex );
+		}
+
+		this.fire( 'change', {
+			added: items,
+			removed: [],
+			index
+		} );
 
 		return this;
 	}
@@ -271,52 +295,16 @@ export default class Collection {
 	 * @param {Object|Number|String} subject The item to remove, its id or index in the collection.
 	 * @returns {Object} The removed item.
 	 * @fires remove
+	 * @fires change
 	 */
 	remove( subject ) {
-		let index, id, item;
-		let itemDoesNotExist = false;
-		const idProperty = this._idProperty;
-
-		if ( typeof subject == 'string' ) {
-			id = subject;
-			item = this._itemMap.get( id );
-			itemDoesNotExist = !item;
-
-			if ( item ) {
-				index = this._items.indexOf( item );
-			}
-		} else if ( typeof subject == 'number' ) {
-			index = subject;
-			item = this._items[ index ];
-			itemDoesNotExist = !item;
-
-			if ( item ) {
-				id = item[ idProperty ];
-			}
-		} else {
-			item = subject;
-			id = item[ idProperty ];
-			index = this._items.indexOf( item );
-			itemDoesNotExist = ( index == -1 || !this._itemMap.get( id ) );
-		}
+		const [ item, index ] = this._remove( subject );
 
-		if ( itemDoesNotExist ) {
-			/**
-			 * Item not found.
-			 *
-			 * @error collection-remove-404
-			 */
-			throw new CKEditorError( 'collection-remove-404: Item not found.', this );
-		}
-
-		this._items.splice( index, 1 );
-		this._itemMap.delete( id );
-
-		const externalItem = this._bindToInternalToExternalMap.get( item );
-		this._bindToInternalToExternalMap.delete( item );
-		this._bindToExternalToInternalMap.delete( externalItem );
-
-		this.fire( 'remove', item, index );
+		this.fire( 'change', {
+			added: [],
+			removed: [ item ],
+			index
+		} );
 
 		return item;
 	}
@@ -363,6 +351,9 @@ export default class Collection {
 	/**
 	 * Removes all items from the collection and destroys the binding created using
 	 * {@link #bindTo}.
+	 *
+	 * @fires remove
+	 * @fires change
 	 */
 	clear() {
 		if ( this._bindToCollection ) {
@@ -370,9 +361,17 @@ export default class Collection {
 			this._bindToCollection = null;
 		}
 
+		const removedItems = Array.from( this._items );
+
 		while ( this.length ) {
-			this.remove( 0 );
+			this._remove( 0 );
 		}
+
+		this.fire( 'change', {
+			added: [],
+			removed: removedItems,
+			index: 0
+		} );
 	}
 
 	/**
@@ -665,6 +664,65 @@ export default class Collection {
 	}
 
 	/**
+	 * Core {@link #remove} method implementation shared in other functions.
+	 *
+	 * In contrast this method **does not** fire the {@link #event:change} event.
+	 *
+	 * @private
+	 * @param {Object} subject The item to remove, its id or index in the collection.
+	 * @returns {Array} Returns an array with the removed item and its index.
+	 * @fires remove
+	 */
+	_remove( subject ) {
+		let index, id, item;
+		let itemDoesNotExist = false;
+		const idProperty = this._idProperty;
+
+		if ( typeof subject == 'string' ) {
+			id = subject;
+			item = this._itemMap.get( id );
+			itemDoesNotExist = !item;
+
+			if ( item ) {
+				index = this._items.indexOf( item );
+			}
+		} else if ( typeof subject == 'number' ) {
+			index = subject;
+			item = this._items[ index ];
+			itemDoesNotExist = !item;
+
+			if ( item ) {
+				id = item[ idProperty ];
+			}
+		} else {
+			item = subject;
+			id = item[ idProperty ];
+			index = this._items.indexOf( item );
+			itemDoesNotExist = ( index == -1 || !this._itemMap.get( id ) );
+		}
+
+		if ( itemDoesNotExist ) {
+			/**
+			 * Item not found.
+			 *
+			 * @error collection-remove-404
+			 */
+			throw new CKEditorError( 'collection-remove-404: Item not found.', this );
+		}
+
+		this._items.splice( index, 1 );
+		this._itemMap.delete( id );
+
+		const externalItem = this._bindToInternalToExternalMap.get( item );
+		this._bindToInternalToExternalMap.delete( item );
+		this._bindToExternalToInternalMap.delete( externalItem );
+
+		this.fire( 'remove', item, index );
+
+		return [ item, index ];
+	}
+
+	/**
 	 * Iterable interface.
 	 *
 	 * @returns {Iterable.<*>}
@@ -681,6 +739,15 @@ export default class Collection {
 	 */
 
 	/**
+	 * Fired when the collection was changed due to adding or removing items.
+	 *
+	 * @event change
+	 * @param {Iterable.<Object>} added A list of added items.
+	 * @param {Iterable.<Object>} removed A list of removed items.
+	 * @param {Number} index An index where the addition or removal occurred.
+	 */
+
+	/**
 	 * Fired when an item is removed from the collection.
 	 *
 	 * @event remove

+ 306 - 0
packages/ckeditor5-utils/tests/collection.js

@@ -365,6 +365,277 @@ describe( 'Collection', () => {
 		} );
 	} );
 
+	describe( 'addMany()', () => {
+		it( 'should be chainable', () => {
+			expect( collection.addMany( [ {} ] ) ).to.equal( collection );
+		} );
+
+		it( 'should change the length', () => {
+			expect( collection ).to.have.length( 0 );
+
+			collection.addMany( [ {}, {} ] );
+			expect( collection ).to.have.length( 2 );
+
+			collection.addMany( [ {} ] );
+			expect( collection ).to.have.length( 3 );
+		} );
+
+		it( 'should enable get( index )', () => {
+			const item1 = {};
+			const item2 = {};
+
+			collection.addMany( [ item1, item2 ] );
+			expect( collection.get( 0 ) ).to.equal( item1 );
+			expect( collection.get( 1 ) ).to.equal( item2 );
+		} );
+
+		it( 'should enable get( id )', () => {
+			const item1 = getItem( 'foo' );
+			const item2 = getItem( 'bar' );
+
+			collection.addMany( [ item1, item2 ] );
+
+			expect( collection.get( 'foo' ) ).to.equal( item1 );
+			expect( collection.get( 'bar' ) ).to.equal( item2 );
+		} );
+
+		it( 'should enable get( id ) - custom id property', () => {
+			const collection = new Collection( { idProperty: 'name' } );
+			const item1 = getItem( 'foo', 'name' );
+			const item2 = getItem( 'bar', 'name' );
+
+			collection.add( item1 );
+			collection.add( item2 );
+
+			expect( collection.get( 'foo' ) ).to.equal( item1 );
+			expect( collection.get( 'bar' ) ).to.equal( item2 );
+		} );
+
+		it( 'should generate an id when not defined', () => {
+			const item = {};
+
+			collection.addMany( [ item ] );
+
+			expect( item.id ).to.be.a( 'string' );
+			expect( collection.get( item.id ) ).to.equal( item );
+		} );
+
+		it( 'should generate an id when not defined - custom id property', () => {
+			const collection = new Collection( { idProperty: 'name' } );
+			const item = {};
+
+			collection.addMany( [ item ] );
+
+			expect( item.name ).to.be.a( 'string' );
+			expect( collection.get( item.name ) ).to.equal( item );
+		} );
+
+		it( 'should not change an existing id of an item', () => {
+			const item = getItem( 'foo' );
+
+			collection.addMany( [ item ] );
+
+			expect( item.id ).to.equal( 'foo' );
+		} );
+
+		it( 'should throw when item with this id already exists - single call', () => {
+			const item1 = getItem( 'foo' );
+
+			expectToThrowCKEditorError( () => {
+				collection.addMany( [ item1, item1 ] );
+			}, /^collection-add-item-already-exists/ );
+		} );
+
+		it( 'should throw when item with this id already exists - multiple calls', () => {
+			const item1 = getItem( 'foo' );
+			const item2 = getItem( 'foo' );
+
+			collection.addMany( [ item1 ] );
+
+			expectToThrowCKEditorError( () => {
+				collection.addMany( [ item2 ] );
+			}, /^collection-add-item-already-exists/ );
+		} );
+
+		it( 'should throw when item\'s id is not a string', () => {
+			const item = { id: 1 };
+
+			expectToThrowCKEditorError( () => {
+				collection.addMany( [ item ] );
+			}, /^collection-add-invalid-id/ );
+		} );
+
+		it(
+			'should generate an id when not defined, which is globally unique ' +
+			'so it is possible to move items between collections and avoid id collisions',
+			() => {
+				const collectionA = new Collection();
+				const collectionB = new Collection();
+				const itemA = {};
+				const itemB = {};
+
+				collectionA.addMany( [ itemA ] );
+				collectionB.addMany( [ itemB ] );
+				collectionB.addMany( [ collectionA.remove( itemA ) ] );
+
+				expect( collectionA.length ).to.equal( 0 );
+				expect( collectionB.length ).to.equal( 2 );
+				expect( collectionB.get( 0 ) ).to.equal( itemB );
+				expect( collectionB.get( 1 ) ).to.equal( itemA );
+
+				expect( itemA.id ).to.not.equal( itemB.id );
+			}
+		);
+
+		it(
+			'should generate an id when not defined, which is globally unique ' +
+			'so it is possible to move items between collections and avoid id collisions ' +
+			'– custom id property',
+			() => {
+				const collectionA = new Collection( { idProperty: 'foo' } );
+				const collectionB = new Collection( { idProperty: 'foo' } );
+				const itemA = {};
+				const itemB = {};
+
+				collectionA.addMany( [ itemA ] );
+				collectionB.addMany( [ itemB ] );
+				collectionB.addMany( [ collectionA.remove( itemA ) ] );
+
+				expect( collectionA.length ).to.equal( 0 );
+				expect( collectionB.length ).to.equal( 2 );
+				expect( collectionB.get( 0 ) ).to.equal( itemB );
+				expect( collectionB.get( 1 ) ).to.equal( itemA );
+
+				expect( itemA.foo ).to.not.equal( itemB.foo );
+			}
+		);
+
+		it( 'should allow an item which is already in some other collection', () => {
+			const collectionA = new Collection();
+			const collectionB = new Collection();
+			const item = {};
+
+			collectionA.addMany( [ item ] );
+			collectionB.addMany( [ item ] );
+
+			expect( collectionA.length ).to.equal( 1 );
+			expect( collectionB.length ).to.equal( 1 );
+			expect( collectionA.get( item.id ) ).to.equal( collectionB.get( 0 ) );
+		} );
+
+		it( 'should fire the "add" event', () => {
+			const spy = sinon.spy();
+			const item = {};
+
+			collection.on( 'add', spy );
+
+			collection.addMany( [ item ] );
+
+			sinon.assert.calledWithExactly( spy, sinon.match.has( 'source', collection ), item, 0 );
+		} );
+
+		it( 'should fire the "add" event for each item', () => {
+			const spy = sinon.spy();
+			const items = [ {}, {} ];
+
+			collection.on( 'add', spy );
+
+			collection.addMany( items );
+
+			sinon.assert.calledWithExactly( spy, sinon.match.has( 'source', collection ), items[ 0 ], 0 );
+			sinon.assert.calledWithExactly( spy, sinon.match.has( 'source', collection ), items[ 1 ], 1 );
+
+			expect( spy.callCount ).to.equal( 2 );
+		} );
+
+		it( 'should fire the "add" event with the index argument', () => {
+			const spy = sinon.spy();
+
+			collection.addMany( [ {} ] );
+			collection.addMany( [ {} ] );
+
+			collection.on( 'add', spy );
+
+			const item = {};
+			collection.addMany( [ item ], 1 );
+
+			sinon.assert.calledWithExactly( spy, sinon.match.has( 'source', collection ), item, 1 );
+		} );
+
+		it( 'should fire the "change" event', () => {
+			const spy = sinon.spy();
+			const items = [ {}, {} ];
+
+			collection.on( 'change', spy );
+
+			collection.addMany( items );
+
+			sinon.assert.calledOnce( spy );
+			expect( spy.args[ 0 ][ 1 ] ).to.deep.eql( {
+				added: items,
+				removed: [],
+				index: 0
+			} );
+		} );
+
+		it( 'should fire the "change" event with the index argument', () => {
+			const spy = sinon.spy();
+			const firstBatch = [ {}, {} ];
+			const secondBatch = [ {}, {} ];
+
+			collection.addMany( firstBatch );
+
+			collection.on( 'change', spy );
+
+			collection.addMany( secondBatch, 1 );
+
+			expect( spy.callCount, 'call count' ).to.equal( 1 );
+			expect( spy.args[ 0 ][ 1 ] ).to.deep.eql( {
+				added: secondBatch,
+				removed: [],
+				index: 1
+			} );
+		} );
+
+		it( 'should support an optional index argument', () => {
+			const item1 = getItem( 'foo' );
+			const item2 = getItem( 'bar' );
+			const item3 = getItem( 'baz' );
+			const item4 = getItem( 'abc' );
+
+			collection.addMany( [ item1 ] );
+			collection.addMany( [ item2 ], 0 );
+			collection.addMany( [ item3 ], 1 );
+			collection.addMany( [ item4 ], 3 );
+
+			expect( collection.get( 0 ) ).to.equal( item2 );
+			expect( collection.get( 1 ) ).to.equal( item3 );
+			expect( collection.get( 2 ) ).to.equal( item1 );
+			expect( collection.get( 3 ) ).to.equal( item4 );
+		} );
+
+		it( 'should throw when index argument is invalid', () => {
+			const item1 = getItem( 'foo' );
+			const item2 = getItem( 'bar' );
+			const item3 = getItem( 'baz' );
+
+			collection.addMany( [ item1 ] );
+
+			expectToThrowCKEditorError( () => {
+				collection.addMany( [ item2 ], -1 );
+			}, /^collection-add-item-invalid-index/ );
+
+			expectToThrowCKEditorError( () => {
+				collection.addMany( [ item2 ], 2 );
+			}, /^collection-add-item-invalid-index/ );
+
+			collection.addMany( [ item2 ], 1 );
+			collection.addMany( [ item3 ], 0 );
+
+			expect( collection.length ).to.equal( 3 );
+		} );
+	} );
+
 	describe( 'get()', () => {
 		it( 'should return an item', () => {
 			const item = getItem( 'foo' );
@@ -546,6 +817,23 @@ describe( 'Collection', () => {
 			sinon.assert.calledWithExactly( spy, sinon.match.has( 'source', collection ), item3, 0 );
 		} );
 
+		it( 'should fire the "change" event', () => {
+			const item = getItem( 'foo' );
+			const spy = sinon.spy();
+
+			collection.add( item );
+			collection.on( 'change', spy );
+
+			collection.remove( item );
+
+			sinon.assert.calledOnce( spy );
+			expect( spy.args[ 0 ][ 1 ] ).to.deep.eql( {
+				added: [],
+				removed: [ item ],
+				index: 0
+			} );
+		} );
+
 		it( 'should throw an error on invalid index', () => {
 			collection.add( getItem( 'foo' ) );
 
@@ -656,6 +944,24 @@ describe( 'Collection', () => {
 
 			expect( collection._bindToCollection ).to.be.null;
 		} );
+
+		it( 'should fire the "change" event', () => {
+			const items = [ {}, {}, {} ];
+			const spy = sinon.spy();
+
+			collection.addMany( items );
+			collection.on( 'change', spy );
+
+			collection.clear();
+
+			sinon.assert.calledOnce( spy );
+
+			expect( spy.args[ 0 ][ 1 ] ).to.deep.eql( {
+				added: [],
+				removed: items,
+				index: 0
+			} );
+		} );
 	} );
 
 	describe( 'bindTo()', () => {

+ 1 - 0
scripts/release/changelog.js

@@ -15,6 +15,7 @@ Promise.resolve()
 	.then( () => devEnv.generateChangelogForMonoRepository( {
 		cwd: process.cwd(),
 		packages: 'packages',
+		releaseBranch: 'release',
 		highlightsPlaceholder: true,
 		collaborationFeatures: true,
 		transformScope: name => {