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

Merge branch 'master' into i/7956-reconversion-trigger-by

Maciej Gołaszewski 5 лет назад
Родитель
Сommit
e49cb6369b
41 измененных файлов с 935 добавлено и 390 удалено
  1. 8 6
      docs/framework/guides/support/versioning-policy.md
  2. 7 4
      package.json
  3. 75 16
      packages/ckeditor-cloud-services-core/src/token/token.js
  4. 202 103
      packages/ckeditor-cloud-services-core/tests/token/token.js
  5. 6 2
      packages/ckeditor-cloud-services-core/tests/uploadgateway/fileuploader.js
  6. 4 1
      packages/ckeditor-cloud-services-core/tests/uploadgateway/uploadgateway.js
  7. 12 16
      packages/ckeditor5-core/src/plugincollection.js
  8. 1 7
      packages/ckeditor5-engine/src/conversion/upcasthelpers.js
  9. 1 6
      packages/ckeditor5-image/docs/_snippets/features/image-insert-via-url.js
  10. 2 10
      packages/ckeditor5-image/docs/features/image.md
  11. 14 17
      packages/ckeditor5-image/src/imageinsert.js
  12. 1 1
      packages/ckeditor5-image/src/imageinsert/utils.js
  13. 7 1
      packages/ckeditor5-image/src/imagestyle/utils.js
  14. 9 13
      packages/ckeditor5-image/tests/imageinsert/imageinsertui.js
  15. 14 20
      packages/ckeditor5-image/tests/imageinsert/utils.js
  16. 5 7
      packages/ckeditor5-image/tests/manual/imageinsertviaurl.js
  17. 23 4
      packages/ckeditor5-link/src/linkimageediting.js
  18. 198 81
      packages/ckeditor5-link/tests/linkimageediting.js
  19. 32 12
      packages/ckeditor5-list/src/liststyleediting.js
  20. 184 4
      packages/ckeditor5-list/tests/liststyleediting.js
  21. 2 4
      packages/ckeditor5-media-embed/src/mediaregistry.js
  22. 2 2
      packages/ckeditor5-media-embed/tests/mediaembedediting.js
  23. 1 1
      packages/ckeditor5-media-embed/tests/mediaembedtoolbar.js
  24. 1 1
      packages/ckeditor5-mention/src/mentionui.js
  25. 1 1
      packages/ckeditor5-mention/tests/mentionui.js
  26. 11 1
      packages/ckeditor5-page-break/docs/features/page-break.md
  27. 1 1
      packages/ckeditor5-table/src/tablewalker.js
  28. 1 2
      packages/ckeditor5-ui/src/toolbar/toolbarview.js
  29. 2 4
      packages/ckeditor5-upload/src/adapters/simpleuploadadapter.js
  30. 1 3
      packages/ckeditor5-upload/src/filerepository.js
  31. 1 1
      packages/ckeditor5-upload/tests/adapters/simpleuploadadapter.js
  32. 1 1
      packages/ckeditor5-upload/tests/filerepository.js
  33. 9 18
      packages/ckeditor5-utils/src/ckeditorerror.js
  34. 8 8
      packages/ckeditor5-utils/src/observablemixin.js
  35. 8 2
      packages/ckeditor5-utils/tests/ckeditorerror.js
  36. 1 1
      packages/ckeditor5-watchdog/tests/editorwatchdog.js
  37. 1 1
      packages/ckeditor5-widget/src/widgettoolbarrepository.js
  38. 1 1
      packages/ckeditor5-widget/tests/widgettoolbarrepository.js
  39. 32 0
      tests/manual/all-features.html
  40. 30 6
      tests/manual/all-features.js
  41. 15 0
      tests/manual/all-features.md

+ 8 - 6
docs/framework/guides/support/versioning-policy.md

@@ -5,26 +5,28 @@ order: 50
 
 # Versioning policy
 
-CKEditor 5 consists of multiple npm packages (over 40, at the moment of writing this guide). When releasing them, we use the following rules:
+CKEditor 5 consists of multiple npm packages (over 50, at the moment of writing this guide). When releasing them, we use the following rules:
 
 * We use the `MAJOR.MINOR.PATCH` version identifiers.
-* Each package is versioned independently, with one exception — all packages are always in the same `MAJOR` version.
-* A major release of CKEditor 5 (i.e. of all its packages) is published when at least one of its packages must have a major release. This is to ensure the previous rule.
+* All packages are always in the same version.
+* A major release of CKEditor 5 (i.e. of all its packages) is published when at least one of its packages must have a major release.
+* A minor version of CKEditor 5 (i.e. of all its packages) is published when at least one of its packages must have a minor release and none of them require a major release.
 * A package must have a major release when it contains a *major breaking change*.
 * If none of the packages contain any *major breaking change*, the following rules are used to determine the new version of each package:
 	* If a package contains a *minor breaking change*, a `MINOR` version is increased.
 	* If a package contains a new feature, a `MINOR` version is increased.
 	* If a package contains only bug fixes, unrelated changes (e.g. updated translations), documentation or other internal changes, a `PATCH` version is increased.
+* In order to ensure that all packages are in the same version, some releases of certain packages may be empty (no changes).
 
 ## Major and minor breaking changes
 
 The ecosystem of CKEditor 5 consists of multiple layers. Our approach to breaking changes and their effect depends on which layer is affected.
 
-* **The integration layer.** This is the most commonly used API which is used to integrate and customize existing builds or editors built from source. It also includes their setup (which features are included and their default configuration).
+* **The integration layer.** This is the most commonly used API which is used to integrate and customize existing builds or editors built from source. It also includes their setup (whose features are included and their default configuration).
 	* Breaking changes frequency: as rarely as possible. Therefore, changes to this layer are usually done in a backward compatible way.
 	* A breaking change in this layer is understood as a **major breaking change**.
 * **The plugin development API layer.** This is the API exposed by packages such as {@link api/engine `@ckeditor/ckeditor5-engine`} or {@link api/core `@ckeditor/ckeditor5-core`}, which is commonly used by plugin developers.
-	* Breaking changes frequency: rarely. This layer is still frequently used by developers, therefore, we try to limit breaking changes. However, to avoid increasing the technical debt, from time to time we will introduce breaking changes to one or more packages. We also try to "batch" them in order to have as many breaking changes done in one release, to reduce the frequency of major releases.
+	* Breaking changes frequency: rarely. This layer is still frequently used by developers, therefore, we try to limit breaking changes. However, to avoid increasing the technical debt, from time to time we will introduce breaking changes to one or more packages. We also try to "batch" them in order to have as many breaking changes done in one release as possible, to reduce the frequency of major releases.
 	* A breaking change in this layer is understood as a **major breaking change**.
 * **The low-level customizability API layer.** This is the part of package APIs that allows tweaking the behavior of existing features, their UI, etc. and building other features on top of the existing ones or by using their helpers.
 	* Breaking changes frequency: frequent. This layer, while exposed by CKEditor 5 Framework, is often closely connected to the architecture of a certain feature and may expose some implementation details. We want this layer to be public as it increases the ability to reuse the code, however, we cannot guarantee its stability on the same level as in the two previous layers.
@@ -34,4 +36,4 @@ The ecosystem of CKEditor 5 consists of multiple layers. Our approach to breakin
 
 Prior to version 15.0.0 each package was versioned independently and followed the [semantic versioning (semver)](https://semver.org/). Following semver as close as possible was useful as it allowed to quickly identify what changed in each release of a certain package. However, it lead to [problems with building old versions of the editor](https://github.com/ckeditor/ckeditor5/issues/1746).
 
-Therefore, we switched to a more commonly used practice for an ecosystem of packages, which is to treat a single breaking change as a major release of all packages. It automatically fixed the aforementioned problem in all projects that use caret ranges in their `package.json` files.
+Therefore, we switched to a more commonly used practice for an ecosystem of packages, which is to treat a single breaking change as a major release of all packages. It automatically fixed the aforementioned problem in all projects that use caret ranges in their `package.json` files. Later on, we decided that it will be even more convenient for integrators if all packages are in the exact same version, which is also not uncommon (e.g. [Angular](https://github.com/angular/angular) follows this practice).

+ 7 - 4
package.json

@@ -82,9 +82,10 @@
     "@ckeditor/ckeditor5-dev-tests": "^23.3.0",
     "@ckeditor/ckeditor5-dev-utils": "^23.2.0",
     "@ckeditor/ckeditor5-dev-webpack-plugin": "^23.2.0",
-    "@ckeditor/ckeditor5-export-pdf": "^1.0.0",
-    "@ckeditor/ckeditor5-export-word": "^0.0.2",
+    "@ckeditor/ckeditor5-export-pdf": ">=1.0.0",
+    "@ckeditor/ckeditor5-export-word": ">=1.0.0",
     "@ckeditor/ckeditor5-inspector": "^2.2.0",
+    "@ckeditor/ckeditor5-pagination": ">=1.0.0",
     "@ckeditor/ckeditor5-react": "^2.1.0",
     "@ckeditor/ckeditor5-real-time-collaboration": "^22.0.0",
     "@ckeditor/ckeditor5-track-changes": "^22.0.0",
@@ -175,8 +176,10 @@
       "packages/*",
       "external/*",
       "external/*/packages/*",
-      "external/*/packages/**/ckeditor5",
-      "external/*/packages/**/frontend",
+      "external/*/external/*",
+      "external/*/external/*/packages/*",
+      "external/*/external/*/packages/**/ckeditor5",
+      "external/*/external/*/packages/**/frontend",
       "."
     ],
     "nohoist": [

+ 75 - 16
packages/ckeditor-cloud-services-core/src/token/token.js

@@ -7,13 +7,14 @@
  * @module cloud-services-core/token
  */
 
-/* globals XMLHttpRequest, setInterval, clearInterval */
+/* globals XMLHttpRequest, setTimeout, clearTimeout, atob */
 
 import mix from '@ckeditor/ckeditor5-utils/src/mix';
 import ObservableMixin from '@ckeditor/ckeditor5-utils/src/observablemixin';
 import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
 
-const DEFAULT_OPTIONS = { refreshInterval: 3600000, autoRefresh: true };
+const DEFAULT_OPTIONS = { autoRefresh: true };
+const DEFAULT_TOKEN_REFRESH_TIMEOUT_TIME = 3600000;
 
 /**
  * Class representing the token used for communication with CKEditor Cloud Services.
@@ -30,7 +31,6 @@ class Token {
 	 * value is a function it has to match the {@link module:cloud-services-core/token~refreshToken} interface.
 	 * @param {Object} options
 	 * @param {String} [options.initValue] Initial value of the token.
-	 * @param {Number} [options.refreshInterval=3600000] Delay between refreshes. Default 1 hour.
 	 * @param {Boolean} [options.autoRefresh=true] Specifies whether to start the refresh automatically.
 	 */
 	constructor( tokenUrlOrRefreshToken, options = DEFAULT_OPTIONS ) {
@@ -46,6 +46,10 @@ class Token {
 			);
 		}
 
+		if ( options.initValue ) {
+			this._validateTokenValue( options.initValue );
+		}
+
 		/**
 		 * Value of the token.
 		 * The value of the token is null if `initValue` is not provided or `init` method was not called.
@@ -84,10 +88,6 @@ class Token {
 	 */
 	init() {
 		return new Promise( ( resolve, reject ) => {
-			if ( this._options.autoRefresh ) {
-				this._startRefreshing();
-			}
-
 			if ( !this.value ) {
 				this.refreshToken()
 					.then( resolve )
@@ -96,6 +96,10 @@ class Token {
 				return;
 			}
 
+			if ( this._options.autoRefresh ) {
+				this._registerRefreshTokenTimeout();
+			}
+
 			resolve( this );
 		} );
 	}
@@ -106,7 +110,14 @@ class Token {
 	 */
 	refreshToken() {
 		return this._refresh()
-			.then( value => this.set( 'value', value ) )
+			.then( value => {
+				this._validateTokenValue( value );
+				this.set( 'value', value );
+
+				if ( this._options.autoRefresh ) {
+					this._registerRefreshTokenTimeout();
+				}
+			} )
 			.then( () => this );
 	}
 
@@ -114,25 +125,74 @@ class Token {
 	 * Destroys token instance. Stops refreshing.
 	 */
 	destroy() {
-		this._stopRefreshing();
+		clearTimeout( this._tokenRefreshTimeout );
+	}
+
+	/**
+	 * Checks whether the provided token follows the JSON Web Tokens (JWT) format.
+	 *
+	 * @protected
+	 * @param {String} tokenValue The token to validate.
+	 */
+	_validateTokenValue( tokenValue ) {
+		// The token must be a string.
+		const isString = typeof tokenValue === 'string';
+
+		// The token must be a plain string without quotes ("").
+		const isPlainString = !/^".*"$/.test( tokenValue );
+
+		// JWT token contains 3 parts: header, payload, and signature.
+		// Each part is separated by a dot.
+		const isJWTFormat = isString && tokenValue.split( '.' ).length === 3;
+
+		if ( !( isPlainString && isJWTFormat ) ) {
+			/**
+			 * The provided token must follow the [JSON Web Tokens](https://jwt.io/introduction/) format.
+			 *
+			 * @error token-not-in-jwt-format
+			 */
+			throw new CKEditorError( 'token-not-in-jwt-format', this );
+		}
 	}
 
 	/**
-	 * Starts value refreshing every `refreshInterval` time.
+	 * Registers a refresh token timeout for the time taken from token.
 	 *
 	 * @protected
 	 */
-	_startRefreshing() {
-		this._refreshInterval = setInterval( () => this.refreshToken(), this._options.refreshInterval );
+	_registerRefreshTokenTimeout() {
+		const tokenRefreshTimeoutTime = this._getTokenRefreshTimeoutTime();
+
+		clearTimeout( this._tokenRefreshTimeout );
+
+		this._tokenRefreshTimeout = setTimeout( () => {
+			this.refreshToken();
+		}, tokenRefreshTimeoutTime );
 	}
 
 	/**
-	 * Stops value refreshing.
+	 * Returns token refresh timeout time calculated from expire time in the token payload.
+	 *
+	 * If the token parse fails or the token payload doesn't contain, the default DEFAULT_TOKEN_REFRESH_TIMEOUT_TIME is returned.
 	 *
 	 * @protected
+	 * @returns {Number}
 	 */
-	_stopRefreshing() {
-		clearInterval( this._refreshInterval );
+	_getTokenRefreshTimeoutTime() {
+		try {
+			const [ , binaryTokenPayload ] = this.value.split( '.' );
+			const { exp: tokenExpireTime } = JSON.parse( atob( binaryTokenPayload ) );
+
+			if ( !tokenExpireTime ) {
+				return DEFAULT_TOKEN_REFRESH_TIMEOUT_TIME;
+			}
+
+			const tokenRefreshTimeoutTime = Math.floor( ( ( tokenExpireTime * 1000 ) - Date.now() ) / 2 );
+
+			return tokenRefreshTimeoutTime;
+		} catch ( err ) {
+			return DEFAULT_TOKEN_REFRESH_TIMEOUT_TIME;
+		}
 	}
 
 	/**
@@ -142,7 +202,6 @@ class Token {
 	 * value is a function it has to match the {@link module:cloud-services-core/token~refreshToken} interface.
 	 * @param {Object} options
 	 * @param {String} [options.initValue] Initial value of the token.
-	 * @param {Number} [options.refreshInterval=3600000] Delay between refreshes. Default 1 hour.
 	 * @param {Boolean} [options.autoRefresh=true] Specifies whether to start the refresh automatically.
 	 * @returns {Promise.<module:cloud-services-core/token~Token>}
 	 */

+ 202 - 103
packages/ckeditor-cloud-services-core/tests/token/token.js

@@ -26,31 +26,56 @@ describe( 'Token', () => {
 	} );
 
 	describe( 'constructor()', () => {
-		it( 'should throw error when no tokenUrl provided', () => {
+		it( 'should throw an error when no tokenUrl provided', () => {
 			expect( () => new Token() ).to.throw(
 				CKEditorError,
 				'token-missing-token-url'
 			);
 		} );
 
-		it( 'should set a init token value', () => {
-			const token = new Token( 'http://token-endpoint', { initValue: 'initValue', autoRefresh: false } );
+		it( 'should throw an error if the token passed in options is not a string', () => {
+			expect( () => new Token( 'http://token-endpoint', { initValue: 123456 } ) ).to.throw(
+				CKEditorError,
+				'token-not-in-jwt-format'
+			);
+		} );
+
+		it( 'should throw an error if the token passed in options is wrapped in additional quotes', () => {
+			const tokenInitValue = getTestTokenValue();
+
+			expect( () => new Token( 'http://token-endpoint', { initValue: `"${ tokenInitValue }"` } ) ).to.throw(
+				CKEditorError,
+				'token-not-in-jwt-format'
+			);
+		} );
 
-			expect( token.value ).to.equal( 'initValue' );
+		it( 'should throw an error if the token passed in options is not a valid JWT token', () => {
+			expect( () => new Token( 'http://token-endpoint', { initValue: 'token' } ) ).to.throw(
+				CKEditorError,
+				'token-not-in-jwt-format'
+			);
+		} );
+
+		it( 'should set token value if the token passed in options is valid', () => {
+			const tokenInitValue = getTestTokenValue();
+			const token = new Token( 'http://token-endpoint', { initValue: tokenInitValue } );
+
+			expect( token.value ).to.equal( tokenInitValue );
 		} );
 
 		it( 'should fire `change:value` event if the value of the token has changed', done => {
+			const tokenValue = getTestTokenValue();
 			const token = new Token( 'http://token-endpoint', { autoRefresh: false } );
 
 			token.on( 'change:value', ( event, name, newValue ) => {
-				expect( newValue ).to.equal( 'token-value' );
+				expect( newValue ).to.equal( tokenValue );
 
 				done();
 			} );
 
 			token.init();
 
-			requests[ 0 ].respond( 200, '', 'token-value' );
+			requests[ 0 ].respond( 200, '', tokenValue );
 		} );
 
 		it( 'should accept the callback in the constructor', () => {
@@ -62,98 +87,205 @@ describe( 'Token', () => {
 	} );
 
 	describe( 'init()', () => {
-		it( 'should get a token value from endpoint', done => {
+		it( 'should get a token value from the endpoint', done => {
+			const tokenValue = getTestTokenValue();
 			const token = new Token( 'http://token-endpoint', { autoRefresh: false } );
 
 			token.init()
 				.then( () => {
-					expect( token.value ).to.equal( 'token-value' );
+					expect( token.value ).to.equal( tokenValue );
 
 					done();
 				} );
 
-			requests[ 0 ].respond( 200, '', 'token-value' );
+			requests[ 0 ].respond( 200, '', tokenValue );
 		} );
 
 		it( 'should get a token from the refreshToken function when is provided', () => {
-			const token = new Token( () => Promise.resolve( 'token-value' ), { autoRefresh: false } );
+			const tokenValue = getTestTokenValue();
+			const token = new Token( () => Promise.resolve( tokenValue ), { autoRefresh: false } );
 
 			return token.init()
 				.then( () => {
-					expect( token.value ).to.equal( 'token-value' );
+					expect( token.value ).to.equal( tokenValue );
 				} );
 		} );
 
-		it( 'should start token refresh every 1 hour', done => {
-			const clock = sinon.useFakeTimers( { toFake: [ 'setInterval' ] } );
+		it( 'should not refresh token if autoRefresh is disabled in options', async () => {
+			const clock = sinon.useFakeTimers( { toFake: [ 'setTimeout' ] } );
+			const tokenInitValue = getTestTokenValue();
 
-			const token = new Token( 'http://token-endpoint', { initValue: 'initValue' } );
+			const token = new Token( 'http://token-endpoint', { initValue: tokenInitValue, autoRefresh: false } );
 
-			token.init()
-				.then( () => {
-					clock.tick( 3600000 );
-					clock.tick( 3600000 );
-					clock.tick( 3600000 );
-					clock.tick( 3600000 );
-					clock.tick( 3600000 );
+			await token.init();
 
-					expect( requests.length ).to.equal( 5 );
+			await clock.tickAsync( 3600000 );
 
-					clock.restore();
+			expect( requests ).to.be.empty;
 
-					done();
-				} );
+			clock.restore();
+		} );
+
+		it( 'should refresh token with the time specified in token `exp` payload property', async () => {
+			const clock = sinon.useFakeTimers( { toFake: [ 'setTimeout' ] } );
+			const tokenInitValue = getTestTokenValue();
+
+			const token = new Token( 'http://token-endpoint', { initValue: tokenInitValue } );
+
+			await token.init();
+
+			await clock.tickAsync( 1800000 );
+			requests[ 0 ].respond( 200, '', getTestTokenValue( 1500 ) );
+
+			await clock.tickAsync( 750000 );
+			requests[ 1 ].respond( 200, '', getTestTokenValue( 900 ) );
+
+			await clock.tickAsync( 450000 );
+			requests[ 2 ].respond( 200, '', getTestTokenValue( 450 ) );
+
+			await clock.tickAsync( 225000 );
+			requests[ 3 ].respond( 200, '', getTestTokenValue( 20 ) );
+
+			await clock.tickAsync( 10000 );
+			requests[ 4 ].respond( 200, '', getTestTokenValue( 20 ) );
+
+			expect( requests.length ).to.equal( 5 );
+
+			clock.restore();
+		} );
+
+		it( 'should refresh the token with the default time if getting token expiration time failed', async () => {
+			const clock = sinon.useFakeTimers( { toFake: [ 'setTimeout' ] } );
+			const tokenValue = 'header.test.signature';
+
+			const token = new Token( 'http://token-endpoint', { initValue: tokenValue } );
+
+			await token.init();
+
+			await clock.tickAsync( 3600000 );
+			requests[ 0 ].respond( 200, '', tokenValue );
+
+			await clock.tickAsync( 3600000 );
+			requests[ 1 ].respond( 200, '', tokenValue );
+
+			expect( requests.length ).to.equal( 2 );
+
+			clock.restore();
+		} );
+
+		it( 'should refresh the token with the default time if the token payload does not contain `exp` property', async () => {
+			const clock = sinon.useFakeTimers( { toFake: [ 'setTimeout' ] } );
+			const tokenValue = `header.${ btoa( JSON.stringify( {} ) ) }.signature`;
+
+			const token = new Token( 'http://token-endpoint', { initValue: tokenValue } );
+
+			await token.init();
+
+			await clock.tickAsync( 3600000 );
+			requests[ 0 ].respond( 200, '', tokenValue );
+
+			await clock.tickAsync( 3600000 );
+			requests[ 1 ].respond( 200, '', tokenValue );
+
+			await clock.tickAsync( 3600000 );
+			requests[ 2 ].respond( 200, '', tokenValue );
+
+			expect( requests.length ).to.equal( 3 );
+
+			clock.restore();
 		} );
 	} );
 
 	describe( 'destroy', () => {
-		it( 'should stop refreshing the token', () => {
-			const clock = sinon.useFakeTimers( { toFake: [ 'setInterval', 'clearInterval' ] } );
-			const token = new Token( 'http://token-endpoint', { initValue: 'initValue' } );
+		it( 'should stop refreshing the token', async () => {
+			const clock = sinon.useFakeTimers( { toFake: [ 'setTimeout', 'clearTimeout' ] } );
+			const tokenInitValue = getTestTokenValue();
 
-			return token.init()
-				.then( () => {
-					clock.tick( 3600000 );
-					clock.tick( 3600000 );
+			const token = new Token( 'http://token-endpoint', { initValue: tokenInitValue } );
 
-					expect( requests.length ).to.equal( 2 );
+			await token.init();
 
-					token.destroy();
+			await clock.tickAsync( 1800000 );
+			requests[ 0 ].respond( 200, '', getTestTokenValue( 1500 ) );
+			await clock.tickAsync( 100 );
 
-					clock.tick( 3600000 );
-					clock.tick( 3600000 );
-					clock.tick( 3600000 );
+			await clock.tickAsync( 750000 );
+			requests[ 1 ].respond( 200, '', getTestTokenValue( 900 ) );
+			await clock.tickAsync( 100 );
 
-					expect( requests.length ).to.equal( 2 );
-				} );
+			token.destroy();
+
+			await clock.tickAsync( 3600000 );
+			await clock.tickAsync( 3600000 );
+			await clock.tickAsync( 3600000 );
+
+			expect( requests.length ).to.equal( 2 );
+
+			clock.restore();
 		} );
 	} );
 
 	describe( 'refreshToken()', () => {
 		it( 'should get a token from the specified address', done => {
-			const token = new Token( 'http://token-endpoint', { initValue: 'initValue', autoRefresh: false } );
+			const tokenValue = getTestTokenValue();
+			const token = new Token( 'http://token-endpoint', { autoRefresh: false } );
 
 			token.refreshToken()
 				.then( newToken => {
-					expect( newToken.value ).to.equal( 'token-value' );
+					expect( newToken.value ).to.equal( tokenValue );
 
 					done();
 				} );
 
-			requests[ 0 ].respond( 200, '', 'token-value' );
+			requests[ 0 ].respond( 200, '', tokenValue );
+		} );
+
+		it( 'should throw an error if the returned token is wrapped in additional quotes', done => {
+			const tokenValue = getTestTokenValue();
+			const token = new Token( 'http://token-endpoint', { autoRefresh: false } );
+
+			token.refreshToken()
+				.then( () => {
+					done( new Error( 'Promise should be rejected' ) );
+				} )
+				.catch( error => {
+					expect( error.constructor ).to.equal( CKEditorError );
+					expect( error ).to.match( /token-not-in-jwt-format/ );
+					done();
+				} );
+
+			requests[ 0 ].respond( 200, '', `"${ tokenValue }"` );
+		} );
+
+		it( 'should throw an error if the returned token is not a valid JWT token', done => {
+			const token = new Token( 'http://token-endpoint', { autoRefresh: false } );
+
+			token.refreshToken()
+				.then( () => {
+					done( new Error( 'Promise should be rejected' ) );
+				} )
+				.catch( error => {
+					expect( error.constructor ).to.equal( CKEditorError );
+					expect( error ).to.match( /token-not-in-jwt-format/ );
+					done();
+				} );
+
+			requests[ 0 ].respond( 200, '', 'token' );
 		} );
 
 		it( 'should get a token from the specified callback function', () => {
-			const token = new Token( () => Promise.resolve( 'token-value' ), { initValue: 'initValue', autoRefresh: false } );
+			const tokenValue = getTestTokenValue();
+			const token = new Token( () => Promise.resolve( tokenValue ), { autoRefresh: false } );
 
 			return token.refreshToken()
 				.then( newToken => {
-					expect( newToken.value ).to.equal( 'token-value' );
+					expect( newToken.value ).to.equal( tokenValue );
 				} );
 		} );
 
-		it( 'should throw an error when cannot download new token', () => {
-			const token = new Token( 'http://token-endpoint', { initValue: 'initValue', autoRefresh: false } );
+		it( 'should throw an error when cannot download a new token', () => {
+			const tokenInitValue = getTestTokenValue();
+			const token = new Token( 'http://token-endpoint', { initValue: tokenInitValue, autoRefresh: false } );
 			const promise = token._refresh();
 
 			requests[ 0 ].respond( 401 );
@@ -167,7 +299,8 @@ describe( 'Token', () => {
 		} );
 
 		it( 'should throw an error when the response is aborted', () => {
-			const token = new Token( 'http://token-endpoint', { initValue: 'initValue', autoRefresh: false } );
+			const tokenInitValue = getTestTokenValue();
+			const token = new Token( 'http://token-endpoint', { initValue: tokenInitValue, autoRefresh: false } );
 			const promise = token._refresh();
 
 			requests[ 0 ].abort();
@@ -180,7 +313,8 @@ describe( 'Token', () => {
 		} );
 
 		it( 'should throw an error when network error occurs', () => {
-			const token = new Token( 'http://token-endpoint', { initValue: 'initValue', autoRefresh: false } );
+			const tokenInitValue = getTestTokenValue();
+			const token = new Token( 'http://token-endpoint', { initValue: tokenInitValue, autoRefresh: false } );
 			const promise = token._refresh();
 
 			requests[ 0 ].error();
@@ -192,8 +326,9 @@ describe( 'Token', () => {
 			} );
 		} );
 
-		it( 'should throw an error when the callback throws error', () => {
-			const token = new Token( () => Promise.reject( 'Custom error occurred' ), { initValue: 'initValue', autoRefresh: false } );
+		it( 'should throw an error when the callback throws an error', () => {
+			const tokenInitValue = getTestTokenValue();
+			const token = new Token( () => Promise.reject( 'Custom error occurred' ), { initValue: tokenInitValue, autoRefresh: false } );
 
 			token.refreshToken()
 				.catch( error => {
@@ -202,75 +337,39 @@ describe( 'Token', () => {
 		} );
 	} );
 
-	describe( '_startRefreshing()', () => {
-		it( 'should start refreshing', () => {
-			const clock = sinon.useFakeTimers( { toFake: [ 'setInterval' ] } );
-
-			const token = new Token( 'http://token-endpoint', { initValue: 'initValue', autoRefresh: false } );
-
-			token._startRefreshing();
-
-			clock.tick( 3600000 );
-			clock.tick( 3600000 );
-			clock.tick( 3600000 );
-			clock.tick( 3600000 );
-			clock.tick( 3600000 );
-
-			expect( requests.length ).to.equal( 5 );
-
-			clock.restore();
-		} );
-	} );
-
-	describe( '_stopRefreshing()', () => {
-		it( 'should stop refreshing', done => {
-			const clock = sinon.useFakeTimers( { toFake: [ 'setInterval', 'clearInterval' ] } );
-
-			const token = new Token( 'http://token-endpoint', { initValue: 'initValue' } );
-
-			token.init()
-				.then( () => {
-					clock.tick( 3600000 );
-					clock.tick( 3600000 );
-					clock.tick( 3600000 );
-
-					token._stopRefreshing();
-
-					clock.tick( 3600000 );
-					clock.tick( 3600000 );
-
-					expect( requests.length ).to.equal( 3 );
-
-					clock.restore();
-
-					done();
-				} );
-		} );
-	} );
-
 	describe( 'static create()', () => {
-		it( 'should return a initialized token', done => {
+		it( 'should return an initialized token', done => {
+			const tokenValue = getTestTokenValue();
+
 			Token.create( 'http://token-endpoint', { autoRefresh: false } )
 				.then( token => {
-					expect( token.value ).to.equal( 'token-value' );
+					expect( token.value ).to.equal( tokenValue );
 
 					done();
 				} );
 
-			requests[ 0 ].respond( 200, '', 'token-value' );
+			requests[ 0 ].respond( 200, '', tokenValue );
 		} );
 
 		it( 'should use default options when none passed', done => {
-			const intervalSpy = sinon.spy( window, 'setInterval' );
+			const tokenValue = getTestTokenValue();
 
 			Token.create( 'http://token-endpoint' )
-				.then( () => {
-					expect( intervalSpy.args[ 0 ][ 1 ] ).to.equal( 3600000 );
+				.then( token => {
+					expect( token._options ).to.eql( { autoRefresh: true } );
 
 					done();
 				} );
 
-			requests[ 0 ].respond( 200, '', 'token-value' );
+			requests[ 0 ].respond( 200, '', tokenValue );
 		} );
 	} );
 } );
+
+// Returns valid token for tests with given expiration time offset.
+//
+// @param {Number} [timeOffset=3600000]
+// @returns {String}
+function getTestTokenValue( timeOffset = 3600 ) {
+	return `header.${ btoa( JSON.stringify( { exp: ( Math.floor( Date.now() / 1000 ) ) + timeOffset } ) ) }.signature`;
+}

+ 6 - 2
packages/ckeditor-cloud-services-core/tests/uploadgateway/fileuploader.js

@@ -14,7 +14,8 @@ const BASE_64_FILE = 'data:image/gif;base64,R0lGODlhCQAJAPIAAGFhYZXK/1FRUf///' +
 	'9ra2gD/AAAAAAAAACH5BAEAAAUALAAAAAAJAAkAAAMYWFqwru2xERcYJLSNNWNBVimC5wjfaTkJADs=';
 
 describe( 'FileUploader', () => {
-	const token = new Token( 'url', { initValue: 'token', autoRefresh: false } );
+	const tokenInitValue = `header.${ btoa( JSON.stringify( { exp: Date.now() + 3600000 } ) ) }.signature`;
+	const token = new Token( 'url', { initValue: tokenInitValue, autoRefresh: false } );
 
 	let fileUploader;
 
@@ -116,9 +117,12 @@ describe( 'FileUploader', () => {
 					expect( request.url ).to.equal( API_ADDRESS );
 					expect( request.method ).to.equal( 'POST' );
 					expect( request.responseType ).to.equal( 'json' );
-					expect( request.requestHeaders ).to.be.deep.equal( { Authorization: 'token' } );
+					expect( request.requestHeaders ).to.be.deep.equal( { Authorization: tokenInitValue } );
 
 					done();
+				} )
+				.catch( err => {
+					console.log( err );
 				} );
 
 			request.respond( 200, { 'Content-Type': 'application/json' },

+ 4 - 1
packages/ckeditor-cloud-services-core/tests/uploadgateway/uploadgateway.js

@@ -3,13 +3,16 @@
  * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
  */
 
+/* eslint-env browser */
+
 import FileUploader from '../../src/uploadgateway/fileuploader';
 import UploadGateway from '../../src/uploadgateway/uploadgateway';
 import Token from '../../src/token/token';
 import { expectToThrowCKEditorError } from '@ckeditor/ckeditor5-utils/tests/_utils/utils';
 
 describe( 'UploadGateway', () => {
-	const token = new Token( 'url', { initValue: 'token', autoRefresh: false } );
+	const tokenInitValue = `header.${ btoa( JSON.stringify( { exp: Date.now() + 3600000 } ) ) }.signature`;
+	const token = new Token( 'url', { initValue: tokenInitValue, autoRefresh: false } );
 
 	describe( 'constructor()', () => {
 		it( 'should throw error when no token provided', () => {

+ 12 - 16
packages/ckeditor5-core/src/plugincollection.js

@@ -117,6 +117,12 @@ export default class PluginCollection {
 		const plugin = this._plugins.get( key );
 
 		if ( !plugin ) {
+			let pluginName = key;
+
+			if ( typeof key == 'function' ) {
+				pluginName = key.pluginName || key.name;
+			}
+
 			/**
 			 * The plugin is not loaded and could not be obtained.
 			 *
@@ -131,15 +137,7 @@ export default class PluginCollection {
 			 * @error plugincollection-plugin-not-loaded
 			 * @param {String} plugin The name of the plugin which is not loaded.
 			 */
-			const errorMsg = 'plugincollection-plugin-not-loaded: The requested plugin is not loaded.';
-
-			let pluginName = key;
-
-			if ( typeof key == 'function' ) {
-				pluginName = key.pluginName || key.name;
-			}
-
-			throw new CKEditorError( errorMsg, this._context, { plugin: pluginName } );
+			throw new CKEditorError( 'plugincollection-plugin-not-loaded', this._context, { plugin: pluginName } );
 		}
 
 		return plugin;
@@ -206,12 +204,12 @@ export default class PluginCollection {
 			 * @error plugincollection-plugin-not-found
 			 * @param {Array.<String>} plugins The name of the plugins which could not be loaded.
 			 */
-			const errorMsg = 'plugincollection-plugin-not-found: Some plugins are not available and could not be loaded.';
+			const errorId = 'plugincollection-plugin-not-found';
 
-			// Log the error so it's more visible on the console. Hopefully, for better DX.
-			console.error( attachLinkToDocumentation( errorMsg ), { plugins: missingPlugins } );
+			// Log the error, so it's more visible on the console. Hopefully, for better DX.
+			console.error( attachLinkToDocumentation( errorId ), { plugins: missingPlugins } );
 
-			return Promise.reject( new CKEditorError( errorMsg, context, { plugins: missingPlugins } ) );
+			return Promise.reject( new CKEditorError( errorId, context, { plugins: missingPlugins } ) );
 		}
 
 		return Promise.all( pluginConstructors.map( loadPlugin ) )
@@ -251,9 +249,7 @@ export default class PluginCollection {
 					 * @error plugincollection-load
 					 * @param {String} plugin The name of the plugin that could not be loaded.
 					 */
-					console.error( attachLinkToDocumentation(
-						'plugincollection-load: It was not possible to load the plugin.'
-					), { plugin: PluginConstructor } );
+					console.error( attachLinkToDocumentation( 'plugincollection-load' ), { plugin: PluginConstructor } );
 
 					throw err;
 				} );

+ 1 - 7
packages/ckeditor5-engine/src/conversion/upcasthelpers.js

@@ -349,13 +349,7 @@ export default class UpcastHelpers extends ConversionHelpers {
 		 *
 		 * @error upcast-helpers-element-to-marker-deprecated
 		 */
-		console.warn(
-			attachLinkToDocumentation(
-				'upcast-helpers-element-to-marker-deprecated: ' +
-				'The UpcastHelpers#elementToMarker() method was deprecated and will be removed in the near future. ' +
-				'Please use UpcastHelpers#dataToMarker() instead.'
-			)
-		);
+		console.warn( attachLinkToDocumentation( 'upcast-helpers-element-to-marker-deprecated' ) );
 
 		return this.add( upcastElementToMarker( config ) );
 	}

+ 1 - 6
packages/ckeditor5-image/docs/_snippets/features/image-insert-via-url.js

@@ -19,12 +19,7 @@ ClassicEditor
 			viewportTopOffset: window.getViewportTopOffsetConfig()
 		},
 		image: {
-			toolbar: [ 'imageStyle:full', 'imageStyle:side', '|', 'imageTextAlternative' ],
-			upload: {
-				panel: {
-					items: [ 'insertImageViaUrl' ]
-				}
-			}
+			toolbar: [ 'imageStyle:full', 'imageStyle:side', '|', 'imageTextAlternative' ]
 		},
 		cloudServices: CS_CONFIG
 	} )

+ 2 - 10
packages/ckeditor5-image/docs/features/image.md

@@ -87,7 +87,7 @@ See the {@link features/image-upload Image upload} guide.
 
 Besides the ability to insert images by uploading them directly from your disk or via CKFinder, you can also configure CKEditor 5 to allow inserting images via source URL.
 
-In order to enable this option, install the `ImageInsert` plugin and configure {@link module:image/imageupload~ImageUploadPanelConfig#items `image.upload.panel.items`} like below:
+In order to enable this option, install the `ImageInsert` plugin and add the `imageInsert` button to the toolbar (it replaces the standard `imageUpload` button).
 
 ```js
 import ImageInsert from '@ckeditor/ckeditor5-image/src/imageinsert';
@@ -95,15 +95,7 @@ import ImageInsert from '@ckeditor/ckeditor5-image/src/imageinsert';
 ClassicEditor
 	.create( document.querySelector( '#editor' ), {
 		plugins: [ ... , ImageInsert ],
-		toolbar: [ ... , 'imageInsert' ],
-		image: {
-			// ...
-			upload: {
-				panel: {
-					items: [ 'insertImageViaUrl' ]
-				}
-			}
-		}
+		toolbar: [ ... , 'imageInsert' ]
 	} )
 ```
 

+ 14 - 17
packages/ckeditor5-image/src/imageinsert.js

@@ -42,11 +42,10 @@ export default class ImageInsert extends Plugin {
 }
 
 /**
- * Image insert panel view configuration.
- * **NOTE:** Currently the panel settings are configurable through the `image.upload` property.
+ * The image insert configuration.
  *
  * @protected
- * @member {module:image/imageupload~ImageUploadPanelConfig} module:image/imageupload~ImageUploadConfig#panel
+ * @member {module:image/imageinsert~ImageInsertConfig} module:image/image~ImageConfig#insert
  */
 
 /**
@@ -55,8 +54,8 @@ export default class ImageInsert extends Plugin {
  *		ClassicEditor
  *			.create( editorElement, {
  * 				image: {
- * 					upload: {
- * 						panel: ... // panel settings for "imageInsert" view goes here
+ * 					insert: {
+ *						... // settings for "imageInsert" view goes here
  * 					}
  * 				}
  *			} )
@@ -66,11 +65,11 @@ export default class ImageInsert extends Plugin {
  * See {@link module:core/editor/editorconfig~EditorConfig all editor options}.
  *
  * @protected
- * @interface module:image/imageupload~ImageUploadPanelConfig
+ * @interface module:image/imageinsert~ImageInsertConfig
  */
 
 /**
- * The list of {@link module:image/imageinsert~ImageInsert} integrations.
+ * The image insert panel view configuration contains a list of {@link module:image/imageinsert~ImageInsert} integrations.
  *
  * The option accepts string tokens.
  * * for predefined integrations, we have two special strings: `insertImageViaUrl` and `openCKFinder`.
@@ -81,18 +80,16 @@ export default class ImageInsert extends Plugin {
  * in that case should be `pluginXButton`.
  *
  *		// Add `insertImageViaUrl`, `openCKFinder` and custom `pluginXButton` integrations.
- *		const imageUploadConfig = {
- *			upload: {
- *				panel: {
- *					items: [
- *						'insertImageViaUrl',
- *						'openCKFinder',
- *						'pluginXButton'
- *					]
- *				}
+ *		const imageInsertConfig = {
+ *			insert: {
+ *				integrations: [
+ *					'insertImageViaUrl',
+ *					'openCKFinder',
+ *					'pluginXButton'
+ *				]
  *			}
  *		};
  *
- * @member {Array.<String>} module:image/imageupload~ImageUploadPanelConfig#items
+ * @member {Array.<String>} module:image/imageinsert~ImageInsertConfig#integrations
  * @default [ 'insertImageViaUrl' ]
  */

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

@@ -19,7 +19,7 @@ import { createLabeledInputText } from '@ckeditor/ckeditor5-ui/src/labeledfield/
  * @returns {Object.<String, module:ui/view~View>} Integrations object.
  */
 export function prepareIntegrations( editor ) {
-	const panelItems = editor.config.get( 'image.upload.panel.items' );
+	const panelItems = editor.config.get( 'image.insert.integrations' );
 	const imageInsertUIPlugin = editor.plugins.get( 'ImageInsertUI' );
 
 	const PREDEFINED_INTEGRATIONS = {

+ 7 - 1
packages/ckeditor5-image/src/imagestyle/utils.js

@@ -116,8 +116,14 @@ function _normalizeStyle( style ) {
 		}
 		// If it's just a name but none of the defaults, warn because probably it's a mistake.
 		else {
+			/**
+			 * There is no such image style of given name.
+			 *
+			 * @error image-style-not-found
+		 	 * @param {String} name Name of a missing style name.
+			 */
 			console.warn(
-				attachLinkToDocumentation( 'image-style-not-found: There is no such image style of given name.' ),
+				attachLinkToDocumentation( 'image-style-not-found' ),
 				{ name: styleName }
 			);
 

+ 9 - 13
packages/ckeditor5-image/tests/imageinsert/imageinsertui.js

@@ -48,12 +48,10 @@ describe( 'ImageInsertUI', () => {
 					plugins: [ Paragraph, Image, ImageInsert, ImageInsertUI, FileRepository, UploadAdapterPluginMock, Clipboard ],
 					toolbar: [ 'imageInsert' ],
 					image: {
-						upload: {
-							panel: {
-								items: [
-									'insertImageViaUrl'
-								]
-							}
+						insert: {
+							integrations: [
+								'insertImageViaUrl'
+							]
 						}
 					}
 				} )
@@ -296,13 +294,11 @@ describe( 'ImageInsertUI', () => {
 						Clipboard
 					],
 					image: {
-						upload: {
-							panel: {
-								items: [
-									'insertImageViaUrl',
-									'openCKFinder'
-								]
-							}
+						insert: {
+							integrations: [
+								'insertImageViaUrl',
+								'openCKFinder'
+							]
 						}
 					}
 				} );

+ 14 - 20
packages/ckeditor5-image/tests/imageinsert/utils.js

@@ -30,13 +30,11 @@ describe( 'Upload utils', () => {
 						ImageUploadUI
 					],
 					image: {
-						upload: {
-							panel: {
-								items: [
-									'insertImageViaUrl',
-									'openCKFinder'
-								]
-							}
+						insert: {
+							integrations: [
+								'insertImageViaUrl',
+								'openCKFinder'
+							]
 						}
 					}
 				} );
@@ -64,13 +62,11 @@ describe( 'Upload utils', () => {
 						ImageUploadUI
 					],
 					image: {
-						upload: {
-							panel: {
-								items: [
-									'insertImageViaUrl',
-									'openCKFinder'
-								]
-							}
+						insert: {
+							integrations: [
+								'insertImageViaUrl',
+								'openCKFinder'
+							]
 						}
 					}
 				} );
@@ -94,12 +90,10 @@ describe( 'Upload utils', () => {
 						ImageUploadUI
 					],
 					image: {
-						upload: {
-							panel: {
-								items: [
-									'link'
-								]
-							}
+						insert: {
+							integrations: [
+								'link'
+							]
 						}
 					}
 				} );

+ 5 - 7
packages/ckeditor5-image/tests/manual/imageinsertviaurl.js

@@ -30,13 +30,11 @@ ClassicEditor
 		],
 		image: {
 			toolbar: [ 'imageStyle:full', 'imageStyle:side', '|', 'imageTextAlternative' ],
-			upload: {
-				panel: {
-					items: [
-						'insertImageViaUrl',
-						'openCKFinder'
-					]
-				}
+			insert: {
+				integrations: [
+					'insertImageViaUrl',
+					'openCKFinder'
+				]
 			}
 		},
 		ckfinder: {

+ 23 - 4
packages/ckeditor5-link/src/linkimageediting.js

@@ -95,7 +95,7 @@ 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' );
+			const imageInLink = getFirstImage( viewLink );
 
 			if ( !imageInLink ) {
 				return;
@@ -118,11 +118,11 @@ function upcastLink() {
 			}
 
 			// A full definition of the image feature.
-			// figure > a > img: parent of the link element is an image element.
+			// figure > a > img: parent of the view link element is an image element (figure).
 			let modelElement = data.modelCursor.parent;
 
 			if ( !modelElement.is( 'element', 'image' ) ) {
-				// a > img: parent of the link is not the image element. We need to convert it manually.
+				// a > img: parent of the view link is not the image (figure) element. We need to convert it manually.
 				const conversionResult = conversionApi.convertItem( imageInLink, data.modelCursor );
 
 				// Set image range as conversion result.
@@ -229,6 +229,13 @@ function upcastImageLinkManualDecorator( manualDecorators, decorator ) {
 	return dispatcher => {
 		dispatcher.on( 'element:a', ( evt, data, conversionApi ) => {
 			const viewLink = data.viewItem;
+			const imageInLink = getFirstImage( viewLink );
+
+			// We need to check whether an image is inside a link because the converter handles
+			// only manual decorators for linked images. See #7975.
+			if ( !imageInLink ) {
+				return;
+			}
 
 			const consumableAttributes = {
 				attributes: manualDecorators.get( decorator.id ).attributes
@@ -248,10 +255,22 @@ function upcastImageLinkManualDecorator( manualDecorators, decorator ) {
 			}
 
 			// At this stage we can assume that we have the `<image>` element.
-			const modelElement = data.modelCursor.parent;
+			// `nodeBefore` comes after conversion: `<a><img></a>`.
+			// `parent` comes with full image definition: `<figure><a><img></a></figure>.
+			// See the body of the `upcastLink()` function.
+			const modelElement = data.modelCursor.nodeBefore || data.modelCursor.parent;
 
 			conversionApi.writer.setAttribute( decorator.id, true, modelElement );
 		}, { priority: 'high' } );
 		// Using the same priority that `upcastLink()` converter guarantees that the linked image was properly converted.
 	};
 }
+
+// Returns the first image in a given view element.
+//
+// @private
+// @param {module:engine/view/element~Element}
+// @returns {module:engine/view/element~Element|undefined}
+function getFirstImage( viewElement ) {
+	return Array.from( viewElement.getChildren() ).find( child => child.name === 'img' );
+}

+ 198 - 81
packages/ckeditor5-link/tests/linkimageediting.js

@@ -587,35 +587,57 @@ describe( 'LinkImageEditing', () => {
 		} );
 
 		describe( 'upcast converter', () => {
-			let editor;
-
-			it( 'should upcast attributes from initial data', async () => {
-				editor = await VirtualTestEditor.create( {
-					initialData: '<figure class="image"><a href="url" target="_blank" rel="noopener noreferrer" download="file">' +
-						'<img src="/assets/sample.png"></a></figure>',
-					plugins: [ Paragraph, LinkImageEditing ],
-					link: {
-						decorators: {
-							isExternal: {
-								mode: 'manual',
-								label: 'Open in a new window',
-								attributes: {
-									target: '_blank',
-									rel: 'noopener noreferrer'
-								}
-							},
-							isDownloadable: {
-								mode: 'manual',
-								label: 'Downloadable',
-								attributes: {
-									download: 'file'
+			let editor, model;
+
+			beforeEach( () => {
+				return VirtualTestEditor
+					.create( {
+						plugins: [ Paragraph, LinkImageEditing ],
+						link: {
+							decorators: {
+								isExternal: {
+									mode: 'manual',
+									label: 'Open in a new tab',
+									attributes: {
+										target: '_blank',
+										rel: 'noopener noreferrer'
+									}
+								},
+								isDownloadable: {
+									mode: 'manual',
+									label: 'Downloadable',
+									attributes: {
+										download: 'download'
+									}
+								},
+								isGallery: {
+									mode: 'manual',
+									label: 'Gallery link',
+									attributes: {
+										class: 'gallery'
+									}
 								}
 							}
 						}
-					}
-				} );
+					} )
+					.then( newEditor => {
+						editor = newEditor;
+						model = editor.model;
+					} );
+			} );
 
-				model = editor.model;
+			afterEach( () => {
+				return editor.destroy();
+			} );
+
+			it( 'should upcast attributes', async () => {
+				editor.setData(
+					'<figure class="image">' +
+						'<a href="url" target="_blank" rel="noopener noreferrer" download="download">' +
+							'<img src="/assets/sample.png">' +
+						'</a>' +
+					'</figure>'
+				);
 
 				expect( getModelData( model, { withoutSelection: true } ) ).to.equal(
 					'<image linkHref="url" linkIsDownloadable="true" linkIsExternal="true" src="/assets/sample.png"></image>'
@@ -625,67 +647,20 @@ describe( 'LinkImageEditing', () => {
 			} );
 
 			it( 'should not upcast partial and incorrect attributes', async () => {
-				editor = await VirtualTestEditor.create( {
-					initialData: '<figure class="image"><a href="url" target="_blank" rel="noopener noreferrer" download="something">' +
-						'<img src="/assets/sample.png"></a></figure>',
-					plugins: [ Paragraph, LinkImageEditing ],
-					link: {
-						decorators: {
-							isExternal: {
-								mode: 'manual',
-								label: 'Open in a new window',
-								attributes: {
-									target: '_blank',
-									rel: 'noopener noreferrer'
-								}
-							},
-							isDownloadable: {
-								mode: 'manual',
-								label: 'Downloadable',
-								attributes: {
-									download: 'file'
-								}
-							}
-						}
-					}
-				} );
-
-				model = editor.model;
+				editor.setData(
+					'<figure class="image">' +
+						'<a href="url" target="_blank" rel="noopener noreferrer" download="something">' +
+							'<img src="/assets/sample.png">' +
+						'</a>' +
+					'</figure>'
+				);
 
 				expect( getModelData( model, { withoutSelection: true } ) ).to.equal(
 					'<image linkHref="url" linkIsExternal="true" src="/assets/sample.png"></image>'
 				);
-
-				await editor.destroy();
 			} );
 
-			it( 'allows overwriting conversion process using highest priority', async () => {
-				editor = await VirtualTestEditor.create( {
-					initialData: '',
-					plugins: [ Paragraph, LinkImageEditing ],
-					link: {
-						decorators: {
-							isExternal: {
-								mode: 'manual',
-								label: 'Open in a new window',
-								attributes: {
-									target: '_blank',
-									rel: 'noopener noreferrer'
-								}
-							},
-							isDownloadable: {
-								mode: 'manual',
-								label: 'Downloadable',
-								attributes: {
-									download: 'file'
-								}
-							}
-						}
-					}
-				} );
-
-				model = editor.model;
-
+			it( 'allows overwriting conversion process using highest priority', () => {
 				// Block manual decorator converter. Consume all attributes and do nothing with them.
 				editor.conversion.for( 'upcast' ).add( dispatcher => {
 					dispatcher.on( 'element:a', ( evt, data, conversionApi ) => {
@@ -706,8 +681,150 @@ describe( 'LinkImageEditing', () => {
 				);
 
 				expect( editor.getData() ).to.equal( '<figure class="image"><a href="url"><img src="/assets/sample.png"></a></figure>' );
+			} );
 
-				await editor.destroy();
+			it( 'should upcast the decorators when linked image (figure > a > img)', () => {
+				// (#7975)
+				editor.setData(
+					'<figure class="image">' +
+						'<a class="gallery" href="https://cksource.com" target="_blank" rel="noopener noreferrer" download="download">' +
+							'<img src="sample.jpg" alt="bar">' +
+						'</a>' +
+						'<figcaption>Caption</figcaption>' +
+					'</figure>' +
+					'<p>' +
+						'<a href="https://cksource.com" target="_blank" rel="noopener noreferrer" download="download">' +
+							'https://cksource.com' +
+						'</a>' +
+					'</p>'
+				);
+
+				expect( getModelData( model, { withoutSelection: true } ) ).to.equal(
+					'<image alt="bar" ' +
+						'linkHref="https://cksource.com" ' +
+						'linkIsDownloadable="true" ' +
+						'linkIsExternal="true" ' +
+						'linkIsGallery="true" ' +
+						'src="sample.jpg">' +
+					'</image>' +
+					'<paragraph>' +
+						'<$text linkHref="https://cksource.com" linkIsDownloadable="true" linkIsExternal="true">' +
+							'https://cksource.com' +
+						'</$text>' +
+					'</paragraph>'
+				);
+			} );
+
+			it( 'should upcast the decorators when linked image (a > img)', () => {
+				// (#7975)
+				editor.setData(
+					'<a class="gallery" href="https://cksource.com" target="_blank" rel="noopener noreferrer" download="download">' +
+						'<img src="sample.jpg" alt="bar">' +
+					'</a>' +
+					'<p>' +
+						'<a href="https://cksource.com" target="_blank" rel="noopener noreferrer" download="download">' +
+							'https://cksource.com' +
+						'</a>' +
+					'</p>'
+				);
+
+				expect( getModelData( model, { withoutSelection: true } ) ).to.equal(
+					'<image alt="bar" ' +
+						'linkHref="https://cksource.com" ' +
+						'linkIsDownloadable="true" ' +
+						'linkIsExternal="true" ' +
+						'linkIsGallery="true" ' +
+						'src="sample.jpg">' +
+					'</image>' +
+					'<paragraph>' +
+						'<$text linkHref="https://cksource.com" linkIsDownloadable="true" linkIsExternal="true">' +
+							'https://cksource.com' +
+						'</$text>' +
+					'</paragraph>'
+				);
+			} );
+		} );
+
+		describe( 'downcast converter', () => {
+			let editor, model;
+
+			beforeEach( () => {
+				return VirtualTestEditor
+					.create( {
+						plugins: [ Paragraph, LinkImageEditing ],
+						link: {
+							decorators: {
+								isExternal: {
+									mode: 'manual',
+									label: 'Open in a new tab',
+									attributes: {
+										target: '_blank',
+										rel: 'noopener noreferrer'
+									}
+								},
+								isDownloadable: {
+									mode: 'manual',
+									label: 'Downloadable',
+									attributes: {
+										download: 'download'
+									}
+								},
+								isGallery: {
+									mode: 'manual',
+									label: 'Gallery link',
+									attributes: {
+										class: 'gallery'
+									}
+								}
+							}
+						}
+					} )
+					.then( newEditor => {
+						editor = newEditor;
+						model = editor.model;
+					} );
+			} );
+
+			afterEach( () => {
+				return editor.destroy();
+			} );
+
+			it( 'should downcast the decorators after applying a change', () => {
+				setModelData( model,
+					'[<image alt="bar" src="sample.jpg"></image>]' +
+					'<paragraph>' +
+						'<$text>https://cksource.com</$text>' +
+					'</paragraph>'
+				);
+
+				editor.execute( 'link', 'https://cksource.com', {
+					linkIsDownloadable: true,
+					linkIsExternal: true,
+					linkIsGallery: true
+				} );
+
+				model.change( writer => {
+					writer.setSelection( model.document.getRoot().getChild( 1 ).getChild( 0 ), 'on' );
+				} );
+
+				editor.execute( 'link', 'https://cksource.com', {
+					linkIsDownloadable: true,
+					linkIsExternal: true,
+					linkIsGallery: true
+				} );
+
+				expect( editor.getData() ).to.equal(
+					'<figure class="image">' +
+						'<a class="gallery" href="https://cksource.com" download="download" target="_blank" rel="noopener noreferrer">' +
+							'<img src="sample.jpg" alt="bar">' +
+						'</a>' +
+					'</figure>' +
+					'<p>' +
+						'<a class="gallery" href="https://cksource.com" download="download" target="_blank" rel="noopener noreferrer">' +
+							'https://cksource.com' +
+						'</a>' +
+					'</p>'
+				);
 			} );
 		} );
 	} );

+ 32 - 12
packages/ckeditor5-list/src/liststyleediting.js

@@ -141,11 +141,29 @@ export default class ListStyleEditing extends Plugin {
 				return;
 			}
 
+			// Find the outermost list item based on the `listIndent` attribute. We can't assume that `listIndent=0`
+			// because the selection can be hooked in nested lists.
+			//
+			// <listItem listIndent="0" listType="bulleted" listStyle="square">UL List item 1</listItem>
+			// <listItem listIndent="1" listType="bulleted" listStyle="square">UL List [item 1.1</listItem>
+			// <listItem listIndent="0" listType="bulleted" listStyle="circle">[]UL List item 1.</listItem>
+			// <listItem listIndent="1" listType="bulleted" listStyle="circle">UL List ]item 1.1</listItem>
+			//
+			// After deleting the content, we would like to inherit the "square" attribute for the last element:
+			//
+			// <listItem listIndent="0" listType="bulleted" listStyle="square">UL List item 1</listItem>
+			// <listItem listIndent="1" listType="bulleted" listStyle="square">UL List []item 1.1</listItem>
 			const mostOuterItemList = getSiblingListItem( firstPosition.parent, {
 				sameIndent: true,
-				listIndent: 0
+				listIndent: nextSibling.getAttribute( 'listIndent' )
 			} );
 
+			// The outermost list item may not exist while removing elements between lists with different value
+			// of the `listIndent` attribute. In such a case we don't want to update anything. See: #8073.
+			if ( !mostOuterItemList ) {
+				return;
+			}
+
 			if ( mostOuterItemList.getAttribute( 'listType' ) === nextSibling.getAttribute( 'listType' ) ) {
 				firstMostOuterItem = mostOuterItemList;
 			}
@@ -167,7 +185,7 @@ export default class ListStyleEditing extends Plugin {
 				// <listItem listIndent="0" listType="bulleted" listStyle="circle">UL List item 2</listItem>
 				const secondListMostOuterItem = getSiblingListItem( firstMostOuterItem.nextSibling, {
 					sameIndent: true,
-					listIndent: 0,
+					listIndent: firstMostOuterItem.getAttribute( 'listIndent' ),
 					direction: 'forward'
 				} );
 
@@ -211,7 +229,6 @@ function downcastListStyleAttribute() {
 		dispatcher.on( 'attribute:listStyle:listItem', ( evt, data, conversionApi ) => {
 			const viewWriter = conversionApi.writer;
 			const currentElement = data.item;
-			const listStyle = data.attributeNewValue;
 
 			const previousElement = getSiblingListItem( currentElement.previousSibling, {
 				sameIndent: true,
@@ -221,25 +238,23 @@ function downcastListStyleAttribute() {
 
 			const viewItem = conversionApi.mapper.toViewElement( currentElement );
 
-			// Single item list.
-			if ( !previousElement ) {
-				setListStyle( viewWriter, listStyle, viewItem.parent );
-			} else if ( !areRepresentingSameList( previousElement, currentElement ) ) {
+			// A case when elements represent different lists. We need to separate their container.
+			if ( !areRepresentingSameList( currentElement, previousElement ) ) {
 				viewWriter.breakContainer( viewWriter.createPositionBefore( viewItem ) );
-				viewWriter.breakContainer( viewWriter.createPositionAfter( viewItem ) );
-
-				setListStyle( viewWriter, listStyle, viewItem.parent );
 			}
+
+			setListStyle( viewWriter, data.attributeNewValue, viewItem.parent );
 		}, { priority: 'low' } );
 	};
 
 	// Checks whether specified list items belong to the same list.
 	//
 	// @param {module:engine/model/element~Element} listItem1 The first list item to check.
-	// @param {module:engine/model/element~Element} listItem2 The second list item to check.
+	// @param {module:engine/model/element~Element|null} listItem2 The second list item to check.
 	// @returns {Boolean}
 	function areRepresentingSameList( listItem1, listItem2 ) {
-		return listItem1.getAttribute( 'listType' ) === listItem2.getAttribute( 'listType' ) &&
+		return listItem2 &&
+			listItem1.getAttribute( 'listType' ) === listItem2.getAttribute( 'listType' ) &&
 			listItem1.getAttribute( 'listIndent' ) === listItem2.getAttribute( 'listIndent' ) &&
 			listItem1.getAttribute( 'listStyle' ) === listItem2.getAttribute( 'listStyle' );
 	}
@@ -459,6 +474,11 @@ function fixListStyleAttributeOnListItemElements( editor ) {
 				// ■ Paragraph[]  // <-- The inserted item.
 				while ( existingListItem.is( 'element', 'listItem' ) && existingListItem.getAttribute( 'listIndent' ) !== indent ) {
 					existingListItem = existingListItem.previousSibling;
+
+					// If the item does not exist, most probably there is no other content in the editor. See: #8072.
+					if ( !existingListItem ) {
+						break;
+					}
 				}
 			}
 		}

+ 184 - 4
packages/ckeditor5-list/tests/liststyleediting.js

@@ -384,6 +384,41 @@ describe( 'ListStyleEditing', () => {
 					'</ul>'
 				);
 			} );
+
+			// See: #8081.
+			it( 'should convert properly nested list styles', () => {
+				// ■ Level 0
+				//     ▶ Level 0.1
+				//         ○ Level 0.1.1
+				//     ▶ Level 0.2
+				//         ○ Level 0.2.1
+				setModelData( model,
+					'<listItem listIndent="0" listType="bulleted" listStyle="default">Level 0</listItem>' +
+					'<listItem listIndent="1" listType="bulleted" listStyle="default">Level 0.1</listItem>' +
+					'<listItem listIndent="2" listType="bulleted" listStyle="circle">Level 0.1.1</listItem>' +
+					'<listItem listIndent="1" listType="bulleted" listStyle="default">Level 0.2</listItem>' +
+					'<listItem listIndent="2" listType="bulleted" listStyle="circle">Level 0.2.1</listItem>'
+				);
+
+				expect( getViewData( view, { withoutSelection: true } ) ).to.equal(
+					'<ul>' +
+						'<li>Level 0' +
+							'<ul>' +
+								'<li>Level 0.1' +
+									'<ul style="list-style-type:circle">' +
+										'<li>Level 0.1.1</li>' +
+									'</ul>' +
+								'</li>' +
+								'<li>Level 0.2' +
+									'<ul style="list-style-type:circle">' +
+										'<li>Level 0.2.1</li>' +
+									'</ul>' +
+								'</li>' +
+							'</ul>' +
+						'</li>' +
+					'</ul>'
+				);
+			} );
 		} );
 	} );
 
@@ -725,6 +760,21 @@ describe( 'ListStyleEditing', () => {
 					'<listItem listIndent="1" listStyle="decimal" listType="numbered">3.[]</listItem>'
 				);
 			} );
+
+			// See: #8072.
+			it( 'should not throw when indenting a list without any other content in the editor', () => {
+				setModelData( model,
+					'<listItem listIndent="0" listStyle="default" listType="bulleted">Foo</listItem>' +
+					'<listItem listIndent="0" listStyle="default" listType="bulleted">[]</listItem>'
+				);
+
+				editor.execute( 'indentList' );
+
+				expect( getModelData( model ) ).to.equal(
+					'<listItem listIndent="0" listStyle="default" listType="bulleted">Foo</listItem>' +
+					'<listItem listIndent="1" listStyle="default" listType="bulleted">[]</listItem>'
+				);
+			} );
 		} );
 
 		describe( 'outdenting lists', () => {
@@ -923,6 +973,97 @@ describe( 'ListStyleEditing', () => {
 			} );
 		} );
 
+		describe( 'delete + undo', () => {
+			let editor, model, view;
+
+			beforeEach( () => {
+				return VirtualTestEditor
+					.create( {
+						plugins: [ Paragraph, ListStyleEditing, Typing, UndoEditing ]
+					} )
+					.then( newEditor => {
+						editor = newEditor;
+						model = editor.model;
+						view = editor.editing.view;
+					} );
+			} );
+
+			afterEach( () => {
+				return editor.destroy();
+			} );
+
+			// See: #7930.
+			it( 'should restore proper list style attribute after undo merging lists', () => {
+				// ○ 1.
+				// ○ 2.
+				// ○ 3.
+				// <paragraph>
+				// ■ 1.
+				// ■ 2.
+				setModelData( model,
+					'<listItem listIndent="0" listStyle="circle" listType="bulleted">1.</listItem>' +
+					'<listItem listIndent="0" listStyle="circle" listType="bulleted">2.</listItem>' +
+					'<listItem listIndent="0" listStyle="circle" listType="bulleted">3.</listItem>' +
+					'<paragraph>[]</paragraph>' +
+					'<listItem listIndent="0" listStyle="square" listType="bulleted">1.</listItem>' +
+					'<listItem listIndent="0" listStyle="square" listType="bulleted">2.</listItem>'
+				);
+
+				expect( getViewData( view, { withoutSelection: true } ), 'initial data' ).to.equal(
+					'<ul style="list-style-type:circle">' +
+						'<li>1.</li>' +
+						'<li>2.</li>' +
+						'<li>3.</li>' +
+					'</ul>' +
+					'<p></p>' +
+					'<ul style="list-style-type:square">' +
+						'<li>1.</li>' +
+						'<li>2.</li>' +
+					'</ul>'
+				);
+
+				// After removing the paragraph.
+				// ○ 1.
+				// ○ 2.
+				// ○ 3.
+				// ○ 1.
+				// ○ 2.
+				editor.execute( 'delete' );
+
+				expect( getViewData( view, { withoutSelection: true } ), 'executing delete' ).to.equal(
+					'<ul style="list-style-type:circle">' +
+						'<li>1.</li>' +
+						'<li>2.</li>' +
+						'<li>3.</li>' +
+						'<li>1.</li>' +
+						'<li>2.</li>' +
+					'</ul>'
+				);
+
+				// After undo.
+				// ○ 1.
+				// ○ 2.
+				// ○ 3.
+				// <paragraph>
+				// ■ 1.
+				// ■ 2.
+				editor.execute( 'undo' );
+
+				expect( getViewData( view, { withoutSelection: true } ), 'initial data' ).to.equal(
+					'<ul style="list-style-type:circle">' +
+						'<li>1.</li>' +
+						'<li>2.</li>' +
+						'<li>3.</li>' +
+					'</ul>' +
+					'<p></p>' +
+					'<ul style="list-style-type:square">' +
+						'<li>1.</li>' +
+						'<li>2.</li>' +
+					'</ul>'
+				);
+			} );
+		} );
+
 		describe( 'todo list', () => {
 			let editor, model;
 
@@ -1096,8 +1237,8 @@ describe( 'ListStyleEditing', () => {
 				setModelData( model,
 					'<listItem listIndent="0" listStyle="square" listType="bulleted">1.</listItem>' +
 					'<listItem listIndent="0" listStyle="square" listType="bulleted">2.</listItem>' +
-					'<listItem listIndent="1" listStyle="numbered" listType="decimal">2.1.</listItem>' +
-					'<listItem listIndent="2" listStyle="default" listType="default">2.1.1</listItem>' +
+					'<listItem listIndent="1" listStyle="decimal" listType="numbered">2.1.</listItem>' +
+					'<listItem listIndent="2" listStyle="default" listType="numbered">2.1.1</listItem>' +
 					'<paragraph>[]</paragraph>' +
 					'<listItem listIndent="0" listStyle="circle" listType="bulleted">1.</listItem>' +
 					'<listItem listIndent="0" listStyle="circle" listType="bulleted">2.</listItem>'
@@ -1108,8 +1249,8 @@ describe( 'ListStyleEditing', () => {
 				expect( getModelData( model ) ).to.equal(
 					'<listItem listIndent="0" listStyle="square" listType="bulleted">1.</listItem>' +
 					'<listItem listIndent="0" listStyle="square" listType="bulleted">2.</listItem>' +
-					'<listItem listIndent="1" listStyle="numbered" listType="decimal">2.1.</listItem>' +
-					'<listItem listIndent="2" listStyle="default" listType="default">2.1.1[]</listItem>' +
+					'<listItem listIndent="1" listStyle="decimal" listType="numbered">2.1.</listItem>' +
+					'<listItem listIndent="2" listStyle="default" listType="numbered">2.1.1[]</listItem>' +
 					'<listItem listIndent="0" listStyle="square" listType="bulleted">1.</listItem>' +
 					'<listItem listIndent="0" listStyle="square" listType="bulleted">2.</listItem>'
 				);
@@ -1233,6 +1374,45 @@ describe( 'ListStyleEditing', () => {
 				);
 			} );
 
+			// See: #8073.
+			it( 'should not crash when removing a content between intended lists', () => {
+				setModelData( model,
+					'<listItem listIndent="0" listStyle="default" listType="bulleted">aaaa</listItem>' +
+					'<listItem listIndent="1" listStyle="default" listType="bulleted">bb[bb</listItem>' +
+					'<listItem listIndent="2" listStyle="default" listType="bulleted">cc]cc</listItem>' +
+					'<listItem listIndent="3" listStyle="default" listType="bulleted">dddd</listItem>'
+				);
+
+				editor.execute( 'delete' );
+
+				expect( getModelData( model ) ).to.equal(
+					'<listItem listIndent="0" listStyle="default" listType="bulleted">aaaa</listItem>' +
+					'<listItem listIndent="1" listStyle="default" listType="bulleted">bb[]cc</listItem>' +
+					'<listItem listIndent="2" listStyle="default" listType="bulleted">dddd</listItem>'
+				);
+			} );
+
+			it( 'should read the `listStyle` attribute from the most outer selected list while removing content between lists', () => {
+				setModelData( model,
+					'<listItem listIndent="0" listStyle="square" listType="bulleted">1.</listItem>' +
+					'<listItem listIndent="0" listStyle="square" listType="bulleted">2.</listItem>' +
+					'<listItem listIndent="1" listStyle="decimal" listType="numbered">2.1.</listItem>' +
+					'<listItem listIndent="2" listStyle="lower-latin" listType="numbered">2.1.1[foo</listItem>' +
+					'<paragraph>Foo</paragraph>' +
+					'<listItem listIndent="0" listStyle="circle" listType="bulleted">1.</listItem>' +
+					'<listItem listIndent="1" listStyle="circle" listType="bulleted">bar]2.</listItem>'
+				);
+
+				editor.execute( 'delete' );
+
+				expect( getModelData( model ) ).to.equal(
+					'<listItem listIndent="0" listStyle="square" listType="bulleted">1.</listItem>' +
+					'<listItem listIndent="0" listStyle="square" listType="bulleted">2.</listItem>' +
+					'<listItem listIndent="1" listStyle="decimal" listType="numbered">2.1.</listItem>' +
+					'<listItem listIndent="2" listStyle="lower-latin" listType="numbered">2.1.1[]2.</listItem>'
+				);
+			} );
+
 			function simulateTyping( text ) {
 				// While typing, every character is an atomic change.
 				text.split( '' ).forEach( character => {

+ 2 - 4
packages/ckeditor5-media-embed/src/mediaregistry.js

@@ -46,11 +46,9 @@ export default class MediaRegistry {
 					 * has no name and will not be used by the editor. In order to get this media
 					 * provider working, double check your editor configuration.
 					 *
-					 * @warning media-embed-no-provider-name
+					 * @error media-embed-no-provider-name
 					 */
-					console.warn( attachLinkToDocumentation(
-						'media-embed-no-provider-name: The configured media provider has no name and cannot be used.'
-					), { provider } );
+					console.warn( attachLinkToDocumentation( 'media-embed-no-provider-name' ), { provider } );
 
 					return false;
 				}

+ 2 - 2
packages/ckeditor5-media-embed/tests/mediaembedediting.js

@@ -74,7 +74,7 @@ describe( 'MediaEmbedEditing', () => {
 						providers: [ provider ]
 					} ).then( () => {
 						expect( consoleWarnStub.calledOnce ).to.equal( true );
-						expect( consoleWarnStub.firstCall.args[ 0 ] ).to.match( /^media-embed-no-provider-name:/ );
+						expect( consoleWarnStub.firstCall.args[ 0 ] ).to.match( /^media-embed-no-provider-name/ );
 						expect( consoleWarnStub.firstCall.args[ 1 ].provider ).to.deep.equal( provider );
 					} );
 				} );
@@ -333,7 +333,7 @@ describe( 'MediaEmbedEditing', () => {
 						extraProviders: [ provider ]
 					} ).then( () => {
 						expect( consoleWarnStub.calledOnce ).to.equal( true );
-						expect( consoleWarnStub.firstCall.args[ 0 ] ).to.match( /^media-embed-no-provider-name:/ );
+						expect( consoleWarnStub.firstCall.args[ 0 ] ).to.match( /^media-embed-no-provider-name/ );
 						expect( consoleWarnStub.firstCall.args[ 1 ].provider ).to.deep.equal( provider );
 					} );
 				} );

+ 1 - 1
packages/ckeditor5-media-embed/tests/mediaembedtoolbar.js

@@ -250,7 +250,7 @@ describe( 'MediaEmbedToolbar - integration with BalloonEditor', () => {
 
 			expect( widgetToolbarRepository._toolbarDefinitions.get( 'mediaEmbed' ) ).to.be.undefined;
 			expect( consoleWarnStub.calledOnce ).to.equal( true );
-			expect( consoleWarnStub.firstCall.args[ 0 ] ).to.match( /^widget-toolbar-no-items:/ );
+			expect( consoleWarnStub.firstCall.args[ 0 ] ).to.match( /^widget-toolbar-no-items/ );
 
 			element.remove();
 			return editor.destroy();

+ 1 - 1
packages/ckeditor5-mention/src/mentionui.js

@@ -349,7 +349,7 @@ export default class MentionUI extends Plugin {
 				 *
 				 * @error mention-feed-callback-error
 				 */
-				console.warn( attachLinkToDocumentation( 'mention-feed-callback-error: Could not obtain mention autocomplete feed.' ) );
+				console.warn( attachLinkToDocumentation( 'mention-feed-callback-error' ) );
 			} );
 	}
 

+ 1 - 1
packages/ckeditor5-mention/tests/mentionui.js

@@ -1249,7 +1249,7 @@ describe( 'MentionUI', () => {
 						expect( panelView.isVisible, 'panel is hidden' ).to.be.false;
 						expect( editor.model.markers.has( 'mention' ), 'there is no marker' ).to.be.false;
 
-						sinon.assert.calledWithExactly( warnSpy, sinon.match( /^mention-feed-callback-error:/ ) );
+						sinon.assert.calledWithExactly( warnSpy, sinon.match( /^mention-feed-callback-error/ ) );
 						sinon.assert.calledOnce( eventSpy );
 					} );
 			} );

+ 11 - 1
packages/ckeditor5-page-break/docs/features/page-break.md

@@ -5,7 +5,9 @@ menu-title: Page break
 
 # Page break
 
-The {@link module:page-break/pagebreak~PageBreak} plugin provides a possibility to insert a page break into the rich-text editor.
+The {@link module:page-break/pagebreak~PageBreak} plugin provides a possibility to insert a page break into the rich-text editor. This is useful in paged editing scenarios where you want to have more control over the final structure of the document that is printed, [exported to PDF](https://ckeditor.com/docs/ckeditor5/latest/features/export-pdf.html) or [to Word](https://ckeditor.com/docs/ckeditor5/latest/features/export-word.html).
+
+The page break feature is further complemented by the [pagination feature](https://ckeditor.com/docs/ckeditor5/latest/features/pagination.html) that allows you to see where page breaks would be after the document is exported to PDF or to Word.
 
 ## Demo
 
@@ -39,6 +41,14 @@ ClassicEditor
 	Read more about {@link builds/guides/integration/installing-plugins installing plugins}.
 </info-box>
 
+## Related features
+
+Here are some useful CKEditor 5 features that you can use together with the page break plugin for an all-around paged editing experience:
+
+* The [pagination feature](https://ckeditor.com/docs/ckeditor5/latest/features/pagination.html) allows you to see where page breaks would be after the document is [exported to PDF](https://ckeditor.com/docs/ckeditor5/latest/features/export-pdf.html) or [to Word](https://ckeditor.com/docs/ckeditor5/latest/features/export-word.html).
+* The [export to Word](https://ckeditor.com/docs/ckeditor5/latest/features/export-word.html) feature will allow you to generate editable, paged `.docx` files out of your editor-created content.
+* The [export to PDF](https://ckeditor.com/docs/ckeditor5/latest/features/export-pdf.html) feature will allow you to generate portable, paged PDF files out of your editor-created content.
+
 ## Common API
 
 The {@link module:page-break/pagebreak~PageBreak} plugin registers:

+ 1 - 1
packages/ckeditor5-table/src/tablewalker.js

@@ -532,7 +532,7 @@ class TableSlot {
  */
 
 // @if CK_DEBUG // function throwMissingGetterError( getterName ) {
-// @if CK_DEBUG //		throw new CKEditorError( 'tableslot-getter-removed: This TableSlot getter does not exist anymore.', this, {
+// @if CK_DEBUG //		throw new CKEditorError( 'tableslot-getter-removed', this, {
 // @if CK_DEBUG //			getterName
 // @if CK_DEBUG //		} );
 // @if CK_DEBUG // }

+ 1 - 2
packages/ckeditor5-ui/src/toolbar/toolbarview.js

@@ -299,8 +299,7 @@ export default class ToolbarView extends View {
 				 * @error toolbarview-item-unavailable
 				 * @param {String} name The name of the component.
 				 */
-				console.warn( attachLinkToDocumentation(
-					'toolbarview-item-unavailable: The requested toolbar item is unavailable.' ), { name } );
+				console.warn( attachLinkToDocumentation( 'toolbarview-item-unavailable' ), { name } );
 			}
 		} ).filter( item => item !== undefined ) );
 	}

+ 2 - 4
packages/ckeditor5-upload/src/adapters/simpleuploadadapter.js

@@ -69,11 +69,9 @@ export default class SimpleUploadAdapter extends Plugin {
 			 * configuration required by the {@link module:upload/adapters/simpleuploadadapter~SimpleUploadAdapter `SimpleUploadAdapter`}
 			 * is missing. Make sure the correct URL is specified for the image upload to work properly.
 			 *
-			 * @error simple-upload-adapter-missing-uploadUrl
+			 * @error simple-upload-adapter-missing-uploadurl
 			 */
-			console.warn( attachLinkToDocumentation(
-				'simple-upload-adapter-missing-uploadUrl: Missing the "uploadUrl" property in the "simpleUpload" editor configuration.'
-			) );
+			console.warn( attachLinkToDocumentation( 'simple-upload-adapter-missing-uploadurl' ) );
 
 			return;
 		}

+ 1 - 3
packages/ckeditor5-upload/src/filerepository.js

@@ -172,9 +172,7 @@ export default class FileRepository extends Plugin {
 			 *
 			 * @error filerepository-no-upload-adapter
 			 */
-			console.warn( attachLinkToDocumentation(
-				'filerepository-no-upload-adapter: Upload adapter is not defined.'
-			) );
+			console.warn( attachLinkToDocumentation( 'filerepository-no-upload-adapter' ) );
 
 			return null;
 		}

+ 1 - 1
packages/ckeditor5-upload/tests/adapters/simpleuploadadapter.js

@@ -120,7 +120,7 @@ describe( 'SimpleUploadAdapter', () => {
 				} )
 				.then( editor => {
 					expect( consoleWarnStub.callCount ).to.equal( 1 );
-					expect( consoleWarnStub.firstCall.args[ 0 ] ).to.match( /^simple-upload-adapter-missing-uploadUrl/ );
+					expect( consoleWarnStub.firstCall.args[ 0 ] ).to.match( /^simple-upload-adapter-missing-uploadurl/ );
 
 					const fileRepository = editor.plugins.get( FileRepository );
 

+ 1 - 1
packages/ckeditor5-upload/tests/filerepository.js

@@ -178,7 +178,7 @@ describe( 'FileRepository', () => {
 			sinon.assert.calledOnce( consoleWarnStub );
 			sinon.assert.calledWithExactly(
 				consoleWarnStub,
-				sinon.match( 'filerepository-no-upload-adapter: Upload adapter is not defined.' )
+				sinon.match( /^filerepository-no-upload-adapter/ )
 			);
 		} );
 

+ 9 - 18
packages/ckeditor5-utils/src/ckeditorerror.js

@@ -33,7 +33,7 @@ export const DOCUMENTATION_URL =
  *		 * @param pluginName The name of the plugin that could not be loaded.
  *		 * @param moduleName The name of the module which tried to load this plugin.
  *		 * /
- *		throw new CKEditorError( 'plugin-load: It was not possible to load the "{$pluginName}" plugin in module "{$moduleName}', {
+ *		throw new CKEditorError( 'plugin-load', {
  *			pluginName: 'foo',
  *			moduleName: 'bar'
  *		} );
@@ -44,9 +44,8 @@ export default class CKEditorError extends Error {
 	/**
 	 * Creates an instance of the CKEditorError class.
 	 *
-	 * @param {String} message The error message in an `error-name: Error message.` format.
-	 * During the minification process the "Error message" part will be removed to limit the code size
-	 * and a link to this error documentation will be added to the `message`.
+	 * @param {String} errorName The error id in an `error-name` format. A link to this error documentation page will be added
+	 * to the thrown error's `message`.
 	 * @param {Object|null} context A context of the error by which the {@link module:watchdog/watchdog~Watchdog watchdog}
 	 * is able to determine which editor crashed. It should be an editor instance or a property connected to it. It can be also
 	 * a `null` value if the editor should not be restarted in case of the error (e.g. during the editor initialization).
@@ -56,8 +55,8 @@ export default class CKEditorError extends Error {
 	 * will be appended to the error message, so the data are quickly visible in the console. The original
 	 * data object will also be later available under the {@link #data} property.
 	 */
-	constructor( message, context, data ) {
-		message = attachLinkToDocumentation( message );
+	constructor( errorName, context, data ) {
+		let message = attachLinkToDocumentation( errorName );
 
 		if ( data ) {
 			message += ' ' + JSON.stringify( data );
@@ -136,19 +135,11 @@ export default class CKEditorError extends Error {
  *		  * @error toolbarview-item-unavailable
  *		  * @param {String} name The name of the component.
  *		  * /
- *		 console.warn( attachLinkToDocumentation(
- *		 	'toolbarview-item-unavailable: The requested toolbar item is unavailable.' ), { name } );
+ *		 console.warn( attachLinkToDocumentation( 'toolbarview-item-unavailable' ), { name } );
  *
- * @param {String} message Message to be logged.
+ * @param {String} errorName Error name to be linked.
  * @returns {String}
  */
-export function attachLinkToDocumentation( message ) {
-	// The link to documentation should work with console.warn format: "message-id: Explanation message." and single error id.
-	const matchedErrorName = message.match( /^([a-z0-9]+-[a-z-0-9]+):?/ );
-
-	if ( !matchedErrorName ) {
-		return message;
-	}
-
-	return message + ` Read more: ${ DOCUMENTATION_URL }#error-${ matchedErrorName[ 1 ] }\n`;
+export function attachLinkToDocumentation( errorName ) {
+	return errorName + ` Read more: ${ DOCUMENTATION_URL }#error-${ errorName }\n`;
 }

+ 8 - 8
packages/ckeditor5-utils/src/observablemixin.js

@@ -16,7 +16,7 @@ const boundObservablesSymbol = Symbol( 'boundObservables' );
 const boundPropertiesSymbol = Symbol( 'boundProperties' );
 
 /**
- * Mixin that injects the "observable properties" and data binding functionality described in the
+ * A mixin that injects the "observable properties" and data binding functionality described in the
  * {@link ~Observable} interface.
  *
  * Read more about the concept of observables in the:
@@ -50,7 +50,7 @@ const ObservableMixin = {
 			/**
 			 * Cannot override an existing property.
 			 *
-			 * This error is thrown when trying to {@link ~Observable#set set} an property with
+			 * This error is thrown when trying to {@link ~Observable#set set} a property with
 			 * a name of an already existing property. For example:
 			 *
 			 *		let observable = new Model();
@@ -265,7 +265,7 @@ extend( ObservableMixin, EmitterMixin );
 
 export default ObservableMixin;
 
-// Init symbol properties needed to for the observable mechanism to work.
+// Init symbol properties needed for the observable mechanism to work.
 //
 // @private
 // @param {module:utils/observablemixin~ObservableMixin} observable
@@ -667,7 +667,7 @@ function attachBindToListeners( observable, toBindings ) {
 }
 
 /**
- * Interface which adds "observable properties" and data binding functionality.
+ * An interface which adds "observable properties" and data binding functionality.
  *
  * Can be easily implemented by a class by mixing the {@link module:utils/observablemixin~ObservableMixin} mixin.
  *
@@ -721,7 +721,7 @@ function attachBindToListeners( observable, toBindings ) {
  *		                     // -> 'Current property value is 1'
  *		                     // -> 'Value has changed from 1 to 3'
  *
- * **Note:** Event is fired even when the new value is the same as the old value.
+ * **Note:** The event is fired even when the new value is the same as the old value.
  *
  * @event set:{property}
  * @param {String} name The property name.
@@ -730,13 +730,13 @@ function attachBindToListeners( observable, toBindings ) {
  */
 
 /**
- * Creates and sets the value of an observable property of this object. Such an property becomes a part
- * of the state and is be observable.
+ * Creates and sets the value of an observable property of this object. Such a property becomes a part
+ * of the state and is observable.
  *
  * It accepts also a single object literal containing key/value pairs with properties to be set.
  *
  * This method throws the `observable-set-cannot-override` error if the observable instance already
- * have a property with the given property name. This prevents from mistakenly overriding existing
+ * has a property with the given property name. This prevents from mistakenly overriding existing
  * properties and methods, but means that `foo.set( 'bar', 1 )` may be slightly slower than `foo.bar = 1`.
  *
  * @method #set

+ 8 - 2
packages/ckeditor5-utils/tests/ckeditorerror.js

@@ -33,7 +33,10 @@ describe( 'CKEditorError', () => {
 		const data = { bar: 1 };
 		const error = new CKEditorError( 'foo', null, data );
 
-		expect( error ).to.have.property( 'message', 'foo {"bar":1}' );
+		expect( error ).to.have.property(
+			'message',
+			`foo Read more: ${ DOCUMENTATION_URL }#error-foo\n {"bar":1}`
+		);
 		expect( error ).to.have.property( 'data', data );
 	} );
 
@@ -59,7 +62,10 @@ describe( 'CKEditorError', () => {
 		};
 		const error = new CKEditorError( 'foo', null, data );
 
-		expect( error ).to.have.property( 'message', 'foo {"bar":"a","bom":{"x":1},"bim":10}' );
+		expect( error ).to.have.property(
+			'message',
+			`foo Read more: ${ DOCUMENTATION_URL }#error-foo\n {"bar":"a","bom":{"x":1},"bim":10}`
+		);
 		expect( error ).to.have.property( 'data', data );
 	} );
 

+ 1 - 1
packages/ckeditor5-watchdog/tests/editorwatchdog.js

@@ -817,7 +817,7 @@ describe( 'EditorWatchdog', () => {
 						expect( watchdog.crashes[ 0 ].lineno ).to.be.a( 'number' );
 						expect( watchdog.crashes[ 0 ].colno ).to.be.a( 'number' );
 
-						expect( watchdog.crashes[ 1 ].message ).to.equal( 'bar' );
+						expect( watchdog.crashes[ 1 ].message ).to.match( /^bar/ );
 
 						watchdog.destroy().then( res );
 					} );

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

@@ -135,7 +135,7 @@ export default class WidgetToolbarRepository extends Plugin {
 			 * @error widget-toolbar-no-items
 			 */
 			console.warn(
-				attachLinkToDocumentation( 'widget-toolbar-no-items: Trying to register a toolbar without items.' ), { toolbarId }
+				attachLinkToDocumentation( 'widget-toolbar-no-items' ), { toolbarId }
 			);
 
 			return;

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

@@ -138,7 +138,7 @@ describe( 'WidgetToolbarRepository', () => {
 			expect( widgetToolbarRepository._toolbarDefinitions.get( 'fake' ) ).to.be.undefined;
 
 			expect( consoleWarnStub.calledOnce ).to.equal( true );
-			expect( consoleWarnStub.firstCall.args[ 0 ] ).to.match( /^widget-toolbar-no-items:/ );
+			expect( consoleWarnStub.firstCall.args[ 0 ] ).to.match( /^widget-toolbar-no-items/ );
 		} );
 	} );
 

+ 32 - 0
tests/manual/all-features.html

@@ -157,6 +157,38 @@ function bar() {
 			</tbody>
 		</table>
 	</figure>
+
+	<hr>
+
+	<h2>Link images + Link decorators</h2>
+
+	<table>
+		<tr>
+			<td>Linked text</td>
+			<td>Linked image (<code>figure > a > img</code>)</td>
+			<td>Linked image (<code>a > img</code>)</td>
+		</tr>
+		<tr>
+			<td>
+				<p>
+					<a href="https://cksource.com" target="_blank" rel="noopener noreferrer" download="download">https://cksource.com</a>
+				</p>
+			</td>
+			<td>
+				<figure class="image">
+					<a class="gallery" href="https://cksource.com">
+						<img src="sample.jpg" alt="bar">
+					</a>
+					<figcaption>Caption</figcaption>
+				</figure>
+			</td>
+			<td>
+				<a class="gallery" href="https://cksource.com" target="_blank" rel="noopener noreferrer" download="download">
+					<img src="sample.jpg" alt="bar">
+				</a>
+			</td>
+		</tr>
+	</table>
 </div>
 
 <style>

+ 30 - 6
tests/manual/all-features.js

@@ -97,12 +97,10 @@ ClassicEditor
 				'imageStyle:alignLeft', 'imageStyle:alignCenter', 'imageStyle:alignRight', '|',
 				'imageResize'
 			],
-			upload: {
-				panel: {
-					items: [
-						'insertImageViaUrl'
-					]
-				}
+			insert: {
+				integrations: [
+					'insertImageViaUrl'
+				]
 			}
 		},
 		placeholder: 'Type the content here!',
@@ -119,6 +117,32 @@ ClassicEditor
 					minimumCharacters: 1
 				}
 			]
+		},
+		link: {
+			decorators: {
+				isExternal: {
+					mode: 'manual',
+					label: 'Open in a new tab',
+					attributes: {
+						target: '_blank',
+						rel: 'noopener noreferrer'
+					}
+				},
+				isDownloadable: {
+					mode: 'manual',
+					label: 'Downloadable',
+					attributes: {
+						download: 'download'
+					}
+				},
+				isGallery: {
+					mode: 'manual',
+					label: 'Gallery link',
+					attributes: {
+						class: 'gallery'
+					}
+				}
+			}
 		}
 	} )
 	.then( editor => {

+ 15 - 0
tests/manual/all-features.md

@@ -4,6 +4,8 @@
 
 It should contain as many features as we developed. By resizing your viewport you can observe whether grouping toolbar items work.
 
+---
+
 ## Editor
 
 There should be "two pages" with two sections per each page. Pages should be separated by the page break feature.
@@ -20,16 +22,29 @@ There should be "two pages" with two sections per each page. Pages should be sep
 
 **Code blocks in the table** - in the table (4x3), in the second and fourth columns should be visible code snippets.
 
+**Horizontal line** - There is the `<hr>` in the source HTML. It should be displayed in the editor.
+
+**Link images + Link decorators** - in the table (3x2), there are a linked text and two linked images that uses the manual decorators feature:
+  - Left column: the text with two decorators enabled: `Open in a new tab`, `Downloadable`
+  - Middle column: an image with the caption that is a `Gallery link`
+  - Right column: an image without the caption with enabled all decorators (listed above)
+
+---
+
 ## Action buttons
 
 - Clear editor - calls `editor.setData( '' )`
 - Open print preview - opens the print preview window
 - Turn on/off read-only mode - toggle read-only mode
 
+---
+
 ## Console
 
 Wordcount plugin logs into the console number of characters and words in the editor's data.
 
+---
+
 ## Additional
 
 - Empty editor should display a placeholder: `'Type the content here!'`