Parcourir la source

Change: Move `ImageUpload` feature to ckeditor5-image package.

Maciej Gołaszewski il y a 7 ans
Parent
commit
63267b79af
22 fichiers modifiés avec 1870 ajouts et 1 suppressions
  1. 1 1
      packages/ckeditor5-image/docs/features/image.md
  2. 1 0
      packages/ckeditor5-image/package.json
  3. 39 0
      packages/ckeditor5-image/src/imageupload.js
  4. 67 0
      packages/ckeditor5-image/src/imageupload/imageuploadcommand.js
  5. 214 0
      packages/ckeditor5-image/src/imageupload/imageuploadediting.js
  6. 138 0
      packages/ckeditor5-image/src/imageupload/imageuploadprogress.js
  7. 60 0
      packages/ckeditor5-image/src/imageupload/imageuploadui.js
  8. 67 0
      packages/ckeditor5-image/src/imageupload/utils.js
  9. 51 0
      packages/ckeditor5-image/tests/imageupload.js
  10. 135 0
      packages/ckeditor5-image/tests/imageupload/imageuploadcommand.js
  11. 436 0
      packages/ckeditor5-image/tests/imageupload/imageuploadediting.js
  12. 193 0
      packages/ckeditor5-image/tests/imageupload/imageuploadprogress.js
  13. 166 0
      packages/ckeditor5-image/tests/imageupload/imageuploadui.js
  14. 114 0
      packages/ckeditor5-image/tests/imageupload/utils.js
  15. 2 0
      packages/ckeditor5-image/tests/manual/imageplaceholder.html
  16. 21 0
      packages/ckeditor5-image/tests/manual/imageplaceholder.js
  17. 3 0
      packages/ckeditor5-image/tests/manual/imageplaceholder.md
  18. 17 0
      packages/ckeditor5-image/tests/manual/imageupload.html
  19. 100 0
      packages/ckeditor5-image/tests/manual/imageupload.js
  20. 15 0
      packages/ckeditor5-image/tests/manual/imageupload.md
  21. 1 0
      packages/ckeditor5-image/theme/icons/image_placeholder.svg
  22. 29 0
      packages/ckeditor5-image/theme/imageuploadprogress.css

+ 1 - 1
packages/ckeditor5-image/docs/features/image.md

@@ -11,7 +11,7 @@ The [`@ckeditor/ckeditor5-image`](https://www.npmjs.com/package/@ckeditor/ckedit
 * {@link module:image/imagetoolbar~ImageToolbar} adds the image feature's contextual toolbar,
 * {@link module:image/imagecaption~ImageCaption} adds support for captions,
 * {@link module:image/imagestyle~ImageStyle} adds support for image styles,
-* {@link module:upload/imageupload~ImageUpload} adds support for uploading dropped or pasted images (note: it is currently located in the [`@ckeditor/ckeditor5-upload`](https://www.npmjs.com/package/@ckeditor/ckeditor5-upload) package but will be moved to the `@ckeditor/ckeditor5-image` package).
+* {@link module:image/imageupload~ImageUpload} adds support for uploading dropped or pasted images (note: it is currently located in the [`@ckeditor/ckeditor5-upload`](https://www.npmjs.com/package/@ckeditor/ckeditor5-upload) package but will be moved to the `@ckeditor/ckeditor5-image` package).
 
 <info-box info>
 	The first four features listed above (so all except the upload support) are enabled by default in all builds.

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

@@ -12,6 +12,7 @@
     "@ckeditor/ckeditor5-ui": "^1.0.0-alpha.2",
     "@ckeditor/ckeditor5-utils": "^1.0.0-alpha.2",
     "@ckeditor/ckeditor5-theme-lark": "^1.0.0-alpha.2",
+    "@ckeditor/ckeditor5-upload": "^1.0.0-alpha.2",
     "@ckeditor/ckeditor5-widget": "^1.0.0-alpha.2"
   },
   "devDependencies": {

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

@@ -0,0 +1,39 @@
+/**
+ * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/**
+ * @module image/imageupload
+ */
+
+import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
+import ImageUploadUI from './imageupload/imageuploadui';
+import ImageUploadProgress from './imageupload/imageuploadprogress';
+import ImageUploadEditing from './imageupload/imageuploadediting';
+
+/**
+ * Image upload plugin.
+ *
+ * This plugin do not do anything directly, but loads set of specific plugins to enable image uploading:
+ * * {@link module:image/imageupload/imageuploadediting~ImageUploadEditing},
+ * * {@link module:image/imageupload/imageuploadui~ImageUploadUI},
+ * * {@link module:image/imageupload/imageuploadprogress~ImageUploadProgress}.
+ *
+ * @extends module:core/plugin~Plugin
+ */
+export default class ImageUpload extends Plugin {
+	/**
+	 * @inheritDoc
+	 */
+	static get pluginName() {
+		return 'ImageUpload';
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	static get requires() {
+		return [ ImageUploadEditing, ImageUploadUI, ImageUploadProgress ];
+	}
+}

+ 67 - 0
packages/ckeditor5-image/src/imageupload/imageuploadcommand.js

@@ -0,0 +1,67 @@
+/**
+ * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+import ModelElement from '@ckeditor/ckeditor5-engine/src/model/element';
+import ModelRange from '@ckeditor/ckeditor5-engine/src/model/range';
+import ModelSelection from '@ckeditor/ckeditor5-engine/src/model/selection';
+import FileRepository from '@ckeditor/ckeditor5-upload/src/filerepository';
+import Command from '@ckeditor/ckeditor5-core/src/command';
+
+/**
+ * @module image/imageupload/imageuploadcommand
+ */
+
+/**
+ * Image upload command.
+ *
+ * @extends module:core/command~Command
+ */
+export default class ImageUploadCommand extends Command {
+	/**
+	 * Executes the command.
+	 *
+	 * @fires execute
+	 * @param {Object} options Options for executed command.
+	 * @param {File} options.file Image file to upload.
+	 * @param {module:engine/model/position~Position} [options.insertAt] Position at which the image should be inserted.
+	 * If the position is not specified the image will be inserted into the current selection.
+	 * Note: You can use the {@link module:upload/utils~findOptimalInsertionPosition} function to calculate
+	 * (e.g. based on the current selection) a position which is more optimal from UX perspective.
+	 */
+	execute( options ) {
+		const editor = this.editor;
+		const doc = editor.model.document;
+		const file = options.file;
+		const fileRepository = editor.plugins.get( FileRepository );
+
+		editor.model.change( writer => {
+			const loader = fileRepository.createLoader( file );
+
+			// Do not throw when upload adapter is not set. FileRepository will log an error anyway.
+			if ( !loader ) {
+				return;
+			}
+
+			const imageElement = new ModelElement( 'image', {
+				uploadId: loader.id
+			} );
+
+			let insertAtSelection;
+
+			if ( options.insertAt ) {
+				insertAtSelection = new ModelSelection( [ new ModelRange( options.insertAt ) ] );
+			} else {
+				insertAtSelection = doc.selection;
+			}
+
+			editor.model.insertContent( imageElement, insertAtSelection );
+
+			// Inserting an image might've failed due to schema regulations.
+			if ( imageElement.parent ) {
+				writer.setSelection( ModelRange.createOn( imageElement ) );
+			}
+		} );
+	}
+}

+ 214 - 0
packages/ckeditor5-image/src/imageupload/imageuploadediting.js

@@ -0,0 +1,214 @@
+/**
+ * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/**
+ * @module image/imageupload/imageuploadediting
+ */
+
+import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
+import FileRepository from '@ckeditor/ckeditor5-upload/src/filerepository';
+import ImageUploadCommand from '../../src/imageupload/imageuploadcommand';
+import Notification from '@ckeditor/ckeditor5-ui/src/notification/notification';
+import ModelSelection from '@ckeditor/ckeditor5-engine/src/model/selection';
+import { isImageType, findOptimalInsertionPosition } from '../../src/imageupload/utils';
+
+/**
+ * Image upload editing plugin.
+ *
+ * @extends module:core/plugin~Plugin
+ */
+export default class ImageUploadEditing extends Plugin {
+	/**
+	 * @inheritDoc
+	 */
+	static get requires() {
+		return [ FileRepository, Notification ];
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	init() {
+		const editor = this.editor;
+		const doc = editor.model.document;
+		const schema = editor.model.schema;
+		const fileRepository = editor.plugins.get( FileRepository );
+
+		// 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 ) );
+
+		// Execute imageUpload command when image is dropped or pasted.
+		editor.editing.view.on( 'clipboardInput', ( evt, data ) => {
+			// Skip if non empty HTML data is included.
+			// https://github.com/ckeditor/ckeditor5-upload/issues/68
+			if ( isHtmlIncluded( data.dataTransfer ) ) {
+				return;
+			}
+
+			let targetModelSelection = new ModelSelection(
+				data.targetRanges.map( viewRange => editor.editing.mapper.toModelRange( viewRange ) )
+			);
+
+			for ( const file of data.dataTransfer.files ) {
+				const insertAt = findOptimalInsertionPosition( targetModelSelection );
+
+				if ( isImageType( file ) ) {
+					editor.execute( 'imageUpload', { file, insertAt } );
+					evt.stop();
+				}
+
+				// Use target ranges only for the first image. Then, use that image position
+				// so we keep adding the next ones after the previous one.
+				targetModelSelection = doc.selection;
+			}
+		} );
+
+		// Prevents from browser redirecting to the dropped image.
+		editor.editing.view.on( 'dragover', ( evt, data ) => {
+			data.preventDefault();
+		} );
+
+		doc.on( 'change', () => {
+			const changes = doc.differ.getChanges( { includeChangesInGraveyard: true } );
+
+			for ( const entry of changes ) {
+				if ( entry.type == 'insert' && entry.name == 'image' ) {
+					const item = entry.position.nodeAfter;
+					const isInGraveyard = entry.position.root.rootName == '$graveyard';
+
+					// Check if the image element still has upload id.
+					const uploadId = item.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, start loading it.
+						this._load( loader, item );
+					}
+				}
+			}
+		} );
+	}
+
+	/**
+	 * Performs image loading. Image is read from the disk and temporary data is displayed, after uploading process
+	 * is complete we replace temporary data with target image from the server.
+	 *
+	 * @private
+	 * @param {module:upload/filerepository~FileLoader} loader
+	 * @param {module:engine/model/element~Element} imageElement
+	 */
+	_load( 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 );
+		} );
+
+		loader.read()
+			.then( data => {
+				const viewFigure = editor.editing.mapper.toViewElement( imageElement );
+				const viewImg = viewFigure.getChild( 0 );
+				const promise = loader.upload();
+
+				viewImg.setAttribute( 'src', data );
+				editor.editing.view.render();
+
+				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 );
+
+					// 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
+						}, imageElement );
+					}
+				} );
+
+				clean();
+			} )
+			.catch( msg => {
+				// Might be 'aborted'.
+				if ( loader.status == 'error' ) {
+					notification.showWarning( msg, {
+						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 );
+		}
+	}
+}
+
+// Returns true if non-empty `text/html` is included in 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' ) !== '';
+}

+ 138 - 0
packages/ckeditor5-image/src/imageupload/imageuploadprogress.js

@@ -0,0 +1,138 @@
+/**
+ * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/**
+ * @module image/imageupload/imageuploadprogress
+ */
+
+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 UIElement from '@ckeditor/ckeditor5-engine/src/view/uielement';
+
+import '../../theme/imageuploadprogress.css';
+
+/**
+ * Image upload progress plugin.
+ * Shows placeholder when image is read from disk and progress bar while image is uploading.
+ *
+ * @extends module:core/plugin~Plugin
+ */
+export default class ImageUploadProgress extends Plugin {
+	/**
+	 * @inheritDoc
+	 */
+	constructor( editor ) {
+		super( editor );
+
+		/**
+		 * Image's 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.modelToView.on( 'attribute:uploadStatus:image', ( ...args ) => this.uploadStatusChange( ...args ) );
+	}
+
+	/**
+	 * This ethod is called each time image's `uploadStatus` attribute is changed.
+	 *
+	 * @param {module:utils/eventinfo~EventInfo} evt Object containing information about the fired event.
+	 * @param {Object} data Additional information about the change.
+	 * @param {module:engine/conversion/modelconsumable~ModelConsumable} consumable Values to consume.
+	 */
+	uploadStatusChange( evt, data, consumable ) {
+		const editor = this.editor;
+		const modelImage = data.item;
+		const uploadId = modelImage.getAttribute( 'uploadId' );
+
+		if ( !consumable.consume( data.item, evt.name ) || !uploadId ) {
+			return;
+		}
+
+		const fileRepository = editor.plugins.get( FileRepository );
+		const placeholder = this.placeholder;
+		const status = data.attributeNewValue;
+		const viewFigure = editor.editing.mapper.toViewElement( modelImage );
+
+		// Show placeholder with infinite progress bar on the top while image is read from disk.
+		if ( status == 'reading' ) {
+			viewFigure.addClass( 'ck-appear', 'ck-infinite-progress', 'ck-image-upload-placeholder' );
+			const viewImg = viewFigure.getChild( 0 );
+			viewImg.setAttribute( 'src', placeholder );
+
+			return;
+		}
+
+		// Show progress bar on the top of the image when image is uploading.
+		if ( status == 'uploading' ) {
+			const loader = fileRepository.loaders.get( uploadId );
+
+			if ( loader ) {
+				const progressBar = createProgressBar();
+
+				viewFigure.removeClass( 'ck-infinite-progress', 'ck-image-upload-placeholder' );
+				viewFigure.appendChildren( progressBar );
+
+				// Update progress bar width when uploadedPercent is changed.
+				loader.on( 'change:uploadedPercent', ( evt, name, value ) => {
+					progressBar.setStyle( 'width', value + '%' );
+					editor.editing.view.render();
+				} );
+			}
+
+			return;
+		}
+
+		// Hide progress bar and clean up classes.
+		const progressBar = getProgressBar( viewFigure );
+
+		if ( progressBar ) {
+			progressBar.remove();
+		} else {
+			viewFigure.removeClass( 'ck-infinite-progress' );
+		}
+
+		viewFigure.removeClass( 'ck-appear', 'ck-image-upload-placeholder' );
+	}
+}
+
+// Symbol added to progress bar UIElement to distinguish it from other elements.
+const progressBarSymbol = Symbol( 'progress-bar' );
+
+// Create progress bar element using {@link module:engine/view/uielement~UIElement}.
+//
+// @private
+// @returns {module:engine/view/uielement~UIElement}
+function createProgressBar() {
+	const progressBar = new UIElement( 'div', { class: 'ck-progress-bar' } );
+	progressBar.setCustomProperty( progressBarSymbol, true );
+
+	return progressBar;
+}
+
+// Returns progress bar {@link module:engine/view/uielement~UIElement} from image figure element. Returns `undefined` if
+// progress bar element is not found.
+//
+// @private
+// @param {module:engine/view/element~Element} imageFigure
+// @returns {module:engine/view/uielement~UIElement|undefined}
+function getProgressBar( imageFigure ) {
+	for ( const child of imageFigure.getChildren() ) {
+		if ( child.getCustomProperty( progressBarSymbol ) ) {
+			return child;
+		}
+	}
+}

+ 60 - 0
packages/ckeditor5-image/src/imageupload/imageuploadui.js

@@ -0,0 +1,60 @@
+/**
+ * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/**
+ * @module image/imageupload/imageuploadui
+ */
+
+import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
+import FileDialogButtonView from '@ckeditor/ckeditor5-upload/src/ui/filedialogbuttonview';
+import imageIcon from '@ckeditor/ckeditor5-core/theme/icons/image.svg';
+import { isImageType, findOptimalInsertionPosition } from './utils';
+
+/**
+ * Image upload button plugin.
+ * Adds `uploadImage` button to UI component factory.
+ *
+ * @extends module:core/plugin~Plugin
+ */
+export default class ImageUploadUI extends Plugin {
+	/**
+	 * @inheritDoc
+	 */
+	init() {
+		const editor = this.editor;
+		const t = editor.t;
+
+		// Setup `uploadImage` button.
+		editor.ui.componentFactory.add( 'uploadImage', locale => {
+			const view = new FileDialogButtonView( locale );
+			const command = editor.commands.get( 'imageUpload' );
+
+			view.set( {
+				acceptedType: 'image/*',
+				allowMultipleFiles: true
+			} );
+
+			view.buttonView.set( {
+				label: t( 'Insert image' ),
+				icon: imageIcon,
+				tooltip: true
+			} );
+
+			view.buttonView.bind( 'isEnabled' ).to( command );
+
+			view.on( 'done', ( evt, files ) => {
+				for ( const file of Array.from( files ) ) {
+					const insertAt = findOptimalInsertionPosition( editor.model.document.selection );
+
+					if ( isImageType( file ) ) {
+						editor.execute( 'imageUpload', { file, insertAt } );
+					}
+				}
+			} );
+
+			return view;
+		} );
+	}
+}

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

@@ -0,0 +1,67 @@
+/**
+ * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/**
+ * @module upload/utils
+ */
+
+import ModelPosition from '@ckeditor/ckeditor5-engine/src/model/position';
+
+/**
+ * Checks if given file is an image.
+ *
+ * @param {File} file
+ * @returns {Boolean}
+ */
+export function isImageType( file ) {
+	const types = /^image\/(jpeg|png|gif|bmp)$/;
+
+	return types.test( file.type );
+}
+
+/**
+ * Returns a model position which is optimal (in terms of UX) for inserting an image.
+ *
+ * For instance, if a selection is in a middle of a paragraph, position before this paragraph
+ * will be returned, so that it's not split. If the selection is at the end of a paragraph,
+ * position after this paragraph will be returned.
+ *
+ * Note: If selection is placed in an empty block, that block will be returned. If that position
+ * is then passed to {@link module:engine/model/model~Model#insertContent}
+ * that block will be fully replaced by the image.
+ *
+ * @param {module:engine/model/selection~Selection} selection Selection based on which the
+ * insertion position should be calculated.
+ * @returns {module:engine/model/position~Position} The optimal position.
+ */
+export function findOptimalInsertionPosition( selection ) {
+	const selectedElement = selection.getSelectedElement();
+
+	if ( selectedElement ) {
+		return ModelPosition.createAfter( selectedElement );
+	}
+
+	const firstBlock = selection.getSelectedBlocks().next().value;
+
+	if ( firstBlock ) {
+		// If inserting into an empty block – return position in that block. It will get
+		// replaced with the image by insertContent(). #42.
+		if ( firstBlock.isEmpty ) {
+			return ModelPosition.createAt( firstBlock );
+		}
+
+		const positionAfter = ModelPosition.createAfter( firstBlock );
+
+		// If selection is at the end of the block - return position after the block.
+		if ( selection.focus.isTouching( positionAfter ) ) {
+			return positionAfter;
+		}
+
+		// Otherwise return position before the block.
+		return ModelPosition.createBefore( firstBlock );
+	}
+
+	return selection.focus;
+}

+ 51 - 0
packages/ckeditor5-image/tests/imageupload.js

@@ -0,0 +1,51 @@
+/**
+ * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* globals document */
+
+import ClassicEditor from '@ckeditor/ckeditor5-editor-classic/src/classiceditor';
+import Image from '@ckeditor/ckeditor5-image/src/image';
+import ImageUpload from '../src/imageupload';
+import ImageUploadEditing from '../src/imageupload/imageuploadediting';
+import ImageUploadProgress from '../src/imageupload/imageuploadprogress';
+import ImageUploadUI from '../src/imageupload/imageuploadui';
+
+import { UploadAdapterPluginMock } from '@ckeditor/ckeditor5-upload/tests/_utils/mocks';
+
+describe( 'ImageUpload', () => {
+	let editor, editorElement;
+
+	beforeEach( () => {
+		editorElement = document.createElement( 'div' );
+		document.body.appendChild( editorElement );
+
+		return ClassicEditor
+			.create( editorElement, {
+				plugins: [ Image, ImageUpload, UploadAdapterPluginMock ]
+			} )
+			.then( newEditor => {
+				editor = newEditor;
+			} );
+	} );
+
+	afterEach( () => {
+		editorElement.remove();
+
+		return editor.destroy();
+	} );
+
+	it( 'should include ImageUploadEditing', () => {
+		expect( editor.plugins.get( ImageUploadEditing ) ).to.be.instanceOf( ImageUploadEditing );
+	} );
+
+	it( 'should include ImageUploadProgress', () => {
+		expect( editor.plugins.get( ImageUploadProgress ) ).to.be.instanceOf( ImageUploadProgress );
+	} );
+
+	it( 'should include ImageUploadUI', () => {
+		expect( editor.plugins.get( ImageUploadUI ) ).to.be.instanceOf( ImageUploadUI );
+	} );
+} );
+

+ 135 - 0
packages/ckeditor5-image/tests/imageupload/imageuploadcommand.js

@@ -0,0 +1,135 @@
+/**
+ * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+import VirtualTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/virtualtesteditor';
+
+import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
+
+import ImageUploadCommand from '../../src/imageupload/imageuploadcommand';
+import FileRepository from '@ckeditor/ckeditor5-upload/src/filerepository';
+
+import { createNativeFileMock, AdapterMock } 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/imageengine';
+import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
+import buildModelConverter from '@ckeditor/ckeditor5-engine/src/conversion/buildmodelconverter';
+import ModelPosition from '@ckeditor/ckeditor5-engine/src/model/position';
+
+import log from '@ckeditor/ckeditor5-utils/src/log';
+
+import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils';
+
+describe( 'ImageUploadCommand', () => {
+	let editor, command, model, doc, fileRepository;
+
+	testUtils.createSinonSandbox();
+
+	class UploadAdapterPluginMock extends Plugin {
+		init() {
+			fileRepository = this.editor.plugins.get( FileRepository );
+			fileRepository.createAdapter = loader => {
+				return new AdapterMock( loader );
+			};
+		}
+	}
+
+	beforeEach( () => {
+		return VirtualTestEditor
+			.create( {
+				plugins: [ FileRepository, Image, Paragraph, UploadAdapterPluginMock ]
+			} )
+			.then( newEditor => {
+				editor = newEditor;
+				model = editor.model;
+				doc = model.document;
+
+				command = new ImageUploadCommand( editor );
+
+				const schema = model.schema;
+				schema.extend( 'image', { allowAttributes: 'uploadId' } );
+			} );
+	} );
+
+	afterEach( () => {
+		return editor.destroy();
+	} );
+
+	describe( 'execute()', () => {
+		it( 'should insert image at selection position (includes deleting selected content)', () => {
+			const file = createNativeFileMock();
+			setModelData( model, '<paragraph>f[o]o</paragraph>' );
+
+			command.execute( { file } );
+
+			const id = fileRepository.getLoader( file ).id;
+			expect( getModelData( model ) )
+				.to.equal( `<paragraph>f</paragraph>[<image uploadId="${ id }"></image>]<paragraph>o</paragraph>` );
+		} );
+
+		it( 'should insert directly at specified position (options.insertAt)', () => {
+			const file = createNativeFileMock();
+			setModelData( model, '<paragraph>f[]oo</paragraph>' );
+
+			const insertAt = new ModelPosition( doc.getRoot(), [ 0, 2 ] ); // fo[]o
+
+			command.execute( { file, insertAt } );
+
+			const id = fileRepository.getLoader( file ).id;
+			expect( getModelData( model ) )
+				.to.equal( `<paragraph>fo</paragraph>[<image uploadId="${ id }"></image>]<paragraph>o</paragraph>` );
+		} );
+
+		it( 'should use parent batch', () => {
+			const file = createNativeFileMock();
+
+			setModelData( model, '<paragraph>[]foo</paragraph>' );
+
+			model.change( writer => {
+				expect( writer.batch.deltas ).to.length( 0 );
+
+				command.execute( { file } );
+
+				expect( writer.batch.deltas ).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' } );
+
+			buildModelConverter().for( editor.editing.modelToView )
+				.fromElement( 'other' )
+				.toElement( '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 error anyway)', () => {
+			const file = createNativeFileMock();
+
+			fileRepository.createAdapter = undefined;
+
+			const logStub = testUtils.sinon.stub( log, 'error' );
+
+			setModelData( model, '<paragraph>fo[]o</paragraph>' );
+
+			expect( () => {
+				command.execute( { file } );
+			} ).to.not.throw();
+
+			expect( getModelData( model ) ).to.equal( '<paragraph>fo[]o</paragraph>' );
+			expect( logStub.calledOnce ).to.be.true;
+		} );
+	} );
+} );

+ 436 - 0
packages/ckeditor5-image/tests/imageupload/imageuploadediting.js

@@ -0,0 +1,436 @@
+/**
+ * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* globals window, setTimeout */
+
+import VirtualTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/virtualtesteditor';
+
+import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
+import ImageEngine from '../../src/image/imageengine';
+import ImageUploadEditing from '../../src/imageupload/imageuploadediting';
+import ImageUploadCommand from '../../src/imageupload/imageuploadcommand';
+import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
+import UndoEngine from '@ckeditor/ckeditor5-undo/src/undoengine';
+import DataTransfer from '@ckeditor/ckeditor5-clipboard/src/datatransfer';
+
+import FileRepository from '@ckeditor/ckeditor5-upload/src/filerepository';
+import { AdapterMock, 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 } from '@ckeditor/ckeditor5-engine/src/dev-utils/view';
+import Range from '@ckeditor/ckeditor5-engine/src/model/range';
+import Position from '@ckeditor/ckeditor5-engine/src/model/position';
+
+import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils';
+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 editor, model, doc, fileRepository, viewDocument, nativeReaderMock, loader, adapterMock;
+
+	testUtils.createSinonSandbox();
+
+	class UploadAdapterPluginMock extends Plugin {
+		init() {
+			fileRepository = this.editor.plugins.get( FileRepository );
+			fileRepository.createAdapter = newLoader => {
+				loader = newLoader;
+				adapterMock = new AdapterMock( loader );
+
+				return adapterMock;
+			};
+		}
+	}
+
+	beforeEach( () => {
+		testUtils.sinon.stub( window, 'FileReader' ).callsFake( () => {
+			nativeReaderMock = new NativeFileReaderMock();
+
+			return nativeReaderMock;
+		} );
+
+		return VirtualTestEditor
+			.create( {
+				plugins: [ ImageEngine, ImageUploadEditing, Paragraph, UndoEngine, UploadAdapterPluginMock ]
+			} )
+			.then( newEditor => {
+				editor = newEditor;
+				model = editor.model;
+				doc = model.document;
+				viewDocument = editor.editing.view;
+			} );
+	} );
+
+	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 execute imageUpload command when image is pasted', () => {
+		const spy = sinon.spy( editor, 'execute' );
+		const fileMock = createNativeFileMock();
+		const dataTransfer = new DataTransfer( { files: [ fileMock ], types: [ 'Files' ] } );
+		setModelData( model, '<paragraph>[]foo</paragraph>' );
+
+		const targetRange = Range.createFromParentsAndOffsets( doc.getRoot(), 1, doc.getRoot(), 1 );
+		const targetViewRange = editor.editing.mapper.toViewRange( targetRange );
+
+		viewDocument.fire( 'clipboardInput', { dataTransfer, targetRanges: [ targetViewRange ] } );
+
+		sinon.assert.calledOnce( spy );
+		sinon.assert.calledWith( spy, 'imageUpload' );
+
+		const id = fileRepository.getLoader( fileMock ).id;
+		expect( getModelData( model ) ).to.equal(
+			`<paragraph>foo</paragraph>[<image uploadId="${ id }" uploadStatus="reading"></image>]`
+		);
+	} );
+
+	it( 'should execute imageUpload command with an optimized position when image is pasted', () => {
+		const spy = sinon.spy( editor, 'execute' );
+		const fileMock = createNativeFileMock();
+		const dataTransfer = new DataTransfer( { files: [ fileMock ], types: [ 'Files' ] } );
+		setModelData( model, '<paragraph>[]foo</paragraph>' );
+
+		const paragraph = doc.getRoot().getChild( 0 );
+		const targetRange = Range.createFromParentsAndOffsets( paragraph, 1, paragraph, 1 ); // f[]oo
+		const targetViewRange = editor.editing.mapper.toViewRange( targetRange );
+
+		viewDocument.fire( 'clipboardInput', { dataTransfer, targetRanges: [ targetViewRange ] } );
+
+		sinon.assert.calledOnce( spy );
+		sinon.assert.calledWith( spy, 'imageUpload' );
+
+		const id = fileRepository.getLoader( fileMock ).id;
+		expect( getModelData( model ) ).to.equal(
+			`[<image uploadId="${ id }" uploadStatus="reading"></image>]<paragraph>foo</paragraph>`
+		);
+	} );
+
+	it( 'should execute imageUpload command when multiple files image are pasted', () => {
+		const spy = sinon.spy( editor, 'execute' );
+		const files = [ createNativeFileMock(), createNativeFileMock() ];
+		const dataTransfer = new DataTransfer( { files, types: [ 'Files' ] } );
+		setModelData( model, '<paragraph>[]foo</paragraph>' );
+
+		const targetRange = Range.createFromParentsAndOffsets( doc.getRoot(), 1, doc.getRoot(), 1 );
+		const targetViewRange = editor.editing.mapper.toViewRange( targetRange );
+
+		viewDocument.fire( 'clipboardInput', { dataTransfer, targetRanges: [ targetViewRange ] } );
+
+		sinon.assert.calledTwice( spy );
+		sinon.assert.calledWith( spy, 'imageUpload' );
+
+		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 not execute imageUpload command when file is not an image', () => {
+		const spy = sinon.spy( editor, 'execute' );
+		const viewDocument = editor.editing.view;
+		const fileMock = {
+			type: 'media/mp3',
+			size: 1024
+		};
+		const dataTransfer = new DataTransfer( { files: [ fileMock ], types: [ 'Files' ] } );
+
+		setModelData( model, '<paragraph>foo[]</paragraph>' );
+
+		const targetRange = Range.createFromParentsAndOffsets( doc.getRoot(), 1, doc.getRoot(), 1 );
+		const targetViewRange = editor.editing.mapper.toViewRange( targetRange );
+
+		viewDocument.fire( 'clipboardInput', { dataTransfer, targetRanges: [ targetViewRange ] } );
+
+		sinon.assert.notCalled( spy );
+	} );
+
+	it( 'should not execute imageUpload command when there is non-empty HTML content pasted', () => {
+		const spy = sinon.spy( editor, 'execute' );
+		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 = Range.createFromParentsAndOffsets( doc.getRoot(), 1, doc.getRoot(), 1 );
+		const targetViewRange = editor.editing.mapper.toViewRange( targetRange );
+
+		viewDocument.fire( 'clipboardInput', { dataTransfer, targetRanges: [ targetViewRange ] } );
+
+		sinon.assert.notCalled( spy );
+	} );
+
+	// 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>[]foo</paragraph>' );
+	} );
+
+	it( 'should not convert image\'s uploadId attribute if is consumed already', () => {
+		editor.editing.modelToView.on( 'attribute:uploadId:image', ( evt, data, consumable ) => {
+			consumable.consume( data.item, evt.name );
+		}, { priority: 'high' } );
+
+		setModelData( model, '<image uploadId="1234"></image>' );
+
+		expect( getViewData( viewDocument ) ).to.equal(
+			'[<figure class="ck-widget image" contenteditable="false">' +
+				'<img></img>' +
+			'</figure>]' );
+	} );
+
+	it( 'should use read data once it is present', done => {
+		const file = createNativeFileMock();
+		setModelData( model, '<paragraph>{}foo bar</paragraph>' );
+		editor.execute( 'imageUpload', { file } );
+
+		model.once( '_change', () => {
+			expect( getViewData( viewDocument ) ).to.equal(
+				'[<figure class="ck-widget image" contenteditable="false">' +
+				`<img src="${ base64Sample }"></img>` +
+				'</figure>]' +
+				'<p>foo bar</p>' );
+			expect( loader.status ).to.equal( 'uploading' );
+
+			done();
+		} );
+
+		expect( loader.status ).to.equal( 'reading' );
+		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', () => {
+				expect( getViewData( viewDocument ) ).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' );
+
+				done();
+			}, { priority: 'lowest' } );
+
+			adapterMock.mockSuccess( { default: 'image.png' } );
+		} );
+
+		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 ) => {
+			expect( data.message ).to.equal( 'Reading error.' );
+			expect( data.title ).to.equal( 'Upload failed' );
+			evt.stop();
+
+			done();
+		}, { priority: 'high' } );
+
+		setModelData( model, '<paragraph>{}foo bar</paragraph>' );
+		editor.execute( 'imageUpload', { file } );
+
+		nativeReaderMock.mockError( 'Reading error.' );
+	} );
+
+	it( 'should not fire notification on abort', done => {
+		const notification = editor.plugins.get( Notification );
+		const file = createNativeFileMock();
+		const spy = testUtils.sinon.spy();
+
+		notification.on( 'show:warning', evt => {
+			spy();
+			evt.stop();
+		}, { priority: 'high' } );
+
+		setModelData( model, '<paragraph>{}foo bar</paragraph>' );
+		editor.execute( 'imageUpload', { file } );
+		nativeReaderMock.abort();
+
+		setTimeout( () => {
+			sinon.assert.notCalled( spy );
+			done();
+		}, 0 );
+	} );
+
+	it( 'should do nothing if image does not have uploadId', () => {
+		setModelData( model, '<image src="image.png"></image>' );
+
+		expect( getViewData( viewDocument ) ).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 = testUtils.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', () => {
+				expect( getModelData( model ) ).to.equal( '<paragraph>[]foo bar</paragraph>' );
+				sinon.assert.calledOnce( spy );
+
+				done();
+			} );
+		} );
+
+		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 = testUtils.sinon.spy( loader, 'abort' );
+
+		expect( loader.status ).to.equal( 'reading' );
+		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 = testUtils.sinon.spy( loader, 'abort' );
+		const loadSpy = testUtils.sinon.spy( loader, 'read' );
+
+		const image = doc.getRoot().getChild( 0 );
+
+		model.change( writer => {
+			writer.move( Range.createOn( image ), Position.createAt( 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 } );
+
+		model.document.once( 'change', () => {
+			// This is called after "manual" remove.
+			model.document.once( 'change', () => {
+				// This is called after attributes are removed.
+				let undone = false;
+
+				model.document.once( 'change', () => {
+					if ( !undone ) {
+						undone = true;
+
+						// This is called after abort remove.
+						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', () => {
+				expect( getViewData( viewDocument ) ).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' );
+
+				done();
+			}, { priority: 'lowest' } );
+
+			adapterMock.mockSuccess( { default: 'image.png', 500: 'image-500.png', 800: 'image-800.png' } );
+		} );
+
+		nativeReaderMock.mockSuccess( base64Sample );
+	} );
+
+	it( 'should prevent from browser redirecting when an image is dropped on another image', () => {
+		const spy = testUtils.sinon.spy();
+
+		editor.editing.view.fire( 'dragover', {
+			preventDefault: spy
+		} );
+
+		expect( spy.calledOnce ).to.equal( true );
+	} );
+} );

+ 193 - 0
packages/ckeditor5-image/tests/imageupload/imageuploadprogress.js

@@ -0,0 +1,193 @@
+/**
+ * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* globals window */
+
+import VirtualTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/virtualtesteditor';
+
+import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
+import ImageEngine from '../../src/image/imageengine';
+import ImageUploadEditing from '../../src/imageupload/imageuploadediting';
+import ImageUploadProgress from '../../src/imageupload/imageuploadprogress';
+import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
+import FileRepository from '@ckeditor/ckeditor5-upload/src/filerepository';
+
+import { AdapterMock, createNativeFileMock, NativeFileReaderMock } 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, document, fileRepository, viewDocument, nativeReaderMock, loader, adapterMock;
+
+	class UploadAdapterPluginMock extends Plugin {
+		init() {
+			fileRepository = this.editor.plugins.get( FileRepository );
+			fileRepository.createAdapter = newLoader => {
+				loader = newLoader;
+				adapterMock = new AdapterMock( loader );
+
+				return adapterMock;
+			};
+		}
+	}
+
+	testUtils.createSinonSandbox();
+
+	beforeEach( () => {
+		testUtils.sinon.stub( window, 'FileReader' ).callsFake( () => {
+			nativeReaderMock = new NativeFileReaderMock();
+
+			return nativeReaderMock;
+		} );
+
+		return VirtualTestEditor
+			.create( {
+				plugins: [ ImageEngine, Paragraph, ImageUploadEditing, ImageUploadProgress, UploadAdapterPluginMock ]
+			} )
+			.then( newEditor => {
+				editor = newEditor;
+				model = editor.model;
+				document = model.document;
+				viewDocument = editor.editing.view;
+
+				fileRepository = editor.plugins.get( FileRepository );
+				fileRepository.createAdapter = newLoader => {
+					loader = newLoader;
+					adapterMock = new AdapterMock( loader );
+
+					return adapterMock;
+				};
+			} );
+	} );
+
+	it( 'should convert image\'s "reading" uploadStatus attribute', () => {
+		setModelData( model, '<paragraph>[]foo</paragraph>' );
+		editor.execute( 'imageUpload', { file: createNativeFileMock() } );
+
+		expect( getViewData( viewDocument ) ).to.equal(
+			'[<figure class="ck-appear ck-image-upload-placeholder ck-infinite-progress ck-widget image" contenteditable="false">' +
+				`<img src="data:image/svg+xml;utf8,${ imagePlaceholder }"></img>` +
+			'</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', () => {
+			expect( getViewData( viewDocument ) ).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();
+		}, { priority: 'lowest' } );
+
+		nativeReaderMock.mockSuccess( base64Sample );
+	} );
+
+	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 );
+
+			expect( getViewData( viewDocument ) ).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();
+		}, { priority: 'lowest' } );
+
+		nativeReaderMock.mockSuccess( base64Sample );
+	} );
+
+	it( 'should convert image\'s "complete" uploadStatus attribute', done => {
+		setModelData( model, '<paragraph>[]foo</paragraph>' );
+		editor.execute( 'imageUpload', { file: createNativeFileMock() } );
+
+		model.document.once( 'change', () => {
+			model.document.once( 'change', () => {
+				expect( getViewData( viewDocument ) ).to.equal(
+					'[<figure class="ck-widget image" contenteditable="false">' +
+						'<img src="image.png"></img>' +
+					'</figure>]<p>foo</p>'
+				);
+
+				done();
+			}, { priority: 'lowest' } );
+
+			adapterMock.mockSuccess( { default: 'image.png' } );
+		} );
+
+		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( viewDocument ) ).to.equal(
+			'[<figure class="ck-appear ck-image-upload-placeholder ck-infinite-progress ck-widget image" contenteditable="false">' +
+				`<img src="${ base64Sample }"></img>` +
+			'</figure>]<p>foo</p>'
+		);
+	} );
+
+	it( 'should not process attribute change if it is already consumed', () => {
+		editor.editing.modelToView.on( 'attribute:uploadStatus:image', ( evt, data, consumable ) => {
+			consumable.consume( data.item, evt.name );
+		}, { priority: 'highest' } );
+
+		setModelData( model, '<paragraph>[]foo</paragraph>' );
+		editor.execute( 'imageUpload', { file: createNativeFileMock() } );
+
+		expect( getViewData( viewDocument ) ).to.equal(
+			'[<figure class="ck-widget image" contenteditable="false"><img></img></figure>]<p>foo</p>'
+		);
+	} );
+
+	it( 'should not show progress bar if there is no loader with given uploadId', () => {
+		setModelData( model, '<image uploadId="123" uploadStatus="reading"></image>' );
+
+		const image = document.getRoot().getChild( 0 );
+
+		model.change( writer => {
+			writer.setAttribute( 'uploadStatus', 'uploading', image );
+		} );
+
+		expect( getViewData( viewDocument ) ).to.equal(
+			'[<figure class="ck-appear ck-image-upload-placeholder ck-infinite-progress ck-widget image" contenteditable="false">' +
+				`<img src="data:image/svg+xml;utf8,${ imagePlaceholder }"></img>` +
+			'</figure>]'
+		);
+
+		model.change( writer => {
+			writer.setAttribute( 'uploadStatus', 'complete', image );
+		} );
+
+		expect( getViewData( viewDocument ) ).to.equal(
+			'[<figure class="ck-widget image" contenteditable="false">' +
+				`<img src="data:image/svg+xml;utf8,${ imagePlaceholder }"></img>` +
+			'</figure>]'
+		);
+	} );
+} );

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

@@ -0,0 +1,166 @@
+/**
+ * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* 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 FileDialogButtonView from '@ckeditor/ckeditor5-upload/src/ui/filedialogbuttonview';
+import FileRepository from '@ckeditor/ckeditor5-upload/src/filerepository';
+import ImageUploadUI from '../../src/imageupload/imageuploadui';
+import ImageUploadEditing from '../../src/imageupload/imageuploadediting';
+import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
+import Notification from '@ckeditor/ckeditor5-ui/src/notification/notification';
+
+import { createNativeFileMock, AdapterMock } 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.createAdapter = loader => {
+				return new AdapterMock( loader );
+			};
+		}
+	}
+
+	beforeEach( () => {
+		editorElement = document.createElement( 'div' );
+		document.body.appendChild( editorElement );
+
+		return ClassicEditor
+			.create( editorElement, {
+				plugins: [ Paragraph, Image, ImageUploadEditing, ImageUploadUI, FileRepository, UploadAdapterPluginMock ]
+			} )
+			.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 uploadImage button', () => {
+		const button = editor.ui.componentFactory.create( 'uploadImage' );
+
+		expect( button ).to.be.instanceOf( FileDialogButtonView );
+	} );
+
+	it( 'should be disabled while ImageUploadCommand is disabled', () => {
+		const button = editor.ui.componentFactory.create( 'uploadImage' );
+		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( 'uploadImage' );
+		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( 'uploadImage' );
+		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.equal( files[ 0 ] );
+	} );
+
+	it( 'should optimize the insertion position', () => {
+		const button = editor.ui.componentFactory.create( 'uploadImage' );
+		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( 'uploadImage' );
+		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( 'uploadImage' );
+		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( 'uploadImage' );
+		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.equal( files[ 0 ] );
+	} );
+} );
+

+ 114 - 0
packages/ckeditor5-image/tests/imageupload/utils.js

@@ -0,0 +1,114 @@
+/**
+ * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+import { isImageType, findOptimalInsertionPosition } from '../../src/imageupload/utils';
+import Model from '@ckeditor/ckeditor5-engine/src/model/model';
+import { setData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
+
+describe( 'upload utils', () => {
+	describe( 'isImageType()', () => {
+		it( 'should return true for png mime type', () => {
+			expect( isImageType( { type: 'image/png' } ) ).to.be.true;
+		} );
+
+		it( 'should return true for jpeg mime type', () => {
+			expect( isImageType( { type: 'image/jpeg' } ) ).to.be.true;
+		} );
+
+		it( 'should return true for gif mime type', () => {
+			expect( isImageType( { type: 'image/gif' } ) ).to.be.true;
+		} );
+
+		it( 'should return true for bmp mime type', () => {
+			expect( isImageType( { type: 'image/bmp' } ) ).to.be.true;
+		} );
+
+		it( 'should return false for other mime types', () => {
+			expect( isImageType( { type: 'audio/mp3' } ) ).to.be.false;
+			expect( isImageType( { type: 'video/mpeg' } ) ).to.be.false;
+		} );
+	} );
+
+	describe( 'findOptimalInsertionPosition()', () => {
+		let model, doc;
+
+		beforeEach( () => {
+			model = new Model();
+			doc = model.document;
+
+			doc.createRoot();
+
+			model.schema.register( 'paragraph', { inheritAllFrom: '$block' } );
+			model.schema.register( 'image' );
+			model.schema.register( 'span' );
+
+			model.schema.extend( 'image', {
+				allowIn: '$root',
+				isObject: true
+			} );
+
+			model.schema.extend( 'span', { allowIn: 'paragraph' } );
+			model.schema.extend( '$text', { allowIn: 'span' } );
+		} );
+
+		it( 'returns position after selected element', () => {
+			setData( model, '<paragraph>x</paragraph>[<image></image>]<paragraph>y</paragraph>' );
+
+			const pos = findOptimalInsertionPosition( doc.selection );
+
+			expect( pos.path ).to.deep.equal( [ 2 ] );
+		} );
+
+		it( 'returns position inside empty block', () => {
+			setData( model, '<paragraph>x</paragraph><paragraph>[]</paragraph><paragraph>y</paragraph>' );
+
+			const pos = findOptimalInsertionPosition( doc.selection );
+
+			expect( pos.path ).to.deep.equal( [ 1, 0 ] );
+		} );
+
+		it( 'returns position before block if at the beginning of that block', () => {
+			setData( model, '<paragraph>x</paragraph><paragraph>[]foo</paragraph><paragraph>y</paragraph>' );
+
+			const pos = findOptimalInsertionPosition( doc.selection );
+
+			expect( pos.path ).to.deep.equal( [ 1 ] );
+		} );
+
+		it( 'returns position before block if in the middle of that block', () => {
+			setData( model, '<paragraph>x</paragraph><paragraph>f[]oo</paragraph><paragraph>y</paragraph>' );
+
+			const pos = findOptimalInsertionPosition( doc.selection );
+
+			expect( pos.path ).to.deep.equal( [ 1 ] );
+		} );
+
+		it( 'returns position after block if at the end of that block', () => {
+			setData( model, '<paragraph>x</paragraph><paragraph>foo[]</paragraph><paragraph>y</paragraph>' );
+
+			const pos = findOptimalInsertionPosition( doc.selection );
+
+			expect( pos.path ).to.deep.equal( [ 2 ] );
+		} );
+
+		// Checking if isTouching() was used.
+		it( 'returns position after block if at the end of that block (deeply nested)', () => {
+			setData( model, '<paragraph>x</paragraph><paragraph>foo<span>bar[]</span></paragraph><paragraph>y</paragraph>' );
+
+			const pos = findOptimalInsertionPosition( doc.selection );
+
+			expect( pos.path ).to.deep.equal( [ 2 ] );
+		} );
+
+		it( 'returns selection focus if not in a block', () => {
+			model.schema.extend( '$text', { allowIn: '$root' } );
+			setData( model, 'foo[]bar' );
+
+			const pos = findOptimalInsertionPosition( doc.selection );
+
+			expect( pos.path ).to.deep.equal( [ 3 ] );
+		} );
+	} );
+} );

+ 2 - 0
packages/ckeditor5-image/tests/manual/imageplaceholder.html

@@ -0,0 +1,2 @@
+<div id="container">
+</div>

+ 21 - 0
packages/ckeditor5-image/tests/manual/imageplaceholder.js

@@ -0,0 +1,21 @@
+/**
+ * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* global document */
+
+import VirtualTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/virtualtesteditor';
+import ImageEngine from '../../src/image/imageengine';
+import ImageUploadEditing from '../../src/imageupload/imageuploadediting';
+import ImageUploadProgress from '../../src/imageupload/imageuploadprogress';
+
+VirtualTestEditor.create( { plugins: [ ImageEngine, ImageUploadEditing, ImageUploadProgress ] } )
+	.then( editor => {
+		const imageUploadProgress = editor.plugins.get( ImageUploadProgress );
+		const img = document.createElement( 'img' );
+
+		img.src = imageUploadProgress.placeholder;
+		document.getElementById( 'container' ).appendChild( img );
+	} );
+

+ 3 - 0
packages/ckeditor5-image/tests/manual/imageplaceholder.md

@@ -0,0 +1,3 @@
+## Image placeholder
+
+Check if image placeholder is visible.

+ 17 - 0
packages/ckeditor5-image/tests/manual/imageupload.html

@@ -0,0 +1,17 @@
+<div id="editor">
+	<h2>Image upload</h2>
+	<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nulla finibus consequat placerat. Vestibulum id tellus et mauris sagittis tincidunt quis id mauris. Curabitur consectetur lectus sit amet tellus mattis, non lobortis leo interdum.</p>
+</div>
+
+<div id="button-container"></div>
+
+<style>
+	#button-container div {
+		margin-top: 10px;
+	}
+
+	#button-container button {
+		margin-right: 10px;
+	}
+</style>
+

+ 100 - 0
packages/ckeditor5-image/tests/manual/imageupload.js

@@ -0,0 +1,100 @@
+/**
+ * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* globals document, console */
+
+import ClassicEditor from '@ckeditor/ckeditor5-editor-classic/src/classiceditor';
+import Bold from '@ckeditor/ckeditor5-basic-styles/src/bold';
+import Clipboard from '@ckeditor/ckeditor5-clipboard/src/clipboard';
+import Enter from '@ckeditor/ckeditor5-enter/src/enter';
+import Heading from '@ckeditor/ckeditor5-heading/src/heading';
+import Italic from '@ckeditor/ckeditor5-basic-styles/src/italic';
+import List from '@ckeditor/ckeditor5-list/src/list';
+import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
+import Typing from '@ckeditor/ckeditor5-typing/src/typing';
+import Undo from '@ckeditor/ckeditor5-undo/src/undo';
+import { AdapterMock } from '@ckeditor/ckeditor5-upload/tests/_utils/mocks';
+import ImageStyle from '../../src/imagestyle';
+import ImageToolbar from '../../src/imagetoolbar';
+import Image from '../../src/image';
+import ImageCaption from '../../src/imagecaption';
+import ImageUpload from '../../src/imageupload';
+
+const buttonContainer = document.getElementById( 'button-container' );
+
+ClassicEditor
+	.create( document.querySelector( '#editor' ), {
+		plugins: [
+			Enter, Typing, Paragraph, Heading, Undo, Bold, Italic, Heading, List, Image, ImageToolbar, Clipboard,
+			ImageCaption, ImageStyle, ImageUpload
+		],
+		toolbar: [ 'headings', 'undo', 'redo', 'bold', 'italic', 'bulletedList', 'numberedList', 'uploadImage' ],
+		image: {
+			toolbar: [ 'imageStyleFull', 'imageStyleSide', '|', 'imageTextAlternative' ]
+		}
+	} )
+	.then( editor => {
+		// Register fake adapter.
+		editor.plugins.get( 'FileRepository' ).createAdapter = loader => {
+			const adapterMock = new AdapterMock( loader );
+			createProgressButton( loader, adapterMock );
+
+			return adapterMock;
+		};
+	} )
+	.catch( err => {
+		console.error( err.stack );
+	} );
+
+function createProgressButton( loader, adapterMock ) {
+	const fileName = loader.file.name;
+	const container = document.createElement( 'div' );
+	const progressInfo = document.createElement( 'span' );
+	progressInfo.innerHTML = `File: ${ fileName }. Progress: 0%.`;
+	const progressButton = document.createElement( 'button' );
+	const errorButton = document.createElement( 'button' );
+	const abortButton = document.createElement( 'button' );
+	progressButton.innerHTML = 'Upload progress';
+	errorButton.innerHTML = 'Simulate error';
+	abortButton.innerHTML = 'Simulate aborting';
+
+	container.appendChild( progressButton );
+	container.appendChild( errorButton );
+	container.appendChild( abortButton );
+	container.appendChild( progressInfo );
+
+	buttonContainer.appendChild( container );
+
+	let progress = 0;
+	const total = 500;
+	progressButton.addEventListener( 'click', () => {
+		progress += 100;
+		adapterMock.mockProgress( progress, total );
+
+		if ( progress == total ) {
+			disableButtons();
+			adapterMock.mockSuccess( { default: './sample.jpg' } );
+		}
+
+		progressInfo.innerHTML = `File: ${ fileName }. Progress: ${ loader.uploadedPercent }%.`;
+	} );
+
+	errorButton.addEventListener( 'click', () => {
+		adapterMock.mockError( 'Upload error!' );
+		disableButtons();
+	} );
+
+	abortButton.addEventListener( 'click', () => {
+		loader.abort();
+		disableButtons();
+	} );
+
+	function disableButtons() {
+		progressButton.setAttribute( 'disabled', 'true' );
+		errorButton.setAttribute( 'disabled', 'true' );
+		abortButton.setAttribute( 'disabled', 'true' );
+	}
+}
+

+ 15 - 0
packages/ckeditor5-image/tests/manual/imageupload.md

@@ -0,0 +1,15 @@
+## Image upload
+
+1. Drop an image into editor.
+1. Image should be read and displayed.
+1. Press "Upload progress" button couple times to simulate upload process.
+1. After uploading is complete your image should be replaced with sample image from server.
+
+On the occasionn – when you drop an image on another image in the editor,
+your browser [**should not** redirect to the image](https://github.com/ckeditor/ckeditor5-upload/issues/32).
+
+Repeat all the steps with:
+* dropping multiple images,
+* using toolbar button to add one and multiple images,
+* using `Simulate error` button to stop upload, show error and remove image,
+* using `Simulate aborting` button to stop upload and remove image.

+ 1 - 0
packages/ckeditor5-image/theme/icons/image_placeholder.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 700 250"><g fill="none" fill-rule="evenodd"><rect width="700" height="250" fill="#F7F7F7" rx="4"/><text fill="#5F6F77" font-family="Arial,sans-serif" font-size="24"><tspan x="247.9" y="135">Uploading image…</tspan></text></g></svg>

+ 29 - 0
packages/ckeditor5-image/theme/imageuploadprogress.css

@@ -0,0 +1,29 @@
+/*
+ * Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* Infinite progress bar default width. */
+:root {
+	--ck-image-upload-progress-line-width: 30px;
+}
+
+figure.image {
+	position: relative;
+	overflow: hidden;
+
+	/* Infinite progress bar on top while image is read. */
+	&.ck-infinite-progress::before {
+		content: "";
+		position: absolute;
+		top: 0;
+		right: 0;
+	}
+
+	/* Upload progress bar. */
+	& .ck-progress-bar {
+		position: absolute;
+		top: 0;
+		left: 0;
+	}
+}