8
0
فهرست منبع

Merge branch 'master' into memory-test

Maciej Gołaszewski 5 سال پیش
والد
کامیت
a14548866f

+ 120 - 96
docs/builds/guides/integration/content-styles.md

@@ -48,18 +48,18 @@ Below there is a full list of content styles used by the editor features. You ca
 
 ```css
 /*
- * CKEditor 5 (v17.0.0) content styles.
- * Generated on Fri, 13 Mar 2020 13:27:10 GMT.
+ * CKEditor 5 (v19.1.1) content styles.
+ * Generated on Tue, 09 Jun 2020 10:37:49 GMT.
  * For more information, check out https://ckeditor.com/docs/ckeditor5/latest/builds/guides/integration/content-styles.html
  */
 
 :root {
-	--ck-highlight-marker-blue: #72cdfd;
-	--ck-highlight-marker-green: #63f963;
-	--ck-highlight-marker-pink: #fc7999;
-	--ck-highlight-marker-yellow: #fdfd77;
-	--ck-highlight-pen-green: #118800;
-	--ck-highlight-pen-red: #e91313;
+	--ck-highlight-marker-blue: hsl(201, 97%, 72%);
+	--ck-highlight-marker-green: hsl(120, 93%, 68%);
+	--ck-highlight-marker-pink: hsl(345, 96%, 73%);
+	--ck-highlight-marker-yellow: hsl(60, 97%, 73%);
+	--ck-highlight-pen-green: hsl(112, 100%, 27%);
+	--ck-highlight-pen-red: hsl(0, 85%, 49%);
 	--ck-image-style-spacing: 1.5em;
 	--ck-todo-list-checkmark-size: 16px;
 }
@@ -78,26 +78,23 @@ Below there is a full list of content styles used by the editor features. You ca
 .ck-content .image.image_resized > figcaption {
 	display: block;
 }
+/* ckeditor5-image/theme/imagecaption.css */
+.ck-content .image > figcaption {
+	display: table-caption;
+	caption-side: bottom;
+	word-break: break-word;
+	color: hsl(0, 0%, 20%);
+	background-color: hsl(0, 0%, 97%);
+	padding: .6em;
+	font-size: .75em;
+	outline-offset: -1px;
+}
 /* ckeditor5-basic-styles/theme/code.css */
 .ck-content code {
 	background-color: hsla(0, 0%, 78%, 0.3);
 	padding: .15em;
 	border-radius: 2px;
 }
-/* ckeditor5-image/theme/image.css */
-.ck-content .image {
-	display: table;
-	clear: both;
-	text-align: center;
-	margin: 1em auto;
-}
-/* ckeditor5-image/theme/image.css */
-.ck-content .image > img {
-	display: block;
-	margin: 0 auto;
-	max-width: 100%;
-	min-width: 50px;
-}
 /* ckeditor5-image/theme/imagestyle.css */
 .ck-content .image-style-side,
 .ck-content .image-style-align-left,
@@ -125,42 +122,6 @@ Below there is a full list of content styles used by the editor features. You ca
 	float: right;
 	margin-left: var(--ck-image-style-spacing);
 }
-/* ckeditor5-page-break/theme/pagebreak.css */
-.ck-content .page-break {
-	position: relative;
-	clear: both;
-	padding: 5px 0;
-	display: flex;
-	align-items: center;
-	justify-content: center;
-}
-/* ckeditor5-page-break/theme/pagebreak.css */
-.ck-content .page-break::after {
-	content: '';
-	position: absolute;
-	border-bottom: 2px dashed hsl(0, 0%, 77%);
-	width: 100%;
-}
-/* ckeditor5-page-break/theme/pagebreak.css */
-.ck-content .page-break__label {
-	position: relative;
-	z-index: 1;
-	padding: .3em .6em;
-	display: block;
-	text-transform: uppercase;
-	border: 1px solid hsl(0, 0%, 77%);
-	border-radius: 2px;
-	font-family: Helvetica, Arial, Tahoma, Verdana, Sans-Serif;
-	font-size: 0.75em;
-	font-weight: bold;
-	color: hsl(0, 0%, 20%);
-	background: #fff;
-	box-shadow: 2px 2px 1px hsla(0, 0%, 0%, 0.15);
-	-webkit-user-select: none;
-	-moz-user-select: none;
-	-ms-user-select: none;
-	user-select: none;
-}
 /* ckeditor5-block-quote/theme/blockquote.css */
 .ck-content blockquote {
 	overflow: hidden;
@@ -176,12 +137,46 @@ Below there is a full list of content styles used by the editor features. You ca
 	border-left: 0;
 	border-right: solid 5px hsl(0, 0%, 80%);
 }
-/* ckeditor5-media-embed/theme/mediaembed.css */
-.ck-content .media {
+/* ckeditor5-code-block/theme/codeblock.css */
+.ck-content pre {
+	padding: 1em;
+	color: hsl(0, 0%, 20.8%);
+	background: hsla(0, 0%, 78%, 0.3);
+	border: 1px solid hsl(0, 0%, 77%);
+	border-radius: 2px;
+	text-align: left;
+	direction: ltr;
+	tab-size: 4;
+	white-space: pre-wrap;
+	font-style: normal;
+	min-width: 200px;
+}
+/* ckeditor5-code-block/theme/codeblock.css */
+.ck-content pre code {
+	background: unset;
+	padding: 0;
+	border-radius: 0;
+}
+/* ckeditor5-horizontal-line/theme/horizontalline.css */
+.ck-content hr {
+	border-width: 1px 0 0;
+	border-style: solid;
+	border-color: hsl(0, 0%, 37%);
+	margin: 0;
+}
+/* ckeditor5-image/theme/image.css */
+.ck-content .image {
+	display: table;
 	clear: both;
-	margin: 1em 0;
+	text-align: center;
+	margin: 1em auto;
+}
+/* ckeditor5-image/theme/image.css */
+.ck-content .image > img {
 	display: block;
-	min-width: 15em;
+	margin: 0 auto;
+	max-width: 100%;
+	min-width: 50px;
 }
 /* ckeditor5-table/theme/table.css */
 .ck-content .table {
@@ -201,13 +196,28 @@ Below there is a full list of content styles used by the editor features. You ca
 .ck-content .table table th {
 	min-width: 2em;
 	padding: .4em;
-	border-color: hsl(0, 0%, 75%);
+	border: 1px solid hsl(0, 0%, 75%);
 }
 /* ckeditor5-table/theme/table.css */
 .ck-content .table table th {
 	font-weight: bold;
 	background: hsla(0, 0%, 0%, 5%);
 }
+/* ckeditor5-table/theme/table.css */
+.ck-content[dir="rtl"] .table th {
+	text-align: right;
+}
+/* ckeditor5-table/theme/table.css */
+.ck-content[dir="ltr"] .table th {
+	text-align: left;
+}
+/* ckeditor5-media-embed/theme/mediaembed.css */
+.ck-content .media {
+	clear: both;
+	margin: 1em 0;
+	display: block;
+	min-width: 15em;
+}
 /* ckeditor5-list/theme/todolist.css */
 .ck-content .todo-list {
 	list-style: none;
@@ -275,17 +285,6 @@ Below there is a full list of content styles used by the editor features. You ca
 .ck-content .todo-list .todo-list__label .todo-list__label__description {
 	vertical-align: middle;
 }
-/* ckeditor5-image/theme/imagecaption.css */
-.ck-content .image > figcaption {
-	display: table-caption;
-	caption-side: bottom;
-	word-break: break-word;
-	color: hsl(0, 0%, 20%);
-	background-color: hsl(0, 0%, 97%);
-	padding: .6em;
-	font-size: .75em;
-	outline-offset: -1px;
-}
 /* ckeditor5-highlight/theme/highlight.css */
 .ck-content .marker-yellow {
 	background-color: var(--ck-highlight-marker-yellow);
@@ -312,32 +311,57 @@ Below there is a full list of content styles used by the editor features. You ca
 	color: var(--ck-highlight-pen-green);
 	background-color: transparent;
 }
-/* ckeditor5-horizontal-line/theme/horizontalline.css */
-.ck-content hr {
-	border-width: 1px 0 0;
-	border-style: solid;
-	border-color: hsl(0, 0%, 37%);
-	margin: 0;
+/* ckeditor5-page-break/theme/pagebreak.css */
+.ck-content .page-break {
+	position: relative;
+	clear: both;
+	padding: 5px 0;
+	display: flex;
+	align-items: center;
+	justify-content: center;
 }
-/* ckeditor5-code-block/theme/codeblock.css */
-.ck-content pre {
-	padding: 1em;
-	color: #353535;
-	background: hsla(0, 0%, 78%, 0.3);
+/* ckeditor5-page-break/theme/pagebreak.css */
+.ck-content .page-break::after {
+	content: '';
+	position: absolute;
+	border-bottom: 2px dashed hsl(0, 0%, 77%);
+	width: 100%;
+}
+/* ckeditor5-page-break/theme/pagebreak.css */
+.ck-content .page-break__label {
+	position: relative;
+	z-index: 1;
+	padding: .3em .6em;
+	display: block;
+	text-transform: uppercase;
 	border: 1px solid hsl(0, 0%, 77%);
 	border-radius: 2px;
-	text-align: left;
-	direction: ltr;
-	tab-size: 4;
-	white-space: pre-wrap;
-	font-style: normal;
-	min-width: 200px;
+	font-family: Helvetica, Arial, Tahoma, Verdana, Sans-Serif;
+	font-size: 0.75em;
+	font-weight: bold;
+	color: hsl(0, 0%, 20%);
+	background: hsl(0, 0%, 100%);
+	box-shadow: 2px 2px 1px hsla(0, 0%, 0%, 0.15);
+	-webkit-user-select: none;
+	-moz-user-select: none;
+	-ms-user-select: none;
+	user-select: none;
 }
-/* ckeditor5-code-block/theme/codeblock.css */
-.ck-content pre code {
-	background: unset;
-	padding: 0;
-	border-radius: 0;
+/* ckeditor5-font/theme/fontsize.css */
+.ck-content .text-tiny {
+	font-size: .7em;
+}
+/* ckeditor5-font/theme/fontsize.css */
+.ck-content .text-small {
+	font-size: .85em;
+}
+/* ckeditor5-font/theme/fontsize.css */
+.ck-content .text-big {
+	font-size: 1.4em;
+}
+/* ckeditor5-font/theme/fontsize.css */
+.ck-content .text-huge {
+	font-size: 1.8em;
 }
 @media print {
 	/* ckeditor5-page-break/theme/pagebreak.css */

+ 45 - 11
packages/ckeditor5-engine/tests/manual/highlight.html

@@ -1,37 +1,61 @@
 <style>
+	:root {
+		--ck-manual-test-highlight-yellow: hsla(60, 100%, 50%, 0.5);
+		--ck-manual-test-highlight-red: hsla(0, 100%, 50%, 0.5);
+		--ck-manual-test-highlight-blue: hsla(240, 100%, 50%, 0.5);
+	}
+
 	span.highlight-yellow {
-		background-color: rgba( 255, 255, 0, 0.5 );
+		background-color: var(--ck-manual-test-highlight-yellow);
 	}
 
 	span.highlight-blue {
-		background-color: rgba( 0, 0, 255, 0.5 );
+		background-color: var(--ck-manual-test-highlight-blue);
 	}
 
 	span.highlight-red {
-		background-color: rgba( 255, 0, 0, 0.5 );
+		background-color: var(--ck-manual-test-highlight-red);
 	}
 
 	.ck-widget.fancy-widget {
-		background-color: black;
-		color: white;
+		background-color: hsl(0, 0%, 0%);
+		color: hsl(0, 0%, 100%);
 		text-align: center;
 		height: 40px;
 		line-height: 40px;
 	}
 
 	.ck-widget.fancy-widget.highlight-yellow {
-		background-color: yellow;
-		color: black;
+		background-color: var(--ck-manual-test-highlight-yellow);
+		color: hsl(0, 0%, 0%);
 	}
 
 	.ck-widget.fancy-widget.highlight-blue {
-		background-color: blue;
-		color: black;
+		background-color: var(--ck-manual-test-highlight-blue);
+		color: hsl(0, 0%, 0%);
 	}
 
 	.ck-widget.fancy-widget.highlight-red {
-		background-color: red;
-		color: black;
+		background-color: var(--ck-manual-test-highlight-red);
+		color: hsl(0, 0%, 0%);
+	}
+
+	.table td.highlight-yellow,
+	.table th.highlight-yellow {
+		/* !important to override selected editable styles. */
+		background-color: var(--ck-manual-test-highlight-yellow) !important;
+	}
+
+	.table td.highlight-blue,
+	.table th.highlight-blue {
+		/* !important to override selected editable styles. */
+		background-color: var(--ck-manual-test-highlight-blue) !important;
+	}
+
+	.table td.highlight-red,
+	.table th.highlight-red {
+		/* !important to override selected editable styles. */
+		background-color: var(--ck-manual-test-highlight-red) !important;
 	}
 
 	button {
@@ -43,6 +67,16 @@
 	<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit. Maecenas at diam quis justo imperdiet posuere non eu tortor. Mauris vel magna eu sem hendrerit varius. Ut imperdiet velit ut ante interdum convallis. Vestibulum vitae lacinia mi. </p>
 	<figure></figure>
 	<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit. Maecenas at diam quis justo imperdiet posuere non eu tortor. Mauris vel magna eu sem hendrerit varius. Ut imperdiet velit ut ante interdum convallis. Vestibulum vitae lacinia mi. </p>
+	<table>
+		<tr>
+			<td>foo</td>
+			<td>bar</td>
+		</tr>
+		<tr>
+			<td>baz</td>
+			<td>qux</td>
+		</tr>
+	</table>
 </div>
 <h2>Markers</h2>
 <button id="add-marker-yellow">Set yellow highlight</button><button id="remove-marker-yellow">Remove yellow highlight</button>

+ 3 - 2
packages/ckeditor5-engine/tests/manual/highlight.js

@@ -16,6 +16,7 @@ import Italic from '@ckeditor/ckeditor5-basic-styles/src/italic';
 import List from '@ckeditor/ckeditor5-list/src/list';
 import global from '@ckeditor/ckeditor5-utils/src/dom/global';
 import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
+import Table from '@ckeditor/ckeditor5-table/src/table';
 import Widget from '@ckeditor/ckeditor5-widget/src/widget';
 import { toWidget } from '@ckeditor/ckeditor5-widget/src/utils';
 
@@ -53,8 +54,8 @@ class FancyWidget extends Plugin {
 }
 
 ClassicEditor.create( global.document.querySelector( '#editor' ), {
-	plugins: [ Enter, Typing, Paragraph, Undo, Heading, Bold, Italic, List, FancyWidget ],
-	toolbar: [ 'heading', '|', 'undo', 'redo', 'bold', 'italic', 'numberedList', 'bulletedList' ]
+	plugins: [ Enter, Typing, Paragraph, Undo, Heading, Bold, Italic, List, Table, FancyWidget ],
+	toolbar: [ 'heading', '|', 'undo', 'redo', 'bold', 'italic', 'numberedList', 'bulletedList', 'insertTable' ]
 } )
 	.then( editor => {
 		window.editor = editor;

+ 6 - 1
packages/ckeditor5-engine/tests/manual/highlight.md

@@ -10,5 +10,10 @@ applied.
 1. Red highlight should be visible on the whole content, including the widget.
 1. Play with highlights, add and remove them, check if they are applied correctly.
 
-NOTE: Yellow highlight should be visible over red and blue one. Red highlight should be visible over blue one. 
+### Highlighting table cells
+
+1. You can select the entire table cell and apply a highlight to it.
+1. The highlight should change the background of an **entire cell** (not only its content).
+
+NOTE: Yellow highlight should be visible over red and blue one. Red highlight should be visible over blue one.
 

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

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


+ 21 - 1
packages/ckeditor5-table/src/converters/downcast.js

@@ -8,7 +8,7 @@
  */
 
 import TableWalker from './../tablewalker';
-import { toWidget, toWidgetEditable } from '@ckeditor/ckeditor5-widget/src/utils';
+import { toWidget, toWidgetEditable, setHighlightHandling } from '@ckeditor/ckeditor5-widget/src/utils';
 
 /**
  * Model table element to view table element conversion helper.
@@ -343,6 +343,13 @@ function renameViewTableCell( tableCell, desiredCellElementName, conversionApi,
 		const editable = viewWriter.createEditableElement( desiredCellElementName, viewCell.getAttributes() );
 		renamedCell = toWidgetEditable( editable, viewWriter );
 
+		setHighlightHandling(
+			renamedCell,
+			viewWriter,
+			( element, descriptor, writer ) => writer.addClass( normalizeToArray( descriptor.classes ), element ),
+			( element, descriptor, writer ) => writer.removeClass( normalizeToArray( descriptor.classes ), element )
+		);
+
 		viewWriter.insert( viewWriter.createPositionAfter( viewCell ), renamedCell );
 		viewWriter.move( viewWriter.createRangeIn( viewCell ), viewWriter.createPositionAt( renamedCell, 0 ) );
 		viewWriter.remove( viewWriter.createRangeOn( viewCell ) );
@@ -354,6 +361,10 @@ function renameViewTableCell( tableCell, desiredCellElementName, conversionApi,
 	conversionApi.mapper.bindElements( tableCell, renamedCell );
 }
 
+function normalizeToArray( classes ) {
+	return Array.isArray( classes ) ? classes : [ classes ];
+}
+
 // Renames a table cell element in the view according to its location in the table.
 //
 // @param {module:table/tablewalker~TableSlot} tableSlot
@@ -388,6 +399,15 @@ function createViewTableCellElement( tableSlot, tableAttributes, insertPosition,
 		toWidgetEditable( conversionApi.writer.createEditableElement( cellElementName ), conversionApi.writer ) :
 		conversionApi.writer.createContainerElement( cellElementName );
 
+	if ( asWidget ) {
+		setHighlightHandling(
+			cellElement,
+			conversionApi.writer,
+			( element, descriptor, writer ) => writer.addClass( normalizeToArray( descriptor.classes ), element ),
+			( element, descriptor, writer ) => writer.removeClass( normalizeToArray( descriptor.classes ), element )
+		);
+	}
+
 	const tableCell = tableSlot.cell;
 
 	const firstChild = tableCell.getChild( 0 );

+ 266 - 1
packages/ckeditor5-table/tests/converters/downcast.js

@@ -329,6 +329,7 @@ describe( 'downcast converters', () => {
 
 						defaultSchema( model.schema );
 						defaultConversion( editor.conversion, true );
+						markerConversion( editor.conversion );
 					} );
 			} );
 
@@ -350,6 +351,55 @@ describe( 'downcast converters', () => {
 					'</figure>'
 				);
 			} );
+
+			it( 'should apply marker class on tableCell', () => {
+				setModelData( model, modelTable( [ [ '00' ] ] ) );
+
+				model.change( writer => {
+					const cell = root.getNodeByPath( [ 0, 0, 0 ] );
+
+					writer.addMarker( 'marker:yellow', {
+						range: writer.createRangeOn( cell ),
+						usingOperation: false
+					} );
+
+					checkCustomPropertyForHighlight( editor.editing.mapper.toViewElement( cell ) );
+				} );
+
+				assertEqualMarkup( getViewData( view, { withoutSelection: true } ),
+					'<figure class="ck-widget ck-widget_with-selection-handle table" contenteditable="false">' +
+						'<div class="ck ck-widget__selection-handle"></div>' +
+						'<table>' +
+							'<tbody>' +
+								'<tr>' +
+									'<td class="ck-editor__editable ck-editor__nested-editable highlight-yellow" contenteditable="true">' +
+										'<span style="display:inline-block">00</span>' +
+									'</td>' +
+								'</tr>' +
+							'</tbody>' +
+						'</table>' +
+					'</figure>'
+				);
+
+				model.change( writer => {
+					writer.removeMarker( 'marker:yellow' );
+				} );
+
+				assertEqualMarkup( getViewData( view, { withoutSelection: true } ),
+					'<figure class="ck-widget ck-widget_with-selection-handle table" contenteditable="false">' +
+						'<div class="ck ck-widget__selection-handle"></div>' +
+						'<table>' +
+							'<tbody>' +
+								'<tr>' +
+									'<td class="ck-editor__editable ck-editor__nested-editable" contenteditable="true">' +
+										'<span style="display:inline-block">00</span>' +
+									'</td>' +
+								'</tr>' +
+							'</tbody>' +
+						'</table>' +
+					'</figure>'
+				);
+			} );
 		} );
 	} );
 
@@ -569,6 +619,7 @@ describe( 'downcast converters', () => {
 
 						defaultSchema( model.schema );
 						defaultConversion( editor.conversion, true );
+						markerConversion( editor.conversion );
 					} );
 			} );
 
@@ -602,6 +653,69 @@ describe( 'downcast converters', () => {
 					'</figure>'
 				);
 			} );
+
+			it( 'should apply marker class on tableCell', () => {
+				setModelData( model, modelTable( [ [ '00' ] ] ) );
+
+				const table = root.getChild( 0 );
+
+				model.change( writer => {
+					const firstRow = writer.createElement( 'tableRow' );
+					const cell = writer.createElement( 'tableCell' );
+
+					writer.insert( firstRow, table, 1 );
+					writer.insert( cell, firstRow, 'end' );
+
+					writer.addMarker( 'marker:yellow', {
+						range: writer.createRangeOn( cell ),
+						usingOperation: false
+					} );
+				} );
+
+				const cell = root.getNodeByPath( [ 0, 1, 0 ] );
+				checkCustomPropertyForHighlight( editor.editing.mapper.toViewElement( cell ) );
+
+				assertEqualMarkup( getViewData( view, { withoutSelection: true } ),
+					'<figure class="ck-widget ck-widget_with-selection-handle table" contenteditable="false">' +
+						'<div class="ck ck-widget__selection-handle"></div>' +
+						'<table>' +
+							'<tbody>' +
+								'<tr>' +
+									'<td class="ck-editor__editable ck-editor__nested-editable" contenteditable="true">' +
+										'<span style="display:inline-block">00</span>' +
+									'</td>' +
+								'</tr>' +
+								'<tr>' +
+									'<td class="ck-editor__editable ck-editor__nested-editable highlight-yellow" ' +
+										'contenteditable="true"></td>' +
+								'</tr>' +
+							'</tbody>' +
+						'</table>' +
+					'</figure>'
+				);
+
+				model.change( writer => {
+					writer.removeMarker( 'marker:yellow' );
+				} );
+
+				assertEqualMarkup( getViewData( view, { withoutSelection: true } ),
+					'<figure class="ck-widget ck-widget_with-selection-handle table" contenteditable="false">' +
+						'<div class="ck ck-widget__selection-handle"></div>' +
+						'<table>' +
+							'<tbody>' +
+								'<tr>' +
+									'<td class="ck-editor__editable ck-editor__nested-editable" contenteditable="true">' +
+										'<span style="display:inline-block">00</span>' +
+									'</td>' +
+								'</tr>' +
+								'<tr>' +
+									'<td class="ck-editor__editable ck-editor__nested-editable" contenteditable="true"></td>' +
+								'</tr>' +
+							'</tbody>' +
+						'</table>' +
+					'</figure>'
+				);
+			} );
 		} );
 	} );
 
@@ -731,6 +845,7 @@ describe( 'downcast converters', () => {
 
 						defaultSchema( model.schema );
 						defaultConversion( editor.conversion, true );
+						markerConversion( editor.conversion );
 					} );
 			} );
 
@@ -747,7 +862,64 @@ describe( 'downcast converters', () => {
 
 				assertEqualMarkup( getViewData( view, { withoutSelection: true } ),
 					'<figure class="ck-widget ck-widget_with-selection-handle table" contenteditable="false">' +
-					'<div class="ck ck-widget__selection-handle"></div>' +
+						'<div class="ck ck-widget__selection-handle"></div>' +
+						'<table>' +
+							'<tbody>' +
+								'<tr>' +
+									'<td class="ck-editor__editable ck-editor__nested-editable" contenteditable="true">' +
+										'<span style="display:inline-block">00</span>' +
+									'</td>' +
+									'<td class="ck-editor__editable ck-editor__nested-editable" contenteditable="true"></td>' +
+								'</tr>' +
+							'</tbody>' +
+						'</table>' +
+					'</figure>'
+				);
+			} );
+
+			it( 'should apply marker class on tableCell', () => {
+				setModelData( model, modelTable( [ [ '00' ] ] ) );
+
+				const table = root.getChild( 0 );
+
+				model.change( writer => {
+					const row = table.getChild( 0 );
+					const cell = writer.createElement( 'tableCell' );
+
+					writer.insert( cell, row, 'end' );
+					writer.addMarker( 'marker:yellow', {
+						range: writer.createRangeOn( cell ),
+						usingOperation: false
+					} );
+				} );
+
+				const cell = root.getNodeByPath( [ 0, 0, 1 ] );
+				checkCustomPropertyForHighlight( editor.editing.mapper.toViewElement( cell ) );
+
+				assertEqualMarkup( getViewData( view, { withoutSelection: true } ),
+					'<figure class="ck-widget ck-widget_with-selection-handle table" contenteditable="false">' +
+						'<div class="ck ck-widget__selection-handle"></div>' +
+						'<table>' +
+							'<tbody>' +
+								'<tr>' +
+									'<td class="ck-editor__editable ck-editor__nested-editable" contenteditable="true">' +
+										'<span style="display:inline-block">00</span>' +
+									'</td>' +
+									'<td class="ck-editor__editable ck-editor__nested-editable highlight-yellow" ' +
+										'contenteditable="true"></td>' +
+								'</tr>' +
+							'</tbody>' +
+						'</table>' +
+					'</figure>'
+				);
+
+				model.change( writer => {
+					writer.removeMarker( 'marker:yellow' );
+				} );
+
+				assertEqualMarkup( getViewData( view, { withoutSelection: true } ),
+					'<figure class="ck-widget ck-widget_with-selection-handle table" contenteditable="false">' +
+						'<div class="ck ck-widget__selection-handle"></div>' +
 						'<table>' +
 							'<tbody>' +
 								'<tr>' +
@@ -925,6 +1097,7 @@ describe( 'downcast converters', () => {
 
 						defaultSchema( model.schema );
 						defaultConversion( editor.conversion, true );
+						markerConversion( editor.conversion );
 					} );
 			} );
 
@@ -952,6 +1125,40 @@ describe( 'downcast converters', () => {
 					'</figure>'
 				);
 			} );
+
+			it( 'should preserve marker class on tableCell', () => {
+				setModelData( model, modelTable( [
+					[ '00', '01', '02', '03' ],
+					[ '10', '11', '12', '13' ]
+				], { headingColumns: 1 } ) );
+
+				const table = root.getChild( 0 );
+
+				model.change( writer => {
+					const cell = root.getNodeByPath( [ 0, 0, 1 ] );
+
+					writer.addMarker( 'marker:yellow', {
+						range: writer.createRangeOn( cell ),
+						usingOperation: false
+					} );
+				} );
+
+				model.change( writer => {
+					writer.setAttribute( 'headingColumns', 3, table );
+				} );
+
+				const cell = root.getNodeByPath( [ 0, 0, 1 ] );
+				const viewElement = editor.editing.mapper.toViewElement( cell );
+
+				checkCustomPropertyForHighlight( viewElement );
+				expect( viewElement.hasClass( 'highlight-yellow' ) ).to.be.true;
+
+				model.change( writer => {
+					writer.removeMarker( 'marker:yellow' );
+				} );
+
+				expect( viewElement.hasClass( 'highlight-yellow' ) ).to.be.false;
+			} );
 		} );
 	} );
 
@@ -965,6 +1172,8 @@ describe( 'downcast converters', () => {
 					doc = model.document;
 					root = doc.getRoot( 'main' );
 					view = editor.editing.view;
+
+					markerConversion( editor.conversion, 'marker' );
 				} );
 		} );
 
@@ -1174,6 +1383,41 @@ describe( 'downcast converters', () => {
 					'</figure>'
 				);
 			} );
+
+			it( 'should preserve marker class on tableCell', () => {
+				setModelData( model, modelTable( [
+					[ '00', '01' ],
+					[ '10', '11' ],
+					[ '20', '21' ]
+				], { headingRows: 1 } ) );
+
+				const table = root.getChild( 0 );
+
+				model.change( writer => {
+					const cell = root.getNodeByPath( [ 0, 1, 0 ] );
+
+					writer.addMarker( 'marker:yellow', {
+						range: writer.createRangeOn( cell ),
+						usingOperation: false
+					} );
+				} );
+
+				model.change( writer => {
+					writer.setAttribute( 'headingRows', 2, table );
+				} );
+
+				const cell = root.getNodeByPath( [ 0, 1, 0 ] );
+				const viewElement = editor.editing.mapper.toViewElement( cell );
+
+				checkCustomPropertyForHighlight( viewElement );
+				expect( viewElement.hasClass( 'highlight-yellow' ) ).to.be.true;
+
+				model.change( writer => {
+					writer.removeMarker( 'marker:yellow' );
+				} );
+
+				expect( viewElement.hasClass( 'highlight-yellow' ) ).to.be.false;
+			} );
 		} );
 	} );
 
@@ -1485,4 +1729,25 @@ describe( 'downcast converters', () => {
 			], { asWidget: true } ) );
 		} );
 	} );
+
+	function markerConversion( conversion, extraClasses = null ) {
+		conversion.for( 'editingDowncast' ).markerToHighlight( {
+			model: 'marker',
+			view: data => {
+				const className = 'highlight-' + data.markerName.split( ':' )[ 1 ];
+
+				return {
+					classes: extraClasses ? [ ...extraClasses, className ] : className
+				};
+			}
+		} );
+	}
+
+	function checkCustomPropertyForHighlight( viewElement ) {
+		const set = viewElement.getCustomProperty( 'addHighlight' );
+		const remove = viewElement.getCustomProperty( 'removeHighlight' );
+
+		expect( typeof set ).to.equal( 'function' );
+		expect( typeof remove ).to.equal( 'function' );
+	}
 } );