瀏覽代碼

Merge pull request #7835 from ckeditor/i/7794

Feature (image): Introduced the insert image via URL feature. Closes #7794.
Piotrek Koszuliński 5 年之前
父節點
當前提交
bb00c23f62

+ 6 - 1
packages/ckeditor5-image/lang/contexts.json

@@ -15,5 +15,10 @@
 	"Resize image to %0": "The label used for the standalone resize options buttons in the image toolbar",
 	"Resize image to the original size": "The accessibility label of the standalone image resize reset option button in the image toolbar for the screen readers",
 	"Original": "Default label for the resize option that resets the size of the image.",
-	"Image resize list": "The accessibility label of the image resize dropdown list for the screen readers."
+	"Image resize list": "The accessibility label of the image resize dropdown list for the screen readers.",
+	"Insert": "The label of submit form button if image src URL input has no value",
+	"Update": "The label of submit form button if image src URL input has value",
+	"Cancel": "The label of cancel form button",
+	"Insert image via URL": "The input label for the Insert image via URL form",
+	"Paste the image source URL.": "The tip label below the Insert image via URL form"
 }

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

@@ -22,6 +22,7 @@
     "@ckeditor/ckeditor5-basic-styles": "^21.0.0",
     "@ckeditor/ckeditor5-block-quote": "^21.0.0",
     "@ckeditor/ckeditor5-cloud-services": "^21.0.0",
+    "@ckeditor/ckeditor5-ckfinder": "^21.0.0",
     "@ckeditor/ckeditor5-editor-classic": "^21.0.0",
     "@ckeditor/ckeditor5-enter": "^21.0.0",
     "@ckeditor/ckeditor5-easy-image": "^21.0.0",

+ 4 - 1
packages/ckeditor5-image/src/imageupload/imageuploadcommand.js

@@ -44,7 +44,10 @@ export default class ImageUploadCommand extends Command {
 	 * @inheritDoc
 	 */
 	refresh() {
-		this.isEnabled = isImageAllowed( this.editor.model );
+		const imageElement = this.editor.model.document.selection.getSelectedElement();
+		const isImage = imageElement && imageElement.name === 'image' || false;
+
+		this.isEnabled = isImageAllowed( this.editor.model ) || isImage;
 	}
 
 	/**

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

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

+ 103 - 0
packages/ckeditor5-image/src/imageupload/ui/imageuploadformrowview.js

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

+ 303 - 0
packages/ckeditor5-image/src/imageupload/ui/imageuploadpanelview.js

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

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

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

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

@@ -81,9 +81,9 @@ describe( 'ImageUploadCommand', () => {
 			expect( command.isEnabled ).to.be.true;
 		} );
 
-		it( 'should be false when the selection is on other image', () => {
+		it( 'should be true when the selection is on other image', () => {
 			setModelData( model, '[<image></image>]' );
-			expect( command.isEnabled ).to.be.false;
+			expect( command.isEnabled ).to.be.true;
 		} );
 
 		it( 'should be false when the selection is inside other image', () => {
@@ -94,6 +94,7 @@ describe( 'ImageUploadCommand', () => {
 			} );
 			editor.conversion.for( 'downcast' ).elementToElement( { model: 'caption', view: 'figcaption' } );
 			setModelData( model, '<image><caption>[]</caption></image>' );
+
 			expect( command.isEnabled ).to.be.false;
 		} );
 

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

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

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

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

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

@@ -0,0 +1,100 @@
+/**
+ * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+import View from '@ckeditor/ckeditor5-ui/src/view';
+import ImageUploadFormRowView from '../../../src/imageupload/ui/imageuploadformrowview';
+import ViewCollection from '@ckeditor/ckeditor5-ui/src/viewcollection';
+
+describe( 'ImageUploadFormRowView', () => {
+	let view, locale;
+
+	beforeEach( () => {
+		locale = { t: val => val };
+		view = new ImageUploadFormRowView( locale );
+		view.render();
+	} );
+
+	afterEach( () => {
+		view.element.remove();
+	} );
+
+	describe( 'constructor()', () => {
+		it( 'should set view#locale', () => {
+			expect( view.locale ).to.equal( locale );
+		} );
+
+		it( 'should create view#children collection', () => {
+			expect( view.children ).to.be.instanceOf( ViewCollection );
+			expect( view.children ).to.have.length( 0 );
+		} );
+
+		it( 'should set view#class', () => {
+			expect( view.class ).to.be.null;
+		} );
+
+		it( 'should set the template', () => {
+			expect( view.element.classList.contains( 'ck' ) ).to.be.true;
+			expect( view.element.classList.contains( 'ck-form__row' ) ).to.be.true;
+		} );
+
+		describe( 'options', () => {
+			it( 'should set view#class when class was passed', () => {
+				const view = new ImageUploadFormRowView( locale, {
+					class: 'foo'
+				} );
+
+				expect( view.class ).to.equal( 'foo' );
+
+				view.destroy();
+			} );
+
+			it( 'should fill view#children when children were passed', () => {
+				const view = new ImageUploadFormRowView( locale, {
+					children: [
+						new View()
+					]
+				} );
+
+				expect( view.children ).to.have.length( 1 );
+
+				view.destroy();
+			} );
+
+			it( 'should use a label view when passed', () => {
+				const labelView = new View();
+				labelView.id = '123';
+
+				const view = new ImageUploadFormRowView( locale, {
+					labelView
+				} );
+
+				view.render();
+
+				expect( view.element.getAttribute( 'role' ) ).to.equal( 'group' );
+				expect( view.element.getAttribute( 'aria-labelledby' ) ).to.equal( '123' );
+
+				view.destroy();
+			} );
+		} );
+
+		describe( 'template bindings', () => {
+			it( 'should bind #class to the template', () => {
+				view.class = 'foo';
+				expect( view.element.classList.contains( 'foo' ) ).to.be.true;
+			} );
+
+			it( 'should bind #children to the template', () => {
+				const child = new View();
+				child.setTemplate( { tag: 'div' } );
+
+				view.children.add( child );
+
+				expect( view.element.firstChild ).to.equal( child.element );
+
+				view.destroy();
+			} );
+		} );
+	} );
+} );

+ 298 - 0
packages/ckeditor5-image/tests/imageupload/ui/imageuploadpanelview.js

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

+ 147 - 2
packages/ckeditor5-image/tests/imageupload/utils.js

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

+ 28 - 0
packages/ckeditor5-image/tests/manual/imageuploadviaurl.html

@@ -0,0 +1,28 @@
+<head>
+	<meta http-equiv="Content-Security-Policy" content="script-src 'self' https://ckeditor.com 'unsafe-inline' 'unsafe-eval'">
+</head>
+
+<script src="https://ckeditor.com/apps/ckfinder/3.5.0/ckfinder.js"></script>
+
+<style>
+	code {
+		word-break: break-all;
+	}
+</style>
+
+<div id="editor2">
+	<h2>Image upload via URL with CKFinder integration</h2>
+	<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nulla finibus consequat placerat. Vestibulum id tellus et mauris sagittis tincidunt quis id mauris. Curabitur consectetur lectus sit amet tellus mattis, non lobortis leo interdum.</p>
+</div>
+
+<div id="button-container"></div>
+
+<style>
+	#button-container div {
+		margin-top: 10px;
+	}
+
+	#button-container button {
+		margin-right: 10px;
+	}
+</style>

+ 61 - 0
packages/ckeditor5-image/tests/manual/imageuploadviaurl.js

@@ -0,0 +1,61 @@
+/**
+ * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+/* globals window, document, console */
+
+import ClassicEditor from '@ckeditor/ckeditor5-editor-classic/src/classiceditor';
+import ArticlePluginSet from '@ckeditor/ckeditor5-core/tests/_utils/articlepluginset';
+import ImageUpload from '../../src/imageupload';
+import CKFinder from '@ckeditor/ckeditor5-ckfinder/src/ckfinder';
+
+import { UploadAdapterMock } from '@ckeditor/ckeditor5-upload/tests/_utils/mocks';
+
+ClassicEditor
+	.create( document.querySelector( '#editor2' ), {
+		plugins: [ ArticlePluginSet, ImageUpload, CKFinder ],
+		toolbar: [
+			'heading',
+			'|',
+			'bold',
+			'italic',
+			'link',
+			'bulletedList',
+			'numberedList',
+			'blockQuote',
+			'imageUpload',
+			'insertTable',
+			'mediaEmbed',
+			'undo',
+			'redo'
+		],
+		image: {
+			toolbar: [ 'imageStyle:full', 'imageStyle:side', '|', 'imageTextAlternative' ],
+			upload: {
+				panel: {
+					items: [
+						'insertImageViaUrl',
+						'openCKFinder'
+					]
+				}
+			}
+		},
+		ckfinder: {
+			// eslint-disable-next-line max-len
+			uploadUrl: 'https://ckeditor.com/apps/ckfinder/3.5.0/core/connector/php/connector.php?command=QuickUpload&type=Files&responseType=json'
+		}
+	} )
+	.then( editor => {
+		window.editor2 = editor;
+
+		// Register fake adapter.
+		editor.plugins.get( 'FileRepository' ).createUploadAdapter = loader => {
+			const adapterMock = new UploadAdapterMock( loader );
+
+			return adapterMock;
+		};
+	} )
+	.catch( err => {
+		console.error( err.stack );
+	} );

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

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

+ 16 - 0
packages/ckeditor5-image/theme/imageupload.css

@@ -0,0 +1,16 @@
+/*
+ * Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+.ck.ck-image-upload__panel {
+	padding: var(--ck-spacing-standard);
+}
+
+.ck.ck-image-upload__ck-finder-button {
+	display: block;
+	width: 100%;
+	margin: var(--ck-spacing-standard) auto;
+	border: 1px solid hsl(0, 0%, 80%);
+	border-radius: var(--ck-border-radius);
+}

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

@@ -0,0 +1,29 @@
+/*
+ * Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+.ck.ck-form__row {
+	display: flex;
+	flex-direction: row;
+	flex-wrap: nowrap;
+	justify-content: space-between;
+
+	/* Ignore labels that work as fieldset legends */
+	& > *:not(.ck-label) {
+		flex-grow: 1;
+	}
+
+	&.ck-image-upload-form__action-row {
+		margin-top: var(--ck-spacing-standard);
+
+		& .ck-button-save,
+		& .ck-button-cancel {
+			justify-content: center;
+		}
+
+		& .ck-button .ck-button__label {
+			color: var(--ck-color-text);
+		}
+	}
+}

+ 4 - 0
packages/ckeditor5-theme-lark/theme/ckeditor5-image/imageupload.css

@@ -0,0 +1,4 @@
+/*
+ * Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */

+ 8 - 1
tests/manual/all-features.js

@@ -96,7 +96,14 @@ ClassicEditor
 				'imageTextAlternative', '|',
 				'imageStyle:alignLeft', 'imageStyle:alignCenter', 'imageStyle:alignRight', '|',
 				'imageResize'
-			]
+			],
+			upload: {
+				panel: {
+					items: [
+						'insertImageViaUrl'
+					]
+				}
+			}
 		},
 		placeholder: 'Type the content here!',
 		mention: {