8
0
فهرست منبع

Merge pull request #21 from ckeditor/t/15

Feature: Introduced ImageUploadProgress plugin. Closes #15.
Piotr Jasiun 8 سال پیش
والد
کامیت
ef68a08e9f

+ 2 - 2
packages/ckeditor5-upload/src/filerepository.js

@@ -67,7 +67,7 @@ export default class FileRepository extends Plugin {
 		/**
 		 * Number of total bytes to upload.
 		 * It might be different than the file size because of headers and additional data.
-		 * It contains `null` if value is not available yet, so it's better to use {@link #uploadPercent} to monitor
+		 * It contains `null` if value is not available yet, so it's better to use {@link #uploadedPercent} to monitor
 		 * the progress.
 		 *
 		 * @readonly
@@ -317,7 +317,7 @@ class FileLoader {
 	}
 
 	/**
-	 * Reads file using provided {@link module:upload/filereader~Adapter}.
+	 * Reads file using provided {@link module:upload/filerepository~Adapter}.
 	 * Throws {@link module:utils/ckeditorerror~CKEditorError CKEditorError} `filerepository-upload-wrong-status` when status
 	 * is different than `idle`.
 	 * Example usage:

+ 6 - 33
packages/ckeditor5-upload/src/imageupload.js

@@ -8,13 +8,14 @@
  */
 
 import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
-import ImageUploadEngine from './imageuploadengine';
-import FileDialogButtonView from './ui/filedialogbuttonview';
-import imageIcon from '@ckeditor/ckeditor5-core/theme/icons/image.svg';
+import ImageUploadButton from './imageuploadbutton';
+import ImageUploadProgress from './imageuploadprogress';
 
 /**
  * Image upload plugin.
- * Adds `insertImage` button to UI component factory.
+ * This plugin do not do anything directly, but loads set of specific plugins to enable image uploading:
+ * * {@link module:upload/imageuploadbutton~ImageUploadButton},
+ * * {@link module:upload/imageuploadprogress~ImageUploadProgress}.
  *
  * @extends module:core/plugin~Plugin
  */
@@ -23,34 +24,6 @@ export default class ImageUpload extends Plugin {
 	 * @inheritDoc
 	 */
 	static get requires() {
-		return [ ImageUploadEngine ];
-	}
-
-	/**
-	 * @inheritDoc
-	 */
-	init() {
-		const editor = this.editor;
-		const t = editor.t;
-
-		editor.ui.componentFactory.add( 'insertImage', ( locale ) => {
-			const view = new FileDialogButtonView( locale );
-
-			view.set( {
-				label: t( 'Insert image' ),
-				icon: imageIcon,
-				tooltip: true,
-				acceptedType: 'image/*',
-				allowMultipleFiles: true
-			} );
-
-			view.on( 'done', ( evt, files ) => {
-				for ( const file of files ) {
-					editor.execute( 'imageUpload', { file: file } );
-				}
-			} );
-
-			return view;
-		} );
+		return [ ImageUploadButton, ImageUploadProgress ];
 	}
 }

+ 57 - 0
packages/ckeditor5-upload/src/imageuploadbutton.js

@@ -0,0 +1,57 @@
+/**
+ * @license Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/**
+ * @module upload/imageuploadbutton
+ */
+
+import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
+import ImageUploadEngine from './imageuploadengine';
+import FileDialogButtonView from './ui/filedialogbuttonview';
+import imageIcon from '@ckeditor/ckeditor5-core/theme/icons/image.svg';
+
+/**
+ * Image upload button plugin.
+ * Adds `insertImage` button to UI component factory.
+ *
+ * @extends module:core/plugin~Plugin
+ */
+export default class ImageUploadButton extends Plugin {
+	/**
+	 * @inheritDoc
+	 */
+	static get requires() {
+		return [ ImageUploadEngine ];
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	init() {
+		const editor = this.editor;
+		const t = editor.t;
+
+		// Setup `insertImage` button.
+		editor.ui.componentFactory.add( 'insertImage', ( locale ) => {
+			const view = new FileDialogButtonView( locale );
+
+			view.set( {
+				label: t( 'Insert image' ),
+				icon: imageIcon,
+				tooltip: true,
+				acceptedType: 'image/*',
+				allowMultipleFiles: true
+			} );
+
+			view.on( 'done', ( evt, files ) => {
+				for ( const file of files ) {
+					editor.execute( 'imageUpload', { file: file } );
+				}
+			} );
+
+			return view;
+		} );
+	}
+}

+ 14 - 28
packages/ckeditor5-upload/src/imageuploadengine.js

@@ -8,11 +8,9 @@
  */
 
 import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
-import { eventNameToConsumableType } from '@ckeditor/ckeditor5-engine/src/conversion/model-to-view-converters';
 import FileRepository from './filerepository';
 import ImageUploadCommand from './imageuploadcommand';
 import Notification from '@ckeditor/ckeditor5-ui/src/notification/notification';
-import uploadingPlaceholder from '../theme/icons/image_placeholder.svg';
 import { isImageType } from './utils';
 
 /**
@@ -21,18 +19,6 @@ import { isImageType } from './utils';
  * @extends module:core/plugin~Plugin
  */
 export default class ImageUploadEngine extends Plugin {
-	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,' + uploadingPlaceholder;
-	}
-
 	/**
 	 * @inheritDoc
 	 */
@@ -50,6 +36,7 @@ export default class ImageUploadEngine extends Plugin {
 
 		// Setup schema to allow uploadId for images.
 		schema.allow( { name: 'image', attributes: [ 'uploadId' ], inside: '$root' } );
+		schema.allow( { name: 'image', attributes: [ 'uploadStatus' ], inside: '$root' } );
 		schema.requireAttributes( 'image', [ 'uploadId' ] );
 
 		// Register imageUpload command.
@@ -85,19 +72,6 @@ export default class ImageUploadEngine extends Plugin {
 				}
 			}
 		} );
-
-		// Model to view converter for image's `uploadId` attribute.
-		editor.editing.modelToView.on( 'addAttribute:uploadId:image', ( evt, data, consumable ) => {
-			if ( !consumable.consume( data.item, eventNameToConsumableType( evt.name ) ) ) {
-				return;
-			}
-
-			const modelImage = data.item;
-			const viewFigure = editor.editing.mapper.toViewElement( modelImage );
-			const viewImg = viewFigure.getChild( 0 );
-
-			viewImg.setAttribute( 'src', this.placeholder );
-		} );
 	}
 
 	/**
@@ -115,17 +89,28 @@ export default class ImageUploadEngine extends Plugin {
 		const fileRepository = editor.plugins.get( FileRepository );
 		const notification = editor.plugins.get( Notification );
 
+		doc.enqueueChanges( () => {
+			batch.setAttribute( imageElement, 'uploadStatus', 'reading' );
+		} );
+
 		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();
 
-				return loader.upload();
+				doc.enqueueChanges( () => {
+					batch.setAttribute( imageElement, 'uploadStatus', 'uploading' );
+				} );
+
+				return promise;
 			} )
 			.then( data => {
 				doc.enqueueChanges( () => {
+					batch.setAttribute( imageElement, 'uploadStatus', 'complete' );
 					batch.setAttribute( imageElement, 'src', data.original );
 				} );
 
@@ -143,6 +128,7 @@ export default class ImageUploadEngine extends Plugin {
 		function clean() {
 			doc.enqueueChanges( () => {
 				batch.removeAttribute( imageElement, 'uploadId' );
+				batch.removeAttribute( imageElement, 'uploadStatus' );
 			} );
 
 			fileRepository.destroyLoader( loader );

+ 139 - 0
packages/ckeditor5-upload/src/imageuploadprogress.js

@@ -0,0 +1,139 @@
+/**
+ * @license Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/**
+ * @module upload/imageuploadprogress
+ */
+
+import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
+import { eventNameToConsumableType } from '@ckeditor/ckeditor5-engine/src/conversion/model-to-view-converters';
+import FileRepository from './filerepository';
+import uploadingPlaceholder from '../theme/icons/image_placeholder.svg';
+import UIElement from '@ckeditor/ckeditor5-engine/src/view/uielement';
+import ImageUploadEngine from './imageuploadengine';
+
+import '../theme/imageuploadprogress.scss';
+
+/**
+ * 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
+	 */
+	static get requires() {
+		return [ ImageUploadEngine ];
+	}
+
+	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,' + uploadingPlaceholder;
+	}
+
+	init() {
+		const editor = this.editor;
+
+		// Upload status change - update image's view according to that status.
+		editor.editing.modelToView.on( 'addAttribute:uploadStatus:image', ( ...args ) => this.uploadStatusChange( ...args ) );
+		editor.editing.modelToView.on( 'changeAttribute: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 fileRepository = editor.plugins.get( FileRepository );
+		const placeholder = this.placeholder;
+
+		if ( !consumable.consume( data.item, eventNameToConsumableType( evt.name ) ) ) {
+			return;
+		}
+
+		const status = data.attributeNewValue;
+		const modelImage = data.item;
+		const viewFigure = editor.editing.mapper.toViewElement( modelImage );
+		const uploadId = modelImage.getAttribute( 'uploadId' );
+		const loader = fileRepository.loaders.get( uploadId );
+
+		if ( !loader ) {
+			return;
+		}
+
+		// 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' );
+			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' ) {
+			viewFigure.removeClass( 'ck-infinite-progress' );
+			const progressBar = createProgressBar();
+			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 );
+
+		viewFigure.removeClass( 'ck-appear' );
+		progressBar.remove();
+
+		editor.editing.view.render();
+	}
+}
+
+// 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;
+		}
+	}
+}

+ 1 - 1
packages/ckeditor5-upload/src/ui/filedialogbuttonview.js

@@ -29,7 +29,7 @@ export default class FileDialogButtonView extends ButtonView {
 		 * Hidden input view used to execute file dialog. It will be hidden and added to the end of `document.body`.
 		 *
 		 * @protected
-		 * @member module:upload/ui/filedialogbuttonview~FileDialogButtonView #fileInputView
+		 * @member module:upload/ui/filedialogbuttonview~FileInputView #fileInputView
 		 */
 		this.fileInputView = new FileInputView( locale );
 

+ 6 - 20
packages/ckeditor5-upload/tests/imageupload.js

@@ -7,10 +7,9 @@
 
 import ClassicEditor from '@ckeditor/ckeditor5-editor-classic/src/classic';
 import Image from '@ckeditor/ckeditor5-image/src/image';
-import FileDialogButtonView from '../src/ui/filedialogbuttonview';
 import ImageUpload from '../src/imageupload';
-import ImageUploadEngine from '../src/imageuploadengine';
-import { createNativeFileMock } from './_utils/mocks';
+import ImageUploadProgress from '../src/imageuploadprogress';
+import ImageUploadButton from '../src/imageuploadbutton';
 
 describe( 'ImageUpload', () => {
 	let editor;
@@ -27,25 +26,12 @@ describe( 'ImageUpload', () => {
 		} );
 	} );
 
-	it( 'should load ImageUploadEngine', () => {
-		expect( editor.plugins.get( ImageUploadEngine ) ).to.be.instanceOf( ImageUploadEngine );
+	it( 'should include ImageUploadProgress', () => {
+		expect( editor.plugins.get( ImageUploadProgress ) ).to.be.instanceOf( ImageUploadProgress );
 	} );
 
-	it( 'should register insertImage button', () => {
-		const button = editor.ui.componentFactory.create( 'insertImage' );
-
-		expect( button ).to.be.instanceOf( FileDialogButtonView );
-	} );
-
-	it( 'should execute imageUpload command', () => {
-		const executeStub = sinon.stub( editor, 'execute' );
-		const button = editor.ui.componentFactory.create( 'insertImage' );
-		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 include ImageUploadButton', () => {
+		expect( editor.plugins.get( ImageUploadButton ) ).to.be.instanceOf( ImageUploadButton );
 	} );
 } );
 

+ 51 - 0
packages/ckeditor5-upload/tests/imageuploadbutton.js

@@ -0,0 +1,51 @@
+/**
+ * @license Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* globals document */
+
+import ClassicEditor from '@ckeditor/ckeditor5-editor-classic/src/classic';
+import Image from '@ckeditor/ckeditor5-image/src/image';
+import FileDialogButtonView from '../src/ui/filedialogbuttonview';
+import ImageUploadButton from '../src/imageuploadbutton';
+import ImageUploadEngine from '../src/imageuploadengine';
+import { createNativeFileMock } from './_utils/mocks';
+
+describe( 'ImageUploadButton', () => {
+	let editor;
+
+	beforeEach( () => {
+		const editorElement = document.createElement( 'div' );
+		document.body.appendChild( editorElement );
+
+		return ClassicEditor.create( editorElement, {
+			plugins: [ Image, ImageUploadButton ]
+		} )
+			.then( newEditor => {
+				editor = newEditor;
+			} );
+	} );
+
+	it( 'should include ImageUploadEngine', () => {
+		expect( editor.plugins.get( ImageUploadEngine ) ).to.be.instanceOf( ImageUploadEngine );
+	} );
+
+	it( 'should register insertImage button', () => {
+		const button = editor.ui.componentFactory.create( 'insertImage' );
+
+		expect( button ).to.be.instanceOf( FileDialogButtonView );
+	} );
+
+	it( 'should execute imageUpload command', () => {
+		const executeStub = sinon.stub( editor, 'execute' );
+		const button = editor.ui.componentFactory.create( 'insertImage' );
+		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 ] );
+	} );
+} );
+

+ 8 - 30
packages/ckeditor5-upload/tests/imageuploadengine.js

@@ -15,7 +15,6 @@ import FileRepository from '../src/filerepository';
 import { AdapterMock, createNativeFileMock, NativeFileReaderMock } from './_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 imagePlaceholder from '../theme/icons/image_placeholder.svg';
 import { eventNameToConsumableType } from '@ckeditor/ckeditor5-engine/src/conversion/model-to-view-converters';
 import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils';
 import Notification from '@ckeditor/ckeditor5-ui/src/notification/notification';
@@ -71,7 +70,7 @@ describe( 'ImageUploadEngine', () => {
 
 		const id = fileRepository.getLoader( fileMock ).id;
 		expect( getModelData( document ) ).to.equal(
-			`[<image uploadId="${ id }"></image>]<paragraph>foo bar baz</paragraph>`
+			`[<image uploadId="${ id }" uploadStatus="reading"></image>]<paragraph>foo bar baz</paragraph>`
 		);
 	} );
 
@@ -90,16 +89,6 @@ describe( 'ImageUploadEngine', () => {
 		sinon.assert.notCalled( spy );
 	} );
 
-	it( 'should convert image\'s uploadId attribute from model to view', () => {
-		setModelData( document, '<image uploadId="1234"></image>' );
-
-		expect( getViewData( viewDocument ) ).to.equal(
-			'[]<figure class="image ck-widget" contenteditable="false">' +
-				`<img src="data:image/svg+xml;utf8,${ imagePlaceholder }"></img>` +
-			'</figure>'
-		);
-	} );
-
 	it( 'should not convert image\'s uploadId attribute if is consumed already', () => {
 		editor.editing.modelToView.on( 'addAttribute:uploadId:image', ( evt, data, consumable ) => {
 			consumable.consume( data.item, eventNameToConsumableType( evt.name ) );
@@ -113,20 +102,21 @@ describe( 'ImageUploadEngine', () => {
 			'</figure>' );
 	} );
 
-	it( 'should replace placeholder with read data once it is present', ( done ) => {
+	it( 'should use read data once it is present', ( done ) => {
 		const file = createNativeFileMock();
 		setModelData( document, '<paragraph>{}foo bar</paragraph>' );
 		editor.execute( 'imageUpload', { file } );
 
-		adapterMock.uploadStartedCallback = () => {
+		document.once( 'changesDone', () => {
 			expect( getViewData( viewDocument ) ).to.equal(
 				'[<figure class="image ck-widget" contenteditable="false">' +
-					`<img src="${ base64Sample }"></img>` +
+				`<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 );
@@ -137,7 +127,7 @@ describe( 'ImageUploadEngine', () => {
 		setModelData( document, '<paragraph>{}foo bar</paragraph>' );
 		editor.execute( 'imageUpload', { file } );
 
-		adapterMock.uploadStartedCallback = () => {
+		document.once( 'changesDone', () => {
 			document.once( 'changesDone', () => {
 				expect( getViewData( viewDocument ) ).to.equal(
 					'[<figure class="image ck-widget" contenteditable="false"><img src="image.png"></img></figure>]<p>foo bar</p>'
@@ -148,7 +138,7 @@ describe( 'ImageUploadEngine', () => {
 			} );
 
 			adapterMock.mockSuccess( { original: 'image.png' } );
-		};
+		} );
 
 		nativeReaderMock.mockSuccess( base64Sample );
 	} );
@@ -197,16 +187,4 @@ describe( 'ImageUploadEngine', () => {
 			'[]<figure class="image ck-widget" contenteditable="false"><img src="image.png"></img></figure>'
 		);
 	} );
-
-	it( 'should allow to customize placeholder image', () => {
-		const uploadEngine = editor.plugins.get( ImageUploadEngine );
-		uploadEngine.placeholder = base64Sample;
-		setModelData( document, '<image uploadId="1234"></image>' );
-
-		expect( getViewData( viewDocument ) ).to.equal(
-			'[]<figure class="image ck-widget" contenteditable="false">' +
-			`<img src="${ base64Sample }"></img>` +
-			'</figure>'
-		);
-	} );
 } );

+ 159 - 0
packages/ckeditor5-upload/tests/imageuploadprogress.js

@@ -0,0 +1,159 @@
+/**
+ * @license Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* globals window */
+
+import ClassicTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/virtualtesteditor';
+import ImageEngine from '@ckeditor/ckeditor5-image/src/image/imageengine';
+import ImageUploadEngine from '../src/imageuploadengine';
+import ImageUploadProgress from '../src/imageuploadprogress';
+import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
+import FileRepository from '../src/filerepository';
+import { AdapterMock, createNativeFileMock, NativeFileReaderMock } from './_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 { eventNameToConsumableType } from '@ckeditor/ckeditor5-engine/src/conversion/model-to-view-converters';
+import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils';
+import imagePlaceholder from '../theme/icons/image_placeholder.svg';
+
+describe( 'ImageUploadProgress', () => {
+	const base64Sample = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=';
+	let editor, document, fileRepository, viewDocument, nativeReaderMock, loader, adapterMock;
+	testUtils.createSinonSandbox();
+
+	beforeEach( () => {
+		testUtils.sinon.stub( window, 'FileReader', () => {
+			nativeReaderMock = new NativeFileReaderMock();
+
+			return nativeReaderMock;
+		} );
+
+		return ClassicTestEditor.create( {
+			plugins: [ ImageEngine, Paragraph, ImageUploadProgress ]
+		} )
+		.then( newEditor => {
+			editor = newEditor;
+			document = editor.document;
+			viewDocument = editor.editing.view;
+
+			fileRepository = editor.plugins.get( FileRepository );
+			fileRepository.createAdapter = newLoader => {
+				loader = newLoader;
+				adapterMock = new AdapterMock( loader );
+
+				return adapterMock;
+			};
+		} );
+	} );
+
+	it( 'should include ImageUploadEngine', () => {
+		expect( editor.plugins.get( ImageUploadEngine ) ).to.be.instanceOf( ImageUploadEngine );
+	} );
+
+	it( 'should convert image\'s "reading" uploadStatus attribute', () => {
+		setModelData( document, '<paragraph>foo[]</paragraph>' );
+		editor.execute( 'imageUpload', { file: createNativeFileMock() } );
+
+		expect( getViewData( viewDocument ) ).to.equal(
+			'[<figure class="image ck-widget ck-appear ck-infinite-progress" 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( document, '<paragraph>foo[]</paragraph>' );
+		editor.execute( 'imageUpload', { file: createNativeFileMock() } );
+
+		document.once( 'changesDone', () => {
+			expect( getViewData( viewDocument ) ).to.equal(
+				'[<figure class="image ck-widget ck-appear" contenteditable="false">' +
+					`<img src="${ base64Sample }"></img>` +
+					'<div class="ck-progress-bar"></div>' +
+				'</figure>]<p>foo</p>'
+			);
+
+			done();
+		} );
+
+		nativeReaderMock.mockSuccess( base64Sample );
+	} );
+
+	it( 'should update progressbar width on progress', ( done ) => {
+		setModelData( document, '<paragraph>foo[]</paragraph>' );
+		editor.execute( 'imageUpload', { file: createNativeFileMock() } );
+
+		document.once( 'changesDone', () => {
+			adapterMock.mockProgress( 40, 100 );
+
+			expect( getViewData( viewDocument ) ).to.equal(
+				'[<figure class="image ck-widget ck-appear" contenteditable="false">' +
+				`<img src="${ base64Sample }"></img>` +
+				'<div class="ck-progress-bar" style="width:40%;"></div>' +
+				'</figure>]<p>foo</p>'
+			);
+
+			done();
+		} );
+
+		nativeReaderMock.mockSuccess( base64Sample );
+	} );
+
+	it( 'should convert image\'s "complete" uploadStatus attribute', ( done ) => {
+		setModelData( document, '<paragraph>foo[]</paragraph>' );
+		editor.execute( 'imageUpload', { file: createNativeFileMock() } );
+
+		document.once( 'changesDone', () => {
+			document.once( 'changesDone', () => {
+				expect( getViewData( viewDocument ) ).to.equal(
+					'[<figure class="image ck-widget" contenteditable="false">' +
+						'<img src="image.png"></img>' +
+					'</figure>]<p>foo</p>'
+				);
+
+				done();
+			} );
+
+			adapterMock.mockSuccess( { original: 'image.png' } );
+		} );
+
+		nativeReaderMock.mockSuccess( base64Sample );
+	} );
+
+	it( 'should allow to customize placeholder image', () => {
+		const uploadProgress = editor.plugins.get( ImageUploadProgress );
+		uploadProgress.placeholder = base64Sample;
+
+		setModelData( document, '<paragraph>foo[]</paragraph>' );
+		editor.execute( 'imageUpload', { file: createNativeFileMock() } );
+
+		expect( getViewData( viewDocument ) ).to.equal(
+			'[<figure class="image ck-widget ck-appear ck-infinite-progress" 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( 'addAttribute:uploadStatus:image', ( evt, data, consumable ) => {
+			consumable.consume( data.item, eventNameToConsumableType( evt.name ) );
+		}, { priority: 'highest' } );
+
+		setModelData( document, '<paragraph>foo[]</paragraph>' );
+		editor.execute( 'imageUpload', { file: createNativeFileMock() } );
+
+		expect( getViewData( viewDocument ) ).to.equal(
+			'[<figure class="image ck-widget" contenteditable="false"><img></img></figure>]<p>foo</p>'
+		);
+	} );
+
+	it( 'should not process attribute change if there is wrong uploadId', () => {
+		setModelData( document, '<image uploadId="123" uploadStatus="reading"></image><paragraph>foo{}</paragraph>' );
+
+		expect( getViewData( viewDocument ) ).to.equal(
+			'<figure class="image ck-widget" contenteditable="false"><img></img></figure><p>foo{}</p>'
+		);
+	} );
+} );

+ 58 - 0
packages/ckeditor5-upload/theme/imageuploadprogress.scss

@@ -0,0 +1,58 @@
+// Infinite progress bar default width.
+$lineWidth: 30px;
+
+figure.image {
+	position: relative;
+	overflow: hidden;
+
+	// Showing animation.
+	&.ck-appear {
+		animation: fadeIn 700ms;
+	}
+
+	// Infinite progress bar on top while image is read.
+	&.ck-infinite-progress::before {
+		content: "";
+		width: $lineWidth;
+		height: 2px;
+		position: absolute;
+		top: 0;
+		right: 0;
+		background: rgba(0, 0, 0, 0.1);
+		animation-name: readingProgressAnimation;
+		animation-duration: 1500ms;
+		animation-iteration-count: infinite;
+		transition-timing-function: linear;
+	}
+
+	// Upload progress bar.
+	.ck-progress-bar {
+		height: 2px;
+		width: 0;
+		position: absolute;
+		top: 0;
+		left: 0;
+		background: #6ab5f9;
+		transition: width 100ms;
+	}
+}
+
+@keyframes fadeIn {
+	from { opacity: 0; }
+	to   { opacity: 1; }
+}
+
+@keyframes readingProgressAnimation {
+	0% {
+		width: $lineWidth;
+		right: 0;
+	}
+
+	50% {
+		width: $lineWidth * 1.5;
+	}
+
+	100% {
+		right: 100%;
+	}
+}