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

Create ImageInsert plugin (step1).

panr 5 лет назад
Родитель
Сommit
c29b6ae902
22 измененных файлов с 4298 добавлено и 20 удалено
  1. 90 0
      packages/ckeditor5-image/src/imageinsert/imageinsertcommand.js
  2. 345 0
      packages/ckeditor5-image/src/imageinsert/imageinsertediting.js
  3. 276 0
      packages/ckeditor5-image/src/imageinsert/imageinsertprogress.js
  4. 186 0
      packages/ckeditor5-image/src/imageinsert/imageinsertui.js
  5. 103 0
      packages/ckeditor5-image/src/imageinsert/ui/imageinsertformrowview.js
  6. 315 0
      packages/ckeditor5-image/src/imageinsert/ui/imageinsertpanelview.js
  7. 153 0
      packages/ckeditor5-image/src/imageinsert/utils.js
  8. 199 0
      packages/ckeditor5-image/tests/imageinsert/imageinsertcommand.js
  9. 1054 0
      packages/ckeditor5-image/tests/imageinsert/imageinsertediting.js
  10. 332 0
      packages/ckeditor5-image/tests/imageinsert/imageinsertprogress.js
  11. 615 0
      packages/ckeditor5-image/tests/imageinsert/imageinsertui.js
  12. 100 0
      packages/ckeditor5-image/tests/imageinsert/ui/imageinsertformrowview.js
  13. 298 0
      packages/ckeditor5-image/tests/imageinsert/ui/imageinsertpanelview.js
  14. 179 0
      packages/ckeditor5-image/tests/imageinsert/utils.js
  15. 2 2
      packages/ckeditor5-image/tests/imageupload/imageuploadcommand.js
  16. 1 1
      packages/ckeditor5-image/tests/imageupload/imageuploadediting.js
  17. 0 17
      packages/ckeditor5-image/tests/imageupload/imageuploadui.js
  18. 0 0
      packages/ckeditor5-image/theme/imageinsert.css
  19. 0 0
      packages/ckeditor5-image/theme/imageinsertformrowview.css
  20. 17 0
      packages/ckeditor5-image/theme/imageinserticon.css
  21. 18 0
      packages/ckeditor5-image/theme/imageinsertloader.css
  22. 15 0
      packages/ckeditor5-image/theme/imageinsertprogress.css

+ 90 - 0
packages/ckeditor5-image/src/imageinsert/imageinsertcommand.js

@@ -0,0 +1,90 @@
+/**
+ * @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 FileRepository from '@ckeditor/ckeditor5-upload/src/filerepository';
+import Command from '@ckeditor/ckeditor5-core/src/command';
+import { insertImage, isImageAllowed } from '../image/utils';
+
+/**
+ * @module image/imageinsert/imageinsertcommand
+ */
+
+/**
+ * The image upload command.
+ *
+ * The command is registered by the {@link module:image/imageinsert/imageinsertediting~ImageUploadEditing} plugin as `'imageUpload'`.
+ *
+ * In order to upload an image at the current selection position
+ * (according to the {@link module:widget/utils~findOptimalInsertionPosition} algorithm),
+ * execute the command and pass the native image file instance:
+ *
+ *		this.listenTo( editor.editing.view.document, 'clipboardInput', ( evt, data ) => {
+ *			// Assuming that only images were pasted:
+ *			const images = Array.from( data.dataTransfer.files );
+ *
+ *			// Upload the first image:
+ *			editor.execute( 'imageUpload', { file: images[ 0 ] } );
+ *		} );
+ *
+ * It is also possible to insert multiple images at once:
+ *
+ *		editor.execute( 'imageUpload', {
+ *			file: [
+ *				file1,
+ *				file2
+ *			]
+ *		} );
+ *
+ * @extends module:core/command~Command
+ */
+export default class ImageUploadCommand extends Command {
+	/**
+	 * @inheritDoc
+	 */
+	refresh() {
+		const imageElement = this.editor.model.document.selection.getSelectedElement();
+		const isImage = imageElement && imageElement.name === 'image' || false;
+
+		this.isEnabled = isImageAllowed( this.editor.model ) || isImage;
+	}
+
+	/**
+	 * Executes the command.
+	 *
+	 * @fires execute
+	 * @param {Object} options Options for the executed command.
+	 * @param {File|Array.<File>} options.file The image file or an array of image files to upload.
+	 */
+	execute( options ) {
+		const editor = this.editor;
+		const model = editor.model;
+
+		const fileRepository = editor.plugins.get( FileRepository );
+
+		model.change( writer => {
+			const filesToUpload = Array.isArray( options.file ) ? options.file : [ options.file ];
+
+			for ( const file of filesToUpload ) {
+				uploadImage( writer, model, fileRepository, file );
+			}
+		} );
+	}
+}
+
+// Handles uploading single file.
+//
+// @param {module:engine/model/writer~writer} writer
+// @param {module:engine/model/model~Model} model
+// @param {File} file
+function uploadImage( writer, model, fileRepository, file ) {
+	const loader = fileRepository.createLoader( file );
+
+	// Do not throw when upload adapter is not set. FileRepository will log an error anyway.
+	if ( !loader ) {
+		return;
+	}
+
+	insertImage( writer, model, { uploadId: loader.id } );
+}

+ 345 - 0
packages/ckeditor5-image/src/imageinsert/imageinsertediting.js

@@ -0,0 +1,345 @@
+/**
+ * @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/imageinsertediting
+ */
+
+import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
+import FileRepository from '@ckeditor/ckeditor5-upload/src/filerepository';
+import Notification from '@ckeditor/ckeditor5-ui/src/notification/notification';
+import Clipboard from '@ckeditor/ckeditor5-clipboard/src/clipboard';
+import UpcastWriter from '@ckeditor/ckeditor5-engine/src/view/upcastwriter';
+import env from '@ckeditor/ckeditor5-utils/src/env';
+
+import ImageUploadCommand from '../../src/imageinsert/imageinsertcommand';
+import { fetchLocalImage, isLocalImage } from '../../src/imageinsert/utils';
+import { createImageTypeRegExp } from './utils';
+import { getViewImgFromWidget } from '../image/utils';
+
+/**
+ * The editing part of the image upload feature. It registers the `'imageUpload'` command.
+ *
+ * @extends module:core/plugin~Plugin
+ */
+export default class ImageUploadEditing extends Plugin {
+	/**
+	 * @inheritDoc
+	 */
+	static get requires() {
+		return [ FileRepository, Notification, Clipboard ];
+	}
+
+	static get pluginName() {
+		return 'ImageUploadEditing';
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	constructor( editor ) {
+		super( editor );
+
+		editor.config.define( 'image', {
+			upload: {
+				types: [ 'jpeg', 'png', 'gif', 'bmp', 'webp', 'tiff' ]
+			}
+		} );
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	init() {
+		const editor = this.editor;
+		const doc = editor.model.document;
+		const schema = editor.model.schema;
+		const conversion = editor.conversion;
+		const fileRepository = editor.plugins.get( FileRepository );
+
+		const imageTypes = createImageTypeRegExp( editor.config.get( 'image.upload.types' ) );
+
+		// Setup schema to allow uploadId and uploadStatus for images.
+		schema.extend( 'image', {
+			allowAttributes: [ 'uploadId', 'uploadStatus' ]
+		} );
+
+		// Register imageUpload command.
+		editor.commands.add( 'imageUpload', new ImageUploadCommand( editor ) );
+
+		// Register upcast converter for uploadId.
+		conversion.for( 'upcast' )
+			.attributeToAttribute( {
+				view: {
+					name: 'img',
+					key: 'uploadId'
+				},
+				model: 'uploadId'
+			} );
+
+		// Handle pasted images.
+		// For every image file, a new file loader is created and a placeholder image is
+		// inserted into the content. Then, those images are uploaded once they appear in the model
+		// (see Document#change listener below).
+		this.listenTo( editor.editing.view.document, 'clipboardInput', ( evt, data ) => {
+			// Skip if non empty HTML data is included.
+			// https://github.com/ckeditor/ckeditor5-upload/issues/68
+			if ( isHtmlIncluded( data.dataTransfer ) ) {
+				return;
+			}
+
+			const images = Array.from( data.dataTransfer.files ).filter( file => {
+				// See https://github.com/ckeditor/ckeditor5-image/pull/254.
+				if ( !file ) {
+					return false;
+				}
+
+				return imageTypes.test( file.type );
+			} );
+
+			const ranges = data.targetRanges.map( viewRange => editor.editing.mapper.toModelRange( viewRange ) );
+
+			editor.model.change( writer => {
+				// Set selection to paste target.
+				writer.setSelection( ranges );
+
+				if ( images.length ) {
+					evt.stop();
+
+					// Upload images after the selection has changed in order to ensure the command's state is refreshed.
+					editor.model.enqueueChange( 'default', () => {
+						editor.execute( 'imageUpload', { file: images } );
+					} );
+				}
+			} );
+		} );
+
+		// Handle HTML pasted with images with base64 or blob sources.
+		// For every image file, a new file loader is created and a placeholder image is
+		// inserted into the content. Then, those images are uploaded once they appear in the model
+		// (see Document#change listener below).
+		this.listenTo( editor.plugins.get( Clipboard ), 'inputTransformation', ( evt, data ) => {
+			const fetchableImages = Array.from( editor.editing.view.createRangeIn( data.content ) )
+				.filter( value => isLocalImage( value.item ) && !value.item.getAttribute( 'uploadProcessed' ) )
+				.map( value => { return { promise: fetchLocalImage( value.item ), imageElement: value.item }; } );
+
+			if ( !fetchableImages.length ) {
+				return;
+			}
+
+			const writer = new UpcastWriter( editor.editing.view.document );
+
+			for ( const fetchableImage of fetchableImages ) {
+				// Set attribute marking that the image was processed already.
+				writer.setAttribute( 'uploadProcessed', true, fetchableImage.imageElement );
+
+				const loader = fileRepository.createLoader( fetchableImage.promise );
+
+				if ( loader ) {
+					writer.setAttribute( 'src', '', fetchableImage.imageElement );
+					writer.setAttribute( 'uploadId', loader.id, fetchableImage.imageElement );
+				}
+			}
+		} );
+
+		// Prevents from the browser redirecting to the dropped image.
+		editor.editing.view.document.on( 'dragover', ( evt, data ) => {
+			data.preventDefault();
+		} );
+
+		// Upload placeholder images that appeared in the model.
+		doc.on( 'change', () => {
+			const changes = doc.differ.getChanges( { includeChangesInGraveyard: true } );
+
+			for ( const entry of changes ) {
+				if ( entry.type == 'insert' && entry.name != '$text' ) {
+					const item = entry.position.nodeAfter;
+					const isInGraveyard = entry.position.root.rootName == '$graveyard';
+
+					for ( const image of getImagesFromChangeItem( editor, item ) ) {
+						// Check if the image element still has upload id.
+						const uploadId = image.getAttribute( 'uploadId' );
+
+						if ( !uploadId ) {
+							continue;
+						}
+
+						// Check if the image is loaded on this client.
+						const loader = fileRepository.loaders.get( uploadId );
+
+						if ( !loader ) {
+							continue;
+						}
+
+						if ( isInGraveyard ) {
+							// If the image was inserted to the graveyard - abort the loading process.
+							loader.abort();
+						} else if ( loader.status == 'idle' ) {
+							// If the image was inserted into content and has not been loaded yet, start loading it.
+							this._readAndUpload( loader, image );
+						}
+					}
+				}
+			}
+		} );
+	}
+
+	/**
+	 * Reads and uploads an image.
+	 *
+	 * The image is read from the disk and as a Base64-encoded string it is set temporarily to
+	 * `image[src]`. When the image is successfully uploaded, the temporary data is replaced with the target
+	 * image's URL (the URL to the uploaded image on the server).
+	 *
+	 * @protected
+	 * @param {module:upload/filerepository~FileLoader} loader
+	 * @param {module:engine/model/element~Element} imageElement
+	 * @returns {Promise}
+	 */
+	_readAndUpload( loader, imageElement ) {
+		const editor = this.editor;
+		const model = editor.model;
+		const t = editor.locale.t;
+		const fileRepository = editor.plugins.get( FileRepository );
+		const notification = editor.plugins.get( Notification );
+
+		model.enqueueChange( 'transparent', writer => {
+			writer.setAttribute( 'uploadStatus', 'reading', imageElement );
+		} );
+
+		return loader.read()
+			.then( () => {
+				const promise = loader.upload();
+
+				// Force re–paint in Safari. Without it, the image will display with a wrong size.
+				// https://github.com/ckeditor/ckeditor5/issues/1975
+				/* istanbul ignore next */
+				if ( env.isSafari ) {
+					const viewFigure = editor.editing.mapper.toViewElement( imageElement );
+					const viewImg = getViewImgFromWidget( viewFigure );
+
+					editor.editing.view.once( 'render', () => {
+						// Early returns just to be safe. There might be some code ran
+						// in between the outer scope and this callback.
+						if ( !viewImg.parent ) {
+							return;
+						}
+
+						const domFigure = editor.editing.view.domConverter.mapViewToDom( viewImg.parent );
+
+						if ( !domFigure ) {
+							return;
+						}
+
+						const originalDisplay = domFigure.style.display;
+
+						domFigure.style.display = 'none';
+
+						// Make sure this line will never be removed during minification for having "no effect".
+						domFigure._ckHack = domFigure.offsetHeight;
+
+						domFigure.style.display = originalDisplay;
+					} );
+				}
+
+				model.enqueueChange( 'transparent', writer => {
+					writer.setAttribute( 'uploadStatus', 'uploading', imageElement );
+				} );
+
+				return promise;
+			} )
+			.then( data => {
+				model.enqueueChange( 'transparent', writer => {
+					writer.setAttributes( { uploadStatus: 'complete', src: data.default }, imageElement );
+					this._parseAndSetSrcsetAttributeOnImage( data, imageElement, writer );
+				} );
+
+				clean();
+			} )
+			.catch( error => {
+				// If status is not 'error' nor 'aborted' - throw error because it means that something else went wrong,
+				// it might be generic error and it would be real pain to find what is going on.
+				if ( loader.status !== 'error' && loader.status !== 'aborted' ) {
+					throw error;
+				}
+
+				// Might be 'aborted'.
+				if ( loader.status == 'error' && error ) {
+					notification.showWarning( error, {
+						title: t( 'Upload failed' ),
+						namespace: 'upload'
+					} );
+				}
+
+				clean();
+
+				// Permanently remove image from insertion batch.
+				model.enqueueChange( 'transparent', writer => {
+					writer.remove( imageElement );
+				} );
+			} );
+
+		function clean() {
+			model.enqueueChange( 'transparent', writer => {
+				writer.removeAttribute( 'uploadId', imageElement );
+				writer.removeAttribute( 'uploadStatus', imageElement );
+			} );
+
+			fileRepository.destroyLoader( loader );
+		}
+	}
+
+	/**
+	 * Creates the `srcset` attribute based on a given file upload response and sets it as an attribute to a specific image element.
+	 *
+	 * @protected
+	 * @param {Object} data Data object from which `srcset` will be created.
+	 * @param {module:engine/model/element~Element} image The image element on which the `srcset` attribute will be set.
+	 * @param {module:engine/model/writer~Writer} writer
+	 */
+	_parseAndSetSrcsetAttributeOnImage( data, image, writer ) {
+		// Srcset attribute for responsive images support.
+		let maxWidth = 0;
+
+		const srcsetAttribute = Object.keys( data )
+		// Filter out keys that are not integers.
+			.filter( key => {
+				const width = parseInt( key, 10 );
+
+				if ( !isNaN( width ) ) {
+					maxWidth = Math.max( maxWidth, width );
+
+					return true;
+				}
+			} )
+
+			// Convert each key to srcset entry.
+			.map( key => `${ data[ key ] } ${ key }w` )
+
+			// Join all entries.
+			.join( ', ' );
+
+		if ( srcsetAttribute != '' ) {
+			writer.setAttribute( 'srcset', {
+				data: srcsetAttribute,
+				width: maxWidth
+			}, image );
+		}
+	}
+}
+
+// Returns `true` if non-empty `text/html` is included in the data transfer.
+//
+// @param {module:clipboard/datatransfer~DataTransfer} dataTransfer
+// @returns {Boolean}
+export function isHtmlIncluded( dataTransfer ) {
+	return Array.from( dataTransfer.types ).includes( 'text/html' ) && dataTransfer.getData( 'text/html' ) !== '';
+}
+
+function getImagesFromChangeItem( editor, item ) {
+	return Array.from( editor.model.createRangeOn( item ) )
+		.filter( value => value.item.is( 'element', 'image' ) )
+		.map( value => value.item );
+}

+ 276 - 0
packages/ckeditor5-image/src/imageinsert/imageinsertprogress.js

@@ -0,0 +1,276 @@
+/**
+ * @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/imageinsertprogress
+ */
+
+/* globals setTimeout */
+
+import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
+import FileRepository from '@ckeditor/ckeditor5-upload/src/filerepository';
+import uploadingPlaceholder from '../../theme/icons/image_placeholder.svg';
+import { getViewImgFromWidget } from '../image/utils';
+
+import '../../theme/imageinsertprogress.css';
+import '../../theme/imageinserticon.css';
+import '../../theme/imageinsertloader.css';
+
+/**
+ * The image upload progress plugin.
+ * It shows a placeholder when the image is read from the disk and a progress bar while the image is uploading.
+ *
+ * @extends module:core/plugin~Plugin
+ */
+export default class ImageUploadProgress extends Plugin {
+	/**
+	 * @inheritDoc
+	 */
+	constructor( editor ) {
+		super( editor );
+
+		/**
+		 * The image placeholder that is displayed before real image data can be accessed.
+		 *
+		 * @protected
+		 * @member {String} #placeholder
+		 */
+		this.placeholder = 'data:image/svg+xml;utf8,' + encodeURIComponent( uploadingPlaceholder );
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	init() {
+		const editor = this.editor;
+
+		// Upload status change - update image's view according to that status.
+		editor.editing.downcastDispatcher.on( 'attribute:uploadStatus:image', ( ...args ) => this.uploadStatusChange( ...args ) );
+	}
+
+	/**
+	 * This method is called each time the image `uploadStatus` attribute is changed.
+	 *
+	 * @param {module:utils/eventinfo~EventInfo} evt An object containing information about the fired event.
+	 * @param {Object} data Additional information about the change.
+	 * @param {module:engine/conversion/downcastdispatcher~DowncastConversionApi} conversionApi
+	 */
+	uploadStatusChange( evt, data, conversionApi ) {
+		const editor = this.editor;
+		const modelImage = data.item;
+		const uploadId = modelImage.getAttribute( 'uploadId' );
+
+		if ( !conversionApi.consumable.consume( data.item, evt.name ) ) {
+			return;
+		}
+
+		const fileRepository = editor.plugins.get( FileRepository );
+		const status = uploadId ? data.attributeNewValue : null;
+		const placeholder = this.placeholder;
+		const viewFigure = editor.editing.mapper.toViewElement( modelImage );
+		const viewWriter = conversionApi.writer;
+
+		if ( status == 'reading' ) {
+			// Start "appearing" effect and show placeholder with infinite progress bar on the top
+			// while image is read from disk.
+			_startAppearEffect( viewFigure, viewWriter );
+			_showPlaceholder( placeholder, viewFigure, viewWriter );
+
+			return;
+		}
+
+		// Show progress bar on the top of the image when image is uploading.
+		if ( status == 'uploading' ) {
+			const loader = fileRepository.loaders.get( uploadId );
+
+			// Start appear effect if needed - see https://github.com/ckeditor/ckeditor5-image/issues/191.
+			_startAppearEffect( viewFigure, viewWriter );
+
+			if ( !loader ) {
+				// There is no loader associated with uploadId - this means that image came from external changes.
+				// In such cases we still want to show the placeholder until image is fully uploaded.
+				// Show placeholder if needed - see https://github.com/ckeditor/ckeditor5-image/issues/191.
+				_showPlaceholder( placeholder, viewFigure, viewWriter );
+			} else {
+				// Hide placeholder and initialize progress bar showing upload progress.
+				_hidePlaceholder( viewFigure, viewWriter );
+				_showProgressBar( viewFigure, viewWriter, loader, editor.editing.view );
+				_displayLocalImage( viewFigure, viewWriter, loader );
+			}
+
+			return;
+		}
+
+		if ( status == 'complete' && fileRepository.loaders.get( uploadId ) ) {
+			_showCompleteIcon( viewFigure, viewWriter, editor.editing.view );
+		}
+
+		// Clean up.
+		_hideProgressBar( viewFigure, viewWriter );
+		_hidePlaceholder( viewFigure, viewWriter );
+		_stopAppearEffect( viewFigure, viewWriter );
+	}
+}
+
+// Adds ck-appear class to the image figure if one is not already applied.
+//
+// @param {module:engine/view/containerelement~ContainerElement} viewFigure
+// @param {module:engine/view/downcastwriter~DowncastWriter} writer
+function _startAppearEffect( viewFigure, writer ) {
+	if ( !viewFigure.hasClass( 'ck-appear' ) ) {
+		writer.addClass( 'ck-appear', viewFigure );
+	}
+}
+
+// Removes ck-appear class to the image figure if one is not already removed.
+//
+// @param {module:engine/view/containerelement~ContainerElement} viewFigure
+// @param {module:engine/view/downcastwriter~DowncastWriter} writer
+function _stopAppearEffect( viewFigure, writer ) {
+	writer.removeClass( 'ck-appear', viewFigure );
+}
+
+// Shows placeholder together with infinite progress bar on given image figure.
+//
+// @param {String} Data-uri with a svg placeholder.
+// @param {module:engine/view/containerelement~ContainerElement} viewFigure
+// @param {module:engine/view/downcastwriter~DowncastWriter} writer
+function _showPlaceholder( placeholder, viewFigure, writer ) {
+	if ( !viewFigure.hasClass( 'ck-image-upload-placeholder' ) ) {
+		writer.addClass( 'ck-image-upload-placeholder', viewFigure );
+	}
+
+	const viewImg = getViewImgFromWidget( viewFigure );
+
+	if ( viewImg.getAttribute( 'src' ) !== placeholder ) {
+		writer.setAttribute( 'src', placeholder, viewImg );
+	}
+
+	if ( !_getUIElement( viewFigure, 'placeholder' ) ) {
+		writer.insert( writer.createPositionAfter( viewImg ), _createPlaceholder( writer ) );
+	}
+}
+
+// Removes placeholder together with infinite progress bar on given image figure.
+//
+// @param {module:engine/view/containerelement~ContainerElement} viewFigure
+// @param {module:engine/view/downcastwriter~DowncastWriter} writer
+function _hidePlaceholder( viewFigure, writer ) {
+	if ( viewFigure.hasClass( 'ck-image-upload-placeholder' ) ) {
+		writer.removeClass( 'ck-image-upload-placeholder', viewFigure );
+	}
+
+	_removeUIElement( viewFigure, writer, 'placeholder' );
+}
+
+// Shows progress bar displaying upload progress.
+// Attaches it to the file loader to update when upload percentace is changed.
+//
+// @param {module:engine/view/containerelement~ContainerElement} viewFigure
+// @param {module:engine/view/downcastwriter~DowncastWriter} writer
+// @param {module:upload/filerepository~FileLoader} loader
+// @param {module:engine/view/view~View} view
+function _showProgressBar( viewFigure, writer, loader, view ) {
+	const progressBar = _createProgressBar( writer );
+	writer.insert( writer.createPositionAt( viewFigure, 'end' ), progressBar );
+
+	// Update progress bar width when uploadedPercent is changed.
+	loader.on( 'change:uploadedPercent', ( evt, name, value ) => {
+		view.change( writer => {
+			writer.setStyle( 'width', value + '%', progressBar );
+		} );
+	} );
+}
+
+// Hides upload progress bar.
+//
+// @param {module:engine/view/containerelement~ContainerElement} viewFigure
+// @param {module:engine/view/downcastwriter~DowncastWriter} writer
+function _hideProgressBar( viewFigure, writer ) {
+	_removeUIElement( viewFigure, writer, 'progressBar' );
+}
+
+// Shows complete icon and hides after a certain amount of time.
+//
+// @param {module:engine/view/containerelement~ContainerElement} viewFigure
+// @param {module:engine/view/downcastwriter~DowncastWriter} writer
+// @param {module:engine/view/view~View} view
+function _showCompleteIcon( viewFigure, writer, view ) {
+	const completeIcon = writer.createUIElement( 'div', { class: 'ck-image-upload-complete-icon' } );
+
+	writer.insert( writer.createPositionAt( viewFigure, 'end' ), completeIcon );
+
+	setTimeout( () => {
+		view.change( writer => writer.remove( writer.createRangeOn( completeIcon ) ) );
+	}, 3000 );
+}
+
+// Create progress bar element using {@link module:engine/view/uielement~UIElement}.
+//
+// @private
+// @param {module:engine/view/downcastwriter~DowncastWriter} writer
+// @returns {module:engine/view/uielement~UIElement}
+function _createProgressBar( writer ) {
+	const progressBar = writer.createUIElement( 'div', { class: 'ck-progress-bar' } );
+
+	writer.setCustomProperty( 'progressBar', true, progressBar );
+
+	return progressBar;
+}
+
+// Create placeholder element using {@link module:engine/view/uielement~UIElement}.
+//
+// @private
+// @param {module:engine/view/downcastwriter~DowncastWriter} writer
+// @returns {module:engine/view/uielement~UIElement}
+function _createPlaceholder( writer ) {
+	const placeholder = writer.createUIElement( 'div', { class: 'ck-upload-placeholder-loader' } );
+
+	writer.setCustomProperty( 'placeholder', true, placeholder );
+
+	return placeholder;
+}
+
+// Returns {@link module:engine/view/uielement~UIElement} of given unique property from image figure element.
+// Returns `undefined` if element is not found.
+//
+// @private
+// @param {module:engine/view/element~Element} imageFigure
+// @param {String} uniqueProperty
+// @returns {module:engine/view/uielement~UIElement|undefined}
+function _getUIElement( imageFigure, uniqueProperty ) {
+	for ( const child of imageFigure.getChildren() ) {
+		if ( child.getCustomProperty( uniqueProperty ) ) {
+			return child;
+		}
+	}
+}
+
+// Removes {@link module:engine/view/uielement~UIElement} of given unique property from image figure element.
+//
+// @private
+// @param {module:engine/view/element~Element} imageFigure
+// @param {module:engine/view/downcastwriter~DowncastWriter} writer
+// @param {String} uniqueProperty
+function _removeUIElement( viewFigure, writer, uniqueProperty ) {
+	const element = _getUIElement( viewFigure, uniqueProperty );
+
+	if ( element ) {
+		writer.remove( writer.createRangeOn( element ) );
+	}
+}
+
+// Displays local data from file loader.
+//
+// @param {module:engine/view/element~Element} imageFigure
+// @param {module:engine/view/downcastwriter~DowncastWriter} writer
+// @param {module:upload/filerepository~FileLoader} loader
+function _displayLocalImage( viewFigure, writer, loader ) {
+	if ( loader.data ) {
+		const viewImg = getViewImgFromWidget( viewFigure );
+
+		writer.setAttribute( 'src', loader.data, viewImg );
+	}
+}

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

@@ -0,0 +1,186 @@
+/**
+ * @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/imageinsert/ui
+ */
+
+import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
+import ImageUploadPanelView from './ui/imageinsertpanelview';
+
+import FileDialogButtonView from '@ckeditor/ckeditor5-upload/src/ui/filedialogbuttonview';
+import { createImageTypeRegExp, prepareIntegrations } 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}.
+ *
+ * @extends module:core/plugin~Plugin
+ */
+export default class ImageUploadUI extends Plugin {
+	/**
+	 * @inheritDoc
+	 */
+	static get pluginName() {
+		return 'ImageUploadUI';
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	init() {
+		const editor = this.editor;
+		const isImageUploadPanelViewEnabled = !!editor.config.get( 'image.upload.panel.items' );
+
+		editor.ui.componentFactory.add( 'imageUpload', locale => {
+			if ( isImageUploadPanelViewEnabled ) {
+				return this._createDropdownView( locale );
+			} else {
+				return this._createFileDialogButtonView( locale );
+			}
+		} );
+	}
+
+	/**
+	 * Sets up the dropdown view.
+	 *
+	 * @param {module:ui/dropdown/dropdownview~DropdownView} dropdownView A dropdownView.
+	 * @param {module:image/imageinsert/ui/imageinsertpanelview~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' );
+
+		dropdownView.bind( 'isEnabled' ).to( command );
+
+		dropdownView.on( 'change:isOpen', () => {
+			const selectedElement = editor.model.document.selection.getSelectedElement();
+
+			if ( dropdownView.isOpen ) {
+				imageUploadView.focus();
+
+				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' );
+				}
+			}
+		} );
+
+		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 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
+		} );
+
+		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;
+	}
+}

+ 103 - 0
packages/ckeditor5-image/src/imageinsert/ui/imageinsertformrowview.js

@@ -0,0 +1,103 @@
+/**
+ * @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/ui/imageinsertformrowview
+ */
+
+import View from '@ckeditor/ckeditor5-ui/src/view';
+
+import '../../../theme/imageinsertformrowview.css';
+
+/**
+ * The class representing a single row in a complex form,
+ * used by {@link module:image/imageinsert/ui/imageinsertpanelview~ImageUploadPanelView}.
+ *
+ * **Note**: For now this class is private. When more use cases arrive (beyond ckeditor5-table and ckeditor5-image),
+ * it will become a component in ckeditor5-ui.
+ *
+ * @private
+ * @extends module:ui/view~View
+ */
+export default class ImageUploadFormRowView extends View {
+	/**
+	 * Creates an instance of the form row class.
+	 *
+	 * @param {module:utils/locale~Locale} locale The locale instance.
+	 * @param {Object} options
+	 * @param {Array.<module:ui/view~View>} [options.children]
+	 * @param {String} [options.class]
+	 * @param {module:ui/view~View} [options.labelView] When passed, the row gets the `group` and `aria-labelledby`
+	 * DOM attributes and gets described by the label.
+	 */
+	constructor( locale, options = {} ) {
+		super( locale );
+
+		const bind = this.bindTemplate;
+
+		/**
+		 * An additional CSS class added to the {@link #element}.
+		 *
+		 * @observable
+		 * @member {String} #class
+		 */
+		this.set( 'class', options.class || null );
+
+		/**
+		 * A collection of row items (buttons, dropdowns, etc.).
+		 *
+		 * @readonly
+		 * @member {module:ui/viewcollection~ViewCollection}
+		 */
+		this.children = this.createCollection();
+
+		if ( options.children ) {
+			options.children.forEach( child => this.children.add( child ) );
+		}
+
+		/**
+		 * The role property reflected by the `role` DOM attribute of the {@link #element}.
+		 *
+		 * **Note**: Used only when a `labelView` is passed to constructor `options`.
+		 *
+		 * @private
+		 * @observable
+		 * @member {String} #role
+		 */
+		this.set( '_role', null );
+
+		/**
+		 * The ARIA property reflected by the `aria-labelledby` DOM attribute of the {@link #element}.
+		 *
+		 * **Note**: Used only when a `labelView` is passed to constructor `options`.
+		 *
+		 * @private
+		 * @observable
+		 * @member {String} #ariaLabelledBy
+		 */
+		this.set( '_ariaLabelledBy', null );
+
+		if ( options.labelView ) {
+			this.set( {
+				_role: 'group',
+				_ariaLabelledBy: options.labelView.id
+			} );
+		}
+
+		this.setTemplate( {
+			tag: 'div',
+			attributes: {
+				class: [
+					'ck',
+					'ck-form__row',
+					bind.to( 'class' )
+				],
+				role: bind.to( '_role' ),
+				'aria-labelledby': bind.to( '_ariaLabelledBy' )
+			},
+			children: this.children
+		} );
+	}
+}

+ 315 - 0
packages/ckeditor5-image/src/imageinsert/ui/imageinsertpanelview.js

@@ -0,0 +1,315 @@
+/**
+ * @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/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 './imageinsertformrowview';
+import { createDropdown } from '@ckeditor/ckeditor5-ui/src/dropdown/utils';
+
+import Collection from '@ckeditor/ckeditor5-utils/src/collection';
+import ViewCollection from '@ckeditor/ckeditor5-ui/src/viewcollection';
+import submitHandler from '@ckeditor/ckeditor5-ui/src/bindings/submithandler';
+import FocusTracker from '@ckeditor/ckeditor5-utils/src/focustracker';
+import FocusCycler from '@ckeditor/ckeditor5-ui/src/focuscycler';
+import KeystrokeHandler from '@ckeditor/ckeditor5-utils/src/keystrokehandler';
+
+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/imageinsert.css';
+
+/**
+ * The insert an image via URL view controller class.
+ *
+ * See {@link module:image/imageinsert/ui/imageinsertpanelview~ImageUploadPanelView}.
+ *
+ * @extends module:ui/view~View
+ */
+export default class ImageUploadPanelView extends View {
+	/**
+	 * Creates a view for the dropdown panel of {@link module:image/imageinsert/imageinsert/ui~ImageUploadUI}.
+	 *
+	 * @param {module:utils/locale~Locale} [locale] The localization services instance..
+	 * @param {Object} [integrations] Integrations object that contain
+	 * components (or tokens for components) to be shown in the panel view.
+	 */
+	constructor( locale, integrations ) {
+		super( locale );
+
+		const { insertButtonView, cancelButtonView } = this._createActionButtons( locale );
+
+		/**
+		 * The "insert/update" button view.
+		 *
+		 * @member {module:ui/button/buttonview~ButtonView}
+		 */
+		this.insertButtonView = insertButtonView;
+
+		/**
+		 * The "cancel" button view.
+		 *
+		 * @member {module:ui/button/buttonview~ButtonView}
+		 */
+		this.cancelButtonView = cancelButtonView;
+
+		/**
+		 * The dropdown view.
+		 *
+		 * @member {module:ui/dropdown/dropdownview~DropdownView}
+		 */
+		this.dropdownView = this._createDropdownView( locale );
+
+		/**
+		 * Value of the URL input.
+		 *
+		 * @member {String} #imageURLInputValue
+		 * @observable
+		 */
+		this.set( 'imageURLInputValue', '' );
+
+		/**
+		 * Tracks information about DOM focus in the form.
+		 *
+		 * @readonly
+		 * @member {module:utils/focustracker~FocusTracker}
+		 */
+		this.focusTracker = new FocusTracker();
+
+		/**
+		 * An instance of the {@link module:utils/keystrokehandler~KeystrokeHandler}.
+		 *
+		 * @readonly
+		 * @member {module:utils/keystrokehandler~KeystrokeHandler}
+		 */
+		this.keystrokes = new KeystrokeHandler();
+
+		/**
+		 * A collection of views that can be focused in the form.
+		 *
+		 * @readonly
+		 * @protected
+		 * @member {module:ui/viewcollection~ViewCollection}
+		 */
+		this._focusables = new ViewCollection();
+
+		/**
+		 * Helps cycling over {@link #_focusables} in the form.
+		 *
+		 * @readonly
+		 * @protected
+		 * @member {module:ui/focuscycler~FocusCycler}
+		 */
+		this._focusCycler = new FocusCycler( {
+			focusables: this._focusables,
+			focusTracker: this.focusTracker,
+			keystrokeHandler: this.keystrokes,
+			actions: {
+				// Navigate form fields backwards using the Shift + Tab keystroke.
+				focusPrevious: 'shift + tab',
+
+				// Navigate form fields forwards using the Tab key.
+				focusNext: 'tab'
+			}
+		} );
+
+		/**
+		 * Collection of the defined integrations for inserting the images.
+		 *
+		 * @private
+		 * @member {module:utils/collection~Collection}
+		 */
+		this.set( '_integrations', new Collection() );
+
+		if ( integrations ) {
+			for ( const [ integration, integrationView ] of Object.entries( integrations ) ) {
+				if ( integration === 'insertImageViaUrl' ) {
+					integrationView.fieldView.bind( 'value' ).to( this, 'imageURLInputValue', value => value || '' );
+
+					integrationView.fieldView.on( 'input', () => {
+						this.imageURLInputValue = integrationView.fieldView.element.value;
+					} );
+				}
+
+				integrationView.name = integration;
+
+				this._integrations.add( integrationView );
+			}
+		}
+
+		this.setTemplate( {
+			tag: 'form',
+
+			attributes: {
+				class: [
+					'ck',
+					'ck-image-upload-form'
+				],
+
+				tabindex: '-1'
+			},
+
+			children: [
+				...this._integrations,
+				new ImageUploadFormRowView( locale, {
+					children: [
+						this.insertButtonView,
+						this.cancelButtonView
+					],
+					class: 'ck-image-upload-form__action-row'
+				} )
+			]
+		} );
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	render() {
+		super.render();
+
+		submitHandler( {
+			view: this
+		} );
+
+		const childViews = [
+			...this._integrations,
+			this.insertButtonView,
+			this.cancelButtonView
+		];
+
+		childViews.forEach( v => {
+			// Register the view as focusable.
+			this._focusables.add( v );
+
+			// Register the view in the focus tracker.
+			this.focusTracker.add( v.element );
+		} );
+
+		// Start listening for the keystrokes coming from #element.
+		this.keystrokes.listenTo( this.element );
+
+		const stopPropagation = data => data.stopPropagation();
+
+		// Since the form is in the dropdown panel which is a child of the toolbar, the toolbar's
+		// keystroke handler would take over the key management in the URL input. We need to prevent
+		// this ASAP. Otherwise, the basic caret movement using the arrow keys will be impossible.
+		this.keystrokes.set( 'arrowright', stopPropagation );
+		this.keystrokes.set( 'arrowleft', stopPropagation );
+		this.keystrokes.set( 'arrowup', stopPropagation );
+		this.keystrokes.set( 'arrowdown', stopPropagation );
+
+		// Intercept the "selectstart" event, which is blocked by default because of the default behavior
+		// of the DropdownView#panelView.
+		// TODO: blocking "selectstart" in the #panelView should be configurable per–drop–down instance.
+		this.listenTo( childViews[ 0 ].element, 'selectstart', ( evt, domEvt ) => {
+			domEvt.stopPropagation();
+		}, { priority: 'high' } );
+	}
+
+	/**
+	 * Returns a view of the integration.
+	 *
+	 * @param {string} name The name of the integration.
+	 * @returns {module:ui/view~View}
+	 */
+	getIntegration( name ) {
+		return this._integrations.find( integration => integration.name === name );
+	}
+
+	/**
+	 * Creates dropdown view.
+	 *
+	 * @param {module:utils/locale~Locale} locale The localization services instance.
+	 *
+	 * @private
+	 * @returns {module:ui/dropdown/dropdownview~DropdownView}
+	 */
+	_createDropdownView( locale ) {
+		const t = locale.t;
+		const dropdownView = createDropdown( locale, SplitButtonView );
+		const splitButtonView = dropdownView.buttonView;
+		const panelView = dropdownView.panelView;
+
+		splitButtonView.set( {
+			label: t( 'Insert image' ),
+			icon: imageIcon,
+			tooltip: true
+		} );
+
+		panelView.extendTemplate( {
+			attributes: {
+				class: 'ck-image-upload__panel'
+			}
+		} );
+
+		return dropdownView;
+	}
+
+	/**
+	 * Creates the following form controls:
+	 *
+	 * * {@link #insertButtonView},
+	 * * {@link #cancelButtonView}.
+	 *
+	 * @param {module:utils/locale~Locale} locale The localization services instance.
+	 *
+	 * @private
+	 * @returns {Object.<String,module:ui/view~View>}
+	 */
+	_createActionButtons( locale ) {
+		const t = locale.t;
+		const insertButtonView = new ButtonView( locale );
+		const cancelButtonView = new ButtonView( locale );
+
+		insertButtonView.set( {
+			label: t( 'Insert' ),
+			icon: checkIcon,
+			class: 'ck-button-save',
+			type: 'submit',
+			withText: true,
+			isEnabled: this.imageURLInputValue
+		} );
+
+		cancelButtonView.set( {
+			label: t( 'Cancel' ),
+			icon: cancelIcon,
+			class: 'ck-button-cancel',
+			withText: true
+		} );
+
+		insertButtonView.bind( 'isEnabled' ).to( this, 'imageURLInputValue' );
+		insertButtonView.delegate( 'execute' ).to( this, 'submit' );
+		cancelButtonView.delegate( 'execute' ).to( this, 'cancel' );
+
+		return { insertButtonView, cancelButtonView };
+	}
+
+	/**
+	 * Focuses the fist {@link #_focusables} in the form.
+	 */
+	focus() {
+		this._focusCycler.focusFirst();
+	}
+}
+
+/**
+ * Fired when the form view is submitted (when one of the children triggered the submit event),
+ * e.g. click on {@link #insertButtonView}.
+ *
+ * @event submit
+ */
+
+/**
+ * Fired when the form view is canceled, e.g. click on {@link #cancelButtonView}.
+ *
+ * @event cancel
+ */

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

@@ -0,0 +1,153 @@
+/**
+ * @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
+ */
+
+/* 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.
+ *
+ *		const imageType = createImageTypeRegExp( [ 'png', 'jpeg', 'svg+xml', 'vnd.microsoft.icon' ] );
+ *
+ *		console.log( 'is supported image', imageType.test( file.type ) );
+ *
+ * @param {Array.<String>} types
+ * @returns {RegExp}
+ */
+export function createImageTypeRegExp( types ) {
+	// Sanitize the MIME type name which may include: "+", "-" or ".".
+	const regExpSafeNames = types.map( type => type.replace( '+', '\\+' ) );
+
+	return new RegExp( `^image\\/(${ regExpSafeNames.join( '|' ) })$` );
+}
+
+/**
+ * Creates a promise that fetches the image local source (Base64 or blob) and resolves with a `File` object.
+ *
+ * @param {module:engine/view/element~Element} image Image whose source to fetch.
+ * @returns {Promise.<File>} A promise which resolves when an image source is fetched and converted to a `File` instance.
+ * It resolves with a `File` object. If there were any errors during file processing, the promise will be rejected.
+ */
+export function fetchLocalImage( image ) {
+	return new Promise( ( resolve, reject ) => {
+		const imageSrc = image.getAttribute( 'src' );
+
+		// Fetch works asynchronously and so does not block browser UI when processing data.
+		fetch( imageSrc )
+			.then( resource => resource.blob() )
+			.then( blob => {
+				const mimeType = getImageMimeType( blob, imageSrc );
+				const ext = mimeType.replace( 'image/', '' );
+				const filename = `image.${ ext }`;
+				const file = new File( [ blob ], filename, { type: mimeType } );
+
+				resolve( file );
+			} )
+			.catch( reject );
+	} );
+}
+
+/**
+ * Checks whether a given node is an image element with a local source (Base64 or blob).
+ *
+ * @param {module:engine/view/node~Node} node The node to check.
+ * @returns {Boolean}
+ */
+export function isLocalImage( node ) {
+	if ( !node.is( 'element', 'img' ) || !node.getAttribute( 'src' ) ) {
+		return false;
+	}
+
+	return node.getAttribute( 'src' ).match( /^data:image\/\w+;base64,/g ) ||
+		node.getAttribute( 'src' ).match( /^blob:/g );
+}
+
+// Extracts an image type based on its blob representation or its source.
+//
+// @param {String} src Image `src` attribute value.
+// @param {Blob} blob Image blob representation.
+// @returns {String}
+function getImageMimeType( blob, src ) {
+	if ( blob.type ) {
+		return blob.type;
+	} else if ( src.match( /data:(image\/\w+);base64/ ) ) {
+		return src.match( /data:(image\/\w+);base64/ )[ 1 ].toLowerCase();
+	} else {
+		// Fallback to 'jpeg' as common extension.
+		return 'image/jpeg';
+	}
+}
+
+/**
+ * Creates integrations object that will be passed to the
+ * {@link module:image/imageinsert/ui/imageinsertpanelview~ImageUploadPanelView}.
+ *
+ * @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.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 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;
+}

+ 199 - 0
packages/ckeditor5-image/tests/imageinsert/imageinsertcommand.js

@@ -0,0 +1,199 @@
+/**
+ * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+/* globals console */
+
+import VirtualTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/virtualtesteditor';
+
+import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
+
+import ImageUploadCommand from '../../src/imageinsert/imageinsertcommand';
+import FileRepository from '@ckeditor/ckeditor5-upload/src/filerepository';
+
+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';
+import Image from '../../src/image/imageediting';
+import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
+
+describe( 'ImageUploadCommand', () => {
+	let editor, command, model, fileRepository;
+
+	class UploadAdapterPluginMock extends Plugin {
+		init() {
+			fileRepository = this.editor.plugins.get( FileRepository );
+			fileRepository.createUploadAdapter = loader => {
+				return new UploadAdapterMock( loader );
+			};
+		}
+	}
+
+	beforeEach( () => {
+		return VirtualTestEditor
+			.create( {
+				plugins: [ FileRepository, Image, Paragraph, UploadAdapterPluginMock ]
+			} )
+			.then( newEditor => {
+				editor = newEditor;
+				model = editor.model;
+
+				command = new ImageUploadCommand( editor );
+
+				const schema = model.schema;
+				schema.extend( 'image', { allowAttributes: 'uploadId' } );
+			} );
+	} );
+
+	afterEach( () => {
+		sinon.restore();
+
+		return editor.destroy();
+	} );
+
+	describe( 'isEnabled', () => {
+		it( 'should be true when the selection directly in the root', () => {
+			model.enqueueChange( 'transparent', () => {
+				setModelData( model, '[]' );
+
+				command.refresh();
+				expect( command.isEnabled ).to.be.true;
+			} );
+		} );
+
+		it( 'should be true when the selection is in empty block', () => {
+			setModelData( model, '<paragraph>[]</paragraph>' );
+
+			expect( command.isEnabled ).to.be.true;
+		} );
+
+		it( 'should be true when the selection directly in a paragraph', () => {
+			setModelData( model, '<paragraph>foo[]</paragraph>' );
+			expect( command.isEnabled ).to.be.true;
+		} );
+
+		it( 'should be true when the selection directly in a block', () => {
+			model.schema.register( 'block', { inheritAllFrom: '$block' } );
+			model.schema.extend( '$text', { allowIn: 'block' } );
+			editor.conversion.for( 'downcast' ).elementToElement( { model: 'block', view: 'block' } );
+
+			setModelData( model, '<block>foo[]</block>' );
+			expect( command.isEnabled ).to.be.true;
+		} );
+
+		it( 'should be true when the selection is on other image', () => {
+			setModelData( model, '[<image></image>]' );
+			expect( command.isEnabled ).to.be.true;
+		} );
+
+		it( 'should be false when the selection is inside other image', () => {
+			model.schema.register( 'caption', {
+				allowIn: 'image',
+				allowContentOf: '$block',
+				isLimit: true
+			} );
+			editor.conversion.for( 'downcast' ).elementToElement( { model: 'caption', view: 'figcaption' } );
+			setModelData( model, '<image><caption>[]</caption></image>' );
+
+			expect( command.isEnabled ).to.be.false;
+		} );
+
+		it( 'should be false when the selection is on other object', () => {
+			model.schema.register( 'object', { isObject: true, allowIn: '$root' } );
+			editor.conversion.for( 'downcast' ).elementToElement( { model: 'object', view: 'object' } );
+			setModelData( model, '[<object></object>]' );
+
+			expect( command.isEnabled ).to.be.false;
+		} );
+
+		it( 'should be true when the selection is inside block element inside isLimit element which allows image', () => {
+			model.schema.register( 'table', { allowWhere: '$block', isLimit: true, isObject: true, isBlock: true } );
+			model.schema.register( 'tableRow', { allowIn: 'table', isLimit: true } );
+			model.schema.register( 'tableCell', { allowIn: 'tableRow', isLimit: true, isSelectable: true } );
+			model.schema.extend( '$block', { allowIn: 'tableCell' } );
+			editor.conversion.for( 'downcast' ).elementToElement( { model: 'table', view: 'table' } );
+			editor.conversion.for( 'downcast' ).elementToElement( { model: 'tableRow', view: 'tableRow' } );
+			editor.conversion.for( 'downcast' ).elementToElement( { model: 'tableCell', view: 'tableCell' } );
+
+			setModelData( model, '<table><tableRow><tableCell><paragraph>foo[]</paragraph></tableCell></tableRow></table>' );
+		} );
+
+		it( 'should be false when schema disallows image', () => {
+			model.schema.register( 'block', { inheritAllFrom: '$block' } );
+			model.schema.extend( 'paragraph', { allowIn: 'block' } );
+			// Block image in block.
+			model.schema.addChildCheck( ( context, childDefinition ) => {
+				if ( childDefinition.name === 'image' && context.last.name === 'block' ) {
+					return false;
+				}
+			} );
+			editor.conversion.for( 'downcast' ).elementToElement( { model: 'block', view: 'block' } );
+
+			setModelData( model, '<block><paragraph>[]</paragraph></block>' );
+
+			expect( command.isEnabled ).to.be.false;
+		} );
+	} );
+
+	describe( 'execute()', () => {
+		it( 'should insert image at selection position as other widgets', () => {
+			const file = createNativeFileMock();
+			setModelData( model, '<paragraph>f[o]o</paragraph>' );
+
+			command.execute( { file } );
+
+			const id = fileRepository.getLoader( file ).id;
+			expect( getModelData( model ) )
+				.to.equal( `[<image uploadId="${ id }"></image>]<paragraph>foo</paragraph>` );
+		} );
+
+		it( 'should use parent batch', () => {
+			const file = createNativeFileMock();
+
+			setModelData( model, '<paragraph>[]foo</paragraph>' );
+
+			model.change( writer => {
+				expect( writer.batch.operations ).to.length( 0 );
+
+				command.execute( { file } );
+
+				expect( writer.batch.operations ).to.length.above( 0 );
+			} );
+		} );
+
+		it( 'should not insert image nor crash when image could not be inserted', () => {
+			const file = createNativeFileMock();
+
+			model.schema.register( 'other', {
+				allowIn: '$root',
+				isLimit: true
+			} );
+			model.schema.extend( '$text', { allowIn: 'other' } );
+
+			editor.conversion.for( 'downcast' ).elementToElement( { model: 'other', view: 'p' } );
+
+			setModelData( model, '<other>[]</other>' );
+
+			command.execute( { file } );
+
+			expect( getModelData( model ) ).to.equal( '<other>[]</other>' );
+		} );
+
+		it( 'should not throw when upload adapter is not set (FileRepository will log an warn anyway)', () => {
+			const file = createNativeFileMock();
+
+			fileRepository.createUploadAdapter = undefined;
+
+			const consoleWarnStub = sinon.stub( console, 'warn' );
+
+			setModelData( model, '<paragraph>fo[]o</paragraph>' );
+
+			expect( () => {
+				command.execute( { file } );
+			} ).to.not.throw();
+
+			expect( getModelData( model ) ).to.equal( '<paragraph>fo[]o</paragraph>' );
+			sinon.assert.calledOnce( consoleWarnStub );
+		} );
+	} );
+} );

+ 1054 - 0
packages/ckeditor5-image/tests/imageinsert/imageinsertediting.js

@@ -0,0 +1,1054 @@
+/**
+ * @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 window, setTimeout, atob, URL, Blob, console */
+
+import VirtualTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/virtualtesteditor';
+
+import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
+import Clipboard from '@ckeditor/ckeditor5-clipboard/src/clipboard';
+import ImageEditing from '../../src/image/imageediting';
+import ImageUploadEditing from '../../src/imageinsert/imageinsertediting';
+import ImageUploadCommand from '../../src/imageinsert/imageinsertcommand';
+import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
+import UndoEditing from '@ckeditor/ckeditor5-undo/src/undoediting';
+import DataTransfer from '@ckeditor/ckeditor5-clipboard/src/datatransfer';
+import EventInfo from '@ckeditor/ckeditor5-utils/src/eventinfo';
+
+import FileRepository from '@ckeditor/ckeditor5-upload/src/filerepository';
+import { UploadAdapterMock, createNativeFileMock, NativeFileReaderMock } from '@ckeditor/ckeditor5-upload/tests/_utils/mocks';
+
+import { setData as setModelData, getData as getModelData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
+import { getData as getViewData, stringify as stringifyView } from '@ckeditor/ckeditor5-engine/src/dev-utils/view';
+
+import Notification from '@ckeditor/ckeditor5-ui/src/notification/notification';
+
+describe( 'ImageUploadEditing', () => {
+	// eslint-disable-next-line max-len
+	const base64Sample = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=';
+
+	let adapterMocks = [];
+	let editor, model, view, doc, fileRepository, viewDocument, nativeReaderMock, loader;
+
+	class UploadAdapterPluginMock extends Plugin {
+		init() {
+			fileRepository = this.editor.plugins.get( FileRepository );
+			fileRepository.createUploadAdapter = newLoader => {
+				loader = newLoader;
+				const adapterMock = new UploadAdapterMock( loader );
+
+				adapterMocks.push( adapterMock );
+
+				return adapterMock;
+			};
+		}
+	}
+
+	beforeEach( () => {
+		sinon.stub( window, 'FileReader' ).callsFake( () => {
+			nativeReaderMock = new NativeFileReaderMock();
+
+			return nativeReaderMock;
+		} );
+
+		return VirtualTestEditor
+			.create( {
+				plugins: [ ImageEditing, ImageUploadEditing, Paragraph, UndoEditing, UploadAdapterPluginMock, Clipboard ]
+			} )
+			.then( newEditor => {
+				editor = newEditor;
+				model = editor.model;
+				doc = model.document;
+				view = editor.editing.view;
+				viewDocument = view.document;
+
+				// Stub `view.scrollToTheSelection` as it will fail on VirtualTestEditor without DOM.
+				sinon.stub( view, 'scrollToTheSelection' ).callsFake( () => {} );
+			} );
+	} );
+
+	afterEach( () => {
+		sinon.restore();
+		adapterMocks = [];
+
+		return editor.destroy();
+	} );
+
+	it( 'should register proper schema rules', () => {
+		expect( model.schema.checkAttribute( [ '$root', 'image' ], 'uploadId' ) ).to.be.true;
+	} );
+
+	it( 'should register imageUpload command', () => {
+		expect( editor.commands.get( 'imageUpload' ) ).to.be.instanceOf( ImageUploadCommand );
+	} );
+
+	it( 'should load Clipboard plugin', () => {
+		return VirtualTestEditor
+			.create( {
+				plugins: [ ImageEditing, ImageUploadEditing, Paragraph, UndoEditing, UploadAdapterPluginMock ]
+			} )
+			.then( editor => {
+				expect( editor.plugins.get( Clipboard ) ).to.be.instanceOf( Clipboard );
+			} );
+	} );
+
+	it( 'should insert image when is pasted', () => {
+		const fileMock = createNativeFileMock();
+		const dataTransfer = new DataTransfer( { files: [ fileMock ], types: [ 'Files' ] } );
+		setModelData( model, '<paragraph>[]foo</paragraph>' );
+
+		const targetRange = model.createRange( model.createPositionAt( doc.getRoot(), 1 ), model.createPositionAt( doc.getRoot(), 1 ) );
+		const targetViewRange = editor.editing.mapper.toViewRange( targetRange );
+
+		const eventInfo = new EventInfo( viewDocument, 'clipboardInput' );
+		viewDocument.fire( eventInfo, { dataTransfer, targetRanges: [ targetViewRange ] } );
+
+		const id = fileRepository.getLoader( fileMock ).id;
+		expect( getModelData( model ) ).to.equal(
+			`<paragraph>foo</paragraph>[<image uploadId="${ id }" uploadStatus="reading"></image>]`
+		);
+		expect( eventInfo.stop.called ).to.be.true;
+	} );
+
+	it( 'should insert image at optimized position when is pasted', () => {
+		const fileMock = createNativeFileMock();
+		const dataTransfer = new DataTransfer( { files: [ fileMock ], types: [ 'Files' ] } );
+		setModelData( model, '<paragraph>[]foo</paragraph>' );
+
+		const paragraph = doc.getRoot().getChild( 0 );
+		const targetRange = model.createRange( model.createPositionAt( paragraph, 1 ), model.createPositionAt( paragraph, 1 ) ); // f[]oo
+		const targetViewRange = editor.editing.mapper.toViewRange( targetRange );
+
+		viewDocument.fire( 'clipboardInput', { dataTransfer, targetRanges: [ targetViewRange ] } );
+
+		const id = fileRepository.getLoader( fileMock ).id;
+		expect( getModelData( model ) ).to.equal(
+			`[<image uploadId="${ id }" uploadStatus="reading"></image>]<paragraph>foo</paragraph>`
+		);
+	} );
+
+	it( 'should insert multiple image files when are pasted', () => {
+		const files = [ createNativeFileMock(), createNativeFileMock() ];
+		const dataTransfer = new DataTransfer( { files, types: [ 'Files' ] } );
+		setModelData( model, '<paragraph>[]foo</paragraph>' );
+
+		const targetRange = model.createRange( model.createPositionAt( doc.getRoot(), 1 ), model.createPositionAt( doc.getRoot(), 1 ) );
+		const targetViewRange = editor.editing.mapper.toViewRange( targetRange );
+
+		viewDocument.fire( 'clipboardInput', { dataTransfer, targetRanges: [ targetViewRange ] } );
+
+		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>]`
+		);
+	} );
+
+	it( 'should insert image when is pasted on allowed position when ImageUploadCommand is disabled', () => {
+		setModelData( model, '<paragraph>foo</paragraph>[<image></image>]' );
+
+		const fileMock = createNativeFileMock();
+		const dataTransfer = new DataTransfer( { files: [ fileMock ], types: [ 'Files' ] } );
+
+		const command = editor.commands.get( 'imageUpload' );
+
+		expect( command.isEnabled ).to.be.true;
+
+		const targetRange = model.createRange( model.createPositionAt( doc.getRoot(), 0 ), model.createPositionAt( doc.getRoot(), 0 ) );
+		const targetViewRange = editor.editing.mapper.toViewRange( targetRange );
+
+		viewDocument.fire( 'clipboardInput', { dataTransfer, targetRanges: [ targetViewRange ] } );
+
+		const id = fileRepository.getLoader( fileMock ).id;
+		expect( getModelData( model ) ).to.equal(
+			`[<image uploadId="${ id }" uploadStatus="reading"></image>]<paragraph>foo</paragraph><image></image>`
+		);
+	} );
+
+	it( 'should not insert image when editor is in read-only mode', () => {
+		// Clipboard plugin is required for this test.
+		return VirtualTestEditor
+			.create( {
+				plugins: [ ImageEditing, ImageUploadEditing, Paragraph, UploadAdapterPluginMock, Clipboard ]
+			} )
+			.then( editor => {
+				const fileMock = createNativeFileMock();
+				const dataTransfer = new DataTransfer( { files: [ fileMock ], types: [ 'Files' ] } );
+				setModelData( editor.model, '<paragraph>[]foo</paragraph>' );
+
+				const targetRange = editor.model.document.selection.getFirstRange();
+				const targetViewRange = editor.editing.mapper.toViewRange( targetRange );
+
+				editor.isReadOnly = true;
+
+				editor.editing.view.document.fire( 'clipboardInput', { dataTransfer, targetRanges: [ targetViewRange ] } );
+
+				expect( getModelData( editor.model ) ).to.equal( '<paragraph>[]foo</paragraph>' );
+
+				return editor.destroy();
+			} );
+	} );
+
+	it( 'should not insert image when file is not an image', () => {
+		const viewDocument = editor.editing.view.document;
+		const fileMock = {
+			type: 'media/mp3',
+			size: 1024
+		};
+		const dataTransfer = new DataTransfer( {
+			files: [ fileMock ],
+			types: [ 'Files' ],
+			getData: () => ''
+		} );
+
+		setModelData( model, '<paragraph>foo[]</paragraph>' );
+
+		const targetRange = doc.selection.getFirstRange();
+		const targetViewRange = editor.editing.mapper.toViewRange( targetRange );
+
+		const eventInfo = new EventInfo( viewDocument, 'clipboardInput' );
+		viewDocument.fire( eventInfo, { dataTransfer, targetRanges: [ targetViewRange ] } );
+
+		expect( getModelData( model ) ).to.equal( '<paragraph>foo[]</paragraph>' );
+		expect( eventInfo.stop.called ).to.be.undefined;
+	} );
+
+	it( 'should not insert image when file is not an configured image type', () => {
+		const viewDocument = editor.editing.view.document;
+		const fileMock = {
+			type: 'image/svg+xml',
+			size: 1024
+		};
+		const dataTransfer = new DataTransfer( {
+			files: [ fileMock ],
+			types: [ 'Files' ],
+			getData: () => ''
+		} );
+
+		setModelData( model, '<paragraph>foo[]</paragraph>' );
+
+		const targetRange = doc.selection.getFirstRange();
+		const targetViewRange = editor.editing.mapper.toViewRange( targetRange );
+
+		const eventInfo = new EventInfo( viewDocument, 'clipboardInput' );
+		viewDocument.fire( eventInfo, { dataTransfer, targetRanges: [ targetViewRange ] } );
+
+		expect( getModelData( model ) ).to.equal( '<paragraph>foo[]</paragraph>' );
+		expect( eventInfo.stop.called ).to.be.undefined;
+	} );
+
+	it( 'should not insert image when file is null', () => {
+		const viewDocument = editor.editing.view.document;
+		const dataTransfer = new DataTransfer( { files: [ null ], types: [ 'Files' ], getData: () => null } );
+
+		setModelData( model, '<paragraph>foo[]</paragraph>' );
+
+		const targetRange = doc.selection.getFirstRange();
+		const targetViewRange = editor.editing.mapper.toViewRange( targetRange );
+
+		viewDocument.fire( 'clipboardInput', { dataTransfer, targetRanges: [ targetViewRange ] } );
+
+		expect( getModelData( model ) ).to.equal( '<paragraph>foo[]</paragraph>' );
+	} );
+
+	it( 'should not insert image when there is non-empty HTML content pasted', () => {
+		const fileMock = createNativeFileMock();
+		const dataTransfer = new DataTransfer( {
+			files: [ fileMock ],
+			types: [ 'Files', 'text/html' ],
+			getData: type => type === 'text/html' ? '<p>SomeData</p>' : ''
+		} );
+		setModelData( model, '<paragraph>[]foo</paragraph>' );
+
+		const targetRange = model.createRange( model.createPositionAt( doc.getRoot(), 1 ), model.createPositionAt( doc.getRoot(), 1 ) );
+		const targetViewRange = editor.editing.mapper.toViewRange( targetRange );
+
+		viewDocument.fire( 'clipboardInput', { dataTransfer, targetRanges: [ targetViewRange ] } );
+
+		expect( getModelData( model ) ).to.equal( '<paragraph>SomeData[]foo</paragraph>' );
+	} );
+
+	it( 'should not insert image nor crash when pasted image could not be inserted', () => {
+		model.schema.register( 'other', {
+			allowIn: '$root',
+			isLimit: true
+		} );
+		model.schema.extend( '$text', { allowIn: 'other' } );
+
+		editor.conversion.elementToElement( { model: 'other', view: 'p' } );
+
+		setModelData( model, '<other>[]</other>' );
+
+		const fileMock = createNativeFileMock();
+		const dataTransfer = new DataTransfer( { files: [ fileMock ], types: [ 'Files' ] } );
+
+		const targetRange = doc.selection.getFirstRange();
+		const targetViewRange = editor.editing.mapper.toViewRange( targetRange );
+
+		viewDocument.fire( 'clipboardInput', { dataTransfer, targetRanges: [ targetViewRange ] } );
+
+		expect( getModelData( model ) ).to.equal( '<other>[]</other>' );
+	} );
+
+	it( 'should not throw when upload adapter is not set (FileRepository will log an warn anyway) when image is pasted', () => {
+		const fileMock = createNativeFileMock();
+		const dataTransfer = new DataTransfer( { files: [ fileMock ], types: [ 'Files' ] } );
+		const consoleWarnStub = sinon.stub( console, 'warn' );
+
+		setModelData( model, '<paragraph>[]foo</paragraph>' );
+
+		fileRepository.createUploadAdapter = undefined;
+
+		const targetRange = model.createRange( model.createPositionAt( doc.getRoot(), 1 ), model.createPositionAt( doc.getRoot(), 1 ) );
+		const targetViewRange = editor.editing.mapper.toViewRange( targetRange );
+
+		expect( () => {
+			viewDocument.fire( 'clipboardInput', { dataTransfer, targetRanges: [ targetViewRange ] } );
+		} ).to.not.throw();
+
+		expect( getModelData( model ) ).to.equal( '<paragraph>foo[]</paragraph>' );
+		sinon.assert.calledOnce( consoleWarnStub );
+	} );
+
+	// https://github.com/ckeditor/ckeditor5-upload/issues/70
+	it( 'should not crash on browsers which do not implement DOMStringList as a child class of an Array', () => {
+		const typesDomStringListMock = {
+			length: 2,
+			'0': 'text/html',
+			'1': 'text/plain'
+		};
+		const dataTransfer = new DataTransfer( {
+			types: typesDomStringListMock,
+			getData: type => type === 'text/html' ? '<p>SomeData</p>' : 'SomeData'
+		} );
+		setModelData( model, '<paragraph>[]foo</paragraph>' );
+
+		const targetRange = doc.selection.getFirstRange();
+		const targetViewRange = editor.editing.mapper.toViewRange( targetRange );
+
+		viewDocument.fire( 'clipboardInput', { dataTransfer, targetRanges: [ targetViewRange ] } );
+
+		// Well, there's no clipboard plugin, so nothing happens.
+		expect( getModelData( model ) ).to.equal( '<paragraph>SomeData[]foo</paragraph>' );
+	} );
+
+	it( 'should not convert image\'s uploadId attribute if is consumed already', () => {
+		editor.editing.downcastDispatcher.on( 'attribute:uploadId:image', ( evt, data, conversionApi ) => {
+			conversionApi.consumable.consume( data.item, evt.name );
+		}, { priority: 'high' } );
+
+		setModelData( model, '<image uploadId="1234"></image>' );
+
+		expect( getViewData( view ) ).to.equal(
+			'[<figure class="ck-widget image" contenteditable="false">' +
+			'<img></img>' +
+			'</figure>]' );
+	} );
+
+	it( 'should not use read data once it is present', done => {
+		const file = createNativeFileMock();
+		setModelData( model, '<paragraph>{}foo bar</paragraph>' );
+		editor.execute( 'imageUpload', { file } );
+
+		model.document.once( 'change', () => {
+			tryExpect( done, () => {
+				expect( getViewData( view ) ).to.equal(
+					'[<figure class="ck-widget image" contenteditable="false">' +
+						// Rendering the image data is left to a upload progress converter.
+						'<img></img>' +
+						'</figure>]' +
+					'<p>foo bar</p>'
+				);
+
+				expect( loader.status ).to.equal( 'uploading' );
+			} );
+		} );
+
+		expect( loader.status ).to.equal( 'reading' );
+
+		loader.file.then( () => nativeReaderMock.mockSuccess( base64Sample ) );
+	} );
+
+	it( 'should replace read data with server response once it is present', done => {
+		const file = createNativeFileMock();
+		setModelData( model, '<paragraph>{}foo bar</paragraph>' );
+		editor.execute( 'imageUpload', { file } );
+
+		model.document.once( 'change', () => {
+			model.document.once( 'change', () => {
+				tryExpect( done, () => {
+					expect( getViewData( view ) ).to.equal(
+						'[<figure class="ck-widget image" contenteditable="false"><img src="image.png"></img></figure>]<p>foo bar</p>'
+					);
+					expect( loader.status ).to.equal( 'idle' );
+				} );
+			}, { priority: 'lowest' } );
+
+			loader.file.then( () => adapterMocks[ 0 ].mockSuccess( { default: 'image.png' } ) );
+		} );
+
+		loader.file.then( () => nativeReaderMock.mockSuccess( base64Sample ) );
+	} );
+
+	it( 'should fire notification event in case of error', done => {
+		const notification = editor.plugins.get( Notification );
+		const file = createNativeFileMock();
+
+		notification.on( 'show:warning', ( evt, data ) => {
+			tryExpect( done, () => {
+				expect( data.message ).to.equal( 'Reading error.' );
+				expect( data.title ).to.equal( 'Upload failed' );
+				evt.stop();
+			} );
+		}, { priority: 'high' } );
+
+		setModelData( model, '<paragraph>{}foo bar</paragraph>' );
+		editor.execute( 'imageUpload', { file } );
+
+		loader.file.then( () => nativeReaderMock.mockError( 'Reading error.' ) );
+	} );
+
+	it( 'should not fire notification on abort', done => {
+		const notification = editor.plugins.get( Notification );
+		const file = createNativeFileMock();
+		const spy = sinon.spy();
+
+		notification.on( 'show:warning', evt => {
+			spy();
+			evt.stop();
+		}, { priority: 'high' } );
+
+		setModelData( model, '<paragraph>{}foo bar</paragraph>' );
+		editor.execute( 'imageUpload', { file } );
+
+		loader.file.then( () => {
+			nativeReaderMock.abort();
+
+			setTimeout( () => {
+				sinon.assert.notCalled( spy );
+				done();
+			}, 0 );
+		} );
+	} );
+
+	it( 'should throw when other error happens during upload', done => {
+		const file = createNativeFileMock();
+		const error = new Error( 'Foo bar baz' );
+		const uploadEditing = editor.plugins.get( ImageUploadEditing );
+		const loadSpy = sinon.spy( uploadEditing, '_readAndUpload' );
+		const catchSpy = sinon.spy();
+
+		// Throw an error when async attribute change occur.
+		editor.editing.downcastDispatcher.on( 'attribute:uploadStatus:image', ( evt, data ) => {
+			if ( data.attributeNewValue == 'uploading' ) {
+				throw error;
+			}
+		} );
+
+		setModelData( model, '<paragraph>{}foo bar</paragraph>' );
+		editor.execute( 'imageUpload', { file } );
+
+		sinon.assert.calledOnce( loadSpy );
+
+		const promise = loadSpy.returnValues[ 0 ];
+
+		// Check if error can be caught.
+		promise.catch( catchSpy );
+
+		loader.file.then( () => {
+			nativeReaderMock.mockSuccess();
+
+			setTimeout( () => {
+				sinon.assert.calledOnce( catchSpy );
+				const error = catchSpy.getCall( 0 ).args[ 0 ];
+
+				expect( error ).to.be.instanceOf( Error );
+				expect( error ).to.haveOwnProperty( 'message', 'Foo bar baz' );
+
+				done();
+			}, 0 );
+		} );
+	} );
+
+	it( 'should do nothing if image does not have uploadId', () => {
+		setModelData( model, '<image src="image.png"></image>' );
+
+		expect( getViewData( view ) ).to.equal(
+			'[<figure class="ck-widget image" contenteditable="false"><img src="image.png"></img></figure>]'
+		);
+	} );
+
+	it( 'should remove image in case of upload error', done => {
+		const file = createNativeFileMock();
+		const spy = sinon.spy();
+		const notification = editor.plugins.get( Notification );
+		setModelData( model, '<paragraph>{}foo bar</paragraph>' );
+
+		notification.on( 'show:warning', evt => {
+			spy();
+			evt.stop();
+		}, { priority: 'high' } );
+
+		editor.execute( 'imageUpload', { file } );
+
+		model.document.once( 'change', () => {
+			model.document.once( 'change', () => {
+				tryExpect( done, () => {
+					expect( getModelData( model ) ).to.equal( '<paragraph>[]foo bar</paragraph>' );
+					sinon.assert.calledOnce( spy );
+				} );
+			} );
+		} );
+
+		loader.file.then( () => nativeReaderMock.mockError( 'Upload error.' ) );
+	} );
+
+	it( 'should abort upload if image is removed', () => {
+		const file = createNativeFileMock();
+		setModelData( model, '<paragraph>{}foo bar</paragraph>' );
+		editor.execute( 'imageUpload', { file } );
+
+		const abortSpy = sinon.spy( loader, 'abort' );
+
+		expect( loader.status ).to.equal( 'reading' );
+
+		return loader.file.then( () => {
+			nativeReaderMock.mockSuccess( base64Sample );
+
+			const image = doc.getRoot().getChild( 0 );
+			model.change( writer => {
+				writer.remove( image );
+			} );
+
+			expect( loader.status ).to.equal( 'aborted' );
+			sinon.assert.calledOnce( abortSpy );
+		} );
+	} );
+
+	it( 'should not abort and not restart upload when image is moved', () => {
+		const file = createNativeFileMock();
+		setModelData( model, '<paragraph>{}foo bar</paragraph>' );
+		editor.execute( 'imageUpload', { file } );
+
+		const abortSpy = sinon.spy( loader, 'abort' );
+		const loadSpy = sinon.spy( loader, 'read' );
+
+		const image = doc.getRoot().getChild( 0 );
+
+		model.change( writer => {
+			writer.move( writer.createRangeOn( image ), writer.createPositionAt( doc.getRoot(), 2 ) );
+		} );
+
+		expect( abortSpy.called ).to.be.false;
+		expect( loadSpy.called ).to.be.false;
+	} );
+
+	it( 'image should be permanently removed if it is removed by user during upload', done => {
+		const file = createNativeFileMock();
+		const notification = editor.plugins.get( Notification );
+		setModelData( model, '<paragraph>{}foo bar</paragraph>' );
+
+		// Prevent popping up alert window.
+		notification.on( 'show:warning', evt => {
+			evt.stop();
+		}, { priority: 'high' } );
+
+		editor.execute( 'imageUpload', { file } );
+
+		const stub = sinon.stub();
+		model.document.on( 'change', stub );
+
+		// The first `change` event is fired after the "manual" remove.
+		// The second `change` event is fired after cleaning attributes.
+		stub.onSecondCall().callsFake( () => {
+			expect( getModelData( model ) ).to.equal( '<paragraph>[]foo bar</paragraph>' );
+
+			editor.execute( 'undo' );
+
+			// Expect that the image has not been brought back.
+			expect( getModelData( model ) ).to.equal( '<paragraph>[]foo bar</paragraph>' );
+
+			done();
+		} );
+
+		const image = doc.getRoot().getChild( 0 );
+
+		model.change( writer => {
+			writer.remove( image );
+		} );
+	} );
+
+	it( 'should create responsive image if server return multiple images', done => {
+		const file = createNativeFileMock();
+		setModelData( model, '<paragraph>{}foo bar</paragraph>' );
+		editor.execute( 'imageUpload', { file } );
+
+		model.document.once( 'change', () => {
+			model.document.once( 'change', () => {
+				tryExpect( done, () => {
+					expect( getViewData( view ) ).to.equal(
+						'[<figure class="ck-widget image" contenteditable="false">' +
+						'<img sizes="100vw" src="image.png" srcset="image-500.png 500w, image-800.png 800w" width="800"></img>' +
+						'</figure>]<p>foo bar</p>'
+					);
+					expect( loader.status ).to.equal( 'idle' );
+				} );
+			}, { priority: 'lowest' } );
+
+			loader.file.then( () => adapterMocks[ 0 ].mockSuccess( { default: 'image.png', 500: 'image-500.png', 800: 'image-800.png' } ) );
+		} );
+
+		loader.file.then( () => nativeReaderMock.mockSuccess( base64Sample ) );
+	} );
+
+	it( 'should prevent from browser redirecting when an image is dropped on another image', () => {
+		const spy = sinon.spy();
+
+		editor.editing.view.document.fire( 'dragover', {
+			preventDefault: spy
+		} );
+
+		expect( spy.calledOnce ).to.equal( true );
+	} );
+
+	it( 'should upload image with base64 src', done => {
+		setModelData( model, '<paragraph>[]foo</paragraph>' );
+
+		const clipboardHtml = `<p>bar</p><img src=${ base64Sample } />`;
+		const dataTransfer = mockDataTransfer( clipboardHtml );
+
+		const targetRange = model.createRange( model.createPositionAt( doc.getRoot(), 1 ), model.createPositionAt( doc.getRoot(), 1 ) );
+		const targetViewRange = editor.editing.mapper.toViewRange( targetRange );
+
+		viewDocument.fire( 'clipboardInput', { dataTransfer, targetRanges: [ targetViewRange ] } );
+
+		const id = adapterMocks[ 0 ].loader.id;
+		const expected = '<paragraph>bar</paragraph>' +
+			`[<image src="" uploadId="${ id }" uploadStatus="reading"></image>]` +
+			'<paragraph>foo</paragraph>';
+
+		expectModel( done, getModelData( model ), expected );
+	} );
+
+	it( 'should upload image with blob src', done => {
+		setModelData( model, '<paragraph>[]foo</paragraph>' );
+
+		const clipboardHtml = `<img src=${ base64ToBlobUrl( base64Sample ) } />`;
+		const dataTransfer = mockDataTransfer( clipboardHtml );
+
+		const targetRange = model.createRange( model.createPositionAt( doc.getRoot(), 1 ), model.createPositionAt( doc.getRoot(), 1 ) );
+		const targetViewRange = editor.editing.mapper.toViewRange( targetRange );
+
+		viewDocument.fire( 'clipboardInput', { dataTransfer, targetRanges: [ targetViewRange ] } );
+
+		const id = adapterMocks[ 0 ].loader.id;
+		const expected = `[<image src="" uploadId="${ id }" uploadStatus="reading"></image>]` +
+			'<paragraph>foo</paragraph>';
+
+		expectModel( done, getModelData( model ), expected );
+	} );
+
+	it( 'should not upload image if no loader available', done => {
+		sinon.stub( fileRepository, 'createLoader' ).callsFake( () => null );
+
+		setModelData( model, '<paragraph>[]foo</paragraph>' );
+
+		const clipboardHtml = `<img src=${ base64Sample } />`;
+		const dataTransfer = mockDataTransfer( clipboardHtml );
+
+		const targetRange = model.createRange( model.createPositionAt( doc.getRoot(), 1 ), model.createPositionAt( doc.getRoot(), 1 ) );
+		const targetViewRange = editor.editing.mapper.toViewRange( targetRange );
+
+		viewDocument.fire( 'clipboardInput', { dataTransfer, targetRanges: [ targetViewRange ] } );
+
+		const expected = `[<image src="${ base64Sample }"></image>]<paragraph>foo</paragraph>`;
+
+		expectModel( done, getModelData( model ), expected );
+	} );
+
+	it( 'should not upload and remove image if fetch failed', done => {
+		const notification = editor.plugins.get( Notification );
+
+		// Prevent popping up alert window.
+		notification.on( 'show:warning', evt => {
+			evt.stop();
+		}, { priority: 'high' } );
+
+		setModelData( model, '<paragraph>[]foo</paragraph>' );
+
+		const clipboardHtml = `<img src=${ base64Sample } />`;
+		const dataTransfer = mockDataTransfer( clipboardHtml );
+
+		const targetRange = model.createRange( model.createPositionAt( doc.getRoot(), 1 ), model.createPositionAt( doc.getRoot(), 1 ) );
+		const targetViewRange = editor.editing.mapper.toViewRange( targetRange );
+
+		// Stub `fetch` so it can be rejected.
+		sinon.stub( window, 'fetch' ).callsFake( () => {
+			return new Promise( ( res, rej ) => rej( 'could not fetch' ) );
+		} );
+
+		let content = null;
+		editor.plugins.get( 'Clipboard' ).on( 'inputTransformation', ( evt, data ) => {
+			content = data.content;
+		} );
+
+		viewDocument.fire( 'clipboardInput', { dataTransfer, targetRanges: [ targetViewRange ] } );
+
+		expectData(
+			'<img src="" uploadId="#loader1_id" uploadProcessed="true"></img>',
+			'[<image src="" uploadId="#loader1_id" uploadStatus="reading"></image>]<paragraph>foo</paragraph>',
+			'<paragraph>[]foo</paragraph>',
+			content,
+			done,
+			false
+		);
+	} );
+
+	it( 'should upload only images which were successfully fetched and remove failed ones', done => {
+		const notification = editor.plugins.get( Notification );
+
+		// Prevent popping up alert window.
+		notification.on( 'show:warning', evt => {
+			evt.stop();
+		}, { priority: 'high' } );
+
+		const expectedModel = '<paragraph>bar</paragraph>' +
+			'<image src="" uploadId="#loader1_id" uploadStatus="reading"></image>' +
+			'<image src="" uploadId="#loader2_id" uploadStatus="reading"></image>' +
+			'[<image src="" uploadId="#loader3_id" uploadStatus="reading"></image>]' +
+			'<paragraph>foo</paragraph>';
+		const expectedFinalModel = '<paragraph>bar</paragraph>' +
+			'<image src="" uploadId="#loader1_id" uploadStatus="reading"></image>' +
+			'[<image src="" uploadId="#loader2_id" uploadStatus="reading"></image>]' +
+			'<paragraph>foo</paragraph>';
+
+		setModelData( model, '<paragraph>[]foo</paragraph>' );
+
+		const clipboardHtml = `<p>bar</p><img src=${ base64Sample } />` +
+			`<img src=${ base64ToBlobUrl( base64Sample ) } /><img src=${ base64Sample } />`;
+		const dataTransfer = mockDataTransfer( clipboardHtml );
+
+		const targetRange = model.createRange( model.createPositionAt( doc.getRoot(), 1 ), model.createPositionAt( doc.getRoot(), 1 ) );
+		const targetViewRange = editor.editing.mapper.toViewRange( targetRange );
+
+		// Stub `fetch` in a way that 2 first calls are successful and 3rd fails.
+		let counter = 0;
+		const fetch = window.fetch;
+		sinon.stub( window, 'fetch' ).callsFake( src => {
+			counter++;
+			if ( counter < 3 ) {
+				return fetch( src );
+			} else {
+				return Promise.reject();
+			}
+		} );
+
+		let content = null;
+		editor.plugins.get( 'Clipboard' ).on( 'inputTransformation', ( evt, data ) => {
+			content = data.content;
+		} );
+
+		viewDocument.fire( 'clipboardInput', { dataTransfer, targetRanges: [ targetViewRange ] } );
+
+		expectData(
+			'',
+			expectedModel,
+			expectedFinalModel,
+			content,
+			done
+		);
+	} );
+
+	it( 'should not upload and remove image when `File` constructor is not present', done => {
+		const fileFn = window.File;
+
+		window.File = undefined;
+
+		const notification = editor.plugins.get( Notification );
+
+		// Prevent popping up alert window.
+		notification.on( 'show:warning', evt => {
+			evt.stop();
+		}, { priority: 'high' } );
+
+		setModelData( model, '<paragraph>[]foo</paragraph>' );
+
+		const clipboardHtml = `<img src=${ base64ToBlobUrl( base64Sample ) } /><p>baz</p>`;
+		const dataTransfer = mockDataTransfer( clipboardHtml );
+
+		const targetRange = model.createRange( model.createPositionAt( doc.getRoot(), 1 ), model.createPositionAt( doc.getRoot(), 1 ) );
+		const targetViewRange = editor.editing.mapper.toViewRange( targetRange );
+
+		let content = null;
+		editor.plugins.get( 'Clipboard' ).on( 'inputTransformation', ( evt, data ) => {
+			content = data.content;
+		} );
+
+		viewDocument.fire( 'clipboardInput', { dataTransfer, targetRanges: [ targetViewRange ] } );
+
+		expectData(
+			'<img src="" uploadId="#loader1_id" uploadProcessed="true"></img><p>baz</p>',
+			'<image src="" uploadId="#loader1_id" uploadStatus="reading"></image><paragraph>baz[]foo</paragraph>',
+			'<paragraph>baz[]foo</paragraph>',
+			content,
+			err => {
+				window.File = fileFn;
+				done( err );
+			},
+			false
+		);
+	} );
+
+	it( 'should not upload and remove image when `File` constructor is not supported', done => {
+		sinon.stub( window, 'File' ).throws( 'Function expected.' );
+
+		const notification = editor.plugins.get( Notification );
+
+		// Prevent popping up alert window.
+		notification.on( 'show:warning', evt => {
+			evt.stop();
+		}, { priority: 'high' } );
+
+		setModelData( model, '<paragraph>[]foo</paragraph>' );
+
+		const clipboardHtml = `<p>baz</p><img src=${ base64ToBlobUrl( base64Sample ) } />`;
+		const dataTransfer = mockDataTransfer( clipboardHtml );
+
+		const targetRange = model.createRange( model.createPositionAt( doc.getRoot(), 1 ), model.createPositionAt( doc.getRoot(), 1 ) );
+		const targetViewRange = editor.editing.mapper.toViewRange( targetRange );
+
+		let content = null;
+		editor.plugins.get( 'Clipboard' ).on( 'inputTransformation', ( evt, data ) => {
+			content = data.content;
+		} );
+
+		viewDocument.fire( 'clipboardInput', { dataTransfer, targetRanges: [ targetViewRange ] } );
+
+		expectData(
+			'<p>baz</p><img src="" uploadId="#loader1_id" uploadProcessed="true"></img>',
+			'<paragraph>baz</paragraph>[<image src="" uploadId="#loader1_id" uploadStatus="reading"></image>]<paragraph>foo</paragraph>',
+			'<paragraph>baz[]</paragraph><paragraph>foo</paragraph>',
+			content,
+			done,
+			false
+		);
+	} );
+
+	it( 'should get file extension from base64 string', done => {
+		setModelData( model, '<paragraph>[]foo</paragraph>' );
+
+		const clipboardHtml = `<img src=${ base64Sample } />`;
+		const dataTransfer = mockDataTransfer( clipboardHtml );
+
+		const targetRange = model.createRange( model.createPositionAt( doc.getRoot(), 1 ), model.createPositionAt( doc.getRoot(), 1 ) );
+		const targetViewRange = editor.editing.mapper.toViewRange( targetRange );
+
+		// Stub `fetch` to return custom blob without type.
+		sinon.stub( window, 'fetch' ).callsFake( () => {
+			return new Promise( res => res( {
+				blob() {
+					return new Promise( res => res( new Blob( [ 'foo', 'bar' ] ) ) );
+				}
+			} ) );
+		} );
+
+		viewDocument.fire( 'clipboardInput', { dataTransfer, targetRanges: [ targetViewRange ] } );
+
+		tryExpect( done, () => {
+			loader.file.then( file => expect( file.name.split( '.' ).pop() ).to.equal( 'png' ) );
+		} );
+	} );
+
+	it( 'should use fallback file extension', done => {
+		setModelData( model, '<paragraph>[]foo</paragraph>' );
+
+		const clipboardHtml = `<img src=${ base64ToBlobUrl( base64Sample ) } />`;
+		const dataTransfer = mockDataTransfer( clipboardHtml );
+
+		const targetRange = model.createRange( model.createPositionAt( doc.getRoot(), 1 ), model.createPositionAt( doc.getRoot(), 1 ) );
+		const targetViewRange = editor.editing.mapper.toViewRange( targetRange );
+
+		// Stub `fetch` to return custom blob without type.
+		sinon.stub( window, 'fetch' ).callsFake( () => {
+			return new Promise( res => res( {
+				blob() {
+					return new Promise( res => res( new Blob( [ 'foo', 'bar' ] ) ) );
+				}
+			} ) );
+		} );
+
+		viewDocument.fire( 'clipboardInput', { dataTransfer, targetRanges: [ targetViewRange ] } );
+
+		tryExpect( done, () => {
+			loader.file.then( file => expect( file.name.split( '.' ).pop() ).to.equal( 'jpeg' ) );
+		} );
+	} );
+
+	it( 'should not show notification when file loader failed with no error', done => {
+		const notification = editor.plugins.get( Notification );
+
+		let notificationsCount = 0;
+		// Prevent popping up alert window.
+		notification.on( 'show:warning', evt => {
+			notificationsCount++;
+			evt.stop();
+		}, { priority: 'high' } );
+
+		setModelData( model, '<paragraph>[]foo</paragraph>' );
+
+		const clipboardHtml = `<img src=${ base64Sample } />`;
+		const dataTransfer = mockDataTransfer( clipboardHtml );
+
+		const targetRange = model.createRange( model.createPositionAt( doc.getRoot(), 1 ), model.createPositionAt( doc.getRoot(), 1 ) );
+		const targetViewRange = editor.editing.mapper.toViewRange( targetRange );
+
+		// Stub `fetch` in a way that it always fails.
+		sinon.stub( window, 'fetch' ).callsFake( () => Promise.reject() );
+
+		viewDocument.fire( 'clipboardInput', { dataTransfer, targetRanges: [ targetViewRange ] } );
+
+		adapterMocks[ 0 ].loader.file.then( () => {
+			expect.fail( 'Promise should be rejected.' );
+		} ).catch( () => {
+			// Deffer so the promise could be resolved.
+			setTimeout( () => {
+				expect( notificationsCount ).to.equal( 0 );
+				done();
+			} );
+		} );
+	} );
+
+	// Helper for validating clipboard and model data as a result of a paste operation. This function checks both clipboard
+	// data and model data synchronously (`expectedClipboardData`, `expectedModel`) and then the model data after `loader.file`
+	// promise is resolved (so model state after successful/failed file fetch attempt).
+	//
+	// @param {String} expectedClipboardData Expected clipboard data on `inputTransformation` event.
+	// @param {String} expectedModel Expected model data on `inputTransformation` event.
+	// @param {String} expectedModelOnFile Expected model data after all `file.loader` promises are fetched.
+	// @param {DocumentFragment} content Content processed in inputTransformation
+	// @param {Function} doneFn Callback function to be called when all assertions are done or error occures.
+	// @param {Boolean} [onSuccess=true] If `expectedModelOnFile` data should be validated
+	// on `loader.file` a promise successful resolution or promise rejection.
+	function expectData( expectedClipboardData, expectedModel, expectedModelOnFile, content, doneFn, onSuccess ) {
+		const clipboardData = injectLoaderId( expectedClipboardData || '', adapterMocks );
+		const modelData = injectLoaderId( expectedModel, adapterMocks );
+		const finalModelData = injectLoaderId( expectedModelOnFile, adapterMocks );
+
+		if ( clipboardData.length ) {
+			expect( stringifyView( content ) ).to.equal( clipboardData );
+		}
+		expect( getModelData( model ) ).to.equal( modelData );
+
+		if ( onSuccess !== false ) {
+			adapterMocks[ 0 ].loader.file.then( () => {
+				// Deffer so the promise could be resolved.
+				setTimeout( () => {
+					expectModel( doneFn, getModelData( model ), finalModelData );
+				} );
+			} );
+		} else {
+			adapterMocks[ 0 ].loader.file.then( () => {
+				expect.fail( 'The `loader.file` should be rejected.' );
+			} ).catch( () => {
+				// Deffer so the promise could be resolved.
+				setTimeout( () => {
+					expectModel( doneFn, getModelData( model ), finalModelData );
+				} );
+			} );
+		}
+	}
+} );
+
+// Replaces '#loaderX_id' parameter in the given string with a loader id. It is used
+// so data string could be created before loader is initialized.
+//
+// @param {String} data String which have 'loader params' replaced.
+// @param {Array.<UploadAdapterMock>} adapters Adapters list. Each adapter holds a reference to a loader which id is used.
+// @returns {String} Data string with 'loader params' replaced.
+function injectLoaderId( data, adapters ) {
+	let newData = data;
+
+	if ( newData.includes( '#loader1_id' ) ) {
+		newData = newData.replace( '#loader1_id', adapters[ 0 ].loader.id );
+	}
+	if ( newData.includes( '#loader2_id' ) ) {
+		newData = newData.replace( '#loader2_id', adapters[ 1 ].loader.id );
+	}
+	if ( newData.includes( '#loader3_id' ) ) {
+		newData = newData.replace( '#loader3_id', adapters[ 2 ].loader.id );
+	}
+
+	return newData;
+}
+
+// Asserts actual and expected model data.
+//
+// @param {function} done Callback function to be called when assertion is done.
+// @param {String} actual Actual model data.
+// @param {String} expected Expected model data.
+function expectModel( done, actual, expected ) {
+	tryExpect( done, () => {
+		expect( actual ).to.equal( expected );
+	} );
+}
+
+// Runs given expect function in a try-catch. It should be used only when `expect` is called as a result of a `Promise`
+// resolution as all errors may be caught by tested code and needs to be rethrow to be correctly processed by a testing framework.
+//
+// @param {Function} doneFn Function to run when assertion is done.
+// @param {Function} expectFn Function containing all assertions.
+function tryExpect( doneFn, expectFn ) {
+	try {
+		expectFn();
+		doneFn();
+	} catch ( err ) {
+		doneFn( err );
+	}
+}
+
+// Creates data transfer object with predefined data.
+//
+// @param {String} content The content returned as `text/html` when queried.
+// @returns {module:clipboard/datatransfer~DataTransfer} DataTransfer object.
+function mockDataTransfer( content ) {
+	return new DataTransfer( {
+		types: [ 'text/html' ],
+		getData: type => type === 'text/html' ? content : ''
+	} );
+}
+
+// Creates blob url from the given base64 data.
+//
+// @param {String} base64 The base64 string from which blob url will be generated.
+// @returns {String} Blob url.
+function base64ToBlobUrl( base64 ) {
+	return URL.createObjectURL( base64ToBlob( base64.trim() ) );
+}
+
+// Transforms base64 data into a blob object.
+//
+// @param {String} The base64 data to be transformed.
+// @returns {Blob} Blob object representing given base64 data.
+function base64ToBlob( base64Data ) {
+	const [ type, data ] = base64Data.split( ',' );
+	const byteCharacters = atob( data );
+	const byteArrays = [];
+
+	for ( let offset = 0; offset < byteCharacters.length; offset += 512 ) {
+		const slice = byteCharacters.slice( offset, offset + 512 );
+		const byteNumbers = new Array( slice.length );
+
+		for ( let i = 0; i < slice.length; i++ ) {
+			byteNumbers[ i ] = slice.charCodeAt( i );
+		}
+
+		byteArrays.push( new Uint8Array( byteNumbers ) );
+	}
+
+	return new Blob( byteArrays, { type } );
+}

+ 332 - 0
packages/ckeditor5-image/tests/imageinsert/imageinsertprogress.js

@@ -0,0 +1,332 @@
+/**
+ * @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 window */
+
+import VirtualTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/virtualtesteditor';
+
+import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
+import ImageEditing from '../../src/image/imageediting';
+import ImageUploadEditing from '../../src/imageinsert/imageinsertediting';
+import ImageUploadProgress from '../../src/imageinsert/imageinsertprogress';
+import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
+import FileRepository from '@ckeditor/ckeditor5-upload/src/filerepository';
+import Clipboard from '@ckeditor/ckeditor5-clipboard/src/clipboard';
+
+import { createNativeFileMock, NativeFileReaderMock, UploadAdapterMock } from '@ckeditor/ckeditor5-upload/tests/_utils/mocks';
+import { setData as setModelData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
+import { getData as getViewData } from '@ckeditor/ckeditor5-engine/src/dev-utils/view';
+import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils';
+import svgPlaceholder from '../../theme/icons/image_placeholder.svg';
+
+describe( 'ImageUploadProgress', () => {
+	const imagePlaceholder = encodeURIComponent( svgPlaceholder );
+
+	// eslint-disable-next-line max-len
+	const base64Sample = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=';
+	let editor, model, doc, fileRepository, view, nativeReaderMock, loader, adapterMock;
+
+	class UploadAdapterPluginMock extends Plugin {
+		init() {
+			fileRepository = this.editor.plugins.get( FileRepository );
+			fileRepository.createUploadAdapter = newLoader => {
+				loader = newLoader;
+				adapterMock = new UploadAdapterMock( loader );
+
+				return adapterMock;
+			};
+		}
+	}
+
+	testUtils.createSinonSandbox();
+
+	beforeEach( () => {
+		testUtils.sinon.stub( window, 'FileReader' ).callsFake( () => {
+			nativeReaderMock = new NativeFileReaderMock();
+
+			return nativeReaderMock;
+		} );
+
+		return VirtualTestEditor
+			.create( {
+				plugins: [ ImageEditing, Paragraph, ImageUploadEditing, ImageUploadProgress, UploadAdapterPluginMock, Clipboard ]
+			} )
+			.then( newEditor => {
+				editor = newEditor;
+				model = editor.model;
+				doc = model.document;
+				view = editor.editing.view;
+
+				fileRepository = editor.plugins.get( FileRepository );
+				fileRepository.createUploadAdapter = newLoader => {
+					loader = newLoader;
+					adapterMock = new UploadAdapterMock( loader );
+
+					return adapterMock;
+				};
+			} );
+	} );
+
+	it( 'should convert image\'s "reading" uploadStatus attribute', () => {
+		setModelData( model, '<paragraph>[]foo</paragraph>' );
+		editor.execute( 'imageUpload', { file: createNativeFileMock() } );
+
+		expect( getViewData( view ) ).to.equal(
+			'[<figure class="ck-appear ck-image-upload-placeholder ck-widget image" contenteditable="false">' +
+				`<img src="data:image/svg+xml;utf8,${ imagePlaceholder }"></img>` +
+				'<div class="ck-upload-placeholder-loader"></div>' +
+			'</figure>]<p>foo</p>'
+		);
+	} );
+
+	it( 'should convert image\'s "uploading" uploadStatus attribute', done => {
+		setModelData( model, '<paragraph>[]foo</paragraph>' );
+		editor.execute( 'imageUpload', { file: createNativeFileMock() } );
+
+		model.document.once( 'change', () => {
+			try {
+				expect( getViewData( view ) ).to.equal(
+					'[<figure class="ck-appear ck-widget image" contenteditable="false">' +
+					`<img src="${ base64Sample }"></img>` +
+					'<div class="ck-progress-bar"></div>' +
+					'</figure>]<p>foo</p>'
+				);
+
+				done();
+			} catch ( err ) {
+				done( err );
+			}
+		}, { priority: 'lowest' } );
+
+		loader.file.then( () => nativeReaderMock.mockSuccess( base64Sample ) );
+	} );
+
+	// See https://github.com/ckeditor/ckeditor5/issues/1985.
+	it( 'should work if image parent is refreshed by the differ', function( done ) {
+		model.schema.register( 'outerBlock', {
+			allowWhere: '$block',
+			isBlock: true
+		} );
+
+		model.schema.register( 'innerBlock', {
+			allowIn: 'outerBlock',
+			isLimit: true
+		} );
+
+		model.schema.extend( '$block', { allowIn: 'innerBlock' } );
+		editor.conversion.elementToElement( { model: 'outerBlock', view: 'outerBlock' } );
+		editor.conversion.elementToElement( { model: 'innerBlock', view: 'innerBlock' } );
+
+		model.document.registerPostFixer( () => {
+			for ( const change of doc.differ.getChanges() ) {
+				// The differ.refreshItem() simulates remove and insert of and image parent thus preventing image from proper work.
+				if ( change.type == 'insert' && change.name == 'image' ) {
+					doc.differ.refreshItem( change.position.parent );
+
+					return true;
+				}
+			}
+		} );
+
+		setModelData( model, '<outerBlock><innerBlock><paragraph>[]</paragraph></innerBlock></outerBlock>' );
+
+		editor.execute( 'imageUpload', { file: createNativeFileMock() } );
+
+		model.document.once( 'change', () => {
+			try {
+				expect( getViewData( view ) ).to.equal(
+					'<outerBlock><innerBlock>[<figure class="ck-appear ck-widget image" contenteditable="false">' +
+					`<img src="${ base64Sample }"></img>` +
+					'<div class="ck-progress-bar"></div>' +
+					'</figure>]</innerBlock></outerBlock>'
+				);
+
+				done();
+			} catch ( err ) {
+				done( err );
+			}
+		}, { priority: 'lowest' } );
+
+		loader.file.then( () => nativeReaderMock.mockSuccess( base64Sample ) );
+	} );
+
+	it( 'should work correctly when there is no "reading" status and go straight to "uploading"', () => {
+		const fileRepository = editor.plugins.get( FileRepository );
+		const file = createNativeFileMock();
+		const loader = fileRepository.createLoader( file );
+
+		setModelData( model, '<image></image>' );
+		const image = doc.getRoot().getChild( 0 );
+
+		// Set attributes directly on image to simulate instant "uploading" status.
+		model.change( writer => {
+			writer.setAttribute( 'uploadStatus', 'uploading', image );
+			writer.setAttribute( 'uploadId', loader.id, image );
+			writer.setAttribute( 'src', 'image.png', image );
+		} );
+
+		expect( getViewData( view ) ).to.equal(
+			'[<figure class="ck-appear ck-widget image" contenteditable="false">' +
+				'<img src="image.png"></img>' +
+				'<div class="ck-progress-bar"></div>' +
+			'</figure>]'
+		);
+	} );
+
+	it( 'should work correctly when there is no "reading" status and go straight to "uploading" - external changes', () => {
+		setModelData( model, '<image></image>' );
+		const image = doc.getRoot().getChild( 0 );
+
+		// Set attributes directly on image to simulate instant "uploading" status.
+		model.change( writer => {
+			writer.setAttribute( 'uploadStatus', 'uploading', image );
+			writer.setAttribute( 'uploadId', '12345', image );
+		} );
+
+		expect( getViewData( view ) ).to.equal(
+			'[<figure class="ck-appear ck-image-upload-placeholder ck-widget image" contenteditable="false">' +
+				`<img src="data:image/svg+xml;utf8,${ imagePlaceholder }"></img>` +
+				'<div class="ck-upload-placeholder-loader"></div>' +
+			'</figure>]'
+		);
+	} );
+
+	it( 'should "clear" image when uploadId changes to null', () => {
+		setModelData( model, '<image></image>' );
+		const image = doc.getRoot().getChild( 0 );
+
+		// Set attributes directly on image to simulate instant "uploading" status.
+		model.change( writer => {
+			writer.setAttribute( 'uploadStatus', 'uploading', image );
+			writer.setAttribute( 'uploadId', '12345', image );
+		} );
+
+		model.change( writer => {
+			writer.setAttribute( 'uploadStatus', null, image );
+			writer.setAttribute( 'uploadId', null, image );
+		} );
+
+		expect( getViewData( view ) ).to.equal(
+			'[<figure class="ck-widget image" contenteditable="false">' +
+				`<img src="data:image/svg+xml;utf8,${ imagePlaceholder }"></img>` +
+			'</figure>]'
+		);
+	} );
+
+	it( 'should update progressbar width on progress', done => {
+		setModelData( model, '<paragraph>[]foo</paragraph>' );
+		editor.execute( 'imageUpload', { file: createNativeFileMock() } );
+
+		model.document.once( 'change', () => {
+			adapterMock.mockProgress( 40, 100 );
+
+			try {
+				expect( getViewData( view ) ).to.equal(
+					'[<figure class="ck-appear ck-widget image" contenteditable="false">' +
+					`<img src="${ base64Sample }"></img>` +
+					'<div class="ck-progress-bar" style="width:40%"></div>' +
+					'</figure>]<p>foo</p>'
+				);
+
+				done();
+			} catch ( err ) {
+				done( err );
+			}
+		}, { priority: 'lowest' } );
+
+		loader.file.then( () => nativeReaderMock.mockSuccess( base64Sample ) );
+	} );
+
+	it( 'should convert image\'s "complete" uploadStatus attribute and display temporary icon', done => {
+		const clock = testUtils.sinon.useFakeTimers();
+
+		setModelData( model, '<paragraph>[]foo</paragraph>' );
+		editor.execute( 'imageUpload', { file: createNativeFileMock() } );
+
+		model.document.once( 'change', () => {
+			model.document.once( 'change', () => {
+				try {
+					expect( getViewData( view ) ).to.equal(
+						'[<figure class="ck-widget image" contenteditable="false">' +
+						'<img src="image.png"></img>' +
+						'<div class="ck-image-upload-complete-icon"></div>' +
+						'</figure>]<p>foo</p>'
+					);
+
+					clock.tick( 3000 );
+
+					expect( getViewData( view ) ).to.equal(
+						'[<figure class="ck-widget image" contenteditable="false">' +
+						'<img src="image.png"></img>' +
+						'</figure>]<p>foo</p>'
+					);
+
+					done();
+				} catch ( err ) {
+					done( err );
+				}
+			}, { priority: 'lowest' } );
+
+			loader.file.then( () => adapterMock.mockSuccess( { default: 'image.png' } ) );
+		} );
+
+		loader.file.then( () => nativeReaderMock.mockSuccess( base64Sample ) );
+	} );
+
+	it( 'should allow to customize placeholder image', () => {
+		const uploadProgress = editor.plugins.get( ImageUploadProgress );
+		uploadProgress.placeholder = base64Sample;
+
+		setModelData( model, '<paragraph>[]foo</paragraph>' );
+		editor.execute( 'imageUpload', { file: createNativeFileMock() } );
+
+		expect( getViewData( view ) ).to.equal(
+			'[<figure class="ck-appear ck-image-upload-placeholder ck-widget image" contenteditable="false">' +
+				`<img src="${ base64Sample }"></img>` +
+				'<div class="ck-upload-placeholder-loader"></div>' +
+			'</figure>]<p>foo</p>'
+		);
+	} );
+
+	it( 'should not process attribute change if it is already consumed', () => {
+		editor.editing.downcastDispatcher.on( 'attribute:uploadStatus:image', ( evt, data, conversionApi ) => {
+			conversionApi.consumable.consume( data.item, evt.name );
+		}, { priority: 'highest' } );
+
+		setModelData( model, '<paragraph>[]foo</paragraph>' );
+		editor.execute( 'imageUpload', { file: createNativeFileMock() } );
+
+		expect( getViewData( view ) ).to.equal(
+			'[<figure class="ck-widget image" contenteditable="false"><img></img></figure>]<p>foo</p>'
+		);
+	} );
+
+	it( 'should not show progress bar and complete icon if there is no loader with given uploadId', () => {
+		setModelData( model, '<image uploadId="123" uploadStatus="reading"></image>' );
+
+		const image = doc.getRoot().getChild( 0 );
+
+		model.change( writer => {
+			writer.setAttribute( 'uploadStatus', 'uploading', image );
+		} );
+
+		expect( getViewData( view ) ).to.equal(
+			'[<figure class="ck-appear ck-image-upload-placeholder ck-widget image" contenteditable="false">' +
+				`<img src="data:image/svg+xml;utf8,${ imagePlaceholder }"></img>` +
+				'<div class="ck-upload-placeholder-loader"></div>' +
+			'</figure>]'
+		);
+
+		model.change( writer => {
+			writer.setAttribute( 'uploadStatus', 'complete', image );
+		} );
+
+		expect( getViewData( view ) ).to.equal(
+			'[<figure class="ck-widget image" contenteditable="false">' +
+				`<img src="data:image/svg+xml;utf8,${ imagePlaceholder }"></img>` +
+			'</figure>]'
+		);
+	} );
+} );

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

@@ -0,0 +1,615 @@
+/**
+ * @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, Event */
+
+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/imageinsert/imageinsertui';
+import ImageUploadEditing from '../../src/imageinsert/imageinsertediting';
+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';
+
+describe( 'ImageUploadUI', () => {
+	let editor, model, editorElement, fileRepository;
+
+	class UploadAdapterPluginMock extends Plugin {
+		init() {
+			fileRepository = this.editor.plugins.get( FileRepository );
+			fileRepository.createUploadAdapter = loader => {
+				return new UploadAdapterMock( loader );
+			};
+		}
+	}
+
+	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>'
+			);
+		} );
+	} );
+
+	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;
+
+					// 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 dropdown', () => {
+			const button = editor.ui.componentFactory.create( 'imageUpload' );
+
+			expect( button ).to.be.instanceOf( DropdownView );
+		} );
+
+		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 );
+
+			expect( fileDialogButton.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 dropdown = editor.ui.componentFactory.create( 'imageUpload' );
+			const command = editor.commands.get( 'imageUpload' );
+			const spy = sinon.spy();
+
+			dropdown.render();
+
+			dropdown.buttonView.on( 'execute', spy );
+
+			command.isEnabled = false;
+
+			dropdown.buttonView.element.dispatchEvent( new Event( 'click' ) );
+
+			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() ];
+
+			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 );
+		} );
+
+		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() ];
+
+			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 );
+		} );
+
+		it( 'should optimize the insertion position', () => {
+			const plugin = editor.plugins.get( 'ImageUploadUI' );
+			const fileDialogButton = plugin._createFileDialogButtonView( editor.locale );
+			const files = [ createNativeFileMock() ];
+
+			setModelData( model, '<paragraph>f[]oo</paragraph>' );
+
+			fileDialogButton.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 plugin = editor.plugins.get( 'ImageUploadUI' );
+			const fileDialogButton = plugin._createFileDialogButtonView( editor.locale );
+			const files = [ createNativeFileMock(), createNativeFileMock() ];
+
+			setModelData( model, '<paragraph>foo[]</paragraph><paragraph>bar</paragraph>' );
+
+			fileDialogButton.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 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 );
+
+				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,
+						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();
+		} );
+	} );
+} );
+
+function fakeEventData() {
+	return {
+		preventDefault: sinon.spy()
+	};
+}

+ 100 - 0
packages/ckeditor5-image/tests/imageinsert/ui/imageinsertformrowview.js

@@ -0,0 +1,100 @@
+/**
+ * @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 View from '@ckeditor/ckeditor5-ui/src/view';
+import ImageUploadFormRowView from '../../../src/imageinsert/ui/imageinsertformrowview';
+import ViewCollection from '@ckeditor/ckeditor5-ui/src/viewcollection';
+
+describe( 'ImageUploadFormRowView', () => {
+	let view, locale;
+
+	beforeEach( () => {
+		locale = { t: val => val };
+		view = new ImageUploadFormRowView( locale );
+		view.render();
+	} );
+
+	afterEach( () => {
+		view.element.remove();
+	} );
+
+	describe( 'constructor()', () => {
+		it( 'should set view#locale', () => {
+			expect( view.locale ).to.equal( locale );
+		} );
+
+		it( 'should create view#children collection', () => {
+			expect( view.children ).to.be.instanceOf( ViewCollection );
+			expect( view.children ).to.have.length( 0 );
+		} );
+
+		it( 'should set view#class', () => {
+			expect( view.class ).to.be.null;
+		} );
+
+		it( 'should set the template', () => {
+			expect( view.element.classList.contains( 'ck' ) ).to.be.true;
+			expect( view.element.classList.contains( 'ck-form__row' ) ).to.be.true;
+		} );
+
+		describe( 'options', () => {
+			it( 'should set view#class when class was passed', () => {
+				const view = new ImageUploadFormRowView( locale, {
+					class: 'foo'
+				} );
+
+				expect( view.class ).to.equal( 'foo' );
+
+				view.destroy();
+			} );
+
+			it( 'should fill view#children when children were passed', () => {
+				const view = new ImageUploadFormRowView( locale, {
+					children: [
+						new View()
+					]
+				} );
+
+				expect( view.children ).to.have.length( 1 );
+
+				view.destroy();
+			} );
+
+			it( 'should use a label view when passed', () => {
+				const labelView = new View();
+				labelView.id = '123';
+
+				const view = new ImageUploadFormRowView( locale, {
+					labelView
+				} );
+
+				view.render();
+
+				expect( view.element.getAttribute( 'role' ) ).to.equal( 'group' );
+				expect( view.element.getAttribute( 'aria-labelledby' ) ).to.equal( '123' );
+
+				view.destroy();
+			} );
+		} );
+
+		describe( 'template bindings', () => {
+			it( 'should bind #class to the template', () => {
+				view.class = 'foo';
+				expect( view.element.classList.contains( 'foo' ) ).to.be.true;
+			} );
+
+			it( 'should bind #children to the template', () => {
+				const child = new View();
+				child.setTemplate( { tag: 'div' } );
+
+				view.children.add( child );
+
+				expect( view.element.firstChild ).to.equal( child.element );
+
+				view.destroy();
+			} );
+		} );
+	} );
+} );

+ 298 - 0
packages/ckeditor5-image/tests/imageinsert/ui/imageinsertpanelview.js

@@ -0,0 +1,298 @@
+/**
+ * @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 Event */
+
+import DropdownView from '@ckeditor/ckeditor5-ui/src/dropdown/dropdownview';
+import LabeledFieldView from '@ckeditor/ckeditor5-ui/src/labeledfield/labeledfieldview';
+
+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';
+
+import { keyCodes } from '@ckeditor/ckeditor5-utils/src/keyboard';
+import KeystrokeHandler from '@ckeditor/ckeditor5-utils/src/keystrokehandler';
+import FocusTracker from '@ckeditor/ckeditor5-utils/src/focustracker';
+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/imageinsert/utils';
+
+import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils';
+
+describe( 'ImageUploadPanelView', () => {
+	let view;
+
+	beforeEach( () => {
+		view = new ImageUploadPanelView( { t: val => val }, {
+			'insertImageViaUrl': createLabeledInputView( { t: val => val } )
+		} );
+		view.render();
+	} );
+
+	afterEach( () => {
+		sinon.restore();
+	} );
+
+	describe( 'constructor()', () => {
+		it( 'should contain instance of ButtonView as #insertButtonView', () => {
+			expect( view.insertButtonView ).to.be.instanceOf( ButtonView );
+			expect( view.insertButtonView.label ).to.equal( 'Insert' );
+		} );
+
+		it( 'should contain instance of ButtonView as #cancelButtonView', () => {
+			expect( view.cancelButtonView ).to.be.instanceOf( ButtonView );
+			expect( view.cancelButtonView.label ).to.equal( 'Cancel' );
+		} );
+
+		it( 'should contain instance of DropdownView as #dropdownView', () => {
+			expect( view.dropdownView ).to.be.instanceOf( DropdownView );
+		} );
+
+		it( 'should contain instance of SplitButtonView for the #dropdownView button', () => {
+			expect( view.dropdownView ).to.be.instanceOf( DropdownView );
+			expect( view.dropdownView.buttonView ).to.be.instanceOf( SplitButtonView );
+		} );
+
+		it( 'should contain #imageURLInputValue', () => {
+			expect( view.imageURLInputValue ).to.equal( '' );
+		} );
+
+		it( 'should contain #_integrations as an instance of Collection', () => {
+			expect( view._integrations ).to.be.instanceOf( Collection );
+		} );
+
+		describe( 'integrations', () => {
+			it( 'should contain 2 integrations when they were passed to the ImageUploadPanelView as integrations object', () => {
+				const view = new ImageUploadPanelView( { t: val => val }, {
+					'integration1': new View(),
+					'integration2': new ButtonView()
+				} );
+
+				expect( view._integrations ).to.be.instanceOf( Collection );
+				expect( view._integrations.length ).to.equal( 2 );
+			} );
+
+			it( 'should contain insertImageViaUrl view when it is passed via integrations object', () => {
+				const view = new ImageUploadPanelView( { t: val => val }, {
+					'insertImageViaUrl': createLabeledInputView( { t: val => val } ),
+					'integration1': new View(),
+					'integration2': new ButtonView()
+				} );
+
+				expect( view._integrations ).to.be.instanceOf( Collection );
+				expect( view._integrations.length ).to.equal( 3 );
+				expect( view._integrations.first ).to.be.instanceOf( LabeledFieldView );
+			} );
+
+			it( 'should contain no integrations when they were not provided', () => {
+				const view = new ImageUploadPanelView( { t: val => val } );
+
+				expect( view._integrations ).to.be.instanceOf( Collection );
+				expect( view._integrations.length ).to.equal( 0 );
+			} );
+		} );
+
+		it( 'should create #focusTracker instance', () => {
+			expect( view.focusTracker ).to.be.instanceOf( FocusTracker );
+		} );
+
+		it( 'should create #keystrokes instance', () => {
+			expect( view.keystrokes ).to.be.instanceOf( KeystrokeHandler );
+		} );
+
+		it( 'should create #_focusCycler instance', () => {
+			expect( view._focusCycler ).to.be.instanceOf( FocusCycler );
+		} );
+
+		it( 'should create #_focusables view collection', () => {
+			expect( view._focusables ).to.be.instanceOf( ViewCollection );
+		} );
+
+		describe( 'events', () => {
+			it( 'should fire "submit" event on insertButtonView#execute', () => {
+				const spy = sinon.spy();
+
+				view.on( 'submit', spy );
+
+				view.insertButtonView.fire( 'execute' );
+
+				expect( spy.calledOnce ).to.true;
+			} );
+
+			it( 'should fire "cancel" event on cancelButtonView#execute', () => {
+				const spy = sinon.spy();
+
+				view.on( 'cancel', spy );
+
+				view.cancelButtonView.fire( 'execute' );
+
+				expect( spy.calledOnce ).to.true;
+			} );
+		} );
+	} );
+
+	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.getAttribute( 'tabindex' ) ).to.equal( '-1' );
+		} );
+
+		it( 'should have form row view with buttons', () => {
+			expect( view.template.children[ 1 ] ).to.be.instanceOf( ImageUploadFormRowView );
+			expect( view.template.children[ 1 ].children.first ).to.equal( view.insertButtonView );
+			expect( view.template.children[ 1 ].children.last ).to.equal( view.cancelButtonView );
+		} );
+	} );
+
+	describe( 'render()', () => {
+		it( 'should register child views in #_focusables', () => {
+			expect( view._focusables.map( f => f ) ).to.have.members( [
+				...view._integrations,
+				view.insertButtonView,
+				view.cancelButtonView
+			] );
+		} );
+
+		it( 'should register child views\' #element in #focusTracker with no integrations', () => {
+			const spy = testUtils.sinon.spy( FocusTracker.prototype, 'add' );
+
+			view = new ImageUploadPanelView( { t: () => {} } );
+			view.render();
+
+			sinon.assert.calledWithExactly( spy.getCall( 0 ), view.insertButtonView.element );
+			sinon.assert.calledWithExactly( spy.getCall( 1 ), view.cancelButtonView.element );
+		} );
+
+		it( 'should register child views\' #element in #focusTracker with "insertImageViaUrl" integration', () => {
+			const spy = testUtils.sinon.spy( FocusTracker.prototype, 'add' );
+
+			view = new ImageUploadPanelView( { t: () => {} }, {
+				'insertImageViaUrl': createLabeledInputView( { t: val => val } )
+			} );
+			view.render();
+
+			sinon.assert.calledWithExactly( spy.getCall( 0 ), view.getIntegration( 'insertImageViaUrl' ).element );
+			sinon.assert.calledWithExactly( spy.getCall( 1 ), view.insertButtonView.element );
+			sinon.assert.calledWithExactly( spy.getCall( 2 ), view.cancelButtonView.element );
+		} );
+
+		it( 'starts listening for #keystrokes coming from #element', () => {
+			view = new ImageUploadPanelView( { t: () => {} } );
+
+			const spy = sinon.spy( view.keystrokes, 'listenTo' );
+
+			view.render();
+			sinon.assert.calledOnce( spy );
+			sinon.assert.calledWithExactly( spy, view.element );
+		} );
+
+		it( 'intercepts the arrow* events and overrides the default toolbar behavior', () => {
+			const keyEvtData = {
+				stopPropagation: sinon.spy()
+			};
+
+			keyEvtData.keyCode = keyCodes.arrowdown;
+			view.keystrokes.press( keyEvtData );
+			sinon.assert.calledOnce( keyEvtData.stopPropagation );
+
+			keyEvtData.keyCode = keyCodes.arrowup;
+			view.keystrokes.press( keyEvtData );
+			sinon.assert.calledTwice( keyEvtData.stopPropagation );
+
+			keyEvtData.keyCode = keyCodes.arrowleft;
+			view.keystrokes.press( keyEvtData );
+			sinon.assert.calledThrice( keyEvtData.stopPropagation );
+
+			keyEvtData.keyCode = keyCodes.arrowright;
+			view.keystrokes.press( keyEvtData );
+			sinon.assert.callCount( keyEvtData.stopPropagation, 4 );
+		} );
+
+		it( 'intercepts the "selectstart" event of the first integration element with the high priority', () => {
+			const spy = sinon.spy();
+			const event = new Event( 'selectstart', {
+				bubbles: true,
+				cancelable: true
+			} );
+
+			event.stopPropagation = spy;
+
+			view.getIntegration( 'insertImageViaUrl' ).element.dispatchEvent( event );
+			sinon.assert.calledOnce( spy );
+		} );
+
+		describe( 'activates keyboard navigation for the toolbar', () => {
+			it( 'so "tab" focuses the next focusable item', () => {
+				const keyEvtData = {
+					keyCode: keyCodes.tab,
+					preventDefault: sinon.spy(),
+					stopPropagation: sinon.spy()
+				};
+
+				// Mock the url input is focused.
+				view.focusTracker.isFocused = true;
+				view.focusTracker.focusedElement = view.getIntegration( 'insertImageViaUrl' ).element;
+
+				const spy = sinon.spy( view.insertButtonView, 'focus' );
+
+				view.keystrokes.press( keyEvtData );
+				sinon.assert.calledOnce( keyEvtData.preventDefault );
+				sinon.assert.calledOnce( keyEvtData.stopPropagation );
+				sinon.assert.calledOnce( spy );
+			} );
+
+			it( 'so "shift + tab" focuses the previous focusable item', () => {
+				const keyEvtData = {
+					keyCode: keyCodes.tab,
+					shiftKey: true,
+					preventDefault: sinon.spy(),
+					stopPropagation: sinon.spy()
+				};
+
+				// Mock the cancel button is focused.
+				view.focusTracker.isFocused = true;
+				view.focusTracker.focusedElement = view.cancelButtonView.element;
+
+				const spy = sinon.spy( view.insertButtonView, 'focus' );
+
+				view.keystrokes.press( keyEvtData );
+				sinon.assert.calledOnce( keyEvtData.preventDefault );
+				sinon.assert.calledOnce( keyEvtData.stopPropagation );
+				sinon.assert.calledOnce( spy );
+			} );
+		} );
+	} );
+
+	describe( 'focus()', () => {
+		it( 'should focus on the first integration', () => {
+			const spy = sinon.spy( view.getIntegration( 'insertImageViaUrl' ), 'focus' );
+
+			view.focus();
+
+			sinon.assert.calledOnce( spy );
+		} );
+	} );
+
+	describe( 'Insert image via URL integration input', () => {
+		it( 'should be bound with #imageURLInputValue', () => {
+			const form = view.getIntegration( 'insertImageViaUrl' );
+
+			form.fieldView.element.value = 'abc';
+			form.fieldView.fire( 'input' );
+
+			expect( view.imageURLInputValue ).to.equal( 'abc' );
+
+			form.fieldView.element.value = 'xyz';
+			form.fieldView.fire( 'input' );
+
+			expect( view.imageURLInputValue ).to.equal( 'xyz' );
+		} );
+	} );
+} );

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

@@ -0,0 +1,179 @@
+/**
+ * @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 ImageUploadEditing from '../../src/imageinsert/imageinsertediting';
+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/imageinsert/utils';
+
+describe( 'Upload utils', () => {
+	describe( 'createImageTypeRegExp()', () => {
+		it( 'should return RegExp for testing regular mime type', () => {
+			expect( createImageTypeRegExp( [ 'png' ] ).test( 'image/png' ) ).to.be.true;
+			expect( createImageTypeRegExp( [ 'png' ] ).test( 'foo/png' ) ).to.be.false;
+			expect( createImageTypeRegExp( [ 'png' ] ).test( 'png' ) ).to.be.false;
+		} );
+
+		it( 'should return RegExp for testing mime type with dot', () => {
+			expect( createImageTypeRegExp( [ 'vnd.microsoft.icon' ] ).test( 'image/vnd.microsoft.icon' ) ).to.be.true;
+			expect( createImageTypeRegExp( [ 'png' ] ).test( 'foo/vnd.microsoft.icon' ) ).to.be.false;
+			expect( createImageTypeRegExp( [ 'png' ] ).test( 'vnd.microsoft.icon' ) ).to.be.false;
+		} );
+
+		it( 'should return RegExp for testing mime type with dash', () => {
+			expect( createImageTypeRegExp( [ 'x-xbitmap' ] ).test( 'image/x-xbitmap' ) ).to.be.true;
+			expect( createImageTypeRegExp( [ 'png' ] ).test( 'foo/x-xbitmap' ) ).to.be.false;
+			expect( createImageTypeRegExp( [ 'png' ] ).test( 'x-xbitmap' ) ).to.be.false;
+		} );
+
+		it( 'should return RegExp for testing mime type with plus', () => {
+			expect( createImageTypeRegExp( [ 'svg+xml' ] ).test( 'image/svg+xml' ) ).to.be.true;
+			expect( createImageTypeRegExp( [ 'png' ] ).test( 'foo/svg+xml' ) ).to.be.false;
+			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/ );
+			} );
+		} );
+	} );
+} );

+ 2 - 2
packages/ckeditor5-image/tests/imageupload/imageuploadcommand.js

@@ -81,9 +81,9 @@ describe( 'ImageUploadCommand', () => {
 			expect( command.isEnabled ).to.be.true;
 		} );
 
-		it( 'should be true when the selection is on other image', () => {
+		it( 'should be false when the selection is on other image', () => {
 			setModelData( model, '[<image></image>]' );
-			expect( command.isEnabled ).to.be.true;
+			expect( command.isEnabled ).to.be.false;
 		} );
 
 		it( 'should be false when the selection is inside other image', () => {

+ 1 - 1
packages/ckeditor5-image/tests/imageupload/imageuploadediting.js

@@ -157,7 +157,7 @@ describe( 'ImageUploadEditing', () => {
 
 		const command = editor.commands.get( 'imageUpload' );
 
-		expect( command.isEnabled ).to.be.true;
+		expect( command.isEnabled ).to.be.false;
 
 		const targetRange = model.createRange( model.createPositionAt( doc.getRoot(), 0 ), model.createPositionAt( doc.getRoot(), 0 ) );
 		const targetViewRange = editor.editing.mapper.toViewRange( targetRange );

+ 0 - 17
packages/ckeditor5-image/tests/imageupload/imageuploadui.js

@@ -182,22 +182,5 @@ describe( 'ImageUploadUI', () => {
 			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>'
-			);
-		} );
 	} );
 } );

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


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


+ 17 - 0
packages/ckeditor5-image/theme/imageinserticon.css

@@ -0,0 +1,17 @@
+/*
+ * Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+.ck-image-upload-complete-icon {
+	display: block;
+	position: absolute;
+	top: 10px;
+	right: 10px;
+	border-radius: 50%;
+
+	&::after {
+		content: "";
+		position: absolute;
+	}
+}

+ 18 - 0
packages/ckeditor5-image/theme/imageinsertloader.css

@@ -0,0 +1,18 @@
+/*
+ * Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+.ck .ck-upload-placeholder-loader {
+	position: absolute;
+	display: flex;
+	align-items: center;
+	justify-content: center;
+	top: 0;
+	left: 0;
+
+	&::before {
+		content: '';
+		position: relative;
+	}
+}

+ 15 - 0
packages/ckeditor5-image/theme/imageinsertprogress.css

@@ -0,0 +1,15 @@
+/*
+ * Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+.ck.ck-editor__editable .image {
+	position: relative;
+}
+
+/* Upload progress bar. */
+.ck.ck-editor__editable .image .ck-progress-bar {
+	position: absolute;
+	top: 0;
+	left: 0;
+}