瀏覽代碼

Merge pull request #7386 from ckeditor/i/7330

Feature (link): Introduced the linking images feature. Closes #7330.
Maciej 5 年之前
父節點
當前提交
cc0e69478e

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

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

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

@@ -20,6 +20,7 @@
     "@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",

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

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

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

二進制
packages/ckeditor5-link/tests/manual/sample.jpg