Sfoglia il codice sorgente

Merge branch 'master' into t/246

Piotrek Koszuliński 7 anni fa
parent
commit
cddb6c1c91
23 ha cambiato i file con 567 aggiunte e 100 eliminazioni
  1. 1 1
      packages/ckeditor5-image/docs/_snippets/features/image-caption.js
  2. 1 1
      packages/ckeditor5-image/docs/_snippets/features/image-style-custom.js
  3. 1 1
      packages/ckeditor5-image/docs/_snippets/features/image-style.js
  4. 1 1
      packages/ckeditor5-image/docs/_snippets/features/image-toolbar.js
  5. 1 1
      packages/ckeditor5-image/docs/_snippets/features/image.js
  6. 2 1
      packages/ckeditor5-image/docs/features/image.md
  7. 10 2
      packages/ckeditor5-image/src/image/imageediting.js
  8. 61 0
      packages/ckeditor5-image/src/image/imageinsertcommand.js
  9. 75 1
      packages/ckeditor5-image/src/image/utils.js
  10. 35 58
      packages/ckeditor5-image/src/imageupload/imageuploadcommand.js
  11. 10 3
      packages/ckeditor5-image/src/imageupload/imageuploadediting.js
  12. 1 1
      packages/ckeditor5-image/src/imageupload/imageuploadui.js
  13. 5 0
      packages/ckeditor5-image/tests/image/imageediting.js
  14. 159 0
      packages/ckeditor5-image/tests/image/imageinsertcommand.js
  15. 158 2
      packages/ckeditor5-image/tests/image/utils.js
  16. 4 4
      packages/ckeditor5-image/tests/imageupload/imageuploadcommand.js
  17. 24 10
      packages/ckeditor5-image/tests/imageupload/imageuploadediting.js
  18. 7 7
      packages/ckeditor5-image/tests/imageupload/imageuploadprogress.js
  19. 3 3
      packages/ckeditor5-image/tests/imageupload/imageuploadui.js
  20. 1 1
      packages/ckeditor5-image/tests/manual/imagestyle.js
  21. 3 1
      packages/ckeditor5-image/tests/manual/imageupload.js
  22. 1 1
      packages/ckeditor5-image/theme/icons/image_placeholder.svg
  23. 3 0
      packages/ckeditor5-image/theme/image.css

+ 1 - 1
packages/ckeditor5-image/docs/_snippets/features/image-caption.js

@@ -14,7 +14,7 @@ ClassicEditor
 			toolbar: [ 'imageTextAlternative' ]
 		},
 		toolbar: {
-			viewportTopOffset: 100
+			viewportTopOffset: window.getViewportTopOffsetConfig()
 		},
 		cloudServices: CS_CONFIG
 	} )

+ 1 - 1
packages/ckeditor5-image/docs/_snippets/features/image-style-custom.js

@@ -24,7 +24,7 @@ ClassicEditor
 			toolbar: [ 'imageTextAlternative', '|', 'imageStyle:alignLeft', 'imageStyle:full', 'imageStyle:alignRight' ]
 		},
 		toolbar: {
-			viewportTopOffset: 100
+			viewportTopOffset: window.getViewportTopOffsetConfig()
 		},
 		cloudServices: CS_CONFIG
 	} )

+ 1 - 1
packages/ckeditor5-image/docs/_snippets/features/image-style.js

@@ -10,7 +10,7 @@ import { CS_CONFIG } from '@ckeditor/ckeditor5-cloud-services/tests/_utils/cloud
 ClassicEditor
 	.create( document.querySelector( '#snippet-image-style' ), {
 		toolbar: {
-			viewportTopOffset: 100
+			viewportTopOffset: window.getViewportTopOffsetConfig()
 		},
 		cloudServices: CS_CONFIG
 	} )

+ 1 - 1
packages/ckeditor5-image/docs/_snippets/features/image-toolbar.js

@@ -14,7 +14,7 @@ ClassicEditor
 			toolbar: [ 'imageTextAlternative' ]
 		},
 		toolbar: {
-			viewportTopOffset: 100
+			viewportTopOffset: window.getViewportTopOffsetConfig()
 		},
 		cloudServices: CS_CONFIG
 	} )

+ 1 - 1
packages/ckeditor5-image/docs/_snippets/features/image.js

@@ -11,7 +11,7 @@ ClassicEditor
 	.create( document.querySelector( '#snippet-image' ), {
 		removePlugins: [ 'ImageToolbar', 'ImageCaption', 'ImageStyle' ],
 		toolbar: {
-			viewportTopOffset: 100
+			viewportTopOffset: window.getViewportTopOffsetConfig()
 		},
 		cloudServices: CS_CONFIG
 	} )

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

@@ -213,7 +213,8 @@ ClassicEditor
 The {@link module:image/image~Image} plugin registers:
 
 * The `'imageTextAlternative'` button.
-* * The {@link module:image/imagetextalternative/imagetextalternativecommand~ImageTextAlternativeCommand `'imageTextAlternative'` command}
+* The {@link module:image/imagetextalternative/imagetextalternativecommand~ImageTextAlternativeCommand `'imageTextAlternative'` command}
+* The {@link module:image/image/imageinsertcommand~ImageInsertCommand `'imageInsert'` command} which accepts a source (e.g. an URL) of an image to insert.
 
 The {@link module:image/imagestyle~ImageStyle} plugin registers:
 

+ 10 - 2
packages/ckeditor5-image/src/image/imageediting.js

@@ -20,11 +20,16 @@ import { toImageWidget } from './utils';
 
 import { downcastElementToElement } from '@ckeditor/ckeditor5-engine/src/conversion/downcast-converters';
 import { upcastElementToElement, upcastAttributeToAttribute } from '@ckeditor/ckeditor5-engine/src/conversion/upcast-converters';
+import ImageInsertCommand from './imageinsertcommand';
 
 /**
  * The image engine plugin.
- * It registers `<image>` as a block element in the document schema, and allows `alt`, `src` and `srcset` attributes.
- * It also registers converters for editing and data pipelines.
+ *
+ * It registers:
+ *
+ * * `<image>` as a block element in the document schema, and allows `alt`, `src` and `srcset` attributes.
+ * * converters for editing and data pipelines.
+ * * `'imageInsert'` command.
  *
  * @extends module:core/plugin~Plugin
  */
@@ -102,6 +107,9 @@ export default class ImageEditing extends Plugin {
 				}
 			} ) )
 			.add( viewFigureToModel() );
+
+		// Register imageUpload command.
+		editor.commands.add( 'imageInsert', new ImageInsertCommand( editor ) );
 	}
 }
 

+ 61 - 0
packages/ckeditor5-image/src/image/imageinsertcommand.js

@@ -0,0 +1,61 @@
+/**
+ * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+import Command from '@ckeditor/ckeditor5-core/src/command';
+import { insertImage, isImageAllowed } from './utils';
+
+/**
+ * @module image/image/imageinsertcommand
+ */
+
+/**
+ * Insert image command.
+ *
+ * The command is registered by the {@link module:image/image/imageediting~ImageEditing} plugin as `'imageInsert'`.
+ *
+ * In order to insert an image at the current selection position
+ * (according to the {@link module:widget/utils~findOptimalInsertionPosition} algorithm),
+ * execute the command and specify the image source:
+ *
+ *		editor.execute( 'imageInsert', { source: 'http://url.to.the/image' } );
+ *
+ * It is also possible to insert multiple images at once:
+ *
+ *		editor.execute( 'imageInsert', {
+ *			source:  [
+ *				'path/to/image.jpg',
+ *				'path/to/other-image.jpg'
+ *			]
+ *		} );
+ *
+ * @extends module:core/command~Command
+ */
+export default class ImageInsertCommand extends Command {
+	/**
+	 * @inheritDoc
+	 */
+	refresh() {
+		this.isEnabled = isImageAllowed( this.editor.model );
+	}
+
+	/**
+	 * Executes the command.
+	 *
+	 * @fires execute
+	 * @param {Object} options Options for the executed command.
+	 * @param {String|Array.<String>} options.source The image source or an array of image sources to insert.
+	 */
+	execute( options ) {
+		const model = this.editor.model;
+
+		model.change( writer => {
+			const sources = Array.isArray( options.source ) ? options.source : [ options.source ];
+
+			for ( const src of sources ) {
+				insertImage( writer, model, { src } );
+			}
+		} );
+	}
+}

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

@@ -7,7 +7,7 @@
  * @module image/image/utils
  */
 
-import { toWidget, isWidget } from '@ckeditor/ckeditor5-widget/src/utils';
+import { findOptimalInsertionPosition, isWidget, toWidget } from '@ckeditor/ckeditor5-widget/src/utils';
 
 const imageSymbol = Symbol( 'isImage' );
 
@@ -65,3 +65,77 @@ export function isImageWidgetSelected( selection ) {
 export function isImage( modelElement ) {
 	return !!modelElement && modelElement.is( 'image' );
 }
+
+/**
+ * Handles inserting single file. This method unifies image insertion using {@link module:widget/utils~findOptimalInsertionPosition} method.
+ *
+ *		model.change( writer => {
+ *			insertImage( writer, model, { src: 'path/to/image.jpg' } );
+ *		} );
+ *
+ * @param {module:engine/model/writer~Writer} writer
+ * @param {module:engine/model/model~Model} model
+ * @param {Object} [attributes={}] Attributes of inserted image
+ */
+export function insertImage( writer, model, attributes = {} ) {
+	const imageElement = writer.createElement( 'image', attributes );
+
+	const insertAtSelection = findOptimalInsertionPosition( model.document.selection, model );
+
+	model.insertContent( imageElement, insertAtSelection );
+
+	// Inserting an image might've failed due to schema regulations.
+	if ( imageElement.parent ) {
+		writer.setSelection( imageElement, 'on' );
+	}
+}
+
+/**
+ * Checks if image can be inserted at current model selection.
+ *
+ * @param {module:engine/model/model~Model} model
+ * @returns {Boolean}
+ */
+export function isImageAllowed( model ) {
+	const schema = model.schema;
+	const selection = model.document.selection;
+
+	return isImageAllowedInParent( selection, schema, model ) && checkSelectionWithObject( selection, schema );
+}
+
+// Checks if image is allowed by schema in optimal insertion parent.
+//
+// @returns {Boolean}
+function isImageAllowedInParent( selection, schema, model ) {
+	const parent = getInsertImageParent( selection, model );
+
+	return schema.checkChild( parent, 'image' );
+}
+
+// Check used in image commands for additional cases when the command should be disabled:
+//
+// - selection is on object
+// - selection is inside object
+//
+// @returns {Boolean}
+function checkSelectionWithObject( selection, schema ) {
+	const selectedElement = selection.getSelectedElement();
+
+	const isSelectionOnObject = !!selectedElement && schema.isObject( selectedElement );
+	const isSelectionInObject = !![ ...selection.focus.getAncestors() ].find( ancestor => schema.isObject( ancestor ) );
+
+	return !isSelectionOnObject && !isSelectionInObject;
+}
+
+// Returns a node that will be used to insert image with `model.insertContent` to check if image can be placed there.
+function getInsertImageParent( selection, model ) {
+	const insertAt = findOptimalInsertionPosition( selection, model );
+
+	let parent = insertAt.parent;
+
+	if ( !parent.is( '$root' ) ) {
+		parent = parent.parent;
+	}
+
+	return parent;
+}

+ 35 - 58
packages/ckeditor5-image/src/imageupload/imageuploadcommand.js

@@ -5,7 +5,7 @@
 
 import FileRepository from '@ckeditor/ckeditor5-upload/src/filerepository';
 import Command from '@ckeditor/ckeditor5-core/src/command';
-import { findOptimalInsertionPosition } from '@ckeditor/ckeditor5-widget/src/utils';
+import { insertImage, isImageAllowed } from '../image/utils';
 
 /**
  * @module image/imageupload/imageuploadcommand
@@ -14,6 +14,29 @@ import { findOptimalInsertionPosition } from '@ckeditor/ckeditor5-widget/src/uti
 /**
  * Image upload command.
  *
+ * The command is registered by the {@link module:image/imageupload/imageuploadediting~ImageUploadEditing} plugin as `'imageUpload'`.
+ *
+ * In order to upload an image at the current selection position
+ * (according to the {@link module:widget/utils~findOptimalInsertionPosition} algorithm),
+ * execute the command and pass the native image file instance:
+ *
+ *		this.listenTo( editor.editing.view.document, 'clipboardInput', ( evt, data ) => {
+ *			// Assuming that only images were pasted:
+ *			const images = Array.from( data.dataTransfer.files );
+ *
+ *			// Upload the first image:
+ *			editor.execute( 'imageUpload', { file: images[ 0 ] } );
+ *		} );
+ *
+ * It is also possible to insert multiple images at once:
+ *
+ *		editor.execute( 'imageUpload', {
+ *			file: [
+ *				file1,
+ *				file2
+ *			]
+ *		} );
+ *
  * @extends module:core/command~Command
  */
 export default class ImageUploadCommand extends Command {
@@ -21,11 +44,7 @@ export default class ImageUploadCommand extends Command {
 	 * @inheritDoc
 	 */
 	refresh() {
-		const model = this.editor.model;
-		const selection = model.document.selection;
-		const schema = model.schema;
-
-		this.isEnabled = isImageAllowedInParent( selection, schema, model ) && checkSelectionWithObject( selection, schema );
+		this.isEnabled = isImageAllowed( this.editor.model );
 	}
 
 	/**
@@ -33,16 +52,19 @@ export default class ImageUploadCommand extends Command {
 	 *
 	 * @fires execute
 	 * @param {Object} options Options for the executed command.
-	 * @param {File|Array.<File>} options.files The image file or an array of image files to upload.
+	 * @param {File|Array.<File>} options.file The image file or an array of image files to upload.
 	 */
 	execute( options ) {
 		const editor = this.editor;
+		const model = editor.model;
 
-		editor.model.change( writer => {
-			const filesToUpload = Array.isArray( options.files ) ? options.files : [ options.files ];
+		const fileRepository = editor.plugins.get( FileRepository );
+
+		model.change( writer => {
+			const filesToUpload = Array.isArray( options.file ) ? options.file : [ options.file ];
 
 			for ( const file of filesToUpload ) {
-				uploadImage( writer, editor, file );
+				uploadImage( writer, model, fileRepository, file );
 			}
 		} );
 	}
@@ -51,13 +73,9 @@ export default class ImageUploadCommand extends Command {
 // Handles uploading single file.
 //
 // @param {module:engine/model/writer~writer} writer
-// @param {module:core/editor/editor~Editor} editor
+// @param {module:engine/model/model~Model} model
 // @param {File} file
-function uploadImage( writer, editor, file ) {
-	const model = editor.model;
-	const doc = model.document;
-	const fileRepository = editor.plugins.get( FileRepository );
-
+function uploadImage( writer, model, fileRepository, file ) {
 	const loader = fileRepository.createLoader( file );
 
 	// Do not throw when upload adapter is not set. FileRepository will log an error anyway.
@@ -65,46 +83,5 @@ function uploadImage( writer, editor, file ) {
 		return;
 	}
 
-	const imageElement = writer.createElement( 'image', { uploadId: loader.id } );
-
-	const insertAtSelection = findOptimalInsertionPosition( doc.selection, model );
-
-	model.insertContent( imageElement, insertAtSelection );
-
-	// Inserting an image might've failed due to schema regulations.
-	if ( imageElement.parent ) {
-		writer.setSelection( imageElement, 'on' );
-	}
-}
-
-// Checks if image is allowed by schema in optimal insertion parent.
-function isImageAllowedInParent( selection, schema, model ) {
-	const parent = getInsertImageParent( selection, model );
-
-	return schema.checkChild( parent, 'image' );
-}
-
-// Additional check for when the command should be disabled:
-// - selection is on object
-// - selection is inside object
-function checkSelectionWithObject( selection, schema ) {
-	const selectedElement = selection.getSelectedElement();
-
-	const isSelectionOnObject = !!selectedElement && schema.isObject( selectedElement );
-	const isSelectionInObject = !![ ...selection.focus.getAncestors() ].find( ancestor => schema.isObject( ancestor ) );
-
-	return !isSelectionOnObject && !isSelectionInObject;
-}
-
-// Returns a node that will be used to insert image with `model.insertContent` to check if image can be placed there.
-function getInsertImageParent( selection, model ) {
-	const insertAt = findOptimalInsertionPosition( selection, model );
-
-	let parent = insertAt.parent;
-
-	if ( !parent.is( '$root' ) ) {
-		parent = parent.parent;
-	}
-
-	return parent;
+	insertImage( writer, model, { uploadId: loader.id } );
 }

+ 10 - 3
packages/ckeditor5-image/src/imageupload/imageuploadediting.js

@@ -16,7 +16,7 @@ import ImageUploadCommand from '../../src/imageupload/imageuploadcommand';
 import { isImageType, isLocalImage, wrapImageToFetch } from '../../src/imageupload/utils';
 
 /**
- * The editing part of the image upload feature.
+ * The editing part of the image upload feature. It registers the `'imageUpload'` command.
  *
  * @extends module:core/plugin~Plugin
  */
@@ -67,7 +67,14 @@ export default class ImageUploadEditing extends Plugin {
 				return;
 			}
 
-			const images = Array.from( data.dataTransfer.files ).filter( isImageType );
+			const images = Array.from( data.dataTransfer.files ).filter( file => {
+				// See https://github.com/ckeditor/ckeditor5-image/pull/254.
+				if ( !file ) {
+					return false;
+				}
+
+				return isImageType( file );
+			} );
 
 			const ranges = data.targetRanges.map( viewRange => editor.editing.mapper.toModelRange( viewRange ) );
 
@@ -80,7 +87,7 @@ export default class ImageUploadEditing extends Plugin {
 
 					// Upload images after the selection has changed in order to ensure the command's state is refreshed.
 					editor.model.enqueueChange( 'default', () => {
-						editor.execute( 'imageUpload', { files: images } );
+						editor.execute( 'imageUpload', { file: images } );
 					} );
 				}
 			} );

+ 1 - 1
packages/ckeditor5-image/src/imageupload/imageuploadui.js

@@ -51,7 +51,7 @@ export default class ImageUploadUI extends Plugin {
 				const imagesToUpload = Array.from( files ).filter( isImageType );
 
 				if ( imagesToUpload.length ) {
-					editor.execute( 'imageUpload', { files: imagesToUpload } );
+					editor.execute( 'imageUpload', { file: imagesToUpload } );
 				}
 			} );
 

+ 5 - 0
packages/ckeditor5-image/tests/image/imageediting.js

@@ -9,6 +9,7 @@ import VirtualTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/virtualtest
 import ClassicTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/classictesteditor';
 import ImageEditing from '../../src/image/imageediting';
 import ImageLoadObserver from '../../src/image/imageloadobserver';
+import ImageInsertCommand from '../../src/image/imageinsertcommand';
 import { getData as getModelData, setData as setModelData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
 import { getData as getViewData } from '@ckeditor/ckeditor5-engine/src/dev-utils/view';
 import { isImageWidget } from '../../src/image/utils';
@@ -58,6 +59,10 @@ describe( 'ImageEditing', () => {
 		expect( view.getObserver( ImageLoadObserver ) ).to.be.instanceOf( ImageLoadObserver );
 	} );
 
+	it( 'should register imageInsert command', () => {
+		expect( editor.commands.get( 'imageInsert' ) ).to.be.instanceOf( ImageInsertCommand );
+	} );
+
 	// See https://github.com/ckeditor/ckeditor5-image/issues/142.
 	it( 'should update the ui after image has been loaded in the DOM', () => {
 		const element = document.createElement( 'div' );

+ 159 - 0
packages/ckeditor5-image/tests/image/imageinsertcommand.js

@@ -0,0 +1,159 @@
+/**
+ * @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 ImageInsertCommand from '../../src/image/imageinsertcommand';
+import Image from '../../src/image/imageediting';
+
+import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
+import { downcastElementToElement } from '@ckeditor/ckeditor5-engine/src/conversion/downcast-converters';
+import { setData as setModelData, getData as getModelData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
+
+describe( 'ImageInsertCommand', () => {
+	let editor, command, model;
+
+	beforeEach( () => {
+		return VirtualTestEditor
+			.create( {
+				plugins: [ Image, Paragraph ]
+			} )
+			.then( newEditor => {
+				editor = newEditor;
+				model = editor.model;
+
+				command = new ImageInsertCommand( editor );
+
+				const schema = model.schema;
+				schema.extend( 'image', { allowAttributes: 'uploadId' } );
+			} );
+	} );
+
+	afterEach( () => {
+		return editor.destroy();
+	} );
+
+	describe( 'isEnabled', () => {
+		it( 'should be true when the selection directly in the root', () => {
+			model.enqueueChange( 'transparent', () => {
+				setModelData( model, '[]' );
+
+				command.refresh();
+				expect( command.isEnabled ).to.be.true;
+			} );
+		} );
+
+		it( 'should be true when the selection is in empty block', () => {
+			setModelData( model, '<paragraph>[]</paragraph>' );
+
+			expect( command.isEnabled ).to.be.true;
+		} );
+
+		it( 'should be true when the selection directly in a paragraph', () => {
+			setModelData( model, '<paragraph>foo[]</paragraph>' );
+			expect( command.isEnabled ).to.be.true;
+		} );
+
+		it( 'should be true when the selection directly in a block', () => {
+			model.schema.register( 'block', { inheritAllFrom: '$block' } );
+			model.schema.extend( '$text', { allowIn: 'block' } );
+			editor.conversion.for( 'downcast' ).add( downcastElementToElement( { model: 'block', view: 'block' } ) );
+
+			setModelData( model, '<block>foo[]</block>' );
+			expect( command.isEnabled ).to.be.true;
+		} );
+
+		it( 'should be false when the selection is on other image', () => {
+			setModelData( model, '[<image></image>]' );
+			expect( command.isEnabled ).to.be.false;
+		} );
+
+		it( 'should be false when the selection is inside other image', () => {
+			model.schema.register( 'caption', {
+				allowIn: 'image',
+				allowContentOf: '$block',
+				isLimit: true
+			} );
+			editor.conversion.for( 'downcast' ).add( downcastElementToElement( { model: 'caption', view: 'figcaption' } ) );
+			setModelData( model, '<image><caption>[]</caption></image>' );
+			expect( command.isEnabled ).to.be.false;
+		} );
+
+		it( 'should be false when the selection is on other object', () => {
+			model.schema.register( 'object', { isObject: true, allowIn: '$root' } );
+			editor.conversion.for( 'downcast' ).add( downcastElementToElement( { model: 'object', view: 'object' } ) );
+			setModelData( model, '[<object></object>]' );
+
+			expect( command.isEnabled ).to.be.false;
+		} );
+
+		it( 'should be false when the selection is inside other object', () => {
+			model.schema.register( 'object', { isObject: true, allowIn: '$root' } );
+			model.schema.extend( '$text', { allowIn: 'object' } );
+			editor.conversion.for( 'downcast' ).add( downcastElementToElement( { model: 'object', view: 'object' } ) );
+			setModelData( model, '<object>[]</object>' );
+
+			expect( command.isEnabled ).to.be.false;
+		} );
+
+		it( 'should be false when schema disallows image', () => {
+			model.schema.register( 'block', { inheritAllFrom: '$block' } );
+			model.schema.extend( 'paragraph', { allowIn: 'block' } );
+			// Block image in block.
+			model.schema.addChildCheck( ( context, childDefinition ) => {
+				if ( childDefinition.name === 'image' && context.last.name === 'block' ) {
+					return false;
+				}
+			} );
+			editor.conversion.for( 'downcast' ).add( downcastElementToElement( { model: 'block', view: 'block' } ) );
+
+			setModelData( model, '<block><paragraph>[]</paragraph></block>' );
+
+			expect( command.isEnabled ).to.be.false;
+		} );
+	} );
+
+	describe( 'execute()', () => {
+		it( 'should insert image at selection position as other widgets', () => {
+			const imgSrc = 'foo/bar.jpg';
+
+			setModelData( model, '<paragraph>f[o]o</paragraph>' );
+
+			command.execute( { source: imgSrc } );
+
+			expect( getModelData( model ) ).to.equal( `[<image src="${ imgSrc }"></image>]<paragraph>foo</paragraph>` );
+		} );
+
+		it( 'should insert multiple images at selection position as other widgets', () => {
+			const imgSrc1 = 'foo/bar.jpg';
+			const imgSrc2 = 'foo/baz.jpg';
+
+			setModelData( model, '<paragraph>f[o]o</paragraph>' );
+
+			command.execute( { source: [ imgSrc1, imgSrc2 ] } );
+
+			expect( getModelData( model ) )
+				.to.equal( `<image src="${ imgSrc1 }"></image>[<image src="${ imgSrc2 }"></image>]<paragraph>foo</paragraph>` );
+		} );
+
+		it( 'should not insert image nor crash when image could not be inserted', () => {
+			const imgSrc = 'foo/bar.jpg';
+
+			model.schema.register( 'other', {
+				allowIn: '$root',
+				isLimit: true
+			} );
+			model.schema.extend( '$text', { allowIn: 'other' } );
+
+			editor.conversion.for( 'downcast' ).add( downcastElementToElement( { model: 'other', view: 'p' } ) );
+
+			setModelData( model, '<other>[]</other>' );
+
+			command.execute( { source: imgSrc } );
+
+			expect( getModelData( model ) ).to.equal( '<other>[]</other>' );
+		} );
+	} );
+} );

+ 158 - 2
packages/ckeditor5-image/tests/image/utils.js

@@ -7,8 +7,13 @@ import ViewDocumentFragment from '@ckeditor/ckeditor5-engine/src/view/documentfr
 import ViewDowncastWriter from '@ckeditor/ckeditor5-engine/src/view/downcastwriter';
 import ViewDocument from '@ckeditor/ckeditor5-engine/src/view/document';
 import ModelElement from '@ckeditor/ckeditor5-engine/src/model/element';
-import { toImageWidget, isImageWidget, isImageWidgetSelected, isImage } from '../../src/image/utils';
+import { toImageWidget, isImageWidget, isImageWidgetSelected, isImage, isImageAllowed, insertImage } from '../../src/image/utils';
 import { isWidget, getLabel } from '@ckeditor/ckeditor5-widget/src/utils';
+import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
+import VirtualTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/virtualtesteditor';
+import { setData as setModelData, getData as getModelData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
+import { downcastElementToElement } from '@ckeditor/ckeditor5-engine/src/conversion/downcast-converters';
+import Image from '../../src/image/imageediting';
 
 describe( 'image widget utils', () => {
 	let element, image, writer;
@@ -87,7 +92,7 @@ describe( 'image widget utils', () => {
 		} );
 	} );
 
-	describe( 'isImage', () => {
+	describe( 'isImage()', () => {
 		it( 'should return true for image element', () => {
 			const image = new ModelElement( 'image' );
 
@@ -105,4 +110,155 @@ describe( 'image widget utils', () => {
 			expect( isImage( undefined ) ).to.be.false;
 		} );
 	} );
+
+	describe( 'isImageAllowed()', () => {
+		let editor, model;
+
+		beforeEach( () => {
+			return VirtualTestEditor
+				.create( {
+					plugins: [ Image, Paragraph ]
+				} )
+				.then( newEditor => {
+					editor = newEditor;
+					model = editor.model;
+
+					const schema = model.schema;
+					schema.extend( 'image', { allowAttributes: 'uploadId' } );
+				} );
+		} );
+
+		it( 'should return true when the selection directly in the root', () => {
+			model.enqueueChange( 'transparent', () => {
+				setModelData( model, '[]' );
+
+				expect( isImageAllowed( model ) ).to.be.true;
+			} );
+		} );
+
+		it( 'should return true when the selection is in empty block', () => {
+			setModelData( model, '<paragraph>[]</paragraph>' );
+
+			expect( isImageAllowed( model ) ).to.be.true;
+		} );
+
+		it( 'should return true when the selection directly in a paragraph', () => {
+			setModelData( model, '<paragraph>foo[]</paragraph>' );
+			expect( isImageAllowed( model ) ).to.be.true;
+		} );
+
+		it( 'should return true when the selection directly in a block', () => {
+			model.schema.register( 'block', { inheritAllFrom: '$block' } );
+			model.schema.extend( '$text', { allowIn: 'block' } );
+			editor.conversion.for( 'downcast' ).add( downcastElementToElement( { model: 'block', view: 'block' } ) );
+
+			setModelData( model, '<block>foo[]</block>' );
+			expect( isImageAllowed( model ) ).to.be.true;
+		} );
+
+		it( 'should return false when the selection is on other image', () => {
+			setModelData( model, '[<image></image>]' );
+			expect( isImageAllowed( model ) ).to.be.false;
+		} );
+
+		it( 'should return false when the selection is inside other image', () => {
+			model.schema.register( 'caption', {
+				allowIn: 'image',
+				allowContentOf: '$block',
+				isLimit: true
+			} );
+			editor.conversion.for( 'downcast' ).add( downcastElementToElement( { model: 'caption', view: 'figcaption' } ) );
+			setModelData( model, '<image><caption>[]</caption></image>' );
+			expect( isImageAllowed( model ) ).to.be.false;
+		} );
+
+		it( 'should return false when the selection is on other object', () => {
+			model.schema.register( 'object', { isObject: true, allowIn: '$root' } );
+			editor.conversion.for( 'downcast' ).add( downcastElementToElement( { model: 'object', view: 'object' } ) );
+			setModelData( model, '[<object></object>]' );
+
+			expect( isImageAllowed( model ) ).to.be.false;
+		} );
+
+		it( 'should return false when the selection is inside other object', () => {
+			model.schema.register( 'object', { isObject: true, allowIn: '$root' } );
+			model.schema.extend( '$text', { allowIn: 'object' } );
+			editor.conversion.for( 'downcast' ).add( downcastElementToElement( { model: 'object', view: 'object' } ) );
+			setModelData( model, '<object>[]</object>' );
+
+			expect( isImageAllowed( model ) ).to.be.false;
+		} );
+
+		it( 'should return false when schema disallows image', () => {
+			model.schema.register( 'block', { inheritAllFrom: '$block' } );
+			model.schema.extend( 'paragraph', { allowIn: 'block' } );
+			// Block image in block.
+			model.schema.addChildCheck( ( context, childDefinition ) => {
+				if ( childDefinition.name === 'image' && context.last.name === 'block' ) {
+					return false;
+				}
+			} );
+			editor.conversion.for( 'downcast' ).add( downcastElementToElement( { model: 'block', view: 'block' } ) );
+
+			setModelData( model, '<block><paragraph>[]</paragraph></block>' );
+
+			expect( isImageAllowed( model ) ).to.be.false;
+		} );
+	} );
+
+	describe( 'insertImage()', () => {
+		let editor, model;
+
+		beforeEach( () => {
+			return VirtualTestEditor
+				.create( {
+					plugins: [ Image, Paragraph ]
+				} )
+				.then( newEditor => {
+					editor = newEditor;
+					model = editor.model;
+
+					const schema = model.schema;
+					schema.extend( 'image', { allowAttributes: 'uploadId' } );
+				} );
+		} );
+
+		it( 'should insert image at selection position as other widgets', () => {
+			setModelData( model, '<paragraph>f[o]o</paragraph>' );
+
+			model.change( writer => {
+				insertImage( writer, model );
+			} );
+
+			expect( getModelData( model ) ).to.equal( '[<image></image>]<paragraph>foo</paragraph>' );
+		} );
+
+		it( 'should insert image with given attributes', () => {
+			setModelData( model, '<paragraph>f[o]o</paragraph>' );
+
+			model.change( writer => {
+				insertImage( writer, model, { src: 'bar' } );
+			} );
+
+			expect( getModelData( model ) ).to.equal( '[<image src="bar"></image>]<paragraph>foo</paragraph>' );
+		} );
+
+		it( 'should not insert image nor crash when image could not be inserted', () => {
+			model.schema.register( 'other', {
+				allowIn: '$root',
+				isLimit: true
+			} );
+			model.schema.extend( '$text', { allowIn: 'other' } );
+
+			editor.conversion.for( 'downcast' ).add( downcastElementToElement( { model: 'other', view: 'p' } ) );
+
+			setModelData( model, '<other>[]</other>' );
+
+			model.change( writer => {
+				insertImage( writer, model );
+			} );
+
+			expect( getModelData( model ) ).to.equal( '<other>[]</other>' );
+		} );
+	} );
 } );

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

@@ -139,7 +139,7 @@ describe( 'ImageUploadCommand', () => {
 			const file = createNativeFileMock();
 			setModelData( model, '<paragraph>f[o]o</paragraph>' );
 
-			command.execute( { files: file } );
+			command.execute( { file } );
 
 			const id = fileRepository.getLoader( file ).id;
 			expect( getModelData( model ) )
@@ -154,7 +154,7 @@ describe( 'ImageUploadCommand', () => {
 			model.change( writer => {
 				expect( writer.batch.operations ).to.length( 0 );
 
-				command.execute( { files: file } );
+				command.execute( { file } );
 
 				expect( writer.batch.operations ).to.length.above( 0 );
 			} );
@@ -173,7 +173,7 @@ describe( 'ImageUploadCommand', () => {
 
 			setModelData( model, '<other>[]</other>' );
 
-			command.execute( { files: file } );
+			command.execute( { file } );
 
 			expect( getModelData( model ) ).to.equal( '<other>[]</other>' );
 		} );
@@ -188,7 +188,7 @@ describe( 'ImageUploadCommand', () => {
 			setModelData( model, '<paragraph>fo[]o</paragraph>' );
 
 			expect( () => {
-				command.execute( { files: file } );
+				command.execute( { file } );
 			} ).to.not.throw();
 
 			expect( getModelData( model ) ).to.equal( '<paragraph>fo[]o</paragraph>' );

+ 24 - 10
packages/ckeditor5-image/tests/imageupload/imageuploadediting.js

@@ -217,6 +217,20 @@ describe( 'ImageUploadEditing', () => {
 		expect( getModelData( model ) ).to.equal( '<paragraph>foo[]</paragraph>' );
 	} );
 
+	it( 'should not insert image when file is null', () => {
+		const viewDocument = editor.editing.view.document;
+		const dataTransfer = new DataTransfer( { files: [ null ], types: [ 'Files' ] } );
+
+		setModelData( model, '<paragraph>foo[]</paragraph>' );
+
+		const targetRange = doc.selection.getFirstRange();
+		const targetViewRange = editor.editing.mapper.toViewRange( targetRange );
+
+		viewDocument.fire( 'clipboardInput', { dataTransfer, targetRanges: [ targetViewRange ] } );
+
+		expect( getModelData( model ) ).to.equal( '<paragraph>foo[]</paragraph>' );
+	} );
+
 	it( 'should not insert image when there is non-empty HTML content pasted', () => {
 		const fileMock = createNativeFileMock();
 		const dataTransfer = new DataTransfer( {
@@ -314,7 +328,7 @@ describe( 'ImageUploadEditing', () => {
 	it( 'should use read data once it is present', done => {
 		const file = createNativeFileMock();
 		setModelData( model, '<paragraph>{}foo bar</paragraph>' );
-		editor.execute( 'imageUpload', { files: file } );
+		editor.execute( 'imageUpload', { file } );
 
 		model.once( '_change', () => {
 			expect( getViewData( view ) ).to.equal(
@@ -334,7 +348,7 @@ describe( 'ImageUploadEditing', () => {
 	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', { files: file } );
+		editor.execute( 'imageUpload', { file } );
 
 		model.document.once( 'change', () => {
 			model.document.once( 'change', () => {
@@ -365,7 +379,7 @@ describe( 'ImageUploadEditing', () => {
 		}, { priority: 'high' } );
 
 		setModelData( model, '<paragraph>{}foo bar</paragraph>' );
-		editor.execute( 'imageUpload', { files: file } );
+		editor.execute( 'imageUpload', { file } );
 
 		nativeReaderMock.mockError( 'Reading error.' );
 	} );
@@ -381,7 +395,7 @@ describe( 'ImageUploadEditing', () => {
 		}, { priority: 'high' } );
 
 		setModelData( model, '<paragraph>{}foo bar</paragraph>' );
-		editor.execute( 'imageUpload', { files: file } );
+		editor.execute( 'imageUpload', { file } );
 		nativeReaderMock.abort();
 
 		setTimeout( () => {
@@ -405,7 +419,7 @@ describe( 'ImageUploadEditing', () => {
 		} );
 
 		setModelData( model, '<paragraph>{}foo bar</paragraph>' );
-		editor.execute( 'imageUpload', { files: file } );
+		editor.execute( 'imageUpload', { file } );
 
 		sinon.assert.calledOnce( loadSpy );
 
@@ -442,7 +456,7 @@ describe( 'ImageUploadEditing', () => {
 			evt.stop();
 		}, { priority: 'high' } );
 
-		editor.execute( 'imageUpload', { files: file } );
+		editor.execute( 'imageUpload', { file } );
 
 		model.document.once( 'change', () => {
 			model.document.once( 'change', () => {
@@ -459,7 +473,7 @@ describe( 'ImageUploadEditing', () => {
 	it( 'should abort upload if image is removed', () => {
 		const file = createNativeFileMock();
 		setModelData( model, '<paragraph>{}foo bar</paragraph>' );
-		editor.execute( 'imageUpload', { files: file } );
+		editor.execute( 'imageUpload', { file } );
 
 		const abortSpy = testUtils.sinon.spy( loader, 'abort' );
 
@@ -478,7 +492,7 @@ describe( 'ImageUploadEditing', () => {
 	it( 'should not abort and not restart upload when image is moved', () => {
 		const file = createNativeFileMock();
 		setModelData( model, '<paragraph>{}foo bar</paragraph>' );
-		editor.execute( 'imageUpload', { files: file } );
+		editor.execute( 'imageUpload', { file } );
 
 		const abortSpy = testUtils.sinon.spy( loader, 'abort' );
 		const loadSpy = testUtils.sinon.spy( loader, 'read' );
@@ -503,7 +517,7 @@ describe( 'ImageUploadEditing', () => {
 			evt.stop();
 		}, { priority: 'high' } );
 
-		editor.execute( 'imageUpload', { files: file } );
+		editor.execute( 'imageUpload', { file } );
 
 		model.document.once( 'change', () => {
 			// This is called after "manual" remove.
@@ -539,7 +553,7 @@ describe( 'ImageUploadEditing', () => {
 	it( 'should create responsive image if server return multiple images', done => {
 		const file = createNativeFileMock();
 		setModelData( model, '<paragraph>{}foo bar</paragraph>' );
-		editor.execute( 'imageUpload', { files: file } );
+		editor.execute( 'imageUpload', { file } );
 
 		model.document.once( 'change', () => {
 			model.document.once( 'change', () => {

+ 7 - 7
packages/ckeditor5-image/tests/imageupload/imageuploadprogress.js

@@ -75,7 +75,7 @@ describe( 'ImageUploadProgress', () => {
 
 	it( 'should convert image\'s "reading" uploadStatus attribute', () => {
 		setModelData( model, '<paragraph>[]foo</paragraph>' );
-		editor.execute( 'imageUpload', { files: createNativeFileMock() } );
+		editor.execute( 'imageUpload', { file: createNativeFileMock() } );
 
 		expect( getViewData( view ) ).to.equal(
 			'[<figure class="ck-appear ck-image-upload-placeholder ck-widget image" contenteditable="false">' +
@@ -87,7 +87,7 @@ describe( 'ImageUploadProgress', () => {
 
 	it( 'should convert image\'s "uploading" uploadStatus attribute', done => {
 		setModelData( model, '<paragraph>[]foo</paragraph>' );
-		editor.execute( 'imageUpload', { files: createNativeFileMock() } );
+		editor.execute( 'imageUpload', { file: createNativeFileMock() } );
 
 		model.document.once( 'change', () => {
 			expect( getViewData( view ) ).to.equal(
@@ -168,7 +168,7 @@ describe( 'ImageUploadProgress', () => {
 
 	it( 'should update progressbar width on progress', done => {
 		setModelData( model, '<paragraph>[]foo</paragraph>' );
-		editor.execute( 'imageUpload', { files: createNativeFileMock() } );
+		editor.execute( 'imageUpload', { file: createNativeFileMock() } );
 
 		model.document.once( 'change', () => {
 			adapterMock.mockProgress( 40, 100 );
@@ -190,7 +190,7 @@ describe( 'ImageUploadProgress', () => {
 		const clock = testUtils.sinon.useFakeTimers();
 
 		setModelData( model, '<paragraph>[]foo</paragraph>' );
-		editor.execute( 'imageUpload', { files: createNativeFileMock() } );
+		editor.execute( 'imageUpload', { file: createNativeFileMock() } );
 
 		model.document.once( 'change', () => {
 			model.document.once( 'change', () => {
@@ -223,7 +223,7 @@ describe( 'ImageUploadProgress', () => {
 		uploadProgress.placeholder = base64Sample;
 
 		setModelData( model, '<paragraph>[]foo</paragraph>' );
-		editor.execute( 'imageUpload', { files: createNativeFileMock() } );
+		editor.execute( 'imageUpload', { file: createNativeFileMock() } );
 
 		expect( getViewData( view ) ).to.equal(
 			'[<figure class="ck-appear ck-image-upload-placeholder ck-widget image" contenteditable="false">' +
@@ -239,7 +239,7 @@ describe( 'ImageUploadProgress', () => {
 		}, { priority: 'highest' } );
 
 		setModelData( model, '<paragraph>[]foo</paragraph>' );
-		editor.execute( 'imageUpload', { files: createNativeFileMock() } );
+		editor.execute( 'imageUpload', { file: createNativeFileMock() } );
 
 		expect( getViewData( view ) ).to.equal(
 			'[<figure class="ck-widget image" contenteditable="false"><img></img></figure>]<p>foo</p>'
@@ -277,7 +277,7 @@ describe( 'ImageUploadProgress', () => {
 		testUtils.sinon.stub( env, 'isEdge' ).get( () => true );
 
 		setModelData( model, '<paragraph>[]foo</paragraph>' );
-		editor.execute( 'imageUpload', { files: createNativeFileMock() } );
+		editor.execute( 'imageUpload', { file: createNativeFileMock() } );
 
 		model.document.once( 'change', () => {
 			model.document.once( 'change', () => {

+ 3 - 3
packages/ckeditor5-image/tests/imageupload/imageuploadui.js

@@ -100,7 +100,7 @@ describe( 'ImageUploadUI', () => {
 		button.fire( 'done', files );
 		sinon.assert.calledOnce( executeStub );
 		expect( executeStub.firstCall.args[ 0 ] ).to.equal( 'imageUpload' );
-		expect( executeStub.firstCall.args[ 1 ].files ).to.deep.equal( files );
+		expect( executeStub.firstCall.args[ 1 ].file ).to.deep.equal( files );
 	} );
 
 	it( 'should execute imageUpload command with multiple files', () => {
@@ -111,7 +111,7 @@ describe( 'ImageUploadUI', () => {
 		button.fire( 'done', files );
 		sinon.assert.calledOnce( executeStub );
 		expect( executeStub.firstCall.args[ 0 ] ).to.equal( 'imageUpload' );
-		expect( executeStub.firstCall.args[ 1 ].files ).to.deep.equal( files );
+		expect( executeStub.firstCall.args[ 1 ].file ).to.deep.equal( files );
 	} );
 
 	it( 'should optimize the insertion position', () => {
@@ -172,6 +172,6 @@ describe( 'ImageUploadUI', () => {
 		button.fire( 'done', files );
 		sinon.assert.calledOnce( executeStub );
 		expect( executeStub.firstCall.args[ 0 ] ).to.equal( 'imageUpload' );
-		expect( executeStub.firstCall.args[ 1 ].files ).to.deep.equal( [ files[ 0 ] ] );
+		expect( executeStub.firstCall.args[ 1 ].file ).to.deep.equal( [ files[ 0 ] ] );
 	} );
 } );

+ 1 - 1
packages/ckeditor5-image/tests/manual/imagestyle.js

@@ -56,7 +56,7 @@ ClassicEditor
 		],
 		toolbar: [ 'heading', '|', 'undo', 'redo' ],
 		image: {
-			styles: [ 'imageStyle:alignLeft', 'imageStyle:alignCenter', 'imageStyle:alignRight' ],
+			styles: [ 'alignLeft', 'alignCenter', 'alignRight' ],
 			toolbar: [ 'imageStyle:alignLeft', 'imageStyle:alignCenter', 'imageStyle:alignRight' ]
 		}
 	} )

+ 3 - 1
packages/ckeditor5-image/tests/manual/imageupload.js

@@ -3,7 +3,7 @@
  * For licensing, see LICENSE.md.
  */
 
-/* globals document, console */
+/* globals window, document, console */
 
 import ClassicEditor from '@ckeditor/ckeditor5-editor-classic/src/classiceditor';
 import Bold from '@ckeditor/ckeditor5-basic-styles/src/bold';
@@ -36,6 +36,8 @@ ClassicEditor
 		}
 	} )
 	.then( editor => {
+		window.editor = editor;
+
 		// Register fake adapter.
 		editor.plugins.get( 'FileRepository' ).createUploadAdapter = loader => {
 			const adapterMock = new UploadAdapterMock( loader );

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

@@ -1 +1 @@
-<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 700 250"><g fill="#FAFAFA" fill-rule="evenodd"><rect width="700" height="250" rx="4"/></g></svg>
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 700 250"><rect rx="4"/></svg>

+ 3 - 0
packages/ckeditor5-image/theme/image.css

@@ -7,6 +7,9 @@
 	clear: both;
 	text-align: center;
 
+	/* Make sure there is some space between the content and the image. */
+	margin: 1em 0;
+
 	& > img {
 		/* Prevent unnecessary margins caused by line-height (see #44). */
 		display: block;