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

Merge pull request #7482 from ckeditor/i/5201

Feature (image): Introduced the UI for a manual image resizing via a dropdown or standalone buttons. Closes #5201.

Feature (image): Introduced the UI for restoring original image size.  Closes #5197.
Maciej 5 лет назад
Родитель
Сommit
70e0b41025

+ 14 - 0
packages/ckeditor5-image/docs/_snippets/features/image-resizeui.html

@@ -0,0 +1,14 @@
+<div id="snippet-image-resizeui">
+	<h3>Resize me using image toolbar buttons!</h3>
+
+	<figure class="image">
+		<img src="%BASE_PATH%/assets/img/fields.jpg" alt="Autumn fields">
+		<figcaption>Autumn fields by Aleksander Nowodziński</figcaption>
+	</figure>
+
+	<h3>Resized image (width: 75%):</h3>
+
+	<figure class="image image_resized" style="width:75%;">
+		<img src="%BASE_PATH%/assets/img/fields.jpg" alt="Autumn fields">
+	</figure>
+</div>

+ 49 - 0
packages/ckeditor5-image/docs/_snippets/features/image-resizeui.js

@@ -0,0 +1,49 @@
+/**
+ * @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 ClassicEditor, console, window, document */
+
+import { CS_CONFIG } from '@ckeditor/ckeditor5-cloud-services/tests/_utils/cloud-services-config.js';
+
+ClassicEditor
+	.create( document.querySelector( '#snippet-image-resizeui' ), {
+		removePlugins: [ 'LinkImage' ],
+		toolbar: {
+			viewportTopOffset: window.getViewportTopOffsetConfig()
+		},
+		image: {
+			resizeOptions: [
+				{
+					name: 'imageResize:original',
+					label: 'Original size',
+					value: null
+				},
+				{
+					name: 'imageResize:50',
+					label: '50%',
+					value: '50'
+				},
+				{
+					name: 'imageResize:75',
+					label: '75%',
+					value: '75'
+				}
+			],
+			toolbar: [
+				'imageStyle:full',
+				'imageStyle:side', '|',
+				'imageResize:original',
+				'imageResize:50',
+				'imageResize:75'
+			]
+		},
+		cloudServices: CS_CONFIG
+	} )
+	.then( editor => {
+		window.editorResizeUI = editor;
+	} )
+	.catch( err => {
+		console.error( err );
+	} );

+ 14 - 0
packages/ckeditor5-image/docs/_snippets/features/image-resizeuidropdown.html

@@ -0,0 +1,14 @@
+<div id="snippet-image-resizeui-dropdown">
+	<h3>Resize me using the dropdown!</h3>
+
+	<figure class="image">
+		<img src="%BASE_PATH%/assets/img/fields.jpg" alt="Autumn fields">
+		<figcaption>Autumn fields by Aleksander Nowodziński</figcaption>
+	</figure>
+
+	<h3>Resized image (width: 75%):</h3>
+
+	<figure class="image image_resized" style="width:75%;">
+		<img src="%BASE_PATH%/assets/img/fields.jpg" alt="Autumn fields">
+	</figure>
+</div>

+ 43 - 0
packages/ckeditor5-image/docs/_snippets/features/image-resizeuidropdown.js

@@ -0,0 +1,43 @@
+/**
+ * @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 ClassicEditor, console, window, document */
+
+import { CS_CONFIG } from '@ckeditor/ckeditor5-cloud-services/tests/_utils/cloud-services-config.js';
+
+ClassicEditor
+	.create( document.querySelector( '#snippet-image-resizeui-dropdown' ), {
+		removePlugins: [ 'LinkImage' ],
+		toolbar: {
+			viewportTopOffset: window.getViewportTopOffsetConfig()
+		},
+		image: {
+			resizeOptions: [
+				{
+					name: 'imageResize:original',
+					label: 'Original size',
+					value: null
+				},
+				{
+					name: 'imageResize:50',
+					label: '50%',
+					value: '50'
+				},
+				{
+					name: 'imageResize:75',
+					label: '75%',
+					value: '75'
+				}
+			],
+			toolbar: [ 'imageStyle:full', 'imageStyle:side', '|', 'imageResize' ]
+		},
+		cloudServices: CS_CONFIG
+	} )
+	.then( editor => {
+		window.editorResizeUIDropdown = editor;
+	} )
+	.catch( err => {
+		console.error( err );
+	} );

+ 63 - 0
packages/ckeditor5-image/docs/features/image.md

@@ -185,8 +185,71 @@ The [image styles](#image-styles) feature is meant to give the user the choice b
 
 It is implemented by the {@link module:image/imageresize~ImageResize} plugin and enables four "resize handles" displayed over the selected image. The user can freely resize the image by dragging them. The feature can be configured to use either percentage (default) or pixel values.
 
+The plugin also gives you an ability to change the size of the image through the image toolbar. You can set an optional static configuration with {@link module:image/image~ImageConfig#resizeOptions} and choose whether you want to use a dropdown or set of the standalone buttons.
+
+### Resize image using handles
+
 {@snippet features/image-resize}
 
+### Resize image using the plugin dropdown
+
+```js
+const imageConfiguration = {
+	resizeOptions: [
+		{
+			name: 'imageResize:original',
+			label: 'Original size',
+			value: null
+		},
+		{
+			name: 'imageResize:50',
+			label: '50%',
+			value: '50'
+		},
+		{
+			name: 'imageResize:75',
+			label: '75%',
+			value: '75'
+		}
+	],
+	toolbar: [ ... , 'imageResize' ]
+}
+```
+
+{@snippet features/image-resizeuidropdown}
+
+### Resize image using the standalone buttons
+
+```js
+const imageConfiguration = {
+	resizeOptions: [
+		{
+			name: 'imageResize:original',
+			label: 'Original size',
+			value: null
+		},
+		{
+			name: 'imageResize:50',
+			label: '50%',
+			value: '50'
+		},
+		{
+			name: 'imageResize:75',
+			label: '75%',
+			value: '75'
+		}
+	],
+	toolbar: [
+		// ...,
+		'imageResize:original',
+		'imageResize:50',
+		'imageResize:75'
+	]
+}
+```
+
+{@snippet features/image-resizeui}
+
 ### Enabling image resizing
 
 The image resize feature is not enabled by default in any of the editor builds. In order to enable it, you need to load the {@link module:image/imageresize~ImageResize} plugin. Read more in the [Installation](#installation) section.

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

@@ -10,5 +10,8 @@
 	"Enter image caption": "Placeholder text for image caption displayed when caption is empty.",
 	"Insert image": "Label for the insert image toolbar button.",
 	"Upload failed": "Title of the notification displayed when upload fails.",
-	"Image toolbar": "The label used by assistive technologies describing an image toolbar attached to an image widget."
+	"Image toolbar": "The label used by assistive technologies describing an image toolbar attached to an image widget.",
+	"Resize image to": "The label used for the standalone resize option buttons in the image toolbar",
+	"Resize image": "The label used for the dropdown in the image toolbar, containing defined resize options",
+	"Resize image to the original size": "The label used for the standalone resize reset option button in the image toolbar, when user set the value to `null`"
 }

+ 101 - 109
packages/ckeditor5-image/src/imageresize.js

@@ -8,8 +8,8 @@
  */
 
 import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
-import WidgetResize from '@ckeditor/ckeditor5-widget/src/widgetresize';
-import ImageResizeCommand from './imageresize/imageresizecommand';
+import ImageResizeUI from './imageresize/imageresizeui';
+import ImageResizeEditing from './imageresize/imageresizeediting';
 
 import '../theme/imageresize.css';
 
@@ -25,7 +25,7 @@ export default class ImageResize extends Plugin {
 	 * @inheritDoc
 	 */
 	static get requires() {
-		return [ WidgetResize ];
+		return [ ImageResizeEditing, ImageResizeUI ];
 	}
 
 	/**
@@ -34,112 +34,6 @@ export default class ImageResize extends Plugin {
 	static get pluginName() {
 		return 'ImageResize';
 	}
-
-	/**
-	 * @inheritDoc
-	 */
-	init() {
-		const editor = this.editor;
-		const command = new ImageResizeCommand( editor );
-
-		this._registerSchema();
-		this._registerConverters();
-
-		editor.commands.add( 'imageResize', command );
-
-		editor.editing.downcastDispatcher.on( 'insert:image', ( evt, data, conversionApi ) => {
-			const widget = conversionApi.mapper.toViewElement( data.item );
-
-			const resizer = editor.plugins
-				.get( WidgetResize )
-				.attachTo( {
-					unit: editor.config.get( 'image.resizeUnit' ) || '%',
-
-					modelElement: data.item,
-					viewElement: widget,
-					editor,
-
-					getHandleHost( domWidgetElement ) {
-						return domWidgetElement.querySelector( 'img' );
-					},
-					getResizeHost( domWidgetElement ) {
-						return domWidgetElement;
-					},
-					// TODO consider other positions.
-					isCentered() {
-						const imageStyle = data.item.getAttribute( 'imageStyle' );
-
-						return !imageStyle || imageStyle == 'full' || imageStyle == 'alignCenter';
-					},
-
-					onCommit( newValue ) {
-						editor.execute( 'imageResize', { width: newValue } );
-					}
-				} );
-
-			resizer.on( 'updateSize', () => {
-				if ( !widget.hasClass( 'image_resized' ) ) {
-					editor.editing.view.change( writer => {
-						writer.addClass( 'image_resized', widget );
-					} );
-				}
-			} );
-
-			resizer.bind( 'isEnabled' ).to( command );
-		}, { priority: 'low' } );
-	}
-
-	/**
-	 * @private
-	 */
-	_registerSchema() {
-		this.editor.model.schema.extend( 'image', {
-			allowAttributes: 'width'
-		} );
-	}
-
-	/**
-	 * Registers image resize converters.
-	 *
-	 * @private
-	 */
-	_registerConverters() {
-		const editor = this.editor;
-
-		// Dedicated converter to propagate image's attribute to the img tag.
-		editor.conversion.for( 'downcast' ).add( dispatcher =>
-			dispatcher.on( 'attribute:width:image', ( evt, data, conversionApi ) => {
-				if ( !conversionApi.consumable.consume( data.item, evt.name ) ) {
-					return;
-				}
-
-				const viewWriter = conversionApi.writer;
-				const figure = conversionApi.mapper.toViewElement( data.item );
-
-				if ( data.attributeNewValue !== null ) {
-					viewWriter.setStyle( 'width', data.attributeNewValue, figure );
-					viewWriter.addClass( 'image_resized', figure );
-				} else {
-					viewWriter.removeStyle( 'width', figure );
-					viewWriter.removeClass( 'image_resized', figure );
-				}
-			} )
-		);
-
-		editor.conversion.for( 'upcast' )
-			.attributeToAttribute( {
-				view: {
-					name: 'figure',
-					styles: {
-						width: /.+/
-					}
-				},
-				model: {
-					key: 'width',
-					value: viewElement => viewElement.getStyle( 'width' )
-				}
-			} );
-	}
 }
 
 /**
@@ -162,3 +56,101 @@ export default class ImageResize extends Plugin {
  * @default '%'
  * @member {String} module:image/image~ImageConfig#resizeUnit
  */
+
+/**
+ * The resize options.
+ *
+ * Each option should have its `name`, which is a component name definition that will be
+ * used in the {@link module:image/imageresize/imageresizeui~ImageResizeUI} plugin.
+ * Other properties like `label` and `value` define the following:
+ * a text label for the option button and the value that will be applied to the image's width.
+ *
+ * The value property is combined with the `resizeUnit` (`%` by default), eg: `value: '50'` and `resizeUnit: '%'` is `50%`.
+ *
+ * **NOTE:** If you want to set an option that will reset image to its original size, you need to pass a `null` value
+ * to one of the options. The `:original` token is not mandatory, you can call it anything you wish, but it must reflect
+ * in the standalone buttons configuration for the image toolbar.
+ *
+ *		ClassicEditor
+ *			.create( editorElement, {
+ *				image: {
+ *					resizeUnit: "%",
+ *					resizeOptions: [ {
+ *						name: 'imageResize:original',
+ *						label: 'Original size',
+ *						value: null
+ *					},
+ *					{
+ *						name: 'imageResize:50',
+ *						label: '50%',
+ *						value: '50'
+ *					},
+ *					{
+ *						name: 'imageResize:75',
+ *						label: '75%',
+ *						value: '75'
+ *					} ]
+ *				}
+ *			} )
+ *			.then( ... )
+ *			.catch( ... );
+ *
+ * With resize options defined, you can decide whether you want to display them as a dropdown or as standalone buttons.
+ * For the dropdown, you need to pass only the `imageResize` token to the `image.toolbar`.
+ * The dropdown contains all defined options by default:
+ *
+ *			ClassicEditor
+ *			.create( editorElement, {
+ *				image: {
+ *					resizeUnit: "%",
+ *					resizeOptions: [ {
+ *						name: 'imageResize:original',
+ *						label: 'Original size',
+ *						value: null
+ *					},
+ *					{
+ *						name: 'imageResize:50',
+ *						label: '50%',
+ *						value: '50'
+ *					},
+ *					{
+ *						name: 'imageResize:75',
+ *						label: '75%',
+ *						value: '75'
+ *					} ],
+ *					toolbar: [ 'imageResize', ... ],
+ *				}
+ *			} )
+ *			.then( ... )
+ *			.catch( ... );
+ *
+ * If you want to have separate buttons for each option, pass their names instead:
+ *
+ *			ClassicEditor
+ *			.create( editorElement, {
+ *				image: {
+ *					resizeUnit: "%",
+ *					resizeOptions: [ {
+ *						name: 'imageResize:original',
+ *						label: 'Original size',
+ *						value: null
+ *					},
+ *					{
+ *						name: 'imageResize:50',
+ *						label: '50%',
+ *						value: '50'
+ *					},
+ *					{
+ *						name: 'imageResize:75',
+ *						label: '75%',
+ *						value: '75'
+ *					} ],
+ *					toolbar: [ 'imageResize:original', 'imageResize:50', 'imageResize:75', ... ],
+ *				}
+ *			} )
+ *			.then( ... )
+ *			.catch( ... );
+ *
+ *
+ * @member {Array.<module:image/imageresize/imageresizeui~ImageResizeOption>} module:image/image~ImageConfig#resizeOptions
+ */

+ 10 - 3
packages/ckeditor5-image/src/imageresize/imageresizecommand.js

@@ -51,8 +51,15 @@ export default class ImageResizeCommand extends Command {
 		const model = this.editor.model;
 		const imageElement = model.document.selection.getSelectedElement();
 
-		model.change( writer => {
-			writer.setAttribute( 'width', options.width, imageElement );
-		} );
+		this.value = {
+			width: options.width,
+			height: null
+		};
+
+		if ( imageElement ) {
+			model.change( writer => {
+				writer.setAttribute( 'width', options.width, imageElement );
+			} );
+		}
 	}
 }

+ 142 - 0
packages/ckeditor5-image/src/imageresize/imageresizeediting.js

@@ -0,0 +1,142 @@
+/**
+ * @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/imageresizeediting
+ */
+
+import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
+import WidgetResize from '@ckeditor/ckeditor5-widget/src/widgetresize';
+import ImageResizeCommand from './imageresizecommand';
+
+/**
+ * The image resize feature.
+ *
+ * It adds a possibility to resize each image using handles or manually by
+ * {@link module:image/imageresize/imageresizeui~ImageResizeUI} buttons.
+ *
+ * @extends module:core/plugin~Plugin
+ */
+export default class ImageResizeEditing extends Plugin {
+	/**
+	 * @inheritDoc
+	 */
+	static get requires() {
+		return [ WidgetResize ];
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	static get pluginName() {
+		return 'ImageResizeEditing';
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	init() {
+		const editor = this.editor;
+		const command = new ImageResizeCommand( editor );
+
+		this._registerSchema();
+		this._registerConverters();
+
+		editor.commands.add( 'imageResize', command );
+
+		editor.editing.downcastDispatcher.on( 'insert:image', ( evt, data, conversionApi ) => {
+			const widget = conversionApi.mapper.toViewElement( data.item );
+
+			const resizer = editor.plugins
+				.get( WidgetResize )
+				.attachTo( {
+					unit: editor.config.get( 'image.resizeUnit' ) || '%',
+
+					modelElement: data.item,
+					viewElement: widget,
+					editor,
+
+					getHandleHost( domWidgetElement ) {
+						return domWidgetElement.querySelector( 'img' );
+					},
+					getResizeHost( domWidgetElement ) {
+						return domWidgetElement;
+					},
+					// TODO consider other positions.
+					isCentered() {
+						const imageStyle = data.item.getAttribute( 'imageStyle' );
+
+						return !imageStyle || imageStyle == 'full' || imageStyle == 'alignCenter';
+					},
+
+					onCommit( newValue ) {
+						editor.execute( 'imageResize', { width: newValue } );
+					}
+				} );
+
+			resizer.on( 'updateSize', () => {
+				if ( !widget.hasClass( 'image_resized' ) ) {
+					editor.editing.view.change( writer => {
+						writer.addClass( 'image_resized', widget );
+					} );
+				}
+			} );
+
+			resizer.bind( 'isEnabled' ).to( command );
+		}, { priority: 'low' } );
+	}
+
+	/**
+	 * @private
+	 */
+	_registerSchema() {
+		this.editor.model.schema.extend( 'image', {
+			allowAttributes: 'width'
+		} );
+	}
+
+	/**
+	 * Registers image resize converters.
+	 *
+	 * @private
+	 */
+	_registerConverters() {
+		const editor = this.editor;
+
+		// Dedicated converter to propagate image's attribute to the img tag.
+		editor.conversion.for( 'downcast' ).add( dispatcher =>
+			dispatcher.on( 'attribute:width:image', ( evt, data, conversionApi ) => {
+				if ( !conversionApi.consumable.consume( data.item, evt.name ) ) {
+					return;
+				}
+
+				const viewWriter = conversionApi.writer;
+				const figure = conversionApi.mapper.toViewElement( data.item );
+
+				if ( data.attributeNewValue !== null ) {
+					viewWriter.setStyle( 'width', data.attributeNewValue, figure );
+					viewWriter.addClass( 'image_resized', figure );
+				} else {
+					viewWriter.removeStyle( 'width', figure );
+					viewWriter.removeClass( 'image_resized', figure );
+				}
+			} )
+		);
+
+		editor.conversion.for( 'upcast' )
+			.attributeToAttribute( {
+				view: {
+					name: 'figure',
+					styles: {
+						width: /.+/
+					}
+				},
+				model: {
+					key: 'width',
+					value: viewElement => viewElement.getStyle( 'width' )
+				}
+			} );
+	}
+}

+ 199 - 0
packages/ckeditor5-image/src/imageresize/imageresizeui.js

@@ -0,0 +1,199 @@
+/**
+ * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+/**
+ * @module image/imageresize/imageresizeui
+ */
+
+import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
+import ButtonView from '@ckeditor/ckeditor5-ui/src/button/buttonview';
+import ImageResizeEditing from './imageresizeediting';
+import { createDropdown, addListToDropdown } from '@ckeditor/ckeditor5-ui/src/dropdown/utils';
+import DropdownButtonView from '@ckeditor/ckeditor5-ui/src/dropdown/button/dropdownbuttonview';
+
+import Model from '@ckeditor/ckeditor5-ui/src/model';
+import Collection from '@ckeditor/ckeditor5-utils/src/collection';
+
+/**
+ * The `ImageResizeUI` plugin.
+ *
+ * It adds a possibility to resize each image using toolbar dropdown or separate buttons, depends on the plugin configuration.
+ *
+ * @extends module:core/plugin~Plugin
+ */
+export default class ImageResizeUI extends Plugin {
+	/**
+	 * @inheritDoc
+	 */
+	static get requires() {
+		return [ ImageResizeEditing ];
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	static get pluginName() {
+		return 'ImageResizeUI';
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	init() {
+		const editor = this.editor;
+		const options = editor.config.get( 'image.resizeOptions' );
+		const command = editor.commands.get( 'imageResize' );
+		const resizeUnit = editor.config.get( 'image.resizeUnit' ) || '%';
+
+		if ( !options ) {
+			return;
+		}
+
+		this.bind( 'isEnabled' ).to( command );
+
+		for ( const option of options ) {
+			this._addButton( option, resizeUnit );
+		}
+
+		this._addDropdown( options, resizeUnit );
+	}
+
+	/**
+	 * A helper function that creates a standalone button component for the plugin.
+	 *
+	 * @private
+	 *
+	 * @param {module:image/imageresize/imageresizeui~ImageResizeOption} resizeOption A model of resize option.
+	 * @param {String} unit A resize unit.
+	 */
+	_addButton( { name, label, value }, unit ) {
+		const editor = this.editor;
+		const t = editor.t;
+		const parsedValue = value ? value + unit : null;
+
+		editor.ui.componentFactory.add( name, locale => {
+			const button = new ButtonView( locale );
+			const command = editor.commands.get( 'imageResize' );
+			const commandCallback = setOptionOn( parsedValue );
+
+			button.set( {
+				label: t( label ),
+				withText: true,
+				tooltip: parsedValue ? t( 'Resize image to' ) + ' ' + parsedValue : t( 'Resize image to the original size' ),
+				isToggleable: true,
+				commandValue: parsedValue
+			} );
+
+			// Bind button to the command.
+			button.bind( 'isEnabled' ).to( this );
+			button.bind( 'isOn' ).to( command, 'value', commandCallback );
+
+			this.listenTo( button, 'execute', evt => {
+				editor.execute( 'imageResize', { width: evt.source.commandValue } );
+			} );
+
+			return button;
+		} );
+	}
+
+	/**
+	 * A helper function that creates a dropdown component for the plugin containing all resize options created through the
+	 * plugin configuration settings.
+	 *
+	 * @private
+	 *
+	 * @param {Array.<module:image/imageresize/imageresizeui~ImageResizeOption>} options An array of the configured options.
+	 * @param {String} unit A resize unit.
+	 */
+	_addDropdown( options, unit ) {
+		const editor = this.editor;
+		const t = editor.t;
+		const firstOption = options[ 0 ];
+		const resetOption = options.find( option => option.value === null );
+
+		// Register dropdown.
+		editor.ui.componentFactory.add( 'imageResize', locale => {
+			const command = editor.commands.get( 'imageResize' );
+			const dropdownView = createDropdown( locale, DropdownButtonView );
+			const dropdownButton = dropdownView.buttonView;
+
+			dropdownButton.set( {
+				tooltip: t( 'Resize image' ),
+				commandValue: firstOption.value,
+				isToggleable: true,
+				label: firstOption.label,
+				withText: true
+			} );
+
+			dropdownButton.bind( 'label' ).to( command, 'value', commandValue => {
+				return commandValue && commandValue.width || resetOption.label;
+			} );
+			dropdownView.bind( 'isOn' ).to( command );
+			dropdownView.bind( 'isEnabled' ).to( this );
+
+			addListToDropdown( dropdownView, prepareListDefinitions( options, command, unit ) );
+
+			dropdownView.listView.ariaLabel = t( 'Image resize list' );
+
+			// Execute command when an item from the dropdown is selected.
+			this.listenTo( dropdownView, 'execute', evt => {
+				editor.execute( evt.source.commandName, { width: evt.source.commandValue } );
+				editor.editing.view.focus();
+			} );
+
+			return dropdownView;
+		} );
+	}
+}
+
+// A helper function for parsing resize options definitions.
+function prepareListDefinitions( definitions, command, resizeUnit ) {
+	const itemDefinitions = new Collection();
+
+	definitions.map( itemDefinition => {
+		const parsedValue = itemDefinition.value ? itemDefinition.value + resizeUnit : null;
+		const definition = {
+			type: 'button',
+			model: new Model( {
+				commandName: 'imageResize',
+				commandValue: parsedValue,
+				label: itemDefinition.label,
+				withText: true,
+				icon: null
+			} )
+		};
+
+		const commandCallback = setOptionOn( parsedValue );
+
+		definition.model.bind( 'isOn' ).to( command, 'value', commandCallback );
+
+		itemDefinitions.add( definition );
+	} );
+
+	return itemDefinitions;
+}
+
+// A helper function for setting the `isOn` state used for creating a callback function in a value binding.
+function setOptionOn( value ) {
+	return commandValue => {
+		// Set reseting option on when command equals `null`.
+		if ( commandValue === value ) {
+			return true;
+		}
+
+		return commandValue && commandValue.width === value;
+	};
+}
+
+/**
+ * A resize option type.
+ *
+ * @typedef {Object} module:image/imageresize/imageresizeui~ImageResizeOption
+ *
+ * @property {String} resizeOption.name A name of the option used for creating a component.
+ * You refer to that name later in the {@link module:image/image~ImageConfig#toolbar}.
+ * @property {String} resizeOption.label A label to be displayed with a button.
+ * @property {String} resizeOption.value A value of a resize option. `null` value is for resetting an image to its original size.
+ */

+ 18 - 0
packages/ckeditor5-image/tests/imageresize/imageresize.js

@@ -0,0 +1,18 @@
+/**
+ * @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 ImageResize from '../../src/imageresize';
+import ImageResizeUI from '../../src/imageresize/imageresizeui';
+import ImageResizeEditing from '../../src/imageresize/imageresizeediting';
+
+describe( 'ImageResize', () => {
+	it( 'should require "ImageResizeEditing" and "ImageResizeUI"', () => {
+		expect( ImageResize.requires ).to.deep.equal( [ ImageResizeEditing, ImageResizeUI ] );
+	} );
+
+	it( 'should be named', () => {
+		expect( ImageResize.pluginName ).to.equal( 'ImageResize' );
+	} );
+} );

+ 14 - 10
packages/ckeditor5-image/tests/imageresize.js → packages/ckeditor5-image/tests/imageresize/imageresizeediting.js

@@ -9,12 +9,12 @@
 import ClassicEditor from '@ckeditor/ckeditor5-editor-classic/src/classiceditor';
 
 import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
-import Image from '../src/image';
-import ImageResize from '../src/imageresize';
-import ImageResizeCommand from '../src/imageresize/imageresizecommand';
-import ImageStyle from '../src/imagestyle';
-import ImageToolbar from '../src/imagetoolbar';
-import ImageTextAlternative from '../src/imagetextalternative';
+import Image from '../../src/image';
+import ImageResizeEditing from '../../src/imageresize/imageresizeediting';
+import ImageResizeCommand from '../../src/imageresize/imageresizecommand';
+import ImageStyle from '../../src/imagestyle';
+import ImageToolbar from '../../src/imagetoolbar';
+import ImageTextAlternative from '../../src/imagetextalternative';
 import Undo from '@ckeditor/ckeditor5-undo/src/undo';
 import Table from '@ckeditor/ckeditor5-table/src/table';
 
@@ -30,7 +30,7 @@ import {
 
 import WidgetResize from '@ckeditor/ckeditor5-widget/src/widgetresize';
 
-describe( 'ImageResize', () => {
+describe( 'ImageResizeEditing', () => {
 	// 100x50 black png image
 	const IMAGE_SRC_FIXTURE = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAGQAAAAyCAQAAAAAPLY1AAAAQklEQVR42u3PQREAAAgDoK1/' +
 		'aM3g14MGNJMXKiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiJysRFNMgH0RpujAAAAAElFTkSuQmCC';
@@ -63,6 +63,10 @@ describe( 'ImageResize', () => {
 		}
 	} );
 
+	it( 'should be named', () => {
+		expect( ImageResizeEditing.pluginName ).to.equal( 'ImageResizeEditing' );
+	} );
+
 	describe( 'conversion', () => {
 		beforeEach( () => createEditor() );
 
@@ -195,7 +199,7 @@ describe( 'ImageResize', () => {
 
 	it( 'uses percents by default', async () => {
 		const localEditor = await createEditor( {
-			plugins: [ Image, ImageStyle, Paragraph, Undo, Table, ImageResize ]
+			plugins: [ Image, ImageStyle, Paragraph, Undo, Table, ImageResizeEditing ]
 		} );
 
 		const attachToSpy = sinon.spy( localEditor.plugins.get( WidgetResize ), 'attachTo' );
@@ -402,7 +406,7 @@ describe( 'ImageResize', () => {
 
 		beforeEach( async () => {
 			await createEditor( {
-				plugins: [ Image, ImageStyle, Paragraph, Undo, Table, ImageResize, ImageToolbar, ImageTextAlternative ],
+				plugins: [ Image, ImageStyle, Paragraph, Undo, Table, ImageResizeEditing, ImageToolbar, ImageTextAlternative ],
 				image: {
 					toolbar: [ 'imageTextAlternative' ],
 					resizeUnit: 'px'
@@ -481,7 +485,7 @@ describe( 'ImageResize', () => {
 
 		return ClassicEditor
 			.create( editorElement, config || {
-				plugins: [ Image, ImageStyle, Paragraph, Undo, Table, ImageResize ],
+				plugins: [ Image, ImageStyle, Paragraph, Undo, Table, ImageResizeEditing ],
 				image: {
 					resizeUnit: 'px'
 				}

+ 255 - 0
packages/ckeditor5-image/tests/imageresize/imageresizeui.js

@@ -0,0 +1,255 @@
+/**
+ * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+/* global document */
+
+import ClassicTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/classictesteditor';
+import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
+import Image from '../../src/image';
+import DropdownView from '@ckeditor/ckeditor5-ui/src/dropdown/dropdownview';
+import ButtonView from '@ckeditor/ckeditor5-ui/src/button/buttonview';
+import View from '@ckeditor/ckeditor5-ui/src/view';
+import ImageResizeUI from '../../src/imageresize/imageresizeui';
+import ImageStyle from '../../src/imagestyle';
+import Undo from '@ckeditor/ckeditor5-undo/src/undo';
+import Table from '@ckeditor/ckeditor5-table/src/table';
+
+import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils';
+
+describe( 'ImageResizeUI', () => {
+	let plugin, command, editor, editorElement;
+
+	const resizeOptions = [ {
+		name: 'imageResize:original',
+		label: 'Original size',
+		value: null
+	},
+	{
+		name: 'imageResize:50',
+		label: '50%',
+		value: '50'
+	},
+	{
+		name: 'imageResize:75',
+		label: '75%',
+		value: '75'
+	} ];
+
+	testUtils.createSinonSandbox();
+
+	beforeEach( async () => {
+		editorElement = document.createElement( 'div' );
+		document.body.appendChild( editorElement );
+
+		editor = await ClassicTestEditor
+			.create( editorElement, {
+				plugins: [ Image, ImageStyle, Paragraph, Undo, Table, ImageResizeUI ],
+				image: {
+					resizeUnit: '%',
+					resizeOptions
+				}
+			} );
+
+		command = editor.commands.get( 'imageResize' );
+		plugin = editor.plugins.get( 'ImageResizeUI' );
+	} );
+
+	afterEach( async () => {
+		if ( editorElement ) {
+			editorElement.remove();
+		}
+
+		if ( editor ) {
+			await editor.destroy();
+		}
+	} );
+
+	describe( 'plugin', () => {
+		it( 'should be named', () => {
+			expect( ImageResizeUI.pluginName ).to.equal( 'ImageResizeUI' );
+		} );
+
+		it( 'should be disabled when command is disabled', () => {
+			command.isEnabled = true;
+
+			expect( plugin.isEnabled ).to.be.true;
+
+			command.isEnabled = false;
+
+			expect( plugin.isEnabled ).to.be.false;
+		} );
+	} );
+
+	describe( 'init()', () => {
+		it( 'should have set "%" resize unit', () => {
+			const unit = editor.config.get( 'image.resizeUnit' );
+
+			expect( unit ).to.equal( '%' );
+		} );
+
+		it( 'should have set "%" resize unit if not defined', async () => {
+			const editor = await ClassicTestEditor
+				.create( editorElement, {
+					plugins: [ Image, ImageStyle, Paragraph, Undo, Table, ImageResizeUI ],
+					image: {
+						resizeOptions
+					}
+				} );
+
+			const button = editor.ui.componentFactory.create( 'imageResize:50' );
+			const command = editor.commands.get( 'imageResize' );
+
+			command.isEnabled = true;
+
+			button.fire( 'execute' );
+
+			expect( command.value.width.includes( '%' ) ).to.be.true;
+
+			await editor.destroy();
+		} );
+
+		it( 'should have set "px" resize unit', async () => {
+			const editor = await ClassicTestEditor
+				.create( editorElement, {
+					plugins: [ Image, ImageStyle, Paragraph, Undo, Table, ImageResizeUI ],
+					image: {
+						resizeUnit: 'px',
+						resizeOptions
+					}
+				} );
+
+			const button = editor.ui.componentFactory.create( 'imageResize:50' );
+			const command = editor.commands.get( 'imageResize' );
+
+			command.isEnabled = true;
+
+			button.fire( 'execute' );
+
+			expect( command.value.width.includes( 'px' ) ).to.be.true;
+
+			await editor.destroy();
+		} );
+
+		it( 'should not register a dropdown or buttons if no resize options passed', async () => {
+			const editor = await ClassicTestEditor
+				.create( editorElement, {
+					plugins: [ Image, ImageStyle, Paragraph, Undo, Table, ImageResizeUI ],
+					image: {
+						resizeUnit: 'px'
+					}
+				} );
+
+			const resizeOptions = editor.config.get( 'image.resizeOptions' );
+
+			expect( resizeOptions ).to.be.undefined;
+			expect( editor.ui.componentFactory.has( 'imageResize' ) ).to.be.false;
+
+			await editor.destroy();
+		} );
+	} );
+
+	describe( 'resize options dropdown', () => {
+		it( 'should be disabled when plugin is disabled', () => {
+			const dropdownView = editor.ui.componentFactory.create( 'imageResize' );
+
+			plugin.isEnabled = true;
+
+			expect( dropdownView.isEnabled ).to.be.true;
+
+			plugin.isEnabled = false;
+
+			expect( dropdownView.isEnabled ).to.be.false;
+		} );
+
+		it( 'should be an instance of `DropdownView` if component is created without a value suffix', () => {
+			expect( editor.ui.componentFactory.create( 'imageResize' ) ).to.be.instanceof( DropdownView );
+		} );
+
+		it( 'should have 3 resize options in the `imageResize` dropdown', () => {
+			const dropdownView = editor.ui.componentFactory.create( 'imageResize' );
+
+			expect( dropdownView.listView.items.length ).to.equal( 3 );
+			expect( dropdownView.listView.items.first.element.textContent ).to.equal( 'Original size' );
+			expect( dropdownView.listView.items._items[ 1 ].element.textContent ).to.equal( '50%' );
+			expect( dropdownView.listView.items.last.element.textContent ).to.equal( '75%' );
+		} );
+
+		it( 'should be created with a proper tooltip', () => {
+			const dropdownView = editor.ui.componentFactory.create( 'imageResize' );
+
+			expect( dropdownView.buttonView.tooltip ).to.equal( 'Resize image' );
+		} );
+
+		it( 'should execute resize command with a proper value', () => {
+			const dropdownView = editor.ui.componentFactory.create( 'imageResize' );
+			const commandSpy = sinon.spy( command, 'execute' );
+			const resizeBy50Percent = dropdownView.listView.items._items[ 1 ].children._items[ 0 ];
+
+			command.isEnabled = true;
+
+			resizeBy50Percent.fire( 'execute' );
+
+			sinon.assert.calledOnce( commandSpy );
+			expect( command.value.width ).to.equal( '50%' );
+		} );
+	} );
+
+	describe( 'resize option button', () => {
+		it( 'should be bound to `#isEnabled`', () => {
+			const buttonView = editor.ui.componentFactory.create( 'imageResize:50' );
+
+			plugin.isEnabled = true;
+
+			expect( buttonView.isEnabled ).to.be.true;
+
+			plugin.isEnabled = false;
+
+			expect( buttonView.isEnabled ).to.be.false;
+		} );
+
+		it( 'should be an instance of `ButtonView` if component is created with a value suffix', () => {
+			expect( editor.ui.componentFactory.create( 'imageResize:50' ) ).to.be.instanceof( ButtonView );
+		} );
+
+		it( 'should be created with visible "50%" label', () => {
+			const buttonView = editor.ui.componentFactory.create( 'imageResize:50' );
+			buttonView.render();
+
+			expect( buttonView.withText ).to.be.true;
+			expect( buttonView.label ).to.equal( '50%' );
+			expect( buttonView.labelView ).to.be.instanceOf( View );
+		} );
+
+		it( 'should be created with a proper tooltip, depends on the set value', () => {
+			const buttonViewOriginal = editor.ui.componentFactory.create( 'imageResize:original' );
+			const buttonView50 = editor.ui.componentFactory.create( 'imageResize:50' );
+
+			buttonViewOriginal.render();
+			buttonView50.render();
+
+			expect( buttonViewOriginal.tooltip ).to.equal( 'Resize image to the original size' );
+			expect( buttonView50.tooltip ).to.equal( 'Resize image to 50%' );
+		} );
+
+		it( 'should have `commandValue` equal "50%"', () => {
+			const buttonView = editor.ui.componentFactory.create( 'imageResize:50' );
+
+			expect( buttonView.commandValue ).to.equal( '50%' );
+		} );
+
+		it( 'should execute `imageResize` command with "50%" value', () => {
+			const buttonView = editor.ui.componentFactory.create( 'imageResize:50' );
+			const command = editor.commands.get( 'imageResize' );
+			const commandSpy = sinon.spy( command, 'execute' );
+
+			command.isEnabled = true;
+
+			buttonView.fire( 'execute' );
+
+			sinon.assert.calledOnce( commandSpy );
+			expect( command.value.width ).to.equal( '50%' );
+		} );
+	} );
+} );

+ 37 - 0
packages/ckeditor5-image/tests/manual/imageresizeui.html

@@ -0,0 +1,37 @@
+<style>
+	.fancy-editor-wrapper figure {
+		padding: 20px;
+	}
+
+	.ck-editor {
+		max-width: 800px;
+	}
+
+	h2.header {
+		background: hsl(0, 0%, 90%);
+	}
+</style>
+
+<h2 class="header">Dropdown</h2>
+
+<div id="editor1">
+	<h2>Responsive image (width style: not set, width attribute present on the image)</h2>
+
+	<figure class="image">
+		<img alt="Picture of the Warsaw Old Town." src="sample.jpg">
+	</figure>
+
+	<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nulla finibus consequat placerat. Vestibulum id telluset mauris sagittis tincidunt quis id mauris. Curabitur consectetur lectus sit amet tellus mattis, non lobortisleo interdum. </p>
+</div>
+
+<h2 class="header">Standalone buttons</h2>
+
+<div id="editor2">
+	<h2>Responsive image (width style: not set, width attribute present on the image)</h2>
+
+	<figure class="image">
+		<img alt="Picture of the Warsaw Old Town." src="sample.jpg">
+	</figure>
+
+	<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nulla finibus consequat placerat. Vestibulum id telluset mauris sagittis tincidunt quis id mauris. Curabitur consectetur lectus sit amet tellus mattis, non lobortisleo interdum. </p>
+</div>

+ 121 - 0
packages/ckeditor5-image/tests/manual/imageresizeui.js

@@ -0,0 +1,121 @@
+/**
+ * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+/* global document, console, window */
+
+import ClassicEditor from '@ckeditor/ckeditor5-editor-classic/src/classiceditor';
+import ArticlePluginSet from '@ckeditor/ckeditor5-core/tests/_utils/articlepluginset';
+import ImageResize from '../../src/imageresize';
+import Indent from '@ckeditor/ckeditor5-indent/src/indent';
+import IndentBlock from '@ckeditor/ckeditor5-indent/src/indentblock';
+import EasyImage from '@ckeditor/ckeditor5-easy-image/src/easyimage';
+
+import { CS_CONFIG } from '@ckeditor/ckeditor5-cloud-services/tests/_utils/cloud-services-config';
+
+const commonConfig = {
+	plugins: [
+		ArticlePluginSet,
+		ImageResize,
+		Indent,
+		IndentBlock,
+		EasyImage
+	],
+	toolbar: [ 'heading', '|', 'bold', 'italic', 'link',
+		'bulletedList', 'numberedList', 'blockQuote', 'insertTable', 'undo', 'redo', 'outdent', 'indent' ],
+	table: {
+		contentToolbar: [ 'tableColumn', 'tableRow', 'mergeTableCells' ],
+		tableToolbar: [ 'bold', 'italic' ]
+	},
+	cloudServices: CS_CONFIG
+};
+
+const imageConfig1 = {
+	resizeUnit: '%',
+	resizeOptions: [
+		{
+			name: 'imageResize:original',
+			label: 'Original size',
+			value: null
+		},
+		{
+			name: 'imageResize:50',
+			label: '50%',
+			value: '50'
+		},
+		{
+			name: 'imageResize:75',
+			label: '75%',
+			value: '75'
+		}
+	],
+	toolbar: [ 'imageStyle:alignLeft', 'imageStyle:full', 'imageStyle:side', '|', 'imageResize' ],
+	styles: [
+		'full',
+		'alignLeft',
+		'side' // Purposely using side image instead right aligned image to make sure it works well with both style types.
+	]
+};
+
+const config1 = {
+	...commonConfig,
+	image: imageConfig1
+};
+
+ClassicEditor
+	.create( document.querySelector( '#editor1' ), config1 )
+	.then( editor => {
+		window.editor = editor;
+	} )
+	.catch( err => {
+		console.error( err.stack );
+	} );
+
+const imageConfig2 = {
+	resizeUnit: '%',
+	resizeOptions: [
+		{
+			name: 'imageResize:original',
+			label: 'Original size',
+			value: null
+		},
+		{
+			name: 'imageResize:50',
+			label: '50%',
+			value: '50'
+		},
+		{
+			name: 'imageResize:75',
+			label: '75%',
+			value: '75'
+		}
+	],
+	toolbar: [
+		'imageStyle:alignLeft',
+		'imageStyle:full',
+		'imageStyle:side', '|',
+		'imageResize:original',
+		'imageResize:50',
+		'imageResize:75'
+	],
+	styles: [
+		'full',
+		'alignLeft',
+		'side' // Purposely using side image instead right aligned image to make sure it works well with both style types.
+	]
+};
+
+const config2 = {
+	...commonConfig,
+	image: imageConfig2
+};
+
+ClassicEditor
+	.create( document.querySelector( '#editor2' ), config2 )
+	.then( editor => {
+		window.editor = editor;
+	} )
+	.catch( err => {
+		console.error( err.stack );
+	} );

+ 11 - 0
packages/ckeditor5-image/tests/manual/imageresizeui.md

@@ -0,0 +1,11 @@
+## Image Resize UI
+
+The tests for manual image resizing.
+- The first test should have the dropdown with configured options in the image toolbar (using `imageResize`).
+	- Plugin icon should appear only in the dropbdown button.
+	- Each option should have a label text represented an option value defined in the plugin configuration.
+	- Selected options should be set "on" when dropdown is open.
+- The second one should have the standalone buttons instead of dropdown (from the first test) in the image toolbar (using
+`imageResize:option`).
+	- Each option should have the plugin icon "stretched" over the label text which represents an option value defined in the plugin configuration.
+	- Selected option should be set "on".