Sfoglia il codice sorgente

Merge branch 'master' into i/7548

Marek Lewandowski 5 anni fa
parent
commit
31db1d7488
43 ha cambiato i file con 2038 aggiunte e 1224 eliminazioni
  1. 1 1
      .github/workflows/merge-stable.yml
  2. 1 1
      docs/builds/guides/migrate.md
  3. 2 2
      docs/framework/guides/contributing/code-style.md
  4. 4 3
      package.json
  5. 75 16
      packages/ckeditor-cloud-services-core/src/token/token.js
  6. 202 103
      packages/ckeditor-cloud-services-core/tests/token/token.js
  7. 6 2
      packages/ckeditor-cloud-services-core/tests/uploadgateway/fileuploader.js
  8. 4 1
      packages/ckeditor-cloud-services-core/tests/uploadgateway/uploadgateway.js
  9. 30 6
      packages/ckeditor5-clipboard/src/clipboard.js
  10. 49 0
      packages/ckeditor5-clipboard/src/pasteplaintext.js
  11. 93 0
      packages/ckeditor5-clipboard/tests/clipboard.js
  12. 96 0
      packages/ckeditor5-clipboard/tests/pasteplaintext.js
  13. 2 0
      packages/ckeditor5-image/docs/_snippets/features/build-image-source.js
  14. 7 7
      packages/ckeditor5-image/docs/_snippets/features/image-insert-via-url.js
  15. 7 11
      packages/ckeditor5-image/docs/features/image.md
  16. 95 0
      packages/ckeditor5-image/src/imageinsert.js
  17. 151 0
      packages/ckeditor5-image/src/imageinsert/imageinsertui.js
  18. 3 3
      packages/ckeditor5-image/src/imageinsert/ui/imageinsertformrowview.js
  19. 10 10
      packages/ckeditor5-image/src/imageinsert/ui/imageinsertpanelview.js
  20. 77 0
      packages/ckeditor5-image/src/imageinsert/utils.js
  21. 0 55
      packages/ckeditor5-image/src/imageupload.js
  22. 24 139
      packages/ckeditor5-image/src/imageupload/imageuploadui.js
  23. 0 69
      packages/ckeditor5-image/src/imageupload/utils.js
  24. 323 0
      packages/ckeditor5-image/tests/imageinsert/imageinsertui.js
  25. 1 1
      packages/ckeditor5-image/tests/imageinsert/ui/imageinsertformrowview.js
  26. 4 4
      packages/ckeditor5-image/tests/imageinsert/ui/imageinsertpanelview.js
  27. 142 0
      packages/ckeditor5-image/tests/imageinsert/utils.js
  28. 109 539
      packages/ckeditor5-image/tests/imageupload/imageuploadui.js
  29. 1 146
      packages/ckeditor5-image/tests/imageupload/utils.js
  30. 1 1
      packages/ckeditor5-image/tests/manual/imageinsertviaurl.html
  31. 9 20
      packages/ckeditor5-image/tests/manual/imageinsertviaurl.js
  32. 18 0
      packages/ckeditor5-image/tests/manual/imageinsertviaurl.md
  33. 0 18
      packages/ckeditor5-image/tests/manual/imageuploadviaurl.md
  34. 9 2
      packages/ckeditor5-image/theme/imageinsert.css
  35. 1 1
      packages/ckeditor5-image/theme/imageinsertformrowview.css
  36. 2 1
      packages/ckeditor5-link/src/link.js
  37. 43 8
      packages/ckeditor5-link/src/linkui.js
  38. 3 2
      packages/ckeditor5-link/tests/link.js
  39. 212 29
      packages/ckeditor5-link/tests/linkui.js
  40. 32 12
      packages/ckeditor5-list/src/liststyleediting.js
  41. 184 4
      packages/ckeditor5-list/tests/liststyleediting.js
  42. 1 1
      packages/ckeditor5-upload/docs/features/simple-upload-adapter.md
  43. 4 6
      tests/manual/all-features.js

+ 1 - 1
.github/workflows/merge-stable.yml

@@ -22,7 +22,7 @@ jobs:
       - uses: rtCamp/action-slack-notify@v2.0.2
         id: error_message_slack
         name: Slack notification
-        if: (steps.merge_action.outputs.status != 201) && (steps.merge_action.outputs.status != 204)
+        if: ((steps.merge_action.outputs.status != 201) && (steps.merge_action.outputs.status != 204)) || failure()
         env:
           SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }}
           SLACK_CHANNEL: "cke5-ci"

+ 1 - 1
docs/builds/guides/migrate.md

@@ -337,7 +337,7 @@ Note: The number of options was reduced on purpose. We understood that configuri
 			<a href="../../../../ckeditor4/latest/api/CKEDITOR_config.html#cfg-exportPdf_fileName">exportPdf_fileName</a> <br><a href="../../../../ckeditor4/latest/api/CKEDITOR_config.html#cfg-exportPdf_options">exportPdf_options</a> <br> <a href="../../../../ckeditor4/latest/api/CKEDITOR_config.html#cfg-exportPdf_service">exportPdf_service</a> <br> <a href="../../../../ckeditor4/latest/api/CKEDITOR_config.html#cfg-exportPdf_stylesheets">exportPdf_stylesheets</a> <br> <a href="../../../../ckeditor4/latest/api/CKEDITOR_config.html#cfg-exportPdf_tokenUrl">exportPdf_tokenUrl</a> <br>
 			</td>
 			<td>
-				Refer to the {@link features/export-pdf Export to PDF feature} guide to learn more about about configuring the HTML to PDF converter in CKEditor 5.
+				Refer to the [Export to PDF feature](https://ckeditor.com/docs/ckeditor5/latest/features/export-pdf.html) guide to learn more about about configuring the HTML to PDF converter in CKEditor 5.
 			</td>
 		</tr>
 		<tr>

+ 2 - 2
docs/framework/guides/contributing/code-style.md

@@ -334,8 +334,8 @@ CKEditor 5 development environment uses [ESLint](https://eslint.org) and [stylel
 A couple of useful links:
 
 * [Disabling ESLint with inline comments](https://eslint.org/docs/2.13.1/user-guide/configuring#disabling-rules-with-inline-comments).
-* [CKEditor 5 ESLint preset](https://github.com/ckeditor/ckeditor5-dev/blob/master/packages/eslint-config-ckeditor5/.eslintrc.js) (npm: [`eslint-config-ckeditor5`](http://npmjs.com/package/eslint-config-ckeditor5)).
-* [CKEditor 5 stylelint preset](https://github.com/ckeditor/ckeditor5-dev/blob/master/packages/stylelint-config-ckeditor5/.stylelintrc) (npm: [`stylelint-config-ckeditor5`](https://www.npmjs.com/package/stylelint-config-ckeditor5)).
+* [CKEditor 5 ESLint preset](https://github.com/ckeditor/eslint-config-ckeditor5/blob/master/.eslintrc.js) (npm: [`eslint-config-ckeditor5`](http://npmjs.com/package/eslint-config-ckeditor5)).
+* [CKEditor 5 stylelint preset](https://github.com/ckeditor/stylelint-config-ckeditor5/blob/master/.stylelintrc) (npm: [`stylelint-config-ckeditor5`](https://www.npmjs.com/package/stylelint-config-ckeditor5)).
 
 <info-box>
 	Avoid using automatic code formatters on existing code. It is fine to automatically format code that you are editing, but you should not be changing the formatting of the code that is already written to not pollute your PRs. You should also not rely solely on automatic corrections.

+ 4 - 3
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.1",
+    "@ckeditor/ckeditor5-export-word": "^1.0.0",
     "@ckeditor/ckeditor5-inspector": "^2.2.0",
+    "@ckeditor/ckeditor5-pagination": "^0.0.3",
     "@ckeditor/ckeditor5-react": "^2.1.0",
     "@ckeditor/ckeditor5-real-time-collaboration": "^22.0.0",
     "@ckeditor/ckeditor5-track-changes": "^22.0.0",
@@ -131,7 +132,7 @@
   },
   "scripts": {
     "lint": "eslint --quiet '**/*.js'",
-    "stylelint": "stylelint --quiet --allow-empty-input 'packages/**/*.css' 'external/*/packages/**/*.css' 'docs/**/*.css'",
+    "stylelint": "stylelint --quiet --allow-empty-input 'packages/**/*.css' 'docs/**/*.css'",
     "test": "node --max_old_space_size=8192 node_modules/@ckeditor/ckeditor5-dev-tests/bin/test.js",
     "manual": "node --max_old_space_size=8192 node_modules/@ckeditor/ckeditor5-dev-tests/bin/test-manual.js",
     "bootstrap": "yarn install",

+ 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', () => {

+ 30 - 6
packages/ckeditor5-clipboard/src/clipboard.js

@@ -8,6 +8,7 @@
  */
 
 import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
+import PastePlainText from './pasteplaintext';
 
 import ClipboardObserver from './clipboardobserver';
 
@@ -35,6 +36,13 @@ export default class Clipboard extends Plugin {
 		return 'Clipboard';
 	}
 
+	/**
+	 * @inheritDoc
+	 */
+	static get requires() {
+		return [ PastePlainText ];
+	}
+
 	/**
 	 * @inheritDoc
 	 */
@@ -77,7 +85,11 @@ export default class Clipboard extends Plugin {
 			content = this._htmlDataProcessor.toView( content );
 
 			const eventInfo = new EventInfo( this, 'inputTransformation' );
-			this.fire( eventInfo, { content, dataTransfer } );
+			this.fire( eventInfo, {
+				content,
+				dataTransfer,
+				asPlainText: data.asPlainText
+			} );
 
 			// If CKEditor handled the input, do not bubble the original event any further.
 			// This helps external integrations recognize that fact and act accordingly.
@@ -103,16 +115,27 @@ export default class Clipboard extends Plugin {
 					return;
 				}
 
-				// While pasting plain text, apply selection attributes on the text.
-				if ( isPlainText( modelFragment ) ) {
-					const node = modelFragment.getChild( 0 );
+				// Plain text can be determined based on event flag (#7799) or auto detection (#1006). If detected
+				// preserve selection attributes on pasted items.
+				if ( data.asPlainText || isPlainTextFragment( modelFragment ) ) {
+					// Consider only formatting attributes.
+					const textAttributes = new Map( Array.from( modelDocument.selection.getAttributes() ).filter(
+						keyValuePair => editor.model.schema.getAttributeProperties( keyValuePair[ 0 ] ).isFormatting
+					) );
 
 					model.change( writer => {
-						writer.setAttributes( modelDocument.selection.getAttributes(), node );
+						const range = writer.createRangeIn( modelFragment );
+
+						for ( const item of range.getItems() ) {
+							if ( item.is( '$text' ) || item.is( '$textProxy' ) ) {
+								writer.setAttributes( textAttributes, item );
+							}
+						}
 					} );
 				}
 
 				model.insertContent( modelFragment );
+
 				evt.stop();
 			}
 		}, { priority: 'low' } );
@@ -168,6 +191,7 @@ export default class Clipboard extends Plugin {
  * It can be modified by the event listeners. Read more about the clipboard pipelines in
  * {@glink framework/guides/deep-dive/clipboard "Clipboard" deep dive}.
  * @param {module:clipboard/datatransfer~DataTransfer} data.dataTransfer Data transfer instance.
+ * @param {Boolean} data.asPlainText If set to `true` content is pasted as plain text.
  */
 
 /**
@@ -212,7 +236,7 @@ export default class Clipboard extends Plugin {
 //
 // @param {module:engine/view/documentfragment~DocumentFragment} documentFragment
 // @returns {Boolean}
-function isPlainText( documentFragment ) {
+function isPlainTextFragment( documentFragment ) {
 	if ( documentFragment.childCount > 1 ) {
 		return false;
 	}

+ 49 - 0
packages/ckeditor5-clipboard/src/pasteplaintext.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
+ */
+
+/**
+ * @module clipboard/clipboard
+ */
+
+import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
+
+import ClipboardObserver from './clipboardobserver';
+
+/**
+ * The plugin detects user intentions for pasting plain text.
+ *
+ * For example, it detects <kbd>ctrl/cmd</kbd> + <kbd>shift</kbd> + <kbd>ctrl/v</kbd> keystroke.
+ *
+ * @extends module:core/plugin~Plugin
+ */
+export default class PastePlainText extends Plugin {
+	/**
+	 * @inheritDoc
+	 */
+	static get pluginName() {
+		return 'PastePlainText';
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	init() {
+		const view = this.editor.editing.view;
+		const viewDocument = view.document;
+		let shiftPressed = false;
+
+		view.addObserver( ClipboardObserver );
+
+		this.listenTo( viewDocument, 'keydown', ( evt, data ) => {
+			shiftPressed = data.shiftKey;
+		} );
+
+		this.listenTo( viewDocument, 'clipboardInput', ( evt, data ) => {
+			if ( shiftPressed ) {
+				data.asPlainText = true;
+			}
+		}, { priority: 'high' } );
+	}
+}

+ 93 - 0
packages/ckeditor5-clipboard/tests/clipboard.js

@@ -351,6 +351,19 @@ describe( 'Clipboard feature', () => {
 				model = editor.model;
 
 				model.schema.extend( '$text', { allowAttributes: 'bold' } );
+				model.schema.extend( '$text', { allowAttributes: 'test' } );
+
+				editor.model.schema.setAttributeProperties( 'bold', { isFormatting: true } );
+
+				model.schema.register( 'softBreak', {
+					allowWhere: '$text',
+					isInline: true
+				} );
+				editor.conversion.for( 'upcast' )
+					.elementToElement( {
+						model: 'softBreak',
+						view: 'br'
+					} );
 			} );
 
 			it( 'should inherit selection attributes (collapsed selection)', () => {
@@ -451,6 +464,86 @@ describe( 'Clipboard feature', () => {
 
 				expect( getModelData( model ) ).to.equal( '<paragraph><$text bold="true">Bolded foo[]text.</$text></paragraph>' );
 			} );
+
+			it( 'should inherit selection attributes with data.asPlainText switch set', () => {
+				setModelData( model, '<paragraph><$text bold="true">Bolded []text.</$text></paragraph>' );
+
+				const dataTransferMock = createDataTransfer( {
+					'text/html': 'foo',
+					'text/plain': 'foo'
+				} );
+
+				viewDocument.fire( 'clipboardInput', {
+					dataTransfer: dataTransferMock,
+					asPlainText: true,
+					stopPropagation() {},
+					preventDefault() {}
+				} );
+
+				expect( getModelData( model ) ).to.equal( '<paragraph><$text bold="true">Bolded foo[]text.</$text></paragraph>' );
+			} );
+
+			it( 'should discard selection attributes with data.asPlainText switch set to false', () => {
+				setModelData( model, '<paragraph><$text bold="true">Bolded []text.</$text></paragraph>' );
+
+				const dataTransferMock = createDataTransfer( {
+					'text/html': 'foo<br>bar',
+					'text/plain': 'foo\nbar'
+				} );
+
+				viewDocument.fire( 'clipboardInput', {
+					dataTransfer: dataTransferMock,
+					asPlainText: false,
+					stopPropagation() {},
+					preventDefault() {}
+				} );
+
+				expect( getModelData( model ) ).to.equal( '<paragraph><$text bold="true">Bolded </$text>' +
+					'foo<softBreak></softBreak>bar[]' +
+					'<$text bold="true">text.</$text></paragraph>' );
+			} );
+
+			it( 'should work if the insertContent event is cancelled', () => {
+				// (#7887).
+				setModelData( model, '<paragraph><$text bold="true">Bolded []text.</$text></paragraph>' );
+
+				const dataTransferMock = createDataTransfer( {
+					'text/html': 'foo',
+					'text/plain': 'foo'
+				} );
+
+				model.on( 'insertContent', event => {
+					event.stop();
+				}, { priority: 'high' } );
+
+				viewDocument.fire( 'clipboardInput', {
+					dataTransfer: dataTransferMock,
+					asPlainText: false,
+					stopPropagation() {},
+					preventDefault() {}
+				} );
+
+				expect( getModelData( model ) ).to.equal( '<paragraph><$text bold="true">Bolded []text.</$text></paragraph>' );
+			} );
+
+			it( 'ignores non-formatting text attributes', () => {
+				setModelData( model, '<paragraph><$text test="true">Bolded []text.</$text></paragraph>' );
+
+				const dataTransferMock = createDataTransfer( {
+					'text/html': 'foo',
+					'text/plain': 'foo'
+				} );
+
+				viewDocument.fire( 'clipboardInput', {
+					dataTransfer: dataTransferMock,
+					asPlainText: false,
+					stopPropagation() {},
+					preventDefault() {}
+				} );
+
+				expect( getModelData( model ) ).to.equal(
+					'<paragraph><$text test="true">Bolded </$text>foo[]<$text test="true">text.</$text></paragraph>' );
+			} );
 		} );
 
 		function createDataTransfer( data ) {

+ 96 - 0
packages/ckeditor5-clipboard/tests/pasteplaintext.js

@@ -0,0 +1,96 @@
+/**
+ * @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 PastePlainText from '../src/pasteplaintext';
+import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
+
+import { getCode } from '@ckeditor/ckeditor5-utils/src/keyboard';
+
+/* global document */
+
+describe( 'PastePlainText', () => {
+	let editor, viewDocument;
+
+	beforeEach( () => {
+		return VirtualTestEditor
+			.create( {
+				plugins: [ PastePlainText, Paragraph ]
+			} )
+			.then( newEditor => {
+				editor = newEditor;
+				viewDocument = editor.editing.view.document;
+			} );
+	} );
+
+	it( 'marks clipboard input as plain text with shift pressed', () => {
+		const dataTransferMock = createDataTransfer( { 'text/html': '<p>x</p>', 'text/plain': 'y' } );
+
+		viewDocument.on( 'clipboardInput', ( event, data ) => {
+			expect( data.asPlainText ).to.be.true;
+
+			// No need for further execution.
+			event.stop();
+		} );
+
+		viewDocument.fire( 'keydown', {
+			keyCode: getCode( 'v' ),
+			shiftKey: true,
+			ctrlKey: true,
+			preventDefault: () => {},
+			domTarget: document.body
+		} );
+
+		viewDocument.fire( 'clipboardInput', {
+			dataTransfer: dataTransferMock
+		} );
+	} );
+
+	it( 'ignores clipboard input as plain text when shift was released', () => {
+		const dataTransferMock = createDataTransfer( { 'text/html': '<p>x</p>', 'text/plain': 'y' } );
+
+		viewDocument.on( 'clipboardInput', ( event, data ) => {
+			expect( data.asPlainText ).to.be.undefined;
+
+			// No need for further execution.
+			event.stop();
+		} );
+
+		viewDocument.fire( 'keydown', {
+			keyCode: getCode( 'a' ),
+			shiftKey: true,
+			preventDefault: () => {},
+			domTarget: document.body
+		} );
+
+		viewDocument.fire( 'keyup', {
+			keyCode: getCode( 'a' ),
+			shiftKey: true,
+			preventDefault: () => {},
+			domTarget: document.body
+		} );
+
+		viewDocument.fire( 'keydown', {
+			keyCode: getCode( 'v' ),
+			shiftKey: false,
+			ctrlKey: true,
+			preventDefault: () => {},
+			domTarget: document.body
+		} );
+
+		viewDocument.fire( 'clipboardInput', {
+			dataTransfer: dataTransferMock
+		} );
+	} );
+
+	function createDataTransfer( data ) {
+		return {
+			getData( type ) {
+				return data[ type ];
+			}
+		};
+	}
+} );

+ 2 - 0
packages/ckeditor5-image/docs/_snippets/features/build-image-source.js

@@ -8,9 +8,11 @@
 import ClassicEditor from '@ckeditor/ckeditor5-build-classic/src/ckeditor';
 
 import ImageResize from '@ckeditor/ckeditor5-image/src/imageresize';
+import ImageInsert from '@ckeditor/ckeditor5-image/src/imageinsert';
 import LinkImage from '@ckeditor/ckeditor5-link/src/linkimage';
 
 ClassicEditor.builtinPlugins.push( ImageResize );
+ClassicEditor.builtinPlugins.push( ImageInsert );
 ClassicEditor.builtinPlugins.push( LinkImage );
 
 window.ClassicEditor = ClassicEditor;

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

@@ -7,24 +7,24 @@
 
 import { CS_CONFIG } from '@ckeditor/ckeditor5-cloud-services/tests/_utils/cloud-services-config.js';
 
+const toolbarItems = [ ...ClassicEditor.defaultConfig.toolbar.items ];
+
+toolbarItems.splice( toolbarItems.indexOf( 'imageUpload' ), 1, 'imageInsert' );
+
 ClassicEditor
 	.create( document.querySelector( '#snippet-image-insert-via-url' ), {
 		removePlugins: [ 'ImageToolbar', 'ImageCaption', 'ImageStyle', 'ImageResize', 'LinkImage' ],
 		toolbar: {
+			items: toolbarItems,
 			viewportTopOffset: window.getViewportTopOffsetConfig()
 		},
 		image: {
-			toolbar: [ 'imageStyle:full', 'imageStyle:side', '|', 'imageTextAlternative' ],
-			upload: {
-				panel: {
-					items: [ 'insertImageViaUrl' ]
-				}
-			}
+			toolbar: [ 'imageStyle:full', 'imageStyle:side', '|', 'imageTextAlternative' ]
 		},
 		cloudServices: CS_CONFIG
 	} )
 	.then( editor => {
-		window.imageViaUrl = editor;
+		window.editorInsertImageViaUrl = editor;
 	} )
 	.catch( err => {
 		console.error( err );

+ 7 - 11
packages/ckeditor5-image/docs/features/image.md

@@ -13,6 +13,7 @@ The [`@ckeditor/ckeditor5-image`](https://www.npmjs.com/package/@ckeditor/ckedit
 * {@link module:image/imagestyle~ImageStyle} adds support for [image styles](#image-styles).
 * {@link module:image/imagetextalternative~ImageTextAlternative} adds support for adding text alternative.
 * {@link module:image/imageupload~ImageUpload} adds support for {@link features/image-upload uploading dropped or pasted images}.
+* {@link module:image/imageinsert~ImageInsert} adds support for [inserting images via URL](#inserting-images-via-source-url) and other custom integrations.
 * {@link module:image/imageresize~ImageResize} adds support for [resizing images](#resizing-images).
 * {@link module:link/linkimage~LinkImage} adds support for [linking images](#linking-images).
 
@@ -86,24 +87,19 @@ 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, 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';
+
 ClassicEditor
 	.create( document.querySelector( '#editor' ), {
-		// ...
-		image: {
-			// ...
-			upload: {
-				panel: {
-					items: [ 'insertImageViaUrl' ]
-				}
-			}
-		}
+		plugins: [ ... , ImageInsert ],
+		toolbar: [ ... , 'imageInsert' ]
 	} )
 ```
 
-This will extend the standalone **Insert image** button in the toolbar by adding a dropdown panel with the new feature. To open the panel and add the image URL, click the arrow next to the image button. Check the demo below to insert a new image via URL or update an existing image by selecting it, opening the dropdown panel and pasting a new URL.
+This will add a new **Insert image** dropdown in the toolbar. To open the panel and add the image URL, click the arrow next to the image button. Check the demo below to insert a new image via URL or update an existing image by selecting it, opening the dropdown panel and pasting a new URL.
 
 {@snippet features/image-insert-via-url}
 

+ 95 - 0
packages/ckeditor5-image/src/imageinsert.js

@@ -0,0 +1,95 @@
+/**
+ * @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 image/imageinsert
+ */
+
+import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
+import ImageUpload from './imageupload';
+import ImageInsertUI from './imageinsert/imageinsertui';
+
+/**
+ * The image insert plugin.
+ *
+ * For a detailed overview, check the {@glink features/image-upload/image-upload Image upload feature}
+ * and {@glink features/image#inserting-images-via-source-url Insert images via source URL} documentation.
+ *
+ * This plugin does not do anything directly, but it loads a set of specific plugins
+ * to enable image uploading or inserting via implemented integrations:
+ *
+ * * {@link module:image/imageupload~ImageUpload}
+ * * {@link module:image/imageinsert/imageinsertui~ImageInsertUI},
+ *
+ * @extends module:core/plugin~Plugin
+ */
+export default class ImageInsert extends Plugin {
+	/**
+	 * @inheritDoc
+	 */
+	static get pluginName() {
+		return 'ImageInsert';
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	static get requires() {
+		return [ ImageUpload, ImageInsertUI ];
+	}
+}
+
+/**
+ * The image insert configuration.
+ *
+ * @protected
+ * @member {module:image/imageinsert~ImageInsertConfig} module:image/image~ImageConfig#insert
+ */
+
+/**
+ * The configuration of the image insert dropdown panel view. Used by the image insert feature in the `@ckeditor/ckeditor5-image` package.
+ *
+ *		ClassicEditor
+ *			.create( editorElement, {
+ * 				image: {
+ * 					insert: {
+ *						... // settings for "imageInsert" view goes here
+ * 					}
+ * 				}
+ *			} )
+ *			.then( ... )
+ *			.catch( ... );
+ *
+ * See {@link module:core/editor/editorconfig~EditorConfig all editor options}.
+ *
+ * @protected
+ * @interface module:image/imageinsert~ImageInsertConfig
+ */
+
+/**
+ * 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`.
+ * The former adds the **Insert image via URL** feature, while the latter adds the built-in **CKFinder** integration.
+ * * for custom integrations, each string should be a name of the component registered in the
+ * {@link module:ui/componentfactory~ComponentFactory component factory}.
+ * If you have a plugin `PluginX` that registers `pluginXButton` component, then the integration token
+ * in that case should be `pluginXButton`.
+ *
+ *		// Add `insertImageViaUrl`, `openCKFinder` and custom `pluginXButton` integrations.
+ *		const imageInsertConfig = {
+ *			insert: {
+ *				integrations: [
+ *					'insertImageViaUrl',
+ *					'openCKFinder',
+ *					'pluginXButton'
+ *				]
+ *			}
+ *		};
+ *
+ * @member {Array.<String>} module:image/imageinsert~ImageInsertConfig#integrations
+ * @default [ 'insertImageViaUrl' ]
+ */

+ 151 - 0
packages/ckeditor5-image/src/imageinsert/imageinsertui.js

@@ -0,0 +1,151 @@
+/**
+ * @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 image/imageinsert/imageinsertui
+ */
+
+import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
+import ImageInsertPanelView from './ui/imageinsertpanelview';
+import { prepareIntegrations } from './utils';
+
+import { isImage } from '../image/utils';
+
+/**
+ * The image insert dropdown plugin.
+ *
+ * For a detailed overview, check the {@glink features/image-upload/image-upload Image upload feature}
+ * and {@glink features/image#inserting-images-via-source-url Insert images via source URL} documentation.
+ *
+ * Adds the `'imageInsert'` dropdown to the {@link module:ui/componentfactory~ComponentFactory UI component factory}.
+ *
+ * @extends module:core/plugin~Plugin
+ */
+export default class ImageInsertUI extends Plugin {
+	/**
+	 * @inheritDoc
+	 */
+	static get pluginName() {
+		return 'ImageInsertUI';
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	init() {
+		const editor = this.editor;
+
+		editor.ui.componentFactory.add( 'imageInsert', locale => {
+			return this._createDropdownView( locale );
+		} );
+	}
+
+	/**
+	 * Creates the dropdown view.
+	 *
+	 * @param {module:utils/locale~Locale} locale The localization services instance.
+	 *
+	 * @private
+	 * @returns {module:ui/dropdown/dropdownview~DropdownView}
+	 */
+	_createDropdownView( locale ) {
+		const editor = this.editor;
+		const imageInsertView = new ImageInsertPanelView( locale, prepareIntegrations( editor ) );
+		const command = editor.commands.get( 'imageUpload' );
+
+		const dropdownView = imageInsertView.dropdownView;
+		const splitButtonView = dropdownView.buttonView;
+
+		splitButtonView.actionView = editor.ui.componentFactory.create( 'imageUpload' );
+		// After we replaced action button with `imageUpload` component,
+		// we have lost a proper styling and some minor visual quirks have appeared.
+		// Brining back original split button classes helps fix the button styling
+		// See https://github.com/ckeditor/ckeditor5/issues/7986.
+		splitButtonView.actionView.extendTemplate( {
+			attributes: {
+				class: 'ck ck-button ck-splitbutton__action'
+			}
+		} );
+
+		return this._setUpDropdown( dropdownView, imageInsertView, command );
+	}
+
+	/**
+	 * Sets up the dropdown view.
+	 *
+	 * @param {module:ui/dropdown/dropdownview~DropdownView} dropdownView A dropdownView.
+	 * @param {module:image/imageinsert/ui/imageinsertpanelview~ImageInsertPanelView} imageInsertView An imageInsertView.
+	 * @param {module:core/command~Command} command An imageInsert command
+	 *
+	 * @private
+	 * @returns {module:ui/dropdown/dropdownview~DropdownView}
+	 */
+	_setUpDropdown( dropdownView, imageInsertView, command ) {
+		const editor = this.editor;
+		const t = editor.t;
+		const insertButtonView = imageInsertView.insertButtonView;
+		const insertImageViaUrlForm = imageInsertView.getIntegration( 'insertImageViaUrl' );
+		const panelView = dropdownView.panelView;
+
+		dropdownView.bind( 'isEnabled' ).to( command );
+
+		// Defer the children injection to improve initial performance.
+		// See https://github.com/ckeditor/ckeditor5/pull/8019#discussion_r484069652.
+		dropdownView.buttonView.once( 'open', () => {
+			panelView.children.add( imageInsertView );
+		} );
+
+		dropdownView.on( 'change:isOpen', () => {
+			const selectedElement = editor.model.document.selection.getSelectedElement();
+
+			if ( dropdownView.isOpen ) {
+				imageInsertView.focus();
+
+				if ( isImage( selectedElement ) ) {
+					imageInsertView.imageURLInputValue = selectedElement.getAttribute( 'src' );
+					insertButtonView.label = t( 'Update' );
+					insertImageViaUrlForm.label = t( 'Update image URL' );
+				} else {
+					imageInsertView.imageURLInputValue = '';
+					insertButtonView.label = t( 'Insert' );
+					insertImageViaUrlForm.label = t( 'Insert image via URL' );
+				}
+			}
+		} );
+
+		imageInsertView.delegate( 'submit', 'cancel' ).to( dropdownView );
+		this.delegate( 'cancel' ).to( dropdownView );
+
+		dropdownView.on( 'submit', () => {
+			closePanel();
+			onSubmit();
+		} );
+
+		dropdownView.on( 'cancel', () => {
+			closePanel();
+		} );
+
+		function onSubmit() {
+			const selectedElement = editor.model.document.selection.getSelectedElement();
+
+			if ( isImage( selectedElement ) ) {
+				editor.model.change( writer => {
+					writer.setAttribute( 'src', imageInsertView.imageURLInputValue, selectedElement );
+					writer.removeAttribute( 'srcset', selectedElement );
+					writer.removeAttribute( 'sizes', selectedElement );
+				} );
+			} else {
+				editor.execute( 'imageInsert', { source: imageInsertView.imageURLInputValue } );
+			}
+		}
+
+		function closePanel() {
+			editor.editing.view.focus();
+			dropdownView.isOpen = false;
+		}
+
+		return dropdownView;
+	}
+}

+ 3 - 3
packages/ckeditor5-image/src/imageupload/ui/imageuploadformrowview.js → packages/ckeditor5-image/src/imageinsert/ui/imageinsertformrowview.js

@@ -4,16 +4,16 @@
  */
 
 /**
- * @module image/imageupload/ui/imageuploadformrowview
+ * @module image/imageinsert/ui/imageinsertformrowview
  */
 
 import View from '@ckeditor/ckeditor5-ui/src/view';
 
-import '../../../theme/imageuploadformrowview.css';
+import '../../../theme/imageinsertformrowview.css';
 
 /**
  * The class representing a single row in a complex form,
- * used by {@link module:image/imageupload/ui/imageuploadpanelview~ImageUploadPanelView}.
+ * used by {@link module:image/imageinsert/ui/imageinsertpanelview~ImageInsertPanelView}.
  *
  * **Note**: For now this class is private. When more use cases appear (beyond `ckeditor5-table` and `ckeditor5-image`),
  * it will become a component in `ckeditor5-ui`.

+ 10 - 10
packages/ckeditor5-image/src/imageupload/ui/imageuploadpanelview.js → packages/ckeditor5-image/src/imageinsert/ui/imageinsertpanelview.js

@@ -4,14 +4,14 @@
  */
 
 /**
- * @module image/imageupload/ui/imageuploadpanelview
+ * @module image/imageinsert/ui/imageinsertpanelview
  */
 
 import View from '@ckeditor/ckeditor5-ui/src/view';
 
 import ButtonView from '@ckeditor/ckeditor5-ui/src/button/buttonview';
 import SplitButtonView from '@ckeditor/ckeditor5-ui/src/dropdown/button/splitbuttonview';
-import ImageUploadFormRowView from './imageuploadformrowview';
+import ImageInsertFormRowView from './imageinsertformrowview';
 import { createDropdown } from '@ckeditor/ckeditor5-ui/src/dropdown/utils';
 
 import Collection from '@ckeditor/ckeditor5-utils/src/collection';
@@ -25,18 +25,18 @@ import imageIcon from '@ckeditor/ckeditor5-core/theme/icons/image.svg';
 import checkIcon from '@ckeditor/ckeditor5-core/theme/icons/check.svg';
 import cancelIcon from '@ckeditor/ckeditor5-core/theme/icons/cancel.svg';
 
-import '../../../theme/imageupload.css';
+import '../../../theme/imageinsert.css';
 
 /**
  * The insert an image via URL view controller class.
  *
- * See {@link module:image/imageupload/ui/imageuploadpanelview~ImageUploadPanelView}.
+ * See {@link module:image/imageinsert/ui/imageinsertpanelview~ImageInsertPanelView}.
  *
  * @extends module:ui/view~View
  */
-export default class ImageUploadPanelView extends View {
+export default class ImageInsertPanelView extends View {
 	/**
-	 * Creates a view for the dropdown panel of {@link module:image/imageupload/imageuploadui~ImageUploadUI}.
+	 * Creates a view for the dropdown panel of {@link module:image/imageinsert/imageinsertui~ImageInsertUI}.
 	 *
 	 * @param {module:utils/locale~Locale} [locale] The localization services instance.
 	 * @param {Object} [integrations] An integrations object that contains
@@ -151,7 +151,7 @@ export default class ImageUploadPanelView extends View {
 			attributes: {
 				class: [
 					'ck',
-					'ck-image-upload-form'
+					'ck-image-insert-form'
 				],
 
 				tabindex: '-1'
@@ -159,12 +159,12 @@ export default class ImageUploadPanelView extends View {
 
 			children: [
 				...this._integrations,
-				new ImageUploadFormRowView( locale, {
+				new ImageInsertFormRowView( locale, {
 					children: [
 						this.insertButtonView,
 						this.cancelButtonView
 					],
-					class: 'ck-image-upload-form__action-row'
+					class: 'ck-image-insert-form__action-row'
 				} )
 			]
 		} );
@@ -247,7 +247,7 @@ export default class ImageUploadPanelView extends View {
 
 		panelView.extendTemplate( {
 			attributes: {
-				class: 'ck-image-upload__panel'
+				class: 'ck-image-insert__panel'
 			}
 		} );
 

+ 77 - 0
packages/ckeditor5-image/src/imageinsert/utils.js

@@ -0,0 +1,77 @@
+/**
+ * @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 image/imageinsert/utils
+ */
+
+import LabeledFieldView from '@ckeditor/ckeditor5-ui/src/labeledfield/labeledfieldview';
+import { createLabeledInputText } from '@ckeditor/ckeditor5-ui/src/labeledfield/utils';
+
+/**
+ * Creates integrations object that will be passed to the
+ * {@link module:image/imageinsert/ui/imageinsertpanelview~ImageInsertPanelView}.
+ *
+ * @param {module:core/editor/editor~Editor} editor Editor instance.
+ *
+ * @returns {Object.<String, module:ui/view~View>} Integrations object.
+ */
+export function prepareIntegrations( editor ) {
+	const panelItems = editor.config.get( 'image.insert.integrations' );
+	const imageInsertUIPlugin = editor.plugins.get( 'ImageInsertUI' );
+
+	const PREDEFINED_INTEGRATIONS = {
+		'insertImageViaUrl': createLabeledInputView( editor.locale )
+	};
+
+	if ( !panelItems ) {
+		return PREDEFINED_INTEGRATIONS;
+	}
+
+	// Prepares ckfinder component for the `openCKFinder` integration token.
+	if ( panelItems.find( item => item === 'openCKFinder' ) && editor.ui.componentFactory.has( 'ckfinder' ) ) {
+		const ckFinderButton = editor.ui.componentFactory.create( 'ckfinder' );
+		ckFinderButton.set( {
+			withText: true,
+			class: 'ck-image-insert__ck-finder-button'
+		} );
+
+		// We want to close the dropdown panel view when user clicks the ckFinderButton.
+		ckFinderButton.delegate( 'execute' ).to( imageInsertUIPlugin, 'cancel' );
+
+		PREDEFINED_INTEGRATIONS.openCKFinder = ckFinderButton;
+	}
+
+	// Creates integrations object of valid views to pass it to the ImageInsertPanelView.
+	return panelItems.reduce( ( object, key ) => {
+		if ( PREDEFINED_INTEGRATIONS[ key ] ) {
+			object[ key ] = PREDEFINED_INTEGRATIONS[ key ];
+		} else if ( editor.ui.componentFactory.has( key ) ) {
+			object[ key ] = editor.ui.componentFactory.create( key );
+		}
+
+		return object;
+	}, {} );
+}
+
+/**
+ * Creates labeled field view.
+ *
+ * @param {module:utils/locale~Locale} locale The localization services instance.
+ *
+ * @returns {module:ui/labeledfield/labeledfieldview~LabeledFieldView}
+ */
+export function createLabeledInputView( locale ) {
+	const t = locale.t;
+	const labeledInputView = new LabeledFieldView( locale, createLabeledInputText );
+
+	labeledInputView.set( {
+		label: t( 'Insert image via URL' )
+	} );
+	labeledInputView.fieldView.placeholder = 'https://example.com/src/image.png';
+	labeledInputView.infoText = t( 'Paste the image source URL.' );
+
+	return labeledInputView;
+}

+ 0 - 55
packages/ckeditor5-image/src/imageupload.js

@@ -83,58 +83,3 @@ export default class ImageUpload extends Plugin {
  * @member {Array.<String>} module:image/imageupload~ImageUploadConfig#types
  * @default [ 'jpeg', 'png', 'gif', 'bmp', 'webp', 'tiff' ]
  */
-
-/**
- * The image upload panel view configuration.
- *
- * @protected
- * @member {module:image/imageupload~ImageUploadPanelConfig} module:image/imageupload~ImageUploadConfig#panel
- */
-
-/**
- * The configuration of the image upload dropdown panel view. Used by the image upload feature in the `@ckeditor/ckeditor5-image` package.
- *
- *		ClassicEditor
- *			.create( editorElement, {
- * 				image: {
- * 					upload: {
- * 						panel: ... // Panel settings go here.
- * 					}
- * 				}
- *			} )
- *			.then( ... )
- *			.catch( ... );
- *
- * See {@link module:core/editor/editorconfig~EditorConfig all editor options}.
- *
- * @protected
- * @interface module:image/imageupload~ImageUploadPanelConfig
- */
-
-/**
- * The list of {@link module:image/imageupload~ImageUpload} integrations.
- *
- * This option accepts string tokens:
- * * For predefined integrations, there are two special strings: `insertImageViaUrl` and `openCKFinder`.
- * The former adds the {@glink features/image#inserting-images-via-source-url Insert image via URL} feature, while the latter adds the
- * built-in {@glink features/image-upload/ckfinder CKFinder} integration.
- * * For custom integrations, each string should be a name of the already registered component.
- * If you have a plugin `PluginX` that registers the `pluginXButton` component, then in that case the integration token
- * should be `pluginXButton`.
- *
- *		// Add `insertImageViaUrl`, `openCKFinder` and custom `pluginXButton` integrations.
- *		const imageUploadConfig = {
- *			upload: {
- *				panel: {
- *					items: [
- *						'insertImageViaUrl',
- *						'openCKFinder',
- *						'pluginXButton'
- *					]
- *				}
- *			}
- *		};
- *
- * @member {Array.<String>} module:image/imageupload~ImageUploadPanelConfig#items
- * @default [ 'insertImageViaUrl' ]
- */

+ 24 - 139
packages/ckeditor5-image/src/imageupload/imageuploadui.js

@@ -8,21 +8,17 @@
  */
 
 import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
-import ImageUploadPanelView from './ui/imageuploadpanelview';
-
 import FileDialogButtonView from '@ckeditor/ckeditor5-upload/src/ui/filedialogbuttonview';
-import { createImageTypeRegExp, prepareIntegrations } from './utils';
+import { createImageTypeRegExp } from './utils';
 
 import imageIcon from '@ckeditor/ckeditor5-core/theme/icons/image.svg';
 
-import { isImage } from '../image/utils';
-
 /**
  * The image upload button plugin.
  *
  * For a detailed overview, check the {@glink features/image-upload/image-upload Image upload feature} documentation.
  *
- * Adds the `'imageUpload'` dropdown to the {@link module:ui/componentfactory~ComponentFactory UI component factory}.
+ * Adds the `'imageUpload'` button to the {@link module:ui/componentfactory~ComponentFactory UI component factory}.
  *
  * @extends module:core/plugin~Plugin
  */
@@ -39,148 +35,37 @@ export default class ImageUploadUI extends Plugin {
 	 */
 	init() {
 		const editor = this.editor;
-		const isImageUploadPanelViewEnabled = !!editor.config.get( 'image.upload.panel.items' );
+		const t = editor.t;
 
+		// Setup `imageUpload` button.
 		editor.ui.componentFactory.add( 'imageUpload', locale => {
-			if ( isImageUploadPanelViewEnabled ) {
-				return this._createDropdownView( locale );
-			} else {
-				return this._createFileDialogButtonView( locale );
-			}
-		} );
-	}
+			const view = new FileDialogButtonView( locale );
+			const command = editor.commands.get( 'imageUpload' );
+			const imageTypes = editor.config.get( 'image.upload.types' );
+			const imageTypesRegExp = createImageTypeRegExp( imageTypes );
 
-	/**
-	 * Sets up the dropdown view.
-	 *
-	 * @param {module:ui/dropdown/dropdownview~DropdownView} dropdownView A dropdownView.
-	 * @param {module:image/imageupload/ui/imageuploadpanelview~ImageUploadPanelView} imageUploadView An imageUploadView.
-	 * @param {module:core/command~Command} command An imageUpload command
-	 *
-	 * @private
-	 * @returns {module:ui/dropdown/dropdownview~DropdownView}
-	 */
-	_setUpDropdown( dropdownView, imageUploadView, command ) {
-		const editor = this.editor;
-		const t = editor.t;
-		const insertButtonView = imageUploadView.insertButtonView;
-		const insertImageViaUrlForm = imageUploadView.getIntegration( 'insertImageViaUrl' );
+			view.set( {
+				acceptedType: imageTypes.map( type => `image/${ type }` ).join( ',' ),
+				allowMultipleFiles: true
+			} );
 
-		dropdownView.bind( 'isEnabled' ).to( command );
+			view.buttonView.set( {
+				label: t( 'Insert image' ),
+				icon: imageIcon,
+				tooltip: true
+			} );
 
-		dropdownView.on( 'change:isOpen', () => {
-			const selectedElement = editor.model.document.selection.getSelectedElement();
+			view.buttonView.bind( 'isEnabled' ).to( command );
 
-			if ( dropdownView.isOpen ) {
-				imageUploadView.focus();
+			view.on( 'done', ( evt, files ) => {
+				const imagesToUpload = Array.from( files ).filter( file => imageTypesRegExp.test( file.type ) );
 
-				if ( isImage( selectedElement ) ) {
-					imageUploadView.imageURLInputValue = selectedElement.getAttribute( 'src' );
-					insertButtonView.label = t( 'Update' );
-					insertImageViaUrlForm.label = t( 'Update image URL' );
-				} else {
-					imageUploadView.imageURLInputValue = '';
-					insertButtonView.label = t( 'Insert' );
-					insertImageViaUrlForm.label = t( 'Insert image via URL' );
+				if ( imagesToUpload.length ) {
+					editor.execute( 'imageUpload', { file: imagesToUpload } );
 				}
-			}
-		} );
-
-		imageUploadView.delegate( 'submit', 'cancel' ).to( dropdownView );
-		this.delegate( 'cancel' ).to( dropdownView );
-
-		dropdownView.on( 'submit', () => {
-			closePanel();
-			onSubmit();
-		} );
-
-		dropdownView.on( 'cancel', () => {
-			closePanel();
-		} );
-
-		function onSubmit() {
-			const selectedElement = editor.model.document.selection.getSelectedElement();
-
-			if ( isImage( selectedElement ) ) {
-				editor.model.change( writer => {
-					writer.setAttribute( 'src', imageUploadView.imageURLInputValue, selectedElement );
-					writer.removeAttribute( 'srcset', selectedElement );
-					writer.removeAttribute( 'sizes', selectedElement );
-				} );
-			} else {
-				editor.execute( 'imageInsert', { source: imageUploadView.imageURLInputValue } );
-			}
-		}
-
-		function closePanel() {
-			editor.editing.view.focus();
-			dropdownView.isOpen = false;
-		}
-
-		return dropdownView;
-	}
+			} );
 
-	/**
-	 * Creates the dropdown view.
-	 *
-	 * @param {module:utils/locale~Locale} locale The localization services instance.
-	 *
-	 * @private
-	 * @returns {module:ui/dropdown/dropdownview~DropdownView}
-	 */
-	_createDropdownView( locale ) {
-		const editor = this.editor;
-		const imageUploadView = new ImageUploadPanelView( locale, prepareIntegrations( editor ) );
-		const command = editor.commands.get( 'imageUpload' );
-
-		const dropdownView = imageUploadView.dropdownView;
-		const panelView = dropdownView.panelView;
-		const splitButtonView = dropdownView.buttonView;
-
-		splitButtonView.actionView = this._createFileDialogButtonView( locale );
-
-		panelView.children.add( imageUploadView );
-
-		return this._setUpDropdown( dropdownView, imageUploadView, command );
-	}
-
-	/**
-	 * Creates and sets up the file dialog button view.
-	 *
-	 * @param {module:utils/locale~Locale} locale The localization services instance.
-	 *
-	 * @private
-	 * @returns {module:upload/ui/filedialogbuttonview~FileDialogButtonView}
-	 */
-	_createFileDialogButtonView( locale ) {
-		const editor = this.editor;
-		const t = locale.t;
-		const imageTypes = editor.config.get( 'image.upload.types' );
-		const fileDialogButtonView = new FileDialogButtonView( locale );
-		const imageTypesRegExp = createImageTypeRegExp( imageTypes );
-		const command = editor.commands.get( 'imageUpload' );
-
-		fileDialogButtonView.set( {
-			acceptedType: imageTypes.map( type => `image/${ type }` ).join( ',' ),
-			allowMultipleFiles: true
-		} );
-
-		fileDialogButtonView.buttonView.set( {
-			label: t( 'Insert image' ),
-			icon: imageIcon,
-			tooltip: true
+			return view;
 		} );
-
-		fileDialogButtonView.buttonView.bind( 'isEnabled' ).to( command );
-
-		fileDialogButtonView.on( 'done', ( evt, files ) => {
-			const imagesToUpload = Array.from( files ).filter( file => imageTypesRegExp.test( file.type ) );
-
-			if ( imagesToUpload.length ) {
-				editor.execute( 'imageUpload', { file: imagesToUpload } );
-			}
-		} );
-
-		return fileDialogButtonView;
 	}
 }

+ 0 - 69
packages/ckeditor5-image/src/imageupload/utils.js

@@ -9,9 +9,6 @@
 
 /* global fetch, File */
 
-import LabeledFieldView from '@ckeditor/ckeditor5-ui/src/labeledfield/labeledfieldview';
-import { createLabeledInputText } from '@ckeditor/ckeditor5-ui/src/labeledfield/utils';
-
 /**
  * Creates a regular expression used to test for image files.
  *
@@ -85,69 +82,3 @@ function getImageMimeType( blob, src ) {
 		return 'image/jpeg';
 	}
 }
-
-/**
- * Creates an integrations object that will be passed to the
- * {@link module:image/imageupload/ui/imageuploadpanelview~ImageUploadPanelView}.
- *
- * @param {module:core/editor/editor~Editor} editor The editor instance.
- *
- * @returns {Object.<String, module:ui/view~View>} The integrations object.
- */
-export function prepareIntegrations( editor ) {
-	const panelItems = editor.config.get( 'image.upload.panel.items' );
-	const imageUploadUIPlugin = editor.plugins.get( 'ImageUploadUI' );
-
-	const PREDEFINED_INTEGRATIONS = {
-		'insertImageViaUrl': createLabeledInputView( editor.locale )
-	};
-
-	if ( !panelItems ) {
-		return PREDEFINED_INTEGRATIONS;
-	}
-
-	// Prepares ckfinder component for the `openCKFinder` integration token.
-	if ( panelItems.find( item => item === 'openCKFinder' ) && editor.ui.componentFactory.has( 'ckfinder' ) ) {
-		const ckFinderButton = editor.ui.componentFactory.create( 'ckfinder' );
-		ckFinderButton.set( {
-			withText: true,
-			class: 'ck-image-upload__ck-finder-button'
-		} );
-
-		// We want to close the dropdown panel view when user clicks the ckFinderButton.
-		ckFinderButton.delegate( 'execute' ).to( imageUploadUIPlugin, 'cancel' );
-
-		PREDEFINED_INTEGRATIONS.openCKFinder = ckFinderButton;
-	}
-
-	// Creates integrations object of valid views to pass it to the ImageUploadPanelView.
-	return panelItems.reduce( ( object, key ) => {
-		if ( PREDEFINED_INTEGRATIONS[ key ] ) {
-			object[ key ] = PREDEFINED_INTEGRATIONS[ key ];
-		} else if ( editor.ui.componentFactory.has( key ) ) {
-			object[ key ] = editor.ui.componentFactory.create( key );
-		}
-
-		return object;
-	}, {} );
-}
-
-/**
- * Creates a labeled field view.
- *
- * @param {module:utils/locale~Locale} locale The localization services instance.
- *
- * @returns {module:ui/labeledfield/labeledfieldview~LabeledFieldView}
- */
-export function createLabeledInputView( locale ) {
-	const t = locale.t;
-	const labeledInputView = new LabeledFieldView( locale, createLabeledInputText );
-
-	labeledInputView.set( {
-		label: t( 'Insert image via URL' )
-	} );
-	labeledInputView.fieldView.placeholder = 'https://example.com/src/image.png';
-	labeledInputView.infoText = t( 'Paste the image source URL.' );
-
-	return labeledInputView;
-}

+ 323 - 0
packages/ckeditor5-image/tests/imageinsert/imageinsertui.js

@@ -0,0 +1,323 @@
+/**
+ * @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 ClassicEditor from '@ckeditor/ckeditor5-editor-classic/src/classiceditor';
+
+import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
+import Image from '../../src/image';
+import DropdownView from '@ckeditor/ckeditor5-ui/src/dropdown/dropdownview';
+import FileDialogButtonView from '@ckeditor/ckeditor5-upload/src/ui/filedialogbuttonview';
+import FileRepository from '@ckeditor/ckeditor5-upload/src/filerepository';
+import ImageInsert from '../../src/imageinsert';
+import ImageInsertUI from '../../src/imageinsert/imageinsertui';
+import ImageInsertPanelView from '../../src/imageinsert/ui/imageinsertpanelview';
+import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
+import Notification from '@ckeditor/ckeditor5-ui/src/notification/notification';
+import Clipboard from '@ckeditor/ckeditor5-clipboard/src/clipboard';
+import EventInfo from '@ckeditor/ckeditor5-utils/src/eventinfo';
+import DomEventData from '@ckeditor/ckeditor5-engine/src/view/observer/domeventdata';
+import CKFinder from '@ckeditor/ckeditor5-ckfinder/src/ckfinder';
+import LabeledFieldView from '@ckeditor/ckeditor5-ui/src/labeledfield/labeledfieldview';
+import ButtonView from '@ckeditor/ckeditor5-ui/src/button/buttonview';
+
+import { UploadAdapterMock } from '@ckeditor/ckeditor5-upload/tests/_utils/mocks';
+
+describe( 'ImageInsertUI', () => {
+	let editor, editorElement, fileRepository, dropdown;
+
+	class UploadAdapterPluginMock extends Plugin {
+		init() {
+			fileRepository = this.editor.plugins.get( FileRepository );
+			fileRepository.createUploadAdapter = loader => {
+				return new UploadAdapterMock( loader );
+			};
+		}
+	}
+
+	describe( 'dropdown', () => {
+		beforeEach( () => {
+			editorElement = document.createElement( 'div' );
+			document.body.appendChild( editorElement );
+
+			return ClassicEditor
+				.create( editorElement, {
+					plugins: [ Paragraph, Image, ImageInsert, ImageInsertUI, FileRepository, UploadAdapterPluginMock, Clipboard ],
+					toolbar: [ 'imageInsert' ],
+					image: {
+						insert: {
+							integrations: [
+								'insertImageViaUrl'
+							]
+						}
+					}
+				} )
+				.then( newEditor => {
+					editor = newEditor;
+
+					dropdown = editor.ui.view.toolbar.children.first.children.first;
+
+					// Hide all notifications (prevent alert() calls).
+					const notification = editor.plugins.get( Notification );
+					notification.on( 'show', evt => evt.stop() );
+				} );
+		} );
+
+		afterEach( () => {
+			editorElement.remove();
+
+			return editor.destroy();
+		} );
+
+		it( 'should register the "imageInsert" dropdown', () => {
+			const button = editor.ui.componentFactory.create( 'imageInsert' );
+
+			expect( button ).to.be.instanceOf( DropdownView );
+		} );
+
+		it( 'should not insert panel view children until dropdown is not open for the first time', () => {
+			expect( dropdown.panelView.children.length ).to.equal( 0 );
+
+			dropdown.buttonView.fire( 'open' );
+
+			expect( dropdown.panelView.children.length ).to.equal( 1 );
+			expect( dropdown.panelView.children.first ).to.be.instanceOf( ImageInsertPanelView );
+		} );
+
+		describe( 'dropdown action button', () => {
+			it( 'should be an instance of FileDialogButtonView', () => {
+				const dropdown = editor.ui.componentFactory.create( 'imageInsert' );
+
+				expect( dropdown.buttonView.actionView ).to.be.instanceOf( FileDialogButtonView );
+			} );
+		} );
+
+		describe( 'dropdown panel buttons', () => {
+			it( 'should have "Update" label on submit button when URL input is already filled', () => {
+				const viewDocument = editor.editing.view.document;
+
+				editor.setData( '<figure><img src="/assets/sample.png" /></figure>' );
+
+				editor.editing.view.change( writer => {
+					writer.setSelection( viewDocument.getRoot().getChild( 0 ), 'on' );
+				} );
+
+				const img = viewDocument.selection.getSelectedElement();
+
+				const data = fakeEventData();
+				const eventInfo = new EventInfo( img, 'click' );
+				const domEventDataMock = new DomEventData( viewDocument, eventInfo, data );
+
+				viewDocument.fire( 'click', domEventDataMock );
+
+				dropdown.buttonView.fire( 'open' );
+
+				const inputValue = dropdown.panelView.children.first.imageURLInputValue;
+
+				expect( inputValue ).to.equal( '/assets/sample.png' );
+				expect( dropdown.panelView.children.first.insertButtonView.label ).to.equal( 'Update' );
+			} );
+
+			it( 'should have "Insert" label on submit button on uploading a new image', () => {
+				const viewDocument = editor.editing.view.document;
+
+				editor.setData( '<p>test</p>' );
+
+				editor.editing.view.change( writer => {
+					writer.setSelection( viewDocument.getRoot().getChild( 0 ), 'end' );
+				} );
+
+				const el = viewDocument.selection.getSelectedElement();
+
+				const data = fakeEventData();
+				const eventInfo = new EventInfo( el, 'click' );
+				const domEventDataMock = new DomEventData( viewDocument, eventInfo, data );
+
+				viewDocument.fire( 'click', domEventDataMock );
+
+				dropdown.buttonView.fire( 'open' );
+
+				const inputValue = dropdown.panelView.children.first.imageURLInputValue;
+
+				expect( dropdown.isOpen ).to.be.true;
+				expect( inputValue ).to.equal( '' );
+				expect( dropdown.panelView.children.first.insertButtonView.label ).to.equal( 'Insert' );
+			} );
+		} );
+
+		describe( 'dropdown panel integrations', () => {
+			describe( 'insert image via URL form', () => {
+				it( 'should have "Insert image via URL" label on inserting new image', () => {
+					const viewDocument = editor.editing.view.document;
+
+					editor.setData( '<p>test</p>' );
+
+					editor.editing.view.change( writer => {
+						writer.setSelection( viewDocument.getRoot().getChild( 0 ), 'end' );
+					} );
+
+					const el = viewDocument.selection.getSelectedElement();
+
+					const data = fakeEventData();
+					const eventInfo = new EventInfo( el, 'click' );
+					const domEventDataMock = new DomEventData( viewDocument, eventInfo, data );
+
+					viewDocument.fire( 'click', domEventDataMock );
+
+					dropdown.buttonView.fire( 'open' );
+
+					const inputValue = dropdown.panelView.children.first.imageURLInputValue;
+
+					const insertImageViaUrlForm = dropdown.panelView.children.first.getIntegration( 'insertImageViaUrl' );
+
+					expect( dropdown.isOpen ).to.be.true;
+					expect( inputValue ).to.equal( '' );
+					expect( insertImageViaUrlForm.label ).to.equal( 'Insert image via URL' );
+				} );
+
+				it( 'should have "Update image URL" label on updating the image source URL', () => {
+					const viewDocument = editor.editing.view.document;
+
+					editor.setData( '<figure><img src="/assets/sample.png" /></figure>' );
+
+					editor.editing.view.change( writer => {
+						writer.setSelection( viewDocument.getRoot().getChild( 0 ), 'on' );
+					} );
+
+					const el = viewDocument.selection.getSelectedElement();
+
+					const data = fakeEventData();
+					const eventInfo = new EventInfo( el, 'click' );
+					const domEventDataMock = new DomEventData( viewDocument, eventInfo, data );
+
+					viewDocument.fire( 'click', domEventDataMock );
+
+					dropdown.buttonView.fire( 'open' );
+
+					const inputValue = dropdown.panelView.children.first.imageURLInputValue;
+					const insertImageViaUrlForm = dropdown.panelView.children.first.getIntegration( 'insertImageViaUrl' );
+
+					expect( dropdown.isOpen ).to.be.true;
+					expect( inputValue ).to.equal( '/assets/sample.png' );
+					expect( insertImageViaUrlForm.label ).to.equal( 'Update image URL' );
+				} );
+			} );
+		} );
+
+		it( 'should remove all attributes from model except "src" when updating the image source URL', () => {
+			const viewDocument = editor.editing.view.document;
+			const commandSpy = sinon.spy( editor.commands.get( 'imageInsert' ), 'execute' );
+			const submitSpy = sinon.spy();
+
+			dropdown.buttonView.fire( 'open' );
+
+			const insertButtonView = dropdown.panelView.children.first.insertButtonView;
+
+			editor.setData( '<figure><img src="image-url-800w.jpg"' +
+			'srcset="image-url-480w.jpg 480w,image-url-800w.jpg 800w"' +
+			'sizes="(max-width: 600px) 480px,800px"' +
+			'alt="test-image"></figure>' );
+
+			editor.editing.view.change( writer => {
+				writer.setSelection( viewDocument.getRoot().getChild( 0 ), 'on' );
+			} );
+
+			const selectedElement = editor.model.document.selection.getSelectedElement();
+
+			expect( selectedElement.getAttribute( 'src' ) ).to.equal( 'image-url-800w.jpg' );
+			expect( selectedElement.hasAttribute( 'srcset' ) ).to.be.true;
+
+			dropdown.panelView.children.first.imageURLInputValue = '/assets/sample3.png';
+
+			dropdown.on( 'submit', submitSpy );
+
+			insertButtonView.fire( 'execute' );
+
+			sinon.assert.notCalled( commandSpy );
+			sinon.assert.calledOnce( submitSpy );
+			expect( dropdown.isOpen ).to.be.false;
+			expect( selectedElement.getAttribute( 'src' ) ).to.equal( '/assets/sample3.png' );
+			expect( selectedElement.hasAttribute( 'srcset' ) ).to.be.false;
+			expect( selectedElement.hasAttribute( 'sizes' ) ).to.be.false;
+		} );
+
+		describe( 'events', () => {
+			it( 'should emit "submit" event when clicking on submit button', () => {
+				const commandSpy = sinon.spy( editor.commands.get( 'imageInsert' ), 'execute' );
+				const submitSpy = sinon.spy();
+
+				dropdown.buttonView.fire( 'open' );
+
+				dropdown.on( 'submit', submitSpy );
+
+				const insertButtonView = dropdown.panelView.children.first.insertButtonView;
+
+				insertButtonView.fire( 'execute' );
+
+				expect( dropdown.isOpen ).to.be.false;
+				sinon.assert.calledOnce( commandSpy );
+				sinon.assert.calledOnce( submitSpy );
+			} );
+
+			it( 'should emit "cancel" event when clicking on cancel button', () => {
+				const commandSpy = sinon.spy( editor.commands.get( 'imageInsert' ), 'execute' );
+				const cancelSpy = sinon.spy();
+
+				dropdown.buttonView.fire( 'open' );
+
+				dropdown.on( 'cancel', cancelSpy );
+
+				const cancelButtonView = dropdown.panelView.children.first.cancelButtonView;
+
+				cancelButtonView.fire( 'execute' );
+
+				expect( dropdown.isOpen ).to.be.false;
+				sinon.assert.notCalled( commandSpy );
+				sinon.assert.calledOnce( cancelSpy );
+			} );
+		} );
+
+		it( 'should inject integrations to the dropdown panel view from the config', async () => {
+			const editor = await ClassicEditor
+				.create( editorElement, {
+					plugins: [
+						CKFinder,
+						Paragraph,
+						Image,
+						ImageInsert,
+						ImageInsertUI,
+						FileRepository,
+						UploadAdapterPluginMock,
+						Clipboard
+					],
+					image: {
+						insert: {
+							integrations: [
+								'insertImageViaUrl',
+								'openCKFinder'
+							]
+						}
+					}
+				} );
+
+			const dropdown = editor.ui.componentFactory.create( 'imageInsert' );
+
+			dropdown.buttonView.fire( 'open' );
+
+			expect( dropdown.panelView.children.first._integrations.length ).to.equal( 2 );
+			expect( dropdown.panelView.children.first._integrations.first ).to.be.instanceOf( LabeledFieldView );
+			expect( dropdown.panelView.children.first._integrations.last ).to.be.instanceOf( ButtonView );
+
+			editor.destroy();
+		} );
+	} );
+} );
+
+function fakeEventData() {
+	return {
+		preventDefault: sinon.spy()
+	};
+}

+ 1 - 1
packages/ckeditor5-image/tests/imageupload/ui/imageuploadformrowview.js → packages/ckeditor5-image/tests/imageinsert/ui/imageinsertformrowview.js

@@ -4,7 +4,7 @@
  */
 
 import View from '@ckeditor/ckeditor5-ui/src/view';
-import ImageUploadFormRowView from '../../../src/imageupload/ui/imageuploadformrowview';
+import ImageUploadFormRowView from '../../../src/imageinsert/ui/imageinsertformrowview';
 import ViewCollection from '@ckeditor/ckeditor5-ui/src/viewcollection';
 
 describe( 'ImageUploadFormRowView', () => {

+ 4 - 4
packages/ckeditor5-image/tests/imageupload/ui/imageuploadpanelview.js → packages/ckeditor5-image/tests/imageinsert/ui/imageinsertpanelview.js

@@ -8,8 +8,8 @@
 import DropdownView from '@ckeditor/ckeditor5-ui/src/dropdown/dropdownview';
 import LabeledFieldView from '@ckeditor/ckeditor5-ui/src/labeledfield/labeledfieldview';
 
-import ImageUploadPanelView from '../../../src/imageupload/ui/imageuploadpanelview';
-import ImageUploadFormRowView from '../../../src/imageupload/ui/imageuploadformrowview';
+import ImageUploadPanelView from '../../../src/imageinsert/ui/imageinsertpanelview';
+import ImageUploadFormRowView from '../../../src/imageinsert/ui/imageinsertformrowview';
 import ButtonView from '@ckeditor/ckeditor5-ui/src/button/buttonview';
 import SplitButtonView from '@ckeditor/ckeditor5-ui/src/dropdown/button/splitbuttonview';
 import Collection from '@ckeditor/ckeditor5-utils/src/collection';
@@ -21,7 +21,7 @@ import FocusCycler from '@ckeditor/ckeditor5-ui/src/focuscycler';
 import ViewCollection from '@ckeditor/ckeditor5-ui/src/viewcollection';
 import View from '@ckeditor/ckeditor5-ui/src/view';
 
-import { createLabeledInputView } from '../../../src/imageupload/utils';
+import { createLabeledInputView } from '../../../src/imageinsert/utils';
 
 import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils';
 
@@ -140,7 +140,7 @@ describe( 'ImageUploadPanelView', () => {
 	describe( 'template', () => {
 		it( 'should create element from the template', () => {
 			expect( view.element.classList.contains( 'ck' ) ).to.true;
-			expect( view.element.classList.contains( 'ck-image-upload-form' ) ).to.true;
+			expect( view.element.classList.contains( 'ck-image-insert-form' ) ).to.true;
 			expect( view.element.getAttribute( 'tabindex' ) ).to.equal( '-1' );
 		} );
 

+ 142 - 0
packages/ckeditor5-image/tests/imageinsert/utils.js

@@ -0,0 +1,142 @@
+/**
+ * @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 ClassicEditor from '@ckeditor/ckeditor5-editor-classic/src/classiceditor';
+
+import ButtonView from '@ckeditor/ckeditor5-ui/src/button/buttonview';
+import Image from '../../src/image';
+import ImageUploadUI from '../../src/imageinsert/imageinsertui';
+import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
+import Link from '@ckeditor/ckeditor5-link/src/link';
+import CKFinder from '@ckeditor/ckeditor5-ckfinder/src/ckfinder';
+import { prepareIntegrations, createLabeledInputView } from '../../src/imageinsert/utils';
+
+describe( 'Upload utils', () => {
+	describe( 'prepareIntegrations()', () => {
+		it( 'should return "insetImageViaUrl" and "openCKFinder" integrations', async () => {
+			const editorElement = document.createElement( 'div' );
+			document.body.appendChild( editorElement );
+
+			const editor = await ClassicEditor
+				.create( editorElement, {
+					plugins: [
+						CKFinder,
+						Paragraph,
+						Image,
+						ImageUploadUI
+					],
+					image: {
+						insert: {
+							integrations: [
+								'insertImageViaUrl',
+								'openCKFinder'
+							]
+						}
+					}
+				} );
+
+			const openCKFinderExtendedView = Object.values( prepareIntegrations( editor ) )[ 1 ];
+
+			expect( openCKFinderExtendedView.class ).contains( 'ck-image-insert__ck-finder-button' );
+			expect( openCKFinderExtendedView.label ).to.equal( 'Insert image or file' );
+			expect( openCKFinderExtendedView.withText ).to.be.true;
+
+			editor.destroy();
+			editorElement.remove();
+		} );
+
+		it( 'should return only "insertImageViaUrl" integration and throw warning ' +
+			'for "image-upload-integrations-invalid-view" error', async () => {
+			const editorElement = document.createElement( 'div' );
+			document.body.appendChild( editorElement );
+
+			const editor = await ClassicEditor
+				.create( editorElement, {
+					plugins: [
+						Paragraph,
+						Image,
+						ImageUploadUI
+					],
+					image: {
+						insert: {
+							integrations: [
+								'insertImageViaUrl',
+								'openCKFinder'
+							]
+						}
+					}
+				} );
+
+			expect( Object.values( prepareIntegrations( editor ) ).length ).to.equal( 1 );
+
+			editor.destroy();
+			editorElement.remove();
+		} );
+
+		it( 'should return only "link" integration', async () => {
+			const editorElement = document.createElement( 'div' );
+			document.body.appendChild( editorElement );
+
+			const editor = await ClassicEditor
+				.create( editorElement, {
+					plugins: [
+						Paragraph,
+						Link,
+						Image,
+						ImageUploadUI
+					],
+					image: {
+						insert: {
+							integrations: [
+								'link'
+							]
+						}
+					}
+				} );
+
+			expect( Object.values( prepareIntegrations( editor ) ).length ).to.equal( 1 );
+			expect( Object.values( prepareIntegrations( editor ) )[ 0 ].label ).to.equal( 'Link' );
+			expect( Object.values( prepareIntegrations( editor ) )[ 0 ] ).to.be.instanceOf( ButtonView );
+
+			editor.destroy();
+			editorElement.remove();
+		} );
+
+		it( 'should return "insertImageViaUrl" integration, when no integrations were configured', async () => {
+			const editorElement = document.createElement( 'div' );
+			document.body.appendChild( editorElement );
+
+			const editor = await ClassicEditor
+				.create( editorElement, {
+					plugins: [
+						Paragraph,
+						Image,
+						ImageUploadUI
+					]
+				} );
+
+			expect( Object.keys( prepareIntegrations( editor ) ).length ).to.equal( 1 );
+
+			editor.destroy();
+			editorElement.remove();
+		} );
+	} );
+
+	describe( 'createLabeledInputView()', () => {
+		describe( 'image URL input view', () => {
+			it( 'should have placeholder', () => {
+				const view = createLabeledInputView( { t: val => val } );
+				expect( view.fieldView.placeholder ).to.equal( 'https://example.com/src/image.png' );
+			} );
+
+			it( 'should have info text', () => {
+				const view = createLabeledInputView( { t: val => val } );
+				expect( view.infoText ).to.match( /^Paste the image source URL/ );
+			} );
+		} );
+	} );
+} );

+ 109 - 539
packages/ckeditor5-image/tests/imageupload/imageuploadui.js

@@ -9,7 +9,6 @@ import ClassicEditor from '@ckeditor/ckeditor5-editor-classic/src/classiceditor'
 
 import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
 import Image from '../../src/image';
-import DropdownView from '@ckeditor/ckeditor5-ui/src/dropdown/dropdownview';
 import FileDialogButtonView from '@ckeditor/ckeditor5-upload/src/ui/filedialogbuttonview';
 import FileRepository from '@ckeditor/ckeditor5-upload/src/filerepository';
 import ImageUploadUI from '../../src/imageupload/imageuploadui';
@@ -17,11 +16,6 @@ import ImageUploadEditing from '../../src/imageupload/imageuploadediting';
 import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
 import Notification from '@ckeditor/ckeditor5-ui/src/notification/notification';
 import Clipboard from '@ckeditor/ckeditor5-clipboard/src/clipboard';
-import EventInfo from '@ckeditor/ckeditor5-utils/src/eventinfo';
-import DomEventData from '@ckeditor/ckeditor5-engine/src/view/observer/domeventdata';
-import CKFinder from '@ckeditor/ckeditor5-ckfinder/src/ckfinder';
-import LabeledFieldView from '@ckeditor/ckeditor5-ui/src/labeledfield/labeledfieldview';
-import ButtonView from '@ckeditor/ckeditor5-ui/src/button/buttonview';
 
 import { createNativeFileMock, UploadAdapterMock } from '@ckeditor/ckeditor5-upload/tests/_utils/mocks';
 import { setData as setModelData, getData as getModelData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
@@ -38,578 +32,154 @@ describe( 'ImageUploadUI', () => {
 		}
 	}
 
-	describe( 'file dialog button', () => {
-		beforeEach( () => {
-			editorElement = document.createElement( 'div' );
-			document.body.appendChild( editorElement );
-
-			return ClassicEditor
-				.create( editorElement, {
-					plugins: [ Paragraph, Image, ImageUploadEditing, ImageUploadUI, FileRepository, UploadAdapterPluginMock, Clipboard ]
-				} )
-				.then( newEditor => {
-					editor = newEditor;
-					model = editor.model;
-
-					// Hide all notifications (prevent alert() calls).
-					const notification = editor.plugins.get( Notification );
-					notification.on( 'show', evt => evt.stop() );
-				} );
-		} );
-
-		afterEach( () => {
-			editorElement.remove();
-
-			return editor.destroy();
-		} );
-		it( 'should register imageUpload file dialog button', () => {
-			const button = editor.ui.componentFactory.create( 'imageUpload' );
-
-			expect( button ).to.be.instanceOf( FileDialogButtonView );
-		} );
-
-		it( 'should set proper accepted mime-types for imageUpload button as defined in configuration', () => {
-			editor.config.set( 'image.upload.types', [ 'svg+xml', 'jpeg', 'vnd.microsoft.icon', 'x-xbitmap' ] );
-
-			const button = editor.ui.componentFactory.create( 'imageUpload' );
-
-			expect( button.acceptedType ).to.equal( 'image/svg+xml,image/jpeg,image/vnd.microsoft.icon,image/x-xbitmap' );
-		} );
-
-		it( 'should be disabled while ImageUploadCommand is disabled', () => {
-			const button = editor.ui.componentFactory.create( 'imageUpload' );
-			const command = editor.commands.get( 'imageUpload' );
-
-			command.isEnabled = true;
-
-			expect( button.buttonView.isEnabled ).to.true;
-
-			command.isEnabled = false;
-
-			expect( button.buttonView.isEnabled ).to.false;
-		} );
-
-		// ckeditor5-upload/#77
-		it( 'should be properly bound with ImageUploadCommand', () => {
-			const button = editor.ui.componentFactory.create( 'imageUpload' );
-			const command = editor.commands.get( 'imageUpload' );
-			const spy = sinon.spy();
-
-			button.render();
-
-			button.buttonView.on( 'execute', spy );
-
-			command.isEnabled = false;
-
-			button.buttonView.element.dispatchEvent( new Event( 'click' ) );
-
-			sinon.assert.notCalled( spy );
-		} );
-
-		it( 'should execute imageUpload command', () => {
-			const executeStub = sinon.stub( editor, 'execute' );
-			const button = editor.ui.componentFactory.create( 'imageUpload' );
-			const files = [ createNativeFileMock() ];
-
-			button.fire( 'done', files );
-			sinon.assert.calledOnce( executeStub );
-			expect( executeStub.firstCall.args[ 0 ] ).to.equal( 'imageUpload' );
-			expect( executeStub.firstCall.args[ 1 ].file ).to.deep.equal( files );
-		} );
-
-		it( 'should execute imageUpload command with multiple files', () => {
-			const executeStub = sinon.stub( editor, 'execute' );
-			const button = editor.ui.componentFactory.create( 'imageUpload' );
-			const files = [ createNativeFileMock(), createNativeFileMock(), createNativeFileMock() ];
-
-			button.fire( 'done', files );
-			sinon.assert.calledOnce( executeStub );
-			expect( executeStub.firstCall.args[ 0 ] ).to.equal( 'imageUpload' );
-			expect( executeStub.firstCall.args[ 1 ].file ).to.deep.equal( files );
-		} );
-
-		it( 'should optimize the insertion position', () => {
-			const button = editor.ui.componentFactory.create( 'imageUpload' );
-			const files = [ createNativeFileMock() ];
-
-			setModelData( model, '<paragraph>f[]oo</paragraph>' );
-
-			button.fire( 'done', files );
-
-			const id = fileRepository.getLoader( files[ 0 ] ).id;
-
-			expect( getModelData( model ) ).to.equal(
-				`[<image uploadId="${ id }" uploadStatus="reading"></image>]` +
-			'<paragraph>foo</paragraph>'
-			);
-		} );
-
-		it( 'should correctly insert multiple files', () => {
-			const button = editor.ui.componentFactory.create( 'imageUpload' );
-			const files = [ createNativeFileMock(), createNativeFileMock() ];
-
-			setModelData( model, '<paragraph>foo[]</paragraph><paragraph>bar</paragraph>' );
-
-			button.fire( 'done', files );
-
-			const id1 = fileRepository.getLoader( files[ 0 ] ).id;
-			const id2 = fileRepository.getLoader( files[ 1 ] ).id;
-
-			expect( getModelData( model ) ).to.equal(
-				'<paragraph>foo</paragraph>' +
-			`<image uploadId="${ id1 }" uploadStatus="reading"></image>` +
-			`[<image uploadId="${ id2 }" uploadStatus="reading"></image>]` +
-			'<paragraph>bar</paragraph>'
-			);
-		} );
-
-		it( 'should not execute imageUpload if the file is not an image', () => {
-			const executeStub = sinon.stub( editor, 'execute' );
-			const button = editor.ui.componentFactory.create( 'imageUpload' );
-			const file = {
-				type: 'media/mp3',
-				size: 1024
-			};
-
-			button.fire( 'done', [ file ] );
-			sinon.assert.notCalled( executeStub );
-		} );
-
-		it( 'should work even if the FileList does not support iterators', () => {
-			const executeStub = sinon.stub( editor, 'execute' );
-			const button = editor.ui.componentFactory.create( 'imageUpload' );
-			const files = {
-				0: createNativeFileMock(),
-				length: 1
-			};
-
-			button.fire( 'done', files );
-			sinon.assert.calledOnce( executeStub );
-			expect( executeStub.firstCall.args[ 0 ] ).to.equal( 'imageUpload' );
-			expect( executeStub.firstCall.args[ 1 ].file ).to.deep.equal( [ files[ 0 ] ] );
-		} );
-
-		it( 'should add the new image after the selected one, without replacing the selected image', () => {
-			const button = editor.ui.componentFactory.create( 'imageUpload' );
-			const files = [ createNativeFileMock() ];
-
-			setModelData( model, '[<image src="/assets/sample.png"></image>]<paragraph>bar</paragraph>' );
-
-			button.fire( 'done', files );
-
-			const id1 = fileRepository.getLoader( files[ 0 ] ).id;
-
-			expect( getModelData( model ) ).to.equal(
-				'<image src="/assets/sample.png"></image>' +
-				`[<image uploadId="${ id1 }" uploadStatus="reading"></image>]` +
-				'<paragraph>bar</paragraph>'
-			);
-		} );
+	beforeEach( () => {
+		editorElement = document.createElement( 'div' );
+		document.body.appendChild( editorElement );
+
+		return ClassicEditor
+			.create( editorElement, {
+				plugins: [ Paragraph, Image, ImageUploadEditing, ImageUploadUI, FileRepository, UploadAdapterPluginMock, Clipboard ]
+			} )
+			.then( newEditor => {
+				editor = newEditor;
+				model = editor.model;
+
+				// Hide all notifications (prevent alert() calls).
+				const notification = editor.plugins.get( Notification );
+				notification.on( 'show', evt => evt.stop() );
+			} );
 	} );
 
-	describe( 'dropdown', () => {
-		beforeEach( () => {
-			editorElement = document.createElement( 'div' );
-			document.body.appendChild( editorElement );
-
-			return ClassicEditor
-				.create( editorElement, {
-					plugins: [ Paragraph, Image, ImageUploadEditing, ImageUploadUI, FileRepository, UploadAdapterPluginMock, Clipboard ],
-					image: {
-						upload: {
-							panel: {
-								items: [
-									'insertImageViaUrl'
-								]
-							}
-						}
-					}
-				} )
-				.then( newEditor => {
-					editor = newEditor;
-					model = editor.model;
+	afterEach( () => {
+		editorElement.remove();
 
-					// Hide all notifications (prevent alert() calls).
-					const notification = editor.plugins.get( Notification );
-					notification.on( 'show', evt => evt.stop() );
-				} );
-		} );
-
-		afterEach( () => {
-			editorElement.remove();
+		return editor.destroy();
+	} );
 
-			return editor.destroy();
-		} );
-		it( 'should register imageUpload dropdown', () => {
-			const button = editor.ui.componentFactory.create( 'imageUpload' );
+	it( 'should register imageUpload button', () => {
+		const button = editor.ui.componentFactory.create( 'imageUpload' );
 
-			expect( button ).to.be.instanceOf( DropdownView );
-		} );
+		expect( button ).to.be.instanceOf( FileDialogButtonView );
+	} );
 
-		it( 'should set proper accepted mime-types for imageUpload button as defined in configuration', () => {
-			editor.config.set( 'image.upload.types', [ 'svg+xml', 'jpeg', 'vnd.microsoft.icon', 'x-xbitmap' ] );
+	it( 'should set proper accepted mime-types for imageUpload button as defined in configuration', () => {
+		editor.config.set( 'image.upload.types', [ 'svg+xml', 'jpeg', 'vnd.microsoft.icon', 'x-xbitmap' ] );
 
-			const plugin = editor.plugins.get( 'ImageUploadUI' );
-			const fileDialogButton = plugin._createFileDialogButtonView( editor.locale );
+		const button = editor.ui.componentFactory.create( 'imageUpload' );
 
-			expect( fileDialogButton.acceptedType ).to.equal( 'image/svg+xml,image/jpeg,image/vnd.microsoft.icon,image/x-xbitmap' );
-		} );
+		expect( button.acceptedType ).to.equal( 'image/svg+xml,image/jpeg,image/vnd.microsoft.icon,image/x-xbitmap' );
+	} );
 
-		it( 'should be disabled while ImageUploadCommand is disabled', () => {
-			const button = editor.ui.componentFactory.create( 'imageUpload' );
-			const command = editor.commands.get( 'imageUpload' );
+	it( 'should be disabled while ImageUploadCommand is disabled', () => {
+		const button = editor.ui.componentFactory.create( 'imageUpload' );
+		const command = editor.commands.get( 'imageUpload' );
 
-			command.isEnabled = true;
+		command.isEnabled = true;
 
-			expect( button.buttonView.isEnabled ).to.true;
+		expect( button.buttonView.isEnabled ).to.true;
 
-			command.isEnabled = false;
+		command.isEnabled = false;
 
-			expect( button.buttonView.isEnabled ).to.false;
-		} );
+		expect( button.buttonView.isEnabled ).to.false;
+	} );
 
-		// ckeditor5-upload/#77
-		it( 'should be properly bound with ImageUploadCommand', () => {
-			const dropdown = editor.ui.componentFactory.create( 'imageUpload' );
-			const command = editor.commands.get( 'imageUpload' );
-			const spy = sinon.spy();
+	// ckeditor5-upload/#77
+	it( 'should be properly bound with ImageUploadCommand', () => {
+		const button = editor.ui.componentFactory.create( 'imageUpload' );
+		const command = editor.commands.get( 'imageUpload' );
+		const spy = sinon.spy();
 
-			dropdown.render();
+		button.render();
 
-			dropdown.buttonView.on( 'execute', spy );
+		button.buttonView.on( 'execute', spy );
 
-			command.isEnabled = false;
+		command.isEnabled = false;
 
-			dropdown.buttonView.element.dispatchEvent( new Event( 'click' ) );
+		button.buttonView.element.dispatchEvent( new Event( 'click' ) );
 
-			sinon.assert.notCalled( spy );
-		} );
+		sinon.assert.notCalled( spy );
+	} );
 
-		it( 'should execute imageUpload command', () => {
-			const executeStub = sinon.stub( editor, 'execute' );
-			const plugin = editor.plugins.get( 'ImageUploadUI' );
-			const fileDialogButton = plugin._createFileDialogButtonView( editor.locale );
-			const files = [ createNativeFileMock() ];
+	it( 'should execute imageUpload command', () => {
+		const executeStub = sinon.stub( editor, 'execute' );
+		const button = editor.ui.componentFactory.create( 'imageUpload' );
+		const files = [ createNativeFileMock() ];
 
-			fileDialogButton.fire( 'done', files );
-			sinon.assert.calledOnce( executeStub );
-			expect( executeStub.firstCall.args[ 0 ] ).to.equal( 'imageUpload' );
-			expect( executeStub.firstCall.args[ 1 ].file ).to.deep.equal( files );
-		} );
+		button.fire( 'done', files );
+		sinon.assert.calledOnce( executeStub );
+		expect( executeStub.firstCall.args[ 0 ] ).to.equal( 'imageUpload' );
+		expect( executeStub.firstCall.args[ 1 ].file ).to.deep.equal( files );
+	} );
 
-		it( 'should execute imageUpload command with multiple files', () => {
-			const executeStub = sinon.stub( editor, 'execute' );
-			const plugin = editor.plugins.get( 'ImageUploadUI' );
-			const fileDialogButton = plugin._createFileDialogButtonView( editor.locale );
-			const files = [ createNativeFileMock(), createNativeFileMock(), createNativeFileMock() ];
+	it( 'should execute imageUpload command with multiple files', () => {
+		const executeStub = sinon.stub( editor, 'execute' );
+		const button = editor.ui.componentFactory.create( 'imageUpload' );
+		const files = [ createNativeFileMock(), createNativeFileMock(), createNativeFileMock() ];
 
-			fileDialogButton.fire( 'done', files );
-			sinon.assert.calledOnce( executeStub );
-			expect( executeStub.firstCall.args[ 0 ] ).to.equal( 'imageUpload' );
-			expect( executeStub.firstCall.args[ 1 ].file ).to.deep.equal( files );
-		} );
+		button.fire( 'done', files );
+		sinon.assert.calledOnce( executeStub );
+		expect( executeStub.firstCall.args[ 0 ] ).to.equal( 'imageUpload' );
+		expect( executeStub.firstCall.args[ 1 ].file ).to.deep.equal( files );
+	} );
 
-		it( 'should optimize the insertion position', () => {
-			const plugin = editor.plugins.get( 'ImageUploadUI' );
-			const fileDialogButton = plugin._createFileDialogButtonView( editor.locale );
-			const files = [ createNativeFileMock() ];
+	it( 'should optimize the insertion position', () => {
+		const button = editor.ui.componentFactory.create( 'imageUpload' );
+		const files = [ createNativeFileMock() ];
 
-			setModelData( model, '<paragraph>f[]oo</paragraph>' );
+		setModelData( model, '<paragraph>f[]oo</paragraph>' );
 
-			fileDialogButton.fire( 'done', files );
+		button.fire( 'done', files );
 
-			const id = fileRepository.getLoader( files[ 0 ] ).id;
+		const id = fileRepository.getLoader( files[ 0 ] ).id;
 
-			expect( getModelData( model ) ).to.equal(
-				`[<image uploadId="${ id }" uploadStatus="reading"></image>]` +
+		expect( getModelData( model ) ).to.equal(
+			`[<image uploadId="${ id }" uploadStatus="reading"></image>]` +
 			'<paragraph>foo</paragraph>'
-			);
-		} );
+		);
+	} );
 
-		it( 'should correctly insert multiple files', () => {
-			const plugin = editor.plugins.get( 'ImageUploadUI' );
-			const fileDialogButton = plugin._createFileDialogButtonView( editor.locale );
-			const files = [ createNativeFileMock(), createNativeFileMock() ];
+	it( 'should correctly insert multiple files', () => {
+		const button = editor.ui.componentFactory.create( 'imageUpload' );
+		const files = [ createNativeFileMock(), createNativeFileMock() ];
 
-			setModelData( model, '<paragraph>foo[]</paragraph><paragraph>bar</paragraph>' );
+		setModelData( model, '<paragraph>foo[]</paragraph><paragraph>bar</paragraph>' );
 
-			fileDialogButton.fire( 'done', files );
+		button.fire( 'done', files );
 
-			const id1 = fileRepository.getLoader( files[ 0 ] ).id;
-			const id2 = fileRepository.getLoader( files[ 1 ] ).id;
+		const id1 = fileRepository.getLoader( files[ 0 ] ).id;
+		const id2 = fileRepository.getLoader( files[ 1 ] ).id;
 
-			expect( getModelData( model ) ).to.equal(
-				'<paragraph>foo</paragraph>' +
+		expect( getModelData( model ) ).to.equal(
+			'<paragraph>foo</paragraph>' +
 			`<image uploadId="${ id1 }" uploadStatus="reading"></image>` +
 			`[<image uploadId="${ id2 }" uploadStatus="reading"></image>]` +
 			'<paragraph>bar</paragraph>'
-			);
-		} );
-
-		it( 'should not execute imageUpload if the file is not an image', () => {
-			const executeStub = sinon.stub( editor, 'execute' );
-			const plugin = editor.plugins.get( 'ImageUploadUI' );
-			const fileDialogButton = plugin._createFileDialogButtonView( editor.locale );
-			const file = {
-				type: 'media/mp3',
-				size: 1024
-			};
-
-			fileDialogButton.fire( 'done', [ file ] );
-			sinon.assert.notCalled( executeStub );
-		} );
-
-		it( 'should work even if the FileList does not support iterators', () => {
-			const executeStub = sinon.stub( editor, 'execute' );
-			const plugin = editor.plugins.get( 'ImageUploadUI' );
-			const fileDialogButton = plugin._createFileDialogButtonView( editor.locale );
-			const files = {
-				0: createNativeFileMock(),
-				length: 1
-			};
-
-			fileDialogButton.fire( 'done', files );
-			sinon.assert.calledOnce( executeStub );
-			expect( executeStub.firstCall.args[ 0 ] ).to.equal( 'imageUpload' );
-			expect( executeStub.firstCall.args[ 1 ].file ).to.deep.equal( [ files[ 0 ] ] );
-		} );
-
-		describe( 'dropdown action button', () => {
-			it( 'should be an instance of FileDialogButtonView', () => {
-				const dropdown = editor.ui.componentFactory.create( 'imageUpload' );
-
-				expect( dropdown.buttonView.actionView ).to.be.instanceOf( FileDialogButtonView );
-			} );
-		} );
-
-		describe( 'dropdown panel buttons', () => {
-			it( 'should have "Update" label on submit button when URL input is already filled', () => {
-				const dropdown = editor.ui.componentFactory.create( 'imageUpload' );
-				const viewDocument = editor.editing.view.document;
-
-				editor.setData( '<figure><img src="/assets/sample.png" /></figure>' );
-
-				editor.editing.view.change( writer => {
-					writer.setSelection( viewDocument.getRoot().getChild( 0 ), 'on' );
-				} );
-
-				const img = viewDocument.selection.getSelectedElement();
-
-				const data = fakeEventData();
-				const eventInfo = new EventInfo( img, 'click' );
-				const domEventDataMock = new DomEventData( viewDocument, eventInfo, data );
-
-				viewDocument.fire( 'click', domEventDataMock );
-
-				dropdown.isOpen = true;
-
-				const inputValue = dropdown.panelView.children.first.imageURLInputValue;
-
-				expect( dropdown.isOpen ).to.be.true;
-				expect( inputValue ).to.equal( '/assets/sample.png' );
-				expect( dropdown.panelView.children.first.insertButtonView.label ).to.equal( 'Update' );
-			} );
-
-			it( 'should have "Insert" label on submit button on uploading a new image', () => {
-				const dropdown = editor.ui.componentFactory.create( 'imageUpload' );
-				const viewDocument = editor.editing.view.document;
-
-				editor.setData( '<p>test</p>' );
-
-				editor.editing.view.change( writer => {
-					writer.setSelection( viewDocument.getRoot().getChild( 0 ), 'end' );
-				} );
-
-				const el = viewDocument.selection.getSelectedElement();
-
-				const data = fakeEventData();
-				const eventInfo = new EventInfo( el, 'click' );
-				const domEventDataMock = new DomEventData( viewDocument, eventInfo, data );
-
-				viewDocument.fire( 'click', domEventDataMock );
-
-				dropdown.isOpen = true;
-
-				const inputValue = dropdown.panelView.children.first.imageURLInputValue;
-
-				expect( dropdown.isOpen ).to.be.true;
-				expect( inputValue ).to.equal( '' );
-				expect( dropdown.panelView.children.first.insertButtonView.label ).to.equal( 'Insert' );
-			} );
-		} );
-
-		describe( 'dropdown panel integrations', () => {
-			describe( 'insert image via URL form', () => {
-				it( 'should have "Insert image via URL" label on inserting new image', () => {
-					const dropdown = editor.ui.componentFactory.create( 'imageUpload' );
-					const viewDocument = editor.editing.view.document;
-
-					editor.setData( '<p>test</p>' );
-
-					editor.editing.view.change( writer => {
-						writer.setSelection( viewDocument.getRoot().getChild( 0 ), 'end' );
-					} );
-
-					const el = viewDocument.selection.getSelectedElement();
-
-					const data = fakeEventData();
-					const eventInfo = new EventInfo( el, 'click' );
-					const domEventDataMock = new DomEventData( viewDocument, eventInfo, data );
-
-					viewDocument.fire( 'click', domEventDataMock );
-
-					dropdown.isOpen = true;
-
-					const inputValue = dropdown.panelView.children.first.imageURLInputValue;
-
-					const insertImageViaUrlForm = dropdown.panelView.children.first.getIntegration( 'insertImageViaUrl' );
-
-					expect( dropdown.isOpen ).to.be.true;
-					expect( inputValue ).to.equal( '' );
-					expect( insertImageViaUrlForm.label ).to.equal( 'Insert image via URL' );
-				} );
-
-				it( 'should have "Update image URL" label on updating the image source URL', () => {
-					const dropdown = editor.ui.componentFactory.create( 'imageUpload' );
-					const viewDocument = editor.editing.view.document;
-
-					editor.setData( '<figure><img src="/assets/sample.png" /></figure>' );
-
-					editor.editing.view.change( writer => {
-						writer.setSelection( viewDocument.getRoot().getChild( 0 ), 'on' );
-					} );
-
-					const el = viewDocument.selection.getSelectedElement();
-
-					const data = fakeEventData();
-					const eventInfo = new EventInfo( el, 'click' );
-					const domEventDataMock = new DomEventData( viewDocument, eventInfo, data );
-
-					viewDocument.fire( 'click', domEventDataMock );
-
-					dropdown.isOpen = true;
-
-					const inputValue = dropdown.panelView.children.first.imageURLInputValue;
-					const insertImageViaUrlForm = dropdown.panelView.children.first.getIntegration( 'insertImageViaUrl' );
-
-					expect( dropdown.isOpen ).to.be.true;
-					expect( inputValue ).to.equal( '/assets/sample.png' );
-					expect( insertImageViaUrlForm.label ).to.equal( 'Update image URL' );
-				} );
-			} );
-		} );
-
-		it( 'should remove all attributes from model except "src" when updating the image source URL', () => {
-			const viewDocument = editor.editing.view.document;
-			const dropdown = editor.ui.componentFactory.create( 'imageUpload' );
-			const insertButtonView = dropdown.panelView.children.first.insertButtonView;
-			const commandSpy = sinon.spy( editor.commands.get( 'imageInsert' ), 'execute' );
-			const submitSpy = sinon.spy();
-
-			dropdown.isOpen = true;
-
-			editor.setData( '<figure><img src="image-url-800w.jpg"' +
-			'srcset="image-url-480w.jpg 480w,image-url-800w.jpg 800w"' +
-			'sizes="(max-width: 600px) 480px,800px"' +
-			'alt="test-image"></figure>' );
-
-			editor.editing.view.change( writer => {
-				writer.setSelection( viewDocument.getRoot().getChild( 0 ), 'on' );
-			} );
-
-			const selectedElement = editor.model.document.selection.getSelectedElement();
-
-			expect( selectedElement.getAttribute( 'src' ) ).to.equal( 'image-url-800w.jpg' );
-			expect( selectedElement.hasAttribute( 'srcset' ) ).to.be.true;
-
-			dropdown.panelView.children.first.imageURLInputValue = '/assets/sample3.png';
-
-			dropdown.on( 'submit', submitSpy );
-
-			insertButtonView.fire( 'execute' );
-
-			sinon.assert.notCalled( commandSpy );
-			sinon.assert.calledOnce( submitSpy );
-			expect( dropdown.isOpen ).to.be.false;
-			expect( selectedElement.getAttribute( 'src' ) ).to.equal( '/assets/sample3.png' );
-			expect( selectedElement.hasAttribute( 'srcset' ) ).to.be.false;
-			expect( selectedElement.hasAttribute( 'sizes' ) ).to.be.false;
-		} );
-
-		describe( 'events', () => {
-			it( 'should emit "submit" event when clicking on submit button', () => {
-				const dropdown = editor.ui.componentFactory.create( 'imageUpload' );
-				const insertButtonView = dropdown.panelView.children.first.insertButtonView;
-				const commandSpy = sinon.spy( editor.commands.get( 'imageInsert' ), 'execute' );
-				const submitSpy = sinon.spy();
-
-				dropdown.isOpen = true;
-
-				dropdown.on( 'submit', submitSpy );
-
-				insertButtonView.fire( 'execute' );
-
-				expect( dropdown.isOpen ).to.be.false;
-				sinon.assert.calledOnce( commandSpy );
-				sinon.assert.calledOnce( submitSpy );
-			} );
-
-			it( 'should emit "cancel" event when clicking on cancel button', () => {
-				const dropdown = editor.ui.componentFactory.create( 'imageUpload' );
-				const cancelButtonView = dropdown.panelView.children.first.cancelButtonView;
-				const commandSpy = sinon.spy( editor.commands.get( 'imageInsert' ), 'execute' );
-				const cancelSpy = sinon.spy();
-
-				dropdown.isOpen = true;
+		);
+	} );
 
-				dropdown.on( 'cancel', cancelSpy );
+	it( 'should not execute imageUpload if the file is not an image', () => {
+		const executeStub = sinon.stub( editor, 'execute' );
+		const button = editor.ui.componentFactory.create( 'imageUpload' );
+		const file = {
+			type: 'media/mp3',
+			size: 1024
+		};
 
-				cancelButtonView.fire( 'execute' );
+		button.fire( 'done', [ file ] );
+		sinon.assert.notCalled( executeStub );
+	} );
 
-				expect( dropdown.isOpen ).to.be.false;
-				sinon.assert.notCalled( commandSpy );
-				sinon.assert.calledOnce( cancelSpy );
-			} );
-		} );
-
-		it( 'should inject integrations to the dropdown panel view from the config', async () => {
-			const editor = await ClassicEditor
-				.create( editorElement, {
-					plugins: [
-						CKFinder,
-						Paragraph,
-						Image,
-						ImageUploadEditing,
-						ImageUploadUI,
-						FileRepository,
-						UploadAdapterPluginMock,
-						Clipboard
-					],
-					image: {
-						upload: {
-							panel: {
-								items: [
-									'insertImageViaUrl',
-									'openCKFinder'
-								]
-							}
-						}
-					}
-				} );
-
-			const dropdown = editor.ui.componentFactory.create( 'imageUpload' );
-
-			expect( dropdown.panelView.children.first._integrations.length ).to.equal( 2 );
-			expect( dropdown.panelView.children.first._integrations.first ).to.be.instanceOf( LabeledFieldView );
-			expect( dropdown.panelView.children.first._integrations.last ).to.be.instanceOf( ButtonView );
-
-			editor.destroy();
-		} );
+	it( 'should work even if the FileList does not support iterators', () => {
+		const executeStub = sinon.stub( editor, 'execute' );
+		const button = editor.ui.componentFactory.create( 'imageUpload' );
+		const files = {
+			0: createNativeFileMock(),
+			length: 1
+		};
+
+		button.fire( 'done', files );
+		sinon.assert.calledOnce( executeStub );
+		expect( executeStub.firstCall.args[ 0 ] ).to.equal( 'imageUpload' );
+		expect( executeStub.firstCall.args[ 1 ].file ).to.deep.equal( [ files[ 0 ] ] );
 	} );
 } );
-
-function fakeEventData() {
-	return {
-		preventDefault: sinon.spy()
-	};
-}

+ 1 - 146
packages/ckeditor5-image/tests/imageupload/utils.js

@@ -3,18 +3,7 @@
  * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
  */
 
-/* globals document */
-
-import ClassicEditor from '@ckeditor/ckeditor5-editor-classic/src/classiceditor';
-
-import ButtonView from '@ckeditor/ckeditor5-ui/src/button/buttonview';
-import Image from '../../src/image';
-import ImageUploadUI from '../../src/imageupload/imageuploadui';
-import ImageUploadEditing from '../../src/imageupload/imageuploadediting';
-import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
-import Link from '@ckeditor/ckeditor5-link/src/link';
-import CKFinder from '@ckeditor/ckeditor5-ckfinder/src/ckfinder';
-import { createImageTypeRegExp, prepareIntegrations, createLabeledInputView } from '../../src/imageupload/utils';
+import { createImageTypeRegExp } from '../../src/imageupload/utils';
 
 describe( 'Upload utils', () => {
 	describe( 'createImageTypeRegExp()', () => {
@@ -42,138 +31,4 @@ describe( 'Upload utils', () => {
 			expect( createImageTypeRegExp( [ 'png' ] ).test( 'svg+xml' ) ).to.be.false;
 		} );
 	} );
-
-	describe( 'prepareIntegrations()', () => {
-		it( 'should return "insetImageViaUrl" and "openCKFinder" integrations', async () => {
-			const editorElement = document.createElement( 'div' );
-			document.body.appendChild( editorElement );
-
-			const editor = await ClassicEditor
-				.create( editorElement, {
-					plugins: [
-						CKFinder,
-						Paragraph,
-						Image,
-						ImageUploadEditing,
-						ImageUploadUI
-					],
-					image: {
-						upload: {
-							panel: {
-								items: [
-									'insertImageViaUrl',
-									'openCKFinder'
-								]
-							}
-						}
-					}
-				} );
-
-			const openCKFinderExtendedView = Object.values( prepareIntegrations( editor ) )[ 1 ];
-
-			expect( openCKFinderExtendedView.class ).contains( 'ck-image-upload__ck-finder-button' );
-			expect( openCKFinderExtendedView.label ).to.equal( 'Insert image or file' );
-			expect( openCKFinderExtendedView.withText ).to.be.true;
-
-			editor.destroy();
-			editorElement.remove();
-		} );
-
-		it( 'should return only "insertImageViaUrl" integration and throw warning' +
-			'for "image-upload-integrations-invalid-view" error', async () => {
-			const editorElement = document.createElement( 'div' );
-			document.body.appendChild( editorElement );
-
-			const editor = await ClassicEditor
-				.create( editorElement, {
-					plugins: [
-						Paragraph,
-						Image,
-						ImageUploadEditing,
-						ImageUploadUI
-					],
-					image: {
-						upload: {
-							panel: {
-								items: [
-									'insertImageViaUrl',
-									'openCKFinder'
-								]
-							}
-						}
-					}
-				} );
-
-			expect( Object.values( prepareIntegrations( editor ) ).length ).to.equal( 1 );
-
-			editor.destroy();
-			editorElement.remove();
-		} );
-
-		it( 'should return only "link" integration', async () => {
-			const editorElement = document.createElement( 'div' );
-			document.body.appendChild( editorElement );
-
-			const editor = await ClassicEditor
-				.create( editorElement, {
-					plugins: [
-						Paragraph,
-						Link,
-						Image,
-						ImageUploadEditing,
-						ImageUploadUI
-					],
-					image: {
-						upload: {
-							panel: {
-								items: [
-									'link'
-								]
-							}
-						}
-					}
-				} );
-
-			expect( Object.values( prepareIntegrations( editor ) ).length ).to.equal( 1 );
-			expect( Object.values( prepareIntegrations( editor ) )[ 0 ].label ).to.equal( 'Link' );
-			expect( Object.values( prepareIntegrations( editor ) )[ 0 ] ).to.be.instanceOf( ButtonView );
-
-			editor.destroy();
-			editorElement.remove();
-		} );
-
-		it( 'should return "insertImageViaUrl" integration, when no integrations were configured', async () => {
-			const editorElement = document.createElement( 'div' );
-			document.body.appendChild( editorElement );
-
-			const editor = await ClassicEditor
-				.create( editorElement, {
-					plugins: [
-						Paragraph,
-						Image,
-						ImageUploadEditing,
-						ImageUploadUI
-					]
-				} );
-
-			expect( Object.keys( prepareIntegrations( editor ) ).length ).to.equal( 1 );
-
-			editor.destroy();
-			editorElement.remove();
-		} );
-	} );
-
-	describe( 'createLabeledInputView()', () => {
-		describe( 'image URL input view', () => {
-			it( 'should have placeholder', () => {
-				const view = createLabeledInputView( { t: val => val } );
-				expect( view.fieldView.placeholder ).to.equal( 'https://example.com/src/image.png' );
-			} );
-
-			it( 'should have info text', () => {
-				const view = createLabeledInputView( { t: val => val } );
-				expect( view.infoText ).to.match( /^Paste the image source URL/ );
-			} );
-		} );
-	} );
 } );

+ 1 - 1
packages/ckeditor5-image/tests/manual/imageuploadviaurl.html → packages/ckeditor5-image/tests/manual/imageinsertviaurl.html

@@ -10,7 +10,7 @@
 	}
 </style>
 
-<div id="editor2">
+<div id="editor">
 	<h2>Image upload via URL with CKFinder integration</h2>
 	<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nulla finibus consequat placerat. Vestibulum id tellus et mauris sagittis tincidunt quis id mauris. Curabitur consectetur lectus sit amet tellus mattis, non lobortis leo interdum.</p>
 </div>

+ 9 - 20
packages/ckeditor5-image/tests/manual/imageuploadviaurl.js → packages/ckeditor5-image/tests/manual/imageinsertviaurl.js

@@ -7,14 +7,12 @@
 
 import ClassicEditor from '@ckeditor/ckeditor5-editor-classic/src/classiceditor';
 import ArticlePluginSet from '@ckeditor/ckeditor5-core/tests/_utils/articlepluginset';
-import ImageUpload from '../../src/imageupload';
+import ImageInsert from '../../src/imageinsert';
 import CKFinder from '@ckeditor/ckeditor5-ckfinder/src/ckfinder';
 
-import { UploadAdapterMock } from '@ckeditor/ckeditor5-upload/tests/_utils/mocks';
-
 ClassicEditor
-	.create( document.querySelector( '#editor2' ), {
-		plugins: [ ArticlePluginSet, ImageUpload, CKFinder ],
+	.create( document.querySelector( '#editor' ), {
+		plugins: [ ArticlePluginSet, ImageInsert, CKFinder ],
 		toolbar: [
 			'heading',
 			'|',
@@ -24,7 +22,7 @@ ClassicEditor
 			'bulletedList',
 			'numberedList',
 			'blockQuote',
-			'imageUpload',
+			'imageInsert',
 			'insertTable',
 			'mediaEmbed',
 			'undo',
@@ -32,13 +30,11 @@ ClassicEditor
 		],
 		image: {
 			toolbar: [ 'imageStyle:full', 'imageStyle:side', '|', 'imageTextAlternative' ],
-			upload: {
-				panel: {
-					items: [
-						'insertImageViaUrl',
-						'openCKFinder'
-					]
-				}
+			insert: {
+				integrations: [
+					'insertImageViaUrl',
+					'openCKFinder'
+				]
 			}
 		},
 		ckfinder: {
@@ -48,13 +44,6 @@ ClassicEditor
 	} )
 	.then( editor => {
 		window.editor = editor;
-
-		// Register fake adapter.
-		editor.plugins.get( 'FileRepository' ).createUploadAdapter = loader => {
-			const adapterMock = new UploadAdapterMock( loader );
-
-			return adapterMock;
-		};
 	} )
 	.catch( err => {
 		console.error( err.stack );

+ 18 - 0
packages/ckeditor5-image/tests/manual/imageinsertviaurl.md

@@ -0,0 +1,18 @@
+## Image insert via URL
+
+1. Click on the arrow button in `imageInsert` plugin to reveal the image insert panel.
+1. Paste the URL to the input (eg: `https://ckeditor.com/docs/ckeditor5/latest/assets/img/malta.jpg`).
+1. Click `Insert` button.
+
+## Image replace via URL
+
+1. Click on the image in the editor.
+1. Click on the arrow button in `imageInsert` plugin to reveal the image insert panel.
+1. Edit the value of the input.
+1. Click `Update` button.
+
+## Image insert via integrations
+
+1. In the **Editor 2** click on the arrow button in `imageInsert` plugin to reveal the image insert panel.
+1. Click on the **CKFinder** button.
+1. Choose image and confirm.

+ 0 - 18
packages/ckeditor5-image/tests/manual/imageuploadviaurl.md

@@ -1,18 +0,0 @@
-## Image upload via URL
-
-1. Click on the arrow button in `imageUpload` plugin to reveal the image upload panel.
-1. Paste the URL to the input (eg: `https://ckeditor.com/docs/ckeditor5/latest/assets/img/malta.jpg`).
-1. Click `Insert` button.
-
-## Image replace via URL
-
-1. Click on the image in the editor.
-1. Click on the arrow button in `imageUpload` plugin to reveal the image upload panel.
-1. Edit the value of the input.
-1. Click `Update` button.
-
-## Image upload via integrations
-
-1. In the **Editor 2** click on the arrow button in `imageUpload` plugin to reveal the image upload panel.
-1. Click on the **CKFinder** button.
-1. Choose image and confirm.

+ 9 - 2
packages/ckeditor5-image/theme/imageupload.css → packages/ckeditor5-image/theme/imageinsert.css

@@ -3,14 +3,21 @@
  * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
  */
 
-.ck.ck-image-upload__panel {
+.ck.ck-image-insert__panel {
 	padding: var(--ck-spacing-standard);
 }
 
-.ck.ck-image-upload__ck-finder-button {
+.ck.ck-image-insert__ck-finder-button {
 	display: block;
 	width: 100%;
 	margin: var(--ck-spacing-standard) auto;
 	border: 1px solid hsl(0, 0%, 80%);
 	border-radius: var(--ck-border-radius);
 }
+
+/* https://github.com/ckeditor/ckeditor5/issues/7986 */
+.ck.ck-splitbutton > .ck-file-dialog-button.ck-button {
+	padding: 0;
+	margin: 0;
+	border: none;
+}

+ 1 - 1
packages/ckeditor5-image/theme/imageuploadformrowview.css → packages/ckeditor5-image/theme/imageinsertformrowview.css

@@ -14,7 +14,7 @@
 		flex-grow: 1;
 	}
 
-	&.ck-image-upload-form__action-row {
+	&.ck-image-insert-form__action-row {
 		margin-top: var(--ck-spacing-standard);
 
 		& .ck-button-save,

+ 2 - 1
packages/ckeditor5-link/src/link.js

@@ -10,6 +10,7 @@
 import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
 import LinkEditing from './linkediting';
 import LinkUI from './linkui';
+import AutoLink from './autolink';
 
 /**
  * The link plugin.
@@ -24,7 +25,7 @@ export default class Link extends Plugin {
 	 * @inheritDoc
 	 */
 	static get requires() {
-		return [ LinkEditing, LinkUI ];
+		return [ LinkEditing, LinkUI, AutoLink ];
 	}
 
 	/**

+ 43 - 8
packages/ckeditor5-link/src/linkui.js

@@ -662,16 +662,27 @@ export default class LinkUI extends Plugin {
 		const model = this.editor.model;
 
 		model.change( writer => {
+			const range = model.document.selection.getFirstRange();
+
 			if ( model.markers.has( VISUAL_SELECTION_MARKER_NAME ) ) {
-				writer.updateMarker( VISUAL_SELECTION_MARKER_NAME, {
-					range: model.document.selection.getFirstRange()
-				} );
+				writer.updateMarker( VISUAL_SELECTION_MARKER_NAME, { range } );
 			} else {
-				writer.addMarker( VISUAL_SELECTION_MARKER_NAME, {
-					usingOperation: false,
-					affectsData: false,
-					range: model.document.selection.getFirstRange()
-				} );
+				if ( range.start.isAtEnd ) {
+					const focus = model.document.selection.focus;
+					const nextValidRange = getNextValidRange( range, focus, writer );
+
+					writer.addMarker( VISUAL_SELECTION_MARKER_NAME, {
+						usingOperation: false,
+						affectsData: false,
+						range: nextValidRange
+					} );
+				} else {
+					writer.addMarker( VISUAL_SELECTION_MARKER_NAME, {
+						usingOperation: false,
+						affectsData: false,
+						range
+					} );
+				}
 			}
 		} );
 	}
@@ -700,3 +711,27 @@ export default class LinkUI extends Plugin {
 function findLinkElementAncestor( position ) {
 	return position.getAncestors().find( ancestor => isLinkElement( ancestor ) );
 }
+
+// Returns next valid range for the fake visual selection marker.
+//
+// @private
+// @param {module:engine/model/range~Range} range Current range.
+// @param {module:engine/model/position~Position} focus Selection focus.
+// @param {module:engine/model/writer~Writer} writer Writer.
+// @returns {module:engine/model/range~Range} New valid range for the fake visual selection marker.
+function getNextValidRange( range, focus, writer ) {
+	const nextStartPath = [ range.start.path[ 0 ] + 1, 0 ];
+	const nextStartPosition = writer.createPositionFromPath( range.start.root, nextStartPath, 'toNext' );
+	const nextRange = writer.createRange( nextStartPosition, range.end );
+
+	// Block creating a potential next valid range over the current range end.
+	if ( nextRange.start.path[ 0 ] > range.end.path[ 0 ] ) {
+		return writer.createRange( focus );
+	}
+
+	if ( nextStartPosition.isAtStart && nextStartPosition.isAtEnd ) {
+		return getNextValidRange( nextRange, focus, writer );
+	}
+
+	return nextRange;
+}

+ 3 - 2
packages/ckeditor5-link/tests/link.js

@@ -4,12 +4,13 @@
  */
 
 import Link from '../src/link';
+import AutoLink from '../src/autolink';
 import LinkEditing from '../src/linkediting';
 import LinkUI from '../src/linkui';
 
 describe( 'Link', () => {
-	it( 'should require LinkEditing and LinkUI', () => {
-		expect( Link.requires ).to.deep.equal( [ LinkEditing, LinkUI ] );
+	it( 'should require LinkEditing, LinkUI and AutoLink', () => {
+		expect( Link.requires ).to.deep.equal( [ LinkEditing, LinkUI, AutoLink ] );
 	} );
 
 	it( 'should be named', () => {

+ 212 - 29
packages/ckeditor5-link/tests/linkui.js

@@ -477,46 +477,229 @@ describe( 'LinkUI', () => {
 			} );
 		} );
 
-		it( 'should display a fake visual selection when a text fragment is selected', () => {
-			setModelData( editor.model, '<paragraph>f[o]o</paragraph>' );
+		describe( 'fake visual selection', () => {
+			describe( 'non-collapsed', () => {
+				it( 'should be displayed when a text fragment is selected', () => {
+					setModelData( editor.model, '<paragraph>f[o]o</paragraph>' );
 
-			linkUIFeature._showUI();
+					linkUIFeature._showUI();
 
-			expect( editor.model.markers.has( 'link-ui' ) ).to.be.true;
+					expect( editor.model.markers.has( 'link-ui' ) ).to.be.true;
 
-			const paragraph = editor.model.document.getRoot().getChild( 0 );
-			const expectedRange = editor.model.createRange(
-				editor.model.createPositionAt( paragraph, 1 ),
-				editor.model.createPositionAt( paragraph, 2 )
-			);
-			const markerRange = editor.model.markers.get( 'link-ui' ).getRange();
+					const paragraph = editor.model.document.getRoot().getChild( 0 );
+					const expectedRange = editor.model.createRange(
+						editor.model.createPositionAt( paragraph, 1 ),
+						editor.model.createPositionAt( paragraph, 2 )
+					);
+					const markerRange = editor.model.markers.get( 'link-ui' ).getRange();
 
-			expect( markerRange.isEqual( expectedRange ) ).to.be.true;
+					expect( markerRange.isEqual( expectedRange ) ).to.be.true;
 
-			expect( getViewData( editor.editing.view ) ).to.equal( '<p>f{<span class="ck-fake-link-selection">o</span>}o</p>' );
-			expect( editor.getData() ).to.equal( '<p>foo</p>' );
-		} );
+					expect( getViewData( editor.editing.view ) ).to.equal( '<p>f{<span class="ck-fake-link-selection">o</span>}o</p>' );
+					expect( editor.getData() ).to.equal( '<p>foo</p>' );
+				} );
 
-		it( 'should display a fake visual selection on a collapsed selection', () => {
-			setModelData( editor.model, '<paragraph>f[]o</paragraph>' );
+				it( 'should display a fake visual selection on the next non-empty text node when selection starts at the end ' +
+					'of the empty block in the multiline selection', () => {
+					setModelData( editor.model, '<paragraph>[</paragraph><paragraph>foo]</paragraph>' );
 
-			linkUIFeature._showUI();
+					linkUIFeature._showUI();
 
-			expect( editor.model.markers.has( 'link-ui' ) ).to.be.true;
+					expect( editor.model.markers.has( 'link-ui' ) ).to.be.true;
+
+					const secondParagraph = editor.model.document.getRoot().getChild( 1 );
+					const expectedRange = editor.model.createRange(
+						editor.model.createPositionAt( secondParagraph, 0 ),
+						editor.model.createPositionAt( secondParagraph, 3 )
+					);
+
+					const markerRange = editor.model.markers.get( 'link-ui' ).getRange();
+
+					expect( markerRange.isEqual( expectedRange ) ).to.be.true;
+
+					expect( getViewData( editor.editing.view ) ).to.equal(
+						'<p>[</p>' +
+						'<p><span class="ck-fake-link-selection">foo</span>]</p>'
+					);
+					expect( editor.getData() ).to.equal( '<p>&nbsp;</p><p>foo</p>' );
+				} );
+
+				it( 'should display a fake visual selection on the next non-empty text node when selection starts at the end ' +
+					'of the first block in the multiline selection', () => {
+					setModelData( editor.model, '<paragraph>foo[</paragraph><paragraph>bar]</paragraph>' );
+
+					linkUIFeature._showUI();
+
+					expect( editor.model.markers.has( 'link-ui' ) ).to.be.true;
 
-			const paragraph = editor.model.document.getRoot().getChild( 0 );
-			const expectedRange = editor.model.createRange(
-				editor.model.createPositionAt( paragraph, 1 ),
-				editor.model.createPositionAt( paragraph, 1 )
-			);
-			const markerRange = editor.model.markers.get( 'link-ui' ).getRange();
+					const secondParagraph = editor.model.document.getRoot().getChild( 1 );
+					const expectedRange = editor.model.createRange(
+						editor.model.createPositionAt( secondParagraph, 0 ),
+						editor.model.createPositionAt( secondParagraph, 3 )
+					);
+
+					const markerRange = editor.model.markers.get( 'link-ui' ).getRange();
+
+					expect( markerRange.isEqual( expectedRange ) ).to.be.true;
+
+					expect( getViewData( editor.editing.view ) ).to.equal(
+						'<p>foo{</p>' +
+						'<p><span class="ck-fake-link-selection">bar</span>]</p>'
+					);
+					expect( editor.getData() ).to.equal( '<p>foo</p><p>bar</p>' );
+				} );
+
+				it( 'should be displayed on first text node in non-empty element when selection contains few empty elements', () => {
+					setModelData( editor.model, '<paragraph>foo[</paragraph>' +
+						'<paragraph></paragraph>' +
+						'<paragraph></paragraph>' +
+						'<paragraph>bar</paragraph>' +
+						'<paragraph></paragraph>' +
+						'<paragraph></paragraph>' +
+						'<paragraph>]baz</paragraph>' );
+
+					linkUIFeature._showUI();
+
+					expect( editor.model.markers.has( 'link-ui' ) ).to.be.true;
 
-			expect( markerRange.isEqual( expectedRange ) ).to.be.true;
+					const firstNonEmptyElementInTheSelection = editor.model.document.getRoot().getChild( 3 );
+					const rangeEnd = editor.model.document.selection.getFirstRange().end;
+					const expectedRange = editor.model.createRange(
+						editor.model.createPositionAt( firstNonEmptyElementInTheSelection, 0 ),
+						editor.model.createPositionAt( rangeEnd, 0 )
+					);
+
+					const markerRange = editor.model.markers.get( 'link-ui' ).getRange();
+
+					expect( markerRange.isEqual( expectedRange ) ).to.be.true;
+
+					const expectedViewData = '<p>foo{</p>' +
+						'<p></p>' +
+						'<p></p>' +
+						'<p><span class="ck-fake-link-selection">bar</span></p>' +
+						'<p></p>' +
+						'<p></p>' +
+						'<p>}baz</p>';
+
+					expect( getViewData( editor.editing.view ) ).to.equal( expectedViewData );
+					expect( editor.getData() ).to.equal(
+						'<p>foo</p>' +
+						'<p>&nbsp;</p><p>&nbsp;</p>' +
+						'<p>bar</p>' +
+						'<p>&nbsp;</p><p>&nbsp;</p>' +
+						'<p>baz</p>'
+					);
+				} );
+			} );
 
-			expect( getViewData( editor.editing.view ) ).to.equal(
-				'<p>f{}<span class="ck-fake-link-selection ck-fake-link-selection_collapsed"></span>o</p>'
-			);
-			expect( editor.getData() ).to.equal( '<p>fo</p>' );
+			describe( 'collapsed', () => {
+				it( 'should be displayed on a collapsed selection', () => {
+					setModelData( editor.model, '<paragraph>f[]o</paragraph>' );
+
+					linkUIFeature._showUI();
+
+					expect( editor.model.markers.has( 'link-ui' ) ).to.be.true;
+
+					const paragraph = editor.model.document.getRoot().getChild( 0 );
+					const expectedRange = editor.model.createRange(
+						editor.model.createPositionAt( paragraph, 1 ),
+						editor.model.createPositionAt( paragraph, 1 )
+					);
+					const markerRange = editor.model.markers.get( 'link-ui' ).getRange();
+
+					expect( markerRange.isEqual( expectedRange ) ).to.be.true;
+
+					expect( getViewData( editor.editing.view ) ).to.equal(
+						'<p>f{}<span class="ck-fake-link-selection ck-fake-link-selection_collapsed"></span>o</p>'
+					);
+					expect( editor.getData() ).to.equal( '<p>fo</p>' );
+				} );
+
+				it( 'should be displayed on selection focus when selection contains only one empty element ' +
+					'(selection focus is at the beginning of the first non-empty element)', () => {
+					setModelData( editor.model, '<paragraph>foo[</paragraph>' +
+						'<paragraph></paragraph>' +
+						'<paragraph>]bar</paragraph>' );
+
+					linkUIFeature._showUI();
+
+					expect( editor.model.markers.has( 'link-ui' ) ).to.be.true;
+
+					const focus = editor.model.document.selection.focus;
+					const expectedRange = editor.model.createRange(
+						editor.model.createPositionAt( focus, 0 )
+					);
+
+					const markerRange = editor.model.markers.get( 'link-ui' ).getRange();
+
+					expect( markerRange.isEqual( expectedRange ) ).to.be.true;
+
+					const expectedViewData = '<p>foo{</p>' +
+						'<p></p>' +
+						'<p>]<span class="ck-fake-link-selection ck-fake-link-selection_collapsed"></span>bar</p>';
+
+					expect( getViewData( editor.editing.view ) ).to.equal( expectedViewData );
+					expect( editor.getData() ).to.equal( '<p>foo</p><p>&nbsp;</p><p>bar</p>' );
+				} );
+
+				it( 'should be displayed on selection focus when selection contains few empty elements ' +
+					'(selection focus is at the beginning of the first non-empty element)', () => {
+					setModelData( editor.model, '<paragraph>foo[</paragraph>' +
+						'<paragraph></paragraph>' +
+						'<paragraph></paragraph>' +
+						'<paragraph>]bar</paragraph>' );
+
+					linkUIFeature._showUI();
+
+					expect( editor.model.markers.has( 'link-ui' ) ).to.be.true;
+
+					const focus = editor.model.document.selection.focus;
+					const expectedRange = editor.model.createRange(
+						editor.model.createPositionAt( focus, 0 )
+					);
+
+					const markerRange = editor.model.markers.get( 'link-ui' ).getRange();
+
+					expect( markerRange.isEqual( expectedRange ) ).to.be.true;
+
+					const expectedViewData = '<p>foo{</p>' +
+						'<p></p>' +
+						'<p></p>' +
+						'<p>]<span class="ck-fake-link-selection ck-fake-link-selection_collapsed"></span>bar</p>';
+
+					expect( getViewData( editor.editing.view ) ).to.equal( expectedViewData );
+					expect( editor.getData() ).to.equal( '<p>foo</p><p>&nbsp;</p><p>&nbsp;</p><p>bar</p>' );
+				} );
+
+				it( 'should be displayed on selection focus when selection contains few empty elements ' +
+					'(selection focus is inside an empty element)', () => {
+					setModelData( editor.model, '<paragraph>foo[</paragraph>' +
+						'<paragraph></paragraph>' +
+						'<paragraph>]</paragraph>' +
+						'<paragraph>bar</paragraph>' );
+
+					linkUIFeature._showUI();
+
+					expect( editor.model.markers.has( 'link-ui' ) ).to.be.true;
+
+					const focus = editor.model.document.selection.focus;
+					const expectedRange = editor.model.createRange(
+						editor.model.createPositionAt( focus, 0 )
+					);
+
+					const markerRange = editor.model.markers.get( 'link-ui' ).getRange();
+
+					expect( markerRange.isEqual( expectedRange ) ).to.be.true;
+
+					const expectedViewData = '<p>foo{</p>' +
+						'<p></p>' +
+						'<p>]<span class="ck-fake-link-selection ck-fake-link-selection_collapsed"></span></p>' +
+						'<p>bar</p>';
+
+					expect( getViewData( editor.editing.view ) ).to.equal( expectedViewData );
+					expect( editor.getData() ).to.equal( '<p>foo</p><p>&nbsp;</p><p>&nbsp;</p><p>bar</p>' );
+				} );
+			} );
 		} );
 
 		function getMarkersRange( editor ) {

+ 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 => {

+ 1 - 1
packages/ckeditor5-upload/docs/features/simple-upload-adapter.md

@@ -67,7 +67,7 @@ ClassicEditor
 
 			// Headers sent along with the XMLHttpRequest to the upload server.
 			headers: {
-				'X-CSRF-TOKEN': 'CSFR-Token',
+				'X-CSRF-TOKEN': 'CSRF-Token',
 				Authorization: 'Bearer <JSON Web Token>'
 			}
 		}

+ 4 - 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!',