Browse Source

Merge pull request #7634 from ckeditor/i/7519

Fix (link): Manual and automatic decorators will work properly with a link on an image. Closes #7519.

Internal (link): An icon that indicates that the image is linked will not be displayed in the editor's data. Closes #7626.
Maciej 5 years ago
parent
commit
d38b5e5267

+ 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( '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( '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' );
+		} );
+	} );
 } );